the waterline stops stepping, and mobs in far rooms lose the keep's fog
- the step in the water at the shore, which travelled with you as you walked: `WATER_BIAS` (−2e4) exists to give the water pass its sort slot, and bevy's `StandardMaterial` also packs that f32 into the rasterizer's depth bias, so every water surface in the game shipped with a −20000 depth pull — on the one world surface that meets the ground at a grazing angle over tens of yards that does not settle a tie, it moves the waterline, and a float depth buffer's bias unit doubles at every exponent boundary, so two water triangles either side of one stopped agreeing about where the shore is, at a fixed distance from the eye; the bias is out of the rasterizer (a ~17 px jog at the reported Stranglethorn beach, present before and absent after) - the foam wake went dull under the next water chunk and painted onto dry sand: it sorted against ONE water surface with a coplanar epsilon (the chunk 33 yd nearer the eye outranked it thirty times over, while the reference drains every liquid queue before it draws foam, `0x6816d0`), and its coplanar settle was a 0.03 yd geometric LIFT, which on a 29% beach is 0.10 yd of dry sand painted and proportionally more on every flatter shore; it is a rung above the whole water band now and sits exactly on the plane, and the RE on its depth state came back: a depth-buffer bias (`footstepBias × [0x810390]`), depth-tested LEQUAL with write off — the "−2048 GL units polygon offset" our comment justified the lift with existed nowhere in wow-re - a unit standing in a room the camera's chain does not reach wore the building's fog while the walls around it wore the scene's: the reference's unit test has a second conjunct (`[P+0x98]`, the same `[0xca7f00]` chain gate the walls use), so the attach ray now returns the room it claimed and the gate is re-read every frame because it moves with the CAMERA — at Shadowfang, from inside the pinned room, three true interiors are in frame across the courtyards and a mob in g13, 91 yd out, wore the keep's teal at ~84% against a scene fog of ~3% - two instruments for the shore class of report: `water_here <map> <x> <y>` reads a shoreline offline (the wet-cell lattice, the plane on it, the terrain under it, and the four scans that tell an authored defect from a rendered one), and `WOW_CAPTURE=waterfx WOW_WFX_AT=x,y,z` wades the foam rig in the REAL streamed water at a pin, where the synthetic square had no bank to clip against Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -638,10 +638,11 @@ impl Plugin for CapturePlugin {
|
||||
ui: None,
|
||||
}
|
||||
} else if name == "waterfx" {
|
||||
// The water-foam viewer (see `water_fx::view`): a synthetic wading unit over a
|
||||
// synthetic wet lattice, framed by a fixed orbit around the rig centre. Knobs:
|
||||
// WOW_WFX_MODE (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s),
|
||||
// WOW_WFX_DEPTH (yd), camera WOW_WFX_AZ/EL/DIST. Not a golden scenario.
|
||||
// The water-foam viewer (see `capture::waterfx`): a wading unit over a synthetic
|
||||
// wet lattice — or, with WOW_WFX_AT=x,y,z, in the real streamed liquid at that pin
|
||||
// — framed by a fixed orbit around the rig centre. Knobs: WOW_WFX_MODE
|
||||
// (ring|wake|turn), WOW_WFX_SPEED (yd/s), WOW_WFX_AGE (s), WOW_WFX_DEPTH (yd),
|
||||
// camera WOW_WFX_AZ/EL/DIST. Not a golden scenario.
|
||||
let knob = |k: &str, d: f32| {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
@@ -653,9 +654,17 @@ impl Plugin for CapturePlugin {
|
||||
Ok("turn") => waterfx::WfxMode::Turn,
|
||||
_ => waterfx::WfxMode::Ring,
|
||||
};
|
||||
// Rig centre in raw WoW coords: over the Northshire ground scene, the synthetic
|
||||
// surface a few yards above the terrain so the backdrop plane reads clean.
|
||||
let center = [-8961.0_f32, -145.0, 95.0];
|
||||
// Rig centre in raw WoW coords. Default: over the Northshire ground scene, the
|
||||
// synthetic surface a few yards above the terrain so the backdrop plane reads
|
||||
// clean. `WOW_WFX_AT=x,y,z` moves it anywhere and switches the rig to the REAL
|
||||
// streamed liquid there (`z` = that water's surface height) — a synthetic square
|
||||
// of water has no bank to clip against and no neighbour to be sorted against.
|
||||
let at = std::env::var("WOW_WFX_AT").ok().and_then(|v| {
|
||||
let c: Vec<f32> = v.split(',').filter_map(|p| p.trim().parse().ok()).collect();
|
||||
(c.len() == 3).then(|| [c[0], c[1], c[2]])
|
||||
});
|
||||
let live = at.is_some();
|
||||
let center = at.unwrap_or([-8961.0_f32, -145.0, 95.0]);
|
||||
let az = knob("WOW_WFX_AZ", 180.0).to_radians();
|
||||
let el = knob("WOW_WFX_EL", 35.0).to_radians();
|
||||
let dist = knob("WOW_WFX_DIST", 14.0);
|
||||
@@ -670,11 +679,17 @@ impl Plugin for CapturePlugin {
|
||||
age: knob("WOW_WFX_AGE", 1.5),
|
||||
center,
|
||||
depth: knob("WOW_WFX_DEPTH", 0.5),
|
||||
live,
|
||||
})
|
||||
.init_resource::<FxViewState>();
|
||||
Scenario {
|
||||
name: "waterfx",
|
||||
map: scenarios::MAP_AZEROTH, // the synthetic lattice sits over the Northshire slope
|
||||
// The synthetic lattice sits over the Northshire slope; a live rig
|
||||
// (`WOW_WFX_AT`) goes wherever its pin is, so its map is a knob like `vista`'s.
|
||||
map: std::env::var("WOW_MAP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(scenarios::MAP_AZEROTH),
|
||||
eye,
|
||||
look: center,
|
||||
minute: 720,
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
//! `WOW_WFX_AGE` (s of motion before the shot), `WOW_WFX_DEPTH` (yd below the surface; > ~0.8
|
||||
//! also exercises the step-in one-shot), camera `WOW_WFX_AZ`/`EL`/`DIST`. Not a golden scenario —
|
||||
//! output depends on the knobs.
|
||||
//!
|
||||
//! **`WOW_WFX_AT=x,y,z` wades the dummy in the REAL world instead** (`z` = the liquid surface
|
||||
//! height there, which `benilla-formats --example water_here` prints). The synthetic lattice and
|
||||
//! backdrop stand down and the rig wades in the streamed ADT/WMO liquid at that pin — which is the
|
||||
//! only way to see the two things a synthetic square of water cannot show: how a patch **clips at a
|
||||
//! real bank**, and how it **sorts against the neighbouring water chunks** (B348 — one square of
|
||||
//! water has no neighbour to be painted over by). `WOW_MAP` picks the map.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -42,6 +49,10 @@ pub(crate) struct WaterFxView {
|
||||
pub(crate) center: [f32; 3],
|
||||
/// Feet depth below the surface (yd) — must land inside the wading gate.
|
||||
pub(crate) depth: f32,
|
||||
/// Wade in the **real** streamed liquid at [`Self::center`] (`WOW_WFX_AT`) rather than over the
|
||||
/// synthetic lattice: no backdrop, no fixture water, just the dummy. A real shoreline is the
|
||||
/// only rig that exercises bank clipping and multi-chunk sorting.
|
||||
pub(crate) live: bool,
|
||||
}
|
||||
|
||||
/// Marks the rig's entities (spawn-once guard).
|
||||
@@ -71,6 +82,13 @@ pub(crate) fn spawn(
|
||||
}
|
||||
let [cx, cy, surf] = view.center;
|
||||
|
||||
if view.live {
|
||||
spawn_dummy(&mut commands, &view, cx, cy, surf);
|
||||
state.attached_at = Some(time.elapsed_secs());
|
||||
info!("waterfx: rig armed in LIVE water at ({cx}, {cy}, {surf})");
|
||||
return;
|
||||
}
|
||||
|
||||
// A big flat mid-gray "water body" backdrop 0.15 yd under the surface, so the additive foam
|
||||
// reads against a stable tone (the real liquid shader is beside the point here).
|
||||
commands.spawn((
|
||||
@@ -111,8 +129,25 @@ pub(crate) fn spawn(
|
||||
Transform::IDENTITY,
|
||||
));
|
||||
|
||||
// The wading dummy: a plain streamed-unit shape (no display id, visual pre-attached so the
|
||||
// entity subsystem never gives it a fallback cube over the foam).
|
||||
spawn_dummy(&mut commands, &view, cx, cy, surf);
|
||||
state.attached_at = Some(time.elapsed_secs());
|
||||
info!(
|
||||
"waterfx: rig armed (mode {}, speed {}, depth {}, age {})",
|
||||
match view.mode {
|
||||
WfxMode::Ring => "ring",
|
||||
WfxMode::Wake => "wake",
|
||||
WfxMode::Turn => "turn",
|
||||
},
|
||||
view.speed,
|
||||
view.depth,
|
||||
view.age
|
||||
);
|
||||
}
|
||||
|
||||
/// The wading dummy: a plain streamed-unit shape (no display id, visual pre-attached so the entity
|
||||
/// subsystem never gives it a fallback cube over the foam). A wake starts far enough back that its
|
||||
/// trail ENDS at the rig centre after `age` seconds.
|
||||
fn spawn_dummy(commands: &mut Commands, view: &WaterFxView, cx: f32, cy: f32, surf: f32) {
|
||||
let start_x = if view.mode == WfxMode::Wake {
|
||||
cx - view.speed * view.age
|
||||
} else {
|
||||
@@ -140,18 +175,6 @@ pub(crate) fn spawn(
|
||||
crate::entities::VisualAttached,
|
||||
Transform::from_translation(wow_to_bevy([start_x, cy, surf - view.depth])),
|
||||
));
|
||||
state.attached_at = Some(time.elapsed_secs());
|
||||
info!(
|
||||
"waterfx: rig armed (mode {}, speed {}, depth {}, age {})",
|
||||
match view.mode {
|
||||
WfxMode::Ring => "ring",
|
||||
WfxMode::Wake => "wake",
|
||||
WfxMode::Turn => "turn",
|
||||
},
|
||||
view.speed,
|
||||
view.depth,
|
||||
view.age
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive the dummy each frame: translate (wake) or spin (turn) until the age elapses; the foam
|
||||
|
||||
@@ -613,10 +613,19 @@ fn warm_effect_lane(
|
||||
EffectBlend::Multiply,
|
||||
EffectBlend::Mod2x,
|
||||
] {
|
||||
for raster_bias in [
|
||||
0,
|
||||
benilla_world::sky_order::Rung::DECAL_RASTER,
|
||||
benilla_world::sky_order::Rung::SHADOW_RASTER,
|
||||
// The rasterizer settle is a PAIR — constant and slope-scale — and both halves are
|
||||
// pipeline-key axes, so they are warmed as the pairs that actually ship, not as a
|
||||
// cross product. The foam's is the reference's own (`Rung::FOAM_RASTER`, 1809) and is
|
||||
// the only one carrying a slope term; a hole here is a live compile the first time
|
||||
// anyone wades.
|
||||
for (raster_bias, raster_slope) in [
|
||||
(0, 0.0),
|
||||
(benilla_world::sky_order::Rung::DECAL_RASTER, 0.0),
|
||||
(benilla_world::sky_order::Rung::SHADOW_RASTER, 0.0),
|
||||
(
|
||||
benilla_world::sky_order::Rung::FOAM_RASTER,
|
||||
benilla_world::sky_order::Rung::FOAM_RASTER_SLOPE,
|
||||
),
|
||||
] {
|
||||
// `lit` is a pipeline-key axis (a shader def), so BOTH arms are warmed — a hole
|
||||
// here is a first-lit-emitter compile mid-play, which is the whole failure this
|
||||
@@ -646,6 +655,7 @@ fn warm_effect_lane(
|
||||
anchor: Vec3::ZERO,
|
||||
bias: 0.0,
|
||||
raster_bias,
|
||||
raster_slope,
|
||||
cam_relative: false,
|
||||
main_entity: cam,
|
||||
light: None,
|
||||
|
||||
@@ -542,6 +542,30 @@ impl MaterialExtension for LiquidExt {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
"embedded://benilla_assets/shaders/liquid.wgsl".into()
|
||||
}
|
||||
|
||||
/// **`WATER_BIAS` is a SORT rung; keep it out of the rasterizer** — the same split the far-side
|
||||
/// twin and the WMO skybox make above, for the one surface that most needs it.
|
||||
///
|
||||
/// The water pass has a fixed frame slot (`sky_order::WATER_BIAS`, −2e4), and the base
|
||||
/// `StandardMaterial::specialize` packs that same f32 into `depth_stencil.bias.constant` (bevy
|
||||
/// 0.18 `pbr_material.rs`). At −2e4 ULPs that shoves every water fragment ~0.24 % of its view
|
||||
/// distance *away* from the eye — and the water plane is the one world surface that meets the
|
||||
/// ground at a grazing angle over tens of yards, so a relative depth pull there does not settle
|
||||
/// a tie, it moves the waterline. Worse, a float depth buffer's bias unit is the ULP at the
|
||||
/// primitive's own depth, so the pull **doubles at every exponent boundary**: two water
|
||||
/// triangles either side of one land in different buckets and their shorelines stop agreeing.
|
||||
/// The ordering job needs none of it — the rung exists for the transparent phase's sort key.
|
||||
fn specialize(
|
||||
_pipeline: &MaterialExtensionPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
_layout: &MeshVertexBufferLayoutRef,
|
||||
_key: MaterialExtensionKey<Self>,
|
||||
) -> Result<(), SpecializedMeshPipelineError> {
|
||||
if let Some(ds) = descriptor.depth_stencil.as_mut() {
|
||||
ds.bias.constant = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-tile splat inputs: a `texture_2d_array` of the tile's ground textures, a `texture_2d_array`
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! What the ADT liquid actually **is** under a pin — the shoreline instrument:
|
||||
//! `cargo run -p benilla-formats --example water_here -- <map> <x> <y> [radius_yd]`
|
||||
//! e.g. `water_here Azeroth -12512.69 -180.21 40`.
|
||||
//!
|
||||
//! A water-at-the-shore report ("there's a step in it", "the foam runs past the waterline") is a
|
||||
//! question about three surfaces that only ever agree in the middle of a lake: the MCLQ **wet-cell
|
||||
//! lattice** (4.167-yd cells, the granularity everything on the water path clips to), the liquid
|
||||
//! **surface height** on those cells, and the **terrain** underneath. This prints all three over one
|
||||
//! neighbourhood so the geometry can be read instead of guessed:
|
||||
//!
|
||||
//! - the **block census** — one line per MCLQ block: kind, wet cells, surface-height span, depth-byte
|
||||
//! span. Where a step would be *authored*, this is where it shows: two blocks over one chunk, or a
|
||||
//! block whose own plane has relief;
|
||||
//! - the **overlap** scan — cells claimed by two blocks at once, which would composite the
|
||||
//! translucent water twice;
|
||||
//! - the **seam** scan — every pair of adjacent wet cells whose planes disagree, so an authored step
|
||||
//! can be told apart from a rendered one before anyone opens the renderer;
|
||||
//! - the **lattice-vs-waterline** scan — dry cells the ground still runs under. Whole wet cells are
|
||||
//! the only thing the renderer draws, so wherever the lattice runs out before the ground climbs
|
||||
//! through the plane, the water ends on a 4.167-yd straight edge in open water: a shape no
|
||||
//! shoreline has;
|
||||
//! - the **shoreline slope** at the pin, and what a coplanarity *lift* costs there. A decal held `l`
|
||||
//! yards above the water plane keeps painting for `l / slope` yards past the waterline, on dry
|
||||
//! ground — the arithmetic behind B348's foam-on-the-sand, and the reason that settle is a depth
|
||||
//! bias now and not a lift.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use benilla_formats::{terrain_height_at, ChunkMesh, CHUNK_SIZE};
|
||||
|
||||
/// One MCLQ cell edge, in yards — the granularity of `wet`, and of every clip on the water path.
|
||||
const CELL: f32 = CHUNK_SIZE / 8.0;
|
||||
/// How far past the reported radius the block census still enumerates, so that a cell at the edge
|
||||
/// of the answer has its real neighbours to be judged against (one chunk clears it).
|
||||
const MARGIN: f32 = CHUNK_SIZE;
|
||||
|
||||
/// One seam between two adjacent wet cells whose surface planes disagree.
|
||||
struct Seam {
|
||||
/// `to.surface − from.surface`, in yards.
|
||||
dz: f32,
|
||||
from: (i32, i32),
|
||||
to: (i32, i32),
|
||||
}
|
||||
|
||||
/// One MCLQ cell of one block, resolved into world terms.
|
||||
struct Cell {
|
||||
/// Cell-centre world XY.
|
||||
x: f32,
|
||||
y: f32,
|
||||
/// The block's surface height over the cell (bilinear at the centre, as the queries sample it).
|
||||
surface: f32,
|
||||
/// Terrain height at the same point, `None` where the tile has a hole.
|
||||
ground: Option<f32>,
|
||||
/// Which chunk/block this cell came from — `(chunk index, block index)`.
|
||||
owner: (usize, usize),
|
||||
kind: benilla_formats::LiquidKind,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let usage = || anyhow::anyhow!("usage: water_here <map> <x> <y> [radius_yd]");
|
||||
let map = args.next().ok_or_else(usage)?;
|
||||
let x: f32 = args.next().ok_or_else(usage)?.parse()?;
|
||||
let y: f32 = args.next().ok_or_else(usage)?.parse()?;
|
||||
let radius: f32 = args.next().map_or(Ok(30.0), |r| r.parse())?;
|
||||
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
// One tile of slack: a shoreline pin sits on a tile seam as often as not.
|
||||
let tiles = benilla_formats::load_tiles_around(&mut chain, &map, x, y, 1)?;
|
||||
let chunks: Vec<ChunkMesh> = tiles.into_iter().flat_map(|(_, t)| t.chunks).collect();
|
||||
println!(
|
||||
"{map}: pin ({x}, {y}) r={radius} yd — {} chunks loaded",
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
// ── The block census, and the cells ──────────────────────────────────────────────────────
|
||||
let mut cells: Vec<Cell> = Vec::new();
|
||||
for (ci, chunk) in chunks.iter().enumerate() {
|
||||
let Some(nw) = chunk.positions.first() else {
|
||||
continue;
|
||||
};
|
||||
// The chunk's own footprint against the pin box (chunk spans south/east of its NW corner),
|
||||
// widened by MARGIN so every cell inside the radius has its real neighbours loaded — a cell
|
||||
// at the box edge would otherwise read as "the lattice stops here" when it is only the
|
||||
// enumeration that stopped.
|
||||
if nw[0] - CHUNK_SIZE > x + radius + MARGIN
|
||||
|| nw[0] < x - radius - MARGIN
|
||||
|| nw[1] - CHUNK_SIZE > y + radius + MARGIN
|
||||
|| nw[1] < y - radius - MARGIN
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (bi, liq) in chunk.liquids.iter().enumerate() {
|
||||
let [cols, rows] = liq.grid;
|
||||
let (cols, rows) = (cols as usize, rows as usize);
|
||||
let wet_n = liq.wet.iter().filter(|w| **w).count();
|
||||
let (mut zlo, mut zhi) = (f32::MAX, f32::MIN);
|
||||
let (mut dlo, mut dhi) = (f32::MAX, f32::MIN);
|
||||
for (n, p) in liq.positions.iter().enumerate() {
|
||||
let cell_touched = touching_cells(n, cols, rows)
|
||||
.into_iter()
|
||||
.any(|c| liq.wet.get(c).copied().unwrap_or(false));
|
||||
if !cell_touched {
|
||||
continue;
|
||||
}
|
||||
zlo = zlo.min(p[2]);
|
||||
zhi = zhi.max(p[2]);
|
||||
dlo = dlo.min(liq.depths[n]);
|
||||
dhi = dhi.max(liq.depths[n]);
|
||||
}
|
||||
println!(
|
||||
" chunk[{ci:3}] {:>2}_{:<2} block[{bi}] {:?} nibble {} — {wet_n:2}/{} wet, \
|
||||
z {zlo:.3}..{zhi:.3} (span {:.3}), depthV {dlo:.3}..{dhi:.3}",
|
||||
chunk.index_x,
|
||||
chunk.index_y,
|
||||
liq.kind,
|
||||
liq.sound_nibble,
|
||||
(cols - 1) * (rows - 1),
|
||||
zhi - zlo,
|
||||
);
|
||||
|
||||
for j in 0..rows - 1 {
|
||||
for i in 0..cols - 1 {
|
||||
if !liq.wet[j * (cols - 1) + i] {
|
||||
continue;
|
||||
}
|
||||
let corners = [
|
||||
liq.positions[j * cols + i],
|
||||
liq.positions[j * cols + i + 1],
|
||||
liq.positions[(j + 1) * cols + i],
|
||||
liq.positions[(j + 1) * cols + i + 1],
|
||||
];
|
||||
let cx = corners.iter().map(|p| p[0]).sum::<f32>() / 4.0;
|
||||
let cy = corners.iter().map(|p| p[1]).sum::<f32>() / 4.0;
|
||||
let surface = corners.iter().map(|p| p[2]).sum::<f32>() / 4.0;
|
||||
cells.push(Cell {
|
||||
x: cx,
|
||||
y: cy,
|
||||
surface,
|
||||
ground: terrain_height_at(&chunks, [cx, cy, 0.0]),
|
||||
owner: (ci, bi),
|
||||
kind: liq.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cells.is_empty() {
|
||||
println!(" (no MCLQ liquid within the box)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Overlap: two blocks claiming one patch of world, at two heights ──────────────────────
|
||||
// Key a cell by its lattice index, floored — two DIFFERENT cells of one block must never
|
||||
// collide, or the overlap report cries wolf on its own rounding.
|
||||
let key = |c: &Cell| ((c.x / CELL).floor() as i32, (c.y / CELL).floor() as i32);
|
||||
let mut by_key: BTreeMap<(i32, i32), Vec<usize>> = BTreeMap::new();
|
||||
for (n, c) in cells.iter().enumerate() {
|
||||
by_key.entry(key(c)).or_default().push(n);
|
||||
}
|
||||
let mut overlaps = 0;
|
||||
for (k, ns) in &by_key {
|
||||
if ns.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
overlaps += 1;
|
||||
if overlaps <= 12 {
|
||||
let list: Vec<String> = ns
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
format!(
|
||||
"chunk[{}]blk[{}] {:?} z {:.3}",
|
||||
cells[n].owner.0, cells[n].owner.1, cells[n].kind, cells[n].surface
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
println!(" OVERLAP at cell {k:?}: {}", list.join(" | "));
|
||||
}
|
||||
}
|
||||
println!(" overlapping cells: {overlaps}");
|
||||
|
||||
// ── The seam scan: adjacent wet cells whose planes disagree ──────────────────────────────
|
||||
let mut steps: Vec<Seam> = Vec::new();
|
||||
for (k, ns) in &by_key {
|
||||
let z = cells[ns[0]].surface;
|
||||
for nb in [(k.0 + 1, k.1), (k.0, k.1 + 1)] {
|
||||
if let Some(other) = by_key.get(&nb) {
|
||||
let dz = cells[other[0]].surface - z;
|
||||
if dz.abs() > 0.01 {
|
||||
steps.push(Seam {
|
||||
dz,
|
||||
from: *k,
|
||||
to: nb,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
steps.sort_by(|a, b| b.dz.abs().partial_cmp(&a.dz.abs()).expect("finite"));
|
||||
println!(
|
||||
" wet-cell seams with a height step > 0.01 yd: {} (largest first)",
|
||||
steps.len()
|
||||
);
|
||||
for s in steps.iter().take(10) {
|
||||
println!(" {:?} -> {:?} dz {:+.3} yd", s.from, s.to, s.dz);
|
||||
}
|
||||
|
||||
// ── Where the LATTICE stops vs where the WATERLINE is ────────────────────────────────────
|
||||
// The renderer draws whole wet cells and nothing else, so the water's visible boundary is the
|
||||
// *lattice* edge wherever the lattice runs out before the ground climbs through the plane. That
|
||||
// edge is a 4.167-yd straight line in open water — the shape a shoreline never has. Sample every
|
||||
// dry cell that touches a wet one on a 0.25-yd grid and report how deep the water would have
|
||||
// been there.
|
||||
let mut short_cells: Vec<(f32, (i32, i32))> = Vec::new();
|
||||
for k in by_key.keys().copied().collect::<Vec<_>>() {
|
||||
for nb in [
|
||||
(k.0 + 1, k.1),
|
||||
(k.0 - 1, k.1),
|
||||
(k.0, k.1 + 1),
|
||||
(k.0, k.1 - 1),
|
||||
] {
|
||||
if by_key.contains_key(&nb) {
|
||||
continue;
|
||||
}
|
||||
let surface = cells[by_key[&k][0]].surface;
|
||||
let (bx, by) = ((nb.0 as f32 + 0.5) * CELL, (nb.1 as f32 + 0.5) * CELL);
|
||||
let mut deepest = f32::MIN;
|
||||
for si in 0..=16 {
|
||||
for sj in 0..=16 {
|
||||
let px = bx - CELL / 2.0 + CELL * si as f32 / 16.0;
|
||||
let py = by - CELL / 2.0 + CELL * sj as f32 / 16.0;
|
||||
if let Some(g) = terrain_height_at(&chunks, [px, py, 0.0]) {
|
||||
deepest = deepest.max(surface - g);
|
||||
}
|
||||
}
|
||||
}
|
||||
if deepest > 0.02 && (bx - x).hypot(by - y) <= radius {
|
||||
short_cells.push((deepest, nb));
|
||||
}
|
||||
}
|
||||
}
|
||||
short_cells.sort_by(|a, b| b.0.partial_cmp(&a.0).expect("finite"));
|
||||
short_cells.dedup_by_key(|(_, k)| *k);
|
||||
println!(
|
||||
"\n DRY cells the ground still runs under (the lattice stops before the waterline does): {}",
|
||||
short_cells.len()
|
||||
);
|
||||
for (d, k) in short_cells.iter().take(10) {
|
||||
println!(
|
||||
" cell {k:?} at ({:.1}, {:.1}) — up to {d:.3} yd of water missing",
|
||||
(k.0 as f32 + 0.5) * CELL,
|
||||
(k.1 as f32 + 0.5) * CELL
|
||||
);
|
||||
}
|
||||
|
||||
// ── The waterline's slope, and what a coplanarity lift costs horizontally ────────────────
|
||||
// A decal on the water plane, lifted `l` yards to win the coplanar tie, keeps painting for
|
||||
// `l / slope` yards past the waterline — up the beach, on dry ground. Measure the slope where
|
||||
// the plane actually meets the ground nearest the pin.
|
||||
let mut best: Option<(f32, f32, f32, f32)> = None; // (dist to pin, x, y, slope)
|
||||
for c in &cells {
|
||||
let Some(g) = c.ground else { continue };
|
||||
if (c.surface - g).abs() > 0.5 {
|
||||
continue;
|
||||
}
|
||||
let h = 1.0_f32;
|
||||
let sample = |dx: f32, dy: f32| terrain_height_at(&chunks, [c.x + dx, c.y + dy, 0.0]);
|
||||
let (Some(px), Some(mx), Some(py), Some(my)) = (
|
||||
sample(h, 0.0),
|
||||
sample(-h, 0.0),
|
||||
sample(0.0, h),
|
||||
sample(0.0, -h),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let slope = (((px - mx) / (2.0 * h)).powi(2) + ((py - my) / (2.0 * h)).powi(2)).sqrt();
|
||||
let d = (c.x - x).hypot(c.y - y);
|
||||
if best.as_ref().is_none_or(|b| d < b.0) {
|
||||
best = Some((d, c.x, c.y, slope));
|
||||
}
|
||||
}
|
||||
if let Some((d, wx, wy, slope)) = best {
|
||||
println!(
|
||||
"\n waterline nearest the pin: ({wx:.2}, {wy:.2}), {d:.1} yd away — ground slope {slope:.4} \
|
||||
({:.1}%)",
|
||||
slope * 100.0
|
||||
);
|
||||
for lift in [0.03_f32, 0.01, 0.0] {
|
||||
println!(
|
||||
" a {lift:.2}-yd lift paints {:.2} yd of dry beach",
|
||||
if slope > 0.0 {
|
||||
lift / slope
|
||||
} else {
|
||||
f32::INFINITY
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The (up to four) cell indices a grid vertex `n` is a corner of, row-major over `(cols−1)` cells.
|
||||
fn touching_cells(n: usize, cols: usize, rows: usize) -> Vec<usize> {
|
||||
let (i, j) = (n % cols, n / cols);
|
||||
let mut out = Vec::new();
|
||||
for dj in [0isize, -1] {
|
||||
for di in [0isize, -1] {
|
||||
let (ci, cj) = (i as isize + di, j as isize + dj);
|
||||
if ci >= 0 && cj >= 0 && (ci as usize) < cols - 1 && (cj as usize) < rows - 1 {
|
||||
out.push(cj as usize * (cols - 1) + ci as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -260,6 +260,7 @@ pub(crate) fn update_ground_fx_decals(
|
||||
anchor: decal.center,
|
||||
bias: crate::sky_order::Rung::GROUND_FX,
|
||||
raster_bias: crate::sky_order::Rung::GROUND_FX as i32,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: entity,
|
||||
light: None,
|
||||
|
||||
@@ -241,6 +241,12 @@ pub struct ParticleLight(pub [f32; 3]);
|
||||
#[derive(Component)]
|
||||
pub struct InteriorAnchor {
|
||||
law: AppliedLaw,
|
||||
/// The room the law's own attach ray claimed — `None` on the exterior law. It is the key the
|
||||
/// per-frame interior-FOG gate is asked at, which is why a settled anchor keeps it: the gate's
|
||||
/// answer moves with the CAMERA, so an anchor that never rays again still needs its room.
|
||||
room: Option<crate::wmo_portal::WmoRoom>,
|
||||
/// The standing fog verdict for that room, re-read every frame (see [`anchor_room_fogged`]).
|
||||
fog: bool,
|
||||
/// Anchor position at the last down-ray + the residency generation then — the re-test gate.
|
||||
last_pos: Vec3,
|
||||
generation: u32,
|
||||
@@ -290,6 +296,11 @@ pub struct InteriorLit {
|
||||
/// Last applied law — the part's last-written record (the anchor's [`InteriorAnchor`] is the
|
||||
/// authority): the write gate, and [`Self::is_bake`]'s source. `None` until first written.
|
||||
applied: Option<AppliedLaw>,
|
||||
/// The [`crate::mesh_tag::INTERIOR_FOG_BIT`] as last written, beside the law rather than inside
|
||||
/// it: the two are decided by different questions (the law by where this part STANDS, the fog
|
||||
/// by whether that room is on the camera's chain this frame — decision 1792 §4), so a part can
|
||||
/// need a rewrite for one with the other unchanged. Meaningless while `applied` is `None`.
|
||||
fogged: bool,
|
||||
}
|
||||
|
||||
impl InteriorLit {
|
||||
@@ -343,6 +354,7 @@ impl InteriorLit {
|
||||
kind,
|
||||
exterior,
|
||||
applied: None,
|
||||
fogged: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +371,7 @@ impl InteriorLit {
|
||||
kind,
|
||||
exterior,
|
||||
applied: Some(AppliedLaw::Bake(0)),
|
||||
fogged: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,7 +411,15 @@ impl Plugin for InteriorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<WmoResidency>()
|
||||
.init_resource::<InteriorReauthor>()
|
||||
.add_systems(Update, classify_entity_interior)
|
||||
// **After the flood**, like every other reader of the per-group gate
|
||||
// (`model_render::ModelVisSet`): since the interior-FOG half of a part's channel is
|
||||
// `[0xca7f00]` (decision 1792 §4), classifying before the flood would answer a
|
||||
// camera-move with last frame's rooms. The ray half wants the same order anyway — the
|
||||
// placements it rays are the ones this frame's residency published.
|
||||
.add_systems(
|
||||
Update,
|
||||
classify_entity_interior.after(crate::wmo_portal::WmoPvsSet),
|
||||
)
|
||||
.add_observer(enqueue_on_fade_latch);
|
||||
}
|
||||
}
|
||||
@@ -434,7 +455,7 @@ pub fn classify_entity_interior(
|
||||
time: Res<Time>,
|
||||
residency: Res<WmoResidency>,
|
||||
wmos: Res<Assets<WmoModel>>,
|
||||
instances: Query<&WmoPortalInstance>,
|
||||
instances: Query<(Entity, &WmoPortalInstance)>,
|
||||
streamer: Res<TerrainStreamer>,
|
||||
adt_tiles: Res<Assets<AdtTile>>,
|
||||
lighting: Res<crate::lighting::WowLighting>,
|
||||
@@ -470,14 +491,7 @@ pub fn classify_entity_interior(
|
||||
// no fight. Excluding them is what left a streamed indoor entity with no law at all for the
|
||||
// whole 2 s ramp, so it appeared under exterior light and swapped to its room's in one frame
|
||||
// at the latch.
|
||||
mut parts: Query<(
|
||||
&mut InteriorLit,
|
||||
&mut MeshMaterial3d<WowModelMaterial>,
|
||||
&mut MeshTag,
|
||||
Option<&crate::model_fade::FadeMaterials>,
|
||||
Has<RenderFade>,
|
||||
Has<PendingAppearFade>,
|
||||
)>,
|
||||
mut parts: PartWrite,
|
||||
) {
|
||||
let _t0 = std::time::Instant::now();
|
||||
let (mut n_anchors, mut n_resolved, mut n_written) = (0usize, 0usize, 0usize);
|
||||
@@ -531,6 +545,16 @@ pub fn classify_entity_interior(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The law is settled; the FOG is not. Its gate is the camera's chain, not this
|
||||
// anchor's position (decision 1792 §4) — a unit standing perfectly still leaves
|
||||
// and rejoins its building's MFOG as the camera walks between rooms — so the one
|
||||
// thing a settled anchor still re-reads is this bit. A compare per anchor, and a
|
||||
// part write only on the frames the verdict actually moves.
|
||||
let fog = anchor_room_fogged(state.room, &instances);
|
||||
if state.fog != fog {
|
||||
state.fog = fog;
|
||||
n_written += write_anchor_parts(state.law, fog, lit_parts, &mut parts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -563,7 +587,7 @@ pub fn classify_entity_interior(
|
||||
n_resolved += 1;
|
||||
let _r = std::time::Instant::now();
|
||||
let (attach, attach_at) = attach_anchor(containment, bake_center, anchor_t);
|
||||
let law = resolve_anchor_law(
|
||||
let (law, room) = resolve_anchor_law(
|
||||
&mut commands,
|
||||
&mut probes,
|
||||
&wmos,
|
||||
@@ -580,11 +604,14 @@ pub fn classify_entity_interior(
|
||||
seated,
|
||||
);
|
||||
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
|
||||
let fog = anchor_room_fogged(room, &instances);
|
||||
let kind_bake = bake.is_some();
|
||||
let changed = match state.as_deref_mut() {
|
||||
Some(state) => {
|
||||
let changed = state.law != law;
|
||||
let changed = state.law != law || state.fog != fog;
|
||||
state.law = law;
|
||||
state.room = room;
|
||||
state.fog = fog;
|
||||
state.last_pos = pos;
|
||||
state.generation = residency.generation;
|
||||
state.kind_bake = kind_bake;
|
||||
@@ -594,6 +621,8 @@ pub fn classify_entity_interior(
|
||||
// `try_insert`: the anchor may carry a same-frame despawn already queued.
|
||||
commands.entity(anchor).try_insert(InteriorAnchor {
|
||||
law,
|
||||
room,
|
||||
fog,
|
||||
last_pos: pos,
|
||||
generation: residency.generation,
|
||||
kind_bake,
|
||||
@@ -632,9 +661,22 @@ pub fn classify_entity_interior(
|
||||
// whose batches are all unlit registers zero parts and is here entirely on its lit
|
||||
// emitters, and "0 parts / 0 emitters" is impossible — so the pair says which
|
||||
// consumer put this node in front of the classifier, which is otherwise invisible.
|
||||
// The ROOM and its fog gate ride the same line as the law, because since 1792 §5 they
|
||||
// are the other half of what a part's channel says: "INTERIOR matte" alone cannot
|
||||
// distinguish a unit wearing its building's MFOG from one on the scene fog two rooms
|
||||
// out, and that difference is the whole of B335's colour.
|
||||
let room = match room {
|
||||
Some(r) => format!(
|
||||
" room g{} of {:?} · fog {}",
|
||||
r.group,
|
||||
r.instance,
|
||||
if fog { "INTERIOR" } else { "scene (off-chain)" }
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
eprintln!(
|
||||
"[interior] t {:.2} anchor {anchor:?} ({} parts, {} emitters) at \
|
||||
({:.1}, {:.1}, {:.1}) {} probe ({:.1}, {:.1}, {:.1}) -> {}",
|
||||
({:.1}, {:.1}, {:.1}) {} probe ({:.1}, {:.1}, {:.1}) -> {}{room}",
|
||||
time.elapsed_secs(),
|
||||
lit_parts.map_or(0, |p| p.len()),
|
||||
lit_emitters.map_or(0, |e| e.len()),
|
||||
@@ -655,20 +697,7 @@ pub fn classify_entity_interior(
|
||||
}
|
||||
);
|
||||
}
|
||||
for part in lit_parts.into_iter().flat_map(LitParts::iter) {
|
||||
if let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part)
|
||||
{
|
||||
let fade = fm.filter(|_| ramping || pending);
|
||||
n_written += usize::from(write_part_law(
|
||||
law,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
false,
|
||||
fade,
|
||||
));
|
||||
}
|
||||
}
|
||||
n_written += write_anchor_parts(law, fog, lit_parts, &mut parts);
|
||||
}
|
||||
// Drain the convergence queue: each entry re-authors from its anchor's standing law. Forced
|
||||
// through the part's change gate — the enqueuing edges (fade latch, zoom release) mean a
|
||||
@@ -696,6 +725,7 @@ pub fn classify_entity_interior(
|
||||
}
|
||||
n_written += usize::from(write_part_law(
|
||||
state.law,
|
||||
state.fog,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
@@ -738,6 +768,49 @@ pub fn classify_entity_interior(
|
||||
}
|
||||
}
|
||||
|
||||
/// The classifier's part-write query: a part's law record, the material and tag the law authors,
|
||||
/// and the fade state that picks the material's blend twin. Named because both the system and its
|
||||
/// shared write loop ([`write_anchor_parts`]) take it.
|
||||
type PartWrite<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
&'static mut InteriorLit,
|
||||
&'static mut MeshMaterial3d<WowModelMaterial>,
|
||||
&'static mut MeshTag,
|
||||
Option<&'static crate::model_fade::FadeMaterials>,
|
||||
Has<RenderFade>,
|
||||
Has<PendingAppearFade>,
|
||||
),
|
||||
>;
|
||||
|
||||
/// Author every part of one anchor from its standing `(law, fog)` — the classifier's ONE part-write
|
||||
/// loop, shared by the law resolve and by the per-frame fog gate, so the two can never write the
|
||||
/// channel differently. Returns how many parts actually took a write.
|
||||
fn write_anchor_parts(
|
||||
law: AppliedLaw,
|
||||
fog: bool,
|
||||
lit_parts: Option<&LitParts>,
|
||||
parts: &mut PartWrite,
|
||||
) -> usize {
|
||||
let mut written = 0;
|
||||
for part in lit_parts.into_iter().flat_map(LitParts::iter) {
|
||||
if let Ok((mut lit, mut material, mut tag, fm, ramping, pending)) = parts.get_mut(part) {
|
||||
let fade = fm.filter(|_| ramping || pending);
|
||||
written += usize::from(write_part_law(
|
||||
law,
|
||||
fog,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
false,
|
||||
fade,
|
||||
));
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Write one part's material + tag for `law` — the single place a part's channel is authored.
|
||||
/// Change-gated on the part's last-written record unless `force` (a transient author — the zoom
|
||||
/// feather — overwrote the channel while `applied` stayed current). Returns whether it wrote.
|
||||
@@ -745,13 +818,15 @@ pub fn classify_entity_interior(
|
||||
/// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field; the other laws
|
||||
/// reset to the plain exterior payload (shade byte 0 — `entity_shade` runs after the classifier and
|
||||
/// re-asserts the ramped intensity byte the same frame; it skips only Bake parts). Both writes
|
||||
/// carry the tag's **alpha** field through, so a law change lands cleanly mid-fade. BOTH indoor
|
||||
/// laws carry the INTERIOR_FOG_BIT (Bake bakes it in): the reference fogs a unit by the unit's OWN
|
||||
/// interior classification, so an indoor day/night character keeps the room's fog — never the
|
||||
/// storm's near veil — while the exterior law returns it to the scene fog (wow-re
|
||||
/// `m2-unit-interior-fog.md`; the director's corridor-vs-porch walk-out). Every arm carries the
|
||||
/// part's rig field through (decision 0720): a skinned part keeps its palette across the
|
||||
/// indoor/outdoor transition.
|
||||
/// carry the tag's **alpha** field through, so a law change lands cleanly mid-fade. Every arm
|
||||
/// carries the part's rig field through (decision 0720): a skinned part keeps its palette across
|
||||
/// the indoor/outdoor transition.
|
||||
///
|
||||
/// `fog` is the INTERIOR_FOG_BIT, decided by the CALLER and orthogonal to the law: the reference
|
||||
/// fogs a unit by its own interior classification **and** by whether the room it attached to is on
|
||||
/// the camera's `[0xca7f00]` chain this frame — `[node+0xc]&2` is necessary, `[P+0x98] != 0` is the
|
||||
/// other half (decision 1792 §4). An exterior-law part is never fogged, which the caller asserts by
|
||||
/// passing `false`; that is the only coupling left between the two.
|
||||
///
|
||||
/// `fade` is `Some` only while an appear/despawn ramp owns the part (live **or** pending), and it
|
||||
/// selects the law's BLEND twin instead of its steady material (decision 0755). It is the same
|
||||
@@ -765,16 +840,18 @@ pub fn classify_entity_interior(
|
||||
/// exterior material, where the shader decodes those bits as a ground-shade byte.
|
||||
fn write_part_law(
|
||||
law: AppliedLaw,
|
||||
fog: bool,
|
||||
lit: &mut InteriorLit,
|
||||
material: &mut MeshMaterial3d<WowModelMaterial>,
|
||||
tag: &mut MeshTag,
|
||||
force: bool,
|
||||
fade: Option<&crate::model_fade::FadeMaterials>,
|
||||
) -> bool {
|
||||
if lit.applied == Some(law) && !force {
|
||||
if lit.applied == Some(law) && lit.fogged == fog && !force {
|
||||
return false;
|
||||
}
|
||||
lit.applied = Some(law);
|
||||
lit.fogged = fog;
|
||||
let want = match fade {
|
||||
Some(fm) => fm.material_for(Some(&*lit), true).clone(),
|
||||
None => lit.steady_material().clone(),
|
||||
@@ -782,16 +859,36 @@ fn write_part_law(
|
||||
if material.0 != want {
|
||||
material.0 = want;
|
||||
}
|
||||
tag.0 = match law {
|
||||
let payload = match law {
|
||||
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
|
||||
AppliedLaw::Matte => {
|
||||
crate::mesh_tag::INTERIOR_FOG_BIT | crate::mesh_tag::with_exterior_reset(tag.0)
|
||||
}
|
||||
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
|
||||
AppliedLaw::Matte | AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
|
||||
};
|
||||
tag.0 = crate::mesh_tag::with_interior_fog(payload, fog);
|
||||
true
|
||||
}
|
||||
|
||||
/// **Is this anchor's room on the camera's interior-fog chain this frame?** — the `[P+0x98]` half
|
||||
/// of a unit's fog lane (decision 1792 §4), resolved through the same chain the room-visibility
|
||||
/// leg walks: anchor record → placement instance → the flood's per-group gate.
|
||||
///
|
||||
/// Fails CLOSED at every seam (no room, despawned placement, a group past the set), which is
|
||||
/// [`crate::wmo_portal::WmoPortalInstance::fogs_group`]'s own convention: the scene fog is what
|
||||
/// every un-gated draw already wears, and a lookup miss must not paint a building's MFOG onto
|
||||
/// something the flood never placed in it. An exterior-law anchor holds no room, so it answers
|
||||
/// `false` without a lookup.
|
||||
fn anchor_room_fogged(
|
||||
room: Option<crate::wmo_portal::WmoRoom>,
|
||||
instances: &Query<(Entity, &WmoPortalInstance)>,
|
||||
) -> bool {
|
||||
let Some(room) = room else {
|
||||
return false;
|
||||
};
|
||||
let Ok((_, inst)) = instances.get(room.instance) else {
|
||||
return false;
|
||||
};
|
||||
inst.fogs_group(room.group)
|
||||
}
|
||||
|
||||
/// The attach and its anchor point — one choice, because `0x6a86d0`'s mode fork picks both the
|
||||
/// routine and the field it reads: a GameObject attaches by CONTAINMENT from its world
|
||||
/// bounding-box centre (`[node+0x5c]`), everything else DOWN-RAYS from its position
|
||||
@@ -827,7 +924,7 @@ fn resolve_anchor_law(
|
||||
commands: &mut Commands,
|
||||
probes: &mut PropProbes,
|
||||
wmos: &Assets<WmoModel>,
|
||||
instances: &Query<&WmoPortalInstance>,
|
||||
instances: &Query<(Entity, &WmoPortalInstance)>,
|
||||
streamer: &TerrainStreamer,
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
lighting: &crate::lighting::WowLighting,
|
||||
@@ -838,8 +935,8 @@ fn resolve_anchor_law(
|
||||
attach_at: Vec3,
|
||||
bake_center: Option<Vec3>,
|
||||
seated: Option<u16>,
|
||||
) -> AppliedLaw {
|
||||
let verdict = indoor_verdict_at(
|
||||
) -> (AppliedLaw, Option<crate::wmo_portal::WmoRoom>) {
|
||||
let (verdict, claimed) = indoor_verdict_at(
|
||||
wmos,
|
||||
instances.iter(),
|
||||
streamer,
|
||||
@@ -963,7 +1060,17 @@ fn resolve_anchor_law(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
law
|
||||
// The room rides back with the law, but only for the indoor laws: the exterior law is the
|
||||
// "no record" arm, and holding a room there would let the fog gate fire on a unit standing on
|
||||
// a porch. (`claimed` is already `None` on both outdoor verdicts; the probe-table-full fallback
|
||||
// to Matte is the one arm that keeps a room the verdict resolved.)
|
||||
let room = claimed
|
||||
.filter(|_| law != AppliedLaw::Exterior)
|
||||
.map(|(instance, group)| crate::wmo_portal::WmoRoom {
|
||||
instance,
|
||||
group: group as u16,
|
||||
});
|
||||
(law, room)
|
||||
}
|
||||
|
||||
/// Queue the Bake slot swap on `anchor`, tolerant at APPLY time: the anchor may carry a
|
||||
@@ -1029,12 +1136,17 @@ mod tests {
|
||||
world.resource_mut::<PropProbes>().release(stale);
|
||||
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
// In a room whose gate is on, so the reauthored tag is the whole Bake payload — slot and
|
||||
// fog flag together, which is what `probe_bits` names.
|
||||
let instance = placement_with_gate(&mut world, true);
|
||||
let anchor = world
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
PropProbeSlot(live),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Bake(live),
|
||||
room: Some(crate::wmo_portal::WmoRoom { instance, group: 0 }),
|
||||
fog: true,
|
||||
last_pos: Vec3::ZERO, // matches the transform: the settled gate sees NO movement
|
||||
generation,
|
||||
kind_bake: true,
|
||||
@@ -1120,6 +1232,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A one-group placement whose interior-fog gate is `on`/`off` this frame — the `[0xca7f00]`
|
||||
/// half of a unit's fog lane, as something the classifier tests can actually set.
|
||||
fn placement_with_gate(world: &mut World, gate: bool) -> Entity {
|
||||
world
|
||||
.spawn(WmoPortalInstance {
|
||||
handle: Handle::default(),
|
||||
world_from_local: bevy::math::Affine3A::IDENTITY,
|
||||
name_set: 0,
|
||||
visible: vec![true],
|
||||
interior_fog: vec![gate],
|
||||
liquid_visited: vec![false],
|
||||
flooded: vec![None],
|
||||
})
|
||||
.id()
|
||||
}
|
||||
|
||||
/// A part spawned onto a SETTLED anchor (the gear-swap-indoors case) takes the standing law
|
||||
/// through the `on_add` hook + drain — no law change, no anchor movement, and still the fresh
|
||||
/// part's material/tag land on the anchor's law the very next classifier run.
|
||||
@@ -1129,11 +1257,14 @@ mod tests {
|
||||
|
||||
let mut world = classifier_world();
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
let instance = placement_with_gate(&mut world, true);
|
||||
let anchor = world
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Matte,
|
||||
room: Some(crate::wmo_portal::WmoRoom { instance, group: 0 }),
|
||||
fog: true,
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: false,
|
||||
@@ -1156,10 +1287,72 @@ mod tests {
|
||||
assert_ne!(
|
||||
world.get::<MeshTag>(part).unwrap().0 & crate::mesh_tag::INTERIOR_FOG_BIT,
|
||||
0,
|
||||
"the day/night law carries the room's fog bit"
|
||||
"the day/night law in a room whose gate is on carries the room's fog bit"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The `[P+0x98]` conjunct** (decision 1792 §4): standing indoors is not enough. A settled
|
||||
/// anchor whose room drops off the camera's interior-fog chain must return to the SCENE fog
|
||||
/// without moving an inch and without changing its light law — the camera walked, not the
|
||||
/// unit. This is B335's own artifact seen from one room further in: at Shadowfang the walls of
|
||||
/// a room across the courtyard already read the scene fog (1787), and before this the mob
|
||||
/// standing in it did not.
|
||||
#[test]
|
||||
fn a_settled_anchor_follows_its_rooms_fog_gate_without_moving() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
let mut world = classifier_world();
|
||||
let generation = world.resource::<WmoResidency>().generation;
|
||||
let instance = placement_with_gate(&mut world, true);
|
||||
let anchor = world
|
||||
.spawn((
|
||||
GlobalTransform::default(),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Matte,
|
||||
room: Some(crate::wmo_portal::WmoRoom { instance, group: 0 }),
|
||||
fog: true,
|
||||
last_pos: Vec3::ZERO, // settled: the walk never re-rays this anchor
|
||||
generation,
|
||||
kind_bake: false,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let part = world
|
||||
.spawn((
|
||||
InteriorLit::new(InteriorKind::Matte, Handle::default()),
|
||||
ClassifiedBy(anchor),
|
||||
MeshMaterial3d::<WowModelMaterial>(Handle::default()),
|
||||
MeshTag(0),
|
||||
))
|
||||
.id();
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
let fog_of = |world: &World, part| {
|
||||
world.get::<MeshTag>(part).unwrap().0 & crate::mesh_tag::INTERIOR_FOG_BIT != 0
|
||||
};
|
||||
assert!(fog_of(&world, part), "gate on ⇒ the room's fog");
|
||||
|
||||
// The camera moves out of the chain; the anchor does not move at all.
|
||||
world
|
||||
.get_mut::<WmoPortalInstance>(instance)
|
||||
.unwrap()
|
||||
.interior_fog[0] = false;
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
assert!(!fog_of(&world, part), "gate off ⇒ back on the scene fog");
|
||||
assert_eq!(
|
||||
world.get::<InteriorLit>(part).unwrap().applied,
|
||||
Some(AppliedLaw::Matte),
|
||||
"and the LIGHT law is untouched — the unit is still standing in the same room"
|
||||
);
|
||||
|
||||
// …and back, so the gate is a live read rather than a one-way latch.
|
||||
world
|
||||
.get_mut::<WmoPortalInstance>(instance)
|
||||
.unwrap()
|
||||
.interior_fog[0] = true;
|
||||
world.run_system_once(classify_entity_interior).unwrap();
|
||||
assert!(fog_of(&world, part), "the camera comes back into the chain");
|
||||
}
|
||||
|
||||
/// Decision 0778: a model's BILLBOARD batch takes the same law as its mesh batches. The card
|
||||
/// spawns as a world ROOT (the facing system owns its transform, 0153) rather than as a child
|
||||
/// of the model, and that is the whole difference — it goes through the same
|
||||
@@ -1185,6 +1378,8 @@ mod tests {
|
||||
PropProbeSlot(slot),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Bake(slot),
|
||||
room: None,
|
||||
fog: false,
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: true,
|
||||
@@ -1253,6 +1448,8 @@ mod tests {
|
||||
GlobalTransform::default(),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Exterior,
|
||||
room: None,
|
||||
fog: false,
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: false,
|
||||
@@ -1340,6 +1537,8 @@ mod tests {
|
||||
PropProbeSlot(slot),
|
||||
InteriorAnchor {
|
||||
law: AppliedLaw::Bake(slot),
|
||||
room: None,
|
||||
fog: false,
|
||||
last_pos: Vec3::ZERO, // settled: the walk skips the ray, the drain does the work
|
||||
generation,
|
||||
kind_bake: true,
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
//! adds the client's emissive lift to the lighting sum when set. Orthogonal by construction:
|
||||
//! no payload mode ever sets bit 31, and the shader masks it off before reading the payload —
|
||||
//! so the untagged-⇒-opaque `0` sentinel still works on the masked value.
|
||||
//! - **Interior fog** ([`INTERIOR_FOG_BIT`], bit 30): this instance's model stands in a WMO
|
||||
//! interior, so the shader fogs it with the INTERIOR triple (shared-light rows 18-19) instead of
|
||||
//! the scene fog — the reference stages a unit's fog by the unit's OWN interior classification
|
||||
//! (`0x71c110` → collector `+0x184..`, lane by `[node+0xc]`; wow-re `m2-unit-interior-fog.md`).
|
||||
//! Carried by BOTH indoor laws: [`probe_bits`] bakes it in, and the entity classifier ORs it
|
||||
//! onto the Matte (day/night) payload. Masked off with bit 31 before the payload decode.
|
||||
//! - **Interior fog** ([`INTERIOR_FOG_BIT`], bit 30): this instance draws with the INTERIOR fog
|
||||
//! triple (shared-light rows 18-19) instead of the scene fog. **Two conjuncts, never one**: the
|
||||
//! model must stand in a WMO interior (the reference's per-unit classification `0x71c110` →
|
||||
//! collector `+0x184..`, lane by `[node+0xc]&2`) *and* the room it attached to must be on the
|
||||
//! camera's `[0xca7f00]` chain this frame (`[P+0x98] != 0`, decision 1792 §4). Both writers
|
||||
//! below decide it that way — the classifier for entity parts, `apply_model_visibility` for
|
||||
//! room-bound WMO content — through [`with_interior_fog`]. Masked off with bit 31 before the
|
||||
//! payload decode.
|
||||
//! - **Instance slot** (bits 19..=29, BOTH payload modes — decisions 0720, 0812, 0820): the
|
||||
//! per-instance index into the shared buffer's slot-keyed regions. `0` is the world's shared
|
||||
//! no-instance sentinel (terrain, WMO, doodads, clutter, and every part of a rig-less model). Two
|
||||
@@ -63,11 +65,17 @@
|
||||
//! entities out (`Without<RenderFade>`, `Without<PendingAppearFade>`). It writes through
|
||||
//! [`with_alpha`], so the shade, probe-slot and rig fields survive a fade.
|
||||
//! 2. `interior::classify_entity_interior` is the owner of the **light law** for interior-capable
|
||||
//! parts (`InteriorLit` holders) — the payload's non-alpha fields and [`INTERIOR_FOG_BIT`]:
|
||||
//! probe slot + fog bit on the Bake law, fog bit alone on the Matte law, plain payload outdoors
|
||||
//! (dropping the shade byte — the shade writer below re-asserts it the same frame, ordered
|
||||
//! after). Its whole-payload writes go through [`with_interior_probe`] /
|
||||
//! [`with_exterior_reset`], which carry the rig **and alpha** fields.
|
||||
//! parts (`InteriorLit` holders) — the payload's non-alpha fields (probe slot on the Bake law,
|
||||
//! plain payload otherwise, dropping the shade byte, which the shade writer below re-asserts
|
||||
//! the same frame, ordered after) **and** [`INTERIOR_FOG_BIT`] on that same population. Its
|
||||
//! whole-payload writes go through [`with_interior_probe`] / [`with_exterior_reset`], which
|
||||
//! carry the rig **and alpha** fields, and it then sets the flag through [`with_interior_fog`].
|
||||
//!
|
||||
//! The law and the flag are **two questions with two answers**: the law is where the part
|
||||
//! stands (its own down-ray, re-asked only when it moves), the flag is whether that room is on
|
||||
//! the camera's chain (re-read every frame, because the camera moves when the part does not).
|
||||
//! An exterior-law part is never fogged; an indoor-law one is fogged only while its room's gate
|
||||
//! is on.
|
||||
//!
|
||||
//! It is **not** filtered by fade state (decision 0755): the light law and the fade alpha are
|
||||
//! orthogonal, so 1 and 2 are deconflicted by *field*, not by lockout — the law follows a part
|
||||
@@ -110,12 +118,16 @@
|
||||
pub const HIGHLIGHT_BIT: u32 = 0x8000_0000;
|
||||
|
||||
/// Bit 30 of the `MeshTag`: the **interior-fog** flag — fog this instance with the interior
|
||||
/// triple (shared-light rows 18-19, the camera-crossfaded MFOG) instead of the scene fog. The
|
||||
/// entity classifier (`crate::interior`) owns it: set on both indoor laws (Bake via
|
||||
/// [`probe_bits`], Matte by OR), cleared on the exterior law's payload reset. At camera-out the
|
||||
/// interior triple EQUALS the scene triple (the reference's t=0 lerp), so the bit only ever
|
||||
/// diverges the fog while the camera is inside a fogged WMO — exactly the byte semantics
|
||||
/// (wow-re `m2-unit-interior-fog.md`).
|
||||
/// triple (shared-light rows 18-19, the camera-crossfaded MFOG) instead of the scene fog. Two
|
||||
/// owners over disjoint populations, both applying the same two-conjunct law (see the module doc):
|
||||
/// `crate::interior` for entity parts, `apply_model_visibility` for room-bound WMO content.
|
||||
///
|
||||
/// At camera-out the interior triple EQUALS the scene triple (the reference's t=0 lerp), so the
|
||||
/// bit cannot diverge the fog unless the camera is inside a fogged WMO. That is why it took until
|
||||
/// B335 to matter — and why the chain conjunct is the rest of the story: with the camera inside a
|
||||
/// building, "inside the same building" is *not* the same room-complex, and a unit two courtyards
|
||||
/// away wore 95 %-saturated MFOG while the walls behind it wore none (wow-re
|
||||
/// `m2-unit-interior-fog.md`; decisions 1787, 1792).
|
||||
pub(crate) const INTERIOR_FOG_BIT: u32 = 0x4000_0000;
|
||||
|
||||
/// Bits 0..=5 of BOTH payload modes: the fade alpha as a 6-bit fraction (`63` = opaque).
|
||||
@@ -135,9 +147,16 @@ const RIG_SHIFT: u32 = 19;
|
||||
pub const MAX_RIG_SLOTS: usize = 1 << 11;
|
||||
|
||||
/// An interior-probe payload constructor for RIG-LESS spawns (the static-prop spawner): the slot
|
||||
/// in bits 6..=18 + an opaque alpha field + the [`INTERIOR_FOG_BIT`] (a probe payload always
|
||||
/// means the model stands indoors). Rig-carrying writers (the entity classifier) go through
|
||||
/// [`with_interior_probe`] instead, which preserves the rig and alpha fields.
|
||||
/// in bits 6..=18 + an opaque alpha field + the [`INTERIOR_FOG_BIT`]. Rig-carrying writers (the
|
||||
/// entity classifier) go through [`with_interior_probe`] instead, which preserves the rig and
|
||||
/// alpha fields.
|
||||
///
|
||||
/// It sets the fog bit where `with_interior_probe` leaves it alone, and the difference is which
|
||||
/// population each serves. This is a **spawn** value for room-bound WMO content, whose bit
|
||||
/// `apply_model_visibility` then owns and rewrites every frame from the room's gate — except for
|
||||
/// an unnamed prop that no group's MODR references, which carries no `WmoGroupVis`, is never
|
||||
/// visited by that writer, and therefore keeps this value for its whole life (1787 §5). Set is
|
||||
/// what that population has always had.
|
||||
pub(crate) fn probe_bits(slot: u16) -> u32 {
|
||||
INTERIOR_FOG_BIT | (u32::from(slot) << PROBE_SHIFT) | alpha_bits(1.0)
|
||||
}
|
||||
@@ -162,8 +181,13 @@ fn carried_alpha(tag: u32) -> u32 {
|
||||
/// else — the classifier owns the rest of the payload when it fires): the entity classifier's
|
||||
/// Bake-law write for skinned unit parts, whose rig slot must ride through the indoor/outdoor
|
||||
/// transitions and whose fade alpha must ride through a mid-ramp re-lane.
|
||||
///
|
||||
/// **Payload only — it does not touch [`INTERIOR_FOG_BIT`]**, unlike its spawn-side sibling
|
||||
/// [`probe_bits`]. Standing indoors is necessary for a part's fog but not sufficient: the room must
|
||||
/// also be on the camera's `[0xca7f00]` chain this frame (decision 1792 §4), so the classifier
|
||||
/// composes this with [`with_interior_fog`] and the bit has exactly one decision point.
|
||||
pub(crate) fn with_interior_probe(tag: u32, slot: u16) -> u32 {
|
||||
INTERIOR_FOG_BIT | (tag & RIG_MASK) | (u32::from(slot) << PROBE_SHIFT) | carried_alpha(tag)
|
||||
(tag & RIG_MASK) | (u32::from(slot) << PROBE_SHIFT) | carried_alpha(tag)
|
||||
}
|
||||
|
||||
/// Rewrite a tag as a fresh exterior payload, preserving the rig and alpha fields: the
|
||||
@@ -401,7 +425,11 @@ mod tests {
|
||||
let indoor = with_interior_probe(spawn, 4321); // the classifier's Bake law (writer 2)
|
||||
assert_eq!(rig_of(indoor), 1000);
|
||||
assert_eq!((indoor & PROBE_MASK) >> PROBE_SHIFT, 4321);
|
||||
assert_eq!(indoor & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
|
||||
assert_eq!(
|
||||
indoor & INTERIOR_FOG_BIT,
|
||||
0,
|
||||
"payload only — the flag is decided apart"
|
||||
);
|
||||
let outdoor = with_exterior_reset(indoor); // …and its outdoor reclaim
|
||||
assert_eq!(rig_of(outdoor), 1000);
|
||||
assert_eq!(outdoor & (PROBE_MASK | INTERIOR_FOG_BIT), 0);
|
||||
@@ -414,7 +442,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn interior_fog_bit_survives_the_field_writers() {
|
||||
// The bit is owned by the classifier's whole-payload writes; the alpha/shade
|
||||
// The bit is set by its owners through `with_interior_fog`; the alpha/shade
|
||||
// read-modify-writes must carry it (a feathering or MCSH-ramping indoor unit keeps
|
||||
// its room fog).
|
||||
let t = INTERIOR_FOG_BIT | alpha_bits(1.0);
|
||||
@@ -443,14 +471,14 @@ mod tests {
|
||||
);
|
||||
assert_eq!((indoor & PROBE_MASK) >> PROBE_SHIFT, 1234);
|
||||
assert_eq!(rig_of(indoor), 7);
|
||||
assert_eq!(indoor & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
|
||||
let outdoor = with_exterior_reset(indoor);
|
||||
assert_eq!(outdoor & ALPHA_MASK, mid_ramp, "and the reclaim too");
|
||||
assert_eq!(rig_of(outdoor), 7);
|
||||
assert_eq!(outdoor & (PROBE_MASK | INTERIOR_FOG_BIT), 0);
|
||||
// A settled (opaque) part is unaffected — the steady state still reads exactly as before.
|
||||
// A settled (opaque) part is unaffected — the steady state still reads exactly as before,
|
||||
// barring the flag the two constructors deliberately differ on (see `probe_bits`).
|
||||
assert_eq!(
|
||||
with_interior_probe(alpha_bits(1.0), 1234),
|
||||
with_interior_fog(with_interior_probe(alpha_bits(1.0), 1234), true),
|
||||
probe_bits(1234),
|
||||
"an opaque part's Bake payload is unchanged"
|
||||
);
|
||||
|
||||
@@ -261,6 +261,8 @@ pub struct EffectDraw {
|
||||
/// the draw's verts skip the cam-relative rebase and run the world-mesh `clip_from_world`,
|
||||
/// so the depth tie the bias settles is against the same arithmetic.
|
||||
pub(crate) raster_bias: i32,
|
||||
/// The slope-scale half of the settle — see [`EffectDrawSpec::raster_slope`].
|
||||
pub(crate) raster_slope: f32,
|
||||
/// Are this draw's vertices **already camera-relative**? See [`EffectDrawSpec::cam_relative`].
|
||||
pub(crate) cam_relative: bool,
|
||||
/// Vertex range in [`EffectQuads::verts`] (a multiple of 4 for quads, 3 for tris).
|
||||
@@ -327,6 +329,15 @@ pub struct EffectDrawSpec {
|
||||
pub anchor: Vec3,
|
||||
pub bias: f32,
|
||||
pub raster_bias: i32,
|
||||
/// The rasterizer `DepthBiasState` **slope-scale** half of this draw's settle — the term that
|
||||
/// scales with the primitive's own depth slope, and the one a near-horizontal decal seen at a
|
||||
/// grazing angle actually needs (a constant cannot follow a surface whose depth gradient runs
|
||||
/// away from the eye). `0.0` for every family with no coplanar receiver.
|
||||
///
|
||||
/// It exists because the reference has one: the sole `glPolygonOffset` site (`0x59bf0a`, a
|
||||
/// hardcoded `0xc0800000`) arms `factor = −4.0` alongside the units term whenever EGxRs id `0`
|
||||
/// is set non-zero, which is what the foam draw's `0x68fd0f` does. 1809.
|
||||
pub raster_slope: f32,
|
||||
/// Set when the producer has **already** written camera-relative vertices, so
|
||||
/// [`super::render`]'s rebase must skip this draw. The default (`false`) is the lane's
|
||||
/// contract: producers write ABSOLUTE world positions and the rebase subtracts the camera
|
||||
@@ -391,6 +402,7 @@ impl EffectQuads {
|
||||
anchor: spec.anchor,
|
||||
bias: spec.bias,
|
||||
raster_bias: spec.raster_bias,
|
||||
raster_slope: spec.raster_slope,
|
||||
cam_relative: spec.cam_relative,
|
||||
range: start..end,
|
||||
main_entity: spec.main_entity,
|
||||
@@ -505,6 +517,7 @@ mod tests {
|
||||
anchor: Vec3::ZERO,
|
||||
bias: 0.0,
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
@@ -602,6 +615,7 @@ impl<'w> WorldEffectDraw<'w> {
|
||||
anchor: Vec3::ZERO,
|
||||
bias: 0.0,
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: Entity::PLACEHOLDER,
|
||||
light: None,
|
||||
@@ -653,7 +667,8 @@ impl EffectBatch<'_> {
|
||||
}
|
||||
|
||||
/// This batch's rung on the draw-order ladder: the transparent sort bias and the rasterizer
|
||||
/// depth-bias constant (`crate::sky_order::Rung`).
|
||||
/// depth-bias constant (`crate::sky_order::Rung`). The slope-scale half stays 0 — only the
|
||||
/// foam lane carries one, and it builds its spec directly.
|
||||
pub fn rung(mut self, sort: f32, raster: i32) -> Self {
|
||||
self.spec.bias = sort;
|
||||
self.spec.raster_bias = raster;
|
||||
|
||||
@@ -84,6 +84,8 @@ struct ExtractedDraw {
|
||||
anchor: Vec3,
|
||||
bias: f32,
|
||||
raster_bias: i32,
|
||||
/// The slope-scale half of the settle — see [`super::buffer::EffectDrawSpec::raster_slope`].
|
||||
raster_slope: f32,
|
||||
/// Vertices are already camera-relative — the prepare rebase skips this draw. See
|
||||
/// [`super::buffer::EffectDrawSpec::cam_relative`].
|
||||
cam_relative: bool,
|
||||
@@ -190,6 +192,9 @@ pub struct EffectPipelineKey {
|
||||
/// keeps absolute verts and runs the mesh path's `clip_from_world`, so its depth ties
|
||||
/// against the drawn ground within the bias.
|
||||
raster_bias: i32,
|
||||
/// The **slope-scale** half of the settle, keyed by bits because `f32` is not `Hash`/`Eq`.
|
||||
/// Two values exist ({0.0, the foam's}), so the key space stays as small as `raster_bias`'s.
|
||||
raster_slope_bits: u32,
|
||||
/// Does the fragment multiply by the scene's matte light? The reference decides this per
|
||||
/// emitter through a synthesized `M2Material` (`EffectDrawSpec::lit`), and it is a shader
|
||||
/// def rather than a uniform bit so the 95% unlit majority keeps the identical instruction
|
||||
@@ -315,7 +320,7 @@ impl SpecializedRenderPipeline for EffectPipeline {
|
||||
stencil: StencilState::default(),
|
||||
bias: DepthBiasState {
|
||||
constant: key.raster_bias,
|
||||
slope_scale: 0.0,
|
||||
slope_scale: f32::from_bits(key.raster_slope_bits),
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
@@ -361,6 +366,7 @@ fn extract_effects(
|
||||
anchor: d.anchor,
|
||||
bias: d.bias,
|
||||
raster_bias: d.raster_bias,
|
||||
raster_slope: d.raster_slope,
|
||||
cam_relative: d.cam_relative,
|
||||
range: d.range.clone(),
|
||||
light: d.light.clone(),
|
||||
@@ -416,6 +422,7 @@ fn queue_effects(
|
||||
hdr: view.hdr,
|
||||
blend: draw.blend,
|
||||
raster_bias: draw.raster_bias,
|
||||
raster_slope_bits: draw.raster_slope.to_bits(),
|
||||
lit: draw.lit,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1288,6 +1288,7 @@ pub(super) fn simulate_particles(
|
||||
anchor,
|
||||
bias,
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: entity,
|
||||
light: light_override.map(|l| l.0.clone()),
|
||||
@@ -1329,6 +1330,7 @@ pub(super) fn simulate_particles(
|
||||
anchor,
|
||||
bias,
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: entity,
|
||||
light: light_override.map(|l| l.0.clone()),
|
||||
|
||||
@@ -703,6 +703,7 @@ pub(crate) fn simulate_ribbons(
|
||||
0.0
|
||||
},
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
cam_relative: false,
|
||||
main_entity: entity,
|
||||
light: None, // trails never carry a light override (world lane only)
|
||||
|
||||
@@ -123,6 +123,22 @@ pub(crate) const FAR_SIDE_BIAS: f32 = -4.0e4;
|
||||
/// `StandardMaterial` base, so it IS also a depth-test bias — ~0.24% depth pull, half of what
|
||||
/// the nameplates ship at +4e4; the water-noon/name-water capture diffs are the shoreline check).
|
||||
pub(crate) const WATER_BIAS: f32 = -2.0e4;
|
||||
/// **The water pass's own foam** — `CWater0Ripple`'s wade wake / standing ring, which the reference
|
||||
/// draws INSIDE the water group and AFTER every liquid queue has drained (`0x6816d0`: ocean → river
|
||||
/// → WMO liquid → foam). So foam is unconditionally over every water surface in the frame and still
|
||||
/// under the near-side world transparents that follow the pass.
|
||||
///
|
||||
/// It rode [`WATER_BIAS`]` + 1.0` until B348: `+1` is a *coplanar* tie-break, and the thing it has to
|
||||
/// beat is not one surface but the whole water band. A water chunk's key is `WATER_BIAS + its own
|
||||
/// view-z`, so the chunk one lattice over — 33 yd nearer the eye — outranks a foam patch by ~33,
|
||||
/// thirty times the tie-break, and paints over it: the director's "the wake goes dull, like another
|
||||
/// layer without the effect is over it". A rung, not an epsilon, is the fix, and the window is
|
||||
/// arithmetic: above every water key (`WATER_BIAS` + 0) even from the far plane, below every
|
||||
/// unbiased near-side transparent (view-z ≥ [`WORLD_VIEW_Z_FLOOR`]) even at the eye — i.e.
|
||||
/// `(WATER_BIAS − WORLD_VIEW_Z_FLOOR, WORLD_VIEW_Z_FLOOR)` = `(−1.7e4, −3e3)`, taken centred with
|
||||
/// 7e3 of margin at each end. Both ends are asserted below; the blanket 1e4 does not fit a 1.4e4
|
||||
/// window and is not claimed (the decal band's precedent).
|
||||
pub(crate) const FOAM_BIAS: f32 = -1.0e4;
|
||||
/// The sun/moon glare quads — the frame's last render (`0x483740` tail): over the clouds and the
|
||||
/// rain, under the nameplates; the z-buffer (their forced far depth, `celestial.wgsl`) is what
|
||||
/// occludes them. Only ~6× the far plane, not ~10⁵: a rung this far up is also a rasterizer bias
|
||||
@@ -241,6 +257,37 @@ impl Rung {
|
||||
/// every zoom ≥1 yd and stays centimetre-order at 30 yd. Sizing pinned by
|
||||
/// `raised_bias_dominates_the_bake_residual` (particles/render.rs).
|
||||
pub const SHADOW_RASTER: i32 = 32768;
|
||||
|
||||
/// **The wade-foam decal's rasterizer settle — the reference's own, both halves** (VERIFIED,
|
||||
/// wow-re `terrain/scratch/foam-decal-depth-and-drain-slot.md` §3; folded back as 1809).
|
||||
///
|
||||
/// `0x68fd0f` arms EGxRs id `0` for the foam draw, and arming that id non-zero is what issues
|
||||
/// the client's sole `glPolygonOffset` — `factor` a hardcoded `0xc0800000` = **−4.0**
|
||||
/// (`0x59bf0a`, in capability arm `0x41`, whose only image-wide call site is inside the id-`0`
|
||||
/// applicator), `units` = `footstepBias(0.125) × [0x810390]` ⇒ **−8.0001**. So the reference
|
||||
/// settles this decal with a **slope-scaled** term plus a near-zero constant, which is the
|
||||
/// right instrument and not an accident: a near-horizontal decal seen at a grazing angle has a
|
||||
/// depth gradient a constant cannot follow, and the shoreline is exactly that view.
|
||||
///
|
||||
/// **The signs invert here.** GL depth runs 0 = near, so its negative offset pulls toward the
|
||||
/// eye; ours is reversed-Z (`CompareFunction::GreaterEqual`, near = 1), where toward the eye is
|
||||
/// *larger* depth — so both terms are positive. [`DECAL_RASTER`](Rung::DECAL_RASTER) being
|
||||
/// positive for the same job corroborates the convention independently.
|
||||
///
|
||||
/// **Two things here are ours, not transcribed.** `factor` multiplies the primitive's *window*
|
||||
/// depth slope, and reversed-Z gives a plane a different gradient than GL's mapping does, so
|
||||
/// 4.0 is the reference's number in a frame where it means something slightly different.
|
||||
/// And the constant is in ULPs of a **float** depth buffer (per-primitive, the quantisation
|
||||
/// 1806 is about) rather than GL's fixed `2⁻²⁴` — same order, not the same unit. The client's
|
||||
/// *D3D* backend arms no slope term at all and compensates with a flat bias ~256× the GL
|
||||
/// constant; which of the two encodings is canonical turns on the `gxApi` device-identity
|
||||
/// question, which is open on wow-re's `gx` board. The GL shape is ported because it is the
|
||||
/// one that is *correct* in any depth format, and because a flat constant is described at the
|
||||
/// bytes as standing in for the missing slope term.
|
||||
pub const FOAM_RASTER: i32 = 8;
|
||||
/// The slope-scale half of [`FOAM_RASTER`](Rung::FOAM_RASTER) — see its doc for the bytes,
|
||||
/// the sign inversion, and what is ported vs transcribed.
|
||||
pub const FOAM_RASTER_SLOPE: f32 = 4.0;
|
||||
/// **World text** — above the celestial glare, so a flare never washes a nameplate, and above
|
||||
/// every sky rung. The reference draws its world text late in the frame (decision 0519).
|
||||
/// Small on purpose: 6× the far plane is all the ordering needs, and this same field doubles
|
||||
@@ -268,12 +315,22 @@ const _: () = {
|
||||
assert!(FAR_SIDE_BIAS - CLOUDS_BIAS > 1.0e4);
|
||||
assert!(WATER_BIAS - FAR_SIDE_BIAS > 1.0e4);
|
||||
assert!(-3.0e3 - WATER_BIAS > 1.0e4); // world view-z floor = −far (the ~3 km projection) stays above
|
||||
// Foam sits in the water pass, over every liquid surface and under the near-side default,
|
||||
// whatever the two draws' distances are (B348).
|
||||
assert!(FOAM_BIAS + WORLD_VIEW_Z_FLOOR - WATER_BIAS > 1.0e3);
|
||||
assert!(WORLD_VIEW_Z_FLOOR - FOAM_BIAS > 1.0e3);
|
||||
assert!(Rung::GROUND_FX - CLOUDS_BIAS > 1.0e4);
|
||||
assert!(GLARE_BIAS - Rung::GROUND_FX > 1.0e4);
|
||||
assert!(Rung::NAMEPLATE - GLARE_BIAS > 1.0e4);
|
||||
// The two raster margins are their own axis (B131) — not comparable to the sort rungs above,
|
||||
// only to zero and to each other: the shadow's is the raised one.
|
||||
assert!(Rung::SHADOW_RASTER > Rung::DECAL_RASTER && Rung::DECAL_RASTER > 0);
|
||||
// The foam's settle is the reference's own and is deliberately the SMALL one: its work is done
|
||||
// by the slope term, which the other three decal lanes do not carry (their own arming is
|
||||
// unread — see FOAM_RASTER). Both halves pull toward the eye under reversed-Z, so both are
|
||||
// positive; a negative here would push the decal INTO its receiver.
|
||||
assert!(Rung::FOAM_RASTER > 0 && Rung::FOAM_RASTER < Rung::DECAL_RASTER);
|
||||
assert!(Rung::FOAM_RASTER_SLOPE > 0.0);
|
||||
|
||||
// ─── The pre-water decal band (B347, 1785/1789) ─────────────────────────────────────────
|
||||
// Internally ordered as the reference's frame emits them — ring then shadow, both from
|
||||
|
||||
@@ -34,6 +34,21 @@
|
||||
//! kinds: rings every 400–450 ms, wakes one per ~0.625 yd of travel (the byte cadence laws —
|
||||
//! the 0264 INTERIM constants are resolved, decision 0265).
|
||||
//!
|
||||
//! - **The patch sits ON the plane; its coplanar ties are settled in DEPTH, never by a lift**
|
||||
//! (B348, 1807/1808). A decal's verts are the plane's own, so it ties with the water surface
|
||||
//! everywhere and with the *terrain* along the shoreline, where the two cross. Lifting the patch
|
||||
//! in world space wins that tie and also moves the geometry — and on a beach a vertical lift is a
|
||||
//! horizontal overshoot: a decal `l` yards up keeps painting for `l / slope` yards past the
|
||||
//! waterline, on dry sand (0.10 yd at the 29 % Stranglethorn beach the defect was reported on,
|
||||
//! proportionally more on every flatter shore — measured with
|
||||
//! `benilla-formats --example water_here`). **The reference agrees**: `0x68fd0f` arms a
|
||||
//! depth-buffer bias and the foam vertices sit at the queried MCLQ height exactly, so there is no
|
||||
//! geometric lift in the binary to be faithful to (the "−2048 GL units" this file cited for a
|
||||
//! year is refuted; the real term is `footstepBias(0.125) × [0x810390]` = D3D `DEPTHBIAS −1/8192`).
|
||||
//! Ours is the decal family's rasterizer bias plus `DECAL_WORLD_CLIP` — absolute verts through the
|
||||
//! same `clip_from_world` the world meshes run (0781), so the tie is settled in depth ULPs with
|
||||
//! zero horizontal cost.
|
||||
//!
|
||||
//! The formulas + lifecycle math live in [`params`]; this half is the ECS: the emitter over the
|
||||
//! avatar + streamed units, the record pool, and one additive effect-stream draw per
|
||||
//! (chunk, category) with live records (fog OFF — the reference's verified foam render state).
|
||||
@@ -95,10 +110,6 @@ const MOVE_EPSILON: f32 = 0.5;
|
||||
/// (the `& 0x30` proxy).
|
||||
const TURN_EPSILON: f32 = 0.35;
|
||||
|
||||
/// Geometric lift (yd) above the water surface — the anti-coplanarity measure standing in for the
|
||||
/// reference's polygon offset (−2048 GL units) where foam meets the *terrain* at the shoreline.
|
||||
const SURFACE_LIFT: f32 = 0.03;
|
||||
|
||||
/// One live foam decal — a `CWater0Ripple` pool record. Geometry is static (built at emission);
|
||||
/// per-frame size/alpha are derived from `born` + the params (`params.rs`).
|
||||
struct FoamRecord {
|
||||
@@ -181,22 +192,26 @@ fn alloc_slot(cursor: &mut usize, base: usize, len: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// The two foam stencils (ring/wake), decoded raw at startup. The draws they feed are
|
||||
/// effect-stream records: additive, fog OFF (VERIFIED `0x68fcc1`), sort bias +1.0 over the
|
||||
/// hosting water chunk (the coplanar tie — the reference draws foam in the water group right
|
||||
/// after the liquid surface).
|
||||
/// The two foam stencils (ring/wake), decoded raw at startup. The draws they feed carry the
|
||||
/// reference's **whole** foam render state (VERIFIED at the bytes — wow-re's §5 on `0x68fae0`,
|
||||
/// folded back as 1808): additive; **fog off** (`0x68fcd0`/`0x68fcd2`/`0x68fcd7` — *not*
|
||||
/// `0x68fcc1`, which is the blend value's `mov edx,3` and was this file's citation until then);
|
||||
/// **depth-tested `LEQUAL`, depth-write off**; and a depth-buffer bias armed at `0x68fd0f`.
|
||||
/// Sorted on [`crate::sky_order::FOAM_BIAS`], a rung over the whole water band, because
|
||||
/// `0x68fae0` is `0x6816d0`'s tail-jump on both arms of the submersion branch — one call per
|
||||
/// frame, after **all** liquid has drained, with no sort key in it anywhere.
|
||||
#[derive(Resource)]
|
||||
struct FoamAssets {
|
||||
ring: Handle<Image>,
|
||||
wake: Handle<Image>,
|
||||
}
|
||||
|
||||
/// The foam draw's sort-bias: **just over its water**, riding the water-pass rung. The reference
|
||||
/// draws foam inside the water group, right after the liquid surfaces (`0x6816d0`: ocean → river
|
||||
/// → WMO liquid → foam) — so when the water pass moved to its fixed frame slot
|
||||
/// ([`crate::sky_order::WATER_BIAS`]), the foam moved with it: still +1 over the surface for the
|
||||
/// coplanar tie, now *below* the near-side world transparents like the reference's.
|
||||
const FOAM_BIAS: f32 = crate::sky_order::WATER_BIAS + 1.0;
|
||||
/// The foam draw's sort rung — [`crate::sky_order::FOAM_BIAS`], whose doc carries the arithmetic.
|
||||
/// It is a **rung above the whole water band**, not an epsilon over one surface: the reference
|
||||
/// drains every liquid queue before it draws foam, so no water surface in the frame can come after
|
||||
/// it. (It rode `WATER_BIAS + 1.0` until B348, where the water chunk one lattice nearer the eye
|
||||
/// outsorted the foam patch by ~33 and painted over it.)
|
||||
use crate::sky_order::FOAM_BIAS;
|
||||
|
||||
/// Load the stencils raw (CLAMP, no mips — the reference's measured sampler state).
|
||||
fn setup_water_fx(
|
||||
@@ -285,7 +300,7 @@ fn build_patch(
|
||||
}
|
||||
// The liquid mesh's own winding: [tl, bl, br] then [tl, br, tr].
|
||||
for v in [tl, bl, br, tl, br, tr] {
|
||||
verts.push(wow_to_bevy([v[0], v[1], v[2] + SURFACE_LIFT]));
|
||||
verts.push(wow_to_bevy(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -575,14 +590,22 @@ fn push_water_foam(
|
||||
assets.wake.id()
|
||||
},
|
||||
blend: EffectBlend::Add,
|
||||
// The reference's foam render sets FOG off (VERIFIED `0x68fcc1`).
|
||||
// The reference's foam render sets FOG off (VERIFIED `0x68fcd0`/`0x68fcd2`/
|
||||
// `0x68fcd7`; the long-standing `0x68fcc1` citation was the blend setup — 1808).
|
||||
fog: EffectFog::Off,
|
||||
// The reference's foam render is its own additive path (`0x68fae0`), not the
|
||||
// M2 batch state producer — no GL_LIGHTING on it.
|
||||
lighting: crate::particles::buffer::EffectLighting::None,
|
||||
anchor: centroid / n as f32,
|
||||
bias: FOAM_BIAS,
|
||||
raster_bias: 0,
|
||||
// **The reference's own settle, both halves** (`Rung::FOAM_RASTER`, whose doc
|
||||
// carries the bytes): a near-zero constant plus a slope-scaled term, which is what
|
||||
// a near-horizontal decal at a grazing angle needs — the shoreline view. A nonzero
|
||||
// constant also selects `DECAL_WORLD_CLIP`, so these absolute verts skip the
|
||||
// cam-relative rebase and tie against the ground through the world meshes' own
|
||||
// matrix (0781).
|
||||
raster_bias: crate::sky_order::Rung::FOAM_RASTER,
|
||||
raster_slope: crate::sky_order::Rung::FOAM_RASTER_SLOPE,
|
||||
cam_relative: false,
|
||||
// NEVER the chunk entity: a draw's probe identity must not own a registered
|
||||
// mesh, or bevy's sorted-phase batcher claims the item and rewrites its
|
||||
@@ -735,9 +758,11 @@ mod tests {
|
||||
let (verts, _) = build_patch([2.0, 2.0], 1.5, &chunks).unwrap();
|
||||
assert_eq!(verts.len(), 6, "only the one wet cell overlaps");
|
||||
let wow = bevy_to_wow(verts[0]);
|
||||
// ON the plane, to the bit — the coplanar tie is the rasterizer's, and a geometric lift
|
||||
// here is a horizontal overshoot onto dry ground at every shoreline (B348).
|
||||
assert!(
|
||||
(wow[2] - (5.0 + SURFACE_LIFT)).abs() < 1e-4,
|
||||
"lifted to the surface"
|
||||
(wow[2] - 5.0).abs() < 1e-4,
|
||||
"the patch sits exactly on the liquid surface, unlifted"
|
||||
);
|
||||
assert!(
|
||||
build_patch([50.0, 50.0], 1.5, &chunks).is_none(),
|
||||
|
||||
@@ -663,6 +663,7 @@ fn push_precip(
|
||||
anchor: cam_pos,
|
||||
bias: 0.0,
|
||||
raster_bias: 0,
|
||||
raster_slope: 0.0,
|
||||
// Streaks, patters and mist are all centimetre-scale or bigger, so absolute world verts
|
||||
// cost them nothing; the flake draw below overrides this (its quads are millimetres).
|
||||
cam_relative: false,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
|
||||
use bevy::math::{Mat4, Vec3};
|
||||
|
||||
use super::super::seed::down_ray_claim;
|
||||
use super::super::EXTERIOR_LIT;
|
||||
use super::{
|
||||
load_subject, Site, BLACKSMITH, DEADMINES, EXTERIOR, FARGODEEP, GOLDSHIRE, IRONFORGE,
|
||||
SHADOWFANG, UNDERCITY,
|
||||
@@ -629,3 +631,99 @@ fn shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog() {
|
||||
"control: the room seeds its own flood, so it wears the building's MFOG from inside"
|
||||
);
|
||||
}
|
||||
|
||||
/// **1792 §5's fixture** — the sibling case to [`shadowfang_courtyard_leaves_the_far_room_on_the_scene_fog`],
|
||||
/// one room further in. Stand *inside* g61 (the director's own "standing inside it" pin) and look
|
||||
/// back the way they came: the flood reaches three more true-interior rooms across the courtyards,
|
||||
/// and every one of them is off the interior-fog chain. Their walls take the scene fog, which is
|
||||
/// 1787 working — but a unit standing in one classifies indoors by its OWN down-ray, so before the
|
||||
/// `[P+0x98]` conjunct it wore this building's MFOG regardless. g13 is a 14x18x13 room whose centre
|
||||
/// is 91 yd from the eye, where that MFOG (start 10.7, end 106.9) is ~84 % saturated toward
|
||||
/// rgb(40,68,79) while the scene fog at map 33 noon (start 83, end 333) is ~3 %: the mob reads as a
|
||||
/// flat teal cut-out in an unfogged room.
|
||||
///
|
||||
/// The invariant this pins is that the case is REACHABLE — a visible true interior off the chain —
|
||||
/// because it is what makes the conjunct load-bearing rather than theoretical. The conjunct itself
|
||||
/// is `crate::interior`'s `a_settled_anchor_follows_its_rooms_fog_gate_without_moving`.
|
||||
#[test]
|
||||
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
|
||||
fn shadowfang_sees_true_interiors_that_are_off_the_fog_chain() {
|
||||
let site = SHADOWFANG;
|
||||
let subject = load_subject(site.wmo, Some(&site));
|
||||
let placed = subject
|
||||
.placed
|
||||
.as_ref()
|
||||
.expect("the fixture needs a placement");
|
||||
let to_local =
|
||||
|wow: [f32; 3]| bevy_to_wow(placed.local_from_world.transform_point3(wow_to_bevy(wow)));
|
||||
let inside = to_local([-213.90, 2236.15, 81.5]);
|
||||
let look = to_local([-227.1, 2157.0, 83.7]);
|
||||
let inside_bevy = placed
|
||||
.world_from_local
|
||||
.transform_point3(wow_to_bevy(inside));
|
||||
let look_bevy = placed.world_from_local.transform_point3(wow_to_bevy(look));
|
||||
let clip = Mat4::perspective_rh(crate::view::CAM_FOVY, 16.0 / 9.0, 0.1, 1000.0)
|
||||
* Mat4::look_at_rh(inside_bevy, look_bevy, Vec3::Y);
|
||||
let pvs = crate::wmo_portal::compute_pvs_traced(
|
||||
&subject.model,
|
||||
inside,
|
||||
subject.terrain_z(inside),
|
||||
&clip,
|
||||
&placed.world_from_local,
|
||||
&mut (),
|
||||
);
|
||||
|
||||
assert!(
|
||||
pvs.interior_fog[61],
|
||||
"the camera's own room is on the chain — otherwise nothing here is being tested"
|
||||
);
|
||||
let off_chain: Vec<usize> = (0..subject.model.group_nav.len())
|
||||
.filter(|&g| {
|
||||
pvs.visible[g] && !pvs.interior_fog[g] && subject.model.group_nav[g].flags & 0x48 == 0
|
||||
})
|
||||
.collect();
|
||||
// And the other half of the verdict, end to end on the real data: a unit standing in one of
|
||||
// those rooms attaches to it. Same ray and same `0x48` mask the light classifier's attach uses
|
||||
// (`crate::interior` → `indoor_verdict_at`), cast from a point the body occupies — so the room
|
||||
// it claims is the key the fog gate is asked at. g13 claims g13 (indoor, off the chain ⇒ the
|
||||
// scene fog), and the control g61 claims g61 (indoor, ON the chain ⇒ the building's MFOG).
|
||||
let attach_room = |gi: usize| {
|
||||
let g = &subject.model.group_nav[gi];
|
||||
let centre = [
|
||||
(g.bbox_min[0] + g.bbox_max[0]) * 0.5,
|
||||
(g.bbox_min[1] + g.bbox_max[1]) * 0.5,
|
||||
(g.bbox_min[2] + g.bbox_max[2]) * 0.5,
|
||||
];
|
||||
down_ray_claim(
|
||||
&subject.model.group_collision_tris,
|
||||
&subject.model.group_collision_bounds,
|
||||
&subject.model.group_collision_grids,
|
||||
&subject.model.group_nav,
|
||||
centre,
|
||||
None,
|
||||
EXTERIOR | EXTERIOR_LIT,
|
||||
)
|
||||
.map(|c| (c.group, c.outdoor))
|
||||
};
|
||||
assert_eq!(
|
||||
attach_room(13),
|
||||
Some((13, false)),
|
||||
"a body standing in g13 attaches to g13 and classifies INDOORS — the light law says fog it"
|
||||
);
|
||||
assert!(
|
||||
!pvs.interior_fog[13],
|
||||
"…and its room is off the chain, so the fog it takes is the SCENE's (1792 §5's fix)"
|
||||
);
|
||||
assert_eq!(
|
||||
attach_room(61),
|
||||
Some((61, false)),
|
||||
"the control: a body in the camera's own room attaches to it"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
off_chain,
|
||||
vec![1, 2, 13],
|
||||
"three true-interior rooms are in frame across the courtyards and none is on the chain: \
|
||||
this is the population a unit's fog gate has to answer for"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -451,12 +451,13 @@ pub fn indoors_at<'a>(
|
||||
matches!(
|
||||
indoor_verdict_at(
|
||||
wmos,
|
||||
instances,
|
||||
instances.into_iter().map(|inst| ((), inst)),
|
||||
streamer,
|
||||
adt_tiles,
|
||||
feet_world,
|
||||
LightAttach::DownRay,
|
||||
),
|
||||
)
|
||||
.0,
|
||||
IndoorVerdict::DayNight | IndoorVerdict::Baked { .. }
|
||||
)
|
||||
}
|
||||
@@ -527,29 +528,35 @@ pub enum IndoorVerdict {
|
||||
/// a ray from there leaves the building entirely; the reference never casts from there (0776).
|
||||
/// `Containment` also takes the reference's UPWARD retry on the footprint leg (`6a908d`), so an
|
||||
/// object resting a hair below its floor still bakes from it.
|
||||
pub fn indoor_verdict_at<'a>(
|
||||
///
|
||||
/// Returns the winning **room** alongside the verdict — the caller's own key for the placement that
|
||||
/// won, paired with the claim's group index — `None` on both outdoor arms. That pair is the
|
||||
/// reference's per-(instance, group) render record `P`: the light node's attach is what creates it
|
||||
/// (`0x685f85` → `[P+0x98]`), and it is the key the interior-FOG gate is asked at, separately from
|
||||
/// the light law (decision 1792 §4). Callers that only want the bool pass `()` as the key.
|
||||
pub fn indoor_verdict_at<'a, K: Copy>(
|
||||
wmos: &Assets<WmoModel>,
|
||||
instances: impl IntoIterator<Item = &'a WmoPortalInstance>,
|
||||
instances: impl IntoIterator<Item = (K, &'a WmoPortalInstance)>,
|
||||
streamer: &TerrainStreamer,
|
||||
adt_tiles: &Assets<AdtTile>,
|
||||
anchor_world: Vec3,
|
||||
attach: LightAttach,
|
||||
) -> IndoorVerdict {
|
||||
) -> (IndoorVerdict, Option<(K, usize)>) {
|
||||
let probe_world = anchor_world + Vec3::Y * POSITION_PROBE_LIFT;
|
||||
let terrain = terrain_height_under(streamer, adt_tiles, probe_world);
|
||||
let mut best: Option<(DownRayClaim, &WmoModel, &WmoPortalInstance, [f32; 3])> = None;
|
||||
let mut best: Option<(DownRayClaim, &WmoModel, &WmoPortalInstance, [f32; 3], K)> = None;
|
||||
// The containment lane's UPWARD retry (`0x6a8ed0`'s `6a9093 fadd ds:0x8022cc`): a GameObject's
|
||||
// light node anchors at its authored-box centre, which for a particle-heavy model sits well
|
||||
// BELOW the floor it stands on — 104 of Onyxia's lava traps anchor ~40 yd under the chamber
|
||||
// (their authored box reaches 98 yd past their own geometry). The reference recovers by casting
|
||||
// up; run it only when the whole downward pass came back empty, so a claim is never arbitrated
|
||||
// against one made in the other direction. See [`up_ray_claim`].
|
||||
let instances: Vec<&WmoPortalInstance> = instances.into_iter().collect();
|
||||
let instances: Vec<(K, &WmoPortalInstance)> = instances.into_iter().collect();
|
||||
for retry_up in [false, true] {
|
||||
if retry_up && (best.is_some() || !matches!(attach, LightAttach::Containment)) {
|
||||
break;
|
||||
}
|
||||
for inst in instances.iter().copied() {
|
||||
for (key, inst) in instances.iter().copied() {
|
||||
let Some(model) = wmos.get(&inst.handle) else {
|
||||
continue;
|
||||
};
|
||||
@@ -583,16 +590,21 @@ pub fn indoor_verdict_at<'a>(
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(b, ..)| claim.depth < b.depth) {
|
||||
best = Some((claim, model, inst, probe_local));
|
||||
best = Some((claim, model, inst, probe_local, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((claim, model, inst, probe_local)) = best else {
|
||||
return IndoorVerdict::Outdoors;
|
||||
let Some((claim, model, inst, probe_local, key)) = best else {
|
||||
return (IndoorVerdict::Outdoors, None);
|
||||
};
|
||||
if claim.outdoor {
|
||||
return IndoorVerdict::OutdoorsOnWmo;
|
||||
return (IndoorVerdict::OutdoorsOnWmo, None);
|
||||
}
|
||||
// The ROOM the light node attached to, for every indoor verdict below. It is the ATTACH ray's
|
||||
// group (`0x6a8a20`'s claim), not the footprint sample's: the reference's per-(instance,group)
|
||||
// render record hangs off the node's attach, and the footprint leg is a later, separate ray
|
||||
// that only picks the bake colours. The two agree on every floor where one face owns both.
|
||||
let room = Some((key, claim.group));
|
||||
// Indoors — run the footprint sample on the winning placement's render mesh. The containment
|
||||
// lane retries UPWARD on a miss (`6a908d..6a90c7`); the down-ray lane has no such leg here (its
|
||||
// own retry is a second DOWNWARD cast from 1000 above, `6a8ab0`, which cannot reach a face
|
||||
@@ -604,10 +616,10 @@ pub fn indoor_verdict_at<'a>(
|
||||
.flatten()
|
||||
});
|
||||
let Some((gi, mocv, mopy_daynight)) = sample else {
|
||||
return IndoorVerdict::DayNight;
|
||||
return (IndoorVerdict::DayNight, room);
|
||||
};
|
||||
if mopy_daynight {
|
||||
return IndoorVerdict::DayNight;
|
||||
return (IndoorVerdict::DayNight, room);
|
||||
}
|
||||
let lobes = model
|
||||
.group_light_refs
|
||||
@@ -625,7 +637,7 @@ pub fn indoor_verdict_at<'a>(
|
||||
atten_end: l.attenuation_end,
|
||||
})
|
||||
.collect();
|
||||
IndoorVerdict::Baked { mocv, lobes }
|
||||
(IndoorVerdict::Baked { mocv, lobes }, room)
|
||||
}
|
||||
|
||||
/// The footprint down-ray: the nearest render-mesh face below `probe_local` across the model's
|
||||
|
||||
@@ -161,9 +161,7 @@ impl WmoGroupVis {
|
||||
/// fail-open: a missing bit here must not paint a room's fog onto the open world, and the scene
|
||||
/// fog is what every un-gated surface already wears.
|
||||
pub fn interior_fogged_by(&self, inst: &WmoPortalInstance) -> bool {
|
||||
self.groups
|
||||
.iter()
|
||||
.any(|&g| inst.interior_fog.get(g as usize).copied().unwrap_or(false))
|
||||
self.groups.iter().any(|&g| inst.fogs_group(g))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +240,21 @@ pub struct WmoPortalInstance {
|
||||
pub flooded: Vec<Option<benilla_formats::LiquidKind>>,
|
||||
}
|
||||
|
||||
impl WmoPortalInstance {
|
||||
/// **Is one group on this frame's interior-fog chain?** — the client's `[0xca7f00]` read for a
|
||||
/// single group, which is the grain every consumer of the gate actually asks at: a WMO piece
|
||||
/// ORs it over the groups that name it ([`WmoGroupVis::interior_fogged_by`]), and a unit asks
|
||||
/// it of the one room its light node attached to (`crate::interior`, the `[P+0x98]` conjunct
|
||||
/// of decision 1792 §4). A group past the set reads FALSE — see `interior_fogged_by` for why
|
||||
/// this one fails closed where `drawn_by` fails open.
|
||||
pub fn fogs_group(&self, group: u16) -> bool {
|
||||
self.interior_fog
|
||||
.get(group as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// One WMO **room**: a placed building's instance entity plus the absolute group index inside it.
|
||||
///
|
||||
/// This is the client's `[0xc7b748]` (the camera-containing placed map-object — wow-re
|
||||
|
||||
Reference in New Issue
Block a user