three parked costs burned, two schedules go single-threaded
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<impl FromWorld>`
|
||||
//! 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::<DumpedSchedules>()
|
||||
.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::<DumpedSchedules>()
|
||||
.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<String>);
|
||||
|
||||
/// 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<u32>) {
|
||||
move |world: &mut World, mut frame: Local<u32>| {
|
||||
*frame += 1;
|
||||
if *frame != CENSUS_FRAME {
|
||||
return;
|
||||
}
|
||||
world.resource_scope::<DumpedSchedules, _>(|world, mut dumped| {
|
||||
world.resource_scope::<Schedules, _>(|_, 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<String> = 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<u32>, mut exit: MessageWriter<AppExit>) {
|
||||
*frame += 1;
|
||||
if *frame == EXIT_FRAME {
|
||||
println!("SCHED_CENSUS_DONE");
|
||||
exit.write(AppExit::Success);
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Transform>, Changed<CollisionHeight>)>;
|
||||
|
||||
/// 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<SplashQuery, With<NetEntity>>,
|
||||
units: Query<SplashQuery, (With<NetEntity>, SplashGate)>,
|
||||
world: benilla_world::world_point::WorldPoint,
|
||||
mut wet: Local<EntityHashMap<bool>>,
|
||||
kits: Option<ResMut<SoundKits>>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<bevy::audio::AudioPlugin>()
|
||||
// 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<T>` 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::<bevy::gltf::GltfPlugin>()
|
||||
// No bevy AA: no Fxaa/TAA/SMAA/CAS component anywhere (MSAA is core render, unaffected).
|
||||
.disable::<bevy::anti_alias::AntiAliasPlugin>()
|
||||
// No gamepad input; 1.12's bindings are keyboard/mouse (0997).
|
||||
.disable::<bevy::gilrs::GilrsPlugin>()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
state.items.len(),
|
||||
baked.draws.last().map_or(0, |d| d.vertex_range.end),
|
||||
);
|
||||
world.cells.insert(cell, baked);
|
||||
world.cells.insert(cell, std::sync::Arc::new(baked));
|
||||
}
|
||||
for (&instance, state) in wmos.iter_mut() {
|
||||
if !bake_due(state, world.wmos.contains_key(&instance), frame) {
|
||||
@@ -93,7 +93,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
baked.groups.len(),
|
||||
baked.draws.last().map_or(0, |d| d.vertex_range.end),
|
||||
);
|
||||
world.wmos.insert(instance, baked);
|
||||
world.wmos.insert(instance, std::sync::Arc::new(baked));
|
||||
}
|
||||
// The prop regions (B4): the WMO loop's shape with the referrer SET as the selection
|
||||
// grain — the sort keeps runs set-homogeneous, and the published draw carries the
|
||||
@@ -122,7 +122,7 @@ pub(super) fn flush_static_gx(mut gx: ResMut<StaticGx>, mut meshes: ResMut<Asset
|
||||
baked.sets.len(),
|
||||
baked.draws.last().map_or(0, |d| d.vertex_range.end),
|
||||
);
|
||||
world.props.insert(instance, baked);
|
||||
world.props.insert(instance, std::sync::Arc::new(baked));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +338,10 @@ pub(super) fn cull_cells(
|
||||
cell.settled = if unarmed_left { None } else { verdict };
|
||||
if bits_changed {
|
||||
if let Some(draw) = world.cells.get_mut(&key) {
|
||||
// Copy-on-write (1436): the region is Arc-shared with the render world's
|
||||
// extracted clone; a real bitmap change — the only writer — pays one region
|
||||
// copy here so the per-frame publish/extract clones stay refcount bumps.
|
||||
let draw = std::sync::Arc::make_mut(draw);
|
||||
// Rebuild whole from the states — cheap, and immune to index drift across
|
||||
// re-bakes (the flush refreshed `items` and set `bits_stale` this frame).
|
||||
draw.killed.iter_mut().for_each(|w| *w = 0);
|
||||
|
||||
@@ -122,18 +122,22 @@ pub(crate) enum GxDoodadVis {
|
||||
Prop(Entity, Vec<bool>),
|
||||
}
|
||||
|
||||
/// 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<GxCellDraw>>,
|
||||
/// 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<GxDoodadVis>,
|
||||
/// The WMO regions (slice 2), keyed by placement instance entity.
|
||||
pub wmos: HashMap<Entity, GxCellDraw>,
|
||||
pub wmos: HashMap<Entity, std::sync::Arc<GxCellDraw>>,
|
||||
/// 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<Entity, GxCellDraw>,
|
||||
pub props: HashMap<Entity, std::sync::Arc<GxCellDraw>>,
|
||||
/// 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.
|
||||
|
||||
@@ -150,10 +150,21 @@ fn flare_ray_clear(height_under: impl Fn(Vec3) -> Option<f32>, 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<f32>,
|
||||
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<f32>,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user