render: skip guest draw-list dispatch for occlusion-culled statics

This commit is contained in:
mchughalex 2026-07-22 11:24:27 +01:00
parent 73c19c8cd3
commit 2aef1222a0
5 changed files with 182 additions and 3 deletions

View file

@ -8,9 +8,11 @@
#include "generated/skate3_init.h" #include "generated/skate3_init.h"
#include <algorithm>
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
#include <cstring>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
#include <vector> #include <vector>
@ -31,6 +33,7 @@ REXCVAR_DEFINE_INT32(skate3_native_render_log_interval, 0, "Skate 3",
.range(0, 100000) .range(0, 100000)
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DECLARE(bool, skate3_native_render_scene_perf_log); REXCVAR_DECLARE(bool, skate3_native_render_scene_perf_log);
REXCVAR_DECLARE(bool, skate3_native_render_scene_occlusion_cull_guest);
REXCVAR_DEFINE_DOUBLE(skate3_guest_fps_cap, 0.0, "Skate 3", REXCVAR_DEFINE_DOUBLE(skate3_guest_fps_cap, 0.0, "Skate 3",
"Pace the guest render loop to this frame rate (0 = uncapped). The " "Pace the guest render loop to this frame rate (0 = uncapped). The "
"guest produces frames at irregular 2-9 ms intervals; the display " "guest produces frames at irregular 2-9 ms intervals; the display "
@ -122,6 +125,88 @@ void OnSceneDrawList(uint8_t* base, uint32_t view, uint32_t sort_vec, uint32_t f
} }
} }
// ---- Guest-side occlusion dispatch filter ---------------------------------
// The native renderer suppresses the guest's emulated draws, so for world
// statics the sorted-list dispatch below (material setup, command-packet
// building) produces nothing anyone consumes - yet it is the guest render
// thread's dominant per-item cost. For MeshContexts the render-side
// occlusion cull proved hidden last frame (published by RenderScene), the
// hook compacts the entry segment before invoking the guest dispatcher and
// restores it afterwards. Capture above has already recorded every entry,
// so scene.items, the shadow caster caches, and all state capture stay
// complete; the only guest work skipped is packet-building for draws that
// were both suppressed and occlusion-culled anyway. Guest render thread
// only.
struct OcclDispatchFilter {
uint64_t stamp = ~0ull; // g_frame_index of the ctx snapshot
std::vector<uint32_t> ctxs; // sorted culled-ctx snapshot
std::vector<uint8_t> saved; // original segment bytes for restore
uint32_t saved_addr = 0;
uint32_t saved_bytes = 0;
bool active = false; // a filtered dispatch is in flight
};
OcclDispatchFilter g_occl_filter;
// Compacts entries[first..first+count) in place, dropping culled ctxs, and
// returns the kept count. Returns `count` unchanged (nothing saved) when
// filtering is off, stale, empty, or nothing matched.
uint32_t FilterSceneDrawList(uint8_t* base, uint32_t sort_vec, uint32_t first,
uint32_t count) {
OcclDispatchFilter& f = g_occl_filter;
if (f.active || count == 0 || count > 100000 ||
!REXCVAR_GET(skate3_native_render_scene_occlusion_cull_guest)) {
return count;
}
if (f.stamp != g_frame_index) {
f.stamp = g_frame_index;
skate3::native_scene::CopyOcclusionCulledCtxs(f.ctxs);
}
if (f.ctxs.empty()) {
return count;
}
const uint32_t entries = REX_LOAD_U32(sort_vec);
if (entries == 0) {
return count;
}
const uint32_t seg = entries + first * 8;
f.saved.assign(base + seg, base + seg + size_t(count) * 8);
uint32_t kept = 0;
uint32_t skipped = 0;
for (uint32_t i = 0; i < count; ++i) {
const uint8_t* src = f.saved.data() + size_t(i) * 8;
uint32_t ctx_be;
std::memcpy(&ctx_be, src + 4, 4);
const uint32_t mesh_context = __builtin_bswap32(ctx_be);
if (mesh_context != 0 &&
std::binary_search(f.ctxs.begin(), f.ctxs.end(), mesh_context)) {
++skipped;
continue;
}
if (kept != i) {
std::memcpy(base + seg + size_t(kept) * 8, src, 8);
}
++kept;
}
if (skipped == 0) {
f.saved.clear();
return count;
}
f.active = true;
f.saved_addr = seg;
f.saved_bytes = count * 8;
skate3::native_scene::AddGuestOcclSkipped(skipped);
return kept;
}
void RestoreSceneDrawList(uint8_t* base) {
OcclDispatchFilter& f = g_occl_filter;
if (!f.active) {
return;
}
std::memcpy(base + f.saved_addr, f.saved.data(), f.saved_bytes);
f.active = false;
}
// Non-indexed cloth patch draws (see native_scene::CaptureClothDraw). The // Non-indexed cloth patch draws (see native_scene::CaptureClothDraw). The
// synthetic "context" key is the dynamic buffer object, stable per garment. // synthetic "context" key is the dynamic buffer object, stable per garment.
void OnClothDraw(uint8_t* base, uint32_t r4, uint32_t r5, uint32_t r6, uint32_t r7) { void OnClothDraw(uint8_t* base, uint32_t r4, uint32_t r5, uint32_t r6, uint32_t r7) {
@ -319,6 +404,18 @@ extern "C" REX_FUNC(sub_827FAF50) {
if (skate3::native_render::Enabled()) { if (skate3::native_render::Enabled()) {
skate3::native_render::OnSceneDrawList(base, ctx.r3.u32, ctx.r4.u32, ctx.r5.u32, skate3::native_render::OnSceneDrawList(base, ctx.r3.u32, ctx.r4.u32, ctx.r5.u32,
ctx.r6.u32); ctx.r6.u32);
// Guest-side occlusion dispatch filter: capture above saw every entry;
// the dispatcher only gets the ones the occlusion cull did not prove
// hidden. The segment is restored after the call (the list object
// outlives this dispatch).
const uint32_t kept = skate3::native_render::FilterSceneDrawList(
base, ctx.r4.u32, ctx.r5.u32, ctx.r6.u32);
if (kept != ctx.r6.u32) {
ctx.r6.u64 = kept;
__imp__sub_827FAF50(ctx, base);
skate3::native_render::RestoreSceneDrawList(base);
return;
}
} }
__imp__sub_827FAF50(ctx, base); __imp__sub_827FAF50(ctx, base);
} }

View file

@ -1177,6 +1177,17 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_occlusion_cull, true, "Skate 3",
"items.") "items.")
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_occlusion_cull_guest, true,
"Skate 3",
"Also skip the GUEST engine's draw-list dispatch for "
"items the occlusion cull proved hidden (the dispatch "
"builds command packets the native renderer suppresses "
"anyway, and it is the frame-time-critical cost). "
"Capture still sees every item, so shadows and scene "
"state are unaffected. Requires "
"skate3_native_render_scene_occlusion_cull.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(skate3_native_render_scene_perf_items, false, "Skate 3", REXCVAR_DEFINE_BOOL(skate3_native_render_scene_perf_items, false, "Skate 3",
"Deep per-item CPU profiling: adds a perf-items log line " "Deep per-item CPU profiling: adds a perf-items log line "
"per perf window attributing per-item cost to pipeline " "per perf window attributing per-item cost to pipeline "
@ -2580,6 +2591,10 @@ bool BuildItemGeometry(uint8_t* base, uint32_t ctx, DrawItem& item) {
if (prof) { if (prof) {
g_pw_bi_fetch.Add(PerfNsSince(fetch_t0)); g_pw_bi_fetch.Add(PerfNsSince(fetch_t0));
} }
// Identity for per-instance consumers (the occlusion cull's guest-side
// dispatch filter keys on it; the dynamic capture path overwrites it with
// its own value).
item.ctx = ctx;
// Culled island draw list from the context. // Culled island draw list from the context.
const uint32_t draw_count = REX_LOAD_U16(ctx + kCtxDrawCountU16); const uint32_t draw_count = REX_LOAD_U16(ctx + kCtxDrawCountU16);

View file

@ -523,6 +523,16 @@ float CharFadeAlpha(const DrawItem& item);
// per-item profiling attribution. // per-item profiling attribution.
bool ItemOutsideFrustum(const DrawItem& it, const float vp[16], float margin); bool ItemOutsideFrustum(const DrawItem& it, const float vp[16], float margin);
// Occlusion-cull handoff for the guest-side dispatch filter (defined in
// skate3_native_scene_gpu.cpp): copies the most recent render frame's
// culled-static MeshContext set (sorted ascending) into `out`, clearing it
// instead when the last publish is older than a quarter second (native
// render idle: menus, loading). Returns true when `out` is non-empty.
bool CopyOcclusionCulledCtxs(std::vector<uint32_t>& out);
// Counts sort-list entries the guest-side filter kept away from the guest
// dispatcher (frame-stats telemetry).
void AddGuestOcclSkipped(uint32_t n);
// Called from the cProcessArenaAsset::RegisterTexture hook: guid -> guest // Called from the cProcessArenaAsset::RegisterTexture hook: guid -> guest
// renderengine::Texture object. // renderengine::Texture object.
void OnRegisterTexture(uint64_t guid, uint32_t texture); void OnRegisterTexture(uint64_t guid, uint32_t texture);

View file

@ -7139,7 +7139,7 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn,
"reused={}] dynobj[valid={} drawn={}] water[valid={} drawn={}] " "reused={}] dynobj[valid={} drawn={}] water[valid={} drawn={}] "
"lw[ctxs={} ents={} stamp={} fade0={} resc={} fill={} pal={} rows={}] " "lw[ctxs={} ents={} stamp={} fade0={} resc={} fill={} pal={} rows={}] "
"refl[pair={} flat={} gate={:#x}] flips={} alt[bones={} shadow={}] " "refl[pair={} flat={} gate={:#x}] flips={} alt[bones={} shadow={}] "
"rows_inst={} pal_srv={} occl_culled={}", "rows_inst={} pal_srv={} occl_culled={} guest_skip={}",
frames, scene.items.size(), drawn, g_draws_2d.load(), drawn_2d, frames, scene.items.size(), drawn, g_draws_2d.load(), drawn_2d,
drawn_spline, g_draws_spline.load(), drawn_spline, g_draws_spline.load(),
g_draws_2d_other.load(), g_draws_2d_dropped.load(), g_draws_2d_other.load(), g_draws_2d_dropped.load(),
@ -7173,7 +7173,8 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t drawn,
g_refl_pair.load(), g_refl_flat.load(), g_refl_gate.load(), g_refl_pair.load(), g_refl_flat.load(), g_refl_gate.load(),
g_hair_route_flips.load(), g_hair_bone_alternations.load(), g_hair_route_flips.load(), g_hair_bone_alternations.load(),
g_shadow_alternations.load(), g_char_rows_inst_served.load(), g_shadow_alternations.load(), g_char_rows_inst_served.load(),
g_pal_served_total.load(), g_occl_culled.load()); g_pal_served_total.load(), g_occl_culled.load(),
g_occl_guest_skipped.load());
} }
} }
@ -7473,6 +7474,36 @@ bool ItemOccludedByGrid(const DrawItem& it, float depth_margin) {
return true; return true;
} }
// Culled-ctx publication for the guest-side dispatch filter: the classify
// loop collects the MeshContexts of statics it occlusion-culled and swaps
// them in here once per rendered frame (empty when the cull is inactive, so
// the guest filter clears within a frame). The timestamp lets the consumer
// expire the set when the native render idles.
std::mutex g_occl_pub_mutex;
std::vector<uint32_t> g_occl_pub_ctxs;
std::atomic<int64_t> g_occl_pub_ms{0};
} // namespace
bool CopyOcclusionCulledCtxs(std::vector<uint32_t>& out) {
const int64_t now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
if (now_ms - g_occl_pub_ms.load(std::memory_order_relaxed) > 250) {
out.clear();
return false;
}
std::lock_guard<std::mutex> lock(g_occl_pub_mutex);
out = g_occl_pub_ctxs;
return !out.empty();
}
void AddGuestOcclSkipped(uint32_t n) {
g_occl_guest_skipped.fetch_add(n, std::memory_order_relaxed);
}
namespace {
bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_data*/) { bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_data*/) {
if (!SceneEnabled() || if (!SceneEnabled() ||
(context.backend != NativeGuestOutputBackend::kD3D12 && (context.backend != NativeGuestOutputBackend::kD3D12 &&
@ -9718,6 +9749,10 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
} }
occl_cull_active = cam_d2 < 1.0f; occl_cull_active = cam_d2 < 1.0f;
} }
std::vector<uint32_t> culled_ctxs;
if (occl_cull_active) {
culled_ctxs.reserve(scene.items.size() / 2);
}
// Opaque items draw front-to-back (bbox-center distance): early-z rejects // 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 // 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 // order is by render state, not depth; depth-write LESS_EQUAL makes the
@ -9907,11 +9942,17 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
// profiler): the 1 m depth margin on top of the conservative bbox test // profiler): the 1 m depth margin on top of the conservative bbox test
// keeps reveal edges safe at speed - deeply hidden mass sits many // keeps reveal edges safe at speed - deeply hidden mass sits many
// meters behind its occluders, so the margin costs almost none of the // meters behind its occluders, so the margin costs almost none of the
// win. Ring route 3 = culled. // win. Ring route 3 = culled. Live-submitted culled ctxs (never
// retained ones: a re-appended item's ctx may already be freed, and a
// reused address would filter an unrelated new instance) feed the
// guest-side dispatch filter via the publication below.
if (occl_cull_active && !item.skinned && !item.ropa && if (occl_cull_active && !item.skinned && !item.ropa &&
!item.cloth_quads && item.bones.empty() && !item.cloth_quads && item.bones.empty() &&
ItemOccludedByGrid(item, 1.0f)) { ItemOccludedByGrid(item, 1.0f)) {
g_occl_culled.fetch_add(1, std::memory_order_relaxed); g_occl_culled.fetch_add(1, std::memory_order_relaxed);
if (!item.retained && item.ctx != 0) {
culled_ctxs.push_back(item.ctx);
}
stamp_route(3); stamp_route(3);
continue; continue;
} }
@ -9934,6 +9975,18 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat
opaque_items.emplace_back(view_dist2(item), &item); opaque_items.emplace_back(view_dist2(item), &item);
stamp_route(1); stamp_route(1);
} }
// Publish the culled-ctx set every rendered frame, including empty ones:
// the guest filter must clear promptly when the cull stands down.
{
const int64_t now_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
std::sort(culled_ctxs.begin(), culled_ctxs.end());
std::lock_guard<std::mutex> lock(g_occl_pub_mutex);
g_occl_pub_ctxs.swap(culled_ctxs);
g_occl_pub_ms.store(now_ms, std::memory_order_relaxed);
}
if (REXCVAR_GET(skate3_native_render_scene_sort_opaque) && debug_mode == 0) { if (REXCVAR_GET(skate3_native_render_scene_sort_opaque) && debug_mode == 0) {
std::stable_sort(opaque_items.begin(), opaque_items.end(), std::stable_sort(opaque_items.begin(), opaque_items.end(),
[](const auto& a, const auto& b) { return a.first < b.first; }); [](const auto& a, const auto& b) { return a.first < b.first; });

View file

@ -964,6 +964,10 @@ inline std::atomic<uint64_t> g_vis_out_retained{0}; // out-of-frustum draws fro
// skate3_native_render_scene_occlusion_cull cvar; reported on the frame // skate3_native_render_scene_occlusion_cull cvar; reported on the frame
// stats line like the other cumulative counters there). // stats line like the other cumulative counters there).
inline std::atomic<uint64_t> g_occl_culled{0}; inline std::atomic<uint64_t> g_occl_culled{0};
// Session-cumulative count of sort-list entries the guest-side dispatch
// filter removed before the guest's draw-list renderer ran (see
// skate3_native_render_scene_occlusion_cull_guest).
inline std::atomic<uint64_t> g_occl_guest_skipped{0};
// Guest render thread: decomposition of the per-item build walk. // Guest render thread: decomposition of the per-item build walk.
inline PerfWindow g_pw_bi_core; // BuildItemFromMeshCached total (hit validate + misses) inline PerfWindow g_pw_bi_core; // BuildItemFromMeshCached total (hit validate + misses)
inline PerfWindow g_pw_bi_fp; // ComputeItemFingerprint on the cache-hit path inline PerfWindow g_pw_bi_fp; // ComputeItemFingerprint on the cache-hit path