From 22d75ea613a5b2dd7791ca3851263a1dba292880 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 17:50:38 +0100
Subject: [PATCH] MONKEY (skybox): second texture stage for two-texture skybox
batches
RenderSubmesh carries stage 1 (texture, UV set, transform, Mod/Mod2x) for
textureCount 2 batches; M2Vertex reads UV1. WowModelExt gains a stage1
uniform + texture binding 94/95 and the WOW_STAGE1 shader def, so every
one-texture batch compiles the old code. The skybox lane binds stage 1 on
its own material copies and drives its transform rows.
Co-Authored-By: Claude Opus 5.5
---
crates/benilla-app/src/pipe_warm/menagerie.rs | 1 +
crates/benilla-assets/src/materials.rs | 18 +++++++
.../benilla-assets/src/shaders/wow_model.wgsl | 22 ++++++++
crates/benilla-assets/src/world_assets/mod.rs | 3 ++
crates/benilla-formats/src/lib.rs | 2 +-
.../benilla-formats/src/models/m2_batches.rs | 49 ++++++++++++++++-
crates/benilla-formats/src/models/mod.rs | 1 +
crates/benilla-formats/src/models/types.rs | 21 ++++++++
crates/benilla-m2/src/lib.rs | 5 ++
crates/benilla-m2/src/model.rs | 4 +-
crates/benilla-world/src/model_render.rs | 6 +++
crates/benilla-world/src/skybox.rs | 38 ++++++++++++-
crates/benilla-world/src/skybox_anim.rs | 53 ++++++++++++++++++-
13 files changed, 218 insertions(+), 5 deletions(-)
diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs
index 079ee8ed..93339a2c 100644
--- a/crates/benilla-app/src/pipe_warm/menagerie.rs
+++ b/crates/benilla-app/src/pipe_warm/menagerie.rs
@@ -889,6 +889,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
rgb_seq: None,
wmo_batch: None,
section: None,
+ stage1: None,
}
}
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 18aa1682..ff171a45 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -71,6 +71,8 @@ pub struct WowModelKey {
/// The WMO-skybox lane (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), a key axis
/// because only this model's depth is pinned.
sky_depth: bool,
+ /// MONKEY (skybox): a second texture stage (`stage1.x`), the `WOW_STAGE1` shader def.
+ stage1: bool,
// The WMO batch order is deliberately not a key axis (a pipeline per batch index stalls a
// city's first sight): the file-order layering (`0x6b4f10`/`0x6b5190`) rides `sun_scale.y`.
}
@@ -89,6 +91,7 @@ impl From<&WowModelExt> for WowModelKey {
zfill: markers & 0x200 != 0,
far_side: markers & 0x800 != 0,
sky_depth: markers & 0x2000 != 0,
+ stage1: e.stage1.x > 0.5,
}
}
}
@@ -122,6 +125,14 @@ pub struct WowModelExt {
/// tint, `z` = the texture-transform affine, `w` = the UI tile's cell clip.
#[uniform(100)]
pub anim_slots: Vec4,
+ /// MONKEY (skybox): a two-texture batch's stage 1: `x` = 0 none, 1 Mod, 2 Mod2x; `z`/`w` = the
+ /// `matanim` rows of its translation and affine, read on UV set B. `0` on every other batch.
+ #[uniform(100)]
+ pub stage1: Vec4,
+ /// MONKEY (skybox): stage 1's texture; `None` (the fallback image) unless `stage1.x > 0`.
+ #[texture(94)]
+ #[sampler(95)]
+ pub stage1_texture: Option>,
/// The shared global light (`lighting::global_light`), updated in place once a frame; the
/// vertex stage reads its point-light table, since Bevy's clusterable lights are fragment-only.
#[storage(90, read_only, buffer, visibility(vertex, fragment))]
@@ -254,6 +265,13 @@ impl MaterialExtension for WowModelExt {
ds.bias.constant = 0;
}
}
+ // MONKEY (skybox): the second texture stage compiles only where a batch has one.
+ if key.bind_group_data.stage1 {
+ descriptor.vertex.shader_defs.push("WOW_STAGE1".into());
+ if let Some(fragment) = descriptor.fragment.as_mut() {
+ fragment.shader_defs.push("WOW_STAGE1".into());
+ }
+ }
// The skybox pins clip z to 0, reverse-Z far, in the vertex stage; its bias is sort-only.
if key.bind_group_data.sky_depth {
descriptor.vertex.shader_defs.push("WOW_SKY_DEPTH".into());
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 07705ba5..f5c8ea58 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -47,8 +47,14 @@ struct ModelParams {
// Rows of `wow_light.matanim`, 0 = identity: x = UV scroll, y = tint, z = texture-transform
// affine, w = the UI tile's cell clip.
anim_slots: vec4,
+ // MONKEY (skybox): stage 1 of a two-texture batch: x = 0 none / 1 Mod / 2 Mod2x, z/w = the
+ // matanim rows of its translation and affine.
+ stage1: vec4,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var m: ModelParams;
+// MONKEY (skybox): stage 1's texture, the fallback image off `WOW_STAGE1`.
+@group(#{MATERIAL_BIND_GROUP}) @binding(94) var stage1_texture: texture_2d;
+@group(#{MATERIAL_BIND_GROUP}) @binding(95) var stage1_sampler: sampler;
// A fully covered character shadow retains 45% of the authored model lighting.
const SHADOW_SUN_FLOOR: f32 = 0.45;
@@ -1286,6 +1292,22 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
base_color.a = base_color.a * ramp;
}
+ // MONKEY (skybox): stage 1 multiplies in on UV set B, moved by its own texture transform
+ // (the stage-0 law above): colour ×1 (Mod) or ×2 (Mod2x), alpha ×1.
+#ifdef WOW_STAGE1
+#ifdef VERTEX_UVS_B
+ {
+ let t1 = wow_light.matanim[u32(m.stage1.z)];
+ let a1 = wow_light.matanim[u32(m.stage1.w)];
+ let d1 = (in.uv_b + t1.xy - vec2(0.5, 0.5)) * vec2(1.0 + a1.z, 1.0 + a1.w);
+ let c1 = 1.0 + a1.x;
+ let uv1 = vec2(0.5 + d1.x * c1 - d1.y * a1.y, 0.5 + d1.x * a1.y + d1.y * c1);
+ let s1 = textureSampleBias(stage1_texture, stage1_sampler, uv1, view.mip_bias);
+ let k1 = select(1.0, 2.0, m.stage1.x > 1.5);
+ base_color = vec4(base_color.rgb * s1.rgb * k1, base_color.a * s1.a);
+ }
+#endif
+#endif
// The MeshTag (mesh_tag.rs): bit 31 = highlight, bit 30 = interior fog; payload bits 0-5 = the
// fade alpha (a zero payload is untagged, opaque), 19-29 = the rig slot, 6-13 = the ground
// shade (0 lit, 255 MCSH-shadowed) or, on an interior-prop material, 6-18 = the SH-probe slot.
diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs
index dba6e3e8..d94106c8 100644
--- a/crates/benilla-assets/src/world_assets/mod.rs
+++ b/crates/benilla-assets/src/world_assets/mod.rs
@@ -577,6 +577,9 @@ impl WorldAssets {
tint: Vec4::ONE, // clutter has no animated M2Color tint and is not a WMO batch
sidn: Vec4::ZERO, // clutter is never SIDN/WINDOW glass (WMO-only)
anim_slots: Vec4::ZERO,
+ // MONKEY (skybox): no second stage.
+ stage1: Vec4::ZERO,
+ stage1_texture: None,
light_buf: self.shared_light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: self.torch.depth.clone(),
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index 83813eda..141c3183 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -275,7 +275,7 @@ pub use models::{
CoverageReader, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone,
GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, M2CameraTracks,
M2Light, M2PaneCamera, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis,
- PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
+ PlayableAnim, RenderSubmesh, RgbAnim, StageTwo, ScalarAnim, SeqLoops, ShippedGlueScene, Skeleton,
SkeletonBone, StringAnchors, UvAnim, UvRotAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
ALPHA_KEY_REF, DEGENERATE_RING_FOOTPRINT, GLUE_AUTHORED_ASPECT, NO_GROUP_LIQUID,
diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs
index 40043970..6cb13f03 100644
--- a/crates/benilla-formats/src/models/m2_batches.rs
+++ b/crates/benilla-formats/src/models/m2_batches.rs
@@ -17,7 +17,7 @@ use super::tex_anim;
use super::{le_u16, le_u32, model_path, remap_submesh};
use super::{
AlphaAnim, Billboard, BillboardKind, BoneScaleAnim, CharSkinSlot, FogPolicy, ModelBlend,
- RenderSubmesh,
+ RenderSubmesh, StageTwo,
};
fn parent_dir(path: &str) -> &str {
@@ -479,6 +479,33 @@ pub fn parse_m2_render_submeshes(
let rgb_seq = rgb_track
.and_then(|t| mat_anim::bake_rgb_seqs(t, &model.global_sequences, &seq_slots))
.filter(|set| set.uniform().is_none());
+ // MONKEY (skybox): stage 1 of a two-texture batch; an env-mapped stage 1 is left out.
+ let stage1 = (batch.texture_count >= 2 && !model.stage_is_env_mapped(batch, 1))
+ .then(|| {
+ let rec = model
+ .raw_data
+ .texture_lookup_table
+ .get(batch.texture_combo_index as usize + 1)
+ .and_then(|&ti| model.textures.get(ti as usize))?;
+ let set = model
+ .texture_unit_lookup
+ .get(batch.texture_coord_combo_index as usize + 1)
+ .copied()
+ .unwrap_or(0);
+ let combo = batch.texture_transform_combo_index.wrapping_add(1);
+ Some((
+ resolve_texture(rec, dir, skins).0,
+ (rec.wrap_x, rec.wrap_y),
+ set == 1,
+ batch.shader_id & 0xf == 4,
+ tex_anim::bake_uv_anim(model, combo, seq0_slot),
+ tex_anim::bake_uv_rot_seqs(model, combo, &seq_slots)
+ .and_then(|s| s.seq(None).cloned()),
+ tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots)
+ .and_then(|s| s.seq(None).cloned()),
+ ))
+ })
+ .flatten();
// The reference turns each billboard bone to the camera about its own pivot, and a batch
// can hold cards on several bones (a candelabra's glows): one submesh per billboard bone.
let make_billboard = |bone_idx: usize| -> Option {
@@ -591,6 +618,26 @@ pub fn parse_m2_render_submeshes(
sub.uv_scale_seq = uv_scale_seq.clone();
sub.rgb_anim = rgb_anim.clone();
sub.rgb_seq = rgb_seq.clone();
+ // MONKEY (skybox): stage 1's UVs follow the submesh's own vertex order.
+ sub.stage1 = stage1.as_ref().map(
+ |(texture, (wrap_x, wrap_y), uv1, mod2x, uv_anim, uv_rot, uv_scale)| StageTwo {
+ texture: texture.clone(),
+ wrap_x: *wrap_x,
+ wrap_y: *wrap_y,
+ uvs: globals
+ .iter()
+ .map(|&g| {
+ let v = &model.vertices[g as usize];
+ let c = if *uv1 { &v.tex_coords2 } else { &v.tex_coords };
+ [c.x, c.y]
+ })
+ .collect(),
+ mod2x: *mod2x,
+ uv_anim: uv_anim.clone(),
+ uv_rot: uv_rot.clone(),
+ uv_scale: uv_scale.clone(),
+ },
+ );
out.push(sub);
}
}
diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs
index 636b1b3c..ea0c07bf 100644
--- a/crates/benilla-formats/src/models/mod.rs
+++ b/crates/benilla-formats/src/models/mod.rs
@@ -100,6 +100,7 @@ fn remap_submesh(
wmo_batch: None,
env_map: false,
section: None,
+ stage1: None,
},
globals,
)
diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs
index 154eb3a3..47c31cda 100644
--- a/crates/benilla-formats/src/models/types.rs
+++ b/crates/benilla-formats/src/models/types.rs
@@ -262,6 +262,24 @@ pub enum FogPolicy {
Off = 4,
}
+/// MONKEY (skybox): a two-texture batch's second stage (`textureCount 2`, which no stock 1.12
+/// model authors): texture `textureLookup[combo + 1]` on the UV set `texUnitLookup[coord + 1]`,
+/// moved by `texAnimLookup[transform + 1]`, multiplied into stage 0 (`Mod`, or `Mod2x` on the
+/// modern `0x4014` combiner).
+#[derive(Debug, Clone, Default)]
+pub struct StageTwo {
+ pub texture: Option,
+ pub wrap_x: bool,
+ pub wrap_y: bool,
+ /// Per vertex, parallel to [`RenderSubmesh::positions`], from the stage's own UV set.
+ pub uvs: Vec<[f32; 2]>,
+ /// The stage's colour doubles (`Mod2x`).
+ pub mod2x: bool,
+ pub uv_anim: Option,
+ pub uv_rot: Option>,
+ pub uv_scale: Option>,
+}
+
/// One render batch: self-contained geometry and its material.
#[derive(Debug, Clone)]
pub struct RenderSubmesh {
@@ -346,6 +364,8 @@ pub struct RenderSubmesh {
/// over its base); the reference draws both from one vertex array under LEQUAL, the later
/// winning exactly (`0x70c190`), so consolidators refuse a section they cannot take whole.
pub section: Option,
+ /// MONKEY (skybox): the second texture stage, `None` on every one-texture batch.
+ pub stage1: Option,
/// Texture coordinates generated as a view-space sphere map, not read from [`Self::uvs`]
/// (`texture_unit_lookup[texCoordSet] > 2`): `uv = normalize(P − 2(P·N)N).xy · 0.5 + 0.5`.
/// Such a mesh parks its UVs at one point (`GnomeSubwayGlass.m2`).
@@ -416,6 +436,7 @@ impl Default for RenderSubmesh {
wmo_batch: None,
env_map: false,
section: None,
+ stage1: None,
}
}
}
diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs
index bc0214c2..010bb5d7 100644
--- a/crates/benilla-m2/src/lib.rs
+++ b/crates/benilla-m2/src/lib.rs
@@ -173,6 +173,11 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result {
x: v.f32_at(32).ok_or(Error::Truncated)?,
y: v.f32_at(36).ok_or(Error::Truncated)?,
},
+ // MONKEY (skybox): the second UV set.
+ tex_coords2: C2 {
+ x: v.f32_at(40).ok_or(Error::Truncated)?,
+ y: v.f32_at(44).ok_or(Error::Truncated)?,
+ },
});
}
diff --git a/crates/benilla-m2/src/model.rs b/crates/benilla-m2/src/model.rs
index 6bc8a864..14f37477 100644
--- a/crates/benilla-m2/src/model.rs
+++ b/crates/benilla-m2/src/model.rs
@@ -106,7 +106,7 @@ impl M2Bone {
}
/// One M2 vertex, 48 bytes: position @0, weights @0x0c, indices @0x10, normal @0x14, UV @0x20,
-/// and an unread second UV @0x28.
+/// and a second UV @0x28.
pub struct M2Vertex {
pub position: C3,
/// Per-vertex bone influence weights (`/255`), paired with [`Self::bone_indices`].
@@ -114,6 +114,8 @@ pub struct M2Vertex {
pub bone_indices: [u8; 4],
pub normal: C3,
pub tex_coords: C2,
+ /// MONKEY (skybox): the second UV set, which a two-texture batch's stage 1 can read.
+ pub tex_coords2: C2,
}
/// The texture lookup table and the collision hull's raw bytes.
diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs
index 7dc4bcfc..42b3951f 100644
--- a/crates/benilla-world/src/model_render.rs
+++ b/crates/benilla-world/src/model_render.rs
@@ -323,6 +323,9 @@ pub fn model_material(
},
// Zero until a sampler registers this material and bakes its table slot in once.
anim_slots: Vec4::ZERO,
+ // MONKEY (skybox): no second stage.
+ stage1: Vec4::ZERO,
+ stage1_texture: None,
light_buf: light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: torch.depth.clone(),
@@ -446,6 +449,9 @@ pub fn zfill_material(
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
sidn: Vec4::ZERO,
anim_slots: Vec4::ZERO,
+ // MONKEY (skybox): no second stage.
+ stage1: Vec4::ZERO,
+ stage1_texture: None,
light_buf: light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: torch.depth.clone(),
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index 8ff00863..bd750db4 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -481,6 +481,10 @@ fn build_skybox(
{
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone());
}
+ // MONKEY (skybox): stage 1 reads UV set B.
+ if let Some(st) = sub.stage1.as_ref().filter(|s| s.uvs.len() == sub.positions.len()) {
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, st.uvs.clone());
+ }
mesh.insert_indices(Indices::U32(sub.indices.clone()));
// The batch's authored address mode: Caverns of Time's belts wrap their UVs.
let texture = sub
@@ -493,9 +497,41 @@ fn build_skybox(
// The authored batch order after the layer's base (0 is unordered): every batch shares
// one sort distance.
let order = base + u16::try_from(i + 1).unwrap_or(0);
- let Some(pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref()) else {
+ let Some(mut pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref())
+ else {
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
};
+ // MONKEY (skybox): a two-texture batch takes its own copies with stage 1 bound, so
+ // the deduped one-texture materials stay as they were.
+ if let Some(st) = sub.stage1.as_ref().filter(|s| s.uvs.len() == sub.positions.len()) {
+ let tex1 = st
+ .texture
+ .as_deref()
+ .and_then(|t| world_assets.texture(t, (st.wrap_x, st.wrap_y), &mut images));
+ let mode = if st.mod2x { 2.0 } else { 1.0 };
+ let mut with_stage1 = |h: &Handle| {
+ let mut m = crate::model_render::lazy::with_material_mut(
+ mats.materials(),
+ h.id(),
+ |m| m.clone(),
+ )?;
+ m.extension.stage1 = Vec4::new(mode, 0.0, 0.0, 0.0);
+ m.extension.stage1_texture = tex1.clone();
+ Some(mats.materials().add(m))
+ };
+ let shared = pair.fade_blend == pair.steady;
+ if let Some(steady) = with_stage1(&pair.steady) {
+ let fade = if shared {
+ Some(steady.clone())
+ } else {
+ with_stage1(&pair.fade_blend)
+ };
+ if let Some(fade) = fade {
+ pair.steady = steady;
+ pair.fade_blend = fade;
+ }
+ }
+ }
let lane = SkyMatLane::register(
sub,
uv,
diff --git a/crates/benilla-world/src/skybox_anim.rs b/crates/benilla-world/src/skybox_anim.rs
index 0ea157b7..652fa163 100644
--- a/crates/benilla-world/src/skybox_anim.rs
+++ b/crates/benilla-world/src/skybox_anim.rs
@@ -313,6 +313,9 @@ pub(crate) struct SkyMatLane {
pub(crate) uv: Option<(u16, [f32; 2], Arc)>,
pub(crate) affine: Option<(u16, AffineLoops)>,
pub(crate) tint: Option<(u16, [f32; 3], Arc)>,
+ /// Stage 1's translation row (absolute, no seed) and affine row.
+ pub(crate) stage1_uv: Option<(u16, benilla_formats::UvAnim)>,
+ pub(crate) stage1_affine: Option<(u16, AffineLoops)>,
}
/// The rotation and scaling channels of a texture transform, slot 0's loops.
@@ -383,11 +386,36 @@ impl SkyMatLane {
lane.tint = Some((slot, seed, tint));
}
}
+ // Stage 1: rows only where it moves; row 0 is the identity.
+ if let Some(st) = &sub.stage1 {
+ if let Some(uv) = st.uv_anim.clone() {
+ if let Some(slot) = table.alloc() {
+ write(materials, &|m| m.extension.stage1.z = f32::from(slot));
+ lane.stage1_uv = Some((slot, uv));
+ }
+ }
+ if st.uv_rot.is_some() || st.uv_scale.is_some() {
+ if let Some(slot) = table.alloc() {
+ write(materials, &|m| m.extension.stage1.w = f32::from(slot));
+ lane.stage1_affine = Some((
+ slot,
+ AffineLoops {
+ rot: st.uv_rot.clone(),
+ scale: st.uv_scale.clone(),
+ },
+ ));
+ }
+ }
+ }
lane
}
pub(crate) fn any(&self) -> bool {
- self.uv.is_some() || self.affine.is_some() || self.tint.is_some()
+ self.uv.is_some()
+ || self.affine.is_some()
+ || self.tint.is_some()
+ || self.stage1_uv.is_some()
+ || self.stage1_affine.is_some()
}
/// Write this frame's rows: the delta from each seed, quantized as the shared lane does.
@@ -420,6 +448,29 @@ impl SkyMatLane {
.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, a)) = &self.stage1_uv {
+ let v = a.sample(a.clock(band_t, gseq_now));
+ table.set(
+ *slot,
+ [
+ benilla_assets::quantize(v[0], 4096.0),
+ benilla_assets::quantize(v[1], 4096.0),
+ 0.0,
+ 0.0,
+ ],
+ );
+ }
+ if let Some((slot, loops)) = &self.stage1_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(