- Entering the world used to show an uncovered, movable world before the screen
came up — seconds of it on a slower machine ("like the Stormwind intro
video", as one report with video put it). The screen also cleared onto bare
terrain with the buildings still streaming in, and could hang forever if you
arrived swimming or flying. It has a real lifecycle now: it rises at the
entry edges (character pick, transfer, teleport), clears only when the scene
is actually presentable — terrain, buildings, props and their collision all
resident — and its physics hold releases in every movement mode, not just
walking. The progress bar counts buildings too.
- A far teleport flashed a frame of the destination before the screen rose.
The "swap never flashes" guarantee existed only in comments — a streamer
rework had silently dropped the scheduling constraint that enforced it; it
is structural now. And logout got its missing cover: the original's plain
black cut, not a frame of torn-down world.
- Animation parking now governs everything with a rig. ~350 animated objects
(braziers, doors, chests) were sampling their loops off-screen forever, and
a patrolling parked mob kept writing pose data nothing could read — ~620 rig
refreshes a frame became 7, and creatures in rooms the camera can't reach
through any portal park too.
- The engine's own tax got measured: bare Bevy spends ~3.9 CPU ms a frame on
this machine drawing nothing at all, which resets what "fast enough"
honestly means for everything built on top of it. A shipping build profile
(fat LTO) landed alongside — worth about −1 ms at the pin, kept out of
everyday builds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
732 lines
30 KiB
Rust
732 lines
30 KiB
Rust
//! The owned skin palette (decision 0720): benilla computes every skinned rig's joint palette
|
||
//! itself and uploads it to a region of the shared light buffer; `wow_model.wgsl`'s vertex stage
|
||
//! skins from it. This replaces Bevy's `SkinnedMesh` lane end to end — `extract_skins`'
|
||
//! world-wide `Changed<GlobalTransform>` sweep and `prepare_skins`' full-staging-buffer rewrite
|
||
//! (both O(everything), measured in decision 0718) become no-ops because no entity carries
|
||
//! `SkinnedMesh` anymore (and no mesh carries Bevy's joint attributes, so the `SKINNED`
|
||
//! pipeline path never compiles).
|
||
//!
|
||
//! The shape mirrors the interior-prop probe table (`lighting::prop_probes`), the house pattern:
|
||
//! a main-world slab ([`RigPalettes`]) whose slots free themselves through a component hook
|
||
//! ([`RigSkin`]'s `on_replace`), `Arc`-shared rows so the render-world extract is a pointer bump,
|
||
//! and a `PrepareResources` upload that writes only what changed — here per-rig **dirty ranges**
|
||
//! (a parked, stationary rig re-uploads nothing) plus the slot table on allocation changes.
|
||
//!
|
||
//! **Palettes are world-space, Bevy's exact semantics**: row `b` of rig `r` is
|
||
//! `world_from_joint(b) × inverse_bindpose(b)` — the same matrix Bevy's `skin_model` would
|
||
//! produce — stored as 3 `vec4` rows (the affine's rows; the fourth row is implicit
|
||
//! `[0,0,0,1]`). The vertex stage reads the part's rig slot from its `MeshTag` rig field
|
||
//! (`mesh_tag::rig_bits`), resolves the rig's base bone index through the slot table, and blends
|
||
//! the four indexed bones' rows by the vertex weights — replacing (not composing with) the mesh's
|
||
//! world matrix, exactly like Bevy skinning.
|
||
//!
|
||
//! **Dirtiness is structural, not assumed**: every joint entity carries a [`RigJoint`] marker
|
||
//! (planted by `entities::spawn_joints`), and [`compute_rig_palettes`] runs one linear
|
||
//! `Changed<GlobalTransform>` pass over exactly those entities — so *every* writer of a joint's
|
||
//! world pose (the 0712 evaluator, the body twist, global-sequence channels, the billboard joint
|
||
//! pass, a mount's seat, the terrain-conform node, plain movement) marks its rig by construction.
|
||
//! The pass is rig-scoped (≈ the bone population), unlike the Bevy sweep it replaces (the whole
|
||
//! world); Stage B of 0720 replaces it with writer-driven dirtying when the bone entities
|
||
//! themselves collapse.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use bevy::ecs::lifecycle::HookContext;
|
||
use bevy::ecs::world::DeferredWorld;
|
||
use bevy::math::Affine3A;
|
||
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
|
||
use bevy::platform::collections::HashSet;
|
||
use bevy::prelude::*;
|
||
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
|
||
use bevy::render::renderer::RenderQueue;
|
||
use bevy::render::{Render, RenderApp, RenderSystems};
|
||
|
||
use crate::mesh_tag::MAX_RIG_SLOTS;
|
||
|
||
/// Bone capacity of the palette region — concurrently-resident skinned bones across every live
|
||
/// rig (units, animated doodads, spell effects, booths). The LBRS census (0718) holds ~59 k unit
|
||
/// bones + ~1.6 k doodad-rig bones; 128 k is head-room, at 48 B/bone = 6 MB of the shared buffer.
|
||
/// Mirrored by `wow_model.wgsl`'s region declaration — keep in sync.
|
||
pub(crate) const MAX_PALETTE_BONES: usize = 131_072;
|
||
|
||
/// Bytes per bone in the palette region: 3 × vec4 rows of the affine.
|
||
const BONE_BYTES: u64 = 48;
|
||
|
||
/// Byte offset of the rig slot table inside the shared light buffer (after the prop-probe
|
||
/// region). One u32 per slot: the rig's base bone index.
|
||
pub(crate) fn rig_table_region_offset() -> u64 {
|
||
crate::lighting::prop_probe_region_offset() + (7 * crate::lighting::MAX_PROP_PROBES * 16) as u64
|
||
}
|
||
|
||
/// Byte offset of the palette rows themselves (after the slot table).
|
||
pub(crate) fn palette_region_offset() -> u64 {
|
||
rig_table_region_offset() + (MAX_RIG_SLOTS * 4) as u64
|
||
}
|
||
|
||
/// Total bytes both regions add to every `wow_light`-layout buffer (`lighting::light_blob_bytes`).
|
||
pub(crate) fn palette_regions_bytes() -> u64 {
|
||
(MAX_RIG_SLOTS * 4) as u64 + MAX_PALETTE_BONES as u64 * BONE_BYTES
|
||
}
|
||
|
||
/// The main-world palette table: rows + slot table behind `Arc` (the extract is a pointer bump),
|
||
/// a slot slab and a coalescing bone-range free list, and the frame's dirty ranges.
|
||
#[derive(Resource)]
|
||
pub(crate) struct RigPalettes {
|
||
/// `3 × MAX_PALETTE_BONES` vec4 rows — the CPU mirror (the mouseover picker reads it).
|
||
rows: Arc<Vec<[f32; 4]>>,
|
||
/// Slot → base bone index. Slot 0 is the tag's "no rig" sentinel and never allocated.
|
||
table: Arc<Vec<u32>>,
|
||
/// Per-slot bone count; 0 = free slot.
|
||
slot_len: Vec<u32>,
|
||
free_slots: Vec<u16>,
|
||
slot_high: usize,
|
||
/// Free bone ranges `(base, len)`, kept sorted by base and coalesced on free.
|
||
free_ranges: Vec<(u32, u32)>,
|
||
/// Per slot: whether this rig's rows must ALSO reach the booth mirror buffers
|
||
/// ([`RigPaletteMirrors`]) — set for booth rigs only, so the world's rig traffic never
|
||
/// multiplies across the studio buffers.
|
||
mirrored: Vec<bool>,
|
||
/// Bone ranges rewritten since the last publish; `.2` = the owning slot was mirrored.
|
||
dirty: Vec<(u32, u32, bool)>,
|
||
/// Bumped on every alloc/free — the slot table's upload gate.
|
||
table_generation: u64,
|
||
/// Peak concurrent slots / bones (the overflow diagnostic — near capacity ⇒ grow).
|
||
peak_slots: usize,
|
||
peak_bones: u32,
|
||
live_bones: u32,
|
||
/// `WOW_RIG_COST` meters (decision 0736 premise check): whole-vec deep copies this frame
|
||
/// (`Arc::make_mut` clones when the extract still holds last publish's reference), the µs
|
||
/// they took, and the rows (bones) written. Printed + reset by [`publish_rig_palettes`].
|
||
cost_copies: u32,
|
||
cost_copy_us: f32,
|
||
cost_rows: u32,
|
||
}
|
||
|
||
impl Default for RigPalettes {
|
||
fn default() -> Self {
|
||
Self {
|
||
rows: Arc::new(vec![[0.0; 4]; 3 * MAX_PALETTE_BONES]),
|
||
table: Arc::new(vec![0; MAX_RIG_SLOTS]),
|
||
slot_len: vec![0; MAX_RIG_SLOTS],
|
||
mirrored: vec![false; MAX_RIG_SLOTS],
|
||
free_slots: Vec::new(),
|
||
slot_high: 1, // slot 0 = the "no rig" tag sentinel
|
||
free_ranges: vec![(0, MAX_PALETTE_BONES as u32)],
|
||
dirty: Vec::new(),
|
||
table_generation: 0,
|
||
peak_slots: 0,
|
||
peak_bones: 0,
|
||
live_bones: 0,
|
||
cost_copies: 0,
|
||
cost_copy_us: 0.0,
|
||
cost_rows: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `WOW_RIG_COST=1`: the rig lane's per-frame cost split — copy vs compose vs upload — in
|
||
/// grep-and-average lines beside `FPS_PROBE` (the 0736 premise meter; the 0734 lesson made
|
||
/// premise checks law). Zero-cost when off: one env read, once.
|
||
pub(crate) fn rig_cost_enabled() -> bool {
|
||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*ON.get_or_init(|| std::env::var_os("WOW_RIG_COST").is_some())
|
||
}
|
||
|
||
/// `Arc::make_mut` on the shared rows, timing the calls that actually deep-copy: between a
|
||
/// publish and the frame's first write the extract holds a second reference, so that write
|
||
/// clones the whole ~6.3 MB vec. A static helper (not a method) so call sites keep their
|
||
/// split-field borrows.
|
||
fn rows_make_mut<'a>(
|
||
rows: &'a mut Arc<Vec<[f32; 4]>>,
|
||
copies: &mut u32,
|
||
copy_us: &mut f32,
|
||
) -> &'a mut Vec<[f32; 4]> {
|
||
if Arc::strong_count(rows) > 1 || Arc::weak_count(rows) > 0 {
|
||
let t = std::time::Instant::now();
|
||
let out = Arc::make_mut(rows);
|
||
*copy_us += t.elapsed().as_secs_f32() * 1e6;
|
||
*copies += 1;
|
||
return out;
|
||
}
|
||
Arc::make_mut(rows)
|
||
}
|
||
|
||
impl RigPalettes {
|
||
/// Claim a slot + a bone range for a rig. `None` when either slab is exhausted — the caller
|
||
/// warns and falls back to the static (bind-pose) mesh, graceful and visible in the log
|
||
/// rather than wrong.
|
||
pub(crate) fn alloc(&mut self, bones: u32) -> Option<(u16, u32)> {
|
||
if bones == 0 {
|
||
return None;
|
||
}
|
||
// First-fit range.
|
||
let (i, &(base, len)) = self
|
||
.free_ranges
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(_, &(_, len))| len >= bones)?;
|
||
let slot = match self.free_slots.pop() {
|
||
Some(s) => s,
|
||
None if self.slot_high < MAX_RIG_SLOTS => {
|
||
self.slot_high += 1;
|
||
(self.slot_high - 1) as u16
|
||
}
|
||
None => return None,
|
||
};
|
||
if len == bones {
|
||
self.free_ranges.remove(i);
|
||
} else {
|
||
self.free_ranges[i] = (base + bones, len - bones);
|
||
}
|
||
Arc::make_mut(&mut self.table)[slot as usize] = base;
|
||
self.slot_len[slot as usize] = bones;
|
||
self.mirrored[slot as usize] = false;
|
||
self.table_generation += 1;
|
||
self.live_bones += bones;
|
||
self.peak_bones = self.peak_bones.max(self.live_bones);
|
||
self.peak_slots = self
|
||
.peak_slots
|
||
.max(self.slot_high - 1 - self.free_slots.len());
|
||
Some((slot, base))
|
||
}
|
||
|
||
/// Return a rig's slot + range. Rows are zeroed (a part's stale tag during the one-frame
|
||
/// despawn skew reads a degenerate matrix — collapsed at the origin — never another rig's
|
||
/// pose), and the range coalesces back into the free list.
|
||
pub(crate) fn free(&mut self, slot: u16) {
|
||
let s = slot as usize;
|
||
let Some(&len) = self.slot_len.get(s).filter(|&&l| l > 0) else {
|
||
return;
|
||
};
|
||
let base = self.table[s];
|
||
self.slot_len[s] = 0;
|
||
self.free_slots.push(slot);
|
||
self.live_bones -= len;
|
||
self.table_generation += 1;
|
||
let rows = rows_make_mut(
|
||
&mut self.rows,
|
||
&mut self.cost_copies,
|
||
&mut self.cost_copy_us,
|
||
);
|
||
rows[3 * base as usize..3 * (base + len) as usize].fill([0.0; 4]);
|
||
self.dirty.push((base, len, self.mirrored[s]));
|
||
// Insert sorted + coalesce with both neighbours.
|
||
let i = self.free_ranges.partition_point(|&(b, _)| b < base);
|
||
self.free_ranges.insert(i, (base, len));
|
||
if i + 1 < self.free_ranges.len() {
|
||
let (nb, nl) = self.free_ranges[i + 1];
|
||
if base + len == nb {
|
||
self.free_ranges[i].1 += nl;
|
||
self.free_ranges.remove(i + 1);
|
||
}
|
||
}
|
||
if i > 0 {
|
||
let (pb, pl) = self.free_ranges[i - 1];
|
||
if pb + pl == base {
|
||
self.free_ranges[i - 1].1 += self.free_ranges[i].1;
|
||
self.free_ranges.remove(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Rewrite one rig's rows from its joints' world transforms × inverse bindposes, and mark the
|
||
/// range dirty. `n` is clamped to the allocated length (a malformed model whose joint list
|
||
/// and bindpose list disagree writes the shorter).
|
||
fn write_rig(
|
||
&mut self,
|
||
slot: u16,
|
||
base: u32,
|
||
alloc_len: u32,
|
||
joints: &[Entity],
|
||
ibp: &[Mat4],
|
||
worlds: &Query<Ref<GlobalTransform>>,
|
||
) {
|
||
let n = (joints.len().min(ibp.len()) as u32).min(alloc_len);
|
||
self.cost_rows += n;
|
||
let rows = rows_make_mut(
|
||
&mut self.rows,
|
||
&mut self.cost_copies,
|
||
&mut self.cost_copy_us,
|
||
);
|
||
for b in 0..n as usize {
|
||
let Ok(g) = worlds.get(joints[b]) else {
|
||
continue; // a torn-down joint keeps its last rows
|
||
};
|
||
let m = g.affine() * Affine3A::from_mat4(ibp[b]);
|
||
let (m3, t) = (m.matrix3, m.translation);
|
||
let r = 3 * (base as usize + b);
|
||
rows[r] = [m3.x_axis.x, m3.y_axis.x, m3.z_axis.x, t.x];
|
||
rows[r + 1] = [m3.x_axis.y, m3.y_axis.y, m3.z_axis.y, t.y];
|
||
rows[r + 2] = [m3.x_axis.z, m3.y_axis.z, m3.z_axis.z, t.z];
|
||
}
|
||
if n > 0 {
|
||
let mirrored = self.mirrored.get(slot as usize).copied().unwrap_or(false);
|
||
self.dirty.push((base, n, mirrored));
|
||
}
|
||
}
|
||
|
||
/// The collapsed-rig lane's row write (decision 0724): the world pass hands the composed —
|
||
/// and billboard-replaced — per-bone world frames directly; rows are `world × ibp`, the same
|
||
/// product [`Self::write_rig`] reads off joint entities. `worlds` beyond the allocation (or
|
||
/// the bindpose list) is ignored, mirroring the entity path's clamp.
|
||
pub(crate) fn write_rig_worlds(
|
||
&mut self,
|
||
rig: &RigSkin,
|
||
worlds: &[GlobalTransform],
|
||
ibp: &[Mat4],
|
||
) {
|
||
let n = (worlds.len().min(ibp.len()) as u32).min(rig.len);
|
||
let (slot, base) = (rig.slot, rig.base);
|
||
self.cost_rows += n;
|
||
let rows = rows_make_mut(
|
||
&mut self.rows,
|
||
&mut self.cost_copies,
|
||
&mut self.cost_copy_us,
|
||
);
|
||
for b in 0..n as usize {
|
||
let m = worlds[b].affine() * Affine3A::from_mat4(ibp[b]);
|
||
let (m3, t) = (m.matrix3, m.translation);
|
||
let r = 3 * (base as usize + b);
|
||
rows[r] = [m3.x_axis.x, m3.y_axis.x, m3.z_axis.x, t.x];
|
||
rows[r + 1] = [m3.x_axis.y, m3.y_axis.y, m3.z_axis.y, t.y];
|
||
rows[r + 2] = [m3.x_axis.z, m3.y_axis.z, m3.z_axis.z, t.z];
|
||
}
|
||
if n > 0 {
|
||
let mirrored = self.mirrored.get(slot as usize).copied().unwrap_or(false);
|
||
self.dirty.push((base, n, mirrored));
|
||
}
|
||
}
|
||
|
||
/// Flag a rig's rows to ALSO reach the booth mirror buffers ([`RigPaletteMirrors`]): the
|
||
/// booth spawners call it for their rigs — whose materials bind a studio light buffer, not
|
||
/// the shared one — right after allocation.
|
||
pub(crate) fn mark_mirrored(&mut self, slot: u16) {
|
||
if let Some(m) = self.mirrored.get_mut(slot as usize) {
|
||
*m = true;
|
||
}
|
||
}
|
||
|
||
/// The mouseover picker's read side: rig slot → world-space `Mat4` palette (the same values
|
||
/// Bevy's `skin_model` would compute), reconstructed from the rows.
|
||
pub(crate) fn world_palette(&self, slot: u16, bones: usize) -> Option<Vec<Mat4>> {
|
||
let s = slot as usize;
|
||
let len = (*self.slot_len.get(s)? as usize).min(bones);
|
||
if len == 0 {
|
||
return None;
|
||
}
|
||
let base = self.table[s] as usize;
|
||
Some(
|
||
(0..len)
|
||
.map(|b| {
|
||
let r = 3 * (base + b);
|
||
Mat4::from_cols(
|
||
Vec4::from_array(self.rows[r]),
|
||
Vec4::from_array(self.rows[r + 1]),
|
||
Vec4::from_array(self.rows[r + 2]),
|
||
Vec4::W,
|
||
)
|
||
.transpose()
|
||
})
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
/// How many live rigs have a COMPUTED palette (their first bone's first row is non-zero — a
|
||
/// freshly allocated or never-computed rig reads all-zero rows). The FPS probe prints it:
|
||
/// allocation without computation renders origin-collapsed rigs, and no other probe number
|
||
/// would catch that failure mode.
|
||
pub(crate) fn computed_rigs(&self) -> usize {
|
||
(0..self.slot_high)
|
||
.filter(|&s| self.slot_len[s] > 0 && self.rows[3 * self.table[s] as usize] != [0.0; 4])
|
||
.count()
|
||
}
|
||
|
||
/// Live and peak occupancy `(slots, bones, peak_slots, peak_bones)` — the overflow diagnostic.
|
||
pub(crate) fn occupancy(&self) -> (usize, u32, usize, u32) {
|
||
(
|
||
self.slot_high - 1 - self.free_slots.len(),
|
||
self.live_bones,
|
||
self.peak_slots,
|
||
self.peak_bones,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// A registered rig, on the entity that owns its pose (the unit / doodad-host / booth root):
|
||
/// its palette slot + the joint entities + the shared inverse bindposes. The hook frees the slot
|
||
/// whoever despawns the rig — and `on_replace` (not `on_remove`) because a gear/display rebuild
|
||
/// re-runs the attach on the SAME entity and insert-overwrites this component: `on_remove` never
|
||
/// fires on an overwrite, and the old slot would leak until the unit despawned.
|
||
#[derive(Component)]
|
||
#[component(on_replace = free_rig_skin)]
|
||
pub(crate) struct RigSkin {
|
||
pub(crate) slot: u16,
|
||
base: u32,
|
||
len: u32,
|
||
pub(crate) joints: Vec<Entity>,
|
||
pub(crate) ibp: Handle<SkinnedMeshInverseBindposes>,
|
||
}
|
||
|
||
impl RigSkin {
|
||
/// The rig's allocated bone count — the palette-facing length (the mouseover picker sizes
|
||
/// its palette read with it; the collapsed lane has no joint list to measure).
|
||
pub(crate) fn bones(&self) -> u32 {
|
||
self.len
|
||
}
|
||
|
||
/// Allocate a palette rig for the collapsed lane (decision 0724): no joint entities — the
|
||
/// world pass hands composed frames to [`RigPalettes::write_rig_worlds`] instead, so the
|
||
/// entity-lane change sweep never touches this slot (empty `joints` make its `write_rig` a
|
||
/// no-op by construction).
|
||
pub(crate) fn allocate_bones(
|
||
palettes: &mut RigPalettes,
|
||
bones: u32,
|
||
ibp: Handle<SkinnedMeshInverseBindposes>,
|
||
) -> Option<Self> {
|
||
Self::allocate_inner(palettes, bones, Vec::new(), ibp)
|
||
}
|
||
|
||
/// Allocate a palette rig over live joint entities (the doodad/effect/booth lane — the
|
||
/// change sweep computes its rows). `None` (with one loud warn per session) when the table
|
||
/// is full — the caller renders the static bind-pose mesh instead.
|
||
pub(crate) fn allocate(
|
||
palettes: &mut RigPalettes,
|
||
joints: Vec<Entity>,
|
||
ibp: Handle<SkinnedMeshInverseBindposes>,
|
||
) -> Option<Self> {
|
||
let bones = joints.len() as u32;
|
||
Self::allocate_inner(palettes, bones, joints, ibp)
|
||
}
|
||
|
||
fn allocate_inner(
|
||
palettes: &mut RigPalettes,
|
||
bones: u32,
|
||
joints: Vec<Entity>,
|
||
ibp: Handle<SkinnedMeshInverseBindposes>,
|
||
) -> Option<Self> {
|
||
match palettes.alloc(bones) {
|
||
Some((slot, base)) => Some(Self {
|
||
slot,
|
||
base,
|
||
len: bones,
|
||
joints,
|
||
ibp,
|
||
}),
|
||
None => {
|
||
let (s, b, ps, pb) = palettes.occupancy();
|
||
warn!(
|
||
"rig palette exhausted ({bones} bones wanted; live {s} slots / {b} bones, \
|
||
peak {ps}/{pb}) — rig renders at bind pose"
|
||
);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn free_rig_skin(mut world: DeferredWorld, ctx: HookContext) {
|
||
let slot = world
|
||
.get::<RigSkin>(ctx.entity)
|
||
.map(|r| r.slot)
|
||
.expect("on_remove runs with the component still present");
|
||
world.resource_mut::<RigPalettes>().free(slot);
|
||
}
|
||
|
||
/// On every joint entity (planted by `entities::spawn_joints`): the rig root whose palette this
|
||
/// joint feeds. The change sweep below is a linear pass over exactly these.
|
||
#[derive(Component)]
|
||
pub(crate) struct RigJoint(pub(crate) Entity);
|
||
|
||
/// On every skinned part entity: the rig root, for CPU-side palette consumers (the mouseover
|
||
/// picker's skinned ray test). The GPU side reads the same link from the part's `MeshTag` rig
|
||
/// field instead.
|
||
#[derive(Component)]
|
||
pub(crate) struct RigPart(pub(crate) Entity);
|
||
|
||
/// PostUpdate, after the billboard joint pass (the last joint-world writer): one linear
|
||
/// `Changed<GlobalTransform>` sweep over the joint population → the set of rigs whose palettes
|
||
/// must recompute this frame → rewrite exactly those rigs' rows.
|
||
fn compute_rig_palettes(
|
||
changed: Query<&RigJoint, Changed<GlobalTransform>>,
|
||
rigs: Query<&RigSkin>,
|
||
worlds: Query<Ref<GlobalTransform>>,
|
||
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
|
||
mut palettes: ResMut<RigPalettes>,
|
||
) {
|
||
let dirty_rigs: HashSet<Entity> = changed.iter().map(|j| j.0).collect();
|
||
for root in dirty_rigs {
|
||
let Ok(rig) = rigs.get(root) else {
|
||
continue; // allocation failed at spawn (bind-pose fallback) or already torn down
|
||
};
|
||
let Some(ibp) = ibps.get(&rig.ibp) else {
|
||
continue;
|
||
};
|
||
palettes.write_rig(rig.slot, rig.base, rig.len, &rig.joints, ibp, &worlds);
|
||
}
|
||
}
|
||
|
||
/// The render-world mirror: `Arc` bumps + the frame's dirty ranges.
|
||
#[derive(Resource, Clone, ExtractResource)]
|
||
struct RigPaletteExtract {
|
||
rows: Arc<Vec<[f32; 4]>>,
|
||
table: Arc<Vec<u32>>,
|
||
dirty: Arc<Vec<(u32, u32, bool)>>,
|
||
table_generation: u64,
|
||
}
|
||
|
||
impl Default for RigPaletteExtract {
|
||
fn default() -> Self {
|
||
Self {
|
||
rows: Arc::new(Vec::new()),
|
||
table: Arc::new(Vec::new()),
|
||
dirty: Arc::new(Vec::new()),
|
||
// != RigPalettes' initial 0, so the first publish uploads even an empty table.
|
||
table_generation: u64::MAX,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Main world, after the compute: hand the frame's dirty ranges + the shared rows to extraction.
|
||
fn publish_rig_palettes(mut palettes: ResMut<RigPalettes>, mut out: ResMut<RigPaletteExtract>) {
|
||
if palettes.dirty.is_empty() && out.table_generation == palettes.table_generation {
|
||
return;
|
||
}
|
||
let p = palettes.as_mut();
|
||
if rig_cost_enabled() {
|
||
eprintln!(
|
||
"[rig-cost] ranges={} rows={} copies={} copy_ms={:.3}",
|
||
p.dirty.len(),
|
||
p.cost_rows,
|
||
p.cost_copies,
|
||
p.cost_copy_us / 1000.0
|
||
);
|
||
(p.cost_copies, p.cost_copy_us, p.cost_rows) = (0, 0.0, 0);
|
||
}
|
||
out.rows = Arc::clone(&p.rows);
|
||
out.table = Arc::clone(&p.table);
|
||
out.dirty = Arc::new(std::mem::take(&mut p.dirty));
|
||
out.table_generation = p.table_generation;
|
||
}
|
||
|
||
/// Extra `wow_light`-layout buffers that mirror the palette regions (the glue/portrait booths'
|
||
/// studio buffers — their skinned rigs bind those, not the shared buffer). Registered by key at
|
||
/// booth creation; re-registration replaces (a rebuilt booth's old buffer drops).
|
||
#[derive(Resource, Clone, Default, ExtractResource)]
|
||
pub(crate) struct RigPaletteMirrors(
|
||
pub(crate) std::collections::HashMap<&'static str, bevy::render::render_resource::Buffer>,
|
||
);
|
||
|
||
/// Render world (`PrepareResources`): write the dirty rows + (on change) the slot table into the
|
||
/// shared buffer and every registered mirror. A parked, stationary world costs zero bytes here.
|
||
fn upload_rig_palettes(
|
||
queue: Res<RenderQueue>,
|
||
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
|
||
mirrors: Option<Res<RigPaletteMirrors>>,
|
||
data: Option<Res<RigPaletteExtract>>,
|
||
mut last: Local<(Option<u64>, u64)>,
|
||
) {
|
||
let Some(data) = data else { return };
|
||
// The extract clones every frame; gate on content. `last.1` counts published dirty batches
|
||
// via the Arc pointer (a new publish makes a new Arc).
|
||
let dirty_ptr = Arc::as_ptr(&data.dirty) as u64;
|
||
let table_new = last.0 != Some(data.table_generation);
|
||
let dirty_new = last.1 != dirty_ptr && !data.dirty.is_empty();
|
||
if !table_new && !dirty_new {
|
||
return;
|
||
}
|
||
*last = (Some(data.table_generation), dirty_ptr);
|
||
let cost_t0 = rig_cost_enabled().then(std::time::Instant::now);
|
||
let mut cost_calls = 0u32;
|
||
let mut cost_bytes = 0u64;
|
||
// Coalesce the per-rig dirty ranges before touching the queue: rigs allocate contiguously,
|
||
// so a steady frame's ~750 one-rig ranges merge into a handful of runs — the 0724 ledger
|
||
// measured the per-range `write_buffer` loop at 2.7 ms/frame, almost all call overhead.
|
||
let all = coalesce_ranges(data.dirty.iter().map(|&(b, l, _)| (b, l)).collect());
|
||
let mirrored_only = coalesce_ranges(
|
||
data.dirty
|
||
.iter()
|
||
.filter(|&&(_, _, m)| m)
|
||
.map(|&(b, l, _)| (b, l))
|
||
.collect(),
|
||
);
|
||
// Every target sees the slot table; the shared buffer takes all dirty rows, the booth
|
||
// mirrors only the ranges flagged mirrored (their rigs) — world traffic never multiplies.
|
||
let targets = shared
|
||
.iter()
|
||
.map(|s| (&s.0, false))
|
||
.chain(mirrors.iter().flat_map(|m| m.0.values().map(|b| (b, true))));
|
||
for (buffer, mirror_only) in targets {
|
||
if table_new && !data.table.is_empty() {
|
||
queue.write_buffer(
|
||
buffer,
|
||
rig_table_region_offset(),
|
||
bytemuck::cast_slice(&data.table),
|
||
);
|
||
}
|
||
if dirty_new {
|
||
let ranges = if mirror_only { &mirrored_only } else { &all };
|
||
for &(base, len) in ranges {
|
||
let rows = &data.rows[3 * base as usize..3 * (base + len) as usize];
|
||
queue.write_buffer(
|
||
buffer,
|
||
palette_region_offset() + base as u64 * BONE_BYTES,
|
||
bytemuck::cast_slice(rows),
|
||
);
|
||
cost_calls += 1;
|
||
cost_bytes += len as u64 * BONE_BYTES;
|
||
}
|
||
}
|
||
}
|
||
if let Some(t0) = cost_t0 {
|
||
eprintln!(
|
||
"[rig-upload] calls={cost_calls} kb={} ms={:.3}",
|
||
cost_bytes / 1024,
|
||
t0.elapsed().as_secs_f32() * 1000.0
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Sort + merge overlapping/adjacent `(base, len)` bone ranges into maximal runs.
|
||
fn coalesce_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
|
||
ranges.sort_unstable_by_key(|r| r.0);
|
||
let mut out: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
|
||
for (base, len) in ranges {
|
||
if let Some(last) = out.last_mut() {
|
||
if base <= last.0 + last.1 {
|
||
last.1 = (base + len).max(last.0 + last.1) - last.0;
|
||
continue;
|
||
}
|
||
}
|
||
out.push((base, len));
|
||
}
|
||
out
|
||
}
|
||
|
||
pub(crate) fn plugin(app: &mut App) {
|
||
app.init_resource::<RigPalettes>()
|
||
.init_resource::<RigPaletteExtract>()
|
||
.init_resource::<RigPaletteMirrors>()
|
||
.add_plugins(ExtractResourcePlugin::<RigPaletteExtract>::default())
|
||
.add_plugins(ExtractResourcePlugin::<RigPaletteMirrors>::default())
|
||
.add_systems(
|
||
PostUpdate,
|
||
(compute_rig_palettes, publish_rig_palettes)
|
||
.chain()
|
||
// After the last joint-world writer: propagation, then the billboard joint pass
|
||
// (which rewrites joint GlobalTransforms in place — see its module doc).
|
||
.after(crate::billboard::BillboardPlace),
|
||
);
|
||
if let Some(render) = app.get_sub_app_mut(RenderApp) {
|
||
render.add_systems(
|
||
Render,
|
||
upload_rig_palettes.in_set(RenderSystems::PrepareResources),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn alloc_free_coalesce_roundtrip() {
|
||
let mut p = RigPalettes::default();
|
||
let (s1, b1) = p.alloc(10).unwrap();
|
||
let (s2, b2) = p.alloc(20).unwrap();
|
||
let (s3, b3) = p.alloc(30).unwrap();
|
||
assert!(s1 >= 1, "slot 0 is the no-rig sentinel");
|
||
assert_eq!((b1, b2, b3), (0, 10, 30));
|
||
// Free the middle range: the free list holds it separately…
|
||
p.free(s2);
|
||
assert_eq!(p.occupancy().0, 2);
|
||
// …an exact-fit alloc reuses it…
|
||
let (s4, b4) = p.alloc(20).unwrap();
|
||
assert_eq!(b4, 10);
|
||
// …and freeing everything coalesces back to one range.
|
||
p.free(s1);
|
||
p.free(s4);
|
||
p.free(s3);
|
||
assert_eq!(p.free_ranges, vec![(0, MAX_PALETTE_BONES as u32)]);
|
||
assert_eq!(p.occupancy().0, 0);
|
||
assert_eq!(p.occupancy().2, 3, "peak slots");
|
||
assert_eq!(p.occupancy().3, 60, "peak bones");
|
||
}
|
||
|
||
#[test]
|
||
fn freed_rows_read_zero_not_stale() {
|
||
let mut p = RigPalettes::default();
|
||
let (slot, base) = p.alloc(2).unwrap();
|
||
Arc::make_mut(&mut p.rows)[3 * base as usize] = [1.0; 4];
|
||
p.free(slot);
|
||
assert_eq!(p.rows[3 * base as usize], [0.0; 4]);
|
||
// The free is a dirty range too — the GPU mirror zeroes with us.
|
||
assert!(p.dirty.contains(&(base, 2, false)));
|
||
}
|
||
|
||
#[test]
|
||
fn alloc_fails_gracefully_at_capacity() {
|
||
let mut p = RigPalettes::default();
|
||
let (big, _) = p.alloc(MAX_PALETTE_BONES as u32).unwrap();
|
||
assert!(p.alloc(1).is_none(), "no bones left");
|
||
p.free(big);
|
||
// Slot exhaustion, separately from bone exhaustion.
|
||
let taken: Vec<u16> = (0..MAX_RIG_SLOTS - 1)
|
||
.map(|_| p.alloc(1).unwrap().0)
|
||
.collect();
|
||
assert!(p.alloc(1).is_none(), "no slots left");
|
||
for s in taken {
|
||
p.free(s);
|
||
}
|
||
assert!(p.alloc(1).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn world_palette_reconstructs_the_affine() {
|
||
let mut p = RigPalettes::default();
|
||
let (slot, base) = p.alloc(1).unwrap();
|
||
// A recognisable affine: rotate 90° about Y, translate (1,2,3).
|
||
let m = Mat4::from_rotation_translation(
|
||
Quat::from_rotation_y(std::f32::consts::FRAC_PI_2),
|
||
Vec3::new(1.0, 2.0, 3.0),
|
||
);
|
||
let a = Affine3A::from_mat4(m);
|
||
let rows = Arc::make_mut(&mut p.rows);
|
||
let (m3, t) = (a.matrix3, a.translation);
|
||
let r = 3 * base as usize;
|
||
rows[r] = [m3.x_axis.x, m3.y_axis.x, m3.z_axis.x, t.x];
|
||
rows[r + 1] = [m3.x_axis.y, m3.y_axis.y, m3.z_axis.y, t.y];
|
||
rows[r + 2] = [m3.x_axis.z, m3.y_axis.z, m3.z_axis.z, t.z];
|
||
let pal = p.world_palette(slot, 1).unwrap();
|
||
assert!(pal[0].abs_diff_eq(m, 1e-6), "{:?} vs {m:?}", pal[0]);
|
||
// A transformed point round-trips through the reconstruction.
|
||
let q = Vec3::new(0.5, -1.0, 2.0);
|
||
assert!(pal[0]
|
||
.transform_point3(q)
|
||
.abs_diff_eq(m.transform_point3(q), 1e-5));
|
||
}
|
||
|
||
#[test]
|
||
fn dirty_ranges_coalesce_into_runs() {
|
||
// Adjacent + overlapping merge; a hole splits. Order-independent (upload sorts).
|
||
assert_eq!(
|
||
coalesce_ranges(vec![(70, 5), (0, 10), (10, 20), (25, 10), (40, 5)]),
|
||
vec![(0, 35), (40, 5), (70, 5)]
|
||
);
|
||
assert_eq!(coalesce_ranges(Vec::new()), Vec::new());
|
||
}
|
||
|
||
#[test]
|
||
fn region_layout_is_consistent() {
|
||
// The table sits after the probe region, palettes after the table, and the blob-size
|
||
// extension covers both exactly (wow_model.wgsl mirrors this layout).
|
||
assert_eq!(
|
||
palette_region_offset() - rig_table_region_offset(),
|
||
(MAX_RIG_SLOTS * 4) as u64
|
||
);
|
||
assert_eq!(
|
||
palette_regions_bytes(),
|
||
(MAX_RIG_SLOTS * 4) as u64 + (MAX_PALETTE_BONES as u64) * BONE_BYTES
|
||
);
|
||
}
|
||
}
|