From 2193f9d33efef41f45a0093d4721797d284fc652 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 26 Sep 2026 06:00:26 +0100
Subject: [PATCH] MONKEY (visualfix): skybox animation stops allocating and
re-uploading per frame
Review leftover #17. animate_skyboxes keeps its per-model clocks across frames (a path String is
cloned only when a model first shows), poses into reused buffers (SkyRig::pose_into), and
re-skins and re-uploads a skinned batch only when its model's pose changed (a generation
counter). resolve_camera_skybox compares paths with an allocation-free norm_eq and owns only the
winning WMO skybox name. Test: norm_eq agrees with norm.
Co-Authored-By: Claude Opus 5.5
---
crates/benilla-world/src/skybox.rs | 106 +++++++++++++++++++-----
crates/benilla-world/src/skybox_anim.rs | 25 ++++--
2 files changed, 105 insertions(+), 26 deletions(-)
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index 225471bf..9df4635a 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -130,6 +130,9 @@ struct SkyboxPart {
lane: SkyMatLane,
/// MONKEY (skybox): how the rig moves the batch.
pose: PartPose,
+ /// MONKEY (visualfix): the model-clock pose generation this skinned batch was last uploaded
+ /// at; an unchanged pose is not re-skinned or re-uploaded.
+ skinned_at: Option,
}
/// MONKEY (skybox): how a batch follows its model's rig.
@@ -226,6 +229,23 @@ fn norm(path: &str) -> String {
}
}
+/// MONKEY (visualfix): `norm(a) == norm(b)` without allocating (the per-frame resolver's test).
+fn norm_eq(a: &str, b: &str) -> bool {
+ fn split(p: &str) -> (&str, bool) {
+ for ext in [".mdx", ".mdl", ".m2"] {
+ let cut = p.len().saturating_sub(ext.len());
+ if let (Some(stem), Some(tail)) = (p.get(..cut), p.get(cut..)) {
+ if tail.eq_ignore_ascii_case(ext) {
+ return (stem, true);
+ }
+ }
+ }
+ (p, false)
+ }
+ let ((sa, ma), (sb, mb)) = (split(a), split(b));
+ ma == mb && sa.eq_ignore_ascii_case(sb)
+}
+
/// MONKEY (reviewfix): celestial batches occupy the low rungs and a main model starts immediately
/// after the largest active celestial model. Oversized art compresses onto the last model rung but
/// can never collide with the fog cone.
@@ -238,8 +258,7 @@ fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) ->
/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the
/// larger weight and every other layer is scaled by `1 − weight`.
fn collect_layer(layers: &mut Vec, path: &str, weight: f32, flags: u32) {
- let key = norm(path);
- let at = match layers.iter().position(|l| norm(&l.path) == key) {
+ let at = match layers.iter().position(|l| norm_eq(&l.path, path)) {
Some(i) => {
layers[i].weight = layers[i].weight.max(weight);
i
@@ -332,7 +351,7 @@ fn resolve_camera_skybox(
if w <= 0.0 {
continue;
}
- match layers.iter_mut().find(|l| norm(&l.path) == def.path) {
+ match layers.iter_mut().find(|l| norm_eq(&l.path, &def.path)) {
Some(l) => l.weight = l.weight.max(w),
None => layers.push(SkyboxLayer {
path: def.path.clone(),
@@ -362,9 +381,11 @@ fn resolve_camera_skybox(
nav.flags & SHOW_SKYBOX != 0
&& inst.visible.get(i).copied().unwrap_or(false)
})
- .then(|| sky.to_owned())
+ .then_some(sky)
})
- .min();
+ .min()
+ // MONKEY (visualfix): one owned copy for the winner, not one per candidate.
+ .map(str::to_owned);
// The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a
// doorway at weight 0 fills the slot, which `0x6d4afe` declines to draw.
if let Some(sky) = resolved {
@@ -372,7 +393,7 @@ fn resolve_camera_skybox(
let flags = flags_of(&sky);
if t > 0.0 {
collect_layer(&mut layers, &sky, t, flags);
- } else if !layers.iter().any(|l| norm(&l.path) == norm(&sky)) {
+ } else if !layers.iter().any(|l| norm_eq(&l.path, &sky)) {
layers.push(SkyboxLayer {
path: sky,
weight: 0.0,
@@ -599,6 +620,7 @@ fn build_skybox(
anim_alpha: 1.0,
lane,
pose,
+ skinned_at: None,
},
))
.id();
@@ -705,11 +727,18 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option {
}
/// MONKEY (skybox): one model's clock and pose this frame.
+#[derive(Default)]
struct ModelClock {
band_t: f32,
gseq: f64,
live: bool,
pose: Vec,
+ /// MONKEY (visualfix): bumped (from a system-wide counter) whenever `pose` changes.
+ generation: u64,
+ /// MONKEY (visualfix): the next pose, sampled here and swapped in when it differs.
+ next: Vec,
+ /// MONKEY (visualfix): the resolve memo, reused.
+ scratch: Vec