10679 lines
484 KiB
C++
10679 lines
484 KiB
C++
// 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 <array>
|
|
#include <atomic>
|
|
#include <algorithm>
|
|
#include <bit>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <limits>
|
|
#include <condition_variable>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
|
|
#if defined(_WIN32)
|
|
#include <windows.h>
|
|
#endif
|
|
|
|
#include <rex/cvar.h>
|
|
#include <rex/graphics/native_guest_renderer.h>
|
|
#include <rex/kernel/guest_presence.h>
|
|
#include <rex/logging.h>
|
|
|
|
#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 <rex/graphics/native_rhi.h>
|
|
#include <rex/graphics/pipeline/texture/info.h>
|
|
#include <rex/graphics/pipeline/texture/util.h>
|
|
#include <rex/graphics/xenos.h>
|
|
#if defined(_WIN32)
|
|
#include <windows.h>
|
|
#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<uint64_t> 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<std::pair<uint64_t, uint64_t>> 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<StagedTexResult> 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<PrewarmResult> 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<PrewarmEntry> 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<float>(sign);
|
|
// subnormal
|
|
while (!(mant & 0x400)) {
|
|
mant <<= 1;
|
|
--exp;
|
|
}
|
|
++exp;
|
|
mant &= 0x3FF;
|
|
} else if (exp == 31) {
|
|
return std::bit_cast<float>(sign | 0x7F800000u | (mant << 13));
|
|
}
|
|
return std::bit_cast<float>(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<uint8_t> vb_scratch;
|
|
static thread_local std::vector<uint8_t> 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<float> 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<float*>(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<float>(SwapU32(*reinterpret_cast<const uint32_t*>(p)));
|
|
y = std::bit_cast<float>(SwapU32(*reinterpret_cast<const uint32_t*>(p + 4)));
|
|
z = std::bit_cast<float>(SwapU32(*reinterpret_cast<const uint32_t*>(p + 8)));
|
|
break;
|
|
}
|
|
case 32: { // k_16_16_16_16_FLOAT
|
|
x = HalfToFloat(SwapU16(*reinterpret_cast<const uint16_t*>(p)));
|
|
y = HalfToFloat(SwapU16(*reinterpret_cast<const uint16_t*>(p + 2)));
|
|
z = HalfToFloat(SwapU16(*reinterpret_cast<const uint16_t*>(p + 4)));
|
|
break;
|
|
}
|
|
case 26: { // k_16_16_16_16 snorm character dequant
|
|
const auto s16 = [&](int off) {
|
|
return int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(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<const uint16_t*>(q)));
|
|
w = HalfToFloat(SwapU16(*reinterpret_cast<const uint16_t*>(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<float>(SwapU32(*reinterpret_cast<const uint32_t*>(q)));
|
|
w = std::bit_cast<float>(SwapU32(*reinterpret_cast<const uint32_t*>(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<const uint16_t*>(q)));
|
|
const int16_t sv = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(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<const uint16_t*>(q)));
|
|
const int16_t sv = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(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<const uint32_t*>(src_vb + size_t(v) * item.stride +
|
|
item.bw_offset));
|
|
bi = SwapU32(*reinterpret_cast<const uint32_t*>(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<const uint32_t*>(
|
|
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<const uint16_t*>(q)));
|
|
const int16_t sy = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(q + 2)));
|
|
const int16_t nx = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(q + 4)));
|
|
const int16_t ny = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(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<const uint16_t*>(q)));
|
|
const int16_t sy = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(q + 2)));
|
|
const int16_t nx = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(q + 4)));
|
|
const int16_t ny = int16_t(SwapU16(*reinterpret_cast<const uint16_t*>(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<int> 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<std::mutex> 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<uint16_t*>(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<const uint16_t*>(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<SheetTri> 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<char> 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<uint32_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<std::vector<uint8_t>> 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<uint8_t>& srcm = mips.back();
|
|
std::vector<uint8_t> 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<Plan> 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<uint8_t*>(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<uint32_t>(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<uint8_t> 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<uint32_t> 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<uint8_t*>(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<uint8_t> 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<uint32_t>(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<uint32_t> 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<uint32_t>(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<uint8_t> 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<uint8_t*>(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<uint8_t> 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<uint8_t> 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<uint8_t*>(device->Map(out.upload));
|
|
static thread_local std::vector<uint8_t> rgba; // level 0 of one face
|
|
static thread_local std::vector<uint8_t> down; // downsample scratch
|
|
static thread_local std::vector<uint8_t> 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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<PrewarmResult> done;
|
|
{
|
|
std::lock_guard<std::mutex> 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<PrewarmResult> 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<double>(
|
|
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<uint32_t> s_swap_logs{0};
|
|
static std::atomic<int64_t> s_swap_win{0};
|
|
const int64_t now_s =
|
|
std::chrono::duration_cast<std::chrono::seconds>(
|
|
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<uint64_t> 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<std::mutex> 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<std::chrono::nanoseconds>(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<bool> 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::nanoseconds>(
|
|
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<std::mutex> lock(g_prewarm_mutex);
|
|
g_prewarm_seen.clear();
|
|
g_prewarm_tex_seen.clear();
|
|
} else {
|
|
size_t queued = 0;
|
|
{
|
|
std::lock_guard<std::mutex> 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<std::mutex> 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<FrontEndManager>::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<std::chrono::nanoseconds>(
|
|
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<std::chrono::nanoseconds>(
|
|
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<bool> g_photo_grab_native_armed{false};
|
|
// Last successful guest write (PerfClock ns): the watchdog liveness signal.
|
|
std::atomic<int64_t> 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<bool> g_photo_display_yielding{false};
|
|
std::atomic<int64_t> 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<uint8_t> staging;
|
|
staging.resize(size_t(kW) * kH * 4);
|
|
for (uint32_t y = 0; y < kH; ++y) {
|
|
const uint32_t* row = reinterpret_cast<const uint32_t*>(
|
|
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<uint32_t*>(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<std::chrono::nanoseconds>(
|
|
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<std::chrono::nanoseconds>(
|
|
PerfClock::now().time_since_epoch())
|
|
.count(),
|
|
std::memory_order_relaxed);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
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::nanoseconds>(
|
|
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<float>::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<const char*>(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<Caster> 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<uint8_t*>(
|
|
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<const DrawItem*> 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<Step> 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<double>(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<std::chrono::nanoseconds>(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<std::mutex> 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<const FrameScene> 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<const FrameScene> s_loading_scene =
|
|
std::make_shared<const FrameScene>();
|
|
scene_ptr = s_loading_scene;
|
|
} else {
|
|
std::lock_guard<std::mutex> 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<std::mutex> lock(g_movie_mutex);
|
|
std::memcpy(movies, g_movies, sizeof(movies));
|
|
}
|
|
const int64_t movie_now_ns =
|
|
std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
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<std::mutex> 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<uint64_t>(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<const uint8_t*>(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<uint32_t> 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<float*>(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<const DrawItem*, uint32_t> 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<double>(std::chrono::steady_clock::now() - water_t0).count() -
|
|
std::floor(
|
|
std::chrono::duration<double>(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<uint32_t> 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<SsrGbufItem> 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<uint32_t> 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<uint32_t> 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<uint32_t> 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<uint32_t> 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<uint32_t, std::pair<float, float>> fold_cache;
|
|
auto fit = fold_cache.find(item.detail_tex);
|
|
if (fit == fold_cache.end()) {
|
|
std::pair<float, float> 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<float>* gv[DrawItem::kShapeGens] = {};
|
|
float gw[DrawItem::kShapeGens] = {};
|
|
int ng = 0;
|
|
float total = 0.0f;
|
|
uint64_t newest_seq = 0;
|
|
const std::vector<float>* 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<uint32_t> 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<float*>(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<uint32_t> 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<const DrawItem*> 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<std::pair<float, const DrawItem*>> 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<uint64_t, RouteProbe> 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<const uint8_t*>(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<uint32_t> 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<uint32_t> 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<uint32_t, uint8_t> 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<uint64_t> 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<std::chrono::nanoseconds>(
|
|
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<uint32_t> 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<uint32_t> 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<uint64_t> 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<uint32_t, uint64_t> 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<std::mutex> 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<SplineDraw> scene_spline;
|
|
{
|
|
std::lock_guard<std::mutex> 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<float*>(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<uint8_t*>(
|
|
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<std::chrono::microseconds>(
|
|
PerfClock::now() - tile_t0)
|
|
.count());
|
|
++g_r.grab_writes;
|
|
g_grab_last_write_ns.store(
|
|
std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
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<std::chrono::nanoseconds>(
|
|
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<Draw2d> scene_2d;
|
|
{
|
|
std::lock_guard<std::mutex> 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<uint32_t> 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<uint32_t> 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<uint32_t> 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<bool> 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<std::chrono::nanoseconds>(
|
|
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
|
|
|