feat(gl): OpenGL 4.1 and the ludic.render3d renderer
`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:
parent
470971bf70
commit
f25289db20
90 changed files with 35316 additions and 19853 deletions
23
packages/ludic.render3d/shaders/adapt.frag
Normal file
23
packages/ludic.render3d/shaders/adapt.frag
Normal 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);
|
||||
}
|
||||
29
packages/ludic.render3d/shaders/bake.frag
Normal file
29
packages/ludic.render3d/shaders/bake.frag
Normal 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);
|
||||
}
|
||||
27
packages/ludic.render3d/shaders/bloom_down.frag
Normal file
27
packages/ludic.render3d/shaders/bloom_down.frag
Normal 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);
|
||||
}
|
||||
13
packages/ludic.render3d/shaders/bloom_up.frag
Normal file
13
packages/ludic.render3d/shaders/bloom_up.frag
Normal 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);
|
||||
}
|
||||
7
packages/ludic.render3d/shaders/fullscreen.vert
Normal file
7
packages/ludic.render3d/shaders/fullscreen.vert
Normal 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);
|
||||
}
|
||||
149
packages/ludic.render3d/shaders/grass.vert
Normal file
149
packages/ludic.render3d/shaders/grass.vert
Normal 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);
|
||||
}
|
||||
110
packages/ludic.render3d/shaders/heightgen.frag
Normal file
110
packages/ludic.render3d/shaders/heightgen.frag
Normal 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
|
||||
}
|
||||
36
packages/ludic.render3d/shaders/ibl_brdf.frag
Normal file
36
packages/ludic.render3d/shaders/ibl_brdf.frag
Normal 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);
|
||||
}
|
||||
36
packages/ludic.render3d/shaders/ibl_irradiance.frag
Normal file
36
packages/ludic.render3d/shaders/ibl_irradiance.frag
Normal 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);
|
||||
}
|
||||
53
packages/ludic.render3d/shaders/ibl_prefilter.frag
Normal file
53
packages/ludic.render3d/shaders/ibl_prefilter.frag
Normal 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);
|
||||
}
|
||||
66
packages/ludic.render3d/shaders/impostor.frag
Normal file
66
packages/ludic.render3d/shaders/impostor.frag
Normal 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
|
||||
}
|
||||
42
packages/ludic.render3d/shaders/impostor.vert
Normal file
42
packages/ludic.render3d/shaders/impostor.vert
Normal 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
|
||||
}
|
||||
272
packages/ludic.render3d/shaders/lighting.glsl
Normal file
272
packages/ludic.render3d/shaders/lighting.glsl
Normal 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));
|
||||
}
|
||||
185
packages/ludic.render3d/shaders/model.frag
Normal file
185
packages/ludic.render3d/shaders/model.frag
Normal 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
|
||||
}
|
||||
102
packages/ludic.render3d/shaders/model.vert
Normal file
102
packages/ludic.render3d/shaders/model.vert
Normal 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
|
||||
}
|
||||
27
packages/ludic.render3d/shaders/noise.glsl
Normal file
27
packages/ludic.render3d/shaders/noise.glsl
Normal 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);
|
||||
}
|
||||
14
packages/ludic.render3d/shaders/overlay.frag
Normal file
14
packages/ludic.render3d/shaders/overlay.frag
Normal 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);
|
||||
}
|
||||
}
|
||||
13
packages/ludic.render3d/shaders/overlay.vert
Normal file
13
packages/ludic.render3d/shaders/overlay.vert
Normal 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;
|
||||
}
|
||||
11
packages/ludic.render3d/shaders/shadow.frag
Normal file
11
packages/ludic.render3d/shaders/shadow.frag
Normal 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
|
||||
21
packages/ludic.render3d/shaders/sharpen.frag
Normal file
21
packages/ludic.render3d/shaders/sharpen.frag
Normal 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);
|
||||
}
|
||||
43
packages/ludic.render3d/shaders/skin.vert
Normal file
43
packages/ludic.render3d/shaders/skin.vert
Normal 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
|
||||
}
|
||||
40
packages/ludic.render3d/shaders/sky.frag
Normal file
40
packages/ludic.render3d/shaders/sky.frag
Normal 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);
|
||||
}
|
||||
47
packages/ludic.render3d/shaders/ssao.frag
Normal file
47
packages/ludic.render3d/shaders/ssao.frag
Normal 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);
|
||||
}
|
||||
19
packages/ludic.render3d/shaders/ssao_blur.frag
Normal file
19
packages/ludic.render3d/shaders/ssao_blur.frag
Normal 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);
|
||||
}
|
||||
66
packages/ludic.render3d/shaders/ssgi.frag
Normal file
66
packages/ludic.render3d/shaders/ssgi.frag
Normal 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);
|
||||
}
|
||||
32
packages/ludic.render3d/shaders/ternormal.frag
Normal file
32
packages/ludic.render3d/shaders/ternormal.frag
Normal 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);
|
||||
}
|
||||
467
packages/ludic.render3d/shaders/terrain.frag
Normal file
467
packages/ludic.render3d/shaders/terrain.frag
Normal 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);
|
||||
}
|
||||
50
packages/ludic.render3d/shaders/terrain.vert
Normal file
50
packages/ludic.render3d/shaders/terrain.vert
Normal 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);
|
||||
}
|
||||
39
packages/ludic.render3d/shaders/tershadow.frag
Normal file
39
packages/ludic.render3d/shaders/tershadow.frag
Normal 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);
|
||||
}
|
||||
31
packages/ludic.render3d/shaders/tersun.frag
Normal file
31
packages/ludic.render3d/shaders/tersun.frag
Normal 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));
|
||||
}
|
||||
47
packages/ludic.render3d/shaders/tonemap.frag
Normal file
47
packages/ludic.render3d/shaders/tonemap.frag
Normal 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);
|
||||
}
|
||||
107
packages/ludic.render3d/shaders/water.frag
Normal file
107
packages/ludic.render3d/shaders/water.frag
Normal 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);
|
||||
}
|
||||
12
packages/ludic.render3d/shaders/water.vert
Normal file
12
packages/ludic.render3d/shaders/water.vert
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue