perf(static-gx): budget the bake, 43 ms off a world-load frame

`flush_static_gx` bakes a cell once it has been dirty and *quiet* for its
window, which staggers cells whose changes are staggered — and a world load
dirties hundreds within a few frames of each other, so their windows expire
together and the whole burst baked in one flush. A cold Elwynn vista measured
one run of 43.2 ms against 2.6 ms for the other nineteen in that second
(`WOW_SYSTEM_TIMING=1`): a visible stall, and a scheduling artefact rather than
work, since the same bakes cost the same total spread over later frames.

`BAKE_BUDGET` spends 4 ms of wall clock per flush — the figure
`terrain_stream::SPAWN_BUDGET` already paces placement spawns by, and the two
passes share a frame — and hands the rest to the next flush. A region that
yields stays `dirty` with its `last_change` untouched, so `bake_due` says yes
again next flush: yielding is the absence of a decision rather than one, which
is what keeps the budget to *when* a region bakes and never *whether*. The three
exemptions say the same thing. Nothing yields until one region has baked, so a
region larger than the whole budget cannot stall behind itself. `flush_now` gets
the full flush it asked for. The `MAX_DIRTY_FRAMES` age cap outranks the budget,
so the backstop that already bounded a region's wait stays absolute instead of
becoming advisory. And an empty region is an unload — free to publish, and
deferring one would leave dead geometry on screen — so removals are never
budgeted at all.

Measured after, same vista: every budgeted flush landed 4.0–4.5 ms, draining a
backlog that peaked at 398 held regions at ~30 a frame over 16 consecutive
flushes. Worst visible flush 43.2 → 4.5 ms.

One flush still runs 58.4 ms and should. `terrain_stream.rs:908` asks for a
`flush_now` while `!focus.paced`, because a region that cannot draw yet is "a
hole in the reveal", and `ViewFocus::paced` is false only "through entry, a
teleport and a world swap, while the loading cover absorbs the burst". So the
budget moves that stall out of play time and behind the cover, which is the
point; it is not a leftover to fix.

`WOW_GX_BAKE_MS=<ms>` overrides the budget and an explicit `0` retires it, so
the A/B runs in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-19 20:33:00 -06:00
co-authored by Claude Opus 5
parent d8d8f4fc59
commit df72d823cd
+124 -3
View File
@@ -1,6 +1,8 @@
//! The retained pass's bake: the flush turns each dirty, quiet cell or region into one recentred
//! mesh and a per-item draw list ([`bake_cell`]).
use std::time::{Duration, Instant};
use benilla_assets::coords::wow_to_bevy;
use bevy::asset::RenderAssetUsages;
use bevy::camera::primitives::Aabb;
@@ -48,6 +50,63 @@ const CENSUS_SETTLE_FRAMES: u32 = 60;
/// The ceiling on that hold (~10 s), so counts that never settle, as on a flight path, still print.
const CENSUS_MAX_HOLD_FRAMES: u32 = 600;
/// The flush's own share of one frame, spent before it starts handing regions to the next flush.
///
/// Regions fall due on the frame their quiet window expires, and a world load dirties hundreds of
/// cells within a few frames of each other, so their windows expire together and the whole burst
/// used to bake in **one** flush. Measured on a cold Elwynn vista (`WOW_SYSTEM_TIMING=1`): a
/// single `flush_static_gx` run of **43.2 ms** against 2.6 ms for the other nineteen runs in that
/// same second. That is not a slow frame, it is a visible stall, and it is a scheduling artefact
/// rather than work: the same bakes spread over the following frames cost the same total.
///
/// 4 ms is a quarter of a 60 Hz frame, which drains a 43 ms burst in about nine frames (150 ms)
/// while leaving three quarters of every one of them for the rest of the game. It is also exactly
/// `terrain_stream::SPAWN_BUDGET`, the same figure the streamer already paces placement spawns by
/// — the two passes share a frame, so there is no case for them to disagree about what a frame's
/// share of streaming work is worth.
const BAKE_BUDGET: Duration = Duration::from_millis(4);
/// `WOW_GX_BAKE_MS=<ms>`: override [`BAKE_BUDGET`]. An explicit `0` (or any non-positive number)
/// retires the budget, baking every due region in one flush as this pass did before it existed —
/// the A/B for "is the budget what I am seeing?", in either direction.
fn bake_budget() -> Option<Duration> {
static BUDGET: std::sync::OnceLock<Option<Duration>> = std::sync::OnceLock::new();
*BUDGET.get_or_init(|| match std::env::var("WOW_GX_BAKE_MS") {
Ok(v) => match v.parse::<f32>() {
Ok(ms) if ms.is_finite() && ms > 0.0 => Some(Duration::from_secs_f32(ms / 1000.0)),
// Only a number asks for no budget at all; junk is not a request for that.
Ok(_) => None,
Err(_) => Some(BAKE_BUDGET),
},
Err(_) => Some(BAKE_BUDGET),
})
}
/// Whether a due region hands the rest of this flush to the next one, `elapsed` into the flush.
///
/// The budget changes **when** a region bakes and never **whether** it does, which is what the
/// three exemptions are for: `baked == 0`, so a region whose own bake exceeds the whole budget
/// still bakes and can never stall behind itself; `now`, so a [`StaticGx::flush_now`] caller gets
/// the full flush it asked for; and the [`MAX_DIRTY_FRAMES`] age cap, so the backstop that already
/// bounded how long a dirty region may wait stays absolute rather than becoming advisory.
///
/// A region that yields stays `dirty` with its `last_change` untouched, so [`bake_due`] says yes
/// again next flush — yielding is not a decision, it is the absence of one.
///
/// The flush walks cells, then WMO regions, then prop regions, so sustained pressure is paid in
/// that order and a prop region can wait behind cells. Measured, the deepest backlog a cold Elwynn
/// load reached was 398 regions draining at ~30 a flush, so the wait is frames; the age cap is the
/// bound if a world ever dirties cells faster than a flush can take them.
fn yields(elapsed: Duration, baked: u32, now: bool, state: &super::GxCell, frame: u32) -> bool {
let Some(budget) = bake_budget() else {
return false;
};
baked > 0
&& !now
&& frame.wrapping_sub(state.dirty_since) < MAX_DIRTY_FRAMES
&& elapsed >= budget
}
/// Bake dirty-and-quiet cells into retained draw data; publish into [`render::GxWorld`].
pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Assets<Mesh>>) {
let _t = super::gx_perf_guard(0);
@@ -56,6 +115,10 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
census_declines(&mut gx, frame);
// A `StaticGx::flush_now` request bakes one flush's worth; the windows resume next frame.
let now = std::mem::take(&mut gx.flush_now);
// The budget's clock and its spend, in regions baked ([`yields`]).
let t0 = Instant::now();
let mut baked_regions = 0u32;
let mut yielded = 0u32;
let StaticGx {
cells,
wmos,
@@ -67,11 +130,18 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
if !bake_due(state, world.cells.contains_key(&cell), frame, now) {
continue;
}
state.dirty = false;
// An empty region is an unload: it costs nothing to publish and deferring one would leave
// dead geometry on screen, so removals are never budgeted.
if state.items.is_empty() {
state.dirty = false;
world.cells.remove(&cell);
continue;
}
if yields(t0.elapsed(), baked_regions, now, state, frame) {
yielded += 1;
continue;
}
state.dirty = false;
// Sorted by (bucket, texture) so the node draws one range per run, not one per item.
state
.items
@@ -86,16 +156,24 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
baked.draws.last().map_or(0, |d| d.vertex_range.end),
);
world.cells.insert(cell, std::sync::Arc::new(baked));
baked_regions += 1;
}
for (&instance, state) in wmos.iter_mut() {
if !bake_due(state, world.wmos.contains_key(&instance), frame, now) {
continue;
}
state.dirty = false;
// An empty region is an unload: it costs nothing to publish and deferring one would leave
// dead geometry on screen, so removals are never budgeted.
if state.items.is_empty() {
state.dirty = false;
world.wmos.remove(&instance);
continue;
}
if yields(t0.elapsed(), baked_regions, now, state, frame) {
yielded += 1;
continue;
}
state.dirty = false;
// The group joins the key: the PVS selects per group, so a run must not cross one.
state.items.sort_by_key(|i| {
(
@@ -112,17 +190,25 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
baked.draws.last().map_or(0, |d| d.vertex_range.end),
);
world.wmos.insert(instance, std::sync::Arc::new(baked));
baked_regions += 1;
}
// Prop regions: the referrer set is the selection key, and the draw carries the set list.
for (&instance, state) in props.iter_mut() {
if !bake_due(state, world.props.contains_key(&instance), frame, now) {
continue;
}
state.dirty = false;
// An empty region is an unload: it costs nothing to publish and deferring one would leave
// dead geometry on screen, so removals are never budgeted.
if state.items.is_empty() {
state.dirty = false;
world.props.remove(&instance);
continue;
}
if yields(t0.elapsed(), baked_regions, now, state, frame) {
yielded += 1;
continue;
}
state.dirty = false;
state.items.sort_by_key(|i| {
(
(u8::from(i.cutout) << 1) | u8::from(i.two_sided),
@@ -139,6 +225,14 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
baked.draws.last().map_or(0, |d| d.vertex_range.end),
);
world.props.insert(instance, std::sync::Arc::new(baked));
baked_regions += 1;
}
if yielded > 0 {
debug!(
"static-gx: {baked_regions} region(s) baked in {:.1} ms, {yielded} held for the next \
flush (WOW_GX_BAKE_MS)",
t0.elapsed().as_secs_f32() * 1000.0,
);
}
}
@@ -427,6 +521,33 @@ mod tests {
assert!(bake_due(&state, true, MAX_DIRTY_FRAMES, false));
}
#[test]
fn the_budget_defers_a_bake_and_never_a_rule() {
// `yields` reads `dirty_since` alone, and a default cell's is 0: dirty since the epoch.
let state = super::super::GxCell::default();
let spent = BAKE_BUDGET + Duration::from_millis(1);
assert!(
!yields(Duration::ZERO, 1, false, &state, 1),
"inside the budget nothing is held back"
);
assert!(
yields(spent, 1, false, &state, 1),
"past it, with a region already baked this flush, the next one waits"
);
assert!(
!yields(spent, 0, false, &state, 1),
"a flush always bakes its first region, however far past the budget it lands"
);
assert!(
!yields(spent, 1, true, &state, 1),
"flush_now asked for the whole flush and gets it"
);
assert!(
!yields(spent, 1, false, &state, MAX_DIRTY_FRAMES),
"the age cap outranks the budget: the backstop stays absolute"
);
}
#[test]
fn a_quiet_cell_bakes_sorted_contiguous_draws() {
let mut gx = StaticGx::default();