MONKEY (integration): merge gfx/skybox

Zone Skyboxes row moved to the end of the Advanced Graphics page and added to
OPTIONS_PAGE_ROWS (the lane had left it out of the list).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 18:08:23 +01:00
co-authored by Claude Opus 5.5
27 changed files with 1939 additions and 195 deletions
+1
View File
@@ -34,6 +34,7 @@ behind each constant.
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets |
| Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..4` diagnostic views (factor, protection, distance, raw occlusion), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning |
| Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` |
| Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
@@ -501,6 +501,8 @@
-- MONKEY (sky): the sky tier row.
skyQuality = "Sky Quality",
fogOff = "Off",
-- MONKEY (skybox): the zone skybox row.
zoneSkyboxes = "Zone Skyboxes",
waterQuality = "Water Quality",
waterClassic = "Classic",
waterEnhanced = "Enhanced",
@@ -570,6 +572,12 @@
.. "gradient, adds a soft glow around the sun and a starry night sky with the "
.. "Milky Way. High also gives the clouds finer detail and sunlit edges. "
.. "Costs very little.",
-- MONKEY (skybox): what the zone skybox row changes.
ZONE_SKYBOXES =
"Draws the painted sky a zone's lighting names, as later expansions do: "
.. "the volcanic sky of the Burning Steppes, the alien sky of the Blasted Lands "
.. "and the starry night over Mount Hyjal, fading in at the zone's edge. "
.. "Off keeps the original client's sky everywhere but ghost and dungeon skies. Nearly free.",
WATER_QUALITY =
"Classic is the original client's water. Enhanced adds soft shorelines, "
.. "animated foam around shores and objects, moving wave highlights, sky reflection "
@@ -709,6 +717,8 @@ Applies to buildings loaded after the change. "
-- MONKEY (sky): the sky tier row.
skyQuality = "Качество неба",
fogOff = "Выкл",
-- MONKEY (skybox): the zone skybox row.
zoneSkyboxes = "Небосводы зон",
waterQuality = "Качество воды",
waterClassic = "Классическое",
waterEnhanced = "Улучшенное",
@@ -780,6 +790,12 @@ Applies to buildings loaded after the change. "
.. "цветовой переход неба, добавляет мягкое сияние вокруг солнца и звёздное "
.. "ночное небо с Млечным Путём. «Высокое» также добавляет облакам мелкие "
.. "детали и освещённые солнцем края. Почти не влияет на производительность.",
-- MONKEY (skybox): what the zone skybox row changes.
ZONE_SKYBOXES =
"Рисует нарисованное небо, заданное освещением зоны, как в поздних дополнениях: "
.. "вулканическое небо Пылающих степей, чужое небо Выжженных земель "
.. "и звёздную ночь над горой Хиджал, с плавным появлением на границе зоны. "
.. "«Выкл.» оставляет небо оригинального клиента везде, кроме неба призрака и подземелий. Почти бесплатно.",
WATER_QUALITY =
"«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, "
.. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба "
@@ -993,6 +1009,8 @@ Applies to buildings loaded after the change. "
"RowLampFog",
-- MONKEY (integration): daylight window split.
"RowDaylightWindowSplit",
-- MONKEY (integration): the skybox lane's row (the lane left it out of this list).
"RowZoneSkyboxes",
},
-- Player Guild Names is a child of Player Names, as in 1.12 (l.702-708).
Nameplates = {
@@ -3186,6 +3204,18 @@ Applies to buildings loaded after the change. "
"BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (skybox): the zone skybox switch (integration: appended at the page end). -->
<Frame name="$parentRowZoneSkyboxes" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowDaylightWindowSplit" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowDaylightWindowSplit" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "zoneSkyboxes",
BENILLA_ADVGFX.zoneSkyboxes,
"BENILLA_TOOLTIP_ZONE_SKYBOXES")
</OnLoad></Scripts>
</Frame>
</Frames>
<!-- The poll (see AdvGfx_OnUpdate): this is the one page whose
settings move from outside it — the Lighting Quality row
@@ -1703,6 +1703,82 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
minute: 720,
ui: None,
},
// MONKEY (skybox): zone skyboxes (run with `WOW_ZONE_SKYBOXES=1`) and the two stock skybox
// lanes. Karazahn40 (Turtle map 814) is the one stock sphere naming a clear-slot skybox
// (Light 553, HellfireSkyBox); the zone shots need the sky patch.
// The stock MOSB lane: Stratholme_B's groups flagged 0x40000, weight = the interior crossfade.
Scenario {
name: "skybox-stratholme-noon",
map: Some(329),
eye: SKYBOX_STRAT_EYE,
look: SKYBOX_STRAT_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-karazhan-noon",
map: Some(814),
eye: SKYBOX_KZ_EYE,
look: SKYBOX_KZ_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-karazhan-night",
map: Some(814),
eye: SKYBOX_KZ_EYE,
look: SKYBOX_KZ_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "skybox-steppes-noon",
map: Some(MAP_AZEROTH),
eye: SKYBOX_STEPPES_EYE,
look: SKYBOX_STEPPES_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-steppes-night",
map: Some(MAP_AZEROTH),
eye: SKYBOX_STEPPES_EYE,
look: SKYBOX_STEPPES_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "skybox-blasted-noon",
map: Some(MAP_AZEROTH),
eye: SKYBOX_BLASTED_EYE,
look: SKYBOX_BLASTED_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-blasted-night",
map: Some(MAP_AZEROTH),
eye: SKYBOX_BLASTED_EYE,
look: SKYBOX_BLASTED_LOOK,
minute: 0,
ui: None,
},
Scenario {
name: "skybox-hyjal-noon",
map: Some(MAP_KALIMDOR),
eye: SKYBOX_HYJAL_EYE,
look: SKYBOX_HYJAL_LOOK,
minute: 720,
ui: None,
},
Scenario {
name: "skybox-hyjal-night",
map: Some(MAP_KALIMDOR),
eye: SKYBOX_HYJAL_EYE,
look: SKYBOX_HYJAL_LOOK,
minute: 0,
ui: None,
},
];
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
@@ -1823,6 +1899,19 @@ pub(crate) fn ui_opted_in() -> bool {
std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") || scenario_declares_ui()
}
// MONKEY (skybox): the skybox shots, eye just above the ground at a sphere's centre, pitched up so
// the upper frame is sky.
const SKYBOX_STRAT_EYE: [f32; 3] = [3450.0, -3380.0, 150.0];
const SKYBOX_STRAT_LOOK: [f32; 3] = [3600.0, -3300.0, 200.0];
const SKYBOX_KZ_EYE: [f32; 3] = [-6474.0, -2912.0, 40.0];
const SKYBOX_KZ_LOOK: [f32; 3] = [-6300.0, -2800.0, 95.0];
const SKYBOX_STEPPES_EYE: [f32; 3] = [-7979.0, -2571.0, 260.0];
const SKYBOX_STEPPES_LOOK: [f32; 3] = [-7800.0, -2450.0, 320.0];
const SKYBOX_BLASTED_EYE: [f32; 3] = [-11300.0, -3073.0, 30.0];
const SKYBOX_BLASTED_LOOK: [f32; 3] = [-11120.0, -2960.0, 90.0];
const SKYBOX_HYJAL_EYE: [f32; 3] = [4637.0, -4461.0, 1130.0];
const SKYBOX_HYJAL_LOOK: [f32; 3] = [4800.0, -4350.0, 1230.0];
#[cfg(test)]
mod ui_opt_in_tests {
use super::*;
+6
View File
@@ -967,6 +967,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
),
// MONKEY (ao): opt-in contact shadows; the High graphics preset value is 2.
ours("ambientOcclusion", "0", "benilla's own: screen-space ambient occlusion, 0 Off / 1 Low / 2 High"),
// MONKEY (skybox): the living player's zone skybox from `LightParams`; High sets 1.
ours(
"zoneSkyboxes",
"0",
"benilla's own: draw the zone skybox LightParams names for the living, 0 Off / 1 On; the reference draws a DBC skybox only for the ghost",
),
ours(
"waterQuality",
"1",
+6 -4
View File
@@ -105,12 +105,14 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
// 4-second crossfade (== the interior-fog blend, one number): `w 0.00` beside a name means
// the flood published a skybox the crossfade hasn't engaged — faithful standing outside
// the gate, not a resolve bug.
match world.skybox.0.as_deref() {
Some(path) => {
// MONKEY (skybox): the heaviest layer of the weighted list, at its own weight.
match world.skybox.primary() {
Some(layer) => {
let path = layer.path.as_str();
let leaf = path.rsplit('\\').next().unwrap_or(path);
line.push_str(&format!(
" · skybox {leaf} w {:.2}",
world.skybox_weight.0
" · skybox {leaf} w {:.2} (stand-down {:.2})",
layer.weight, world.skybox_weight.0
));
}
None => line.push_str(" · sky gradient"),
+2
View File
@@ -253,6 +253,8 @@ impl PluginGroup for GamePlugins {
.add(crate::sky_quality::SkyQualityPlugin)
// MONKEY (ao): contact shadows after the opaque pass, before the water copy.
.add(crate::ssao::AmbientOcclusionPlugin)
// MONKEY (skybox): bridge the zone-skybox setting to the world lane.
.add(crate::zone_skybox::ZoneSkyboxPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add(crate::realmlist::RealmlistPlugin)
+2
View File
@@ -83,6 +83,8 @@ mod post;
mod sky_quality;
// MONKEY (ao): optional screen-space ambient occlusion plugin.
mod ssao;
// MONKEY (skybox): the zoneSkyboxes cvar bridge.
mod zone_skybox;
mod entities;
mod fishing_line;
mod footprints;
@@ -905,6 +905,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh {
rgb_seq: None,
wmo_batch: None,
section: None,
stage1: None,
}
}
@@ -2164,8 +2164,8 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() {
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
// MONKEY (volumetric fog): account for the atmosphere row after water.
// MONKEY (integration): all programme rows (sky, post, dither, fog, wet, wind, ao, lamp fog, window split) counted.
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 30);
// MONKEY (integration): all programme rows (sky, post, dither, fog, wet, wind, ao, lamp fog, window split, zone skyboxes) counted.
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 31);
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[21]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
@@ -3106,6 +3106,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
("BENILLA_TOOLTIP_LAMP_FOG", "AdvancedGraphicsRowLampFog"),
// MONKEY (integration): the daylight window split row.
("BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT", "AdvancedGraphicsRowDaylightWindowSplit"),
// MONKEY (skybox): the zone skybox row.
("BENILLA_TOOLTIP_ZONE_SKYBOXES", "AdvancedGraphicsRowZoneSkyboxes"),
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
+45
View File
@@ -0,0 +1,45 @@
//! MONKEY (skybox): the `zoneSkyboxes` cvar's bridge to [`benilla_world::skybox::ZoneSkyboxes`].
//! `WOW_ZONE_SKYBOXES=0|1` overrides it for the session, which is how a hermetic capture (no config
//! file, registry defaults) turns the lane on.
use bevy::prelude::*;
/// The session override, read once.
#[derive(Resource, Clone, Copy)]
struct ZoneSkyboxOverride(Option<bool>);
pub(crate) struct ZoneSkyboxPlugin;
impl Plugin for ZoneSkyboxPlugin {
fn build(&self, app: &mut App) {
let forced = std::env::var("WOW_ZONE_SKYBOXES")
.ok()
.and_then(|v| v.trim().parse::<u8>().ok())
.map(|v| v != 0);
app.insert_resource(ZoneSkyboxOverride(forced))
.add_observer(on_cvar)
.add_systems(Startup, apply_override);
}
}
fn apply_override(
forced: Res<ZoneSkyboxOverride>,
zone: Option<ResMut<benilla_world::skybox::ZoneSkyboxes>>,
) {
if let (Some(on), Some(mut zone)) = (forced.0, zone) {
zone.set_if_neq(benilla_world::skybox::ZoneSkyboxes(on));
}
}
fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
forced: Res<ZoneSkyboxOverride>,
zone: Option<ResMut<benilla_world::skybox::ZoneSkyboxes>>,
) {
if !ev.is("zoneSkyboxes") || forced.0.is_some() {
return;
}
if let Some(mut zone) = zone {
zone.set_if_neq(benilla_world::skybox::ZoneSkyboxes(ev.flag()));
}
}
+18
View File
@@ -82,6 +82,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`.
}
@@ -100,6 +102,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,
}
}
}
@@ -133,6 +136,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<Handle<Image>>,
/// 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))]
@@ -265,6 +276,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());
@@ -59,8 +59,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<f32>,
// 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<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> 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<f32>;
@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;
@@ -1320,6 +1326,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<f32>(0.5, 0.5)) * vec2<f32>(1.0 + a1.z, 1.0 + a1.w);
let c1 = 1.0 + a1.x;
let uv1 = vec2<f32>(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<f32>(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.
@@ -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(),
+3 -1
View File
@@ -134,6 +134,8 @@ mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
// MONKEY (fog): `LightFogBand.dbc`.
pub use light::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
// MONKEY (skybox): the skybox row and the zone walk's entries.
pub use light::{SkyboxDef, ZoneSkybox, SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
@@ -277,7 +279,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,
+11 -21
View File
@@ -7,16 +7,19 @@ 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 (fog): `LightFogBand.dbc`, the modern fog fields.
mod fog_band;
// MONKEY (skybox): the extended LightSkybox layout and the zone skybox walk.
mod skybox;
mod tables;
pub use atmosphere::Atmosphere;
pub use fog_band::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
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,
@@ -174,9 +177,10 @@ pub struct LightCatalog {
/// `LightParams` id → `lightSkyboxID` ([`LP_SKYBOX`]), for its non-zero rows only.
light_params_skybox: HashMap<u32, u32>,
/// `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<u32, String>,
/// 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<u32, SkyboxDef>,
}
fn light_schema() -> Schema {
@@ -322,22 +326,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,
@@ -441,7 +431,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
+249
View File
@@ -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<String>,
}
/// 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<HashMap<u32, SkyboxDef>> {
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<ZoneSkybox>, 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<u32> {
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<ZoneSkybox> {
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::<f32>()
.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"
);
}
}
@@ -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<Billboard> {
@@ -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);
}
}
+1
View File
@@ -100,6 +100,7 @@ fn remap_submesh(
wmo_batch: None,
env_map: false,
section: None,
stage1: None,
},
globals,
)
@@ -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<String>,
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<UvAnim>,
pub uv_rot: Option<super::key_anim::KeyAnim<[f32; 4]>>,
pub uv_scale: Option<super::key_anim::KeyAnim<[f32; 2]>>,
}
/// 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<u16>,
/// MONKEY (skybox): the second texture stage, `None` on every one-texture batch.
pub stage1: Option<StageTwo>,
/// 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,
}
}
}
+5
View File
@@ -173,6 +173,11 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
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)?,
},
});
}
+3 -1
View File
@@ -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.
+2
View File
@@ -148,6 +148,8 @@ pub mod sky;
pub mod sky_fx;
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;
+6
View File
@@ -459,6 +459,9 @@ fn model_material_impl(
},
// 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(),
@@ -589,6 +592,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(),
+42 -4
View File
@@ -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<Handle<Image>>,
order: u16,
uv: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
tint: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
) -> Option<SkyboxBatch> {
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<Handle<WowModelMaterial>> {
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
File diff suppressed because it is too large Load Diff
+579
View File
@@ -0,0 +1,579 @@
//! MONKEY (skybox): the animated half of the skybox lane. A skybox draws camera-anchored at the far
//! depth ([`crate::skybox`]), so it cannot ride the joint-entity rig: its anchor is written after
//! transform propagation. Its bones are posed here on the CPU instead, each frame, from the armed
//! sequence (animation id 0) and the global sequences, as `0x714260` composes them:
//! `M = parent · T(pivot) · T(t) · R(q) · S(s) · T(−pivot)`. A batch wholly on one bone takes that
//! bone's matrix as its transform; a batch spread over several moving bones is skinned on the CPU
//! (skyboxes are a few thousand vertices).
//!
//! Its texture-transform, colour and alpha tracks run on the same clock through the shared
//! mat-anim table ([`crate::mat_anim_table`]), in rows this module owns, so a skybox flagged
//! `0x1` (sequence 0 across the game day) drives every channel from the day fraction.
use std::sync::Arc;
use bevy::math::Affine3A;
use bevy::prelude::*;
use benilla_assets::coords::{wow_rotation_to_bevy, wow_to_bevy};
/// A key list for one channel: `(seconds, value)` time-ascending, and whether it interpolates.
#[derive(Clone, Debug, Default)]
struct Channel<V> {
keys: Vec<(f32, V)>,
linear: bool,
}
impl<V: Copy> Channel<V> {
/// The value at `t` (seconds inside the channel's own span): held before the first key and past
/// the last, stepped or blended between, as the reference's key search clamps.
fn sample(&self, t: f32, blend: impl Fn(V, V, f32) -> V) -> Option<V> {
let (&(t0, v0), rest) = self.keys.split_first()?;
if rest.is_empty() || t <= t0 {
return Some(v0);
}
let mut k = 0;
while k + 1 < self.keys.len() && self.keys[k + 1].0 <= t {
k += 1;
}
let (ta, va) = self.keys[k];
if !self.linear || k + 1 >= self.keys.len() {
return Some(va);
}
let (tb, vb) = self.keys[k + 1];
let f = if tb > ta { (t - ta) / (tb - ta) } else { 0.0 };
Some(blend(va, vb, f))
}
}
/// A global-sequence channel: keys in seconds over `period`, sampled at the free clock mod it.
#[derive(Clone, Debug, Default)]
struct GlobalChannel<V> {
period: f32,
channel: Channel<V>,
}
/// One bone's channels, Bevy space. A global-sequence channel replaces the sequence one.
#[derive(Clone, Debug, Default)]
struct BoneTracks {
translation: Channel<Vec3>,
rotation: Channel<Quat>,
scale: Channel<Vec3>,
g_translation: Option<GlobalChannel<Vec3>>,
g_rotation: Option<GlobalChannel<Quat>>,
g_scale: Option<GlobalChannel<Vec3>>,
}
impl BoneTracks {
fn moves(&self) -> bool {
!self.translation.keys.is_empty()
|| !self.rotation.keys.is_empty()
|| !self.scale.keys.is_empty()
|| self.g_translation.is_some()
|| self.g_rotation.is_some()
|| self.g_scale.is_some()
}
}
/// A skybox model's rig: the bone tree and the armed sequence's tracks, Bevy space.
#[derive(Debug, Default)]
pub(crate) struct SkyRig {
parents: Vec<i16>,
pivots: Vec<Vec3>,
tracks: Vec<BoneTracks>,
/// The armed sequence's length in seconds and its file slot (the alpha bake's slot index).
pub(crate) duration: f32,
pub(crate) seq_slot: Option<usize>,
looping: bool,
}
fn lerp3(a: Vec3, b: Vec3, f: f32) -> Vec3 {
a.lerp(b, f)
}
fn slerp(a: Quat, b: Quat, f: f32) -> Quat {
a.slerp(b, f)
}
/// The interpolation flag of the `M2Track` at `track` (`interp_type != 0`), linear when unreadable.
fn track_linear(bytes: &[u8], track: usize) -> bool {
bytes
.get(track..track + 2)
.is_none_or(|b| u16::from_le_bytes([b[0], b[1]]) != 0)
}
/// WoW-space scale to Bevy space: [`wow_to_bevy`] permutes the axes, so a scale permutes too.
fn scale_to_bevy(s: [f32; 3]) -> Vec3 {
Vec3::new(s[1], s[2], s[0])
}
impl SkyRig {
/// Parse the rig out of the model bytes; an unparsable or boneless model has an empty rig.
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
let Ok(skeleton) = benilla_formats::parse_m2_skeleton(bytes) else {
return Self::default();
};
let n = skeleton.bones.len();
let mut rig = SkyRig {
parents: skeleton.bones.iter().map(|b| b.parent).collect(),
pivots: skeleton.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect(),
tracks: vec![BoneTracks::default(); n],
duration: 0.0,
seq_slot: None,
looping: true,
};
let bone_ofs = bytes
.get(0x38..0x3c)
.map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize);
let rec = |i: usize| bone_ofs + i * 0x6c;
// The armed sequence: animation id 0 (`0x70ebd0`), else the file's first.
let anims = benilla_formats::parse_m2_animations(bytes);
if let Some(seq) = anims
.iter()
.find(|a| a.anim_id == 0)
.or_else(|| anims.first())
{
rig.duration = seq.duration;
rig.seq_slot = Some(seq.seq_index);
rig.looping = seq.looping;
for keys in &seq.bones {
let i = usize::from(keys.bone);
let Some(t) = rig.tracks.get_mut(i) else {
continue;
};
t.translation = Channel {
keys: keys
.translation
.iter()
.map(|&(s, v)| (s, wow_to_bevy(v)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x0c),
};
t.rotation = Channel {
keys: keys
.rotation
.iter()
.map(|&(s, q)| (s, wow_rotation_to_bevy(q)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x28),
};
t.scale = Channel {
keys: keys
.scale
.iter()
.map(|&(s, v)| (s, scale_to_bevy(v)))
.collect(),
linear: track_linear(bytes, rec(i) + 0x44),
};
}
}
fn global<T, V>(
c: &Option<benilla_formats::GlobalSeqChannel<T>>,
linear: bool,
conv: impl Fn(T) -> V,
) -> Option<GlobalChannel<V>>
where
T: Copy,
{
let c = c.as_ref()?;
Some(GlobalChannel {
period: c.period_ms as f32 / 1000.0,
channel: Channel {
keys: c
.keys
.iter()
.map(|&(ms, v)| (ms as f32 / 1000.0, conv(v)))
.collect(),
linear,
},
})
}
for g in benilla_formats::parse_m2_global_sequence_bones(bytes) {
let i = usize::from(g.bone);
let Some(t) = rig.tracks.get_mut(i) else {
continue;
};
t.g_translation = global(&g.translation, track_linear(bytes, rec(i) + 0x0c), |v| {
wow_to_bevy(v)
});
t.g_rotation = global(&g.rotation, track_linear(bytes, rec(i) + 0x28), |q| {
wow_rotation_to_bevy(q)
});
t.g_scale = global(&g.scale, track_linear(bytes, rec(i) + 0x44), scale_to_bevy);
}
rig
}
/// Whether any bone has a channel, i.e. posing can move a vertex.
pub(crate) fn animates(&self) -> bool {
self.tracks.iter().any(BoneTracks::moves)
}
/// Whether `bone` or any bone above it has a channel.
pub(crate) fn bone_moves(&self, bone: u16) -> bool {
let mut i = usize::from(bone);
for _ in 0..=self.parents.len() {
let Some(t) = self.tracks.get(i) else {
return false;
};
if t.moves() {
return true;
}
match usize::try_from(self.parents[i]) {
Ok(p) => i = p,
Err(_) => return false,
}
}
false
}
/// The sequence clock for `t` seconds on the scene or day clock: wrapped for a looping
/// sequence, clamped for a one-shot, as the band bake does.
pub(crate) fn band_time(&self, t: f32) -> f32 {
if self.duration <= 0.0 {
0.0
} else if self.looping {
t.rem_euclid(self.duration)
} else {
t.clamp(0.0, self.duration)
}
}
/// Every bone's model-space matrix at band time `band_t` and free clock `gseq_now` seconds.
pub(crate) fn pose(&self, band_t: f32, gseq_now: f64) -> Vec<Affine3A> {
let n = self.parents.len();
let mut out: Vec<Option<Affine3A>> = vec![None; n];
for i in 0..n {
self.resolve(i, band_t, gseq_now, &mut out, 0);
}
out.into_iter()
.map(|m| m.unwrap_or(Affine3A::IDENTITY))
.collect()
}
fn resolve(
&self,
i: usize,
band_t: f32,
gseq_now: f64,
out: &mut Vec<Option<Affine3A>>,
depth: usize,
) -> Affine3A {
if let Some(m) = out[i] {
return m;
}
let local = self.local(i, band_t, gseq_now);
// Bounded by the bone count: the guard against a malformed parent cycle.
let m = match usize::try_from(self.parents[i]) {
Ok(p) if p < out.len() && p != i && depth < out.len() => {
self.resolve(p, band_t, gseq_now, out, depth + 1) * local
}
_ => local,
};
out[i] = Some(m);
m
}
fn local(&self, i: usize, band_t: f32, gseq_now: f64) -> Affine3A {
let t = &self.tracks[i];
if !t.moves() {
return Affine3A::IDENTITY;
}
fn at<V: Copy>(
g: &Option<GlobalChannel<V>>,
c: &Channel<V>,
band_t: f32,
gseq_now: f64,
blend: impl Fn(V, V, f32) -> V,
) -> Option<V> {
match g {
Some(g) if g.period > 0.0 => {
let tt = (gseq_now % f64::from(g.period)) as f32;
g.channel.sample(tt, blend)
}
_ => c.sample(band_t, blend),
}
}
let tr = at(&t.g_translation, &t.translation, band_t, gseq_now, lerp3).unwrap_or(Vec3::ZERO);
let rot = at(&t.g_rotation, &t.rotation, band_t, gseq_now, slerp)
.unwrap_or(Quat::IDENTITY)
.normalize();
let sc = at(&t.g_scale, &t.scale, band_t, gseq_now, lerp3).unwrap_or(Vec3::ONE);
let pivot = self.pivots[i];
Affine3A::from_translation(pivot + tr)
* Affine3A::from_scale_rotation_translation(sc, rot, Vec3::ZERO)
* Affine3A::from_translation(-pivot)
}
}
/// The mat-anim rows a skybox batch owns: its texture transform (translation row, and an affine
/// row for rotation/scale) and its colour track, each seeded at `t = 0` like the shared lane.
#[derive(Default)]
pub(crate) struct SkyMatLane {
pub(crate) uv: Option<(u16, [f32; 2], Arc<benilla_formats::UvAnim>)>,
pub(crate) affine: Option<(u16, AffineLoops)>,
pub(crate) tint: Option<(u16, [f32; 3], Arc<benilla_formats::RgbAnim>)>,
/// 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.
pub(crate) struct AffineLoops {
pub(crate) rot: Option<benilla_formats::KeyAnim<[f32; 4]>>,
pub(crate) scale: Option<benilla_formats::KeyAnim<[f32; 2]>>,
}
impl SkyMatLane {
/// Allocate the rows for a batch and write their slots into every material it draws with
/// (`anim_slots.x` UV, `.y` tint, `.z` affine). A full table leaves the batch at its seed.
pub(crate) fn register(
sub: &benilla_formats::RenderSubmesh,
uv: Option<Arc<benilla_formats::UvAnim>>,
tint: Option<Arc<benilla_formats::RgbAnim>>,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
mats: &[AssetId<benilla_assets::materials::WowModelMaterial>],
) -> Self {
let mut lane = SkyMatLane::default();
let write = |materials: &mut Assets<benilla_assets::materials::WowModelMaterial>,
f: &dyn Fn(&mut benilla_assets::materials::WowModelMaterial)| {
for id in mats {
crate::model_render::lazy::with_material_mut(materials, *id, |m| f(m));
}
};
if let Some(uv) = uv.filter(|a| a.period > 0.0) {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
.and_then(|id| {
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
[m.extension.sun_scale.z, m.extension.sun_scale.w]
})
})
.unwrap_or([0.0, 0.0]);
write(materials, &|m| m.extension.anim_slots.x = f32::from(slot));
lane.uv = Some((slot, seed, uv));
}
}
let rot = sub
.uv_rot_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
let scale = sub
.uv_scale_seq
.as_ref()
.and_then(|s| s.seq(None))
.cloned();
if rot.is_some() || scale.is_some() {
if let Some(slot) = table.alloc() {
write(materials, &|m| m.extension.anim_slots.z = f32::from(slot));
lane.affine = Some((slot, AffineLoops { rot, scale }));
}
}
if let Some(tint) = tint {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
.and_then(|id| {
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
[m.extension.tint.x, m.extension.tint.y, m.extension.tint.z]
})
})
.unwrap_or([1.0, 1.0, 1.0]);
write(materials, &|m| m.extension.anim_slots.y = f32::from(slot));
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.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.
pub(crate) fn tick(
&self,
band_t: f32,
gseq_now: f64,
table: &mut crate::mat_anim_table::MatAnimTable,
) {
if let Some((slot, seed, a)) = &self.uv {
let v = a.sample(a.clock(band_t, gseq_now));
table.set(
*slot,
[
benilla_assets::quantize(v[0], 4096.0) - seed[0],
benilla_assets::quantize(v[1], 4096.0) - seed[1],
0.0,
0.0,
],
);
}
if let Some((slot, loops)) = &self.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, 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(
*slot,
[c[0] - seed[0], c[1] - seed[1], c[2] - seed[2], 0.0],
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spin_rig() -> SkyRig {
// One parentless bone at pivot (1,0,0) turning 135° about +Y over 4 s, linear; the quarter
// turn falls at 8/3 s.
SkyRig {
parents: vec![-1],
pivots: vec![Vec3::X],
tracks: vec![BoneTracks {
rotation: Channel {
keys: vec![
(0.0, Quat::IDENTITY),
(4.0, Quat::from_rotation_y(std::f32::consts::FRAC_PI_2 * 1.5)),
],
linear: true,
},
..Default::default()
}],
duration: 4.0,
seq_slot: Some(0),
looping: true,
}
}
#[test]
fn a_bone_turns_about_its_pivot() {
let rig = spin_rig();
let m = rig.pose(rig.band_time(4.0 + 8.0 / 3.0), 0.0)[0];
// A quarter turn about the pivot; the pivot itself stays put.
assert!(m.transform_point3(Vec3::X).abs_diff_eq(Vec3::X, 1e-5));
let p = m.transform_point3(Vec3::new(2.0, 0.0, 0.0));
assert!(p.abs_diff_eq(Vec3::new(1.0, 0.0, -1.0), 1e-5), "{p}");
}
#[test]
fn a_child_composes_onto_its_parent() {
let mut rig = spin_rig();
rig.parents.push(0);
rig.pivots.push(Vec3::new(2.0, 0.0, 0.0));
rig.tracks.push(BoneTracks {
translation: Channel {
keys: vec![(0.0, Vec3::Y)],
linear: true,
},
..Default::default()
});
let pose = rig.pose(8.0 / 3.0, 0.0);
let p = pose[1].transform_point3(Vec3::new(2.0, 0.0, 0.0));
// Lifted by the child, then turned a quarter by the parent.
assert!(p.abs_diff_eq(Vec3::new(1.0, 1.0, -1.0), 1e-5), "{p}");
assert!(rig.bone_moves(1) && rig.animates());
}
#[test]
fn a_trackless_rig_is_the_bind_pose() {
let rig = SkyRig {
parents: vec![-1, 0],
pivots: vec![Vec3::ZERO, Vec3::ONE],
tracks: vec![BoneTracks::default(); 2],
..Default::default()
};
assert!(!rig.animates());
assert!(rig
.pose(1.0, 1.0)
.iter()
.all(|m| *m == Affine3A::IDENTITY));
}
/// The Caverns of Time belts: the general rig reproduces the rigid spin the old lane drew.
#[test]
fn the_caverns_belts_spin_as_the_bone_spin_does() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::Chain::open(&data).expect("open chain");
const SKY: &str = "Environments\\Stars\\CavernsOfTimeSky.m2";
let bytes = chain.read_file(SKY).expect("read the sky");
let rig = SkyRig::from_bytes(&bytes);
let spins = benilla_formats::m2_bone_spins(&bytes);
assert!(!spins.is_empty());
for t in [0.0f32, 3.3, 17.0, 50.0] {
let pose = rig.pose(rig.band_time(t), f64::from(t));
for (bone, spin) in &spins {
let pivot = wow_to_bevy(spin.pivot);
let rot = wow_rotation_to_bevy(spin.sample(t));
let old = Affine3A::from_translation(pivot)
* Affine3A::from_quat(rot)
* Affine3A::from_translation(-pivot);
let p = Vec3::new(10.0, 3.0, -7.0) + pivot;
let a = pose[usize::from(*bone)].transform_point3(p);
let b = old.transform_point3(p);
assert!(a.abs_diff_eq(b, 1e-2), "bone {bone} t {t}: {a} vs {b}");
}
}
}
}
+3 -2
View File
@@ -244,8 +244,9 @@ impl WorldCensus<'_, '_> {
};
let sky = self.skybox.as_ref().map(|s| {
s.0.as_deref()
.map_or_else(|| "dome".to_string(), str::to_ascii_lowercase)
// MONKEY (skybox): the heaviest layer of the weighted list.
s.primary()
.map_or_else(|| "dome".to_string(), |l| l.path.to_ascii_lowercase())
});
CensusReport {