Files
benilla-octo/crates/benilla-world/src/clutter.rs
T
SamandClaude Fable 5.1 c957f98d84 grass fades on a plane, model panes render, and the FPS journal ships
- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004)
- a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013)
- the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010)
- the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:33:58 +07:00

618 lines
31 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.
//! Ground clutter: grass/flowers/pebbles scattered per MCNK cell (authored `GroundEffect*.dbc`
//! placement). At tile-load each chunk's tufts are **scattered** (MCSH/normal-baked) into a per-chunk
//! [`ClutterChunk`]; [`stream_chunk_clutter`] then **builds** its merged meshes lazily only while the
//! chunk is within the ~70 yd detail-doodad horizon and tears them down past it — the reference's
//! per-chunk `CDetailDoodadInst` lifecycle (`ground-effects.md` §7), which bounds live grass to a bubble
//! and spreads the build across frames. [`ClutterPlugin`] owns the catalog + the lazy build lifecycle
//! independently of the terrain streamer; whichever streamer is active does the per-tile *scatter* (it
//! has the tile's chunks) into the `ClutterChunk`s this builds — so the streamer can be swapped.
use std::collections::HashMap;
use bevy::asset::RenderAssetUsages;
use bevy::image::Image;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use crate::view::WorldCamera;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::LockRecover;
use benilla_assets::{AssetSet, WorldAssets};
use benilla_formats::{
load_ground_effect_catalog, load_m2_mesh, scatter_ground_doodads, ChunkMesh,
GroundDoodadPlacement, GroundEffectCatalog, ModelBlend, RenderSubmesh, SHADOW_MAP_SIZE,
};
/// Ground clutter as its own subsystem: loads the `GroundEffect*` catalog + config at startup, and runs
/// the lazy per-chunk build/teardown every frame. The per-tile *scatter* is driven by whichever terrain
/// streamer is active (it has the tile's chunks); this plugin owns the catalog and the build lifecycle
/// so they don't depend on the terrain streamer's own setup (which lets the streamer be swapped).
pub(crate) struct ClutterPlugin;
impl Plugin for ClutterPlugin {
fn build(&self, app: &mut App) {
// The config exists from plugin build, not `setup_clutter`: the CVar loader
// (`crate::cvars::load_config`, also Startup) applies a saved `WorldDetail` onto it, and
// an ordering flip must not silently drop the file's value (0992).
app.init_resource::<ClutterConfig>()
.add_systems(Startup, setup_clutter.after(AssetSet::Open))
.add_systems(
Update,
(
stream_chunk_clutter,
evict_clutter_geometry,
scope_clutter_geometry,
),
);
}
}
/// Drop the decoded clutter-geometry cache on a cross-map transition (`world_map::MapChange` —
/// see its doc): it holds CPU submesh copies of every detail M2 ever decoded, and clutter models
/// are map-flavored (a new map re-decodes its own handful on first build).
fn evict_clutter_geometry(
mut changes: MessageReader<crate::world_map::MapChange>,
geometry: Option<ResMut<ClutterGeometry>>,
) {
if changes.is_empty() {
return;
}
changes.clear();
if let Some(mut g) = geometry {
g.0.clear();
}
}
/// Expire the decoded clutter geometry by **distance** (decision 0793) — the within-map half of
/// [`evict_clutter_geometry`]. Clutter models are map-*flavoured*: the handful a zone uses is decoded
/// on its first chunk build and then held for the process, so a continent tour accumulates every
/// zone's set. Re-approaching re-decodes one M2 off the chain, inside the same lazy per-chunk build
/// that would rebuild the meshes anyway.
fn scope_clutter_geometry(
mut scope: crate::art_scope::ArtScope,
geometry: Option<ResMut<ClutterGeometry>>,
) {
if let Some(mut g) = geometry {
scope.apply(&mut g.0, crate::art_scope::ArtSlot::ClutterGeo);
}
}
/// Startup: load the ground-effect (clutter) catalog off the shared chain + insert the per-chunk
/// geometry cache. (Moved out of the terrain streamer's `setup_terrain`.)
fn setup_clutter(mut commands: Commands, world_assets: Option<ResMut<WorldAssets>>) {
let Some(world_assets) = world_assets else {
return;
};
// Per-chunk clutter geometry cache, read by the lazy `stream_chunk_clutter` build.
commands.insert_resource(ClutterGeometry::default());
let mut chain = world_assets.chain.lock_recover();
// key_by_internal_id = true: the faithful GroundEffectDoodad keying (see load_ground_effect_catalog).
match load_ground_effect_catalog(&mut chain, true) {
Ok(catalog) => {
info!("ground-effect catalog: {} effects", catalog.len());
commands.insert_resource(GroundClutter { catalog });
}
Err(e) => warn!("ground-effect catalog unavailable, no ground clutter: {e:#}"),
}
}
/// Ground-clutter rendering: the `effectId → (models, density)` catalog (GroundEffect* DBCs).
/// Optional — if the DBCs fail to load, the ground simply has no detail doodads. Clutter is scattered
/// per chunk into [`ClutterChunk`] units at tile-load (tracked with the tile so it streams out with the
/// terrain) and built lazily by [`stream_chunk_clutter`]. The raw model geometry is cached in
/// [`ClutterGeometry`].
#[derive(Resource)]
pub(crate) struct GroundClutter {
pub(crate) catalog: GroundEffectCatalog,
}
/// The client's detail-doodad alpha-test reference: `detailDoodadAlpha`, default **128/255 ≈ 0.5**
/// (RE'd in `WoW.exe`). Foliage is an alpha-tested cutout at
/// this ref. We default clutter to it instead of the general (higher) vanilla model key, which was
/// over-cutting the thin grass blades in the shared grass/flower atlas (they vanished while the solid
/// flowers survived).
pub(crate) const DETAIL_DOODAD_ALPHA_REF: f32 = 128.0 / 255.0;
/// The client's detail-doodad draw distance — a **hardcoded 70 yd** (RE'd `_DAT_00867958`); clutter
/// fades out over the last quarter (full to 52.5 yd = 0.75×, gone by 70 yd). This is why the real
/// client's clutter "appears as you walk into it" rather than being drawn to the horizon.
pub(crate) const DETAIL_DOODAD_FADE_FAR: f32 = 70.0;
/// Ground-clutter tunables (read at tile scatter; a `density` change re-scatters LOADED tiles too —
/// `terrain_stream::rescatter_clutter`, the 1.12 setter's own chunk-rebuild law, 0992):
/// `density` multiplies the per-chunk cell-visit count (the client's `frillDensity`, faithful=16 at ×1)
/// — player-settable as the `WorldDetail` CVar (panel 0/1/2 → ×1/×2/×3; the arm in [`crate::cvars`]);
/// `scale` resizes each doodad model; `alpha_ref` is the alpha-test cutout threshold
/// ([`DETAIL_DOODAD_ALPHA_REF`]); `fade_far` is the clutter draw-distance horizon (yd,
/// [`DETAIL_DOODAD_FADE_FAR`]; fade starts at 0.75×). Initial values from `$WOW_CLUTTER_DENSITY` /
/// `$WOW_CLUTTER_SCALE` / `$WOW_CLUTTER_ALPHA` / `$WOW_CLUTTER_FADE`; `density 0` disables clutter.
#[derive(Resource, Clone, Copy)]
pub struct ClutterConfig {
pub density: f32,
pub scale: f32,
pub alpha_ref: f32,
pub fade_far: f32,
}
impl Default for ClutterConfig {
fn default() -> Self {
let env = |k: &str| std::env::var(k).ok().and_then(|s| s.parse::<f32>().ok());
Self {
// Default ×2 = frillDensity 32 = the panel's Medium (1649). It was ×3/High, matched to
// the director's OWN reference install, which runs High — a setting on their machine,
// not a default of the game's: `frillDensity` registers at 16, and a first launch has
// `hwDetect` overwrite it from `VideoHardware.dbc` to 24 on any D3D9-class part and 8
// on the weakest. Both are below Medium, and 24 is not on a panel stop at all, so every
// stop is a divergence; High was simply the dearest one, and this is alpha-tested
// overdraw on the ground — the thing a bandwidth-bound part has least of. Medium is the
// nearest stop no sparser than a fresh install, and the row is one drag from High.
density: env("WOW_CLUTTER_DENSITY").unwrap_or(2.0).max(0.0),
scale: env("WOW_CLUTTER_SCALE").unwrap_or(1.0).max(0.01),
alpha_ref: env("WOW_CLUTTER_ALPHA")
.unwrap_or(DETAIL_DOODAD_ALPHA_REF)
.clamp(0.0, 1.0),
fade_far: env("WOW_CLUTTER_FADE")
.unwrap_or(DETAIL_DOODAD_FADE_FAR)
.max(1.0),
}
}
}
/// Decoded clutter-model geometry cache (model path → submeshes), keyed by normalized path so each
/// tiny detail M2 is loaded once and then *merged* per chunk. Lives as a resource (not on the streamer)
/// so the lazy per-chunk build system can reach it independently of `WorldAssets`. Replaces the
/// streamer's old per-tile `clutter_cache`.
#[derive(Resource, Default)]
pub(crate) struct ClutterGeometry(
pub(crate) benilla_assets::SpatialCache<String, Vec<RenderSubmesh>>,
);
/// One MCNK chunk's ground clutter as a **lazily-built** unit — the faithful per-chunk `CDetailDoodadInst`
/// lifecycle (`ground-effects.md` §7): scattered + MCSH/normal-baked at tile-load, but its meshes are
/// built only while the chunk is within the detail-doodad horizon and torn down past it. This bounds live
/// grass to a ~70 yd bubble (vs every loaded tile) and spreads the mesh-build over frames instead of one
/// per-tile hitch. Owned by its tile (despawned on unload, which cascades to `built`).
#[derive(Component)]
pub(crate) struct ClutterChunk {
/// The chunk's clutter AABB in Bevy space (its terrain box, grown by a tuft's height).
/// Build/teardown gate on the camera's distance to the BOX — its nearest point — which is what
/// "any of this chunk is still within reach" actually means. A centre distance has to carry a
/// chunk-radius margin to approximate it, and that margin is a horizontal figure: on relief a
/// 33 yd chunk's vertical spread pushes its centre past the margin while its near corner is
/// still deep inside the visible band, and the whole chunk's grass pops in on approach (2004).
bounds: (Vec3, Vec3),
/// Scattered placements grouped by model path; each becomes one merged mesh per submesh when built.
models: Vec<(String, Vec<ShadedPlacement>)>,
/// The spawned merged meshes while built (children of this entity); empty ⇒ not currently built.
built: Vec<Entity>,
}
/// Slack (yd) past the reach computed below, so a chunk is built a moment before any of its grass
/// could be visible and the per-frame build cap has room to spend. Past the reach the ramp is
/// already alpha 0, so building/tearing down here is invisible — the same reason the reference ties
/// its build distance to the fade end.
const CLUTTER_BUILD_MARGIN: f32 = 8.0;
/// Hysteresis (yd) between the build distance and the teardown distance, so a chunk hovering at the
/// boundary doesn't thrash build↔despawn every frame.
const CLUTTER_TEARDOWN_HYSTERESIS: f32 = 6.0;
/// How far above the terrain surface a tuft reaches (yd) — the chunk's clutter AABB is its terrain
/// box grown by this, so the gate measures the geometry that actually draws.
const CLUTTER_TUFT_HEIGHT: f32 = 3.0;
/// Build at most this many chunks' clutter per frame, so entering a dense area (or a teleport) spreads
/// the mesh-build + GPU upload over a few frames instead of one stutter.
const CLUTTER_BUILDS_PER_FRAME: usize = 8;
/// **Step 8 — per-doodad MCSH shadow tint.** Vanilla draws detail doodads UNLIT with a hardcoded
/// DIFFUSE constant: `0xFFFFFFFF ≈ 1.0` (lit) or `0xFFC0C0C0 ≈ 0.753` (MCSH-shadowed). Picked
/// per-vertex from the chunk's static MCSH map at the doodad's placement coord. q12 RE:
/// no day/night, no ambient, no N·L — the only shading
/// input is this grey. Baked into vertex `ATTRIBUTE_COLOR` at scatter time so the model shader
/// can `texture × vertex_color` in gamma space (the faithful MODULATE 1×).
const MCSH_LIT: f32 = 1.0;
const MCSH_SHADOWED: f32 = 192.0 / 255.0; // 0xC0 / 0xFF
/// Sample the chunk's MCSH map at a world-space placement coord. Returns the per-doodad shadow
/// tint (1.0 lit, ≈0.753 shadowed). Falls back to lit when the chunk has no MCSH (most chunks
/// don't) or when the position somehow lands outside the chunk's footprint. Coord math: the
/// chunk's NW corner is `chunk.positions[0]`; +X is north (south distance = `nw_x − wx`), +Y is
/// west (east distance = `nw_y − wy`); MCSH is row-major over the chunk's 0..1 UV with row=south,
/// col=east, 64×64 texels.
fn mcsh_tint_at(chunk: &ChunkMesh, world: [f32; 3]) -> f32 {
let Some(shadow) = chunk.shadow.as_ref() else {
return MCSH_LIT;
};
let nw_x = chunk.positions[0][0];
let nw_y = chunk.positions[0][1];
let south = (nw_x - world[0]) / benilla_formats::TILE_SIZE * 16.0; // 0..1 across the chunk
let east = (nw_y - world[1]) / benilla_formats::TILE_SIZE * 16.0;
if !(0.0..=1.0).contains(&south) || !(0.0..=1.0).contains(&east) {
return MCSH_LIT;
}
let n = SHADOW_MAP_SIZE as usize;
let row = ((south * n as f32) as usize).min(n - 1);
let col = ((east * n as f32) as usize).min(n - 1);
if shadow[row * n + col] >= 128 {
MCSH_SHADOWED
} else {
MCSH_LIT
}
}
/// Sample the chunk's MCNR terrain normal (WoW coords) nearest a world-space position — the **ground
/// normal under the tuft**. Lighting clutter by this (instead of a uniform world-up) makes a tuft
/// follow the terrain beneath it: darker on a slope facing away from the sun, like the dirt under it
/// — the per-position sun/shade variation the reference shows. Nearest outer-grid vertex (≈2 yd
/// spacing) is plenty; falls back to WoW-up (`+Z`) when the chunk has no MCNR.
fn terrain_normal_at(chunk: &ChunkMesh, world: [f32; 3]) -> [f32; 3] {
if chunk.normals.len() < 145 || chunk.positions.is_empty() {
return [0.0, 0.0, 1.0];
}
let nw_x = chunk.positions[0][0];
let nw_y = chunk.positions[0][1];
let south = ((nw_x - world[0]) / benilla_formats::TILE_SIZE * 16.0).clamp(0.0, 1.0);
let east = ((nw_y - world[1]) / benilla_formats::TILE_SIZE * 16.0).clamp(0.0, 1.0);
let row = (south * 8.0).round() as usize; // outer 9×9 grid in the stride-17 MCVT layout
let col = (east * 8.0).round() as usize;
chunk.normals[row * 17 + col]
}
/// A scattered placement plus its baked MCSH tint and ground normal — what the merge iterates over.
struct ShadedPlacement {
placement: GroundDoodadPlacement,
tint: f32,
/// Terrain (ground) normal under the tuft, **Bevy space** — baked onto every vertex so clutter is
/// lit by the ground it stands on (`wow_model.wgsl` ground-normal N·L). Same value for all of a
/// tuft's vertices ⇒ no per-face view-dependence. VERIFIED faithful: WoW writes the terrain
/// quadrant-plane normal onto the clutter vertex normal channel.
ground_normal: [f32; 3],
}
/// **Scatter** a tile's ground clutter into per-chunk [`ClutterChunk`] units — WITHOUT building any
/// meshes. Each chunk's placements are MCSH/normal-baked here (while the parent chunk is in scope; the
/// static per-vertex shading matches the real client, baked at map-compile time) and stored on a
/// `ClutterChunk` entity owned by the tile. [`stream_chunk_clutter`] builds the meshes lazily as chunks
/// come within the detail-doodad horizon. Empty chunks are skipped (no entity). Replaces the old
/// build-everything-at-tile-load path.
pub(crate) fn scatter_tile_clutter(
commands: &mut Commands,
chunks: &[ChunkMesh],
tile_x: u32,
tile_y: u32,
catalog: &GroundEffectCatalog,
density: f32,
entities: &mut Vec<Entity>,
) {
for chunk in chunks {
let mut by_model: HashMap<String, Vec<ShadedPlacement>> = HashMap::new();
for placement in scatter_ground_doodads(chunk, catalog, tile_x, tile_y, density) {
let tint = mcsh_tint_at(chunk, placement.position);
let ground_normal =
wow_to_bevy(terrain_normal_at(chunk, placement.position)).to_array();
by_model
.entry(placement.model.clone())
.or_default()
.push(ShadedPlacement {
placement,
tint,
ground_normal,
});
}
if by_model.is_empty() {
continue;
}
// The chunk's terrain AABB in Bevy space — what the LOD system measures against.
let mut lo = Vec3::splat(f32::MAX);
let mut hi = Vec3::splat(f32::MIN);
for p in &chunk.positions {
let v = wow_to_bevy(*p);
lo = lo.min(v);
hi = hi.max(v);
}
// Tufts stand ON the surface, so the drawn geometry reaches a little above the terrain box.
hi.y += CLUTTER_TUFT_HEIGHT;
entities.push(
commands
.spawn(ClutterChunk {
bounds: (lo, hi),
models: by_model.into_iter().collect(),
built: Vec::new(),
})
.id(),
);
}
}
/// **Lazily build** one chunk's clutter: merge every instance of each model (within this chunk) into one
/// mesh per submesh — baking each tuft's yaw/pos/scale + MCSH tint + ground normal into the vertices —
/// and spawn them as children of `chunk_entity` (so a tile unload cascades to them). Returns the spawned
/// entities (tracked on the `ClutterChunk` for distance teardown). Same merge as the old per-tile path,
/// now per-chunk so only the ~70 yd bubble is ever built/drawn.
#[allow(clippy::too_many_arguments)]
fn build_chunk_clutter(
chunk_entity: Entity,
models: &[(String, Vec<ShadedPlacement>)],
scale: f32,
alpha_ref: f32,
fade_far: f32,
geometry: &mut ClutterGeometry,
assets: &mut WorldAssets,
meshes: &mut Assets<Mesh>,
images: &mut Assets<Image>,
materials: &mut Assets<WowModelMaterial>,
commands: &mut Commands,
) -> Vec<Entity> {
let mut out = Vec::new();
for (model_path, placements) in models {
let subs = geometry.0.or_insert_with(model_path.clone(), || {
load_m2_mesh(&mut assets.chain.lock_recover(), model_path).unwrap_or_default()
});
if subs.is_empty() {
continue;
}
// One merged mesh per submesh (each submesh has its own texture/blend → material).
for sub in subs.iter() {
let vcount = sub.positions.len() * placements.len();
let mut positions = Vec::with_capacity(vcount);
let mut uvs = Vec::with_capacity(vcount);
// Clutter normals = the GROUND normal under each tuft (the terrain quadrant normal WoW
// bakes onto the clutter vertex channel — NOT the grass-blade M2 normal). Same value on
// every vertex of a tuft ⇒ no per-face view-dependence; the shader lights it like the
// terrain beneath.
let mut normals = Vec::with_capacity(vcount);
let mut colors = Vec::with_capacity(vcount);
let mut indices = Vec::with_capacity(sub.indices.len() * placements.len());
for sp in placements {
let d = &sp.placement;
let base = positions.len() as u32;
let origin = wow_to_bevy(d.position);
let rot = Quat::from_rotation_y(d.yaw);
// Per-instance scale (client's random [0.9,1.1]) × the debug-panel size multiplier.
let inst_scale = scale * d.scale;
// MCSH tint per placement — same value on every vertex of THIS instance, so all
// tris of one grass clump share the lit/shadowed colour. The shader multiplies
// `texture × vertex_color` in gamma space (the faithful MODULATE 1×).
let tint = [sp.tint, sp.tint, sp.tint, 1.0];
for (i, p) in sub.positions.iter().enumerate() {
positions.push((rot * (wow_to_bevy(*p) * inst_scale) + origin).to_array());
uvs.push(sub.uvs[i]);
normals.push(sp.ground_normal);
colors.push(tint);
}
indices.extend(sub.indices.iter().map(|idx| base + idx));
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
// Step 8: per-vertex MCSH tint (1.0 lit / ≈0.753 shadowed). Triggers Bevy's
// `VERTEX_COLORS` shader def → `pbr_input.material.base_color` becomes
// `vertex_color × texture`. Read by the clutter path in `wow_model.wgsl`.
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
mesh.insert_indices(Indices::U32(indices));
let mesh = meshes.add(mesh);
// **The detail-doodad pass owns its render state** — it is not the batch's (2004).
// The reference sets one state for every tuft in the frame (`0x6b2b80`: blend mode 2
// = SRC_ALPHA/INV_SRC_ALPHA, ALPHAREF = `detailDoodadAlpha` 128, cull OFF, depth-write
// ON) and its per-slot draw `0x6b2d60` binds nothing but the texture — the M2 batch's
// own blend/cull is never consulted for a detail doodad. Passing `sub.blend` through
// gave the two Opaque-authored detail models no cutout at all (their transparent
// margin drew as solid card) and left five single-sided ones showing only one face.
let material = assets.model_material(
sub.texture.as_deref(),
ModelBlend::AlphaTest, // the pass's cutout, not the batch's authored blend
true, // cull off for every tuft, like the reference's pass
Some(alpha_ref), // detail-doodad alpha-test ref (≈0.5), not the higher general key
Some(fade_far), // detail-doodad distance fade (≈70 yd horizon — opacity, dc30e1f)
false, // clutter is not WMO (keeps its ground-normal N·L path)
false, // not a doodad fade twin (clutter's own clutter_fade path handles it)
(sub.wrap_x, sub.wrap_y), // the M2 texture record's own address mode (0763)
images,
materials,
);
out.push(
commands
.spawn((Mesh3d(mesh), MeshMaterial3d(material), Transform::IDENTITY))
.id(),
);
}
}
if !out.is_empty() {
// Parent the freshly-built clutter to its chunk — but tolerate the chunk having been despawned
// before this command applies. A cross-map teleport (or any tile unload) despawns the tile,
// which cascades to its `ClutterChunk` children; a plain `commands.entity(..).add_children`
// then PANICS on the dead parent (the Ashenvale-teleport crash). Check at apply time, and if the
// parent is gone, despawn the orphan clutter we just spawned so it doesn't leak as parentless
// (world-baked) geometry.
let children = out.clone();
commands.queue(
move |world: &mut World| match world.get_entity_mut(chunk_entity) {
Ok(mut parent) => {
parent.add_children(&children);
}
Err(_) => {
for e in children {
if let Ok(child) = world.get_entity_mut(e) {
child.despawn();
}
}
}
},
);
}
out
}
/// Squared distance from a point to an axis-aligned box — zero inside it. The clutter LOD's measure:
/// "is any of this chunk within reach", which a centre distance can only approximate.
fn box_distance_squared(p: Vec3, (lo, hi): (Vec3, Vec3)) -> f32 {
(lo - p).max(p - hi).max(Vec3::ZERO).length_squared()
}
/// How far (as a multiple of the fade horizon) a chunk can sit from the eye and still have grass the
/// shader will draw.
///
/// The fade is keyed on **view-space depth** (`wow_model.wgsl`, the reference's camera-space texgen),
/// so the horizon is a plane across the view, not a sphere around the eye: a fragment out at the
/// frustum's corner is `1/cos θ` further in straight-line distance than one dead ahead at the same
/// depth. The LOD gate measures straight-line distance — it must not depend on where the camera
/// happens to be pointing, or a turn would drop and rebuild chunks that are still being drawn — so it
/// takes the worst case, the corner ray, and that is exactly this factor. Derived from the live
/// projection rather than pinned, because it moves with the aspect ratio: ~1.31 at 16:9 and ~1.45 at
/// 21:9, and a constant tuned on one monitor silently clips the screen edges on a wider one.
fn frustum_corner_reach(fov_y: f32, aspect: f32) -> f32 {
let tan_v = (fov_y * 0.5).tan();
let tan_h = tan_v * aspect;
(1.0 + tan_v * tan_v + tan_h * tan_h).sqrt()
}
/// The per-chunk clutter LOD: build a chunk's clutter when its BOX comes within reach of the
/// detail-doodad horizon and tear it down when it leaves — the reference's `CDetailDoodad` lifecycle
/// (per-chunk build/unlink at the 70 yd `[0x867958]`, which it measures as the nearest view-space
/// depth of the chunk's bounding sphere and then never frees). Bounds live grass to a bubble around
/// the player instead of every loaded tile and, with the per-frame build cap, spreads the cost so a
/// tile-load no longer builds 256 chunks at once. Build/teardown happen where the fade ramp is
/// already alpha 0, so they are invisible.
#[allow(clippy::too_many_arguments)]
pub(crate) fn stream_chunk_clutter(
mut commands: Commands,
cam: Query<(&GlobalTransform, Option<&Projection>), With<WorldCamera>>,
mut chunks: Query<(Entity, &mut ClutterChunk)>,
cfg: Res<ClutterConfig>,
geometry: Option<ResMut<ClutterGeometry>>,
assets: Option<ResMut<WorldAssets>>,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
) {
// Both come from `setup_clutter`, which inserts neither when there is no client data — and a
// hard `ResMut` here is a *validation* failure, not a `None` the body can handle: the system
// never runs, Bevy's default error handler panics, and a client that found no install died on
// its first frame instead of sitting at the login screen (decision 1451).
let (Some(mut geometry), Some(mut assets)) = (geometry, assets) else {
return;
};
let Some((cam_tf, projection)) = cam.iter().next() else {
return;
};
let cam_pos = cam_tf.translation();
// The straight-line reach the view-depth fade implies at the frustum's corner (see the helper).
// A missing/non-perspective projection falls back to Bevy's own perspective default, the one
// `view.rs` leaves in place (≈ the reference's 44.1° vertical) at 16:9.
let reach = cfg.fade_far
* match projection {
Some(Projection::Perspective(p)) => frustum_corner_reach(p.fov, p.aspect_ratio),
_ => {
let d = PerspectiveProjection::default();
frustum_corner_reach(d.fov, d.aspect_ratio)
}
};
let build_d2 = (reach + CLUTTER_BUILD_MARGIN).powi(2);
let drop_d2 = (reach + CLUTTER_BUILD_MARGIN + CLUTTER_TEARDOWN_HYSTERESIS).powi(2);
let mut budget = CLUTTER_BUILDS_PER_FRAME;
for (ent, mut cc) in &mut chunks {
let d2 = box_distance_squared(cam_pos, cc.bounds);
if cc.built.is_empty() {
if d2 <= build_d2 && budget > 0 {
budget -= 1;
let built = build_chunk_clutter(
ent,
&cc.models,
cfg.scale,
cfg.alpha_ref,
cfg.fade_far,
&mut geometry,
&mut assets,
&mut meshes,
&mut images,
&mut materials,
&mut commands,
);
cc.built = built;
}
} else if d2 > drop_d2 {
for e in cc.built.drain(..) {
commands.entity(e).try_despawn();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The ramp `wow_model.wgsl` evaluates, mirrored here so the reference's four landmarks are
/// **checkable** rather than a comment. Not a second implementation to keep in sync — a pin: if
/// anyone retunes `0.75`, the `254`/`256` texel-centre constants or the `252/255` cap without
/// re-deriving them, these numbers move and the test says so.
fn ramp(z: f32, far: f32) -> f32 {
let near = far * 0.75;
let u = (z - near) / (far - near);
((254.0 - 256.0 * u) / 255.0).clamp(0.0, 252.0 / 255.0)
}
/// wow-re `terrain/scratch/detail-doodad-distance-fade.md`: a 64-texel CLAMP/LINEAR ramp whose
/// texel centres give `alpha = (254 − 256u)/255`, capped at texel 0's `252/255`. So the plateau
/// runs to 52.63672 yd (not 52.5), the ramp hits zero at 69.86328 yd (not 70), the slope is
/// −0.0573670 per yard, and the 128/255 detail-doodad cutout erases a fully-opaque texel at
/// 61.11328 yd — the horizon grass actually vanishes at, well short of the 70 yd draw distance.
#[test]
fn the_ramp_hits_the_reference_landmarks() {
assert!(
(ramp(0.0, 70.0) - 252.0 / 255.0).abs() < 1e-6,
"near plateau"
);
assert!(
(ramp(52.63672, 70.0) - 252.0 / 255.0).abs() < 1e-5,
"plateau ends at 52.63672, not 52.5"
);
assert!(
ramp(52.7, 70.0) < 252.0 / 255.0,
"past the plateau it falls"
);
assert!(ramp(69.86328, 70.0) < 1e-5, "zero at 69.86328, not 70");
assert_eq!(ramp(70.0, 70.0), 0.0);
let slope = ramp(60.0, 70.0) - ramp(61.0, 70.0);
assert!((slope - 0.0573670).abs() < 1e-6, "slope per yard");
// The cutout erases a fully-opaque texel where the ramp crosses the 128/255 reference.
assert!(ramp(61.11328, 70.0) < DETAIL_DOODAD_ALPHA_REF + 1e-5);
assert!(ramp(61.11, 70.0) > DETAIL_DOODAD_ALPHA_REF);
}
/// The gate measures the chunk's box, not its centre: zero inside, and the nearest point outside.
#[test]
fn box_distance_measures_the_nearest_point() {
let b = (Vec3::new(-1.0, -1.0, -1.0), Vec3::new(1.0, 1.0, 1.0));
assert_eq!(box_distance_squared(Vec3::ZERO, b), 0.0);
assert_eq!(box_distance_squared(Vec3::new(4.0, 0.0, 0.0), b), 9.0);
// A tall thin chunk: a point level with the top face is 3 yd away, though its CENTRE — the
// old measure — is 5 yd away. That gap is the relief case that popped whole chunks.
let tall = (Vec3::new(-1.0, -4.0, -1.0), Vec3::new(1.0, 4.0, 1.0));
assert_eq!(box_distance_squared(Vec3::new(4.0, 4.0, 0.0), tall), 9.0);
}
/// The fade is keyed on view depth, so the LOD's straight-line reach is the corner ray's — and it
/// widens with the aspect ratio, which is why it is derived and not pinned to one monitor.
#[test]
fn the_corner_reach_widens_with_the_aspect() {
let fov = PerspectiveProjection::default().fov;
let wide = frustum_corner_reach(fov, 16.0 / 9.0);
let ultrawide = frustum_corner_reach(fov, 21.0 / 9.0);
assert!((wide - 1.309).abs() < 0.01, "16:9 reach was {wide}");
assert!(
(ultrawide - 1.451).abs() < 0.01,
"21:9 reach was {ultrawide}"
);
assert!(ultrawide > wide);
// Dead ahead is the degenerate case: no width, no extra reach.
assert!((frustum_corner_reach(0.0, 0.0) - 1.0).abs() < 1e-6);
}
}