From 36b5169e27ab6e86b0aa2556a9319e9df352a96d Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Wed, 26 Aug 2026 21:17:43 -0400 Subject: [PATCH] ghost multiplayer: cloth on ghosts + player nameplates Phase 4: - Cloth: remote ghosts now wear this player's cloth garments (tee/pants). The local player's rigid character.cloth_ropa items (CPU-sim deformed root-local VB) are cloned per ghost and drawn with the REMOTE entity world from the pose, so each ghost is draped in its own root frame. No mesh or cloth data is transmitted; the shader already applies item.world to the rigid cloth path (zero blend weights). Sim-inactive (skinned) garments and garments not near the local body are skipped. - Nameplates: display names ride the wire. JOIN carries a display name (skate3_ghost_name cvar, default the OS user name); the relay stores it per client and echoes {session_id, name} pairs in the HELLO roster (RosterEntry). Clients re-JOIN every 5 s so the roster/names stay current as members join and leave. A GhostNameplateOverlay (always registered, draws only while enabled) projects each ghost's entity origin +1.75 m through the published camera and draws a colored name label above it. Uses skate3::native_scene::GetPublishedCamera (new thread-safe snapshot of the published view-proj/cam) so the UI thread can project world points. - GhostDialog gains a display-name field. Protocol version bumped to 3. --- src/native/skate3_native_ghost.cpp | 95 +++++++++++++++++++++++- src/native/skate3_native_ghost.h | 6 ++ src/native/skate3_native_ghost_proto.h | 20 ++++- src/native/skate3_native_ghost_relay.cpp | 16 +++- src/native/skate3_native_ghost_relay.h | 2 + src/skate3_app_common.cpp | 3 + src/skate3_app_common.h | 1 + src/skate3_native_ghost_dialog.cpp | 71 ++++++++++++++++++ src/skate3_native_ghost_dialog.h | 22 +++++- src/skate3_native_scene.cpp | 10 +++ src/skate3_native_scene.h | 6 ++ src/skate3_native_scene_gpu.cpp | 41 +++++++++- 12 files changed, 278 insertions(+), 15 deletions(-) diff --git a/src/native/skate3_native_ghost.cpp b/src/native/skate3_native_ghost.cpp index 6ebd8f3..64d3892 100644 --- a/src/native/skate3_native_ghost.cpp +++ b/src/native/skate3_native_ghost.cpp @@ -1,10 +1,12 @@ #include "skate3_native_ghost.h" #include +#include #include #include #include #include +#include #include #include #include @@ -25,6 +27,10 @@ #include #include +#if defined(_WIN32) +#include +#endif + // ---- cvars ----------------------------------------------------------------- // The default public relay lives in ghost_proto.h (kGhostDefaultServer); // leave skate3_ghost_server empty to use it. Set it to reach a custom relay @@ -50,6 +56,10 @@ 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_STRING(skate3_ghost_name, "", "Skate 3", + "Display name shown on this player's nameplate above " + "the ghost. Empty = derived from the OS user name.") + .lifecycle(rex::cvar::Lifecycle::kHotReload); 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).") @@ -207,6 +217,10 @@ uint8_t g_room_len = 0; size_t g_roster = 0; char g_peer[96] = {}; +// Display names of the room members, keyed by their session id (from the +// latest HELLO roster; refreshed by the periodic re-JOIN). g_mu-guarded. +std::unordered_map> g_roster_names; + // Network thread. std::thread g_thread; std::atomic g_stop{false}; @@ -250,6 +264,42 @@ bool Enabled() { return REXCVAR_GET(skate3_ghost_enable); } bool Debug() { return REXCVAR_GET(skate3_ghost_debug); } +// This player's display name (cvar override or the OS user name). +void CurrentDisplayName(char out[kGhostMaxNameLen], uint8_t* out_len) { + std::string name = REXCVAR_GET(skate3_ghost_name); + if (name.empty()) { + const char* user = nullptr; +#if defined(_WIN32) + static char s_win_user[64] = {}; + if (s_win_user[0] == '\0') { + DWORD n = sizeof(s_win_user); + if (GetUserNameA(s_win_user, &n) == 0) { + std::snprintf(s_win_user, sizeof(s_win_user), "Player"); + } + } + user = s_win_user; +#else + user = std::getenv("USER"); +#endif + if (user != nullptr && user[0] != '\0') { + name = user; + } else { + name = "Player"; + } + } + uint8_t len = 0; + for (char c : name) { + if (len >= kGhostMaxNameLen) { + break; + } + if (c < 0x20 || c == 0x7F) { + continue; // printable ASCII only (payload is a raw byte array) + } + out[len++] = c; + } + *out_len = len; +} + void CopyRoomFromPayload(const char* room, uint8_t len) { std::lock_guard lock(g_mu); g_room_len = std::min(len, kGhostMaxRoomLen); @@ -271,6 +321,9 @@ void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) { std::memcpy(jp.room, g_room, g_room_len); jp.room_len = g_room_len; } + // Display name: JOIN carries it so the relay can echo it in the roster; + // the LAN discovery PING carries it too (a host can tag probes). + CurrentDisplayName(jp.name, &jp.name_len); PacketBuf p; BuildPacket(p, ping ? kTypePing : kTypeJoin, &jp, sizeof(jp)); GhostSockSendTo(sock, to, &p, PacketSize(p)); @@ -422,6 +475,17 @@ void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) { std::lock_guard lock(g_mu); g_roster = hp.roster_count; g_connected = true; + // Refresh the room members' display names (nameplate labels). + g_roster_names.clear(); + for (uint8_t i = 0; i < hp.roster_count; ++i) { + const RosterEntry& e = hp.roster[i]; + std::array name = {}; + // Reserve one byte for the NUL so the label can always snprintf. + const uint8_t n = + std::min(e.name_len, kGhostMaxNameLen - 1); + std::memcpy(name.data(), e.name, n); + g_roster_names[e.session_id] = name; + } } SetStatusPeer(from); if (Debug()) { @@ -494,6 +558,7 @@ void ResetSession() { g_ghosts.clear(); g_ghost_index.clear(); g_pending_poses.clear(); + g_roster_names.clear(); g_session_public = 0; g_session_lan = 0; g_peer_public = Peer{}; @@ -598,9 +663,11 @@ void GhostNetworkLoop() { last_port = port; } - // Public relay: JOIN every kJoinIntervalSec until HELLO, then send POSE. + // Public relay: JOIN every kJoinIntervalSec (a periodic refresh that also + // keeps the roster/name labels current as members join and leave), and + // reconnect when the HELLO stream goes quiet. if (g_peer_public.configured) { - if (!g_peer_public.joined && now - g_peer_public.last_join >= kJoinIntervalSec) { + if (now - g_peer_public.last_join >= kJoinIntervalSec) { SendJoin(sock, g_peer_public.addr, /*ping=*/false); g_peer_public.last_join = now; } @@ -647,7 +714,7 @@ void GhostNetworkLoop() { // The LAN peer (a discovered host, or our own loopback host): JOIN + POSE. if (g_peer_lan.configured) { - if (!g_peer_lan.joined && now - g_peer_lan.last_join >= kJoinIntervalSec) { + if (now - g_peer_lan.last_join >= kJoinIntervalSec) { SendJoin(sock, g_peer_lan.addr, /*ping=*/false); g_peer_lan.last_join = now; } @@ -869,6 +936,28 @@ bool GetLocalWorld(float world[16]) { return true; } +bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, + size_t name_max) { + std::lock_guard lock(g_mu); + if (index >= g_ghosts.size()) { + return false; + } + const Ghost& gh = g_ghosts[index]; + pos[0] = gh.world[12]; + pos[1] = gh.world[13]; + pos[2] = gh.world[14]; + GhostColor(gh.session_id, color); + if (name != nullptr && name_max > 0) { + const auto it = g_roster_names.find(gh.session_id); + if (it != g_roster_names.end() && it->second[0] != '\0') { + std::snprintf(name, name_max, "%s", it->second.data()); + } else { + std::snprintf(name, name_max, "P%u", gh.session_id); + } + } + return true; +} + // ---- cube geometry ----------------------------------------------------------- // Unit cube centered at the origin, half-extent 0.5, 12 triangles as a // non-indexed triangle list (36 vertices). All attribute lanes except the diff --git a/src/native/skate3_native_ghost.h b/src/native/skate3_native_ghost.h index 2edd69f..d3e6954 100644 --- a/src/native/skate3_native_ghost.h +++ b/src/native/skate3_native_ghost.h @@ -70,6 +70,12 @@ bool GetGhostPose(uint32_t index, float world[16], float color[3], // Self-check cube at the local captured pose (skate3_ghost_draw_local). bool GetLocalWorld(float world[16]); +// Lightweight per-ghost query for the nameplate overlay: the ghost's world +// position (entity world), marker color, and display name (from the room +// roster, or a session fallback). Returns false if the ghost is gone. +bool GetGhostLabel(uint32_t index, float pos[3], float color[3], char* name, + size_t name_max); + // ---- cube geometry (56-byte records matching kSceneInputLayout) ----------------- struct GhostCubeVertex { float pos[3]; diff --git a/src/native/skate3_native_ghost_proto.h b/src/native/skate3_native_ghost_proto.h index 508321f..d458527 100644 --- a/src/native/skate3_native_ghost_proto.h +++ b/src/native/skate3_native_ghost_proto.h @@ -23,10 +23,11 @@ namespace skate3::native_ghost { constexpr uint32_t kGhostMagic = 0x54484753u; // "GHST" (LE) -constexpr uint8_t kGhostVersion = 2; +constexpr uint8_t kGhostVersion = 3; constexpr uint16_t kGhostDefaultPort = 40001; constexpr uint32_t kGhostMaxRoomLen = 8; constexpr uint32_t kGhostMaxRoster = 32; +constexpr uint32_t kGhostMaxNameLen = 16; // display name, UTF-8-safe ASCII // 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 @@ -55,18 +56,29 @@ struct alignas(1) PacketHeader { }; // JOIN and PING share the room payload (PING may carry the probing client's -// room so LAN hosts can answer only matching rooms). +// room so LAN hosts can answer only matching rooms); JOIN additionally +// carries the client's display name, which the relay echoes to room members +// in the HELLO roster so nameplates can label every ghost. struct alignas(1) JoinPayload { uint8_t room_len; char room[kGhostMaxRoomLen]; + uint8_t name_len; + char name[kGhostMaxNameLen]; +}; + +// One room member as reported in a HELLO roster: session id + display name. +struct alignas(1) RosterEntry { + uint32_t session_id; + uint8_t name_len; + char name[kGhostMaxNameLen]; }; struct alignas(1) HelloPayload { uint32_t session_id; // our assigned session id (0 = refused) uint8_t room_len; char room[kGhostMaxRoomLen]; - uint8_t roster_count; // other members' session ids in this room - uint32_t roster[kGhostMaxRoster]; + uint8_t roster_count; // other members in this room + RosterEntry roster[kGhostMaxRoster]; }; // Full-pose blob (the character-model path): the sender's skater skeleton, diff --git a/src/native/skate3_native_ghost_relay.cpp b/src/native/skate3_native_ghost_relay.cpp index a7b986c..181ed60 100644 --- a/src/native/skate3_native_ghost_relay.cpp +++ b/src/native/skate3_native_ghost_relay.cpp @@ -126,6 +126,12 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, std::memcpy(me->room, jp.room, jp.room_len); me->room_len = jp.room_len; } + // Keep the client's display name (JOIN carries it; PINGs from probes + // may omit it, so only overwrite with a non-empty name). + if (jp.name_len != 0 && jp.name_len <= kGhostMaxNameLen) { + std::memcpy(me->name, jp.name, jp.name_len); + me->name_len = jp.name_len; + } // Empty room: assign one (uppercase alnum, 4 chars, seeded by the // session id and the wall of the receiving thread). if (me->room_len == 0) { @@ -152,7 +158,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, if (&c != me && c.room_len == me->room_len && RoomEquals(c.room, c.room_len, me->room, me->room_len) && hp.roster_count < kGhostMaxRoster) { - hp.roster[hp.roster_count++] = c.session_id; + RosterEntry& entry = hp.roster[hp.roster_count++]; + entry.session_id = c.session_id; + entry.name_len = c.name_len; + std::memcpy(entry.name, c.name, c.name_len); } } BuildPacket(out, kTypeHello, &hp, static_cast(sizeof(hp))); @@ -168,7 +177,10 @@ void GhostRelay::HandlePacket(const GhostAddr& from, const PacketBuf& p, if (&c != me && c.room_len == me->room_len && RoomEquals(c.room, c.room_len, me->room, me->room_len) && hp.roster_count < kGhostMaxRoster) { - hp.roster[hp.roster_count++] = c.session_id; + RosterEntry& entry = hp.roster[hp.roster_count++]; + entry.session_id = c.session_id; + entry.name_len = c.name_len; + std::memcpy(entry.name, c.name, c.name_len); } } BuildPacket(out, kTypeHello, &hp, static_cast(sizeof(hp))); diff --git a/src/native/skate3_native_ghost_relay.h b/src/native/skate3_native_ghost_relay.h index 9e55eed..bb67bb5 100644 --- a/src/native/skate3_native_ghost_relay.h +++ b/src/native/skate3_native_ghost_relay.h @@ -59,6 +59,8 @@ class GhostRelay { uint32_t session_id = 0; char room[kGhostMaxRoomLen] = {}; uint8_t room_len = 0; + char name[kGhostMaxNameLen] = {}; + uint8_t name_len = 0; double last_seen = 0.0; }; mutable std::mutex mu_; diff --git a/src/skate3_app_common.cpp b/src/skate3_app_common.cpp index bf66071..ef89153 100644 --- a/src/skate3_app_common.cpp +++ b/src/skate3_app_common.cpp @@ -683,6 +683,8 @@ void Skate3BaseApp::OnCreateDialogs(rex::ui::ImGuiDrawer* drawer) { // skate3_native_render_mode_indicator shows it live). Input-transparent, // so it never affects cursor or focus handling. render_mode_indicator_ = std::make_unique(drawer); + ghost_nameplate_overlay_ = + std::make_unique(drawer); rex::ui::RegisterBind("bind_skate3_menu", "Escape", "Skate 3 settings", [this] { // Escape backs out of the settings screen level by level (rows -> // category rail -> closed); when the screen is closed it opens it. @@ -835,6 +837,7 @@ void Skate3BaseApp::OnShutdown() { simple_settings_dialog_.reset(); native_debug_dialog_.reset(); ghost_dialog_.reset(); + ghost_nameplate_overlay_.reset(); render_mode_indicator_.reset(); } diff --git a/src/skate3_app_common.h b/src/skate3_app_common.h index c24f10e..a2bb75d 100644 --- a/src/skate3_app_common.h +++ b/src/skate3_app_common.h @@ -62,6 +62,7 @@ class Skate3BaseApp : public rex::ReXApp { std::unique_ptr simple_settings_dialog_; std::unique_ptr native_debug_dialog_; std::unique_ptr ghost_dialog_; + std::unique_ptr ghost_nameplate_overlay_; std::unique_ptr render_mode_indicator_; bool recipe_overlay_installed_ = false; bool big_device_aliases_installed_ = false; diff --git a/src/skate3_native_ghost_dialog.cpp b/src/skate3_native_ghost_dialog.cpp index 9f0a38b..9018c91 100644 --- a/src/skate3_native_ghost_dialog.cpp +++ b/src/skate3_native_ghost_dialog.cpp @@ -1,5 +1,6 @@ #include "skate3_native_ghost_dialog.h" +#include #include #include @@ -9,11 +10,13 @@ #include #include "native/skate3_native_ghost.h" +#include "skate3_native_scene.h" REXCVAR_DECLARE(bool, skate3_ghost_enable); REXCVAR_DECLARE(std::string, skate3_ghost_server); REXCVAR_DECLARE(int32_t, skate3_ghost_port); REXCVAR_DECLARE(std::string, skate3_ghost_room); +REXCVAR_DECLARE(std::string, skate3_ghost_name); REXCVAR_DECLARE(double, skate3_ghost_rate); REXCVAR_DECLARE(bool, skate3_ghost_draw_local); @@ -45,6 +48,8 @@ void GhostDialog::SyncBuffersFromCvars() { std::snprintf(room_input_, sizeof(room_input_), "%s", room.c_str()); const std::string server = REXCVAR_GET(skate3_ghost_server); std::snprintf(server_input_, sizeof(server_input_), "%s", server.c_str()); + const std::string name = REXCVAR_GET(skate3_ghost_name); + std::snprintf(name_input_, sizeof(name_input_), "%s", name.c_str()); synced_ = true; } @@ -94,6 +99,17 @@ void GhostDialog::OnDraw(ImGuiIO& io) { ImGui::Separator(); + ImGui::Text("Display name"); + if (ImGui::InputText("##ghost_name", name_input_, sizeof(name_input_), + ImGuiInputTextFlags_EnterReturnsTrue)) { + REXCVAR_SET(skate3_ghost_name, std::string(name_input_)); + ImGui::SetKeyboardFocusHere(-1); + } + ImGui::TextDisabled("Shown on your nameplate above the ghost. Empty = " + "your OS user name."); + + ImGui::Separator(); + ImGui::Text("Relay server (optional)"); ImGui::PushItemWidth(-1.0f); if (ImGui::InputText("##ghost_server", server_input_, sizeof(server_input_), @@ -144,4 +160,59 @@ void GhostDialog::OnDraw(ImGuiIO& io) { } } +void GhostNameplateOverlay::OnDraw(ImGuiIO& io) { + if (!REXCVAR_GET(skate3_ghost_enable)) { + return; + } + if (io.DisplaySize.x < 16.0f || io.DisplaySize.y < 16.0f) { + return; + } + float vp[16]; + float cam[3]; + if (!skate3::native_scene::GetPublishedCamera(vp, cam)) { + return; + } + const uint32_t count = skate3::native_ghost::GhostCount(); + if (count == 0) { + return; + } + ImDrawList* dl = ImGui::GetBackgroundDrawList(); + const float w = io.DisplaySize.x; + const float h = io.DisplaySize.y; + for (uint32_t i = 0; i < count; ++i) { + float pos[3]; + float color[3]; + char name[32]; + if (!skate3::native_ghost::GetGhostLabel(i, pos, color, name, + sizeof(name))) { + continue; + } + // Project the point ~1.75 m above the entity origin (roughly the head). + const float p[3] = {pos[0], pos[1] + 1.75f, pos[2]}; + float c[4]; + for (int r = 0; r < 4; ++r) { + c[r] = p[0] * vp[r] + p[1] * vp[4 + r] + p[2] * vp[8 + r] + vp[12 + r]; + } + if (c[3] < 0.01f) { + continue; // behind the camera + } + const float ndc_x = c[0] / c[3]; + const float ndc_y = c[1] / c[3]; + if (ndc_x < -1.6f || ndc_x > 1.6f || ndc_y < -1.6f || ndc_y > 1.6f) { + continue; + } + const float sx = (ndc_x * 0.5f + 0.5f) * w; + const float sy = (1.0f - (ndc_y * 0.5f + 0.5f)) * h; + const ImU32 text_col = + IM_COL32(int(color[0] * 255.0f), int(color[1] * 255.0f), + int(color[2] * 255.0f), 235); + const ImVec2 text_size = ImGui::CalcTextSize(name); + // Small dark backdrop so the label stays readable over the world. + dl->AddRectFilled(ImVec2(sx - 4.0f, sy - 14.0f), + ImVec2(sx + text_size.x + 4.0f, sy + 2.0f), + IM_COL32(0, 0, 0, 140)); + dl->AddText(ImVec2(sx, sy - 14.0f), text_col, name); + } +} + } // namespace skate3 \ No newline at end of file diff --git a/src/skate3_native_ghost_dialog.h b/src/skate3_native_ghost_dialog.h index f3d2015..ec9355d 100644 --- a/src/skate3_native_ghost_dialog.h +++ b/src/skate3_native_ghost_dialog.h @@ -1,12 +1,13 @@ #pragma once -// Ghost multiplayer menu: enable toggle, room-code entry, relay override and -// a live connection status readout. A player-facing dialog (bound to M by -// default in skate3_app_common.cpp); everything it edits is a hot-reload -// cvar in skate3_native_ghost.cpp. +// Ghost multiplayer UI: the multiplayer menu (GhostDialog, bound to M) and +// the always-present nameplate overlay (GhostNameplateOverlay) that labels +// each remote ghost with its owner's display name. #include +#include "native/skate3_native_ghost_proto.h" + namespace skate3 { class GhostDialog final : public rex::ui::ImGuiDialog { @@ -28,7 +29,20 @@ class GhostDialog final : public rex::ui::ImGuiDialog { bool synced_ = false; char room_input_[16] = {}; char server_input_[128] = {}; + char name_input_[skate3::native_ghost::kGhostMaxNameLen + 1] = {}; int rate_int_ = 20; }; +// Draws a small colored nameplate above every remote ghost whenever ghost +// multiplayer is enabled. Registers like the render-mode indicator (always +// present, draws only while active). +class GhostNameplateOverlay final : public rex::ui::ImGuiDialog { + public: + explicit GhostNameplateOverlay(rex::ui::ImGuiDrawer* drawer) + : ImGuiDialog(drawer) {} + + protected: + void OnDraw(ImGuiIO& io) override; +}; + } // namespace skate3 \ No newline at end of file diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index d5d38c3..bc68aaf 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -2756,6 +2756,16 @@ bool ToggleSceneEnabled() { return enabled; } +bool GetPublishedCamera(float view_proj[16], float cam_pos[3]) { + std::lock_guard lock(g_scene_mutex); + if (!g_scene) { + return false; + } + std::memcpy(view_proj, g_scene->view_proj, sizeof(float) * 16); + std::memcpy(cam_pos, g_scene->cam_pos, sizeof(float) * 3); + return true; +} + void OnRegisterTexture(uint64_t guid, uint32_t texture) { if (texture == 0) { return; diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index f90267e..4d9652d 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -689,6 +689,12 @@ void Install(); // layer was not enabled at boot. bool ToggleSceneEnabled(); +// Snapshot of the latest published frame's camera (smoothed view-proj + +// world position). Thread-safe (any thread); returns false before the first +// publish. Used by the ghost nameplate overlay to project world positions +// into screen space on the UI thread. +bool GetPublishedCamera(float view_proj[16], float cam_pos[3]); + // True once the scene renderer has hit an unrecoverable pipeline/resource // failure and yields to the emulated output until re-enabled. Surfaced so // the render-mode indicator can show the degraded state even though the diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp index 1f0ad0f..946e286 100644 --- a/src/skate3_native_scene_gpu.cpp +++ b/src/skate3_native_scene_gpu.cpp @@ -10360,6 +10360,11 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat // skinned non-ropa, non-LivingWorld items), found the same way the // ghost capture on the game thread finds the body. std::vector player_pieces; + // The local player's cloth garments (rigid character.cloth_ropa items: + // CPU-sim deformed ROOT-LOCAL VB, drawn with the entity world affine). + // Cloned for ghosts and rendered with the REMOTE entity world so the + // ghost keeps its tee/pants, draped to its root frame. + std::vector ghost_cloth; { const DrawItem* body = nullptr; uint32_t best_bones = 0; @@ -10404,6 +10409,19 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat player_pieces.push_back(&it); } } + // Cloth garments near the body (world translation = the entity + // position; only the rigid state has a root-local deformed VB). + for (const DrawItem& it : scene.items) { + if (!it.ropa || !it.bones.empty()) { + continue; + } + const float px = it.world[12], py = it.world[13], pz = it.world[14]; + const float dx = px - body_p[0], dy = py - body_p[1], + dz = pz - body_p[2]; + if (dx * dx + dy * dy + dz * dz <= 6.25f) { + ghost_cloth.push_back(&it); + } + } } } @@ -10455,7 +10473,8 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat cmd->Draw(kCubeVerts, 0); }; - const auto draw_model = [&](const float* pose_rows, uint16_t pose_count) { + const auto draw_model = [&](const float* pose_rows, uint16_t pose_count, + const float* ghost_world) { if (player_pieces.empty()) { return; } @@ -10489,6 +10508,24 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat g.lw_alpha = -1.0f; timed_draw(g); } + // Cloth: clone the local player's garments (root-local deformed VB) + // and draw them with the REMOTE entity world, so each ghost wears + // this player's tee/pants draped in its own root frame. + for (const DrawItem* cloth : ghost_cloth) { + DrawItem g = *cloth; + std::memcpy(g.world, ghost_world, sizeof(g.world)); + g.bones.clear(); + g.skinned = 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]; @@ -10501,7 +10538,7 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat uint16_t(skate3::native_ghost::kGhostMaxBones), pose_scratch, &ghost_rows)) { if (ghost_rows != 0) { - draw_model(pose_scratch, ghost_rows); + draw_model(pose_scratch, ghost_rows, ghost_world); continue; } draw_cube(ghost_world, ghost_color);