From 384cd17dac344bc195a2d92ab7a881d575c51df2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 14:00:44 +0000 Subject: [PATCH] scene: terrain fine grain domain-warped so its plane waves stop reading as hatching The fine albedo octave is three plane waves per projection (about 5 m periods): a regular lattice, resolved (not aliased) at near-ground pixel size, so it reads as diagonal hatching. Switching the octave off in a SwiftShader render removes it; coarse grain off does not. The octave's coordinates are now pushed by a slower sine each, in terrainAlbedo.wavesWarped and the shader's warpq. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 2 ++ .../features/scene/terrain/terrainAlbedo.ts | 19 +++++++++++++++---- .../features/scene/terrain/terrainShaders.ts | 5 ++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 17363282..6afac79d 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -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); }); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index fef204ff..48dc2b42 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -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, 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, 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) => - 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, + 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, diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index b79e2873..f6bcce37 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -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); -- 2.47.3