From cc42b2f9fbfed5398181ce9a310d75df98923c9e Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 23:36:16 +0100
Subject: [PATCH] MONKEY (reviewfix): stitch displaced water LOD edges
Co-Authored-By: GPT-5.6 Sol (Codex)
---
.../src/shaders/enhanced_water.wgsl | 20 ++++++-
crates/benilla-assets/src/shaders/liquid.wgsl | 2 +-
crates/benilla-world/src/liquid/lod.rs | 60 ++++++++++++++++++-
3 files changed, 76 insertions(+), 6 deletions(-)
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index faf60bd9..5cd1bd25 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -132,9 +132,25 @@ fn water_time() -> f32 {
// Vertex Gerstner displacement for the ocean's long swell (zero everywhere else).
// `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract.
-fn water_swell(xz: vec2, authored_depth: f32) -> vec3 {
+fn water_swell(xz: vec2, authored_depth: f32, stitch: f32) -> vec3 {
if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 {
- return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz;
+ let now = water_time();
+ let shore = swell_shore_fade(authored_depth);
+ // MONKEY (reviewfix): a tagged fine boundary vertex follows the straight edge between the
+ // two displaced coarse endpoints. That closes the T-junction against a coarse neighbour.
+ if stitch >= 1.0 {
+ let lane = u32(floor(stitch));
+ let along = fract(stitch);
+ var direction = vec2(1.0, 0.0);
+ if lane == 2u { direction = vec2(-1.0, 0.0); }
+ if lane == 3u { direction = vec2(0.0, 1.0); }
+ if lane == 4u { direction = vec2(0.0, -1.0); }
+ let lo = xz - direction * along * 4.1666665;
+ let hi = lo + direction * 4.1666665;
+ return mix(water_gerstner(lo, now, shore).xyz,
+ water_gerstner(hi, now, shore).xyz, along);
+ }
+ return water_gerstner(xz, now, shore).xyz;
}
return vec3(0.0);
}
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index c414754b..53027ebd 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -148,7 +148,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 += vec4(water_swell(out.world_position.xz, in.uv_b.x), 0.0);
+ out.world_position += vec4(water_swell(out.world_position.xz, in.uv_b.x, in.uv_b.y), 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
index 6ac8ad6d..e7c5e817 100644
--- a/crates/benilla-world/src/liquid/lod.rs
+++ b/crates/benilla-world/src/liquid/lod.rs
@@ -2,8 +2,10 @@
//!
//! 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.
+//! Every original edge vertex survives bit-for-bit. Non-corner fine vertices on an outer edge carry
+//! a compact stitch tag in UV1.y, so the vertex shader lerps the displaced coarse endpoints instead
+//! of evaluating a new Gerstner point that can leave the neighbour's straight edge. 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.
@@ -135,6 +137,46 @@ fn bilerp(
out
}
+/// MONKEY (reviewfix): encode an outer-edge fine vertex as `axis/sign + coarse-edge fraction`.
+/// UV1.y is otherwise zero. Coarse corners and interior fine vertices need no stitch.
+fn stitch_tag(
+ positions: &[[f32; 3]],
+ cols: usize,
+ rows: usize,
+ fine_cols: usize,
+ fine_rows: usize,
+ fx: usize,
+ fy: usize,
+ sx: usize,
+ sy: usize,
+ tx: f32,
+ ty: f32,
+) -> f32 {
+ let (a, b, t) = if (fy == 0 || fy + 1 == fine_rows) && fx % SUBDIVISIONS != 0 {
+ let y = if fy == 0 { 0 } else { rows - 1 };
+ (y * cols + sx, y * cols + sx + 1, tx)
+ } else if (fx == 0 || fx + 1 == fine_cols) && fy % SUBDIVISIONS != 0 {
+ let x = if fx == 0 { 0 } else { cols - 1 };
+ (sy * cols + x, (sy + 1) * cols + x, ty)
+ } else {
+ return 0.0;
+ };
+ let dx = positions[b][0] - positions[a][0];
+ let dz = positions[b][2] - positions[a][2];
+ let lane = if dx.abs() >= dz.abs() {
+ if dx >= 0.0 {
+ 1.0
+ } else {
+ 2.0
+ }
+ } else if dz >= 0.0 {
+ 3.0
+ } else {
+ 4.0
+ };
+ lane + t
+}
+
/// 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 {
@@ -185,7 +227,11 @@ fn subdivide(coarse: &Mesh, grid: [u32; 2], wet: &[bool]) -> Option {
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));
+ let mut uv1 = bilerp(uv1, cols, sx, sy, tx, ty);
+ uv1[1] = stitch_tag(
+ positions, cols, rows, fine_cols, fine_rows, fx, fy, sx, sy, tx, ty,
+ );
+ fine_uv1.push(uv1);
if let (Some(source), Some(output)) = (colours, fine_colours.as_mut()) {
output.push(bilerp(source, cols, sx, sy, tx, ty));
}
@@ -265,6 +311,14 @@ mod tests {
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]);
+ let Some(VertexAttributeValues::Float32x2(uv1)) = fine.attribute(Mesh::ATTRIBUTE_UV_1)
+ else {
+ panic!("uv1");
+ };
+ assert_eq!(uv1[0][1], 0.0, "coarse corner");
+ assert_eq!(uv1[2][1], 1.5, "top-edge midpoint, +x at t=0.5");
+ assert_eq!(uv1[10][1], 3.5, "left-edge midpoint, +z at t=0.5");
+ assert_eq!(uv1[12][1], 0.0, "interior fine vertex");
assert_eq!(fine.indices().expect("indices").len(), 16 * 6);
}