skate3recomp-multiplayer/src/native/skate3_native_ghost.cpp
MiTHRAL 269fbcee32 Add ghost multiplayer: UDP relay + pose broadcasting + placeholder cubes
Bolt-on multiplayer layer confined to src/native and hand-written code (no
codegen output touched):

- GhostRelay core (src/native/skate3_native_ghost_relay.*): small UDP relay
  matching clients into rooms by a shareable code; forwards each client's
  pose to the other room members. Used by both the standalone binary and
  the in-game LAN host fallback.
- Standalone server (tools/ghost_relay_main.cpp, ghost_relay target): runs
  on a public VPS or behind a free tunnel; clients connect OUTBOUND, so no
  port forwarding is needed on any client.
- In-game module (skate3_native_ghost.*): captures the local player's world
  transform at Sk8::UpdateBoneTransforms exit (pack-owner entity +416,
  verified layout) and broadcasts it at a fixed rate; auto-connects to the
  default public relay, falls back to LAN auto-host + UDP broadcast
  discovery when unreachable.
- Renderer: draws a solid-color cube per remote player in RenderScene via
  the existing scene PSO tint.a>0 branch (no new shader/SPIR-V).
- UI: GhostDialog (M key) with enable toggle, room code, relay override,
  status readout. Off by default.

Wire protocol in skate3_native_ghost_proto.h (JOIN/HELLO/POSE/PING/LEAVE).
2026-08-26 20:12:45 -04:00

724 lines
No EOL
23 KiB
C++

#include "skate3_native_ghost.h"
#include <algorithm>
#include <atomic>
#include <bit>
#include <chrono>
#include <cmath>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "skate3_native_ghost_net.h"
#include "skate3_native_ghost_relay.h"
#include "skate3_native_guest_read.h"
#include "skate3_native_palette.h"
#include "generated/skate3_init.h"
#include <rex/cvar.h>
#include <rex/logging.h>
// ---- 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
// (your own VPS, a friend's, or a free tunnel like playit.gg / ngrok).
REXCVAR_DEFINE_BOOL(skate3_ghost_enable, false, "Skate 3",
"Enable ghost multiplayer: relay this player's position and "
"render remote players as placeholder cubes. Off by default; "
"no packets are sent while off.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_STRING(skate3_ghost_server, "", "Skate 3",
"Ghost relay 'host[:port]' (empty = the built-in default "
"public relay). Any relay on the internet works; clients "
"only connect outbound, so no port forwarding is needed.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(skate3_ghost_port, int32_t(skate3::native_ghost::kGhostDefaultPort),
"Skate 3",
"UDP port for the ghost relay (outbound connect + LAN "
"discovery/host).")
.range(1024, 65535)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_STRING(skate3_ghost_room, "", "Skate 3",
"Room code (4-8 letters/digits). Empty = the server "
"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.")
.range(1.0, 60.0)
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_lan, true, "Skate 3",
"LAN auto-discovery + auto-host fallback when the public "
"relay is unreachable (broadcast PING/HELLO on the ghost "
"port).")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_draw_local, false, "Skate 3",
"Draw a cube at this player's captured pose (self-check "
"that the transform capture works without any peer).")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_ghost_debug, false, "Skate 3",
"Verbose ghost multiplayer log lines.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
namespace skate3::native_ghost {
namespace {
using Clock = std::chrono::steady_clock;
double NowSec() {
return std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
}
// ---- guest memory layouts (verified in skate3_native_entity.h) -------------
constexpr uint32_t kEntLtoWTrans = 416; // m_MatLtoWTrans (staged rows)
constexpr uint32_t kEntLtoW = 352; // m_MatLtoW (row-vector Matrix44)
bool MatrixPlausibleRowVector(const float m[16]) {
if (!(m[15] > 0.99f && m[15] < 1.01f)) {
return false;
}
for (int i = 0; i < 3; ++i) {
if (m[3 + i * 4] > 1e-3f || m[3 + i * 4] < -1e-3f) {
return false;
}
}
for (int i = 0; i < 3; ++i) {
const float t = m[12 + i];
if (t > 20000.0f || t < -20000.0f) {
return false;
}
}
for (int r = 0; r < 3; ++r) {
const float* row = m + r * 4;
const float n = row[0] * row[0] + row[1] * row[1] + row[2] * row[2];
if (!(n > 6.25e-6f && n < 160000.0f)) {
return false;
}
}
return true;
}
// entity+416 staged layout: rows r0..r2 (translation in component 3), tail
// row (0,0,0,1). Converted to the row-vector world used by DrawItem (and the
// cube), i.e. translation in row 3.
bool StagedToWorld(const float s[16], float world[16]) {
if (!(s[12] > -1e-3f && s[12] < 1e-3f && s[13] > -1e-3f && s[13] < 1e-3f &&
s[14] > -1e-3f && s[14] < 1e-3f && s[15] > 0.99f && s[15] < 1.01f)) {
return false;
}
for (int r = 0; r < 3; ++r) {
const float* row = s + r * 4;
const float n = row[0] * row[0] + row[1] * row[1] + row[2] * row[2];
if (!(n > 6.25e-6f && n < 160000.0f)) {
return false;
}
}
std::memset(world, 0, sizeof(float) * 16);
world[0] = s[0]; world[1] = s[1]; world[2] = s[2];
world[4] = s[4]; world[5] = s[5]; world[6] = s[6];
world[8] = s[8]; world[9] = s[9]; world[10] = s[10];
for (int i = 0; i < 3; ++i) {
const float t = s[3 + i * 4];
if (t > 20000.0f || t < -20000.0f) {
return false;
}
world[12 + i] = t;
}
world[15] = 1.0f;
return true;
}
bool ReadEntityWorld(uint8_t* base, uint32_t entity, float world[16]) {
uint32_t raw[16] = {};
if (skate3::native_scene::GuestTryCopy(raw, REX_RAW_ADDR(entity + kEntLtoWTrans),
sizeof(raw))) {
float s[16];
for (int i = 0; i < 16; ++i) {
s[i] = std::bit_cast<float>(__builtin_bswap32(raw[i]));
}
if (StagedToWorld(s, world)) {
return true;
}
}
if (skate3::native_scene::GuestTryCopy(raw, REX_RAW_ADDR(entity + kEntLtoW),
sizeof(raw))) {
float m[16];
for (int i = 0; i < 16; ++i) {
m[i] = std::bit_cast<float>(__builtin_bswap32(raw[i]));
}
if (MatrixPlausibleRowVector(m)) {
std::memcpy(world, m, sizeof(float) * 16);
return true;
}
}
return false;
}
// ---- shared state ----------------------------------------------------------
std::mutex g_mu;
// Local player pose (captured on the sim thread; consumed by the network
// thread and the render thread).
bool g_local_valid = false;
float g_local_world[16] = {};
double g_local_time = 0.0;
// Remote ghosts, keyed by the sender's session id.
struct Ghost {
uint32_t session_id = 0;
float world[16] = {};
double last_seen = 0.0;
};
std::vector<Ghost> g_ghosts;
std::unordered_map<uint32_t, size_t> g_ghost_index;
// Status snapshot.
bool g_connected = false;
bool g_hosting = false;
uint32_t g_session = 0;
char g_room[kGhostMaxRoomLen] = {};
uint8_t g_room_len = 0;
size_t g_roster = 0;
char g_peer[96] = {};
// Network thread.
std::thread g_thread;
std::atomic<bool> g_stop{false};
std::atomic<bool> g_started{false};
// Session ids we own (never rendered as ghosts).
uint32_t g_session_public = 0;
uint32_t g_session_lan = 0;
// One relay we are joined to (public relay or LAN host / loopback host).
struct Peer {
GhostAddr addr;
bool configured = false;
bool joined = false;
double last_join = 0.0;
double last_hello = 0.0;
};
Peer g_peer_public;
Peer g_peer_lan;
// LAN discovery / hosting.
std::unique_ptr<GhostRelay> g_relay;
bool g_lan_probing = false;
double g_lan_probe_since = 0.0;
double g_last_ping = 0.0;
// Pose pacing.
double g_pose_acc = 0.0;
uint32_t g_seq = 0;
constexpr double kJoinIntervalSec = 5.0;
constexpr double kHelloTimeoutSec = 8.0;
constexpr double kGhostStaleSec = 8.0;
constexpr double kLanProbeBeforeHostSec = 4.0;
bool Enabled() { return REXCVAR_GET(skate3_ghost_enable); }
bool Debug() { return REXCVAR_GET(skate3_ghost_debug); }
void CopyRoomFromPayload(const char* room, uint8_t len) {
std::lock_guard<std::mutex> lock(g_mu);
g_room_len = std::min<uint8_t>(len, kGhostMaxRoomLen);
std::memcpy(g_room, room, g_room_len);
}
void SetStatusPeer(const GhostAddr& a) {
const std::string s = GhostAddrStr(a);
std::lock_guard<std::mutex> lock(g_mu);
std::snprintf(g_peer, sizeof(g_peer), "%s", s.c_str());
}
// ---- packet send helpers ---------------------------------------------------
void SendJoin(GhostSock sock, const GhostAddr& to, bool ping) {
JoinPayload jp = {};
jp.room_len = 0;
{
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(jp.room, g_room, g_room_len);
jp.room_len = g_room_len;
}
PacketBuf p;
BuildPacket(p, ping ? kTypePing : kTypeJoin, &jp, sizeof(jp));
GhostSockSendTo(sock, to, &p, PacketSize(p));
}
void SendPose(GhostSock sock, const GhostAddr& to, uint32_t& seq) {
float world[16];
{
std::lock_guard<std::mutex> lock(g_mu);
if (!g_local_valid) {
return;
}
std::memcpy(world, g_local_world, sizeof(world));
}
PosePayload pp = {};
pp.session_id = 0; // filled by the relay
pp.seq = ++seq;
pp.timestamp = NowSec();
std::memcpy(pp.rot, world, 9 * sizeof(float));
std::memcpy(pp.pos, world + 12, 3 * sizeof(float));
PacketBuf p;
BuildPacket(p, kTypePose, &pp, sizeof(pp));
GhostSockSendTo(sock, to, &p, PacketSize(p));
}
// ---- packet receive --------------------------------------------------------
void HandlePacket(const GhostAddr& from, const PacketBuf& p, double now) {
if (p.h.type == kTypePose) {
PosePayload pp = {};
std::memcpy(&pp, p.payload, sizeof(pp));
{
std::lock_guard<std::mutex> lock(g_mu);
// Determine which relay this came from and skip our own pose echoes
// (session ids are per-relay, so a LAN session can collide with a
// public session; compare against the matching own session).
const bool from_public =
g_peer_public.configured && GhostAddrEqual(from, g_peer_public.addr);
const bool from_lan =
g_peer_lan.configured && GhostAddrEqual(from, g_peer_lan.addr);
const uint32_t own = from_public ? g_session_public
: from_lan ? g_session_lan
: 0;
if (pp.session_id == 0 || (own != 0 && pp.session_id == own)) {
return;
}
const bool finite =
std::isfinite(pp.pos[0]) && std::isfinite(pp.pos[1]) &&
std::isfinite(pp.pos[2]);
if (!finite) {
return;
}
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.last_seen = now;
} else if (g_ghosts.size() < 32) {
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.last_seen = now;
g_ghost_index[pp.session_id] = g_ghosts.size();
g_ghosts.push_back(gh);
}
}
return;
}
if (p.h.type == kTypeHello) {
HelloPayload hp = {};
std::memcpy(&hp, p.payload, sizeof(hp));
const bool from_public =
g_peer_public.configured && GhostAddrEqual(from, g_peer_public.addr);
const bool from_lan =
g_peer_lan.configured && GhostAddrEqual(from, g_peer_lan.addr);
if (from_public || from_lan) {
Peer& peer = from_public ? g_peer_public : g_peer_lan;
peer.joined = true;
peer.last_hello = now;
if (from_public) {
g_session_public = hp.session_id;
} else {
g_session_lan = hp.session_id;
}
g_session = hp.session_id;
CopyRoomFromPayload(hp.room, hp.room_len);
{
std::lock_guard<std::mutex> lock(g_mu);
g_roster = hp.roster_count;
g_connected = true;
}
SetStatusPeer(from);
if (Debug()) {
REXLOG_INFO("ghost: joined {} room={} roster={}", GhostAddrStr(from).c_str(),
std::string(hp.room, hp.room_len).c_str(), hp.roster_count);
}
return;
}
// A LAN host reply to our discovery PING (from != our configured peers):
// remember the host if its room matches ours (or we have no room yet).
// Only adopt a new host while we are not already joined to one (multiple
// LAN hosts would otherwise steal our active peer away).
const bool room_match =
RoomEquals(g_room, g_room_len, hp.room, hp.room_len) || g_room_len == 0;
if (room_match && !g_peer_public.joined && !g_hosting && !g_peer_lan.joined) {
g_peer_lan.addr = from;
g_peer_lan.configured = true;
g_peer_lan.last_hello = now;
// A proper JOIN is sent to the discovered host on the next tick.
g_peer_lan.joined = false;
g_peer_lan.last_join = 0.0;
}
return;
}
// PING / JOIN / PONG / LEAVE are relay-side concerns; a client ignores
// them (the in-process GhostRelay answers PINGs on its own socket).
}
void DrainSocket(GhostSock sock, double now) {
for (int i = 0; i < 32; ++i) {
GhostAddr from;
PacketBuf p;
const int n = GhostSockRecvFrom(sock, &from, &p, sizeof(p));
if (n <= 0) {
break;
}
if (n < static_cast<int>(sizeof(PacketHeader)) || p.h.magic != kGhostMagic ||
p.h.payload_size != static_cast<uint16_t>(n - sizeof(PacketHeader)) ||
p.h.payload_size > kGhostMaxPayload) {
continue;
}
HandlePacket(from, p, now);
}
}
void PruneGhosts(double now) {
std::lock_guard<std::mutex> lock(g_mu);
for (size_t i = 0; i < g_ghosts.size();) {
if (now - g_ghosts[i].last_seen > kGhostStaleSec) {
g_ghost_index.erase(g_ghosts[i].session_id);
g_ghosts[i] = g_ghosts.back();
g_ghosts.pop_back();
// Fix the moved entry's index (only if it wasn't the one we removed).
if (i < g_ghosts.size()) {
g_ghost_index[g_ghosts[i].session_id] = i;
}
} else {
++i;
}
}
}
void ResetSession() {
std::lock_guard<std::mutex> lock(g_mu);
g_connected = false;
g_session = 0;
g_roster = 0;
g_ghosts.clear();
g_ghost_index.clear();
g_session_public = 0;
g_session_lan = 0;
g_peer_public = Peer{};
g_peer_lan = Peer{};
if (g_relay) {
g_relay->Stop();
g_relay.reset();
}
g_hosting = false;
g_lan_probing = false;
g_lan_probe_since = 0.0;
g_last_ping = 0.0;
std::snprintf(g_peer, sizeof(g_peer), "%s", "");
}
// Start the in-process LAN relay (host mode).
bool StartHost(uint16_t port) {
if (g_relay) {
g_relay->Stop();
g_relay.reset();
}
auto relay = std::make_unique<GhostRelay>(port);
if (!relay->Start()) {
if (Debug()) {
REXLOG_WARN("ghost: LAN host bind failed on UDP {}", port);
}
return false;
}
g_relay = std::move(relay);
{
std::lock_guard<std::mutex> lock(g_mu);
g_hosting = true;
}
// Feed our own pose through the local relay so LAN members see us.
g_peer_lan.addr = GhostAddrLoopback(port);
g_peer_lan.configured = true;
g_peer_lan.joined = false;
g_peer_lan.last_join = 0.0;
if (Debug()) {
REXLOG_INFO("ghost: LAN host started on UDP {} (room auto-advertised)",
port);
}
return true;
}
void GhostNetworkLoop() {
GhostSock sock = GhostSocketCreate(/*allow_broadcast=*/true);
if (sock != GHOST_INVALID_SOCKET) {
GhostSockSetRecvTimeout(sock, 50);
}
uint16_t last_port = 0;
std::string last_server;
double last_tick = NowSec();
while (!g_stop.load(std::memory_order_relaxed)) {
const double now = NowSec();
const double dt = std::min(now - last_tick, 0.25);
last_tick = now;
if (!Enabled()) {
ResetSession();
std::this_thread::sleep_for(std::chrono::milliseconds(50));
continue;
}
const bool debug = Debug();
const uint16_t port = uint16_t(REXCVAR_GET(skate3_ghost_port));
std::string server = REXCVAR_GET(skate3_ghost_server);
if (server.empty()) {
server = kGhostDefaultServer;
}
const std::string room_str = REXCVAR_GET(skate3_ghost_room);
char room_buf[kGhostMaxRoomLen] = {};
uint8_t room_len = 0;
RoomSet(room_buf, room_len, room_str.c_str());
{
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(g_room, room_buf, kGhostMaxRoomLen);
g_room_len = room_len;
}
if (sock == GHOST_INVALID_SOCKET) {
sock = GhostSocketCreate(/*allow_broadcast=*/true);
if (sock == GHOST_INVALID_SOCKET) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
continue;
}
GhostSockSetRecvTimeout(sock, 50);
}
// Resolve the public relay (cache per server/port change).
if (server != last_server || port != last_port) {
GhostAddr a;
g_peer_public.configured =
GhostResolve(server + ":" + std::to_string(port), port, &a);
if (g_peer_public.configured) {
g_peer_public.addr = a;
}
last_server = server;
last_port = port;
}
// Public relay: JOIN every kJoinIntervalSec until HELLO, then send POSE.
if (g_peer_public.configured) {
if (!g_peer_public.joined && now - g_peer_public.last_join >= kJoinIntervalSec) {
SendJoin(sock, g_peer_public.addr, /*ping=*/false);
g_peer_public.last_join = now;
}
if (g_peer_public.joined && now - g_peer_public.last_hello > kHelloTimeoutSec) {
g_peer_public.joined = false;
g_peer_public.last_join = 0.0;
if (debug) {
REXLOG_INFO("ghost: public relay timed out; reconnecting");
}
}
}
// LAN fallback only when the public relay is not connected.
const bool lan_enabled = REXCVAR_GET(skate3_ghost_lan);
if (lan_enabled && !g_peer_public.joined) {
if (!g_hosting) {
// Discovery: broadcast PING (with our room) until a host replies or
// we decide to host ourselves. Stops once we are joined to a host.
if (!g_peer_lan.joined) {
if (!g_lan_probing) {
g_lan_probing = true;
g_lan_probe_since = now;
}
if (now - g_last_ping >= 2.0) {
const GhostAddr bcast = GhostAddrBroadcast(port);
SendJoin(sock, bcast, /*ping=*/true);
g_last_ping = now;
}
}
// No matching host found in time: become the host so LAN friends
// with our room code can join.
if (now - g_lan_probe_since >= kLanProbeBeforeHostSec &&
!g_peer_lan.configured) {
if (StartHost(port)) {
g_lan_probing = false;
} else {
g_lan_probe_since = now; // retry hosting later
}
}
}
} else {
g_lan_probing = false;
}
// 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) {
SendJoin(sock, g_peer_lan.addr, /*ping=*/false);
g_peer_lan.last_join = now;
}
if (g_peer_lan.joined && now - g_peer_lan.last_hello > kHelloTimeoutSec) {
g_peer_lan.joined = false;
g_peer_lan.last_join = 0.0;
}
}
// Send our pose at the configured rate to every joined peer.
g_pose_acc += dt;
const double rate = REXCVAR_GET(skate3_ghost_rate);
if (rate > 0.0 && g_pose_acc >= 1.0 / rate) {
g_pose_acc = 0.0;
if (g_peer_public.configured && g_peer_public.joined) {
SendPose(sock, g_peer_public.addr, g_seq);
}
if (g_peer_lan.configured && g_peer_lan.joined) {
SendPose(sock, g_peer_lan.addr, g_seq);
}
}
DrainSocket(sock, now);
PruneGhosts(now);
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
if (sock != GHOST_INVALID_SOCKET) {
GhostSockClose(sock);
}
{
std::lock_guard<std::mutex> lock(g_mu);
if (g_relay) {
g_relay->Stop();
g_relay.reset();
}
}
}
} // namespace
// ---- capture (sim thread; hook in skate3_native_render.cpp) ------------------
void OnLocalSkaterTick(uint8_t* base) {
if (!Enabled()) {
return;
}
const uint32_t entity = skate3::native_palette::CurrentPackOwner();
if (entity == 0) {
return;
}
float world[16];
if (!ReadEntityWorld(base, entity, world)) {
return;
}
std::lock_guard<std::mutex> lock(g_mu);
std::memcpy(g_local_world, world, sizeof(g_local_world));
g_local_valid = true;
g_local_time = NowSec();
}
void EnsureNetworkThread() {
if (g_started.exchange(true)) {
return;
}
g_thread = std::thread(GhostNetworkLoop);
}
void Shutdown() {
g_stop.store(true, std::memory_order_relaxed);
if (g_thread.joinable()) {
g_thread.join();
}
}
bool GetStatus(Status* out) {
std::lock_guard<std::mutex> lock(g_mu);
if (out == nullptr) {
return false;
}
out->enabled = Enabled();
out->connected = g_connected;
out->hosting = g_hosting;
out->session_id = g_session;
out->room_len = g_room_len;
std::memcpy(out->room, g_room, g_room_len);
out->roster = g_roster;
out->ghosts = g_ghosts.size();
std::snprintf(out->peer, sizeof(out->peer), "%s", g_peer);
return true;
}
size_t GhostCount() {
std::lock_guard<std::mutex> lock(g_mu);
return g_ghosts.size();
}
bool GetGhost(uint32_t index, float world[16], float color[3]) {
std::lock_guard<std::mutex> lock(g_mu);
if (index >= g_ghosts.size()) {
return false;
}
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);
}
return true;
}
bool GetLocalWorld(float world[16]) {
std::lock_guard<std::mutex> lock(g_mu);
if (!g_local_valid) {
return false;
}
std::memcpy(world, g_local_world, sizeof(float) * 16);
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
// position are zeroed: vs_main's non-skinned path only needs POSITION (the
// tint.a > 0 solid-color branch in ShadePixel returns before any sampling).
const GhostCubeVertex* CubeVertices() {
static const GhostCubeVertex kCube[36] = {
// +Z face
{{-0.5f, -0.5f, 0.5f}}, {{0.5f, -0.5f, 0.5f}}, {{-0.5f, 0.5f, 0.5f}},
{{0.5f, -0.5f, 0.5f}}, {{0.5f, 0.5f, 0.5f}}, {{-0.5f, 0.5f, 0.5f}},
// -Z face
{{-0.5f, -0.5f, -0.5f}}, {{-0.5f, 0.5f, -0.5f}}, {{0.5f, -0.5f, -0.5f}},
{{0.5f, -0.5f, -0.5f}}, {{-0.5f, 0.5f, -0.5f}}, {{0.5f, 0.5f, -0.5f}},
// +X face
{{0.5f, -0.5f, -0.5f}}, {{0.5f, 0.5f, -0.5f}}, {{0.5f, -0.5f, 0.5f}},
{{0.5f, -0.5f, 0.5f}}, {{0.5f, 0.5f, -0.5f}}, {{0.5f, 0.5f, 0.5f}},
// -X face
{{-0.5f, -0.5f, -0.5f}}, {{-0.5f, -0.5f, 0.5f}}, {{-0.5f, 0.5f, -0.5f}},
{{-0.5f, -0.5f, 0.5f}}, {{-0.5f, 0.5f, 0.5f}}, {{-0.5f, 0.5f, -0.5f}},
// +Y face
{{-0.5f, 0.5f, -0.5f}}, {{-0.5f, 0.5f, 0.5f}}, {{0.5f, 0.5f, -0.5f}},
{{0.5f, 0.5f, -0.5f}}, {{-0.5f, 0.5f, 0.5f}}, {{0.5f, 0.5f, 0.5f}},
// -Y face
{{-0.5f, -0.5f, -0.5f}}, {{0.5f, -0.5f, -0.5f}}, {{-0.5f, -0.5f, 0.5f}},
{{0.5f, -0.5f, -0.5f}}, {{0.5f, -0.5f, 0.5f}}, {{-0.5f, -0.5f, 0.5f}},
};
return kCube;
}
uint32_t CubeVertexCount() { return 36; }
} // namespace skate3::native_ghost