From 2aef1222a03e308ff981ca90405ccea0201eb7e5 Mon Sep 17 00:00:00 2001 From: mchughalex <25671999+mchughalex@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:24:27 +0100 Subject: [PATCH] render: skip guest draw-list dispatch for occlusion-culled statics --- src/skate3_native_render.cpp | 97 +++++++++++++++++++++++++++++++++ src/skate3_native_scene.cpp | 15 +++++ src/skate3_native_scene.h | 10 ++++ src/skate3_native_scene_gpu.cpp | 59 +++++++++++++++++++- src/skate3_native_scene_state.h | 4 ++ 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/src/skate3_native_render.cpp b/src/skate3_native_render.cpp index 9065993..bddd07a 100644 --- a/src/skate3_native_render.cpp +++ b/src/skate3_native_render.cpp @@ -8,9 +8,11 @@ #include "generated/skate3_init.h" +#include #include #include #include +#include #include #include #include @@ -31,6 +33,7 @@ REXCVAR_DEFINE_INT32(skate3_native_render_log_interval, 0, "Skate 3", .range(0, 100000) .lifecycle(rex::cvar::Lifecycle::kHotReload); 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", "Pace the guest render loop to this frame rate (0 = uncapped). The " "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 ctxs; // sorted culled-ctx snapshot + std::vector 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 // 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) { @@ -319,6 +404,18 @@ extern "C" REX_FUNC(sub_827FAF50) { if (skate3::native_render::Enabled()) { skate3::native_render::OnSceneDrawList(base, ctx.r3.u32, ctx.r4.u32, ctx.r5.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); } diff --git a/src/skate3_native_scene.cpp b/src/skate3_native_scene.cpp index e403062..af75b37 100644 --- a/src/skate3_native_scene.cpp +++ b/src/skate3_native_scene.cpp @@ -1177,6 +1177,17 @@ REXCVAR_DEFINE_BOOL(skate3_native_render_scene_occlusion_cull, true, "Skate 3", "items.") .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", "Deep per-item CPU profiling: adds a perf-items log line " "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) { 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. const uint32_t draw_count = REX_LOAD_U16(ctx + kCtxDrawCountU16); diff --git a/src/skate3_native_scene.h b/src/skate3_native_scene.h index dceb249..48ed2ab 100644 --- a/src/skate3_native_scene.h +++ b/src/skate3_native_scene.h @@ -523,6 +523,16 @@ float CharFadeAlpha(const DrawItem& item); // per-item profiling attribution. 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& 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 // renderengine::Texture object. void OnRegisterTexture(uint64_t guid, uint32_t texture); diff --git a/src/skate3_native_scene_gpu.cpp b/src/skate3_native_scene_gpu.cpp index 713799f..5cf06c8 100644 --- a/src/skate3_native_scene_gpu.cpp +++ b/src/skate3_native_scene_gpu.cpp @@ -7139,7 +7139,7 @@ void LogFrameStats(const FrameScene& scene, uint64_t frames, uint32_t 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={} occl_culled={}", + "rows_inst={} pal_srv={} occl_culled={} guest_skip={}", 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(), @@ -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_hair_route_flips.load(), g_hair_bone_alternations.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; } +// 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 g_occl_pub_ctxs; +std::atomic g_occl_pub_ms{0}; + +} // namespace + +bool CopyOcclusionCulledCtxs(std::vector& out) { + const int64_t now_ms = std::chrono::duration_cast( + 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 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*/) { if (!SceneEnabled() || (context.backend != NativeGuestOutputBackend::kD3D12 && @@ -9718,6 +9749,10 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat } occl_cull_active = cam_d2 < 1.0f; } + std::vector 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 // 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 @@ -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 // keeps reveal edges safe at speed - deeply hidden mass sits many // 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 && !item.cloth_quads && item.bones.empty() && ItemOccludedByGrid(item, 1.0f)) { 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); continue; } @@ -9934,6 +9975,18 @@ bool RenderScene(const NativeGuestOutputRenderContext& context, void* /*user_dat opaque_items.emplace_back(view_dist2(item), &item); 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::steady_clock::now().time_since_epoch()) + .count(); + std::sort(culled_ctxs.begin(), culled_ctxs.end()); + std::lock_guard 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) { std::stable_sort(opaque_items.begin(), opaque_items.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); diff --git a/src/skate3_native_scene_state.h b/src/skate3_native_scene_state.h index 2aeb7dd..4519d48 100644 --- a/src/skate3_native_scene_state.h +++ b/src/skate3_native_scene_state.h @@ -964,6 +964,10 @@ inline std::atomic g_vis_out_retained{0}; // out-of-frustum draws fro // skate3_native_render_scene_occlusion_cull cvar; reported on the frame // stats line like the other cumulative counters there). inline std::atomic 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 g_occl_guest_skipped{0}; // 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_fp; // ComputeItemFingerprint on the cache-hit path