feat(gl): OpenGL 4.1 and the ludic.render3d renderer
Some checks failed
ci / build-and-test (push) Waiting to run
commit-lint / conventional-commits (push) Waiting to run
bootstrap / cfree-fixpoint (push) Has been cancelled
docs / build-and-deploy (push) Successful in 34s

`Gl.*` binds the whole OpenGL 4.1 core API — every entry point of the
platform gl3.h with every GL_* constant, generated by `ludic-dev glgen`
with per-call ABI thunks. Windowed builds get an NSOpenGLContext on the
existing window at Retina resolution; headless builds render into an
offscreen CGL context, so a program that uses Gl.* renders and
screenshots identically under the test harness. It links gl.ll, the
thunks and OpenGL.framework only when used; every other build stays
byte-identical.

packages/ludic.render3d is a physically based renderer written on that
surface: HDRI image-based lighting, GPU-generated terrain with scanned
PBR materials, CDLOD, cascaded shadows, glTF with skinning, instanced
vegetation with impostors, procedural grass, water, SSAO, and an HDR
pipeline with bloom, auto-exposure and ACES.

It also carries this session's work on it: the terrain at half its cost
(10.3 -> 5.4 ms of frame), the streaming hitch that got worse the longer
you played, a resize that emptied the world, and the packaging that lets
a game use the renderer from its own repository — `ludic assets`, the
material manifest shipping with the package, and shader lookup falling
back to the install root. See changes/ for each, with its numbers.

The camping game that drove all of it has moved out to its own
repository, Maroon Lake; examples/rendering/smooth.ludic stays as the
renderer's example here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 03:31:12 +03:00
parent 470971bf70
commit f25289db20
90 changed files with 35316 additions and 19853 deletions

View file

@ -0,0 +1,23 @@
// auto-exposure on the GPU: the scene's mean luminance from the top of its mip chain,
// eased toward from the previous frame's value, written to a 1x1 texture the tonemapper
// reads. Nothing comes back to the CPU (a readback there waited for the whole frame's
// GPU work and serialised the two: 34 ms -> the sum of both, measured 2026-09-09).
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_scene;
uniform sampler2D u_prev;
uniform float u_lod;
uniform float u_key;
uniform float u_max;
uniform float u_rate;
uniform float u_reset;
void main() {
vec3 c = textureLod(u_scene, vec2(0.5), u_lod).rgb;
c = clamp(c, vec3(0.0), vec3(1.0e5));
float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
float target = clamp(u_key / max(lum, 0.001), 0.02, u_max);
float prev = texture(u_prev, vec2(0.5)).r;
float e = mix(prev, target, u_rate);
if (u_reset > 0.5 || !(prev > 0.0)) e = target;
o_color = vec4(e, 0.0, 0.0, 1.0);
}

View file

@ -0,0 +1,29 @@
// impostor bake: albedo + coverage, and the model-frame normal
in vec3 v_wpos;
in vec3 v_nrm;
in vec2 v_uv;
in float v_seed;
layout(location = 0) out vec4 o_albedo;
layout(location = 1) out vec4 o_normal;
uniform sampler2D u_diff;
uniform sampler2D u_arm;
void main() {
vec4 d = texture(u_diff, v_uv);
if (d.a < 0.5) discard; // cut-out cards (needles, blades, leaves) bake with their shape
vec3 N = normalize(v_nrm);
if (!gl_FrontFacing) N = -N;
#ifdef FLOWER
float vy = clamp(v_uv.y, 0.0, 1.0);
if (v_uv.x >= 2.0) d.rgb = vec3(0.035, 0.09, 0.02) * (0.7 + 0.6 * vy);
else if (v_uv.x >= 1.0) {
float f = fract(v_uv.x);
vec3 violet = vec3(0.06, 0.03, 0.3);
vec3 lip = vec3(0.4, 0.3, 0.68);
// each floret: a dark keel at the base, a pale standard at the top edge
d.rgb = mix(violet, lip, smoothstep(0.35, 1.0, vy) * 0.6 + 0.25 * smoothstep(0.3, 0.0, abs(f - 0.5)));
d.rgb *= 0.85 + 0.15 * vy;
} else d.rgb = vec3(0.08, 0.17, 0.03);
#endif
o_albedo = vec4(d.rgb, 1.0);
o_normal = vec4(N * 0.5 + 0.5, texture(u_arm, v_uv).r);
}

View file

@ -0,0 +1,27 @@
// 13-tap downsample (Jimenez), with a soft threshold on the first level
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_src;
uniform vec2 u_texel;
uniform float u_threshold; // <0: no threshold
void main() {
vec2 t = u_texel;
vec3 a = texture(u_src, v_uv + t * vec2(-2, 2)).rgb, b = texture(u_src, v_uv + t * vec2(0, 2)).rgb, c = texture(u_src, v_uv + t * vec2(2, 2)).rgb;
vec3 d = texture(u_src, v_uv + t * vec2(-2, 0)).rgb, e = texture(u_src, v_uv).rgb, f = texture(u_src, v_uv + t * vec2(2, 0)).rgb;
vec3 g = texture(u_src, v_uv + t * vec2(-2, -2)).rgb, h = texture(u_src, v_uv + t * vec2(0, -2)).rgb, i = texture(u_src, v_uv + t * vec2(2, -2)).rgb;
vec3 j = texture(u_src, v_uv + t * vec2(-1, 1)).rgb, k = texture(u_src, v_uv + t * vec2(1, 1)).rgb;
vec3 l = texture(u_src, v_uv + t * vec2(-1, -1)).rgb, m = texture(u_src, v_uv + t * vec2(1, -1)).rgb;
a = min(a, vec3(4096.0)); b = min(b, vec3(4096.0)); c = min(c, vec3(4096.0)); d = min(d, vec3(4096.0)); e = min(e, vec3(4096.0));
f = min(f, vec3(4096.0)); g = min(g, vec3(4096.0)); h = min(h, vec3(4096.0)); i = min(i, vec3(4096.0)); j = min(j, vec3(4096.0));
k = min(k, vec3(4096.0)); l = min(l, vec3(4096.0)); m = min(m, vec3(4096.0));
vec3 col = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125;
if (u_threshold >= 0.0) {
float br = max(col.r, max(col.g, col.b));
float knee = u_threshold * 0.5;
float soft = clamp(br - u_threshold + knee, 0.0, 2.0 * knee);
soft = soft * soft / (4.0 * knee + 1e-4);
float contrib = max(soft, br - u_threshold) / max(br, 1e-4);
col *= contrib;
}
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,13 @@
// 3x3 tent upsample, added onto the destination (blend ONE ONE)
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_src;
uniform vec2 u_texel;
uniform float u_radius;
void main() {
vec2 t = u_texel * u_radius;
vec3 s = texture(u_src, v_uv + t * vec2(-1, 1)).rgb + texture(u_src, v_uv + t * vec2(0, 1)).rgb * 2.0 + texture(u_src, v_uv + t * vec2(1, 1)).rgb
+ texture(u_src, v_uv + t * vec2(-1, 0)).rgb * 2.0 + texture(u_src, v_uv).rgb * 4.0 + texture(u_src, v_uv + t * vec2(1, 0)).rgb * 2.0
+ texture(u_src, v_uv + t * vec2(-1, -1)).rgb + texture(u_src, v_uv + t * vec2(0, -1)).rgb * 2.0 + texture(u_src, v_uv + t * vec2(1, -1)).rgb;
o_color = vec4(s / 16.0, 1.0);
}

View file

@ -0,0 +1,7 @@
// full-screen triangle from gl_VertexID; uv in [0,1], z = 1 (the far plane)
out vec2 v_uv;
void main() {
vec2 p = vec2((gl_VertexID == 1) ? 3.0 : -1.0, (gl_VertexID == 2) ? 3.0 : -1.0);
v_uv = p * 0.5 + 0.5;
gl_Position = vec4(p, 1.0, 1.0);
}

View file

@ -0,0 +1,149 @@
// Procedural ground-cover blades, generated on the GPU, with NO distance rings.
//
// The world is cut into 16 m cells. Blade j of a cell always stands at the same place
// (a hash of the cell and j), so a blade never moves. How many of a cell's blades exist
// is a smooth function of the blade's own distance to the camera: cell area over the
// square of a spacing that grows linearly with distance. Thinning removes the highest
// indices first, and a blade shrinks before it goes, so density is continuous in space
// and in time and nothing can form a boundary. Draws are per tile (CPU-side frustum
// culling, grass.ludic); a tile only decides how many indices to feed the shader.
layout(location = 0) in vec3 a_pos; // x: -0.5..0.5 across, y: 0..1 along the blade, z: bend
layout(location = 2) in vec2 a_uv;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_vp;
uniform vec3 u_cam_pos;
uniform float u_time;
uniform sampler2D u_ts_height;
uniform vec2 u_ts_origin;
uniform float u_ts_half;
uniform sampler2D u_ortho;
uniform float u_ortho_on;
uniform float u_lake_level;
uniform float u_snow_line;
uniform float u_wind;
uniform vec2 u_tile; // world xz of this tile's corner
uniform int u_tile_cells; // 16 m cells per tile side
uniform int u_per_cell; // indices drawn per cell in this tile
uniform float u_s0; // blade spacing at the camera (m)
uniform float u_d0; // distance at which the spacing has doubled (m)
uniform float u_radius; // no blades past this
uniform int u_dbg; // R3D_GRASS_DBG: 1 lift blades 0.3 m, 2 light as ground everywhere, 3 both
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
out float v_hull;
const float CELL = 16.0;
float hash1(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
// An integer hash (PCG) for the per-blade values. The sine hash advanced linearly with
// the blade index, so a cell's blades fell into diagonal rows, and the rows read as
// streaks across the meadow with an edge wherever they thinned out.
uint pcg(uint v) { uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; }
float bladeHash(ivec2 cell, int j, int k) {
uint h = pcg(uint(cell.x + 32768) * 73856093u ^ uint(cell.y + 32768) * 19349663u ^ uint(j) * 83492791u ^ uint(k) * 2654435761u);
return float(h) * (1.0 / 4294967295.0);
}
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;
}
void cull() { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); v_wpos = vec3(0.0); v_nrm = vec3(0.0, 1.0, 0.0); v_uv = vec2(0.0); v_seed = 0.0; v_rot = vec2(0.0, 1.0); v_hull = 1.0; }
void main() {
int i = gl_InstanceID;
int c = i / u_per_cell;
int j = i - c * u_per_cell;
vec2 cell = u_tile + vec2(float(c % u_tile_cells), float(c / u_tile_cells)) * CELL;
vec2 cid = floor(cell / CELL + 0.5);
ivec2 ci = ivec2(cid);
float fj = float(j);
// this blade's fixed place in its cell
vec2 hv = vec2(bladeHash(ci, j, 0), bladeHash(ci, j, 1));
vec2 xz = cell + hv * CELL;
vec2 d2 = xz - u_cam_pos.xz;
float dist = length(d2);
if (dist >= u_radius) { cull(); return; }
// how many of this cell's blades exist at this distance: area over spacing^2, spacing
// growing linearly with distance. j beyond that count does not exist; the last fifth
// of the count shrinks to nothing so a blade never pops.
float spacing = u_s0 * (1.0 + dist / u_d0);
float count = CELL * CELL / (spacing * spacing) * (1.0 - smoothstep(u_radius * 0.7, u_radius, dist));
if (fj >= count) { cull(); return; }
float life = 1.0 - smoothstep(0.8, 1.0, fj / max(count, 1.0));
// the ground under it
vec2 huv = (xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
if (huv.x < 0.0 || huv.x > 1.0 || huv.y < 0.0 || huv.y > 1.0) { cull(); return; }
vec4 ht = texture(u_ts_height, huv);
vec4 croot = u_vp * vec4(xz.x, ht.r, xz.y, 1.0);
if (croot.w < -1.0 || abs(croot.x) > croot.w * 1.25 + 1.5 || abs(croot.y) > croot.w * 1.4 + 1.5) { cull(); return; }
vec3 gn = normalize(ht.gba);
float h3 = bladeHash(ci, j, 2), h4 = bladeHash(ci, j, 3);
float ok = (1.0 - smoothstep(0.30, 0.55, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - u_lake_level - 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));
}
if (h4 > ok) { cull(); return; }
// the root on the drawn surface: the CDLOD mesh follows the B-spline to within
// centimetres near the camera, so the smooth sample is the drawn height
bool far = dist > 300.0;
float h = far ? ht.r : heightSmooth(u_ts_height, huv);
if (far) h += 0.03;
if ((u_dbg & 1) != 0) h += 0.3;
// the blade: sized so that coverage stays level as the spacing grows
float seed = hv.x * 0.7 + hv.y * 0.3;
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;
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));
vec3 n = vec3(0.0, 0.3, 1.0);
float gust = sin(xz.x * 0.09 + u_time * 1.1) * 0.5 + sin(xz.y * 0.13 - u_time * 0.8 + xz.x * 0.05) * 0.5;
float ph = u_time * 1.7 + seed * 6.2831 + xz.x * 0.05 + xz.y * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4 + gust) * u_wind;
float hgt = max(p.y, 0.0);
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);
p = vec3(c_ * p.x + s * p.z, p.y, -s * p.x + c_ * p.z);
n = normalize(vec3(c_ * n.x + s * n.z, n.y, -s * n.x + c_ * n.z));
// stand on the ground: rotate the blade's frame from world-up to the surface normal
{
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 k = cross(up, gn);
float sk = length(k), ck = gn.y;
if (sk > 1e-4) {
k /= sk;
p = p * ck + cross(k, p) * sk + k * dot(k, p) * (1.0 - ck);
n = normalize(n * ck + cross(k, n) * sk + k * dot(k, n) * (1.0 - ck));
}
}
// Blades are lit with the ground's normal from a few metres out. Lit by their own
// facing, the wind's sine field bent them in bands and the lit/unlit sides flipped in
// those bands: light and dark rows across the whole meadow. Ground-normal lighting is
// what open-world grass does (the blade's own normal only matters within arm's reach).
n = normalize(mix(n, gn, smoothstep(2.0, 12.0, dist)));
vec3 w = vec3(xz.x, h - 0.02, xz.y) + p;
v_wpos = w;
v_nrm = n;
v_uv = far ? vec2(a_uv.x, 0.45 + 0.2 * a_uv.y) : a_uv;
v_seed = seed;
v_rot = vec2(s, c_);
v_hull = (dist > 2.0 || far || (u_dbg & 2) != 0) ? -1.0 : 1.0; // no back-face flip, no rounding past arm's reach
gl_Position = u_proj * u_view * vec4(w, 1.0);
}

View file

@ -0,0 +1,110 @@
// world height (metres) for the texel's x/z; R32F target
in vec2 v_uv;
out vec4 o;
uniform float u_half;
#ifdef DEM
uniform sampler2D u_dem; // 16-bit height map of a real place (Copernicus GLO-30)
uniform float u_dem_min;
uniform float u_dem_max;
uniform float u_dem_base; // the elevation that becomes y = 0
uniform vec2 u_origin; // world x/z of the map's centre
uniform vec4 u_lake; // a lake: centre x/z, half extents (zero = none)
uniform float u_lake_level; // its surface height; the model records the surface, the bed is carved below it
// The DEM is Copernicus GLO-30 — 30 m data resampled onto this 4 m grid — so the stored
// field is piecewise linear with a slope discontinuity every ~7 texels. Differencing it
// for a shading normal turns each kink into a ridge, and on steep ground, where the same
// kink spans a large height change, they read as a regular corrugation running across
// the slope. Smoothing across that lattice removes them and discards no real detail:
// there is none below 30 m in the source, and the fractal detail below supplies the fine
// relief. Kernel is a separable gaussian sampled at 3-texel spacing (~12 m each side).
float demRaw(vec2 uv) { return texture(u_dem, uv).r; }
// u_dem_blur texels of separable gaussian (0 = the survey as it is). The 30 m Copernicus
// model needed ~3 texels to hide its resampling lattice; 2 m lidar needs none.
uniform float u_dem_blur;
float demH(vec2 uv) {
if (u_dem_blur <= 0.0) return mix(u_dem_min, u_dem_max, demRaw(uv));
vec2 t = u_dem_blur / vec2(textureSize(u_dem, 0));
float c = demRaw(uv);
float e = demRaw(uv + vec2(t.x, 0.0)) + demRaw(uv - vec2(t.x, 0.0))
+ demRaw(uv + vec2(0.0, t.y)) + demRaw(uv - vec2(0.0, t.y));
float d = demRaw(uv + t) + demRaw(uv - t)
+ demRaw(uv + vec2(t.x, -t.y)) + demRaw(uv + vec2(-t.x, t.y));
float f = demRaw(uv + 2.0 * vec2(t.x, 0.0)) + demRaw(uv - 2.0 * vec2(t.x, 0.0))
+ demRaw(uv + 2.0 * vec2(0.0, t.y)) + demRaw(uv - 2.0 * vec2(0.0, t.y));
float h = (12.0 * c + 6.0 * e + 3.0 * d + 2.0 * f) / (12.0 + 24.0 + 12.0 + 8.0);
return mix(u_dem_min, u_dem_max, h);
}
#endif
float bump(vec2 p, vec2 c, float r) { float d = length(p - c) / r; return exp(-d * d * 2.0); }
void main() {
#ifdef DEM
vec2 xz = (v_uv - 0.5) * 2.0 * u_half + u_origin;
float e = 1.0 / 2048.0;
float h = demH(v_uv) - u_dem_base;
// the basin mask keys off the UNSMOOTHED sample: the lake is only ~1 m below its shore
// in the model, and the 12 m blur above averages the shore with the flat water beside
// it, dragging it under the outline threshold — the camera's own bank was carved 7 m down
float raw = demRaw(v_uv) * (u_dem_max - u_dem_min) + u_dem_min - u_dem_base;
// the survey carries its own relief at this resolution; only the foot track is added
h -= 0.25 * smoothstep(6.0, 1.5, pathDist(xz));
if (u_lake.z > 0.0) {
// the elevation model samples the water surface as flat ground: inside the lake's
// outline sink it into a bed, deepest in the middle, with a gentle gravel ramp at the shore
vec2 q = (xz - u_lake.xy) / u_lake.zw;
float inside = smoothstep(1.0, 0.8, dot(q, q));
// the lidar records the lake as a flat surface exactly at its level: everything at or
// just above that level inside the outline is lake bed
float basin = smoothstep(u_lake_level + 0.6, u_lake_level - 0.3, raw) * inside;
float bed = u_lake_level - 0.4 - (5.0 + 3.0 * (1.0 - dot(q, q)) + 1.5 * fbm(xz * 0.02, 3)) * basin * basin; // a gentle gravel ramp, then the drop
h = mix(h, bed, basin);
}
o = vec4(h, 0.0, 0.0, 1.0);
#elif defined(SMOOTH)
// A purpose-built test ground: 2 km square, analytically smooth everywhere. No survey
// data, so no resampling lattice and no quantised source — if a grid still shows on
// this, the cause is in the renderer rather than the elevation model.
vec2 xz = (v_uv - 0.5) * 2.0 * u_half;
float d = length(xz);
// meadow: a gentle roll a few metres either side of 8 m, two octaves only
float h = 8.0 + 4.5 * fbm(xz * 0.0018, 3) + 1.4 * fbm(xz * 0.007, 3);
// a small pond in the middle: a smooth basin ~90 m across, floor about 5 m down
float pond = exp(-dot(xz, xz) / (2.0 * 70.0 * 70.0));
h -= 13.0 * pond;
// the rim mountain, 200 m above the meadow, starting well outside the grass
h += smoothstep(620.0, 980.0, d) * (200.0 + 90.0 * fbm(xz * 0.0025 + 4.0, 4));
o = vec4(h, 0.0, 0.0, 1.0);
#else
vec2 xz = (v_uv - 0.5) * 2.0 * u_half;
float d = length(xz);
// the meadow falls away northward (-z) from the rise the camera stands on, into a broad valley
float base = 0.055 * xz.y + 4.0 * fbm(xz * 0.008, 5) + 1.2 * fbm(xz * 0.04, 4) + 0.15 * fbm(xz * 0.35, 3);
// soft valley floor with a stream
float floorY = -22.0;
float k = 12.0;
base = floorY + k * log(1.0 + exp((base - floorY) / k));
float sb = smoothstep(6.0, 0.0, abs(xz.x - 150.0 - 90.0 * sin(xz.y * 0.006 + 1.0))) * smoothstep(-220.0, -400.0, xz.y);
base -= 1.6 * sb;
// the track sits slightly worn in
base -= 0.25 * smoothstep(6.0, 1.5, pathDist(xz));
float h = base;
// far ridge across the valley, forested hills
float back = smoothstep(-550.0, -1000.0, xz.y);
h += back * (90.0 + 160.0 * ridged(xz * 0.0025 + 5.0, 6));
// the great snow mountain to the north-west, and its shoulder
float m1 = bump(xz, vec2(-820.0, -520.0), 520.0);
float m2 = bump(xz, vec2(-1150.0, -900.0), 600.0);
float m3 = bump(xz, vec2(-560.0, -150.0), 260.0);
float mtn = max(m1 * 620.0, max(m2 * 720.0, m3 * 180.0));
h += mtn * (0.45 + 0.55 * ridged(xz * 0.0018 + 11.0, 7)) + 40.0 * (m1 + m2) * ridged(xz * 0.008 + 3.0, 5);
// hills to the east, lower and rounder
float e1 = bump(xz, vec2(760.0, -420.0), 420.0);
float e2 = bump(xz, vec2(950.0, 150.0), 380.0);
h += (e1 * 170.0 + e2 * 120.0) * (0.6 + 0.4 * fbm(xz * 0.004 + 9.0, 5));
// the outer rim so nothing ends at a flat edge
h += smoothstep(750.0, 1024.0, d) * (120.0 + 120.0 * ridged(xz * 0.003, 5));
// a rocky knoll on the right of the meadow
float knoll = 14.0 * bump(xz, vec2(230.0, -40.0), 70.0);
h += knoll * (0.6 + 0.4 * fbm(xz * 0.05, 4));
o = vec4(h, 0.0, 0.0, 1.0);
#endif
}

View file

@ -0,0 +1,36 @@
in vec2 v_uv;
out vec4 o_color;
vec2 hammersley(uint i, uint n) {
uint b = i;
b = (b << 16u) | (b >> 16u);
b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
return vec2(float(i) / float(n), float(b) * 2.3283064365386963e-10);
}
void main() {
float NoV = max(v_uv.x, 1e-3);
float rough = max(v_uv.y, 0.02);
vec3 v = vec3(sqrt(1.0 - NoV * NoV), 0.0, NoV);
float a = rough * rough;
float A = 0.0, B = 0.0;
const uint N = 512u;
for (uint i = 0u; i < N; i++) {
vec2 x = hammersley(i, N);
float phi = 2.0 * PI * x.x;
float ct = sqrt((1.0 - x.y) / (1.0 + (a * a - 1.0) * x.y));
float st = sqrt(1.0 - ct * ct);
vec3 h = vec3(cos(phi) * st, sin(phi) * st, ct);
vec3 l = 2.0 * dot(v, h) * h - v;
float NoL = max(l.z, 0.0), NoH = max(h.z, 0.0), VoH = max(dot(v, h), 0.0);
if (NoL > 0.0) {
float G = V_Smith(NoV, NoL, a) * 4.0 * NoL * NoV; // Smith G from the visibility term
float Gv = G * VoH / max(NoH * NoV, 1e-4);
float Fc = pow(1.0 - VoH, 5.0);
A += (1.0 - Fc) * Gv;
B += Fc * Gv;
}
}
o_color = vec4(clamp(A / float(N), 0.0, 1.0), clamp(B / float(N), 0.0, 1.0), 0.0, 1.0);
}

View file

@ -0,0 +1,36 @@
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_sky;
uniform float u_sun_clip; // clamp the sun's radiance so it does not alias the convolution
vec3 dirFromUV(vec2 uv) {
float phi = (uv.x - 0.5) * 2.0 * PI;
float theta = uv.y * PI;
return vec3(sin(theta) * sin(phi), cos(theta), -sin(theta) * cos(phi));
}
vec2 hammersley(uint i, uint n) {
uint b = i;
b = (b << 16u) | (b >> 16u);
b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
return vec2(float(i) / float(n), float(b) * 2.3283064365386963e-10);
}
void main() {
vec3 n = dirFromUV(v_uv);
vec3 up = abs(n.y) < 0.999 ? vec3(0, 1, 0) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, n));
vec3 b = cross(n, t);
vec3 acc = vec3(0.0);
const uint N = 512u;
for (uint i = 0u; i < N; i++) {
vec2 h = hammersley(i, N);
float phi = 2.0 * PI * h.x;
float ct = sqrt(1.0 - h.y); // cosine-weighted
float st = sqrt(h.y);
vec3 d = t * (cos(phi) * st) + b * (sin(phi) * st) + n * ct;
vec3 c = textureLod(u_sky, skyUV(d), 5.0).rgb;
acc += min(c, vec3(u_sun_clip));
}
o_color = vec4(acc / float(N), 1.0);
}

View file

@ -0,0 +1,53 @@
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_sky;
uniform float u_rough;
uniform float u_sun_clip;
uniform float u_sky_w;
vec3 dirFromUV(vec2 uv) {
float phi = (uv.x - 0.5) * 2.0 * PI;
float theta = uv.y * PI;
return vec3(sin(theta) * sin(phi), cos(theta), -sin(theta) * cos(phi));
}
vec2 hammersley(uint i, uint n) {
uint b = i;
b = (b << 16u) | (b >> 16u);
b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
return vec2(float(i) / float(n), float(b) * 2.3283064365386963e-10);
}
void main() {
vec3 n = dirFromUV(v_uv);
vec3 v = n;
if (u_rough < 0.02) { o_color = vec4(textureLod(u_sky, skyUV(n), 0.0).rgb, 1.0); return; }
vec3 up = abs(n.y) < 0.999 ? vec3(0, 1, 0) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, n));
vec3 b = cross(n, t);
float a = u_rough * u_rough;
vec3 acc = vec3(0.0);
float wsum = 0.0;
const uint N = 256u;
for (uint i = 0u; i < N; i++) {
vec2 x = hammersley(i, N);
float phi = 2.0 * PI * x.x;
float ct = sqrt((1.0 - x.y) / (1.0 + (a * a - 1.0) * x.y));
float st = sqrt(1.0 - ct * ct);
vec3 h = t * (cos(phi) * st) + b * (sin(phi) * st) + n * ct;
vec3 l = 2.0 * dot(v, h) * h - v;
float NoL = dot(n, l);
if (NoL > 0.0) {
float NoH = max(ct, 0.0);
float D = D_GGX(NoH, a);
float pdf = D * NoH / (4.0 * max(dot(v, h), 1e-4)) + 1e-4;
float saTexel = 4.0 * PI / (u_sky_w * u_sky_w * 0.5);
float saSample = 1.0 / (float(N) * pdf);
float mip = clamp(0.5 * log2(saSample / saTexel) + 1.0, 0.0, 8.0);
vec3 c = textureLod(u_sky, skyUV(l), mip).rgb;
acc += min(c, vec3(u_sun_clip)) * NoL;
wsum += NoL;
}
}
o_color = vec4(acc / max(wsum, 1e-4), 1.0);
}

View file

@ -0,0 +1,66 @@
in vec2 v_uv;
in vec3 v_wpos;
in float v_seed;
in float v_tile;
in float v_yaw;
out vec4 o_color;
uniform sampler2D u_atlas_albedo;
uniform sampler2D u_atlas_normal;
uniform mat4 u_view;
uniform float u_tiles;
uniform vec3 u_tint;
uniform float u_radius;
void main() {
if (v_wpos.y < u_clip_y) discard;
vec2 uv = vec2((v_tile + v_uv.x) / u_tiles, v_uv.y);
#ifdef SHADOW_PASS
// the map's texels are coarse: read a finer mip so the crown's coverage is not averaged away
vec4 a = texture(u_atlas_albedo, uv, -3.0);
if (a.a < 0.22) discard;
#else
vec4 a = texture(u_atlas_albedo, uv);
float rawA = a.a;
a.rgb /= max(a.a, 1e-3); // the atlas mips are premultiplied by coverage
// alpha-to-coverage, sharpened per mip so the silhouette stays crisp at any distance
float cov = (a.a - 0.3) / max(fwidth(a.a), 1e-4) + 0.5;
if (cov < 0.02) discard;
a.a = clamp(cov, 0.0, 1.0);
#endif
#ifdef SHADOW_PASS
return;
#else
// The normal atlas is cleared to black where nothing was drawn, and its mips average
// that black into every silhouette texel — decoded, a half-covered texel pointed away
// from everything and shaded near black, so each tree wore a dark outline. Dividing by
// the same coverage restores the normal (and the AO in .a) of the covered part.
vec4 nn = texture(u_atlas_normal, uv) / max(rawA, 1e-3);
vec3 n = clamp(nn.rgb, 0.0, 1.0) * 2.0 - 1.0;
// the tile was baked from angle tile*2pi/tiles around the canonical tree; rotate by the instance yaw
float s = sin(v_yaw), c = cos(v_yaw);
n = vec3(c * n.x + s * n.z, n.y, -s * n.x + c * n.z);
n = normalize(n);
// canopy hull normal: a rounded shell over the card, blended with the baked detail
float far = smoothstep(200.0, 1200.0, length(v_wpos - u_cam_pos));
vec2 q = vec2(v_uv.x * 2.0 - 1.0, v_uv.y * 2.0 - 1.0);
vec3 toCam = normalize(u_cam_pos - v_wpos); toCam.y = 0.0; toCam = normalize(toCam);
vec3 right = vec3(-toCam.z, 0.0, toCam.x);
vec3 hull = normalize(right * q.x * 0.8 + vec3(0.0, 1.0, 0.0) * (q.y * 0.6 + 0.35) + toCam * 0.7);
n = normalize(mix(hull, n, mix(0.65, 0.35, far)));
float dist = length(v_wpos - u_cam_pos);
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
vec3 alb = a.rgb * u_tint * (0.85 + 0.3 * fract(v_seed * 7.13)) * regionTint(v_wpos, 0.4);
// a distant stand reads as a dark mass, not as bright separate sprites
alb = mix(alb, alb * vec3(0.72, 0.78, 0.72), far);
// the card itself is the caster: look up the shadow a little toward the sun so it does not self-shadow
float shadow = sunShadow(v_wpos + u_sun_dir * u_radius * 0.7, vec3(0, 1, 0), viewDepth);
// crowns are dense: darken toward the centre of the card as a cheap interior occlusion
float interior = 1.0 - 0.45 * smoothstep(0.9, 0.3, abs(q.x)) * smoothstep(1.0, 0.2, v_uv.y);
// ground contact: the lowest part of anything sitting on the ground is occluded by it
// (a boulder's underside, a trunk's base); without it a far boulder is a sticker on the grass
interior *= mix(0.55, 1.0, smoothstep(0.0, 0.3, v_uv.y));
vec3 col = shade(v_wpos, n, alb, 0.85, 0.0, clamp(nn.a, 0.0, 1.0) * 0.8 * interior, shadow * interior, viewDepth);
col += alb * skyIrradiance(vec3(0, 1, 0)) * 0.06;
col = applyFog(col, v_wpos, dist);
o_color = vec4(sane(col), a.a);
#endif
}

View file

@ -0,0 +1,42 @@
// camera-facing (around y) card per instance, showing the atlas tile nearest the view angle
layout(location = 0) in vec2 a_xy; // [-0.5, 0.5] x [0, 1]
layout(location = 1) in vec2 a_uv;
layout(location = 3) in vec4 i_pos; // x y z scale
layout(location = 4) in vec4 i_rot; // sin cos seed wind
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_light_vp;
uniform vec3 u_cam_pos;
uniform vec3 u_face_dir; // direction the cards face (to the camera, or the sun in the shadow pass)
uniform float u_radius;
uniform float u_height;
uniform float u_tiles;
out vec2 v_uv;
out vec3 v_wpos;
out float v_seed;
out float v_tile;
out float v_yaw;
void main() {
vec3 center = i_pos.xyz;
#ifdef SHADOW_PASS
vec3 toCam = normalize(vec3(u_face_dir.x, 0.0, u_face_dir.z));
#else
vec3 toCam = u_cam_pos - center; toCam.y = 0.0; toCam = normalize(toCam);
#endif
vec3 right = vec3(-toCam.z, 0.0, toCam.x);
float yaw = atan(i_rot.x, i_rot.y);
// angle of the viewer around the (rotated) tree, in tile units
float ang = atan(toCam.x, -toCam.z) - yaw;
float t = floor(fract(ang / 6.2831853) * u_tiles + 0.5);
v_tile = mod(t, u_tiles);
v_yaw = yaw;
vec3 w = center + right * (a_xy.x * 2.0 * u_radius * i_pos.w) + vec3(0.0, a_xy.y * u_height * i_pos.w, 0.0);
v_wpos = w;
v_uv = a_uv;
v_seed = i_rot.z;
#ifdef SHADOW_PASS
gl_Position = u_light_vp * vec4(w, 1.0);
#else
gl_Position = u_proj * u_view * vec4(w, 1.0);
#endif
}

View file

@ -0,0 +1,272 @@
// ---- PBR + IBL + cascaded shadows + aerial perspective (shared) ---------------------
uniform sampler2D u_irradiance; // equirect, diffuse-convolved sky
uniform sampler2DArray u_prefilter; // equirect, GGX-prefiltered sky per roughness level
uniform sampler2D u_brdf; // split-sum BRDF LUT
#define CASCADES 5
uniform sampler2DArrayShadow u_shadow; // CASCADES layers
float shadowTap(vec2 uv, int c, float ref) { return texture(u_shadow, vec4(uv, float(c), ref)); }
uniform mat4 u_cascade_vp[CASCADES];
uniform float u_cascade_split[CASCADES]; // view-space far distance of each cascade
uniform float u_cascade_range[CASCADES]; // light-frustum depth extent of each cascade (m)
uniform float u_cascade_texel[CASCADES]; // shadow texel size of each cascade (m)
uniform vec3 u_sun_dir; // toward the sun
uniform vec3 u_sun_color; // radiance
uniform vec3 u_cam_pos;
uniform float u_prefilter_levels;
uniform float u_fog_density;
uniform float u_fog_height_falloff;
uniform float u_clip_y; // planar-reflection pass: discard everything below this height
uniform float u_spec_scale; // 1 for surfaces; foliage crowns get a fraction: needles are
// tiny rough cylinders, not sheets, and a crown of card quads
// seen at grazing angles otherwise mirrors the sky and frosts
const float PI = 3.14159265359;
// never let a NaN or an infinity reach the frame: it would smear through the bloom pyramid
vec3 sane(vec3 c) { return (any(isnan(c)) || any(isinf(c))) ? vec3(0.0) : c; }
vec2 equirectUV(vec3 d) {
return vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
}
// the HDRI itself is read through a yaw rotation (u_sky_rot = sin, cos), so the sun can be
// placed where the scene wants it; the convolved maps are built through the same rotation
uniform vec2 u_sky_rot;
vec2 skyUV(vec3 d) {
vec3 r = vec3(u_sky_rot.y * d.x + u_sky_rot.x * d.z, d.y, -u_sky_rot.x * d.x + u_sky_rot.y * d.z);
return equirectUV(r);
}
// The HDRI is a pure sky: below the horizon it is a flat bright grey, not ground. Anything
// whose normal points down — the underside of a needle card, the lower half of a crown —
// was lighting itself from that grey and came out white. Below the horizon the light is
// what the ground reflects: the horizon sky times a meadow albedo.
const vec3 GROUND_ALB = vec3(0.30, 0.34, 0.14);
// the time of day (daylight.ludic): the sky's light scaled toward night, and the campfire
uniform vec3 u_ibl_scale;
uniform float u_daylight;
uniform vec3 u_fire_pos;
uniform vec3 u_fire_color;
uniform vec3 u_hand_pos; // a torch or flashlight in the hand
uniform vec3 u_hand_color;
uniform vec3 u_hand_dir;
uniform float u_hand_cone; // cos of the half-angle; <= -1: a point light
vec3 skyIrradianceRaw(vec3 n) { return texture(u_irradiance, equirectUV(n)).rgb * u_ibl_scale; }
vec3 skyIrradiance(vec3 n) {
vec3 up = skyIrradianceRaw(vec3(n.x, max(n.y, 0.0), n.z));
vec3 ground = skyIrradianceRaw(normalize(vec3(n.x, 0.15, n.z) + vec3(1e-4, 0.0, 0.0))) * GROUND_ALB;
return mix(ground, up, smoothstep(-0.25, 0.2, n.y));
}
vec3 skyPrefilteredRaw(vec3 r, float rough) {
float lv = rough * (u_prefilter_levels - 1.0);
float l0 = floor(lv);
float l1 = min(l0 + 1.0, u_prefilter_levels - 1.0);
vec2 uv = equirectUV(r);
return mix(texture(u_prefilter, vec3(uv, l0)).rgb, texture(u_prefilter, vec3(uv, l1)).rgb, lv - l0) * u_ibl_scale;
}
// the campfire: one warm point light, out by twelve metres
vec3 fireLight(vec3 wpos, vec3 n, vec3 albedo) {
vec3 d = u_fire_pos - wpos;
float r2 = max(dot(d, d), 0.04);
vec3 l = d * inversesqrt(r2);
float att = smoothstep(14.0, 5.0, sqrt(r2)) / (0.6 + r2);
return albedo / PI * u_fire_color * max(dot(n, l), 0.0) * att;
}
vec3 handLight(vec3 wpos, vec3 n, vec3 albedo) {
vec3 d = u_hand_pos - wpos;
float r2 = max(dot(d, d), 0.04);
vec3 l = d * inversesqrt(r2);
float att = smoothstep(26.0, 6.0, sqrt(r2)) / (0.5 + r2 * 0.35);
if (u_hand_cone > -1.0) {
float c = dot(-l, u_hand_dir);
att *= smoothstep(u_hand_cone, u_hand_cone + 0.12, c);
}
return albedo / PI * u_hand_color * max(dot(n, l), 0.0) * att;
}
vec3 skyPrefiltered(vec3 r, float rough) {
vec3 up = skyPrefilteredRaw(vec3(r.x, max(r.y, 0.0), r.z), rough);
vec3 ground = skyPrefilteredRaw(normalize(vec3(r.x, 0.15, r.z) + vec3(1e-4, 0.0, 0.0)), max(rough, 0.6)) * GROUND_ALB;
return mix(ground, up, smoothstep(-0.2, 0.15, r.y));
}
float D_GGX(float NoH, float a) { float a2 = a * a; float d = NoH * NoH * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); }
float V_Smith(float NoV, float NoL, float a) {
float a2 = a * a;
float gv = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
float gl = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
return 0.5 / max(gv + gl, 1e-4);
}
vec3 F_Schlick(float VoH, vec3 f0) { float f = pow(1.0 - VoH, 5.0); return f0 + (1.0 - f0) * f; }
vec3 F_SchlickRough(float NoV, vec3 f0, float rough) { return f0 + (max(vec3(1.0 - rough), f0) - f0) * pow(1.0 - NoV, 5.0); }
// interleaved-gradient noise for rotated PCF taps
float ign(vec2 p) { return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y)); }
float cascadeRange(int c) { return u_cascade_range[c]; }
float cascadeTexel(int c) { return u_cascade_texel[c]; }
// biasWorld in metres; the receiver is pushed along its normal by a texel first
float shadowSample(int c, vec3 wpos, float biasWorld) {
vec4 lp = u_cascade_vp[c] * vec4(wpos, 1.0);
vec3 p = lp.xyz / lp.w * 0.5 + 0.5;
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) return 1.0;
float bias = biasWorld / cascadeRange(c);
float texel = 1.0 / 2048.0;
float r = ign(gl_FragCoord.xy) * 6.2831853;
float cs = cos(r), sn = sin(r);
mat2 rot = mat2(cs, sn, -sn, cs);
float s = 0.0;
const vec2 taps[8] = vec2[8](vec2(-0.7071, 0.7071), vec2(-0.0, -0.875), vec2(0.5303, 0.5303), vec2(-0.625, -0.0),
vec2(0.3536, -0.3536), vec2(-0.0, 0.375), vec2(-0.1768, -0.1768), vec2(0.125, 0.0));
// the far cascades' texels are metres wide: a wider filter turns their staircase into a penumbra
float rad = texel * ((c >= 4) ? 2.6 : (c == 3) ? 2.0 : 1.5);
for (int i = 0; i < 8; i++) {
vec2 off = rot * taps[i] * rad;
s += shadowTap(p.xy + off, c, p.z - bias);
}
return s / 8.0;
}
// one cascade's lookup, with a normal offset and a slope-scaled depth bias
float shadowSlope(int c, vec3 wpos, vec3 n, float tanT) {
float tx = cascadeTexel(c);
float filt = (c >= 4) ? 2.6 : ((c == 3) ? 2.0 : 1.5); // matches shadowSample's rad
vec3 wp = wpos + n * tx * (2.5 + 1.5 * tanT);
return shadowSample(c, wp, tx * (1.0 + filt * tanT) + 0.02);
}
// The baked height-field shadow (tershadow.frag): R = the lowest lit height over this
// ground texel, G = distance to the occluder that set it. Any receiver — ground, crown,
// card, water — compares its own height, so everything agrees on where the hill's
// shadow falls. The penumbra widens with the occluder's distance like a real one.
uniform sampler2D u_tershadow;
uniform vec2 u_ts_origin;
uniform float u_ts_half;
uniform float u_ts_on;
uniform sampler2D u_ts_height;
// the ground's normal under a world position (4 m texels): cover standing on the ground
// is lit with this beyond a few tens of metres, so a hillside and the grass on it agree
vec3 terrainNormalAt(vec3 wpos) {
vec2 uv = (wpos.xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
float step = 1.0 / float(textureSize(u_ts_height, 0).x);
float world = step * 2.0 * u_ts_half;
float hl = texture(u_ts_height, uv - vec2(step, 0)).r, hr = texture(u_ts_height, uv + vec2(step, 0)).r;
float hd = texture(u_ts_height, uv - vec2(0, step)).r, hu = texture(u_ts_height, uv + vec2(0, step)).r;
return normalize(vec3(hl - hr, 2.0 * world, hd - hu));
}
float terrainShadow(vec3 wpos) {
if (u_ts_on < 0.5) return 1.0;
vec2 uv = (wpos.xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
vec2 s = texture(u_tershadow, uv).rg;
float w = 0.6 + 0.02 * s.y;
return smoothstep(-w, w, wpos.y + 0.25 - s.x);
}
uniform int u_force_cascade;
// the far-field version: one hardware 2x2 tap in the cascade, no rotated disc, no blend
float sunShadowCheap(vec3 wpos, vec3 n, float viewDepth) {
int c = CASCADES - 1;
for (int i = 0; i < CASCADES - 1; i++) { if (viewDepth < u_cascade_split[i]) { c = i; break; } }
float tx = cascadeTexel(c);
vec4 lp = u_cascade_vp[c] * vec4(wpos + n * tx * 2.5, 1.0);
vec3 p = lp.xyz / lp.w * 0.5 + 0.5;
float s = 1.0;
if (p.x >= 0.0 && p.x <= 1.0 && p.y >= 0.0 && p.y <= 1.0 && p.z <= 1.0) s = shadowTap(p.xy, c, p.z - (tx * 2.0 + 0.02) / cascadeRange(c));
return min(s, terrainShadow(wpos));
}
float sunShadow(vec3 wpos, vec3 n, float viewDepth) {
int c = CASCADES - 1;
if (u_force_cascade >= 0) { float tx0 = cascadeTexel(u_force_cascade); return shadowSample(u_force_cascade, wpos + n * tx0 * 1.5, tx0 * 1.5 + 0.02); }
for (int i = 0; i < CASCADES - 1; i++) { if (viewDepth < u_cascade_split[i]) { c = i; break; } }
float NoL = max(dot(n, u_sun_dir), 0.0);
// Depth across one shadow texel changes by texel * tan(theta) on a surface lit at
// theta from its normal, and the PCF disc reaches `filt` texels out, so the bias must
// cover the drop over the whole filter rather than a single texel. The old form used
// (1 - NoL): at NoL = 0.2 that is 0.8 where tan(theta) is 4.9, six times short. With
// the caster and receiver now the same mesh, that shortfall is what let the terrain
// shadow itself along its own triangle edges — a faint grid over the whole slope.
float tanT = min(sqrt(max(1.0 - NoL * NoL, 0.0)) / max(NoL, 0.05), 10.0);
float s = shadowSlope(c, wpos, n, tanT);
// blend across the cascade edge
float edge = u_cascade_split[c];
float f = smoothstep(edge * 0.85, edge, viewDepth);
if (f > 0.0 && c < CASCADES - 1) {
s = mix(s, shadowSlope(c + 1, wpos, n, tanT), f);
}
return min(s, terrainShadow(wpos));
}
// patchy sunlight: a cloud layer projected along the sun onto the ground
uniform float u_cloud_shadow; // strength
uniform float u_time;
// the mask is baked into the height-field shadow texture's B (tershadow.frag); the
// projection along the sun and the drift are a uv shift
float cloudShadow(vec3 wpos) {
if (u_cloud_shadow <= 0.0 || u_ts_on < 0.5) return 1.0;
float h = 1400.0 - wpos.y;
vec2 c = wpos.xz + u_sun_dir.xz / max(u_sun_dir.y, 0.1) * h;
c += vec2(u_time * 3.0, u_time * 1.2);
vec2 uv = (c - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
return 1.0 - u_cloud_shadow * texture(u_tershadow, uv).b;
}
// regional vegetation colour: aspen groves and drier ridges read lighter and yellower than
// the dark spruce and the lush hollows (a slow noise over the world, shaped by elevation)
vec3 regionTint(vec3 wpos, float strength) {
float n = fbm(wpos.xz * 0.0018 + 4.0, 3) * 0.5 + 0.5;
float aspen = smoothstep(0.52, 0.7, n) * smoothstep(520.0, 250.0, wpos.y);
float dry = smoothstep(0.35, 0.15, fbm(wpos.xz * 0.004 + 9.0, 3) * 0.5 + 0.5);
vec3 t = vec3(1.0);
t = mix(t, vec3(1.25, 1.3, 0.85), aspen * strength);
t = mix(t, vec3(1.15, 1.05, 0.7), dry * strength * 0.6);
return t;
}
// direct + image-based lighting for one surface
vec3 shade(vec3 wpos, vec3 n, vec3 albedo, float rough, float metal, float ao, float shadow, float viewDepth) {
vec3 v = normalize(u_cam_pos - wpos);
vec3 l = u_sun_dir;
vec3 h = normalize(v + l);
float NoV = max(dot(n, v), 1e-2);
float NoL = max(dot(n, l), 0.0);
float NoH = max(dot(n, h), 0.0);
float VoH = max(dot(v, h), 0.0);
rough = clamp(rough, 0.045, 1.0);
float a = rough * rough;
vec3 f0 = mix(vec3(0.04), albedo, metal);
vec3 F = F_Schlick(VoH, f0);
vec3 spec = min(D_GGX(NoH, a) * V_Smith(NoV, NoL, a), 12.0) * F * u_spec_scale; // cap the highlight: no half-float overflow, no fireflies
vec3 kd = (1.0 - F) * (1.0 - metal);
vec3 direct = (kd * albedo / PI + spec) * u_sun_color * NoL * shadow * cloudShadow(wpos);
// IBL
vec3 Fr = F_SchlickRough(NoV, f0, rough);
vec3 kdi = (1.0 - Fr) * (1.0 - metal);
vec3 irr = skyIrradiance(n);
vec3 diffuseIBL = kdi * albedo * irr;
vec3 r = reflect(-v, n);
vec3 pre = skyPrefiltered(r, rough);
vec2 brdf = texture(u_brdf, vec2(NoV, rough)).rg;
vec3 specIBL = pre * (Fr * brdf.x + brdf.y) * u_spec_scale;
// one bounce off the sunlit ground onto whatever faces it (a warm fill from below)
vec3 groundAlb = vec3(0.16, 0.2, 0.07);
vec3 bounce = kdi * albedo * groundAlb * (u_sun_color * max(u_sun_dir.y, 0.0) / PI + irr) * clamp(0.5 - 0.5 * n.y, 0.0, 1.0) * 0.5;
// specular occlusion from ao
float so = clamp(pow(NoV + ao, exp2(-16.0 * rough - 1.0)) - 1.0 + ao, 0.0, 1.0);
// check for NaN before min(): on this GPU min(NaN, x) returns x, which would hide the fault as a hot pixel
vec3 c = direct + (diffuseIBL * ao + specIBL * so) + bounce * ao + (fireLight(wpos, n, albedo) + handLight(wpos, n, albedo)) * ao;
if (any(isnan(direct)) || any(isnan(diffuseIBL)) || any(isnan(specIBL)) || isnan(so)) return vec3(0.0);
return sane(min(c, vec3(4096.0)));
}
// aerial perspective: exponential height fog toward the horizon sky, with sun inscatter
vec3 applyFog(vec3 col, vec3 wpos, float dist) {
vec3 dir = normalize(wpos - u_cam_pos);
float hf = u_fog_height_falloff;
float t = dir.y * hf;
float integ = (abs(t) > 1e-4) ? (1.0 - exp(-dist * t)) / t : dist * (1.0 - 0.5 * dist * t);
float fogAmt = u_fog_density * exp(-u_cam_pos.y * hf) * integ;
float f = 1.0 - exp(-fogAmt);
vec3 fogCol = skyPrefiltered(vec3(dir.x, max(dir.y, 0.02), dir.z), 0.6);
float sunAmt = pow(max(dot(dir, u_sun_dir), 0.0), 8.0);
fogCol += u_sun_color * 0.02 * sunAmt;
return mix(col, fogCol, clamp(f, 0.0, 1.0));
}

View file

@ -0,0 +1,185 @@
in vec3 v_wpos;
in vec3 v_nrm;
in vec2 v_uv;
in float v_seed;
in vec2 v_rot;
in float v_hull;
uniform float u_model_h;
out vec4 o_color;
uniform sampler2D u_diff;
uniform sampler2D u_nrm;
uniform sampler2D u_arm;
uniform mat4 u_view;
uniform vec3 u_tint;
uniform float u_rough_scale;
uniform float u_emissive; // self-lit (a flame): albedo added back after shading
#ifdef BLADE
uniform vec3 u_blade_base;
uniform vec3 u_blade_tip;
#endif
#ifdef CARD
uniform float u_cull; // the layer's cull distance (m); 0 = none
#endif
mat3 cotangentFrame(vec3 N, vec3 p, vec2 uv) {
vec3 dp1 = dFdx(p), dp2 = dFdy(p);
vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
vec3 dp2perp = cross(dp2, N), dp1perp = cross(N, dp1);
vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
float invmax = inversesqrt(max(dot(T, T), dot(B, B)) + 1e-12);
return mat3(T * invmax, B * invmax, N);
}
void main() {
if (v_wpos.y < u_clip_y) discard;
vec3 N = normalize(v_nrm);
if (!gl_FrontFacing && v_hull >= 0.0) N = -N;
#ifdef CARD
// a baked card: albedo with coverage (premultiplied mips), normal in the card's own frame
vec4 ca = texture(u_diff, v_uv);
float rawA = ca.a;
ca.rgb /= max(ca.a, 1e-3);
#ifdef SHADOW_PASS
if (ca.a < 0.3) discard;
float cardAlpha = 1.0;
return;
#else
float cov = (ca.a - 0.4) / max(fwidth(ca.a), 1e-4) + 0.5;
if (cov < 0.02) discard;
float cardAlpha = clamp(cov, 0.0, 1.0);
#endif
#endif
float dist = length(v_wpos - u_cam_pos);
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
#if defined(CARD) && !defined(SHADOW_PASS)
// A clump card at 400 m is a two-pixel disc of pure leaf colour on whatever the ground
// is doing — on dark scree it glows. Dissolve toward the cull distance: the carpet
// drape on the ground carries the meadow's colour from there on.
if (u_cull > 1.0) cardAlpha *= 1.0 - smoothstep(u_cull * 0.55, u_cull, dist);
if (cardAlpha < 0.02) discard;
#endif
vec3 alb;
vec3 n;
vec3 arm;
float meshAlpha = 1.0;
#ifdef BLADE
// a procedural blade: dark at the root, lighter at the tip; some blades gone to seed
float t = clamp(v_uv.y, 0.0, 1.0);
alb = mix(u_blade_base, u_blade_tip, t * t) * regionTint(v_wpos, 0.35);
float dry = smoothstep(0.7, 0.8, fract(v_seed * 3.17));
alb = mix(alb, vec3(0.40, 0.36, 0.13) * (0.45 + 0.55 * t), dry * 0.75);
float patchy = fbm(v_wpos.xz * 0.045, 3) * 0.5 + 0.5;
alb *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
// a far tuft is a patch of the meadow, darker than a lit blade tip and never straw
if (v_hull < 0.0) alb = mix(u_blade_base, u_blade_tip, 0.45) * regionTint(v_wpos, 0.35) * mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy) * 0.72;
// a rounded cross-section reads softer than a flat card
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = (v_hull < 0.0) ? N : normalize(N + side * (v_uv.x * 2.0 - 1.0) * 0.6);
arm = vec3(mix(0.2, 1.0, t * t), 0.85, 0.0);
#elif defined(CARD)
// thin grass is lit from either side: face the card toward the sun before shading
if (dot(N, u_sun_dir) < 0.0) N = -N;
// the normal atlas is premultiplied by coverage like the albedo (see impostor.frag)
vec4 cn = texture(u_nrm, v_uv) / max(rawA, 1e-3);
cn = clamp(cn, 0.0, 1.0);
vec3 bn = cn.rgb * 2.0 - 1.0;
// the card frame: right along the quad, up, and out of the quad
vec3 T = normalize(cross(vec3(0.0, 1.0, 0.0), N) + vec3(1e-5));
n = normalize(T * -bn.x + vec3(0.0, 1.0, 0.0) * bn.y + N * max(abs(bn.z), 0.25));
n = normalize(mix(n, normalize(N + vec3(0.0, 0.8, 0.0)), 0.35));
// A clump is a few pixels at 100 m: what the eye reads there is the hillside's shading,
// and a card facing the sun on a slope facing away from it glows against the ground
// like a sticker. Light it with the ground's own normal as it recedes (as the blades
// already do), so cover and terrain darken together.
#ifdef CHEAP
n = terrainNormalAt(v_wpos);
#else
n = normalize(mix(n, terrainNormalAt(v_wpos), smoothstep(25.0, 90.0, dist)));
#endif
alb = ca.rgb * 1.05 * regionTint(v_wpos, 0.8);
arm = vec3(mix(0.5, 1.0, clamp(v_uv.y, 0.0, 1.0)) * (0.6 + 0.4 * cn.a), 0.85, 0.0);
#elif defined(FLOWER)
// a lupine: green stem (uv.x < 1), violet florets above (uv.x in [1,2]), tinted per plant
float hue = fract(v_seed * 5.71);
float vy = clamp(v_uv.y, 0.0, 1.0);
vec3 violet = mix(vec3(0.07, 0.03, 0.32), vec3(0.28, 0.06, 0.36), hue);
vec3 tipc = mix(violet, vec3(0.5, 0.3, 0.7), 0.3);
if (v_uv.x >= 2.0) {
alb = vec3(0.05, 0.13, 0.025) * (0.7 + 0.6 * vy);
} else if (v_uv.x >= 1.0) {
float f = fract(v_uv.x);
alb = mix(violet, tipc, vy) * (0.65 + 0.35 * abs(f * 2.0 - 1.0));
// florets as little lobes: darker between them, a paler lip on each
float lobe = 0.55 + 0.45 * abs(sin(vy * 9.0 + f * 6.0));
alb = mix(alb * lobe, vec3(0.55, 0.45, 0.75), 0.18 * smoothstep(0.6, 1.0, lobe));
} else {
alb = vec3(0.07, 0.16, 0.03);
}
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = normalize(N + side * (fract(v_uv.x) * 2.0 - 1.0) * 0.7);
arm = vec3(0.9, 0.7, 0.0);
#else
vec4 d = texture(u_diff, v_uv);
#ifdef ALPHA_TEST
// A needle sprig's alpha averages away in the mips, so a plain 0.5 test strips the
// crown bare past 50 m. Scale the alpha back up by the mip level (Castano's alpha
// mipmaps, done at sample time) and sharpen the edge with its screen derivative, then
// let alpha-to-coverage resolve it.
float lod = textureQueryLod(u_diff, v_uv).x;
float a = min(d.a * (1.0 + 0.45 * max(lod, 0.0)), 1.0);
float cov = (a - 0.4) / max(fwidth(a), 1e-4) + 0.5;
if (cov < 0.02) discard;
meshAlpha = clamp(cov, 0.0, 1.0);
#endif
vec3 tn = texture(u_nrm, v_uv).rgb * 2.0 - 1.0;
mat3 tbn = cotangentFrame(N, v_wpos, v_uv);
n = normalize(tbn * tn);
arm = texture(u_arm, v_uv).rgb;
n = normalize(mix(n, N, smoothstep(30.0, 120.0, dist)));
alb = d.rgb;
// a crown's interior is occluded by its own cards
if (u_model_h > 2.0) arm.r *= mix(0.5, 1.0, v_hull);
#endif
alb *= u_tint * (0.85 + 0.3 * fract(v_seed * 7.13));
#ifdef CARD
// a card is its own caster: look up the shadow a little above and in front of it, and let
// light bleed through the thin clump as real grass does
#ifdef CHEAP
float shadow = cloudShadow(v_wpos) * terrainShadow(v_wpos);
#else
float shadow = mix(1.0, sunShadow(v_wpos + N * 0.1 + vec3(0.0, 0.2, 0.0), N, viewDepth), 0.55);
#endif
#else
float shadow = sunShadow(v_wpos, N, viewDepth);
#endif
#ifdef FOLIAGE
// leaves and needles are matte at every angle: no grazing Fresnel on a two-sided card
float roughF = 1.0;
#else
float roughF = clamp(arm.g * u_rough_scale, 0.35, 1.0);
#endif
vec3 col = shade(v_wpos, n, alb, roughF, 0.0, arm.r, shadow, viewDepth);
#ifdef FOLIAGE
// thin-leaf translucency: light leaking through toward the viewer, and a wrapped diffuse
vec3 v = normalize(u_cam_pos - v_wpos);
float back = pow(max(dot(-v, u_sun_dir), 0.0), 3.0);
float wrap = max(dot(N, u_sun_dir) * 0.5 + 0.5, 0.0);
// Only a thin leaf a few metres away is translucent. A clump card at 200 m is a whole
// bush in two pixels, and giving it the leaf's glow toward the sun painted the
// backlit hillsides with lime discs. The term fades out with distance.
float thin = 1.0 - smoothstep(30.0, 140.0, dist);
// a dense crown of cards is not a thin leaf: much less light comes through it
if (u_model_h > 2.0) thin *= 0.3;
col += alb * u_sun_color * (0.14 * back + 0.05 * wrap) * thin * shadow * cloudShadow(v_wpos);
col += alb * skyIrradiance(vec3(0, 1, 0)) * 0.12 * arm.r;
#endif
col += alb * u_emissive;
if (any(isnan(col))) col = vec3(0.0);
col = applyFog(col, v_wpos, dist);
#ifdef CARD
o_color = vec4(sane(col), cardAlpha);
#else
o_color = vec4(sane(col), meshAlpha);
#endif
}

View file

@ -0,0 +1,102 @@
// instanced glTF model: attribute 3 = (x, y, z, scale), 4 = (sin yaw, cos yaw, seed, wind)
layout(location = 0) in vec3 a_pos;
layout(location = 1) in vec3 a_nrm;
layout(location = 2) in vec2 a_uv;
layout(location = 3) in vec4 i_pos;
layout(location = 4) in vec4 i_rot;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_light_vp;
uniform float u_time;
uniform float u_wind;
uniform float u_card_w;
uniform float u_card_h;
#ifdef BLADE
uniform vec3 u_cam_pos;
uniform float u_cull; // the blade ring's edge (m)
#endif
// Ground cover is placed on the CPU from a bilinear read of the 4 m height texels, but
// the terrain is drawn from a B-spline of the same texels — two different surfaces,
// up to half a metre apart on rough ground, which buried blades and floated cards.
// Cover layers (u_ground) read the surface the terrain actually draws, so they always
// stand on it, at any tessellation level, with no hand-tuned lift.
uniform float u_ground;
uniform sampler2D u_ts_height;
uniform vec2 u_ts_origin;
uniform float u_ts_half;
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;
}
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
// Crown hull: a tree's needle cards are lit as if they were the surface of a rounded
// crown (normal from the crown's centre), not each as a flat top-lit quad, and the
// cards near the trunk are darkened as the crown's interior. This is how game trees
// have been shaded since SpeedTree; without it a card crown reads as frosted.
uniform float u_model_h; // the model's height (m), 0 = not a crown
out float v_hull; // 0 at the crown's axis .. 1 at its rim
void main() {
float s = i_rot.x, c = i_rot.y;
vec3 p = a_pos * i_pos.w;
#ifdef CARD
p = vec3(p.x * u_card_w, p.y * u_card_h, p.z * u_card_w);
#endif
vec3 n = a_nrm;
#ifdef BLADE
// distant blades: wider so a thinner field keeps its coverage, lit like the ground
// they stand on, and sunk into the carpet texture at the ring's edge instead of popping
float bd = distance(u_cam_pos.xz, i_pos.xz);
p.x *= 1.0 + 2.5 * smoothstep(12.0, 90.0, bd);
p.y *= 1.0 - smoothstep(u_cull * 0.72, u_cull, bd);
n = normalize(mix(n, vec3(0.0, 1.0, 0.0), smoothstep(15.0, 70.0, bd)));
#endif
v_rot = vec2(s, c);
p = vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z);
n = vec3(c * n.x + s * n.z, n.y, -s * n.x + c * n.z);
#ifdef WIND
// sway grows with height above the base; gust phase from the instance seed
float hgt = max(p.y, 0.0);
float ph = u_time * 1.7 + i_rot.z * 6.2831 + i_pos.x * 0.05 + i_pos.z * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4) * u_wind * i_rot.w;
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);
#endif
v_hull = 1.0;
if (u_model_h > 2.0) {
vec3 cc = vec3(0.0, u_model_h * i_pos.w * 0.55, 0.0);
vec3 rel = p - cc;
float rr = length(rel.xz) / max(u_model_h * i_pos.w * 0.28, 0.1);
v_hull = clamp(rr, 0.0, 1.0);
vec3 hull = normalize(vec3(rel.x, rel.y * 0.5, rel.z) + vec3(0.0, 0.15, 0.0));
n = normalize(mix(n, hull, 0.7));
}
vec3 w = p + i_pos.xyz;
if (u_ground > 0.5) {
vec2 huv = (i_pos.xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
w.y = heightSmooth(u_ts_height, huv) - 0.03 + p.y;
}
v_wpos = w;
v_nrm = n;
v_uv = a_uv;
v_seed = i_rot.z;
#ifdef SHADOW_PASS
gl_Position = u_light_vp * vec4(w, 1.0);
#else
gl_Position = u_proj * u_view * vec4(w, 1.0);
#endif
}

View file

@ -0,0 +1,27 @@
// ---- shared noise (value / gradient / fbm / ridged) ----------------------------
float hash1(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec2 hash2(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return fract(sin(p) * 43758.5453123) * 2.0 - 1.0; }
float gnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(dot(hash2(i + vec2(0, 0)), f - vec2(0, 0)), dot(hash2(i + vec2(1, 0)), f - vec2(1, 0)), u.x),
mix(dot(hash2(i + vec2(0, 1)), f - vec2(0, 1)), dot(hash2(i + vec2(1, 1)), f - vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p, int oct) {
float a = 0.5, s = 0.0, n = 0.0;
mat2 r = mat2(0.8, 0.6, -0.6, 0.8) * 2.02;
for (int i = 0; i < oct; i++) { s += a * gnoise(p); n += a; a *= 0.5; p = r * p; }
return s / n;
}
float ridged(vec2 p, int oct) {
float a = 0.5, s = 0.0, w = 1.0;
mat2 r = mat2(0.8, 0.6, -0.6, 0.8) * 2.1;
for (int i = 0; i < oct; i++) { float n = 1.0 - abs(gnoise(p)); n = n * n * w; w = clamp(n * 1.5, 0.0, 1.0); s += a * n; a *= 0.5; p = r * p; }
return s;
}
// the dirt track: distance from a winding curve through the meadow
float pathDist(vec2 xz) {
float cx = 40.0 * sin(xz.y * 0.011) + 18.0 * sin(xz.y * 0.031 + 1.7) - 30.0;
float cx2 = -180.0 + 25.0 * sin(xz.y * 0.017 + 0.4) + (xz.y * 0.35);
return min(abs(xz.x - cx), abs(xz.x - cx2) + 1.0);
}

View file

@ -0,0 +1,14 @@
// 2D overlay: a texture times a colour; the font atlas is white glyphs on alpha
in vec2 v_uv;
in vec4 v_col;
uniform sampler2D u_tex;
uniform float u_is_font;
out vec4 o_color;
void main() {
vec4 t = texture(u_tex, v_uv);
if (u_is_font > 0.5) {
o_color = vec4(v_col.rgb, v_col.a * t.a);
} else {
o_color = vec4(v_col.rgb * t.rgb, v_col.a * t.a);
}
}

View file

@ -0,0 +1,13 @@
// 2D overlay (overlay.ludic): pixels with the origin top-left, straight to clip space
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec2 a_uv;
layout(location = 2) in vec4 a_col;
uniform vec2 u_screen;
out vec2 v_uv;
out vec4 v_col;
void main() {
vec2 p = a_pos / u_screen * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
v_uv = a_uv;
v_col = a_col;
}

View file

@ -0,0 +1,11 @@
#ifdef ALPHA_TEST
// foliage meshes are cut-out cards: their shadow must have the card's shape, not the quad's
in vec2 v_uv;
uniform sampler2D u_diff;
void main() {
float lod = textureQueryLod(u_diff, v_uv).x;
if (texture(u_diff, v_uv).a * (1.0 + 0.45 * max(lod, 0.0)) < 0.45) discard;
}
#else
void main() { }
#endif

View file

@ -0,0 +1,21 @@
// luma unsharp mask + film grain, on the final LDR image
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_src;
uniform vec2 u_texel;
uniform float u_amount;
uniform float u_grain;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
void main() {
vec3 c = texture(u_src, v_uv).rgb;
vec3 n = texture(u_src, v_uv + vec2(0, u_texel.y)).rgb, s = texture(u_src, v_uv - vec2(0, u_texel.y)).rgb;
vec3 e = texture(u_src, v_uv + vec2(u_texel.x, 0)).rgb, w = texture(u_src, v_uv - vec2(u_texel.x, 0)).rgb;
float lc = luma(c);
float lb = (luma(n) + luma(s) + luma(e) + luma(w) + lc * 4.0) / 8.0;
float d = clamp((lc - lb) * u_amount, -0.08, 0.08);
vec3 col = c * (1.0 + d / max(lc, 1e-3));
float g = (hash(v_uv * 1731.0 + fract(u_time)) - 0.5) * u_grain;
col += g * (0.6 + 0.4 * (1.0 - lc));
o_color = vec4(clamp(col, 0.0, 1.0), 1.0);
}

View file

@ -0,0 +1,43 @@
// a skinned glTF model (skin.ludic / actor.ludic): four joint influences per vertex
// blended on the GPU, then one model matrix. Writes the same varyings as model.vert so
// model.frag (lit) and shadow.frag (casters) shade it unchanged.
layout(location = 0) in vec3 a_pos;
layout(location = 1) in vec3 a_nrm;
layout(location = 2) in vec2 a_uv;
layout(location = 5) in vec4 a_joints; // integer indices, read as floats
layout(location = 6) in vec4 a_weights;
uniform mat4 u_model;
uniform mat4 u_bones[48];
uniform float u_skinned; // 0: a rigid model on the same path
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_light_vp;
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
out float v_hull;
void main() {
mat4 m = u_model;
if (u_skinned > 0.5) {
mat4 sk = a_weights.x * u_bones[int(a_joints.x + 0.5)]
+ a_weights.y * u_bones[int(a_joints.y + 0.5)]
+ a_weights.z * u_bones[int(a_joints.z + 0.5)]
+ a_weights.w * u_bones[int(a_joints.w + 0.5)];
m = u_model * sk;
}
vec4 w = m * vec4(a_pos, 1.0);
v_wpos = w.xyz;
v_nrm = normalize(mat3(m) * a_nrm);
v_uv = a_uv;
// model.frag scales the albedo by 0.85 + 0.3 * fract(seed * 7.13); this seed makes that 1
v_seed = 0.0701;
v_rot = vec2(0.0, 1.0);
v_hull = 1.0;
#ifdef SHADOW_PASS
gl_Position = u_light_vp * w;
#else
gl_Position = u_proj * u_view * w;
#endif
}

View file

@ -0,0 +1,40 @@
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_sky;
uniform mat4 u_inv_vp;
uniform float u_sky_gain;
uniform float u_sky_sat;
// a star: one hash per cell of the direction, a few of them bright, a slow twinkle
float starField(vec3 dir, float t) {
vec3 p = dir * 230.0;
vec3 c = floor(p);
vec3 f = p - c - 0.5;
float h = fract(sin(dot(c, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
float h2 = fract(h * 91.7);
float bright = smoothstep(0.972, 1.0, h);
float disc = smoothstep(0.42, 0.0, length(f));
float twinkle = 0.7 + 0.3 * sin(t * (1.5 + 3.0 * h2) + h2 * 40.0);
return bright * disc * twinkle * (0.5 + h2);
}
void main() {
vec4 a = u_inv_vp * vec4(v_uv * 2.0 - 1.0, 1.0, 1.0);
vec3 dir = normalize(a.xyz / a.w - u_cam_pos);
// level 0: the equirect seam (atan wraps) would otherwise pick the smallest mip along one column
vec3 col = min(textureLod(u_sky, skyUV(dir), 0.0).rgb, vec3(4096.0)) * u_sky_gain;
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = max(mix(vec3(l), col, u_sky_sat), vec3(0.0));
// the photograph's sky dims with the day (daylight.ludic); the night adds its own
col *= u_ibl_scale;
float night = 1.0 - smoothstep(0.0, 0.45, u_daylight);
if (night > 0.0) {
vec3 nightCol = mix(vec3(0.012, 0.016, 0.034), vec3(0.003, 0.004, 0.010), clamp(dir.y, 0.0, 1.0));
float stars = starField(dir, u_time) * smoothstep(-0.02, 0.15, dir.y);
nightCol += stars * vec3(0.55, 0.6, 0.7) * night;
col += nightCol * night;
}
// below the horizon the HDRI ground is replaced by the fog colour
float below = smoothstep(0.0, -0.08, dir.y);
vec3 fogCol = skyPrefiltered(vec3(dir.x, 0.02, dir.z), 0.6);
col = mix(col, fogCol, below);
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,47 @@
// screen-space ambient occlusion from the resolved depth (half resolution)
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_depth;
uniform mat4 u_inv_proj;
uniform mat4 u_proj;
uniform vec2 u_texel;
uniform float u_radius; // world metres
uniform float u_intensity;
vec3 viewPos(vec2 uv) {
float d = texture(u_depth, uv).r;
vec4 p = u_inv_proj * vec4(uv * 2.0 - 1.0, d * 2.0 - 1.0, 1.0);
return p.xyz / p.w;
}
void main() {
vec3 P = viewPos(v_uv);
if (-P.z > 900.0) { o_color = vec4(1.0); return; }
// normal from the depth's neighbourhood (take the smaller difference on each axis)
vec3 Pr = viewPos(v_uv + vec2(u_texel.x, 0.0)), Pl = viewPos(v_uv - vec2(u_texel.x, 0.0));
vec3 Pu = viewPos(v_uv + vec2(0.0, u_texel.y)), Pd = viewPos(v_uv - vec2(0.0, u_texel.y));
vec3 dx = (abs(Pr.z - P.z) < abs(P.z - Pl.z)) ? Pr - P : P - Pl;
vec3 dy = (abs(Pu.z - P.z) < abs(P.z - Pd.z)) ? Pu - P : P - Pd;
vec3 N = normalize(cross(dx, dy));
float noise = ign(gl_FragCoord.xy);
float ao = 0.0;
const int S = 12;
float radius = u_radius * (1.0 + 0.01 * -P.z);
for (int i = 0; i < S; i++) {
float a = (float(i) + noise) * 2.3999632; // golden angle spiral
float r = sqrt((float(i) + 0.5 + noise) / float(S));
vec3 dir = vec3(cos(a) * r, sin(a) * r, sqrt(max(0.0, 1.0 - r * r)));
// hemisphere around N
vec3 up = abs(N.z) < 0.999 ? vec3(0, 0, 1) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, N)), b = cross(N, t);
vec3 s = P + (t * dir.x + b * dir.y + N * dir.z) * radius * (0.2 + 0.8 * r);
vec4 c = u_proj * vec4(s, 1.0);
vec2 suv = c.xy / c.w * 0.5 + 0.5;
if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) continue;
float sz = viewPos(suv).z;
float rangeCheck = smoothstep(0.0, 1.0, radius / max(abs(P.z - sz), 1e-3));
ao += (sz >= s.z + 0.02 * radius ? 1.0 : 0.0) * rangeCheck;
}
ao = 1.0 - u_intensity * ao / float(S);
// contact occlusion is a near-field effect: fade it out with distance
ao = mix(clamp(ao, 0.0, 1.0), 1.0, smoothstep(120.0, 350.0, -P.z));
o_color = vec4(ao, -P.z, 0.0, 1.0);
}

View file

@ -0,0 +1,19 @@
// depth-aware 4x4 blur of the ao (a) and indirect bounce (rgb)
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_ao;
uniform sampler2D u_depth;
uniform vec2 u_texel;
void main() {
float cd = texture(u_depth, v_uv).r;
vec4 sum = vec4(0.0);
float wsum = 0.0;
for (int y = -2; y < 2; y++) for (int x = -2; x < 2; x++) {
vec2 o = vec2(float(x) + 0.5, float(y) + 0.5) * u_texel;
vec4 s = texture(u_ao, v_uv + o);
float sd = texture(u_depth, v_uv + o).r;
float w = exp(-abs(sd - cd) * 4000.0);
sum += s * w; wsum += w;
}
o_color = sum / max(wsum, 1e-4);
}

View file

@ -0,0 +1,66 @@
// screen-space ambient occlusion + one indirect diffuse bounce (SSGI) from the previous
// frame's lit colour; half resolution, denoised over time by the TAA history it feeds
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_depth;
uniform sampler2D u_prev_color; // last frame's anti-aliased HDR colour
uniform mat4 u_inv_proj;
uniform mat4 u_proj;
uniform vec2 u_texel;
uniform float u_radius;
uniform float u_intensity;
uniform float u_frame;
vec3 viewPos(vec2 uv) {
float d = texture(u_depth, uv).r;
vec4 p = u_inv_proj * vec4(uv * 2.0 - 1.0, d * 2.0 - 1.0, 1.0);
return p.xyz / p.w;
}
void main() {
vec3 P = viewPos(v_uv);
if (-P.z > 900.0) { o_color = vec4(0.0, 0.0, 0.0, 1.0); return; }
vec3 Pr = viewPos(v_uv + vec2(u_texel.x, 0.0)), Pl = viewPos(v_uv - vec2(u_texel.x, 0.0));
vec3 Pu = viewPos(v_uv + vec2(0.0, u_texel.y)), Pd = viewPos(v_uv - vec2(0.0, u_texel.y));
vec3 dx = (abs(Pr.z - P.z) < abs(P.z - Pl.z)) ? Pr - P : P - Pl;
vec3 dy = (abs(Pu.z - P.z) < abs(P.z - Pd.z)) ? Pu - P : P - Pd;
vec3 N = normalize(cross(dx, dy));
// A fixed per-pixel dither, not a per-frame one. Advancing the sequence every frame
// spreads the sampling error over time, which is only an improvement if something
// then averages the frames; with no temporal anti-aliasing left it is just noise that
// changes every frame, and it was the largest single source of the flicker on movement.
float noise = ign(gl_FragCoord.xy);
float ao = 0.0;
vec3 gi = vec3(0.0);
float giW = 0.0;
const int S = 8;
float radius = u_radius * (1.0 + 0.01 * -P.z);
vec3 up = abs(N.z) < 0.999 ? vec3(0, 0, 1) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, N)), b = cross(N, t);
for (int i = 0; i < S; i++) {
float a = (float(i) + noise) * 2.3999632;
float r = sqrt((float(i) + 0.5 + noise) / float(S));
vec3 dir = vec3(cos(a) * r, sin(a) * r, sqrt(max(0.0, 1.0 - r * r)));
vec3 wdir = t * dir.x + b * dir.y + N * dir.z;
vec3 s = P + wdir * radius * (0.2 + 0.8 * r);
vec4 c = u_proj * vec4(s, 1.0);
vec2 suv = c.xy / c.w * 0.5 + 0.5;
if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) continue;
vec3 sp = viewPos(suv);
float rangeCheck = smoothstep(0.0, 1.0, radius / max(abs(P.z - sp.z), 1e-3));
bool occluded = sp.z >= s.z + 0.02 * radius;
ao += (occluded ? 1.0 : 0.0) * rangeCheck;
// the occluder's lit colour bounces back toward P (weighted by how squarely it faces P)
if (occluded) {
vec3 toS = sp - P;
float d2 = max(dot(toS, toS), 1e-3);
float cosP = max(dot(N, toS) * inversesqrt(d2), 0.0);
vec3 col = sane(texture(u_prev_color, suv).rgb);
gi += col * cosP * rangeCheck;
giW += 1.0;
}
}
ao = 1.0 - u_intensity * ao / float(S);
float fade = smoothstep(120.0, 350.0, -P.z);
ao = mix(clamp(ao, 0.0, 1.0), 1.0, fade);
gi = (giW > 0.0 ? gi / float(S) : vec3(0.0)) * (1.0 - fade);
o_color = vec4(gi, ao);
}

View file

@ -0,0 +1,32 @@
// Second generation pass: copy the height into R and bake the B-spline surface normal into
// GBA, once, at texel resolution. terrain.frag used to differentiate the bicubic height
// per pixel — four bicubic reads, sixteen taps — for a quantity that never changes.
in vec2 v_uv;
out vec4 o;
uniform sampler2D u_src;
uniform float u_half;
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;
}
void main() {
float step = 1.0 / float(textureSize(u_src, 0).x);
float world = step * 2.0 * u_half;
float hl = heightSmooth(u_src, v_uv - vec2(step, 0));
float hr = heightSmooth(u_src, v_uv + vec2(step, 0));
float hd = heightSmooth(u_src, v_uv - vec2(0, step));
float hu = heightSmooth(u_src, v_uv + vec2(0, step));
vec3 n = normalize(vec3(hl - hr, 2.0 * world, hd - hu));
o = vec4(texture(u_src, v_uv).r, n);
}

View file

@ -0,0 +1,467 @@
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 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);
}
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(u_lake_level + 0.8, u_lake_level + 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(u_lake_level + 0.2, u_lake_level + 1.2, p.y);
snowC = smoothstep(0.08, 0.04, mxc - mnc) * smoothstep(0.55, 0.8, mxc);
}
// 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
float gullyGate = smoothstep(450.0, 650.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);
vec3 rA = vec3(0.3), 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
rA *= mix(vec3(0.14, 0.08, 0.06), vec3(0.24, 0.15, 0.11), 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.
float orthoW = 0.55;
// 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 - u_lake_level; // >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;
#else
// tersun.frag computed this for exactly this pixel; see the note there for why the
// cascade read cannot happen in here.
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;
}
uniform float u_far_split;
uniform float u_far_band;
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);
}

View file

@ -0,0 +1,50 @@
// CDLOD terrain (Strugar 2009): every draw is one 32x32 patch of the quadtree, placed
// and scaled by u_node. Toward the outer edge of its level's range each vertex morphs
// onto the parent level's grid (odd vertices slide to their even neighbours), so a patch
// meets its coarser neighbour edge-for-edge with no cracks and no popping. Height comes
// from one B-spline sample of the height field at the morphed position, so every level
// sits on the same continuous surface.
layout(location = 0) in vec2 a_xz; // 0..1 across the patch
uniform sampler2D u_height;
uniform float u_half;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform vec2 u_origin;
uniform vec3 u_cam_pos;
uniform vec3 u_node; // x0, z0, size (m)
uniform vec2 u_morph; // distance where the morph starts, and where it is complete
uniform float u_grid; // cells per patch side
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;
}
out vec3 v_wpos;
out vec2 v_huv;
void main() {
vec2 grid = a_xz * u_grid;
vec2 xz = u_node.xy + a_xz * u_node.z;
vec2 huv = (xz - u_origin) / (2.0 * u_half) + 0.5;
float h0 = texture(u_height, huv).r;
float d = distance(vec3(xz.x, h0, xz.y), u_cam_pos);
float k = clamp((d - u_morph.x) / max(u_morph.y - u_morph.x, 1.0), 0.0, 1.0);
vec2 frac2 = fract(grid * 0.5) * 2.0; // 1 on odd vertices
grid -= frac2 * k;
xz = u_node.xy + grid / u_grid * u_node.z;
huv = (xz - u_origin) / (2.0 * u_half) + 0.5;
float h = heightSmooth(u_height, huv);
vec3 p = vec3(xz.x, h, xz.y);
v_wpos = p;
v_huv = huv;
gl_Position = u_proj * u_view * vec4(p, 1.0);
}

View file

@ -0,0 +1,39 @@
// Height-field sun shadow, baked once per sun direction (the sun is fixed per scene).
//
// For every height-map texel: the lowest height at which a point above that texel still
// sees the sun. A point (xz, y) is lit iff the ray toward the sun clears the ground
// everywhere along it, i.e. y > h(xz + d.xz t) - d.y t for all t — so the value stored
// is the maximum of that expression over the ray. Every receiver in the scene (ground,
// trunk, crown, card, water) compares its own height against it: one march per texel,
// once, instead of 28 taps per terrain pixel per frame, and vegetation standing in a
// hillside's shadow goes dark with the hillside instead of glowing in front of it.
// The second channel is the distance to the occluder that set the bound, which widens
// the penumbra the way a real shadow softens with distance from its caster.
in vec2 v_uv;
out vec4 o;
uniform sampler2D u_height;
uniform float u_half;
uniform vec3 u_sun;
void main() {
vec2 xz = (v_uv - 0.5) * 2.0 * u_half;
vec3 d = u_sun;
float lit = -1.0e6;
float at = 0.0;
if (d.y > 0.02) {
float t = 1.5, step = 1.5;
for (int i = 0; i < 128; i++) {
vec2 q = xz + d.xz * t;
vec2 uv = q / (2.0 * u_half) + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break;
float h = texture(u_height, uv).r - d.y * t;
if (h > lit) { lit = h; at = t; }
t += step;
step *= 1.045;
}
}
// the cloud layer's mask, once, into B: sampled by cloudShadow() with the sun offset and
// the drift applied as a uv shift, instead of a five-octave fbm in every lit pixel of
// every pass
float cloud = smoothstep(0.02, 0.32, fbm(xz * 0.0011, 5));
o = vec4(lit, at, cloud, 1.0);
}

View file

@ -0,0 +1,31 @@
// tersun.frag — the terrain's sun visibility, on its own, one screen-sized R8 buffer.
//
// The ground's shading shader is large: it blends four scanned materials, a photograph
// and a dozen noise fields. Adding a read of the cascade shadow map to it costs about
// six milliseconds a frame on this driver — and costs the same whether the map is tapped
// once or eight times, filtered or texelFetched, compared in hardware or by hand. It is
// a cliff the big shader falls off, not work it performs. The same read from a small
// shader is nearly free, so the read happens here instead: this pass rasterises the same
// CDLOD patches, evaluates the cascades once per pixel, and writes the answer for
// terrain.frag to look up by fragment coordinate.
in vec3 v_wpos;
in vec2 v_huv;
out float o_sh;
uniform sampler2D u_height;
uniform mat4 u_view;
uniform float u_far_split;
uniform float u_far_band;
void main() {
vec3 p = v_wpos;
if (p.y < u_clip_y) discard;
vec3 N = normalize(texture(u_height, v_huv).gba);
float dist = length(p - u_cam_pos);
float viewDepth = -(u_view * vec4(p, 1.0)).z;
// The same tier choice the ground makes, cross-faded over the same band: the near tier
// keeps its filtered penumbra, the far tier its single tap, and the boundary between
// them is not a contour you can find on the hillside.
if (dist > u_far_split + u_far_band) o_sh = sunShadowCheap(p, N, viewDepth);
else if (dist < u_far_split - u_far_band) o_sh = sunShadow(p, N, viewDepth);
else o_sh = mix(sunShadow(p, N, viewDepth), sunShadowCheap(p, N, viewDepth),
smoothstep(u_far_split - u_far_band, u_far_split + u_far_band, dist));
}

View file

@ -0,0 +1,47 @@
// exposure -> ACES -> vignette -> sRGB, with dithering
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_hdr;
uniform sampler2D u_bloom;
uniform sampler2D u_ao;
uniform float u_ao_strength;
uniform float u_gi_strength;
uniform vec3 u_wb; // white balance multiplier
uniform vec3 u_lift;
uniform vec3 u_gain;
uniform float u_exposure;
uniform sampler2D u_adapt; // the GPU's adapted exposure (adapt.frag), 1x1
uniform float u_auto; // 1: use it, 0: u_exposure as set
uniform float u_bloom_strength;
uniform float u_vignette;
uniform float u_saturation;
uniform float u_contrast;
vec3 aces(vec3 x) {
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec3 hdr = sane(texture(u_hdr, v_uv).rgb);
vec4 gi = texture(u_ao, v_uv);
hdr *= mix(1.0, gi.a, u_ao_strength);
// the indirect bounce arrives in the surface's own hue (no albedo buffer in a forward renderer)
float l = dot(hdr, vec3(0.2126, 0.7152, 0.0722));
hdr += gi.rgb * (hdr / max(l, 1e-3)) * u_gi_strength;
vec3 bloom = texture(u_bloom, v_uv).rgb;
float exposure = mix(u_exposure, texture(u_adapt, vec2(0.5)).r, u_auto);
vec3 c = (hdr + bloom * u_bloom_strength) * exposure * u_wb;
// filmic contrast around mid grey in log space
c = max(c, vec3(0.0));
c = pow(c / 0.18, vec3(u_contrast)) * 0.18;
c = aces(c);
// lift / gain grade in display space
c = c * u_gain + u_lift * (1.0 - c);
float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = mix(vec3(lum), c, u_saturation);
vec2 q = v_uv * 2.0 - 1.0;
c *= 1.0 - u_vignette * dot(q, q) * 0.5;
c = pow(c, vec3(1.0 / 2.2));
c += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
o_color = vec4(c, 1.0);
}

View file

@ -0,0 +1,107 @@
// still water: sky reflection with fresnel, sun glitter, scrolling ripple normals, absorption colour
in vec3 v_wpos;
out vec4 o_color;
uniform mat4 u_view;
uniform sampler2D u_depth; // scene depth (resolved) for shore softness / depth tint
uniform mat4 u_inv_vp;
uniform vec2 u_screen;
uniform sampler2D u_refl; // the world mirrored in the surface (rendered by the reflection pass)
uniform float u_refl_on;
uniform sampler2D u_scene; // the scene as drawn before the water: the bed, to refract
// 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));
}
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));
}
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
// 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;
vec4 gp = u_inv_vp * vec4(suv * 2.0 - 1.0, sd * 2.0 - 1.0, 1.0);
vec3 ground = gp.xyz / gp.w;
float depthBelow = clamp(v_wpos.y - ground.y, 0.0, 10.0);
// How opaque the water is at the shoreline. This used to fade over the last 1.2 m of
// depth, which is the same band the foam lives in, so the surface went transparent
// exactly where it should have been breaking white: the foam was drawn and then
// alpha'd away, leaving a gap of dark wet ground and water that looked like it
// stopped short of the bank. Fade over a much shorter distance so the water reaches
// the edge, and let the foam carry its own opacity below.
vec3 r = reflect(-v, n);
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);
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);
}
// 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);
// 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
// octaves plus a fine breakup term give it structure at every range it is seen from.
float lap = 0.5 + 0.5 * sin(depthBelow * 9.0 - u_time * 1.6 + 2.0 * gnoise(v_wpos.xz * 0.8 + u_time * 0.2));
float fdet = fbm(v_wpos.xz * 7.0 - u_time * 0.35, 3) * 0.5 + 0.5; // fine bubbles
float fmid = fbm(v_wpos.xz * 2.6 + u_time * 0.5, 3) * 0.5 + 0.5;
float fedge = fbm(v_wpos.xz * 1.4 - u_time * 0.3, 2) * 0.5 + 0.5;
// a still alpine lake has a wet line, not surf: the wash is thin (the last 0.35 m of
// depth) and faint, and the terrain runs the same fields at the same strength
foam += smoothstep(0.35, 0.0, depthBelow) * (0.12 * smoothstep(0.30, 0.72, fmid) + 0.10 * smoothstep(0.55, 0.95, lap) * smoothstep(0.22, 0.6, fedge)) * (0.55 + 0.75 * fdet);
// the lap is a near-field detail: from a distance a lake's edge is a line, not a surf
foam *= smoothstep(120.0, 15.0, dist);
// the wash dies where the surface meets the ground, so it cannot end on a hard line
foam *= smoothstep(0.0, 0.5, length(ground - v_wpos));
float NoV = max(dot(n, v), 0.0);
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 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 -------------------------------------------------
// The bed is sampled from the scene as it was drawn before the water, nudged by the
// ripple normal (refraction), then attenuated per channel over the path the light
// actually travelled: down through the water and back up to the eye. Red goes first,
// then green, so shallows stay bright and readable and depth turns blue-green and
// dark on its own. This is what makes it a body of water rather than a tinted sheet:
// the ground is seen through it, not behind it.
vec2 ruv2 = clamp(suv + n.xz * 0.03 * smoothstep(0.0, 2.0, depthBelow), 0.001, 0.999);
// never refract something that is actually in front of the surface (the near bank),
// or the grass on the shore smears out over the water
float rd = texture(u_depth, ruv2).r;
vec4 rgp = u_inv_vp * vec4(ruv2 * 2.0 - 1.0, rd * 2.0 - 1.0, 1.0);
vec3 rground = rgp.xyz / rgp.w;
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
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;
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
// zero over a fraction of a pixel when the surface is seen edge-on, which is exactly
// the low, near-the-waterline view where the plane's silhouette turns into a hard
// glassy line. The distance from the surface to the bed along the ray stays a smooth
// quantity at any angle, so the water dissolves into the ground it meets instead.
float alongRay = length(ground - v_wpos);
float soft = smoothstep(0.0, 0.5, alongRay);
col = mix(bed, col, soft);
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,12 @@
layout(location = 0) in vec2 a_xz;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform float u_level;
uniform vec2 u_center;
uniform vec2 u_extent;
out vec3 v_wpos;
void main() {
vec3 p = vec3(u_center.x + a_xz.x * 2.0 * u_extent.x, u_level, u_center.y + a_xz.y * 2.0 * u_extent.y); // the grid spans ±0.5
v_wpos = p;
gl_Position = u_proj * u_view * vec4(p, 1.0);
}