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:
parent
7b949a312b
commit
cceeac7c4f
3 changed files with 217 additions and 40 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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_) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue