fix(render3d): the meadow is vegetation, and still water reads as water

Two changes that have been sitting uncommitted in this checkout, gathered as a recovery
point before the next run of work.

grass.vert: the blade existence gate tested green DOMINANCE - g - max(r, b) - which is a
test for lush green and nothing else. A dry alpine meadow is yellow-green, its red as high
as its green, so the meadow scored zero and was thinned to the floor - a QUARTER of the
blades - on exactly the ground that should be thickest. Measured on Maroon's own ortho,
g - max(r, b) reads +0.026 at the camp and -0.002 six hundred metres away, flipping between
full density and a quarter over continuous meadow; g - b reads +0.076 and +0.014 and
separates plant from rock and snow just as well, because rock and snow are neutral and
vegetation is not. Bare ground in the near band went 82% -> 57% looking down.

Blade height is biased short (h3 * h3) rather than spread evenly, so a meadow is a dense
mat with taller stems out of it; an even spread read as a lawn that had been cut.

water.frag: the planar reflection is returned at 0.72 of the scene's exposure rather than
all of it, the fresnel mix caps at 0.70 rather than 0.86, absorption falls so the bed is
visible through the surface at the angles a player stands at, and the ripple field is
roughly halved - so a sheltered lake is one sheet of water with a mountain in it instead
of open chop to the horizon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-19 18:07:01 +03:00
parent ae52e9c4da
commit c5693db0e4
3 changed files with 63 additions and 13 deletions

12
changes/still-water.md Normal file
View file

@ -0,0 +1,12 @@
bump: patch
type: fix
Still water reads as water rather than polished metal. The planar reflection is
returned at 0.72 of the scene's own exposure instead of all of it, the fresnel mix
is capped at 0.70 rather than 0.86, and absorption falls from `(0.55, 0.24, 0.14)`
to `(0.34, 0.15, 0.09)` per metre - so the bed is visible through the surface at the
angles a player actually stands at, and a clear lake stops going opaque a metre and a
half down. The ripple field is roughly halved and its normals flattened further
(slope 0.26 -> 0.15), the reflection's ripple nudge halved with it, and the surface
goes flat by 400 m rather than 600, so a sheltered lake is one sheet of water with a
mountain in it instead of open chop to the horizon. Sun glitter and wind-streak foam
come down to match.

View file

@ -115,7 +115,15 @@ void main() {
float ok = (1.0 - smoothstep(0.30, 0.55, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - wl - 0.15) * smoothstep(u_snow_line - 80.0, u_snow_line - 200.0, ht.r); float ok = (1.0 - smoothstep(0.30, 0.55, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - wl - 0.15) * smoothstep(u_snow_line - 80.0, u_snow_line - 200.0, ht.r);
if (u_ortho_on > 0.5) { if (u_ortho_on > 0.5) {
vec3 oc = textureLod(u_ortho, huv, 1.5).rgb; vec3 oc = textureLod(u_ortho, huv, 1.5).rgb;
ok *= 0.25 + 0.75 * smoothstep(0.0, 0.02, oc.g - max(oc.r, oc.b)); // Is this ground vegetated, by the photograph? The test used to be green DOMINANCE -
// g - max(r, b) - which is a test for lush green and nothing else. A dry alpine meadow
// is yellow-green: its red is as high as its green, so the whole meadow scored zero and
// was thinned to the floor, a quarter of the blades, on exactly the ground that should
// be thickest. Measured on Maroon's own ortho, g - max(r, b) reads +0.026 at the camp
// and -0.002 six hundred metres away, flipping between full density and a quarter over
// open meadow; g - b reads +0.076 and +0.014 and separates plant from rock and snow
// just as well, because rock and snow are neutral and vegetation is not.
ok *= 0.40 + 0.60 * smoothstep(0.0, 0.025, oc.g - oc.b);
} }
if (h4 > ok) { cull(); return; } if (h4 > ok) { cull(); return; }
// the root on the drawn surface: the CDLOD mesh follows the B-spline to within // the root on the drawn surface: the CDLOD mesh follows the B-spline to within
@ -129,7 +137,13 @@ void main() {
float ang = hv.y * 6.2831853; float ang = hv.y * 6.2831853;
float s = sin(ang), c_ = cos(ang); float s = sin(ang), c_ = cos(ang);
float grow = spacing / u_s0; // 1 at the camera, growing with distance float grow = spacing / u_s0; // 1 at the camera, growing with distance
float tall = mix(0.18, 0.42, h3) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life; // Height is biased SHORT rather than spread evenly. A meadow is not one length of grass: it
// is a dense mat with taller stems and seed heads standing out of it, and h3 * h3 gives that
// for nothing - half the blades come out under a quarter of the range, and the few long ones
// are what carry the silhouette against the light. An even 0.18-0.42 spread read as a lawn
// that had been cut, which is the one thing an alpine meadow is not.
float hh = h3 * h3;
float tall = mix(0.09, 0.60, hh) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life;
float bw = 0.028 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow)); float bw = 0.028 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow));
if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); } if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); }
vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * (0.6 + 0.8 * h4)); vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * (0.6 + 0.8 * h4));

View file

@ -14,14 +14,19 @@ uniform sampler2D u_scene; // the scene as drawn before the water: the bed
// wind-streaked capillary ripples (stretched along the wind) over slower swells // wind-streaked capillary ripples (stretched along the wind) over slower swells
float waterH(vec2 p, float t) { float waterH(vec2 p, float t) {
vec2 w = vec2(p.x * 0.7 + p.y * 0.15, p.y * 1.4) ; // mildly anisotropic: cat's-paws stretched along the wind vec2 w = vec2(p.x * 0.7 + p.y * 0.15, p.y * 1.4) ; // mildly anisotropic: cat's-paws stretched along the wind
// calmer water: the swell keeps most of its weight, the two ripple octaves are // A sheltered alpine lake, not a sea. The swell carries what movement there is and the
// pulled well down so the surface reads as a lake rather than a chop // two capillary octaves are most of the way out: at their old weights the whole surface
return 0.4 * gnoise(w * 0.9 + vec2(t * 0.06, t * 0.4)) + 0.16 * gnoise(p * 2.3 - vec2(t * 0.05, -t * 0.07)) + 0.07 * gnoise(p * 6.0 + vec2(t * 0.9, t * 0.3)); // was in motion to the horizon, which is chop, and chop is what stops a lake reading as
// one sheet of water with a mountain in it.
return 0.26 * gnoise(w * 0.9 + vec2(t * 0.05, t * 0.3)) + 0.075 * gnoise(p * 2.3 - vec2(t * 0.04, -t * 0.055)) + 0.028 * gnoise(p * 6.0 + vec2(t * 0.7, t * 0.24));
} }
vec3 rippleNormal(vec2 p, float t) { vec3 rippleNormal(vec2 p, float t) {
float e = 0.06; float e = 0.06;
float h = waterH(p, t), hx = waterH(p + vec2(e, 0), t), hz = waterH(p + vec2(0, e), t); float h = waterH(p, t), hx = waterH(p + vec2(e, 0), t), hz = waterH(p + vec2(0, e), t);
return normalize(vec3(-(hx - h) * 0.26 / e, 1.0, -(hz - h) * 0.26 / e)); // and the slope the height is turned into is shallower still: the normal is what the
// reflection is read through, so this is what decides whether a mirrored ridge arrives
// whole or shivered into pieces
return normalize(vec3(-(hx - h) * 0.15 / e, 1.0, -(hz - h) * 0.15 / e));
} }
void main() { void main() {
// A lake is an ellipse - the carved bed, the grass and a game's own water test all use // A lake is an ellipse - the carved bed, the grass and a game's own water test all use
@ -34,7 +39,7 @@ void main() {
vec3 v = normalize(u_cam_pos - v_wpos); vec3 v = normalize(u_cam_pos - v_wpos);
float dist = length(u_cam_pos - v_wpos); float dist = length(u_cam_pos - v_wpos);
vec3 n = rippleNormal(v_wpos.xz, u_time); vec3 n = rippleNormal(v_wpos.xz, u_time);
n = normalize(mix(n, vec3(0, 1, 0), smoothstep(100.0, 600.0, dist))); // calm at a distance n = normalize(mix(n, vec3(0, 1, 0), smoothstep(60.0, 400.0, dist))); // flat sooner: past a few tens of metres a still lake has no texture at all
// how deep the ground is under this pixel: from the scene depth // how deep the ground is under this pixel: from the scene depth
vec2 suv = gl_FragCoord.xy / u_screen; vec2 suv = gl_FragCoord.xy / u_screen;
float sd = texture(u_depth, suv).r; float sd = texture(u_depth, suv).r;
@ -52,13 +57,22 @@ void main() {
r.y = abs(r.y); r.y = abs(r.y);
vec3 refl = skyPrefiltered(r, 0.12); vec3 refl = skyPrefiltered(r, 0.12);
if (u_refl_on > 0.5) { if (u_refl_on > 0.5) {
// the mirrored render lines up with the screen; the ripples nudge and soften the lookup // the mirrored render lines up with the screen; the ripples nudge and soften the lookup.
vec2 ruv = suv + n.xz * 0.02 * smoothstep(500.0, 20.0, dist); // The nudge is halved with the ripples that drive it - at the old strength a calm
// surface still tore the reflection, because the offset was tuned against a normal
// that no longer exists.
vec2 ruv = suv + n.xz * 0.010 * smoothstep(500.0, 20.0, dist);
float blur = mix(0.5, 0.2, smoothstep(0.0, 300.0, dist)); float blur = mix(0.5, 0.2, smoothstep(0.0, 300.0, dist));
refl = sane(textureLod(u_refl, clamp(ruv, 0.001, 0.999), blur).rgb); refl = sane(textureLod(u_refl, clamp(ruv, 0.001, 0.999), blur).rgb);
} }
// Water is a dim mirror, not a bright one. The mirrored render comes back at the scene's
// own exposure, so at full strength the lake was as bright as the ridge standing in it and
// the surface read as polished metal - and being that bright it also buried the bed under
// it whatever the fresnel mix said. REFL_DIM is what a real surface returns.
const float REFL_DIM = 0.72;
refl *= REFL_DIM;
// wind-blown foam streaks and shoreline wash // wind-blown foam streaks and shoreline wash
float foam = smoothstep(0.62, 0.9, gnoise(vec2(v_wpos.x * 0.25 + u_time * 0.3, v_wpos.z * 1.5) ) * 0.5 + 0.5) * 0.03 * smoothstep(200.0, 30.0, dist); float foam = smoothstep(0.62, 0.9, gnoise(vec2(v_wpos.x * 0.25 + u_time * 0.24, v_wpos.z * 1.5) ) * 0.5 + 0.5) * 0.014 * smoothstep(200.0, 30.0, dist);
// Wash: the shallows lapping the shore. Built from fbm rather than one gnoise octave — // Wash: the shallows lapping the shore. Built from fbm rather than one gnoise octave —
// a single octave is a blobby lattice that magnifies into visible squares when you // a single octave is a blobby lattice that magnifies into visible squares when you
// stand next to it, which is what made the wash read as cartoon cut-outs. Several // stand next to it, which is what made the wash read as cartoon cut-outs. Several
@ -78,7 +92,7 @@ void main() {
float F = 0.02 + 0.98 * pow(1.0 - NoV, 5.0); float F = 0.02 + 0.98 * pow(1.0 - NoV, 5.0);
vec3 hv = normalize(v + u_sun_dir); vec3 hv = normalize(v + u_sun_dir);
float NoH = max(dot(n, hv), 0.0); float NoH = max(dot(n, hv), 0.0);
float glitter = D_GGX(NoH, 0.06) * 0.25; float glitter = D_GGX(NoH, 0.06) * 0.17; // a narrower sun track: glitter is spread by ripples, and there are fewer
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z; float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
float shadow = sunShadow(v_wpos, vec3(0, 1, 0), viewDepth) * cloudShadow(v_wpos); float shadow = sunShadow(v_wpos, vec3(0, 1, 0), viewDepth) * cloudShadow(v_wpos);
// ---- what is under the surface ------------------------------------------------- // ---- what is under the surface -------------------------------------------------
@ -97,12 +111,22 @@ void main() {
if (rground.y > v_wpos.y) { ruv2 = suv; } if (rground.y > v_wpos.y) { ruv2 = suv; }
vec3 bed = sane(texture(u_scene, ruv2).rgb); vec3 bed = sane(texture(u_scene, ruv2).rgb);
float pathLen = depthBelow * (1.0 + 1.0 / max(NoV, 0.25)); float pathLen = depthBelow * (1.0 + 1.0 / max(NoV, 0.25));
vec3 absorb = vec3(0.55, 0.24, 0.14); // per metre: red first, then green — a cold blue-teal depth // Per metre: red first, then green — a cold blue-teal depth. Pulled down by a third, so
// the light reaches about half as far again into the water as it did: at the old rates a
// metre and a half of a clear mountain lake was already opaque, which is a pond, and the
// gravel of the shelf went out before the bank did.
vec3 absorb = vec3(0.34, 0.15, 0.09);
vec3 trans = exp(-absorb * pathLen); vec3 trans = exp(-absorb * pathLen);
vec3 tint = vec3(0.030, 0.085, 0.105) * skyIrradiance(vec3(0, 1, 0)) * 1.15; // Maroon Lake: deep, dark blue-green, not turquoise vec3 tint = vec3(0.030, 0.085, 0.105) * skyIrradiance(vec3(0, 1, 0)) * 1.15; // Maroon Lake: deep, dark blue-green, not turquoise
vec3 through = bed * trans + tint * (1.0 - trans); vec3 through = bed * trans + tint * (1.0 - trans);
// ---- surface ------------------------------------------------------------------- // ---- surface -------------------------------------------------------------------
vec3 col = mix(through, refl, clamp(F * 1.1 + 0.05, 0.0, 0.86)) + u_sun_color * glitter * F * shadow; // How much of what you see is the sky and how much is the bottom. The fresnel term is
// physical and stays; what changed is the weight it is given and, more to the point, the
// CEILING. At 0.86 the surface went to a near-total mirror as soon as you looked along it,
// which is true of a sea and is why you could stand at the shore of a lake nine metres
// deep and never see into it. At 0.70 the bed is there at every angle you actually stand
// at, and the mirrored Bells are still the thing you see across the water.
vec3 col = mix(through, refl, clamp(F * 0.85 + 0.03, 0.0, 0.70)) + u_sun_color * glitter * F * shadow;
col = mix(col, vec3(0.7, 0.75, 0.75) * (skyIrradiance(vec3(0, 1, 0)) * 0.5 + u_sun_color * 0.08 * shadow), clamp(foam, 0.0, 1.0)); col = mix(col, vec3(0.7, 0.75, 0.75) * (skyIrradiance(vec3(0, 1, 0)) * 0.5 + u_sun_color * 0.08 * shadow), clamp(foam, 0.0, 1.0));
col = applyFog(col, v_wpos, dist); col = applyFog(col, v_wpos, dist);
// Soft edge measured ALONG THE VIEW RAY, not vertically. Vertical depth collapses to // Soft edge measured ALONG THE VIEW RAY, not vertically. Vertical depth collapses to