ghost multiplayer: MTU-safe chunked pose transport + pose smoothing

Phase 3: two robustness fixes for full-model ghosts.

- Wire: a full skeleton pose is ~3.1 KB, above the 1500-byte path MTU, so it
  now travels as up to 3 MTU-safe kTypePoseChunk datagrams (<=1.2 KB each,
  ~1.2 KB total with headers). The relay forwards each chunk verbatim
  (re-stamping only the chunk header session id), so its code path is
  shared with the legacy single-datagram POSE and needs no pose-body
  parsing. GhostPoseBuildChunks / GhostPoseReassembler (inline in the proto
  header, standalone-testable) tolerate out-of-order and duplicate chunks,
  drop incomplete sequences (the next frame resends), discard a partial on
  a newer seq, and special-case a header-only pose as a single chunk.
  Receive identity comes from the chunk header (the relay re-stamps it),
  not the blob, which the relay never touches.

- Smoothing: each remote ghost now holds a target skeleton (latest received
  pose) and a display skeleton chased per render query with exponential
  catch-up (time constant ~33 ms), so the 15 Hz bone stream renders as
  smooth motion instead of stepping. New/reshaped skeletons snap instead of
  blending between layouts.

Reassembly state is per-session on the network thread, bounded (64) and
cleared on prune/disable. Protocol version bumped to 2.
This commit is contained in:
MiTHRAL 2026-08-26 20:46:08 -04:00
parent 7b949a312b
commit cceeac7c4f
3 changed files with 217 additions and 40 deletions

View file

@ -183,9 +183,16 @@ double g_local_pose_time = 0.0;
// Remote ghosts, keyed by the sender's session id.
struct Ghost {
uint32_t session_id = 0;
float world[16] = {};
uint16_t bone_count = 0; // live skeleton rows (0 = no pose)
float bones[kGhostMaxBones * 12] = {};
float world[16] = {}; // latest received entity world (cube fallback)
// Target skeleton: the latest complete pose received off the wire.
uint16_t target_count = 0;
float target_bones[kGhostMaxBones * 12] = {};
// Display skeleton: target chased per render query (exponential smoothing),
// so 15 Hz bone updates render as smooth motion instead of stepping.
uint16_t display_count = 0;
float display_bones[kGhostMaxBones * 12] = {};
double last_display = 0.0;
bool display_snapped = false;
double last_seen = 0.0;
};
std::vector<Ghost> g_ghosts;
@ -226,6 +233,10 @@ bool g_lan_probing = false;
double g_lan_probe_since = 0.0;
double g_last_ping = 0.0;
// Chunk reassembly state per remote session (network thread only; bounded,
// ResetSession clears it).
std::unordered_map<uint32_t, GhostPoseReassembler> g_pending_poses;
// Pose pacing.
double g_pose_acc = 0.0;
uint32_t g_seq = 0;
@ -288,16 +299,36 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) {
}
pp.bone_count = pose_rows;
pp.flags = pose_rows != 0 ? kPoseFlagsBones : 0;
PacketBuf p;
BuildPacket(p, kTypePose, &pp, sizeof(pp));
GhostSockSendTo(sock, to, &p, PacketSize(p));
// MTU-safe chunks (the full pose is ~3 KB and must not IP-fragment).
PacketBuf chunks[kGhostMaxChunks];
const uint8_t n = GhostPoseBuildChunks(0, pp.seq, pp, chunks);
for (uint8_t i = 0; i < n; ++i) {
GhostSockSendTo(sock, to, &chunks[i], PacketSize(chunks[i]));
}
}
// ---- packet receive --------------------------------------------------------
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
if (p.h.type == kTypePose) {
PosePayload pp = {};
std::memcpy(&pp, p.payload, sizeof(pp));
if (p.h.type == kTypePoseChunk) {
PoseChunkPayload cp = {};
std::memcpy(&cp, p.payload, sizeof(cp));
if (cp.session_id == 0 || cp.chunks == 0 || cp.chunks > kGhostMaxChunks) {
return;
}
// Reassemble this session's pose stream (network thread only). The
// sender identity is the CHUNK HEADER's session id: the relay re-stamps
// that (offset 0 of the payload), while the blob's own session field is
// a sender-side zero the relay never touches.
const uint32_t sender = cp.session_id;
if (g_pending_poses.size() > 64) {
g_pending_poses.clear(); // churn guard: partials re-send next frame
}
PosePayload pose = {};
GhostPoseReassembler& r = g_pending_poses[sender];
if (!r.Push(cp.seq, cp.blob_size, cp.chunk, cp.chunks, cp.data,
cp.data_size, &pose)) {
return;
}
{
std::lock_guard<std::mutex> lock(g_mu);
// Determine which relay this came from and skip our own pose echoes
@ -310,42 +341,59 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
const uint32_t own = from_public ? g_session_public
: from_lan ? g_session_lan
: 0;
if (pp.session_id == 0 || (own != 0 && pp.session_id == own)) {
if (sender == 0 || (own != 0 && sender == own)) {
return;
}
const bool finite =
std::isfinite(pp.pos[0]) && std::isfinite(pp.pos[1]) &&
std::isfinite(pp.pos[2]);
std::isfinite(pose.pos[0]) && std::isfinite(pose.pos[1]) &&
std::isfinite(pose.pos[2]);
if (!finite) {
return;
}
const uint16_t rows =
(pp.flags & kPoseFlagsBones) != 0
? std::min<uint16_t>(pp.bone_count, kGhostMaxBones)
(pose.flags & kPoseFlagsBones) != 0
? std::min<uint16_t>(pose.bone_count, kGhostMaxBones)
: 0;
auto it = g_ghost_index.find(pp.session_id);
auto it = g_ghost_index.find(sender);
if (it != g_ghost_index.end() && it->second < g_ghosts.size()) {
Ghost& gh = g_ghosts[it->second];
std::memcpy(gh.world, pp.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float));
std::memcpy(gh.world, pose.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pose.pos, 3 * sizeof(float));
gh.world[15] = 1.0f;
gh.bone_count = rows;
gh.target_count = rows;
if (rows != 0) {
std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float));
std::memcpy(gh.target_bones, pose.bones,
size_t(rows) * 12 * sizeof(float));
}
// New skeleton (count changed or first pose): snap the display so we
// never blend between two different bone layouts.
if (!gh.display_snapped || gh.display_count != rows) {
gh.display_count = rows;
if (rows != 0) {
std::memcpy(gh.display_bones, gh.target_bones,
size_t(rows) * 12 * sizeof(float));
}
gh.display_snapped = true;
gh.last_display = 0.0;
}
gh.last_seen = now;
} else if (g_ghosts.size() < 16) {
Ghost gh;
gh.session_id = pp.session_id;
std::memcpy(gh.world, pp.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float));
gh.session_id = sender;
std::memcpy(gh.world, pose.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pose.pos, 3 * sizeof(float));
gh.world[15] = 1.0f;
gh.bone_count = rows;
gh.target_count = rows;
gh.display_count = rows;
if (rows != 0) {
std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float));
std::memcpy(gh.target_bones, pose.bones,
size_t(rows) * 12 * sizeof(float));
std::memcpy(gh.display_bones, gh.target_bones,
size_t(rows) * 12 * sizeof(float));
}
gh.display_snapped = true;
gh.last_seen = now;
g_ghost_index[pp.session_id] = g_ghosts.size();
g_ghost_index[sender] = g_ghosts.size();
g_ghosts.push_back(gh);
}
}
@ -425,6 +473,7 @@ void PruneGhosts(double now) {
for (size_t i = 0; i < g_ghosts.size();) {
if (now - g_ghosts[i].last_seen > kGhostStaleSec) {
g_ghost_index.erase(g_ghosts[i].session_id);
g_pending_poses.erase(g_ghosts[i].session_id);
g_ghosts[i] = g_ghosts.back();
g_ghosts.pop_back();
// Fix the moved entry's index (only if it wasn't the one we removed).
@ -444,6 +493,7 @@ void ResetSession() {
g_roster = 0;
g_ghosts.clear();
g_ghost_index.clear();
g_pending_poses.clear();
g_session_public = 0;
g_session_lan = 0;
g_peer_public = Peer{};
@ -781,13 +831,31 @@ bool GetGhostPose(uint32_t index, float world[16], float color[3],
if (index >= g_ghosts.size()) {
return false;
}
const Ghost& gh = g_ghosts[index];
Ghost& gh = g_ghosts[index];
std::memcpy(world, gh.world, sizeof(float) * 16);
GhostColor(gh.session_id, color);
const uint16_t rows = std::min<uint16_t>(gh.bone_count, max_bones);
// Chase the display skeleton toward the latest received target: per-frame
// exponential catch-up (time-constant ~33 ms) turns the stepped 15 Hz
// bone stream into smooth motion. Only once the render thread has moved
// the display do we advance it, so two queries in one frame can't double.
if (gh.display_count != 0 && gh.display_count == gh.target_count) {
const double now = NowSec();
double dt = gh.last_display > 0.0 ? now - gh.last_display : 0.0;
dt = std::min(dt, 0.1);
if (dt > 0.0) {
const float alpha = float(1.0 - std::exp(-dt * 30.0));
const size_t n = size_t(gh.display_count) * 12;
for (size_t i = 0; i < n; ++i) {
gh.display_bones[i] +=
(gh.target_bones[i] - gh.display_bones[i]) * alpha;
}
}
gh.last_display = now;
}
const uint16_t rows = std::min<uint16_t>(gh.display_count, max_bones);
*bone_count = rows;
if (rows != 0) {
std::memcpy(bones, gh.bones, size_t(rows) * 12 * sizeof(float));
std::memcpy(bones, gh.display_bones, size_t(rows) * 12 * sizeof(float));
}
return true;
}

View file

@ -15,13 +15,15 @@
// A packet is PacketHeader followed by a fixed per-type payload. PacketBuf
// is sized for the largest payload.
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace skate3::native_ghost {
constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE)
constexpr uint8_t kGhostVersion = 1;
constexpr uint8_t kGhostVersion = 2;
constexpr uint16_t kGhostDefaultPort = 40001;
constexpr uint32_t kGhostMaxRoomLen = 8;
constexpr uint32_t kGhostMaxRoster = 32;
@ -39,10 +41,11 @@ constexpr const char* kGhostLobbyRoom = "LOBBY";
enum PacketType : uint8_t {
kTypeJoin = 1, // client -> relay: { JoinPayload } (empty room = server assigns)
kTypeHello = 2, // relay -> client: { HelloPayload } (session id, room, roster)
kTypePose = 3, // client -> relay -> other members: { PosePayload }
kTypePose = 3, // legacy single-datagram pose (forwarded, unused by clients)
kTypePing = 4, // client -> relay / LAN broadcast probe: { JoinPayload }
kTypePong = 5, // relay -> client: empty
kTypeLeave = 6, // client -> relay: empty
kTypePoseChunk = 7, // client -> relay -> other members: { PoseChunkPayload }
};
struct alignas(1) PacketHeader {
@ -66,11 +69,12 @@ struct alignas(1) HelloPayload {
uint32_t roster[kGhostMaxRoster];
};
// Full-pose packet (the Phase 2 character-model path): the sender's skater
// skeleton, 3 float4 rows per bone (column-vector affine [R | t], model ->
// world, row k = bone k exactly as the scene renderer's bone ring expects).
// Flags bit 0 = bones[] valid. pos/rot are kept from the entity world for
// the cube fallback / diagnostics.
// Full-pose blob (the character-model path): the sender's skater skeleton,
// 3 float4 rows per bone (column-vector affine [R | t], model -> world, row
// k = bone k exactly as the scene renderer's bone ring expects). Flags bit 0
// = bones[] valid. pos/rot are kept from the entity world for the cube
// fallback / diagnostics. This struct is the REASSEMBLED content of a
// chunked PoseChunk stream; it is no longer sent as a single datagram.
struct alignas(1) PosePayload {
uint32_t session_id; // set by the relay on forward (the sender's id)
uint32_t seq; // sender monotonic sequence
@ -85,7 +89,81 @@ struct alignas(1) PosePayload {
constexpr uint8_t kPoseFlagsBones = 1;
// Largest payload: PosePayload (32 + 3072 = 3104 bytes).
// ---- chunked pose transport -------------------------------------------------
// A full PosePayload is ~3.1 KB, far above the 1500-byte path MTU, so it is
// split into kGhostMaxChunks MTU-safe datagrams (<= ~1.2 KB each). The relay
// forwards each chunk verbatim (it never parses the body), and the receiver
// reassembles on the sequence. A lost chunk drops that pose (the next frame
// resends); out-of-order chunks are tolerated by the per-chunk offset copy.
constexpr uint16_t kGhostPoseHeaderSize =
uint16_t(offsetof(PosePayload, bones)); // 68
constexpr uint16_t kGhostChunkData = 1200;
constexpr uint8_t kGhostMaxChunks = 3;
constexpr uint16_t kGhostPoseBlobSize = uint16_t(sizeof(PosePayload)); // 3140
struct alignas(1) PoseChunkPayload {
uint32_t session_id; // offset 0: relay re-stamps the sender id
uint32_t seq; // pose sequence this chunk belongs to
uint16_t blob_size; // total blob bytes this seq comprises
uint16_t data_size; // bytes actually valid in data[]
uint8_t chunk; // 0-based chunk index
uint8_t chunks; // total chunks for this seq
uint8_t data[kGhostChunkData];
};
// Receiver-side reassembly for ONE session (one instance per remote peer,
// network thread only). Push() returns true exactly when a complete pose
// becomes available and fills *out; a fresh seq discards any partial state.
struct GhostPoseReassembler {
void Reset() {
seq_ = 0;
blob_size_ = 0;
chunks_ = 0;
received_ = 0;
}
bool Push(uint32_t seq, uint16_t blob_size, uint8_t chunk, uint8_t chunks,
const uint8_t* data, uint16_t data_size, PosePayload* out) {
if (chunk >= chunks || chunks == 0 || chunks > kGhostMaxChunks ||
blob_size == 0 || blob_size > kGhostPoseBlobSize) {
return false;
}
if (seq != seq_) {
Reset();
seq_ = seq;
blob_size_ = blob_size;
chunks_ = chunks;
} else if (blob_size != blob_size_ || chunks != chunks_) {
return false; // inconsistent mid-sequence; drop
}
const uint32_t bit = 1u << chunk;
if ((received_ & bit) != 0) {
return false; // duplicate chunk
}
received_ |= bit;
const uint16_t off = uint16_t(chunk) * kGhostChunkData;
const uint16_t avail = blob_size_ > off ? blob_size_ - off : 0;
const uint16_t n = std::min<uint16_t>(data_size, avail);
std::memcpy(blob_ + off, data, n);
const uint32_t want = (1u << chunks_) - 1u;
if ((received_ & want) != want) {
return false;
}
std::memset(out, 0, sizeof(*out));
std::memcpy(out, blob_, std::min<uint16_t>(blob_size_, uint16_t(sizeof(*out))));
Reset();
return true;
}
private:
uint32_t seq_ = 0;
uint16_t blob_size_ = 0;
uint8_t chunks_ = 0;
uint32_t received_ = 0;
uint8_t blob_[kGhostPoseBlobSize] = {};
};
// Largest payload: PoseChunkPayload (1214 bytes).
constexpr uint16_t kGhostMaxPayload = 3200;
struct alignas(1) PacketBuf {
@ -107,6 +185,35 @@ inline void BuildPacket(PacketBuf& p, uint8_t type, const void* payload,
}
}
// Builds the chunk packets for a pose. Always writes exactly the returned
// chunk count (1 for a header-only pose, up to kGhostMaxChunks for a full
// skeleton). The blob is the PosePayload serialized from its first byte.
inline uint8_t GhostPoseBuildChunks(uint32_t session_id, uint32_t seq,
const PosePayload& pose,
PacketBuf out[kGhostMaxChunks]) {
const uint16_t blob_size =
(pose.flags & kPoseFlagsBones) != 0
? uint16_t(sizeof(PosePayload))
: kGhostPoseHeaderSize;
const uint8_t chunks =
uint8_t((blob_size + kGhostChunkData - 1) / kGhostChunkData);
const uint8_t* blob = reinterpret_cast<const uint8_t*>(&pose);
for (uint8_t c = 0; c < chunks; ++c) {
PoseChunkPayload cp = {};
cp.session_id = session_id;
cp.seq = seq;
cp.blob_size = blob_size;
cp.chunk = c;
cp.chunks = chunks;
const uint16_t off = uint16_t(c) * kGhostChunkData;
const uint16_t n = std::min<uint16_t>(kGhostChunkData, blob_size - off);
cp.data_size = n;
std::memcpy(cp.data, blob + off, n);
BuildPacket(out[c], kTypePoseChunk, &cp, sizeof(cp));
}
return chunks;
}
// ---- room code helpers (uppercase alnum, max kGhostMaxRoomLen) -------------
inline bool RoomValid(const char* room, uint8_t len) {

View file

@ -177,7 +177,8 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
break;
}
case kTypePose: {
case kTypePose:
case kTypePoseChunk: {
// Clients join before posing; a pose with no room is auto-joined to
// the lobby so the relay stays resilient to a missed HELLO.
if (me->room_len == 0) {
@ -186,11 +187,12 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
me->last_seen = now;
// Forward the payload VERBATIM to the other room members, re-stamping
// only the sender session id at payload offset 0 (little-endian). The
// relay never parses the pose body, so the protocol can grow (e.g. the
// full skeleton palette) without a relay update.
// relay never parses the pose body (a full skeleton pose is a
// multi-chunk kTypePoseChunk stream; each chunk is forwarded the same
// way), so the protocol can grow without a relay update.
PacketBuf out = p;
out.h.magic = kGhostMagic;
out.h.type = kTypePose;
out.h.type = p.h.type;
std::memcpy(out.payload, &me->session_id, sizeof(me->session_id));
const uint16_t size = PacketSize(out);
for (const Client& c : clients_) {