From e591f76f218d73b0b8f0b1d70cbed01bd77085e4 Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:19:22 +0100 Subject: [PATCH] render: split the scene renderer into capture and GPU translation units --- CMakeLists.txt | 1 + src/skate3_native_scene.cpp | 12347 +----------------------------- src/skate3_native_scene_gpu.cpp | 11501 ++++++++++++++++++++++++++++ src/skate3_native_scene_state.h | 1022 +++ 4 files changed, 12547 insertions(+), 12324 deletions(-) create mode 100644 src/skate3_native_scene_gpu.cpp create mode 100644 src/skate3_native_scene_state.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6b30afe..3c9699a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -235,6 +235,7 @@ set(SKATE3_COMMON_SOURCES src/skate3_native_debug_dialog.cpp src/skate3_native_render.cpp src/skate3_native_scene.cpp + src/skate3_native_scene_gpu.cpp src/skate3_screenshot.cpp src/skate3_shader_disasm.cpp src/skate3_title_update_installer.cpp diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index eca20b5..42b14ca 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -1,3 +1,9 @@ +// Native scene renderer, game-thread half: captures the hooked MeshContext +// stream and guest render state, and builds/publishes the per-frame +// FrameScene. The render-thread RHI half lives in +// skate3_native_scene_gpu.cpp; state shared between the two is in +// skate3_native_scene_state.h. + #include "skate3_native_scene.h" #include "generated/skate3_init.h" @@ -39,6 +45,7 @@ // sources with DXC): the Vulkan RHI backend consumes these blobs; the D3D12 // backend runtime-compiles the embedded HLSL as before. #include "native/shaders/spirv/skate3_native_shaders_spirv.h" +#include "skate3_native_scene_state.h" #if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN) #include @@ -879,6 +886,11 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_perf_log, false, "Skate 3", "(600-frame windows)") .lifecycle(rex::cvar::Lifecycle::kHotReload); +REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ring, false, "Skate 3", + "Record a rolling per-frame scene-composition ring (~900 " + "frames); F7 dumps it to logs/scene_ring_.csv for " + "diffing 1-2 frame artifacts no capture can catch") + .lifecycle(rex::cvar::Lifecycle::kHotReload); namespace skate3::native_scene { namespace { @@ -897,866 +909,8 @@ constexpr uint32_t kIbCount = 0x20; constexpr uint32_t kViewCameraFromView = 0x08; constexpr uint32_t kViewCamViewProj = 0xA0; -std::mutex g_scene_mutex; -// Published scene: immutable once published (BuildFrameScene builds a fresh -// one per guest frame). shared_ptr so the render thread grabs a reference -// under the mutex instead of deep-copying ~800 items (with per-item heap -// vectors) every frame. -std::shared_ptr g_scene; -uint64_t g_generation = 0; -// Steady-clock stamp of the last scene publish (BuildFrameScene only -// publishes when a perspective view submitted this frame). YieldForMenus -// uses its freshness to tell the in-game pause menu (the world keeps -// resubmitting behind the menu) apart from loading screens and the boot -// frontend (publishes stop), for skate3_native_render_scene_pause_native. -std::atomic g_last_publish_ns{-1}; -// Debug-dialog cache flushes: consumed at the top of RenderScene so texture/ -// mesh-affecting toggles (mip chains, 565 fixes, ...) take effect immediately -// instead of only for newly streamed content. -std::atomic g_flush_textures{false}; -std::atomic g_flush_meshes{false}; -// Off-screen retention clear request (see g_retained_items further down): -// set on the menus/loading flip (render thread) and the F5 re-enable, -// consumed at the top of BuildFrameScene's retention block (guest thread). -std::atomic g_retained_clear{false}; - -// Camera/bone signal recorders + synthetic-pan probe + offline recording: -// state and file writers live in native/skate3_native_diagnostics.cpp -// (native/skate3_native_diag.h); capture call sites below read per-frame -// locals and stay here. -std::atomic g_guest_base{nullptr}; -std::atomic g_frames_rendered{0}; - -// Loading -> gameplay takeover. Armed while the game reports menus/loading -// (and by the F5 enable toggle, hence the atomic); the loading-screen -// prewarm (below) does the decode heavy lifting behind the load, and the -// FIRST substantial post-load scene renders natively immediately, no -// emulated-gameplay stretch. The frames right after takeover run a budgeted -// "settle" decode pass for whatever prewarm missed (dynamic entities, -// late-registered textures); the draw path's miss budgets are clamped while -// settling so leftovers render white/skip for a frame instead of freezing -// the takeover frame. Everything except the armed flag is touched on the -// render thread only, inside RenderScene. -std::atomic g_warmup_armed{true}; -// BuildFrameScene stops publishing during loading (no submissions / no -// perspective view), so g_scene holds the PREVIOUS map's scene through the -// whole loading screen. Only scenes published at or after this generation -// (stamped while the loading screen shows) are eligible for takeover; -// rendering the stale scene shows old-map garbage at the flip. -uint64_t g_warmup_fresh_generation = 0; -// Settle window: frames (of the native frame counter) still running the -// per-frame settle decode pass after a takeover. -uint64_t g_settle_until_frame = 0; - -// Loading-screen prewarm queue: world meshes pushed by the -// tROptiMeshData::Unfix hook (the rw-arena LOAD-time pointer resolve; it -// fires once per world mesh as its arena streams in, i.e. exactly during -// loading screens and gameplay world streaming). The render thread drains -// the queue in RenderScene: with the prewarm budget behind the loading -// screen (where the heavy lifting belongs), with the warmup budget's -// leftover while warming, and with a small fixed slice during gameplay -// (streamed-in areas decode before their first draw instead of hitching -// it). Entries whose buffer objects are not initialized yet retry a bounded -// number of drains. -struct PrewarmEntry { - uint32_t mesh; - uint16_t retries; - // Environment-cube entry (mesh == 0, tex != 0): the one object-keyed - // texture path left (see EnqueueCubeMiss). - uint32_t tex = 0; - // Fetch-words entry (mesh == 0, tex == 0, wkey != 0): a content-store - // miss decoded from the captured stable words snapshot (see - // EnqueueWordsMiss): every 2D/3D texture miss and heal. - uint64_t wkey = 0; - uint32_t words[6] = {}; - // Environment-cube entry (tex != 0 && cube): a cube-cache miss: a single - // cube decode measured up to ~100 ms, the largest remaining traversal - // hitch when a vehicle / reflective area streamed in. - bool cube = false; - // Draw-path miss (vs speculative prewarm registration): the result is - // visible RIGHT NOW, so the commit takes it this frame regardless of the - // gameplay per-frame commit cap. - bool miss = false; - // 2D/HUD overlay miss (large-art async routing, see - // skate3_native_render_scene_2d_async_px): the commit skips the - // payload-stability verify for these; APT re-rasterizes animating UI - // art every guest frame, so "payload moved between decode and commit" is - // the NORMAL state mid-animation and the verify would reject every - // commit, freezing the element (spinners, ramping fades). The per-frame - // content probe in the 2D resolve is the heal path for torn reads, the - // same exposure the old inline decode had. - bool ui = false; -}; -std::mutex g_prewarm_mutex; -std::condition_variable g_prewarm_cv; // wakes the decode workers -std::vector g_prewarm_queue; -// Draw-path misses (EnqueueMeshMiss/EnqueueWordsMiss/EnqueueCubeMiss): -// content that is visible RIGHT NOW (white / skipped), -// served FIFO with strict priority over the speculative registration -// backlog in g_prewarm_queue. Guarded by g_prewarm_mutex. -std::vector g_miss_queue; -// Steady-state draw-path misses currently being decoded on the workers (one -// in-flight decode per key; the commit erases). Guarded by g_prewarm_mutex. -// This is the panning-hitch fix: a texture decode averages ~10 ms (max ~70 ms -// measured) and a pan surfaces dozens of new textures/meshes in one frame; -// decoding them inline on the render thread was the lag spike. Misses now -// render white / keep the previous decode for the 1-3 frames the workers -// need. -std::unordered_set g_miss_inflight_mesh; -std::unordered_set g_miss_inflight_tex; -std::unordered_set g_miss_inflight_words; -// Dynamic-payload decode jobs: cloth-sim garments (ropa) have their vertex -// buffers rewritten by the CPU sim EVERY frame, so their mesh decode used to -// run inline on the render thread every frame (~0.7 ms per garment; a -// 4-garment outfit = ~2.9 ms = the 160 fps cap during real play once -// everything else was fixed; the demo character wears no ropa, which hid -// it). The guest thread snapshots the changed payload at publish time; a -// worker converts it into fresh upload buffers; PrewarmCommit swaps the mesh -// cache entry. The render thread keeps drawing the previous decode, one -// frame of cloth lag, invisible (the ropa sim is frame-paced anyway). -struct DynDecodeJob { - DrawItem item; // offsets/formats/fingerprint (bones not needed) - uint64_t seq = 0; // enqueue order; the commit drops stale results - std::vector vb; // big-endian guest payload snapshots - std::vector ib; -}; -std::vector g_dyn_jobs; // FIFO; guarded by g_prewarm_mutex -// Push-side dedupe: clone instances share one tRModelData, so the same mesh -// registers many times per load, but a permanent per-address set silently -// DISABLED the prewarm for re-streamed content (the streamer reuses the -// same mesh/texture object addresses all session): after a few minutes -// every re-registered mesh was "seen", its re-decode was skipped, and every -// streamed texture surfaced as a draw-path first-sight miss instead: -// 388 white-flash textures in one 45 s traversal = the -// residual medium-distance pop-in. Now a mesh -> last-queued-frame map: -// clone bursts still dedupe inside the window, re-streams past it re-queue -// (registration is the game's own "content changed here" signal, see -// InvalidateCachedItem above). Cleared when the game enters menus/loading. -std::unordered_map g_prewarm_seen; -// Texture-object dedupe for the workers (they cannot read the render -// thread's g_r caches): address -> fetch-words key at last staging; a -// words change at a reused address re-stages, an unchanged one skips. -std::unordered_map g_prewarm_tex_seen; -std::atomic g_prewarm_done{0}; // entries fully warm-decoded -std::atomic g_prewarm_dropped{0}; // entries given up on -std::atomic g_prewarm_workers_started{false}; -// tInstance::m_pRModel offset, confirmed at runtime by the material -// cross-check probe in OnAddRenderInstance (Skate 2 layout: +0x78). -std::atomic g_instance_rmodel_offset{0}; - -// guid -> guest renderengine::Texture, from the RegisterTexture hook. Keys -// masked of the top bit: material channel guids carry an extra flag bit -// relative to the registered cAssetIDs. -constexpr uint64_t kGuidMask = 0x7FFFFFFFFFFFFFFFull; -std::mutex g_texture_map_mutex; -std::unordered_map g_texture_map; - -// VS constant staging bank (bank id 0x4000, device+1920). -// D3D::SetPending_AluConstants(device, dirty group mask, bank, ptr) is -// called from inside the guest Draw* functions; ptr is the device's -// positional 256-register shadow (partial uploads leave the rest intact). -// Two verified layouts: pre-pass keeps the per-draw transform / bone -// palette at c4; main-pass keeps camera at c4, palette at c7, rigid world -// at c8..c11 (see BankPaletteBase / BankRigidWorld). Because the dynamic -// capture runs right after RenderMesh returns, the bank holds exactly that -// mesh's constants when its draws ran inline. -std::atomic g_vs_bank{0}; -// Pixel constant shadow bank (bank id 0x4400, device+6016): material colors -// (e.g. the CAS hair tint) are staged here per draw. -std::atomic g_ps_bank{0}; -// Global per-frame fog rows (see FrameScene::fog_ramp): main-pass VS banks -// keep the camera at c4 and the frame's fog params at c5 (ramp) / c6 (color) -// - identical across every main-pass draw of a frame (verified from a -// recorded draw stream). OnDrawDone grabs them from the first 3D draw whose -// c4 matches the last built scene's camera; BuildFrameScene publishes them -// and re-arms the capture. All on the guest render thread. -float g_fog_cam[3] = {}; -float g_fog_rows[8] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; -bool g_fog_have = false; -bool g_fog_frame_done = false; -// Sky-dome viewpos height (see FrameScene::sky_height): sky.fx's VS adds -// g_vViewPos to every dome vertex, but the game feeds the SKY draw a viewpos -// whose Y is a fixed level elevation (165.0 in every Port Carverton capture) -// instead of the camera's; the baked skyline must not bob with the skater. -// Captured from the sky bank's unique signature: c4.xz matches the camera -// while c4.y sits far above it (every other main-pass draw has c4 == camera; -// water-reflection passes mirror y DOWNWARD and are excluded by dy > 0). -float g_sky_height = 165.0f; // every Port Carverton capture to date -bool g_sky_have = false; -bool g_sky_frame_done = false; -// Sky sun rows from the same draw's PIXEL bank (see FrameScene::sky_sun): -// light dir (c0.xyz), sun angular scale (c4.x), pre-tone multiplier (c4.y), -// exposure (c3.x). Persist like g_sky_height once captured. -float g_sky_sun[6] = {}; -bool g_sky_sun_have = false; -// UI background blur (see FrameScene::ui_blur and kBlurShaderSource): while -// a frontend popup is up the game appends blur_hBlur/vBlur + basictex passes -// after the postfx uber. g_ui_blur holds the captured kernel scale (PS c0.x, -// 8 in every capture); g_ui_blur_seen latches per frame on the blur_hBlurPS -// draw and is cleared at publish; the pass chain only exists while the -// popup is actually up, so this can never stick on. -float g_ui_blur = 8.0f; -bool g_ui_blur_seen = false; -// Blur modulate color (the blur passes' PS c1): both blur ucodes end in -// `mul oC0, r0, c1`: the game's menu fade. Gameplay popups stage (1,1,1) -// (why the exact port originally had no multiply); the pause menu stages -// ~(0.35,0.33,0.32), squared across the H+V passes = the darkened pause -// backdrop. Read live on the blur_hBlurPS draw, accepted only when the -// bank's c0 kernel row reads exactly (8,8); a stale mid-stage bank fails -// that gate (the staleness class that pulsed the blur radius when c0.x was -// fed live, see g_ui_blur above). -float g_ui_blur_color[3] = {1.0f, 1.0f, 1.0f}; -// Hold the blur across publishes that carry no blur draw: the game does not -// issue the pass chain on every swap while the popup is up, and publishing -// the raw per-swap flag alternated blur on/off: a visible brightness -// shimmer. Two publishes of hysteresis bridges the gaps; on popup close the -// blur lingers ~2 guest frames, imperceptible. -int g_ui_blur_hold = 0; -// Host settings-overlay backdrop: forces the same blur chain while the SDK -// settings menu is open (the game issues no popup blur then). Set from the -// app UI thread, read at scene publish. -std::atomic g_settings_menu_blur{false}; -// Emulated-post blur diagnostics: reset on each menu open so every open -// logs a fresh burst. -std::atomic g_post_blur_log_count{0}; -// Selected-object outline capture (see DrawItem::selected): in the park -// editor / object mover the game EXCLUDES the selected object from the main -// color pass and re-draws it right after the sky, twice, back to back, the -// stencil-marking passes the postfx edge-detect turns into the blue contour -// (verified in capture: consecutive environmentpark decal + -// diffuse parts, identical VS banks, vs the same mesh at OTHER placements in -// the main pass). Hook side records each post-sky environmentpark/ -// dynamicobject draw's (ib, vb, world translation); entries seen >= 2 times -// mark the matching published items. Guest render thread only, like the fog -// capture state. -struct SelectedDrawKey { - uint32_t ib; - uint32_t vb; - float t[3]; - uint32_t count; -}; -bool g_sky_seen_this_frame = false; -std::vector g_frame_selected; -// The guest issued its postfx_edgedetectstencil draw this frame: the game's -// own "an outline is being drawn" signal (park editor / object mover with an -// active selection). Post-sky double-draws alone are NOT sufficient evidence -// of a selection: normal gameplay draws some small props twice after the sky -// (far LOD/imposter passes), which used to sprout phantom outlines on distant -// objects. Guest render thread only; reset per frame in BuildFrameScene. -bool g_outline_edge_seen = false; -// Outline color, refreshed from the guest postfx_edgedetectstencil draw's -// PS c0 (the park-editor blue in every capture) whenever that pass runs. -float g_outline_color[4] = {0.21569f, 0.64706f, 1.0f, 1.0f}; -// PIXEL banks keep the CSM constants at c0..c8, pass-global (identical on -// every environment-family draw of the pass; character/hair/tree PSes -// allocate differently and are rejected by the sanity gate). Captured on the -// same camera-keyed main-pass draws as the fog rows. -float g_shadow_rows[48] = {}; -bool g_shadow_have = false; -bool g_shadow_frame_done = false; -// Frame-global rows of the tree / proxyworld shader families (see -// FrameScene::family_rows), captured from their PS banks when a draw with -// the matching debug path runs. Guest render thread only. -float g_family_rows[4] = {0.3435f, 0.02f, 1.0f, 0.45f}; -bool g_tree_frame_done = false; -bool g_proxy_frame_done = false; -// dynamicobject.fx frame-global lighting rows (see FrameScene::dynobj_rows), -// captured from a dynamicobject/alphatestdynamicobject PS bank (debug-path -// classified). Guest render thread only. -float g_dynobj_rows[10] = {}; -bool g_dynobj_have = false; -bool g_dynobj_frame_done = false; -// dynamicobject static world-shadow transform rows (PS c5/c6/c7), captured -// alongside g_dynobj_rows, see FrameScene::dynobj_ws. -float g_dynobj_ws[12] = {}; -bool g_dynobj_ws_have = false; -// Dynamic entities (characters, movable props) live entirely in transient -// per-frame arenas: the context, its record, mesh pointers, transforms and -// bone palettes are all recycled before frame end. The complete DrawItem is -// therefore built at RenderMesh hook time and stored here. -std::mutex g_palette_mutex; -std::vector g_frame_dynitems; -// (ib_obj << 32 | vb_obj) -> indices of pending items in g_frame_dynitems, -// for the post-draw state fixup (deferred / world-path captures draw after -// their capture; the fixup fills palette or world from the actual draw). -std::unordered_multimap g_frame_pending_by_buffers; -// Contexts submitted through an ORTHO (shadow caster-cascade) bank this -// frame, i.e. items the GAME drew into its own shadow passes. The game's -// caster list is per-piece: the CAS trucker hat submits ONLY to the main -// view (verified in capture: every char piece has shadow-pass draws -// except the hat); natively casting it painted a hard low-sun brim band -// across the editor face that the emulated frame never shows. The native -// atlas pass renders char pieces only when their ctx is in this set -// (rescued/gap items inherit last frame's flag). Guarded by -// g_palette_mutex; cleared at the end of BuildFrameScene. -std::unordered_set g_frame_ortho_ctx; -// (ib_obj << 32 | vb_obj) -> dynitem indices whose character-lighting rows -// should refresh on every later matching draw this frame. The one-shot -// palette fixup usually pops on the shadow-CASTER draw, whose PS bank is -// stale (shadowPS uploads no PS constants); re-capturing on the main-pass -// draw (last writer) lands the real per-instance rows (stamp tints etc.). -std::unordered_multimap g_frame_char_refresh; -// (ib_obj << 32 | vb_obj) -> device fetch-shadow slots 3+4 (12 dwords) at -// this frame's LAST indexed 3D draw with those buffers (the z-prepass draws -// first, so the main pass wins). Consumed at frame end by the -// streamed-artwork diffuse override (DrawItem::diffuse_fetch): poster/advert -// materials keep a 16x16 min-mip placeholder in the material channel while -// the engine binds the streamed art via the fetch constants at draw time. -// Guarded by g_palette_mutex; cleared at the end of BuildFrameScene. -std::unordered_map> g_frame_draw_fetch; -// mesh -> last VALIDATED character-lighting rows (see CaptureCharLighting). -// The per-frame capture depends on a fragile draw-order chain (capture at -// submit-exit -> pending fixup -> per-draw refresh, commit-on-success); on -// frames where no draw in the chain carried this character's main-pass PS -// bank the item ends with INVALID rows and the renderer falls back to the -// legacy empirical shading; the player visibly flickered between the two -// looks while moving, and the hair left the blended sub-pass (opaque "hair -// helmet" covering the cap crown). The rows are frame-coherent (sun/ambient -// move slowly), so reusing the previous valid capture is visually seamless. -// TRAP: NPC clones share a mesh with PER-INSTANCE rows (stamp tints, the -// teal-vest twins), so the fallback applies only to meshes with a single -// instance in the frame. Guest-render-thread only (capture hooks + -// BuildFrameScene), no lock needed. -std::unordered_map> g_char_rows_cache; -// Frame-global character CSM receive biases (see FrameScene::char_shadow_bias) -// + the guest frame they were captured on (served while <120 frames stale; -// they only change with the scene lighting setup). Guest render thread only. -float g_char_shadow_bias[3] = {}; -uint64_t g_char_shadow_bias_frame = 0; -std::atomic g_char_rows_reused{0}; -// Cached palette entry for the per-instance rescue below. A refused -// capture re-publishes with the cached palette: one frame of pose lag is -// invisible; a one-frame disappearance is not. Guest-render-thread only. -struct CachedBones { - std::vector bones; - // g_guest_frame at refresh: rescues/heals must be FRESH; a close-pass - // refusal resurrecting a seconds-old palette rendered the vehicle 10-20 m - // BEHIND its live position (the momentary ghost-back). - uint64_t frame = 0; -}; -// ctx -> last published palette for skinned character-family items. The -// MeshContext is the game's own per-instance identity, so this cache -// rescues each instance with ITS OWN last palette regardless of how many -// clones are alive. -std::unordered_map g_bones_cache_ctx; -std::atomic g_lw_ctx_rescued{0}; -// LW entity store consumption counters (stats line lw[...]). -std::atomic g_lw_stamped{0}; -std::atomic g_lw_fade0{0}; -std::atomic g_lw_gap_filled{0}; -// Authoritative caster-palette substitutions + per-ctx lighting-rows serves -// (edge-of-view vehicles; see the stamp pass). -std::atomic g_lw_pal_sub{0}; -std::atomic g_lw_rows_served{0}; -// ctx -> last VALIDATED lighting/paint rows, entity-checked (a recycled -// instance must not inherit the previous occupant's paint). The mesh-keyed -// g_char_rows_cache fallback is gated to single-instance meshes; cloned -// traffic never qualified, so a caster-only stretch (main view culls the -// vehicle at the screen edge before it leaves the screen) dropped to -// legacy flat shading: the "vehicle loses its texture/color at the edge" -// sighting. -struct CharRowsCtx { - std::array rows; - uint32_t entity = 0; -}; -std::unordered_map g_char_rows_cache_ctx; -// (mesh,ctx) -> last VALIDATED rows for EVERY character item with an -// instance context: the general rescue the two caches above each cover -// only a slice of: the mesh-keyed cache is gated to single-instance meshes -// (character pieces also publish caster items of the same mesh, so gameplay -// pieces rarely qualify) and the ctx cache is gated to LW-mapped entities -// (the PLAYER is not one). A capture-failed frame without a rescue drops -// hair out of the blended sub-pass onto the legacy opaque path: a -// frame-to-frame hair/garment appearance flip (the -// failure rate scales with guest fps, so it reads as a high-fps -// flicker). Entity-stamped like CharRowsCtx (0 for non-LW instances) so a -// recycled ctx never inherits the previous occupant's rows. -struct CharRowsInst { - std::array rows; - uint32_t entity = 0; -}; -std::unordered_map g_char_rows_cache_inst; -// Entity-keyed lighting rows for ROPA garments only: the sim on/off switch -// swaps between a garment's two renderables (cloth-target vs original -// model, different mesh AND ctx), so the (mesh,ctx) caches never carry -// rows across the switch and the freshly resolved model rendered dark/flat -// until its own capture validated. Both models map to the same owning -// entity in the identity store, and they are the same garment; sharing -// rows across the pair is exact. Garment-to-garment only; body pieces keep -// their per-piece caches. -struct CharRowsEnt { - std::array rows; - uint8_t fam = 0; // donor family: the row layout is family-specific -}; -std::unordered_map g_char_rows_cache_ent; -std::atomic g_char_rows_ent_served{0}; -std::atomic g_char_rows_inst_served{0}; -// Items served authoritative pack palettes this frame / total (see -// native_palette::ServeAuthoritativePalettes). -uint32_t g_pal_served_frame = 0; -std::atomic g_pal_served_total{0}; -// Route-flip probe: counts hair/character-alpha pieces whose pass route -// (blended sub-pass vs legacy opaque) changed between consecutive frames; -// each count is one visible flicker event. -std::atomic g_hair_route_flips{0}; -// A,B,A,B alternation probes: bone palettes (per piece) and the shadow -// cascade state (scene-wide) returning EXACTLY to the previous-but-one -// frame's value (legitimate animation/lighting drift never does). -std::atomic g_hair_bone_alternations{0}; -std::atomic g_shadow_alternations{0}; -// ctx -> last PUBLISHED item of a live LW entity (skinned character -// families, non-ropa): when a ctx drops out of the submit records for a -// frame or two while its entity is still alive in the store (observed as -// "dyn publish GAP" incidents on fam-3/5 ped meshes, 1-3 -// frames each: an in-view NPC vanishing for a frame reads as a blink or, -// moving, a small teleport), the whole item republishes. Console behavior: -// a live entity never skips a frame. Self-limiting: entries expire after 2 -// unpublished frames and refresh only from LIVE publishes (never from a -// fill, dbg_src 9), so a real despawn shows at most 2 filled frames, at -// the entity's own served alpha. -struct LwRetained { - DrawItem item; - uint64_t frame = 0; -}; -std::unordered_map g_lw_last_items; -// mesh -> last RESOLVED ropa garment state (rigid world OR skinned palette). -// Ropa items must NOT ride the palette-only rescue above: a ropa capture is -// refused exactly when the bank is stale (g_ropa_stale), and while the cloth -// sim is ACTIVE the VB holds sim-deformed root-local positions; skinning -// those with a cached palette is the mangled-ribbon interpretation (verified -// 0/31 in-clip; on-screen as map-length stretched strips, with matching -// shadows since the caster pass shares the item). And with no rescue at all -// a refused frame drops the garment (the momentary invisible torso). The -// dyn decode workers already keep the drawn cloth VB one frame behind the -// sim, so re-publishing last frame's resolved MODE + transform is exactly -// age-consistent with the vertices being drawn. Single-instance meshes only -// - clones share meshes with per-instance transforms. Guest-render-thread -// only. -struct RopaResolvedState { - bool skinned = false; - float world[16] = {}; - std::vector bones; - // Publish frame of the cached state: the rescues below exist to bridge - // 1-3 frame capture gaps, and re-publishing an unbounded-age state pins a - // ghost of the garment at a stale pose for as long as captures keep - // failing (the frozen-in-the-air garment behind a skater whose capture - // path was refusing every frame). - uint64_t frame = 0; -}; -std::unordered_map g_ropa_state_cache; -std::atomic g_ropa_rescued{0}; -// mesh -> sample verts of the most recent DECODE of a skinned mesh (bind- -// space position + raw blend attribs, ~32 evenly spaced), written by -// DecodeMesh on whichever thread decodes (workers / render), read by the -// guest thread's draw-time STRETCH VETO in BuildFrameScene. The veto skins -// these samples with the FINAL post-interpolation palette, i.e. it judges -// what the GPU will ACTUALLY draw. Every upstream gate (capture acceptance, -// publish coherence) judges the LIVE guest VB instead, so a decode-content -// vs palette pairing mismatch, or junk introduced by the interpolation -// substitutions, passes all of them and still flashes the 1-frame -// map-length ribbon (observed with every ropa[] counter clean). -// `fp` = the decode's payload fingerprint so the veto can log whether the -// drawn content even matches the frame's payload (content lag vs junk -// palette: the decisive diagnostic split). -struct SkinProbeSample { - float p[3]; - uint32_t bw; - uint32_t bi; -}; -struct SkinProbe { - std::vector s; - uint64_t fp = 0; -}; -std::mutex g_skin_probe_mutex; -std::unordered_map g_skin_probe; -std::atomic g_cur_ib{0}; -std::atomic g_cur_vb{0}; -std::atomic g_cur_vb_offset{0}; -std::atomic g_cur_vb_stride{0}; -// Guest D3D::CDevice: the fetch-constant shadow lives at device+0x480, -// 6 dwords per fetch group (from recompiled SetPending_FetchConstants). -std::atomic g_device{0}; -// 2D/APT phase depth counters (see On2dPhase). All HUD/menu 2D elements are -// Flash SWFs converted to APT; draws issued inside these brackets are the 2D -// draw stream. -std::atomic g_phase2d_depth[6]; -std::atomic g_draws_2d{0}; -// 2D draws seen through paths the overlay does not replay yet -// (DrawIndexedVertices / DrawVertices in the 2D phase; gameplay HUD uses -// only the BeginVertices inline path, verified by capture). -std::atomic g_draws_2d_other{0}; -std::atomic g_draws_2d_dropped{0}; -// Large-art async decode routing (see skate3_native_render_scene_2d_async_px): -// quads skipped while their first decode is in flight on the workers, and -// stale decodes served while a content-change re-decode is in flight. -std::atomic g_2d_async_skip{0}; -std::atomic g_2d_async_stale{0}; - -// One captured 2D draw (verified layout): BeginVertices -// inline vertices, stride 24 = {float x, y, z, w; float u, v} in 1280x720 -// APT movie space; VS c0..c3 = ortho projection rows, c4..c7 = the -// element's 2D transform, c8 = color multiplier; diffuse texture in fetch -// shadow slot 0. Vertex payload is written by the CPU after BeginVertices -// returns, so it is read at frame end (addr) rather than at capture. -// NOTE on render-to-texture: in gameplay the ENTIRE HUD renders inside -// AptRenderingIntegration::UpdateRenderToTexture (bracket bit 3) into a -// screen-sized overlay texture, at true screen coordinates; the game -// composites that overlay over the 3D frame in a later (emulated, -// suppressed) pass. Replaying the draws directly onto the native output IS -// that composite, so bit 3 is diagnostic only, not a routing signal. The -// overlay content is straight-alpha art (verified by decoding the clock -// face/needle/sheen textures offline). -struct Draw2d { - uint32_t prim; // xenos primitive: 4 trilist, 5 tristrip, 13 quadlist - uint32_t count; // vertex count - uint32_t stride; // bytes per vertex at capture (see publish normalization) - // Capture-time stride, preserved across the publish normalization (which - // rewrites stride to the renderer's 28). The FMV substitution keys on it: - // the movie quad is the stride-24 textured layout inside the - // AptMovieIntegration bracket (bit 1). - uint32_t src_stride = 0; - uint32_t addr; // guest inline-ring write pointer - uint32_t flags; // bracket bits at capture (layout disambiguation) - // Texture fetch constants, shadow slots 0-2 (6 dwords each). Slot 0 is - // the draw's own texture (all the replay paths read only [0..5]); slots - // 1-2 exist for the VIDEO quads, whose console YUV shader binds the U and - // V planes there; the replay's YUV-triple detection reads them. - uint32_t fetch[18]; - float consts[36]; // VS c0..c8 - std::vector verts; // filled at frame end (little-endian dwords) -}; -std::mutex g_2d_mutex; -std::vector g_frame_2d; // capture in submission order -std::vector g_scene_2d; // published at frame end -uint64_t g_scene_2d_generation = 0; - -// One captured in-world neon spline draw (waypoint arrows / marker beams; -// decoded from a live capture + the game's own spline.fx source): a -// DrawVertices triangle strip whose 12-byte float3 "vertices" are only -// parameters: x = control-point index + fractional t, y = U texcoord, -// z = side flag (0/1) selecting the extrusion offset and V texcoord. The -// guest VS (43309A8C) evaluates a uniform cubic B-spline through i_cp[] -// (VS c7..) transformed by world columns c4..c6, adds the world-rotated -// extrusion offset c[151 + z], projects by VP columns c0..c3, and fades by -// clip-z against i_clipvalues (c150) with i_intensity (c149) as the pass -// gain. Two passes per element: spline_darkenPS (straight alpha) then -// spline_defaultPS (additive glow), both depth-tested with no z-write, -// replayed inside the native scene pass with the spline evaluated on the -// CPU at publish time. -struct SplineDraw { - uint32_t pass; // 1 = darken (straight alpha), 2 = default (additive) - uint32_t count; // strip vertex count - uint32_t fetch[6]; // texture fetch slot 3 (the neon gradient) - float consts[153 * 4]; // VS c0..c152 as staged - // capture: raw big-endian float3 params; publish: evaluated little-endian - // {float4 clip_pos, float2 uv, float fade} (28-byte stride). - std::vector verts; -}; -std::vector g_frame_spline; // guarded by g_2d_mutex -std::vector g_scene_spline; -std::atomic g_draws_spline{0}; -// Current guest shader objects and render-state shadow bank, for the 2D -// recon recording (grouping the stream by shader / reading blend state). -std::atomic g_cur_ps_obj{0}; -std::atomic g_cur_vs_obj{0}; -std::atomic g_rs_bank{0}; -std::atomic g_cur_viewport[6]; -std::atomic g_cur_scissor[4]; -// All four vertex streams (vb, offset, stride); cloth binds its simulated -// vertices on a stream other than 0. -std::atomic g_cur_streams[4][3]; - -// Offline-analysis recording structs/state: native/skate3_native_diag.h. -std::atomic g_vs_uploads{0}; -std::atomic g_palette_snapshots{0}; -// Palettes captured at base+1 (the cloth/morph VS layout with an extra -// parameter row before the palette, see RefinePaletteBase). -std::atomic g_palette_base_plus1{0}; -// Reflective-glass (fam 5/6) normal-map pair telemetry: pair = draws with -// the masks+normal t4/t5 descriptor pair bound (overlay.w == 4), flat = -// spec-bound reflective draws still on the flat-normal path, gate = the -// last no-pair reason bitmask (1 no spec masks, 2 no normal channel, 4 -// masks SRV recipe missing, 8 normal unresolved/white, 16 normal invalid, -// 32 normal recipe missing, 64 pair slots exhausted). -std::atomic g_refl_pair{0}; -std::atomic g_refl_flat{0}; -std::atomic g_refl_gate{0}; - -// ---- F7 scene-composition ring (see RequestSceneRingDump in the header) ---- -REXCVAR_DEFINE_BOOL(skate3_native_render_scene_ring, false, "Skate 3", - "Record a rolling per-frame scene-composition ring (~900 " - "frames); F7 dumps it to logs/scene_ring_.csv for " - "diffing 1-2 frame artifacts no capture can catch") - .lifecycle(rex::cvar::Lifecycle::kHotReload); -struct SceneRingItem { - uint32_t ctx; - uint32_t mesh; - uint32_t diffuse; - uint32_t lightmap; - uint32_t vb_obj; - // Full material texture set: a one-frame spec/normal/macro/decal-art - // resolution glitch changes shading (e.g. an unmasked sky reflection on a - // reflective bank face) with the diffuse/lightmap identical; the first - // ring revision could not see those. - uint32_t spec; - uint32_t macro; - uint32_t decal_art; - uint32_t wnormal; - uint32_t indices; // summed index counts of the item's draw entries - uint16_t drawn; // draw calls actually issued (0 = skipped at draw) - uint8_t env_family; - uint8_t char_family; - uint8_t flags; // 1 transparent 2 water 4 skinned 8 retained 16 pending - // 32 caster_bank 64 decal - uint8_t route; // 0 skipped pre-pass, 1 opaque pass, 2 blended sub-pass - // SERVED-content fingerprints, stamped in draw_item after the resolves: - // the object pointers above cannot see an in-place content swap (a heal - // commit changes what a texture shows with every pointer identical); - // a fp that changes A->B->A across the flash frame names the texture, - // the slot, and both contents. 0 = white fallback / not resolved. - uint64_t fp_diffuse; - uint64_t fp_lightmap; - uint64_t fp_macro; - uint64_t fp_decal; -}; -struct SceneRingFrame { - uint64_t frame = 0; - float cam[3] = {}; - // Per-frame captured globals: a one-frame glitch in any of these shifts - // shading on every consumer with the composition identical. - float fog[6] = {}; // ramp xyz + color rgb - float family_rows[4] = {}; - float sky_height = 0.0f; - bool shadow_valid = false; - std::vector items; -}; -std::deque g_scene_ring; // render thread only -constexpr size_t kSceneRingFrames = 2400; -std::atomic g_scene_ring_dump{false}; - -// Render thread, frame end: write the whole ring as CSV. One F line per -// frame, then one I line per item (hex object addresses to match every -// other diagnostic). -void MaybeDumpSceneRing() { - if (!g_scene_ring_dump.exchange(false, std::memory_order_acq_rel)) { - return; - } - char path[128]; - std::snprintf(path, sizeof(path), "logs/scene_ring_%lld.csv", - static_cast(std::time(nullptr))); - std::ofstream f(path); - if (!f) { - REXLOG_WARN("native-scene ring: cannot open {}", path); - return; - } - f << "kind,frame,ctx,mesh,diffuse,lightmap,vb,spec,macro,decal_art," - "wnormal,indices,drawn,env_fam,char_fam,flags,route,fp_diffuse," - "fp_lightmap,fp_macro,fp_decal\n"; - char line[256]; - for (const SceneRingFrame& fr : g_scene_ring) { - std::snprintf(line, sizeof(line), - "F,%llu,cam,%.2f,%.2f,%.2f,items,%zu,fog,%.5f,%.4f," - "%.3f,%.4f,%.4f,%.4f,fam,%.4f,%.4f,%.4f,%.4f,sky,%.1f," - "shadow,%d\n", - static_cast(fr.frame), double(fr.cam[0]), - double(fr.cam[1]), double(fr.cam[2]), - fr.items.size(), double(fr.fog[0]), double(fr.fog[1]), - double(fr.fog[2]), double(fr.fog[3]), double(fr.fog[4]), - double(fr.fog[5]), double(fr.family_rows[0]), - double(fr.family_rows[1]), double(fr.family_rows[2]), - double(fr.family_rows[3]), double(fr.sky_height), - fr.shadow_valid ? 1 : 0); - f << line; - for (const SceneRingItem& it : fr.items) { - std::snprintf(line, sizeof(line), - "I,%llu,%08X,%08X,%08X,%08X,%08X,%08X,%08X,%08X,%08X," - "%u,%u,%u,%u,%u,%u,%016llX,%016llX,%016llX,%016llX\n", - static_cast(fr.frame), it.ctx, it.mesh, - it.diffuse, it.lightmap, it.vb_obj, it.spec, it.macro, - it.decal_art, it.wnormal, it.indices, unsigned(it.drawn), - unsigned(it.env_family), unsigned(it.char_family), - unsigned(it.flags), unsigned(it.route), - static_cast(it.fp_diffuse), - static_cast(it.fp_lightmap), - static_cast(it.fp_macro), - static_cast(it.fp_decal)); - f << line; - } - } - REXLOG_INFO("native-scene ring: wrote {} frames -> {}", g_scene_ring.size(), - path); -} -// Character-lighting capture telemetry: attempts vs validated captures per -// family (see CaptureCharLighting). -std::atomic g_char_attempts{0}; -std::atomic g_char_valid{0}; -std::atomic g_char_drawn{0}; -std::atomic g_dynobj_drawn{0}; -// character.cloth_ropa items captured in the sim-active RIGID mode (world -// from c188/c191 instead of a bone palette, see CaptureSkinnedState). -std::atomic g_ropa_rigid{0}; -// ropa rigid captures REFUSED because the bank's c188/c191 was implausible -// or projected the garment off-clip (stale bank from another mesh's draw); -// the item stays pending for the post-draw fixup. -std::atomic g_ropa_stale{0}; -// ropa captures accepted through the RELAXED near-camera criterion (all -// samples in FRONT of the projection inside a loose 6x guard band + sane -// spread) after the strict half-in-clip score refused them. Up close the -// garment fills/overflows the screen and most of the 6 sample verts clip; -// the strict gate refused the CORRECT palette every frame, and with no -// cached resolved state to rescue from the torso stayed invisible until the -// NPC was far enough away again (log signature: `ropa mesh=... score=2 -// flag=(1.000,...)` repeating while stale climbed with rescued flat). -std::atomic g_ropa_relaxed{0}; -// mesh -> newest enqueued cloth-shape generation (DynDecodeJob seq). GUEST -// render thread only: written by the dyn-job enqueue, read by the interp -// ring's pose ingestion (the pose <-> shape pairing). -std::unordered_map g_ropa_last_seq; -// Shape blends served / skipped (generation missing from the ring) per -// stats window. -std::atomic g_ropa_blend_drawn{0}; -std::atomic g_ropa_blend_miss{0}; -// Ropa garments PUBLISHED with a caster-cascade (ortho) bank's palette: -// the ~40 ms-stale rows behind the garment-offset-from-body symptom. The -// same-mode graft in the dyn_slot merge should keep this at ~0 whenever a -// perspective capture exists in the same frame. -std::atomic g_ropa_caster{0}; -// Published skinned items whose palette FAILED the dense 32-sample -// publish-time coherence gate (see PublishedPaletteSane): the 1-frame -// junk-palette ribbon flash that passes every 6-sample capture gate. The -// item re-publishes its cached state instead (or drops for one frame). -std::atomic g_pub_incoherent{0}; -// Draw-time STRETCH VETO trips (see g_skin_probe): the final palette skins -// the resident decode's sample verts wider than bind size; the item's -// draws are cleared for the frame (blink, not ribbon) and the first events -// dump full palette + samples to logs/stretch_*.txt for offline diagnosis. -std::atomic g_stretch_veto{0}; -// skinned/ropa meshes that published after a 1-3 frame publish GAP (was on -// screen, vanished, came back): the "flickered invisible" telemetry. -std::atomic g_dyn_gap{0}; -std::atomic g_skinned_items{0}; -std::atomic g_skinned_skipped{0}; -// Rigid items whose transform had to wait for the post-draw fixup (deferred -// draws), and those still pending at frame end (dropped). -std::atomic g_rigid_pending{0}; -std::atomic g_rigid_dropped{0}; -// Model-space props in the world sort lists, excluded from the identity -// world path (their placed copies come from the kind-2 capture). -std::atomic g_world_props{0}; -std::atomic g_rej_no_dynstate{0}; -std::atomic g_rej_dyn_range{0}; -// Render-side silent-skip diagnostics: a scene item that decodes badly or -// loses its bone binding produces no (or wrong) pixels with no other trace. -std::atomic g_rr_decode_fail{0}; -std::atomic g_rr_no_bones{0}; -// Decodes pushed past the per-frame budget (item/texture appears a few -// frames late instead of hitching the render thread). -std::atomic g_rr_mesh_deferred{0}; -std::atomic g_rr_tex_deferred{0}; -std::atomic g_rej_chain{0}; -std::atomic g_rej_geom{0}; -std::atomic g_rej_draws{0}; -std::atomic g_rej_bbox{0}; - -// ---- Perf telemetry (windowed; logged + reset with the 600-frame stats) ---- -// Written by the guest render thread (capture/build) and the command -// processor thread (render segments); read by the render thread's stats log. -// Atomics with relaxed ordering: telemetry, not synchronization. -using PerfClock = std::chrono::steady_clock; -inline uint64_t PerfNsSince(PerfClock::time_point t0) { - return uint64_t( - std::chrono::duration_cast(PerfClock::now() - t0) - .count()); -} -struct PerfWindow { - std::atomic ns{0}; - std::atomic max_ns{0}; - std::atomic count{0}; - void Add(uint64_t v) { - ns.fetch_add(v, std::memory_order_relaxed); - count.fetch_add(1, std::memory_order_relaxed); - uint64_t prev = max_ns.load(std::memory_order_relaxed); - while (v > prev && - !max_ns.compare_exchange_weak(prev, v, std::memory_order_relaxed)) { - } - } - double AvgMs() const { - const uint64_t c = count.load(std::memory_order_relaxed); - return c != 0 ? double(ns.load(std::memory_order_relaxed)) / double(c) * 1e-6 - : 0.0; - } - double MaxMs() const { return double(max_ns.load(std::memory_order_relaxed)) * 1e-6; } - void Reset() { - ns.store(0, std::memory_order_relaxed); - max_ns.store(0, std::memory_order_relaxed); - count.store(0, std::memory_order_relaxed); - } -}; -// Guest frame counter (guest render thread only; incremented at -// BuildFrameScene entry): paces the world-item cache's revalidation. -uint64_t g_guest_frame = 0; - -// Guest render thread: per-frame sums (one Add per guest frame). -PerfWindow g_pw_capture; // hook-time capture work (sort lists + RenderMesh) -PerfWindow g_pw_build; // BuildFrameScene -PerfWindow g_pw_pal_tail; // frame tail: palette snapshot-ring rotation -PerfWindow g_pw_guest_dt; // guest frame interval (guest fps + spike max) -uint64_t g_capture_frame_ns = 0; // guest render thread only; folded per frame -// Command processor thread: per-RenderScene-call segments. -PerfWindow g_pw_render; // whole native render -PerfWindow g_pw_items; // opaque + transparent item loop (incl. decodes) -PerfWindow g_pw_shadow; // dynamic-shadow atlas pass -PerfWindow g_pw_mesh_decode; // inline DecodeMesh calls (count = decodes) -PerfWindow g_pw_tex_decode; // inline texture-ish decodes (cubes, HUD words) -PerfWindow g_pw_commit; // PrewarmCommit batches (count = non-empty runs) -// RenderScene phase attribution (the sustained "render=13ms, items=0.7ms" -// Vulkan mystery): everything render= covers that items=/shadow= -// do not. pre = EnsurePipeline..prewarm+evict+retire before the scene pass; -// twod = the 2D/HUD overlay replay (incl. inline decodes + video planes); -// tail = MSAA resolve, outline, pfx chain, photo grab, popup blur, final -// transitions: RenderScene minus everything else. -PerfWindow g_pw_pre; -PerfWindow g_pw_2d; -PerfWindow g_pw_tail; -// Settle-pass attribution (the sticky ~80fps post-map-change state: -// pre=8.4ms == the whole warmup budget, GPU idle at -// P0): the takeover settle pass extends itself +8 frames whenever a frame's -// warm walk leaves work deferred, so content that keeps failing revalidation -// (old-map items over reused arenas, streamed payloads) can hold it open for -// the rest of the session, burning the full budget every frame. settle= times -// the block; wdec/wdef count warm-path decodes/deferrals; the offender logs -// name what churns. -PerfWindow g_pw_settle; -// Guest-thread build-phase attribution (the standing-still 320->250 fps -// oscillation): build= runs on the guest render thread and its -// variance IS guest frame-time variance. b2d/bspl/bpal split the phases; -// the item walk is the remainder. dt histogram + slow-frame log quantify -// and attribute the dips. -PerfWindow g_pw_b2d; // Publish2dDraws -PerfWindow g_pw_bspl; // PublishSplineDraws -PerfWindow g_pw_bpal; // ServeAuthoritativePalettes -// This frame's phase costs (guest render thread only - no atomics needed). -uint64_t g_frame_b2d_ns = 0; -uint64_t g_frame_bspl_ns = 0; -uint64_t g_frame_bpal_ns = 0; -uint64_t g_frame_pal_tail_ns = 0; -std::atomic g_dt_hist[5] = {}; // <3.6 / <4.5 / <6 / <10 / >=10 ms -std::atomic g_slow_frame_log_budget{0}; -std::atomic g_warm_decodes{0}; -std::atomic g_warm_deferred{0}; -std::atomic g_warm_mesh_log_budget{0}; -std::atomic g_warm_tex_log_budget{0}; -uint64_t g_takeover_frame = 0; -// Camera-update cadence (the "world judders while panning at 320 fps" -// question): if the guest updates its camera on a fixed sim tick while the -// render loop free-runs, view_proj repeats for several rendered frames and -// then steps, visible as the surroundings skipping to catch up. Counted per -// published scene on the guest render thread. -std::atomic g_cam_changes{0}; -std::atomic g_cam_repeats{0}; -std::atomic g_cam_max_streak{0}; - -bool SceneEnabled() { return REXCVAR_GET(skate3_native_render_scene); } +// Cross-thread renderer state (published scene, prewarm queues, capture +// rows, telemetry, perf windows) lives in skate3_native_scene_state.h. float LoadGuestF32(uint8_t* base, uint32_t addr) { const uint32_t bits = REX_LOAD_U32(addr); @@ -1786,9 +940,6 @@ std::atomic g_draw_seq{0}; // palette, still projects on-screen at a plausible spread, and rendered // glued to his walking bones (the player-becomes-the-vehicle bug). std::atomic g_last_draw_ibvb{0}; -// drew_inside captures whose bank provably belonged to another mesh's draw -// (now deferred to the post-draw fixup instead of consumed). -std::atomic g_capture_foreign_bank{0}; // Where does this bank keep its bone palette? Pre-pass layout: c4 (bone 0's // affine rows right after viewproj). Main-pass layout: camera position at @@ -2323,6 +1474,7 @@ bool GuestReadableApprox(uint8_t* base, uint32_t addr) { // (volatile fn-ptr + noinline: plain __try{memcpy}__except compiles to an // UNPROTECTED `jmp memcpy`) moved with it. +} // namespace // SEH-guarded single-value guest loads for registry-time walks (the // loading-screen prewarm): unlike the capture-path walks, whose pointers // the game is actively rendering from, registry probes dereference @@ -2353,6 +1505,8 @@ bool GuestTryLoadU64(uint8_t* base, uint32_t addr, uint64_t* out) { return true; } +namespace { + // Guarded bounded C-string read (`out` gets up to cap-1 chars + NUL, tail // zeroed like the old byte-at-a-time loops). Fast path is one bulk guarded // copy; a short string right before an unmapped page falls back to @@ -2438,6 +1592,7 @@ bool ComputeItemFingerprint(uint8_t* base, DrawItem& item) { // world left as identity). Returns false if any pointer in the chain is // implausible. For dynamic entities this MUST run at RenderMesh hook time; // the whole chain lives in transient per-frame arenas. +} // namespace // Parse a guest mesh struct (vertex descriptor, buffers, bbox, material // channels, payload fingerprint) into the item. The draw list and world // transform are the caller's responsibility (world starts as identity). @@ -2864,6 +2019,8 @@ bool BuildItemFromMesh(uint8_t* base, uint32_t mesh, DrawItem& item) { return true; } +namespace { + // Streamed-artwork override (event ads): the Massive ad system rebinds // texture fetch slots at draw time to show the current event-ad art in // place of the asset's default poster. Two observed shapes: a 16x16 @@ -2959,19 +2116,20 @@ struct CachedItemCore { }; std::mutex g_item_cache_mutex; std::unordered_map g_item_cache; -std::atomic g_item_cache_hits{0}; -std::atomic g_item_cache_builds{0}; void InvalidateCachedItem(uint32_t mesh) { std::lock_guard lock(g_item_cache_mutex); g_item_cache.erase(mesh); } +} // namespace void ClearItemCache() { std::lock_guard lock(g_item_cache_mutex); g_item_cache.clear(); } +namespace { + bool BuildItemFromMeshCached(uint8_t* base, uint32_t mesh, DrawItem& item) { const uint64_t frame = g_guest_frame; { @@ -3279,25 +2437,6 @@ void OnAddRenderInstance(uint8_t* base, uint32_t instance) { QueueModelMeshes(base, model); } -// ---- Photo-editor postfx capture (FrameScene::PhotoFx / photo_fx.hlsl) ---- -// While a photo flow is active (g_photo_flow_frame, armed by -// UpdatePhotoGrabWindow), the OnDrawDone hook snapshots each postfx pass's -// PS/VS constant rows (g_ps_bank/g_vs_bank, the game's staging banks) plus -// the device fetch-constant shadow (device+0x480) at DRAW time, the same -// reads the F11 per-draw records use, verified correct for every postfx -// pass in captures 1783957072 and 1783963636. Upload-time (SetPending) -// capture was removed: the game stages the next pass's rows while the -// previous pass's shader is still bound, so upload-time snapshots mix -// neighbouring passes' constants. -enum PfxPass { - kPfxVisualFx = 0, - kPfxDofDown, - kPfxDofMB, - kPfxDof, - kPfxUber, - kPfxFisheye, - kPfxPassCount -}; struct PfxCapture { float ps[32][4]; float vs[8][4]; @@ -3306,18 +2445,7 @@ struct PfxCapture { bool vs_seen; }; PfxCapture g_pfx_cap[kPfxPassCount] = {}; -std::atomic g_photo_flow_frame{false}; -// Photo display-card sighting stamp (see the photo-grab helpers for the -// full story): stamped by Publish2dDraws on the guest thread whenever the -// card-signature quad is in the 2D stream; read by YieldForPhotoDisplay. -std::atomic g_photo_card_seen_ns{-1}; -// Last ScreenshotBackEnd grab REQUEST (PerfClock ns): the game arms a grab -// 1-2 frames before it renders the shot + card-composite frame sequence and -// CPU-reads the resolves (GrabScreenshot -> OnScreenShot compose). The -// shutter burst (UpdatePhotoGrabWindow) keys on this: the ONLY moment the -// card-build's inputs can be made real under the native path. -std::atomic g_photo_grab_request_ns{-1}; void OnPhotoGrabRequest() { g_photo_grab_request_ns.store( @@ -3327,12 +2455,6 @@ void OnPhotoGrabRequest() { std::memory_order_relaxed); } -// GrabScreenshot returned: the grab CPU-read the resolved shot, and the -// OnScreenShot card compose that follows (same guest thread, same Update) -// only reads memory the burst already copied. The burst closes here: -// event-driven, no timers (a short grace in the window logic covers the -// aux-target grab some flows issue on the following frame). -std::atomic g_photo_grab_done_ns{-1}; void OnPhotoGrabDone() { g_photo_grab_done_ns.store( @@ -3358,12 +2480,6 @@ bool PhotoCardVisible() { .count(); return now_ns - seen < 250'000'000; } -// Defined with the yield helpers below (anonymous namespace); used by the -// BuildFrameScene publish gate (the chain must only run while the editor -// itself is up). -namespace { -const char* PhotoEditorSignal(uint8_t* base); -} // namespace // Debug-path classification, cached per shader object (guest render thread // only, like the blur classifier). -1 = not a photo postfx shader. @@ -3474,10 +2590,6 @@ void OnVsConstantUpload(uint8_t* base, uint64_t mask, uint32_t bank, uint32_t pt // the sole capture site now. } -// Last PhotoReplayController::Update heartbeat, nanoseconds on PerfClock -// (steady). -1 until the first heartbeat. Written from the guest thread, -// read by RenderScene on the render thread. -static std::atomic g_photo_replay_last_ns{-1}; void OnPhotoReplayUpdate() { g_photo_replay_last_ns.store( @@ -3487,12 +2599,6 @@ void OnPhotoReplayUpdate() { std::memory_order_relaxed); } -// Last FrontEndState_Replay2::TakePhoto heartbeat: a photo was just taken -// (replay editor or photo mission). The frames that follow render and -// CPU-grab the 1152x640 screenshot target, so the forced-readback window -// (UpdatePhotoGrabWindow) stays armed for a few seconds after it. Same -// thread contract as the photo-replay heartbeat above. -static std::atomic g_take_photo_last_ns{-1}; void OnTakePhoto() { g_take_photo_last_ns.store( @@ -3502,23 +2608,6 @@ void OnTakePhoto() { std::memory_order_relaxed); } -// Last rw::movie::MovieDecoder::Decode heartbeat (FMV playback: intro -// logos, any full-motion video). Same contract as the photo-replay -// heartbeat above. -static std::atomic g_movie_decode_last_ns{-1}; -// Last frame the 2D replay actually SUBSTITUTED a movie quad (ps_yuv2d). -// Consulted by YieldForMovie: while the native substitution is serving the -// video, the emulated yield must never engage; the emulated framebuffer -// is STALE under draw suppression, and presenting it flashed the previous -// video's last frame at every video boundary. -static std::atomic g_movie_native_last_ns{-1}; -// Last frame the 2D replay CONTAINED a video quad at all (substituted or -// not; detection runs regardless of movie_sub). Consulted by YieldForMovie: -// with no quad on screen there is nothing a yield could display - a -// skipped/force-completed video (intro skip) leaves the decoder heartbeat -// alive ~0.5 s with no quad anywhere, and yielding then just flashed the -// stale emulated buffer (the "momentary emulated switch" at intro skip). -static std::atomic g_movie_quad_last_ns{-1}; void OnMovieDecode() { g_movie_decode_last_ns.store( @@ -3528,29 +2617,6 @@ void OnMovieDecode() { std::memory_order_relaxed); } -// YUV plane fetch words of the FMV frames handed to -// VideoRenderer_RwTexture::Render (Y full res, U/V half res, CPU-filled via -// Texture::Lock). Multiple videos play at once (the camera-angle select -// page runs TWO preview movies), so this is a small table keyed by the Y -// plane's fetch-words hash; the 2D replay matches each captured draw's own -// slot-0 fetch against it; a video quad samples its Y plane there (the -// camera-page previews rendered as slow GREYSCALE through the plain 2D -// shader: exactly that luma). Movie threads publish under the mutex; -// RenderScene copies per frame. -struct MoviePlanes { - // Y plane identity = the fetch constant's ADDRESS dword (words[0][1]). - // NOT a full-words hash: the words staged into the device shadow at draw - // time carry per-draw sampling flags that differ from the texture - // object's stored constant (observed: both camera-page videos - // published, exact-words match still 0 hits): the address dword is the - // physical identity both sides agree on. - uint32_t y_addr = 0; - uint32_t words[3][6]; // Y, U, V fetch constants - int64_t ns = -1; -}; -constexpr int kMaxMovies = 4; -std::mutex g_movie_mutex; -MoviePlanes g_movies[kMaxMovies]; void OnMovieFrame(uint8_t* base, uint32_t renderer) { // Plane texture members of VideoRenderer_RwTexture, from the recompiled @@ -3632,17 +2698,6 @@ uint32_t Phase2dFlags() { } -// Heartbeat of the create-a-skater editor's OWN pixel shaders: the editor's -// in-view skater draws use "_nis" compiles (cacstamp_skin_nisPS, -// cac_cloth_nisPS, cac_face_nisPS, cac_hair_nisPS, defaultcharacter_nisPS, -// cacstamp_shift_nisPS) that exist nowhere else in the game; a fresh -// sighting means the CAS editor is on screen NOW. Backs the FE-stack id-15 -// detection in YieldForCasEditor: the startup new-game flow reaches the -// editor through a different FE screen (observed: the indicator stayed -// NATIVE there). Deliberately NOT matched: the "_unwrap" texture-space -// composite variants; outfit composition also runs mid-gameplay after an -// outfit change and must never yield a gameplay frame. -std::atomic g_cas_ps_last_ns{-1}; // Guest render thread only: per-object cached debug-path classifier // (same pattern as ClassifySplineShader). @@ -9635,11359 +8690,3 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) { } // namespace skate3::native_scene -#if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN) - -namespace skate3::native_scene { -namespace { - -using rex::graphics::NativeGuestOutputBackend; -using rex::graphics::NativeGuestOutputRenderContext; -namespace xenos = rex::graphics::xenos; -namespace nrhi = rex::graphics::nrhi; - -// App-local vertex/index binding records (replacing D3D12_VERTEX_BUFFER_VIEW / -// D3D12_INDEX_BUFFER_VIEW): the RHI binds (buffer, offset) directly, no GPU -// virtual addresses. -struct VbBinding { - nrhi::Buffer* buffer = nullptr; - uint64_t offset = 0; - uint32_t size_bytes = 0; - uint32_t stride = 0; -}; -struct IbBinding { - nrhi::Buffer* buffer = nullptr; - uint64_t offset = 0; - uint32_t size_bytes = 0; -}; - -struct MeshBuffers { - nrhi::Buffer* vb = nullptr; - nrhi::Buffer* ib = nullptr; - VbBinding vb_view{}; - IbBinding ib_view{}; - uint64_t fingerprint = 0; - // Dynamic-payload decode order (DynDecodeJob::seq): the commit drops - // results older than the cached entry so multi-worker reordering cannot - // step the cloth backwards a frame. 0 on static decodes. - uint64_t dyn_seq = 0; - // Double-sided sheet prop (banners/flags): most triangles have an - // opposite-winding twin ~1cm behind, and the two faces map to DIFFERENT - // lightmap atlas cells (lit vs shaded side). Drawn without culling both - // faces z-fight once their depth gap drops below buffer precision; - // distant triangles alternate between the two cells per frame (the "flag - // flicker", stops up close where 1cm still resolves). These meshes draw - // with the backface-culling PSO instead. - bool two_sided_sheet = false; - // ROPA only: the decoded vertex array (num_verts x 14 floats, the scene - // VS layout) retained for draw-time shape blending onto the play clock. - std::vector ropa_verts; -}; - -struct GuestTexture { - nrhi::Texture* texture = nullptr; - nrhi::Buffer* upload = nullptr; // kept alive; copy recorded in deferred list - // In-place re-decode ping-pong partner (UpdateGuestTexture2DInPlace): the - // GPU may still be copying from the OTHER upload buffer for the previous - // frame's content change, so hot content alternates buffers instead of - // paying two buffer creations per change. - nrhi::Buffer* upload_b = nullptr; - bool upload_flip = false; - uint32_t fetch_words[6] = {}; // big-endian words as read for revalidation - nrhi::TextureView* srv = nullptr; - // For failed decodes: frame number for periodic retry (payload may stream - // in after the fetch constant is already valid). - uint64_t retry_after_frame = 0; - // Payload-content revalidation: a texture first seen while its payload is - // still STREAMING IN decodes to garbage (blocky macro-tile checkers / - // blacked-out walls), and the fetch words never change when the content - // finishes filling in at the same address, so the garbage decode was - // cached forever. Sampled qwords across mip 0, rechecked periodically. - uint32_t payload_addr = 0; // 0xA-mirror guest address of mip 0 - uint32_t payload_size = 0; - uint64_t payload_fp = 0; - uint64_t recheck_frame = 0; - // Consecutive failed decodes of this entry (payload still streaming in): - // drives the escalating retry backoff at commit; the first failure - // retries fast (the payload usually lands within a few frames; a fixed - // +120 held freshly streamed textures white for ~half a second), repeat - // failures back off toward the old cadence. - uint8_t fail_count = 0; - // Successful payload rechecks so far: fresh entries re-verify fast - // (2/4/8 frames; a mid-stream garbage decode otherwise stays on screen - // up to the full recheck interval) before settling at the 16-frame - // steady-state cadence. - uint8_t recheck_count = 0; - // Tiled-aware payload probes: absolute 0xA-mirror addresses of up to 16 - // of this texture's OWN mip-0 blocks (a 4x4 spread over the block grid, - // resolved through the tiled address swizzle). The old contiguous - // [payload_addr, payload_size) fingerprint was WRONG for streamed world - // textures: they are pitch-packed into shared mip pools and tiled across - // padded macro rows, so the range interleaved NEIGHBOR textures' bytes; - // pool churn kept the fingerprint flapping, the commit-time stability - // verify then rejected every legitimate heal, and freshly promoted mips - // stayed stale/low-res for seconds (the medium-distance texture pop-in; - // one promote re-logged 8x while its heals were refused). - // probe_count == 0 falls back to the range fingerprint (cubes). - // 8x8 = 64 probes: the first 4x4 = 16 grid was too sparse; a mid-stream - // decode whose 16 probed blocks happened to be final (other regions still - // garbage) COMMITTED and never healed, visible as a black/garbage decal - // until the next words change replaced it ("decal flash and replace"). - uint32_t probe_addr[64] = {}; - uint8_t probe_count = 0; - // SRV recipe remnants (2D textures): srv_format is the revalidation - // compare key and srv_mips gates the single-mip in-place update path. - // The paired t4/t5 descriptor re-creation this recipe once fed is gone; - // pairs bind their two views directly via SetTexturePair (descriptor - // management is backend-internal now). - nrhi::Format srv_format = nrhi::Format::kUnknown; - uint32_t srv_mips = 0; - bool valid = false; - // Store LRU clock: last frame this entry was served/touched. Superseded - // words states (old mip levels, pre-demote detail sets) age out once - // nothing routes to them. - uint64_t last_used_frame = 0; - // A tiled mip's padded macro-row copy faulted and fell back to the - // reported size; blocks beyond it uploaded as ZERO (the half-black - // banner mip: tiled addressing puts the image's bottom rows past - // min_size). The entry serves (better than white) but keeps re-decoding - // until a complete copy lands; the commit prefers complete decodes. - bool incomplete = false; - // Decode-time SampleProbeNearBlack verdict: the payload was near-uniform - // black when this decode was taken (a lightmap page mid-compose). The - // lightmap-slot resolve serves the white fallback instead (tint.r == 0 -> - // the unshadowed-bright window, 59810af semantics) until a heal lands - // real content; other slots ignore it (black diffuse content is legal). - bool near_black = false; - // Same-content confirmations of a near-black verdict (commit dedups of - // forced re-decodes). The forcing covers ONE race, a decode reading a - // page mid-compose while the fingerprint sampled the composed result, - // so a few confirmations prove the content is genuinely uniform and the - // forcing stops (the plain fp poll still heals a later in-place compose). - // Unbounded forcing re-decoded permanently-uniform textures every poll - // forever, each a discarded worker round trip. - uint8_t nb_redecodes = 0; - // Frame of the last decode that landed CHANGED content for this key - // (stamped by the words-key commit and the inline 2D decode). Classifies - // video-plane content changes as mid-playback (recent change -> serve the - // stale frame while the worker re-decodes, keeps 30 fps cadence) vs a - // playback-START edge (quiet for seconds -> the entry holds the PREVIOUS - // video's last frame; serving it flashed old content at every video - // boundary; hold the quad black until fresh content commits). - uint64_t last_change_frame = 0; -}; - -// THE texture identity key: FNV-1a -// over the six fetch-constant words, console identity semantics for the -// content store. Object resolves, draw-time word bindings, 2D/HUD art, and -// the decode workers all key the same g_r.tex_store entries with it. -inline uint64_t FetchWordsKey(const uint32_t words[6]) { - uint64_t key = 1469598103934665603ull; - for (int k = 0; k < 6; ++k) { - // Clamp modes (dword_0 bits 10-18, clamp_x/y/z) are SAMPLER state, not - // content identity; the same texture object binds with different clamp - // bits per draw (observed: one texture decoded twice under 01004802 vs - // 01024802, bit 17), and keying on them decodes/stores every variant - // separately. The store holds textures + SRVs only; samplers are - // static in the replay pipelines, so clamp-variant entries are exact - // duplicates. Sign bits stay: they select the host SRV format. - key ^= k == 0 ? (words[k] & ~0x0007FC00u) : words[k]; - key *= 1099511628211ull; - } - return key; -} - -// Base-level pixel area from stored fetch words (word 2 packs width-1 / -// height-1 in 13-bit fields), the material-detail downgrade compare key. -inline uint64_t FetchWordsArea(const uint32_t words[6]) { - return uint64_t((words[2] & 0x1FFFu) + 1u) * - uint64_t(((words[2] >> 13) & 0x1FFFu) + 1u); -} - -// Escalating retry backoff (native frames) for failed texture decodes: the -// payload is usually mid-stream and lands within a few frames; the first -// retry is fast (a fixed +120 held freshly streamed textures white for -// ~half a second); repeated failures back off toward the old cadence. -inline uint64_t RetryBackoff(uint8_t fails) { - const uint32_t n = fails > 0 ? uint32_t(fails) - 1 : 0u; - return std::min(120, 8ull << std::min(n, 4u)); -} -inline uint8_t BumpFail(uint8_t v) { return v < 250 ? uint8_t(v + 1) : v; } - -// FNV-1a guest-payload fingerprint (SEH-guarded reads; streaming can -// decommit the range). Returns 0 only on unreadable payloads. -// Payloads up to 64 KB hash FULLY: the runtime-composed lightmap atlas -// pages (32 KB DXT1) receive REGIONAL in-place writes as the game composes -// late-streamed cells into a page we already decoded; the old 16-qword -// probe missed writes that fell between its samples, so the revalidation -// pass never re-decoded and the affected cells served the half-composed -// first decode forever (the 2x-bright canopy / dark awning reflective_trans -// glass: one texture, early-composed cells correct, late cells -// stale). Larger payloads (streamed assets whose fetch words change when -// content moves) keep a strided sample, densified 16 -> 64 qwords. -uint64_t SamplePayloadFingerprint(uint8_t* base, uint32_t addr, uint32_t size) { - if (addr == 0 || size < 8) { - return 0; - } - uint64_t h = 1469598103934665603ull; - if (size <= 65536) { - uint64_t buf[512]; - for (uint32_t off = 0; off < size; off += sizeof(buf)) { - const uint32_t n = std::min(sizeof(buf), (size - off) & ~7u); - if (n == 0) { - break; - } - if (!GuestTryCopy(buf, base + addr + off, n)) { - return 0; - } - for (uint32_t k = 0; k < n / 8; ++k) { - h = (h ^ buf[k]) * 1099511628211ull; - } - } - return h; - } - for (uint32_t k = 0; k < 64; ++k) { - const uint32_t off = uint32_t(uint64_t(size - 8) * k / 63u) & ~7u; - uint64_t v = 0; - if (!GuestTryCopy(&v, base + addr + off, sizeof(v))) { - return 0; - } - h = (h ^ v) * 1099511628211ull; - } - return h; -} - -// Heal-pipeline telemetry (see the 600-frame stats line): verify-fail = -// commit-time stability rejections, demote-hold = mip-0 demotes served -// from the cached full-chain decode without a doomed re-decode. -std::atomic g_heal_verify_fail{0}; -std::atomic g_demote_hold{0}; -// Sticky/skip serving (the anti-white-flash layer): sticky = draws served -// with the item's previous texture while a fresh object decodes; skipnew = -// first-sight draws suppressed entirely for the in-flight window. -std::atomic g_tex_sticky_served{0}; -std::atomic g_skip_new{0}; -// Per-mesh pipeline trace (skate3_native_render_scene_trace_mesh). Render -// thread only: the traced mesh address (parsed once per frame), the traced -// mesh's current store keys (so worker-commit events can be matched), and -// the last logged per-ctx state signature (summaries log on change). -uint32_t g_trace_mesh_addr = 0; -std::unordered_set g_trace_keys; -std::unordered_map g_trace_sig; -// Words-keyed (event-ad / streamed-artwork) serving: stale = site served -// its previous art while a new-words decode is in flight; none = nothing -// decoded for the site yet (caller shows the baked placeholder). -std::atomic g_ad_stale_served{0}; -std::atomic g_ad_placeholder{0}; - -// Fill GuestTexture::probe_addr with up to 16 of the texture's own mip-0 -// blocks: a 4x4 spread over the block grid, each resolved through the same -// tiled/linear addressing the decode uses, clamped to the guarded copy's -// readable range. Probes crossing into the padded macro-row tail are skipped -// rather than clamped; the tail belongs to pool neighbors. -void BuildPayloadProbes(const rex::graphics::TextureInfo& info, uint32_t mip0_addr, - uint32_t ox, uint32_t oy, uint32_t pitch_blocks, - uint32_t copy_size, GuestTexture& out) { - out.probe_count = 0; - const rex::graphics::FormatInfo* fi = info.format_info(); - const uint32_t bpb = fi->bytes_per_block(); - if (mip0_addr == 0 || bpb == 0 || (bpb & (bpb - 1)) != 0) { - return; - } - const uint32_t bpb_log2 = uint32_t(std::countr_zero(bpb)); - const uint32_t width = info.width + 1u; - const uint32_t height = info.height + 1u; - const uint32_t cols = (width + fi->block_width - 1) / fi->block_width; - const uint32_t rows = (height + fi->block_height - 1) / fi->block_height; - for (uint32_t gy = 0; gy < 8; ++gy) { - for (uint32_t gx = 0; gx < 8; ++gx) { - const uint32_t bx = cols > 1 ? gx * (cols - 1) / 7 : 0; - const uint32_t by = rows > 1 ? gy * (rows - 1) / 7 : 0; - uint32_t off; - if (info.is_tiled) { - off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(bx + ox), int32_t(by + oy), pitch_blocks, bpb_log2)); - } else { - off = ((by + oy) * pitch_blocks + bx + ox) * bpb; - } - if (off + bpb > copy_size) { - continue; - } - out.probe_addr[out.probe_count++] = (0xA0000000u | mip0_addr) + off; - } - } -} - -// Probe-based payload fingerprint: hashes one block-leading qword (or the -// whole block for narrow formats) at each probe address. Returns 0 only when -// a probe is unreadable: same semantics as the range fingerprint. Falls -// back to the range hash when no probes were built (cube maps). -uint64_t SampleProbeFingerprint(uint8_t* base, const GuestTexture& t) { - if (t.probe_count == 0) { - return SamplePayloadFingerprint(base, t.payload_addr, t.payload_size); - } - uint64_t h = 1469598103934665603ull; - for (uint32_t i = 0; i < t.probe_count; ++i) { - uint64_t v = 0; - if (!GuestTryCopy(&v, base + t.probe_addr[i], sizeof(v))) { - return 0; - } - h = (h ^ v) * 1099511628211ull; - } - return h; -} - -// Near-UNIFORM payload detector, sampled over the same probe grid at decode -// time. A COMPOSED lightmap page or weathering overlay is never one flat -// value across its whole mip-0 (a daylight bake / grime map atlases many -// surfaces), but a page decoded mid-compose is uniform fill: zeroed OR a -// flat grey (the reported "black/grey squares": a zero-only detector -// is structurally blind to grey). A real-but-garbage lightmap binds with -// tint.r > 0 so the CSM min-clamp renders BLACK (the door 59810af's -// white-fallback shader gate cannot see), and since 59810af a garbage macro -// multiplies OVER the decal paint. Consumers act on this only for the -// WHITE-NEUTRAL slots (lightmap/macro); uniform diffuse/spec content is -// legal and unaffected. -bool SampleProbeNearBlack(uint8_t* base, const GuestTexture& t) { - if (t.probe_count < 16) { - return false; // range-fallback entries (cubes): not enough coverage - } - uint64_t a = 0, b = 0; - bool have_a = false, have_b = false; - for (uint32_t i = 0; i < t.probe_count; ++i) { - uint64_t v = 0; - if (!GuestTryCopy(&v, base + t.probe_addr[i], sizeof(v))) { - return false; - } - if (!have_a) { - a = v; - have_a = true; - } else if (v != a && !have_b) { - b = v; - have_b = true; - } else if (v != a && v != b) { - return false; // 3+ distinct block values = real composed content - } - } - return true; // <= 2 distinct block values across the whole probe grid -} - -// Host format mapping for the formats Skate 3 uses (mirrors the SDK's -// D3D12 texture cache table). host_swizzle remaps guest data components -// before the fetch-constant swizzle composes on top. -struct HostTextureFormat { - nrhi::Format resource_format = nrhi::Format::kUnknown; - nrhi::Format srv_format = nrhi::Format::kUnknown; - uint32_t host_swizzle = xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA; -}; - -bool GetHostTextureFormat(xenos::TextureFormat format, HostTextureFormat& out) { - switch (rex::graphics::GetBaseFormat(format)) { - case xenos::TextureFormat::k_DXT1: - out = {nrhi::Format::kBC1_UNORM, nrhi::Format::kBC1_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; - return true; - case xenos::TextureFormat::k_DXT2_3: - out = {nrhi::Format::kBC2_UNORM, nrhi::Format::kBC2_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; - return true; - case xenos::TextureFormat::k_DXT4_5: - out = {nrhi::Format::kBC3_UNORM, nrhi::Format::kBC3_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; - return true; - case xenos::TextureFormat::k_DXT5A: - out = {nrhi::Format::kBC4_UNORM, nrhi::Format::kBC4_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RRRR}; - return true; - case xenos::TextureFormat::k_DXN: - out = {nrhi::Format::kBC5_UNORM, nrhi::Format::kBC5_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGGG}; - return true; - case xenos::TextureFormat::k_8_8_8_8: - out = {nrhi::Format::kR8G8B8A8_UNORM, nrhi::Format::kR8G8B8A8_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; - return true; - case xenos::TextureFormat::k_8: - out = {nrhi::Format::kR8_UNORM, nrhi::Format::kR8_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RRRR}; - return true; - case xenos::TextureFormat::k_8_8: - out = {nrhi::Format::kR8G8_UNORM, nrhi::Format::kR8G8_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGGG}; - return true; - case xenos::TextureFormat::k_5_6_5: - // Red/blue swapped CPU-side while uploading. - out = {nrhi::Format::kB5G6R5_UNORM, nrhi::Format::kB5G6R5_UNORM, - xenos::XE_GPU_TEXTURE_SWIZZLE_RGBB}; - return true; - default: - return false; - } -} - -// Compose the fetch-constant swizzle over the host-format component remap -// into a per-channel view swizzle (Xenos 0..5 = X,Y,Z,W,0,1, the same -// order as nrhi::Swizzle). -void ComposeSrvSwizzle(uint32_t fetch_swizzle, uint32_t host_swizzle, - nrhi::Swizzle out[4]) { - for (uint32_t c = 0; c < 4; ++c) { - const uint32_t guest = (fetch_swizzle >> (3 * c)) & 7u; - const uint32_t v = guest >= 4 ? guest : ((host_swizzle >> (3 * guest)) & 7u); - out[c] = nrhi::Swizzle(v); - } -} - -void SwapGuestEndian(uint8_t* data, uint32_t size, xenos::Endian endian) { - switch (endian) { - case xenos::Endian::k8in16: - for (uint32_t i = 0; i + 2 <= size; i += 2) { - std::swap(data[i], data[i + 1]); - } - break; - case xenos::Endian::k8in32: - for (uint32_t i = 0; i + 4 <= size; i += 4) { - std::swap(data[i], data[i + 3]); - std::swap(data[i + 1], data[i + 2]); - } - break; - case xenos::Endian::k16in32: - for (uint32_t i = 0; i + 4 <= size; i += 4) { - std::swap(data[i], data[i + 2]); - std::swap(data[i + 1], data[i + 3]); - } - break; - default: - break; - } -} - -struct RendererState { - nrhi::Device* device = nullptr; - nrhi::BindingLayout* layout = nullptr; // main scene binding layout (was the root signature) - nrhi::Pipeline* pso = nullptr; - nrhi::Pipeline* pso_cullback = nullptr; // two_sided_sheet meshes (see MeshBuffers) - nrhi::Pipeline* pso_nodepth = nullptr; - // environment.transparent sub-pass: straight alpha blend, depth test on, - // z-write OFF; items drawn back-to-front after all opaque items. - nrhi::Pipeline* pso_transparent = nullptr; - // Entity-fade variant of the transparent PSO: same straight alpha blend - // but z-write ON. A fading character/vehicle is a solid object at partial - // opacity; z-write-off blending composites every overlapping piece (skin - // under clothes, far-side doors/wheels through the body shell) into an - // x-ray. With depth writes the nearest surface wins and each pixel blends - // once, matching the game's main-pass fade. - nrhi::Pipeline* pso_fade = nullptr; - // Hair sub-passes: transparent blend state with cull BACK / cull FRONT - // (the game's cac_hair/defaulthair two-pass draw order). - nrhi::Pipeline* pso_hair_a = nullptr; - nrhi::Pipeline* pso_hair_b = nullptr; - nrhi::Format rtv_format = nrhi::Format::kUnknown; - nrhi::Texture* depth = nullptr; - uint32_t depth_width = 0; - uint32_t depth_height = 0; - // Cached guest-output texture identity (context.guest_output) for change - // detection: the presenter recreates the output image on resize. - nrhi::Texture* rtv_resource = nullptr; - // MSAA: the scene draws into msaa_color (+ MSAA depth) and a fullscreen - // pass averages the samples into the guest output texture (the RHI command - // stream has no resolve copy). - uint32_t msaa = 1; - nrhi::Texture* msaa_color = nullptr; - nrhi::Pipeline* resolve_pso = nullptr; - nrhi::TextureView* msaa_srv_slot = nullptr; - bool msaa_srv_allocated = false; - // Popup background blur (see kBlurShaderSource): two intermediates at the - // game's fixed 1152x640 internal resolution + a view of the guest output - // (re-created when the output texture changes). Steady state for - // blur_tex is RENDER_TARGET. - static constexpr uint32_t kBlurWidth = 1152; - static constexpr uint32_t kBlurHeight = 640; - nrhi::Texture* blur_tex[2] = {nullptr, nullptr}; - nrhi::TextureView* blur_srv[2] = {nullptr, nullptr}; - nrhi::TextureView* output_srv_slot = nullptr; - bool output_srv_allocated = false; - nrhi::Pipeline* pso_blur = nullptr; - nrhi::Pipeline* pso_blur_blit = nullptr; - nrhi::Pipeline* pso_blur_down = nullptr; - // Settings-menu gaussian backdrop (ps_menu_gauss): clean separable - // gaussian on half-output-res ping-pong intermediates, applied at the END - // of the compose so it frosts everything drawn (3D + HUD/2D + FMV). - // Deliberately independent of the popup-blur port above (that chain - // reproduces the console's 1152x640 lattice on purpose). Steady state - // RENDER_TARGET; recreated on output size change. - nrhi::Pipeline* pso_menu_gauss = nullptr; - nrhi::Texture* menu_blur_tex[2] = {nullptr, nullptr}; - nrhi::TextureView* menu_blur_srv[2] = {nullptr, nullptr}; - uint32_t menu_blur_w = 0; - uint32_t menu_blur_h = 0; - // Native photo grab (photo_grab_native): the blur downsample pass (whose - // 1152x640 blur space happens to BE the game's screenshot-target raster) - // renders the finished frame into blur_tex[0], which is then copied into - // one of these persistently-mapped READBACK buffers; the next armed frame - // CPU-tiles the completed one big-endian into the guest grab target. Two - // buffers so no frame ever waits on the GPU (a full kFull-style drain per - // frame was the old path's stall). Row pitch 1152*4 = 4608 is already - // 256-aligned. - static constexpr uint32_t kGrabRowPitch = kBlurWidth * 4; - nrhi::Buffer* grab_readback[2] = {nullptr, nullptr}; - uint8_t* grab_readback_ptr[2] = {nullptr, nullptr}; - uint64_t grab_submission[2] = {0, 0}; - bool grab_pending[2] = {false, false}; - uint32_t grab_write_index = 0; - bool grab_failed = false; - // Window stats for the close log: frames written to guest + CPU tile time. - uint32_t grab_writes = 0; - uint64_t grab_cpu_us = 0; - // Native FMV: overlay-2D pipeline variant whose PS combines the movie - // player's three CPU-filled YUV plane textures (ps_yuv2d; substituted for - // the captured movie quad in the 2D replay, see OnMovieFrame). - nrhi::Pipeline* pso_yuv2d = nullptr; - // Selection outline (see DrawItem::selected): selected items re-render - // into a single-sample R8 mask at OUTPUT resolution (a low-res mask - // stairstepped the contour centerline at 4K) and a fullscreen edge-detect - // pass with fixed UV-fraction tap pitch adds the blue outline onto the - // resolved output (postfx_edgedetectstencil equivalent). Mask steady - // state is RENDER_TARGET. - nrhi::Texture* outline_mask = nullptr; - uint32_t outline_mask_width = 0; - uint32_t outline_mask_height = 0; - nrhi::TextureView* outline_mask_srv = nullptr; - bool outline_mask_srv_allocated = false; - nrhi::Pipeline* pso_outline_mask = nullptr; - nrhi::Pipeline* pso_outline_edge = nullptr; - // Photo-editor postfx chain (photo_fx.hlsl: exact ucode ports, see - // FrameScene::PhotoFx). Own binding layout: CBV b0 (the pass's 256 - // captured/baked constant rows) + eight single-texture tables t0..t7 + - // three static samplers. Intermediates at the game's exact half/quarter - // sizes (the DOF tap offsets are baked for them); the full-res stages run - // at output resolution. pfx_quarter persists across frames (accumulation - // input). All pfx color targets idle in RENDER_TARGET state. - static constexpr uint32_t kPfxHalfW = 576, kPfxHalfH = 320; - static constexpr uint32_t kPfxQuarterW = 288, kPfxQuarterH = 160; - nrhi::BindingLayout* pfx_layout = nullptr; - // PSO order: depthpack, visualfx, dof_down, dof_mb, dof, uber, fisheye, blit. - nrhi::Pipeline* pfx_pso[9] = {}; - nrhi::Texture* pfx_full[2] = {}; // output-res RGBA8 (visualfx out, uber out) - nrhi::Texture* pfx_half[2] = {}; // 576x320 RGBA8 - nrhi::Texture* pfx_quarter = nullptr; // 288x160 RGBA8 accumulation - nrhi::Texture* pfx_depth = nullptr; // output-res packed-depth RGBA8 - nrhi::Texture* pfx_lut = nullptr; // 32^3 identity grade LUT (RGBA8) - nrhi::Buffer* pfx_lut_upload = nullptr; - bool pfx_lut_uploaded = false; - nrhi::Buffer* pfx_cb = nullptr; // upload heap, persistently mapped - uint8_t* pfx_cb_ptr = nullptr; - // Fixed SRV views: 0/1 = pfx_full, 2/3 = pfx_half, 4 = quarter, 5 = - // packed depth, 6 = LUT, 7 = the native MSAA/1x depth resource. - nrhi::TextureView* pfx_srv[8] = {}; - bool pfx_srv_allocated = false; - uint32_t pfx_width = 0, pfx_height = 0; - bool pfx_ready = false; - bool pfx_failed = false; - // Screen-space ambient occlusion (ssao.hlsl: GTAO over the resolved - // scene; see ApplySsaoPass). Own binding layout: root constants b0 + two - // single-texture tables t0/t1 + point/linear clamp samplers. Full-res - // intermediates: linearized view-Z (R32F) + two AO ping-pong planes - // (R8), idle in RENDER_TARGET state. PSOs rebuild when the MSAA level - // (depth SRV dimension / linearize variant) or the output format - // (composite target) changes. - nrhi::BindingLayout* ao_layout = nullptr; - nrhi::Pipeline* pso_ao_linearize = nullptr; - nrhi::Pipeline* pso_ao_gtao = nullptr; - nrhi::Pipeline* pso_ao_blur = nullptr; - nrhi::Pipeline* pso_ao_luma = nullptr; - nrhi::Pipeline* pso_ao_composite = nullptr; - nrhi::Pipeline* pso_ao_debug = nullptr; - nrhi::Texture* ao_lin_depth = nullptr; - nrhi::Texture* ao_tex[2] = {}; - // Scene luminance at the AO raster: the sun-lit protection mask input - // (bright pixels resist AO and skip the march entirely). - nrhi::Texture* ao_luma = nullptr; - nrhi::TextureView* ao_lin_srv = nullptr; - nrhi::TextureView* ao_srv[2] = {}; - nrhi::TextureView* ao_luma_srv = nullptr; - // View of the scene depth buffer, re-pointed when depth is rebuilt. - nrhi::TextureView* ao_depth_srv = nullptr; - nrhi::Texture* ao_depth_srv_of = nullptr; - uint32_t ao_width = 0, ao_height = 0; // AO raster (half or full res) - uint32_t ao_lin_width = 0, ao_lin_height = 0; // linear depth (always full) - uint32_t ao_msaa = 0; - nrhi::Format ao_rtv_format = nrhi::Format::kUnknown; - bool ao_hdr = false; // luma/gtao/composite built for the HDR target - bool ao_failed = false; - // Fused-AO handoff (HDR path): the AO pass leaves the finished multiplier - // plane (ao_tex[0]) in PIXEL_SHADER_RESOURCE state for ps_tonemap to - // consume at t2 (replacing the classic full-res composite draw); - // ApplyHdrPost restores it to RENDER_TARGET and clears the flag. - bool ao_plane_in_psr = false; - // HDR pipeline (hdr.hlsl): the scene renders pre-tonemap linear into a - // float intermediate (the MSAA color target and/or hdr_resolved), a bloom - // pyramid extracts real HDR energy, and ps_tonemap applies the game's - // shared tone chain once into the gamma guest output. Binding layout = - // the SSAO shape (root constants b0 + two single-texture tables + - // point/linear clamp samplers). All targets idle in RENDER_TARGET state. - bool hdr_active = false; // latched per pipeline build (PSO formats/variants) - bool hdr_failed = false; // float targets unavailable -> classic path - // Latched scene float format (RGBA16F or the packed R11G11B10 option); - // every HDR-path PSO/target creation reads this, never the cvar. - nrhi::Format hdr_scene_format = nrhi::Format::kR16G16B16A16_FLOAT; - nrhi::BindingLayout* hdr_layout = nullptr; - nrhi::Pipeline* pso_bloom_first = nullptr; // mip-0 extract (threshold+Karis) - nrhi::Pipeline* pso_bloom_down = nullptr; - nrhi::Pipeline* pso_bloom_up = nullptr; // additive ONE/ONE tent upsample - nrhi::Pipeline* pso_tonemap = nullptr; - nrhi::Format hdr_pso_out_format = nrhi::Format::kUnknown; - // 1x float scene plane: the MSAA resolve destination (or the scene target - // itself when MSAA is off); the AO composite, bloom extract and tonemap - // all consume/write it. - nrhi::Texture* hdr_resolved = nullptr; - nrhi::TextureView* hdr_srv = nullptr; - static constexpr uint32_t kBloomMaxLevels = 6; - nrhi::Texture* bloom_tex[kBloomMaxLevels] = {}; - nrhi::TextureView* bloom_srv[kBloomMaxLevels] = {}; - uint32_t bloom_w[kBloomMaxLevels] = {}; - uint32_t bloom_h[kBloomMaxLevels] = {}; - uint32_t bloom_levels = 0; - uint32_t bloom_base_w = 0, bloom_base_h = 0; // output size the chain fits - bool targets_hdr = false; // output-sized targets built for the HDR path - // Scene float format the output-sized targets were built with (kUnknown - // when classic); packed-format toggles rebuild them. - nrhi::Format targets_scene_fmt = nrhi::Format::kUnknown; - // Screen-space reflections (ssr.hlsl + scene.hlsl ps_refl_gbuf): a - // half-res reflection G-buffer re-rendered from the frame's reflective - // items (env fams 5/6/13 + water), a half-res screen-space march over the - // full-res linear depth sampling the pre-tonemap HDR plane, and a - // src-alpha composite back onto it between the AO pass and the HDR post - // (so reflections tonemap/bloom like directly-visible scenery). HDR path - // only. Post binding layout = the SSAO shape with 32 root floats + three - // single-texture tables; the G-buffer pass runs under the MAIN layout - // (scene VS + per-item constants). All intermediates idle in - // RENDER_TARGET state. - nrhi::BindingLayout* ssr_layout = nullptr; - nrhi::Pipeline* pso_ssr_gbuf = nullptr; // scene-VS geometry pass - nrhi::Pipeline* pso_ssr_linearize = nullptr; // fallback when SSAO idle - nrhi::Pipeline* pso_ssr_march = nullptr; - nrhi::Pipeline* pso_ssr_composite = nullptr; - nrhi::Texture* ssr_gbuf = nullptr; // half res RGBA16F reflection G-buffer - // Half-res depth for the G-buffer pass (test+write LESS): reflective - // items overlap themselves in screen space (a curved glass tower's far - // side shares texels with its near side inside ONE mesh), so without a - // depth test draw/triangle order decides which surface owns a texel and - // whole visible panes fail the march's scene-depth match. Idles in - // DEPTH_WRITE state. - nrhi::Texture* ssr_gbuf_depth = nullptr; - nrhi::Texture* ssr_tex = nullptr; // half res RGBA16F march output - nrhi::Texture* ssr_lin_depth = nullptr; // full res R32F (own linearize) - nrhi::TextureView* ssr_gbuf_srv = nullptr; - nrhi::TextureView* ssr_srv = nullptr; - nrhi::TextureView* ssr_lin_srv = nullptr; - // Scene-depth SRV for the own linearize, re-pointed on depth rebuilds. - nrhi::TextureView* ssr_depth_srv = nullptr; - nrhi::Texture* ssr_depth_srv_of = nullptr; - uint32_t ssr_width = 0, ssr_height = 0; // half-res raster - uint32_t ssr_lin_width = 0, ssr_lin_height = 0; - uint32_t ssr_msaa = 0; // linearize-variant PSO latch - nrhi::Format ssr_scene_fmt = nrhi::Format::kUnknown; // composite target - bool ssr_failed = false; - // Per-frame handoff: the G-buffer pass drew and left ssr_gbuf in - // PIXEL_SHADER_RESOURCE for ApplySsrPass (which consumes + restores it). - bool ssr_gbuf_ready = false; - std::unordered_map meshes; - // (The old D3D12 bookkeeping, the retired-resource vector, the CPU SRV - // staging heap and its slot allocator/recycling lists, is gone: resource - // destruction is deferred inside the RHI (Device::DestroyDeferred) and - // texture bindings are backend-managed view objects.) - GuestTexture white; - // Water environment CUBE maps (t6): separate cache; same guest object - // addresses decode differently (6 faces, TextureCube SRV). - std::unordered_map cube_textures; - GuestTexture white_cube; - // Bone palette ring: persistent-mapped upload buffer, one region per - // in-flight frame. - static constexpr uint32_t kBoneRegionSize = 1u << 20; - // 8-deep (was 4): the Vulkan CP was observed letting the GPU lag more - // than 3 frames under load - 4-deep regions then recycle while still - // referenced (hair shimmer / collapsed garments). If the ring race is - // confirmed, the CP-side throttle is the - // proper fix and this is defense in depth. - static constexpr uint32_t kBoneRegions = 8; - nrhi::Buffer* bone_ring = nullptr; - uint8_t* bone_ring_cpu = nullptr; - uint32_t bone_ring_offset = 0; - // ROPA shape-generation ring (per garment mesh): the last decoded vertex - // arrays keyed by dyn_seq, so the draw can combine the generations under - // the interp pass's kernel weights (the body's own 8-tap boxcar / - // pair-lerp: the stepped OR filter-mismatched shape against the - // interpolated body was the tee jelly). Plus a persistent-mapped upload - // ring the per-frame blended verts are written into (regioned like the - // bone ring). - struct RopaGen { - uint64_t seq = 0; - // Host time at commit: the blend refuses to mix generations recorded - // across a sim-sleep gap (a garment whose cloth sim slept keeps its - // last drapes in the ring; blending one of those, recorded when the - // character stood elsewhere / faced differently, against the fresh - // drape renders the garment rotated/offset from the body). - double t = 0.0; - std::vector verts; // num_verts x 14 floats (scene VS layout) - }; - std::unordered_map> ropa_shapes; - static constexpr uint32_t kRopaRegionSize = 1u << 20; - nrhi::Buffer* ropa_ring = nullptr; - uint8_t* ropa_ring_cpu = nullptr; - uint32_t ropa_ring_offset = 0; - // 2D overlay (HUD/APT replay): alpha-blended depth-less pipeline drawing - // the captured inline vertices from a per-frame upload ring. - nrhi::Pipeline* pso_2d = nullptr; - // In-world neon splines, drawn inside the MSAA scene pass (depth test on, - // no z-write): darken = straight alpha, default = additive glow. - nrhi::Pipeline* pso_spline_darken = nullptr; - nrhi::Pipeline* pso_spline_default = nullptr; - // Dynamic CSM shadows: casters render - // into a 3-tile (depth, coverage) atlas with MIN blend (depth clear 1, - // "uncoverage" clear 1 -> covered texels write 0), then the game's exact - // Gaussian-coverage + depth-dilation blur runs per tile (5-tap cascade 0, - // 3-tap cascade 1, format-convert-only cascade 2) into the atlas the - // scene pass samples at t5. - nrhi::Pipeline* pso_shadow_caster = nullptr; - nrhi::Pipeline* pso_shadow_blur = nullptr; - nrhi::Texture* shadow_raw = nullptr; // caster pass target - nrhi::Texture* shadow_mid = nullptr; // hblur output - nrhi::Texture* shadow_final = nullptr; // vblur output, sampled - uint32_t shadow_tile = 0; // per-cascade tile size (atlas = 3*tile x tile) - nrhi::TextureView* shadow_srv_raw = nullptr; - nrhi::TextureView* shadow_srv_mid = nullptr; - nrhi::TextureView* shadow_srv_final = nullptr; - // After a shadow pass all three textures sit in PIXEL_SHADER_RESOURCE; - // the next pass transitions them back to RENDER_TARGET first. - bool shadow_in_srv_state = false; - // Shadow-atlas readback, taken on kShadowDumpFrames CONSECUTIVE frames - // once per F11 recording session: the raw and blurred planes (R16G16) - // land in the snapshot dir for offline analysis; the guest's own atlas - // resolve never reaches CPU memory, so this is the only view of what - // receivers actually sample. Consecutive frames make temporal-stability - // diffs possible (silhouette buzz vs animation). - static constexpr uint32_t kShadowDumpFrames = 4; - nrhi::Buffer* shadow_dump_buf[kShadowDumpFrames] = {}; - uint8_t* shadow_dump_ptr[kShadowDumpFrames] = {}; - uint64_t shadow_dump_submission[kShadowDumpFrames] = {}; - uint32_t shadow_dump_enqueued = 0; - uint32_t shadow_dump_written = 0; - bool shadow_dump_done = false; - // Per-frame shadow constant buffer ring (CBV b1). 512-byte slices: the - // first 256 bytes are the original 16-row block, rows 16-18 carry the - // dynamicobject world-shadow transform (dyn_ws* in the scene shader). - static constexpr uint32_t kShadowCbRegions = 8; - static constexpr uint32_t kShadowCbSlice = 512; - nrhi::Buffer* shadow_cb = nullptr; - uint8_t* shadow_cb_cpu = nullptr; - // dynamicobject static world-shadow map (512x512, same convention as the - // game's own: x = light-space depth, sge(x >= ray) = lit): re-rendered - // natively from the frame's STATIC world items with the captured c5/c6/c7 - // transform. MIN blend accumulates across frames WITHOUT clearing; the - // per-frame item list is view-culled, and dropping off-screen casters - // would flicker props' shade with the camera. Cleared (re-primed) only - // when the captured transform changes (streaming region switch). - nrhi::Texture* world_shadow = nullptr; - nrhi::TextureView* world_shadow_srv = nullptr; - bool world_shadow_in_srv = false; - bool world_shadow_primed = false; - float world_shadow_rows[12] = {}; - static constexpr uint32_t kWorldShadowSize = 512; - static constexpr uint32_t kUiRegionSize = 1u << 20; - static constexpr uint32_t kUiRegions = 8; - nrhi::Buffer* ui_ring = nullptr; - uint8_t* ui_ring_cpu = nullptr; - // Last successfully resolved words-key per streamed-art site - // (mesh << 1 | slot; slot 0 = diffuse override, 1 = decal override). - // Serves the site's previous art while a new-mip-words decode is in - // flight (see resolve_fetch_words). Render thread only. - std::unordered_map words_sticky; - // (The fam 5/6 masks+normal t4/t5 descriptor-pair cache is gone: draws - // bind both views directly via cmd->SetTexturePair(5, spec, normal); - // pair descriptors are cached inside the backend, so texture replacement - // can never leave a stale descriptor.) - // THE texture content store (console identity - // semantics): fetch-words key -> decode. One - // words state = one entry; both states of a streaming flap A<->B simply - // stay resident under their own keys (what the old lookaside simulated - // with park/take), and superseded states age out via the LRU. Shared by - // the 3D draw path (through the object routes below), the 2D/HUD pass, - // and the draw-time fetch-word overrides (posters/ads). Render thread - // only. - std::unordered_map tex_store; - // Texture object -> its last STABLE fetch-words state (seqlock - // double-read at resolve). A route is a lookup aid, never an owner: the - // game freely retargets objects (mip promote/demote, detail demote, - // object reuse) and every retarget is just a different store key; a - // reused object can never serve another binding's art. Render thread - // only. - struct TexRoute { - uint32_t words[6] = {}; - uint64_t key = 0; - // Live words carry no mip-0 base (streamer demoted the top level; the - // old pool range is already reused). The route holds the pre-demote - // state, its cached decode carries the full chain, strictly better, - // and payload polls are suspended while held (the probes would read - // the reused pool). A re-promote publishes fresh words and re-routes. - bool demoted = false; - }; - std::unordered_map tex_routes; - // Sticky texture serving (see resolve_texture in draw_item): the last - // ADOPTED (served-live) words state per (mesh << 3 | slot). Serves the - // site's previous art while the current binding's decode is in flight - // (the console's own mip-transition look), and powers the - // detail-downgrade hold: a strict base-area shrink keeps serving the - // previous state's store entry for the hold window. Render thread only. - struct TexStickySite { - uint64_t words_key = 0; - uint64_t area = 0; - // Nonzero = a downgrade is being held, first seen at this frame. The - // site adopts the smaller binding once the downgrade persists past - // skate3_native_render_scene_detail_hold frames. - uint64_t downgrade_since = 0; - }; - std::unordered_map tex_sticky; - // First frame a texture object resolved white-with-heal-in-flight: - // brand-new items (no sticky fallback) skip drawing for a short window - // instead of flashing white. - std::unordered_map tex_pending_first; - bool failed = false; - bool announced = false; -}; - -RendererState g_r; - -// Retire a guest texture's GPU resources AND its view. Destruction is -// deferred inside the RHI against the CURRENT submission; the `submission` -// parameter is kept for call-site compatibility and ignored. -void RetireGuestTexture(const GuestTexture& t, uint64_t submission) { - (void)submission; - g_r.device->DestroyDeferred(t.texture); - g_r.device->DestroyDeferred(t.upload); - g_r.device->DestroyDeferred(t.upload_b); - g_r.device->DestroyDeferred(t.srv); -} - -// ---- Content store helpers -------------------------------------------------- -// The store is words-keyed; the guest -// streamer's object retargeting (mip flap A<->B, detail demote, object -// reuse) is just different keys, so both states of any transition stay -// resident and no rebind can ever serve another binding's art. -std::atomic g_store_evicted{0}; -constexpr size_t kTexStoreCap = 3072; - -uint32_t SwapU32(uint32_t v); // defined with the decode helpers below - -// Seqlock-stable read of a texture object's six fetch words, guest -> host -// order. The streamer rewrites the words word-by-word on its own thread; a -// mixed snapshot decodes a coherent image of the WRONG memory (a shared -// mip-pool page reads as a collage of neighbor art), and the result is -// stable, valid-looking content no fingerprint can reject after the fact; -// the read itself must be self-consistent. Two consecutive identical -// snapshots (4 attempts) or the caller keeps its previous route/skips. -bool ReadStableTexWords(uint8_t* base, uint32_t tex_ptr, uint32_t out[6]) { - uint32_t raw[6]; - uint32_t raw2[6]; - bool stable = false; - for (int attempt = 0; attempt < 4 && !stable; ++attempt) { - if (!GuestTryCopy(raw, base + tex_ptr + 7 * 4, sizeof(raw)) || - !GuestTryCopy(raw2, base + tex_ptr + 7 * 4, sizeof(raw2))) { - return false; - } - stable = std::memcmp(raw, raw2, sizeof(raw)) == 0; - } - if (!stable) { - return false; - } - for (uint32_t i = 0; i < 6; ++i) { - out[i] = SwapU32(raw[i]); - } - return true; -} - -// Store LRU eviction, run once per frame: superseded words states (old mip -// levels, pre-demote detail sets, one-shot UI art) hold SRV slots + GPU -// memory until nothing has routed to them for a while. Never evicts -// entries touched within the last few frames. -void EvictTexStore(uint64_t frame_number, uint64_t submission) { - if (g_r.tex_store.size() <= kTexStoreCap) { - return; - } - std::vector> ages; // (last-used frame, key) - ages.reserve(g_r.tex_store.size()); - for (const auto& [k, t] : g_r.tex_store) { - if (t.last_used_frame + 4 < frame_number) { - ages.emplace_back(t.last_used_frame, k); - } - } - const size_t excess = g_r.tex_store.size() - kTexStoreCap / 2; - const size_t n = std::min(ages.size(), excess); - if (n == 0) { - return; - } - std::nth_element(ages.begin(), ages.begin() + (n - 1), ages.end()); - for (size_t i = 0; i < n; ++i) { - const auto it = g_r.tex_store.find(ages[i].second); - if (it != g_r.tex_store.end()) { - RetireGuestTexture(it->second, submission); - g_r.tex_store.erase(it); - } - } - g_store_evicted.fetch_add(n, std::memory_order_relaxed); -} - -// ---- Staged texture decode (worker-thread half) --------------------------- -// The texture decoders normally finish by recording GPU copies into the -// frame's command stream, pushing a barrier and creating the SRV, all -// render-thread-only. When `g_tex_stage_out` is set (decode worker), they -// stop after filling the upload resource and export what the render-thread -// commit needs instead. RHI resource creation and upload mapping are -// thread-safe, so everything up to that point is safe off-thread. -struct StagedMipCopy { - uint32_t offset, pitch, w, h; // upload footprint per mip -}; -struct StagedTexCommit { - nrhi::Format copy_format = nrhi::Format::kUnknown; - nrhi::Format srv_format = nrhi::Format::kUnknown; - nrhi::Swizzle swizzle[4] = {nrhi::Swizzle::kX, nrhi::Swizzle::kY, - nrhi::Swizzle::kZ, nrhi::Swizzle::kW}; - uint32_t mip_count = 0; - // Cube map (environment cubes): mips[] holds face-major (face * levels + - // mip) subresource copies, the old D3D12 subresource numbering, which the - // commit decomposes back into (face, mip), and the SRV is a kCube view - // with cube_mip_levels levels. The cube MIP CHAIN - // is load-bearing: the game's reflective glass perturbs its reflection - // vector with a per-pixel normal map, so the hardware cube fetch runs at - // a DEEP gradient-derived LOD; a mip-0-only cube shows the plaza cube's - // baked streetlight heads as a sharp magnified smear the real console - // output blurs away. - bool cube = false; - uint32_t cube_mip_levels = 1; - // 6 faces x up to 10 levels (512 -> 1 full generated chain). - StagedMipCopy mips[64] = {}; -}; -thread_local StagedTexCommit* g_tex_stage_out = nullptr; - -// Render-thread half: record the staged upload's copies + barrier, create -// the SRV, mark the texture live. On view-creation failure the texture stays -// invalid (renders white; the backend-managed views make this near-impossible). -void CommitStagedGuestTexture(const NativeGuestOutputRenderContext& context, - GuestTexture& gt, const StagedTexCommit& sc) { - for (uint32_t m = 0; m < sc.mip_count; ++m) { - const StagedMipCopy& p = sc.mips[m]; - // Cube commits stage face-major (face * levels + mip) entries (the old - // D3D12 subresource numbering): decompose into (mip, face). - const uint32_t mip = sc.cube ? m % sc.cube_mip_levels : m; - const uint32_t face = sc.cube ? m / sc.cube_mip_levels : 0; - context.cmd->CopyBufferToTexture(gt.texture, mip, face, gt.upload, p.offset, - p.pitch, p.w, p.h, 1); - } - context.cmd->Barrier(gt.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - nrhi::TextureViewDesc vd; - vd.format = sc.srv_format; - for (uint32_t c = 0; c < 4; ++c) { - vd.swizzle[c] = sc.swizzle[c]; - } - if (sc.cube) { - vd.dimension = nrhi::ViewDimension::kCube; - vd.mip_levels = sc.cube_mip_levels; - } else { - vd.dimension = nrhi::ViewDimension::k2D; - vd.mip_levels = sc.mip_count; - gt.srv_format = sc.srv_format; - gt.srv_mips = sc.mip_count; - } - gt.srv = g_r.device->CreateTextureView(gt.texture, vd); - if (gt.srv == nullptr) { - gt.valid = false; - return; - } - gt.valid = true; -} - -// Worker-pool result plumbing (see the prewarm queue globals above; these -// live here because they need the resource/item types). -struct StagedTexResult { - uint32_t key = 0; // guest texture object address (object-keyed cache) - uint64_t words_key = 0; // != 0: content-store result (g_r.tex_store) - bool cube = false; // environment cube (g_r.cube_textures) - GuestTexture gt; - StagedTexCommit commit; - bool valid = false; - // The commit-time stability verify rejected this decode (payload moved - // between the worker's read and the commit): retry fast, not on the - // failed-decode ladder. - bool verify_failed = false; - // 2D/HUD overlay origin: skip the stability verify (see PrewarmEntry::ui). - bool ui = false; -}; -struct PrewarmResult { - DrawItem item; - MeshBuffers buffers; - bool mesh_valid = false; - std::vector textures; - // Draw-path miss result (visible right now): the commit takes it this - // frame regardless of the gameplay per-frame cap. - bool miss = false; -}; -std::mutex g_prewarm_out_mutex; -std::vector g_prewarm_out; -// Failed builds (buffer objects not initialized yet) land here; the render -// thread re-injects them each frame so retries are frame-paced instead of -// hot-spinning the workers. -std::vector g_prewarm_retry; // under g_prewarm_out_mutex - -// Shader sources live in src/native/shaders/*.hlsl; the build embeds them -// into this generated header (cmake/EmbedShaders.cmake) so the exe stays -// self-contained. Same kFooSource char arrays as before. (The offline -// SPIR-V table for the same shaders is included at the top of the file; -// it declares ::skate3::native_spirv and must not nest inside this -// namespace.) -#include "skate3_native_shaders.h" - -float HalfToFloat(uint16_t h) { - const uint32_t sign = (h & 0x8000u) << 16; - uint32_t exp = (h >> 10) & 0x1F; - uint32_t mant = h & 0x3FF; - if (exp == 0) { - if (mant == 0) return std::bit_cast(sign); - // subnormal - while (!(mant & 0x400)) { - mant <<= 1; - --exp; - } - ++exp; - mant &= 0x3FF; - } else if (exp == 31) { - return std::bit_cast(sign | 0x7F800000u | (mant << 13)); - } - return std::bit_cast(sign | ((exp + 112) << 23) | (mant << 13)); -} - -nrhi::Buffer* CreateUploadBuffer(nrhi::Device* device, size_t size) { - nrhi::BufferDesc desc; - desc.size = size; - desc.heap = nrhi::HeapKind::kUpload; - return device->CreateBuffer(desc); // nullptr on failure, like the old helper -} - -uint16_t SwapU16(uint16_t v) { return uint16_t((v >> 8) | (v << 8)); } -uint32_t SwapU32(uint32_t v) { -#if defined(_MSC_VER) - return _byteswap_ulong(v); -#else - return __builtin_bswap32(v); -#endif -} - -// Upload sub-allocation alignment for per-mip footprints (the old -// D3D12_TEXTURE_DATA_PLACEMENT_ALIGNMENT; row pitches use -// nrhi::kRowPitchAlignment = the old D3D12_TEXTURE_DATA_PITCH_ALIGNMENT). -constexpr uint32_t kUploadPlacementAlignment = 512; - -// Decode guest vertices into {float3 position, float2 uv, float2 uv2, -// unorm4 blend weights, u8x4 blend indices, float3 normal, float2 decal_uv} -// (56-byte stride). decal_uv = the 3rd/4th halves of a half4 first texcoord -// (the packed second UV pair environment.decal art is mapped with), equal to -// the first pair when the element has only two halves. -// vb_payload/ib_payload: optional pre-copied guest payload snapshots (the -// dynamic cloth decode jobs snapshot on the guest thread and convert on a -// worker, see DynDecodeJob); when null the payloads are read live from -// guest memory with the guarded copy. -bool DecodeMesh(nrhi::Device* device, uint8_t* base, const DrawItem& item, - MeshBuffers& out, const uint8_t* vb_payload = nullptr, - const uint8_t* ib_payload = nullptr) { - const uint32_t num_verts = item.vb_bytes / item.stride; - if (num_verts == 0) return false; - // This runs on the render thread; the guest payloads were valid on the - // game thread this frame but streaming can decommit them in between. - // Copy them out with the lock-free guarded copy (never VirtualQuery here: - // the VAD lock stalls behind the guest streaming threads while panning). - static thread_local std::vector vb_scratch; - static thread_local std::vector ib_scratch; - if (vb_payload == nullptr) { - vb_scratch.resize(item.vb_bytes); - if (!GuestTryCopy(vb_scratch.data(), base + item.vb_addr, item.vb_bytes)) { - return false; - } - vb_payload = vb_scratch.data(); - } - if (!item.cloth_quads && ib_payload == nullptr) { - ib_scratch.resize(size_t(item.ib_count) * 2); - if (!GuestTryCopy(ib_scratch.data(), base + item.ib_addr, size_t(item.ib_count) * 2)) { - return false; - } - ib_payload = ib_scratch.data(); - } - nrhi::Buffer* vb = CreateUploadBuffer(device, size_t(num_verts) * 56); - nrhi::Buffer* ib = CreateUploadBuffer(device, size_t(item.ib_count) * 2); - if (!vb || !ib) { - device->DestroyDeferred(vb); - device->DestroyDeferred(ib); - return false; - } - - // two_sided_sheet detection eligibility (see MeshBuffers): small static - // triangle-list meshes only. Strips alternate winding per triangle and - // skinned/cloth meshes deform, so both stay on the uncull(ed) PSO. - bool detect_sheet = - !item.skinned && !item.cloth_quads && item.ib_count >= 6 && item.ib_count <= 8192; - for (const DrawEntry& de : item.draws) { - if (de.prim != 4) { - detect_sheet = false; - } - } - std::vector sheet_pos; - if (detect_sheet) { - sheet_pos.reserve(size_t(num_verts) * 3); - } - - const uint8_t* src_vb = vb_payload; - float* dst = nullptr; - uint32_t garbage = 0; - int min_bi = 255; - int max_bi = -1; - dst = static_cast(device->Map(vb)); - for (uint32_t v = 0; v < num_verts; ++v) { - const uint8_t* p = src_vb + size_t(v) * item.stride + item.pos_offset; - float x = 0.0f, y = 0.0f, z = 0.0f; - switch (item.pos_fmt) { - case 57: { // k_32_32_32_FLOAT - x = std::bit_cast(SwapU32(*reinterpret_cast(p))); - y = std::bit_cast(SwapU32(*reinterpret_cast(p + 4))); - z = std::bit_cast(SwapU32(*reinterpret_cast(p + 8))); - break; - } - case 32: { // k_16_16_16_16_FLOAT - x = HalfToFloat(SwapU16(*reinterpret_cast(p))); - y = HalfToFloat(SwapU16(*reinterpret_cast(p + 2))); - z = HalfToFloat(SwapU16(*reinterpret_cast(p + 4))); - break; - } - case 26: { // k_16_16_16_16 snorm character dequant - const auto s16 = [&](int off) { - return int16_t(SwapU16(*reinterpret_cast(p + off))); - }; - constexpr float kScale = 2.0f / 32767.0f; - x = s16(0) * kScale; - y = s16(2) * kScale + 0.8f; - z = s16(4) * kScale; - break; - } - default: - device->Unmap(vb); - device->DestroyDeferred(vb); - device->DestroyDeferred(ib); - return false; - } - if (!(x == x && y == y && z == z) || - x < item.bbox_min[0] - 2.f || x > item.bbox_max[0] + 2.f || - y < item.bbox_min[1] - 2.f || y > item.bbox_max[1] + 2.f || - z < item.bbox_min[2] - 2.f || z > item.bbox_max[2] + 2.f) { - ++garbage; - } - const auto decode_uv = [&](uint32_t fmt, uint32_t offset, float& u, float& w) { - u = 0.0f; - w = 0.0f; - if (fmt == 0) return; - const uint8_t* q = src_vb + size_t(v) * item.stride + offset; - switch (fmt) { - case 31: // k_16_16_FLOAT - case 32: // k_16_16_16_16_FLOAT (use xy) - u = HalfToFloat(SwapU16(*reinterpret_cast(q))); - w = HalfToFloat(SwapU16(*reinterpret_cast(q + 2))); - break; - case 37: // k_32_32_FLOAT (hair strand-alpha UV; xenos enum 37) - case 38: // k_32_32_32_32_FLOAT (use xy) - u = std::bit_cast(SwapU32(*reinterpret_cast(q))); - w = std::bit_cast(SwapU32(*reinterpret_cast(q + 4))); - break; - case 25: { // k_16_16 (snorm; UVs span the full s16 range for [0,1]) - const int16_t su = int16_t(SwapU16(*reinterpret_cast(q))); - const int16_t sv = int16_t(SwapU16(*reinterpret_cast(q + 2))); - u = su / 32767.0f; - w = sv / 32767.0f; - break; - } - case 26: { // k_16_16_16_16 (snorm; use xy: unwrap UV sets) - const int16_t su = int16_t(SwapU16(*reinterpret_cast(q))); - const int16_t sv = int16_t(SwapU16(*reinterpret_cast(q + 2))); - u = su / 32767.0f; - w = sv / 32767.0f; - break; - } - default: - break; - } - }; - dst[v * 14 + 0] = x; - dst[v * 14 + 1] = y; - dst[v * 14 + 2] = z; - if (detect_sheet) { - sheet_pos.push_back(x); - sheet_pos.push_back(y); - sheet_pos.push_back(z); - } - decode_uv(item.uv_fmt, item.uv_offset, dst[v * 14 + 3], dst[v * 14 + 4]); - decode_uv(item.uv2_fmt, item.uv2_offset, dst[v * 14 + 5], dst[v * 14 + 6]); - // The second texcoord is the lightmap/decal UNWRAP, stored as an - // absolute value with tangent handedness in the sign bits, in BOTH the - // s16-snorm (fmt 26, decal meshes) and half-float (fmt 31, world tiles) - // encodings (decalenvironment VS: o0.zw = |uv|; baseenvironment VS: - // maxs r2.zw = |r4.xy|). Raw signed values sample mirrored atlas cells. - // Exception: hair meshes, their second texcoord is the strand-alpha - // UV, passed RAW by the hair VS (o0.zw = uv, float2). - if (item.char_family < 4) { - dst[v * 14 + 5] = std::fabs(dst[v * 14 + 5]); - dst[v * 14 + 6] = std::fabs(dst[v * 14 + 6]); - } - // Blend weights (unorm bytes) and indices (raw bytes). Guest u8x4 - // attributes are stored big-endian per 32-bit word; swap so component k - // in the shader matches guest component k. - uint32_t bw = 0; - uint32_t bi = 0; - if (item.skinned) { - bw = SwapU32(*reinterpret_cast(src_vb + size_t(v) * item.stride + - item.bw_offset)); - bi = SwapU32(*reinterpret_cast(src_vb + size_t(v) * item.stride + - item.bi_offset)); - for (int k = 0; k < 4; ++k) { - const uint8_t weight = uint8_t(bw >> (k * 8)); - const uint8_t index = uint8_t(bi >> (k * 8)); - if (weight != 0) { - if (index < min_bi) min_bi = index; - if (index > max_bi) max_bi = index; - } - } - } - std::memcpy(&dst[v * 14 + 7], &bw, 4); - std::memcpy(&dst[v * 14 + 8], &bi, 4); - // Decal-art UV: second half pair of a half4 first texcoord, else uv0. - if (item.uv_fmt == 32) { - decode_uv(31, item.uv_offset + 4, dst[v * 14 + 12], dst[v * 14 + 13]); - } else { - dst[v * 14 + 12] = dst[v * 14 + 3]; - dst[v * 14 + 13] = dst[v * 14 + 4]; - } - // Vertex normal: k_10_11_11 packed (x 11 bits, y 11 bits, z 10 bits, - // LSB to MSB, signed). Zero when absent -> derivative face-normal - // fallback in the shader. - const auto unpack_10_11_11 = [&](uint32_t offset, float out[3]) { - const uint32_t word = SwapU32(*reinterpret_cast( - src_vb + size_t(v) * item.stride + offset)); - const int32_t ix = int32_t(word << 21) >> 21; - const int32_t iy = int32_t((word >> 11) << 21) >> 21; - const int32_t iz = int32_t((word >> 22) << 22) >> 22; - out[0] = float(ix) / 1023.0f; - out[1] = float(iy) / 1023.0f; - out[2] = float(iz) / 511.0f; - }; - // Stored-tangent-frame pack (world-shading / dynobj v2): snorm - // component to the unorm byte of the blend-weight word. - const auto pk = [](float f) { - return uint32_t(std::lround((f * 0.5f + 0.5f) * 255.0f)) & 0xFFu; - }; - float n3[3] = {0.0f, 0.0f, 0.0f}; - if (item.env_family != 0 && - (item.env_family <= 6 || item.env_family == 13) && - item.uv2_fmt == 26) { - // Exact world families: the REAL vertex normal is packed in the - // lightmap-unwrap element (fmt 26 s16x4): zw = normal.xy (snorm), and - // the unwrap xy SIGN bits carry the handedness; sign.y flips - // normal.z (baseenvironment VS: vNormal.z = signs.y * sqrt(1 - xy^2); - // signs = saturate(65535 * lm.xy) * 2 - 1). The k_10_11_11 element on - // these meshes is the BINORMAL, not the normal; using it as the - // normal breaks the sun/spec terms of the exact shading. - const uint8_t* q = src_vb + size_t(v) * item.stride + item.uv2_offset; - const int16_t sx = int16_t(SwapU16(*reinterpret_cast(q))); - const int16_t sy = int16_t(SwapU16(*reinterpret_cast(q + 2))); - const int16_t nx = int16_t(SwapU16(*reinterpret_cast(q + 4))); - const int16_t ny = int16_t(SwapU16(*reinterpret_cast(q + 6))); - n3[0] = nx / 32767.0f; - n3[1] = ny / 32767.0f; - const float d = 1.0f - n3[0] * n3[0] - n3[1] * n3[1]; - n3[2] = (sy > 0 ? 1.0f : -1.0f) * std::sqrt(d > 0.0f ? d : 0.0f); - // World-shading v2: the STORED tangent frame. These static meshes - // never skin, so the blend-weight bytes are free: pack the mesh's - // k_10_11_11 binormal (unorm-mapped) plus the unwrap sign.x tangent - // handedness into them. The shader reconstructs T = cross(B, N) x - // handedness; authoring decides per UV island whether the frame - // follows a mirror, which derivative frames cannot know (the wooden - // ramp panels banded light/dark per island without this). - if (!item.skinned && item.normal_fmt == 16) { - float b3[3]; - unpack_10_11_11(item.normal_offset, b3); - // w byte is a three-state presence sentinel: 0 = no stored frame - // (meshes that skip this pack leave the word zero), 100 = negative - // handedness, 200 = positive. - const uint32_t packed = pk(b3[0]) | (pk(b3[1]) << 8) | - (pk(b3[2]) << 16) | - ((sx > 0 ? 200u : 100u) << 24); - std::memcpy(&dst[v * 14 + 7], &packed, 4); - } - } else if (item.normal_fmt == 16) { - // (Vehicle meshes: the game's VS derives cross(tangent, binormal) - // instead, but the stored usage-3 normal MATCHES it on every vertex - // with a non-degenerate tangent frame and stays sane on the ~13% - // interior verts where t is parallel to b and the cross vanishes, - // measured in capture, so the stored normal is used.) - unpack_10_11_11(item.normal_offset, n3); - // Dynamicobject v2: rigid props store a full usage-6/7 tangent + - // binormal pair alongside the usage-3 normal. Pack the binormal plus - // the handedness relating cross(B, N) to the stored tangent into the - // unused blend-weight bytes (same sentinel encoding as the env - // families above); the authored frame carries the per-UV-island - // mirror decisions the screen-space fallback cannot know. - if (!item.skinned && item.dynobj != 0 && item.tb_fmt == 16) { - float t3[3], b3[3]; - unpack_10_11_11(item.tangent_offset, t3); - unpack_10_11_11(item.binormal_offset, b3); - const float h = (b3[1] * n3[2] - b3[2] * n3[1]) * t3[0] + - (b3[2] * n3[0] - b3[0] * n3[2]) * t3[1] + - (b3[0] * n3[1] - b3[1] * n3[0]) * t3[2]; - const uint32_t packed = pk(b3[0]) | (pk(b3[1]) << 8) | - (pk(b3[2]) << 16) | - ((h >= 0.0f ? 200u : 100u) << 24); - std::memcpy(&dst[v * 14 + 7], &packed, 4); - } - } else if (item.tb_fmt == 16) { - // NPC character meshes carry no normal element; the game's VS - // derives it as cross(tangent, binormal) (usage 6 x usage 7; verified - // against the emulated VS outputs). Without this the strong - // character N.L shading exposes the face-normal fallback as visible - // low-poly facets on every pedestrian. - float t3[3], b3[3]; - unpack_10_11_11(item.tangent_offset, t3); - unpack_10_11_11(item.binormal_offset, b3); - n3[0] = t3[1] * b3[2] - t3[2] * b3[1]; - n3[1] = t3[2] * b3[0] - t3[0] * b3[2]; - n3[2] = t3[0] * b3[1] - t3[1] * b3[0]; - // Dynamicobject v2, tangent+binormal-only layout variant: the derived - // normal makes cross(B, N) == T x |B|^2 up to orthogonality error, so - // the handedness is positive by construction. - if (!item.skinned && item.dynobj != 0) { - const uint32_t packed = pk(b3[0]) | (pk(b3[1]) << 8) | - (pk(b3[2]) << 16) | (200u << 24); - std::memcpy(&dst[v * 14 + 7], &packed, 4); - } - } - dst[v * 14 + 9] = n3[0]; - dst[v * 14 + 10] = n3[1]; - dst[v * 14 + 11] = n3[2]; - } - // Dynobj v2 layout telemetry (first few meshes): which tangent-frame - // source the props carry decides between the packed authored frame and - // the shader's screen-space fallback. - if (item.dynobj != 0) { - static std::atomic dynobj_layout_logged{0}; - if (dynobj_layout_logged.fetch_add(1, std::memory_order_relaxed) < 8) { - REXLOG_INFO( - "native-scene: dynobj mesh {:08X} layout normal_fmt={} tb_fmt={} " - "skinned={}", - item.mesh, item.normal_fmt, item.tb_fmt, item.skinned); - } - } - // Stretch-veto probe (see g_skin_probe): cache ~32 decoded sample verts - // so the guest thread can cheaply skin what the GPU will actually draw. - if (item.skinned) { - constexpr uint32_t kProbe = 32; - const uint32_t pn = std::min(kProbe, num_verts); - SkinProbe probe; - probe.fp = item.fingerprint; - probe.s.reserve(pn); - for (uint32_t s = 0; s < pn; ++s) { - const uint32_t v = pn > 1 ? (s * (num_verts - 1) / (pn - 1)) : 0; - SkinProbeSample ps; - ps.p[0] = dst[v * 14 + 0]; - ps.p[1] = dst[v * 14 + 1]; - ps.p[2] = dst[v * 14 + 2]; - std::memcpy(&ps.bw, &dst[v * 14 + 7], 4); - std::memcpy(&ps.bi, &dst[v * 14 + 8], 4); - probe.s.push_back(ps); - } - std::lock_guard lock(g_skin_probe_mutex); - if (g_skin_probe.size() > 4096) { - g_skin_probe.clear(); - } - g_skin_probe[item.mesh] = std::move(probe); - } - // ROPA shape blending: retain the decoded vertex array (14 floats per - // vertex, the scene VS layout) so consecutive generations can be lerped - // onto the motion-smoothing play clock at draw time; the stepped shape - // against the interpolated body was the tee jelly (worse at LOWER fps: - // the excursion scales with the guest period; emulated pairs pose N with - // shape N and shows none of it). - if (item.ropa) { - out.ropa_verts.assign(dst, dst + size_t(num_verts) * 14); - } - device->Unmap(vb); - // Blend indices outside the captured palette read garbage rows and mangle - // the vertex. Indices are plain bone numbers, bone k = palette rows 3k. - // (Dyn decode jobs carry no palette: the item's bones ride the scene item, - // not the decode; skip the check there.) - if (item.skinned && max_bi >= 0 && !item.bones.empty()) { - const uint32_t palette_bones = uint32_t(item.bones.size() / 12); - if (uint32_t(max_bi) >= palette_bones) { - REXLOG_WARN( - "native-scene: skinned mesh {:08X} blend index range {}..{} exceeds " - "captured palette of {} bones", - item.mesh, min_bi, max_bi, palette_bones); - } - } - if (garbage != 0) { - REXLOG_WARN( - "native-scene: mesh {:08X} decoded {} of {} verts outside bbox " - "({:.1f},{:.1f},{:.1f})..({:.1f},{:.1f},{:.1f}) fmt {} stride {} vb {:08X}", - item.mesh, garbage, num_verts, item.bbox_min[0], item.bbox_min[1], item.bbox_min[2], - item.bbox_max[0], item.bbox_max[1], item.bbox_max[2], item.pos_fmt, item.stride, - item.vb_addr); - } - - uint16_t* dst_ib = nullptr; - dst_ib = static_cast(device->Map(ib)); - if (item.cloth_quads) { - // Quad-list topology with no guest index buffer (live vertex range - // already exact from the draw args): two triangles per quad. - const uint32_t quads = item.ib_count / 6; - for (uint32_t q = 0; q < quads; ++q) { - const uint16_t v = uint16_t(q * 4); - uint16_t* o = dst_ib + q * 6; - o[0] = v; - o[1] = uint16_t(v + 1); - o[2] = uint16_t(v + 2); - o[3] = v; - o[4] = uint16_t(v + 2); - o[5] = uint16_t(v + 3); - } - } else { - const uint16_t* src_ib = reinterpret_cast(ib_payload); - for (uint32_t i = 0; i < item.ib_count; ++i) { - dst_ib[i] = SwapU16(src_ib[i]); - } - } - // Front/back sheet pattern: opposite-winding twin triangles a few mm-cm - // apart ALONG THE NORMAL. The two sides are often triangulated along - // OPPOSITE quad diagonals (downtown lamppost posters: sheets 4mm apart, - // twin centroids 0.26m apart in-plane), so twins are matched by - // plane-to-plane distance with an in-plane tolerance scaled to triangle - // size; raw centroid distance misses them. >=60% twinned marks the mesh - // double-sided (banners are 100%); O(T^2) but only for <=8192-index - // static tri-list meshes and only once per decode. - bool two_sided = false; - if (detect_sheet) { - struct SheetTri { - float c[3]; - float n[3]; - float edge; // longest edge length (in-plane tolerance scale) - }; - std::vector tris; - tris.reserve(item.ib_count / 3); - for (const DrawEntry& de : item.draws) { - if (uint64_t(de.start_index) + de.index_count > item.ib_count) { - continue; - } - for (uint32_t i = 0; i + 2 < de.index_count; i += 3) { - const uint32_t a = uint32_t(dst_ib[de.start_index + i]) + de.base_vertex; - const uint32_t b = uint32_t(dst_ib[de.start_index + i + 1]) + de.base_vertex; - const uint32_t c = uint32_t(dst_ib[de.start_index + i + 2]) + de.base_vertex; - if (a >= num_verts || b >= num_verts || c >= num_verts) { - continue; - } - const float* pa = &sheet_pos[size_t(a) * 3]; - const float* pb = &sheet_pos[size_t(b) * 3]; - const float* pc = &sheet_pos[size_t(c) * 3]; - const float e1[3] = {pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]}; - const float e2[3] = {pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]}; - const float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], - e1[0] * e2[1] - e1[1] * e2[0]}; - const float len = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); - if (len < 1e-8f) { - continue; - } - SheetTri t; - for (int k = 0; k < 3; ++k) { - t.c[k] = (pa[k] + pb[k] + pc[k]) / 3.0f; - t.n[k] = n[k] / len; - } - const float e3[3] = {pc[0] - pb[0], pc[1] - pb[1], pc[2] - pb[2]}; - const float l1 = e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]; - const float l2 = e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2]; - const float l3 = e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]; - t.edge = std::sqrt(std::max(l1, std::max(l2, l3))); - tris.push_back(t); - } - } - if (tris.size() >= 2) { - std::vector used(tris.size(), 0); - size_t twins = 0; - for (size_t i = 0; i < tris.size(); ++i) { - if (used[i]) { - continue; - } - for (size_t j = i + 1; j < tris.size(); ++j) { - if (used[j]) { - continue; - } - const float dx = tris[i].c[0] - tris[j].c[0]; - const float dy = tris[i].c[1] - tris[j].c[1]; - const float dz = tris[i].c[2] - tris[j].c[2]; - const float d2 = dx * dx + dy * dy + dz * dz; - // Plane separation along i's normal must be small (back-to-back - // sheets); in-plane offset up to the triangle scale (opposite - // diagonal splits put twin centroids half a quad apart). - const float along = std::fabs(dx * tris[i].n[0] + dy * tris[i].n[1] + - dz * tris[i].n[2]); - const float lat_limit = 0.5f * (tris[i].edge + tris[j].edge); - if (along > 0.05f || d2 - along * along > lat_limit * lat_limit) { - continue; - } - const float dot = tris[i].n[0] * tris[j].n[0] + tris[i].n[1] * tris[j].n[1] + - tris[i].n[2] * tris[j].n[2]; - if (dot < -0.9f) { - used[i] = 1; - used[j] = 1; - twins += 2; - break; - } - } - } - // Fully twinned sheets (banners ~100%), OR a meaningful twin patch - // inside a larger single-sided mesh (harbor sailboats: the twinned - // SAIL rides a hull/mast mesh at 4-8% twins; the coincident copies - // sit below far-field depth precision and z-fight into per-frame - // lit/dark shimmer at range). The twins themselves are the evidence - // the mesh was authored for culling-on; coincident opposite-normal - // copies would z-fight on console too, so cull the whole mesh. - // EXCEPT alpha-tested foliage (tree fams 9/10, envsimple.alphatest 7, - // alphatest dynobj, transparent, and unclassified fam 0): a z-fight - // between twinned leaf cards is invisible through the alpha test, so - // twins do NOT prove culling-on there; culling stripped every - // single-sided leaf/branch card seen from its back (the missing- - // foliage regression). Those keep the strict fully-twinned rule. - const bool partial_rule_ok = - !item.transparent && item.dynobj != 2 && item.env_family != 0 && - item.env_family != 7 && item.env_family != 9 && - item.env_family != 10 && item.env_family != 13; - two_sided = twins * 10 >= tris.size() * 6 || - (partial_rule_ok && twins >= 12 && twins * 33 >= tris.size()); - if (two_sided) { - static std::atomic logged{0}; - if (logged.fetch_add(1, std::memory_order_relaxed) < 16) { - REXLOG_INFO("native-scene: two-sided sheet mesh {:08X} ({} indices, {}/{} twins) -> cull-back", - item.mesh, item.ib_count, twins, tris.size()); - } - } - } - } - device->Unmap(ib); - - out.vb = vb; - out.ib = ib; - out.vb_view = {vb, 0, num_verts * 56u, 56u}; - out.ib_view = {ib, 0, item.ib_count * 2u}; - out.two_sided_sheet = two_sided; - return true; -} - -// Decode a guest texture from its 6 fetch-constant words (host-endian), -// the v1-verified path: CPU untile block by block through the 0xA0000000 -// physical mirror, endian swap, and create its SRV view. -// The 3D path reads the words from renderengine::Texture objects; the 2D -// path passes the device fetch-shadow words directly. -// BC1/DXT1 block decode (both color modes) into 16 RGBA8 texels. -void DecodeBc1Block(const uint8_t* b, uint8_t px[16][4]) { - const uint16_t c0 = uint16_t(b[0] | (b[1] << 8)); - const uint16_t c1 = uint16_t(b[2] | (b[3] << 8)); - uint8_t col[4][4]; - // Endpoint expansion by BIT REPLICATION, what GPU hardware does. The - // previous integer-division expansion (v*255/31) reads up to 1/255 dark; - // on the reflective glass that error, folded through the constant detail - // texture, tilted every reflection ~0.8 deg (found empirically via the - // F12 trim sliders: the hand-matched values equaled the - // replication expansion exactly). - const auto expand = [](uint16_t c, uint8_t* o) { - const uint32_t r5 = (c >> 11) & 31, g6 = (c >> 5) & 63, b5 = c & 31; - o[0] = uint8_t((r5 << 3) | (r5 >> 2)); - o[1] = uint8_t((g6 << 2) | (g6 >> 4)); - o[2] = uint8_t((b5 << 3) | (b5 >> 2)); - o[3] = 255; - }; - expand(c0, col[0]); - expand(c1, col[1]); - if (c0 > c1) { - for (int k = 0; k < 3; ++k) { - col[2][k] = uint8_t((2 * col[0][k] + col[1][k]) / 3); - col[3][k] = uint8_t((col[0][k] + 2 * col[1][k]) / 3); - } - col[2][3] = 255; - col[3][3] = 255; - } else { - for (int k = 0; k < 3; ++k) { - col[2][k] = uint8_t((col[0][k] + col[1][k]) / 2); - col[3][k] = 0; - } - col[2][3] = 255; - col[3][3] = 0; - } - uint32_t bits = - uint32_t(b[4]) | (uint32_t(b[5]) << 8) | (uint32_t(b[6]) << 16) | (uint32_t(b[7]) << 24); - for (int i = 0; i < 16; ++i) { - std::memcpy(px[i], col[bits & 3u], 4); - bits >>= 2; - } -} - -// Runtime-composed textures (lightmap atlas pages above all) ship with NO -// mip chain; sampling their razor-contrast mip 0 under minification -// shimmers, grazing-angle banner posters, distant thin geometry, because -// the pixel footprint spans many texels the aniso sampler cannot average at -// mip 0 alone. The game masked this with its 720p softness and the 4-tap -// lightmap filter; at native 4K we need real prefiltering. Small no-mip -// DXT1/8888 textures are decoded to RGBA8 and uploaded with a CPU -// box-filtered mip chain instead. Returns false to fall back to the plain -// single-mip path. -bool UploadGeneratedMips(const NativeGuestOutputRenderContext& context, uint8_t* base, - const rex::graphics::TextureInfo& info, uint32_t fetch_swizzle, - GuestTexture& out) { - const rex::graphics::FormatInfo* format_info = info.format_info(); - const uint32_t bytes_per_block = format_info->bytes_per_block(); - const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); - const uint32_t width = info.width + 1u; - const uint32_t height = info.height + 1u; - const uint32_t block_w = format_info->block_width; - const uint32_t block_h = format_info->block_height; - const bool bc1 = - rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_DXT1; - - // Guest mip 0 copy: same packed-base + tiled macro-row padding rules as - // the plain path. - uint32_t ox = 0, oy = 0; - const uint32_t addr = info.GetMipLocation(0, &ox, &oy, true); - if (addr == 0) { - return false; - } - const uint32_t pitch_blocks = info.extent.block_pitch_h; - const uint32_t min_size = info.memory.base_size; - uint32_t size = min_size; - const uint32_t cols = (width + block_w - 1) / block_w; - const uint32_t rows = (height + block_h - 1) / block_h; - if (info.is_tiled) { - const uint32_t padded_rows = ((rows + oy) + 31u) & ~31u; - size = std::max(size, padded_rows * pitch_blocks * bytes_per_block); - } - static thread_local std::vector gen_scratch; - gen_scratch.resize(size); - if (!GuestTryCopy(gen_scratch.data(), base + (0xA0000000u | addr), size)) { - if (min_size >= size || - !GuestTryCopy(gen_scratch.data(), base + (0xA0000000u | addr), min_size)) { - return false; - } - size = min_size; - } - - // Untile into linear block rows, endian-swap per row. Run-copy untiling: - // same contiguity rule as the plain path (see EnsureGuestTextureFromWords): - // aligned x-runs of clamp(16 >> bpb_log2, 1, 8) blocks are contiguous. - std::vector linear(size_t(cols) * rows * bytes_per_block); - const uint32_t run_blocks = std::clamp(16u >> bytes_per_block_log2, 1u, 8u); - for (uint32_t by = 0; by < rows; ++by) { - uint8_t* out_row = linear.data() + size_t(by) * cols * bytes_per_block; - if (!info.is_tiled) { - const uint32_t row_off = ((by + oy) * pitch_blocks + ox) * bytes_per_block; - const uint32_t row_bytes = cols * bytes_per_block; - if (row_off + row_bytes <= size) { - std::memcpy(out_row, gen_scratch.data() + row_off, row_bytes); - } else { - for (uint32_t bx = 0; bx < cols; ++bx) { - const uint32_t off = row_off + bx * bytes_per_block; - if (off + bytes_per_block > size) { - std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); - continue; - } - std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + off, - bytes_per_block); - } - } - } else { - uint32_t bx = 0; - while (bx < cols) { - const uint32_t x = bx + ox; - const uint32_t run = std::min(cols - bx, run_blocks - (x & (run_blocks - 1))); - const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(x), int32_t(by + oy), pitch_blocks, bytes_per_block_log2)); - const uint32_t bytes = run * bytes_per_block; - if (off + bytes <= size) { - std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + off, - bytes); - } else { - for (uint32_t i = 0; i < run; ++i) { - const uint32_t boff = off + i * bytes_per_block; - if (boff + bytes_per_block > size) { - std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, bytes_per_block); - } else { - std::memcpy(out_row + size_t(bx + i) * bytes_per_block, - gen_scratch.data() + boff, bytes_per_block); - } - } - } - bx += run; - } - } - SwapGuestEndian(out_row, cols * bytes_per_block, info.endianness); - } - - // Decode to RGBA8 mip 0. - std::vector rgba(size_t(width) * height * 4); - if (bc1) { - for (uint32_t by = 0; by < rows; ++by) { - for (uint32_t bx = 0; bx < cols; ++bx) { - uint8_t px[16][4]; - DecodeBc1Block(linear.data() + (size_t(by) * cols + bx) * bytes_per_block, px); - for (uint32_t t = 0; t < 16; ++t) { - const uint32_t x = bx * 4 + (t & 3u); - const uint32_t y = by * 4 + (t >> 2); - if (x < width && y < height) { - std::memcpy(&rgba[(size_t(y) * width + x) * 4], px[t], 4); - } - } - } - } - } else { // k_8_8_8_8: rows are already RGBA8 after the endian swap - for (uint32_t y = 0; y < height; ++y) { - std::memcpy(&rgba[size_t(y) * width * 4], linear.data() + size_t(y) * cols * 4, - size_t(width) * 4); - } - } - - // Diagnostic dump: what the RUNTIME decoded, for an offline byte diff - // against gsnap_tex_decode of the same fetch words (the reflective_trans - // glass panels' wrong-brightness hunt: their composed lightmap pages take - // exactly this generated-mips path). - if (REXCVAR_GET(skate3_native_render_scene_lm_dump)) { - char path[260]; - std::snprintf(path, sizeof(path), - "native_texture_dumps/gen_%08X_%ux%u_t%u.rgba", - info.memory.base_address, width, height, - info.is_tiled ? 1u : 0u); - if (FILE* f = std::fopen(path, "wb")) { - std::fwrite(rgba.data(), 1, rgba.size(), f); - std::fclose(f); - } - } - - // Box-filtered chain down to 1x1. - std::vector> mips; - mips.emplace_back(std::move(rgba)); - uint32_t mw = width, mh = height; - while (mw > 1 || mh > 1) { - const uint32_t nw = std::max(mw >> 1, 1u); - const uint32_t nh = std::max(mh >> 1, 1u); - const std::vector& srcm = mips.back(); - std::vector dstm(size_t(nw) * nh * 4); - for (uint32_t y = 0; y < nh; ++y) { - const uint32_t y0 = std::min(y * 2, mh - 1); - const uint32_t y1 = std::min(y * 2 + 1, mh - 1); - for (uint32_t x = 0; x < nw; ++x) { - const uint32_t x0 = std::min(x * 2, mw - 1); - const uint32_t x1 = std::min(x * 2 + 1, mw - 1); - for (int k = 0; k < 4; ++k) { - const uint32_t sum = srcm[(size_t(y0) * mw + x0) * 4 + k] + - srcm[(size_t(y0) * mw + x1) * 4 + k] + - srcm[(size_t(y1) * mw + x0) * 4 + k] + - srcm[(size_t(y1) * mw + x1) * 4 + k]; - dstm[(size_t(y) * nw + x) * 4 + k] = uint8_t((sum + 2) / 4); - } - } - } - mips.emplace_back(std::move(dstm)); - mw = nw; - mh = nh; - } - const uint32_t mip_count = uint32_t(mips.size()); - - // Upload plan + resource (RGBA8). - struct Plan { - uint32_t offset, pitch, w, h; - }; - std::vector plans(mip_count); - uint32_t upload_size = 0; - for (uint32_t m = 0; m < mip_count; ++m) { - Plan& p = plans[m]; - p.w = std::max(width >> m, 1u); - p.h = std::max(height >> m, 1u); - p.pitch = (p.w * 4u + (nrhi::kRowPitchAlignment - 1u)) & - ~(nrhi::kRowPitchAlignment - 1u); - p.offset = (upload_size + (kUploadPlacementAlignment - 1u)) & - ~(kUploadPlacementAlignment - 1u); - upload_size = p.offset + p.pitch * p.h; - } - nrhi::Device* device = context.device; - nrhi::TextureDesc desc; - desc.kind = nrhi::TextureKind::k2D; - desc.width = width; - desc.height = height; - desc.mip_levels = mip_count; - desc.format = nrhi::Format::kR8G8B8A8_UNORM; - desc.initial_state = nrhi::ResourceState::kCopyDest; - out.texture = device->CreateTexture(desc); - if (out.texture == nullptr) { - return false; - } - out.upload = CreateUploadBuffer(device, upload_size); - if (!out.upload) { - device->DestroyDeferred(out.texture); - out.texture = nullptr; - return false; - } - uint8_t* mapping = static_cast(device->Map(out.upload)); - for (uint32_t m = 0; m < mip_count; ++m) { - const Plan& p = plans[m]; - for (uint32_t y = 0; y < p.h; ++y) { - std::memcpy(mapping + p.offset + size_t(y) * p.pitch, &mips[m][size_t(y) * p.w * 4], - size_t(p.w) * 4); - } - } - device->Unmap(out.upload); - - if (g_tex_stage_out != nullptr) { - // Decode worker: export the commit recipe; the render thread records - // the copies + barrier and creates the SRV (CommitStagedGuestTexture). - StagedTexCommit& sc = *g_tex_stage_out; - sc.copy_format = nrhi::Format::kR8G8B8A8_UNORM; - sc.srv_format = nrhi::Format::kR8G8B8A8_UNORM; - ComposeSrvSwizzle(fetch_swizzle, xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA, sc.swizzle); - sc.mip_count = std::min(mip_count, 16); - for (uint32_t m = 0; m < sc.mip_count; ++m) { - sc.mips[m] = {plans[m].offset, plans[m].pitch, plans[m].w, plans[m].h}; - } - } else { - for (uint32_t m = 0; m < mip_count; ++m) { - const Plan& p = plans[m]; - context.cmd->CopyBufferToTexture(out.texture, m, 0, out.upload, p.offset, - p.pitch, p.w, p.h, 1); - } - context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - - nrhi::TextureViewDesc srv; - srv.dimension = nrhi::ViewDimension::k2D; - srv.format = nrhi::Format::kR8G8B8A8_UNORM; - ComposeSrvSwizzle(fetch_swizzle, xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA, srv.swizzle); - srv.mip_levels = mip_count; - out.srv_format = srv.format; - out.srv_mips = mip_count; - out.srv = device->CreateTextureView(out.texture, srv); - if (out.srv == nullptr) { - return false; - } - } - out.payload_addr = 0xA0000000u | info.memory.base_address; - out.payload_size = size; - BuildPayloadProbes(info, addr, ox, oy, pitch_blocks, size, out); - out.payload_fp = SampleProbeFingerprint(base, out); - out.near_black = SampleProbeNearBlack(base, out); - out.recheck_frame = 0; - out.valid = g_tex_stage_out == nullptr; // staged: live only after commit - return true; -} - -// Phase attribution for the SLOW-inline-decode log: time inside RHI -// resource creation / the generated-mips path / the guest scratch copies, -// reset at each EnsureGuestTextureFromWords entry (per thread: workers and -// the render thread decode concurrently). -thread_local uint64_t g_tex_dec_create_ns = 0; -thread_local uint64_t g_tex_dec_gen_ns = 0; -thread_local uint64_t g_tex_dec_copy_ns = 0; - -bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context, - uint8_t* base, const uint32_t words[6], - GuestTexture& out) { - g_tex_dec_create_ns = 0; - g_tex_dec_gen_ns = 0; - g_tex_dec_copy_ns = 0; - std::memcpy(out.fetch_words, words, 6 * sizeof(uint32_t)); - - xenos::xe_gpu_texture_fetch_t fetch = {}; - fetch.dword_0 = words[0]; - fetch.dword_1 = words[1]; - fetch.dword_2 = words[2]; - fetch.dword_3 = words[3]; - fetch.dword_4 = words[4]; - fetch.dword_5 = words[5]; - if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) { - return false; - } - // Pre-validate the raw format BEFORE Prepare: TextureInfo::Prepare calls - // TextureExtent::Calculate, which divides by the format table's - // block_width/block_height/bytes_per_block; a garbage fetch constant - // (freed texture object read mid-teardown during a map change) carries a - // format whose table entry has zeros and crashed a prewarm worker with an - // integer divide-by-zero (dump skate3.exe.pre-icon.24004, second load - // into Daly Estates). - const rex::graphics::FormatInfo* pre_fi = - rex::graphics::FormatInfo::Get(uint32_t(fetch.format)); - if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 || - pre_fi->bytes_per_block() == 0) { - return false; - } - rex::graphics::TextureInfo info; - if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) { - return false; - } - HostTextureFormat host; - if (!GetHostTextureFormat(info.format, host)) { - return false; - } - if (info.dimension != xenos::DataDimension::k2DOrStacked || info.is_stacked || - info.width >= 8192 || info.height >= 8192 || info.memory.base_address == 0 || - info.memory.base_size == 0 || info.memory.base_size > 64u * 1024u * 1024u) { - return false; - } - - const rex::graphics::FormatInfo* format_info = info.format_info(); - const uint32_t bytes_per_block = format_info->bytes_per_block(); - if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) { - return false; - } - const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); - const uint32_t width = info.width + 1u; - const uint32_t height = info.height + 1u; - const uint32_t block_w = format_info->block_width; - const uint32_t block_h = format_info->block_height; - const uint32_t host_width = ((width + block_w - 1) / block_w) * block_w; - const uint32_t host_height = ((height + block_h - 1) / block_h) * block_h; - - // Upload the guest MIP CHAIN, not just mip 0; sampling mip 0 at distance - // is the source of the grass "TV static" and flickering floor/window - // lines. Power-of-two sizes only (everything the game ships) so BC block - // alignment holds on every level. - const bool pow2 = (width & (width - 1)) == 0 && (height & (height - 1)) == 0; - uint32_t mip_count = 1; - if (pow2 && info.memory.mip_address != 0 && - REXCVAR_GET(skate3_native_render_scene_tex_mips)) { - const uint32_t avail = std::min(info.mip_levels(), info.GetMaxMipLevels()); - while (mip_count < avail && (width >> mip_count) >= 4 && (height >> mip_count) >= 4) { - uint32_t ox = 0, oy = 0; - if (info.GetMipLocation(mip_count, &ox, &oy, true) == 0) { - break; - } - ++mip_count; - } - } - // No guest chain at all (runtime-composed lightmap pages): generate one, - // see UploadGeneratedMips. Small DXT1/8888 textures only; falls back to - // the plain single-mip path on any failure. - if (mip_count == 1 && pow2 && REXCVAR_GET(skate3_native_render_scene_tex_mips) && - width >= 8 && height >= 8 && width <= 512 && height <= 512) { - const auto base_fmt = rex::graphics::GetBaseFormat(info.format); - if (base_fmt == xenos::TextureFormat::k_DXT1 || - base_fmt == xenos::TextureFormat::k_8_8_8_8) { - GuestTexture gen = out; // keeps fetch_words already copied - const auto gen_t0 = PerfClock::now(); - const bool gen_ok = UploadGeneratedMips(context, base, info, fetch.swizzle, gen); - g_tex_dec_gen_ns += PerfNsSince(gen_t0); - if (gen_ok) { - out = gen; - return true; - } - } - } - - // Copy the whole guest mip chain out up front with the lock-free guarded - // copy (never VirtualQuery on the render thread: the VAD lock stalls - // behind the guest streaming threads exactly while panning streams - // textures in), truncating the chain at the first unreadable level. - struct MipSrc { - uint32_t addr, scratch_off, size, min_size, pitch_blocks, ox, oy; - }; - MipSrc srcs[16] = {}; - static thread_local std::vector tex_scratch; - uint32_t scratch_total = 0; - for (uint32_t m = 0; m < mip_count; ++m) { - uint32_t ox = 0, oy = 0; - // Mip 0 through GetMipLocation too: textures <= 16 texels on a side - // store their BASE level packed inside a 32x32 tile at a block offset. - // Reading at (0,0) decoded garbage; the 16x16 "default_white" macro - // overlay came out as pink/black blocks and multiplied giant soft black - // blobs over every wall whose material uses it (validated: with the - // packed offset it decodes pure white). Non-packed textures return the - // plain base address with zero offsets. - const uint32_t mip_addr = info.GetMipLocation(m, &ox, &oy, true); - const auto ext = info.GetMipExtent(m, true); - MipSrc& s = srcs[m]; - s.addr = mip_addr; - s.pitch_blocks = m == 0 ? info.extent.block_pitch_h : ext.block_pitch_h; - s.size = m == 0 ? info.memory.base_size : ext.all_blocks() * bytes_per_block; - s.min_size = s.size; - if (info.is_tiled) { - // Tiled addressing swizzles across 32x32-BLOCK macro tiles AND, for - // narrow block formats, interleaves across 64x64/128x128-block - // portions whose byte extent EXCEEDS the linear size; 16bpp reaches - // 0xC00 bytes from a 32x32 tile origin vs the naive 32*32*2 = 0x800. - // The previous padded-macro-ROW estimate (added for the 32x32 DXT5 - // HUD compass icons, whose last block sat at 5952 vs base_size 1024) - // missed that interleave: every 16bpp mip's swizzled offsets for the - // BOTTOM half of its rows landed past the estimate, the range guard - // zeroed them, and every PCU Library banner rendered with its lower - // half black at mip-1 viewing distance (MIP DIAG: guard_zeroed - // exactly total/2 on 64x64 fmt-4 mips, total/1 on packed oy=16 mips). - // Size the copy with the SDK's swizzle-aware upper bound instead; if - // that over-reaches the committed allocation the copy loop below - // falls back to the reported size and marks the decode incomplete. - const uint32_t mw = std::max(width >> m, 1u); - const uint32_t mh = std::max(height >> m, 1u); - const uint32_t right = (mw + block_w - 1) / block_w + ox; - const uint32_t bottom = (mh + block_h - 1) / block_h + oy; - s.size = std::max( - s.size, rex::graphics::texture_util::GetTiledAddressUpperBound2D( - right, bottom, s.pitch_blocks, bytes_per_block_log2)); - } - s.ox = ox; - s.oy = oy; - s.scratch_off = scratch_total; - scratch_total += s.size; - } - const auto copy_t0 = PerfClock::now(); - tex_scratch.resize(scratch_total); - uint32_t mips_copied = 0; - bool copy_truncated = false; - for (uint32_t m = 0; m < mip_count; ++m) { - MipSrc& s = srcs[m]; - if (!GuestTryCopy(tex_scratch.data() + s.scratch_off, - base + (0xA0000000u | s.addr), s.size)) { - if (s.min_size >= s.size || - !GuestTryCopy(tex_scratch.data() + s.scratch_off, - base + (0xA0000000u | s.addr), s.min_size)) { - break; - } - // Tiled fallback: the padded macro rows hold real blocks past - // min_size; every one of them uploads as ZERO below (the PCU - // Library half-black banner mips). The decode is marked incomplete - // so the draw path keeps retrying until the pool commits. - s.size = s.min_size; - copy_truncated = true; - } - ++mips_copied; - } - g_tex_dec_copy_ns += PerfNsSince(copy_t0); - if (mips_copied == 0) { - return false; - } - mip_count = mips_copied; - out.incomplete = copy_truncated; - if (copy_truncated) { - static std::atomic s_trunc_logs{0}; - if (s_trunc_logs.fetch_add(1, std::memory_order_relaxed) < 16) { - REXLOG_INFO( - "native-scene: texture decode INCOMPLETE (tiled mip copy fell back " - "to min_size: zeroed tail blocks) {}x{} mips={} w1={:08X}; will " - "re-decode until complete", - width, height, mip_count, words[1]); - } - } - - // Per-mip upload footprints (D3D12 alignment rules). - struct MipPlan { - uint32_t offset, pitch, cols, rows; - }; - MipPlan plans[16] = {}; - uint32_t upload_size = 0; - for (uint32_t m = 0; m < mip_count; ++m) { - const uint32_t mw = std::max(width >> m, 1u); - const uint32_t mh = std::max(height >> m, 1u); - MipPlan& p = plans[m]; - p.cols = (mw + block_w - 1) / block_w; - p.rows = (mh + block_h - 1) / block_h; - p.pitch = (p.cols * bytes_per_block + (nrhi::kRowPitchAlignment - 1u)) & - ~(nrhi::kRowPitchAlignment - 1u); - p.offset = (upload_size + (kUploadPlacementAlignment - 1u)) & - ~(kUploadPlacementAlignment - 1u); - upload_size = p.offset + p.pitch * p.rows; - } - - nrhi::Device* device = context.device; - nrhi::TextureDesc desc; - desc.kind = nrhi::TextureKind::k2D; - desc.width = host_width; - desc.height = host_height; - desc.mip_levels = mip_count; - desc.format = host.resource_format; - desc.initial_state = nrhi::ResourceState::kCopyDest; - const auto create_t0 = PerfClock::now(); - out.texture = device->CreateTexture(desc); - if (out.texture == nullptr) { - return false; - } - out.upload = CreateUploadBuffer(device, upload_size); - g_tex_dec_create_ns += PerfNsSince(create_t0); - if (!out.upload) { - device->DestroyDeferred(out.texture); - out.texture = nullptr; - return false; - } - uint8_t* mapping = static_cast(device->Map(out.upload)); - const bool swap_rb_565 = - rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_5_6_5; - for (uint32_t m = 0; m < mip_count; ++m) { - const MipPlan& p = plans[m]; - const uint32_t ox = srcs[m].ox; - const uint32_t oy = srcs[m].oy; - const uint32_t src_pitch_blocks = srcs[m].pitch_blocks; - const uint32_t src_size = srcs[m].size; - const uint8_t* guest = tex_scratch.data() + srcs[m].scratch_off; - const uint32_t row_bytes = p.cols * bytes_per_block; - uint32_t guard_zeroed = 0; // blocks zeroed by the range guard (diag) - // Run-copy untiling. The per-BLOCK GetTiledOffset2D loop made a single - // 1280x720 8888 decode cost 3-13 ms (921k address computations + 4-byte - // memcpys); animating fullscreen menu art and FMV planes pay that on - // EVERY content change, which is where the menu frame spikes lived. - // From the tiling formula (util.cpp GetTiledOffset2D): within a row, an - // x-run aligned to run_blocks = clamp(16 >> bpb_log2, 1, 8) maps to - // CONTIGUOUS byte offsets; the micro term stays inside its 16-byte - // nibble group ((y & 0xE) contributes only multiples of 16 at every - // bpb), and runs <= 8 aligned blocks never cross the (x>>3)/(x>>5) mix - // boundaries. Linear mips copy whole rows. - // - // ROWS STAGE THROUGH A CACHED BUFFER, never the upload mapping directly: - // upload heaps are WRITE-COMBINED memory, and SwapGuestEndian/565-swap - // READ the row back; uncached WC reads cost ~33 ns/byte, which was the - // long-unattributed 35-135 ms decode class (512x512 = 1 MB = 34 ms, - // 1024x1024+chain = 135 ms; linear fit across a slow-decode log). - // The mapping gets exactly one streaming memcpy per row. - const uint32_t run_blocks = - std::clamp(16u >> bytes_per_block_log2, 1u, 8u); - static thread_local std::vector row_buf; - row_buf.resize(row_bytes); - for (uint32_t by = 0; by < p.rows; ++by) { - uint8_t* out_row = row_buf.data(); - if (!info.is_tiled) { - const uint32_t row_off = - ((by + oy) * src_pitch_blocks + ox) * bytes_per_block; - if (row_off + row_bytes <= src_size) { - std::memcpy(out_row, guest + row_off, row_bytes); - } else { - for (uint32_t bx = 0; bx < p.cols; ++bx) { - const uint32_t off = row_off + bx * bytes_per_block; - if (off + bytes_per_block > src_size) { - std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); - ++guard_zeroed; - continue; - } - std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, - bytes_per_block); - } - } - } else { - uint32_t bx = 0; - while (bx < p.cols) { - const uint32_t x = bx + ox; - const uint32_t run = - std::min(p.cols - bx, run_blocks - (x & (run_blocks - 1))); - const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(x), int32_t(by + oy), src_pitch_blocks, bytes_per_block_log2)); - const uint32_t bytes = run * bytes_per_block; - if (off + bytes <= src_size) { - std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, bytes); - } else { - // Padded macro rows past the copied size: per-block guard-zero - // (offsets within the run are contiguous, same proof as above). - for (uint32_t i = 0; i < run; ++i) { - const uint32_t boff = off + i * bytes_per_block; - if (boff + bytes_per_block > src_size) { - std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, - bytes_per_block); - ++guard_zeroed; - } else { - std::memcpy(out_row + size_t(bx + i) * bytes_per_block, guest + boff, - bytes_per_block); - } - } - } - bx += run; - } - } - SwapGuestEndian(out_row, row_bytes, info.endianness); - if (swap_rb_565) { - for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) { - uint16_t value; - std::memcpy(&value, out_row + i, sizeof(value)); - value = uint16_t((value & 0x07E0u) | ((value >> 11) & 0x1Fu) | - ((value & 0x1Fu) << 11)); - std::memcpy(out_row + i, &value, sizeof(value)); - } - } - std::memcpy(mapping + p.offset + size_t(by) * p.pitch, out_row, row_bytes); - } - // Half-black-mip diagnostic (PCU Library banners): discriminate "the - // guest pool genuinely holds zeros for this mip" from "our addressing - // zeroed/misread it". Samples 32 uploaded blocks spread over the mip; - // guard_zeroed separates range-guard zeroing from zero CONTENT. - if (m > 0) { - uint32_t zero_samples = 0; - const uint32_t total_blocks = p.rows * p.cols; - for (uint32_t s = 0; s < 32; ++s) { - const uint32_t bi = uint32_t(uint64_t(total_blocks - 1) * s / 31u); - const uint32_t by = bi / p.cols; - const uint32_t bx = bi % p.cols; - uint64_t q = 0; - std::memcpy(&q, mapping + p.offset + size_t(by) * p.pitch + - size_t(bx) * bytes_per_block, - std::min(8, bytes_per_block)); - zero_samples += q == 0 ? 1 : 0; - } - if (total_blocks >= 32 && (guard_zeroed * 4 >= total_blocks || - zero_samples >= 12)) { - static std::atomic s_mip_diag{0}; - if (s_mip_diag.fetch_add(1, std::memory_order_relaxed) < 24) { - REXLOG_INFO( - "native-scene: MIP DIAG {}x{} mip {}/{} zero_samples={}/32 " - "guard_zeroed={}/{} ox={} oy={} pitch_b={} size={} min={} " - "tiled={} fmt={} w0={:08X} w1={:08X} w2={:08X} mip_addr={:08X}", - width, height, m, mip_count, zero_samples, guard_zeroed, - total_blocks, ox, oy, src_pitch_blocks, src_size, - srcs[m].min_size, info.is_tiled ? 1 : 0, uint32_t(info.format), - words[0], words[1], words[2], srcs[m].addr); - } - } - } - } - // Diagnostic dump (skate3_native_render_scene_lm_dump): no-chain textures - // through the PLAIN path (the >512px composed lightmap pages the - // generated-mips gate excludes): mip 0 as linear block rows, raw guest - // block format post-endian-swap, for offline decode + byte-diff against - // gsnap_tex_decode of the same fetch words. - if (mip_count == 1 && REXCVAR_GET(skate3_native_render_scene_lm_dump)) { - char path[260]; - std::snprintf(path, sizeof(path), - "native_texture_dumps/plain_%08X_%ux%u_f%u_t%u.blk", - info.memory.base_address, width, height, - uint32_t(info.format), info.is_tiled ? 1u : 0u); - if (FILE* f = std::fopen(path, "wb")) { - const MipPlan& p0 = plans[0]; - for (uint32_t by = 0; by < p0.rows; ++by) { - std::fwrite(mapping + p0.offset + size_t(by) * p0.pitch, 1, - size_t(p0.cols) * bytes_per_block, f); - } - std::fclose(f); - } - } - device->Unmap(out.upload); - - if (g_tex_stage_out != nullptr) { - // Decode worker: export the commit recipe; the render thread records - // the copies + barrier and creates the SRV (CommitStagedGuestTexture). - StagedTexCommit& sc = *g_tex_stage_out; - sc.copy_format = host.resource_format; - sc.srv_format = host.srv_format; - ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, sc.swizzle); - sc.mip_count = std::min(mip_count, 16); - for (uint32_t m = 0; m < sc.mip_count; ++m) { - sc.mips[m] = {plans[m].offset, plans[m].pitch, std::max(host_width >> m, 1u), - std::max(host_height >> m, 1u)}; - } - } else { - // Record the upload copies into the deferred command list. - for (uint32_t m = 0; m < mip_count; ++m) { - const MipPlan& p = plans[m]; - context.cmd->CopyBufferToTexture(out.texture, m, 0, out.upload, p.offset, - p.pitch, std::max(host_width >> m, 1u), - std::max(host_height >> m, 1u), 1); - } - context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - - // SRV view with the composed swizzle. - nrhi::TextureViewDesc srv; - srv.dimension = nrhi::ViewDimension::k2D; - srv.format = host.srv_format; - ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, srv.swizzle); - srv.mip_levels = mip_count; - out.srv_format = srv.format; - out.srv_mips = mip_count; - out.srv = device->CreateTextureView(out.texture, srv); - if (out.srv == nullptr) { - return false; - } - } - // Payload sample for content revalidation (see GuestTexture). - out.payload_addr = 0xA0000000u | info.memory.base_address; - out.payload_size = srcs[0].size; - BuildPayloadProbes(info, srcs[0].addr, srcs[0].ox, srcs[0].oy, - srcs[0].pitch_blocks, srcs[0].size, out); - out.payload_fp = SampleProbeFingerprint(base, out); - out.near_black = SampleProbeNearBlack(base, out); - out.recheck_frame = 0; - out.valid = g_tex_stage_out == nullptr; // staged: live only after commit - return true; -} - -// (The object-keyed EnsureGuestTexture wrapper is gone: everything decodes -// from an explicit stable words snapshot, ReadStableTexWords + -// EnsureGuestTextureFromWords, and lands in the words-keyed store.) - -// In-place re-decode for HOT single-mip 2D content (video planes, animating -// fullscreen menu/loading art): identical fetch words = identical layout, so -// the committed texture, upload footprint and SRV slot are all reusable; -// the retire+recreate path paid two GPU resource creations per -// content change (~90/s during FMV playback, plus every animation step of -// fullscreen frontend art). Non-pow2 single-mip entries only: pow2 entries -// may own a real or generated mip chain (the full path handles those). -// The TEAR-GUARD also lives here: the CPU writer (VP6 video decode, APT -// re-raster) can be mid-write when the liveness probe fires; decoding a -// half-written fullscreen payload shows a frame of mixed old/new content: -// a subtle whole-screen brightness flicker on fades and video. The guest -// copy retries until the probe fingerprint reads stable across it. -bool UpdateGuestTexture2DInPlace(const NativeGuestOutputRenderContext& context, - uint8_t* base, GuestTexture& t) { - if (!t.valid || t.texture == nullptr || t.srv_mips != 1 || t.incomplete) { - return false; - } - xenos::xe_gpu_texture_fetch_t fetch = {}; - fetch.dword_0 = t.fetch_words[0]; - fetch.dword_1 = t.fetch_words[1]; - fetch.dword_2 = t.fetch_words[2]; - fetch.dword_3 = t.fetch_words[3]; - fetch.dword_4 = t.fetch_words[4]; - fetch.dword_5 = t.fetch_words[5]; - if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) { - return false; - } - const rex::graphics::FormatInfo* pre_fi = - rex::graphics::FormatInfo::Get(uint32_t(fetch.format)); - if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 || - pre_fi->bytes_per_block() == 0) { - return false; - } - rex::graphics::TextureInfo info; - if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) { - return false; - } - HostTextureFormat host; - if (!GetHostTextureFormat(info.format, host)) { - return false; - } - if (info.dimension != xenos::DataDimension::k2DOrStacked || info.is_stacked || - info.width >= 8192 || info.height >= 8192 || info.memory.base_address == 0 || - info.memory.base_size == 0 || info.memory.base_size > 64u * 1024u * 1024u) { - return false; - } - const rex::graphics::FormatInfo* format_info = info.format_info(); - const uint32_t bytes_per_block = format_info->bytes_per_block(); - if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) { - return false; - } - const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); - const uint32_t width = info.width + 1u; - const uint32_t height = info.height + 1u; - if ((width & (width - 1)) == 0 && (height & (height - 1)) == 0) { - return false; // pow2: may own a mip chain, full path - } - if (host.srv_format != t.srv_format) { - return false; // the existing SRV recipe must stay exact - } - const uint32_t block_w = format_info->block_width; - const uint32_t block_h = format_info->block_height; - - // Mip-0 source extent, same rules as the full decode. - uint32_t ox = 0, oy = 0; - const uint32_t addr = info.GetMipLocation(0, &ox, &oy, true); - if (addr == 0) { - return false; - } - const uint32_t pitch_blocks = info.extent.block_pitch_h; - const uint32_t cols = (width + block_w - 1) / block_w; - const uint32_t rows = (height + block_h - 1) / block_h; - const uint32_t min_size = info.memory.base_size; - uint32_t size = min_size; - if (info.is_tiled) { - size = std::max(size, rex::graphics::texture_util::GetTiledAddressUpperBound2D( - cols + ox, rows + oy, pitch_blocks, bytes_per_block_log2)); - } - static thread_local std::vector inplace_scratch; - inplace_scratch.resize(size); - - // Tear-guarded guest copy: retry while the writer races us. - uint64_t fp = 0; - for (int attempt = 0; attempt < 3; ++attempt) { - const uint64_t fp_before = SampleProbeFingerprint(base, t); - if (!GuestTryCopy(inplace_scratch.data(), base + (0xA0000000u | addr), size)) { - if (min_size >= size || - !GuestTryCopy(inplace_scratch.data(), base + (0xA0000000u | addr), min_size)) { - return false; // payload unreadable: let the full path sort it out - } - size = min_size; - } - fp = SampleProbeFingerprint(base, t); - if (fp != 0 && fp == fp_before) { - break; - } - } - - // Upload footprint (single mip at offset 0). - const uint32_t pitch = - (cols * bytes_per_block + (nrhi::kRowPitchAlignment - 1u)) & - ~(nrhi::kRowPitchAlignment - 1u); - const uint32_t upload_size = pitch * rows; - nrhi::Device* device = context.device; - nrhi::Buffer*& up = t.upload_flip ? t.upload_b : t.upload; - if (up != nullptr && up->size() < upload_size) { - // Never expected (same words = same footprint): recreate defensively. - device->DestroyDeferred(up); - up = nullptr; - } - if (up == nullptr) { - up = CreateUploadBuffer(device, upload_size); - if (up == nullptr) { - return false; - } - } - uint8_t* mapping = static_cast(device->Map(up)); - if (mapping == nullptr) { - return false; - } - const bool swap_rb_565 = - rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_5_6_5; - const uint8_t* guest = inplace_scratch.data(); - const uint32_t row_bytes = cols * bytes_per_block; - const uint32_t run_blocks = std::clamp(16u >> bytes_per_block_log2, 1u, 8u); - // Rows stage through a CACHED buffer; the endian/565 swaps read the row - // back, and reading the write-combined upload mapping costs ~33 ns/byte - // (the 35-135 ms decode class; see the plain path). - static thread_local std::vector row_buf; - row_buf.resize(row_bytes); - for (uint32_t by = 0; by < rows; ++by) { - uint8_t* out_row = row_buf.data(); - if (!info.is_tiled) { - const uint32_t row_off = ((by + oy) * pitch_blocks + ox) * bytes_per_block; - if (row_off + row_bytes <= size) { - std::memcpy(out_row, guest + row_off, row_bytes); - } else { - for (uint32_t bx = 0; bx < cols; ++bx) { - const uint32_t off = row_off + bx * bytes_per_block; - if (off + bytes_per_block > size) { - std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); - } else { - std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, - bytes_per_block); - } - } - } - } else { - uint32_t bx = 0; - while (bx < cols) { - const uint32_t x = bx + ox; - const uint32_t run = std::min(cols - bx, run_blocks - (x & (run_blocks - 1))); - const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(x), int32_t(by + oy), pitch_blocks, bytes_per_block_log2)); - const uint32_t bytes = run * bytes_per_block; - if (off + bytes <= size) { - std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, bytes); - } else { - for (uint32_t i = 0; i < run; ++i) { - const uint32_t boff = off + i * bytes_per_block; - if (boff + bytes_per_block > size) { - std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, bytes_per_block); - } else { - std::memcpy(out_row + size_t(bx + i) * bytes_per_block, guest + boff, - bytes_per_block); - } - } - } - bx += run; - } - } - SwapGuestEndian(out_row, row_bytes, info.endianness); - if (swap_rb_565) { - for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) { - uint16_t value; - std::memcpy(&value, out_row + i, sizeof(value)); - value = uint16_t((value & 0x07E0u) | ((value >> 11) & 0x1Fu) | - ((value & 0x1Fu) << 11)); - std::memcpy(out_row + i, &value, sizeof(value)); - } - } - std::memcpy(mapping + size_t(by) * pitch, out_row, row_bytes); - } - device->Unmap(up); - - // Barriers must land in the deferred list BEFORE the copy: flush both - // sides explicitly (earlier draws this frame sample the OLD content, - // later ones the new: same semantics as a retire+recreate mid-frame). - context.cmd->Barrier(t.texture, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kCopyDest); - context.cmd->FlushBarriers(); - context.cmd->CopyBufferToTexture(t.texture, 0, 0, up, 0, pitch, cols * block_w, - rows * block_h, 1); - context.cmd->Barrier(t.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - context.cmd->FlushBarriers(); - - t.upload_flip = !t.upload_flip; - t.payload_fp = fp; - t.near_black = SampleProbeNearBlack(base, t); - t.recheck_frame = 0; - return true; -} - -// Environment CUBE map for the water / reflective-glass reflection term -// (t6). Six faces untiled independently per level (Xenos cubes are 2D-tiled -// per face slice), WITH the guest mip chain; gradient-derived LOD on the -// normal-mapped reflection vector is what blurs baked cube detail -// (streetlight heads) into the soft reflections of the real console output. -bool EnsureGuestCubeTexture(const NativeGuestOutputRenderContext& context, uint8_t* base, - uint32_t tex_ptr, GuestTexture& out) { - uint32_t words[6] = {}; - { - uint32_t raw[6]; - if (!GuestTryCopy(raw, base + tex_ptr + 7 * 4, sizeof(raw))) { - return false; - } - for (uint32_t i = 0; i < 6; ++i) { - words[i] = SwapU32(raw[i]); - } - } - std::memcpy(out.fetch_words, words, sizeof(words)); - xenos::xe_gpu_texture_fetch_t fetch = {}; - fetch.dword_0 = words[0]; - fetch.dword_1 = words[1]; - fetch.dword_2 = words[2]; - fetch.dword_3 = words[3]; - fetch.dword_4 = words[4]; - fetch.dword_5 = words[5]; - if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) { - return false; - } - // Same divide-by-zero pre-guard as EnsureGuestTextureFromWords (garbage - // format -> zero block dims inside Prepare's extent math). - const rex::graphics::FormatInfo* pre_fi = - rex::graphics::FormatInfo::Get(uint32_t(fetch.format)); - if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 || - pre_fi->bytes_per_block() == 0) { - return false; - } - rex::graphics::TextureInfo info; - if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) { - return false; - } - if (info.dimension != xenos::DataDimension::kCube || info.memory.base_address == 0 || - info.width >= 1024 || info.height >= 1024) { - return false; - } - HostTextureFormat host; - if (!GetHostTextureFormat(info.format, host)) { - return false; - } - const rex::graphics::FormatInfo* format_info = info.format_info(); - const uint32_t bytes_per_block = format_info->bytes_per_block(); - if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) { - return false; - } - const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); - const uint32_t width = info.width + 1u; - const uint32_t height = info.height + 1u; - const uint32_t block_w = format_info->block_width; - const uint32_t block_h = format_info->block_height; - const uint32_t host_width = ((width + block_w - 1) / block_w) * block_w; - const uint32_t host_height = ((height + block_h - 1) / block_h) * block_h; - // A FULL mip chain is load-bearing for the reflective families. The real - // baseenvironmentreflective PS perturbs its reflection vector with a - // per-pixel normal map, so adjacent pixels reflect degrees apart; the - // hardware cube fetch runs at a VERY deep gradient-derived LOD and the - // whole reflection resolves to a near-face-average wash (the emulated - // frame's uniform blue glass; offline: a CUBE_LOD=8 box-downsample probe - // reproduces the emulated facade's uniformity where mip 0 shows a sharp - // baked tree/streetlight blob). A truncated chain - // clamps the LOD shallow and leaves that blob visible. DXT1 cubes (every - // env cube observed) decode mip 0 to RGBA8 and generate the complete - // chain down to 1x1 below; other formats fall back to copying the guest - // chain (down to 32px: smaller levels live packed inside a shared 32x32 - // tile, see GetPackedTileOffset). - const bool rgba_chain = - rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_DXT1 && - width >= 8 && (width & (width - 1)) == 0 && width == height; - uint32_t mip_levels = 1; - if (!rgba_chain && info.memory.mip_address != 0 && (width & (width - 1)) == 0 && - (height & (height - 1)) == 0) { - const uint32_t avail = std::min(info.mip_levels(), info.GetMaxMipLevels()); - while (mip_levels < avail && (width >> mip_levels) >= 32 && - (height >> mip_levels) >= 32) { - uint32_t ox = 0, oy = 0; - if (info.GetMipLocation(mip_levels, &ox, &oy, true) == 0 || ox != 0 || oy != 0) { - break; - } - ++mip_levels; - } - } - // Per-level guest layout: each level stores the six face slices - // consecutively (extent depth = 6; GetMipLocation walks whole levels). - struct CubeLevel { - uint32_t addr, pitch_blocks, slice_bytes, cols, rows, scratch_off; - uint32_t up_pitch, up_face_bytes, w, h; - }; - CubeLevel lv[6] = {}; - uint32_t scratch_total = 0; - for (uint32_t m = 0; m < mip_levels; ++m) { - CubeLevel& L = lv[m]; - const auto ext = m == 0 ? info.extent : info.GetMipExtent(m, true); - uint32_t ox = 0, oy = 0; - L.addr = m == 0 ? info.memory.base_address : info.GetMipLocation(m, &ox, &oy, true); - L.pitch_blocks = ext.block_pitch_h; - L.slice_bytes = ext.block_pitch_h * ext.block_pitch_v * bytes_per_block; - const uint32_t mw = std::max(width >> m, 1u); - const uint32_t mh = std::max(height >> m, 1u); - L.cols = (mw + block_w - 1) / block_w; - L.rows = (mh + block_h - 1) / block_h; - L.w = L.cols * block_w; - L.h = L.rows * block_h; - L.up_pitch = (L.cols * bytes_per_block + (nrhi::kRowPitchAlignment - 1u)) & - ~(nrhi::kRowPitchAlignment - 1u); - L.up_face_bytes = - (L.up_pitch * L.rows + (kUploadPlacementAlignment - 1u)) & - ~(kUploadPlacementAlignment - 1u); - L.scratch_off = scratch_total; - scratch_total += L.slice_bytes * 6; - } - if (scratch_total == 0 || scratch_total > 24u * 1024u * 1024u) { - return false; - } - static thread_local std::vector cube_scratch; - cube_scratch.resize(scratch_total); - for (uint32_t m = 0; m < mip_levels; ++m) { - if (!GuestTryCopy(cube_scratch.data() + lv[m].scratch_off, - base + (0xA0000000u | lv[m].addr), lv[m].slice_bytes * 6)) { - if (m == 0) { - return false; - } - mip_levels = m; // truncate the chain at the first unreadable level - break; - } - } - - if (rgba_chain) { - // Decode DXT1 mip 0 -> RGBA8 per face, box-filter the full chain to - // 1x1, upload as an RGBA cube. CPU cost is one-time per cube (runs on - // the decode workers). - nrhi::Device* device = context.device; - const uint32_t levels = 1u + uint32_t(std::countr_zero(width)); - struct Level { - uint32_t w, pitch, face_bytes, upload_off; // upload_off within a face - }; - Level lvs[16] = {}; - uint32_t face_upload = 0; - for (uint32_t m = 0; m < levels; ++m) { - Level& L = lvs[m]; - L.w = std::max(width >> m, 1u); - L.pitch = (L.w * 4u + (nrhi::kRowPitchAlignment - 1u)) & - ~(nrhi::kRowPitchAlignment - 1u); - L.face_bytes = (L.pitch * L.w + (kUploadPlacementAlignment - 1u)) & - ~(kUploadPlacementAlignment - 1u); - L.upload_off = face_upload; - face_upload += L.face_bytes; - } - nrhi::TextureDesc desc; - desc.kind = nrhi::TextureKind::kCube; - desc.width = width; - desc.height = height; - desc.mip_levels = levels; - desc.format = nrhi::Format::kR8G8B8A8_UNORM; - desc.initial_state = nrhi::ResourceState::kCopyDest; - out.texture = device->CreateTexture(desc); - if (out.texture == nullptr) { - return false; - } - out.upload = CreateUploadBuffer(device, face_upload * 6); - if (!out.upload) { - device->DestroyDeferred(out.texture); - out.texture = nullptr; - return false; - } - uint8_t* mapping = static_cast(device->Map(out.upload)); - static thread_local std::vector rgba; // level 0 of one face - static thread_local std::vector down; // downsample scratch - static thread_local std::vector bc_row; // one untiled block row - const CubeLevel& L0 = lv[0]; - bc_row.resize(size_t(L0.cols) * bytes_per_block); - for (uint32_t face = 0; face < 6; ++face) { - // Per-face: the downsample loop below SWAPS rgba/down, so their sizes - // end the chain tiny; the next face's full-size decode writes must - // not index a shrunken buffer. - rgba.resize(size_t(width) * height * 4); - down.resize(size_t(width / 2) * (height / 2) * 4); - const uint8_t* guest = - cube_scratch.data() + L0.scratch_off + size_t(face) * L0.slice_bytes; - for (uint32_t by = 0; by < L0.rows; ++by) { - for (uint32_t bx = 0; bx < L0.cols; ++bx) { - uint32_t source_offset; - if (info.is_tiled) { - source_offset = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(bx), int32_t(by), L0.pitch_blocks, bytes_per_block_log2)); - } else { - source_offset = (by * L0.pitch_blocks + bx) * bytes_per_block; - } - if (source_offset + bytes_per_block > L0.slice_bytes) { - std::memset(&bc_row[size_t(bx) * bytes_per_block], 0, bytes_per_block); - continue; - } - std::memcpy(&bc_row[size_t(bx) * bytes_per_block], guest + source_offset, - bytes_per_block); - } - SwapGuestEndian(bc_row.data(), uint32_t(bc_row.size()), info.endianness); - for (uint32_t bx = 0; bx < L0.cols; ++bx) { - uint8_t px[16][4]; - DecodeBc1Block(&bc_row[size_t(bx) * bytes_per_block], px); - for (uint32_t r = 0; r < 4; ++r) { - std::memcpy(&rgba[(size_t(by * 4 + r) * width + bx * 4) * 4], px[r * 4], - 16); - } - } - } - // Upload level 0, then box-filter down the chain in place. - const uint8_t* src = rgba.data(); - uint32_t w = width; - for (uint32_t m = 0; m < levels; ++m) { - uint8_t* up = mapping + size_t(face) * face_upload + lvs[m].upload_off; - for (uint32_t y = 0; y < w; ++y) { - std::memcpy(up + size_t(y) * lvs[m].pitch, src + size_t(y) * w * 4, - size_t(w) * 4); - } - if (m + 1 >= levels) { - break; - } - const uint32_t hw = w / 2; - for (uint32_t y = 0; y < hw; ++y) { - for (uint32_t x = 0; x < hw; ++x) { - for (uint32_t c = 0; c < 4; ++c) { - const uint32_t s = - uint32_t(src[((y * 2) * w + x * 2) * 4 + c]) + - uint32_t(src[((y * 2) * w + x * 2 + 1) * 4 + c]) + - uint32_t(src[((y * 2 + 1) * w + x * 2) * 4 + c]) + - uint32_t(src[((y * 2 + 1) * w + x * 2 + 1) * 4 + c]); - down[(size_t(y) * hw + x) * 4 + c] = uint8_t((s + 2) / 4); - } - } - } - rgba.swap(down); - src = rgba.data(); - w = hw; - } - } - device->Unmap(out.upload); - REXLOG_INFO("native-scene: cube {:08X} {}x{} DXT1 -> RGBA full chain ({} levels)", - tex_ptr, width, height, levels); - nrhi::Swizzle swizzle_mapping[4]; - ComposeSrvSwizzle(fetch.swizzle, xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA, - swizzle_mapping); - if (g_tex_stage_out != nullptr) { - StagedTexCommit& sc = *g_tex_stage_out; - sc.copy_format = nrhi::Format::kR8G8B8A8_UNORM; - sc.srv_format = nrhi::Format::kR8G8B8A8_UNORM; - for (uint32_t c = 0; c < 4; ++c) { - sc.swizzle[c] = swizzle_mapping[c]; - } - sc.cube = true; - sc.cube_mip_levels = levels; - sc.mip_count = 6 * levels; - for (uint32_t face = 0; face < 6; ++face) { - for (uint32_t m = 0; m < levels; ++m) { - sc.mips[face * levels + m] = {face * face_upload + lvs[m].upload_off, - lvs[m].pitch, lvs[m].w, lvs[m].w}; - } - } - out.payload_addr = 0xA0000000u | info.memory.base_address; - out.payload_size = lv[0].slice_bytes * 6; - out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); - out.recheck_frame = 0; - out.valid = false; // live only after commit - return true; - } - for (uint32_t face = 0; face < 6; ++face) { - for (uint32_t m = 0; m < levels; ++m) { - context.cmd->CopyBufferToTexture( - out.texture, m, face, out.upload, - size_t(face) * face_upload + lvs[m].upload_off, lvs[m].pitch, - lvs[m].w, lvs[m].w, 1); - } - } - context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - nrhi::TextureViewDesc srv; - srv.dimension = nrhi::ViewDimension::kCube; - srv.format = nrhi::Format::kR8G8B8A8_UNORM; - for (uint32_t c = 0; c < 4; ++c) { - srv.swizzle[c] = swizzle_mapping[c]; - } - srv.mip_levels = levels; - out.srv = device->CreateTextureView(out.texture, srv); - if (out.srv == nullptr) { - return false; - } - out.payload_addr = 0xA0000000u | info.memory.base_address; - out.payload_size = lv[0].slice_bytes * 6; - out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); - out.recheck_frame = 0; - out.valid = true; - return true; - } - - uint32_t face_upload = 0; // one face's full mip chain in the upload buffer - for (uint32_t m = 0; m < mip_levels; ++m) { - face_upload += lv[m].up_face_bytes; - } - nrhi::Device* device = context.device; - nrhi::TextureDesc desc; - desc.kind = nrhi::TextureKind::kCube; - desc.width = host_width; - desc.height = host_height; - desc.mip_levels = mip_levels; - desc.format = host.resource_format; - desc.initial_state = nrhi::ResourceState::kCopyDest; - out.texture = device->CreateTexture(desc); - if (out.texture == nullptr) { - return false; - } - out.upload = CreateUploadBuffer(device, face_upload * 6); - if (!out.upload) { - device->DestroyDeferred(out.texture); - out.texture = nullptr; - return false; - } - uint8_t* mapping = static_cast(device->Map(out.upload)); - const auto upload_offset = [&](uint32_t face, uint32_t m) { - uint32_t off = face * face_upload; - for (uint32_t k = 0; k < m; ++k) { - off += lv[k].up_face_bytes; - } - return off; - }; - // (The "Xenos cube T runs bottom-up" flip that briefly lived here was - // WRONG; it matched two probe pixels by coincidence and turned every - // facade pavement-white in game. The stored face orientation is correct - // as-is; the emulated look comes from LOD depth, not orientation.) - for (uint32_t face = 0; face < 6; ++face) { - for (uint32_t m = 0; m < mip_levels; ++m) { - const CubeLevel& L = lv[m]; - const uint8_t* guest = - cube_scratch.data() + L.scratch_off + size_t(face) * L.slice_bytes; - uint8_t* up = mapping + upload_offset(face, m); - const uint32_t row_bytes = L.cols * bytes_per_block; - for (uint32_t by = 0; by < L.rows; ++by) { - uint8_t* out_row = up + size_t(by) * L.up_pitch; - for (uint32_t bx = 0; bx < L.cols; ++bx) { - uint32_t source_offset; - if (info.is_tiled) { - source_offset = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(bx), int32_t(by), L.pitch_blocks, bytes_per_block_log2)); - } else { - source_offset = (by * L.pitch_blocks + bx) * bytes_per_block; - } - if (source_offset + bytes_per_block > L.slice_bytes) { - std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); - continue; - } - std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + source_offset, - bytes_per_block); - } - SwapGuestEndian(out_row, row_bytes, info.endianness); - } - } - } - device->Unmap(out.upload); - - REXLOG_INFO("native-scene: cube {:08X} {}x{} fmt={} mips={} (of {} avail)", tex_ptr, - width, height, uint32_t(info.format), mip_levels, info.mip_levels()); - if (g_tex_stage_out != nullptr) { - // Decode worker: export the commit recipe, face-major (face * levels + - // mip) entries matching the old D3D12 subresource numbering. - StagedTexCommit& sc = *g_tex_stage_out; - sc.copy_format = host.resource_format; - sc.srv_format = host.srv_format; - ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, sc.swizzle); - sc.cube = true; - sc.cube_mip_levels = mip_levels; - sc.mip_count = 6 * mip_levels; - for (uint32_t face = 0; face < 6; ++face) { - for (uint32_t m = 0; m < mip_levels; ++m) { - sc.mips[face * mip_levels + m] = {upload_offset(face, m), lv[m].up_pitch, - lv[m].w, lv[m].h}; - } - } - out.payload_addr = 0xA0000000u | info.memory.base_address; - out.payload_size = lv[0].slice_bytes * 6; - out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); - out.recheck_frame = 0; - out.valid = false; // live only after commit - return true; - } - - for (uint32_t face = 0; face < 6; ++face) { - for (uint32_t m = 0; m < mip_levels; ++m) { - context.cmd->CopyBufferToTexture(out.texture, m, face, out.upload, - upload_offset(face, m), lv[m].up_pitch, - lv[m].w, lv[m].h, 1); - } - } - context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - nrhi::TextureViewDesc srv; - srv.dimension = nrhi::ViewDimension::kCube; - srv.format = host.srv_format; - ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, srv.swizzle); - srv.mip_levels = mip_levels; - out.srv = device->CreateTextureView(out.texture, srv); - if (out.srv == nullptr) { - return false; - } - out.payload_addr = 0xA0000000u | info.memory.base_address; - out.payload_size = lv[0].slice_bytes * 6; - out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); - out.recheck_frame = 0; - out.valid = true; - return true; -} - -// Scene vertex layout (DecodeMesh's 56-byte output): shared by the scene -// PSO family and the shadow-caster PSO. `location` = the element's -// declaration order in the scene VS input struct (the Vulkan input -// location; see nrhi::InputElementDesc). -constexpr nrhi::InputElementDesc kSceneInputLayout[7] = { - {"POSITION", 0, 0, nrhi::Format::kR32G32B32_FLOAT, 0}, - {"TEXCOORD", 0, 1, nrhi::Format::kR32G32_FLOAT, 12}, - {"TEXCOORD", 1, 2, nrhi::Format::kR32G32_FLOAT, 20}, - {"BLENDWEIGHT", 0, 3, nrhi::Format::kR8G8B8A8_UNORM, 28}, - {"BLENDINDICES", 0, 4, nrhi::Format::kR8G8B8A8_UINT, 32}, - {"NORMAL", 0, 5, nrhi::Format::kR32G32B32_FLOAT, 36}, - {"TEXCOORD", 2, 6, nrhi::Format::kR32G32_FLOAT, 48}}; - -// ---- EnsurePipeline helpers ------------------------------------------------ -// Split from the former ~1,000-line EnsurePipeline: one resource -// group per function, bodies unchanged. Every helper is idempotent (guarded -// by its own g_r state) and returns false only on a failure that must abort -// the native path (g_r.failed set where the original did). - -// Looks up the offline-compiled SPIR-V for (file, entry, variant); variant is -// the canonical macro signature ("", "SAMPLES=4", "PFX_MSAA=1"). Returns a -// fully-populated ShaderDesc; SPIR-V absent only if the table and the compile -// matrix ever drift (Vulkan pipeline creation then fails loudly, D3D12 is -// unaffected). -nrhi::ShaderDesc MakeShaderDesc(nrhi::ShaderStage stage, const char* file, - const char* hlsl_source, const char* entry, - const nrhi::ShaderMacro* macros, - const char* variant) { - nrhi::ShaderDesc sd; - sd.stage = stage; - sd.name = file; - sd.hlsl_source = hlsl_source; - sd.entry_point = entry; - sd.macros = macros; - for (size_t i = 0; i < skate3::native_spirv::kNativeSpirvBlobCount; ++i) { - const auto& b = skate3::native_spirv::kNativeSpirvBlobs[i]; - if (std::strcmp(b.file, file) == 0 && std::strcmp(b.entry, entry) == 0 && - std::strcmp(b.variant, variant) == 0) { - sd.spirv = b.data; - sd.spirv_size_bytes = b.size_bytes; - break; - } - } - return sd; -} - -bool EnsureRootSignature(const NativeGuestOutputRenderContext& context) { - if (g_r.layout) { - return true; - } - { - nrhi::BindingLayoutDesc ld; - // NOTE the 64-DWORD root-signature budget (a D3D12-backend constraint: - // the layout maps 1:1 onto a D3D12 root signature there): 52 constants + - // 6 descriptor tables (1 each) + 1 root SRV (2) + 2 root CBVs (2 each) - // = 64, FULL. Going past 64 makes the D3D12 layout creation fail - // (renderer falls back to emulated). Any further addition must pack into - // existing rows/tables. - ld.param_count = 10; - ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 52, - nrhi::Visibility::kAll}; - ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, - nrhi::Visibility::kPixel}; - ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, - nrhi::Visibility::kPixel}; - ld.params[3] = {nrhi::BindingParamKind::kBufferSrv, /*t*/ 2, 1, - nrhi::Visibility::kVertex}; - // Macro overlay (t3). - ld.params[4] = {nrhi::BindingParamKind::kTextureTable, 3, 1, - nrhi::Visibility::kPixel}; - // Decal art / spec masks (t4). Second entry of the table = the fam 5/6 - // normal map (t5), bound via cmd->SetTexturePair. Draws without a pair - // leave t5 at the backend fallback; the shader only samples t5 when - // overlay.w == 4 (pair bound). - ld.params[5] = {nrhi::BindingParamKind::kTextureTable, 4, 2, - nrhi::Visibility::kPixel}; - // Dynamic-shadow additions: per-frame receiver constants (b1). - ld.params[6] = {nrhi::BindingParamKind::kConstantBuffer, /*b*/ 1, 1, - nrhi::Visibility::kPixel}; - // Environment cube (t6) + blurred shadow atlas (t7) as ONE two-entry - // table (bound together via SetTexturePair); merging them freed the - // root-signature DWORD the v2 material table below needs. - ld.params[7] = {nrhi::BindingParamKind::kTextureTable, 6, 2, - nrhi::Visibility::kPixel}; - // World-shading v2 material maps: the detail normal map (t8) + the - // decal families' spec/ecc masks (t9), bound as a pair on draws whose - // misc.z flags enable them. - ld.params[8] = {nrhi::BindingParamKind::kTextureTable, 8, 2, - nrhi::Visibility::kPixel}; - // Character lighting block (b2): the canonical per-draw rows captured - // from the guest PS bank (CaptureCharLighting), sliced out of the bone - // upload ring per character draw. - ld.params[9] = {nrhi::BindingParamKind::kConstantBuffer, 2, 1, - nrhi::Visibility::kPixel}; - ld.static_sampler_count = 2; - ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kAnisotropic, - nrhi::AddressMode::kWrap, 8}; - // s1: bilinear CLAMP for the 2D overlay. The HUD fetch constants carry - // clamp_x/clamp_y = 2 (clamp-to-edge), and the clock face is built from - // MIRRORED quadrant tiles whose outer-edge UVs run past 1.0; wrap - // sampling pulls the opposite edge of the art in as 1px seam lines - // (bright rim row across the middle, dark center column at the edges). - ld.static_samplers[1] = {1, nrhi::Filter::kLinear, - nrhi::AddressMode::kClamp, 1}; - ld.allow_input_layout = true; - g_r.layout = context.device->CreateBindingLayout(ld); - if (g_r.layout == nullptr) { - REXLOG_ERROR("native-scene: root signature creation failed"); - g_r.failed = true; - return false; - } - } - return true; -} - -// MSAA level selection + the main scene PSO and its culling/blend/depth -// variants (cull-back sheets, transparent, entity fade, hair 2-pass, -// no-depth, outline mask). -bool EnsureScenePsoFamily(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - // Scene color format: the float HDR intermediate when the HDR post chain - // is active (the tonemap pass then writes the gamma guest output), - // otherwise the guest output itself. - const nrhi::Format scene_fmt = g_r.hdr_active - ? g_r.hdr_scene_format - : context.guest_output->format(); - // MSAA level: the requested count, reduced to what the scene color format - // supports (1 disables and renders directly into the 1x target). The - // RHI walks the requested count down in powers of two, exactly like the - // old CheckFeatureSupport loop. - const int32_t req = REXCVAR_GET(skate3_native_render_scene_msaa); - uint32_t msaa = req >= 8 ? 8u : req >= 4 ? 4u : req >= 2 ? 2u : 1u; - msaa = device->GetSupportedSampleCount(scene_fmt, msaa); - g_r.msaa = msaa; - - // The family rebuilds on HDR toggles / output-format changes: retire the - // previous pipelines (in-flight frames keep them alive via the deferred - // destruction queue). - for (nrhi::Pipeline** p : - {&g_r.pso, &g_r.pso_cullback, &g_r.pso_transparent, &g_r.pso_fade, - &g_r.pso_hair_a, &g_r.pso_hair_b, &g_r.pso_nodepth, - &g_r.pso_outline_mask}) { - if (*p != nullptr) { - device->DestroyDeferred(*p); - *p = nullptr; - } - } - - // HDR=1 switches the pixel shader's output encode to pre-tonemap linear - // (see scene.hlsl ToneOut/PassGamma). - const nrhi::ShaderMacro hdr_defs[] = {{"HDR", "1"}, {nullptr, nullptr}}; - nrhi::Shader* vs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "scene.hlsl", kShaderSource, - "vs_main", nullptr, "")); - nrhi::Shader* ps = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, - "ps_main", g_r.hdr_active ? hdr_defs : nullptr, - g_r.hdr_active ? "HDR=1" : "")); - if (vs == nullptr || ps == nullptr) { - REXLOG_ERROR("native-scene: shader compile failed"); - g_r.failed = true; - return false; - } - nrhi::GraphicsPipelineDesc pso; - pso.layout = g_r.layout; - pso.vs = vs; - pso.ps = ps; - pso.input_elements = kSceneInputLayout; - pso.input_element_count = 7; - pso.vertex_stride = 56; - pso.cull = nrhi::CullMode::kNone; - pso.depth_clip = true; - pso.depth.test_enable = true; - pso.depth.write_enable = true; - pso.depth.func = nrhi::CompareFunc::kLess; - pso.rtv_format = scene_fmt; - pso.dsv_format = nrhi::Format::kD32_FLOAT; - pso.sample_count = g_r.msaa; - g_r.pso = device->CreateGraphicsPipeline(pso); - // Culling variant for double-sided sheet props (banners/flags, - // MeshBuffers::two_sided_sheet). The sheet whose winding-derived world - // normal faces the camera is the one the game keeps (its lightmap cell - // reproduces the emulated banner exactly: albedo x lm x 2 lands within - // 3-8% of the F11 emulated reference on capture 1783387480); under our - // pipeline those triangles are D3D12 BACK faces, so cull FRONT. - // (CULL_BACK was tried first and kept the wrong sheet; banners rendered - // the sun-side lightmap cell ~2.4x brighter than emulated.) - pso.cull = nrhi::CullMode::kFront; - g_r.pso_cullback = device->CreateGraphicsPipeline(pso); - if (g_r.pso_cullback == nullptr) { - REXLOG_WARN("native-scene: cull-back PSO creation failed"); - // sheets fall back to the uncull(ed) PSO - } - pso.cull = nrhi::CullMode::kNone; - // Transparent variant: straight alpha blend, depth-tested, no z-write. - pso.depth.write_enable = false; - pso.blend.enable = true; - pso.blend.src = nrhi::BlendFactor::kSrcAlpha; - pso.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; - pso.blend.op = nrhi::BlendOp::kAdd; - pso.blend.src_alpha = nrhi::BlendFactor::kOne; - pso.blend.dst_alpha = nrhi::BlendFactor::kInvSrcAlpha; - pso.blend.op_alpha = nrhi::BlendOp::kAdd; - g_r.pso_transparent = device->CreateGraphicsPipeline(pso); - // Entity-fade variant: z-write ON (see RendererState::pso_fade). Drawn - // at the head of the blended sub-pass so glass/hair still composite - // over the faded body. - pso.depth.write_enable = true; - g_r.pso_fade = device->CreateGraphicsPipeline(pso); - // (nullptr = fade items fall back to the z-write-off blend) - pso.depth.write_enable = false; - // Hair passes: the game draws hair twice with the SAME shader; cull - // BACK then cull FRONT (cac_hair.xml passes 0/1) so far-side strands - // never composite over near-side ones (one uncull(ed) blended pass - // interleaves them per triangle order = crunchy noise). Same blend / - // z-write-off state as the transparent variant. - pso.cull = nrhi::CullMode::kBack; - g_r.pso_hair_a = device->CreateGraphicsPipeline(pso); - pso.cull = nrhi::CullMode::kFront; - g_r.pso_hair_b = device->CreateGraphicsPipeline(pso); - pso.cull = nrhi::CullMode::kNone; - pso.blend = {}; - pso.depth.write_enable = true; - pso.depth.test_enable = false; - pso.dsv_format = nrhi::Format::kUnknown; - g_r.pso_nodepth = device->CreateGraphicsPipeline(pso); - // Selection-outline mask: the same scene VS/PS (the tint.a > 0 solid - // path renders flat 1.0) into the small single-sample R8 target. No - // depth: the mask is the full silhouette. The guest marking pass is - // depth-tested, so a partially occluded selection outlines slightly - // differently, acceptable for the editor UI. - pso.rtv_format = nrhi::Format::kR8_UNORM; - pso.sample_count = 1; - g_r.pso_outline_mask = device->CreateGraphicsPipeline(pso); - if (g_r.pso_outline_mask == nullptr) { - REXLOG_WARN("native-scene: outline mask PSO creation failed"); - // outline pass disables itself - } - pso.rtv_format = scene_fmt; - pso.sample_count = g_r.msaa; - device->DestroyDeferred(vs); - device->DestroyDeferred(ps); - if (g_r.pso == nullptr || g_r.pso_nodepth == nullptr || - g_r.pso_transparent == nullptr) { - REXLOG_ERROR("native-scene: PSO creation failed"); - g_r.failed = true; - return false; - } - return true; -} - -// Fullscreen MSAA resolve PSO (no-op below MSAA 2x). -bool EnsureResolvePso(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - if (g_r.resolve_pso != nullptr) { - device->DestroyDeferred(g_r.resolve_pso); - g_r.resolve_pso = nullptr; - } - if (g_r.msaa > 1) { - char samples[8]; - std::snprintf(samples, sizeof(samples), "%u", g_r.msaa); - const nrhi::ShaderMacro macros[] = {{"SAMPLES", samples}, - {nullptr, nullptr}}; - char variant[16]; - std::snprintf(variant, sizeof(variant), "SAMPLES=%u", g_r.msaa); - nrhi::Shader* rvs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "resolve.hlsl", - kResolveShaderSource, "vs_main", macros, variant)); - nrhi::Shader* rps = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "resolve.hlsl", - kResolveShaderSource, "ps_main", macros, variant)); - if (rvs == nullptr || rps == nullptr) { - REXLOG_ERROR("native-scene: resolve shader compile failed"); - g_r.failed = true; - return false; - } - nrhi::GraphicsPipelineDesc rp; - rp.layout = g_r.layout; - rp.vs = rvs; - rp.ps = rps; - rp.cull = nrhi::CullMode::kNone; - // Under HDR the resolve averages the float MSAA scene into the 1x float - // plane (hdr_resolved); classic resolves straight into the guest output. - rp.rtv_format = g_r.hdr_active ? g_r.hdr_scene_format - : context.guest_output->format(); - rp.sample_count = 1; - g_r.resolve_pso = device->CreateGraphicsPipeline(rp); - device->DestroyDeferred(rvs); - device->DestroyDeferred(rps); - if (g_r.resolve_pso == nullptr) { - REXLOG_ERROR("native-scene: resolve PSO creation failed"); - g_r.failed = true; - return false; - } - } - return true; -} - -// Popup background blur pipelines; PSO-create failure only disables blur. -bool EnsureBlurPsos(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - { - // Popup background blur pipelines (blur_hBlur/vBlur port + basictex - // replace): fullscreen-triangle passes, no vertex buffer. - nrhi::Shader* bvs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "blur.hlsl", - kBlurShaderSource, "vs_main", nullptr, "")); - nrhi::Shader* bps = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", - kBlurShaderSource, "ps_main", nullptr, "")); - nrhi::Shader* bblit = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", - kBlurShaderSource, "ps_blit", nullptr, "")); - nrhi::Shader* bdown = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", - kBlurShaderSource, "ps_down", nullptr, "")); - nrhi::Shader* bgauss = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", - kBlurShaderSource, "ps_menu_gauss", nullptr, "")); - if (bvs == nullptr || bps == nullptr || bblit == nullptr || - bdown == nullptr || bgauss == nullptr) { - REXLOG_ERROR("native-scene: blur shader compile failed"); - g_r.failed = true; - return false; - } - nrhi::GraphicsPipelineDesc bp; - bp.layout = g_r.layout; - bp.vs = bvs; - bp.ps = bps; - bp.cull = nrhi::CullMode::kNone; - bp.rtv_format = context.guest_output->format(); - bp.sample_count = 1; - if (g_r.pso_blur) device->DestroyDeferred(g_r.pso_blur); - if (g_r.pso_blur_blit) device->DestroyDeferred(g_r.pso_blur_blit); - if (g_r.pso_blur_down) device->DestroyDeferred(g_r.pso_blur_down); - if (g_r.pso_menu_gauss) device->DestroyDeferred(g_r.pso_menu_gauss); - g_r.pso_blur = device->CreateGraphicsPipeline(bp); - bp.ps = bblit; - g_r.pso_blur_blit = device->CreateGraphicsPipeline(bp); - bp.ps = bdown; - g_r.pso_blur_down = device->CreateGraphicsPipeline(bp); - bp.ps = bgauss; - g_r.pso_menu_gauss = device->CreateGraphicsPipeline(bp); - device->DestroyDeferred(bvs); - device->DestroyDeferred(bps); - device->DestroyDeferred(bblit); - device->DestroyDeferred(bdown); - device->DestroyDeferred(bgauss); - if (g_r.pso_blur == nullptr || g_r.pso_blur_blit == nullptr || - g_r.pso_blur_down == nullptr || g_r.pso_menu_gauss == nullptr) { - REXLOG_ERROR("native-scene: blur PSO creation failed"); - g_r.pso_blur = nullptr; - g_r.pso_blur_blit = nullptr; // blur unavailable; everything else runs - g_r.pso_blur_down = nullptr; - g_r.pso_menu_gauss = nullptr; - } - } - return true; -} - -// Selection-outline edge composite PSO; create failure disables outline. -bool EnsureOutlineEdgePso(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - { - // Selection-outline edge composite (postfx_edgedetectstencil port): - // fullscreen triangle over the resolved output, additive blend. - nrhi::Shader* ovs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "outline.hlsl", - kOutlineShaderSource, "vs_main", nullptr, "")); - nrhi::Shader* ops = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "outline.hlsl", - kOutlineShaderSource, "ps_main", nullptr, "")); - if (ovs == nullptr || ops == nullptr) { - REXLOG_ERROR("native-scene: outline shader compile failed"); - g_r.failed = true; - return false; - } - nrhi::GraphicsPipelineDesc op; - op.layout = g_r.layout; - op.vs = ovs; - op.ps = ops; - op.blend.enable = true; - op.blend.src = nrhi::BlendFactor::kOne; - op.blend.dst = nrhi::BlendFactor::kOne; - op.blend.op = nrhi::BlendOp::kAdd; - op.blend.src_alpha = nrhi::BlendFactor::kZero; - op.blend.dst_alpha = nrhi::BlendFactor::kOne; - op.blend.op_alpha = nrhi::BlendOp::kAdd; - op.cull = nrhi::CullMode::kNone; - op.rtv_format = context.guest_output->format(); - op.sample_count = 1; - if (g_r.pso_outline_edge) device->DestroyDeferred(g_r.pso_outline_edge); - g_r.pso_outline_edge = device->CreateGraphicsPipeline(op); - device->DestroyDeferred(ovs); - device->DestroyDeferred(ops); - if (g_r.pso_outline_edge == nullptr) { - REXLOG_WARN("native-scene: outline edge PSO creation failed"); - // outline unavailable; everything else runs - } - } - return true; -} - -// 2D overlay pipeline. -bool Ensure2dPso(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - { - // 2D overlay pipeline: standard alpha blend, no depth, drawn into the - // resolved guest output (sample count 1). - nrhi::Shader* uvs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "overlay2d.hlsl", - kShader2dSource, "vs_main", nullptr, "")); - nrhi::Shader* ups = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "overlay2d.hlsl", - kShader2dSource, "ps_main", nullptr, "")); - if (uvs == nullptr || ups == nullptr) { - REXLOG_ERROR("native-scene: 2D shader compile failed"); - g_r.failed = true; - return false; - } - static constexpr nrhi::InputElementDesc input2d[3] = { - {"POSITION", 0, 0, nrhi::Format::kR32G32B32A32_FLOAT, 0}, - {"TEXCOORD", 0, 1, nrhi::Format::kR32G32_FLOAT, 16}, - {"COLOR", 0, 2, nrhi::Format::kR8G8B8A8_UNORM, 24}}; - nrhi::GraphicsPipelineDesc up; - up.layout = g_r.layout; - up.vs = uvs; - up.ps = ups; - up.blend.enable = true; - // Straight (non-premultiplied) alpha, verified against the decoded - // HUD clock art: the glass-sheen texture is white RGB at low alpha - // (premultiplied blending blows it out into an opaque white blob). - up.blend.src = nrhi::BlendFactor::kSrcAlpha; - up.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; - up.blend.op = nrhi::BlendOp::kAdd; - up.blend.src_alpha = nrhi::BlendFactor::kOne; - up.blend.dst_alpha = nrhi::BlendFactor::kInvSrcAlpha; - up.blend.op_alpha = nrhi::BlendOp::kAdd; - up.cull = nrhi::CullMode::kNone; - up.input_elements = input2d; - up.input_element_count = 3; - up.vertex_stride = 28; - up.rtv_format = context.guest_output->format(); - up.sample_count = 1; - g_r.pso_2d = device->CreateGraphicsPipeline(up); - // FMV variant: identical pipeline, ps_yuv2d combine (see the movie-quad - // substitution in the 2D replay). Failure only loses native FMV; the - // emulated yield fallback covers it. - nrhi::Shader* uyuv = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "overlay2d.hlsl", - kShader2dSource, "ps_yuv2d", nullptr, "")); - if (uyuv != nullptr) { - up.ps = uyuv; - if (g_r.pso_yuv2d) device->DestroyDeferred(g_r.pso_yuv2d); - g_r.pso_yuv2d = device->CreateGraphicsPipeline(up); - if (g_r.pso_yuv2d == nullptr) { - REXLOG_WARN("native-scene: FMV 2D PSO creation failed - movies yield"); - } - device->DestroyDeferred(uyuv); - } else { - REXLOG_WARN("native-scene: ps_yuv2d compile failed - movies yield"); - } - device->DestroyDeferred(uvs); - device->DestroyDeferred(ups); - if (g_r.pso_2d == nullptr) { - REXLOG_ERROR("native-scene: 2D PSO creation failed"); - g_r.failed = true; - return false; - } - } - return true; -} - -// In-world spline pipelines (darken + additive default). -bool EnsureSplinePsos(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - { - // In-world spline pipelines: drawn inside the scene pass (MSAA sample - // count, depth test LESS_EQUAL, no z-write) with the game's own blend - // states (spline.xml): darken = straight alpha, default = additive. - // Under HDR the pixel shaders encode through the tone chain's inverse - // (SplineOut) so the host tonemap restores the authored look. - for (nrhi::Pipeline** p : {&g_r.pso_spline_darken, &g_r.pso_spline_default}) { - if (*p != nullptr) { - device->DestroyDeferred(*p); - *p = nullptr; - } - } - const nrhi::ShaderMacro hdr_defs[] = {{"HDR", "1"}, {nullptr, nullptr}}; - const nrhi::ShaderMacro* sp_defs = g_r.hdr_active ? hdr_defs : nullptr; - const char* sp_variant = g_r.hdr_active ? "HDR=1" : ""; - nrhi::Shader* svs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "spline.hlsl", - kShaderSplineSource, "vs_main", nullptr, "")); - nrhi::Shader* spd = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "spline.hlsl", - kShaderSplineSource, "ps_default", sp_defs, sp_variant)); - nrhi::Shader* spk = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "spline.hlsl", - kShaderSplineSource, "ps_darken", sp_defs, sp_variant)); - if (svs == nullptr || spd == nullptr || spk == nullptr) { - REXLOG_ERROR("native-scene: spline shader compile failed"); - g_r.failed = true; - return false; - } - static constexpr nrhi::InputElementDesc input_spline[3] = { - {"POSITION", 0, 0, nrhi::Format::kR32G32B32A32_FLOAT, 0}, - {"TEXCOORD", 0, 1, nrhi::Format::kR32G32_FLOAT, 16}, - {"TEXCOORD", 1, 2, nrhi::Format::kR32_FLOAT, 24}}; - nrhi::GraphicsPipelineDesc sp; - sp.layout = g_r.layout; - sp.vs = svs; - sp.cull = nrhi::CullMode::kNone; - sp.depth_clip = true; - sp.depth.test_enable = true; - sp.depth.write_enable = false; - sp.depth.func = nrhi::CompareFunc::kLessEqual; - sp.input_elements = input_spline; - sp.input_element_count = 3; - sp.vertex_stride = 28; - sp.rtv_format = g_r.hdr_active ? g_r.hdr_scene_format - : context.guest_output->format(); - sp.dsv_format = nrhi::Format::kD32_FLOAT; - sp.sample_count = g_r.msaa; - sp.blend.enable = true; - sp.blend.op = nrhi::BlendOp::kAdd; - sp.blend.op_alpha = nrhi::BlendOp::kAdd; - // darken pass: straight alpha. - sp.ps = spk; - sp.blend.src = nrhi::BlendFactor::kSrcAlpha; - sp.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; - sp.blend.src_alpha = nrhi::BlendFactor::kSrcAlpha; - sp.blend.dst_alpha = nrhi::BlendFactor::kInvSrcAlpha; - g_r.pso_spline_darken = device->CreateGraphicsPipeline(sp); - // default pass: additive glow. - sp.ps = spd; - sp.blend.src = nrhi::BlendFactor::kOne; - sp.blend.dst = nrhi::BlendFactor::kOne; - sp.blend.src_alpha = nrhi::BlendFactor::kOne; - sp.blend.dst_alpha = nrhi::BlendFactor::kOne; - g_r.pso_spline_default = device->CreateGraphicsPipeline(sp); - device->DestroyDeferred(svs); - device->DestroyDeferred(spd); - device->DestroyDeferred(spk); - if (g_r.pso_spline_darken == nullptr || g_r.pso_spline_default == nullptr) { - REXLOG_ERROR("native-scene: spline PSO creation failed"); - g_r.failed = true; - return false; - } - } - return true; -} - -// Dynamic-shadow caster + per-tile blur/convert PSOs. -bool EnsureShadowPsos(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - { - // Dynamic-shadow pipelines: casters (scene VS with a light-space - // "view-proj", MIN-blend depth/uncoverage accumulation, no depth - // buffer, depth clip OFF so casters outside the 12 m height window - // clamp like the game accepts) + the per-tile blur/convert pass. - nrhi::Shader* cvs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "scene.hlsl", kShaderSource, - "vs_main", nullptr, "")); - nrhi::Shader* cps = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, - "ps_shadow_caster", nullptr, "")); - nrhi::Shader* bvs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "shadow_blur.hlsl", - kShadowBlurSource, "vs_main", nullptr, "")); - nrhi::Shader* bps = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "shadow_blur.hlsl", - kShadowBlurSource, "ps_main", nullptr, "")); - if (cvs == nullptr || cps == nullptr || bvs == nullptr || bps == nullptr) { - REXLOG_ERROR("native-scene: shadow shader compile failed"); - g_r.failed = true; - return false; - } - nrhi::GraphicsPipelineDesc cp; - cp.layout = g_r.layout; - cp.vs = cvs; - cp.ps = cps; - cp.blend.enable = true; - cp.blend.src = nrhi::BlendFactor::kOne; - cp.blend.dst = nrhi::BlendFactor::kOne; - cp.blend.op = nrhi::BlendOp::kMin; - cp.blend.src_alpha = nrhi::BlendFactor::kOne; - cp.blend.dst_alpha = nrhi::BlendFactor::kOne; - cp.blend.op_alpha = nrhi::BlendOp::kMin; - cp.blend.write_mask = 0x3; // RG only - cp.cull = nrhi::CullMode::kNone; - cp.depth_clip = false; - cp.input_elements = kSceneInputLayout; - cp.input_element_count = 7; - cp.vertex_stride = 56; - cp.rtv_format = nrhi::Format::kR16G16_UNORM; - cp.sample_count = 1; - g_r.pso_shadow_caster = device->CreateGraphicsPipeline(cp); - nrhi::GraphicsPipelineDesc bp; - bp.layout = g_r.layout; - bp.vs = bvs; - bp.ps = bps; - bp.cull = nrhi::CullMode::kNone; - bp.depth_clip = true; - bp.rtv_format = nrhi::Format::kR16G16_UNORM; - bp.sample_count = 1; - g_r.pso_shadow_blur = device->CreateGraphicsPipeline(bp); - device->DestroyDeferred(cvs); - device->DestroyDeferred(cps); - device->DestroyDeferred(bvs); - device->DestroyDeferred(bps); - if (g_r.pso_shadow_caster == nullptr || g_r.pso_shadow_blur == nullptr) { - REXLOG_ERROR("native-scene: shadow PSO creation failed"); - g_r.failed = true; - return false; - } - } - return true; -} - -// The bone / ROPA / 2D-vertex upload rings (persistently mapped). The old -// RTV/DSV/SRV descriptor heaps and their increment-size bookkeeping are gone -// - render targets bind by texture and sampled views are backend-managed -// objects. (Name kept for the call sites.) -bool EnsureHeapsAndRings(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - - if (!g_r.bone_ring) { - g_r.bone_ring = CreateUploadBuffer( - device, size_t(RendererState::kBoneRegionSize) * RendererState::kBoneRegions); - g_r.bone_ring_cpu = - g_r.bone_ring ? static_cast(device->Map(g_r.bone_ring)) - : nullptr; - if (g_r.bone_ring_cpu == nullptr) { - g_r.failed = true; - return false; - } - } - - if (!g_r.ropa_ring) { - g_r.ropa_ring = CreateUploadBuffer( - device, size_t(RendererState::kRopaRegionSize) * RendererState::kBoneRegions); - g_r.ropa_ring_cpu = - g_r.ropa_ring ? static_cast(device->Map(g_r.ropa_ring)) - : nullptr; - if (g_r.ropa_ring_cpu == nullptr) { - g_r.failed = true; - return false; - } - } - - if (!g_r.ui_ring) { - g_r.ui_ring = CreateUploadBuffer( - device, size_t(RendererState::kUiRegionSize) * RendererState::kUiRegions); - g_r.ui_ring_cpu = g_r.ui_ring - ? static_cast(device->Map(g_r.ui_ring)) - : nullptr; - if (g_r.ui_ring_cpu == nullptr) { - g_r.failed = true; - return false; - } - } - return true; -} - -// ---- Photo-editor postfx chain (photo_fx.hlsl) ----------------------------- -// Root signature + eight PSOs + the fixed-size intermediates. Lazy: built on -// the first photo-editor frame (a one-time ~100 ms compile the frozen-scene -// editor absorbs invisibly). Output-sized targets are (re)built per frame by -// the render block on size change. -bool EnsurePhotoFxPipeline(const NativeGuestOutputRenderContext& context) { - if (g_r.pfx_ready) { - return true; - } - if (g_r.pfx_failed) { - return false; - } - nrhi::Device* device = context.device; - const auto fail = [&](const char* what) { - REXLOG_ERROR("native-scene: photo postfx pipeline setup failed ({})", what); - g_r.pfx_failed = true; - return false; - }; - // Binding layout: CBV b0 + ONE eight-texture table t0..t7 + samplers. (The - // old eight single-SRV tables would need nine Vulkan descriptor sets, - // over the 8-set minimum on Intel/MoltenVK, so the passes gather their - // t0..t7 views and bind them with one cmd->SetTextures(1, views, 8).) - if (g_r.pfx_layout == nullptr) { - nrhi::BindingLayoutDesc ld; - ld.param_count = 2; - ld.params[0] = {nrhi::BindingParamKind::kConstantBuffer, /*b*/ 0, 1, - nrhi::Visibility::kAll}; - ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 8, - nrhi::Visibility::kPixel}; - ld.static_sampler_count = 3; - ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kLinear, - nrhi::AddressMode::kClamp, 1}; - ld.static_samplers[1] = {1, nrhi::Filter::kPoint, nrhi::AddressMode::kClamp, - 1}; // packed depth - ld.static_samplers[2] = {2, nrhi::Filter::kLinear, nrhi::AddressMode::kWrap, - 1}; // grain - ld.allow_input_layout = false; - g_r.pfx_layout = device->CreateBindingLayout(ld); - if (g_r.pfx_layout == nullptr) { - return fail("root signature"); - } - } - // Shaders + PSOs. Pass order matches RendererState::pfx_pso. - struct Entry { - const char* vs; - const char* ps; - nrhi::Format rtv; - }; - const Entry entries[9] = { - {"vs_raw", "ps_depthpack", nrhi::Format::kR8G8B8A8_UNORM}, - {"vs_offset", "ps_visualfx", nrhi::Format::kR8G8B8A8_UNORM}, - {"vs_offset", "ps_dof_down", nrhi::Format::kR8G8B8A8_UNORM}, - {"vs_offset", "ps_dof_mb", nrhi::Format::kR8G8B8A8_UNORM}, - {"vs_offset", "ps_dof", nrhi::Format::kR8G8B8A8_UNORM}, - {"vs_raw", "ps_uber", nrhi::Format::kR8G8B8A8_UNORM}, - {"vs_scaled", "ps_fisheye", context.guest_output->format()}, - {"vs_raw", "ps_blit", nrhi::Format::kR8G8B8A8_UNORM}, - // Debug visualizer (photo_native_debug cvar): CoC / packed-depth view - // drawn over the output instead of the fisheye result. - {"vs_raw", "ps_pfx_debug", context.guest_output->format()}, - }; - const nrhi::ShaderMacro msaa_defines[] = {{"PFX_MSAA", "1"}, - {nullptr, nullptr}}; - for (int i = 0; i < 9; ++i) { - const bool msaa_pass = (i == 0 && g_r.msaa > 1); - const nrhi::ShaderMacro* defs = msaa_pass ? msaa_defines : nullptr; - const char* variant = msaa_pass ? "PFX_MSAA=1" : ""; - nrhi::Shader* vs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "photo_fx.hlsl", - kPhotoFxShaderSource, entries[i].vs, defs, variant)); - nrhi::Shader* ps = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "photo_fx.hlsl", - kPhotoFxShaderSource, entries[i].ps, defs, variant)); - if (vs == nullptr || ps == nullptr) { - REXLOG_ERROR("native-scene: photo postfx shader compile failed ({})", - entries[i].ps); - g_r.pfx_failed = true; - return false; - } - nrhi::GraphicsPipelineDesc pso; - pso.layout = g_r.pfx_layout; - pso.vs = vs; - pso.ps = ps; - pso.cull = nrhi::CullMode::kNone; - pso.depth_clip = true; - pso.rtv_format = entries[i].rtv; - pso.sample_count = 1; - g_r.pfx_pso[i] = device->CreateGraphicsPipeline(pso); - device->DestroyDeferred(vs); - device->DestroyDeferred(ps); - if (g_r.pfx_pso[i] == nullptr) { - return fail(entries[i].ps); - } - } - // Fixed-size intermediates + the identity grade LUT + the CB ring. - { - nrhi::TextureDesc desc; - desc.format = nrhi::Format::kR8G8B8A8_UNORM; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - struct Fixed { - nrhi::Texture** res; - uint32_t w, h; - }; - const Fixed fixed[3] = { - {&g_r.pfx_half[0], RendererState::kPfxHalfW, RendererState::kPfxHalfH}, - {&g_r.pfx_half[1], RendererState::kPfxHalfW, RendererState::kPfxHalfH}, - {&g_r.pfx_quarter, RendererState::kPfxQuarterW, RendererState::kPfxQuarterH}, - }; - for (const Fixed& f : fixed) { - if (*f.res != nullptr) { - continue; - } - desc.width = f.w; - desc.height = f.h; - *f.res = device->CreateTexture(desc); - if (*f.res == nullptr) { - return fail("intermediate target"); - } - } - if (g_r.pfx_lut == nullptr) { - // 32^3 identity grade LUT (the editor captures run with the LUT blend - // weight at 0; identity keeps any treatment that enables it neutral - // instead of garbage). Coordinate mapping mirrors the uber literals: - // u = g*0.96875 + 0.015625, v = r*(-0.96875) + 0.984375 (flipped), - // w = b*0.96875 + 0.015625, so voxel (ix,iy,iz) stores - // r = (0.984375 - v)/0.96875, g = (u - 0.015625)/0.96875, b likewise. - nrhi::TextureDesc lut; - lut.kind = nrhi::TextureKind::k3D; - lut.width = 32; - lut.height = 32; - lut.depth = 32; - lut.format = nrhi::Format::kR8G8B8A8_UNORM; - lut.initial_state = nrhi::ResourceState::kCopyDest; - g_r.pfx_lut = device->CreateTexture(lut); - if (g_r.pfx_lut == nullptr) { - return fail("LUT"); - } - // Upload: 32 rows of 32 texels x 32 slices, 256-byte row pitch. - const uint32_t row_pitch = 256; - const uint32_t slice_pitch = row_pitch * 32; - g_r.pfx_lut_upload = CreateUploadBuffer(device, size_t(slice_pitch) * 32); - if (g_r.pfx_lut_upload == nullptr) { - return fail("LUT upload"); - } - uint8_t* p = static_cast(device->Map(g_r.pfx_lut_upload)); - if (p == nullptr) { - return fail("LUT map"); - } - for (uint32_t iz = 0; iz < 32; ++iz) { - for (uint32_t iy = 0; iy < 32; ++iy) { - uint8_t* row = p + size_t(iz) * slice_pitch + size_t(iy) * row_pitch; - const float v = (float(iy) + 0.5f) / 32.0f; - const float w = (float(iz) + 0.5f) / 32.0f; - const float r = (0.984375f - v) / 0.96875f; - const float b = (w - 0.015625f) / 0.96875f; - for (uint32_t ix = 0; ix < 32; ++ix) { - const float u = (float(ix) + 0.5f) / 32.0f; - const float g = (u - 0.015625f) / 0.96875f; - row[ix * 4 + 0] = uint8_t(std::clamp(r, 0.0f, 1.0f) * 255.0f + 0.5f); - row[ix * 4 + 1] = uint8_t(std::clamp(g, 0.0f, 1.0f) * 255.0f + 0.5f); - row[ix * 4 + 2] = uint8_t(std::clamp(b, 0.0f, 1.0f) * 255.0f + 0.5f); - row[ix * 4 + 3] = 255; - } - } - } - device->Unmap(g_r.pfx_lut_upload); - } - if (g_r.pfx_cb == nullptr) { - // 8 pass slots x 4 KB x 4 frames in flight. - // 10 slots per frame region: depth pack + accum feed + 6 passes + - // accum mode 2 + the debug view. - g_r.pfx_cb = CreateUploadBuffer(device, 10u * 4096u * 4u); - g_r.pfx_cb_ptr = g_r.pfx_cb - ? static_cast(device->Map(g_r.pfx_cb)) - : nullptr; - if (g_r.pfx_cb_ptr == nullptr) { - return fail("constant ring"); - } - } - } - // Fixed-target views (2/3 = halves, 4 = quarter, 6 = LUT; 0/1/5/7, the - // output-sized full/depth targets and the native depth resource, are - // (re)created by the render block on size change). - if (!g_r.pfx_srv_allocated) { - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.pfx_srv[2] = device->CreateTextureView(g_r.pfx_half[0], vd); - g_r.pfx_srv[3] = device->CreateTextureView(g_r.pfx_half[1], vd); - g_r.pfx_srv[4] = device->CreateTextureView(g_r.pfx_quarter, vd); - // LUT SRV (Texture3D). - nrhi::TextureViewDesc lv; - lv.dimension = nrhi::ViewDimension::k3D; - lv.mip_levels = 1; - g_r.pfx_srv[6] = device->CreateTextureView(g_r.pfx_lut, lv); - if (g_r.pfx_srv[2] == nullptr || g_r.pfx_srv[3] == nullptr || - g_r.pfx_srv[4] == nullptr || g_r.pfx_srv[6] == nullptr) { - return fail("fixed-target views"); - } - g_r.pfx_srv_allocated = true; - } - g_r.pfx_ready = true; - REXLOG_INFO("native-scene: photo postfx pipeline ready (9 passes, msaa={})", - g_r.msaa); - return true; -} - -// Shadow atlas targets + the always-bound b1 receiver constant buffer. -bool EnsureShadowResources(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - if (!g_r.shadow_raw && REXCVAR_GET(skate3_native_render_scene_shadows)) { - // Dynamic-shadow atlas chain: raw casters -> hblur intermediate -> - // blurred final (the texture the scene pass samples). Three fixed-size - // R16G16_UNORM targets (the game's atlas is fmt 25 = 16_16 fixed point; - // half-float ulp at the typical ~0.85 depth is ~6 mm of world height, - // too coarse for board/feet-height casters 1-2 cm off the ground), - // 3 tiles of tile x tile each. - const int32_t tile_cfg = REXCVAR_GET(skate3_native_render_scene_shadow_tile); - if (tile_cfg > 0) { - g_r.shadow_tile = uint32_t(tile_cfg); - } else { - // Auto: the game's 512 tiles at the RENDER resolution scale. - // guest_output is the scaled frontbuffer (720p x the draw resolution - // scale, independent of window/monitor size), so deriving from its - // height follows the Resolution Scale setting, including any - // device-limit clamping the texture cache applied, and gives the - // same effective shadow raster the emulated GPU renders at that - // scale. Resolved once at atlas creation (the cvar is - // restart-scoped). - const uint32_t scale = - std::max(1u, (context.guest_output_height + 719u) / 720u); - g_r.shadow_tile = std::min(512u * scale, 4096u); - } - nrhi::TextureDesc desc; - desc.width = g_r.shadow_tile * 3; - desc.height = g_r.shadow_tile; - desc.format = nrhi::Format::kR16G16_UNORM; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - desc.clear_color[0] = 1.0f; // depth: far - desc.clear_color[1] = 1.0f; // "uncoverage": empty - nrhi::Texture** targets[3] = {&g_r.shadow_raw, &g_r.shadow_mid, - &g_r.shadow_final}; - nrhi::TextureView** views[3] = {&g_r.shadow_srv_raw, &g_r.shadow_srv_mid, - &g_r.shadow_srv_final}; - for (int t = 0; t < 3; ++t) { - *targets[t] = device->CreateTexture(desc); - if (*targets[t] == nullptr) { - REXLOG_ERROR("native-scene: shadow atlas creation failed"); - g_r.failed = true; - return false; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - *views[t] = device->CreateTextureView(*targets[t], vd); - if (*views[t] == nullptr) { - REXLOG_ERROR("native-scene: shadow atlas creation failed"); - g_r.failed = true; - return false; - } - } - g_r.shadow_in_srv_state = false; - REXLOG_INFO("native-scene: shadow atlas created ({}x{} tiles{})", - g_r.shadow_tile, g_r.shadow_tile, - tile_cfg > 0 ? "" : ", auto"); - } - if (!g_r.world_shadow && g_r.shadow_raw != nullptr) { - // dynamicobject static world-shadow map (see RendererState). Fixed at - // the game's own 512x512: a coarse region-scale baked-shade map whose - // PCF footprint the props' shading was tuned against. - nrhi::TextureDesc desc; - desc.width = RendererState::kWorldShadowSize; - desc.height = RendererState::kWorldShadowSize; - desc.format = nrhi::Format::kR16G16_UNORM; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - desc.clear_color[0] = 1.0f; // depth: far = lit - desc.clear_color[1] = 1.0f; - g_r.world_shadow = device->CreateTexture(desc); - if (g_r.world_shadow != nullptr) { - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.world_shadow_srv = device->CreateTextureView(g_r.world_shadow, vd); - } - if (g_r.world_shadow_srv == nullptr) { - REXLOG_ERROR("native-scene: world-shadow map creation failed"); - g_r.failed = true; - return false; - } - g_r.world_shadow_in_srv = false; - g_r.world_shadow_primed = false; - } - if (!g_r.shadow_cb) { - // Always created (even with shadows off): the scene PS declares b1 and - // a root CBV must be bound; a zeroed block disables the shadow branch. - g_r.shadow_cb = CreateUploadBuffer( - device, size_t(RendererState::kShadowCbSlice) * RendererState::kShadowCbRegions); - g_r.shadow_cb_cpu = - g_r.shadow_cb ? static_cast(device->Map(g_r.shadow_cb)) - : nullptr; - if (g_r.shadow_cb_cpu == nullptr) { - g_r.failed = true; - return false; - } - } - return true; -} - -// Blur intermediates + the output-sized selection-outline mask. Failures -// only disable their feature, never abort the native path. -bool EnsureBlurOutlineTargets(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - if (g_r.blur_tex[0] == nullptr && g_r.pso_blur != nullptr) { - // Popup background blur intermediates at the game's fixed 1152x640 - // internal resolution (the bilinear stretch back to the output is what - // produces the authentic frosted-glass lattice). - nrhi::TextureDesc desc; - desc.width = RendererState::kBlurWidth; - desc.height = RendererState::kBlurHeight; - desc.format = context.guest_output->format(); - desc.initial_state = nrhi::ResourceState::kRenderTarget; - bool ok = true; - for (int t = 0; t < 2 && ok; ++t) { - // blur_tex[0] is also the photo-grab readback source (copied into the - // grab readback buffers), so it alone needs copy-source usage. - desc.usage = nrhi::kTextureUsageRenderTarget | - (t == 0 ? nrhi::kTextureUsageCopySource - : nrhi::kTextureUsageNone); - g_r.blur_tex[t] = device->CreateTexture(desc); - if (g_r.blur_tex[t] == nullptr) { - ok = false; - break; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.blur_srv[t] = device->CreateTextureView(g_r.blur_tex[t], vd); - if (g_r.blur_srv[t] == nullptr) { - ok = false; - break; - } - } - if (!ok) { - REXLOG_ERROR("native-scene: blur intermediate creation failed; blur disabled"); - for (int t = 0; t < 2; ++t) { - if (g_r.blur_srv[t]) device->DestroyDeferred(g_r.blur_srv[t]); - g_r.blur_srv[t] = nullptr; - if (g_r.blur_tex[t]) device->DestroyDeferred(g_r.blur_tex[t]); - g_r.blur_tex[t] = nullptr; - } - g_r.pso_blur = nullptr; // leaked PSO acceptable on this cold path - } - } - - // Settings-menu gaussian intermediates: half the output resolution - // (sigma halves with it; the final bilinear stretch back is invisible - // under a real blur), recreated whenever the output size changes. - const uint32_t menu_w = std::max(1u, context.guest_output_width / 2); - const uint32_t menu_h = std::max(1u, context.guest_output_height / 2); - if ((g_r.menu_blur_tex[0] == nullptr || g_r.menu_blur_w != menu_w || - g_r.menu_blur_h != menu_h) && - g_r.pso_menu_gauss != nullptr) { - for (int t = 0; t < 2; ++t) { - if (g_r.menu_blur_srv[t]) device->DestroyDeferred(g_r.menu_blur_srv[t]); - g_r.menu_blur_srv[t] = nullptr; - if (g_r.menu_blur_tex[t]) device->DestroyDeferred(g_r.menu_blur_tex[t]); - g_r.menu_blur_tex[t] = nullptr; - } - nrhi::TextureDesc desc; - desc.width = menu_w; - desc.height = menu_h; - desc.format = context.guest_output->format(); - desc.initial_state = nrhi::ResourceState::kRenderTarget; - desc.usage = nrhi::kTextureUsageRenderTarget; - bool ok = true; - for (int t = 0; t < 2 && ok; ++t) { - g_r.menu_blur_tex[t] = device->CreateTexture(desc); - if (g_r.menu_blur_tex[t] == nullptr) { - ok = false; - break; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.menu_blur_srv[t] = device->CreateTextureView(g_r.menu_blur_tex[t], vd); - if (g_r.menu_blur_srv[t] == nullptr) { - ok = false; - break; - } - } - if (ok) { - g_r.menu_blur_w = menu_w; - g_r.menu_blur_h = menu_h; - } else { - REXLOG_ERROR( - "native-scene: menu blur intermediate creation failed; menu blur " - "disabled"); - for (int t = 0; t < 2; ++t) { - if (g_r.menu_blur_srv[t]) device->DestroyDeferred(g_r.menu_blur_srv[t]); - g_r.menu_blur_srv[t] = nullptr; - if (g_r.menu_blur_tex[t]) device->DestroyDeferred(g_r.menu_blur_tex[t]); - g_r.menu_blur_tex[t] = nullptr; - } - g_r.pso_menu_gauss = nullptr; // leaked PSO acceptable on this cold path - } - } - - if ((g_r.outline_mask == nullptr || - g_r.outline_mask_width != context.guest_output_width || - g_r.outline_mask_height != context.guest_output_height) && - g_r.pso_outline_mask != nullptr && g_r.pso_outline_edge != nullptr) { - // Selection-outline mask: single-sample R8 target at output resolution - // (a 1152x640 mask left the contour centerline visibly stairstepped; - // the mask's own rasterization aliasing survives any amount of - // downstream filtering). - if (g_r.outline_mask) { - device->DestroyDeferred(g_r.outline_mask); - g_r.outline_mask = nullptr; - } - nrhi::TextureDesc desc; - desc.width = context.guest_output_width; - desc.height = context.guest_output_height; - desc.format = nrhi::Format::kR8_UNORM; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - g_r.outline_mask = device->CreateTexture(desc); - if (g_r.outline_mask == nullptr) { - REXLOG_ERROR("native-scene: outline mask creation failed; outline disabled"); - g_r.pso_outline_edge = nullptr; - } else { - g_r.outline_mask_width = context.guest_output_width; - g_r.outline_mask_height = context.guest_output_height; - // Re-point the mask view at the recreated texture. - if (g_r.outline_mask_srv) { - device->DestroyDeferred(g_r.outline_mask_srv); - g_r.outline_mask_srv = nullptr; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.outline_mask_srv = device->CreateTextureView(g_r.outline_mask, vd); - g_r.outline_mask_srv_allocated = g_r.outline_mask_srv != nullptr; - } - } - return true; -} - -// 1x1 white diffuse fallback + 1x1x6 mid-gray environment-cube fallback. -bool EnsureFallbackTextures(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - if (!g_r.white.valid) { - // 1x1 white fallback for items without a resolved diffuse texture. - nrhi::TextureDesc desc; - desc.width = 1; - desc.height = 1; - desc.format = nrhi::Format::kR8G8B8A8_UNORM; - desc.initial_state = nrhi::ResourceState::kCopyDest; - g_r.white.texture = device->CreateTexture(desc); - if (g_r.white.texture == nullptr) { - g_r.failed = true; - return false; - } - g_r.white.upload = CreateUploadBuffer(device, 256); - if (!g_r.white.upload) { - g_r.failed = true; - return false; - } - uint8_t* mapping = static_cast(device->Map(g_r.white.upload)); - std::memset(mapping, 0xFF, 4); - device->Unmap(g_r.white.upload); - context.cmd->CopyBufferToTexture(g_r.white.texture, 0, 0, g_r.white.upload, - 0, 256, 1, 1, 1); - context.cmd->Barrier(g_r.white.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.white.srv = device->CreateTextureView(g_r.white.texture, vd); - if (g_r.white.srv == nullptr) { - g_r.failed = true; - return false; - } - g_r.white.valid = true; - } - if (!g_r.white_cube.valid) { - // 1x1x6 mid-gray fallback cube for the water reflection slot (t6): a - // TextureCube SRV must always be bound where the shader declares one. - nrhi::TextureDesc desc; - desc.kind = nrhi::TextureKind::kCube; - desc.width = 1; - desc.height = 1; - desc.format = nrhi::Format::kR8G8B8A8_UNORM; - desc.initial_state = nrhi::ResourceState::kCopyDest; - g_r.white_cube.texture = device->CreateTexture(desc); - if (g_r.white_cube.texture == nullptr) { - g_r.failed = true; - return false; - } - g_r.white_cube.upload = CreateUploadBuffer(device, 512 * 6); - if (!g_r.white_cube.upload) { - g_r.failed = true; - return false; - } - uint8_t* mapping = static_cast(device->Map(g_r.white_cube.upload)); - for (uint32_t f = 0; f < 6; ++f) { - std::memset(mapping + f * 512, 0x80, 4); - } - device->Unmap(g_r.white_cube.upload); - for (uint32_t f = 0; f < 6; ++f) { - context.cmd->CopyBufferToTexture(g_r.white_cube.texture, 0, f, - g_r.white_cube.upload, f * 512, 256, 1, - 1, 1); - } - context.cmd->Barrier(g_r.white_cube.texture, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - nrhi::TextureViewDesc vd; - vd.dimension = nrhi::ViewDimension::kCube; - vd.mip_levels = 1; - g_r.white_cube.srv = device->CreateTextureView(g_r.white_cube.texture, vd); - if (g_r.white_cube.srv == nullptr) { - g_r.failed = true; - return false; - } - g_r.white_cube.valid = true; - } - return true; -} - -// Depth buffer + MSAA color target, rebuilt on output-size change. -bool EnsureOutputSizedTargets(const NativeGuestOutputRenderContext& context) { - nrhi::Device* device = context.device; - const uint32_t width = context.guest_output_width; - const uint32_t height = context.guest_output_height; - const nrhi::Format want_scene_fmt = - g_r.hdr_active ? g_r.hdr_scene_format : nrhi::Format::kUnknown; - if (!g_r.depth || g_r.depth_width != width || g_r.depth_height != height || - g_r.targets_hdr != g_r.hdr_active || - g_r.targets_scene_fmt != want_scene_fmt) { - if (g_r.depth) { - g_r.device->DestroyDeferred(g_r.depth); - g_r.depth = nullptr; - } - // kD32_FLOAT here is the backend's R32_TYPELESS + D32 DSV + R32_FLOAT - // SRV arrangement, not a fully-typed depth format: the photo-editor - // postfx depth pack samples this buffer through an R32_FLOAT SRV, and - // casting an SRV over a fully-typed depth resource is undefined in - // D3D12; in practice those reads returned the 1.0 clear - // value for the whole frame, which saturated the ported DoF CoC - // everywhere (a uniform smear + silhouette halos + scaffold ghosting; - // sim-verified as the cleared-depth failure mode). - nrhi::TextureDesc desc; - desc.width = width; - desc.height = height; - desc.format = nrhi::Format::kD32_FLOAT; - desc.sample_count = g_r.msaa; - desc.usage = nrhi::kTextureUsageDepthStencil; - desc.initial_state = nrhi::ResourceState::kDepthWrite; - desc.clear_depth = 1.0f; - g_r.depth = g_r.device->CreateTexture(desc); - if (g_r.depth == nullptr) { - g_r.failed = true; - return false; - } - g_r.depth_width = width; - g_r.depth_height = height; - - if (g_r.msaa > 1) { - // MSAA color target + its Texture2DMS view for the fullscreen resolve - // pass. Lives in RENDER_TARGET state between frames. Float under HDR - // (the scene writes pre-tonemap linear). - if (g_r.msaa_color) { - g_r.device->DestroyDeferred(g_r.msaa_color); - g_r.msaa_color = nullptr; - } - nrhi::TextureDesc cdesc = desc; - cdesc.format = g_r.hdr_active ? g_r.hdr_scene_format - : context.guest_output->format(); - cdesc.usage = nrhi::kTextureUsageRenderTarget; - cdesc.initial_state = nrhi::ResourceState::kRenderTarget; - cdesc.clear_color[0] = 0.25f; - cdesc.clear_color[1] = 0.35f; - cdesc.clear_color[2] = 0.55f; - cdesc.clear_color[3] = 1.0f; - g_r.msaa_color = g_r.device->CreateTexture(cdesc); - if (g_r.msaa_color == nullptr) { - if (g_r.hdr_active) { - // Float MSAA target unavailable: drop to the classic path instead - // of killing the native renderer (rebuilt next frame). - REXLOG_WARN( - "native-scene: HDR MSAA color target creation failed, " - "falling back to the classic tonemap path"); - g_r.hdr_failed = true; - g_r.depth_width = 0; - return false; - } - g_r.failed = true; - return false; - } - // Re-point the resolve pass's view at the recreated texture. - if (g_r.msaa_srv_slot) { - g_r.device->DestroyDeferred(g_r.msaa_srv_slot); - g_r.msaa_srv_slot = nullptr; - } - nrhi::TextureViewDesc vd; - vd.dimension = nrhi::ViewDimension::k2DMS; - g_r.msaa_srv_slot = device->CreateTextureView(g_r.msaa_color, vd); - g_r.msaa_srv_allocated = g_r.msaa_srv_slot != nullptr; - } - - // 1x float scene plane for the HDR post chain: the MSAA resolve - // destination, or the scene target itself when MSAA is off. Idles in - // RENDER_TARGET state; the bloom/tonemap passes sample it through - // hdr_srv. - if (g_r.hdr_srv != nullptr) { - g_r.device->DestroyDeferred(g_r.hdr_srv); - g_r.hdr_srv = nullptr; - } - if (g_r.hdr_resolved != nullptr) { - g_r.device->DestroyDeferred(g_r.hdr_resolved); - g_r.hdr_resolved = nullptr; - } - if (g_r.hdr_active) { - nrhi::TextureDesc hdesc; - hdesc.width = width; - hdesc.height = height; - hdesc.mip_levels = 1; - hdesc.format = g_r.hdr_scene_format; - hdesc.usage = nrhi::kTextureUsageRenderTarget; - hdesc.initial_state = nrhi::ResourceState::kRenderTarget; - g_r.hdr_resolved = g_r.device->CreateTexture(hdesc); - if (g_r.hdr_resolved != nullptr) { - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.hdr_srv = g_r.device->CreateTextureView(g_r.hdr_resolved, vd); - } - if (g_r.hdr_resolved == nullptr || g_r.hdr_srv == nullptr) { - REXLOG_WARN( - "native-scene: HDR scene plane creation failed, falling back " - "to the classic tonemap path"); - g_r.hdr_failed = true; - g_r.depth_width = 0; - return false; - } - } - g_r.targets_hdr = g_r.hdr_active; - g_r.targets_scene_fmt = want_scene_fmt; - } - return true; -} - -// ---- Screen-space ambient occlusion (ssao.hlsl: GTAO) -------------------- -// Layout + PSOs, built lazily on the first enabled frame and rebuilt when -// the MSAA level (linearize shader variant) or the output format (composite -// target) changes. Failure disables the effect only. -bool EnsureSsaoPipeline(const NativeGuestOutputRenderContext& context) { - if (g_r.ao_failed) { - return false; - } - // Composite target: the float HDR plane (pre-tonemap multiply) or the - // gamma guest output (classic). - const nrhi::Format ao_out_fmt = g_r.hdr_active - ? g_r.hdr_scene_format - : context.guest_output->format(); - if (g_r.pso_ao_linearize != nullptr && g_r.ao_msaa == g_r.msaa && - g_r.ao_rtv_format == ao_out_fmt && g_r.ao_hdr == g_r.hdr_active) { - return true; - } - nrhi::Device* device = context.device; - const auto fail = [&](const char* what) { - REXLOG_ERROR("native-scene: ssao pipeline setup failed ({})", what); - g_r.ao_failed = true; - return false; - }; - if (g_r.ao_layout == nullptr) { - nrhi::BindingLayoutDesc ld; - ld.param_count = 3; - ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 16, - nrhi::Visibility::kAll}; - ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, - nrhi::Visibility::kPixel}; - ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, - nrhi::Visibility::kPixel}; - ld.static_sampler_count = 2; - ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, - nrhi::AddressMode::kClamp, 1}; - ld.static_samplers[1] = {1, nrhi::Filter::kLinear, - nrhi::AddressMode::kClamp, 1}; - ld.allow_input_layout = false; - g_r.ao_layout = device->CreateBindingLayout(ld); - if (g_r.ao_layout == nullptr) { - return fail("binding layout"); - } - } - nrhi::Pipeline** psos[6] = {&g_r.pso_ao_linearize, &g_r.pso_ao_gtao, - &g_r.pso_ao_blur, &g_r.pso_ao_luma, - &g_r.pso_ao_composite, &g_r.pso_ao_debug}; - for (nrhi::Pipeline** p : psos) { - if (*p != nullptr) { - device->DestroyDeferred(*p); - *p = nullptr; - } - } - const bool msaa = g_r.msaa > 1; - const nrhi::ShaderMacro msaa_defs[] = {{"AO_MSAA", "1"}, {nullptr, nullptr}}; - // HDR=1 on the luma pass (tonemap the pre-tonemap scene before the - // protection luma) and the march (linear-space intensity exponent). - const nrhi::ShaderMacro hdr_defs[] = {{"HDR", "1"}, {nullptr, nullptr}}; - nrhi::Shader* vs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "ssao.hlsl", - kSsaoShaderSource, "vs_main", nullptr, "")); - const char* ps_entries[6] = {"ps_linearize", "ps_gtao", "ps_blur", - "ps_luma", "ps_composite", "ps_debug"}; - nrhi::Shader* ps[6] = {}; - bool ok = vs != nullptr; - for (int i = 0; i < 6; ++i) { - const bool msaa_entry = i == 0 && msaa; - const bool hdr_entry = (i == 1 || i == 3) && g_r.hdr_active; - ps[i] = device->CreateShader(MakeShaderDesc( - nrhi::ShaderStage::kPixel, "ssao.hlsl", kSsaoShaderSource, - ps_entries[i], - msaa_entry ? msaa_defs : (hdr_entry ? hdr_defs : nullptr), - msaa_entry ? "AO_MSAA=1" : (hdr_entry ? "HDR=1" : ""))); - ok = ok && ps[i] != nullptr; - } - if (!ok) { - device->DestroyDeferred(vs); - for (nrhi::Shader* s : ps) { - device->DestroyDeferred(s); - } - return fail("shader compile"); - } - nrhi::GraphicsPipelineDesc pso; - pso.layout = g_r.ao_layout; - pso.vs = vs; - pso.cull = nrhi::CullMode::kNone; - pso.sample_count = 1; - pso.ps = ps[0]; - pso.rtv_format = nrhi::Format::kR32_FLOAT; - g_r.pso_ao_linearize = device->CreateGraphicsPipeline(pso); - pso.ps = ps[1]; - pso.rtv_format = nrhi::Format::kR8_UNORM; - g_r.pso_ao_gtao = device->CreateGraphicsPipeline(pso); - pso.ps = ps[2]; - g_r.pso_ao_blur = device->CreateGraphicsPipeline(pso); - pso.ps = ps[3]; - g_r.pso_ao_luma = device->CreateGraphicsPipeline(pso); - // Composite: dst.rgb * src.a; the AO plane carries the finished - // multiplier, and the RGB-only write mask keeps the output's alpha - // untouched. - pso.ps = ps[4]; - pso.rtv_format = ao_out_fmt; - pso.blend.enable = true; - pso.blend.src = nrhi::BlendFactor::kZero; - pso.blend.dst = nrhi::BlendFactor::kSrcAlpha; - pso.blend.op = nrhi::BlendOp::kAdd; - pso.blend.src_alpha = nrhi::BlendFactor::kZero; - pso.blend.dst_alpha = nrhi::BlendFactor::kOne; - pso.blend.op_alpha = nrhi::BlendOp::kAdd; - pso.blend.write_mask = 0x7; - g_r.pso_ao_composite = device->CreateGraphicsPipeline(pso); - pso.blend = {}; - pso.ps = ps[5]; - g_r.pso_ao_debug = device->CreateGraphicsPipeline(pso); - device->DestroyDeferred(vs); - for (nrhi::Shader* s : ps) { - device->DestroyDeferred(s); - } - if (g_r.pso_ao_linearize == nullptr || g_r.pso_ao_gtao == nullptr || - g_r.pso_ao_blur == nullptr || g_r.pso_ao_luma == nullptr || - g_r.pso_ao_composite == nullptr || g_r.pso_ao_debug == nullptr) { - return fail("pso"); - } - g_r.ao_msaa = g_r.msaa; - g_r.ao_rtv_format = ao_out_fmt; - g_r.ao_hdr = g_r.hdr_active; - REXLOG_INFO("native-scene: ssao pipeline created (MSAA x{}, {})", g_r.msaa, - g_r.hdr_active ? "HDR" : "classic"); - return true; -} - -// GTAO chain over the resolved scene (see ssao.hlsl): linearize depth, -// horizon-based AO, depth-aware separable blur, multiply-composite onto the -// guest output (which must be in RENDER_TARGET state). Returns true when it -// drew; binding layout/state was changed and the caller restores the main -// pass's bindings. -bool ApplySsaoPass(const NativeGuestOutputRenderContext& context, - nrhi::Cmd* cmd, const FrameScene& scene, - const nrhi::Viewport& viewport, const nrhi::Rect& scissor) { - // The published projection must be the live perspective matrix (row-vector - // m23 == 1; all zeros until the first publish). - const float* pr = scene.proj; - if (pr[11] != 1.0f || pr[0] == 0.0f || pr[5] == 0.0f || pr[14] == 0.0f) { - return false; - } - if (!EnsureSsaoPipeline(context)) { - return false; - } - // The luma pass samples the scene through this view; the composite writes - // the same plane (HDR: the float scene plane pre-tonemap, classic: the - // guest output). - const bool hdr = g_r.hdr_active && g_r.hdr_resolved != nullptr; - nrhi::Texture* const scene_plane = - hdr ? g_r.hdr_resolved : context.guest_output; - nrhi::TextureView* const scene_srv = - hdr ? g_r.hdr_srv : g_r.output_srv_slot; - if (scene_srv == nullptr) { - return false; - } - nrhi::Device* device = context.device; - const uint32_t width = context.guest_output_width; - const uint32_t height = context.guest_output_height; - // AO raster: half the output resolution by default: for a low-frequency - // term the depth-aware blur + bilinear upsample hide it, at 1/4 the march - // cost (full-res GTAO at 4K x 300+ uncapped fps pegged the GPU). The - // linearized depth stays full res: the march samples it at arbitrary UVs - // either way, and it keeps silhouette depth exact. - const bool ao_full = - REXCVAR_GET(skate3_native_render_scene_ssao_full_res); - const uint32_t aw = ao_full ? width : std::max(1u, (width + 1) / 2); - const uint32_t ah = ao_full ? height : std::max(1u, (height + 1) / 2); - // Intermediates (steady state RENDER_TARGET), rebuilt on either size. - if (g_r.ao_width != aw || g_r.ao_height != ah || - g_r.ao_lin_width != width || g_r.ao_lin_height != height || - g_r.ao_lin_depth == nullptr || g_r.ao_luma == nullptr) { - nrhi::Texture** res[4] = {&g_r.ao_lin_depth, &g_r.ao_tex[0], - &g_r.ao_tex[1], &g_r.ao_luma}; - nrhi::TextureView** views[4] = {&g_r.ao_lin_srv, &g_r.ao_srv[0], - &g_r.ao_srv[1], &g_r.ao_luma_srv}; - for (int i = 0; i < 4; ++i) { - if (*views[i] != nullptr) { - device->DestroyDeferred(*views[i]); - *views[i] = nullptr; - } - if (*res[i] != nullptr) { - device->DestroyDeferred(*res[i]); - *res[i] = nullptr; - } - nrhi::TextureDesc desc; - desc.width = i == 0 ? width : aw; - desc.height = i == 0 ? height : ah; - desc.mip_levels = 1; - desc.format = - i == 0 ? nrhi::Format::kR32_FLOAT : nrhi::Format::kR8_UNORM; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - *res[i] = device->CreateTexture(desc); - if (*res[i] == nullptr) { - g_r.ao_failed = true; - return false; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - *views[i] = device->CreateTextureView(*res[i], vd); - if (*views[i] == nullptr) { - g_r.ao_failed = true; - return false; - } - } - g_r.ao_width = aw; - g_r.ao_height = ah; - g_r.ao_lin_width = width; - g_r.ao_lin_height = height; - } - // Scene-depth SRV, re-pointed when the depth buffer is rebuilt (resize / - // MSAA change). D32 textures view as R32_FLOAT automatically. - if (g_r.ao_depth_srv == nullptr || g_r.ao_depth_srv_of != g_r.depth) { - if (g_r.ao_depth_srv != nullptr) { - device->DestroyDeferred(g_r.ao_depth_srv); - g_r.ao_depth_srv = nullptr; - } - nrhi::TextureViewDesc sd; - if (g_r.msaa > 1) { - sd.dimension = nrhi::ViewDimension::k2DMS; - } else { - sd.dimension = nrhi::ViewDimension::k2D; - sd.mip_levels = 1; - } - g_r.ao_depth_srv = device->CreateTextureView(g_r.depth, sd); - if (g_r.ao_depth_srv == nullptr) { - g_r.ao_failed = true; - return false; - } - g_r.ao_depth_srv_of = g_r.depth; - } - - // b0 rows (see ssao.hlsl cbuffer C): size, projection, tuning, blur axis. - // size = the AO raster the march/blur passes run at; every shader use is - // UV-relative, so half res needs no shader-side changes. - float c[16]; - c[0] = float(aw); - c[1] = float(ah); - c[2] = 1.0f / float(aw); - c[3] = 1.0f / float(ah); - c[4] = std::fabs(pr[0]); // |m00|: the guest projection's x scale is - // negative; AO is mirror-invariant - c[5] = std::fabs(pr[5]); // |m11| - c[6] = pr[10]; // m22 - c[7] = pr[14]; // m32 - c[8] = float(REXCVAR_GET(skate3_native_render_scene_ssao_radius)); - c[9] = float(REXCVAR_GET(skate3_native_render_scene_ssao_intensity)); - // Distance fade 150..250 view units: fog owns the far field and the - // horizon-search precision degrades with depth anyway. - c[10] = 150.0f; - c[11] = 1.0f / 100.0f; - c[12] = 1.0f; // blur axis (horizontal first) - c[13] = 0.0f; - c[14] = float(REXCVAR_GET(skate3_native_render_scene_ssao_luma_protect)); - c[15] = 0.0f; - - const nrhi::Viewport ao_vp{0.0f, 0.0f, float(aw), float(ah), 0.0f, 1.0f}; - const nrhi::Rect ao_sc{0, 0, int32_t(aw), int32_t(ah)}; - - cmd->SetBindingLayout(g_r.ao_layout); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetRootConstants(0, 16, c, 0); - - // 1) Scene depth -> linear view Z (sample 0 when MSAA). Full res. - cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetRenderTargets(g_r.ao_lin_depth, nullptr); - cmd->SetPipeline(g_r.pso_ao_linearize); - cmd->SetTexture(1, g_r.ao_depth_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kDepthWrite); - cmd->Barrier(g_r.ao_lin_depth, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - - // 1b) Scene luminance -> ao_luma (AO raster): the sun-lit protection - // mask. The scene plane pauses as a sampled source for one draw. - cmd->Barrier(scene_plane, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetViewport(ao_vp); - cmd->SetScissor(ao_sc); - cmd->SetRenderTargets(g_r.ao_luma, nullptr); - cmd->SetPipeline(g_r.pso_ao_luma); - cmd->SetTexture(1, scene_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.ao_luma, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->Barrier(scene_plane, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - - // 2) GTAO -> ao_tex[0], at the AO raster size. - cmd->SetRenderTargets(g_r.ao_tex[0], nullptr); - cmd->SetPipeline(g_r.pso_ao_gtao); - cmd->SetTexture(1, g_r.ao_lin_srv); - cmd->SetTexture(2, g_r.ao_luma_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - - // 3) Depth-aware blur, horizontal -> ao_tex[1]. - cmd->SetRenderTargets(g_r.ao_tex[1], nullptr); - cmd->SetPipeline(g_r.pso_ao_blur); - cmd->SetTexture(1, g_r.ao_srv[0]); - cmd->SetTexture(2, g_r.ao_lin_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.ao_tex[1], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - - // 4) Vertical -> ao_tex[0]. - const float dir_v[2] = {0.0f, 1.0f}; - cmd->SetRootConstants(0, 2, dir_v, 12); - cmd->SetRenderTargets(g_r.ao_tex[0], nullptr); - cmd->SetTexture(1, g_r.ao_srv[1]); - cmd->SetTexture(2, g_r.ao_lin_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - - // 5) Consume the finished multiplier plane. Classic: a full-res composite - // (or debug replace) draw onto the scene plane, s1 bilinearly - // upsampling. HDR: no draw at all: ps_tonemap samples the plane at t2 - // and multiplies pre-tonemap (identical math, one less full-res pass); - // the plane stays in PIXEL_SHADER_RESOURCE for ApplyHdrPost, which - // restores it. - if (hdr) { - g_r.ao_plane_in_psr = true; - } else { - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetRenderTargets(scene_plane, nullptr); - cmd->SetPipeline(REXCVAR_GET(skate3_native_render_scene_ssao_debug) - ? g_r.pso_ao_debug - : g_r.pso_ao_composite); - cmd->SetTexture(1, g_r.ao_srv[0]); - cmd->Draw(3, 0); - cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - } - - // Restore intermediate steady states. - cmd->Barrier(g_r.ao_tex[1], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->Barrier(g_r.ao_lin_depth, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->Barrier(g_r.ao_luma, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - return true; -} - -// ---- Screen-space reflections (ssr.hlsl + scene.hlsl ps_refl_gbuf) -------- -// Layout + PSOs, built lazily on the first SSR frame and rebuilt when the -// MSAA level (linearize variant) or the scene float format (composite -// target) changes. Failure disables the effect only, never g_r.failed. -bool EnsureSsrPipeline(const NativeGuestOutputRenderContext& context) { - if (g_r.ssr_failed || !g_r.hdr_active) { - return false; - } - if (g_r.pso_ssr_march != nullptr && g_r.ssr_msaa == g_r.msaa && - g_r.ssr_scene_fmt == g_r.hdr_scene_format) { - return true; - } - nrhi::Device* device = context.device; - const auto fail = [&](const char* what) { - REXLOG_ERROR("native-scene: ssr pipeline setup failed ({})", what); - g_r.ssr_failed = true; - return false; - }; - if (g_r.ssr_layout == nullptr) { - // The SSAO layout shape with a wider constant block (the march needs - // the world->view rotation rows): root constants b0 (32 floats) + three - // single-texture tables + point/linear clamp samplers. - nrhi::BindingLayoutDesc ld; - ld.param_count = 4; - ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 32, - nrhi::Visibility::kAll}; - ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, - nrhi::Visibility::kPixel}; - ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, - nrhi::Visibility::kPixel}; - ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1, - nrhi::Visibility::kPixel}; - ld.static_sampler_count = 2; - ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, - nrhi::AddressMode::kClamp, 1}; - ld.static_samplers[1] = {1, nrhi::Filter::kLinear, - nrhi::AddressMode::kClamp, 1}; - ld.allow_input_layout = false; - g_r.ssr_layout = device->CreateBindingLayout(ld); - if (g_r.ssr_layout == nullptr) { - return fail("binding layout"); - } - } - for (nrhi::Pipeline** p : {&g_r.pso_ssr_gbuf, &g_r.pso_ssr_linearize, - &g_r.pso_ssr_march, &g_r.pso_ssr_composite}) { - if (*p != nullptr) { - device->DestroyDeferred(*p); - *p = nullptr; - } - } - const bool msaa = g_r.msaa > 1; - const nrhi::ShaderMacro msaa_defs[] = {{"SSR_MSAA", "1"}, - {nullptr, nullptr}}; - nrhi::Shader* scene_vs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "scene.hlsl", kShaderSource, - "vs_main", nullptr, "")); - nrhi::Shader* ps_gbuf = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, - "ps_refl_gbuf", nullptr, "")); - nrhi::Shader* vs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "ssr.hlsl", kSsrShaderSource, - "vs_main", nullptr, "")); - nrhi::Shader* ps_lin = device->CreateShader(MakeShaderDesc( - nrhi::ShaderStage::kPixel, "ssr.hlsl", kSsrShaderSource, "ps_linearize", - msaa ? msaa_defs : nullptr, msaa ? "SSR_MSAA=1" : "")); - nrhi::Shader* ps_march = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "ssr.hlsl", kSsrShaderSource, - "ps_march", nullptr, "")); - nrhi::Shader* ps_comp = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "ssr.hlsl", kSsrShaderSource, - "ps_composite", nullptr, "")); - const bool shaders_ok = scene_vs != nullptr && ps_gbuf != nullptr && - vs != nullptr && ps_lin != nullptr && - ps_march != nullptr && ps_comp != nullptr; - if (!shaders_ok) { - for (nrhi::Shader* s : - {scene_vs, ps_gbuf, vs, ps_lin, ps_march, ps_comp}) { - device->DestroyDeferred(s); - } - return fail("shader compile"); - } - // Reflection G-buffer pass: the scene VS + input layout under the MAIN - // binding layout (the caller re-stages each reflective item's main-pass - // root constants / textures), half-res single-sample float target with - // its OWN half-res depth (test+write: the MSAA scene depth cannot pair - // with a 1x RTV) so the nearest reflective surface owns each texel; - // occlusion by non-reflective geometry is then resolved in the march by - // comparing the stored G-buffer depth against the scene linear depth. - { - nrhi::GraphicsPipelineDesc pso; - pso.layout = g_r.layout; - pso.vs = scene_vs; - pso.ps = ps_gbuf; - pso.input_elements = kSceneInputLayout; - pso.input_element_count = 7; - pso.vertex_stride = 56; - pso.cull = nrhi::CullMode::kNone; - pso.sample_count = 1; - pso.depth_clip = true; - pso.depth.test_enable = true; - pso.depth.write_enable = true; - pso.depth.func = nrhi::CompareFunc::kLess; - pso.dsv_format = nrhi::Format::kD32_FLOAT; - pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT; - g_r.pso_ssr_gbuf = device->CreateGraphicsPipeline(pso); - } - nrhi::GraphicsPipelineDesc pso; - pso.layout = g_r.ssr_layout; - pso.vs = vs; - pso.cull = nrhi::CullMode::kNone; - pso.sample_count = 1; - pso.ps = ps_lin; - pso.rtv_format = nrhi::Format::kR32_FLOAT; - g_r.pso_ssr_linearize = device->CreateGraphicsPipeline(pso); - pso.ps = ps_march; - pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT; - g_r.pso_ssr_march = device->CreateGraphicsPipeline(pso); - // Composite: straight src-alpha blend onto the float scene plane (the - // shader outputs confidence x reflectivity x intensity in alpha), RGB - // write mask: the plane's alpha stays untouched. - pso.ps = ps_comp; - pso.rtv_format = g_r.hdr_scene_format; - pso.blend.enable = true; - pso.blend.src = nrhi::BlendFactor::kSrcAlpha; - pso.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; - pso.blend.op = nrhi::BlendOp::kAdd; - pso.blend.src_alpha = nrhi::BlendFactor::kZero; - pso.blend.dst_alpha = nrhi::BlendFactor::kOne; - pso.blend.op_alpha = nrhi::BlendOp::kAdd; - pso.blend.write_mask = 0x7; - g_r.pso_ssr_composite = device->CreateGraphicsPipeline(pso); - for (nrhi::Shader* s : {scene_vs, ps_gbuf, vs, ps_lin, ps_march, ps_comp}) { - device->DestroyDeferred(s); - } - if (g_r.pso_ssr_gbuf == nullptr || g_r.pso_ssr_linearize == nullptr || - g_r.pso_ssr_march == nullptr || g_r.pso_ssr_composite == nullptr) { - return fail("pso"); - } - g_r.ssr_msaa = g_r.msaa; - g_r.ssr_scene_fmt = g_r.hdr_scene_format; - REXLOG_INFO("native-scene: ssr pipeline created (MSAA x{})", g_r.msaa); - return true; -} - -// Half-res SSR intermediates (reflection G-buffer + march output), steady -// state RENDER_TARGET, rebuilt on resize. -bool EnsureSsrTargets(const NativeGuestOutputRenderContext& context) { - const uint32_t sw = std::max(1u, (context.guest_output_width + 1) / 2); - const uint32_t sh = std::max(1u, (context.guest_output_height + 1) / 2); - if (g_r.ssr_width == sw && g_r.ssr_height == sh && g_r.ssr_gbuf != nullptr && - g_r.ssr_tex != nullptr) { - return true; - } - nrhi::Device* device = context.device; - if (g_r.ssr_gbuf_depth != nullptr) { - device->DestroyDeferred(g_r.ssr_gbuf_depth); - g_r.ssr_gbuf_depth = nullptr; - } - { - nrhi::TextureDesc dd; - dd.width = sw; - dd.height = sh; - dd.format = nrhi::Format::kD32_FLOAT; - dd.usage = nrhi::kTextureUsageDepthStencil; - dd.initial_state = nrhi::ResourceState::kDepthWrite; - dd.clear_depth = 1.0f; - g_r.ssr_gbuf_depth = device->CreateTexture(dd); - if (g_r.ssr_gbuf_depth == nullptr) { - g_r.ssr_failed = true; - return false; - } - } - nrhi::Texture** res[2] = {&g_r.ssr_gbuf, &g_r.ssr_tex}; - nrhi::TextureView** views[2] = {&g_r.ssr_gbuf_srv, &g_r.ssr_srv}; - for (int i = 0; i < 2; ++i) { - if (*views[i] != nullptr) { - device->DestroyDeferred(*views[i]); - *views[i] = nullptr; - } - if (*res[i] != nullptr) { - device->DestroyDeferred(*res[i]); - *res[i] = nullptr; - } - nrhi::TextureDesc desc; - desc.width = sw; - desc.height = sh; - desc.mip_levels = 1; - desc.format = nrhi::Format::kR16G16B16A16_FLOAT; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - *res[i] = device->CreateTexture(desc); - if (*res[i] == nullptr) { - g_r.ssr_failed = true; - return false; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - *views[i] = device->CreateTextureView(*res[i], vd); - if (*views[i] == nullptr) { - g_r.ssr_failed = true; - return false; - } - } - g_r.ssr_width = sw; - g_r.ssr_height = sh; - return true; -} - -// SSR march + composite (see ssr.hlsl): consumes the reflection G-buffer -// the in-pass draw left in PIXEL_SHADER_RESOURCE, marches the full-res -// linear depth (reusing the SSAO linearize plane when AO ran this frame) -// while sampling the pre-tonemap HDR plane, and blends the result back onto -// it. Returns true when it drew; the binding layout changed and the -// caller's post_ran block restores the main-pass bindings. -bool ApplySsrPass(const NativeGuestOutputRenderContext& context, - nrhi::Cmd* cmd, const FrameScene& scene, - const nrhi::Viewport& viewport, const nrhi::Rect& scissor, - bool lin_depth_ready) { - if (!g_r.ssr_gbuf_ready) { - return false; - } - // Consume the handoff up front: every exit below must leave the G-buffer - // back in its RENDER_TARGET steady state. - g_r.ssr_gbuf_ready = false; - nrhi::Device* device = context.device; - const uint32_t width = context.guest_output_width; - const uint32_t height = context.guest_output_height; - const auto bail = [&] { - cmd->Barrier(g_r.ssr_gbuf, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - return false; - }; - // Full-res linear view-Z: the SSAO plane when AO linearized this frame - // (it idles back in RENDER_TARGET after the AO pass), else the own - // linearize below. - lin_depth_ready = lin_depth_ready && g_r.ao_lin_depth != nullptr && - g_r.ao_lin_srv != nullptr && g_r.ao_lin_width == width && - g_r.ao_lin_height == height; - if (!lin_depth_ready) { - if (g_r.ssr_lin_depth == nullptr || g_r.ssr_lin_width != width || - g_r.ssr_lin_height != height) { - if (g_r.ssr_lin_srv != nullptr) { - device->DestroyDeferred(g_r.ssr_lin_srv); - g_r.ssr_lin_srv = nullptr; - } - if (g_r.ssr_lin_depth != nullptr) { - device->DestroyDeferred(g_r.ssr_lin_depth); - g_r.ssr_lin_depth = nullptr; - } - nrhi::TextureDesc desc; - desc.width = width; - desc.height = height; - desc.mip_levels = 1; - desc.format = nrhi::Format::kR32_FLOAT; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - g_r.ssr_lin_depth = device->CreateTexture(desc); - if (g_r.ssr_lin_depth == nullptr) { - g_r.ssr_failed = true; - return bail(); - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.ssr_lin_srv = device->CreateTextureView(g_r.ssr_lin_depth, vd); - if (g_r.ssr_lin_srv == nullptr) { - g_r.ssr_failed = true; - return bail(); - } - g_r.ssr_lin_width = width; - g_r.ssr_lin_height = height; - } - // Scene-depth SRV, re-pointed when the depth buffer is rebuilt. - if (g_r.ssr_depth_srv == nullptr || g_r.ssr_depth_srv_of != g_r.depth) { - if (g_r.ssr_depth_srv != nullptr) { - device->DestroyDeferred(g_r.ssr_depth_srv); - g_r.ssr_depth_srv = nullptr; - } - nrhi::TextureViewDesc sd; - if (g_r.msaa > 1) { - sd.dimension = nrhi::ViewDimension::k2DMS; - } else { - sd.dimension = nrhi::ViewDimension::k2D; - sd.mip_levels = 1; - } - g_r.ssr_depth_srv = device->CreateTextureView(g_r.depth, sd); - if (g_r.ssr_depth_srv == nullptr) { - g_r.ssr_failed = true; - return bail(); - } - g_r.ssr_depth_srv_of = g_r.depth; - } - } - nrhi::Texture* const lin_tex = - lin_depth_ready ? g_r.ao_lin_depth : g_r.ssr_lin_depth; - nrhi::TextureView* const lin_srv = - lin_depth_ready ? g_r.ao_lin_srv : g_r.ssr_lin_srv; - - // b0 rows (see ssr.hlsl cbuffer C): dest size, projection, the world -> - // "AO view space" rotation, tuning. view = view_proj * proj^-1 (row- - // vector); the projection's per-axis signs fold into the rotation so - // normals land in the same mirrored space ViewPos reconstructs positions - // in; reflection directions are mirror-SENSITIVE, unlike every AO term. - const float* pr = scene.proj; - float c[32] = {}; - c[0] = float(g_r.ssr_width); - c[1] = float(g_r.ssr_height); - c[2] = 1.0f / float(g_r.ssr_width); - c[3] = 1.0f / float(g_r.ssr_height); - c[4] = std::fabs(pr[0]); - c[5] = std::fabs(pr[5]); - c[6] = pr[10]; - c[7] = pr[14]; - const float sxs = pr[0] < 0.0f ? -1.0f : 1.0f; - const float sys = pr[5] < 0.0f ? -1.0f : 1.0f; - const float inv00 = 1.0f / pr[0]; - const float inv11 = 1.0f / pr[5]; - for (int r = 0; r < 3; ++r) { - // proj^-1 columns: x/y scale by 1/m00 / 1/m11, view z = clip w (the - // view_proj row's w column); the AO-space axis signs multiply in. - c[8 + r * 4 + 0] = scene.view_proj[r * 4 + 0] * inv00 * sxs; - c[8 + r * 4 + 1] = scene.view_proj[r * 4 + 1] * inv11 * sys; - c[8 + r * 4 + 2] = scene.view_proj[r * 4 + 3]; - } - c[20] = float(REXCVAR_GET(skate3_native_render_scene_ssr_steps)); - c[21] = float(REXCVAR_GET(skate3_native_render_scene_ssr_thickness)); - c[22] = float(REXCVAR_GET(skate3_native_render_scene_ssr_intensity)); - c[23] = float(REXCVAR_GET(skate3_native_render_scene_ssr_debug)); - c[24] = 250.0f; // max ray distance (view units; the screen clip governs) - - cmd->SetBindingLayout(g_r.ssr_layout); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - cmd->SetRootConstants(0, 32, c, 0); - - // 1) Own linearize when AO did not produce the plane this frame. - if (!lin_depth_ready) { - cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetRenderTargets(g_r.ssr_lin_depth, nullptr); - cmd->SetPipeline(g_r.pso_ssr_linearize); - cmd->SetTexture(1, g_r.ssr_depth_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kDepthWrite); - } - cmd->Barrier(lin_tex, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - - // 2) March (half res): the HDR plane pauses as a sampled source. - cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - const nrhi::Viewport ssr_vp{0.0f, 0.0f, float(g_r.ssr_width), - float(g_r.ssr_height), 0.0f, 1.0f}; - const nrhi::Rect ssr_sc{0, 0, int32_t(g_r.ssr_width), - int32_t(g_r.ssr_height)}; - cmd->SetViewport(ssr_vp); - cmd->SetScissor(ssr_sc); - cmd->SetRenderTargets(g_r.ssr_tex, nullptr); - cmd->SetPipeline(g_r.pso_ssr_march); - cmd->SetTexture(1, g_r.ssr_gbuf_srv); - cmd->SetTexture(2, lin_srv); - cmd->SetTexture(3, g_r.hdr_srv); - cmd->Draw(3, 0); - cmd->Barrier(g_r.ssr_tex, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - - // 3) Composite (full res, src-alpha blend onto the HDR plane). Dest-size - // constants switch to the full raster; the rest carries over. - c[0] = float(width); - c[1] = float(height); - c[2] = 1.0f / float(width); - c[3] = 1.0f / float(height); - cmd->SetRootConstants(0, 4, c, 0); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetRenderTargets(g_r.hdr_resolved, nullptr); - cmd->SetPipeline(g_r.pso_ssr_composite); - cmd->SetTexture(1, g_r.ssr_srv); - cmd->SetTexture(2, g_r.ssr_gbuf_srv); - cmd->SetTexture(3, lin_srv); - cmd->Draw(3, 0); - - // Restore intermediate steady states. - cmd->Barrier(g_r.ssr_tex, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->Barrier(lin_tex, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->Barrier(g_r.ssr_gbuf, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - return true; -} - -// ---- HDR post chain (hdr.hlsl: bloom pyramid + the extracted tonemap) ---- -// Layout + PSOs, built when the HDR path activates and rebuilt when the -// guest output format (the tonemap target) changes. Failure falls back to -// the classic in-material tonemap instead of killing the native renderer. -bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) { - if (!g_r.hdr_active || g_r.hdr_failed) { - return false; - } - if (g_r.pso_tonemap != nullptr && - g_r.hdr_pso_out_format == context.guest_output->format()) { - return true; - } - nrhi::Device* device = context.device; - const auto fail = [&](const char* what) { - REXLOG_WARN( - "native-scene: HDR pipeline setup failed ({}), falling back to the " - "classic tonemap path", - what); - g_r.hdr_failed = true; - return false; - }; - if (g_r.hdr_layout == nullptr) { - // The SSAO layout shape plus a third single-texture table (t2 = the - // fused AO multiplier plane): root constants b0 + three single-texture - // tables + point/linear clamp samplers (hdr.hlsl's frozen Vulkan plan). - nrhi::BindingLayoutDesc ld; - ld.param_count = 4; - ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 16, - nrhi::Visibility::kAll}; - ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, - nrhi::Visibility::kPixel}; - ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, - nrhi::Visibility::kPixel}; - ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1, - nrhi::Visibility::kPixel}; - ld.static_sampler_count = 2; - ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, - nrhi::AddressMode::kClamp, 1}; - ld.static_samplers[1] = {1, nrhi::Filter::kLinear, - nrhi::AddressMode::kClamp, 1}; - ld.allow_input_layout = false; - g_r.hdr_layout = device->CreateBindingLayout(ld); - if (g_r.hdr_layout == nullptr) { - return fail("binding layout"); - } - } - for (nrhi::Pipeline** p : {&g_r.pso_bloom_first, &g_r.pso_bloom_down, - &g_r.pso_bloom_up, &g_r.pso_tonemap}) { - if (*p != nullptr) { - device->DestroyDeferred(*p); - *p = nullptr; - } - } - const nrhi::ShaderMacro first_defs[] = {{"BLOOM_FIRST", "1"}, - {nullptr, nullptr}}; - nrhi::Shader* vs = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kVertex, "hdr.hlsl", kHdrShaderSource, - "vs_main", nullptr, "")); - nrhi::Shader* ps_first = device->CreateShader(MakeShaderDesc( - nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, - "ps_bloom_down", first_defs, "BLOOM_FIRST=1")); - nrhi::Shader* ps_down = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, - "ps_bloom_down", nullptr, "")); - nrhi::Shader* ps_up = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, - "ps_bloom_up", nullptr, "")); - nrhi::Shader* ps_tone = device->CreateShader( - MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, - "ps_tonemap", nullptr, "")); - const bool shaders_ok = vs != nullptr && ps_first != nullptr && - ps_down != nullptr && ps_up != nullptr && - ps_tone != nullptr; - if (!shaders_ok) { - for (nrhi::Shader* s : {vs, ps_first, ps_down, ps_up, ps_tone}) { - device->DestroyDeferred(s); - } - return fail("shader compile"); - } - nrhi::GraphicsPipelineDesc pso; - pso.layout = g_r.hdr_layout; - pso.vs = vs; - pso.cull = nrhi::CullMode::kNone; - pso.sample_count = 1; - pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT; - pso.ps = ps_first; - g_r.pso_bloom_first = device->CreateGraphicsPipeline(pso); - pso.ps = ps_down; - g_r.pso_bloom_down = device->CreateGraphicsPipeline(pso); - // Progressive upsample: additive ONE/ONE onto the next-larger level. - pso.ps = ps_up; - pso.blend.enable = true; - pso.blend.src = nrhi::BlendFactor::kOne; - pso.blend.dst = nrhi::BlendFactor::kOne; - pso.blend.op = nrhi::BlendOp::kAdd; - pso.blend.src_alpha = nrhi::BlendFactor::kOne; - pso.blend.dst_alpha = nrhi::BlendFactor::kOne; - pso.blend.op_alpha = nrhi::BlendOp::kAdd; - g_r.pso_bloom_up = device->CreateGraphicsPipeline(pso); - pso.blend = {}; - pso.ps = ps_tone; - pso.rtv_format = context.guest_output->format(); - g_r.pso_tonemap = device->CreateGraphicsPipeline(pso); - for (nrhi::Shader* s : {vs, ps_first, ps_down, ps_up, ps_tone}) { - device->DestroyDeferred(s); - } - if (g_r.pso_bloom_first == nullptr || g_r.pso_bloom_down == nullptr || - g_r.pso_bloom_up == nullptr || g_r.pso_tonemap == nullptr) { - return fail("pso"); - } - g_r.hdr_pso_out_format = context.guest_output->format(); - REXLOG_INFO("native-scene: HDR post pipeline created"); - return true; -} - -// Bloom pyramid + tonemap over the float scene plane into the guest output. -// Runs after the AO composite (bloom sees the occluded scene). Transitions -// the guest output kGuestOutput -> kRenderTarget (the state every later -// consumer expects); the caller restores the main binding layout after. -void ApplyHdrPost(const NativeGuestOutputRenderContext& context, - nrhi::Cmd* cmd, const nrhi::Viewport& viewport, - const nrhi::Rect& scissor, bool loading_native) { - nrhi::Device* device = context.device; - const uint32_t width = context.guest_output_width; - const uint32_t height = context.guest_output_height; - // Bloom chain intermediates: QUARTER res halving down to the level cap or - // an 8-px floor, RGBA16F, steady state RENDER_TARGET. Quarter start = - // 1/4 the pyramid cost of a half-res chain; the extraction pass widens - // its 13-tap spread (p1.x = 2) so the 4x reduction still integrates the - // whole source block. Creation failure only loses bloom (the tonemap - // still runs). - if (g_r.bloom_base_w != width || g_r.bloom_base_h != height || - (g_r.bloom_levels > 0 && g_r.bloom_tex[0] == nullptr)) { - for (uint32_t i = 0; i < RendererState::kBloomMaxLevels; ++i) { - if (g_r.bloom_srv[i] != nullptr) { - device->DestroyDeferred(g_r.bloom_srv[i]); - g_r.bloom_srv[i] = nullptr; - } - if (g_r.bloom_tex[i] != nullptr) { - device->DestroyDeferred(g_r.bloom_tex[i]); - g_r.bloom_tex[i] = nullptr; - } - } - g_r.bloom_levels = 0; - uint32_t w = std::max(1u, (width + 1) / 2); - uint32_t h = std::max(1u, (height + 1) / 2); - for (uint32_t i = 0; i < RendererState::kBloomMaxLevels; ++i) { - w = std::max(1u, (w + 1) / 2); - h = std::max(1u, (h + 1) / 2); - if (w < 8 || h < 8) { - break; - } - nrhi::TextureDesc desc; - desc.width = w; - desc.height = h; - desc.mip_levels = 1; - desc.format = nrhi::Format::kR16G16B16A16_FLOAT; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - g_r.bloom_tex[i] = device->CreateTexture(desc); - if (g_r.bloom_tex[i] == nullptr) { - break; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.bloom_srv[i] = device->CreateTextureView(g_r.bloom_tex[i], vd); - if (g_r.bloom_srv[i] == nullptr) { - device->DestroyDeferred(g_r.bloom_tex[i]); - g_r.bloom_tex[i] = nullptr; - break; - } - g_r.bloom_w[i] = w; - g_r.bloom_h[i] = h; - ++g_r.bloom_levels; - } - g_r.bloom_base_w = width; - g_r.bloom_base_h = height; - if (g_r.bloom_levels == 0) { - REXLOG_WARN("native-scene: bloom chain allocation failed (bloom off)"); - } - } - const bool bloom = REXCVAR_GET(skate3_native_render_scene_bloom) && - !loading_native && g_r.bloom_levels > 0 && - g_r.pso_bloom_first != nullptr && - g_r.pso_bloom_down != nullptr && - g_r.pso_bloom_up != nullptr; - - cmd->SetBindingLayout(g_r.hdr_layout); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - // The scene plane is the sampled source for the whole chain. - cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - - float c[16]; - const auto set_consts = [&](uint32_t dw, uint32_t dh, uint32_t sw, - uint32_t sh, float intensity, float tap_scale) { - c[0] = float(dw); - c[1] = float(dh); - c[2] = 1.0f / float(dw); - c[3] = 1.0f / float(dh); - c[4] = float(sw); - c[5] = float(sh); - c[6] = 1.0f / float(sw); - c[7] = 1.0f / float(sh); - c[8] = float(REXCVAR_GET(skate3_native_render_scene_bloom_threshold)); - c[9] = float(REXCVAR_GET(skate3_native_render_scene_bloom_knee)); - c[10] = intensity; - // Debug view (ps_tonemap p0.w); the classic ssao_debug cvar maps onto - // the fused AO view when the plane rode along this frame. - c[11] = float(REXCVAR_GET(skate3_native_render_scene_hdr_debug)); - if (c[11] == 0.0f && g_r.ao_plane_in_psr && - REXCVAR_GET(skate3_native_render_scene_ssao_debug)) { - c[11] = 3.0f; - } - c[12] = tap_scale; - c[13] = c[14] = c[15] = 0.0f; - cmd->SetRootConstants(0, 16, c, 0); - }; - - if (bloom) { - // Downsample chain (level 0 extracts with threshold + Karis weighting; - // its 4x reduction doubles the tap spread to cover the source block). - for (uint32_t i = 0; i < g_r.bloom_levels; ++i) { - const uint32_t sw = i == 0 ? width : g_r.bloom_w[i - 1]; - const uint32_t sh = i == 0 ? height : g_r.bloom_h[i - 1]; - set_consts(g_r.bloom_w[i], g_r.bloom_h[i], sw, sh, 0.0f, - i == 0 ? 2.0f : 1.0f); - const nrhi::Viewport vp{0.0f, 0.0f, float(g_r.bloom_w[i]), - float(g_r.bloom_h[i]), 0.0f, 1.0f}; - const nrhi::Rect sc{0, 0, int32_t(g_r.bloom_w[i]), - int32_t(g_r.bloom_h[i])}; - cmd->SetViewport(vp); - cmd->SetScissor(sc); - cmd->SetRenderTargets(g_r.bloom_tex[i], nullptr); - cmd->SetPipeline(i == 0 ? g_r.pso_bloom_first : g_r.pso_bloom_down); - cmd->SetTexture(1, i == 0 ? g_r.hdr_srv : g_r.bloom_srv[i - 1]); - cmd->Draw(3, 0); - cmd->Barrier(g_r.bloom_tex[i], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - } - // Progressive tent upsample, additive onto each larger level. - for (int32_t i = int32_t(g_r.bloom_levels) - 2; i >= 0; --i) { - cmd->Barrier(g_r.bloom_tex[i], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - set_consts(g_r.bloom_w[i], g_r.bloom_h[i], g_r.bloom_w[i + 1], - g_r.bloom_h[i + 1], 0.0f, 1.0f); - const nrhi::Viewport vp{0.0f, 0.0f, float(g_r.bloom_w[i]), - float(g_r.bloom_h[i]), 0.0f, 1.0f}; - const nrhi::Rect sc{0, 0, int32_t(g_r.bloom_w[i]), - int32_t(g_r.bloom_h[i])}; - cmd->SetViewport(vp); - cmd->SetScissor(sc); - cmd->SetRenderTargets(g_r.bloom_tex[i], nullptr); - cmd->SetPipeline(g_r.pso_bloom_up); - cmd->SetTexture(1, g_r.bloom_srv[i + 1]); - cmd->Draw(3, 0); - cmd->Barrier(g_r.bloom_tex[i], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - } - } - - // Tonemap into the guest output (the single application of the game's - // shared tone chain; bloom energy joins pre-tonemap). - cmd->Barrier(context.guest_output, nrhi::ResourceState::kGuestOutput, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - set_consts(width, height, width, height, - bloom ? float(REXCVAR_GET( - skate3_native_render_scene_bloom_intensity)) - : 0.0f, - 1.0f); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetRenderTargets(context.guest_output, nullptr); - cmd->SetPipeline(g_r.pso_tonemap); - cmd->SetTexture(1, g_r.hdr_srv); - cmd->SetTexture(2, bloom ? g_r.bloom_srv[0] : g_r.white.srv); - // t2 = the fused AO multiplier plane (white = no AO this frame). - cmd->SetTexture(3, g_r.ao_plane_in_psr ? g_r.ao_srv[0] : g_r.white.srv); - cmd->Draw(3, 0); - - // Restore steady states. - cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - if (g_r.ao_plane_in_psr) { - cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - g_r.ao_plane_in_psr = false; - } - if (bloom) { - for (uint32_t i = 0; i < g_r.bloom_levels; ++i) { - cmd->Barrier(g_r.bloom_tex[i], - nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - } - } - cmd->FlushBarriers(); -} - -bool EnsurePipeline(const NativeGuestOutputRenderContext& context) { - if (g_r.failed) return false; - nrhi::Device* device = context.device; - g_r.device = device; - - if (!EnsureRootSignature(context)) { - return false; - } - - // HDR path decision, latched for the whole pipeline family (scene/spline - // PSO formats + shader variants, resolve target, AO composite target, - // output-sized targets). Hot cvar toggles rebuild everything below; - // hdr_failed pins the classic path after a float-target failure. - const bool hdr_want = - REXCVAR_GET(skate3_native_render_scene_hdr) && !g_r.hdr_failed; - const nrhi::Format hdr_fmt_want = - REXCVAR_GET(skate3_native_render_scene_hdr_packed) - ? nrhi::Format::kR11G11B10_FLOAT - : nrhi::Format::kR16G16B16A16_FLOAT; - if (!g_r.pso || g_r.rtv_format != context.guest_output->format() || - g_r.hdr_active != hdr_want || - (hdr_want && g_r.hdr_scene_format != hdr_fmt_want)) { - g_r.hdr_active = hdr_want; - g_r.hdr_scene_format = hdr_fmt_want; - if (!EnsureScenePsoFamily(context) || !EnsureResolvePso(context) || - !EnsureBlurPsos(context) || !EnsureOutlineEdgePso(context) || - !Ensure2dPso(context) || !EnsureSplinePsos(context) || - !EnsureShadowPsos(context)) { - return false; - } - REXLOG_INFO("native-scene: pipelines created (MSAA x{}, {})", g_r.msaa, - g_r.hdr_active ? "HDR" : "classic"); - g_r.rtv_format = context.guest_output->format(); - } - - if (!EnsureHeapsAndRings(context) || !EnsureShadowResources(context)) { - return false; - } - EnsureBlurOutlineTargets(context); - if (!EnsureFallbackTextures(context) || !EnsureOutputSizedTargets(context)) { - return false; - } - if (g_r.hdr_active && !EnsureHdrPipeline(context)) { - // hdr_failed is set: the next frame rebuilds the classic path. Abort - // this frame (the scene PSOs already target the float format). - return false; - } - - if (g_r.rtv_resource != context.guest_output) { - // The presenter recreated the output image (resize). Render-target - // binding is by texture now (the old heap-slot-0 RTV is gone); re-point - // the cached identity and the sampled view of the output (blur source / - // photo passes). - if (g_r.output_srv_slot) { - device->DestroyDeferred(g_r.output_srv_slot); - g_r.output_srv_slot = nullptr; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.output_srv_slot = device->CreateTextureView(context.guest_output, vd); - g_r.output_srv_allocated = g_r.output_srv_slot != nullptr; - g_r.rtv_resource = context.guest_output; - } - return true; -} - -// ---- Settings-menu backdrop blur (shared native + emulated-post paths) ---- -// Clean separable gaussian over the finished guest output; deliberately -// shares nothing with the game's -// popup chain (that one reproduces the console's 1152x640 lattice on -// purpose). Downsample to half res (plain bilinear = 2x2 box), gaussian H, -// gaussian V, bilinear stretch back. Eased in/out (~60 ms) so open/close -// doesn't pop; the smoothing state is shared across the native and -// emulated-post paths (only one runs per frame). Returns true while the -// effect is active (target > 0 or still easing out). -bool ApplyMenuBlurPass(const NativeGuestOutputRenderContext& context, nrhi::Cmd* cmd, - float target_sigma, bool output_in_guest_output_state) { - static float s_menu_sigma = 0.0f; - static int64_t s_menu_ns = 0; - const int64_t menu_now_ns = std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - float menu_dt = s_menu_ns != 0 ? float(menu_now_ns - s_menu_ns) * 1e-9f : 1.0f; - if (menu_dt > 0.25f) { - s_menu_sigma = 0.0f; - menu_dt = 0.0f; - } - s_menu_ns = menu_now_ns; - const float menu_a = 1.0f - std::exp(-menu_dt / 0.06f); - s_menu_sigma += (target_sigma - s_menu_sigma) * menu_a; - const bool active = target_sigma > 0.0f || s_menu_sigma > 0.05f; - if (s_menu_sigma <= 0.05f || g_r.pso_menu_gauss == nullptr || - g_r.pso_blur_blit == nullptr || g_r.menu_blur_tex[0] == nullptr || - g_r.output_srv_slot == nullptr) { - return active; - } - const float w = float(g_r.menu_blur_w); - const float h = float(g_r.menu_blur_h); - // Sigma is authored in 1080p pixels; halve again for the half-res space. - const float sigma_half = std::max( - 0.3f, s_menu_sigma * (float(context.guest_output_height) / 1080.0f) * 0.5f); - const float radius = std::min(48.0f, std::ceil(sigma_half * 3.0f)); - const nrhi::Viewport half_vp{0.0f, 0.0f, w, h, 0.0f, 1.0f}; - const nrhi::Rect half_sc{0, 0, int32_t(g_r.menu_blur_w), int32_t(g_r.menu_blur_h)}; - const nrhi::Viewport full_vp{0.0f, - 0.0f, - float(context.guest_output_width), - float(context.guest_output_height), - 0.0f, - 1.0f}; - const nrhi::Rect full_sc{0, 0, int32_t(context.guest_output_width), - int32_t(context.guest_output_height)}; - const nrhi::ResourceState output_entry_state = output_in_guest_output_state - ? nrhi::ResourceState::kGuestOutput - : nrhi::ResourceState::kRenderTarget; - // The binding layout must be latched on this cmd before any draw; on the - // emulated-post path this is a FRESH frame state where no layout has been - // set, and nrhi draws silently no-op without one (EnsureDrawState). - // Harmless on the native path (same layout, re-latch). - cmd->SetBindingLayout(g_r.layout); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - // Downsample: guest output -> menu[0]. - cmd->Barrier(context.guest_output, output_entry_state, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetRenderTargets(g_r.menu_blur_tex[0], nullptr); - cmd->SetViewport(half_vp); - cmd->SetScissor(half_sc); - cmd->SetPipeline(g_r.pso_blur_blit); - cmd->SetTexture(1, g_r.output_srv_slot); - cmd->Draw(3, 0); - // H: menu[0] -> menu[1]. - cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetRenderTargets(g_r.menu_blur_tex[1], nullptr); - cmd->SetPipeline(g_r.pso_menu_gauss); - const float h_consts[8] = {1.0f / w, 0.0f, sigma_half, radius, 1.0f, 1.0f, 1.0f, 1.0f}; - cmd->SetRootConstants(0, 8, h_consts, 0); - cmd->SetTexture(1, g_r.menu_blur_srv[0]); - cmd->Draw(3, 0); - // V: menu[1] -> menu[0]. - cmd->Barrier(g_r.menu_blur_tex[1], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - cmd->SetRenderTargets(g_r.menu_blur_tex[0], nullptr); - const float v_consts[8] = {0.0f, 1.0f / h, sigma_half, radius, 1.0f, 1.0f, 1.0f, 1.0f}; - cmd->SetRootConstants(0, 8, v_consts, 0); - cmd->SetTexture(1, g_r.menu_blur_srv[1]); - cmd->Draw(3, 0); - // Stretch back over the full output. - cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->Barrier(context.guest_output, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - cmd->SetRenderTargets(context.guest_output, nullptr); - cmd->SetViewport(full_vp); - cmd->SetScissor(full_sc); - cmd->SetPipeline(g_r.pso_blur_blit); - cmd->SetTexture(1, g_r.menu_blur_srv[0]); - cmd->Draw(3, 0); - // Restore steady states (the intermediates stay in RENDER_TARGET; the - // output goes back to the state the caller's path expects). - cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->Barrier(g_r.menu_blur_tex[1], nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - if (output_in_guest_output_state) { - cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kGuestOutput); - cmd->FlushBarriers(); - } - return active; -} - -// Minimal resource bring-up for the emulated-output post-processor path: the -// full EnsurePipeline never runs while the native renderer is disabled or -// yielding, so the blur ensures just what it needs (binding layout, the blur -// PSOs, the half-res intermediates, and a sampled view of the output). -bool EnsureMenuBlurStandalone(const NativeGuestOutputRenderContext& context) { - if (g_r.failed) { - return false; - } - if (!EnsureRootSignature(context)) { - return false; - } - if (g_r.pso_menu_gauss == nullptr) { - static bool s_attempted = false; - if (s_attempted) { - return false; // creation failed once; don't hammer every frame - } - s_attempted = true; - EnsureBlurPsos(context); - if (g_r.pso_menu_gauss == nullptr) { - return false; - } - } - EnsureBlurOutlineTargets(context); - if (g_r.menu_blur_tex[0] == nullptr) { - return false; - } - if (g_r.rtv_resource != context.guest_output) { - if (g_r.output_srv_slot) { - context.device->DestroyDeferred(g_r.output_srv_slot); - g_r.output_srv_slot = nullptr; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - g_r.output_srv_slot = context.device->CreateTextureView(context.guest_output, vd); - g_r.output_srv_allocated = g_r.output_srv_slot != nullptr; - g_r.rtv_resource = context.guest_output; - } - return g_r.output_srv_slot != nullptr; -} - -// Registered with the command processors: invoked at the end of the EMULATED -// guest-output refresh (native-rendered frames apply the blur inline in -// RenderScene). Covers boot/startup frames before the native scene takes -// over and manual emulated mode. -void PostProcessGuestOutput(const NativeGuestOutputRenderContext& context, void* /*user_data*/) { - const float target = g_settings_menu_blur.load(std::memory_order_relaxed) - ? float(REXCVAR_GET(skate3_menu_blur_sigma)) - : 0.0f; - // Diagnostics (throttled): the emulated-path blur has several silent - // early-outs; log which leg each invocation takes. - const bool log_this = g_post_blur_log_count.load(std::memory_order_relaxed) < 8; - const bool ensured = EnsureMenuBlurStandalone(context); - if (log_this) { - g_post_blur_log_count.fetch_add(1, std::memory_order_relaxed); - REXLOG_INFO( - "native-scene: emulated-post blur invoked (target={:.1f} ensured={} pso={} tex={} " - "srv={} failed={})", - target, ensured, (void*)g_r.pso_menu_gauss, (void*)g_r.menu_blur_tex[0], - (void*)g_r.output_srv_slot, g_r.failed); - } - if (!ensured) { - if (target <= 0.0f) { - rex::graphics::RequestNativeGuestOutputPostProcess(false); - } - return; - } - if (!ApplyMenuBlurPass(context, context.cmd, target, - /*output_in_guest_output_state=*/true)) { - // Fully eased out: stop the per-frame post-process invocations until the - // menu opens again. - rex::graphics::RequestNativeGuestOutputPostProcess(false); - } -} - -// ---- Warm decode (loading-screen prewarm + gameplay warmup) -------------- -// Decodes, within `deadline`, every GPU resource `item`'s draw would -// resolve: the mesh VB/IB (with fingerprint revalidation) and each texture -// slot its material binds. Mirrors the draw loop's miss paths, including -// negative-caching of failed texture decodes and retiring replaced -// resources, so the caches end up exactly as drawing would leave them and -// the takeover frame has nothing left to pay. Work past the deadline counts -// as deferred. Cached textures get ONE content revalidation per warmup -// (recheck_frame gate; the frame counter is frozen while yielded): a -// texture pre-decoded while its payload was still streaming in is healed -// here, under budget, instead of by an unbudgeted re-decode burst on the -// takeover frame. -struct WarmCounters { - uint32_t decodes = 0; - uint32_t deferred = 0; -}; - -void WarmItemResources(const NativeGuestOutputRenderContext& context, uint8_t* base, - uint64_t frame_number, const DrawItem& item, - std::chrono::steady_clock::time_point deadline, - WarmCounters& wc) { - const auto within = [&] { return std::chrono::steady_clock::now() < deadline; }; - - bool need_mesh = false; - auto mit = g_r.meshes.find(item.mesh); - if (mit == g_r.meshes.end()) { - need_mesh = true; - } else if (mit->second.fingerprint != item.fingerprint && !item.ropa && - REXCVAR_GET(skate3_native_render_scene_mesh_revalidate)) { - // Streaming filled/replaced the payload after an earlier (pre)decode. - // Ropa meshes are excluded: the cloth sim rewrites their payload every - // frame BY DESIGN (fingerprint never converges), so revalidating them - // here just destroys+redecodes the base mesh once per settle frame - - // the churn that kept the settle pass extending itself (log 1624: - // mesh 40D30140 re-fingerprinting 4x in 50ms). The draw path serves - // ropa content from the ropa ring / shape generations regardless. - if (within()) { - if (g_warm_mesh_log_budget.load(std::memory_order_relaxed) > 0 && - g_warm_mesh_log_budget.fetch_sub(1, std::memory_order_relaxed) > 0) { - REXLOG_INFO("native-scene: settle mesh REVALIDATE mesh={:08X} fp {:016X}->{:016X}", - item.mesh, mit->second.fingerprint, item.fingerprint); - } - g_r.device->DestroyDeferred(mit->second.vb); - g_r.device->DestroyDeferred(mit->second.ib); - g_r.meshes.erase(mit); - need_mesh = true; - } else { - ++wc.deferred; - } - } - if (need_mesh) { - if (!within()) { - ++wc.deferred; - } else { - ++wc.decodes; - MeshBuffers buffers; - if (DecodeMesh(g_r.device, base, item, buffers)) { - buffers.fingerprint = item.fingerprint; - g_r.meshes.emplace(item.mesh, buffers); - } - // Failures retry through the draw path (logged + counted there). - } - } - - const auto warm_texture = [&](uint32_t tex_ptr) { - if (tex_ptr == 0) { - return; - } - uint32_t words[6]; - if (!ReadStableTexWords(base, tex_ptr, words) || words[1] == 0) { - return; // unreadable / mid-rewrite / demoted: the draw path routes it - } - const uint64_t key = FetchWordsKey(words); - auto it = g_r.tex_store.find(key); - if (it != g_r.tex_store.end()) { - if (!it->second.valid || frame_number < it->second.recheck_frame || - !REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { - return; // negative caches retry via the draw path's schedule - } - if (!within()) { - ++wc.deferred; - return; - } - it->second.recheck_frame = frame_number + 16; - const uint64_t fp = SampleProbeFingerprint(base, it->second); - if (!it->second.incomplete && (fp == 0 || fp == it->second.payload_fp)) { - return; - } - if (g_warm_tex_log_budget.load(std::memory_order_relaxed) > 0 && - g_warm_tex_log_budget.fetch_sub(1, std::memory_order_relaxed) > 0) { - REXLOG_INFO("native-scene: settle tex RETIRE ptr={:08X} key={:016X} incomplete={} " - "fp {:016X}->{:016X}", - tex_ptr, key, it->second.incomplete, it->second.payload_fp, fp); - } - RetireGuestTexture(it->second, context.device->CurrentSubmission()); - g_r.tex_store.erase(it); - } - if (!within()) { - ++wc.deferred; - return; - } - ++wc.decodes; - GuestTexture gt; - EnsureGuestTextureFromWords(context, base, words, gt); - if (!gt.valid) { - // Negative-cache exactly like the draw path so a permanently - // unreadable payload cannot hold warmup open. - std::memcpy(gt.fetch_words, words, sizeof(gt.fetch_words)); - gt.retry_after_frame = frame_number + 120; - } - gt.last_used_frame = frame_number; - g_r.tex_store.emplace(key, gt); - }; - // Draw-time fetch-word bindings (streamed artwork / decal ad overrides) - // share the same store. - const auto warm_fetch_words = [&](const uint32_t words[6]) { - if (words[1] == 0) { - return; - } - const uint64_t fkey = FetchWordsKey(words); - if (g_r.tex_store.contains(fkey)) { - return; - } - if (!within()) { - ++wc.deferred; - return; - } - ++wc.decodes; - GuestTexture gt; - EnsureGuestTextureFromWords(context, base, words, gt); - gt.last_used_frame = frame_number; - g_r.tex_store.emplace(fkey, gt); - }; - - warm_fetch_words(item.diffuse_fetch); - warm_texture(item.diffuse_tex); - if (REXCVAR_GET(skate3_native_render_scene_lightmaps)) { - warm_texture(item.lightmap_tex); - } - if (REXCVAR_GET(skate3_native_render_scene_macro)) { - warm_texture(item.macro_tex); - } - if (item.water || item.char_family >= 6 || item.dynobj != 0 || - (item.env_family >= 1 && item.env_family <= 6)) { - // v2: fams 1-4 and the dynobj families sample their base normal map - // per-pixel too. - warm_texture(item.water_normal); - } - if ((item.env_family >= 1 && item.env_family <= 4 && item.env_family != 2) || - item.dynobj != 0) { - // v2 detail normal map (t8; fam 2 carries no base+detail pair). - warm_texture(item.detail_tex); - } - if (item.decal && REXCVAR_GET(skate3_native_render_scene_decals)) { - warm_texture(item.decal_art); - warm_fetch_words(item.decal_fetch); - } - if (item.char_family >= 4 && item.char_family <= 5) { - warm_texture(item.hair_alpha_tex); - } - if ((item.env_family != 0 && item.env_family != 10) || item.unlit || - item.dynobj != 0) { - // unlit = sky: spec_tex is the 1D sun gradient. Decal fams (3/4) and - // the dynobj families consume their spec masks too (v2, t9). - warm_texture(item.spec_tex); - } - // Environment cube (negative-cached like the draw path). - if ((item.water || item.char_family >= 6 || - (item.env_family >= 5 && item.env_family <= 6) || - item.env_family == 13) && - item.water_env != 0 && !g_r.cube_textures.contains(item.water_env)) { - if (!within()) { - ++wc.deferred; - } else { - ++wc.decodes; - GuestTexture c{}; - if (!EnsureGuestCubeTexture(context, base, item.water_env, c)) { - if (c.upload) g_r.device->DestroyDeferred(c.upload); - if (c.texture) g_r.device->DestroyDeferred(c.texture); - c = GuestTexture{}; - c.valid = false; - } - g_r.cube_textures.emplace(item.water_env, c); - } - } -} - -// ---- Steady-state miss routing (render thread -> decode workers) ---------- -// Draw-path cache misses for STATIC content (world meshes, material -// textures) enqueue here instead of decoding inline on the render thread: -// the item skips / renders white / keeps its previous decode for the 1-3 -// frames the workers need, instead of stalling the frame for the decode -// (measured ~10 ms avg, ~70 ms max per texture, the panning lag spikes). -// Dynamic payloads (skinned, cloth, ropa) still decode inline: their buffers -// change every frame, so an async result would always be stale. -void EnqueueMeshMiss(uint32_t mesh) { - std::lock_guard lock(g_prewarm_mutex); - if (g_miss_queue.size() < 65536 && g_miss_inflight_mesh.insert(mesh).second) { - PrewarmEntry e{mesh, 8}; - e.miss = true; - g_miss_queue.push_back(e); - g_prewarm_cv.notify_one(); - } -} - - -// Words-keyed texture miss (streamed-artwork posters / event ads): the art -// exists only as draw-time fetch words. Decoded unbudgeted inline these were -// a traversal hitch (a poster decode costs the same ~10 ms as any texture); -// while a decode is in flight the item falls back to its channel diffuse -// (the placeholder poster), not white. -void EnqueueWordsMiss(uint64_t key, const uint32_t words[6], bool ui = false) { - std::lock_guard lock(g_prewarm_mutex); - if (g_miss_queue.size() < 65536 && g_miss_inflight_words.insert(key).second) { - PrewarmEntry e{0, 0, 0, key}; - std::memcpy(e.words, words, sizeof(e.words)); - e.miss = true; - e.ui = ui; - g_miss_queue.push_back(e); - g_prewarm_cv.notify_one(); - } -} - -// Environment-cube miss: one cube decode measured up to ~100 ms inline; -// the gray fallback cube shows for the 1-3 frames the workers need instead. -void EnqueueCubeMiss(uint32_t tex) { - std::lock_guard lock(g_prewarm_mutex); - if (g_miss_queue.size() < 65536 && g_miss_inflight_tex.insert(tex).second) { - PrewarmEntry e{0, 0, tex}; - e.cube = true; - e.miss = true; - g_miss_queue.push_back(e); - g_prewarm_cv.notify_one(); - } -} - -// ---- Prewarm decode workers ----------------------------------------------- -// Process one registered mesh on a worker: build the item (same walk and -// payload fingerprint as the capture, so cache entries are identical), -// decode the mesh into upload-heap buffers, and stage every material -// texture up to a filled upload resource. Results go to g_prewarm_out for -// the render thread's commit. -void ProcessPrewarmEntry(uint8_t* base, const PrewarmEntry& e) { - if (e.mesh == 0 && e.tex == 0 && e.wkey != 0) { - // Words-keyed texture miss (posters/ads, see EnqueueWordsMiss): stage - // the decode from the captured fetch words. - StagedTexResult tr; - tr.words_key = e.wkey; - tr.ui = e.ui; - NativeGuestOutputRenderContext stage_ctx{}; - stage_ctx.device = g_r.device; - g_tex_stage_out = &tr.commit; - tr.valid = EnsureGuestTextureFromWords(stage_ctx, base, e.words, tr.gt); - g_tex_stage_out = nullptr; - PrewarmResult res; - res.item.mesh = 0; - res.mesh_valid = false; - res.miss = e.miss; - res.textures.push_back(std::move(tr)); - std::lock_guard lock(g_prewarm_out_mutex); - g_prewarm_out.push_back(std::move(res)); - return; - } - if (e.mesh == 0 && e.tex != 0) { - // Environment-cube miss (see EnqueueCubeMiss, the only object-keyed - // texture path left): stage the decode up to a filled upload resource; - // the commit records the GPU copies + SRV. - StagedTexResult tr; - tr.key = e.tex; - tr.cube = true; - NativeGuestOutputRenderContext stage_ctx{}; - stage_ctx.device = g_r.device; - g_tex_stage_out = &tr.commit; - tr.valid = EnsureGuestCubeTexture(stage_ctx, base, e.tex, tr.gt); - g_tex_stage_out = nullptr; - PrewarmResult res; - res.item.mesh = 0; // texture-only result (DrawItem::mesh has no default) - res.mesh_valid = false; - res.miss = e.miss; - res.textures.push_back(std::move(tr)); - std::lock_guard lock(g_prewarm_out_mutex); - g_prewarm_out.push_back(std::move(res)); - return; - } - uint8_t record[0x60]; - DrawItem item{}; - if (!GuestTryCopy(record, base + e.mesh, sizeof(record)) || - !BuildItemFromMesh(base, e.mesh, item)) { - // Buffer objects can finish initializing shortly after registration; - // retries are re-injected frame-paced by the render thread. - bool dropped = false; - { - std::lock_guard lock(g_prewarm_out_mutex); - if (e.retries > 0) { - g_prewarm_retry.push_back({e.mesh, uint16_t(e.retries - 1)}); - } else { - g_prewarm_dropped.fetch_add(1, std::memory_order_relaxed); - dropped = true; - } - } - if (dropped) { - // A dropped draw-path miss must leave the in-flight set so a later - // frame can retry it (the payload may finish streaming in). - std::lock_guard lock(g_prewarm_mutex); - g_miss_inflight_mesh.erase(e.mesh); - } - return; - } - // One representative draw entry so DecodeMesh's two-sided-sheet detection - // keys off the real primitive type (empty draw lists would pass it - // unconditionally). Guarded: island params live outside the validated - // mesh record. - const uint32_t num_islands = REX_LOAD_U32(e.mesh + 0x38); - const uint32_t island_params = REX_LOAD_U32(e.mesh + 0x44); - uint32_t prim0 = 0; - if (num_islands != 0 && num_islands < 4096 && - GuestTryLoadU32(base, island_params, &prim0)) { - item.draws.push_back(DrawEntry{prim0, 0, 0, item.ib_count}); - } - - PrewarmResult res; - res.miss = e.miss; - res.mesh_valid = DecodeMesh(g_r.device, base, item, res.buffers); - if (res.mesh_valid) { - res.buffers.fingerprint = item.fingerprint; - } - - // Stage the material textures (cube maps stay on the render thread: - // rare, and their decode has its own path). Dedupe through the shared - // per-load set: workers cannot read the render thread's g_r caches, so a - // texture cached from a previous map decodes once more per load; the - // commit discards the duplicate. - const auto stage_texture = [&](uint32_t tex_ptr) { - if (tex_ptr == 0) { - return; - } - // Stable words snapshot: the decode and its store key both come from - // this snapshot, so a mid-rewrite object can never stage a mixed state. - uint32_t words[6]; - if (!ReadStableTexWords(base, tex_ptr, words) || words[1] == 0) { - return; - } - const uint64_t wkey = FetchWordsKey(words); - { - // Words-aware dedupe: the same content staged once per load; a - // rebound object (new words) re-stages under its new key. - std::lock_guard lock(g_prewarm_mutex); - const auto [it, fresh] = g_prewarm_tex_seen.try_emplace(tex_ptr, wkey); - if (!fresh) { - if (it->second == wkey) { - return; - } - it->second = wkey; - } - } - StagedTexResult tr; - tr.words_key = wkey; - // Staged mode uses only context.device (copies/barrier/SRV are - // exported for the commit), so a device-only context suffices. - NativeGuestOutputRenderContext stage_ctx{}; - stage_ctx.device = g_r.device; - g_tex_stage_out = &tr.commit; - tr.valid = EnsureGuestTextureFromWords(stage_ctx, base, words, tr.gt); - g_tex_stage_out = nullptr; - if (!tr.valid) { - std::memcpy(tr.gt.fetch_words, words, sizeof(tr.gt.fetch_words)); - } - res.textures.push_back(std::move(tr)); - }; - stage_texture(item.diffuse_tex); - if (REXCVAR_GET(skate3_native_render_scene_lightmaps)) { - stage_texture(item.lightmap_tex); - } - if (REXCVAR_GET(skate3_native_render_scene_macro)) { - stage_texture(item.macro_tex); - } - if (item.water || item.char_family >= 6 || item.dynobj != 0 || - (item.env_family >= 1 && item.env_family <= 6)) { - // v2: fams 1-4 and the dynobj families sample their base normal map - // per-pixel too. - stage_texture(item.water_normal); - } - if ((item.env_family >= 1 && item.env_family <= 4 && item.env_family != 2) || - item.dynobj != 0) { - // v2 detail normal map (t8; fam 2 carries no base+detail pair). - stage_texture(item.detail_tex); - } - if (item.decal && REXCVAR_GET(skate3_native_render_scene_decals)) { - stage_texture(item.decal_art); - } - if (item.char_family >= 4 && item.char_family <= 5) { - stage_texture(item.hair_alpha_tex); - } - if ((item.env_family != 0 && item.env_family != 10) || item.unlit || - item.dynobj != 0) { - // unlit = sky: spec_tex is the 1D sun gradient. Decal fams (3/4) and - // the dynobj families consume their spec masks too (v2, t9). - stage_texture(item.spec_tex); - } - - res.item = std::move(item); - std::lock_guard lock(g_prewarm_out_mutex); - g_prewarm_out.push_back(std::move(res)); -} - -void PrewarmWorkerLoop() { -#if defined(_WIN32) - // Below-normal priority: the workers flood in exactly when the guest's - // single-threaded world ACTIVATION runs (registration is the final load - // phase), and at normal priority they stretch the game's own black - // window at the loading->gameplay boundary. At below-normal they only - // soak idle cores and the guest always wins the contention. - SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); -#endif - for (;;) { - if (!SceneEnabled()) { - std::this_thread::sleep_for(std::chrono::milliseconds(250)); - continue; - } - PrewarmEntry e{}; - DynDecodeJob dyn; - bool have_dyn = false; - { - std::unique_lock lock(g_prewarm_mutex); - g_prewarm_cv.wait_for(lock, std::chrono::milliseconds(500), [] { - return !g_prewarm_queue.empty() || !g_miss_queue.empty() || - !g_dyn_jobs.empty(); - }); - if (!g_dyn_jobs.empty()) { - // Dynamic cloth first: these are per-frame payloads whose result - // should land at the very next commit. - dyn = std::move(g_dyn_jobs.front()); - g_dyn_jobs.erase(g_dyn_jobs.begin()); - have_dyn = true; - } else if (!g_miss_queue.empty()) { - // Draw-path misses next: this content is visible RIGHT NOW (white / - // skipped geometry). On the old shared LIFO queue a streaming - // registration burst kept cutting the line ahead of the visible - // miss; medium-distance pop-in lasted the whole backlog. - e = g_miss_queue.front(); - g_miss_queue.erase(g_miss_queue.begin()); - } else if (!g_prewarm_queue.empty()) { - e = g_prewarm_queue.back(); - g_prewarm_queue.pop_back(); - } else { - continue; - } - } - uint8_t* base = g_guest_base.load(std::memory_order_relaxed); - if (base == nullptr || g_r.device == nullptr) { - if (!have_dyn) { - std::lock_guard lock(g_prewarm_out_mutex); - g_prewarm_retry.push_back(e); - } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - if (have_dyn) { - PrewarmResult res; - res.mesh_valid = - DecodeMesh(g_r.device, base, dyn.item, res.buffers, dyn.vb.data(), - dyn.ib.empty() ? nullptr : dyn.ib.data()); - if (res.mesh_valid) { - res.buffers.fingerprint = dyn.item.fingerprint; - res.buffers.dyn_seq = dyn.seq; - res.item = std::move(dyn.item); - res.miss = true; // per-frame cloth: never behind the commit cap - std::lock_guard lock(g_prewarm_out_mutex); - g_prewarm_out.push_back(std::move(res)); - } - continue; - } - ProcessPrewarmEntry(base, e); - } -} - -// Lazily start the decode workers (process-lifetime, parked on the queue's -// condition variable when idle). Only started once the pipeline exists; -// the workers create GPU resources through g_r.device (thread-safe). -void EnsurePrewarmWorkers() { - if (g_r.device == nullptr || - g_prewarm_workers_started.exchange(true, std::memory_order_acq_rel)) { - return; - } - const unsigned hw = std::max(4u, std::thread::hardware_concurrency()); - // Below-normal priority makes extra workers near-free (they only soak - // idle cores; the guest always wins the contention) while the pool size - // governs how fast a gameplay streaming burst decodes; 4 workers left - // medium-distance pop-in on big sectors visibly behind the emulated - // renderer. - const unsigned n = std::clamp(hw / 3u, 2u, 8u); - for (unsigned i = 0; i < n; ++i) { - std::thread(PrewarmWorkerLoop).detach(); - } - REXLOG_INFO("native-scene: {} prewarm decode workers started", n); -} - -// Render-thread commit of the workers' results: record GPU copies + -// barriers, create SRVs, insert into the caches, re-inject retries. -// Microseconds per item, safe to run every frame (loading, settle and -// gameplay alike). -void PrewarmCommit(const NativeGuestOutputRenderContext& context, - uint64_t frame_number, bool loading = false) { - { - std::scoped_lock lock(g_prewarm_out_mutex, g_prewarm_mutex); - if (!g_prewarm_retry.empty()) { - g_prewarm_queue.insert(g_prewarm_queue.end(), g_prewarm_retry.begin(), - g_prewarm_retry.end()); - g_prewarm_retry.clear(); - g_prewarm_cv.notify_all(); - } - } - std::vector done; - { - std::lock_guard lock(g_prewarm_out_mutex); - // Soft cap per frame: during the activation burst the workers can - // complete hundreds of items between two refreshes, and committing - // them all at once records thousands of copies + SRVs into a single - // frame (measured ~96 us per item; a 256 batch is ~25 ms). Behind a - // loading screen that is fine; in gameplay the cap stays small so a - // streaming burst spreads over a few frames instead of hitching one. - const size_t kMaxCommitPerFrame = loading ? 256 : 32; - if (g_prewarm_out.size() <= kMaxCommitPerFrame) { - done.swap(g_prewarm_out); - } else { - // Oldest first: draining from the END starved early results under a - // sustained streaming burst (they sat behind an ever-refilling tail, - // so exactly the content that had waited longest stayed popped-out). - // Draw-path MISS results (content visible right now: white/skipped) - // bypass the cap entirely: under a big sector streaming burst they - // otherwise queued behind hundreds of speculative prewarm results - // for several frames of visible pop-in. Only a handful arrive per - // frame, so the bypass cannot recreate the commit hitch the cap - // exists to prevent. - std::vector rest; - rest.reserve(g_prewarm_out.size()); - size_t taken = 0; - for (PrewarmResult& r : g_prewarm_out) { - const bool is_miss = r.miss; - if (is_miss || taken < kMaxCommitPerFrame) { - if (!is_miss) { - ++taken; - } - done.push_back(std::move(r)); - } else { - rest.push_back(std::move(r)); - } - } - g_prewarm_out.swap(rest); - } - } - if (done.empty()) { - return; - } - const auto commit_t0 = PerfClock::now(); - bool committed_tex = false; - // For the payload-stability verify below (SEH-guarded sampled reads). - uint8_t* verify_base = g_guest_base.load(std::memory_order_relaxed); - for (PrewarmResult& r : done) { - if (r.mesh_valid) { - auto mit = g_r.meshes.find(r.item.mesh); - const bool superseded = - mit != g_r.meshes.end() && mit->second.dyn_seq > r.buffers.dyn_seq; - // ROPA shape-generation ring: retain this decode's vertex array - // (keyed by dyn_seq) for the draw-time blend onto the play clock. - // Runs even when the GPU buffers get dropped as identical below; - // the SEQ still advances and the interp ring may reference it. - if (r.item.ropa && r.buffers.dyn_seq != 0 && !superseded && - !r.buffers.ropa_verts.empty()) { - auto& ring = g_r.ropa_shapes[r.item.mesh]; - const double now_s = - std::chrono::duration( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - ring.push_back( - {r.buffers.dyn_seq, now_s, std::move(r.buffers.ropa_verts)}); - // 16 generations = ~114 ms at a 140 Hz guest: the 8-tap boxcar - // kernel reaches filter_w/2 (~25 ms) past the play clock (itself - // ~2 guest periods behind), plus decode-latency slack. - while (ring.size() > 16) { - ring.pop_front(); - } - if (g_r.ropa_shapes.size() > 64) { - g_r.ropa_shapes.clear(); // outfit-change growth backstop - } - } - if (mit != g_r.meshes.end() && - (mit->second.fingerprint == r.buffers.fingerprint || superseded)) { - // Identical content already cached (lost the race against the draw - // path / an earlier result), or a NEWER dynamic decode already - // landed (multi-worker reordering must not step the cloth - // backwards): the staged buffers were never referenced by any - // submission. - g_r.device->DestroyDeferred(r.buffers.vb); - g_r.device->DestroyDeferred(r.buffers.ib); - } else { - if (mit != g_r.meshes.end()) { - // Stale decode (miss-driven revalidation heal): swap it out. The - // old buffers may be referenced by the in-flight submission. - g_r.device->DestroyDeferred(mit->second.vb); - g_r.device->DestroyDeferred(mit->second.ib); - g_r.meshes.erase(mit); - } - g_r.meshes.emplace(r.item.mesh, r.buffers); - } - } - for (StagedTexResult& t : r.textures) { - // Payload-stability verify: a decode read while its payload was still - // STREAMING IN is a garbage interleave (the mip-churn "goes black, - // then reloads" flash; fresh mip words repoint mid-upload, and the - // fingerprint sampled right after the worker's read can look stable). - // The commit runs 1-3 frames later: re-sample here and FAIL unstable - // results, so the cache keeps the previous good decode and the retry - // clock re-runs the heal once the payload settles. Cubes are exempt - // (static assets; a failed cube negative-caches permanently). - // UI-origin results skip the verify (see PrewarmEntry::ui): animating - // APT art legitimately rewrites its payload every guest frame, so the - // re-sample below would reject every mid-animation commit and freeze - // the element; the 2D resolve's content probe is the heal path there. - if (t.valid && !t.cube && !t.ui && verify_base != nullptr && - t.gt.payload_addr != 0 && - SampleProbeFingerprint(verify_base, t.gt) != t.gt.payload_fp) { - if (t.gt.texture) { - g_r.device->DestroyDeferred(t.gt.texture); - t.gt.texture = nullptr; - } - if (t.gt.upload) { - g_r.device->DestroyDeferred(t.gt.upload); - t.gt.upload = nullptr; - } - t.valid = false; - t.verify_failed = true; - g_heal_verify_fail.fetch_add(1, std::memory_order_relaxed); - } - if (t.cube) { - // Environment cube: lands in the cube cache. Cubes are static - // assets: an existing valid entry wins; a failed decode - // negative-caches like the old inline path did. - auto cit = g_r.cube_textures.find(t.key); - if (cit != g_r.cube_textures.end() && cit->second.valid) { - if (t.gt.texture) g_r.device->DestroyDeferred(t.gt.texture); - if (t.gt.upload) g_r.device->DestroyDeferred(t.gt.upload); - continue; - } - if (cit != g_r.cube_textures.end()) { - RetireGuestTexture(cit->second, context.device->CurrentSubmission()); - g_r.cube_textures.erase(cit); - } - if (t.valid) { - CommitStagedGuestTexture(context, t.gt, t.commit); - committed_tex = true; - } - g_r.cube_textures.emplace(t.key, t.gt); - continue; - } - if (t.words_key != 0) { - // Store commit: the result files under its decode-time words key - // unconditionally; a rebound object simply routes elsewhere, so a - // worker result can never land on the wrong identity. The only - // remaining valid->valid swap class is an in-place content change - // at the same words (event-ad rotation, mip-pool fills, composed - // lightmap pages); the payload verify above covers exactly that. - const bool tr_key = - !g_trace_keys.empty() && g_trace_keys.count(t.words_key) != 0; - auto wit = g_r.tex_store.find(t.words_key); - if (tr_key) { - REXLOG_INFO( - "tex-trace: f{} COMMIT key={:016X} valid={} vfail={} " - "fp={:016X} inc={} nb={} cached={}", - frame_number, t.words_key, t.valid ? 1 : 0, - t.verify_failed ? 1 : 0, t.gt.payload_fp, - t.gt.incomplete ? 1 : 0, t.gt.near_black ? 1 : 0, - wit != g_r.tex_store.end() - ? (wit->second.valid ? "valid" : "invalid") - : "none"); - } - if (wit != g_r.tex_store.end()) { - // Same-content dedup, except a complete re-decode always - // displaces an incomplete cached entry (truncated tiled-mip copy: - // the zeroed blocks live in mips the fingerprint never samples). - const bool same_content = t.valid && wit->second.valid && - t.gt.payload_fp == wit->second.payload_fp && - !(wit->second.incomplete && !t.gt.incomplete); - if (same_content || (!t.valid && wit->second.valid)) { - // Keep the cached decode ("keep the old decode when the payload - // became unreadable": mips stream out at range). A failed heal - // of a still-serving entry needs no retry stamp: the payload - // poll re-detects on its own cadence and the miss-inflight set - // already dedupes. - if (same_content && t.gt.near_black && wit->second.near_black && - wit->second.nb_redecodes < 255) { - // A forced near-black re-decode came back identical: one more - // confirmation toward "genuinely uniform content". - ++wit->second.nb_redecodes; - } - if (t.gt.texture) g_r.device->DestroyDeferred(t.gt.texture); - if (t.gt.upload) g_r.device->DestroyDeferred(t.gt.upload); - continue; - } - if (t.valid && wit->second.valid) { - // In-place content swap: the only commit class a player can - // SEE; rolling-capped log so a flicker sighting names its - // texture. - static std::atomic s_swap_logs{0}; - static std::atomic s_swap_win{0}; - const int64_t now_s = - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - int64_t swin = s_swap_win.load(std::memory_order_relaxed); - if (now_s - swin >= 5 && - s_swap_win.compare_exchange_strong(swin, now_s)) { - s_swap_logs.store(0, std::memory_order_relaxed); - } - if (s_swap_logs.fetch_add(1) < 8) { - REXLOG_INFO( - "native-scene: texture heal commit key={:016X} fp {:016X} " - "-> {:016X}", - t.words_key, wit->second.payload_fp, t.gt.payload_fp); - } - } - if (!t.valid) { - t.gt.fail_count = wit->second.fail_count; // keep the backoff arc - } - t.gt.last_used_frame = wit->second.last_used_frame; - RetireGuestTexture(wit->second, context.device->CurrentSubmission()); - g_r.tex_store.erase(wit); - } - if (t.valid) { - CommitStagedGuestTexture(context, t.gt, t.commit); - committed_tex = true; - // Content landed this frame; the video-start cold/hot classifier - // (GuestTexture::last_change_frame) keys off commit times. - t.gt.last_change_frame = frame_number; - } else { - t.gt.fail_count = BumpFail(t.gt.fail_count); - t.gt.retry_after_frame = frame_number + RetryBackoff(t.gt.fail_count); - // Failed decodes render white: log each once (capped) so white - // meshes stay attributable to a specific texture. - static std::unordered_set logged_failed; - if (logged_failed.size() < 64 && - logged_failed.insert(t.words_key).second) { - REXLOG_INFO( - "native-scene: texture decode FAILED key={:016X} " - "fetch=[{:08X} {:08X} {:08X} {:08X} {:08X} {:08X}]", - t.words_key, t.gt.fetch_words[0], t.gt.fetch_words[1], - t.gt.fetch_words[2], t.gt.fetch_words[3], t.gt.fetch_words[4], - t.gt.fetch_words[5]); - } - } - g_r.tex_store.emplace(t.words_key, t.gt); - continue; - } - // No words key and not a cube: an empty/failed stage slot; release - // whatever it carries (nothing routes to it). - if (t.gt.texture) g_r.device->DestroyDeferred(t.gt.texture); - if (t.gt.upload) g_r.device->DestroyDeferred(t.gt.upload); - } - g_prewarm_done.fetch_add(1, std::memory_order_relaxed); - } - // Release the miss-in-flight keys so later revalidation cycles can enqueue - // these again (erasing keys that were never in the sets is harmless). - { - std::lock_guard lock(g_prewarm_mutex); - for (const PrewarmResult& r : done) { - if (r.item.mesh != 0) { - g_miss_inflight_mesh.erase(r.item.mesh); - } - for (const StagedTexResult& t : r.textures) { - if (t.words_key != 0) { - g_miss_inflight_words.erase(t.words_key); - } else { - g_miss_inflight_tex.erase(t.key); - } - } - } - } - if (committed_tex) { - context.cmd->FlushBarriers(); - } - g_pw_commit.Add(uint64_t( - std::chrono::duration_cast(PerfClock::now() - commit_t0) - .count())); -} - -// Render-thread mirror of the presence-context check (set in YieldForMenus): -// menu screens shorten the 2D texture liveness recheck cadence so in-place -// UI-texture rewrites (portrait resolves) heal fast. -std::atomic g_in_menus_frame{false}; - -// Set by YieldForMenus, consumed by RenderScene in the same call (render -// thread only): this frame is a NATIVE loading-screen frame; no world scene -// exists (or it is the previous map's stale one), so RenderScene renders a -// black backdrop + the captured 2D loading UI instead of the world. -bool g_loading_native_frame = false; -// Render thread only: the previous rendered frame was a native loading -// frame. Used to HOLD the loading visuals through the post-load takeover -// gate window; the emulated output was suppressed all through the native -// loading screen, so yielding there would flash the stale pre-load frame. -bool g_loading_hold = false; - -// Menus / pause / loading yield gate (see the comment at the call site): -// returns true when RenderScene must yield this frame to the emulated -// output. Handles the cache clears on entry and the takeover re-arm + -// loading-screen pipeline build / prewarm commit while in a load, whether -// the loading pixels themselves render emulated (yield) or natively -// (g_loading_native_frame). -bool YieldForMenus(const NativeGuestOutputRenderContext& context) { - static bool s_in_loading = false; - static bool s_seen_gameplay = false; - static bool s_pause_native = false; - const bool in_menus = rex::graphics::ultrawide_debug::Skate3GameplayContextValue() == 0; - // Render-thread mirror for the 2D texture resolver: menu screens shorten - // the content-liveness recheck cadence (see resolve_2d_texture) so - // in-place rewrites of UI textures (the one-shot skater-portrait resolves) - // heal within a couple of frames instead of up to 16. - g_in_menus_frame.store(in_menus, std::memory_order_relaxed); - if (!in_menus) { - s_seen_gameplay = true; - } - // In-game pause menu: the presence context reads 0, but the world keeps - // resubmitting perspective scenes behind the menu (loading screens and the - // boot frontend stop publishing): stay native there so the pause backdrop - // renders natively and the caches survive the pause. The 2D pause UI rides - // the same captured-APT overlay replay as the gameplay HUD. If publishes - // go stale (a load was picked from the pause menu, or the game stops - // redrawing the world), this degrades to the yield path below within - // ~300 ms, cache clears and all. - // boot_native lifts the first-gameplay prerequisite from both native menu - // modes: a boot-frontend 3D backdrop renders like a pause backdrop, and - // everything else (videos, menus, the first load) renders as 2D-over-black. - const bool boot_native = REXCVAR_GET(skate3_native_render_scene_boot_native); - bool pause_native = false; - if (in_menus && (s_seen_gameplay || boot_native) && - REXCVAR_GET(skate3_native_render_scene_pause_native)) { - const int64_t last_ns = g_last_publish_ns.load(std::memory_order_relaxed); - const int64_t now_ns = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - pause_native = last_ns >= 0 && now_ns - last_ns < 300'000'000; - } - if (pause_native != s_pause_native) { - s_pause_native = pause_native; - if (pause_native) { - REXLOG_INFO( - "native-scene: pause menu over live world - staying NATIVE " - "(2d stats at entry: draws_2d={} other={} dropped={})", - g_draws_2d.load(std::memory_order_relaxed), - g_draws_2d_other.load(std::memory_order_relaxed), - g_draws_2d_dropped.load(std::memory_order_relaxed)); - } else { - REXLOG_INFO( - "native-scene: leaving native pause ({}; 2d stats at exit: " - "draws_2d={} other={} dropped={})", - in_menus ? "scene publishes went stale - loading/frontend" - : "gameplay resumed", - g_draws_2d.load(std::memory_order_relaxed), - g_draws_2d_other.load(std::memory_order_relaxed), - g_draws_2d_dropped.load(std::memory_order_relaxed)); - } - } - // Menu-context un-suppression: the game produces some content as one-shot - // off-screen renders; the team-menu skater portrait boxes are a - // render-to-texture pass issued once when the screen opens (and re-issued - // after an edit). With emulated draws suppressed those passes never - // execute, the resolve never writes the portrait texture, and the boxes - // stay empty forever (the F11 emulated pair-shot showed the same empty box - // - the texture is persistent guest state that was simply never filled). - // While a menu context is up, clear the SDK suppress cvar so the emulated - // pipeline keeps every RTT/composite current; the extra GPU cost is - // menu-only. The saved value is restored on the first gameplay frame, so a - // user toggle of the underlying cvar in the debug dialog survives (it is - // re-read at each menu entry). - { - static bool s_unsup_forced = false; - static bool s_unsup_saved = false; - const bool want = - in_menus && REXCVAR_GET(skate3_native_render_scene_menu_unsuppress); - if (want && !s_unsup_forced) { - s_unsup_saved = REXCVAR_GET(native_render_suppress_emulated_draws); - if (s_unsup_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, false); - REXLOG_INFO( - "native-scene: menu context - emulated draw suppression OFF " - "(one-shot render-to-texture passes execute; restored on " - "gameplay)"); - } - s_unsup_forced = true; - } else if (!want && s_unsup_forced) { - if (s_unsup_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, true); - REXLOG_INFO( - "native-scene: leaving menu context - emulated draw suppression " - "restored"); - } - s_unsup_forced = false; - } - } - // Menu-context suppression FILTER relaxation (the near-native sibling of - // the full lift above): mode 0 keeps the framebuffer passes suppressed, - // the screen stays natively composed, but lets the sub-framebuffer RTT - // passes execute, which is where the one-shot skater-portrait renders - // live (their pitch sits inside the mode-2 suppressed band; with mode 2 - // active in menus the boxes stayed empty). - { - static bool s_mode_forced = false; - static int32_t s_mode_saved = 0; - // menu_rtt_scope 1: only hold mode 0 inside the portrait window - // (screen-transition grace / not-known-steady screens / CAS); outside - // it, menus keep the gameplay-proven mode-2 suppression instead of - // executing the game's postfx chain emulated at scale every frame - // (the pause-menu/title-screen GPU contention). - const bool want = - in_menus && REXCVAR_GET(skate3_native_render_scene_menu_rtt_passes) && - (REXCVAR_GET(skate3_native_render_scene_menu_rtt_scope) == 0 || - PortraitRttWindowActive()); - if (want && !s_mode_forced) { - s_mode_saved = REXCVAR_GET(native_render_suppress_mode); - // Mode 3, not 0: the portrait-class RTTs (census 560-1200 during the - // window) execute, but the 1152-wide main scene + postfx band stays - // suppressed; mode 0 ran the game's whole pipeline at scaled - // resolution for the window's duration, dropping the pause menu to - // ~60 fps whenever a screen push (or its 3 s transition grace) - // opened the window. - if (s_mode_saved != 3) { - REXCVAR_SET(native_render_suppress_mode, 3); - REXLOG_INFO( - "native-scene: portrait window - suppress mode {} -> 3 (portrait " - "RTT passes execute, scene/postfx band stays suppressed; " - "restored when the window closes)", - s_mode_saved); - } - s_mode_forced = true; - } else if (!want && s_mode_forced) { - if (s_mode_saved != 3) { - REXCVAR_SET(native_render_suppress_mode, s_mode_saved); - REXLOG_INFO( - "native-scene: portrait window closed - suppress mode {} restored", - s_mode_saved); - } - s_mode_forced = false; - } - // Same menu window: shader compilation goes SYNCHRONOUS. With - // async_shader_compilation on, the d3d12 command processor SKIPS any - // draw whose pipeline is still compiling (command_processor.cpp - // ConfigurePipeline tail): fine mid-gameplay, but the skater-portrait - // boxes are ONE-SHOT renders: pieces skipped during a first-run compile - // are baked into the portrait forever (the armless/torso-less - // skaters; later runs are fine because the - // shader/pipeline disk storage is warm). Menus tolerate the one-time - // compile stalls invisibly. - static bool s_async_forced = false; - static bool s_async_saved = false; - if (want && !s_async_forced) { - s_async_saved = REXCVAR_GET(async_shader_compilation); - if (s_async_saved) { - REXCVAR_SET(async_shader_compilation, false); - REXLOG_INFO( - "native-scene: menu context - shader compilation synchronous " - "(one-shot portrait renders can't skip still-compiling pieces)"); - } - s_async_forced = true; - } else if (!want && s_async_forced) { - if (s_async_saved) { - REXCVAR_SET(async_shader_compilation, true); - } - s_async_forced = false; - } - } - const bool in_loading = in_menus && !pause_native; - // Loading screens themselves render natively too (black + the captured 2D - // loading UI) when enabled, everything after the first gameplay. The - // housekeeping below runs for the loading STATE either way; only the - // yield decision changes. - const bool loading_native = - in_loading && (s_seen_gameplay || boot_native) && - REXCVAR_GET(skate3_native_render_scene_loading_native); - g_loading_native_frame = loading_native; - if (in_loading != s_in_loading) { - s_in_loading = in_loading; - if (in_loading) { - REXLOG_INFO( - "native-scene: menus/loading - {} (presence context)", - loading_native ? "rendering the loading screen NATIVELY" - : "yielding to emulated output"); - // Arena addresses are reused across map loads: let the next load's - // registrations re-queue meshes (and re-stage textures) at reused - // addresses, and drop the cached item cores built from them. - ClearItemCache(); - // Off-screen retention holds guest-address-keyed copies too. - g_retained_clear.store(true, std::memory_order_relaxed); - std::lock_guard lock(g_prewarm_mutex); - g_prewarm_seen.clear(); - g_prewarm_tex_seen.clear(); - } else { - size_t queued = 0; - { - std::lock_guard lock(g_prewarm_mutex); - queued = g_prewarm_queue.size(); - } - REXLOG_INFO( - "native-scene: gameplay (prewarm: {} meshes decoded, {} dropped, {} still " - "queued)", - g_prewarm_done.load(std::memory_order_relaxed), - g_prewarm_dropped.load(std::memory_order_relaxed), queued); - } - } - if (in_loading) { - // Re-arm the takeover gate for the next stretch of gameplay (map - // load, unpause). - g_warmup_armed.store(true, std::memory_order_relaxed); - { - // Freshness gate: only scenes published AFTER this point qualify; - // g_scene still holds the previous map's last scene all through the - // loading screen (BuildFrameScene stops publishing without a - // perspective view). - std::lock_guard lock(g_scene_mutex); - g_warmup_fresh_generation = (g_scene ? g_scene->generation : 0) + 1; - } - // Build the pipelines / render targets behind the loading screen so - // the one-time PSO compilation (~200 ms) never lands on a gameplay - // frame. - if (!g_r.failed && g_r.pso == nullptr) { - EnsurePipeline(context); - } - if (loading_native) { - // RenderScene renders this frame (black + 2D loading UI) and runs - // the prewarm commit itself with the loading budget. - return false; - } - // THE loading-screen heavy lifting runs on the prewarm decode WORKER - // POOL (a serial render-thread drain both tanked the loading spinner - // to ~13 fps and still left 1500 of a map's ~2600 meshes undecoded at - // takeover; map-change loads register most of the world in their - // final seconds and drop the guest to 10-25 fps while doing it). - // Here the render thread only commits finished results: record the - // GPU copies, create SRVs, insert into the caches. - if (REXCVAR_GET(skate3_native_render_scene_prewarm_budget_ms) > 0 && - !g_r.failed && g_r.pso != nullptr) { - EnsurePrewarmWorkers(); - PrewarmCommit(context, g_frames_rendered.load(std::memory_order_relaxed), - /*loading=*/true); - } - return true; - } - return false; -} - - // Photo-mission photo editor (the "Pick a photo" screen with the depth of - // field / saturation / brightness / contrast controls; FE screen class - // PhotoSelect, challenge/photoselect.swf): the editor's effects ARE the - // game's postfx chain, which native rendering suppresses, so natively - // the photo showed the raw scene and the controls did nothing. Yield to - // the emulated output while the editor is up: the emulated frame there is - // complete and exact, and the scene is frozen so emulated-path - // performance is fine. Unlike the menus branch above this touches no - // caches and no takeover gates; native rendering resumes on the next - // frame after the editor closes. - // - // Detection is a per-frame poll of the game's FE state (stateless, so it - // can't get stuck): FrontEndManager singleton ptr global 0x830CFE14 - // (TU3; from SingletonHolder::Instance = 824AD2F0), - // whose NIS FE push-state stack (eastl::vector of 20-byte records at - // +0x210) holds a {1, 11} record exactly while the photographer NIS has - // the editor pushed. Surveyed across 50 gsnaps spanning gameplay, menus - // and other missions: every non-editor record reads {x, -1}; only the - // photo-editor capture shows a non-(-1) second field. The - // PhotoReplayController heartbeat (sub_825623F0 hook) is kept as a - // secondary signal for photo flows that bypass the photographer NIS. -// FMV playback (intro logos, any full-motion video): the frame is -// CPU-decoded into a texture (VideoRenderer_RwTexture Lock/Fill/Unlock) and -// reaches the screen through the game's postfx chain + swap; no capturable -// 2D draw exists (captured FMV frames show ~15 draws, all postfx -// passes + fade fills), so the native path has nothing to replay. Yield -// while the MovieDecoder::Decode heartbeat is fresh; the emulated frame is -// complete and correct there (photo-editor class). Touches no caches or -// takeover gates; native rendering resumes on the next frame after the -// movie ends. -bool YieldForMovie() { - if (!REXCVAR_GET(skate3_native_render_scene_fmv_yield)) { - return false; - } - const int64_t last_ns = g_movie_decode_last_ns.load(std::memory_order_relaxed); - if (last_ns < 0) { - return false; - } - const int64_t now_ns = std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - const bool active = now_ns - last_ns < 500'000'000; - // LAST-RESORT gating: yielding INSTANTLY at video start presented the - // emulated framebuffer, which under draw suppression still holds a stale - // frame: the flash of the PREVIOUS video at every video boundary - // (measured: yield, planes published +16 ms, substitution - // native +38 ms, a ~5-frame stale-frame window). The substitution path - // gets 400 ms from the first heartbeat to engage, and once it has drawn - // recently the yield stays off entirely (covers the video-END race the - // same way). Genuine native-FMV failure (fmv_native off, pso missing, - // plane decode failure) still reaches the emulated yield after the - // grace window. - static int64_t s_fresh_since = -1; - if (!active) { - s_fresh_since = -1; - } else if (s_fresh_since < 0) { - s_fresh_since = now_ns; - } - const int64_t native_ns = g_movie_native_last_ns.load(std::memory_order_relaxed); - // Yield only for a video that is ON SCREEN and NOT being served: - // (a) quad_recent - the 2D replay recently carried a video quad (detection - // runs regardless of movie_sub). A skipped/force-completed video - // (intro skip) leaves the decoder heartbeat alive ~0.5 s with no quad - // anywhere; yielding then flashed the stale emulated buffer. - // (b) staleness measured decoder-vs-native, not wall-clock: at a manual - // skip the quad vanishes instantly but the decoder tail keeps beating - // up to ~500 ms - a genuinely unsubstituted video keeps decoding long - // past the last native draw and crosses the 1 s bar within ~1.4 s of - // starting; a skip tail (<= ~600 ms) never can. - const int64_t quad_ns = g_movie_quad_last_ns.load(std::memory_order_relaxed); - const bool quad_recent = quad_ns >= 0 && now_ns - quad_ns < 500'000'000; - const bool yield = active && now_ns - s_fresh_since >= 400'000'000 && quad_recent && - (native_ns < 0 || last_ns - native_ns > 1'000'000'000); - static bool s_active = false; - if (yield != s_active) { - s_active = yield; - if (yield) { - REXLOG_INFO( - "native-scene: FMV playing - yielding to emulated output " - "(MovieDecoder heartbeat; substitution did not engage)"); - } else { - REXLOG_INFO("native-scene: FMV ended - native output resumes"); - } - } - return yield; -} - -// The photo-editor detection, shared by the yield and the photo-grab -// readback window: nullptr when inactive, else the name of the signal. -const char* PhotoEditorSignal(uint8_t* base) { - const char* signal = nullptr; - { - constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; - uint32_t mgr = 0, beg = 0, end = 0; - if (GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) && mgr != 0 && - GuestTryLoadU32(base, mgr + 0x210, &beg) && - GuestTryLoadU32(base, mgr + 0x214, &end) && beg < end && - end - beg <= 20 * 16) { - const uint32_t n = (end - beg) / 20; - for (uint32_t i = 0; i < n && signal == nullptr; ++i) { - uint32_t f0 = 0, f1 = 0; - if (GuestTryLoadU32(base, beg + i * 20, &f0) && - GuestTryLoadU32(base, beg + i * 20 + 4, &f1) && f0 == 1 && f1 == 11) { - signal = "FE PhotoSelect push-state"; - } - } - } - } - if (signal == nullptr) { - const int64_t last_ns = g_photo_replay_last_ns.load(std::memory_order_relaxed); - const int64_t now_ns = std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - if (last_ns >= 0 && now_ns - last_ns < 250'000'000) { - signal = "PhotoReplayController heartbeat"; - } - } - return signal; -} - -// ---- Native photo grab (photo_grab_native) --------------------------------- -// The grab content is produced NATIVELY while emulated-draw suppression stays -// ON: RenderScene downsamples the finished native frame into blur_tex[0] -// (1152x640: the blur space IS the game's screenshot raster), copies it to a -// persistently-mapped READBACK buffer (double-buffered, never a GPU drain), -// and the next armed frame CPU-tiles the completed one big-endian into the -// guest screenshot target that ScreenshotBackEnd::GrabScreenshot reads. -// Layout contract (traced through sub_824FD550 -> the sub_82A7DC20 surface -// download): tiled Xenos 2D, 32bpp big-endian ARGB (guest byte order -// A,R,G,B); the game XGUntileTextureLevel's it into a linear buffer and -// hands the raw bytes to JPEG::WriteJPEG with no channel swap anywhere. The -// 160x90 gallery thumb is CPU-resized from this same image INSIDE -// GrabScreenshot (cImageOperateMan::ResizeJPEG); there is no second buffer -// to fill. Ground truth fetch words (from capture): 1152x640 -// pitch 1152 k_8_8_8_8 tiled at 0x04911000 (the same surface the SDK's -// Import-Skater special case hardcodes). The write transform is -// byte-for-byte PROVEN offline against that capture's real readback -// content: tiling + [A,R,G,B] guest order reproduced all 0x2D0000 bytes -// with zero mismatches (alpha included: the emulated resolve also wrote -// 255). -constexpr uint32_t kGrabGuestAddr = 0x04911000; -// The CPU (and the app's whole texture-decode path) accesses physical -// memory through the 0xA0000000 cached-physical view; the gsnap ground -// truth lives at 0xA4911000 and plain base+0x04911000 is NOT an alias of -// those pages. All host-side reads/writes of the grab target go through -// this view. -constexpr uint32_t kGrabGuestView = 0xA0000000u | kGrabGuestAddr; -// Photo window armed AND fulfilled natively this window (RenderScene's grab -// block produces/consumes while this is set; the watchdog in -// UpdatePhotoGrabWindow clears it and flips to the emulated fallback if -// native frames stop landing). -std::atomic g_photo_grab_native_armed{false}; -// Last successful guest write (PerfClock ns): the watchdog liveness signal. -std::atomic g_grab_last_write_ns{-1}; -// The photo display-card quad was in the 2D stream this instant (PerfClock -// ns, -1 = never). Stamped by Publish2dDraws (guest thread, runs every -// guest frame regardless of yields) whenever the card-signature quad, the -// 504x640 LINEAR JPEG-decode texture, unique to the photo display card, -// is present. Drives YieldForPhotoDisplay: the display screen renders -// EMULATED, because the game composes the framed card (white border / -// caption / logo) in a ONE-SHOT pitch-1280 pass whose timing proved -// unpredictable (observed anywhere from settle+0.8s to settle+4.0s; every -// readback-window attempt missed it and cost seconds of concurrent-pipeline -// lag). Yielded-emulated, the compose and the display chain just work -// GPU-side with no readbacks, and with the native renderer not running -// concurrently the frozen scene renders at the emulated pipeline's own -// healthy rate. (g_photo_card_seen_ns itself is defined next to -// g_photo_flow_frame; Publish2dDraws lives outside this anonymous -// namespace.) -// The display yield is active / when it last ended (watchdog interplay: -// grab writes legitimately stop while yielded, and get fresh grace after). -std::atomic g_photo_display_yielding{false}; -std::atomic g_photo_display_yield_end_ns{-1}; - -// Validate the game-side target before writing guest memory: GrabScreenshot -// reads its main-target dims from the postfx render manager (mgr = -// *(0x83083C5C), width/height at mgr+380/384). The GPU address has been -// 0x04911000 in every observed session, but refuse to write if the dims -// chain does not confirm the expected 1152x640 raster. -bool GrabTargetValid(uint8_t* base) { - uint32_t mgr = 0, w = 0, h = 0; - if (!GuestTryLoadU32(base, 0x83083C5C, &mgr) || mgr == 0 || - !GuestTryLoadU32(base, mgr + 380, &w) || - !GuestTryLoadU32(base, mgr + 384, &h)) { - return false; - } - return w == RendererState::kBlurWidth && h == RendererState::kBlurHeight; -} - -// CPU-tile one mapped readback image (R10G10B10A2 rows at kGrabRowPitch) -// into the guest screenshot target: assemble the tiled big-endian ARGB image -// in a host staging buffer, then land it with one SEH-guarded copy. Inverse -// of the decode path's run-copy untiler: at 32bpp a 4-texel-aligned x-run of -// 4 maps to contiguous tiled bytes (same proof as the untiler's run_blocks = -// 16 >> bpb_log2), so one GetTiledOffset2D per 4 texels. 1152x640 = 184k -// offset computations, well under a millisecond. -bool WriteGrabToGuest(uint8_t* base, const uint8_t* src) { - constexpr uint32_t kW = RendererState::kBlurWidth; - constexpr uint32_t kH = RendererState::kBlurHeight; - static thread_local std::vector staging; - staging.resize(size_t(kW) * kH * 4); - for (uint32_t y = 0; y < kH; ++y) { - const uint32_t* row = reinterpret_cast( - src + size_t(y) * RendererState::kGrabRowPitch); - for (uint32_t x = 0; x < kW; x += 4) { - const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( - int32_t(x), int32_t(y), kW, 2)); - uint32_t* dst = reinterpret_cast(staging.data() + off); - for (uint32_t i = 0; i < 4; ++i) { - // R10G10B10A2 (R bits 0..9, G 10..19, B 20..29), top 8 of each -> - // little-endian byte order A,R,G,B = the guest's big-endian ARGB. - const uint32_t v = row[x + i]; - dst[i] = 0xFFu | ((v << 6) & 0xFF00u) | ((v << 4) & 0xFF0000u) | - ((v << 2) & 0xFF000000u); - } - } - } - return GuestTryCopy(base + kGrabGuestView, staging.data(), staging.size()); -} - -// The photo-grab window (see skate3_native_render_scene_photo_readback / -// photo_grab_native): while the photo editor is up OR a TakePhoto fired -// within the last few seconds, the game CPU-reads the resolved screenshot -// target from guest memory to JPEG-encode the photo. Two ways to fulfill it: -// - NATIVE (photo_grab_native, default): RenderScene writes the target -// itself from the native output; emulated-draw suppression stays ON, so -// the emulated pipeline never renders the scene+postfx concurrently (that -// concurrent 3x3-scale render was the editor's 40-50 fps, proven by the -// kFast run: zero readback drains, same fps). -// - EMULATED fallback: (a) arm the SDK's forced small-resolve CPU readback -// so the game's own resolve lands in guest memory, and (b) lift -// emulated-draw suppression so the passes that render the target execute. -// Runs every frame from RenderScene regardless of the yield decisions. A -// watchdog flips native -> emulated mid-window if no native grab write has -// landed recently (mode toggle, unexpected yield, readback failure). -void UpdatePhotoGrabWindow(uint8_t* base) { - // The grab target is 1152x640x4 = 0x2D0000 bytes (the same surface the - // SDK's Import-Skater special case reads); the thumb path is smaller. - // Framebuffer-sized resolves (0x384000 at 1280x720) stay excluded. - constexpr int32_t kForceReadbackMaxLength = 0x2D0000; - static bool s_armed = false; - static bool s_emulated_armed = false; - static bool s_burst_armed = false; - static int64_t s_arm_ns = 0; - static int64_t s_last_run_ns = 0; - static int32_t s_readback_saved = 0; - static bool s_suppress_saved = false; - static bool s_halfpx_saved = false; - static int32_t s_burst_max_saved = 0; - static bool s_burst_suppress_saved = false; - static bool s_burst_halfpx_saved = false; - static int64_t s_burst_open_ns = 0; - const int64_t now_ns = std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - const auto disarm_burst = [&] { - s_burst_armed = false; - if (s_burst_max_saved <= 0) { - REXCVAR_SET(native_render_force_resolve_readback_max_length, - s_burst_max_saved); - } - if (s_burst_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, true); - } - if (!s_burst_halfpx_saved) { - REXCVAR_SET(readback_resolve_half_pixel_offset, false); - } - }; - // This function only runs while the native renderer is producing frames - // (RenderScene). A gap means the native path was toggled off or yielding - // - grab writes legitimately paused, so give the watchdog a fresh grace - // period on resume instead of instantly tripping the fallback - // (an emulated/native comparison toggle on the display - // screen fired the watchdog 2 ms after switching back). - if (s_armed && s_last_run_ns != 0 && now_ns - s_last_run_ns > 300'000'000) { - s_arm_ns = now_ns; - } - s_last_run_ns = now_ns; - bool want = false; - if (REXCVAR_GET(skate3_native_render_scene_photo_readback)) { - want = PhotoEditorSignal(base) != nullptr; - if (!want) { - const int64_t last_ns = g_take_photo_last_ns.load(std::memory_order_relaxed); - if (last_ns >= 0) { - want = now_ns - last_ns < 3'000'000'000; - } - } - } - // Same window arms the photo postfx constant capture (CapturePfxConstants - // runs on the guest thread at the draw-done hook). - g_photo_flow_frame.store(want, std::memory_order_relaxed); - const auto arm_emulated = [&] { - s_readback_saved = REXCVAR_GET(native_render_force_resolve_readback_max_length); - if (s_readback_saved <= 0) { - REXCVAR_SET(native_render_force_resolve_readback_max_length, - kForceReadbackMaxLength); - } - s_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws); - if (s_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, false); - } - // Sample host-pixel CENTERS in the scaled->1x readback extraction while - // the window is armed (photo quality: undoes the D3D9 half-pixel shift - // that becomes a scale/2-pixel offset at scaled resolutions). - s_halfpx_saved = REXCVAR_GET(readback_resolve_half_pixel_offset); - if (!s_halfpx_saved) { - REXCVAR_SET(readback_resolve_half_pixel_offset, true); - } - s_emulated_armed = true; - }; - // Shader compilation goes SYNCHRONOUS for the whole photo flow; the - // menus lesson applied here: with async_shader_compilation on, the d3d12 - // command processor SKIPS any draw whose pipeline is still compiling, and - // the photo display screen's framed-card compose is a ONE-SHOT pass. - // Its shaders have been suppressed-cold in every native session (and the - // pipeline caches were invalidated by SDK rebuilds), so the compose's - // draws were skipped at the exact moment they finally ran, which is why - // the card stayed un-framed EVEN IN EMULATED rendering. - // One-time compile stalls during a photo UI moment are invisible. - static bool s_async_forced = false; - static bool s_async_saved = false; - if (want && !s_async_forced) { - s_async_saved = REXCVAR_GET(async_shader_compilation); - if (s_async_saved) { - REXCVAR_SET(async_shader_compilation, false); - REXLOG_INFO( - "native-scene: photo flow - shader compilation synchronous (the " - "one-shot card compose can't skip still-compiling draws)"); - } - s_async_forced = true; - } else if (!want && s_async_forced) { - if (s_async_saved) { - REXCVAR_SET(async_shader_compilation, true); - } - s_async_forced = false; - } - if (want && !s_armed) { - s_armed = true; - s_arm_ns = now_ns; - g_grab_last_write_ns.store(-1, std::memory_order_relaxed); - g_r.grab_writes = 0; - g_r.grab_cpu_us = 0; - // Drop any readback still pending from a previous window; its content - // is stale (pre-window) and must never land ahead of fresh frames. - g_r.grab_pending[0] = false; - g_r.grab_pending[1] = false; - if (REXCVAR_GET(skate3_native_render_scene_photo_grab_native) && - !g_r.grab_failed) { - g_photo_grab_native_armed.store(true, std::memory_order_relaxed); - REXLOG_INFO( - "native-scene: photo flow - NATIVE grab armed (suppression stays " - "on; native output CPU-tiled into the guest screenshot target; " - "display card yields to emulated; shutter burst on grab request)"); - } else { - arm_emulated(); - REXLOG_INFO( - "native-scene: photo flow - forcing small-resolve CPU readback " - "(the photo grab reads the resolved screenshot target from guest " - "memory){}", - s_suppress_saved ? " + emulated draw suppression OFF" : ""); - } - } else if (!want && s_armed) { - s_armed = false; - if (s_burst_armed) { - disarm_burst(); - } - if (g_photo_grab_native_armed.exchange(false, std::memory_order_relaxed)) { - REXLOG_INFO( - "native-scene: photo flow ended - native grab wrote {} frames " - "(avg tile+write {} us)", - g_r.grab_writes, - g_r.grab_writes ? g_r.grab_cpu_us / g_r.grab_writes : 0); - } - if (s_emulated_armed) { - s_emulated_armed = false; - if (s_readback_saved <= 0) { - REXCVAR_SET(native_render_force_resolve_readback_max_length, - s_readback_saved); - } - if (s_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, true); - } - if (!s_halfpx_saved) { - REXCVAR_SET(readback_resolve_half_pixel_offset, false); - } - REXLOG_INFO( - "native-scene: photo flow ended - resolve readback{} restored", - s_suppress_saved ? " + suppression" : ""); - } - } - // Watchdog: native mode armed but no grab write has landed within 700 ms - // of the window opening / the last write / the display yield ending; - // native frames stopped while the native output should be live - // (persistent yield, readback failure). Flip to the emulated fallback so - // the photo still works; one-way for the rest of this window. Skipped - // while the display-card yield is active: grab writes legitimately stop - // there (the photo was already taken and saved). - if (s_armed && !s_emulated_armed && - !g_photo_display_yielding.load(std::memory_order_relaxed) && - g_photo_grab_native_armed.load(std::memory_order_relaxed)) { - const int64_t last_write = g_grab_last_write_ns.load(std::memory_order_relaxed); - const int64_t yield_end = - g_photo_display_yield_end_ns.load(std::memory_order_relaxed); - const int64_t basis = std::max(std::max(last_write, s_arm_ns), yield_end); - if (now_ns - basis > 700'000'000) { - g_photo_grab_native_armed.store(false, std::memory_order_relaxed); - if (s_burst_armed) { - disarm_burst(); - } - arm_emulated(); - REXLOG_WARN( - "native-scene: photo flow - native grab produced no frames for " - "700 ms; falling back to the forced-readback window{}", - s_suppress_saved ? " + suppression lifted" : ""); - } - } - // SHUTTER BURST: the game builds the framed card as a dedicated frame - // sequence at the grab - request (hooked: OnPhotoGrabRequest) -> render - // the shot + card-composite passes -> resolve -> GrabScreenshot/ - // OnScreenShot CPU-read the resolves and CPU-compose the card texture. - // Under the native path those frames are suppressed and nothing lands in - // CPU memory, so the compose blends zeros = the un-framed card (proven: - // the control run's composite CPU copies all land inside this window; - // every post-hoc timing window missed it because the CONSUMER runs - // immediately at the shutter). For ~1.5 s from the request: suppression - // fully lifted + kFull readbacks up to 0x2D0000 (the full composite - // class; the handful of drains sits inside the shutter freeze the game - // already has). The emulated 0x04911000 resolve may overwrite the - // natively-written photo during the burst, same frozen scene, emulated - // 3x3 quality, the pre-native-grab standard, acceptable. - if (s_armed && !s_emulated_armed && - g_photo_grab_native_armed.load(std::memory_order_relaxed)) { - const int64_t req = g_photo_grab_request_ns.load(std::memory_order_relaxed); - // EVENT-DRIVEN close: GrabScreenshot COMPLETING (post-call stamp, - // OnPhotoGrabDone) means every remaining card-build step, the - // OnScreenShot compose included, is CPU-side work on memory the burst - // already copied. The burst therefore spans exactly request -> - // grab-done: the 1-3 shot frames, the physical minimum, all inside the - // game's own shutter freeze, instead of a timer running into the - // display fly-in (the user-visible lag spike). A 150 ms grace after - // grab-done covers the aux-target grab some flows issue on the - // following frame; the 1.5 s cap remains only as the fallback for a - // request that never grabs. - const int64_t done_ns = g_photo_grab_done_ns.load(std::memory_order_relaxed); - const bool grab_done = done_ns > req && now_ns - done_ns > 150'000'000; - const bool burst_want = - req >= 0 && now_ns - req < 1'500'000'000 && !grab_done; - if (burst_want && !s_burst_armed) { - s_burst_armed = true; - s_burst_max_saved = - REXCVAR_GET(native_render_force_resolve_readback_max_length); - if (s_burst_max_saved <= 0) { - REXCVAR_SET(native_render_force_resolve_readback_max_length, 0x2D0000); - } - s_burst_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws); - if (s_burst_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, false); - } - s_burst_halfpx_saved = REXCVAR_GET(readback_resolve_half_pixel_offset); - if (!s_burst_halfpx_saved) { - REXCVAR_SET(readback_resolve_half_pixel_offset, true); - } - s_burst_open_ns = now_ns; - REXLOG_INFO( - "native-scene: photo grab REQUEST - shutter burst OPEN " - "(suppression lifted + <=0x2D0000 kFull readbacks; the shot + " - "card-composite frames render and land in guest memory; closes " - "when the grab completes)"); - } else if (!burst_want && s_burst_armed) { - disarm_burst(); - REXLOG_INFO( - "native-scene: shutter burst closed after {} ms ({}) - " - "suppression + readback restored", - (now_ns - s_burst_open_ns) / 1'000'000, - grab_done ? "grab completed" : "1.5 s cap"); - } - } -} - -// Photo display-card screen: yield to the emulated output while the framed -// card is up (card-signature quad in the 2D stream, stamped by -// Publish2dDraws). The game composes the framed card in a ONE-SHOT -// pitch-1280 pass at an unpredictable moment after display-open -// (observed settle+0.8s to settle+4.0s); chasing it with -// suppression-lift + forced-readback windows never worked and cost -// seconds of concurrent-pipeline lag. Yielded-emulated, the compose and the -// display chain run GPU-side with no readbacks, exactly as on console, and -// with the native renderer idle the frozen scene renders at the emulated -// pipeline's own rate. A short PRE-ROLL lifts suppression while the native -// output still presents, so the emulated framebuffer has repainted (the -// card fly-in, live) before the swap, no stale-frame flash (the FMV-yield -// lesson). -bool YieldForPhotoDisplay() { - constexpr int64_t kCardGoneNs = 250'000'000; - constexpr int64_t kPreRollNs = 250'000'000; - static bool s_preroll = false; - static bool s_suppress_saved = false; - static int64_t s_preroll_ns = 0; - // Default OFF since the shutter burst was added: the framed card - // is CPU-composed into the card texture at the grab, and the native 2D - // replay samples that same texture; the display screen renders fully - // native (card + blurred backdrop + buttons). The yield remains as a - // safety hatch for display-screen regressions. - if (!REXCVAR_GET(skate3_native_render_scene_photo_display_yield)) { - if (s_preroll) { - s_preroll = false; - if (s_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, true); - } - if (g_photo_display_yielding.exchange(false, std::memory_order_relaxed)) { - g_photo_display_yield_end_ns.store( - std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(), - std::memory_order_relaxed); - } - } - return false; - } - const int64_t now_ns = std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - const int64_t seen = g_photo_card_seen_ns.load(std::memory_order_relaxed); - const bool card_up = g_photo_flow_frame.load(std::memory_order_relaxed) && - seen >= 0 && now_ns - seen < kCardGoneNs; - if (!card_up) { - if (s_preroll) { - s_preroll = false; - if (s_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, true); - } - if (g_photo_display_yielding.exchange(false, std::memory_order_relaxed)) { - g_photo_display_yield_end_ns.store(now_ns, std::memory_order_relaxed); - REXLOG_INFO( - "native-scene: photo display card gone - native output resumes " - "(suppression restored)"); - } - } - return false; - } - if (!s_preroll) { - s_preroll = true; - s_preroll_ns = now_ns; - s_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws); - if (s_suppress_saved) { - REXCVAR_SET(native_render_suppress_emulated_draws, false); - } - REXLOG_INFO( - "native-scene: photo display card up - emulated pre-roll " - "(suppression lifted; yielding to the emulated output in {} ms so " - "the game's own card compose + display chain render there)", - kPreRollNs / 1'000'000); - } - if (now_ns - s_preroll_ns < kPreRollNs) { - return false; - } - if (!g_photo_display_yielding.exchange(true, std::memory_order_relaxed)) { - REXLOG_INFO("native-scene: photo display - yielded to the emulated output"); - } - return true; -} - -bool YieldForPhotoEditor(uint8_t* base) { - // The native photo-fx chain (photo_fx.hlsl, photo_native cvar) takes - // precedence: the editor stays native and RenderScene applies the game's - // postfx as exact ported passes with live-captured constants. - const bool native_fx = REXCVAR_GET(skate3_native_render_scene_photo_native); - if (!native_fx && !REXCVAR_GET(skate3_native_render_scene_photo_yield)) { - return false; - } - static bool s_in_photo_editor = false; - const char* signal = PhotoEditorSignal(base); - const bool photo_active = signal != nullptr; - if (photo_active != s_in_photo_editor) { - s_in_photo_editor = photo_active; - if (photo_active) { - REXLOG_INFO( - "native-scene: photo editor - {} ({})", - native_fx ? "staying NATIVE (ported postfx chain)" - : "yielding to emulated output (the game's postfx applies " - "the photo effects)", - signal); - } else { - REXLOG_INFO("native-scene: photo editor closed"); - } - } - if (native_fx) { - return false; - } - return photo_active && REXCVAR_GET(skate3_native_render_scene_photo_yield); -} - -// Create-a-skater editor (the 'Edit Skater' screen: skater + garage wall, -// Skin/Clothing/Body Mods panels): a special FE renderer the native scene -// does not model; the skater draws with editor-only CAC shader variants -// (cacstamp_skin_nisPS / cac_cloth_nisPS / cac_face_nisPS...) whose constant -// layouts differ from gameplay (the cacstamp map shifted +1 row: light c10, -// key c16, SH c25..c33 scale c22.y, tint c24, alpha c23.x, measured -// in capture), so every char-lighting capture is rejected and -// the skater rendered legacy-shaded (grey tank, pale skin, black jeans). The -// editor also runs per-frame texture-space composite passes (cac*_unwrapPS -// paint the edited garment/skin art into textures) and its own DOF postfx; -// live-edit previews are only correct with the full emulated chain. Yield -// while it is up (photo-editor class: scene is a small frozen room, emulated -// performance is fine; no cache or takeover-gate side effects; native -// resumes the frame after the editor closes, and the un-suppressed yield -// window also lets the game re-render the team-box skater portrait RTT that -// follows an accepted edit). -// -// Detection: stateless per-frame poll of the FrontEndManager push-state -// stack (same struct as YieldForPhotoEditor above) for a record with screen -// id 15, surveyed across the 40 gsnaps on hand (gameplay, pause root 56, -// team screen 63, photo editor {1,11}, FMV): id 15 appears exactly in the -// CAS editor capture and nowhere else. -bool YieldForCasEditor(uint8_t* base) { - if (!REXCVAR_GET(skate3_native_render_scene_cas_yield)) { - return false; - } - static bool s_in_cas_editor = false; - const char* signal = nullptr; - { - constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; - uint32_t mgr = 0, beg = 0, end = 0; - if (GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) && mgr != 0 && - GuestTryLoadU32(base, mgr + 0x210, &beg) && - GuestTryLoadU32(base, mgr + 0x214, &end) && beg < end && - end - beg <= 20 * 16) { - const uint32_t n = (end - beg) / 20; - for (uint32_t i = 0; i < n && signal == nullptr; ++i) { - uint32_t f0 = 0; - if (GuestTryLoadU32(base, beg + i * 20, &f0) && f0 == 15) { - signal = "FE push-state id 15"; - } - } - } - } - if (signal == nullptr) { - // Shader heartbeat: the editor's own "_nis" pixel shaders were set - // within the last 0.5 s (see IsCasEditorPs): covers editor entry - // points that use a different FE screen id (the startup new-game flow - // stayed NATIVE on the FE detection alone). - const int64_t last_ns = g_cas_ps_last_ns.load(std::memory_order_relaxed); - if (last_ns >= 0) { - const int64_t now_ns = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - if (now_ns - last_ns < 500'000'000) { - signal = "CAS _nis shader heartbeat"; - } - } - } - const bool cas_active = signal != nullptr; - if (cas_active != s_in_cas_editor) { - s_in_cas_editor = cas_active; - if (cas_active) { - REXLOG_INFO( - "native-scene: create-a-skater editor - yielding to emulated " - "output ({}; editor CAC shading + live composite passes render " - "exactly there)", - signal); - } else { - REXLOG_INFO( - "native-scene: create-a-skater editor closed - native output " - "resumes"); - } - } - return cas_active; -} - -// Services the debug-dialog texture/mesh cache flushes. (The old -// retired-buffer drain / SRV-slot recycling is gone: deferred destruction -// is backend-internal now: Device::DestroyDeferred.) -void ReleaseRetiredAndFlushCaches(const NativeGuestOutputRenderContext& context) { - // Debug-dialog cache flushes: retire every cached decode (freed once the - // GPU is done with the current submission) so hot-toggled decode settings - // rebuild the world with the new rules this frame. - if (g_flush_textures.exchange(false, std::memory_order_relaxed)) { - const uint64_t submission = context.device->CurrentSubmission(); - for (auto& [key, t] : g_r.tex_store) { - RetireGuestTexture(t, submission); - } - g_r.tex_store.clear(); - g_r.tex_routes.clear(); - g_r.words_sticky.clear(); - g_r.tex_sticky.clear(); - g_r.tex_pending_first.clear(); - REXLOG_INFO("native-scene: texture cache flushed (debug dialog)"); - } - if (g_flush_meshes.exchange(false, std::memory_order_relaxed)) { - for (auto& [key, m] : g_r.meshes) { - if (m.vb) g_r.device->DestroyDeferred(m.vb); - if (m.ib) g_r.device->DestroyDeferred(m.ib); - } - g_r.meshes.clear(); - ClearItemCache(); // decode-affecting toggles should re-walk items too - REXLOG_INFO("native-scene: mesh cache flushed (debug dialog)"); - } -} - - // ---- Dynamic-shadow atlas pass ---- - // Renders the frame's dynamic casters (skinned characters + rigid - // non-identity-world props: exactly the game's caster list) into the - // three cascade tiles with the captured light rows, then applies the - // game's coverage blur + depth dilation. Runs before the main pass so the - // scene shader can sample the finished atlas. -bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context, - const FrameScene& scene, uint32_t bone_region, - int32_t debug_mode, uint32_t* out_draws) { - nrhi::Cmd* cmd = context.cmd; - const float* sh = scene.shadow_rows; - bool shadow_ready = false; - uint32_t shadow_draws = 0; - // Atlas-readback bookkeeping: write completed dumps to disk in frame - // order; re-arm once the recording session that requested them ends. - if (!g_recording.load(std::memory_order_relaxed) && - g_r.shadow_dump_written == g_r.shadow_dump_enqueued) { - g_r.shadow_dump_done = false; - g_r.shadow_dump_enqueued = 0; - g_r.shadow_dump_written = 0; - } - while (g_r.shadow_dump_written < g_r.shadow_dump_enqueued && - context.device->CompletedSubmission() > - g_r.shadow_dump_submission[g_r.shadow_dump_written]) { - const uint32_t k = g_r.shadow_dump_written; - const uint32_t tile_px = g_r.shadow_tile; - const uint32_t pitch = tile_px * 3u * 4u; - const uint64_t plane = uint64_t(pitch) * tile_px; - context.device->InvalidateForRead(g_r.shadow_dump_buf[k], 0, plane * 2); - std::string dir = REXCVAR_GET(skate3_native_render_snapshot_dir); - if (dir.empty()) { - dir = "native_render_snapshots"; - } - std::error_code ec; - std::filesystem::create_directories(dir, ec); - const std::filesystem::path path = - std::filesystem::path(dir) / - ("shadow_atlas_" + std::to_string(uint64_t(std::time(nullptr))) + "_f" + - std::to_string(k) + "_" + std::to_string(tile_px * 3) + "x" + - std::to_string(tile_px) + "_rg16.bin"); - std::ofstream out(path, std::ios::binary); - out.write(reinterpret_cast(g_r.shadow_dump_ptr[k]), - std::streamsize(plane * 2)); - REXLOG_INFO( - "native-scene: shadow atlas frame {} dumped (raw+blurred planes) -> {}", - k, path.string()); - ++g_r.shadow_dump_written; - } - if (REXCVAR_GET(skate3_native_render_scene_shadows) && scene.shadow_valid && - g_r.shadow_raw != nullptr && g_r.pso_shadow_caster != nullptr && - g_r.pso_shadow_blur != nullptr && debug_mode == 0) { - struct Caster { - const DrawItem* item; - uint32_t bone_offset; - bool bones; - }; - std::vector casters; - for (const DrawItem& item : scene.items) { - if (item.transparent || item.unlit || item.cloth_quads) { - continue; - } - const bool skinned = item.skinned && !item.bones.empty(); - // The game's CSM casts only DYNAMIC content: characters/vehicles - // (skinned), Ropa cloth, and dynamicobject props (the truck's caster - // draws, F10-verified). Static world scenery has its shadows BAKED - // into the lightmaps, and that includes world-PLACED instances - // (streetlights, trees, rails), which carry real world transforms. - // The old identity-matrix test let every placed prop into the caster - // pass, painting phantom streetlight-head/tree silhouettes onto the - // plaza glass towers (a "reflection-like" soft dark blob high on the - // facade, geometrically impossible as a real shadow: sun at 47 deg, - // lamp 8 m tall, blob at 96 m; the emulated frame has no such - // shadow). - if (!skinned && item.dynobj == 0 && !item.ropa && item.char_family == 0) { - continue; - } - // Entities the game is holding invisible (spawn settle / distance - // fade, see CharFadeAlpha) must not paint a shadow either; the - // emulated frame shows neither the NPC nor a blob under it. - if (item.char_family != 0 && CharFadeAlpha(item) < 0.05f && - REXCVAR_GET(skate3_native_render_scene_entity_fade)) { - continue; - } - // Per-piece caster parity: the game's own shadow passes skip some - // character pieces (the CAS trucker hat casts NOTHING; natively - // casting it painted a hard low-sun brim band across the editor face - // that the emulated frame never shows). Cast only what the game - // submitted through an ortho bank this frame (see - // DrawItem::shadow_caster / g_frame_ortho_ctx). - if (item.char_family != 0 && !item.shadow_caster && - REXCVAR_GET(skate3_native_render_scene_shadow_caster_parity)) { - continue; - } - // NO inline decode here (this block used to re-decode every cloth - // garment every frame, ~2.9 ms, the 160 fps cap during real play). - // The dyn decode jobs / worker miss queue keep the cache fresh, one - // frame behind the sim; a first-sight caster shadows 1-2 frames late. - if (!g_r.meshes.contains(item.mesh)) { - continue; - } - Caster c{&item, 0, false}; - if (skinned) { - const uint32_t bytes = uint32_t(item.bones.size() * sizeof(float)); - const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u; - if (offset + bytes > RendererState::kBoneRegionSize) { - continue; - } - std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.bones.data(), bytes); - g_r.bone_ring_offset = offset + bytes; - c.bone_offset = offset; - c.bones = true; - } - casters.push_back(c); - } - if (!casters.empty()) { - if (g_r.shadow_in_srv_state) { - for (nrhi::Texture* res : {g_r.shadow_raw, g_r.shadow_mid, g_r.shadow_final}) { - cmd->Barrier(res, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - } - cmd->FlushBarriers(); - g_r.shadow_in_srv_state = false; - } - const uint32_t tile = g_r.shadow_tile; - const float shadow_clear[4] = {1.0f, 1.0f, 0.0f, 0.0f}; - cmd->ClearRenderTarget(g_r.shadow_raw, shadow_clear); - cmd->SetRenderTargets(g_r.shadow_raw, nullptr); - cmd->SetBindingLayout(g_r.layout); - cmd->SetPipeline(g_r.pso_shadow_caster); - // Unused by the caster shaders, but never leave root CBVs unset. - cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); - for (int ci = 0; ci < 3; ++ci) { - // Cascade scale/offset (cascade 0 = identity; PS c1/c2 for 1/2). - float sx = 1.0f, sy = 1.0f, ox = 0.0f, oy = 0.0f; - if (ci == 1) { - sx = sh[4]; sy = sh[5]; ox = sh[6]; oy = sh[7]; - } else if (ci == 2) { - sx = sh[8]; sy = sh[9]; ox = sh[10]; oy = sh[11]; - } - // Light view-proj, row-vector convention: clip.x = ls_i.x, - // clip.y = ls_i.y, clip.z = the height-ramp depth, clip.w = 1, - // columns built from the receiver rows c0/c3/c4. - float lightvp[16]; - for (int r = 0; r < 3; ++r) { - lightvp[r * 4 + 0] = sh[0 + r] * sx; - lightvp[r * 4 + 1] = sh[12 + r] * sy; - lightvp[r * 4 + 2] = sh[16 + r]; - lightvp[r * 4 + 3] = 0.0f; - } - lightvp[12] = sh[3] * sx + ox; - lightvp[13] = sh[15] * sy + oy; - lightvp[14] = sh[19]; - lightvp[15] = 1.0f; - cmd->SetViewport(nrhi::Viewport{float(tile) * ci, 0.0f, float(tile), - float(tile), 0.0f, 1.0f}); - cmd->SetScissor(nrhi::Rect{int32_t(tile) * ci, 0, - int32_t(tile) * (ci + 1), int32_t(tile)}); - for (const Caster& c : casters) { - auto mit = g_r.meshes.find(c.item->mesh); - if (mit == g_r.meshes.end()) { - continue; // undecodable this frame; casts once the workers land - } - // A stale fingerprint is fine here: cloth decodes ride one frame - // behind the sim by design (dyn decode jobs). - float constants[52] = {}; - std::memcpy(constants, c.item->world, sizeof(c.item->world)); - float* mvp = constants + 16; - for (int r = 0; r < 4; ++r) { - for (int col = 0; col < 4; ++col) { - float sum = 0.0f; - for (int k = 0; k < 4; ++k) { - sum += c.item->world[r * 4 + k] * lightvp[k * 4 + col]; - } - mvp[r * 4 + col] = sum; - } - } - constants[33] = c.bones ? 1.0f : 0.0f; // tint.g = skinned branch - cmd->SetRootConstants(0, 52, constants, 0); - cmd->SetBufferSrv(3, g_r.bone_ring, - bone_region + (c.bones ? c.bone_offset : 0)); - cmd->SetVertexBuffer(mit->second.vb_view.buffer, - mit->second.vb_view.offset, - mit->second.vb_view.size_bytes, - mit->second.vb_view.stride); - cmd->SetIndexBuffer(mit->second.ib_view.buffer, - mit->second.ib_view.offset, - mit->second.ib_view.size_bytes); - for (const DrawEntry& draw : c.item->draws) { - if (draw.prim == 4) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - } else if (draw.prim == 6) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); - } else { - continue; - } - cmd->DrawIndexed(draw.index_count, draw.start_index, - draw.base_vertex); - ++shadow_draws; - } - } - } - // Blur/convert chain: raw -> (hblur) -> mid -> (vblur) -> final. The - // game's kernels: 5-tap Gaussian coverage cascade 0, 3-tap cascade 1, - // format-convert only for cascade 2 (weights (1,0,0), 0 taps), plus - // depth dilation into the penumbra. One fullscreen-triangle draw per - // tile per direction, taps clamped inside the tile. - cmd->Barrier(g_r.shadow_raw, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetPipeline(g_r.pso_shadow_blur); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - const float kernels[3][3] = {{0.292082f, 0.233881f, 0.120078f}, - {0.667243f, 0.166379f, 0.0f}, - {1.0f, 0.0f, 0.0f}}; - const float ntaps[3] = {2.0f, 1.0f, 0.0f}; - const auto blur_pass = [&](int ci, bool horizontal, - nrhi::TextureView* src, nrhi::Texture* dst, - bool src_raw) { - cmd->SetRenderTargets(dst, nullptr); - cmd->SetViewport(nrhi::Viewport{float(tile) * ci, 0.0f, float(tile), - float(tile), 0.0f, 1.0f}); - cmd->SetScissor(nrhi::Rect{int32_t(tile) * ci, 0, - int32_t(tile) * (ci + 1), int32_t(tile)}); - // Blur tap offsets step RASTER texels, matching the emulated GPU: - // the game's blur PS taps via tfetch integer offsets (OffsetX/Y - // -2..+2), which a resolution-scaled - // raster applies in physical texels, so the penumbra NARROWS as the - // tile grows (the emulated baseline is sharper than original - // hardware; 512 reproduces the softer original-360 look). Scaling - // the step by tile/512 to hold a console-width penumbra was tried - // and made receivers visibly blurrier than the emulated reference. - const float bc[12] = {horizontal ? 1.0f : 0.0f, horizontal ? 0.0f : 1.0f, - ntaps[ci], src_raw ? 1.0f : 0.0f, - kernels[ci][0], kernels[ci][1], kernels[ci][2], 0.0f, - float(tile) * ci, float(tile) * (ci + 1) - 1.0f, - float(tile) - 1.0f, 0.0f}; - cmd->SetRootConstants(0, 12, bc, 0); - cmd->SetTexture(1, src); - cmd->Draw(3, 0); - }; - for (int ci = 0; ci < 3; ++ci) { - blur_pass(ci, true, g_r.shadow_srv_raw, g_r.shadow_mid, true); - } - cmd->Barrier(g_r.shadow_mid, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - for (int ci = 0; ci < 3; ++ci) { - blur_pass(ci, false, g_r.shadow_srv_mid, g_r.shadow_final, false); - } - cmd->Barrier(g_r.shadow_final, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - g_r.shadow_in_srv_state = true; - shadow_ready = shadow_draws > 0; - // Atlas readback on kShadowDumpFrames consecutive frames per - // recording session (see the completion handler at function entry): - // per frame, raw plane at offset 0, blurred plane after it. Each - // frame gets its own readback buffer so no frame waits on a copy. - if (g_recording.load(std::memory_order_relaxed) && - !g_r.shadow_dump_done && - g_r.shadow_dump_enqueued < RendererState::kShadowDumpFrames) { - const uint32_t k = g_r.shadow_dump_enqueued; - const uint32_t pitch = tile * 3u * 4u; - const uint64_t plane = uint64_t(pitch) * tile; - if (g_r.shadow_dump_buf[k] == nullptr) { - nrhi::BufferDesc desc; - desc.size = plane * 2; - desc.heap = nrhi::HeapKind::kReadback; - g_r.shadow_dump_buf[k] = context.device->CreateBuffer(desc); - g_r.shadow_dump_ptr[k] = - g_r.shadow_dump_buf[k] != nullptr - ? static_cast( - context.device->Map(g_r.shadow_dump_buf[k])) - : nullptr; - } - if (g_r.shadow_dump_ptr[k] != nullptr) { - cmd->Barrier(g_r.shadow_raw, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kCopySource); - cmd->Barrier(g_r.shadow_final, - nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kCopySource); - cmd->FlushBarriers(); - cmd->CopyTextureToBuffer(g_r.shadow_dump_buf[k], 0, pitch, - g_r.shadow_raw, 0, tile * 3, tile); - cmd->CopyTextureToBuffer(g_r.shadow_dump_buf[k], plane, pitch, - g_r.shadow_final, 0, tile * 3, tile); - cmd->Barrier(g_r.shadow_raw, nrhi::ResourceState::kCopySource, - nrhi::ResourceState::kPixelShaderResource); - cmd->Barrier(g_r.shadow_final, nrhi::ResourceState::kCopySource, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - g_r.shadow_dump_submission[k] = context.device->CurrentSubmission(); - ++g_r.shadow_dump_enqueued; - if (g_r.shadow_dump_enqueued == RendererState::kShadowDumpFrames) { - g_r.shadow_dump_done = true; - } - } else { - g_r.shadow_dump_done = true; // allocation failed; don't retry - } - } - } - } - // ---- dynamicobject static world-shadow map ---- - // The game's props sample a 512x512 baked-shade depth map (tf1) the guest - // never renders in native mode (its memory stays zeroed). Re-render it - // natively from the frame's STATIC world items with the captured c5/c6/c7 - // projection, ps_shadow_caster convention (x = light-space depth, MIN - // blend). The map ACCUMULATES across frames without clearing: the item - // list is view-culled, and dropping off-screen casters would flicker - // props' shade with the camera; static geometry seen once stays in the - // map until the transform changes (streaming region switch), which - // re-primes it. - if (g_r.world_shadow != nullptr && g_r.pso_shadow_caster != nullptr && - debug_mode == 0 && scene.dynobj_ws_valid && - REXCVAR_GET(skate3_native_render_scene_shadows) && - REXCVAR_GET(skate3_native_render_scene_dynobj_v2)) { - bool changed = !g_r.world_shadow_primed; - for (int k = 0; k < 12 && !changed; ++k) { - changed = std::fabs(scene.dynobj_ws[k] - g_r.world_shadow_rows[k]) > - 1e-4f * std::max(1.0f, std::fabs(g_r.world_shadow_rows[k])); - } - static uint64_t ws_pass_counter = 0; - ++ws_pass_counter; - // Accumulation cadence: a full static-item pass every 4th frame keeps - // newly streamed / newly visible geometry flowing into the map at - // negligible steady-state cost; a transform change renders immediately. - if (changed || (ws_pass_counter & 3u) == 0) { - if (g_r.world_shadow_in_srv) { - cmd->Barrier(g_r.world_shadow, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - g_r.world_shadow_in_srv = false; - } - if (changed) { - const float ws_clear[4] = {1.0f, 1.0f, 0.0f, 0.0f}; - cmd->ClearRenderTarget(g_r.world_shadow, ws_clear); - std::memcpy(g_r.world_shadow_rows, scene.dynobj_ws, - sizeof(g_r.world_shadow_rows)); - if (!g_r.world_shadow_primed) { - REXLOG_INFO( - "native-scene: world-shadow map primed (dynobj c5/c6/c7 rows)"); - } - g_r.world_shadow_primed = true; - } - // View-proj columns = the game's own projection rows: clip.x/y/z = - // dot(wp4, c5/c6/c7), clip.w = 1 (same construction as the CSM - // lightvp above). - float wsvp[16]; - for (int r = 0; r < 3; ++r) { - wsvp[r * 4 + 0] = scene.dynobj_ws[r]; - wsvp[r * 4 + 1] = scene.dynobj_ws[4 + r]; - wsvp[r * 4 + 2] = scene.dynobj_ws[8 + r]; - wsvp[r * 4 + 3] = 0.0f; - } - wsvp[12] = scene.dynobj_ws[3]; - wsvp[13] = scene.dynobj_ws[7]; - wsvp[14] = scene.dynobj_ws[11]; - wsvp[15] = 1.0f; - cmd->SetRenderTargets(g_r.world_shadow, nullptr); - cmd->SetBindingLayout(g_r.layout); - cmd->SetPipeline(g_r.pso_shadow_caster); - cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); - const float ws_size = float(RendererState::kWorldShadowSize); - cmd->SetViewport(nrhi::Viewport{0.0f, 0.0f, ws_size, ws_size, 0.0f, 1.0f}); - cmd->SetScissor(nrhi::Rect{0, 0, int32_t(RendererState::kWorldShadowSize), - int32_t(RendererState::kWorldShadowSize)}); - for (const DrawItem& item : scene.items) { - // STATIC world geometry only: the game's map bakes buildings/trees/ - // ground. Dynamic content (characters, vehicles, cloth, movable - // props) casts through the CSM instead, accumulating a movable - // prop here would freeze its shade at a stale position. - if (item.transparent || item.unlit || item.cloth_quads || item.water || - item.skinned || item.ropa || item.char_family != 0 || - item.dynobj != 0) { - continue; - } - auto mit = g_r.meshes.find(item.mesh); - if (mit == g_r.meshes.end()) { - continue; - } - float constants[52] = {}; - std::memcpy(constants, item.world, sizeof(item.world)); - float* mvp = constants + 16; - for (int r = 0; r < 4; ++r) { - for (int col = 0; col < 4; ++col) { - float sum = 0.0f; - for (int k = 0; k < 4; ++k) { - sum += item.world[r * 4 + k] * wsvp[k * 4 + col]; - } - mvp[r * 4 + col] = sum; - } - } - cmd->SetRootConstants(0, 52, constants, 0); - cmd->SetBufferSrv(3, g_r.bone_ring, bone_region); - cmd->SetVertexBuffer(mit->second.vb_view.buffer, - mit->second.vb_view.offset, - mit->second.vb_view.size_bytes, - mit->second.vb_view.stride); - cmd->SetIndexBuffer(mit->second.ib_view.buffer, - mit->second.ib_view.offset, - mit->second.ib_view.size_bytes); - for (const DrawEntry& draw : item.draws) { - if (draw.prim == 4) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - } else if (draw.prim == 6) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); - } else { - continue; - } - cmd->DrawIndexed(draw.index_count, draw.start_index, draw.base_vertex); - } - } - cmd->Barrier(g_r.world_shadow, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - g_r.world_shadow_in_srv = true; - } - } - *out_draws = shadow_draws; - return shadow_ready; -} - - // Selection-outline mask (see kOutlineShaderSource): re-render the frame's - // selected items into the small R8 target while the scene pass state is - // still bound. The edge composite runs after the resolve, on the - // single-sample output. -bool RenderOutlineMask(const NativeGuestOutputRenderContext& context, - const FrameScene& scene, const nrhi::Viewport& viewport, - const nrhi::Rect& scissor, bool msaa_on, - nrhi::Texture* scene_color, nrhi::Texture* scene_depth, - bool use_depth) { - nrhi::Cmd* cmd = context.cmd; - bool outline_ready = false; - if (REXCVAR_GET(skate3_native_render_scene_selection_outline) && - g_r.pso_outline_mask != nullptr && g_r.pso_outline_edge != nullptr && - g_r.outline_mask != nullptr) { - std::vector sel; - for (const DrawItem& item : scene.items) { - // Skinned items are excluded: the mask VS runs the rigid path (world - // matrix), which renders a skinned mesh at bind pose at the origin. - if (item.selected && !item.skinned) { - sel.push_back(&item); - } - } - if (!sel.empty()) { - const float mask_clear[4] = {0.0f, 0.0f, 0.0f, 0.0f}; - cmd->ClearRenderTarget(g_r.outline_mask, mask_clear); - cmd->SetRenderTargets(g_r.outline_mask, nullptr); - cmd->SetViewport(nrhi::Viewport{0.0f, 0.0f, - float(g_r.outline_mask_width), - float(g_r.outline_mask_height), 0.0f, - 1.0f}); - cmd->SetScissor(nrhi::Rect{0, 0, int32_t(g_r.outline_mask_width), - int32_t(g_r.outline_mask_height)}); - cmd->SetPipeline(g_r.pso_outline_mask); - for (const DrawItem* item : sel) { - auto mit = g_r.meshes.find(item->mesh); - if (mit == g_r.meshes.end() || mit->second.fingerprint != item->fingerprint) { - continue; // decoded by the main pass this frame; masks from the next - } - float constants[52] = {}; - std::memcpy(constants, item->world, sizeof(item->world)); - float* mvp = constants + 16; - for (int r = 0; r < 4; ++r) { - for (int c = 0; c < 4; ++c) { - float sum = 0.0f; - for (int k = 0; k < 4; ++k) { - sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c]; - } - mvp[r * 4 + c] = sum; - } - } - // tint = (1, 0, 0, 1): the scene PS's solid-color early-out - // (tint.a > 0) writes 1.0 into the R8 mask; tint.g = 0 keeps the VS - // skinning branch off. - constants[32] = 1.0f; - constants[35] = 1.0f; - cmd->SetRootConstants(0, 52, constants, 0); - cmd->SetVertexBuffer(mit->second.vb_view.buffer, - mit->second.vb_view.offset, - mit->second.vb_view.size_bytes, - mit->second.vb_view.stride); - cmd->SetIndexBuffer(mit->second.ib_view.buffer, - mit->second.ib_view.offset, - mit->second.ib_view.size_bytes); - for (const DrawEntry& draw : item->draws) { - if (draw.prim == 4) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - } else if (draw.prim == 6) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); - } else { - continue; - } - cmd->DrawIndexed(draw.index_count, draw.start_index, - draw.base_vertex); - outline_ready = true; - } - } - // Restore the pass state the resolve/2D paths rely on (fullscreen - // viewport; the non-MSAA path keeps rendering into the scene target). - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - if (!msaa_on) { - cmd->SetRenderTargets(scene_color, use_depth ? scene_depth : nullptr); - } - if (outline_ready) { - cmd->Barrier(g_r.outline_mask, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - } - } - } - return outline_ready; -} - -// Selection-outline composite: additive stencil-edge-detect over the -// resolved output (before the popup blur, like the game's postfx order). -void RenderOutlineComposite(const NativeGuestOutputRenderContext& context, - const FrameScene& scene, nrhi::Texture* output, - const nrhi::Viewport& viewport, - const nrhi::Rect& scissor) { - nrhi::Cmd* cmd = context.cmd; - cmd->FlushBarriers(); - cmd->SetRenderTargets(output, nullptr); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetPipeline(g_r.pso_outline_edge); - cmd->SetRootConstants(0, 4, scene.outline_color, 0); - cmd->SetTexture(1, g_r.outline_mask_srv); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - cmd->Draw(3, 0); - // Back to the mask's steady state for the next frame. - cmd->Barrier(g_r.outline_mask, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); -} - -// 600-frame perf + telemetry log lines (verbatim from the former tail of -// RenderScene). -void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, - uint32_t drawn_2d, uint32_t drawn_spline, bool shadow_ready, - uint32_t shadow_draws) { - if (frames % 600 == 0 && REXCVAR_GET(skate3_native_render_scene_perf_log)) { - // CPU-side perf snapshot for this 600-frame window. guest_fps is derived - // from the guest frame interval; capture/build run on the guest render - // thread (they extend guest frame time directly), render/items/shadow on - // the command processor thread, decode inline on the render thread - // (count = decodes this window; max = the worst single decode). - const double guest_dt_ms = g_pw_guest_dt.AvgMs(); - REXLOG_INFO( - "native-scene perf: guest_fps={:.0f} guest_dt_max={:.1f}ms " - "capture={:.2f}/{:.2f}ms build={:.2f}/{:.2f}ms " - "bld[2d={:.2f}/{:.2f} spl={:.2f}/{:.2f} pal={:.2f}/{:.2f}]ms " - "dt[{}|{}|{}|{}|{}] ptail={:.2f}/{:.2f}ms | render={:.2f}/{:.2f}ms " - "items={:.2f}/{:.2f}ms shadow={:.2f}/{:.2f}ms " - "pre={:.2f}/{:.2f}ms settle[{:.2f}/{:.2f}ms n={} dec={} def={}] " - "tail={:.2f}/{:.2f}ms twod={:.2f}/{:.2f}ms " - "decode[mesh n={} avg={:.2f} max={:.2f}ms tex n={} avg={:.2f} max={:.2f}ms] " - "commit={:.2f}/{:.2f}ms itemcache[hit={} build={}] cam[chg={} rep={} maxstreak={}]", - guest_dt_ms > 0.0 ? 1000.0 / guest_dt_ms : 0.0, g_pw_guest_dt.MaxMs(), - g_pw_capture.AvgMs(), g_pw_capture.MaxMs(), g_pw_build.AvgMs(), - g_pw_build.MaxMs(), g_pw_b2d.AvgMs(), g_pw_b2d.MaxMs(), g_pw_bspl.AvgMs(), - g_pw_bspl.MaxMs(), g_pw_bpal.AvgMs(), g_pw_bpal.MaxMs(), - g_dt_hist[0].exchange(0, std::memory_order_relaxed), - g_dt_hist[1].exchange(0, std::memory_order_relaxed), - g_dt_hist[2].exchange(0, std::memory_order_relaxed), - g_dt_hist[3].exchange(0, std::memory_order_relaxed), - g_dt_hist[4].exchange(0, std::memory_order_relaxed), - g_pw_pal_tail.AvgMs(), g_pw_pal_tail.MaxMs(), - g_pw_render.AvgMs(), g_pw_render.MaxMs(), - g_pw_items.AvgMs(), g_pw_items.MaxMs(), g_pw_shadow.AvgMs(), - g_pw_shadow.MaxMs(), g_pw_pre.AvgMs(), g_pw_pre.MaxMs(), - g_pw_settle.AvgMs(), g_pw_settle.MaxMs(), - g_pw_settle.count.load(std::memory_order_relaxed), - g_warm_decodes.exchange(0, std::memory_order_relaxed), - g_warm_deferred.exchange(0, std::memory_order_relaxed), - g_pw_tail.AvgMs(), g_pw_tail.MaxMs(), g_pw_2d.AvgMs(), g_pw_2d.MaxMs(), - g_pw_mesh_decode.count.load(std::memory_order_relaxed), - g_pw_mesh_decode.AvgMs(), g_pw_mesh_decode.MaxMs(), - g_pw_tex_decode.count.load(std::memory_order_relaxed), g_pw_tex_decode.AvgMs(), - g_pw_tex_decode.MaxMs(), g_pw_commit.AvgMs(), g_pw_commit.MaxMs(), - g_item_cache_hits.exchange(0, std::memory_order_relaxed), - g_item_cache_builds.exchange(0, std::memory_order_relaxed), - g_cam_changes.exchange(0, std::memory_order_relaxed), - g_cam_repeats.exchange(0, std::memory_order_relaxed), - g_cam_max_streak.exchange(0, std::memory_order_relaxed)); - for (PerfWindow* w : {&g_pw_guest_dt, &g_pw_capture, &g_pw_build, &g_pw_pal_tail, - &g_pw_b2d, &g_pw_bspl, &g_pw_bpal, - &g_pw_render, &g_pw_items, &g_pw_shadow, &g_pw_pre, - &g_pw_settle, &g_pw_tail, &g_pw_2d, &g_pw_mesh_decode, - &g_pw_tex_decode, &g_pw_commit}) { - w->Reset(); - } - // Refill the settle offender-log + slow-frame budgets for the next window. - g_warm_mesh_log_budget.store(4, std::memory_order_relaxed); - g_warm_tex_log_budget.store(4, std::memory_order_relaxed); - g_slow_frame_log_budget.store(3, std::memory_order_relaxed); - } - if (frames % 600 == 0) { - uint32_t lw_ctxs = 0, lw_ents = 0; - skate3::native_lw::QueryLwStats(&lw_ctxs, &lw_ents); - REXLOG_INFO( - "native-scene: frame {} items={} draws={} draws_2d={} drawn_2d={} " - "splines[{}/{}] " - "2d[other={} dropped={} askip={} astale={} textures={}] cached_meshes={} textures={} " - "vs_uploads={} palettes={} palette_base_plus1={} ropa[rigid={} stale={} rescued={} relax={} caster={} incoh={} stretch={} blend={} blendmiss={}] dyn_gap={} skinned={} skinned_skipped={} foreign_bank={} " - "rigid[pending={} dropped={} worldprops={}] " - "rej[dyn={} range={} chain={} geom={} draws={} bbox={}] " - "rr[decode_fail={} no_bones={} mesh_deferred={} tex_deferred={}] " - "store[n={} routes={} evict={}] " - "heal[vfail={} demote={}] serve[sticky={} skipnew={} adstale={} adnone={}] " - "shadow[valid={} ready={} draws={}] char[attempt={} valid={} drawn={} " - "reused={}] dynobj[valid={} drawn={}] " - "lw[ctxs={} ents={} stamp={} fade0={} resc={} fill={} pal={} rows={}] " - "refl[pair={} flat={} gate={:#x}] flips={} alt[bones={} shadow={}] " - "rows_inst={} pal_srv={}", - frames, scene.items.size(), drawn, g_draws_2d.load(), drawn_2d, - drawn_spline, g_draws_spline.load(), - g_draws_2d_other.load(), g_draws_2d_dropped.load(), - g_2d_async_skip.load(), g_2d_async_stale.load(), g_r.tex_store.size(), - g_r.meshes.size(), g_r.tex_store.size(), - g_vs_uploads.load(), g_palette_snapshots.load(), g_palette_base_plus1.load(), - g_ropa_rigid.load(), g_ropa_stale.load(), g_ropa_rescued.load(), - g_ropa_relaxed.load(), g_ropa_caster.load(), g_pub_incoherent.load(), - g_stretch_veto.load(), - g_ropa_blend_drawn.load(), g_ropa_blend_miss.load(), g_dyn_gap.load(), - g_skinned_items.load(), - g_skinned_skipped.load(), g_capture_foreign_bank.load(), - g_rigid_pending.load(), g_rigid_dropped.load(), - g_world_props.load(), - g_rej_no_dynstate.load(), g_rej_dyn_range.load(), - g_rej_chain.load(), g_rej_geom.load(), g_rej_draws.load(), g_rej_bbox.load(), - g_rr_decode_fail.load(), g_rr_no_bones.load(), g_rr_mesh_deferred.load(), - g_rr_tex_deferred.load(), g_r.tex_store.size(), g_r.tex_routes.size(), - g_store_evicted.load(), g_heal_verify_fail.load(), - g_demote_hold.load(), - g_tex_sticky_served.load(), g_skip_new.load(), g_ad_stale_served.load(), - g_ad_placeholder.load(), scene.shadow_valid, - shadow_ready, shadow_draws, - g_char_attempts.load(), g_char_valid.load(), g_char_drawn.load(), - g_char_rows_reused.load(), scene.dynobj_valid, - g_dynobj_drawn.load(), lw_ctxs, lw_ents, g_lw_stamped.load(), - g_lw_fade0.load(), g_lw_ctx_rescued.load(), g_lw_gap_filled.load(), - g_lw_pal_sub.load(), g_lw_rows_served.load(), - g_refl_pair.load(), g_refl_flat.load(), g_refl_gate.load(), - g_hair_route_flips.load(), g_hair_bone_alternations.load(), - g_shadow_alternations.load(), g_char_rows_inst_served.load(), - g_pal_served_total.load()); - } -} - -bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_data*/) { - if (!SceneEnabled() || - (context.backend != NativeGuestOutputBackend::kD3D12 && - context.backend != NativeGuestOutputBackend::kVulkan)) { - return false; - } - // While the game reports menus / loading (presence context 0x8001 == 0), - // yield to the emulated output, EXCEPT the in-game pause menu (world - // still publishing perspective scenes), which stays native when - // skate3_native_render_scene_pause_native is on: the pause UI rides the - // same captured-APT 2D replay as the gameplay HUD, and the backdrop is the - // ordinary native world. Loading screens and the boot frontend still - // render emulated (complete and correct there). - // The photo-grab readback window runs before any yield decision so it - // updates every frame in every mode (the grab must work whether the photo - // flow renders yielded-emulated or native). - uint8_t* base = g_guest_base.load(std::memory_order_relaxed); - if (base != nullptr) { - UpdatePhotoGrabWindow(base); - } - if (YieldForMenus(context)) { - return false; - } - if (base == nullptr) { - return false; - } - if (YieldForPhotoDisplay()) { - return false; - } - if (YieldForPhotoEditor(base)) { - return false; - } - if (YieldForCasEditor(base)) { - return false; - } - - const auto render_t0 = PerfClock::now(); - const auto perf_ns_since = [](PerfClock::time_point t0) { - return uint64_t( - std::chrono::duration_cast(PerfClock::now() - t0) - .count()); - }; - - bool loading_native = g_loading_native_frame; - // Render the takeover-gate window (warmup armed, no fresh substantial - // scene yet) as a native loading frame instead of yielding, in two cases: - // (a) g_loading_hold: the frames right after a native loading screen - // (the presence context flips to gameplay before the first fresh - // scene, and the emulated output was suppressed all through the load, - // so yielding would flash its stale pre-load content); - // (b) boot_native: the whole startup flow (intro videos, boot frontend) - // runs with the gate armed and no scene published, and should render - // natively as 2D-over-black rather than fall back to emulated. - if (!loading_native && - (g_loading_hold || REXCVAR_GET(skate3_native_render_scene_boot_native)) && - REXCVAR_GET(skate3_native_render_scene_warmup_budget_ms) > 0 && - g_warmup_armed.load(std::memory_order_relaxed)) { - std::lock_guard lock(g_scene_mutex); - // A fresh CAS-editor scene qualifies at ANY size: the startup-flow - // editor is skater-only (~10 items, below warmup_min_items) but is a - // complete, deliberate scene; holding it rendered the 3D view black - // behind the live editor menu. It renders WITHOUT disarming the gate - // (see the takeover gate below); real gameplay still needs min_items. - const bool fresh = - g_scene && g_scene->generation >= g_warmup_fresh_generation; - const bool ready = - fresh && (g_scene->items.size() >= - size_t(REXCVAR_GET(skate3_native_render_scene_warmup_min_items)) || - (!g_scene->items.empty() && CasEditorActive(base))); - loading_native = !ready; - } - g_loading_hold = loading_native; - std::shared_ptr scene_ptr; - if (loading_native) { - // Native loading screen: there is no current world scene (g_scene holds - // the PREVIOUS map's stale one); render an empty scene, i.e. a black - // backdrop, and let the 2D overlay tail replay the game's live loading - // UI (Publish2dDraws publishes every guest frame, loads included). - // Value-initialized: zero items, no shadow/blur/outline, generation 0. - static const std::shared_ptr s_loading_scene = - std::make_shared(); - scene_ptr = s_loading_scene; - } else { - std::lock_guard lock(g_scene_mutex); - if (!g_scene || g_scene->items.empty()) { - return false; - } - scene_ptr = g_scene; - } - const FrameScene& scene = *scene_ptr; - - if (!EnsurePipeline(context)) { - return false; - } - // Flush any barriers pushed by lazy resource creation (white texture). - context.cmd->FlushBarriers(); - - // FMV routing: prefer the native path; video quads in the 2D replay are - // SUBSTITUTED with the ps_yuv2d combine, matched by their own captured - // slot-0 fetch (== that video's Y plane; through ps_main a movie quad - // renders as an opaque black cover, intro, or slow greyscale luma, - // camera-page previews). Order-faithful: backdrop fills land under the - // video like the emulated frame; multiple simultaneous videos each match - // their own plane set. Only when this path is unavailable does a live - // movie heartbeat yield to the emulated output. - MoviePlanes movies[kMaxMovies]; - { - std::lock_guard lock(g_movie_mutex); - std::memcpy(movies, g_movies, sizeof(movies)); - } - const int64_t movie_now_ns = - std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - bool movie_fresh = false; - for (const MoviePlanes& m : movies) { - movie_fresh |= m.ns >= 0 && movie_now_ns - m.ns < 500'000'000; - } - // Movie SESSION start (publish-freshness OFF->ON edge = a video began): - // the triple substitution only serves plane content DECODED DURING THIS - // SESSION (GuestTexture::last_change_frame >= this stamp). The APT plane - // copies keep their addresses across videos, so at video N+1's start - // both the store AND guest memory can still hold video N's last frame; - // serving either flashed the previous video for a few frames at every - // boundary. Until the new video's first frame lands (content change -> - // inline re-decode), the quad holds black, which is what a starting - // video looks like. - static uint64_t s_movie_session_frame = 0; - { - static bool s_movie_fresh_prev = false; - if (movie_fresh && !s_movie_fresh_prev) { - s_movie_session_frame = g_frames_rendered.load(std::memory_order_relaxed); - } - s_movie_fresh_prev = movie_fresh; - } - const bool movie_sub = movie_fresh && - REXCVAR_GET(skate3_native_render_scene_fmv_native) && - g_r.pso_yuv2d != nullptr; - if (!movie_sub && YieldForMovie()) { - return false; - } - - nrhi::Cmd* cmd = context.cmd; - - ReleaseRetiredAndFlushCaches(context); - - // Reset this frame's bone ring region (shared by the shadow casters and - // the main pass: the shadow pass allocates first, the main pass appends). - const uint64_t frame_number = g_frames_rendered.load(std::memory_order_relaxed); - const uint32_t bone_region = - uint32_t(frame_number % RendererState::kBoneRegions) * - RendererState::kBoneRegionSize; - g_r.bone_ring_offset = 0; - g_r.ropa_ring_offset = 0; - - { - const std::string tm(REXCVAR_GET(skate3_native_render_scene_trace_mesh)); - const uint32_t parsed = - tm.empty() ? 0u : uint32_t(std::strtoul(tm.c_str(), nullptr, 16)); - if (parsed != g_trace_mesh_addr) { - g_trace_mesh_addr = parsed; - g_trace_keys.clear(); - g_trace_sig.clear(); - REXLOG_INFO("tex-trace: mesh={:08X} {}", parsed, - parsed ? "TRACING" : "off"); - } - } - - // ---- Loading -> gameplay takeover (seamless boot / map-change loads) ---- - // The loading-screen prewarm (menus branch above) already decoded the - // registered world behind the load; the FIRST substantial post-load scene - // renders natively right away. Gates kept: staleness (g_scene holds the - // PREVIOUS map's scene through the whole load; rendering it shows - // old-map garbage) and min items (the capture holds a near-empty scene - // for a few frames while the game fades in; taking over there shows a - // black/void world; the brief yield shows the game's own fade instead). - // The frames after takeover run a budgeted settle pass for whatever - // prewarm missed (dynamic entities, late textures), and the draw path's - // miss budgets are clamped while settling so leftovers render white/skip - // for a frame instead of freezing the takeover frame. - const int32_t warmup_ms = REXCVAR_GET(skate3_native_render_scene_warmup_budget_ms); - bool settling = false; - // The takeover gates judge REAL scenes only; a native loading frame - // renders its empty scene deliberately (the gates re-run as usual once - // the presence context flips back to gameplay). - if (warmup_ms > 0 && !loading_native && - REXCVAR_GET(skate3_native_render_scene_debug) == 0) { - if (g_warmup_armed.load(std::memory_order_relaxed)) { - const bool small_or_stale = - scene.generation < g_warmup_fresh_generation || - scene.items.size() < - size_t(REXCVAR_GET(skate3_native_render_scene_warmup_min_items)); - // A fresh CAS-editor scene renders despite being under min_items (the - // startup-flow editor is skater-only, ~10 items), but does NOT count - // as the gameplay takeover; the gate stays armed for the real load - // that follows the editor. - const bool editor_scene = scene.generation >= g_warmup_fresh_generation && - !scene.items.empty() && CasEditorActive(base); - if (small_or_stale && !editor_scene) { - // Stale or fade-in scene: yield (brief, a few frames). Keep - // committing worker results meanwhile; every pre-takeover frame - // counts on map changes. - PrewarmCommit(context, frame_number); - return false; - } - if (!small_or_stale) { - g_warmup_armed.store(false, std::memory_order_relaxed); - g_settle_until_frame = frame_number + 120; - g_takeover_frame = frame_number; - size_t queued = 0; - { - std::lock_guard lock(g_prewarm_mutex); - queued = g_prewarm_queue.size(); - } - REXLOG_INFO( - "native-scene: taking over natively ({} items; prewarm {} done / {} dropped " - "/ {} queued)", - scene.items.size(), g_prewarm_done.load(std::memory_order_relaxed), - g_prewarm_dropped.load(std::memory_order_relaxed), queued); - } - } - settling = frame_number < g_settle_until_frame; - if (settling) { - const auto settle_t0 = PerfClock::now(); - const auto deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(warmup_ms); - WarmCounters wc; - for (const DrawItem& item : scene.items) { - WarmItemResources(context, base, frame_number, item, deadline, wc); - } - if (wc.deferred != 0) { - // Still behind: keep the settle pass (and the draw-path budget - // clamp) alive until a frame clears with budget to spare - but never - // past the hard cap: revalidation-churning content (streamed - // payloads, post-map-change stale items) re-defers every frame and - // would otherwise hold the full budget open for the whole session. - const int32_t max_frames = - REXCVAR_GET(skate3_native_render_scene_settle_max_frames); - const uint64_t extended = frame_number + 8; - if (max_frames <= 0 || extended <= g_takeover_frame + uint64_t(max_frames)) { - g_settle_until_frame = std::max(g_settle_until_frame, extended); - } else if (g_settle_until_frame <= frame_number + 1) { - REXLOG_INFO( - "native-scene: settle pass CAPPED {} frames after takeover with {} " - "deferred (revalidation churn?) - draw-path budgets resume", - frame_number - g_takeover_frame, wc.deferred); - } - } - if (wc.decodes > 0) { - cmd->FlushBarriers(); - } - g_warm_decodes.fetch_add(wc.decodes, std::memory_order_relaxed); - g_warm_deferred.fetch_add(wc.deferred, std::memory_order_relaxed); - g_pw_settle.Add(perf_ns_since(settle_t0)); - } - } - - if (!g_r.announced) { - g_r.announced = true; - REXLOG_INFO("native-scene: rendering natively ({} items, {}x{})", scene.items.size(), - context.guest_output_width, context.guest_output_height); - } - - // Commit finished worker decodes every frame (streamed arenas decode on - // the workers before their first draw instead of hitching the first frame - // that sees them). Near-no-op when nothing completed. The workers also - // serve the draw path's steady-state misses (EnqueueMeshMiss/ - // EnqueueWordsMiss), so make sure they exist even on a session that never - // showed a loading screen with the pipeline up. - EnsurePrewarmWorkers(); - // Native loading frames take the loading-screen commit budget; the heavy - // decode lifting behind the load is unchanged from the yielded path. - PrewarmCommit(context, frame_number, /*loading=*/loading_native); - // Content-store LRU: superseded words states (old mip levels, pre-demote - // detail sets, one-shot UI art) age out once nothing routes to them. - EvictTexStore(frame_number, context.device->CurrentSubmission()); - - bool shadow_ready = false; - uint32_t shadow_draws = 0; - const auto shadow_t0 = PerfClock::now(); - const float* sh = scene.shadow_rows; - const int32_t debug_mode = REXCVAR_GET(skate3_native_render_scene_debug); - shadow_ready = - RenderShadowAtlas(context, scene, bone_region, debug_mode, &shadow_draws); - g_pw_shadow.Add(perf_ns_since(shadow_t0)); - // Scene-level flicker probe: the shadow cascade rows (or validity/readiness) - // returning EXACTLY to the previous-but-one frame's state; hair samples the - // atlas, so an alternating cascade capture darkens/lightens it per frame. - { - uint64_t sh_h = 1469598103934665603ull; - const uint8_t* sb = reinterpret_cast(scene.shadow_rows); - for (size_t bi = 0; bi < sizeof(scene.shadow_rows); ++bi) { - sh_h = (sh_h ^ sb[bi]) * 1099511628211ull; - } - sh_h ^= (scene.shadow_valid ? 2u : 0u) | (shadow_ready ? 4u : 0u); - static uint64_t s_sh_h1 = 0, s_sh_h2 = 0; // render thread - if (s_sh_h2 != 0 && sh_h != s_sh_h1 && sh_h == s_sh_h2) { - g_shadow_alternations.fetch_add(1, std::memory_order_relaxed); - static std::atomic s_sh_logged{0}; - const uint32_t sl = s_sh_logged.fetch_add(1, std::memory_order_relaxed); - if (sl < 24 || (sl & 1023u) == 0) { - REXLOG_INFO( - "native-scene: SHADOW ALTERNATION valid={} ready={} draws={} " - "row0=({:.3f},{:.3f},{:.3f},{:.3f}) (n={})", - scene.shadow_valid ? 1 : 0, shadow_ready ? 1 : 0, shadow_draws, - sh[0], sh[1], sh[2], sh[3], sl); - } - } - s_sh_h2 = s_sh_h1; - s_sh_h1 = sh_h; - } - - // The scene draws into the MSAA target when enabled (resolved into the 1x - // scene plane at the end of the pass), or straight into the 1x plane. The - // 1x plane is the float HDR intermediate when the HDR post chain is live - // (ps_tonemap then writes the guest output), else the guest output itself. - const bool hdr_on = g_r.hdr_active && g_r.hdr_resolved != nullptr && - g_r.hdr_srv != nullptr && g_r.pso_tonemap != nullptr; - const bool msaa_on = g_r.msaa > 1 && g_r.msaa_color != nullptr && g_r.resolve_pso != nullptr; - nrhi::Texture* scene_color = - msaa_on ? g_r.msaa_color - : (hdr_on ? g_r.hdr_resolved : context.guest_output); - if (!msaa_on && !hdr_on) { - cmd->Barrier(context.guest_output, nrhi::ResourceState::kGuestOutput, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - } - - const bool use_depth = debug_mode != 4; - // Loading frames clear to black (the game's loading UI composes over - // black); real scenes keep the sky-ish debug clear that shows through - // undecoded holes. - float clear_color[4] = {loading_native ? 0.0f : 0.25f, - loading_native ? 0.0f : 0.35f, - loading_native ? 0.0f : 0.55f, 1.0f}; - if (hdr_on) { - // The clear is authored in the final gamma space; the HDR plane holds - // pre-tonemap values, so encode through the tone chain's inverse (the - // same transform as scene.hlsl PassGamma) so undecoded holes keep the - // same debug color after the tonemap pass. - for (int k = 0; k < 3; ++k) { - const float tm = - clear_color[k] * clear_color[k] * (2.0f / (1.41f * 1.41f)); - clear_color[k] = tm > 1.0f - ? tm * 4.0f - 3.0f - : 1.0f - std::sqrt(std::max(1.0f - tm, 0.0f)); - } - } - cmd->ClearRenderTarget(scene_color, clear_color); - if (use_depth) { - cmd->ClearDepth(g_r.depth, 1.0f); - cmd->SetRenderTargets(scene_color, g_r.depth); - } else { - cmd->SetRenderTargets(scene_color, nullptr); - } - - const nrhi::Viewport viewport{0.0f, - 0.0f, - float(context.guest_output_width), - float(context.guest_output_height), - 0.0f, - 1.0f}; - cmd->SetViewport(viewport); - const nrhi::Rect scissor{0, 0, int32_t(context.guest_output_width), - int32_t(context.guest_output_height)}; - cmd->SetScissor(scissor); - cmd->SetBindingLayout(g_r.layout); - cmd->SetPipeline(use_depth ? g_r.pso : g_r.pso_nodepth); - // Per-item PSO tracking for the opaque pass: two_sided_sheet meshes swap - // to the backface-culling variant (see MeshBuffers), everything else uses - // the pass PSO. Only meaningful while use_depth; the transparent sub-pass - // sets its own PSO and is not switched per item. - nrhi::Pipeline* scene_pso_bound = use_depth ? g_r.pso : g_r.pso_nodepth; - - // Per-frame dynamic-shadow bindings: receiver constants at b1 (a small - // per-frame CBV ring: the 52-float root-constant block is full) and the - // blurred atlas at t5 (white when no atlas was rendered this frame; the - // shader is also gated by sh_misc.y). - if (g_r.shadow_cb != nullptr) { - const uint32_t cb_offset = - uint32_t(frame_number % RendererState::kShadowCbRegions) * - RendererState::kShadowCbSlice; - float* cb = reinterpret_cast(g_r.shadow_cb_cpu + cb_offset); - std::memset(cb, 0, RendererState::kShadowCbSlice); - if (shadow_ready) { - std::memcpy(cb + 0, sh + 0, 4 * sizeof(float)); // sh_x = PS c0 - std::memcpy(cb + 4, sh + 12, 4 * sizeof(float)); // sh_y = PS c3 - std::memcpy(cb + 8, sh + 16, 4 * sizeof(float)); // sh_z = PS c4 - std::memcpy(cb + 12, sh + 4, 4 * sizeof(float)); // sh_c1 = PS c1 - std::memcpy(cb + 16, sh + 8, 4 * sizeof(float)); // sh_c2 = PS c2 - cb[20] = sh[32]; // sh_color = PS c8 - cb[21] = sh[33]; - cb[22] = sh[34]; - cb[23] = 0.299f * sh[32] + 0.587f * sh[33] + 0.114f * sh[34]; - cb[24] = sh[20]; // depth bias (PS c5.x) - cb[25] = 1.0f; // enable - // sh_misc.zw = atlas dimensions (3*tile x tile) for the char path's - // point-snapped taps. - cb[26] = float(g_r.shadow_tile * 3); - cb[27] = float(g_r.shadow_tile); - // sh_char = the game's per-cascade character receive biases + enable - // (see FrameScene::char_shadow_bias; exact 9-tap char sampling). - if (scene.char_shadow_bias[0] > 0.0f && - REXCVAR_GET(skate3_native_render_scene_char_shadow_exact)) { - cb[56] = scene.char_shadow_bias[0]; - cb[57] = scene.char_shadow_bias[1]; - cb[58] = scene.char_shadow_bias[2]; - cb[59] = 1.0f; - } - } - // Exact world-shading frame rows (valid whenever the env-family PS bank - // was captured this frame, independent of the shadow ATLAS being - // rendered; draw_item only selects the exact branch when - // scene.shadow_valid). - if (scene.shadow_valid) { - cb[28] = sh[24]; // sun direction (PS c6), for the normal-map kd (v2) - cb[29] = sh[25]; - cb[30] = sh[26]; - cb[31] = sh[40]; // scene exposure (PS c10.x) - cb[32] = sh[45]; // material multiplier (PS c11.y) - cb[33] = scene.family_rows[0]; // tree lightmap scale (tree PS c0.x) - cb[34] = scene.family_rows[1]; // tree lightmap floor (tree PS c0.y) - cb[35] = scene.family_rows[2]; // tree tint multiplier (tree PS c4.y) - cb[36] = scene.fog_ramp[0]; // global fog: sat(d*x+y)^z - cb[37] = scene.fog_ramp[1]; - cb[38] = scene.fog_ramp[2]; - cb[39] = scene.family_rows[3]; // proxyworld scale (proxy PS c3.y) - std::memcpy(cb + 40, scene.fog_color, 4 * sizeof(float)); - } - // World-shading v2 row (sh_v2, cb[60..63]): x = stored-tangent - // polarity. - cb[60] = - float(REXCVAR_GET(skate3_native_render_scene_world_v2_tan_sign)); - // dynamicobject.fx frame rows (dyn_sun/dyn_amb/dyn_misc at cb[44..55]). - if (scene.dynobj_valid) { - cb[44] = scene.dynobj_rows[0]; // sun dir (PS c9) - cb[45] = scene.dynobj_rows[1]; - cb[46] = scene.dynobj_rows[2]; - cb[47] = scene.dynobj_rows[3]; // scene exposure (c13.x) - cb[48] = scene.dynobj_rows[4]; // flat ambient rgb (c15.xyz) - cb[49] = scene.dynobj_rows[5]; - cb[50] = scene.dynobj_rows[6]; - cb[51] = scene.dynobj_rows[7]; // bounce scale (c15.w) - cb[52] = scene.dynobj_rows[8]; // material multiplier (c14.y) - cb[53] = scene.dynobj_rows[9]; // static world-shadow floor (c8.w) - // Static world-shadow transform rows (dyn_wsx/dyn_wsy/dyn_wsz, - // cb[64..75]): consumed only when a draw carries the misc.z world- - // shadow bind flag (the map primed and bound at t4). - if (scene.dynobj_ws_valid) { - std::memcpy(cb + 64, scene.dynobj_ws, sizeof(scene.dynobj_ws)); - } - } - cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset); - // b2 (character lighting) default: point at the ring base so the root - // CBV is never left unset; character draws re-point it per item. - cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); - // t6/t7 ride one table: default = white cube + this frame's atlas - // (draw_item re-pairs with the item's real cube when one resolves). - cmd->SetTexturePair(7, g_r.white_cube.srv, - shadow_ready ? g_r.shadow_srv_final : g_r.white.srv); - } - - uint32_t drawn = 0; - uint32_t item_index = 0; - // F7 scene-composition ring (RequestSceneRingDump): one compact signature - // per scene item per frame, ~900 frames deep. A 1-2 frame artifact (the - // dam-bank blue flash) is uncapturable by F10/F11; the ring lets a - // keypress seconds later name exactly which item appeared / vanished / - // changed textures on the artifact frame. Entries are recorded at - // classification below; draw_item stamps the issued-draw count, so an - // item that early-returned (deferred mesh, skip-new, fade skip) shows - // drawn=0 that frame. - SceneRingFrame* ring_frame = nullptr; - std::unordered_map ring_map; - if (REXCVAR_GET(skate3_native_render_scene_ring) && debug_mode == 0) { - g_scene_ring.emplace_back(); - while (g_scene_ring.size() > kSceneRingFrames) { - g_scene_ring.pop_front(); - } - ring_frame = &g_scene_ring.back(); - ring_frame->frame = frame_number; - std::memcpy(ring_frame->cam, scene.cam_pos, sizeof(ring_frame->cam)); - ring_frame->fog[0] = scene.fog_ramp[0]; - ring_frame->fog[1] = scene.fog_ramp[1]; - ring_frame->fog[2] = scene.fog_ramp[2]; - ring_frame->fog[3] = scene.fog_color[0]; - ring_frame->fog[4] = scene.fog_color[1]; - ring_frame->fog[5] = scene.fog_color[2]; - std::memcpy(ring_frame->family_rows, scene.family_rows, - sizeof(ring_frame->family_rows)); - ring_frame->sky_height = scene.sky_height; - ring_frame->shadow_valid = scene.shadow_valid; - ring_frame->items.reserve(scene.items.size()); - ring_map.reserve(scene.items.size()); - } - // Inline decode budget for DYNAMIC payloads only (skinned/cloth/ropa - // buffers that change every frame). Static meshes and all textures route - // to the decode workers on miss; the render thread never pays their - // decode cost. - int32_t mesh_budget = REXCVAR_GET(skate3_native_render_scene_mesh_decode_budget); - if (settling) { - // Post-takeover settle window: cap inline dynamic decodes too, so the - // takeover frame never absorbs an unbounded decode burst. - if (mesh_budget == 0 || mesh_budget > 16) mesh_budget = 16; - } - uint32_t mesh_decodes = 0; - // Item drawing body. environment.decal items draw in the same opaque pass: - // they ARE the wall/ground sections they cover, with the art composited - // in-shader (alpha-blending them as separate overlay geometry punched - // holes in the world). - // Ripple scroll clock for the water branch (seconds; wraps every hour to - // keep float precision on the scrolled UVs). - static const std::chrono::steady_clock::time_point water_t0 = - std::chrono::steady_clock::now(); - const float water_time = float( - std::chrono::duration(std::chrono::steady_clock::now() - water_t0).count() - - std::floor( - std::chrono::duration(std::chrono::steady_clock::now() - water_t0).count() / - 3600.0) * - 3600.0); - - // Words-keyed texture lookup with payload revalidation, shared by the - // streamed-artwork diffuse override and the 2D pass. The event-ad system - // rotates artwork IN PLACE; it streams the next poster into the same - // guest buffer, so the fetch words (this cache's key) never change; without - // the fingerprint recheck every frame keeps the first ad ever decoded at - // that address and the wall posters diverge from the emulated frame. - const auto find_words_texture = [&](uint64_t key) { - auto it = g_r.tex_store.find(key); - if (it != g_r.tex_store.end()) { - it->second.last_used_frame = frame_number; - } - if (it != g_r.tex_store.end() && it->second.valid && - frame_number >= it->second.recheck_frame && - REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { - // 2-frame cadence in menu/editor contexts, like the item-draw poll - // (in-place CAS composite rewrites during edits). - it->second.recheck_frame = - frame_number + - (g_in_menus_frame.load(std::memory_order_relaxed) ? 2 : 16); - const uint64_t fp = SampleProbeFingerprint(base, it->second); - const bool fp_new = fp != 0 && fp != it->second.payload_fp; - if (it->second.recheck_count < 3) { - ++it->second.recheck_count; - } - if (it->second.incomplete || fp_new) { - // In-place content rotation (event ads stream the next poster into - // the same buffer; the words, and so the key, never change): heal - // on the workers; keep serving the current decode meanwhile. - EnqueueWordsMiss(key, it->second.fetch_words); - } - } - return it; - }; - // Resolve six raw fetch-constant words (a draw-time streamed-artwork - // binding with no guest texture object) through the words-keyed cache. - // `site` identifies the consuming slot (mesh << 1 | slot) for the sticky - // fallback: streaming rebinds NEW mip words as you approach the art (the - // cache key changes wholesale), and dropping to the placeholder / channel - // diffuse for the worker round trip was the visible poster/decal flash; - // the site's previous art keeps serving until the new decode lands. - // Returns null only when nothing ever decoded for this site. - const auto resolve_fetch_words = [&](const uint32_t words[6], uint64_t site, - bool retained) -> const GuestTexture* { - const uint64_t fkey = FetchWordsKey(words); - auto fit = find_words_texture(fkey); - if (fit == g_r.tex_store.end()) { - if (!retained) { - EnqueueWordsMiss(fkey, words); - } - } else if (fit->second.valid) { - g_r.words_sticky[site] = fkey; - return &fit->second; - } else if (!retained && frame_number >= fit->second.retry_after_frame) { - // Failed words decode (payload was mid-stream at first sight): keep - // retrying; without this the entry negative-cached until the words - // changed again. - EnqueueWordsMiss(fkey, words); - fit->second.retry_after_frame = frame_number + 30; - } - const auto pit = g_r.words_sticky.find(site); - if (pit != g_r.words_sticky.end() && pit->second != fkey) { - const auto old = g_r.tex_store.find(pit->second); - if (old != g_r.tex_store.end() && old->second.valid) { - old->second.last_used_frame = frame_number; - g_ad_stale_served.fetch_add(1, std::memory_order_relaxed); - return &old->second; - } - } - // Nothing decoded for this site yet: the caller falls back to the - // channel diffuse (the baked placeholder poster). If the "decal flash - // and replace" report survives the dense-probe fix, a climbing none= - // with a capped log here names this path as the flasher. - g_ad_placeholder.fetch_add(1, std::memory_order_relaxed); - static std::atomic s_ad_logs{0}; - if (!retained && s_ad_logs.fetch_add(1) < 24) { - REXLOG_INFO( - "native-scene: ad/decal words site={:X} fkey={:016X} -> placeholder " - "(no decode yet) words=[{:08X} {:08X} {:08X} {:08X} {:08X} {:08X}]", - site, fkey, words[0], words[1], words[2], words[3], words[4], - words[5]); - } - return nullptr; - }; - - // ---- SSR reflective-item capture (see ApplySsrPass) ---- - // draw_item records each reflective item's fully-staged draw state - // (constants + resolved texture views) so the reflection G-buffer pass - // after the scene pass re-renders exactly what the main pass drew. - struct SsrGbufItem { - float constants[52]; - nrhi::TextureView* t3; // macro slot (water ripple normal map) - nrhi::TextureView* t4; // spec/reflection masks - nrhi::TextureView* t5; // fam 5/6 normal map (white when unpaired) - uint32_t mesh; - uint64_t fingerprint; - const DrawItem* item; - }; - std::vector ssr_items; - const bool ssr_on = - hdr_on && use_depth && debug_mode == 0 && !loading_native && - !g_r.ssr_failed && REXCVAR_GET(skate3_native_render_scene_ssr) && - // The published projection must be the live perspective matrix (same - // gate as the SSAO pass). - scene.proj[11] == 1.0f && scene.proj[0] != 0.0f && - scene.proj[5] != 0.0f && scene.proj[14] != 0.0f; - - const auto draw_item = [&](const DrawItem& item) { - // NO per-frame inline decodes here. Static content (world geometry, - // props) loads/heals on the decode workers via the miss queue; a - // texture decode averages ~10 ms and panning surfaces dozens of new - // payloads in one frame; inline decode WAS the panning lag spike. - // Dynamic payloads (skinned, CPU-rewritten every frame) are kept - // fresh by the dyn decode jobs (guest-thread snapshot -> worker), one - // frame behind the sim; only their FIRST sight decodes inline. The - // cloth-quads particle path (gated off by default) still re-decodes - // inline on change; it has no job route. - // EXCEPTION: ROPA garments decode INLINE (skate3_native_render_scene_ - // ropa_inline): the worker route put the GPU-resident cloth shape 1-2 - // frames behind the body, visible as jelly/clip-through-torso during - // direction changes (the shape only pauses when motion is steady). A - // garment decode is sub-millisecond (a few hundred verts; it was - // lumped in with the expensive texture decodes in the perf overhaul), - // and the game's ping-pong double buffer makes the LIVE read tear-safe - // (the sim writes the other half). - const bool ropa_inline = - item.ropa && REXCVAR_GET(skate3_native_render_scene_ropa_inline); - const bool dynamic_payload = item.skinned || item.cloth_quads || item.ropa; - auto it = g_r.meshes.find(item.mesh); - if (item.retained && - (it == g_r.meshes.end() || it->second.fingerprint != item.fingerprint)) { - // Retained off-screen items (edge-of-view guard band) outlive their - // guest-side lifetime guarantees; the arena may have streamed out or - // been reused since capture. Draw only the exact cached decode; no - // guest reads, no heals, no miss enqueues from here. - return; - } - if (it != g_r.meshes.end() && it->second.fingerprint != item.fingerprint && - REXCVAR_GET(skate3_native_render_scene_mesh_revalidate)) { - if (item.cloth_quads || ropa_inline) { - g_r.device->DestroyDeferred(it->second.vb); - g_r.device->DestroyDeferred(it->second.ib); - g_r.meshes.erase(it); - it = g_r.meshes.end(); - } else if (!dynamic_payload) { - // Streaming heal: keep drawing the old decode this frame; the - // workers decode the new payload and the commit swaps it in. - EnqueueMeshMiss(item.mesh); - } - } - if (it == g_r.meshes.end()) { - if (!item.cloth_quads && !ropa_inline) { - // ALL mesh misses decode on the workers, including first-sight - // skinned entities (a streamed-in NPC appears 1-2 frames late - // instead of hitching the frame; the dyn decode jobs usually land - // the same content even sooner from the guest-thread snapshot). - EnqueueMeshMiss(item.mesh); - g_rr_mesh_deferred.fetch_add(1, std::memory_order_relaxed); - return; - } - if (mesh_budget > 0 && mesh_decodes >= uint32_t(mesh_budget)) { - g_rr_mesh_deferred.fetch_add(1, std::memory_order_relaxed); - return; // decodes on a later frame - } - ++mesh_decodes; - const auto decode_t0 = PerfClock::now(); - MeshBuffers buffers; - const bool decode_ok = DecodeMesh(g_r.device, base, item, buffers); - g_pw_mesh_decode.Add(perf_ns_since(decode_t0)); - if (!decode_ok) { - g_rr_decode_fail.fetch_add(1, std::memory_order_relaxed); - static std::unordered_set logged; - if (logged.size() < 32 && logged.insert(item.mesh).second) { - REXLOG_WARN("native-scene: DecodeMesh FAILED mesh={:08X} vb={:08X} fmt={} stride={}", - item.mesh, item.vb_addr, item.pos_fmt, item.stride); - } - return; - } - buffers.fingerprint = item.fingerprint; - it = g_r.meshes.emplace(item.mesh, buffers).first; - } - const MeshBuffers& buffers = it->second; - - // Double-sided sheet props draw with backface culling; without it the - // front/back copies z-fight into lightmap flicker at range (banners/ - // flags). Opaque depth pass only; a mirrored instance (negative world - // determinant) would flip winding, so those stay uncull(ed). - // (hair items with a validated lighting capture draw in the blended - // sub-pass under their own cull PSOs; never reset those here) - const bool hair_pass = item.char_family >= 4 && item.char_family <= 5 && - item.char_rows[14 * 4 + 1] > 0.0f; - // reflective_trans glass draws in the blended sub-pass whenever its - // exact branch is live (same gate as the sub-pass routing below); the - // opaque cull-PSO reset must not fire there. - const bool refl_trans_pass = - item.env_family == 13 && debug_mode == 0 && scene.shadow_valid; - // Character items mid-fade (spawn settle / distance) draw in the blended - // sub-pass too (same gate as the routing below), same exemption. - const bool char_fade_pass = - (item.char_family == 1 || item.char_family == 2 || - item.char_family == 3 || item.char_family == 6) && - debug_mode == 0 && CharFadeAlpha(item) < 0.999f && - REXCVAR_GET(skate3_native_render_scene_entity_fade); - if (use_depth && !item.transparent && !item.water && !hair_pass && - !refl_trans_pass && !char_fade_pass && g_r.pso_cullback != nullptr) { - const float* w = item.world; - const float det3 = w[0] * (w[5] * w[10] - w[6] * w[9]) - - w[1] * (w[4] * w[10] - w[6] * w[8]) + - w[2] * (w[4] * w[9] - w[5] * w[8]); - // Game-parity backface culling: every world environment material's - // XML sets CULLMODE=FRONT (== our CULL_FRONT; the banner work - // calibrated game-kept faces as our D3D12 BACK faces). CULL_NONE - // showed interior/away faces the game never renders, e.g. the - // building wall's inside face stacking behind the translucent canopy - // glass. Trees/alphatest (fams 7/9/10: leaf/fence cards read from - // both sides) and mirrored instances (flipped winding) stay - // uncull(ed), matching the two-sided-sheet rules. - const bool cull_family = - REXCVAR_GET(skate3_native_render_scene_backface_cull) && - item.env_family != 0 && item.env_family != 7 && - item.env_family != 9 && item.env_family != 10 && - item.env_family != 13; - nrhi::Pipeline* want = - ((buffers.two_sided_sheet || cull_family) && det3 >= 0.0f) - ? g_r.pso_cullback - : g_r.pso; - if (want != scene_pso_bound) { - cmd->SetPipeline(want); - scene_pso_bound = want; - } - } - - // Resolve guest textures (white fallback) through the words-keyed - // content store: object -> stable live words -> store entry - // (console identity semantics; a - // retargeted or reused object is just a different key, never a stale - // serve). The object addresses were readable on the game thread this - // frame; NO VirtualQuery here; it takes the process VAD lock, which - // the guest streaming threads hammer, and ~2 calls per item stalled - // the whole renderer to 3 fps. - // Set by resolve_texture_raw when it returns the white fallback while a - // decode is in flight (first-sight miss or a still-failing heal); the - // sticky wrapper below then serves the item's last-good texture instead. - bool tex_pending = false; - const auto resolve_texture_raw = [&](uint32_t tex_ptr) -> const GuestTexture* { - if (tex_ptr == 0) { - return &g_r.white; - } - const bool trm = g_trace_mesh_addr != 0 && item.mesh == g_trace_mesh_addr; - auto rit = g_r.tex_routes.find(tex_ptr); - if (!item.retained) { - // Route refresh: seqlock-stable read of the live fetch words (a - // mid-rewrite mixed snapshot must never become a key; it would - // decode a coherent image of the WRONG memory, the pool-page - // collage class). An unstable read keeps the previous route for a - // frame. Retained items skip all live reads: the object may be - // gone; their route is frozen at retention. - uint32_t live[6]; - if (ReadStableTexWords(base, tex_ptr, live)) { - const bool demoted = (live[1] & 0xFFFFF000u) == 0u; - if (rit == g_r.tex_routes.end()) { - rit = g_r.tex_routes.emplace(tex_ptr, RendererState::TexRoute{}) - .first; - std::memcpy(rit->second.words, live, sizeof(live)); - rit->second.key = FetchWordsKey(live); - rit->second.demoted = demoted; - } else if (demoted) { - // Mip-0 demoted (base address cleared; the old pool range is - // already reused): hold the pre-demote route; its decode - // carries the full chain, strictly better, and suspend its - // payload polls (the probes would read the reused pool and - // heal in foreign bytes). A re-promote publishes fresh words - // and re-routes. - if (!rit->second.demoted) { - rit->second.demoted = true; - g_demote_hold.fetch_add(1, std::memory_order_relaxed); - if (trm) { - REXLOG_INFO("tex-trace: f{} obj={:08X} DEMOTED (route held, " - "polls suspended)", - frame_number, tex_ptr); - } - } - } else if (std::memcmp(live, rit->second.words, sizeof(live)) != 0) { - if (trm) { - REXLOG_INFO( - "tex-trace: f{} obj={:08X} REROUTE old=[{:08X} {:08X} " - "{:08X} {:08X} {:08X} {:08X}] new=[{:08X} {:08X} {:08X} " - "{:08X} {:08X} {:08X}]", - frame_number, tex_ptr, rit->second.words[0], - rit->second.words[1], rit->second.words[2], - rit->second.words[3], rit->second.words[4], - rit->second.words[5], live[0], live[1], live[2], live[3], - live[4], live[5]); - } - std::memcpy(rit->second.words, live, sizeof(live)); - rit->second.key = FetchWordsKey(live); - rit->second.demoted = false; - } else { - rit->second.demoted = false; - } - } else if (trm) { - REXLOG_INFO("tex-trace: f{} obj={:08X} words UNSTABLE (mid-rewrite)", - frame_number, tex_ptr); - } - } - if (rit == g_r.tex_routes.end()) { - // Unreadable/unstable object with no prior route: nothing safe to - // serve or decode yet; next frame's read settles it. - tex_pending = true; - return &g_r.white; - } - const RendererState::TexRoute& route = rit->second; - auto sit = g_r.tex_store.find(route.key); - if (sit == g_r.tex_store.end()) { - if (item.retained || route.demoted) { - // Retained: no enqueues, no live reads. Demote-held with nothing - // cached: the pre-demote pool may already be reused; a decode - // would commit foreign bytes under a good key. White/sticky until - // a re-promote publishes live words. - tex_pending = true; - return &g_r.white; - } - // Decode on the workers; white/sticky for the 1-3 frames that takes - // (inline decode measured ~10 ms avg / ~70 ms max, the panning - // lag spikes). - if (trm) { - REXLOG_INFO("tex-trace: f{} obj={:08X} MISS key={:016X} enqueued", - frame_number, tex_ptr, route.key); - } - EnqueueWordsMiss(route.key, route.words); - g_rr_tex_deferred.fetch_add(1, std::memory_order_relaxed); - tex_pending = true; - return &g_r.white; - } - GuestTexture& e = sit->second; - e.last_used_frame = frame_number; - if (!e.valid) { - // Failed decode: retry on its backoff clock (the payload usually - // lands within a few frames of first sight; the commit stamps the - // real backoff). - if (!item.retained && !route.demoted && - frame_number >= e.retry_after_frame) { - e.retry_after_frame = frame_number + 120; - EnqueueWordsMiss(route.key, route.words); - } - if (trm) { - REXLOG_INFO("tex-trace: f{} obj={:08X} INVALID entry key={:016X} " - "(retry at f{})", - frame_number, tex_ptr, route.key, e.retry_after_frame); - } - tex_pending = true; - return &g_r.white; - } - // Payload revalidation, the one irreducible heuristic: the game - // streams content IN PLACE at addresses the words already point to - // (event-ad rotation, mip-pool fills, composed lightmap pages) with - // no CPU-visible event. Escalating cadence (2/4/8 then 16): a decode - // taken while the payload was still streaming reads back garbage; - // fresh entries re-verify fast, then settle to the cheap steady - // cadence. An incomplete decode (truncated tiled-mip copy) and a - // near_black verdict re-decode regardless of the mip-0 fingerprint; - // the commit's same-content dedup absorbs the no-ops. Suspended while - // the route is demote-held (the probes would read the reused pool). - if (!item.retained && !route.demoted && - frame_number >= e.recheck_frame && - REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { - // Menu/editor contexts poll STEADY entries on a 2-frame cadence - // (mirrors the 2D resolver): the CAS editor recomposes the skater's - // skin/garment textures IN PLACE progressively over ~a second on - // every edit, and the 16-frame steady cadence made each composite - // step land ~100+ ms late, desynchronized across the pieces, the - // "broken for 1-2 s after changing skin color" state. Gameplay - // keeps the cheap steady cadence. - e.recheck_frame = - frame_number + - (e.recheck_count < 3 - ? (2ull << e.recheck_count) - : (g_in_menus_frame.load(std::memory_order_relaxed) ? 2ull - : 16ull)); - const uint64_t fp = SampleProbeFingerprint(base, e); - const bool fp_new = fp != 0 && fp != e.payload_fp; - if (trm) { - REXLOG_INFO( - "tex-trace: f{} obj={:08X} poll key={:016X} fp={:016X} " - "cached={:016X} new={} inc={} nb={} cnt={}", - frame_number, tex_ptr, route.key, fp, e.payload_fp, - fp_new ? 1 : 0, e.incomplete ? 1 : 0, e.near_black ? 1 : 0, - e.recheck_count); - } - if (e.recheck_count < 3) { - ++e.recheck_count; - } - if (fp_new || e.incomplete || (e.near_black && e.nb_redecodes < 3)) { - // Re-decode churn diagnostic: repeated in-place payload changes - // on one key = streaming oscillation, visible as texture flicker - // on the affected meshes. - static std::atomic s_redecode_logs{0}; - if (s_redecode_logs.fetch_add(1) < 256) { - REXLOG_INFO( - "native-scene: texture re-decode key={:016X} reason={}{}{} " - "words=[{:08X} {:08X} {:08X} {:08X} {:08X} {:08X}]", - route.key, fp_new ? "fp" : "", e.incomplete ? "inc" : "", - e.near_black ? "nb" : "", e.fetch_words[0], e.fetch_words[1], - e.fetch_words[2], e.fetch_words[3], e.fetch_words[4], - e.fetch_words[5]); - } - if (trm) { - REXLOG_INFO("tex-trace: f{} obj={:08X} ENQUEUE heal key={:016X} " - "reason={}{}{}", - frame_number, tex_ptr, route.key, fp_new ? "fp" : "", - e.incomplete ? "inc" : "", e.near_black ? "nb" : ""); - } - EnqueueWordsMiss(route.key, e.fetch_words); - } - } - return &e; - }; - // Sticky serving: streaming rotates content onto NEW texture objects (a - // mip promote is usually a fresh object: 287 first-sight objects vs 73 - // in-place rebinds in one 45 s traversal), so a plain - // cache miss white-flashed content that was on screen with the previous - // mip one frame earlier. While the new object's decode is in flight, - // serve the last texture this item slot successfully resolved, the - // same visual as the console's own mip transition. slot: 0 diffuse, - // 1 lightmap, 2 macro, 3 normal/ripple, 4 decal art, 5 hair, 6 spec. - const auto resolve_texture = [&](uint32_t tex_ptr, - uint32_t slot) -> const GuestTexture* { - tex_pending = false; - const GuestTexture* t = resolve_texture_raw(tex_ptr); - // Near-uniform-black decodes on the WHITE-NEUTRAL slots (1 lightmap, - // 2 macro) serve the white fallback until a heal lands real content. - // Lightmap: a real-but-black page binds with tint.r > 0 and the CSM - // min-clamp renders the surface BLACK (the door 59810af's shader gate - // cannot see). Macro: since 59810af the weathering multiplies OVER - // the composited decal art, so a mid-stream dark macro decode turns - // the paint into the black-square flash; white is the macro's - // authored neutral (materials without weathering bind default_white). - // Slots with legitimately dark content (diffuse, decal art, spec) - // are untouched. Applied in the wrapper so retained items get the - // same protection. - const bool trm = g_trace_mesh_addr != 0 && item.mesh == g_trace_mesh_addr; - if ((slot == 1 || slot == 2) && t != &g_r.white && t->valid && - t->near_black) { - if (trm) { - REXLOG_INFO("tex-trace: f{} slot={} obj={:08X} NEAR-BLACK -> white", - frame_number, slot, tex_ptr); - } - static std::atomic s_nb_logs{0}; - if (s_nb_logs.fetch_add(1, std::memory_order_relaxed) < 24) { - REXLOG_INFO( - "native-scene: near-black decode obj={:08X} slot={} served as " - "white fallback (mid-compose/stream content)", - tex_ptr, slot); - } - return &g_r.white; - } - if (item.retained || tex_ptr == 0) { - return t; - } - const uint64_t skey = (uint64_t(item.mesh) << 3) | slot; - if (t != &g_r.white) { - RendererState::TexStickySite& site = g_r.tex_sticky[skey]; - // Material-detail downgrade hold: the game's streaming demotes a - // nearby mesh to its UN (undetailed) material for a fraction of a - // second and back, the visible flash to completely different/ - // blurry art. Under words identity a demote is just a smaller-area - // key; the site's last-adopted decode is still resident in the - // store, so keep serving it while the downgrade is young. A - // persistent downgrade (a real demote as the player leaves) adopts - // after the hold window. - const int32_t hold = REXCVAR_GET(skate3_native_render_scene_detail_hold); - const uint64_t cur_key = FetchWordsKey(t->fetch_words); - const uint64_t cur_area = FetchWordsArea(t->fetch_words); - if (hold > 0 && site.area > cur_area && site.words_key != 0) { - const auto hit = g_r.tex_store.find(site.words_key); - const GuestTexture* held = - hit != g_r.tex_store.end() && hit->second.valid ? &hit->second - : nullptr; - if (held != nullptr) { - // The held entry may no longer be polled by any live route; - // re-probe its payload on its own recheck clock so a reused - // pool page abandons the hold instead of serving foreign - // bytes. - hit->second.last_used_frame = frame_number; - if (frame_number >= hit->second.recheck_frame) { - hit->second.recheck_frame = frame_number + 16; - const uint64_t fp = SampleProbeFingerprint(base, hit->second); - if (fp == 0 || fp != hit->second.payload_fp) { - held = nullptr; - } - } - } - if (held != nullptr) { - if (site.downgrade_since == 0) { - site.downgrade_since = frame_number; - static std::atomic s_hold_logs{0}; - if (s_hold_logs.fetch_add(1, std::memory_order_relaxed) < 24) { - REXLOG_INFO( - "native-scene: detail-downgrade HOLD mesh={:08X} slot={} " - "held={:016X} bound={:016X} area {} -> {}", - item.mesh, slot, site.words_key, cur_key, site.area, - cur_area); - } - } - if (frame_number - site.downgrade_since < uint64_t(hold)) { - if (trm && ((frame_number - site.downgrade_since) & 63u) == 0) { - REXLOG_INFO( - "tex-trace: f{} slot={} DETAIL-HOLD serving fp={:016X} " - "(bound area {} < {})", - frame_number, slot, held->payload_fp, cur_area, - site.area); - } - return held; - } - } - } - if (trm && site.words_key != 0 && site.words_key != cur_key) { - REXLOG_INFO( - "tex-trace: f{} slot={} ADOPT key {:016X}(area {}) -> " - "{:016X}(area {})", - frame_number, slot, site.words_key, site.area, cur_key, - cur_area); - } - site.words_key = cur_key; - site.area = cur_area; - site.downgrade_since = 0; - if (!g_r.tex_pending_first.empty()) { - g_r.tex_pending_first.erase(tex_ptr); - } - return t; - } - if (tex_pending) { - // The current binding's decode is in flight: serve the site's - // previous art from the store, the console's own mip-transition - // look instead of a white flash. - const auto sit = g_r.tex_sticky.find(skey); - if (sit != g_r.tex_sticky.end() && sit->second.words_key != 0) { - const auto old = g_r.tex_store.find(sit->second.words_key); - if (old != g_r.tex_store.end() && old->second.valid) { - old->second.last_used_frame = frame_number; - g_tex_sticky_served.fetch_add(1, std::memory_order_relaxed); - if (trm) { - REXLOG_INFO("tex-trace: f{} slot={} STICKY serving key={:016X} " - "(pending {:08X})", - frame_number, slot, sit->second.words_key, tex_ptr); - } - return &old->second; - } - } - } - if (trm && t == &g_r.white) { - REXLOG_INFO("tex-trace: f{} slot={} serving WHITE (req {:08X})", - frame_number, slot, tex_ptr); - } - return t; - }; - // Streamed-artwork diffuse override (see DrawItem::diffuse_fetch): the - // real art exists only as draw-time fetch words; resolve those through - // the words-keyed cache (shared with the 2D pass; the art has no guest - // object to key on). - const GuestTexture* diffuse = - item.diffuse_fetch[1] != 0 - ? resolve_fetch_words(item.diffuse_fetch, uint64_t(item.mesh) << 1, - item.retained) - : nullptr; - if (diffuse == nullptr) { - diffuse = resolve_texture(item.diffuse_tex, 0); - if (diffuse == &g_r.white && tex_pending && item.diffuse_tex != 0 && - !item.retained) { - // Brand-new content, first decode in flight, and no previous mip - // cached to serve: draw NOTHING for a short window instead of a - // white flash: the emulated pop-in semantics (the game shows - // streamed content only once its data is ready). Bounded so a - // permanently failing texture still falls back to visible white. - const auto [pit, fresh] = - g_r.tex_pending_first.try_emplace(item.diffuse_tex, frame_number); - if (frame_number - pit->second < 20) { - g_skip_new.fetch_add(1, std::memory_order_relaxed); - return; - } - } - } - const GuestTexture* lightmap = - item.lightmap_tex != 0 && REXCVAR_GET(skate3_native_render_scene_lightmaps) - ? resolve_texture(item.lightmap_tex, 1) - : nullptr; - if (lightmap == &g_r.white) { - lightmap = nullptr; - } - - // constants = world + mvp (world * view_proj, row-vector) + tint + cam - // + material tint + overlay params + misc. tint.a > 0 selects debug - // solid colors; tint.r > 0 marks a bound lightmap. For transparent - // items misc.yzw carries the fog ramp and mat_tint the fog color. - float constants[52] = {}; - std::memcpy(constants, item.world, sizeof(item.world)); - if (item.unlit) { - // sky.*: the dome mesh is CAMERA-RELATIVE (sky.fx defaultVS adds - // g_vViewPos to every vertex). Anchoring it at the world origin put - // the baked skyline panorama ~700 m off, visibly rotated/parallaxed - // against the emulated frame. The game's sky viewpos tracks the camera - // in x/z but pins Y at the level's fixed sky elevation (captured per - // frame from the sky draw's VS bank; 165.0 in every capture); using - // cam.y rendered the skyline ~160 m too LOW. - constants[12] += scene.cam_pos[0]; - constants[13] += scene.sky_height; - constants[14] += scene.cam_pos[2]; - } - float* mvp = constants + 16; - for (int r = 0; r < 4; ++r) { - for (int c = 0; c < 4; ++c) { - float sum = 0.0f; - for (int k = 0; k < 4; ++k) { - sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c]; - } - mvp[r * 4 + c] = sum; - } - } - // Bone palette upload for skinned items; tint.g flags skinning. - bool bones_bound = false; - if (item.skinned && !item.bones.empty()) { - const uint32_t bytes = uint32_t(item.bones.size() * sizeof(float)); - const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u; - if (offset + bytes <= RendererState::kBoneRegionSize) { - std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.bones.data(), bytes); - g_r.bone_ring_offset = offset + bytes; - cmd->SetBufferSrv(3, g_r.bone_ring, bone_region + offset); - bones_bound = true; - } - } - if (!bones_bound) { - if (item.skinned && !item.bones.empty()) { - // Ring exhausted: this item renders bind-pose at identity, - // effectively invisible. Must never happen silently. - g_rr_no_bones.fetch_add(1, std::memory_order_relaxed); - } - cmd->SetBufferSrv(3, g_r.bone_ring, 0); - } - - if (debug_mode >= 2) { - // Stable per-object colors: hash the mesh address, not the (sort-order - // dependent) item index. - const uint32_t hash = (item.mesh >> 4) * 2654435761u; - constants[32] = float((hash >> 0) & 0xFF) / 255.0f; - constants[33] = float((hash >> 8) & 0xFF) / 255.0f; - constants[34] = float((hash >> 16) & 0xFF) / 255.0f; - constants[35] = 1.0f; - } else { - constants[32] = lightmap != nullptr ? 1.0f : 0.0f; - // tint.g doubles as the "character: alpha = gloss, no alpha-test" - // marker; ropa garments rendered rigid have no bones but must not be - // alpha-clipped (their VS skinning branch is inert: zero weights). - constants[33] = (bones_bound || item.ropa) ? 1.0f : 0.0f; - constants[34] = item.unlit ? 1.0f : 0.0f; - constants[35] = 0.0f; - } - constants[36] = scene.cam_pos[0]; - constants[37] = scene.cam_pos[1]; - constants[38] = scene.cam_pos[2]; - // cam_pos.w = character shading family: > 0 switches the PS to the - // captured character-lighting branch (rows uploaded to b2 below); - // char_rows[14*4+1] stays 0 when the capture failed validation, which - // keeps the item on the legacy empirical shading. - constants[39] = 0.0f; - if (debug_mode == 0 && item.char_family != 0 && - item.char_rows[14 * 4 + 1] > 0.0f) { - const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u; - if (offset + 256u <= RendererState::kBoneRegionSize) { - std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.char_rows, - sizeof(item.char_rows)); - g_r.bone_ring_offset = offset + 256u; - cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region + offset); - constants[39] = item.char_rows[14 * 4 + 1]; - g_char_drawn.fetch_add(1, std::memory_order_relaxed); - } - } - // cam_pos.w = -family selects the exact world-material branch. Gated on - // the frame rows being captured (scene.shadow_valid carries the scene - // exposure / material multiplier at b1); without them the tone chain - // would multiply by zero and render black. - if (debug_mode == 0 && item.env_family != 0 && scene.shadow_valid && - !item.water && !item.transparent) { - constants[39] = -float(item.env_family); - } - // cam_pos.w = -(20 + variant) selects the exact dynamicobject branch - // (rigid props). Gated on the frame's dynamicobject lighting rows having - // been captured (dyn_* at b1); without them the tone chain multiplies by - // zero and renders black; fall back to the legacy shading otherwise. - if (debug_mode == 0 && item.dynobj != 0 && scene.dynobj_valid) { - constants[39] = -float(20 + item.dynobj); - g_dynobj_drawn.fetch_add(1, std::memory_order_relaxed); - } - std::memcpy(constants + 40, item.tint, 4 * sizeof(float)); - // t3 = macro grime/crack overlay, t4 = decal art for environment.decal - // surfaces (in-shader composite). Independent slots: decal ground/wall - // sections carry the same macrooverlay as their non-decal neighbors, and - // binding only the art dropped the macro multiply there; alternating - // plaza sections rendered ~1.4x too bright (the ground checkerboard). - const GuestTexture* macro_tex = - item.macro_tex != 0 && REXCVAR_GET(skate3_native_render_scene_macro) - ? resolve_texture(item.macro_tex, 2) - : &g_r.white; - if ((item.water || item.char_family >= 6) && item.water_normal != 0) { - // Water rides its ripple normal map in the macro slot (water never - // carries a macro overlay; overlay.z stays 0 below so the macro - // composite path never runs). Vehicles do the same with their DXN - // panel normal map; without it the hinged panels' vertex normals - // face away from the sun and shade as a dark ambient-blue patch that - // stops at the door seams (the exact PS with a FLAT map reproduces - // that artifact; with the real map it matches the emulated car). - macro_tex = resolve_texture(item.water_normal, 3); - } - // Water / vehicle environment cube (t6, root param 8): decoded once per - // guest object into the cube cache; the gray fallback cube otherwise. - // Vehicle materials carry an `environment` channel that resolves through - // the same chan+0x1C path as the ocean's. Cube decodes run on the - // workers (a SINGLE inline cube decode measured up to ~100 ms, the - // largest remaining traversal hitch when a vehicle / reflective area - // streamed in); flat-gray reflections for the 1-3 frames in flight are - // invisible. - const GuestTexture* cube_tex = &g_r.white_cube; - if ((item.water || item.char_family >= 6 || - (item.env_family >= 5 && item.env_family <= 6) || - item.env_family == 13) && - item.water_env != 0) { - auto cit = g_r.cube_textures.find(item.water_env); - if (cit == g_r.cube_textures.end()) { - if (!item.retained) { - EnqueueCubeMiss(item.water_env); - } - } else if (cit->second.valid) { - cube_tex = &cit->second; - } - } - const GuestTexture* decal_tex = item.decal && item.decal_art != 0 && - REXCVAR_GET(skate3_native_render_scene_decals) - ? resolve_texture(item.decal_art, 4) - : &g_r.white; - // Streamed-artwork decal override (see DrawItem::decal_fetch): ad frames - // covered by an environment.decal section get the current event-ad art - // bound over the decal channel at draw time. - if (item.decal && item.decal_fetch[1] != 0 && - REXCVAR_GET(skate3_native_render_scene_decals)) { - const GuestTexture* ad = resolve_fetch_words( - item.decal_fetch, (uint64_t(item.mesh) << 1) | 1, item.retained); - if (ad != nullptr) { - decal_tex = ad; - } - } - if (((item.char_family >= 4 && item.char_family <= 5) || item.char_alpha) && - item.hair_alpha_tex != 0) { - // Hair strand coverage rides the (otherwise unused) decal slot; the - // PS hair branch samples it at the raw second texcoord. The white - // fallback keeps failed decodes opaque rather than invisible. - // character.alpha lenses use the same plumbing (ch_misc.z signals the - // fam-1/2 branch to take alpha from this texture). - decal_tex = resolve_texture(item.hair_alpha_tex, 5); - } - // F7 ring: stamp the SERVED content fingerprints (see SceneRingItem); - // pointer identity cannot see an in-place content swap. - if (ring_frame != nullptr) { - const auto rit = ring_map.find(&item); - if (rit != ring_map.end()) { - SceneRingItem& ri = ring_frame->items[rit->second]; - const auto fp_of = [](const GuestTexture* t) -> uint64_t { - return t != nullptr && t->valid ? t->payload_fp : 0; - }; - ri.fp_diffuse = fp_of(diffuse); - ri.fp_lightmap = fp_of(lightmap); - ri.fp_macro = fp_of(macro_tex); - ri.fp_decal = fp_of(decal_tex); - } - } - if (g_trace_mesh_addr != 0 && item.mesh == g_trace_mesh_addr) { - // Traced-mesh per-frame summary: SERVED state (post-resolve), logged - // when anything changes. The mesh's texture objects also register for - // the worker-commit trace. - const auto fp_of = [](const GuestTexture* t) -> uint64_t { - return t != nullptr && t->valid ? t->payload_fp : 0; - }; - for (const GuestTexture* t : - {diffuse, lightmap, macro_tex, decal_tex}) { - if (t != nullptr && t != &g_r.white) { - g_trace_keys.insert(FetchWordsKey(t->fetch_words)); - } - } - uint64_t sig = uint64_t(item.diffuse_tex) ^ - (uint64_t(item.decal_art) << 16) ^ - (uint64_t(item.lightmap_tex) << 32) ^ - (uint64_t(item.macro_tex) << 48); - sig ^= fp_of(diffuse) * 3 ^ fp_of(decal_tex) * 5 ^ fp_of(lightmap) * 7 ^ - fp_of(macro_tex) * 11; - sig ^= item.retained ? 1 : 0; - uint64_t& last = g_trace_sig[item.ctx]; - if (sig != last) { - last = sig; - REXLOG_INFO( - "tex-trace: f{} SERVED ctx={:08X} retained={} " - "dif={:08X}/{:016X} dec={:08X}/{:016X} lm={:08X}/{:016X} " - "mac={:08X}/{:016X}", - frame_number, item.ctx, item.retained ? 1 : 0, item.diffuse_tex, - fp_of(diffuse), item.decal_art, fp_of(decal_tex), - item.lightmap_tex, fp_of(lightmap), item.macro_tex, - fp_of(macro_tex)); - } - } - // Exact env families without decal art bind the material's spec/ecc/ - // refmask map (or the animated.tree noise tint) in the free decal slot; - // overlay.w == 3 tells the shader the masks are live. - bool spec_bound = false; - if (item.env_family != 0 && !item.decal && item.env_family != 10 && - item.spec_tex != 0) { - const GuestTexture* spec = resolve_texture(item.spec_tex, 6); - if (spec != &g_r.white) { - decal_tex = spec; - spec_bound = true; - } - } - // Sky dome: the material's `specular` channel is the 1D radial sun - // gradient (512x16), bound in the free decal slot for the exact sky - // branch. Until it decodes the dome falls back to the plain fullbright - // panorama (sunless for the 1-3 frames in flight). - bool sky_sun_bound = false; - if (item.unlit && item.spec_tex != 0) { - const GuestTexture* sun = resolve_texture(item.spec_tex, 6); - if (sun != &g_r.white) { - decal_tex = sun; - sky_sun_bound = true; - } - } - const bool is_decal = - item.char_family < 4 && item.decal && decal_tex != &g_r.white; - // Fam 5/6 masks+normal pair (t4/t5): the reflective PS perturbs its - // reflection/spec with the material's normal map (shader overlay.w == 4 - // branch, the fix for the giant flat-normal cube smear on glass - // facades). Both views bind together at the draw via SetTexturePair; - // pair descriptors are backend-cached, so prewarm/revalidation texture - // swaps can never leave a stale descriptor. Until the normal map - // decodes, overlay.w stays 3 (flat-normal reflection, the old behavior). - const GuestTexture* pair_normal = nullptr; - bool normal_paired = false; - if (item.env_family >= 5 && item.env_family <= 6) { - uint32_t gate = 0; - if (!spec_bound) gate |= 1; - if (item.water_normal == 0) gate |= 2; - if (spec_bound && (!decal_tex->valid || decal_tex->srv_mips == 0)) gate |= 4; - if (gate == 0) { - const GuestTexture* nrm = resolve_texture(item.water_normal, 3); - if (nrm == &g_r.white) { - gate |= 8; - } else { - if (!nrm->valid) gate |= 16; - if (nrm->srv_mips == 0) gate |= 32; - } - if (gate == 0) { - pair_normal = nrm; - normal_paired = true; - } - } - if (normal_paired) { - g_refl_pair.fetch_add(1, std::memory_order_relaxed); - } else { - g_refl_flat.fetch_add(1, std::memory_order_relaxed); - g_refl_gate.store(gate, std::memory_order_relaxed); - } - } - // World-shading v2 (fams 1-4): the material's real per-pixel maps. The - // base normal map rides the free t5 pair slot (t4 holds the spec masks - // or the decal art there), the detail normal map + the decal families' - // spec/ecc masks ride the t8/t9 pair. misc.z carries the bind flags - // (1 = normal, 2 = detail, 4 = spec2), misc.w the detailNormalUVScale - // channel constant; both slots are spare on opaque fams 1-4 (fog only - // uses them on transparent/water items, F12 only on fams 5/6/13). - const GuestTexture* v2_detail = nullptr; - const GuestTexture* v2_spec2 = nullptr; - uint32_t v2_flags = 0; - if (item.env_family >= 1 && item.env_family <= 4 && !item.transparent && - !item.water && debug_mode == 0 && scene.shadow_valid && - REXCVAR_GET(skate3_native_render_scene_world_v2)) { - if (item.water_normal != 0) { - const GuestTexture* nrm = resolve_texture(item.water_normal, 3); - if (nrm != &g_r.white && nrm->valid && nrm->srv_mips != 0) { - pair_normal = nrm; - normal_paired = true; // t5 pair bind; overlay.w stays 0..3 here - v2_flags |= 1; - } - } - // Fam 2 (environmentsimple) carries a plain normal map with no - // base+detail pair; its vnd is the raw 2t-1 (shader-side). - if ((v2_flags & 1u) != 0 && item.env_family != 2 && - item.detail_tex != 0 && item.detail_scale > 0.0f) { - const GuestTexture* det = resolve_texture(item.detail_tex, 7); - if (det != &g_r.white && det->valid && det->srv_mips != 0) { - v2_detail = det; - v2_flags |= 2; - } - } - if (item.decal && item.spec_tex != 0) { - const GuestTexture* sp = resolve_texture(item.spec_tex, 6); - if (sp != &g_r.white && sp->valid && sp->srv_mips != 0) { - v2_spec2 = sp; - v2_flags |= 4; - } - } - } - // Dynamicobject v2: the same misc.z/w convention and t5/t8/t9 slots as - // fams 1-4; dynobj draws are never transparent/water and carry - // env_family 0, so both misc slots are free, and t4 stays on its white - // fallback (the spec-mask t4 bind is env-family-only), so the t5 pair - // costs nothing. The spec/ecc masks go to t9 rather than t4: it keeps - // t4's semantics untouched and matches the decal families' 2-channel - // mask convention. Gated exactly like the PS branch selection (dynobj - // frame rows captured) plus the dynobj_v2 cvar for A/B against the v1 - // flat response. - if (item.dynobj != 0 && debug_mode == 0 && scene.dynobj_valid && - REXCVAR_GET(skate3_native_render_scene_dynobj_v2)) { - if (item.water_normal != 0) { - const GuestTexture* nrm = resolve_texture(item.water_normal, 3); - if (nrm != &g_r.white && nrm->valid && nrm->srv_mips != 0) { - pair_normal = nrm; - normal_paired = true; - v2_flags |= 1; - } - } - if ((v2_flags & 1u) != 0 && item.detail_tex != 0 && - item.detail_scale > 0.0f) { - const GuestTexture* det = resolve_texture(item.detail_tex, 7); - if (det != &g_r.white && det->valid && det->srv_mips != 0) { - v2_detail = det; - v2_flags |= 2; - } - } - if (item.spec_tex != 0) { - const GuestTexture* sp = resolve_texture(item.spec_tex, 6); - if (sp != &g_r.white && sp->valid && sp->srv_mips != 0) { - v2_spec2 = sp; - v2_flags |= 4; - } - } - // Static world-shadow map at t4 (flag 8): the native re-render of the - // game's baked-shade map (see RenderShadowAtlas). t4 is otherwise the - // white fallback on dynobj draws. Without it the PS world term is 1 - // and props inside baked building shade light at full key. - if (g_r.world_shadow_srv != nullptr && g_r.world_shadow_in_srv && - scene.dynobj_ws_valid) { - v2_flags |= 8; - } - } - constants[44] = item.macro_scale; - constants[45] = item.macro_opacity; - constants[46] = macro_tex != &g_r.white ? 1.0f : 0.0f; - // overlay.w: 1 = single-placement decal (art clamps), 2 = tileable - // decal (art wraps; clamping a many-period uv range stretched the - // border texels into the giant cliff-face streaks), 3 = spec masks - // bound (exact env families). The 4-encode (t5 normal pair driving the - // reflective trim path) is fams 5/6 only; fams 1-4 signal their - // normal map through the misc.z flags instead. - constants[47] = - is_decal ? (item.decal_tileable ? 2.0f : 1.0f) - : (spec_bound ? ((normal_paired && item.env_family >= 5) - ? 4.0f - : 3.0f) - : 0.0f); - if (item.water) { - // overlay.x = ripple scroll time, overlay.y = real environment cube - // bound at t6, overlay.z = ripple normal map resolved (in the macro - // slot; the shader synthesizes procedural ripples otherwise). - // overlay.w = 1 marks water with NO diffuse channel (ocean.default): - // the body term must be zero (ocean.fx diffTerm = 0); the white - // fallback diffuse otherwise renders the whole sea as a bright plain. - constants[44] = water_time; - constants[45] = cube_tex != &g_r.white_cube ? 1.0f : 0.0f; - constants[46] = macro_tex != &g_r.white ? 1.0f : 0.0f; - constants[47] = item.diffuse_tex == 0 ? 1.0f : 0.0f; - } else if (item.char_family >= 6) { - // Vehicles reuse the water convention: overlay.y > 0 = a real - // environment cube bound at t6 (the PS vehicle branch's reflection - // term). Vehicles never carry macro/decal channels, so the macro - // defaults staged above are inert, but overlay.y must not inherit - // macro_opacity's 1.0 default when no cube resolved. - constants[45] = cube_tex != &g_r.white_cube ? 1.0f : 0.0f; - } - // misc.x: alpha-blended sub-pass item (1 = transparentenvironment - // shading, 2 = water branch); fog rides in misc.yzw (ramp) and the - // mat_tint row (color), unused by these items otherwise - // (root-signature DWORD budget). - constants[48] = item.water ? 2.0f : (item.transparent ? 1.0f : 0.0f); - if (item.transparent || item.water) { - constants[49] = scene.fog_ramp[0]; - constants[50] = scene.fog_ramp[1]; - constants[51] = scene.fog_ramp[2]; - std::memcpy(constants + 40, scene.fog_color, 4 * sizeof(float)); - } else if (v2_flags != 0) { - // misc.z = the v2 material bind flags, misc.w = detailNormalUVScale - // (opaque fams 1-4 and the dynobj families; see the v2 blocks above). - constants[50] = float(v2_flags); - constants[51] = item.detail_scale; - } else if ((item.env_family >= 5 && item.env_family <= 6) || - item.env_family == 13) { - // misc.y = cube LOD bias for the reflective families: the guest's - // cube fetch computes its gradient LOD at the game's own 1152x640 - // render; at 4K our reflection-vector gradients are ~3.4x smaller - // per pixel, so mips alone leave baked cube detail (the streetlight - // heads) visible that the console blurs away. - // misc.z/misc.w = the F12 reflection-isolation controls (see the - // refl_mode cvar; both spare on opaque items; the fog packing only - // uses these slots on transparent/water). - constants[49] = - log2f(std::max(1.0f, float(context.guest_output_height) / 640.0f)); - constants[50] = float(REXCVAR_GET(skate3_native_render_scene_refl_mode)); - constants[51] = float(REXCVAR_GET(skate3_native_render_scene_refl_lod)); - // misc.x (spare on opaque fam 5/6): both constant normal-tilt trims, - // fixed-point packed (each mapped to 0..999 around 500; float-exact). - // Only when the EXACT branch will run (same gate as cam_pos.w = -fam) - // - the legacy fallback reads misc.x as the transparent/water flag. - // Fam 13 has no normal map (its PS reflects off the vertex normal), - // so its misc.x stays 0. - if (debug_mode == 0 && scene.shadow_valid && item.env_family != 13) { - double bx = REXCVAR_GET(skate3_native_render_scene_refl_bias_x); - double by = REXCVAR_GET(skate3_native_render_scene_refl_bias_y); - if (REXCVAR_GET(skate3_native_render_scene_refl_bias_auto) && - item.detail_tex != 0) { - // Derive the fold from the material's own detail texture: its - // first BC1 block decoded with hardware bit replication, texels - // averaged, folded as 2*d - 1. Cached per texture object; the - // packed-tile quirk of <=16px textures is benign here (neighbor - // packed mips of a constant texture hold the same constant). - // Implausible results (non-DXT1 / unreadable / |fold| > 0.1) - // keep the cvar values. - static std::unordered_map> fold_cache; - auto fit = fold_cache.find(item.detail_tex); - if (fit == fold_cache.end()) { - std::pair fold{float(bx), float(by)}; - uint32_t raw[6]; - if (GuestTryCopy(raw, base + item.detail_tex + 7 * 4, sizeof(raw))) { - const uint32_t w0 = SwapU32(raw[0]); - const uint32_t w1 = SwapU32(raw[1]); - uint8_t block[8]; - if ((w0 & 3) == 2 && (w1 & 0x3F) == 0x12 && - GuestTryCopy(block, - base + (0xA0000000u | ((w1 >> 12) << 12)), 8)) { - uint8_t le[8]; - for (int k = 0; k < 8; k += 2) { // k_8in16 guest endianness - le[k] = block[k + 1]; - le[k + 1] = block[k]; - } - uint8_t px[16][4]; - DecodeBc1Block(le, px); - float ax = 0.0f, ay = 0.0f; - for (int k = 0; k < 16; ++k) { - ax += px[k][0]; - ay += px[k][1]; - } - const float fx = (ax / 16.0f) * (2.0f / 255.0f) - 1.0f; - const float fy = (ay / 16.0f) * (2.0f / 255.0f) - 1.0f; - if (std::fabs(fx) < 0.1f && std::fabs(fy) < 0.1f) { - fold = {fx, fy}; - } - REXLOG_INFO( - "native-scene: detail fold tex={:08X} = ({:+.6f}, {:+.6f})", - item.detail_tex, fx, fy); - } - } - fit = fold_cache.emplace(item.detail_tex, fold).first; - } - bx = fit->second.first; - by = fit->second.second; - } - const auto pack_trim = [](double v) { - int i = int(std::lround(v * 1000.0)) + 500; - return std::clamp(i, 0, 999); - }; - constants[48] = float(pack_trim(bx) + 1000 * pack_trim(by)); - } - } - // cam_pos.w = -40 selects the exact sky branch: the game computes the - // sun glow inside the dome shader (see the PS sky branch). Needs the - // frame's captured sky sun rows AND the 1D gradient bound above; falls - // back to the legacy fullbright dome otherwise. The sky item never uses - // mat_tint/overlay/misc, so those root constants carry the sun rows. - if (debug_mode == 0 && item.unlit && sky_sun_bound && scene.sky_sun_valid) { - constants[39] = -40.0f; - constants[40] = scene.sky_sun[0]; // mat_tint.xyz = sun direction - constants[41] = scene.sky_sun[1]; - constants[42] = scene.sky_sun[2]; - constants[43] = scene.sky_height; // mat_tint.w = dome viewpos Y - constants[44] = scene.sky_sun[3]; // overlay.x = sun angular scale - constants[45] = scene.sky_sun[4]; // overlay.y = pre-tone multiplier - constants[46] = 0.0f; - constants[47] = 0.0f; - constants[49] = scene.sky_sun[5]; // misc.y = scene exposure - } - // SSR: record reflective items, env fams 5/6/13 on their exact branch - // with live spec masks (the reflection mask rides t4.z), plus water, - // for the reflection G-buffer pass after the scene pass. - if (ssr_on && !item.skinned && - (item.water || - ((item.env_family == 5 || item.env_family == 6 || - item.env_family == 13) && - constants[39] < -4.5f && constants[47] > 2.5f))) { - SsrGbufItem si; - std::memcpy(si.constants, constants, sizeof(constants)); - si.t3 = macro_tex->srv; - si.t4 = decal_tex->srv; - si.t5 = normal_paired ? pair_normal->srv : g_r.white.srv; - si.mesh = item.mesh; - si.fingerprint = item.fingerprint; - si.item = &item; - ssr_items.push_back(si); - } - cmd->SetRootConstants(0, 52, constants, 0); - - cmd->SetTexture(1, diffuse->srv); - cmd->SetTexture(2, (lightmap != nullptr ? lightmap : &g_r.white)->srv); - cmd->SetTexture(4, macro_tex->srv); - // t4 override: the dynobj world-shadow map rides the free first entry - // of the t4/t5 pair table (flag 8: dynobj draws never bind decal art - // or spec masks there). - nrhi::TextureView* t4_view = - (v2_flags & 8u) != 0 ? g_r.world_shadow_srv : decal_tex->srv; - if (normal_paired || t4_view != decal_tex->srv) { - cmd->SetTexturePair(5, t4_view, - normal_paired ? pair_normal->srv : g_r.white.srv); - } else { - cmd->SetTexture(5, decal_tex->srv); - } - // t6 (cube) shares its table with t7 (shadow atlas); re-pair both. - cmd->SetTexturePair(7, cube_tex->srv, - shadow_ready ? g_r.shadow_srv_final : g_r.white.srv); - // World-shading v2 material pair (t8 detail + t9 decal spec). - if (v2_flags != 0) { - cmd->SetTexturePair(8, (v2_detail != nullptr ? v2_detail : &g_r.white)->srv, - (v2_spec2 != nullptr ? v2_spec2 : &g_r.white)->srv); - } - // ROPA shape blend (see RendererState::ropa_shapes): combine the shape - // generations with the kernel weights InterpolateDynamicItems computed - // (the SAME 8-tap boxcar / pair-lerp the body bones and garment world - // took this frame) into the per-frame ropa upload ring and draw from - // it; the stepped cloth shape against the interpolated body was the - // tee jelly/clip-through, and a filter-MISMATCHED blend (plain lerp vs - // boxcar body) kept a period-scaled residue of it. Positions/normals - // (floats 0..6) blend; packed attributes (blend words, uvs: floats - // 7..13) copy from the newest generation present. Generations missing - // from the ring (evicted / decode in flight) renormalize over what IS - // present when at least half the kernel's weight survives. - VbBinding item_vbv = buffers.vb_view; - if (item.ropa && item.shape_count > 0 && - REXCVAR_GET(skate3_native_render_scene_ropa_blend)) { - const std::vector* gv[DrawItem::kShapeGens] = {}; - float gw[DrawItem::kShapeGens] = {}; - int ng = 0; - float total = 0.0f; - uint64_t newest_seq = 0; - const std::vector* newest = nullptr; - // Generations must match the RESIDENT decode's extent exactly; the - // index buffer references that many vertices. After a re-stream/ - // outfit swap the ring briefly holds stale-size generations; binding - // one against the current IB reads past the bound VB and collapses - // triangles (a momentary partial-invisible blink). Stale sizes drop - // out here; if too much kernel weight is stale, the raw resident VB - // draws instead (always self-consistent). - const size_t want_floats = - size_t(buffers.vb_view.size_bytes) / sizeof(float); - const auto rit = g_r.ropa_shapes.find(item.mesh); - if (rit != g_r.ropa_shapes.end() && !rit->second.empty()) { - // Sim-sleep gate: generations grossly older than the ring's newest - // were recorded before a cloth-sim sleep (continuous streams span - // ~114 ms end to end). Blending one against the fresh drape draws - // the garment where/how the character USED to stand, the - // detached/perpendicular class. Dropped generations shed their - // kernel weight; if too much weight is stale the raw resident VB - // draws instead (self-consistent current shape). - const double ring_newest_t = rit->second.back().t; - for (int k = 0; k < item.shape_count; ++k) { - for (const RendererState::RopaGen& g : rit->second) { - if (g.seq != item.shape_seq[k]) { - continue; - } - if (g.verts.size() != want_floats) { - break; // stale-size generation (re-stream/outfit swap) - } - if (ring_newest_t - g.t > 0.35) { - static std::atomic s_stale{0}; - const uint32_t ln = - s_stale.fetch_add(1, std::memory_order_relaxed); - if (ln < 16 || (ln & 1023u) == 0) { - REXLOG_INFO( - "native-scene: ropa shape STALE gen dropped mesh={:08X} " - "age={:.2f}s seq_gap={} (n={})", - item.mesh, ring_newest_t - g.t, - rit->second.back().seq - g.seq, ln); - } - break; - } - gv[ng] = &g.verts; - gw[ng] = item.shape_w[k]; - total += item.shape_w[k]; - ++ng; - if (g.seq >= newest_seq) { - newest_seq = g.seq; - newest = &g.verts; - } - break; - } - } - } - const uint32_t region = - uint32_t(frame_number % RendererState::kBoneRegions) * - RendererState::kRopaRegionSize; - const uint32_t bytes = uint32_t(want_floats * sizeof(float)); - if (ng > 0 && total >= 0.5f && newest != nullptr && - buffers.vb_view.stride == 56 && - g_r.ropa_ring_offset + bytes <= RendererState::kRopaRegionSize) { - float* dst = reinterpret_cast(g_r.ropa_ring_cpu + region + - g_r.ropa_ring_offset); - const float inv = 1.0f / total; - for (size_t v = 0; v + 14 <= want_floats; v += 14) { - float blend7[7] = {}; - for (int k = 0; k < ng; ++k) { - const float* src = gv[k]->data() + v; - const float wk = gw[k] * inv; - for (int f = 0; f < 7; ++f) { - blend7[f] += src[f] * wk; - } - } - // One store per float: dst is write-combined upload memory. - std::memcpy(dst + v, blend7, sizeof(blend7)); - std::memcpy(dst + v + 7, newest->data() + v + 7, 7 * sizeof(float)); - } - item_vbv.buffer = g_r.ropa_ring; - item_vbv.offset = region + g_r.ropa_ring_offset; - item_vbv.size_bytes = bytes; - item_vbv.stride = buffers.vb_view.stride; - g_r.ropa_ring_offset += (bytes + 255u) & ~255u; - g_ropa_blend_drawn.fetch_add(1, std::memory_order_relaxed); - } else { - g_ropa_blend_miss.fetch_add(1, std::memory_order_relaxed); - } - } - cmd->SetVertexBuffer(item_vbv.buffer, item_vbv.offset, item_vbv.size_bytes, - item_vbv.stride); - cmd->SetIndexBuffer(buffers.ib_view.buffer, buffers.ib_view.offset, - buffers.ib_view.size_bytes); - for (const DrawEntry& draw : item.draws) { - if (draw.prim == 4) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - } else if (draw.prim == 6) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); - } else { - continue; - } - cmd->DrawIndexed(draw.index_count, draw.start_index, draw.base_vertex); - ++drawn; - if (ring_frame != nullptr) { - const auto rit = ring_map.find(&item); - if (rit != ring_map.end()) { - ++ring_frame->items[rit->second].drawn; - } - } - } - }; - - // Opaque items first; environment.transparent items are deferred to an - // alpha-blended sub-pass (depth test on, z-write off) drawn back-to-front; - // interleaved opaque rendering alpha-tested the mist sheets into solid - // white cloud blobs. - const auto items_t0 = PerfClock::now(); - const auto view_dist2 = [&](const DrawItem& item) { - float c[3], w[3]; - for (int a = 0; a < 3; ++a) { - c[a] = (item.bbox_min[a] + item.bbox_max[a]) * 0.5f; - } - for (int a = 0; a < 3; ++a) { - w[a] = c[0] * item.world[0 * 4 + a] + c[1] * item.world[1 * 4 + a] + - c[2] * item.world[2 * 4 + a] + item.world[3 * 4 + a]; - } - float d2 = 0.0f; - for (int a = 0; a < 3; ++a) { - const float d = w[a] - scene.cam_pos[a]; - d2 += d * d; - } - return d2; - }; - std::vector transparent_items; - // Mid-fade entity items (see CharFadeAlpha / pso_fade): blended but with - // z-write ON, drawn at the HEAD of the blended sub-pass; they behave like - // main-pass objects whose glass/hair still composites over them, and depth - // writes stop their own overlapping pieces (skin under clothes, far-side - // doors/wheels through the body shell) from double-blending into an x-ray. - const auto char_fade_zwrite = [&](const DrawItem& it) { - return debug_mode == 0 && it.char_rows[14 * 4 + 1] > 0.0f && - (it.char_family == 1 || it.char_family == 2 || - it.char_family == 3 || it.char_family == 6) && - REXCVAR_GET(skate3_native_render_scene_entity_fade) && - CharFadeAlpha(it) < 0.999f; - }; - // Opaque items draw front-to-back (bbox-center distance): early-z rejects - // occluded pixels before the heavy material PS runs. The game's sort-list - // order is by render state, not depth; depth-write LESS_EQUAL makes the - // final image order-independent, so reordering is safe. - std::vector> opaque_items; - opaque_items.reserve(scene.items.size()); - for (const DrawItem& item : scene.items) { - const uint32_t index = item_index++; - if (debug_mode == 1) { - break; - } - if (debug_mode == 3 && index >= 20) { - break; - } - if (ring_frame != nullptr) { - SceneRingItem ri{}; - ri.ctx = item.ctx; - ri.mesh = item.mesh; - ri.diffuse = item.diffuse_tex; - ri.lightmap = item.lightmap_tex; - ri.vb_obj = item.vb_obj; - ri.spec = item.spec_tex; - ri.macro = item.macro_tex; - ri.decal_art = item.decal_art; - ri.wnormal = item.water_normal; - uint32_t idx_total = 0; - for (const DrawEntry& e : item.draws) { - idx_total += e.index_count; - } - ri.indices = idx_total; - ri.env_family = item.env_family; - ri.char_family = item.char_family; - ri.flags = uint8_t((item.transparent ? 1u : 0u) | (item.water ? 2u : 0u) | - (item.skinned ? 4u : 0u) | (item.retained ? 8u : 0u) | - (item.pending ? 16u : 0u) | - (item.caster_bank ? 32u : 0u) | - (item.decal ? 64u : 0u)); - ring_map.emplace(&item, uint32_t(ring_frame->items.size())); - ring_frame->items.push_back(ri); - } - // Hair with a validated lighting capture joins the sorted alpha - // sub-pass (strand coverage blend, depth test on / z-write off, the - // game's own hair render state); without the capture it stays on the - // legacy opaque path. Vehicle glass (fam 7) blends there too, - // reflection-only windows at the captured alpha; vehicle bodies (fam 6) - // stay opaque. - const bool char_capture_ok = item.char_rows[14 * 4 + 1] > 0.0f; - const bool hair_blend = item.char_family >= 4 && item.char_family <= 5 && - char_capture_ok; - const bool glass_blend = item.char_family == 7 && char_capture_ok; - // character.alpha accessory pieces (sunglass lenses): translucent, alpha - // from the coverage texture at uv2 (ch_misc.z set by the capture); the - // game draws them after every opaque character piece (see - // DrawItem::char_alpha). Gated on the SAME capture validation as hair: - // without validated rows ch_misc.z is 0 and the item stays opaque. - const bool cac_alpha_blend = item.char_alpha && char_capture_ok && - item.char_rows[14 * 4 + 2] > 0.0f; - // Flicker probes: a hair/character-alpha piece whose pass route flips - // between consecutive frames, or whose bone palette returns bit-exact - // to the previous-but-one frame's value (A,B,A,B: genuine motion - // never round-trips in two rendered frames), is the visible hair - // flicker. - if (item.char_family >= 4 || item.char_alpha) { - struct RouteProbe { - uint8_t route = 0; - uint64_t bones_h1 = 0; // last frame's palette hash - uint64_t bones_h2 = 0; // the frame before - }; - static std::unordered_map s_probe; // render thread - const uint64_t rk = (uint64_t(item.mesh) << 32) | item.ctx; - const uint8_t route = uint8_t((hair_blend || cac_alpha_blend) ? 2 : 1); - uint64_t bones_h = 1469598103934665603ull; - if (!item.bones.empty()) { - const uint8_t* bb = reinterpret_cast(item.bones.data()); - const size_t bn = item.bones.size() * sizeof(float); - for (size_t bi = 0; bi < bn; ++bi) { - bones_h = (bones_h ^ bb[bi]) * 1099511628211ull; - } - } - auto [rit2, fresh2] = s_probe.try_emplace(rk); - RouteProbe& pr = rit2->second; - if (!fresh2 && pr.route != route) { - g_hair_route_flips.fetch_add(1, std::memory_order_relaxed); - static std::atomic s_flip_logged{0}; - const uint32_t fl = s_flip_logged.fetch_add(1, std::memory_order_relaxed); - if (fl < 24 || (fl & 511u) == 0) { - REXLOG_INFO( - "native-scene: hair ROUTE FLIP mesh={:08X} ctx={:08X} fam={} " - "alpha={} -> route {} rows14=({:.3f},{:.3f},{:.3f}) (n={})", - item.mesh, item.ctx, item.char_family, item.char_alpha ? 1 : 0, - route, item.char_rows[14 * 4 + 0], item.char_rows[14 * 4 + 1], - item.char_rows[14 * 4 + 2], fl); - } - } - if (!fresh2 && !item.bones.empty() && bones_h != pr.bones_h1 && - bones_h == pr.bones_h2) { - g_hair_bone_alternations.fetch_add(1, std::memory_order_relaxed); - static std::atomic s_bone_alt_logged{0}; - const uint32_t bl = s_bone_alt_logged.fetch_add(1, std::memory_order_relaxed); - if (bl < 24 || (bl & 1023u) == 0) { - REXLOG_INFO( - "native-scene: hair BONES ALTERNATION mesh={:08X} ctx={:08X} " - "fam={} bones={} src={} caster_bank={} (n={})", - item.mesh, item.ctx, item.char_family, item.bones.size() / 12, - item.dbg_src, item.caster_bank ? 1 : 0, bl); - } - } - pr.route = route; - pr.bones_h2 = pr.bones_h1; - pr.bones_h1 = bones_h; - if (s_probe.size() > 4096) { - s_probe.clear(); - } - } - // Per-entity spawn/distance fade (CharFadeAlpha): the game submits - // LivingWorld entity draws at alpha 0 through the whole spawn settle - // (NPCs drop ~1 m to the ground before fading in) and ramps alpha with - // distance; skip invisible items entirely, blend mid-fade ones. - // LW-mapped items (lw_alpha >= 0) do not need a validated lighting - // capture to honor the fade: the entity alpha is authoritative even - // when the capture chain failed; a spawn-settling NPC is invisible - // regardless of whether its rows validated this frame. - const bool entity_fade = debug_mode == 0 && item.char_family != 0 && - (char_capture_ok || item.lw_alpha >= 0.0f) && - REXCVAR_GET(skate3_native_render_scene_entity_fade); - const float fade_a = entity_fade ? CharFadeAlpha(item) : 1.0f; - // One-frame fade-blink guard: a mesh that rendered ~opaque last frame - // cannot legitimately sit at alpha 0 this frame; the game's spawn/ - // distance fades ramp over ~0.5 s. An opaque->0 step means a garbage/ - // foreign constant row served the alpha this capture (the clone-shared - // char-rows suspect; observed as part of the tee flickering invisible - // for a moment). Repair: draw the item OPAQUE this frame (skip the fade - // skip + the mid-fade blend routing) and log it. The last-alpha map - // updates from the RAW value, so a persisting alpha 0 (real despawn / - // spawn settle) only gets one repaired frame and then skips normally. - // LW-mapped items bypass the blink repair entirely (read AND write): - // the entity alpha cannot blink; the repair exists for garbage/foreign - // CAPTURED rows, and its mesh key is clone-shared, which force-drew - // spawn-settling clones OPAQUE every frame their twin was visible (the - // "NPC drops out of the sky with no fade" sighting). - bool fade_blink = false; - if (item.char_family != 0 && debug_mode == 0 && item.lw_alpha < 0.0f) { - static std::unordered_map s_fade_opaque; // render thread - uint8_t& was_opaque = s_fade_opaque[item.mesh]; - if (entity_fade && fade_a <= 0.004f && was_opaque) { - fade_blink = true; - static std::atomic s_blinks{0}; - const uint64_t n = s_blinks.fetch_add(1, std::memory_order_relaxed); - if (n < 16 || (n & 255u) == 0) { - REXLOG_INFO( - "native-scene: fade BLINK repaired mesh={:08X} fam={} ropa={} " - "src={} rows13=({:.3f},{:.3f},{:.3f},{:.3f}) " - "rows14=({:.3f},{:.3f}) (n={})", - item.mesh, item.char_family, item.ropa ? 1 : 0, item.dbg_src, - item.char_rows[13 * 4 + 0], item.char_rows[13 * 4 + 1], - item.char_rows[13 * 4 + 2], item.char_rows[13 * 4 + 3], - item.char_rows[14 * 4 + 0], item.char_rows[14 * 4 + 1], n); - } - } - was_opaque = (!entity_fade || fade_a > 0.9f) ? 1 : 0; - if (s_fade_opaque.size() > 1024) { - s_fade_opaque.clear(); - } - } - if (entity_fade && fade_a <= 0.004f && !fade_blink) { - if (item.lw_alpha >= 0.0f) { - g_lw_fade0.fetch_add(1, std::memory_order_relaxed); - } - continue; - } - const bool char_fade_blend = char_fade_zwrite(item) && !fade_blink; - // environment.reflective_trans (fam 13): blended glass canopies, joins - // the sorted alpha sub-pass whenever its exact branch is live (same - // shadow_valid gate as cam_pos.w = -fam; the legacy fallback renders it - // opaque exactly as before classification). - const bool refl_trans_blend = - item.env_family == 13 && scene.shadow_valid; - const auto stamp_route = [&](uint8_t route) { - if (ring_frame != nullptr) { - const auto rit = ring_map.find(&item); - if (rit != ring_map.end()) { - ring_frame->items[rit->second].route = route; - } - } - }; - if ((item.transparent || item.water || hair_blend || glass_blend || - cac_alpha_blend || refl_trans_blend || char_fade_blend) && - debug_mode == 0) { - if (REXCVAR_GET(skate3_native_render_scene_transparents)) { - transparent_items.push_back(&item); - stamp_route(2); - } - continue; - } - opaque_items.emplace_back(view_dist2(item), &item); - stamp_route(1); - } - if (REXCVAR_GET(skate3_native_render_scene_sort_opaque) && debug_mode == 0) { - std::stable_sort(opaque_items.begin(), opaque_items.end(), - [](const auto& a, const auto& b) { return a.first < b.first; }); - } - for (const auto& [dist, item] : opaque_items) { - draw_item(*item); - } - if (!transparent_items.empty() && g_r.pso_transparent != nullptr) { - std::stable_sort(transparent_items.begin(), transparent_items.end(), - [&](const DrawItem* a, const DrawItem* b) { - // Fading entities (z-write blend) draw before every - // z-write-off blend so hair/glass composite over them. - const bool fa = char_fade_zwrite(*a); - const bool fb = char_fade_zwrite(*b); - if (fa != fb) { - return fa; - } - return view_dist2(*a) > view_dist2(*b); - }); - cmd->SetPipeline(use_depth ? g_r.pso_transparent : g_r.pso_nodepth); - nrhi::Pipeline* blend_bound = use_depth ? g_r.pso_transparent : g_r.pso_nodepth; - for (const DrawItem* item : transparent_items) { - // Mid-fade entity pieces: alpha blend with z-write ON (see pso_fade). - if (use_depth && g_r.pso_fade != nullptr && char_fade_zwrite(*item)) { - if (blend_bound != g_r.pso_fade) { - cmd->SetPipeline(g_r.pso_fade); - blend_bound = g_r.pso_fade; - } - draw_item(*item); - continue; - } - const bool hair = item->char_family >= 4 && item->char_family <= 5 && - item->char_rows[14 * 4 + 1] > 0.0f; - if (hair && use_depth && g_r.pso_hair_a != nullptr && g_r.pso_hair_b != nullptr) { - // The game's two hair passes: cull BACK then cull FRONT with the - // same shader: keeps far-side strands from compositing over - // near-side ones (one uncull(ed) pass reads as crunchy noise). - cmd->SetPipeline(g_r.pso_hair_a); - draw_item(*item); - cmd->SetPipeline(g_r.pso_hair_b); - draw_item(*item); - cmd->SetPipeline(blend_bound); - continue; - } - // reflective_trans glass culls like the game (its material family's - // XML: CULLMODE=FRONT): the canopy panels are double-glazed pane - // PAIRS a few cm apart; uncull(ed) we composited BOTH panes (an - // extra a^2 blend layer the emulated frame doesn't have). - // pso_hair_b IS the transparent state with CULL_FRONT. - if (item->env_family == 13 && use_depth && g_r.pso_hair_b != nullptr && - REXCVAR_GET(skate3_native_render_scene_backface_cull)) { - const float* w = item->world; - const float det3 = w[0] * (w[5] * w[10] - w[6] * w[9]) - - w[1] * (w[4] * w[10] - w[6] * w[8]) + - w[2] * (w[4] * w[9] - w[5] * w[8]); - nrhi::Pipeline* want = - det3 >= 0.0f ? g_r.pso_hair_b - : (use_depth ? g_r.pso_transparent : g_r.pso_nodepth); - if (want != blend_bound) { - cmd->SetPipeline(want); - blend_bound = want; - } - draw_item(*item); - continue; - } - if (blend_bound != (use_depth ? g_r.pso_transparent : g_r.pso_nodepth)) { - blend_bound = use_depth ? g_r.pso_transparent : g_r.pso_nodepth; - cmd->SetPipeline(blend_bound); - } - draw_item(*item); - } - } - g_pw_items.Add(perf_ns_since(items_t0)); - g_pw_pre.Add(uint64_t(std::chrono::duration_cast( - items_t0 - render_t0) - .count())); - const auto mid_t0 = PerfClock::now(); - - // Guest-texture resolver shared by the spline pass (pre-resolve, in the - // scene pass) and the HUD pass (post-resolve); both allocate strip/quad - // vertices from the same per-frame ui_ring region. - // Per-frame inline budget for HOT content re-decodes (actively-animating - // elements + video planes): inline keeps their content latency at ONE - // frame; overflow degrades to the async heal for the rest of the frame. - int64_t hot_inline_budget_ns = 4'000'000; - // force_inline: the dormant FMV bracket-fallback resolve stays on the - // inline decode (fires at most once per plane set). - // video: the FMV triple resolves; content changes always decode inline - // (frame-exact playback), and the caller additionally gates serving on - // last_change_frame >= the movie session start (see the triple loop). - const auto resolve_2d_texture = [&](const uint32_t fetch[6], - bool force_inline = false, - bool video = false) -> const GuestTexture* { - if ((fetch[0] & 0x3u) != 2 || fetch[1] == 0) { - return &g_r.white; - } - const uint64_t key = FetchWordsKey(fetch); - // Routing (see the 2d_async_px cvar): INLINE-FIRST under the per-frame - // budget; the run-copy untiler + in-place hot updates made decodes - // cheap (1280x720 sub-ms; no CreateCommittedResource churn on content - // changes), so first sightings AND content changes decode inline for - // 1-frame content latency. The async workers are the burst-overflow - // valve only: they exist because inline decodes stall the frame AND the - // guest (swap blocks), which mattered when a screen change brought - // 100+ ms of per-pixel decode work, but routinely - // skipping first-sight quads made every APT re-raster (new address = - // new key) blink its element for 1-3 frames (the menu flicker). - const int32_t async_px = REXCVAR_GET(skate3_native_render_scene_2d_async_px); - const uint32_t px_w = (fetch[2] & 0x1FFFu) + 1; - const uint32_t px_h = ((fetch[2] >> 13) & 0x1FFFu) + 1; - const bool async_ui = !force_inline && async_px > 0 && - uint64_t(px_w) * px_h > uint64_t(async_px); - // PLAIN store lookup on purpose: find_words_texture is the 3D poster - // revalidator; it probes, RE-ARMS recheck_frame and enqueues its own - // ui=false heal, which made this resolver's liveness block dead code - // (recheck always freshly re-armed before it ran; astale=0 across whole - // sessions) and put every 2D content update on that 4-5 - // frame async round trip. - auto it = g_r.tex_store.find(key); - bool inline_redecode = false; - if (it != g_r.tex_store.end()) { - it->second.last_used_frame = frame_number; - } - if (it != g_r.tex_store.end() && it->second.valid && !it->second.incomplete && - frame_number >= it->second.recheck_frame && - REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { - // Content liveness for CPU-rewritten UI art: video frames and APT - // re-rasterized tiles rewrite the same payload with the fetch words - // unchanged; without the probe the words-keyed cache would freeze - // them on their first decoded content. - const uint64_t fp = SampleProbeFingerprint(base, it->second); - if (fp != 0 && fp != it->second.payload_fp) { - // Content changes decode INLINE while the per-frame budget lasts - // (1-frame latency for animating UI); fullscreen-class art gets an - // extended budget; serving it stale/blank is a whole-screen - // artifact, and the run-copy untiler made even 1280x720 cheap. - const bool fullscreen_class = uint64_t(px_w) * px_h >= 460'000; - inline_redecode = - video || !async_ui || hot_inline_budget_ns > 0 || - (fullscreen_class && hot_inline_budget_ns > -4'000'000); - if (inline_redecode) { - // Same layout = update the committed texture in place (no - // CreateCommittedResource churn, SRV slot kept, tear-guarded). - const auto up_t0 = PerfClock::now(); - if (UpdateGuestTexture2DInPlace(context, base, it->second)) { - const uint64_t up_ns = perf_ns_since(up_t0); - hot_inline_budget_ns -= int64_t(up_ns); - g_pw_tex_decode.Add(up_ns); - it->second.last_change_frame = frame_number; - return &it->second; - } - RetireGuestTexture(it->second, context.device->CurrentSubmission()); - g_r.tex_store.erase(it); - it = g_r.tex_store.end(); // falls into the inline decode below - } else { - // Budget exhausted (burst overflow): heal on the workers, serve - // the stale decode meanwhile. - EnqueueWordsMiss(key, fetch, /*ui=*/true); - it->second.recheck_frame = frame_number + 2; - g_2d_async_stale.fetch_add(1, std::memory_order_relaxed); - } - } else { - // Menu screens probe on a 2-frame cadence: the skater-portrait - // boxes are resolved IN PLACE (words unchanged) up to hundreds of - // ms after the quad first draws (asset streaming first), and a - // 16-frame recheck left the pre-resolve memory content, the - // loading-poster flash, on screen for its full window. - it->second.recheck_frame = - frame_number + (g_in_menus_frame.load(std::memory_order_relaxed) ? 2 : 16); - } - } - if (it == g_r.tex_store.end()) { - // First sightings also decode INLINE while the budget lasts. The old - // always-async policy skipped the quad for the 1-3 worker-round-trip - // frames, but animating APT art re-rasterizes at NEW addresses, so - // every animation step was a "first sighting" and menu elements - // (including fullscreen backdrops) BLINKED through every animation - // (askip +150/window during navigation, visible - // menu flicker/brightness pulsing). Async remains the burst-overflow - // valve only. - const bool fullscreen_class = uint64_t(px_w) * px_h >= 460'000; - const bool inline_ok = - hot_inline_budget_ns > 0 || - (fullscreen_class && hot_inline_budget_ns > -4'000'000); - if (async_ui && !video && !inline_redecode && !inline_ok) { - EnqueueWordsMiss(key, fetch, /*ui=*/true); - g_2d_async_skip.fetch_add(1, std::memory_order_relaxed); - return nullptr; - } - // Small HUD/spline art decodes inline (sub-ms; async would pop - // HUD elements for no gain). - const auto hud_t0 = PerfClock::now(); - GuestTexture gt; - EnsureGuestTextureFromWords(context, base, fetch, gt); - const uint64_t decode_ns = perf_ns_since(hud_t0); - hot_inline_budget_ns -= int64_t(decode_ns); - g_pw_tex_decode.Add(decode_ns); - // Attribution for residual render-thread stalls: anything still - // decoding inline for >3 ms should either move over the async - // threshold or explain itself here. - if (decode_ns > 3'000'000) { - static std::atomic s_slow{0}; - const uint32_t n = s_slow.fetch_add(1, std::memory_order_relaxed); - if (n < 24 || (n & 255u) == 0) { - REXLOG_INFO( - "native-scene: SLOW inline 2D decode {:.1f}ms (copy {:.1f} " - "create {:.1f} gen {:.1f}) {}x{} fetch=[{:08X} {:08X} {:08X}] " - "forced={} (n={})", - double(decode_ns) / 1e6, double(g_tex_dec_copy_ns) / 1e6, - double(g_tex_dec_create_ns) / 1e6, double(g_tex_dec_gen_ns) / 1e6, - px_w, px_h, fetch[0], fetch[1], fetch[2], force_inline ? 1 : 0, n); - } - } - if (!gt.valid) { - static std::unordered_set logged; - if (logged.size() < 32 && logged.insert(key).second) { - REXLOG_INFO( - "native-scene: 2D texture decode FAILED fetch=[{:08X} {:08X} {:08X} " - "{:08X} {:08X} {:08X}]", - fetch[0], fetch[1], fetch[2], fetch[3], fetch[4], fetch[5]); - } - } - cmd->FlushBarriers(); - gt.last_used_frame = frame_number; - gt.last_change_frame = frame_number; - it = g_r.tex_store.emplace(key, gt).first; - } - return it->second.valid ? &it->second : &g_r.white; - }; - const uint32_t ui_region = - uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize; - uint32_t ui_offset = 0; - - // In-world neon splines (waypoint arrows / marker beams): replayed inside - // the scene pass, depth-tested against the world like the emulated frame, - // in submission order (darken backdrop passes precede the additive glow). - uint32_t drawn_spline = 0; - if (REXCVAR_GET(skate3_native_render_scene_splines) && - g_r.pso_spline_default != nullptr && g_r.ui_ring_cpu != nullptr) { - std::vector scene_spline; - { - std::lock_guard lock(g_2d_mutex); - scene_spline = g_scene_spline; - } - for (const SplineDraw& s : scene_spline) { - const uint32_t bytes = uint32_t(s.verts.size()); - if (bytes == 0 || ui_offset + bytes > RendererState::kUiRegionSize) { - continue; - } - std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset, s.verts.data(), bytes); - const GuestTexture* spline_tex = resolve_2d_texture(s.fetch); - if (spline_tex == nullptr) { - continue; // big-art decode in flight on the workers; skip a frame - } - cmd->SetPipeline(s.pass == 1 ? g_r.pso_spline_darken - : g_r.pso_spline_default); - // Root constants: the scene's (smoothed) view_proj rows; the verts - // are world-space, then i_intensity as staged (c149). - float spline_consts[20]; - std::memcpy(spline_consts, scene.view_proj, sizeof(float) * 16); - std::memcpy(spline_consts + 16, s.consts + 149 * 4, sizeof(float) * 4); - cmd->SetRootConstants(0, 20, spline_consts, 0); - cmd->SetTexture(1, spline_tex->srv); - cmd->SetVertexBuffer(g_r.ui_ring, ui_region + ui_offset, bytes, 28); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); - cmd->Draw(s.count, 0); - ui_offset += bytes; - ++drawn_spline; - } - } - - const bool outline_ready = - RenderOutlineMask(context, scene, viewport, scissor, msaa_on, scene_color, - g_r.depth, use_depth); - - // ---- SSR reflection G-buffer (scene.hlsl ps_refl_gbuf) ---- - // Re-render the frame's reflective items (captured with their main-pass - // constants/textures by draw_item) into the half-res G-buffer while the - // main binding layout is still live. The march/composite consume it after - // the resolve + AO passes (ApplySsrPass); it hands off in - // PIXEL_SHADER_RESOURCE state via ssr_gbuf_ready. - g_r.ssr_gbuf_ready = false; - if (!ssr_items.empty() && EnsureSsrPipeline(context) && - EnsureSsrTargets(context)) { - // Front-to-back for early-z; the pass's own depth buffer resolves - // overlap (a curved facade's far side shares texels with its near side - // within one mesh; no draw order can fix that). - std::stable_sort(ssr_items.begin(), ssr_items.end(), - [&](const SsrGbufItem& a, const SsrGbufItem& b) { - return view_dist2(*a.item) < view_dist2(*b.item); - }); - const float gbuf_clear[4] = {0.0f, 0.0f, 0.0f, 0.0f}; - cmd->ClearRenderTarget(g_r.ssr_gbuf, gbuf_clear); - cmd->ClearDepth(g_r.ssr_gbuf_depth, 1.0f); - cmd->SetRenderTargets(g_r.ssr_gbuf, g_r.ssr_gbuf_depth); - cmd->SetViewport(nrhi::Viewport{0.0f, 0.0f, float(g_r.ssr_width), - float(g_r.ssr_height), 0.0f, 1.0f}); - cmd->SetScissor( - nrhi::Rect{0, 0, int32_t(g_r.ssr_width), int32_t(g_r.ssr_height)}); - cmd->SetPipeline(g_r.pso_ssr_gbuf); - uint32_t gbuf_draws = 0; - for (const SsrGbufItem& si : ssr_items) { - auto mit = g_r.meshes.find(si.mesh); - if (mit == g_r.meshes.end() || - mit->second.fingerprint != si.fingerprint) { - continue; // decode swapped since the main-pass draw; next frame - } - cmd->SetRootConstants(0, 52, si.constants, 0); - cmd->SetTexture(4, si.t3); - cmd->SetTexturePair(5, si.t4, si.t5); - cmd->SetVertexBuffer(mit->second.vb_view.buffer, - mit->second.vb_view.offset, - mit->second.vb_view.size_bytes, - mit->second.vb_view.stride); - cmd->SetIndexBuffer(mit->second.ib_view.buffer, - mit->second.ib_view.offset, - mit->second.ib_view.size_bytes); - for (const DrawEntry& draw : si.item->draws) { - if (draw.prim == 4) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - } else if (draw.prim == 6) { - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); - } else { - continue; - } - cmd->DrawIndexed(draw.index_count, draw.start_index, - draw.base_vertex); - ++gbuf_draws; - } - } - // Restore the pass state the resolve/post paths rely on. - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - if (!msaa_on) { - cmd->SetRenderTargets(scene_color, use_depth ? g_r.depth : nullptr); - } - if (gbuf_draws > 0) { - cmd->Barrier(g_r.ssr_gbuf, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - g_r.ssr_gbuf_ready = true; - } - } - - if (msaa_on) { - // Resolve: average the MSAA samples into the 1x scene plane (the float - // HDR plane, or the guest output on the classic path) with a fullscreen - // pass, then restore steady-state resource states. - cmd->Barrier(g_r.msaa_color, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - if (!hdr_on) { - cmd->Barrier(context.guest_output, nrhi::ResourceState::kGuestOutput, - nrhi::ResourceState::kRenderTarget); - } - cmd->FlushBarriers(); - cmd->SetRenderTargets(hdr_on ? g_r.hdr_resolved : context.guest_output, - nullptr); - cmd->SetPipeline(g_r.resolve_pso); - cmd->SetTexture(1, g_r.msaa_srv_slot); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - cmd->Draw(3, 0); - cmd->Barrier(g_r.msaa_color, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - } - - // Classic path: the outline composites straight onto the resolved guest - // output. Under HDR it runs after the tonemap below (a gamma-space - // screen overlay, not scene lighting). - if (outline_ready && !hdr_on) { - RenderOutlineComposite(context, scene, context.guest_output, viewport, - scissor); - } - - // ---- Screen-space ambient occlusion (ssao.hlsl: GTAO) ---- - // Multiplies the resolved scene by a horizon-based visibility term before - // any downstream consumer (bloom/tonemap under HDR, photo chain, 2D - // overlay, blur). Runs after the outline composite, which relies on the - // main layout latched from the scene pass. - bool post_ran = false; - bool ssao_ran = false; - if (use_depth && !loading_native && - REXCVAR_GET(skate3_native_render_scene_ssao) && - ApplySsaoPass(context, cmd, scene, viewport, scissor)) { - post_ran = true; - ssao_ran = true; - } - - // ---- Screen-space reflections (ssr.hlsl) ---- - // March + composite onto the pre-tonemap HDR plane, between the AO pass - // and the HDR post, so reflections tonemap and bloom exactly like - // directly-visible scenery. The reflection G-buffer was drawn inside the - // scene pass (ssr_gbuf_ready); the SSAO linear-depth plane is reused when - // AO ran this frame. - if (g_r.ssr_gbuf_ready && - ApplySsrPass(context, cmd, scene, viewport, scissor, ssao_ran)) { - post_ran = true; - } - - // ---- HDR post (hdr.hlsl: bloom pyramid + the extracted tonemap) ---- - // Bloom reads the AO-composited float scene; ps_tonemap applies the - // game's shared tone chain once into the guest output, which every later - // consumer (photo chain, 2D overlay, blur, grab) reads exactly as on the - // classic path. - if (hdr_on) { - ApplyHdrPost(context, cmd, viewport, scissor, loading_native); - post_ran = true; - } - - if (post_ran) { - // The AO/HDR chains switched binding layouts; restore the main-pass - // root bindings the later passes latch (same restore as the photo - // chain's). - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetBindingLayout(g_r.layout); - if (g_r.shadow_cb != nullptr) { - const uint32_t cb_offset = - uint32_t(frame_number % RendererState::kShadowCbRegions) * - RendererState::kShadowCbSlice; - cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset); - cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); - } - } - - if (outline_ready && hdr_on) { - RenderOutlineComposite(context, scene, context.guest_output, viewport, - scissor); - } - - // ---- Photo-editor postfx chain (photo_fx.hlsl: exact ucode ports) ---- - // While the photo-mission photo editor is up and every pass's live - // constants were captured this frame, apply the game's own chain over the - // resolved native frame: depth pack -> visualfx (grade/vignette/CoC) -> - // DOF downsample -> tap9dofMotionBlur -> tap9dof -> uber -> fisheye. - if (scene.photo_fx.valid && - REXCVAR_GET(skate3_native_render_scene_photo_native) && - EnsurePhotoFxPipeline(context)) { - const auto pfx_to_srv = [&](nrhi::Texture* r) { - cmd->Barrier(r, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - }; - const auto pfx_to_rt = [&](nrhi::Texture* r) { - cmd->Barrier(r, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - }; - const auto pfx_flush = [&] { cmd->FlushBarriers(); }; - // Output-sized targets (visualfx out, uber out, packed depth). - bool pfx_ok = true; - if (g_r.pfx_width != context.guest_output_width || - g_r.pfx_height != context.guest_output_height || g_r.pfx_full[0] == nullptr) { - nrhi::Texture** res[3] = {&g_r.pfx_full[0], &g_r.pfx_full[1], &g_r.pfx_depth}; - nrhi::TextureView** views[3] = {&g_r.pfx_srv[0], &g_r.pfx_srv[1], - &g_r.pfx_srv[5]}; - nrhi::TextureDesc desc; - desc.width = context.guest_output_width; - desc.height = context.guest_output_height; - desc.mip_levels = 1; - desc.format = nrhi::Format::kR8G8B8A8_UNORM; - desc.usage = nrhi::kTextureUsageRenderTarget; - desc.initial_state = nrhi::ResourceState::kRenderTarget; - for (int i = 0; i < 3 && pfx_ok; ++i) { - if (*views[i] != nullptr) { - g_r.device->DestroyDeferred(*views[i]); - *views[i] = nullptr; - } - if (*res[i] != nullptr) { - g_r.device->DestroyDeferred(*res[i]); - *res[i] = nullptr; - } - *res[i] = context.device->CreateTexture(desc); - if (*res[i] == nullptr) { - pfx_ok = false; - break; - } - nrhi::TextureViewDesc vd; - vd.mip_levels = 1; - *views[i] = context.device->CreateTextureView(*res[i], vd); - if (*views[i] == nullptr) { - pfx_ok = false; - break; - } - } - if (pfx_ok) { - g_r.pfx_width = context.guest_output_width; - g_r.pfx_height = context.guest_output_height; - } - } - if (pfx_ok) { - // Identity grade-LUT upload (once). - if (!g_r.pfx_lut_uploaded) { - cmd->CopyBufferToTexture(g_r.pfx_lut, 0, 0, g_r.pfx_lut_upload, 0, 256, - 32, 32, 32); - cmd->Barrier(g_r.pfx_lut, nrhi::ResourceState::kCopyDest, - nrhi::ResourceState::kPixelShaderResource); - g_r.pfx_lut_uploaded = true; - } - // Native depth SRV (re-pointed when the depth texture is rebuilt on - // output resize; D32 textures view as R32_FLOAT automatically). - { - static nrhi::Texture* s_pfx_depth_tex = nullptr; - if (g_r.pfx_srv[7] == nullptr || s_pfx_depth_tex != g_r.depth) { - if (g_r.pfx_srv[7] != nullptr) { - g_r.device->DestroyDeferred(g_r.pfx_srv[7]); - g_r.pfx_srv[7] = nullptr; - } - nrhi::TextureViewDesc sd; - if (g_r.msaa > 1) { - sd.dimension = nrhi::ViewDimension::k2DMS; - } else { - sd.dimension = nrhi::ViewDimension::k2D; - sd.mip_levels = 1; - } - g_r.pfx_srv[7] = context.device->CreateTextureView(g_r.depth, sd); - s_pfx_depth_tex = g_r.depth; - } - } - // (The guest-output view, the finished native frame = the chain's - // scene input, is g_r.output_srv_slot, owned/re-pointed by - // EnsurePipeline on output change.) - // The two static input textures (fetch words captured at the game's - // own uber/fisheye flushes): the 512x2 vignette gradient + the grain. - const GuestTexture* vig = - resolve_2d_texture(scene.photo_fx.vignette_fetch, /*force_inline=*/true); - const GuestTexture* grain = - resolve_2d_texture(scene.photo_fx.grain_fetch, /*force_inline=*/true); - nrhi::TextureView* const vig_slot = - (vig != nullptr && vig->valid) ? vig->srv : g_r.white.srv; - nrhi::TextureView* const grain_slot = - (grain != nullptr && grain->valid) ? grain->srv : g_r.white.srv; - - // Baked literal rows c250..c255 per pass (the shader asset footers the - // game loads via PM4 LOAD_ALU_CONSTANT to PS rows 252+; values read - // from capture). - static constexpr float kPfxLiterals[kPfxPassCount][6][4] = { - // visualfx - {{0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {-0.200000003f, 1.0f, 1.41412354f, 2.0f}, - {1.51991853e-05f, 0.99609381f, 0.00389099144f, 0.00392156886f}, - {0.200000003f, 0.300000012f, 0.5f, 1.0f}}, - // dof downsample - {{0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0.000868055562f, 0.00156250002f, 0.25f, 0.00100000005f}}, - // tap9dofMotionBlur - {{0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {1.0f, 1.5f, 9.99999975e-05f, 0.00392156886f}, - {1.51991853e-05f, 0.99609381f, 0.00389099144f, 0.0f}}, - // tap9dof - {{0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0.100000001f, 0, 0, 0}}, - // uber - {{0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {2.0f, 0.5f, -1.0f, 0.0f}, - {0.015625f, 0.984375f, 0.96875f, -0.96875f}, - {1.0f, 0, 0, 0}}, - // fisheye - {{0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {0, 0, 0, 0}, - {-1.0f, 0.5f, 0, 0}, - {-0.5f, -0.888888896f, 1.0f, 1.77777779f}}, - }; - const uint32_t cb_slot_base = uint32_t(frame_number % 4) * 10; - uint32_t cb_slot_next = 0; - const int32_t pfx_debug = - REXCVAR_GET(skate3_native_render_scene_photo_native_debug); - // Returns the byte OFFSET into g_r.pfx_cb for SetConstantBuffer(0, ...). - const auto fill_cb = [&](int pass) -> uint64_t { - const uint32_t slot = cb_slot_base + (cb_slot_next++); - uint8_t* dst = g_r.pfx_cb_ptr + size_t(slot) * 4096; - std::memset(dst, 0, 4096); - float* rows = reinterpret_cast(dst); - if (pass >= 0) { - std::memcpy(rows, scene.photo_fx.ps[pass], sizeof(scene.photo_fx.ps[pass])); - std::memcpy(rows + 240 * 4, scene.photo_fx.vs[pass], - sizeof(scene.photo_fx.vs[pass])); - std::memcpy(rows + 250 * 4, kPfxLiterals[pass], sizeof(kPfxLiterals[pass])); - // The game's quad VSs add half a DEST texel to the sampling uv - // (visualfx c3 = 0.5/1152,0.5/640; dof passes c3 = 0.5/576, - // 0.5/320; fisheye c2 = 0.5/1280,0.5/720), D3D9/Xenos raster - // compensation: their pixel centers interpolate uv = i/W, so - // +half lands on the intended (i+0.5)/W. Our D3D12 fullscreen - // triangle interpolates (i+0.5)/W ALREADY; keeping the captured - // offsets shifts each pass's sampling by half a dest texel - // (content drifts up-left ~1.5 half-res texels across the three - // half-res DOF passes = the observed "blur starts above the - // pole" mask misregistration). ZERO them - // natively. Guarded to the expected sub-pixel magnitudes so a - // mis-captured row is left alone. - { - float* half_px = - rows + (pass == kPfxFisheye ? 242 : 243) * 4; - if (std::fabs(half_px[0]) < 0.002f && std::fabs(half_px[1]) < 0.004f) { - half_px[0] = 0.0f; - half_px[1] = 0.0f; - } - } - } - rows[248 * 4 + 0] = float(context.guest_output_width); - rows[248 * 4 + 1] = float(context.guest_output_height); - rows[249 * 4 + 0] = float(pfx_debug); - return uint64_t(slot) * 4096; - }; - // One 8-entry texture table (pfx_layout param 1, registers t0..t7); - // the argument order keeps the old per-parameter helper's shape (t5 - // last), the gather re-orders into register order. - const auto pfx_bind_all = [&](nrhi::TextureView* t0, nrhi::TextureView* t1, - nrhi::TextureView* t2, nrhi::TextureView* t3, - nrhi::TextureView* t4, nrhi::TextureView* t6, - nrhi::TextureView* t7, nrhi::TextureView* t5) { - nrhi::TextureView* views[8] = {t0, t1, t2, t3, t4, t5, t6, t7}; - cmd->SetTextures(1, views, 8); - }; - nrhi::TextureView* const W = g_r.white.srv; - const nrhi::Viewport half_vp{0.0f, 0.0f, float(RendererState::kPfxHalfW), - float(RendererState::kPfxHalfH), 0.0f, 1.0f}; - const nrhi::Rect half_sc{0, 0, int32_t(RendererState::kPfxHalfW), - int32_t(RendererState::kPfxHalfH)}; - const nrhi::Viewport quarter_vp{0.0f, 0.0f, - float(RendererState::kPfxQuarterW), - float(RendererState::kPfxQuarterH), 0.0f, - 1.0f}; - const nrhi::Rect quarter_sc{0, 0, int32_t(RendererState::kPfxQuarterW), - int32_t(RendererState::kPfxQuarterH)}; - const auto set_rtv = [&](nrhi::Texture* target) { - cmd->SetRenderTargets(target, nullptr); - }; - cmd->SetBindingLayout(g_r.pfx_layout); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - const int32_t accum_mode = - REXCVAR_GET(skate3_native_render_scene_photo_native_accum); - // Live capture telemetry (~every 2 s while the chain runs): the rows - // that move with the editor sliders. Lets a session log confirm the - // draw-time capture tracks DoF (dof c0.x / dofmb c2.x, 0 = slider - // off), focus (visualfx c2.y), the grade matrix (visualfx c7) and the - // uber jitter phase against the emulated ground-truth values. - { - static uint64_t s_pfx_log_frame = 0; - if (frame_number - s_pfx_log_frame > 120) { - s_pfx_log_frame = frame_number; - const auto& fx = scene.photo_fx; - REXLOG_INFO( - "native-scene: pfx live rows: visualfx c2=({:.3f},{:.3f}) " - "c4.x={:.5f} c7=({:.4f},{:.4f},{:.4f},{:.4f}) | dofmb " - "c2.x={:.3f} | dof c0.x={:.3f} | uber c0.x={:.3f} " - "c2.x={:.4f} c5.x={:.3f} | vfx vs c3=({:.6f},{:.6f})", - fx.ps[kPfxVisualFx][2][0], fx.ps[kPfxVisualFx][2][1], - fx.ps[kPfxVisualFx][4][0], fx.ps[kPfxVisualFx][7][0], - fx.ps[kPfxVisualFx][7][1], fx.ps[kPfxVisualFx][7][2], - fx.ps[kPfxVisualFx][7][3], fx.ps[kPfxDofMB][2][0], - fx.ps[kPfxDof][0][0], fx.ps[kPfxUber][0][0], - fx.ps[kPfxUber][2][0], fx.ps[kPfxUber][5][0], - fx.vs[kPfxVisualFx][3][0], fx.vs[kPfxVisualFx][3][1]); - } - } - - // 1) Depth pack: native depth (sample 0) -> the console D24-as-8888 - // layout at output res. - cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite, - nrhi::ResourceState::kPixelShaderResource); - pfx_flush(); - set_rtv(g_r.pfx_depth); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetPipeline(g_r.pfx_pso[0]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); - pfx_bind_all(W, W, W, W, g_r.pfx_srv[6], W, W, g_r.pfx_srv[7]); - cmd->Draw(3, 0); - cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kDepthWrite); - pfx_to_srv(g_r.pfx_depth); - // Scene input -> SRV for the rest of the chain. - cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - pfx_flush(); - - // 2) Accumulation input (visualfx t3). Mode 0 = black, 1 = the scene - // downsampled (this frame, the editor scene is frozen). - if (accum_mode == 0) { - const float black[4] = {0, 0, 0, 0}; - cmd->ClearRenderTarget(g_r.pfx_quarter, black); - } else if (accum_mode == 1) { - set_rtv(g_r.pfx_quarter); - cmd->SetViewport(quarter_vp); - cmd->SetScissor(quarter_sc); - cmd->SetPipeline(g_r.pfx_pso[7]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); - pfx_bind_all(g_r.output_srv_slot, W, W, W, g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - } - pfx_to_srv(g_r.pfx_quarter); - pfx_flush(); - - // 3) visualfx (full res): scene + depth + accumulation -> pfx_full[0]. - set_rtv(g_r.pfx_full[0]); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetPipeline(g_r.pfx_pso[1]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxVisualFx)); - pfx_bind_all(g_r.output_srv_slot, g_r.pfx_srv[5], W, g_r.pfx_srv[4], - g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - pfx_to_srv(g_r.pfx_full[0]); - pfx_flush(); - - // 4) DOF downsample: pfx_full[0] -> pfx_half[0]. - set_rtv(g_r.pfx_half[0]); - cmd->SetViewport(half_vp); - cmd->SetScissor(half_sc); - cmd->SetPipeline(g_r.pfx_pso[2]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxDofDown)); - pfx_bind_all(g_r.pfx_srv[0], W, W, W, g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - pfx_to_srv(g_r.pfx_half[0]); - pfx_flush(); - - // 5) tap9dofMotionBlur: pfx_half[0] + depth -> pfx_half[1]. - set_rtv(g_r.pfx_half[1]); - cmd->SetPipeline(g_r.pfx_pso[3]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxDofMB)); - pfx_bind_all(g_r.pfx_srv[2], g_r.pfx_srv[5], W, W, g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - pfx_to_srv(g_r.pfx_half[1]); - pfx_to_rt(g_r.pfx_half[0]); - pfx_flush(); - - // 6) tap9dof: pfx_half[1] -> pfx_half[0]. - set_rtv(g_r.pfx_half[0]); - cmd->SetPipeline(g_r.pfx_pso[4]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxDof)); - pfx_bind_all(g_r.pfx_srv[3], W, W, W, g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - pfx_to_srv(g_r.pfx_half[0]); - pfx_flush(); - - // 7) uber (full res): graded sharp + depth + LUT + grain + blurred - // half -> pfx_full[1]. - set_rtv(g_r.pfx_full[1]); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetPipeline(g_r.pfx_pso[5]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxUber)); - pfx_bind_all(g_r.pfx_srv[0], g_r.pfx_srv[5], W, W, g_r.pfx_srv[6], - grain_slot, g_r.pfx_srv[2], W); - cmd->Draw(3, 0); - pfx_to_srv(g_r.pfx_full[1]); - // The finished chain replaces the output: back to RT for fisheye. - cmd->Barrier(context.guest_output, - nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - pfx_flush(); - - // 8) fisheye (to screen): lens warp + vignette gradient + tint. - cmd->SetRenderTargets(context.guest_output, nullptr); - cmd->SetPipeline(g_r.pfx_pso[6]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxFisheye)); - pfx_bind_all(g_r.pfx_srv[1], W, vig_slot, W, g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - - // 8b) Debug view (photo_native_debug): overwrite the output with the - // visualfx CoC map (mode 1) or the packed-depth reconstruction - // (mode 2): t0 = visualfx out, t1 = packed depth, both still in - // SRV state here. - if (pfx_debug > 0 && g_r.pfx_pso[8] != nullptr) { - cmd->SetPipeline(g_r.pfx_pso[8]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); - pfx_bind_all(g_r.pfx_srv[0], g_r.pfx_srv[5], W, W, g_r.pfx_srv[6], W, - W, W); - cmd->Draw(3, 0); - } - - // 9) Accumulation mode 2: next frame's visualfx t3 = the finished - // frame downsampled (pfx_full[1] is still in SRV state here). - if (accum_mode == 2) { - pfx_to_rt(g_r.pfx_quarter); - pfx_flush(); - set_rtv(g_r.pfx_quarter); - cmd->SetViewport(quarter_vp); - cmd->SetScissor(quarter_sc); - cmd->SetPipeline(g_r.pfx_pso[7]); - cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); - pfx_bind_all(g_r.pfx_srv[1], W, W, W, g_r.pfx_srv[6], W, W, W); - cmd->Draw(3, 0); - } - - // Restore steady states (all pfx color targets idle as RENDER_TARGET) - // + the main pass's root bindings for the 2D overlay. - pfx_to_rt(g_r.pfx_full[0]); - pfx_to_rt(g_r.pfx_full[1]); - pfx_to_rt(g_r.pfx_half[0]); - pfx_to_rt(g_r.pfx_half[1]); - if (accum_mode != 2) { - pfx_to_rt(g_r.pfx_quarter); - } - pfx_to_rt(g_r.pfx_depth); - pfx_flush(); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetBindingLayout(g_r.layout); - if (g_r.shadow_cb != nullptr) { - const uint32_t cb_offset = - uint32_t(frame_number % RendererState::kShadowCbRegions) * - RendererState::kShadowCbSlice; - cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset); - cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); - } - static bool s_pfx_first = true; - if (s_pfx_first) { - s_pfx_first = false; - REXLOG_INFO( - "native-scene: photo editor postfx chain LIVE (native; accum mode {}, " - "vignette {}, grain {})", - accum_mode, vig_slot == W ? "WHITE-fallback" : "resolved", - grain_slot == W ? "WHITE-fallback" : "resolved"); - } - } - } - - // ---- Native photo grab (photo_grab_native) ---- - // Consume: CPU-tile the newest fence-completed readback into the guest - // screenshot target. Produce: downsample this frame's finished output - // (photo editor = the ported postfx chain result just drawn above; plain - // TakePhoto window = the plain frame, both the correct grab semantics) - // into blur_tex[0] via the blur chain's prefiltered downsample (its - // 1152x640 blur space IS the game's screenshot raster) and enqueue the - // copy into the free readback buffer. Runs BEFORE the 2D overlay so the - // photo never contains editor chrome / HUD, and before the blur block so - // this scratch use of blur_tex[0] is enqueued ahead of any blur rewrite. - if (g_photo_grab_native_armed.load(std::memory_order_relaxed) && - g_r.pso_blur_down != nullptr && g_r.blur_tex[0] != nullptr && - g_r.output_srv_allocated && !g_r.grab_failed) { - // Lazy readback-pair creation (persistently mapped; the copy is never - // waited on same-frame; the old path's per-resolve GPU drain was the - // editor's multi-second stall class). - if (g_r.grab_readback[0] == nullptr) { - nrhi::BufferDesc desc; - desc.size = - uint64_t(RendererState::kGrabRowPitch) * RendererState::kBlurHeight; - desc.heap = nrhi::HeapKind::kReadback; - for (int i = 0; i < 2 && !g_r.grab_failed; ++i) { - g_r.grab_readback[i] = g_r.device->CreateBuffer(desc); - if (g_r.grab_readback[i] == nullptr || - (g_r.grab_readback_ptr[i] = static_cast( - g_r.device->Map(g_r.grab_readback[i]))) == nullptr) { - g_r.grab_failed = true; - } - } - if (g_r.grab_failed) { - REXLOG_ERROR( - "native-scene: photo grab readback creation failed - native " - "grab disabled (watchdog will fall back)"); - } - } - if (!g_r.grab_failed) { - // Consume the newest completed buffer; retire older completed ones - // unwritten so stale content never lands after fresh content. - const uint64_t grab_completed = context.device->CompletedSubmission(); - int newest = -1; - for (int i = 0; i < 2; ++i) { - if (g_r.grab_pending[i] && g_r.grab_submission[i] < grab_completed && - (newest < 0 || - g_r.grab_submission[i] > g_r.grab_submission[newest])) { - newest = i; - } - } - if (newest >= 0) { - static bool s_grab_write_warned = false; - const auto tile_t0 = PerfClock::now(); - // Make the GPU's completed copy visible to the CPU read below (no-op - // on D3D12; cache invalidate on non-coherent Vulkan memory). - g_r.device->InvalidateForRead( - g_r.grab_readback[newest], 0, - uint64_t(RendererState::kGrabRowPitch) * RendererState::kBlurHeight); - const bool wrote = GrabTargetValid(base) && - WriteGrabToGuest(base, g_r.grab_readback_ptr[newest]); - if (wrote) { - g_r.grab_cpu_us += - uint64_t(std::chrono::duration_cast( - PerfClock::now() - tile_t0) - .count()); - ++g_r.grab_writes; - g_grab_last_write_ns.store( - std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(), - std::memory_order_relaxed); - if (g_r.grab_writes == 1) { - REXLOG_INFO( - "native-scene: photo grab - first native frame written to " - "guest 0x{:08X} via the 0xA0000000 view (tiled BE ARGB " - "1152x640)", - kGrabGuestAddr); - } - } else if (!s_grab_write_warned) { - s_grab_write_warned = true; - REXLOG_WARN( - "native-scene: photo grab - guest target validation/write " - "FAILED (dims chain mismatch or unmapped page); the window " - "watchdog will fall back to forced readbacks"); - } - for (int i = 0; i < 2; ++i) { - if (g_r.grab_pending[i] && g_r.grab_submission[i] < grab_completed) { - g_r.grab_pending[i] = false; - } - } - } - // Produce into the free buffer (skip the frame if both are still in - // flight, never wait). - const uint32_t w = g_r.grab_write_index; - if (!g_r.grab_pending[w]) { - // (The output view g_r.output_srv_slot tracks the current output - // texture; EnsurePipeline re-points it on output change.) - cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - cmd->FlushBarriers(); - cmd->SetRenderTargets(g_r.blur_tex[0], nullptr); - const nrhi::Viewport grab_vp{0.0f, - 0.0f, - float(RendererState::kBlurWidth), - float(RendererState::kBlurHeight), - 0.0f, - 1.0f}; - const nrhi::Rect grab_sc{0, 0, int32_t(RendererState::kBlurWidth), - int32_t(RendererState::kBlurHeight)}; - cmd->SetViewport(grab_vp); - cmd->SetScissor(grab_sc); - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - cmd->SetPipeline(g_r.pso_blur_down); - const float grab_consts[4] = {1.0f / float(context.guest_output_width), - 1.0f / float(context.guest_output_height), - 0.0f, 0.0f}; - cmd->SetRootConstants(0, 4, grab_consts, 0); - cmd->SetTexture(1, g_r.output_srv_slot); - cmd->Draw(3, 0); - // blur_tex[0] -> the readback buffer; restore steady states. - cmd->Barrier(g_r.blur_tex[0], nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kCopySource); - cmd->Barrier(context.guest_output, - nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - cmd->CopyTextureToBuffer(g_r.grab_readback[w], 0, - RendererState::kGrabRowPitch, g_r.blur_tex[0], - 0, RendererState::kBlurWidth, - RendererState::kBlurHeight); - cmd->Barrier(g_r.blur_tex[0], nrhi::ResourceState::kCopySource, - nrhi::ResourceState::kRenderTarget); - cmd->FlushBarriers(); - // Restore the frame's output binding for the passes that follow. - cmd->SetRenderTargets(context.guest_output, nullptr); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - g_r.grab_submission[w] = context.device->CurrentSubmission(); - g_r.grab_pending[w] = true; - g_r.grab_write_index = 1 - w; - } - } - } - - // Popup background blur: exact port of the game's blur_hBlur/vBlur + - // postfx_basictex chain (see kBlurShaderSource): H blur of the finished - // frame into a 1152x640 intermediate, V blur, then a fullscreen bilinear - // stretch back over the output. Runs only on frames where the game issued - // the blur draws (scene.ui_blur = captured kernel scale). The popup's own - // 2D draws follow after and stay sharp. - if (scene.ui_blur > 0.0f && g_r.pso_blur != nullptr && g_r.pso_blur_blit != nullptr && - g_r.pso_blur_down != nullptr && g_r.blur_tex[0] != nullptr) { - // Temporal smoothing of the captured radius + fade (~50 ms time - // constant, render thread): the live bank reads can land mid-rewrite - // (1-frame value flaps = the tinted-backdrop flicker), and smoothing - // FROM the off state (radius 0, white tint) supplies the pause-open - // animate-in even when the game's own ramp frames were missed by the - // capture gate. A >0.25 s gap since the last blur frame = a fresh ON - // edge (resets the state); at the render rate active frames are ~7 ms - // apart so the reset can never fire mid-popup. - static float s_blur_r = 0.0f; - static float s_blur_tint[3] = {1.0f, 1.0f, 1.0f}; - static int64_t s_blur_ns = 0; - const int64_t blur_now_ns = std::chrono::duration_cast( - PerfClock::now().time_since_epoch()) - .count(); - float blur_dt = s_blur_ns != 0 ? float(blur_now_ns - s_blur_ns) * 1e-9f : 1.0f; - if (blur_dt > 0.25f) { - s_blur_r = 0.0f; - s_blur_tint[0] = s_blur_tint[1] = s_blur_tint[2] = 1.0f; - blur_dt = 0.0f; - } - s_blur_ns = blur_now_ns; - const float blur_a = 1.0f - std::exp(-blur_dt / 0.05f); - s_blur_r += (scene.ui_blur - s_blur_r) * blur_a; - for (int a = 0; a < 3; ++a) { - s_blur_tint[a] += (scene.ui_blur_color[a] - s_blur_tint[a]) * blur_a; - } - // (The guest output texture can change between frames; the dedicated - // view g_r.output_srv_slot is re-pointed by EnsurePipeline on change.) - const nrhi::Viewport blur_vp{0.0f, 0.0f, float(RendererState::kBlurWidth), - float(RendererState::kBlurHeight), 0.0f, 1.0f}; - const nrhi::Rect blur_sc{0, 0, int32_t(RendererState::kBlurWidth), - int32_t(RendererState::kBlurHeight)}; - const auto to_srv = [&](nrhi::Texture* r) { - cmd->Barrier(r, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kPixelShaderResource); - }; - const auto to_rt = [&](nrhi::Texture* r) { - cmd->Barrier(r, nrhi::ResourceState::kPixelShaderResource, - nrhi::ResourceState::kRenderTarget); - }; - const auto flush = [&] { cmd->FlushBarriers(); }; - cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); - // Downsample (prefiltered): output -> blur_tex[0], the game's 1152x640 - // blur space. The H/V passes then run 1:1 like the original chain. - to_srv(context.guest_output); - flush(); - cmd->SetRenderTargets(g_r.blur_tex[0], nullptr); - cmd->SetViewport(blur_vp); - cmd->SetScissor(blur_sc); - cmd->SetPipeline(g_r.pso_blur_down); - const float d_consts[4] = {1.0f / float(context.guest_output_width), - 1.0f / float(context.guest_output_height), 0.0f, 0.0f}; - cmd->SetRootConstants(0, 4, d_consts, 0); - cmd->SetTexture(1, g_r.output_srv_slot); - cmd->Draw(3, 0); - // H: blur_tex[0] -> blur_tex[1]. - to_srv(g_r.blur_tex[0]); - flush(); - cmd->SetRenderTargets(g_r.blur_tex[1], nullptr); - cmd->SetPipeline(g_r.pso_blur); - const float h_consts[8] = {1.0f, - 0.0f, - s_blur_r, - 0.0f, - s_blur_tint[0], - s_blur_tint[1], - s_blur_tint[2], - 1.0f}; - cmd->SetRootConstants(0, 8, h_consts, 0); - cmd->SetTexture(1, g_r.blur_srv[0]); - cmd->Draw(3, 0); - // V: blur_tex[1] -> blur_tex[0]. - to_srv(g_r.blur_tex[1]); - to_rt(g_r.blur_tex[0]); - flush(); - cmd->SetRenderTargets(g_r.blur_tex[0], nullptr); - const float v_consts[8] = {0.0f, - 1.0f, - s_blur_r, - 0.0f, - s_blur_tint[0], - s_blur_tint[1], - s_blur_tint[2], - 1.0f}; - cmd->SetRootConstants(0, 8, v_consts, 0); - cmd->SetTexture(1, g_r.blur_srv[1]); - cmd->Draw(3, 0); - // Replace: blur_tex[0] stretched over the full output (basictex). - to_srv(g_r.blur_tex[0]); - to_rt(context.guest_output); - flush(); - cmd->SetRenderTargets(context.guest_output, nullptr); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - cmd->SetPipeline(g_r.pso_blur_blit); - cmd->SetTexture(1, g_r.blur_srv[0]); - cmd->Draw(3, 0); - // Restore the intermediates' steady state for the next blur frame. - to_rt(g_r.blur_tex[0]); - to_rt(g_r.blur_tex[1]); - } - - // 2D overlay (HUD/APT): replay the frame's captured 2D draws over the - // resolved output, in submission order, with the game's own transform - // constants and textures. In gameplay these draws compose the game's - // full-screen HUD overlay texture at true screen coordinates; drawing - // them here IS the composite the (suppressed) emulated pass used to do. - const auto twod_t0 = PerfClock::now(); - uint32_t drawn_2d = 0; - if (REXCVAR_GET(skate3_native_render_scene_2d) && g_r.pso_2d != nullptr && - g_r.ui_ring_cpu != nullptr) { - std::vector scene_2d; - { - std::lock_guard lock(g_2d_mutex); - scene_2d = g_scene_2d; - } - if (!scene_2d.empty()) { - cmd->SetPipeline(g_r.pso_2d); - // One shared draw routine for both the RTT passes and the screen pass. - // ui_region/ui_offset continue after the spline pass's allocations. - // yuv != nullptr redirects this draw to the FMV combine: pso_yuv2d - // with the three movie plane views on params 1/2/5 (t0/t1/t4), the - // quad's own geometry/transform (so windowed movies place exactly). - const auto emit_draw = [&](const Draw2d& d, - nrhi::TextureView* const* yuv = nullptr) { - const uint32_t bytes = uint32_t(d.verts.size()); - if (bytes == 0 || d.stride != 28) { - return; - } - if (ui_offset + bytes > RendererState::kUiRegionSize) { - g_draws_2d_dropped.fetch_add(1, std::memory_order_relaxed); - return; - } - std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset, d.verts.data(), bytes); - nrhi::TextureView* srv_view; - if (yuv != nullptr) { - srv_view = yuv[0]; - } else { - const GuestTexture* t = resolve_2d_texture(d.fetch); - if (t == nullptr) { - // Big-art decode in flight on the workers (large-art async - // routing); skip the quad; it lands 1-3 frames later. - return; - } - srv_view = t->srv; - } - float constants[40]; - std::memcpy(constants, d.consts, sizeof(d.consts)); - // 2D ortho draws have no translation row in the projection (c3 == - // (0,0,0,1)); perspective view-proj rows do. Half-pixel applies to - // the former only. - const bool ortho = d.consts[12] == 0.0f && d.consts[13] == 0.0f && - d.consts[14] == 0.0f && d.consts[15] == 1.0f; - constants[36] = ortho ? 1.0f : 0.0f; - // m[9].y: sharp-magnification amount for APT cached-bitmap tiles - // (see the cvar; the shader gates on actual fetch magnification). - constants[37] = float(std::clamp( - REXCVAR_GET(skate3_native_render_scene_2d_sharp), 0.0, 2.0)); - // m[9].zw: unused. The D3D9 half-pixel shift is derived in the VS - // from the draw's own ortho scale (+0.5 GUEST pixel down-right, - // matching the emulated path's half_pixel_offset reversal); the - // old up-left OUTPUT-pixel nudge staged here left a see-through - // sliver along the bottom/right of edge-to-edge loading quads. - constants[38] = 0.0f; - constants[39] = 0.0f; - cmd->SetRootConstants(0, 40, constants, 0); - cmd->SetTexture(1, srv_view); - if (yuv != nullptr) { - cmd->SetPipeline(g_r.pso_yuv2d); - cmd->SetTexture(2, yuv[1]); - cmd->SetTexture(5, yuv[2]); - } - cmd->SetVertexBuffer(g_r.ui_ring, ui_region + ui_offset, bytes, - d.stride); - cmd->SetPrimitiveTopology(d.prim == 5 - ? nrhi::PrimitiveTopology::kTriangleStrip - : nrhi::PrimitiveTopology::kTriangleList); - cmd->Draw(d.count, 0); - if (yuv != nullptr) { - cmd->SetPipeline(g_r.pso_2d); - } - ui_offset += bytes; - ++drawn_2d; - }; - - cmd->SetRenderTargets(context.guest_output, nullptr); - cmd->SetViewport(viewport); - cmd->SetScissor(scissor); - // Native FMV substitution, self-contained: a video quad's console - // shader binds Y at the DRAW's fetch slot 0 and the U/V planes at - // slots 1/2: three valid distinct textures with the chroma at - // exactly half the luma dimensions is a video draw, regardless of - // which UI path drew it (the camera-page previews are plain APT - // elements; matching against the VideoRenderer-published planes was - // refuted twice; the UI paths sample APT-side plane COPIES, e.g. - // 0x1F6xxxxx vs the published 0xA59xxxxx). Resolved - // triples are cached per frame by the Y address, and their store - // entries are forced to a per-frame liveness probe (a 30 fps video - // rewriting its planes every few RENDERED frames would otherwise - // settle the probe to 16-frame sampling, the "sluggish video"). - const auto yuv_triple = [](const Draw2d& d) -> bool { - const uint32_t* s0 = d.fetch; - const uint32_t* s1 = d.fetch + 6; - const uint32_t* s2 = d.fetch + 12; - if ((s0[0] & 3u) != 2 || (s1[0] & 3u) != 2 || (s2[0] & 3u) != 2 || - s0[1] == 0 || s1[1] == 0 || s2[1] == 0 || s1[1] == s0[1] || - s2[1] == s0[1] || s1[1] == s2[1]) { - return false; - } - const uint32_t w0 = (s0[2] & 0x1FFFu) + 1, h0 = ((s0[2] >> 13) & 0x1FFFu) + 1; - const uint32_t w1 = (s1[2] & 0x1FFFu) + 1, h1 = ((s1[2] >> 13) & 0x1FFFu) + 1; - const uint32_t w2 = (s2[2] & 0x1FFFu) + 1, h2 = ((s2[2] >> 13) & 0x1FFFu) + 1; - const auto half = [](uint32_t full, uint32_t c) { - return c == full / 2 || c == (full + 1) / 2; - }; - return w0 >= 32 && h0 >= 32 && half(w0, w1) && half(h0, h1) && - w2 == w1 && h2 == h1; - }; - struct TripleCacheEntry { - uint32_t y_addr; - bool ok; - nrhi::TextureView* slots[3]; - }; - TripleCacheEntry triple_cache[kMaxMovies]; - int triple_count = 0; - // Backup: the freshest VideoRenderer-published plane set, for a - // bracketed movie quad without a readable triple (the boot intro - // rendered through this before the triple detection existed). - // Resolved lazily; the capped log tracks whether it is still ever - // needed; if it stays silent across sessions, this path and the - // OnMovieFrame publish machinery behind it can be retired. - nrhi::TextureView* fallback_slots[3] = {}; - int fallback_state = 0; // 0 = unresolved, 1 = ok, -1 = unavailable - const auto fallback_yuv = [&]() -> nrhi::TextureView* const* { - if (fallback_state == 0) { - fallback_state = -1; - const MoviePlanes* best = nullptr; - for (const MoviePlanes& m : movies) { - if (m.ns >= 0 && movie_now_ns - m.ns < 500'000'000 && - (best == nullptr || m.ns > best->ns)) { - best = &m; - } - } - if (best != nullptr) { - bool ok = true; - for (int p = 0; p < 3 && ok; ++p) { - auto hot = g_r.tex_store.find(FetchWordsKey(best->words[p])); - if (hot != g_r.tex_store.end()) { - hot->second.recheck_frame = 0; - } - const GuestTexture* t = - resolve_2d_texture(best->words[p], /*force_inline=*/true); - ok = t != &g_r.white && t->texture != nullptr; - fallback_slots[p] = t->srv; - } - if (ok) { - fallback_state = 1; - } - } - } - return fallback_state == 1 ? fallback_slots : nullptr; - }; - bool movie_drawn = false; - for (const Draw2d& d : scene_2d) { - nrhi::TextureView* const* yuv = nullptr; - // Video-quad detection runs regardless of movie_sub: a quad whose - // own fetch slots form a YUV triple must NEVER draw through ps_main - // - that renders the raw Y plane (greyscale luma, or the PREVIOUS - // video's frame while the plane copies are still stale), the - // video-boundary flash class. If the planes can't resolve yet - // (async decode in flight, substitution off) the quad is SKIPPED; - // black under a starting video is what the real thing looks like. - bool video_quad = false; - if (yuv_triple(d)) { - video_quad = true; - if (movie_sub) { - TripleCacheEntry* e = nullptr; - for (int t = 0; t < triple_count && e == nullptr; ++t) { - if (triple_cache[t].y_addr == d.fetch[1]) { - e = &triple_cache[t]; - } - } - if (e == nullptr && triple_count < kMaxMovies) { - e = &triple_cache[triple_count++]; - e->y_addr = d.fetch[1]; - e->ok = true; - bool decode_failed = false; - for (int p = 0; p < 3 && e->ok; ++p) { - const uint32_t* w = d.fetch + p * 6; - // Plain find (find_words_texture would probe + enqueue its - // own ui=false heal first): force the per-frame probe, the - // resolve inline-decodes any content change (video=true). - auto hot = g_r.tex_store.find(FetchWordsKey(w)); - if (hot != g_r.tex_store.end()) { - hot->second.recheck_frame = 0; // content-hot: per-frame probe - } - const GuestTexture* t = - resolve_2d_texture(w, /*force_inline=*/false, /*video=*/true); - // Session gate: only content decoded during THIS movie - // session serves; at a video boundary both the store and - // guest memory can still hold the PREVIOUS video's last - // frame (the plane copies keep their addresses); the quad - // holds black until the new video's first frame lands. - e->ok = t != nullptr && t != &g_r.white && t->texture != nullptr && - t->last_change_frame >= s_movie_session_frame; - decode_failed |= t == &g_r.white; - e->slots[p] = e->ok ? t->srv : nullptr; - } - if (decode_failed) { - static std::atomic s_triple_failed{0}; - if (s_triple_failed.fetch_add(1, std::memory_order_relaxed) < 8) { - REXLOG_INFO( - "native-scene: FMV plane triple resolve FAILED " - "(y={:08X})", - d.fetch[1]); - } - } - } - if (e != nullptr && e->ok) { - yuv = e->slots; - } - } - } - if (yuv == nullptr && movie_sub && (d.flags & 0x2u) != 0 && - d.src_stride == 24) { - // Bracketed movie quad without a readable triple: through ps_main - // it draws its c8 opaque-black cover, also a video quad. - video_quad = true; - yuv = fallback_yuv(); - if (yuv != nullptr) { - static std::atomic s_fb_logged{0}; - if (s_fb_logged.fetch_add(1, std::memory_order_relaxed) < 4) { - REXLOG_INFO( - "native-scene: FMV bracket fallback served a quad (no " - "readable YUV triple on it)"); - } - } - } - if (video_quad) { - // Consulted by YieldForMovie: a video is only "on screen" while - // the 2D replay actually carries a video quad. A skipped/dismissed - // video leaves the decoder tail beating with no quad anywhere - - // yielding then flashes the (suppressed, stale) emulated buffer - // for nothing. - g_movie_quad_last_ns.store(movie_now_ns, std::memory_order_relaxed); - } - if (video_quad && yuv == nullptr) { - static std::atomic s_vskip{0}; - const uint32_t n = s_vskip.fetch_add(1, std::memory_order_relaxed); - if (n < 8 || (n & 511u) == 0) { - REXLOG_INFO( - "native-scene: video quad skipped (planes not ready, " - "y={:08X}, movie_sub={}) (n={})", - d.fetch[1], movie_sub ? 1 : 0, n); - } - continue; - } - emit_draw(d, yuv); - movie_drawn |= yuv != nullptr; - } - if (movie_drawn) { - g_movie_native_last_ns.store(movie_now_ns, std::memory_order_relaxed); - } - if (movie_drawn) { - static std::atomic s_movie_logged{false}; - if (!s_movie_logged.exchange(true, std::memory_order_relaxed)) { - REXLOG_INFO( - "native-scene: FMV rendering NATIVELY (movie-quad YUV " - "substitution)"); - } - } - } - } - - // Settings-menu backdrop blur over everything drawn this frame (scene + - // HUD/2D + FMV). Shared with the emulated-output post-processor path. - // Host state read directly at RENDER time - routing it through the - // published FrameScene made the blur depend on fresh guest publishes, - // which the startup flow doesn't reliably produce (stale scenes rendered - // with a frozen sigma of 0). - const float menu_blur_target = g_settings_menu_blur.load(std::memory_order_relaxed) - ? float(REXCVAR_GET(skate3_menu_blur_sigma)) - : 0.0f; - ApplyMenuBlurPass(context, cmd, menu_blur_target, - /*output_in_guest_output_state=*/false); - - cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, - nrhi::ResourceState::kGuestOutput); - cmd->FlushBarriers(); - - g_pw_2d.Add(perf_ns_since(twod_t0)); - g_pw_tail.Add(uint64_t(std::chrono::duration_cast( - twod_t0 - mid_t0) - .count())); - g_pw_render.Add(perf_ns_since(render_t0)); - const uint64_t frames = g_frames_rendered.fetch_add(1) + 1; - MaybeDumpSceneRing(); - LogFrameStats(scene, frames, drawn, drawn_2d, drawn_spline, shadow_ready, - shadow_draws); - return true; -} - -} // namespace - -void Install() { - // Registered even when the scene cvar starts off: RenderScene yields to the - // emulated output while disabled, and the runtime toggle (F5) can flip the - // cvar live at any point after boot. - rex::graphics::SetNativeGuestOutputRenderer(&RenderScene, nullptr); - // Settings-menu backdrop blur over frames the EMULATED path produced - // (boot/startup before the native scene takes over, manual emulated - // mode); native-rendered frames blur inline in RenderScene. - rex::graphics::SetNativeGuestOutputPostProcessor(&PostProcessGuestOutput, nullptr); - REXLOG_INFO("native-scene: guest output renderer registered (scene {})", - SceneEnabled() ? "on" : "off"); -} - -} // namespace skate3::native_scene - -#else // !(REX_HAS_D3D12 || REX_HAS_VULKAN) - -namespace skate3::native_scene { -void Install() {} -} // namespace skate3::native_scene - -#endif // REX_HAS_D3D12 || REX_HAS_VULKAN diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp new file mode 100644 index 0000000..b730d69 --- /dev/null +++ b/src/skate3_native_scene_gpu.cpp @@ -0,0 +1,11501 @@ +// Native scene renderer, render-thread half: consumes the FrameScene +// published by skate3_native_scene.cpp (game-thread capture/build) and draws +// it through the RHI (D3D12 / Vulkan) into the guest output texture. Shared +// cross-thread state lives in skate3_native_scene_state.h. + +#include "skate3_native_scene.h" + +#include "generated/skate3_init.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +#include +#include +#include +#include + +#include "native/skate3_native_diag.h" +#include "native/skate3_native_entity.h" +#include "native/skate3_native_guest_read.h" +#include "native/skate3_native_lw.h" +#include "native/skate3_native_palette.h" +// Offline-compiled SPIR-V for the native shaders (compiled from the HLSL +// sources with DXC): the Vulkan RHI backend consumes these blobs; the D3D12 +// backend runtime-compiles the embedded HLSL as before. +#include "native/shaders/spirv/skate3_native_shaders_spirv.h" + +#if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN) +#include +#include +#include +#include +#if defined(_WIN32) +#include +#endif +#endif +#include "skate3_native_scene_state.h" + +// Cvars defined in skate3_native_scene.cpp (and SDK cvars re-declared there). +REXCVAR_DECLARE(bool, async_shader_compilation); +REXCVAR_DECLARE(bool, native_render_suppress_emulated_draws); +REXCVAR_DECLARE(bool, readback_resolve_half_pixel_offset); +REXCVAR_DECLARE(bool, skate3_native_render_scene_2d); +REXCVAR_DECLARE(bool, skate3_native_render_scene_backface_cull); +REXCVAR_DECLARE(bool, skate3_native_render_scene_bloom); +REXCVAR_DECLARE(bool, skate3_native_render_scene_boot_native); +REXCVAR_DECLARE(bool, skate3_native_render_scene_cas_yield); +REXCVAR_DECLARE(bool, skate3_native_render_scene_char_shadow_exact); +REXCVAR_DECLARE(bool, skate3_native_render_scene_decals); +REXCVAR_DECLARE(bool, skate3_native_render_scene_dynobj_v2); +REXCVAR_DECLARE(bool, skate3_native_render_scene_entity_fade); +REXCVAR_DECLARE(bool, skate3_native_render_scene_fmv_native); +REXCVAR_DECLARE(bool, skate3_native_render_scene_fmv_yield); +REXCVAR_DECLARE(bool, skate3_native_render_scene_hdr); +REXCVAR_DECLARE(bool, skate3_native_render_scene_hdr_packed); +REXCVAR_DECLARE(bool, skate3_native_render_scene_lightmaps); +REXCVAR_DECLARE(bool, skate3_native_render_scene_lm_dump); +REXCVAR_DECLARE(bool, skate3_native_render_scene_loading_native); +REXCVAR_DECLARE(bool, skate3_native_render_scene_macro); +REXCVAR_DECLARE(bool, skate3_native_render_scene_menu_rtt_passes); +REXCVAR_DECLARE(bool, skate3_native_render_scene_menu_unsuppress); +REXCVAR_DECLARE(bool, skate3_native_render_scene_mesh_revalidate); +REXCVAR_DECLARE(bool, skate3_native_render_scene_pause_native); +REXCVAR_DECLARE(bool, skate3_native_render_scene_perf_log); +REXCVAR_DECLARE(bool, skate3_native_render_scene_photo_display_yield); +REXCVAR_DECLARE(bool, skate3_native_render_scene_photo_grab_native); +REXCVAR_DECLARE(bool, skate3_native_render_scene_photo_native); +REXCVAR_DECLARE(bool, skate3_native_render_scene_photo_readback); +REXCVAR_DECLARE(bool, skate3_native_render_scene_photo_yield); +REXCVAR_DECLARE(bool, skate3_native_render_scene_refl_bias_auto); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ring); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ropa_blend); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ropa_inline); +REXCVAR_DECLARE(bool, skate3_native_render_scene_selection_outline); +REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_caster_parity); +REXCVAR_DECLARE(bool, skate3_native_render_scene_shadows); +REXCVAR_DECLARE(bool, skate3_native_render_scene_sort_opaque); +REXCVAR_DECLARE(bool, skate3_native_render_scene_splines); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao_debug); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ssao_full_res); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ssr); +REXCVAR_DECLARE(bool, skate3_native_render_scene_tex_mips); +REXCVAR_DECLARE(bool, skate3_native_render_scene_tex_revalidate); +REXCVAR_DECLARE(bool, skate3_native_render_scene_transparents); +REXCVAR_DECLARE(bool, skate3_native_render_scene_world_v2); +REXCVAR_DECLARE(double, skate3_menu_blur_sigma); +REXCVAR_DECLARE(double, skate3_native_render_scene_2d_sharp); +REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_intensity); +REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_knee); +REXCVAR_DECLARE(double, skate3_native_render_scene_bloom_threshold); +REXCVAR_DECLARE(double, skate3_native_render_scene_refl_bias_x); +REXCVAR_DECLARE(double, skate3_native_render_scene_refl_bias_y); +REXCVAR_DECLARE(double, skate3_native_render_scene_refl_lod); +REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_intensity); +REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_luma_protect); +REXCVAR_DECLARE(double, skate3_native_render_scene_ssao_radius); +REXCVAR_DECLARE(double, skate3_native_render_scene_ssr_intensity); +REXCVAR_DECLARE(double, skate3_native_render_scene_ssr_thickness); +REXCVAR_DECLARE(double, skate3_native_render_scene_world_v2_tan_sign); +REXCVAR_DECLARE(int32_t, native_render_force_resolve_readback_max_length); +REXCVAR_DECLARE(int32_t, native_render_suppress_mode); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_2d_async_px); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_debug); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_detail_hold); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_hdr_debug); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_menu_rtt_scope); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_mesh_decode_budget); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_msaa); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_photo_native_accum); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_photo_native_debug); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_prewarm_budget_ms); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_refl_mode); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_settle_max_frames); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_shadow_tile); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_ssr_debug); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_ssr_steps); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_warmup_budget_ms); +REXCVAR_DECLARE(int32_t, skate3_native_render_scene_warmup_min_items); +REXCVAR_DECLARE(std::string, skate3_native_render_scene_trace_mesh); +REXCVAR_DECLARE(std::string, skate3_native_render_snapshot_dir); + +#if (defined(REX_HAS_D3D12) && REX_HAS_D3D12) || (defined(REX_HAS_VULKAN) && REX_HAS_VULKAN) + +namespace skate3::native_scene { +namespace { + +using rex::graphics::NativeGuestOutputBackend; +using rex::graphics::NativeGuestOutputRenderContext; +namespace xenos = rex::graphics::xenos; +namespace nrhi = rex::graphics::nrhi; + +// App-local vertex/index binding records (replacing D3D12_VERTEX_BUFFER_VIEW / +// D3D12_INDEX_BUFFER_VIEW): the RHI binds (buffer, offset) directly, no GPU +// virtual addresses. +struct VbBinding { + nrhi::Buffer* buffer = nullptr; + uint64_t offset = 0; + uint32_t size_bytes = 0; + uint32_t stride = 0; +}; +struct IbBinding { + nrhi::Buffer* buffer = nullptr; + uint64_t offset = 0; + uint32_t size_bytes = 0; +}; + +struct MeshBuffers { + nrhi::Buffer* vb = nullptr; + nrhi::Buffer* ib = nullptr; + VbBinding vb_view{}; + IbBinding ib_view{}; + uint64_t fingerprint = 0; + // Dynamic-payload decode order (DynDecodeJob::seq): the commit drops + // results older than the cached entry so multi-worker reordering cannot + // step the cloth backwards a frame. 0 on static decodes. + uint64_t dyn_seq = 0; + // Double-sided sheet prop (banners/flags): most triangles have an + // opposite-winding twin ~1cm behind, and the two faces map to DIFFERENT + // lightmap atlas cells (lit vs shaded side). Drawn without culling both + // faces z-fight once their depth gap drops below buffer precision; + // distant triangles alternate between the two cells per frame (the "flag + // flicker", stops up close where 1cm still resolves). These meshes draw + // with the backface-culling PSO instead. + bool two_sided_sheet = false; + // ROPA only: the decoded vertex array (num_verts x 14 floats, the scene + // VS layout) retained for draw-time shape blending onto the play clock. + std::vector ropa_verts; +}; + +struct GuestTexture { + nrhi::Texture* texture = nullptr; + nrhi::Buffer* upload = nullptr; // kept alive; copy recorded in deferred list + // In-place re-decode ping-pong partner (UpdateGuestTexture2DInPlace): the + // GPU may still be copying from the OTHER upload buffer for the previous + // frame's content change, so hot content alternates buffers instead of + // paying two buffer creations per change. + nrhi::Buffer* upload_b = nullptr; + bool upload_flip = false; + uint32_t fetch_words[6] = {}; // big-endian words as read for revalidation + nrhi::TextureView* srv = nullptr; + // For failed decodes: frame number for periodic retry (payload may stream + // in after the fetch constant is already valid). + uint64_t retry_after_frame = 0; + // Payload-content revalidation: a texture first seen while its payload is + // still STREAMING IN decodes to garbage (blocky macro-tile checkers / + // blacked-out walls), and the fetch words never change when the content + // finishes filling in at the same address, so the garbage decode was + // cached forever. Sampled qwords across mip 0, rechecked periodically. + uint32_t payload_addr = 0; // 0xA-mirror guest address of mip 0 + uint32_t payload_size = 0; + uint64_t payload_fp = 0; + uint64_t recheck_frame = 0; + // Consecutive failed decodes of this entry (payload still streaming in): + // drives the escalating retry backoff at commit; the first failure + // retries fast (the payload usually lands within a few frames; a fixed + // +120 held freshly streamed textures white for ~half a second), repeat + // failures back off toward the old cadence. + uint8_t fail_count = 0; + // Successful payload rechecks so far: fresh entries re-verify fast + // (2/4/8 frames; a mid-stream garbage decode otherwise stays on screen + // up to the full recheck interval) before settling at the 16-frame + // steady-state cadence. + uint8_t recheck_count = 0; + // Tiled-aware payload probes: absolute 0xA-mirror addresses of up to 16 + // of this texture's OWN mip-0 blocks (a 4x4 spread over the block grid, + // resolved through the tiled address swizzle). The old contiguous + // [payload_addr, payload_size) fingerprint was WRONG for streamed world + // textures: they are pitch-packed into shared mip pools and tiled across + // padded macro rows, so the range interleaved NEIGHBOR textures' bytes; + // pool churn kept the fingerprint flapping, the commit-time stability + // verify then rejected every legitimate heal, and freshly promoted mips + // stayed stale/low-res for seconds (the medium-distance texture pop-in; + // one promote re-logged 8x while its heals were refused). + // probe_count == 0 falls back to the range fingerprint (cubes). + // 8x8 = 64 probes: the first 4x4 = 16 grid was too sparse; a mid-stream + // decode whose 16 probed blocks happened to be final (other regions still + // garbage) COMMITTED and never healed, visible as a black/garbage decal + // until the next words change replaced it ("decal flash and replace"). + uint32_t probe_addr[64] = {}; + uint8_t probe_count = 0; + // SRV recipe remnants (2D textures): srv_format is the revalidation + // compare key and srv_mips gates the single-mip in-place update path. + // The paired t4/t5 descriptor re-creation this recipe once fed is gone; + // pairs bind their two views directly via SetTexturePair (descriptor + // management is backend-internal now). + nrhi::Format srv_format = nrhi::Format::kUnknown; + uint32_t srv_mips = 0; + bool valid = false; + // Store LRU clock: last frame this entry was served/touched. Superseded + // words states (old mip levels, pre-demote detail sets) age out once + // nothing routes to them. + uint64_t last_used_frame = 0; + // A tiled mip's padded macro-row copy faulted and fell back to the + // reported size; blocks beyond it uploaded as ZERO (the half-black + // banner mip: tiled addressing puts the image's bottom rows past + // min_size). The entry serves (better than white) but keeps re-decoding + // until a complete copy lands; the commit prefers complete decodes. + bool incomplete = false; + // Decode-time SampleProbeNearBlack verdict: the payload was near-uniform + // black when this decode was taken (a lightmap page mid-compose). The + // lightmap-slot resolve serves the white fallback instead (tint.r == 0 -> + // the unshadowed-bright window, 59810af semantics) until a heal lands + // real content; other slots ignore it (black diffuse content is legal). + bool near_black = false; + // Same-content confirmations of a near-black verdict (commit dedups of + // forced re-decodes). The forcing covers ONE race, a decode reading a + // page mid-compose while the fingerprint sampled the composed result, + // so a few confirmations prove the content is genuinely uniform and the + // forcing stops (the plain fp poll still heals a later in-place compose). + // Unbounded forcing re-decoded permanently-uniform textures every poll + // forever, each a discarded worker round trip. + uint8_t nb_redecodes = 0; + // Frame of the last decode that landed CHANGED content for this key + // (stamped by the words-key commit and the inline 2D decode). Classifies + // video-plane content changes as mid-playback (recent change -> serve the + // stale frame while the worker re-decodes, keeps 30 fps cadence) vs a + // playback-START edge (quiet for seconds -> the entry holds the PREVIOUS + // video's last frame; serving it flashed old content at every video + // boundary; hold the quad black until fresh content commits). + uint64_t last_change_frame = 0; +}; + +// THE texture identity key: FNV-1a +// over the six fetch-constant words, console identity semantics for the +// content store. Object resolves, draw-time word bindings, 2D/HUD art, and +// the decode workers all key the same g_r.tex_store entries with it. +inline uint64_t FetchWordsKey(const uint32_t words[6]) { + uint64_t key = 1469598103934665603ull; + for (int k = 0; k < 6; ++k) { + // Clamp modes (dword_0 bits 10-18, clamp_x/y/z) are SAMPLER state, not + // content identity; the same texture object binds with different clamp + // bits per draw (observed: one texture decoded twice under 01004802 vs + // 01024802, bit 17), and keying on them decodes/stores every variant + // separately. The store holds textures + SRVs only; samplers are + // static in the replay pipelines, so clamp-variant entries are exact + // duplicates. Sign bits stay: they select the host SRV format. + key ^= k == 0 ? (words[k] & ~0x0007FC00u) : words[k]; + key *= 1099511628211ull; + } + return key; +} + +// Base-level pixel area from stored fetch words (word 2 packs width-1 / +// height-1 in 13-bit fields), the material-detail downgrade compare key. +inline uint64_t FetchWordsArea(const uint32_t words[6]) { + return uint64_t((words[2] & 0x1FFFu) + 1u) * + uint64_t(((words[2] >> 13) & 0x1FFFu) + 1u); +} + +// Escalating retry backoff (native frames) for failed texture decodes: the +// payload is usually mid-stream and lands within a few frames; the first +// retry is fast (a fixed +120 held freshly streamed textures white for +// ~half a second); repeated failures back off toward the old cadence. +inline uint64_t RetryBackoff(uint8_t fails) { + const uint32_t n = fails > 0 ? uint32_t(fails) - 1 : 0u; + return std::min(120, 8ull << std::min(n, 4u)); +} +inline uint8_t BumpFail(uint8_t v) { return v < 250 ? uint8_t(v + 1) : v; } + +// FNV-1a guest-payload fingerprint (SEH-guarded reads; streaming can +// decommit the range). Returns 0 only on unreadable payloads. +// Payloads up to 64 KB hash FULLY: the runtime-composed lightmap atlas +// pages (32 KB DXT1) receive REGIONAL in-place writes as the game composes +// late-streamed cells into a page we already decoded; the old 16-qword +// probe missed writes that fell between its samples, so the revalidation +// pass never re-decoded and the affected cells served the half-composed +// first decode forever (the 2x-bright canopy / dark awning reflective_trans +// glass: one texture, early-composed cells correct, late cells +// stale). Larger payloads (streamed assets whose fetch words change when +// content moves) keep a strided sample, densified 16 -> 64 qwords. +uint64_t SamplePayloadFingerprint(uint8_t* base, uint32_t addr, uint32_t size) { + if (addr == 0 || size < 8) { + return 0; + } + uint64_t h = 1469598103934665603ull; + if (size <= 65536) { + uint64_t buf[512]; + for (uint32_t off = 0; off < size; off += sizeof(buf)) { + const uint32_t n = std::min(sizeof(buf), (size - off) & ~7u); + if (n == 0) { + break; + } + if (!GuestTryCopy(buf, base + addr + off, n)) { + return 0; + } + for (uint32_t k = 0; k < n / 8; ++k) { + h = (h ^ buf[k]) * 1099511628211ull; + } + } + return h; + } + for (uint32_t k = 0; k < 64; ++k) { + const uint32_t off = uint32_t(uint64_t(size - 8) * k / 63u) & ~7u; + uint64_t v = 0; + if (!GuestTryCopy(&v, base + addr + off, sizeof(v))) { + return 0; + } + h = (h ^ v) * 1099511628211ull; + } + return h; +} + +// Heal-pipeline telemetry (see the 600-frame stats line): verify-fail = +// commit-time stability rejections, demote-hold = mip-0 demotes served +// from the cached full-chain decode without a doomed re-decode. +std::atomic g_heal_verify_fail{0}; +std::atomic g_demote_hold{0}; +// Sticky/skip serving (the anti-white-flash layer): sticky = draws served +// with the item's previous texture while a fresh object decodes; skipnew = +// first-sight draws suppressed entirely for the in-flight window. +std::atomic g_tex_sticky_served{0}; +std::atomic g_skip_new{0}; +// Per-mesh pipeline trace (skate3_native_render_scene_trace_mesh). Render +// thread only: the traced mesh address (parsed once per frame), the traced +// mesh's current store keys (so worker-commit events can be matched), and +// the last logged per-ctx state signature (summaries log on change). +uint32_t g_trace_mesh_addr = 0; +std::unordered_set g_trace_keys; +std::unordered_map g_trace_sig; +// Words-keyed (event-ad / streamed-artwork) serving: stale = site served +// its previous art while a new-words decode is in flight; none = nothing +// decoded for the site yet (caller shows the baked placeholder). +std::atomic g_ad_stale_served{0}; +std::atomic g_ad_placeholder{0}; + +// Fill GuestTexture::probe_addr with up to 16 of the texture's own mip-0 +// blocks: a 4x4 spread over the block grid, each resolved through the same +// tiled/linear addressing the decode uses, clamped to the guarded copy's +// readable range. Probes crossing into the padded macro-row tail are skipped +// rather than clamped; the tail belongs to pool neighbors. +void BuildPayloadProbes(const rex::graphics::TextureInfo& info, uint32_t mip0_addr, + uint32_t ox, uint32_t oy, uint32_t pitch_blocks, + uint32_t copy_size, GuestTexture& out) { + out.probe_count = 0; + const rex::graphics::FormatInfo* fi = info.format_info(); + const uint32_t bpb = fi->bytes_per_block(); + if (mip0_addr == 0 || bpb == 0 || (bpb & (bpb - 1)) != 0) { + return; + } + const uint32_t bpb_log2 = uint32_t(std::countr_zero(bpb)); + const uint32_t width = info.width + 1u; + const uint32_t height = info.height + 1u; + const uint32_t cols = (width + fi->block_width - 1) / fi->block_width; + const uint32_t rows = (height + fi->block_height - 1) / fi->block_height; + for (uint32_t gy = 0; gy < 8; ++gy) { + for (uint32_t gx = 0; gx < 8; ++gx) { + const uint32_t bx = cols > 1 ? gx * (cols - 1) / 7 : 0; + const uint32_t by = rows > 1 ? gy * (rows - 1) / 7 : 0; + uint32_t off; + if (info.is_tiled) { + off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(bx + ox), int32_t(by + oy), pitch_blocks, bpb_log2)); + } else { + off = ((by + oy) * pitch_blocks + bx + ox) * bpb; + } + if (off + bpb > copy_size) { + continue; + } + out.probe_addr[out.probe_count++] = (0xA0000000u | mip0_addr) + off; + } + } +} + +// Probe-based payload fingerprint: hashes one block-leading qword (or the +// whole block for narrow formats) at each probe address. Returns 0 only when +// a probe is unreadable: same semantics as the range fingerprint. Falls +// back to the range hash when no probes were built (cube maps). +uint64_t SampleProbeFingerprint(uint8_t* base, const GuestTexture& t) { + if (t.probe_count == 0) { + return SamplePayloadFingerprint(base, t.payload_addr, t.payload_size); + } + uint64_t h = 1469598103934665603ull; + for (uint32_t i = 0; i < t.probe_count; ++i) { + uint64_t v = 0; + if (!GuestTryCopy(&v, base + t.probe_addr[i], sizeof(v))) { + return 0; + } + h = (h ^ v) * 1099511628211ull; + } + return h; +} + +// Near-UNIFORM payload detector, sampled over the same probe grid at decode +// time. A COMPOSED lightmap page or weathering overlay is never one flat +// value across its whole mip-0 (a daylight bake / grime map atlases many +// surfaces), but a page decoded mid-compose is uniform fill: zeroed OR a +// flat grey (the reported "black/grey squares": a zero-only detector +// is structurally blind to grey). A real-but-garbage lightmap binds with +// tint.r > 0 so the CSM min-clamp renders BLACK (the door 59810af's +// white-fallback shader gate cannot see), and since 59810af a garbage macro +// multiplies OVER the decal paint. Consumers act on this only for the +// WHITE-NEUTRAL slots (lightmap/macro); uniform diffuse/spec content is +// legal and unaffected. +bool SampleProbeNearBlack(uint8_t* base, const GuestTexture& t) { + if (t.probe_count < 16) { + return false; // range-fallback entries (cubes): not enough coverage + } + uint64_t a = 0, b = 0; + bool have_a = false, have_b = false; + for (uint32_t i = 0; i < t.probe_count; ++i) { + uint64_t v = 0; + if (!GuestTryCopy(&v, base + t.probe_addr[i], sizeof(v))) { + return false; + } + if (!have_a) { + a = v; + have_a = true; + } else if (v != a && !have_b) { + b = v; + have_b = true; + } else if (v != a && v != b) { + return false; // 3+ distinct block values = real composed content + } + } + return true; // <= 2 distinct block values across the whole probe grid +} + +// Host format mapping for the formats Skate 3 uses (mirrors the SDK's +// D3D12 texture cache table). host_swizzle remaps guest data components +// before the fetch-constant swizzle composes on top. +struct HostTextureFormat { + nrhi::Format resource_format = nrhi::Format::kUnknown; + nrhi::Format srv_format = nrhi::Format::kUnknown; + uint32_t host_swizzle = xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA; +}; + +bool GetHostTextureFormat(xenos::TextureFormat format, HostTextureFormat& out) { + switch (rex::graphics::GetBaseFormat(format)) { + case xenos::TextureFormat::k_DXT1: + out = {nrhi::Format::kBC1_UNORM, nrhi::Format::kBC1_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; + return true; + case xenos::TextureFormat::k_DXT2_3: + out = {nrhi::Format::kBC2_UNORM, nrhi::Format::kBC2_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; + return true; + case xenos::TextureFormat::k_DXT4_5: + out = {nrhi::Format::kBC3_UNORM, nrhi::Format::kBC3_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; + return true; + case xenos::TextureFormat::k_DXT5A: + out = {nrhi::Format::kBC4_UNORM, nrhi::Format::kBC4_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RRRR}; + return true; + case xenos::TextureFormat::k_DXN: + out = {nrhi::Format::kBC5_UNORM, nrhi::Format::kBC5_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGGG}; + return true; + case xenos::TextureFormat::k_8_8_8_8: + out = {nrhi::Format::kR8G8B8A8_UNORM, nrhi::Format::kR8G8B8A8_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA}; + return true; + case xenos::TextureFormat::k_8: + out = {nrhi::Format::kR8_UNORM, nrhi::Format::kR8_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RRRR}; + return true; + case xenos::TextureFormat::k_8_8: + out = {nrhi::Format::kR8G8_UNORM, nrhi::Format::kR8G8_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGGG}; + return true; + case xenos::TextureFormat::k_5_6_5: + // Red/blue swapped CPU-side while uploading. + out = {nrhi::Format::kB5G6R5_UNORM, nrhi::Format::kB5G6R5_UNORM, + xenos::XE_GPU_TEXTURE_SWIZZLE_RGBB}; + return true; + default: + return false; + } +} + +// Compose the fetch-constant swizzle over the host-format component remap +// into a per-channel view swizzle (Xenos 0..5 = X,Y,Z,W,0,1, the same +// order as nrhi::Swizzle). +void ComposeSrvSwizzle(uint32_t fetch_swizzle, uint32_t host_swizzle, + nrhi::Swizzle out[4]) { + for (uint32_t c = 0; c < 4; ++c) { + const uint32_t guest = (fetch_swizzle >> (3 * c)) & 7u; + const uint32_t v = guest >= 4 ? guest : ((host_swizzle >> (3 * guest)) & 7u); + out[c] = nrhi::Swizzle(v); + } +} + +void SwapGuestEndian(uint8_t* data, uint32_t size, xenos::Endian endian) { + switch (endian) { + case xenos::Endian::k8in16: + for (uint32_t i = 0; i + 2 <= size; i += 2) { + std::swap(data[i], data[i + 1]); + } + break; + case xenos::Endian::k8in32: + for (uint32_t i = 0; i + 4 <= size; i += 4) { + std::swap(data[i], data[i + 3]); + std::swap(data[i + 1], data[i + 2]); + } + break; + case xenos::Endian::k16in32: + for (uint32_t i = 0; i + 4 <= size; i += 4) { + std::swap(data[i], data[i + 2]); + std::swap(data[i + 1], data[i + 3]); + } + break; + default: + break; + } +} + +struct RendererState { + nrhi::Device* device = nullptr; + nrhi::BindingLayout* layout = nullptr; // main scene binding layout (was the root signature) + nrhi::Pipeline* pso = nullptr; + nrhi::Pipeline* pso_cullback = nullptr; // two_sided_sheet meshes (see MeshBuffers) + nrhi::Pipeline* pso_nodepth = nullptr; + // environment.transparent sub-pass: straight alpha blend, depth test on, + // z-write OFF; items drawn back-to-front after all opaque items. + nrhi::Pipeline* pso_transparent = nullptr; + // Entity-fade variant of the transparent PSO: same straight alpha blend + // but z-write ON. A fading character/vehicle is a solid object at partial + // opacity; z-write-off blending composites every overlapping piece (skin + // under clothes, far-side doors/wheels through the body shell) into an + // x-ray. With depth writes the nearest surface wins and each pixel blends + // once, matching the game's main-pass fade. + nrhi::Pipeline* pso_fade = nullptr; + // Hair sub-passes: transparent blend state with cull BACK / cull FRONT + // (the game's cac_hair/defaulthair two-pass draw order). + nrhi::Pipeline* pso_hair_a = nullptr; + nrhi::Pipeline* pso_hair_b = nullptr; + nrhi::Format rtv_format = nrhi::Format::kUnknown; + nrhi::Texture* depth = nullptr; + uint32_t depth_width = 0; + uint32_t depth_height = 0; + // Cached guest-output texture identity (context.guest_output) for change + // detection: the presenter recreates the output image on resize. + nrhi::Texture* rtv_resource = nullptr; + // MSAA: the scene draws into msaa_color (+ MSAA depth) and a fullscreen + // pass averages the samples into the guest output texture (the RHI command + // stream has no resolve copy). + uint32_t msaa = 1; + nrhi::Texture* msaa_color = nullptr; + nrhi::Pipeline* resolve_pso = nullptr; + nrhi::TextureView* msaa_srv_slot = nullptr; + bool msaa_srv_allocated = false; + // Popup background blur (see kBlurShaderSource): two intermediates at the + // game's fixed 1152x640 internal resolution + a view of the guest output + // (re-created when the output texture changes). Steady state for + // blur_tex is RENDER_TARGET. + static constexpr uint32_t kBlurWidth = 1152; + static constexpr uint32_t kBlurHeight = 640; + nrhi::Texture* blur_tex[2] = {nullptr, nullptr}; + nrhi::TextureView* blur_srv[2] = {nullptr, nullptr}; + nrhi::TextureView* output_srv_slot = nullptr; + bool output_srv_allocated = false; + nrhi::Pipeline* pso_blur = nullptr; + nrhi::Pipeline* pso_blur_blit = nullptr; + nrhi::Pipeline* pso_blur_down = nullptr; + // Settings-menu gaussian backdrop (ps_menu_gauss): clean separable + // gaussian on half-output-res ping-pong intermediates, applied at the END + // of the compose so it frosts everything drawn (3D + HUD/2D + FMV). + // Deliberately independent of the popup-blur port above (that chain + // reproduces the console's 1152x640 lattice on purpose). Steady state + // RENDER_TARGET; recreated on output size change. + nrhi::Pipeline* pso_menu_gauss = nullptr; + nrhi::Texture* menu_blur_tex[2] = {nullptr, nullptr}; + nrhi::TextureView* menu_blur_srv[2] = {nullptr, nullptr}; + uint32_t menu_blur_w = 0; + uint32_t menu_blur_h = 0; + // Native photo grab (photo_grab_native): the blur downsample pass (whose + // 1152x640 blur space happens to BE the game's screenshot-target raster) + // renders the finished frame into blur_tex[0], which is then copied into + // one of these persistently-mapped READBACK buffers; the next armed frame + // CPU-tiles the completed one big-endian into the guest grab target. Two + // buffers so no frame ever waits on the GPU (a full kFull-style drain per + // frame was the old path's stall). Row pitch 1152*4 = 4608 is already + // 256-aligned. + static constexpr uint32_t kGrabRowPitch = kBlurWidth * 4; + nrhi::Buffer* grab_readback[2] = {nullptr, nullptr}; + uint8_t* grab_readback_ptr[2] = {nullptr, nullptr}; + uint64_t grab_submission[2] = {0, 0}; + bool grab_pending[2] = {false, false}; + uint32_t grab_write_index = 0; + bool grab_failed = false; + // Window stats for the close log: frames written to guest + CPU tile time. + uint32_t grab_writes = 0; + uint64_t grab_cpu_us = 0; + // Native FMV: overlay-2D pipeline variant whose PS combines the movie + // player's three CPU-filled YUV plane textures (ps_yuv2d; substituted for + // the captured movie quad in the 2D replay, see OnMovieFrame). + nrhi::Pipeline* pso_yuv2d = nullptr; + // Selection outline (see DrawItem::selected): selected items re-render + // into a single-sample R8 mask at OUTPUT resolution (a low-res mask + // stairstepped the contour centerline at 4K) and a fullscreen edge-detect + // pass with fixed UV-fraction tap pitch adds the blue outline onto the + // resolved output (postfx_edgedetectstencil equivalent). Mask steady + // state is RENDER_TARGET. + nrhi::Texture* outline_mask = nullptr; + uint32_t outline_mask_width = 0; + uint32_t outline_mask_height = 0; + nrhi::TextureView* outline_mask_srv = nullptr; + bool outline_mask_srv_allocated = false; + nrhi::Pipeline* pso_outline_mask = nullptr; + nrhi::Pipeline* pso_outline_edge = nullptr; + // Photo-editor postfx chain (photo_fx.hlsl: exact ucode ports, see + // FrameScene::PhotoFx). Own binding layout: CBV b0 (the pass's 256 + // captured/baked constant rows) + eight single-texture tables t0..t7 + + // three static samplers. Intermediates at the game's exact half/quarter + // sizes (the DOF tap offsets are baked for them); the full-res stages run + // at output resolution. pfx_quarter persists across frames (accumulation + // input). All pfx color targets idle in RENDER_TARGET state. + static constexpr uint32_t kPfxHalfW = 576, kPfxHalfH = 320; + static constexpr uint32_t kPfxQuarterW = 288, kPfxQuarterH = 160; + nrhi::BindingLayout* pfx_layout = nullptr; + // PSO order: depthpack, visualfx, dof_down, dof_mb, dof, uber, fisheye, blit. + nrhi::Pipeline* pfx_pso[9] = {}; + nrhi::Texture* pfx_full[2] = {}; // output-res RGBA8 (visualfx out, uber out) + nrhi::Texture* pfx_half[2] = {}; // 576x320 RGBA8 + nrhi::Texture* pfx_quarter = nullptr; // 288x160 RGBA8 accumulation + nrhi::Texture* pfx_depth = nullptr; // output-res packed-depth RGBA8 + nrhi::Texture* pfx_lut = nullptr; // 32^3 identity grade LUT (RGBA8) + nrhi::Buffer* pfx_lut_upload = nullptr; + bool pfx_lut_uploaded = false; + nrhi::Buffer* pfx_cb = nullptr; // upload heap, persistently mapped + uint8_t* pfx_cb_ptr = nullptr; + // Fixed SRV views: 0/1 = pfx_full, 2/3 = pfx_half, 4 = quarter, 5 = + // packed depth, 6 = LUT, 7 = the native MSAA/1x depth resource. + nrhi::TextureView* pfx_srv[8] = {}; + bool pfx_srv_allocated = false; + uint32_t pfx_width = 0, pfx_height = 0; + bool pfx_ready = false; + bool pfx_failed = false; + // Screen-space ambient occlusion (ssao.hlsl: GTAO over the resolved + // scene; see ApplySsaoPass). Own binding layout: root constants b0 + two + // single-texture tables t0/t1 + point/linear clamp samplers. Full-res + // intermediates: linearized view-Z (R32F) + two AO ping-pong planes + // (R8), idle in RENDER_TARGET state. PSOs rebuild when the MSAA level + // (depth SRV dimension / linearize variant) or the output format + // (composite target) changes. + nrhi::BindingLayout* ao_layout = nullptr; + nrhi::Pipeline* pso_ao_linearize = nullptr; + nrhi::Pipeline* pso_ao_gtao = nullptr; + nrhi::Pipeline* pso_ao_blur = nullptr; + nrhi::Pipeline* pso_ao_luma = nullptr; + nrhi::Pipeline* pso_ao_composite = nullptr; + nrhi::Pipeline* pso_ao_debug = nullptr; + nrhi::Texture* ao_lin_depth = nullptr; + nrhi::Texture* ao_tex[2] = {}; + // Scene luminance at the AO raster: the sun-lit protection mask input + // (bright pixels resist AO and skip the march entirely). + nrhi::Texture* ao_luma = nullptr; + nrhi::TextureView* ao_lin_srv = nullptr; + nrhi::TextureView* ao_srv[2] = {}; + nrhi::TextureView* ao_luma_srv = nullptr; + // View of the scene depth buffer, re-pointed when depth is rebuilt. + nrhi::TextureView* ao_depth_srv = nullptr; + nrhi::Texture* ao_depth_srv_of = nullptr; + uint32_t ao_width = 0, ao_height = 0; // AO raster (half or full res) + uint32_t ao_lin_width = 0, ao_lin_height = 0; // linear depth (always full) + uint32_t ao_msaa = 0; + nrhi::Format ao_rtv_format = nrhi::Format::kUnknown; + bool ao_hdr = false; // luma/gtao/composite built for the HDR target + bool ao_failed = false; + // Fused-AO handoff (HDR path): the AO pass leaves the finished multiplier + // plane (ao_tex[0]) in PIXEL_SHADER_RESOURCE state for ps_tonemap to + // consume at t2 (replacing the classic full-res composite draw); + // ApplyHdrPost restores it to RENDER_TARGET and clears the flag. + bool ao_plane_in_psr = false; + // HDR pipeline (hdr.hlsl): the scene renders pre-tonemap linear into a + // float intermediate (the MSAA color target and/or hdr_resolved), a bloom + // pyramid extracts real HDR energy, and ps_tonemap applies the game's + // shared tone chain once into the gamma guest output. Binding layout = + // the SSAO shape (root constants b0 + two single-texture tables + + // point/linear clamp samplers). All targets idle in RENDER_TARGET state. + bool hdr_active = false; // latched per pipeline build (PSO formats/variants) + bool hdr_failed = false; // float targets unavailable -> classic path + // Latched scene float format (RGBA16F or the packed R11G11B10 option); + // every HDR-path PSO/target creation reads this, never the cvar. + nrhi::Format hdr_scene_format = nrhi::Format::kR16G16B16A16_FLOAT; + nrhi::BindingLayout* hdr_layout = nullptr; + nrhi::Pipeline* pso_bloom_first = nullptr; // mip-0 extract (threshold+Karis) + nrhi::Pipeline* pso_bloom_down = nullptr; + nrhi::Pipeline* pso_bloom_up = nullptr; // additive ONE/ONE tent upsample + nrhi::Pipeline* pso_tonemap = nullptr; + nrhi::Format hdr_pso_out_format = nrhi::Format::kUnknown; + // 1x float scene plane: the MSAA resolve destination (or the scene target + // itself when MSAA is off); the AO composite, bloom extract and tonemap + // all consume/write it. + nrhi::Texture* hdr_resolved = nullptr; + nrhi::TextureView* hdr_srv = nullptr; + static constexpr uint32_t kBloomMaxLevels = 6; + nrhi::Texture* bloom_tex[kBloomMaxLevels] = {}; + nrhi::TextureView* bloom_srv[kBloomMaxLevels] = {}; + uint32_t bloom_w[kBloomMaxLevels] = {}; + uint32_t bloom_h[kBloomMaxLevels] = {}; + uint32_t bloom_levels = 0; + uint32_t bloom_base_w = 0, bloom_base_h = 0; // output size the chain fits + bool targets_hdr = false; // output-sized targets built for the HDR path + // Scene float format the output-sized targets were built with (kUnknown + // when classic); packed-format toggles rebuild them. + nrhi::Format targets_scene_fmt = nrhi::Format::kUnknown; + // Screen-space reflections (ssr.hlsl + scene.hlsl ps_refl_gbuf): a + // half-res reflection G-buffer re-rendered from the frame's reflective + // items (env fams 5/6/13 + water), a half-res screen-space march over the + // full-res linear depth sampling the pre-tonemap HDR plane, and a + // src-alpha composite back onto it between the AO pass and the HDR post + // (so reflections tonemap/bloom like directly-visible scenery). HDR path + // only. Post binding layout = the SSAO shape with 32 root floats + three + // single-texture tables; the G-buffer pass runs under the MAIN layout + // (scene VS + per-item constants). All intermediates idle in + // RENDER_TARGET state. + nrhi::BindingLayout* ssr_layout = nullptr; + nrhi::Pipeline* pso_ssr_gbuf = nullptr; // scene-VS geometry pass + nrhi::Pipeline* pso_ssr_linearize = nullptr; // fallback when SSAO idle + nrhi::Pipeline* pso_ssr_march = nullptr; + nrhi::Pipeline* pso_ssr_composite = nullptr; + nrhi::Texture* ssr_gbuf = nullptr; // half res RGBA16F reflection G-buffer + // Half-res depth for the G-buffer pass (test+write LESS): reflective + // items overlap themselves in screen space (a curved glass tower's far + // side shares texels with its near side inside ONE mesh), so without a + // depth test draw/triangle order decides which surface owns a texel and + // whole visible panes fail the march's scene-depth match. Idles in + // DEPTH_WRITE state. + nrhi::Texture* ssr_gbuf_depth = nullptr; + nrhi::Texture* ssr_tex = nullptr; // half res RGBA16F march output + nrhi::Texture* ssr_lin_depth = nullptr; // full res R32F (own linearize) + nrhi::TextureView* ssr_gbuf_srv = nullptr; + nrhi::TextureView* ssr_srv = nullptr; + nrhi::TextureView* ssr_lin_srv = nullptr; + // Scene-depth SRV for the own linearize, re-pointed on depth rebuilds. + nrhi::TextureView* ssr_depth_srv = nullptr; + nrhi::Texture* ssr_depth_srv_of = nullptr; + uint32_t ssr_width = 0, ssr_height = 0; // half-res raster + uint32_t ssr_lin_width = 0, ssr_lin_height = 0; + uint32_t ssr_msaa = 0; // linearize-variant PSO latch + nrhi::Format ssr_scene_fmt = nrhi::Format::kUnknown; // composite target + bool ssr_failed = false; + // Per-frame handoff: the G-buffer pass drew and left ssr_gbuf in + // PIXEL_SHADER_RESOURCE for ApplySsrPass (which consumes + restores it). + bool ssr_gbuf_ready = false; + std::unordered_map meshes; + // (The old D3D12 bookkeeping, the retired-resource vector, the CPU SRV + // staging heap and its slot allocator/recycling lists, is gone: resource + // destruction is deferred inside the RHI (Device::DestroyDeferred) and + // texture bindings are backend-managed view objects.) + GuestTexture white; + // Water environment CUBE maps (t6): separate cache; same guest object + // addresses decode differently (6 faces, TextureCube SRV). + std::unordered_map cube_textures; + GuestTexture white_cube; + // Bone palette ring: persistent-mapped upload buffer, one region per + // in-flight frame. + static constexpr uint32_t kBoneRegionSize = 1u << 20; + // 8-deep (was 4): the Vulkan CP was observed letting the GPU lag more + // than 3 frames under load - 4-deep regions then recycle while still + // referenced (hair shimmer / collapsed garments). If the ring race is + // confirmed, the CP-side throttle is the + // proper fix and this is defense in depth. + static constexpr uint32_t kBoneRegions = 8; + nrhi::Buffer* bone_ring = nullptr; + uint8_t* bone_ring_cpu = nullptr; + uint32_t bone_ring_offset = 0; + // ROPA shape-generation ring (per garment mesh): the last decoded vertex + // arrays keyed by dyn_seq, so the draw can combine the generations under + // the interp pass's kernel weights (the body's own 8-tap boxcar / + // pair-lerp: the stepped OR filter-mismatched shape against the + // interpolated body was the tee jelly). Plus a persistent-mapped upload + // ring the per-frame blended verts are written into (regioned like the + // bone ring). + struct RopaGen { + uint64_t seq = 0; + // Host time at commit: the blend refuses to mix generations recorded + // across a sim-sleep gap (a garment whose cloth sim slept keeps its + // last drapes in the ring; blending one of those, recorded when the + // character stood elsewhere / faced differently, against the fresh + // drape renders the garment rotated/offset from the body). + double t = 0.0; + std::vector verts; // num_verts x 14 floats (scene VS layout) + }; + std::unordered_map> ropa_shapes; + static constexpr uint32_t kRopaRegionSize = 1u << 20; + nrhi::Buffer* ropa_ring = nullptr; + uint8_t* ropa_ring_cpu = nullptr; + uint32_t ropa_ring_offset = 0; + // 2D overlay (HUD/APT replay): alpha-blended depth-less pipeline drawing + // the captured inline vertices from a per-frame upload ring. + nrhi::Pipeline* pso_2d = nullptr; + // In-world neon splines, drawn inside the MSAA scene pass (depth test on, + // no z-write): darken = straight alpha, default = additive glow. + nrhi::Pipeline* pso_spline_darken = nullptr; + nrhi::Pipeline* pso_spline_default = nullptr; + // Dynamic CSM shadows: casters render + // into a 3-tile (depth, coverage) atlas with MIN blend (depth clear 1, + // "uncoverage" clear 1 -> covered texels write 0), then the game's exact + // Gaussian-coverage + depth-dilation blur runs per tile (5-tap cascade 0, + // 3-tap cascade 1, format-convert-only cascade 2) into the atlas the + // scene pass samples at t5. + nrhi::Pipeline* pso_shadow_caster = nullptr; + nrhi::Pipeline* pso_shadow_blur = nullptr; + nrhi::Texture* shadow_raw = nullptr; // caster pass target + nrhi::Texture* shadow_mid = nullptr; // hblur output + nrhi::Texture* shadow_final = nullptr; // vblur output, sampled + uint32_t shadow_tile = 0; // per-cascade tile size (atlas = 3*tile x tile) + nrhi::TextureView* shadow_srv_raw = nullptr; + nrhi::TextureView* shadow_srv_mid = nullptr; + nrhi::TextureView* shadow_srv_final = nullptr; + // After a shadow pass all three textures sit in PIXEL_SHADER_RESOURCE; + // the next pass transitions them back to RENDER_TARGET first. + bool shadow_in_srv_state = false; + // Shadow-atlas readback, taken on kShadowDumpFrames CONSECUTIVE frames + // once per F11 recording session: the raw and blurred planes (R16G16) + // land in the snapshot dir for offline analysis; the guest's own atlas + // resolve never reaches CPU memory, so this is the only view of what + // receivers actually sample. Consecutive frames make temporal-stability + // diffs possible (silhouette buzz vs animation). + static constexpr uint32_t kShadowDumpFrames = 4; + nrhi::Buffer* shadow_dump_buf[kShadowDumpFrames] = {}; + uint8_t* shadow_dump_ptr[kShadowDumpFrames] = {}; + uint64_t shadow_dump_submission[kShadowDumpFrames] = {}; + uint32_t shadow_dump_enqueued = 0; + uint32_t shadow_dump_written = 0; + bool shadow_dump_done = false; + // Per-frame shadow constant buffer ring (CBV b1). 512-byte slices: the + // first 256 bytes are the original 16-row block, rows 16-18 carry the + // dynamicobject world-shadow transform (dyn_ws* in the scene shader). + static constexpr uint32_t kShadowCbRegions = 8; + static constexpr uint32_t kShadowCbSlice = 512; + nrhi::Buffer* shadow_cb = nullptr; + uint8_t* shadow_cb_cpu = nullptr; + // dynamicobject static world-shadow map (512x512, same convention as the + // game's own: x = light-space depth, sge(x >= ray) = lit): re-rendered + // natively from the frame's STATIC world items with the captured c5/c6/c7 + // transform. MIN blend accumulates across frames WITHOUT clearing; the + // per-frame item list is view-culled, and dropping off-screen casters + // would flicker props' shade with the camera. Cleared (re-primed) only + // when the captured transform changes (streaming region switch). + nrhi::Texture* world_shadow = nullptr; + nrhi::TextureView* world_shadow_srv = nullptr; + bool world_shadow_in_srv = false; + bool world_shadow_primed = false; + float world_shadow_rows[12] = {}; + static constexpr uint32_t kWorldShadowSize = 512; + static constexpr uint32_t kUiRegionSize = 1u << 20; + static constexpr uint32_t kUiRegions = 8; + nrhi::Buffer* ui_ring = nullptr; + uint8_t* ui_ring_cpu = nullptr; + // Last successfully resolved words-key per streamed-art site + // (mesh << 1 | slot; slot 0 = diffuse override, 1 = decal override). + // Serves the site's previous art while a new-mip-words decode is in + // flight (see resolve_fetch_words). Render thread only. + std::unordered_map words_sticky; + // (The fam 5/6 masks+normal t4/t5 descriptor-pair cache is gone: draws + // bind both views directly via cmd->SetTexturePair(5, spec, normal); + // pair descriptors are cached inside the backend, so texture replacement + // can never leave a stale descriptor.) + // THE texture content store (console identity + // semantics): fetch-words key -> decode. One + // words state = one entry; both states of a streaming flap A<->B simply + // stay resident under their own keys (what the old lookaside simulated + // with park/take), and superseded states age out via the LRU. Shared by + // the 3D draw path (through the object routes below), the 2D/HUD pass, + // and the draw-time fetch-word overrides (posters/ads). Render thread + // only. + std::unordered_map tex_store; + // Texture object -> its last STABLE fetch-words state (seqlock + // double-read at resolve). A route is a lookup aid, never an owner: the + // game freely retargets objects (mip promote/demote, detail demote, + // object reuse) and every retarget is just a different store key; a + // reused object can never serve another binding's art. Render thread + // only. + struct TexRoute { + uint32_t words[6] = {}; + uint64_t key = 0; + // Live words carry no mip-0 base (streamer demoted the top level; the + // old pool range is already reused). The route holds the pre-demote + // state, its cached decode carries the full chain, strictly better, + // and payload polls are suspended while held (the probes would read + // the reused pool). A re-promote publishes fresh words and re-routes. + bool demoted = false; + }; + std::unordered_map tex_routes; + // Sticky texture serving (see resolve_texture in draw_item): the last + // ADOPTED (served-live) words state per (mesh << 3 | slot). Serves the + // site's previous art while the current binding's decode is in flight + // (the console's own mip-transition look), and powers the + // detail-downgrade hold: a strict base-area shrink keeps serving the + // previous state's store entry for the hold window. Render thread only. + struct TexStickySite { + uint64_t words_key = 0; + uint64_t area = 0; + // Nonzero = a downgrade is being held, first seen at this frame. The + // site adopts the smaller binding once the downgrade persists past + // skate3_native_render_scene_detail_hold frames. + uint64_t downgrade_since = 0; + }; + std::unordered_map tex_sticky; + // First frame a texture object resolved white-with-heal-in-flight: + // brand-new items (no sticky fallback) skip drawing for a short window + // instead of flashing white. + std::unordered_map tex_pending_first; + bool failed = false; + bool announced = false; +}; + +RendererState g_r; + +// Retire a guest texture's GPU resources AND its view. Destruction is +// deferred inside the RHI against the CURRENT submission; the `submission` +// parameter is kept for call-site compatibility and ignored. +void RetireGuestTexture(const GuestTexture& t, uint64_t submission) { + (void)submission; + g_r.device->DestroyDeferred(t.texture); + g_r.device->DestroyDeferred(t.upload); + g_r.device->DestroyDeferred(t.upload_b); + g_r.device->DestroyDeferred(t.srv); +} + +// ---- Content store helpers -------------------------------------------------- +// The store is words-keyed; the guest +// streamer's object retargeting (mip flap A<->B, detail demote, object +// reuse) is just different keys, so both states of any transition stay +// resident and no rebind can ever serve another binding's art. +std::atomic g_store_evicted{0}; +constexpr size_t kTexStoreCap = 3072; + +uint32_t SwapU32(uint32_t v); // defined with the decode helpers below + +// Seqlock-stable read of a texture object's six fetch words, guest -> host +// order. The streamer rewrites the words word-by-word on its own thread; a +// mixed snapshot decodes a coherent image of the WRONG memory (a shared +// mip-pool page reads as a collage of neighbor art), and the result is +// stable, valid-looking content no fingerprint can reject after the fact; +// the read itself must be self-consistent. Two consecutive identical +// snapshots (4 attempts) or the caller keeps its previous route/skips. +bool ReadStableTexWords(uint8_t* base, uint32_t tex_ptr, uint32_t out[6]) { + uint32_t raw[6]; + uint32_t raw2[6]; + bool stable = false; + for (int attempt = 0; attempt < 4 && !stable; ++attempt) { + if (!GuestTryCopy(raw, base + tex_ptr + 7 * 4, sizeof(raw)) || + !GuestTryCopy(raw2, base + tex_ptr + 7 * 4, sizeof(raw2))) { + return false; + } + stable = std::memcmp(raw, raw2, sizeof(raw)) == 0; + } + if (!stable) { + return false; + } + for (uint32_t i = 0; i < 6; ++i) { + out[i] = SwapU32(raw[i]); + } + return true; +} + +// Store LRU eviction, run once per frame: superseded words states (old mip +// levels, pre-demote detail sets, one-shot UI art) hold SRV slots + GPU +// memory until nothing has routed to them for a while. Never evicts +// entries touched within the last few frames. +void EvictTexStore(uint64_t frame_number, uint64_t submission) { + if (g_r.tex_store.size() <= kTexStoreCap) { + return; + } + std::vector> ages; // (last-used frame, key) + ages.reserve(g_r.tex_store.size()); + for (const auto& [k, t] : g_r.tex_store) { + if (t.last_used_frame + 4 < frame_number) { + ages.emplace_back(t.last_used_frame, k); + } + } + const size_t excess = g_r.tex_store.size() - kTexStoreCap / 2; + const size_t n = std::min(ages.size(), excess); + if (n == 0) { + return; + } + std::nth_element(ages.begin(), ages.begin() + (n - 1), ages.end()); + for (size_t i = 0; i < n; ++i) { + const auto it = g_r.tex_store.find(ages[i].second); + if (it != g_r.tex_store.end()) { + RetireGuestTexture(it->second, submission); + g_r.tex_store.erase(it); + } + } + g_store_evicted.fetch_add(n, std::memory_order_relaxed); +} + +// ---- Staged texture decode (worker-thread half) --------------------------- +// The texture decoders normally finish by recording GPU copies into the +// frame's command stream, pushing a barrier and creating the SRV, all +// render-thread-only. When `g_tex_stage_out` is set (decode worker), they +// stop after filling the upload resource and export what the render-thread +// commit needs instead. RHI resource creation and upload mapping are +// thread-safe, so everything up to that point is safe off-thread. +struct StagedMipCopy { + uint32_t offset, pitch, w, h; // upload footprint per mip +}; +struct StagedTexCommit { + nrhi::Format copy_format = nrhi::Format::kUnknown; + nrhi::Format srv_format = nrhi::Format::kUnknown; + nrhi::Swizzle swizzle[4] = {nrhi::Swizzle::kX, nrhi::Swizzle::kY, + nrhi::Swizzle::kZ, nrhi::Swizzle::kW}; + uint32_t mip_count = 0; + // Cube map (environment cubes): mips[] holds face-major (face * levels + + // mip) subresource copies, the old D3D12 subresource numbering, which the + // commit decomposes back into (face, mip), and the SRV is a kCube view + // with cube_mip_levels levels. The cube MIP CHAIN + // is load-bearing: the game's reflective glass perturbs its reflection + // vector with a per-pixel normal map, so the hardware cube fetch runs at + // a DEEP gradient-derived LOD; a mip-0-only cube shows the plaza cube's + // baked streetlight heads as a sharp magnified smear the real console + // output blurs away. + bool cube = false; + uint32_t cube_mip_levels = 1; + // 6 faces x up to 10 levels (512 -> 1 full generated chain). + StagedMipCopy mips[64] = {}; +}; +thread_local StagedTexCommit* g_tex_stage_out = nullptr; + +// Render-thread half: record the staged upload's copies + barrier, create +// the SRV, mark the texture live. On view-creation failure the texture stays +// invalid (renders white; the backend-managed views make this near-impossible). +void CommitStagedGuestTexture(const NativeGuestOutputRenderContext& context, + GuestTexture& gt, const StagedTexCommit& sc) { + for (uint32_t m = 0; m < sc.mip_count; ++m) { + const StagedMipCopy& p = sc.mips[m]; + // Cube commits stage face-major (face * levels + mip) entries (the old + // D3D12 subresource numbering): decompose into (mip, face). + const uint32_t mip = sc.cube ? m % sc.cube_mip_levels : m; + const uint32_t face = sc.cube ? m / sc.cube_mip_levels : 0; + context.cmd->CopyBufferToTexture(gt.texture, mip, face, gt.upload, p.offset, + p.pitch, p.w, p.h, 1); + } + context.cmd->Barrier(gt.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + nrhi::TextureViewDesc vd; + vd.format = sc.srv_format; + for (uint32_t c = 0; c < 4; ++c) { + vd.swizzle[c] = sc.swizzle[c]; + } + if (sc.cube) { + vd.dimension = nrhi::ViewDimension::kCube; + vd.mip_levels = sc.cube_mip_levels; + } else { + vd.dimension = nrhi::ViewDimension::k2D; + vd.mip_levels = sc.mip_count; + gt.srv_format = sc.srv_format; + gt.srv_mips = sc.mip_count; + } + gt.srv = g_r.device->CreateTextureView(gt.texture, vd); + if (gt.srv == nullptr) { + gt.valid = false; + return; + } + gt.valid = true; +} + +// Worker-pool result plumbing (see the prewarm queue globals above; these +// live here because they need the resource/item types). +struct StagedTexResult { + uint32_t key = 0; // guest texture object address (object-keyed cache) + uint64_t words_key = 0; // != 0: content-store result (g_r.tex_store) + bool cube = false; // environment cube (g_r.cube_textures) + GuestTexture gt; + StagedTexCommit commit; + bool valid = false; + // The commit-time stability verify rejected this decode (payload moved + // between the worker's read and the commit): retry fast, not on the + // failed-decode ladder. + bool verify_failed = false; + // 2D/HUD overlay origin: skip the stability verify (see PrewarmEntry::ui). + bool ui = false; +}; +struct PrewarmResult { + DrawItem item; + MeshBuffers buffers; + bool mesh_valid = false; + std::vector textures; + // Draw-path miss result (visible right now): the commit takes it this + // frame regardless of the gameplay per-frame cap. + bool miss = false; +}; +std::mutex g_prewarm_out_mutex; +std::vector g_prewarm_out; +// Failed builds (buffer objects not initialized yet) land here; the render +// thread re-injects them each frame so retries are frame-paced instead of +// hot-spinning the workers. +std::vector g_prewarm_retry; // under g_prewarm_out_mutex + +// Shader sources live in src/native/shaders/*.hlsl; the build embeds them +// into this generated header (cmake/EmbedShaders.cmake) so the exe stays +// self-contained. Same kFooSource char arrays as before. (The offline +// SPIR-V table for the same shaders is included at the top of the file; +// it declares ::skate3::native_spirv and must not nest inside this +// namespace.) +#include "skate3_native_shaders.h" + +float HalfToFloat(uint16_t h) { + const uint32_t sign = (h & 0x8000u) << 16; + uint32_t exp = (h >> 10) & 0x1F; + uint32_t mant = h & 0x3FF; + if (exp == 0) { + if (mant == 0) return std::bit_cast(sign); + // subnormal + while (!(mant & 0x400)) { + mant <<= 1; + --exp; + } + ++exp; + mant &= 0x3FF; + } else if (exp == 31) { + return std::bit_cast(sign | 0x7F800000u | (mant << 13)); + } + return std::bit_cast(sign | ((exp + 112) << 23) | (mant << 13)); +} + +nrhi::Buffer* CreateUploadBuffer(nrhi::Device* device, size_t size) { + nrhi::BufferDesc desc; + desc.size = size; + desc.heap = nrhi::HeapKind::kUpload; + return device->CreateBuffer(desc); // nullptr on failure, like the old helper +} + +uint16_t SwapU16(uint16_t v) { return uint16_t((v >> 8) | (v << 8)); } +uint32_t SwapU32(uint32_t v) { +#if defined(_MSC_VER) + return _byteswap_ulong(v); +#else + return __builtin_bswap32(v); +#endif +} + +// Upload sub-allocation alignment for per-mip footprints (the old +// D3D12_TEXTURE_DATA_PLACEMENT_ALIGNMENT; row pitches use +// nrhi::kRowPitchAlignment = the old D3D12_TEXTURE_DATA_PITCH_ALIGNMENT). +constexpr uint32_t kUploadPlacementAlignment = 512; + +// Decode guest vertices into {float3 position, float2 uv, float2 uv2, +// unorm4 blend weights, u8x4 blend indices, float3 normal, float2 decal_uv} +// (56-byte stride). decal_uv = the 3rd/4th halves of a half4 first texcoord +// (the packed second UV pair environment.decal art is mapped with), equal to +// the first pair when the element has only two halves. +// vb_payload/ib_payload: optional pre-copied guest payload snapshots (the +// dynamic cloth decode jobs snapshot on the guest thread and convert on a +// worker, see DynDecodeJob); when null the payloads are read live from +// guest memory with the guarded copy. +bool DecodeMesh(nrhi::Device* device, uint8_t* base, const DrawItem& item, + MeshBuffers& out, const uint8_t* vb_payload = nullptr, + const uint8_t* ib_payload = nullptr) { + const uint32_t num_verts = item.vb_bytes / item.stride; + if (num_verts == 0) return false; + // This runs on the render thread; the guest payloads were valid on the + // game thread this frame but streaming can decommit them in between. + // Copy them out with the lock-free guarded copy (never VirtualQuery here: + // the VAD lock stalls behind the guest streaming threads while panning). + static thread_local std::vector vb_scratch; + static thread_local std::vector ib_scratch; + if (vb_payload == nullptr) { + vb_scratch.resize(item.vb_bytes); + if (!GuestTryCopy(vb_scratch.data(), base + item.vb_addr, item.vb_bytes)) { + return false; + } + vb_payload = vb_scratch.data(); + } + if (!item.cloth_quads && ib_payload == nullptr) { + ib_scratch.resize(size_t(item.ib_count) * 2); + if (!GuestTryCopy(ib_scratch.data(), base + item.ib_addr, size_t(item.ib_count) * 2)) { + return false; + } + ib_payload = ib_scratch.data(); + } + nrhi::Buffer* vb = CreateUploadBuffer(device, size_t(num_verts) * 56); + nrhi::Buffer* ib = CreateUploadBuffer(device, size_t(item.ib_count) * 2); + if (!vb || !ib) { + device->DestroyDeferred(vb); + device->DestroyDeferred(ib); + return false; + } + + // two_sided_sheet detection eligibility (see MeshBuffers): small static + // triangle-list meshes only. Strips alternate winding per triangle and + // skinned/cloth meshes deform, so both stay on the uncull(ed) PSO. + bool detect_sheet = + !item.skinned && !item.cloth_quads && item.ib_count >= 6 && item.ib_count <= 8192; + for (const DrawEntry& de : item.draws) { + if (de.prim != 4) { + detect_sheet = false; + } + } + std::vector sheet_pos; + if (detect_sheet) { + sheet_pos.reserve(size_t(num_verts) * 3); + } + + const uint8_t* src_vb = vb_payload; + float* dst = nullptr; + uint32_t garbage = 0; + int min_bi = 255; + int max_bi = -1; + dst = static_cast(device->Map(vb)); + for (uint32_t v = 0; v < num_verts; ++v) { + const uint8_t* p = src_vb + size_t(v) * item.stride + item.pos_offset; + float x = 0.0f, y = 0.0f, z = 0.0f; + switch (item.pos_fmt) { + case 57: { // k_32_32_32_FLOAT + x = std::bit_cast(SwapU32(*reinterpret_cast(p))); + y = std::bit_cast(SwapU32(*reinterpret_cast(p + 4))); + z = std::bit_cast(SwapU32(*reinterpret_cast(p + 8))); + break; + } + case 32: { // k_16_16_16_16_FLOAT + x = HalfToFloat(SwapU16(*reinterpret_cast(p))); + y = HalfToFloat(SwapU16(*reinterpret_cast(p + 2))); + z = HalfToFloat(SwapU16(*reinterpret_cast(p + 4))); + break; + } + case 26: { // k_16_16_16_16 snorm character dequant + const auto s16 = [&](int off) { + return int16_t(SwapU16(*reinterpret_cast(p + off))); + }; + constexpr float kScale = 2.0f / 32767.0f; + x = s16(0) * kScale; + y = s16(2) * kScale + 0.8f; + z = s16(4) * kScale; + break; + } + default: + device->Unmap(vb); + device->DestroyDeferred(vb); + device->DestroyDeferred(ib); + return false; + } + if (!(x == x && y == y && z == z) || + x < item.bbox_min[0] - 2.f || x > item.bbox_max[0] + 2.f || + y < item.bbox_min[1] - 2.f || y > item.bbox_max[1] + 2.f || + z < item.bbox_min[2] - 2.f || z > item.bbox_max[2] + 2.f) { + ++garbage; + } + const auto decode_uv = [&](uint32_t fmt, uint32_t offset, float& u, float& w) { + u = 0.0f; + w = 0.0f; + if (fmt == 0) return; + const uint8_t* q = src_vb + size_t(v) * item.stride + offset; + switch (fmt) { + case 31: // k_16_16_FLOAT + case 32: // k_16_16_16_16_FLOAT (use xy) + u = HalfToFloat(SwapU16(*reinterpret_cast(q))); + w = HalfToFloat(SwapU16(*reinterpret_cast(q + 2))); + break; + case 37: // k_32_32_FLOAT (hair strand-alpha UV; xenos enum 37) + case 38: // k_32_32_32_32_FLOAT (use xy) + u = std::bit_cast(SwapU32(*reinterpret_cast(q))); + w = std::bit_cast(SwapU32(*reinterpret_cast(q + 4))); + break; + case 25: { // k_16_16 (snorm; UVs span the full s16 range for [0,1]) + const int16_t su = int16_t(SwapU16(*reinterpret_cast(q))); + const int16_t sv = int16_t(SwapU16(*reinterpret_cast(q + 2))); + u = su / 32767.0f; + w = sv / 32767.0f; + break; + } + case 26: { // k_16_16_16_16 (snorm; use xy: unwrap UV sets) + const int16_t su = int16_t(SwapU16(*reinterpret_cast(q))); + const int16_t sv = int16_t(SwapU16(*reinterpret_cast(q + 2))); + u = su / 32767.0f; + w = sv / 32767.0f; + break; + } + default: + break; + } + }; + dst[v * 14 + 0] = x; + dst[v * 14 + 1] = y; + dst[v * 14 + 2] = z; + if (detect_sheet) { + sheet_pos.push_back(x); + sheet_pos.push_back(y); + sheet_pos.push_back(z); + } + decode_uv(item.uv_fmt, item.uv_offset, dst[v * 14 + 3], dst[v * 14 + 4]); + decode_uv(item.uv2_fmt, item.uv2_offset, dst[v * 14 + 5], dst[v * 14 + 6]); + // The second texcoord is the lightmap/decal UNWRAP, stored as an + // absolute value with tangent handedness in the sign bits, in BOTH the + // s16-snorm (fmt 26, decal meshes) and half-float (fmt 31, world tiles) + // encodings (decalenvironment VS: o0.zw = |uv|; baseenvironment VS: + // maxs r2.zw = |r4.xy|). Raw signed values sample mirrored atlas cells. + // Exception: hair meshes, their second texcoord is the strand-alpha + // UV, passed RAW by the hair VS (o0.zw = uv, float2). + if (item.char_family < 4) { + dst[v * 14 + 5] = std::fabs(dst[v * 14 + 5]); + dst[v * 14 + 6] = std::fabs(dst[v * 14 + 6]); + } + // Blend weights (unorm bytes) and indices (raw bytes). Guest u8x4 + // attributes are stored big-endian per 32-bit word; swap so component k + // in the shader matches guest component k. + uint32_t bw = 0; + uint32_t bi = 0; + if (item.skinned) { + bw = SwapU32(*reinterpret_cast(src_vb + size_t(v) * item.stride + + item.bw_offset)); + bi = SwapU32(*reinterpret_cast(src_vb + size_t(v) * item.stride + + item.bi_offset)); + for (int k = 0; k < 4; ++k) { + const uint8_t weight = uint8_t(bw >> (k * 8)); + const uint8_t index = uint8_t(bi >> (k * 8)); + if (weight != 0) { + if (index < min_bi) min_bi = index; + if (index > max_bi) max_bi = index; + } + } + } + std::memcpy(&dst[v * 14 + 7], &bw, 4); + std::memcpy(&dst[v * 14 + 8], &bi, 4); + // Decal-art UV: second half pair of a half4 first texcoord, else uv0. + if (item.uv_fmt == 32) { + decode_uv(31, item.uv_offset + 4, dst[v * 14 + 12], dst[v * 14 + 13]); + } else { + dst[v * 14 + 12] = dst[v * 14 + 3]; + dst[v * 14 + 13] = dst[v * 14 + 4]; + } + // Vertex normal: k_10_11_11 packed (x 11 bits, y 11 bits, z 10 bits, + // LSB to MSB, signed). Zero when absent -> derivative face-normal + // fallback in the shader. + const auto unpack_10_11_11 = [&](uint32_t offset, float out[3]) { + const uint32_t word = SwapU32(*reinterpret_cast( + src_vb + size_t(v) * item.stride + offset)); + const int32_t ix = int32_t(word << 21) >> 21; + const int32_t iy = int32_t((word >> 11) << 21) >> 21; + const int32_t iz = int32_t((word >> 22) << 22) >> 22; + out[0] = float(ix) / 1023.0f; + out[1] = float(iy) / 1023.0f; + out[2] = float(iz) / 511.0f; + }; + // Stored-tangent-frame pack (world-shading / dynobj v2): snorm + // component to the unorm byte of the blend-weight word. + const auto pk = [](float f) { + return uint32_t(std::lround((f * 0.5f + 0.5f) * 255.0f)) & 0xFFu; + }; + float n3[3] = {0.0f, 0.0f, 0.0f}; + if (item.env_family != 0 && + (item.env_family <= 6 || item.env_family == 13) && + item.uv2_fmt == 26) { + // Exact world families: the REAL vertex normal is packed in the + // lightmap-unwrap element (fmt 26 s16x4): zw = normal.xy (snorm), and + // the unwrap xy SIGN bits carry the handedness; sign.y flips + // normal.z (baseenvironment VS: vNormal.z = signs.y * sqrt(1 - xy^2); + // signs = saturate(65535 * lm.xy) * 2 - 1). The k_10_11_11 element on + // these meshes is the BINORMAL, not the normal; using it as the + // normal breaks the sun/spec terms of the exact shading. + const uint8_t* q = src_vb + size_t(v) * item.stride + item.uv2_offset; + const int16_t sx = int16_t(SwapU16(*reinterpret_cast(q))); + const int16_t sy = int16_t(SwapU16(*reinterpret_cast(q + 2))); + const int16_t nx = int16_t(SwapU16(*reinterpret_cast(q + 4))); + const int16_t ny = int16_t(SwapU16(*reinterpret_cast(q + 6))); + n3[0] = nx / 32767.0f; + n3[1] = ny / 32767.0f; + const float d = 1.0f - n3[0] * n3[0] - n3[1] * n3[1]; + n3[2] = (sy > 0 ? 1.0f : -1.0f) * std::sqrt(d > 0.0f ? d : 0.0f); + // World-shading v2: the STORED tangent frame. These static meshes + // never skin, so the blend-weight bytes are free: pack the mesh's + // k_10_11_11 binormal (unorm-mapped) plus the unwrap sign.x tangent + // handedness into them. The shader reconstructs T = cross(B, N) x + // handedness; authoring decides per UV island whether the frame + // follows a mirror, which derivative frames cannot know (the wooden + // ramp panels banded light/dark per island without this). + if (!item.skinned && item.normal_fmt == 16) { + float b3[3]; + unpack_10_11_11(item.normal_offset, b3); + // w byte is a three-state presence sentinel: 0 = no stored frame + // (meshes that skip this pack leave the word zero), 100 = negative + // handedness, 200 = positive. + const uint32_t packed = pk(b3[0]) | (pk(b3[1]) << 8) | + (pk(b3[2]) << 16) | + ((sx > 0 ? 200u : 100u) << 24); + std::memcpy(&dst[v * 14 + 7], &packed, 4); + } + } else if (item.normal_fmt == 16) { + // (Vehicle meshes: the game's VS derives cross(tangent, binormal) + // instead, but the stored usage-3 normal MATCHES it on every vertex + // with a non-degenerate tangent frame and stays sane on the ~13% + // interior verts where t is parallel to b and the cross vanishes, + // measured in capture, so the stored normal is used.) + unpack_10_11_11(item.normal_offset, n3); + // Dynamicobject v2: rigid props store a full usage-6/7 tangent + + // binormal pair alongside the usage-3 normal. Pack the binormal plus + // the handedness relating cross(B, N) to the stored tangent into the + // unused blend-weight bytes (same sentinel encoding as the env + // families above); the authored frame carries the per-UV-island + // mirror decisions the screen-space fallback cannot know. + if (!item.skinned && item.dynobj != 0 && item.tb_fmt == 16) { + float t3[3], b3[3]; + unpack_10_11_11(item.tangent_offset, t3); + unpack_10_11_11(item.binormal_offset, b3); + const float h = (b3[1] * n3[2] - b3[2] * n3[1]) * t3[0] + + (b3[2] * n3[0] - b3[0] * n3[2]) * t3[1] + + (b3[0] * n3[1] - b3[1] * n3[0]) * t3[2]; + const uint32_t packed = pk(b3[0]) | (pk(b3[1]) << 8) | + (pk(b3[2]) << 16) | + ((h >= 0.0f ? 200u : 100u) << 24); + std::memcpy(&dst[v * 14 + 7], &packed, 4); + } + } else if (item.tb_fmt == 16) { + // NPC character meshes carry no normal element; the game's VS + // derives it as cross(tangent, binormal) (usage 6 x usage 7; verified + // against the emulated VS outputs). Without this the strong + // character N.L shading exposes the face-normal fallback as visible + // low-poly facets on every pedestrian. + float t3[3], b3[3]; + unpack_10_11_11(item.tangent_offset, t3); + unpack_10_11_11(item.binormal_offset, b3); + n3[0] = t3[1] * b3[2] - t3[2] * b3[1]; + n3[1] = t3[2] * b3[0] - t3[0] * b3[2]; + n3[2] = t3[0] * b3[1] - t3[1] * b3[0]; + // Dynamicobject v2, tangent+binormal-only layout variant: the derived + // normal makes cross(B, N) == T x |B|^2 up to orthogonality error, so + // the handedness is positive by construction. + if (!item.skinned && item.dynobj != 0) { + const uint32_t packed = pk(b3[0]) | (pk(b3[1]) << 8) | + (pk(b3[2]) << 16) | (200u << 24); + std::memcpy(&dst[v * 14 + 7], &packed, 4); + } + } + dst[v * 14 + 9] = n3[0]; + dst[v * 14 + 10] = n3[1]; + dst[v * 14 + 11] = n3[2]; + } + // Dynobj v2 layout telemetry (first few meshes): which tangent-frame + // source the props carry decides between the packed authored frame and + // the shader's screen-space fallback. + if (item.dynobj != 0) { + static std::atomic dynobj_layout_logged{0}; + if (dynobj_layout_logged.fetch_add(1, std::memory_order_relaxed) < 8) { + REXLOG_INFO( + "native-scene: dynobj mesh {:08X} layout normal_fmt={} tb_fmt={} " + "skinned={}", + item.mesh, item.normal_fmt, item.tb_fmt, item.skinned); + } + } + // Stretch-veto probe (see g_skin_probe): cache ~32 decoded sample verts + // so the guest thread can cheaply skin what the GPU will actually draw. + if (item.skinned) { + constexpr uint32_t kProbe = 32; + const uint32_t pn = std::min(kProbe, num_verts); + SkinProbe probe; + probe.fp = item.fingerprint; + probe.s.reserve(pn); + for (uint32_t s = 0; s < pn; ++s) { + const uint32_t v = pn > 1 ? (s * (num_verts - 1) / (pn - 1)) : 0; + SkinProbeSample ps; + ps.p[0] = dst[v * 14 + 0]; + ps.p[1] = dst[v * 14 + 1]; + ps.p[2] = dst[v * 14 + 2]; + std::memcpy(&ps.bw, &dst[v * 14 + 7], 4); + std::memcpy(&ps.bi, &dst[v * 14 + 8], 4); + probe.s.push_back(ps); + } + std::lock_guard lock(g_skin_probe_mutex); + if (g_skin_probe.size() > 4096) { + g_skin_probe.clear(); + } + g_skin_probe[item.mesh] = std::move(probe); + } + // ROPA shape blending: retain the decoded vertex array (14 floats per + // vertex, the scene VS layout) so consecutive generations can be lerped + // onto the motion-smoothing play clock at draw time; the stepped shape + // against the interpolated body was the tee jelly (worse at LOWER fps: + // the excursion scales with the guest period; emulated pairs pose N with + // shape N and shows none of it). + if (item.ropa) { + out.ropa_verts.assign(dst, dst + size_t(num_verts) * 14); + } + device->Unmap(vb); + // Blend indices outside the captured palette read garbage rows and mangle + // the vertex. Indices are plain bone numbers, bone k = palette rows 3k. + // (Dyn decode jobs carry no palette: the item's bones ride the scene item, + // not the decode; skip the check there.) + if (item.skinned && max_bi >= 0 && !item.bones.empty()) { + const uint32_t palette_bones = uint32_t(item.bones.size() / 12); + if (uint32_t(max_bi) >= palette_bones) { + REXLOG_WARN( + "native-scene: skinned mesh {:08X} blend index range {}..{} exceeds " + "captured palette of {} bones", + item.mesh, min_bi, max_bi, palette_bones); + } + } + if (garbage != 0) { + REXLOG_WARN( + "native-scene: mesh {:08X} decoded {} of {} verts outside bbox " + "({:.1f},{:.1f},{:.1f})..({:.1f},{:.1f},{:.1f}) fmt {} stride {} vb {:08X}", + item.mesh, garbage, num_verts, item.bbox_min[0], item.bbox_min[1], item.bbox_min[2], + item.bbox_max[0], item.bbox_max[1], item.bbox_max[2], item.pos_fmt, item.stride, + item.vb_addr); + } + + uint16_t* dst_ib = nullptr; + dst_ib = static_cast(device->Map(ib)); + if (item.cloth_quads) { + // Quad-list topology with no guest index buffer (live vertex range + // already exact from the draw args): two triangles per quad. + const uint32_t quads = item.ib_count / 6; + for (uint32_t q = 0; q < quads; ++q) { + const uint16_t v = uint16_t(q * 4); + uint16_t* o = dst_ib + q * 6; + o[0] = v; + o[1] = uint16_t(v + 1); + o[2] = uint16_t(v + 2); + o[3] = v; + o[4] = uint16_t(v + 2); + o[5] = uint16_t(v + 3); + } + } else { + const uint16_t* src_ib = reinterpret_cast(ib_payload); + for (uint32_t i = 0; i < item.ib_count; ++i) { + dst_ib[i] = SwapU16(src_ib[i]); + } + } + // Front/back sheet pattern: opposite-winding twin triangles a few mm-cm + // apart ALONG THE NORMAL. The two sides are often triangulated along + // OPPOSITE quad diagonals (downtown lamppost posters: sheets 4mm apart, + // twin centroids 0.26m apart in-plane), so twins are matched by + // plane-to-plane distance with an in-plane tolerance scaled to triangle + // size; raw centroid distance misses them. >=60% twinned marks the mesh + // double-sided (banners are 100%); O(T^2) but only for <=8192-index + // static tri-list meshes and only once per decode. + bool two_sided = false; + if (detect_sheet) { + struct SheetTri { + float c[3]; + float n[3]; + float edge; // longest edge length (in-plane tolerance scale) + }; + std::vector tris; + tris.reserve(item.ib_count / 3); + for (const DrawEntry& de : item.draws) { + if (uint64_t(de.start_index) + de.index_count > item.ib_count) { + continue; + } + for (uint32_t i = 0; i + 2 < de.index_count; i += 3) { + const uint32_t a = uint32_t(dst_ib[de.start_index + i]) + de.base_vertex; + const uint32_t b = uint32_t(dst_ib[de.start_index + i + 1]) + de.base_vertex; + const uint32_t c = uint32_t(dst_ib[de.start_index + i + 2]) + de.base_vertex; + if (a >= num_verts || b >= num_verts || c >= num_verts) { + continue; + } + const float* pa = &sheet_pos[size_t(a) * 3]; + const float* pb = &sheet_pos[size_t(b) * 3]; + const float* pc = &sheet_pos[size_t(c) * 3]; + const float e1[3] = {pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]}; + const float e2[3] = {pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]}; + const float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], + e1[0] * e2[1] - e1[1] * e2[0]}; + const float len = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + if (len < 1e-8f) { + continue; + } + SheetTri t; + for (int k = 0; k < 3; ++k) { + t.c[k] = (pa[k] + pb[k] + pc[k]) / 3.0f; + t.n[k] = n[k] / len; + } + const float e3[3] = {pc[0] - pb[0], pc[1] - pb[1], pc[2] - pb[2]}; + const float l1 = e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]; + const float l2 = e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2]; + const float l3 = e3[0] * e3[0] + e3[1] * e3[1] + e3[2] * e3[2]; + t.edge = std::sqrt(std::max(l1, std::max(l2, l3))); + tris.push_back(t); + } + } + if (tris.size() >= 2) { + std::vector used(tris.size(), 0); + size_t twins = 0; + for (size_t i = 0; i < tris.size(); ++i) { + if (used[i]) { + continue; + } + for (size_t j = i + 1; j < tris.size(); ++j) { + if (used[j]) { + continue; + } + const float dx = tris[i].c[0] - tris[j].c[0]; + const float dy = tris[i].c[1] - tris[j].c[1]; + const float dz = tris[i].c[2] - tris[j].c[2]; + const float d2 = dx * dx + dy * dy + dz * dz; + // Plane separation along i's normal must be small (back-to-back + // sheets); in-plane offset up to the triangle scale (opposite + // diagonal splits put twin centroids half a quad apart). + const float along = std::fabs(dx * tris[i].n[0] + dy * tris[i].n[1] + + dz * tris[i].n[2]); + const float lat_limit = 0.5f * (tris[i].edge + tris[j].edge); + if (along > 0.05f || d2 - along * along > lat_limit * lat_limit) { + continue; + } + const float dot = tris[i].n[0] * tris[j].n[0] + tris[i].n[1] * tris[j].n[1] + + tris[i].n[2] * tris[j].n[2]; + if (dot < -0.9f) { + used[i] = 1; + used[j] = 1; + twins += 2; + break; + } + } + } + // Fully twinned sheets (banners ~100%), OR a meaningful twin patch + // inside a larger single-sided mesh (harbor sailboats: the twinned + // SAIL rides a hull/mast mesh at 4-8% twins; the coincident copies + // sit below far-field depth precision and z-fight into per-frame + // lit/dark shimmer at range). The twins themselves are the evidence + // the mesh was authored for culling-on; coincident opposite-normal + // copies would z-fight on console too, so cull the whole mesh. + // EXCEPT alpha-tested foliage (tree fams 9/10, envsimple.alphatest 7, + // alphatest dynobj, transparent, and unclassified fam 0): a z-fight + // between twinned leaf cards is invisible through the alpha test, so + // twins do NOT prove culling-on there; culling stripped every + // single-sided leaf/branch card seen from its back (the missing- + // foliage regression). Those keep the strict fully-twinned rule. + const bool partial_rule_ok = + !item.transparent && item.dynobj != 2 && item.env_family != 0 && + item.env_family != 7 && item.env_family != 9 && + item.env_family != 10 && item.env_family != 13; + two_sided = twins * 10 >= tris.size() * 6 || + (partial_rule_ok && twins >= 12 && twins * 33 >= tris.size()); + if (two_sided) { + static std::atomic logged{0}; + if (logged.fetch_add(1, std::memory_order_relaxed) < 16) { + REXLOG_INFO("native-scene: two-sided sheet mesh {:08X} ({} indices, {}/{} twins) -> cull-back", + item.mesh, item.ib_count, twins, tris.size()); + } + } + } + } + device->Unmap(ib); + + out.vb = vb; + out.ib = ib; + out.vb_view = {vb, 0, num_verts * 56u, 56u}; + out.ib_view = {ib, 0, item.ib_count * 2u}; + out.two_sided_sheet = two_sided; + return true; +} + +// Decode a guest texture from its 6 fetch-constant words (host-endian), +// the v1-verified path: CPU untile block by block through the 0xA0000000 +// physical mirror, endian swap, and create its SRV view. +// The 3D path reads the words from renderengine::Texture objects; the 2D +// path passes the device fetch-shadow words directly. +// BC1/DXT1 block decode (both color modes) into 16 RGBA8 texels. +void DecodeBc1Block(const uint8_t* b, uint8_t px[16][4]) { + const uint16_t c0 = uint16_t(b[0] | (b[1] << 8)); + const uint16_t c1 = uint16_t(b[2] | (b[3] << 8)); + uint8_t col[4][4]; + // Endpoint expansion by BIT REPLICATION, what GPU hardware does. The + // previous integer-division expansion (v*255/31) reads up to 1/255 dark; + // on the reflective glass that error, folded through the constant detail + // texture, tilted every reflection ~0.8 deg (found empirically via the + // F12 trim sliders: the hand-matched values equaled the + // replication expansion exactly). + const auto expand = [](uint16_t c, uint8_t* o) { + const uint32_t r5 = (c >> 11) & 31, g6 = (c >> 5) & 63, b5 = c & 31; + o[0] = uint8_t((r5 << 3) | (r5 >> 2)); + o[1] = uint8_t((g6 << 2) | (g6 >> 4)); + o[2] = uint8_t((b5 << 3) | (b5 >> 2)); + o[3] = 255; + }; + expand(c0, col[0]); + expand(c1, col[1]); + if (c0 > c1) { + for (int k = 0; k < 3; ++k) { + col[2][k] = uint8_t((2 * col[0][k] + col[1][k]) / 3); + col[3][k] = uint8_t((col[0][k] + 2 * col[1][k]) / 3); + } + col[2][3] = 255; + col[3][3] = 255; + } else { + for (int k = 0; k < 3; ++k) { + col[2][k] = uint8_t((col[0][k] + col[1][k]) / 2); + col[3][k] = 0; + } + col[2][3] = 255; + col[3][3] = 0; + } + uint32_t bits = + uint32_t(b[4]) | (uint32_t(b[5]) << 8) | (uint32_t(b[6]) << 16) | (uint32_t(b[7]) << 24); + for (int i = 0; i < 16; ++i) { + std::memcpy(px[i], col[bits & 3u], 4); + bits >>= 2; + } +} + +// Runtime-composed textures (lightmap atlas pages above all) ship with NO +// mip chain; sampling their razor-contrast mip 0 under minification +// shimmers, grazing-angle banner posters, distant thin geometry, because +// the pixel footprint spans many texels the aniso sampler cannot average at +// mip 0 alone. The game masked this with its 720p softness and the 4-tap +// lightmap filter; at native 4K we need real prefiltering. Small no-mip +// DXT1/8888 textures are decoded to RGBA8 and uploaded with a CPU +// box-filtered mip chain instead. Returns false to fall back to the plain +// single-mip path. +bool UploadGeneratedMips(const NativeGuestOutputRenderContext& context, uint8_t* base, + const rex::graphics::TextureInfo& info, uint32_t fetch_swizzle, + GuestTexture& out) { + const rex::graphics::FormatInfo* format_info = info.format_info(); + const uint32_t bytes_per_block = format_info->bytes_per_block(); + const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); + const uint32_t width = info.width + 1u; + const uint32_t height = info.height + 1u; + const uint32_t block_w = format_info->block_width; + const uint32_t block_h = format_info->block_height; + const bool bc1 = + rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_DXT1; + + // Guest mip 0 copy: same packed-base + tiled macro-row padding rules as + // the plain path. + uint32_t ox = 0, oy = 0; + const uint32_t addr = info.GetMipLocation(0, &ox, &oy, true); + if (addr == 0) { + return false; + } + const uint32_t pitch_blocks = info.extent.block_pitch_h; + const uint32_t min_size = info.memory.base_size; + uint32_t size = min_size; + const uint32_t cols = (width + block_w - 1) / block_w; + const uint32_t rows = (height + block_h - 1) / block_h; + if (info.is_tiled) { + const uint32_t padded_rows = ((rows + oy) + 31u) & ~31u; + size = std::max(size, padded_rows * pitch_blocks * bytes_per_block); + } + static thread_local std::vector gen_scratch; + gen_scratch.resize(size); + if (!GuestTryCopy(gen_scratch.data(), base + (0xA0000000u | addr), size)) { + if (min_size >= size || + !GuestTryCopy(gen_scratch.data(), base + (0xA0000000u | addr), min_size)) { + return false; + } + size = min_size; + } + + // Untile into linear block rows, endian-swap per row. Run-copy untiling: + // same contiguity rule as the plain path (see EnsureGuestTextureFromWords): + // aligned x-runs of clamp(16 >> bpb_log2, 1, 8) blocks are contiguous. + std::vector linear(size_t(cols) * rows * bytes_per_block); + const uint32_t run_blocks = std::clamp(16u >> bytes_per_block_log2, 1u, 8u); + for (uint32_t by = 0; by < rows; ++by) { + uint8_t* out_row = linear.data() + size_t(by) * cols * bytes_per_block; + if (!info.is_tiled) { + const uint32_t row_off = ((by + oy) * pitch_blocks + ox) * bytes_per_block; + const uint32_t row_bytes = cols * bytes_per_block; + if (row_off + row_bytes <= size) { + std::memcpy(out_row, gen_scratch.data() + row_off, row_bytes); + } else { + for (uint32_t bx = 0; bx < cols; ++bx) { + const uint32_t off = row_off + bx * bytes_per_block; + if (off + bytes_per_block > size) { + std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); + continue; + } + std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + off, + bytes_per_block); + } + } + } else { + uint32_t bx = 0; + while (bx < cols) { + const uint32_t x = bx + ox; + const uint32_t run = std::min(cols - bx, run_blocks - (x & (run_blocks - 1))); + const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(x), int32_t(by + oy), pitch_blocks, bytes_per_block_log2)); + const uint32_t bytes = run * bytes_per_block; + if (off + bytes <= size) { + std::memcpy(out_row + size_t(bx) * bytes_per_block, gen_scratch.data() + off, + bytes); + } else { + for (uint32_t i = 0; i < run; ++i) { + const uint32_t boff = off + i * bytes_per_block; + if (boff + bytes_per_block > size) { + std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, bytes_per_block); + } else { + std::memcpy(out_row + size_t(bx + i) * bytes_per_block, + gen_scratch.data() + boff, bytes_per_block); + } + } + } + bx += run; + } + } + SwapGuestEndian(out_row, cols * bytes_per_block, info.endianness); + } + + // Decode to RGBA8 mip 0. + std::vector rgba(size_t(width) * height * 4); + if (bc1) { + for (uint32_t by = 0; by < rows; ++by) { + for (uint32_t bx = 0; bx < cols; ++bx) { + uint8_t px[16][4]; + DecodeBc1Block(linear.data() + (size_t(by) * cols + bx) * bytes_per_block, px); + for (uint32_t t = 0; t < 16; ++t) { + const uint32_t x = bx * 4 + (t & 3u); + const uint32_t y = by * 4 + (t >> 2); + if (x < width && y < height) { + std::memcpy(&rgba[(size_t(y) * width + x) * 4], px[t], 4); + } + } + } + } + } else { // k_8_8_8_8: rows are already RGBA8 after the endian swap + for (uint32_t y = 0; y < height; ++y) { + std::memcpy(&rgba[size_t(y) * width * 4], linear.data() + size_t(y) * cols * 4, + size_t(width) * 4); + } + } + + // Diagnostic dump: what the RUNTIME decoded, for an offline byte diff + // against gsnap_tex_decode of the same fetch words (the reflective_trans + // glass panels' wrong-brightness hunt: their composed lightmap pages take + // exactly this generated-mips path). + if (REXCVAR_GET(skate3_native_render_scene_lm_dump)) { + char path[260]; + std::snprintf(path, sizeof(path), + "native_texture_dumps/gen_%08X_%ux%u_t%u.rgba", + info.memory.base_address, width, height, + info.is_tiled ? 1u : 0u); + if (FILE* f = std::fopen(path, "wb")) { + std::fwrite(rgba.data(), 1, rgba.size(), f); + std::fclose(f); + } + } + + // Box-filtered chain down to 1x1. + std::vector> mips; + mips.emplace_back(std::move(rgba)); + uint32_t mw = width, mh = height; + while (mw > 1 || mh > 1) { + const uint32_t nw = std::max(mw >> 1, 1u); + const uint32_t nh = std::max(mh >> 1, 1u); + const std::vector& srcm = mips.back(); + std::vector dstm(size_t(nw) * nh * 4); + for (uint32_t y = 0; y < nh; ++y) { + const uint32_t y0 = std::min(y * 2, mh - 1); + const uint32_t y1 = std::min(y * 2 + 1, mh - 1); + for (uint32_t x = 0; x < nw; ++x) { + const uint32_t x0 = std::min(x * 2, mw - 1); + const uint32_t x1 = std::min(x * 2 + 1, mw - 1); + for (int k = 0; k < 4; ++k) { + const uint32_t sum = srcm[(size_t(y0) * mw + x0) * 4 + k] + + srcm[(size_t(y0) * mw + x1) * 4 + k] + + srcm[(size_t(y1) * mw + x0) * 4 + k] + + srcm[(size_t(y1) * mw + x1) * 4 + k]; + dstm[(size_t(y) * nw + x) * 4 + k] = uint8_t((sum + 2) / 4); + } + } + } + mips.emplace_back(std::move(dstm)); + mw = nw; + mh = nh; + } + const uint32_t mip_count = uint32_t(mips.size()); + + // Upload plan + resource (RGBA8). + struct Plan { + uint32_t offset, pitch, w, h; + }; + std::vector plans(mip_count); + uint32_t upload_size = 0; + for (uint32_t m = 0; m < mip_count; ++m) { + Plan& p = plans[m]; + p.w = std::max(width >> m, 1u); + p.h = std::max(height >> m, 1u); + p.pitch = (p.w * 4u + (nrhi::kRowPitchAlignment - 1u)) & + ~(nrhi::kRowPitchAlignment - 1u); + p.offset = (upload_size + (kUploadPlacementAlignment - 1u)) & + ~(kUploadPlacementAlignment - 1u); + upload_size = p.offset + p.pitch * p.h; + } + nrhi::Device* device = context.device; + nrhi::TextureDesc desc; + desc.kind = nrhi::TextureKind::k2D; + desc.width = width; + desc.height = height; + desc.mip_levels = mip_count; + desc.format = nrhi::Format::kR8G8B8A8_UNORM; + desc.initial_state = nrhi::ResourceState::kCopyDest; + out.texture = device->CreateTexture(desc); + if (out.texture == nullptr) { + return false; + } + out.upload = CreateUploadBuffer(device, upload_size); + if (!out.upload) { + device->DestroyDeferred(out.texture); + out.texture = nullptr; + return false; + } + uint8_t* mapping = static_cast(device->Map(out.upload)); + for (uint32_t m = 0; m < mip_count; ++m) { + const Plan& p = plans[m]; + for (uint32_t y = 0; y < p.h; ++y) { + std::memcpy(mapping + p.offset + size_t(y) * p.pitch, &mips[m][size_t(y) * p.w * 4], + size_t(p.w) * 4); + } + } + device->Unmap(out.upload); + + if (g_tex_stage_out != nullptr) { + // Decode worker: export the commit recipe; the render thread records + // the copies + barrier and creates the SRV (CommitStagedGuestTexture). + StagedTexCommit& sc = *g_tex_stage_out; + sc.copy_format = nrhi::Format::kR8G8B8A8_UNORM; + sc.srv_format = nrhi::Format::kR8G8B8A8_UNORM; + ComposeSrvSwizzle(fetch_swizzle, xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA, sc.swizzle); + sc.mip_count = std::min(mip_count, 16); + for (uint32_t m = 0; m < sc.mip_count; ++m) { + sc.mips[m] = {plans[m].offset, plans[m].pitch, plans[m].w, plans[m].h}; + } + } else { + for (uint32_t m = 0; m < mip_count; ++m) { + const Plan& p = plans[m]; + context.cmd->CopyBufferToTexture(out.texture, m, 0, out.upload, p.offset, + p.pitch, p.w, p.h, 1); + } + context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + + nrhi::TextureViewDesc srv; + srv.dimension = nrhi::ViewDimension::k2D; + srv.format = nrhi::Format::kR8G8B8A8_UNORM; + ComposeSrvSwizzle(fetch_swizzle, xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA, srv.swizzle); + srv.mip_levels = mip_count; + out.srv_format = srv.format; + out.srv_mips = mip_count; + out.srv = device->CreateTextureView(out.texture, srv); + if (out.srv == nullptr) { + return false; + } + } + out.payload_addr = 0xA0000000u | info.memory.base_address; + out.payload_size = size; + BuildPayloadProbes(info, addr, ox, oy, pitch_blocks, size, out); + out.payload_fp = SampleProbeFingerprint(base, out); + out.near_black = SampleProbeNearBlack(base, out); + out.recheck_frame = 0; + out.valid = g_tex_stage_out == nullptr; // staged: live only after commit + return true; +} + +// Phase attribution for the SLOW-inline-decode log: time inside RHI +// resource creation / the generated-mips path / the guest scratch copies, +// reset at each EnsureGuestTextureFromWords entry (per thread: workers and +// the render thread decode concurrently). +thread_local uint64_t g_tex_dec_create_ns = 0; +thread_local uint64_t g_tex_dec_gen_ns = 0; +thread_local uint64_t g_tex_dec_copy_ns = 0; + +bool EnsureGuestTextureFromWords(const NativeGuestOutputRenderContext& context, + uint8_t* base, const uint32_t words[6], + GuestTexture& out) { + g_tex_dec_create_ns = 0; + g_tex_dec_gen_ns = 0; + g_tex_dec_copy_ns = 0; + std::memcpy(out.fetch_words, words, 6 * sizeof(uint32_t)); + + xenos::xe_gpu_texture_fetch_t fetch = {}; + fetch.dword_0 = words[0]; + fetch.dword_1 = words[1]; + fetch.dword_2 = words[2]; + fetch.dword_3 = words[3]; + fetch.dword_4 = words[4]; + fetch.dword_5 = words[5]; + if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) { + return false; + } + // Pre-validate the raw format BEFORE Prepare: TextureInfo::Prepare calls + // TextureExtent::Calculate, which divides by the format table's + // block_width/block_height/bytes_per_block; a garbage fetch constant + // (freed texture object read mid-teardown during a map change) carries a + // format whose table entry has zeros and crashed a prewarm worker with an + // integer divide-by-zero (dump skate3.exe.pre-icon.24004, second load + // into Daly Estates). + const rex::graphics::FormatInfo* pre_fi = + rex::graphics::FormatInfo::Get(uint32_t(fetch.format)); + if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 || + pre_fi->bytes_per_block() == 0) { + return false; + } + rex::graphics::TextureInfo info; + if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) { + return false; + } + HostTextureFormat host; + if (!GetHostTextureFormat(info.format, host)) { + return false; + } + if (info.dimension != xenos::DataDimension::k2DOrStacked || info.is_stacked || + info.width >= 8192 || info.height >= 8192 || info.memory.base_address == 0 || + info.memory.base_size == 0 || info.memory.base_size > 64u * 1024u * 1024u) { + return false; + } + + const rex::graphics::FormatInfo* format_info = info.format_info(); + const uint32_t bytes_per_block = format_info->bytes_per_block(); + if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) { + return false; + } + const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); + const uint32_t width = info.width + 1u; + const uint32_t height = info.height + 1u; + const uint32_t block_w = format_info->block_width; + const uint32_t block_h = format_info->block_height; + const uint32_t host_width = ((width + block_w - 1) / block_w) * block_w; + const uint32_t host_height = ((height + block_h - 1) / block_h) * block_h; + + // Upload the guest MIP CHAIN, not just mip 0; sampling mip 0 at distance + // is the source of the grass "TV static" and flickering floor/window + // lines. Power-of-two sizes only (everything the game ships) so BC block + // alignment holds on every level. + const bool pow2 = (width & (width - 1)) == 0 && (height & (height - 1)) == 0; + uint32_t mip_count = 1; + if (pow2 && info.memory.mip_address != 0 && + REXCVAR_GET(skate3_native_render_scene_tex_mips)) { + const uint32_t avail = std::min(info.mip_levels(), info.GetMaxMipLevels()); + while (mip_count < avail && (width >> mip_count) >= 4 && (height >> mip_count) >= 4) { + uint32_t ox = 0, oy = 0; + if (info.GetMipLocation(mip_count, &ox, &oy, true) == 0) { + break; + } + ++mip_count; + } + } + // No guest chain at all (runtime-composed lightmap pages): generate one, + // see UploadGeneratedMips. Small DXT1/8888 textures only; falls back to + // the plain single-mip path on any failure. + if (mip_count == 1 && pow2 && REXCVAR_GET(skate3_native_render_scene_tex_mips) && + width >= 8 && height >= 8 && width <= 512 && height <= 512) { + const auto base_fmt = rex::graphics::GetBaseFormat(info.format); + if (base_fmt == xenos::TextureFormat::k_DXT1 || + base_fmt == xenos::TextureFormat::k_8_8_8_8) { + GuestTexture gen = out; // keeps fetch_words already copied + const auto gen_t0 = PerfClock::now(); + const bool gen_ok = UploadGeneratedMips(context, base, info, fetch.swizzle, gen); + g_tex_dec_gen_ns += PerfNsSince(gen_t0); + if (gen_ok) { + out = gen; + return true; + } + } + } + + // Copy the whole guest mip chain out up front with the lock-free guarded + // copy (never VirtualQuery on the render thread: the VAD lock stalls + // behind the guest streaming threads exactly while panning streams + // textures in), truncating the chain at the first unreadable level. + struct MipSrc { + uint32_t addr, scratch_off, size, min_size, pitch_blocks, ox, oy; + }; + MipSrc srcs[16] = {}; + static thread_local std::vector tex_scratch; + uint32_t scratch_total = 0; + for (uint32_t m = 0; m < mip_count; ++m) { + uint32_t ox = 0, oy = 0; + // Mip 0 through GetMipLocation too: textures <= 16 texels on a side + // store their BASE level packed inside a 32x32 tile at a block offset. + // Reading at (0,0) decoded garbage; the 16x16 "default_white" macro + // overlay came out as pink/black blocks and multiplied giant soft black + // blobs over every wall whose material uses it (validated: with the + // packed offset it decodes pure white). Non-packed textures return the + // plain base address with zero offsets. + const uint32_t mip_addr = info.GetMipLocation(m, &ox, &oy, true); + const auto ext = info.GetMipExtent(m, true); + MipSrc& s = srcs[m]; + s.addr = mip_addr; + s.pitch_blocks = m == 0 ? info.extent.block_pitch_h : ext.block_pitch_h; + s.size = m == 0 ? info.memory.base_size : ext.all_blocks() * bytes_per_block; + s.min_size = s.size; + if (info.is_tiled) { + // Tiled addressing swizzles across 32x32-BLOCK macro tiles AND, for + // narrow block formats, interleaves across 64x64/128x128-block + // portions whose byte extent EXCEEDS the linear size; 16bpp reaches + // 0xC00 bytes from a 32x32 tile origin vs the naive 32*32*2 = 0x800. + // The previous padded-macro-ROW estimate (added for the 32x32 DXT5 + // HUD compass icons, whose last block sat at 5952 vs base_size 1024) + // missed that interleave: every 16bpp mip's swizzled offsets for the + // BOTTOM half of its rows landed past the estimate, the range guard + // zeroed them, and every PCU Library banner rendered with its lower + // half black at mip-1 viewing distance (MIP DIAG: guard_zeroed + // exactly total/2 on 64x64 fmt-4 mips, total/1 on packed oy=16 mips). + // Size the copy with the SDK's swizzle-aware upper bound instead; if + // that over-reaches the committed allocation the copy loop below + // falls back to the reported size and marks the decode incomplete. + const uint32_t mw = std::max(width >> m, 1u); + const uint32_t mh = std::max(height >> m, 1u); + const uint32_t right = (mw + block_w - 1) / block_w + ox; + const uint32_t bottom = (mh + block_h - 1) / block_h + oy; + s.size = std::max( + s.size, rex::graphics::texture_util::GetTiledAddressUpperBound2D( + right, bottom, s.pitch_blocks, bytes_per_block_log2)); + } + s.ox = ox; + s.oy = oy; + s.scratch_off = scratch_total; + scratch_total += s.size; + } + const auto copy_t0 = PerfClock::now(); + tex_scratch.resize(scratch_total); + uint32_t mips_copied = 0; + bool copy_truncated = false; + for (uint32_t m = 0; m < mip_count; ++m) { + MipSrc& s = srcs[m]; + if (!GuestTryCopy(tex_scratch.data() + s.scratch_off, + base + (0xA0000000u | s.addr), s.size)) { + if (s.min_size >= s.size || + !GuestTryCopy(tex_scratch.data() + s.scratch_off, + base + (0xA0000000u | s.addr), s.min_size)) { + break; + } + // Tiled fallback: the padded macro rows hold real blocks past + // min_size; every one of them uploads as ZERO below (the PCU + // Library half-black banner mips). The decode is marked incomplete + // so the draw path keeps retrying until the pool commits. + s.size = s.min_size; + copy_truncated = true; + } + ++mips_copied; + } + g_tex_dec_copy_ns += PerfNsSince(copy_t0); + if (mips_copied == 0) { + return false; + } + mip_count = mips_copied; + out.incomplete = copy_truncated; + if (copy_truncated) { + static std::atomic s_trunc_logs{0}; + if (s_trunc_logs.fetch_add(1, std::memory_order_relaxed) < 16) { + REXLOG_INFO( + "native-scene: texture decode INCOMPLETE (tiled mip copy fell back " + "to min_size: zeroed tail blocks) {}x{} mips={} w1={:08X}; will " + "re-decode until complete", + width, height, mip_count, words[1]); + } + } + + // Per-mip upload footprints (D3D12 alignment rules). + struct MipPlan { + uint32_t offset, pitch, cols, rows; + }; + MipPlan plans[16] = {}; + uint32_t upload_size = 0; + for (uint32_t m = 0; m < mip_count; ++m) { + const uint32_t mw = std::max(width >> m, 1u); + const uint32_t mh = std::max(height >> m, 1u); + MipPlan& p = plans[m]; + p.cols = (mw + block_w - 1) / block_w; + p.rows = (mh + block_h - 1) / block_h; + p.pitch = (p.cols * bytes_per_block + (nrhi::kRowPitchAlignment - 1u)) & + ~(nrhi::kRowPitchAlignment - 1u); + p.offset = (upload_size + (kUploadPlacementAlignment - 1u)) & + ~(kUploadPlacementAlignment - 1u); + upload_size = p.offset + p.pitch * p.rows; + } + + nrhi::Device* device = context.device; + nrhi::TextureDesc desc; + desc.kind = nrhi::TextureKind::k2D; + desc.width = host_width; + desc.height = host_height; + desc.mip_levels = mip_count; + desc.format = host.resource_format; + desc.initial_state = nrhi::ResourceState::kCopyDest; + const auto create_t0 = PerfClock::now(); + out.texture = device->CreateTexture(desc); + if (out.texture == nullptr) { + return false; + } + out.upload = CreateUploadBuffer(device, upload_size); + g_tex_dec_create_ns += PerfNsSince(create_t0); + if (!out.upload) { + device->DestroyDeferred(out.texture); + out.texture = nullptr; + return false; + } + uint8_t* mapping = static_cast(device->Map(out.upload)); + const bool swap_rb_565 = + rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_5_6_5; + for (uint32_t m = 0; m < mip_count; ++m) { + const MipPlan& p = plans[m]; + const uint32_t ox = srcs[m].ox; + const uint32_t oy = srcs[m].oy; + const uint32_t src_pitch_blocks = srcs[m].pitch_blocks; + const uint32_t src_size = srcs[m].size; + const uint8_t* guest = tex_scratch.data() + srcs[m].scratch_off; + const uint32_t row_bytes = p.cols * bytes_per_block; + uint32_t guard_zeroed = 0; // blocks zeroed by the range guard (diag) + // Run-copy untiling. The per-BLOCK GetTiledOffset2D loop made a single + // 1280x720 8888 decode cost 3-13 ms (921k address computations + 4-byte + // memcpys); animating fullscreen menu art and FMV planes pay that on + // EVERY content change, which is where the menu frame spikes lived. + // From the tiling formula (util.cpp GetTiledOffset2D): within a row, an + // x-run aligned to run_blocks = clamp(16 >> bpb_log2, 1, 8) maps to + // CONTIGUOUS byte offsets; the micro term stays inside its 16-byte + // nibble group ((y & 0xE) contributes only multiples of 16 at every + // bpb), and runs <= 8 aligned blocks never cross the (x>>3)/(x>>5) mix + // boundaries. Linear mips copy whole rows. + // + // ROWS STAGE THROUGH A CACHED BUFFER, never the upload mapping directly: + // upload heaps are WRITE-COMBINED memory, and SwapGuestEndian/565-swap + // READ the row back; uncached WC reads cost ~33 ns/byte, which was the + // long-unattributed 35-135 ms decode class (512x512 = 1 MB = 34 ms, + // 1024x1024+chain = 135 ms; linear fit across a slow-decode log). + // The mapping gets exactly one streaming memcpy per row. + const uint32_t run_blocks = + std::clamp(16u >> bytes_per_block_log2, 1u, 8u); + static thread_local std::vector row_buf; + row_buf.resize(row_bytes); + for (uint32_t by = 0; by < p.rows; ++by) { + uint8_t* out_row = row_buf.data(); + if (!info.is_tiled) { + const uint32_t row_off = + ((by + oy) * src_pitch_blocks + ox) * bytes_per_block; + if (row_off + row_bytes <= src_size) { + std::memcpy(out_row, guest + row_off, row_bytes); + } else { + for (uint32_t bx = 0; bx < p.cols; ++bx) { + const uint32_t off = row_off + bx * bytes_per_block; + if (off + bytes_per_block > src_size) { + std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); + ++guard_zeroed; + continue; + } + std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, + bytes_per_block); + } + } + } else { + uint32_t bx = 0; + while (bx < p.cols) { + const uint32_t x = bx + ox; + const uint32_t run = + std::min(p.cols - bx, run_blocks - (x & (run_blocks - 1))); + const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(x), int32_t(by + oy), src_pitch_blocks, bytes_per_block_log2)); + const uint32_t bytes = run * bytes_per_block; + if (off + bytes <= src_size) { + std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, bytes); + } else { + // Padded macro rows past the copied size: per-block guard-zero + // (offsets within the run are contiguous, same proof as above). + for (uint32_t i = 0; i < run; ++i) { + const uint32_t boff = off + i * bytes_per_block; + if (boff + bytes_per_block > src_size) { + std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, + bytes_per_block); + ++guard_zeroed; + } else { + std::memcpy(out_row + size_t(bx + i) * bytes_per_block, guest + boff, + bytes_per_block); + } + } + } + bx += run; + } + } + SwapGuestEndian(out_row, row_bytes, info.endianness); + if (swap_rb_565) { + for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) { + uint16_t value; + std::memcpy(&value, out_row + i, sizeof(value)); + value = uint16_t((value & 0x07E0u) | ((value >> 11) & 0x1Fu) | + ((value & 0x1Fu) << 11)); + std::memcpy(out_row + i, &value, sizeof(value)); + } + } + std::memcpy(mapping + p.offset + size_t(by) * p.pitch, out_row, row_bytes); + } + // Half-black-mip diagnostic (PCU Library banners): discriminate "the + // guest pool genuinely holds zeros for this mip" from "our addressing + // zeroed/misread it". Samples 32 uploaded blocks spread over the mip; + // guard_zeroed separates range-guard zeroing from zero CONTENT. + if (m > 0) { + uint32_t zero_samples = 0; + const uint32_t total_blocks = p.rows * p.cols; + for (uint32_t s = 0; s < 32; ++s) { + const uint32_t bi = uint32_t(uint64_t(total_blocks - 1) * s / 31u); + const uint32_t by = bi / p.cols; + const uint32_t bx = bi % p.cols; + uint64_t q = 0; + std::memcpy(&q, mapping + p.offset + size_t(by) * p.pitch + + size_t(bx) * bytes_per_block, + std::min(8, bytes_per_block)); + zero_samples += q == 0 ? 1 : 0; + } + if (total_blocks >= 32 && (guard_zeroed * 4 >= total_blocks || + zero_samples >= 12)) { + static std::atomic s_mip_diag{0}; + if (s_mip_diag.fetch_add(1, std::memory_order_relaxed) < 24) { + REXLOG_INFO( + "native-scene: MIP DIAG {}x{} mip {}/{} zero_samples={}/32 " + "guard_zeroed={}/{} ox={} oy={} pitch_b={} size={} min={} " + "tiled={} fmt={} w0={:08X} w1={:08X} w2={:08X} mip_addr={:08X}", + width, height, m, mip_count, zero_samples, guard_zeroed, + total_blocks, ox, oy, src_pitch_blocks, src_size, + srcs[m].min_size, info.is_tiled ? 1 : 0, uint32_t(info.format), + words[0], words[1], words[2], srcs[m].addr); + } + } + } + } + // Diagnostic dump (skate3_native_render_scene_lm_dump): no-chain textures + // through the PLAIN path (the >512px composed lightmap pages the + // generated-mips gate excludes): mip 0 as linear block rows, raw guest + // block format post-endian-swap, for offline decode + byte-diff against + // gsnap_tex_decode of the same fetch words. + if (mip_count == 1 && REXCVAR_GET(skate3_native_render_scene_lm_dump)) { + char path[260]; + std::snprintf(path, sizeof(path), + "native_texture_dumps/plain_%08X_%ux%u_f%u_t%u.blk", + info.memory.base_address, width, height, + uint32_t(info.format), info.is_tiled ? 1u : 0u); + if (FILE* f = std::fopen(path, "wb")) { + const MipPlan& p0 = plans[0]; + for (uint32_t by = 0; by < p0.rows; ++by) { + std::fwrite(mapping + p0.offset + size_t(by) * p0.pitch, 1, + size_t(p0.cols) * bytes_per_block, f); + } + std::fclose(f); + } + } + device->Unmap(out.upload); + + if (g_tex_stage_out != nullptr) { + // Decode worker: export the commit recipe; the render thread records + // the copies + barrier and creates the SRV (CommitStagedGuestTexture). + StagedTexCommit& sc = *g_tex_stage_out; + sc.copy_format = host.resource_format; + sc.srv_format = host.srv_format; + ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, sc.swizzle); + sc.mip_count = std::min(mip_count, 16); + for (uint32_t m = 0; m < sc.mip_count; ++m) { + sc.mips[m] = {plans[m].offset, plans[m].pitch, std::max(host_width >> m, 1u), + std::max(host_height >> m, 1u)}; + } + } else { + // Record the upload copies into the deferred command list. + for (uint32_t m = 0; m < mip_count; ++m) { + const MipPlan& p = plans[m]; + context.cmd->CopyBufferToTexture(out.texture, m, 0, out.upload, p.offset, + p.pitch, std::max(host_width >> m, 1u), + std::max(host_height >> m, 1u), 1); + } + context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + + // SRV view with the composed swizzle. + nrhi::TextureViewDesc srv; + srv.dimension = nrhi::ViewDimension::k2D; + srv.format = host.srv_format; + ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, srv.swizzle); + srv.mip_levels = mip_count; + out.srv_format = srv.format; + out.srv_mips = mip_count; + out.srv = device->CreateTextureView(out.texture, srv); + if (out.srv == nullptr) { + return false; + } + } + // Payload sample for content revalidation (see GuestTexture). + out.payload_addr = 0xA0000000u | info.memory.base_address; + out.payload_size = srcs[0].size; + BuildPayloadProbes(info, srcs[0].addr, srcs[0].ox, srcs[0].oy, + srcs[0].pitch_blocks, srcs[0].size, out); + out.payload_fp = SampleProbeFingerprint(base, out); + out.near_black = SampleProbeNearBlack(base, out); + out.recheck_frame = 0; + out.valid = g_tex_stage_out == nullptr; // staged: live only after commit + return true; +} + +// (The object-keyed EnsureGuestTexture wrapper is gone: everything decodes +// from an explicit stable words snapshot, ReadStableTexWords + +// EnsureGuestTextureFromWords, and lands in the words-keyed store.) + +// In-place re-decode for HOT single-mip 2D content (video planes, animating +// fullscreen menu/loading art): identical fetch words = identical layout, so +// the committed texture, upload footprint and SRV slot are all reusable; +// the retire+recreate path paid two GPU resource creations per +// content change (~90/s during FMV playback, plus every animation step of +// fullscreen frontend art). Non-pow2 single-mip entries only: pow2 entries +// may own a real or generated mip chain (the full path handles those). +// The TEAR-GUARD also lives here: the CPU writer (VP6 video decode, APT +// re-raster) can be mid-write when the liveness probe fires; decoding a +// half-written fullscreen payload shows a frame of mixed old/new content: +// a subtle whole-screen brightness flicker on fades and video. The guest +// copy retries until the probe fingerprint reads stable across it. +bool UpdateGuestTexture2DInPlace(const NativeGuestOutputRenderContext& context, + uint8_t* base, GuestTexture& t) { + if (!t.valid || t.texture == nullptr || t.srv_mips != 1 || t.incomplete) { + return false; + } + xenos::xe_gpu_texture_fetch_t fetch = {}; + fetch.dword_0 = t.fetch_words[0]; + fetch.dword_1 = t.fetch_words[1]; + fetch.dword_2 = t.fetch_words[2]; + fetch.dword_3 = t.fetch_words[3]; + fetch.dword_4 = t.fetch_words[4]; + fetch.dword_5 = t.fetch_words[5]; + if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) { + return false; + } + const rex::graphics::FormatInfo* pre_fi = + rex::graphics::FormatInfo::Get(uint32_t(fetch.format)); + if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 || + pre_fi->bytes_per_block() == 0) { + return false; + } + rex::graphics::TextureInfo info; + if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) { + return false; + } + HostTextureFormat host; + if (!GetHostTextureFormat(info.format, host)) { + return false; + } + if (info.dimension != xenos::DataDimension::k2DOrStacked || info.is_stacked || + info.width >= 8192 || info.height >= 8192 || info.memory.base_address == 0 || + info.memory.base_size == 0 || info.memory.base_size > 64u * 1024u * 1024u) { + return false; + } + const rex::graphics::FormatInfo* format_info = info.format_info(); + const uint32_t bytes_per_block = format_info->bytes_per_block(); + if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) { + return false; + } + const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); + const uint32_t width = info.width + 1u; + const uint32_t height = info.height + 1u; + if ((width & (width - 1)) == 0 && (height & (height - 1)) == 0) { + return false; // pow2: may own a mip chain, full path + } + if (host.srv_format != t.srv_format) { + return false; // the existing SRV recipe must stay exact + } + const uint32_t block_w = format_info->block_width; + const uint32_t block_h = format_info->block_height; + + // Mip-0 source extent, same rules as the full decode. + uint32_t ox = 0, oy = 0; + const uint32_t addr = info.GetMipLocation(0, &ox, &oy, true); + if (addr == 0) { + return false; + } + const uint32_t pitch_blocks = info.extent.block_pitch_h; + const uint32_t cols = (width + block_w - 1) / block_w; + const uint32_t rows = (height + block_h - 1) / block_h; + const uint32_t min_size = info.memory.base_size; + uint32_t size = min_size; + if (info.is_tiled) { + size = std::max(size, rex::graphics::texture_util::GetTiledAddressUpperBound2D( + cols + ox, rows + oy, pitch_blocks, bytes_per_block_log2)); + } + static thread_local std::vector inplace_scratch; + inplace_scratch.resize(size); + + // Tear-guarded guest copy: retry while the writer races us. + uint64_t fp = 0; + for (int attempt = 0; attempt < 3; ++attempt) { + const uint64_t fp_before = SampleProbeFingerprint(base, t); + if (!GuestTryCopy(inplace_scratch.data(), base + (0xA0000000u | addr), size)) { + if (min_size >= size || + !GuestTryCopy(inplace_scratch.data(), base + (0xA0000000u | addr), min_size)) { + return false; // payload unreadable: let the full path sort it out + } + size = min_size; + } + fp = SampleProbeFingerprint(base, t); + if (fp != 0 && fp == fp_before) { + break; + } + } + + // Upload footprint (single mip at offset 0). + const uint32_t pitch = + (cols * bytes_per_block + (nrhi::kRowPitchAlignment - 1u)) & + ~(nrhi::kRowPitchAlignment - 1u); + const uint32_t upload_size = pitch * rows; + nrhi::Device* device = context.device; + nrhi::Buffer*& up = t.upload_flip ? t.upload_b : t.upload; + if (up != nullptr && up->size() < upload_size) { + // Never expected (same words = same footprint): recreate defensively. + device->DestroyDeferred(up); + up = nullptr; + } + if (up == nullptr) { + up = CreateUploadBuffer(device, upload_size); + if (up == nullptr) { + return false; + } + } + uint8_t* mapping = static_cast(device->Map(up)); + if (mapping == nullptr) { + return false; + } + const bool swap_rb_565 = + rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_5_6_5; + const uint8_t* guest = inplace_scratch.data(); + const uint32_t row_bytes = cols * bytes_per_block; + const uint32_t run_blocks = std::clamp(16u >> bytes_per_block_log2, 1u, 8u); + // Rows stage through a CACHED buffer; the endian/565 swaps read the row + // back, and reading the write-combined upload mapping costs ~33 ns/byte + // (the 35-135 ms decode class; see the plain path). + static thread_local std::vector row_buf; + row_buf.resize(row_bytes); + for (uint32_t by = 0; by < rows; ++by) { + uint8_t* out_row = row_buf.data(); + if (!info.is_tiled) { + const uint32_t row_off = ((by + oy) * pitch_blocks + ox) * bytes_per_block; + if (row_off + row_bytes <= size) { + std::memcpy(out_row, guest + row_off, row_bytes); + } else { + for (uint32_t bx = 0; bx < cols; ++bx) { + const uint32_t off = row_off + bx * bytes_per_block; + if (off + bytes_per_block > size) { + std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); + } else { + std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, + bytes_per_block); + } + } + } + } else { + uint32_t bx = 0; + while (bx < cols) { + const uint32_t x = bx + ox; + const uint32_t run = std::min(cols - bx, run_blocks - (x & (run_blocks - 1))); + const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(x), int32_t(by + oy), pitch_blocks, bytes_per_block_log2)); + const uint32_t bytes = run * bytes_per_block; + if (off + bytes <= size) { + std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + off, bytes); + } else { + for (uint32_t i = 0; i < run; ++i) { + const uint32_t boff = off + i * bytes_per_block; + if (boff + bytes_per_block > size) { + std::memset(out_row + size_t(bx + i) * bytes_per_block, 0, bytes_per_block); + } else { + std::memcpy(out_row + size_t(bx + i) * bytes_per_block, guest + boff, + bytes_per_block); + } + } + } + bx += run; + } + } + SwapGuestEndian(out_row, row_bytes, info.endianness); + if (swap_rb_565) { + for (uint32_t i = 0; i + 2 <= row_bytes; i += 2) { + uint16_t value; + std::memcpy(&value, out_row + i, sizeof(value)); + value = uint16_t((value & 0x07E0u) | ((value >> 11) & 0x1Fu) | + ((value & 0x1Fu) << 11)); + std::memcpy(out_row + i, &value, sizeof(value)); + } + } + std::memcpy(mapping + size_t(by) * pitch, out_row, row_bytes); + } + device->Unmap(up); + + // Barriers must land in the deferred list BEFORE the copy: flush both + // sides explicitly (earlier draws this frame sample the OLD content, + // later ones the new: same semantics as a retire+recreate mid-frame). + context.cmd->Barrier(t.texture, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kCopyDest); + context.cmd->FlushBarriers(); + context.cmd->CopyBufferToTexture(t.texture, 0, 0, up, 0, pitch, cols * block_w, + rows * block_h, 1); + context.cmd->Barrier(t.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + context.cmd->FlushBarriers(); + + t.upload_flip = !t.upload_flip; + t.payload_fp = fp; + t.near_black = SampleProbeNearBlack(base, t); + t.recheck_frame = 0; + return true; +} + +// Environment CUBE map for the water / reflective-glass reflection term +// (t6). Six faces untiled independently per level (Xenos cubes are 2D-tiled +// per face slice), WITH the guest mip chain; gradient-derived LOD on the +// normal-mapped reflection vector is what blurs baked cube detail +// (streetlight heads) into the soft reflections of the real console output. +bool EnsureGuestCubeTexture(const NativeGuestOutputRenderContext& context, uint8_t* base, + uint32_t tex_ptr, GuestTexture& out) { + uint32_t words[6] = {}; + { + uint32_t raw[6]; + if (!GuestTryCopy(raw, base + tex_ptr + 7 * 4, sizeof(raw))) { + return false; + } + for (uint32_t i = 0; i < 6; ++i) { + words[i] = SwapU32(raw[i]); + } + } + std::memcpy(out.fetch_words, words, sizeof(words)); + xenos::xe_gpu_texture_fetch_t fetch = {}; + fetch.dword_0 = words[0]; + fetch.dword_1 = words[1]; + fetch.dword_2 = words[2]; + fetch.dword_3 = words[3]; + fetch.dword_4 = words[4]; + fetch.dword_5 = words[5]; + if (fetch.type != xenos::FetchConstantType::kTexture || fetch.base_address == 0) { + return false; + } + // Same divide-by-zero pre-guard as EnsureGuestTextureFromWords (garbage + // format -> zero block dims inside Prepare's extent math). + const rex::graphics::FormatInfo* pre_fi = + rex::graphics::FormatInfo::Get(uint32_t(fetch.format)); + if (pre_fi == nullptr || pre_fi->block_width == 0 || pre_fi->block_height == 0 || + pre_fi->bytes_per_block() == 0) { + return false; + } + rex::graphics::TextureInfo info; + if (!rex::graphics::TextureInfo::Prepare(fetch, &info)) { + return false; + } + if (info.dimension != xenos::DataDimension::kCube || info.memory.base_address == 0 || + info.width >= 1024 || info.height >= 1024) { + return false; + } + HostTextureFormat host; + if (!GetHostTextureFormat(info.format, host)) { + return false; + } + const rex::graphics::FormatInfo* format_info = info.format_info(); + const uint32_t bytes_per_block = format_info->bytes_per_block(); + if (bytes_per_block == 0 || (bytes_per_block & (bytes_per_block - 1)) != 0) { + return false; + } + const uint32_t bytes_per_block_log2 = uint32_t(std::countr_zero(bytes_per_block)); + const uint32_t width = info.width + 1u; + const uint32_t height = info.height + 1u; + const uint32_t block_w = format_info->block_width; + const uint32_t block_h = format_info->block_height; + const uint32_t host_width = ((width + block_w - 1) / block_w) * block_w; + const uint32_t host_height = ((height + block_h - 1) / block_h) * block_h; + // A FULL mip chain is load-bearing for the reflective families. The real + // baseenvironmentreflective PS perturbs its reflection vector with a + // per-pixel normal map, so adjacent pixels reflect degrees apart; the + // hardware cube fetch runs at a VERY deep gradient-derived LOD and the + // whole reflection resolves to a near-face-average wash (the emulated + // frame's uniform blue glass; offline: a CUBE_LOD=8 box-downsample probe + // reproduces the emulated facade's uniformity where mip 0 shows a sharp + // baked tree/streetlight blob). A truncated chain + // clamps the LOD shallow and leaves that blob visible. DXT1 cubes (every + // env cube observed) decode mip 0 to RGBA8 and generate the complete + // chain down to 1x1 below; other formats fall back to copying the guest + // chain (down to 32px: smaller levels live packed inside a shared 32x32 + // tile, see GetPackedTileOffset). + const bool rgba_chain = + rex::graphics::GetBaseFormat(info.format) == xenos::TextureFormat::k_DXT1 && + width >= 8 && (width & (width - 1)) == 0 && width == height; + uint32_t mip_levels = 1; + if (!rgba_chain && info.memory.mip_address != 0 && (width & (width - 1)) == 0 && + (height & (height - 1)) == 0) { + const uint32_t avail = std::min(info.mip_levels(), info.GetMaxMipLevels()); + while (mip_levels < avail && (width >> mip_levels) >= 32 && + (height >> mip_levels) >= 32) { + uint32_t ox = 0, oy = 0; + if (info.GetMipLocation(mip_levels, &ox, &oy, true) == 0 || ox != 0 || oy != 0) { + break; + } + ++mip_levels; + } + } + // Per-level guest layout: each level stores the six face slices + // consecutively (extent depth = 6; GetMipLocation walks whole levels). + struct CubeLevel { + uint32_t addr, pitch_blocks, slice_bytes, cols, rows, scratch_off; + uint32_t up_pitch, up_face_bytes, w, h; + }; + CubeLevel lv[6] = {}; + uint32_t scratch_total = 0; + for (uint32_t m = 0; m < mip_levels; ++m) { + CubeLevel& L = lv[m]; + const auto ext = m == 0 ? info.extent : info.GetMipExtent(m, true); + uint32_t ox = 0, oy = 0; + L.addr = m == 0 ? info.memory.base_address : info.GetMipLocation(m, &ox, &oy, true); + L.pitch_blocks = ext.block_pitch_h; + L.slice_bytes = ext.block_pitch_h * ext.block_pitch_v * bytes_per_block; + const uint32_t mw = std::max(width >> m, 1u); + const uint32_t mh = std::max(height >> m, 1u); + L.cols = (mw + block_w - 1) / block_w; + L.rows = (mh + block_h - 1) / block_h; + L.w = L.cols * block_w; + L.h = L.rows * block_h; + L.up_pitch = (L.cols * bytes_per_block + (nrhi::kRowPitchAlignment - 1u)) & + ~(nrhi::kRowPitchAlignment - 1u); + L.up_face_bytes = + (L.up_pitch * L.rows + (kUploadPlacementAlignment - 1u)) & + ~(kUploadPlacementAlignment - 1u); + L.scratch_off = scratch_total; + scratch_total += L.slice_bytes * 6; + } + if (scratch_total == 0 || scratch_total > 24u * 1024u * 1024u) { + return false; + } + static thread_local std::vector cube_scratch; + cube_scratch.resize(scratch_total); + for (uint32_t m = 0; m < mip_levels; ++m) { + if (!GuestTryCopy(cube_scratch.data() + lv[m].scratch_off, + base + (0xA0000000u | lv[m].addr), lv[m].slice_bytes * 6)) { + if (m == 0) { + return false; + } + mip_levels = m; // truncate the chain at the first unreadable level + break; + } + } + + if (rgba_chain) { + // Decode DXT1 mip 0 -> RGBA8 per face, box-filter the full chain to + // 1x1, upload as an RGBA cube. CPU cost is one-time per cube (runs on + // the decode workers). + nrhi::Device* device = context.device; + const uint32_t levels = 1u + uint32_t(std::countr_zero(width)); + struct Level { + uint32_t w, pitch, face_bytes, upload_off; // upload_off within a face + }; + Level lvs[16] = {}; + uint32_t face_upload = 0; + for (uint32_t m = 0; m < levels; ++m) { + Level& L = lvs[m]; + L.w = std::max(width >> m, 1u); + L.pitch = (L.w * 4u + (nrhi::kRowPitchAlignment - 1u)) & + ~(nrhi::kRowPitchAlignment - 1u); + L.face_bytes = (L.pitch * L.w + (kUploadPlacementAlignment - 1u)) & + ~(kUploadPlacementAlignment - 1u); + L.upload_off = face_upload; + face_upload += L.face_bytes; + } + nrhi::TextureDesc desc; + desc.kind = nrhi::TextureKind::kCube; + desc.width = width; + desc.height = height; + desc.mip_levels = levels; + desc.format = nrhi::Format::kR8G8B8A8_UNORM; + desc.initial_state = nrhi::ResourceState::kCopyDest; + out.texture = device->CreateTexture(desc); + if (out.texture == nullptr) { + return false; + } + out.upload = CreateUploadBuffer(device, face_upload * 6); + if (!out.upload) { + device->DestroyDeferred(out.texture); + out.texture = nullptr; + return false; + } + uint8_t* mapping = static_cast(device->Map(out.upload)); + static thread_local std::vector rgba; // level 0 of one face + static thread_local std::vector down; // downsample scratch + static thread_local std::vector bc_row; // one untiled block row + const CubeLevel& L0 = lv[0]; + bc_row.resize(size_t(L0.cols) * bytes_per_block); + for (uint32_t face = 0; face < 6; ++face) { + // Per-face: the downsample loop below SWAPS rgba/down, so their sizes + // end the chain tiny; the next face's full-size decode writes must + // not index a shrunken buffer. + rgba.resize(size_t(width) * height * 4); + down.resize(size_t(width / 2) * (height / 2) * 4); + const uint8_t* guest = + cube_scratch.data() + L0.scratch_off + size_t(face) * L0.slice_bytes; + for (uint32_t by = 0; by < L0.rows; ++by) { + for (uint32_t bx = 0; bx < L0.cols; ++bx) { + uint32_t source_offset; + if (info.is_tiled) { + source_offset = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(bx), int32_t(by), L0.pitch_blocks, bytes_per_block_log2)); + } else { + source_offset = (by * L0.pitch_blocks + bx) * bytes_per_block; + } + if (source_offset + bytes_per_block > L0.slice_bytes) { + std::memset(&bc_row[size_t(bx) * bytes_per_block], 0, bytes_per_block); + continue; + } + std::memcpy(&bc_row[size_t(bx) * bytes_per_block], guest + source_offset, + bytes_per_block); + } + SwapGuestEndian(bc_row.data(), uint32_t(bc_row.size()), info.endianness); + for (uint32_t bx = 0; bx < L0.cols; ++bx) { + uint8_t px[16][4]; + DecodeBc1Block(&bc_row[size_t(bx) * bytes_per_block], px); + for (uint32_t r = 0; r < 4; ++r) { + std::memcpy(&rgba[(size_t(by * 4 + r) * width + bx * 4) * 4], px[r * 4], + 16); + } + } + } + // Upload level 0, then box-filter down the chain in place. + const uint8_t* src = rgba.data(); + uint32_t w = width; + for (uint32_t m = 0; m < levels; ++m) { + uint8_t* up = mapping + size_t(face) * face_upload + lvs[m].upload_off; + for (uint32_t y = 0; y < w; ++y) { + std::memcpy(up + size_t(y) * lvs[m].pitch, src + size_t(y) * w * 4, + size_t(w) * 4); + } + if (m + 1 >= levels) { + break; + } + const uint32_t hw = w / 2; + for (uint32_t y = 0; y < hw; ++y) { + for (uint32_t x = 0; x < hw; ++x) { + for (uint32_t c = 0; c < 4; ++c) { + const uint32_t s = + uint32_t(src[((y * 2) * w + x * 2) * 4 + c]) + + uint32_t(src[((y * 2) * w + x * 2 + 1) * 4 + c]) + + uint32_t(src[((y * 2 + 1) * w + x * 2) * 4 + c]) + + uint32_t(src[((y * 2 + 1) * w + x * 2 + 1) * 4 + c]); + down[(size_t(y) * hw + x) * 4 + c] = uint8_t((s + 2) / 4); + } + } + } + rgba.swap(down); + src = rgba.data(); + w = hw; + } + } + device->Unmap(out.upload); + REXLOG_INFO("native-scene: cube {:08X} {}x{} DXT1 -> RGBA full chain ({} levels)", + tex_ptr, width, height, levels); + nrhi::Swizzle swizzle_mapping[4]; + ComposeSrvSwizzle(fetch.swizzle, xenos::XE_GPU_TEXTURE_SWIZZLE_RGBA, + swizzle_mapping); + if (g_tex_stage_out != nullptr) { + StagedTexCommit& sc = *g_tex_stage_out; + sc.copy_format = nrhi::Format::kR8G8B8A8_UNORM; + sc.srv_format = nrhi::Format::kR8G8B8A8_UNORM; + for (uint32_t c = 0; c < 4; ++c) { + sc.swizzle[c] = swizzle_mapping[c]; + } + sc.cube = true; + sc.cube_mip_levels = levels; + sc.mip_count = 6 * levels; + for (uint32_t face = 0; face < 6; ++face) { + for (uint32_t m = 0; m < levels; ++m) { + sc.mips[face * levels + m] = {face * face_upload + lvs[m].upload_off, + lvs[m].pitch, lvs[m].w, lvs[m].w}; + } + } + out.payload_addr = 0xA0000000u | info.memory.base_address; + out.payload_size = lv[0].slice_bytes * 6; + out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); + out.recheck_frame = 0; + out.valid = false; // live only after commit + return true; + } + for (uint32_t face = 0; face < 6; ++face) { + for (uint32_t m = 0; m < levels; ++m) { + context.cmd->CopyBufferToTexture( + out.texture, m, face, out.upload, + size_t(face) * face_upload + lvs[m].upload_off, lvs[m].pitch, + lvs[m].w, lvs[m].w, 1); + } + } + context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + nrhi::TextureViewDesc srv; + srv.dimension = nrhi::ViewDimension::kCube; + srv.format = nrhi::Format::kR8G8B8A8_UNORM; + for (uint32_t c = 0; c < 4; ++c) { + srv.swizzle[c] = swizzle_mapping[c]; + } + srv.mip_levels = levels; + out.srv = device->CreateTextureView(out.texture, srv); + if (out.srv == nullptr) { + return false; + } + out.payload_addr = 0xA0000000u | info.memory.base_address; + out.payload_size = lv[0].slice_bytes * 6; + out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); + out.recheck_frame = 0; + out.valid = true; + return true; + } + + uint32_t face_upload = 0; // one face's full mip chain in the upload buffer + for (uint32_t m = 0; m < mip_levels; ++m) { + face_upload += lv[m].up_face_bytes; + } + nrhi::Device* device = context.device; + nrhi::TextureDesc desc; + desc.kind = nrhi::TextureKind::kCube; + desc.width = host_width; + desc.height = host_height; + desc.mip_levels = mip_levels; + desc.format = host.resource_format; + desc.initial_state = nrhi::ResourceState::kCopyDest; + out.texture = device->CreateTexture(desc); + if (out.texture == nullptr) { + return false; + } + out.upload = CreateUploadBuffer(device, face_upload * 6); + if (!out.upload) { + device->DestroyDeferred(out.texture); + out.texture = nullptr; + return false; + } + uint8_t* mapping = static_cast(device->Map(out.upload)); + const auto upload_offset = [&](uint32_t face, uint32_t m) { + uint32_t off = face * face_upload; + for (uint32_t k = 0; k < m; ++k) { + off += lv[k].up_face_bytes; + } + return off; + }; + // (The "Xenos cube T runs bottom-up" flip that briefly lived here was + // WRONG; it matched two probe pixels by coincidence and turned every + // facade pavement-white in game. The stored face orientation is correct + // as-is; the emulated look comes from LOD depth, not orientation.) + for (uint32_t face = 0; face < 6; ++face) { + for (uint32_t m = 0; m < mip_levels; ++m) { + const CubeLevel& L = lv[m]; + const uint8_t* guest = + cube_scratch.data() + L.scratch_off + size_t(face) * L.slice_bytes; + uint8_t* up = mapping + upload_offset(face, m); + const uint32_t row_bytes = L.cols * bytes_per_block; + for (uint32_t by = 0; by < L.rows; ++by) { + uint8_t* out_row = up + size_t(by) * L.up_pitch; + for (uint32_t bx = 0; bx < L.cols; ++bx) { + uint32_t source_offset; + if (info.is_tiled) { + source_offset = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(bx), int32_t(by), L.pitch_blocks, bytes_per_block_log2)); + } else { + source_offset = (by * L.pitch_blocks + bx) * bytes_per_block; + } + if (source_offset + bytes_per_block > L.slice_bytes) { + std::memset(out_row + size_t(bx) * bytes_per_block, 0, bytes_per_block); + continue; + } + std::memcpy(out_row + size_t(bx) * bytes_per_block, guest + source_offset, + bytes_per_block); + } + SwapGuestEndian(out_row, row_bytes, info.endianness); + } + } + } + device->Unmap(out.upload); + + REXLOG_INFO("native-scene: cube {:08X} {}x{} fmt={} mips={} (of {} avail)", tex_ptr, + width, height, uint32_t(info.format), mip_levels, info.mip_levels()); + if (g_tex_stage_out != nullptr) { + // Decode worker: export the commit recipe, face-major (face * levels + + // mip) entries matching the old D3D12 subresource numbering. + StagedTexCommit& sc = *g_tex_stage_out; + sc.copy_format = host.resource_format; + sc.srv_format = host.srv_format; + ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, sc.swizzle); + sc.cube = true; + sc.cube_mip_levels = mip_levels; + sc.mip_count = 6 * mip_levels; + for (uint32_t face = 0; face < 6; ++face) { + for (uint32_t m = 0; m < mip_levels; ++m) { + sc.mips[face * mip_levels + m] = {upload_offset(face, m), lv[m].up_pitch, + lv[m].w, lv[m].h}; + } + } + out.payload_addr = 0xA0000000u | info.memory.base_address; + out.payload_size = lv[0].slice_bytes * 6; + out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); + out.recheck_frame = 0; + out.valid = false; // live only after commit + return true; + } + + for (uint32_t face = 0; face < 6; ++face) { + for (uint32_t m = 0; m < mip_levels; ++m) { + context.cmd->CopyBufferToTexture(out.texture, m, face, out.upload, + upload_offset(face, m), lv[m].up_pitch, + lv[m].w, lv[m].h, 1); + } + } + context.cmd->Barrier(out.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + nrhi::TextureViewDesc srv; + srv.dimension = nrhi::ViewDimension::kCube; + srv.format = host.srv_format; + ComposeSrvSwizzle(fetch.swizzle, host.host_swizzle, srv.swizzle); + srv.mip_levels = mip_levels; + out.srv = device->CreateTextureView(out.texture, srv); + if (out.srv == nullptr) { + return false; + } + out.payload_addr = 0xA0000000u | info.memory.base_address; + out.payload_size = lv[0].slice_bytes * 6; + out.payload_fp = SamplePayloadFingerprint(base, out.payload_addr, out.payload_size); + out.recheck_frame = 0; + out.valid = true; + return true; +} + +// Scene vertex layout (DecodeMesh's 56-byte output): shared by the scene +// PSO family and the shadow-caster PSO. `location` = the element's +// declaration order in the scene VS input struct (the Vulkan input +// location; see nrhi::InputElementDesc). +constexpr nrhi::InputElementDesc kSceneInputLayout[7] = { + {"POSITION", 0, 0, nrhi::Format::kR32G32B32_FLOAT, 0}, + {"TEXCOORD", 0, 1, nrhi::Format::kR32G32_FLOAT, 12}, + {"TEXCOORD", 1, 2, nrhi::Format::kR32G32_FLOAT, 20}, + {"BLENDWEIGHT", 0, 3, nrhi::Format::kR8G8B8A8_UNORM, 28}, + {"BLENDINDICES", 0, 4, nrhi::Format::kR8G8B8A8_UINT, 32}, + {"NORMAL", 0, 5, nrhi::Format::kR32G32B32_FLOAT, 36}, + {"TEXCOORD", 2, 6, nrhi::Format::kR32G32_FLOAT, 48}}; + +// ---- EnsurePipeline helpers ------------------------------------------------ +// Split from the former ~1,000-line EnsurePipeline: one resource +// group per function, bodies unchanged. Every helper is idempotent (guarded +// by its own g_r state) and returns false only on a failure that must abort +// the native path (g_r.failed set where the original did). + +// Looks up the offline-compiled SPIR-V for (file, entry, variant); variant is +// the canonical macro signature ("", "SAMPLES=4", "PFX_MSAA=1"). Returns a +// fully-populated ShaderDesc; SPIR-V absent only if the table and the compile +// matrix ever drift (Vulkan pipeline creation then fails loudly, D3D12 is +// unaffected). +nrhi::ShaderDesc MakeShaderDesc(nrhi::ShaderStage stage, const char* file, + const char* hlsl_source, const char* entry, + const nrhi::ShaderMacro* macros, + const char* variant) { + nrhi::ShaderDesc sd; + sd.stage = stage; + sd.name = file; + sd.hlsl_source = hlsl_source; + sd.entry_point = entry; + sd.macros = macros; + for (size_t i = 0; i < skate3::native_spirv::kNativeSpirvBlobCount; ++i) { + const auto& b = skate3::native_spirv::kNativeSpirvBlobs[i]; + if (std::strcmp(b.file, file) == 0 && std::strcmp(b.entry, entry) == 0 && + std::strcmp(b.variant, variant) == 0) { + sd.spirv = b.data; + sd.spirv_size_bytes = b.size_bytes; + break; + } + } + return sd; +} + +bool EnsureRootSignature(const NativeGuestOutputRenderContext& context) { + if (g_r.layout) { + return true; + } + { + nrhi::BindingLayoutDesc ld; + // NOTE the 64-DWORD root-signature budget (a D3D12-backend constraint: + // the layout maps 1:1 onto a D3D12 root signature there): 52 constants + + // 6 descriptor tables (1 each) + 1 root SRV (2) + 2 root CBVs (2 each) + // = 64, FULL. Going past 64 makes the D3D12 layout creation fail + // (renderer falls back to emulated). Any further addition must pack into + // existing rows/tables. + ld.param_count = 10; + ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 52, + nrhi::Visibility::kAll}; + ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, + nrhi::Visibility::kPixel}; + ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, + nrhi::Visibility::kPixel}; + ld.params[3] = {nrhi::BindingParamKind::kBufferSrv, /*t*/ 2, 1, + nrhi::Visibility::kVertex}; + // Macro overlay (t3). + ld.params[4] = {nrhi::BindingParamKind::kTextureTable, 3, 1, + nrhi::Visibility::kPixel}; + // Decal art / spec masks (t4). Second entry of the table = the fam 5/6 + // normal map (t5), bound via cmd->SetTexturePair. Draws without a pair + // leave t5 at the backend fallback; the shader only samples t5 when + // overlay.w == 4 (pair bound). + ld.params[5] = {nrhi::BindingParamKind::kTextureTable, 4, 2, + nrhi::Visibility::kPixel}; + // Dynamic-shadow additions: per-frame receiver constants (b1). + ld.params[6] = {nrhi::BindingParamKind::kConstantBuffer, /*b*/ 1, 1, + nrhi::Visibility::kPixel}; + // Environment cube (t6) + blurred shadow atlas (t7) as ONE two-entry + // table (bound together via SetTexturePair); merging them freed the + // root-signature DWORD the v2 material table below needs. + ld.params[7] = {nrhi::BindingParamKind::kTextureTable, 6, 2, + nrhi::Visibility::kPixel}; + // World-shading v2 material maps: the detail normal map (t8) + the + // decal families' spec/ecc masks (t9), bound as a pair on draws whose + // misc.z flags enable them. + ld.params[8] = {nrhi::BindingParamKind::kTextureTable, 8, 2, + nrhi::Visibility::kPixel}; + // Character lighting block (b2): the canonical per-draw rows captured + // from the guest PS bank (CaptureCharLighting), sliced out of the bone + // upload ring per character draw. + ld.params[9] = {nrhi::BindingParamKind::kConstantBuffer, 2, 1, + nrhi::Visibility::kPixel}; + ld.static_sampler_count = 2; + ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kAnisotropic, + nrhi::AddressMode::kWrap, 8}; + // s1: bilinear CLAMP for the 2D overlay. The HUD fetch constants carry + // clamp_x/clamp_y = 2 (clamp-to-edge), and the clock face is built from + // MIRRORED quadrant tiles whose outer-edge UVs run past 1.0; wrap + // sampling pulls the opposite edge of the art in as 1px seam lines + // (bright rim row across the middle, dark center column at the edges). + ld.static_samplers[1] = {1, nrhi::Filter::kLinear, + nrhi::AddressMode::kClamp, 1}; + ld.allow_input_layout = true; + g_r.layout = context.device->CreateBindingLayout(ld); + if (g_r.layout == nullptr) { + REXLOG_ERROR("native-scene: root signature creation failed"); + g_r.failed = true; + return false; + } + } + return true; +} + +// MSAA level selection + the main scene PSO and its culling/blend/depth +// variants (cull-back sheets, transparent, entity fade, hair 2-pass, +// no-depth, outline mask). +bool EnsureScenePsoFamily(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + // Scene color format: the float HDR intermediate when the HDR post chain + // is active (the tonemap pass then writes the gamma guest output), + // otherwise the guest output itself. + const nrhi::Format scene_fmt = g_r.hdr_active + ? g_r.hdr_scene_format + : context.guest_output->format(); + // MSAA level: the requested count, reduced to what the scene color format + // supports (1 disables and renders directly into the 1x target). The + // RHI walks the requested count down in powers of two, exactly like the + // old CheckFeatureSupport loop. + const int32_t req = REXCVAR_GET(skate3_native_render_scene_msaa); + uint32_t msaa = req >= 8 ? 8u : req >= 4 ? 4u : req >= 2 ? 2u : 1u; + msaa = device->GetSupportedSampleCount(scene_fmt, msaa); + g_r.msaa = msaa; + + // The family rebuilds on HDR toggles / output-format changes: retire the + // previous pipelines (in-flight frames keep them alive via the deferred + // destruction queue). + for (nrhi::Pipeline** p : + {&g_r.pso, &g_r.pso_cullback, &g_r.pso_transparent, &g_r.pso_fade, + &g_r.pso_hair_a, &g_r.pso_hair_b, &g_r.pso_nodepth, + &g_r.pso_outline_mask}) { + if (*p != nullptr) { + device->DestroyDeferred(*p); + *p = nullptr; + } + } + + // HDR=1 switches the pixel shader's output encode to pre-tonemap linear + // (see scene.hlsl ToneOut/PassGamma). + const nrhi::ShaderMacro hdr_defs[] = {{"HDR", "1"}, {nullptr, nullptr}}; + nrhi::Shader* vs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "scene.hlsl", kShaderSource, + "vs_main", nullptr, "")); + nrhi::Shader* ps = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, + "ps_main", g_r.hdr_active ? hdr_defs : nullptr, + g_r.hdr_active ? "HDR=1" : "")); + if (vs == nullptr || ps == nullptr) { + REXLOG_ERROR("native-scene: shader compile failed"); + g_r.failed = true; + return false; + } + nrhi::GraphicsPipelineDesc pso; + pso.layout = g_r.layout; + pso.vs = vs; + pso.ps = ps; + pso.input_elements = kSceneInputLayout; + pso.input_element_count = 7; + pso.vertex_stride = 56; + pso.cull = nrhi::CullMode::kNone; + pso.depth_clip = true; + pso.depth.test_enable = true; + pso.depth.write_enable = true; + pso.depth.func = nrhi::CompareFunc::kLess; + pso.rtv_format = scene_fmt; + pso.dsv_format = nrhi::Format::kD32_FLOAT; + pso.sample_count = g_r.msaa; + g_r.pso = device->CreateGraphicsPipeline(pso); + // Culling variant for double-sided sheet props (banners/flags, + // MeshBuffers::two_sided_sheet). The sheet whose winding-derived world + // normal faces the camera is the one the game keeps (its lightmap cell + // reproduces the emulated banner exactly: albedo x lm x 2 lands within + // 3-8% of the F11 emulated reference on capture 1783387480); under our + // pipeline those triangles are D3D12 BACK faces, so cull FRONT. + // (CULL_BACK was tried first and kept the wrong sheet; banners rendered + // the sun-side lightmap cell ~2.4x brighter than emulated.) + pso.cull = nrhi::CullMode::kFront; + g_r.pso_cullback = device->CreateGraphicsPipeline(pso); + if (g_r.pso_cullback == nullptr) { + REXLOG_WARN("native-scene: cull-back PSO creation failed"); + // sheets fall back to the uncull(ed) PSO + } + pso.cull = nrhi::CullMode::kNone; + // Transparent variant: straight alpha blend, depth-tested, no z-write. + pso.depth.write_enable = false; + pso.blend.enable = true; + pso.blend.src = nrhi::BlendFactor::kSrcAlpha; + pso.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; + pso.blend.op = nrhi::BlendOp::kAdd; + pso.blend.src_alpha = nrhi::BlendFactor::kOne; + pso.blend.dst_alpha = nrhi::BlendFactor::kInvSrcAlpha; + pso.blend.op_alpha = nrhi::BlendOp::kAdd; + g_r.pso_transparent = device->CreateGraphicsPipeline(pso); + // Entity-fade variant: z-write ON (see RendererState::pso_fade). Drawn + // at the head of the blended sub-pass so glass/hair still composite + // over the faded body. + pso.depth.write_enable = true; + g_r.pso_fade = device->CreateGraphicsPipeline(pso); + // (nullptr = fade items fall back to the z-write-off blend) + pso.depth.write_enable = false; + // Hair passes: the game draws hair twice with the SAME shader; cull + // BACK then cull FRONT (cac_hair.xml passes 0/1) so far-side strands + // never composite over near-side ones (one uncull(ed) blended pass + // interleaves them per triangle order = crunchy noise). Same blend / + // z-write-off state as the transparent variant. + pso.cull = nrhi::CullMode::kBack; + g_r.pso_hair_a = device->CreateGraphicsPipeline(pso); + pso.cull = nrhi::CullMode::kFront; + g_r.pso_hair_b = device->CreateGraphicsPipeline(pso); + pso.cull = nrhi::CullMode::kNone; + pso.blend = {}; + pso.depth.write_enable = true; + pso.depth.test_enable = false; + pso.dsv_format = nrhi::Format::kUnknown; + g_r.pso_nodepth = device->CreateGraphicsPipeline(pso); + // Selection-outline mask: the same scene VS/PS (the tint.a > 0 solid + // path renders flat 1.0) into the small single-sample R8 target. No + // depth: the mask is the full silhouette. The guest marking pass is + // depth-tested, so a partially occluded selection outlines slightly + // differently, acceptable for the editor UI. + pso.rtv_format = nrhi::Format::kR8_UNORM; + pso.sample_count = 1; + g_r.pso_outline_mask = device->CreateGraphicsPipeline(pso); + if (g_r.pso_outline_mask == nullptr) { + REXLOG_WARN("native-scene: outline mask PSO creation failed"); + // outline pass disables itself + } + pso.rtv_format = scene_fmt; + pso.sample_count = g_r.msaa; + device->DestroyDeferred(vs); + device->DestroyDeferred(ps); + if (g_r.pso == nullptr || g_r.pso_nodepth == nullptr || + g_r.pso_transparent == nullptr) { + REXLOG_ERROR("native-scene: PSO creation failed"); + g_r.failed = true; + return false; + } + return true; +} + +// Fullscreen MSAA resolve PSO (no-op below MSAA 2x). +bool EnsureResolvePso(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + if (g_r.resolve_pso != nullptr) { + device->DestroyDeferred(g_r.resolve_pso); + g_r.resolve_pso = nullptr; + } + if (g_r.msaa > 1) { + char samples[8]; + std::snprintf(samples, sizeof(samples), "%u", g_r.msaa); + const nrhi::ShaderMacro macros[] = {{"SAMPLES", samples}, + {nullptr, nullptr}}; + char variant[16]; + std::snprintf(variant, sizeof(variant), "SAMPLES=%u", g_r.msaa); + nrhi::Shader* rvs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "resolve.hlsl", + kResolveShaderSource, "vs_main", macros, variant)); + nrhi::Shader* rps = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "resolve.hlsl", + kResolveShaderSource, "ps_main", macros, variant)); + if (rvs == nullptr || rps == nullptr) { + REXLOG_ERROR("native-scene: resolve shader compile failed"); + g_r.failed = true; + return false; + } + nrhi::GraphicsPipelineDesc rp; + rp.layout = g_r.layout; + rp.vs = rvs; + rp.ps = rps; + rp.cull = nrhi::CullMode::kNone; + // Under HDR the resolve averages the float MSAA scene into the 1x float + // plane (hdr_resolved); classic resolves straight into the guest output. + rp.rtv_format = g_r.hdr_active ? g_r.hdr_scene_format + : context.guest_output->format(); + rp.sample_count = 1; + g_r.resolve_pso = device->CreateGraphicsPipeline(rp); + device->DestroyDeferred(rvs); + device->DestroyDeferred(rps); + if (g_r.resolve_pso == nullptr) { + REXLOG_ERROR("native-scene: resolve PSO creation failed"); + g_r.failed = true; + return false; + } + } + return true; +} + +// Popup background blur pipelines; PSO-create failure only disables blur. +bool EnsureBlurPsos(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + { + // Popup background blur pipelines (blur_hBlur/vBlur port + basictex + // replace): fullscreen-triangle passes, no vertex buffer. + nrhi::Shader* bvs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "blur.hlsl", + kBlurShaderSource, "vs_main", nullptr, "")); + nrhi::Shader* bps = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", + kBlurShaderSource, "ps_main", nullptr, "")); + nrhi::Shader* bblit = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", + kBlurShaderSource, "ps_blit", nullptr, "")); + nrhi::Shader* bdown = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", + kBlurShaderSource, "ps_down", nullptr, "")); + nrhi::Shader* bgauss = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "blur.hlsl", + kBlurShaderSource, "ps_menu_gauss", nullptr, "")); + if (bvs == nullptr || bps == nullptr || bblit == nullptr || + bdown == nullptr || bgauss == nullptr) { + REXLOG_ERROR("native-scene: blur shader compile failed"); + g_r.failed = true; + return false; + } + nrhi::GraphicsPipelineDesc bp; + bp.layout = g_r.layout; + bp.vs = bvs; + bp.ps = bps; + bp.cull = nrhi::CullMode::kNone; + bp.rtv_format = context.guest_output->format(); + bp.sample_count = 1; + if (g_r.pso_blur) device->DestroyDeferred(g_r.pso_blur); + if (g_r.pso_blur_blit) device->DestroyDeferred(g_r.pso_blur_blit); + if (g_r.pso_blur_down) device->DestroyDeferred(g_r.pso_blur_down); + if (g_r.pso_menu_gauss) device->DestroyDeferred(g_r.pso_menu_gauss); + g_r.pso_blur = device->CreateGraphicsPipeline(bp); + bp.ps = bblit; + g_r.pso_blur_blit = device->CreateGraphicsPipeline(bp); + bp.ps = bdown; + g_r.pso_blur_down = device->CreateGraphicsPipeline(bp); + bp.ps = bgauss; + g_r.pso_menu_gauss = device->CreateGraphicsPipeline(bp); + device->DestroyDeferred(bvs); + device->DestroyDeferred(bps); + device->DestroyDeferred(bblit); + device->DestroyDeferred(bdown); + device->DestroyDeferred(bgauss); + if (g_r.pso_blur == nullptr || g_r.pso_blur_blit == nullptr || + g_r.pso_blur_down == nullptr || g_r.pso_menu_gauss == nullptr) { + REXLOG_ERROR("native-scene: blur PSO creation failed"); + g_r.pso_blur = nullptr; + g_r.pso_blur_blit = nullptr; // blur unavailable; everything else runs + g_r.pso_blur_down = nullptr; + g_r.pso_menu_gauss = nullptr; + } + } + return true; +} + +// Selection-outline edge composite PSO; create failure disables outline. +bool EnsureOutlineEdgePso(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + { + // Selection-outline edge composite (postfx_edgedetectstencil port): + // fullscreen triangle over the resolved output, additive blend. + nrhi::Shader* ovs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "outline.hlsl", + kOutlineShaderSource, "vs_main", nullptr, "")); + nrhi::Shader* ops = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "outline.hlsl", + kOutlineShaderSource, "ps_main", nullptr, "")); + if (ovs == nullptr || ops == nullptr) { + REXLOG_ERROR("native-scene: outline shader compile failed"); + g_r.failed = true; + return false; + } + nrhi::GraphicsPipelineDesc op; + op.layout = g_r.layout; + op.vs = ovs; + op.ps = ops; + op.blend.enable = true; + op.blend.src = nrhi::BlendFactor::kOne; + op.blend.dst = nrhi::BlendFactor::kOne; + op.blend.op = nrhi::BlendOp::kAdd; + op.blend.src_alpha = nrhi::BlendFactor::kZero; + op.blend.dst_alpha = nrhi::BlendFactor::kOne; + op.blend.op_alpha = nrhi::BlendOp::kAdd; + op.cull = nrhi::CullMode::kNone; + op.rtv_format = context.guest_output->format(); + op.sample_count = 1; + if (g_r.pso_outline_edge) device->DestroyDeferred(g_r.pso_outline_edge); + g_r.pso_outline_edge = device->CreateGraphicsPipeline(op); + device->DestroyDeferred(ovs); + device->DestroyDeferred(ops); + if (g_r.pso_outline_edge == nullptr) { + REXLOG_WARN("native-scene: outline edge PSO creation failed"); + // outline unavailable; everything else runs + } + } + return true; +} + +// 2D overlay pipeline. +bool Ensure2dPso(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + { + // 2D overlay pipeline: standard alpha blend, no depth, drawn into the + // resolved guest output (sample count 1). + nrhi::Shader* uvs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "overlay2d.hlsl", + kShader2dSource, "vs_main", nullptr, "")); + nrhi::Shader* ups = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "overlay2d.hlsl", + kShader2dSource, "ps_main", nullptr, "")); + if (uvs == nullptr || ups == nullptr) { + REXLOG_ERROR("native-scene: 2D shader compile failed"); + g_r.failed = true; + return false; + } + static constexpr nrhi::InputElementDesc input2d[3] = { + {"POSITION", 0, 0, nrhi::Format::kR32G32B32A32_FLOAT, 0}, + {"TEXCOORD", 0, 1, nrhi::Format::kR32G32_FLOAT, 16}, + {"COLOR", 0, 2, nrhi::Format::kR8G8B8A8_UNORM, 24}}; + nrhi::GraphicsPipelineDesc up; + up.layout = g_r.layout; + up.vs = uvs; + up.ps = ups; + up.blend.enable = true; + // Straight (non-premultiplied) alpha, verified against the decoded + // HUD clock art: the glass-sheen texture is white RGB at low alpha + // (premultiplied blending blows it out into an opaque white blob). + up.blend.src = nrhi::BlendFactor::kSrcAlpha; + up.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; + up.blend.op = nrhi::BlendOp::kAdd; + up.blend.src_alpha = nrhi::BlendFactor::kOne; + up.blend.dst_alpha = nrhi::BlendFactor::kInvSrcAlpha; + up.blend.op_alpha = nrhi::BlendOp::kAdd; + up.cull = nrhi::CullMode::kNone; + up.input_elements = input2d; + up.input_element_count = 3; + up.vertex_stride = 28; + up.rtv_format = context.guest_output->format(); + up.sample_count = 1; + g_r.pso_2d = device->CreateGraphicsPipeline(up); + // FMV variant: identical pipeline, ps_yuv2d combine (see the movie-quad + // substitution in the 2D replay). Failure only loses native FMV; the + // emulated yield fallback covers it. + nrhi::Shader* uyuv = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "overlay2d.hlsl", + kShader2dSource, "ps_yuv2d", nullptr, "")); + if (uyuv != nullptr) { + up.ps = uyuv; + if (g_r.pso_yuv2d) device->DestroyDeferred(g_r.pso_yuv2d); + g_r.pso_yuv2d = device->CreateGraphicsPipeline(up); + if (g_r.pso_yuv2d == nullptr) { + REXLOG_WARN("native-scene: FMV 2D PSO creation failed - movies yield"); + } + device->DestroyDeferred(uyuv); + } else { + REXLOG_WARN("native-scene: ps_yuv2d compile failed - movies yield"); + } + device->DestroyDeferred(uvs); + device->DestroyDeferred(ups); + if (g_r.pso_2d == nullptr) { + REXLOG_ERROR("native-scene: 2D PSO creation failed"); + g_r.failed = true; + return false; + } + } + return true; +} + +// In-world spline pipelines (darken + additive default). +bool EnsureSplinePsos(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + { + // In-world spline pipelines: drawn inside the scene pass (MSAA sample + // count, depth test LESS_EQUAL, no z-write) with the game's own blend + // states (spline.xml): darken = straight alpha, default = additive. + // Under HDR the pixel shaders encode through the tone chain's inverse + // (SplineOut) so the host tonemap restores the authored look. + for (nrhi::Pipeline** p : {&g_r.pso_spline_darken, &g_r.pso_spline_default}) { + if (*p != nullptr) { + device->DestroyDeferred(*p); + *p = nullptr; + } + } + const nrhi::ShaderMacro hdr_defs[] = {{"HDR", "1"}, {nullptr, nullptr}}; + const nrhi::ShaderMacro* sp_defs = g_r.hdr_active ? hdr_defs : nullptr; + const char* sp_variant = g_r.hdr_active ? "HDR=1" : ""; + nrhi::Shader* svs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "spline.hlsl", + kShaderSplineSource, "vs_main", nullptr, "")); + nrhi::Shader* spd = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "spline.hlsl", + kShaderSplineSource, "ps_default", sp_defs, sp_variant)); + nrhi::Shader* spk = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "spline.hlsl", + kShaderSplineSource, "ps_darken", sp_defs, sp_variant)); + if (svs == nullptr || spd == nullptr || spk == nullptr) { + REXLOG_ERROR("native-scene: spline shader compile failed"); + g_r.failed = true; + return false; + } + static constexpr nrhi::InputElementDesc input_spline[3] = { + {"POSITION", 0, 0, nrhi::Format::kR32G32B32A32_FLOAT, 0}, + {"TEXCOORD", 0, 1, nrhi::Format::kR32G32_FLOAT, 16}, + {"TEXCOORD", 1, 2, nrhi::Format::kR32_FLOAT, 24}}; + nrhi::GraphicsPipelineDesc sp; + sp.layout = g_r.layout; + sp.vs = svs; + sp.cull = nrhi::CullMode::kNone; + sp.depth_clip = true; + sp.depth.test_enable = true; + sp.depth.write_enable = false; + sp.depth.func = nrhi::CompareFunc::kLessEqual; + sp.input_elements = input_spline; + sp.input_element_count = 3; + sp.vertex_stride = 28; + sp.rtv_format = g_r.hdr_active ? g_r.hdr_scene_format + : context.guest_output->format(); + sp.dsv_format = nrhi::Format::kD32_FLOAT; + sp.sample_count = g_r.msaa; + sp.blend.enable = true; + sp.blend.op = nrhi::BlendOp::kAdd; + sp.blend.op_alpha = nrhi::BlendOp::kAdd; + // darken pass: straight alpha. + sp.ps = spk; + sp.blend.src = nrhi::BlendFactor::kSrcAlpha; + sp.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; + sp.blend.src_alpha = nrhi::BlendFactor::kSrcAlpha; + sp.blend.dst_alpha = nrhi::BlendFactor::kInvSrcAlpha; + g_r.pso_spline_darken = device->CreateGraphicsPipeline(sp); + // default pass: additive glow. + sp.ps = spd; + sp.blend.src = nrhi::BlendFactor::kOne; + sp.blend.dst = nrhi::BlendFactor::kOne; + sp.blend.src_alpha = nrhi::BlendFactor::kOne; + sp.blend.dst_alpha = nrhi::BlendFactor::kOne; + g_r.pso_spline_default = device->CreateGraphicsPipeline(sp); + device->DestroyDeferred(svs); + device->DestroyDeferred(spd); + device->DestroyDeferred(spk); + if (g_r.pso_spline_darken == nullptr || g_r.pso_spline_default == nullptr) { + REXLOG_ERROR("native-scene: spline PSO creation failed"); + g_r.failed = true; + return false; + } + } + return true; +} + +// Dynamic-shadow caster + per-tile blur/convert PSOs. +bool EnsureShadowPsos(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + { + // Dynamic-shadow pipelines: casters (scene VS with a light-space + // "view-proj", MIN-blend depth/uncoverage accumulation, no depth + // buffer, depth clip OFF so casters outside the 12 m height window + // clamp like the game accepts) + the per-tile blur/convert pass. + nrhi::Shader* cvs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "scene.hlsl", kShaderSource, + "vs_main", nullptr, "")); + nrhi::Shader* cps = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, + "ps_shadow_caster", nullptr, "")); + nrhi::Shader* bvs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "shadow_blur.hlsl", + kShadowBlurSource, "vs_main", nullptr, "")); + nrhi::Shader* bps = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "shadow_blur.hlsl", + kShadowBlurSource, "ps_main", nullptr, "")); + if (cvs == nullptr || cps == nullptr || bvs == nullptr || bps == nullptr) { + REXLOG_ERROR("native-scene: shadow shader compile failed"); + g_r.failed = true; + return false; + } + nrhi::GraphicsPipelineDesc cp; + cp.layout = g_r.layout; + cp.vs = cvs; + cp.ps = cps; + cp.blend.enable = true; + cp.blend.src = nrhi::BlendFactor::kOne; + cp.blend.dst = nrhi::BlendFactor::kOne; + cp.blend.op = nrhi::BlendOp::kMin; + cp.blend.src_alpha = nrhi::BlendFactor::kOne; + cp.blend.dst_alpha = nrhi::BlendFactor::kOne; + cp.blend.op_alpha = nrhi::BlendOp::kMin; + cp.blend.write_mask = 0x3; // RG only + cp.cull = nrhi::CullMode::kNone; + cp.depth_clip = false; + cp.input_elements = kSceneInputLayout; + cp.input_element_count = 7; + cp.vertex_stride = 56; + cp.rtv_format = nrhi::Format::kR16G16_UNORM; + cp.sample_count = 1; + g_r.pso_shadow_caster = device->CreateGraphicsPipeline(cp); + nrhi::GraphicsPipelineDesc bp; + bp.layout = g_r.layout; + bp.vs = bvs; + bp.ps = bps; + bp.cull = nrhi::CullMode::kNone; + bp.depth_clip = true; + bp.rtv_format = nrhi::Format::kR16G16_UNORM; + bp.sample_count = 1; + g_r.pso_shadow_blur = device->CreateGraphicsPipeline(bp); + device->DestroyDeferred(cvs); + device->DestroyDeferred(cps); + device->DestroyDeferred(bvs); + device->DestroyDeferred(bps); + if (g_r.pso_shadow_caster == nullptr || g_r.pso_shadow_blur == nullptr) { + REXLOG_ERROR("native-scene: shadow PSO creation failed"); + g_r.failed = true; + return false; + } + } + return true; +} + +// The bone / ROPA / 2D-vertex upload rings (persistently mapped). The old +// RTV/DSV/SRV descriptor heaps and their increment-size bookkeeping are gone +// - render targets bind by texture and sampled views are backend-managed +// objects. (Name kept for the call sites.) +bool EnsureHeapsAndRings(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + + if (!g_r.bone_ring) { + g_r.bone_ring = CreateUploadBuffer( + device, size_t(RendererState::kBoneRegionSize) * RendererState::kBoneRegions); + g_r.bone_ring_cpu = + g_r.bone_ring ? static_cast(device->Map(g_r.bone_ring)) + : nullptr; + if (g_r.bone_ring_cpu == nullptr) { + g_r.failed = true; + return false; + } + } + + if (!g_r.ropa_ring) { + g_r.ropa_ring = CreateUploadBuffer( + device, size_t(RendererState::kRopaRegionSize) * RendererState::kBoneRegions); + g_r.ropa_ring_cpu = + g_r.ropa_ring ? static_cast(device->Map(g_r.ropa_ring)) + : nullptr; + if (g_r.ropa_ring_cpu == nullptr) { + g_r.failed = true; + return false; + } + } + + if (!g_r.ui_ring) { + g_r.ui_ring = CreateUploadBuffer( + device, size_t(RendererState::kUiRegionSize) * RendererState::kUiRegions); + g_r.ui_ring_cpu = g_r.ui_ring + ? static_cast(device->Map(g_r.ui_ring)) + : nullptr; + if (g_r.ui_ring_cpu == nullptr) { + g_r.failed = true; + return false; + } + } + return true; +} + +// ---- Photo-editor postfx chain (photo_fx.hlsl) ----------------------------- +// Root signature + eight PSOs + the fixed-size intermediates. Lazy: built on +// the first photo-editor frame (a one-time ~100 ms compile the frozen-scene +// editor absorbs invisibly). Output-sized targets are (re)built per frame by +// the render block on size change. +bool EnsurePhotoFxPipeline(const NativeGuestOutputRenderContext& context) { + if (g_r.pfx_ready) { + return true; + } + if (g_r.pfx_failed) { + return false; + } + nrhi::Device* device = context.device; + const auto fail = [&](const char* what) { + REXLOG_ERROR("native-scene: photo postfx pipeline setup failed ({})", what); + g_r.pfx_failed = true; + return false; + }; + // Binding layout: CBV b0 + ONE eight-texture table t0..t7 + samplers. (The + // old eight single-SRV tables would need nine Vulkan descriptor sets, + // over the 8-set minimum on Intel/MoltenVK, so the passes gather their + // t0..t7 views and bind them with one cmd->SetTextures(1, views, 8).) + if (g_r.pfx_layout == nullptr) { + nrhi::BindingLayoutDesc ld; + ld.param_count = 2; + ld.params[0] = {nrhi::BindingParamKind::kConstantBuffer, /*b*/ 0, 1, + nrhi::Visibility::kAll}; + ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 8, + nrhi::Visibility::kPixel}; + ld.static_sampler_count = 3; + ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kLinear, + nrhi::AddressMode::kClamp, 1}; + ld.static_samplers[1] = {1, nrhi::Filter::kPoint, nrhi::AddressMode::kClamp, + 1}; // packed depth + ld.static_samplers[2] = {2, nrhi::Filter::kLinear, nrhi::AddressMode::kWrap, + 1}; // grain + ld.allow_input_layout = false; + g_r.pfx_layout = device->CreateBindingLayout(ld); + if (g_r.pfx_layout == nullptr) { + return fail("root signature"); + } + } + // Shaders + PSOs. Pass order matches RendererState::pfx_pso. + struct Entry { + const char* vs; + const char* ps; + nrhi::Format rtv; + }; + const Entry entries[9] = { + {"vs_raw", "ps_depthpack", nrhi::Format::kR8G8B8A8_UNORM}, + {"vs_offset", "ps_visualfx", nrhi::Format::kR8G8B8A8_UNORM}, + {"vs_offset", "ps_dof_down", nrhi::Format::kR8G8B8A8_UNORM}, + {"vs_offset", "ps_dof_mb", nrhi::Format::kR8G8B8A8_UNORM}, + {"vs_offset", "ps_dof", nrhi::Format::kR8G8B8A8_UNORM}, + {"vs_raw", "ps_uber", nrhi::Format::kR8G8B8A8_UNORM}, + {"vs_scaled", "ps_fisheye", context.guest_output->format()}, + {"vs_raw", "ps_blit", nrhi::Format::kR8G8B8A8_UNORM}, + // Debug visualizer (photo_native_debug cvar): CoC / packed-depth view + // drawn over the output instead of the fisheye result. + {"vs_raw", "ps_pfx_debug", context.guest_output->format()}, + }; + const nrhi::ShaderMacro msaa_defines[] = {{"PFX_MSAA", "1"}, + {nullptr, nullptr}}; + for (int i = 0; i < 9; ++i) { + const bool msaa_pass = (i == 0 && g_r.msaa > 1); + const nrhi::ShaderMacro* defs = msaa_pass ? msaa_defines : nullptr; + const char* variant = msaa_pass ? "PFX_MSAA=1" : ""; + nrhi::Shader* vs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "photo_fx.hlsl", + kPhotoFxShaderSource, entries[i].vs, defs, variant)); + nrhi::Shader* ps = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "photo_fx.hlsl", + kPhotoFxShaderSource, entries[i].ps, defs, variant)); + if (vs == nullptr || ps == nullptr) { + REXLOG_ERROR("native-scene: photo postfx shader compile failed ({})", + entries[i].ps); + g_r.pfx_failed = true; + return false; + } + nrhi::GraphicsPipelineDesc pso; + pso.layout = g_r.pfx_layout; + pso.vs = vs; + pso.ps = ps; + pso.cull = nrhi::CullMode::kNone; + pso.depth_clip = true; + pso.rtv_format = entries[i].rtv; + pso.sample_count = 1; + g_r.pfx_pso[i] = device->CreateGraphicsPipeline(pso); + device->DestroyDeferred(vs); + device->DestroyDeferred(ps); + if (g_r.pfx_pso[i] == nullptr) { + return fail(entries[i].ps); + } + } + // Fixed-size intermediates + the identity grade LUT + the CB ring. + { + nrhi::TextureDesc desc; + desc.format = nrhi::Format::kR8G8B8A8_UNORM; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + struct Fixed { + nrhi::Texture** res; + uint32_t w, h; + }; + const Fixed fixed[3] = { + {&g_r.pfx_half[0], RendererState::kPfxHalfW, RendererState::kPfxHalfH}, + {&g_r.pfx_half[1], RendererState::kPfxHalfW, RendererState::kPfxHalfH}, + {&g_r.pfx_quarter, RendererState::kPfxQuarterW, RendererState::kPfxQuarterH}, + }; + for (const Fixed& f : fixed) { + if (*f.res != nullptr) { + continue; + } + desc.width = f.w; + desc.height = f.h; + *f.res = device->CreateTexture(desc); + if (*f.res == nullptr) { + return fail("intermediate target"); + } + } + if (g_r.pfx_lut == nullptr) { + // 32^3 identity grade LUT (the editor captures run with the LUT blend + // weight at 0; identity keeps any treatment that enables it neutral + // instead of garbage). Coordinate mapping mirrors the uber literals: + // u = g*0.96875 + 0.015625, v = r*(-0.96875) + 0.984375 (flipped), + // w = b*0.96875 + 0.015625, so voxel (ix,iy,iz) stores + // r = (0.984375 - v)/0.96875, g = (u - 0.015625)/0.96875, b likewise. + nrhi::TextureDesc lut; + lut.kind = nrhi::TextureKind::k3D; + lut.width = 32; + lut.height = 32; + lut.depth = 32; + lut.format = nrhi::Format::kR8G8B8A8_UNORM; + lut.initial_state = nrhi::ResourceState::kCopyDest; + g_r.pfx_lut = device->CreateTexture(lut); + if (g_r.pfx_lut == nullptr) { + return fail("LUT"); + } + // Upload: 32 rows of 32 texels x 32 slices, 256-byte row pitch. + const uint32_t row_pitch = 256; + const uint32_t slice_pitch = row_pitch * 32; + g_r.pfx_lut_upload = CreateUploadBuffer(device, size_t(slice_pitch) * 32); + if (g_r.pfx_lut_upload == nullptr) { + return fail("LUT upload"); + } + uint8_t* p = static_cast(device->Map(g_r.pfx_lut_upload)); + if (p == nullptr) { + return fail("LUT map"); + } + for (uint32_t iz = 0; iz < 32; ++iz) { + for (uint32_t iy = 0; iy < 32; ++iy) { + uint8_t* row = p + size_t(iz) * slice_pitch + size_t(iy) * row_pitch; + const float v = (float(iy) + 0.5f) / 32.0f; + const float w = (float(iz) + 0.5f) / 32.0f; + const float r = (0.984375f - v) / 0.96875f; + const float b = (w - 0.015625f) / 0.96875f; + for (uint32_t ix = 0; ix < 32; ++ix) { + const float u = (float(ix) + 0.5f) / 32.0f; + const float g = (u - 0.015625f) / 0.96875f; + row[ix * 4 + 0] = uint8_t(std::clamp(r, 0.0f, 1.0f) * 255.0f + 0.5f); + row[ix * 4 + 1] = uint8_t(std::clamp(g, 0.0f, 1.0f) * 255.0f + 0.5f); + row[ix * 4 + 2] = uint8_t(std::clamp(b, 0.0f, 1.0f) * 255.0f + 0.5f); + row[ix * 4 + 3] = 255; + } + } + } + device->Unmap(g_r.pfx_lut_upload); + } + if (g_r.pfx_cb == nullptr) { + // 8 pass slots x 4 KB x 4 frames in flight. + // 10 slots per frame region: depth pack + accum feed + 6 passes + + // accum mode 2 + the debug view. + g_r.pfx_cb = CreateUploadBuffer(device, 10u * 4096u * 4u); + g_r.pfx_cb_ptr = g_r.pfx_cb + ? static_cast(device->Map(g_r.pfx_cb)) + : nullptr; + if (g_r.pfx_cb_ptr == nullptr) { + return fail("constant ring"); + } + } + } + // Fixed-target views (2/3 = halves, 4 = quarter, 6 = LUT; 0/1/5/7, the + // output-sized full/depth targets and the native depth resource, are + // (re)created by the render block on size change). + if (!g_r.pfx_srv_allocated) { + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.pfx_srv[2] = device->CreateTextureView(g_r.pfx_half[0], vd); + g_r.pfx_srv[3] = device->CreateTextureView(g_r.pfx_half[1], vd); + g_r.pfx_srv[4] = device->CreateTextureView(g_r.pfx_quarter, vd); + // LUT SRV (Texture3D). + nrhi::TextureViewDesc lv; + lv.dimension = nrhi::ViewDimension::k3D; + lv.mip_levels = 1; + g_r.pfx_srv[6] = device->CreateTextureView(g_r.pfx_lut, lv); + if (g_r.pfx_srv[2] == nullptr || g_r.pfx_srv[3] == nullptr || + g_r.pfx_srv[4] == nullptr || g_r.pfx_srv[6] == nullptr) { + return fail("fixed-target views"); + } + g_r.pfx_srv_allocated = true; + } + g_r.pfx_ready = true; + REXLOG_INFO("native-scene: photo postfx pipeline ready (9 passes, msaa={})", + g_r.msaa); + return true; +} + +// Shadow atlas targets + the always-bound b1 receiver constant buffer. +bool EnsureShadowResources(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + if (!g_r.shadow_raw && REXCVAR_GET(skate3_native_render_scene_shadows)) { + // Dynamic-shadow atlas chain: raw casters -> hblur intermediate -> + // blurred final (the texture the scene pass samples). Three fixed-size + // R16G16_UNORM targets (the game's atlas is fmt 25 = 16_16 fixed point; + // half-float ulp at the typical ~0.85 depth is ~6 mm of world height, + // too coarse for board/feet-height casters 1-2 cm off the ground), + // 3 tiles of tile x tile each. + const int32_t tile_cfg = REXCVAR_GET(skate3_native_render_scene_shadow_tile); + if (tile_cfg > 0) { + g_r.shadow_tile = uint32_t(tile_cfg); + } else { + // Auto: the game's 512 tiles at the RENDER resolution scale. + // guest_output is the scaled frontbuffer (720p x the draw resolution + // scale, independent of window/monitor size), so deriving from its + // height follows the Resolution Scale setting, including any + // device-limit clamping the texture cache applied, and gives the + // same effective shadow raster the emulated GPU renders at that + // scale. Resolved once at atlas creation (the cvar is + // restart-scoped). + const uint32_t scale = + std::max(1u, (context.guest_output_height + 719u) / 720u); + g_r.shadow_tile = std::min(512u * scale, 4096u); + } + nrhi::TextureDesc desc; + desc.width = g_r.shadow_tile * 3; + desc.height = g_r.shadow_tile; + desc.format = nrhi::Format::kR16G16_UNORM; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + desc.clear_color[0] = 1.0f; // depth: far + desc.clear_color[1] = 1.0f; // "uncoverage": empty + nrhi::Texture** targets[3] = {&g_r.shadow_raw, &g_r.shadow_mid, + &g_r.shadow_final}; + nrhi::TextureView** views[3] = {&g_r.shadow_srv_raw, &g_r.shadow_srv_mid, + &g_r.shadow_srv_final}; + for (int t = 0; t < 3; ++t) { + *targets[t] = device->CreateTexture(desc); + if (*targets[t] == nullptr) { + REXLOG_ERROR("native-scene: shadow atlas creation failed"); + g_r.failed = true; + return false; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + *views[t] = device->CreateTextureView(*targets[t], vd); + if (*views[t] == nullptr) { + REXLOG_ERROR("native-scene: shadow atlas creation failed"); + g_r.failed = true; + return false; + } + } + g_r.shadow_in_srv_state = false; + REXLOG_INFO("native-scene: shadow atlas created ({}x{} tiles{})", + g_r.shadow_tile, g_r.shadow_tile, + tile_cfg > 0 ? "" : ", auto"); + } + if (!g_r.world_shadow && g_r.shadow_raw != nullptr) { + // dynamicobject static world-shadow map (see RendererState). Fixed at + // the game's own 512x512: a coarse region-scale baked-shade map whose + // PCF footprint the props' shading was tuned against. + nrhi::TextureDesc desc; + desc.width = RendererState::kWorldShadowSize; + desc.height = RendererState::kWorldShadowSize; + desc.format = nrhi::Format::kR16G16_UNORM; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + desc.clear_color[0] = 1.0f; // depth: far = lit + desc.clear_color[1] = 1.0f; + g_r.world_shadow = device->CreateTexture(desc); + if (g_r.world_shadow != nullptr) { + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.world_shadow_srv = device->CreateTextureView(g_r.world_shadow, vd); + } + if (g_r.world_shadow_srv == nullptr) { + REXLOG_ERROR("native-scene: world-shadow map creation failed"); + g_r.failed = true; + return false; + } + g_r.world_shadow_in_srv = false; + g_r.world_shadow_primed = false; + } + if (!g_r.shadow_cb) { + // Always created (even with shadows off): the scene PS declares b1 and + // a root CBV must be bound; a zeroed block disables the shadow branch. + g_r.shadow_cb = CreateUploadBuffer( + device, size_t(RendererState::kShadowCbSlice) * RendererState::kShadowCbRegions); + g_r.shadow_cb_cpu = + g_r.shadow_cb ? static_cast(device->Map(g_r.shadow_cb)) + : nullptr; + if (g_r.shadow_cb_cpu == nullptr) { + g_r.failed = true; + return false; + } + } + return true; +} + +// Blur intermediates + the output-sized selection-outline mask. Failures +// only disable their feature, never abort the native path. +bool EnsureBlurOutlineTargets(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + if (g_r.blur_tex[0] == nullptr && g_r.pso_blur != nullptr) { + // Popup background blur intermediates at the game's fixed 1152x640 + // internal resolution (the bilinear stretch back to the output is what + // produces the authentic frosted-glass lattice). + nrhi::TextureDesc desc; + desc.width = RendererState::kBlurWidth; + desc.height = RendererState::kBlurHeight; + desc.format = context.guest_output->format(); + desc.initial_state = nrhi::ResourceState::kRenderTarget; + bool ok = true; + for (int t = 0; t < 2 && ok; ++t) { + // blur_tex[0] is also the photo-grab readback source (copied into the + // grab readback buffers), so it alone needs copy-source usage. + desc.usage = nrhi::kTextureUsageRenderTarget | + (t == 0 ? nrhi::kTextureUsageCopySource + : nrhi::kTextureUsageNone); + g_r.blur_tex[t] = device->CreateTexture(desc); + if (g_r.blur_tex[t] == nullptr) { + ok = false; + break; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.blur_srv[t] = device->CreateTextureView(g_r.blur_tex[t], vd); + if (g_r.blur_srv[t] == nullptr) { + ok = false; + break; + } + } + if (!ok) { + REXLOG_ERROR("native-scene: blur intermediate creation failed; blur disabled"); + for (int t = 0; t < 2; ++t) { + if (g_r.blur_srv[t]) device->DestroyDeferred(g_r.blur_srv[t]); + g_r.blur_srv[t] = nullptr; + if (g_r.blur_tex[t]) device->DestroyDeferred(g_r.blur_tex[t]); + g_r.blur_tex[t] = nullptr; + } + g_r.pso_blur = nullptr; // leaked PSO acceptable on this cold path + } + } + + // Settings-menu gaussian intermediates: half the output resolution + // (sigma halves with it; the final bilinear stretch back is invisible + // under a real blur), recreated whenever the output size changes. + const uint32_t menu_w = std::max(1u, context.guest_output_width / 2); + const uint32_t menu_h = std::max(1u, context.guest_output_height / 2); + if ((g_r.menu_blur_tex[0] == nullptr || g_r.menu_blur_w != menu_w || + g_r.menu_blur_h != menu_h) && + g_r.pso_menu_gauss != nullptr) { + for (int t = 0; t < 2; ++t) { + if (g_r.menu_blur_srv[t]) device->DestroyDeferred(g_r.menu_blur_srv[t]); + g_r.menu_blur_srv[t] = nullptr; + if (g_r.menu_blur_tex[t]) device->DestroyDeferred(g_r.menu_blur_tex[t]); + g_r.menu_blur_tex[t] = nullptr; + } + nrhi::TextureDesc desc; + desc.width = menu_w; + desc.height = menu_h; + desc.format = context.guest_output->format(); + desc.initial_state = nrhi::ResourceState::kRenderTarget; + desc.usage = nrhi::kTextureUsageRenderTarget; + bool ok = true; + for (int t = 0; t < 2 && ok; ++t) { + g_r.menu_blur_tex[t] = device->CreateTexture(desc); + if (g_r.menu_blur_tex[t] == nullptr) { + ok = false; + break; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.menu_blur_srv[t] = device->CreateTextureView(g_r.menu_blur_tex[t], vd); + if (g_r.menu_blur_srv[t] == nullptr) { + ok = false; + break; + } + } + if (ok) { + g_r.menu_blur_w = menu_w; + g_r.menu_blur_h = menu_h; + } else { + REXLOG_ERROR( + "native-scene: menu blur intermediate creation failed; menu blur " + "disabled"); + for (int t = 0; t < 2; ++t) { + if (g_r.menu_blur_srv[t]) device->DestroyDeferred(g_r.menu_blur_srv[t]); + g_r.menu_blur_srv[t] = nullptr; + if (g_r.menu_blur_tex[t]) device->DestroyDeferred(g_r.menu_blur_tex[t]); + g_r.menu_blur_tex[t] = nullptr; + } + g_r.pso_menu_gauss = nullptr; // leaked PSO acceptable on this cold path + } + } + + if ((g_r.outline_mask == nullptr || + g_r.outline_mask_width != context.guest_output_width || + g_r.outline_mask_height != context.guest_output_height) && + g_r.pso_outline_mask != nullptr && g_r.pso_outline_edge != nullptr) { + // Selection-outline mask: single-sample R8 target at output resolution + // (a 1152x640 mask left the contour centerline visibly stairstepped; + // the mask's own rasterization aliasing survives any amount of + // downstream filtering). + if (g_r.outline_mask) { + device->DestroyDeferred(g_r.outline_mask); + g_r.outline_mask = nullptr; + } + nrhi::TextureDesc desc; + desc.width = context.guest_output_width; + desc.height = context.guest_output_height; + desc.format = nrhi::Format::kR8_UNORM; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + g_r.outline_mask = device->CreateTexture(desc); + if (g_r.outline_mask == nullptr) { + REXLOG_ERROR("native-scene: outline mask creation failed; outline disabled"); + g_r.pso_outline_edge = nullptr; + } else { + g_r.outline_mask_width = context.guest_output_width; + g_r.outline_mask_height = context.guest_output_height; + // Re-point the mask view at the recreated texture. + if (g_r.outline_mask_srv) { + device->DestroyDeferred(g_r.outline_mask_srv); + g_r.outline_mask_srv = nullptr; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.outline_mask_srv = device->CreateTextureView(g_r.outline_mask, vd); + g_r.outline_mask_srv_allocated = g_r.outline_mask_srv != nullptr; + } + } + return true; +} + +// 1x1 white diffuse fallback + 1x1x6 mid-gray environment-cube fallback. +bool EnsureFallbackTextures(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + if (!g_r.white.valid) { + // 1x1 white fallback for items without a resolved diffuse texture. + nrhi::TextureDesc desc; + desc.width = 1; + desc.height = 1; + desc.format = nrhi::Format::kR8G8B8A8_UNORM; + desc.initial_state = nrhi::ResourceState::kCopyDest; + g_r.white.texture = device->CreateTexture(desc); + if (g_r.white.texture == nullptr) { + g_r.failed = true; + return false; + } + g_r.white.upload = CreateUploadBuffer(device, 256); + if (!g_r.white.upload) { + g_r.failed = true; + return false; + } + uint8_t* mapping = static_cast(device->Map(g_r.white.upload)); + std::memset(mapping, 0xFF, 4); + device->Unmap(g_r.white.upload); + context.cmd->CopyBufferToTexture(g_r.white.texture, 0, 0, g_r.white.upload, + 0, 256, 1, 1, 1); + context.cmd->Barrier(g_r.white.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.white.srv = device->CreateTextureView(g_r.white.texture, vd); + if (g_r.white.srv == nullptr) { + g_r.failed = true; + return false; + } + g_r.white.valid = true; + } + if (!g_r.white_cube.valid) { + // 1x1x6 mid-gray fallback cube for the water reflection slot (t6): a + // TextureCube SRV must always be bound where the shader declares one. + nrhi::TextureDesc desc; + desc.kind = nrhi::TextureKind::kCube; + desc.width = 1; + desc.height = 1; + desc.format = nrhi::Format::kR8G8B8A8_UNORM; + desc.initial_state = nrhi::ResourceState::kCopyDest; + g_r.white_cube.texture = device->CreateTexture(desc); + if (g_r.white_cube.texture == nullptr) { + g_r.failed = true; + return false; + } + g_r.white_cube.upload = CreateUploadBuffer(device, 512 * 6); + if (!g_r.white_cube.upload) { + g_r.failed = true; + return false; + } + uint8_t* mapping = static_cast(device->Map(g_r.white_cube.upload)); + for (uint32_t f = 0; f < 6; ++f) { + std::memset(mapping + f * 512, 0x80, 4); + } + device->Unmap(g_r.white_cube.upload); + for (uint32_t f = 0; f < 6; ++f) { + context.cmd->CopyBufferToTexture(g_r.white_cube.texture, 0, f, + g_r.white_cube.upload, f * 512, 256, 1, + 1, 1); + } + context.cmd->Barrier(g_r.white_cube.texture, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + nrhi::TextureViewDesc vd; + vd.dimension = nrhi::ViewDimension::kCube; + vd.mip_levels = 1; + g_r.white_cube.srv = device->CreateTextureView(g_r.white_cube.texture, vd); + if (g_r.white_cube.srv == nullptr) { + g_r.failed = true; + return false; + } + g_r.white_cube.valid = true; + } + return true; +} + +// Depth buffer + MSAA color target, rebuilt on output-size change. +bool EnsureOutputSizedTargets(const NativeGuestOutputRenderContext& context) { + nrhi::Device* device = context.device; + const uint32_t width = context.guest_output_width; + const uint32_t height = context.guest_output_height; + const nrhi::Format want_scene_fmt = + g_r.hdr_active ? g_r.hdr_scene_format : nrhi::Format::kUnknown; + if (!g_r.depth || g_r.depth_width != width || g_r.depth_height != height || + g_r.targets_hdr != g_r.hdr_active || + g_r.targets_scene_fmt != want_scene_fmt) { + if (g_r.depth) { + g_r.device->DestroyDeferred(g_r.depth); + g_r.depth = nullptr; + } + // kD32_FLOAT here is the backend's R32_TYPELESS + D32 DSV + R32_FLOAT + // SRV arrangement, not a fully-typed depth format: the photo-editor + // postfx depth pack samples this buffer through an R32_FLOAT SRV, and + // casting an SRV over a fully-typed depth resource is undefined in + // D3D12; in practice those reads returned the 1.0 clear + // value for the whole frame, which saturated the ported DoF CoC + // everywhere (a uniform smear + silhouette halos + scaffold ghosting; + // sim-verified as the cleared-depth failure mode). + nrhi::TextureDesc desc; + desc.width = width; + desc.height = height; + desc.format = nrhi::Format::kD32_FLOAT; + desc.sample_count = g_r.msaa; + desc.usage = nrhi::kTextureUsageDepthStencil; + desc.initial_state = nrhi::ResourceState::kDepthWrite; + desc.clear_depth = 1.0f; + g_r.depth = g_r.device->CreateTexture(desc); + if (g_r.depth == nullptr) { + g_r.failed = true; + return false; + } + g_r.depth_width = width; + g_r.depth_height = height; + + if (g_r.msaa > 1) { + // MSAA color target + its Texture2DMS view for the fullscreen resolve + // pass. Lives in RENDER_TARGET state between frames. Float under HDR + // (the scene writes pre-tonemap linear). + if (g_r.msaa_color) { + g_r.device->DestroyDeferred(g_r.msaa_color); + g_r.msaa_color = nullptr; + } + nrhi::TextureDesc cdesc = desc; + cdesc.format = g_r.hdr_active ? g_r.hdr_scene_format + : context.guest_output->format(); + cdesc.usage = nrhi::kTextureUsageRenderTarget; + cdesc.initial_state = nrhi::ResourceState::kRenderTarget; + cdesc.clear_color[0] = 0.25f; + cdesc.clear_color[1] = 0.35f; + cdesc.clear_color[2] = 0.55f; + cdesc.clear_color[3] = 1.0f; + g_r.msaa_color = g_r.device->CreateTexture(cdesc); + if (g_r.msaa_color == nullptr) { + if (g_r.hdr_active) { + // Float MSAA target unavailable: drop to the classic path instead + // of killing the native renderer (rebuilt next frame). + REXLOG_WARN( + "native-scene: HDR MSAA color target creation failed, " + "falling back to the classic tonemap path"); + g_r.hdr_failed = true; + g_r.depth_width = 0; + return false; + } + g_r.failed = true; + return false; + } + // Re-point the resolve pass's view at the recreated texture. + if (g_r.msaa_srv_slot) { + g_r.device->DestroyDeferred(g_r.msaa_srv_slot); + g_r.msaa_srv_slot = nullptr; + } + nrhi::TextureViewDesc vd; + vd.dimension = nrhi::ViewDimension::k2DMS; + g_r.msaa_srv_slot = device->CreateTextureView(g_r.msaa_color, vd); + g_r.msaa_srv_allocated = g_r.msaa_srv_slot != nullptr; + } + + // 1x float scene plane for the HDR post chain: the MSAA resolve + // destination, or the scene target itself when MSAA is off. Idles in + // RENDER_TARGET state; the bloom/tonemap passes sample it through + // hdr_srv. + if (g_r.hdr_srv != nullptr) { + g_r.device->DestroyDeferred(g_r.hdr_srv); + g_r.hdr_srv = nullptr; + } + if (g_r.hdr_resolved != nullptr) { + g_r.device->DestroyDeferred(g_r.hdr_resolved); + g_r.hdr_resolved = nullptr; + } + if (g_r.hdr_active) { + nrhi::TextureDesc hdesc; + hdesc.width = width; + hdesc.height = height; + hdesc.mip_levels = 1; + hdesc.format = g_r.hdr_scene_format; + hdesc.usage = nrhi::kTextureUsageRenderTarget; + hdesc.initial_state = nrhi::ResourceState::kRenderTarget; + g_r.hdr_resolved = g_r.device->CreateTexture(hdesc); + if (g_r.hdr_resolved != nullptr) { + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.hdr_srv = g_r.device->CreateTextureView(g_r.hdr_resolved, vd); + } + if (g_r.hdr_resolved == nullptr || g_r.hdr_srv == nullptr) { + REXLOG_WARN( + "native-scene: HDR scene plane creation failed, falling back " + "to the classic tonemap path"); + g_r.hdr_failed = true; + g_r.depth_width = 0; + return false; + } + } + g_r.targets_hdr = g_r.hdr_active; + g_r.targets_scene_fmt = want_scene_fmt; + } + return true; +} + +// ---- Screen-space ambient occlusion (ssao.hlsl: GTAO) -------------------- +// Layout + PSOs, built lazily on the first enabled frame and rebuilt when +// the MSAA level (linearize shader variant) or the output format (composite +// target) changes. Failure disables the effect only. +bool EnsureSsaoPipeline(const NativeGuestOutputRenderContext& context) { + if (g_r.ao_failed) { + return false; + } + // Composite target: the float HDR plane (pre-tonemap multiply) or the + // gamma guest output (classic). + const nrhi::Format ao_out_fmt = g_r.hdr_active + ? g_r.hdr_scene_format + : context.guest_output->format(); + if (g_r.pso_ao_linearize != nullptr && g_r.ao_msaa == g_r.msaa && + g_r.ao_rtv_format == ao_out_fmt && g_r.ao_hdr == g_r.hdr_active) { + return true; + } + nrhi::Device* device = context.device; + const auto fail = [&](const char* what) { + REXLOG_ERROR("native-scene: ssao pipeline setup failed ({})", what); + g_r.ao_failed = true; + return false; + }; + if (g_r.ao_layout == nullptr) { + nrhi::BindingLayoutDesc ld; + ld.param_count = 3; + ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 16, + nrhi::Visibility::kAll}; + ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, + nrhi::Visibility::kPixel}; + ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, + nrhi::Visibility::kPixel}; + ld.static_sampler_count = 2; + ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, + nrhi::AddressMode::kClamp, 1}; + ld.static_samplers[1] = {1, nrhi::Filter::kLinear, + nrhi::AddressMode::kClamp, 1}; + ld.allow_input_layout = false; + g_r.ao_layout = device->CreateBindingLayout(ld); + if (g_r.ao_layout == nullptr) { + return fail("binding layout"); + } + } + nrhi::Pipeline** psos[6] = {&g_r.pso_ao_linearize, &g_r.pso_ao_gtao, + &g_r.pso_ao_blur, &g_r.pso_ao_luma, + &g_r.pso_ao_composite, &g_r.pso_ao_debug}; + for (nrhi::Pipeline** p : psos) { + if (*p != nullptr) { + device->DestroyDeferred(*p); + *p = nullptr; + } + } + const bool msaa = g_r.msaa > 1; + const nrhi::ShaderMacro msaa_defs[] = {{"AO_MSAA", "1"}, {nullptr, nullptr}}; + // HDR=1 on the luma pass (tonemap the pre-tonemap scene before the + // protection luma) and the march (linear-space intensity exponent). + const nrhi::ShaderMacro hdr_defs[] = {{"HDR", "1"}, {nullptr, nullptr}}; + nrhi::Shader* vs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "ssao.hlsl", + kSsaoShaderSource, "vs_main", nullptr, "")); + const char* ps_entries[6] = {"ps_linearize", "ps_gtao", "ps_blur", + "ps_luma", "ps_composite", "ps_debug"}; + nrhi::Shader* ps[6] = {}; + bool ok = vs != nullptr; + for (int i = 0; i < 6; ++i) { + const bool msaa_entry = i == 0 && msaa; + const bool hdr_entry = (i == 1 || i == 3) && g_r.hdr_active; + ps[i] = device->CreateShader(MakeShaderDesc( + nrhi::ShaderStage::kPixel, "ssao.hlsl", kSsaoShaderSource, + ps_entries[i], + msaa_entry ? msaa_defs : (hdr_entry ? hdr_defs : nullptr), + msaa_entry ? "AO_MSAA=1" : (hdr_entry ? "HDR=1" : ""))); + ok = ok && ps[i] != nullptr; + } + if (!ok) { + device->DestroyDeferred(vs); + for (nrhi::Shader* s : ps) { + device->DestroyDeferred(s); + } + return fail("shader compile"); + } + nrhi::GraphicsPipelineDesc pso; + pso.layout = g_r.ao_layout; + pso.vs = vs; + pso.cull = nrhi::CullMode::kNone; + pso.sample_count = 1; + pso.ps = ps[0]; + pso.rtv_format = nrhi::Format::kR32_FLOAT; + g_r.pso_ao_linearize = device->CreateGraphicsPipeline(pso); + pso.ps = ps[1]; + pso.rtv_format = nrhi::Format::kR8_UNORM; + g_r.pso_ao_gtao = device->CreateGraphicsPipeline(pso); + pso.ps = ps[2]; + g_r.pso_ao_blur = device->CreateGraphicsPipeline(pso); + pso.ps = ps[3]; + g_r.pso_ao_luma = device->CreateGraphicsPipeline(pso); + // Composite: dst.rgb * src.a; the AO plane carries the finished + // multiplier, and the RGB-only write mask keeps the output's alpha + // untouched. + pso.ps = ps[4]; + pso.rtv_format = ao_out_fmt; + pso.blend.enable = true; + pso.blend.src = nrhi::BlendFactor::kZero; + pso.blend.dst = nrhi::BlendFactor::kSrcAlpha; + pso.blend.op = nrhi::BlendOp::kAdd; + pso.blend.src_alpha = nrhi::BlendFactor::kZero; + pso.blend.dst_alpha = nrhi::BlendFactor::kOne; + pso.blend.op_alpha = nrhi::BlendOp::kAdd; + pso.blend.write_mask = 0x7; + g_r.pso_ao_composite = device->CreateGraphicsPipeline(pso); + pso.blend = {}; + pso.ps = ps[5]; + g_r.pso_ao_debug = device->CreateGraphicsPipeline(pso); + device->DestroyDeferred(vs); + for (nrhi::Shader* s : ps) { + device->DestroyDeferred(s); + } + if (g_r.pso_ao_linearize == nullptr || g_r.pso_ao_gtao == nullptr || + g_r.pso_ao_blur == nullptr || g_r.pso_ao_luma == nullptr || + g_r.pso_ao_composite == nullptr || g_r.pso_ao_debug == nullptr) { + return fail("pso"); + } + g_r.ao_msaa = g_r.msaa; + g_r.ao_rtv_format = ao_out_fmt; + g_r.ao_hdr = g_r.hdr_active; + REXLOG_INFO("native-scene: ssao pipeline created (MSAA x{}, {})", g_r.msaa, + g_r.hdr_active ? "HDR" : "classic"); + return true; +} + +// GTAO chain over the resolved scene (see ssao.hlsl): linearize depth, +// horizon-based AO, depth-aware separable blur, multiply-composite onto the +// guest output (which must be in RENDER_TARGET state). Returns true when it +// drew; binding layout/state was changed and the caller restores the main +// pass's bindings. +bool ApplySsaoPass(const NativeGuestOutputRenderContext& context, + nrhi::Cmd* cmd, const FrameScene& scene, + const nrhi::Viewport& viewport, const nrhi::Rect& scissor) { + // The published projection must be the live perspective matrix (row-vector + // m23 == 1; all zeros until the first publish). + const float* pr = scene.proj; + if (pr[11] != 1.0f || pr[0] == 0.0f || pr[5] == 0.0f || pr[14] == 0.0f) { + return false; + } + if (!EnsureSsaoPipeline(context)) { + return false; + } + // The luma pass samples the scene through this view; the composite writes + // the same plane (HDR: the float scene plane pre-tonemap, classic: the + // guest output). + const bool hdr = g_r.hdr_active && g_r.hdr_resolved != nullptr; + nrhi::Texture* const scene_plane = + hdr ? g_r.hdr_resolved : context.guest_output; + nrhi::TextureView* const scene_srv = + hdr ? g_r.hdr_srv : g_r.output_srv_slot; + if (scene_srv == nullptr) { + return false; + } + nrhi::Device* device = context.device; + const uint32_t width = context.guest_output_width; + const uint32_t height = context.guest_output_height; + // AO raster: half the output resolution by default: for a low-frequency + // term the depth-aware blur + bilinear upsample hide it, at 1/4 the march + // cost (full-res GTAO at 4K x 300+ uncapped fps pegged the GPU). The + // linearized depth stays full res: the march samples it at arbitrary UVs + // either way, and it keeps silhouette depth exact. + const bool ao_full = + REXCVAR_GET(skate3_native_render_scene_ssao_full_res); + const uint32_t aw = ao_full ? width : std::max(1u, (width + 1) / 2); + const uint32_t ah = ao_full ? height : std::max(1u, (height + 1) / 2); + // Intermediates (steady state RENDER_TARGET), rebuilt on either size. + if (g_r.ao_width != aw || g_r.ao_height != ah || + g_r.ao_lin_width != width || g_r.ao_lin_height != height || + g_r.ao_lin_depth == nullptr || g_r.ao_luma == nullptr) { + nrhi::Texture** res[4] = {&g_r.ao_lin_depth, &g_r.ao_tex[0], + &g_r.ao_tex[1], &g_r.ao_luma}; + nrhi::TextureView** views[4] = {&g_r.ao_lin_srv, &g_r.ao_srv[0], + &g_r.ao_srv[1], &g_r.ao_luma_srv}; + for (int i = 0; i < 4; ++i) { + if (*views[i] != nullptr) { + device->DestroyDeferred(*views[i]); + *views[i] = nullptr; + } + if (*res[i] != nullptr) { + device->DestroyDeferred(*res[i]); + *res[i] = nullptr; + } + nrhi::TextureDesc desc; + desc.width = i == 0 ? width : aw; + desc.height = i == 0 ? height : ah; + desc.mip_levels = 1; + desc.format = + i == 0 ? nrhi::Format::kR32_FLOAT : nrhi::Format::kR8_UNORM; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + *res[i] = device->CreateTexture(desc); + if (*res[i] == nullptr) { + g_r.ao_failed = true; + return false; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + *views[i] = device->CreateTextureView(*res[i], vd); + if (*views[i] == nullptr) { + g_r.ao_failed = true; + return false; + } + } + g_r.ao_width = aw; + g_r.ao_height = ah; + g_r.ao_lin_width = width; + g_r.ao_lin_height = height; + } + // Scene-depth SRV, re-pointed when the depth buffer is rebuilt (resize / + // MSAA change). D32 textures view as R32_FLOAT automatically. + if (g_r.ao_depth_srv == nullptr || g_r.ao_depth_srv_of != g_r.depth) { + if (g_r.ao_depth_srv != nullptr) { + device->DestroyDeferred(g_r.ao_depth_srv); + g_r.ao_depth_srv = nullptr; + } + nrhi::TextureViewDesc sd; + if (g_r.msaa > 1) { + sd.dimension = nrhi::ViewDimension::k2DMS; + } else { + sd.dimension = nrhi::ViewDimension::k2D; + sd.mip_levels = 1; + } + g_r.ao_depth_srv = device->CreateTextureView(g_r.depth, sd); + if (g_r.ao_depth_srv == nullptr) { + g_r.ao_failed = true; + return false; + } + g_r.ao_depth_srv_of = g_r.depth; + } + + // b0 rows (see ssao.hlsl cbuffer C): size, projection, tuning, blur axis. + // size = the AO raster the march/blur passes run at; every shader use is + // UV-relative, so half res needs no shader-side changes. + float c[16]; + c[0] = float(aw); + c[1] = float(ah); + c[2] = 1.0f / float(aw); + c[3] = 1.0f / float(ah); + c[4] = std::fabs(pr[0]); // |m00|: the guest projection's x scale is + // negative; AO is mirror-invariant + c[5] = std::fabs(pr[5]); // |m11| + c[6] = pr[10]; // m22 + c[7] = pr[14]; // m32 + c[8] = float(REXCVAR_GET(skate3_native_render_scene_ssao_radius)); + c[9] = float(REXCVAR_GET(skate3_native_render_scene_ssao_intensity)); + // Distance fade 150..250 view units: fog owns the far field and the + // horizon-search precision degrades with depth anyway. + c[10] = 150.0f; + c[11] = 1.0f / 100.0f; + c[12] = 1.0f; // blur axis (horizontal first) + c[13] = 0.0f; + c[14] = float(REXCVAR_GET(skate3_native_render_scene_ssao_luma_protect)); + c[15] = 0.0f; + + const nrhi::Viewport ao_vp{0.0f, 0.0f, float(aw), float(ah), 0.0f, 1.0f}; + const nrhi::Rect ao_sc{0, 0, int32_t(aw), int32_t(ah)}; + + cmd->SetBindingLayout(g_r.ao_layout); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetRootConstants(0, 16, c, 0); + + // 1) Scene depth -> linear view Z (sample 0 when MSAA). Full res. + cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetRenderTargets(g_r.ao_lin_depth, nullptr); + cmd->SetPipeline(g_r.pso_ao_linearize); + cmd->SetTexture(1, g_r.ao_depth_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kDepthWrite); + cmd->Barrier(g_r.ao_lin_depth, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + + // 1b) Scene luminance -> ao_luma (AO raster): the sun-lit protection + // mask. The scene plane pauses as a sampled source for one draw. + cmd->Barrier(scene_plane, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetViewport(ao_vp); + cmd->SetScissor(ao_sc); + cmd->SetRenderTargets(g_r.ao_luma, nullptr); + cmd->SetPipeline(g_r.pso_ao_luma); + cmd->SetTexture(1, scene_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.ao_luma, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->Barrier(scene_plane, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + + // 2) GTAO -> ao_tex[0], at the AO raster size. + cmd->SetRenderTargets(g_r.ao_tex[0], nullptr); + cmd->SetPipeline(g_r.pso_ao_gtao); + cmd->SetTexture(1, g_r.ao_lin_srv); + cmd->SetTexture(2, g_r.ao_luma_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + + // 3) Depth-aware blur, horizontal -> ao_tex[1]. + cmd->SetRenderTargets(g_r.ao_tex[1], nullptr); + cmd->SetPipeline(g_r.pso_ao_blur); + cmd->SetTexture(1, g_r.ao_srv[0]); + cmd->SetTexture(2, g_r.ao_lin_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.ao_tex[1], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + + // 4) Vertical -> ao_tex[0]. + const float dir_v[2] = {0.0f, 1.0f}; + cmd->SetRootConstants(0, 2, dir_v, 12); + cmd->SetRenderTargets(g_r.ao_tex[0], nullptr); + cmd->SetTexture(1, g_r.ao_srv[1]); + cmd->SetTexture(2, g_r.ao_lin_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + + // 5) Consume the finished multiplier plane. Classic: a full-res composite + // (or debug replace) draw onto the scene plane, s1 bilinearly + // upsampling. HDR: no draw at all: ps_tonemap samples the plane at t2 + // and multiplies pre-tonemap (identical math, one less full-res pass); + // the plane stays in PIXEL_SHADER_RESOURCE for ApplyHdrPost, which + // restores it. + if (hdr) { + g_r.ao_plane_in_psr = true; + } else { + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetRenderTargets(scene_plane, nullptr); + cmd->SetPipeline(REXCVAR_GET(skate3_native_render_scene_ssao_debug) + ? g_r.pso_ao_debug + : g_r.pso_ao_composite); + cmd->SetTexture(1, g_r.ao_srv[0]); + cmd->Draw(3, 0); + cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + } + + // Restore intermediate steady states. + cmd->Barrier(g_r.ao_tex[1], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->Barrier(g_r.ao_lin_depth, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->Barrier(g_r.ao_luma, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + return true; +} + +// ---- Screen-space reflections (ssr.hlsl + scene.hlsl ps_refl_gbuf) -------- +// Layout + PSOs, built lazily on the first SSR frame and rebuilt when the +// MSAA level (linearize variant) or the scene float format (composite +// target) changes. Failure disables the effect only, never g_r.failed. +bool EnsureSsrPipeline(const NativeGuestOutputRenderContext& context) { + if (g_r.ssr_failed || !g_r.hdr_active) { + return false; + } + if (g_r.pso_ssr_march != nullptr && g_r.ssr_msaa == g_r.msaa && + g_r.ssr_scene_fmt == g_r.hdr_scene_format) { + return true; + } + nrhi::Device* device = context.device; + const auto fail = [&](const char* what) { + REXLOG_ERROR("native-scene: ssr pipeline setup failed ({})", what); + g_r.ssr_failed = true; + return false; + }; + if (g_r.ssr_layout == nullptr) { + // The SSAO layout shape with a wider constant block (the march needs + // the world->view rotation rows): root constants b0 (32 floats) + three + // single-texture tables + point/linear clamp samplers. + nrhi::BindingLayoutDesc ld; + ld.param_count = 4; + ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 32, + nrhi::Visibility::kAll}; + ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, + nrhi::Visibility::kPixel}; + ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, + nrhi::Visibility::kPixel}; + ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1, + nrhi::Visibility::kPixel}; + ld.static_sampler_count = 2; + ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, + nrhi::AddressMode::kClamp, 1}; + ld.static_samplers[1] = {1, nrhi::Filter::kLinear, + nrhi::AddressMode::kClamp, 1}; + ld.allow_input_layout = false; + g_r.ssr_layout = device->CreateBindingLayout(ld); + if (g_r.ssr_layout == nullptr) { + return fail("binding layout"); + } + } + for (nrhi::Pipeline** p : {&g_r.pso_ssr_gbuf, &g_r.pso_ssr_linearize, + &g_r.pso_ssr_march, &g_r.pso_ssr_composite}) { + if (*p != nullptr) { + device->DestroyDeferred(*p); + *p = nullptr; + } + } + const bool msaa = g_r.msaa > 1; + const nrhi::ShaderMacro msaa_defs[] = {{"SSR_MSAA", "1"}, + {nullptr, nullptr}}; + nrhi::Shader* scene_vs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "scene.hlsl", kShaderSource, + "vs_main", nullptr, "")); + nrhi::Shader* ps_gbuf = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, + "ps_refl_gbuf", nullptr, "")); + nrhi::Shader* vs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "ssr.hlsl", kSsrShaderSource, + "vs_main", nullptr, "")); + nrhi::Shader* ps_lin = device->CreateShader(MakeShaderDesc( + nrhi::ShaderStage::kPixel, "ssr.hlsl", kSsrShaderSource, "ps_linearize", + msaa ? msaa_defs : nullptr, msaa ? "SSR_MSAA=1" : "")); + nrhi::Shader* ps_march = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "ssr.hlsl", kSsrShaderSource, + "ps_march", nullptr, "")); + nrhi::Shader* ps_comp = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "ssr.hlsl", kSsrShaderSource, + "ps_composite", nullptr, "")); + const bool shaders_ok = scene_vs != nullptr && ps_gbuf != nullptr && + vs != nullptr && ps_lin != nullptr && + ps_march != nullptr && ps_comp != nullptr; + if (!shaders_ok) { + for (nrhi::Shader* s : + {scene_vs, ps_gbuf, vs, ps_lin, ps_march, ps_comp}) { + device->DestroyDeferred(s); + } + return fail("shader compile"); + } + // Reflection G-buffer pass: the scene VS + input layout under the MAIN + // binding layout (the caller re-stages each reflective item's main-pass + // root constants / textures), half-res single-sample float target with + // its OWN half-res depth (test+write: the MSAA scene depth cannot pair + // with a 1x RTV) so the nearest reflective surface owns each texel; + // occlusion by non-reflective geometry is then resolved in the march by + // comparing the stored G-buffer depth against the scene linear depth. + { + nrhi::GraphicsPipelineDesc pso; + pso.layout = g_r.layout; + pso.vs = scene_vs; + pso.ps = ps_gbuf; + pso.input_elements = kSceneInputLayout; + pso.input_element_count = 7; + pso.vertex_stride = 56; + pso.cull = nrhi::CullMode::kNone; + pso.sample_count = 1; + pso.depth_clip = true; + pso.depth.test_enable = true; + pso.depth.write_enable = true; + pso.depth.func = nrhi::CompareFunc::kLess; + pso.dsv_format = nrhi::Format::kD32_FLOAT; + pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT; + g_r.pso_ssr_gbuf = device->CreateGraphicsPipeline(pso); + } + nrhi::GraphicsPipelineDesc pso; + pso.layout = g_r.ssr_layout; + pso.vs = vs; + pso.cull = nrhi::CullMode::kNone; + pso.sample_count = 1; + pso.ps = ps_lin; + pso.rtv_format = nrhi::Format::kR32_FLOAT; + g_r.pso_ssr_linearize = device->CreateGraphicsPipeline(pso); + pso.ps = ps_march; + pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT; + g_r.pso_ssr_march = device->CreateGraphicsPipeline(pso); + // Composite: straight src-alpha blend onto the float scene plane (the + // shader outputs confidence x reflectivity x intensity in alpha), RGB + // write mask: the plane's alpha stays untouched. + pso.ps = ps_comp; + pso.rtv_format = g_r.hdr_scene_format; + pso.blend.enable = true; + pso.blend.src = nrhi::BlendFactor::kSrcAlpha; + pso.blend.dst = nrhi::BlendFactor::kInvSrcAlpha; + pso.blend.op = nrhi::BlendOp::kAdd; + pso.blend.src_alpha = nrhi::BlendFactor::kZero; + pso.blend.dst_alpha = nrhi::BlendFactor::kOne; + pso.blend.op_alpha = nrhi::BlendOp::kAdd; + pso.blend.write_mask = 0x7; + g_r.pso_ssr_composite = device->CreateGraphicsPipeline(pso); + for (nrhi::Shader* s : {scene_vs, ps_gbuf, vs, ps_lin, ps_march, ps_comp}) { + device->DestroyDeferred(s); + } + if (g_r.pso_ssr_gbuf == nullptr || g_r.pso_ssr_linearize == nullptr || + g_r.pso_ssr_march == nullptr || g_r.pso_ssr_composite == nullptr) { + return fail("pso"); + } + g_r.ssr_msaa = g_r.msaa; + g_r.ssr_scene_fmt = g_r.hdr_scene_format; + REXLOG_INFO("native-scene: ssr pipeline created (MSAA x{})", g_r.msaa); + return true; +} + +// Half-res SSR intermediates (reflection G-buffer + march output), steady +// state RENDER_TARGET, rebuilt on resize. +bool EnsureSsrTargets(const NativeGuestOutputRenderContext& context) { + const uint32_t sw = std::max(1u, (context.guest_output_width + 1) / 2); + const uint32_t sh = std::max(1u, (context.guest_output_height + 1) / 2); + if (g_r.ssr_width == sw && g_r.ssr_height == sh && g_r.ssr_gbuf != nullptr && + g_r.ssr_tex != nullptr) { + return true; + } + nrhi::Device* device = context.device; + if (g_r.ssr_gbuf_depth != nullptr) { + device->DestroyDeferred(g_r.ssr_gbuf_depth); + g_r.ssr_gbuf_depth = nullptr; + } + { + nrhi::TextureDesc dd; + dd.width = sw; + dd.height = sh; + dd.format = nrhi::Format::kD32_FLOAT; + dd.usage = nrhi::kTextureUsageDepthStencil; + dd.initial_state = nrhi::ResourceState::kDepthWrite; + dd.clear_depth = 1.0f; + g_r.ssr_gbuf_depth = device->CreateTexture(dd); + if (g_r.ssr_gbuf_depth == nullptr) { + g_r.ssr_failed = true; + return false; + } + } + nrhi::Texture** res[2] = {&g_r.ssr_gbuf, &g_r.ssr_tex}; + nrhi::TextureView** views[2] = {&g_r.ssr_gbuf_srv, &g_r.ssr_srv}; + for (int i = 0; i < 2; ++i) { + if (*views[i] != nullptr) { + device->DestroyDeferred(*views[i]); + *views[i] = nullptr; + } + if (*res[i] != nullptr) { + device->DestroyDeferred(*res[i]); + *res[i] = nullptr; + } + nrhi::TextureDesc desc; + desc.width = sw; + desc.height = sh; + desc.mip_levels = 1; + desc.format = nrhi::Format::kR16G16B16A16_FLOAT; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + *res[i] = device->CreateTexture(desc); + if (*res[i] == nullptr) { + g_r.ssr_failed = true; + return false; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + *views[i] = device->CreateTextureView(*res[i], vd); + if (*views[i] == nullptr) { + g_r.ssr_failed = true; + return false; + } + } + g_r.ssr_width = sw; + g_r.ssr_height = sh; + return true; +} + +// SSR march + composite (see ssr.hlsl): consumes the reflection G-buffer +// the in-pass draw left in PIXEL_SHADER_RESOURCE, marches the full-res +// linear depth (reusing the SSAO linearize plane when AO ran this frame) +// while sampling the pre-tonemap HDR plane, and blends the result back onto +// it. Returns true when it drew; the binding layout changed and the +// caller's post_ran block restores the main-pass bindings. +bool ApplySsrPass(const NativeGuestOutputRenderContext& context, + nrhi::Cmd* cmd, const FrameScene& scene, + const nrhi::Viewport& viewport, const nrhi::Rect& scissor, + bool lin_depth_ready) { + if (!g_r.ssr_gbuf_ready) { + return false; + } + // Consume the handoff up front: every exit below must leave the G-buffer + // back in its RENDER_TARGET steady state. + g_r.ssr_gbuf_ready = false; + nrhi::Device* device = context.device; + const uint32_t width = context.guest_output_width; + const uint32_t height = context.guest_output_height; + const auto bail = [&] { + cmd->Barrier(g_r.ssr_gbuf, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + return false; + }; + // Full-res linear view-Z: the SSAO plane when AO linearized this frame + // (it idles back in RENDER_TARGET after the AO pass), else the own + // linearize below. + lin_depth_ready = lin_depth_ready && g_r.ao_lin_depth != nullptr && + g_r.ao_lin_srv != nullptr && g_r.ao_lin_width == width && + g_r.ao_lin_height == height; + if (!lin_depth_ready) { + if (g_r.ssr_lin_depth == nullptr || g_r.ssr_lin_width != width || + g_r.ssr_lin_height != height) { + if (g_r.ssr_lin_srv != nullptr) { + device->DestroyDeferred(g_r.ssr_lin_srv); + g_r.ssr_lin_srv = nullptr; + } + if (g_r.ssr_lin_depth != nullptr) { + device->DestroyDeferred(g_r.ssr_lin_depth); + g_r.ssr_lin_depth = nullptr; + } + nrhi::TextureDesc desc; + desc.width = width; + desc.height = height; + desc.mip_levels = 1; + desc.format = nrhi::Format::kR32_FLOAT; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + g_r.ssr_lin_depth = device->CreateTexture(desc); + if (g_r.ssr_lin_depth == nullptr) { + g_r.ssr_failed = true; + return bail(); + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.ssr_lin_srv = device->CreateTextureView(g_r.ssr_lin_depth, vd); + if (g_r.ssr_lin_srv == nullptr) { + g_r.ssr_failed = true; + return bail(); + } + g_r.ssr_lin_width = width; + g_r.ssr_lin_height = height; + } + // Scene-depth SRV, re-pointed when the depth buffer is rebuilt. + if (g_r.ssr_depth_srv == nullptr || g_r.ssr_depth_srv_of != g_r.depth) { + if (g_r.ssr_depth_srv != nullptr) { + device->DestroyDeferred(g_r.ssr_depth_srv); + g_r.ssr_depth_srv = nullptr; + } + nrhi::TextureViewDesc sd; + if (g_r.msaa > 1) { + sd.dimension = nrhi::ViewDimension::k2DMS; + } else { + sd.dimension = nrhi::ViewDimension::k2D; + sd.mip_levels = 1; + } + g_r.ssr_depth_srv = device->CreateTextureView(g_r.depth, sd); + if (g_r.ssr_depth_srv == nullptr) { + g_r.ssr_failed = true; + return bail(); + } + g_r.ssr_depth_srv_of = g_r.depth; + } + } + nrhi::Texture* const lin_tex = + lin_depth_ready ? g_r.ao_lin_depth : g_r.ssr_lin_depth; + nrhi::TextureView* const lin_srv = + lin_depth_ready ? g_r.ao_lin_srv : g_r.ssr_lin_srv; + + // b0 rows (see ssr.hlsl cbuffer C): dest size, projection, the world -> + // "AO view space" rotation, tuning. view = view_proj * proj^-1 (row- + // vector); the projection's per-axis signs fold into the rotation so + // normals land in the same mirrored space ViewPos reconstructs positions + // in; reflection directions are mirror-SENSITIVE, unlike every AO term. + const float* pr = scene.proj; + float c[32] = {}; + c[0] = float(g_r.ssr_width); + c[1] = float(g_r.ssr_height); + c[2] = 1.0f / float(g_r.ssr_width); + c[3] = 1.0f / float(g_r.ssr_height); + c[4] = std::fabs(pr[0]); + c[5] = std::fabs(pr[5]); + c[6] = pr[10]; + c[7] = pr[14]; + const float sxs = pr[0] < 0.0f ? -1.0f : 1.0f; + const float sys = pr[5] < 0.0f ? -1.0f : 1.0f; + const float inv00 = 1.0f / pr[0]; + const float inv11 = 1.0f / pr[5]; + for (int r = 0; r < 3; ++r) { + // proj^-1 columns: x/y scale by 1/m00 / 1/m11, view z = clip w (the + // view_proj row's w column); the AO-space axis signs multiply in. + c[8 + r * 4 + 0] = scene.view_proj[r * 4 + 0] * inv00 * sxs; + c[8 + r * 4 + 1] = scene.view_proj[r * 4 + 1] * inv11 * sys; + c[8 + r * 4 + 2] = scene.view_proj[r * 4 + 3]; + } + c[20] = float(REXCVAR_GET(skate3_native_render_scene_ssr_steps)); + c[21] = float(REXCVAR_GET(skate3_native_render_scene_ssr_thickness)); + c[22] = float(REXCVAR_GET(skate3_native_render_scene_ssr_intensity)); + c[23] = float(REXCVAR_GET(skate3_native_render_scene_ssr_debug)); + c[24] = 250.0f; // max ray distance (view units; the screen clip governs) + + cmd->SetBindingLayout(g_r.ssr_layout); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + cmd->SetRootConstants(0, 32, c, 0); + + // 1) Own linearize when AO did not produce the plane this frame. + if (!lin_depth_ready) { + cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetRenderTargets(g_r.ssr_lin_depth, nullptr); + cmd->SetPipeline(g_r.pso_ssr_linearize); + cmd->SetTexture(1, g_r.ssr_depth_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kDepthWrite); + } + cmd->Barrier(lin_tex, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + + // 2) March (half res): the HDR plane pauses as a sampled source. + cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + const nrhi::Viewport ssr_vp{0.0f, 0.0f, float(g_r.ssr_width), + float(g_r.ssr_height), 0.0f, 1.0f}; + const nrhi::Rect ssr_sc{0, 0, int32_t(g_r.ssr_width), + int32_t(g_r.ssr_height)}; + cmd->SetViewport(ssr_vp); + cmd->SetScissor(ssr_sc); + cmd->SetRenderTargets(g_r.ssr_tex, nullptr); + cmd->SetPipeline(g_r.pso_ssr_march); + cmd->SetTexture(1, g_r.ssr_gbuf_srv); + cmd->SetTexture(2, lin_srv); + cmd->SetTexture(3, g_r.hdr_srv); + cmd->Draw(3, 0); + cmd->Barrier(g_r.ssr_tex, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + + // 3) Composite (full res, src-alpha blend onto the HDR plane). Dest-size + // constants switch to the full raster; the rest carries over. + c[0] = float(width); + c[1] = float(height); + c[2] = 1.0f / float(width); + c[3] = 1.0f / float(height); + cmd->SetRootConstants(0, 4, c, 0); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetRenderTargets(g_r.hdr_resolved, nullptr); + cmd->SetPipeline(g_r.pso_ssr_composite); + cmd->SetTexture(1, g_r.ssr_srv); + cmd->SetTexture(2, g_r.ssr_gbuf_srv); + cmd->SetTexture(3, lin_srv); + cmd->Draw(3, 0); + + // Restore intermediate steady states. + cmd->Barrier(g_r.ssr_tex, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->Barrier(lin_tex, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->Barrier(g_r.ssr_gbuf, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + return true; +} + +// ---- HDR post chain (hdr.hlsl: bloom pyramid + the extracted tonemap) ---- +// Layout + PSOs, built when the HDR path activates and rebuilt when the +// guest output format (the tonemap target) changes. Failure falls back to +// the classic in-material tonemap instead of killing the native renderer. +bool EnsureHdrPipeline(const NativeGuestOutputRenderContext& context) { + if (!g_r.hdr_active || g_r.hdr_failed) { + return false; + } + if (g_r.pso_tonemap != nullptr && + g_r.hdr_pso_out_format == context.guest_output->format()) { + return true; + } + nrhi::Device* device = context.device; + const auto fail = [&](const char* what) { + REXLOG_WARN( + "native-scene: HDR pipeline setup failed ({}), falling back to the " + "classic tonemap path", + what); + g_r.hdr_failed = true; + return false; + }; + if (g_r.hdr_layout == nullptr) { + // The SSAO layout shape plus a third single-texture table (t2 = the + // fused AO multiplier plane): root constants b0 + three single-texture + // tables + point/linear clamp samplers (hdr.hlsl's frozen Vulkan plan). + nrhi::BindingLayoutDesc ld; + ld.param_count = 4; + ld.params[0] = {nrhi::BindingParamKind::kConstants, /*b*/ 0, 16, + nrhi::Visibility::kAll}; + ld.params[1] = {nrhi::BindingParamKind::kTextureTable, /*t*/ 0, 1, + nrhi::Visibility::kPixel}; + ld.params[2] = {nrhi::BindingParamKind::kTextureTable, 1, 1, + nrhi::Visibility::kPixel}; + ld.params[3] = {nrhi::BindingParamKind::kTextureTable, 2, 1, + nrhi::Visibility::kPixel}; + ld.static_sampler_count = 2; + ld.static_samplers[0] = {/*s*/ 0, nrhi::Filter::kPoint, + nrhi::AddressMode::kClamp, 1}; + ld.static_samplers[1] = {1, nrhi::Filter::kLinear, + nrhi::AddressMode::kClamp, 1}; + ld.allow_input_layout = false; + g_r.hdr_layout = device->CreateBindingLayout(ld); + if (g_r.hdr_layout == nullptr) { + return fail("binding layout"); + } + } + for (nrhi::Pipeline** p : {&g_r.pso_bloom_first, &g_r.pso_bloom_down, + &g_r.pso_bloom_up, &g_r.pso_tonemap}) { + if (*p != nullptr) { + device->DestroyDeferred(*p); + *p = nullptr; + } + } + const nrhi::ShaderMacro first_defs[] = {{"BLOOM_FIRST", "1"}, + {nullptr, nullptr}}; + nrhi::Shader* vs = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kVertex, "hdr.hlsl", kHdrShaderSource, + "vs_main", nullptr, "")); + nrhi::Shader* ps_first = device->CreateShader(MakeShaderDesc( + nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, + "ps_bloom_down", first_defs, "BLOOM_FIRST=1")); + nrhi::Shader* ps_down = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, + "ps_bloom_down", nullptr, "")); + nrhi::Shader* ps_up = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, + "ps_bloom_up", nullptr, "")); + nrhi::Shader* ps_tone = device->CreateShader( + MakeShaderDesc(nrhi::ShaderStage::kPixel, "hdr.hlsl", kHdrShaderSource, + "ps_tonemap", nullptr, "")); + const bool shaders_ok = vs != nullptr && ps_first != nullptr && + ps_down != nullptr && ps_up != nullptr && + ps_tone != nullptr; + if (!shaders_ok) { + for (nrhi::Shader* s : {vs, ps_first, ps_down, ps_up, ps_tone}) { + device->DestroyDeferred(s); + } + return fail("shader compile"); + } + nrhi::GraphicsPipelineDesc pso; + pso.layout = g_r.hdr_layout; + pso.vs = vs; + pso.cull = nrhi::CullMode::kNone; + pso.sample_count = 1; + pso.rtv_format = nrhi::Format::kR16G16B16A16_FLOAT; + pso.ps = ps_first; + g_r.pso_bloom_first = device->CreateGraphicsPipeline(pso); + pso.ps = ps_down; + g_r.pso_bloom_down = device->CreateGraphicsPipeline(pso); + // Progressive upsample: additive ONE/ONE onto the next-larger level. + pso.ps = ps_up; + pso.blend.enable = true; + pso.blend.src = nrhi::BlendFactor::kOne; + pso.blend.dst = nrhi::BlendFactor::kOne; + pso.blend.op = nrhi::BlendOp::kAdd; + pso.blend.src_alpha = nrhi::BlendFactor::kOne; + pso.blend.dst_alpha = nrhi::BlendFactor::kOne; + pso.blend.op_alpha = nrhi::BlendOp::kAdd; + g_r.pso_bloom_up = device->CreateGraphicsPipeline(pso); + pso.blend = {}; + pso.ps = ps_tone; + pso.rtv_format = context.guest_output->format(); + g_r.pso_tonemap = device->CreateGraphicsPipeline(pso); + for (nrhi::Shader* s : {vs, ps_first, ps_down, ps_up, ps_tone}) { + device->DestroyDeferred(s); + } + if (g_r.pso_bloom_first == nullptr || g_r.pso_bloom_down == nullptr || + g_r.pso_bloom_up == nullptr || g_r.pso_tonemap == nullptr) { + return fail("pso"); + } + g_r.hdr_pso_out_format = context.guest_output->format(); + REXLOG_INFO("native-scene: HDR post pipeline created"); + return true; +} + +// Bloom pyramid + tonemap over the float scene plane into the guest output. +// Runs after the AO composite (bloom sees the occluded scene). Transitions +// the guest output kGuestOutput -> kRenderTarget (the state every later +// consumer expects); the caller restores the main binding layout after. +void ApplyHdrPost(const NativeGuestOutputRenderContext& context, + nrhi::Cmd* cmd, const nrhi::Viewport& viewport, + const nrhi::Rect& scissor, bool loading_native) { + nrhi::Device* device = context.device; + const uint32_t width = context.guest_output_width; + const uint32_t height = context.guest_output_height; + // Bloom chain intermediates: QUARTER res halving down to the level cap or + // an 8-px floor, RGBA16F, steady state RENDER_TARGET. Quarter start = + // 1/4 the pyramid cost of a half-res chain; the extraction pass widens + // its 13-tap spread (p1.x = 2) so the 4x reduction still integrates the + // whole source block. Creation failure only loses bloom (the tonemap + // still runs). + if (g_r.bloom_base_w != width || g_r.bloom_base_h != height || + (g_r.bloom_levels > 0 && g_r.bloom_tex[0] == nullptr)) { + for (uint32_t i = 0; i < RendererState::kBloomMaxLevels; ++i) { + if (g_r.bloom_srv[i] != nullptr) { + device->DestroyDeferred(g_r.bloom_srv[i]); + g_r.bloom_srv[i] = nullptr; + } + if (g_r.bloom_tex[i] != nullptr) { + device->DestroyDeferred(g_r.bloom_tex[i]); + g_r.bloom_tex[i] = nullptr; + } + } + g_r.bloom_levels = 0; + uint32_t w = std::max(1u, (width + 1) / 2); + uint32_t h = std::max(1u, (height + 1) / 2); + for (uint32_t i = 0; i < RendererState::kBloomMaxLevels; ++i) { + w = std::max(1u, (w + 1) / 2); + h = std::max(1u, (h + 1) / 2); + if (w < 8 || h < 8) { + break; + } + nrhi::TextureDesc desc; + desc.width = w; + desc.height = h; + desc.mip_levels = 1; + desc.format = nrhi::Format::kR16G16B16A16_FLOAT; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + g_r.bloom_tex[i] = device->CreateTexture(desc); + if (g_r.bloom_tex[i] == nullptr) { + break; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.bloom_srv[i] = device->CreateTextureView(g_r.bloom_tex[i], vd); + if (g_r.bloom_srv[i] == nullptr) { + device->DestroyDeferred(g_r.bloom_tex[i]); + g_r.bloom_tex[i] = nullptr; + break; + } + g_r.bloom_w[i] = w; + g_r.bloom_h[i] = h; + ++g_r.bloom_levels; + } + g_r.bloom_base_w = width; + g_r.bloom_base_h = height; + if (g_r.bloom_levels == 0) { + REXLOG_WARN("native-scene: bloom chain allocation failed (bloom off)"); + } + } + const bool bloom = REXCVAR_GET(skate3_native_render_scene_bloom) && + !loading_native && g_r.bloom_levels > 0 && + g_r.pso_bloom_first != nullptr && + g_r.pso_bloom_down != nullptr && + g_r.pso_bloom_up != nullptr; + + cmd->SetBindingLayout(g_r.hdr_layout); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + // The scene plane is the sampled source for the whole chain. + cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + + float c[16]; + const auto set_consts = [&](uint32_t dw, uint32_t dh, uint32_t sw, + uint32_t sh, float intensity, float tap_scale) { + c[0] = float(dw); + c[1] = float(dh); + c[2] = 1.0f / float(dw); + c[3] = 1.0f / float(dh); + c[4] = float(sw); + c[5] = float(sh); + c[6] = 1.0f / float(sw); + c[7] = 1.0f / float(sh); + c[8] = float(REXCVAR_GET(skate3_native_render_scene_bloom_threshold)); + c[9] = float(REXCVAR_GET(skate3_native_render_scene_bloom_knee)); + c[10] = intensity; + // Debug view (ps_tonemap p0.w); the classic ssao_debug cvar maps onto + // the fused AO view when the plane rode along this frame. + c[11] = float(REXCVAR_GET(skate3_native_render_scene_hdr_debug)); + if (c[11] == 0.0f && g_r.ao_plane_in_psr && + REXCVAR_GET(skate3_native_render_scene_ssao_debug)) { + c[11] = 3.0f; + } + c[12] = tap_scale; + c[13] = c[14] = c[15] = 0.0f; + cmd->SetRootConstants(0, 16, c, 0); + }; + + if (bloom) { + // Downsample chain (level 0 extracts with threshold + Karis weighting; + // its 4x reduction doubles the tap spread to cover the source block). + for (uint32_t i = 0; i < g_r.bloom_levels; ++i) { + const uint32_t sw = i == 0 ? width : g_r.bloom_w[i - 1]; + const uint32_t sh = i == 0 ? height : g_r.bloom_h[i - 1]; + set_consts(g_r.bloom_w[i], g_r.bloom_h[i], sw, sh, 0.0f, + i == 0 ? 2.0f : 1.0f); + const nrhi::Viewport vp{0.0f, 0.0f, float(g_r.bloom_w[i]), + float(g_r.bloom_h[i]), 0.0f, 1.0f}; + const nrhi::Rect sc{0, 0, int32_t(g_r.bloom_w[i]), + int32_t(g_r.bloom_h[i])}; + cmd->SetViewport(vp); + cmd->SetScissor(sc); + cmd->SetRenderTargets(g_r.bloom_tex[i], nullptr); + cmd->SetPipeline(i == 0 ? g_r.pso_bloom_first : g_r.pso_bloom_down); + cmd->SetTexture(1, i == 0 ? g_r.hdr_srv : g_r.bloom_srv[i - 1]); + cmd->Draw(3, 0); + cmd->Barrier(g_r.bloom_tex[i], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + } + // Progressive tent upsample, additive onto each larger level. + for (int32_t i = int32_t(g_r.bloom_levels) - 2; i >= 0; --i) { + cmd->Barrier(g_r.bloom_tex[i], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + set_consts(g_r.bloom_w[i], g_r.bloom_h[i], g_r.bloom_w[i + 1], + g_r.bloom_h[i + 1], 0.0f, 1.0f); + const nrhi::Viewport vp{0.0f, 0.0f, float(g_r.bloom_w[i]), + float(g_r.bloom_h[i]), 0.0f, 1.0f}; + const nrhi::Rect sc{0, 0, int32_t(g_r.bloom_w[i]), + int32_t(g_r.bloom_h[i])}; + cmd->SetViewport(vp); + cmd->SetScissor(sc); + cmd->SetRenderTargets(g_r.bloom_tex[i], nullptr); + cmd->SetPipeline(g_r.pso_bloom_up); + cmd->SetTexture(1, g_r.bloom_srv[i + 1]); + cmd->Draw(3, 0); + cmd->Barrier(g_r.bloom_tex[i], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + } + } + + // Tonemap into the guest output (the single application of the game's + // shared tone chain; bloom energy joins pre-tonemap). + cmd->Barrier(context.guest_output, nrhi::ResourceState::kGuestOutput, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + set_consts(width, height, width, height, + bloom ? float(REXCVAR_GET( + skate3_native_render_scene_bloom_intensity)) + : 0.0f, + 1.0f); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetRenderTargets(context.guest_output, nullptr); + cmd->SetPipeline(g_r.pso_tonemap); + cmd->SetTexture(1, g_r.hdr_srv); + cmd->SetTexture(2, bloom ? g_r.bloom_srv[0] : g_r.white.srv); + // t2 = the fused AO multiplier plane (white = no AO this frame). + cmd->SetTexture(3, g_r.ao_plane_in_psr ? g_r.ao_srv[0] : g_r.white.srv); + cmd->Draw(3, 0); + + // Restore steady states. + cmd->Barrier(g_r.hdr_resolved, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + if (g_r.ao_plane_in_psr) { + cmd->Barrier(g_r.ao_tex[0], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + g_r.ao_plane_in_psr = false; + } + if (bloom) { + for (uint32_t i = 0; i < g_r.bloom_levels; ++i) { + cmd->Barrier(g_r.bloom_tex[i], + nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + } + } + cmd->FlushBarriers(); +} + +bool EnsurePipeline(const NativeGuestOutputRenderContext& context) { + if (g_r.failed) return false; + nrhi::Device* device = context.device; + g_r.device = device; + + if (!EnsureRootSignature(context)) { + return false; + } + + // HDR path decision, latched for the whole pipeline family (scene/spline + // PSO formats + shader variants, resolve target, AO composite target, + // output-sized targets). Hot cvar toggles rebuild everything below; + // hdr_failed pins the classic path after a float-target failure. + const bool hdr_want = + REXCVAR_GET(skate3_native_render_scene_hdr) && !g_r.hdr_failed; + const nrhi::Format hdr_fmt_want = + REXCVAR_GET(skate3_native_render_scene_hdr_packed) + ? nrhi::Format::kR11G11B10_FLOAT + : nrhi::Format::kR16G16B16A16_FLOAT; + if (!g_r.pso || g_r.rtv_format != context.guest_output->format() || + g_r.hdr_active != hdr_want || + (hdr_want && g_r.hdr_scene_format != hdr_fmt_want)) { + g_r.hdr_active = hdr_want; + g_r.hdr_scene_format = hdr_fmt_want; + if (!EnsureScenePsoFamily(context) || !EnsureResolvePso(context) || + !EnsureBlurPsos(context) || !EnsureOutlineEdgePso(context) || + !Ensure2dPso(context) || !EnsureSplinePsos(context) || + !EnsureShadowPsos(context)) { + return false; + } + REXLOG_INFO("native-scene: pipelines created (MSAA x{}, {})", g_r.msaa, + g_r.hdr_active ? "HDR" : "classic"); + g_r.rtv_format = context.guest_output->format(); + } + + if (!EnsureHeapsAndRings(context) || !EnsureShadowResources(context)) { + return false; + } + EnsureBlurOutlineTargets(context); + if (!EnsureFallbackTextures(context) || !EnsureOutputSizedTargets(context)) { + return false; + } + if (g_r.hdr_active && !EnsureHdrPipeline(context)) { + // hdr_failed is set: the next frame rebuilds the classic path. Abort + // this frame (the scene PSOs already target the float format). + return false; + } + + if (g_r.rtv_resource != context.guest_output) { + // The presenter recreated the output image (resize). Render-target + // binding is by texture now (the old heap-slot-0 RTV is gone); re-point + // the cached identity and the sampled view of the output (blur source / + // photo passes). + if (g_r.output_srv_slot) { + device->DestroyDeferred(g_r.output_srv_slot); + g_r.output_srv_slot = nullptr; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.output_srv_slot = device->CreateTextureView(context.guest_output, vd); + g_r.output_srv_allocated = g_r.output_srv_slot != nullptr; + g_r.rtv_resource = context.guest_output; + } + return true; +} + +// ---- Settings-menu backdrop blur (shared native + emulated-post paths) ---- +// Clean separable gaussian over the finished guest output; deliberately +// shares nothing with the game's +// popup chain (that one reproduces the console's 1152x640 lattice on +// purpose). Downsample to half res (plain bilinear = 2x2 box), gaussian H, +// gaussian V, bilinear stretch back. Eased in/out (~60 ms) so open/close +// doesn't pop; the smoothing state is shared across the native and +// emulated-post paths (only one runs per frame). Returns true while the +// effect is active (target > 0 or still easing out). +bool ApplyMenuBlurPass(const NativeGuestOutputRenderContext& context, nrhi::Cmd* cmd, + float target_sigma, bool output_in_guest_output_state) { + static float s_menu_sigma = 0.0f; + static int64_t s_menu_ns = 0; + const int64_t menu_now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + float menu_dt = s_menu_ns != 0 ? float(menu_now_ns - s_menu_ns) * 1e-9f : 1.0f; + if (menu_dt > 0.25f) { + s_menu_sigma = 0.0f; + menu_dt = 0.0f; + } + s_menu_ns = menu_now_ns; + const float menu_a = 1.0f - std::exp(-menu_dt / 0.06f); + s_menu_sigma += (target_sigma - s_menu_sigma) * menu_a; + const bool active = target_sigma > 0.0f || s_menu_sigma > 0.05f; + if (s_menu_sigma <= 0.05f || g_r.pso_menu_gauss == nullptr || + g_r.pso_blur_blit == nullptr || g_r.menu_blur_tex[0] == nullptr || + g_r.output_srv_slot == nullptr) { + return active; + } + const float w = float(g_r.menu_blur_w); + const float h = float(g_r.menu_blur_h); + // Sigma is authored in 1080p pixels; halve again for the half-res space. + const float sigma_half = std::max( + 0.3f, s_menu_sigma * (float(context.guest_output_height) / 1080.0f) * 0.5f); + const float radius = std::min(48.0f, std::ceil(sigma_half * 3.0f)); + const nrhi::Viewport half_vp{0.0f, 0.0f, w, h, 0.0f, 1.0f}; + const nrhi::Rect half_sc{0, 0, int32_t(g_r.menu_blur_w), int32_t(g_r.menu_blur_h)}; + const nrhi::Viewport full_vp{0.0f, + 0.0f, + float(context.guest_output_width), + float(context.guest_output_height), + 0.0f, + 1.0f}; + const nrhi::Rect full_sc{0, 0, int32_t(context.guest_output_width), + int32_t(context.guest_output_height)}; + const nrhi::ResourceState output_entry_state = output_in_guest_output_state + ? nrhi::ResourceState::kGuestOutput + : nrhi::ResourceState::kRenderTarget; + // The binding layout must be latched on this cmd before any draw; on the + // emulated-post path this is a FRESH frame state where no layout has been + // set, and nrhi draws silently no-op without one (EnsureDrawState). + // Harmless on the native path (same layout, re-latch). + cmd->SetBindingLayout(g_r.layout); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + // Downsample: guest output -> menu[0]. + cmd->Barrier(context.guest_output, output_entry_state, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetRenderTargets(g_r.menu_blur_tex[0], nullptr); + cmd->SetViewport(half_vp); + cmd->SetScissor(half_sc); + cmd->SetPipeline(g_r.pso_blur_blit); + cmd->SetTexture(1, g_r.output_srv_slot); + cmd->Draw(3, 0); + // H: menu[0] -> menu[1]. + cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetRenderTargets(g_r.menu_blur_tex[1], nullptr); + cmd->SetPipeline(g_r.pso_menu_gauss); + const float h_consts[8] = {1.0f / w, 0.0f, sigma_half, radius, 1.0f, 1.0f, 1.0f, 1.0f}; + cmd->SetRootConstants(0, 8, h_consts, 0); + cmd->SetTexture(1, g_r.menu_blur_srv[0]); + cmd->Draw(3, 0); + // V: menu[1] -> menu[0]. + cmd->Barrier(g_r.menu_blur_tex[1], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + cmd->SetRenderTargets(g_r.menu_blur_tex[0], nullptr); + const float v_consts[8] = {0.0f, 1.0f / h, sigma_half, radius, 1.0f, 1.0f, 1.0f, 1.0f}; + cmd->SetRootConstants(0, 8, v_consts, 0); + cmd->SetTexture(1, g_r.menu_blur_srv[1]); + cmd->Draw(3, 0); + // Stretch back over the full output. + cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->Barrier(context.guest_output, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + cmd->SetRenderTargets(context.guest_output, nullptr); + cmd->SetViewport(full_vp); + cmd->SetScissor(full_sc); + cmd->SetPipeline(g_r.pso_blur_blit); + cmd->SetTexture(1, g_r.menu_blur_srv[0]); + cmd->Draw(3, 0); + // Restore steady states (the intermediates stay in RENDER_TARGET; the + // output goes back to the state the caller's path expects). + cmd->Barrier(g_r.menu_blur_tex[0], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->Barrier(g_r.menu_blur_tex[1], nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + if (output_in_guest_output_state) { + cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kGuestOutput); + cmd->FlushBarriers(); + } + return active; +} + +// Minimal resource bring-up for the emulated-output post-processor path: the +// full EnsurePipeline never runs while the native renderer is disabled or +// yielding, so the blur ensures just what it needs (binding layout, the blur +// PSOs, the half-res intermediates, and a sampled view of the output). +bool EnsureMenuBlurStandalone(const NativeGuestOutputRenderContext& context) { + if (g_r.failed) { + return false; + } + if (!EnsureRootSignature(context)) { + return false; + } + if (g_r.pso_menu_gauss == nullptr) { + static bool s_attempted = false; + if (s_attempted) { + return false; // creation failed once; don't hammer every frame + } + s_attempted = true; + EnsureBlurPsos(context); + if (g_r.pso_menu_gauss == nullptr) { + return false; + } + } + EnsureBlurOutlineTargets(context); + if (g_r.menu_blur_tex[0] == nullptr) { + return false; + } + if (g_r.rtv_resource != context.guest_output) { + if (g_r.output_srv_slot) { + context.device->DestroyDeferred(g_r.output_srv_slot); + g_r.output_srv_slot = nullptr; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + g_r.output_srv_slot = context.device->CreateTextureView(context.guest_output, vd); + g_r.output_srv_allocated = g_r.output_srv_slot != nullptr; + g_r.rtv_resource = context.guest_output; + } + return g_r.output_srv_slot != nullptr; +} + +// Registered with the command processors: invoked at the end of the EMULATED +// guest-output refresh (native-rendered frames apply the blur inline in +// RenderScene). Covers boot/startup frames before the native scene takes +// over and manual emulated mode. +void PostProcessGuestOutput(const NativeGuestOutputRenderContext& context, void* /*user_data*/) { + const float target = g_settings_menu_blur.load(std::memory_order_relaxed) + ? float(REXCVAR_GET(skate3_menu_blur_sigma)) + : 0.0f; + // Diagnostics (throttled): the emulated-path blur has several silent + // early-outs; log which leg each invocation takes. + const bool log_this = g_post_blur_log_count.load(std::memory_order_relaxed) < 8; + const bool ensured = EnsureMenuBlurStandalone(context); + if (log_this) { + g_post_blur_log_count.fetch_add(1, std::memory_order_relaxed); + REXLOG_INFO( + "native-scene: emulated-post blur invoked (target={:.1f} ensured={} pso={} tex={} " + "srv={} failed={})", + target, ensured, (void*)g_r.pso_menu_gauss, (void*)g_r.menu_blur_tex[0], + (void*)g_r.output_srv_slot, g_r.failed); + } + if (!ensured) { + if (target <= 0.0f) { + rex::graphics::RequestNativeGuestOutputPostProcess(false); + } + return; + } + if (!ApplyMenuBlurPass(context, context.cmd, target, + /*output_in_guest_output_state=*/true)) { + // Fully eased out: stop the per-frame post-process invocations until the + // menu opens again. + rex::graphics::RequestNativeGuestOutputPostProcess(false); + } +} + +// ---- Warm decode (loading-screen prewarm + gameplay warmup) -------------- +// Decodes, within `deadline`, every GPU resource `item`'s draw would +// resolve: the mesh VB/IB (with fingerprint revalidation) and each texture +// slot its material binds. Mirrors the draw loop's miss paths, including +// negative-caching of failed texture decodes and retiring replaced +// resources, so the caches end up exactly as drawing would leave them and +// the takeover frame has nothing left to pay. Work past the deadline counts +// as deferred. Cached textures get ONE content revalidation per warmup +// (recheck_frame gate; the frame counter is frozen while yielded): a +// texture pre-decoded while its payload was still streaming in is healed +// here, under budget, instead of by an unbudgeted re-decode burst on the +// takeover frame. +struct WarmCounters { + uint32_t decodes = 0; + uint32_t deferred = 0; +}; + +void WarmItemResources(const NativeGuestOutputRenderContext& context, uint8_t* base, + uint64_t frame_number, const DrawItem& item, + std::chrono::steady_clock::time_point deadline, + WarmCounters& wc) { + const auto within = [&] { return std::chrono::steady_clock::now() < deadline; }; + + bool need_mesh = false; + auto mit = g_r.meshes.find(item.mesh); + if (mit == g_r.meshes.end()) { + need_mesh = true; + } else if (mit->second.fingerprint != item.fingerprint && !item.ropa && + REXCVAR_GET(skate3_native_render_scene_mesh_revalidate)) { + // Streaming filled/replaced the payload after an earlier (pre)decode. + // Ropa meshes are excluded: the cloth sim rewrites their payload every + // frame BY DESIGN (fingerprint never converges), so revalidating them + // here just destroys+redecodes the base mesh once per settle frame - + // the churn that kept the settle pass extending itself (log 1624: + // mesh 40D30140 re-fingerprinting 4x in 50ms). The draw path serves + // ropa content from the ropa ring / shape generations regardless. + if (within()) { + if (g_warm_mesh_log_budget.load(std::memory_order_relaxed) > 0 && + g_warm_mesh_log_budget.fetch_sub(1, std::memory_order_relaxed) > 0) { + REXLOG_INFO("native-scene: settle mesh REVALIDATE mesh={:08X} fp {:016X}->{:016X}", + item.mesh, mit->second.fingerprint, item.fingerprint); + } + g_r.device->DestroyDeferred(mit->second.vb); + g_r.device->DestroyDeferred(mit->second.ib); + g_r.meshes.erase(mit); + need_mesh = true; + } else { + ++wc.deferred; + } + } + if (need_mesh) { + if (!within()) { + ++wc.deferred; + } else { + ++wc.decodes; + MeshBuffers buffers; + if (DecodeMesh(g_r.device, base, item, buffers)) { + buffers.fingerprint = item.fingerprint; + g_r.meshes.emplace(item.mesh, buffers); + } + // Failures retry through the draw path (logged + counted there). + } + } + + const auto warm_texture = [&](uint32_t tex_ptr) { + if (tex_ptr == 0) { + return; + } + uint32_t words[6]; + if (!ReadStableTexWords(base, tex_ptr, words) || words[1] == 0) { + return; // unreadable / mid-rewrite / demoted: the draw path routes it + } + const uint64_t key = FetchWordsKey(words); + auto it = g_r.tex_store.find(key); + if (it != g_r.tex_store.end()) { + if (!it->second.valid || frame_number < it->second.recheck_frame || + !REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { + return; // negative caches retry via the draw path's schedule + } + if (!within()) { + ++wc.deferred; + return; + } + it->second.recheck_frame = frame_number + 16; + const uint64_t fp = SampleProbeFingerprint(base, it->second); + if (!it->second.incomplete && (fp == 0 || fp == it->second.payload_fp)) { + return; + } + if (g_warm_tex_log_budget.load(std::memory_order_relaxed) > 0 && + g_warm_tex_log_budget.fetch_sub(1, std::memory_order_relaxed) > 0) { + REXLOG_INFO("native-scene: settle tex RETIRE ptr={:08X} key={:016X} incomplete={} " + "fp {:016X}->{:016X}", + tex_ptr, key, it->second.incomplete, it->second.payload_fp, fp); + } + RetireGuestTexture(it->second, context.device->CurrentSubmission()); + g_r.tex_store.erase(it); + } + if (!within()) { + ++wc.deferred; + return; + } + ++wc.decodes; + GuestTexture gt; + EnsureGuestTextureFromWords(context, base, words, gt); + if (!gt.valid) { + // Negative-cache exactly like the draw path so a permanently + // unreadable payload cannot hold warmup open. + std::memcpy(gt.fetch_words, words, sizeof(gt.fetch_words)); + gt.retry_after_frame = frame_number + 120; + } + gt.last_used_frame = frame_number; + g_r.tex_store.emplace(key, gt); + }; + // Draw-time fetch-word bindings (streamed artwork / decal ad overrides) + // share the same store. + const auto warm_fetch_words = [&](const uint32_t words[6]) { + if (words[1] == 0) { + return; + } + const uint64_t fkey = FetchWordsKey(words); + if (g_r.tex_store.contains(fkey)) { + return; + } + if (!within()) { + ++wc.deferred; + return; + } + ++wc.decodes; + GuestTexture gt; + EnsureGuestTextureFromWords(context, base, words, gt); + gt.last_used_frame = frame_number; + g_r.tex_store.emplace(fkey, gt); + }; + + warm_fetch_words(item.diffuse_fetch); + warm_texture(item.diffuse_tex); + if (REXCVAR_GET(skate3_native_render_scene_lightmaps)) { + warm_texture(item.lightmap_tex); + } + if (REXCVAR_GET(skate3_native_render_scene_macro)) { + warm_texture(item.macro_tex); + } + if (item.water || item.char_family >= 6 || item.dynobj != 0 || + (item.env_family >= 1 && item.env_family <= 6)) { + // v2: fams 1-4 and the dynobj families sample their base normal map + // per-pixel too. + warm_texture(item.water_normal); + } + if ((item.env_family >= 1 && item.env_family <= 4 && item.env_family != 2) || + item.dynobj != 0) { + // v2 detail normal map (t8; fam 2 carries no base+detail pair). + warm_texture(item.detail_tex); + } + if (item.decal && REXCVAR_GET(skate3_native_render_scene_decals)) { + warm_texture(item.decal_art); + warm_fetch_words(item.decal_fetch); + } + if (item.char_family >= 4 && item.char_family <= 5) { + warm_texture(item.hair_alpha_tex); + } + if ((item.env_family != 0 && item.env_family != 10) || item.unlit || + item.dynobj != 0) { + // unlit = sky: spec_tex is the 1D sun gradient. Decal fams (3/4) and + // the dynobj families consume their spec masks too (v2, t9). + warm_texture(item.spec_tex); + } + // Environment cube (negative-cached like the draw path). + if ((item.water || item.char_family >= 6 || + (item.env_family >= 5 && item.env_family <= 6) || + item.env_family == 13) && + item.water_env != 0 && !g_r.cube_textures.contains(item.water_env)) { + if (!within()) { + ++wc.deferred; + } else { + ++wc.decodes; + GuestTexture c{}; + if (!EnsureGuestCubeTexture(context, base, item.water_env, c)) { + if (c.upload) g_r.device->DestroyDeferred(c.upload); + if (c.texture) g_r.device->DestroyDeferred(c.texture); + c = GuestTexture{}; + c.valid = false; + } + g_r.cube_textures.emplace(item.water_env, c); + } + } +} + +// ---- Steady-state miss routing (render thread -> decode workers) ---------- +// Draw-path cache misses for STATIC content (world meshes, material +// textures) enqueue here instead of decoding inline on the render thread: +// the item skips / renders white / keeps its previous decode for the 1-3 +// frames the workers need, instead of stalling the frame for the decode +// (measured ~10 ms avg, ~70 ms max per texture, the panning lag spikes). +// Dynamic payloads (skinned, cloth, ropa) still decode inline: their buffers +// change every frame, so an async result would always be stale. +void EnqueueMeshMiss(uint32_t mesh) { + std::lock_guard lock(g_prewarm_mutex); + if (g_miss_queue.size() < 65536 && g_miss_inflight_mesh.insert(mesh).second) { + PrewarmEntry e{mesh, 8}; + e.miss = true; + g_miss_queue.push_back(e); + g_prewarm_cv.notify_one(); + } +} + + +// Words-keyed texture miss (streamed-artwork posters / event ads): the art +// exists only as draw-time fetch words. Decoded unbudgeted inline these were +// a traversal hitch (a poster decode costs the same ~10 ms as any texture); +// while a decode is in flight the item falls back to its channel diffuse +// (the placeholder poster), not white. +void EnqueueWordsMiss(uint64_t key, const uint32_t words[6], bool ui = false) { + std::lock_guard lock(g_prewarm_mutex); + if (g_miss_queue.size() < 65536 && g_miss_inflight_words.insert(key).second) { + PrewarmEntry e{0, 0, 0, key}; + std::memcpy(e.words, words, sizeof(e.words)); + e.miss = true; + e.ui = ui; + g_miss_queue.push_back(e); + g_prewarm_cv.notify_one(); + } +} + +// Environment-cube miss: one cube decode measured up to ~100 ms inline; +// the gray fallback cube shows for the 1-3 frames the workers need instead. +void EnqueueCubeMiss(uint32_t tex) { + std::lock_guard lock(g_prewarm_mutex); + if (g_miss_queue.size() < 65536 && g_miss_inflight_tex.insert(tex).second) { + PrewarmEntry e{0, 0, tex}; + e.cube = true; + e.miss = true; + g_miss_queue.push_back(e); + g_prewarm_cv.notify_one(); + } +} + +// ---- Prewarm decode workers ----------------------------------------------- +// Process one registered mesh on a worker: build the item (same walk and +// payload fingerprint as the capture, so cache entries are identical), +// decode the mesh into upload-heap buffers, and stage every material +// texture up to a filled upload resource. Results go to g_prewarm_out for +// the render thread's commit. +void ProcessPrewarmEntry(uint8_t* base, const PrewarmEntry& e) { + if (e.mesh == 0 && e.tex == 0 && e.wkey != 0) { + // Words-keyed texture miss (posters/ads, see EnqueueWordsMiss): stage + // the decode from the captured fetch words. + StagedTexResult tr; + tr.words_key = e.wkey; + tr.ui = e.ui; + NativeGuestOutputRenderContext stage_ctx{}; + stage_ctx.device = g_r.device; + g_tex_stage_out = &tr.commit; + tr.valid = EnsureGuestTextureFromWords(stage_ctx, base, e.words, tr.gt); + g_tex_stage_out = nullptr; + PrewarmResult res; + res.item.mesh = 0; + res.mesh_valid = false; + res.miss = e.miss; + res.textures.push_back(std::move(tr)); + std::lock_guard lock(g_prewarm_out_mutex); + g_prewarm_out.push_back(std::move(res)); + return; + } + if (e.mesh == 0 && e.tex != 0) { + // Environment-cube miss (see EnqueueCubeMiss, the only object-keyed + // texture path left): stage the decode up to a filled upload resource; + // the commit records the GPU copies + SRV. + StagedTexResult tr; + tr.key = e.tex; + tr.cube = true; + NativeGuestOutputRenderContext stage_ctx{}; + stage_ctx.device = g_r.device; + g_tex_stage_out = &tr.commit; + tr.valid = EnsureGuestCubeTexture(stage_ctx, base, e.tex, tr.gt); + g_tex_stage_out = nullptr; + PrewarmResult res; + res.item.mesh = 0; // texture-only result (DrawItem::mesh has no default) + res.mesh_valid = false; + res.miss = e.miss; + res.textures.push_back(std::move(tr)); + std::lock_guard lock(g_prewarm_out_mutex); + g_prewarm_out.push_back(std::move(res)); + return; + } + uint8_t record[0x60]; + DrawItem item{}; + if (!GuestTryCopy(record, base + e.mesh, sizeof(record)) || + !BuildItemFromMesh(base, e.mesh, item)) { + // Buffer objects can finish initializing shortly after registration; + // retries are re-injected frame-paced by the render thread. + bool dropped = false; + { + std::lock_guard lock(g_prewarm_out_mutex); + if (e.retries > 0) { + g_prewarm_retry.push_back({e.mesh, uint16_t(e.retries - 1)}); + } else { + g_prewarm_dropped.fetch_add(1, std::memory_order_relaxed); + dropped = true; + } + } + if (dropped) { + // A dropped draw-path miss must leave the in-flight set so a later + // frame can retry it (the payload may finish streaming in). + std::lock_guard lock(g_prewarm_mutex); + g_miss_inflight_mesh.erase(e.mesh); + } + return; + } + // One representative draw entry so DecodeMesh's two-sided-sheet detection + // keys off the real primitive type (empty draw lists would pass it + // unconditionally). Guarded: island params live outside the validated + // mesh record. + const uint32_t num_islands = REX_LOAD_U32(e.mesh + 0x38); + const uint32_t island_params = REX_LOAD_U32(e.mesh + 0x44); + uint32_t prim0 = 0; + if (num_islands != 0 && num_islands < 4096 && + GuestTryLoadU32(base, island_params, &prim0)) { + item.draws.push_back(DrawEntry{prim0, 0, 0, item.ib_count}); + } + + PrewarmResult res; + res.miss = e.miss; + res.mesh_valid = DecodeMesh(g_r.device, base, item, res.buffers); + if (res.mesh_valid) { + res.buffers.fingerprint = item.fingerprint; + } + + // Stage the material textures (cube maps stay on the render thread: + // rare, and their decode has its own path). Dedupe through the shared + // per-load set: workers cannot read the render thread's g_r caches, so a + // texture cached from a previous map decodes once more per load; the + // commit discards the duplicate. + const auto stage_texture = [&](uint32_t tex_ptr) { + if (tex_ptr == 0) { + return; + } + // Stable words snapshot: the decode and its store key both come from + // this snapshot, so a mid-rewrite object can never stage a mixed state. + uint32_t words[6]; + if (!ReadStableTexWords(base, tex_ptr, words) || words[1] == 0) { + return; + } + const uint64_t wkey = FetchWordsKey(words); + { + // Words-aware dedupe: the same content staged once per load; a + // rebound object (new words) re-stages under its new key. + std::lock_guard lock(g_prewarm_mutex); + const auto [it, fresh] = g_prewarm_tex_seen.try_emplace(tex_ptr, wkey); + if (!fresh) { + if (it->second == wkey) { + return; + } + it->second = wkey; + } + } + StagedTexResult tr; + tr.words_key = wkey; + // Staged mode uses only context.device (copies/barrier/SRV are + // exported for the commit), so a device-only context suffices. + NativeGuestOutputRenderContext stage_ctx{}; + stage_ctx.device = g_r.device; + g_tex_stage_out = &tr.commit; + tr.valid = EnsureGuestTextureFromWords(stage_ctx, base, words, tr.gt); + g_tex_stage_out = nullptr; + if (!tr.valid) { + std::memcpy(tr.gt.fetch_words, words, sizeof(tr.gt.fetch_words)); + } + res.textures.push_back(std::move(tr)); + }; + stage_texture(item.diffuse_tex); + if (REXCVAR_GET(skate3_native_render_scene_lightmaps)) { + stage_texture(item.lightmap_tex); + } + if (REXCVAR_GET(skate3_native_render_scene_macro)) { + stage_texture(item.macro_tex); + } + if (item.water || item.char_family >= 6 || item.dynobj != 0 || + (item.env_family >= 1 && item.env_family <= 6)) { + // v2: fams 1-4 and the dynobj families sample their base normal map + // per-pixel too. + stage_texture(item.water_normal); + } + if ((item.env_family >= 1 && item.env_family <= 4 && item.env_family != 2) || + item.dynobj != 0) { + // v2 detail normal map (t8; fam 2 carries no base+detail pair). + stage_texture(item.detail_tex); + } + if (item.decal && REXCVAR_GET(skate3_native_render_scene_decals)) { + stage_texture(item.decal_art); + } + if (item.char_family >= 4 && item.char_family <= 5) { + stage_texture(item.hair_alpha_tex); + } + if ((item.env_family != 0 && item.env_family != 10) || item.unlit || + item.dynobj != 0) { + // unlit = sky: spec_tex is the 1D sun gradient. Decal fams (3/4) and + // the dynobj families consume their spec masks too (v2, t9). + stage_texture(item.spec_tex); + } + + res.item = std::move(item); + std::lock_guard lock(g_prewarm_out_mutex); + g_prewarm_out.push_back(std::move(res)); +} + +void PrewarmWorkerLoop() { +#if defined(_WIN32) + // Below-normal priority: the workers flood in exactly when the guest's + // single-threaded world ACTIVATION runs (registration is the final load + // phase), and at normal priority they stretch the game's own black + // window at the loading->gameplay boundary. At below-normal they only + // soak idle cores and the guest always wins the contention. + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL); +#endif + for (;;) { + if (!SceneEnabled()) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + continue; + } + PrewarmEntry e{}; + DynDecodeJob dyn; + bool have_dyn = false; + { + std::unique_lock lock(g_prewarm_mutex); + g_prewarm_cv.wait_for(lock, std::chrono::milliseconds(500), [] { + return !g_prewarm_queue.empty() || !g_miss_queue.empty() || + !g_dyn_jobs.empty(); + }); + if (!g_dyn_jobs.empty()) { + // Dynamic cloth first: these are per-frame payloads whose result + // should land at the very next commit. + dyn = std::move(g_dyn_jobs.front()); + g_dyn_jobs.erase(g_dyn_jobs.begin()); + have_dyn = true; + } else if (!g_miss_queue.empty()) { + // Draw-path misses next: this content is visible RIGHT NOW (white / + // skipped geometry). On the old shared LIFO queue a streaming + // registration burst kept cutting the line ahead of the visible + // miss; medium-distance pop-in lasted the whole backlog. + e = g_miss_queue.front(); + g_miss_queue.erase(g_miss_queue.begin()); + } else if (!g_prewarm_queue.empty()) { + e = g_prewarm_queue.back(); + g_prewarm_queue.pop_back(); + } else { + continue; + } + } + uint8_t* base = g_guest_base.load(std::memory_order_relaxed); + if (base == nullptr || g_r.device == nullptr) { + if (!have_dyn) { + std::lock_guard lock(g_prewarm_out_mutex); + g_prewarm_retry.push_back(e); + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + if (have_dyn) { + PrewarmResult res; + res.mesh_valid = + DecodeMesh(g_r.device, base, dyn.item, res.buffers, dyn.vb.data(), + dyn.ib.empty() ? nullptr : dyn.ib.data()); + if (res.mesh_valid) { + res.buffers.fingerprint = dyn.item.fingerprint; + res.buffers.dyn_seq = dyn.seq; + res.item = std::move(dyn.item); + res.miss = true; // per-frame cloth: never behind the commit cap + std::lock_guard lock(g_prewarm_out_mutex); + g_prewarm_out.push_back(std::move(res)); + } + continue; + } + ProcessPrewarmEntry(base, e); + } +} + +// Lazily start the decode workers (process-lifetime, parked on the queue's +// condition variable when idle). Only started once the pipeline exists; +// the workers create GPU resources through g_r.device (thread-safe). +void EnsurePrewarmWorkers() { + if (g_r.device == nullptr || + g_prewarm_workers_started.exchange(true, std::memory_order_acq_rel)) { + return; + } + const unsigned hw = std::max(4u, std::thread::hardware_concurrency()); + // Below-normal priority makes extra workers near-free (they only soak + // idle cores; the guest always wins the contention) while the pool size + // governs how fast a gameplay streaming burst decodes; 4 workers left + // medium-distance pop-in on big sectors visibly behind the emulated + // renderer. + const unsigned n = std::clamp(hw / 3u, 2u, 8u); + for (unsigned i = 0; i < n; ++i) { + std::thread(PrewarmWorkerLoop).detach(); + } + REXLOG_INFO("native-scene: {} prewarm decode workers started", n); +} + +// Render-thread commit of the workers' results: record GPU copies + +// barriers, create SRVs, insert into the caches, re-inject retries. +// Microseconds per item, safe to run every frame (loading, settle and +// gameplay alike). +void PrewarmCommit(const NativeGuestOutputRenderContext& context, + uint64_t frame_number, bool loading = false) { + { + std::scoped_lock lock(g_prewarm_out_mutex, g_prewarm_mutex); + if (!g_prewarm_retry.empty()) { + g_prewarm_queue.insert(g_prewarm_queue.end(), g_prewarm_retry.begin(), + g_prewarm_retry.end()); + g_prewarm_retry.clear(); + g_prewarm_cv.notify_all(); + } + } + std::vector done; + { + std::lock_guard lock(g_prewarm_out_mutex); + // Soft cap per frame: during the activation burst the workers can + // complete hundreds of items between two refreshes, and committing + // them all at once records thousands of copies + SRVs into a single + // frame (measured ~96 us per item; a 256 batch is ~25 ms). Behind a + // loading screen that is fine; in gameplay the cap stays small so a + // streaming burst spreads over a few frames instead of hitching one. + const size_t kMaxCommitPerFrame = loading ? 256 : 32; + if (g_prewarm_out.size() <= kMaxCommitPerFrame) { + done.swap(g_prewarm_out); + } else { + // Oldest first: draining from the END starved early results under a + // sustained streaming burst (they sat behind an ever-refilling tail, + // so exactly the content that had waited longest stayed popped-out). + // Draw-path MISS results (content visible right now: white/skipped) + // bypass the cap entirely: under a big sector streaming burst they + // otherwise queued behind hundreds of speculative prewarm results + // for several frames of visible pop-in. Only a handful arrive per + // frame, so the bypass cannot recreate the commit hitch the cap + // exists to prevent. + std::vector rest; + rest.reserve(g_prewarm_out.size()); + size_t taken = 0; + for (PrewarmResult& r : g_prewarm_out) { + const bool is_miss = r.miss; + if (is_miss || taken < kMaxCommitPerFrame) { + if (!is_miss) { + ++taken; + } + done.push_back(std::move(r)); + } else { + rest.push_back(std::move(r)); + } + } + g_prewarm_out.swap(rest); + } + } + if (done.empty()) { + return; + } + const auto commit_t0 = PerfClock::now(); + bool committed_tex = false; + // For the payload-stability verify below (SEH-guarded sampled reads). + uint8_t* verify_base = g_guest_base.load(std::memory_order_relaxed); + for (PrewarmResult& r : done) { + if (r.mesh_valid) { + auto mit = g_r.meshes.find(r.item.mesh); + const bool superseded = + mit != g_r.meshes.end() && mit->second.dyn_seq > r.buffers.dyn_seq; + // ROPA shape-generation ring: retain this decode's vertex array + // (keyed by dyn_seq) for the draw-time blend onto the play clock. + // Runs even when the GPU buffers get dropped as identical below; + // the SEQ still advances and the interp ring may reference it. + if (r.item.ropa && r.buffers.dyn_seq != 0 && !superseded && + !r.buffers.ropa_verts.empty()) { + auto& ring = g_r.ropa_shapes[r.item.mesh]; + const double now_s = + std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + ring.push_back( + {r.buffers.dyn_seq, now_s, std::move(r.buffers.ropa_verts)}); + // 16 generations = ~114 ms at a 140 Hz guest: the 8-tap boxcar + // kernel reaches filter_w/2 (~25 ms) past the play clock (itself + // ~2 guest periods behind), plus decode-latency slack. + while (ring.size() > 16) { + ring.pop_front(); + } + if (g_r.ropa_shapes.size() > 64) { + g_r.ropa_shapes.clear(); // outfit-change growth backstop + } + } + if (mit != g_r.meshes.end() && + (mit->second.fingerprint == r.buffers.fingerprint || superseded)) { + // Identical content already cached (lost the race against the draw + // path / an earlier result), or a NEWER dynamic decode already + // landed (multi-worker reordering must not step the cloth + // backwards): the staged buffers were never referenced by any + // submission. + g_r.device->DestroyDeferred(r.buffers.vb); + g_r.device->DestroyDeferred(r.buffers.ib); + } else { + if (mit != g_r.meshes.end()) { + // Stale decode (miss-driven revalidation heal): swap it out. The + // old buffers may be referenced by the in-flight submission. + g_r.device->DestroyDeferred(mit->second.vb); + g_r.device->DestroyDeferred(mit->second.ib); + g_r.meshes.erase(mit); + } + g_r.meshes.emplace(r.item.mesh, r.buffers); + } + } + for (StagedTexResult& t : r.textures) { + // Payload-stability verify: a decode read while its payload was still + // STREAMING IN is a garbage interleave (the mip-churn "goes black, + // then reloads" flash; fresh mip words repoint mid-upload, and the + // fingerprint sampled right after the worker's read can look stable). + // The commit runs 1-3 frames later: re-sample here and FAIL unstable + // results, so the cache keeps the previous good decode and the retry + // clock re-runs the heal once the payload settles. Cubes are exempt + // (static assets; a failed cube negative-caches permanently). + // UI-origin results skip the verify (see PrewarmEntry::ui): animating + // APT art legitimately rewrites its payload every guest frame, so the + // re-sample below would reject every mid-animation commit and freeze + // the element; the 2D resolve's content probe is the heal path there. + if (t.valid && !t.cube && !t.ui && verify_base != nullptr && + t.gt.payload_addr != 0 && + SampleProbeFingerprint(verify_base, t.gt) != t.gt.payload_fp) { + if (t.gt.texture) { + g_r.device->DestroyDeferred(t.gt.texture); + t.gt.texture = nullptr; + } + if (t.gt.upload) { + g_r.device->DestroyDeferred(t.gt.upload); + t.gt.upload = nullptr; + } + t.valid = false; + t.verify_failed = true; + g_heal_verify_fail.fetch_add(1, std::memory_order_relaxed); + } + if (t.cube) { + // Environment cube: lands in the cube cache. Cubes are static + // assets: an existing valid entry wins; a failed decode + // negative-caches like the old inline path did. + auto cit = g_r.cube_textures.find(t.key); + if (cit != g_r.cube_textures.end() && cit->second.valid) { + if (t.gt.texture) g_r.device->DestroyDeferred(t.gt.texture); + if (t.gt.upload) g_r.device->DestroyDeferred(t.gt.upload); + continue; + } + if (cit != g_r.cube_textures.end()) { + RetireGuestTexture(cit->second, context.device->CurrentSubmission()); + g_r.cube_textures.erase(cit); + } + if (t.valid) { + CommitStagedGuestTexture(context, t.gt, t.commit); + committed_tex = true; + } + g_r.cube_textures.emplace(t.key, t.gt); + continue; + } + if (t.words_key != 0) { + // Store commit: the result files under its decode-time words key + // unconditionally; a rebound object simply routes elsewhere, so a + // worker result can never land on the wrong identity. The only + // remaining valid->valid swap class is an in-place content change + // at the same words (event-ad rotation, mip-pool fills, composed + // lightmap pages); the payload verify above covers exactly that. + const bool tr_key = + !g_trace_keys.empty() && g_trace_keys.count(t.words_key) != 0; + auto wit = g_r.tex_store.find(t.words_key); + if (tr_key) { + REXLOG_INFO( + "tex-trace: f{} COMMIT key={:016X} valid={} vfail={} " + "fp={:016X} inc={} nb={} cached={}", + frame_number, t.words_key, t.valid ? 1 : 0, + t.verify_failed ? 1 : 0, t.gt.payload_fp, + t.gt.incomplete ? 1 : 0, t.gt.near_black ? 1 : 0, + wit != g_r.tex_store.end() + ? (wit->second.valid ? "valid" : "invalid") + : "none"); + } + if (wit != g_r.tex_store.end()) { + // Same-content dedup, except a complete re-decode always + // displaces an incomplete cached entry (truncated tiled-mip copy: + // the zeroed blocks live in mips the fingerprint never samples). + const bool same_content = t.valid && wit->second.valid && + t.gt.payload_fp == wit->second.payload_fp && + !(wit->second.incomplete && !t.gt.incomplete); + if (same_content || (!t.valid && wit->second.valid)) { + // Keep the cached decode ("keep the old decode when the payload + // became unreadable": mips stream out at range). A failed heal + // of a still-serving entry needs no retry stamp: the payload + // poll re-detects on its own cadence and the miss-inflight set + // already dedupes. + if (same_content && t.gt.near_black && wit->second.near_black && + wit->second.nb_redecodes < 255) { + // A forced near-black re-decode came back identical: one more + // confirmation toward "genuinely uniform content". + ++wit->second.nb_redecodes; + } + if (t.gt.texture) g_r.device->DestroyDeferred(t.gt.texture); + if (t.gt.upload) g_r.device->DestroyDeferred(t.gt.upload); + continue; + } + if (t.valid && wit->second.valid) { + // In-place content swap: the only commit class a player can + // SEE; rolling-capped log so a flicker sighting names its + // texture. + static std::atomic s_swap_logs{0}; + static std::atomic s_swap_win{0}; + const int64_t now_s = + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + int64_t swin = s_swap_win.load(std::memory_order_relaxed); + if (now_s - swin >= 5 && + s_swap_win.compare_exchange_strong(swin, now_s)) { + s_swap_logs.store(0, std::memory_order_relaxed); + } + if (s_swap_logs.fetch_add(1) < 8) { + REXLOG_INFO( + "native-scene: texture heal commit key={:016X} fp {:016X} " + "-> {:016X}", + t.words_key, wit->second.payload_fp, t.gt.payload_fp); + } + } + if (!t.valid) { + t.gt.fail_count = wit->second.fail_count; // keep the backoff arc + } + t.gt.last_used_frame = wit->second.last_used_frame; + RetireGuestTexture(wit->second, context.device->CurrentSubmission()); + g_r.tex_store.erase(wit); + } + if (t.valid) { + CommitStagedGuestTexture(context, t.gt, t.commit); + committed_tex = true; + // Content landed this frame; the video-start cold/hot classifier + // (GuestTexture::last_change_frame) keys off commit times. + t.gt.last_change_frame = frame_number; + } else { + t.gt.fail_count = BumpFail(t.gt.fail_count); + t.gt.retry_after_frame = frame_number + RetryBackoff(t.gt.fail_count); + // Failed decodes render white: log each once (capped) so white + // meshes stay attributable to a specific texture. + static std::unordered_set logged_failed; + if (logged_failed.size() < 64 && + logged_failed.insert(t.words_key).second) { + REXLOG_INFO( + "native-scene: texture decode FAILED key={:016X} " + "fetch=[{:08X} {:08X} {:08X} {:08X} {:08X} {:08X}]", + t.words_key, t.gt.fetch_words[0], t.gt.fetch_words[1], + t.gt.fetch_words[2], t.gt.fetch_words[3], t.gt.fetch_words[4], + t.gt.fetch_words[5]); + } + } + g_r.tex_store.emplace(t.words_key, t.gt); + continue; + } + // No words key and not a cube: an empty/failed stage slot; release + // whatever it carries (nothing routes to it). + if (t.gt.texture) g_r.device->DestroyDeferred(t.gt.texture); + if (t.gt.upload) g_r.device->DestroyDeferred(t.gt.upload); + } + g_prewarm_done.fetch_add(1, std::memory_order_relaxed); + } + // Release the miss-in-flight keys so later revalidation cycles can enqueue + // these again (erasing keys that were never in the sets is harmless). + { + std::lock_guard lock(g_prewarm_mutex); + for (const PrewarmResult& r : done) { + if (r.item.mesh != 0) { + g_miss_inflight_mesh.erase(r.item.mesh); + } + for (const StagedTexResult& t : r.textures) { + if (t.words_key != 0) { + g_miss_inflight_words.erase(t.words_key); + } else { + g_miss_inflight_tex.erase(t.key); + } + } + } + } + if (committed_tex) { + context.cmd->FlushBarriers(); + } + g_pw_commit.Add(uint64_t( + std::chrono::duration_cast(PerfClock::now() - commit_t0) + .count())); +} + +// Render-thread mirror of the presence-context check (set in YieldForMenus): +// menu screens shorten the 2D texture liveness recheck cadence so in-place +// UI-texture rewrites (portrait resolves) heal fast. +std::atomic g_in_menus_frame{false}; + +// Set by YieldForMenus, consumed by RenderScene in the same call (render +// thread only): this frame is a NATIVE loading-screen frame; no world scene +// exists (or it is the previous map's stale one), so RenderScene renders a +// black backdrop + the captured 2D loading UI instead of the world. +bool g_loading_native_frame = false; +// Render thread only: the previous rendered frame was a native loading +// frame. Used to HOLD the loading visuals through the post-load takeover +// gate window; the emulated output was suppressed all through the native +// loading screen, so yielding there would flash the stale pre-load frame. +bool g_loading_hold = false; + +// Menus / pause / loading yield gate (see the comment at the call site): +// returns true when RenderScene must yield this frame to the emulated +// output. Handles the cache clears on entry and the takeover re-arm + +// loading-screen pipeline build / prewarm commit while in a load, whether +// the loading pixels themselves render emulated (yield) or natively +// (g_loading_native_frame). +bool YieldForMenus(const NativeGuestOutputRenderContext& context) { + static bool s_in_loading = false; + static bool s_seen_gameplay = false; + static bool s_pause_native = false; + const bool in_menus = rex::graphics::ultrawide_debug::Skate3GameplayContextValue() == 0; + // Render-thread mirror for the 2D texture resolver: menu screens shorten + // the content-liveness recheck cadence (see resolve_2d_texture) so + // in-place rewrites of UI textures (the one-shot skater-portrait resolves) + // heal within a couple of frames instead of up to 16. + g_in_menus_frame.store(in_menus, std::memory_order_relaxed); + if (!in_menus) { + s_seen_gameplay = true; + } + // In-game pause menu: the presence context reads 0, but the world keeps + // resubmitting perspective scenes behind the menu (loading screens and the + // boot frontend stop publishing): stay native there so the pause backdrop + // renders natively and the caches survive the pause. The 2D pause UI rides + // the same captured-APT overlay replay as the gameplay HUD. If publishes + // go stale (a load was picked from the pause menu, or the game stops + // redrawing the world), this degrades to the yield path below within + // ~300 ms, cache clears and all. + // boot_native lifts the first-gameplay prerequisite from both native menu + // modes: a boot-frontend 3D backdrop renders like a pause backdrop, and + // everything else (videos, menus, the first load) renders as 2D-over-black. + const bool boot_native = REXCVAR_GET(skate3_native_render_scene_boot_native); + bool pause_native = false; + if (in_menus && (s_seen_gameplay || boot_native) && + REXCVAR_GET(skate3_native_render_scene_pause_native)) { + const int64_t last_ns = g_last_publish_ns.load(std::memory_order_relaxed); + const int64_t now_ns = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + pause_native = last_ns >= 0 && now_ns - last_ns < 300'000'000; + } + if (pause_native != s_pause_native) { + s_pause_native = pause_native; + if (pause_native) { + REXLOG_INFO( + "native-scene: pause menu over live world - staying NATIVE " + "(2d stats at entry: draws_2d={} other={} dropped={})", + g_draws_2d.load(std::memory_order_relaxed), + g_draws_2d_other.load(std::memory_order_relaxed), + g_draws_2d_dropped.load(std::memory_order_relaxed)); + } else { + REXLOG_INFO( + "native-scene: leaving native pause ({}; 2d stats at exit: " + "draws_2d={} other={} dropped={})", + in_menus ? "scene publishes went stale - loading/frontend" + : "gameplay resumed", + g_draws_2d.load(std::memory_order_relaxed), + g_draws_2d_other.load(std::memory_order_relaxed), + g_draws_2d_dropped.load(std::memory_order_relaxed)); + } + } + // Menu-context un-suppression: the game produces some content as one-shot + // off-screen renders; the team-menu skater portrait boxes are a + // render-to-texture pass issued once when the screen opens (and re-issued + // after an edit). With emulated draws suppressed those passes never + // execute, the resolve never writes the portrait texture, and the boxes + // stay empty forever (the F11 emulated pair-shot showed the same empty box + // - the texture is persistent guest state that was simply never filled). + // While a menu context is up, clear the SDK suppress cvar so the emulated + // pipeline keeps every RTT/composite current; the extra GPU cost is + // menu-only. The saved value is restored on the first gameplay frame, so a + // user toggle of the underlying cvar in the debug dialog survives (it is + // re-read at each menu entry). + { + static bool s_unsup_forced = false; + static bool s_unsup_saved = false; + const bool want = + in_menus && REXCVAR_GET(skate3_native_render_scene_menu_unsuppress); + if (want && !s_unsup_forced) { + s_unsup_saved = REXCVAR_GET(native_render_suppress_emulated_draws); + if (s_unsup_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, false); + REXLOG_INFO( + "native-scene: menu context - emulated draw suppression OFF " + "(one-shot render-to-texture passes execute; restored on " + "gameplay)"); + } + s_unsup_forced = true; + } else if (!want && s_unsup_forced) { + if (s_unsup_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, true); + REXLOG_INFO( + "native-scene: leaving menu context - emulated draw suppression " + "restored"); + } + s_unsup_forced = false; + } + } + // Menu-context suppression FILTER relaxation (the near-native sibling of + // the full lift above): mode 0 keeps the framebuffer passes suppressed, + // the screen stays natively composed, but lets the sub-framebuffer RTT + // passes execute, which is where the one-shot skater-portrait renders + // live (their pitch sits inside the mode-2 suppressed band; with mode 2 + // active in menus the boxes stayed empty). + { + static bool s_mode_forced = false; + static int32_t s_mode_saved = 0; + // menu_rtt_scope 1: only hold mode 0 inside the portrait window + // (screen-transition grace / not-known-steady screens / CAS); outside + // it, menus keep the gameplay-proven mode-2 suppression instead of + // executing the game's postfx chain emulated at scale every frame + // (the pause-menu/title-screen GPU contention). + const bool want = + in_menus && REXCVAR_GET(skate3_native_render_scene_menu_rtt_passes) && + (REXCVAR_GET(skate3_native_render_scene_menu_rtt_scope) == 0 || + PortraitRttWindowActive()); + if (want && !s_mode_forced) { + s_mode_saved = REXCVAR_GET(native_render_suppress_mode); + // Mode 3, not 0: the portrait-class RTTs (census 560-1200 during the + // window) execute, but the 1152-wide main scene + postfx band stays + // suppressed; mode 0 ran the game's whole pipeline at scaled + // resolution for the window's duration, dropping the pause menu to + // ~60 fps whenever a screen push (or its 3 s transition grace) + // opened the window. + if (s_mode_saved != 3) { + REXCVAR_SET(native_render_suppress_mode, 3); + REXLOG_INFO( + "native-scene: portrait window - suppress mode {} -> 3 (portrait " + "RTT passes execute, scene/postfx band stays suppressed; " + "restored when the window closes)", + s_mode_saved); + } + s_mode_forced = true; + } else if (!want && s_mode_forced) { + if (s_mode_saved != 3) { + REXCVAR_SET(native_render_suppress_mode, s_mode_saved); + REXLOG_INFO( + "native-scene: portrait window closed - suppress mode {} restored", + s_mode_saved); + } + s_mode_forced = false; + } + // Same menu window: shader compilation goes SYNCHRONOUS. With + // async_shader_compilation on, the d3d12 command processor SKIPS any + // draw whose pipeline is still compiling (command_processor.cpp + // ConfigurePipeline tail): fine mid-gameplay, but the skater-portrait + // boxes are ONE-SHOT renders: pieces skipped during a first-run compile + // are baked into the portrait forever (the armless/torso-less + // skaters; later runs are fine because the + // shader/pipeline disk storage is warm). Menus tolerate the one-time + // compile stalls invisibly. + static bool s_async_forced = false; + static bool s_async_saved = false; + if (want && !s_async_forced) { + s_async_saved = REXCVAR_GET(async_shader_compilation); + if (s_async_saved) { + REXCVAR_SET(async_shader_compilation, false); + REXLOG_INFO( + "native-scene: menu context - shader compilation synchronous " + "(one-shot portrait renders can't skip still-compiling pieces)"); + } + s_async_forced = true; + } else if (!want && s_async_forced) { + if (s_async_saved) { + REXCVAR_SET(async_shader_compilation, true); + } + s_async_forced = false; + } + } + const bool in_loading = in_menus && !pause_native; + // Loading screens themselves render natively too (black + the captured 2D + // loading UI) when enabled, everything after the first gameplay. The + // housekeeping below runs for the loading STATE either way; only the + // yield decision changes. + const bool loading_native = + in_loading && (s_seen_gameplay || boot_native) && + REXCVAR_GET(skate3_native_render_scene_loading_native); + g_loading_native_frame = loading_native; + if (in_loading != s_in_loading) { + s_in_loading = in_loading; + if (in_loading) { + REXLOG_INFO( + "native-scene: menus/loading - {} (presence context)", + loading_native ? "rendering the loading screen NATIVELY" + : "yielding to emulated output"); + // Arena addresses are reused across map loads: let the next load's + // registrations re-queue meshes (and re-stage textures) at reused + // addresses, and drop the cached item cores built from them. + ClearItemCache(); + // Off-screen retention holds guest-address-keyed copies too. + g_retained_clear.store(true, std::memory_order_relaxed); + std::lock_guard lock(g_prewarm_mutex); + g_prewarm_seen.clear(); + g_prewarm_tex_seen.clear(); + } else { + size_t queued = 0; + { + std::lock_guard lock(g_prewarm_mutex); + queued = g_prewarm_queue.size(); + } + REXLOG_INFO( + "native-scene: gameplay (prewarm: {} meshes decoded, {} dropped, {} still " + "queued)", + g_prewarm_done.load(std::memory_order_relaxed), + g_prewarm_dropped.load(std::memory_order_relaxed), queued); + } + } + if (in_loading) { + // Re-arm the takeover gate for the next stretch of gameplay (map + // load, unpause). + g_warmup_armed.store(true, std::memory_order_relaxed); + { + // Freshness gate: only scenes published AFTER this point qualify; + // g_scene still holds the previous map's last scene all through the + // loading screen (BuildFrameScene stops publishing without a + // perspective view). + std::lock_guard lock(g_scene_mutex); + g_warmup_fresh_generation = (g_scene ? g_scene->generation : 0) + 1; + } + // Build the pipelines / render targets behind the loading screen so + // the one-time PSO compilation (~200 ms) never lands on a gameplay + // frame. + if (!g_r.failed && g_r.pso == nullptr) { + EnsurePipeline(context); + } + if (loading_native) { + // RenderScene renders this frame (black + 2D loading UI) and runs + // the prewarm commit itself with the loading budget. + return false; + } + // THE loading-screen heavy lifting runs on the prewarm decode WORKER + // POOL (a serial render-thread drain both tanked the loading spinner + // to ~13 fps and still left 1500 of a map's ~2600 meshes undecoded at + // takeover; map-change loads register most of the world in their + // final seconds and drop the guest to 10-25 fps while doing it). + // Here the render thread only commits finished results: record the + // GPU copies, create SRVs, insert into the caches. + if (REXCVAR_GET(skate3_native_render_scene_prewarm_budget_ms) > 0 && + !g_r.failed && g_r.pso != nullptr) { + EnsurePrewarmWorkers(); + PrewarmCommit(context, g_frames_rendered.load(std::memory_order_relaxed), + /*loading=*/true); + } + return true; + } + return false; +} + + // Photo-mission photo editor (the "Pick a photo" screen with the depth of + // field / saturation / brightness / contrast controls; FE screen class + // PhotoSelect, challenge/photoselect.swf): the editor's effects ARE the + // game's postfx chain, which native rendering suppresses, so natively + // the photo showed the raw scene and the controls did nothing. Yield to + // the emulated output while the editor is up: the emulated frame there is + // complete and exact, and the scene is frozen so emulated-path + // performance is fine. Unlike the menus branch above this touches no + // caches and no takeover gates; native rendering resumes on the next + // frame after the editor closes. + // + // Detection is a per-frame poll of the game's FE state (stateless, so it + // can't get stuck): FrontEndManager singleton ptr global 0x830CFE14 + // (TU3; from SingletonHolder::Instance = 824AD2F0), + // whose NIS FE push-state stack (eastl::vector of 20-byte records at + // +0x210) holds a {1, 11} record exactly while the photographer NIS has + // the editor pushed. Surveyed across 50 gsnaps spanning gameplay, menus + // and other missions: every non-editor record reads {x, -1}; only the + // photo-editor capture shows a non-(-1) second field. The + // PhotoReplayController heartbeat (sub_825623F0 hook) is kept as a + // secondary signal for photo flows that bypass the photographer NIS. +// FMV playback (intro logos, any full-motion video): the frame is +// CPU-decoded into a texture (VideoRenderer_RwTexture Lock/Fill/Unlock) and +// reaches the screen through the game's postfx chain + swap; no capturable +// 2D draw exists (captured FMV frames show ~15 draws, all postfx +// passes + fade fills), so the native path has nothing to replay. Yield +// while the MovieDecoder::Decode heartbeat is fresh; the emulated frame is +// complete and correct there (photo-editor class). Touches no caches or +// takeover gates; native rendering resumes on the next frame after the +// movie ends. +bool YieldForMovie() { + if (!REXCVAR_GET(skate3_native_render_scene_fmv_yield)) { + return false; + } + const int64_t last_ns = g_movie_decode_last_ns.load(std::memory_order_relaxed); + if (last_ns < 0) { + return false; + } + const int64_t now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + const bool active = now_ns - last_ns < 500'000'000; + // LAST-RESORT gating: yielding INSTANTLY at video start presented the + // emulated framebuffer, which under draw suppression still holds a stale + // frame: the flash of the PREVIOUS video at every video boundary + // (measured: yield, planes published +16 ms, substitution + // native +38 ms, a ~5-frame stale-frame window). The substitution path + // gets 400 ms from the first heartbeat to engage, and once it has drawn + // recently the yield stays off entirely (covers the video-END race the + // same way). Genuine native-FMV failure (fmv_native off, pso missing, + // plane decode failure) still reaches the emulated yield after the + // grace window. + static int64_t s_fresh_since = -1; + if (!active) { + s_fresh_since = -1; + } else if (s_fresh_since < 0) { + s_fresh_since = now_ns; + } + const int64_t native_ns = g_movie_native_last_ns.load(std::memory_order_relaxed); + // Yield only for a video that is ON SCREEN and NOT being served: + // (a) quad_recent - the 2D replay recently carried a video quad (detection + // runs regardless of movie_sub). A skipped/force-completed video + // (intro skip) leaves the decoder heartbeat alive ~0.5 s with no quad + // anywhere; yielding then flashed the stale emulated buffer. + // (b) staleness measured decoder-vs-native, not wall-clock: at a manual + // skip the quad vanishes instantly but the decoder tail keeps beating + // up to ~500 ms - a genuinely unsubstituted video keeps decoding long + // past the last native draw and crosses the 1 s bar within ~1.4 s of + // starting; a skip tail (<= ~600 ms) never can. + const int64_t quad_ns = g_movie_quad_last_ns.load(std::memory_order_relaxed); + const bool quad_recent = quad_ns >= 0 && now_ns - quad_ns < 500'000'000; + const bool yield = active && now_ns - s_fresh_since >= 400'000'000 && quad_recent && + (native_ns < 0 || last_ns - native_ns > 1'000'000'000); + static bool s_active = false; + if (yield != s_active) { + s_active = yield; + if (yield) { + REXLOG_INFO( + "native-scene: FMV playing - yielding to emulated output " + "(MovieDecoder heartbeat; substitution did not engage)"); + } else { + REXLOG_INFO("native-scene: FMV ended - native output resumes"); + } + } + return yield; +} + +} // namespace +// The photo-editor detection, shared by the yield and the photo-grab +// readback window: nullptr when inactive, else the name of the signal. +const char* PhotoEditorSignal(uint8_t* base) { + const char* signal = nullptr; + { + constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; + uint32_t mgr = 0, beg = 0, end = 0; + if (GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) && mgr != 0 && + GuestTryLoadU32(base, mgr + 0x210, &beg) && + GuestTryLoadU32(base, mgr + 0x214, &end) && beg < end && + end - beg <= 20 * 16) { + const uint32_t n = (end - beg) / 20; + for (uint32_t i = 0; i < n && signal == nullptr; ++i) { + uint32_t f0 = 0, f1 = 0; + if (GuestTryLoadU32(base, beg + i * 20, &f0) && + GuestTryLoadU32(base, beg + i * 20 + 4, &f1) && f0 == 1 && f1 == 11) { + signal = "FE PhotoSelect push-state"; + } + } + } + } + if (signal == nullptr) { + const int64_t last_ns = g_photo_replay_last_ns.load(std::memory_order_relaxed); + const int64_t now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + if (last_ns >= 0 && now_ns - last_ns < 250'000'000) { + signal = "PhotoReplayController heartbeat"; + } + } + return signal; +} + +namespace { + +// ---- Native photo grab (photo_grab_native) --------------------------------- +// The grab content is produced NATIVELY while emulated-draw suppression stays +// ON: RenderScene downsamples the finished native frame into blur_tex[0] +// (1152x640: the blur space IS the game's screenshot raster), copies it to a +// persistently-mapped READBACK buffer (double-buffered, never a GPU drain), +// and the next armed frame CPU-tiles the completed one big-endian into the +// guest screenshot target that ScreenshotBackEnd::GrabScreenshot reads. +// Layout contract (traced through sub_824FD550 -> the sub_82A7DC20 surface +// download): tiled Xenos 2D, 32bpp big-endian ARGB (guest byte order +// A,R,G,B); the game XGUntileTextureLevel's it into a linear buffer and +// hands the raw bytes to JPEG::WriteJPEG with no channel swap anywhere. The +// 160x90 gallery thumb is CPU-resized from this same image INSIDE +// GrabScreenshot (cImageOperateMan::ResizeJPEG); there is no second buffer +// to fill. Ground truth fetch words (from capture): 1152x640 +// pitch 1152 k_8_8_8_8 tiled at 0x04911000 (the same surface the SDK's +// Import-Skater special case hardcodes). The write transform is +// byte-for-byte PROVEN offline against that capture's real readback +// content: tiling + [A,R,G,B] guest order reproduced all 0x2D0000 bytes +// with zero mismatches (alpha included: the emulated resolve also wrote +// 255). +constexpr uint32_t kGrabGuestAddr = 0x04911000; +// The CPU (and the app's whole texture-decode path) accesses physical +// memory through the 0xA0000000 cached-physical view; the gsnap ground +// truth lives at 0xA4911000 and plain base+0x04911000 is NOT an alias of +// those pages. All host-side reads/writes of the grab target go through +// this view. +constexpr uint32_t kGrabGuestView = 0xA0000000u | kGrabGuestAddr; +// Photo window armed AND fulfilled natively this window (RenderScene's grab +// block produces/consumes while this is set; the watchdog in +// UpdatePhotoGrabWindow clears it and flips to the emulated fallback if +// native frames stop landing). +std::atomic g_photo_grab_native_armed{false}; +// Last successful guest write (PerfClock ns): the watchdog liveness signal. +std::atomic g_grab_last_write_ns{-1}; +// The photo display-card quad was in the 2D stream this instant (PerfClock +// ns, -1 = never). Stamped by Publish2dDraws (guest thread, runs every +// guest frame regardless of yields) whenever the card-signature quad, the +// 504x640 LINEAR JPEG-decode texture, unique to the photo display card, +// is present. Drives YieldForPhotoDisplay: the display screen renders +// EMULATED, because the game composes the framed card (white border / +// caption / logo) in a ONE-SHOT pitch-1280 pass whose timing proved +// unpredictable (observed anywhere from settle+0.8s to settle+4.0s; every +// readback-window attempt missed it and cost seconds of concurrent-pipeline +// lag). Yielded-emulated, the compose and the display chain just work +// GPU-side with no readbacks, and with the native renderer not running +// concurrently the frozen scene renders at the emulated pipeline's own +// healthy rate. (g_photo_card_seen_ns itself is defined next to +// g_photo_flow_frame; Publish2dDraws lives outside this anonymous +// namespace.) +// The display yield is active / when it last ended (watchdog interplay: +// grab writes legitimately stop while yielded, and get fresh grace after). +std::atomic g_photo_display_yielding{false}; +std::atomic g_photo_display_yield_end_ns{-1}; + +// Validate the game-side target before writing guest memory: GrabScreenshot +// reads its main-target dims from the postfx render manager (mgr = +// *(0x83083C5C), width/height at mgr+380/384). The GPU address has been +// 0x04911000 in every observed session, but refuse to write if the dims +// chain does not confirm the expected 1152x640 raster. +bool GrabTargetValid(uint8_t* base) { + uint32_t mgr = 0, w = 0, h = 0; + if (!GuestTryLoadU32(base, 0x83083C5C, &mgr) || mgr == 0 || + !GuestTryLoadU32(base, mgr + 380, &w) || + !GuestTryLoadU32(base, mgr + 384, &h)) { + return false; + } + return w == RendererState::kBlurWidth && h == RendererState::kBlurHeight; +} + +// CPU-tile one mapped readback image (R10G10B10A2 rows at kGrabRowPitch) +// into the guest screenshot target: assemble the tiled big-endian ARGB image +// in a host staging buffer, then land it with one SEH-guarded copy. Inverse +// of the decode path's run-copy untiler: at 32bpp a 4-texel-aligned x-run of +// 4 maps to contiguous tiled bytes (same proof as the untiler's run_blocks = +// 16 >> bpb_log2), so one GetTiledOffset2D per 4 texels. 1152x640 = 184k +// offset computations, well under a millisecond. +bool WriteGrabToGuest(uint8_t* base, const uint8_t* src) { + constexpr uint32_t kW = RendererState::kBlurWidth; + constexpr uint32_t kH = RendererState::kBlurHeight; + static thread_local std::vector staging; + staging.resize(size_t(kW) * kH * 4); + for (uint32_t y = 0; y < kH; ++y) { + const uint32_t* row = reinterpret_cast( + src + size_t(y) * RendererState::kGrabRowPitch); + for (uint32_t x = 0; x < kW; x += 4) { + const uint32_t off = uint32_t(rex::graphics::texture_util::GetTiledOffset2D( + int32_t(x), int32_t(y), kW, 2)); + uint32_t* dst = reinterpret_cast(staging.data() + off); + for (uint32_t i = 0; i < 4; ++i) { + // R10G10B10A2 (R bits 0..9, G 10..19, B 20..29), top 8 of each -> + // little-endian byte order A,R,G,B = the guest's big-endian ARGB. + const uint32_t v = row[x + i]; + dst[i] = 0xFFu | ((v << 6) & 0xFF00u) | ((v << 4) & 0xFF0000u) | + ((v << 2) & 0xFF000000u); + } + } + } + return GuestTryCopy(base + kGrabGuestView, staging.data(), staging.size()); +} + +// The photo-grab window (see skate3_native_render_scene_photo_readback / +// photo_grab_native): while the photo editor is up OR a TakePhoto fired +// within the last few seconds, the game CPU-reads the resolved screenshot +// target from guest memory to JPEG-encode the photo. Two ways to fulfill it: +// - NATIVE (photo_grab_native, default): RenderScene writes the target +// itself from the native output; emulated-draw suppression stays ON, so +// the emulated pipeline never renders the scene+postfx concurrently (that +// concurrent 3x3-scale render was the editor's 40-50 fps, proven by the +// kFast run: zero readback drains, same fps). +// - EMULATED fallback: (a) arm the SDK's forced small-resolve CPU readback +// so the game's own resolve lands in guest memory, and (b) lift +// emulated-draw suppression so the passes that render the target execute. +// Runs every frame from RenderScene regardless of the yield decisions. A +// watchdog flips native -> emulated mid-window if no native grab write has +// landed recently (mode toggle, unexpected yield, readback failure). +void UpdatePhotoGrabWindow(uint8_t* base) { + // The grab target is 1152x640x4 = 0x2D0000 bytes (the same surface the + // SDK's Import-Skater special case reads); the thumb path is smaller. + // Framebuffer-sized resolves (0x384000 at 1280x720) stay excluded. + constexpr int32_t kForceReadbackMaxLength = 0x2D0000; + static bool s_armed = false; + static bool s_emulated_armed = false; + static bool s_burst_armed = false; + static int64_t s_arm_ns = 0; + static int64_t s_last_run_ns = 0; + static int32_t s_readback_saved = 0; + static bool s_suppress_saved = false; + static bool s_halfpx_saved = false; + static int32_t s_burst_max_saved = 0; + static bool s_burst_suppress_saved = false; + static bool s_burst_halfpx_saved = false; + static int64_t s_burst_open_ns = 0; + const int64_t now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + const auto disarm_burst = [&] { + s_burst_armed = false; + if (s_burst_max_saved <= 0) { + REXCVAR_SET(native_render_force_resolve_readback_max_length, + s_burst_max_saved); + } + if (s_burst_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, true); + } + if (!s_burst_halfpx_saved) { + REXCVAR_SET(readback_resolve_half_pixel_offset, false); + } + }; + // This function only runs while the native renderer is producing frames + // (RenderScene). A gap means the native path was toggled off or yielding + // - grab writes legitimately paused, so give the watchdog a fresh grace + // period on resume instead of instantly tripping the fallback + // (an emulated/native comparison toggle on the display + // screen fired the watchdog 2 ms after switching back). + if (s_armed && s_last_run_ns != 0 && now_ns - s_last_run_ns > 300'000'000) { + s_arm_ns = now_ns; + } + s_last_run_ns = now_ns; + bool want = false; + if (REXCVAR_GET(skate3_native_render_scene_photo_readback)) { + want = PhotoEditorSignal(base) != nullptr; + if (!want) { + const int64_t last_ns = g_take_photo_last_ns.load(std::memory_order_relaxed); + if (last_ns >= 0) { + want = now_ns - last_ns < 3'000'000'000; + } + } + } + // Same window arms the photo postfx constant capture (CapturePfxConstants + // runs on the guest thread at the draw-done hook). + g_photo_flow_frame.store(want, std::memory_order_relaxed); + const auto arm_emulated = [&] { + s_readback_saved = REXCVAR_GET(native_render_force_resolve_readback_max_length); + if (s_readback_saved <= 0) { + REXCVAR_SET(native_render_force_resolve_readback_max_length, + kForceReadbackMaxLength); + } + s_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws); + if (s_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, false); + } + // Sample host-pixel CENTERS in the scaled->1x readback extraction while + // the window is armed (photo quality: undoes the D3D9 half-pixel shift + // that becomes a scale/2-pixel offset at scaled resolutions). + s_halfpx_saved = REXCVAR_GET(readback_resolve_half_pixel_offset); + if (!s_halfpx_saved) { + REXCVAR_SET(readback_resolve_half_pixel_offset, true); + } + s_emulated_armed = true; + }; + // Shader compilation goes SYNCHRONOUS for the whole photo flow; the + // menus lesson applied here: with async_shader_compilation on, the d3d12 + // command processor SKIPS any draw whose pipeline is still compiling, and + // the photo display screen's framed-card compose is a ONE-SHOT pass. + // Its shaders have been suppressed-cold in every native session (and the + // pipeline caches were invalidated by SDK rebuilds), so the compose's + // draws were skipped at the exact moment they finally ran, which is why + // the card stayed un-framed EVEN IN EMULATED rendering. + // One-time compile stalls during a photo UI moment are invisible. + static bool s_async_forced = false; + static bool s_async_saved = false; + if (want && !s_async_forced) { + s_async_saved = REXCVAR_GET(async_shader_compilation); + if (s_async_saved) { + REXCVAR_SET(async_shader_compilation, false); + REXLOG_INFO( + "native-scene: photo flow - shader compilation synchronous (the " + "one-shot card compose can't skip still-compiling draws)"); + } + s_async_forced = true; + } else if (!want && s_async_forced) { + if (s_async_saved) { + REXCVAR_SET(async_shader_compilation, true); + } + s_async_forced = false; + } + if (want && !s_armed) { + s_armed = true; + s_arm_ns = now_ns; + g_grab_last_write_ns.store(-1, std::memory_order_relaxed); + g_r.grab_writes = 0; + g_r.grab_cpu_us = 0; + // Drop any readback still pending from a previous window; its content + // is stale (pre-window) and must never land ahead of fresh frames. + g_r.grab_pending[0] = false; + g_r.grab_pending[1] = false; + if (REXCVAR_GET(skate3_native_render_scene_photo_grab_native) && + !g_r.grab_failed) { + g_photo_grab_native_armed.store(true, std::memory_order_relaxed); + REXLOG_INFO( + "native-scene: photo flow - NATIVE grab armed (suppression stays " + "on; native output CPU-tiled into the guest screenshot target; " + "display card yields to emulated; shutter burst on grab request)"); + } else { + arm_emulated(); + REXLOG_INFO( + "native-scene: photo flow - forcing small-resolve CPU readback " + "(the photo grab reads the resolved screenshot target from guest " + "memory){}", + s_suppress_saved ? " + emulated draw suppression OFF" : ""); + } + } else if (!want && s_armed) { + s_armed = false; + if (s_burst_armed) { + disarm_burst(); + } + if (g_photo_grab_native_armed.exchange(false, std::memory_order_relaxed)) { + REXLOG_INFO( + "native-scene: photo flow ended - native grab wrote {} frames " + "(avg tile+write {} us)", + g_r.grab_writes, + g_r.grab_writes ? g_r.grab_cpu_us / g_r.grab_writes : 0); + } + if (s_emulated_armed) { + s_emulated_armed = false; + if (s_readback_saved <= 0) { + REXCVAR_SET(native_render_force_resolve_readback_max_length, + s_readback_saved); + } + if (s_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, true); + } + if (!s_halfpx_saved) { + REXCVAR_SET(readback_resolve_half_pixel_offset, false); + } + REXLOG_INFO( + "native-scene: photo flow ended - resolve readback{} restored", + s_suppress_saved ? " + suppression" : ""); + } + } + // Watchdog: native mode armed but no grab write has landed within 700 ms + // of the window opening / the last write / the display yield ending; + // native frames stopped while the native output should be live + // (persistent yield, readback failure). Flip to the emulated fallback so + // the photo still works; one-way for the rest of this window. Skipped + // while the display-card yield is active: grab writes legitimately stop + // there (the photo was already taken and saved). + if (s_armed && !s_emulated_armed && + !g_photo_display_yielding.load(std::memory_order_relaxed) && + g_photo_grab_native_armed.load(std::memory_order_relaxed)) { + const int64_t last_write = g_grab_last_write_ns.load(std::memory_order_relaxed); + const int64_t yield_end = + g_photo_display_yield_end_ns.load(std::memory_order_relaxed); + const int64_t basis = std::max(std::max(last_write, s_arm_ns), yield_end); + if (now_ns - basis > 700'000'000) { + g_photo_grab_native_armed.store(false, std::memory_order_relaxed); + if (s_burst_armed) { + disarm_burst(); + } + arm_emulated(); + REXLOG_WARN( + "native-scene: photo flow - native grab produced no frames for " + "700 ms; falling back to the forced-readback window{}", + s_suppress_saved ? " + suppression lifted" : ""); + } + } + // SHUTTER BURST: the game builds the framed card as a dedicated frame + // sequence at the grab - request (hooked: OnPhotoGrabRequest) -> render + // the shot + card-composite passes -> resolve -> GrabScreenshot/ + // OnScreenShot CPU-read the resolves and CPU-compose the card texture. + // Under the native path those frames are suppressed and nothing lands in + // CPU memory, so the compose blends zeros = the un-framed card (proven: + // the control run's composite CPU copies all land inside this window; + // every post-hoc timing window missed it because the CONSUMER runs + // immediately at the shutter). For ~1.5 s from the request: suppression + // fully lifted + kFull readbacks up to 0x2D0000 (the full composite + // class; the handful of drains sits inside the shutter freeze the game + // already has). The emulated 0x04911000 resolve may overwrite the + // natively-written photo during the burst, same frozen scene, emulated + // 3x3 quality, the pre-native-grab standard, acceptable. + if (s_armed && !s_emulated_armed && + g_photo_grab_native_armed.load(std::memory_order_relaxed)) { + const int64_t req = g_photo_grab_request_ns.load(std::memory_order_relaxed); + // EVENT-DRIVEN close: GrabScreenshot COMPLETING (post-call stamp, + // OnPhotoGrabDone) means every remaining card-build step, the + // OnScreenShot compose included, is CPU-side work on memory the burst + // already copied. The burst therefore spans exactly request -> + // grab-done: the 1-3 shot frames, the physical minimum, all inside the + // game's own shutter freeze, instead of a timer running into the + // display fly-in (the user-visible lag spike). A 150 ms grace after + // grab-done covers the aux-target grab some flows issue on the + // following frame; the 1.5 s cap remains only as the fallback for a + // request that never grabs. + const int64_t done_ns = g_photo_grab_done_ns.load(std::memory_order_relaxed); + const bool grab_done = done_ns > req && now_ns - done_ns > 150'000'000; + const bool burst_want = + req >= 0 && now_ns - req < 1'500'000'000 && !grab_done; + if (burst_want && !s_burst_armed) { + s_burst_armed = true; + s_burst_max_saved = + REXCVAR_GET(native_render_force_resolve_readback_max_length); + if (s_burst_max_saved <= 0) { + REXCVAR_SET(native_render_force_resolve_readback_max_length, 0x2D0000); + } + s_burst_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws); + if (s_burst_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, false); + } + s_burst_halfpx_saved = REXCVAR_GET(readback_resolve_half_pixel_offset); + if (!s_burst_halfpx_saved) { + REXCVAR_SET(readback_resolve_half_pixel_offset, true); + } + s_burst_open_ns = now_ns; + REXLOG_INFO( + "native-scene: photo grab REQUEST - shutter burst OPEN " + "(suppression lifted + <=0x2D0000 kFull readbacks; the shot + " + "card-composite frames render and land in guest memory; closes " + "when the grab completes)"); + } else if (!burst_want && s_burst_armed) { + disarm_burst(); + REXLOG_INFO( + "native-scene: shutter burst closed after {} ms ({}) - " + "suppression + readback restored", + (now_ns - s_burst_open_ns) / 1'000'000, + grab_done ? "grab completed" : "1.5 s cap"); + } + } +} + +// Photo display-card screen: yield to the emulated output while the framed +// card is up (card-signature quad in the 2D stream, stamped by +// Publish2dDraws). The game composes the framed card in a ONE-SHOT +// pitch-1280 pass at an unpredictable moment after display-open +// (observed settle+0.8s to settle+4.0s); chasing it with +// suppression-lift + forced-readback windows never worked and cost +// seconds of concurrent-pipeline lag. Yielded-emulated, the compose and the +// display chain run GPU-side with no readbacks, exactly as on console, and +// with the native renderer idle the frozen scene renders at the emulated +// pipeline's own rate. A short PRE-ROLL lifts suppression while the native +// output still presents, so the emulated framebuffer has repainted (the +// card fly-in, live) before the swap, no stale-frame flash (the FMV-yield +// lesson). +bool YieldForPhotoDisplay() { + constexpr int64_t kCardGoneNs = 250'000'000; + constexpr int64_t kPreRollNs = 250'000'000; + static bool s_preroll = false; + static bool s_suppress_saved = false; + static int64_t s_preroll_ns = 0; + // Default OFF since the shutter burst was added: the framed card + // is CPU-composed into the card texture at the grab, and the native 2D + // replay samples that same texture; the display screen renders fully + // native (card + blurred backdrop + buttons). The yield remains as a + // safety hatch for display-screen regressions. + if (!REXCVAR_GET(skate3_native_render_scene_photo_display_yield)) { + if (s_preroll) { + s_preroll = false; + if (s_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, true); + } + if (g_photo_display_yielding.exchange(false, std::memory_order_relaxed)) { + g_photo_display_yield_end_ns.store( + std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(), + std::memory_order_relaxed); + } + } + return false; + } + const int64_t now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + const int64_t seen = g_photo_card_seen_ns.load(std::memory_order_relaxed); + const bool card_up = g_photo_flow_frame.load(std::memory_order_relaxed) && + seen >= 0 && now_ns - seen < kCardGoneNs; + if (!card_up) { + if (s_preroll) { + s_preroll = false; + if (s_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, true); + } + if (g_photo_display_yielding.exchange(false, std::memory_order_relaxed)) { + g_photo_display_yield_end_ns.store(now_ns, std::memory_order_relaxed); + REXLOG_INFO( + "native-scene: photo display card gone - native output resumes " + "(suppression restored)"); + } + } + return false; + } + if (!s_preroll) { + s_preroll = true; + s_preroll_ns = now_ns; + s_suppress_saved = REXCVAR_GET(native_render_suppress_emulated_draws); + if (s_suppress_saved) { + REXCVAR_SET(native_render_suppress_emulated_draws, false); + } + REXLOG_INFO( + "native-scene: photo display card up - emulated pre-roll " + "(suppression lifted; yielding to the emulated output in {} ms so " + "the game's own card compose + display chain render there)", + kPreRollNs / 1'000'000); + } + if (now_ns - s_preroll_ns < kPreRollNs) { + return false; + } + if (!g_photo_display_yielding.exchange(true, std::memory_order_relaxed)) { + REXLOG_INFO("native-scene: photo display - yielded to the emulated output"); + } + return true; +} + +bool YieldForPhotoEditor(uint8_t* base) { + // The native photo-fx chain (photo_fx.hlsl, photo_native cvar) takes + // precedence: the editor stays native and RenderScene applies the game's + // postfx as exact ported passes with live-captured constants. + const bool native_fx = REXCVAR_GET(skate3_native_render_scene_photo_native); + if (!native_fx && !REXCVAR_GET(skate3_native_render_scene_photo_yield)) { + return false; + } + static bool s_in_photo_editor = false; + const char* signal = PhotoEditorSignal(base); + const bool photo_active = signal != nullptr; + if (photo_active != s_in_photo_editor) { + s_in_photo_editor = photo_active; + if (photo_active) { + REXLOG_INFO( + "native-scene: photo editor - {} ({})", + native_fx ? "staying NATIVE (ported postfx chain)" + : "yielding to emulated output (the game's postfx applies " + "the photo effects)", + signal); + } else { + REXLOG_INFO("native-scene: photo editor closed"); + } + } + if (native_fx) { + return false; + } + return photo_active && REXCVAR_GET(skate3_native_render_scene_photo_yield); +} + +// Create-a-skater editor (the 'Edit Skater' screen: skater + garage wall, +// Skin/Clothing/Body Mods panels): a special FE renderer the native scene +// does not model; the skater draws with editor-only CAC shader variants +// (cacstamp_skin_nisPS / cac_cloth_nisPS / cac_face_nisPS...) whose constant +// layouts differ from gameplay (the cacstamp map shifted +1 row: light c10, +// key c16, SH c25..c33 scale c22.y, tint c24, alpha c23.x, measured +// in capture), so every char-lighting capture is rejected and +// the skater rendered legacy-shaded (grey tank, pale skin, black jeans). The +// editor also runs per-frame texture-space composite passes (cac*_unwrapPS +// paint the edited garment/skin art into textures) and its own DOF postfx; +// live-edit previews are only correct with the full emulated chain. Yield +// while it is up (photo-editor class: scene is a small frozen room, emulated +// performance is fine; no cache or takeover-gate side effects; native +// resumes the frame after the editor closes, and the un-suppressed yield +// window also lets the game re-render the team-box skater portrait RTT that +// follows an accepted edit). +// +// Detection: stateless per-frame poll of the FrontEndManager push-state +// stack (same struct as YieldForPhotoEditor above) for a record with screen +// id 15, surveyed across the 40 gsnaps on hand (gameplay, pause root 56, +// team screen 63, photo editor {1,11}, FMV): id 15 appears exactly in the +// CAS editor capture and nowhere else. +bool YieldForCasEditor(uint8_t* base) { + if (!REXCVAR_GET(skate3_native_render_scene_cas_yield)) { + return false; + } + static bool s_in_cas_editor = false; + const char* signal = nullptr; + { + constexpr uint32_t kFrontEndManagerPtr = 0x830CFE14; + uint32_t mgr = 0, beg = 0, end = 0; + if (GuestTryLoadU32(base, kFrontEndManagerPtr, &mgr) && mgr != 0 && + GuestTryLoadU32(base, mgr + 0x210, &beg) && + GuestTryLoadU32(base, mgr + 0x214, &end) && beg < end && + end - beg <= 20 * 16) { + const uint32_t n = (end - beg) / 20; + for (uint32_t i = 0; i < n && signal == nullptr; ++i) { + uint32_t f0 = 0; + if (GuestTryLoadU32(base, beg + i * 20, &f0) && f0 == 15) { + signal = "FE push-state id 15"; + } + } + } + } + if (signal == nullptr) { + // Shader heartbeat: the editor's own "_nis" pixel shaders were set + // within the last 0.5 s (see IsCasEditorPs): covers editor entry + // points that use a different FE screen id (the startup new-game flow + // stayed NATIVE on the FE detection alone). + const int64_t last_ns = g_cas_ps_last_ns.load(std::memory_order_relaxed); + if (last_ns >= 0) { + const int64_t now_ns = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + if (now_ns - last_ns < 500'000'000) { + signal = "CAS _nis shader heartbeat"; + } + } + } + const bool cas_active = signal != nullptr; + if (cas_active != s_in_cas_editor) { + s_in_cas_editor = cas_active; + if (cas_active) { + REXLOG_INFO( + "native-scene: create-a-skater editor - yielding to emulated " + "output ({}; editor CAC shading + live composite passes render " + "exactly there)", + signal); + } else { + REXLOG_INFO( + "native-scene: create-a-skater editor closed - native output " + "resumes"); + } + } + return cas_active; +} + +// Services the debug-dialog texture/mesh cache flushes. (The old +// retired-buffer drain / SRV-slot recycling is gone: deferred destruction +// is backend-internal now: Device::DestroyDeferred.) +void ReleaseRetiredAndFlushCaches(const NativeGuestOutputRenderContext& context) { + // Debug-dialog cache flushes: retire every cached decode (freed once the + // GPU is done with the current submission) so hot-toggled decode settings + // rebuild the world with the new rules this frame. + if (g_flush_textures.exchange(false, std::memory_order_relaxed)) { + const uint64_t submission = context.device->CurrentSubmission(); + for (auto& [key, t] : g_r.tex_store) { + RetireGuestTexture(t, submission); + } + g_r.tex_store.clear(); + g_r.tex_routes.clear(); + g_r.words_sticky.clear(); + g_r.tex_sticky.clear(); + g_r.tex_pending_first.clear(); + REXLOG_INFO("native-scene: texture cache flushed (debug dialog)"); + } + if (g_flush_meshes.exchange(false, std::memory_order_relaxed)) { + for (auto& [key, m] : g_r.meshes) { + if (m.vb) g_r.device->DestroyDeferred(m.vb); + if (m.ib) g_r.device->DestroyDeferred(m.ib); + } + g_r.meshes.clear(); + ClearItemCache(); // decode-affecting toggles should re-walk items too + REXLOG_INFO("native-scene: mesh cache flushed (debug dialog)"); + } +} + + // ---- Dynamic-shadow atlas pass ---- + // Renders the frame's dynamic casters (skinned characters + rigid + // non-identity-world props: exactly the game's caster list) into the + // three cascade tiles with the captured light rows, then applies the + // game's coverage blur + depth dilation. Runs before the main pass so the + // scene shader can sample the finished atlas. +bool RenderShadowAtlas(const NativeGuestOutputRenderContext& context, + const FrameScene& scene, uint32_t bone_region, + int32_t debug_mode, uint32_t* out_draws) { + nrhi::Cmd* cmd = context.cmd; + const float* sh = scene.shadow_rows; + bool shadow_ready = false; + uint32_t shadow_draws = 0; + // Atlas-readback bookkeeping: write completed dumps to disk in frame + // order; re-arm once the recording session that requested them ends. + if (!g_recording.load(std::memory_order_relaxed) && + g_r.shadow_dump_written == g_r.shadow_dump_enqueued) { + g_r.shadow_dump_done = false; + g_r.shadow_dump_enqueued = 0; + g_r.shadow_dump_written = 0; + } + while (g_r.shadow_dump_written < g_r.shadow_dump_enqueued && + context.device->CompletedSubmission() > + g_r.shadow_dump_submission[g_r.shadow_dump_written]) { + const uint32_t k = g_r.shadow_dump_written; + const uint32_t tile_px = g_r.shadow_tile; + const uint32_t pitch = tile_px * 3u * 4u; + const uint64_t plane = uint64_t(pitch) * tile_px; + context.device->InvalidateForRead(g_r.shadow_dump_buf[k], 0, plane * 2); + std::string dir = REXCVAR_GET(skate3_native_render_snapshot_dir); + if (dir.empty()) { + dir = "native_render_snapshots"; + } + std::error_code ec; + std::filesystem::create_directories(dir, ec); + const std::filesystem::path path = + std::filesystem::path(dir) / + ("shadow_atlas_" + std::to_string(uint64_t(std::time(nullptr))) + "_f" + + std::to_string(k) + "_" + std::to_string(tile_px * 3) + "x" + + std::to_string(tile_px) + "_rg16.bin"); + std::ofstream out(path, std::ios::binary); + out.write(reinterpret_cast(g_r.shadow_dump_ptr[k]), + std::streamsize(plane * 2)); + REXLOG_INFO( + "native-scene: shadow atlas frame {} dumped (raw+blurred planes) -> {}", + k, path.string()); + ++g_r.shadow_dump_written; + } + if (REXCVAR_GET(skate3_native_render_scene_shadows) && scene.shadow_valid && + g_r.shadow_raw != nullptr && g_r.pso_shadow_caster != nullptr && + g_r.pso_shadow_blur != nullptr && debug_mode == 0) { + struct Caster { + const DrawItem* item; + uint32_t bone_offset; + bool bones; + }; + std::vector casters; + for (const DrawItem& item : scene.items) { + if (item.transparent || item.unlit || item.cloth_quads) { + continue; + } + const bool skinned = item.skinned && !item.bones.empty(); + // The game's CSM casts only DYNAMIC content: characters/vehicles + // (skinned), Ropa cloth, and dynamicobject props (the truck's caster + // draws, F10-verified). Static world scenery has its shadows BAKED + // into the lightmaps, and that includes world-PLACED instances + // (streetlights, trees, rails), which carry real world transforms. + // The old identity-matrix test let every placed prop into the caster + // pass, painting phantom streetlight-head/tree silhouettes onto the + // plaza glass towers (a "reflection-like" soft dark blob high on the + // facade, geometrically impossible as a real shadow: sun at 47 deg, + // lamp 8 m tall, blob at 96 m; the emulated frame has no such + // shadow). + if (!skinned && item.dynobj == 0 && !item.ropa && item.char_family == 0) { + continue; + } + // Entities the game is holding invisible (spawn settle / distance + // fade, see CharFadeAlpha) must not paint a shadow either; the + // emulated frame shows neither the NPC nor a blob under it. + if (item.char_family != 0 && CharFadeAlpha(item) < 0.05f && + REXCVAR_GET(skate3_native_render_scene_entity_fade)) { + continue; + } + // Per-piece caster parity: the game's own shadow passes skip some + // character pieces (the CAS trucker hat casts NOTHING; natively + // casting it painted a hard low-sun brim band across the editor face + // that the emulated frame never shows). Cast only what the game + // submitted through an ortho bank this frame (see + // DrawItem::shadow_caster / g_frame_ortho_ctx). + if (item.char_family != 0 && !item.shadow_caster && + REXCVAR_GET(skate3_native_render_scene_shadow_caster_parity)) { + continue; + } + // NO inline decode here (this block used to re-decode every cloth + // garment every frame, ~2.9 ms, the 160 fps cap during real play). + // The dyn decode jobs / worker miss queue keep the cache fresh, one + // frame behind the sim; a first-sight caster shadows 1-2 frames late. + if (!g_r.meshes.contains(item.mesh)) { + continue; + } + Caster c{&item, 0, false}; + if (skinned) { + const uint32_t bytes = uint32_t(item.bones.size() * sizeof(float)); + const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u; + if (offset + bytes > RendererState::kBoneRegionSize) { + continue; + } + std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.bones.data(), bytes); + g_r.bone_ring_offset = offset + bytes; + c.bone_offset = offset; + c.bones = true; + } + casters.push_back(c); + } + if (!casters.empty()) { + if (g_r.shadow_in_srv_state) { + for (nrhi::Texture* res : {g_r.shadow_raw, g_r.shadow_mid, g_r.shadow_final}) { + cmd->Barrier(res, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + } + cmd->FlushBarriers(); + g_r.shadow_in_srv_state = false; + } + const uint32_t tile = g_r.shadow_tile; + const float shadow_clear[4] = {1.0f, 1.0f, 0.0f, 0.0f}; + cmd->ClearRenderTarget(g_r.shadow_raw, shadow_clear); + cmd->SetRenderTargets(g_r.shadow_raw, nullptr); + cmd->SetBindingLayout(g_r.layout); + cmd->SetPipeline(g_r.pso_shadow_caster); + // Unused by the caster shaders, but never leave root CBVs unset. + cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); + for (int ci = 0; ci < 3; ++ci) { + // Cascade scale/offset (cascade 0 = identity; PS c1/c2 for 1/2). + float sx = 1.0f, sy = 1.0f, ox = 0.0f, oy = 0.0f; + if (ci == 1) { + sx = sh[4]; sy = sh[5]; ox = sh[6]; oy = sh[7]; + } else if (ci == 2) { + sx = sh[8]; sy = sh[9]; ox = sh[10]; oy = sh[11]; + } + // Light view-proj, row-vector convention: clip.x = ls_i.x, + // clip.y = ls_i.y, clip.z = the height-ramp depth, clip.w = 1, + // columns built from the receiver rows c0/c3/c4. + float lightvp[16]; + for (int r = 0; r < 3; ++r) { + lightvp[r * 4 + 0] = sh[0 + r] * sx; + lightvp[r * 4 + 1] = sh[12 + r] * sy; + lightvp[r * 4 + 2] = sh[16 + r]; + lightvp[r * 4 + 3] = 0.0f; + } + lightvp[12] = sh[3] * sx + ox; + lightvp[13] = sh[15] * sy + oy; + lightvp[14] = sh[19]; + lightvp[15] = 1.0f; + cmd->SetViewport(nrhi::Viewport{float(tile) * ci, 0.0f, float(tile), + float(tile), 0.0f, 1.0f}); + cmd->SetScissor(nrhi::Rect{int32_t(tile) * ci, 0, + int32_t(tile) * (ci + 1), int32_t(tile)}); + for (const Caster& c : casters) { + auto mit = g_r.meshes.find(c.item->mesh); + if (mit == g_r.meshes.end()) { + continue; // undecodable this frame; casts once the workers land + } + // A stale fingerprint is fine here: cloth decodes ride one frame + // behind the sim by design (dyn decode jobs). + float constants[52] = {}; + std::memcpy(constants, c.item->world, sizeof(c.item->world)); + float* mvp = constants + 16; + for (int r = 0; r < 4; ++r) { + for (int col = 0; col < 4; ++col) { + float sum = 0.0f; + for (int k = 0; k < 4; ++k) { + sum += c.item->world[r * 4 + k] * lightvp[k * 4 + col]; + } + mvp[r * 4 + col] = sum; + } + } + constants[33] = c.bones ? 1.0f : 0.0f; // tint.g = skinned branch + cmd->SetRootConstants(0, 52, constants, 0); + cmd->SetBufferSrv(3, g_r.bone_ring, + bone_region + (c.bones ? c.bone_offset : 0)); + cmd->SetVertexBuffer(mit->second.vb_view.buffer, + mit->second.vb_view.offset, + mit->second.vb_view.size_bytes, + mit->second.vb_view.stride); + cmd->SetIndexBuffer(mit->second.ib_view.buffer, + mit->second.ib_view.offset, + mit->second.ib_view.size_bytes); + for (const DrawEntry& draw : c.item->draws) { + if (draw.prim == 4) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + } else if (draw.prim == 6) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); + } else { + continue; + } + cmd->DrawIndexed(draw.index_count, draw.start_index, + draw.base_vertex); + ++shadow_draws; + } + } + } + // Blur/convert chain: raw -> (hblur) -> mid -> (vblur) -> final. The + // game's kernels: 5-tap Gaussian coverage cascade 0, 3-tap cascade 1, + // format-convert only for cascade 2 (weights (1,0,0), 0 taps), plus + // depth dilation into the penumbra. One fullscreen-triangle draw per + // tile per direction, taps clamped inside the tile. + cmd->Barrier(g_r.shadow_raw, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetPipeline(g_r.pso_shadow_blur); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + const float kernels[3][3] = {{0.292082f, 0.233881f, 0.120078f}, + {0.667243f, 0.166379f, 0.0f}, + {1.0f, 0.0f, 0.0f}}; + const float ntaps[3] = {2.0f, 1.0f, 0.0f}; + const auto blur_pass = [&](int ci, bool horizontal, + nrhi::TextureView* src, nrhi::Texture* dst, + bool src_raw) { + cmd->SetRenderTargets(dst, nullptr); + cmd->SetViewport(nrhi::Viewport{float(tile) * ci, 0.0f, float(tile), + float(tile), 0.0f, 1.0f}); + cmd->SetScissor(nrhi::Rect{int32_t(tile) * ci, 0, + int32_t(tile) * (ci + 1), int32_t(tile)}); + // Blur tap offsets step RASTER texels, matching the emulated GPU: + // the game's blur PS taps via tfetch integer offsets (OffsetX/Y + // -2..+2), which a resolution-scaled + // raster applies in physical texels, so the penumbra NARROWS as the + // tile grows (the emulated baseline is sharper than original + // hardware; 512 reproduces the softer original-360 look). Scaling + // the step by tile/512 to hold a console-width penumbra was tried + // and made receivers visibly blurrier than the emulated reference. + const float bc[12] = {horizontal ? 1.0f : 0.0f, horizontal ? 0.0f : 1.0f, + ntaps[ci], src_raw ? 1.0f : 0.0f, + kernels[ci][0], kernels[ci][1], kernels[ci][2], 0.0f, + float(tile) * ci, float(tile) * (ci + 1) - 1.0f, + float(tile) - 1.0f, 0.0f}; + cmd->SetRootConstants(0, 12, bc, 0); + cmd->SetTexture(1, src); + cmd->Draw(3, 0); + }; + for (int ci = 0; ci < 3; ++ci) { + blur_pass(ci, true, g_r.shadow_srv_raw, g_r.shadow_mid, true); + } + cmd->Barrier(g_r.shadow_mid, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + for (int ci = 0; ci < 3; ++ci) { + blur_pass(ci, false, g_r.shadow_srv_mid, g_r.shadow_final, false); + } + cmd->Barrier(g_r.shadow_final, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + g_r.shadow_in_srv_state = true; + shadow_ready = shadow_draws > 0; + // Atlas readback on kShadowDumpFrames consecutive frames per + // recording session (see the completion handler at function entry): + // per frame, raw plane at offset 0, blurred plane after it. Each + // frame gets its own readback buffer so no frame waits on a copy. + if (g_recording.load(std::memory_order_relaxed) && + !g_r.shadow_dump_done && + g_r.shadow_dump_enqueued < RendererState::kShadowDumpFrames) { + const uint32_t k = g_r.shadow_dump_enqueued; + const uint32_t pitch = tile * 3u * 4u; + const uint64_t plane = uint64_t(pitch) * tile; + if (g_r.shadow_dump_buf[k] == nullptr) { + nrhi::BufferDesc desc; + desc.size = plane * 2; + desc.heap = nrhi::HeapKind::kReadback; + g_r.shadow_dump_buf[k] = context.device->CreateBuffer(desc); + g_r.shadow_dump_ptr[k] = + g_r.shadow_dump_buf[k] != nullptr + ? static_cast( + context.device->Map(g_r.shadow_dump_buf[k])) + : nullptr; + } + if (g_r.shadow_dump_ptr[k] != nullptr) { + cmd->Barrier(g_r.shadow_raw, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kCopySource); + cmd->Barrier(g_r.shadow_final, + nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kCopySource); + cmd->FlushBarriers(); + cmd->CopyTextureToBuffer(g_r.shadow_dump_buf[k], 0, pitch, + g_r.shadow_raw, 0, tile * 3, tile); + cmd->CopyTextureToBuffer(g_r.shadow_dump_buf[k], plane, pitch, + g_r.shadow_final, 0, tile * 3, tile); + cmd->Barrier(g_r.shadow_raw, nrhi::ResourceState::kCopySource, + nrhi::ResourceState::kPixelShaderResource); + cmd->Barrier(g_r.shadow_final, nrhi::ResourceState::kCopySource, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + g_r.shadow_dump_submission[k] = context.device->CurrentSubmission(); + ++g_r.shadow_dump_enqueued; + if (g_r.shadow_dump_enqueued == RendererState::kShadowDumpFrames) { + g_r.shadow_dump_done = true; + } + } else { + g_r.shadow_dump_done = true; // allocation failed; don't retry + } + } + } + } + // ---- dynamicobject static world-shadow map ---- + // The game's props sample a 512x512 baked-shade depth map (tf1) the guest + // never renders in native mode (its memory stays zeroed). Re-render it + // natively from the frame's STATIC world items with the captured c5/c6/c7 + // projection, ps_shadow_caster convention (x = light-space depth, MIN + // blend). The map ACCUMULATES across frames without clearing: the item + // list is view-culled, and dropping off-screen casters would flicker + // props' shade with the camera; static geometry seen once stays in the + // map until the transform changes (streaming region switch), which + // re-primes it. + if (g_r.world_shadow != nullptr && g_r.pso_shadow_caster != nullptr && + debug_mode == 0 && scene.dynobj_ws_valid && + REXCVAR_GET(skate3_native_render_scene_shadows) && + REXCVAR_GET(skate3_native_render_scene_dynobj_v2)) { + bool changed = !g_r.world_shadow_primed; + for (int k = 0; k < 12 && !changed; ++k) { + changed = std::fabs(scene.dynobj_ws[k] - g_r.world_shadow_rows[k]) > + 1e-4f * std::max(1.0f, std::fabs(g_r.world_shadow_rows[k])); + } + static uint64_t ws_pass_counter = 0; + ++ws_pass_counter; + // Accumulation cadence: a full static-item pass every 4th frame keeps + // newly streamed / newly visible geometry flowing into the map at + // negligible steady-state cost; a transform change renders immediately. + if (changed || (ws_pass_counter & 3u) == 0) { + if (g_r.world_shadow_in_srv) { + cmd->Barrier(g_r.world_shadow, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + g_r.world_shadow_in_srv = false; + } + if (changed) { + const float ws_clear[4] = {1.0f, 1.0f, 0.0f, 0.0f}; + cmd->ClearRenderTarget(g_r.world_shadow, ws_clear); + std::memcpy(g_r.world_shadow_rows, scene.dynobj_ws, + sizeof(g_r.world_shadow_rows)); + if (!g_r.world_shadow_primed) { + REXLOG_INFO( + "native-scene: world-shadow map primed (dynobj c5/c6/c7 rows)"); + } + g_r.world_shadow_primed = true; + } + // View-proj columns = the game's own projection rows: clip.x/y/z = + // dot(wp4, c5/c6/c7), clip.w = 1 (same construction as the CSM + // lightvp above). + float wsvp[16]; + for (int r = 0; r < 3; ++r) { + wsvp[r * 4 + 0] = scene.dynobj_ws[r]; + wsvp[r * 4 + 1] = scene.dynobj_ws[4 + r]; + wsvp[r * 4 + 2] = scene.dynobj_ws[8 + r]; + wsvp[r * 4 + 3] = 0.0f; + } + wsvp[12] = scene.dynobj_ws[3]; + wsvp[13] = scene.dynobj_ws[7]; + wsvp[14] = scene.dynobj_ws[11]; + wsvp[15] = 1.0f; + cmd->SetRenderTargets(g_r.world_shadow, nullptr); + cmd->SetBindingLayout(g_r.layout); + cmd->SetPipeline(g_r.pso_shadow_caster); + cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); + const float ws_size = float(RendererState::kWorldShadowSize); + cmd->SetViewport(nrhi::Viewport{0.0f, 0.0f, ws_size, ws_size, 0.0f, 1.0f}); + cmd->SetScissor(nrhi::Rect{0, 0, int32_t(RendererState::kWorldShadowSize), + int32_t(RendererState::kWorldShadowSize)}); + for (const DrawItem& item : scene.items) { + // STATIC world geometry only: the game's map bakes buildings/trees/ + // ground. Dynamic content (characters, vehicles, cloth, movable + // props) casts through the CSM instead, accumulating a movable + // prop here would freeze its shade at a stale position. + if (item.transparent || item.unlit || item.cloth_quads || item.water || + item.skinned || item.ropa || item.char_family != 0 || + item.dynobj != 0) { + continue; + } + auto mit = g_r.meshes.find(item.mesh); + if (mit == g_r.meshes.end()) { + continue; + } + float constants[52] = {}; + std::memcpy(constants, item.world, sizeof(item.world)); + float* mvp = constants + 16; + for (int r = 0; r < 4; ++r) { + for (int col = 0; col < 4; ++col) { + float sum = 0.0f; + for (int k = 0; k < 4; ++k) { + sum += item.world[r * 4 + k] * wsvp[k * 4 + col]; + } + mvp[r * 4 + col] = sum; + } + } + cmd->SetRootConstants(0, 52, constants, 0); + cmd->SetBufferSrv(3, g_r.bone_ring, bone_region); + cmd->SetVertexBuffer(mit->second.vb_view.buffer, + mit->second.vb_view.offset, + mit->second.vb_view.size_bytes, + mit->second.vb_view.stride); + cmd->SetIndexBuffer(mit->second.ib_view.buffer, + mit->second.ib_view.offset, + mit->second.ib_view.size_bytes); + for (const DrawEntry& draw : item.draws) { + if (draw.prim == 4) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + } else if (draw.prim == 6) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); + } else { + continue; + } + cmd->DrawIndexed(draw.index_count, draw.start_index, draw.base_vertex); + } + } + cmd->Barrier(g_r.world_shadow, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + g_r.world_shadow_in_srv = true; + } + } + *out_draws = shadow_draws; + return shadow_ready; +} + + // Selection-outline mask (see kOutlineShaderSource): re-render the frame's + // selected items into the small R8 target while the scene pass state is + // still bound. The edge composite runs after the resolve, on the + // single-sample output. +bool RenderOutlineMask(const NativeGuestOutputRenderContext& context, + const FrameScene& scene, const nrhi::Viewport& viewport, + const nrhi::Rect& scissor, bool msaa_on, + nrhi::Texture* scene_color, nrhi::Texture* scene_depth, + bool use_depth) { + nrhi::Cmd* cmd = context.cmd; + bool outline_ready = false; + if (REXCVAR_GET(skate3_native_render_scene_selection_outline) && + g_r.pso_outline_mask != nullptr && g_r.pso_outline_edge != nullptr && + g_r.outline_mask != nullptr) { + std::vector sel; + for (const DrawItem& item : scene.items) { + // Skinned items are excluded: the mask VS runs the rigid path (world + // matrix), which renders a skinned mesh at bind pose at the origin. + if (item.selected && !item.skinned) { + sel.push_back(&item); + } + } + if (!sel.empty()) { + const float mask_clear[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + cmd->ClearRenderTarget(g_r.outline_mask, mask_clear); + cmd->SetRenderTargets(g_r.outline_mask, nullptr); + cmd->SetViewport(nrhi::Viewport{0.0f, 0.0f, + float(g_r.outline_mask_width), + float(g_r.outline_mask_height), 0.0f, + 1.0f}); + cmd->SetScissor(nrhi::Rect{0, 0, int32_t(g_r.outline_mask_width), + int32_t(g_r.outline_mask_height)}); + cmd->SetPipeline(g_r.pso_outline_mask); + for (const DrawItem* item : sel) { + auto mit = g_r.meshes.find(item->mesh); + if (mit == g_r.meshes.end() || mit->second.fingerprint != item->fingerprint) { + continue; // decoded by the main pass this frame; masks from the next + } + float constants[52] = {}; + std::memcpy(constants, item->world, sizeof(item->world)); + float* mvp = constants + 16; + for (int r = 0; r < 4; ++r) { + for (int c = 0; c < 4; ++c) { + float sum = 0.0f; + for (int k = 0; k < 4; ++k) { + sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c]; + } + mvp[r * 4 + c] = sum; + } + } + // tint = (1, 0, 0, 1): the scene PS's solid-color early-out + // (tint.a > 0) writes 1.0 into the R8 mask; tint.g = 0 keeps the VS + // skinning branch off. + constants[32] = 1.0f; + constants[35] = 1.0f; + cmd->SetRootConstants(0, 52, constants, 0); + cmd->SetVertexBuffer(mit->second.vb_view.buffer, + mit->second.vb_view.offset, + mit->second.vb_view.size_bytes, + mit->second.vb_view.stride); + cmd->SetIndexBuffer(mit->second.ib_view.buffer, + mit->second.ib_view.offset, + mit->second.ib_view.size_bytes); + for (const DrawEntry& draw : item->draws) { + if (draw.prim == 4) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + } else if (draw.prim == 6) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); + } else { + continue; + } + cmd->DrawIndexed(draw.index_count, draw.start_index, + draw.base_vertex); + outline_ready = true; + } + } + // Restore the pass state the resolve/2D paths rely on (fullscreen + // viewport; the non-MSAA path keeps rendering into the scene target). + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + if (!msaa_on) { + cmd->SetRenderTargets(scene_color, use_depth ? scene_depth : nullptr); + } + if (outline_ready) { + cmd->Barrier(g_r.outline_mask, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + } + } + } + return outline_ready; +} + +// Selection-outline composite: additive stencil-edge-detect over the +// resolved output (before the popup blur, like the game's postfx order). +void RenderOutlineComposite(const NativeGuestOutputRenderContext& context, + const FrameScene& scene, nrhi::Texture* output, + const nrhi::Viewport& viewport, + const nrhi::Rect& scissor) { + nrhi::Cmd* cmd = context.cmd; + cmd->FlushBarriers(); + cmd->SetRenderTargets(output, nullptr); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetPipeline(g_r.pso_outline_edge); + cmd->SetRootConstants(0, 4, scene.outline_color, 0); + cmd->SetTexture(1, g_r.outline_mask_srv); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + cmd->Draw(3, 0); + // Back to the mask's steady state for the next frame. + cmd->Barrier(g_r.outline_mask, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); +} + +// 600-frame perf + telemetry log lines (verbatim from the former tail of +// RenderScene). +void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn, + uint32_t drawn_2d, uint32_t drawn_spline, bool shadow_ready, + uint32_t shadow_draws) { + if (frames % 600 == 0 && REXCVAR_GET(skate3_native_render_scene_perf_log)) { + // CPU-side perf snapshot for this 600-frame window. guest_fps is derived + // from the guest frame interval; capture/build run on the guest render + // thread (they extend guest frame time directly), render/items/shadow on + // the command processor thread, decode inline on the render thread + // (count = decodes this window; max = the worst single decode). + const double guest_dt_ms = g_pw_guest_dt.AvgMs(); + REXLOG_INFO( + "native-scene perf: guest_fps={:.0f} guest_dt_max={:.1f}ms " + "capture={:.2f}/{:.2f}ms build={:.2f}/{:.2f}ms " + "bld[2d={:.2f}/{:.2f} spl={:.2f}/{:.2f} pal={:.2f}/{:.2f}]ms " + "dt[{}|{}|{}|{}|{}] ptail={:.2f}/{:.2f}ms | render={:.2f}/{:.2f}ms " + "items={:.2f}/{:.2f}ms shadow={:.2f}/{:.2f}ms " + "pre={:.2f}/{:.2f}ms settle[{:.2f}/{:.2f}ms n={} dec={} def={}] " + "tail={:.2f}/{:.2f}ms twod={:.2f}/{:.2f}ms " + "decode[mesh n={} avg={:.2f} max={:.2f}ms tex n={} avg={:.2f} max={:.2f}ms] " + "commit={:.2f}/{:.2f}ms itemcache[hit={} build={}] cam[chg={} rep={} maxstreak={}]", + guest_dt_ms > 0.0 ? 1000.0 / guest_dt_ms : 0.0, g_pw_guest_dt.MaxMs(), + g_pw_capture.AvgMs(), g_pw_capture.MaxMs(), g_pw_build.AvgMs(), + g_pw_build.MaxMs(), g_pw_b2d.AvgMs(), g_pw_b2d.MaxMs(), g_pw_bspl.AvgMs(), + g_pw_bspl.MaxMs(), g_pw_bpal.AvgMs(), g_pw_bpal.MaxMs(), + g_dt_hist[0].exchange(0, std::memory_order_relaxed), + g_dt_hist[1].exchange(0, std::memory_order_relaxed), + g_dt_hist[2].exchange(0, std::memory_order_relaxed), + g_dt_hist[3].exchange(0, std::memory_order_relaxed), + g_dt_hist[4].exchange(0, std::memory_order_relaxed), + g_pw_pal_tail.AvgMs(), g_pw_pal_tail.MaxMs(), + g_pw_render.AvgMs(), g_pw_render.MaxMs(), + g_pw_items.AvgMs(), g_pw_items.MaxMs(), g_pw_shadow.AvgMs(), + g_pw_shadow.MaxMs(), g_pw_pre.AvgMs(), g_pw_pre.MaxMs(), + g_pw_settle.AvgMs(), g_pw_settle.MaxMs(), + g_pw_settle.count.load(std::memory_order_relaxed), + g_warm_decodes.exchange(0, std::memory_order_relaxed), + g_warm_deferred.exchange(0, std::memory_order_relaxed), + g_pw_tail.AvgMs(), g_pw_tail.MaxMs(), g_pw_2d.AvgMs(), g_pw_2d.MaxMs(), + g_pw_mesh_decode.count.load(std::memory_order_relaxed), + g_pw_mesh_decode.AvgMs(), g_pw_mesh_decode.MaxMs(), + g_pw_tex_decode.count.load(std::memory_order_relaxed), g_pw_tex_decode.AvgMs(), + g_pw_tex_decode.MaxMs(), g_pw_commit.AvgMs(), g_pw_commit.MaxMs(), + g_item_cache_hits.exchange(0, std::memory_order_relaxed), + g_item_cache_builds.exchange(0, std::memory_order_relaxed), + g_cam_changes.exchange(0, std::memory_order_relaxed), + g_cam_repeats.exchange(0, std::memory_order_relaxed), + g_cam_max_streak.exchange(0, std::memory_order_relaxed)); + for (PerfWindow* w : {&g_pw_guest_dt, &g_pw_capture, &g_pw_build, &g_pw_pal_tail, + &g_pw_b2d, &g_pw_bspl, &g_pw_bpal, + &g_pw_render, &g_pw_items, &g_pw_shadow, &g_pw_pre, + &g_pw_settle, &g_pw_tail, &g_pw_2d, &g_pw_mesh_decode, + &g_pw_tex_decode, &g_pw_commit}) { + w->Reset(); + } + // Refill the settle offender-log + slow-frame budgets for the next window. + g_warm_mesh_log_budget.store(4, std::memory_order_relaxed); + g_warm_tex_log_budget.store(4, std::memory_order_relaxed); + g_slow_frame_log_budget.store(3, std::memory_order_relaxed); + } + if (frames % 600 == 0) { + uint32_t lw_ctxs = 0, lw_ents = 0; + skate3::native_lw::QueryLwStats(&lw_ctxs, &lw_ents); + REXLOG_INFO( + "native-scene: frame {} items={} draws={} draws_2d={} drawn_2d={} " + "splines[{}/{}] " + "2d[other={} dropped={} askip={} astale={} textures={}] cached_meshes={} textures={} " + "vs_uploads={} palettes={} palette_base_plus1={} ropa[rigid={} stale={} rescued={} relax={} caster={} incoh={} stretch={} blend={} blendmiss={}] dyn_gap={} skinned={} skinned_skipped={} foreign_bank={} " + "rigid[pending={} dropped={} worldprops={}] " + "rej[dyn={} range={} chain={} geom={} draws={} bbox={}] " + "rr[decode_fail={} no_bones={} mesh_deferred={} tex_deferred={}] " + "store[n={} routes={} evict={}] " + "heal[vfail={} demote={}] serve[sticky={} skipnew={} adstale={} adnone={}] " + "shadow[valid={} ready={} draws={}] char[attempt={} valid={} drawn={} " + "reused={}] dynobj[valid={} drawn={}] " + "lw[ctxs={} ents={} stamp={} fade0={} resc={} fill={} pal={} rows={}] " + "refl[pair={} flat={} gate={:#x}] flips={} alt[bones={} shadow={}] " + "rows_inst={} pal_srv={}", + frames, scene.items.size(), drawn, g_draws_2d.load(), drawn_2d, + drawn_spline, g_draws_spline.load(), + g_draws_2d_other.load(), g_draws_2d_dropped.load(), + g_2d_async_skip.load(), g_2d_async_stale.load(), g_r.tex_store.size(), + g_r.meshes.size(), g_r.tex_store.size(), + g_vs_uploads.load(), g_palette_snapshots.load(), g_palette_base_plus1.load(), + g_ropa_rigid.load(), g_ropa_stale.load(), g_ropa_rescued.load(), + g_ropa_relaxed.load(), g_ropa_caster.load(), g_pub_incoherent.load(), + g_stretch_veto.load(), + g_ropa_blend_drawn.load(), g_ropa_blend_miss.load(), g_dyn_gap.load(), + g_skinned_items.load(), + g_skinned_skipped.load(), g_capture_foreign_bank.load(), + g_rigid_pending.load(), g_rigid_dropped.load(), + g_world_props.load(), + g_rej_no_dynstate.load(), g_rej_dyn_range.load(), + g_rej_chain.load(), g_rej_geom.load(), g_rej_draws.load(), g_rej_bbox.load(), + g_rr_decode_fail.load(), g_rr_no_bones.load(), g_rr_mesh_deferred.load(), + g_rr_tex_deferred.load(), g_r.tex_store.size(), g_r.tex_routes.size(), + g_store_evicted.load(), g_heal_verify_fail.load(), + g_demote_hold.load(), + g_tex_sticky_served.load(), g_skip_new.load(), g_ad_stale_served.load(), + g_ad_placeholder.load(), scene.shadow_valid, + shadow_ready, shadow_draws, + g_char_attempts.load(), g_char_valid.load(), g_char_drawn.load(), + g_char_rows_reused.load(), scene.dynobj_valid, + g_dynobj_drawn.load(), lw_ctxs, lw_ents, g_lw_stamped.load(), + g_lw_fade0.load(), g_lw_ctx_rescued.load(), g_lw_gap_filled.load(), + g_lw_pal_sub.load(), g_lw_rows_served.load(), + g_refl_pair.load(), g_refl_flat.load(), g_refl_gate.load(), + g_hair_route_flips.load(), g_hair_bone_alternations.load(), + g_shadow_alternations.load(), g_char_rows_inst_served.load(), + g_pal_served_total.load()); + } +} + +bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_data*/) { + if (!SceneEnabled() || + (context.backend != NativeGuestOutputBackend::kD3D12 && + context.backend != NativeGuestOutputBackend::kVulkan)) { + return false; + } + // While the game reports menus / loading (presence context 0x8001 == 0), + // yield to the emulated output, EXCEPT the in-game pause menu (world + // still publishing perspective scenes), which stays native when + // skate3_native_render_scene_pause_native is on: the pause UI rides the + // same captured-APT 2D replay as the gameplay HUD, and the backdrop is the + // ordinary native world. Loading screens and the boot frontend still + // render emulated (complete and correct there). + // The photo-grab readback window runs before any yield decision so it + // updates every frame in every mode (the grab must work whether the photo + // flow renders yielded-emulated or native). + uint8_t* base = g_guest_base.load(std::memory_order_relaxed); + if (base != nullptr) { + UpdatePhotoGrabWindow(base); + } + if (YieldForMenus(context)) { + return false; + } + if (base == nullptr) { + return false; + } + if (YieldForPhotoDisplay()) { + return false; + } + if (YieldForPhotoEditor(base)) { + return false; + } + if (YieldForCasEditor(base)) { + return false; + } + + const auto render_t0 = PerfClock::now(); + const auto perf_ns_since = [](PerfClock::time_point t0) { + return uint64_t( + std::chrono::duration_cast(PerfClock::now() - t0) + .count()); + }; + + bool loading_native = g_loading_native_frame; + // Render the takeover-gate window (warmup armed, no fresh substantial + // scene yet) as a native loading frame instead of yielding, in two cases: + // (a) g_loading_hold: the frames right after a native loading screen + // (the presence context flips to gameplay before the first fresh + // scene, and the emulated output was suppressed all through the load, + // so yielding would flash its stale pre-load content); + // (b) boot_native: the whole startup flow (intro videos, boot frontend) + // runs with the gate armed and no scene published, and should render + // natively as 2D-over-black rather than fall back to emulated. + if (!loading_native && + (g_loading_hold || REXCVAR_GET(skate3_native_render_scene_boot_native)) && + REXCVAR_GET(skate3_native_render_scene_warmup_budget_ms) > 0 && + g_warmup_armed.load(std::memory_order_relaxed)) { + std::lock_guard lock(g_scene_mutex); + // A fresh CAS-editor scene qualifies at ANY size: the startup-flow + // editor is skater-only (~10 items, below warmup_min_items) but is a + // complete, deliberate scene; holding it rendered the 3D view black + // behind the live editor menu. It renders WITHOUT disarming the gate + // (see the takeover gate below); real gameplay still needs min_items. + const bool fresh = + g_scene && g_scene->generation >= g_warmup_fresh_generation; + const bool ready = + fresh && (g_scene->items.size() >= + size_t(REXCVAR_GET(skate3_native_render_scene_warmup_min_items)) || + (!g_scene->items.empty() && CasEditorActive(base))); + loading_native = !ready; + } + g_loading_hold = loading_native; + std::shared_ptr scene_ptr; + if (loading_native) { + // Native loading screen: there is no current world scene (g_scene holds + // the PREVIOUS map's stale one); render an empty scene, i.e. a black + // backdrop, and let the 2D overlay tail replay the game's live loading + // UI (Publish2dDraws publishes every guest frame, loads included). + // Value-initialized: zero items, no shadow/blur/outline, generation 0. + static const std::shared_ptr s_loading_scene = + std::make_shared(); + scene_ptr = s_loading_scene; + } else { + std::lock_guard lock(g_scene_mutex); + if (!g_scene || g_scene->items.empty()) { + return false; + } + scene_ptr = g_scene; + } + const FrameScene& scene = *scene_ptr; + + if (!EnsurePipeline(context)) { + return false; + } + // Flush any barriers pushed by lazy resource creation (white texture). + context.cmd->FlushBarriers(); + + // FMV routing: prefer the native path; video quads in the 2D replay are + // SUBSTITUTED with the ps_yuv2d combine, matched by their own captured + // slot-0 fetch (== that video's Y plane; through ps_main a movie quad + // renders as an opaque black cover, intro, or slow greyscale luma, + // camera-page previews). Order-faithful: backdrop fills land under the + // video like the emulated frame; multiple simultaneous videos each match + // their own plane set. Only when this path is unavailable does a live + // movie heartbeat yield to the emulated output. + MoviePlanes movies[kMaxMovies]; + { + std::lock_guard lock(g_movie_mutex); + std::memcpy(movies, g_movies, sizeof(movies)); + } + const int64_t movie_now_ns = + std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + bool movie_fresh = false; + for (const MoviePlanes& m : movies) { + movie_fresh |= m.ns >= 0 && movie_now_ns - m.ns < 500'000'000; + } + // Movie SESSION start (publish-freshness OFF->ON edge = a video began): + // the triple substitution only serves plane content DECODED DURING THIS + // SESSION (GuestTexture::last_change_frame >= this stamp). The APT plane + // copies keep their addresses across videos, so at video N+1's start + // both the store AND guest memory can still hold video N's last frame; + // serving either flashed the previous video for a few frames at every + // boundary. Until the new video's first frame lands (content change -> + // inline re-decode), the quad holds black, which is what a starting + // video looks like. + static uint64_t s_movie_session_frame = 0; + { + static bool s_movie_fresh_prev = false; + if (movie_fresh && !s_movie_fresh_prev) { + s_movie_session_frame = g_frames_rendered.load(std::memory_order_relaxed); + } + s_movie_fresh_prev = movie_fresh; + } + const bool movie_sub = movie_fresh && + REXCVAR_GET(skate3_native_render_scene_fmv_native) && + g_r.pso_yuv2d != nullptr; + if (!movie_sub && YieldForMovie()) { + return false; + } + + nrhi::Cmd* cmd = context.cmd; + + ReleaseRetiredAndFlushCaches(context); + + // Reset this frame's bone ring region (shared by the shadow casters and + // the main pass: the shadow pass allocates first, the main pass appends). + const uint64_t frame_number = g_frames_rendered.load(std::memory_order_relaxed); + const uint32_t bone_region = + uint32_t(frame_number % RendererState::kBoneRegions) * + RendererState::kBoneRegionSize; + g_r.bone_ring_offset = 0; + g_r.ropa_ring_offset = 0; + + { + const std::string tm(REXCVAR_GET(skate3_native_render_scene_trace_mesh)); + const uint32_t parsed = + tm.empty() ? 0u : uint32_t(std::strtoul(tm.c_str(), nullptr, 16)); + if (parsed != g_trace_mesh_addr) { + g_trace_mesh_addr = parsed; + g_trace_keys.clear(); + g_trace_sig.clear(); + REXLOG_INFO("tex-trace: mesh={:08X} {}", parsed, + parsed ? "TRACING" : "off"); + } + } + + // ---- Loading -> gameplay takeover (seamless boot / map-change loads) ---- + // The loading-screen prewarm (menus branch above) already decoded the + // registered world behind the load; the FIRST substantial post-load scene + // renders natively right away. Gates kept: staleness (g_scene holds the + // PREVIOUS map's scene through the whole load; rendering it shows + // old-map garbage) and min items (the capture holds a near-empty scene + // for a few frames while the game fades in; taking over there shows a + // black/void world; the brief yield shows the game's own fade instead). + // The frames after takeover run a budgeted settle pass for whatever + // prewarm missed (dynamic entities, late textures), and the draw path's + // miss budgets are clamped while settling so leftovers render white/skip + // for a frame instead of freezing the takeover frame. + const int32_t warmup_ms = REXCVAR_GET(skate3_native_render_scene_warmup_budget_ms); + bool settling = false; + // The takeover gates judge REAL scenes only; a native loading frame + // renders its empty scene deliberately (the gates re-run as usual once + // the presence context flips back to gameplay). + if (warmup_ms > 0 && !loading_native && + REXCVAR_GET(skate3_native_render_scene_debug) == 0) { + if (g_warmup_armed.load(std::memory_order_relaxed)) { + const bool small_or_stale = + scene.generation < g_warmup_fresh_generation || + scene.items.size() < + size_t(REXCVAR_GET(skate3_native_render_scene_warmup_min_items)); + // A fresh CAS-editor scene renders despite being under min_items (the + // startup-flow editor is skater-only, ~10 items), but does NOT count + // as the gameplay takeover; the gate stays armed for the real load + // that follows the editor. + const bool editor_scene = scene.generation >= g_warmup_fresh_generation && + !scene.items.empty() && CasEditorActive(base); + if (small_or_stale && !editor_scene) { + // Stale or fade-in scene: yield (brief, a few frames). Keep + // committing worker results meanwhile; every pre-takeover frame + // counts on map changes. + PrewarmCommit(context, frame_number); + return false; + } + if (!small_or_stale) { + g_warmup_armed.store(false, std::memory_order_relaxed); + g_settle_until_frame = frame_number + 120; + g_takeover_frame = frame_number; + size_t queued = 0; + { + std::lock_guard lock(g_prewarm_mutex); + queued = g_prewarm_queue.size(); + } + REXLOG_INFO( + "native-scene: taking over natively ({} items; prewarm {} done / {} dropped " + "/ {} queued)", + scene.items.size(), g_prewarm_done.load(std::memory_order_relaxed), + g_prewarm_dropped.load(std::memory_order_relaxed), queued); + } + } + settling = frame_number < g_settle_until_frame; + if (settling) { + const auto settle_t0 = PerfClock::now(); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(warmup_ms); + WarmCounters wc; + for (const DrawItem& item : scene.items) { + WarmItemResources(context, base, frame_number, item, deadline, wc); + } + if (wc.deferred != 0) { + // Still behind: keep the settle pass (and the draw-path budget + // clamp) alive until a frame clears with budget to spare - but never + // past the hard cap: revalidation-churning content (streamed + // payloads, post-map-change stale items) re-defers every frame and + // would otherwise hold the full budget open for the whole session. + const int32_t max_frames = + REXCVAR_GET(skate3_native_render_scene_settle_max_frames); + const uint64_t extended = frame_number + 8; + if (max_frames <= 0 || extended <= g_takeover_frame + uint64_t(max_frames)) { + g_settle_until_frame = std::max(g_settle_until_frame, extended); + } else if (g_settle_until_frame <= frame_number + 1) { + REXLOG_INFO( + "native-scene: settle pass CAPPED {} frames after takeover with {} " + "deferred (revalidation churn?) - draw-path budgets resume", + frame_number - g_takeover_frame, wc.deferred); + } + } + if (wc.decodes > 0) { + cmd->FlushBarriers(); + } + g_warm_decodes.fetch_add(wc.decodes, std::memory_order_relaxed); + g_warm_deferred.fetch_add(wc.deferred, std::memory_order_relaxed); + g_pw_settle.Add(perf_ns_since(settle_t0)); + } + } + + if (!g_r.announced) { + g_r.announced = true; + REXLOG_INFO("native-scene: rendering natively ({} items, {}x{})", scene.items.size(), + context.guest_output_width, context.guest_output_height); + } + + // Commit finished worker decodes every frame (streamed arenas decode on + // the workers before their first draw instead of hitching the first frame + // that sees them). Near-no-op when nothing completed. The workers also + // serve the draw path's steady-state misses (EnqueueMeshMiss/ + // EnqueueWordsMiss), so make sure they exist even on a session that never + // showed a loading screen with the pipeline up. + EnsurePrewarmWorkers(); + // Native loading frames take the loading-screen commit budget; the heavy + // decode lifting behind the load is unchanged from the yielded path. + PrewarmCommit(context, frame_number, /*loading=*/loading_native); + // Content-store LRU: superseded words states (old mip levels, pre-demote + // detail sets, one-shot UI art) age out once nothing routes to them. + EvictTexStore(frame_number, context.device->CurrentSubmission()); + + bool shadow_ready = false; + uint32_t shadow_draws = 0; + const auto shadow_t0 = PerfClock::now(); + const float* sh = scene.shadow_rows; + const int32_t debug_mode = REXCVAR_GET(skate3_native_render_scene_debug); + shadow_ready = + RenderShadowAtlas(context, scene, bone_region, debug_mode, &shadow_draws); + g_pw_shadow.Add(perf_ns_since(shadow_t0)); + // Scene-level flicker probe: the shadow cascade rows (or validity/readiness) + // returning EXACTLY to the previous-but-one frame's state; hair samples the + // atlas, so an alternating cascade capture darkens/lightens it per frame. + { + uint64_t sh_h = 1469598103934665603ull; + const uint8_t* sb = reinterpret_cast(scene.shadow_rows); + for (size_t bi = 0; bi < sizeof(scene.shadow_rows); ++bi) { + sh_h = (sh_h ^ sb[bi]) * 1099511628211ull; + } + sh_h ^= (scene.shadow_valid ? 2u : 0u) | (shadow_ready ? 4u : 0u); + static uint64_t s_sh_h1 = 0, s_sh_h2 = 0; // render thread + if (s_sh_h2 != 0 && sh_h != s_sh_h1 && sh_h == s_sh_h2) { + g_shadow_alternations.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_sh_logged{0}; + const uint32_t sl = s_sh_logged.fetch_add(1, std::memory_order_relaxed); + if (sl < 24 || (sl & 1023u) == 0) { + REXLOG_INFO( + "native-scene: SHADOW ALTERNATION valid={} ready={} draws={} " + "row0=({:.3f},{:.3f},{:.3f},{:.3f}) (n={})", + scene.shadow_valid ? 1 : 0, shadow_ready ? 1 : 0, shadow_draws, + sh[0], sh[1], sh[2], sh[3], sl); + } + } + s_sh_h2 = s_sh_h1; + s_sh_h1 = sh_h; + } + + // The scene draws into the MSAA target when enabled (resolved into the 1x + // scene plane at the end of the pass), or straight into the 1x plane. The + // 1x plane is the float HDR intermediate when the HDR post chain is live + // (ps_tonemap then writes the guest output), else the guest output itself. + const bool hdr_on = g_r.hdr_active && g_r.hdr_resolved != nullptr && + g_r.hdr_srv != nullptr && g_r.pso_tonemap != nullptr; + const bool msaa_on = g_r.msaa > 1 && g_r.msaa_color != nullptr && g_r.resolve_pso != nullptr; + nrhi::Texture* scene_color = + msaa_on ? g_r.msaa_color + : (hdr_on ? g_r.hdr_resolved : context.guest_output); + if (!msaa_on && !hdr_on) { + cmd->Barrier(context.guest_output, nrhi::ResourceState::kGuestOutput, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + } + + const bool use_depth = debug_mode != 4; + // Loading frames clear to black (the game's loading UI composes over + // black); real scenes keep the sky-ish debug clear that shows through + // undecoded holes. + float clear_color[4] = {loading_native ? 0.0f : 0.25f, + loading_native ? 0.0f : 0.35f, + loading_native ? 0.0f : 0.55f, 1.0f}; + if (hdr_on) { + // The clear is authored in the final gamma space; the HDR plane holds + // pre-tonemap values, so encode through the tone chain's inverse (the + // same transform as scene.hlsl PassGamma) so undecoded holes keep the + // same debug color after the tonemap pass. + for (int k = 0; k < 3; ++k) { + const float tm = + clear_color[k] * clear_color[k] * (2.0f / (1.41f * 1.41f)); + clear_color[k] = tm > 1.0f + ? tm * 4.0f - 3.0f + : 1.0f - std::sqrt(std::max(1.0f - tm, 0.0f)); + } + } + cmd->ClearRenderTarget(scene_color, clear_color); + if (use_depth) { + cmd->ClearDepth(g_r.depth, 1.0f); + cmd->SetRenderTargets(scene_color, g_r.depth); + } else { + cmd->SetRenderTargets(scene_color, nullptr); + } + + const nrhi::Viewport viewport{0.0f, + 0.0f, + float(context.guest_output_width), + float(context.guest_output_height), + 0.0f, + 1.0f}; + cmd->SetViewport(viewport); + const nrhi::Rect scissor{0, 0, int32_t(context.guest_output_width), + int32_t(context.guest_output_height)}; + cmd->SetScissor(scissor); + cmd->SetBindingLayout(g_r.layout); + cmd->SetPipeline(use_depth ? g_r.pso : g_r.pso_nodepth); + // Per-item PSO tracking for the opaque pass: two_sided_sheet meshes swap + // to the backface-culling variant (see MeshBuffers), everything else uses + // the pass PSO. Only meaningful while use_depth; the transparent sub-pass + // sets its own PSO and is not switched per item. + nrhi::Pipeline* scene_pso_bound = use_depth ? g_r.pso : g_r.pso_nodepth; + + // Per-frame dynamic-shadow bindings: receiver constants at b1 (a small + // per-frame CBV ring: the 52-float root-constant block is full) and the + // blurred atlas at t5 (white when no atlas was rendered this frame; the + // shader is also gated by sh_misc.y). + if (g_r.shadow_cb != nullptr) { + const uint32_t cb_offset = + uint32_t(frame_number % RendererState::kShadowCbRegions) * + RendererState::kShadowCbSlice; + float* cb = reinterpret_cast(g_r.shadow_cb_cpu + cb_offset); + std::memset(cb, 0, RendererState::kShadowCbSlice); + if (shadow_ready) { + std::memcpy(cb + 0, sh + 0, 4 * sizeof(float)); // sh_x = PS c0 + std::memcpy(cb + 4, sh + 12, 4 * sizeof(float)); // sh_y = PS c3 + std::memcpy(cb + 8, sh + 16, 4 * sizeof(float)); // sh_z = PS c4 + std::memcpy(cb + 12, sh + 4, 4 * sizeof(float)); // sh_c1 = PS c1 + std::memcpy(cb + 16, sh + 8, 4 * sizeof(float)); // sh_c2 = PS c2 + cb[20] = sh[32]; // sh_color = PS c8 + cb[21] = sh[33]; + cb[22] = sh[34]; + cb[23] = 0.299f * sh[32] + 0.587f * sh[33] + 0.114f * sh[34]; + cb[24] = sh[20]; // depth bias (PS c5.x) + cb[25] = 1.0f; // enable + // sh_misc.zw = atlas dimensions (3*tile x tile) for the char path's + // point-snapped taps. + cb[26] = float(g_r.shadow_tile * 3); + cb[27] = float(g_r.shadow_tile); + // sh_char = the game's per-cascade character receive biases + enable + // (see FrameScene::char_shadow_bias; exact 9-tap char sampling). + if (scene.char_shadow_bias[0] > 0.0f && + REXCVAR_GET(skate3_native_render_scene_char_shadow_exact)) { + cb[56] = scene.char_shadow_bias[0]; + cb[57] = scene.char_shadow_bias[1]; + cb[58] = scene.char_shadow_bias[2]; + cb[59] = 1.0f; + } + } + // Exact world-shading frame rows (valid whenever the env-family PS bank + // was captured this frame, independent of the shadow ATLAS being + // rendered; draw_item only selects the exact branch when + // scene.shadow_valid). + if (scene.shadow_valid) { + cb[28] = sh[24]; // sun direction (PS c6), for the normal-map kd (v2) + cb[29] = sh[25]; + cb[30] = sh[26]; + cb[31] = sh[40]; // scene exposure (PS c10.x) + cb[32] = sh[45]; // material multiplier (PS c11.y) + cb[33] = scene.family_rows[0]; // tree lightmap scale (tree PS c0.x) + cb[34] = scene.family_rows[1]; // tree lightmap floor (tree PS c0.y) + cb[35] = scene.family_rows[2]; // tree tint multiplier (tree PS c4.y) + cb[36] = scene.fog_ramp[0]; // global fog: sat(d*x+y)^z + cb[37] = scene.fog_ramp[1]; + cb[38] = scene.fog_ramp[2]; + cb[39] = scene.family_rows[3]; // proxyworld scale (proxy PS c3.y) + std::memcpy(cb + 40, scene.fog_color, 4 * sizeof(float)); + } + // World-shading v2 row (sh_v2, cb[60..63]): x = stored-tangent + // polarity. + cb[60] = + float(REXCVAR_GET(skate3_native_render_scene_world_v2_tan_sign)); + // dynamicobject.fx frame rows (dyn_sun/dyn_amb/dyn_misc at cb[44..55]). + if (scene.dynobj_valid) { + cb[44] = scene.dynobj_rows[0]; // sun dir (PS c9) + cb[45] = scene.dynobj_rows[1]; + cb[46] = scene.dynobj_rows[2]; + cb[47] = scene.dynobj_rows[3]; // scene exposure (c13.x) + cb[48] = scene.dynobj_rows[4]; // flat ambient rgb (c15.xyz) + cb[49] = scene.dynobj_rows[5]; + cb[50] = scene.dynobj_rows[6]; + cb[51] = scene.dynobj_rows[7]; // bounce scale (c15.w) + cb[52] = scene.dynobj_rows[8]; // material multiplier (c14.y) + cb[53] = scene.dynobj_rows[9]; // static world-shadow floor (c8.w) + // Static world-shadow transform rows (dyn_wsx/dyn_wsy/dyn_wsz, + // cb[64..75]): consumed only when a draw carries the misc.z world- + // shadow bind flag (the map primed and bound at t4). + if (scene.dynobj_ws_valid) { + std::memcpy(cb + 64, scene.dynobj_ws, sizeof(scene.dynobj_ws)); + } + } + cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset); + // b2 (character lighting) default: point at the ring base so the root + // CBV is never left unset; character draws re-point it per item. + cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); + // t6/t7 ride one table: default = white cube + this frame's atlas + // (draw_item re-pairs with the item's real cube when one resolves). + cmd->SetTexturePair(7, g_r.white_cube.srv, + shadow_ready ? g_r.shadow_srv_final : g_r.white.srv); + } + + uint32_t drawn = 0; + uint32_t item_index = 0; + // F7 scene-composition ring (RequestSceneRingDump): one compact signature + // per scene item per frame, ~900 frames deep. A 1-2 frame artifact (the + // dam-bank blue flash) is uncapturable by F10/F11; the ring lets a + // keypress seconds later name exactly which item appeared / vanished / + // changed textures on the artifact frame. Entries are recorded at + // classification below; draw_item stamps the issued-draw count, so an + // item that early-returned (deferred mesh, skip-new, fade skip) shows + // drawn=0 that frame. + SceneRingFrame* ring_frame = nullptr; + std::unordered_map ring_map; + if (REXCVAR_GET(skate3_native_render_scene_ring) && debug_mode == 0) { + g_scene_ring.emplace_back(); + while (g_scene_ring.size() > kSceneRingFrames) { + g_scene_ring.pop_front(); + } + ring_frame = &g_scene_ring.back(); + ring_frame->frame = frame_number; + std::memcpy(ring_frame->cam, scene.cam_pos, sizeof(ring_frame->cam)); + ring_frame->fog[0] = scene.fog_ramp[0]; + ring_frame->fog[1] = scene.fog_ramp[1]; + ring_frame->fog[2] = scene.fog_ramp[2]; + ring_frame->fog[3] = scene.fog_color[0]; + ring_frame->fog[4] = scene.fog_color[1]; + ring_frame->fog[5] = scene.fog_color[2]; + std::memcpy(ring_frame->family_rows, scene.family_rows, + sizeof(ring_frame->family_rows)); + ring_frame->sky_height = scene.sky_height; + ring_frame->shadow_valid = scene.shadow_valid; + ring_frame->items.reserve(scene.items.size()); + ring_map.reserve(scene.items.size()); + } + // Inline decode budget for DYNAMIC payloads only (skinned/cloth/ropa + // buffers that change every frame). Static meshes and all textures route + // to the decode workers on miss; the render thread never pays their + // decode cost. + int32_t mesh_budget = REXCVAR_GET(skate3_native_render_scene_mesh_decode_budget); + if (settling) { + // Post-takeover settle window: cap inline dynamic decodes too, so the + // takeover frame never absorbs an unbounded decode burst. + if (mesh_budget == 0 || mesh_budget > 16) mesh_budget = 16; + } + uint32_t mesh_decodes = 0; + // Item drawing body. environment.decal items draw in the same opaque pass: + // they ARE the wall/ground sections they cover, with the art composited + // in-shader (alpha-blending them as separate overlay geometry punched + // holes in the world). + // Ripple scroll clock for the water branch (seconds; wraps every hour to + // keep float precision on the scrolled UVs). + static const std::chrono::steady_clock::time_point water_t0 = + std::chrono::steady_clock::now(); + const float water_time = float( + std::chrono::duration(std::chrono::steady_clock::now() - water_t0).count() - + std::floor( + std::chrono::duration(std::chrono::steady_clock::now() - water_t0).count() / + 3600.0) * + 3600.0); + + // Words-keyed texture lookup with payload revalidation, shared by the + // streamed-artwork diffuse override and the 2D pass. The event-ad system + // rotates artwork IN PLACE; it streams the next poster into the same + // guest buffer, so the fetch words (this cache's key) never change; without + // the fingerprint recheck every frame keeps the first ad ever decoded at + // that address and the wall posters diverge from the emulated frame. + const auto find_words_texture = [&](uint64_t key) { + auto it = g_r.tex_store.find(key); + if (it != g_r.tex_store.end()) { + it->second.last_used_frame = frame_number; + } + if (it != g_r.tex_store.end() && it->second.valid && + frame_number >= it->second.recheck_frame && + REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { + // 2-frame cadence in menu/editor contexts, like the item-draw poll + // (in-place CAS composite rewrites during edits). + it->second.recheck_frame = + frame_number + + (g_in_menus_frame.load(std::memory_order_relaxed) ? 2 : 16); + const uint64_t fp = SampleProbeFingerprint(base, it->second); + const bool fp_new = fp != 0 && fp != it->second.payload_fp; + if (it->second.recheck_count < 3) { + ++it->second.recheck_count; + } + if (it->second.incomplete || fp_new) { + // In-place content rotation (event ads stream the next poster into + // the same buffer; the words, and so the key, never change): heal + // on the workers; keep serving the current decode meanwhile. + EnqueueWordsMiss(key, it->second.fetch_words); + } + } + return it; + }; + // Resolve six raw fetch-constant words (a draw-time streamed-artwork + // binding with no guest texture object) through the words-keyed cache. + // `site` identifies the consuming slot (mesh << 1 | slot) for the sticky + // fallback: streaming rebinds NEW mip words as you approach the art (the + // cache key changes wholesale), and dropping to the placeholder / channel + // diffuse for the worker round trip was the visible poster/decal flash; + // the site's previous art keeps serving until the new decode lands. + // Returns null only when nothing ever decoded for this site. + const auto resolve_fetch_words = [&](const uint32_t words[6], uint64_t site, + bool retained) -> const GuestTexture* { + const uint64_t fkey = FetchWordsKey(words); + auto fit = find_words_texture(fkey); + if (fit == g_r.tex_store.end()) { + if (!retained) { + EnqueueWordsMiss(fkey, words); + } + } else if (fit->second.valid) { + g_r.words_sticky[site] = fkey; + return &fit->second; + } else if (!retained && frame_number >= fit->second.retry_after_frame) { + // Failed words decode (payload was mid-stream at first sight): keep + // retrying; without this the entry negative-cached until the words + // changed again. + EnqueueWordsMiss(fkey, words); + fit->second.retry_after_frame = frame_number + 30; + } + const auto pit = g_r.words_sticky.find(site); + if (pit != g_r.words_sticky.end() && pit->second != fkey) { + const auto old = g_r.tex_store.find(pit->second); + if (old != g_r.tex_store.end() && old->second.valid) { + old->second.last_used_frame = frame_number; + g_ad_stale_served.fetch_add(1, std::memory_order_relaxed); + return &old->second; + } + } + // Nothing decoded for this site yet: the caller falls back to the + // channel diffuse (the baked placeholder poster). If the "decal flash + // and replace" report survives the dense-probe fix, a climbing none= + // with a capped log here names this path as the flasher. + g_ad_placeholder.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_ad_logs{0}; + if (!retained && s_ad_logs.fetch_add(1) < 24) { + REXLOG_INFO( + "native-scene: ad/decal words site={:X} fkey={:016X} -> placeholder " + "(no decode yet) words=[{:08X} {:08X} {:08X} {:08X} {:08X} {:08X}]", + site, fkey, words[0], words[1], words[2], words[3], words[4], + words[5]); + } + return nullptr; + }; + + // ---- SSR reflective-item capture (see ApplySsrPass) ---- + // draw_item records each reflective item's fully-staged draw state + // (constants + resolved texture views) so the reflection G-buffer pass + // after the scene pass re-renders exactly what the main pass drew. + struct SsrGbufItem { + float constants[52]; + nrhi::TextureView* t3; // macro slot (water ripple normal map) + nrhi::TextureView* t4; // spec/reflection masks + nrhi::TextureView* t5; // fam 5/6 normal map (white when unpaired) + uint32_t mesh; + uint64_t fingerprint; + const DrawItem* item; + }; + std::vector ssr_items; + const bool ssr_on = + hdr_on && use_depth && debug_mode == 0 && !loading_native && + !g_r.ssr_failed && REXCVAR_GET(skate3_native_render_scene_ssr) && + // The published projection must be the live perspective matrix (same + // gate as the SSAO pass). + scene.proj[11] == 1.0f && scene.proj[0] != 0.0f && + scene.proj[5] != 0.0f && scene.proj[14] != 0.0f; + + const auto draw_item = [&](const DrawItem& item) { + // NO per-frame inline decodes here. Static content (world geometry, + // props) loads/heals on the decode workers via the miss queue; a + // texture decode averages ~10 ms and panning surfaces dozens of new + // payloads in one frame; inline decode WAS the panning lag spike. + // Dynamic payloads (skinned, CPU-rewritten every frame) are kept + // fresh by the dyn decode jobs (guest-thread snapshot -> worker), one + // frame behind the sim; only their FIRST sight decodes inline. The + // cloth-quads particle path (gated off by default) still re-decodes + // inline on change; it has no job route. + // EXCEPTION: ROPA garments decode INLINE (skate3_native_render_scene_ + // ropa_inline): the worker route put the GPU-resident cloth shape 1-2 + // frames behind the body, visible as jelly/clip-through-torso during + // direction changes (the shape only pauses when motion is steady). A + // garment decode is sub-millisecond (a few hundred verts; it was + // lumped in with the expensive texture decodes in the perf overhaul), + // and the game's ping-pong double buffer makes the LIVE read tear-safe + // (the sim writes the other half). + const bool ropa_inline = + item.ropa && REXCVAR_GET(skate3_native_render_scene_ropa_inline); + const bool dynamic_payload = item.skinned || item.cloth_quads || item.ropa; + auto it = g_r.meshes.find(item.mesh); + if (item.retained && + (it == g_r.meshes.end() || it->second.fingerprint != item.fingerprint)) { + // Retained off-screen items (edge-of-view guard band) outlive their + // guest-side lifetime guarantees; the arena may have streamed out or + // been reused since capture. Draw only the exact cached decode; no + // guest reads, no heals, no miss enqueues from here. + return; + } + if (it != g_r.meshes.end() && it->second.fingerprint != item.fingerprint && + REXCVAR_GET(skate3_native_render_scene_mesh_revalidate)) { + if (item.cloth_quads || ropa_inline) { + g_r.device->DestroyDeferred(it->second.vb); + g_r.device->DestroyDeferred(it->second.ib); + g_r.meshes.erase(it); + it = g_r.meshes.end(); + } else if (!dynamic_payload) { + // Streaming heal: keep drawing the old decode this frame; the + // workers decode the new payload and the commit swaps it in. + EnqueueMeshMiss(item.mesh); + } + } + if (it == g_r.meshes.end()) { + if (!item.cloth_quads && !ropa_inline) { + // ALL mesh misses decode on the workers, including first-sight + // skinned entities (a streamed-in NPC appears 1-2 frames late + // instead of hitching the frame; the dyn decode jobs usually land + // the same content even sooner from the guest-thread snapshot). + EnqueueMeshMiss(item.mesh); + g_rr_mesh_deferred.fetch_add(1, std::memory_order_relaxed); + return; + } + if (mesh_budget > 0 && mesh_decodes >= uint32_t(mesh_budget)) { + g_rr_mesh_deferred.fetch_add(1, std::memory_order_relaxed); + return; // decodes on a later frame + } + ++mesh_decodes; + const auto decode_t0 = PerfClock::now(); + MeshBuffers buffers; + const bool decode_ok = DecodeMesh(g_r.device, base, item, buffers); + g_pw_mesh_decode.Add(perf_ns_since(decode_t0)); + if (!decode_ok) { + g_rr_decode_fail.fetch_add(1, std::memory_order_relaxed); + static std::unordered_set logged; + if (logged.size() < 32 && logged.insert(item.mesh).second) { + REXLOG_WARN("native-scene: DecodeMesh FAILED mesh={:08X} vb={:08X} fmt={} stride={}", + item.mesh, item.vb_addr, item.pos_fmt, item.stride); + } + return; + } + buffers.fingerprint = item.fingerprint; + it = g_r.meshes.emplace(item.mesh, buffers).first; + } + const MeshBuffers& buffers = it->second; + + // Double-sided sheet props draw with backface culling; without it the + // front/back copies z-fight into lightmap flicker at range (banners/ + // flags). Opaque depth pass only; a mirrored instance (negative world + // determinant) would flip winding, so those stay uncull(ed). + // (hair items with a validated lighting capture draw in the blended + // sub-pass under their own cull PSOs; never reset those here) + const bool hair_pass = item.char_family >= 4 && item.char_family <= 5 && + item.char_rows[14 * 4 + 1] > 0.0f; + // reflective_trans glass draws in the blended sub-pass whenever its + // exact branch is live (same gate as the sub-pass routing below); the + // opaque cull-PSO reset must not fire there. + const bool refl_trans_pass = + item.env_family == 13 && debug_mode == 0 && scene.shadow_valid; + // Character items mid-fade (spawn settle / distance) draw in the blended + // sub-pass too (same gate as the routing below), same exemption. + const bool char_fade_pass = + (item.char_family == 1 || item.char_family == 2 || + item.char_family == 3 || item.char_family == 6) && + debug_mode == 0 && CharFadeAlpha(item) < 0.999f && + REXCVAR_GET(skate3_native_render_scene_entity_fade); + if (use_depth && !item.transparent && !item.water && !hair_pass && + !refl_trans_pass && !char_fade_pass && g_r.pso_cullback != nullptr) { + const float* w = item.world; + const float det3 = w[0] * (w[5] * w[10] - w[6] * w[9]) - + w[1] * (w[4] * w[10] - w[6] * w[8]) + + w[2] * (w[4] * w[9] - w[5] * w[8]); + // Game-parity backface culling: every world environment material's + // XML sets CULLMODE=FRONT (== our CULL_FRONT; the banner work + // calibrated game-kept faces as our D3D12 BACK faces). CULL_NONE + // showed interior/away faces the game never renders, e.g. the + // building wall's inside face stacking behind the translucent canopy + // glass. Trees/alphatest (fams 7/9/10: leaf/fence cards read from + // both sides) and mirrored instances (flipped winding) stay + // uncull(ed), matching the two-sided-sheet rules. + const bool cull_family = + REXCVAR_GET(skate3_native_render_scene_backface_cull) && + item.env_family != 0 && item.env_family != 7 && + item.env_family != 9 && item.env_family != 10 && + item.env_family != 13; + nrhi::Pipeline* want = + ((buffers.two_sided_sheet || cull_family) && det3 >= 0.0f) + ? g_r.pso_cullback + : g_r.pso; + if (want != scene_pso_bound) { + cmd->SetPipeline(want); + scene_pso_bound = want; + } + } + + // Resolve guest textures (white fallback) through the words-keyed + // content store: object -> stable live words -> store entry + // (console identity semantics; a + // retargeted or reused object is just a different key, never a stale + // serve). The object addresses were readable on the game thread this + // frame; NO VirtualQuery here; it takes the process VAD lock, which + // the guest streaming threads hammer, and ~2 calls per item stalled + // the whole renderer to 3 fps. + // Set by resolve_texture_raw when it returns the white fallback while a + // decode is in flight (first-sight miss or a still-failing heal); the + // sticky wrapper below then serves the item's last-good texture instead. + bool tex_pending = false; + const auto resolve_texture_raw = [&](uint32_t tex_ptr) -> const GuestTexture* { + if (tex_ptr == 0) { + return &g_r.white; + } + const bool trm = g_trace_mesh_addr != 0 && item.mesh == g_trace_mesh_addr; + auto rit = g_r.tex_routes.find(tex_ptr); + if (!item.retained) { + // Route refresh: seqlock-stable read of the live fetch words (a + // mid-rewrite mixed snapshot must never become a key; it would + // decode a coherent image of the WRONG memory, the pool-page + // collage class). An unstable read keeps the previous route for a + // frame. Retained items skip all live reads: the object may be + // gone; their route is frozen at retention. + uint32_t live[6]; + if (ReadStableTexWords(base, tex_ptr, live)) { + const bool demoted = (live[1] & 0xFFFFF000u) == 0u; + if (rit == g_r.tex_routes.end()) { + rit = g_r.tex_routes.emplace(tex_ptr, RendererState::TexRoute{}) + .first; + std::memcpy(rit->second.words, live, sizeof(live)); + rit->second.key = FetchWordsKey(live); + rit->second.demoted = demoted; + } else if (demoted) { + // Mip-0 demoted (base address cleared; the old pool range is + // already reused): hold the pre-demote route; its decode + // carries the full chain, strictly better, and suspend its + // payload polls (the probes would read the reused pool and + // heal in foreign bytes). A re-promote publishes fresh words + // and re-routes. + if (!rit->second.demoted) { + rit->second.demoted = true; + g_demote_hold.fetch_add(1, std::memory_order_relaxed); + if (trm) { + REXLOG_INFO("tex-trace: f{} obj={:08X} DEMOTED (route held, " + "polls suspended)", + frame_number, tex_ptr); + } + } + } else if (std::memcmp(live, rit->second.words, sizeof(live)) != 0) { + if (trm) { + REXLOG_INFO( + "tex-trace: f{} obj={:08X} REROUTE old=[{:08X} {:08X} " + "{:08X} {:08X} {:08X} {:08X}] new=[{:08X} {:08X} {:08X} " + "{:08X} {:08X} {:08X}]", + frame_number, tex_ptr, rit->second.words[0], + rit->second.words[1], rit->second.words[2], + rit->second.words[3], rit->second.words[4], + rit->second.words[5], live[0], live[1], live[2], live[3], + live[4], live[5]); + } + std::memcpy(rit->second.words, live, sizeof(live)); + rit->second.key = FetchWordsKey(live); + rit->second.demoted = false; + } else { + rit->second.demoted = false; + } + } else if (trm) { + REXLOG_INFO("tex-trace: f{} obj={:08X} words UNSTABLE (mid-rewrite)", + frame_number, tex_ptr); + } + } + if (rit == g_r.tex_routes.end()) { + // Unreadable/unstable object with no prior route: nothing safe to + // serve or decode yet; next frame's read settles it. + tex_pending = true; + return &g_r.white; + } + const RendererState::TexRoute& route = rit->second; + auto sit = g_r.tex_store.find(route.key); + if (sit == g_r.tex_store.end()) { + if (item.retained || route.demoted) { + // Retained: no enqueues, no live reads. Demote-held with nothing + // cached: the pre-demote pool may already be reused; a decode + // would commit foreign bytes under a good key. White/sticky until + // a re-promote publishes live words. + tex_pending = true; + return &g_r.white; + } + // Decode on the workers; white/sticky for the 1-3 frames that takes + // (inline decode measured ~10 ms avg / ~70 ms max, the panning + // lag spikes). + if (trm) { + REXLOG_INFO("tex-trace: f{} obj={:08X} MISS key={:016X} enqueued", + frame_number, tex_ptr, route.key); + } + EnqueueWordsMiss(route.key, route.words); + g_rr_tex_deferred.fetch_add(1, std::memory_order_relaxed); + tex_pending = true; + return &g_r.white; + } + GuestTexture& e = sit->second; + e.last_used_frame = frame_number; + if (!e.valid) { + // Failed decode: retry on its backoff clock (the payload usually + // lands within a few frames of first sight; the commit stamps the + // real backoff). + if (!item.retained && !route.demoted && + frame_number >= e.retry_after_frame) { + e.retry_after_frame = frame_number + 120; + EnqueueWordsMiss(route.key, route.words); + } + if (trm) { + REXLOG_INFO("tex-trace: f{} obj={:08X} INVALID entry key={:016X} " + "(retry at f{})", + frame_number, tex_ptr, route.key, e.retry_after_frame); + } + tex_pending = true; + return &g_r.white; + } + // Payload revalidation, the one irreducible heuristic: the game + // streams content IN PLACE at addresses the words already point to + // (event-ad rotation, mip-pool fills, composed lightmap pages) with + // no CPU-visible event. Escalating cadence (2/4/8 then 16): a decode + // taken while the payload was still streaming reads back garbage; + // fresh entries re-verify fast, then settle to the cheap steady + // cadence. An incomplete decode (truncated tiled-mip copy) and a + // near_black verdict re-decode regardless of the mip-0 fingerprint; + // the commit's same-content dedup absorbs the no-ops. Suspended while + // the route is demote-held (the probes would read the reused pool). + if (!item.retained && !route.demoted && + frame_number >= e.recheck_frame && + REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { + // Menu/editor contexts poll STEADY entries on a 2-frame cadence + // (mirrors the 2D resolver): the CAS editor recomposes the skater's + // skin/garment textures IN PLACE progressively over ~a second on + // every edit, and the 16-frame steady cadence made each composite + // step land ~100+ ms late, desynchronized across the pieces, the + // "broken for 1-2 s after changing skin color" state. Gameplay + // keeps the cheap steady cadence. + e.recheck_frame = + frame_number + + (e.recheck_count < 3 + ? (2ull << e.recheck_count) + : (g_in_menus_frame.load(std::memory_order_relaxed) ? 2ull + : 16ull)); + const uint64_t fp = SampleProbeFingerprint(base, e); + const bool fp_new = fp != 0 && fp != e.payload_fp; + if (trm) { + REXLOG_INFO( + "tex-trace: f{} obj={:08X} poll key={:016X} fp={:016X} " + "cached={:016X} new={} inc={} nb={} cnt={}", + frame_number, tex_ptr, route.key, fp, e.payload_fp, + fp_new ? 1 : 0, e.incomplete ? 1 : 0, e.near_black ? 1 : 0, + e.recheck_count); + } + if (e.recheck_count < 3) { + ++e.recheck_count; + } + if (fp_new || e.incomplete || (e.near_black && e.nb_redecodes < 3)) { + // Re-decode churn diagnostic: repeated in-place payload changes + // on one key = streaming oscillation, visible as texture flicker + // on the affected meshes. + static std::atomic s_redecode_logs{0}; + if (s_redecode_logs.fetch_add(1) < 256) { + REXLOG_INFO( + "native-scene: texture re-decode key={:016X} reason={}{}{} " + "words=[{:08X} {:08X} {:08X} {:08X} {:08X} {:08X}]", + route.key, fp_new ? "fp" : "", e.incomplete ? "inc" : "", + e.near_black ? "nb" : "", e.fetch_words[0], e.fetch_words[1], + e.fetch_words[2], e.fetch_words[3], e.fetch_words[4], + e.fetch_words[5]); + } + if (trm) { + REXLOG_INFO("tex-trace: f{} obj={:08X} ENQUEUE heal key={:016X} " + "reason={}{}{}", + frame_number, tex_ptr, route.key, fp_new ? "fp" : "", + e.incomplete ? "inc" : "", e.near_black ? "nb" : ""); + } + EnqueueWordsMiss(route.key, e.fetch_words); + } + } + return &e; + }; + // Sticky serving: streaming rotates content onto NEW texture objects (a + // mip promote is usually a fresh object: 287 first-sight objects vs 73 + // in-place rebinds in one 45 s traversal), so a plain + // cache miss white-flashed content that was on screen with the previous + // mip one frame earlier. While the new object's decode is in flight, + // serve the last texture this item slot successfully resolved, the + // same visual as the console's own mip transition. slot: 0 diffuse, + // 1 lightmap, 2 macro, 3 normal/ripple, 4 decal art, 5 hair, 6 spec. + const auto resolve_texture = [&](uint32_t tex_ptr, + uint32_t slot) -> const GuestTexture* { + tex_pending = false; + const GuestTexture* t = resolve_texture_raw(tex_ptr); + // Near-uniform-black decodes on the WHITE-NEUTRAL slots (1 lightmap, + // 2 macro) serve the white fallback until a heal lands real content. + // Lightmap: a real-but-black page binds with tint.r > 0 and the CSM + // min-clamp renders the surface BLACK (the door 59810af's shader gate + // cannot see). Macro: since 59810af the weathering multiplies OVER + // the composited decal art, so a mid-stream dark macro decode turns + // the paint into the black-square flash; white is the macro's + // authored neutral (materials without weathering bind default_white). + // Slots with legitimately dark content (diffuse, decal art, spec) + // are untouched. Applied in the wrapper so retained items get the + // same protection. + const bool trm = g_trace_mesh_addr != 0 && item.mesh == g_trace_mesh_addr; + if ((slot == 1 || slot == 2) && t != &g_r.white && t->valid && + t->near_black) { + if (trm) { + REXLOG_INFO("tex-trace: f{} slot={} obj={:08X} NEAR-BLACK -> white", + frame_number, slot, tex_ptr); + } + static std::atomic s_nb_logs{0}; + if (s_nb_logs.fetch_add(1, std::memory_order_relaxed) < 24) { + REXLOG_INFO( + "native-scene: near-black decode obj={:08X} slot={} served as " + "white fallback (mid-compose/stream content)", + tex_ptr, slot); + } + return &g_r.white; + } + if (item.retained || tex_ptr == 0) { + return t; + } + const uint64_t skey = (uint64_t(item.mesh) << 3) | slot; + if (t != &g_r.white) { + RendererState::TexStickySite& site = g_r.tex_sticky[skey]; + // Material-detail downgrade hold: the game's streaming demotes a + // nearby mesh to its UN (undetailed) material for a fraction of a + // second and back, the visible flash to completely different/ + // blurry art. Under words identity a demote is just a smaller-area + // key; the site's last-adopted decode is still resident in the + // store, so keep serving it while the downgrade is young. A + // persistent downgrade (a real demote as the player leaves) adopts + // after the hold window. + const int32_t hold = REXCVAR_GET(skate3_native_render_scene_detail_hold); + const uint64_t cur_key = FetchWordsKey(t->fetch_words); + const uint64_t cur_area = FetchWordsArea(t->fetch_words); + if (hold > 0 && site.area > cur_area && site.words_key != 0) { + const auto hit = g_r.tex_store.find(site.words_key); + const GuestTexture* held = + hit != g_r.tex_store.end() && hit->second.valid ? &hit->second + : nullptr; + if (held != nullptr) { + // The held entry may no longer be polled by any live route; + // re-probe its payload on its own recheck clock so a reused + // pool page abandons the hold instead of serving foreign + // bytes. + hit->second.last_used_frame = frame_number; + if (frame_number >= hit->second.recheck_frame) { + hit->second.recheck_frame = frame_number + 16; + const uint64_t fp = SampleProbeFingerprint(base, hit->second); + if (fp == 0 || fp != hit->second.payload_fp) { + held = nullptr; + } + } + } + if (held != nullptr) { + if (site.downgrade_since == 0) { + site.downgrade_since = frame_number; + static std::atomic s_hold_logs{0}; + if (s_hold_logs.fetch_add(1, std::memory_order_relaxed) < 24) { + REXLOG_INFO( + "native-scene: detail-downgrade HOLD mesh={:08X} slot={} " + "held={:016X} bound={:016X} area {} -> {}", + item.mesh, slot, site.words_key, cur_key, site.area, + cur_area); + } + } + if (frame_number - site.downgrade_since < uint64_t(hold)) { + if (trm && ((frame_number - site.downgrade_since) & 63u) == 0) { + REXLOG_INFO( + "tex-trace: f{} slot={} DETAIL-HOLD serving fp={:016X} " + "(bound area {} < {})", + frame_number, slot, held->payload_fp, cur_area, + site.area); + } + return held; + } + } + } + if (trm && site.words_key != 0 && site.words_key != cur_key) { + REXLOG_INFO( + "tex-trace: f{} slot={} ADOPT key {:016X}(area {}) -> " + "{:016X}(area {})", + frame_number, slot, site.words_key, site.area, cur_key, + cur_area); + } + site.words_key = cur_key; + site.area = cur_area; + site.downgrade_since = 0; + if (!g_r.tex_pending_first.empty()) { + g_r.tex_pending_first.erase(tex_ptr); + } + return t; + } + if (tex_pending) { + // The current binding's decode is in flight: serve the site's + // previous art from the store, the console's own mip-transition + // look instead of a white flash. + const auto sit = g_r.tex_sticky.find(skey); + if (sit != g_r.tex_sticky.end() && sit->second.words_key != 0) { + const auto old = g_r.tex_store.find(sit->second.words_key); + if (old != g_r.tex_store.end() && old->second.valid) { + old->second.last_used_frame = frame_number; + g_tex_sticky_served.fetch_add(1, std::memory_order_relaxed); + if (trm) { + REXLOG_INFO("tex-trace: f{} slot={} STICKY serving key={:016X} " + "(pending {:08X})", + frame_number, slot, sit->second.words_key, tex_ptr); + } + return &old->second; + } + } + } + if (trm && t == &g_r.white) { + REXLOG_INFO("tex-trace: f{} slot={} serving WHITE (req {:08X})", + frame_number, slot, tex_ptr); + } + return t; + }; + // Streamed-artwork diffuse override (see DrawItem::diffuse_fetch): the + // real art exists only as draw-time fetch words; resolve those through + // the words-keyed cache (shared with the 2D pass; the art has no guest + // object to key on). + const GuestTexture* diffuse = + item.diffuse_fetch[1] != 0 + ? resolve_fetch_words(item.diffuse_fetch, uint64_t(item.mesh) << 1, + item.retained) + : nullptr; + if (diffuse == nullptr) { + diffuse = resolve_texture(item.diffuse_tex, 0); + if (diffuse == &g_r.white && tex_pending && item.diffuse_tex != 0 && + !item.retained) { + // Brand-new content, first decode in flight, and no previous mip + // cached to serve: draw NOTHING for a short window instead of a + // white flash: the emulated pop-in semantics (the game shows + // streamed content only once its data is ready). Bounded so a + // permanently failing texture still falls back to visible white. + const auto [pit, fresh] = + g_r.tex_pending_first.try_emplace(item.diffuse_tex, frame_number); + if (frame_number - pit->second < 20) { + g_skip_new.fetch_add(1, std::memory_order_relaxed); + return; + } + } + } + const GuestTexture* lightmap = + item.lightmap_tex != 0 && REXCVAR_GET(skate3_native_render_scene_lightmaps) + ? resolve_texture(item.lightmap_tex, 1) + : nullptr; + if (lightmap == &g_r.white) { + lightmap = nullptr; + } + + // constants = world + mvp (world * view_proj, row-vector) + tint + cam + // + material tint + overlay params + misc. tint.a > 0 selects debug + // solid colors; tint.r > 0 marks a bound lightmap. For transparent + // items misc.yzw carries the fog ramp and mat_tint the fog color. + float constants[52] = {}; + std::memcpy(constants, item.world, sizeof(item.world)); + if (item.unlit) { + // sky.*: the dome mesh is CAMERA-RELATIVE (sky.fx defaultVS adds + // g_vViewPos to every vertex). Anchoring it at the world origin put + // the baked skyline panorama ~700 m off, visibly rotated/parallaxed + // against the emulated frame. The game's sky viewpos tracks the camera + // in x/z but pins Y at the level's fixed sky elevation (captured per + // frame from the sky draw's VS bank; 165.0 in every capture); using + // cam.y rendered the skyline ~160 m too LOW. + constants[12] += scene.cam_pos[0]; + constants[13] += scene.sky_height; + constants[14] += scene.cam_pos[2]; + } + float* mvp = constants + 16; + for (int r = 0; r < 4; ++r) { + for (int c = 0; c < 4; ++c) { + float sum = 0.0f; + for (int k = 0; k < 4; ++k) { + sum += constants[r * 4 + k] * scene.view_proj[k * 4 + c]; + } + mvp[r * 4 + c] = sum; + } + } + // Bone palette upload for skinned items; tint.g flags skinning. + bool bones_bound = false; + if (item.skinned && !item.bones.empty()) { + const uint32_t bytes = uint32_t(item.bones.size() * sizeof(float)); + const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u; + if (offset + bytes <= RendererState::kBoneRegionSize) { + std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.bones.data(), bytes); + g_r.bone_ring_offset = offset + bytes; + cmd->SetBufferSrv(3, g_r.bone_ring, bone_region + offset); + bones_bound = true; + } + } + if (!bones_bound) { + if (item.skinned && !item.bones.empty()) { + // Ring exhausted: this item renders bind-pose at identity, + // effectively invisible. Must never happen silently. + g_rr_no_bones.fetch_add(1, std::memory_order_relaxed); + } + cmd->SetBufferSrv(3, g_r.bone_ring, 0); + } + + if (debug_mode >= 2) { + // Stable per-object colors: hash the mesh address, not the (sort-order + // dependent) item index. + const uint32_t hash = (item.mesh >> 4) * 2654435761u; + constants[32] = float((hash >> 0) & 0xFF) / 255.0f; + constants[33] = float((hash >> 8) & 0xFF) / 255.0f; + constants[34] = float((hash >> 16) & 0xFF) / 255.0f; + constants[35] = 1.0f; + } else { + constants[32] = lightmap != nullptr ? 1.0f : 0.0f; + // tint.g doubles as the "character: alpha = gloss, no alpha-test" + // marker; ropa garments rendered rigid have no bones but must not be + // alpha-clipped (their VS skinning branch is inert: zero weights). + constants[33] = (bones_bound || item.ropa) ? 1.0f : 0.0f; + constants[34] = item.unlit ? 1.0f : 0.0f; + constants[35] = 0.0f; + } + constants[36] = scene.cam_pos[0]; + constants[37] = scene.cam_pos[1]; + constants[38] = scene.cam_pos[2]; + // cam_pos.w = character shading family: > 0 switches the PS to the + // captured character-lighting branch (rows uploaded to b2 below); + // char_rows[14*4+1] stays 0 when the capture failed validation, which + // keeps the item on the legacy empirical shading. + constants[39] = 0.0f; + if (debug_mode == 0 && item.char_family != 0 && + item.char_rows[14 * 4 + 1] > 0.0f) { + const uint32_t offset = (g_r.bone_ring_offset + 255u) & ~255u; + if (offset + 256u <= RendererState::kBoneRegionSize) { + std::memcpy(g_r.bone_ring_cpu + bone_region + offset, item.char_rows, + sizeof(item.char_rows)); + g_r.bone_ring_offset = offset + 256u; + cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region + offset); + constants[39] = item.char_rows[14 * 4 + 1]; + g_char_drawn.fetch_add(1, std::memory_order_relaxed); + } + } + // cam_pos.w = -family selects the exact world-material branch. Gated on + // the frame rows being captured (scene.shadow_valid carries the scene + // exposure / material multiplier at b1); without them the tone chain + // would multiply by zero and render black. + if (debug_mode == 0 && item.env_family != 0 && scene.shadow_valid && + !item.water && !item.transparent) { + constants[39] = -float(item.env_family); + } + // cam_pos.w = -(20 + variant) selects the exact dynamicobject branch + // (rigid props). Gated on the frame's dynamicobject lighting rows having + // been captured (dyn_* at b1); without them the tone chain multiplies by + // zero and renders black; fall back to the legacy shading otherwise. + if (debug_mode == 0 && item.dynobj != 0 && scene.dynobj_valid) { + constants[39] = -float(20 + item.dynobj); + g_dynobj_drawn.fetch_add(1, std::memory_order_relaxed); + } + std::memcpy(constants + 40, item.tint, 4 * sizeof(float)); + // t3 = macro grime/crack overlay, t4 = decal art for environment.decal + // surfaces (in-shader composite). Independent slots: decal ground/wall + // sections carry the same macrooverlay as their non-decal neighbors, and + // binding only the art dropped the macro multiply there; alternating + // plaza sections rendered ~1.4x too bright (the ground checkerboard). + const GuestTexture* macro_tex = + item.macro_tex != 0 && REXCVAR_GET(skate3_native_render_scene_macro) + ? resolve_texture(item.macro_tex, 2) + : &g_r.white; + if ((item.water || item.char_family >= 6) && item.water_normal != 0) { + // Water rides its ripple normal map in the macro slot (water never + // carries a macro overlay; overlay.z stays 0 below so the macro + // composite path never runs). Vehicles do the same with their DXN + // panel normal map; without it the hinged panels' vertex normals + // face away from the sun and shade as a dark ambient-blue patch that + // stops at the door seams (the exact PS with a FLAT map reproduces + // that artifact; with the real map it matches the emulated car). + macro_tex = resolve_texture(item.water_normal, 3); + } + // Water / vehicle environment cube (t6, root param 8): decoded once per + // guest object into the cube cache; the gray fallback cube otherwise. + // Vehicle materials carry an `environment` channel that resolves through + // the same chan+0x1C path as the ocean's. Cube decodes run on the + // workers (a SINGLE inline cube decode measured up to ~100 ms, the + // largest remaining traversal hitch when a vehicle / reflective area + // streamed in); flat-gray reflections for the 1-3 frames in flight are + // invisible. + const GuestTexture* cube_tex = &g_r.white_cube; + if ((item.water || item.char_family >= 6 || + (item.env_family >= 5 && item.env_family <= 6) || + item.env_family == 13) && + item.water_env != 0) { + auto cit = g_r.cube_textures.find(item.water_env); + if (cit == g_r.cube_textures.end()) { + if (!item.retained) { + EnqueueCubeMiss(item.water_env); + } + } else if (cit->second.valid) { + cube_tex = &cit->second; + } + } + const GuestTexture* decal_tex = item.decal && item.decal_art != 0 && + REXCVAR_GET(skate3_native_render_scene_decals) + ? resolve_texture(item.decal_art, 4) + : &g_r.white; + // Streamed-artwork decal override (see DrawItem::decal_fetch): ad frames + // covered by an environment.decal section get the current event-ad art + // bound over the decal channel at draw time. + if (item.decal && item.decal_fetch[1] != 0 && + REXCVAR_GET(skate3_native_render_scene_decals)) { + const GuestTexture* ad = resolve_fetch_words( + item.decal_fetch, (uint64_t(item.mesh) << 1) | 1, item.retained); + if (ad != nullptr) { + decal_tex = ad; + } + } + if (((item.char_family >= 4 && item.char_family <= 5) || item.char_alpha) && + item.hair_alpha_tex != 0) { + // Hair strand coverage rides the (otherwise unused) decal slot; the + // PS hair branch samples it at the raw second texcoord. The white + // fallback keeps failed decodes opaque rather than invisible. + // character.alpha lenses use the same plumbing (ch_misc.z signals the + // fam-1/2 branch to take alpha from this texture). + decal_tex = resolve_texture(item.hair_alpha_tex, 5); + } + // F7 ring: stamp the SERVED content fingerprints (see SceneRingItem); + // pointer identity cannot see an in-place content swap. + if (ring_frame != nullptr) { + const auto rit = ring_map.find(&item); + if (rit != ring_map.end()) { + SceneRingItem& ri = ring_frame->items[rit->second]; + const auto fp_of = [](const GuestTexture* t) -> uint64_t { + return t != nullptr && t->valid ? t->payload_fp : 0; + }; + ri.fp_diffuse = fp_of(diffuse); + ri.fp_lightmap = fp_of(lightmap); + ri.fp_macro = fp_of(macro_tex); + ri.fp_decal = fp_of(decal_tex); + } + } + if (g_trace_mesh_addr != 0 && item.mesh == g_trace_mesh_addr) { + // Traced-mesh per-frame summary: SERVED state (post-resolve), logged + // when anything changes. The mesh's texture objects also register for + // the worker-commit trace. + const auto fp_of = [](const GuestTexture* t) -> uint64_t { + return t != nullptr && t->valid ? t->payload_fp : 0; + }; + for (const GuestTexture* t : + {diffuse, lightmap, macro_tex, decal_tex}) { + if (t != nullptr && t != &g_r.white) { + g_trace_keys.insert(FetchWordsKey(t->fetch_words)); + } + } + uint64_t sig = uint64_t(item.diffuse_tex) ^ + (uint64_t(item.decal_art) << 16) ^ + (uint64_t(item.lightmap_tex) << 32) ^ + (uint64_t(item.macro_tex) << 48); + sig ^= fp_of(diffuse) * 3 ^ fp_of(decal_tex) * 5 ^ fp_of(lightmap) * 7 ^ + fp_of(macro_tex) * 11; + sig ^= item.retained ? 1 : 0; + uint64_t& last = g_trace_sig[item.ctx]; + if (sig != last) { + last = sig; + REXLOG_INFO( + "tex-trace: f{} SERVED ctx={:08X} retained={} " + "dif={:08X}/{:016X} dec={:08X}/{:016X} lm={:08X}/{:016X} " + "mac={:08X}/{:016X}", + frame_number, item.ctx, item.retained ? 1 : 0, item.diffuse_tex, + fp_of(diffuse), item.decal_art, fp_of(decal_tex), + item.lightmap_tex, fp_of(lightmap), item.macro_tex, + fp_of(macro_tex)); + } + } + // Exact env families without decal art bind the material's spec/ecc/ + // refmask map (or the animated.tree noise tint) in the free decal slot; + // overlay.w == 3 tells the shader the masks are live. + bool spec_bound = false; + if (item.env_family != 0 && !item.decal && item.env_family != 10 && + item.spec_tex != 0) { + const GuestTexture* spec = resolve_texture(item.spec_tex, 6); + if (spec != &g_r.white) { + decal_tex = spec; + spec_bound = true; + } + } + // Sky dome: the material's `specular` channel is the 1D radial sun + // gradient (512x16), bound in the free decal slot for the exact sky + // branch. Until it decodes the dome falls back to the plain fullbright + // panorama (sunless for the 1-3 frames in flight). + bool sky_sun_bound = false; + if (item.unlit && item.spec_tex != 0) { + const GuestTexture* sun = resolve_texture(item.spec_tex, 6); + if (sun != &g_r.white) { + decal_tex = sun; + sky_sun_bound = true; + } + } + const bool is_decal = + item.char_family < 4 && item.decal && decal_tex != &g_r.white; + // Fam 5/6 masks+normal pair (t4/t5): the reflective PS perturbs its + // reflection/spec with the material's normal map (shader overlay.w == 4 + // branch, the fix for the giant flat-normal cube smear on glass + // facades). Both views bind together at the draw via SetTexturePair; + // pair descriptors are backend-cached, so prewarm/revalidation texture + // swaps can never leave a stale descriptor. Until the normal map + // decodes, overlay.w stays 3 (flat-normal reflection, the old behavior). + const GuestTexture* pair_normal = nullptr; + bool normal_paired = false; + if (item.env_family >= 5 && item.env_family <= 6) { + uint32_t gate = 0; + if (!spec_bound) gate |= 1; + if (item.water_normal == 0) gate |= 2; + if (spec_bound && (!decal_tex->valid || decal_tex->srv_mips == 0)) gate |= 4; + if (gate == 0) { + const GuestTexture* nrm = resolve_texture(item.water_normal, 3); + if (nrm == &g_r.white) { + gate |= 8; + } else { + if (!nrm->valid) gate |= 16; + if (nrm->srv_mips == 0) gate |= 32; + } + if (gate == 0) { + pair_normal = nrm; + normal_paired = true; + } + } + if (normal_paired) { + g_refl_pair.fetch_add(1, std::memory_order_relaxed); + } else { + g_refl_flat.fetch_add(1, std::memory_order_relaxed); + g_refl_gate.store(gate, std::memory_order_relaxed); + } + } + // World-shading v2 (fams 1-4): the material's real per-pixel maps. The + // base normal map rides the free t5 pair slot (t4 holds the spec masks + // or the decal art there), the detail normal map + the decal families' + // spec/ecc masks ride the t8/t9 pair. misc.z carries the bind flags + // (1 = normal, 2 = detail, 4 = spec2), misc.w the detailNormalUVScale + // channel constant; both slots are spare on opaque fams 1-4 (fog only + // uses them on transparent/water items, F12 only on fams 5/6/13). + const GuestTexture* v2_detail = nullptr; + const GuestTexture* v2_spec2 = nullptr; + uint32_t v2_flags = 0; + if (item.env_family >= 1 && item.env_family <= 4 && !item.transparent && + !item.water && debug_mode == 0 && scene.shadow_valid && + REXCVAR_GET(skate3_native_render_scene_world_v2)) { + if (item.water_normal != 0) { + const GuestTexture* nrm = resolve_texture(item.water_normal, 3); + if (nrm != &g_r.white && nrm->valid && nrm->srv_mips != 0) { + pair_normal = nrm; + normal_paired = true; // t5 pair bind; overlay.w stays 0..3 here + v2_flags |= 1; + } + } + // Fam 2 (environmentsimple) carries a plain normal map with no + // base+detail pair; its vnd is the raw 2t-1 (shader-side). + if ((v2_flags & 1u) != 0 && item.env_family != 2 && + item.detail_tex != 0 && item.detail_scale > 0.0f) { + const GuestTexture* det = resolve_texture(item.detail_tex, 7); + if (det != &g_r.white && det->valid && det->srv_mips != 0) { + v2_detail = det; + v2_flags |= 2; + } + } + if (item.decal && item.spec_tex != 0) { + const GuestTexture* sp = resolve_texture(item.spec_tex, 6); + if (sp != &g_r.white && sp->valid && sp->srv_mips != 0) { + v2_spec2 = sp; + v2_flags |= 4; + } + } + } + // Dynamicobject v2: the same misc.z/w convention and t5/t8/t9 slots as + // fams 1-4; dynobj draws are never transparent/water and carry + // env_family 0, so both misc slots are free, and t4 stays on its white + // fallback (the spec-mask t4 bind is env-family-only), so the t5 pair + // costs nothing. The spec/ecc masks go to t9 rather than t4: it keeps + // t4's semantics untouched and matches the decal families' 2-channel + // mask convention. Gated exactly like the PS branch selection (dynobj + // frame rows captured) plus the dynobj_v2 cvar for A/B against the v1 + // flat response. + if (item.dynobj != 0 && debug_mode == 0 && scene.dynobj_valid && + REXCVAR_GET(skate3_native_render_scene_dynobj_v2)) { + if (item.water_normal != 0) { + const GuestTexture* nrm = resolve_texture(item.water_normal, 3); + if (nrm != &g_r.white && nrm->valid && nrm->srv_mips != 0) { + pair_normal = nrm; + normal_paired = true; + v2_flags |= 1; + } + } + if ((v2_flags & 1u) != 0 && item.detail_tex != 0 && + item.detail_scale > 0.0f) { + const GuestTexture* det = resolve_texture(item.detail_tex, 7); + if (det != &g_r.white && det->valid && det->srv_mips != 0) { + v2_detail = det; + v2_flags |= 2; + } + } + if (item.spec_tex != 0) { + const GuestTexture* sp = resolve_texture(item.spec_tex, 6); + if (sp != &g_r.white && sp->valid && sp->srv_mips != 0) { + v2_spec2 = sp; + v2_flags |= 4; + } + } + // Static world-shadow map at t4 (flag 8): the native re-render of the + // game's baked-shade map (see RenderShadowAtlas). t4 is otherwise the + // white fallback on dynobj draws. Without it the PS world term is 1 + // and props inside baked building shade light at full key. + if (g_r.world_shadow_srv != nullptr && g_r.world_shadow_in_srv && + scene.dynobj_ws_valid) { + v2_flags |= 8; + } + } + constants[44] = item.macro_scale; + constants[45] = item.macro_opacity; + constants[46] = macro_tex != &g_r.white ? 1.0f : 0.0f; + // overlay.w: 1 = single-placement decal (art clamps), 2 = tileable + // decal (art wraps; clamping a many-period uv range stretched the + // border texels into the giant cliff-face streaks), 3 = spec masks + // bound (exact env families). The 4-encode (t5 normal pair driving the + // reflective trim path) is fams 5/6 only; fams 1-4 signal their + // normal map through the misc.z flags instead. + constants[47] = + is_decal ? (item.decal_tileable ? 2.0f : 1.0f) + : (spec_bound ? ((normal_paired && item.env_family >= 5) + ? 4.0f + : 3.0f) + : 0.0f); + if (item.water) { + // overlay.x = ripple scroll time, overlay.y = real environment cube + // bound at t6, overlay.z = ripple normal map resolved (in the macro + // slot; the shader synthesizes procedural ripples otherwise). + // overlay.w = 1 marks water with NO diffuse channel (ocean.default): + // the body term must be zero (ocean.fx diffTerm = 0); the white + // fallback diffuse otherwise renders the whole sea as a bright plain. + constants[44] = water_time; + constants[45] = cube_tex != &g_r.white_cube ? 1.0f : 0.0f; + constants[46] = macro_tex != &g_r.white ? 1.0f : 0.0f; + constants[47] = item.diffuse_tex == 0 ? 1.0f : 0.0f; + } else if (item.char_family >= 6) { + // Vehicles reuse the water convention: overlay.y > 0 = a real + // environment cube bound at t6 (the PS vehicle branch's reflection + // term). Vehicles never carry macro/decal channels, so the macro + // defaults staged above are inert, but overlay.y must not inherit + // macro_opacity's 1.0 default when no cube resolved. + constants[45] = cube_tex != &g_r.white_cube ? 1.0f : 0.0f; + } + // misc.x: alpha-blended sub-pass item (1 = transparentenvironment + // shading, 2 = water branch); fog rides in misc.yzw (ramp) and the + // mat_tint row (color), unused by these items otherwise + // (root-signature DWORD budget). + constants[48] = item.water ? 2.0f : (item.transparent ? 1.0f : 0.0f); + if (item.transparent || item.water) { + constants[49] = scene.fog_ramp[0]; + constants[50] = scene.fog_ramp[1]; + constants[51] = scene.fog_ramp[2]; + std::memcpy(constants + 40, scene.fog_color, 4 * sizeof(float)); + } else if (v2_flags != 0) { + // misc.z = the v2 material bind flags, misc.w = detailNormalUVScale + // (opaque fams 1-4 and the dynobj families; see the v2 blocks above). + constants[50] = float(v2_flags); + constants[51] = item.detail_scale; + } else if ((item.env_family >= 5 && item.env_family <= 6) || + item.env_family == 13) { + // misc.y = cube LOD bias for the reflective families: the guest's + // cube fetch computes its gradient LOD at the game's own 1152x640 + // render; at 4K our reflection-vector gradients are ~3.4x smaller + // per pixel, so mips alone leave baked cube detail (the streetlight + // heads) visible that the console blurs away. + // misc.z/misc.w = the F12 reflection-isolation controls (see the + // refl_mode cvar; both spare on opaque items; the fog packing only + // uses these slots on transparent/water). + constants[49] = + log2f(std::max(1.0f, float(context.guest_output_height) / 640.0f)); + constants[50] = float(REXCVAR_GET(skate3_native_render_scene_refl_mode)); + constants[51] = float(REXCVAR_GET(skate3_native_render_scene_refl_lod)); + // misc.x (spare on opaque fam 5/6): both constant normal-tilt trims, + // fixed-point packed (each mapped to 0..999 around 500; float-exact). + // Only when the EXACT branch will run (same gate as cam_pos.w = -fam) + // - the legacy fallback reads misc.x as the transparent/water flag. + // Fam 13 has no normal map (its PS reflects off the vertex normal), + // so its misc.x stays 0. + if (debug_mode == 0 && scene.shadow_valid && item.env_family != 13) { + double bx = REXCVAR_GET(skate3_native_render_scene_refl_bias_x); + double by = REXCVAR_GET(skate3_native_render_scene_refl_bias_y); + if (REXCVAR_GET(skate3_native_render_scene_refl_bias_auto) && + item.detail_tex != 0) { + // Derive the fold from the material's own detail texture: its + // first BC1 block decoded with hardware bit replication, texels + // averaged, folded as 2*d - 1. Cached per texture object; the + // packed-tile quirk of <=16px textures is benign here (neighbor + // packed mips of a constant texture hold the same constant). + // Implausible results (non-DXT1 / unreadable / |fold| > 0.1) + // keep the cvar values. + static std::unordered_map> fold_cache; + auto fit = fold_cache.find(item.detail_tex); + if (fit == fold_cache.end()) { + std::pair fold{float(bx), float(by)}; + uint32_t raw[6]; + if (GuestTryCopy(raw, base + item.detail_tex + 7 * 4, sizeof(raw))) { + const uint32_t w0 = SwapU32(raw[0]); + const uint32_t w1 = SwapU32(raw[1]); + uint8_t block[8]; + if ((w0 & 3) == 2 && (w1 & 0x3F) == 0x12 && + GuestTryCopy(block, + base + (0xA0000000u | ((w1 >> 12) << 12)), 8)) { + uint8_t le[8]; + for (int k = 0; k < 8; k += 2) { // k_8in16 guest endianness + le[k] = block[k + 1]; + le[k + 1] = block[k]; + } + uint8_t px[16][4]; + DecodeBc1Block(le, px); + float ax = 0.0f, ay = 0.0f; + for (int k = 0; k < 16; ++k) { + ax += px[k][0]; + ay += px[k][1]; + } + const float fx = (ax / 16.0f) * (2.0f / 255.0f) - 1.0f; + const float fy = (ay / 16.0f) * (2.0f / 255.0f) - 1.0f; + if (std::fabs(fx) < 0.1f && std::fabs(fy) < 0.1f) { + fold = {fx, fy}; + } + REXLOG_INFO( + "native-scene: detail fold tex={:08X} = ({:+.6f}, {:+.6f})", + item.detail_tex, fx, fy); + } + } + fit = fold_cache.emplace(item.detail_tex, fold).first; + } + bx = fit->second.first; + by = fit->second.second; + } + const auto pack_trim = [](double v) { + int i = int(std::lround(v * 1000.0)) + 500; + return std::clamp(i, 0, 999); + }; + constants[48] = float(pack_trim(bx) + 1000 * pack_trim(by)); + } + } + // cam_pos.w = -40 selects the exact sky branch: the game computes the + // sun glow inside the dome shader (see the PS sky branch). Needs the + // frame's captured sky sun rows AND the 1D gradient bound above; falls + // back to the legacy fullbright dome otherwise. The sky item never uses + // mat_tint/overlay/misc, so those root constants carry the sun rows. + if (debug_mode == 0 && item.unlit && sky_sun_bound && scene.sky_sun_valid) { + constants[39] = -40.0f; + constants[40] = scene.sky_sun[0]; // mat_tint.xyz = sun direction + constants[41] = scene.sky_sun[1]; + constants[42] = scene.sky_sun[2]; + constants[43] = scene.sky_height; // mat_tint.w = dome viewpos Y + constants[44] = scene.sky_sun[3]; // overlay.x = sun angular scale + constants[45] = scene.sky_sun[4]; // overlay.y = pre-tone multiplier + constants[46] = 0.0f; + constants[47] = 0.0f; + constants[49] = scene.sky_sun[5]; // misc.y = scene exposure + } + // SSR: record reflective items, env fams 5/6/13 on their exact branch + // with live spec masks (the reflection mask rides t4.z), plus water, + // for the reflection G-buffer pass after the scene pass. + if (ssr_on && !item.skinned && + (item.water || + ((item.env_family == 5 || item.env_family == 6 || + item.env_family == 13) && + constants[39] < -4.5f && constants[47] > 2.5f))) { + SsrGbufItem si; + std::memcpy(si.constants, constants, sizeof(constants)); + si.t3 = macro_tex->srv; + si.t4 = decal_tex->srv; + si.t5 = normal_paired ? pair_normal->srv : g_r.white.srv; + si.mesh = item.mesh; + si.fingerprint = item.fingerprint; + si.item = &item; + ssr_items.push_back(si); + } + cmd->SetRootConstants(0, 52, constants, 0); + + cmd->SetTexture(1, diffuse->srv); + cmd->SetTexture(2, (lightmap != nullptr ? lightmap : &g_r.white)->srv); + cmd->SetTexture(4, macro_tex->srv); + // t4 override: the dynobj world-shadow map rides the free first entry + // of the t4/t5 pair table (flag 8: dynobj draws never bind decal art + // or spec masks there). + nrhi::TextureView* t4_view = + (v2_flags & 8u) != 0 ? g_r.world_shadow_srv : decal_tex->srv; + if (normal_paired || t4_view != decal_tex->srv) { + cmd->SetTexturePair(5, t4_view, + normal_paired ? pair_normal->srv : g_r.white.srv); + } else { + cmd->SetTexture(5, decal_tex->srv); + } + // t6 (cube) shares its table with t7 (shadow atlas); re-pair both. + cmd->SetTexturePair(7, cube_tex->srv, + shadow_ready ? g_r.shadow_srv_final : g_r.white.srv); + // World-shading v2 material pair (t8 detail + t9 decal spec). + if (v2_flags != 0) { + cmd->SetTexturePair(8, (v2_detail != nullptr ? v2_detail : &g_r.white)->srv, + (v2_spec2 != nullptr ? v2_spec2 : &g_r.white)->srv); + } + // ROPA shape blend (see RendererState::ropa_shapes): combine the shape + // generations with the kernel weights InterpolateDynamicItems computed + // (the SAME 8-tap boxcar / pair-lerp the body bones and garment world + // took this frame) into the per-frame ropa upload ring and draw from + // it; the stepped cloth shape against the interpolated body was the + // tee jelly/clip-through, and a filter-MISMATCHED blend (plain lerp vs + // boxcar body) kept a period-scaled residue of it. Positions/normals + // (floats 0..6) blend; packed attributes (blend words, uvs: floats + // 7..13) copy from the newest generation present. Generations missing + // from the ring (evicted / decode in flight) renormalize over what IS + // present when at least half the kernel's weight survives. + VbBinding item_vbv = buffers.vb_view; + if (item.ropa && item.shape_count > 0 && + REXCVAR_GET(skate3_native_render_scene_ropa_blend)) { + const std::vector* gv[DrawItem::kShapeGens] = {}; + float gw[DrawItem::kShapeGens] = {}; + int ng = 0; + float total = 0.0f; + uint64_t newest_seq = 0; + const std::vector* newest = nullptr; + // Generations must match the RESIDENT decode's extent exactly; the + // index buffer references that many vertices. After a re-stream/ + // outfit swap the ring briefly holds stale-size generations; binding + // one against the current IB reads past the bound VB and collapses + // triangles (a momentary partial-invisible blink). Stale sizes drop + // out here; if too much kernel weight is stale, the raw resident VB + // draws instead (always self-consistent). + const size_t want_floats = + size_t(buffers.vb_view.size_bytes) / sizeof(float); + const auto rit = g_r.ropa_shapes.find(item.mesh); + if (rit != g_r.ropa_shapes.end() && !rit->second.empty()) { + // Sim-sleep gate: generations grossly older than the ring's newest + // were recorded before a cloth-sim sleep (continuous streams span + // ~114 ms end to end). Blending one against the fresh drape draws + // the garment where/how the character USED to stand, the + // detached/perpendicular class. Dropped generations shed their + // kernel weight; if too much weight is stale the raw resident VB + // draws instead (self-consistent current shape). + const double ring_newest_t = rit->second.back().t; + for (int k = 0; k < item.shape_count; ++k) { + for (const RendererState::RopaGen& g : rit->second) { + if (g.seq != item.shape_seq[k]) { + continue; + } + if (g.verts.size() != want_floats) { + break; // stale-size generation (re-stream/outfit swap) + } + if (ring_newest_t - g.t > 0.35) { + static std::atomic s_stale{0}; + const uint32_t ln = + s_stale.fetch_add(1, std::memory_order_relaxed); + if (ln < 16 || (ln & 1023u) == 0) { + REXLOG_INFO( + "native-scene: ropa shape STALE gen dropped mesh={:08X} " + "age={:.2f}s seq_gap={} (n={})", + item.mesh, ring_newest_t - g.t, + rit->second.back().seq - g.seq, ln); + } + break; + } + gv[ng] = &g.verts; + gw[ng] = item.shape_w[k]; + total += item.shape_w[k]; + ++ng; + if (g.seq >= newest_seq) { + newest_seq = g.seq; + newest = &g.verts; + } + break; + } + } + } + const uint32_t region = + uint32_t(frame_number % RendererState::kBoneRegions) * + RendererState::kRopaRegionSize; + const uint32_t bytes = uint32_t(want_floats * sizeof(float)); + if (ng > 0 && total >= 0.5f && newest != nullptr && + buffers.vb_view.stride == 56 && + g_r.ropa_ring_offset + bytes <= RendererState::kRopaRegionSize) { + float* dst = reinterpret_cast(g_r.ropa_ring_cpu + region + + g_r.ropa_ring_offset); + const float inv = 1.0f / total; + for (size_t v = 0; v + 14 <= want_floats; v += 14) { + float blend7[7] = {}; + for (int k = 0; k < ng; ++k) { + const float* src = gv[k]->data() + v; + const float wk = gw[k] * inv; + for (int f = 0; f < 7; ++f) { + blend7[f] += src[f] * wk; + } + } + // One store per float: dst is write-combined upload memory. + std::memcpy(dst + v, blend7, sizeof(blend7)); + std::memcpy(dst + v + 7, newest->data() + v + 7, 7 * sizeof(float)); + } + item_vbv.buffer = g_r.ropa_ring; + item_vbv.offset = region + g_r.ropa_ring_offset; + item_vbv.size_bytes = bytes; + item_vbv.stride = buffers.vb_view.stride; + g_r.ropa_ring_offset += (bytes + 255u) & ~255u; + g_ropa_blend_drawn.fetch_add(1, std::memory_order_relaxed); + } else { + g_ropa_blend_miss.fetch_add(1, std::memory_order_relaxed); + } + } + cmd->SetVertexBuffer(item_vbv.buffer, item_vbv.offset, item_vbv.size_bytes, + item_vbv.stride); + cmd->SetIndexBuffer(buffers.ib_view.buffer, buffers.ib_view.offset, + buffers.ib_view.size_bytes); + for (const DrawEntry& draw : item.draws) { + if (draw.prim == 4) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + } else if (draw.prim == 6) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); + } else { + continue; + } + cmd->DrawIndexed(draw.index_count, draw.start_index, draw.base_vertex); + ++drawn; + if (ring_frame != nullptr) { + const auto rit = ring_map.find(&item); + if (rit != ring_map.end()) { + ++ring_frame->items[rit->second].drawn; + } + } + } + }; + + // Opaque items first; environment.transparent items are deferred to an + // alpha-blended sub-pass (depth test on, z-write off) drawn back-to-front; + // interleaved opaque rendering alpha-tested the mist sheets into solid + // white cloud blobs. + const auto items_t0 = PerfClock::now(); + const auto view_dist2 = [&](const DrawItem& item) { + float c[3], w[3]; + for (int a = 0; a < 3; ++a) { + c[a] = (item.bbox_min[a] + item.bbox_max[a]) * 0.5f; + } + for (int a = 0; a < 3; ++a) { + w[a] = c[0] * item.world[0 * 4 + a] + c[1] * item.world[1 * 4 + a] + + c[2] * item.world[2 * 4 + a] + item.world[3 * 4 + a]; + } + float d2 = 0.0f; + for (int a = 0; a < 3; ++a) { + const float d = w[a] - scene.cam_pos[a]; + d2 += d * d; + } + return d2; + }; + std::vector transparent_items; + // Mid-fade entity items (see CharFadeAlpha / pso_fade): blended but with + // z-write ON, drawn at the HEAD of the blended sub-pass; they behave like + // main-pass objects whose glass/hair still composites over them, and depth + // writes stop their own overlapping pieces (skin under clothes, far-side + // doors/wheels through the body shell) from double-blending into an x-ray. + const auto char_fade_zwrite = [&](const DrawItem& it) { + return debug_mode == 0 && it.char_rows[14 * 4 + 1] > 0.0f && + (it.char_family == 1 || it.char_family == 2 || + it.char_family == 3 || it.char_family == 6) && + REXCVAR_GET(skate3_native_render_scene_entity_fade) && + CharFadeAlpha(it) < 0.999f; + }; + // Opaque items draw front-to-back (bbox-center distance): early-z rejects + // occluded pixels before the heavy material PS runs. The game's sort-list + // order is by render state, not depth; depth-write LESS_EQUAL makes the + // final image order-independent, so reordering is safe. + std::vector> opaque_items; + opaque_items.reserve(scene.items.size()); + for (const DrawItem& item : scene.items) { + const uint32_t index = item_index++; + if (debug_mode == 1) { + break; + } + if (debug_mode == 3 && index >= 20) { + break; + } + if (ring_frame != nullptr) { + SceneRingItem ri{}; + ri.ctx = item.ctx; + ri.mesh = item.mesh; + ri.diffuse = item.diffuse_tex; + ri.lightmap = item.lightmap_tex; + ri.vb_obj = item.vb_obj; + ri.spec = item.spec_tex; + ri.macro = item.macro_tex; + ri.decal_art = item.decal_art; + ri.wnormal = item.water_normal; + uint32_t idx_total = 0; + for (const DrawEntry& e : item.draws) { + idx_total += e.index_count; + } + ri.indices = idx_total; + ri.env_family = item.env_family; + ri.char_family = item.char_family; + ri.flags = uint8_t((item.transparent ? 1u : 0u) | (item.water ? 2u : 0u) | + (item.skinned ? 4u : 0u) | (item.retained ? 8u : 0u) | + (item.pending ? 16u : 0u) | + (item.caster_bank ? 32u : 0u) | + (item.decal ? 64u : 0u)); + ring_map.emplace(&item, uint32_t(ring_frame->items.size())); + ring_frame->items.push_back(ri); + } + // Hair with a validated lighting capture joins the sorted alpha + // sub-pass (strand coverage blend, depth test on / z-write off, the + // game's own hair render state); without the capture it stays on the + // legacy opaque path. Vehicle glass (fam 7) blends there too, + // reflection-only windows at the captured alpha; vehicle bodies (fam 6) + // stay opaque. + const bool char_capture_ok = item.char_rows[14 * 4 + 1] > 0.0f; + const bool hair_blend = item.char_family >= 4 && item.char_family <= 5 && + char_capture_ok; + const bool glass_blend = item.char_family == 7 && char_capture_ok; + // character.alpha accessory pieces (sunglass lenses): translucent, alpha + // from the coverage texture at uv2 (ch_misc.z set by the capture); the + // game draws them after every opaque character piece (see + // DrawItem::char_alpha). Gated on the SAME capture validation as hair: + // without validated rows ch_misc.z is 0 and the item stays opaque. + const bool cac_alpha_blend = item.char_alpha && char_capture_ok && + item.char_rows[14 * 4 + 2] > 0.0f; + // Flicker probes: a hair/character-alpha piece whose pass route flips + // between consecutive frames, or whose bone palette returns bit-exact + // to the previous-but-one frame's value (A,B,A,B: genuine motion + // never round-trips in two rendered frames), is the visible hair + // flicker. + if (item.char_family >= 4 || item.char_alpha) { + struct RouteProbe { + uint8_t route = 0; + uint64_t bones_h1 = 0; // last frame's palette hash + uint64_t bones_h2 = 0; // the frame before + }; + static std::unordered_map s_probe; // render thread + const uint64_t rk = (uint64_t(item.mesh) << 32) | item.ctx; + const uint8_t route = uint8_t((hair_blend || cac_alpha_blend) ? 2 : 1); + uint64_t bones_h = 1469598103934665603ull; + if (!item.bones.empty()) { + const uint8_t* bb = reinterpret_cast(item.bones.data()); + const size_t bn = item.bones.size() * sizeof(float); + for (size_t bi = 0; bi < bn; ++bi) { + bones_h = (bones_h ^ bb[bi]) * 1099511628211ull; + } + } + auto [rit2, fresh2] = s_probe.try_emplace(rk); + RouteProbe& pr = rit2->second; + if (!fresh2 && pr.route != route) { + g_hair_route_flips.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_flip_logged{0}; + const uint32_t fl = s_flip_logged.fetch_add(1, std::memory_order_relaxed); + if (fl < 24 || (fl & 511u) == 0) { + REXLOG_INFO( + "native-scene: hair ROUTE FLIP mesh={:08X} ctx={:08X} fam={} " + "alpha={} -> route {} rows14=({:.3f},{:.3f},{:.3f}) (n={})", + item.mesh, item.ctx, item.char_family, item.char_alpha ? 1 : 0, + route, item.char_rows[14 * 4 + 0], item.char_rows[14 * 4 + 1], + item.char_rows[14 * 4 + 2], fl); + } + } + if (!fresh2 && !item.bones.empty() && bones_h != pr.bones_h1 && + bones_h == pr.bones_h2) { + g_hair_bone_alternations.fetch_add(1, std::memory_order_relaxed); + static std::atomic s_bone_alt_logged{0}; + const uint32_t bl = s_bone_alt_logged.fetch_add(1, std::memory_order_relaxed); + if (bl < 24 || (bl & 1023u) == 0) { + REXLOG_INFO( + "native-scene: hair BONES ALTERNATION mesh={:08X} ctx={:08X} " + "fam={} bones={} src={} caster_bank={} (n={})", + item.mesh, item.ctx, item.char_family, item.bones.size() / 12, + item.dbg_src, item.caster_bank ? 1 : 0, bl); + } + } + pr.route = route; + pr.bones_h2 = pr.bones_h1; + pr.bones_h1 = bones_h; + if (s_probe.size() > 4096) { + s_probe.clear(); + } + } + // Per-entity spawn/distance fade (CharFadeAlpha): the game submits + // LivingWorld entity draws at alpha 0 through the whole spawn settle + // (NPCs drop ~1 m to the ground before fading in) and ramps alpha with + // distance; skip invisible items entirely, blend mid-fade ones. + // LW-mapped items (lw_alpha >= 0) do not need a validated lighting + // capture to honor the fade: the entity alpha is authoritative even + // when the capture chain failed; a spawn-settling NPC is invisible + // regardless of whether its rows validated this frame. + const bool entity_fade = debug_mode == 0 && item.char_family != 0 && + (char_capture_ok || item.lw_alpha >= 0.0f) && + REXCVAR_GET(skate3_native_render_scene_entity_fade); + const float fade_a = entity_fade ? CharFadeAlpha(item) : 1.0f; + // One-frame fade-blink guard: a mesh that rendered ~opaque last frame + // cannot legitimately sit at alpha 0 this frame; the game's spawn/ + // distance fades ramp over ~0.5 s. An opaque->0 step means a garbage/ + // foreign constant row served the alpha this capture (the clone-shared + // char-rows suspect; observed as part of the tee flickering invisible + // for a moment). Repair: draw the item OPAQUE this frame (skip the fade + // skip + the mid-fade blend routing) and log it. The last-alpha map + // updates from the RAW value, so a persisting alpha 0 (real despawn / + // spawn settle) only gets one repaired frame and then skips normally. + // LW-mapped items bypass the blink repair entirely (read AND write): + // the entity alpha cannot blink; the repair exists for garbage/foreign + // CAPTURED rows, and its mesh key is clone-shared, which force-drew + // spawn-settling clones OPAQUE every frame their twin was visible (the + // "NPC drops out of the sky with no fade" sighting). + bool fade_blink = false; + if (item.char_family != 0 && debug_mode == 0 && item.lw_alpha < 0.0f) { + static std::unordered_map s_fade_opaque; // render thread + uint8_t& was_opaque = s_fade_opaque[item.mesh]; + if (entity_fade && fade_a <= 0.004f && was_opaque) { + fade_blink = true; + static std::atomic s_blinks{0}; + const uint64_t n = s_blinks.fetch_add(1, std::memory_order_relaxed); + if (n < 16 || (n & 255u) == 0) { + REXLOG_INFO( + "native-scene: fade BLINK repaired mesh={:08X} fam={} ropa={} " + "src={} rows13=({:.3f},{:.3f},{:.3f},{:.3f}) " + "rows14=({:.3f},{:.3f}) (n={})", + item.mesh, item.char_family, item.ropa ? 1 : 0, item.dbg_src, + item.char_rows[13 * 4 + 0], item.char_rows[13 * 4 + 1], + item.char_rows[13 * 4 + 2], item.char_rows[13 * 4 + 3], + item.char_rows[14 * 4 + 0], item.char_rows[14 * 4 + 1], n); + } + } + was_opaque = (!entity_fade || fade_a > 0.9f) ? 1 : 0; + if (s_fade_opaque.size() > 1024) { + s_fade_opaque.clear(); + } + } + if (entity_fade && fade_a <= 0.004f && !fade_blink) { + if (item.lw_alpha >= 0.0f) { + g_lw_fade0.fetch_add(1, std::memory_order_relaxed); + } + continue; + } + const bool char_fade_blend = char_fade_zwrite(item) && !fade_blink; + // environment.reflective_trans (fam 13): blended glass canopies, joins + // the sorted alpha sub-pass whenever its exact branch is live (same + // shadow_valid gate as cam_pos.w = -fam; the legacy fallback renders it + // opaque exactly as before classification). + const bool refl_trans_blend = + item.env_family == 13 && scene.shadow_valid; + const auto stamp_route = [&](uint8_t route) { + if (ring_frame != nullptr) { + const auto rit = ring_map.find(&item); + if (rit != ring_map.end()) { + ring_frame->items[rit->second].route = route; + } + } + }; + if ((item.transparent || item.water || hair_blend || glass_blend || + cac_alpha_blend || refl_trans_blend || char_fade_blend) && + debug_mode == 0) { + if (REXCVAR_GET(skate3_native_render_scene_transparents)) { + transparent_items.push_back(&item); + stamp_route(2); + } + continue; + } + opaque_items.emplace_back(view_dist2(item), &item); + stamp_route(1); + } + if (REXCVAR_GET(skate3_native_render_scene_sort_opaque) && debug_mode == 0) { + std::stable_sort(opaque_items.begin(), opaque_items.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + } + for (const auto& [dist, item] : opaque_items) { + draw_item(*item); + } + if (!transparent_items.empty() && g_r.pso_transparent != nullptr) { + std::stable_sort(transparent_items.begin(), transparent_items.end(), + [&](const DrawItem* a, const DrawItem* b) { + // Fading entities (z-write blend) draw before every + // z-write-off blend so hair/glass composite over them. + const bool fa = char_fade_zwrite(*a); + const bool fb = char_fade_zwrite(*b); + if (fa != fb) { + return fa; + } + return view_dist2(*a) > view_dist2(*b); + }); + cmd->SetPipeline(use_depth ? g_r.pso_transparent : g_r.pso_nodepth); + nrhi::Pipeline* blend_bound = use_depth ? g_r.pso_transparent : g_r.pso_nodepth; + for (const DrawItem* item : transparent_items) { + // Mid-fade entity pieces: alpha blend with z-write ON (see pso_fade). + if (use_depth && g_r.pso_fade != nullptr && char_fade_zwrite(*item)) { + if (blend_bound != g_r.pso_fade) { + cmd->SetPipeline(g_r.pso_fade); + blend_bound = g_r.pso_fade; + } + draw_item(*item); + continue; + } + const bool hair = item->char_family >= 4 && item->char_family <= 5 && + item->char_rows[14 * 4 + 1] > 0.0f; + if (hair && use_depth && g_r.pso_hair_a != nullptr && g_r.pso_hair_b != nullptr) { + // The game's two hair passes: cull BACK then cull FRONT with the + // same shader: keeps far-side strands from compositing over + // near-side ones (one uncull(ed) pass reads as crunchy noise). + cmd->SetPipeline(g_r.pso_hair_a); + draw_item(*item); + cmd->SetPipeline(g_r.pso_hair_b); + draw_item(*item); + cmd->SetPipeline(blend_bound); + continue; + } + // reflective_trans glass culls like the game (its material family's + // XML: CULLMODE=FRONT): the canopy panels are double-glazed pane + // PAIRS a few cm apart; uncull(ed) we composited BOTH panes (an + // extra a^2 blend layer the emulated frame doesn't have). + // pso_hair_b IS the transparent state with CULL_FRONT. + if (item->env_family == 13 && use_depth && g_r.pso_hair_b != nullptr && + REXCVAR_GET(skate3_native_render_scene_backface_cull)) { + const float* w = item->world; + const float det3 = w[0] * (w[5] * w[10] - w[6] * w[9]) - + w[1] * (w[4] * w[10] - w[6] * w[8]) + + w[2] * (w[4] * w[9] - w[5] * w[8]); + nrhi::Pipeline* want = + det3 >= 0.0f ? g_r.pso_hair_b + : (use_depth ? g_r.pso_transparent : g_r.pso_nodepth); + if (want != blend_bound) { + cmd->SetPipeline(want); + blend_bound = want; + } + draw_item(*item); + continue; + } + if (blend_bound != (use_depth ? g_r.pso_transparent : g_r.pso_nodepth)) { + blend_bound = use_depth ? g_r.pso_transparent : g_r.pso_nodepth; + cmd->SetPipeline(blend_bound); + } + draw_item(*item); + } + } + g_pw_items.Add(perf_ns_since(items_t0)); + g_pw_pre.Add(uint64_t(std::chrono::duration_cast( + items_t0 - render_t0) + .count())); + const auto mid_t0 = PerfClock::now(); + + // Guest-texture resolver shared by the spline pass (pre-resolve, in the + // scene pass) and the HUD pass (post-resolve); both allocate strip/quad + // vertices from the same per-frame ui_ring region. + // Per-frame inline budget for HOT content re-decodes (actively-animating + // elements + video planes): inline keeps their content latency at ONE + // frame; overflow degrades to the async heal for the rest of the frame. + int64_t hot_inline_budget_ns = 4'000'000; + // force_inline: the dormant FMV bracket-fallback resolve stays on the + // inline decode (fires at most once per plane set). + // video: the FMV triple resolves; content changes always decode inline + // (frame-exact playback), and the caller additionally gates serving on + // last_change_frame >= the movie session start (see the triple loop). + const auto resolve_2d_texture = [&](const uint32_t fetch[6], + bool force_inline = false, + bool video = false) -> const GuestTexture* { + if ((fetch[0] & 0x3u) != 2 || fetch[1] == 0) { + return &g_r.white; + } + const uint64_t key = FetchWordsKey(fetch); + // Routing (see the 2d_async_px cvar): INLINE-FIRST under the per-frame + // budget; the run-copy untiler + in-place hot updates made decodes + // cheap (1280x720 sub-ms; no CreateCommittedResource churn on content + // changes), so first sightings AND content changes decode inline for + // 1-frame content latency. The async workers are the burst-overflow + // valve only: they exist because inline decodes stall the frame AND the + // guest (swap blocks), which mattered when a screen change brought + // 100+ ms of per-pixel decode work, but routinely + // skipping first-sight quads made every APT re-raster (new address = + // new key) blink its element for 1-3 frames (the menu flicker). + const int32_t async_px = REXCVAR_GET(skate3_native_render_scene_2d_async_px); + const uint32_t px_w = (fetch[2] & 0x1FFFu) + 1; + const uint32_t px_h = ((fetch[2] >> 13) & 0x1FFFu) + 1; + const bool async_ui = !force_inline && async_px > 0 && + uint64_t(px_w) * px_h > uint64_t(async_px); + // PLAIN store lookup on purpose: find_words_texture is the 3D poster + // revalidator; it probes, RE-ARMS recheck_frame and enqueues its own + // ui=false heal, which made this resolver's liveness block dead code + // (recheck always freshly re-armed before it ran; astale=0 across whole + // sessions) and put every 2D content update on that 4-5 + // frame async round trip. + auto it = g_r.tex_store.find(key); + bool inline_redecode = false; + if (it != g_r.tex_store.end()) { + it->second.last_used_frame = frame_number; + } + if (it != g_r.tex_store.end() && it->second.valid && !it->second.incomplete && + frame_number >= it->second.recheck_frame && + REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) { + // Content liveness for CPU-rewritten UI art: video frames and APT + // re-rasterized tiles rewrite the same payload with the fetch words + // unchanged; without the probe the words-keyed cache would freeze + // them on their first decoded content. + const uint64_t fp = SampleProbeFingerprint(base, it->second); + if (fp != 0 && fp != it->second.payload_fp) { + // Content changes decode INLINE while the per-frame budget lasts + // (1-frame latency for animating UI); fullscreen-class art gets an + // extended budget; serving it stale/blank is a whole-screen + // artifact, and the run-copy untiler made even 1280x720 cheap. + const bool fullscreen_class = uint64_t(px_w) * px_h >= 460'000; + inline_redecode = + video || !async_ui || hot_inline_budget_ns > 0 || + (fullscreen_class && hot_inline_budget_ns > -4'000'000); + if (inline_redecode) { + // Same layout = update the committed texture in place (no + // CreateCommittedResource churn, SRV slot kept, tear-guarded). + const auto up_t0 = PerfClock::now(); + if (UpdateGuestTexture2DInPlace(context, base, it->second)) { + const uint64_t up_ns = perf_ns_since(up_t0); + hot_inline_budget_ns -= int64_t(up_ns); + g_pw_tex_decode.Add(up_ns); + it->second.last_change_frame = frame_number; + return &it->second; + } + RetireGuestTexture(it->second, context.device->CurrentSubmission()); + g_r.tex_store.erase(it); + it = g_r.tex_store.end(); // falls into the inline decode below + } else { + // Budget exhausted (burst overflow): heal on the workers, serve + // the stale decode meanwhile. + EnqueueWordsMiss(key, fetch, /*ui=*/true); + it->second.recheck_frame = frame_number + 2; + g_2d_async_stale.fetch_add(1, std::memory_order_relaxed); + } + } else { + // Menu screens probe on a 2-frame cadence: the skater-portrait + // boxes are resolved IN PLACE (words unchanged) up to hundreds of + // ms after the quad first draws (asset streaming first), and a + // 16-frame recheck left the pre-resolve memory content, the + // loading-poster flash, on screen for its full window. + it->second.recheck_frame = + frame_number + (g_in_menus_frame.load(std::memory_order_relaxed) ? 2 : 16); + } + } + if (it == g_r.tex_store.end()) { + // First sightings also decode INLINE while the budget lasts. The old + // always-async policy skipped the quad for the 1-3 worker-round-trip + // frames, but animating APT art re-rasterizes at NEW addresses, so + // every animation step was a "first sighting" and menu elements + // (including fullscreen backdrops) BLINKED through every animation + // (askip +150/window during navigation, visible + // menu flicker/brightness pulsing). Async remains the burst-overflow + // valve only. + const bool fullscreen_class = uint64_t(px_w) * px_h >= 460'000; + const bool inline_ok = + hot_inline_budget_ns > 0 || + (fullscreen_class && hot_inline_budget_ns > -4'000'000); + if (async_ui && !video && !inline_redecode && !inline_ok) { + EnqueueWordsMiss(key, fetch, /*ui=*/true); + g_2d_async_skip.fetch_add(1, std::memory_order_relaxed); + return nullptr; + } + // Small HUD/spline art decodes inline (sub-ms; async would pop + // HUD elements for no gain). + const auto hud_t0 = PerfClock::now(); + GuestTexture gt; + EnsureGuestTextureFromWords(context, base, fetch, gt); + const uint64_t decode_ns = perf_ns_since(hud_t0); + hot_inline_budget_ns -= int64_t(decode_ns); + g_pw_tex_decode.Add(decode_ns); + // Attribution for residual render-thread stalls: anything still + // decoding inline for >3 ms should either move over the async + // threshold or explain itself here. + if (decode_ns > 3'000'000) { + static std::atomic s_slow{0}; + const uint32_t n = s_slow.fetch_add(1, std::memory_order_relaxed); + if (n < 24 || (n & 255u) == 0) { + REXLOG_INFO( + "native-scene: SLOW inline 2D decode {:.1f}ms (copy {:.1f} " + "create {:.1f} gen {:.1f}) {}x{} fetch=[{:08X} {:08X} {:08X}] " + "forced={} (n={})", + double(decode_ns) / 1e6, double(g_tex_dec_copy_ns) / 1e6, + double(g_tex_dec_create_ns) / 1e6, double(g_tex_dec_gen_ns) / 1e6, + px_w, px_h, fetch[0], fetch[1], fetch[2], force_inline ? 1 : 0, n); + } + } + if (!gt.valid) { + static std::unordered_set logged; + if (logged.size() < 32 && logged.insert(key).second) { + REXLOG_INFO( + "native-scene: 2D texture decode FAILED fetch=[{:08X} {:08X} {:08X} " + "{:08X} {:08X} {:08X}]", + fetch[0], fetch[1], fetch[2], fetch[3], fetch[4], fetch[5]); + } + } + cmd->FlushBarriers(); + gt.last_used_frame = frame_number; + gt.last_change_frame = frame_number; + it = g_r.tex_store.emplace(key, gt).first; + } + return it->second.valid ? &it->second : &g_r.white; + }; + const uint32_t ui_region = + uint32_t(frame_number % RendererState::kUiRegions) * RendererState::kUiRegionSize; + uint32_t ui_offset = 0; + + // In-world neon splines (waypoint arrows / marker beams): replayed inside + // the scene pass, depth-tested against the world like the emulated frame, + // in submission order (darken backdrop passes precede the additive glow). + uint32_t drawn_spline = 0; + if (REXCVAR_GET(skate3_native_render_scene_splines) && + g_r.pso_spline_default != nullptr && g_r.ui_ring_cpu != nullptr) { + std::vector scene_spline; + { + std::lock_guard lock(g_2d_mutex); + scene_spline = g_scene_spline; + } + for (const SplineDraw& s : scene_spline) { + const uint32_t bytes = uint32_t(s.verts.size()); + if (bytes == 0 || ui_offset + bytes > RendererState::kUiRegionSize) { + continue; + } + std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset, s.verts.data(), bytes); + const GuestTexture* spline_tex = resolve_2d_texture(s.fetch); + if (spline_tex == nullptr) { + continue; // big-art decode in flight on the workers; skip a frame + } + cmd->SetPipeline(s.pass == 1 ? g_r.pso_spline_darken + : g_r.pso_spline_default); + // Root constants: the scene's (smoothed) view_proj rows; the verts + // are world-space, then i_intensity as staged (c149). + float spline_consts[20]; + std::memcpy(spline_consts, scene.view_proj, sizeof(float) * 16); + std::memcpy(spline_consts + 16, s.consts + 149 * 4, sizeof(float) * 4); + cmd->SetRootConstants(0, 20, spline_consts, 0); + cmd->SetTexture(1, spline_tex->srv); + cmd->SetVertexBuffer(g_r.ui_ring, ui_region + ui_offset, bytes, 28); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); + cmd->Draw(s.count, 0); + ui_offset += bytes; + ++drawn_spline; + } + } + + const bool outline_ready = + RenderOutlineMask(context, scene, viewport, scissor, msaa_on, scene_color, + g_r.depth, use_depth); + + // ---- SSR reflection G-buffer (scene.hlsl ps_refl_gbuf) ---- + // Re-render the frame's reflective items (captured with their main-pass + // constants/textures by draw_item) into the half-res G-buffer while the + // main binding layout is still live. The march/composite consume it after + // the resolve + AO passes (ApplySsrPass); it hands off in + // PIXEL_SHADER_RESOURCE state via ssr_gbuf_ready. + g_r.ssr_gbuf_ready = false; + if (!ssr_items.empty() && EnsureSsrPipeline(context) && + EnsureSsrTargets(context)) { + // Front-to-back for early-z; the pass's own depth buffer resolves + // overlap (a curved facade's far side shares texels with its near side + // within one mesh; no draw order can fix that). + std::stable_sort(ssr_items.begin(), ssr_items.end(), + [&](const SsrGbufItem& a, const SsrGbufItem& b) { + return view_dist2(*a.item) < view_dist2(*b.item); + }); + const float gbuf_clear[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + cmd->ClearRenderTarget(g_r.ssr_gbuf, gbuf_clear); + cmd->ClearDepth(g_r.ssr_gbuf_depth, 1.0f); + cmd->SetRenderTargets(g_r.ssr_gbuf, g_r.ssr_gbuf_depth); + cmd->SetViewport(nrhi::Viewport{0.0f, 0.0f, float(g_r.ssr_width), + float(g_r.ssr_height), 0.0f, 1.0f}); + cmd->SetScissor( + nrhi::Rect{0, 0, int32_t(g_r.ssr_width), int32_t(g_r.ssr_height)}); + cmd->SetPipeline(g_r.pso_ssr_gbuf); + uint32_t gbuf_draws = 0; + for (const SsrGbufItem& si : ssr_items) { + auto mit = g_r.meshes.find(si.mesh); + if (mit == g_r.meshes.end() || + mit->second.fingerprint != si.fingerprint) { + continue; // decode swapped since the main-pass draw; next frame + } + cmd->SetRootConstants(0, 52, si.constants, 0); + cmd->SetTexture(4, si.t3); + cmd->SetTexturePair(5, si.t4, si.t5); + cmd->SetVertexBuffer(mit->second.vb_view.buffer, + mit->second.vb_view.offset, + mit->second.vb_view.size_bytes, + mit->second.vb_view.stride); + cmd->SetIndexBuffer(mit->second.ib_view.buffer, + mit->second.ib_view.offset, + mit->second.ib_view.size_bytes); + for (const DrawEntry& draw : si.item->draws) { + if (draw.prim == 4) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + } else if (draw.prim == 6) { + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleStrip); + } else { + continue; + } + cmd->DrawIndexed(draw.index_count, draw.start_index, + draw.base_vertex); + ++gbuf_draws; + } + } + // Restore the pass state the resolve/post paths rely on. + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + if (!msaa_on) { + cmd->SetRenderTargets(scene_color, use_depth ? g_r.depth : nullptr); + } + if (gbuf_draws > 0) { + cmd->Barrier(g_r.ssr_gbuf, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + g_r.ssr_gbuf_ready = true; + } + } + + if (msaa_on) { + // Resolve: average the MSAA samples into the 1x scene plane (the float + // HDR plane, or the guest output on the classic path) with a fullscreen + // pass, then restore steady-state resource states. + cmd->Barrier(g_r.msaa_color, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + if (!hdr_on) { + cmd->Barrier(context.guest_output, nrhi::ResourceState::kGuestOutput, + nrhi::ResourceState::kRenderTarget); + } + cmd->FlushBarriers(); + cmd->SetRenderTargets(hdr_on ? g_r.hdr_resolved : context.guest_output, + nullptr); + cmd->SetPipeline(g_r.resolve_pso); + cmd->SetTexture(1, g_r.msaa_srv_slot); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + cmd->Draw(3, 0); + cmd->Barrier(g_r.msaa_color, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + } + + // Classic path: the outline composites straight onto the resolved guest + // output. Under HDR it runs after the tonemap below (a gamma-space + // screen overlay, not scene lighting). + if (outline_ready && !hdr_on) { + RenderOutlineComposite(context, scene, context.guest_output, viewport, + scissor); + } + + // ---- Screen-space ambient occlusion (ssao.hlsl: GTAO) ---- + // Multiplies the resolved scene by a horizon-based visibility term before + // any downstream consumer (bloom/tonemap under HDR, photo chain, 2D + // overlay, blur). Runs after the outline composite, which relies on the + // main layout latched from the scene pass. + bool post_ran = false; + bool ssao_ran = false; + if (use_depth && !loading_native && + REXCVAR_GET(skate3_native_render_scene_ssao) && + ApplySsaoPass(context, cmd, scene, viewport, scissor)) { + post_ran = true; + ssao_ran = true; + } + + // ---- Screen-space reflections (ssr.hlsl) ---- + // March + composite onto the pre-tonemap HDR plane, between the AO pass + // and the HDR post, so reflections tonemap and bloom exactly like + // directly-visible scenery. The reflection G-buffer was drawn inside the + // scene pass (ssr_gbuf_ready); the SSAO linear-depth plane is reused when + // AO ran this frame. + if (g_r.ssr_gbuf_ready && + ApplySsrPass(context, cmd, scene, viewport, scissor, ssao_ran)) { + post_ran = true; + } + + // ---- HDR post (hdr.hlsl: bloom pyramid + the extracted tonemap) ---- + // Bloom reads the AO-composited float scene; ps_tonemap applies the + // game's shared tone chain once into the guest output, which every later + // consumer (photo chain, 2D overlay, blur, grab) reads exactly as on the + // classic path. + if (hdr_on) { + ApplyHdrPost(context, cmd, viewport, scissor, loading_native); + post_ran = true; + } + + if (post_ran) { + // The AO/HDR chains switched binding layouts; restore the main-pass + // root bindings the later passes latch (same restore as the photo + // chain's). + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetBindingLayout(g_r.layout); + if (g_r.shadow_cb != nullptr) { + const uint32_t cb_offset = + uint32_t(frame_number % RendererState::kShadowCbRegions) * + RendererState::kShadowCbSlice; + cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset); + cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); + } + } + + if (outline_ready && hdr_on) { + RenderOutlineComposite(context, scene, context.guest_output, viewport, + scissor); + } + + // ---- Photo-editor postfx chain (photo_fx.hlsl: exact ucode ports) ---- + // While the photo-mission photo editor is up and every pass's live + // constants were captured this frame, apply the game's own chain over the + // resolved native frame: depth pack -> visualfx (grade/vignette/CoC) -> + // DOF downsample -> tap9dofMotionBlur -> tap9dof -> uber -> fisheye. + if (scene.photo_fx.valid && + REXCVAR_GET(skate3_native_render_scene_photo_native) && + EnsurePhotoFxPipeline(context)) { + const auto pfx_to_srv = [&](nrhi::Texture* r) { + cmd->Barrier(r, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + }; + const auto pfx_to_rt = [&](nrhi::Texture* r) { + cmd->Barrier(r, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + }; + const auto pfx_flush = [&] { cmd->FlushBarriers(); }; + // Output-sized targets (visualfx out, uber out, packed depth). + bool pfx_ok = true; + if (g_r.pfx_width != context.guest_output_width || + g_r.pfx_height != context.guest_output_height || g_r.pfx_full[0] == nullptr) { + nrhi::Texture** res[3] = {&g_r.pfx_full[0], &g_r.pfx_full[1], &g_r.pfx_depth}; + nrhi::TextureView** views[3] = {&g_r.pfx_srv[0], &g_r.pfx_srv[1], + &g_r.pfx_srv[5]}; + nrhi::TextureDesc desc; + desc.width = context.guest_output_width; + desc.height = context.guest_output_height; + desc.mip_levels = 1; + desc.format = nrhi::Format::kR8G8B8A8_UNORM; + desc.usage = nrhi::kTextureUsageRenderTarget; + desc.initial_state = nrhi::ResourceState::kRenderTarget; + for (int i = 0; i < 3 && pfx_ok; ++i) { + if (*views[i] != nullptr) { + g_r.device->DestroyDeferred(*views[i]); + *views[i] = nullptr; + } + if (*res[i] != nullptr) { + g_r.device->DestroyDeferred(*res[i]); + *res[i] = nullptr; + } + *res[i] = context.device->CreateTexture(desc); + if (*res[i] == nullptr) { + pfx_ok = false; + break; + } + nrhi::TextureViewDesc vd; + vd.mip_levels = 1; + *views[i] = context.device->CreateTextureView(*res[i], vd); + if (*views[i] == nullptr) { + pfx_ok = false; + break; + } + } + if (pfx_ok) { + g_r.pfx_width = context.guest_output_width; + g_r.pfx_height = context.guest_output_height; + } + } + if (pfx_ok) { + // Identity grade-LUT upload (once). + if (!g_r.pfx_lut_uploaded) { + cmd->CopyBufferToTexture(g_r.pfx_lut, 0, 0, g_r.pfx_lut_upload, 0, 256, + 32, 32, 32); + cmd->Barrier(g_r.pfx_lut, nrhi::ResourceState::kCopyDest, + nrhi::ResourceState::kPixelShaderResource); + g_r.pfx_lut_uploaded = true; + } + // Native depth SRV (re-pointed when the depth texture is rebuilt on + // output resize; D32 textures view as R32_FLOAT automatically). + { + static nrhi::Texture* s_pfx_depth_tex = nullptr; + if (g_r.pfx_srv[7] == nullptr || s_pfx_depth_tex != g_r.depth) { + if (g_r.pfx_srv[7] != nullptr) { + g_r.device->DestroyDeferred(g_r.pfx_srv[7]); + g_r.pfx_srv[7] = nullptr; + } + nrhi::TextureViewDesc sd; + if (g_r.msaa > 1) { + sd.dimension = nrhi::ViewDimension::k2DMS; + } else { + sd.dimension = nrhi::ViewDimension::k2D; + sd.mip_levels = 1; + } + g_r.pfx_srv[7] = context.device->CreateTextureView(g_r.depth, sd); + s_pfx_depth_tex = g_r.depth; + } + } + // (The guest-output view, the finished native frame = the chain's + // scene input, is g_r.output_srv_slot, owned/re-pointed by + // EnsurePipeline on output change.) + // The two static input textures (fetch words captured at the game's + // own uber/fisheye flushes): the 512x2 vignette gradient + the grain. + const GuestTexture* vig = + resolve_2d_texture(scene.photo_fx.vignette_fetch, /*force_inline=*/true); + const GuestTexture* grain = + resolve_2d_texture(scene.photo_fx.grain_fetch, /*force_inline=*/true); + nrhi::TextureView* const vig_slot = + (vig != nullptr && vig->valid) ? vig->srv : g_r.white.srv; + nrhi::TextureView* const grain_slot = + (grain != nullptr && grain->valid) ? grain->srv : g_r.white.srv; + + // Baked literal rows c250..c255 per pass (the shader asset footers the + // game loads via PM4 LOAD_ALU_CONSTANT to PS rows 252+; values read + // from capture). + static constexpr float kPfxLiterals[kPfxPassCount][6][4] = { + // visualfx + {{0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {-0.200000003f, 1.0f, 1.41412354f, 2.0f}, + {1.51991853e-05f, 0.99609381f, 0.00389099144f, 0.00392156886f}, + {0.200000003f, 0.300000012f, 0.5f, 1.0f}}, + // dof downsample + {{0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0.000868055562f, 0.00156250002f, 0.25f, 0.00100000005f}}, + // tap9dofMotionBlur + {{0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {1.0f, 1.5f, 9.99999975e-05f, 0.00392156886f}, + {1.51991853e-05f, 0.99609381f, 0.00389099144f, 0.0f}}, + // tap9dof + {{0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0.100000001f, 0, 0, 0}}, + // uber + {{0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {2.0f, 0.5f, -1.0f, 0.0f}, + {0.015625f, 0.984375f, 0.96875f, -0.96875f}, + {1.0f, 0, 0, 0}}, + // fisheye + {{0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {0, 0, 0, 0}, + {-1.0f, 0.5f, 0, 0}, + {-0.5f, -0.888888896f, 1.0f, 1.77777779f}}, + }; + const uint32_t cb_slot_base = uint32_t(frame_number % 4) * 10; + uint32_t cb_slot_next = 0; + const int32_t pfx_debug = + REXCVAR_GET(skate3_native_render_scene_photo_native_debug); + // Returns the byte OFFSET into g_r.pfx_cb for SetConstantBuffer(0, ...). + const auto fill_cb = [&](int pass) -> uint64_t { + const uint32_t slot = cb_slot_base + (cb_slot_next++); + uint8_t* dst = g_r.pfx_cb_ptr + size_t(slot) * 4096; + std::memset(dst, 0, 4096); + float* rows = reinterpret_cast(dst); + if (pass >= 0) { + std::memcpy(rows, scene.photo_fx.ps[pass], sizeof(scene.photo_fx.ps[pass])); + std::memcpy(rows + 240 * 4, scene.photo_fx.vs[pass], + sizeof(scene.photo_fx.vs[pass])); + std::memcpy(rows + 250 * 4, kPfxLiterals[pass], sizeof(kPfxLiterals[pass])); + // The game's quad VSs add half a DEST texel to the sampling uv + // (visualfx c3 = 0.5/1152,0.5/640; dof passes c3 = 0.5/576, + // 0.5/320; fisheye c2 = 0.5/1280,0.5/720), D3D9/Xenos raster + // compensation: their pixel centers interpolate uv = i/W, so + // +half lands on the intended (i+0.5)/W. Our D3D12 fullscreen + // triangle interpolates (i+0.5)/W ALREADY; keeping the captured + // offsets shifts each pass's sampling by half a dest texel + // (content drifts up-left ~1.5 half-res texels across the three + // half-res DOF passes = the observed "blur starts above the + // pole" mask misregistration). ZERO them + // natively. Guarded to the expected sub-pixel magnitudes so a + // mis-captured row is left alone. + { + float* half_px = + rows + (pass == kPfxFisheye ? 242 : 243) * 4; + if (std::fabs(half_px[0]) < 0.002f && std::fabs(half_px[1]) < 0.004f) { + half_px[0] = 0.0f; + half_px[1] = 0.0f; + } + } + } + rows[248 * 4 + 0] = float(context.guest_output_width); + rows[248 * 4 + 1] = float(context.guest_output_height); + rows[249 * 4 + 0] = float(pfx_debug); + return uint64_t(slot) * 4096; + }; + // One 8-entry texture table (pfx_layout param 1, registers t0..t7); + // the argument order keeps the old per-parameter helper's shape (t5 + // last), the gather re-orders into register order. + const auto pfx_bind_all = [&](nrhi::TextureView* t0, nrhi::TextureView* t1, + nrhi::TextureView* t2, nrhi::TextureView* t3, + nrhi::TextureView* t4, nrhi::TextureView* t6, + nrhi::TextureView* t7, nrhi::TextureView* t5) { + nrhi::TextureView* views[8] = {t0, t1, t2, t3, t4, t5, t6, t7}; + cmd->SetTextures(1, views, 8); + }; + nrhi::TextureView* const W = g_r.white.srv; + const nrhi::Viewport half_vp{0.0f, 0.0f, float(RendererState::kPfxHalfW), + float(RendererState::kPfxHalfH), 0.0f, 1.0f}; + const nrhi::Rect half_sc{0, 0, int32_t(RendererState::kPfxHalfW), + int32_t(RendererState::kPfxHalfH)}; + const nrhi::Viewport quarter_vp{0.0f, 0.0f, + float(RendererState::kPfxQuarterW), + float(RendererState::kPfxQuarterH), 0.0f, + 1.0f}; + const nrhi::Rect quarter_sc{0, 0, int32_t(RendererState::kPfxQuarterW), + int32_t(RendererState::kPfxQuarterH)}; + const auto set_rtv = [&](nrhi::Texture* target) { + cmd->SetRenderTargets(target, nullptr); + }; + cmd->SetBindingLayout(g_r.pfx_layout); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + const int32_t accum_mode = + REXCVAR_GET(skate3_native_render_scene_photo_native_accum); + // Live capture telemetry (~every 2 s while the chain runs): the rows + // that move with the editor sliders. Lets a session log confirm the + // draw-time capture tracks DoF (dof c0.x / dofmb c2.x, 0 = slider + // off), focus (visualfx c2.y), the grade matrix (visualfx c7) and the + // uber jitter phase against the emulated ground-truth values. + { + static uint64_t s_pfx_log_frame = 0; + if (frame_number - s_pfx_log_frame > 120) { + s_pfx_log_frame = frame_number; + const auto& fx = scene.photo_fx; + REXLOG_INFO( + "native-scene: pfx live rows: visualfx c2=({:.3f},{:.3f}) " + "c4.x={:.5f} c7=({:.4f},{:.4f},{:.4f},{:.4f}) | dofmb " + "c2.x={:.3f} | dof c0.x={:.3f} | uber c0.x={:.3f} " + "c2.x={:.4f} c5.x={:.3f} | vfx vs c3=({:.6f},{:.6f})", + fx.ps[kPfxVisualFx][2][0], fx.ps[kPfxVisualFx][2][1], + fx.ps[kPfxVisualFx][4][0], fx.ps[kPfxVisualFx][7][0], + fx.ps[kPfxVisualFx][7][1], fx.ps[kPfxVisualFx][7][2], + fx.ps[kPfxVisualFx][7][3], fx.ps[kPfxDofMB][2][0], + fx.ps[kPfxDof][0][0], fx.ps[kPfxUber][0][0], + fx.ps[kPfxUber][2][0], fx.ps[kPfxUber][5][0], + fx.vs[kPfxVisualFx][3][0], fx.vs[kPfxVisualFx][3][1]); + } + } + + // 1) Depth pack: native depth (sample 0) -> the console D24-as-8888 + // layout at output res. + cmd->Barrier(g_r.depth, nrhi::ResourceState::kDepthWrite, + nrhi::ResourceState::kPixelShaderResource); + pfx_flush(); + set_rtv(g_r.pfx_depth); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetPipeline(g_r.pfx_pso[0]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); + pfx_bind_all(W, W, W, W, g_r.pfx_srv[6], W, W, g_r.pfx_srv[7]); + cmd->Draw(3, 0); + cmd->Barrier(g_r.depth, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kDepthWrite); + pfx_to_srv(g_r.pfx_depth); + // Scene input -> SRV for the rest of the chain. + cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + pfx_flush(); + + // 2) Accumulation input (visualfx t3). Mode 0 = black, 1 = the scene + // downsampled (this frame, the editor scene is frozen). + if (accum_mode == 0) { + const float black[4] = {0, 0, 0, 0}; + cmd->ClearRenderTarget(g_r.pfx_quarter, black); + } else if (accum_mode == 1) { + set_rtv(g_r.pfx_quarter); + cmd->SetViewport(quarter_vp); + cmd->SetScissor(quarter_sc); + cmd->SetPipeline(g_r.pfx_pso[7]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); + pfx_bind_all(g_r.output_srv_slot, W, W, W, g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + } + pfx_to_srv(g_r.pfx_quarter); + pfx_flush(); + + // 3) visualfx (full res): scene + depth + accumulation -> pfx_full[0]. + set_rtv(g_r.pfx_full[0]); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetPipeline(g_r.pfx_pso[1]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxVisualFx)); + pfx_bind_all(g_r.output_srv_slot, g_r.pfx_srv[5], W, g_r.pfx_srv[4], + g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + pfx_to_srv(g_r.pfx_full[0]); + pfx_flush(); + + // 4) DOF downsample: pfx_full[0] -> pfx_half[0]. + set_rtv(g_r.pfx_half[0]); + cmd->SetViewport(half_vp); + cmd->SetScissor(half_sc); + cmd->SetPipeline(g_r.pfx_pso[2]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxDofDown)); + pfx_bind_all(g_r.pfx_srv[0], W, W, W, g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + pfx_to_srv(g_r.pfx_half[0]); + pfx_flush(); + + // 5) tap9dofMotionBlur: pfx_half[0] + depth -> pfx_half[1]. + set_rtv(g_r.pfx_half[1]); + cmd->SetPipeline(g_r.pfx_pso[3]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxDofMB)); + pfx_bind_all(g_r.pfx_srv[2], g_r.pfx_srv[5], W, W, g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + pfx_to_srv(g_r.pfx_half[1]); + pfx_to_rt(g_r.pfx_half[0]); + pfx_flush(); + + // 6) tap9dof: pfx_half[1] -> pfx_half[0]. + set_rtv(g_r.pfx_half[0]); + cmd->SetPipeline(g_r.pfx_pso[4]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxDof)); + pfx_bind_all(g_r.pfx_srv[3], W, W, W, g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + pfx_to_srv(g_r.pfx_half[0]); + pfx_flush(); + + // 7) uber (full res): graded sharp + depth + LUT + grain + blurred + // half -> pfx_full[1]. + set_rtv(g_r.pfx_full[1]); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetPipeline(g_r.pfx_pso[5]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxUber)); + pfx_bind_all(g_r.pfx_srv[0], g_r.pfx_srv[5], W, W, g_r.pfx_srv[6], + grain_slot, g_r.pfx_srv[2], W); + cmd->Draw(3, 0); + pfx_to_srv(g_r.pfx_full[1]); + // The finished chain replaces the output: back to RT for fisheye. + cmd->Barrier(context.guest_output, + nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + pfx_flush(); + + // 8) fisheye (to screen): lens warp + vignette gradient + tint. + cmd->SetRenderTargets(context.guest_output, nullptr); + cmd->SetPipeline(g_r.pfx_pso[6]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(kPfxFisheye)); + pfx_bind_all(g_r.pfx_srv[1], W, vig_slot, W, g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + + // 8b) Debug view (photo_native_debug): overwrite the output with the + // visualfx CoC map (mode 1) or the packed-depth reconstruction + // (mode 2): t0 = visualfx out, t1 = packed depth, both still in + // SRV state here. + if (pfx_debug > 0 && g_r.pfx_pso[8] != nullptr) { + cmd->SetPipeline(g_r.pfx_pso[8]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); + pfx_bind_all(g_r.pfx_srv[0], g_r.pfx_srv[5], W, W, g_r.pfx_srv[6], W, + W, W); + cmd->Draw(3, 0); + } + + // 9) Accumulation mode 2: next frame's visualfx t3 = the finished + // frame downsampled (pfx_full[1] is still in SRV state here). + if (accum_mode == 2) { + pfx_to_rt(g_r.pfx_quarter); + pfx_flush(); + set_rtv(g_r.pfx_quarter); + cmd->SetViewport(quarter_vp); + cmd->SetScissor(quarter_sc); + cmd->SetPipeline(g_r.pfx_pso[7]); + cmd->SetConstantBuffer(0, g_r.pfx_cb, fill_cb(-1)); + pfx_bind_all(g_r.pfx_srv[1], W, W, W, g_r.pfx_srv[6], W, W, W); + cmd->Draw(3, 0); + } + + // Restore steady states (all pfx color targets idle as RENDER_TARGET) + // + the main pass's root bindings for the 2D overlay. + pfx_to_rt(g_r.pfx_full[0]); + pfx_to_rt(g_r.pfx_full[1]); + pfx_to_rt(g_r.pfx_half[0]); + pfx_to_rt(g_r.pfx_half[1]); + if (accum_mode != 2) { + pfx_to_rt(g_r.pfx_quarter); + } + pfx_to_rt(g_r.pfx_depth); + pfx_flush(); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetBindingLayout(g_r.layout); + if (g_r.shadow_cb != nullptr) { + const uint32_t cb_offset = + uint32_t(frame_number % RendererState::kShadowCbRegions) * + RendererState::kShadowCbSlice; + cmd->SetConstantBuffer(6, g_r.shadow_cb, cb_offset); + cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); + } + static bool s_pfx_first = true; + if (s_pfx_first) { + s_pfx_first = false; + REXLOG_INFO( + "native-scene: photo editor postfx chain LIVE (native; accum mode {}, " + "vignette {}, grain {})", + accum_mode, vig_slot == W ? "WHITE-fallback" : "resolved", + grain_slot == W ? "WHITE-fallback" : "resolved"); + } + } + } + + // ---- Native photo grab (photo_grab_native) ---- + // Consume: CPU-tile the newest fence-completed readback into the guest + // screenshot target. Produce: downsample this frame's finished output + // (photo editor = the ported postfx chain result just drawn above; plain + // TakePhoto window = the plain frame, both the correct grab semantics) + // into blur_tex[0] via the blur chain's prefiltered downsample (its + // 1152x640 blur space IS the game's screenshot raster) and enqueue the + // copy into the free readback buffer. Runs BEFORE the 2D overlay so the + // photo never contains editor chrome / HUD, and before the blur block so + // this scratch use of blur_tex[0] is enqueued ahead of any blur rewrite. + if (g_photo_grab_native_armed.load(std::memory_order_relaxed) && + g_r.pso_blur_down != nullptr && g_r.blur_tex[0] != nullptr && + g_r.output_srv_allocated && !g_r.grab_failed) { + // Lazy readback-pair creation (persistently mapped; the copy is never + // waited on same-frame; the old path's per-resolve GPU drain was the + // editor's multi-second stall class). + if (g_r.grab_readback[0] == nullptr) { + nrhi::BufferDesc desc; + desc.size = + uint64_t(RendererState::kGrabRowPitch) * RendererState::kBlurHeight; + desc.heap = nrhi::HeapKind::kReadback; + for (int i = 0; i < 2 && !g_r.grab_failed; ++i) { + g_r.grab_readback[i] = g_r.device->CreateBuffer(desc); + if (g_r.grab_readback[i] == nullptr || + (g_r.grab_readback_ptr[i] = static_cast( + g_r.device->Map(g_r.grab_readback[i]))) == nullptr) { + g_r.grab_failed = true; + } + } + if (g_r.grab_failed) { + REXLOG_ERROR( + "native-scene: photo grab readback creation failed - native " + "grab disabled (watchdog will fall back)"); + } + } + if (!g_r.grab_failed) { + // Consume the newest completed buffer; retire older completed ones + // unwritten so stale content never lands after fresh content. + const uint64_t grab_completed = context.device->CompletedSubmission(); + int newest = -1; + for (int i = 0; i < 2; ++i) { + if (g_r.grab_pending[i] && g_r.grab_submission[i] < grab_completed && + (newest < 0 || + g_r.grab_submission[i] > g_r.grab_submission[newest])) { + newest = i; + } + } + if (newest >= 0) { + static bool s_grab_write_warned = false; + const auto tile_t0 = PerfClock::now(); + // Make the GPU's completed copy visible to the CPU read below (no-op + // on D3D12; cache invalidate on non-coherent Vulkan memory). + g_r.device->InvalidateForRead( + g_r.grab_readback[newest], 0, + uint64_t(RendererState::kGrabRowPitch) * RendererState::kBlurHeight); + const bool wrote = GrabTargetValid(base) && + WriteGrabToGuest(base, g_r.grab_readback_ptr[newest]); + if (wrote) { + g_r.grab_cpu_us += + uint64_t(std::chrono::duration_cast( + PerfClock::now() - tile_t0) + .count()); + ++g_r.grab_writes; + g_grab_last_write_ns.store( + std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(), + std::memory_order_relaxed); + if (g_r.grab_writes == 1) { + REXLOG_INFO( + "native-scene: photo grab - first native frame written to " + "guest 0x{:08X} via the 0xA0000000 view (tiled BE ARGB " + "1152x640)", + kGrabGuestAddr); + } + } else if (!s_grab_write_warned) { + s_grab_write_warned = true; + REXLOG_WARN( + "native-scene: photo grab - guest target validation/write " + "FAILED (dims chain mismatch or unmapped page); the window " + "watchdog will fall back to forced readbacks"); + } + for (int i = 0; i < 2; ++i) { + if (g_r.grab_pending[i] && g_r.grab_submission[i] < grab_completed) { + g_r.grab_pending[i] = false; + } + } + } + // Produce into the free buffer (skip the frame if both are still in + // flight, never wait). + const uint32_t w = g_r.grab_write_index; + if (!g_r.grab_pending[w]) { + // (The output view g_r.output_srv_slot tracks the current output + // texture; EnsurePipeline re-points it on output change.) + cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + cmd->FlushBarriers(); + cmd->SetRenderTargets(g_r.blur_tex[0], nullptr); + const nrhi::Viewport grab_vp{0.0f, + 0.0f, + float(RendererState::kBlurWidth), + float(RendererState::kBlurHeight), + 0.0f, + 1.0f}; + const nrhi::Rect grab_sc{0, 0, int32_t(RendererState::kBlurWidth), + int32_t(RendererState::kBlurHeight)}; + cmd->SetViewport(grab_vp); + cmd->SetScissor(grab_sc); + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + cmd->SetPipeline(g_r.pso_blur_down); + const float grab_consts[4] = {1.0f / float(context.guest_output_width), + 1.0f / float(context.guest_output_height), + 0.0f, 0.0f}; + cmd->SetRootConstants(0, 4, grab_consts, 0); + cmd->SetTexture(1, g_r.output_srv_slot); + cmd->Draw(3, 0); + // blur_tex[0] -> the readback buffer; restore steady states. + cmd->Barrier(g_r.blur_tex[0], nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kCopySource); + cmd->Barrier(context.guest_output, + nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + cmd->CopyTextureToBuffer(g_r.grab_readback[w], 0, + RendererState::kGrabRowPitch, g_r.blur_tex[0], + 0, RendererState::kBlurWidth, + RendererState::kBlurHeight); + cmd->Barrier(g_r.blur_tex[0], nrhi::ResourceState::kCopySource, + nrhi::ResourceState::kRenderTarget); + cmd->FlushBarriers(); + // Restore the frame's output binding for the passes that follow. + cmd->SetRenderTargets(context.guest_output, nullptr); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + g_r.grab_submission[w] = context.device->CurrentSubmission(); + g_r.grab_pending[w] = true; + g_r.grab_write_index = 1 - w; + } + } + } + + // Popup background blur: exact port of the game's blur_hBlur/vBlur + + // postfx_basictex chain (see kBlurShaderSource): H blur of the finished + // frame into a 1152x640 intermediate, V blur, then a fullscreen bilinear + // stretch back over the output. Runs only on frames where the game issued + // the blur draws (scene.ui_blur = captured kernel scale). The popup's own + // 2D draws follow after and stay sharp. + if (scene.ui_blur > 0.0f && g_r.pso_blur != nullptr && g_r.pso_blur_blit != nullptr && + g_r.pso_blur_down != nullptr && g_r.blur_tex[0] != nullptr) { + // Temporal smoothing of the captured radius + fade (~50 ms time + // constant, render thread): the live bank reads can land mid-rewrite + // (1-frame value flaps = the tinted-backdrop flicker), and smoothing + // FROM the off state (radius 0, white tint) supplies the pause-open + // animate-in even when the game's own ramp frames were missed by the + // capture gate. A >0.25 s gap since the last blur frame = a fresh ON + // edge (resets the state); at the render rate active frames are ~7 ms + // apart so the reset can never fire mid-popup. + static float s_blur_r = 0.0f; + static float s_blur_tint[3] = {1.0f, 1.0f, 1.0f}; + static int64_t s_blur_ns = 0; + const int64_t blur_now_ns = std::chrono::duration_cast( + PerfClock::now().time_since_epoch()) + .count(); + float blur_dt = s_blur_ns != 0 ? float(blur_now_ns - s_blur_ns) * 1e-9f : 1.0f; + if (blur_dt > 0.25f) { + s_blur_r = 0.0f; + s_blur_tint[0] = s_blur_tint[1] = s_blur_tint[2] = 1.0f; + blur_dt = 0.0f; + } + s_blur_ns = blur_now_ns; + const float blur_a = 1.0f - std::exp(-blur_dt / 0.05f); + s_blur_r += (scene.ui_blur - s_blur_r) * blur_a; + for (int a = 0; a < 3; ++a) { + s_blur_tint[a] += (scene.ui_blur_color[a] - s_blur_tint[a]) * blur_a; + } + // (The guest output texture can change between frames; the dedicated + // view g_r.output_srv_slot is re-pointed by EnsurePipeline on change.) + const nrhi::Viewport blur_vp{0.0f, 0.0f, float(RendererState::kBlurWidth), + float(RendererState::kBlurHeight), 0.0f, 1.0f}; + const nrhi::Rect blur_sc{0, 0, int32_t(RendererState::kBlurWidth), + int32_t(RendererState::kBlurHeight)}; + const auto to_srv = [&](nrhi::Texture* r) { + cmd->Barrier(r, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kPixelShaderResource); + }; + const auto to_rt = [&](nrhi::Texture* r) { + cmd->Barrier(r, nrhi::ResourceState::kPixelShaderResource, + nrhi::ResourceState::kRenderTarget); + }; + const auto flush = [&] { cmd->FlushBarriers(); }; + cmd->SetPrimitiveTopology(nrhi::PrimitiveTopology::kTriangleList); + // Downsample (prefiltered): output -> blur_tex[0], the game's 1152x640 + // blur space. The H/V passes then run 1:1 like the original chain. + to_srv(context.guest_output); + flush(); + cmd->SetRenderTargets(g_r.blur_tex[0], nullptr); + cmd->SetViewport(blur_vp); + cmd->SetScissor(blur_sc); + cmd->SetPipeline(g_r.pso_blur_down); + const float d_consts[4] = {1.0f / float(context.guest_output_width), + 1.0f / float(context.guest_output_height), 0.0f, 0.0f}; + cmd->SetRootConstants(0, 4, d_consts, 0); + cmd->SetTexture(1, g_r.output_srv_slot); + cmd->Draw(3, 0); + // H: blur_tex[0] -> blur_tex[1]. + to_srv(g_r.blur_tex[0]); + flush(); + cmd->SetRenderTargets(g_r.blur_tex[1], nullptr); + cmd->SetPipeline(g_r.pso_blur); + const float h_consts[8] = {1.0f, + 0.0f, + s_blur_r, + 0.0f, + s_blur_tint[0], + s_blur_tint[1], + s_blur_tint[2], + 1.0f}; + cmd->SetRootConstants(0, 8, h_consts, 0); + cmd->SetTexture(1, g_r.blur_srv[0]); + cmd->Draw(3, 0); + // V: blur_tex[1] -> blur_tex[0]. + to_srv(g_r.blur_tex[1]); + to_rt(g_r.blur_tex[0]); + flush(); + cmd->SetRenderTargets(g_r.blur_tex[0], nullptr); + const float v_consts[8] = {0.0f, + 1.0f, + s_blur_r, + 0.0f, + s_blur_tint[0], + s_blur_tint[1], + s_blur_tint[2], + 1.0f}; + cmd->SetRootConstants(0, 8, v_consts, 0); + cmd->SetTexture(1, g_r.blur_srv[1]); + cmd->Draw(3, 0); + // Replace: blur_tex[0] stretched over the full output (basictex). + to_srv(g_r.blur_tex[0]); + to_rt(context.guest_output); + flush(); + cmd->SetRenderTargets(context.guest_output, nullptr); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + cmd->SetPipeline(g_r.pso_blur_blit); + cmd->SetTexture(1, g_r.blur_srv[0]); + cmd->Draw(3, 0); + // Restore the intermediates' steady state for the next blur frame. + to_rt(g_r.blur_tex[0]); + to_rt(g_r.blur_tex[1]); + } + + // 2D overlay (HUD/APT): replay the frame's captured 2D draws over the + // resolved output, in submission order, with the game's own transform + // constants and textures. In gameplay these draws compose the game's + // full-screen HUD overlay texture at true screen coordinates; drawing + // them here IS the composite the (suppressed) emulated pass used to do. + const auto twod_t0 = PerfClock::now(); + uint32_t drawn_2d = 0; + if (REXCVAR_GET(skate3_native_render_scene_2d) && g_r.pso_2d != nullptr && + g_r.ui_ring_cpu != nullptr) { + std::vector scene_2d; + { + std::lock_guard lock(g_2d_mutex); + scene_2d = g_scene_2d; + } + if (!scene_2d.empty()) { + cmd->SetPipeline(g_r.pso_2d); + // One shared draw routine for both the RTT passes and the screen pass. + // ui_region/ui_offset continue after the spline pass's allocations. + // yuv != nullptr redirects this draw to the FMV combine: pso_yuv2d + // with the three movie plane views on params 1/2/5 (t0/t1/t4), the + // quad's own geometry/transform (so windowed movies place exactly). + const auto emit_draw = [&](const Draw2d& d, + nrhi::TextureView* const* yuv = nullptr) { + const uint32_t bytes = uint32_t(d.verts.size()); + if (bytes == 0 || d.stride != 28) { + return; + } + if (ui_offset + bytes > RendererState::kUiRegionSize) { + g_draws_2d_dropped.fetch_add(1, std::memory_order_relaxed); + return; + } + std::memcpy(g_r.ui_ring_cpu + ui_region + ui_offset, d.verts.data(), bytes); + nrhi::TextureView* srv_view; + if (yuv != nullptr) { + srv_view = yuv[0]; + } else { + const GuestTexture* t = resolve_2d_texture(d.fetch); + if (t == nullptr) { + // Big-art decode in flight on the workers (large-art async + // routing); skip the quad; it lands 1-3 frames later. + return; + } + srv_view = t->srv; + } + float constants[40]; + std::memcpy(constants, d.consts, sizeof(d.consts)); + // 2D ortho draws have no translation row in the projection (c3 == + // (0,0,0,1)); perspective view-proj rows do. Half-pixel applies to + // the former only. + const bool ortho = d.consts[12] == 0.0f && d.consts[13] == 0.0f && + d.consts[14] == 0.0f && d.consts[15] == 1.0f; + constants[36] = ortho ? 1.0f : 0.0f; + // m[9].y: sharp-magnification amount for APT cached-bitmap tiles + // (see the cvar; the shader gates on actual fetch magnification). + constants[37] = float(std::clamp( + REXCVAR_GET(skate3_native_render_scene_2d_sharp), 0.0, 2.0)); + // m[9].zw: unused. The D3D9 half-pixel shift is derived in the VS + // from the draw's own ortho scale (+0.5 GUEST pixel down-right, + // matching the emulated path's half_pixel_offset reversal); the + // old up-left OUTPUT-pixel nudge staged here left a see-through + // sliver along the bottom/right of edge-to-edge loading quads. + constants[38] = 0.0f; + constants[39] = 0.0f; + cmd->SetRootConstants(0, 40, constants, 0); + cmd->SetTexture(1, srv_view); + if (yuv != nullptr) { + cmd->SetPipeline(g_r.pso_yuv2d); + cmd->SetTexture(2, yuv[1]); + cmd->SetTexture(5, yuv[2]); + } + cmd->SetVertexBuffer(g_r.ui_ring, ui_region + ui_offset, bytes, + d.stride); + cmd->SetPrimitiveTopology(d.prim == 5 + ? nrhi::PrimitiveTopology::kTriangleStrip + : nrhi::PrimitiveTopology::kTriangleList); + cmd->Draw(d.count, 0); + if (yuv != nullptr) { + cmd->SetPipeline(g_r.pso_2d); + } + ui_offset += bytes; + ++drawn_2d; + }; + + cmd->SetRenderTargets(context.guest_output, nullptr); + cmd->SetViewport(viewport); + cmd->SetScissor(scissor); + // Native FMV substitution, self-contained: a video quad's console + // shader binds Y at the DRAW's fetch slot 0 and the U/V planes at + // slots 1/2: three valid distinct textures with the chroma at + // exactly half the luma dimensions is a video draw, regardless of + // which UI path drew it (the camera-page previews are plain APT + // elements; matching against the VideoRenderer-published planes was + // refuted twice; the UI paths sample APT-side plane COPIES, e.g. + // 0x1F6xxxxx vs the published 0xA59xxxxx). Resolved + // triples are cached per frame by the Y address, and their store + // entries are forced to a per-frame liveness probe (a 30 fps video + // rewriting its planes every few RENDERED frames would otherwise + // settle the probe to 16-frame sampling, the "sluggish video"). + const auto yuv_triple = [](const Draw2d& d) -> bool { + const uint32_t* s0 = d.fetch; + const uint32_t* s1 = d.fetch + 6; + const uint32_t* s2 = d.fetch + 12; + if ((s0[0] & 3u) != 2 || (s1[0] & 3u) != 2 || (s2[0] & 3u) != 2 || + s0[1] == 0 || s1[1] == 0 || s2[1] == 0 || s1[1] == s0[1] || + s2[1] == s0[1] || s1[1] == s2[1]) { + return false; + } + const uint32_t w0 = (s0[2] & 0x1FFFu) + 1, h0 = ((s0[2] >> 13) & 0x1FFFu) + 1; + const uint32_t w1 = (s1[2] & 0x1FFFu) + 1, h1 = ((s1[2] >> 13) & 0x1FFFu) + 1; + const uint32_t w2 = (s2[2] & 0x1FFFu) + 1, h2 = ((s2[2] >> 13) & 0x1FFFu) + 1; + const auto half = [](uint32_t full, uint32_t c) { + return c == full / 2 || c == (full + 1) / 2; + }; + return w0 >= 32 && h0 >= 32 && half(w0, w1) && half(h0, h1) && + w2 == w1 && h2 == h1; + }; + struct TripleCacheEntry { + uint32_t y_addr; + bool ok; + nrhi::TextureView* slots[3]; + }; + TripleCacheEntry triple_cache[kMaxMovies]; + int triple_count = 0; + // Backup: the freshest VideoRenderer-published plane set, for a + // bracketed movie quad without a readable triple (the boot intro + // rendered through this before the triple detection existed). + // Resolved lazily; the capped log tracks whether it is still ever + // needed; if it stays silent across sessions, this path and the + // OnMovieFrame publish machinery behind it can be retired. + nrhi::TextureView* fallback_slots[3] = {}; + int fallback_state = 0; // 0 = unresolved, 1 = ok, -1 = unavailable + const auto fallback_yuv = [&]() -> nrhi::TextureView* const* { + if (fallback_state == 0) { + fallback_state = -1; + const MoviePlanes* best = nullptr; + for (const MoviePlanes& m : movies) { + if (m.ns >= 0 && movie_now_ns - m.ns < 500'000'000 && + (best == nullptr || m.ns > best->ns)) { + best = &m; + } + } + if (best != nullptr) { + bool ok = true; + for (int p = 0; p < 3 && ok; ++p) { + auto hot = g_r.tex_store.find(FetchWordsKey(best->words[p])); + if (hot != g_r.tex_store.end()) { + hot->second.recheck_frame = 0; + } + const GuestTexture* t = + resolve_2d_texture(best->words[p], /*force_inline=*/true); + ok = t != &g_r.white && t->texture != nullptr; + fallback_slots[p] = t->srv; + } + if (ok) { + fallback_state = 1; + } + } + } + return fallback_state == 1 ? fallback_slots : nullptr; + }; + bool movie_drawn = false; + for (const Draw2d& d : scene_2d) { + nrhi::TextureView* const* yuv = nullptr; + // Video-quad detection runs regardless of movie_sub: a quad whose + // own fetch slots form a YUV triple must NEVER draw through ps_main + // - that renders the raw Y plane (greyscale luma, or the PREVIOUS + // video's frame while the plane copies are still stale), the + // video-boundary flash class. If the planes can't resolve yet + // (async decode in flight, substitution off) the quad is SKIPPED; + // black under a starting video is what the real thing looks like. + bool video_quad = false; + if (yuv_triple(d)) { + video_quad = true; + if (movie_sub) { + TripleCacheEntry* e = nullptr; + for (int t = 0; t < triple_count && e == nullptr; ++t) { + if (triple_cache[t].y_addr == d.fetch[1]) { + e = &triple_cache[t]; + } + } + if (e == nullptr && triple_count < kMaxMovies) { + e = &triple_cache[triple_count++]; + e->y_addr = d.fetch[1]; + e->ok = true; + bool decode_failed = false; + for (int p = 0; p < 3 && e->ok; ++p) { + const uint32_t* w = d.fetch + p * 6; + // Plain find (find_words_texture would probe + enqueue its + // own ui=false heal first): force the per-frame probe, the + // resolve inline-decodes any content change (video=true). + auto hot = g_r.tex_store.find(FetchWordsKey(w)); + if (hot != g_r.tex_store.end()) { + hot->second.recheck_frame = 0; // content-hot: per-frame probe + } + const GuestTexture* t = + resolve_2d_texture(w, /*force_inline=*/false, /*video=*/true); + // Session gate: only content decoded during THIS movie + // session serves; at a video boundary both the store and + // guest memory can still hold the PREVIOUS video's last + // frame (the plane copies keep their addresses); the quad + // holds black until the new video's first frame lands. + e->ok = t != nullptr && t != &g_r.white && t->texture != nullptr && + t->last_change_frame >= s_movie_session_frame; + decode_failed |= t == &g_r.white; + e->slots[p] = e->ok ? t->srv : nullptr; + } + if (decode_failed) { + static std::atomic s_triple_failed{0}; + if (s_triple_failed.fetch_add(1, std::memory_order_relaxed) < 8) { + REXLOG_INFO( + "native-scene: FMV plane triple resolve FAILED " + "(y={:08X})", + d.fetch[1]); + } + } + } + if (e != nullptr && e->ok) { + yuv = e->slots; + } + } + } + if (yuv == nullptr && movie_sub && (d.flags & 0x2u) != 0 && + d.src_stride == 24) { + // Bracketed movie quad without a readable triple: through ps_main + // it draws its c8 opaque-black cover, also a video quad. + video_quad = true; + yuv = fallback_yuv(); + if (yuv != nullptr) { + static std::atomic s_fb_logged{0}; + if (s_fb_logged.fetch_add(1, std::memory_order_relaxed) < 4) { + REXLOG_INFO( + "native-scene: FMV bracket fallback served a quad (no " + "readable YUV triple on it)"); + } + } + } + if (video_quad) { + // Consulted by YieldForMovie: a video is only "on screen" while + // the 2D replay actually carries a video quad. A skipped/dismissed + // video leaves the decoder tail beating with no quad anywhere - + // yielding then flashes the (suppressed, stale) emulated buffer + // for nothing. + g_movie_quad_last_ns.store(movie_now_ns, std::memory_order_relaxed); + } + if (video_quad && yuv == nullptr) { + static std::atomic s_vskip{0}; + const uint32_t n = s_vskip.fetch_add(1, std::memory_order_relaxed); + if (n < 8 || (n & 511u) == 0) { + REXLOG_INFO( + "native-scene: video quad skipped (planes not ready, " + "y={:08X}, movie_sub={}) (n={})", + d.fetch[1], movie_sub ? 1 : 0, n); + } + continue; + } + emit_draw(d, yuv); + movie_drawn |= yuv != nullptr; + } + if (movie_drawn) { + g_movie_native_last_ns.store(movie_now_ns, std::memory_order_relaxed); + } + if (movie_drawn) { + static std::atomic s_movie_logged{false}; + if (!s_movie_logged.exchange(true, std::memory_order_relaxed)) { + REXLOG_INFO( + "native-scene: FMV rendering NATIVELY (movie-quad YUV " + "substitution)"); + } + } + } + } + + // Settings-menu backdrop blur over everything drawn this frame (scene + + // HUD/2D + FMV). Shared with the emulated-output post-processor path. + // Host state read directly at RENDER time - routing it through the + // published FrameScene made the blur depend on fresh guest publishes, + // which the startup flow doesn't reliably produce (stale scenes rendered + // with a frozen sigma of 0). + const float menu_blur_target = g_settings_menu_blur.load(std::memory_order_relaxed) + ? float(REXCVAR_GET(skate3_menu_blur_sigma)) + : 0.0f; + ApplyMenuBlurPass(context, cmd, menu_blur_target, + /*output_in_guest_output_state=*/false); + + cmd->Barrier(context.guest_output, nrhi::ResourceState::kRenderTarget, + nrhi::ResourceState::kGuestOutput); + cmd->FlushBarriers(); + + g_pw_2d.Add(perf_ns_since(twod_t0)); + g_pw_tail.Add(uint64_t(std::chrono::duration_cast( + twod_t0 - mid_t0) + .count())); + g_pw_render.Add(perf_ns_since(render_t0)); + const uint64_t frames = g_frames_rendered.fetch_add(1) + 1; + MaybeDumpSceneRing(); + LogFrameStats(scene, frames, drawn, drawn_2d, drawn_spline, shadow_ready, + shadow_draws); + return true; +} + +} // namespace + +void Install() { + // Registered even when the scene cvar starts off: RenderScene yields to the + // emulated output while disabled, and the runtime toggle (F5) can flip the + // cvar live at any point after boot. + rex::graphics::SetNativeGuestOutputRenderer(&RenderScene, nullptr); + // Settings-menu backdrop blur over frames the EMULATED path produced + // (boot/startup before the native scene takes over, manual emulated + // mode); native-rendered frames blur inline in RenderScene. + rex::graphics::SetNativeGuestOutputPostProcessor(&PostProcessGuestOutput, nullptr); + REXLOG_INFO("native-scene: guest output renderer registered (scene {})", + SceneEnabled() ? "on" : "off"); +} + +} // namespace skate3::native_scene + +#else // !(REX_HAS_D3D12 || REX_HAS_VULKAN) + +namespace skate3::native_scene { +void Install() {} +} // namespace skate3::native_scene + +#endif // REX_HAS_D3D12 || REX_HAS_VULKAN diff --git a/src/skate3_native_scene_state.h b/src/skate3_native_scene_state.h new file mode 100644 index 0000000..b598e46 --- /dev/null +++ b/src/skate3_native_scene_state.h @@ -0,0 +1,1022 @@ +#pragma once + +// Shared state of the native scene renderer, used by both of its translation +// units: skate3_native_scene.cpp (game-thread capture + frame build) and +// skate3_native_scene_gpu.cpp (render-thread RHI drawing). Everything here is +// written on one thread and read on another as documented per member; inline +// variables keep a single instance across the TUs. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "skate3_native_scene.h" + +REXCVAR_DECLARE(bool, skate3_native_render_scene); +REXCVAR_DECLARE(bool, skate3_native_render_scene_ring); + +namespace skate3::native_scene { + +inline std::mutex g_scene_mutex; +// Published scene: immutable once published (BuildFrameScene builds a fresh +// one per guest frame). shared_ptr so the render thread grabs a reference +// under the mutex instead of deep-copying ~800 items (with per-item heap +// vectors) every frame. +inline std::shared_ptr g_scene; +inline uint64_t g_generation = 0; +// Steady-clock stamp of the last scene publish (BuildFrameScene only +// publishes when a perspective view submitted this frame). YieldForMenus +// uses its freshness to tell the in-game pause menu (the world keeps +// resubmitting behind the menu) apart from loading screens and the boot +// frontend (publishes stop), for skate3_native_render_scene_pause_native. +inline std::atomic g_last_publish_ns{-1}; +// Debug-dialog cache flushes: consumed at the top of RenderScene so texture/ +// mesh-affecting toggles (mip chains, 565 fixes, ...) take effect immediately +// instead of only for newly streamed content. +inline std::atomic g_flush_textures{false}; +inline std::atomic g_flush_meshes{false}; +// Off-screen retention clear request (see g_retained_items further down): +// set on the menus/loading flip (render thread) and the F5 re-enable, +// consumed at the top of BuildFrameScene's retention block (guest thread). +inline std::atomic g_retained_clear{false}; + +// Camera/bone signal recorders + synthetic-pan probe + offline recording: +// state and file writers live in native/skate3_native_diagnostics.cpp +// (native/skate3_native_diag.h); capture call sites below read per-frame +// locals and stay here. +inline std::atomic g_guest_base{nullptr}; +inline std::atomic g_frames_rendered{0}; + +// Loading -> gameplay takeover. Armed while the game reports menus/loading +// (and by the F5 enable toggle, hence the atomic); the loading-screen +// prewarm (below) does the decode heavy lifting behind the load, and the +// FIRST substantial post-load scene renders natively immediately, no +// emulated-gameplay stretch. The frames right after takeover run a budgeted +// "settle" decode pass for whatever prewarm missed (dynamic entities, +// late-registered textures); the draw path's miss budgets are clamped while +// settling so leftovers render white/skip for a frame instead of freezing +// the takeover frame. Everything except the armed flag is touched on the +// render thread only, inside RenderScene. +inline std::atomic g_warmup_armed{true}; +// BuildFrameScene stops publishing during loading (no submissions / no +// perspective view), so g_scene holds the PREVIOUS map's scene through the +// whole loading screen. Only scenes published at or after this generation +// (stamped while the loading screen shows) are eligible for takeover; +// rendering the stale scene shows old-map garbage at the flip. +inline uint64_t g_warmup_fresh_generation = 0; +// Settle window: frames (of the native frame counter) still running the +// per-frame settle decode pass after a takeover. +inline uint64_t g_settle_until_frame = 0; + +// Loading-screen prewarm queue: world meshes pushed by the +// tROptiMeshData::Unfix hook (the rw-arena LOAD-time pointer resolve; it +// fires once per world mesh as its arena streams in, i.e. exactly during +// loading screens and gameplay world streaming). The render thread drains +// the queue in RenderScene: with the prewarm budget behind the loading +// screen (where the heavy lifting belongs), with the warmup budget's +// leftover while warming, and with a small fixed slice during gameplay +// (streamed-in areas decode before their first draw instead of hitching +// it). Entries whose buffer objects are not initialized yet retry a bounded +// number of drains. +struct PrewarmEntry { + uint32_t mesh; + uint16_t retries; + // Environment-cube entry (mesh == 0, tex != 0): the one object-keyed + // texture path left (see EnqueueCubeMiss). + uint32_t tex = 0; + // Fetch-words entry (mesh == 0, tex == 0, wkey != 0): a content-store + // miss decoded from the captured stable words snapshot (see + // EnqueueWordsMiss): every 2D/3D texture miss and heal. + uint64_t wkey = 0; + uint32_t words[6] = {}; + // Environment-cube entry (tex != 0 && cube): a cube-cache miss: a single + // cube decode measured up to ~100 ms, the largest remaining traversal + // hitch when a vehicle / reflective area streamed in. + bool cube = false; + // Draw-path miss (vs speculative prewarm registration): the result is + // visible RIGHT NOW, so the commit takes it this frame regardless of the + // gameplay per-frame commit cap. + bool miss = false; + // 2D/HUD overlay miss (large-art async routing, see + // skate3_native_render_scene_2d_async_px): the commit skips the + // payload-stability verify for these; APT re-rasterizes animating UI + // art every guest frame, so "payload moved between decode and commit" is + // the NORMAL state mid-animation and the verify would reject every + // commit, freezing the element (spinners, ramping fades). The per-frame + // content probe in the 2D resolve is the heal path for torn reads, the + // same exposure the old inline decode had. + bool ui = false; +}; +inline std::mutex g_prewarm_mutex; +inline std::condition_variable g_prewarm_cv; // wakes the decode workers +inline std::vector g_prewarm_queue; +// Draw-path misses (EnqueueMeshMiss/EnqueueWordsMiss/EnqueueCubeMiss): +// content that is visible RIGHT NOW (white / skipped), +// served FIFO with strict priority over the speculative registration +// backlog in g_prewarm_queue. Guarded by g_prewarm_mutex. +inline std::vector g_miss_queue; +// Steady-state draw-path misses currently being decoded on the workers (one +// in-flight decode per key; the commit erases). Guarded by g_prewarm_mutex. +// This is the panning-hitch fix: a texture decode averages ~10 ms (max ~70 ms +// measured) and a pan surfaces dozens of new textures/meshes in one frame; +// decoding them inline on the render thread was the lag spike. Misses now +// render white / keep the previous decode for the 1-3 frames the workers +// need. +inline std::unordered_set g_miss_inflight_mesh; +inline std::unordered_set g_miss_inflight_tex; +inline std::unordered_set g_miss_inflight_words; +// Dynamic-payload decode jobs: cloth-sim garments (ropa) have their vertex +// buffers rewritten by the CPU sim EVERY frame, so their mesh decode used to +// run inline on the render thread every frame (~0.7 ms per garment; a +// 4-garment outfit = ~2.9 ms = the 160 fps cap during real play once +// everything else was fixed; the demo character wears no ropa, which hid +// it). The guest thread snapshots the changed payload at publish time; a +// worker converts it into fresh upload buffers; PrewarmCommit swaps the mesh +// cache entry. The render thread keeps drawing the previous decode, one +// frame of cloth lag, invisible (the ropa sim is frame-paced anyway). +struct DynDecodeJob { + DrawItem item; // offsets/formats/fingerprint (bones not needed) + uint64_t seq = 0; // enqueue order; the commit drops stale results + std::vector vb; // big-endian guest payload snapshots + std::vector ib; +}; +inline std::vector g_dyn_jobs; // FIFO; guarded by g_prewarm_mutex +// Push-side dedupe: clone instances share one tRModelData, so the same mesh +// registers many times per load, but a permanent per-address set silently +// DISABLED the prewarm for re-streamed content (the streamer reuses the +// same mesh/texture object addresses all session): after a few minutes +// every re-registered mesh was "seen", its re-decode was skipped, and every +// streamed texture surfaced as a draw-path first-sight miss instead: +// 388 white-flash textures in one 45 s traversal = the +// residual medium-distance pop-in. Now a mesh -> last-queued-frame map: +// clone bursts still dedupe inside the window, re-streams past it re-queue +// (registration is the game's own "content changed here" signal, see +// InvalidateCachedItem above). Cleared when the game enters menus/loading. +inline std::unordered_map g_prewarm_seen; +// Texture-object dedupe for the workers (they cannot read the render +// thread's g_r caches): address -> fetch-words key at last staging; a +// words change at a reused address re-stages, an unchanged one skips. +inline std::unordered_map g_prewarm_tex_seen; +inline std::atomic g_prewarm_done{0}; // entries fully warm-decoded +inline std::atomic g_prewarm_dropped{0}; // entries given up on +inline std::atomic g_prewarm_workers_started{false}; +// tInstance::m_pRModel offset, confirmed at runtime by the material +// cross-check probe in OnAddRenderInstance (Skate 2 layout: +0x78). +inline std::atomic g_instance_rmodel_offset{0}; + +// guid -> guest renderengine::Texture, from the RegisterTexture hook. Keys +// masked of the top bit: material channel guids carry an extra flag bit +// relative to the registered cAssetIDs. +constexpr uint64_t kGuidMask = 0x7FFFFFFFFFFFFFFFull; +inline std::mutex g_texture_map_mutex; +inline std::unordered_map g_texture_map; + +// VS constant staging bank (bank id 0x4000, device+1920). +// D3D::SetPending_AluConstants(device, dirty group mask, bank, ptr) is +// called from inside the guest Draw* functions; ptr is the device's +// positional 256-register shadow (partial uploads leave the rest intact). +// Two verified layouts: pre-pass keeps the per-draw transform / bone +// palette at c4; main-pass keeps camera at c4, palette at c7, rigid world +// at c8..c11 (see BankPaletteBase / BankRigidWorld). Because the dynamic +// capture runs right after RenderMesh returns, the bank holds exactly that +// mesh's constants when its draws ran inline. +inline std::atomic g_vs_bank{0}; +// Pixel constant shadow bank (bank id 0x4400, device+6016): material colors +// (e.g. the CAS hair tint) are staged here per draw. +inline std::atomic g_ps_bank{0}; +// Global per-frame fog rows (see FrameScene::fog_ramp): main-pass VS banks +// keep the camera at c4 and the frame's fog params at c5 (ramp) / c6 (color) +// - identical across every main-pass draw of a frame (verified from a +// recorded draw stream). OnDrawDone grabs them from the first 3D draw whose +// c4 matches the last built scene's camera; BuildFrameScene publishes them +// and re-arms the capture. All on the guest render thread. +inline float g_fog_cam[3] = {}; +inline float g_fog_rows[8] = {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}; +inline bool g_fog_have = false; +inline bool g_fog_frame_done = false; +// Sky-dome viewpos height (see FrameScene::sky_height): sky.fx's VS adds +// g_vViewPos to every dome vertex, but the game feeds the SKY draw a viewpos +// whose Y is a fixed level elevation (165.0 in every Port Carverton capture) +// instead of the camera's; the baked skyline must not bob with the skater. +// Captured from the sky bank's unique signature: c4.xz matches the camera +// while c4.y sits far above it (every other main-pass draw has c4 == camera; +// water-reflection passes mirror y DOWNWARD and are excluded by dy > 0). +inline float g_sky_height = 165.0f; // every Port Carverton capture to date +inline bool g_sky_have = false; +inline bool g_sky_frame_done = false; +// Sky sun rows from the same draw's PIXEL bank (see FrameScene::sky_sun): +// light dir (c0.xyz), sun angular scale (c4.x), pre-tone multiplier (c4.y), +// exposure (c3.x). Persist like g_sky_height once captured. +inline float g_sky_sun[6] = {}; +inline bool g_sky_sun_have = false; +// UI background blur (see FrameScene::ui_blur and kBlurShaderSource): while +// a frontend popup is up the game appends blur_hBlur/vBlur + basictex passes +// after the postfx uber. g_ui_blur holds the captured kernel scale (PS c0.x, +// 8 in every capture); g_ui_blur_seen latches per frame on the blur_hBlurPS +// draw and is cleared at publish; the pass chain only exists while the +// popup is actually up, so this can never stick on. +inline float g_ui_blur = 8.0f; +inline bool g_ui_blur_seen = false; +// Blur modulate color (the blur passes' PS c1): both blur ucodes end in +// `mul oC0, r0, c1`: the game's menu fade. Gameplay popups stage (1,1,1) +// (why the exact port originally had no multiply); the pause menu stages +// ~(0.35,0.33,0.32), squared across the H+V passes = the darkened pause +// backdrop. Read live on the blur_hBlurPS draw, accepted only when the +// bank's c0 kernel row reads exactly (8,8); a stale mid-stage bank fails +// that gate (the staleness class that pulsed the blur radius when c0.x was +// fed live, see g_ui_blur above). +inline float g_ui_blur_color[3] = {1.0f, 1.0f, 1.0f}; +// Hold the blur across publishes that carry no blur draw: the game does not +// issue the pass chain on every swap while the popup is up, and publishing +// the raw per-swap flag alternated blur on/off: a visible brightness +// shimmer. Two publishes of hysteresis bridges the gaps; on popup close the +// blur lingers ~2 guest frames, imperceptible. +inline int g_ui_blur_hold = 0; +// Host settings-overlay backdrop: forces the same blur chain while the SDK +// settings menu is open (the game issues no popup blur then). Set from the +// app UI thread, read at scene publish. +inline std::atomic g_settings_menu_blur{false}; +// Emulated-post blur diagnostics: reset on each menu open so every open +// logs a fresh burst. +inline std::atomic g_post_blur_log_count{0}; +// Selected-object outline capture (see DrawItem::selected): in the park +// editor / object mover the game EXCLUDES the selected object from the main +// color pass and re-draws it right after the sky, twice, back to back, the +// stencil-marking passes the postfx edge-detect turns into the blue contour +// (verified in capture: consecutive environmentpark decal + +// diffuse parts, identical VS banks, vs the same mesh at OTHER placements in +// the main pass). Hook side records each post-sky environmentpark/ +// dynamicobject draw's (ib, vb, world translation); entries seen >= 2 times +// mark the matching published items. Guest render thread only, like the fog +// capture state. +struct SelectedDrawKey { + uint32_t ib; + uint32_t vb; + float t[3]; + uint32_t count; +}; +inline bool g_sky_seen_this_frame = false; +inline std::vector g_frame_selected; +// The guest issued its postfx_edgedetectstencil draw this frame: the game's +// own "an outline is being drawn" signal (park editor / object mover with an +// active selection). Post-sky double-draws alone are NOT sufficient evidence +// of a selection: normal gameplay draws some small props twice after the sky +// (far LOD/imposter passes), which used to sprout phantom outlines on distant +// objects. Guest render thread only; reset per frame in BuildFrameScene. +inline bool g_outline_edge_seen = false; +// Outline color, refreshed from the guest postfx_edgedetectstencil draw's +// PS c0 (the park-editor blue in every capture) whenever that pass runs. +inline float g_outline_color[4] = {0.21569f, 0.64706f, 1.0f, 1.0f}; +// PIXEL banks keep the CSM constants at c0..c8, pass-global (identical on +// every environment-family draw of the pass; character/hair/tree PSes +// allocate differently and are rejected by the sanity gate). Captured on the +// same camera-keyed main-pass draws as the fog rows. +inline float g_shadow_rows[48] = {}; +inline bool g_shadow_have = false; +inline bool g_shadow_frame_done = false; +// Frame-global rows of the tree / proxyworld shader families (see +// FrameScene::family_rows), captured from their PS banks when a draw with +// the matching debug path runs. Guest render thread only. +inline float g_family_rows[4] = {0.3435f, 0.02f, 1.0f, 0.45f}; +inline bool g_tree_frame_done = false; +inline bool g_proxy_frame_done = false; +// dynamicobject.fx frame-global lighting rows (see FrameScene::dynobj_rows), +// captured from a dynamicobject/alphatestdynamicobject PS bank (debug-path +// classified). Guest render thread only. +inline float g_dynobj_rows[10] = {}; +inline bool g_dynobj_have = false; +inline bool g_dynobj_frame_done = false; +// dynamicobject static world-shadow transform rows (PS c5/c6/c7), captured +// alongside g_dynobj_rows, see FrameScene::dynobj_ws. +inline float g_dynobj_ws[12] = {}; +inline bool g_dynobj_ws_have = false; +// Dynamic entities (characters, movable props) live entirely in transient +// per-frame arenas: the context, its record, mesh pointers, transforms and +// bone palettes are all recycled before frame end. The complete DrawItem is +// therefore built at RenderMesh hook time and stored here. +inline std::mutex g_palette_mutex; +inline std::vector g_frame_dynitems; +// (ib_obj << 32 | vb_obj) -> indices of pending items in g_frame_dynitems, +// for the post-draw state fixup (deferred / world-path captures draw after +// their capture; the fixup fills palette or world from the actual draw). +inline std::unordered_multimap g_frame_pending_by_buffers; +// Contexts submitted through an ORTHO (shadow caster-cascade) bank this +// frame, i.e. items the GAME drew into its own shadow passes. The game's +// caster list is per-piece: the CAS trucker hat submits ONLY to the main +// view (verified in capture: every char piece has shadow-pass draws +// except the hat); natively casting it painted a hard low-sun brim band +// across the editor face that the emulated frame never shows. The native +// atlas pass renders char pieces only when their ctx is in this set +// (rescued/gap items inherit last frame's flag). Guarded by +// g_palette_mutex; cleared at the end of BuildFrameScene. +inline std::unordered_set g_frame_ortho_ctx; +// (ib_obj << 32 | vb_obj) -> dynitem indices whose character-lighting rows +// should refresh on every later matching draw this frame. The one-shot +// palette fixup usually pops on the shadow-CASTER draw, whose PS bank is +// stale (shadowPS uploads no PS constants); re-capturing on the main-pass +// draw (last writer) lands the real per-instance rows (stamp tints etc.). +inline std::unordered_multimap g_frame_char_refresh; +// (ib_obj << 32 | vb_obj) -> device fetch-shadow slots 3+4 (12 dwords) at +// this frame's LAST indexed 3D draw with those buffers (the z-prepass draws +// first, so the main pass wins). Consumed at frame end by the +// streamed-artwork diffuse override (DrawItem::diffuse_fetch): poster/advert +// materials keep a 16x16 min-mip placeholder in the material channel while +// the engine binds the streamed art via the fetch constants at draw time. +// Guarded by g_palette_mutex; cleared at the end of BuildFrameScene. +inline std::unordered_map> g_frame_draw_fetch; +// mesh -> last VALIDATED character-lighting rows (see CaptureCharLighting). +// The per-frame capture depends on a fragile draw-order chain (capture at +// submit-exit -> pending fixup -> per-draw refresh, commit-on-success); on +// frames where no draw in the chain carried this character's main-pass PS +// bank the item ends with INVALID rows and the renderer falls back to the +// legacy empirical shading; the player visibly flickered between the two +// looks while moving, and the hair left the blended sub-pass (opaque "hair +// helmet" covering the cap crown). The rows are frame-coherent (sun/ambient +// move slowly), so reusing the previous valid capture is visually seamless. +// TRAP: NPC clones share a mesh with PER-INSTANCE rows (stamp tints, the +// teal-vest twins), so the fallback applies only to meshes with a single +// instance in the frame. Guest-render-thread only (capture hooks + +// BuildFrameScene), no lock needed. +inline std::unordered_map> g_char_rows_cache; +// Frame-global character CSM receive biases (see FrameScene::char_shadow_bias) +// + the guest frame they were captured on (served while <120 frames stale; +// they only change with the scene lighting setup). Guest render thread only. +inline float g_char_shadow_bias[3] = {}; +inline uint64_t g_char_shadow_bias_frame = 0; +inline std::atomic g_char_rows_reused{0}; +// Cached palette entry for the per-instance rescue below. A refused +// capture re-publishes with the cached palette: one frame of pose lag is +// invisible; a one-frame disappearance is not. Guest-render-thread only. +struct CachedBones { + std::vector bones; + // g_guest_frame at refresh: rescues/heals must be FRESH; a close-pass + // refusal resurrecting a seconds-old palette rendered the vehicle 10-20 m + // BEHIND its live position (the momentary ghost-back). + uint64_t frame = 0; +}; +// ctx -> last published palette for skinned character-family items. The +// MeshContext is the game's own per-instance identity, so this cache +// rescues each instance with ITS OWN last palette regardless of how many +// clones are alive. +inline std::unordered_map g_bones_cache_ctx; +inline std::atomic g_lw_ctx_rescued{0}; +// LW entity store consumption counters (stats line lw[...]). +inline std::atomic g_lw_stamped{0}; +inline std::atomic g_lw_fade0{0}; +inline std::atomic g_lw_gap_filled{0}; +// Authoritative caster-palette substitutions + per-ctx lighting-rows serves +// (edge-of-view vehicles; see the stamp pass). +inline std::atomic g_lw_pal_sub{0}; +inline std::atomic g_lw_rows_served{0}; +// ctx -> last VALIDATED lighting/paint rows, entity-checked (a recycled +// instance must not inherit the previous occupant's paint). The mesh-keyed +// g_char_rows_cache fallback is gated to single-instance meshes; cloned +// traffic never qualified, so a caster-only stretch (main view culls the +// vehicle at the screen edge before it leaves the screen) dropped to +// legacy flat shading: the "vehicle loses its texture/color at the edge" +// sighting. +struct CharRowsCtx { + std::array rows; + uint32_t entity = 0; +}; +inline std::unordered_map g_char_rows_cache_ctx; +// (mesh,ctx) -> last VALIDATED rows for EVERY character item with an +// instance context: the general rescue the two caches above each cover +// only a slice of: the mesh-keyed cache is gated to single-instance meshes +// (character pieces also publish caster items of the same mesh, so gameplay +// pieces rarely qualify) and the ctx cache is gated to LW-mapped entities +// (the PLAYER is not one). A capture-failed frame without a rescue drops +// hair out of the blended sub-pass onto the legacy opaque path: a +// frame-to-frame hair/garment appearance flip (the +// failure rate scales with guest fps, so it reads as a high-fps +// flicker). Entity-stamped like CharRowsCtx (0 for non-LW instances) so a +// recycled ctx never inherits the previous occupant's rows. +struct CharRowsInst { + std::array rows; + uint32_t entity = 0; +}; +inline std::unordered_map g_char_rows_cache_inst; +// Entity-keyed lighting rows for ROPA garments only: the sim on/off switch +// swaps between a garment's two renderables (cloth-target vs original +// model, different mesh AND ctx), so the (mesh,ctx) caches never carry +// rows across the switch and the freshly resolved model rendered dark/flat +// until its own capture validated. Both models map to the same owning +// entity in the identity store, and they are the same garment; sharing +// rows across the pair is exact. Garment-to-garment only; body pieces keep +// their per-piece caches. +struct CharRowsEnt { + std::array rows; + uint8_t fam = 0; // donor family: the row layout is family-specific +}; +inline std::unordered_map g_char_rows_cache_ent; +inline std::atomic g_char_rows_ent_served{0}; +inline std::atomic g_char_rows_inst_served{0}; +// Items served authoritative pack palettes this frame / total (see +// native_palette::ServeAuthoritativePalettes). +inline uint32_t g_pal_served_frame = 0; +inline std::atomic g_pal_served_total{0}; +// Route-flip probe: counts hair/character-alpha pieces whose pass route +// (blended sub-pass vs legacy opaque) changed between consecutive frames; +// each count is one visible flicker event. +inline std::atomic g_hair_route_flips{0}; +// A,B,A,B alternation probes: bone palettes (per piece) and the shadow +// cascade state (scene-wide) returning EXACTLY to the previous-but-one +// frame's value (legitimate animation/lighting drift never does). +inline std::atomic g_hair_bone_alternations{0}; +inline std::atomic g_shadow_alternations{0}; +// ctx -> last PUBLISHED item of a live LW entity (skinned character +// families, non-ropa): when a ctx drops out of the submit records for a +// frame or two while its entity is still alive in the store (observed as +// "dyn publish GAP" incidents on fam-3/5 ped meshes, 1-3 +// frames each: an in-view NPC vanishing for a frame reads as a blink or, +// moving, a small teleport), the whole item republishes. Console behavior: +// a live entity never skips a frame. Self-limiting: entries expire after 2 +// unpublished frames and refresh only from LIVE publishes (never from a +// fill, dbg_src 9), so a real despawn shows at most 2 filled frames, at +// the entity's own served alpha. +struct LwRetained { + DrawItem item; + uint64_t frame = 0; +}; +inline std::unordered_map g_lw_last_items; +// mesh -> last RESOLVED ropa garment state (rigid world OR skinned palette). +// Ropa items must NOT ride the palette-only rescue above: a ropa capture is +// refused exactly when the bank is stale (g_ropa_stale), and while the cloth +// sim is ACTIVE the VB holds sim-deformed root-local positions; skinning +// those with a cached palette is the mangled-ribbon interpretation (verified +// 0/31 in-clip; on-screen as map-length stretched strips, with matching +// shadows since the caster pass shares the item). And with no rescue at all +// a refused frame drops the garment (the momentary invisible torso). The +// dyn decode workers already keep the drawn cloth VB one frame behind the +// sim, so re-publishing last frame's resolved MODE + transform is exactly +// age-consistent with the vertices being drawn. Single-instance meshes only +// - clones share meshes with per-instance transforms. Guest-render-thread +// only. +struct RopaResolvedState { + bool skinned = false; + float world[16] = {}; + std::vector bones; + // Publish frame of the cached state: the rescues below exist to bridge + // 1-3 frame capture gaps, and re-publishing an unbounded-age state pins a + // ghost of the garment at a stale pose for as long as captures keep + // failing (the frozen-in-the-air garment behind a skater whose capture + // path was refusing every frame). + uint64_t frame = 0; +}; +inline std::unordered_map g_ropa_state_cache; +inline std::atomic g_ropa_rescued{0}; +// mesh -> sample verts of the most recent DECODE of a skinned mesh (bind- +// space position + raw blend attribs, ~32 evenly spaced), written by +// DecodeMesh on whichever thread decodes (workers / render), read by the +// guest thread's draw-time STRETCH VETO in BuildFrameScene. The veto skins +// these samples with the FINAL post-interpolation palette, i.e. it judges +// what the GPU will ACTUALLY draw. Every upstream gate (capture acceptance, +// publish coherence) judges the LIVE guest VB instead, so a decode-content +// vs palette pairing mismatch, or junk introduced by the interpolation +// substitutions, passes all of them and still flashes the 1-frame +// map-length ribbon (observed with every ropa[] counter clean). +// `fp` = the decode's payload fingerprint so the veto can log whether the +// drawn content even matches the frame's payload (content lag vs junk +// palette: the decisive diagnostic split). +struct SkinProbeSample { + float p[3]; + uint32_t bw; + uint32_t bi; +}; +struct SkinProbe { + std::vector s; + uint64_t fp = 0; +}; +inline std::mutex g_skin_probe_mutex; +inline std::unordered_map g_skin_probe; +inline std::atomic g_cur_ib{0}; +inline std::atomic g_cur_vb{0}; +inline std::atomic g_cur_vb_offset{0}; +inline std::atomic g_cur_vb_stride{0}; +// Guest D3D::CDevice: the fetch-constant shadow lives at device+0x480, +// 6 dwords per fetch group (from recompiled SetPending_FetchConstants). +inline std::atomic g_device{0}; +// 2D/APT phase depth counters (see On2dPhase). All HUD/menu 2D elements are +// Flash SWFs converted to APT; draws issued inside these brackets are the 2D +// draw stream. +inline std::atomic g_phase2d_depth[6]; +inline std::atomic g_draws_2d{0}; +// 2D draws seen through paths the overlay does not replay yet +// (DrawIndexedVertices / DrawVertices in the 2D phase; gameplay HUD uses +// only the BeginVertices inline path, verified by capture). +inline std::atomic g_draws_2d_other{0}; +inline std::atomic g_draws_2d_dropped{0}; +// Large-art async decode routing (see skate3_native_render_scene_2d_async_px): +// quads skipped while their first decode is in flight on the workers, and +// stale decodes served while a content-change re-decode is in flight. +inline std::atomic g_2d_async_skip{0}; +inline std::atomic g_2d_async_stale{0}; + +// One captured 2D draw (verified layout): BeginVertices +// inline vertices, stride 24 = {float x, y, z, w; float u, v} in 1280x720 +// APT movie space; VS c0..c3 = ortho projection rows, c4..c7 = the +// element's 2D transform, c8 = color multiplier; diffuse texture in fetch +// shadow slot 0. Vertex payload is written by the CPU after BeginVertices +// returns, so it is read at frame end (addr) rather than at capture. +// NOTE on render-to-texture: in gameplay the ENTIRE HUD renders inside +// AptRenderingIntegration::UpdateRenderToTexture (bracket bit 3) into a +// screen-sized overlay texture, at true screen coordinates; the game +// composites that overlay over the 3D frame in a later (emulated, +// suppressed) pass. Replaying the draws directly onto the native output IS +// that composite, so bit 3 is diagnostic only, not a routing signal. The +// overlay content is straight-alpha art (verified by decoding the clock +// face/needle/sheen textures offline). +struct Draw2d { + uint32_t prim; // xenos primitive: 4 trilist, 5 tristrip, 13 quadlist + uint32_t count; // vertex count + uint32_t stride; // bytes per vertex at capture (see publish normalization) + // Capture-time stride, preserved across the publish normalization (which + // rewrites stride to the renderer's 28). The FMV substitution keys on it: + // the movie quad is the stride-24 textured layout inside the + // AptMovieIntegration bracket (bit 1). + uint32_t src_stride = 0; + uint32_t addr; // guest inline-ring write pointer + uint32_t flags; // bracket bits at capture (layout disambiguation) + // Texture fetch constants, shadow slots 0-2 (6 dwords each). Slot 0 is + // the draw's own texture (all the replay paths read only [0..5]); slots + // 1-2 exist for the VIDEO quads, whose console YUV shader binds the U and + // V planes there; the replay's YUV-triple detection reads them. + uint32_t fetch[18]; + float consts[36]; // VS c0..c8 + std::vector verts; // filled at frame end (little-endian dwords) +}; +inline std::mutex g_2d_mutex; +inline std::vector g_frame_2d; // capture in submission order +inline std::vector g_scene_2d; // published at frame end +inline uint64_t g_scene_2d_generation = 0; + +// One captured in-world neon spline draw (waypoint arrows / marker beams; +// decoded from a live capture + the game's own spline.fx source): a +// DrawVertices triangle strip whose 12-byte float3 "vertices" are only +// parameters: x = control-point index + fractional t, y = U texcoord, +// z = side flag (0/1) selecting the extrusion offset and V texcoord. The +// guest VS (43309A8C) evaluates a uniform cubic B-spline through i_cp[] +// (VS c7..) transformed by world columns c4..c6, adds the world-rotated +// extrusion offset c[151 + z], projects by VP columns c0..c3, and fades by +// clip-z against i_clipvalues (c150) with i_intensity (c149) as the pass +// gain. Two passes per element: spline_darkenPS (straight alpha) then +// spline_defaultPS (additive glow), both depth-tested with no z-write, +// replayed inside the native scene pass with the spline evaluated on the +// CPU at publish time. +struct SplineDraw { + uint32_t pass; // 1 = darken (straight alpha), 2 = default (additive) + uint32_t count; // strip vertex count + uint32_t fetch[6]; // texture fetch slot 3 (the neon gradient) + float consts[153 * 4]; // VS c0..c152 as staged + // capture: raw big-endian float3 params; publish: evaluated little-endian + // {float4 clip_pos, float2 uv, float fade} (28-byte stride). + std::vector verts; +}; +inline std::vector g_frame_spline; // guarded by g_2d_mutex +inline std::vector g_scene_spline; +inline std::atomic g_draws_spline{0}; +// Current guest shader objects and render-state shadow bank, for the 2D +// recon recording (grouping the stream by shader / reading blend state). +inline std::atomic g_cur_ps_obj{0}; +inline std::atomic g_cur_vs_obj{0}; +inline std::atomic g_rs_bank{0}; +inline std::atomic g_cur_viewport[6]; +inline std::atomic g_cur_scissor[4]; +// All four vertex streams (vb, offset, stride); cloth binds its simulated +// vertices on a stream other than 0. +inline std::atomic g_cur_streams[4][3]; + +// Offline-analysis recording structs/state: native/skate3_native_diag.h. +inline std::atomic g_vs_uploads{0}; +inline std::atomic g_palette_snapshots{0}; +// Palettes captured at base+1 (the cloth/morph VS layout with an extra +// parameter row before the palette, see RefinePaletteBase). +inline std::atomic g_palette_base_plus1{0}; +// Reflective-glass (fam 5/6) normal-map pair telemetry: pair = draws with +// the masks+normal t4/t5 descriptor pair bound (overlay.w == 4), flat = +// spec-bound reflective draws still on the flat-normal path, gate = the +// last no-pair reason bitmask (1 no spec masks, 2 no normal channel, 4 +// masks SRV recipe missing, 8 normal unresolved/white, 16 normal invalid, +// 32 normal recipe missing, 64 pair slots exhausted). +inline std::atomic g_refl_pair{0}; +inline std::atomic g_refl_flat{0}; +inline std::atomic g_refl_gate{0}; + +// ---- F7 scene-composition ring (see RequestSceneRingDump in the header) ---- +struct SceneRingItem { + uint32_t ctx; + uint32_t mesh; + uint32_t diffuse; + uint32_t lightmap; + uint32_t vb_obj; + // Full material texture set: a one-frame spec/normal/macro/decal-art + // resolution glitch changes shading (e.g. an unmasked sky reflection on a + // reflective bank face) with the diffuse/lightmap identical; the first + // ring revision could not see those. + uint32_t spec; + uint32_t macro; + uint32_t decal_art; + uint32_t wnormal; + uint32_t indices; // summed index counts of the item's draw entries + uint16_t drawn; // draw calls actually issued (0 = skipped at draw) + uint8_t env_family; + uint8_t char_family; + uint8_t flags; // 1 transparent 2 water 4 skinned 8 retained 16 pending + // 32 caster_bank 64 decal + uint8_t route; // 0 skipped pre-pass, 1 opaque pass, 2 blended sub-pass + // SERVED-content fingerprints, stamped in draw_item after the resolves: + // the object pointers above cannot see an in-place content swap (a heal + // commit changes what a texture shows with every pointer identical); + // a fp that changes A->B->A across the flash frame names the texture, + // the slot, and both contents. 0 = white fallback / not resolved. + uint64_t fp_diffuse; + uint64_t fp_lightmap; + uint64_t fp_macro; + uint64_t fp_decal; +}; +struct SceneRingFrame { + uint64_t frame = 0; + float cam[3] = {}; + // Per-frame captured globals: a one-frame glitch in any of these shifts + // shading on every consumer with the composition identical. + float fog[6] = {}; // ramp xyz + color rgb + float family_rows[4] = {}; + float sky_height = 0.0f; + bool shadow_valid = false; + std::vector items; +}; +inline std::deque g_scene_ring; // render thread only +constexpr size_t kSceneRingFrames = 2400; +inline std::atomic g_scene_ring_dump{false}; + +// Render thread, frame end: write the whole ring as CSV. One F line per +// frame, then one I line per item (hex object addresses to match every +// other diagnostic). +inline void MaybeDumpSceneRing() { + if (!g_scene_ring_dump.exchange(false, std::memory_order_acq_rel)) { + return; + } + char path[128]; + std::snprintf(path, sizeof(path), "logs/scene_ring_%lld.csv", + static_cast(std::time(nullptr))); + std::ofstream f(path); + if (!f) { + REXLOG_WARN("native-scene ring: cannot open {}", path); + return; + } + f << "kind,frame,ctx,mesh,diffuse,lightmap,vb,spec,macro,decal_art," + "wnormal,indices,drawn,env_fam,char_fam,flags,route,fp_diffuse," + "fp_lightmap,fp_macro,fp_decal\n"; + char line[256]; + for (const SceneRingFrame& fr : g_scene_ring) { + std::snprintf(line, sizeof(line), + "F,%llu,cam,%.2f,%.2f,%.2f,items,%zu,fog,%.5f,%.4f," + "%.3f,%.4f,%.4f,%.4f,fam,%.4f,%.4f,%.4f,%.4f,sky,%.1f," + "shadow,%d\n", + static_cast(fr.frame), double(fr.cam[0]), + double(fr.cam[1]), double(fr.cam[2]), + fr.items.size(), double(fr.fog[0]), double(fr.fog[1]), + double(fr.fog[2]), double(fr.fog[3]), double(fr.fog[4]), + double(fr.fog[5]), double(fr.family_rows[0]), + double(fr.family_rows[1]), double(fr.family_rows[2]), + double(fr.family_rows[3]), double(fr.sky_height), + fr.shadow_valid ? 1 : 0); + f << line; + for (const SceneRingItem& it : fr.items) { + std::snprintf(line, sizeof(line), + "I,%llu,%08X,%08X,%08X,%08X,%08X,%08X,%08X,%08X,%08X," + "%u,%u,%u,%u,%u,%u,%016llX,%016llX,%016llX,%016llX\n", + static_cast(fr.frame), it.ctx, it.mesh, + it.diffuse, it.lightmap, it.vb_obj, it.spec, it.macro, + it.decal_art, it.wnormal, it.indices, unsigned(it.drawn), + unsigned(it.env_family), unsigned(it.char_family), + unsigned(it.flags), unsigned(it.route), + static_cast(it.fp_diffuse), + static_cast(it.fp_lightmap), + static_cast(it.fp_macro), + static_cast(it.fp_decal)); + f << line; + } + } + REXLOG_INFO("native-scene ring: wrote {} frames -> {}", g_scene_ring.size(), + path); +} +// Character-lighting capture telemetry: attempts vs validated captures per +// family (see CaptureCharLighting). +inline std::atomic g_char_attempts{0}; +inline std::atomic g_char_valid{0}; +inline std::atomic g_char_drawn{0}; +inline std::atomic g_dynobj_drawn{0}; +// character.cloth_ropa items captured in the sim-active RIGID mode (world +// from c188/c191 instead of a bone palette, see CaptureSkinnedState). +inline std::atomic g_ropa_rigid{0}; +// ropa rigid captures REFUSED because the bank's c188/c191 was implausible +// or projected the garment off-clip (stale bank from another mesh's draw); +// the item stays pending for the post-draw fixup. +inline std::atomic g_ropa_stale{0}; +// ropa captures accepted through the RELAXED near-camera criterion (all +// samples in FRONT of the projection inside a loose 6x guard band + sane +// spread) after the strict half-in-clip score refused them. Up close the +// garment fills/overflows the screen and most of the 6 sample verts clip; +// the strict gate refused the CORRECT palette every frame, and with no +// cached resolved state to rescue from the torso stayed invisible until the +// NPC was far enough away again (log signature: `ropa mesh=... score=2 +// flag=(1.000,...)` repeating while stale climbed with rescued flat). +inline std::atomic g_ropa_relaxed{0}; +// mesh -> newest enqueued cloth-shape generation (DynDecodeJob seq). GUEST +// render thread only: written by the dyn-job enqueue, read by the interp +// ring's pose ingestion (the pose <-> shape pairing). +inline std::unordered_map g_ropa_last_seq; +// Shape blends served / skipped (generation missing from the ring) per +// stats window. +inline std::atomic g_ropa_blend_drawn{0}; +inline std::atomic g_ropa_blend_miss{0}; +// Ropa garments PUBLISHED with a caster-cascade (ortho) bank's palette: +// the ~40 ms-stale rows behind the garment-offset-from-body symptom. The +// same-mode graft in the dyn_slot merge should keep this at ~0 whenever a +// perspective capture exists in the same frame. +inline std::atomic g_ropa_caster{0}; +// Published skinned items whose palette FAILED the dense 32-sample +// publish-time coherence gate (see PublishedPaletteSane): the 1-frame +// junk-palette ribbon flash that passes every 6-sample capture gate. The +// item re-publishes its cached state instead (or drops for one frame). +inline std::atomic g_pub_incoherent{0}; +// Draw-time STRETCH VETO trips (see g_skin_probe): the final palette skins +// the resident decode's sample verts wider than bind size; the item's +// draws are cleared for the frame (blink, not ribbon) and the first events +// dump full palette + samples to logs/stretch_*.txt for offline diagnosis. +inline std::atomic g_stretch_veto{0}; +// skinned/ropa meshes that published after a 1-3 frame publish GAP (was on +// screen, vanished, came back): the "flickered invisible" telemetry. +inline std::atomic g_dyn_gap{0}; +inline std::atomic g_skinned_items{0}; +inline std::atomic g_skinned_skipped{0}; +// Rigid items whose transform had to wait for the post-draw fixup (deferred +// draws), and those still pending at frame end (dropped). +inline std::atomic g_rigid_pending{0}; +inline std::atomic g_rigid_dropped{0}; +// Model-space props in the world sort lists, excluded from the identity +// world path (their placed copies come from the kind-2 capture). +inline std::atomic g_world_props{0}; +inline std::atomic g_rej_no_dynstate{0}; +inline std::atomic g_rej_dyn_range{0}; +// Render-side silent-skip diagnostics: a scene item that decodes badly or +// loses its bone binding produces no (or wrong) pixels with no other trace. +inline std::atomic g_rr_decode_fail{0}; +inline std::atomic g_rr_no_bones{0}; +// Decodes pushed past the per-frame budget (item/texture appears a few +// frames late instead of hitching the render thread). +inline std::atomic g_rr_mesh_deferred{0}; +inline std::atomic g_rr_tex_deferred{0}; +inline std::atomic g_rej_chain{0}; +inline std::atomic g_rej_geom{0}; +inline std::atomic g_rej_draws{0}; +inline std::atomic g_rej_bbox{0}; + +// ---- Perf telemetry (windowed; logged + reset with the 600-frame stats) ---- +// Written by the guest render thread (capture/build) and the command +// processor thread (render segments); read by the render thread's stats log. +// Atomics with relaxed ordering: telemetry, not synchronization. +using PerfClock = std::chrono::steady_clock; +inline uint64_t PerfNsSince(PerfClock::time_point t0) { + return uint64_t( + std::chrono::duration_cast(PerfClock::now() - t0) + .count()); +} +struct PerfWindow { + std::atomic ns{0}; + std::atomic max_ns{0}; + std::atomic count{0}; + void Add(uint64_t v) { + ns.fetch_add(v, std::memory_order_relaxed); + count.fetch_add(1, std::memory_order_relaxed); + uint64_t prev = max_ns.load(std::memory_order_relaxed); + while (v > prev && + !max_ns.compare_exchange_weak(prev, v, std::memory_order_relaxed)) { + } + } + double AvgMs() const { + const uint64_t c = count.load(std::memory_order_relaxed); + return c != 0 ? double(ns.load(std::memory_order_relaxed)) / double(c) * 1e-6 + : 0.0; + } + double MaxMs() const { return double(max_ns.load(std::memory_order_relaxed)) * 1e-6; } + void Reset() { + ns.store(0, std::memory_order_relaxed); + max_ns.store(0, std::memory_order_relaxed); + count.store(0, std::memory_order_relaxed); + } +}; +// Guest frame counter (guest render thread only; incremented at +// BuildFrameScene entry): paces the world-item cache's revalidation. +inline uint64_t g_guest_frame = 0; + +// Guest render thread: per-frame sums (one Add per guest frame). +inline PerfWindow g_pw_capture; // hook-time capture work (sort lists + RenderMesh) +inline PerfWindow g_pw_build; // BuildFrameScene +inline PerfWindow g_pw_pal_tail; // frame tail: palette snapshot-ring rotation +inline PerfWindow g_pw_guest_dt; // guest frame interval (guest fps + spike max) +inline uint64_t g_capture_frame_ns = 0; // guest render thread only; folded per frame +// Command processor thread: per-RenderScene-call segments. +inline PerfWindow g_pw_render; // whole native render +inline PerfWindow g_pw_items; // opaque + transparent item loop (incl. decodes) +inline PerfWindow g_pw_shadow; // dynamic-shadow atlas pass +inline PerfWindow g_pw_mesh_decode; // inline DecodeMesh calls (count = decodes) +inline PerfWindow g_pw_tex_decode; // inline texture-ish decodes (cubes, HUD words) +inline PerfWindow g_pw_commit; // PrewarmCommit batches (count = non-empty runs) +// RenderScene phase attribution (the sustained "render=13ms, items=0.7ms" +// Vulkan mystery): everything render= covers that items=/shadow= +// do not. pre = EnsurePipeline..prewarm+evict+retire before the scene pass; +// twod = the 2D/HUD overlay replay (incl. inline decodes + video planes); +// tail = MSAA resolve, outline, pfx chain, photo grab, popup blur, final +// transitions: RenderScene minus everything else. +inline PerfWindow g_pw_pre; +inline PerfWindow g_pw_2d; +inline PerfWindow g_pw_tail; +// Settle-pass attribution (the sticky ~80fps post-map-change state: +// pre=8.4ms == the whole warmup budget, GPU idle at +// P0): the takeover settle pass extends itself +8 frames whenever a frame's +// warm walk leaves work deferred, so content that keeps failing revalidation +// (old-map items over reused arenas, streamed payloads) can hold it open for +// the rest of the session, burning the full budget every frame. settle= times +// the block; wdec/wdef count warm-path decodes/deferrals; the offender logs +// name what churns. +inline PerfWindow g_pw_settle; +// Guest-thread build-phase attribution (the standing-still 320->250 fps +// oscillation): build= runs on the guest render thread and its +// variance IS guest frame-time variance. b2d/bspl/bpal split the phases; +// the item walk is the remainder. dt histogram + slow-frame log quantify +// and attribute the dips. +inline PerfWindow g_pw_b2d; // Publish2dDraws +inline PerfWindow g_pw_bspl; // PublishSplineDraws +inline PerfWindow g_pw_bpal; // ServeAuthoritativePalettes +// This frame's phase costs (guest render thread only - no atomics needed). +inline uint64_t g_frame_b2d_ns = 0; +inline uint64_t g_frame_bspl_ns = 0; +inline uint64_t g_frame_bpal_ns = 0; +inline uint64_t g_frame_pal_tail_ns = 0; +inline std::atomic g_dt_hist[5] = {}; // <3.6 / <4.5 / <6 / <10 / >=10 ms +inline std::atomic g_slow_frame_log_budget{0}; +inline std::atomic g_warm_decodes{0}; +inline std::atomic g_warm_deferred{0}; +inline std::atomic g_warm_mesh_log_budget{0}; +inline std::atomic g_warm_tex_log_budget{0}; +inline uint64_t g_takeover_frame = 0; +// Camera-update cadence (the "world judders while panning at 320 fps" +// question): if the guest updates its camera on a fixed sim tick while the +// render loop free-runs, view_proj repeats for several rendered frames and +// then steps, visible as the surroundings skipping to catch up. Counted per +// published scene on the guest render thread. +inline std::atomic g_cam_changes{0}; +inline std::atomic g_cam_repeats{0}; +inline std::atomic g_cam_max_streak{0}; + +inline bool SceneEnabled() { return REXCVAR_GET(skate3_native_render_scene); } + +// ---- Photo / movie / CAS-editor state shared with the render thread ---- + +// drew_inside captures whose bank provably belonged to another mesh's draw +// (now deferred to the post-draw fixup instead of consumed). +inline std::atomic g_capture_foreign_bank{0}; + +inline std::atomic g_item_cache_hits{0}; +inline std::atomic g_item_cache_builds{0}; + +// ---- Photo-editor postfx capture (FrameScene::PhotoFx / photo_fx.hlsl) ---- +// While a photo flow is active (g_photo_flow_frame, armed by +// UpdatePhotoGrabWindow), the OnDrawDone hook snapshots each postfx pass's +// PS/VS constant rows (g_ps_bank/g_vs_bank, the game's staging banks) plus +// the device fetch-constant shadow (device+0x480) at DRAW time, the same +// reads the F11 per-draw records use, verified correct for every postfx +// pass in captures 1783957072 and 1783963636. Upload-time (SetPending) +// capture was removed: the game stages the next pass's rows while the +// previous pass's shader is still bound, so upload-time snapshots mix +// neighbouring passes' constants. +enum PfxPass { + kPfxVisualFx = 0, + kPfxDofDown, + kPfxDofMB, + kPfxDof, + kPfxUber, + kPfxFisheye, + kPfxPassCount +}; + +inline std::atomic g_photo_flow_frame{false}; + +// Photo display-card sighting stamp (see the photo-grab helpers for the +// full story): stamped by Publish2dDraws on the guest thread whenever the +// card-signature quad is in the 2D stream; read by YieldForPhotoDisplay. +inline std::atomic g_photo_card_seen_ns{-1}; + +// Last ScreenshotBackEnd grab REQUEST (PerfClock ns): the game arms a grab +// 1-2 frames before it renders the shot + card-composite frame sequence and +// CPU-reads the resolves (GrabScreenshot -> OnScreenShot compose). The +// shutter burst (UpdatePhotoGrabWindow) keys on this: the ONLY moment the +// card-build's inputs can be made real under the native path. +inline std::atomic g_photo_grab_request_ns{-1}; + +// GrabScreenshot returned: the grab CPU-read the resolved shot, and the +// OnScreenShot card compose that follows (same guest thread, same Update) +// only reads memory the burst already copied. The burst closes here: +// event-driven, no timers (a short grace in the window logic covers the +// aux-target grab some flows issue on the following frame). +inline std::atomic g_photo_grab_done_ns{-1}; + +// Last PhotoReplayController::Update heartbeat, nanoseconds on PerfClock +// (steady). -1 until the first heartbeat. Written from the guest thread, +// read by RenderScene on the render thread. +inline std::atomic g_photo_replay_last_ns{-1}; + +// Last FrontEndState_Replay2::TakePhoto heartbeat: a photo was just taken +// (replay editor or photo mission). The frames that follow render and +// CPU-grab the 1152x640 screenshot target, so the forced-readback window +// (UpdatePhotoGrabWindow) stays armed for a few seconds after it. Same +// thread contract as the photo-replay heartbeat above. +inline std::atomic g_take_photo_last_ns{-1}; + +// Last rw::movie::MovieDecoder::Decode heartbeat (FMV playback: intro +// logos, any full-motion video). Same contract as the photo-replay +// heartbeat above. +inline std::atomic g_movie_decode_last_ns{-1}; + +// Last frame the 2D replay actually SUBSTITUTED a movie quad (ps_yuv2d). +// Consulted by YieldForMovie: while the native substitution is serving the +// video, the emulated yield must never engage; the emulated framebuffer +// is STALE under draw suppression, and presenting it flashed the previous +// video's last frame at every video boundary. +inline std::atomic g_movie_native_last_ns{-1}; + +// Last frame the 2D replay CONTAINED a video quad at all (substituted or +// not; detection runs regardless of movie_sub). Consulted by YieldForMovie: +// with no quad on screen there is nothing a yield could display - a +// skipped/force-completed video (intro skip) leaves the decoder heartbeat +// alive ~0.5 s with no quad anywhere, and yielding then just flashed the +// stale emulated buffer (the "momentary emulated switch" at intro skip). +inline std::atomic g_movie_quad_last_ns{-1}; + +// YUV plane fetch words of the FMV frames handed to +// VideoRenderer_RwTexture::Render (Y full res, U/V half res, CPU-filled via +// Texture::Lock). Multiple videos play at once (the camera-angle select +// page runs TWO preview movies), so this is a small table keyed by the Y +// plane's fetch-words hash; the 2D replay matches each captured draw's own +// slot-0 fetch against it; a video quad samples its Y plane there (the +// camera-page previews rendered as slow GREYSCALE through the plain 2D +// shader: exactly that luma). Movie threads publish under the mutex; +// RenderScene copies per frame. +struct MoviePlanes { + // Y plane identity = the fetch constant's ADDRESS dword (words[0][1]). + // NOT a full-words hash: the words staged into the device shadow at draw + // time carry per-draw sampling flags that differ from the texture + // object's stored constant (observed: both camera-page videos + // published, exact-words match still 0 hits): the address dword is the + // physical identity both sides agree on. + uint32_t y_addr = 0; + uint32_t words[3][6]; // Y, U, V fetch constants + int64_t ns = -1; +}; +constexpr int kMaxMovies = 4; +inline std::mutex g_movie_mutex; +inline MoviePlanes g_movies[kMaxMovies]; + +// Heartbeat of the create-a-skater editor's OWN pixel shaders: the editor's +// in-view skater draws use "_nis" compiles (cacstamp_skin_nisPS, +// cac_cloth_nisPS, cac_face_nisPS, cac_hair_nisPS, defaultcharacter_nisPS, +// cacstamp_shift_nisPS) that exist nowhere else in the game; a fresh +// sighting means the CAS editor is on screen NOW. Backs the FE-stack id-15 +// detection in YieldForCasEditor: the startup new-game flow reaches the +// editor through a different FE screen (observed: the indicator stayed +// NATIVE there). Deliberately NOT matched: the "_unwrap" texture-space +// composite variants; outfit composition also runs mid-gameplay after an +// outfit change and must never yield a gameplay frame. +inline std::atomic g_cas_ps_last_ns{-1}; + +// Capture-side helpers the render thread calls (defined in +// skate3_native_scene.cpp). +bool GuestTryLoadU32(uint8_t* base, uint32_t addr, uint32_t* out); +void ClearItemCache(); +bool CasEditorActive(uint8_t* base); +bool PortraitRttWindowActive(); +// Parse a guest mesh struct into a DrawItem (prewarm decode workers build +// items off the queue without a live capture). +bool BuildItemFromMesh(uint8_t* base, uint32_t mesh, DrawItem& item); + +// Render-side helper the capture thread calls (defined in +// skate3_native_scene_gpu.cpp): photo-editor detection shared by the yield +// and the photo-grab readback window: nullptr when inactive, else the name +// of the active signal. +const char* PhotoEditorSignal(uint8_t* base); + +} // namespace skate3::native_scene