MONKEY (water): refine nearby liquid meshes

Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
pkuzic
2026-09-25 06:12:57 +01:00
co-authored by GPT-5.6 Sol (Codex)
parent b77393c98d
commit 647cd87795
5 changed files with 316 additions and 4 deletions
+2
View File
@@ -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`.
@@ -138,7 +138,7 @@ fn vertex(in: Vertex) -> LiquidVsOut {
out.world_position =
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(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<f32>(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;
+301
View File
@@ -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<Mesh>` and are copied only
/// during an actual near-ring transition.
#[derive(Component)]
pub(super) struct LiquidLod {
coarse: Handle<Mesh>,
fine: Option<Handle<Mesh>>,
grid: [u32; 2],
wet: Vec<bool>,
local_center: Vec3,
local_radius_xz: f32,
}
impl LiquidLod {
pub(super) fn new(coarse: Handle<Mesh>, 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<WaterQuality>,
camera: Query<&GlobalTransform, With<WorldCamera>>,
mut surfaces: Query<(&GlobalTransform, &mut Mesh3d, &mut LiquidLod)>,
mut meshes: ResMut<Assets<Mesh>>,
) {
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<const N: usize>(
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<Mesh> {
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);
}
}
+2
View File
@@ -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);
}
}
+10 -3
View File
@@ -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) {