MONKEY (skybox): weighted zone skybox lane, flags, animated skybox rig

CameraSkybox becomes a weighted layer list (ghost > WMO > zone, modern
crossfade), flags 0x1 day cycle / 0x2 keep celestial / 0x4 fog cone, the
celestial model as a second layer, and a CPU rig + mat-anim rows so skybox
bones, texture transforms, colour and alpha tracks play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 03:23:34 +01:00
co-authored by Claude Opus 5.5
parent 658f50d823
commit 88ceb9d2c2
5 changed files with 1260 additions and 165 deletions
+2
View File
@@ -146,6 +146,8 @@ mod shaders;
pub mod sky;
pub mod sky_order;
pub mod skybox;
// MONKEY (skybox): the skybox rig and material loops.
mod skybox_anim;
pub mod static_gx;
pub mod static_merge;
pub mod straddle;
+42 -4
View File
@@ -110,11 +110,16 @@ impl M2BatchMaterials<'_> {
/// (`[CM2Model+0x180]`, from the interior crossfade `[0xce9bdc]`) scales every batch's alpha,
/// and `0 < A < 1` promotes a batch to SRC_ALPHA blending (`0x70c190`): `fade_blend` is that
/// twin, or `steady` when the batch already blends.
///
/// MONKEY (skybox): `uv` and `tint` are the batch's texture-transform and colour loops, the
/// material's seed and identity; [`crate::skybox_anim`] drives their rows.
pub fn skybox(
&mut self,
sub: &benilla_formats::RenderSubmesh,
texture: Option<Handle<Image>>,
order: u16,
uv: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
tint: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
) -> Option<SkyboxBatch> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
@@ -136,10 +141,9 @@ impl M2BatchMaterials<'_> {
sub.env_map,
ShadeSel::Lit, // unread: every shipped skybox batch is UNLIT (0x01)
order,
// No texture-transform or colour loop: neither shipped skybox authors one. Wiring
// one needs the `M2Model` lane's `Arc`, which the material key identifies it by.
None,
None,
// MONKEY (skybox): the loops' `Arc`s, which the material key identifies them by.
uv,
tint,
None, // M2: no MOBA class, no SIDN, no WINDOW
None,
false,
@@ -161,6 +165,40 @@ impl M2BatchMaterials<'_> {
Some(SkyboxBatch { steady, fade_blend })
}
/// MONKEY (skybox): the flag `0x4` fog cone's material: an untextured, unlit, two-sided blend
/// on the sky lane, its colour the vertex colour times the tint row the skybox lane writes.
pub fn sky_fog_cone(&mut self, order: u16) -> Option<Handle<WowModelMaterial>> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
Some(model_material(
&mut self.cache.0,
&mut self.materials,
None,
ModelBlend::Blend,
true,
false,
false,
true, // unlit
false,
false,
true, // never writes depth
false, // …and always tests it
benilla_formats::FogPolicy::Off,
false,
ShadeSel::Lit,
order,
None,
None,
None,
None,
false,
true, // the sky lane
&light,
&torch,
None,
))
}
/// One steady material lit by a render target's own light buffer (a portrait booth, the model
/// pane, char-select), which [`super::MatKey::light`] keys apart from the world. `rig` lights
/// by the scene's authored M2 rig ([`ShadeSel::Rig`]) and plays the UV animation. `order` must
File diff suppressed because it is too large Load Diff
+527
View File
@@ -0,0 +1,527 @@
//! MONKEY (skybox): the animated half of the skybox lane. A skybox draws camera-anchored at the far
//! depth ([`crate::skybox`]), so it cannot ride the joint-entity rig: its anchor is written after
//! transform propagation. Its bones are posed here on the CPU instead, each frame, from the armed
//! sequence (animation id 0) and the global sequences, as `0x714260` composes them:
//! `M = parent · T(pivot) · T(t) · R(q) · S(s) · T(−pivot)`. A batch wholly on one bone takes that
//! bone's matrix as its transform; a batch spread over several moving bones is skinned on the CPU
//! (skyboxes are a few thousand vertices).
//!
//! Its texture-transform, colour and alpha tracks run on the same clock through the shared
//! mat-anim table ([`crate::mat_anim_table`]), in rows this module owns, so a skybox flagged
//! `0x1` (sequence 0 across the game day) drives every channel from the day fraction.
use std::sync::Arc;
use bevy::math::Affine3A;
use bevy::prelude::*;
use benilla_assets::coords::{wow_rotation_to_bevy, wow_to_bevy};
/// A key list for one channel: `(seconds, value)` time-ascending, and whether it interpolates.
#[derive(Clone, Debug, Default)]
struct Channel<V> {
keys: Vec<(f32, V)>,
linear: bool,
}
impl<V: Copy> Channel<V> {
/// The value at `t` (seconds inside the channel's own span): held before the first key and past
/// the last, stepped or blended between, as the reference's key search clamps.
fn sample(&self, t: f32, blend: impl Fn(V, V, f32) -> V) -> Option<V> {
let (&(t0, v0), rest) = self.keys.split_first()?;
if rest.is_empty() || t <= t0 {
return Some(v0);
}
let mut k = 0;
while k + 1 < self.keys.len() && self.keys[k + 1].0 <= t {
k += 1;
}
let (ta, va) = self.keys[k];
if !self.linear || k + 1 >= self.keys.len() {
return Some(va);
}
let (tb, vb) = self.keys[k + 1];
let f = if tb > ta { (t - ta) / (tb - ta) } else { 0.0 };
Some(blend(va, vb, f))
}
}
/// A global-sequence channel: keys in seconds over `period`, sampled at the free clock mod it.
#[derive(Clone, Debug, Default)]
struct GlobalChannel<V> {
period: f32,
channel: Channel<V>,
}
/// One bone's channels, Bevy space. A global-sequence channel replaces the sequence one.
#[derive(Clone, Debug, Default)]
struct BoneTracks {
translation: Channel<Vec3>,
rotation: Channel<Quat>,
scale: Channel<Vec3>,
g_translation: Option<GlobalChannel<Vec3>>,
g_rotation: Option<GlobalChannel<Quat>>,
g_scale: Option<GlobalChannel<Vec3>>,
}
impl BoneTracks {
fn moves(&self) -> bool {
!self.translation.keys.is_empty()
|| !self.rotation.keys.is_empty()
|| !self.scale.keys.is_empty()
|| self.g_translation.is_some()
|| self.g_rotation.is_some()
|| self.g_scale.is_some()
}
}
/// A skybox model's rig: the bone tree and the armed sequence's tracks, Bevy space.
#[derive(Debug, Default)]
pub(crate) struct SkyRig {
parents: Vec<i16>,
pivots: Vec<Vec3>,
tracks: Vec<BoneTracks>,
/// The armed sequence's length in seconds and its file slot (the alpha bake's slot index).
pub(crate) duration: f32,
pub(crate) seq_slot: Option<usize>,
looping: bool,
}
fn lerp3(a: Vec3, b: Vec3, f: f32) -> Vec3 {
a.lerp(b, f)
}
fn slerp(a: Quat, b: Quat, f: f32) -> Quat {
a.slerp(b, f)
}
/// The interpolation flag of the `M2Track` at `track` (`interp_type != 0`), linear when unreadable.
fn track_linear(bytes: &[u8], track: usize) -> bool {
bytes
.get(track..track + 2)
.is_none_or(|b| u16::from_le_bytes([b[0], b[1]]) != 0)
}
/// WoW-space scale to Bevy space: [`wow_to_bevy`] permutes the axes, so a scale permutes too.
fn scale_to_bevy(s: [f32; 3]) -> Vec3 {
Vec3::new(s[1], s[2], s[0])
}
impl SkyRig {
/// Parse the rig out of the model bytes; an unparsable or boneless model has an empty rig.
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
let Ok(skeleton) = benilla_formats::parse_m2_skeleton(bytes) else {
return Self::default();
};
let n = skeleton.bones.len();
let mut rig = SkyRig {
parents: skeleton.bones.iter().map(|b| b.parent).collect(),
pivots: skeleton.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect(),
tracks: vec![BoneTracks::default(); n],
duration: 0.0,
seq_slot: None,
looping: true,
};
let bone_ofs = bytes
.get(0x38..0x3c)
.map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
let rec = |i: usize| bone_ofs + i * 0x6c;
// The armed sequence: animation id 0 (`0x70ebd0`), else the file's first.
let anims = benilla_formats::parse_m2_animations(bytes);
if let Some(seq) = anims
.iter()
.find(|a| a.anim_id == 0)
.or_else(|| anims.first())
{
rig.duration = seq.duration;
rig.seq_slot = Some(seq.seq_index);
rig.looping = seq.looping;
for keys in &seq.bones {
let i = usize::from(keys.bone);
let Some(t) = rig.tracks.get_mut(i) else {
continue;
};
t.translation = Channel {
keys: keys
.translation
.iter()
.map(|&(s, v)| (s, wow_to_bevy(v)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x0c),
};
t.rotation = Channel {
keys: keys
.rotation
.iter()
.map(|&(s, q)| (s, wow_rotation_to_bevy(q)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x28),
};
t.scale = Channel {
keys: keys
.scale
.iter()
.map(|&(s, v)| (s, scale_to_bevy(v)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x44),
};
}
}
fn global<T, V>(
c: &Option<benilla_formats::GlobalSeqChannel<T>>,
linear: bool,
conv: impl Fn(T) -> V,
) -> Option<GlobalChannel<V>>
where
T: Copy,
{
let c = c.as_ref()?;
Some(GlobalChannel {
period: c.period_ms as f32 / 1000.0,
channel: Channel {
keys: c
.keys
.iter()
.map(|&(ms, v)| (ms as f32 / 1000.0, conv(v)))
.collect(),
linear,
},
})
}
for g in benilla_formats::parse_m2_global_sequence_bones(bytes) {
let i = usize::from(g.bone);
let Some(t) = rig.tracks.get_mut(i) else {
continue;
};
t.g_translation = global(&g.translation, track_linear(bytes, rec(i) + 0x0c), |v| {
wow_to_bevy(v)
});
t.g_rotation = global(&g.rotation, track_linear(bytes, rec(i) + 0x28), |q| {
wow_rotation_to_bevy(q)
});
t.g_scale = global(&g.scale, track_linear(bytes, rec(i) + 0x44), scale_to_bevy);
}
rig
}
/// Whether any bone has a channel, i.e. posing can move a vertex.
pub(crate) fn animates(&self) -> bool {
self.tracks.iter().any(BoneTracks::moves)
}
/// Whether `bone` or any bone above it has a channel.
pub(crate) fn bone_moves(&self, bone: u16) -> bool {
let mut i = usize::from(bone);
for _ in 0..=self.parents.len() {
let Some(t) = self.tracks.get(i) else {
return false;
};
if t.moves() {
return true;
}
match usize::try_from(self.parents[i]) {
Ok(p) => i = p,
Err(_) => return false,
}
}
false
}
/// The sequence clock for `t` seconds on the scene or day clock: wrapped for a looping
/// sequence, clamped for a one-shot, as the band bake does.
pub(crate) fn band_time(&self, t: f32) -> f32 {
if self.duration <= 0.0 {
0.0
} else if self.looping {
t.rem_euclid(self.duration)
} else {
t.clamp(0.0, self.duration)
}
}
/// Every bone's model-space matrix at band time `band_t` and free clock `gseq_now` seconds.
pub(crate) fn pose(&self, band_t: f32, gseq_now: f64) -> Vec<Affine3A> {
let n = self.parents.len();
let mut out: Vec<Option<Affine3A>> = vec![None; n];
for i in 0..n {
self.resolve(i, band_t, gseq_now, &mut out, 0);
}
out.into_iter()
.map(|m| m.unwrap_or(Affine3A::IDENTITY))
.collect()
}
fn resolve(
&self,
i: usize,
band_t: f32,
gseq_now: f64,
out: &mut Vec<Option<Affine3A>>,
depth: usize,
) -> Affine3A {
if let Some(m) = out[i] {
return m;
}
let local = self.local(i, band_t, gseq_now);
// Bounded by the bone count: the guard against a malformed parent cycle.
let m = match usize::try_from(self.parents[i]) {
Ok(p) if p < out.len() && p != i && depth < out.len() => {
self.resolve(p, band_t, gseq_now, out, depth + 1) * local
}
_ => local,
};
out[i] = Some(m);
m
}
fn local(&self, i: usize, band_t: f32, gseq_now: f64) -> Affine3A {
let t = &self.tracks[i];
if !t.moves() {
return Affine3A::IDENTITY;
}
fn at<V: Copy>(
g: &Option<GlobalChannel<V>>,
c: &Channel<V>,
band_t: f32,
gseq_now: f64,
blend: impl Fn(V, V, f32) -> V,
) -> Option<V> {
match g {
Some(g) if g.period > 0.0 => {
let tt = (gseq_now % f64::from(g.period)) as f32;
g.channel.sample(tt, blend)
}
_ => c.sample(band_t, blend),
}
}
let tr = at(&t.g_translation, &t.translation, band_t, gseq_now, lerp3).unwrap_or(Vec3::ZERO);
let rot = at(&t.g_rotation, &t.rotation, band_t, gseq_now, slerp)
.unwrap_or(Quat::IDENTITY)
.normalize();
let sc = at(&t.g_scale, &t.scale, band_t, gseq_now, lerp3).unwrap_or(Vec3::ONE);
let pivot = self.pivots[i];
Affine3A::from_translation(pivot + tr)
* Affine3A::from_scale_rotation_translation(sc, rot, Vec3::ZERO)
* Affine3A::from_translation(-pivot)
}
}
/// The mat-anim rows a skybox batch owns: its texture transform (translation row, and an affine
/// row for rotation/scale) and its colour track, each seeded at `t = 0` like the shared lane.
#[derive(Default)]
pub(crate) struct SkyMatLane {
pub(crate) uv: Option<(u16, [f32; 2], Arc<benilla_formats::UvAnim>)>,
pub(crate) affine: Option<(u16, AffineLoops)>,
pub(crate) tint: Option<(u16, [f32; 3], Arc<benilla_formats::RgbAnim>)>,
}
/// The rotation and scaling channels of a texture transform, slot 0's loops.
pub(crate) struct AffineLoops {
pub(crate) rot: Option<benilla_formats::KeyAnim<[f32; 4]>>,
pub(crate) scale: Option<benilla_formats::KeyAnim<[f32; 2]>>,
}
impl SkyMatLane {
/// Allocate the rows for a batch and write their slots into every material it draws with
/// (`anim_slots.x` UV, `.y` tint, `.z` affine). A full table leaves the batch at its seed.
pub(crate) fn register(
sub: &benilla_formats::RenderSubmesh,
uv: Option<Arc<benilla_formats::UvAnim>>,
tint: Option<Arc<benilla_formats::RgbAnim>>,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
mats: &[AssetId<benilla_assets::materials::WowModelMaterial>],
) -> Self {
let mut lane = SkyMatLane::default();
let write = |materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| {
for id in mats {
crate::model_render::lazy::with_material_mut(materials, *id, |m| f(m));
}
};
if let Some(uv) = uv.filter(|a| a.period > 0.0) {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
.and_then(|id| {
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
[m.extension.sun_scale.z, m.extension.sun_scale.w]
})
})
.unwrap_or([0.0, 0.0]);
write(materials, &|m| m.extension.anim_slots.x = f32::from(slot));
lane.uv = Some((slot, seed, uv));
}
}
let rot = sub
.uv_rot_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
let scale = sub
.uv_scale_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
if rot.is_some() || scale.is_some() {
if let Some(slot) = table.alloc() {
write(materials, &|m| m.extension.anim_slots.z = f32::from(slot));
lane.affine = Some((slot, AffineLoops { rot, scale }));
}
}
if let Some(tint) = tint {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
.and_then(|id| {
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
[m.extension.tint.x, m.extension.tint.y, m.extension.tint.z]
})
})
.unwrap_or([1.0, 1.0, 1.0]);
write(materials, &|m| m.extension.anim_slots.y = f32::from(slot));
lane.tint = Some((slot, seed, tint));
}
}
lane
}
pub(crate) fn any(&self) -> bool {
self.uv.is_some() || self.affine.is_some() || self.tint.is_some()
}
/// Write this frame's rows: the delta from each seed, quantized as the shared lane does.
pub(crate) fn tick(
&self,
band_t: f32,
gseq_now: f64,
table: &mut crate::mat_anim_table::MatAnimTable,
) {
if let Some((slot, seed, a)) = &self.uv {
let v = a.sample(a.clock(band_t, gseq_now));
table.set(
*slot,
[
benilla_assets::quantize(v[0], 4096.0) - seed[0],
benilla_assets::quantize(v[1], 4096.0) - seed[1],
0.0,
0.0,
],
);
}
if let Some((slot, loops)) = &self.affine {
let q = loops
.rot
.as_ref()
.map_or([0.0, 0.0, 0.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
let s = loops
.scale
.as_ref()
.map_or([1.0, 1.0], |l| l.sample(l.clock(band_t, gseq_now)));
table.set(*slot, crate::mat_anim_table::affine_row(q, s));
}
if let Some((slot, seed, a)) = &self.tint {
let c = benilla_assets::quant255(a.sample(a.clock(band_t, gseq_now)));
table.set(
*slot,
[c[0] - seed[0], c[1] - seed[1], c[2] - seed[2], 0.0],
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spin_rig() -> SkyRig {
// One parentless bone at pivot (1,0,0) turning about +Y over 4 s, linear.
SkyRig {
parents: vec![-1],
pivots: vec![Vec3::X],
tracks: vec![BoneTracks {
rotation: Channel {
keys: vec![
(0.0, Quat::IDENTITY),
(4.0, Quat::from_rotation_y(std::f32::consts::PI)),
],
linear: true,
},
..Default::default()
}],
duration: 4.0,
seq_slot: Some(0),
looping: true,
}
}
#[test]
fn a_bone_turns_about_its_pivot() {
let rig = spin_rig();
let m = rig.pose(rig.band_time(4.0 + 2.0), 0.0)[0];
// Half way: a quarter turn about the pivot; the pivot itself stays put.
assert!(m.transform_point3(Vec3::X).abs_diff_eq(Vec3::X, 1e-5));
let p = m.transform_point3(Vec3::new(2.0, 0.0, 0.0));
assert!(p.abs_diff_eq(Vec3::new(1.0, 0.0, -1.0), 1e-5), "{p}");
}
#[test]
fn a_child_composes_onto_its_parent() {
let mut rig = spin_rig();
rig.parents.push(0);
rig.pivots.push(Vec3::new(2.0, 0.0, 0.0));
rig.tracks.push(BoneTracks {
translation: Channel {
keys: vec![(0.0, Vec3::Y)],
linear: true,
},
..Default::default()
});
let pose = rig.pose(2.0, 0.0);
let p = pose[1].transform_point3(Vec3::new(2.0, 0.0, 0.0));
// Lifted by the child, then turned a quarter by the parent.
assert!(p.abs_diff_eq(Vec3::new(1.0, 1.0, -1.0), 1e-5), "{p}");
assert!(rig.bone_moves(1) && rig.animates());
}
#[test]
fn a_trackless_rig_is_the_bind_pose() {
let rig = SkyRig {
parents: vec![-1, 0],
pivots: vec![Vec3::ZERO, Vec3::ONE],
tracks: vec![BoneTracks::default(); 2],
..Default::default()
};
assert!(!rig.animates());
assert!(rig
.pose(1.0, 1.0)
.iter()
.all(|m| *m == Affine3A::IDENTITY));
}
/// The Caverns of Time belts: the general rig reproduces the rigid spin the old lane drew.
#[test]
fn the_caverns_belts_spin_as_the_bone_spin_does() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::Chain::open(&data).expect("open chain");
const SKY: &str = "Environments\\Stars\\CavernsOfTimeSky.m2";
let bytes = chain.read_file(SKY).expect("read the sky");
let rig = SkyRig::from_bytes(&bytes);
let spins = benilla_formats::m2_bone_spins(&bytes);
assert!(!spins.is_empty());
for t in [0.0f32, 3.3, 17.0, 50.0] {
let pose = rig.pose(rig.band_time(t), f64::from(t));
for (bone, spin) in &spins {
let pivot = wow_to_bevy(spin.pivot);
let rot = wow_rotation_to_bevy(spin.sample(t));
let old = Affine3A::from_translation(pivot)
* Affine3A::from_quat(rot)
* Affine3A::from_translation(-pivot);
let p = Vec3::new(10.0, 3.0, -7.0) + pivot;
let a = pose[usize::from(*bone)].transform_point3(p);
let b = old.transform_point3(p);
assert!(a.abs_diff_eq(b, 1e-2), "bone {bone} t {t}: {a} vs {b}");
}
}
}
}
+3 -2
View File
@@ -244,8 +244,9 @@ impl WorldCensus<'_, '_> {
};
let sky = self.skybox.as_ref().map(|s| {
s.0.as_deref()
.map_or_else(|| "dome".to_string(), str::to_ascii_lowercase)
// MONKEY (skybox): the heaviest layer of the weighted list.
s.primary()
.map_or_else(|| "dome".to_string(), |l| l.path.to_ascii_lowercase())
});
CensusReport {