scene: terrain fine grain domain-warped so it stops reading as hatching #66

Merged
void merged 1 commit from nibu/terrain-hatching into dev 2026-11-10 14:09:23 +00:00
3 changed files with 21 additions and 5 deletions

View file

@ -114,6 +114,8 @@ describe('shader agrees with the pure function on its numbers', () => {
expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`);
expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`);
expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`);
expect(GRID_FRAG).toContain(`#define ALB_FINE_WARP ${ALBEDO.fineWarp.toFixed(1)}`);
expect(GRID_FRAG).toContain('waves3(warpq(qy),');
// the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's
expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1);
});

View file

@ -31,6 +31,8 @@ export const ALBEDO = {
/** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */
grainCoarse: 0.35,
grainFine: 0.3,
/** Metres the fine octave's coordinates are pushed by a slower sine, so its parallel stripes bend and do not read as hatching. */
fineWarp: 7,
/** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */
fineNear: 250,
fineFar: 700,
@ -132,6 +134,13 @@ const FINE = [
] as const;
const waves = (k: ReadonlyArray<readonly [number, number]>, a: number, b: number) =>
k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3;
/** `waves` of coordinates warped by a sine each (the shader's `warpq`): three plane waves alone make a regular lattice. */
const wavesWarped = (k: ReadonlyArray<readonly [number, number]>, a: number, b: number) =>
waves(
k,
a + ALBEDO.fineWarp * Math.sin(b * 0.55 + 1.3),
b + ALBEDO.fineWarp * Math.sin(a * 0.47 + 0.7)
);
/**
* The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit
@ -148,10 +157,12 @@ export function grainAt(
distance: number
): number {
const w = triWeights(n);
const over = (k: ReadonlyArray<readonly [number, number]>) =>
w[0] * waves(k, z, y) + w[1] * waves(k, x, z) + w[2] * waves(k, x, y);
const coarse = over(COARSE);
const fine = over(FINE) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance));
const over = (
k: ReadonlyArray<readonly [number, number]>,
f: typeof waves
) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y);
const coarse = over(COARSE, waves);
const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance));
const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3);
return Math.max(
0,

View file

@ -133,6 +133,7 @@ const ALBEDO_DEFINES = `
#define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)}
#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)}
#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)}
#define ALB_FINE_WARP ${lit(ALBEDO.fineWarp)}
#define ALB_STRATA ${lit(ALBEDO.strata)}
#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)}
#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)}
@ -175,6 +176,8 @@ ${FETCH}
// Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights).
float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); }
float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; }
// The fine octave's coordinates pushed by a slower sine each (terrainAlbedo wavesWarped): three plane waves alone make a regular lattice.
vec2 warpq(vec2 q) { return q + ALB_FINE_WARP * vec2(sin(q.y * 0.55 + 1.3), sin(q.x * 0.47 + 0.7)); }
void main() {
// Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders.
vec2 p = v_uv * float(uTex - 1);
@ -195,7 +198,7 @@ void main() {
vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW);
// Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt().
float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12));
float fine = tw.x * waves3(qx, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(qy, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(qz, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6));
float fine = tw.x * waves3(warpq(qx), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(warpq(qy), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(warpq(qz), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6));
float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera));
float layers = sin(v_world.y * 0.9 + w * 3.0);
albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers);