From 88ceb9d2c2fc283f3fff61f745aae914e5adf4a1 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 03:23:34 +0100
Subject: [PATCH] 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
---
crates/benilla-world/src/lib.rs | 2 +
.../benilla-world/src/model_render/batch.rs | 46 +-
crates/benilla-world/src/skybox.rs | 845 ++++++++++++++----
crates/benilla-world/src/skybox_anim.rs | 527 +++++++++++
crates/benilla-world/src/world_census.rs | 5 +-
5 files changed, 1260 insertions(+), 165 deletions(-)
create mode 100644 crates/benilla-world/src/skybox_anim.rs
diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs
index 02aef8e8..38281004 100644
--- a/crates/benilla-world/src/lib.rs
+++ b/crates/benilla-world/src/lib.rs
@@ -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;
diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs
index 9adc718e..eeeded74 100644
--- a/crates/benilla-world/src/model_render/batch.rs
+++ b/crates/benilla-world/src/model_render/batch.rs
@@ -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>,
order: u16,
+ uv: Option<&std::sync::Arc>,
+ tint: Option<&std::sync::Arc>,
) -> Option {
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> {
+ 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
diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs
index d4995c09..c7ab7add 100644
--- a/crates/benilla-world/src/skybox.rs
+++ b/crates/benilla-world/src/skybox.rs
@@ -14,31 +14,88 @@
//! both moons, gradient band and cloud dome; below it they draw under the sky. The glare quads
//! draw on their own path (`0x483740` → `0x6d48c0` → `0x7e57e0`), and the fog, ambient and
//! diffuse are untouched.
+//!
+//! MONKEY (skybox): a third slot, the living player's zone skybox, behind the cvar
+//! `zoneSkyboxes` ([`ZoneSkyboxes`]). The 1.12 engine never draws a `LightParams` skybox for the
+//! living; the modern client does, weighted by the Light sphere falloff
+//! ([`benilla_formats::LightCatalog::zone_skyboxes`]). The backdrop is a weighted list
+//! ([`CameraSkybox`]): ghost > WMO > zone, the WMO sky crossfading the zone list by the modern
+//! collector's rule. `LightSkybox.dbc` flags from the extended table: `0x1` plays sequence 0 over
+//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon;
+//! a celestial model draws as a layer under its main model. Every batch animates
+//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks.
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
use bevy::asset::RenderAssetUsages;
+use bevy::camera::visibility::NoFrustumCulling;
+use bevy::math::Affine3A;
use bevy::mesh::{Indices, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use crate::model_render::M2BatchMaterials;
+use crate::skybox_anim::{SkyMatLane, SkyRig};
use crate::view::WorldCamera;
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WmoModel;
use benilla_assets::{LockRecover, WorldAssets};
+use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
/// The MOGP/MOGI group flag asking for the root's MOSB sky; the loader keeps the MOGP copy in
/// `WmoGroupNav::flags`.
const SHOW_SKYBOX: u32 = 0x40000;
-/// The skybox model this frame asks for; `None` leaves the [`crate::sky`] gradient dome.
-#[derive(Resource, Default, PartialEq, Eq)]
-pub struct CameraSkybox(pub Option);
+/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so
+/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last.
+const MAIN_ORDER_BASE: u16 = 24;
+/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step.
+const FOG_CONE_ORDER: u16 = 56;
+
+/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox
+/// from `LightParams`. Off leaves the reference's two slots only.
+#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)]
+pub struct ZoneSkyboxes(pub bool);
+
+/// One skybox model the frame asks for, at its weight.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyboxLayer {
+ /// The model path, as the slot named it.
+ pub path: String,
+ /// The slot weight, `[0, 1]`; 0 fills the slot without drawing (`0x6d4afe`).
+ pub weight: f32,
+ /// `LightSkybox.dbc` flags, 0 without a row or in the 1.12 table.
+ pub flags: u32,
+ /// The celestial layer of another layer's row, drawn under it.
+ pub celestial: bool,
+}
+
+/// The skybox models this frame asks for; empty leaves the [`crate::sky`] gradient dome.
+#[derive(Resource, Default, PartialEq)]
+pub struct CameraSkybox(pub Vec);
+
+impl CameraSkybox {
+ /// The heaviest main layer, the one a readout names.
+ pub fn primary(&self) -> Option<&SkyboxLayer> {
+ self.0
+ .iter()
+ .filter(|l| !l.celestial)
+ .fold(None, |best: Option<&SkyboxLayer>, l| match best {
+ Some(b) if b.weight >= l.weight => Some(b),
+ _ => Some(l),
+ })
+ }
+
+ fn layer(&self, path: &str) -> Option<&SkyboxLayer> {
+ self.0.iter().find(|l| l.path == path)
+ }
+}
/// The skybox slot's weight this frame: the interior crossfade [`crate::lighting::WmoCrossfade`]
/// (±0.25/s, `[0x8115b0]`) for a WMO sky, 1.0 for the ghost sky (`0x6d2260`), and 0 when none
-/// resolves.
+/// resolves. MONKEY (skybox): the heaviest layer that does not keep the celestial pass (flag
+/// `0x2`), so a combining zone sky never stands the procedural sky down.
#[derive(Resource, Default, PartialEq)]
pub struct SkyboxWeight(pub f32);
@@ -60,22 +117,55 @@ struct SkyboxPart {
/// The blend-promotion twin for `0 < weight < 1` (`0x811fe0`); `steady` itself when the batch
/// already blends.
fade_blend: Handle,
+ /// MONKEY (skybox): the batch's colour-alpha × transparency loops (`0x707680`).
+ alpha: Option,
+ /// MONKEY (skybox): the last sampled alpha factor.
+ anim_alpha: f32,
+ /// MONKEY (skybox): the batch's texture-transform and colour rows.
+ lane: SkyMatLane,
+ /// MONKEY (skybox): how the rig moves the batch.
+ pose: PartPose,
}
-/// A batch wholly weighted to one parentless rotation-only bone, so it spins rigidly about the
-/// bone's pivot ([`benilla_formats::BoneSpin`]) with no joint children, which would lag the
-/// post-propagation anchor a frame. Only `CavernsOfTimeSky.m2`'s four asteroid belts spin.
-#[derive(Component)]
-struct SkyboxSpin {
- /// The bone's pivot, in the same (Bevy) space the batch's vertices were baked into.
- pivot: Vec3,
- spin: benilla_formats::BoneSpin,
+/// MONKEY (skybox): how a batch follows its model's rig.
+enum PartPose {
+ /// No moving bone under the batch.
+ Static,
+ /// Wholly weighted to one bone: the bone's matrix is the batch's transform.
+ Rigid(u16),
+ /// Spread over moving bones: skinned on the CPU into its mesh.
+ Skinned {
+ mesh: Handle,
+ base: Vec,
+ joints: Vec<[u16; 4]>,
+ weights: Vec<[f32; 4]>,
+ },
}
+/// MONKEY (skybox): the batch's model-space pose this frame, composed with the eye in
+/// [`follow_camera`].
+#[derive(Component, Default)]
+struct SkyboxLocal(Affine3A);
+
/// Skybox paths built this session, failed loads included, so a failure is not retried per frame.
#[derive(Resource, Default)]
struct BuiltSkyboxes(HashSet);
+/// MONKEY (skybox): each built model's rig, by path.
+#[derive(Resource, Default)]
+struct SkyRigs(HashMap>);
+
+/// MONKEY (skybox): the flag `0x4` fog cone: its entity and its tint row.
+#[derive(Resource, Default)]
+struct FogCone {
+ entity: Option,
+ slot: Option,
+}
+
+/// MONKEY (skybox): marks the fog cone's entity.
+#[derive(Component)]
+struct FogConePart;
+
/// [`CameraSkybox`] is settled after this set; the dome's gate runs after it, so the two backdrops
/// agree within a frame.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -89,9 +179,17 @@ impl Plugin for SkyboxPlugin {
app.init_resource::()
.init_resource::()
.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
.add_systems(
Update,
- (resolve_camera_skybox, build_skybox, apply_skybox_visibility)
+ (
+ resolve_camera_skybox,
+ build_skybox,
+ animate_skyboxes,
+ apply_skybox_visibility,
+ )
.chain()
// After the PVS pass, whose flood this reads the same frame.
.after(crate::wmo_portal::WmoPvsSet)
@@ -108,9 +206,49 @@ impl Plugin for SkyboxPlugin {
}
}
+/// MONKEY (skybox): a path as `model_path` spells it, for comparing a MOSB name with a DBC one.
+fn norm(path: &str) -> String {
+ let lower = path.to_ascii_lowercase();
+ match lower
+ .strip_suffix(".mdx")
+ .or_else(|| lower.strip_suffix(".mdl"))
+ {
+ Some(stem) => format!("{stem}.m2"),
+ None => lower,
+ }
+}
+
+/// 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) {
+ Some(i) => {
+ layers[i].weight = layers[i].weight.max(weight);
+ i
+ }
+ None => {
+ layers.push(SkyboxLayer {
+ path: path.to_owned(),
+ weight,
+ flags,
+ celestial: false,
+ });
+ layers.len() - 1
+ }
+ };
+ let w = layers[at].weight;
+ for (i, l) in layers.iter_mut().enumerate() {
+ if i != at {
+ l.weight *= 1.0 - w;
+ }
+ }
+}
+
/// Resolve the wanted skybox and its weight. A WMO sky needs a group of the placement's flood PVS,
/// not the camera's own group, to carry [`SHOW_SKYBOX`] (`0x6b42e0`, `ebx` the group visited) and
/// its root to name a MOSB; the weight rides the down-ray claim's crossfade, a separate resolver.
+#[allow(clippy::too_many_arguments)]
fn resolve_camera_skybox(
instances: Query<&crate::wmo_portal::WmoPortalInstance>,
wmos: Res>,
@@ -119,71 +257,158 @@ fn resolve_camera_skybox(
current_map: Option>,
cam: Query<&GlobalTransform, With>,
crossfade: Res,
+ zone: Res,
+ weather: Option>,
+ eye_liquid: crate::liquid::EyeLiquid,
mut want: ResMut,
mut weight: ResMut,
) {
+ let pos = cam.single().ok().map(|t| bevy_to_wow(t.translation()));
+ let map = current_map.as_ref().map_or(0, |m| m.0);
+ let catalog = sampler.as_ref().map(|s| &s.0);
+ let flags_of = |path: &str| {
+ catalog
+ .and_then(|c| c.skybox_def_by_path(&norm(path)))
+ .map_or(0, |d| d.flags)
+ };
+ let mut layers: Vec = Vec::new();
+
// The ghost sky first, as its slot skips the WMO one. Resolved from the atmosphere's map and
// camera position, off the same ghost flag (`PLAYER_FLAGS` 0x10), so the two switch together.
- if viewer.ghost {
- let ghost_sky = sampler.as_ref().and_then(|s| {
- let pos = bevy_to_wow(cam.single().ok()?.translation());
- let map = current_map.as_ref().map_or(0, |m| m.0);
- s.0.ghost_skybox(map, pos)
+ let ghost_sky = viewer
+ .ghost
+ .then(|| catalog.zip(pos).and_then(|(c, p)| c.ghost_skybox(map, p)))
+ .flatten();
+ if let Some(sky) = ghost_sky {
+ // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in.
+ layers.push(SkyboxLayer {
+ path: sky.to_owned(),
+ weight: 1.0,
+ flags: flags_of(sky),
+ celestial: false,
});
- if let Some(sky) = ghost_sky {
- if want.0.as_deref() != Some(sky) {
- want.0 = Some(sky.to_owned());
+ } else {
+ // MONKEY (skybox): the zone list, under the WMO slot. Hidden submerged, as the celestial
+ // pass is; a storm lerps the storm slot's list over the clear one, as the atmosphere does.
+ if zone.0 && !eye_liquid.submersion().any() {
+ if let (Some(c), Some(p)) = (catalog, pos) {
+ let storm = weather
+ .as_ref()
+ .map_or(0.0, |w| crate::weather::storm_blend(w.sky_density));
+ let mut acc: Vec<(u32, f32)> = c
+ .zone_skyboxes(map, p, false, benilla_formats::Submersion::Dry)
+ .into_iter()
+ .map(|e| (e.id, e.weight * (1.0 - storm)))
+ .collect();
+ if storm > 0.0 {
+ for e in c.zone_skyboxes(map, p, true, benilla_formats::Submersion::Dry) {
+ match acc.iter_mut().find(|a| a.0 == e.id) {
+ Some(a) => a.1 += e.weight * storm,
+ None => acc.push((e.id, e.weight * storm)),
+ }
+ }
+ }
+ for (id, w) in acc {
+ let Some(def) = c.skybox_def(id) else {
+ continue;
+ };
+ if w <= 0.0 {
+ continue;
+ }
+ match layers.iter_mut().find(|l| norm(&l.path) == def.path) {
+ Some(l) => l.weight = l.weight.max(w),
+ None => layers.push(SkyboxLayer {
+ path: def.path.clone(),
+ weight: w,
+ flags: def.flags,
+ celestial: false,
+ }),
+ }
+ }
+ }
+ }
+ // `min()`, not the first match: query order is unstable across frames, and two
+ // overlapping Caverns of Time shells both qualify.
+ let resolved = instances
+ .iter()
+ .filter_map(|inst| {
+ let model = wmos.get(&inst.handle)?;
+ // The MOSB test first: 810 of the game's 815 WMO roots name no skybox.
+ let sky = model.skybox.as_deref()?;
+ model
+ .group_nav
+ .iter()
+ .enumerate()
+ .any(|(i, nav)| {
+ // Fail closed, unlike the cull: a lookup miss here would paint a sky over
+ // the whole world.
+ nav.flags & SHOW_SKYBOX != 0
+ && inst.visible.get(i).copied().unwrap_or(false)
+ })
+ .then(|| sky.to_owned())
+ })
+ .min();
+ // 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 {
+ let t = crossfade.t();
+ 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)) {
+ layers.push(SkyboxLayer {
+ path: sky,
+ weight: 0.0,
+ flags,
+ celestial: false,
+ });
}
- // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in.
- weight.set_if_neq(SkyboxWeight(1.0));
- return;
}
}
- // `min()`, not the first match: query order is unstable across frames, and two overlapping
- // Caverns of Time shells both qualify.
- let resolved = instances
+ // MONKEY (skybox): each row's celestial model, a layer at its main model's weight and flags.
+ let celestial: Vec = layers
.iter()
- .filter_map(|inst| {
- let model = wmos.get(&inst.handle)?;
- // The MOSB test first: 810 of the game's 815 WMO roots name no skybox.
- let sky = model.skybox.as_deref()?;
- model
- .group_nav
- .iter()
- .enumerate()
- .any(|(i, nav)| {
- // Fail closed, unlike the cull: a lookup miss here would paint a sky over the
- // whole world.
- nav.flags & SHOW_SKYBOX != 0 && inst.visible.get(i).copied().unwrap_or(false)
- })
- .then(|| sky.to_owned())
+ .filter_map(|l| {
+ let path = catalog?
+ .skybox_def_by_path(&norm(&l.path))?
+ .celestial
+ .clone()?;
+ Some(SkyboxLayer {
+ path,
+ weight: l.weight,
+ flags: l.flags,
+ celestial: true,
+ })
})
- .min();
- if want.0 != resolved {
- want.0 = resolved;
+ .collect();
+ layers.extend(celestial);
+ if want.0 != layers {
+ want.0 = layers;
}
- // 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.
- weight.set_if_neq(SkyboxWeight(match want.0 {
- Some(_) => crossfade.t(),
- None => 0.0,
- }));
+ let stand_down = want
+ .0
+ .iter()
+ .filter(|l| l.flags & SKYBOX_KEEP_CELESTIAL == 0)
+ .map(|l| l.weight)
+ .fold(0.0, f32::max);
+ weight.set_if_neq(SkyboxWeight(stand_down));
}
-/// Build the wanted skybox on first request; the models are small and few, so it stays built.
+/// Build the wanted skyboxes on first request; the models are small and few, so they stay built.
+#[allow(clippy::too_many_arguments)]
fn build_skybox(
mut commands: Commands,
want: Res,
mut built: ResMut,
+ mut rigs: ResMut,
+ mut cone: ResMut,
world_assets: Option>,
mut meshes: ResMut>,
mut images: ResMut>,
+ mut table: ResMut,
mut mats: M2BatchMaterials,
) {
- let Some(path) = want.0.as_deref() else {
- return;
- };
- if built.0.contains(path) {
+ if want.0.is_empty() {
return;
}
// Before the shared light buffer exists: retry, without latching `built`.
@@ -193,84 +418,213 @@ fn build_skybox(
let Some(mut world_assets) = world_assets else {
return; // assetless run: the gradient dome stays the backdrop
};
- // The model's rigid spins by bone; none under a deterministic run, which keeps bind poses.
- let spins = if crate::dev_state::deterministic_run() {
- Default::default()
- } else {
- benilla_formats::load_m2_bone_spins(&mut world_assets.chain.lock_recover(), path)
- .unwrap_or_default()
- };
- let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path);
- let subs = match subs {
- Ok(subs) if !subs.is_empty() => subs,
- Ok(_) => {
- warn!("skybox '{path}' has no render batches — keeping the gradient dome");
- built.0.insert(path.to_string());
- return;
+ if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) {
+ build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats);
+ }
+ for layer in &want.0 {
+ let path = layer.path.as_str();
+ if built.0.contains(path) {
+ continue;
}
- Err(e) => {
- warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}");
- built.0.insert(path.to_string());
- return;
- }
- };
- for (i, sub) in subs.iter().enumerate() {
- let mut mesh = Mesh::new(
- PrimitiveTopology::TriangleList,
- RenderAssetUsages::default(),
- );
- let positions: Vec<[f32; 3]> = sub
- .positions
- .iter()
- .map(|p| wow_to_bevy(*p).to_array())
- .collect();
- mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
- // Unread by the unlit sky, but the shared shader's vertex layout needs it.
- mesh.insert_attribute(
- Mesh::ATTRIBUTE_NORMAL,
- sub.normals
- .iter()
- .map(|n| wow_to_bevy(*n).to_array())
- .collect::>(),
+ // MONKEY (skybox): the rig, off the same bytes the batches come from.
+ let bytes = world_assets
+ .chain
+ .lock_recover()
+ .read_file(&norm(path))
+ .ok();
+ let rig = Arc::new(
+ bytes
+ .as_deref()
+ .map(SkyRig::from_bytes)
+ .unwrap_or_default(),
);
- mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.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
- .texture
- .as_deref()
- .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images));
- // `i + 1`, the authored batch order (0 is unordered): every batch shares one sort distance.
- let Some(pair) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else {
- return; // light buffer vanished mid-build; `built` is unlatched, so we retry
+ let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path);
+ let subs = match subs {
+ Ok(subs) if !subs.is_empty() => subs,
+ Ok(_) => {
+ warn!("skybox '{path}' has no render batches — keeping the gradient dome");
+ built.0.insert(path.to_string());
+ continue;
+ }
+ Err(e) => {
+ warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}");
+ built.0.insert(path.to_string());
+ continue;
+ }
};
- let part = commands
- .spawn((
- Mesh3d(meshes.add(mesh)),
- MeshMaterial3d(pair.steady.clone()),
- Transform::default(),
- Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one
- // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`.
- MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)),
- SkyboxPart {
- path: path.to_string(),
- steady: pair.steady,
- fade_blend: pair.fade_blend,
+ let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE };
+ for (i, sub) in subs.iter().enumerate() {
+ let mut mesh = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ let positions: Vec = sub.positions.iter().map(|p| wow_to_bevy(*p)).collect();
+ mesh.insert_attribute(
+ Mesh::ATTRIBUTE_POSITION,
+ positions.iter().map(|p| p.to_array()).collect::>(),
+ );
+ // Unread by the unlit sky, but the shared shader's vertex layout needs it.
+ mesh.insert_attribute(
+ Mesh::ATTRIBUTE_NORMAL,
+ sub.normals
+ .iter()
+ .map(|n| wow_to_bevy(*n).to_array())
+ .collect::>(),
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone());
+ // MONKEY (skybox): the constant M2Color, when the batch has one that is not white.
+ if sub.vertex_colors.len() == sub.positions.len()
+ && sub
+ .vertex_colors
+ .iter()
+ .any(|c| c.iter().any(|v| (v - 1.0).abs() > 1e-3))
+ {
+ mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.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
+ .texture
+ .as_deref()
+ .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images));
+ // MONKEY (skybox): the texture transform and colour loops as material identities.
+ let uv = sub.uv_anim.clone().map(Arc::new);
+ let tint = sub.rgb_anim.clone().map(Arc::new);
+ // 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 {
+ return; // light buffer vanished mid-build; `built` is unlatched, so we retry
+ };
+ let lane = SkyMatLane::register(
+ sub,
+ uv,
+ tint,
+ &mut table,
+ mats.materials(),
+ &[pair.steady.id(), pair.fade_blend.id()],
+ );
+ let mesh = meshes.add(mesh);
+ let pose = match sole_bone(sub) {
+ Some(b) if rig.bone_moves(b) => PartPose::Rigid(b),
+ Some(_) => PartPose::Static,
+ None if rig.animates() && !sub.joints.is_empty() => PartPose::Skinned {
+ mesh: mesh.clone(),
+ base: positions,
+ joints: sub.joints.clone(),
+ weights: sub.weights.clone(),
},
- ))
- .id();
- if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) {
- commands.entity(part).insert(SkyboxSpin {
- pivot: wow_to_bevy(spin.pivot),
- spin: spin.clone(),
- });
+ None => PartPose::Static,
+ };
+ let skinned = matches!(pose, PartPose::Skinned { .. });
+ let part = commands
+ .spawn((
+ Mesh3d(mesh),
+ MeshMaterial3d(pair.steady.clone()),
+ Transform::default(),
+ Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one
+ // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`.
+ MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)),
+ SkyboxLocal::default(),
+ SkyboxPart {
+ path: path.to_string(),
+ steady: pair.steady,
+ fade_blend: pair.fade_blend,
+ alpha: sub.alpha_anim.clone(),
+ anim_alpha: 1.0,
+ lane,
+ pose,
+ },
+ ))
+ .id();
+ if skinned {
+ // The mesh moves under its bounds; the shell surrounds the eye anyway.
+ commands.entity(part).insert(NoFrustumCulling);
+ }
}
+ rigs.0.insert(path.to_string(), rig);
+ built.0.insert(path.to_string());
}
- built.0.insert(path.to_string());
}
-/// The bone every vertex of this batch is wholly weighted to, if any: the batch's half of
-/// [`benilla_formats::BoneSpin`]'s rigid condition.
+/// MONKEY (skybox): the flag `0x4` cone, the modern client's sky-cone draw in the final fog colour
+/// (`skyMesh0x4Sky`): a band around the eye, opaque at and below the horizon, fading out by 24°
+/// of elevation. White vertices carry the alpha; the fog colour rides its tint row.
+fn build_fog_cone(
+ commands: &mut Commands,
+ cone: &mut FogCone,
+ meshes: &mut Assets,
+ table: &mut crate::mat_anim_table::MatAnimTable,
+ mats: &mut M2BatchMaterials,
+) {
+ const SEGMENTS: usize = 32;
+ const RADIUS: f32 = 40.0;
+ // (elevation degrees, alpha), bottom to top.
+ const RINGS: [(f32, f32); 7] = [
+ (-60.0, 1.0),
+ (0.0, 1.0),
+ (4.0, 0.85),
+ (8.0, 0.6),
+ (12.0, 0.35),
+ (18.0, 0.12),
+ (24.0, 0.0),
+ ];
+ let mut positions = Vec::new();
+ let mut colors = Vec::new();
+ for (elev, alpha) in RINGS {
+ let (se, ce) = elev.to_radians().sin_cos();
+ for s in 0..=SEGMENTS {
+ let az = s as f32 / SEGMENTS as f32 * std::f32::consts::TAU;
+ positions.push([RADIUS * ce * az.cos(), RADIUS * se, RADIUS * ce * az.sin()]);
+ colors.push([1.0, 1.0, 1.0, alpha]);
+ }
+ }
+ let row = SEGMENTS as u32 + 1;
+ let mut indices = Vec::new();
+ for r in 0..(RINGS.len() as u32 - 1) {
+ for s in 0..SEGMENTS as u32 {
+ let a = r * row + s;
+ let b = a + row;
+ indices.extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]);
+ }
+ }
+ let n = positions.len();
+ let mut mesh = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
+ mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]);
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.0, 0.0]; n]);
+ mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
+ mesh.insert_indices(Indices::U32(indices));
+ let Some(material) = mats.sky_fog_cone(FOG_CONE_ORDER) else {
+ return;
+ };
+ let slot = table.alloc();
+ if let Some(slot) = slot {
+ crate::model_render::lazy::with_material_mut(mats.materials(), material.id(), |m| {
+ m.extension.anim_slots.y = f32::from(slot);
+ });
+ }
+ let entity = commands
+ .spawn((
+ Mesh3d(meshes.add(mesh)),
+ MeshMaterial3d(material),
+ Transform::default(),
+ Visibility::Hidden,
+ MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)),
+ SkyboxLocal::default(),
+ FogConePart,
+ NoFrustumCulling,
+ ))
+ .id();
+ cone.entity = Some(entity);
+ cone.slot = slot;
+}
+
+/// The bone every vertex of this batch is wholly weighted to, if any: a rigid batch takes the
+/// bone's matrix whole, with no skinning.
fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option {
let bone = sub.joints.first()?[0];
(sub.weights.len() == sub.joints.len()
@@ -283,22 +637,145 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option {
.then_some(bone)
}
-/// Show the wanted skybox at the slot weight and hide every other, the sole `Visibility`, material
-/// and `MeshTag` writer for these entities. As the reference: hidden at weight 0 (`0x6d4afe`), the
-/// weight in every batch's alpha (`0x710cb0` → `[CM2Model+0x180]`), and below 1 the batch
-/// promoted to SRC_ALPHA blending whatever its mode (`0x811fe0`).
+/// MONKEY (skybox): one model's clock and pose this frame.
+struct ModelClock {
+ band_t: f32,
+ gseq: f64,
+ live: bool,
+ pose: Vec,
+}
+
+/// MONKEY (skybox): pose every shown skybox and run its material loops on the model's clock:
+/// sequence 0 at `duration × day fraction` under flag `0x1`, else the scene clock. A capture keeps
+/// the bind pose and the seeds, as the shared lanes freeze, unless the day drives the clock.
+#[allow(clippy::too_many_arguments)]
+fn animate_skyboxes(
+ time: Res