Merge graphics-programme (visual fixes: ocean fog wedge, cloud ring, whitecaps, skybox allocs, fog clock) into main

This commit is contained in:
pkuzic
2026-09-26 06:02:01 +01:00
11 changed files with 229 additions and 45 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ behind each constant.
| Spell and firework lights | fire, holy and fel effects light their surroundings for their lifetime; frost, nature, arcane and shadow do not | `spellLightGain`, env `WOW_SPELL_LIGHT=0` |
| Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` |
| Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` |
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
| Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors; the dawn curve follows the rendered clock, so a manual or capture time sets it; over an ocean a point below sea level is hazed at the sea surface, since water writes no depth) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` |
| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` |
| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
+1 -1
View File
@@ -43,7 +43,7 @@ fork. Do not remove any of them.
|---|---|---|
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear. The fold mask adds the next two wave bands, a slow domain-warped gust field moves the break threshold and calms patches, and ~6 yd segments break each crest into short runs, so the caps do not form a lattice (`whitecap_fold`, `enhanced_water.wgsl`) | tier |
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | tier |
| Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier |
| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier |
+1 -1
View File
@@ -362,7 +362,7 @@ fn update_views(
/// MONKEY (reviewfix-a): the server's fractional minute while it is the minute being rendered,
/// else the rendered (manual or capture) minute.
fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 {
pub(crate) fn rendered_minute(time: &WorldTime, rendered: Option<&benilla_world::lighting::GameClock>) -> f32 {
match rendered {
Some(c) if time.minute_f.floor() as u32 != c.minute => c.minute as f32,
_ => time.minute_f,
+2 -1
View File
@@ -1,7 +1,8 @@
//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp.
mod bloom;
mod grading;
// MONKEY (visualfix): crate-visible for `rendered_minute` (volumetric fog).
pub(crate) mod grading;
mod sun_shafts;
pub(crate) struct PostPlugin;
+57 -7
View File
@@ -155,6 +155,11 @@ impl Plugin for VolumetricFogPlugin {
}
}
/// MONKEY (visualfix): how near (yd, WoW XY) an ocean footprint must come to arm the sea plane.
const SEA_PLANE_REACH: f32 = 400.0;
/// MONKEY (visualfix): the eye must be this far above sea level (yd) for the plane to apply.
const SEA_LEVEL_MIN_EYE: f32 = 0.25;
fn density(minute: f32, weather: f32, indoors: bool) -> f32 {
let dawn = (1.0 - ((minute - 390.0) / 90.0).abs()).clamp(0.0, 1.0);
let dawn = dawn * dawn * (3.0 - 2.0 * dawn);
@@ -236,6 +241,8 @@ fn update_fog(
interior: Res<CameraInteriorClaim>,
// MONKEY (lampfog): the resolved point-table view published by global_light's packer.
point_lights: Res<ResolvedPointLights>,
// MONKEY (visualfix): the minute the lighting rendered (manual / capture clock offline).
rendered: Option<Res<benilla_world::lighting::GameClock>>,
// MONKEY (fog): the Modern fog rows (Option: absent in unit tests).
monkey: Option<Res<benilla_world::lighting::MonkeyFrame>>,
mut cameras: Query<
@@ -249,6 +256,8 @@ fn update_fog(
With<WorldCamera>,
>,
suns: Query<(Entity, &DirectionalLight), With<ShadowSun>>,
// MONKEY (visualfix): ocean surfaces, for the haze's sea-level plane.
water: Query<&benilla_world::liquid::WaterChunkInfo>,
) {
let tier = override_value.fog.unwrap_or(video.volumetric_fog).min(2);
let lamp_tier = override_value.lamp.unwrap_or(video.lamp_fog).min(2);
@@ -303,17 +312,32 @@ fn update_fog(
continue;
}
let fog = Vec3::from_array(lighting.fog_color);
// MONKEY (visualfix): 1 when the eye is above an ocean in reach. Water writes no depth, so
// without this the haze measured the SEABED behind it, and water over no seabed read as
// sky and took none: the underwater slope of an island drew a hard wedge across the sea.
let sea = camera_transform.map_or(0.0, |t| {
let eye = benilla_assets::coords::bevy_to_wow(t.translation());
let over = eye[2] > SEA_LEVEL_MIN_EYE
&& water
.iter()
.any(|w| w.ocean_within(eye[0], eye[1], SEA_PLANE_REACH));
if over && tier != 0 { 1.0 } else { 0.0 }
});
// MONKEY (fog): rows 1-3 as packed for the light buffer.
let rows = monkey.as_ref().map_or([[0.0; 4]; 16], |m| m.pack(0.0, 0.0));
commands.entity(entity).insert(FogView {
mf_fog1: Vec4::from_array(rows[1]),
mf_fog2: Vec4::from_array(rows[2]),
mf_fog3: Vec4::from_array(rows[3]),
lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, 0.0),
lamp_meta: Vec4::new(lamps.count as f32, lamp_strength, 0.0, sea),
lamp_positions: lamps.positions,
lamp_colors: lamps.colors,
color_density: fog.extend(
density(clock.minute_f, weather_amount, interior.0.is_some())
density(
crate::post::grading::rendered_minute(&clock, rendered.as_deref()),
weather_amount,
interior.0.is_some(),
)
* if tier == 0 {
0.0
} else if tier == 2 {
@@ -602,7 +626,8 @@ fn visibility(light_index: u32, p: vec3<f32>) -> f32 {
i32((*light).depth_texture_base_index + cascade), ndc.z);
}
// Unknown outside caster coverage is not evidence of direct sunlight.
return 0.0;
// MONKEY (visualfix): -1 marks "unknown"; the march fills it from the ray's covered samples.
return -1.0;
}
// MONKEY (lampfog): a mild Henyey-Greenstein lobe. The inverse-square term is integrated exactly;
@@ -661,7 +686,21 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
let z = textureLoad(depth, xy, 0);
let uv = (pixel.xy - view.viewport.xy) / view.viewport.zw;
let q = view.world_from_clip * vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), max(z, 0.000001), 1.0);
let delta = q.xyz / q.w - view.world_position;
var delta = q.xyz / q.w - view.world_position;
// MONKEY (visualfix): over an ocean, a point below sea level (seabed, or no depth at all under
// the horizon) is hazed at the ocean surface (Bevy y = 0), where the eye actually meets it.
var sea_surface = false;
if (fog.lamp_meta.w > 0.5 && view.world_position.y > 0.0) {
let down = normalize(delta);
let below = (view.world_position + delta).y < 0.0;
if (down.y < -0.0001 && (below || z <= 0.0)) {
let to_sea = view.world_position.y / -down.y;
if (to_sea < length(delta)) {
delta = down * to_sea;
sea_surface = true;
}
}
}
let distance = min(length(delta), 150.0);
if (distance <= 10.0 && fog.lamp_meta.x < 0.5) { return source; }
let ray = normalize(delta);
@@ -674,7 +713,7 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
if (fog_hook::fog_is_modern(fog.mf_fog3)) {
// MONKEY (fog): the sky (depth 0, clamped to 150 yd above) already carries its own
// horizon fog, so it takes no distance haze; the haze colour is the shared fog colour.
if (z <= 0.0) { opacity = 0.0; }
if (z <= 0.0 && !sea_surface) { opacity = 0.0; }
haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta),
fog.mf_fog1, fog.mf_fog2, fog.mf_fog3);
}
@@ -694,12 +733,23 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
var scatter = 0.0;
for (var light_index = 0u; light_index < lights.n_directional_lights; light_index += 1u) {
if ((lights.directional_lights[light_index].flags & 3u) != 3u) { continue; }
// MONKEY (visualfix): a sample past the cascade (beyond shadowDistance) takes the
// mean of this ray's covered samples, not 0: a hard 0 cut the sun lobe into rings
// of the far-bound step. A ray that starts in a dark room stays dark.
var known = 0.0;
var known_count = 0.0;
for (var step = 0u; step < count; step += 1u) {
// Fixed midpoint samples: no temporal noise in this gamma lane.
let t = 10.0 + (f32(step) + 0.5) * step_size;
let density = fog.color_density.w * smoothstep(10.0, 30.0, t);
scatter += visibility(light_index, view.world_position + ray*t)
* exp(-fog.color_density.w * fog_path(t)) * density * step_size;
var seen = visibility(light_index, view.world_position + ray*t);
if (seen >= 0.0) {
known += seen;
known_count += 1.0;
} else {
seen = select(0.0, known / known_count, known_count > 0.0);
}
scatter += seen * exp(-fog.color_density.w * fog_path(t)) * density * step_size;
}
}
let shafts = fog.sun_strength.rgb * fog.sun_strength.w * phase * scatter;
@@ -282,6 +282,24 @@ fn water_gerstner(p: vec2<f32>, t: f32, shore: f32) -> vec4<f32> {
return vec4<f32>(displacement, fold);
}
// MONKEY (visualfix): the whitecap fold. The two long bands alone cross in a regular diamond
// lattice, so every crest broke at the same spots; the next two bands (non-integer length and
// direction ratios to the first two) add their compression at a reduced weight. Mask only: the
// displaced geometry still moves on the two long bands.
fn whitecap_fold(p: vec2<f32>, t: f32, shore: f32) -> f32 {
var fold = water_gerstner(p, t, shore).w;
let energy = clamp(water.mode.y, 0.0, 1.0);
let tempo = mix(0.4, 1.0, sqrt(energy));
for (var i = 2u; i < 4u; i += 1u) {
let wave = WATER_WAVES[i];
let direction = vec2<f32>(cos(wave.x), sin(wave.x));
let k = 6.2831853 / wave.y;
let phase = k * (dot(direction, p) - sqrt(10.72 / k) * tempo * t) + wave.w;
fold += 0.55 * WATER_GERSTNER_CHOP * wave.z * energy * shore * k * sin(phase);
}
return max(fold, 0.0);
}
// MONKEY (water): seam for the later wet-weather lane. Its result is a height-gradient
// perturbation in world XZ; zero preserves today's image until that lane supplies rain data.
// MONKEY (wet): rain rings. Each layer is a grid of cells, one drop per cell at a hashed spot and
@@ -634,7 +652,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
let wave = water_waves(p, t, length(eye_pos), footprint, shore, false);
var open_sea_fold = 0.0;
if water.mode.x > 1.5 && ocean_mesh {
open_sea_fold = water_gerstner(p, t, shore).w;
open_sea_fold = whitecap_fold(p, t, shore);
}
// One surface gradient: the procedural bands and the shore break.
let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xyz, footprint,
@@ -840,7 +858,17 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> vec
// MONKEY (water): WarcraftXL's white belongs to a connected horizontal fold, not to every
// steep normal. Threshold jitter stops identical crests drawing identical white contours.
let fold_jitter = (noise - 0.5) * 0.04;
let breaking = smoothstep(0.13, 0.25, open_sea_fold + fold_jitter);
// MONKEY (visualfix): a slow, domain-warped gust field (tens of yards, unrelated to the wave
// lengths) moves the break threshold and calms whole patches, so the caps do not repeat.
let gust_warp = vec2<f32>(foam_noise(p * 0.013 + vec2<f32>(t * 0.004, 7.3)),
foam_noise(p * 0.013 + vec2<f32>(-4.1, t * 0.003))) - vec2<f32>(0.5);
let gust = foam_noise(p * 0.037 + gust_warp * 2.3 + t * vec2<f32>(0.006, -0.005));
let cap_lo = 0.13 + (0.5 - gust) * 0.16;
// A crest breaks in short runs, not along its whole length: ~6 yd warped segments.
let segment = smoothstep(0.42, 0.62,
foam_noise(p * 0.16 + gust_warp * 1.7 + t * vec2<f32>(0.011, 0.007)));
let breaking = smoothstep(cap_lo, cap_lo + 0.14, open_sea_fold + fold_jitter)
* smoothstep(0.25, 0.6, gust) * segment;
let whitecap_alpha = FOAM_WHITECAP * breaking * mix(0.55, 1.0, breakup)
* smoothstep(0.55, 1.0, energy);
// The cached derivatives from the top of the function — same expression as before, one tap.
+10
View File
@@ -240,6 +240,16 @@ impl LiquidGrid {
}
impl WaterChunkInfo {
/// MONKEY (visualfix): an ocean surface (pinned to z = 0) whose footprint comes within `reach`
/// yards of the WoW XY, for the volumetric haze's sea-level plane.
pub fn ocean_within(&self, x: f32, y: f32, reach: f32) -> bool {
matches!(self.kind, LiquidKind::Ocean)
&& x > self.min_x - reach
&& x < self.max_x + reach
&& y > self.min_y - reach
&& y < self.max_y + reach
}
/// The grid's highest wet vertex, which `super::real_data` shows is not the surface.
#[cfg(test)]
pub(super) fn chunk_max_z(&self) -> f32 {
+14 -5
View File
@@ -32,7 +32,9 @@ fn detailed_cover(uv: vec2<f32>, oct: i32) -> f32 {
if (a <= 0.0) {
return 0.0;
}
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, sky_clock.x, oct));
// MONKEY (visualfix): centred like the pixel's own erosion.
return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, sky_clock.x, oct) + 0.5
- sky_fx::CLOUD_DETAIL_MEAN);
}
#endif
@@ -46,7 +48,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
#ifdef SKY_FX_CLOUDS
if (cfx.fx.x >= 1.5) {
let n = sky_fx::cloud_detail(in.uv, sky_clock.x, 4);
a = sky_fx::cloud_erode(a, n);
// MONKEY (visualfix): erode about the detail's mean, so High does not add coverage; the
// bias lifted every mid-alpha plateau and drew a ring out of a soft hollow in the tile.
a = sky_fx::cloud_erode(a, n + 0.5 - sky_fx::CLOUD_DETAIL_MEAN);
// A painterly tone inside the mass from the same detail.
rgb = rgb * (0.93 + 0.14 * n);
let strength = cfx.lit.w;
@@ -59,9 +63,14 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let transmit = exp(-occl * 1.44 * strength);
// WarcraftXL's ambient floor 0.55, softened toward the painted texel.
let light = 0.62 + 0.38 * transmit;
let ahead = detailed_cover(in.uv + sd * stp * 1.5, 2);
let rim = max(ahead - a, 0.0) * (1.0 - a) * transmit * strength;
rgb = rgb * mix(1.0, light, strength) + cfx.lit.rgb * rim * 0.9;
// MONKEY (visualfix): the silver lining reads a wider footprint ahead and is capped,
// and thin cloud is not self-shadowed: thin cloud before the glow stays a diffuse
// brightening instead of a dark disc inside a bright, rimmed ring.
let ahead = 0.5 * (detailed_cover(in.uv + sd * stp * 1.5, 2)
+ detailed_cover(in.uv + sd * stp * 3.0, 2));
let rim = min(max(ahead - a, 0.0) * (1.0 - a), 0.25) * transmit * strength;
let body = smoothstep(0.25, 0.85, a);
rgb = rgb * mix(1.0, light, strength * body) + cfx.lit.rgb * rim * 0.9;
rgb = min(rgb, vec3<f32>(1.0));
}
}
+8 -1
View File
@@ -291,9 +291,16 @@ fn cloud_detail(uv: vec2<f32>, t: f32, octaves: i32) -> f32 {
return cloud_billow(p + w * 2.6, octaves);
}
// MONKEY (visualfix): the mean of `cloud_detail` (4 octaves; 0.573 measured over 1e6 samples of
// the quintic value noise through `billow`). `cloud_erode` is centred on 0.5.
const CLOUD_DETAIL_MEAN: f32 = 0.573;
// Apply the detail to a base coverage: the edges (mid coverage) are pushed in and out by the
// detail, solid cores and clear sky stay put, so the CPU tile still says where cloud is.
fn cloud_erode(a: f32, n: f32) -> f32 {
let edge = 4.0 * a * (1.0 - a);
// MONKEY (visualfix): the weight peaks at a = 1/3 (thin edges) and is 0.43 at a = 0.7, where
// 4a(1-a) gave 0.84: one low base-octave cell (~5 degrees) punched a round hole in a dense
// mass, which read as a ring round the glow.
let edge = 6.75 * a * (1.0 - a) * (1.0 - a);
return clamp(a + (n - 0.5) * 1.2 * edge, 0.0, 1.0);
}
+85 -21
View File
@@ -130,6 +130,9 @@ struct SkyboxPart {
lane: SkyMatLane,
/// MONKEY (skybox): how the rig moves the batch.
pose: PartPose,
/// MONKEY (visualfix): the model-clock pose generation this skinned batch was last uploaded
/// at; an unchanged pose is not re-skinned or re-uploaded.
skinned_at: Option<u64>,
}
/// MONKEY (skybox): how a batch follows its model's rig.
@@ -226,6 +229,23 @@ fn norm(path: &str) -> String {
}
}
/// MONKEY (visualfix): `norm(a) == norm(b)` without allocating (the per-frame resolver's test).
fn norm_eq(a: &str, b: &str) -> bool {
fn split(p: &str) -> (&str, bool) {
for ext in [".mdx", ".mdl", ".m2"] {
let cut = p.len().saturating_sub(ext.len());
if let (Some(stem), Some(tail)) = (p.get(..cut), p.get(cut..)) {
if tail.eq_ignore_ascii_case(ext) {
return (stem, true);
}
}
}
(p, false)
}
let ((sa, ma), (sb, mb)) = (split(a), split(b));
ma == mb && sa.eq_ignore_ascii_case(sb)
}
/// 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.
@@ -238,8 +258,7 @@ fn skybox_batch_order(celestial: bool, celestial_batches: u16, batch: usize) ->
/// 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) {
let key = norm(path);
let at = match layers.iter().position(|l| norm(&l.path) == key) {
let at = match layers.iter().position(|l| norm_eq(&l.path, path)) {
Some(i) => {
layers[i].weight = layers[i].weight.max(weight);
i
@@ -332,7 +351,7 @@ fn resolve_camera_skybox(
if w <= 0.0 {
continue;
}
match layers.iter_mut().find(|l| norm(&l.path) == def.path) {
match layers.iter_mut().find(|l| norm_eq(&l.path, &def.path)) {
Some(l) => l.weight = l.weight.max(w),
None => layers.push(SkyboxLayer {
path: def.path.clone(),
@@ -362,9 +381,11 @@ fn resolve_camera_skybox(
nav.flags & SHOW_SKYBOX != 0
&& inst.visible.get(i).copied().unwrap_or(false)
})
.then(|| sky.to_owned())
.then_some(sky)
})
.min();
.min()
// MONKEY (visualfix): one owned copy for the winner, not one per candidate.
.map(str::to_owned);
// The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a
// doorway at weight 0 fills the slot, which `0x6d4afe` declines to draw.
if let Some(sky) = resolved {
@@ -372,7 +393,7 @@ fn resolve_camera_skybox(
let flags = flags_of(&sky);
if t > 0.0 {
collect_layer(&mut layers, &sky, t, flags);
} else if !layers.iter().any(|l| norm(&l.path) == norm(&sky)) {
} else if !layers.iter().any(|l| norm_eq(&l.path, &sky)) {
layers.push(SkyboxLayer {
path: sky,
weight: 0.0,
@@ -599,6 +620,7 @@ fn build_skybox(
anim_alpha: 1.0,
lane,
pose,
skinned_at: None,
},
))
.id();
@@ -705,11 +727,18 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option<u16> {
}
/// MONKEY (skybox): one model's clock and pose this frame.
#[derive(Default)]
struct ModelClock {
band_t: f32,
gseq: f64,
live: bool,
pose: Vec<Affine3A>,
/// MONKEY (visualfix): bumped (from a system-wide counter) whenever `pose` changes.
generation: u64,
/// MONKEY (visualfix): the next pose, sampled here and swapped in when it differs.
next: Vec<Affine3A>,
/// MONKEY (visualfix): the resolve memo, reused.
scratch: Vec<Option<Affine3A>>,
}
/// MONKEY (skybox): `WOW_SKYBOX_T=<secs>` holds a capture's skybox clock there instead of 0, to
@@ -737,9 +766,13 @@ fn animate_skyboxes(
mut table: ResMut<crate::mat_anim_table::MatAnimTable>,
mut meshes: ResMut<Assets<Mesh>>,
mut parts: Query<(&mut SkyboxPart, &mut SkyboxLocal)>,
// MONKEY (visualfix): kept across frames (keys cloned only when a model first shows), with
// a pose generation counter so an unchanged skinned pose is not re-uploaded.
mut clocks: Local<HashMap<String, ModelClock>>,
mut generation: Local<u64>,
) {
if want.0.is_empty() {
clocks.clear();
return;
}
let deterministic = crate::dev_state::deterministic_run();
@@ -747,11 +780,17 @@ fn animate_skyboxes(
// 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();
clocks.retain(|path, _| want.0.iter().any(|l| l.weight > 0.0 && l.path == *path));
for layer in want.0.iter().filter(|l| l.weight > 0.0) {
let Some(rig) = rigs.0.get(&layer.path) else {
continue;
};
if !clocks.contains_key(&layer.path) {
clocks.insert(layer.path.clone(), ModelClock::default());
}
let Some(c) = clocks.get_mut(&layer.path) else {
continue;
};
// 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 {
@@ -767,20 +806,20 @@ fn animate_skyboxes(
};
(band_t, now, true)
};
let pose = if live && rig.animates() {
rig.pose(rig.band_time(band_t), gseq)
if live && rig.animates() {
let (next, scratch) = (&mut c.next, &mut c.scratch);
rig.pose_into(rig.band_time(band_t), gseq, scratch, next);
} else {
Vec::new()
};
clocks.insert(
layer.path.clone(),
ModelClock {
band_t,
gseq,
live,
pose,
},
);
c.next.clear();
}
if c.next != c.pose {
std::mem::swap(&mut c.pose, &mut c.next);
*generation += 1;
c.generation = *generation;
}
c.band_t = band_t;
c.gseq = gseq;
c.live = live;
}
for (mut part, mut local) in &mut parts {
let Some(c) = clocks.get(&part.path) else {
@@ -803,6 +842,8 @@ fn animate_skyboxes(
.copied()
.unwrap_or(Affine3A::IDENTITY)
};
let skinned_at = part.skinned_at;
let mut uploaded = false;
match &part.pose {
PartPose::Static => {}
PartPose::Rigid(b) => {
@@ -817,7 +858,7 @@ fn animate_skyboxes(
joints,
weights,
} => {
if c.pose.is_empty() {
if c.pose.is_empty() || skinned_at == Some(c.generation) {
continue;
}
let skinned: Vec<[f32; 3]> = base
@@ -836,8 +877,12 @@ fn animate_skyboxes(
if let Some(m) = meshes.get_mut(mesh) {
m.insert_attribute(Mesh::ATTRIBUTE_POSITION, skinned);
}
uploaded = true;
}
}
if uploaded {
part.skinned_at = Some(c.generation);
}
}
}
@@ -990,6 +1035,25 @@ mod tests {
}
}
/// MONKEY (visualfix): the allocation-free comparison agrees with comparing `norm` strings.
#[test]
fn norm_eq_matches_norm() {
let paths = [
r"Environments\Stars\StratholmeSkybox.mdx",
r"environments\stars\stratholmeskybox.m2",
r"ENVIRONMENTS\STARS\STRATHOLMESKYBOX.MDL",
r"Environments\Stars\StratholmeSkybox.wmo",
r"Environments\Stars\Other.m2",
r"m2",
r"",
];
for a in paths {
for b in paths {
assert_eq!(norm_eq(a, b), norm(a) == norm(b), "{a} vs {b}");
}
}
}
#[test]
fn skybox_order_bands_follow_batch_counts_and_reserve_the_cone() {
assert_eq!(skybox_batch_order(true, 0, 23), 24);
+20 -5
View File
@@ -240,15 +240,30 @@ impl SkyRig {
}
/// Every bone's model-space matrix at band time `band_t` and free clock `gseq_now` seconds.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn pose(&self, band_t: f32, gseq_now: f64) -> Vec<Affine3A> {
let mut out = Vec::new();
self.pose_into(band_t, gseq_now, &mut Vec::new(), &mut out);
out
}
/// MONKEY (visualfix): [`Self::pose`] into reused buffers (`scratch` is the resolve memo), so
/// the per-frame skybox animation allocates nothing once warm.
pub(crate) fn pose_into(
&self,
band_t: f32,
gseq_now: f64,
scratch: &mut Vec<Option<Affine3A>>,
out: &mut Vec<Affine3A>,
) {
let n = self.parents.len();
let mut out: Vec<Option<Affine3A>> = vec![None; n];
scratch.clear();
scratch.resize(n, None);
for i in 0..n {
self.resolve(i, band_t, gseq_now, &mut out, 0);
self.resolve(i, band_t, gseq_now, scratch, 0);
}
out.into_iter()
.map(|m| m.unwrap_or(Affine3A::IDENTITY))
.collect()
out.clear();
out.extend(scratch.iter().map(|m| m.unwrap_or(Affine3A::IDENTITY)));
}
fn resolve(