From 57e21bfc2af0e24f93338cc466a8c95c12e8cb20 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 16:54:08 +0100
Subject: [PATCH] MONKEY (fix-wind): tree exile sway rides a material marker,
not MeshTag bit 18
Bit 18 aliases the top lane-weight bit and bit 12 of the interior probe slot,
so doorway units and probe-lit interior props swayed like trees. A classified
leaf batch's fade-band exile now gets marked copies of its cutout/blend
materials (clutter_fade.z bit 14, FOLIAGE_WIND_MARKER, not a key axis) and
wow_model.wgsl sways only those.
Co-Authored-By: Claude Opus 5.5
---
.../benilla-assets/src/shaders/wow_model.wgsl | 9 +-
crates/benilla-world/src/mesh_tag.rs | 25 ---
crates/benilla-world/src/model_render.rs | 145 +++++++++++++++++-
crates/benilla-world/src/static_gx/cull.rs | 7 +-
crates/benilla-world/src/static_gx/mod.rs | 5 +-
.../src/terrain_stream/spawn/assemble.rs | 47 +++++-
6 files changed, 197 insertions(+), 41 deletions(-)
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 744502bb..d4810031 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -1095,10 +1095,11 @@ fn vertex(vertex: WowVertex) -> WowVsOut {
);
}
#endif
- // MONKEY (wind): a retained tree inside its fade band renders here. StaticGx marks only its
- // alpha-tested leaf batches in MeshTag bit 18; use the same anchor/field maths to avoid a pop.
- let wind_tag = mesh_functions::get_tag(vertex.instance_index);
- if ((wind_tag & 262144u) != 0u && m.clutter_fade.w <= 0.5) {
+ // MONKEY (wind): a retained tree inside its fade band renders here with the same anchor/field
+ // maths, to avoid a pop. MONKEY (fix-wind): the leaf-batch marker is material bit 14
+ // (`model_render::FOLIAGE_WIND_MARKER`), never a MeshTag bit (bit 18 aliases the lane weight
+ // and the interior probe slot, so doorway units and interior props swayed).
+ if ((u32(m.clutter_fade.z) & 16384u) != 0u && m.clutter_fade.w <= 0.5) {
let world = p_cam + view.world_position;
p_cam += wind_hook::tree_offset(
world, frame_origin, view.world_position, wow_light.monkey
diff --git a/crates/benilla-world/src/mesh_tag.rs b/crates/benilla-world/src/mesh_tag.rs
index 85339128..d6ddc327 100644
--- a/crates/benilla-world/src/mesh_tag.rs
+++ b/crates/benilla-world/src/mesh_tag.rs
@@ -90,10 +90,6 @@ const SHADE_SHIFT: u32 = 6;
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
/// carry it through and the shader's 8-bit shade decode never sees it.
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
-/// MONKEY (wind): bit 18 on an exterior, non-Matte static doodad exile marks a classified
-/// tree/bush leaf batch. It aliases the top lane-weight bit safely: lane weight is read only with
-/// [`MATTE_INDOOR_BIT`], which these exterior fade twins never carry.
-const FOLIAGE_WIND_BIT: u32 = 0x0004_0000;
/// Bits 15..=18 of the exterior payload (MONKEY, portal lane fade): the **lane weight**, a 4-bit
/// crossfade `0` (fully exterior-lit) … [`LANE_MAX`] (fully room-lit) that the shader mixes the
/// two lanes by. It rides beside [`MATTE_INDOOR_BIT`] — the bit says "this part is on the room
@@ -208,16 +204,6 @@ pub fn alpha_bits(alpha: f32) -> u32 {
}
}
-/// MONKEY (wind): marks or clears the fade-twin foliage receiver without disturbing its live
-/// alpha. Only exterior static doodad exiles use this payload convention.
-pub(crate) fn with_foliage_wind(tag: u32, on: bool) -> u32 {
- if on {
- tag | FOLIAGE_WIND_BIT
- } else {
- tag & !FOLIAGE_WIND_BIT
- }
-}
-
/// Sets or clears [`INTERIOR_FOG_BIT`] alone: the probe slot says how a prop is lit, the
/// reference's `[0xca7f00]` only which fog triple its batch gets.
pub(crate) fn with_interior_fog(tag: u32, on: bool) -> u32 {
@@ -366,17 +352,6 @@ mod tests {
assert_eq!(shade_of(t, ext()), 10);
}
- // MONKEY (wind): the fade-twin marker is orthogonal to the live fade field.
- #[test]
- fn foliage_marker_survives_alpha_writes_and_clears_cleanly() {
- let alpha = alpha_bits(0.5);
- let marked = with_foliage_wind(alpha, true);
- assert_eq!(marked & FOLIAGE_WIND_BIT, FOLIAGE_WIND_BIT);
- assert_eq!(marked & ALPHA_MASK, alpha);
- assert_eq!(with_alpha(marked, 0.25) & FOLIAGE_WIND_BIT, FOLIAGE_WIND_BIT);
- assert_eq!(with_foliage_wind(marked, false), alpha);
- }
-
#[test]
fn probe_bits_compose_with_the_alpha_field() {
let t = probe_bits(6660);
diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs
index 7dc4bcfc..a521b40d 100644
--- a/crates/benilla-world/src/model_render.rs
+++ b/crates/benilla-world/src/model_render.rs
@@ -75,6 +75,9 @@ pub struct MatKey {
window: bool,
/// A depth-prime twin ([`zfill_material`], the reference's `M2UseZFill` clone at `0x707f7d`).
zfill: bool,
+ /// MONKEY (fix-wind): a classified tree/bush leaf batch's exile copy, marker bit 14
+ /// ([`FOLIAGE_WIND_MARKER`]); `wow_model.wgsl` sways only these.
+ foliage_wind: bool,
/// A WMO skybox batch ([`crate::skybox`]), with its own pipeline and sort rung.
sky_depth: bool,
/// The placement owning this material when its animated loop follows the instance's sequence
@@ -154,6 +157,137 @@ pub fn model_material(
// the sequence its instance is playing (decision 1408): the animated-material registries are
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
instance: Option,
+) -> Handle {
+ model_material_impl(
+ cache,
+ materials,
+ texture,
+ blend,
+ two_sided,
+ is_wmo,
+ is_interior,
+ is_emissive,
+ is_additive,
+ fade_variant,
+ no_depth_write,
+ no_depth_test,
+ fog_policy,
+ env_map,
+ shade,
+ batch_order,
+ uv_anim,
+ rgb_anim,
+ wmo_class,
+ sidn,
+ window,
+ sky_depth,
+ light,
+ torch,
+ instance,
+ false,
+ )
+}
+
+/// MONKEY (fix-wind): [`model_material`] for a classified tree/bush leaf batch's exile copy: the
+/// same material plus [`FOLIAGE_WIND_MARKER`], so the entity draw sways like the retained batch.
+#[allow(clippy::too_many_arguments)]
+pub fn foliage_model_material(
+ cache: &mut MaterialCache,
+ materials: &mut Assets,
+ texture: Option>,
+ blend: ModelBlend,
+ two_sided: bool,
+ is_wmo: bool,
+ is_interior: bool,
+ is_emissive: bool,
+ is_additive: bool,
+ fade_variant: bool,
+ no_depth_write: bool,
+ no_depth_test: bool,
+ fog_policy: benilla_formats::FogPolicy,
+ env_map: bool,
+ shade: ShadeSel,
+ batch_order: u16,
+ uv_anim: Option<&std::sync::Arc>,
+ rgb_anim: Option<&std::sync::Arc>,
+ wmo_class: Option,
+ sidn: Option<[u8; 3]>,
+ window: bool,
+ sky_depth: bool,
+ light: &Buffer,
+ // MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
+ // material beside `light` (not a key axis — one pair for the whole scene, like the light).
+ torch: &TorchBinds,
+ // The ONE placement this material belongs to, or `None` for the shared batch material every
+ // instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
+ // the sequence its instance is playing (decision 1408): the animated-material registries are
+ // keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
+ instance: Option,
+) -> Handle {
+ model_material_impl(
+ cache,
+ materials,
+ texture,
+ blend,
+ two_sided,
+ is_wmo,
+ is_interior,
+ is_emissive,
+ is_additive,
+ fade_variant,
+ no_depth_write,
+ no_depth_test,
+ fog_policy,
+ env_map,
+ shade,
+ batch_order,
+ uv_anim,
+ rgb_anim,
+ wmo_class,
+ sidn,
+ window,
+ sky_depth,
+ light,
+ torch,
+ instance,
+ true,
+ )
+}
+
+#[allow(clippy::too_many_arguments)]
+fn model_material_impl(
+ cache: &mut MaterialCache,
+ materials: &mut Assets,
+ texture: Option>,
+ blend: ModelBlend,
+ two_sided: bool,
+ is_wmo: bool,
+ is_interior: bool,
+ is_emissive: bool,
+ is_additive: bool,
+ fade_variant: bool,
+ no_depth_write: bool,
+ no_depth_test: bool,
+ fog_policy: benilla_formats::FogPolicy,
+ env_map: bool,
+ shade: ShadeSel,
+ batch_order: u16,
+ uv_anim: Option<&std::sync::Arc>,
+ rgb_anim: Option<&std::sync::Arc>,
+ wmo_class: Option,
+ sidn: Option<[u8; 3]>,
+ window: bool,
+ sky_depth: bool,
+ light: &Buffer,
+ // MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
+ // material beside `light` (not a key axis — one pair for the whole scene, like the light).
+ torch: &TorchBinds,
+ // The ONE placement this material belongs to, or `None` for the shared batch material every
+ // instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
+ // the sequence its instance is playing (decision 1408): the animated-material registries are
+ // keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
+ instance: Option,
+ foliage_wind: bool,
) -> Handle {
let key = MatKey {
light: light.id(),
@@ -177,6 +311,7 @@ pub fn model_material(
sidn,
window,
zfill: false,
+ foliage_wind,
sky_depth,
instance,
};
@@ -263,7 +398,8 @@ pub fn model_material(
| (u16::from(blend == ModelBlend::Mod2x) << 8)
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER)
| (u16::from(env_map) * ENV_MAP_MARKER)
- | (u16::from(sky_depth) * SKY_DEPTH_MARKER),
+ | (u16::from(sky_depth) * SKY_DEPTH_MARKER)
+ | (u16::from(foliage_wind) * FOLIAGE_WIND_MARKER),
),
0.0,
),
@@ -349,6 +485,12 @@ pub(crate) const ENV_MAP_MARKER: u16 = 1 << 12;
/// vertex with no raster bias; a `WowModelKey` axis, so every other draw keeps its real depth.
pub(crate) const SKY_DEPTH_MARKER: u16 = 1 << 13;
+/// MONKEY (fix-wind): `clutter_fade.z` marker bit 14: a classified tree/bush leaf batch's exile
+/// copy, the only entity draw `wow_model.wgsl` applies `tree_offset` to. Vertex-only uniform data,
+/// not a `WowModelKey` axis. (It replaced MeshTag bit 18, which aliases the lane weight and the
+/// interior probe slot, so doorway units and interior props swayed.)
+pub(crate) const FOLIAGE_WIND_MARKER: u16 = 1 << 14;
+
/// [`BATCH_ORDER_SORT_EPS`] for the WMO skybox lane: at the −6e4 rung the f32 ulp is 0.0039, so
/// 1e-3 would round away; this step is 4 ulps. Every skybox batch pair ties, being eye-anchored.
pub(crate) const SKYBOX_ORDER_EPS: f32 = 0.015625;
@@ -412,6 +554,7 @@ pub fn zfill_material(
sidn: None,
window: false,
zfill: true,
+ foliage_wind: false,
// No skybox batch has a twin: the sky writes no depth.
sky_depth: false,
instance: None,
diff --git a/crates/benilla-world/src/static_gx/cull.rs b/crates/benilla-world/src/static_gx/cull.rs
index 8db109b1..814bb594 100644
--- a/crates/benilla-world/src/static_gx/cull.rs
+++ b/crates/benilla-world/src/static_gx/cull.rs
@@ -100,11 +100,8 @@ fn spawn_exile(commands: &mut Commands, f: &GxFader, alpha: f32, admitted: bool)
},
crate::model_render::EntityPathWhy("exile"),
crate::interact::PickMesh(b.geometry.clone()),
- // MONKEY (wind): bit 18 is the fade-twin leaf marker in exterior payload mode.
- bevy::mesh::MeshTag(crate::mesh_tag::with_foliage_wind(
- crate::mesh_tag::alpha_bits(alpha),
- b.foliage_wind,
- )),
+ // MONKEY (fix-wind): a leaf batch's sway marker rides its material (`FOLIAGE_WIND_MARKER`).
+ bevy::mesh::MeshTag(crate::mesh_tag::alpha_bits(alpha)),
crate::model_fade::DoodadFade {
radius: f.radius,
local_center: f.local_center,
diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs
index e860e688..f61d4cd4 100644
--- a/crates/benilla-world/src/static_gx/mod.rs
+++ b/crates/benilla-world/src/static_gx/mod.rs
@@ -215,8 +215,6 @@ struct GxFaderBatch {
blend: Handle,
blend_mode: ModelBlend,
geometry: Arc,
- /// MONKEY (wind): mirror the retained batch's sway while this placement fades as an entity.
- foliage_wind: bool,
}
/// A fader's exile state. Steady: drawn retained at fade 1. Exiled: drawn as ordinary entities,
@@ -569,7 +567,6 @@ impl StaticGx {
blend: seed.blend,
blend_mode: b.blend,
geometry: b.geometry.clone(),
- foliage_wind,
});
if is_new {
// A placement's later batches share its sphere: the caches move only on a new one.
@@ -706,7 +703,7 @@ impl StaticGx {
/// MONKEY (wind): conservative leaf classification. Alpha test excludes trunks/rocks even when a
/// whole model path says tree; the name terms cover vanilla's tree/bush/plant families. Animated
/// doodads never reach this function because `assemble.rs` excludes them before `StaticGx::divert`.
-fn foliage_wind_batch(path: &str, blend: ModelBlend, wmo_geometry: bool) -> bool {
+pub(crate) fn foliage_wind_batch(path: &str, blend: ModelBlend, wmo_geometry: bool) -> bool {
if wmo_geometry || blend != ModelBlend::AlphaTest {
return false;
}
diff --git a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs
index fa58849d..8327b925 100644
--- a/crates/benilla-world/src/terrain_stream/spawn/assemble.rs
+++ b/crates/benilla-world/src/terrain_stream/spawn/assemble.rs
@@ -233,6 +233,44 @@ pub fn spawn_model_entities(
seq_owner,
)
};
+ // MONKEY (fix-wind): a classified exterior tree/bush leaf batch gets marked copies of both
+ // materials for its fade-band exile (`FOLIAGE_WIND_MARKER`), so only those entity draws
+ // sway; the plain pair stays on every other path.
+ let wind_seed = (!interior
+ && !steady_interior_prop
+ && crate::static_gx::foliage_wind_batch(&object.label, sub.blend, is_wmo))
+ .then(|| {
+ let mut wind_mat = |fade: bool| {
+ crate::model_render::foliage_model_material(
+ mat_cache,
+ materials,
+ sub.texture.clone(),
+ sub.blend,
+ two_sided,
+ is_wmo,
+ interior,
+ sub.emissive,
+ sub.additive,
+ fade,
+ sub.no_depth_write,
+ sub.no_depth_test,
+ sub.fog_policy,
+ sub.env_map,
+ shade,
+ batch_order,
+ sub.uv_anim.as_ref(),
+ sub.rgb_anim.as_ref(),
+ sub.wmo_batch,
+ sub.sidn,
+ sub.window,
+ false,
+ light,
+ torch,
+ seq_owner,
+ )
+ };
+ (wind_mat(false), wind_mat(true))
+ });
// One classification for the census tally and the diverts, so the two cannot drift.
let class = crate::static_merge::BatchClass {
excluded: animated
@@ -311,8 +349,13 @@ pub fn spawn_model_entities(
local_center,
stat_mesh: stat_mesh.clone(),
aabb: *stat_aabb,
- cutout: cutout.clone(),
- blend: blend.clone(),
+ // MONKEY (fix-wind): the marked pair for a leaf batch.
+ cutout: wind_seed
+ .as_ref()
+ .map_or_else(|| cutout.clone(), |w| w.0.clone()),
+ blend: wind_seed
+ .as_ref()
+ .map_or_else(|| blend.clone(), |w| w.1.clone()),
});
// A never-fader enters bare, a fader only with its seed.
(class.never_fade || fade_seed.is_some())