55% of every ground pixel was the orthophotograph, at every distance and on every material. A survey image records WHERE the forest and the scree are well and what a steep rock face is coloured badly - it is shot through kilometres of air at an angle no player stands at. The photograph keeps the meadow and the forest and gives the rock back to the rock, by material and by elevation and never by distance: a distance fade used to live here and was removed because the photograph has the day's own shadows baked in, so grass grew dark patches as you backed away and they slid as you walked. The far tier's rock fallback is the maroon mean, not a neutral grey - TFAST_3 skips the rock sample and leaves that constant standing in for a whole mountain. Ortho-classified snow needs altitude now. Bright and unsaturated is what snow looks like from a satellite and also what a sunlit rock face looks like. Gully snow is gated on the snow line rather than two absolute heights. Distance desaturation eased 1.9 -> 1.15: tuned on a valley whose rock was grey anyway. NOT FIXED, and I want it recorded rather than implied: the Bells themselves are still not maroon. The near and middle rock is warmer and measurably so, but the distant peak does not respond to ANY of this - not the rock tint, not the ortho weight, not the snow line, not the gully gate. Painting sA bright red left it unchanged, so whatever surface that is, it is not this shader's snow and not this shader's material blend. Somebody should find out what draws it before tuning any of these numbers further. 400 frames: GL 7.0 s, VK 7.2 s. Backends agree to 0.62/255. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
539 lines
31 KiB
GLSL
539 lines
31 KiB
GLSL
in vec3 v_wpos;
|
|
in vec2 v_huv;
|
|
out vec4 o_color;
|
|
#ifdef TFAST_2
|
|
#define fbm(p, o) 0.1
|
|
#define ridged(p, o) 0.3
|
|
#define gnoise(p) 0.1
|
|
#endif
|
|
uniform sampler2D u_height;
|
|
uniform float u_half;
|
|
uniform float u_texel; // height-map texel size in uv
|
|
uniform sampler2D u_grass_d; uniform sampler2D u_grass_n; uniform sampler2D u_grass_a;
|
|
uniform sampler2D u_ortho;
|
|
uniform sampler2D u_sunshadow; // the sun visibility this pixel already has (tersun.frag)
|
|
uniform float u_ortho_on;
|
|
uniform sampler2D u_rock_d; uniform sampler2D u_rock_n; uniform sampler2D u_rock_a;
|
|
uniform sampler2D u_snow_d; uniform sampler2D u_carpet; // the clump cards baked straight down (alpha = coverage)
|
|
uniform float u_carpet_on;
|
|
uniform float u_snow_line;
|
|
uniform float u_lake_level; // the ground just above the water is wet and dark
|
|
uniform float u_sea_level; // the sea's line: shore, forest and scree gates (terrain_sea)
|
|
uniform vec4 u_lake; // the carved lake's outline: centre x/z, half extents (zero = none)
|
|
// The water line that applies here: the sea everywhere, the lake's own inside its outline -
|
|
// the rule the grass and the game's water test use. One line for both put a lake above the
|
|
// sea with no wet shore and forest painted down its bed.
|
|
float waterLine(vec2 xz) {
|
|
float wl = u_sea_level;
|
|
if (u_lake.z > 0.0) { vec2 q = (xz - u_lake.xy) / u_lake.zw; if (dot(q, q) < 1.0) wl = max(wl, u_lake_level); }
|
|
return wl;
|
|
}
|
|
uniform vec2 u_origin; // world offset of the terrain grid
|
|
|
|
// The ground's own sun shadow comes from the baked height-field map (tershadow.frag),
|
|
// applied inside sunShadow() for every receiver in the scene.
|
|
// bicubic (B-spline) sample through four bilinear taps: the 10 m photo pixels stop reading as squares
|
|
vec3 orthoSmooth(vec2 uv) {
|
|
vec2 res = vec2(textureSize(u_ortho, 0));
|
|
vec2 t = uv * res - 0.5;
|
|
vec2 f = fract(t);
|
|
vec2 i = floor(t);
|
|
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0, w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
|
|
vec2 w3 = f * f * f / 6.0, w2 = 1.0 - w0 - w1 - w3;
|
|
vec2 s0 = w0 + w1, s1 = w2 + w3;
|
|
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res, o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
|
|
return (texture(u_ortho, vec2(o0.x, o0.y)).rgb * s0.x + texture(u_ortho, vec2(o1.x, o0.y)).rgb * s1.x) * s0.y
|
|
+ (texture(u_ortho, vec2(o0.x, o1.y)).rgb * s0.x + texture(u_ortho, vec2(o1.x, o1.y)).rgb * s1.x) * s1.y;
|
|
}
|
|
uniform mat4 u_view;
|
|
|
|
// B-spline bicubic sample of the height field, through four bilinear taps. The height
|
|
// texture is only C0 under bilinear filtering: its slope jumps at every texel edge, and
|
|
// the mesh chords across each triangle, so the geometry and the normal were reading two
|
|
// different surfaces and the shading kinked along every triangle diagonal. Both stages
|
|
// call this, so they now agree on one smooth surface.
|
|
float heightSmooth(sampler2D tex, vec2 uv) {
|
|
vec2 res = vec2(textureSize(tex, 0));
|
|
vec2 t = uv * res - 0.5;
|
|
vec2 f = fract(t);
|
|
vec2 i = floor(t);
|
|
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
|
|
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
|
|
vec2 w3 = f * f * f / 6.0;
|
|
vec2 w2 = 1.0 - w0 - w1 - w3;
|
|
vec2 s0 = w0 + w1, s1 = w2 + w3;
|
|
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
|
|
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
|
|
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
|
|
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
|
|
}
|
|
// baked at generation (ternormal.frag) into the height texture's GBA
|
|
vec3 terrainNormal(vec2 uv) {
|
|
return normalize(texture(u_height, uv).gba);
|
|
}
|
|
|
|
// stochastic (triangle-grid) sampling: three randomly offset / rotated taps blended by
|
|
// barycentric weights, so a scanned tile never repeats visibly
|
|
void triGrid(vec2 uv, out float w1, out float w2, out float w3, out vec2 v1, out vec2 v2, out vec2 v3) {
|
|
const mat2 skew = mat2(1.0, 0.0, -0.57735027, 1.15470054);
|
|
vec2 sk = skew * (uv * 3.4641016);
|
|
vec2 base = floor(sk);
|
|
vec3 t = vec3(fract(sk), 0.0);
|
|
t.z = 1.0 - t.x - t.y;
|
|
if (t.z > 0.0) { w1 = t.z; w2 = t.y; w3 = t.x; v1 = base; v2 = base + vec2(0, 1); v3 = base + vec2(1, 0); }
|
|
else { w1 = -t.z; w2 = 1.0 - t.y; w3 = 1.0 - t.x; v1 = base + vec2(1, 1); v2 = base + vec2(1, 0); v3 = base + vec2(0, 1); }
|
|
}
|
|
// The per-cell rotation must be applied to the DERIVATIVES as well as the coordinate.
|
|
// Handing textureGrad the gradients of the unrotated uv makes every cell sample with a
|
|
// footprint pointing the wrong way, so each one lands on a slightly different mip and
|
|
// anisotropy — and that per-cell difference is exactly the faint lattice over every
|
|
// surface. Returning the rotation lets the caller transform its gradients to match.
|
|
mat2 cellRot(vec2 cell) {
|
|
float a = hash1(cell) * 6.2831853;
|
|
float c = cos(a), s = sin(a);
|
|
return mat2(c, s, -s, c);
|
|
}
|
|
vec2 rotUV(vec2 uv, vec2 cell) {
|
|
return cellRot(cell) * uv + hash2(cell + 3.7) * 4.0;
|
|
}
|
|
// A tap whose weight rounds away is a tap not worth taking. The barycentric weights are
|
|
// raised to the fourth power to sharpen the blend, which leaves one of the three
|
|
// dominant over most of the plane and the other two often at a few thousandths; taking
|
|
// only the ones that carry any of the result, and renormalising over those, is
|
|
// indistinguishable from taking all three and is most of what this shader used to spend
|
|
// on the ground. TRI_EPS is the weight below which a tap cannot move an 8-bit channel.
|
|
#define TRI_EPS 0.004
|
|
vec4 sampleCarpet(vec2 uv, vec2 dx, vec2 dy) {
|
|
float w1, w2, w3; vec2 v1, v2, v3;
|
|
triGrid(uv * 0.3, w1, w2, w3, v1, v2, v3);
|
|
vec3 w = pow(vec3(w1, w2, w3), vec3(4.0)); w /= (w.x + w.y + w.z);
|
|
vec4 acc = vec4(0.0);
|
|
float wsum = 0.0;
|
|
if (w.x > TRI_EPS) { mat2 C = cellRot(v1); acc += textureGrad(u_carpet, rotUV(uv, v1), C * dx, C * dy) * w.x; wsum += w.x; }
|
|
if (w.y > TRI_EPS) { mat2 C = cellRot(v2); acc += textureGrad(u_carpet, rotUV(uv, v2), C * dx, C * dy) * w.y; wsum += w.y; }
|
|
if (w.z > TRI_EPS) { mat2 C = cellRot(v3); acc += textureGrad(u_carpet, rotUV(uv, v3), C * dx, C * dy) * w.z; wsum += w.z; }
|
|
return acc / max(wsum, 1e-4);
|
|
}
|
|
// Stochastic (triangle-grid) sampling: three randomly offset / rotated taps blended by
|
|
// barycentric weights, so a scanned tile never repeats visibly. The gradients are
|
|
// rotated per cell to match each tap's own rotation — handing textureGrad the gradients
|
|
// of the unrotated uv makes every cell sample with a footprint pointing the wrong way,
|
|
// landing on a different mip and anisotropy.
|
|
// one cell of the triangle grid: its rotation, its offset, and its three maps
|
|
void matTap(sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, vec2 cell, float wt,
|
|
inout vec3 alb, inout vec3 nsum, inout vec3 arm, inout float wsum) {
|
|
mat2 R = cellRot(cell);
|
|
vec2 u = R * uv + hash2(cell + 3.7) * 4.0;
|
|
vec2 gx = R * dx, gy = R * dy;
|
|
alb += textureGrad(d, u, gx, gy).rgb * wt;
|
|
nsum += (textureGrad(nm, u, gx, gy).rgb * 2.0 - 1.0) * wt;
|
|
arm += textureGrad(am, u, gx, gy).rgb * wt;
|
|
wsum += wt;
|
|
}
|
|
// Stochastic (triangle-grid) sampling: three randomly offset / rotated taps blended by
|
|
// barycentric weights, so a scanned tile never repeats visibly. The gradients are
|
|
// rotated per cell to match each tap's own rotation — handing textureGrad the gradients
|
|
// of the unrotated uv makes every cell sample with a footprint pointing the wrong way,
|
|
// landing on a different mip and anisotropy.
|
|
//
|
|
// The weights are sharpened to the fourth power, which leaves one cell dominant over
|
|
// most of the plane and the other two at a few thousandths. Everything a cell needs —
|
|
// its rotation (a hash, a sine and a cosine), its offset, its two rotated gradients —
|
|
// is computed inside its own test, so a cell that cannot move the result costs nothing.
|
|
void sampleMat(sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, out vec3 alb, out vec3 nrm, out vec3 arm) {
|
|
float w1, w2, w3; vec2 v1, v2, v3;
|
|
triGrid(uv * 0.3, w1, w2, w3, v1, v2, v3);
|
|
vec3 w = pow(vec3(w1, w2, w3), vec3(4.0)); w /= (w.x + w.y + w.z);
|
|
alb = vec3(0.0); arm = vec3(0.0);
|
|
vec3 nsum = vec3(0.0);
|
|
float wsum = 0.0;
|
|
if (w.x > TRI_EPS) { matTap(d, nm, am, uv, dx, dy, v1, w.x, alb, nsum, arm, wsum); }
|
|
if (w.y > TRI_EPS) { matTap(d, nm, am, uv, dx, dy, v2, w.y, alb, nsum, arm, wsum); }
|
|
if (w.z > TRI_EPS) { matTap(d, nm, am, uv, dx, dy, v3, w.z, alb, nsum, arm, wsum); }
|
|
float iw = 1.0 / max(wsum, 1e-4);
|
|
alb *= iw; arm *= iw;
|
|
// rotate the tangent normals back with their taps
|
|
nrm = normalize(nsum);
|
|
}
|
|
|
|
void samplePlain(sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, out vec3 alb, out vec3 nrm, out vec3 arm) {
|
|
alb = textureGrad(d, uv, dx, dy).rgb;
|
|
nrm = textureGrad(nm, uv, dx, dy).rgb * 2.0 - 1.0;
|
|
arm = textureGrad(am, uv, dx, dy).rgb;
|
|
}
|
|
// triplanar sample for steep rock
|
|
void sampleTri(sampler2D d, sampler2D nm, sampler2D am, vec3 p, vec3 dpx, vec3 dpy, vec3 n, float scale, out vec3 alb, out vec3 nrm, out vec3 arm) {
|
|
vec3 w = pow(abs(n), vec3(4.0)); w /= (w.x + w.y + w.z);
|
|
// The fourth power leaves ground facing one axis almost entirely on that axis's plane:
|
|
// a slope has to be within a few degrees of a diagonal before a second projection
|
|
// carries anything, and the third almost never does.
|
|
vec3 a0, n0, r0;
|
|
alb = vec3(0.0); arm = vec3(0.0);
|
|
vec3 nsum = vec3(0.0);
|
|
float wsum = 0.0;
|
|
if (w.x > TRI_EPS) {
|
|
samplePlain(d, nm, am, p.zy * scale, dpx.zy * scale, dpy.zy * scale, a0, n0, r0);
|
|
alb += a0 * w.x; arm += r0 * w.x;
|
|
nsum += vec3(n0.xy + n.zy, abs(n0.z) * n.x).zyx * w.x;
|
|
wsum += w.x;
|
|
}
|
|
if (w.y > TRI_EPS) {
|
|
samplePlain(d, nm, am, p.xz * scale, dpx.xz * scale, dpy.xz * scale, a0, n0, r0);
|
|
alb += a0 * w.y; arm += r0 * w.y;
|
|
nsum += vec3(n0.xy + n.xz, abs(n0.z) * n.y).xzy * w.y;
|
|
wsum += w.y;
|
|
}
|
|
if (w.z > TRI_EPS) {
|
|
samplePlain(d, nm, am, p.xy * scale, dpx.xy * scale, dpy.xy * scale, a0, n0, r0);
|
|
alb += a0 * w.z; arm += r0 * w.z;
|
|
nsum += vec3(n0.xy + n.xy, abs(n0.z) * n.z) * w.z;
|
|
wsum += w.z;
|
|
}
|
|
float iw = 1.0 / max(wsum, 1e-4);
|
|
alb *= iw; arm *= iw;
|
|
nrm = normalize(nsum);
|
|
}
|
|
|
|
vec3 dbg_n; vec3 dbg_alb; float dbg_shadow; vec3 dbg_mat;
|
|
// cheap = the far tier: single taps, noise at its mean, one shadow tap. Same code, same
|
|
// mean colour, so the tier boundary cannot show as a ring.
|
|
float fbmC(bool cheap, vec2 q, int o) { return cheap ? 0.0 : fbm(q, o); }
|
|
float ridgedC(bool cheap, vec2 q, int o) { return cheap ? 0.35 : ridged(q, o); }
|
|
float gnoiseC(bool cheap, vec2 q) { return cheap ? 0.0 : gnoise(q); }
|
|
vec3 orthoC(bool cheap, vec2 uv) { return cheap ? textureLod(u_ortho, uv, 1.0).rgb : orthoSmooth(uv); }
|
|
vec4 carpetC(bool cheap, vec2 uv, vec2 dx, vec2 dy) { return cheap ? textureGrad(u_carpet, uv, dx, dy) : sampleCarpet(uv, dx, dy); }
|
|
void matC(bool cheap, sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, out vec3 alb, out vec3 nrm, out vec3 arm) {
|
|
if (cheap) samplePlain(d, nm, am, uv, dx, dy, alb, nrm, arm); else sampleMat(d, nm, am, uv, dx, dy, alb, nrm, arm);
|
|
}
|
|
void triC(bool cheap, sampler2D d, sampler2D nm, sampler2D am, vec3 p, vec3 dpx, vec3 dpy, vec3 n, float scale, out vec3 alb, out vec3 nrm, out vec3 arm) {
|
|
if (cheap) { samplePlain(d, nm, am, p.xz * scale, dpx.xz * scale, dpy.xz * scale, alb, nrm, arm); nrm = normalize(vec3(nrm.x, 1.0, nrm.y) + vec3(0.0, 1e-3, 0.0)); }
|
|
else sampleTri(d, nm, am, p, dpx, dpy, n, scale, alb, nrm, arm);
|
|
}
|
|
#ifdef SUN_INLINE
|
|
// Vulkan: the cascades are read here, in the ground's own shader, and tersun.frag's pass - a second
|
|
// rasterisation of every patch - is not drawn. The cliff tersun.frag describes is OpenGL's driver, and
|
|
// OpenGL keeps that pass. The same normal, the same two tiers and the same cross-fade as tersun.frag.
|
|
uniform float u_far_split;
|
|
uniform float u_far_band;
|
|
float sunInline(vec3 p, float dist, float viewDepth) {
|
|
vec3 Ng = normalize(texture(u_height, v_huv).gba);
|
|
if (dist > u_far_split + u_far_band) return sunShadowCheap(p, Ng, viewDepth);
|
|
if (dist < u_far_split - u_far_band) return sunShadow(p, Ng, viewDepth);
|
|
return mix(sunShadow(p, Ng, viewDepth), sunShadowCheap(p, Ng, viewDepth),
|
|
smoothstep(u_far_split - u_far_band, u_far_split + u_far_band, dist));
|
|
}
|
|
#endif
|
|
vec3 groundShade(vec3 p, vec3 N, float slope, float dist, float viewDepth, bool cheap) {
|
|
// ---- material weights ----
|
|
float macro = fbmC(cheap, p.xz * 0.02, 2);
|
|
// a foot track: two metres wide, worn into whatever the ground is, not a painted band
|
|
float pathW = 0.6 * smoothstep(3.0 + 0.8 * macro, 1.0, pathDist(p.xz)) * smoothstep(0.35, 0.1, slope);
|
|
float rockW = max(smoothstep(0.30, 0.55, slope + 0.1 * macro), 0.9 * smoothstep(170.0, 300.0, p.y + 30.0 * macro));
|
|
// The ridge field places the snow line's raggedness and nothing else. Both terms below
|
|
// are zero more than 160 m under the snow line whatever it returns (macro and ridgeN
|
|
// can lift the test height by at most 100 m), which is the whole valley floor.
|
|
float snowW = 0.0;
|
|
if (p.y > u_snow_line - 160.0) {
|
|
float ridgeN = ridgedC(cheap, p.xz * 0.0018 + 11.0, 2);
|
|
snowW = smoothstep(u_snow_line - 60.0, u_snow_line + 60.0, p.y + 60.0 * macro + 40.0 * ridgeN) * smoothstep(0.55, 0.15, slope);
|
|
// wind-packed snow lingers in the gullies of the steep faces too
|
|
snowW = max(snowW, 0.6 * smoothstep(u_snow_line - 120.0, u_snow_line, p.y) * smoothstep(0.45, 0.2, slope) * smoothstep(0.55, 0.75, ridgeN));
|
|
}
|
|
float grassW = 1.0 - max(pathW, max(rockW, snowW));
|
|
|
|
// ---- what the photograph says is here -------------------------------------------
|
|
// This classification used to sit between the material samples, which meant every
|
|
// pixel sampled every material before anything knew which of them it would use. It
|
|
// runs first now: it costs three filtered taps of the survey image and it decides
|
|
// whether the scanned grass, rock and snow are needed at all.
|
|
vec3 oc = vec3(0.0);
|
|
float forestW = 0.0; // dense conifer: the ground under it is duff, not meadow
|
|
float screeC = 0.0; // bare ground: talus, moraine gravel, the lake's cobble shore
|
|
float snowC = 0.0;
|
|
bool orthoOn = u_ortho_on > 0.5;
|
|
#ifdef TFAST_4
|
|
orthoOn = false;
|
|
#endif
|
|
if (orthoOn) {
|
|
oc = orthoC(cheap, v_huv);
|
|
// (classified from a ~40 m blur: thresholding the raw 10 m pixels drew hard squares)
|
|
vec3 ocf = textureLod(u_ortho, v_huv, 2.0).rgb;
|
|
float gx = ocf.g - max(ocf.r, ocf.b);
|
|
// (the photograph is sampled linear: sRGB 72 is 0.06, 92 is 0.11)
|
|
forestW = smoothstep(0.12, 0.06, max(ocf.r, max(ocf.g, ocf.b))) * smoothstep(0.004, 0.012, gx) * smoothstep(waterLine(p.xz) + 0.8, waterLine(p.xz) + 1.8, p.y);
|
|
// Classify from a ~60 m blur, never from the pixels: the survey's 10 m pixels carry
|
|
// a foot trail as a broken line of bare ground, and thresholding them painted it
|
|
// across the meadow as tan dashes (and, on the CPU, lined boulders up along it).
|
|
vec3 ocl = textureLod(u_ortho, v_huv, 2.5).rgb;
|
|
float mxc = max(ocl.r, max(ocl.g, ocl.b)), mnc = min(ocl.r, min(ocl.g, ocl.b));
|
|
float greenEx = ocl.g - max(ocl.r, ocl.b);
|
|
screeC = smoothstep(0.008, -0.002, greenEx) * smoothstep(0.06, 0.12, mxc) * (1.0 - smoothstep(0.55, 0.75, mxc)) * smoothstep(waterLine(p.xz) + 0.2, waterLine(p.xz) + 1.2, p.y);
|
|
// Snow, according to the photograph: bright and unsaturated. Both of which a SUNLIT ROCK
|
|
// FACE also is, seen from a satellite through several kilometres of air - so the Bells'
|
|
// own faces classified as snow and were painted 90% white, which is the real reason the
|
|
// range named for the colour of its rock rendered as a white cone. It needs the elevation
|
|
// gate the terrain's own snow has: snow does not lie below the snow line in July, however
|
|
// pale the photograph is there.
|
|
snowC = smoothstep(0.08, 0.04, mxc - mnc) * smoothstep(0.62, 0.84, mxc)
|
|
* smoothstep(u_snow_line - 260.0, u_snow_line - 40.0, p.y);
|
|
}
|
|
// snow lingering in the high gullies is drawn further down, but whether it can be
|
|
// there at all is known now, and it is the third caller of the snow sample
|
|
// Snow lingering in the high gullies, and it has to be gated on THE SNOW LINE rather than on
|
|
// two absolute heights. At 450-650 m it started five hundred metres below where snow can lie,
|
|
// covered most of the upper mountain at nine tenths opacity, and is the single biggest reason
|
|
// the Bells rendered as a white cone: the mean colour of that peak measured 208/255 and
|
|
// neutral, which is not rock under any light, it is snow. Gullies hold snow NEAR the line,
|
|
// not half a kilometre under it.
|
|
float gullyGate = smoothstep(u_snow_line - 340.0, u_snow_line - 120.0, p.y) * smoothstep(0.75, 0.35, slope);
|
|
|
|
// ---- samples ---------------------------------------------------------------------
|
|
// World-space derivatives, taken once and in unbranched control flow: every sample
|
|
// below is in a branch and takes its gradients from these.
|
|
vec3 dpx = dFdx(p), dpy = dFdy(p);
|
|
vec3 gA = vec3(0.3, 0.4, 0.2), gN = vec3(0.0, 0.0, 1.0), gR = vec3(1.0, 0.8, 0.0);
|
|
// The rock's fallback is the MAROON MEAN, not a neutral grey. The far tier (TFAST_3) skips
|
|
// the rock sample altogether and leaves this constant standing in for it, so a neutral grey
|
|
// here is what the mountain three kilometres away is actually painted - and it is the
|
|
// mountain the range is named for. The file's own rule a hundred lines up says the cheap
|
|
// tier must keep the near tier's MEAN COLOUR or the boundary shows as a ring; this is that
|
|
// rule applied to the one material where the mean is not grey.
|
|
vec3 rA = vec3(0.26, 0.13, 0.10), rN = vec3(0.0, 1.0, 0.0), rR = vec3(1.0, 0.8, 0.0);
|
|
vec3 sA = vec3(0.86, 0.88, 0.92), sN = vec3(0.0, 0.0, 1.0), sR = vec3(1.0, 0.55, 0.0);
|
|
vec3 pA, pN, pR;
|
|
vec2 uvg = p.xz * 0.28;
|
|
vec2 duvgx = dpx.xz * 0.28, duvgy = dpy.xz * 0.28;
|
|
// the grass carries the path too (the track is worn into it), and the forest duff
|
|
float needGrass = max(grassW, pathW);
|
|
float needRock = max(rockW, screeC);
|
|
float needSnow = max(snowW, max(snowC, gullyGate));
|
|
#ifdef TFAST_3
|
|
needGrass = 0.0; needRock = 0.0;
|
|
#endif
|
|
if (needGrass > 0.002) {
|
|
// dry / lush variation across the meadow (read only here and by the carpet below)
|
|
float lush = fbmC(cheap, p.xz * 0.006 + 2.0, 2) * 0.5 + 0.5;
|
|
matC(cheap, u_grass_d, u_grass_n, u_grass_a, uvg, duvgx, duvgy, gA, gN, gR);
|
|
// tint the grass by lushness
|
|
gA *= mix(vec3(0.42, 0.55, 0.3), vec3(0.28, 0.55, 0.25), lush) * 0.5;
|
|
// sun-facing slopes (south, +z) dry out lighter and warmer; shaded faces stay deep green
|
|
gA *= mix(vec3(0.85, 0.92, 0.9), vec3(1.12, 1.06, 0.82), smoothstep(-0.35, 0.35, N.z));
|
|
// beyond the blade rings the ground itself carries the clumps: the same cards, seen from above
|
|
// Also under the near blades, at reduced weight: the ground seen between standing
|
|
// blades must carry the same hue as the carpet that replaces them further out, or
|
|
// the field turns from grey-beige to green along a line that walks with the viewer.
|
|
float carpetW = mix(0.55, 1.0, smoothstep(8.0, 45.0, dist)) * smoothstep(0.7, 0.35, slope) * grassW;
|
|
#ifdef TFAST_3
|
|
carpetW = 0.0;
|
|
#endif
|
|
if (u_carpet_on > 0.5 && carpetW > 0.002) {
|
|
vec4 cp = carpetC(cheap, p.xz / 6.0, dpx.xz / 6.0, dpy.xz / 6.0);
|
|
// the standing blades in front of it are self-shaded: the carpet is held darker to match them
|
|
vec3 cc = cp.rgb * vec3(0.5, 0.57, 0.45) * mix(vec3(0.85, 0.92, 0.9), vec3(1.1, 1.05, 0.85), smoothstep(-0.35, 0.35, N.z)) * (0.75 + 0.35 * lush);
|
|
gA = mix(gA, cc, max(cp.a, 0.35) * carpetW * 0.97);
|
|
}
|
|
// Subalpine forest floor: dark duff where the stands are dense. The photograph-driven
|
|
// term marks duff only where the survey actually shows dense conifer, and is the same
|
|
// at any distance — ground shading must not depend on where the viewer is.
|
|
gA = mix(gA, vec3(0.045, 0.06, 0.025), forestW * 0.85);
|
|
}
|
|
pA = gA * vec3(0.95, 0.82, 0.62); // the same ground, worn to earth
|
|
pN = gN; pR = vec3(0.9, 0.85, 0.0);
|
|
// the scanned cliff face on the steep, high slopes; scree below
|
|
// The cliff sample and the relief/cliff blend that used to sit here wrote rA/rN/rR
|
|
// and were then overwritten wholesale by the rock sample below — they never reached
|
|
// the screen (removing them is pixel-identical). Deleting them frees the two sampler
|
|
// slots the histogram LUT needs; this shader was at the hardware limit of 16.
|
|
if (needRock > 0.002) {
|
|
triC(cheap, u_rock_d, u_rock_n, u_rock_a, p, dpx, dpy, N, 0.12, rA, rN, rR);
|
|
// macro rock structure for the mountains: a coarse second tile, strata darkening, blue-grey shade side
|
|
vec2 uv2 = p.xz * 0.006 + p.y * 0.002;
|
|
vec3 rA2 = textureGrad(u_rock_d, uv2, dpx.xz * 0.006 + dpx.y * 0.002, dpy.xz * 0.006 + dpy.y * 0.002).rgb;
|
|
vec3 rN2 = textureGrad(u_rock_n, p.zy * 0.01, dpx.zy * 0.01, dpy.zy * 0.01).rgb * 2.0 - 1.0;
|
|
rA = mix(rA, rA * rA2 * 2.2, 0.35) * (0.9 + 0.2 * fbmC(cheap, vec2(p.y * 0.03, p.x * 0.004 + p.z * 0.004), 3));
|
|
// the Bells' sedimentary strata: near-horizontal bands, tilted a little, sharper on the cliffs
|
|
float strata = 0.5 + 0.5 * sin(p.y * 0.45 + p.x * 0.012 + 3.0 * fbmC(cheap, p.xz * 0.01, 2));
|
|
rA *= mix(1.0, 0.75 + 0.5 * smoothstep(0.35, 0.65, strata), 0.5 * smoothstep(0.3, 0.6, slope));
|
|
rN = normalize(rN + vec3(rN2.x, 0.0, rN2.y) * 0.6 * smoothstep(80.0, 400.0, dist));
|
|
// the Bells are maroon mudstone: warm red-brown rock with grey scree below
|
|
// The Bells are maroon mudstone: warm red-brown rock, with grey weathered scree below.
|
|
// These were tuned while 55% of a grey photograph was mixed over them, so they had to
|
|
// be dark and timid to survive it; with the rock now mostly its own colour they can be
|
|
// the colour the range is named for.
|
|
rA *= mix(vec3(0.38, 0.17, 0.13), vec3(0.58, 0.31, 0.23), fbmC(cheap, p.xz * 0.003, 2) * 0.5 + 0.5);
|
|
}
|
|
if (needSnow > 0.002) {
|
|
sA = textureGrad(u_snow_d, p.xz * 0.25, dpx.xz * 0.25, dpy.xz * 0.25).rgb * 0.8;
|
|
}
|
|
// ---- blend (height-ish: sharpen with the weights) ----
|
|
vec3 alb = gA * grassW + pA * pathW + rA * rockW + sA * snowW;
|
|
// the photographed surface (a satellite image of this ground) takes over with distance,
|
|
// keeping the scanned materials' fine luminance detail so the middle ground still has grain
|
|
float screeMix = 0.0;
|
|
if (orthoOn) {
|
|
if (screeC > 0.002) {
|
|
// near the camera the scanned rocks take the scree at cobble scale (the far talus keeps the coarse tile)
|
|
float pebW = smoothstep(220.0, 40.0, dist);
|
|
vec3 pebA = vec3(0.36, 0.35, 0.33), pebN = vec3(0.0, 0.0, 1.0);
|
|
if (pebW > 0.002) {
|
|
pebA = textureGrad(u_rock_d, p.xz * 0.55, dpx.xz * 0.55, dpy.xz * 0.55).rgb * vec3(0.36, 0.35, 0.33);
|
|
pebN = textureGrad(u_rock_n, p.xz * 0.55, dpx.xz * 0.55, dpy.xz * 0.55).rgb * 2.0 - 1.0;
|
|
}
|
|
vec3 screeA = mix(rA * vec3(1.25, 1.2, 1.15), pebA * (0.75 + 0.5 * fbmC(cheap, p.xz * 0.15, 2)), pebW);
|
|
// the 10 m photo pixels blur turf and gravel together on the shore: keep grass showing between the cobbles
|
|
alb = mix(alb, screeA, screeC * (1.0 - rockW) * mix(0.55, 0.9, smoothstep(30.0, 200.0, dist)));
|
|
rN = normalize(mix(rN, normalize(vec3(pebN.x, 1.0, pebN.y)), screeC * pebW * 0.8));
|
|
screeMix = screeC;
|
|
}
|
|
alb = mix(alb, sA, snowC * 0.9);
|
|
float lumA = dot(alb, vec3(0.3, 0.59, 0.11));
|
|
// How much of the photograph shows through. This was smoothstep(260, 800, dist) —
|
|
// ground colour cross-fading toward the survey image as it receded from the camera.
|
|
// The photograph has shadows and dark vegetation baked into it from the day it was
|
|
// flown, so grass that was plain up close grew dark patches as you backed away, and
|
|
// those patches slid and changed shape as you walked. A constant keeps the
|
|
// photograph's large-scale colour without tying any of it to the camera.
|
|
// How much of the photograph shows through, and it is NOT one number any more.
|
|
//
|
|
// The Maroon Bells are named for the colour of their mudstone and rendered grey, and
|
|
// this line was why: the maroon tint two hundred lines up is real, and then 55% of a
|
|
// hazy, sun-washed aerial photograph was mixed over the top of it everywhere. A survey
|
|
// image is a good record of WHERE the forest and the scree are and a poor record of
|
|
// what a steep rock face is coloured - it is shot through kilometres of atmosphere at
|
|
// an angle no player ever stands at.
|
|
//
|
|
// So the photograph keeps the meadow and the forest, where it carries real variation
|
|
// nothing else knows about, and gives the rock back to the rock. Deliberately NOT a
|
|
// distance fade: one used to live here and was removed because the photograph has the
|
|
// day's own shadows baked into it, so grass that was plain up close grew dark patches
|
|
// as you backed away and those patches slid as you walked. This keys off the material,
|
|
// which does not move when the camera does.
|
|
float orthoW = 0.55 * (1.0 - 0.62 * rockW);
|
|
// ...and it falls away with HEIGHT as well. A survey image is least trustworthy exactly
|
|
// where it matters most here: a high face is shot through the most air, at the most
|
|
// oblique angle, and comes back pale and grey whatever colour the rock is. Down in the
|
|
// valley the same image is the best record there is of where the meadow, the duff and the
|
|
// gravel actually are, so it keeps the valley and gives up the mountain.
|
|
orthoW *= 1.0 - 0.72 * smoothstep(280.0, 720.0, p.y);
|
|
// grain at three scales so the far slopes keep structure the photograph's pixels cannot carry
|
|
#ifdef TFAST_20
|
|
float grain = 0.82;
|
|
#else
|
|
float grain = 0.82 + 0.36 * fbmC(cheap, p.xz * 0.7, 2) + 0.12 * fbmC(cheap, p.xz * 4.0, 2) + 0.3 * (fbmC(cheap, p.xz * 0.06 + 5.0, 3) - 0.5) + 0.15 * (ridgedC(cheap, p.xz * 0.02 + 9.0, 2) - 0.5);
|
|
#endif
|
|
alb = mix(alb, oc * (0.35 + 1.4 * lumA / max(lumA + 0.12, 1e-3)) * grain, orthoW);
|
|
}
|
|
// ---- the shoreline, continued onto the land ------------------------------------
|
|
// A flat water plane cutting a slope meets it along one exact contour, and no amount
|
|
// of shading on the water side removes a mathematically sharp line. The transition
|
|
// has to be drawn on BOTH surfaces, so the same wash the water runs is continued up
|
|
// the bank here: identical noise fields, identical time, identical phase, keyed off
|
|
// height above the lake instead of depth below it. Across the seam the two agree, so
|
|
// there is nothing there to read as an edge.
|
|
float above = p.y - waterLine(p.xz); // >0 on land, metres
|
|
// wet ground: darker and glossier near the water, fading out over ~1.2 m
|
|
float wet = smoothstep(1.2, 0.0, above);
|
|
alb *= mix(1.0, 0.5, wet * 0.85);
|
|
// A sheet of water actually runs up the bank ahead of the foam, so the ground inside
|
|
// the wash is seen through water, not bare. Without this the gaps between the foam
|
|
// streaks showed dry grass and the wash looked like white paint on a lawn.
|
|
float film = smoothstep(0.4, 0.0, above);
|
|
vec3 shoreWater = vec3(0.05, 0.11, 0.13) * skyIrradiance(vec3(0, 1, 0)) * 1.15;
|
|
alb = mix(alb, mix(alb * 0.5, shoreWater, 0.45), film);
|
|
// The wash itself: the water's lap, run above the line and fading as it climbs — the
|
|
// same fields, octaves and phase the water uses, so the two agree across the seam.
|
|
// Its two gates — the last 22 cm above the waterline, and the first 120 m from the
|
|
// camera — are pure geometry, and outside them the four noise fields behind it cannot
|
|
// reach the screen. They are worth testing first: the wash is a hairline along one
|
|
// shore and the fields were being evaluated for every pixel of the valley.
|
|
float washGate = smoothstep(0.22, 0.0, above) * smoothstep(120.0, 15.0, dist);
|
|
if (washGate > 0.002) {
|
|
float lapT = 0.5 + 0.5 * sin(-above * 9.0 - u_time * 1.6 + 2.0 * gnoiseC(cheap, p.xz * 0.8 + u_time * 0.2));
|
|
float fdetT = fbmC(cheap, p.xz * 7.0 - u_time * 0.35, 3) * 0.5 + 0.5;
|
|
float fmidT = fbmC(cheap, p.xz * 2.6 + u_time * 0.5, 3) * 0.5 + 0.5;
|
|
float fedgeT = fbmC(cheap, p.xz * 1.4 - u_time * 0.3, 2) * 0.5 + 0.5;
|
|
// a still alpine lake has a wet line, not surf: keep the wash thin and faint
|
|
float fringe = washGate
|
|
* (0.12 * smoothstep(0.30, 0.72, fmidT)
|
|
+ 0.10 * smoothstep(0.55, 0.95, lapT) * smoothstep(0.22, 0.6, fedgeT)) * (0.55 + 0.75 * fdetT);
|
|
alb = mix(alb, vec3(0.72, 0.76, 0.76), clamp(fringe, 0.0, 1.0));
|
|
}
|
|
// snow lingering in the high gullies (the July photograph's white streaks)
|
|
float gully = 0.0;
|
|
if (gullyGate > 0.002) { gully = smoothstep(0.62, 0.85, ridgedC(cheap, p.xz * 0.02 + 3.0, 2)) * gullyGate; }
|
|
alb = mix(alb, sA * 1.05, gully * 0.9);
|
|
vec3 arm = gR * grassW + pR * pathW + rR * rockW + sR * snowW;
|
|
// world tangent frame for the planar maps
|
|
vec3 T = normalize(vec3(1.0, 0.0, 0.0) - N * N.x);
|
|
vec3 B = cross(N, T);
|
|
vec3 tn = normalize(gN * grassW + pN * pathW + sN * snowW + vec3(0, 0, 1e-3));
|
|
vec3 nPlanar = normalize(T * tn.x + B * tn.y + N * tn.z);
|
|
vec3 n = normalize(mix(nPlanar, rN, max(rockW, screeMix * 0.6)));
|
|
// fade the detail normal with distance so the far terrain does not sparkle
|
|
n = normalize(mix(n, N, smoothstep(150.0, 900.0, dist)));
|
|
float ao = arm.r;
|
|
float rough = clamp(arm.g, 0.3, 1.0);
|
|
float metal = 0.0;
|
|
// the shadow map carries the objects standing on the ground (trees, rocks); the
|
|
// ground's own relief is in the baked height-field shadow that sunShadow() applies
|
|
#ifdef TFAST_1
|
|
float shadow = 1.0;
|
|
#elif defined(SUN_INLINE)
|
|
float shadow = sunInline(p, dist, viewDepth);
|
|
#else
|
|
// tersun.frag computed this for exactly this pixel; see the note there for why the
|
|
// cascade read cannot happen in here (on OpenGL).
|
|
float shadow = texelFetch(u_sunshadow, ivec2(gl_FragCoord.xy), 0).r;
|
|
#endif
|
|
vec3 col = shade(p, n, alb, rough, metal, ao, shadow, viewDepth);
|
|
dbg_n = n; dbg_alb = alb; dbg_shadow = shadow; dbg_mat = vec3(rockW, grassW, snowW);
|
|
return col;
|
|
}
|
|
|
|
#ifndef SUN_INLINE
|
|
uniform float u_far_split;
|
|
uniform float u_far_band;
|
|
#endif
|
|
void main() {
|
|
vec3 p = v_wpos;
|
|
if (p.y < u_clip_y) discard;
|
|
vec3 N = terrainNormal(v_huv);
|
|
float slope = 1.0 - N.y;
|
|
float dist = length(p - u_cam_pos);
|
|
float viewDepth = -(u_view * vec4(p, 1.0)).z;
|
|
vec3 col;
|
|
#ifdef NEAR_ONLY
|
|
// The near program: this patch lies entirely inside the split, so only the detailed
|
|
// tier can run here. Compiled alone it does not have to hold the cheap tier's code
|
|
// beside it, which is what pushed the combined shader past the register budget.
|
|
col = groundShade(p, N, slope, dist, viewDepth, false);
|
|
#elif defined(FAR_ONLY)
|
|
// The far program: this patch is entirely beyond u_far_split + u_far_band, so every
|
|
// pixel in it would take the cheap tier anyway. Compiling that tier on its own — with
|
|
// no near path inlined beside it — is the whole point: the two tiers together put this
|
|
// shader over the register budget, and the far pixels (most of the screen: the valley
|
|
// walls and the Bells) were paying for a near path they never ran.
|
|
col = groundShade(p, N, slope, dist, viewDepth, true);
|
|
#elif defined(TFAST_5)
|
|
col = groundShade(p, N, slope, dist, viewDepth, false);
|
|
#else
|
|
float band = u_far_band;
|
|
if (dist > u_far_split + band) col = groundShade(p, N, slope, dist, viewDepth, true);
|
|
else if (dist < u_far_split - band) col = groundShade(p, N, slope, dist, viewDepth, false);
|
|
else col = mix(groundShade(p, N, slope, dist, viewDepth, false), groundShade(p, N, slope, dist, viewDepth, true), smoothstep(u_far_split - band, u_far_split + band, dist));
|
|
#endif
|
|
col = applyFog(col, p, dist);
|
|
#ifdef DEBUG_SHADOW
|
|
col = vec3(dbg_shadow);
|
|
#endif
|
|
#ifdef DEBUG_NRM
|
|
col = dbg_n * 0.5 + 0.5;
|
|
#endif
|
|
#ifdef DEBUG_MAT
|
|
col = dbg_mat;
|
|
#endif
|
|
#ifdef DEBUG_ALB
|
|
col = dbg_alb * 3.0;
|
|
#endif
|
|
o_color = vec4(sane(col), 1.0);
|
|
}
|