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:
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)>()
|
||||
|
||||
@@ -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))]),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)>()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user