diff --git a/changes/still-water.md b/changes/still-water.md new file mode 100644 index 00000000..bb04d918 --- /dev/null +++ b/changes/still-water.md @@ -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. diff --git a/packages/ludic.render3d/shaders/grass.vert b/packages/ludic.render3d/shaders/grass.vert index aedf8d1a..d9ac3c1a 100644 --- a/packages/ludic.render3d/shaders/grass.vert +++ b/packages/ludic.render3d/shaders/grass.vert @@ -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); if (u_ortho_on > 0.5) { 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; } // 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 s = sin(ang), c_ = cos(ang); 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)); 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)); diff --git a/packages/ludic.render3d/shaders/water.frag b/packages/ludic.render3d/shaders/water.frag index e0763ec4..2e5dafb7 100644 --- a/packages/ludic.render3d/shaders/water.frag +++ b/packages/ludic.render3d/shaders/water.frag @@ -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 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 - // calmer water: the swell keeps most of its weight, the two ripple octaves are - // pulled well down so the surface reads as a lake rather than a chop - 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)); + // A sheltered alpine lake, not a sea. The swell carries what movement there is and the + // two capillary octaves are most of the way out: at their old weights the whole surface + // 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) { float e = 0.06; 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() { // 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); float dist = length(u_cam_pos - v_wpos); 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 vec2 suv = gl_FragCoord.xy / u_screen; float sd = texture(u_depth, suv).r; @@ -52,13 +57,22 @@ void main() { r.y = abs(r.y); vec3 refl = skyPrefiltered(r, 0.12); if (u_refl_on > 0.5) { - // 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 mirrored render lines up with the screen; the ripples nudge and soften the lookup. + // 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)); 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 - 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 — // 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 @@ -78,7 +92,7 @@ void main() { float F = 0.02 + 0.98 * pow(1.0 - NoV, 5.0); vec3 hv = normalize(v + u_sun_dir); 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 shadow = sunShadow(v_wpos, vec3(0, 1, 0), viewDepth) * cloudShadow(v_wpos); // ---- what is under the surface ------------------------------------------------- @@ -97,12 +111,22 @@ void main() { if (rground.y > v_wpos.y) { ruv2 = suv; } vec3 bed = sane(texture(u_scene, ruv2).rgb); 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 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); // ---- 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 = applyFog(col, v_wpos, dist); // Soft edge measured ALONG THE VIEW RAY, not vertically. Vertical depth collapses to