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:
parent
269fbcee32
commit
bfb504db7d
6 changed files with 323 additions and 65 deletions
|
|
@ -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 = ⁢
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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,55 +10356,160 @@ 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 = ⁢
|
||||||
|
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->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes,
|
cmd->SetPipeline(g_r.pso);
|
||||||
kVertBytes);
|
cmd->SetVertexBuffer(g_r.ui_ring, ui_region + cube_offset, kCubeBytes,
|
||||||
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
|
kVertBytes);
|
||||||
cmd->SetTexture(1, g_r.white.srv);
|
cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList);
|
||||||
const auto draw_ghost = [&](const float world[16], const float color[3]) {
|
cmd->SetTexture(1, g_r.white.srv);
|
||||||
float constants[52] = {};
|
|
||||||
std::memcpy(constants, world, sizeof(float) * 16);
|
const auto draw_cube = [&](const float world[16], const float color[3]) {
|
||||||
float* mvp = constants + 16;
|
if (!cube_uploaded) {
|
||||||
for (int r = 0; r < 4; ++r) {
|
return;
|
||||||
for (int c = 0; c < 4; ++c) {
|
}
|
||||||
float sum = 0.0f;
|
float constants[52] = {};
|
||||||
for (int k = 0; k < 4; ++k) {
|
std::memcpy(constants, world, sizeof(float) * 16);
|
||||||
sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c];
|
float* mvp = constants + 16;
|
||||||
}
|
for (int r = 0; r < 4; ++r) {
|
||||||
mvp[r * 4 + c] = sum;
|
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;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (draw_local) {
|
constants[32] = color[0];
|
||||||
const float local_color[3] = {0.2f, 1.0f, 0.2f};
|
constants[33] = color[1];
|
||||||
draw_ghost(local_world, local_color);
|
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<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) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue