diff --git a/crates/benilla-assets/src/adt.rs b/crates/benilla-assets/src/adt.rs index 84d9cff7..9a05afca 100644 --- a/crates/benilla-assets/src/adt.rs +++ b/crates/benilla-assets/src/adt.rs @@ -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>, + /// 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, /// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains). pub layer_array: Handle, /// `texture_2d_array` of per-chunk alpha (blend-weight) maps. @@ -55,15 +61,91 @@ pub struct AdtTile { pub doodads: Vec, /// WMO building placements (raw WoW coords). pub wmos: Vec, - /// 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, } +/// 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 { + 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 = 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> = 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 = 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), diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index 0eb3f044..e7480768 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -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::>().get(&handle) { - let meshes: Vec> = - 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::>(); - 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::>().get(&handle) { - let meshes: Vec> = - 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::>(); - 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::>().get(&handle) { info = Some(( - t.chunk_meshes.clone(), + t.chunks + .iter() + .zip(&t.shading) + .map(|(c, s)| (c.clone(), *s)) + .collect::>(), + 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::>(); + // 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 = 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::>(); @@ -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() ); } } diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index a4df669c..753753b6 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -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` **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` **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::(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 diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 97a91fef..56b1c98b 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -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, - /// 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, + /// 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, /// 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>, @@ -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, diff --git a/crates/benilla-assets/src/terrain.rs b/crates/benilla-assets/src/terrain.rs index 725269bd..6d103ebb 100644 --- a/crates/benilla-assets/src/terrain.rs +++ b/crates/benilla-assets/src/terrain.rs @@ -154,6 +154,12 @@ pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec) -> /// 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, format: TextureFormat) -> Image { let mut image = Image::new( Extent3d { @@ -164,7 +170,7 @@ fn array_image(size: u32, layer_count: u32, data: Vec, format: TextureFormat TextureDimension::D2, data, format, - RenderAssetUsages::default(), + RenderAssetUsages::RENDER_WORLD, ); image.texture_view_descriptor = Some(TextureViewDescriptor { dimension: Some(TextureViewDimension::D2Array), diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 5b94f41a..077e2aa8 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -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); } diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 09a1b49a..149212ab 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -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, diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 8d7f4e38..2dd69702 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -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 diff --git a/crates/benilla-formats/src/spells/forms.rs b/crates/benilla-formats/src/spells/forms.rs index 9a3aa24f..1988530b 100644 --- a/crates/benilla-formats/src/spells/forms.rs +++ b/crates/benilla-formats/src/spells/forms.rs @@ -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, } impl ShapeshiftForm { @@ -74,6 +81,10 @@ pub fn load_shapeshift_forms(chain: &mut Chain) -> Result Result 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; diff --git a/crates/benilla/src/aura_visual.rs b/crates/benilla/src/aura_visual.rs index 875be05b..d216e5fe 100644 --- a/crates/benilla/src/aura_visual.rs +++ b/crates/benilla/src/aura_visual.rs @@ -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 { /// 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, time: Res