the skinning code catches up, and 59k bone entities become arrays

- An honest correction first: the previous snapshot's message announced the
  owned skinning lane, but the code itself missed that snapshot by minutes —
  two sessions landed at once and the export carried the older tree. The
  skinning lane actually ships here, together with its follow-up.
- The follow-up is the bone-entity collapse. A creature's ~75-bone skeleton
  existed as ~75 world entities, each paying transform propagation and
  bookkeeping every frame, to serve the ~5 bones anything actually hangs off
  (held weapons, glow emitters, the mount seat, overhead markers). A rig's pose
  now lives in plain arrays on the model itself; only the consumer anchor
  points remain as entities. At the crowded dungeon spot ~59,000 entities
  became ~28,000 anchors (80k total → 50k), palette uploads coalesce into a
  handful of writes, and another ~3 CPU ms came off the frame.
- The movement echo the server sends about your own character is applied now,
  the way the original client always applied it — groundwork that GM flight
  cashes in later today.
- The capture harness pins its clock per frame now, not just its frame count —
  a slow run used to drift every animation out from under a golden image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-28 21:55:01 +07:00
co-authored by Claude Fable 5
parent 90efd4efad
commit 63efec188f
48 changed files with 2687 additions and 535 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ mod model;
pub use model::{
bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations,
ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, PoseBone, PoseClip,
PoseNode, PoseSource, PoseTrack,
PoseNode, PoseSource, PoseTrack, ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT,
};
mod adt;
mod terrain;
+30 -7
View File
@@ -175,20 +175,43 @@ pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh {
mesh
}
/// Build the **skinned** twin of [`build_submesh_mesh`]: the same baked geometry plus the per-vertex
/// `JOINT_INDEX` (`Uint16x4` — M2 bone indices, used directly as joint indices) + `JOINT_WEIGHT`
/// (`Float32x4`, normalised). These two attributes are the entire trigger for Bevy's `SKINNED` shader
/// path (decision 0019). 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.
/// The skinned twin's per-vertex joint indices (`Uint16x4` — M2 bone indices, used directly as
/// palette indices). **Deliberately NOT `Mesh::ATTRIBUTE_JOINT_INDEX`** (decision 0720): Bevy's
/// `SKINNED` pipeline path triggers on the standard attributes being in the mesh LAYOUT — while
/// its draw-time skin bind group resolves per ENTITY registration — so a mesh carrying them can
/// only ever render through Bevy's skin lane. Our own attribute ids keep Bevy's `is_skinned()`
/// false everywhere; `WowModelExt::specialize` sees them in the layout and compiles the
/// `WOW_RIG_SKIN` path instead, which skins from the owned palette region of the shared light
/// buffer (`rig_palette`). Shader locations 10/11 (Bevy's builtin set ends at 7).
pub const ATTRIBUTE_WOW_JOINT_INDEX: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_JointIndex",
988_540_917,
bevy::render::render_resource::VertexFormat::Uint16x4,
);
/// The skinned twin's per-vertex joint weights (`Float32x4`, normalised) — see
/// [`ATTRIBUTE_WOW_JOINT_INDEX`].
pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute =
bevy::mesh::MeshVertexAttribute::new(
"Wow_JointWeight",
988_540_918,
bevy::render::render_resource::VertexFormat::Float32x4,
);
/// Build the **skinned** twin of [`build_submesh_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);
if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() {
mesh.insert_attribute(
Mesh::ATTRIBUTE_JOINT_INDEX,
ATTRIBUTE_WOW_JOINT_INDEX,
VertexAttributeValues::Uint16x4(sub.joints.clone()),
);
mesh.insert_attribute(
Mesh::ATTRIBUTE_JOINT_WEIGHT,
ATTRIBUTE_WOW_JOINT_WEIGHT,
VertexAttributeValues::Float32x4(sub.weights.clone()),
);
}
+120 -26
View File
@@ -35,10 +35,9 @@
#import bevy_pbr::{
pbr_fragment::pbr_input_from_standard_material,
pbr_functions::alpha_discard,
forward_io::{Vertex, VertexOutput, FragmentOutput},
forward_io::{VertexOutput, FragmentOutput},
mesh_view_bindings::view,
view_transformations::position_world_to_clip,
skinning,
mesh_functions,
}
@@ -131,6 +130,13 @@ struct WowLight {
// [7·slot .. 7·slot+7) over the fragment normal. Only this shader declares the region — the
// other shaders mirror the buffer PREFIX and bind the same (larger) buffer.
prop_probes: array<vec4<f32>, 57344>,
// The owned skin palette (decision 0720; rig_palette.rs mirrors both sizes). `rig_table`:
// one base bone index per rig slot (2048 = mesh_tag's 11-bit rig field; the instance's slot
// rides its MeshTag bits 19-29). `palettes`: 3 vec4 rows per bone — the rows of
// `world_from_joint × inverse_bindpose`, the same matrix Bevy's skin lane would feed
// `skin_model` — blended in the vertex stage below (WOW_RIG_SKIN).
rig_table: array<u32, 2048>,
palettes: array<vec4<f32>>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -230,28 +236,113 @@ fn mcnk_cell_anchor(P: vec3<f32>) -> vec3<f32> {
return vec3<f32>((ix + 0.5) * cell - half, P.y, (iz + 0.5) * cell - half);
}
// Custom vertex stage — bevy 0.18's `mesh.wgsl` vertex verbatim (same defs: SKINNED skinning,
// VERTEX_* attributes; morph targets omitted — no model mesh authors them), plus the per-vertex
// point-light evaluation on the post-skin world position/normal. A `MaterialExtension` swaps the
// whole stage, so the mirror must track bevy's on upgrades.
// The vertex input — bevy 0.18's `forward_io::Vertex` fields at bevy's shader locations (the
// VERTEX_* defs come from the base mesh-pipeline specialize, driven by what the mesh authors;
// tangents / morphs never — no model mesh has them), PLUS the owned-palette joint attributes at
// locations 10/11 under WOW_RIG_SKIN (`WowModelExt::specialize` sets the def and appends the
// attributes to the buffer layout when the mesh carries `ATTRIBUTE_WOW_JOINT_INDEX` — decision
// 0720; Bevy's `forward_io::Vertex` only declares joints under its own SKINNED path, which no
// benilla mesh triggers anymore).
struct WowVertex {
@builtin(instance_index) instance_index: u32,
#ifdef VERTEX_POSITIONS
@location(0) position: vec3<f32>,
#endif
#ifdef VERTEX_NORMALS
@location(1) normal: vec3<f32>,
#endif
#ifdef VERTEX_UVS_A
@location(2) uv: vec2<f32>,
#endif
#ifdef VERTEX_UVS_B
@location(3) uv_b: vec2<f32>,
#endif
#ifdef VERTEX_COLORS
@location(5) color: vec4<f32>,
#endif
#ifdef WOW_RIG_SKIN
@location(10) joint_indices: vec4<u32>,
@location(11) joint_weights: vec4<f32>,
#endif
}
#ifdef WOW_RIG_SKIN
// The owned-palette skin model (decision 0720): the instance's rig slot from its MeshTag rig
// field (bits 19-29) → the rig's base bone index → the four indexed bones' palette rows blended
// by the vertex weights. Returns `world_from_local` exactly like Bevy's `skin_model` — the rows
// ARE `world_from_joint × inverse_bindpose`, so the reconstruction below is the same matrix
// Bevy's lane produced, and it REPLACES the mesh's world matrix (never composes with it).
fn wow_skin_model(instance_index: u32, indices: vec4<u32>, weights: vec4<f32>) -> mat4x4<f32> {
let tag = mesh_functions::get_tag(instance_index);
let base = wow_light.rig_table[(tag >> 19u) & 0x7ffu];
let b0 = 3u * (base + indices.x);
let b1 = 3u * (base + indices.y);
let b2 = 3u * (base + indices.z);
let b3 = 3u * (base + indices.w);
let r0 = weights.x * wow_light.palettes[b0]
+ weights.y * wow_light.palettes[b1]
+ weights.z * wow_light.palettes[b2]
+ weights.w * wow_light.palettes[b3];
let r1 = weights.x * wow_light.palettes[b0 + 1u]
+ weights.y * wow_light.palettes[b1 + 1u]
+ weights.z * wow_light.palettes[b2 + 1u]
+ weights.w * wow_light.palettes[b3 + 1u];
let r2 = weights.x * wow_light.palettes[b0 + 2u]
+ weights.y * wow_light.palettes[b1 + 2u]
+ weights.z * wow_light.palettes[b2 + 2u]
+ weights.w * wow_light.palettes[b3 + 2u];
// r0/r1/r2 are the affine's ROWS; a wgsl matrix is column-major.
return mat4x4<f32>(
vec4<f32>(r0.x, r1.x, r2.x, 0.0),
vec4<f32>(r0.y, r1.y, r2.y, 0.0),
vec4<f32>(r0.z, r1.z, r2.z, 0.0),
vec4<f32>(r0.w, r1.w, r2.w, 1.0),
);
}
// bevy_pbr::skinning's normal math verbatim (inverse-transpose via the adjugate), on our matrix.
fn inverse_transpose_3x3m(in: mat3x3<f32>) -> mat3x3<f32> {
let x = cross(in[1], in[2]);
let y = cross(in[2], in[0]);
let z = cross(in[0], in[1]);
let det = dot(in[2], z);
return mat3x3<f32>(x / det, y / det, z / det);
}
fn wow_skin_normals(world_from_local: mat4x4<f32>, normal: vec3<f32>) -> vec3<f32> {
return normalize(
inverse_transpose_3x3m(mat3x3<f32>(
world_from_local[0].xyz,
world_from_local[1].xyz,
world_from_local[2].xyz
)) * normal
);
}
#endif
// Custom vertex stage — bevy 0.18's `mesh.wgsl` vertex verbatim (VERTEX_* attributes; morph
// targets omitted — no model mesh authors them) with the owned-palette skinning in place of
// Bevy's SKINNED path (decision 0720), plus the per-vertex point-light evaluation on the
// post-skin world position/normal. A `MaterialExtension` swaps the whole stage, so the mirror
// must track bevy's on upgrades.
@vertex
fn vertex(vertex: Vertex) -> WowVsOut {
fn vertex(vertex: WowVertex) -> WowVsOut {
var out: WowVsOut;
let mesh_world_from_local = mesh_functions::get_world_from_local(vertex.instance_index);
#ifdef SKINNED
var world_from_local = skinning::skin_model(
#ifdef WOW_RIG_SKIN
var world_from_local = wow_skin_model(
vertex.instance_index,
vertex.joint_indices,
vertex.joint_weights,
vertex.instance_index
vertex.joint_weights
);
#else
var world_from_local = mesh_world_from_local;
#endif
#ifdef VERTEX_NORMALS
#ifdef SKINNED
out.world_normal = skinning::skin_normals(world_from_local, vertex.normal);
#ifdef WOW_RIG_SKIN
out.world_normal = wow_skin_normals(world_from_local, vertex.normal);
#else
out.world_normal = mesh_functions::mesh_normal_local_to_world(
vertex.normal,
@@ -367,13 +458,16 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
// constant `tex0.a < 224/255` (STABLE silhouette, never grows/snaps) — with blend on and source
// alpha = `tex0.a × fade`. On the blend twin (`model_flags.y`) `AlphaMode::Blend` does no discard,
// so we re-apply that hard cutout here on the UNFADED alpha; `specialize` keeps depth-write ON.
// The exterior payload is TYPED (mesh_tag.rs, decision 0173): bits 0-15 = fade alpha u16 (a whole
// payload of 0 = the untagged ⇒ opaque sentinel), bits 16-23 = the per-instance ground-shade byte
// (0 lit → 255 MCSH-shadowed — decoded at the doodad sun below; entities ramp it, statics leave 0).
// On an interior-mode material (interior z, not WMO x) the payload carries the SH-probe SLOT in
// bits 16-29 instead — static MODD props at spawn, and every indoor entity on the footprint-bake
// law (decision 0354: units keep the probe lane indoors; the day/night state is the exterior
// material at the intensity-1.0 shade byte, not a mode of its own).
// The payload is TYPED (mesh_tag.rs, decisions 0173/0720): bits 0-5 = the fade alpha (6-bit
// fraction; a whole payload of 0 = the untagged ⇒ opaque sentinel), bits 19-29 = the skin
// rig slot (vertex-stage concern — the fragment never reads it, but it rides every payload,
// so the 0-sentinel test uses the WHOLE masked payload as before). Between them the exterior
// payload carries the per-instance ground-shade byte in bits 6-13 (0 lit → 255 MCSH-shadowed
// — decoded at the doodad sun below; entities ramp it, statics leave 0). On an interior-mode
// material (interior z, not WMO x) bits 6-18 carry the SH-probe SLOT instead — static MODD
// props at spawn, and every indoor entity on the footprint-bake law (decision 0354: units
// keep the probe lane indoors; the day/night state is the exterior material at the
// intensity-1.0 shade byte, not a mode of its own).
// Tag bits 31/30 are standalone flags (mesh_tag.rs), split off before the payload decode so the
// 0-sentinel and both payload modes read the masked value: bit 31 = hover/target HIGHLIGHT,
// bit 30 = INTERIOR FOG — the instance's model stands in a WMO interior, so it fogs with the
@@ -383,11 +477,11 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
let raw_tag = mesh_functions::get_tag(in.instance_index);
let highlighted = (raw_tag & 0x80000000u) != 0u;
let interior_fogged = (raw_tag & 0x40000000u) != 0u;
// Bits 0-15 are the fade alpha in BOTH payload modes (the interior-probe slot rides bits
// 16-29 since 0355), so a feathering indoor entity keeps its probe AND its alpha ramp.
// Bits 0-5 are the fade alpha in BOTH payload modes, so a feathering indoor entity keeps its
// probe AND its alpha ramp — and a skinned part keeps its rig slot through either.
let fade_tag = raw_tag & 0x3fffffffu;
let alpha16 = f32(fade_tag & 0xffffu) / 65535.0;
let obj_fade = select(alpha16, 1.0, fade_tag == 0u);
let alpha6 = f32(fade_tag & 0x3fu) / 63.0;
let obj_fade = select(alpha6, 1.0, fade_tag == 0u);
if (m.model_flags.y > 0.5 && base_color.a < VANILLA_ALPHA_KEY) {
discard;
}
@@ -447,7 +541,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
// binary's `0x69e770`; statics leave it 0), 0.5..0.85 = fixed intensity 1.0 (an exterior WMO
// MODD prop — the 2.5 site is one a MODD prop never reaches, §8b), <0.5 = statically
// MCSH-shadowed (0.5).
let inst_shade = select(f32((fade_tag >> 16u) & 0xffu) / 255.0, 0.0, interior_prop);
let inst_shade = select(f32((fade_tag >> 6u) & 0xffu) / 255.0, 0.0, interior_prop);
let mat_shade = select(0.0, 1.0, m.sun_scale.x < 0.5);
let shade_t = max(mat_shade, inst_shade);
let mid_band = m.sun_scale.x >= 0.5 && m.sun_scale.x < 0.85;
@@ -519,7 +613,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp
// slot. Evaluated here per fragment over the same basis — note the SH lobe's soft wrap (side-on
// ≈ 0.088·C) is the reference's authored response, deliberately NOT a hard max(N·L,0).
// Units/GameObjects never reach this lane (base CGLight — plain day/night ×1.0, §8/§9).
let probe = 7u * ((fade_tag >> 16u) & 0x3fffu);
let probe = 7u * ((fade_tag >> 6u) & 0x1fffu);
let lit_m2_interior = clamp(
vec3<f32>(
dot(wow_light.prop_probes[probe + 0u], n1)
+14 -1
View File
@@ -225,6 +225,12 @@ pub struct BillboardJointRig {
}
impl BillboardJointRig {
/// The host root — the collapsed-rig world pass's do-not-enter set reads it (a nested rig
/// with its own billboard output owns its interior, whichever lane the outer rig is on).
pub(crate) fn root(&self) -> Entity {
self.root
}
/// Build for a spawned rig — `None` when the skeleton authors no billboard bone and no
/// ignore-parent-rotation bone (the common case: ordinary rigs cost nothing). `root` is the
/// host entity the joints hang under (the model-space frame).
@@ -481,7 +487,14 @@ impl Plugin for BillboardPlugin {
)
.add_systems(
PostUpdate,
(billboard_joint_palette, face_billboards)
(
billboard_joint_palette,
// The collapsed-rig world pass (decision 0724): palette rows + replaced-subtree
// anchor re-seats, between the entity lane's joint rewrite and the card facing
// (cards following a unit's billboard-bone anchor read the replaced frame).
crate::creature_anim::finalize_rig_worlds,
face_billboards,
)
.chain()
.in_set(BillboardPlace),
);
+1 -1
View File
@@ -57,7 +57,7 @@ fn draw_bowstrings(
.filter(|(_, latched)| *latched)
.and_then(|(bones, _)| {
let &(bone, offset) = bones.points.get(&HAND_ARROW)?;
let joint = *bones.joints.get(bone as usize)?;
let joint = bones.anchor(bone)?;
Some(joints.get(joint).ok()?.transform_point(offset))
})
.unwrap_or_else(|| (top + bottom) / 2.0);
+184 -10
View File
@@ -11,6 +11,13 @@
//! Captures contain Blizzard-derived imagery (rendered terrain/models), so they live under the
//! gitignored `target/visual/` and are never committed.
//!
//! ## Why the clock is frozen (decision 0723)
//! "Deterministic" is the whole claim: a golden diff is evidence only if two runs of one unchanged
//! build agree. Pinning the camera, the game clock and the clutter seed is not enough, because the
//! sims integrate in *seconds* while the settle counts *frames* — see [`CAPTURE_FRAME_DT`] and
//! [`hold_clock`], which together make a capture a pure function of the build. `scripts/visual.sh
//! selfcheck` is the tripwire that keeps it that way.
//!
//! ## Running one capture by hand
//! **Run through Cargo — never the built binary directly:**
//! ```text
@@ -28,9 +35,11 @@
//! prints the scenario names. `scripts/visual.sh` wraps all of this.
use std::path::Path;
use std::time::Duration;
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Capturing, Screenshot};
use bevy::time::TimeUpdateStrategy;
use benilla_assets::coords::wow_to_bevy;
@@ -192,15 +201,59 @@ pub(crate) struct CaptureMode;
/// alone is not enough: tile *entities* exist well before their pipelines are warm.)
const SETTLE_FRAMES: u32 = 150;
/// The effective settle window: `$WOW_CAPTURE_SETTLE` overrides [`SETTLE_FRAMES`] for scenes that
/// need longer to reach steady state before the shot (e.g. snow — flakes sink at 2-6.5 yd/s from
/// ~22 yd up, so the precipitation column takes ~600 frames to fill).
/// The effective settle window: `$WOW_CAPTURE_SETTLE` overrides [`SETTLE_FRAMES`] for scenes whose
/// assets take longer to arrive. This buys **real** frames (loading, pipeline warm-up, spawning) —
/// for *sim* time, which is what a precipitation column or a particle pool needs, raise
/// [`age_frames`] instead; the two were the same knob until decision 0723 split them.
fn settle_frames() -> u32 {
std::env::var("WOW_CAPTURE_SETTLE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(SETTLE_FRAMES)
}
/// Steps of [`CAPTURE_FRAME_DT`] the sims run, clock released, once the scene is built and
/// quiescent — the shot's *sim age*. 150 = 2.5 s: past the 2 s spawn appear-fade
/// (`model_fade::APPEAR_FADE_SECS`) and long past a flame pool's particle lifetime, so the scene is
/// at steady state and not mid-transient.
///
/// Because it is counted in frozen frames it is a **duration**, identically on any machine — which
/// is the whole point. `$WOW_CAPTURE_AGE` overrides it for a scene that needs longer to fill (snow:
/// flakes sink at 2-6.5 yd/s from ~22 yd up, so the column wants ~11 s ≈ 660).
const AGE_FRAMES: u32 = 150;
/// The effective aging window — see [`AGE_FRAMES`].
fn age_frames() -> u32 {
std::env::var("WOW_CAPTURE_AGE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(AGE_FRAMES)
}
/// Consecutive frames the world entity count must hold before [`Phase::Settling`] calls the scene
/// quiescent. Residency is not enough on its own: it counts terrain *tiles*, while a tile's doodads
/// and WMOs only get *registered* there and spawn later still, as their M2s finish loading under
/// `terrain_stream`'s wall-clock `SPAWN_BUDGET`. Every one of those carries the torches and braziers
/// whose flames this is all about, so a capture that starts its clock at residency still ages each
/// flame from whichever frame its model happened to land on. Waiting for the count to stop moving
/// is the generic form of "everything that was going to spawn has spawned".
const QUIESCE_FRAMES: u32 = 30;
/// The frozen capture clock's frame step: every capture frame advances the game clock by exactly
/// this, whatever the frame really cost. Anything integrated in *seconds* — particle pools, ribbon
/// trails, animation, weather, fades — therefore reaches the shutter in the same state every run.
///
/// Without it the harness is only **frame**-pinned: [`settle_frames`] counts frames while the sims
/// advance on `time.delta_secs()`, i.e. real wall-clock, so two runs spend different real time per
/// frame (streaming, pipeline warm-up, whatever else the machine is doing) and photograph the flames
/// in different places. Measured on this tree before the change — the golden six captured twice from
/// one **unchanged** build: MAE up to 0.009 with max pixel deltas of 180, the same band a real render
/// change lands in. That noise floor is why decision 0721 could not read its own flame pixels, and
/// why 0719 read signal out of some that were partly run-to-run churn.
///
/// 1/60 s, so every settle window keeps the duration its comment claims (150 frames ≈ 2.5 s) instead
/// of buying however much sim time the machine happened to grant.
const CAPTURE_FRAME_DT: Duration = Duration::from_nanos(16_666_667);
/// Hard cap on how long to wait for the screenshot save to land, so a capture never hangs the harness.
const SAVE_TIMEOUT_FRAMES: u32 = 120;
/// A couple of grace frames after the save completes before exiting, so the file is flushed.
@@ -214,8 +267,12 @@ const PROBE_WARMUP_FRAMES: u32 = 60;
enum Phase {
/// Waiting for terrain streaming to report the scene fully resident.
Streaming,
/// Resident; counting settle frames before the shot.
/// Resident; counting settle frames — and waiting for the scene to stop growing — before the
/// shot. The game clock is held here (see [`hold_clock`]).
Settling(u32),
/// Scene built and quiescent, clock released: running exactly [`age_frames`] steps of
/// [`CAPTURE_FRAME_DT`] so every second-driven thing is at a known, machine-independent age.
Aging(u32),
/// fxview only: scene settled, fixture armed; waiting for the effect to attach and run its
/// requested age before the shot.
FxAging,
@@ -240,6 +297,16 @@ struct CaptureCtx {
/// frame-time stats + scene counts and exit. The repeatable perf instrument: same scenario, same
/// settle, numbers instead of pixels. 0 = normal capture.
probe_frames: u32,
/// The frozen clock is in force ([`CAPTURE_FRAME_DT`]) — always, except under a perf probe,
/// whose entire measurement *is* the real frame cost.
frozen_clock: bool,
/// World entity count at the previous frame, and how many consecutive frames it has held —
/// the scene-quiescence test [`Phase::Settling`] waits on. See [`QUIESCE_FRAMES`].
last_entities: usize,
stable_frames: u32,
/// Frames spent in [`Phase::Aging`] including restarts — the backstop that shoots anyway if a
/// scene never goes quiescent, so the harness cannot hang on one.
aging_total: u32,
/// Probe samples (frame ms).
probe_samples: Vec<f32>,
/// Process CPU seconds at the first sampled frame — the window baseline for the probe line's
@@ -431,16 +498,29 @@ impl Plugin for CapturePlugin {
std::process::exit(2);
}
}
let probe_frames = std::env::var("WOW_FPS_PROBE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
// The frozen capture clock ([`CAPTURE_FRAME_DT`]) — never under a perf probe, which reads
// `time.delta_secs()` AS its measurement and would report a flawless constant 16.67 ms for
// any scene, however slow.
let frozen_clock = probe_frames == 0;
if frozen_clock {
app.insert_resource(TimeUpdateStrategy::ManualDuration(CAPTURE_FRAME_DT))
.add_systems(Startup, hold_clock);
}
app.insert_resource(CaptureMode)
.insert_resource(CaptureCtx {
scenario,
out,
phase: Phase::Streaming,
ui_seeded: false,
probe_frames: std::env::var("WOW_FPS_PROBE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0),
probe_frames,
frozen_clock,
last_entities: 0,
stable_frames: 0,
aging_total: 0,
probe_samples: Vec::new(),
probe_cpu_start: None,
})
@@ -519,6 +599,19 @@ fn pin_scene(
}
}
/// Hold the game clock at zero until the scene is resident; [`drive_capture`] releases it.
///
/// The fixed frame step alone is not enough, because it only makes each frame the same *size* — a
/// torch flame still ages from the frame its tile happened to spawn on, and tiles spawn under a
/// wall-clock budget (`terrain_stream`'s `SPAWN_BUDGET`) against asset I/O that finishes on a
/// different frame every run. Held through streaming, every emitter in the scene is zero-age at
/// residency, so at the shutter each has run exactly `settle_frames()` steps no matter when it
/// appeared. Frame-driven work (streaming itself, the spawn budget, the screenshot save) is on the
/// real clock and runs on regardless.
fn hold_clock(mut clock: ResMut<Time<Virtual>>) {
clock.pause();
}
/// Drive the capture lifecycle: wait for streaming, settle, screenshot, exit.
#[allow(clippy::too_many_arguments)]
fn drive_capture(
@@ -536,6 +629,7 @@ fn drive_capture(
wfx_req: Option<Res<crate::water_fx::WaterFxView>>,
mut fx_state: Option<ResMut<FxViewState>>,
game_time: Res<Time>,
mut clock: ResMut<Time<Virtual>>,
) {
let resident =
progress.total > 0 && progress.ready == progress.total && progress.focus_resident;
@@ -545,6 +639,14 @@ fn drive_capture(
.as_ref()
.map(|r| r.age)
.or(wfx_req.as_ref().map(|r| r.age));
// Scene quiescence: how long the world entity count has held still (see [`QUIESCE_FRAMES`]).
let live = entities.iter().len();
ctx.stable_frames = if live == ctx.last_entities {
ctx.stable_frames + 1
} else {
0
};
ctx.last_entities = live;
ctx.phase = match ctx.phase {
Phase::Streaming => {
if resident {
@@ -576,14 +678,26 @@ fn drive_capture(
}
}
Phase::Settling(n) => {
// Settled = the window elapsed AND the scene stopped growing. The quiescence half has
// a hard cap so a scene that never stops churning (a UI fixture with a live animation)
// still shoots rather than hanging — it just forfeits the guarantee, loudly.
let capped = n + 1 >= settle_frames() * 3;
if capped && ctx.stable_frames < QUIESCE_FRAMES {
warn!(
"capture: scene never went quiescent ({} frames); the shot is not reproducible",
n + 1
);
}
let settled =
n + 1 >= settle_frames() && (ctx.stable_frames >= QUIESCE_FRAMES || capped);
if !resident {
Phase::Streaming // a late tile dropped out of residency — wait for it again
} else if n + 1 >= settle_frames() && fixture_age.is_some() {
} else if settled && fixture_age.is_some() {
if let Some(state) = fx_state.as_deref_mut() {
state.armed = true; // scene ready — the fixture spawns now, age clock clean
}
Phase::FxAging
} else if n + 1 >= settle_frames() {
} else if settled {
if ctx.probe_frames > 0 {
// Probe: uncap presentation so we measure true frame cost, not the vsync
// ceiling. `$WOW_PROBE_VSYNC=1` keeps vsync ON instead — the probe then
@@ -600,6 +714,11 @@ fn drive_capture(
if keep_vsync { "KEPT ON" } else { "off" }
);
Phase::ProbeWarmup(0)
} else if ctx.frozen_clock {
// Clock released here: the sims now run exactly `age_frames()` fixed steps, so
// the shot's sim age is the same on any machine (decision 0723).
info!("capture: scene quiescent, aging {} frames", age_frames());
Phase::Aging(0)
} else {
commands
.spawn(Screenshot::primary_window())
@@ -614,6 +733,37 @@ fn drive_capture(
Phase::Settling(n + 1)
}
}
Phase::Aging(n) => {
// A straggler landed (a model whose M2 finished loading after the scene looked
// quiescent). The clock re-holds below and the window restarts, so that emitter and
// every older one still reach the shutter having run exactly `age_frames()` steps.
ctx.aging_total += 1;
let give_up = ctx.aging_total >= age_frames() * 4;
if give_up && ctx.stable_frames < QUIESCE_FRAMES {
warn!(
"capture: scene still churning after {} aging frames; the shot is not \
reproducible",
ctx.aging_total
);
}
if ctx.stable_frames < QUIESCE_FRAMES && !give_up {
if n > 0 {
info!("capture: scene moved during aging at frame {n}; restarting the window");
}
Phase::Aging(0)
} else if n + 1 >= age_frames() {
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(ctx.out.clone()));
info!("capture: scene aged, writing {}", ctx.out);
Phase::Saving {
frames: 0,
seen: false,
}
} else {
Phase::Aging(n + 1)
}
}
Phase::ProbeWarmup(n) => {
if n + 1 >= PROBE_WARMUP_FRAMES {
ctx.probe_cpu_start = crate::perf::process_cpu_secs();
@@ -708,4 +858,28 @@ fn drive_capture(
}
}
};
// Release (or re-hold) the frozen clock with the phase: held while the scene is still being
// built (see [`hold_clock`]), running from the moment it is quiescent. Re-held if a late tile
// drops the scene back out of residency, so the restarted settle is the same settle.
if ctx.frozen_clock {
// The invariant, in one line: **the clock runs only while the world is quiescent.** Held
// while the scene is still being built, and re-held the moment anything spawns, so no
// effect can ever age from the frame its model happened to arrive on.
let held = match ctx.phase {
Phase::Streaming | Phase::Settling(_) => true,
Phase::Aging(_) => ctx.stable_frames < QUIESCE_FRAMES,
// Shutter open — hold the sims still. The screenshot is *requested* on one frame but
// the render world may serve it a frame or two later (pipelined rendering), and with
// the clock still running that is a one-step difference in every particle pool: the
// static scene is identical and the flames are not. Frozen here, it no longer matters
// which frame is grabbed.
Phase::Saving { .. } | Phase::Done(_) => true,
_ => false,
};
match (held, clock.is_paused()) {
(false, true) => clock.unpause(),
(true, false) => clock.pause(),
_ => {}
}
}
}
+14 -1
View File
@@ -463,6 +463,10 @@ fn drive_live_fps(
// fail (a bad map id, a refused command) leaving the run measuring the login spot.
map: Option<Res<crate::world_map::CurrentMap>>,
body: Option<Res<crate::player::Player>>,
// The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the
// probe line proves the palette lane is actually populated (an all-zero table renders
// origin-collapsed rigs, which no other probe number would catch).
palettes: Option<Res<crate::rig_palette::RigPalettes>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut exit: MessageWriter<AppExit>,
) {
@@ -545,8 +549,17 @@ fn drive_live_fps(
}
_ => String::new(),
};
let rigs = palettes
.map(|p| {
let (s, b, ps, pb) = p.occupancy();
format!(
" rigs={s}/{ps} rig_bones={b}/{pb} rig_computed={}",
p.computed_rigs()
)
})
.unwrap_or_default();
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={} px={}x{}{cpu}{present}{at_pin}",
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs} px={}x{}{cpu}{present}{at_pin}",
v.len(),
at(0.50),
at(0.95),
+7 -1
View File
@@ -67,7 +67,12 @@ pub(crate) use global_seq::GlobalSeqDrive;
mod lod;
pub(crate) use lod::AnimParked;
mod pose;
pub(crate) use pose::PosedRig;
pub(crate) use pose::{RigAnchor, RigFrame, RigPose};
/// The collapsed rig's composition passes (decision 0724): the pre-propagation model compose +
/// anchor re-seat, and the post-propagation world pass (palette rows + billboard replacements).
mod compose;
pub(crate) use compose::{finalize_rig_worlds, PosePost};
/// The unit's wielded weapon classes — `(item class, item subclass)` per hand, `None` for an empty
/// (or non-item) hand. Written by the held-item resolution ([`crate::entities`], decision 0072) from
@@ -665,6 +670,7 @@ impl Plugin for CreatureAnimPlugin {
global_seq::plugin(app);
lod::plugin(app);
pose::plugin(app);
compose::plugin(app);
app.add_message::<AnimSoundEvent>()
.add_message::<SwingMessage>()
.add_message::<SwingImpact>()
+383
View File
@@ -0,0 +1,383 @@
//! The collapsed rig's two composition passes (decision 0724) — what transform propagation and
//! the billboard joint pass used to do through ~59 k bone entities, done on the [`RigPose`]
//! arrays instead.
//!
//! **Model pass** ([`compose_rig_models`], pre-propagation): a pose-dirty rig forward-folds its
//! `locals` into model-space affines and re-seats its consumer **anchors** (children of the rig's
//! `joints_root`) from them — so ordinary propagation carries every consumer subtree (held items,
//! nested effect rigs, the mount seat) at this frame's pose, exactly as the joint hierarchy did.
//! Runs after [`PosePost`] (body twist, global sequences — the writers that follow the evaluator).
//!
//! **World pass** ([`finalize_rig_worlds`], post-propagation, inside
//! [`crate::billboard::BillboardPlace`]): per rig needing it — pose-dirty, `joints_root` moved,
//! or camera-faced — compose the world chain `world_from_model × local…`, apply the byte-law
//! bone replacements (`billboard_joint_palette`'s math verbatim: billboard kinds take the camera
//! basis, bone flag `0x04` resets to the holder's rotation, descendants chain onto the replaced
//! frames), write the palette rows (`world × inverse_bindpose`, decision 0720), and re-seat the
//! anchors sitting on replaced subtrees — including the same rigid-child re-walk and the same
//! nested-rig do-not-enter rule as the entity pass. A re-seated subtree that carries another
//! rig's model frame (the mounted rider's seat anchor, [`RigFrame`]) cascades: that rig
//! re-finalizes in the same pass, so its palette never lags its seat.
//!
//! A parked, stationary rig runs neither pass and uploads zero bytes (decision 0448's park
//! observables carry over unchanged).
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
use bevy::prelude::*;
use crate::billboard::{billboard_basis, BillboardJointRig};
use crate::player::WorldCamera;
use crate::rig_palette::{RigPalettes, RigSkin};
use super::{AnimParked, RigFrame, RigPose};
/// The pose post-pass window: every writer of [`RigPose`] locals that runs after the evaluator
/// (the body twist, the global-sequence channels) is a member; the model compose runs after the
/// whole set. Configured after [`bevy::app::AnimationSystems`], before transform propagation.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct PosePost;
/// Pre-propagation: fold each pose-dirty rig's locals into model-space affines and re-seat its
/// anchors' local `Transform`s, so this frame's propagation places every consumer subtree at
/// this frame's pose. `pose_dirty` stays raised — the world pass consumes and clears it.
fn compose_rig_models(mut rigs: Query<&mut RigPose>, mut anchors: Query<&mut Transform>) {
for rig in &mut rigs {
if !rig.pose_dirty {
continue;
}
let rig = rig.into_inner();
rig.compose();
for &(bone, anchor) in &rig.anchors {
let Some(m) = rig.model.get(bone as usize) else {
continue;
};
let Ok(mut t) = anchors.get_mut(anchor) else {
continue;
};
let (scale, rotation, translation) = m.to_scale_rotation_translation();
*t = Transform {
translation,
rotation,
scale,
};
}
}
}
/// One rig's world chain + which bones sit in a replaced subtree. `root_g` is the rig's
/// `joints_root` propagated world, `root_rot` the HOLDER's world rotation (the frame a bone-flag
/// `0x04` joint snaps to — the entity pass read `BillboardJointRig::root`, which spawn sites fed
/// the holder), `cam` the camera basis (`None` = no camera, replacements limited to `0x04`).
/// The math mirrors `billboard_joint_palette` operation for operation — `mul_transform`, then
/// decompose/replace/recompose — so the collapsed lane is bit-compatible with the entity lane.
fn rig_worlds(
rig: &RigPose,
root_g: GlobalTransform,
root_rot: Quat,
cam: Option<(Vec3, Vec3, Vec3)>,
) -> (Vec<GlobalTransform>, Vec<bool>) {
let n = rig.locals.len();
let mut worlds = Vec::with_capacity(n);
let mut touched = vec![false; n];
for i in 0..n {
let parent = usize::try_from(rig.parents[i]).ok().filter(|&p| p < i);
let mut g = match parent {
Some(p) => worlds[p],
None => root_g,
}
.mul_transform(rig.locals[i]);
if rig.ignore_rot[i] {
let (scale, _, translation) = g.to_scale_rotation_translation();
g = GlobalTransform::from(Transform {
translation,
rotation: root_rot,
scale,
});
touched[i] = true;
} else if let (Some(kind), Some((fwd, right, up))) = (rig.kinds[i], cam) {
let (scale, rot, translation) = g.to_scale_rotation_translation();
g = GlobalTransform::from(Transform {
translation,
rotation: billboard_basis(kind, rot, fwd, right, up),
scale,
});
touched[i] = true;
} else if let Some(p) = parent {
touched[i] = touched[p];
}
worlds.push(g);
}
(worlds, touched)
}
/// Post-propagation (inside `BillboardPlace`, chained after the entity lane's
/// `billboard_joint_palette` and before `face_billboards`): finalize every rig that needs it —
/// palette rows + replaced-subtree anchor re-seats, with the seat-frame cascade.
#[allow(clippy::type_complexity, clippy::too_many_arguments)] // billboard_joint_palette's shape
pub(crate) fn finalize_rig_worlds(
cam: Query<&GlobalTransform, With<WorldCamera>>,
mut rigs: Query<(Entity, &mut RigPose, &RigSkin, Has<AnimParked>)>,
// B0001: the `Changed` filter reads `GlobalTransform` ticks, which conflicts with the
// mutable frame query — a `ParamSet` sequences them (the refresh set is collected first).
mut worlds_params: ParamSet<(
Query<(), Changed<GlobalTransform>>,
Query<(&Transform, &mut GlobalTransform), Without<WorldCamera>>,
)>,
children: Query<&Children>,
hosts: Query<&BillboardJointRig>,
frames: Query<&RigFrame>,
ibps: Res<Assets<SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<RigPalettes>,
) {
let cam_basis = cam
.single()
.ok()
.map(|t| (*t.forward(), *t.right(), *t.up()));
// Which rigs refresh this frame: pose-dirty, model frame moved, or camera-faced live.
let refresh: Vec<Entity> = {
let roots_changed = worlds_params.p0();
rigs.iter()
.filter(|(_, rig, _, parked)| {
rig.pose_dirty
|| roots_changed.contains(rig.joints_root)
|| (rig.has_billboard && !parked)
})
.map(|(holder, ..)| holder)
.collect()
};
if refresh.is_empty() {
return;
}
let mut globals = worlds_params.p1();
// The rigid-child re-walk's do-not-enter set, exactly the entity pass's: a nested rig with
// its own billboard output owns its interior (and its root keeps the propagated frame).
let fx_roots: bevy::platform::collections::HashSet<Entity> =
hosts.iter().map(|r| r.root()).collect();
// Model frames re-seated by a patch walk → the rigs riding them re-finalize below.
let mut cascade: Vec<Entity> = Vec::new();
let mut finalize =
|rig: &mut RigPose,
holder: Entity,
skin: &RigSkin,
globals: &mut Query<(&Transform, &mut GlobalTransform), Without<WorldCamera>>,
cascade: &mut Vec<Entity>| {
let Ok(root_g) = globals.get(rig.joints_root).map(|(_, g)| *g) else {
return;
};
let root_rot = globals
.get(holder)
.map(|(_, g)| g.rotation())
.unwrap_or_default();
let (worlds, touched) = rig_worlds(rig, root_g, root_rot, cam_basis);
if let Some(ibp) = ibps.get(&skin.ibp) {
palettes.write_rig_worlds(skin, &worlds, ibp);
}
if !rig.has_special {
return;
}
// Anchors in replaced subtrees: re-seat their globals and re-compose their rigid
// children from the replaced frames — the entity pass's child walk, anchor-rooted.
let mut stack: Vec<(Entity, GlobalTransform)> = Vec::new();
for &(bone, anchor) in &rig.anchors {
let b = bone as usize;
if !touched.get(b).copied().unwrap_or(false) {
continue;
}
if let Ok((_, mut g)) = globals.get_mut(anchor) {
*g = worlds[b];
}
if let Ok(cs) = children.get(anchor) {
stack.extend(
cs.iter()
.filter(|c| !fx_roots.contains(c))
.map(|c| (c, worlds[b])),
);
}
}
while let Some((e, parent_g)) = stack.pop() {
if let Ok(rf) = frames.get(e) {
cascade.push(rf.0);
}
let Ok((local, mut global)) = globals.get_mut(e) else {
continue;
};
let g = parent_g.mul_transform(*local);
*global = g;
if let Ok(cs) = children.get(e) {
stack.extend(cs.iter().filter(|c| !fx_roots.contains(c)).map(|c| (c, g)));
}
}
};
for &holder in &refresh {
let Ok((_, rig, skin, _)) = rigs.get_mut(holder) else {
continue;
};
let rig = rig.into_inner();
finalize(rig, holder, skin, &mut globals, &mut cascade);
rig.pose_dirty = false;
}
// The cascade: a patch walk above moved some rig's model frame after it (or before it) ran —
// re-finalize against the fresh seat. One level deep by construction (a seat anchor's
// subtree holds no further seat anchors).
if !cascade.is_empty() {
for (holder, rig, skin, _) in &mut rigs {
if !cascade.contains(&holder) {
continue;
}
let rig = rig.into_inner();
let mut ignore = Vec::new();
finalize(rig, holder, skin, &mut globals, &mut ignore);
rig.pose_dirty = false;
}
}
}
/// Register the model pass; the world pass is chained by [`crate::billboard::BillboardPlugin`]
/// between the entity lane's joint pass and the card facing, where its readers sit.
pub(super) fn plugin(app: &mut App) {
app.configure_sets(
PostUpdate,
PosePost
.after(bevy::app::AnimationSystems)
.before(bevy::transform::TransformSystems::Propagate),
)
.add_systems(
PostUpdate,
compose_rig_models
.after(PosePost)
.before(bevy::transform::TransformSystems::Propagate),
);
}
#[cfg(test)]
mod tests {
use benilla_assets::{ModelJoint, ModelSkeleton};
use benilla_formats::BillboardKind;
use super::*;
fn skeleton(joints: Vec<ModelJoint>) -> ModelSkeleton {
ModelSkeleton {
joints,
spine_bone: None,
head_bone: None,
}
}
fn joint(parent: i16, t: Vec3) -> ModelJoint {
ModelJoint {
parent,
local_translation: t,
billboard: None,
ignore_parent_rotation: false,
}
}
/// The model compose is the exact affine product entity propagation computed: a three-bone
/// chain with rotation + non-uniform scale folds to the same matrices as chained
/// `GlobalTransform::mul_transform`.
#[test]
fn compose_matches_entity_propagation() {
let sk = skeleton(vec![
joint(-1, Vec3::new(1.0, 2.0, 3.0)),
joint(0, Vec3::Y),
joint(1, Vec3::X),
]);
let mut rig = RigPose::new(Entity::PLACEHOLDER, &sk);
rig.locals[0].rotation = Quat::from_rotation_y(0.7);
rig.locals[1].scale = Vec3::new(2.0, 1.0, 0.5);
rig.locals[1].rotation = Quat::from_rotation_x(-0.3);
rig.compose();
// The oracle: GlobalTransform chaining from an identity root, exactly what propagation
// did through the joint entities. Compared as matrices — decomposing two identical
// affines can NaN an `angle_between` on a dot marginally past 1.
let mut g = GlobalTransform::IDENTITY;
for i in 0..3 {
g = g.mul_transform(rig.locals[i]);
let oracle = Mat4::from(g.affine());
let ours = Mat4::from(rig.model[i]);
assert!(
oracle.abs_diff_eq(ours, 1e-5),
"bone {i}: {oracle} vs {ours}"
);
}
}
/// The world pass reproduces `billboard_joint_palette`'s replacements: a lock-Z billboard
/// bone takes the camera basis (pivot/scale kept), its child chains onto the replaced frame,
/// and a bone-flag-0x04 helper resets to the holder's rotation while its pivot rides the
/// animated parent — the entity pass's three verified behaviours, computed from the arrays.
#[test]
fn world_pass_matches_the_entity_billboard_law() {
let sk = skeleton(vec![
ModelJoint {
parent: -1,
local_translation: Vec3::new(5.0, 1.0, 0.0),
billboard: Some(BillboardKind::LockZ),
ignore_parent_rotation: false,
},
joint(0, Vec3::Y),
ModelJoint {
parent: 1,
local_translation: Vec3::Y,
billboard: None,
ignore_parent_rotation: true,
},
]);
let mut rig = RigPose::new(Entity::PLACEHOLDER, &sk);
rig.locals[0].scale = Vec3::splat(2.0);
rig.locals[1].scale = Vec3::splat(0.5);
rig.locals[1].rotation = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2);
rig.compose();
let root_rot = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
// The identity camera frame: looking down −Z, up Y, right X.
let cam = (-Vec3::Z, Vec3::X, Vec3::Y);
let (worlds, touched) = rig_worlds(
&rig,
GlobalTransform::from(Transform::from_rotation(root_rot)),
root_rot,
Some(cam),
);
assert_eq!(touched, vec![true; 3], "the whole chain is replaced");
// Bone 0: pivot at the root-rotated authored spot, scale kept, lock-Z basis — upright
// kept axis, local −Z toward the viewer (the billboard.rs test's assertions).
let (s0, r0, t0) = worlds[0].to_scale_rotation_translation();
assert!((t0 - root_rot * Vec3::new(5.0, 1.0, 0.0)).length() < 1e-4);
assert!((s0 - Vec3::splat(2.0)).length() < 1e-5, "scale preserved");
assert!((r0 * Vec3::Y).dot(Vec3::Y) > 0.999, "kept axis upright");
assert!((r0 * -Vec3::Z).dot(Vec3::Z) > 0.999, "faces the camera");
// Bone 1 chains onto the REPLACED parent: one parent-scaled unit up the new Y.
let (s1, _, t1) = worlds[1].to_scale_rotation_translation();
assert!((t1 - (t0 + r0 * (Vec3::Y * 2.0))).length() < 1e-4, "{t1}");
assert!((s1 - Vec3::ONE).length() < 1e-5, "2 × 0.5 scale chain");
// Bone 2 (flag 0x04): pivot carried by the animated parent's frame, rotation snapped to
// the holder's.
let (_, r2, t2) = worlds[2].to_scale_rotation_translation();
let expect_t = worlds[1].transform_point(Vec3::Y);
assert!((t2 - expect_t).length() < 1e-4, "pivot rides the parent");
assert!(
r2.angle_between(root_rot) < 1e-3,
"rotation resets to the holder"
);
}
/// An unreplaced chain has no touched bones and composes root × model verbatim — the common
/// rig costs the palette rows and nothing else.
#[test]
fn plain_rigs_touch_nothing() {
let sk = skeleton(vec![joint(-1, Vec3::X), joint(0, Vec3::Y)]);
let mut rig = RigPose::new(Entity::PLACEHOLDER, &sk);
rig.locals[0].rotation = Quat::from_rotation_z(0.4);
rig.compose();
let root = GlobalTransform::from_translation(Vec3::new(0.0, 0.0, 7.0));
let (worlds, touched) = rig_worlds(&rig, root, Quat::IDENTITY, None);
assert_eq!(touched, vec![false; 2]);
for (i, w) in worlds.iter().enumerate() {
let expect = GlobalTransform::from(root.affine() * rig.model[i]);
assert!(
(w.translation() - expect.translation()).length() < 1e-5,
"bone {i}"
);
}
}
}
+61 -27
View File
@@ -11,19 +11,24 @@
//! `0` (lid retracted, eye open) for ~96% of the loop and `1` (lid full, eye shut) for ~100 ms — the
//! blink. Without this pass the eyelid sits at its default identity scale (full size) forever: eyes shut.
use bevy::app::AnimationSystems;
use bevy::prelude::*;
use bevy::transform::TransformSystems;
use benilla_assets::GlobalBone;
/// Per-instance driver for a model's global-sequence bone channels: each channel's joint entity paired
/// with its baked channels, plus the free-running clock they wrap on. Attached beside the
/// One channel's write target: a live joint entity (the doodad/effect/booth lane) or a bone index
/// into the host's [`super::RigPose`] locals (the collapsed unit lane, decision 0724).
enum SeqTarget {
Joint(Entity),
Bone(u16),
}
/// Per-instance driver for a model's global-sequence bone channels: each channel's write target
/// paired with its baked channels, plus the free-running clock they wrap on. Attached beside the
/// [`AnimationPlayer`] on a skinned instance whose model carries any global-sequence track.
#[derive(Component)]
pub(crate) struct GlobalSeqDrive {
/// `(joint entity, its baked global-sequence channels)`.
bones: Vec<(Entity, GlobalBone)>,
/// `(write target, its baked global-sequence channels)`.
bones: Vec<(SeqTarget, GlobalBone)>,
/// Seconds since spawn — the model clock each channel wraps (`t mod period`).
elapsed: f32,
/// Paused: skip sampling entirely (the doodad host gates animation to drawn instances — wow-re
@@ -39,7 +44,26 @@ impl GlobalSeqDrive {
pub(crate) fn new(global_bones: &[GlobalBone], joints: &[Entity]) -> Option<Self> {
let bones: Vec<_> = global_bones
.iter()
.filter_map(|g| joints.get(g.bone as usize).map(|&e| (e, g.clone())))
.filter_map(|g| {
joints
.get(g.bone as usize)
.map(|&e| (SeqTarget::Joint(e), g.clone()))
})
.collect();
(!bones.is_empty()).then_some(Self {
bones,
elapsed: 0.0,
paused: false,
})
}
/// The collapsed-rig lane (decision 0724): channels write the host's [`super::RigPose`]
/// locals by bone index — no joint entities exist. Same `None` gate as [`Self::new`].
pub(crate) fn new_rig(global_bones: &[GlobalBone], nbones: usize) -> Option<Self> {
let bones: Vec<_> = global_bones
.iter()
.filter(|g| (g.bone as usize) < nbones)
.map(|g| (SeqTarget::Bone(g.bone), g.clone()))
.collect();
(!bones.is_empty()).then_some(Self {
bones,
@@ -61,22 +85,23 @@ impl GlobalSeqDrive {
}
}
/// Sample each instance's global-sequence channels on its own clock and write the driven joint
/// components — after [`AnimationSystems`] posed the skeleton, before [`TransformSystems::Propagate`]
/// (the same window as the body twist). A channel overwrites only its own component; a bone the playing
/// animation never keyed (the eyelid) keeps its rest translation/rotation and takes only the global
/// scale, so the eye opens and blinks over whatever gait is playing.
/// Sample each instance's global-sequence channels on its own clock and write the driven bone —
/// in the pose post-pass window ([`super::PosePost`], the same as the body twist), so the model
/// compose folds it. A channel overwrites only its own component; a bone the playing animation
/// never keyed (the eyelid) keeps its rest translation/rotation and takes only the global scale,
/// so the eye opens and blinks over whatever gait is playing.
fn apply_global_sequences(
time: Res<Time>,
mut drives: Query<(&mut GlobalSeqDrive, Has<super::AnimParked>)>,
mut drives: Query<(Entity, &mut GlobalSeqDrive, Has<super::AnimParked>)>,
mut joints: Query<&mut Transform>,
mut rigs: Query<&mut super::RigPose>,
) {
let dt = time.delta_secs();
for (mut drive, parked) in &mut drives {
for (host, mut drive, parked) in &mut drives {
if drive.paused {
continue;
}
// A parked unit's channel clock keeps running — only the joint writes stop (decision
// A parked unit's channel clock keeps running — only the bone writes stop (decision
// 0448: units are absolute-clock, never freeze-and-resume; the doodad lane pauses via
// [`GlobalSeqDrive::set_paused`] + [`GlobalSeqDrive::sync`] instead and never parks).
drive.elapsed += dt;
@@ -84,9 +109,23 @@ fn apply_global_sequences(
continue;
}
let t = drive.elapsed;
for (joint, bone) in &drive.bones {
let Ok(mut tf) = joints.get_mut(*joint) else {
continue;
let mut rig = rigs.get_mut(host).ok();
for (target, bone) in &drive.bones {
let tf: &mut Transform = match target {
SeqTarget::Joint(joint) => {
let Ok(tf) = joints.get_mut(*joint) else {
continue;
};
tf.into_inner()
}
SeqTarget::Bone(b) => {
let Some(rig) = rig.as_mut() else { continue };
rig.pose_dirty = true;
let Some(tf) = rig.locals.get_mut(*b as usize) else {
continue;
};
tf
}
};
if let Some(c) = &bone.translation {
tf.translation = c.sample(t);
@@ -101,14 +140,9 @@ fn apply_global_sequences(
}
}
/// Register [`apply_global_sequences`] in the post-animation window (mirrors the body-twist plugin).
/// Register [`apply_global_sequences`] in the pose post-pass window (beside the body twist).
pub(super) fn plugin(app: &mut App) {
app.add_systems(
PostUpdate,
apply_global_sequences
.after(AnimationSystems)
.before(TransformSystems::Propagate),
);
app.add_systems(PostUpdate, apply_global_sequences.in_set(super::PosePost));
}
#[cfg(test)]
@@ -147,13 +181,13 @@ mod tests {
// Two instances: one clock parked mid-blink (shut), one parked in the open tail.
let shut_joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut().spawn(GlobalSeqDrive {
bones: vec![(shut_joint, eyelid_bone())],
bones: vec![(SeqTarget::Joint(shut_joint), eyelid_bone())],
elapsed: 0.06, // inside [0.033, 0.100] → scale 1
paused: false,
});
let open_joint = app.world_mut().spawn(Transform::default()).id();
app.world_mut().spawn(GlobalSeqDrive {
bones: vec![(open_joint, eyelid_bone())],
bones: vec![(SeqTarget::Joint(open_joint), eyelid_bone())],
elapsed: 3.0, // inside [0.133, 6.633] → scale 0
paused: false,
});
+17 -12
View File
@@ -234,17 +234,21 @@ mod tests {
driver,
))
.id();
let joint = app
.world_mut()
.spawn((Transform::default(), ChildOf(root)))
.id();
// The evaluator's rig handle (0712); target ids no longer ride the joints.
// The evaluator's pose buffer (0712 -> 0724); no joint entities at all.
let skeleton = benilla_assets::ModelSkeleton {
joints: vec![benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
ignore_parent_rotation: false,
}],
spine_bone: None,
head_bone: None,
};
app.world_mut()
.entity_mut(root)
.insert(super::super::PosedRig {
joints: vec![joint],
});
(root, joint)
.insert(super::super::RigPose::new(root, &skeleton));
(root, root)
}
/// A camera at the origin looking down −Z (view = identity): rigs at −Z are in frame, rigs
@@ -286,11 +290,12 @@ mod tests {
active.seek_time()
}
fn bone(app: &App, joint: Entity) -> Vec3 {
fn bone(app: &App, rig: Entity) -> Vec3 {
app.world()
.entity(joint)
.get::<Transform>()
.entity(rig)
.get::<super::super::RigPose>()
.unwrap()
.locals[0]
.translation
}
+149 -71
View File
@@ -18,23 +18,117 @@
//! driver, and events all keep running — which preserves 0448's two observables (absolute-clock
//! snap on wake, off-screen combat audibility) with no `AnimatedBy` repoint machinery.
use benilla_assets::{ModelAnimations, PoseSource};
use benilla_assets::{ModelAnimations, ModelSkeleton, PoseSource};
use bevy::animation::animatable::Animatable;
use bevy::app::AnimationSystems;
use bevy::math::Affine3A;
use bevy::prelude::*;
use bevy::transform::TransformSystems;
use super::AnimParked;
/// The evaluator's rig handle, on the entity carrying the `AnimationPlayer`: bone index → joint
/// entity, in skeleton order (decision 0712). Rigs spawn this **instead of** per-joint
/// `AnimationTargetId`/`AnimatedBy` pairs — with no targets, Bevy's `animate_targets` never
/// touches these bones.
/// The collapsed rig's pose buffer (decision 0724), on the entity carrying the `AnimationPlayer`:
/// one local `Transform` per bone in skeleton order — no joint entities at all (decision 0712's
/// `PosedRig` handle grew into this when the ~59 k bone entities collapsed). Every pose writer —
/// the evaluator below, the body twist, the global-sequence channels — writes `locals` and raises
/// [`Self::pose_dirty`]; `compose` then folds the parent-sorted chain into per-bone model-space
/// affines, and the world finalize (`creature_anim::compose`) turns those into palette rows and
/// consumer-anchor frames.
#[derive(Component)]
pub(crate) struct PosedRig {
pub(crate) joints: Vec<Entity>,
pub(crate) struct RigPose {
/// The rig's model-space frame: the holder itself, its conform node, or a mounted rider's
/// seat anchor. Its `GlobalTransform` is `world_from_model`; the palette recomputes when it
/// moves.
pub(crate) joints_root: Entity,
/// Per-bone local TRS, skeleton order — what the per-bone joint `Transform`s used to hold.
pub(crate) locals: Vec<Transform>,
/// Per-bone **animated** model-space affine (`parent_model × local`), the product transform
/// propagation used to compute through the joint entities. Rebuilt by [`Self::compose`] for a
/// dirty rig; billboard/ignore-rot replacements happen in the world pass, never here.
pub(crate) model: Vec<Affine3A>,
pub(crate) parents: Vec<i16>,
/// The bone's billboard arm (`0x08/0x10/0x20/0x40`), for the world pass's camera replacement.
pub(crate) kinds: Vec<Option<benilla_formats::BillboardKind>>,
/// Bone flag `0x04`: rotation resets to the model root's frame (the HandArrow helpers).
pub(crate) ignore_rot: Vec<bool>,
/// Any bone billboards — the world pass re-faces this rig whenever it isn't parked.
pub(crate) has_billboard: bool,
/// Any bone billboards or resets — the world pass's override walk runs at all.
pub(crate) has_special: bool,
/// `(bone, anchor entity)` — the consumer anchors (attachments, markers, emitter/ribbon/
/// light/billboard-card bones), children of [`Self::joints_root`]. The compose pass re-seats
/// their local `Transform`s so ordinary propagation carries every consumer subtree — held
/// items, nested effect rigs, the mount seat — exactly as the joint hierarchy did.
pub(crate) anchors: Vec<(u16, Entity)>,
/// A pose writer touched `locals` since the last world pass. Starts `true` so a rig that
/// never animates (bind pose) still gets its one palette write.
pub(crate) pose_dirty: bool,
}
impl RigPose {
/// Build the rig's buffer at bind pose (composed), `pose_dirty` armed for the first palette
/// write. Anchors are registered by the spawner as it creates them.
pub(crate) fn new(joints_root: Entity, skeleton: &ModelSkeleton) -> Self {
let locals: Vec<Transform> = skeleton
.joints
.iter()
.map(|j| Transform::from_translation(j.local_translation))
.collect();
let mut rig = Self {
joints_root,
model: vec![Affine3A::IDENTITY; locals.len()],
locals,
parents: skeleton.joints.iter().map(|j| j.parent).collect(),
kinds: skeleton.joints.iter().map(|j| j.billboard).collect(),
ignore_rot: skeleton
.joints
.iter()
.map(|j| j.ignore_parent_rotation)
.collect(),
has_billboard: skeleton.joints.iter().any(|j| j.billboard.is_some()),
has_special: skeleton
.joints
.iter()
.any(|j| j.billboard.is_some() || j.ignore_parent_rotation),
anchors: Vec::new(),
pose_dirty: true,
};
rig.compose();
rig
}
/// Forward-fold `locals` into `model` — the exact affine product transform propagation
/// computed through the joint entities (`parent_global.mul_transform(local)`, model-rooted).
/// M2 bones are parent-sorted (the format guarantees parent < child); a malformed child whose
/// parent follows it composes from the model root, matching the old hierarchy's `unwrap_or(root)`.
pub(crate) fn compose(&mut self) {
for i in 0..self.locals.len() {
let local = self.locals[i].compute_affine();
self.model[i] = match usize::try_from(self.parents[i]).ok().filter(|&p| p < i) {
Some(p) => self.model[p] * local,
None => local,
};
}
}
}
/// On every consumer anchor: which rig and bone it stands in for. The body twist's model-frame
/// walk splices through it (a mounted rider's chain crosses the mount's seat bone), and the world
/// pass uses it to cascade a re-seated root into dependent rigs.
#[derive(Component)]
pub(crate) struct RigAnchor {
/// The rig holder (the entity carrying [`RigPose`]).
pub(crate) rig: Entity,
pub(crate) bone: u16,
}
/// On an entity serving as some rig's `joints_root` while living inside ANOTHER rig's anchor
/// subtree (the mounted rider's seat anchor under the mount's attachment-0 anchor): the world
/// pass, having re-seated that subtree, re-finalizes the dependent rig too — its
/// `world_from_model` moved after propagation ran.
#[derive(Component)]
pub(crate) struct RigFrame(pub(crate) Entity);
/// One active animation's evaluation inputs, resolved against the rig's [`PoseSource`].
struct Active {
/// `AnimationNodeIndex::index()` — the sort key that reproduces Bevy's blend order.
@@ -49,12 +143,11 @@ struct Active {
/// Evaluate every live rig's pose. Runs where `animate_targets` runs — inside [`AnimationSystems`]
/// after `advance_animations` ticked the seek clocks — so the pose post-passes (body twist,
/// global-sequence writes) and transform propagation see it at the same point in the frame.
/// global-sequence writes) and the model compose see it at the same point in the frame.
fn evaluate_rig_poses(
rigs: Query<(&AnimationPlayer, &ModelAnimations, &PosedRig), Without<AnimParked>>,
mut joints: Query<&mut Transform>,
mut rigs: Query<(&AnimationPlayer, &ModelAnimations, &mut RigPose), Without<AnimParked>>,
) {
for (player, anims, rig) in &rigs {
for (player, anims, mut rig) in &mut rigs {
let src = &anims.pose;
// This rig's contributing animations, ascending by node index (Bevy's effective blend
// order — sorted children folded through a LIFO stack, see the module doc).
@@ -83,14 +176,12 @@ fn evaluate_rig_poses(
[one] => {
// The steady state: one looping gait. A single contribution commits at full
// value whatever its weight (Bevy's register initializes unnormalized).
rig.pose_dirty = true;
for pb in &src.clips[one.clip].bones {
if src.bone_masks.get(pb.bone as usize).copied().unwrap_or(0) & one.mask != 0 {
continue;
}
let Some(&joint) = rig.joints.get(pb.bone as usize) else {
continue;
};
let Ok(mut tf) = joints.get_mut(joint) else {
let Some(tf) = rig.locals.get_mut(pb.bone as usize) else {
continue;
};
if let Some(v) = pb.translation.sample(one.seek) {
@@ -104,19 +195,17 @@ fn evaluate_rig_poses(
}
}
}
many => blend_rig(src, many, rig, &mut joints),
many => {
rig.pose_dirty = true;
blend_rig(src, many, &mut rig);
}
}
}
}
/// The multi-animation path (a cross-fade, a masked overlay, the grip): merge-walk the
/// bone-sorted clips and fold each (bone, property)'s contributions in node order.
fn blend_rig(
src: &PoseSource,
active: &mut [Active],
rig: &PosedRig,
joints: &mut Query<&mut Transform>,
) {
fn blend_rig(src: &PoseSource, active: &mut [Active], rig: &mut RigPose) {
// Walk the next keyed bone across all clips until every cursor is spent.
while let Some(bone) = active
.iter()
@@ -147,10 +236,7 @@ fn blend_rig(
if translation.is_none() && rotation.is_none() && scale.is_none() {
continue;
}
let Some(&joint) = rig.joints.get(bone as usize) else {
continue;
};
let Ok(mut tf) = joints.get_mut(joint) else {
let Some(tf) = rig.locals.get_mut(bone as usize) else {
continue;
};
if let Some((v, _)) = translation {
@@ -327,14 +413,14 @@ mod tests {
}
/// Spawn the two rigs: the Bevy-path oracle (per-joint targets, `animate_targets` evaluates
/// it) and the evaluator rig (`PosedRig`). Returns `(oracle_root, oracle_joints, ours_root,
/// our_joints)`.
/// it) and the evaluator rig (a joint-less [`RigPose`] buffer). Returns `(oracle_root,
/// oracle_joints, ours_root)`.
fn twin_rigs(
app: &mut App,
nbones: u16,
graph: &Handle<AnimationGraph>,
pose: &PoseSource,
) -> (Entity, Vec<Entity>, Entity, Vec<Entity>) {
) -> (Entity, Vec<Entity>, Entity) {
let spawn_root = |app: &mut App| {
app.world_mut()
.spawn((
@@ -359,20 +445,23 @@ mod tests {
})
.collect();
let ours = spawn_root(app);
let our_joints: Vec<Entity> = (0..nbones)
.map(|_| {
app.world_mut()
.spawn((Transform::default(), ChildOf(ours)))
.id()
})
.collect();
let skeleton = ModelSkeleton {
joints: (0..nbones)
.map(|_| benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
ignore_parent_rotation: false,
})
.collect(),
spine_bone: None,
head_bone: None,
};
app.world_mut().entity_mut(ours).insert((
model_anims(graph.clone(), pose.clone()),
PosedRig {
joints: our_joints.clone(),
},
RigPose::new(ours, &skeleton),
));
(oracle, oracle_joints, ours, our_joints)
(oracle, oracle_joints, ours)
}
fn app() -> App {
@@ -386,15 +475,15 @@ mod tests {
app
}
/// Run `f` identically on both players, then step and assert every joint's `Transform` is
/// **exactly** equal between the oracle and the evaluator — same inputs, same `Animatable`
/// calls, bit-identical outputs.
/// Run `f` identically on both players, then step and assert every bone's local is
/// **exactly** equal between the oracle's joint `Transform`s and the evaluator's `locals`
/// array — same inputs, same `Animatable` calls, bit-identical outputs.
#[track_caller]
fn assert_twins_equal(app: &mut App, oracle_joints: &[Entity], our_joints: &[Entity]) {
for (i, (&a, &b)) in oracle_joints.iter().zip(our_joints).enumerate() {
fn assert_twins_equal(app: &mut App, oracle_joints: &[Entity], ours: Entity) {
let rig = app.world().entity(ours).get::<RigPose>().unwrap();
for (i, &a) in oracle_joints.iter().enumerate() {
let ta = *app.world().entity(a).get::<Transform>().unwrap();
let tb = *app.world().entity(b).get::<Transform>().unwrap();
assert_eq!(ta, tb, "bone {i} diverged (oracle vs evaluator)");
assert_eq!(ta, rig.locals[i], "bone {i} diverged (oracle vs evaluator)");
}
}
@@ -453,7 +542,7 @@ mod tests {
mask: 0,
};
let (graph, nodes, pose) = build(&mut app, &[spec], vec![0, 0, 0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 3, &graph, &pose);
let (oracle, oj, ours) = twin_rigs(&mut app, 3, &graph, &pose);
// One warm-up frame: Bevy's ThreadedAnimationGraphs builds off the graph asset's Added
// event, so `animate_targets` bails on the spawn frame (our evaluator doesn't need it and
// evaluates immediately — strictly earlier, not different). Compare from frame 2 on.
@@ -463,7 +552,7 @@ mod tests {
});
for _ in 0..5 {
step(&mut app, 0.05);
assert_twins_equal(&mut app, &oj, &uj);
assert_twins_equal(&mut app, &oj, ours);
}
}
@@ -499,7 +588,7 @@ mod tests {
mask: 0,
};
let (graph, nodes, pose) = build(&mut app, &[walk, run], vec![0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 1, &graph, &pose);
let (oracle, oj, ours) = twin_rigs(&mut app, 1, &graph, &pose);
app.update(); // warm-up: see single_clip_matches_animate_targets
drive(&mut app, &[oracle, ours], |p, tr| {
tr.play(p, nodes[0], Duration::ZERO).repeat();
@@ -511,7 +600,7 @@ mod tests {
});
for _ in 0..6 {
step(&mut app, 0.06);
assert_twins_equal(&mut app, &oj, &uj);
assert_twins_equal(&mut app, &oj, ours);
}
}
@@ -579,7 +668,7 @@ mod tests {
};
// bone 0 is in mask group 2 (the "outside the upper subtree" group).
let (graph, nodes, pose) = build(&mut app, &[base, overlay, idle], vec![1 << 2, 0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 2, &graph, &pose);
let (oracle, oj, ours) = twin_rigs(&mut app, 2, &graph, &pose);
app.update(); // warm-up: see single_clip_matches_animate_targets
drive(&mut app, &[oracle, ours], |p, _| {
p.play(nodes[0]).repeat();
@@ -588,11 +677,11 @@ mod tests {
});
for _ in 0..4 {
step(&mut app, 0.07);
assert_twins_equal(&mut app, &oj, &uj);
assert_twins_equal(&mut app, &oj, ours);
}
// And the masked bone really is base-only: it moved off spawn (the base wrote it), yet
// never took the overlay's parked 9.0 constant.
let t0 = app.world().entity(uj[0]).get::<Transform>().unwrap();
let t0 = app.world().entity(ours).get::<RigPose>().unwrap().locals[0];
assert_ne!(
t0.translation,
Vec3::splat(9.0),
@@ -617,31 +706,20 @@ mod tests {
mask: 0,
};
let (graph, nodes, pose) = build(&mut app, &[spec], vec![0]);
let (oracle, oj, ours, uj) = twin_rigs(&mut app, 1, &graph, &pose);
let (oracle, oj, ours) = twin_rigs(&mut app, 1, &graph, &pose);
// The oracle twin runs unparked throughout — the absolute-clock witness.
drive(&mut app, &[oracle, ours], |p, _| {
p.play(nodes[0]).repeat();
});
step(&mut app, 0.05);
app.world_mut().entity_mut(ours).insert(AnimParked);
let frozen = app
.world()
.entity(uj[0])
.get::<Transform>()
.unwrap()
.translation;
let bone0 =
|app: &App| app.world().entity(ours).get::<RigPose>().unwrap().locals[0].translation;
let frozen = bone0(&app);
step(&mut app, 0.1);
assert_eq!(
app.world()
.entity(uj[0])
.get::<Transform>()
.unwrap()
.translation,
frozen,
"parked bones hold"
);
assert_eq!(bone0(&app), frozen, "parked bones hold");
app.world_mut().entity_mut(ours).remove::<AnimParked>();
step(&mut app, 0.05);
assert_twins_equal(&mut app, &oj, &uj); // woke to the absolute-clock pose
assert_twins_equal(&mut app, &oj, ours); // woke to the absolute-clock pose
}
}
@@ -106,6 +106,22 @@ pub(crate) mod move_flags {
/// way the root ack does, but the faithful ack echoes the applied state).
pub const WATER_WALKING: u32 = 0x1000_0000;
/// The bits a **server-authored move packet owns**, and the only ones it may write — the
/// reference's flag *merge* mask, VERIFIED at `0x618c30 @0x618deb`
/// (`new = old ^ ((old ^ wire) & 0x75a07dff)`; wow-re `self-addressed-move.md`, decision 0725).
/// Applying a `MSG_MOVE_*` is not an assignment: bits outside this mask are kept from local
/// state, because they are the client's own (`0x618c30` also holds a second mask,
/// `0x75a01dff`, for a caller arm that passes a non-zero 4th arg — the plain state family
/// passes zero and takes this one).
///
/// Two of ours land outside it and the omissions are the point. **[`ON_TRANSPORT`]** (bit 25)
/// is client-owned: a server-authored pose relocates you, it never boards or deboards you —
/// which is why the self-move arm re-anchors the ride rather than dropping it. Bit 27 (the
/// reference's free-advance selector) and bit 31 (its clock baseline) are CMovement internals
/// with no benilla analog. Every bit we *do* model besides `ON_TRANSPORT` — the direction and
/// turn bits, walk mode, root, [`FALLING`]/[`FALLING_FAR`], swim, water-walk — is inside.
pub const SERVER_AUTHORED: u32 = 0x75a0_7dff;
/// Any horizontal-movement direction bit (forward/back/strafe) — the client's `[9e8] & 0xf` gate.
pub const ANY_MOVE: u32 = FORWARD | BACKWARD | STRAFE_LEFT | STRAFE_RIGHT;
+83 -46
View File
@@ -12,9 +12,7 @@
//! the [`apply_body_twist`] system composes the twist onto the animated bone locals each frame,
//! after Bevy's animation evaluation and before transform propagation.
use bevy::app::AnimationSystems;
use bevy::prelude::*;
use bevy::transform::TransformSystems;
/// The body's counter-twist state, on a skinned unit's root entity. Inserted at visual attach when
/// the model carries either twist key-bone ([`benilla_assets::ModelSkeleton::spine_bone`]/
@@ -30,7 +28,7 @@ pub(crate) struct BodyTwist {
}
impl BodyTwist {
pub(crate) fn new(spine: Option<Entity>, head: Option<Entity>) -> Self {
pub(crate) fn new(spine: Option<u16>, head: Option<u16>) -> Self {
Self {
yaw_gap: 0.0,
spine: spine.map(Channel::new),
@@ -39,22 +37,22 @@ impl BodyTwist {
}
}
/// One twist channel's joint + composition bookkeeping.
/// One twist channel's bone + composition bookkeeping.
struct Channel {
joint: Entity,
bone: u16,
/// The animated local rotation the twist last composed on — the "base" under our twist.
base: Quat,
/// What we last wrote (`base * twist`). If the joint still holds exactly this next frame, the
/// animation didn't retouch the bone this frame (a clip need not key every bone), so `base`
/// stays authoritative — composing onto the joint's current value instead would accumulate the
/// twist frame over frame and spin the bone.
/// What we last wrote (`base * twist`). If the bone still holds exactly this next frame, the
/// animation didn't retouch it this frame (a clip need not key every bone), so `base`
/// stays authoritative — composing onto the bone's current value instead would accumulate the
/// twist frame over frame and spin it.
last_out: Quat,
}
impl Channel {
fn new(joint: Entity) -> Self {
fn new(bone: u16) -> Self {
Self {
joint,
bone,
base: Quat::IDENTITY,
last_out: Quat::IDENTITY,
}
@@ -84,70 +82,109 @@ fn twist_shares(gap: f32) -> (f32, f32) {
(spine, head)
}
/// Compose the counter-twist onto the animated bone locals — PostUpdate, after
/// [`AnimationSystems`] wrote this frame's pose and before [`TransformSystems::Propagate`].
/// Compose the counter-twist onto the animated bone locals — PostUpdate, in the pose post-pass
/// window ([`super::PosePost`]: after the evaluator wrote this frame's pose, before the model
/// compose folds it).
///
/// Each channel yaws its subtree about **world up through the bone's own pivot**: with `g` the
/// bone's model-space rotation (ancestors × its animated local), `local' = local · Quat(g⁻¹·Y, θ)`
/// conjugates to a pure up-axis yaw of the subtree (units stand upright and their root rotation is
/// a Y-yaw, so model up and world up coincide). The head channel runs after the spine write, so its
/// ancestor chain already carries the spine's twist — the head counter-rotates relative to the
/// twisted spine, exactly the client's residual-gap composition.
/// twisted spine, exactly the client's residual-gap composition. The ancestor walk runs up the
/// rig's own parent table, then the entity frames between its `joints_root` and the unit (a
/// conform node's tilt; a mounted rider's seat — splicing through the mount's bone chain via its
/// [`super::RigAnchor`]), exactly the frames the joint-entity walk used to compose.
pub(super) fn apply_body_twist(
// A parked rig's bones are frozen (decision 0448) — composing the twist onto them would
// re-dirty the subtree every frame for a unit no one sees; the wake re-seats `base` from the
// recompute the palette every frame for a unit no one sees; the wake re-seats `base` from the
// fresh sample on its own (`cur != last_out`).
mut units: Query<(Entity, &mut BodyTwist), Without<super::AnimParked>>,
mut joints: Query<&mut Transform>,
mut rigs: Query<&mut super::RigPose>,
anchors: Query<&super::RigAnchor>,
parents: Query<&ChildOf>,
locals: Query<&Transform>,
) {
for (unit, mut twist) in &mut units {
let (spine, head) = twist_shares(twist.yaw_gap);
let twist = &mut *twist;
for (channel, angle) in [(&mut twist.spine, spine), (&mut twist.head, head)] {
let Some(ch) = channel else { continue };
let Ok(cur) = joints.get(ch.joint).map(|t| t.rotation) else {
let bone = ch.bone as usize;
let Some((cur, base, out)) = ({
let rig = rigs.get(unit).ok();
rig.and_then(|rig| {
let cur = rig.locals.get(bone)?.rotation;
let base = if cur == ch.last_out { ch.base } else { cur };
let out = if angle == 0.0 {
base
} else {
// The bone's model-space rotation: its own ancestor chain…
let mut g = base;
let mut b = rig.parents.get(bone).copied().unwrap_or(-1);
while let Ok(p) = usize::try_from(b) {
g = rig.locals.get(p)?.rotation * g;
b = rig.parents.get(p).copied().unwrap_or(-1);
}
// …then the frames between joints_root and (excluding) the unit, splicing
// a rig anchor's bone chain (the mount seat). Depth-capped against a
// malformed hierarchy; the joint walk bottomed out at the world root.
let mut e = rig.joints_root;
for _ in 0..32 {
if e == unit {
break;
}
if let Some(host) = anchors
.get(e)
.ok()
.and_then(|a| rigs.get(a.rig).ok().map(|r| (r, a.bone)))
{
let (host_rig, hb) = host;
let mut b = Ok(hb as usize);
while let Ok(p) = b {
let Some(t) = host_rig.locals.get(p) else {
break;
};
g = t.rotation * g;
b = usize::try_from(
host_rig.parents.get(p).copied().unwrap_or(-1),
);
}
e = host_rig.joints_root;
continue;
}
if let Ok(t) = locals.get(e) {
g = t.rotation * g;
}
let Ok(p) = parents.get(e).map(|c| c.parent()) else {
break;
};
e = p;
}
(base * Quat::from_axis_angle(g.inverse() * Vec3::Y, angle)).normalize()
};
Some((cur, base, out))
})
}) else {
continue;
};
let base = if cur == ch.last_out { ch.base } else { cur };
let out = if angle == 0.0 {
base
} else {
// The bone's model-space rotation: walk local rotations up to (excluding) the unit
// root. (Including the root would be harmless — its Y-yaw preserves the up axis —
// but stopping there keeps the walk from wandering into the world hierarchy.)
let mut g = base;
let mut e = ch.joint;
while let Ok(p) = parents.get(e).map(|c| c.parent()) {
if p == unit {
break;
}
if let Ok(t) = joints.get(p) {
g = t.rotation * g;
}
e = p;
}
(base * Quat::from_axis_angle(g.inverse() * Vec3::Y, angle)).normalize()
};
ch.base = base;
ch.last_out = out;
if out != cur {
if let Ok(mut t) = joints.get_mut(ch.joint) {
t.rotation = out;
if let Ok(mut rig) = rigs.get_mut(unit) {
if let Some(t) = rig.locals.get_mut(bone) {
t.rotation = out;
rig.pose_dirty = true;
}
}
}
}
}
}
/// Register [`apply_body_twist`] in the post-animation window.
/// Register [`apply_body_twist`] in the pose post-pass window.
pub(super) fn plugin(app: &mut App) {
app.add_systems(
PostUpdate,
apply_body_twist
.after(AnimationSystems)
.before(TransformSystems::Propagate),
);
app.add_systems(PostUpdate, apply_body_twist.in_set(super::PosePost));
}
#[cfg(test)]
+22 -1
View File
@@ -72,6 +72,9 @@ pub(crate) struct AnimHostSpawn {
pub root: Entity,
pub joints: Vec<Entity>,
pub inverse_bindposes: Handle<SkinnedMeshInverseBindposes>,
/// The palette rig slot on `root` (decision 0720) — the spawn site tags it into each skinned
/// part's `MeshTag` rig field. `0` = the palette table was full: use the static mesh.
pub slot: u16,
/// The looping first-sequence node + duration, `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
}
@@ -86,6 +89,7 @@ pub(crate) struct AnimHostSpawn {
/// the caller keeps today's path untouched.
pub(crate) fn spawn_anim_host(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
m: &M2Model,
transform: Transform,
) -> Option<AnimHostSpawn> {
@@ -101,7 +105,23 @@ pub(crate) fn spawn_anim_host(
}
let anims = m.animations.as_ref().expect("animated tier ⇒ animations");
let root = commands.spawn((transform, Visibility::default())).id();
let joints = crate::entities::spawn_joints(commands, root, &m.skeleton);
let joints = crate::entities::spawn_joints(commands, root, root, &m.skeleton);
// The owned palette rig (decision 0720): the placement's skinned parts tag this slot; the
// palette compute reads the joints' worlds (Bevy's animate_targets still drives them — the
// doodad lane keeps the graph path, 0712's "cold consumers"). Slot 0 (table full, warned) ⇒
// the spawn site falls back to the static mesh.
let slot = match crate::rig_palette::RigSkin::allocate(
palettes,
joints.clone(),
m.inverse_bindposes.clone(),
) {
Some(rig) => {
let slot = rig.slot;
commands.entity(root).insert(rig);
slot
}
None => 0,
};
// Billboard bones on an animated doodad (a swinging lamp's glow child): the palette-level
// camera facing, children inheriting.
if let Some(bb) = crate::billboard::BillboardJointRig::new(&m.skeleton, &joints, root) {
@@ -142,6 +162,7 @@ pub(crate) fn spawn_anim_host(
root,
joints,
inverse_bindposes: m.inverse_bindposes.clone(),
slot,
clip: clip_info,
})
}
+1 -1
View File
@@ -143,7 +143,7 @@ pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
ATTACH_OVERHEAD
};
let &(bone, offset) = a.points.get(&slot)?;
let joint = *a.joints.get(bone as usize)?;
let joint = a.anchor(bone)?;
Some(globals.get(joint).ok()?.transform_point(offset))
})
.unwrap_or_else(|| {
+223 -136
View File
@@ -13,7 +13,6 @@ use benilla_formats::CharSkinSlot;
use benilla_protocol::EntityKind;
use bevy::camera::primitives::MeshAabb;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
use bevy::mesh::MeshTag;
use bevy::prelude::*;
@@ -70,28 +69,84 @@ pub(super) use glue_preview::build_glue_preview;
/// (which of the 30 types allocate the `[GO+0x210]` handler at the `0x5f708c` dispatch); the
/// seed below is what those types render in the meantime.
///
/// Global-sequence channels ride along regardless of flavour. Returns the joints + inverse-bindposes
/// for the caller to bind each submesh's skinned twin, or `None` when the model has no inverse
/// bindposes. No animations ⇒ the joints just hold bind pose (Milestone A).
/// Global-sequence channels ride along regardless of flavour. This lane spawns **no joint
/// entities** (decision 0724): the pose lives in a [`crate::creature_anim::RigPose`] array on
/// `entity`, and only the CONSUMER bones — attachment points, event markers, emitter/ribbon/
/// light hosts, billboard-card bones — get an anchor entity under `joints_root`, re-seated from
/// the composed pose each frame it changes. Returns the anchors + the palette rig slot
/// (decision 0720), or `None` when the model has no inverse bindposes. Slot `0` = the palette
/// table was full: the anchors still exist (emitters/attachments ride them), but parts fall
/// back to the static bind-pose mesh. No animations ⇒ the pose just holds bind pose
/// (Milestone A).
fn setup_skinned_instance(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
entity: Entity,
joints_root: Entity,
d: &DisplayModel,
kind: EntityKind,
go_state_machine: bool,
) -> Option<(Vec<Entity>, Handle<SkinnedMeshInverseBindposes>)> {
) -> Option<RigBuild> {
let ibp = d.inverse_bindposes.as_ref()?;
// `joints_root` — where the skeleton's root bones parent — is normally `entity` itself; a
// MOUNTED rider's joints root under the seat anchor instead (decision 0441), while the
// `AnimationPlayer`/driver components stay on `entity` (the pose evaluator binds joints
// through the root's `PosedRig`, decision 0712 — parentage never enters it).
// Skinned parts render purely from joint world transforms, so their own parentage is free.
let joints = spawn_joints(commands, joints_root, &d.skeleton);
// Billboard bones (glow riders on billboard children): palette-level camera facing, children inherit.
if let Some(bb) = crate::billboard::BillboardJointRig::new(&d.skeleton, &joints, entity) {
commands.entity(entity).insert(bb);
let nbones = d.skeleton.joints.len();
// `joints_root` — the rig's model-space frame — is normally `entity` itself; a MOUNTED
// rider's frame is the seat anchor instead (decision 0441), a conform-tilted model's its
// conform node, while the `AnimationPlayer`/driver components stay on `entity`. Skinned
// parts render purely from the palette, so their own parentage is free.
let mut pose = crate::creature_anim::RigPose::new(joints_root, &d.skeleton);
// The consumer bones: every bone something in the world reaches by entity — an attachment
// point (held items, spell effects, the mount seat, overhead anchors), an event marker, an
// emitter/ribbon/light host, a billboard card's bone. Everything else is palette-only.
let mut bone_set = std::collections::BTreeSet::new();
bone_set.extend(d.attachments.iter().map(|a| a.bone));
bone_set.extend(d.markers.iter().map(|m| m.bone));
bone_set.extend(d.emitters.iter().map(|e| e.def.bone));
bone_set.extend(d.ribbons.iter().map(|r| r.def.bone));
bone_set.extend(
d.lights
.iter()
.filter_map(|l| u16::try_from(l.def.bone).ok()),
);
if let Some(parts) = &d.parts {
bone_set.extend(
parts
.iter()
.filter_map(|p| p.billboard.as_ref().map(|b| b.bone)),
);
}
let mut anchors = std::collections::HashMap::new();
for bone in bone_set {
let Some(m) = pose.model.get(bone as usize) else {
continue; // an out-of-range authored bone reference — no anchor, consumers miss
};
let (scale, rotation, translation) = m.to_scale_rotation_translation();
let anchor = commands
.spawn((
Transform {
translation,
rotation,
scale,
},
Visibility::default(),
crate::creature_anim::RigAnchor { rig: entity, bone },
))
.id();
commands.entity(joints_root).add_child(anchor);
pose.anchors.push((bone, anchor));
anchors.insert(bone, anchor);
}
// The owned palette rig (decision 0720): the world pass writes this rig's composed frames ×
// these bindposes into the slot; every skinned part below tags the slot so the vertex stage
// finds its palette. The on-replace hook frees the slot with the visual teardown.
let slot =
match crate::rig_palette::RigSkin::allocate_bones(palettes, nbones as u32, ibp.clone()) {
Some(rig) => {
let slot = rig.slot;
commands.entity(entity).insert(rig);
slot
}
None => 0, // table full (warned) — parts render the static bind-pose mesh
};
if let Some(anims) = d.animations.as_ref() {
// **Every** GameObject instance gets the loader-idle seed, state machine or not — the
// reference's `0x70ebd0` tail arms bone 0 the moment the M2 goes LIVE and has exactly two
@@ -147,22 +202,26 @@ fn setup_skinned_instance(
.remove::<crate::creature_anim::AnimParked>();
}
}
// The direct pose evaluator's rig handle (decision 0712) — instead of per-joint
// `AnimationTargetId`/`AnimatedBy` pairs: with no targets, Bevy's `animate_targets` never
// touches these bones, and `creature_anim::pose` samples the player state itself.
commands
.entity(entity)
.insert(crate::creature_anim::PosedRig {
joints: joints.clone(),
});
// Global-sequence bone channels (the eye-blink eyelid scale, resting fidget pulses; a GO's
// free-running flicker): free-clock loops the per-sequence reader drops, driven on their own clock.
if let Some(drive) = crate::creature_anim::GlobalSeqDrive::new(&anims.global_bones, &joints)
if let Some(drive) =
crate::creature_anim::GlobalSeqDrive::new_rig(&anims.global_bones, nbones)
{
commands.entity(entity).insert(drive);
}
}
Some((joints, ibp.clone()))
// The pose buffer last — the anchors above registered themselves into it. The evaluator
// (decision 0712) samples the player state straight into `locals`; with no joint entities and
// no `AnimationTargetId`s, Bevy's `animate_targets` has nothing of ours to touch.
commands.entity(entity).insert(pose);
Some(RigBuild { anchors, slot })
}
/// A collapsed rig's build result (decision 0724): the consumer anchors by bone + the palette
/// slot each skinned part tags.
struct RigBuild {
anchors: std::collections::HashMap<u16, Entity>,
slot: u16,
}
/// Attach a visual to each net entity that doesn't have one yet: its built model (creature / GameObject
@@ -221,6 +280,8 @@ pub(super) fn attach_entity_visuals(
),
mut meshes: ResMut<Assets<Mesh>>,
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
time: Res<Time>,
) {
let (
@@ -342,7 +403,7 @@ pub(super) fn attach_entity_visuals(
match mount_children.get(child) {
Ok((true, Some(bones), _)) => {
if let Some(&(bone, offset)) = bones.points.get(&0) {
if let Some(&joint) = bones.joints.get(bone as usize) {
if let Some(joint) = bones.anchor(bone) {
// The seat anchor: a child of the mount's attachment-0
// joint at the authored offset, counter-scaled so the
// rider keeps its own size (byte-verified: the client's
@@ -354,6 +415,11 @@ pub(super) fn attach_entity_visuals(
Vec3::splat(1.0 / mount_scale.max(0.001)),
),
Visibility::default(),
// The rider's model frame lives inside the
// MOUNT's anchor subtree — the world pass
// cascades a re-seat into the rider's palette
// (decision 0724).
crate::creature_anim::RigFrame(entity),
))
.id();
commands.entity(joint).add_child(anchor);
@@ -411,89 +477,91 @@ pub(super) fn attach_entity_visuals(
// whose entities are children of this entity (so they inherit its world pose; at bind pose
// every joint matrix collapses to that pose, so the model renders exactly where the static
// mesh did). Truly static props keep the static mesh. `skin` is `Some((joints,
// inverse_bindposes))` when instanced.
let skin: Option<(Vec<Entity>, Handle<SkinnedMeshInverseBindposes>)> =
match (net.kind, dm) {
// A creature (or player body — decision 0041) with a real skeleton. The `!is_empty`
// guard keeps a degenerate boneless model on the static mesh (its skinned twin would
// carry joint attributes but have no joints to index — out of bounds).
(EntityKind::Unit | EntityKind::Player, Some(d))
if !d.skeleton.joints.is_empty() =>
{
// `rider_root`: the unit itself, or — mounted — the seat anchor under the
// mount's attachment-0 joint (decision 0441). The `AnimationPlayer` stays
// on the unit entity either way (targets bind by entity, not by path).
//
// Terrain conform (decisions 0482/0486): a flagged model's root bones
// parent one level deeper, under a conform node `conform_units` rotates —
// wild quadruped and mount child alike. A mounted RIDER never gets one
// (`rider_root != entity`): the ref's `0x7106c0` dispatch is on the
// mount-PREFERRED model, and the composite tilts through the mount's
// node, seat joint included.
let mut joints_root = rider_root;
if d.terrain_tilt != 0 && rider_root == entity {
let node = commands
.spawn((
super::conform::ConformNode {
// The ground/yaw source: the streamed unit — for a
// mount child, its HOST (the child sits at the unit
// matrix; its own `Transform` is local).
unit: mount_body.map_or(entity, |mb| mb.host),
mode: d.terrain_tilt,
},
Transform::default(),
Visibility::default(),
))
.id();
commands.entity(entity).add_child(node);
joints_root = node;
}
setup_skinned_instance(
&mut commands,
entity,
joints_root,
d,
net.kind,
false,
)
// inverse_bindposes, palette_slot))` when instanced (decision 0720; slot 0 = palette
// full, parts fall back to the static mesh).
let skin: Option<RigBuild> = match (net.kind, dm) {
// A creature (or player body — decision 0041) with a real skeleton. The `!is_empty`
// guard keeps a degenerate boneless model on the static mesh (its skinned twin would
// carry joint attributes but have no joints to index — out of bounds).
(EntityKind::Unit | EntityKind::Player, Some(d))
if !d.skeleton.joints.is_empty() =>
{
// `rider_root`: the unit itself, or — mounted — the seat anchor under the
// mount's attachment-0 joint (decision 0441). The `AnimationPlayer` stays
// on the unit entity either way (targets bind by entity, not by path).
//
// Terrain conform (decisions 0482/0486): a flagged model's root bones
// parent one level deeper, under a conform node `conform_units` rotates —
// wild quadruped and mount child alike. A mounted RIDER never gets one
// (`rider_root != entity`): the ref's `0x7106c0` dispatch is on the
// mount-PREFERRED model, and the composite tilts through the mount's
// node, seat joint included.
let mut joints_root = rider_root;
if d.terrain_tilt != 0 && rider_root == entity {
let node = commands
.spawn((
super::conform::ConformNode {
// The ground/yaw source: the streamed unit — for a
// mount child, its HOST (the child sits at the unit
// matrix; its own `Transform` is local).
unit: mount_body.map_or(entity, |mb| mb.host),
mode: d.terrain_tilt,
},
Transform::default(),
Visibility::default(),
))
.id();
commands.entity(entity).add_child(node);
joints_root = node;
}
// A GameObject whose model authors a real skeleton + animation draws through the
// skinned twin like a creature. Two flavours share the rig: a door/button/chest
// (`go_animates`) runs the open/close state machine off GAMEOBJECT_STATE (decision
// 0242); ANY other animated GO — a mailbox's wind-swung flags, a banner, a windmill —
// loops its first sequence as the reference's universal loader-idle seed (wow-re
// `doodad-anim-host.md`: a non-transport CGGameObject animates identically to a placed
// doodad). The content gate for the non-state flavour is the doodad classifier's: a GO
// whose first sequence is a constant pose and which has no global sequences
// (`DoodadAnimTier::Static`) has nothing to loop, so it keeps the static mesh.
(EntityKind::GameObject, Some(d))
if !d.skeleton.joints.is_empty() && d.animations.is_some() =>
{
let state_machine = stores
.get(entity)
.is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id()));
let ambient = !matches!(
crate::doodad_anim::classify(&d.skeleton, d.animations.as_ref()),
crate::doodad_anim::DoodadAnimTier::Static
);
(state_machine || ambient)
.then(|| {
setup_skinned_instance(
&mut commands,
entity,
entity,
d,
net.kind,
state_machine,
)
})
.flatten()
}
_ => None,
};
setup_skinned_instance(
&mut commands,
&mut palettes,
entity,
joints_root,
d,
net.kind,
false,
)
}
// A GameObject whose model authors a real skeleton + animation draws through the
// skinned twin like a creature. Two flavours share the rig: a door/button/chest
// (`go_animates`) runs the open/close state machine off GAMEOBJECT_STATE (decision
// 0242); ANY other animated GO — a mailbox's wind-swung flags, a banner, a windmill —
// loops its first sequence as the reference's universal loader-idle seed (wow-re
// `doodad-anim-host.md`: a non-transport CGGameObject animates identically to a placed
// doodad). The content gate for the non-state flavour is the doodad classifier's: a GO
// whose first sequence is a constant pose and which has no global sequences
// (`DoodadAnimTier::Static`) has nothing to loop, so it keeps the static mesh.
(EntityKind::GameObject, Some(d))
if !d.skeleton.joints.is_empty() && d.animations.is_some() =>
{
let state_machine = stores
.get(entity)
.is_ok_and(|s| crate::go_anim::go_animates(s.0.gameobject_type_id()));
let ambient = !matches!(
crate::doodad_anim::classify(&d.skeleton, d.animations.as_ref()),
crate::doodad_anim::DoodadAnimTier::Static
);
(state_machine || ambient)
.then(|| {
setup_skinned_instance(
&mut commands,
&mut palettes,
entity,
entity,
d,
net.kind,
state_machine,
)
})
.flatten()
}
_ => None,
};
// The bone-riding surface (decision 0072): the instance's joints + the model's attachment
// points, so held items (and future bone riders) can hang from the hand/hip/back joints.
if let (Some((joints, _)), Some(d)) = (&skin, dm) {
if let (Some(rb), Some(d)) = (&skin, dm) {
// The event markers keep the client's first-match scan order: an ident already
// present wins (character models carry six `$CSD` records — the first is the one
// `0x7130e0` would return).
@@ -502,7 +570,7 @@ pub(super) fn attach_entity_visuals(
markers.entry(m.ident).or_insert((m.bone, m.offset));
}
commands.entity(entity).insert(super::BoneAttach {
joints: joints.clone(),
anchors: rb.anchors.clone(),
points: d
.attachments
.iter()
@@ -510,13 +578,14 @@ pub(super) fn attach_entity_visuals(
.collect(),
markers,
});
// The display-facing counter-twist channels (the strafe body pose): resolve the
// model's SpineLow/Head key-bones to this instance's joints. Models without either
// The display-facing counter-twist channels (the strafe body pose): the model's
// SpineLow/Head key-bones, straight into the pose buffer. Models without either
// key-bone (beasts, props) get no component — the client's capability gates.
let joint_at = |b: Option<u16>| b.and_then(|i| joints.get(i as usize).copied());
let nb = d.skeleton.joints.len();
let in_range = |b: Option<u16>| b.filter(|&i| (i as usize) < nb);
let (spine, head) = (
joint_at(d.skeleton.spine_bone),
joint_at(d.skeleton.head_bone),
in_range(d.skeleton.spine_bone),
in_range(d.skeleton.head_bone),
);
if spine.is_some() || head.is_some() {
commands
@@ -616,7 +685,7 @@ pub(super) fn attach_entity_visuals(
if let Some(info) = &part.billboard {
let joint = skin
.as_ref()
.and_then(|(joints, _)| joints.get(info.bone as usize).copied());
.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
@@ -684,10 +753,16 @@ pub(super) fn attach_entity_visuals(
Some(b) => (b.clone(), true),
None => (mat.clone(), false),
};
// A skinned creature part draws its skinned-mesh twin (joint attributes → the SKINNED
// shader path); everything else the static mesh.
let mesh = match (&skin, &part.skinned_mesh) {
(Some(_), Some(sm)) => sm.clone(),
// 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.
let rig_slot = match (&skin, &part.skinned_mesh) {
(Some(rb), Some(_)) => rb.slot,
_ => 0,
};
let rig_tag = crate::mesh_tag::rig_bits(rig_slot);
let mesh = match (rig_slot, &part.skinned_mesh) {
(1.., Some(sm)) => sm.clone(),
_ => part.mesh.clone(),
};
let mut child = parent.spawn((
@@ -710,12 +785,16 @@ pub(super) fn attach_entity_visuals(
},
object.clone(),
));
// Bind this part to the instance's skeleton — all parts share the one joint set.
if let (Some((joints, ibp)), Some(_)) = (&skin, &part.skinned_mesh) {
child.insert(SkinnedMesh {
inverse_bindposes: ibp.clone(),
joints: joints.clone(),
});
// Bind this part to the instance's palette rig (decision 0720) — all parts
// share the one joint set through the rig slot in their tag; `RigPart` is the
// CPU-side link (the mouseover picker's skinned ray test).
if rig_slot != 0 {
child.insert((
crate::rig_palette::RigPart(entity),
MeshTag(rig_tag | crate::mesh_tag::alpha_bits(1.0)),
));
}
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
@@ -745,7 +824,9 @@ pub(super) fn attach_entity_visuals(
// classifier reclaims the tag and the steady material. `mat_interior` (the
// interior-capable build) stays the gate for which parts classify at all.
if mat_interior.is_some() {
let tag = crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 });
// `rig_tag` rides every whole-tag write here and below (decision 0720).
let tag =
rig_tag | crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 });
// 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
@@ -779,11 +860,10 @@ pub(super) fn attach_entity_visuals(
// got one above; anything else (a WMO-display part, a model with no
// interior variants) is seeded with the same neutral value.
if mat_interior.is_none() {
child.insert(MeshTag(crate::mesh_tag::alpha_bits(if fading {
0.0
} else {
1.0
})));
child.insert(MeshTag(
rig_tag
| crate::mesh_tag::alpha_bits(if fading { 0.0 } else { 1.0 }),
));
}
}
// Queue the appear-fade on M2 parts — `arm_appear_fade` starts the ramp once the world
@@ -900,7 +980,7 @@ pub(super) fn attach_entity_visuals(
for em in emitters {
let owner = skin
.as_ref()
.and_then(|(joints, _)| joints.get(em.def.bone as usize))
.and_then(|rb| rb.anchors.get(&em.def.bone))
.map_or((entity, [0.0; 3]), |&j| (j, em.bone_pivot));
particles::spawn_emitter(
&mut commands,
@@ -927,7 +1007,8 @@ pub(super) fn attach_entity_visuals(
// `equipment`.)
super::spawn_carried_lights(&mut commands, model_lights, entity, |bone| {
skin.as_ref()
.and_then(|(joints, _)| joints.get(bone as usize))
.zip(u16::try_from(bone).ok())
.and_then(|(rb, b)| rb.anchors.get(&b))
.copied()
});
// Ribbon trails (wisp streamers, trailing quest-object crystals) — the same host-bone
@@ -936,7 +1017,7 @@ pub(super) fn attach_entity_visuals(
for rb in dm.map(|d| d.ribbons.as_slice()).unwrap_or_default() {
let (owner, use_pivot) = skin
.as_ref()
.and_then(|(joints, _)| joints.get(rb.def.bone as usize))
.and_then(|build| build.anchors.get(&rb.def.bone))
.map_or((entity, false), |&j| (j, true));
// A streamed unit's body trails are always-on (wisp streamers, crystal
// trails); the per-sequence visibility gate is the thrown weapon's InFlight
@@ -1031,14 +1112,19 @@ pub(super) fn attach_entity_visuals(
}
}
/// Spawn a creature instance's joint-entity hierarchy under `root` (decision 0019): one entity per
/// bone carrying its rest-local translation, parented per the skeleton — root bones under `root` so
/// they inherit the entity's world pose, others under their parent joint. Returns the joints in bone
/// order, so a vertex's joint index maps straight in and every submesh's `SkinnedMesh` shares this one
/// set. At rest these reproduce the bind pose; the animation pass (next) will drive each joint's TRS.
/// Spawn a rig's joint-entity hierarchy under `root` (decision 0019): one entity per bone
/// carrying its rest-local translation, parented per the skeleton — root bones under `root` so
/// they inherit the entity's world pose, others under their parent joint. Returns the joints in
/// bone order, so a vertex's joint index maps straight in and every submesh's palette rig shares
/// this one set. **The doodad/effect/booth lane only** (decision 0724): a streamed unit's rig is
/// the joint-less [`crate::creature_anim::RigPose`] buffer instead — this hierarchy remains for
/// the Bevy-graph-driven hosts. `holder` is the rig root that carries (or will carry) the
/// [`crate::rig_palette::RigSkin`] — every joint marks it with a `RigJoint`, which is what the
/// palette change-sweep iterates (0720).
pub(crate) fn spawn_joints(
commands: &mut Commands,
root: Entity,
holder: Entity,
skeleton: &ModelSkeleton,
) -> Vec<Entity> {
let joints: Vec<Entity> = skeleton
@@ -1053,6 +1139,7 @@ pub(crate) fn spawn_joints(
.spawn((
Transform::from_translation(j.local_translation),
Visibility::default(),
crate::rig_palette::RigJoint(holder),
))
.id()
})
+22 -6
View File
@@ -168,7 +168,12 @@ pub(super) fn refresh_player_looks(
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
crate::creature_anim::PosedRig,
(
crate::creature_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
BoneAttach,
HeldAttached,
)>()
@@ -235,12 +240,16 @@ pub(super) enum ItemModelKind {
Quiver,
}
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the joint
/// entities in bone order + the model's attachment points (id → bone + Bevy-space local offset).
/// Held items spawn under `joints[bone]` at the offset; future bone riders (mounts) use the same.
/// The per-instance bone-riding surface, inserted at visual attach (decision 0072): the rig's
/// consumer **anchors** by bone (decision 0724 — one anchor entity per bone something reaches,
/// not a full joint hierarchy) + the model's attachment points (id → bone + Bevy-space local
/// offset). Held items spawn under `anchor(bone)` at the offset; every bone rider (mount seat,
/// spell effects, overhead anchors) uses the same two-step lookup.
#[derive(Component)]
pub(crate) struct BoneAttach {
pub(crate) joints: Vec<Entity>,
/// Bone index → its anchor entity. Populated for every bone in `points`/`markers` (and the
/// emitter/ribbon/light/billboard hosts) — a `points` hit always resolves.
pub(crate) anchors: HashMap<u16, Entity>,
/// Attachment id → `(bone index, bevy-space offset from the bone's bind pivot)`.
pub(crate) points: HashMap<u16, (u16, Vec3)>,
/// Animation-event marker 4CC → the same `(bone, offset)` shape — first record per ident
@@ -249,6 +258,13 @@ pub(crate) struct BoneAttach {
pub(crate) markers: HashMap<[u8; 4], (u16, Vec3)>,
}
impl BoneAttach {
/// The anchor entity standing in for `bone` — the frame a rider parents under / reads.
pub(crate) fn anchor(&self, bone: u16) -> Option<Entity> {
self.anchors.get(&bone).copied()
}
}
/// The held-item children currently spawned for a unit: the [`HeldItems`] they were built from (the
/// diff key) + the spawned root entities (one per slot, despawned recursively on change).
#[derive(Component, Default)]
@@ -787,7 +803,7 @@ pub(super) fn attach_held_items(
// Body model has no such attach point (a non-character skeleton) — hold nothing.
continue;
};
let Some(&joint) = bones.joints.get(bone as usize) else {
let Some(joint) = bones.anchor(bone) else {
continue;
};
let root = commands
+6 -1
View File
@@ -119,7 +119,12 @@ pub(super) fn refresh_live_display(
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
crate::creature_anim::PosedRig,
(
crate::creature_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
super::BoneAttach,
super::equipment::HeldAttached,
)>()
+5 -3
View File
@@ -168,7 +168,7 @@ fn attach_world_pos(
let point = bones.and_then(|b| {
tags.into_iter()
.find_map(|tag| b.points.get(&tag).copied())
.and_then(|(bone, offset)| b.joints.get(bone as usize).map(|&j| (j, offset)))
.and_then(|(bone, offset)| b.anchor(bone).map(|j| (j, offset)))
});
Some(
match point.and_then(|(j, off)| joints.get(j).ok().map(|g| g.transform_point(off))) {
@@ -195,7 +195,7 @@ fn launch_world_pos(
.into_iter()
.chain(MARKER_CASCADE)
.find_map(|ident| b.markers.get(&ident).copied())
.and_then(|(bone, offset)| b.joints.get(bone as usize).map(|&j| (j, offset)))
.and_then(|(bone, offset)| b.anchor(bone).map(|j| (j, offset)))
});
Some(
match point.and_then(|(j, off)| joints.get(j).ok().map(|g| g.transform_point(off))) {
@@ -532,6 +532,7 @@ pub(super) fn attach_missile_models(
mut wow_materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
mut tint_reg: ResMut<super::spell_fx::FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
) {
let (Some(fx), Some(light)) = (fx, shared_light) else {
@@ -570,6 +571,7 @@ pub(super) fn attach_missile_models(
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
&light,
Some(INFLIGHT_ANIM),
) {
@@ -682,7 +684,7 @@ mod tests {
.spawn((
GlobalTransform::default(),
BoneAttach {
joints: vec![joint],
anchors: HashMap::from([(0u16, joint)]),
points: HashMap::new(),
markers: HashMap::from([(*b"$CSL", (0u16, Vec3::ZERO))]),
},
+6 -1
View File
@@ -65,7 +65,12 @@ pub(super) fn refresh_mounts(
AnimationGraphHandle,
benilla_assets::ModelAnimations,
crate::creature_anim::AnimDriver,
crate::creature_anim::PosedRig,
(
crate::creature_anim::RigPose,
crate::creature_anim::BodyTwist,
crate::creature_anim::GlobalSeqDrive,
),
crate::rig_palette::RigSkin,
super::BoneAttach,
super::equipment::HeldAttached,
)>()
+37 -13
View File
@@ -47,7 +47,6 @@ use std::collections::HashMap;
use benilla_assets::bone_target_id;
use bevy::animation::AnimatedBy;
use bevy::ecs::entity::EntityHashMap;
use bevy::mesh::skinning::SkinnedMesh;
use bevy::prelude::*;
use crate::creature_anim::{scan_events, AnimSoundEvent, FxClass, SpellKitFx};
@@ -185,6 +184,7 @@ pub(crate) fn drive_fx_view(
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
light: Option<Res<crate::lighting::SharedLightBuffer>>,
spatial: avian3d::prelude::SpatialQuery,
mut transforms: Query<&mut Transform>,
@@ -318,6 +318,7 @@ pub(crate) fn drive_fx_view(
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
&light,
None, // the fixture previews a kit effect at its default (first) clip
) {
@@ -355,6 +356,7 @@ pub(super) fn attach_effect_visuals(
wow_materials: &mut Assets<WowModelMaterial>,
tint_reg: &mut FxTintAnims,
ibps: &Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>,
palettes: &mut crate::rig_palette::RigPalettes,
light: &crate::lighting::SharedLightBuffer,
preferred_anim: Option<u16>,
) -> bool {
@@ -376,7 +378,22 @@ pub(super) fn attach_effect_visuals(
})
.collect();
let joints = arm_effect_rig(commands, root, dm, preferred_anim);
let rigged = !joints.is_empty() && dm.inverse_bindposes.is_some();
// The owned palette rig (decision 0720): allocated when the effect draws skinned parts; the
// hook frees the slot when the instance despawns (impact reap, missile arrival).
let rig_slot = match (&dm.inverse_bindposes, joints.is_empty()) {
(Some(ibp), false) => {
crate::rig_palette::RigSkin::allocate(palettes, joints.clone(), ibp.clone()).map_or(
0,
|rig| {
let slot = rig.slot;
commands.entity(root).insert(rig);
slot
},
)
}
_ => 0,
};
let rigged = rig_slot != 0;
// The clip this instance runs (the missile's InFlight, else the file-order-first) — the same
// pick `arm_effect_rig` armed. Its **file sequence slot** is the key into each batch's
// per-sequence material-alpha loops: an effect that plays a non-first sequence must read that
@@ -408,20 +425,27 @@ pub(super) fn attach_effect_visuals(
blend: part.blend,
},
));
if let (true, Some(ibp), Some(_)) = (rigged, &dm.inverse_bindposes, &part.skinned_mesh)
{
child.insert(SkinnedMesh {
inverse_bindposes: ibp.clone(),
joints: joints.clone(),
});
if let (true, Some(_)) = (rigged, &part.skinned_mesh) {
child.insert((
crate::rig_palette::RigPart(root),
bevy::mesh::MeshTag(
crate::mesh_tag::rig_bits(rig_slot) | crate::mesh_tag::alpha_bits(1.0),
),
));
}
// The part's colour-alpha × weight loops, on this instance's clock: the sampler owns
// the child's render-alpha tag (`drives_tag` — no other writer touches fx parts).
// The rig field rides the whole-tag seed (decision 0720).
if let Some(anim) = &part.alpha_anim {
let mat_anim =
crate::doodad_anim::MatAnim::driving_tag(anim.clone(), now, played_seq);
let rig_tag = if rigged && part.skinned_mesh.is_some() {
crate::mesh_tag::rig_bits(rig_slot)
} else {
0
};
child.insert((
bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(mat_anim.current)),
bevy::mesh::MeshTag(rig_tag | crate::mesh_tag::alpha_bits(mat_anim.current)),
mat_anim,
));
}
@@ -562,7 +586,7 @@ pub(super) fn arm_effect_rig(
if dm.skeleton.joints.is_empty() {
return Vec::new();
}
let joints = super::spawn_joints(commands, root, &dm.skeleton);
let joints = super::spawn_joints(commands, root, root, &dm.skeleton);
// Billboard bones face the camera at the PALETTE level, children inheriting (the frost-armor
// sheets skin to a lock-Z bone's child) — the joint pass needs the map.
if let Some(bb) = crate::billboard::BillboardJointRig::new(&dm.skeleton, &joints, root) {
@@ -733,6 +757,7 @@ pub(super) fn attach_spell_fx(
mut wow_materials: ResMut<Assets<WowModelMaterial>>,
mut tint_reg: ResMut<FxTintAnims>,
ibps: Res<Assets<bevy::mesh::skinning::SkinnedMeshInverseBindposes>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
shared_light: Option<Res<crate::lighting::SharedLightBuffer>>,
) {
let (Some(fx), Some(light)) = (fx, shared_light) else {
@@ -778,9 +803,7 @@ pub(super) fn attach_spell_fx(
.chain(ATTACH_FALLBACKS)
.find_map(|tag| b.points.get(&tag).copied().map(|p| (tag, p)))
.and_then(|(tag, (bone, offset))| {
b.joints
.get(bone as usize)
.map(|&joint| (tag, joint, offset))
b.anchor(bone).map(|joint| (tag, joint, offset))
})
});
// Ground-anchored: the cascade landed on the model's BASE point (`0x13`) or fell
@@ -808,6 +831,7 @@ pub(super) fn attach_spell_fx(
&mut wow_materials,
&mut tint_reg,
&ibps,
&mut palettes,
&light,
None, // an attach-point kit effect runs its default (first) clip
);
+2
View File
@@ -192,6 +192,7 @@ pub(super) fn spawn_wmo_gameobject_props(
mut meshes: ResMut<Assets<Mesh>>,
mut particle_materials: ResMut<Assets<WowParticleMaterial>>,
mut probes: ResMut<PropProbes>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
) {
@@ -255,6 +256,7 @@ pub(super) fn spawn_wmo_gameobject_props(
&mut commands,
&mut entity_mats.0,
&mut materials,
&mut palettes,
light,
&m.submeshes,
prop.local, // doodad-LOCAL — the parent composes the world pose
+13 -10
View File
@@ -38,7 +38,6 @@ use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::lighting::{PropProbeSlot, PropProbes};
use crate::mesh_tag::alpha_bits;
use crate::model_fade::{PendingAppearFade, RenderFade};
use crate::terrain::WowModelMaterial;
use crate::terrain_stream::{fold_interior_probe, PropLobeLight, TerrainStreamer};
@@ -342,19 +341,23 @@ pub(crate) fn classify_entity_interior(
}
);
}
// The tag: the Bake law's payload carries the probe SLOT in bits 16-29 plus an opaque
// alpha field (`probe_bits` — a fade feather composes through `with_alpha` without
// clobbering the slot); the other laws reset to the opaque exterior payload (shade byte 0
// — `entity_shade` runs after this classifier and re-asserts the ramped intensity byte
// The tag: the Bake law's payload carries the probe SLOT in its bits-6..=18 field plus an
// opaque alpha field (a fade feather composes through `with_alpha` without clobbering the
// slot); the other laws reset to the opaque exterior payload (shade byte 0 —
// `entity_shade` runs after this classifier and re-asserts the ramped intensity byte
// the same frame; it skips only Bake parts). BOTH indoor laws carry the INTERIOR_FOG_BIT
// (Bake bakes it into `probe_bits`): the reference fogs a unit by the unit's OWN interior
// (Bake bakes it in): the reference fogs a unit by the unit's OWN interior
// classification, so an indoor day/night character keeps the room's fog — never the
// storm's near veil — while the exterior law returns it to the scene fog (wow-re
// `m2-unit-interior-fog.md`; the director's corridor-vs-porch walk-out).
// `m2-unit-interior-fog.md`; the director's corridor-vs-porch walk-out). Every arm
// carries the part's rig field through (decision 0720): a skinned part keeps its palette
// across the indoor/outdoor transition.
tag.0 = match law {
AppliedLaw::Bake(slot) => crate::mesh_tag::probe_bits(slot),
AppliedLaw::Matte => crate::mesh_tag::INTERIOR_FOG_BIT | alpha_bits(1.0),
AppliedLaw::Exterior => alpha_bits(1.0),
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
AppliedLaw::Matte => {
crate::mesh_tag::INTERIOR_FOG_BIT | crate::mesh_tag::with_exterior_reset(tag.0)
}
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
};
lit.applied = Some(law);
}
+8 -4
View File
@@ -211,13 +211,17 @@ pub(crate) fn new_shared_light_buffer(device: &RenderDevice) -> SharedLightBuffe
}
/// The full byte size of the shared light BUFFER: the per-frame blob ([`LightStd430`] — 19 header
/// rows + the point-light table) PLUS the interior-prop probe region at its tail. **Every buffer
/// bound as `wow_light` must be at least this big** — `wow_model.wgsl` declares the whole layout,
/// rows + the point-light table) PLUS the interior-prop probe region PLUS the skin-palette
/// regions (rig slot table + palette rows — decision 0720) at the tail. **Every buffer bound as
/// `wow_light` must be at least this big** — `wow_model.wgsl` declares the whole layout,
/// and wgpu validates bound size against the shader's struct at draw time. The portrait booth's
/// frozen studio-light buffer sizes itself with this (its table regions stay zeroed ⇒ no scene
/// point lights and black probes on portraits — the studio look is deliberately static).
/// point lights and black probes on portraits — the studio look is deliberately static); a
/// booth's PALETTE region is live, kept written by `rig_palette`'s mirror registry.
pub(crate) fn light_blob_bytes() -> u64 {
per_frame_blob_bytes() + (7 * MAX_PROP_PROBES * 16) as u64
per_frame_blob_bytes()
+ (7 * MAX_PROP_PROBES * 16) as u64
+ crate::rig_palette::palette_regions_bytes()
}
/// Byte size of the per-frame prefix alone (= the probe region's offset — see
+3 -1
View File
@@ -19,7 +19,9 @@ pub(crate) use global_light::{
commit_raw, light_blob_bytes, new_shared_light_buffer, pack_model_core_rows, SharedLightBuffer,
LIGHT_HEADER_ROWS,
};
pub(crate) use prop_probes::{prop_probe_region_offset, PropProbeSlot, PropProbes};
pub(crate) use prop_probes::{
prop_probe_region_offset, PropProbeSlot, PropProbes, MAX_PROP_PROBES,
};
pub(crate) use resolve::fog_range;
use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting};
pub(crate) use sh::prop_probe_coeffs;
+9 -4
View File
@@ -75,6 +75,7 @@ mod probe_shield;
mod quest_markers;
mod raid_marks;
mod ribbons;
mod rig_palette;
mod schedule;
mod sky;
mod sky_order;
@@ -228,10 +229,11 @@ fn main() {
// never fights the director's screen (decision 0703; `WOW_BG` overrides). See `bgwin`.
let background = bgwin::background_run();
if capturing {
// Ground clutter scatters with per-run randomness (the only non-deterministic element in the
// scene), so disable it for byte-stable baselines — clutter isn't what the lighting rework
// validates, and the regression diff must not be masked by grass wobble. Set before plugins
// build so `ClutterConfig::from_env` reads it.
// Ground clutter scatters with per-run randomness, so disable it for byte-stable baselines
// — clutter isn't what the lighting rework validates, and the regression diff must not be
// masked by grass wobble. Set before plugins build so `ClutterConfig::from_env` reads it.
// It is not the only source of per-run drift, though it was long documented as such: the
// other is the frame clock itself, frozen in `capture` (decision 0723).
std::env::set_var("WOW_CLUTTER_DENSITY", "0");
}
@@ -399,6 +401,9 @@ fn main() {
.add_plugins(InteractPlugin)
// M2 billboard cards (glow halos, chains) — faced to the camera each frame.
.add_plugins(BillboardPlugin)
// The owned skin palette (decision 0720): every skinned rig's joint matrices, computed by
// us and skinned in wow_model.wgsl — Bevy's SkinnedMesh lane is fully replaced.
.add_plugins(rig_palette::plugin)
.add_plugins(BowstringPlugin)
.add_plugins(QuestMarkersPlugin)
// Frame-time HUD + diagnostics — the performance standard.
+138 -71
View File
@@ -1,37 +1,42 @@
//! The per-instance `MeshTag` channel: **one home for its bit conventions and ownership protocol**
//! (decisions 0066, 0173 — the typed field layout).
//! (decisions 0066, 0173 — the typed field layout; 0720 — the rig field + the 6-bit alpha).
//!
//! Every `WowModelMaterial` submesh carries a Bevy `MeshTag` (a raw `u32` the shader reads per
//! instance). **Bits 31 and 30 are standalone flags; bits 0..=29 are the payload**, whose meaning
//! switches on **material state** — so per entity the payload has exactly one meaning at a time:
//! instance). **Bits 31 and 30 are standalone flags; bits 0..=29 are the payload.** The **rig
//! field** (bits 19..=29) and the **alpha field** (bits 0..=5) mean the same thing in every mode;
//! the bits between them switch meaning on **material state** — so per entity each bit has exactly
//! one meaning at a time:
//!
//! - **Highlight** ([`HIGHLIGHT_BIT`], bit 31): the hover/target model-brighten flag — the shader
//! adds the client's emissive lift to the lighting sum when set. Orthogonal by construction:
//! both payload modes below never set bit 31, and the shader masks it off before reading the
//! payload — so the untagged-⇒-opaque `0` sentinel still works on the masked value.
//! no payload mode ever sets bit 31, and the shader masks it off before reading the payload —
//! so the untagged-⇒-opaque `0` sentinel still works on the masked value.
//! - **Interior fog** ([`INTERIOR_FOG_BIT`], bit 30): this instance's model stands in a WMO
//! interior, so the shader fogs it with the INTERIOR triple (shared-light rows 18-19) instead of
//! the scene fog — the reference stages a unit's fog by the unit's OWN interior classification
//! (`0x71c110` → collector `+0x184..`, lane by `[node+0xc]`; wow-re `m2-unit-interior-fog.md`),
//! so an indoor character never carries the storm's near veil while the camera is inside.
//! (`0x71c110` → collector `+0x184..`, lane by `[node+0xc]`; wow-re `m2-unit-interior-fog.md`).
//! Carried by BOTH indoor laws: [`probe_bits`] bakes it in, and the entity classifier ORs it
//! onto the Matte (day/night) payload. Masked off with bit 31 before the payload decode.
//! - **Exterior payload** (the default): two typed fields (decision 0173's redesign of the old
//! whole-payload f32 alpha, triggered by the ground-shade writer 0066 anticipated):
//! - bits 0..=15 — the **fade alpha** as u16 (`0xffff` = opaque; 16 bits is far past perceptual
//! for a sub-second ramp), multiplying the cutout alpha. A whole payload of `0` is the shader's
//! *untagged ⇒ opaque* sentinel, so the alpha field is never legitimately `0` — a true zero
//! alpha writes `1` (≈0, invisible) instead ([`alpha_bits`]).
//! - bits 16..=23 — the **ground-shade byte** (`0` = lit, `255` = fully MCSH-shadowed): the
//! per-instance mix from the batch's lit sun level toward the shaded one (`wow_model.wgsl`).
//! Entities (units/players/GameObjects) ramp it per frame ([`crate::entity_shade`]); statics
//! (doodads/props) leave it `0` — their shade is the per-material selector (`sun_scale.x`).
//! - bits 24..=30 — reserved (0).
//! - **Rig slot** (bits 19..=29, BOTH payload modes — decision 0720): the instance's skin-palette
//! rig slot ([`crate::rig_palette`]), `0` = not skinned. The vertex stage reads it to index the
//! shared-buffer palette region; it is written ONCE at part spawn ([`rig_bits`]) and every
//! runtime writer below preserves it by construction (the `with_*` accessors carry bits the
//! writer doesn't own). 11 bits ⇔ [`MAX_RIG_SLOTS`] concurrent rigs.
//! - **Alpha** (bits 0..=5, BOTH payload modes): the fade alpha as a 6-bit fraction (`63` =
//! opaque), multiplying the cutout alpha. 64 steps is past perceptual for the sub-second fade
//! ramps that write it (it was u16 pre-0720 — the rig field bought its bits from here). A whole
//! payload of `0` is the shader's *untagged ⇒ opaque* sentinel, so the alpha field is never
//! legitimately `0` — a true zero alpha writes `1` (≈0, invisible) instead ([`alpha_bits`]).
//! - **Exterior payload** (the default): bits 6..=13 carry the **ground-shade byte** (`0` = lit,
//! `255` = fully MCSH-shadowed): the per-instance mix from the batch's lit sun level toward the
//! shaded one (`wow_model.wgsl`). Entities (units/players/GameObjects) ramp it per frame
//! ([`crate::entity_shade`]); statics (doodads/props) leave it `0` — their shade is the
//! per-material selector (`sun_scale.x`). Bits 14..=18 are reserved (0).
//! - **Interior probe slot** (interior M2 props/entities — material in interior mode,
//! `model_flags.z` set, not a WMO): bits 16..=29 carry the SH-probe TABLE SLOT (see
//! [`crate::lighting::PropProbes`]; 8192 slots < 2¹⁴), and bits 0..=15 stay the **fade alpha**,
//! exactly like the exterior payload — so a fade (the self-avatar zoom feather, a despawn ramp)
//! composes with the slot through [`with_alpha`] instead of clobbering it (the pre-0355 layout
//! `model_flags.z` set, not a WMO): bits 6..=18 carry the SH-probe TABLE SLOT (see
//! [`crate::lighting::PropProbes`]; 8192 slots = 13 bits), alpha and rig ride their fixed
//! fields — so a fade (the self-avatar zoom feather, a despawn ramp) and the skin palette both
//! compose with the slot through the accessors instead of clobbering it (the pre-0355 layout
//! put the slot in the alpha bits: a feathering indoor character lost its probe AND read shade
//! byte 0 = the lit exterior intensity — the director's "light jumps outdoor when I zoom in").
//! Slot 0 is a valid payload: [`probe_bits`] always carries a non-zero alpha field, so the
@@ -42,11 +47,13 @@
//! 1. `model_fade::apply_render_fade` (appear/despawn ramps) owns an entity's **alpha field** (and
//! material) while a `RenderFade` lives on it; the other alpha writers filter those entities out
//! (`Without<RenderFade>`, `Without<PendingAppearFade>`). It writes through [`with_alpha`], so
//! the shade field survives a fade.
//! the shade and rig fields survive a fade.
//! 2. `interior::classify_entity_interior` is the steady-state owner for interior-capable parts
//! (`InteriorLit` holders) — and of [`INTERIOR_FOG_BIT`]: probe slot + fog bit on the Bake law,
//! fog bit alone on the Matte law, plain `alpha_bits(1.0)` outdoors (dropping the shade byte —
//! the shade writer below re-asserts it the same frame, ordered after).
//! fog bit alone on the Matte law, plain opaque payload outdoors (dropping the shade byte —
//! the shade writer below re-asserts it the same frame, ordered after). Its whole-payload
//! writes go through [`with_interior_probe`] / [`with_exterior_reset`], which carry the rig
//! field.
//! 3. `debug_panel::apply_model_visibility` drives the small-prop distance fade (`DoodadFade`
//! holders) + glow-card dimming; `DoodadFade` is **never attached** to a lit interior prop
//! (spawn-time exclusion in `terrain_stream`), so 2 and 3 are disjoint.
@@ -82,45 +89,79 @@ pub(crate) const HIGHLIGHT_BIT: u32 = 0x8000_0000;
/// (wow-re `m2-unit-interior-fog.md`).
pub(crate) const INTERIOR_FOG_BIT: u32 = 0x4000_0000;
/// Bits 0..=15 of BOTH payload modes: the fade alpha as u16.
const ALPHA_MASK: u32 = 0x0000_ffff;
/// Bits 16..=23 of the exterior payload: the ground-shade byte.
const SHADE_MASK: u32 = 0x00ff_0000;
const SHADE_SHIFT: u32 = 16;
/// Bits 0..=5 of BOTH payload modes: the fade alpha as a 6-bit fraction (`63` = opaque).
const ALPHA_MASK: u32 = 0x0000_003f;
const ALPHA_MAX: f32 = 63.0;
/// Bits 6..=13 of the exterior payload: the ground-shade byte.
const SHADE_MASK: u32 = 0x0000_3fc0;
const SHADE_SHIFT: u32 = 6;
/// Bits 6..=18 of the interior payload: the SH-probe table slot (13 bits ⇔ 8192 slots).
const PROBE_MASK: u32 = 0x0007_ffc0;
const PROBE_SHIFT: u32 = 6;
/// Bits 19..=29 of BOTH payload modes: the skin-palette rig slot (decision 0720); `0` = no rig.
const RIG_MASK: u32 = 0x3ff8_0000;
const RIG_SHIFT: u32 = 19;
/// Rig slots addressable by the tag's 11-bit rig field (slot 0 = the "no rig" sentinel). The
/// palette allocator (`crate::rig_palette`) sizes itself to this.
pub(crate) const MAX_RIG_SLOTS: usize = 1 << 11;
/// An interior-probe payload: the slot in bits 16..=29 + an opaque alpha field + the
/// [`INTERIOR_FOG_BIT`] (a probe payload always means the model stands indoors). A fade writer
/// composes through [`with_alpha`], which preserves bits 16..=31 — the slot rides through a
/// feather. `wow_model.wgsl` reads the slot as `(tag >> 16) & 0x3fff` on interior-mode materials.
/// The ONE constructor for BOTH probe-payload writers — the static-prop spawner
/// (`terrain_stream::spawn`) and the entity classifier (`interior`): the 0355 re-lane moved the
/// slot and updated only the classifier; the spawner kept the old bits-0..=15 write, so every
/// static interior prop decoded slot 0 and lit from whichever probe won the streaming race.
/// An interior-probe payload constructor for RIG-LESS spawns (the static-prop spawner): the slot
/// in bits 6..=18 + an opaque alpha field + the [`INTERIOR_FOG_BIT`] (a probe payload always
/// means the model stands indoors). Rig-carrying writers (the entity classifier) go through
/// [`with_interior_probe`] instead, which preserves the rig field.
pub(crate) fn probe_bits(slot: u16) -> u32 {
INTERIOR_FOG_BIT | (u32::from(slot) << SHADE_SHIFT) | alpha_bits(1.0)
INTERIOR_FOG_BIT | (u32::from(slot) << PROBE_SHIFT) | alpha_bits(1.0)
}
/// A fade alpha as `MeshTag` bits: u16 in the low half, shade byte zero. Handles the shader's
/// `0`-sentinel: `MeshTag == 0` means *untagged ⇒ opaque 1.0* in `wow_model.wgsl`, so a true zero
/// (or negative) alpha returns `1` (≈1/65535, invisible) instead of an accidentally-opaque `0`.
/// Rewrite a tag as an interior-probe payload, preserving the rig field (and nothing else — the
/// classifier owns the rest of the payload when it fires): the entity classifier's Bake-law write
/// for skinned unit parts, whose rig slot must ride through the indoor/outdoor transitions.
pub(crate) fn with_interior_probe(tag: u32, slot: u16) -> u32 {
(tag & RIG_MASK) | probe_bits(slot)
}
/// Rewrite a tag as a fresh opaque exterior payload, preserving the rig field: the classifier's
/// outdoor reclaim (the shade writer re-asserts the shade byte the same frame, ordered after).
pub(crate) fn with_exterior_reset(tag: u32) -> u32 {
(tag & RIG_MASK) | alpha_bits(1.0)
}
/// The rig field of a part's spawn tag (decision 0720): the skin-palette rig slot, written once
/// when the skinned part spawns (composed with [`alpha_bits`]`(1.0)` or [`probe_bits`]). Every
/// runtime writer preserves it. Slot 0 is the "no rig" value — [`crate::rig_palette`] never
/// allocates it.
pub(crate) fn rig_bits(slot: u16) -> u32 {
debug_assert!((slot as usize) < MAX_RIG_SLOTS);
u32::from(slot) << RIG_SHIFT
}
/// Read back a tag's rig field (`0` = not skinned).
pub(crate) fn rig_of(tag: u32) -> u16 {
((tag & RIG_MASK) >> RIG_SHIFT) as u16
}
/// A fade alpha as `MeshTag` bits: 6-bit fraction in the low field, everything else zero. Handles
/// the shader's `0`-sentinel: `MeshTag == 0` means *untagged ⇒ opaque 1.0* in `wow_model.wgsl`,
/// so a true zero (or negative) alpha returns `1` (≈1/63, near-invisible) instead of an
/// accidentally-opaque `0`.
pub(crate) fn alpha_bits(alpha: f32) -> u32 {
if alpha <= 0.0 {
1u32
} else {
((alpha.min(1.0) * 65535.0).round() as u32).max(1)
((alpha.min(1.0) * ALPHA_MAX).round() as u32).max(1)
}
}
/// Write the alpha field of an exterior-payload tag, preserving the shade byte and the highlight
/// bit — the fade writers' read-modify-write (an appear-fade on a unit standing in MCSH shadow must
/// not flash it lit).
/// Write the alpha field of a tag, preserving every other field (shade or probe slot, rig, both
/// flag bits) — the fade writers' read-modify-write (an appear-fade on a unit standing in MCSH
/// shadow must not flash it lit, and a feathering skinned part must keep its palette).
pub(crate) fn with_alpha(tag: u32, alpha: f32) -> u32 {
(tag & !ALPHA_MASK) | alpha_bits(alpha)
}
/// Write the shade byte of an exterior-payload tag (`0` = lit … `255` = fully MCSH-shadowed),
/// preserving the alpha field and the highlight bit. If the alpha field reads `0`, the tag was the
/// whole-payload-`0` *untagged ⇒ opaque* sentinel (the field is never legitimately `0` —
/// preserving the alpha and rig fields and the flag bits. If the alpha field reads `0`, the tag
/// was the whole-payload-`0` *untagged ⇒ opaque* sentinel (the field is never legitimately `0` —
/// [`alpha_bits`] floors at `1`), so materialize it as opaque — otherwise a non-zero shade byte
/// would defeat the sentinel and the instance would decode alpha 0 (invisible).
pub(crate) fn with_shade(tag: u32, shade: u8) -> u32 {
@@ -143,9 +184,9 @@ pub(crate) fn shade_of(tag: u32) -> u8 {
/// one of its two writers kept the old bits). The masks stay private; this is the read-out.
///
/// The payload's meaning switches on **material state**, which a tag alone cannot know, so BOTH
/// readings of bits 16..=29 are printed side by side: `shade` is the exterior law's byte, `slot` the
/// readings of bits 6..=18 are printed side by side: `shade` is the exterior law's byte, `slot` the
/// interior law's probe index. A writer using the wrong law for its material is invisible in either
/// reading alone and obvious in the pair — a shade byte of 255 and a slot of 255 are the same bits.
/// reading alone and obvious in the pair — a shade byte of 255 sits inside a slot of 2047.
pub(crate) fn describe(tag: u32) -> String {
if tag == 0 {
return "0 (untagged ⇒ opaque)".to_string();
@@ -157,10 +198,11 @@ pub(crate) fn describe(tag: u32) -> String {
(false, false) => "",
};
format!(
"{tag:#010x}{flags} α {:.4} shade {} / slot {}",
(tag & ALPHA_MASK) as f32 / 65535.0,
"{tag:#010x}{flags} α {:.3} shade {} / slot {} rig {}",
(tag & ALPHA_MASK) as f32 / ALPHA_MAX,
shade_of(tag),
(tag >> SHADE_SHIFT) & 0x3fff,
(tag & PROBE_MASK) >> PROBE_SHIFT,
rig_of(tag),
)
}
@@ -170,10 +212,10 @@ mod tests {
#[test]
fn describe_prints_both_readings_of_the_shared_bits() {
// The 0-sentinel is called out by name rather than decoded as "α 0.0000" (invisible), which
// The 0-sentinel is called out by name rather than decoded as "α 0.000" (invisible), which
// is the one thing it never means.
assert!(describe(0).contains("untagged"));
// Exterior law: the shade byte. Interior law: the same bits as a slot. Both, always.
// Exterior law: the shade byte. Interior law: the same bits inside the slot. Both, always.
let t = with_shade(alpha_bits(1.0), 255);
assert!(describe(t).contains("shade 255"), "{}", describe(t));
assert!(describe(t).contains("slot 255"), "{}", describe(t));
@@ -181,6 +223,9 @@ mod tests {
let t = probe_bits(6660);
assert!(describe(t).contains("slot 6660"), "{}", describe(t));
assert!(describe(t).contains("fog"), "{}", describe(t));
// The rig field is printed from its own bits.
let t = rig_bits(1234) | alpha_bits(1.0);
assert!(describe(t).contains("rig 1234"), "{}", describe(t));
// Both standalone flags are named, and neither is mistaken for payload.
assert!(describe(HIGHLIGHT_BIT | alpha_bits(0.5)).contains("hi"));
assert!(describe(HIGHLIGHT_BIT | INTERIOR_FOG_BIT | 1).contains("hi+fog"));
@@ -193,14 +238,15 @@ mod tests {
assert_eq!(alpha_bits(a) & HIGHLIGHT_BIT, 0);
}
assert_eq!(with_shade(alpha_bits(1.0), 255) & HIGHLIGHT_BIT, 0);
// The interior payload is a u16 probe slot — structurally below bit 31.
assert_eq!(probe_bits(8191) & HIGHLIGHT_BIT, 0);
assert_eq!(rig_bits(2047) & HIGHLIGHT_BIT, 0);
// Both field writers preserve an already-set flag.
assert_eq!(
with_alpha(HIGHLIGHT_BIT | 0x00ff_ffff, 0.5) & HIGHLIGHT_BIT,
with_alpha(HIGHLIGHT_BIT | 0x3fff_ffff, 0.5) & HIGHLIGHT_BIT,
HIGHLIGHT_BIT
);
assert_eq!(
with_shade(HIGHLIGHT_BIT | 0xffff, 7) & HIGHLIGHT_BIT,
with_shade(HIGHLIGHT_BIT | 0x3f, 7) & HIGHLIGHT_BIT,
HIGHLIGHT_BIT
);
}
@@ -210,9 +256,9 @@ mod tests {
assert_eq!(alpha_bits(0.0), 1);
assert_eq!(alpha_bits(-0.5), 1);
assert_ne!(alpha_bits(f32::MIN_POSITIVE), 0);
assert_eq!(alpha_bits(1.0), 0xffff);
// Full alpha and the shade byte occupy disjoint fields.
assert_eq!(alpha_bits(1.0) & SHADE_MASK, 0);
assert_eq!(alpha_bits(1.0), ALPHA_MASK);
// Full alpha stays inside its field — disjoint from shade, probe, and rig.
assert_eq!(alpha_bits(1.0) & (SHADE_MASK | PROBE_MASK | RIG_MASK), 0);
}
#[test]
@@ -230,20 +276,41 @@ mod tests {
#[test]
fn probe_bits_compose_with_the_alpha_field() {
// The slot rides bits 16..=29; a fade write must preserve it (the zoom-feather bug).
// The slot rides bits 6..=18; a fade write must preserve it (the zoom-feather bug).
let t = probe_bits(6660);
assert_eq!((t >> 16) & 0x3fff, 6660);
assert_eq!(t & ALPHA_MASK, 0xffff); // opaque by default — the 0-sentinel can't fire
assert_eq!((t & PROBE_MASK) >> PROBE_SHIFT, 6660);
assert_eq!(t & ALPHA_MASK, ALPHA_MASK); // opaque by default — the 0-sentinel can't fire
let t = with_alpha(t, 0.25);
assert_eq!((t >> 16) & 0x3fff, 6660); // slot survives the feather
assert_eq!((t & PROBE_MASK) >> PROBE_SHIFT, 6660); // slot survives the feather
assert_eq!(t & ALPHA_MASK, alpha_bits(0.25));
assert_eq!(t & HIGHLIGHT_BIT, 0);
// The max slot (8191) stays inside bits 16..=29: the highlight bit stays clear, and the
// The max slot (8191) stays inside bits 6..=18: the rig field stays clear, and the
// interior-fog flag is BAKED IN (a probe payload always fogs interior).
assert_eq!(probe_bits(8191) & HIGHLIGHT_BIT, 0);
assert_eq!(probe_bits(8191) & RIG_MASK, 0);
assert_eq!(probe_bits(8191) & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
}
#[test]
fn rig_field_survives_every_runtime_writer() {
// The rig slot is written once at spawn; every steady-state writer must carry it.
let spawn = rig_bits(1000) | alpha_bits(1.0);
assert_eq!(rig_of(spawn), 1000);
assert_eq!(rig_of(with_alpha(spawn, 0.3)), 1000); // fades (writers 1, 3, 4)
assert_eq!(rig_of(with_shade(spawn, 200)), 1000); // the ground-shade ramp (writer 5)
let indoor = with_interior_probe(spawn, 4321); // the classifier's Bake law (writer 2)
assert_eq!(rig_of(indoor), 1000);
assert_eq!((indoor & PROBE_MASK) >> PROBE_SHIFT, 4321);
assert_eq!(indoor & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
let outdoor = with_exterior_reset(indoor); // …and its outdoor reclaim
assert_eq!(rig_of(outdoor), 1000);
assert_eq!(outdoor & (PROBE_MASK | INTERIOR_FOG_BIT), 0);
assert_eq!(outdoor & ALPHA_MASK, ALPHA_MASK);
// Alpha and probe compose under the rig field exactly as they did without it.
let t = with_alpha(indoor, 0.5);
assert_eq!(rig_of(t), 1000);
assert_eq!((t & PROBE_MASK) >> PROBE_SHIFT, 4321);
}
#[test]
fn interior_fog_bit_survives_the_field_writers() {
// The bit is owned by the classifier's whole-payload writes; the alpha/shade
@@ -254,9 +321,9 @@ mod tests {
assert_eq!(with_shade(t, 191) & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
// And it never leaks into the payload fields it rides above.
assert_eq!(shade_of(t), 0);
assert_eq!(t & ALPHA_MASK, 0xffff);
assert_eq!(t & ALPHA_MASK, ALPHA_MASK);
// A probe payload keeps its slot decode with the flag set.
assert_eq!((probe_bits(6660) >> 16) & 0x3fff, 6660);
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
}
#[test]
@@ -264,10 +331,10 @@ mod tests {
// Shading an untagged (payload 0) instance must not defeat the "0 ⇒ opaque" rule by making
// the payload non-zero with a zero alpha field.
let t = with_shade(0, 128);
assert_eq!(t & ALPHA_MASK, 0xffff);
assert_eq!(t & ALPHA_MASK, ALPHA_MASK);
assert_eq!(shade_of(t), 128);
// Same through the highlight bit (payload still reads 0 under the mask).
let t = with_shade(HIGHLIGHT_BIT, 128);
assert_eq!(t & ALPHA_MASK, 0xffff);
assert_eq!(t & ALPHA_MASK, ALPHA_MASK);
}
}
+27 -1
View File
@@ -40,7 +40,7 @@ use motion::{
drain_pending_moves, extrapolate_remote_units, face_target, ground_clamp_creatures,
mark_swimming_creatures, sample_splines,
};
pub(crate) use motion::{CreatureSwimming, FacingStep, RemoteMotion, Spline};
pub(crate) use motion::{jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline};
/// The net subsystem: spawns the background IO threads and drives the per-frame event drain.
pub(crate) struct NetPlugin {
@@ -73,6 +73,7 @@ impl Plugin for NetPlugin {
.init_resource::<crate::items::Items>()
.init_resource::<crate::world_state::WorldStates>()
.add_message::<TeleportMessage>()
.add_message::<SelfMoveMessage>()
.add_message::<SpeedChangeMessage>()
.add_message::<ServerSoundMessage>()
.add_message::<WeatherMessage>()
@@ -1138,6 +1139,31 @@ pub(crate) struct TeleportMessage {
pub(crate) orientation: f32,
}
/// A **server-authored move for our own mover** — an inbound `MSG_MOVE_*` whose guid is ours. It is
/// never an echo of our own reporting: vmangos stamps every one `MovementInfo::SetAsServerSide`
/// (`ctime = 0`, "not a client packet"), and the senders are all server-side edges with no
/// handshake and no ack owed — `.go forward`/`up`/`relative` (`Unit::NearLandTo` →
/// `MSG_MOVE_FALL_LAND`), `.cheat fly`/`fixedz` and the movement anticheat's snap-back
/// (`SendHeartBeat(true)` → `MSG_MOVE_HEARTBEAT`).
///
/// **The real client applies these** — its inbound move path has no mover-guid gate at all, and the
/// local player sits in the object manager under its own guid, so a self-addressed packet resolves
/// and applies exactly like a remote's (wow-re `system/collision/scratch/self-addressed-move.md`;
/// decision 0725, which corrects the drop this used to take). Our avatar's motion source is the
/// controller rather than the `RemoteMotion` lane, so the pose crosses as this message and lands in
/// `player::wire_in`.
#[derive(Message, Clone, Copy)]
pub(crate) struct SelfMoveMessage {
pub(crate) position: [f32; 3],
pub(crate) orientation: f32,
/// The wire's `MOVEMENTFLAGS`. Merged into ours under a mask, never assigned — see
/// [`crate::creature_anim::move_flags::SERVER_AUTHORED`].
pub(crate) flags: u32,
pub(crate) pitch: f32,
pub(crate) fall_time: u32,
pub(crate) jump: Option<benilla_protocol::JumpInfo>,
}
/// The server changed one of our own mover's speeds (`SMSG_FORCE_*_SPEED_CHANGE` — aura, mount, GM
/// `.modify speed`). Written by [`apply_net_updates`] (which also applies the new value to the
/// entity's [`UnitSpeeds`], and only forwards changes addressed to our guid); read by the player
+5
View File
@@ -87,6 +87,9 @@ pub(super) fn apply_net_updates(
// The server's own answer to a GM dot-command, readable rather than only logged — the
// probe shield's confirmation channel (decision 0677).
MessageWriter<super::ServerSaidMessage>,
// A `MSG_MOVE_*` the server addressed to OUR mover (decision 0725): a pose it wrote, with
// no handshake — the controller applies it in `player::wire_in`.
MessageWriter<super::SelfMoveMessage>,
),
// One tuple param (the 16-SystemParam ceiling): the ask-once query caches + the gossip/merchant
// state the net drain fills for the NPC-interaction windows (decision 0081).
@@ -276,6 +279,7 @@ pub(super) fn apply_net_updates(
mut login_failures,
mut disconnects,
mut server_said,
mut self_moves,
) = session_msgs;
// Descriptor seeds/deltas for objects created *earlier in this same drain* can't land on their
// entities yet (the spawn `Command` hasn't run), so they accumulate here and flush once at the end.
@@ -456,6 +460,7 @@ pub(super) fn apply_net_updates(
&mut remote_motion,
&mut transforms,
&mut audio.13,
&mut self_moves,
);
}
SessionEvent::ObjectValues { guid, fields } => {
+15 -2
View File
@@ -237,10 +237,15 @@ pub(super) fn unit_move(
remote_motion: &mut Query<&mut RemoteMotion>,
transforms: &mut Query<&mut Transform>,
landings: &mut MessageWriter<crate::creature_anim::HardLanding>,
self_moves: &mut MessageWriter<crate::net::SelfMoveMessage>,
) {
use crate::net::motion::{apply_move, arrival_snap, trace_relay, PendingMove, RelayOutcome};
// A relayed move belongs to another player; never let one drive our own avatar (the
// controller owns it, and the server doesn't echo our moves back anyway).
// Addressed to US: the server writing our own pose, never an echo of ours (every one is
// `SetAsServerSide`, `ctime = 0`). The reference APPLIES it — there is no mover-guid gate
// anywhere on its inbound move path, and the local player resolves through the same object
// lookup as anyone else (decision 0725; wow-re `self-addressed-move.md`). What is ours is only
// *where it goes*: our avatar's motion source is the controller, not [`RemoteMotion`], so the
// pose crosses to `player::wire_in` instead of down this lane.
if self_guid.0 == Some(guid) {
trace_relay(
guid,
@@ -250,6 +255,14 @@ pub(super) fn unit_move(
0,
RelayOutcome::SelfMover,
);
self_moves.write(crate::net::SelfMoveMessage {
position: mv.position,
orientation: mv.orientation,
flags: mv.flags,
pitch: mv.pitch,
fall_time: mv.fall_time,
jump: mv.jump,
});
return;
}
let Some(&e) = index.0.get(&guid) else {
+1
View File
@@ -32,6 +32,7 @@ mod tests;
pub(crate) use facing::FacingStep;
pub(super) use facing::{face_target, resolve_facing};
pub(crate) use relay::{PendingMove, RelayMove};
pub(crate) use remote::jump_seed;
pub(crate) use remote::RemoteMotion;
pub(in crate::net) use remote::{apply_move, arrival_snap, trace_relay, RelayOutcome};
pub(super) use remote::{drain_pending_moves, extrapolate_remote_units};
+4 -2
View File
@@ -88,7 +88,9 @@ pub(in crate::net) enum RelayOutcome {
Queued,
/// The unit's first packet: seeds the chain and places it immediately.
Seed,
/// Ours. The controller owns our avatar; the server never echoes our own moves anyway.
/// **Ours** — a server-authored pose for our own mover (`.go forward`, `.cheat fly`, an
/// anticheat snap-back). Handed to the controller as a [`crate::net::SelfMoveMessage`] rather
/// than applied down this lane; the reference has no mover-guid gate at all (decision 0725).
SelfMover,
/// **No entity for this guid.** The mover isn't in our object index — so this packet, Stop or
/// not, changes nothing. A stale mover that keeps running while these accumulate is a streaming
@@ -625,7 +627,7 @@ pub(in crate::net) fn extrapolate_remote_units(
/// and the current up-speed is `-zspeed - g·t`. Mirrors vmangos `Unit.cpp` `ExtrapolateMovement`
/// (`z = start.z + jumpInitialSpeed·t - ½g·t²`, `jumpInitialSpeed = -zspeed`) under the same `gravity`
/// (decision 0053; sign corrected by the sniff — decision 0054).
pub(in crate::net) fn jump_seed(jump: Option<JumpInfo>, fall_time: u32) -> (f32, [f32; 2]) {
pub(crate) fn jump_seed(jump: Option<JumpInfo>, fall_time: u32) -> (f32, [f32; 2]) {
match jump {
Some(j) => {
let t = fall_time as f32 / 1000.0;
+37
View File
@@ -483,6 +483,43 @@ fn relay_chain_seeds_holds_stale_stamps_and_survives_the_clock_wrap() {
);
}
/// **What the chain would do with a server-authored SELF move** — the measurement decision 0725's
/// inline apply rests on, rather than an assertion about it. The reference has one move machine and
/// routes a self-addressed `MSG_MOVE_*` (a GM `.go forward`, a `.cheat fly` toggle, an anticheat
/// snap-back) through this same chain, so the honest question is what fire-time it hands one.
///
/// Two arms, and the second is the one that matters. **Fresh chain → arrival**: the local mover's
/// chain is fed by these packets and nothing else, and in ordinary play none arrive at all, so the
/// first one is a seed and fires immediately. **Seeded chain → the sender's cadence, held**: once
/// there are stamps to pace against, a packet that arrives *earlier* than the previous fire plus
/// the wire step is deliberately delayed to preserve the spacing the stamps carry. That is the
/// chain's headline property working exactly as designed — and it is the property benilla's self
/// arm skips, because reproducing the "cadence" between one GM command and the next buys nothing
/// while delaying a correction to our own pose.
///
/// The stamps are vmangos's own: `SetAsServerSide` writes a fresh `WorldTimer::getMSTime()` into
/// `stime`, so the wire steps track the real time between the commands.
#[test]
fn the_chain_paces_a_server_authored_self_move_and_would_hold_an_early_one() {
let mut chain = RelayChain::default();
// Standing still, nothing queued — the state a GM command finds us in.
assert_eq!(
chain.schedule(1_000, 0.0, 0, true),
0.0,
"the first server-authored move seeds the chain and fires at arrival"
);
// 30 s later, arriving 140 ms behind the chain's pacing: the pacing law says fire at arrival
// (a late packet is already overdue), and the lateness enters the window.
assert_eq!(chain.schedule(31_000, 30_140.0, 0, true), 30_140.0);
// 30 s later again, arriving 90 ms *ahead* of the pacing. The chain holds it back to keep the
// sender's spacing — so the reference would apply this one 90 ms after it landed.
assert_eq!(
chain.schedule(61_000, 60_050.0, 0, true),
60_140.0,
"an early arrival is held to the sender's cadence — the pacing benilla's self arm skips"
);
}
/// The de-jitter buffer is re-sized **only** on a standing mover with an empty queue (`@0x618ce4` /
/// `@0x618cf3`), and it is sized by the window's worst lateness (`0x618b50`) — then held, not
/// re-charged: the ring stores lateness *relative to the base*, so a spike already absorbed doesn't
+2 -1
View File
@@ -873,7 +873,8 @@ impl Plugin for ParticlePlugin {
)
.chain()
.in_set(crate::billboard::BillboardPlace)
.after(crate::billboard::billboard_joint_palette),
.after(crate::billboard::billboard_joint_palette)
.after(crate::creature_anim::finalize_rig_worlds),
);
}
}
+4
View File
@@ -231,6 +231,9 @@ fn control(
// The mounted space-bar flourish (decision 0441 P2): our own MountSpecial(94) plays
// locally at send time; the net drain self-suppresses any broadcast echo.
MessageWriter<crate::creature_anim::MountFlourish>,
// A `MSG_MOVE_*` the server addressed to our OWN mover (decision 0725) — a pose it wrote,
// with no handshake and no ack owed. `wire_in` snaps to it.
MessageReader<crate::net::SelfMoveMessage>,
),
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
speed_capsule: (
@@ -407,6 +410,7 @@ fn control(
&mut net.4,
&mut net.5,
&mut net.6,
&mut net.10,
transports,
self_player.single().ok().map(|(_, t, ..)| t.translation),
);
+182 -7
View File
@@ -1,10 +1,17 @@
//! Server-authored movement edges applied to our mover, each with its mandatory ack — the inbound
//! mirror of [`super::movement_net`] (which streams our own movement out). One entry point,
//! Server-authored movement edges applied to our mover — the inbound mirror of
//! [`super::movement_net`] (which streams our own movement out). One entry point,
//! [`apply_server_moves`], called by [`super::control`] before input integrates: cross-map
//! worldports (incl. the riding-through-the-seam branch, decision 0455), same-map teleports,
//! root/unroot (death — decision 0308), water-walk grants, the one-shot take-control edge, and
//! the pre-control forced-speed acks (the controlled branch answers those through the movement
//! stream's own per-frame payload instead — the returned list).
//! root/unroot (death — decision 0308), water-walk grants, the one-shot take-control edge, the
//! pre-control forced-speed acks (the controlled branch answers those through the movement
//! stream's own per-frame payload instead — the returned list), and the **bare self-addressed
//! move** ([`apply_self_move`], decision 0725).
//!
//! All but the last carry a **mandatory ack**, which is what made the last one easy to miss: a
//! `MSG_MOVE_*` the server addresses to our own guid arrives with no handshake and owes no answer,
//! and this client used to discard it. The real client applies it — its inbound move path has no
//! mover-guid gate at all — and sends nothing back; the next ordinary heartbeat carries the
//! server's own pose home, which is what makes `.go forward` stick.
use bevy::prelude::*;
@@ -13,8 +20,8 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use crate::creature_anim::{move_flags, wrap_pi};
use crate::death::{MoveRootMessage, WaterWalkMessage};
use crate::net::{
ClientCommand, Guid, MoveKind, NetCommands, SelfPlayer, SpeedChangeMessage, TeleportMessage,
WorldportMessage,
ClientCommand, Guid, MoveKind, NetCommands, SelfMoveMessage, SelfPlayer, SpeedChangeMessage,
TeleportMessage, WorldportMessage,
};
use crate::transport::Transport;
use crate::world_map::CurrentMap;
@@ -40,6 +47,7 @@ pub(super) fn apply_server_moves(
speed_msgs: &mut MessageReader<SpeedChangeMessage>,
root_msgs: &mut MessageReader<MoveRootMessage>,
waterwalk_msgs: &mut MessageReader<WaterWalkMessage>,
self_moves: &mut MessageReader<SelfMoveMessage>,
transports: &Query<
(&Transform, &Guid),
(With<Transport>, Without<SelfPlayer>, Without<FlyCam>),
@@ -163,6 +171,15 @@ pub(super) fn apply_server_moves(
t.position
);
}
// A bare server-authored move for our own mover (decision 0725) — no handshake, no ack. Drained
// after the teleport arms deliberately: a teleport in the same frame is the larger edge (it
// swaps maps, holds the settle and owes an ack), so it wins the pose.
for m in self_moves.read() {
if player.active {
apply_self_move(m, player, cam, time, transports);
}
}
// Server root/unroot on our mover (death/release — decision 0308): apply the change locally,
// THEN ack with the resulting flags — the real client's shape, and the server's law: a
// root-apply ack whose MovementInfo lacks MOVEFLAG_ROOT is a KICK (vmangos
@@ -254,6 +271,109 @@ pub(super) fn apply_server_moves(
speed_acks
}
/// Merge a server-authored packet's `MOVEMENTFLAGS` into our own — the reference's masked merge
/// (`0x618c30 @0x618deb`: `new = old ^ ((old ^ wire) & 0x75a07dff)`), not an assignment. Pure, so
/// the omission that actually bites is pinned by test: `ON_TRANSPORT` sits **outside** the mask, so
/// a server-authored pose can relocate a rider but never board or deboard them. See
/// [`move_flags::SERVER_AUTHORED`].
pub(super) fn merge_server_flags(local: u32, wire: u32) -> u32 {
(local & !move_flags::SERVER_AUTHORED) | (wire & move_flags::SERVER_AUTHORED)
}
/// Apply one bare self-addressed `MSG_MOVE_*` — a pose the *server* wrote for our own mover, with
/// no handshake (decision 0725; wow-re `self-addressed-move.md`). `.go forward`/`up`/`relative`,
/// `.cheat fly`/`fixedz` and the movement anticheat's snap-back all land here.
///
/// **A hard snap, and nothing goes back.** The reference writes the wire pose into both its live
/// position cell and its integrator *base* (`0x7c6420`), which is what makes the snap persist —
/// the next frame integrates forward from the server's point instead of rubber-banding off it. For
/// us those are one cell ([`Player::pos`]), so the snap is the whole of it. It sends no ack and
/// opens no suppression window: our ordinary heartbeat then carries the server's own pose home,
/// which is precisely why `.go forward` sticks.
///
/// **Applied inline, not scheduled** — the one place this departs from the reference's shape, and
/// it is a deliberate divergence rather than a shortcut. The reference has a single move machine
/// and routes a self-addressed packet through the same replay chain as a remote's (decisions
/// 0601/0615). Measured against that chain (`net::motion::tests`'
/// `the_chain_paces_a_server_authored_self_move_and_would_hold_an_early_one`) it does **not** always
/// come back due: the first one fires at arrival, but once the chain has stamps to pace against, a
/// packet arriving ahead of the sender's cadence is deliberately *held* — bounded at +1000 ms.
///
/// That holding is the chain's whole purpose and it is right for a remote: replaying a mover on the
/// sender's own spacing is what stops a watched player stuttering between packets. It buys nothing
/// here. Our avatar is not something we extrapolate between packets — there is no motion to
/// de-jitter — so preserving the "cadence" between one GM command and the next would only delay a
/// correction to our own pose behind a queue the controller would have to yield to. We take the
/// snap at arrival. The trigger to revisit is a self-addressed *stream*: `Anticheat.Enable = 1`,
/// whose snap-backs can burst, is the one sender that would produce one.
// The transports query type is `control`'s own param shape passed through, like the caller's.
#[allow(clippy::type_complexity)]
fn apply_self_move(
m: &SelfMoveMessage,
player: &mut Player,
cam: &mut FlyCam,
time: &Time,
transports: &Query<
(&Transform, &Guid),
(With<Transport>, Without<SelfPlayer>, Without<FlyCam>),
>,
) {
let was_falling = player.move_flags & move_flags::FALLING != 0;
player.move_flags = merge_server_flags(player.move_flags, m.flags);
let now_falling = player.move_flags & move_flags::FALLING != 0;
player.pos = wow_to_bevy(m.position);
// Facing turns **rigidly** — aim, rendered body and camera all take the same delta (the
// transport carry's idiom). The reference writes only the mover's own facing cells; hard-setting
// the camera the way the teleport arm does would yank the view on every `.cheat fly` toggle,
// whose heartbeat carries nothing but the server's slightly-stale copy of our own orientation.
let dyaw = wrap_pi(m.orientation - player.face_yaw);
player.face_yaw = wrap_pi(player.face_yaw + dyaw);
player.model_yaw = wrap_pi(player.model_yaw + dyaw);
cam.yaw += dyaw;
player.swim_pitch = m.pitch;
// Riding a deck: `ON_TRANSPORT` is outside the merge mask, so this packet did not deboard us —
// it moved us **within** the platform frame. Re-anchor the local pose from the boat's live
// transform, or next frame's carry recomposes the stale one and undoes the snap.
if let Some(ride) = player.ride.as_mut() {
if let Ok((boat, _)) = transports.get(ride.entity) {
ride.local_pos = boat.rotation.inverse() * (player.pos - boat.translation);
ride.boat_yaw = boat.rotation.to_euler(EulerRot::YXZ).0;
}
}
// The airborne arc. The reference gates the fall tail on the *merged* `FALLING` bit — a data
// gate, not an identity one — seeding the arc from the wire when it is set (`0x7c6490`) and
// running the landing reaction when it just cleared.
match (was_falling, now_falling) {
(_, true) => {
let (vel_y, xy) = crate::net::jump_seed(m.jump, m.fall_time);
let t = m.fall_time as f32 / 1000.0;
player.airborne_since = Some(time.elapsed_secs() - t);
player.jump_zspeed = m.jump.map_or(0.0, |j| -j.zspeed);
player.vel_y = vel_y;
player.horiz_vel = wow_to_bevy([xy[0], xy[1], 0.0]);
// Where this arc launched, recovered from the pose it is at now: `z = z₀ + v₀t − ½gt²`.
player.fall_start_y =
player.pos.y - (player.jump_zspeed * t - 0.5 * crate::player::GRAVITY * t * t);
player.fall_far = player.move_flags & move_flags::FALLING_FAR != 0;
player.airborne_dirs = player.move_flags & move_flags::ANY_MOVE;
}
(true, false) => {
// The arc is over by server decree (`NearLandTo` strips `JUMPING|FALLINGFAR` before it
// sends). Ended **silently**: our landing report is a fall *height*, and the same packet
// just moved the body an arbitrary distance, so there is no descent left to measure.
player.airborne_since = None;
player.fall_far = false;
player.vel_y = 0.0;
}
(false, false) => {}
}
player.wedged = false;
player.wedge_still = 0;
}
/// A confirmed `/logout` (decision 0193): drop control so the next login re-takes it from its own
/// streamed `SelfPlayer` — possibly a different character on a different map (the boot path). The
/// avatar entity itself is despawned by the net drain the same frame this message is written.
@@ -269,3 +389,58 @@ pub(super) fn release_on_logout(
player.wedge_still = 0;
}
}
#[cfg(test)]
mod self_move_tests {
use super::merge_server_flags;
use crate::creature_anim::move_flags as f;
/// The merge is not an assignment, and the bit that proves it is `ON_TRANSPORT`: it sits
/// outside the reference's `0x75a07dff` mask, so a server-authored pose relocates a rider on
/// the deck without ever boarding or deboarding them. Everything else we model is inside, and
/// the wire owns it — including `FALLING`, whose clearing is how `.go forward` ends an arc.
#[test]
fn a_server_move_owns_the_wire_bits_and_leaves_the_transport_bit_alone() {
// Riding a boat, running forward. The server says: standing still, falling, not on a boat.
let local = f::ON_TRANSPORT | f::FORWARD;
let wire = f::FALLING;
let merged = merge_server_flags(local, wire);
assert_eq!(
merged & f::ON_TRANSPORT,
f::ON_TRANSPORT,
"the packet must not deboard a rider — bit 25 is the client's"
);
assert_eq!(merged & f::FALLING, f::FALLING, "the wire owns FALLING");
assert_eq!(merged & f::FORWARD, 0, "and it owns the direction bits too");
// The other direction: the wire claiming ON_TRANSPORT cannot board us either.
assert_eq!(merge_server_flags(0, f::ON_TRANSPORT), 0);
}
/// Every flag benilla models except `ON_TRANSPORT` is inside the mask — a guard against the
/// mask and our constants drifting apart as new bits get modelled.
#[test]
fn every_modelled_flag_but_the_transport_bit_is_server_authored() {
for (name, bit) in [
("FORWARD", f::FORWARD),
("BACKWARD", f::BACKWARD),
("STRAFE_LEFT", f::STRAFE_LEFT),
("STRAFE_RIGHT", f::STRAFE_RIGHT),
("TURN_LEFT", f::TURN_LEFT),
("TURN_RIGHT", f::TURN_RIGHT),
("WALK_MODE", f::WALK_MODE),
("ROOT", f::ROOT),
("FALLING", f::FALLING),
("FALLING_FAR", f::FALLING_FAR),
("SWIMMING", f::SWIMMING),
("WATER_WALKING", f::WATER_WALKING),
] {
assert_eq!(
bit & f::SERVER_AUTHORED,
bit,
"{name} must be inside the mask"
);
}
assert_eq!(f::ON_TRANSPORT & f::SERVER_AUTHORED, 0);
}
}
+30 -9
View File
@@ -71,6 +71,7 @@ pub(super) enum BoothMotion {
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_booth_model(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
root: Entity,
layer: RenderLayers,
parts: &[BoothPart],
@@ -92,13 +93,14 @@ pub(super) fn spawn_booth_model(
// no eye bone); the boneless bake below drops them. `&[]` for booths that dress none.
billboards: &[BoothBillboardSpec],
) -> Vec<Entity> {
use bevy::mesh::skinning::SkinnedMesh;
// A re-bake must not inherit the previous model's animation state on the shared root — nor a
// stale global-sequence drive holding the despawned joints.
// stale global-sequence drive holding the despawned joints, nor the previous rig's palette
// slot (`RigSkin`'s on_replace hook frees it; the boneless bake below has no new one).
commands.entity(root).remove::<(
AnimationPlayer,
AnimationGraphHandle,
crate::creature_anim::GlobalSeqDrive,
crate::rig_palette::RigSkin,
)>();
let Some((skeleton, ibp, anims)) = rig.filter(|(s, _, _)| !s.joints.is_empty()) else {
for p in parts {
@@ -112,7 +114,19 @@ pub(super) fn spawn_booth_model(
}
return Vec::new();
};
let joints = crate::entities::spawn_joints(commands, root, skeleton);
let joints = crate::entities::spawn_joints(commands, root, root, skeleton);
// The owned palette rig (decision 0720): the booth's skinned parts tag this slot; the palette
// compute reads these joints like any world rig, and the booth's studio light buffer mirrors
// the palette region (`rig_palette::RigPaletteMirrors`), so the booth camera sees the pose.
let rig_slot = crate::rig_palette::RigSkin::allocate(palettes, joints.clone(), ibp.clone())
.map_or(0, |rig| {
let slot = rig.slot;
commands.entity(root).insert(rig);
// Booth materials bind a STUDIO light buffer, not the shared one — route this
// rig's rows to the registered mirrors too.
palettes.mark_mirrored(slot);
slot
});
// The model's global-sequence bone channels, by motion (decision 0539 §5):
// - **Loop** (the glue scenes + the create/select character): LIVE, on the world's own
// clock-driven sampler — the login gate's fires flicker, the Tauren windmill turns, the
@@ -155,7 +169,12 @@ pub(super) fn spawn_booth_model(
}
}
for p in parts {
let mesh = p.skinned.clone().unwrap_or_else(|| p.static_mesh.clone());
let use_rig = p.skinned.is_some() && rig_slot != 0;
let mesh = if use_rig {
p.skinned.clone().expect("use_rig ⇒ skinned twin present")
} else {
p.static_mesh.clone()
};
let mut child = commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(p.material.clone()),
@@ -163,11 +182,13 @@ pub(super) fn spawn_booth_model(
layer.clone(),
ChildOf(root),
));
if p.skinned.is_some() {
child.insert(SkinnedMesh {
inverse_bindposes: ibp.clone(),
joints: joints.clone(),
});
if use_rig {
child.insert((
crate::rig_palette::RigPart(root),
bevy::mesh::MeshTag(
crate::mesh_tag::rig_bits(rig_slot) | crate::mesh_tag::alpha_bits(1.0),
),
));
}
}
for r in riders {
+12 -4
View File
@@ -408,7 +408,7 @@ pub(super) fn spawn_glue_booth(
/// seat the character on the stage spot, and hold the booth camera on the scene's authored camera 0
/// — every frame, so the per-bake body framing from [`sync_glue_booth`] never wins while a scene
/// shows. Leaving the screen (`look: None`) tears the scene down and restores the square target.
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn sync_glue_scene(
mut commands: Commands,
preview: Res<GluePreview>,
@@ -425,14 +425,16 @@ pub(super) fn sync_glue_scene(
window: Query<&Window, With<PrimaryWindow>>,
device: Res<bevy::render::renderer::RenderDevice>,
queue: Res<bevy::render::renderer::RenderQueue>,
// The scene's particle-emitter spawn wiring (decision 0539 §5) — one tuple param (the
// 16-SystemParam ceiling).
// 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).
particle_assets: (
ResMut<Assets<Mesh>>,
ResMut<Assets<crate::particles::WowParticleMaterial>>,
ResMut<crate::rig_palette::RigPalettes>,
ResMut<crate::rig_palette::RigPaletteMirrors>,
),
) {
let (mut meshes, mut particle_materials) = particle_assets;
let (mut meshes, mut particle_materials, mut palettes, mut mirrors) = particle_assets;
let Some(booth) = booths.0.get(GLUE_SLOT) else {
return;
};
@@ -514,6 +516,9 @@ pub(super) fn sync_glue_scene(
})
})
.clone();
// The scene's rigs skin from THIS buffer's palette region (decision 0720): register it
// as a mirror (re-registration replaces — a rebuilt scene's old buffer drops).
mirrors.0.insert("glue_scene", light.clone());
let stage = attachment_point(&model.skeleton, &model.attachments, 0).unwrap_or(Vec3::ZERO);
let rig = scene_rig(&model.lights);
let (fog_rgb, fog_far) = scene_fog(token);
@@ -588,6 +593,7 @@ pub(super) fn sync_glue_scene(
.collect();
let joints = spawn_booth_model(
&mut commands,
&mut palettes,
scene.root,
booth.layer.clone(),
&scene_parts,
@@ -709,6 +715,7 @@ pub(super) fn sync_glue_booth(
creatures: Option<Res<Creatures>>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut last: Local<Option<(u64, f32, u64)>>,
) {
let Some(booth) = booths.0.get(GLUE_SLOT) else {
@@ -798,6 +805,7 @@ pub(super) fn sync_glue_booth(
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
&mut commands,
&mut palettes,
booth.root,
booth.layer.clone(),
&booth_parts,
+29 -4
View File
@@ -335,12 +335,14 @@ fn new_target_image(size: u32) -> Image {
/// Startup: stand up one booth per slot — its image (registered in [`PortraitImages`]), a model-root
/// entity, and a camera rendering only that slot's layer into the image (transparent, no bloom/MSAA,
/// rendered before the world camera via a negative order).
#[allow(clippy::too_many_arguments)]
fn setup_booths(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut portraits: ResMut<PortraitImages>,
mut booths: ResMut<Booths>,
mut booth_light: ResMut<BoothLight>,
mut mirrors: ResMut<crate::rig_palette::RigPaletteMirrors>,
device: Res<bevy::render::renderer::RenderDevice>,
queue: Res<bevy::render::renderer::RenderQueue>,
) {
@@ -368,6 +370,16 @@ fn setup_booths(
"wow_model_pane_light",
model_pane_light_rows(),
));
// Booth rigs skin from the palette regions of THESE buffers (decision 0720): register both
// as mirrors so the palette upload keeps their regions live.
for (key, buf) in [
("portrait", &booth_light.studio.buffer),
("pane", &booth_light.pane.buffer),
] {
if let Some(b) = buf {
mirrors.0.insert(key, b.clone());
}
}
for (i, token) in SLOTS.iter().enumerate() {
let image = images.add(new_target_image(PORTRAIT_SIZE));
@@ -579,12 +591,18 @@ fn sync_portraits(
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
interact_npc: Res<crate::ui_session::InteractNpc>,
// The party slots' roster + entity index (one tuple param — the 16-SystemParam ceiling).
party: (Res<crate::ui_party::GroupState>, Res<crate::net::GuidIndex>),
// The party slots' roster + entity index + the skin-palette table (decision 0720) —
// one tuple param (the 16-SystemParam ceiling).
party: (
Res<crate::ui_party::GroupState>,
Res<crate::net::GuidIndex>,
ResMut<crate::rig_palette::RigPalettes>,
),
) {
if test_mode(&mut env_cache) {
return; // the test bake owns the booths
}
let (party_roster, party_index, mut palettes) = party;
for token in SLOTS {
let unit: Option<Entity> = match token {
"player" => self_q.single().ok(),
@@ -598,8 +616,8 @@ fn sync_portraits(
tok => tok
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
.and_then(|n| party.0.party_slots().nth(n - 1))
.and_then(|m| party.1 .0.get(&m.guid))
.and_then(|n| party_roster.party_slots().nth(n - 1))
.and_then(|m| party_index.0.get(&m.guid))
.copied(),
};
let Some(booth) = booths.0.get_mut(token) else {
@@ -684,6 +702,7 @@ fn sync_portraits(
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
&mut commands,
&mut palettes,
booth.root,
booth.layer.clone(),
&booth_parts,
@@ -753,6 +772,7 @@ fn sync_paperdoll(
ent_q: Query<&NetEntity>,
look: DressedLook,
paperdoll: Res<PaperDollBooth>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
mut env_cache: Local<Option<bool>>,
mut last_yaw: Local<Option<f32>>,
@@ -763,6 +783,7 @@ fn sync_paperdoll(
return; // the test bake owns the booths (it drives the paper doll too, see `bake_test`)
}
sync_body_booth(
&mut palettes,
PAPERDOLL_SLOT,
self_q.single().ok(),
paperdoll.yaw,
@@ -792,6 +813,7 @@ fn sync_inspect_booth(
ent_q: Query<&NetEntity>,
look: DressedLook,
inspect: Res<InspectBooth>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
mut wow_mats: ResMut<Assets<WowModelMaterial>>,
mut env_cache: Local<Option<bool>>,
mut last_yaw: Local<Option<f32>>,
@@ -802,6 +824,7 @@ fn sync_inspect_booth(
return;
}
sync_body_booth(
&mut palettes,
INSPECT_SLOT,
inspect.unit,
inspect.yaw,
@@ -824,6 +847,7 @@ fn sync_inspect_booth(
/// there is nothing to show, which empties the booth.
#[allow(clippy::too_many_arguments)]
fn sync_body_booth(
palettes: &mut crate::rig_palette::RigPalettes,
slot: &str,
unit: Option<Entity>,
yaw: f32,
@@ -911,6 +935,7 @@ fn sync_body_booth(
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
commands,
palettes,
booth.root,
booth.layer.clone(),
&booth_parts,
+5
View File
@@ -29,6 +29,7 @@ pub(super) fn sync_test_portraits(
mut env_cache: Local<Option<bool>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
anim_data: Option<Res<crate::creature_anim::AnimData>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
) {
if !test_mode(&mut env_cache) || *test_done {
return;
@@ -36,6 +37,7 @@ pub(super) fn sync_test_portraits(
let path = std::env::var("WOW_PORTRAIT_TEST").expect("gated by test_mode");
if bake_test(
&mut commands,
&mut palettes,
&booths,
&path,
&asset_server,
@@ -58,6 +60,7 @@ pub(super) fn sync_test_portraits(
#[allow(clippy::too_many_arguments)]
fn bake_test(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
booths: &Booths,
path: &str,
asset_server: &AssetServer,
@@ -164,6 +167,7 @@ fn bake_test(
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
commands,
palettes,
booth.root,
booth.layer.clone(),
&parts,
@@ -188,6 +192,7 @@ fn bake_test(
commands.entity(booth.root).despawn_related::<Children>();
spawn_booth_model(
commands,
palettes,
booth.root,
booth.layer.clone(),
&pane_parts, // the pane's own light, not the portraits' studio (decision 0638)
+24 -14
View File
@@ -47,7 +47,6 @@ use query::query_statuses;
use std::collections::HashMap;
use bevy::mesh::skinning::SkinnedMesh;
use bevy::prelude::*;
use benilla_assets::{BillboardInfo, M2Model};
@@ -256,6 +255,7 @@ fn build_markers(
mut roots: Query<(Entity, &mut QuestMarkerRoot)>,
m2s: Res<Assets<M2Model>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut palettes: ResMut<crate::rig_palette::RigPalettes>,
light: Res<SharedLightBuffer>,
index: Res<GuidIndex>,
anchors: Query<&BoneAttach>,
@@ -291,7 +291,7 @@ fn build_markers(
let Some((joint, offset)) = anchor
.points
.get(&slot)
.and_then(|&(bone, offset)| Some((*anchor.joints.get(bone as usize)?, offset)))
.and_then(|&(bone, offset)| Some((anchor.anchor(bone)?, offset)))
else {
debug!(
"quest_markers: {:#x} has no slot-{slot} attachment — marker never parents (invisible, the client's own behavior)",
@@ -310,7 +310,9 @@ fn build_markers(
.submeshes
.iter()
.any(|s| s.billboard.is_none())
.then(|| crate::doodad_anim::spawn_anim_host(&mut commands, model, seat_tf))
.then(|| {
crate::doodad_anim::spawn_anim_host(&mut commands, &mut palettes, model, seat_tf)
})
.flatten();
let seat = match &host {
Some(h) => h.root,
@@ -372,26 +374,34 @@ fn build_markers(
commands.entity(root).add_child(child);
}
None => {
// Plain geometry under the seat: the skinned twin bound to the host's joints
// when the model animates (the `!` bob); the static mesh otherwise (capture
// mode keeps every marker static, like the doodad rail).
let mesh = match &host {
Some(_) => sub.skinned_mesh.clone(),
None => sub.mesh.clone(),
// Plain geometry under the seat: the skinned twin bound to the host's palette
// rig when the model animates (the `!` bob — decision 0720); the static mesh
// otherwise (capture mode keeps every marker static, like the doodad rail),
// 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()
} else {
sub.mesh.clone()
};
let rig_tag = host
.as_ref()
.filter(|_| use_rig)
.map_or(0, |h| crate::mesh_tag::rig_bits(h.slot));
let child = commands
.spawn((
Mesh3d(mesh),
MeshMaterial3d(material),
MeshTag(alpha_bits(1.0)),
MeshTag(rig_tag | alpha_bits(1.0)),
Transform::IDENTITY,
))
.id();
if let Some(h) = &host {
commands.entity(child).insert(SkinnedMesh {
inverse_bindposes: h.inverse_bindposes.clone(),
joints: h.joints.clone(),
});
if use_rig {
commands
.entity(child)
.insert(crate::rig_palette::RigPart(h.root));
}
}
commands.entity(seat).add_child(child);
}
+2 -1
View File
@@ -398,7 +398,8 @@ impl Plugin for RibbonPlugin {
PostUpdate,
simulate_ribbons
.in_set(crate::billboard::BillboardPlace)
.after(crate::billboard::billboard_joint_palette),
.after(crate::billboard::billboard_joint_palette)
.after(crate::creature_anim::finalize_rig_worlds),
);
}
}
+659
View File
@@ -0,0 +1,659 @@
//! 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,
}
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,
}
}
}
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 = Arc::make_mut(&mut self.rows);
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);
let rows = Arc::make_mut(&mut self.rows);
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);
let rows = Arc::make_mut(&mut self.rows);
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();
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);
// 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),
);
}
}
}
}
/// 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
);
}
}
+14 -19
View File
@@ -10,7 +10,6 @@ use std::collections::HashSet;
use benilla_assets::ModelAnimations;
use benilla_protocol::EntityKind;
use bevy::camera::primitives::Aabb;
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
use bevy::mesh::{Indices, VertexAttributeValues};
use bevy::picking::mesh_picking::ray_cast::MeshRayCast;
use bevy::prelude::*;
@@ -90,7 +89,10 @@ pub(super) fn update_hover(
plate_rects: Res<crate::vplates::PlateRects>,
mut hovered: ResMut<Hovered>,
mesh_assets: Res<Assets<Mesh>>,
ibp_assets: Res<Assets<SkinnedMeshInverseBindposes>>,
// The owned palette table (decision 0720): the picker reads the same world-space matrices
// the vertex stage skins with, straight from the CPU rows.
palettes: Res<crate::rig_palette::RigPalettes>,
rigs: Query<&crate::rig_palette::RigSkin>,
// Last frame's pick, for pass 2's sticky-hover (the reference's anti-flicker cache: the previous
// pick outranks everything in the halo retry, so the hover doesn't strobe between two units).
mut last_pick: Local<Option<Entity>>,
@@ -112,9 +114,8 @@ pub(super) fn update_hover(
// A mounted unit's mount-child part children (decision 0441): the mount's geometry joins the
// unit's pick set — the reference draws mount + rider as one clickable unit.
child_sets: Query<&Children>,
// A part child's mesh + skinned binding (the joints its vertices pose through).
parts: Query<(&Mesh3d, &SkinnedMesh)>,
joint_tfs: Query<&GlobalTransform>,
// A part child's mesh + its palette-rig link (the rig its vertices pose through).
parts: Query<(&Mesh3d, &crate::rig_palette::RigPart)>,
// Fallback path: a unit's pickable mesh children — `WorldObject` kind, model-local `Aabb`, world
// transform, and the link to the streamed parent (whose `Guid` we resolve the hit to).
meshes: Query<(&ChildOf, &Aabb, &GlobalTransform, &WorldObject)>,
@@ -189,20 +190,14 @@ pub(super) fn update_hover(
continue;
}
}
// The posed joint palette (parts of one skeleton share the joint set): the same
// world-from-bind-pose matrices GPU skinning applies.
let palette_of = |sk: &[(&Mesh3d, &SkinnedMesh)]| -> Option<Vec<Mat4>> {
sk.first().and_then(|(_, skin)| {
let ibp = ibp_assets.get(&skin.inverse_bindposes)?;
Some(
skin.joints
.iter()
.zip(ibp.iter())
.map(|(&j, ibp)| {
joint_tfs.get(j).map_or(Mat4::IDENTITY, |t| t.to_matrix()) * *ibp
})
.collect(),
)
// The posed joint palette (parts of one skeleton share the rig): the same
// world-from-bind-pose matrices GPU skinning applies, read from the owned palette rows
// (decision 0720 — last frame's propagated pose, exactly what the previous
// joint-GlobalTransform read gave).
let palette_of = |sk: &[(&Mesh3d, &crate::rig_palette::RigPart)]| -> Option<Vec<Mat4>> {
sk.first().and_then(|(_, part)| {
let rig = rigs.get(part.0).ok()?;
palettes.world_palette(rig.slot, rig.bones() as usize)
})
};
let Some(palette) = palette_of(&skinned) else {
+27 -1
View File
@@ -169,9 +169,35 @@ impl MaterialExtension for WowModelExt {
fn specialize(
_pipeline: &MaterialExtensionPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
layout: &MeshVertexBufferLayoutRef,
key: MaterialExtensionKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// The owned-palette skinning path (decision 0720): a mesh carrying the WOW joint
// attributes (the skinned twin — `benilla_assets::build_skinned_submesh_mesh`) compiles
// the WOW_RIG_SKIN vertex path, which skins from the shared buffer's palette region by
// the instance's MeshTag rig field. The base mesh pipeline built the vertex buffer
// layout without the joint attributes (they're not Bevy's, so it doesn't know them);
// rebuild it with the same conditionals (mesh.rs `specialize`, locations 0-5) plus ours
// at 10/11. Bevy's own SKINNED branch never fires for these meshes — that's the point.
if layout.0.contains(benilla_assets::ATTRIBUTE_WOW_JOINT_INDEX) {
descriptor.vertex.shader_defs.push("WOW_RIG_SKIN".into());
let mut attrs = Vec::with_capacity(7);
for (attr, loc) in [
(Mesh::ATTRIBUTE_POSITION, 0),
(Mesh::ATTRIBUTE_NORMAL, 1),
(Mesh::ATTRIBUTE_UV_0, 2),
(Mesh::ATTRIBUTE_UV_1, 3),
(Mesh::ATTRIBUTE_TANGENT, 4),
(Mesh::ATTRIBUTE_COLOR, 5),
] {
if layout.0.contains(attr) {
attrs.push(attr.at_shader_location(loc));
}
}
attrs.push(benilla_assets::ATTRIBUTE_WOW_JOINT_INDEX.at_shader_location(10));
attrs.push(benilla_assets::ATTRIBUTE_WOW_JOINT_WEIGHT.at_shader_location(11));
descriptor.vertex.buffers = vec![layout.0.get_layout(&attrs)?];
}
// M2 render flags 0x10 (no depth-write) / 0x08 (no depth-test), per batch. Default: write depth
// (LEQUAL) like the real client — including transparent batches, which fixes the bleed-through.
if let Some(ds) = descriptor.depth_stencil.as_mut() {
@@ -6,7 +6,6 @@
use benilla_assets::{M2Model, ModelSubmesh};
use benilla_formats::ModelBlend;
use bevy::mesh::skinning::SkinnedMesh;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
@@ -31,6 +30,8 @@ pub(crate) fn spawn_model_entities(
commands: &mut Commands,
mat_cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
// The owned skin-palette table (decision 0720): an animated placement's rig claims a slot.
palettes: &mut crate::rig_palette::RigPalettes,
light: &Buffer,
submeshes: &[ModelSubmesh],
transform: Transform,
@@ -89,12 +90,13 @@ pub(crate) fn spawn_model_entities(
|| !m.emitters.is_empty()
|| !m.ribbons.is_empty()
})
.and_then(|(m, _)| crate::doodad_anim::spawn_anim_host(commands, m, transform));
.and_then(|(m, _)| crate::doodad_anim::spawn_anim_host(commands, palettes, m, transform));
// Captures freeze material-alpha clocks at 0 (deterministic frames) — read the env once.
let mat_frozen = crate::capture::scenario_active();
let skin = host
.as_ref()
.map(|h| (h.joints.clone(), h.inverse_bindposes.clone()));
let (rig_root, rig_slot) = host.as_ref().map_or((None, 0), |h| (Some(h.root), h.slot));
if let Some(h) = &host {
out.push(h.root);
}
@@ -245,11 +247,19 @@ pub(crate) fn spawn_model_entities(
)
} else {
// An animated doodad's ordinary submesh draws the skinned twin bound to the placement's
// joint set (the entity keeps the placement transform — culling/fade read it; the
// skinned draw itself is joint-driven). Everything else draws the static mesh.
let mesh = match &skin {
Some(_) => sub.skinned_mesh.clone(),
None => sub.mesh.clone(),
// 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()
};
let part_tag = if use_rig {
MeshTag(crate::mesh_tag::rig_bits(rig_slot) | mesh_tag.0)
} else {
mesh_tag
};
let entity = commands
.spawn((
@@ -260,14 +270,17 @@ pub(crate) fn spawn_model_entities(
kind,
blend: sub.blend,
},
mesh_tag,
part_tag,
))
.id();
if let Some((joints, ibp)) = &skin {
commands.entity(entity).insert(SkinnedMesh {
inverse_bindposes: ibp.clone(),
joints: joints.clone(),
});
if skin.is_some() {
if let Some(root) = rig_root {
commands
.entity(entity)
.insert(crate::rig_palette::RigPart(root));
}
// The draw-gate list is about visibility, not skinning — the bind-pose fallback
// part still represents the placement.
skinned_meshes.push(entity);
}
(entity, local_center)
@@ -59,8 +59,11 @@ pub(super) fn spawn_loaded_placements(
time: Res<Time>,
mut uv_reg: ResMut<crate::doodad_anim::UvAnimMaterials>,
mut tint_reg: ResMut<crate::doodad_anim::TintAnimMaterials>,
mut probes: ResMut<PropProbes>,
// Nested to stay inside Bevy's 16-element system-param tuple limit: the prop-probe table +
// the skin-palette table (decision 0720).
tables: (ResMut<PropProbes>, ResMut<crate::rig_palette::RigPalettes>),
) {
let (mut probes, mut palettes) = tables;
let Some(shared_light) = shared_light else {
return;
};
@@ -108,6 +111,7 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
mat_cache,
materials,
&mut palettes,
light,
&m.submeshes,
p.transform,
@@ -184,6 +188,7 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
mat_cache,
materials,
&mut palettes,
light,
&m.submeshes,
p.transform,
@@ -408,6 +413,7 @@ pub(super) fn spawn_loaded_placements(
&mut commands,
mat_cache,
materials,
&mut palettes,
light,
&m.submeshes,
d.transform,