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.
This commit is contained in:
MiTHRAL 2026-08-26 20:16:42 -04:00
parent 269fbcee32
commit bfb504db7d
6 changed files with 323 additions and 65 deletions

View file

@ -18,6 +18,8 @@
#include "skate3_native_guest_read.h" #include "skate3_native_guest_read.h"
#include "skate3_native_palette.h" #include "skate3_native_palette.h"
#include "skate3_native_scene.h"
#include "generated/skate3_init.h" #include "generated/skate3_init.h"
#include <rex/cvar.h> #include <rex/cvar.h>
@ -48,8 +50,9 @@ REXCVAR_DEFINE_STRING(skate3_ghost_room, "", "Skate 3",
"assigns one and it is shown in the multiplayer menu. " "assigns one and it is shown in the multiplayer menu. "
"Players sharing a code see each other's ghosts.") "Players sharing a code see each other's ghosts.")
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_DOUBLE(skate3_ghost_rate, 20.0, "Skate 3", REXCVAR_DEFINE_DOUBLE(skate3_ghost_rate, 15.0, "Skate 3",
"Ghost pose broadcast rate in Hz.") "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) .range(1.0, 60.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_lan, true, "Skate 3", REXCVAR_DEFINE_BOOL(skate3_ghost_lan, true, "Skate 3",
@ -169,10 +172,20 @@ bool g_local_valid = false;
float g_local_world[16] = {}; float g_local_world[16] = {};
double g_local_time = 0.0; 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. // Remote ghosts, keyed by the sender's session id.
struct Ghost { struct Ghost {
uint32_t session_id = 0; uint32_t session_id = 0;
float world[16] = {}; float world[16] = {};
uint16_t bone_count = 0; // live skeleton rows (0 = no pose)
float bones[kGhostMaxBones * 12] = {};
double last_seen = 0.0; double last_seen = 0.0;
}; };
std::vector<Ghost> g_ghosts; std::vector<Ghost> 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) { void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) {
float world[16]; float world[16];
uint16_t pose_rows = 0;
{ {
std::lock_guard<std::mutex> lock(g_mu); std::lock_guard<std::mutex> lock(g_mu);
if (!g_local_valid) { if (!g_local_valid) {
return; return;
} }
std::memcpy(world, g_local_world, sizeof(world)); std::memcpy(world, g_local_world, sizeof(world));
pose_rows = g_local_pose_count;
} }
PosePayload pp = {}; PosePayload pp = {};
pp.session_id = 0; // filled by the relay 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(); pp.timestamp = NowSec();
std::memcpy(pp.rot, world, 9 * sizeof(float)); std::memcpy(pp.rot, world, 9 * sizeof(float));
std::memcpy(pp.pos, world + 12, 3 * sizeof(float)); std::memcpy(pp.pos, world + 12, 3 * sizeof(float));
if (pose_rows != 0) {
std::lock_guard<std::mutex> 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; PacketBuf p;
BuildPacket(p, kTypePose, &pp, sizeof(pp)); BuildPacket(p, kTypePose, &pp, sizeof(pp));
GhostSockSendTo(sock, to, &p, PacketSize(p)); GhostSockSendTo(sock, to, &p, PacketSize(p));
@ -298,19 +319,31 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
if (!finite) { if (!finite) {
return; return;
} }
const uint16_t rows =
(pp.flags & kPoseFlagsBones) != 0
? std::min<uint16_t>(pp.bone_count, kGhostMaxBones)
: 0;
auto it = g_ghost_index.find(pp.session_id); auto it = g_ghost_index.find(pp.session_id);
if (it != g_ghost_index.end() && it->second < g_ghosts.size()) { if (it != g_ghost_index.end() && it->second < g_ghosts.size()) {
Ghost& gh = g_ghosts[it->second]; Ghost& gh = g_ghosts[it->second];
std::memcpy(gh.world, pp.rot, 9 * sizeof(float)); std::memcpy(gh.world, pp.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float)); std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float));
gh.world[15] = 1.0f; 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; gh.last_seen = now;
} else if (g_ghosts.size() < 32) { } else if (g_ghosts.size() < 16) {
Ghost gh; Ghost gh;
gh.session_id = pp.session_id; gh.session_id = pp.session_id;
std::memcpy(gh.world, pp.rot, 9 * sizeof(float)); std::memcpy(gh.world, pp.rot, 9 * sizeof(float));
std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float)); std::memcpy(gh.world + 12, pp.pos, 3 * sizeof(float));
gh.world[15] = 1.0f; 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; gh.last_seen = now;
g_ghost_index[pp.session_id] = g_ghosts.size(); g_ghost_index[pp.session_id] = g_ghosts.size();
g_ghosts.push_back(gh); g_ghosts.push_back(gh);
@ -415,6 +448,8 @@ void ResetSession() {
g_session_lan = 0; g_session_lan = 0;
g_peer_public = Peer{}; g_peer_public = Peer{};
g_peer_lan = Peer{}; g_peer_lan = Peer{};
g_local_pose_valid = false;
g_local_pose_count = 0;
if (g_relay) { if (g_relay) {
g_relay->Stop(); g_relay->Stop();
g_relay.reset(); g_relay.reset();
@ -623,6 +658,76 @@ void OnLocalSkaterTick(uint8_t* base) {
g_local_time = NowSec(); 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 = &it;
}
}
if (body == nullptr || best_bones == 0) {
return;
}
const uint16_t rows = uint16_t(std::min<uint32_t>(best_bones, kGhostMaxBones));
std::lock_guard<std::mutex> 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() { void EnsureNetworkThread() {
if (g_started.exchange(true)) { if (g_started.exchange(true)) {
return; return;
@ -666,17 +771,23 @@ bool GetGhost(uint32_t index, float world[16], float color[3]) {
} }
const Ghost& gh = g_ghosts[index]; const Ghost& gh = g_ghosts[index];
std::memcpy(world, gh.world, sizeof(float) * 16); std::memcpy(world, gh.world, sizeof(float) * 16);
// Stable color from the session id. GhostColor(gh.session_id, color);
const uint32_t h = gh.session_id * 2654435761u; return true;
color[0] = float((h >> 8) & 0xFF) / 255.0f; }
color[1] = float((h >> 16) & 0xFF) / 255.0f;
color[2] = float((h >> 24) & 0xFF) / 255.0f; bool GetGhostPose(uint32_t index, float world[16], float color[3],
const float lum = color[0] * 0.3f + color[1] * 0.59f + color[2] * 0.11f; uint16_t max_bones, float* bones, uint16_t* bone_count) {
if (lum < 0.45f) { std::lock_guard<std::mutex> lock(g_mu);
const float boost = 0.6f / (lum + 1e-4f); if (index >= g_ghosts.size()) {
color[0] = std::min(1.0f, color[0] * boost); return false;
color[1] = std::min(1.0f, color[1] * boost); }
color[2] = std::min(1.0f, color[2] * boost); 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<uint16_t>(gh.bone_count, max_bones);
*bone_count = rows;
if (rows != 0) {
std::memcpy(bones, gh.bones, size_t(rows) * 12 * sizeof(float));
} }
return true; return true;
} }

View file

@ -24,11 +24,21 @@
#include "skate3_native_ghost_proto.h" #include "skate3_native_ghost_proto.h"
namespace skate3::native_scene {
struct FrameScene;
} // namespace skate3::native_scene
namespace skate3::native_ghost { namespace skate3::native_ghost {
// ---- host-side capture (called from skate3_native_render.cpp) ------------- // ---- host-side capture (called from skate3_native_render.cpp) -------------
void OnLocalSkaterTick(uint8_t* base); 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 ------------------------------------------------- // ---- network thread lifecycle -------------------------------------------------
void EnsureNetworkThread(); void EnsureNetworkThread();
void Shutdown(); void Shutdown();
@ -51,6 +61,12 @@ bool GetStatus(Status* out);
size_t GhostCount(); size_t GhostCount();
// Row-vector world matrix (translation in row 3) + a stable color per ghost. // Row-vector world matrix (translation in row 3) + a stable color per ghost.
bool GetGhost(uint32_t index, float world[16], float color[3]); 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). // Self-check cube at the local captured pose (skate3_ghost_draw_local).
bool GetLocalWorld(float world[16]); bool GetLocalWorld(float world[16]);

View file

@ -25,6 +25,11 @@ constexpr uint8_t kGhostVersion = 1;
constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint16_t kGhostDefaultPort = 40001;
constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoomLen = 8;
constexpr uint32_t kGhostMaxRoster = 32; 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). // Placeholder default public relay: replace in ONE place (skate3_native_ghost.cpp).
constexpr const char* kGhostDefaultServer = "ghost.skate3.example"; constexpr const char* kGhostDefaultServer = "ghost.skate3.example";
constexpr uint16_t kGhostDefaultServerPort = 40001; constexpr uint16_t kGhostDefaultServerPort = 40001;
@ -61,16 +66,27 @@ struct alignas(1) HelloPayload {
uint32_t roster[kGhostMaxRoster]; 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 { struct alignas(1) PosePayload {
uint32_t session_id; // set by the relay on forward (the sender's id) uint32_t session_id; // set by the relay on forward (the sender's id)
uint32_t seq; // sender monotonic sequence uint32_t seq; // sender monotonic sequence
double timestamp; // sender monotonic seconds (informational) 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) 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 uint8_t kPoseFlagsBones = 1;
constexpr uint16_t kGhostMaxPayload = 256;
// Largest payload: PosePayload (32 + 3072 = 3104 bytes).
constexpr uint16_t kGhostMaxPayload = 3200;
struct alignas(1) PacketBuf { struct alignas(1) PacketBuf {
PacketHeader h; PacketHeader h;

View file

@ -184,9 +184,15 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p,
RoomSet(me->room, me->room_len, kGhostLobbyRoom); RoomSet(me->room, me->room_len, kGhostLobbyRoom);
} }
me->last_seen = now; me->last_seen = now;
PosePayload pose = {}; // Forward the payload VERBATIM to the other room members, re-stamping
std::memcpy(&pose, p.payload, sizeof(pose)); // only the sender session id at payload offset 0 (little-endian). The
pose.session_id = me->session_id; // 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_) { for (const Client& c : clients_) {
if (&c == me) { if (&c == me) {
continue; 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)) { !RoomEquals(c.room, c.room_len, me->room, me->room_len)) {
continue; continue;
} }
BuildPacket(out, kTypePose, &pose, static_cast<uint16_t>(sizeof(pose))); GhostSockSendTo(sock_, c.addr, &out, size);
GhostSockSendTo(sock_, c.addr, &out, PacketSize(out));
} }
break; break;
} }

View file

@ -38,6 +38,7 @@
#include "native/skate3_native_diag.h" #include "native/skate3_native_diag.h"
#include "native/skate3_native_entity.h" #include "native/skate3_native_entity.h"
#include "native/skate3_native_ghost.h"
#include "native/skate3_native_guest_read.h" #include "native/skate3_native_guest_read.h"
#include "native/skate3_native_lw.h" #include "native/skate3_native_lw.h"
#include "native/skate3_native_palette.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()) std::chrono::steady_clock::now().time_since_epoch())
.count(), .count(),
std::memory_order_relaxed); 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<std::mutex> lock(g_scene_mutex); std::lock_guard<std::mutex> lock(g_scene_mutex);
scene.generation = ++g_generation; scene.generation = ++g_generation;
g_scene = std::make_shared<const FrameScene>(std::move(scene)); g_scene = std::make_shared<const FrameScene>(std::move(scene));

View file

@ -10342,11 +10342,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize; uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize;
uint32_t ui_offset = 0; uint32_t ui_offset = 0;
// ---- Ghost multiplayer placeholder cubes (native/skate3_native_ghost) ---- // ---- Ghost multiplayer (native/skate3_native_ghost) ---------------------
// Solid-color unit cubes at the remote players' captured world positions, // Full-character ghosts: each remote player's skater skeleton (broadcast
// drawn inside the scene pass (depth-tested against the world like the // in the POSE packets) drives a CLONE of this player's own character
// spline pass). Reuses the existing main-pass PSO and the scene.hlsl // pieces - same meshes, same textures, same cached GPU decodes, remote
// tint.a > 0 solid-color branch: no new shader, no SPIR-V regeneration. // 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 && if (REXCVAR_GET(skate3_ghost_enable) && g_r.pso != nullptr &&
g_r.ui_ring_cpu != nullptr && debug_mode == 0) { g_r.ui_ring_cpu != nullptr && debug_mode == 0) {
const uint32_t n_ghosts = skate3::native_ghost::GhostCount(); const uint32_t n_ghosts = skate3::native_ghost::GhostCount();
@ -10355,21 +10356,81 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
REXCVAR_GET(skate3_ghost_draw_local) && REXCVAR_GET(skate3_ghost_draw_local) &&
skate3::native_ghost::GetLocalWorld(local_world); skate3::native_ghost::GetLocalWorld(local_world);
if (n_ghosts != 0 || draw_local) { 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<const DrawItem*> 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 = &it;
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; constexpr uint32_t kCubeVerts = 36;
const uint32_t kVertBytes = const uint32_t kVertBytes =
uint32_t(sizeof(skate3::native_ghost::GhostCubeVertex)); uint32_t(sizeof(skate3::native_ghost::GhostCubeVertex));
const uint32_t kCubeBytes = kCubeVerts * kVertBytes; 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, std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset,
skate3::native_ghost::CubeVertices(), kCubeBytes); 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; ui_offset += kCubeBytes;
}
cmd->SetPipeline(g_r.pso); cmd->SetPipeline(g_r.pso);
cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes, cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes,
kVertBytes); kVertBytes);
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
cmd->SetTexture(1, g_r.white.srv); cmd->SetTexture(1, g_r.white.srv);
const auto draw_ghost = [&](const float world[16], const float color[3]) {
const auto draw_cube = [&](const float world[16], const float color[3]) {
if (!cube_uploaded) {
return;
}
float constants[52] = {}; float constants[52] = {};
std::memcpy(constants, world, sizeof(float) * 16); std::memcpy(constants, world, sizeof(float) * 16);
float* mvp = constants + 16; float* mvp = constants + 16;
@ -10393,17 +10454,62 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
cmd->SetRootConstants(0, 52, constants, 0); cmd->SetRootConstants(0, 52, constants, 0);
cmd->Draw(kCubeVerts, 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<uint32_t>(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) { for (uint32_t i = 0; i < n_ghosts; ++i) {
float world[16]; float pose_scratch[skate3::native_ghost::kGhostMaxBones * 12];
float color[3]; uint16_t ghost_rows = 0;
if (skate3::native_ghost::GetGhost(i, world, color)) { if (skate3::native_ghost::GetGhostPose(
draw_ghost(world, color); 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) { if (draw_local) {
const float local_color[3] = {0.2f, 1.0f, 0.2f}; const float local_color[3] = {0.2f, 1.0f, 0.2f};
draw_ghost(local_world, local_color); draw_cube(local_world, local_color);
}
} }
} }
} }