diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs index e7df0540..0ca278b8 100644 --- a/crates/benilla-world/src/static_gx/bake.rs +++ b/crates/benilla-world/src/static_gx/bake.rs @@ -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=`: 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 { + static BUDGET: std::sync::OnceLock> = std::sync::OnceLock::new(); + *BUDGET.get_or_init(|| match std::env::var("WOW_GX_BAKE_MS") { + Ok(v) => match v.parse::() { + 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, mut meshes: ResMut>) { let _t = super::gx_perf_guard(0); @@ -56,6 +115,10 @@ pub(super) fn flush_static_gx(mut gx: ResMut, mut meshes: ResMut, mut meshes: ResMut, mut meshes: ResMut, mut meshes: ResMut, mut meshes: ResMut 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();