Merge gfx/reviewfix-b (water/skybox/formats review fixes) into integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user