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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user