stealth dims its sparkles, and gear changes stop rebuilding you
- a stealthed unit's particles and ribbons now dim with the body, and a depth-prime pass stops overlapping translucent parts from darkening each other - changing gear re-dresses the character in place instead of tearing the whole model down and rebuilding it - terrain tiles furnish cell-by-cell and model render forms build paced across frames, so first contact with fresh ground no longer hitches - Ghost Wolf renders properly ghostly, and a live toggle swaps its action-bar icon to the active form while it holds Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,22 @@
|
||||
//! ADT → terrain-tile asset loader.
|
||||
//!
|
||||
//! Decodes one vanilla ADT tile into an [`AdtTile`]: **one splat-blended `Mesh` per MCNK chunk**
|
||||
//! (Bevy space, absolute world coords) plus its three `texture_2d_array`s — ground layers, per-chunk
|
||||
//! alpha (blend) maps, per-chunk MCSH shadow maps — and the raw doodad/WMO placement lists (for the
|
||||
//! spawn system to load as `M2Model`/`WmoModel`). Each chunk mesh carries its array indices on every
|
||||
//! one of its vertices — `COLOR` (4 layer indices) and `UV1` (alpha index `.x`, shadow index `.y`,
|
||||
//! `-1` = none) — which is why all 256 chunks of a tile still share **one** material; the app's
|
||||
//! terrain material reads them.
|
||||
//! Decodes one vanilla ADT tile into an [`AdtTile`]: the decoded per-MCNK chunks + a
|
||||
//! [`ChunkShading`] per chunk (the splat-array indices resolved while packing), plus the tile's
|
||||
//! three `texture_2d_array`s — ground layers, per-chunk alpha (blend) maps, per-chunk MCSH shadow
|
||||
//! maps — and the raw doodad/WMO placement lists (for the spawn system to load as
|
||||
//! `M2Model`/`WmoModel`).
|
||||
//!
|
||||
//! **The loader ships no meshes.** A tile's ~256 chunk meshes used to be labeled sub-assets built
|
||||
//! here — which made a tile's landing atomic: the whole set hit the render world's
|
||||
//! extract/prepare in ONE frame (~80–105 ms of process CPU per fresh row, measured — the B181
|
||||
//! first-contact spike), and no app-side budget downstream of the load could spread work the
|
||||
//! loader had already packaged. The app builds each cell's mesh itself via [`chunk_to_mesh`], a
|
||||
//! few cells per frame (`terrain_stream::furnish`), so the render-world cost of a landing is
|
||||
//! paced at the only place pacing is possible: mesh-asset creation.
|
||||
//!
|
||||
//! Each chunk mesh carries its array indices on every one of its vertices — `COLOR` (4 layer
|
||||
//! indices) and `UV1` (alpha index `.x`, shadow index `.y`, `-1` = none) — which is why all 256
|
||||
//! chunks of a tile still share **one** material; the app's terrain material reads them.
|
||||
//!
|
||||
//! Layer textures are read via [`LoadContext::read_asset_bytes`] (raw bytes, dependency-tracked) and
|
||||
//! packed by [`crate::terrain`] — the BLP's authored mips verbatim (the C2 fidelity invariant).
|
||||
@@ -31,20 +41,16 @@ use crate::terrain::{
|
||||
LayerTexture, LAYER_TEX_SIZE,
|
||||
};
|
||||
|
||||
/// A loaded ADT terrain tile: one splat mesh per MCNK chunk + its three texture arrays, plus the raw
|
||||
/// doodad/WMO placements (which the spawn system resolves to `M2Model`/`WmoModel` handles).
|
||||
/// A loaded ADT terrain tile: the decoded chunks + per-chunk shading indices + the tile's three
|
||||
/// texture arrays, plus the raw doodad/WMO placements (which the spawn system resolves to
|
||||
/// `M2Model`/`WmoModel` handles). Cell meshes are built by the app, per chunk, via
|
||||
/// [`chunk_to_mesh`] — see the module doc for why the loader ships none.
|
||||
#[derive(Asset, TypePath)]
|
||||
pub struct AdtTile {
|
||||
/// One terrain mesh per drawn MCNK chunk (Bevy space, absolute world coords) — the tile's 256
|
||||
/// cells, less any that a hole mask emptied. Layer indices ride vertex `COLOR`; the alpha and
|
||||
/// shadow array indices ride `UV1.x` / `UV1.y`, uniform across a chunk's own vertices.
|
||||
///
|
||||
/// **Per chunk, not per tile, because the cull's unit is the chunk** (decision 0780): the
|
||||
/// exterior-scene cull tests one AABB per drawn object, and the reference's object there is the
|
||||
/// 33.333 yd cell (`0x683bf0`), never the 533 yd tile — a slab the camera stands on intersects
|
||||
/// every portal window, so it could never be hidden from inside a building. Nothing is duplicated
|
||||
/// by the split: the merged form never shared a vertex across a chunk boundary either.
|
||||
pub chunk_meshes: Vec<Handle<Mesh>>,
|
||||
/// The splat-array indices for each entry of `chunks` (index-parallel; a hole-emptied chunk
|
||||
/// carries the default, which [`chunk_to_mesh`] never reads because it returns `None` first).
|
||||
/// Resolved here because only the loader knows the arrays' layout — it packs them.
|
||||
pub shading: Vec<ChunkShading>,
|
||||
/// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains).
|
||||
pub layer_array: Handle<Image>,
|
||||
/// `texture_2d_array` of per-chunk alpha (blend-weight) maps.
|
||||
@@ -55,15 +61,91 @@ pub struct AdtTile {
|
||||
pub doodads: Vec<Doodad>,
|
||||
/// WMO building placements (raw WoW coords).
|
||||
pub wmos: Vec<WmoInstance>,
|
||||
/// The tile's decoded per-MCNK chunks — the source the `chunk_meshes` + arrays were built from,
|
||||
/// kept resident so the app can derive what those forms drop: the terrain collider, the MCLQ
|
||||
/// liquid surfaces (each
|
||||
/// The tile's decoded per-MCNK chunks — the source the arrays were built from and the cell
|
||||
/// meshes are built from ([`chunk_to_mesh`]), kept resident so the app can derive everything
|
||||
/// else it owes the tile: the terrain collider, the MCLQ liquid surfaces (each
|
||||
/// `ChunkMesh.liquid`) and the ground-clutter scatter (per-chunk layers/normals/MCSH + the
|
||||
/// app-side ground-effect catalog). Bounded by the loaded-tile count; the heaviest field on the
|
||||
/// asset, carried because clutter/liquid are app concerns (the loader stays catalog-independent).
|
||||
pub chunks: Vec<ChunkMesh>,
|
||||
}
|
||||
|
||||
/// One drawn chunk's resolved splat indices — everything [`chunk_to_mesh`] needs beyond the
|
||||
/// decoded [`ChunkMesh`] itself. Uniform across the chunk's own vertices by construction.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ChunkShading {
|
||||
/// The chunk's up-to-4 layer-array indices (vertex `COLOR`; unused slots repeat slot 0, whose
|
||||
/// alpha weight is 0, so they never show).
|
||||
pub layers: [u32; 4],
|
||||
/// The chunk's alpha-array index (vertex `UV1.x`; 0 — a weight-0 slot — when it has no map).
|
||||
pub alpha: u32,
|
||||
/// The chunk's shadow-array index (vertex `UV1.y`; `-1.0` = no MCSH shadow map).
|
||||
pub shadow: f32,
|
||||
}
|
||||
|
||||
impl Default for ChunkShading {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layers: [0; 4],
|
||||
alpha: 0,
|
||||
shadow: -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one drawn MCNK chunk's render mesh (Bevy space, absolute world coords) — called by the
|
||||
/// app a few cells per frame, never by the loader (see the module doc: a loader-built set lands
|
||||
/// atomically, and the landing IS the B181 first-contact spike).
|
||||
///
|
||||
/// **Per chunk, not per tile, because the cull's unit is the chunk** (decision 0780): the
|
||||
/// exterior-scene cull tests one AABB per drawn object, and the reference's object there is the
|
||||
/// 33.333 yd cell (`0x683bf0`), never the 533 yd tile — a slab the camera stands on intersects
|
||||
/// every portal window, so it could never be hidden from inside a building. Nothing is duplicated
|
||||
/// by the split: the merged form never shared a vertex across a chunk boundary either.
|
||||
///
|
||||
/// `RenderAssetUsages::RENDER_WORLD`: the render world takes the buffers on extract — no clone on
|
||||
/// landing, no resident main-world copy per cell. Nothing reads the *mesh* main-side: the
|
||||
/// collider, liquid, and clutter all derive from the decoded [`AdtTile::chunks`]. The one thing
|
||||
/// the main world does need from it — the cell's `Aabb`, which the exterior cull FAILS OPEN
|
||||
/// without — the caller takes via `Mesh::compute_aabb` before handing the mesh over.
|
||||
///
|
||||
/// Returns `None` for a chunk the hole mask emptied (it draws nothing: no mesh, no entity).
|
||||
pub fn chunk_to_mesh(chunk: &ChunkMesh, shading: &ChunkShading) -> Option<Mesh> {
|
||||
if chunk.indices.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let positions: Vec<[f32; 3]> = chunk
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| wow_to_bevy(*p).to_array())
|
||||
.collect();
|
||||
// Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat.
|
||||
let normals: Vec<[f32; 3]> = if chunk.normals.len() == chunk.positions.len() {
|
||||
chunk
|
||||
.normals
|
||||
.iter()
|
||||
.map(|n| wow_to_bevy(*n).to_array())
|
||||
.collect()
|
||||
} else {
|
||||
computed_normals(&positions, &chunk.indices)
|
||||
};
|
||||
let li = shading.layers;
|
||||
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
|
||||
let uv1 = [shading.alpha as f32, shading.shadow];
|
||||
let n = positions.len();
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, chunk.uvs.clone());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![uv1; n]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![col; n]);
|
||||
mesh.insert_indices(Indices::U32(chunk.indices.clone()));
|
||||
Some(mesh)
|
||||
}
|
||||
|
||||
/// Bevy [`AssetLoader`] decoding a vanilla `*.adt` tile → [`AdtTile`].
|
||||
#[derive(Default, TypePath)]
|
||||
pub struct AdtLoader;
|
||||
@@ -96,32 +178,19 @@ impl AssetLoader for AdtLoader {
|
||||
let mut shadow_buf: Vec<u8> = Vec::new();
|
||||
let mut shadow_count = 0u32;
|
||||
|
||||
// One mesh per drawn chunk. COLOR carries the chunk's 4 layer indices, UV1 its alpha/shadow —
|
||||
// uniform over the chunk's own vertices, which is what keeps ONE material per tile.
|
||||
let mut chunk_meshes: Vec<Handle<Mesh>> = Vec::with_capacity(tile.chunks.len());
|
||||
// One shading entry per chunk (holes get the default). COLOR carries the chunk's 4 layer
|
||||
// indices, UV1 its alpha/shadow — uniform over the chunk's own vertices, which is what
|
||||
// keeps ONE material per tile. The MESH built from these lands later, app-side and paced
|
||||
// (`chunk_to_mesh`); only the array-index resolution belongs here, with the arrays.
|
||||
let mut shading: Vec<ChunkShading> = Vec::with_capacity(tile.chunks.len());
|
||||
|
||||
for (i, chunk) in tile.chunks.iter().enumerate() {
|
||||
for chunk in tile.chunks.iter() {
|
||||
// A chunk the hole mask emptied draws nothing: no mesh, no entity, and no alpha/shadow
|
||||
// array slot either (the indices below are running counters, so skipping here is free).
|
||||
if chunk.indices.len() < 3 {
|
||||
shading.push(ChunkShading::default());
|
||||
continue;
|
||||
}
|
||||
let positions: Vec<[f32; 3]> = chunk
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| wow_to_bevy(*p).to_array())
|
||||
.collect();
|
||||
// Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat.
|
||||
let normals: Vec<[f32; 3]> = if chunk.normals.len() == chunk.positions.len() {
|
||||
chunk
|
||||
.normals
|
||||
.iter()
|
||||
.map(|n| wow_to_bevy(*n).to_array())
|
||||
.collect()
|
||||
} else {
|
||||
computed_normals(&positions, &chunk.indices)
|
||||
};
|
||||
|
||||
// Resolve up to 4 layer textures to array indices (appending new ones). Missing slots
|
||||
// reuse layer 0 (its alpha weight is 0, so it never shows).
|
||||
let mut li = [0u32; 4];
|
||||
@@ -158,21 +227,11 @@ impl AssetLoader for AdtLoader {
|
||||
}
|
||||
None => -1.0,
|
||||
};
|
||||
|
||||
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
|
||||
let uv1 = [ai as f32, si];
|
||||
let n = positions.len();
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, chunk.uvs.clone());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![uv1; n]);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![col; n]);
|
||||
mesh.insert_indices(Indices::U32(chunk.indices.clone()));
|
||||
chunk_meshes.push(ctx.add_labeled_asset(format!("chunk{i}"), mesh));
|
||||
shading.push(ChunkShading {
|
||||
layers: li,
|
||||
alpha: ai,
|
||||
shadow: si,
|
||||
});
|
||||
}
|
||||
|
||||
// A `texture_2d_array` needs ≥1 layer even when a tile carries no alpha / shadow maps.
|
||||
@@ -190,7 +249,7 @@ impl AssetLoader for AdtLoader {
|
||||
let shadow_array = shadow_array_image(SHADOW_MAP_SIZE, shadow_count, shadow_buf);
|
||||
|
||||
Ok(AdtTile {
|
||||
chunk_meshes,
|
||||
shading,
|
||||
layer_array: ctx.add_labeled_asset("layer_array".to_string(), layer_array),
|
||||
alpha_array: ctx.add_labeled_asset("alpha_array".to_string(), alpha_array),
|
||||
shadow_array: ctx.add_labeled_asset("shadow_array".to_string(), shadow_array),
|
||||
|
||||
@@ -26,14 +26,15 @@ pub mod minimap_grid;
|
||||
|
||||
mod model;
|
||||
pub use model::{
|
||||
bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations,
|
||||
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
|
||||
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
|
||||
bone_target_id, submesh_to_skinned_mesh, submesh_to_static_mesh, AnimClip, BillboardInfo,
|
||||
GlobalBone, GlobalSeqChannel, ModelAnimations, ModelAttachment, ModelJoint, ModelMarker,
|
||||
ModelSkeleton, ModelSubmesh, PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack,
|
||||
ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
|
||||
};
|
||||
mod adt;
|
||||
mod terrain;
|
||||
mod wdt;
|
||||
pub use adt::{AdtLoader, AdtTile};
|
||||
pub use adt::{chunk_to_mesh, AdtLoader, AdtTile, ChunkShading};
|
||||
pub use wdt::{WdtIndex, WdtIndexLoader};
|
||||
mod blp;
|
||||
pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
|
||||
@@ -186,6 +187,7 @@ pub fn register_asset_loaders(app: &mut App) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::camera::primitives::MeshAabb;
|
||||
use bevy::tasks::block_on;
|
||||
|
||||
/// crates/benilla-assets -> repo root -> WoW/Data (gitignored; test skips when absent).
|
||||
@@ -428,25 +430,35 @@ mod tests {
|
||||
for _ in 0..600 {
|
||||
app.update();
|
||||
if let Some(m) = app.world().resource::<Assets<M2Model>>().get(&handle) {
|
||||
let meshes: Vec<Handle<Mesh>> =
|
||||
m.submeshes.iter().map(|s| s.mesh.clone()).collect();
|
||||
let geometries: Vec<_> = m.submeshes.iter().map(|s| s.geometry.clone()).collect();
|
||||
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
|
||||
info = Some((meshes, textured, m.bounds.is_some()));
|
||||
info = Some((geometries, textured, m.bounds.is_some()));
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let (meshes, textured, has_bounds) =
|
||||
let (geometries, textured, has_bounds) =
|
||||
info.expect("the campfire M2 should load via mpq:// + M2ModelLoader within 600 ticks");
|
||||
assert!(!meshes.is_empty(), "campfire should have render batches");
|
||||
assert!(
|
||||
!geometries.is_empty(),
|
||||
"campfire should have render batches"
|
||||
);
|
||||
assert!(has_bounds, "M2 carries authored bounds");
|
||||
assert!(textured > 0, "campfire batches reference embedded textures");
|
||||
|
||||
// Each submesh mesh sub-asset is present and non-empty (baked to Bevy space).
|
||||
let mesh_assets = app.world().resource::<Assets<Mesh>>();
|
||||
for h in &meshes {
|
||||
let mesh = mesh_assets.get(h).expect("submesh mesh sub-asset present");
|
||||
// The loader ships geometry, no meshes (decision 0834) — the app builds the render form.
|
||||
// Exercise both builders per batch: non-empty, and the static form must yield the Aabb
|
||||
// the spawn side inserts explicitly (RENDER_WORLD meshes race `calculate_bounds`).
|
||||
for g in &geometries {
|
||||
let mesh = submesh_to_static_mesh(g);
|
||||
assert!(mesh.count_vertices() > 0, "submesh has vertices");
|
||||
assert!(mesh.compute_aabb().is_some(), "static form yields an Aabb");
|
||||
let skinned = submesh_to_skinned_mesh(g);
|
||||
assert_eq!(
|
||||
skinned.count_vertices(),
|
||||
mesh.count_vertices(),
|
||||
"the skinned twin bakes the same geometry"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,26 +487,26 @@ mod tests {
|
||||
for _ in 0..600 {
|
||||
app.update();
|
||||
if let Some(m) = app.world().resource::<Assets<WmoModel>>().get(&handle) {
|
||||
let meshes: Vec<Handle<Mesh>> =
|
||||
m.submeshes.iter().map(|s| s.mesh.clone()).collect();
|
||||
let geometries: Vec<_> = m.submeshes.iter().map(|s| s.geometry.clone()).collect();
|
||||
let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count();
|
||||
info = Some((meshes, textured));
|
||||
info = Some((geometries, textured));
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let (meshes, textured) =
|
||||
let (geometries, textured) =
|
||||
info.expect("the Goldshire Inn WMO should load via mpq:// + WmoModelLoader");
|
||||
assert!(
|
||||
!meshes.is_empty(),
|
||||
!geometries.is_empty(),
|
||||
"the inn has render batches across its groups"
|
||||
);
|
||||
assert!(textured > 0, "WMO batches reference textures");
|
||||
|
||||
let mesh_assets = app.world().resource::<Assets<Mesh>>();
|
||||
for h in &meshes {
|
||||
let mesh = mesh_assets.get(h).expect("group submesh sub-asset present");
|
||||
// Geometry, no meshes (decision 0834): the static build is the WMO's one render form.
|
||||
for g in &geometries {
|
||||
let mesh = submesh_to_static_mesh(g);
|
||||
assert!(mesh.count_vertices() > 0, "group submesh has vertices");
|
||||
assert!(mesh.compute_aabb().is_some(), "static form yields an Aabb");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,7 +544,12 @@ mod tests {
|
||||
app.update();
|
||||
if let Some(t) = app.world().resource::<Assets<AdtTile>>().get(&handle) {
|
||||
info = Some((
|
||||
t.chunk_meshes.clone(),
|
||||
t.chunks
|
||||
.iter()
|
||||
.zip(&t.shading)
|
||||
.map(|(c, s)| (c.clone(), *s))
|
||||
.collect::<Vec<_>>(),
|
||||
t.shading.len(),
|
||||
t.layer_array.clone(),
|
||||
t.alpha_array.clone(),
|
||||
t.shadow_array.clone(),
|
||||
@@ -542,24 +559,34 @@ mod tests {
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let (chunk_hs, layer_h, alpha_h, shadow_h, n_doodads) =
|
||||
let (cells, n_shading, layer_h, alpha_h, shadow_h, n_doodads) =
|
||||
info.expect("the ADT tile should load via mpq:// + AdtLoader");
|
||||
|
||||
// One drawn mesh per MCNK cell, never one merged slab (decision 0780) — the exterior-scene
|
||||
// cull's unit is the chunk, and a tile that loads as a single object cannot be culled from
|
||||
// inside a building. Every cell must be non-empty and a 145-vertex 9×9+8×8 grid.
|
||||
let meshes = app.world().resource::<Assets<Mesh>>();
|
||||
// inside a building. The loader ships the shading (index-parallel with the chunks); the
|
||||
// mesh itself is the app's paced `chunk_to_mesh` build — exercised per cell here. Every
|
||||
// drawn cell must be a 145-vertex 9×9+8×8 grid.
|
||||
assert_eq!(n_shading, cells.len(), "one ChunkShading per decoded chunk");
|
||||
let drawn: Vec<Mesh> = cells
|
||||
.iter()
|
||||
.filter_map(|(c, s)| chunk_to_mesh(c, s))
|
||||
.collect();
|
||||
assert!(
|
||||
(1..=256).contains(&chunk_hs.len()),
|
||||
(1..=256).contains(&drawn.len()),
|
||||
"a tile draws as its MCNK cells, got {} meshes",
|
||||
chunk_hs.len()
|
||||
drawn.len()
|
||||
);
|
||||
for h in &chunk_hs {
|
||||
let vc = meshes
|
||||
.get(h)
|
||||
.expect("chunk mesh sub-asset present")
|
||||
.count_vertices();
|
||||
assert_eq!(vc, 145, "an MCNK cell is 9×9 + 8×8 vertices");
|
||||
for mesh in &drawn {
|
||||
assert_eq!(
|
||||
mesh.count_vertices(),
|
||||
145,
|
||||
"an MCNK cell is 9×9 + 8×8 vertices"
|
||||
);
|
||||
assert!(
|
||||
mesh.compute_aabb().is_some(),
|
||||
"a cell mesh must yield the Aabb the exterior cull fails open without"
|
||||
);
|
||||
}
|
||||
|
||||
let images = app.world().resource::<Assets<Image>>();
|
||||
@@ -577,7 +604,7 @@ mod tests {
|
||||
// Placement lists are carried as data (count is tile-dependent — some tiles are bare).
|
||||
eprintln!(
|
||||
"ADT tile loaded: {} MCNK cells, {n_doodads} doodad placements",
|
||||
chunk_hs.len()
|
||||
drawn.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! M2 → model asset loader.
|
||||
//!
|
||||
//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each carrying
|
||||
//! a [`Mesh`](bevy::mesh::Mesh) **labeled sub-asset** (baked to Bevy space via [`crate::coords`]) plus
|
||||
//! the batch's texture as a `Handle<Image>` **dependency** (through the `mpq://` source).
|
||||
//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each
|
||||
//! carrying the batch's **decoded geometry** plus its texture as a `Handle<Image>` **dependency**
|
||||
//! (through the `mpq://` source). **The loader ships no meshes** (decision 0834, the model-lane
|
||||
//! twin of 0832's terrain rule): a labeled mesh sub-asset lands the whole model's render form in
|
||||
//! ONE frame — the city first-contact spike — so the app builds each batch's `Mesh` paced at the
|
||||
//! spawn side (`benilla`'s `model_forms`, via [`submesh_to_static_mesh`](crate::submesh_to_static_mesh)
|
||||
//! / [`submesh_to_skinned_mesh`](crate::submesh_to_skinned_mesh)).
|
||||
//!
|
||||
//! Deliberately app-independent: builds **meshes + texture handles + metadata only**, not materials
|
||||
//! Deliberately app-independent: ships **geometry + texture handles + metadata only**, not materials
|
||||
//! (the WoW lighting material is an app concern built at spawn) — so this asset is shared verbatim by
|
||||
//! doodads, creatures, GameObjects, and the star model. Creature **skin variations** (`Monster1/2/3`)
|
||||
//! are blank here (no `skins` passed) and resolved at the spawn site from `CreatureDisplayInfo`; this
|
||||
@@ -31,9 +35,8 @@ use crate::bone_target_id;
|
||||
use crate::coords::wow_to_bevy;
|
||||
use crate::model::{
|
||||
arm_subtree_roots, billboard_info, build_animation_clip, build_attachments, build_global_bones,
|
||||
build_grip_clip, build_skeleton, build_skinned_submesh_mesh, build_submesh_mesh,
|
||||
finger_subtree_roots, in_subtree, skeleton_pivots, upper_subtree_root, AnimClip,
|
||||
ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh,
|
||||
build_grip_clip, build_skeleton, finger_subtree_roots, in_subtree, skeleton_pivots,
|
||||
upper_subtree_root, AnimClip, ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh,
|
||||
};
|
||||
|
||||
/// A loaded M2 model: its render batches + authored bounds (the distance-fade size source) + the
|
||||
@@ -276,16 +279,11 @@ impl AssetLoader for M2ModelLoader {
|
||||
.filter(|c| !c.is_empty());
|
||||
|
||||
let mut submeshes = Vec::with_capacity(subs.len());
|
||||
for (i, sub) in subs.iter().enumerate() {
|
||||
let mesh = ctx.add_labeled_asset(format!("submesh{i}"), build_submesh_mesh(sub));
|
||||
// The skinned twin (decision 0019): same geometry + JOINT_INDEX/JOINT_WEIGHT. Built for
|
||||
// every M2 (the loader can't know the model's role); only the creature path uses it, so a
|
||||
// doodad/GameObject model carries an unused skinned mesh — a bounded interim cost (the first
|
||||
// deferred optimisation is building this lazily, or unifying all M2s onto the skinned path).
|
||||
let skinned_mesh = ctx.add_labeled_asset(
|
||||
format!("submesh{i}_skinned"),
|
||||
build_skinned_submesh_mesh(sub),
|
||||
);
|
||||
for sub in subs {
|
||||
// No meshes built here (decision 0834): the geometry ships on the submesh and the app
|
||||
// builds the render form paced — the static mesh on demand, the skinned twin only for
|
||||
// the lanes that rig (which also retires the old always-built, mostly-unused twin).
|
||||
//
|
||||
// Embedded texture → an mpq:// dependency (WorldArt, the loader default). Lowercase the
|
||||
// path: Bevy's loader lookup is case-sensitive, and these tables carry uppercase `.BLP`
|
||||
// (the `BlpImageLoader` registers `blp`), so an uppercase extension silently falls back to
|
||||
@@ -298,8 +296,6 @@ impl AssetLoader for M2ModelLoader {
|
||||
.as_deref()
|
||||
.map(|t| ctx.load::<Image>(crate::texture_url(t, (sub.wrap_x, sub.wrap_y))));
|
||||
submeshes.push(ModelSubmesh {
|
||||
mesh,
|
||||
skinned_mesh,
|
||||
texture,
|
||||
skin_slot: sub.skin_slot,
|
||||
geoset_id: sub.geoset_id,
|
||||
@@ -314,12 +310,13 @@ impl AssetLoader for M2ModelLoader {
|
||||
no_depth_write: sub.no_depth_write, // M2 render flag 0x10
|
||||
no_depth_test: sub.no_depth_test, // M2 render flag 0x08
|
||||
fog_policy: sub.fog_policy, // M2 render flag 0x02 / the per-blend fog table
|
||||
billboard: billboard_info(sub), // glow cards / chains face the camera
|
||||
billboard: billboard_info(&sub), // glow cards / chains face the camera
|
||||
alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new),
|
||||
uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new),
|
||||
rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new),
|
||||
wmo_batch: None, // M2 batches have no MOBA section
|
||||
ground_quad: sub.ground_quad(), // flat ground-ring quads → the fx decal lane
|
||||
geometry: std::sync::Arc::new(sub),
|
||||
});
|
||||
}
|
||||
// M2 light blocks (raw — wow-m2's light parse reads the wrong vanilla record shape). The spawn
|
||||
|
||||
@@ -47,20 +47,20 @@ pub struct BillboardInfo {
|
||||
pub seq_translations: Vec<(u16, BoneScaleAnim)>,
|
||||
}
|
||||
|
||||
/// One render batch of a loaded model (an M2 batch or a WMO group batch): geometry as a Bevy `Mesh`
|
||||
/// sub-asset, the batch's albedo texture (WorldArt), and its blend/sidedness. Carries handles +
|
||||
/// metadata only — the material is built at spawn (an app concern).
|
||||
/// One render batch of a loaded model (an M2 batch or a WMO group batch): the decoded geometry,
|
||||
/// the batch's albedo texture (WorldArt), and its blend/sidedness. Carries data + metadata only —
|
||||
/// **no `Mesh` assets** (decision 0834, the model-lane twin of 0832's terrain rule): a labeled
|
||||
/// mesh sub-asset lands the instant the decode completes and the render world ingests the whole
|
||||
/// model in ONE frame, which is the city first-contact spike. The app builds each batch's render
|
||||
/// form paced (`benilla`'s `model_forms`) via [`submesh_to_static_mesh`] /
|
||||
/// [`submesh_to_skinned_mesh`]; the material stays a spawn-site concern as before.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelSubmesh {
|
||||
/// Geometry, baked to Bevy space (a labeled sub-asset of the model). The **static** mesh — no
|
||||
/// joint attributes — used by doodads/GameObjects (which never skin).
|
||||
pub mesh: Handle<Mesh>,
|
||||
/// The **skinned** twin of [`Self::mesh`] (decision 0019): the same geometry plus the per-vertex
|
||||
/// `JOINT_INDEX`/`JOINT_WEIGHT` attributes, so Bevy's mesh pipeline takes the `SKINNED` path. Used
|
||||
/// only by the skinned creature path (which also attaches a `SkinnedMesh` + joint hierarchy); a
|
||||
/// static instance must use [`Self::mesh`] instead, since a skinned-layout mesh with no
|
||||
/// `SkinnedMesh` component indexes the joint buffer out of bounds (garbage, not bind pose).
|
||||
pub skinned_mesh: Handle<Mesh>,
|
||||
/// The batch's decoded geometry (model space, WoW axes) — everything the mesh builders below
|
||||
/// need, shared across every instance of the model. `Arc` keeps the submesh clone cheap and is
|
||||
/// the model's single resident CPU copy of the vertex data (the static render form is
|
||||
/// GPU-only; see [`submesh_to_static_mesh`]).
|
||||
pub geometry: std::sync::Arc<RenderSubmesh>,
|
||||
/// Embedded albedo texture, if any. `None` for creature skin slots (filled at spawn from the
|
||||
/// display's skin variation — see [`Self::skin_slot`]).
|
||||
pub texture: Option<Handle<Image>>,
|
||||
@@ -138,7 +138,12 @@ pub struct ModelSubmesh {
|
||||
/// applied here at the render boundary. Prefers the authored normals (soft, outward — how WoW lights
|
||||
/// foliage); recomputes flat ones only when absent. WMO per-vertex MOCV colour is folded in when
|
||||
/// present (M2 has none). The WoW→Bevy map is a pure rotation, so it applies to normals too.
|
||||
pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
|
||||
///
|
||||
/// `usages` is the caller's lane split (decision 0834): the **static** form is `RENDER_WORLD`-only
|
||||
/// (nothing reads it main-side; the render world takes the buffers at extract, no resident CPU
|
||||
/// copy), while the **skinned** twin keeps the default `MAIN_WORLD | RENDER_WORLD` because the
|
||||
/// mouseover picker rays its vertices on the main world (`target::hover::ray_posed_mesh`).
|
||||
fn build_submesh_mesh(sub: &RenderSubmesh, usages: RenderAssetUsages) -> Mesh {
|
||||
// A billboard batch is built centred at its bone pivot, so the spawn site can rotate it about that
|
||||
// point to face the camera (the entity transform then re-places it at the pivot in the world).
|
||||
let center = sub
|
||||
@@ -150,10 +155,7 @@ pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
|
||||
.iter()
|
||||
.map(|p| (wow_to_bevy(*p) - center).to_array())
|
||||
.collect();
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, usages);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone());
|
||||
mesh.insert_indices(Indices::U32(sub.indices.clone()));
|
||||
@@ -209,13 +211,24 @@ pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute =
|
||||
bevy::render::render_resource::VertexFormat::Float32x4,
|
||||
);
|
||||
|
||||
/// Build the **skinned** twin of [`build_submesh_mesh`]: the same baked geometry plus the
|
||||
/// The app-facing **static** mesh build (decision 0834): geometry only, `RENDER_WORLD`-only
|
||||
/// usages — the render world takes the vertex buffers at extract and the main world keeps no
|
||||
/// copy. Consumers must pair it with an explicit `Aabb` computed at build time (the exterior
|
||||
/// cull fails OPEN on a missing bound, and `RENDER_WORLD` races Bevy's `calculate_bounds` —
|
||||
/// the same rule 0832 set for terrain cells).
|
||||
pub fn submesh_to_static_mesh(sub: &RenderSubmesh) -> Mesh {
|
||||
build_submesh_mesh(sub, RenderAssetUsages::RENDER_WORLD)
|
||||
}
|
||||
|
||||
/// Build the **skinned** twin of [`submesh_to_static_mesh`]: the same baked geometry plus the
|
||||
/// per-vertex [`ATTRIBUTE_WOW_JOINT_INDEX`] + [`ATTRIBUTE_WOW_JOINT_WEIGHT`]. These two
|
||||
/// attributes are the entire trigger for the owned-palette skinning path (decisions 0019/0720).
|
||||
/// When the submesh carries no skin (WMO / a boneless batch) this is identical to the static
|
||||
/// mesh — harmless, but the creature path is the only consumer regardless.
|
||||
pub(crate) fn build_skinned_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
|
||||
let mut mesh = build_submesh_mesh(sub);
|
||||
/// mesh — harmless, but the rigged paths are the only consumers regardless. Keeps the default
|
||||
/// `MAIN_WORLD | RENDER_WORLD` usages: the mouseover picker skins these vertices on the CPU
|
||||
/// (`target::hover`), so the main-world copy is read, not waste.
|
||||
pub fn submesh_to_skinned_mesh(sub: &RenderSubmesh) -> Mesh {
|
||||
let mut mesh = build_submesh_mesh(sub, RenderAssetUsages::default());
|
||||
if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() {
|
||||
mesh.insert_attribute(
|
||||
ATTRIBUTE_WOW_JOINT_INDEX,
|
||||
|
||||
@@ -154,6 +154,12 @@ pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec<u8>) ->
|
||||
|
||||
/// Build a `2d_array` `Image` from concatenated per-layer bytes, with a `D2Array` view so the
|
||||
/// `dimension = "2d_array"` bindings resolve.
|
||||
///
|
||||
/// `RenderAssetUsages::RENDER_WORLD`: the render world takes the bytes on extract — a tile's
|
||||
/// three arrays are megabytes each, and nothing reads them main-side (they flow into the terrain
|
||||
/// material as handles; the minimap, collider, clutter all derive from other sources). The
|
||||
/// default usage cloned every array on landing AND kept a main-world copy resident per tile —
|
||||
/// pure cost on both the B181 landing frame and the resident-set memory.
|
||||
fn array_image(size: u32, layer_count: u32, data: Vec<u8>, format: TextureFormat) -> Image {
|
||||
let mut image = Image::new(
|
||||
Extent3d {
|
||||
@@ -164,7 +170,7 @@ fn array_image(size: u32, layer_count: u32, data: Vec<u8>, format: TextureFormat
|
||||
TextureDimension::D2,
|
||||
data,
|
||||
format,
|
||||
RenderAssetUsages::default(),
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
image.texture_view_descriptor = Some(TextureViewDescriptor {
|
||||
dimension: Some(TextureViewDimension::D2Array),
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
//! [`ModelSubmesh`] per group render batch with its texture resolved against the root's material
|
||||
//! tables.
|
||||
//!
|
||||
//! Same app-independent shape as the M2 loader (meshes + texture handles + metadata; materials at
|
||||
//! spawn). WMOs don't size-fade, so there are no bounds.
|
||||
//! Same app-independent shape as the M2 loader (geometry + texture handles + metadata; meshes are
|
||||
//! built paced app-side — decision 0834 — and materials at spawn). WMOs don't size-fade, so there
|
||||
//! are no bounds.
|
||||
|
||||
use crate::column_grid::ColumnGrid;
|
||||
use std::sync::Arc;
|
||||
@@ -24,7 +25,7 @@ use bevy::asset::{Asset, AssetLoader, LoadContext};
|
||||
use bevy::prelude::*;
|
||||
use bevy::reflect::TypePath;
|
||||
|
||||
use crate::model::{build_submesh_mesh, ModelSubmesh};
|
||||
use crate::model::ModelSubmesh;
|
||||
|
||||
/// Per-WMO-group navigation data for the portal visibility cull (`crate::wmo_portal`): the group's
|
||||
/// MOGP flags (the EXTERIOR `0x8` bit the flood defers on), its bounding box (WMO model space, WoW
|
||||
@@ -596,9 +597,11 @@ impl AssetLoader for WmoModelLoader {
|
||||
if let Some(slot) = group_liquids.get_mut(gi as usize) {
|
||||
*slot = wmo_group_liquid_mesh(&gbytes);
|
||||
}
|
||||
for (j, sub) in subs.iter().enumerate() {
|
||||
let mesh =
|
||||
ctx.add_labeled_asset(format!("group{gi}_sub{j}"), build_submesh_mesh(sub));
|
||||
for sub in subs {
|
||||
// No meshes built here (decision 0834): a city root's ~thousands of group batches
|
||||
// as labeled sub-assets landed in ONE frame — the geometry ships on the submesh
|
||||
// and the app builds each batch's mesh paced (`benilla`'s `model_forms`).
|
||||
//
|
||||
// Lowercase the path: Bevy's loader lookup is case-sensitive and these tables carry
|
||||
// uppercase `.BLP`, so an uppercase extension falls back to type-based resolution —
|
||||
// ambiguous with Bevy's built-in image loader, which spams a "Multiple AssetLoaders
|
||||
@@ -610,8 +613,6 @@ impl AssetLoader for WmoModelLoader {
|
||||
))
|
||||
});
|
||||
submeshes.push(ModelSubmesh {
|
||||
skinned_mesh: mesh.clone(), // WMO never skins; alias the static mesh (unused)
|
||||
mesh,
|
||||
texture,
|
||||
skin_slot: sub.skin_slot, // always None for WMO groups (no creature skins)
|
||||
geoset_id: 0, // WMO has no M2 geoset concept
|
||||
@@ -632,6 +633,7 @@ impl AssetLoader for WmoModelLoader {
|
||||
rgb_anim: None, // …nor M2Color tints
|
||||
wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law
|
||||
ground_quad: None, // the fx decal lane is M2-only
|
||||
geometry: std::sync::Arc::new(sub),
|
||||
});
|
||||
submesh_group.push(gi as u16);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ struct ModelRow {
|
||||
}
|
||||
|
||||
/// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`].
|
||||
///
|
||||
/// `Default` is the **empty** catalog — the same thing "the DBC failed to load" already means to
|
||||
/// every consumer (every lookup misses and the caller takes its documented fallback).
|
||||
#[derive(Default)]
|
||||
pub struct CreatureCatalog {
|
||||
/// CreatureDisplayInfo: displayId → row.
|
||||
display: HashMap<u32, DisplayRow>,
|
||||
|
||||
@@ -569,6 +569,15 @@ fn real_spell_catalog_reads_usable_walk_columns() {
|
||||
// Stance carries 0, which is what keeps a stance un-cancelable on the plain paths.
|
||||
assert_ne!(ghost_wolf.active_icon_id, 0);
|
||||
assert_eq!(cat.get(2457).unwrap().active_icon_id, 0, "Battle Stance");
|
||||
|
||||
// The form's AttackIconID column (field 13, `0x4e6870`'s `+0x34` read — wow-re
|
||||
// `action-spell-icon-apis.md` §3.3), resolved through SpellIcon.dbc at load: Cat Form
|
||||
// carries its own attack face, Ghost Wolf's column is 0 → the weapon fall-through.
|
||||
assert_eq!(
|
||||
forms.get(&1).unwrap().attack_icon.as_deref(),
|
||||
Some("Interface\\Icons\\Ability_Druid_CatFormAttack")
|
||||
);
|
||||
assert_eq!(wolf_row.attack_icon, None);
|
||||
}
|
||||
|
||||
/// The tooltip-arc columns (decision 0274 P2) on the real build-5875 `Spell.dbc`, pinned
|
||||
|
||||
@@ -33,6 +33,13 @@ pub struct ShapeshiftForm {
|
||||
/// predicates. `<= 0` reads Humanoid (the resolver's fallback; vmangos's own row comment).
|
||||
/// Consumed by the tracking dots (decision 0564).
|
||||
pub creature_type: i32,
|
||||
/// **AttackIconID** (field 13, the byte-carved `+0x34` read — wow-re
|
||||
/// `action-spell-icon-apis.md` §3.3): the form's own attack icon, resolved through
|
||||
/// `SpellIcon.dbc` at load. The Attack action's icon resolver (`0x4e6870`) serves the
|
||||
/// CURRENT form's icon before the main-hand weapon's, on both the action bar and the
|
||||
/// spellbook. `None` = column 0, no form icon — fall through to the weapon (5875 data:
|
||||
/// warrior stances and bear forms carry one; Ghost Wolf and Moonkin are 0).
|
||||
pub attack_icon: Option<String>,
|
||||
}
|
||||
|
||||
impl ShapeshiftForm {
|
||||
@@ -74,6 +81,10 @@ pub fn load_shapeshift_forms(chain: &mut Chain) -> Result<HashMap<u32, Shapeshif
|
||||
}
|
||||
}
|
||||
let set = parse(&bytes, schema, "SpellShapeshiftForm.dbc")?;
|
||||
// The AttackIconID column resolves through SpellIcon.dbc like a spell's own icon does
|
||||
// (`0x4e6870`'s `0x4e68af`–`0x4e68da` id → path hop). 1033 tiny records — loading the map
|
||||
// here keeps every call site's signature (six of them) untouched.
|
||||
let icons = crate::dbc::load_spell_icon_map(chain)?;
|
||||
let mut map = HashMap::new();
|
||||
for r in set.records() {
|
||||
if let Some(id) = u32_at(r, 0) {
|
||||
@@ -84,6 +95,9 @@ pub fn load_shapeshift_forms(chain: &mut Chain) -> Result<HashMap<u32, Shapeshif
|
||||
name: str_at(&set, r, 2).unwrap_or_default(),
|
||||
flags: u32_at(r, 11).unwrap_or(0),
|
||||
creature_type: i32_at(r, 12).unwrap_or(0),
|
||||
attack_icon: u32_at(r, 13)
|
||||
.filter(|&i| i != 0)
|
||||
.and_then(|i| icons.get(&i).cloned()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,6 +524,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
|
||||
if (m.model_flags.y > 0.5 && base_color.a < VANILLA_ALPHA_KEY) {
|
||||
discard;
|
||||
}
|
||||
// DEPTH-PRIME TWIN (the zfill pipeline variant — terrain.rs `specialize`, the reference's
|
||||
// M2UseZFill clone command, wow-re m2-blend-promotion-zfill.md §4): colour writes are masked
|
||||
// off at the pipeline, so on that variant only the discards above (farclip wall, the
|
||||
// model_flags.y cutout) shape what depth gets written; the lighting/fog below is computed and
|
||||
// thrown away. (A shader-def early return here would be the natural spelling, but naga's MSL
|
||||
// backend miscompiles the dead tail — "redefinition of '_tmp'" — so the twin pays the colour
|
||||
// math instead. Episodes are transient; the cost is bounded.)
|
||||
// Blend source alpha = texel alpha × fade (translucent fade of the fixed cutout shape). For steady
|
||||
// cutout/opaque draws blend is off so this is ignored; for the fade twin it drives the feather.
|
||||
let faded_alpha = base_color.a * obj_fade;
|
||||
|
||||
@@ -18,19 +18,25 @@
|
||||
//! act on the body:
|
||||
//!
|
||||
//! - **proc 14 = translucency** (`0x60d972`): the param becomes a node keyed by spell id
|
||||
//! (`node+0x18`, value at `node+0x78`) on the unit's list `unit+0xb50`. The unit's effective alpha
|
||||
//! is then recomputed by `0x60d180` as **`baseAlpha × Π(node alphas)`** — `baseAlpha` from the
|
||||
//! CGUnit vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`; a flat `1.0` for
|
||||
//! players) — and handed to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block
|
||||
//! (`+0xec..0x100`) and the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
|
||||
//! ([`crate::model_fade::fade_alpha`]). Per frame `model+0x180 = baseRenderAlpha × fade`
|
||||
//! (`node+0x18`, value at `node+0x78`), linked at the HEAD of the unit's list `unit+0xb50`. The
|
||||
//! unit's effective alpha is then recomputed by `0x60d180` as **`baseAlpha × the head node's
|
||||
//! factor`** — at most one node term, never a product over the chain (wow-re
|
||||
//! `base-render-alpha.md` §4, correcting this note's earlier `Π` gloss) — `baseAlpha` from the
|
||||
//! vtbl+0x6c getter `0x60d2d0` (`CreatureDisplayInfo+0x14 × 1/255`, the SAME slot in the unit
|
||||
//! and player vtables: "players 1.0" was the data talking, not a type fork — §6) — and handed
|
||||
//! to **`0x614f80` StartAlphaFade(target, 1000 ms)**, the same ramp block (`+0xec..0x100`) and
|
||||
//! the same `clamp01(t)³` ease (`0x614a90`) our appear-fade already rides
|
||||
//! ([`crate::model_fade::fade_alpha`]). Per frame `model+0x180 = master × fade`
|
||||
//! (`0x614baa → 0x710cb0`), which multiplies into the per-batch alpha (`0x707680`).
|
||||
//! The recompute has a second, aura-free caller: the DISPLAYID watcher's refresh (`0x60abe0 →
|
||||
//! 0x60ad9f`), which is how a display whose row says `CreatureModelAlpha < 255` — Ghost Wolf's
|
||||
//! 4613 = 102 — renders translucent by itself; [`refresh_base_alpha`] is that leg.
|
||||
//! - **proc 1 = tint** (`0x60d840`): `round(param)` is a packed `0x00RRGGBB` OR'd with `0xff000000`
|
||||
//! into a tint node (list `unit+0xce0`); per frame the head node goes `×1/255` into
|
||||
//! `model+0x184/188/18c`.
|
||||
//!
|
||||
//! Removal is the mirror image: the aura leaves the slot → `0x5ff290` drops that spell's nodes and
|
||||
//! recomputes, so the alpha **ramps back** to the new product over the same 1000 ms.
|
||||
//! recomputes, so the alpha **ramps back** to the new target over the same 1000 ms.
|
||||
//!
|
||||
//! The vis-flag is a decoy and must not be used here: `UNIT_FIELD_BYTES_1` byte 3's CREEP (stealth)
|
||||
//! and GHOST bits drive **no** body render at all — an exhaustive census of every `+0x213` reference
|
||||
@@ -51,7 +57,7 @@
|
||||
//!
|
||||
//! ## What is built here, and what is not
|
||||
//!
|
||||
//! **Proc 14 ships whole** — nodes, the `baseAlpha × Π` product, the 1000 ms cubic ramp both ways,
|
||||
//! **Proc 14 ships whole** — nodes, the `baseAlpha × head node` target, the 1000 ms cubic ramp both ways,
|
||||
//! and the render composition below.
|
||||
//!
|
||||
//! **Proc 1 ships too** (decision 0812): its nodes live here ([`AuraNodes::tint`], head-node-wins as
|
||||
@@ -104,7 +110,7 @@ const ALPHA_EPS: f32 = 1.0 / 128.0;
|
||||
/// reference keys `node+0x18`. Held in [`AuraNodes`]; a `Reap` for that spell id removes it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) enum AuraNode {
|
||||
/// Proc 14: this aura's alpha factor (`node+0x78`), one term of the unit's alpha product.
|
||||
/// Proc 14: this aura's alpha factor (`node+0x78`) — the target's node term while it is the head.
|
||||
Alpha(f32),
|
||||
/// Proc 1: this aura's body tint, unpacked from the param's `0x00RRGGBB`. Modelled, not yet
|
||||
/// rendered (module docs).
|
||||
@@ -112,7 +118,7 @@ pub(crate) enum AuraNode {
|
||||
}
|
||||
|
||||
/// The CharProc node lists of one unit — the ECS twin of the reference's per-unit `unit+0xb50`
|
||||
/// (alpha) and `unit+0xce0` (tint) lists, plus the ramp `0x614f80` drives from their product.
|
||||
/// (alpha) and `unit+0xce0` (tint) lists, plus the ramp `0x614f80` drives from their heads.
|
||||
///
|
||||
/// Lives on the **net entity root**, like [`crate::entity_shade::GroundShade`]: the reference has one
|
||||
/// of these per CGUnit and every attached model (held item, helm, shoulder) renders off the owner's,
|
||||
@@ -120,13 +126,17 @@ pub(crate) enum AuraNode {
|
||||
/// direct children.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub(crate) struct AuraNodes {
|
||||
/// `(spell id, factor)` — proc-14 nodes. The product of these times [`Self::base`] is the target.
|
||||
/// `(spell id, factor)` — proc-14 nodes, newest at the front (the reference links a fresh node
|
||||
/// at the list head). The HEAD times [`Self::base`] is the target — never a product.
|
||||
alpha: Vec<(u32, f32)>,
|
||||
/// `(spell id, rgb)` — proc-1 nodes, insertion-ordered so the front is the reference's head node
|
||||
/// (the one its per-frame apply reads). No renderer yet; see the module docs.
|
||||
/// `(spell id, rgb)` — proc-1 nodes, newest at the front like the alpha list; the head is what
|
||||
/// the per-frame apply reads.
|
||||
tint: Vec<(u32, [u8; 3])>,
|
||||
/// `baseAlpha`: `CreatureDisplayInfo.CreatureModelAlpha / 255` for a creature, `1.0` for a player
|
||||
/// (`0x60d2d0`). Seeded when the first node arrives — by then the display id has landed.
|
||||
/// `baseAlpha`: the display row's `CreatureModelAlpha / 255` — for EVERY unit, players
|
||||
/// included; the getter `0x60d2d0` has no type fork, a normal character's row just says 255
|
||||
/// (`base-render-alpha.md` §6, correcting the "players 1.0" gloss). Owned by
|
||||
/// [`refresh_base_alpha`], which re-resolves it on every display-id change (a shapeshift
|
||||
/// swaps it live) and ramps to the new value.
|
||||
base: f32,
|
||||
/// Where the ramp is now — what the parts actually render at.
|
||||
current: f32,
|
||||
@@ -155,15 +165,16 @@ impl AuraNodes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's `0x60d180`: `baseAlpha × Π(node alphas)`, clamped to a sane `[0, 1]`.
|
||||
/// The reference's `0x60d180`: `baseAlpha × the HEAD alpha node's factor` — at most ONE node
|
||||
/// term, skipped entirely when the list is empty (`0x60d195 je`), never a product over the
|
||||
/// chain (wow-re `base-render-alpha.md` §4, correcting `ghost-death-visuals.md`'s `Π` gloss).
|
||||
/// Nodes are linked at the head as they install, so the newest one is the term that counts.
|
||||
fn target(&self) -> f32 {
|
||||
self.alpha
|
||||
.iter()
|
||||
.fold(self.base, |acc, (_, f)| acc * f)
|
||||
.clamp(0.0, 1.0)
|
||||
let head = self.alpha.first().map_or(1.0, |(_, f)| *f);
|
||||
(self.base * head).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Point the ramp at the current product, from wherever it is now. A no-op when the target
|
||||
/// Point the ramp at the current target, from wherever it is now. A no-op when the target
|
||||
/// hasn't moved, so an aura refresh (or a node whose factor equals the live one) doesn't restart
|
||||
/// the ease.
|
||||
fn retarget(&mut self, now: f32) {
|
||||
@@ -295,11 +306,12 @@ pub(crate) fn node_for(proc: benilla_formats::CharProc) -> Option<AuraNode> {
|
||||
|
||||
/// Apply this frame's [`AuraProc`] edges to the units' node lists, then re-aim every live ramp.
|
||||
///
|
||||
/// `base_alpha` is resolved once per unit, when its first node arrives: a player is a flat `1.0`
|
||||
/// (the reference's getter has no display lookup for them), a creature is its display's
|
||||
/// `CreatureModelAlpha`. A unit whose display is unknown falls back to `1.0` — opaque, i.e. the
|
||||
/// aura's factor alone, which is the safe direction (the alternative would hide a unit whose row
|
||||
/// simply failed to load).
|
||||
/// `base_alpha` here only SEEDS a node set created by an aura edge — its steady-state owner is
|
||||
/// [`refresh_base_alpha`], which re-resolves it on every display change. The getter is the same
|
||||
/// for players and creatures (the display row's `CreatureModelAlpha`; `base-render-alpha.md` §6).
|
||||
/// A unit whose display is unknown falls back to `1.0` — opaque, i.e. the aura's factor alone,
|
||||
/// which is the safe direction (the alternative would hide a unit whose row simply failed to
|
||||
/// load).
|
||||
pub(crate) fn drain_aura_procs(
|
||||
mut edges: MessageReader<AuraProc>,
|
||||
time: Res<Time>,
|
||||
@@ -330,7 +342,7 @@ pub(crate) fn drain_aura_procs(
|
||||
// that streams in already carrying two aura procs (or gets two in one packet
|
||||
// burst) emits two Begins this frame and the second would `get_mut` the
|
||||
// still-absent component, build a *fresh* set, and insert over the first —
|
||||
// silently keeping only the last aura's factor. Staging makes the product of a
|
||||
// silently keeping only the last aura's factor. Staging makes the outcome of a
|
||||
// same-frame burst come out right, which is the common case for a unit observed
|
||||
// mid-buff, not a corner.
|
||||
let n = fresh.entry(*entity).or_insert_with(|| {
|
||||
@@ -358,7 +370,8 @@ pub(crate) fn drain_aura_procs(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp at the new product.
|
||||
/// Drop every node one spell installed (`0x5ff290`) and re-aim the ramp at the new target
|
||||
/// (the next head node down, or the bare base).
|
||||
fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
|
||||
n.alpha.retain(|(s, _)| *s != spell_id);
|
||||
n.tint.retain(|(s, _)| *s != spell_id);
|
||||
@@ -367,20 +380,23 @@ fn reap(n: &mut AuraNodes, spell_id: u32, now: f32) {
|
||||
|
||||
/// Install one spell's nodes, replacing any it already had (an aura re-applied by a second caster
|
||||
/// holds a second slot but installs one node set — the reference's own per-spell-id keying).
|
||||
/// New nodes link **at the head** (`base-render-alpha.md` §4: the newest node is the one the
|
||||
/// recompute reads), which is why both lists insert at the front here and both readers take
|
||||
/// `.first()`.
|
||||
fn install(n: &mut AuraNodes, spell_id: u32, procs: &[AuraNode], now: f32) {
|
||||
n.alpha.retain(|(s, _)| *s != spell_id);
|
||||
n.tint.retain(|(s, _)| *s != spell_id);
|
||||
for node in procs {
|
||||
match *node {
|
||||
AuraNode::Alpha(f) => n.alpha.push((spell_id, f)),
|
||||
AuraNode::Tint(rgb) => n.tint.push((spell_id, rgb)),
|
||||
AuraNode::Alpha(f) => n.alpha.insert(0, (spell_id, f)),
|
||||
AuraNode::Tint(rgb) => n.tint.insert(0, (spell_id, rgb)),
|
||||
}
|
||||
}
|
||||
n.retarget(now);
|
||||
}
|
||||
|
||||
/// The layer's instrument (`WOW_MOVE_TRACE=<path>`, tag `aur`) — one line per node edge, carrying
|
||||
/// the spell, the nodes it installed, and the product the ramp is now aiming at.
|
||||
/// the spell, the nodes it installed, and the target the ramp is now aiming at.
|
||||
///
|
||||
/// This is how "stealth shows nothing" is *reproduced and then closed* without eyeballing a capture
|
||||
/// (`method.md` §5/§6): with the aura layer dead the trace is silent on `.cast 1784`; with it live
|
||||
@@ -407,7 +423,12 @@ fn trace_edge(what: &str, entity: Entity, spell_id: u32, n: &AuraNodes) {
|
||||
);
|
||||
}
|
||||
|
||||
/// `baseAlpha` for a unit: `1.0` for a player, else its display's `CreatureModelAlpha / 255`.
|
||||
/// `baseAlpha` for a unit — the getter `0x60d2d0`: its CURRENT display row's
|
||||
/// `CreatureModelAlpha / 255`, for **every** unit kind. There is no player override — the same
|
||||
/// vtable slot in both classes, byte-identical (`base-render-alpha.md` §6); an unshifted
|
||||
/// character reads 1.0 only because its row (49, 50, …) says 255, and a shaman wearing Ghost
|
||||
/// Wolf's display 4613 reads 102/255 = 0.4 through exactly this path. No row → 1.0 (the
|
||||
/// `0x60d2e4` NULL fallback).
|
||||
fn base_alpha(
|
||||
entity: Entity,
|
||||
units: &Query<&crate::net::NetEntity>,
|
||||
@@ -416,12 +437,73 @@ fn base_alpha(
|
||||
let Ok(net) = units.get(entity) else {
|
||||
return 1.0;
|
||||
};
|
||||
if net.kind != EntityKind::Player {
|
||||
if let (Some(c), Some(display)) = (creatures, net.display_id) {
|
||||
return c.display_base_alpha(display).unwrap_or(1.0);
|
||||
display_base_alpha(net, creatures)
|
||||
}
|
||||
|
||||
/// The display-row half of [`base_alpha`], for callers already holding the `NetEntity`.
|
||||
fn display_base_alpha(
|
||||
net: &crate::net::NetEntity,
|
||||
creatures: Option<&crate::entities::Creatures>,
|
||||
) -> f32 {
|
||||
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
|
||||
return 1.0;
|
||||
}
|
||||
match (creatures, net.display_id) {
|
||||
(Some(c), Some(display)) => c.display_base_alpha(display).unwrap_or(1.0),
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-resolve every unit's base alpha when its display changes — the reference's DISPLAYID
|
||||
/// watcher path (`base-render-alpha.md` §5: `0x604990 → 0x60abe0 → 0x60afb0` caches the row →
|
||||
/// `0x60ad9f → 0x60d180 → StartAlphaFade(target, 1000 ms)`), self-gated on a real record change
|
||||
/// (`0x60ae10`). This is what makes a unit with `CreatureModelAlpha < 255` translucent with **no
|
||||
/// aura in play** — the shifted shaman's ghost look — and what ramps it back to opaque when the
|
||||
/// display swaps home. The 1000 ms cubic fade is part of the mechanism: a display swap always
|
||||
/// eases to the new alpha, never snaps.
|
||||
///
|
||||
/// `Changed<NetEntity>` is our `0x60ae10`: [`crate::entities`]' live-display refresh writes
|
||||
/// `net.display_id` only on an actual swap (and insertion covers the create path). The base
|
||||
/// compare keeps a scale-only mutation from restarting anything.
|
||||
pub(crate) fn refresh_base_alpha(
|
||||
time: Res<Time>,
|
||||
creatures: Option<Res<crate::entities::Creatures>>,
|
||||
mut commands: Commands,
|
||||
mut units: Query<
|
||||
(Entity, &crate::net::NetEntity, Option<&mut AuraNodes>),
|
||||
Changed<crate::net::NetEntity>,
|
||||
>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, net, nodes) in &mut units {
|
||||
let base = display_base_alpha(net, creatures.as_deref());
|
||||
match nodes {
|
||||
Some(mut n) => {
|
||||
if (n.base - base).abs() > f32::EPSILON {
|
||||
debug!(
|
||||
"aura_visual: e={entity} display {:?} base alpha {:.2} -> {base:.2} (1 s ramp)",
|
||||
net.display_id, n.base
|
||||
);
|
||||
n.base = base;
|
||||
n.retarget(now);
|
||||
}
|
||||
}
|
||||
// A unit that never carried a node set needs one exactly when its display is
|
||||
// authored translucent — created settled at opaque so the retarget rides the same
|
||||
// 1000 ms ramp down a fresh aura node would.
|
||||
None if base < 1.0 => {
|
||||
debug!(
|
||||
"aura_visual: e={entity} display {:?} authors base alpha {base:.2} (1 s ramp)",
|
||||
net.display_id
|
||||
);
|
||||
let mut n = AuraNodes::new(1.0);
|
||||
n.base = base;
|
||||
n.retarget(now);
|
||||
commands.entity(entity).try_insert(n);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
1.0
|
||||
}
|
||||
|
||||
/// The fadeable-part view [`apply_aura_alpha`] authors through: the part's material pair and tag,
|
||||
|
||||
@@ -79,13 +79,15 @@ fn the_dispatch_names_only_the_verified_procs() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The product and the ramp ─────────────────────────────────────────────────────────────────────
|
||||
// ── The target and the ramp ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `0x60d180`: the target is `baseAlpha × Π(node alphas)` — every live aura multiplies, and the
|
||||
/// display's own authored opacity is the first term. Stacking stealth (0.3) with invisibility (0.5)
|
||||
/// on a half-opaque display (0.5) is 0.075, not "whichever landed last".
|
||||
/// `0x60d180`: the target is `baseAlpha × the HEAD node's factor` — at most one node term, the
|
||||
/// newest (nodes link at the list head as they install), never a product over the chain (wow-re
|
||||
/// `base-render-alpha.md` §4, correcting the earlier `Π` reading). Stacking stealth (0.3) then the
|
||||
/// ghost aura (0.5) reads 0.5 — whichever landed last — and dropping the head hands the term to
|
||||
/// the next node down.
|
||||
#[test]
|
||||
fn the_target_is_the_product_of_base_and_every_node() {
|
||||
fn the_target_is_base_times_the_newest_node() {
|
||||
let mut n = AuraNodes::new(1.0);
|
||||
assert_eq!(n.target(), 1.0, "no nodes → opaque");
|
||||
|
||||
@@ -93,19 +95,51 @@ fn the_target_is_the_product_of_base_and_every_node() {
|
||||
assert!((n.target() - 0.3).abs() < 1e-6);
|
||||
|
||||
install(&mut n, GHOST, &[AuraNode::Alpha(0.5)], 0.0);
|
||||
assert!((n.target() - 0.15).abs() < 1e-6, "two nodes multiply");
|
||||
assert!(
|
||||
(n.target() - 0.5).abs() < 1e-6,
|
||||
"the newest node is the term"
|
||||
);
|
||||
|
||||
// A display authored translucent (CreatureModelAlpha 128/255) is the product's first term.
|
||||
let mut half = AuraNodes::new(128.0 / 255.0);
|
||||
// Reaping the head hands the term back to the older node.
|
||||
n.alpha.retain(|(s, _)| *s != GHOST);
|
||||
n.retarget(0.0);
|
||||
assert!((n.target() - 0.3).abs() < 1e-6);
|
||||
|
||||
// A display authored translucent (CreatureModelAlpha 128/255) multiplies the head term —
|
||||
// and stands alone when no node is live (the display-set leg, no aura anywhere).
|
||||
let mut half = AuraNodes::new(1.0);
|
||||
half.base = 128.0 / 255.0;
|
||||
install(&mut half, STEALTH, &[AuraNode::Alpha(0.3)], 0.0);
|
||||
assert!((half.target() - (128.0 / 255.0) * 0.3).abs() < 1e-6);
|
||||
|
||||
// Reaping one node leaves the other's factor, not opaque.
|
||||
half.alpha.retain(|(s, _)| *s != STEALTH);
|
||||
half.alpha.clear();
|
||||
half.retarget(0.0);
|
||||
assert!((half.target() - 128.0 / 255.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The display-swap leg ([`refresh_base_alpha`]'s retarget): a base change rides the same 1000 ms
|
||||
/// cubic ramp an aura node does — Ghost Wolf's 102/255 eases in from opaque, and swapping home
|
||||
/// eases back — never snaps (`base-render-alpha.md` §5: the fade is part of the mechanism).
|
||||
#[test]
|
||||
fn a_base_change_rides_the_same_ramp() {
|
||||
let wolf = 102.0 / 255.0;
|
||||
let mut n = AuraNodes::new(1.0);
|
||||
n.base = wolf;
|
||||
n.retarget(0.0);
|
||||
assert!((n.tick(0.0) - 1.0).abs() < 1e-6, "starts where it was");
|
||||
assert!(
|
||||
(n.tick(AURA_ALPHA_FADE_SECS) - wolf).abs() < 1e-6,
|
||||
"arrives"
|
||||
);
|
||||
assert!(n.translucent());
|
||||
|
||||
// The swap home: base back to 1.0, eased up from the live value.
|
||||
n.base = 1.0;
|
||||
n.retarget(AURA_ALPHA_FADE_SECS);
|
||||
assert!((n.from - wolf).abs() < 1e-6);
|
||||
assert!((n.tick(2.0 * AURA_ALPHA_FADE_SECS) - 1.0).abs() < 1e-6);
|
||||
assert!(!n.translucent(), "settled opaque again");
|
||||
}
|
||||
|
||||
/// A re-applied aura installs ONE node set, not a second copy — the reference keys its nodes by spell
|
||||
/// id (`node+0x18`) and its own dedup walk returns early on a spell that already has one. Without
|
||||
/// this a refresh would square the factor every application.
|
||||
@@ -171,14 +205,17 @@ fn a_retarget_to_the_same_value_does_not_restart_the_ease() {
|
||||
assert!((n.tick(10.0) - 0.3).abs() < 1e-6, "and stays arrived");
|
||||
}
|
||||
|
||||
/// Proc 1's nodes are held head-node-first (`unit+0xce0`), so the tint the reference's per-frame
|
||||
/// apply would read is deterministic — the layer is complete even though no renderer consumes it yet.
|
||||
/// Proc 1's nodes link at the head like the alpha list's (`unit+0xce0`), so the tint the per-frame
|
||||
/// apply reads is the NEWEST aura's — deterministic under stacking.
|
||||
#[test]
|
||||
fn the_tint_head_node_is_the_first_installed() {
|
||||
fn the_tint_head_node_is_the_newest_installed() {
|
||||
let mut n = AuraNodes::new(1.0);
|
||||
assert_eq!(n.head_tint(), None);
|
||||
install(&mut n, GHOST, &[AuraNode::Tint([0x8C, 0xB9, 0xFD])], 0.0);
|
||||
install(&mut n, 12881, &[AuraNode::Tint([0x00, 0xFF, 0x00])], 0.0);
|
||||
assert_eq!(n.head_tint(), Some([0x00, 0xFF, 0x00]));
|
||||
// The newest dropping hands the head back.
|
||||
n.tint.retain(|(s, _)| *s != 12881);
|
||||
assert_eq!(n.head_tint(), Some([0x8C, 0xB9, 0xFD]));
|
||||
assert_eq!(n.target(), 1.0, "a tint node is not an alpha term");
|
||||
}
|
||||
@@ -319,8 +356,8 @@ fn a_kit_with_both_procs_installs_both_nodes() {
|
||||
assert_eq!(n.head_tint(), Some([0x8C, 0xB9, 0xFD]));
|
||||
}
|
||||
|
||||
/// Two auras on one unit stack multiplicatively through the live edges, and reaping just one leaves
|
||||
/// the other's translucency behind rather than snapping the body opaque.
|
||||
/// Two auras on one unit: the newest node holds the head (the reference's link-at-head list), and
|
||||
/// reaping the other leaves it untouched — the body never snaps opaque while any alpha aura lives.
|
||||
#[test]
|
||||
fn two_auras_stack_and_unstack_through_the_slots() {
|
||||
let mut app = app_with_chains();
|
||||
@@ -328,7 +365,10 @@ fn two_auras_stack_and_unstack_through_the_slots() {
|
||||
let unit = app.world_mut().spawn(crate::net::ObjectStore(both)).id();
|
||||
app.update();
|
||||
let n = app.world().entity(unit).get::<AuraNodes>().unwrap();
|
||||
assert!((n.target() - 0.15).abs() < 1e-6, "0.3 × 0.5");
|
||||
assert!(
|
||||
(n.target() - 0.5).abs() < 1e-6,
|
||||
"the ghost node installed last → it is the head term"
|
||||
);
|
||||
|
||||
// Stealth alone drops (slot 0 cleared, slot 1 still occupied).
|
||||
app.world_mut()
|
||||
@@ -415,6 +455,7 @@ fn the_author_owns_the_tree_then_releases_at_opaque() {
|
||||
cutout: cutout.clone(),
|
||||
blend: blend.clone(),
|
||||
bake_blend: None,
|
||||
zfill: None,
|
||||
};
|
||||
// root → body part, and root → joint → held weapon (the depth the walk must reach).
|
||||
let mut nodes = AuraNodes::new(1.0);
|
||||
|
||||
@@ -973,16 +973,29 @@ fn kind_index(kind: crate::debug_panel::ModelKind) -> usize {
|
||||
/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at
|
||||
/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already
|
||||
/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live.
|
||||
///
|
||||
/// **`WOW_PARTICLE_CENSUS=+<secs>` fires that long after the world is first SHOWN** (the loading
|
||||
/// screen dropping) instead of after app start. Everything that rides the appear ramp — decision
|
||||
/// 0827/0833's `alpha` column above all — lives in a 2-second window whose start moves by *seconds*
|
||||
/// between a warm and a cold load, and a wall-clock timer either lands in it or does not: three
|
||||
/// runs in a row missed it while the question was "does a weapon glow ramp with its wearer?". A
|
||||
/// probe should not be a dice roll, and the ramp's own trigger is the thing to time from.
|
||||
pub(crate) struct ParticleCensusPlugin;
|
||||
|
||||
impl Plugin for ParticleCensusPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let at = std::env::var("WOW_PARTICLE_CENSUS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10.0);
|
||||
app.insert_resource(ParticleCensus { at, fired: false })
|
||||
.add_systems(Update, fire_particle_census);
|
||||
let spec = std::env::var("WOW_PARTICLE_CENSUS").unwrap_or_default();
|
||||
let after_shown = spec.starts_with('+');
|
||||
let at = spec
|
||||
.trim_start_matches('+')
|
||||
.parse()
|
||||
.unwrap_or(if after_shown { 1.0 } else { 10.0 });
|
||||
app.insert_resource(ParticleCensus {
|
||||
at,
|
||||
after_shown,
|
||||
fired: false,
|
||||
})
|
||||
.add_systems(Update, fire_particle_census);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,11 +1003,15 @@ impl Plugin for ParticleCensusPlugin {
|
||||
#[derive(Resource)]
|
||||
struct ParticleCensus {
|
||||
at: f32,
|
||||
/// `at` is measured from the world being shown, not from app start — and is rewritten into an
|
||||
/// absolute time the first frame the loading screen stops covering.
|
||||
after_shown: bool,
|
||||
fired: bool,
|
||||
}
|
||||
|
||||
fn fire_particle_census(
|
||||
mut probe: ResMut<ParticleCensus>,
|
||||
screen: Res<crate::loading_screen::LoadingScreen>,
|
||||
time: ProbeClock,
|
||||
view: Res<crate::view::ViewDistance>,
|
||||
cam: Query<&GlobalTransform, With<crate::player::WorldCamera>>,
|
||||
@@ -1004,6 +1021,15 @@ fn fire_particle_census(
|
||||
Option<&bevy::camera::visibility::RenderLayers>,
|
||||
)>,
|
||||
) {
|
||||
// Latch the shown-relative deadline the first frame the screen drops (and only then: while it
|
||||
// still covers there is no ramp to be relative to).
|
||||
if probe.after_shown {
|
||||
if screen.covering() {
|
||||
return;
|
||||
}
|
||||
probe.at += time.elapsed_secs();
|
||||
probe.after_shown = false;
|
||||
}
|
||||
if probe.fired || time.elapsed_secs() < probe.at {
|
||||
return;
|
||||
}
|
||||
@@ -1101,13 +1127,17 @@ fn fire_particle_census(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={}{dist}{plane}",
|
||||
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={} alpha={:.2}{dist}{plane}",
|
||||
d.blend,
|
||||
d.flags,
|
||||
rate_keys.join(","),
|
||||
d.lifespan,
|
||||
d.texture.as_deref().unwrap_or("-"),
|
||||
e.live(),
|
||||
// The frame's composed MODEL alpha (decision 0827/0833) — the number that answers
|
||||
// "this cloud is drawing, why can't I see it / why is it full strength?". An effect on
|
||||
// a unit that has not appeared yet reads ~0; one with no model above it reads 1.
|
||||
e.render_alpha(),
|
||||
);
|
||||
total += e.live();
|
||||
n += 1;
|
||||
|
||||
@@ -137,6 +137,18 @@ pub(crate) struct AnimHostSpawn {
|
||||
pub anim_id: Option<u16>,
|
||||
}
|
||||
|
||||
/// Whether [`spawn_anim_host`] would rig this model — the model-forms requester's twin of the
|
||||
/// spawn gate (decision 0834), so the skinned twins are built exactly for the placed models that
|
||||
/// will draw them. Mirrors both of the host's gates: the tier, and the capture freeze (captures
|
||||
/// keep every doodad static, so they need no twins either).
|
||||
pub(crate) fn wants_rig(m: &M2Model) -> bool {
|
||||
!crate::capture::scenario_active()
|
||||
&& !matches!(
|
||||
classify(&m.skeleton, m.animations.as_ref()),
|
||||
DoodadAnimTier::Static
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn the animation host for one placed M2, if [`classify`] says it animates: an anim-root entity
|
||||
/// at the placement transform, the joint hierarchy as its children (so despawning the root cascades),
|
||||
/// and — per tier — an `AnimationPlayer` looping the **loader-idle seed's** clip (the client's
|
||||
|
||||
@@ -57,7 +57,7 @@ use carried_light::spawn_carried_lights;
|
||||
/// helm/shoulder resolution, all resolved from the unit descriptor + ItemDisplayInfo and spawned as
|
||||
/// children of the body's attach-point joints.
|
||||
mod equipment;
|
||||
use equipment::{attach_held_items, refresh_player_looks, resolve_equipment};
|
||||
use equipment::{attach_held_items, resolve_equipment};
|
||||
|
||||
/// Item / enchant glow effects (decision 0805): the `Spells\Enchantments\*.mdx` models an item's
|
||||
/// `ItemVisuals` id hangs on the item's OWN attachment points — the permanent weapon glows and
|
||||
@@ -455,9 +455,10 @@ impl Plugin for EntitiesPlugin {
|
||||
// Fire each live instance's crossed event keyframes (decision 0304) — after
|
||||
// attach so a just-spawned instance's head window [0, cur] fires this frame.
|
||||
spell_fx::fire_fx_anim_events,
|
||||
// A gear change tears the visual down; attach rebuilds it next frame with the
|
||||
// new composite (fade-skipped — a shirt swap isn't a spawn).
|
||||
refresh_player_looks,
|
||||
// A gear change re-dresses the standing visual in place — a re-composited atlas
|
||||
// on the same parts, the equipment geosets re-selected, every attachment left
|
||||
// alone (decision 0835, the reference's own shape).
|
||||
attach::redress_player_looks,
|
||||
// A mount transition does the same (decision 0441): the field diff tears the
|
||||
// visual down, attach rebuilds it mounted (or dismounted) next frame(s).
|
||||
refresh_mounts,
|
||||
@@ -505,6 +506,10 @@ impl Plugin for EntitiesPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// The display's own base alpha first (the reference's DISPLAYID-watcher leg
|
||||
// of the same recompute, `base-render-alpha.md` §5 — no aura needed), so a
|
||||
// same-frame aura edge retargets from the already-updated base.
|
||||
crate::aura_visual::refresh_base_alpha,
|
||||
crate::aura_visual::drain_aura_procs,
|
||||
// The tint publish only needs the drain ahead of it (it writes a resource, not
|
||||
// the alpha channel), so it rides the same chain rather than earning its own.
|
||||
@@ -542,7 +547,10 @@ impl Plugin for EntitiesPlugin {
|
||||
.add_systems(
|
||||
PostUpdate,
|
||||
crate::model_fade::publish_model_alpha.before(crate::billboard::BillboardPlace),
|
||||
);
|
||||
)
|
||||
// The depth-prime twins (decision 0831 — the reference's `M2UseZFill`): PostUpdate too,
|
||||
// after the same Update-side tag writers, so a twin arms on its episode's first frame.
|
||||
.add_systems(PostUpdate, crate::zfill::sync_zfill_twins);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +696,7 @@ fn update_display_models(
|
||||
mut spell_fx: Option<ResMut<spell_fx::SpellFx>>,
|
||||
mut glows: Option<ResMut<ItemGlows>>,
|
||||
model_assets: (Res<Assets<M2Model>>, Res<Assets<WmoModel>>),
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut entity_mats: ResMut<EntityMaterials>,
|
||||
@@ -738,6 +747,7 @@ fn update_display_models(
|
||||
dm,
|
||||
m2s,
|
||||
wmos,
|
||||
&mut forms,
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
cache,
|
||||
@@ -761,6 +771,7 @@ fn update_display_models(
|
||||
dm,
|
||||
m2s,
|
||||
wmos,
|
||||
&mut forms,
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
cache,
|
||||
@@ -783,6 +794,7 @@ fn update_display_models(
|
||||
dm,
|
||||
m2s,
|
||||
wmos,
|
||||
&mut forms,
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
cache,
|
||||
@@ -802,6 +814,7 @@ fn update_display_models(
|
||||
dm,
|
||||
m2s,
|
||||
wmos,
|
||||
&mut forms,
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
cache,
|
||||
@@ -821,6 +834,7 @@ fn update_display_models(
|
||||
dm,
|
||||
m2s,
|
||||
wmos,
|
||||
&mut forms,
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
cache,
|
||||
|
||||
@@ -199,14 +199,15 @@ pub(in crate::entities) fn equip_geosets(
|
||||
}
|
||||
|
||||
/// One per-appearance material set for a character runtime texture slot: (steady, interior-matte,
|
||||
/// appear-fade-blend, interior-bake, interior-bake-blend). `model_material` dedups by
|
||||
/// texture+blend, so all players of one look share them.
|
||||
/// appear-fade-blend, interior-bake, interior-bake-blend, depth-prime twin). `model_material` /
|
||||
/// `zfill_material` dedup by texture+blend, so all players of one look share them.
|
||||
pub(super) type MatQuint = (
|
||||
Handle<WowModelMaterial>,
|
||||
Handle<WowModelMaterial>,
|
||||
Handle<WowModelMaterial>,
|
||||
Handle<WowModelMaterial>,
|
||||
Handle<WowModelMaterial>,
|
||||
Handle<WowModelMaterial>,
|
||||
);
|
||||
|
||||
/// The full character material set [`build_char_skin_materials`] returns: `(body, hair, object,
|
||||
@@ -233,6 +234,17 @@ fn build_char_materials(
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
cache: &mut MaterialCache,
|
||||
) -> MatQuint {
|
||||
// The depth-prime twin (decision 0831): body/hair batches are opaque/alpha-cut and always
|
||||
// z-writing, so every character part twins; `cutout` mirrors the colour twin's 224/255
|
||||
// discard exactly as the model-built parts' twins do. Built before `mk` captures the caches.
|
||||
let zfill = crate::model_render::zfill_material(
|
||||
cache,
|
||||
materials,
|
||||
Some(tex.clone()),
|
||||
two_sided,
|
||||
blend != ModelBlend::Blend,
|
||||
light,
|
||||
);
|
||||
let mut mk = |blend, shade: ShadeSel, fade, probe| {
|
||||
model_material(
|
||||
cache,
|
||||
@@ -273,6 +285,7 @@ fn build_char_materials(
|
||||
mk(ModelBlend::Blend, ShadeSel::Lit, true, false),
|
||||
mk(blend, ShadeSel::Matte, false, true),
|
||||
mk(ModelBlend::Blend, ShadeSel::Matte, true, true),
|
||||
zfill,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! **Dressing** a unit's model parts — the per-batch spawn [`super::attach_entity_visuals`] runs
|
||||
//! once per visual, factored out so the **re-dress** ([`super::redress`]) can run the identical law
|
||||
//! over the parts a unit is already standing in.
|
||||
//!
|
||||
//! The reference never rebuilds a character to change what it is wearing: the composite atlas is
|
||||
//! re-blitted into the component's own 256² target and the geosets are re-selected on the SAME
|
||||
//! `CM2Model` (its per-instance visibility array `+0x98`, whose only writer is the character
|
||||
//! compositor — wow-re `charactermodel.md` "Assembly orchestration" + `models.md`
|
||||
//! §"geoset-visibility-default"). Its attachments — held items, the helm, the shoulders and
|
||||
//! everything hanging off them — are never touched by that path at all. This module is the half of
|
||||
//! that law that concerns one part: which materials it draws through, and how it is born.
|
||||
|
||||
use benilla_formats::CharSkinSlot;
|
||||
use bevy::camera::primitives::Aabb;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::billboard::BillboardCard;
|
||||
use crate::debug_panel::{ModelKind, ModelPart};
|
||||
use crate::interact::WorldObject;
|
||||
use crate::interior::part_interior_lit;
|
||||
use crate::model_fade::{
|
||||
fade_alpha, FadeMaterials, JoinedFade, PendingAppearFade, RenderFade, APPEAR_FADE_SECS,
|
||||
};
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
use super::super::EntityPart;
|
||||
use super::char_skin::CharSkinMaterials;
|
||||
|
||||
/// The display-model batch a spawned part child was built from — its index into
|
||||
/// [`DisplayModel::parts`](super::super::DisplayModel), plus the world-root billboard card that
|
||||
/// belongs to it when the batch is a camera-facing one (decision 0153: the card is a ROOT entity,
|
||||
/// so it does not cascade with its anchor's despawn and must be named here).
|
||||
///
|
||||
/// This is the **re-dress edge** ([`super::redress`]): a gear change re-selects the geoset set and
|
||||
/// re-composites the atlas, and each standing part is found again through this index — its geoset
|
||||
/// id for "does it still show?", its char slot for "which atlas does it draw?". The index is stable
|
||||
/// for as long as the visual lives, because the only thing that can rebuild `parts` is the display
|
||||
/// itself changing, which is a teardown (`live_display::refresh_live_display`).
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub(in crate::entities) struct DressedPart {
|
||||
pub(in crate::entities) index: u32,
|
||||
pub(in crate::entities) card: Option<Entity>,
|
||||
}
|
||||
|
||||
/// The material variants one part draws through, after the per-appearance character swap: the
|
||||
/// steady exterior material, its interior-matte / interior-bake law twins, and the `Blend` twins the
|
||||
/// appear/despawn ramps feather on.
|
||||
///
|
||||
/// One function answers it for both the spawn and the re-dress, which is the point: "which material
|
||||
/// does this batch wear?" has exactly one implementation, so a gear change can never dress a body
|
||||
/// differently from the way it was first built.
|
||||
pub(super) struct PartMaterials<'a> {
|
||||
pub(super) steady: &'a Handle<WowModelMaterial>,
|
||||
pub(super) interior: Option<&'a Handle<WowModelMaterial>>,
|
||||
pub(super) fade_blend: Option<&'a Handle<WowModelMaterial>>,
|
||||
pub(super) bake: Option<&'a Handle<WowModelMaterial>>,
|
||||
pub(super) bake_blend: Option<&'a Handle<WowModelMaterial>>,
|
||||
/// The depth-prime twin (decision 0831) — carried per appearance like the rest, since its
|
||||
/// cutout discard samples the same swapped texture.
|
||||
pub(super) zfill: Option<&'a Handle<WowModelMaterial>>,
|
||||
}
|
||||
|
||||
/// Select `part`'s materials: on a character-slot batch the per-appearance variants over the
|
||||
/// composited body atlas / hair / cape / extra-skin textures (decisions 0041 / 0044 / 0045) — the
|
||||
/// body and the extra skin at the batch's own sidedness, since a robe skirt is authored two-sided
|
||||
/// and the closed body is not — and on every other batch the ones the shared model was built with.
|
||||
pub(super) fn part_materials<'a>(
|
||||
part: &'a EntityPart,
|
||||
char_mats: &'a CharSkinMaterials,
|
||||
) -> PartMaterials<'a> {
|
||||
let slot_mats = match part.char_slot {
|
||||
Some(CharSkinSlot::Body) => {
|
||||
char_mats
|
||||
.0
|
||||
.as_ref()
|
||||
.map(|(single, two)| if part.two_sided { two } else { single })
|
||||
}
|
||||
Some(CharSkinSlot::Hair) => char_mats.1.as_ref(),
|
||||
Some(CharSkinSlot::Object) => char_mats.2.as_ref(),
|
||||
Some(CharSkinSlot::SkinExtra) => {
|
||||
let (single, two) = &char_mats.3;
|
||||
if part.two_sided { two } else { single }.as_ref()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
match slot_mats {
|
||||
Some((ext, int, fade, bake, bake_blend, zfill)) => PartMaterials {
|
||||
steady: ext,
|
||||
interior: Some(int),
|
||||
fade_blend: Some(fade),
|
||||
bake: Some(bake),
|
||||
bake_blend: Some(bake_blend),
|
||||
zfill: Some(zfill),
|
||||
},
|
||||
None => PartMaterials {
|
||||
steady: &part.material,
|
||||
interior: part.material_interior.as_ref(),
|
||||
fade_blend: part.fade_blend.as_ref(),
|
||||
bake: part.material_interior_bake.as_ref(),
|
||||
bake_blend: part.material_interior_bake_blend.as_ref(),
|
||||
zfill: part.zfill.as_ref(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one part's spawn reads from its UNIT — gathered once per visual so the per-part body
|
||||
/// isn't a twenty-argument call, and so the spawn path and the re-dress path provably feed it the
|
||||
/// same things.
|
||||
pub(super) struct PartDress<'a> {
|
||||
/// The unit whose model this is: the parent, the interior classifier's anchor, the animation
|
||||
/// clock every `MatAnim` follows.
|
||||
pub(super) unit: Entity,
|
||||
pub(super) kind: ModelKind,
|
||||
pub(super) char_mats: &'a CharSkinMaterials,
|
||||
/// Identity for the mouseover inspector, cloned onto every part and card.
|
||||
pub(super) object: &'a WorldObject,
|
||||
/// The unit's rig-palette slot in `MeshTag` bits — a per-INSTANCE identity every part carries,
|
||||
/// skinned or not, so a tinted unit tints whole (decision 0812).
|
||||
pub(super) rig_tag: u32,
|
||||
/// `0` = the palette table was full: parts fall back to the static bind-pose mesh.
|
||||
pub(super) inst_slot: u16,
|
||||
/// Whether the unit built a rig at all — the gate on a part drawing its skinned twin.
|
||||
pub(super) rigged: bool,
|
||||
/// Bone index → anchor entity, for a billboard batch's card to ride its live joint.
|
||||
pub(super) anchors: &'a std::collections::HashMap<u16, Entity>,
|
||||
/// The model's interior fold reference (model-local) — one verdict per unit, so a body can
|
||||
/// never split across the two light laws.
|
||||
pub(super) bake_center: Vec3,
|
||||
/// The armed idle's authored CAaBox — the mouseover picker's volume for a skinned part.
|
||||
pub(super) idle_aabb: Option<Aabb>,
|
||||
/// `Time::elapsed_secs`, for a part joining a ramp already in progress.
|
||||
pub(super) now: f32,
|
||||
/// What this part should do about the unit's appear-fade. A fresh visual passes
|
||||
/// `Pending { since: now }` (decision 0032); a part spawned by a **re-dress** JOINS whatever the
|
||||
/// unit's own clock says ([`crate::model_fade::join_unit_appear_fade`]), exactly as a
|
||||
/// late-resolving held item does — so a gear change during the login cascade neither pops the
|
||||
/// new geoset opaque over a still-feathering body nor restarts a second ramp beside it.
|
||||
pub(super) fade: JoinedFade,
|
||||
}
|
||||
|
||||
/// One part's resolved appear-fade for this spawn: [`JoinedFade`] folded with whether the part is
|
||||
/// fade-capable at all (a WMO-display batch has no blend twin).
|
||||
#[derive(Clone, Copy)]
|
||||
enum PartFade {
|
||||
Steady,
|
||||
Pending(f32),
|
||||
Live(f32),
|
||||
}
|
||||
|
||||
/// Spawn one part of `dress.unit`'s model as a child of it, dressed by [`part_materials`]. Returns
|
||||
/// whether it armed an appear-fade — the attach path mirrors that onto the unit root so a held item
|
||||
/// resolving later can join the same ramp.
|
||||
///
|
||||
/// A **billboard** batch (a glow card / chain) can't spawn as an ordinary child: its mesh is centred
|
||||
/// at the bone pivot and its transform belongs to the billboard system, so as a plain child it would
|
||||
/// render at the model origin (the "glow on the ground" family, decision 0153). It spawns as a
|
||||
/// lightweight mirror anchor under the unit plus a world-root card following the anchor's live joint.
|
||||
pub(super) fn spawn_part(
|
||||
commands: &mut Commands,
|
||||
part: &EntityPart,
|
||||
index: usize,
|
||||
dress: &PartDress,
|
||||
) -> bool {
|
||||
if let Some(info) = &part.billboard {
|
||||
spawn_billboard_part(commands, part, index, info, dress);
|
||||
return false;
|
||||
}
|
||||
let mats = part_materials(part, dress.char_mats);
|
||||
// Per-part join decision: the unit's clock combined with whether this part is fade-capable.
|
||||
let effective = match (dress.fade, mats.fade_blend) {
|
||||
(_, None) => PartFade::Steady,
|
||||
(JoinedFade::Steady, _) => PartFade::Steady,
|
||||
(JoinedFade::Pending { since }, _) => PartFade::Pending(since),
|
||||
(JoinedFade::Live { started }, _) => PartFade::Live(started),
|
||||
};
|
||||
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the blend twin
|
||||
// with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before `apply_render_fade` ramps
|
||||
// `α = t³`. A joiner computes the ramp's *current* alpha rather than 0, so it doesn't flash
|
||||
// invisible for a frame either.
|
||||
let (init_mat, tag_alpha) = match effective {
|
||||
PartFade::Steady => (mats.steady.clone(), 1.0),
|
||||
PartFade::Pending(_) => (mats.fade_blend.cloned().unwrap_or_default(), 0.0),
|
||||
PartFade::Live(started) => (
|
||||
mats.fade_blend.cloned().unwrap_or_default(),
|
||||
fade_alpha(0.0, 1.0, (dress.now - started) / APPEAR_FADE_SECS),
|
||||
),
|
||||
};
|
||||
// A skinned creature part draws its skinned-mesh twin (the WOW joint attributes → the
|
||||
// owned-palette `WOW_RIG_SKIN` shader path, decision 0720); everything else — and the
|
||||
// palette-full fallback (slot 0) — the static mesh. Keyed on the part having a twin, which the
|
||||
// slot alone no longer implies.
|
||||
let skinned = dress.inst_slot != 0 && part.skinned_mesh.is_some();
|
||||
let mesh = match &part.skinned_mesh {
|
||||
Some(sm) if skinned => sm.clone(),
|
||||
_ => part.mesh.clone(),
|
||||
};
|
||||
let mut child = commands.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(init_mat),
|
||||
Transform::default(),
|
||||
ChildOf(dress.unit),
|
||||
ModelPart {
|
||||
kind: dress.kind,
|
||||
blend: part.blend,
|
||||
},
|
||||
// The portrait booth mirrors this part ([`crate::portrait`]): both mesh twins — the booth
|
||||
// poses the skinned twin at Stand on its own throwaway skeleton (the ref bake, wow-re §4
|
||||
// D2), falling back to the static bind-pose twin for a boneless model — + the steady
|
||||
// exterior material (not the appear-fade/interior variant the child may wear now).
|
||||
crate::portrait::PortraitPart {
|
||||
static_mesh: part.mesh.clone(),
|
||||
skinned_mesh: part.skinned_mesh.clone(),
|
||||
material: mats.steady.clone(),
|
||||
},
|
||||
dress.object.clone(),
|
||||
DressedPart {
|
||||
index: index as u32,
|
||||
card: None,
|
||||
},
|
||||
));
|
||||
// Every part gets a tag: the instance slot plus the live alpha field. Unconditional because the
|
||||
// slot is a per-instance identity every part needs (the tint) rather than a skinning detail —
|
||||
// and because the two conditional writers that used to seed it (the interior classifier, the
|
||||
// animated-alpha compose) each covered only their own subset, leaving a part in neither with no
|
||||
// tag at all.
|
||||
child.insert(MeshTag(
|
||||
dress.rig_tag | crate::mesh_tag::alpha_bits(tag_alpha),
|
||||
));
|
||||
// `RigPart` stays gated on actually being skinned by that rig — it is the CPU-side link for the
|
||||
// mouseover picker's skinned ray test (decision 0720), which has nothing to say about a static
|
||||
// part.
|
||||
if skinned {
|
||||
child.insert(crate::rig_palette::RigPart(dress.unit));
|
||||
}
|
||||
if dress.rigged && part.skinned_mesh.is_some() {
|
||||
// A streamed entity's M2 is **never view-culled** — the reference registers entity render
|
||||
// records with effectively-infinite bounds and the one frustum cull in its machinery is
|
||||
// map-doodad-only (wow-re `unit-anim-visibility-gate.md` §2/§4). Bevy's bind-pose `Aabb` is
|
||||
// the wrong stand-in (the duel flag plants itself 9 yd below it) and is stomped anyway by
|
||||
// `calculate_bounds` on any `Mesh3d` change. So the renderer gets the faithful
|
||||
// `NoFrustumCulling`, and the `Aabb` beside it serves ONE master: the mouseover picker
|
||||
// (`target/hover.rs`) — the armed idle's authored CAaBox when it has one, else the bind
|
||||
// box, read from the part's build-time bound (decision 0834: the static mesh is
|
||||
// `RENDER_WORLD`-only, so its main-world data — which the old `compute_aabb` fallback
|
||||
// read — is gone after extract). Both `calculate_bounds` queries skip `NoFrustumCulling`
|
||||
// entities, so this box survives.
|
||||
let picker_aabb = dress.idle_aabb.or(part.aabb);
|
||||
if let Some(aabb) = picker_aabb {
|
||||
child.insert((aabb, NoFrustumCulling));
|
||||
}
|
||||
} else if let Some(aabb) = part.aabb {
|
||||
// A STATIC part (a boneless GameObject model, a WMO-display batch) keeps Bevy's ordinary
|
||||
// frustum cull — but its bind-pose box must be inserted here now (decision 0834):
|
||||
// `calculate_bounds` used to derive it from the mesh's main-world data, which the
|
||||
// `RENDER_WORLD`-only static form no longer has after extract.
|
||||
child.insert(aabb);
|
||||
}
|
||||
// M2 parts can light off a WMO room they stand in: a `MeshTag` + the classifier pick the law by
|
||||
// location (0354). Anchored at the unit root so every part shares the root's verdict, and the
|
||||
// indoor LAW is one for every entity M2 — the footprint-MOCV bake (wow-re
|
||||
// `unit-m2-shader-light.md`), with the matte ×1.0 as the bake's miss fallback.
|
||||
if let Some(lit) = part_interior_lit(
|
||||
mats.steady,
|
||||
mats.interior,
|
||||
mats.bake,
|
||||
dress.bake_center,
|
||||
dress.unit,
|
||||
) {
|
||||
child.insert(lit);
|
||||
}
|
||||
// The batch's **animated material alpha** (the verified combine's runtime half, wow-re
|
||||
// `m2-alpha-combine-cull.md`): a creature's colour-alpha/transparency tracks are authored PER
|
||||
// SEQUENCE, so which of its batches draw is a function of what it is playing. Sampling follows
|
||||
// the unit's own `AnimationPlayer`, so the alpha stays in phase with the pose.
|
||||
if let Some(anim) = &part.alpha_anim {
|
||||
child.insert(crate::doodad_anim::MatAnim::following(
|
||||
anim.clone(),
|
||||
dress.unit,
|
||||
));
|
||||
}
|
||||
// `FadeMaterials` is persistent bookkeeping — the self-avatar zoom feather and decision 0032's
|
||||
// despawn fade-out both re-arm from it — not a record of whether *this* spawn happened to join
|
||||
// an in-flight ramp. Attach it whenever the part is fade-capable at all, steady or not; the item
|
||||
// lane (`equipment::spawn`) has always done it this way, and gating it on the arm is what left a
|
||||
// player who had ever swapped a shirt unable to fade out on stream-out.
|
||||
if let Some(blend) = mats.fade_blend {
|
||||
child.insert(FadeMaterials {
|
||||
cutout: mats.steady.clone(),
|
||||
blend: blend.clone(),
|
||||
bake_blend: mats.bake_blend.cloned(),
|
||||
zfill: mats.zfill.cloned(),
|
||||
});
|
||||
}
|
||||
match effective {
|
||||
PartFade::Steady => false,
|
||||
PartFade::Pending(since) => {
|
||||
// `arm_appear_fade` starts the ramp once the world is on-screen (not behind the loading
|
||||
// screen), so it plays where the player can see it.
|
||||
child.insert(PendingAppearFade { since });
|
||||
true
|
||||
}
|
||||
PartFade::Live(started) => {
|
||||
child.insert(RenderFade {
|
||||
started,
|
||||
duration: APPEAR_FADE_SECS,
|
||||
from: 0.0,
|
||||
to: 1.0,
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera-facing half of [`spawn_part`]: a mirror anchor under the unit (so the portrait /
|
||||
/// paper-doll booths can rebuild the batch — the visible card is a world ROOT and can never be
|
||||
/// mirrored) plus the card itself, following the batch's live joint when the unit is rigged and the
|
||||
/// anchor otherwise.
|
||||
fn spawn_billboard_part(
|
||||
commands: &mut Commands,
|
||||
part: &EntityPart,
|
||||
index: usize,
|
||||
info: &benilla_assets::BillboardInfo,
|
||||
dress: &PartDress,
|
||||
) {
|
||||
let anchor = commands
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
ChildOf(dress.unit),
|
||||
crate::portrait::PortraitBillboard {
|
||||
mesh: part.mesh.clone(),
|
||||
material: part.material.clone(),
|
||||
bone: info.bone,
|
||||
// A rigged host's billboard bone already bakes the pivot into its booth joint (the
|
||||
// 0130 rig identity) — and the batch belongs to the host's body, so a mount's own
|
||||
// glow card prunes with the mount (`DressedLook::collect`).
|
||||
seat: crate::portrait::PortraitSeat::Body,
|
||||
kind: info.kind,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let (owner, at_joint) = match dress.anchors.get(&info.bone).copied() {
|
||||
Some(j) => (j, true),
|
||||
None => (anchor, false),
|
||||
};
|
||||
let card_follow = if at_joint {
|
||||
BillboardCard::following_joint(info, owner)
|
||||
} else {
|
||||
BillboardCard::following(info, owner)
|
||||
};
|
||||
let mut card = commands.spawn((
|
||||
Mesh3d(part.mesh.clone()),
|
||||
MeshMaterial3d(part.material.clone()),
|
||||
Transform::default(),
|
||||
ModelPart {
|
||||
kind: dress.kind,
|
||||
blend: part.blend,
|
||||
},
|
||||
dress.object.clone(),
|
||||
card_follow,
|
||||
));
|
||||
// A card takes its MODEL's indoor law through the same constructor as the sibling meshes it was
|
||||
// split out of (decision 0778), and carries the unit's INSTANCE slot like every sibling part
|
||||
// even though it is never skinned by it — it is a batch of the unit's own model, so a tinted
|
||||
// unit tints its eye-glow and torch cards too (decision 0812). No char-slot variants are
|
||||
// consulted: a body/hair/cape/skin-extra batch is never a billboard batch.
|
||||
card.insert(MeshTag(dress.rig_tag | crate::mesh_tag::alpha_bits(1.0)));
|
||||
// The card's build-time bound (decision 0834) — `calculate_bounds` can no longer derive one
|
||||
// from the `RENDER_WORLD`-only static form's data.
|
||||
if let Some(aabb) = part.aabb {
|
||||
card.insert(aabb);
|
||||
}
|
||||
if let Some(lit) = part_interior_lit(
|
||||
&part.material,
|
||||
part.material_interior.as_ref(),
|
||||
part.material_interior_bake.as_ref(),
|
||||
dress.bake_center,
|
||||
dress.unit,
|
||||
) {
|
||||
card.insert(lit);
|
||||
}
|
||||
// A card shares its batch's per-sequence alpha loops (the billboard split copies them onto every
|
||||
// group), sampled off the same unit clock as the mesh parts.
|
||||
if let Some(anim) = &part.alpha_anim {
|
||||
card.insert(crate::doodad_anim::MatAnim::following(
|
||||
anim.clone(),
|
||||
dress.unit,
|
||||
));
|
||||
}
|
||||
let card = card.id();
|
||||
// The card follows the JOINT when the unit is rigged, so it does not cascade with the anchor's
|
||||
// despawn — the re-dress has to name it to reap it (see [`DressedPart::card`]).
|
||||
commands.entity(anchor).insert(DressedPart {
|
||||
index: index as u32,
|
||||
card: Some(card),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One synthetic batch at `char_slot`/`two_sided`, carrying its own (distinguishable) built
|
||||
/// material set so the fallback arm is visible in the assertions.
|
||||
fn part(char_slot: Option<CharSkinSlot>, two_sided: bool) -> EntityPart {
|
||||
EntityPart {
|
||||
mesh: Handle::default(),
|
||||
aabb: None,
|
||||
skinned_mesh: None,
|
||||
material: mat(0),
|
||||
material_interior: None,
|
||||
material_interior_bake: None,
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None,
|
||||
zfill: None,
|
||||
blend: benilla_formats::ModelBlend::Opaque,
|
||||
additive: false,
|
||||
two_sided,
|
||||
geoset_id: 0,
|
||||
char_slot,
|
||||
billboard: None,
|
||||
alpha_anim: None,
|
||||
rgb_anim: None,
|
||||
ground_quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A distinguishable material handle — a stable-id (`Uuid`) handle, which needs no `Assets`
|
||||
/// store behind it and compares by identity like any other.
|
||||
fn mat(n: u128) -> Handle<WowModelMaterial> {
|
||||
Handle::Uuid(
|
||||
bevy::asset::uuid::Uuid::from_u128(n + 1),
|
||||
std::marker::PhantomData,
|
||||
)
|
||||
}
|
||||
|
||||
/// A set of six distinguishable handles, so which variant landed where is checkable.
|
||||
fn quint(seed: u128) -> super::super::char_skin::MatQuint {
|
||||
(
|
||||
mat(seed * 8),
|
||||
mat(seed * 8 + 1),
|
||||
mat(seed * 8 + 2),
|
||||
mat(seed * 8 + 3),
|
||||
mat(seed * 8 + 4),
|
||||
mat(seed * 8 + 5),
|
||||
)
|
||||
}
|
||||
|
||||
/// The character swap picks a slot's quint — and the BODY (and extra skin) pick by the batch's
|
||||
/// own sidedness, because a robe skirt (geoset 1302) is authored two-sided while the closed body
|
||||
/// is not; flattening them to one variant culled the robe's inner faces (see-through from below).
|
||||
#[test]
|
||||
fn a_character_batch_takes_its_slots_variants_at_its_own_sidedness() {
|
||||
let single = quint(1);
|
||||
let two = quint(2);
|
||||
let hair = quint(3);
|
||||
let mats: CharSkinMaterials = (
|
||||
Some((single.clone(), two.clone())),
|
||||
Some(hair.clone()),
|
||||
None,
|
||||
(None, None),
|
||||
);
|
||||
|
||||
let body_single = part(Some(CharSkinSlot::Body), false);
|
||||
assert_eq!(*part_materials(&body_single, &mats).steady, single.0);
|
||||
let body_two = part(Some(CharSkinSlot::Body), true);
|
||||
assert_eq!(*part_materials(&body_two, &mats).steady, two.0);
|
||||
|
||||
let hair_part = part(Some(CharSkinSlot::Hair), false);
|
||||
let m = part_materials(&hair_part, &mats);
|
||||
assert_eq!(*m.steady, hair.0);
|
||||
assert_eq!(m.interior, Some(&hair.1));
|
||||
assert_eq!(m.fade_blend, Some(&hair.2));
|
||||
assert_eq!(m.bake, Some(&hair.3));
|
||||
assert_eq!(m.bake_blend, Some(&hair.4));
|
||||
}
|
||||
|
||||
/// **`FadeMaterials` is a material RECORD, not a record of having armed a fade.** It used to be
|
||||
/// inserted only alongside the appear-fade arm, so a part spawned by anything that isn't a spawn
|
||||
/// — which, before decision 0835, meant a body rebuilt on *every gear change* — carried none at
|
||||
/// all. Two things read it and both silently stopped working for a player who had swapped so
|
||||
/// much as a shirt: the stream-out fade (`arm_despawn_descendants` gates on it) and, worse, the
|
||||
/// self-avatar first-person feather, whose query takes `&FadeMaterials` outright — so your own
|
||||
/// body stayed solid in your face. The item lane always did it this way; the body lane does now.
|
||||
#[test]
|
||||
fn a_steady_spawn_still_records_its_fade_materials() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
|
||||
.init_asset::<Mesh>();
|
||||
let unit = app.world_mut().spawn(Transform::default()).id();
|
||||
let mut fadeable = part(None, false);
|
||||
fadeable.fade_blend = Some(mat(99));
|
||||
let anchors = std::collections::HashMap::new();
|
||||
let object = WorldObject {
|
||||
kind: ModelKind::Creature,
|
||||
label: String::new(),
|
||||
id: 0,
|
||||
detail: String::new(),
|
||||
};
|
||||
let empty: CharSkinMaterials = (None, None, None, (None, None));
|
||||
let dress = PartDress {
|
||||
unit,
|
||||
kind: ModelKind::Creature,
|
||||
char_mats: &empty,
|
||||
object: &object,
|
||||
rig_tag: 0,
|
||||
inst_slot: 0,
|
||||
rigged: false,
|
||||
anchors: &anchors,
|
||||
bake_center: Vec3::ZERO,
|
||||
idle_aabb: None,
|
||||
now: 0.0,
|
||||
fade: JoinedFade::Steady,
|
||||
};
|
||||
let mut queue = bevy::ecs::world::CommandQueue::default();
|
||||
let armed = {
|
||||
let world = app.world();
|
||||
let mut commands = Commands::new(&mut queue, world);
|
||||
spawn_part(&mut commands, &fadeable, 0, &dress)
|
||||
};
|
||||
queue.apply(app.world_mut());
|
||||
assert!(!armed, "steady: no ramp armed");
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query::<(&FadeMaterials, Has<PendingAppearFade>)>();
|
||||
let found: Vec<_> = q
|
||||
.iter(app.world())
|
||||
.map(|(fm, p)| (fm.blend.clone(), p))
|
||||
.collect();
|
||||
assert_eq!(found.len(), 1, "the record is kept even with no ramp");
|
||||
assert_eq!(found[0].0, mat(99), "…and names the part's own blend twin");
|
||||
assert!(!found[0].1, "…without arming an appear fade");
|
||||
}
|
||||
|
||||
/// A batch with no character slot — and a slot whose row the appearance didn't resolve (a bald
|
||||
/// style has no hair texture, only tauren author an extra skin) — keeps the shared model's own
|
||||
/// built materials. That is the arm a creature, a GameObject and an unresolvable look all take.
|
||||
#[test]
|
||||
fn an_unswapped_batch_keeps_the_models_own_materials() {
|
||||
let empty: CharSkinMaterials = (None, None, None, (None, None));
|
||||
let plain = part(None, false);
|
||||
assert_eq!(*part_materials(&plain, &empty).steady, plain.material);
|
||||
// A slot the look didn't fill falls to the same place, not to another slot's quint.
|
||||
let mats: CharSkinMaterials = (Some((quint(1), quint(2))), None, None, (None, None));
|
||||
let bald = part(Some(CharSkinSlot::Hair), false);
|
||||
assert_eq!(*part_materials(&bald, &mats).steady, bald.material);
|
||||
}
|
||||
}
|
||||
@@ -9,23 +9,17 @@
|
||||
|
||||
use avian3d::prelude::RigidBody;
|
||||
use benilla_assets::ModelSkeleton;
|
||||
use benilla_formats::CharSkinSlot;
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::camera::primitives::MeshAabb;
|
||||
use bevy::camera::visibility::NoFrustumCulling;
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::assets::WorldAssets;
|
||||
use crate::billboard::BillboardCard;
|
||||
use bevy::animation::transition::AnimationTransitions;
|
||||
|
||||
use crate::creature_anim::AnimDriver;
|
||||
use crate::debug_panel::{ModelKind, ModelPart};
|
||||
use crate::debug_panel::ModelKind;
|
||||
use crate::interact::WorldObject;
|
||||
use crate::interior::part_interior_lit;
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
use crate::model_fade::{FadeMaterials, PendingAppearFade};
|
||||
use crate::model_fade::JoinedFade;
|
||||
use crate::net::{NetEntity, ObjectStore};
|
||||
use crate::particles;
|
||||
use crate::player::CameraPivot;
|
||||
@@ -39,8 +33,12 @@ use super::{
|
||||
|
||||
mod char_skin;
|
||||
use char_skin::{build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip};
|
||||
mod dress;
|
||||
use dress::{spawn_part, PartDress};
|
||||
mod glue_preview;
|
||||
pub(super) use glue_preview::build_glue_preview;
|
||||
mod redress;
|
||||
pub(super) use redress::redress_player_looks;
|
||||
|
||||
/// Set up the skinned + animated instance shared by creatures/players (decision 0019) and animated
|
||||
/// GameObjects (decision 0242): the per-instance joints, the billboard glow rig, and — when the model
|
||||
@@ -277,7 +275,6 @@ pub(super) fn attach_entity_visuals(
|
||||
ResMut<Assets<WowModelMaterial>>,
|
||||
ResMut<EntityMaterials>,
|
||||
),
|
||||
meshes: Res<Assets<Mesh>>,
|
||||
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
time: Res<Time>,
|
||||
@@ -670,9 +667,6 @@ pub(super) fn attach_entity_visuals(
|
||||
// instance's identity rather than a skinning detail (decision 0812).
|
||||
let inst_slot = skin.as_ref().map_or(0, |rb| rb.slot);
|
||||
let rig_tag = crate::mesh_tag::rig_bits(inst_slot);
|
||||
// Billboard batches (the brazier/lantern glow card) collected for the world-root card
|
||||
// spawn below — inside the loop we only plant their anchor child.
|
||||
let mut billboard_parts = Vec::new();
|
||||
// The armed idle's **authored** CAaBox (decision 0637) — the mouseover picker's
|
||||
// volume for a skinned part, NOT a culling volume (skinned entity parts are never
|
||||
// frustum-culled; see the `NoFrustumCulling` note at the insert below). The bind-pose
|
||||
@@ -688,284 +682,51 @@ pub(super) fn attach_entity_visuals(
|
||||
bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max)
|
||||
})
|
||||
});
|
||||
commands.entity(entity).with_children(|parent| {
|
||||
for part in parts {
|
||||
// Skip a geoset this character doesn't show (an unselected hair/facial/body variant).
|
||||
if visible_geosets
|
||||
.as_ref()
|
||||
.is_some_and(|vis| !vis.contains(&part.geoset_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A billboard batch (glow card / chain) can't spawn as an ordinary child: its
|
||||
// mesh is centred at the bone pivot and its transform belongs to the billboard
|
||||
// system — as a plain child it renders at the model ORIGIN (the brazier glow on
|
||||
// the ground, decision 0153). A skinned host's card rides its billboard bone's
|
||||
// live JOINT (the mount's lights follow the gait — the joint frame bakes the
|
||||
// pivot, 0130 rig identity); a rest-pose host (GameObject, boneless) gets an
|
||||
// empty ANCHOR child at the root (lifecycle matches the sibling meshes exactly).
|
||||
// The world-root card FOLLOWING it spawns below.
|
||||
if let Some(info) = &part.billboard {
|
||||
let joint = skin
|
||||
.as_ref()
|
||||
.and_then(|rb| rb.anchors.get(&info.bone).copied());
|
||||
// A mirror carrier under the unit for the eye-glow: the portrait / paper-doll
|
||||
// booths mirror the unit's dressed DESCENDANTS, but the visible world card
|
||||
// spawned below is a ROOT entity — never a descendant — so it can't be
|
||||
// mirrored. This lightweight anchor rides the unit's tree tagged with the
|
||||
// glow's quad/material/bone so those booths rebuild it as a booth card
|
||||
// (`crate::portrait::PortraitBillboard`). It doubles as the boneless host's
|
||||
// card follow-anchor; a skinned host follows its live joint, leaving the
|
||||
// anchor mirror-only.
|
||||
let anchor = parent
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
crate::portrait::PortraitBillboard {
|
||||
mesh: part.mesh.clone(),
|
||||
material: part.material.clone(),
|
||||
bone: info.bone,
|
||||
// This host IS rigged, so its billboard bone's booth joint
|
||||
// already bakes the pivot (the 0130 rig identity) — and the
|
||||
// batch belongs to the host's body, so a mount's own glow card
|
||||
// prunes with the mount (`DressedLook::collect`).
|
||||
seat: crate::portrait::PortraitSeat::Body,
|
||||
kind: info.kind,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let (owner, at_joint) = match joint {
|
||||
Some(j) => (j, true),
|
||||
None => (anchor, false),
|
||||
};
|
||||
billboard_parts.push((info.clone(), part, owner, at_joint));
|
||||
continue;
|
||||
}
|
||||
// On a player's character-slot part, swap in the per-appearance material variants
|
||||
// (steady / interior-matte / fade / interior-bake): the body atlas for a body
|
||||
// batch — at the batch's own sidedness (the robe skirt is two-sided; the closed
|
||||
// body isn't) — the hair texture for a hair batch, the worn cape for an object
|
||||
// (type 2) batch, the extra-skin fur (per flavor: opaque core / alpha-cut fringe)
|
||||
// for a type-8 batch; every other part keeps its built ones.
|
||||
let slot_mats =
|
||||
match part.char_slot {
|
||||
Some(CharSkinSlot::Body) => char_mats
|
||||
.0
|
||||
.as_ref()
|
||||
.map(|(single, two)| if part.two_sided { two } else { single }),
|
||||
Some(CharSkinSlot::Hair) => char_mats.1.as_ref(),
|
||||
Some(CharSkinSlot::Object) => char_mats.2.as_ref(),
|
||||
Some(CharSkinSlot::SkinExtra) => {
|
||||
let (single, two) = &char_mats.3;
|
||||
if part.two_sided { two } else { single }.as_ref()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let (mat, mat_interior, fade_blend, mat_bake, mat_bake_blend) = match slot_mats
|
||||
{
|
||||
Some((ext, int, fade, bake, bake_blend)) => {
|
||||
(ext, Some(int), Some(fade), Some(bake), Some(bake_blend))
|
||||
}
|
||||
None => (
|
||||
&part.material,
|
||||
part.material_interior.as_ref(),
|
||||
part.fade_blend.as_ref(),
|
||||
part.material_interior_bake.as_ref(),
|
||||
part.material_interior_bake_blend.as_ref(),
|
||||
),
|
||||
};
|
||||
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the
|
||||
// blend twin with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before
|
||||
// `apply_render_fade` ramps `α = t³`. WMO-display parts (no `fade_blend`) spawn steady.
|
||||
let (init_mat, fading) = match fade_blend {
|
||||
Some(b) => (b.clone(), true),
|
||||
None => (mat.clone(), false),
|
||||
};
|
||||
// The tag's slot field identifies the **instance**, not this part's skinning: it
|
||||
// is the UNIT's slot on every part, even a boneless geoset. The vertex stage only
|
||||
// reads it under `WOW_RIG_SKIN`, which `WowModelExt::specialize` keys on the
|
||||
// mesh's own joint attributes — so a static mesh carrying a slot is not skinned
|
||||
// by it — while the fragment stage reads it for the per-instance body tint
|
||||
// (decision 0812). Giving it to every part is what makes a tinted unit tint
|
||||
// *whole*, which is the reference's own rule: an attached/chained model inherits
|
||||
// the parent CM2's computed colours (`0x714000` recursion). Hoisted above the
|
||||
// loop — it is a property of the unit, not of the part.
|
||||
//
|
||||
// A skinned creature part draws its skinned-mesh twin (the WOW joint
|
||||
// attributes → the owned-palette WOW_RIG_SKIN shader path, decision 0720);
|
||||
// everything else — and the palette-full fallback (slot 0) — the static mesh.
|
||||
// Keyed on the part having a twin, which the slot alone no longer implies.
|
||||
let skinned = inst_slot != 0 && part.skinned_mesh.is_some();
|
||||
let mesh = match &part.skinned_mesh {
|
||||
Some(sm) if skinned => sm.clone(),
|
||||
_ => part.mesh.clone(),
|
||||
};
|
||||
let mut child = parent.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(init_mat),
|
||||
Transform::default(),
|
||||
ModelPart {
|
||||
kind,
|
||||
blend: part.blend,
|
||||
},
|
||||
// The portrait booth mirrors this part ([`crate::portrait`]): both mesh
|
||||
// twins — the booth poses the skinned twin at Stand on its own throwaway
|
||||
// skeleton (the ref bake, wow-re §4 D2), falling back to the static
|
||||
// bind-pose twin for a boneless model — + the steady exterior material
|
||||
// (not the appear-fade/interior variant the child may wear now).
|
||||
crate::portrait::PortraitPart {
|
||||
static_mesh: part.mesh.clone(),
|
||||
skinned_mesh: part.skinned_mesh.clone(),
|
||||
material: mat.clone(),
|
||||
},
|
||||
object.clone(),
|
||||
));
|
||||
// Every part gets a tag: the instance slot above plus the live alpha field. It is
|
||||
// unconditional because the slot is now a per-instance identity every part needs
|
||||
// (the tint) rather than a skinning detail only skinned parts have — and because
|
||||
// the two conditional writers that used to seed it (the interior classifier, the
|
||||
// animated-alpha compose) each covered only their own subset, so a part in
|
||||
// neither carried no tag at all. The alpha honours `fading` like those two did:
|
||||
// the old unconditional `1.0` here was overwritten by them on the parts they
|
||||
// covered, and left a one-frame opaque flash on the parts they didn't.
|
||||
child.insert(MeshTag(
|
||||
rig_tag | crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 }),
|
||||
));
|
||||
// `RigPart` stays gated on actually being skinned by that rig — it is the
|
||||
// CPU-side link for the mouseover picker's skinned ray test (decision 0720),
|
||||
// which has nothing to say about a static part.
|
||||
if skinned {
|
||||
child.insert(crate::rig_palette::RigPart(entity));
|
||||
}
|
||||
if let (Some(_), Some(_)) = (&skin, &part.skinned_mesh) {
|
||||
// A streamed entity's M2 is **never view-culled** — the reference registers
|
||||
// entity render records with effectively-infinite bounds (≈1e7) and the one
|
||||
// frustum cull in its machinery is map-doodad-only (wow-re
|
||||
// `unit-anim-visibility-gate.md` §2/§4: "doodads get the faithful
|
||||
// frustum/occlusion/distance cull; units do not"). Bevy's bind-pose `Aabb`
|
||||
// is the wrong stand-in — an armed idle can leave the bind box entirely
|
||||
// (the duel flag plants itself 9 yd below it, so it culled away at every
|
||||
// ground-level camera) — and a better box inserted here is stomped anyway:
|
||||
// `calculate_bounds`' update query rewrites the `Aabb` of any entity whose
|
||||
// `Mesh3d` changed (spawn tick, async mesh load) back to the bind pose.
|
||||
// So the renderer gets the faithful `NoFrustumCulling`, and the `Aabb` we
|
||||
// insert beside it serves ONE master: the mouseover picker
|
||||
// (`target/hover.rs`) — the armed idle's authored CAaBox when it has one
|
||||
// (it tracks the drawn pose), else the bind box. Both `calculate_bounds`
|
||||
// queries skip `NoFrustumCulling` entities, so this box survives.
|
||||
let picker_aabb = idle_aabb
|
||||
.or_else(|| meshes.get(&part.mesh).and_then(MeshAabb::compute_aabb));
|
||||
if let Some(aabb) = picker_aabb {
|
||||
child.insert((aabb, NoFrustumCulling));
|
||||
}
|
||||
}
|
||||
// M2 parts can light off a WMO room they stand in: a `MeshTag` + the classifier
|
||||
// pick the law by location (0354: the day/night state rides the intensity byte
|
||||
// on the SAME exterior material; only the footprint bake swaps to the probe
|
||||
// variant). While the appear-fade is live the tag carries its alpha (≈0 here)
|
||||
// and the classifier yields ([`RenderFade`]); once the fade latches the
|
||||
// classifier reclaims the tag and the steady material. `mat_interior` (the
|
||||
// interior-capable build) stays the gate for which parts classify at all.
|
||||
// Anchored at the unit root: every part shares the root's verdict, so a body
|
||||
// never splits across the interior/exterior light laws. The indoor LAW is one
|
||||
// for every entity M2 — unit, player, GameObject alike take the footprint-MOCV
|
||||
// bake (the reference registers each with the same entity-node fill, wow-re
|
||||
// `unit-m2-shader-light.md`, superseding 0315's class split); the matte ×1.0
|
||||
// stays as the bake's miss fallback.
|
||||
if let Some(lit) =
|
||||
part_interior_lit(mat, mat_interior, mat_bake, bake_center, entity)
|
||||
{
|
||||
// The tag itself is already seeded above, with this same value — the
|
||||
// classifier composes into it rather than owning it.
|
||||
child.insert(lit);
|
||||
}
|
||||
// The batch's **animated material alpha** (the verified combine's runtime half,
|
||||
// wow-re `m2-alpha-combine-cull.md`): a creature's colour-alpha/transparency
|
||||
// tracks are authored PER SEQUENCE, so which of its batches draw is a function
|
||||
// of what it is playing — a voidwalker's two upper armour pieces are weight 0
|
||||
// in Stand/Walk/Run and 1 only in Death. Sampling follows this unit's own
|
||||
// `AnimationPlayer` (the root, `entity`), so the alpha stays in phase with the
|
||||
// pose. The `A <= 0` cull lands through the `Visibility` authority; the partial
|
||||
// factor through `entities::apply_unit_mat_alpha`. Nothing is inserted for the
|
||||
// overwhelming majority of batches, which author no tracks at all.
|
||||
if let Some(anim) = &part.alpha_anim {
|
||||
// The compose needs a tag to write into; every part has one now (above).
|
||||
child.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
|
||||
}
|
||||
// Queue the appear-fade on M2 parts — `arm_appear_fade` starts the ramp once the world
|
||||
// is on-screen (not behind the loading screen), so it plays where the player sees it.
|
||||
// `FadeMaterials` persists the material pair so the despawn fade-out can re-arm later.
|
||||
// Skipped on a gear-change rebuild (`Reattached`) — a shirt swap isn't a spawn.
|
||||
if let (Some(blend), false) = (fade_blend, reattached) {
|
||||
unit_will_fade = true;
|
||||
child.insert((
|
||||
PendingAppearFade { since: now },
|
||||
FadeMaterials {
|
||||
cutout: mat.clone(),
|
||||
blend: blend.clone(),
|
||||
bake_blend: mat_bake_blend.cloned(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
// The billboard cards (decision 0153): world-root entities following their anchor —
|
||||
// the facing system re-seats each from the anchor's live global transform every frame
|
||||
// (camera-facing around the authored bone pivot, exactly like the doodad path) and
|
||||
// despawns it with the anchor.
|
||||
for (info, part, owner, at_joint) in billboard_parts {
|
||||
let card_follow = if at_joint {
|
||||
BillboardCard::following_joint(&info, owner)
|
||||
// Everything one part's spawn reads from this unit, gathered once (`attach::dress`) —
|
||||
// the same context the gear-change **re-dress** feeds `spawn_part`, so a body built at
|
||||
// stream-in and a geoset that appears when a belt is swapped are dressed by one law.
|
||||
let no_anchors = std::collections::HashMap::new();
|
||||
let dress = PartDress {
|
||||
unit: entity,
|
||||
kind,
|
||||
char_mats: &char_mats,
|
||||
object: &object,
|
||||
rig_tag,
|
||||
inst_slot,
|
||||
rigged: skin.is_some(),
|
||||
anchors: skin.as_ref().map_or(&no_anchors, |rb| &rb.anchors),
|
||||
bake_center,
|
||||
idle_aabb,
|
||||
now,
|
||||
// A fresh visual arms the appear-fade on every fade-capable part (decision 0032).
|
||||
// A rebuild that is NOT a spawn — a mount transition, a display swap — spawns
|
||||
// steady: `Reattached` says the unit was already standing there.
|
||||
fade: if reattached {
|
||||
JoinedFade::Steady
|
||||
} else {
|
||||
BillboardCard::following(&info, owner)
|
||||
};
|
||||
let mut card = commands.spawn((
|
||||
Mesh3d(part.mesh.clone()),
|
||||
MeshMaterial3d(part.material.clone()),
|
||||
Transform::default(),
|
||||
ModelPart {
|
||||
kind,
|
||||
blend: part.blend,
|
||||
},
|
||||
object.clone(),
|
||||
card_follow,
|
||||
));
|
||||
// A card takes its MODEL's indoor law, through the same constructor as the sibling
|
||||
// meshes it was split out of (decision 0778). No char-slot variants are consulted:
|
||||
// a body/hair/cape/skin-extra batch is never a billboard batch, so the slot lookup
|
||||
// living past the `continue` above costs a card nothing.
|
||||
//
|
||||
// A card carries the unit's INSTANCE slot like every sibling part, even though it is
|
||||
// never skinned by it (it draws the static mesh whatever its host — that is what the
|
||||
// vertex stage's attribute gate is for): it is a batch of the unit's own model, so a
|
||||
// tinted unit tints its eye-glow and torch cards too (decision 0812). Seeded
|
||||
// unconditionally for the same reason as the mesh parts — the two writers below cover
|
||||
// only their own subsets.
|
||||
card.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)));
|
||||
if let Some(lit) = part_interior_lit(
|
||||
&part.material,
|
||||
part.material_interior.as_ref(),
|
||||
part.material_interior_bake.as_ref(),
|
||||
bake_center,
|
||||
entity,
|
||||
) {
|
||||
card.insert(lit);
|
||||
}
|
||||
// A card shares its batch's per-sequence alpha loops (the billboard split copies
|
||||
// them onto every group), sampled off the same unit clock as the mesh parts — a
|
||||
// creature's eye glow is authored to vanish in the sequences that hide its eyes.
|
||||
// The classifier composes into the same tag rather than stomping it
|
||||
// (`write_part_law` rewrites only the interior bits), so the tag is seeded here
|
||||
// just for the batches that classified out above.
|
||||
if let Some(anim) = &part.alpha_anim {
|
||||
card.insert(crate::doodad_anim::MatAnim::following(anim.clone(), entity));
|
||||
JoinedFade::Pending { since: now }
|
||||
},
|
||||
};
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
// Skip a geoset this character doesn't show (an unselected hair/facial/body
|
||||
// variant, or a body region its worn gear replaces). The equipment half of that
|
||||
// selection is re-run in place on a gear change (`attach::redress`); this is the
|
||||
// same predicate, evaluated once at build.
|
||||
if visible_geosets
|
||||
.as_ref()
|
||||
.is_some_and(|vis| !vis.contains(&part.geoset_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
unit_will_fade |= spawn_part(&mut commands, part, i, &dress);
|
||||
}
|
||||
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
|
||||
// / helm / shoulder attaching later reads this to join the same ramp
|
||||
// (`entities::equipment::attach_held_items`). A gear-change rebuild (`Reattached`) abandons
|
||||
// any in-flight fade outright — matching the body parts it just rebuilt steady, "a shirt
|
||||
// swap isn't a spawn" — so anything spawned for that same rebuild also spawns steady.
|
||||
// (`entities::equipment::attach_held_items`). A `Reattached` rebuild — a mount
|
||||
// transition, a display swap — abandons any in-flight fade outright, matching the body
|
||||
// parts it just rebuilt steady, so anything spawned for that same rebuild is steady too.
|
||||
// (A GEAR change no longer reaches here at all: it re-dresses in place, and the parts it
|
||||
// spawns join this clock instead of clearing it — `attach::redress`.)
|
||||
if unit_will_fade {
|
||||
commands
|
||||
.entity(entity)
|
||||
@@ -988,8 +749,8 @@ pub(super) fn attach_entity_visuals(
|
||||
} else {
|
||||
Transform::default()
|
||||
};
|
||||
// The equipment this visual was dressed with (decision 0074): `refresh_player_looks`
|
||||
// diffs it against the live resolution and rebuilds the visual on a gear change.
|
||||
// The equipment this visual was dressed with (decision 0074): `redress_player_looks`
|
||||
// diffs it against the live resolution and re-dresses the standing visual on a change.
|
||||
if let (EntityKind::Player, Some(e)) = (net.kind, equipment) {
|
||||
commands
|
||||
.entity(entity)
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
//! **Re-dressing** a character in place when its worn equipment changes — the gear-change half of
|
||||
//! [`super`] (decision 0074's teardown, replaced).
|
||||
//!
|
||||
//! A gear change used to tear the whole visual down (`despawn_related::<Children>()` + strip the rig,
|
||||
//! the animation player, the bone-attach table and the held-item bookkeeping) and let
|
||||
//! [`super::attach_entity_visuals`] rebuild it the next frame. That is not what the reference does,
|
||||
//! and the difference is visible: it destroys and re-creates *every* attached model too — both
|
||||
//! weapons, their enchant glows, the helm, the shoulders, every persistent aura visual — so swapping
|
||||
//! a belt blinked things that had nothing to do with the belt (and, before decision 0833 chained an
|
||||
//! effect's lifetime to its model, smeared their particle clouds across the ground behind a running
|
||||
//! character).
|
||||
//!
|
||||
//! The reference re-dresses on the SAME `CM2Model`. Its character compositor re-blits the dirty
|
||||
//! region groups into the component's own 256² target and re-runs the geoset selection `0x477520`,
|
||||
//! whose only reach into the model is the per-instance visibility array `+0x98` — a range filter
|
||||
//! writing ordinals, nothing more (wow-re `charactermodel.md` "Assembly orchestration";
|
||||
//! `models.md` §"geoset-visibility-default", where the writer census proves the character compositor
|
||||
//! is the *only* thing in the binary that can hide a submesh). Attachments are installed through a
|
||||
//! different mechanism entirely (`0x712f70 CM2Model::attachChild`) and the compositor never touches
|
||||
//! them.
|
||||
//!
|
||||
//! So this system is that law, and only that law:
|
||||
//!
|
||||
//! 1. re-composite the body atlas + re-resolve the cape texture for the new equipment, and re-point
|
||||
//! every standing part's material variants at them ([`super::dress::part_materials`]);
|
||||
//! 2. re-run the geoset selection — a batch the new gear hides is despawned, one it reveals is
|
||||
//! spawned by the same [`super::dress::spawn_part`] the first build used;
|
||||
//! 3. touch nothing else. The rig, the pose buffer, the bone anchors, the held items and everything
|
||||
//! hanging off them are the same objects they were a frame ago.
|
||||
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::assets::WorldAssets;
|
||||
use crate::interior::InteriorLit;
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
use crate::model_fade::{join_unit_appear_fade, FadeMaterials, PendingAppearFade, RenderFade};
|
||||
use crate::net::{NetEntity, ObjectStore};
|
||||
use crate::portrait::PortraitPart;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
use super::super::equipment::AppliedEquipment;
|
||||
use super::super::{
|
||||
Characters, Creatures, EntityMaterials, EntityPart, Equipment, ItemDisplays, SkinComposites,
|
||||
SkinSections, VisualAttached,
|
||||
};
|
||||
use super::char_skin::{
|
||||
build_char_skin_materials, equip_geosets, resolve_char_look, resolve_worn_equip,
|
||||
CharSkinMaterials,
|
||||
};
|
||||
use super::dress::{part_materials, spawn_part, DressedPart, PartDress};
|
||||
|
||||
/// The per-part write surface of a re-dress: the displayed material and the three records that
|
||||
/// decide what a part draws when something *else* owns that channel — the interior classifier's law
|
||||
/// variants, the fade ramps' twin pair, and the portrait booths' steady mirror. All optional
|
||||
/// because a [`DressedPart`] is also carried by a billboard batch's **anchor**, which draws nothing
|
||||
/// (its card is a world root) and holds none of them.
|
||||
type PartWrites<'a> = (
|
||||
Option<Mut<'a, MeshMaterial3d<WowModelMaterial>>>,
|
||||
Option<Mut<'a, InteriorLit>>,
|
||||
Option<Mut<'a, FadeMaterials>>,
|
||||
Option<Mut<'a, PortraitPart>>,
|
||||
);
|
||||
|
||||
/// Re-dress every player whose worn equipment changed, in place (module docs). Players only: a
|
||||
/// creature's look never changes this way, and a character-model NPC wears its display's columns —
|
||||
/// a *display* change is a different model and stays a teardown
|
||||
/// ([`super::super::live_display::refresh_live_display`]).
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub(in crate::entities) fn redress_player_looks(
|
||||
mut commands: Commands,
|
||||
mut players: Query<
|
||||
(
|
||||
Entity,
|
||||
&NetEntity,
|
||||
&Equipment,
|
||||
&mut AppliedEquipment,
|
||||
&Children,
|
||||
Option<&crate::rig_palette::RigSkin>,
|
||||
Option<&super::super::BoneAttach>,
|
||||
Option<&crate::interior::BodyBakeCenter>,
|
||||
Option<&crate::model_fade::UnitAppearFade>,
|
||||
),
|
||||
With<VisualAttached>,
|
||||
>,
|
||||
// The parts already standing under those players — found again by the batch index each carries.
|
||||
mut standing: Query<(
|
||||
&DressedPart,
|
||||
Option<&mut MeshMaterial3d<WowModelMaterial>>,
|
||||
Option<&mut InteriorLit>,
|
||||
Option<&mut FadeMaterials>,
|
||||
Option<&mut PortraitPart>,
|
||||
Has<RenderFade>,
|
||||
Has<PendingAppearFade>,
|
||||
)>,
|
||||
stores: Query<&ObjectStore>,
|
||||
creatures: Option<Res<Creatures>>,
|
||||
displays: Option<Res<ItemDisplays>>,
|
||||
characters: Option<Res<Characters>>,
|
||||
// The character-skin build chain, nested to stay inside Bevy's system-param tuple limit — the
|
||||
// same set [`super::attach_entity_visuals`] composites with, because it is the same composite.
|
||||
skin_build: (
|
||||
Option<Res<SkinSections>>,
|
||||
Option<Res<WorldAssets>>,
|
||||
Option<Res<SharedLightBuffer>>,
|
||||
ResMut<Assets<Image>>,
|
||||
ResMut<SkinComposites>,
|
||||
Res<AssetServer>,
|
||||
ResMut<Assets<WowModelMaterial>>,
|
||||
ResMut<EntityMaterials>,
|
||||
),
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let (
|
||||
sections,
|
||||
world_assets,
|
||||
shared_light,
|
||||
mut images,
|
||||
mut skin_composites,
|
||||
asset_server,
|
||||
mut materials,
|
||||
mut entity_mats,
|
||||
) = skin_build;
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, net, live, mut applied, children, rig, bones, bake_center, unit_fade) in
|
||||
&mut players
|
||||
{
|
||||
// `settled` (decision 0074): every non-empty visible-item entry has resolved through the
|
||||
// template cache. Re-dressing on a half-resolved set would composite a half-dressed atlas
|
||||
// and then composite again a frame later.
|
||||
if net.kind != EntityKind::Player || !live.settled || *live == applied.0 {
|
||||
continue;
|
||||
}
|
||||
// Stamp first, unconditionally: a player whose display resolved to no model at all (the
|
||||
// cube fallback) has nothing to re-dress, and must not re-enter this arm every frame.
|
||||
applied.0 = *live;
|
||||
let Some(dm) = net
|
||||
.display_id
|
||||
.and_then(|disp| creatures.as_deref()?.models.get(&disp))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(parts) = dm.parts.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// The dressing inputs, re-resolved exactly as the first build resolved them.
|
||||
let worn = resolve_worn_equip(net, Some(live), Some(dm));
|
||||
let look = resolve_char_look(net, Some(dm), entity, &stores);
|
||||
let eg = equip_geosets(displays.as_deref(), &worn.bodyslots, worn.cloak, worn.helm);
|
||||
let visible: Option<Vec<u16>> = look.as_ref().and_then(|l| {
|
||||
let cg = characters.as_deref()?;
|
||||
Some(cg.0.visible_geosets(l.race, l.sex, l.hair_style, l.facial_hair, &eg))
|
||||
});
|
||||
// The re-composite. Cached per (appearance, worn set) in `SkinComposites`, so a swap back
|
||||
// to a set already worn this session costs a lookup.
|
||||
let char_mats: CharSkinMaterials = match look.as_ref() {
|
||||
Some(l) => build_char_skin_materials(
|
||||
l,
|
||||
worn.bodyslots,
|
||||
worn.cloak,
|
||||
displays.as_deref(),
|
||||
sections.as_deref(),
|
||||
world_assets.as_deref(),
|
||||
shared_light.as_deref(),
|
||||
parts,
|
||||
&mut images,
|
||||
&mut skin_composites.0,
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
&mut entity_mats.0,
|
||||
),
|
||||
None => (None, None, None, (None, None)),
|
||||
};
|
||||
// No look (a druid form, a GM morph — a Player-kind entity on a beast display) means no
|
||||
// geoset filter, exactly as at build: every batch the model authors draws.
|
||||
let shows = |geoset: u16| visible.as_ref().is_none_or(|v| v.contains(&geoset));
|
||||
|
||||
// Pass 1 — the parts already standing: hide-by-despawn, or re-point.
|
||||
let mut present = vec![false; parts.len()];
|
||||
let (mut hidden, mut repointed) = (0usize, 0usize);
|
||||
for child in children.iter() {
|
||||
let Ok((dp, mat, lit, fade_mats, portrait, ramping, pending)) = standing.get_mut(child)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let dp = *dp;
|
||||
let Some(part) = parts.get(dp.index as usize) else {
|
||||
continue; // a stale index (the model rebuilt under us) — leave it to the teardown
|
||||
};
|
||||
if !shows(part.geoset_id) {
|
||||
// A billboard batch's card is a world ROOT following this anchor's joint, so it does
|
||||
// not cascade — reap it by name (decision 0153's lifecycle, [`DressedPart::card`]).
|
||||
if let Some(card) = dp.card {
|
||||
if let Ok(mut ec) = commands.get_entity(card) {
|
||||
ec.despawn();
|
||||
}
|
||||
}
|
||||
commands.entity(child).despawn();
|
||||
hidden += 1;
|
||||
continue;
|
||||
}
|
||||
present[dp.index as usize] = true;
|
||||
// A card's material is the model's own batch material — never a character slot — so a
|
||||
// billboard part has nothing to re-point.
|
||||
if part.billboard.is_none() {
|
||||
repoint_part(
|
||||
part,
|
||||
&char_mats,
|
||||
(mat, lit, fade_mats, portrait),
|
||||
ramping || pending,
|
||||
);
|
||||
repointed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — the batches the new gear reveals. They join the unit's own appear-fade clock if
|
||||
// one is still in flight (the login gear cascade lands mid-ramp), exactly as a
|
||||
// late-resolving held item does — never a second ramp of their own, never a pop.
|
||||
let no_anchors = std::collections::HashMap::new();
|
||||
let dress = PartDress {
|
||||
unit: entity,
|
||||
kind: crate::debug_panel::ModelKind::Creature,
|
||||
char_mats: &char_mats,
|
||||
object: &crate::interact::WorldObject {
|
||||
kind: crate::debug_panel::ModelKind::Creature,
|
||||
label: super::display_label(&dm.handle),
|
||||
id: net.display_id.unwrap_or(0),
|
||||
detail: format!("emitters: {}", dm.emitters.len()),
|
||||
},
|
||||
rig_tag: crate::mesh_tag::rig_bits(rig.map_or(0, |r| r.slot)),
|
||||
inst_slot: rig.map_or(0, |r| r.slot),
|
||||
rigged: bones.is_some(),
|
||||
anchors: bones.map_or(&no_anchors, |b| &b.anchors),
|
||||
bake_center: bake_center.map_or(dm.bake_center_local, |c| c.0),
|
||||
idle_aabb: idle_aabb(dm),
|
||||
now,
|
||||
fade: join_unit_appear_fade(unit_fade.copied()),
|
||||
};
|
||||
let mut shown = 0usize;
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if present[i] || !shows(part.geoset_id) {
|
||||
continue;
|
||||
}
|
||||
spawn_part(&mut commands, part, i, &dress);
|
||||
shown += 1;
|
||||
}
|
||||
// One line per re-dress — a handful per session, and the only readout of a mechanism whose
|
||||
// whole point is that nothing else moves. `atlas` is the composited body material's id: it
|
||||
// must CHANGE when the worn set changes a body region, which is what says the re-composite
|
||||
// reached the parts rather than merely being built. The counts say which batches the new
|
||||
// gear hid and revealed. Nothing about the unit's attachments appears here because this
|
||||
// system cannot touch them.
|
||||
info!(
|
||||
"redress: {entity} — {hidden} batch(es) hidden, {shown} shown, {repointed} re-pointed, \
|
||||
atlas {:?}",
|
||||
char_mats.0.as_ref().map(|(single, _)| single.0.id()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-point one standing part at the freshly-built material set its unit now wears.
|
||||
///
|
||||
/// The three records are updated whatever the part is doing, because each is read by a *different*
|
||||
/// owner at a different moment: the interior classifier resolves the law's steady material, the fade
|
||||
/// ramps resolve their twin every frame from [`FadeMaterials`] + the law, and the portrait booths
|
||||
/// mirror the steady one whenever a booth rebuilds. The **displayed** material is only written when
|
||||
/// no ramp owns that channel — a part mid-appear-fade wears its blend twin, and
|
||||
/// [`crate::model_fade::apply_render_fade`] re-resolves it from the two records above on its next
|
||||
/// tick, so writing the steady handle here would flash it opaque for a frame.
|
||||
fn repoint_part(
|
||||
part: &EntityPart,
|
||||
char_mats: &CharSkinMaterials,
|
||||
(mat, lit, fade_mats, portrait): PartWrites,
|
||||
fading: bool,
|
||||
) {
|
||||
// A batch with no character texture slot draws its shared model's own built materials, which no
|
||||
// amount of gear can change — the body atlas, the hair, the cape and the extra skin are the
|
||||
// whole of what a re-dress can touch.
|
||||
if part.char_slot.is_none() {
|
||||
return;
|
||||
}
|
||||
let m = part_materials(part, char_mats);
|
||||
if let Some(mut fm) = fade_mats {
|
||||
fm.cutout = m.steady.clone();
|
||||
if let Some(blend) = m.fade_blend {
|
||||
fm.blend = blend.clone();
|
||||
}
|
||||
fm.bake_blend = m.bake_blend.cloned();
|
||||
}
|
||||
if let Some(mut p) = portrait {
|
||||
p.material = m.steady.clone();
|
||||
}
|
||||
let want = match lit {
|
||||
Some(mut lit) => lit.repoint(m.steady, m.bake).clone(),
|
||||
None => m.steady.clone(),
|
||||
};
|
||||
if let Some(mut mat) = mat {
|
||||
if !fading && mat.0 != want {
|
||||
mat.0 = want;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The armed idle's authored CAaBox — the mouseover picker's volume for a skinned part (decision
|
||||
/// 0637). Same read as the first build's; see the note there for why the bind box is not a fair
|
||||
/// stand-in.
|
||||
fn idle_aabb(dm: &super::super::DisplayModel) -> Option<bevy::camera::primitives::Aabb> {
|
||||
let anims = dm.animations.as_ref()?;
|
||||
let clip = anims.first_seq.and_then(|i| anims.clips.get(i))?;
|
||||
(clip.bounds_max.cmpgt(clip.bounds_min).all())
|
||||
.then(|| bevy::camera::primitives::Aabb::from_min_max(clip.bounds_min, clip.bounds_max))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::{CharacterGeosets, ItemDisplay, ItemDisplayCatalog, NpcAppearance};
|
||||
|
||||
use super::super::super::display::{empty_display, EntityPart};
|
||||
use super::super::super::{BoneAttach, Creatures};
|
||||
use super::*;
|
||||
|
||||
/// One synthetic body batch at `geoset`.
|
||||
fn part(geoset: u16) -> EntityPart {
|
||||
EntityPart {
|
||||
mesh: Handle::default(),
|
||||
aabb: None,
|
||||
skinned_mesh: None,
|
||||
material: Handle::default(),
|
||||
material_interior: None,
|
||||
material_interior_bake: None,
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None,
|
||||
zfill: None,
|
||||
blend: benilla_formats::ModelBlend::Opaque,
|
||||
additive: false,
|
||||
two_sided: false,
|
||||
geoset_id: geoset,
|
||||
char_slot: None,
|
||||
billboard: None,
|
||||
alpha_anim: None,
|
||||
rgb_anim: None,
|
||||
ground_quad: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A player standing fully dressed: the display model behind it, the resource set the re-dress
|
||||
/// reads, and — the point of the whole change — a bone anchor with a held item hanging off it,
|
||||
/// exactly where a weapon and its enchant glow live.
|
||||
struct Standing {
|
||||
app: App,
|
||||
player: Entity,
|
||||
joint: Entity,
|
||||
held: Entity,
|
||||
}
|
||||
|
||||
/// Build one. `geosets` are the model's batches in order; `showing` the batch indices already
|
||||
/// spawned as children (what the first build selected); `characters` the geoset tables (absent ⇒
|
||||
/// no filter at all, the druid-form / beast-display arm).
|
||||
fn stand(geosets: &[u16], showing: &[usize], characters: Option<CharacterGeosets>) -> Standing {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
|
||||
.init_asset::<Mesh>()
|
||||
.init_asset::<Image>()
|
||||
.init_asset::<WowModelMaterial>()
|
||||
.init_resource::<SkinComposites>()
|
||||
.init_resource::<EntityMaterials>();
|
||||
|
||||
let mut dm = empty_display();
|
||||
dm.parts = Some(geosets.iter().map(|g| part(*g)).collect());
|
||||
// The look comes off the DISPLAY here (a character-model NPC row), which is the same
|
||||
// `CharLook` the wire path builds — and needs no `ObjectStore` to stand one up.
|
||||
dm.npc_appearance = Some(NpcAppearance {
|
||||
race: 1,
|
||||
sex: 0,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
equipment: [0; 10],
|
||||
bake_name: None,
|
||||
});
|
||||
app.insert_resource(Creatures {
|
||||
catalog: Default::default(),
|
||||
models: HashMap::from([(42u32, dm)]),
|
||||
});
|
||||
// Display 7 = a pair of gloves whose `geosetGroup[0]` is 1 (→ geoset 402, the glove group's
|
||||
// own range disabled).
|
||||
app.insert_resource(ItemDisplays::icons_for_tests(
|
||||
ItemDisplayCatalog::from_displays(HashMap::from([(
|
||||
7u32,
|
||||
ItemDisplay {
|
||||
geoset_groups: [1, 0, 0],
|
||||
..Default::default()
|
||||
},
|
||||
)])),
|
||||
));
|
||||
if let Some(cg) = characters {
|
||||
app.insert_resource(Characters(cg));
|
||||
}
|
||||
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
NetEntity {
|
||||
kind: EntityKind::Player,
|
||||
display_id: Some(42),
|
||||
scale: 1.0,
|
||||
},
|
||||
Equipment {
|
||||
settled: true,
|
||||
..Default::default()
|
||||
},
|
||||
AppliedEquipment(Equipment {
|
||||
settled: true,
|
||||
..Default::default()
|
||||
}),
|
||||
VisualAttached,
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
))
|
||||
.id();
|
||||
// The rig's bone anchor and the item hanging off it — the weapon root a gear change used to
|
||||
// take with it.
|
||||
let joint = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(player)))
|
||||
.id();
|
||||
let held = app
|
||||
.world_mut()
|
||||
.spawn((Transform::default(), Visibility::default(), ChildOf(joint)))
|
||||
.id();
|
||||
app.world_mut().entity_mut(player).insert(BoneAttach {
|
||||
anchors: HashMap::from([(3u16, joint)]),
|
||||
points: HashMap::new(),
|
||||
markers: HashMap::new(),
|
||||
});
|
||||
for &i in showing {
|
||||
app.world_mut().spawn((
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
ChildOf(player),
|
||||
MeshMaterial3d(Handle::<WowModelMaterial>::default()),
|
||||
DressedPart {
|
||||
index: i as u32,
|
||||
card: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
app.add_systems(Update, redress_player_looks);
|
||||
app.update();
|
||||
Standing {
|
||||
app,
|
||||
player,
|
||||
joint,
|
||||
held,
|
||||
}
|
||||
}
|
||||
|
||||
impl Standing {
|
||||
/// Swap the player's gear and run one re-dress pass.
|
||||
fn wear(&mut self, gear: Equipment) {
|
||||
*self
|
||||
.app
|
||||
.world_mut()
|
||||
.get_mut::<Equipment>(self.player)
|
||||
.unwrap() = gear;
|
||||
self.app.update();
|
||||
}
|
||||
|
||||
/// The batch indices currently standing under the player.
|
||||
fn showing(&mut self) -> Vec<u32> {
|
||||
let mut out: Vec<u32> = self
|
||||
.app
|
||||
.world_mut()
|
||||
.query::<&DressedPart>()
|
||||
.iter(self.app.world())
|
||||
.map(|d| d.index)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// **The director's report.** A gear change used to `despawn_related::<Children>()` the whole
|
||||
/// visual, taking both weapons, their enchant glows, the helm, the shoulders and every aura
|
||||
/// visual with it — everything hanging off a bone was destroyed and re-created because a belt
|
||||
/// changed. Nothing on the unit but its own body batches may be touched.
|
||||
#[test]
|
||||
fn a_gear_change_leaves_the_rig_and_every_attachment_standing() {
|
||||
let mut s = stand(&[0, 401], &[0, 1], None);
|
||||
s.wear(Equipment {
|
||||
cloak: 9,
|
||||
settled: true,
|
||||
..Default::default()
|
||||
});
|
||||
let w = s.app.world();
|
||||
assert!(w.get_entity(s.joint).is_ok(), "the bone anchor survives");
|
||||
assert!(w.get_entity(s.held).is_ok(), "the held item survives");
|
||||
assert!(
|
||||
w.get::<BoneAttach>(s.player).is_some(),
|
||||
"the attach table survives",
|
||||
);
|
||||
assert_eq!(
|
||||
s.showing(),
|
||||
vec![0, 1],
|
||||
"the body batches are the same ones"
|
||||
);
|
||||
}
|
||||
|
||||
/// …and the diff is stamped, so the re-dress fires **once** per change rather than every frame
|
||||
/// (the composite behind it is a synchronous BLP read on a miss).
|
||||
#[test]
|
||||
fn a_gear_change_restamps_what_the_visual_is_dressed_with() {
|
||||
let mut s = stand(&[0], &[0], None);
|
||||
let gear = Equipment {
|
||||
cloak: 9,
|
||||
settled: true,
|
||||
..Default::default()
|
||||
};
|
||||
s.wear(gear);
|
||||
assert_eq!(
|
||||
s.app.world().get::<AppliedEquipment>(s.player).unwrap().0,
|
||||
gear,
|
||||
);
|
||||
}
|
||||
|
||||
/// The **geoset half**, against the shipped customization tables: putting gloves on replaces the
|
||||
/// glove group — the naked 401 batch stops drawing and the item's 402 batch starts — and that
|
||||
/// now happens by despawning/spawning exactly those two batches instead of rebuilding the unit.
|
||||
/// (The reference flips two entries of the model's own visibility array; the visible result is
|
||||
/// the same set, which is what this pins.)
|
||||
#[test]
|
||||
fn worn_gloves_replace_the_glove_geoset_in_place() {
|
||||
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let cg = CharacterGeosets::load(&mut chain).expect("customization tables");
|
||||
|
||||
let mut s = stand(&[0, 401, 402], &[0, 1], Some(cg));
|
||||
assert_eq!(s.showing(), vec![0, 1], "bare-handed to start");
|
||||
let mut gear = Equipment {
|
||||
settled: true,
|
||||
..Default::default()
|
||||
};
|
||||
gear.bodyslots[6] = 7; // the gloves slot
|
||||
s.wear(gear);
|
||||
assert_eq!(
|
||||
s.showing(),
|
||||
vec![0, 2],
|
||||
"the glove batch replaced the bare-hand one",
|
||||
);
|
||||
assert!(
|
||||
s.app.world().get_entity(s.held).is_ok(),
|
||||
"and the held item never moved",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,9 @@ pub(super) fn attach_ground_fx_models(
|
||||
dm,
|
||||
now,
|
||||
true, // a dest-anchored area model's flat quads ARE ground decals
|
||||
None, // a free world model — any trail stays world-frozen
|
||||
// A free world model standing at the point, chained to nothing: its trail stays
|
||||
// world-frozen and its pool finishes in place when the effect ends.
|
||||
super::spell_fx::EffectHost::default(),
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
|
||||
@@ -28,9 +28,15 @@ pub(super) enum ModelHandle {
|
||||
}
|
||||
|
||||
/// One spawn part of a display model: a submesh's mesh + its built `WowModelMaterial` + blend (for the
|
||||
/// `ModelPart` toggle). Built once the model asset loads.
|
||||
/// `ModelPart` toggle). Built once the model asset loads **and** the model-forms furnisher has
|
||||
/// built its render forms (decision 0834 — the handles here come from the app-side cache, not the
|
||||
/// loader).
|
||||
pub(super) struct EntityPart {
|
||||
pub(super) mesh: Handle<Mesh>,
|
||||
/// The static form's build-time `Aabb` (decision 0834): the static mesh is `RENDER_WORLD`-only,
|
||||
/// so a consumer that used to compute a bound from its main-world data (the attach path's
|
||||
/// picker-volume fallback) reads this instead. `None` for degenerate geometry.
|
||||
pub(super) aabb: Option<bevy::camera::primitives::Aabb>,
|
||||
/// The skinned twin of [`Self::mesh`] (decision 0019), present for every M2 part. An animated
|
||||
/// instance (a creature, or a GameObject that runs the state machine / loops a loader-idle seq)
|
||||
/// renders this, skinned through its palette slot's rows (decision 0720); a truly static
|
||||
@@ -56,6 +62,14 @@ pub(super) struct EntityPart {
|
||||
/// the entity feathers in on this while `α < 1`, then swaps back to `material`. `Some` for M2 parts
|
||||
/// (creatures + M2 GameObjects, the CGObjects that appear-fade); `None` for WMO-display parts.
|
||||
pub(super) fade_blend: Option<Handle<WowModelMaterial>>,
|
||||
/// The **depth-prime twin** material ([`crate::model_render::zfill_material`] — the reference's
|
||||
/// `M2UseZFill` clone, wow-re `m2-blend-promotion-zfill.md` §4). While the part draws
|
||||
/// translucent, `model_fade::sync_zfill_twins` spawns a child mesh on this: colour-masked,
|
||||
/// blend-off, z-writing, sorted before the colour parts — one blended layer everywhere, no
|
||||
/// self-overlap darkening on a stealthed or fading body. `None` when the batch's material
|
||||
/// disables z-write/z-test (the reference's own `(flags & 0x10) == 0` twin gate) or cannot
|
||||
/// fade at all (Mod/Mod2x), and for WMO-display parts.
|
||||
pub(super) zfill: Option<Handle<WowModelMaterial>>,
|
||||
pub(super) blend: ModelBlend,
|
||||
/// Whether this part blends **additively** (M2 blend mode 3 `NoAlphaAdd` / 4 `Add`).
|
||||
/// [`ModelBlend`] deliberately folds "alpha-blended / additive" into its single `Blend`
|
||||
@@ -300,6 +314,10 @@ pub(super) fn build_parts(
|
||||
dm: &mut DisplayModel,
|
||||
m2s: &Assets<M2Model>,
|
||||
wmos: &Assets<WmoModel>,
|
||||
// The app-built render forms (decision 0834): parts wait for the furnisher exactly as they
|
||||
// wait for the asset. Entity displays request at priority 0 — a mob walking into view never
|
||||
// queues behind a city crossing's scenery (whose placements request at 16+).
|
||||
forms: &mut crate::model_forms::ModelForms,
|
||||
asset_server: &AssetServer,
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
cache: &mut MaterialCache,
|
||||
@@ -330,6 +348,14 @@ pub(super) fn build_parts(
|
||||
let Some(model) = m2s.get(h) else {
|
||||
return; // still loading
|
||||
};
|
||||
// Every M2 entity lane can rig (creatures, animated GameObjects, held items with
|
||||
// billboard chains), so both forms are requested; the static form still serves the
|
||||
// truly static instances and the billboard cards.
|
||||
let key = crate::model_forms::ModelKey::from(h);
|
||||
let want = crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED;
|
||||
if !forms.require(key, want, 0) {
|
||||
return; // forms still building — same retry-next-frame as the asset itself
|
||||
}
|
||||
emitters = model.emitters.clone();
|
||||
ribbons = model.ribbons.clone();
|
||||
lights = model.lights.clone();
|
||||
@@ -381,10 +407,13 @@ pub(super) fn build_parts(
|
||||
if gameobject {
|
||||
collider = model.collision.as_ref().and_then(model_local_collider);
|
||||
}
|
||||
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
|
||||
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
|
||||
model
|
||||
.submeshes
|
||||
.iter()
|
||||
.map(|sub| {
|
||||
.enumerate()
|
||||
.map(|(pi, sub)| {
|
||||
let texture = resolve_skin(
|
||||
sub,
|
||||
&dm.dir,
|
||||
@@ -425,6 +454,26 @@ pub(super) fn build_parts(
|
||||
// batch (Mod/Mod2x) gets NO twin: its blend equation reads no alpha, so it cannot
|
||||
// fade — the reference's instanceAlpha ramp leaves the sheen at full strength while
|
||||
// the base fades under it (decision 0528); `None` spawns the part Steady.
|
||||
// The depth-prime twin (wow-re `m2-blend-promotion-zfill.md` §4): every batch
|
||||
// that can fade AND writes depth gets one. `cutout` mirrors what the part's
|
||||
// colour pass discards while translucent — the fade twin's hard 224/255 for
|
||||
// Opaque/AlphaKey sources, nothing for authored-Blend sources (their colour
|
||||
// pass is the plain blend material) — so depth and colour coverage agree.
|
||||
let zfill = if sub.no_depth_write || sub.no_depth_test {
|
||||
None
|
||||
} else {
|
||||
match sub.blend {
|
||||
ModelBlend::Mod | ModelBlend::Mod2x => None,
|
||||
b => Some(crate::model_render::zfill_material(
|
||||
cache,
|
||||
materials,
|
||||
texture.clone(),
|
||||
sub.two_sided,
|
||||
b != ModelBlend::Blend,
|
||||
light,
|
||||
)),
|
||||
}
|
||||
};
|
||||
let fade_blend = match sub.blend {
|
||||
ModelBlend::Mod | ModelBlend::Mod2x => None,
|
||||
ModelBlend::Blend => Some(exterior.clone()),
|
||||
@@ -544,13 +593,20 @@ pub(super) fn build_parts(
|
||||
light,
|
||||
);
|
||||
EntityPart {
|
||||
mesh: sub.mesh.clone(),
|
||||
skinned_mesh: Some(sub.skinned_mesh.clone()),
|
||||
// Index-parallel with the submeshes by the forms contract; a miss is a
|
||||
// broken contract — a default (dead) handle draws nothing rather than panic.
|
||||
mesh: stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
|
||||
skinned_mesh: skin_forms.get(pi).cloned(),
|
||||
material: exterior,
|
||||
material_interior: Some(interior),
|
||||
material_interior_bake: interior_bake,
|
||||
material_interior_bake_blend: interior_bake_blend,
|
||||
fade_blend,
|
||||
zfill,
|
||||
blend: sub.blend,
|
||||
additive: sub.additive,
|
||||
two_sided: sub.two_sided,
|
||||
@@ -568,14 +624,25 @@ pub(super) fn build_parts(
|
||||
let Some(model) = wmos.get(h) else {
|
||||
return;
|
||||
};
|
||||
// WMO-display GameObjects never skin — static forms only.
|
||||
let key = crate::model_forms::ModelKey::from(h);
|
||||
if !forms.require(key, crate::model_forms::WANT_STATIC, 0) {
|
||||
return;
|
||||
}
|
||||
if gameobject {
|
||||
collider = model.collision.as_ref().and_then(model_local_collider);
|
||||
}
|
||||
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
|
||||
model
|
||||
.submeshes
|
||||
.iter()
|
||||
.map(|sub| EntityPart {
|
||||
mesh: sub.mesh.clone(),
|
||||
.enumerate()
|
||||
.map(|(pi, sub)| EntityPart {
|
||||
mesh: stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
aabb: stat_forms.get(pi).and_then(|(_, a)| *a),
|
||||
geoset_id: sub.geoset_id, // 0 for WMO — no geoset selection
|
||||
char_slot: None, // WMO is never a character body
|
||||
skinned_mesh: None, // WMO-display GameObjects don't skin (no skeleton)
|
||||
@@ -606,6 +673,7 @@ pub(super) fn build_parts(
|
||||
material_interior_bake: None, // …and never the M2 footprint lane
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None, // WMO-display GameObjects don't appear-fade (rare; M2 only)
|
||||
zfill: None, // …so they never need the depth-prime twin either
|
||||
blend: sub.blend,
|
||||
additive: false, // WMO MOMT carries no additive mode (`RenderSubmesh::additive`)
|
||||
two_sided: sub.two_sided,
|
||||
|
||||
@@ -30,12 +30,10 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::ItemDisplayCatalog;
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{DisplayModel, ModelHandle};
|
||||
use crate::model_render::m2_url;
|
||||
use crate::net::NetEntity;
|
||||
|
||||
mod resolve;
|
||||
pub(in crate::entities) use resolve::placement;
|
||||
@@ -118,7 +116,7 @@ impl ItemDisplays {
|
||||
/// every non-empty visible-item entry has resolved through the template cache (hit or recorded
|
||||
/// miss) — the attach path waits for it so a player composites dressed, not naked-then-flicker.
|
||||
/// Players only; a character-model NPC's armor ships pre-baked (decision 0060).
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||
pub(crate) struct Equipment {
|
||||
pub(crate) bodyslots: [u32; 8],
|
||||
/// The back slot's display id (0 = no cloak) — a geoset + runtime cape texture, no body region.
|
||||
@@ -129,57 +127,20 @@ pub(crate) struct Equipment {
|
||||
pub(crate) settled: bool,
|
||||
}
|
||||
|
||||
/// The [`Equipment`] a player's attached visual was built with (its composite key's equip half).
|
||||
/// [`refresh_player_looks`] diffs it against the live resolution and re-attaches on change.
|
||||
/// The [`Equipment`] a player's attached visual was **dressed** with (its composite key's equip
|
||||
/// half). [`super::attach::redress_player_looks`] diffs it against the live resolution and re-dresses
|
||||
/// the standing visual in place on a change — the reference's own shape, and the reason a weapon
|
||||
/// glow no longer blinks when a belt is swapped (decision 0835, superseding 0074's teardown).
|
||||
#[derive(Component)]
|
||||
pub(super) struct AppliedEquipment(pub(super) Equipment);
|
||||
pub(in crate::entities) struct AppliedEquipment(pub(in crate::entities) Equipment);
|
||||
|
||||
/// Marks a player whose visual was torn down by [`refresh_player_looks`] (a gear change): the
|
||||
/// re-attach skips the appear-fade — changing a shirt isn't a spawn.
|
||||
/// Marks a unit whose visual was torn down and is being rebuilt for something **other** than a
|
||||
/// spawn: the re-attach skips the appear-fade. A mount transition ([`super::mount`]) and a live
|
||||
/// display swap ([`super::live_display`]) are the two remaining tear-downs; a gear change is not one
|
||||
/// of them any more (decision 0835).
|
||||
#[derive(Component)]
|
||||
pub(super) struct Reattached;
|
||||
|
||||
/// Rebuild a player's visual when their worn equipment changes (decision 0074): the composite atlas
|
||||
/// (and, later, the equipment geosets) are baked into the attached children, so a gear change tears
|
||||
/// the visual down — every child (parts, joints, held-item roots) plus the per-instance visual
|
||||
/// components — and lets [`super::attach_entity_visuals`] re-run next frame with the new
|
||||
/// [`Equipment`], fade-skipped via [`Reattached`]. Players only: a creature's look never changes
|
||||
/// this way (weapon swaps ride [`HeldItems`]' own diff, no teardown).
|
||||
pub(super) fn refresh_player_looks(
|
||||
mut commands: Commands,
|
||||
players: Query<
|
||||
(Entity, &NetEntity, &Equipment, &AppliedEquipment),
|
||||
With<super::VisualAttached>,
|
||||
>,
|
||||
) {
|
||||
for (entity, net, live, applied) in &players {
|
||||
if net.kind != EntityKind::Player || !live.settled || *live == applied.0 {
|
||||
continue;
|
||||
}
|
||||
commands
|
||||
.entity(entity)
|
||||
.despawn_related::<Children>()
|
||||
.remove::<(
|
||||
super::VisualAttached,
|
||||
AppliedEquipment,
|
||||
AnimationPlayer,
|
||||
bevy::animation::transition::AnimationTransitions,
|
||||
AnimationGraphHandle,
|
||||
benilla_assets::ModelAnimations,
|
||||
crate::creature_anim::AnimDriver,
|
||||
(
|
||||
crate::creature_anim::RigPose,
|
||||
crate::creature_anim::BodyTwist,
|
||||
crate::creature_anim::GlobalSeqDrive,
|
||||
),
|
||||
crate::rig_palette::RigSkin,
|
||||
BoneAttach,
|
||||
HeldAttached,
|
||||
)>()
|
||||
.insert(Reattached);
|
||||
}
|
||||
}
|
||||
|
||||
/// Player equipment slots feeding the armor composite → their bodyslot−2 index (decision 0074):
|
||||
/// shirt(3) chest(4) waist(5) legs(6) feet(7) wrists(8) hands(9) tabard(18). The visible-item block
|
||||
/// is indexed by equipment slot, so no invType mapping is needed on this path.
|
||||
|
||||
@@ -256,7 +256,15 @@ fn spawn_slot(
|
||||
let &(bone, offset) = bones.points.get(&hs.attach)?;
|
||||
let joint = bones.anchor(bone)?;
|
||||
let root = commands
|
||||
.spawn((Transform::from_translation(offset), Visibility::default()))
|
||||
.spawn((
|
||||
Transform::from_translation(offset),
|
||||
Visibility::default(),
|
||||
// This item model is CHAINED to the body wearing it (`0x712f70` attach → the
|
||||
// `[model+0x1cc]` parent link): the wearer's computed render alpha multiplies
|
||||
// everything this root carries, and everything chained below it in turn — its glow
|
||||
// instances included (decision 0833).
|
||||
crate::model_fade::ParentModel(entity),
|
||||
))
|
||||
.id();
|
||||
commands.entity(joint).add_child(root);
|
||||
// The item/enchant glow (decision 0805): its instances hang off the ITEM's own
|
||||
@@ -360,6 +368,11 @@ fn spawn_slot(
|
||||
// the ground-shade ramp that darkens its wielder; both now follow the body, which
|
||||
// is the same light-collector aliasing the comment above describes.
|
||||
child.insert(MeshTag(rig_tag | crate::mesh_tag::alpha_bits(tag_alpha)));
|
||||
// The item part's build-time bound (decision 0834): `calculate_bounds` can no longer
|
||||
// derive one from the `RENDER_WORLD`-only static form's data.
|
||||
if let Some(aabb) = part.aabb {
|
||||
child.insert(aabb);
|
||||
}
|
||||
if let Some(lit) = part_interior_lit(
|
||||
&part.material,
|
||||
part.material_interior.as_ref(),
|
||||
@@ -377,6 +390,7 @@ fn spawn_slot(
|
||||
cutout: part.material.clone(),
|
||||
blend: blend.clone(),
|
||||
bake_blend: part.material_interior_bake_blend.clone(),
|
||||
zfill: part.zfill.clone(),
|
||||
});
|
||||
}
|
||||
match effective {
|
||||
@@ -427,6 +441,10 @@ fn spawn_slot(
|
||||
},
|
||||
BillboardCard::following(&info, root),
|
||||
));
|
||||
// The card's build-time bound (decision 0834) — same rule as its mesh siblings above.
|
||||
if let Some(aabb) = part.aabb {
|
||||
card.insert(aabb);
|
||||
}
|
||||
// Same interior-light membership the item's mesh parts get above, through the same
|
||||
// constructor and anchored at the same WEARER (decision 0778) — so a held torch's
|
||||
// glow card can never split from the arm holding it. Spawned at a steady alpha: a
|
||||
@@ -521,10 +539,11 @@ fn spawn_slot(
|
||||
// the air behind the character (decision 0826). A sheath swap no longer comes
|
||||
// through here at all: the root is MOVED, and this pool rides it.
|
||||
on_owner_loss: crate::particles::OwnerLoss::Free,
|
||||
// An ATTACHED model inherits its parent's computed alpha (`0x714000`), so the
|
||||
// sparkle on a pauldron fades in with the body wearing it and vanishes with the
|
||||
// avatar in first person — decision 0827. The WEARER, never the item root.
|
||||
alpha: Some(entity),
|
||||
// This emitter's own model instance is the item root; `ParentModel` above chains
|
||||
// it to the wearer, and the chain is what an ATTACHED model's composed alpha is
|
||||
// (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it
|
||||
// and vanishes with the avatar in first person (0827/0833).
|
||||
alpha: Some(root),
|
||||
},
|
||||
// A held item spawns no rig; its emitters run the item model's own slot-0
|
||||
// loop on the spawn clock (the torch burns always — the doodad law).
|
||||
@@ -551,9 +570,9 @@ fn spawn_slot(
|
||||
false,
|
||||
ctx.scale,
|
||||
Some(0),
|
||||
// An attached model inherits the WEARER's render alpha, so an enchant streamer is
|
||||
// gone with the avatar in first person and absent until the body is shown (0827).
|
||||
Some(entity),
|
||||
// Its own model instance — chained to the wearer above — so an enchant streamer is
|
||||
// gone with the avatar in first person and absent until the body is shown (0827/0833).
|
||||
Some(root),
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
@@ -579,12 +598,14 @@ mod tests {
|
||||
fn part(interior: bool) -> EntityPart {
|
||||
EntityPart {
|
||||
mesh: Handle::default(),
|
||||
aabb: None,
|
||||
skinned_mesh: None,
|
||||
material: Handle::default(),
|
||||
material_interior: interior.then(Handle::default),
|
||||
material_interior_bake: None,
|
||||
material_interior_bake_blend: None,
|
||||
fade_blend: None,
|
||||
zfill: None,
|
||||
blend: benilla_formats::ModelBlend::Opaque,
|
||||
additive: false,
|
||||
two_sided: false,
|
||||
@@ -957,4 +978,41 @@ mod tests {
|
||||
);
|
||||
assert_eq!(after[4], Some(shoulder_root), "the shoulders are untouched");
|
||||
}
|
||||
|
||||
/// The chain's first link, on the real spawn path (decision 0833): an item model is CHAINED to
|
||||
/// the body wearing it, and the emitters it spawns point at **their own** root rather than at
|
||||
/// the wearer. Both halves matter — the item's own sparkle would fade correctly either way,
|
||||
/// but a glow instance hung on this root two links down can only reach the wearer through it,
|
||||
/// and that is the link the enchant-glow lane never had.
|
||||
///
|
||||
/// It survives the sheath MOVE for the same reason the pool does: the root is re-parented, not
|
||||
/// rebuilt.
|
||||
#[test]
|
||||
fn an_item_model_is_chained_to_its_wearer() {
|
||||
use crate::model_fade::ParentModel;
|
||||
|
||||
let (mut app, wearer) = dress_a_wearer();
|
||||
let shoulder = roots_of(&app, wearer)[4].expect("shoulder");
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.entity(shoulder)
|
||||
.get::<ParentModel>()
|
||||
.map(|p| p.0),
|
||||
Some(wearer),
|
||||
"the item chains to the body wearing it"
|
||||
);
|
||||
|
||||
let mut items = app.world_mut().entity_mut(wearer);
|
||||
let mut items = items.get_mut::<HeldItems>().unwrap();
|
||||
items.slots[0].as_mut().unwrap().attach = SHEATH_BACK;
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.entity(shoulder)
|
||||
.get::<ParentModel>()
|
||||
.map(|p| p.0),
|
||||
Some(wearer),
|
||||
"…and a sheath swap elsewhere leaves that link alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ use crate::model_render::m2_url;
|
||||
use crate::terrain::WowModelMaterial;
|
||||
|
||||
use super::equipment::{ItemDisplays, ItemModelKind};
|
||||
use super::spell_fx::{attach_effect_visuals, FxTintAnims};
|
||||
use super::spell_fx::{attach_effect_visuals, EffectHost, FxTintAnims};
|
||||
use super::{DisplayModel, ModelHandle};
|
||||
|
||||
/// The glow chain's data + the effect-model cache: the two joined DBC catalogs, plus a
|
||||
@@ -286,8 +286,15 @@ pub(super) fn attach_item_glows(
|
||||
instance,
|
||||
dm,
|
||||
now,
|
||||
false, // a weapon glow is never ground-anchored
|
||||
Some(instance), // an attached model in the client's sense — it rides the item
|
||||
false, // a weapon glow is never ground-anchored
|
||||
// An attached model in the client's sense — it rides the ITEM (`0x712f70`), so it
|
||||
// is chained to that item root and through it to the wearer: the glow fades in
|
||||
// with the body instead of blazing over a character that has not appeared yet,
|
||||
// and it dies with the weapon instead of hanging in the air behind it (0833).
|
||||
EffectHost {
|
||||
attached: true,
|
||||
parent: Some(root),
|
||||
},
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
//! handler instead **eases the render scale over 2 s with a cosine smoothstep** (byte-verified,
|
||||
//! `0x614bbf`). All wow-re: `questgiver-marker.md` §W6, `w2d2.md` §2.x, `object-layer.md`.
|
||||
//!
|
||||
//! Our shape is the house diff-and-rebuild ([`super::mount::refresh_mounts`] /
|
||||
//! [`super::equipment::refresh_player_looks`]): the visual was BUILT with [`AppliedDisplay`], the
|
||||
//! Our shape is the house diff-and-rebuild ([`super::mount::refresh_mounts`]): the visual was
|
||||
//! BUILT with [`AppliedDisplay`], the
|
||||
//! live truth is the descriptor store, and a difference tears the visual down for
|
||||
//! `attach_entity_visuals` to rebuild — fade-skipped (a shapeshift isn't a spawn), waiting out the
|
||||
//! new model's async load rather than flashing a cube. The collision height restamps **in the same
|
||||
|
||||
@@ -61,7 +61,7 @@ use crate::creature_anim::{
|
||||
use crate::model_render::m2_url;
|
||||
|
||||
use super::equipment::ItemDisplays;
|
||||
use super::spell_fx::{attach_effect_visuals, SpellFx};
|
||||
use super::spell_fx::{attach_effect_visuals, EffectHost, SpellFx};
|
||||
use super::{BoneAttach, DisplayModel, ModelHandle};
|
||||
|
||||
/// The anim-event idents that release queued missiles — the dispatcher's drain arms (`0x5ffbd0`:
|
||||
@@ -561,7 +561,9 @@ pub(super) fn attach_missile_models(
|
||||
dm,
|
||||
time.elapsed_secs(),
|
||||
false, // a missile flies — its flat quads are geometry, never ground decals
|
||||
None, // a missile is a FREE world model — its trail stays world-frozen
|
||||
// A missile is a FREE world model: its trail stays world-frozen, and its pool
|
||||
// finishes in place when it impacts (0202's drain).
|
||||
EffectHost::default(),
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
|
||||
@@ -33,8 +33,9 @@ pub(super) struct AppliedMount(pub(super) u32);
|
||||
/// Rebuild a unit's visual when its mount field changes (decision 0441): 0→id (mount up), id→0
|
||||
/// (dismount — the reference tears the secondary model down on the spot, byte-verified
|
||||
/// `0x607ce0`: detach body, `SetMountModel(0)`, body to Stand, no transition anim), id→id′ (a
|
||||
/// re-mount). The transition is the gear-swap teardown (`refresh_player_looks`' exact shape):
|
||||
/// children — mount child, joints, parts, held roots — despawn, the visual components strip, and
|
||||
/// re-mount). The transition is a full teardown — a mount is a second SKELETON the rider's own
|
||||
/// rig re-roots under, so there is nothing to re-dress in place the way a gear change is
|
||||
/// (`attach::redress`): children — mount child, joints, parts, held roots — despawn, the visual components strip, and
|
||||
/// `attach_entity_visuals` rebuilds next frame(s) in the right configuration, fade-skipped via
|
||||
/// `Reattached` (mounting up isn't a spawn).
|
||||
pub(super) fn refresh_mounts(
|
||||
|
||||
@@ -288,7 +288,12 @@ pub(crate) fn drive_fx_view(
|
||||
&fx.models[&req.model_path],
|
||||
time.elapsed_secs(),
|
||||
true, // the fixture plants at a world point — ground quads decal onto the terrain
|
||||
Some(root), // the fixture previews kit effects, which are attached models
|
||||
// The fixture previews kit effects, which are attached models — but it hangs on
|
||||
// nothing (there is no host model in a preview), so its pool keeps the drain.
|
||||
EffectHost {
|
||||
attached: true,
|
||||
parent: None,
|
||||
},
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
@@ -300,6 +305,24 @@ pub(crate) fn drive_fx_view(
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an effect-model instance sits in the client's **model graph** — the two facts every
|
||||
/// caller of [`attach_effect_visuals`] states, together, because they are one fact seen from two
|
||||
/// sides and a lane that answered only the first is what left a weapon's enchant glow with no
|
||||
/// alpha source and no owner (decision 0833).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct EffectHost {
|
||||
/// Is this an **attached** model in the client's sense (`[model+0x17c] ≠ 0`)? It sets the
|
||||
/// emitters' attach frame `A`: a kit effect on a unit and the `fxview` fixture are attached
|
||||
/// (the cloud fans with the host's motion), a missile is not — its trail stays world-frozen
|
||||
/// (wow-re `part-kit-effect-attach-orient.md`).
|
||||
pub attached: bool,
|
||||
/// The model instance this one is **chained to** ([`crate::model_fade::ParentModel`]): the
|
||||
/// unit a kit effect is hung on, the item root a weapon glow rides. `None` for a model that
|
||||
/// belongs to no other — a missile, the fixture preview — which is also what keeps the 0202
|
||||
/// drain for the impacting trail.
|
||||
pub parent: Option<Entity>,
|
||||
}
|
||||
|
||||
/// Attach one effect model's full visual set to `root` — THE one body for every effect-model
|
||||
/// consumer (spell-kit instances, missiles, the `fxview` capture fixture): the authored rig
|
||||
/// ([`arm_effect_rig`]), the part meshes (skinned twins on a rigged model, `NoFrustumCulling` —
|
||||
@@ -311,11 +334,10 @@ pub(crate) fn drive_fx_view(
|
||||
/// instance (`ground_anchor`: the kit slot resolved to the base point `0x13` or the unit root),
|
||||
/// each flat ground-plane quad part as a projected surface decal ([`crate::ground_fx`]) riding
|
||||
/// its joint, so Battle Shout's crescents drape sloped terrain instead of being buried by it.
|
||||
/// `attach` is the emitters' attach frame — `Some(root)` for an instance that is an ATTACHED
|
||||
/// model in the client's sense (a kit effect on a unit, the fxview fixture), `None` for a
|
||||
/// free world model (a missile: its trail stays world-frozen, wow-re
|
||||
/// `part-kit-effect-attach-orient.md`). Returns `false` while the model's parts are still
|
||||
/// building — call again next frame.
|
||||
/// `host` says where the instance sits in the model graph ([`EffectHost`]) — which decides its
|
||||
/// emitters' attach frame, whose render alpha they inherit, and what becomes of them when the
|
||||
/// instance goes. Returns `false` while the model's parts are still building — call again next
|
||||
/// frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn attach_effect_visuals(
|
||||
commands: &mut Commands,
|
||||
@@ -323,7 +345,7 @@ pub(super) fn attach_effect_visuals(
|
||||
dm: &DisplayModel,
|
||||
now: f32,
|
||||
ground_anchor: bool,
|
||||
attach: Option<Entity>,
|
||||
host: EffectHost,
|
||||
wow_materials: &mut Assets<WowModelMaterial>,
|
||||
tint_reg: &mut FxTintAnims,
|
||||
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
|
||||
@@ -333,6 +355,24 @@ pub(super) fn attach_effect_visuals(
|
||||
let Some(parts) = dm.parts.as_ref() else {
|
||||
return false; // still loading — attach on a later pass
|
||||
};
|
||||
let attach = host.attached.then_some(root);
|
||||
// Chain this instance onto the model it hangs from, so its effects compose through the
|
||||
// parent's computed alpha the way `0x714000` does — a weapon's glow through the item, the
|
||||
// item through its wearer (decision 0833).
|
||||
if let Some(parent) = host.parent {
|
||||
commands
|
||||
.entity(root)
|
||||
.insert(crate::model_fade::ParentModel(parent));
|
||||
}
|
||||
// …and, from the same fact, what losing the owner means: an instance hung on another model is
|
||||
// part of that model's tree, so its pool is freed with it (the reference's dtor) instead of
|
||||
// being left to finish in world space — the ghost clouds a gear change used to strand at the
|
||||
// body. A free-standing instance (a missile) keeps the 0202 drain.
|
||||
let on_owner_loss = if host.parent.is_some() {
|
||||
crate::particles::OwnerLoss::Free
|
||||
} else {
|
||||
crate::particles::OwnerLoss::Drain
|
||||
};
|
||||
let is_ground_decal = |part: &EntityPart| ground_anchor && part.ground_quad.is_some();
|
||||
// Per-part materials for THIS instance (a tint clone where the RGB animates), resolved
|
||||
// before the child-spawn closure so the material assets aren't borrowed inside it.
|
||||
@@ -439,6 +479,11 @@ pub(super) fn attach_effect_visuals(
|
||||
},
|
||||
card,
|
||||
));
|
||||
// The card's build-time bound (decision 0834): `calculate_bounds` can no longer derive
|
||||
// one from the `RENDER_WORLD`-only static form's data.
|
||||
if let Some(aabb) = part.aabb {
|
||||
spawned.insert(aabb);
|
||||
}
|
||||
// A card shares its batch's material-alpha loops (the billboard split copies them).
|
||||
if let Some(anim) = &part.alpha_anim {
|
||||
let mat_anim = crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq);
|
||||
@@ -509,9 +554,12 @@ pub(super) fn attach_effect_visuals(
|
||||
// The cloud anchors at the instance root — the emitter's bone only composes births
|
||||
// (the food sparkle's bone orbits; the risen stars must not swirl with it).
|
||||
anchor: Some(root),
|
||||
// An effect instance that ends mid-flight leaves its live particles to finish
|
||||
// (the impacting missile's trail) — the DRAIN default.
|
||||
..default()
|
||||
// Free with the model when this instance belongs to one, drain when it stands
|
||||
// alone (the impacting missile's trail — 0202's case). See `on_owner_loss` above.
|
||||
on_owner_loss,
|
||||
// This instance IS the model these particles belong to; its chain (set above)
|
||||
// carries the host's fade down to them — decision 0833.
|
||||
alpha: Some(root),
|
||||
},
|
||||
// The effect's rig arms ONE clip; the emitters' rate/enabled windows ride that slot
|
||||
// (a missile's InFlight is not file-order-first), on the instance's own spawn clock.
|
||||
@@ -538,8 +586,9 @@ pub(super) fn attach_effect_visuals(
|
||||
// families land on the same draw-order rung, which is the property that matters.
|
||||
1.0,
|
||||
played_anim,
|
||||
// No model-alpha source: a placed prop / effect instance is always drawn (0827).
|
||||
None,
|
||||
// This instance's own model alpha, chained to its host (0827/0833): a standalone
|
||||
// instance has none above it and draws exactly as before.
|
||||
Some(root),
|
||||
);
|
||||
}
|
||||
true
|
||||
@@ -800,7 +849,13 @@ pub(super) fn attach_spell_fx(
|
||||
dm,
|
||||
now,
|
||||
ground_anchor,
|
||||
Some(root), // a kit instance on a unit is an attached model
|
||||
// A kit instance on a unit is an attached model, chained to that unit: it fades
|
||||
// with the body it is cast on, and it is freed with it (0833) — where before, a
|
||||
// gear change that tore the unit's visual down left its cloud in the air.
|
||||
EffectHost {
|
||||
attached: true,
|
||||
parent: Some(unit),
|
||||
},
|
||||
&mut wow_materials,
|
||||
&mut tint_reg,
|
||||
&ibps,
|
||||
|
||||
@@ -184,6 +184,7 @@ pub(super) fn resolve_wmo_gameobject_props(
|
||||
pub(super) fn spawn_wmo_gameobject_props(
|
||||
mut commands: Commands,
|
||||
m2s: Res<Assets<M2Model>>,
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
shared_light: Option<Res<SharedLightBuffer>>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut entity_mats: ResMut<EntityMaterials>,
|
||||
@@ -211,6 +212,18 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
let Some(m) = m2s.get(&prop.handle) else {
|
||||
return true; // still loading — retry next frame
|
||||
};
|
||||
// The prop's app-built render forms (decision 0834): a transport's cabin props are
|
||||
// the entity lane — priority 0, same as any creature walking into view.
|
||||
let key = crate::model_forms::ModelKey::from(&prop.handle);
|
||||
let kinds = crate::model_forms::WANT_STATIC
|
||||
| if crate::doodad_anim::wants_rig(m) {
|
||||
crate::model_forms::WANT_SKINNED
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if !forms.require(key, kinds, 0) {
|
||||
return true; // forms still building — retry next frame
|
||||
}
|
||||
// A boneless prop's glow card follows an ANCHOR child at the prop's placement (the
|
||||
// card itself stays a world root the billboard pass writes absolutely). Only spawned
|
||||
// when the model has billboard batches — one lantern per ship, not 134 empty entities.
|
||||
@@ -257,6 +270,10 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
&mut palettes,
|
||||
light,
|
||||
&m.submeshes,
|
||||
crate::model_forms::FormSlices {
|
||||
stat: forms.static_meshes(key).unwrap_or(&[]),
|
||||
skin: forms.skinned_meshes(key),
|
||||
},
|
||||
prop.local, // doodad-LOCAL — the parent composes the world pose
|
||||
false,
|
||||
// Deck props: plain matte, like a terrain exterior prop on lit ground (a boat is
|
||||
|
||||
@@ -278,6 +278,28 @@ impl InteriorLit {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-point a standing part's law variants at a freshly-built material set, and answer what it
|
||||
/// should now draw — the character **re-dress** (`entities::attach::redress`): a gear change
|
||||
/// re-composites the body atlas and re-resolves the cape texture, so every variant's handle
|
||||
/// changes while the part, the room and therefore the *law* stay exactly as they were.
|
||||
///
|
||||
/// The applied law is deliberately KEPT, which is what makes this a re-point rather than a
|
||||
/// re-classification: nothing moved, so re-running the down-ray would answer the same thing at
|
||||
/// the cost of a frame on the wrong material. A part's `kind` cannot change under it either —
|
||||
/// bake-capability is a property of the batch's build, and a re-dress never changes which batch
|
||||
/// a part is.
|
||||
pub(crate) fn repoint(
|
||||
&mut self,
|
||||
exterior: &Handle<WowModelMaterial>,
|
||||
bake: Option<&Handle<WowModelMaterial>>,
|
||||
) -> &Handle<WowModelMaterial> {
|
||||
self.exterior = exterior.clone();
|
||||
if let (InteriorKind::Bake { material, .. }, Some(b)) = (&mut self.kind, bake) {
|
||||
*material = b.clone();
|
||||
}
|
||||
self.steady_material()
|
||||
}
|
||||
|
||||
pub(crate) fn new(kind: InteriorKind, exterior: Handle<WowModelMaterial>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
@@ -1188,6 +1210,7 @@ mod tests {
|
||||
cutout: Handle::default(),
|
||||
blend: exterior_blend.clone(),
|
||||
bake_blend: Some(bake_blend.clone()),
|
||||
zfill: None,
|
||||
},
|
||||
crate::model_fade::RenderFade {
|
||||
started: 0.0,
|
||||
|
||||
@@ -69,6 +69,7 @@ mod map_proj;
|
||||
mod mesh_tag;
|
||||
mod minimap;
|
||||
mod model_fade;
|
||||
mod model_forms;
|
||||
mod model_render;
|
||||
mod modkeys;
|
||||
mod nameplates;
|
||||
@@ -145,6 +146,7 @@ mod wmo_portal;
|
||||
mod wmo_sky;
|
||||
mod world_map;
|
||||
mod world_state;
|
||||
mod zfill;
|
||||
|
||||
use assets::AssetPlugin;
|
||||
use avian3d::prelude::*;
|
||||
|
||||
@@ -224,6 +224,18 @@ pub(crate) fn shade_of(tag: u32) -> u8 {
|
||||
((tag & SHADE_MASK) >> SHADE_SHIFT) as u8
|
||||
}
|
||||
|
||||
/// Whether this tag's instance draws **translucent** (`0 < α < 1`) — the depth-prime twin's
|
||||
/// activation rule ([`crate::zfill`], decision 0831), decoding exactly as the shader does: the
|
||||
/// flag bits split off first, a whole payload of `0` is the *untagged ⇒ opaque* sentinel, and a
|
||||
/// full alpha field (63) is opaque. The field is never legitimately `0` ([`alpha_bits`] floors at
|
||||
/// `1`), so there is no "invisible" branch to exclude — bits `1..=62` are the whole translucent
|
||||
/// band, matching the reference's twin gate (any batch whose evaluated `A < 0.99999` routes to the
|
||||
/// transparent lists and clones a twin; only `A ≤ 0` culls the batch outright).
|
||||
pub(crate) fn translucent(tag: u32) -> bool {
|
||||
let payload = tag & !(HIGHLIGHT_BIT | INTERIOR_FOG_BIT);
|
||||
payload != 0 && matches!(payload & ALPHA_MASK, 1..=62)
|
||||
}
|
||||
|
||||
/// Human-readable decode of a `MeshTag`, for the probes (`WOW_PICK`'s per-frame shading dump).
|
||||
///
|
||||
/// It lives **here** because this module owns the bit conventions: a probe that re-derived the masks
|
||||
|
||||
@@ -157,16 +157,74 @@ pub fn fade_alpha(from: f32, to: f32, t: f32) -> f32 {
|
||||
/// An **attached** model inherits its parent's, which is why an item's effects read their WEARER's
|
||||
/// (the `0x714000` recursion: a child model with `[model+0x1cc] ≠ 0` composes onto the parent's
|
||||
/// computed colours and alpha — wow-re `selection-circle.md` §scope, `0x714260`'s `[ebp+0x14]`).
|
||||
/// That inheritance is [`ParentModel`] + [`ModelAlphas`], never a spawn site pointing at the top
|
||||
/// of the chain by hand (decision 0833).
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ModelAlpha(pub f32);
|
||||
|
||||
/// The model instance this one is **chained to** — the reference's `[model+0x1cc]` parent link,
|
||||
/// set on a model that was attached to another model (`0x712f70 CM2Model::attachChild`). An item
|
||||
/// root's parent is its wearer; a weapon's enchant-glow instance's parent is that item root; a
|
||||
/// spell kit hung on a unit's is the unit.
|
||||
///
|
||||
/// It exists because composition is **recursive** in the reference (`0x714000` walks the chain and
|
||||
/// composes each child onto its parent's *computed* colour and alpha) and every spawn site knows
|
||||
/// exactly one thing for certain: who it just attached itself to. Asking a site for the far end of
|
||||
/// the chain instead is what left a weapon's glow — two links down — with no alpha source at all,
|
||||
/// blazing at full strength over a character that had not faded in yet (decision 0833).
|
||||
#[derive(Component, Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ParentModel(pub Entity);
|
||||
|
||||
/// How far [`ModelAlphas`] follows [`ParentModel`] before giving up. The real chains are 1–3 links
|
||||
/// (unit → item → glow); the bound is a cycle backstop, not a policy.
|
||||
const MAX_MODEL_CHAIN: usize = 8;
|
||||
|
||||
/// The composed render alpha of a model instance — [`ModelAlpha`] multiplied along the
|
||||
/// [`ParentModel`] chain, i.e. the reference's `0x714000` recursion as a lookup. This is the number
|
||||
/// an effect multiplies into its vertex alpha; **an effect passes its OWN instance root** and the
|
||||
/// chain supplies everything above it.
|
||||
///
|
||||
/// Missing components read as opaque throughout: an entity with no `ModelAlpha` and no parent is
|
||||
/// `1.0`, which is why the steady-state world pays nothing for this.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub struct ModelAlphas<'w, 's> {
|
||||
chain: Query<'w, 's, (Option<&'static ModelAlpha>, Option<&'static ParentModel>)>,
|
||||
}
|
||||
|
||||
impl ModelAlphas<'_, '_> {
|
||||
/// `instance`'s composed alpha. A despawned link ends the walk (its effects are being freed
|
||||
/// this frame anyway — [`crate::particles::OwnerLoss::Free`]).
|
||||
pub fn get(&self, instance: Entity) -> f32 {
|
||||
let mut alpha = 1.0;
|
||||
let mut at = instance;
|
||||
for _ in 0..MAX_MODEL_CHAIN {
|
||||
let Ok((own, parent)) = self.chain.get(at) else {
|
||||
break;
|
||||
};
|
||||
if let Some(a) = own {
|
||||
alpha *= a.0;
|
||||
}
|
||||
match parent {
|
||||
Some(p) => at = p.0,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
alpha
|
||||
}
|
||||
}
|
||||
|
||||
/// The render alpha of one streamed model instance this frame — pure, so the composition is pinned
|
||||
/// by tests without an ECS (the `model_fade` pattern):
|
||||
///
|
||||
/// - the **appear** ramp: none ⇒ opaque; pending ⇒ 0 (the entity is not being shown yet, so its
|
||||
/// effects must not be either — this is the login symptom); live ⇒ the cubic ramp,
|
||||
/// - × the **despawn** ramp (our stream-out look, 0032/0067) once armed,
|
||||
/// - × the **self-avatar** zoom feather, which applies to the player's own body alone.
|
||||
/// - × the **self-avatar** zoom feather, which applies to the player's own body alone,
|
||||
/// - × the **aura** alpha (`crate::aura_visual` — stealth's 0.3, invisibility's 0.5, …): the ramped
|
||||
/// CharProc-14 product. In the reference this is not a separate channel at all — the aura
|
||||
/// recompute drives the SAME `StartAlphaFade` slot the appear-fade uses (`0x60d180` →
|
||||
/// `0x614f80` → `obj+0xf4` → `+0x180` → `+0x19c` → `emitter+0x1a8`), which is exactly why a
|
||||
/// stealthed unit's weapon-glow particles and ribbons dim with it.
|
||||
///
|
||||
/// The product is the reference's own shape: it multiplies the transition alpha into the same
|
||||
/// `+0x180` slot rather than keeping a second channel.
|
||||
@@ -175,6 +233,7 @@ pub fn model_render_alpha(
|
||||
appear: Option<UnitAppearFade>,
|
||||
despawn_started: Option<f32>,
|
||||
self_fade: f32,
|
||||
aura: f32,
|
||||
) -> f32 {
|
||||
let appear = match appear {
|
||||
None => 1.0,
|
||||
@@ -186,7 +245,7 @@ pub fn model_render_alpha(
|
||||
let despawn = despawn_started.map_or(1.0, |started| {
|
||||
fade_alpha(1.0, 0.0, (now - started) / APPEAR_FADE_SECS)
|
||||
});
|
||||
(appear * despawn * self_fade).clamp(0.0, 1.0)
|
||||
(appear * despawn * self_fade * aura).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Publish [`ModelAlpha`] on every streamed object each frame. Runs in `PostUpdate`, after every
|
||||
@@ -208,13 +267,14 @@ pub(crate) fn publish_model_alpha(
|
||||
Option<&UnitAppearFade>,
|
||||
Option<&DespawnFade>,
|
||||
Has<crate::net::SelfPlayer>,
|
||||
Option<&crate::aura_visual::AuraNodes>,
|
||||
Option<&mut ModelAlpha>,
|
||||
),
|
||||
With<crate::net::NetEntity>,
|
||||
>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
for (entity, appear, despawn, is_self, current) in &mut units {
|
||||
for (entity, appear, despawn, is_self, aura, current) in &mut units {
|
||||
let alpha = model_render_alpha(
|
||||
now,
|
||||
appear.copied(),
|
||||
@@ -225,6 +285,11 @@ pub(crate) fn publish_model_alpha(
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
// The aura ramp ticked this frame in Update (`apply_aura_alpha` — this system runs
|
||||
// PostUpdate), so `current` is fresh. `self_fade()` above is the bare zoom feather
|
||||
// (the mesh-side writer folds the aura factor separately), so this is not a double
|
||||
// application on the self body.
|
||||
crate::aura_visual::root_alpha(aura),
|
||||
);
|
||||
match current {
|
||||
Some(mut c) => {
|
||||
@@ -484,6 +549,13 @@ pub struct FadeMaterials {
|
||||
/// this so its light stays the room's probe through the feather instead of jumping to the
|
||||
/// exterior twin's lit-outdoor intensity (0355). `None` for parts without a bake variant.
|
||||
pub bake_blend: Option<Handle<WowModelMaterial>>,
|
||||
/// The depth-prime twin material ([`crate::model_render::zfill_material`] — the reference's
|
||||
/// `M2UseZFill` clone, wow-re `m2-blend-promotion-zfill.md` §4, decision 0831). While this
|
||||
/// part's instance alpha sits in `(0, 1)`, [`sync_zfill_twins`] keeps a colour-masked,
|
||||
/// z-writing child mesh alive on it, drawn before the model's colour parts — one blended layer
|
||||
/// everywhere, no self-overlap darkening. `None` for a batch whose material disables
|
||||
/// z-write/z-test (the reference's own twin gate).
|
||||
pub zfill: Option<Handle<WowModelMaterial>>,
|
||||
}
|
||||
|
||||
impl FadeMaterials {
|
||||
@@ -598,6 +670,57 @@ fn arm_despawn_descendants(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `0x714000` recursion as a lookup (decision 0833). A weapon's enchant glow is **two**
|
||||
/// links from the body wearing it — glow → item → wearer — and the director's report is what
|
||||
/// happens when a lane has no expression for that at all: the glow's particles blazed at full
|
||||
/// strength over a character that was still at alpha 0, because the site that spawned them
|
||||
/// could name only its immediate host and was asked for the far end of the chain.
|
||||
///
|
||||
/// The `dimmed` case pins the composition as the reference's **product** (own × parent's), not
|
||||
/// a pick of one — that is what makes a fading item on a fading wearer behave.
|
||||
#[test]
|
||||
fn an_attached_models_alpha_composes_through_the_whole_chain() {
|
||||
use bevy::ecs::system::SystemState;
|
||||
|
||||
let mut world = World::new();
|
||||
let wearer = world.spawn(ModelAlpha(0.25)).id();
|
||||
let item = world.spawn(ParentModel(wearer)).id();
|
||||
let glow = world.spawn(ParentModel(item)).id();
|
||||
let dimmed = world.spawn((ModelAlpha(0.5), ParentModel(wearer))).id();
|
||||
let loose = world.spawn_empty().id();
|
||||
let gone = world.spawn(ParentModel(wearer)).id();
|
||||
world.despawn(gone);
|
||||
|
||||
let mut state: SystemState<ModelAlphas> = SystemState::new(&mut world);
|
||||
let alphas = state.get(&world);
|
||||
assert_eq!(alphas.get(wearer), 0.25, "the unit's own");
|
||||
assert_eq!(alphas.get(item), 0.25, "an item takes its wearer's");
|
||||
assert_eq!(alphas.get(glow), 0.25, "…and its glow takes the item's");
|
||||
assert_eq!(alphas.get(dimmed), 0.125, "own × parent's — a product");
|
||||
assert_eq!(alphas.get(loose), 1.0, "chained to nothing ⇒ opaque");
|
||||
assert_eq!(
|
||||
alphas.get(gone),
|
||||
1.0,
|
||||
"a despawned instance never gates a draw"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cycle backstop: [`MAX_MODEL_CHAIN`] bounds the walk, so a link that somehow closes on
|
||||
/// itself costs a few lookups instead of hanging the frame. Nothing builds one today — that is
|
||||
/// exactly why the guard is worth a test rather than a comment.
|
||||
#[test]
|
||||
fn a_looping_chain_terminates() {
|
||||
use bevy::ecs::system::SystemState;
|
||||
|
||||
let mut world = World::new();
|
||||
let a = world.spawn_empty().id();
|
||||
let b = world.spawn(ParentModel(a)).id();
|
||||
world.entity_mut(a).insert(ParentModel(b));
|
||||
|
||||
let mut state: SystemState<ModelAlphas> = SystemState::new(&mut world);
|
||||
assert_eq!(state.get(&world).get(a), 1.0);
|
||||
}
|
||||
|
||||
/// The 0200 login crash, reproduced at the seam: [`arm_appear_fade`]'s query sees the entity
|
||||
/// alive, a wire despawn applies first, and only then do the system's own commands land — the
|
||||
/// `try_*` contract makes that a no-op instead of a panic. `SystemState::apply` after the
|
||||
@@ -650,6 +773,7 @@ mod tests {
|
||||
cutout: cutout.clone(),
|
||||
blend: blend.clone(),
|
||||
bake_blend: Some(bake_blend.clone()),
|
||||
zfill: None,
|
||||
};
|
||||
|
||||
// Unclassified (no `InteriorLit` at all — a WMO-display part): the exterior pair.
|
||||
@@ -675,6 +799,7 @@ mod tests {
|
||||
// dropping the feather.
|
||||
let no_twin = FadeMaterials {
|
||||
bake_blend: None,
|
||||
zfill: None,
|
||||
..fm.clone()
|
||||
};
|
||||
assert_eq!(*no_twin.material_for(Some(&lit), true), blend);
|
||||
@@ -838,13 +963,21 @@ mod tests {
|
||||
10.0,
|
||||
Some(UnitAppearFade::Pending { since: 9.0 }),
|
||||
None,
|
||||
1.0,
|
||||
1.0
|
||||
),
|
||||
0.0,
|
||||
"pending: the unit is not on screen, and neither are its effects"
|
||||
);
|
||||
let live =
|
||||
|now| model_render_alpha(now, Some(UnitAppearFade::Live { started: 10.0 }), None, 1.0);
|
||||
let live = |now| {
|
||||
model_render_alpha(
|
||||
now,
|
||||
Some(UnitAppearFade::Live { started: 10.0 }),
|
||||
None,
|
||||
1.0,
|
||||
1.0,
|
||||
)
|
||||
};
|
||||
assert_eq!(live(10.0), 0.0, "the ramp starts at nothing");
|
||||
assert!(
|
||||
(live(11.0) - fade_alpha(0.0, 1.0, 0.5)).abs() < 1e-6,
|
||||
@@ -852,7 +985,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(live(12.0), 1.0, "…and latches opaque at the duration");
|
||||
assert_eq!(
|
||||
model_render_alpha(10.0, None, None, 1.0),
|
||||
model_render_alpha(10.0, None, None, 1.0, 1.0),
|
||||
1.0,
|
||||
"no fade: opaque"
|
||||
);
|
||||
@@ -864,21 +997,36 @@ mod tests {
|
||||
#[test]
|
||||
fn the_render_alpha_multiplies_its_writers() {
|
||||
// Zooming to first person: the body is opaque-by-appear but the feather is 0.
|
||||
assert_eq!(model_render_alpha(20.0, None, None, 0.0), 0.0);
|
||||
assert!((model_render_alpha(20.0, None, None, 0.5) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(model_render_alpha(20.0, None, None, 0.0, 1.0), 0.0);
|
||||
assert!((model_render_alpha(20.0, None, None, 0.5, 1.0) - 0.5).abs() < 1e-6);
|
||||
// Streaming out: the despawn ramp eases the same channel to nothing.
|
||||
assert_eq!(model_render_alpha(10.0, None, Some(10.0), 1.0), 1.0);
|
||||
assert_eq!(model_render_alpha(12.0, None, Some(10.0), 1.0), 0.0);
|
||||
assert_eq!(model_render_alpha(10.0, None, Some(10.0), 1.0, 1.0), 1.0);
|
||||
assert_eq!(model_render_alpha(12.0, None, Some(10.0), 1.0, 1.0), 0.0);
|
||||
// Both at once stay a product, and the result can never leave [0, 1].
|
||||
let both = model_render_alpha(
|
||||
11.0,
|
||||
Some(UnitAppearFade::Live { started: 10.0 }),
|
||||
Some(10.0),
|
||||
0.5,
|
||||
1.0,
|
||||
);
|
||||
assert!((0.0..=1.0).contains(&both) && both > 0.0);
|
||||
}
|
||||
|
||||
/// **The stealth-particle symptom, at its cause**: the aura CharProc alpha (stealth 0.3,
|
||||
/// invisibility 0.5) is a factor of the SAME render-alpha slot in the reference (`0x60d180`'s
|
||||
/// recompute drives the same `StartAlphaFade` the appear-fade uses), which is what carries a
|
||||
/// stealthed rogue's weapon-enchant glow and kit particles down with the body. Before this
|
||||
/// factor existed, `ModelAlpha` stayed 1.0 under stealth and every emitter burned at full
|
||||
/// brightness beside a 30 %-alpha body.
|
||||
#[test]
|
||||
fn the_aura_alpha_is_a_factor_of_the_effect_render_alpha() {
|
||||
assert!((model_render_alpha(20.0, None, None, 1.0, 0.3) - 0.3).abs() < 1e-6);
|
||||
// …and it composes with the other writers as a product, never a replacement.
|
||||
assert!((model_render_alpha(20.0, None, None, 0.5, 0.3) - 0.15).abs() < 1e-6);
|
||||
assert_eq!(model_render_alpha(20.0, None, Some(20.0), 1.0, 0.3), 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_fade_retires_after_the_appear_duration() {
|
||||
// The join marker itself doesn't retire (that's `retire_unit_appear_fade`, an ECS system), but
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Paced model render-form building — the model-lane twin of the tile furnisher (decision 0834,
|
||||
//! closing 0832's named residual).
|
||||
//!
|
||||
//! The M2/WMO loaders ship **geometry, no meshes** (`ModelSubmesh::geometry`): a loader-built
|
||||
//! labeled sub-asset lands the instant its decode completes, and the render world ingests the
|
||||
//! whole model — a city root's thousands of group batches, a fresh row's hundreds of doodads —
|
||||
//! in ONE frame. Measured at the Stormwind line (pre-0834): 2000–3200 mesh assets per crossing,
|
||||
//! 44–119 ms wall on the worst frame, on every crossing including re-entries. No budget
|
||||
//! downstream of a load can spread work the loader has already packaged, so the build lives
|
||||
//! here: consumers `require()` the forms they need, and [`furnish_model_forms`] builds a bounded
|
||||
//! amount per frame while live — nearest requester first — uncapped behind the loading cover.
|
||||
//!
|
||||
//! One [`Entry`] per loaded model asset holds the built handles; every instance of the model
|
||||
//! shares them (the build runs once per asset, not per placement). The **static** form is
|
||||
//! `RENDER_WORLD`-only with its `Aabb` computed at build time (the exterior cull fails open on a
|
||||
//! missing bound — 0832's rule); the **skinned** twin keeps main-world data because the mouseover
|
||||
//! picker CPU-skins its vertices (`target::hover`), and it is built only for lanes that rig —
|
||||
//! which retires the loader's old always-built, mostly-unused twin (the 0019 deferral).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use benilla_assets::{
|
||||
submesh_to_skinned_mesh, submesh_to_static_mesh, M2Model, ModelSubmesh, WmoModel,
|
||||
};
|
||||
use bevy::asset::AssetId;
|
||||
use bevy::camera::primitives::{Aabb, MeshAabb};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::player::Player;
|
||||
|
||||
/// Request bit: the static (unskinned) render form.
|
||||
pub(crate) const WANT_STATIC: u8 = 1;
|
||||
/// Request bit: the skinned twin (rigged lanes only).
|
||||
pub(crate) const WANT_SKINNED: u8 = 2;
|
||||
|
||||
/// Per-frame build budget while live, in submeshes. The companion vertex budget below is the
|
||||
/// real governor for big WMO group batches; this cap bounds the per-mesh fixed cost (asset add +
|
||||
/// extract + prepare) the Stormwind leg measured dominating at ~3000 small doodad meshes per
|
||||
/// crossing. At 96/frame a whole city crossing's ~3000 meshes land in ~0.5 s of frames — inside
|
||||
/// the two-tile fog margin the 5×5 window gives a first contact.
|
||||
const MESH_BUDGET: usize = 96;
|
||||
/// Per-frame build budget while live, in vertices — the governor for the handful of huge group
|
||||
/// batches (tens of thousands of vertices each). A single submesh may exceed it alone; it then
|
||||
/// lands alone in its frame (a submesh is the atomic unit — at least one always builds, so
|
||||
/// progress is guaranteed).
|
||||
const VERT_BUDGET: usize = 16_384;
|
||||
|
||||
/// A loaded model asset the forms cache keys by — the two model kinds share one cache because
|
||||
/// every consumer lane (placements, entities, markers, booths) handles both.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub(crate) enum ModelKey {
|
||||
M2(AssetId<M2Model>),
|
||||
Wmo(AssetId<WmoModel>),
|
||||
}
|
||||
|
||||
impl From<&Handle<M2Model>> for ModelKey {
|
||||
fn from(h: &Handle<M2Model>) -> Self {
|
||||
Self::M2(h.id())
|
||||
}
|
||||
}
|
||||
impl From<&Handle<WmoModel>> for ModelKey {
|
||||
fn from(h: &Handle<WmoModel>) -> Self {
|
||||
Self::Wmo(h.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// One model's built render forms + its build cursors (`stat.len()` / `skin.len()` — resumed
|
||||
/// across frames, like a tile's cell cursor).
|
||||
#[derive(Default)]
|
||||
struct Entry {
|
||||
/// The static form per submesh, with the `Aabb` computed at build (`None` = degenerate
|
||||
/// geometry): consumers insert it explicitly — `RENDER_WORLD` meshes race Bevy's
|
||||
/// `calculate_bounds`, and the exterior cull fails open on a missing bound.
|
||||
stat: Vec<(Handle<Mesh>, Option<Aabb>)>,
|
||||
/// The skinned twin per submesh (rigged lanes only; main-world data kept for the picker).
|
||||
skin: Vec<Handle<Mesh>>,
|
||||
/// Kinds ever requested (`WANT_*` bits). Sticky: demand for a form doesn't vanish because
|
||||
/// one requester despawned mid-build — the entry frees with the asset.
|
||||
want: u8,
|
||||
/// Kinds fully built.
|
||||
done: u8,
|
||||
/// This frame's most urgent requester (lower = sooner), reset after each furnish pass so a
|
||||
/// vanished requester stops hoisting its model.
|
||||
priority: i32,
|
||||
}
|
||||
|
||||
/// The per-asset render-form cache. Entries are created by [`Self::require`], built by
|
||||
/// [`furnish_model_forms`], and dropped when the model asset itself leaves the store (the mesh
|
||||
/// handles drop with them; instances that already spawned keep theirs via `Mesh3d`).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ModelForms {
|
||||
entries: HashMap<ModelKey, Entry>,
|
||||
}
|
||||
|
||||
/// A borrowed view of one model's built forms for the assembler, index-parallel with the model's
|
||||
/// submeshes: the static handle + build-time `Aabb` per batch, plus the skinned twins when the
|
||||
/// lane rigs.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct FormSlices<'a> {
|
||||
pub(crate) stat: &'a [(Handle<Mesh>, Option<Aabb>)],
|
||||
pub(crate) skin: Option<&'a [Handle<Mesh>]>,
|
||||
}
|
||||
|
||||
impl ModelForms {
|
||||
/// Record that a consumer needs `kinds` of this model's forms, at `priority` (lower =
|
||||
/// sooner; pass the requester's tile/chunk distance). Returns `true` when every requested
|
||||
/// kind is already built — the consumer's gate, polled the same way it polls the model
|
||||
/// asset itself.
|
||||
pub(crate) fn require(&mut self, key: ModelKey, kinds: u8, priority: i32) -> bool {
|
||||
let e = self.entries.entry(key).or_default();
|
||||
e.want |= kinds;
|
||||
e.priority = e.priority.min(priority);
|
||||
e.done & kinds == kinds
|
||||
}
|
||||
|
||||
/// The built static forms (handle + build-time `Aabb` per submesh), or `None` until
|
||||
/// [`Self::require`]`(…, WANT_STATIC, …)` has returned `true`.
|
||||
pub(crate) fn static_meshes(&self, key: ModelKey) -> Option<&[(Handle<Mesh>, Option<Aabb>)]> {
|
||||
let e = self.entries.get(&key)?;
|
||||
(e.done & WANT_STATIC != 0).then_some(e.stat.as_slice())
|
||||
}
|
||||
|
||||
/// The built skinned twins, or `None` until built.
|
||||
pub(crate) fn skinned_meshes(&self, key: ModelKey) -> Option<&[Handle<Mesh>]> {
|
||||
let e = self.entries.get(&key)?;
|
||||
(e.done & WANT_SKINNED != 0).then_some(e.skin.as_slice())
|
||||
}
|
||||
|
||||
/// Build every requested-and-missing form of one model NOW, uncapped — the booth/glue/marker
|
||||
/// lanes: one small model at a time, usually behind a screen whose job is to absorb exactly
|
||||
/// this. The streaming lanes must go through [`Self::require`] + the paced furnisher instead.
|
||||
pub(crate) fn ensure_now(
|
||||
&mut self,
|
||||
key: ModelKey,
|
||||
kinds: u8,
|
||||
submeshes: &[ModelSubmesh],
|
||||
meshes: &mut Assets<Mesh>,
|
||||
) {
|
||||
let e = self.entries.entry(key).or_default();
|
||||
e.want |= kinds;
|
||||
build_entry(e, kinds, submeshes, meshes, usize::MAX, &mut 0);
|
||||
}
|
||||
|
||||
/// Drop one model's entry (its asset left the store).
|
||||
fn forget(&mut self, key: ModelKey) {
|
||||
self.entries.remove(&key);
|
||||
}
|
||||
|
||||
/// Drop everything — leaving the world (`release_world`), where the per-asset `Unused`
|
||||
/// events can no longer reach the world-live-gated furnisher.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance one entry's requested builds, bounded by `budget` submeshes and [`VERT_BUDGET`]
|
||||
/// vertices (via `verts`, shared across entries within a frame). Returns how many submeshes were
|
||||
/// built. Marks a kind done when its cursor has walked every submesh.
|
||||
fn build_entry(
|
||||
e: &mut Entry,
|
||||
kinds: u8,
|
||||
submeshes: &[ModelSubmesh],
|
||||
meshes: &mut Assets<Mesh>,
|
||||
budget: usize,
|
||||
verts: &mut usize,
|
||||
) -> usize {
|
||||
let mut built = 0usize;
|
||||
if kinds & WANT_STATIC != 0 && e.done & WANT_STATIC == 0 {
|
||||
while e.stat.len() < submeshes.len() {
|
||||
if built >= budget || *verts >= VERT_BUDGET {
|
||||
return built;
|
||||
}
|
||||
let geo = &submeshes[e.stat.len()].geometry;
|
||||
let mesh = submesh_to_static_mesh(geo);
|
||||
let aabb = mesh.compute_aabb();
|
||||
*verts += geo.positions.len();
|
||||
e.stat.push((meshes.add(mesh), aabb));
|
||||
built += 1;
|
||||
}
|
||||
e.done |= WANT_STATIC;
|
||||
}
|
||||
if kinds & WANT_SKINNED != 0 && e.done & WANT_SKINNED == 0 {
|
||||
while e.skin.len() < submeshes.len() {
|
||||
if built >= budget || *verts >= VERT_BUDGET {
|
||||
return built;
|
||||
}
|
||||
let geo = &submeshes[e.skin.len()].geometry;
|
||||
*verts += geo.positions.len();
|
||||
e.skin.push(meshes.add(submesh_to_skinned_mesh(geo)));
|
||||
built += 1;
|
||||
}
|
||||
e.done |= WANT_SKINNED;
|
||||
}
|
||||
built
|
||||
}
|
||||
|
||||
/// Build requested model forms, [`MESH_BUDGET`]/[`VERT_BUDGET`] per frame while live — most
|
||||
/// urgent requester first — uncapped behind the loading cover (entry/teleport/world-stale), which
|
||||
/// exists to absorb exactly that burst. At least one submesh always builds, so progress is
|
||||
/// guaranteed. Frees an entry when its model asset leaves the store.
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
|
||||
pub(crate) fn furnish_model_forms(
|
||||
mut forms: ResMut<ModelForms>,
|
||||
m2s: Res<Assets<M2Model>>,
|
||||
wmos: Res<Assets<WmoModel>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut activity: ResMut<crate::perf::StreamActivity>,
|
||||
player: Option<Res<Player>>,
|
||||
mut m2_events: MessageReader<AssetEvent<M2Model>>,
|
||||
mut wmo_events: MessageReader<AssetEvent<WmoModel>>,
|
||||
) {
|
||||
let t0 = Instant::now();
|
||||
// The free path: a model asset leaving the store (or replaced by a reload) drops its forms —
|
||||
// the mesh handles' last cache-side strong refs go with it, so the GPU copies free once the
|
||||
// last spawned instance is gone too. `Unused` fires for untracked (`RENDER_WORLD`-extracted)
|
||||
// and tracked assets alike, exactly once (0832's counter rule).
|
||||
for ev in m2_events.read() {
|
||||
if let AssetEvent::Unused { id }
|
||||
| AssetEvent::Removed { id }
|
||||
| AssetEvent::Modified { id } = ev
|
||||
{
|
||||
forms.forget(ModelKey::M2(*id));
|
||||
}
|
||||
}
|
||||
for ev in wmo_events.read() {
|
||||
if let AssetEvent::Unused { id }
|
||||
| AssetEvent::Removed { id }
|
||||
| AssetEvent::Modified { id } = ev
|
||||
{
|
||||
forms.forget(ModelKey::Wmo(*id));
|
||||
}
|
||||
}
|
||||
|
||||
let live = player
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.active && !p.settling && !p.world_stale);
|
||||
let cap = if live { MESH_BUDGET } else { usize::MAX };
|
||||
|
||||
// The frame's work list: entries with a requested kind still unbuilt, most urgent first.
|
||||
let mut pending: Vec<(i32, ModelKey)> = forms
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|(_, e)| e.done & e.want != e.want)
|
||||
.map(|(&k, e)| (e.priority, k))
|
||||
.collect();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
pending.sort_unstable_by_key(|(p, _)| *p);
|
||||
|
||||
let mut built = 0usize;
|
||||
let mut verts = 0usize;
|
||||
for (_, key) in pending {
|
||||
if built >= cap || verts >= VERT_BUDGET {
|
||||
break;
|
||||
}
|
||||
// The asset can lag its request (still decoding) or predecease it (dropped mid-build);
|
||||
// both just skip — the entry builds when the asset lands, or frees on its event above.
|
||||
let submeshes: &[ModelSubmesh] = match key {
|
||||
ModelKey::M2(id) => match m2s.get(id) {
|
||||
Some(m) => &m.submeshes,
|
||||
None => continue,
|
||||
},
|
||||
ModelKey::Wmo(id) => match wmos.get(id) {
|
||||
Some(m) => &m.submeshes,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let Some(e) = forms.entries.get_mut(&key) else {
|
||||
continue;
|
||||
};
|
||||
let want = e.want;
|
||||
built += build_entry(e, want, submeshes, &mut meshes, cap - built, &mut verts);
|
||||
e.priority = i32::MAX; // re-asserted by next frame's require() calls
|
||||
}
|
||||
activity.model_meshes_built += built as u32;
|
||||
activity.mfurnish_ms += t0.elapsed().as_secs_f32() * 1000.0;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The cap's arithmetic, kept honest in one place (the furnish.rs test's model twin): the
|
||||
/// Stormwind crossing's measured ~3200 mesh burst must land in well under a second of frames
|
||||
/// at 60 Hz — inside the fog margin a 5×5-window first contact gives.
|
||||
#[test]
|
||||
fn a_city_crossing_furnishes_in_under_a_second() {
|
||||
let crossing_meshes: usize = 3200;
|
||||
let frames = crossing_meshes.div_ceil(super::MESH_BUDGET);
|
||||
assert!(
|
||||
frames <= 40,
|
||||
"a city crossing's models must land within ~2/3 s at 60 Hz, got {frames} frames"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ pub(crate) struct MatKey {
|
||||
sidn: Option<[u8; 3]>,
|
||||
/// The WMO MOMT WINDOW flag — an interior-group batch on the brighter midpoint light.
|
||||
window: bool,
|
||||
/// This material is a **depth-prime twin** ([`zfill_material`] — the reference's `M2UseZFill`
|
||||
/// clone command, wow-re `m2-blend-promotion-zfill.md` §4): colour writes masked off, blend off,
|
||||
/// z-write on, drawn before its model's colour batches. Its own key axis so a twin can never
|
||||
/// dedupe onto a colour material.
|
||||
zfill: bool,
|
||||
}
|
||||
|
||||
/// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity
|
||||
@@ -119,8 +124,10 @@ impl ShadeSel {
|
||||
pub(crate) type MaterialCache = crate::art_scope::SpatialCache<MatKey, Handle<WowModelMaterial>>;
|
||||
|
||||
/// Build (or fetch the deduped) [`WowModelMaterial`] for a model batch: a `StandardMaterial` base
|
||||
/// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant` is
|
||||
/// the `AlphaMode::Blend` twin used by the doodad distance-fade feather pass (entities pass `false`).
|
||||
/// carrying the texture/alpha/cull, plus the `WowModelExt` shared-light extension. `fade_variant`
|
||||
/// marks the `AlphaMode::Blend` twin every feather pass rides — the doodad distance fade AND the
|
||||
/// entity appear/despawn/aura fades (`entities::display` builds its twins with it too); steady
|
||||
/// materials pass `false`. `specialize` keys depth-write-ON on this bit for the twin.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn model_material(
|
||||
cache: &mut MaterialCache,
|
||||
@@ -168,6 +175,7 @@ pub(crate) fn model_material(
|
||||
wmo_class,
|
||||
sidn,
|
||||
window,
|
||||
zfill: false,
|
||||
};
|
||||
if let Some(h) = cache.fetch(&key) {
|
||||
return h;
|
||||
@@ -330,6 +338,99 @@ pub(crate) fn model_material(
|
||||
handle
|
||||
}
|
||||
|
||||
/// `clutter_fade.z` marker bit 9: this material is a depth-prime twin ([`zfill_material`]).
|
||||
pub(crate) const ZFILL_MARKER: u16 = 1 << 9;
|
||||
|
||||
/// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is
|
||||
/// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all**
|
||||
/// of a fading model's twins draw before **any** of its colour parts — the reference achieves the
|
||||
/// same by tying every command of one instance to a single sort key (`cmd+0x14 = model+0x84`) and
|
||||
/// putting twins first inside the tie (`m2-blend-promotion-zfill.md` §4/§6). 8 yd covers every
|
||||
/// humanoid and mount; the residue (a model taller than 8 yd may keep a little self-overlap
|
||||
/// darkening at its extremities, and transparent scene content sorting within 8 yd behind a fading
|
||||
/// body draws after the prime and is depth-clipped where the body covers it) is recorded in
|
||||
/// decision 0831. The same field doubles as the rasterizer depth bias in relative ULPs — at −8 that
|
||||
/// is ~2⁻²⁰ relative, and its direction (twin marginally farther) only makes the colour pass's
|
||||
/// GreaterEqual test safer against cross-pipeline ULP noise.
|
||||
pub(crate) const ZFILL_SORT_BIAS: f32 = -8.0;
|
||||
|
||||
/// Build (or fetch) the **depth-prime twin** material for one fadeable entity batch — the
|
||||
/// reference's `M2UseZFill` clone (wow-re `m2-blend-promotion-zfill.md` §4, VERIFIED): while a
|
||||
/// model draws translucent (`0 < A < 1`), a colour-masked, blend-off, z-writing copy of each of its
|
||||
/// depth-writing batches draws first, so every colour fragment behind the model's own nearest
|
||||
/// surface fails the depth test — one blended layer everywhere, no self-overlap darkening.
|
||||
///
|
||||
/// `cutout` mirrors what the part's COLOUR pass discards, and must keep mirroring it: a twin that
|
||||
/// writes depth where the colour pass discards leaves an invisible depth wall inside the cutout
|
||||
/// holes. Opaque/AlphaKey sources ride the fade twin's hard 224/255 cutout (`model_flags.y`);
|
||||
/// authored-Blend sources discard nothing (their colour pass is the plain blend material).
|
||||
///
|
||||
/// Everything that shapes only the *colour* (lighting lane, shade, fog, tint) is canonicalized so
|
||||
/// twins dedupe maximally — the `WOW_ZFILL` fragment returns before any of it.
|
||||
pub(crate) fn zfill_material(
|
||||
cache: &mut MaterialCache,
|
||||
materials: &mut Assets<WowModelMaterial>,
|
||||
texture: Option<Handle<Image>>,
|
||||
two_sided: bool,
|
||||
cutout: bool,
|
||||
light: &Buffer,
|
||||
) -> Handle<WowModelMaterial> {
|
||||
let key = MatKey {
|
||||
texture: texture.as_ref().map(Handle::id),
|
||||
blend: if cutout {
|
||||
ModelBlend::AlphaTest
|
||||
} else {
|
||||
ModelBlend::Blend
|
||||
},
|
||||
two_sided,
|
||||
is_wmo: false,
|
||||
is_interior: false,
|
||||
is_emissive: false,
|
||||
is_additive: false,
|
||||
fade_variant: cutout,
|
||||
no_depth_write: false,
|
||||
no_depth_test: false,
|
||||
fog_policy: benilla_formats::FogPolicy::Scene,
|
||||
shade: ShadeSel::Lit,
|
||||
wmo_batch_order: 0,
|
||||
uv_anim: None,
|
||||
rgb_anim: None,
|
||||
wmo_class: None,
|
||||
sidn: None,
|
||||
window: false,
|
||||
zfill: true,
|
||||
};
|
||||
if let Some(h) = cache.fetch(&key) {
|
||||
return h;
|
||||
}
|
||||
let cull_mode = if two_sided { None } else { Some(Face::Back) };
|
||||
let base = StandardMaterial {
|
||||
base_color_texture: texture,
|
||||
// Blend keeps the twin in the transparent pass, where the sort bias can place it before
|
||||
// the colour parts; `specialize` masks the colour writes off and turns blend off.
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
depth_bias: ZFILL_SORT_BIAS,
|
||||
double_sided: two_sided,
|
||||
cull_mode,
|
||||
..default()
|
||||
};
|
||||
let handle = materials.add(ExtendedMaterial {
|
||||
base,
|
||||
extension: WowModelExt {
|
||||
clutter_fade: Vec4::new(0.0, 0.0, f32::from(ZFILL_MARKER), 0.0),
|
||||
// `model_flags.y` (the fade-twin bit) drives the shader's hard 224/255 cutout — set
|
||||
// exactly when the colour pass discards, so depth and colour coverage agree.
|
||||
model_flags: Vec4::new(0.0, if cutout { 1.0 } else { 0.0 }, 0.0, 0.0),
|
||||
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
|
||||
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
|
||||
sidn: Vec4::ZERO,
|
||||
light_buf: light.clone(),
|
||||
},
|
||||
});
|
||||
cache.insert(key, handle.clone());
|
||||
handle
|
||||
}
|
||||
|
||||
/// Replace the fog-policy bits (4-6) inside a packed `clutter_fade.z` marker word, preserving
|
||||
/// every other pipeline marker — bits 0-3 AND the Mod/Mod2x multiply markers in bits 7-8
|
||||
/// (decision 0528). The mask lives here, beside the packer above: the portrait booth's rig twin
|
||||
|
||||
@@ -41,9 +41,12 @@ pub(super) struct ModelInstance {
|
||||
/// Grow + position each model-particle emitter's instance pool from its freshly-simulated
|
||||
/// pool. Runs after [`super::sim::simulate_particles`] in the same set, so instances land on
|
||||
/// this frame's positions (the anchored-exactness rule).
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
|
||||
pub(super) fn update_model_particles(
|
||||
mut commands: Commands,
|
||||
models: Res<Assets<M2Model>>,
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
mut mesh_assets: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
light: Res<crate::lighting::SharedLightBuffer>,
|
||||
mut emitters: Query<&mut ParticleEmitter>,
|
||||
@@ -64,14 +67,26 @@ pub(super) fn update_model_particles(
|
||||
let Some(model) = models.get(&geometry) else {
|
||||
continue; // still loading — particles simulate meanwhile, nothing draws yet
|
||||
};
|
||||
// The geometry model's render forms, built NOW rather than paced (decision 0834): a
|
||||
// spell's visual must not lag its cast, and a particle-geometry model is a handful of
|
||||
// tiny batches — the booth-lane exception, not the streaming rule.
|
||||
let key = crate::model_forms::ModelKey::from(&geometry);
|
||||
forms.ensure_now(
|
||||
key,
|
||||
crate::model_forms::WANT_STATIC,
|
||||
&model.submeshes,
|
||||
&mut mesh_assets,
|
||||
);
|
||||
// Grow the pool to the live count (bounded).
|
||||
let rung = emitter.owner_rung();
|
||||
let want = emitter.particles.len().min(MAX_INSTANCES);
|
||||
while emitter.model_instances.len() < want {
|
||||
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
|
||||
let meshes = model
|
||||
.submeshes
|
||||
.iter()
|
||||
.map(|sub| {
|
||||
.enumerate()
|
||||
.map(|(pi, sub)| {
|
||||
// A fresh (never-deduped) material per instance — its `tint` is mutated
|
||||
// every frame by the over-life ramp, so instances must not share.
|
||||
let mut throwaway = MaterialCache::default();
|
||||
@@ -113,7 +128,12 @@ pub(super) fn update_model_particles(
|
||||
}
|
||||
let entity = commands
|
||||
.spawn((
|
||||
Mesh3d(sub.mesh.clone()),
|
||||
Mesh3d(
|
||||
stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::IDENTITY,
|
||||
Visibility::Hidden,
|
||||
|
||||
@@ -202,8 +202,9 @@ pub(super) fn simulate_particles(
|
||||
// The ground-snap probe (file 0x2000 births): terrain + WMO/doodad geometry, the walking
|
||||
// collision audience.
|
||||
spatial: SpatialQuery,
|
||||
// The per-model render alpha an entity-owned cloud is multiplied by (decision 0827).
|
||||
model_alphas: Query<&crate::model_fade::ModelAlpha>,
|
||||
// The per-model render alpha an entity-owned cloud is multiplied by (decision 0827), composed
|
||||
// along the attached-model chain (0833).
|
||||
model_alphas: crate::model_fade::ModelAlphas,
|
||||
// `Without<WorldCamera>`: the `&mut GlobalTransform` must be provably disjoint from the
|
||||
// camera read above too (an emitter never rides the camera entity).
|
||||
mut emitters: Query<
|
||||
@@ -484,13 +485,12 @@ pub(super) fn simulate_particles(
|
||||
}
|
||||
// The MODEL's render alpha for this frame (decision 0827 — the reference's per-frame
|
||||
// `emitter+0x1a8 = Model+0x19c` copy at `0x718960` @`0x719073`). Two disjoint sources, the
|
||||
// same slot the reference writes both through: an entity-owned cloud takes its model
|
||||
// instance's [`crate::model_fade::ModelAlpha`] (an item's is its WEARER's — an attached
|
||||
// model inherits the parent's), and a placed doodad's takes its own distance fade, whose
|
||||
// cutoff the draw-set gate above already applies as a hard stop.
|
||||
*alpha = alpha_src
|
||||
.and_then(|e| model_alphas.get(e).ok())
|
||||
.map_or(1.0, |a| a.0)
|
||||
// same slot the reference writes both through: an entity-owned cloud takes its OWN model
|
||||
// instance's composed alpha ([`crate::model_fade::ModelAlphas`] walks the attached-model
|
||||
// chain, so a weapon glow's reaches the item's and the item's the wearer's — 0833), and a
|
||||
// placed doodad's takes its own distance fade, whose cutoff the draw-set gate above
|
||||
// already applies as a hard stop.
|
||||
*alpha = alpha_src.map_or(1.0, |e| model_alphas.get(e))
|
||||
* fade.map_or(1.0, |f| f.distance_alpha(cam_pos));
|
||||
let attach_inv = attach_rot.inverse();
|
||||
// The cloud anchor (see the field doc): the model's live translation, or the last-known
|
||||
|
||||
+48
-27
@@ -118,14 +118,21 @@ pub(crate) struct StreamActivity {
|
||||
pub(crate) placement_entities_dropped: u32,
|
||||
/// New ADT asset requests fired.
|
||||
pub(crate) tiles_requested: u32,
|
||||
/// Loaded tiles spawned (terrain mesh + placements registered + liquid + clutter).
|
||||
/// Loaded tiles spawned (root + collider kicked off + placements registered + liquid + clutter).
|
||||
pub(crate) tiles_spawned: u32,
|
||||
/// MCNK cell meshes built + spawned by the paced furnisher (decision 0832).
|
||||
pub(crate) cells_spawned: u32,
|
||||
/// Model submesh render forms built by the paced model furnisher (decision 0834).
|
||||
pub(crate) model_meshes_built: u32,
|
||||
/// Placements (or WMO props) whose model landed and spawned.
|
||||
pub(crate) placements_spawned: u32,
|
||||
/// Off-thread colliders attached.
|
||||
pub(crate) colliders_attached: u32,
|
||||
/// Self-time of `stream_terrain` / `spawn_loaded_placements` / `finish_colliders` (ms).
|
||||
/// Self-time of `stream_terrain` / `furnish_tile_cells` / `furnish_model_forms` /
|
||||
/// `spawn_loaded_placements` / `finish_colliders` (ms).
|
||||
pub(crate) stream_ms: f32,
|
||||
pub(crate) furnish_ms: f32,
|
||||
pub(crate) mfurnish_ms: f32,
|
||||
pub(crate) spawn_ms: f32,
|
||||
pub(crate) collider_ms: f32,
|
||||
}
|
||||
@@ -138,6 +145,8 @@ impl StreamActivity {
|
||||
+ self.placements_dropped
|
||||
+ self.tiles_requested
|
||||
+ self.tiles_spawned
|
||||
+ self.cells_spawned
|
||||
+ self.model_meshes_built
|
||||
+ self.placements_spawned
|
||||
+ self.colliders_attached
|
||||
> 0
|
||||
@@ -169,10 +178,11 @@ struct StreamTrace {
|
||||
prev_cpu_secs: Option<f64>,
|
||||
}
|
||||
|
||||
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,spawn_ms,collider_ms,\
|
||||
tiles_dropped,pl_dropped,pl_ents_dropped,tiles_req,\
|
||||
tiles_spawned,pl_spawned,attached,adt_freed,meshes_freed,\
|
||||
images_freed\n";
|
||||
const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnish_ms,mfurnish_ms,\
|
||||
spawn_ms,collider_ms,tiles_dropped,pl_dropped,pl_ents_dropped,\
|
||||
tiles_req,tiles_spawned,cells_spawned,mmeshes_built,pl_spawned,\
|
||||
attached,adt_freed,meshes_freed,images_freed,adt_added,\
|
||||
meshes_added,images_added\n";
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn trace_stream(
|
||||
@@ -196,31 +206,38 @@ fn trace_stream(
|
||||
_ => String::new(),
|
||||
};
|
||||
trace.prev_cpu_secs = cpu_now;
|
||||
let freed = |n: usize| n as u32;
|
||||
let removed_adt = freed(
|
||||
adt_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
|
||||
.count(),
|
||||
);
|
||||
let removed_mesh = freed(
|
||||
mesh_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
|
||||
.count(),
|
||||
);
|
||||
let removed_img = freed(
|
||||
image_events
|
||||
.read()
|
||||
.filter(|e| matches!(e, bevy::asset::AssetEvent::Removed { .. }))
|
||||
.count(),
|
||||
);
|
||||
// One pass per reader: `Added` beside `Unused` — B181's recurring spike was the free wave's
|
||||
// family, but the first-contact head frame is the ADD wave (a landed tile's cell meshes +
|
||||
// texture arrays hitting the render world's prepare at once), invisible until counted.
|
||||
//
|
||||
// The freed columns count `Unused` (last strong handle dropped), not `Removed`: a
|
||||
// `RENDER_WORLD`-only asset (chunk-cell meshes, the tile arrays — decision 0832) leaves the
|
||||
// main store at *extract* via the untracked path, so `Removed` never fires for it; `Unused`
|
||||
// is the release signal both usage kinds emit exactly once, and it is what actually frees
|
||||
// the GPU copy.
|
||||
fn count_events<A: bevy::asset::Asset>(
|
||||
events: &mut MessageReader<bevy::asset::AssetEvent<A>>,
|
||||
) -> (u32, u32) {
|
||||
let (mut added, mut unused) = (0u32, 0u32);
|
||||
for e in events.read() {
|
||||
match e {
|
||||
bevy::asset::AssetEvent::Added { .. } => added += 1,
|
||||
bevy::asset::AssetEvent::Unused { .. } => unused += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(added, unused)
|
||||
}
|
||||
let (added_adt, removed_adt) = count_events(&mut adt_events);
|
||||
let (added_mesh, removed_mesh) = count_events(&mut mesh_events);
|
||||
let (added_img, removed_img) = count_events(&mut image_events);
|
||||
let delta_ms = time.delta_secs() * 1000.0;
|
||||
if a.any_event() || removed_adt > 0 {
|
||||
if a.any_event() || removed_adt > 0 || added_adt > 0 {
|
||||
trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
|
||||
}
|
||||
if !(a.any_event()
|
||||
|| removed_adt > 0
|
||||
|| added_adt > 0
|
||||
|| delta_ms > TRACE_FRAME_MS
|
||||
|| trace.frame <= trace.log_until)
|
||||
{
|
||||
@@ -230,11 +247,13 @@ fn trace_stream(
|
||||
let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
|
||||
}
|
||||
let line = format!(
|
||||
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img}\n",
|
||||
"{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img}\n",
|
||||
trace.frame,
|
||||
time.elapsed_secs(),
|
||||
entities.iter().len(),
|
||||
a.stream_ms,
|
||||
a.furnish_ms,
|
||||
a.mfurnish_ms,
|
||||
a.spawn_ms,
|
||||
a.collider_ms,
|
||||
a.tiles_dropped,
|
||||
@@ -242,6 +261,8 @@ fn trace_stream(
|
||||
a.placement_entities_dropped,
|
||||
a.tiles_requested,
|
||||
a.tiles_spawned,
|
||||
a.cells_spawned,
|
||||
a.model_meshes_built,
|
||||
a.placements_spawned,
|
||||
a.colliders_attached,
|
||||
);
|
||||
|
||||
@@ -469,13 +469,16 @@ pub(super) fn sync_glue_scene(
|
||||
device: Res<bevy::render::renderer::RenderDevice>,
|
||||
queue: Res<bevy::render::renderer::RenderQueue>,
|
||||
// The scene's particle-emitter spawn wiring (decision 0539 §5) + the skin-palette table and
|
||||
// its mirror registry (decision 0720) — one tuple param (the 16-SystemParam ceiling).
|
||||
// its mirror registry (decision 0720) + the model-forms cache and mesh store (decision 0834)
|
||||
// — one tuple param (the 16-SystemParam ceiling).
|
||||
particle_assets: (
|
||||
ResMut<crate::rig_palette::RigPalettes>,
|
||||
ResMut<crate::rig_palette::RigPaletteMirrors>,
|
||||
ResMut<crate::model_forms::ModelForms>,
|
||||
ResMut<Assets<Mesh>>,
|
||||
),
|
||||
) {
|
||||
let (mut palettes, mut mirrors) = particle_assets;
|
||||
let (mut palettes, mut mirrors, mut forms, mut mesh_assets) = particle_assets;
|
||||
let Some(booth) = booths.0.get(GLUE_SLOT) else {
|
||||
return;
|
||||
};
|
||||
@@ -597,10 +600,26 @@ pub(super) fn sync_glue_scene(
|
||||
rig.probe[1].w,
|
||||
rig.probe[2].w,
|
||||
);
|
||||
// The scene model's render forms, built NOW (decision 0834): one backdrop model, behind
|
||||
// the glue screen — the booth-lane exception to the paced rule.
|
||||
let scene_key = scene
|
||||
.handle
|
||||
.as_ref()
|
||||
.map(crate::model_forms::ModelKey::from)
|
||||
.expect("scene.handle is Some — `model` above came from it");
|
||||
forms.ensure_now(
|
||||
scene_key,
|
||||
crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED,
|
||||
&model.submeshes,
|
||||
&mut mesh_assets,
|
||||
);
|
||||
let stat_forms = forms.static_meshes(scene_key).unwrap_or(&[]);
|
||||
let skin_forms = forms.skinned_meshes(scene_key).unwrap_or(&[]);
|
||||
let scene_parts: Vec<BoothPart> = model
|
||||
.submeshes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
.enumerate()
|
||||
.map(|(pi, s)| {
|
||||
let material = crate::model_render::model_material(
|
||||
&mut cache,
|
||||
&mut materials,
|
||||
@@ -625,8 +644,11 @@ pub(super) fn sync_glue_scene(
|
||||
&light,
|
||||
);
|
||||
BoothPart {
|
||||
skinned: Some(s.skinned_mesh.clone()),
|
||||
static_mesh: s.mesh.clone(),
|
||||
skinned: skin_forms.get(pi).cloned(),
|
||||
static_mesh: stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
material,
|
||||
// The scene's authored per-batch material alpha (decision 0807). UI_Tauren
|
||||
// alone: a 0.55 `LENSALPHA` corner vignette, a 0.99 `GROUNDSHADOW` decal, 0.15
|
||||
|
||||
@@ -30,6 +30,8 @@ pub(super) fn sync_test_portraits(
|
||||
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
|
||||
anim_data: Option<Res<crate::creature_anim::AnimData>>,
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
mut mesh_assets: ResMut<Assets<Mesh>>,
|
||||
) {
|
||||
if !test_mode(&mut env_cache) || *test_done {
|
||||
return;
|
||||
@@ -42,6 +44,8 @@ pub(super) fn sync_test_portraits(
|
||||
&path,
|
||||
&asset_server,
|
||||
&m2s,
|
||||
&mut forms,
|
||||
&mut mesh_assets,
|
||||
&mut wow_mats,
|
||||
&booth_light,
|
||||
&mut test_cache,
|
||||
@@ -65,6 +69,8 @@ fn bake_test(
|
||||
path: &str,
|
||||
asset_server: &AssetServer,
|
||||
m2s: &Assets<M2Model>,
|
||||
forms: &mut crate::model_forms::ModelForms,
|
||||
mesh_assets: &mut Assets<Mesh>,
|
||||
wow_mats: &mut Assets<WowModelMaterial>,
|
||||
booth_light: &BoothLight,
|
||||
cache: &mut crate::model_render::MaterialCache,
|
||||
@@ -99,13 +105,24 @@ fn bake_test(
|
||||
p.replace('\\', "/").to_ascii_lowercase()
|
||||
))
|
||||
});
|
||||
// The test model's render forms, built NOW (decision 0834) — a dev harness bake, one model.
|
||||
let key = crate::model_forms::ModelKey::from(&handle);
|
||||
forms.ensure_now(
|
||||
key,
|
||||
crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED,
|
||||
&model.submeshes,
|
||||
mesh_assets,
|
||||
);
|
||||
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
|
||||
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
|
||||
let parts_against = |light: &bevy::render::render_resource::Buffer,
|
||||
cache: &mut crate::model_render::MaterialCache,
|
||||
wow_mats: &mut Assets<WowModelMaterial>| {
|
||||
model
|
||||
.submeshes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
.enumerate()
|
||||
.map(|(pi, s)| {
|
||||
let mat = crate::model_render::model_material(
|
||||
cache,
|
||||
wow_mats,
|
||||
@@ -130,8 +147,11 @@ fn bake_test(
|
||||
light,
|
||||
);
|
||||
BoothPart {
|
||||
skinned: Some(s.skinned_mesh.clone()),
|
||||
static_mesh: s.mesh.clone(),
|
||||
skinned: skin_forms.get(pi).cloned(),
|
||||
static_mesh: stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
material: mat,
|
||||
// The harness parses submeshes straight off the model, so the authored alpha is
|
||||
// right here — an eyeball bake should show the batch dimming the artist wrote.
|
||||
|
||||
@@ -254,6 +254,8 @@ fn build_markers(
|
||||
mut commands: Commands,
|
||||
mut roots: Query<(Entity, &mut QuestMarkerRoot)>,
|
||||
m2s: Res<Assets<M2Model>>,
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
mut mesh_assets: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||||
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
|
||||
light: Res<SharedLightBuffer>,
|
||||
@@ -270,6 +272,16 @@ fn build_markers(
|
||||
let Some(model) = m2s.get(&marker.handle) else {
|
||||
continue; // marker M2 still loading
|
||||
};
|
||||
// The marker's render forms, built NOW (decision 0834): two tiny models per map, on the
|
||||
// booth-lane exception — a marker popping a frame late over a questgiver would be a
|
||||
// regression nothing here needs.
|
||||
let key = crate::model_forms::ModelKey::from(&marker.handle);
|
||||
forms.ensure_now(
|
||||
key,
|
||||
crate::model_forms::WANT_STATIC | crate::model_forms::WANT_SKINNED,
|
||||
&model.submeshes,
|
||||
&mut mesh_assets,
|
||||
);
|
||||
// The unit's joint set + attachment table — absent while its own model still loads (or
|
||||
// forever on a boneless/model-less unit, which then simply never shows a marker).
|
||||
let Some((unit, anchor)) = index
|
||||
@@ -329,7 +341,9 @@ fn build_markers(
|
||||
// The client arms the marker's animation at status receive — the cards' bob loop starts
|
||||
// its cursor here (the same clock `face_billboards` samples).
|
||||
let arm_ms = time.elapsed().as_millis() as u32;
|
||||
for sub in &model.submeshes {
|
||||
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
|
||||
let skin_forms = forms.skinned_meshes(key).unwrap_or(&[]);
|
||||
for (pi, sub) in model.submeshes.iter().enumerate() {
|
||||
let material = model_material(
|
||||
&mut cache,
|
||||
&mut materials,
|
||||
@@ -362,7 +376,12 @@ fn build_markers(
|
||||
// world placement each frame; the armed loop plays the bob at the pivot.
|
||||
let child = commands
|
||||
.spawn((
|
||||
Mesh3d(sub.mesh.clone()),
|
||||
Mesh3d(
|
||||
stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
MeshMaterial3d(material),
|
||||
MeshTag(alpha_bits(1.0)),
|
||||
Transform::IDENTITY,
|
||||
@@ -380,9 +399,12 @@ fn build_markers(
|
||||
// including the palette-full fallback (slot 0).
|
||||
let use_rig = host.as_ref().is_some_and(|h| h.slot != 0);
|
||||
let mesh = if use_rig {
|
||||
sub.skinned_mesh.clone()
|
||||
skin_forms.get(pi).cloned().unwrap_or_default()
|
||||
} else {
|
||||
sub.mesh.clone()
|
||||
stat_forms
|
||||
.get(pi)
|
||||
.map(|(h, _)| h.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let rig_tag = host
|
||||
.as_ref()
|
||||
|
||||
@@ -189,8 +189,9 @@ pub(crate) fn simulate_ribbons(
|
||||
transforms: Query<&GlobalTransform, Without<RibbonTrail>>,
|
||||
images: Res<Assets<Image>>,
|
||||
mut quads: ResMut<EffectQuads>,
|
||||
// The owning model's render alpha — the trail's draw gate (decision 0827).
|
||||
model_alphas: Query<&crate::model_fade::ModelAlpha>,
|
||||
// The owning model's render alpha — the trail's draw gate (decision 0827), composed along the
|
||||
// attached-model chain (0833).
|
||||
model_alphas: crate::model_fade::ModelAlphas,
|
||||
// Trails belong to the world lane (no booth ribbons; a booth-parked owner's strip is eaten
|
||||
// by the shader's farclip wall, exactly as on the material path).
|
||||
world_cam: Query<Entity, With<WorldCamera>>,
|
||||
@@ -289,10 +290,7 @@ pub(crate) fn simulate_ribbons(
|
||||
// model's render alpha and drops the draw below a threshold (decision 0827). This is what
|
||||
// takes your own weapon's enchant trail out of your face in first person, and keeps a
|
||||
// not-yet-shown unit's trail off the screen while its body is still at alpha 0.
|
||||
if alpha_src
|
||||
.and_then(|e| model_alphas.get(e).ok())
|
||||
.is_some_and(|a| a.0 <= 1e-3)
|
||||
{
|
||||
if alpha_src.is_some_and(|e| model_alphas.get(e) <= 1e-3) {
|
||||
continue;
|
||||
}
|
||||
let n = edges.len() + usize::from(head.is_some());
|
||||
|
||||
@@ -15,8 +15,8 @@ use bevy::pbr::{
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{
|
||||
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, CompareFunction,
|
||||
RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, ColorWrites,
|
||||
CompareFunction, RenderPipelineDescriptor, SpecializedMeshPipelineError,
|
||||
};
|
||||
use bevy::shader::ShaderRef;
|
||||
|
||||
@@ -65,6 +65,10 @@ pub struct WowModelKey {
|
||||
/// orders draws for batching, not authorship, so coplanar ties were unstable (the run-to-run
|
||||
/// "pale film" on buildings); the bias reproduces the authored layering deterministically.
|
||||
wmo_batch_order: u16,
|
||||
/// Depth-prime twin (`clutter_fade.z` bit 9 — `model_render::zfill_material`, the reference's
|
||||
/// `M2UseZFill` clone command, wow-re `m2-blend-promotion-zfill.md` §4): `specialize` masks the
|
||||
/// colour writes off, turns blend off, and forces depth-write ON.
|
||||
zfill: bool,
|
||||
}
|
||||
|
||||
impl From<&WowModelExt> for WowModelKey {
|
||||
@@ -79,6 +83,7 @@ impl From<&WowModelExt> for WowModelKey {
|
||||
modulate: markers & 0x80 != 0,
|
||||
modulate2x: markers & 0x100 != 0,
|
||||
wmo_batch_order: e.sun_scale.y as u16,
|
||||
zfill: markers & 0x200 != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +284,26 @@ impl MaterialExtension for WowModelExt {
|
||||
});
|
||||
}
|
||||
}
|
||||
if key.bind_group_data.zfill {
|
||||
// The depth-prime twin (`model_render::zfill_material` — the reference's `M2UseZFill`
|
||||
// clone, wow-re `m2-blend-promotion-zfill.md` §4): a translucent model's z-writing
|
||||
// batches draw once colour-masked-off, blend-off, z-write ON, sorted before its colour
|
||||
// batches (the material's negative sort bias) — so each colour fragment passes
|
||||
// GreaterEqual only at the model's own nearest surface, and interior/overlapped layers
|
||||
// fail. One blended layer everywhere: no self-overlap darkening on a stealthed body.
|
||||
// The fragment still runs its discards (farclip, the cutout when `model_flags.y`);
|
||||
// its colour output is computed and masked (an early return would be the natural
|
||||
// spelling, but naga's MSL backend miscompiles the dead tail — see the shader note).
|
||||
if let Some(frag) = descriptor.fragment.as_mut() {
|
||||
if let Some(target) = frag.targets.get_mut(0).and_then(|t| t.as_mut()) {
|
||||
target.blend = None;
|
||||
target.write_mask = ColorWrites::empty();
|
||||
}
|
||||
}
|
||||
if let Some(ds) = descriptor.depth_stencil.as_mut() {
|
||||
ds.depth_write_enabled = true;
|
||||
}
|
||||
}
|
||||
let key = &key.bind_group_data;
|
||||
if key.modulate || key.modulate2x {
|
||||
// The MULTIPLY blends (decision 0528, byte-verified wow-re `m2-depth-blend-state`):
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! [`crate::clutter::ClutterPlugin`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use benilla_assets::coords::{bevy_to_wow, placement_rotation, wow_to_bevy};
|
||||
@@ -40,11 +39,18 @@ use crate::world_map::{CurrentMap, MapCatalogRes};
|
||||
use crate::SPAWN_XY;
|
||||
|
||||
mod collider;
|
||||
mod furnish;
|
||||
mod queries;
|
||||
mod spawn;
|
||||
|
||||
use collider::{finish_colliders, terrain_collider_data};
|
||||
use furnish::furnish_tile_cells;
|
||||
use spawn::prop_light::WmoDoodadInst;
|
||||
use spawn::spawn_loaded_placements;
|
||||
// The WMO prop-light machinery lives spawn-side (0830's named carve, executed in 0832); the two
|
||||
// outside consumers — `crate::interior` and `crate::entities`' `wmo_props` — keep their
|
||||
// `terrain_stream::X` paths, same as the `queries` items below.
|
||||
pub(crate) use spawn::prop_light::{fold_interior_probe, PropLobeLight};
|
||||
// The shared placed-model assembler + the off-thread collider build — also the WMO-gameobject
|
||||
// doodad-prop path's spawner (`crate::entities`' `wmo_props`: the ship's sails ride the streamed
|
||||
// gameobject entity, and its cargo hulls ride the boat's kinematic body).
|
||||
@@ -75,6 +81,9 @@ pub(crate) struct TerrainStreamer {
|
||||
/// The map directory the loaded tiles are for (e.g. `"Azeroth"`); a change means a cross-map
|
||||
/// teleport, so every tile is dropped and re-streamed for the new map.
|
||||
map_dir: Option<String>,
|
||||
/// The focus tile [`stream_terrain`] last streamed around — the furnisher's nearest-first
|
||||
/// ordering key, published here so it never recomputes the focus ladder a second time.
|
||||
focus: (i32, i32),
|
||||
/// The current map's WDT tile index (decision 0476), requested with the map: ADT requests wait
|
||||
/// for it and consult its `MAIN` grid — open ocean authors no tiles to ask for.
|
||||
wdt: Option<Handle<WdtIndex>>,
|
||||
@@ -94,10 +103,10 @@ pub(crate) struct TerrainStreamer {
|
||||
const GLOBAL_WMO_UID: u32 = u32::MAX;
|
||||
|
||||
impl TerrainStreamer {
|
||||
/// Residency counts for the debug panel's World readout: `(spawned, requested)` — tiles whose
|
||||
/// terrain entity exists vs. every tile in the stream window (spawned + still loading).
|
||||
/// Residency counts for the debug panel's World readout: `(spawned, requested)` — tiles fully
|
||||
/// up (root + cells furnished) vs. every tile in the stream window (up + loading/furnishing).
|
||||
pub(crate) fn residency(&self) -> (usize, usize) {
|
||||
let spawned = self.tiles.values().filter(|t| t.entity.is_some()).count();
|
||||
let spawned = self.tiles.values().filter(|t| t.furnished).count();
|
||||
(spawned, self.tiles.len())
|
||||
}
|
||||
}
|
||||
@@ -105,9 +114,19 @@ impl TerrainStreamer {
|
||||
/// One loaded tile: the resident asset handle, the terrain entity, and the placement ids it references.
|
||||
struct TileState {
|
||||
handle: Handle<AdtTile>,
|
||||
/// `None` until the `AdtTile` finishes loading and the terrain mesh entity is spawned (which is also
|
||||
/// `None` until the `AdtTile` finishes loading and the terrain root entity is spawned (which is also
|
||||
/// when this tile's placements are registered).
|
||||
entity: Option<Entity>,
|
||||
/// The tile's terrain material, made at root spawn and shared by every cell the furnisher hangs
|
||||
/// off the root (one material per tile — the loader's arrays make that possible).
|
||||
material: Option<Handle<TerrainMaterial>>,
|
||||
/// The furnishing cursor: the next `AdtTile::chunks` index whose cell [`furnish::furnish_tile_cells`]
|
||||
/// should build. Cells land a few per frame while live (decision 0832) — the mesh-asset creation
|
||||
/// IS the pacing point; everything downstream (extract, prepare, upload) follows its rate.
|
||||
next_cell: usize,
|
||||
/// `true` once every cell is up (or cells are ablated off): the "this tile is really resident"
|
||||
/// bit the loading-screen accounting reads, so a reveal never shows a root with bare ground.
|
||||
furnished: bool,
|
||||
/// uniqueIds of the doodad/WMO placements this tile references — registered once the tile's `AdtTile`
|
||||
/// loads, refcount-released when the tile unloads.
|
||||
placements: Vec<u32>,
|
||||
@@ -155,81 +174,6 @@ struct Placement {
|
||||
refs: u32,
|
||||
}
|
||||
|
||||
/// One WMO doodad prop instance: its M2 handle and the **world** transform (the WMO instance
|
||||
/// transform composed with the doodad's WMO-local transform), spawned once the M2 asset loads.
|
||||
struct WmoDoodadInst {
|
||||
handle: Handle<M2Model>,
|
||||
transform: Transform,
|
||||
/// Every WMO group whose MODR names this prop — the portal-cull key, so a prop is hidden with
|
||||
/// the rooms it furnishes and drawn while any one of them is visible. Empty for a MODD no group
|
||||
/// references (the reference never instantiates one at all; we still show it, uncullable,
|
||||
/// rather than change what draws today).
|
||||
groups: Arc<[u16]>,
|
||||
/// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this
|
||||
/// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR
|
||||
/// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold
|
||||
/// reference point needs the M2 bounds).
|
||||
light: PropLight,
|
||||
spawned: bool,
|
||||
}
|
||||
|
||||
/// A WMO prop's placement-resolved lighting: the asset-level [`DoodadBase`] with the owning group's
|
||||
/// MOLR lights already transformed to WORLD (Bevy) space — so the spawn-time SH fold needs only the
|
||||
/// loaded M2's bounds (its reference point) and nothing from the WMO asset.
|
||||
enum PropLight {
|
||||
Exterior,
|
||||
Interior {
|
||||
/// `cap96(MODD.colour)` — the ambient word (0–1 RGB).
|
||||
ambient: [f32; 3],
|
||||
/// `floor112(MODD.colour)` — the diffuse word, committed on the fixed interior axis.
|
||||
diffuse: [f32; 3],
|
||||
/// The owning group's MOLR omni lights: world (Bevy) position, colour × intensity, and the
|
||||
/// disk `attenStart`/`attenEnd` window (the fold's range gate).
|
||||
lights: Vec<PropLobeLight>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour
|
||||
/// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the
|
||||
/// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict).
|
||||
pub(crate) struct PropLobeLight {
|
||||
pub(crate) pos: Vec3,
|
||||
pub(crate) color_i: [f32; 3],
|
||||
pub(crate) atten_start: f32,
|
||||
pub(crate) atten_end: f32,
|
||||
}
|
||||
|
||||
/// Fold one interior committed light into its 7-row SH probe: the ambient word + the diffuse word
|
||||
/// as a directional on the FIXED interior axis + each MOLR lobe windowed by its disk
|
||||
/// attenStart/attenEnd from `ref_point` (the byte-verified `0x69e1c0` falloff: d ≤ start → 1;
|
||||
/// d ≥ end → excluded; else linear). One definition for both interior lanes — the MODD prop
|
||||
/// (spawn-time, MODD-colour words) and the GameObject footprint (classify-time, MOCV-derived
|
||||
/// words); the SH closed form itself is [`prop_probe_coeffs`].
|
||||
pub(crate) fn fold_interior_probe(
|
||||
ambient: [f32; 3],
|
||||
diffuse: [f32; 3],
|
||||
ref_point: Vec3,
|
||||
lights: &[PropLobeLight],
|
||||
) -> [bevy::math::Vec4; 7] {
|
||||
// Toward-light, Bevy space: wow (0.30822, 0.30822, 0.9) → (−y, z, −x).
|
||||
let mut lobes: Vec<(Vec3, [f32; 3])> = vec![(Vec3::new(-0.30822, 0.9, -0.30822), diffuse)];
|
||||
for l in lights {
|
||||
let dv = l.pos - ref_point;
|
||||
let dist = dv.length();
|
||||
let gain = if dist <= l.atten_start {
|
||||
1.0
|
||||
} else if dist >= l.atten_end || l.atten_end <= l.atten_start {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - (dist - l.atten_start) / (l.atten_end - l.atten_start)
|
||||
};
|
||||
if gain > 0.0 {
|
||||
lobes.push((dv / dist.max(1e-4), l.color_i.map(|c| c * gain)));
|
||||
}
|
||||
}
|
||||
crate::lighting::prop_probe_coeffs(ambient, &lobes)
|
||||
}
|
||||
|
||||
/// **Where the world streams from**, in WoW coordinates — one rule, shared by the detailed
|
||||
/// streamer and the distant WDL ring (which used to carry its own copy under a comment promising
|
||||
/// they agreed).
|
||||
@@ -297,6 +241,7 @@ impl Plugin for TerrainPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<TerrainStreamer>()
|
||||
.init_resource::<Placements>()
|
||||
.init_resource::<crate::model_forms::ModelForms>()
|
||||
.init_resource::<CurrentArea>()
|
||||
// **The streaming chain lives in `WorldStage::Stream`** — the load-bearing half of the
|
||||
// frame ordering contract (schedule.rs): the teleport snap (Input) → the streamer
|
||||
@@ -312,6 +257,7 @@ impl Plugin for TerrainPlugin {
|
||||
(
|
||||
finish_colliders,
|
||||
stream_terrain,
|
||||
furnish_tile_cells,
|
||||
spawn_loaded_placements,
|
||||
sync_interior_volumes,
|
||||
)
|
||||
@@ -323,6 +269,17 @@ impl Plugin for TerrainPlugin {
|
||||
// would leave three systems walking empty queries to prove it every frame.
|
||||
.run_if(crate::schedule::world_is_live),
|
||||
)
|
||||
// The model-forms furnisher (decision 0834) runs UNGATED, unlike the chain above: the
|
||||
// glue screens' character preview requests forms pre-world (`update_display_models`
|
||||
// serves the glue stage too), and a world-live gate would starve it forever. Ordered
|
||||
// before the placement spawner so a form completed this frame can land this frame;
|
||||
// everything it does is demand-driven, so with no requests it is a no-op.
|
||||
.add_systems(
|
||||
Update,
|
||||
crate::model_forms::furnish_model_forms
|
||||
.in_set(WorldStage::Stream)
|
||||
.before(spawn_loaded_placements),
|
||||
)
|
||||
// Leaving the world releases it. Without this, a `/logout` back to character select
|
||||
// would leave the whole streamed world resident and unowned — the gate above stops it
|
||||
// being *maintained*, which is precisely what makes an abandoned world immortal.
|
||||
@@ -472,6 +429,7 @@ fn stream_terrain(
|
||||
let tiling = cfg.as_ref().map(|c| c.tex_tiles).unwrap_or(8.0);
|
||||
let (cx, cy) = world_to_tile(center[0], center[1]);
|
||||
let (cx, cy) = (cx as i32, cy as i32);
|
||||
state.focus = (cx, cy);
|
||||
|
||||
// Desired set: the (2r+1)² square around the focus, clamped to the 64×64 tile grid — and
|
||||
// filtered to tiles the WDT says exist (open ocean authors none). The filter also fixes the
|
||||
@@ -571,6 +529,9 @@ fn stream_terrain(
|
||||
handle: asset_server
|
||||
.load(format!("mpq://World/Maps/{dir}/{dir}_{tx}_{ty}.adt")),
|
||||
entity: None,
|
||||
material: None,
|
||||
next_cell: 0,
|
||||
furnished: false,
|
||||
placements: Vec::new(),
|
||||
liquid: Vec::new(),
|
||||
clutter: Vec::new(),
|
||||
@@ -612,27 +573,13 @@ fn stream_terrain(
|
||||
// exterior-scene cull needs (decision 0780; a 533 yd slab the camera stands on intersects
|
||||
// every portal window, so it could never be hidden), and it also gives Bevy's own frustum cull
|
||||
// something smaller than half a kilometre to reject. Despawning the root takes the cells.
|
||||
//
|
||||
// The cells themselves do NOT spawn here (decision 0832): a whole row of tiles finishes
|
||||
// decoding in one frame, and spawning ~1300 loader-built cell meshes at once handed the
|
||||
// render world their entire extract/prepare/upload as one 80–105 ms (process-CPU) frame —
|
||||
// the B181 first-contact spike. `furnish_tile_cells` (chained right after this system)
|
||||
// builds them from `AdtTile::chunks` + `shading` a few per frame while live.
|
||||
let mut tile_ent = commands.spawn((Transform::IDENTITY, Visibility::default()));
|
||||
// Ablation switch (`WOW_NO_TILE_CELLS=1`): spawn the tile — asset residency, collider,
|
||||
// placements, liquid, clutter — without its per-chunk cell entities, isolating the
|
||||
// render-entity lane from the asset lane when measuring a streaming cost. The B181 carve
|
||||
// ran on it (a crossing's spike survived zero cells, exonerating the entity lane); the
|
||||
// `WOW_NO_LIQUID`/`WOW_NO_PARTICLES` family's pattern.
|
||||
if std::env::var("WOW_NO_TILE_CELLS").is_err() {
|
||||
tile_ent.with_children(|cells| {
|
||||
for mesh in &adt.chunk_meshes {
|
||||
cells.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::IDENTITY, // chunk meshes are already in absolute world coords
|
||||
// ADT terrain is exterior scene: from inside a WMO a cell draws only through a
|
||||
// portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` — see
|
||||
// `crate::exterior_cull`).
|
||||
crate::exterior_cull::ExteriorScene,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some((verts, tris)) = collider_data {
|
||||
// `GroundDecalSurface`: terrain receives the selection ring (see `crate::collision`).
|
||||
// `PickOccluder`: terrain clamps the mouse pick (the reference's world trace).
|
||||
@@ -643,6 +590,7 @@ fn stream_terrain(
|
||||
));
|
||||
}
|
||||
tile.entity = Some(tile_ent.id());
|
||||
tile.material = Some(material);
|
||||
activity.tiles_spawned += 1;
|
||||
|
||||
// Register this tile's doodad/WMO placements (deduped + refcounted by uniqueId across tiles).
|
||||
@@ -702,13 +650,14 @@ fn stream_terrain(
|
||||
// focus tile that doesn't exist (map edge) counts as resident so we never get stuck waiting
|
||||
// for ground that isn't there.
|
||||
if let Some(p) = load_progress.as_mut() {
|
||||
let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.entity.is_some());
|
||||
// "Spawned" means FURNISHED (root + every cell): the furnisher is uncapped while the
|
||||
// cover is up, so this costs entry nothing — but it keeps a reveal from ever showing a
|
||||
// root whose ground is still landing (`furnished` lags root spawn by one frame here,
|
||||
// since the furnisher runs after this system in the same chain).
|
||||
let spawned = |c: &(i32, i32)| state.tiles.get(c).is_some_and(|t| t.furnished);
|
||||
p.total = desired.len();
|
||||
p.ready = desired.iter().filter(|c| spawned(c)).count();
|
||||
p.focus_resident = state
|
||||
.tiles
|
||||
.get(&(cx, cy))
|
||||
.is_none_or(|t| t.entity.is_some());
|
||||
p.focus_resident = state.tiles.get(&(cx, cy)).is_none_or(|t| t.furnished);
|
||||
// The focus neighbourhood's placements join the accounting (bar + scene term). A placement
|
||||
// is *up* once its own model spawned AND its WMO props (each an M2 arriving on its own
|
||||
// schedule) have — the furniture the reveal would otherwise pop in. Placements of tiles not
|
||||
@@ -722,7 +671,7 @@ fn stream_terrain(
|
||||
continue; // off-grid or WDT says no tile — nothing to wait for
|
||||
}
|
||||
match state.tiles.get(&c) {
|
||||
Some(t) if t.entity.is_some() => {
|
||||
Some(t) if t.furnished => {
|
||||
for uid in &t.placements {
|
||||
if let Some(pl) = placements.by_id.get(uid) {
|
||||
let up = pl.spawned && pl.doodads.iter().all(|d| d.spawned);
|
||||
@@ -896,6 +845,7 @@ fn release_world(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<TerrainStreamer>,
|
||||
mut placements: ResMut<Placements>,
|
||||
mut forms: ResMut<crate::model_forms::ModelForms>,
|
||||
mut progress: Option<ResMut<WorldLoadProgress>>,
|
||||
mut activity: ResMut<crate::perf::StreamActivity>,
|
||||
) {
|
||||
@@ -906,6 +856,10 @@ fn release_world(
|
||||
"terrain: leaving the world — releasing {} tiles",
|
||||
state.tiles.len()
|
||||
);
|
||||
// The model-form cache frees per asset on `Unused` events; leaving the world drops the whole
|
||||
// cache in one deterministic sweep instead — nothing world-scoped may survive a logout, and
|
||||
// an entry whose asset another holder keeps resident would otherwise pin its meshes forever.
|
||||
forms.clear();
|
||||
drop_streamed_world(&mut commands, &mut state, &mut placements, &mut activity);
|
||||
state.map_dir = None;
|
||||
state.wdt = None;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Paced per-cell tile furnishing — the fix for B181's *first-contact* spike (decision 0832).
|
||||
//!
|
||||
//! A landed tile's ~256 MCNK cell meshes used to arrive as loader-built sub-assets: the whole
|
||||
//! set (and, decodes bunching, usually the whole 5-tile row's ~1300) hit the render world's
|
||||
//! extract/prepare/upload in ONE frame — 80–105 ms of process CPU at the Emerald Dream pin,
|
||||
//! 120–240 ms at Undercity, on every first crossing of a tile line. No budget downstream of the
|
||||
//! loader could spread work the loader had already packaged, so the build moved here: each cell's
|
||||
//! mesh is made from the decoded [`AdtTile::chunks`] + [`AdtTile::shading`]
|
||||
//! ([`benilla_assets::chunk_to_mesh`]), a bounded number per frame while live, nearest tile
|
||||
//! first. Mesh-asset creation is the pacing point; everything downstream follows its rate.
|
||||
//!
|
||||
//! While the body is **not** live (entry, teleport, world-stale) the cap is off: the loading
|
||||
//! screen exists to absorb exactly that burst, and the residency the settle release reads counts
|
||||
//! *furnished* tiles, so the cover never lifts onto bare ground.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use benilla_assets::{chunk_to_mesh, AdtTile};
|
||||
use bevy::camera::primitives::MeshAabb;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::player::Player;
|
||||
|
||||
use super::TerrainStreamer;
|
||||
|
||||
/// Cells built per frame while live. Measured on the B181 carve: a landed cell mesh costs ~70 µs
|
||||
/// end-to-end (asset add + extract + prepare + upload, dev profile, Metal) — 64 ≈ 4.5 ms of
|
||||
/// render-side work per frame, and a whole fresh row (5 × 256 cells, two tiles out, in fog)
|
||||
/// furnishes in ~20 frames. The placement cap (`SPAWN_COUNT_CAP`) is this same idea one lane over.
|
||||
const CELL_SPAWN_CAP: usize = 64;
|
||||
|
||||
/// Build + spawn the cell entities of spawned-but-unfurnished tiles, [`CELL_SPAWN_CAP`] per frame
|
||||
/// while live, nearest tile to the stream focus first. Chained directly after `stream_terrain`
|
||||
/// (roots spawned there furnish from the same frame on) and before `spawn_loaded_placements`.
|
||||
pub(super) fn furnish_tile_cells(
|
||||
mut commands: Commands,
|
||||
mut state: ResMut<TerrainStreamer>,
|
||||
tiles: Res<Assets<AdtTile>>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut activity: ResMut<crate::perf::StreamActivity>,
|
||||
player: Option<Res<Player>>,
|
||||
) {
|
||||
let t0 = Instant::now();
|
||||
let live = player
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.active && !p.settling && !p.world_stale);
|
||||
let cap = if live { CELL_SPAWN_CAP } else { usize::MAX };
|
||||
// Ablation switch (`WOW_NO_TILE_CELLS=1`): tiles stream — asset residency, collider,
|
||||
// placements, liquid, clutter — without their per-chunk cell entities, isolating the
|
||||
// render-entity lane from the asset lane when measuring a streaming cost. The B181 carve ran
|
||||
// on it (a crossing's spike survived zero cells, exonerating the entity lane); the
|
||||
// `WOW_NO_LIQUID`/`WOW_NO_PARTICLES` family's pattern. Tiles still MARK furnished, so the
|
||||
// loading-screen residency (which now waits on furnishing) converges.
|
||||
let ablated = std::env::var("WOW_NO_TILE_CELLS").is_ok();
|
||||
|
||||
let focus = state.focus;
|
||||
let mut pending: Vec<(i32, (i32, i32))> = state
|
||||
.tiles
|
||||
.iter()
|
||||
.filter(|(_, t)| t.entity.is_some() && !t.furnished)
|
||||
.map(|(&(tx, ty), _)| ((tx - focus.0).abs().max((ty - focus.1).abs()), (tx, ty)))
|
||||
.collect();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
pending.sort_unstable();
|
||||
|
||||
let mut built = 0usize;
|
||||
'tiles: for (_, key) in pending {
|
||||
let Some(tile) = state.tiles.get_mut(&key) else {
|
||||
continue;
|
||||
};
|
||||
let (Some(root), Some(material)) = (tile.entity, tile.material.clone()) else {
|
||||
continue; // unreachable given the filter above, but never worth a panic
|
||||
};
|
||||
// The handle keeps the asset resident for the tile's whole life, so a spawned tile's
|
||||
// `AdtTile` is always here; a missing one (asset store torn down mid-shutdown) just
|
||||
// leaves the tile unfurnished.
|
||||
let Some(adt) = tiles.get(&tile.handle) else {
|
||||
continue;
|
||||
};
|
||||
while tile.next_cell < adt.chunks.len() {
|
||||
if built >= cap {
|
||||
break 'tiles;
|
||||
}
|
||||
let i = tile.next_cell;
|
||||
tile.next_cell += 1;
|
||||
if ablated {
|
||||
continue;
|
||||
}
|
||||
// A hole-emptied chunk yields no mesh (and costs nothing — it doesn't count).
|
||||
let Some(mesh) = chunk_to_mesh(&adt.chunks[i], &adt.shading[i]) else {
|
||||
continue;
|
||||
};
|
||||
// The cell's Aabb, computed HERE and inserted explicitly: the mesh is
|
||||
// `RENDER_WORLD`-only, so Bevy's own `calculate_bounds` may find its data already
|
||||
// extracted — and the exterior cull FAILS OPEN on a missing Aabb (an unbounded cell
|
||||
// would draw forever, silently undoing 0780's cull unit).
|
||||
let aabb = mesh.compute_aabb();
|
||||
let handle = meshes.add(mesh);
|
||||
commands.entity(root).with_children(|cells| {
|
||||
let mut cell = cells.spawn((
|
||||
Mesh3d(handle),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::IDENTITY, // cell meshes are in absolute world coords
|
||||
// ADT terrain is exterior scene: from inside a WMO a cell draws only through
|
||||
// a portal window (`0x683bf0`, fed solely by the per-window walk `0x682fa0` —
|
||||
// see `crate::exterior_cull`).
|
||||
crate::exterior_cull::ExteriorScene,
|
||||
));
|
||||
if let Some(aabb) = aabb {
|
||||
cell.insert(aabb);
|
||||
}
|
||||
});
|
||||
built += 1;
|
||||
}
|
||||
tile.furnished = true;
|
||||
}
|
||||
activity.cells_spawned += built as u32;
|
||||
activity.furnish_ms += t0.elapsed().as_secs_f32() * 1000.0;
|
||||
}
|
||||
|
||||
/// The one thing the furnisher promises the rest of the streamer: a tile is `furnished` only
|
||||
/// when its cursor walked every chunk (or cells are ablated off) — never merely "root exists".
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The cap's arithmetic, kept honest in one place: a full fresh row must furnish in well
|
||||
/// under a second of frames at 60 Hz.
|
||||
#[test]
|
||||
fn a_fresh_row_furnishes_in_under_half_a_second() {
|
||||
let row_cells: usize = 5 * 256;
|
||||
let frames = row_cells.div_ceil(super::CELL_SPAWN_CAP);
|
||||
assert!(
|
||||
frames <= 30,
|
||||
"a 5-tile row must land within ~half a second at 60 Hz, got {frames} frames"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ pub(crate) fn spawn_model_entities(
|
||||
palettes: &mut crate::rig_palette::RigPalettes,
|
||||
light: &Buffer,
|
||||
submeshes: &[ModelSubmesh],
|
||||
// The model's app-built render forms (decision 0834), index-parallel with `submeshes`: the
|
||||
// static handle + its build-time `Aabb` per batch, and the skinned twins when this model's
|
||||
// lane rigs (`None` otherwise). Callers gate on `ModelForms::require`, so by the time a
|
||||
// placement spawns these are complete.
|
||||
forms: crate::model_forms::FormSlices<'_>,
|
||||
transform: Transform,
|
||||
is_wmo: bool,
|
||||
// The static terrain-shade selector for every batch (the MCSH sample at this placement's base →
|
||||
@@ -127,6 +132,11 @@ pub(crate) fn spawn_model_entities(
|
||||
}
|
||||
let mut skinned_meshes: Vec<Entity> = Vec::new();
|
||||
for (wmo_batch_idx, sub) in submeshes.iter().enumerate() {
|
||||
// The batch's app-built render form (decision 0834). Callers gate spawning on the forms
|
||||
// being complete, so a miss here is a broken contract — skip the batch rather than panic.
|
||||
let Some((stat_mesh, stat_aabb)) = forms.stat.get(wmo_batch_idx) else {
|
||||
continue;
|
||||
};
|
||||
// WMO batches carry their authored order (index + 1; 0 = non-WMO) into the material so the
|
||||
// pipeline can bias coplanar layers to resolve in MOBA file order — the byte-verified client
|
||||
// behaviour (wow-5875-re models/scratch/wmo-batch-blend-depth-state.md). Every WMO batch gets
|
||||
@@ -265,50 +275,60 @@ pub(crate) fn spawn_model_entities(
|
||||
None => BillboardCard::new(info, transform),
|
||||
},
|
||||
};
|
||||
(
|
||||
commands
|
||||
.spawn((
|
||||
Mesh3d(sub.mesh.clone()),
|
||||
MeshMaterial3d(cutout.clone()),
|
||||
Transform::from_translation(transform.transform_point(info.pivot)),
|
||||
ModelPart {
|
||||
kind,
|
||||
blend: sub.blend,
|
||||
},
|
||||
mesh_tag,
|
||||
card,
|
||||
))
|
||||
.id(),
|
||||
Vec3::ZERO,
|
||||
)
|
||||
let mut card_entity = commands.spawn((
|
||||
Mesh3d(stat_mesh.clone()),
|
||||
MeshMaterial3d(cutout.clone()),
|
||||
Transform::from_translation(transform.transform_point(info.pivot)),
|
||||
ModelPart {
|
||||
kind,
|
||||
blend: sub.blend,
|
||||
},
|
||||
mesh_tag,
|
||||
card,
|
||||
));
|
||||
// The build-time Aabb, inserted explicitly: the static form is `RENDER_WORLD`-only,
|
||||
// so Bevy's `calculate_bounds` can race extraction — and the exterior cull fails
|
||||
// OPEN on a missing bound (0832's rule, extended to the model lane).
|
||||
if let Some(aabb) = stat_aabb {
|
||||
card_entity.insert(*aabb);
|
||||
}
|
||||
(card_entity.id(), Vec3::ZERO)
|
||||
} else {
|
||||
// An animated doodad's ordinary submesh draws the skinned twin bound to the placement's
|
||||
// palette rig (decision 0720 — the rig slot in the tag; the entity keeps the placement
|
||||
// transform — culling/fade read it; the skinned draw itself is palette-driven).
|
||||
// Everything else — and the palette-full fallback (slot 0) — draws the static mesh.
|
||||
let use_rig = skin.is_some() && rig_slot != 0;
|
||||
let mesh = if use_rig {
|
||||
sub.skinned_mesh.clone()
|
||||
} else {
|
||||
sub.mesh.clone()
|
||||
};
|
||||
// The twin comes from the app-built forms (0834); a lane that didn't request it
|
||||
// (or a contract break) falls back to the static form, un-rigged, rather than warn
|
||||
// the picker with a joint-less "skinned" mesh.
|
||||
let skinned_mesh = (skin.is_some() && rig_slot != 0)
|
||||
.then(|| forms.skin.and_then(|s| s.get(wmo_batch_idx)).cloned())
|
||||
.flatten();
|
||||
let use_rig = skinned_mesh.is_some();
|
||||
let part_tag = if use_rig {
|
||||
MeshTag(crate::mesh_tag::rig_bits(rig_slot) | mesh_tag.0)
|
||||
} else {
|
||||
mesh_tag
|
||||
};
|
||||
let entity = commands
|
||||
.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(cutout.clone()),
|
||||
transform,
|
||||
ModelPart {
|
||||
kind,
|
||||
blend: sub.blend,
|
||||
},
|
||||
part_tag,
|
||||
))
|
||||
.id();
|
||||
let mut part_entity = commands.spawn((
|
||||
Mesh3d(skinned_mesh.unwrap_or_else(|| stat_mesh.clone())),
|
||||
MeshMaterial3d(cutout.clone()),
|
||||
transform,
|
||||
ModelPart {
|
||||
kind,
|
||||
blend: sub.blend,
|
||||
},
|
||||
part_tag,
|
||||
));
|
||||
// Static parts carry the build-time Aabb (the RENDER_WORLD rule above); a rigged
|
||||
// part's skinned mesh keeps main-world data, so `calculate_bounds` covers it as
|
||||
// before.
|
||||
if !use_rig {
|
||||
if let Some(aabb) = stat_aabb {
|
||||
part_entity.insert(*aabb);
|
||||
}
|
||||
}
|
||||
let entity = part_entity.id();
|
||||
if skin.is_some() {
|
||||
if let Some(root) = rig_root {
|
||||
commands
|
||||
|
||||
@@ -154,6 +154,11 @@ pub(super) fn spawn_emitters_for(
|
||||
owner,
|
||||
attach: None, // placed doodads/props are never attached models
|
||||
anchor: None, // anchor at the placement: an animated bone never drags the cloud
|
||||
// An animated doodad's rig goes when its placement unloads, and that IS this
|
||||
// model being destroyed — free the pool with it, the same rule the WMO-prop lane
|
||||
// beside this one already states (0826, missed here: a tile crossing left the
|
||||
// brazier's flame draining at the old spot).
|
||||
on_owner_loss: particles::OwnerLoss::Free,
|
||||
..default()
|
||||
},
|
||||
// A placed doodad's arm is NOT one-time: it re-rolls its variation every play-window
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
mod assemble;
|
||||
mod fx;
|
||||
pub(crate) mod prop_light;
|
||||
|
||||
pub(crate) use assemble::spawn_model_entities;
|
||||
pub(crate) use fx::point_light;
|
||||
@@ -15,24 +16,26 @@ use std::time::Instant;
|
||||
|
||||
use benilla_assets::coords::{wmo_doodad_local, wow_to_bevy};
|
||||
use benilla_assets::{AdtTile, DoodadBase, M2Model, WmoModel};
|
||||
use benilla_formats::M2Bounds;
|
||||
use benilla_formats::{world_to_tile, M2Bounds};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::collision::{camera_layers, walk_layers, GroundDecalSurface, PickOccluder};
|
||||
use crate::debug_panel::ModelKind;
|
||||
use crate::doodad_anim::wants_rig;
|
||||
use crate::interact::WorldObject;
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
use crate::lighting::{PropProbeSlot, PropProbes};
|
||||
use crate::liquid::{spawn_wmo_liquids, LiquidAssets};
|
||||
use crate::model_forms::{FormSlices, ModelForms, ModelKey, WANT_SKINNED, WANT_STATIC};
|
||||
use crate::model_render::{m2_url, ShadeSel};
|
||||
use crate::terrain::WowModelMaterial;
|
||||
use crate::wmo_portal::{WmoGroupVis, WmoPortalInstance, WmoRoom};
|
||||
|
||||
use super::collider::{build_collider_task, placement_collider_data, PendingCollider};
|
||||
use super::{
|
||||
doodad_ground_shade, fold_interior_probe, ModelHandle, Placements, PropLight, PropLobeLight,
|
||||
ShadeResolve, TerrainStreamer, WmoDoodadInst, SPAWN_BUDGET,
|
||||
doodad_ground_shade, ModelHandle, Placements, ShadeResolve, TerrainStreamer, SPAWN_BUDGET,
|
||||
};
|
||||
use prop_light::{fold_interior_probe, PropLight, PropLobeLight, WmoDoodadInst};
|
||||
|
||||
/// Placements (models or WMO props) spawned per frame while live — the count half of the landing
|
||||
/// budget; see the cap's comment in [`spawn_loaded_placements`]. ~150 × the measured ~50 µs
|
||||
@@ -43,6 +46,17 @@ const SPAWN_COUNT_CAP: usize = 150;
|
||||
/// Spawn the submesh entities for any placement whose model asset has finished loading. Each submesh
|
||||
/// gets the production [`WowModelMaterial`] (cutout + blend twin) and the same `ModelPart`/`DoodadFade`/
|
||||
/// `MeshTag` components the old spawn used — so the existing visibility/fade/lighting systems take over.
|
||||
/// The nested resource tuple of [`spawn_loaded_placements`] (the 16-SystemParam ceiling): the
|
||||
/// prop-probe table, the skin-palette table (0720), the stream-trace counters, the live/settling
|
||||
/// state the landing cap reads, and the model-forms cache (0834).
|
||||
type SpawnTables<'w> = (
|
||||
ResMut<'w, PropProbes>,
|
||||
ResMut<'w, crate::rig_palette::RigPalettes>,
|
||||
ResMut<'w, crate::perf::StreamActivity>,
|
||||
Option<Res<'w, crate::player::Player>>,
|
||||
ResMut<'w, ModelForms>,
|
||||
);
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_loaded_placements(
|
||||
mut commands: Commands,
|
||||
@@ -65,15 +79,10 @@ pub(super) fn spawn_loaded_placements(
|
||||
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
|
||||
// Nested to stay inside Bevy's 16-element system-param tuple limit: the prop-probe table +
|
||||
// the skin-palette table (decision 0720) + the stream-trace counters + the live/settling
|
||||
// state the landing cap reads.
|
||||
tables: (
|
||||
ResMut<PropProbes>,
|
||||
ResMut<crate::rig_palette::RigPalettes>,
|
||||
ResMut<crate::perf::StreamActivity>,
|
||||
Option<Res<crate::player::Player>>,
|
||||
),
|
||||
// state the landing cap reads + the model-forms cache (decision 0834).
|
||||
tables: SpawnTables,
|
||||
) {
|
||||
let (mut probes, mut palettes, mut activity, player) = tables;
|
||||
let (mut probes, mut palettes, mut activity, player, mut forms) = tables;
|
||||
let Some(shared_light) = shared_light else {
|
||||
return;
|
||||
};
|
||||
@@ -116,6 +125,18 @@ pub(super) fn spawn_loaded_placements(
|
||||
let Some(m) = m2s.get(h) else {
|
||||
continue; // model still loading (or missing) — try next frame
|
||||
};
|
||||
// The model's app-built render forms (decision 0834): request static — plus
|
||||
// the skinned twins iff the anim host will rig this model — and wait for the
|
||||
// paced furnisher, exactly as the placement waits for the asset itself.
|
||||
let key = ModelKey::from(h);
|
||||
let kinds = WANT_STATIC | if wants_rig(m) { WANT_SKINNED } else { 0 };
|
||||
if !forms.require(
|
||||
key,
|
||||
kinds,
|
||||
placement_priority(&streamer, p.transform.translation),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Resolve the MCSH ground-shade the reference way: a GLOBAL world→tile→chunk lookup at
|
||||
// the doodad's origin, independent of which tile registered it or in what order. An ADT
|
||||
// map doodad on lit ground takes the boosted ADT sun level (`ShadeSel::Lit` — the
|
||||
@@ -136,6 +157,10 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut palettes,
|
||||
light,
|
||||
&m.submeshes,
|
||||
FormSlices {
|
||||
stat: forms.static_meshes(key).unwrap_or(&[]),
|
||||
skin: forms.skinned_meshes(key),
|
||||
},
|
||||
p.transform,
|
||||
false,
|
||||
shade,
|
||||
@@ -217,6 +242,14 @@ pub(super) fn spawn_loaded_placements(
|
||||
let Some(m) = wmos.get(h) else {
|
||||
continue;
|
||||
};
|
||||
// The building's app-built render forms (0834): static only — WMO group
|
||||
// geometry never skins. A city root's thousands of batches are exactly the
|
||||
// burst the paced furnisher exists to spread.
|
||||
let key = ModelKey::from(h);
|
||||
let prio = placement_priority(&streamer, p.transform.translation);
|
||||
if !forms.require(key, WANT_STATIC, prio) {
|
||||
continue;
|
||||
}
|
||||
// WMOs carry no authored bounds → never size-fade (∞ radius), rely on the far-clip.
|
||||
// (`m2: None` ⇒ no anim host, so the joint half of the return is always empty.)
|
||||
let (mut ents, _) = spawn_model_entities(
|
||||
@@ -226,6 +259,10 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut palettes,
|
||||
light,
|
||||
&m.submeshes,
|
||||
FormSlices {
|
||||
stat: forms.static_meshes(key).unwrap_or(&[]),
|
||||
skin: None,
|
||||
},
|
||||
p.transform,
|
||||
true,
|
||||
ShadeSel::Matte, // WMO lights on the FFP N·L path — the selector is unread
|
||||
@@ -425,6 +462,16 @@ pub(super) fn spawn_loaded_placements(
|
||||
let Some(m) = m2s.get(&d.handle) else {
|
||||
continue; // this prop's M2 still loading
|
||||
};
|
||||
// The prop's app-built render forms (0834) — same gate as its owning placement's.
|
||||
let key = ModelKey::from(&d.handle);
|
||||
let kinds = WANT_STATIC | if wants_rig(m) { WANT_SKINNED } else { 0 };
|
||||
if !forms.require(
|
||||
key,
|
||||
kinds,
|
||||
placement_priority(&streamer, d.transform.translation),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// The verified MODD lighting: an EXTERIOR-group WMO prop samples the terrain MCSH at
|
||||
// its footprint like the reference's per-frame refresh `0x698c50` — plain matte (sun
|
||||
// ×1.0) on lit ground, the shaded level on MCSH-shadowed ground, and NEVER the ADT 2.5
|
||||
@@ -476,6 +523,10 @@ pub(super) fn spawn_loaded_placements(
|
||||
&mut palettes,
|
||||
light,
|
||||
&m.submeshes,
|
||||
FormSlices {
|
||||
stat: forms.static_meshes(key).unwrap_or(&[]),
|
||||
skin: forms.skinned_meshes(key),
|
||||
},
|
||||
d.transform,
|
||||
false,
|
||||
shade,
|
||||
@@ -642,6 +693,18 @@ fn resolve_wmo_doodads(
|
||||
|
||||
/// A model handle's source path as a readable label for the object inspector (the asset path without
|
||||
/// the `mpq://` source prefix). Empty if the handle carries no path.
|
||||
/// A placement's model-forms build priority (decision 0834): its Chebyshev tile distance to the
|
||||
/// stream focus, scaled to leave the band below for the entity lane — a mob walking into view
|
||||
/// never queues behind a city's scenery. `translation` is Bevy space (the placement transform);
|
||||
/// `world_to_tile` wants WoW ground coords, the inverse of decision 0002's rotation.
|
||||
fn placement_priority(streamer: &TerrainStreamer, translation: Vec3) -> i32 {
|
||||
let (tx, ty) = world_to_tile(-translation.z, -translation.x);
|
||||
let d = (tx as i32 - streamer.focus.0)
|
||||
.abs()
|
||||
.max((ty as i32 - streamer.focus.1).abs());
|
||||
16 + d * 16
|
||||
}
|
||||
|
||||
fn handle_label<A: Asset>(handle: &Handle<A>) -> String {
|
||||
handle
|
||||
.path()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! The WMO prop-light machinery — a placed prop's resolved lighting and the interior SH fold.
|
||||
//!
|
||||
//! Split out of `terrain_stream.rs` (0830 named the carve; 0832 executed it): this is spawn-side
|
||||
//! state and math — consumed by the placement assembler, the WMO-gameobject prop lane
|
||||
//! (`crate::entities`' `wmo_props`) and the GameObject footprint classifier (`crate::interior`) —
|
||||
//! and none of it touches the streamer's residency logic. Paths outside the streamer are stable:
|
||||
//! `terrain_stream` re-exports [`fold_interior_probe`] and [`PropLobeLight`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use benilla_assets::M2Model;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// One WMO doodad prop instance: its M2 handle and the **world** transform (the WMO instance
|
||||
/// transform composed with the doodad's WMO-local transform), spawned once the M2 asset loads.
|
||||
pub(crate) struct WmoDoodadInst {
|
||||
pub(crate) handle: Handle<M2Model>,
|
||||
pub(crate) transform: Transform,
|
||||
/// Every WMO group whose MODR names this prop — the portal-cull key, so a prop is hidden with
|
||||
/// the rooms it furnishes and drawn while any one of them is visible. Empty for a MODD no group
|
||||
/// references (the reference never instantiates one at all; we still show it, uncullable,
|
||||
/// rather than change what draws today).
|
||||
pub(crate) groups: Arc<[u16]>,
|
||||
/// The prop's lighting ([`PropLight`], from `WmoModel::doodad_base` composed with this
|
||||
/// placement): exterior sky-lit, or the interior MODD-colour base + its owning group's MOLR
|
||||
/// lights placed in world space — folded into the prop's SH probe once its M2 loads (the fold
|
||||
/// reference point needs the M2 bounds).
|
||||
pub(crate) light: PropLight,
|
||||
pub(crate) spawned: bool,
|
||||
}
|
||||
|
||||
/// A WMO prop's placement-resolved lighting: the asset-level [`DoodadBase`](benilla_assets::DoodadBase)
|
||||
/// with the owning group's MOLR lights already transformed to WORLD (Bevy) space — so the
|
||||
/// spawn-time SH fold needs only the loaded M2's bounds (its reference point) and nothing from the
|
||||
/// WMO asset.
|
||||
pub(crate) enum PropLight {
|
||||
Exterior,
|
||||
Interior {
|
||||
/// `cap96(MODD.colour)` — the ambient word (0–1 RGB).
|
||||
ambient: [f32; 3],
|
||||
/// `floor112(MODD.colour)` — the diffuse word, committed on the fixed interior axis.
|
||||
diffuse: [f32; 3],
|
||||
/// The owning group's MOLR omni lights: world (Bevy) position, colour × intensity, and the
|
||||
/// disk `attenStart`/`attenEnd` window (the fold's range gate).
|
||||
lights: Vec<PropLobeLight>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour
|
||||
/// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the
|
||||
/// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict).
|
||||
pub(crate) struct PropLobeLight {
|
||||
pub(crate) pos: Vec3,
|
||||
pub(crate) color_i: [f32; 3],
|
||||
pub(crate) atten_start: f32,
|
||||
pub(crate) atten_end: f32,
|
||||
}
|
||||
|
||||
/// Fold one interior committed light into its 7-row SH probe: the ambient word + the diffuse word
|
||||
/// as a directional on the FIXED interior axis + each MOLR lobe windowed by its disk
|
||||
/// attenStart/attenEnd from `ref_point` (the byte-verified `0x69e1c0` falloff: d ≤ start → 1;
|
||||
/// d ≥ end → excluded; else linear). One definition for both interior lanes — the MODD prop
|
||||
/// (spawn-time, MODD-colour words) and the GameObject footprint (classify-time, MOCV-derived
|
||||
/// words); the SH closed form itself is [`prop_probe_coeffs`](crate::lighting::prop_probe_coeffs).
|
||||
pub(crate) fn fold_interior_probe(
|
||||
ambient: [f32; 3],
|
||||
diffuse: [f32; 3],
|
||||
ref_point: Vec3,
|
||||
lights: &[PropLobeLight],
|
||||
) -> [bevy::math::Vec4; 7] {
|
||||
// Toward-light, Bevy space: wow (0.30822, 0.30822, 0.9) → (−y, z, −x).
|
||||
let mut lobes: Vec<(Vec3, [f32; 3])> = vec![(Vec3::new(-0.30822, 0.9, -0.30822), diffuse)];
|
||||
for l in lights {
|
||||
let dv = l.pos - ref_point;
|
||||
let dist = dv.length();
|
||||
let gain = if dist <= l.atten_start {
|
||||
1.0
|
||||
} else if dist >= l.atten_end || l.atten_end <= l.atten_start {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - (dist - l.atten_start) / (l.atten_end - l.atten_start)
|
||||
};
|
||||
if gain > 0.0 {
|
||||
lobes.push((dv / dist.max(1e-4), l.color_i.map(|c| c * gain)));
|
||||
}
|
||||
}
|
||||
crate::lighting::prop_probe_coeffs(ambient, &lobes)
|
||||
}
|
||||
@@ -242,14 +242,18 @@ pub(super) fn feed_actions(
|
||||
for (slot, button) in &actions.buttons {
|
||||
let (texture, count) = match button.kind {
|
||||
ACTION_KIND_SPELL => {
|
||||
// The melee auto-attack shows the equipped weapon icon, not spell 6603's `Temp`
|
||||
// placeholder (decision 0230); every other spell uses its own icon.
|
||||
let d = spells.as_ref().and_then(|s| s.catalog.get(button.action));
|
||||
let icon = d
|
||||
.and_then(|d| {
|
||||
auto_attack_icon(d, store, &mut items, icons.as_deref(), &commands)
|
||||
})
|
||||
.or_else(|| d.and_then(|d| d.icon.clone()));
|
||||
let icon = spells.as_ref().and_then(|sp| {
|
||||
let d = sp.catalog.get(button.action)?;
|
||||
spell_action_icon(
|
||||
button.action,
|
||||
d,
|
||||
sp,
|
||||
store,
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
)
|
||||
});
|
||||
(icon, 0)
|
||||
}
|
||||
ACTION_KIND_ITEM => {
|
||||
@@ -328,29 +332,39 @@ pub(super) fn feed_actions(
|
||||
}
|
||||
changed
|
||||
}
|
||||
// A weapon-substituting icon tracks the equipped weapon — main-hand for Attack
|
||||
// (decision 0230), the ranged slot for Auto Shot / wand Shoot (decision 0231's
|
||||
// ranged case) — which a swap changes without touching the action table, so
|
||||
// refresh it too. A normal spell's icon is stable, so it's skipped.
|
||||
// Two icon families track live character state, not the action table, so they
|
||||
// refresh every frame: a weapon-substituting icon follows the equipped weapon
|
||||
// AND the current form (Attack's `0x4e6870` — decisions 0230/0231 + the form
|
||||
// face), and a toggle spell with a nonzero ActiveIconID swaps faces with its own
|
||||
// aura (`0x4e6a50`'s `0x4e6bbd` predicate — a shift in/out never touches
|
||||
// SMSG_ACTION_BUTTONS). A plain spell's icon is stable, so it's skipped.
|
||||
ACTION_KIND_SPELL => {
|
||||
let d = spells.as_ref().and_then(|s| s.catalog.get(slot.action));
|
||||
match d.filter(|d| substitutes_weapon_icon(d)) {
|
||||
Some(d) => {
|
||||
let fresh = auto_attack_icon(
|
||||
let d = spells
|
||||
.as_ref()
|
||||
.and_then(|s| s.catalog.get(slot.action))
|
||||
.filter(|d| substitutes_weapon_icon(d) || d.active_icon_id != 0);
|
||||
match (d, spells.as_ref()) {
|
||||
(Some(d), Some(sp)) => {
|
||||
let fresh = spell_action_icon(
|
||||
slot.action,
|
||||
d,
|
||||
sp,
|
||||
Some(store),
|
||||
&mut items,
|
||||
icons.as_deref(),
|
||||
&commands,
|
||||
)
|
||||
.or_else(|| d.icon.clone());
|
||||
);
|
||||
let changed = fresh != slot.texture;
|
||||
if changed {
|
||||
debug!(
|
||||
"ui_action: live icon swap slot {action} ({}) -> {fresh:?}",
|
||||
slot.action
|
||||
);
|
||||
slot.texture = fresh;
|
||||
}
|
||||
changed
|
||||
}
|
||||
None => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
@@ -365,3 +379,37 @@ pub(super) fn feed_actions(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole SPELL-slot icon rule — the reference's `GetActionTexture` resolver `0x4e6a50`
|
||||
/// (wow-re `action-spell-icon-apis.md` §3, §5-verified), arms in its execution order:
|
||||
///
|
||||
/// 1. The **pre-emptive arms** ([`auto_attack_icon`]): Attack serves the current form's face /
|
||||
/// the main-hand weapon (`0x4e6870`), an auto-repeat shot the ranged weapon (`0x4e6990`) —
|
||||
/// before the spell's own icon fields are ever read.
|
||||
/// 2. The **active-toggle swap** (`0x4e6bbd → 0x4e6bc6`): `ActiveIconID` while the button's OWN
|
||||
/// spell id sits live-and-cancelable in the player's aura slots — the literal `0x4e55f0`
|
||||
/// predicate `UseAction`'s cancel fork rides ([`super::toggle::active_action_toggle`]; the
|
||||
/// same function in the binary, by call-target address). Ghost Wolf's swirl while shifted.
|
||||
/// 3. The spell's own `SpellIconID` face.
|
||||
///
|
||||
/// The spellbook's `GetSpellTexture` (`0x4b3f50`) deliberately runs ONLY arms 1 and 3 — it never
|
||||
/// serves `ActiveIconID` (proof by exhaustion in the note). `ui_spellbook` keeps that asymmetry;
|
||||
/// do not "fix" it to match the bar.
|
||||
#[allow(clippy::too_many_arguments)] // the resolver's full input set, twice-called above
|
||||
fn spell_action_icon(
|
||||
spell_id: u32,
|
||||
d: &benilla_formats::SpellDisplay,
|
||||
spells: &super::Spells,
|
||||
store: Option<&crate::net::ObjectStore>,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
auto_attack_icon(d, store, &spells.forms, items, icons, commands)
|
||||
.or_else(|| {
|
||||
store
|
||||
.filter(|s| super::toggle::active_action_toggle(spell_id, d, s))
|
||||
.and_then(|_| d.active_icon.clone())
|
||||
})
|
||||
.or_else(|| d.icon.clone())
|
||||
}
|
||||
|
||||
@@ -194,6 +194,9 @@ pub(super) fn feed_action_state(
|
||||
let (pending, queued_melee, channel) = &cast_state;
|
||||
let me = self_q.iter().next();
|
||||
let engaged = me.is_some_and(|(_, _, e, _)| e);
|
||||
let form_byte = me
|
||||
.map(|(s, _, _, _)| s.0.unit_shapeshift_form())
|
||||
.unwrap_or(0);
|
||||
let casting_spell = me.and_then(|(_, _, _, c)| c.map(|c| c.spell_id));
|
||||
let current_cast = pending.current(now).or(casting_spell);
|
||||
let self_reach = me.map_or(1.5, |(s, _, _, _)| s.0.unit_combat_reach());
|
||||
@@ -225,14 +228,20 @@ pub(super) fn feed_action_state(
|
||||
// spell while it is our in-flight cast OR our queued on-next-swing strike OR our
|
||||
// running channel (the ref reads one inflight id `0xceca88` — which a queued
|
||||
// Heroic Strike *occupies* until the swing fires it — plus the channel id
|
||||
// `0xceac58`; our model splits the queue into its own slot, same observable).
|
||||
// The form/stance legs stay unmodeled.
|
||||
// `0xceac58`; our model splits the queue into its own slot, same observable) —
|
||||
// OR the shapeshift arm (`IsCurrentAction`'s predicate `0x4e53a0` @ `0x4e5556`,
|
||||
// wow-re `action-spell-icon-apis.md` §5): a MOD_SHAPESHIFT spell whose form ==
|
||||
// the player's form byte reads checked. Deliberately NOT the icon's aura-scan
|
||||
// predicate — the two are different functions in the binary and the asymmetry
|
||||
// is load-bearing (a form granted by a different spell lights the check without
|
||||
// swapping the icon).
|
||||
st.current = if st.is_attack {
|
||||
engaged
|
||||
} else {
|
||||
current_cast == Some(button.action)
|
||||
|| queued_melee.current() == Some(button.action)
|
||||
|| channel.current(now) == Some(button.action)
|
||||
|| (form_byte != 0 && d.shapeshift_form == Some(u32::from(form_byte)))
|
||||
};
|
||||
st.auto_repeat = auto_repeat.0 == Some(button.action);
|
||||
// The full usable walk (`0x6e3d60` §2a — [`super::usable`]): reagents, forms,
|
||||
|
||||
@@ -45,16 +45,28 @@ fn main_hand_weapon_icon(
|
||||
}
|
||||
|
||||
/// The character's melee auto-attack icon (decision 0231; the client's melee helper `0x4e6870`):
|
||||
/// the equipped main-hand weapon's icon, or [`SPELL_RESET_ICON`] when unarmed. Character-level —
|
||||
/// independent of WHICH auto-attack spell (they all show this), so the spellbook can pre-resolve it
|
||||
/// once for its whole page. (The verdict's ranged auto-repeat / shapeshift-form / disarmed cases
|
||||
/// are deferred — see decision 0231; this covers the common melee, armed-or-unarmed case.)
|
||||
/// the **current shapeshift form's own attack face** when its `SpellShapeshiftForm` row carries
|
||||
/// one (the `+0x34` AttackIconID read, `0x4e68af`–`0x4e68da` — a cat's paw, a bear's swipe; wow-re
|
||||
/// `action-spell-icon-apis.md` §3.3, closing decision 0231's deferred form case), else the
|
||||
/// equipped main-hand weapon's icon, else [`SPELL_RESET_ICON`]. Character-level — independent of
|
||||
/// WHICH auto-attack spell (they all show this), so the spellbook can pre-resolve it once for its
|
||||
/// whole page.
|
||||
pub(crate) fn melee_auto_attack_icon(
|
||||
store: &ObjectStore,
|
||||
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> String {
|
||||
let form = store.0.unit_shapeshift_form();
|
||||
if form != 0 {
|
||||
if let Some(icon) = forms
|
||||
.get(&u32::from(form))
|
||||
.and_then(|f| f.attack_icon.clone())
|
||||
{
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
main_hand_weapon_icon(store, items, icons, commands)
|
||||
.unwrap_or_else(|| SPELL_RESET_ICON.to_string())
|
||||
}
|
||||
@@ -98,13 +110,14 @@ pub(super) fn substitutes_weapon_icon(spell: &SpellDisplay) -> bool {
|
||||
pub(super) fn auto_attack_icon(
|
||||
spell: &SpellDisplay,
|
||||
store: Option<&ObjectStore>,
|
||||
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
|
||||
items: &mut Items,
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
) -> Option<String> {
|
||||
let store = store?;
|
||||
if spell.is_melee_auto_attack() {
|
||||
return Some(melee_auto_attack_icon(store, items, icons, commands));
|
||||
return Some(melee_auto_attack_icon(store, forms, items, icons, commands));
|
||||
}
|
||||
if spell.ranged_icon_substitution() {
|
||||
return ranged_weapon_icon(store, items, icons, commands);
|
||||
|
||||
@@ -140,8 +140,8 @@ fn feed_spellbook(
|
||||
// The melee auto-attack's icon is the equipped main-hand weapon (or Spell-Reset when unarmed),
|
||||
// not spell 6603's `Temp` placeholder (decision 0230) — resolved here where the self player +
|
||||
// item stores are in hand, once for the whole page (it's the same for any auto-attack spell).
|
||||
let attack_icon =
|
||||
store.map(|s| melee_auto_attack_icon(s, &mut items, icons.as_deref(), &commands));
|
||||
let attack_icon = store
|
||||
.map(|s| melee_auto_attack_icon(s, &spells.forms, &mut items, icons.as_deref(), &commands));
|
||||
// The ranged auto-repeat shots (Auto Shot, wand Shoot) borrow the equipped ranged weapon's
|
||||
// icon the same way (decision 0231's ranged case; `None` — unarmed/thrown — keeps the
|
||||
// spell's own icon, never Spell-Reset). Character-level like the melee icon: one resolve
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! **The depth-prime twin** — the reference's `M2UseZFill` ("z-fill transparent objects", default
|
||||
//! ON) reproduced for fading/stealthed entities (decision 0831; wow-re
|
||||
//! `m2-blend-promotion-zfill.md` §4, VERIFIED at the bytes).
|
||||
//!
|
||||
//! The mechanism it reproduces: when a model instance draws translucent (`0 < A < 1` — stealth's
|
||||
//! CharProc 0.3, the appear/despawn ramps, the self-avatar zoom feather), the reference's collector
|
||||
//! CLONES each of its z-writing mesh commands into a twin that draws **first** (comparator key
|
||||
//! `cmd+0x08` desc inside the instance's sort-key tie), **colour-masked off** (`glColorMask 0`),
|
||||
//! **blend off**, **z-write ON**. The whole model's nearest-surface depth is therefore in the
|
||||
//! buffer before any of its colour batches draw, each colour fragment passes the depth test only at
|
||||
//! that nearest surface, and every interior/overlapped layer fails — **one blended layer
|
||||
//! everywhere**. Without it, a stealthed body's overlapping parts compound `0.3` over `0.3` into
|
||||
//! the darker patches of ledger B182.
|
||||
//!
|
||||
//! The benilla shape: [`sync_zfill_twins`] watches every fadeable part
|
||||
//! ([`FadeMaterials::zfill`] carries the twin material, built next to the part's blend twin) and
|
||||
//! keeps a **twin child mesh** alive on it exactly while the part's `MeshTag` alpha is translucent
|
||||
//! ([`crate::mesh_tag::translucent`]). The twin shares the part's mesh, transform and tag (same
|
||||
//! skinned pose, same rig slot), and its material's `specialize` masks the colour writes off and
|
||||
//! forces z-write on; its ordering comes from the material's negative `Transparent3d` sort bias
|
||||
//! ([`crate::model_render::ZFILL_SORT_BIAS`]).
|
||||
//!
|
||||
//! Two deliberate departures from the reference, both recorded in 0831:
|
||||
//! - **Scope**: the reference twins every translucent mesh command always (authored-Blend glass
|
||||
//! included); we twin only while an instance alpha episode is live, so every steady-state look is
|
||||
//! untouched.
|
||||
//! - **Ordering**: the reference ties one instance's commands to a single sort key and puts twins
|
||||
//! first inside the tie; Bevy sorts per-entity, so the twins ride a fixed −8 yd bias instead —
|
||||
//! transparent scene content sorting within that window behind a fading body draws after the
|
||||
//! prime and is depth-clipped where the body covers it, for the seconds the episode lasts.
|
||||
|
||||
use bevy::mesh::MeshTag;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::model_fade::FadeMaterials;
|
||||
|
||||
/// On a fadeable part: its live depth-prime twin entity. Present exactly while the part draws
|
||||
/// translucent — [`sync_zfill_twins`] is the only writer.
|
||||
#[derive(Component)]
|
||||
pub(crate) struct ZfillTwin(Entity);
|
||||
|
||||
/// On a twin: the part it primes for. The mesh/tag re-sync key, and the marker that keeps the twin
|
||||
/// out of every part query (writers key on [`FadeMaterials`], which a twin never carries; this side
|
||||
/// of the disjointness is the `Without` in the queries below).
|
||||
#[derive(Component)]
|
||||
pub(crate) struct ZfillTwinOf(Entity);
|
||||
|
||||
/// Keep every fadeable part's depth-prime twin in sync with its episode: spawn the twin child when
|
||||
/// the part's tag alpha enters the translucent band, despawn it when the tag settles (opaque or the
|
||||
/// part itself despawns — a child dies with its parent), and mirror the part's mesh + tag onto a
|
||||
/// live twin (a gear-change redress swaps the part's mesh in place, 0835; the rig slot rides the
|
||||
/// tag, so the twin skins into the same pose as the surface it primes).
|
||||
///
|
||||
/// Runs in `PostUpdate`, after every Update-side tag writer (the aura author, the appear/despawn
|
||||
/// ramp, the self feather) — the twin's first frame is the episode's first frame.
|
||||
/// One fadeable part as [`sync_zfill_twins`] sees it: its twin material source, live tag, mesh,
|
||||
/// and (maybe) the twin it already has.
|
||||
type ZfillParts<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Entity,
|
||||
&'static FadeMaterials,
|
||||
&'static MeshTag,
|
||||
&'static Mesh3d,
|
||||
Option<&'static ZfillTwin>,
|
||||
),
|
||||
Without<ZfillTwinOf>,
|
||||
>;
|
||||
|
||||
pub(crate) fn sync_zfill_twins(
|
||||
mut commands: Commands,
|
||||
parts: ZfillParts,
|
||||
mut twins: Query<(&ZfillTwinOf, &mut MeshTag, &mut Mesh3d), Without<FadeMaterials>>,
|
||||
) {
|
||||
for (part, fm, tag, mesh, twin) in &parts {
|
||||
let Some(mat) = fm.zfill.as_ref() else {
|
||||
continue; // no-z-write / Mod / Mod2x batch — the reference's own twin gate
|
||||
};
|
||||
let active = crate::mesh_tag::translucent(tag.0);
|
||||
match twin {
|
||||
None if active => {
|
||||
let t = commands
|
||||
.spawn((
|
||||
Mesh3d(mesh.0.clone()),
|
||||
MeshMaterial3d(mat.clone()),
|
||||
MeshTag(tag.0),
|
||||
Transform::IDENTITY,
|
||||
ZfillTwinOf(part),
|
||||
ChildOf(part),
|
||||
))
|
||||
.id();
|
||||
commands.entity(part).insert(ZfillTwin(t));
|
||||
trace("arm", part, tag.0);
|
||||
}
|
||||
Some(t) if !active => {
|
||||
commands.entity(t.0).despawn();
|
||||
commands.entity(part).remove::<ZfillTwin>();
|
||||
trace("release", part, tag.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (of, mut tag, mut mesh) in &mut twins {
|
||||
// The part's tag moves every fade frame (its alpha bits); mirroring it keeps the rig slot
|
||||
// — the half the twin's vertex stage actually reads — correct across a redress or a slot
|
||||
// reassignment without a dedicated edge.
|
||||
let Ok((_, _, ptag, pmesh, _)) = parts.get(of.0) else {
|
||||
continue; // part going away this frame — the child despawns with it
|
||||
};
|
||||
if tag.0 != ptag.0 {
|
||||
tag.0 = ptag.0;
|
||||
}
|
||||
if mesh.0 != pmesh.0 {
|
||||
mesh.0 = pmesh.0.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer's instrument (`WOW_MOVE_TRACE=<path>`, tag `zfl`): one line per twin edge, so "did
|
||||
/// the depth prime actually arm on this body" is answered from the trace beside the `aur` lines —
|
||||
/// a stealth press prints `aur arm … target 0.30` and then one `zfl arm` per fadeable part.
|
||||
fn trace(what: &str, part: Entity, tag: u32) {
|
||||
if !crate::dbg_trace::enabled() {
|
||||
return;
|
||||
}
|
||||
crate::dbg_trace::line("zfl", &format!("{what} part={part} tag={tag:#010x}"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The activation band, pinned against the tag encoding it reads: the untagged sentinel and a
|
||||
/// full alpha field are opaque (no twin), every ramp value in between is translucent (twin),
|
||||
/// and the flag bits never leak into the decision.
|
||||
#[test]
|
||||
fn the_twin_arms_exactly_on_the_translucent_band() {
|
||||
use crate::mesh_tag;
|
||||
assert!(!mesh_tag::translucent(0), "untagged ⇒ opaque, no twin");
|
||||
assert!(
|
||||
!mesh_tag::translucent(mesh_tag::with_alpha(0, 1.0)),
|
||||
"opaque ramp end: released"
|
||||
);
|
||||
for alpha in [0.02, 0.3, 0.5, 0.9] {
|
||||
assert!(
|
||||
mesh_tag::translucent(mesh_tag::with_alpha(0, alpha)),
|
||||
"mid-ramp {alpha} must arm the twin"
|
||||
);
|
||||
}
|
||||
// alpha_bits floors a non-positive alpha at field value 1 — still armed, matching the
|
||||
// reference (only A ≤ 0 culls the batch, and that never reaches a tag).
|
||||
assert!(mesh_tag::translucent(mesh_tag::with_alpha(0, 0.0)));
|
||||
// The standalone flag bits are not payload: a highlighted-but-untagged instance is opaque.
|
||||
assert!(!mesh_tag::translucent(mesh_tag::HIGHLIGHT_BIT));
|
||||
// …and a highlighted mid-ramp instance still arms.
|
||||
assert!(mesh_tag::translucent(
|
||||
mesh_tag::with_alpha(0, 0.3) | mesh_tag::HIGHLIGHT_BIT
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user