Files
benilla-octo/crates/benilla-world/src/liquid/spatial.rs
T
SamandClaude Fable 5 a8a7d85ca2 six lanes stop paying for the idle crowd, and Print Screen lands
- Print Screen takes a screenshot to `benilla-config/Screenshots/` (F13 on macOS), shows the reference's "Screen Captured" line, and the line never appears in the file — the capture defers one frame so the hidden confirmation's quads rebuild first; and the rule it surfaced is now hard law: benilla never writes anything into the WoW install
- six per-frame taxes now skip when their inputs are settled: water foam asks a spatial index (a dry unit is one hash miss, was a ~2.2k-surface scan per unit), an idle remote stops its capsule sweeps at the fixed point, hover picks stop allocating ahead of the ray-sphere reject, an unchanged unit's loadout is not re-derived, and the ground-shade walk stops re-asserting the world — settled inputs produce bitwise-identical outputs, each lane fenced by its own test; ms pricing still waits on a quiet machine
- overhead names take the V-plates' 20-yard cap, read at the bytes (this one is wrong and reverts in the next snapshot — the law belongs to the other nameplate system)
- the calendar lifts out of the `date()` global into its own module

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:04:55 +07:00

184 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The liquid **spatial index** — which surfaces are over an XY, without walking them all.
//!
//! [`super::surfaces_at`] answers "what liquid is at this XY" by testing every loaded
//! [`WaterChunkInfo`] in turn. That is fine for the consumers that ask once or twice a frame
//! (the camera waterline, the submersion verdict, a footstep) — and it detonates the moment a
//! consumer asks per *draw*: the water-plane interleave's mesh lane (0919) classified ~13k
//! transparent batches against ~2.2k loaded surfaces every frame — 29 M box tests, 54 ms, the
//! 2026-08-03 "60 → 12 fps" regression, measured at the Stormwind pin by the live FPS probe.
//!
//! The index is a plain XY grid hash: one bucket per [`CELL`]-sized cell, each holding every
//! surface whose wet-footprint box overlaps that cell. A point query is one hash lookup, and the
//! hot path re-uses the exact same predicates ([`WaterChunkInfo::contains`] et al.) on the
//! handful of candidates it returns — the verdict set is identical to the full walk by
//! construction, because a surface is registered in every cell its box overlaps.
//!
//! Membership only changes when surfaces stream in or out with their tiles, so the rebuild is
//! edge-triggered (`Added` / `RemovedComponents`) and costs nothing on a steady frame. A stale
//! entry between despawn and rebuild self-filters at the consumer (`Query::get` on a dead entity
//! misses), and a surface spawned this frame is indexed before the next frame's consumers run.
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use super::query::WaterChunkInfo;
/// Cell pitch, yards: one MCNK (100/3). An ADT surface's wet footprint fits one-to-few cells and
/// a WMO pool's larger box lands in a handful (Stormwind's biggest MLIQ box, 95 × 80 yd, spans
/// ~12) — buckets stay short, and a dry-land query misses the map entirely.
const CELL: f32 = 100.0 / 3.0;
/// The XY grid hash over every loaded liquid surface (module docs). Read via [`Self::over`];
/// maintained by [`maintain_water_index`].
#[derive(Resource, Default)]
pub(crate) struct WaterIndex {
cells: HashMap<[i32; 2], Vec<Entity>>,
}
impl WaterIndex {
/// The cell containing a WoW-space XY.
fn cell_of(x: f32, y: f32) -> [i32; 2] {
[(x / CELL).floor() as i32, (y / CELL).floor() as i32]
}
/// Every surface whose wet-footprint box overlaps the cell containing this WoW-space XY — the
/// candidate set for [`super::surfaces_at`], a superset of the surfaces actually containing
/// the point (the caller's own `contains`/`answers` tests still decide).
pub(crate) fn over(&self, x: f32, y: f32) -> &[Entity] {
self.cells
.get(&Self::cell_of(x, y))
.map_or(&[], Vec::as_slice)
}
/// Every surface registered in any cell the WoW-space box `[lo, hi]` touches, deduplicated —
/// the candidate set for a small box query (the foam patch builder), with [`Self::over`]'s
/// same superset guarantee: a surface overlapping the box overlaps one of its cells, and a
/// surface is registered in every cell its footprint overlaps. Boxes here are a few yards
/// (1–4 cells), buckets are short — the linear dedup is the cheap shape.
pub(crate) fn over_box(&self, lo: [f32; 2], hi: [f32; 2]) -> Vec<Entity> {
let [x0, y0] = Self::cell_of(lo[0], lo[1]);
let [x1, y1] = Self::cell_of(hi[0], hi[1]);
let mut out = Vec::new();
for cx in x0..=x1 {
for cy in y0..=y1 {
for &e in self.cells.get(&[cx, cy]).map_or(&[][..], Vec::as_slice) {
if !out.contains(&e) {
out.push(e);
}
}
}
}
out
}
}
/// Rebuild [`WaterIndex`] when the surface population changed — tile stream edges only. A full
/// rebuild over ~2k surfaces is microseconds, so incremental bookkeeping would be complexity
/// with nothing to buy.
pub(crate) fn maintain_water_index(
mut index: ResMut<WaterIndex>,
added: Query<(), Added<WaterChunkInfo>>,
mut removed: RemovedComponents<WaterChunkInfo>,
chunks: Query<(Entity, &WaterChunkInfo)>,
) {
if removed.read().next().is_none() && added.is_empty() {
return;
}
index.cells.clear();
for (entity, chunk) in &chunks {
let Some([lo, hi]) = chunk.xy_bounds() else {
continue; // an empty grid claims no area
};
let [x0, y0] = WaterIndex::cell_of(lo[0], lo[1]);
let [x1, y1] = WaterIndex::cell_of(hi[0], hi[1]);
for cx in x0..=x1 {
for cy in y0..=y1 {
index.cells.entry([cx, cy]).or_default().push(entity);
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::query::{LiquidClaim, LiquidSource};
use super::*;
use crate::liquid::surfaces_at;
use benilla_formats::LiquidKind;
/// A flat all-wet `cols × rows` grid at height `z` with vertex `(0,0)` at `(x0, y0)`,
/// 10 yd pitch.
fn chunk(x0: f32, y0: f32, z: f32, cols: usize, rows: usize) -> WaterChunkInfo {
let positions = (0..rows)
.flat_map(|j| (0..cols).map(move |i| [x0 + 10.0 * i as f32, y0 + 10.0 * j as f32, z]))
.collect();
WaterChunkInfo::new(
LiquidSource::AdtChunk,
LiquidKind::Still,
[cols, rows],
positions,
vec![true; (cols - 1) * (rows - 1)],
)
}
/// An app with just the maintainer, so `Added`/`RemovedComponents` drive it as they do live.
fn app() -> App {
let mut app = App::new();
app.init_resource::<WaterIndex>()
.add_systems(Update, maintain_water_index);
app
}
/// The index answers exactly like the full walk — same surfaces, same heights — from inside
/// a footprint, at its edge, and from dry land; and a surface spanning many cells is found
/// from every one of them.
#[test]
fn indexed_candidates_match_the_full_walk() {
let mut app = app();
// One small pool and one large one overlapping it, both crossing CELL boundaries.
app.world_mut().spawn(chunk(-20.0, -20.0, 5.0, 3, 3));
app.world_mut().spawn(chunk(-50.0, -50.0, 8.0, 12, 12));
app.update();
let world = app.world_mut();
let index = world.remove_resource::<WaterIndex>().unwrap();
let mut chunks = world.query::<&WaterChunkInfo>();
for x in (-60..=70).step_by(7) {
for y in (-60..=70).step_by(7) {
let wow = [x as f32, y as f32, 0.0];
let mut walk: Vec<f32> =
surfaces_at(chunks.iter(world), wow, LiquidClaim::Outdoors).collect();
let candidates: Vec<&WaterChunkInfo> = index
.over(wow[0], wow[1])
.iter()
.filter_map(|&e| chunks.get(world, e).ok())
.collect();
let mut indexed: Vec<f32> =
surfaces_at(candidates.into_iter(), wow, LiquidClaim::Outdoors).collect();
walk.sort_by(f32::total_cmp);
indexed.sort_by(f32::total_cmp);
assert_eq!(walk, indexed, "divergence at ({x}, {y})");
}
}
}
/// Despawning the last surface empties the index on the next pass — the stream-out edge.
#[test]
fn a_despawned_surface_leaves_the_index() {
let mut app = app();
let e = app.world_mut().spawn(chunk(0.0, 0.0, 5.0, 3, 3)).id();
app.update();
assert!(!app
.world()
.resource::<WaterIndex>()
.over(5.0, 5.0)
.is_empty());
app.world_mut().entity_mut(e).despawn();
app.update();
assert!(app
.world()
.resource::<WaterIndex>()
.over(5.0, 5.0)
.is_empty());
}
}