This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
605 lines
25 KiB
Rust
605 lines
25 KiB
Rust
//! Ground clutter (`GroundEffect*.dbc`): the terrain streamer scatters each chunk's tufts at tile
|
||
//! load, and a chunk's meshes exist only inside the 70 yd detail-doodad horizon (`[0x867958]`),
|
||
//! the reference's per-chunk `CDetailDoodadInst` build and unlink.
|
||
//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module
|
||
//! `wxl-experimental-wind`, `grass/GrassWind.cpp` (bend-weight/phase technique).
|
||
|
||
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,
|
||
};
|
||
|
||
/// Loads the ground-effect catalog at startup and runs the per-chunk build and teardown.
|
||
pub(crate) struct ClutterPlugin;
|
||
|
||
impl Plugin for ClutterPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
// Inserted at build, not in `setup_clutter`: `benilla-app`'s Startup CVar loader applies a
|
||
// saved `WorldDetail` onto it.
|
||
app.init_resource::<ClutterConfig>()
|
||
.add_systems(Startup, setup_clutter.after(AssetSet::Open))
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
remesh_on_cutout_change,
|
||
stream_chunk_clutter,
|
||
evict_clutter_geometry,
|
||
scope_clutter_geometry,
|
||
)
|
||
.chain(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Drop the built meshes when the cutout value moves (a density write must not re-mesh): the ref is
|
||
/// baked into the material. The reference's `detailDoodadAlpha` is a console command (`0x6739a0`,
|
||
/// registered at `0x63f9e0`), not a CVar, so it never persists.
|
||
fn remesh_on_cutout_change(
|
||
mut commands: Commands,
|
||
cfg: Res<ClutterConfig>,
|
||
mut chunks: Query<&mut ClutterChunk>,
|
||
mut last: Local<Option<f32>>,
|
||
) {
|
||
let Some(prev) = last.replace(cfg.alpha_ref) else {
|
||
return;
|
||
};
|
||
if prev == cfg.alpha_ref {
|
||
return;
|
||
}
|
||
let mut n = 0;
|
||
for mut cc in &mut chunks {
|
||
for e in cc.built.drain(..) {
|
||
commands.entity(e).try_despawn();
|
||
n += 1;
|
||
}
|
||
}
|
||
info!(
|
||
"clutter: detailDoodadAlpha {} — dropped {n} built mesh(es) to re-cut",
|
||
(cfg.alpha_ref * 255.0).round() as u32
|
||
);
|
||
}
|
||
|
||
/// Drop the decoded clutter geometry on a map change; the new map decodes its own models.
|
||
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 within a map.
|
||
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 catalog and insert the geometry cache.
|
||
fn setup_clutter(mut commands: Commands, world_assets: Option<ResMut<WorldAssets>>) {
|
||
let Some(world_assets) = world_assets else {
|
||
return;
|
||
};
|
||
commands.insert_resource(ClutterGeometry::default());
|
||
let mut chain = world_assets.chain.lock_recover();
|
||
// `true`: a `doodadId` names a `GroundEffectDoodad.internalId`, as the reference keys it.
|
||
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:#}"),
|
||
}
|
||
}
|
||
|
||
/// The `effectId → (models, density)` catalog; absent, and no clutter, when the DBCs fail to load.
|
||
#[derive(Resource)]
|
||
pub(crate) struct GroundClutter {
|
||
pub(crate) catalog: GroundEffectCatalog,
|
||
}
|
||
|
||
/// The detail-doodad alpha-test ref, `detailDoodadAlpha` = 128/255, lower than the model key.
|
||
pub(crate) const DETAIL_DOODAD_ALPHA_REF: f32 = 128.0 / 255.0;
|
||
|
||
/// The reference's hardcoded detail-doodad draw distance, 70 yd (`[0x867958]`); clutter fades out
|
||
/// over its last quarter.
|
||
pub(crate) const DETAIL_DOODAD_FADE_FAR: f32 = 70.0;
|
||
|
||
/// Ground-clutter tunables. `density` scales the reference's 16 cells per chunk (`frillDensity`),
|
||
/// and a change re-scatters loaded tiles, as the 1.12 setter rebuilds its chunks. Seeded from
|
||
/// `$WOW_CLUTTER_DENSITY`, `$WOW_CLUTTER_ALPHA` and `$WOW_CLUTTER_FADE`; density 0 is no clutter.
|
||
#[derive(Resource, Clone, Copy)]
|
||
pub struct ClutterConfig {
|
||
pub density: f32,
|
||
pub alpha_ref: f32,
|
||
pub fade_far: f32,
|
||
}
|
||
|
||
impl ClutterConfig {
|
||
/// The ground cover as the reference's `frillDensity`, cells visited per chunk; the
|
||
/// `WorldDetail` slider stops are 16/32/48 (`SetWorldDetail`, `0x488dd0`).
|
||
pub fn frill_density(&self) -> f32 {
|
||
self.density * benilla_formats::FRILL_DENSITY as f32
|
||
}
|
||
|
||
/// Set the ground cover from a `frillDensity`, clamped to `[1, 256]` as the reference's change
|
||
/// callback `0x688de0` does, so zero is unreachable here.
|
||
pub fn set_frill_density(&mut self, frill: f32) {
|
||
self.density = frill.clamp(1.0, benilla_formats::FRILL_DENSITY_MAX as f32)
|
||
/ benilla_formats::FRILL_DENSITY as 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 {
|
||
// Deviation: ×2, `frillDensity` 32 (the panel's Medium), because a fresh install's
|
||
// `hwDetect` value (24 from `VideoHardware.dbc`, 8 on the weakest parts) is on no panel
|
||
// stop, and Medium is the nearest stop no sparser.
|
||
density: env("WOW_CLUTTER_DENSITY").unwrap_or(2.0).max(0.0),
|
||
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 submeshes by path: each detail M2 is decoded once, then merged per chunk.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct ClutterGeometry(
|
||
pub(crate) benilla_assets::SpatialCache<String, Vec<RenderSubmesh>>,
|
||
);
|
||
|
||
/// One MCNK chunk's scattered clutter, meshed only while inside the detail-doodad horizon.
|
||
#[derive(Component)]
|
||
pub(crate) struct ClutterChunk {
|
||
/// The chunk's terrain box grown by a tuft's height, in Bevy space. The gate measures to its
|
||
/// nearest point, not its centre, so a chunk on relief is not built late.
|
||
bounds: (Vec3, Vec3),
|
||
/// Placements by model path; each model merges into one mesh per submesh.
|
||
models: Vec<(String, Vec<ShadedPlacement>)>,
|
||
/// The built meshes, children of this entity; empty when not built.
|
||
built: Vec<Entity>,
|
||
}
|
||
|
||
/// Slack (yd) past the fade reach, so a chunk is built before any of its grass can show.
|
||
const CLUTTER_BUILD_MARGIN: f32 = 8.0;
|
||
|
||
/// Hysteresis (yd) between build and teardown, so a chunk at the boundary does not thrash.
|
||
const CLUTTER_TEARDOWN_HYSTERESIS: f32 = 6.0;
|
||
|
||
/// How far a tuft reaches above the terrain (yd); the chunk's box is grown by it.
|
||
const CLUTTER_TUFT_HEIGHT: f32 = 3.0;
|
||
|
||
/// Most chunks built per frame, so an arrival in a dense area spreads over a few frames.
|
||
const CLUTTER_BUILDS_PER_FRAME: usize = 8;
|
||
|
||
/// The reference's per-tuft detail-doodad diffuse, picked from the chunk's MCSH map at the
|
||
/// placement: `0xFFFFFFFF` lit, `0xFFC0C0C0` shadowed. Baked into the vertex colour.
|
||
const MCSH_LIT: f32 = 1.0;
|
||
const MCSH_SHADOWED: f32 = 192.0 / 255.0; // 0xC0 / 0xFF
|
||
|
||
/// The MCSH tint at a world position; lit when the chunk has no MCSH or the point falls outside.
|
||
/// MCSH is 64×64, row-major, rows running south and columns east from the chunk's NW corner.
|
||
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
|
||
}
|
||
}
|
||
|
||
/// The MCNR normal (WoW space) at the outer-grid vertex nearest a position, the ground under a
|
||
/// tuft; `+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 with its baked MCSH tint and ground normal.
|
||
struct ShadedPlacement {
|
||
placement: GroundDoodadPlacement,
|
||
tint: f32,
|
||
/// The ground normal under the tuft, in Bevy space, on every vertex: here the MCNR normal of
|
||
/// the nearest outer-grid vertex ([`terrain_normal_at`]). The reference uses the plane of the
|
||
/// cell's centre-fan triangle the tuft lands in (planes built at `0x6bfec1`–`0x6bfefe`, the
|
||
/// triangle picked at `0x6c0148`–`0x6c0193`, the normal stored at `0x6b2a87`–`0x6b2a9c`).
|
||
ground_normal: [f32; 3],
|
||
}
|
||
|
||
/// Scatter a tile's clutter into [`ClutterChunk`]s, tint and normal baked, building no meshes.
|
||
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;
|
||
}
|
||
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);
|
||
}
|
||
hi.y += CLUTTER_TUFT_HEIGHT;
|
||
entities.push(
|
||
commands
|
||
.spawn(ClutterChunk {
|
||
bounds: (lo, hi),
|
||
models: by_model.into_iter().collect(),
|
||
built: Vec::new(),
|
||
})
|
||
.id(),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Build one chunk's clutter: each model's instances merged into one mesh per submesh, every tuft's
|
||
/// transform, tint and ground normal baked in, spawned as children of `chunk_entity`.
|
||
fn build_chunk_clutter(
|
||
chunk_entity: Entity,
|
||
models: &[(String, Vec<ShadedPlacement>)],
|
||
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;
|
||
}
|
||
// MONKEY (wind): source M2 Z is up. Normalize the actual tuft geometry rather than
|
||
// trusting texture V (WXL's 3.3.5 detail doodads use inverse V, but vanilla assets are
|
||
// not guaranteed to). UV_1 stays free here and carries height + per-tuft phase.
|
||
// MONKEY (fix-wind): one span over ALL of the model's submeshes, so a trunk + canopy pair
|
||
// bends as one body instead of tearing apart at the seam.
|
||
let (min_z, max_z) = subs
|
||
.iter()
|
||
.flat_map(|sub| sub.positions.iter())
|
||
.fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
|
||
(lo.min(p[2]), hi.max(p[2]))
|
||
});
|
||
let height_span = (max_z - min_z).max(1.0e-4);
|
||
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);
|
||
let mut wind = Vec::with_capacity(vcount);
|
||
// The ground normal under each tuft, never the blade's own M2 normal.
|
||
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);
|
||
// The reference's random per-tuft scale, in [0.9, 1.1].
|
||
let inst_scale = d.scale;
|
||
let phase = tuft_phase(origin);
|
||
// One tint per tuft. The reference makes the grey the lit material (colour material
|
||
// on ambient and diffuse, `0x59cfec`), `clamp(grey × light) × texture`; the shader
|
||
// multiplies it outside the clamp, `texture × grey × clamp(light)`, so under light
|
||
// above 1.0 a shadowed tuft comes out darker than the reference's.
|
||
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]);
|
||
wind.push([((p[2] - min_z) / height_span).clamp(0.0, 1.0), phase]);
|
||
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);
|
||
// MONKEY (wind): second UV set enables VERTEX_UVS_B only for clutter meshes.
|
||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, wind);
|
||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||
// The MCSH tint, under the `VERTEX_COLORS` def the shader's clutter path reads.
|
||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
|
||
mesh.insert_indices(Indices::U32(indices));
|
||
let mesh = meshes.add(mesh);
|
||
// The detail-doodad pass owns the render state, never the batch: the reference sets one
|
||
// for every tuft (`0x6b2b80`: alpha blend, ALPHAREF `detailDoodadAlpha`, cull off,
|
||
// depth write on), and its per-slot draw `0x6b2d60` binds only the texture.
|
||
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), // the detail-doodad alpha-test ref, not the general key
|
||
Some(fade_far), // the detail-doodad distance fade
|
||
false, // not WMO: keeps the ground-normal N·L path
|
||
false, // not a doodad fade twin: clutter fades on its own path
|
||
(sub.wrap_x, sub.wrap_y), // the M2 texture record's own address mode
|
||
images,
|
||
materials,
|
||
);
|
||
out.push(
|
||
commands
|
||
.spawn((Mesh3d(mesh), MeshMaterial3d(material), Transform::IDENTITY))
|
||
.id(),
|
||
);
|
||
}
|
||
}
|
||
if !out.is_empty() {
|
||
// Parent at apply time: a tile unload in between despawns the chunk, and `add_children`
|
||
// on a dead parent panics, so the orphans are despawned instead.
|
||
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
|
||
}
|
||
|
||
/// MONKEY (wind): deterministic per-tuft phase from its world-space origin. This is an identity
|
||
/// hash, not state; rebuild order and frame timing cannot change the blade's motion.
|
||
fn tuft_phase(origin: Vec3) -> f32 {
|
||
let h = (origin.x * 0.737 + origin.z * 1.311).sin() * 43_758.547;
|
||
h.fract().abs() * std::f32::consts::TAU
|
||
}
|
||
|
||
/// Squared distance from a point to an axis-aligned box, zero inside.
|
||
fn box_distance_squared(p: Vec3, (lo, hi): (Vec3, Vec3)) -> f32 {
|
||
(lo - p).max(p - hi).max(Vec3::ZERO).length_squared()
|
||
}
|
||
|
||
/// The fade horizon's reach along the frustum's corner ray, as a multiple of it: the fade is on
|
||
/// view depth (the reference's camera-space texgen), the gate on a distance that ignores heading.
|
||
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()
|
||
}
|
||
|
||
/// Build a chunk's clutter when its box comes within the fade reach, and tear it down past it,
|
||
/// where the fade is already zero. The reference builds and unlinks per chunk at 70 yd
|
||
/// (`[0x867958]`), by the nearest view depth of the chunk's bounding sphere.
|
||
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 are absent without an install, and a hard `ResMut` would fail validation and panic.
|
||
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();
|
||
// Without a perspective projection, Bevy's default stands in: the one `view.rs` keeps, about
|
||
// 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);
|
||
// The late-build tripwire: a chunk built already inside the horizon pops in where it is seen.
|
||
let visible_d2 = cfg.fade_far.powi(2);
|
||
|
||
// Pass 1: tear down what has left; collect the rest nearest first, for the per-frame cap.
|
||
let mut wanted: Vec<(f32, Entity)> = Vec::new();
|
||
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 {
|
||
wanted.push((d2, ent));
|
||
}
|
||
} else if d2 > drop_d2 {
|
||
for e in cc.built.drain(..) {
|
||
commands.entity(e).try_despawn();
|
||
}
|
||
}
|
||
}
|
||
wanted.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
let backlog = wanted.len().saturating_sub(CLUTTER_BUILDS_PER_FRAME);
|
||
|
||
// Pass 2: spend the frame's budget on the nearest of them.
|
||
let (mut late, mut late_nearest) = (0usize, f32::MAX);
|
||
for &(d2, ent) in wanted.iter().take(CLUTTER_BUILDS_PER_FRAME) {
|
||
let Ok((_, mut cc)) = chunks.get_mut(ent) else {
|
||
continue;
|
||
};
|
||
if d2 < visible_d2 {
|
||
late += 1;
|
||
late_nearest = late_nearest.min(d2.sqrt());
|
||
}
|
||
let built = build_chunk_clutter(
|
||
ent,
|
||
&cc.models,
|
||
cfg.alpha_ref,
|
||
cfg.fade_far,
|
||
&mut geometry,
|
||
&mut assets,
|
||
&mut meshes,
|
||
&mut images,
|
||
&mut materials,
|
||
&mut commands,
|
||
);
|
||
cc.built = built;
|
||
}
|
||
// With a backlog the cap made it late, the expected burst behind a login or teleport's loading
|
||
// cover; with none the distance gate let a visible chunk through late, a defect.
|
||
if late > 0 {
|
||
if backlog > 0 {
|
||
debug!(
|
||
"clutter: {late} chunk(s) built inside the {:.0} yd horizon (nearest \
|
||
{late_nearest:.1} yd) — {backlog} more queued behind the \
|
||
{CLUTTER_BUILDS_PER_FRAME}/frame cap (the expected login/teleport burst)",
|
||
cfg.fade_far,
|
||
);
|
||
} else {
|
||
warn!(
|
||
"clutter: {late} chunk(s) built INSIDE the {:.0} yd horizon (nearest \
|
||
{late_nearest:.1} yd) — grass appeared where it could already be seen, and with \
|
||
no build backlog, so the DISTANCE GATE let it through late",
|
||
cfg.fade_far,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The fade ramp `wow_model.wgsl` evaluates, mirrored as a pin on its constants.
|
||
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)
|
||
}
|
||
|
||
/// The reference's 64-texel clamp/linear ramp (`0x6b235b` fills it, `[0x867958]` = 70 anchors
|
||
/// it): `alpha = (254 − 256u)/255` capped at `252/255`, so the plateau ends at 52.63672 yd,
|
||
/// zero falls at 69.86328 yd, and the 128/255 cutout erases opaque grass at 61.11328 yd.
|
||
#[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");
|
||
assert!(ramp(61.11328, 70.0) < DETAIL_DOODAD_ALPHA_REF + 1e-5);
|
||
assert!(ramp(61.11, 70.0) > DETAIL_DOODAD_ALPHA_REF);
|
||
}
|
||
|
||
#[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 chunk: the point is 3 yd from its top face but 5 yd from its centre.
|
||
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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|