// 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_debug_dialog.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 #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" #include "skate3_native_scene_gpu_internal.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_dynamic_items); 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_shadow_pcss); REXCVAR_DECLARE(bool, skate3_native_render_scene_shadow_static_casters); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_blocker_m); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_max_m); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_min_texel); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_pcss_sun_deg); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_bias); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_radius); REXCVAR_DECLARE(int32_t, skate3_native_render_scene_shadow_static_size); REXCVAR_DECLARE(int32_t, skate3_native_render_scene_debug_fail_static_sun); REXCVAR_DECLARE(double, skate3_native_render_scene_shadow_static_strength); REXCVAR_DECLARE(bool, skate3_native_render_scene_shadows); REXCVAR_DECLARE(bool, skate3_native_render_scene_shafts); REXCVAR_DECLARE(bool, skate3_native_render_scene_haze); REXCVAR_DECLARE(bool, skate3_native_render_scene_showcase); 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_showcase_hold); REXCVAR_DECLARE(double, skate3_native_render_scene_showcase_wipe); REXCVAR_DECLARE(std::string, skate3_native_render_scene_showcase_order); 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_scene_trace_2d); 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 { // 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), and the ocean, // whose VS passes the lightmap UV raw (Out.UV.zw = In.LM, no abs). if (item.char_family < 4 && item.water_ocean == 0) { 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.water_flowing && !item.skinned && item.uv2_fmt == 26 && item.tan_fmt == 16) { // Water meshes (flowingwater VS layout): the fmt-26 element is // lm_norm: xy = |unwrap| with sign bits, zw = normal.xy (snorm), // normal.z = sign.y * sqrt(1 - xy^2), and the usage-6 k_10_11_11 // element is the TANGENT (no usage-3 normal, no usage-7 binormal). // Pack the tangent into the free blend-weight bytes with the unwrap // sign.x in the sentinel byte; the shader rebuilds the frame as // B = sign.x * cross(N, T), exactly the game's VS // (vBinormal = signs.x * cross(vNormal, vTangent)). 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; // The game clamps xy_len at 0.999 (water normals ride |xy| ~ 1, so // z floors at 0.0316 rather than 0, verified against the VS ucode). const float xylen = std::min(n3[0] * n3[0] + n3[1] * n3[1], 0.999f); n3[2] = (sy > 0 ? 1.0f : -1.0f) * std::sqrt(1.0f - xylen); float t3[3]; unpack_10_11_11(item.tangent_offset, t3); const uint32_t packed = pk(t3[0]) | (pk(t3[1]) << 8) | (pk(t3[2]) << 16) | ((sx > 0 ? 200u : 100u) << 24); std::memcpy(&dst[v * 14 + 7], &packed, 4); } else 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 xenos::TextureFormat cube_base_fmt = rex::graphics::GetBaseFormat(info.format); const bool rgba_chain = (cube_base_fmt == xenos::TextureFormat::k_DXT1 || cube_base_fmt == xenos::TextureFormat::k_8_8_8_8) && 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 mip 0 -> RGBA8 per face (DXT1 block decode, or plain 8888 // texels: the water canal cubes), box-filter the full chain to 1x1, // upload as an RGBA cube. The GUEST cube mip chain is never trusted: // its packed sub-32px levels and per-face slice layout are exactly // where garbage sneaks in, and the reflection fetch runs at a deep // gradient LOD where a bad level dominates. 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); if (cube_base_fmt == xenos::TextureFormat::k_DXT1) { 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); } } } else { // k_8_8_8_8: post-endian-swap bytes are already component order // X,Y,Z,W (the SRV swizzle applies the fetch's channel remap). std::memcpy(&rgba[size_t(by) * width * 4], bc_row.data(), size_t(width) * 4); } } // Diagnostic dump (skate3_native_render_scene_lm_dump): the decoded // face exactly as uploaded, for offline byte-diff against the // reference decode of the same fetch words. if (REXCVAR_GET(skate3_native_render_scene_lm_dump)) { char path[260]; std::snprintf(path, sizeof(path), "native_texture_dumps/cube_%08X_f%u_%ux%u.rgba", info.memory.base_address, face, width, height); if (FILE* f = std::fopen(path, "wb")) { std::fwrite(rgba.data(), 1, size_t(width) * height * 4, f); std::fclose(f); } } // 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_DEBUG("native-scene: cube {:08X} {}x{} fmt={} -> RGBA full chain ({} levels)", tex_ptr, width, height, uint32_t(info.format), 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_DEBUG("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). } // namespace // ---- 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), plus the native static // sun-shadow map (t10) as the table's third entry; extending an // existing table costs no root-signature DWORDs. Bound together via // SetTextures (a pair-only bind would drop t10 to the backend // fallback). ld.params[8] = {nrhi::BindingParamKind::kTextureTable, 8, 3, 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; } // The main scene PSO and its culling/blend/depth variants (cull-back // sheets, transparent, entity fade, hair 2-pass, no-depth, outline mask), // built at the g_r.msaa sample count EnsurePipeline selected. 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(); // g_r.msaa is selected by EnsurePipeline (which triggers this rebuild on // change). // The family rebuilds on HDR/MSAA 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* cpc = device->CreateShader( MakeShaderDesc(nrhi::ShaderStage::kPixel, "scene.hlsl", kShaderSource, "ps_shadow_caster_clip", 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 || cpc == 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); cp.ps = cpc; g_r.pso_shadow_caster_clip = 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(cpc); device->DestroyDeferred(bvs); device->DestroyDeferred(bps); if (g_r.pso_shadow_caster == nullptr || g_r.pso_shadow_caster_clip == 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; // Cascade tile size: explicit, or 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. const int32_t tile_cfg = REXCVAR_GET(skate3_native_render_scene_shadow_tile); const uint32_t want_tile = tile_cfg > 0 ? uint32_t(tile_cfg) : std::min(512u * std::max(1u, (context.guest_output_height + 719u) / 720u), 4096u); if (g_r.shadow_raw != nullptr && g_r.shadow_tile != want_tile) { // Hot tile-size change: retire the atlas chain; recreated below. The // new atlas starts in RENDER_TARGET state and is re-rendered by this // frame's shadow pass. 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) { if (*views[t] != nullptr) { device->DestroyDeferred(*views[t]); *views[t] = nullptr; } if (*targets[t] != nullptr) { device->DestroyDeferred(*targets[t]); *targets[t] = nullptr; } } } 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. g_r.shadow_tile = want_tile; 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; } uint32_t want_static_size = uint32_t(std::clamp( REXCVAR_GET(skate3_native_render_scene_shadow_static_size), 1024, 8192)); if (device->backend() == nrhi::Backend::kD3D12) { // The 3-tile cascade row must fit D3D12's 16384 2D-texture width cap: // per-tile sizes above 5461 cannot allocate there (the 8192 setting // asks for 24576x8192), while desktop Vulkan allows 32768-wide. while (want_static_size * 3 > 16384) { want_static_size /= 2; } } if (g_r.static_sun != nullptr && g_r.static_sun_requested != want_static_size) { // Hot size change: retire the map; recreated below. The recreated map // is uninitialized, so force the cross-frame cache to re-render it // (RenderStaticSunMap rebuilds whenever nsm_built_radius <= 0). if (g_r.static_sun_srv != nullptr) { device->DestroyDeferred(g_r.static_sun_srv); g_r.static_sun_srv = nullptr; } device->DestroyDeferred(g_r.static_sun); g_r.static_sun = nullptr; g_r.nsm_built_radius = 0.0f; } if (!g_r.static_sun && g_r.shadow_raw != nullptr && REXCVAR_GET(skate3_native_render_scene_shadow_static_casters)) { // Native static sun-shadow map (see RendererState). THREE cascade // tiles side by side: inner (r/6, centimeter contact detail with // useful reach), mid (r/2) and far (full radius, large-caster // coverage); size is per tile. // Forced-failure debug hook (see the cvar's help text): mode 1 fails // every creation, mode 2 only hot recreates; retries are skipped so the // sticky-failed path below engages like a hard driver rejection. const int32_t debug_fail = REXCVAR_GET(skate3_native_render_scene_debug_fail_static_sun); const bool force_fail = debug_fail == 1 || (debug_fail == 2 && g_r.static_sun_requested != 0); g_r.static_sun_requested = want_static_size; g_r.static_sun_size = want_static_size; nrhi::TextureDesc desc; desc.width = g_r.static_sun_size * 3; desc.height = g_r.static_sun_size; 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.static_sun = force_fail ? nullptr : device->CreateTexture(desc); // Allocation-failure fallback (VRAM pressure, driver limits): a // coarser static sun map beats marking the whole renderer failed and // dropping the session to emulated output. while (!force_fail && g_r.static_sun == nullptr && g_r.static_sun_size > 1024) { g_r.static_sun_size /= 2; desc.width = g_r.static_sun_size * 3; desc.height = g_r.static_sun_size; REXLOG_WARN( "native-scene: static sun-shadow map allocation failed, retrying " "at {}x{}", g_r.static_sun_size * 3, g_r.static_sun_size); g_r.static_sun = device->CreateTexture(desc); } if (g_r.static_sun != nullptr) { nrhi::TextureViewDesc vd; vd.mip_levels = 1; g_r.static_sun_srv = device->CreateTextureView(g_r.static_sun, vd); } if (g_r.static_sun_srv == nullptr) { REXLOG_ERROR("native-scene: static sun-shadow map creation failed"); g_r.failed = true; return false; } g_r.static_sun_in_srv = false; REXLOG_INFO( "native-scene: static sun-shadow map created ({}x{}, 3 cascades)", g_r.static_sun_size * 3, g_r.static_sun_size); } 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 || g_r.targets_msaa != g_r.msaa) { if (g_r.depth) { // The AO/SSR/volumetric scene-depth SRVs alias this texture and // re-point on pointer identity; heap reuse can hand the NEW depth // texture the OLD one's address, so that comparison must never be // the only invalidation. Retire the views with the buffer they view: // a stale view kept sampling the retired depth image (still in // attachment layout), a GPU fault and device hang on Vulkan. nrhi::TextureView** depth_views[3] = { &g_r.ao_depth_srv, &g_r.ssr_depth_srv, &g_r.vol_depth_srv}; nrhi::Texture** depth_views_of[3] = { &g_r.ao_depth_srv_of, &g_r.ssr_depth_srv_of, &g_r.vol_depth_srv_of}; for (int v = 0; v < 3; ++v) { if (*depth_views[v] != nullptr) { g_r.device->DestroyDeferred(*depth_views[v]); *depth_views[v] = nullptr; } *depth_views_of[v] = nullptr; } 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; } else { // MSAA switched off: retire the multisample color target and its // resolve view (the scene renders directly into the 1x target). if (g_r.msaa_srv_slot) { g_r.device->DestroyDeferred(g_r.msaa_srv_slot); g_r.msaa_srv_slot = nullptr; g_r.msaa_srv_allocated = false; } if (g_r.msaa_color) { g_r.device->DestroyDeferred(g_r.msaa_color); g_r.msaa_color = 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; g_r.targets_msaa = g_r.msaa; } return true; } 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; // 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. Hot: a change rebuilds the pipeline family // (the AO/SSR/volumetric passes and the MSAA targets latch on g_r.msaa // and follow). const nrhi::Format scene_fmt_want = hdr_want ? hdr_fmt_want : context.guest_output->format(); const int32_t msaa_req = REXCVAR_GET(skate3_native_render_scene_msaa); uint32_t msaa_want = msaa_req >= 8 ? 8u : msaa_req >= 4 ? 4u : msaa_req >= 2 ? 2u : 1u; msaa_want = device->GetSupportedSampleCount(scene_fmt_want, msaa_want); 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.msaa != msaa_want) { if (g_r.msaa != msaa_want && g_r.pfx_ready) { // The photo-postfx depth-pack pass is compiled against the depth // buffer's sample count (PFX_MSAA variant); retire the chain's PSOs // so the next photo-editor frame rebuilds them. for (nrhi::Pipeline*& p : g_r.pfx_pso) { if (p != nullptr) { device->DestroyDeferred(p); p = nullptr; } } g_r.pfx_ready = false; } g_r.hdr_active = hdr_want; g_r.hdr_scene_format = hdr_fmt_want; g_r.msaa = msaa_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; } namespace { // ---- 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 (observed: one // mesh re-fingerprinting 4x in 50 ms). 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_DEBUG("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_DEBUG("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.water_ocean == 1) { warm_texture(item.water_normal2); // second PCA component (t8) } 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.water_ocean == 1) { stage_texture(item.water_normal2); // second PCA component (t8) } 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)}); // The 8-tap boxcar kernel reaches filter_w/2 (~25 ms) past the // play clock (itself ~2 guest periods behind), plus decode-latency // slack. Generations arrive per rendered frame while the cloth sim // runs (the pose/shape pairing depends on that cadence), so the // retention must scale with the highest supported render rate: // 48 slots keep the kernel's ~55 ms reach covered up to ~800 fps. while (ring.size() > 48) { 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_DEBUG( "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::kernel::guest_presence::GameplayContextValue() == 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); // The static-caster cache holds world-positioned records from the // OUTGOING map; different maps share coordinate ranges, so stale // records whose meshes survive the transition would keep casting // (an entire arriving area sat under the previous map's building // shadows). Invalidate the sun map with it. g_r.static_casters.clear(); g_r.nsm_dirty = true; g_r.static_sun_valid = false; g_r.nsm_built_radius = 0.0f; 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. // Read-only resolved-texture lookup (route -> store entry) for the caster // passes' alpha-test binding: the main pass owns decode scheduling and // live guest reads; an unresolved diffuse just casts opaque for the frames // a first-sight decode takes. static nrhi::TextureView* LookupResolvedTexture(uint32_t tex_ptr) { if (tex_ptr == 0) { return nullptr; } auto rit = g_r.tex_routes.find(tex_ptr); if (rit == g_r.tex_routes.end()) { return nullptr; } auto sit = g_r.tex_store.find(rit->second.key); if (sit == g_r.tex_store.end() || !sit->second.valid) { return nullptr; } return sit->second.srv; } // Native static sun-shadow map: one camera-centered ortho depth pass over // the STATIC world items along the material sun (the captured c6 row: // the direction the world materials and their baked shadows are lit // from). The game's own cascade transforms are a stylized near-vertical // projection fit to a ~12 m height window (measured ~43 degrees off the // material sun): adequate for the dynamic casters they were built for, // but tall static geometry projected through them shadows its own // plan-view footprint. This map gives static shade a true sun axis, a // full-range depth window (no clamp blobs) and its own resolution; // receivers min it with the CSM term (SampleStaticSun), bottoming out at // the configured strength. Re-rendered every frame; the box follows the // camera, with the origin snapped to the texel grid so the static raster // never swims sub-texel (foliage shimmer). static void RenderStaticSunMap(const NativeGuestOutputRenderContext& context, const FrameScene& scene, uint32_t bone_region, int32_t debug_mode, uint64_t frame_number) { g_r.static_sun_valid = false; if (g_r.static_sun == nullptr || g_r.pso_shadow_caster == nullptr || g_r.pso_shadow_caster_clip == nullptr || debug_mode != 0 || !scene.shadow_valid || !REXCVAR_GET(skate3_native_render_scene_shadows) || !REXCVAR_GET(skate3_native_render_scene_shadow_static_casters)) { return; } // Sun axis = the material sun (c6). It is the only sun-like direction // in the captured data: the game's cascade transforms AND its baked // static-shade (world-shadow) projection are both the same stylized // near-vertical axis, measured ~43 degrees off c6, projecting statics // along either shadows their plan-view footprint. Shadows cast along c6 // are geometrically consistent with the materials' lighting; where the // baked lightmaps were authored with yet another sun the two can // disagree, which the strength floor and the receivers' min-clamp keep // plausible rather than doubled. float sun[3] = {scene.shadow_rows[24], scene.shadow_rows[25], scene.shadow_rows[26]}; const float slen = std::sqrt(sun[0] * sun[0] + sun[1] * sun[1] + sun[2] * sun[2]); if (slen < 0.5f) { return; } for (float& v : sun) { v /= slen; } if (sun[1] < 0.08f) { return; // sun at/below the horizon: no usable static sun term } const float zl[3] = {-sun[0], -sun[1], -sun[2]}; float xl[3] = {zl[2], 0.0f, -zl[0]}; const float xll = std::sqrt(xl[0] * xl[0] + xl[2] * xl[2]); if (xll > 1e-5f) { xl[0] /= xll; xl[2] /= xll; } else { xl[0] = 1.0f; xl[2] = 0.0f; } const float yl[3] = {zl[1] * xl[2] - zl[2] * xl[1], zl[2] * xl[0] - zl[0] * xl[2], zl[0] * xl[1] - zl[1] * xl[0]}; const float radius = std::clamp( float(REXCVAR_GET(skate3_native_render_scene_shadow_static_radius)), 20.0f, 500.0f); // Depth window +-2r along the axis: covers cliffs/towers far above the // box at ~16-bit millimeter precision. const float depth_half = radius * 2.0f; const float texel_m = 2.0f * radius / float(g_r.static_sun_size); const float* cam = scene.cam_pos; // Upsert this frame's visible statics into the persistent caster cache // (same static filter as the world-shadow map pass). scene.items is the // game's view-culled list; drawing the map from it directly popped // shadows with the camera; the cache keeps once-seen geometry casting // from any direction. A new or content-changed record marks the map // dirty for a rebuild. for (const DrawItem& item : scene.items) { if (item.transparent || item.unlit || item.cloth_quads || item.water || item.water_ocean != 0 || item.skinned || item.ropa || item.char_family != 0 || item.dynobj != 0) { continue; } // Key: mesh + quantized world translation (placed instances of one // mesh are distinct casters; a re-streamed identical placement maps // back onto its record). const auto q = [](float v) { return uint64_t(int64_t(std::llround(double(v) * 8.0))); }; uint64_t key = uint64_t(item.mesh) * 0x9E3779B97F4A7C15ull; key ^= q(item.world[12]) * 0xC2B2AE3D27D4EB4Full; key ^= (q(item.world[13]) + 0x165667B19E3779F9ull) * 0x27D4EB2F165667C5ull; key ^= (q(item.world[14]) + 0x9E3779B97F4A7C15ull) * 0x85EBCA77C2B2AE63ull; RendererState::StaticCaster& rec = g_r.static_casters[key]; if (rec.mesh == 0 || rec.fingerprint != item.fingerprint) { rec.mesh = item.mesh; rec.fingerprint = item.fingerprint; std::memcpy(rec.world, item.world, sizeof(rec.world)); std::memcpy(rec.bbox_min, item.bbox_min, sizeof(rec.bbox_min)); std::memcpy(rec.bbox_max, item.bbox_max, sizeof(rec.bbox_max)); rec.diffuse_tex = item.diffuse_tex; rec.clip = item.env_family == 7 || item.env_family == 9 || item.env_family == 10; rec.draws.assign(item.draws.begin(), item.draws.end()); g_r.nsm_dirty = true; } rec.last_seen_frame = frame_number; } // Cache maintenance: distance eviction (region streamed away) plus a // long staleness timeout, which bounds ghost shadows from content the // game actually removed (deleted park-editor objects) at the cost of a // late pop for casters never re-viewed. if (frame_number >= g_r.static_casters_sweep_frame) { g_r.static_casters_sweep_frame = frame_number + 600; const float evict_r = radius * 6.0f; for (auto it = g_r.static_casters.begin(); it != g_r.static_casters.end();) { const RendererState::StaticCaster& rec = it->second; const float ex = rec.world[12] - cam[0]; const float ey = rec.world[13] - cam[1]; const float ez = rec.world[14] - cam[2]; const bool far_away = ex * ex + ey * ey + ez * ez > evict_r * evict_r; const bool stale = frame_number > rec.last_seen_frame + 30000; if (far_away || stale) { it = g_r.static_casters.erase(it); g_r.nsm_dirty = true; } else { ++it; } } } // Cross-frame cache: the map re-renders only when something it depends // on changed. Between rebuilds the stored rows and the map contents are // served as-is; statics and the sun are near-constant, so this removes // almost the entire steady-state caster cost (and freezes the raster, // so foliage dapple cannot shimmer frame to frame). // Dirty (content changed) rebuilds are RATE-LIMITED: dense areas stream // new casters near-continuously, and an unthrottled dirty flag degraded // to a full map redraw every frame. A <=30-frame shadow latency for // newly streamed geometry is invisible; drift/sun rebuilds stay // immediate. bool rebuild = g_r.nsm_built_radius <= 0.0f || std::fabs(radius - g_r.nsm_built_radius) > 0.5f || frame_number >= g_r.nsm_rebuild_frame || (g_r.nsm_dirty && frame_number >= g_r.nsm_last_build_frame + 30); if (!rebuild) { // Camera drift from the built center, measured in the map plane: the // INNER tile (radius/6) must keep covering the player's // surroundings, so the threshold rides its extent. const float dcx = cam[0] - g_r.nsm_center[0]; const float dcy = cam[1] - g_r.nsm_center[1]; const float dcz = cam[2] - g_r.nsm_center[2]; const float du = xl[0] * dcx + xl[1] * dcy + xl[2] * dcz; const float dv = yl[0] * dcx + yl[1] * dcy + yl[2] * dcz; rebuild = std::max(std::fabs(du), std::fabs(dv)) > radius * 0.0667f; } if (!rebuild) { rebuild = sun[0] * g_r.nsm_sun[0] + sun[1] * g_r.nsm_sun[1] + sun[2] * g_r.nsm_sun[2] < 0.999995f; } if (!rebuild) { g_r.static_sun_valid = true; // serve the cached map + stored rows return; } g_r.nsm_dirty = false; g_r.nsm_rebuild_frame = frame_number + 600; g_r.nsm_last_build_frame = frame_number; g_r.nsm_built_radius = radius; std::memcpy(g_r.nsm_sun, sun, sizeof(g_r.nsm_sun)); float o[3] = {cam[0], cam[1], cam[2]}; const float cx = xl[0] * o[0] + xl[1] * o[1] + xl[2] * o[2]; const float cy = yl[0] * o[0] + yl[1] * o[1] + yl[2] * o[2]; const float dx = cx - std::round(cx / texel_m) * texel_m; const float dy = cy - std::round(cy / texel_m) * texel_m; for (int a = 0; a < 3; ++a) { o[a] -= xl[a] * dx + yl[a] * dy; } std::memcpy(g_r.nsm_center, o, sizeof(g_r.nsm_center)); // world -> map rows, shared by this caster pass and the receivers // (b1 nsm_x/y/z): uc = dot(row.xyz, wp) + row.w. float rows[12]; for (int a = 0; a < 3; ++a) { rows[0 + a] = xl[a] / radius; rows[4 + a] = yl[a] / radius; rows[8 + a] = zl[a] / (2.0f * depth_half); } rows[3] = -(rows[0] * o[0] + rows[1] * o[1] + rows[2] * o[2]); rows[7] = -(rows[4] * o[0] + rows[5] * o[1] + rows[6] * o[2]); rows[11] = 0.5f - (rows[8] * o[0] + rows[9] * o[1] + rows[10] * o[2]); std::memcpy(g_r.nsm_rows, rows, sizeof(rows)); g_r.nsm_depth_range = 2.0f * depth_half; g_r.nsm_radius = radius; // View-proj columns from the rows (same construction as the CSM // lightvp; clip.y lands in the sampler's v = -y/2 + 0.5 convention via // the raster y-flip). float vp[16] = {}; for (int r = 0; r < 3; ++r) { vp[r * 4 + 0] = rows[0 + r]; vp[r * 4 + 1] = rows[4 + r]; vp[r * 4 + 2] = rows[8 + r]; } vp[12] = rows[3]; vp[13] = rows[7]; vp[14] = rows[11]; vp[15] = 1.0f; // Finer-tile view-projections: same origin, radius/2 and radius/6, // exact rescales of the far transform's X/Y columns (the shader selects // tiles by the same ratios; keep in sync with its // kRatioInner/kRatioMid). Snapping used the FAR texel grid, whose // multiples are also finer-texel multiples (integer 1:2:6 chain), so // every tile stays swim-free. constexpr float kRatioMid = 2.0f; constexpr float kRatioInner = 6.0f; float vp_mid[16]; float vp_inner[16]; std::memcpy(vp_mid, vp, sizeof(vp)); std::memcpy(vp_inner, vp, sizeof(vp)); for (int r = 0; r < 4; ++r) { vp_mid[r * 4 + 0] *= kRatioMid; vp_mid[r * 4 + 1] *= kRatioMid; vp_inner[r * 4 + 0] *= kRatioInner; vp_inner[r * 4 + 1] *= kRatioInner; } nrhi::Cmd* cmd = context.cmd; if (g_r.static_sun_in_srv) { cmd->Barrier(g_r.static_sun, nrhi::ResourceState::kPixelShaderResource, nrhi::ResourceState::kRenderTarget); cmd->FlushBarriers(); g_r.static_sun_in_srv = false; } const float clear[4] = {1.0f, 1.0f, 0.0f, 0.0f}; cmd->ClearRenderTarget(g_r.static_sun, clear); cmd->SetRenderTargets(g_r.static_sun, nullptr); cmd->SetBindingLayout(g_r.layout); cmd->SetConstantBuffer(9, g_r.bone_ring, bone_region); const float size = float(g_r.static_sun_size); // Per tile (0 = inner r/6, 1 = mid r/2, 2 = far), two phases each: // opaque statics, then the alpha-tested families (trees, alphatest // fences/grates) with the clip PSO and their diffuse. for (int tile = 0; tile < 3; ++tile) { const float* tvp = tile == 0 ? vp_inner : (tile == 1 ? vp_mid : vp); // Cull threshold in FAR uc units (finer tiles cover 1/ratio of the // far extent). const float cull = tile == 0 ? 1.05f / kRatioInner : tile == 1 ? 1.05f / kRatioMid : 1.05f; cmd->SetViewport( nrhi::Viewport{size * tile, 0.0f, size, size, 0.0f, 1.0f}); cmd->SetScissor(nrhi::Rect{int32_t(g_r.static_sun_size) * tile, 0, int32_t(g_r.static_sun_size) * (tile + 1), int32_t(g_r.static_sun_size)}); for (int phase = 0; phase < 2; ++phase) { cmd->SetPipeline(phase == 0 ? g_r.pso_shadow_caster : g_r.pso_shadow_caster_clip); for (const auto& [key, rec] : g_r.static_casters) { if (rec.clip != (phase == 1)) { continue; } auto mit = g_r.meshes.find(rec.mesh); // Fingerprint mismatch = the arena address was reused for // different content; drawing it would raster a foreign mesh into // the map. The record refreshes the next time the item is // actually seen. if (mit == g_r.meshes.end() || mit->second.fingerprint != rec.fingerprint) { continue; } // Cull by the mesh bbox footprint in (far) map space. float umin = std::numeric_limits::max(), umax = -umin; float vmin = umin, vmax = -umin; for (int corner = 0; corner < 8; ++corner) { const float px = (corner & 1) ? rec.bbox_max[0] : rec.bbox_min[0]; const float py = (corner & 2) ? rec.bbox_max[1] : rec.bbox_min[1]; const float pz = (corner & 4) ? rec.bbox_max[2] : rec.bbox_min[2]; float w[3]; for (int a = 0; a < 3; ++a) { w[a] = px * rec.world[0 + a] + py * rec.world[4 + a] + pz * rec.world[8 + a] + rec.world[12 + a]; } const float u = rows[0] * w[0] + rows[1] * w[1] + rows[2] * w[2] + rows[3]; const float v = rows[4] * w[0] + rows[5] * w[1] + rows[6] * w[2] + rows[7]; umin = std::min(umin, u); umax = std::max(umax, u); vmin = std::min(vmin, v); vmax = std::max(vmax, v); } if (umax < -cull || umin > cull || vmax < -cull || vmin > cull) { continue; } float constants[52] = {}; std::memcpy(constants, rec.world, sizeof(rec.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 += rec.world[r * 4 + k] * tvp[k * 4 + col]; } mvp[r * 4 + col] = sum; } } cmd->SetRootConstants(0, 52, constants, 0); cmd->SetBufferSrv(3, g_r.bone_ring, bone_region); if (phase == 1) { nrhi::TextureView* srv = LookupResolvedTexture(rec.diffuse_tex); cmd->SetTexture(1, srv != nullptr ? srv : g_r.white.srv); } 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 : rec.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.static_sun, nrhi::ResourceState::kRenderTarget, nrhi::ResourceState::kPixelShaderResource); cmd->FlushBarriers(); g_r.static_sun_in_srv = true; g_r.static_sun_valid = true; } 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; // Alpha-tested caster (dynamicobject.alphatest): draws with the clip // PSO and the item's diffuse bound so the cutout silhouette casts, // not the full card quad. bool clip; nrhi::TextureView* clip_srv; }; std::vector casters; bool any_clip = false; 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). Moreover the cascade transforms are a stylized // near-vertical projection serving a ~12 m height window; tall // static geometry projected through them shadows its own plan-view // footprint. Live static shade comes from the separate sun-aligned // static shadow map instead (RenderStaticSunMap). 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, false, nullptr}; if (item.dynobj == 2) { c.clip = true; c.clip_srv = LookupResolvedTexture(item.diffuse_tex); any_clip = true; } 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)}); // Two phases per cascade: opaque casters, then the alpha-tested // set with the clip PSO and per-item diffuse. for (int phase = 0; phase < (any_clip ? 2 : 1); ++phase) { cmd->SetPipeline(phase == 0 ? g_r.pso_shadow_caster : g_r.pso_shadow_caster_clip); for (const Caster& c : casters) { if (c.clip != (phase == 1)) { continue; } 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)); if (phase == 1) { cmd->SetTexture(1, c.clip_srv != nullptr ? c.clip_srv : g_r.white.srv); } 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 && REXCVAR_GET(skate3_native_render_scene_perf_log)) { 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={}] water[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(), scene.water_valid, g_water_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()); } } // ---- Graphics build-up showcase ------------------------------------------- // Sequencer for the layer-by-layer showcase (cvar // skate3_native_render_scene_showcase / the bind_skate3_showcase hotkey): // one wipe strips the frame to clay geometry, then each following wipe // reveals the next visual layer, a vertical split sweeping left-to-right // with BOTH sides rendered live (per-pixel stage selection in the shaders, // no frozen frames). The split state rides the per-frame b1 rows // (scene.hlsl sh_v2.yzw) and g_r.showcase_rows (the SSR composite reads it // from root constants); every consumer treats an all-zero row as "showcase // off". Runs on the render thread once per frame at b1 staging. The layer // mask encoding matches the shader contract in scene.hlsl ShowcaseMask; // the reveal order and grouping come from the user-editable // skate3_native_render_scene_showcase_order cvar (kShowcaseLayers tokens). static void TickShowcase(uint32_t output_width, bool hdr_on) { // One reveal step: the b1 row encoding (256 + the cumulative layer mask; // 0 = the full render) and its log label. struct Step { float value; std::string label; }; struct State { bool active = false; std::vector steps; int logged = -1; double wipe_s = 3.0; double hold_s = 2.5; PerfClock::time_point t0; }; static State s; float* rows = g_r.showcase_rows; rows[0] = rows[1] = rows[2] = 0.0f; const bool want = REXCVAR_GET(skate3_native_render_scene_showcase); if (!s.active) { if (!want) { return; } // Layer availability: the material looks need nothing; the shadow and // post layers join only when their feature is enabled (the post gates // live in the HDR tonemap, so they need the HDR chain). The subtractive // layers need their content published/composited at all. const auto layer_available = [&](uint32_t bit) -> bool { switch (bit) { case 8u: return REXCVAR_GET(skate3_native_render_scene_shadows); case 16u: return hdr_on && REXCVAR_GET(skate3_native_render_scene_ssao); case 32u: return hdr_on && REXCVAR_GET(skate3_native_render_scene_ssr); case 64u: return hdr_on && (REXCVAR_GET(skate3_native_render_scene_shafts) || REXCVAR_GET(skate3_native_render_scene_haze)); case 128u: return hdr_on && REXCVAR_GET(skate3_native_render_scene_bloom); case 256u: return REXCVAR_GET(skate3_native_render_scene_decals); case 512u: return REXCVAR_GET(skate3_native_render_scene_dynamic_items); default: return true; } }; uint32_t avail_mask = 0; for (const auto& layer : kShowcaseLayers) { if (layer_available(layer.bit)) { avail_mask |= layer.bit; } } // Build the reveal steps from the user-ordered layer list (syntax in // the skate3_native_render_scene_showcase_order cvar description): // comma-separated steps, '+' joins layers into one wipe, '-' prefix = // disabled. Unknown, unavailable and duplicate tokens drop out; steps // left empty are skipped. The run opens ON black (mask bit 1024; a // snap, not a wipe - see the step-0 timing below), wipes clay in from // it, and closes by wiping back out to black. The >= hold_s stretches // of pure black double as machine-findable cut markers for screen // recordings (local/scripts/cut_showcase_clips.py pairs them with // ffmpeg blackdetect). s.steps.clear(); s.steps.push_back({float(256u + 1024u), "black (start marker)"}); s.steps.push_back({256.0f, "clay geometry"}); uint32_t cum = 0; const std::string order = REXCVAR_GET(skate3_native_render_scene_showcase_order); size_t pos = 0; while (pos <= order.size()) { size_t end = order.find(',', pos); if (end == std::string::npos) { end = order.size(); } const std::string group = order.substr(pos, end - pos); pos = end + 1; uint32_t add = 0; std::string label; size_t g = 0; while (g <= group.size()) { size_t ge = group.find('+', g); if (ge == std::string::npos) { ge = group.size(); } std::string tok = group.substr(g, ge - g); g = ge + 1; while (!tok.empty() && (tok.front() == ' ' || tok.front() == '\t')) { tok.erase(tok.begin()); } while (!tok.empty() && (tok.back() == ' ' || tok.back() == '\t')) { tok.pop_back(); } for (char& ch : tok) { if (ch >= 'A' && ch <= 'Z') { ch = char(ch + 32); } } if (tok.empty() || tok[0] == '-') { continue; // disabled layer (kept in the string for its position) } for (const auto& layer : kShowcaseLayers) { if (tok == layer.token) { if ((avail_mask & layer.bit) != 0 && (cum & layer.bit) == 0 && (add & layer.bit) == 0) { add |= layer.bit; if (!label.empty()) { label += " & "; } label += layer.label; } break; } } } if (add != 0) { cum |= add; s.steps.push_back({float(256u + cum), std::move(label)}); } } // Subtractive layers (decals/grime, dynamic entities) left out of the // order or '-'disabled are not build-up beats: fold their bits into // every step, clay included, so their content renders from the first // frame exactly as in the non-showcase frame. An UNAVAILABLE layer // needs no fold; its feature cvar already removes the content itself. const uint32_t always = kShowcaseSubtractiveMask & avail_mask & ~cum; if (always != 0u) { for (Step& st : s.steps) { st.value = float(256u + ((uint32_t(st.value) - 256u) | always)); } } // A final full-render reveal when the list leaves layers unrevealed // (the materials look subsumes the albedo/lighting bits, so those two // stop counting once materials is in; the subtractive bits are either // revealed steps or folded in above, so they never force the step). const bool covered = (cum & 4u) != 0 && ((avail_mask & ~cum) & 0xF8u) == 0u; if (!covered) { s.steps.push_back({0.0f, "full render"}); } // Closing blackout bookend (the end-of-run cut marker). s.steps.push_back({float(256u + 1024u), "black (end marker)"}); // Durations snapshot at start so a live cvar edit cannot jump the // elapsed-time -> step mapping mid-run. s.wipe_s = std::max(0.2, REXCVAR_GET(skate3_native_render_scene_showcase_wipe)); s.hold_s = std::max(0.0, REXCVAR_GET(skate3_native_render_scene_showcase_hold)); s.t0 = PerfClock::now(); s.logged = -1; s.active = true; REXLOG_INFO( "native-scene: showcase started ({} steps, wipe {:.1f}s, hold " "{:.1f}s, hdr={}, order=\"{}\")", s.steps.size(), s.wipe_s, s.hold_s, hdr_on ? 1 : 0, order); } if (!want) { s.active = false; REXLOG_INFO("native-scene: showcase cancelled"); return; } const double elapsed = std::chrono::duration(PerfClock::now() - s.t0).count(); const double per_step = s.wipe_s + s.hold_s; // Step 0 (the blackout start bookend) SNAPS to black instead of wiping // from the live frame and lasts hold_s only: the recording cut lands on // a hard black edge, and the build-up proper starts with clay wiping in // from black. int idx; double phase; if (elapsed < s.hold_s) { idx = 0; phase = s.wipe_s; // past the wipe window: hold, no split } else { const double e = elapsed - s.hold_s; idx = 1 + int(e / per_step); phase = e - double(idx - 1) * per_step; } if (idx >= int(s.steps.size())) { s.active = false; REXCVAR_SET(skate3_native_render_scene_showcase, false); REXLOG_INFO("native-scene: showcase finished"); return; } const float cur = s.steps[idx].value; if (idx != s.logged) { REXLOG_INFO("native-scene: showcase step {}/{} - {}", idx + 1, s.steps.size(), s.steps[idx].label); s.logged = idx; } const float prev = idx == 0 ? 0.0f : s.steps[idx - 1].value; if (phase < s.wipe_s) { float f = float(phase / s.wipe_s); f = f * f * (3.0f - 2.0f * f); // ease the sweep in and out rows[0] = cur; rows[1] = prev; // Overshoot slightly so the divider line exits the screen edge cleanly. rows[2] = f * float(output_width) * 1.02f; } else { rows[0] = rows[1] = cur; rows[2] = 0.0f; } } bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_data*/) { if (!SceneEnabled() || (context.backend != NativeGuestOutputBackend::kD3D12 && context.backend != NativeGuestOutputBackend::kVulkan)) { return false; } { // Debug stress driver (skate3_native_render_scene_maxq_cycle): cycles // the max-quality toggle to exercise the hot rebuild path. static uint64_t s_maxq_cycle_frame = 0; skate3::MaxQualityAutoCycle(++s_maxq_cycle_frame); } // 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"); } const std::string t2(REXCVAR_GET(skate3_native_render_scene_trace_2d)); const uint32_t parsed2 = t2.empty() ? 0u : uint32_t(std::strtoul(t2.c_str(), nullptr, 16)); if (parsed2 != g_trace_2d_w1) { g_trace_2d_w1 = parsed2; REXLOG_INFO("2d-trace: w1={:08X} {}", parsed2, parsed2 ? "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); RenderStaticSunMap(context, scene, bone_region, debug_mode, frame_number); 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_DEBUG( "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; } } // PCSS soft-shadow rows (sh_pcss / sh_pcss2, cb[136..143]). Filled // whenever the light rows are valid; the static sun-shadow sampler // shares the angular knobs even on frames with no dynamic casters. if (scene.shadow_valid) { cb[136] = REXCVAR_GET(skate3_native_render_scene_shadow_pcss) ? 1.0f : 0.0f; cb[137] = std::tan( 0.5f * 0.01745329f * float(REXCVAR_GET(skate3_native_render_scene_shadow_pcss_sun_deg))); cb[138] = float(REXCVAR_GET(skate3_native_render_scene_shadow_pcss_max_m)); cb[139] = 0.0f; // atlas slope bias unused (dynamic casters only) cb[140] = float(REXCVAR_GET(skate3_native_render_scene_shadow_pcss_blocker_m)); const float zlen = std::sqrt(sh[16] * sh[16] + sh[17] * sh[17] + sh[18] * sh[18]); const float xlen = std::sqrt(sh[0] * sh[0] + sh[1] * sh[1] + sh[2] * sh[2]); cb[141] = zlen > 1e-8f ? 1.0f / zlen : 0.0f; cb[142] = xlen; cb[143] = float( REXCVAR_GET(skate3_native_render_scene_shadow_pcss_min_texel)); } // Native static sun-shadow rows (nsm_x/y/z + params, cb[144..163]); // nsm_p.x = 0 disables the term in every branch. if (g_r.static_sun_valid) { std::memcpy(cb + 144, g_r.nsm_rows, sizeof(g_r.nsm_rows)); cb[156] = std::clamp( float(REXCVAR_GET(skate3_native_render_scene_shadow_static_strength)), 0.0f, 1.0f); cb[157] = 1.0f / g_r.nsm_radius; // far-tile uc units per meter cb[158] = g_r.nsm_depth_range; // meters per depth unit // Filter floor: 2 physical texels (uc spans [-1,1] over one tile); // the map has no dilation blur, so a sub-texel kernel exposes raw // stair-step aliasing at full strength; two texels of // rotated-poisson filtering dissolve the steps into a smooth edge. cb[159] = 2.0f * 2.0f * float(REXCVAR_GET( skate3_native_render_scene_shadow_pcss_min_texel)) / float(std::max(1u, g_r.static_sun_size)); const float bias_m = float( REXCVAR_GET(skate3_native_render_scene_shadow_static_bias)); cb[160] = bias_m / g_r.nsm_depth_range; // base receiver bias cb[161] = bias_m / g_r.nsm_depth_range; // slope-scaled term cb[162] = 0.0f; // reserved (cascade ratios are shader constants) cb[163] = float(std::max(1u, g_r.static_sun_size)); // tile px } // 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, yzw = the build-up showcase split state (stage left/right // of the split + its position in output pixels; zeros = showcase off). cb[60] = float(REXCVAR_GET(skate3_native_render_scene_world_v2_tan_sign)); TickShowcase(context.guest_output_width, hdr_on); cb[61] = g_r.showcase_rows[0]; cb[62] = g_r.showcase_rows[1]; cb[63] = g_r.showcase_rows[2]; // 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)); } } // flowingwateralpha m_params rows (wat_p0..wat_p3 at cb[76..91]): // m_params[0..2] verbatim, then (time, mask threshold, spec power, // alpha floor). Consumed by the exact water branch (cam_pos.w = -30), // which is only selected per draw when scene.water_valid. if (scene.water_valid) { std::memcpy(cb + 76, scene.water_rows, 12 * sizeof(float)); // c11..c13 cb[88] = scene.water_rows[16]; // g_fAnimationTime (c15.x) cb[89] = scene.water_rows[13]; // mask threshold (c14.y) cb[90] = scene.water_rows[14]; // spec power (c14.z) cb[91] = scene.water_rows[15]; // alpha floor (c14.w) } // ocean_defaultPS rows (oc_mean/oc_w0..5/oc_p0..2 at cb[92..131]) and // the oceanreflection fade row (orf at cb[132..135]); consumed by the // exact ocean branches (cam_pos.w = -31 / -32). if (scene.ocean_valid) { std::memcpy(cb + 92, scene.ocean_rows, sizeof(scene.ocean_rows)); } if (scene.oceanrefl_valid) { std::memcpy(cb + 132, scene.oceanrefl_rows, sizeof(scene.oceanrefl_rows)); } 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); // v2 material table default (t8/t9 white) + the static sun-shadow map // at its third entry (t10); every lit branch samples it, so the table // must be bound for all draws, not only the v2/ocean re-binds. nrhi::TextureView* t8_default[3] = { g_r.white.srv, g_r.white.srv, g_r.static_sun_valid ? g_r.static_sun_srv : g_r.white.srv}; cmd->SetTextures(8, t8_default, 3); } nrhi::TextureView* const nsm_view = g_r.static_sun_valid ? g_r.static_sun_srv : 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; // tint.a < 0 marks dynamic-entity items for the showcase dyn layer // (ps_main clips them until the bit reveals); the solid-color // early-out only ever reads tint.a > 0. constants[35] = item.dyn_entity ? -1.0f : 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; // 18 float4 rows = 288 bytes -> a 512-byte slot keeps the next // allocation 256-aligned (CBV offset requirement). if (offset + 512u <= 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 + 512u; 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 && // Fam 14 needs the frame's scroll rows (time + multiplier); the // tone chain would multiply by zero without them; the legacy // shading covers the capture-less frames. (item.env_family != 14 || scene.scroll_valid)) { 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.char_family >= 1 && item.char_family <= 2)) && 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). // Character fams 1/2 (defaultcharacter / CAC skin, face, cloth, // lenses) do the same with their DXT5nm `normal` channel, the // garment crease / skin pore detail the emulated render shows; // overlay.z > 0 tells the char branch the map resolved. 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.water_flowing) && 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; } } // Character skin/face spec-mask map (the cacstamp `specular` channel): // rides the free decal slot like the env families. Written to // overlay.w below: 3 = mask bound, 2 = channel present but not yet // decoded (the PS masks the spec OFF; the DXT1 skin diffuse's opaque // alpha would read as a full-white mask), 0 = no channel (the PS masks // by diffuse alpha^2, cloth/jeans). char_alpha lenses keep the decal // slot for their coverage texture. float char_spec = 0.0f; if ((item.char_family == 1 || item.char_family == 2) && !item.char_alpha && item.spec_tex != 0) { const GuestTexture* spec = resolve_texture(item.spec_tex, 6); if (spec != &g_r.white && spec->valid && spec->srv_mips != 0) { decal_tex = spec; char_spec = 3.0f; } else { char_spec = 2.0f; } } 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; } } // Exact ocean (fam 31): the second PCA normal component rides the free // t8 pair slot and the 16x16 macro overlay the t9 slot (water items use // neither). v2_flags stays 0; the ocean branch signals its binds // through the overlay row. bool ocean_n2 = false; bool ocean_ov = false; if (item.water_ocean == 1 && debug_mode == 0 && scene.shadow_valid && scene.ocean_valid) { if (item.water_normal2 != 0) { const GuestTexture* n2 = resolve_texture(item.water_normal2, 7); if (n2 != &g_r.white && n2->valid && n2->srv_mips != 0) { v2_detail = n2; ocean_n2 = true; } } if (item.macro_tex != 0) { const GuestTexture* mo = resolve_texture(item.macro_tex, 6); if (mo != &g_r.white && mo->valid && mo->srv_mips != 0) { v2_spec2 = mo; ocean_ov = true; } } } 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 (char_spec != 0.0f) { // Character fam 1/2 spec-mask state (see the bind above); chars // never take the is_decal/spec_bound paths, so the slot is theirs. constants[47] = char_spec; } if (item.char_family == 1 || item.char_family == 2) { // misc.y = normal/spec-map LOD bias to the console's 640p-gradient // mip (the fam 5/6 cube-bias rationale): at 4K the shader's UV // gradients pick ~1.75 mips finer than the game's own render, and // mip-0 sampling keeps fine wrinkle noise the console filters away // - the authored garment folds read weaker than the emulated // reference without this. constants[49] = log2f(std::max(1.0f, float(context.guest_output_height) / 640.0f)); } // Exact flowingwateralpha branch (cam_pos.w = -30): the canal/waterfall // shader hand-ported from the game's own PS and verified per-pixel // against the ucode. Requires the frame m_params rows (b1), the shared // world frame rows (exposure/sun/fog at b1, scene.shadow_valid) and the // ripple normal map resolved in the macro slot; the legacy water // shading covers every gap (and every non-flowing water material). const bool water_exact = item.water && item.water_flowing && debug_mode == 0 && scene.shadow_valid && scene.water_valid && macro_tex != &g_r.white; // Exact ocean surface (fam 31): both PCA component maps must resolve // (t3 = comp 0 via the water_normal macro-slot bind, t8 = comp 1); the // legacy water shading covers every gap. The horizon sheet (fam 32) // needs only its baked texture (t0) and the captured fade row. const bool ocean_exact = item.water && item.water_ocean == 1 && debug_mode == 0 && scene.shadow_valid && scene.ocean_valid && macro_tex != &g_r.white && ocean_n2; const bool oceanrefl_exact = item.water && item.water_ocean == 2 && debug_mode == 0 && scene.shadow_valid && scene.oceanrefl_valid; if (water_exact) { // overlay.x = spec/reflection masks bound at t4, overlay.y = real // environment cube at t6, overlay.z = ripple resolved (gate above). constants[39] = -30.0f; constants[44] = spec_bound ? 1.0f : 0.0f; constants[45] = cube_tex != &g_r.white_cube ? 1.0f : 0.0f; constants[46] = 1.0f; constants[47] = 0.0f; g_water_drawn.fetch_add(1, std::memory_order_relaxed); } else if (ocean_exact) { // overlay.x = second PCA component at t8 (gate above guarantees it), // overlay.y = real environment cube at t6, overlay.z = macro overlay // at t9, overlay.w = macroOverlayUVScale. constants[39] = -31.0f; constants[44] = 1.0f; constants[45] = cube_tex != &g_r.white_cube ? 1.0f : 0.0f; constants[46] = ocean_ov ? 1.0f : 0.0f; constants[47] = item.macro_scale; g_water_drawn.fetch_add(1, std::memory_order_relaxed); } else if (oceanrefl_exact) { constants[39] = -32.0f; constants[44] = 0.0f; constants[45] = 0.0f; constants[46] = 0.0f; constants[47] = 0.0f; g_water_drawn.fetch_add(1, std::memory_order_relaxed); } else 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 (water_exact || ocean_exact || oceanrefl_exact) { // misc.y = cube LOD bias (same 640p-gradient rationale as fams 5/6); // fog comes from the shared b1 rows; misc.zw stay free. misc.x keeps // the water marker for the SSR G-buffer restage. constants[49] = log2f(std::max(1.0f, float(context.guest_output_height) / 640.0f)); constants[50] = 0.0f; constants[51] = 0.0f; } else 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 (item.env_family == 14 && constants[39] < -13.5f) { // Fam 14 (scrollincandescent, the LED chyron): misc.xy = the frame's // UV scroll offset (g_fAnimationTime x the material's channel speeds, // wrapped to one texture period here; the shader adds a small // bounded offset instead of losing uv precision to a large raw // time x speed product), misc.z = the material multiplier // m_params[0].y. const float ou = scene.scroll_rows[0] * item.scroll_u; const float ov = scene.scroll_rows[0] * item.scroll_v; constants[48] = ou - std::floor(ou); constants[49] = ov - std::floor(ov); constants[50] = scene.scroll_rows[1]; } 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); the exact // ocean rides the same pair (t8 = second PCA component, t9 = overlay). if (v2_flags != 0 || ocean_n2 || ocean_ov) { // Three-entry bind: re-pairing only t8/t9 would reset the t10 static // sun-shadow map to the backend fallback. nrhi::TextureView* t8_views[3] = { (v2_detail != nullptr ? v2_detail : &g_r.white)->srv, (v2_spec2 != nullptr ? v2_spec2 : &g_r.white)->srv, nsm_view}; cmd->SetTextures(8, t8_views, 3); } // 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_DEBUG( "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 { // Fallback to the raw resident shape. Rate-limited detail log: an // ALTERNATION of these with blended frames renders the garment // hopping between the play-clock drape and the zero-lag drape. g_ropa_blend_miss.fetch_add(1, std::memory_order_relaxed); static std::atomic s_blend_miss_log{0}; const uint32_t ln = s_blend_miss_log.fetch_add(1, std::memory_order_relaxed); if (ln < 64 || (ln & 63u) == 0) { REXLOG_DEBUG( "native-scene: ropa blend MISS mesh={:08X} sc={} ng={} " "total={:.2f} stride={} ring={} ring_off={} (n={})", item.mesh, item.shape_count, ng, total, buffers.vb_view.stride, rit != g_r.ropa_shapes.end() ? rit->second.size() : size_t(0), g_r.ropa_ring_offset, ln); } } } 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_DEBUG( "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_DEBUG( "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_DEBUG( "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; } } }; // The exact ocean surface (fam 31) is an OPAQUE draw in the game (its // PS outputs alpha 0 and it writes z-prepass depth); routing it through // the sorted alpha sub-pass would composite the blended horizon sheet // (ocean.reflection) UNDER it. The legacy fallback also renders opaque, // so the route holds while textures/rows are still resolving. const bool ocean_opaque = item.water && item.water_ocean == 1 && scene.shadow_valid && scene.ocean_valid; if ((item.transparent || item.water || hair_blend || glass_blend || cac_alpha_blend || refl_trans_blend || char_fade_blend) && !ocean_opaque && 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); // 2D-resolver trace (trace_2d cvar, matched on fetch word 1): logs the // per-resolve entry state + route below, and registers the words key so // the worker-commit trace covers its heals too. const bool tr2d = g_trace_2d_w1 != 0 && fetch[1] == g_trace_2d_w1; if (tr2d) { g_trace_keys.insert(key); } // 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 && 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. Incomplete decodes (truncated // tiled-mip copy) re-decode here too, like the 3D route revalidator; // the 2D-only art (menu thumbnails) has no other heal path. const uint64_t fp = SampleProbeFingerprint(base, it->second); if ((fp != 0 && fp != it->second.payload_fp) || it->second.incomplete) { // Rolling-capped heal telemetry for menu-class art (the map-switch // thumbnail wrong-image reports): names the key, the route taken // and the fingerprint transition. static std::atomic s_2d_heal_logs{0}; const bool log_heal = uint64_t(px_w) * px_h >= 32768 && s_2d_heal_logs.fetch_add(1, std::memory_order_relaxed) < 64; // 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 (log_heal) { REXLOG_INFO( "native-scene: 2D content heal {}x{} key={:016X} fp {:016X}->" "{:016X} inc={} route={}", px_w, px_h, key, it->second.payload_fp, fp, it->second.incomplete ? 1 : 0, inline_redecode ? "inline" : "async"); } 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; } if (tr2d) { REXLOG_INFO("2d-trace: f{} key={:016X} inplace update REFUSED - " "retire + fresh decode", frame_number, key); } 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); if (tr2d) { REXLOG_INFO("2d-trace: f{} key={:016X} SKIP (async first sight)", frame_number, key); } 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; if (tr2d) { REXLOG_INFO("2d-trace: f{} key={:016X} INLINE decode valid={} inc={} " "fp={:016X}", frame_number, key, gt.valid ? 1 : 0, gt.incomplete ? 1 : 0, gt.payload_fp); } it = g_r.tex_store.emplace(key, gt).first; } if (it != g_r.tex_store.end() && (tr2d || (g_in_menus_frame.load(std::memory_order_relaxed) && uint64_t(px_w) * px_h >= 32768))) { // Menu thumbnail-class serve log: per buffer (fetch word 1), log when // EITHER the served cache content (stored fp) OR the live guest // content (probe fp) changes. A served-fp that lags the live-fp is // exactly the stale-thumbnail bug; the buffer holds the new area's // art while we keep serving the previous decode. static std::mutex s_2d_log_mutex; static std::unordered_map s_2d_seen; // w1 -> stored^live const uint64_t live_fp = SampleProbeFingerprint(base, it->second); const uint64_t sig = it->second.payload_fp ^ (live_fp * 3); std::lock_guard lk(s_2d_log_mutex); auto sit = s_2d_seen.find(fetch[1]); if (sit == s_2d_seen.end() || sit->second != sig) { s_2d_seen[fetch[1]] = sig; REXLOG_INFO( "2d-thumb: f{} w1={:08X} {}x{} key={:016X} served_fp={:016X} " "live_fp={:016X}{} valid={} inc={} recheck=f{}", frame_number, fetch[1], px_w, px_h, key, it->second.payload_fp, live_fp, (live_fp != 0 && live_fp != it->second.payload_fp) ? " STALE" : "", it->second.valid ? 1 : 0, it->second.incomplete ? 1 : 0, it->second.recheck_frame); } } 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); // intensity.zw = showcase blackout gate (the guest row's unused // lanes): z = split x, w = left|right visibility bits. The spline PS // draws outside the scene shaders, so the blackout stage must gate // it here or the neon lines leak over the black recording bookends. const auto side_visible = [](float v) { return v < 255.5f || ((uint32_t(v + 0.5f) - 256u) & 1024u) == 0u; }; spline_consts[18] = g_r.showcase_rows[2]; spline_consts[19] = float((side_visible(g_r.showcase_rows[0]) ? 1 : 0) | (side_visible(g_r.showcase_rows[1]) ? 2 : 0)); 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; } // ---- Volumetric lighting (hdr.hlsl ps_vol_*) ---- // Shadow-marched sun shafts (per-step visibility against the CSM atlas + // the static world-shadow map), plus the constant staging for the // directional haze; both terms join in ps_tonemap after the AO multiply, // so they bloom and tonemap like scene light. if (hdr_on && use_depth && !loading_native && ApplyVolumetricPass(context, cmd, scene, viewport, scissor, ssao_ran, frame_number)) { 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, frame_number); 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_DEBUG( "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"); } } } // Hor+ ultrawide: the 2D stream and the guest screenshot raster are // authored for the 16:9 frontbuffer. On a wide output the 2D replay // scales clip-space X by this factor (centering ortho HUD/menu/FMV draws // in a pillarboxed 16:9 band and narrowing perspective SimpleDraw markers // to match the widened scene projection - one operation covers both), and // the photo grab center-crops the same band. const float out_aspect2d = float(context.guest_output_width) / float(context.guest_output_height); const float wide_2d_scale = out_aspect2d > (16.0f / 9.0f) * 1.01f ? (16.0f / 9.0f) / out_aspect2d : 1.0f; // ---- 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); // Source UV rect (second register): the centered 16:9 band on a // wide output; the guest screenshot raster is 16:9. const float grab_consts[8] = {1.0f / float(context.guest_output_width), 1.0f / float(context.guest_output_height), 0.0f, 0.0f, wide_2d_scale, 1.0f, (1.0f - wide_2d_scale) * 0.5f, 0.0f}; cmd->SetRootConstants(0, 8, 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); // Full-frame source rect: the blur squeezes the whole wide frame into // the 1152x640 blur space and the final stretch-back undoes it. const float d_consts[8] = {1.0f / float(context.guest_output_width), 1.0f / float(context.guest_output_height), 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f}; cmd->SetRootConstants(0, 8, 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)); // Wide output: scale the staged projection's clip.x row (the VS // computes clip.x = dot(wp, c0)) so the 16:9-authored stream lands // centered (see wide_2d_scale). if (wide_2d_scale != 1.0f) { constants[0] *= wide_2d_scale; constants[1] *= wide_2d_scale; constants[2] *= wide_2d_scale; constants[3] *= wide_2d_scale; } // 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].z: clip-space X extent of the 2D band, the edge-snap // boundary in the VS (0 = full target). m[9].w: 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] = wide_2d_scale != 1.0f ? wide_2d_scale : 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 bool SceneFailed() { return g_r.failed; } void ResetSceneFailure() { if (g_r.failed) { g_r.failed = false; REXLOG_INFO( "native-scene: sticky pipeline failure cleared; the next frame " "retries the full pipeline build"); } } 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() {} bool SceneFailed() { return false; } void ResetSceneFailure() {} } // namespace skate3::native_scene #endif // REX_HAS_D3D12 || REX_HAS_VULKAN