Merge gfx/reviewfix-b (water/skybox/formats review fixes) into integration

This commit is contained in:
pkuzic
2026-09-26 03:01:47 +01:00
10 changed files with 274 additions and 57 deletions
+5
View File
@@ -38,6 +38,11 @@ behind each constant.
| 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` |
`zoneSkyboxes = 0` disables only the added living-player zone slot. WMO and ghost skyboxes are
the reference 1.12 slots, so their fidelity fixes remain active in Classic: authored colour and
alpha tracks affect the layer, non-white M2 colours reach the mesh, deterministic captures pose at
their pinned time, and the batch-order base reserves the lower celestial band and final fog cone.
Players reach all of it from **Options -> Advanced Graphics** (a Graphics Preset over everything, a Render Distance slider, a Lighting Quality preset Off / Low / Medium / High / Ultra plus the individual rows; Classic / Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
Bright presets.
@@ -91,6 +91,8 @@ fn water_rain_open(world_pos: vec3<f32>) -> f32 {
struct WaterFragment {
clip_position: vec4<f32>,
world_position: vec4<f32>,
// MONKEY (reviewfix): world XZ before `water_swell`, so fragment waves share its source domain.
source_xz: vec2<f32>,
// The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0.
depth: f32,
// MONKEY (water): an interior WMO pool's authored MOMT diffuse colour; white on other lanes.
@@ -132,9 +134,25 @@ fn water_time() -> f32 {
// Vertex Gerstner displacement for the ocean's long swell (zero everywhere else).
// `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract.
fn water_swell(xz: vec2<f32>, authored_depth: f32) -> vec3<f32> {
fn water_swell(xz: vec2<f32>, authored_depth: f32, stitch: f32) -> vec3<f32> {
if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 {
return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz;
let now = water_time();
let shore = swell_shore_fade(authored_depth);
// MONKEY (reviewfix): a tagged fine boundary vertex follows the straight edge between the
// two displaced coarse endpoints. That closes the T-junction against a coarse neighbour.
if stitch >= 1.0 {
let lane = u32(floor(stitch));
let along = fract(stitch);
var direction = vec2<f32>(1.0, 0.0);
if lane == 2u { direction = vec2<f32>(-1.0, 0.0); }
if lane == 3u { direction = vec2<f32>(0.0, 1.0); }
if lane == 4u { direction = vec2<f32>(0.0, -1.0); }
let lo = xz - direction * along * 4.1666665;
let hi = lo + direction * 4.1666665;
return mix(water_gerstner(lo, now, shore).xyz,
water_gerstner(hi, now, shore).xyz, along);
}
return water_gerstner(xz, now, shore).xyz;
}
return vec3<f32>(0.0);
}
@@ -539,7 +557,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
}
let t = water_time(); // WOW_CAPTURE_WATER_T pins the frozen water phase.
let fog = water_fog(in.world_position.xyz, in.room_fog);
let p = in.world_position.xz;
let p = in.source_xz;
let energy = clamp(water.mode.y, 0.0, 1.0);
let ocean_mesh = water.lane.y > 0.5 && water.lane.x < 0.5;
@@ -85,6 +85,8 @@ struct LiquidVsOut {
@location(5) vcolor: vec4<f32>,
// `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`.
@location(6) @interpolate(flat) room_fog: u32,
// MONKEY (reviewfix): the analytic water domain before the ocean's vertex displacement.
@location(7) source_xz: vec2<f32>,
}
// Sun sheen (`secondary`): the Blinn highlight `light_spec.rgb · (N·H)^shininess`.
@@ -147,8 +149,9 @@ fn vertex(in: Vertex) -> LiquidVsOut {
let world_from_local = mesh_functions::get_world_from_local(in.instance_index);
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(in.position, 1.0));
out.source_xz = out.world_position.xz;
// MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces.
out.world_position += vec4<f32>(water_swell(out.world_position.xz, in.uv_b.x), 0.0);
out.world_position += vec4<f32>(water_swell(out.source_xz, in.uv_b.x, in.uv_b.y), 0.0);
out.clip_position = position_world_to_clip(out.world_position.xyz);
out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.uv = in.uv;
@@ -221,7 +224,8 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
return enhanced_water(
// MONKEY (water): the enhanced interior arm consumes the MOMT colour already carried
// by the reference fragment interface; exterior/ADT vertices supply white.
WaterFragment(in.clip_position, in.world_position, in.depth, in.vcolor, in.room_fog),
WaterFragment(in.clip_position, in.world_position, in.source_xz, in.depth, in.vcolor,
in.room_fog),
shallow_enhanced, deep_enhanced);
}
+5
View File
@@ -181,6 +181,9 @@ pub struct LightCatalog {
/// other rows are MOSB skyboxes. MONKEY (skybox): flags and the celestial model ride along
/// ([`SkyboxDef`]), read from either table layout.
skyboxes: HashMap<u32, SkyboxDef>,
/// MONKEY (reviewfix): normalised model path в†’ lowest matching `LightSkybox` id. Some patched
/// tables alias a model with different flags; path lookup must not depend on `HashMap` order.
skybox_by_path: HashMap<String, u32>,
}
fn light_schema() -> Schema {
@@ -328,6 +331,7 @@ impl LightCatalog {
};
// MONKEY (skybox): both layouts, keyed by the header's field count.
let skyboxes = skybox::load_skyboxes(chain, LIGHT_SKYBOX)?;
let skybox_by_path = skybox::index_paths(&skyboxes);
Ok(LightCatalog {
lights,
int_bands,
@@ -337,6 +341,7 @@ impl LightCatalog {
light_params_water_alpha,
light_params_skybox,
skyboxes,
skybox_by_path,
})
}
+34 -3
View File
@@ -94,6 +94,19 @@ pub(super) fn load_skyboxes(chain: &mut Chain, file: &str) -> Result<HashMap<u32
Ok(m)
}
/// MONKEY (reviewfix): build the deterministic reverse index used by WMO and ghost path lookups.
/// The lowest id wins when patched tables alias one model with multiple rows.
pub(super) fn index_paths(skyboxes: &HashMap<u32, SkyboxDef>) -> HashMap<String, u32> {
let mut by_path = HashMap::with_capacity(skyboxes.len());
for (&id, def) in skyboxes {
by_path
.entry(def.path.to_ascii_lowercase())
.and_modify(|old: &mut u32| *old = (*old).min(id))
.or_insert(id);
}
by_path
}
/// 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) {
@@ -126,9 +139,10 @@ impl LightCatalog {
/// 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))
let path = crate::models::model_path(path).to_ascii_lowercase();
self.skybox_by_path
.get(&path)
.and_then(|id| self.skyboxes.get(id))
}
/// The skybox the slot's param names; a weather slot without one falls back to the clear
@@ -234,6 +248,23 @@ mod tests {
assert_eq!(l, vec![ZoneSkybox { id: 1, weight: 1.0 }]);
}
#[test]
fn duplicate_paths_resolve_to_the_lowest_id() {
let def = |path: &str| SkyboxDef {
path: path.into(),
flags: 0,
celestial: None,
};
let skyboxes = HashMap::from([
(17, def(r"environments\stars\shared.m2")),
(3, def(r"ENVIRONMENTS\STARS\SHARED.M2")),
(9, def(r"environments\stars\other.m2")),
]);
let paths = index_paths(&skyboxes);
assert_eq!(paths.get(r"environments\stars\shared.m2"), Some(&3));
assert_eq!(paths.get(r"environments\stars\other.m2"), Some(&9));
}
/// The installed chain loads in either layout.
#[test]
fn the_installed_table_loads() {
@@ -492,16 +492,20 @@ pub fn parse_m2_render_submeshes(
.get(batch.texture_coord_combo_index as usize + 1)
.copied()
.unwrap_or(0);
let combo = batch.texture_transform_combo_index.wrapping_add(1);
// MONKEY (reviewfix): 0xffff is the no-transform sentinel. Advancing stage 1 must
// keep it absent rather than wrap it onto stage 0's transform at index zero.
let combo = batch.texture_transform_combo_index.checked_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)
combo.and_then(|combo| tex_anim::bake_uv_anim(model, combo, seq0_slot)),
combo
.and_then(|combo| 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)
combo
.and_then(|combo| tex_anim::bake_uv_scale_seqs(model, combo, &seq_slots))
.and_then(|s| s.seq(None).cloned()),
))
})
@@ -67,7 +67,7 @@ struct LightStd430 {
rows: [[f32; 4]; LIGHT_HEADER_ROWS],
points: [[f32; 4]; 2 * MAX_POINT_LIGHTS],
// MONKEY (p0 MonkeyFrame): the programme block, appended AFTER the point table so no earlier
// offset moves (8528 -> 8784 B); packed by [`pack_monkey_frame`] from [`super::MonkeyFrame`].
// offset moves (the per-frame prefix is now 8784 B); packed by [`pack_monkey_frame`].
monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS],
}
@@ -123,8 +123,8 @@ pub fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
/// interior set; [`build_light_data`] packs the nearest-to-camera first when over capacity.
///
/// This is the BUFFER shape (`LightStd430` and [`RoomClaimTable`] size against it) and it must not
/// move: the blob is 8528 B and is mirrored by three shaders plus the portrait booth. How many of
/// those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
/// move: the per-frame blob is 8784 B and is mirrored by the shaders plus the portrait booth. How
/// many of those slots may actually hold a light is [`MAX_LIVE_POINT_LIGHTS`], which is one less.
pub(super) const MAX_POINT_LIGHTS: usize = 256;
/// MONKEY (ext light k8): how many of [`MAX_POINT_LIGHTS`] slots may hold a LIVE entry — **255**,
@@ -140,7 +140,7 @@ pub(super) const MAX_POINT_LIGHTS: usize = 256;
///
/// So the pack truncates one entry earlier. The lights are sorted nearest-camera-first before the
/// truncation, so the one this drops is the farthest of a 256-strong set — sub-pixel and usually
/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8528 B
/// fogged at that density. The BUFFER keeps all 256 slots, so nothing about the layout, the 8784 B
/// blob or the room-claim table changes.
pub(super) const MAX_LIVE_POINT_LIGHTS: usize = MAX_POINT_LIGHTS - 1;
@@ -478,7 +478,7 @@ impl Default for FireLightGain {
}
/// MONKEY (post): live `bloom` tier. The packer adds it to `light_diffuse.w`, whose old value 1
/// was an unread clamp marker, so the 8528-byte shared buffer and every existing decode stay put.
/// was an unread clamp marker, so the 8784-byte per-frame prefix and every existing decode stay put.
#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug)]
pub struct EmissiveTier(pub u8);
@@ -897,7 +897,7 @@ pub(super) struct ShadowLanes<'w> {
/// MONKEY (moon shadows): the CPU half of the `fog_params.z` pack — ONE SIGNED lane carrying both
/// directional-shadow weights.
///
/// **There is still no free f32** ([`LightStd430`] is 8528 B, pinned by tests and mirrored by three
/// **There is still no free f32** ([`LightStd430`] is 8784 B, pinned by tests and mirrored by three
/// shaders plus the portrait booth — see [`DAYLIGHT_LANE_SCALE`] for the full accounting). The
/// daylight floor took `wmo_fog_params.w`'s fraction and the bake floor took `sh_c16.w`'s, so the
/// two lanes with spare RANGE are spent. This one needs neither: the hand-over law above guarantees
@@ -931,7 +931,7 @@ fn unpack_shadow_lane(w: f32) -> (f32, f32) {
/// MONKEY (enclosed day floor): how [`DynamicInteriors::daylight`] rides to the shader — as the
/// FRACTIONAL part of the packed `wmo_fog_params.w` lane, `w = 1 + debug + daylight * this`.
///
/// **There was no free f32 left.** [`LightStd430`] is 8528 B, mirrored by three shaders plus the
/// **There was no free f32 left.** [`LightStd430`] is 8784 B, mirrored by three shaders plus the
/// portrait booth, and must not grow; every `.w` in rows 0..=20 is spoken for (the Mod2x/clamp/SH
/// enables, terrain shininess, fog enable, farclip, the ambient DC lanes, `sh_c13_*.w` which the
/// SH `dot(row, quad)` reads as a real band, the world-shadow flag, the shadow distance, the
@@ -963,7 +963,7 @@ pub const DAYLIGHT_LANE_SCALE: f32 = 0.49;
/// MONKEY (bake floor): how [`DynamicInteriors::bake_floor`] rides to the shader — as the
/// FRACTIONAL part of the packed `sh_c16.w` lane, `w = world_shadow_flag + bake × this`.
///
/// **There is still no free f32.** [`LightStd430`] is 8528 B and mirrored by three shaders plus the
/// **There is still no free f32.** [`LightStd430`] is 8784 B and mirrored by three shaders plus the
/// portrait booth (see [`DAYLIGHT_LANE_SCALE`] for the full accounting of why it must not grow).
/// `wmo_fog_params.w` — the lane the daylight floor rides — is now spent: its integer part is
/// `1 + interiorDebug` and its fraction is `interiorDaylight × 0.49`, and two fractions cannot
@@ -1360,7 +1360,7 @@ fn build_light_data(
// MONKEY (darkness gains): `nightGain` — one live dim over everything the EXTERIOR law lights
// (terrain, models, WMO exteriors, and the ext-class night blend, all of which derive from the
// ambient/diffuse/specular rows below). Folded CPU-side into those packed rows rather than
// added as a shader uniform because `LightStd430` has no free lane left (8528 B, mirrored by
// added as a shader uniform because `LightStd430` has no free lane left (8784 B, mirrored by
// three WGSL structs) — and a pack-time fold costs the GPU exactly nothing anyway.
//
// The ramp is the DUSK CLOCK every other night feature already fades on: `sun_w` is
@@ -1437,7 +1437,7 @@ fn build_light_data(
// the dim and the shadow fade can never drift onto two different dusk clocks.
// MONKEY (moon shadows): pack the rig's ACKNOWLEDGED weight, not the clock's desired one.
// The body may have changed abruptly this frame; the shared state holds zero until the new
// basis has propagated and then ramps. One signed float still keeps the buffer at 8528 bytes.
// basis has propagated and then ramps. One signed float still keeps the prefix at 8784 bytes.
rows[5][2] = shadow.handover.weight;
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
@@ -2717,14 +2717,48 @@ mod tests {
assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders");
assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1);
// 21 header rows + 2 x 256 point rows, 16 B each.
// MONKEY (p0 MonkeyFrame): 8528 + the 256-byte programme block.
// MONKEY (p0 MonkeyFrame): the fixed rows plus the 256-byte programme block.
assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size");
}
/// MONKEY (reviewfix): the shader mirrors use literal storage-array lengths and water reads
/// the shared buffer as raw rows, so pin those literals to the Rust layout constants.
#[test]
fn programme_and_shelter_shader_literals_match_the_rust_layout() {
const WATER: &str = include_str!("../../../benilla-assets/src/shaders/enhanced_water.wgsl");
const TERRAIN: &str = include_str!("../../../benilla-assets/src/shaders/terrain.wgsl");
const MODEL: &str = include_str!("../../../benilla-assets/src/shaders/wow_model.wgsl");
const STATIC_GX: &str = include_str!("../shaders/static_gx.wgsl");
let monkey_row = LIGHT_HEADER_ROWS + 2 * MAX_POINT_LIGHTS;
let shelter_row = monkey_row + super::super::monkey_frame::MONKEY_FRAME_ROWS;
assert!(
WATER.contains(&format!("const MONKEY_ROW: u32 = {monkey_row}u;")),
"enhanced_water.wgsl MONKEY_ROW must follow the header and point table"
);
assert!(
WATER.contains(&format!("const SHELTER_ROW: u32 = {shelter_row}u;")),
"enhanced_water.wgsl SHELTER_ROW must follow MonkeyFrame"
);
let shelter_cells = crate::weather::shelter::GRID * crate::weather::shelter::GRID;
let declaration = format!("array<u32, {shelter_cells}>");
for (name, shader) in [
("terrain.wgsl", TERRAIN),
("wow_model.wgsl", MODEL),
("static_gx.wgsl", STATIC_GX),
] {
assert!(
shader.contains(&declaration),
"{name} shelter length must equal shelter::GRID squared"
);
}
}
/// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's
/// on/off word, and every existing decode of that word must be blind to it.
///
/// This is the test the feature stands on: there was no free `f32` left in an 8528-byte layout
/// This is the test the feature stands on: there was no free `f32` left in the 8784-byte layout
/// three shaders mirror, so the value shares a lane with two other facts. If the fraction ever
/// grew past 0.5 — a wider `DAYLIGHT_LANE_SCALE`, a `daylight` that escaped its clamp — the
/// debug decode `u32(max(w - 1, 0) + 0.5)` would round UP and every building in the frame would
+57 -3
View File
@@ -2,8 +2,10 @@
//!
//! Wgpu has no tessellation stage. The coarse grid therefore remains the streamed source of
//! truth, while a 4x bilinear copy is built only when the camera enters the surface's near ring.
//! Every original edge vertex survives bit-for-bit, so a fine chunk and its coarse neighbour share
//! the same boundary. Hysteresis avoids rebuilding while the camera hovers at the threshold.
//! Every original edge vertex survives bit-for-bit. Non-corner fine vertices on an outer edge carry
//! a compact stitch tag in UV1.y, so the vertex shader lerps the displaced coarse endpoints instead
//! of evaluating a new Gerstner point that can leave the neighbour's straight edge. Hysteresis
//! avoids rebuilding while the camera hovers at the threshold.
//! A full MCLQ copy is 1,089 vertices + 6,144 indices (about 68 KiB without allocator overhead);
//! the 64 yd ring bounds ordinary terrain residency to roughly 3.5 MiB, and a per-surface ceiling
//! refuses pathological MLIQ grids rather than allowing one pool to consume an unbounded copy.
@@ -135,6 +137,46 @@ fn bilerp<const N: usize>(
out
}
/// MONKEY (reviewfix): encode an outer-edge fine vertex as `axis/sign + coarse-edge fraction`.
/// UV1.y is otherwise zero. Coarse corners and interior fine vertices need no stitch.
fn stitch_tag(
positions: &[[f32; 3]],
cols: usize,
rows: usize,
fine_cols: usize,
fine_rows: usize,
fx: usize,
fy: usize,
sx: usize,
sy: usize,
tx: f32,
ty: f32,
) -> f32 {
let (a, b, t) = if (fy == 0 || fy + 1 == fine_rows) && fx % SUBDIVISIONS != 0 {
let y = if fy == 0 { 0 } else { rows - 1 };
(y * cols + sx, y * cols + sx + 1, tx)
} else if (fx == 0 || fx + 1 == fine_cols) && fy % SUBDIVISIONS != 0 {
let x = if fx == 0 { 0 } else { cols - 1 };
(sy * cols + x, (sy + 1) * cols + x, ty)
} else {
return 0.0;
};
let dx = positions[b][0] - positions[a][0];
let dz = positions[b][2] - positions[a][2];
let lane = if dx.abs() >= dz.abs() {
if dx >= 0.0 {
1.0
} else {
2.0
}
} else if dz >= 0.0 {
3.0
} else {
4.0
};
lane + t
}
/// Four-way subdivision of every source cell. The output is still one indexed regular grid: there
/// are no duplicated cell-edge vertices, and dry source cells emit no triangles.
fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option<Mesh> {
@@ -185,7 +227,11 @@ fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option<Mesh> {
let tx = (fx - sx * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32;
fine_positions.push(bilerp(positions, cols, sx, sy, tx, ty));
fine_uv0.push(bilerp(uv0, cols, sx, sy, tx, ty));
fine_uv1.push(bilerp(uv1, cols, sx, sy, tx, ty));
let mut uv1 = bilerp(uv1, cols, sx, sy, tx, ty);
uv1[1] = stitch_tag(
positions, cols, rows, fine_cols, fine_rows, fx, fy, sx, sy, tx, ty,
);
fine_uv1.push(uv1);
if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) {
output.push(bilerp(source, cols, sx, sy, tx, ty));
}
@@ -265,6 +311,14 @@ mod tests {
assert_eq!(positions[20], [0.0, 0.0, 1.0]);
assert_eq!(positions[24], [1.0, 1.0, 1.0]);
assert_eq!(positions[12], [0.5, 0.25, 0.5]);
let Some(VertexAttributeValues::Float32x2(uv1)) = fine.attribute(Mesh::ATTRIBUTE_UV_1)
else {
panic!("uv1");
};
assert_eq!(uv1[0][1], 0.0, "coarse corner");
assert_eq!(uv1[2][1], 1.5, "top-edge midpoint, +x at t=0.5");
assert_eq!(uv1[10][1], 3.5, "left-edge midpoint, +z at t=0.5");
assert_eq!(uv1[12][1], 0.0, "interior fine vertex");
assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
}
+64 -19
View File
@@ -24,8 +24,15 @@
//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon;
//! a celestial model draws as a layer under its main model. Every batch animates
//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks.
//!
//! MONKEY (reviewfix): those M2-fidelity repairs deliberately also apply when `zoneSkyboxes` is
//! off. The reference WMO and ghost slots animate their authored colour/alpha and texture tracks,
//! use non-white M2 colours, and draw batches in authored order; deterministic captures pose those
//! same tracks at their pinned time rather than substituting the bind pose. The batch-order layout
//! leaves room for a celestial under-layer and the final fog cone. The cvar gates the added
//! zone slot, not fixes to the two 1.12 slots.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::sync::Arc;
use bevy::asset::RenderAssetUsages;
@@ -47,11 +54,9 @@ use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL};
/// `WmoGroupNav::flags`.
const SHOW_SKYBOX: u32 = 0x40000;
/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so
/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last.
const MAIN_ORDER_BASE: u16 = 24;
/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step.
const FOG_CONE_ORDER: u16 = 56;
/// MONKEY (reviewfix): the fog cone owns the skybox band's final distinct rung. Orders above this
/// clamp onto it in [`crate::model_render::skybox_sort_bias`], so model batches stop one rung below.
const FOG_CONE_ORDER: u16 = 58;
/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox
/// from `LightParams`. Off leaves the reference's two slots only.
@@ -147,9 +152,10 @@ enum PartPose {
#[derive(Component, Default)]
struct SkyboxLocal(Affine3A);
/// Skybox paths built this session, failed loads included, so a failure is not retried per frame.
/// Skybox paths built this session в†’ their batch counts. Failed loads use zero, so a failure is not
/// retried per frame; successful celestial counts set the main model's dynamic order base.
#[derive(Resource, Default)]
struct BuiltSkyboxes(HashSet<String>);
struct BuiltSkyboxes(HashMap<String, u16>);
/// MONKEY (skybox): each built model's rig, by path.
#[derive(Resource, Default)]
@@ -220,6 +226,15 @@ fn norm(path: &str) -> String {
}
}
/// MONKEY (reviewfix): celestial batches occupy the low rungs and a main model starts immediately
/// after the largest active celestial model. Oversized art compresses onto the last model rung but
/// can never collide with the fog cone.
fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) -> u16 {
let base = if celestial { 0 } else { celestial_batches };
base.saturating_add(u16::try_from(batch.saturating_add(1)).unwrap_or(u16::MAX))
.min(FOG_CONE_ORDER - 1)
}
/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the
/// larger weight and every other layer is scaled by `1 − weight`.
fn collect_layer(layers: &mut Vec<SkyboxLayer>, path: &str, weight: f32, flags: u32) {
@@ -423,9 +438,13 @@ fn build_skybox(
if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) {
build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats);
}
for layer in &want.0 {
// MONKEY (reviewfix): build celestial models first so main bases derive from their real batch
// counts instead of a fixed split that aliases sufficiently large models.
let mut pending: Vec<_> = want.0.iter().collect();
pending.sort_by_key(|layer| !layer.celestial);
for layer in pending {
let path = layer.path.as_str();
if built.0.contains(path) {
if built.0.contains_key(path) {
continue;
}
// MONKEY (skybox): the rig, off the same bytes the batches come from.
@@ -445,16 +464,22 @@ fn build_skybox(
Ok(subs) if !subs.is_empty() => subs,
Ok(_) => {
warn!("skybox '{path}' has no render batches — keeping the gradient dome");
built.0.insert(path.to_string());
built.0.insert(path.to_string(), 0);
continue;
}
Err(e) => {
warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}");
built.0.insert(path.to_string());
built.0.insert(path.to_string(), 0);
continue;
}
};
let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE };
let celestial_batches = want
.0
.iter()
.filter(|layer| layer.celestial)
.filter_map(|layer| built.0.get(&layer.path).copied())
.max()
.unwrap_or(0);
for (i, sub) in subs.iter().enumerate() {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
@@ -498,7 +523,7 @@ fn build_skybox(
let tint = sub.rgb_anim.clone().map(Arc::new);
// The authored batch order after the layer's base (0 is unordered): every batch shares
// one sort distance.
let order = base + u16::try_from(i + 1).unwrap_or(0);
let order = skybox_batch_order(layer.celestial, celestial_batches, i);
let Some(mut pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref())
else {
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
@@ -534,14 +559,16 @@ fn build_skybox(
}
}
}
let mut lane_materials = [pair.steady.clone(), pair.fade_blend.clone()];
let lane = SkyMatLane::register(
sub,
uv,
tint,
&mut table,
mats.materials(),
&[pair.steady.id(), pair.fade_blend.id()],
&mut lane_materials,
);
[pair.steady, pair.fade_blend] = lane_materials;
let mesh = meshes.add(mesh);
let pose = match sole_bone(sub) {
Some(b) if rig.bone_moves(b) => PartPose::Rigid(b),
@@ -581,7 +608,9 @@ fn build_skybox(
}
}
rigs.0.insert(path.to_string(), rig);
built.0.insert(path.to_string());
built
.0
.insert(path.to_string(), u16::try_from(subs.len()).unwrap_or(u16::MAX));
}
}
@@ -714,7 +743,9 @@ fn animate_skyboxes(
return;
}
let deterministic = crate::dev_state::deterministic_run();
let now = time.elapsed_secs();
// MONKEY (reviewfix): retain sub-frame precision for skybox loops across long sessions; only
// narrow the wrapped sequence clock that the f32 track sampler consumes.
let now = time.elapsed_secs_f64();
let day = clock.minute.min(1439) as f32 / 1440.0;
clocks.clear();
for layer in want.0.iter().filter(|l| l.weight > 0.0) {
@@ -724,12 +755,17 @@ fn animate_skyboxes(
// A capture poses at `t = 0`, not the bind pose: a converted skybox places its layers with
// bone keys, and every row at `t = 0` is its seed.
let (band_t, gseq, live) = if layer.flags & SKYBOX_FULL_DAY != 0 {
let g = if deterministic { 0.0 } else { f64::from(now) };
let g = if deterministic { 0.0 } else { now };
(rig.duration * day, g, true)
} else if deterministic {
(capture_t(), f64::from(capture_t()), true)
} else {
(now, f64::from(now), true)
let band_t = if rig.duration > 0.0 {
(now % f64::from(rig.duration)) as f32
} else {
0.0
};
(band_t, now, true)
};
let pose = if live && rig.animates() {
rig.pose(rig.band_time(band_t), gseq)
@@ -954,6 +990,15 @@ mod tests {
}
}
#[test]
fn skybox_order_bands_follow_batch_counts_and_reserve_the_cone() {
assert_eq!(skybox_batch_order(true, 0, 23), 24);
assert_eq!(skybox_batch_order(false, 24, 0), 25);
assert_eq!(skybox_batch_order(false, 0, 31), 32);
assert_eq!(skybox_batch_order(false, 40, usize::MAX), FOG_CONE_ORDER - 1);
assert_eq!(FOG_CONE_ORDER, 58);
}
#[test]
fn a_wmo_sky_crossfades_the_zone_sky() {
let mut layers = vec![layer("a.m2", 1.0)];
+28 -11
View File
@@ -333,21 +333,38 @@ impl SkyMatLane {
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>],
mats: &mut [Handle<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));
// MONKEY (reviewfix): `model_material` is a cache. Animation slots are per sky batch, so
// detach its handles before writing any slot (notably affine-only `.z`) into the material.
let originals = mats.to_vec();
for i in 0..mats.len() {
if let Some(prior) = (0..i).find(|&j| originals[j].id() == originals[i].id()) {
mats[i] = mats[prior].clone();
continue;
}
};
if let Some(material) =
crate::model_render::lazy::with_material_mut(materials, originals[i].id(), |m| {
m.clone()
})
{
mats[i] = materials.add(material);
}
}
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.iter() {
crate::model_render::lazy::with_material_mut(materials, id.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| {
.and_then(|handle| {
crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| {
[m.extension.sun_scale.z, m.extension.sun_scale.w]
})
})
@@ -376,8 +393,8 @@ impl SkyMatLane {
if let Some(slot) = table.alloc() {
let seed = mats
.first()
.and_then(|id| {
crate::model_render::lazy::with_material_mut(materials, *id, |m| {
.and_then(|handle| {
crate::model_render::lazy::with_material_mut(materials, handle.id(), |m| {
[m.extension.tint.x, m.extension.tint.y, m.extension.tint.z]
})
})