scene: native loading screens and boot flow

This commit is contained in:
mchughalex 2026-07-13 14:15:45 +01:00
parent 302ba69241
commit ba50240655

View file

@ -151,6 +151,22 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_pause_native, true, "Skate 3",
"also skips the pause-entry cache clears, so unpausing costs " "also skips the pause-entry cache clears, so unpausing costs "
"nothing.") "nothing.")
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_loading_native, true, "Skate 3",
"Render post-startup loading screens natively, black backdrop "
"plus the game's own captured 2D loading UI, instead of "
"yielding to the emulated output. The loading-screen "
"housekeeping (cache clears, registration prewarm, takeover "
"arming) is unchanged; only the presented pixels switch source. "
"The boot flow before the first gameplay stays emulated.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_boot_native, true, "Skate 3",
"Extend native rendering to the game startup flow (intro videos, "
"boot frontend, the first load); i.e. drop the first-gameplay "
"prerequisite from the native menu/loading modes, and render the "
"pre-takeover boot frames as native 2D-over-black instead of "
"yielding. With this and the pause/loading modes on, the emulated "
"GPU output is never presented.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_snapshot_all_draws, false, "Skate 3", REXCVAR_DEFINE_BOOL(skate3_native_render_snapshot_all_draws, false, "Skate 3",
"Record the draw stream on every recorded frame instead of 2 of every " "Record the draw stream on every recorded frame instead of 2 of every "
"60 (large .draws.bin; for targeted investigations)") "60 (large .draws.bin; for targeted investigations)")
@ -6311,6 +6327,10 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
if (!SceneEnabled()) { if (!SceneEnabled()) {
return; return;
} }
// Published every frame (not just on world submissions): boot/menu frames
// carry only 2D, and the render thread's 2D texture decodes need the
// guest base from the very first natively rendered boot frame.
g_guest_base.store(base, std::memory_order_relaxed);
++g_guest_frame; // paces the world-item cache revalidation ++g_guest_frame; // paces the world-item cache revalidation
// Perf telemetry: guest frame interval + this frame's capture-hook cost. // Perf telemetry: guest frame interval + this frame's capture-hook cost.
static PerfClock::time_point s_last_frame_tp{}; static PerfClock::time_point s_last_frame_tp{};
@ -6376,7 +6396,6 @@ void BuildFrameScene(uint8_t* base, const SubmitRecord* records, size_t count) {
if (count == 0) { if (count == 0) {
return; return;
} }
g_guest_base.store(base, std::memory_order_relaxed);
// Multiple SceneRenderViews can submit per frame (main, shadow cascades, // Multiple SceneRenderViews can submit per frame (main, shadow cascades,
// reflections). Pick the perspective one (proj[2][3] == 1 in row-vector // reflections). Pick the perspective one (proj[2][3] == 1 in row-vector
@ -12947,12 +12966,25 @@ void PrewarmCommit(const NativeGuestOutputRenderContext& context,
.count())); .count()));
} }
// 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): // Menus / pause / loading yield gate (see the comment at the call site):
// returns true when RenderScene must yield this frame to the emulated // returns true when RenderScene must yield this frame to the emulated
// output. Handles the cache clears on entry and the takeover re-arm + // output. Handles the cache clears on entry and the takeover re-arm +
// loading-screen pipeline build / prewarm commit while yielded. // 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) { bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
static bool s_yielding = false; static bool s_in_loading = false;
static bool s_seen_gameplay = false; static bool s_seen_gameplay = false;
static bool s_pause_native = false; static bool s_pause_native = false;
const bool in_menus = rex::graphics::ultrawide_debug::Skate3GameplayContextValue() == 0; const bool in_menus = rex::graphics::ultrawide_debug::Skate3GameplayContextValue() == 0;
@ -12967,8 +12999,12 @@ bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
// go stale (a load was picked from the pause menu, or the game stops // 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 // redrawing the world), this degrades to the yield path below within
// ~300 ms, cache clears and all. // ~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; bool pause_native = false;
if (in_menus && s_seen_gameplay && if (in_menus && (s_seen_gameplay || boot_native) &&
REXCVAR_GET(skate3_native_render_scene_pause_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 last_ns = g_last_publish_ns.load(std::memory_order_relaxed);
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>( const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
@ -12996,13 +13032,22 @@ bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
g_draws_2d_dropped.load(std::memory_order_relaxed)); g_draws_2d_dropped.load(std::memory_order_relaxed));
} }
} }
const bool yield = in_menus && !pause_native; const bool in_loading = in_menus && !pause_native;
if (yield != s_yielding) { // Loading screens themselves render natively too (black + the captured 2D
s_yielding = yield; // loading UI) when enabled, everything after the first gameplay. The
if (yield) { // 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( REXLOG_INFO(
"native-scene: menus/loading - yielding to emulated output " "native-scene: menus/loading - {} (presence context)",
"(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 // Arena addresses are reused across map loads: let the next load's
// registrations re-queue meshes (and re-stage textures) at reused // registrations re-queue meshes (and re-stage textures) at reused
// addresses, and drop the cached item cores built from them. // addresses, and drop the cached item cores built from them.
@ -13025,7 +13070,7 @@ bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
g_prewarm_dropped.load(std::memory_order_relaxed), queued); g_prewarm_dropped.load(std::memory_order_relaxed), queued);
} }
} }
if (yield) { if (in_loading) {
// Re-arm the takeover gate for the next stretch of gameplay (map // Re-arm the takeover gate for the next stretch of gameplay (map
// load, unpause). // load, unpause).
g_warmup_armed.store(true, std::memory_order_relaxed); g_warmup_armed.store(true, std::memory_order_relaxed);
@ -13043,6 +13088,11 @@ bool YieldForMenus(const NativeGuestOutputRenderContext& context) {
if (!g_r.failed && g_r.pso == nullptr) { if (!g_r.failed && g_r.pso == nullptr) {
EnsurePipeline(context); 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 // THE loading-screen heavy lifting runs on the prewarm decode WORKER
// POOL (a serial render-thread drain both tanked the loading spinner // 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 // to ~13 fps and still left 1500 of a map's ~2600 meshes undecoded at
@ -13632,8 +13682,39 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
.count()); .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);
const bool ready =
g_scene && g_scene->generation >= g_warmup_fresh_generation &&
g_scene->items.size() >=
size_t(REXCVAR_GET(skate3_native_render_scene_warmup_min_items));
loading_native = !ready;
}
g_loading_hold = loading_native;
std::shared_ptr<const FrameScene> scene_ptr; 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); std::lock_guard<std::mutex> lock(g_scene_mutex);
if (!g_scene || g_scene->items.empty()) { if (!g_scene || g_scene->items.empty()) {
return false; return false;
@ -13689,7 +13770,11 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// for a frame instead of freezing the takeover frame. // for a frame instead of freezing the takeover frame.
const int32_t warmup_ms = REXCVAR_GET(skate3_native_render_scene_warmup_budget_ms); const int32_t warmup_ms = REXCVAR_GET(skate3_native_render_scene_warmup_budget_ms);
bool settling = false; bool settling = false;
if (warmup_ms > 0 && REXCVAR_GET(skate3_native_render_scene_debug) == 0) { // 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)) { if (g_warmup_armed.load(std::memory_order_relaxed)) {
if (scene.generation < g_warmup_fresh_generation || if (scene.generation < g_warmup_fresh_generation ||
scene.items.size() < scene.items.size() <
@ -13746,7 +13831,9 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// EnqueueWordsMiss), so make sure they exist even on a session that never // EnqueueWordsMiss), so make sure they exist even on a session that never
// showed a loading screen with the pipeline up. // showed a loading screen with the pipeline up.
EnsurePrewarmWorkers(); EnsurePrewarmWorkers();
PrewarmCommit(context, frame_number); // 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 // Content-store LRU: superseded words states (old mip levels, pre-demote
// detail sets, one-shot UI art) age out once nothing routes to them. // detail sets, one-shot UI art) age out once nothing routes to them.
EvictTexStore(frame_number, command_processor->GetCurrentSubmission()); EvictTexStore(frame_number, command_processor->GetCurrentSubmission());
@ -13778,7 +13865,12 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
const bool use_depth = debug_mode != 4; const bool use_depth = debug_mode != 4;
const D3D12_CPU_DESCRIPTOR_HANDLE dsv = g_r.dsv_heap->GetCPUDescriptorHandleForHeapStart(); const D3D12_CPU_DESCRIPTOR_HANDLE dsv = g_r.dsv_heap->GetCPUDescriptorHandleForHeapStart();
const FLOAT clear_color[4] = {0.25f, 0.35f, 0.55f, 1.0f}; // 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.
const FLOAT clear_color[4] = {loading_native ? 0.0f : 0.25f,
loading_native ? 0.0f : 0.35f,
loading_native ? 0.0f : 0.55f, 1.0f};
list.D3DClearRenderTargetView(scene_rtv, clear_color, 0, nullptr); list.D3DClearRenderTargetView(scene_rtv, clear_color, 0, nullptr);
if (use_depth) { if (use_depth) {
list.D3DClearDepthStencilView(dsv, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr); list.D3DClearDepthStencilView(dsv, D3D12_CLEAR_FLAG_DEPTH, 1.0f, 0, 0, nullptr);
@ -15314,6 +15406,25 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
key *= 1099511628211ull; key *= 1099511628211ull;
} }
auto it = find_words_texture(key); auto it = find_words_texture(key);
if (it != g_r.tex_store.end() && it->second.valid && !it->second.incomplete &&
frame_number >= it->second.recheck_frame &&
REXCVAR_GET(skate3_native_render_scene_tex_revalidate)) {
// Content liveness for CPU-rewritten UI art: intro/menu VIDEO frames
// (rw::movie software decode) rewrite the same payload every tick with
// the fetch words unchanged; the words-keyed cache would freeze
// playback on its first decoded frame. Probe the payload; on change,
// retire + re-decode inline (the fresh entry's recheck_frame of 0
// keeps a playing video on a per-frame cadence). Static art settles at
// the same 16-frame cadence as the world store's revalidation.
const uint64_t fp = SampleProbeFingerprint(base, it->second);
if (fp != 0 && fp != it->second.payload_fp) {
RetireGuestTexture(it->second, command_processor->GetCurrentSubmission());
g_r.tex_store.erase(it);
it = g_r.tex_store.end();
} else {
it->second.recheck_frame = frame_number + 16;
}
}
if (it == g_r.tex_store.end()) { if (it == g_r.tex_store.end()) {
// HUD/spline art decodes inline (small; async would flash UI elements // HUD/spline art decodes inline (small; async would flash UI elements
// white on first sight). The big streamed posters go through // white on first sight). The big streamed posters go through