diff --git a/WATER.md b/WATER.md index 0b077de0..53ada875 100644 --- a/WATER.md +++ b/WATER.md @@ -41,6 +41,7 @@ the technique is listed in the table below. Do not remove either. | 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 | +| 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 | | Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier | @@ -67,6 +68,7 @@ request. If it ever returns it should read the authored MCLQ flow records, not d - **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs` (per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log), + `liquid/lod.rs` (High-only 4x near-grid swap), `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`. - **App** (`crates/benilla-app/src`): the setting in `cvars.rs` / `video.rs`, the options rows in `assets/ui/OptionsFrame.xml`, the capture scenes `water-*` and `lava-*` in `capture/scenarios.rs`. diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index ba16672d..be5c9148 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -138,7 +138,7 @@ fn vertex(in: Vertex) -> LiquidVsOut { out.world_position = mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0)); // MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces. - out.world_position.xyz += water_swell(out.world_position.xz, in.uv_b.x); + out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x), 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; diff --git a/crates/benilla-world/src/liquid/lod.rs b/crates/benilla-world/src/liquid/lod.rs new file mode 100644 index 00000000..6ac8ad6d --- /dev/null +++ b/crates/benilla-world/src/liquid/lod.rs @@ -0,0 +1,301 @@ +//! MONKEY (water LOD): High-quality liquid uses a denser near-camera lattice. +//! +//! 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. +//! 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. + +use bevy::asset::RenderAssetUsages; +use bevy::mesh::{Indices, PrimitiveTopology, VertexAttributeValues}; +use bevy::prelude::*; + +use benilla_assets::{coords::wow_to_bevy, WaterQuality}; +use benilla_formats::LiquidMesh; + +use crate::view::WorldCamera; + +const SUBDIVISIONS: usize = 4; +const ENTER_YARDS: f32 = 64.0; +const EXIT_YARDS: f32 = 80.0; +const MAX_FINE_VERTICES: usize = 65_536; + +/// The coarse asset is retained for the far swap. Only the tiny coverage mask/grid metadata is +/// duplicated per surface; source vertex attributes remain in `Assets` and are copied only +/// during an actual near-ring transition. +#[derive(Component)] +pub(super) struct LiquidLod { + coarse: Handle, + fine: Option>, + grid: [u32; 2], + wet: Vec, + local_center: Vec3, + local_radius_xz: f32, +} + +impl LiquidLod { + pub(super) fn new(coarse: Handle, source: &LiquidMesh) -> Self { + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for &position in &source.positions { + let p = wow_to_bevy(position); + min = min.min(p); + max = max.max(p); + } + let valid = min.is_finite() && max.is_finite(); + let local_center = if valid { (min + max) * 0.5 } else { Vec3::ZERO }; + let local_radius_xz = if valid { + Vec2::new(max.x - min.x, max.z - min.z).length() * 0.5 + } else { + 0.0 + }; + Self { + coarse, + fine: None, + grid: source.grid, + wet: source.wet.clone(), + local_center, + local_radius_xz, + } + } +} + +pub(super) fn register(app: &mut App) { + app.add_systems(Update, update_liquid_lod); +} + +fn update_liquid_lod( + quality: Res, + camera: Query<&GlobalTransform, With>, + mut surfaces: Query<(&GlobalTransform, &mut Mesh3d, &mut LiquidLod)>, + mut meshes: ResMut>, +) { + let Ok(camera) = camera.single() else { return }; + let camera = camera.translation().xz(); + + for (transform, mut current, mut lod) in &mut surfaces { + if quality.0 < 2 { + if let Some(fine) = lod.fine.take() { + current.0 = lod.coarse.clone(); + meshes.remove(fine.id()); + } + continue; + } + + let centre = transform.transform_point(lod.local_center).xz(); + let (scale, _, _) = transform.to_scale_rotation_translation(); + let world_radius = lod.local_radius_xz * scale.abs().max_element(); + let edge_distance = (camera.distance(centre) - world_radius).max(0.0); + if lod.fine.is_some() { + if edge_distance > EXIT_YARDS { + let fine = lod.fine.take().expect("checked"); + current.0 = lod.coarse.clone(); + meshes.remove(fine.id()); + } + continue; + } + if edge_distance > ENTER_YARDS { + continue; + } + + let Some(fine_mesh) = meshes + .get(&lod.coarse) + .and_then(|coarse| subdivide(coarse, lod.grid, &lod.wet)) + else { + continue; + }; + let fine = meshes.add(fine_mesh); + current.0 = fine.clone(); + lod.fine = Some(fine); + } +} + +fn bilerp( + values: &[[f32; N]], + cols: usize, + x: usize, + y: usize, + tx: f32, + ty: f32, +) -> [f32; N] { + let [tl, tr, bl, br] = [ + values[y * cols + x], + values[y * cols + x + 1], + values[(y + 1) * cols + x], + values[(y + 1) * cols + x + 1], + ]; + let mut out = [0.0; N]; + for i in 0..N { + let top = tl[i] + (tr[i] - tl[i]) * tx; + let bottom = bl[i] + (br[i] - bl[i]) * tx; + out[i] = top + (bottom - top) * ty; + } + out +} + +/// 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 { + let [cols, rows] = grid.map(|v| v as usize); + if cols < 2 || rows < 2 || wet.len() != (cols - 1) * (rows - 1) { + return None; + } + let VertexAttributeValues::Float32x3(positions) = coarse.attribute(Mesh::ATTRIBUTE_POSITION)? + else { + return None; + }; + let VertexAttributeValues::Float32x2(uv0) = coarse.attribute(Mesh::ATTRIBUTE_UV_0)? else { + return None; + }; + let VertexAttributeValues::Float32x2(uv1) = coarse.attribute(Mesh::ATTRIBUTE_UV_1)? else { + return None; + }; + if positions.len() != cols * rows + || uv0.len() != positions.len() + || uv1.len() != positions.len() + { + return None; + } + let colours = match coarse.attribute(Mesh::ATTRIBUTE_COLOR) { + Some(VertexAttributeValues::Float32x4(values)) if values.len() == positions.len() => { + Some(values) + } + None => None, + _ => return None, + }; + + let fine_cols = (cols - 1).checked_mul(SUBDIVISIONS)?.checked_add(1)?; + let fine_rows = (rows - 1).checked_mul(SUBDIVISIONS)?.checked_add(1)?; + let fine_count = fine_cols.checked_mul(fine_rows)?; + if fine_count > MAX_FINE_VERTICES { + return None; + } + let mut fine_positions = Vec::with_capacity(fine_count); + let mut fine_uv0 = Vec::with_capacity(fine_count); + let mut fine_uv1 = Vec::with_capacity(fine_count); + let mut fine_colours = colours.map(|_| Vec::with_capacity(fine_count)); + + for fy in 0..fine_rows { + let sy = (fy / SUBDIVISIONS).min(rows - 2); + let ty = (fy - sy * SUBDIVISIONS) as f32 / SUBDIVISIONS as f32; + for fx in 0..fine_cols { + let sx = (fx / SUBDIVISIONS).min(cols - 2); + 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)); + if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) { + output.push(bilerp(source, cols, sx, sy, tx, ty)); + } + } + } + + let wet_fine_cells = wet.iter().filter(|&&cell| cell).count() * SUBDIVISIONS * SUBDIVISIONS; + let mut indices = Vec::with_capacity(wet_fine_cells * 6); + for y in 0..fine_rows - 1 { + for x in 0..fine_cols - 1 { + let source_x = x / SUBDIVISIONS; + let source_y = y / SUBDIVISIONS; + if !wet[source_y * (cols - 1) + source_x] { + continue; + } + let tl = (y * fine_cols + x) as u32; + let tr = tl + 1; + let bl = ((y + 1) * fine_cols + x) as u32; + let br = bl + 1; + indices.extend_from_slice(&[tl, bl, br, tl, br, tr]); + } + } + + let mut fine = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + fine.insert_attribute(Mesh::ATTRIBUTE_POSITION, fine_positions); + fine.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; fine_count]); + fine.insert_attribute(Mesh::ATTRIBUTE_UV_0, fine_uv0); + fine.insert_attribute(Mesh::ATTRIBUTE_UV_1, fine_uv1); + if let Some(colours) = fine_colours { + fine.insert_attribute(Mesh::ATTRIBUTE_COLOR, colours); + } + fine.insert_indices(Indices::U32(indices)); + Some(fine) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coarse() -> Mesh { + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 1.0, 1.0], + ], + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 4]); + mesh.insert_attribute( + Mesh::ATTRIBUTE_UV_0, + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![[0.0, 0.0]; 4]); + mesh + } + + #[test] + fn subdivision_preserves_edges_and_splits_every_wet_cell_sixteen_ways() { + let fine = subdivide(&coarse(), [2, 2], &[true]).expect("fine mesh"); + let Some(VertexAttributeValues::Float32x3(positions)) = + fine.attribute(Mesh::ATTRIBUTE_POSITION) + else { + panic!("positions"); + }; + assert_eq!(positions.len(), 25); + assert_eq!(positions[0], [0.0, 0.0, 0.0]); + assert_eq!(positions[4], [1.0, 0.0, 0.0]); + 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]); + assert_eq!(fine.indices().expect("indices").len(), 16 * 6); + } + + #[test] + fn dry_parent_cells_stay_holes() { + let mut mesh = coarse(); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + [1.0, 0.0, 1.0], + [2.0, 0.0, 1.0], + ], + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; 6]); + mesh.insert_attribute( + Mesh::ATTRIBUTE_UV_0, + vec![ + [0.0, 0.0], + [0.5, 0.0], + [1.0, 0.0], + [0.0, 1.0], + [0.5, 1.0], + [1.0, 1.0], + ], + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![[0.0, 0.0]; 6]); + let fine = subdivide(&mesh, [3, 2], &[true, false]).expect("fine mesh"); + assert_eq!(fine.indices().expect("indices").len(), 16 * 6); + } +} diff --git a/crates/benilla-world/src/liquid/mod.rs b/crates/benilla-world/src/liquid/mod.rs index fec0aec9..09861401 100644 --- a/crates/benilla-world/src/liquid/mod.rs +++ b/crates/benilla-world/src/liquid/mod.rs @@ -31,6 +31,7 @@ use benilla_assets::materials::LiquidMaterial; use benilla_assets::AssetSet; mod drift; +mod lod; mod scene_depth; pub use scene_depth::WaterDepthPlugin; mod query; @@ -125,5 +126,6 @@ impl Plugin for LiquidPlugin { ); } drift::register(app); + lod::register(app); } } diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index df6edc5b..e1197ecf 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -11,6 +11,7 @@ use bevy::mesh::{Indices, PrimitiveTopology}; use bevy::pbr::ExtendedMaterial; use bevy::prelude::*; +use super::lod::LiquidLod; use super::query::{wet_footprint, FoamPatch, LiquidSource, WmoPool}; use crate::collision::liquid_layers; use crate::lighting::WATER_SHININESS; @@ -181,7 +182,9 @@ pub(crate) fn spawn_liquids<'a>( if foam { commands .entity(*entities.last().expect("just pushed")) - .insert(FoamPatch); + // MONKEY (water LOD): High may swap this coarse water grid for a transient 4x + // near copy; magma/slime remain on their authored topology. + .insert((FoamPatch, LiquidLod::new(mesh_handle, lq))); } // The waterline for the camera sweep under `cameraWaterCollision`; nothing else queries it. if let Some(collider) = liquid_collider(lq) { @@ -308,9 +311,10 @@ pub(crate) fn spawn_wmo_liquids<'a>( lq.kind, lq.sound_nibble ); } + let mesh_handle = meshes.add(liquid_bevy_mesh(lq, body_color)); let surface = commands .spawn(( - Mesh3d(meshes.add(liquid_bevy_mesh(lq, body_color))), + Mesh3d(mesh_handle.clone()), MeshMaterial3d(material), transform, LiquidSurface, @@ -330,7 +334,10 @@ pub(crate) fn spawn_wmo_liquids<'a>( LiquidSource::WmoGroup(pool), )); if !lq.kind.is_fullbright() { - commands.entity(surface).insert(FoamPatch); + // MONKEY (water LOD): preserve the WMO grid boundary while refining its near interior. + commands + .entity(surface) + .insert((FoamPatch, LiquidLod::new(mesh_handle, lq))); } // The camera's waterline, model-local under the entity's placement `transform`. if let Some(collider) = liquid_collider(lq) {