Merge graphics-programme (visual fixes: ocean fog wedge, cloud ring, whitecaps, skybox allocs, fog clock) into main
This commit is contained in:
+1
-1
@@ -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` |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user