Files
benilla-octo/crates/benilla-app/src/model_forms.rs
T
SamandClaude Fable 5 3b6975fdef the pet gets its frame, flooded rooms fill, and macros really fire
- the pet gets its own unit frame beside yours, and an Attack order no longer latches as the standing command
- five rooms that always read bone dry hold their water now, the Deeprun Tram's flooded stretches among them
- macros fire through the client's own chat door, and the macro window fits its tabs and scrolls its icon chooser
- the options window grows a Nameplates page and its first dropdown, and the name-display switches apply live in the world

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:03:43 +07:00

296 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"
);
}
}