From 658f50d8230130911c87cae081d069e551aa3eb6 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:53:01 +0100
Subject: [PATCH 1/7] MONKEY (skybox): LightSkybox reader for both layouts +
zone_skyboxes walk
Reads the 1.12 two-field and the extended ID/Name/Flags/CelestialName table by
header field count; zone_skyboxes() runs the modern collector over the
sphere-weighted Light walk.
Co-Authored-By: Claude Opus 5.5
---
crates/benilla-formats/src/lib.rs | 6 +-
crates/benilla-formats/src/light.rs | 34 ++-
crates/benilla-formats/src/light/skybox.rs | 249 +++++++++++++++++++++
3 files changed, 267 insertions(+), 22 deletions(-)
create mode 100644 crates/benilla-formats/src/light/skybox.rs
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index ad87a31e..83813eda 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -127,7 +127,11 @@ pub use ground_effects::{
GroundEffectCatalog, FRILL_DENSITY, FRILL_DENSITY_MAX,
};
mod light;
-pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
+// MONKEY (skybox): the skybox row and the zone walk's entries.
+pub use light::{
+ Atmosphere, LightCatalog, SkyboxDef, Submersion, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY,
+ SKYBOX_KEEP_CELESTIAL, ZERO_KEY_COLOR, ZERO_KEY_SCALAR,
+};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs
index 3edc847e..471e2ccf 100644
--- a/crates/benilla-formats/src/light.rs
+++ b/crates/benilla-formats/src/light.rs
@@ -7,13 +7,18 @@ use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
-use crate::dbc::{f32_at, parse, str_at, u32_at};
+use crate::dbc::{f32_at, parse, u32_at};
use crate::Chain;
mod atmosphere;
+// MONKEY (skybox): the extended LightSkybox layout and the zone skybox walk.
+mod skybox;
mod tables;
pub use atmosphere::Atmosphere;
+pub use skybox::{
+ SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL,
+};
use atmosphere::{
FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE,
IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP,
@@ -171,9 +176,10 @@ pub struct LightCatalog {
/// `LightParams` id → `lightSkyboxID` ([`LP_SKYBOX`]), for its non-zero rows only.
light_params_skybox: HashMap,
/// `LightSkybox.dbc` id → model chain path, spelled as a WMO MOSB skybox is so one model never
- /// builds twice. Only id 3 (`DeathClouds.mdx`) is reachable from `LightParams`; the other five
- /// rows are MOSB skyboxes.
- skyboxes: HashMap,
+ /// builds twice. Stock 1.12 reaches only id 3 (`DeathClouds.mdx`) from `LightParams`; the
+ /// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along
+ /// ([`SkyboxDef`]), read from either table layout.
+ skyboxes: HashMap,
}
fn light_schema() -> Schema {
@@ -319,22 +325,8 @@ impl LightCatalog {
}
(glow, highlight, water_alpha, skybox)
};
- let skyboxes = {
- let bytes = chain
- .read_file(LIGHT_SKYBOX)
- .with_context(|| format!("reading {LIGHT_SKYBOX}"))?;
- let mut schema = Schema::new("LightSkybox");
- schema.add_field(SchemaField::new("ID", FieldType::UInt32));
- schema.add_field(SchemaField::new("Name", FieldType::String));
- let rs = parse(&bytes, schema, "LightSkybox")?;
- let mut m = HashMap::with_capacity(rs.records().len());
- for r in rs.records() {
- if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) {
- m.insert(id, crate::models::model_path(&path));
- }
- }
- m
- };
+ // MONKEY (skybox): both layouts, keyed by the header's field count.
+ let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?;
Ok(LightCatalog {
lights,
int_bands,
@@ -438,7 +430,7 @@ impl LightCatalog {
pub fn ghost_skybox(&self, map: u32, pos: [f32; 3]) -> Option<&str> {
let light = self.pick_light(map, pos)?;
let id = self.light_params_skybox.get(&light.params[SLOT_DEATH])?;
- self.skyboxes.get(id).map(String::as_str)
+ self.skyboxes.get(id).map(|d| d.path.as_str())
}
/// The reference's area-light blend: from the map's global light, lerp toward each local
diff --git a/crates/benilla-formats/src/light/skybox.rs b/crates/benilla-formats/src/light/skybox.rs
new file mode 100644
index 00000000..70322687
--- /dev/null
+++ b/crates/benilla-formats/src/light/skybox.rs
@@ -0,0 +1,249 @@
+//! MONKEY (skybox): `LightSkybox.dbc` in both layouts and the zone skybox walk.
+//!
+//! The 1.12 table is `ID, Name` (8-byte records). The extended table the sky patch ships is the
+//! modern column set with paths in place of FileDataIDs: `ID, Name, Flags, CelestialName`
+//! (16-byte records). The layout is picked by the header's field count, so an unpatched data set
+//! loads with flags 0 and no celestial model.
+//!
+//! [`LightCatalog::zone_skyboxes`] is the modern client's skybox collector
+//! (`DayNightLightHolder::SkyBoxCollector::addSkyBox`) over the same global-then-farthest-first
+//! walk as [`LightCatalog::sample_blended`]: the same model keeps its largest alpha, and every
+//! other model collected so far is scaled by `1 − alpha`, so two skyboxes crossfade. A light whose
+//! param names no skybox leaves the list alone, as in the modern client.
+
+use std::collections::HashMap;
+
+use anyhow::{Context, Result};
+use benilla_dbc::{FieldType, Schema, SchemaField};
+
+use super::{blend_alpha, weather_slot, LightCatalog, Submersion, FALLBACK_LIGHT_ID, SLOT_CLEAR};
+use crate::dbc::{parse, str_at, u32_at};
+use crate::Chain;
+
+/// Flag `0x1`: the model's sequence 0 plays across the game day, `time = duration × day fraction`.
+pub const SKYBOX_FULL_DAY: u32 = 0x1;
+/// Flag `0x2`: the procedural sky (dome, sun, moons, stars, clouds) keeps drawing under the model.
+pub const SKYBOX_KEEP_CELESTIAL: u32 = 0x2;
+/// Flag `0x4`: an alpha cone in the final fog colour draws over the model's horizon.
+pub const SKYBOX_FOG_BLEND: u32 = 0x4;
+
+/// One `LightSkybox.dbc` row.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SkyboxDef {
+ /// The model's chain path, spelled as a WMO MOSB skybox is, so one model never builds twice.
+ pub path: String,
+ /// The modern flag word; 0 in the 1.12 layout.
+ pub flags: u32,
+ /// The second model the modern client names as the celestial layer; `None` when empty.
+ pub celestial: Option,
+}
+
+/// One weighted skybox the zone walk collected.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ZoneSkybox {
+ pub id: u32,
+ pub weight: f32,
+}
+
+/// Read `LightSkybox.dbc`, choosing the schema by the header's field count.
+pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result> {
+ let bytes = chain
+ .read_file(file)
+ .with_context(|| format!("reading {file}"))?;
+ let fields = bytes
+ .get(8..12)
+ .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
+ .unwrap_or(2);
+ let extended = fields >= 4;
+ let mut schema = Schema::new("LightSkybox");
+ schema.add_field(SchemaField::new("ID", FieldType::UInt32));
+ schema.add_field(SchemaField::new("Name", FieldType::String));
+ if extended {
+ schema.add_field(SchemaField::new("Flags", FieldType::UInt32));
+ schema.add_field(SchemaField::new("CelestialName", FieldType::String));
+ // Columns past the four this client reads, if a later table carries them.
+ for i in 4..fields {
+ schema.add_field(SchemaField::new(format!("extra{i}"), FieldType::UInt32));
+ }
+ }
+ let rs = parse(&bytes, schema, "LightSkybox")?;
+ let mut m = HashMap::with_capacity(rs.records().len());
+ for r in rs.records() {
+ let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 1)) else {
+ continue;
+ };
+ let (flags, celestial) = if extended {
+ (
+ u32_at(r, 2).unwrap_or(0),
+ str_at(&rs, r, 3)
+ .filter(|c| !c.trim().is_empty())
+ .map(|c| crate::models::model_path(&c)),
+ )
+ } else {
+ (0, None)
+ };
+ m.insert(
+ id,
+ SkyboxDef {
+ path: crate::models::model_path(&name),
+ flags,
+ celestial,
+ },
+ );
+ }
+ Ok(m)
+}
+
+/// The modern collector's step: dedupe by model with the larger alpha, then scale every other
+/// entry by `1 − alpha` (`addSkyBox` steps 1 and 4).
+pub(super) fn collect(list: &mut Vec, id: u32, alpha: f32) {
+ if alpha <= 0.0 {
+ return;
+ }
+ let (at, alpha) = match list.iter_mut().position(|e| e.id == id) {
+ Some(i) => {
+ let a = list[i].weight.max(alpha);
+ list[i].weight = a;
+ (i, a)
+ }
+ None => {
+ list.push(ZoneSkybox { id, weight: alpha });
+ (list.len() - 1, alpha)
+ }
+ };
+ for (i, e) in list.iter_mut().enumerate() {
+ if i != at {
+ e.weight *= 1.0 - alpha;
+ }
+ }
+}
+
+impl LightCatalog {
+ /// A `LightSkybox.dbc` row by id.
+ pub fn skybox_def(&self, id: u32) -> Option<&SkyboxDef> {
+ self.skyboxes.get(&id)
+ }
+
+ /// The row whose model is `path`, for a MOSB skybox the table also names.
+ pub fn skybox_def_by_path(&self, path: &str) -> Option<&SkyboxDef> {
+ self.skyboxes
+ .values()
+ .find(|d| d.path.eq_ignore_ascii_case(path))
+ }
+
+ /// The skybox the slot's param names; a weather slot without one falls back to the clear
+ /// slot's, because the sky patch authors the clear slot only.
+ fn light_skybox(&self, params: &[u32; 5], slot: usize) -> Option {
+ let of = |p: u32| self.light_params_skybox.get(&p).copied();
+ let param = match params[slot] {
+ 0 => params[SLOT_CLEAR],
+ p => p,
+ };
+ of(param).or_else(|| (slot != SLOT_CLEAR).then(|| of(params[SLOT_CLEAR])).flatten())
+ }
+
+ /// The weighted skyboxes at `pos` for the living: the global (or fallback) light at alpha 1,
+ /// then each local sphere containing `pos` farthest first at its falloff alpha, through
+ /// [`collect`]. The slot is the lighting resolve's ([`weather_slot`]), ghost excluded: the
+ /// death sky is [`Self::ghost_skybox`].
+ pub fn zone_skyboxes(
+ &self,
+ map: u32,
+ pos: [f32; 3],
+ stormy: bool,
+ submersion: Submersion,
+ ) -> Vec {
+ let mut out = Vec::new();
+ if submersion.fixed_param().is_some() {
+ return out;
+ }
+ let slot = weather_slot(false, stormy, submersion.is_water());
+ let map_has_no_light = !self.lights.iter().any(|l| l.map == map);
+ let base = self
+ .lights
+ .iter()
+ .find(|l| l.map == map && l.global)
+ .or_else(|| {
+ map_has_no_light
+ .then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID))
+ .flatten()
+ });
+ if let Some(sky) = base.and_then(|l| self.light_skybox(&l.params, slot)) {
+ collect(&mut out, sky, 1.0);
+ }
+ let mut locals: Vec<(f32, &super::Light)> = self
+ .lights
+ .iter()
+ .filter(|l| l.map == map && !l.global)
+ .filter_map(|l| {
+ let d = (0..3)
+ .map(|i| (l.pos[i] - pos[i]).powi(2))
+ .sum::()
+ .sqrt();
+ (d <= l.falloff_end).then_some((d, l))
+ })
+ .collect();
+ locals.sort_by(|a, b| b.0.total_cmp(&a.0));
+ for (dist, l) in locals {
+ if let Some(sky) = self.light_skybox(&l.params, slot) {
+ collect(
+ &mut out,
+ sky,
+ blend_alpha(dist, l.falloff_start, l.falloff_end),
+ );
+ }
+ }
+ out.retain(|e| e.weight > 0.0 && self.skyboxes.contains_key(&e.id));
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn w(list: &[ZoneSkybox], id: u32) -> f32 {
+ list.iter().find(|e| e.id == id).map_or(0.0, |e| e.weight)
+ }
+
+ #[test]
+ fn a_second_model_crossfades_the_first() {
+ let mut l = Vec::new();
+ collect(&mut l, 1, 1.0);
+ collect(&mut l, 2, 0.25);
+ assert!((w(&l, 1) - 0.75).abs() < 1e-6);
+ assert!((w(&l, 2) - 0.25).abs() < 1e-6);
+ }
+
+ #[test]
+ fn the_same_model_keeps_its_largest_alpha() {
+ let mut l = Vec::new();
+ collect(&mut l, 1, 0.5);
+ collect(&mut l, 2, 0.5);
+ collect(&mut l, 1, 0.8);
+ assert_eq!(l.len(), 2);
+ assert!((w(&l, 1) - 0.8).abs() < 1e-6);
+ assert!((w(&l, 2) - 0.5 * 0.2).abs() < 1e-6);
+ }
+
+ #[test]
+ fn a_zero_alpha_leaves_the_list_alone() {
+ let mut l = Vec::new();
+ collect(&mut l, 1, 1.0);
+ collect(&mut l, 2, 0.0);
+ assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]);
+ }
+
+ /// The installed chain loads in either layout.
+ #[test]
+ fn the_installed_table_loads() {
+ let data = crate::wow_data_or_skip!();
+ let mut chain = crate::open_chain(&data).expect("open chain");
+ let m = load_skyboxes(&mut chain, super::super::LIGHT_SKYBOX).expect("LightSkybox");
+ assert_eq!(
+ m.get(&3).map(|d| d.path.as_str()),
+ Some(r"environments\stars\deathclouds.m2"),
+ "the ghost sky row"
+ );
+ }
+}
From 88ceb9d2c2fc283f3fff61f745aae914e5adf4a1 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 03:23:34 +0100
Subject: [PATCH 2/7] 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