MONKEY (water): refine nearby liquid meshes
Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
co-authored by
GPT-5.6 Sol (Codex)
parent
ccd9f42dab
commit
e98a2201dd
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user