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:
@@ -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::*;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
+737
-159
File diff suppressed because it is too large
Load Diff
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user