From bfb504db7d853325912a8304fe7ef18a53453c29 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 26 Aug 2026 20:16:42 -0400 Subject: [PATCH] ghost multiplayer: full skater skeleton pose + character-model ghosts Phase 2: replace the placeholder-cube ghost rendering with real character models driven by the remote player's skeleton. - Wire: POSE now carries the sender's skater skeleton (up to 64 bones, 3 float4 rows each, index-ordered exactly like DrawItem::bones), padded with identity rows so every mesh's referenced bones stay in range. The relay forwards POSE payloads verbatim (re-stamping only the sender session id), so the protocol can grow without a relay update. - Capture: OnFrameSceneBuilt (called from BuildFrameScene pre-publish) snapshots the local player's body item bone palette (highest-bone-count skinned non-ropa non-LivingWorld char item near the camera) as the broadcast pose; defaults the broadcast rate to 15 Hz. - Render: in RenderScene, each remote ghost is drawn by cloning this player's own character pieces (body + nearby skinned char items) and substituting the remote skeleton into the bone ring - same meshes, textures and cached GPU decodes, no mesh transfer. Ghosts without a pose (older peers / capture not ready) fall back to the solid-color cube, which skate3_ghost_draw_local still uses for the self-check. Known Phase-2 limitations (documented in code): ghosts render with this player's own character model/outfit, cloth (ropa) garments are skipped (no remote cloth sim), and hair/alpha pieces render opaque. All confined to src/native and the hand-written scene hooks. --- src/native/skate3_native_ghost.cpp | 139 ++++++++++++++-- src/native/skate3_native_ghost.h | 16 ++ src/native/skate3_native_ghost_proto.h | 22 ++- src/native/skate3_native_ghost_relay.cpp | 15 +- src/skate3_native_scene.cpp | 4 + src/skate3_native_scene_gpu.cpp | 192 ++++++++++++++++++----- 6 files changed, 323 insertions(+), 65 deletions(-) diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp index c755144..3f98c0d 100644 --- a/src/native/skate3_native_ghost.cpp +++ b/src/native/skate3_native_ghost.cpp @@ -18,6 +18,8 @@ #include "skate3_native_guest_read.h" #include "skate3_native_palette.h" +#include "skate3_native_scene.h" + #include "generated/skate3_init.h" #include @@ -48,8 +50,9 @@ REXCVAR_DEFINE_STRING(skate3_ghost_room, "", "Skate 3", "assigns one and it is shown in the multiplayer menu. " "Players sharing a code see each other's ghosts.") .lifecycle(rex::cvar::Lifecycle::kHotReload); -REXCVAR_DEFINE_DOUBLE(skate3_ghost_rate, 20.0, "Skate 3", - "Ghost pose broadcast rate in Hz.") +REXCVAR_DEFINE_DOUBLE(skate3_ghost_rate, 15.0, "Skate 3", + "Ghost pose broadcast rate in Hz (a full skeleton pose " + "is a few KB; 15 Hz is the LAN/internet sweet spot).") .range(1.0, 60.0) .lifecycle(rex::cvar::Lifecycle::kHotReload); REXCVAR_DEFINE_BOOL(skate3_ghost_lan, true, "Skate 3", @@ -169,10 +172,20 @@ bool g_local_valid = false; float g_local_world[16] = {}; double g_local_time = 0.0; +// Local player's skater skeleton (captured per built frame from the body +// item), identity-padded to kGhostMaxBones rows so the wire pose always has +// a full, index-ordered palette. g_local_pose_count = live rows. +bool g_local_pose_valid = false; +uint16_t g_local_pose_count = 0; +float g_local_pose[kGhostMaxBones * 12] = {}; +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] = {}; double last_seen = 0.0; }; std::vector g_ghosts; @@ -254,12 +267,14 @@ void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) { void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) { float world[16]; + uint16_t pose_rows = 0; { std::lock_guard lock(g_mu); if (!g_local_valid) { return; } std::memcpy(world, g_local_world, sizeof(world)); + pose_rows = g_local_pose_count; } PosePayload pp = {}; pp.session_id = 0; // filled by the relay @@ -267,6 +282,12 @@ void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) { pp.timestamp = NowSec(); std::memcpy(pp.rot, world, 9 * sizeof(float)); std::memcpy(pp.pos, world + 12, 3 * sizeof(float)); + if (pose_rows != 0) { + std::lock_guard lock(g_mu); + std::memcpy(pp.bones, g_local_pose, size_t(pose_rows) * 12 * sizeof(float)); + } + 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)); @@ -298,19 +319,31 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { if (!finite) { return; } + const uint16_t rows = + (pp.flags & kPoseFlagsBones) != 0 + ? std::min(pp.bone_count, kGhostMaxBones) + : 0; auto it = g_ghost_index.find(pp.session_id); 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)); gh.world[15] = 1.0f; + gh.bone_count = rows; + if (rows != 0) { + std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float)); + } gh.last_seen = now; - } else if (g_ghosts.size() < 32) { + } 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.world[15] = 1.0f; + gh.bone_count = rows; + if (rows != 0) { + std::memcpy(gh.bones, pp.bones, size_t(rows) * 12 * sizeof(float)); + } gh.last_seen = now; g_ghost_index[pp.session_id] = g_ghosts.size(); g_ghosts.push_back(gh); @@ -415,6 +448,8 @@ void ResetSession() { g_session_lan = 0; g_peer_public = Peer{}; g_peer_lan = Peer{}; + g_local_pose_valid = false; + g_local_pose_count = 0; if (g_relay) { g_relay->Stop(); g_relay.reset(); @@ -623,6 +658,76 @@ void OnLocalSkaterTick(uint8_t* base) { g_local_time = NowSec(); } +namespace { +// Identity rows for a bone (column-vector affine [R|t]): the safe padding +// for skeleton rows the sender did not capture (unreferenced or +// off-skeleton bones render at bind/identity rather than garbage). +void IdentityBoneRows(float* out) { + const float kI[12] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0}; + std::memcpy(out, kI, sizeof(kI)); +} + +// Stable, bright, per-session marker color. +void GhostColor(uint32_t session_id, float color[3]) { + const uint32_t h = session_id * 2654435761u; + color[0] = float((h >> 8) & 0xFF) / 255.0f; + color[1] = float((h >> 16) & 0xFF) / 255.0f; + color[2] = float((h >> 24) & 0xFF) / 255.0f; + const float lum = color[0] * 0.3f + color[1] * 0.59f + color[2] * 0.11f; + if (lum < 0.45f) { + const float boost = 0.6f / (lum + 1e-4f); + color[0] = std::min(1.0f, color[0] * boost); + color[1] = std::min(1.0f, color[1] * boost); + color[2] = std::min(1.0f, color[2] * boost); + } +} +} // namespace + +void OnFrameSceneBuilt(uint8_t* base, + const skate3::native_scene::FrameScene& scene) { + (void)base; + if (!Enabled()) { + return; + } + // Identify the local player's body item: the highest-bone-count skinned + // non-ropa, non-LivingWorld character item near the camera (the same + // heuristic the renderer's char_track uses). Its bone palette is the + // index-ordered skater skeleton this client broadcasts. + const skate3::native_scene::DrawItem* body = nullptr; + uint32_t best_bones = 0; + for (const skate3::native_scene::DrawItem& it : scene.items) { + if (!(it.skinned && it.bones.size() >= 12) || it.ropa || it.lw_entity != 0) { + continue; + } + if (it.char_family < 1 || it.char_family > 5) { + continue; + } + const float px = it.bones[3], py = it.bones[7], pz = it.bones[11]; + const float dx = px - scene.cam_pos[0], dy = py - scene.cam_pos[1], + dz = pz - scene.cam_pos[2]; + if (dx * dx + dy * dy + dz * dz > 25.0f) { + continue; + } + const uint32_t nb = uint32_t(it.bones.size() / 12); + if (nb > best_bones) { + best_bones = nb; + body = ⁢ + } + } + if (body == nullptr || best_bones == 0) { + return; + } + const uint16_t rows = uint16_t(std::min(best_bones, kGhostMaxBones)); + std::lock_guard lock(g_mu); + std::memcpy(g_local_pose, body->bones.data(), size_t(rows) * 12 * sizeof(float)); + for (uint16_t r = rows; r < kGhostMaxBones; ++r) { + IdentityBoneRows(g_local_pose + size_t(r) * 12); + } + g_local_pose_count = rows; + g_local_pose_valid = true; + g_local_pose_time = NowSec(); +} + void EnsureNetworkThread() { if (g_started.exchange(true)) { return; @@ -666,17 +771,23 @@ bool GetGhost(uint32_t index, float world[16], float color[3]) { } const Ghost& gh = g_ghosts[index]; std::memcpy(world, gh.world, sizeof(float) * 16); - // Stable color from the session id. - const uint32_t h = gh.session_id * 2654435761u; - color[0] = float((h >> 8) & 0xFF) / 255.0f; - color[1] = float((h >> 16) & 0xFF) / 255.0f; - color[2] = float((h >> 24) & 0xFF) / 255.0f; - const float lum = color[0] * 0.3f + color[1] * 0.59f + color[2] * 0.11f; - if (lum < 0.45f) { - const float boost = 0.6f / (lum + 1e-4f); - color[0] = std::min(1.0f, color[0] * boost); - color[1] = std::min(1.0f, color[1] * boost); - color[2] = std::min(1.0f, color[2] * boost); + GhostColor(gh.session_id, color); + return true; +} + +bool GetGhostPose(uint32_t index, float world[16], float color[3], + uint16_t max_bones, float* bones, uint16_t* bone_count) { + std::lock_guard lock(g_mu); + if (index >= g_ghosts.size()) { + return false; + } + const Ghost& gh = g_ghosts[index]; + std::memcpy(world, gh.world, sizeof(float) * 16); + GhostColor(gh.session_id, color); + const uint16_t rows = std::min(gh.bone_count, max_bones); + *bone_count = rows; + if (rows != 0) { + std::memcpy(bones, gh.bones, size_t(rows) * 12 * sizeof(float)); } return true; } diff --git a/src/native/skate3_native_ghost.h b/src/native/skate3_native_ghost.h index 268e342..2edd69f 100644 --- a/src/native/skate3_native_ghost.h +++ b/src/native/skate3_native_ghost.h @@ -24,11 +24,21 @@ #include "skate3_native_ghost_proto.h" +namespace skate3::native_scene { +struct FrameScene; +} // namespace skate3::native_scene + namespace skate3::native_ghost { // ---- host-side capture (called from skate3_native_render.cpp) ------------- void OnLocalSkaterTick(uint8_t* base); +// ---- host-side pose capture (called from BuildFrameScene pre-publish) ----- +// Snapshot the local player's skater skeleton (the highest-bone-count +// skinned non-ropa character item near the camera) as the pose this client +// broadcasts. Called every built frame; gated internally on the enable cvar. +void OnFrameSceneBuilt(uint8_t* base, const skate3::native_scene::FrameScene& scene); + // ---- network thread lifecycle ------------------------------------------------- void EnsureNetworkThread(); void Shutdown(); @@ -51,6 +61,12 @@ bool GetStatus(Status* out); size_t GhostCount(); // Row-vector world matrix (translation in row 3) + a stable color per ghost. bool GetGhost(uint32_t index, float world[16], float color[3]); +// Full-pose variant: additionally copies up to max_bones 12-float skeleton +// rows (the exact layout DrawItem::bones uses) so the renderer can drive a +// real character mesh. bone_count receives the copied row count (0 = the +// sender had no pose available; the caller should use the cube fallback). +bool GetGhostPose(uint32_t index, float world[16], float color[3], + uint16_t max_bones, float* bones, uint16_t* bone_count); // Self-check cube at the local captured pose (skate3_ghost_draw_local). bool GetLocalWorld(float world[16]); diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h index fa6c3c0..6b46a6b 100644 --- a/src/native/skate3_native_ghost_proto.h +++ b/src/native/skate3_native_ghost_proto.h @@ -25,6 +25,11 @@ constexpr uint8_t kGhostVersion = 1; constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoster = 32; +// Max skeleton rows carried in a POSE (3 float4s per bone). The Skate 3 +// skater skeleton fits comfortably under this; rows beyond the sender's +// captured count are identity-padded so every mesh's referenced bones stay +// in range on the receiving side. +constexpr uint32_t kGhostMaxBones = 64; // Placeholder default public relay: replace in ONE place (skate3_native_ghost.cpp). constexpr const char* kGhostDefaultServer = "ghost.skate3.example"; constexpr uint16_t kGhostDefaultServerPort = 40001; @@ -61,16 +66,27 @@ 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. struct alignas(1) PosePayload { uint32_t session_id; // set by the relay on forward (the sender's id) uint32_t seq; // sender monotonic sequence double timestamp; // sender monotonic seconds (informational) - float pos[3]; // world translation + float pos[3]; // world translation (entity world, row 3) float rot[9]; // world rotation, row-vector 3x3 (matches entity+416) + uint16_t bone_count; // rows in bones[] (0 = pose unavailable) + uint8_t flags; // bit0 = bones[] valid + uint8_t reserved; + float bones[kGhostMaxBones * 12]; }; -// Largest payload: HelloPayload (4 + 1 + 8 + 1 + 128 = 142 bytes). -constexpr uint16_t kGhostMaxPayload = 256; +constexpr uint8_t kPoseFlagsBones = 1; + +// Largest payload: PosePayload (32 + 3072 = 3104 bytes). +constexpr uint16_t kGhostMaxPayload = 3200; struct alignas(1) PacketBuf { PacketHeader h; diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp index 69d0537..dd797b2 100644 --- a/src/native/skate3_native_ghost_relay.cpp +++ b/src/native/skate3_native_ghost_relay.cpp @@ -184,9 +184,15 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, RoomSet(me->room, me->room_len, kGhostLobbyRoom); } me->last_seen = now; - PosePayload pose = {}; - std::memcpy(&pose, p.payload, sizeof(pose)); - pose.session_id = me->session_id; + // 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. + PacketBuf out = p; + out.h.magic = kGhostMagic; + out.h.type = kTypePose; + std::memcpy(out.payload, &me->session_id, sizeof(me->session_id)); + const uint16_t size = PacketSize(out); for (const Client& c : clients_) { if (&c == me) { continue; @@ -195,8 +201,7 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, !RoomEquals(c.room, c.room_len, me->room, me->room_len)) { continue; } - BuildPacket(out, kTypePose, &pose, static_cast(sizeof(pose))); - GhostSockSendTo(sock_, c.addr, &out, PacketSize(out)); + GhostSockSendTo(sock_, c.addr, &out, size); } break; } diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index 5a7672b..d5d38c3 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -38,6 +38,7 @@ #include "native/skate3_native_diag.h" #include "native/skate3_native_entity.h" +#include "native/skate3_native_ghost.h" #include "native/skate3_native_guest_read.h" #include "native/skate3_native_lw.h" #include "native/skate3_native_palette.h" @@ -10446,6 +10447,9 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { std::chrono::steady_clock::now().time_since_epoch()) .count(), std::memory_order_relaxed); + // Ghost multiplayer: snapshot the local player's skeleton from the final + // item list (the pose this client broadcasts to the relay). + skate3::native_ghost::OnFrameSceneBuilt(base, scene); std::lock_guard lock(g_scene_mutex); scene.generation = ++g_generation; g_scene = std::make_shared(std::move(scene)); diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp index af806b3..1f0ad0f 100644 --- a/src/skate3_native_scene_gpu.cpp +++ b/src/skate3_native_scene_gpu.cpp @@ -10342,11 +10342,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize; uint32_t ui_offset = 0; - // ---- Ghost multiplayer placeholder cubes (native/skate3_native_ghost) ---- - // Solid-color unit cubes at the remote players' captured world positions, - // drawn inside the scene pass (depth-tested against the world like the - // spline pass). Reuses the existing main-pass PSO and the scene.hlsl - // tint.a > 0 solid-color branch: no new shader, no SPIR-V regeneration. + // ---- Ghost multiplayer (native/skate3_native_ghost) --------------------- + // Full-character ghosts: each remote player's skater skeleton (broadcast + // in the POSE packets) drives a CLONE of this player's own character + // pieces - same meshes, same textures, same cached GPU decodes, remote + // pose in the bone ring. Ghosts with no pose data (older peer, capture + // not ready) fall back to a solid-color cube. if (REXCVAR_GET(skate3_ghost_enable) && g_r.pso != nullptr && g_r.ui_ring_cpu != nullptr && debug_mode == 0) { const uint32_t n_ghosts = skate3::native_ghost::GhostCount(); @@ -10355,55 +10356,160 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat REXCVAR_GET(skate3_ghost_draw_local) && skate3::native_ghost::GetLocalWorld(local_world); if (n_ghosts != 0 || draw_local) { + // The mesh source: the local player's character pieces (body + nearby + // skinned non-ropa, non-LivingWorld items), found the same way the + // ghost capture on the game thread finds the body. + std::vector player_pieces; + { + const DrawItem* body = nullptr; + uint32_t best_bones = 0; + float body_p[3] = {}; + for (const DrawItem& it : scene.items) { + if (!(it.skinned && it.bones.size() >= 12) || it.ropa || + it.lw_entity != 0) { + continue; + } + if (it.char_family < 1 || it.char_family > 5) { + continue; + } + const float px = it.bones[3], py = it.bones[7], pz = it.bones[11]; + const float dx = px - scene.cam_pos[0], dy = py - scene.cam_pos[1], + dz = pz - scene.cam_pos[2]; + if (dx * dx + dy * dy + dz * dz > 25.0f) { + continue; + } + const uint32_t nb = uint32_t(it.bones.size() / 12); + if (nb > best_bones) { + best_bones = nb; + body = ⁢ + body_p[0] = px; + body_p[1] = py; + body_p[2] = pz; + } + } + if (body != nullptr) { + player_pieces.push_back(body); + for (const DrawItem& it : scene.items) { + if (&it == body) { + continue; + } + if (!(it.skinned && it.bones.size() >= 12) || it.ropa || + it.lw_entity != 0) { + continue; + } + const float px = it.bones[3], py = it.bones[7], pz = it.bones[11]; + const float dx = px - body_p[0], dy = py - body_p[1], + dz = pz - body_p[2]; + if (dx * dx + dy * dy + dz * dz <= 4.0f) { + player_pieces.push_back(&it); + } + } + } + } + + // Cube fallback geometry (also used by skate3_ghost_draw_local). constexpr uint32_t kCubeVerts = 36; const uint32_t kVertBytes = uint32_t(sizeof(skate3::native_ghost::GhostCubeVertex)); const uint32_t kCubeBytes = kCubeVerts * kVertBytes; - if (ui_offset + kCubeBytes <= RendererState::kUiRegionSize) { + const bool cube_uploaded = ui_offset + kCubeBytes <= RendererState::kUiRegionSize; + if (cube_uploaded) { std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset, skate3::native_ghost::CubeVertices(), kCubeBytes); - const uint32_t cube_offset = ui_offset; + } + const uint32_t cube_offset = ui_offset; + if (cube_uploaded) { ui_offset += kCubeBytes; - cmd->SetPipeline(g_r.pso); - cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes, - kVertBytes); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - cmd->SetTexture(1, g_r.white.srv); - const auto draw_ghost = [&](const float world[16], const float color[3]) { - float constants[52] = {}; - std::memcpy(constants, world, sizeof(float) * 16); - float* mvp = constants + 16; - for (int r = 0; r < 4; ++r) { - for (int c = 0; c < 4; ++c) { - float sum = 0.0f; - for (int k = 0; k < 4; ++k) { - sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c]; - } - mvp[r * 4 + c] = sum; + } + cmd->SetPipeline(g_r.pso); + cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes, + kVertBytes); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + cmd->SetTexture(1, g_r.white.srv); + + const auto draw_cube = [&](const float world[16], const float color[3]) { + if (!cube_uploaded) { + return; + } + float constants[52] = {}; + std::memcpy(constants, world, sizeof(float) * 16); + float* mvp = constants + 16; + for (int r = 0; r < 4; ++r) { + for (int c = 0; c < 4; ++c) { + float sum = 0.0f; + for (int k = 0; k < 4; ++k) { + sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c]; } - } - constants[32] = color[0]; - constants[33] = color[1]; - constants[34] = color[2]; - constants[35] = 1.0f; // tint.a > 0 -> solid color in ShadePixel - constants[36] = scene.cam_pos[0]; - constants[37] = scene.cam_pos[1]; - constants[38] = scene.cam_pos[2]; - constants[39] = 0.0f; - cmd->SetRootConstants(0, 52, constants, 0); - cmd->Draw(kCubeVerts, 0); - }; - for (uint32_t i = 0; i < n_ghosts; ++i) { - float world[16]; - float color[3]; - if (skate3::native_ghost::GetGhost(i, world, color)) { - draw_ghost(world, color); + mvp[r * 4 + c] = sum; } } - if (draw_local) { - const float local_color[3] = {0.2f, 1.0f, 0.2f}; - draw_ghost(local_world, local_color); + constants[32] = color[0]; + constants[33] = color[1]; + constants[34] = color[2]; + constants[35] = 1.0f; // tint.a > 0 -> solid color in ShadePixel + constants[36] = scene.cam_pos[0]; + constants[37] = scene.cam_pos[1]; + constants[38] = scene.cam_pos[2]; + constants[39] = 0.0f; + cmd->SetRootConstants(0, 52, constants, 0); + cmd->Draw(kCubeVerts, 0); + }; + + const auto draw_model = [&](const float* pose_rows, uint16_t pose_count) { + if (player_pieces.empty()) { + return; } + // Identity world: the remote skeleton's bone 0 translation already + // places the ghost; mvp becomes view*proj, exactly like the real + // skinned items (whose bones carry the world). + static const float kIdent[16] = {1, 0, 0, 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, 0, 0, 1}; + for (const DrawItem* piece : player_pieces) { + DrawItem g = *piece; // clone: same mesh/textures, remote pose + const uint32_t need = uint32_t(g.bones.size() / 12); + const uint32_t copy_rows = std::min(pose_count, need); + g.bones.resize(size_t(need) * 12); + std::memcpy(g.bones.data(), pose_rows, + size_t(copy_rows) * 12 * sizeof(float)); + for (uint32_t r = copy_rows; r < need; ++r) { + float* row = g.bones.data() + size_t(r) * 12; + row[0] = 1.0f; + row[5] = 1.0f; + row[10] = 1.0f; + } + std::memcpy(g.world, kIdent, sizeof(kIdent)); + g.ropa = false; + g.pending = false; + g.selected = false; + g.retained = false; + g.dyn_entity = false; + g.unlit = false; + g.transparent = false; + g.water = false; + g.lw_alpha = -1.0f; + timed_draw(g); + } + }; + + float ghost_world[16]; + float ghost_color[3]; + for (uint32_t i = 0; i < n_ghosts; ++i) { + float pose_scratch[skate3::native_ghost::kGhostMaxBones * 12]; + uint16_t ghost_rows = 0; + if (skate3::native_ghost::GetGhostPose( + i, ghost_world, ghost_color, + uint16_t(skate3::native_ghost::kGhostMaxBones), pose_scratch, + &ghost_rows)) { + if (ghost_rows != 0) { + draw_model(pose_scratch, ghost_rows); + continue; + } + draw_cube(ghost_world, ghost_color); + } + } + if (draw_local) { + const float local_color[3] = {0.2f, 1.0f, 0.2f}; + draw_cube(local_world, local_color); } } }