From 447e92aed533fa706f31dad59cf6da78cda05b4b Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Aug 2026 18:59:48 +0700 Subject: [PATCH] three parked costs burned, two schedules go single-threaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the sun flare's terrain-occlusion march re-prices 2 grid cells a frame instead of 768 height queries, the retained pass's published regions go behind an Arc instead of two memcpys, and a unit's splash line is re-read only when its pose changed — −0.66 ms parked at Goldshire, pixels bit-identical - PostUpdate and the render extract schedule run single-threaded by default — ~620 mostly no-op systems were paying the multi-threaded dispatch premium — −0.90 ms parked at Goldshire, −0.8 walking in Lower Blackrock Spire; WOW_MT_POSTUPDATE and WOW_MT_EXTRACT are the levers back - GltfPlugin, AntiAliasPlugin and GilrsPlugin leave the boot — machinery for types nothing makes, 31 systems a frame fewer - WOW_SCHED_CENSUS lists every schedule's systems with their executor-relevant flags, both worlds — the inventory the flips were priced against Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/capture/mod.rs | 2 +- crates/benilla-app/src/capture/probes/mod.rs | 5 + .../src/capture/probes/sched_census.rs | 139 ++++++++++++++++++ crates/benilla-app/src/dev.rs | 6 + crates/benilla-app/src/lib.rs | 53 ++++++- crates/benilla-app/src/sound/water.rs | 9 +- crates/benilla-world/src/bgwin.rs | 2 + crates/benilla-world/src/boot.rs | 22 +++ crates/benilla-world/src/static_gx/bake.rs | 6 +- crates/benilla-world/src/static_gx/cull.rs | 4 + crates/benilla-world/src/static_gx/render.rs | 12 +- crates/benilla-world/src/sun/follow.rs | 130 +++++++++++++--- 12 files changed, 349 insertions(+), 41 deletions(-) create mode 100644 crates/benilla-app/src/capture/probes/sched_census.rs diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 59136cc8..c913d1ac 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -114,7 +114,7 @@ pub(crate) use probe_taxi::ProbeTaxiPlugin; pub(crate) use probes::{ fx_draw_census_plugin, EntityCensusPlugin, GroundCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeExitPlugin, ProbeFocusPlugin, - ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, UnitVisualsPlugin, + ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin, SchedCensusPlugin, UnitVisualsPlugin, }; use scenarios::GlueScreen; use scenarios::{Scenario, SubjectKind, UiFixture, GLUE_SCENARIOS, GROUND_EYE, SCENARIOS}; diff --git a/crates/benilla-app/src/capture/probes/mod.rs b/crates/benilla-app/src/capture/probes/mod.rs index 745b3c0c..892b8b43 100644 --- a/crates/benilla-app/src/capture/probes/mod.rs +++ b/crates/benilla-app/src/capture/probes/mod.rs @@ -45,6 +45,11 @@ pub(crate) use visual_census::UnitVisualsPlugin; mod world_census; pub(crate) use world_census::{EntityCensusPlugin, NodeProbePlugin}; +/// The schedule census — per schedule, every system with its executor-relevant flags, both +/// worlds: the structural inventory under the 1435 orchestration rows (decision 1437). +mod sched_census; +pub(crate) use sched_census::SchedCensusPlugin; + /// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789). /// /// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s", diff --git a/crates/benilla-app/src/capture/probes/sched_census.rs b/crates/benilla-app/src/capture/probes/sched_census.rs new file mode 100644 index 00000000..a338deb0 --- /dev/null +++ b/crates/benilla-app/src/capture/probes/sched_census.rs @@ -0,0 +1,139 @@ +//! The schedule census (`WOW_SCHED_CENSUS=1`): the structural inventory behind the 1435 band +//! map's orchestration rows — per schedule, every system with its executor-relevant flags +//! (non-`Send`, exclusive, has-deferred), for BOTH worlds. Born with decision 1437: the parked +//! frame pays ~5.4 ms/f of pure scheduling (schedule selves + executor + empty command applies +//! across ~600 executed systems), and neither consolidation nor executor choices can be argued +//! about until the population has names and counts. +//! +//! Why runtime one-shots instead of a pre-run `Schedule::initialize` walk: bevy removes a +//! schedule from the `Schedules` resource WHILE it runs (`World::schedule_scope`), so no single +//! vantage sees everything — and pre-run initialization is the wrong tool twice over +//! (`Extract` params panic without the runtime-only `MainWorld`, and `Local` +//! state may read resources Startup hasn't inserted yet). Two vantages per world cover each +//! other: `Update` and `PostUpdate` in the main world, `ExtractSchedule` and `Render` in the +//! render app. Only the `Main`/`RenderStartup` runners stay invisible (each is the fixed +//! one-system runner bevy ships). + +use bevy::prelude::*; +use bevy::render::{ExtractSchedule, Render, RenderApp}; + +/// Frames to wait before dumping — deep enough that every schedule has run at least once (the +/// census reads the live graph, not what a first frame happens to have reached). +const CENSUS_FRAME: u32 = 10; + +/// Frames to wait before the census run exits — the render app runs a frame behind the main +/// world (pipelined), so give its vantages room past [`CENSUS_FRAME`] before the app closes. +const EXIT_FRAME: u32 = 40; + +/// The census instrument. Purely structural — it does not need the world, a server, or a +/// character: the schedule graph is fixed once the plugins have built, so a login-screen run +/// answers for every state. +pub(crate) struct SchedCensusPlugin; + +impl Plugin for SchedCensusPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Update, census_vantage("main", "Update")) + .add_systems(PostUpdate, census_vantage("main", "PostUpdate")) + .add_systems(Last, census_exit); + // The render app still lives in the main `App` here (pipelining detaches it at cleanup, + // after every plugin has built). Its two vantages mirror the main world's pair. + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app + .init_resource::() + .add_systems(ExtractSchedule, census_vantage("render", "Extract")) + .add_systems(Render, census_vantage("render", "Render")); + } + } +} + +/// Which schedules this world's earlier vantage already printed — the later vantage adds only +/// what the earlier one could not see (itself, chiefly). +#[derive(Resource, Default)] +struct DumpedSchedules(std::collections::HashSet); + +/// One vantage: an exclusive one-shot that, at [`CENSUS_FRAME`], prints every schedule visible +/// in `Schedules` that no earlier vantage in this world has printed. +fn census_vantage( + world_tag: &'static str, + vantage: &'static str, +) -> impl FnMut(&mut World, Local) { + move |world: &mut World, mut frame: Local| { + *frame += 1; + if *frame != CENSUS_FRAME { + return; + } + world.resource_scope::(|world, mut dumped| { + world.resource_scope::(|_, schedules| { + let mut labels: Vec<(String, &bevy::ecs::schedule::Schedule)> = schedules + .iter() + .map(|(label, sched)| (format!("{label:?}"), sched)) + .filter(|(name, _)| !dumped.0.contains(name)) + .collect(); + labels.sort_by(|a, b| a.0.cmp(&b.0)); + for (name, sched) in labels { + dump_schedule(world_tag, vantage, &name, sched); + dumped.0.insert(name); + } + }); + }); + } +} + +/// Print one schedule: the tally line, then one line per system. `systems()` errors only on an +/// uninitialized graph — impossible by [`CENSUS_FRAME`] for a schedule that runs, and a +/// registered-but-never-run schedule is exactly worth flagging as such. +fn dump_schedule( + world_tag: &str, + vantage: &str, + name: &str, + sched: &bevy::ecs::schedule::Schedule, +) { + let Ok(systems) = sched.systems() else { + println!( + "SCHED_CENSUS world={world_tag} vantage={vantage} schedule={name} \ + n={} uninitialized=1", + sched.systems_len() + ); + return; + }; + let (mut n, mut nonsend, mut exclusive, mut deferred) = (0u32, 0u32, 0u32, 0u32); + let mut rows: Vec = Vec::new(); + for (_, sys) in systems { + n += 1; + let mut flags = String::new(); + if !sys.is_send() { + nonsend += 1; + flags.push('N'); + } + if sys.is_exclusive() { + exclusive += 1; + flags.push('X'); + } + if sys.has_deferred() { + deferred += 1; + flags.push('D'); + } + rows.push(format!( + "SCHED_SYS world={world_tag} schedule={name} flags={} name={}", + if flags.is_empty() { "-".into() } else { flags }, + sys.name() + )); + } + println!( + "SCHED_CENSUS world={world_tag} vantage={vantage} schedule={name} \ + n={n} nonsend={nonsend} exclusive={exclusive} deferred={deferred}" + ); + for row in rows { + println!("{row}"); + } +} + +/// Close the run once both worlds have had time to print — the census is its own whole run. +fn census_exit(mut frame: Local, mut exit: MessageWriter) { + *frame += 1; + if *frame == EXIT_FRAME { + println!("SCHED_CENSUS_DONE"); + exit.write(AppExit::Success); + } +} diff --git a/crates/benilla-app/src/dev.rs b/crates/benilla-app/src/dev.rs index 0153ae8a..9c5d62ca 100644 --- a/crates/benilla-app/src/dev.rs +++ b/crates/benilla-app/src/dev.rs @@ -199,6 +199,12 @@ impl Plugin for DevProbesPlugin { if std::env::var("WOW_ENTITY_CENSUS").is_ok() { app.add_plugins(crate::capture::EntityCensusPlugin); } + // The schedule census: `WOW_SCHED_CENSUS=1` prints every schedule's systems with their + // executor-relevant flags, both worlds, then exits — the structural inventory under + // the orchestration bands (see `capture::SchedCensusPlugin`, decision 1437). + if std::env::var("WOW_SCHED_CENSUS").is_ok() { + app.add_plugins(crate::capture::SchedCensusPlugin); + } // The melee live probe: `WOW_PROBE=melee` auto-fights the nearest enemy so the dbg-trace // sink can record the combat-text timeline (see `capture::ProbeMeleePlugin`). if std::env::var("WOW_PROBE").as_deref() == Ok("melee") { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index a6d2c32f..d8014824 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -251,22 +251,29 @@ pub fn run(build: BuildId) -> AppExit { // flag anyway, so the cross-thread dispatch machinery was pure overhead — measured // −1.30 cpu_ms at the LBRS pin under 5-round grading with the wall tail (p95/p99/max) // flat-to-better (the 1364 "fatter tail" reading did not reproduce; 1366 has the tables, - // both pins). PostUpdate stays multi-threaded — its wide parallel bands - // (propagation, visibility) are real parallelism; re-grading it is a named open probe. - // `WOW_MT_UPDATE=1` is the A/B lever back to the multi-threaded executor. + // both pins). `WOW_MT_UPDATE=1` is the A/B lever back to the multi-threaded executor. if std::env::var_os("WOW_MT_UPDATE").is_none() { app.edit_schedule(Update, |s| { s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded); }); } - // `WOW_ST_POSTUPDATE=1` — 1366's named open probe: PostUpdate on the single-threaded - // executor, an EXPERIMENT lever only. The expectation is a REGRESSION (propagation and - // visibility are real parallel bands there, not flag-serialized non-Send work); the lever - // exists so the expectation gets measured instead of assumed. - if std::env::var_os("WOW_ST_POSTUPDATE").is_some() { + // PostUpdate too (decision 1437, closing 1366's named open probe — which expected a + // REGRESSION here and was refuted by measurement). The census: 208 systems paying + // ~10 µs/system of MT dispatch parked (schedule self 2.09 ms/f in the 1435 band map) while + // the wide bands the 1366 expectation leaned on are exactly the ones later work gated or + // pinned (1429 idle-gates the animation par sweep, 1356 pins static-transform tracking). + // Graded −0.49 cpu_ms parked (all 5 rounds negative) and winning IN MOTION as part of the + // combo (−0.76/−0.80 across two 4-round LBRS walk sittings), parked tails flat, motion + // p99/max flipping sign wholesale between sittings on both executors — 1366's own + // noise-dominated shape. `WOW_MT_POSTUPDATE=1` is the lever back; the engagement line makes + // every leg log name the config it measured. + if std::env::var_os("WOW_MT_POSTUPDATE").is_none() { app.edit_schedule(PostUpdate, |s| { s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded); }); + println!( + "executor: PostUpdate -> single-threaded (1437 default; WOW_MT_POSTUPDATE=1 for MT)" + ); } // …and one startup line says which build produced this log. Registered HERE, beside the stamp // it prints, rather than in `preflight` where it sat until decision 1179: **which build is @@ -676,6 +683,36 @@ pub fn run(build: BuildId) -> AppExit { // Register benilla-assets' loaders AFTER `AssetPlugin` (they go into the live `AssetServer`). benilla_assets::register_asset_loaders(&mut app); + // The render app's `ExtractSchedule` runs SINGLE-THREADED too (decision 1437, same grading + // as the PostUpdate flip above): bevy_render never sets an executor kind, so it ran the + // multi-threaded one by default — 0.34 ms/f of schedule self in the 1435 parked band map + // over 165 systems (census) with zero true non-Send members. Graded −0.26 parked alone, + // −0.90 parked as the combo. `WOW_MT_EXTRACT=1` is the lever back. + // + // The `Render` schedule stays MULTI-threaded, and not as a tuning judgment (1437, measured + // the hard way): under pipelined rendering the MT executor is ALSO bevy's non-Send→main- + // thread routing, and `bevy_render::view::window::create_surfaces` is non-Send precisely to + // ride it — on macOS a Metal layer can only be made on the UI thread, and the ST executor + // runs everything on the render thread (`get_metal_layer cannot be called in non-ui thread`, + // a startup panic). One system pins the schedule; its 1.06 ms/f self stays on the table + // until that coupling changes upstream. + // + // Lives HERE, not beside its siblings: the render sub-app only exists once the plugin chain + // has built (pipelining detaches it at cleanup, later still). + if std::env::var_os("WOW_MT_EXTRACT").is_none() { + if let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) { + render_app.edit_schedule(bevy::render::ExtractSchedule, |s| { + s.set_executor_kind(bevy::ecs::schedule::ExecutorKind::SingleThreaded); + }); + println!( + "executor: ExtractSchedule -> single-threaded (1437 default; WOW_MT_EXTRACT=1 for MT)" + ); + } else { + // A silently missing sub-app would flip nothing — say so instead of measuring a ghost. + eprintln!("executor: no render app — ExtractSchedule flip NOT applied"); + } + } + // **The probe fleet** — the capture harness and every scripted live probe, each armed by its // own environment variable and inert without it. Added last so they observe the fully-built // app; compiled out entirely by `--no-default-features` (decisions 1173/1174), which is why diff --git a/crates/benilla-app/src/sound/water.rs b/crates/benilla-app/src/sound/water.rs index b0a47974..d460cd40 100644 --- a/crates/benilla-app/src/sound/water.rs +++ b/crates/benilla-app/src/sound/water.rs @@ -43,10 +43,17 @@ const SPLASH_DEPTH_FRAC: f32 = 0.4; /// by entity and `WorldPoint` looks its room up. type SplashQuery = (Entity, &'static Transform, Option<&'static CollisionHeight>); +/// Only units whose depth can have changed since last frame (decision 1436): depth is a pure +/// function of pose + collision height (the room claim follows the pose, liquid surfaces are +/// static), so an unmoved unit cannot cross the line — and the per-unit `water_surface_at` +/// lookup for EVERY unit priced 0.19 ms/f parked in the 1435 band map. A unit's first frame is +/// `Added ⊆ Changed`, so the silent first-seen arming below still happens. +type SplashGate = Or<(Changed, Changed)>; + /// Play the water splash on a unit's `0.4·h` depth-line crossing, either direction (module docs). #[allow(clippy::too_many_arguments)] // the sound-play plumbing, one param per concern fn water_splashes( - units: Query>, + units: Query, SplashGate)>, world: benilla_world::world_point::WorldPoint, mut wet: Local>, kits: Option>, diff --git a/crates/benilla-world/src/bgwin.rs b/crates/benilla-world/src/bgwin.rs index 38b862e0..1de0b020 100644 --- a/crates/benilla-world/src/bgwin.rs +++ b/crates/benilla-world/src/bgwin.rs @@ -66,6 +66,7 @@ const BG_ENV_PREFIXES: &[&str] = &[ "WOW_PORTRAIT_TEST", "WOW_PROBE", "WOW_RIG", + "WOW_SCHED_CENSUS", "WOW_WORLDVIEW_", ]; @@ -99,6 +100,7 @@ const NO_PIXEL_ENV_PREFIXES: &[&str] = &[ "WOW_PARTICLE_CENSUS", "WOW_PROBE", "WOW_RIG", + "WOW_SCHED_CENSUS", // The engine boot check reads no pixels — it reads the ERROR LOG. Its sibling // `WOW_WORLDVIEW_SHOT` is deliberately absent: that one photographs the frame, and the // all-of rule below keeps a CHECK+SHOT pairing at full size. diff --git a/crates/benilla-world/src/boot.rs b/crates/benilla-world/src/boot.rs index ab25c751..b022b8f5 100644 --- a/crates/benilla-world/src/boot.rs +++ b/crates/benilla-world/src/boot.rs @@ -91,4 +91,26 @@ pub fn tuned_default_plugins(primary_window: Window) -> PluginGroupBuilder { // still compiles in via bevy's default feature — trimming the feature set is a separate, // wider call. .disable::() + // The dead registrations (decision 1438): DefaultPlugins members whose only runtime + // trace here was per-frame machinery for types nothing instantiates — every registered + // asset/material type costs an `Assets` event system in PostUpdate plus + // extract/prepare/sweep families in the render app, priced by the 1437 census against + // the 1435 band map. Each cut was usage-grepped NEGATIVE and then proven by a clean + // boot, and the boot vetoed two of the five candidates the greps had passed (its + // missing-resource panic names the dependent): gizmos are the bowstring/fishing-line + // renderer (0938, warmed through a bare-`Gizmos` param no `Gizmos<` pattern sees), and + // the sprite pair carries our OWN FrameXML quad pass — `UiQuadMaterial` is a + // `Material2d` riding `Mesh2dPipeline`. What else stays, stays for a reason: + // bevy_picking drives the world-interact lane, TextPlugin draws the glue-screen text, + // PostProcessPlugin is the EffectGlow bloom, and two members are pinned by upstream + // exactly like 1437's Render-MT — ScenePlugin (avian's collider backend reads + // `SceneSpawner` even in a meshless world; our own harness tests document it) and + // PbrPlugin's ForwardDecal family (registered inside PbrPlugin::build). + // + // We load M2/WMO/ADT through our own mpq:// loaders — no glTF anywhere. + .disable::() + // No bevy AA: no Fxaa/TAA/SMAA/CAS component anywhere (MSAA is core render, unaffected). + .disable::() + // No gamepad input; 1.12's bindings are keyboard/mouse (0997). + .disable::() } diff --git a/crates/benilla-world/src/static_gx/bake.rs b/crates/benilla-world/src/static_gx/bake.rs index 13550532..8bfe5478 100644 --- a/crates/benilla-world/src/static_gx/bake.rs +++ b/crates/benilla-world/src/static_gx/bake.rs @@ -65,7 +65,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut, mut meshes: ResMut, mut meshes: ResMut, mut meshes: ResMut), } -/// The published half the render world clones each frame (handles + ranges — cheap). +/// The published half the render world clones each frame. The baked regions sit behind `Arc` +/// (decision 1436): the 1435 band map priced the publish + extract clone pair at 0.39 ms/f — +/// tens of thousands of `GxItemDraw`s memcpy'd twice a frame — so the per-frame clones are +/// refcount bumps now, and the ONE writer that mutates a published region (the kill scan's +/// bitmap rebuild) pays a copy-on-write of that region alone, only on a real fade transition. #[derive(Clone, Default, Resource, ExtractResource)] pub(crate) struct GxWorld { - pub cells: HashMap<(i32, i32), GxCellDraw>, + pub cells: HashMap<(i32, i32), std::sync::Arc>, /// This frame's doodad-phase draw list, near-first across cells AND prop regions (B4): /// frustum + farclip + exterior gate at cell/set granularity, PVS per set. pub visible: Vec, /// The WMO regions (slice 2), keyed by placement instance entity. - pub wmos: HashMap, + pub wmos: HashMap>, /// The prop regions (B4), keyed by the same instance entity as `wmos` (their lifecycle), /// held apart so prop arrivals never re-bake building geometry. - pub props: HashMap, + pub props: HashMap>, /// This frame's per-group admission per region (indexed by absolute group index): the /// portal flood's verdict collapsed to CPU range selection — the node draws exactly the /// runs whose group bit is set. diff --git a/crates/benilla-world/src/sun/follow.rs b/crates/benilla-world/src/sun/follow.rs index aadbdaba..677de992 100644 --- a/crates/benilla-world/src/sun/follow.rs +++ b/crates/benilla-world/src/sun/follow.rs @@ -150,10 +150,21 @@ fn flare_ray_clear(height_under: impl Fn(Vec3) -> Option, cam_pos: Vec3, di true } -/// Ray-grid side count for [`flare_visible_fraction`] — 4×4 = 16 rays; the envelope slew smooths -/// the 1/16 quantization between frames. +/// Ray-grid side count for the occ3 fraction — 4×4 = 16 rays; the envelope slew smooths the 1/16 +/// quantization between frames. const FLARE_FRACTION_GRID: u32 = 4; +/// The grid's cell count — the fraction's denominator and the cached mask's width. +const FLARE_CELLS: u32 = FLARE_FRACTION_GRID * FLARE_FRACTION_GRID; + +/// How many grid cells the probe re-marches per frame (decision 1436): the full 16×48-sample march +/// every frame priced 0.45 ms/f — the biggest single system in the 1435 parked band map — for a +/// value the slew rate-limits anyway. Two cells/frame refreshes the whole grid in 8 frames +/// (0.13 s at 60 Hz), inside BOTH slew time constants (rise 0.25 s, fall 0.66 s), so the drip is +/// invisible under the smoothing that already covers the grid's own quantization. The faithful +/// end-state stays the reference's GPU occlusion query (`0x7e5220`, recorded below). +const FLARE_RAYS_PER_FRAME: u32 = 2; + /// The **visible fraction** of the body's quad footprint against resident terrain ∈ [0, 1] — our /// CPU stand-in for the reference's fractional occlusion probe (`0x7e5220`, Addendum #8): the real /// client draws the disc's OWN quad (same size/position) depth-tested inside a GPU occlusion query @@ -162,30 +173,61 @@ const FLARE_FRACTION_GRID: u32 = 4; /// half-size), each ray the same quadratic terrain march — fraction = clear rays / total rays. /// Terrain-only, like the march it generalizes; the reference's z-buffer probe also catches /// WMOs/doodads — the GPU-occlusion-query instrument remains the faithful end-state (recorded). +/// Since decision 1436 the live path drips cells through [`flare_mask_update`] instead; the full +/// one-call march stays as the convergence oracle for the tests. +#[cfg(test)] fn flare_visible_fraction( height_under: impl Fn(Vec3) -> Option, cam_pos: Vec3, dir: Vec3, half: f32, ) -> f32 { + let clear = (0..FLARE_CELLS) + .filter(|&c| flare_cell_clear(&height_under, cam_pos, dir, half, c)) + .count(); + clear as f32 / FLARE_CELLS as f32 +} + +/// March ONE grid cell of the quad's [−half, +half]² angular footprint — the unit the round-robin +/// re-prices per frame (decision 1436). Cell `c` = row `c / 4`, column `c % 4`, centre-sampled. +fn flare_cell_clear( + height_under: impl Fn(Vec3) -> Option, + cam_pos: Vec3, + dir: Vec3, + half: f32, + cell: u32, +) -> bool { // Any orthonormal frame across the camera-facing quad works — the probe is symmetric. let right = dir.cross(Vec3::Y).normalize_or_zero(); let right = if right == Vec3::ZERO { Vec3::X } else { right }; let up = right.cross(dir); let n = FLARE_FRACTION_GRID; - let mut clear = 0u32; - for i in 0..n { - for j in 0..n { - // Cell centres over the quad's [−half, +half]² angular footprint. - let u = ((i as f32 + 0.5) / n as f32 - 0.5) * 2.0 * half; - let v = ((j as f32 + 0.5) / n as f32 - 0.5) * 2.0 * half; - let d = (dir + right * u + up * v).normalize(); - if flare_ray_clear(&height_under, cam_pos, d) { - clear += 1; - } + let u = ((cell / n) as f32 + 0.5) / n as f32 - 0.5; + let v = ((cell % n) as f32 + 0.5) / n as f32 - 0.5; + let d = (dir + right * (u * 2.0 * half) + up * (v * 2.0 * half)).normalize(); + flare_ray_clear(&height_under, cam_pos, d) +} + +/// One frame's round-robin step over the cached cell mask (bit set = that cell's ray is clear): +/// re-march `cells` cells from `cursor`, then read the WHOLE mask as the fraction. Pure, for the +/// convergence test; [`FlareGate::envelope`] owns the state. +fn flare_mask_update( + mask: &mut u16, + cursor: &mut u32, + cells: u32, + clear_of: impl Fn(u32) -> bool, +) -> f32 { + for _ in 0..cells { + let c = *cursor % FLARE_CELLS; + let bit = 1u16 << c; + if clear_of(c) { + *mask |= bit; + } else { + *mask &= !bit; } + *cursor = (c + 1) % FLARE_CELLS; } - clear as f32 / (n * n) as f32 + f32::from(mask.count_ones() as u16) / FLARE_CELLS as f32 } /// The flare **envelope** — our build of the reference's `[glare+0x30]` smoothed intensity @@ -217,6 +259,13 @@ pub(super) struct FlareGate<'w, 's> { /// the visible layer, like the reference). clouds: Res<'w, CloudCoverage>, env: Local<'s, f32>, + /// The occ3 grid's cached per-cell verdicts (bit = clear) and the round-robin cursor + /// (decision 1436): [`FLARE_RAYS_PER_FRAME`] cells re-march per active frame. `primed` + /// falls when the probe is skipped (night, interior, below horizon) so the first active + /// frame after a gap marches the whole grid instead of trusting a stale mask. + mask: Local<'s, u16>, + cursor: Local<'s, u32>, + primed: Local<'s, bool>, } /// The weather **celestial-alpha seed** (Addendum #6): under active weather the recompute writes @@ -244,18 +293,28 @@ impl FlareGate<'_, '_> { ) -> f32 { let base = dn * horizon_gate(dir) * occ1; let target = if base > 0.0 && self.camera_interior.0.is_none() { - base * flare_visible_fraction( - // Resident detailed terrain first; the coarse WDL surface everywhere else (it - // covers the whole map, so it also plugs the ADT-ring-to-farclip gap). - |p| { - terrain_height_under(&self.streamer, &self.adt_tiles, p) - .or_else(|| self.wdl.as_ref().and_then(|w| w.height_under(p))) - }, - cam_pos, - dir, - half, - ) + // Resident detailed terrain first; the coarse WDL surface everywhere else (it + // covers the whole map, so it also plugs the ADT-ring-to-farclip gap). + let (streamer, adt_tiles, wdl) = (&self.streamer, &self.adt_tiles, &self.wdl); + let oracle = |p| { + terrain_height_under(streamer, adt_tiles, p) + .or_else(|| wdl.as_ref().and_then(|w| w.height_under(p))) + }; + // The round-robin drip (decision 1436): an unprimed mask marches every cell once, + // a primed one re-prices FLARE_RAYS_PER_FRAME — the slew smooths the ≤8-frame + // staleness exactly as it smooths the grid's own 1/16 quantization. + let cells = if *self.primed { + FLARE_RAYS_PER_FRAME + } else { + FLARE_CELLS + }; + *self.primed = true; + let fraction = flare_mask_update(&mut self.mask, &mut self.cursor, cells, |c| { + flare_cell_clear(oracle, cam_pos, dir, half, c) + }); + base * fraction } else { + *self.primed = false; 0.0 }; *self.env = flare_slew(*self.env, target, rise, FLARE_FALL, self.time.delta_secs()); @@ -738,6 +797,29 @@ mod tests { ); } + /// The 1436 round-robin drip is the full march, just spread over frames: priming reads the + /// exact full-march fraction, and a whole 2-cells-per-step cycle over an unchanged scene + /// lands back on it (the mask never reads a partial window — every step reads all 16 bits). + #[test] + fn flare_mask_drip_converges_to_the_full_march() { + let cam = Vec3::new(0.0, 2.0, 0.0); + let e = 5.4_f32.to_radians(); + let dir = Vec3::new(e.cos(), e.sin(), 0.0); // straddles the ridge crest — a partial mask + let half = 2.0_f32.to_radians(); + let full = flare_visible_fraction(ridge, cam, dir, half); + assert!((0.0..1.0).contains(&full), "oracle must be partial: {full}"); + let (mut mask, mut cursor) = (0u16, 0u32); + let march = |c| flare_cell_clear(ridge, cam, dir, half, c); + let primed = flare_mask_update(&mut mask, &mut cursor, FLARE_CELLS, march); + assert_eq!(primed, full, "priming = the full march in one call"); + let mut last = primed; + for _ in 0..(FLARE_CELLS / FLARE_RAYS_PER_FRAME) { + last = flare_mask_update(&mut mask, &mut cursor, FLARE_RAYS_PER_FRAME, march); + } + assert_eq!(last, full, "a full drip cycle re-derives the same fraction"); + assert_eq!(cursor, 0, "the cursor wrapped exactly once"); + } + #[test] fn disc_span_feeds_the_per_vertex_fade_regimes() { // Elevated (every fixture's regime): the whole span sits far above the ~1.9° fade band