From b8f2bb462e9e2bc0f5df2894866ee13f51ec7e9c Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:22:19 +0000 Subject: [PATCH 01/24] auth: login card fill 50% with 18px blur and a faint edge The blur flattens the sky and ridges behind the text, so the fill can be half as opaque and let the scene show through. Text over the unblurred horizon glow is 4.15:1 (4.5 needs 54%); measured over the real blurred backdrop at 390x844 it is >= 4.94:1. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index a9733ef0..b45539a6 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,7 +56,9 @@ export const AuthFooter = style({ // signed-out ground is clear and the card is a translucent, blurred pane shaped like a message // bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The // --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. 85% fill keeps text >= 4.5:1 over the scene's brightest and darkest backdrops. +// until it lands. The fill is 50%: the 18px blur carries the readability, flattening the sky and +// ridges behind the text. Without the blur, text is 4.15:1 over the brightest backdrop (the horizon +// glow; 4.5:1 needs 54%) and >= 7.3:1 elsewhere in the scene. // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -65,13 +67,14 @@ globalStyle(`${scene} ${AuthLayout}`, { }); globalStyle(`${scene} ${AuthCard}`, { backgroundColor: [ - 'rgba(13, 26, 38, 0.85)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 85%, transparent)', + 'rgba(13, 26, 38, 0.5)', + 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 50%, transparent)', ], color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, - borderColor: 'transparent', - backdropFilter: 'blur(12px)', + // A faint inner edge, so a pane this clear still reads as a surface. + borderColor: 'rgba(183, 199, 217, 0.14)', + backdropFilter: 'blur(18px)', }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', -- 2.47.3 From 833c477785c90eed5d414579edd41f773d49a70b Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:30:20 +0000 Subject: [PATCH 02/24] scene: baked starfield with a rolling twinkle window for dark skies Every star is seeded, placed toward the zenith, faded by the local sky luma, and baked once into the sky canvas. A night sky is one whose zenith luma is below 110; the ridge scene has no theme flag, so the dusk default qualifies faintly and a day sky gets no stars and no overlay. 32 slots twinkle at a time on a small overlay canvas under the ridges: each slot runs back-to-back one-cycle twinkles (own 2-7 s period and offset) of one star from its own partition, shuffled per epoch, never repeating across the seam. Gain is a pure function of (slot, t), 0.35x to 1.15x, exactly 1 at start and end. Dimming paints the sky's own colour over the baked star; brightening adds a star. The pass is clear + at most 32 arcs at 20 fps, stopped under reduced motion (live), while hidden, in tier 'still', and after the slow-frame fallback. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/Ridge.test.ts | 170 ++++++++++++-- src/app/features/scene/ridge/Ridge.ts | 66 +++++- src/app/features/scene/ridge/stars.test.ts | 185 +++++++++++++++ src/app/features/scene/ridge/stars.ts | 249 +++++++++++++++++++++ 4 files changed, 655 insertions(+), 15 deletions(-) create mode 100644 src/app/features/scene/ridge/stars.test.ts create mode 100644 src/app/features/scene/ridge/stars.ts diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index bb35209e..3aecad12 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -15,7 +15,8 @@ import { scatterAlpha, transmittance, } from './ridgeWorld'; -import { skyColours } from './skyColour'; +import { skyColours, SkyColours } from './skyColour'; +import { makeStars } from './stars'; import { startBackgroundScene } from '../index'; describe('noise', () => { @@ -245,6 +246,15 @@ function fakeEl(tag = 'div') { }; return el; } +// A day sky has no stars, so the scene tests below see only the ridge loop; the dusk default +// carries stars and a twinkle overlay, tested in 'the starfield in the scene'. +const DAY: SkyColours = { + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], + ground: [7, 17, 26], + sunAzimuth: 0.3, +}; const draws: string[] = []; function ctx(withFilter = false) { const c: any = { @@ -257,7 +267,19 @@ function ctx(withFilter = false) { moveTo() {}, lineTo() {}, closePath() {}, - fill: () => draws.push('fill'), + // Stars: arc+fill is one draw. A context that has cleared a rect is the twinkle overlay's. + clearRect() { + c.overlay = true; + draws.push('clear'); + }, + arc() { + draws.push(c.overlay ? 'twinkle' : 'star'); + c.arcing = true; + }, + fill: () => { + if (c.arcing) c.arcing = false; + else draws.push('fill'); + }, drawImage: () => draws.push('flatten'), set fillStyle(_v: unknown) {}, }; @@ -312,7 +334,8 @@ const runFrames = (dt: number, n: number, from = 0) => { let t = from; for (let i = 0; i < n && rafQueue.length; i += 1) { t += dt; - rafQueue.shift()!(t); + // One display frame: every callback queued for it sees the same timestamp. + rafQueue.splice(0).forEach((cb) => cb(t)); } return t; }; @@ -327,12 +350,12 @@ describe('createRidgeScene', () => { it('returns null when 2D context is missing, so the host keeps its CSS ground', () => { const { canvas } = host(); canvas.getContext = vi.fn(() => null); - expect(createRidgeScene({ canvas, tier: 'full' })).toBeNull(); + expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull(); }); it('bakes the sky once and paints four layers at integer sizes, farthest first', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(s).not.toBeNull(); const layers = parent.children.slice(1); expect(layers).toHaveLength(LAYERS.length); @@ -348,7 +371,7 @@ describe('createRidgeScene', () => { it('after the layers are painted, a smooth run draws nothing more and ends its rAF loop', () => { const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const before = draws.length; runFrames(16.7, 400); expect(draws.length).toBe(before); @@ -359,7 +382,7 @@ describe('createRidgeScene', () => { it('p95 over 33 ms across 2 s flattens to a still image and removes the layers', () => { const { parent, canvas } = host(); const onProbe = vi.fn(); - const s = createRidgeScene({ canvas, tier: 'full', onProbe })!; + const s = createRidgeScene({ canvas, tier: 'full', onProbe, sky: DAY })!; runFrames(100, 100); expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); expect(parent.children).toEqual([canvas]); @@ -379,7 +402,7 @@ describe('createRidgeScene', () => { it('prefers-reduced-motion: layers are built but never played, and nothing is sampled', () => { motion.matches = true; const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims)); expect(anims.length).toBe(LAYERS.length); anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); @@ -389,7 +412,7 @@ describe('createRidgeScene', () => { it('pauses the drift when the tab is hidden and resumes when it is visible', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = parent.children.slice(1).flatMap((l: any) => l.anims); anims.forEach((a: any) => a.pause.mockClear()); doc.visibilityState = 'hidden'; @@ -405,7 +428,7 @@ describe('createRidgeScene', () => { it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { search = '?probe=1'; const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const readout = parent.children[parent.children.length - 1]; runFrames(16.7, 300); expect(rafQueue.length).toBe(1); // still sampling @@ -416,7 +439,7 @@ describe('createRidgeScene', () => { it('dispose cancels the loop, the animations and the layers', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const anims = parent.children.slice(1).flatMap((l: any) => l.anims); s.dispose(); anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled()); @@ -456,7 +479,7 @@ describe('fbm', () => { describe('aerial perspective in the scene', () => { it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => { const { canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(draws.filter((d) => d === 'scatter')).toHaveLength(LAYERS.length - 1); const before = draws.length; runFrames(16.7, 400); @@ -469,7 +492,7 @@ describe('aerial perspective in the scene', () => { const s = createRidgeScene({ canvas, tier: 'full', - sky: { ...skyColours(), sunAzimuth: undefined }, + sky: { ...DAY, sunAzimuth: undefined }, })!; expect(draws.filter((d) => d === 'scatter')).toHaveLength(0); s.dispose(); @@ -495,7 +518,7 @@ describe('aerial perspective in the scene', () => { }); return el; }; - const s = createRidgeScene({ canvas, tier: 'full' })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; const blurs = filters.filter((f) => f !== 'none'); expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2 @@ -511,3 +534,122 @@ describe('aerial perspective in the scene', () => { expect(draws.filter((d) => d === 'flatten')).toHaveLength(LAYERS.length); }); }); + +describe('the starfield in the scene', () => { + const count = (kind: string) => draws.filter((d) => d === kind).length; + const night = { + ...DAY, + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], + } as SkyColours; + const N = makeStars(night, 400, 800).length; + const hear = (name: string) => { + const calls = motion.addEventListener.mock.calls.filter((c: any[]) => c[0] === name); + return calls[calls.length - 1][1] as () => void; + }; + // Everything but the overlay's own marks: the bake and the ridges. + const baked = () => draws.filter((d) => d !== 'twinkle' && d !== 'clear').length; + + it('bakes every star once into the sky; per frame only the overlay is touched', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(count('star')).toBe(N); + expect(count('sky')).toBe(1); + expect(parent.children.indexOf(parent.children[1])).toBe(1); + expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges + expect(parent.children).toHaveLength(2 + LAYERS.length); + const before = baked(); + runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps + expect(baked()).toBe(before); + expect(count('clear')).toBeGreaterThan(60); + expect(count('clear')).toBeLessThanOrEqual(101); + // each pass paints at most SLOTS stars + expect(count('twinkle')).toBeLessThanOrEqual(count('clear') * 32); + expect(count('twinkle')).toBeGreaterThan(0); + s.dispose(); + }); + + it('a day sky has no stars and no overlay layer', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(count('star')).toBe(0); + expect(parent.children).toHaveLength(1 + LAYERS.length); + runFrames(16.7, 60); + expect(count('clear')).toBe(0); + s.dispose(); + }); + + it("tier 'still' bakes the stars and has no overlay or loop", () => { + const { parent, canvas } = host(); + createRidgeScene({ canvas, tier: 'still', sky: night }); + expect(count('star')).toBe(N); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); + + it('stops twinkling under reduced motion, live, and resumes when it is lifted', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + expect(count('twinkle')).toBeGreaterThan(0); + motion.matches = true; + hear('change')(); + t = runFrames(16.7, 5, t); + const stopped = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(stopped); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + hear('change')(); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(stopped); + s.dispose(); + }); + + it('starts steady, with nothing painted over the stars, when reduced motion is already on', () => { + motion.matches = true; + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + expect(rafQueue).toHaveLength(0); + runFrames(16.7, 60); + expect(count('twinkle')).toBe(0); + s.dispose(); + }); + + it('stops while the tab is hidden and resumes when it is visible', () => { + const { canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + let t = runFrames(16.7, 30); + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + const hidden = draws.length; + t = runFrames(16.7, 200, t); + expect(draws.length).toBe(hidden); + expect(rafQueue).toHaveLength(0); + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + runFrames(16.7, 30, t); + expect(draws.length).toBeGreaterThan(hidden); + s.dispose(); + }); + + it('flattening to a still (slow frames) drops the overlay and leaves every star baked under the ridges', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + runFrames(100, 100); + expect(parent.children).toEqual([canvas]); + expect(count('star')).toBe(N); + expect(draws.indexOf('flatten')).toBeGreaterThan(draws.lastIndexOf('star')); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('dispose removes the overlay and stops the loop', () => { + const { parent, canvas } = host(); + const s = createRidgeScene({ canvas, tier: 'full', sky: night })!; + s.dispose(); + expect(parent.children).toEqual([canvas]); + expect(rafQueue).toHaveLength(0); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 218d7c65..eae0df08 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -1,6 +1,7 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; +import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; import { LAYERS, LayerSpec, @@ -130,8 +131,13 @@ function paintLayer( interface Built { layers: HTMLCanvasElement[]; anims: Animation[]; + /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ + twinkle?: HTMLCanvasElement; } +/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */ +const TWINKLE_FRAME_MS = 50; + /** * The ridge scene: same contract as `createScene` ({ login, dispose }, null for "keep the CSS * ground"), no WebGL. The layers are painted once on the CPU; per frame the only work is the @@ -149,6 +155,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); let built: Built | undefined; + let field: Star[] = []; + let twRaf = 0; + let twLast = 0; let still = tier === 'still'; let disposed = false; let raf = 0; @@ -165,6 +174,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const teardownLayers = () => { built?.anims.forEach((a) => a.cancel()); built?.layers.forEach((l) => l.remove()); + built?.twinkle?.remove(); + cancelAnimationFrame(twRaf); + twRaf = 0; built = undefined; }; @@ -177,10 +189,33 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { canvas.height = Math.round(h * scale); if (!paintSky(canvas, sky)) return false; const layers: HTMLCanvasElement[] = []; + // Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame + // pass clears and paints over. 'still' has no overlay. + const stars = makeStars(sky, w, h); + const skyCtx = context2d(canvas); + if (skyCtx) drawStars(skyCtx, stars, canvas.width, canvas.height, scale); + let twinkle: HTMLCanvasElement | undefined; + if (stars.length && tier !== 'still') { + twinkle = document.createElement('canvas'); + twinkle.width = canvas.width; + twinkle.height = canvas.height; + twinkle.setAttribute('aria-hidden', 'true'); + Object.assign(twinkle.style, { + position: 'absolute', + left: '0', + top: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + }); + parent.appendChild(twinkle); + } + field = stars; for (const spec of LAYERS) { const el = paintLayer(spec, sky, size, canvas.height, scale); if (!el) { layers.forEach((l) => l.remove()); + twinkle?.remove(); return false; } el.setAttribute('aria-hidden', 'true'); @@ -208,7 +243,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { ) ) .filter((a): a is Animation => !!a); - built = { layers, anims }; + built = { layers, anims, twinkle }; syncPlayState(); return true; }; @@ -219,8 +254,37 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { !(reducedMotion?.matches ?? false) && document.visibilityState === 'visible'; + /** Paint the overlay at t seconds (stars away from base), or just clear it with no t (all at base). */ + const drawTwinkle = (t?: number) => { + const el = built?.twinkle; + const ctx = el && context2d(el); + if (!el || !ctx) return; + const active = t === undefined ? [] : activeAt(field.length, t); + paintTwinkle(ctx, field, active, el.width, el.height, viewSize().scale); + }; + + // The same guard as the drift: not still, not disposed, not reduced motion, tab visible. Not moving + // stops the loop and leaves the stars steady at base brightness. + const twTick = (now: number) => { + twRaf = 0; + if (!moving() || !built?.twinkle) return; + if (now - twLast >= TWINKLE_FRAME_MS) { + twLast = now; + drawTwinkle(now / 1000); + } + twRaf = requestAnimationFrame(twTick); + }; + function syncPlayState() { built?.anims.forEach((a) => (moving() ? a.play() : a.pause())); + if (!built?.twinkle) return; + if (moving()) { + if (!twRaf) twRaf = requestAnimationFrame(twTick); + } else { + cancelAnimationFrame(twRaf); + twRaf = 0; + drawTwinkle(); + } } /** Flatten the sky and layers into the host canvas and drop the layers: a still image, no compositor layers left. */ diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts new file mode 100644 index 00000000..38b4e7e8 --- /dev/null +++ b/src/app/features/scene/ridge/stars.test.ts @@ -0,0 +1,185 @@ +/* eslint-disable no-restricted-syntax, no-continue -- test loops over sample times */ +import { describe, expect, it } from 'vitest'; +import { skyColours } from './skyColour'; +import { + MAX_STARS, + NIGHT_LUMA, + SLOTS, + TWINKLE_GAIN, + TWINKLE_PERIOD, + activeAt, + makeStars, + skyLuma, + slotCount, + slotOffset, + slotPeriod, + starCount, + twinkleGain, +} from './stars'; + +const DAY = { + ...skyColours(), + top: [96, 150, 220], + mid: [150, 190, 235], + horizon: [240, 215, 190], +} as const; +const NIGHT = { + ...skyColours(), + top: [10, 14, 40], + mid: [30, 34, 80], + horizon: [60, 50, 90], +} as const; +const N = makeStars(NIGHT, 390, 844).length; + +function hash2(a: number, b: number) { + return ((a * 7919 + b * 104729) % 1000) / 1000; +} + +describe('star placement', () => { + it('is deterministic: the same sky and size give the identical field', () => { + expect(makeStars(NIGHT, 390, 844)).toEqual(makeStars(NIGHT, 390, 844)); + expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); + }); + + it('draws none in a day sky, and the dusk default is dark enough for some', () => { + expect(makeStars(DAY, 390, 844)).toHaveLength(0); + expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50); + }); + + it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { + for (const [w, h] of [ + [390, 844], + [1920, 1080], + [100, 100], + ] as const) { + expect(makeStars(NIGHT, w, h).length).toBeLessThanOrEqual(starCount(w, h)); + expect(starCount(w, h)).toBeLessThanOrEqual(MAX_STARS); + } + expect(N).toBeGreaterThan(150); + }); + + it('thins toward the horizon: more than twice the stars in the top third than the bottom third of the sky', () => { + const all = makeStars(NIGHT, 390, 844); + expect(all.filter((s) => s.y < 0.26).length).toBeGreaterThan( + 2 * all.filter((s) => s.y > 0.52).length + ); + }); + + it('fades with the sky: every star sits where the sky is dark, alpha within bounds', () => { + for (const s of makeStars(NIGHT, 390, 844)) { + expect(skyLuma(NIGHT, s.y)).toBeLessThan(NIGHT_LUMA); + expect(s.alpha).toBeLessThanOrEqual(0.9); + expect(s.alpha).toBeGreaterThanOrEqual(0.05); + } + }); + + it('varies in size and brightness', () => { + const all = makeStars(NIGHT, 390, 844); + expect(new Set(all.map((s) => s.r.toFixed(2))).size).toBeGreaterThan(50); + expect( + Math.max(...all.map((s) => s.alpha)) - Math.min(...all.map((s) => s.alpha)) + ).toBeGreaterThan(0.3); + }); +}); + +describe('twinkle schedule', () => { + const times = Array.from({ length: 400 }, (_, i) => i * 0.37); + + it('is a pure function of (seed, time): same t, same set and values', () => { + for (const t of [0, 0.4, 13.7, 3599.9]) expect(activeAt(N, t)).toEqual(activeAt(N, t)); + }); + + it('the set is SLOTS stars at every sampled time (inside 24..48), never more than the field has', () => { + expect(SLOTS).toBeGreaterThanOrEqual(24); + expect(SLOTS).toBeLessThanOrEqual(48); + for (const t of times) expect(activeAt(N, t)).toHaveLength(SLOTS); + expect(activeAt(10, 5)).toHaveLength(10); + expect(slotCount(10)).toBe(10); + expect(activeAt(0, 5)).toHaveLength(0); + }); + + it('no star is twice in the set at one instant, and every index is a real star', () => { + for (const t of times) { + const ids = activeAt(N, t).map((a) => a.star); + expect(new Set(ids).size).toBe(ids.length); + ids.forEach((i) => expect(i >= 0 && i < N).toBe(true)); + } + }); + + it("periods are each slot's own, within 2..7 s, and the offsets differ", () => { + for (let j = 0; j < SLOTS; j += 1) { + expect(slotPeriod(j)).toBeGreaterThanOrEqual(TWINKLE_PERIOD.min); + expect(slotPeriod(j)).toBeLessThanOrEqual(TWINKLE_PERIOD.max); + } + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotPeriod(j))).size).toBe(SLOTS); + expect(new Set(Array.from({ length: SLOTS }, (_, j) => slotOffset(j))).size).toBe(SLOTS); + for (const a of activeAt(N, 100)) expect(a.period).toBeCloseTo(slotPeriod(a.slot), 12); + }); + + it('over an hour every star twinkles at least once', () => { + const seen = new Set(); + for (let t = 0; t < 3600; t += 1) for (const a of activeAt(N, t + 0.5)) seen.add(a.star); + expect(seen.size).toBe(N); + }); + + it('a star joins at its base brightness and leaves at exactly base, with no pop', () => { + const seed = { flip: false, ripple: 3, phase: 1 }; + expect(twinkleGain(0, seed)).toBe(1); + expect(twinkleGain(1, seed)).toBe(1); + for (const flip of [true, false]) + for (const e of [1e-4, 1 - 1e-4]) + expect(Math.abs(twinkleGain(e, { ...seed, flip }) - 1)).toBeLessThan(0.01); + // through the schedule: the instant a twinkle ends the slot is on a new star's first instant, at gain 1 + for (let j = 0; j < SLOTS; j += 1) { + const end = slotOffset(j) + 40 * slotPeriod(j); + const at = activeAt(N, end).find((a) => a.slot === j)!; + expect(at.gain).toBeCloseTo(1, 9); + } + }); + + it('the star that joins is never the one that just left', () => { + for (let j = 0; j < SLOTS; j += 1) { + let prev = -1; + for (let k = 0; k < 300; k += 1) { + const t = slotOffset(j) + (k + 0.5) * slotPeriod(j); + const { star } = activeAt(N, t).find((a) => a.slot === j)!; + expect(star).not.toBe(prev); + prev = star; + } + } + }); + + it('joins never coincide: every (slot, cycle) start over an hour is distinct', () => { + const starts: number[] = []; + for (let j = 0; j < SLOTS; j += 1) + for (let k = 0; slotOffset(j) + k * slotPeriod(j) < 3600; k += 1) + starts.push(slotOffset(j) + k * slotPeriod(j)); + starts.sort((a, b) => a - b); + let min = Infinity; + for (let i = 1; i < starts.length; i += 1) min = Math.min(min, starts[i] - starts[i - 1]); + expect(min).toBeGreaterThan(0); + console.log(`joins/hour ${starts.length}, min gap between joins ${(min * 1000).toFixed(3)} ms`); + }); + + it('dims deeper than it brightens, within 0.35x..1.15x', () => { + let lo = Infinity; + let hi = -Infinity; + for (let j = 0; j < 20; j += 1) + for (let k = 0; k < 20; k += 1) { + const seed = { flip: hash2(j, k) < 0.5, ripple: 2 + (k % 2), phase: j + k }; + for (let i = 0; i <= 200; i += 1) { + const g = twinkleGain(i / 200, seed); + lo = Math.min(lo, g); + hi = Math.max(hi, g); + } + } + expect(lo).toBeGreaterThanOrEqual(TWINKLE_GAIN.dim - 1e-9); + expect(hi).toBeLessThanOrEqual(TWINKLE_GAIN.bright + 1e-9); + expect(1 - lo).toBeGreaterThan(2 * (hi - 1)); + }); + + it('stars outside the set are steady: only the active ones have a gain to apply', () => { + const inSet = new Set(activeAt(N, 50).map((a) => a.star)); + expect(inSet.size).toBeLessThan(N / 4); + }); +}); diff --git a/src/app/features/scene/ridge/stars.ts b/src/app/features/scene/ridge/stars.ts new file mode 100644 index 00000000..0e6d2628 --- /dev/null +++ b/src/app/features/scene/ridge/stars.ts @@ -0,0 +1,249 @@ +// The Midnight starfield: placed once from a seeded PRNG and baked once into the sky canvas. A +// rolling window of them twinkles on an overlay canvas: SLOTS slots, each running back-to-back +// twinkles of one star at a time, so the active set is always SLOTS stars and every star gets its turn. +// The schedule is a pure function of (seed, time): no state accumulates and no Math.random runs +// after the field is built, so any instant maps straight to the active set and its brightnesses. + +/* eslint-disable no-bitwise -- the seeded PRNG and hash are 32-bit integer arithmetic */ +import type { SkyColours, Srgb } from './skyColour'; + +/** + * Stars show when the sky behind them is darker than this sRGB luma (0..255). The ridge scene has + * no night flag (the sky theme's Midnight/Noonday is a body class chosen by the app, not seen here), + * so night is the sky's own darkness: a star's alpha is 1 - luma/NIGHT_LUMA at its height in the + * gradient, and a sky whose zenith is lighter than this gets no stars at all. + */ +export const NIGHT_LUMA = 110; +/** The sky gradient runs from the top (0) through mid (0.5) to the horizon glow (0.78 and below). */ +export const SKY_STOPS = { mid: 0.5, horizon: 0.78 }; +export const MAX_STARS = 400; +/** Twinkling stars at any instant (fewer only if the field has fewer stars). */ +export const SLOTS = 32; +export const TWINKLE_PERIOD = { min: 2, max: 7 } as const; +/** Gain at the dimmest and brightest swing: dimming goes deeper than brightening. */ +export const TWINKLE_GAIN = { dim: 0.35, bright: 1.15 } as const; +const SEED = 0x57a45eed; + +export interface Star { + /** Position as fractions of the sky canvas width and height. */ + x: number; + y: number; + /** Radius in CSS px. */ + r: number; + /** Base alpha 0..0.9 (brightness times how dark the sky is there). */ + alpha: number; + /** CSS colour, white tinted blue or warm. */ + fill: string; + /** The sky gradient's colour at this height: dimming paints it over the baked star. */ + cover: string; +} + +const luma = (c: Srgb) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** The baked gradient's colour at fraction t of the canvas height (the canvas interpolates in sRGB). */ +function skyAt(sky: SkyColours, t: number): Srgb { + const mix = (a: Srgb, b: Srgb, k: number): Srgb => [ + Math.round(lerp(a[0], b[0], k)), + Math.round(lerp(a[1], b[1], k)), + Math.round(lerp(a[2], b[2], k)), + ]; + if (t <= SKY_STOPS.mid) return mix(sky.top, sky.mid, t / SKY_STOPS.mid); + if (t <= SKY_STOPS.horizon) + return mix(sky.mid, sky.horizon, (t - SKY_STOPS.mid) / (SKY_STOPS.horizon - SKY_STOPS.mid)); + return sky.horizon; +} + +export const skyLuma = (sky: SkyColours, t: number): number => luma(skyAt(sky, t)); +export const isNight = (sky: SkyColours): boolean => luma(sky.top) < NIGHT_LUMA; + +/** mulberry32: 32-bit seeded PRNG, one float in [0, 1) per call. */ +function prng(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Stateless seeded hash of up to three integers to [0, 1). */ +export function hash(a: number, b = 0, c = 0): number { + let h = Math.imul(a | 0, 0x9e3779b1) ^ SEED; + h = Math.imul(h ^ (h >>> 15) ^ Math.imul(b | 0, 0x85ebca6b), 0xc2b2ae35); + h = Math.imul(h ^ (h >>> 13) ^ Math.imul(c | 0, 0x27d4eb2f), 0x165667b1); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +/** How many stars a viewport carries: about one per 1100 CSS px squared, 150..MAX_STARS. */ +export const starCount = (cssW: number, cssH: number): number => + Math.min(MAX_STARS, Math.max(150, Math.round((cssW * cssH) / 1100))); + +/** + * Seeded placement. Each candidate draws all its random numbers whether it is kept or not, so a + * given seed and sky always give the same field and the prefix does not shift with the count. + * Heights are squeezed toward the zenith (y = 0.78 u^1.7) and alpha falls with the sky's luma, so + * the stars thin out and fade into the horizon glow. None for a day sky. + */ +export function makeStars(sky: SkyColours, cssW: number, cssH: number): Star[] { + if (!isNight(sky)) return []; + const rnd = prng(SEED); + const n = starCount(cssW, cssH); + // Every candidate draws all its numbers, kept or not, so the prefix is stable. + const candidates = Array.from({ length: MAX_STARS }, () => ({ + x: rnd(), + y: SKY_STOPS.horizon * rnd() ** 1.7, + r: 0.35 + 1.0 * rnd() ** 4, + bright: 0.35 + 0.65 * rnd() ** 1.5, + tint: rnd(), + })).slice(0, n); + return candidates.flatMap(({ x, y, r, bright, tint }) => { + const alpha = Math.min(0.9, bright * Math.max(0, 1 - skyLuma(sky, y) / NIGHT_LUMA)); + if (alpha < 0.05) return []; + // White, pulled toward blue or toward warm by the tint draw. + const to: Srgb = tint < 0.5 ? [165, 195, 255] : [255, 222, 185]; + const k = 0.55 * Math.abs(tint - 0.5) * 2; + const c = [255, 255, 255].map((v, j) => Math.round(lerp(v, to[j], k))); + const s = skyAt(sky, y); + return [ + { + x, + y, + r, + alpha, + fill: `rgb(${c[0]},${c[1]},${c[2]})`, + cover: `rgb(${s[0]},${s[1]},${s[2]})`, + }, + ]; + }); +} + +// ---- The schedule --------------------------------------------------------------------------- + +export const slotCount = (stars: number): number => Math.min(SLOTS, stars); +/** A slot's twinkle period in seconds: its own, in TWINKLE_PERIOD. */ +export const slotPeriod = (j: number): number => + TWINKLE_PERIOD.min + (TWINKLE_PERIOD.max - TWINKLE_PERIOD.min) * hash(j, 1); +/** A slot's phase: seconds into its first cycle at t = 0. */ +export const slotOffset = (j: number): number => hash(j, 2) * slotPeriod(j); + +/** Stars in slot j are those with index = j (mod slots), `m` of them; no star is in two slots. */ +const partitionSize = (stars: number, slots: number, j: number): number => + Math.floor((stars - 1 - j) / slots) + 1; + +/** The k-th star slot j twinkles: epochs of a seeded shuffle of its partition, never repeating across the seam. */ +function partitionPick(stars: number, slots: number, j: number, k: number): number { + const m = partitionSize(stars, slots, j); + const shuffled = (e: number) => { + const order = Array.from({ length: m }, (_, q) => q).sort( + (a, b) => hash(j, e, a + 10) - hash(j, e, b + 10) || a - b + ); + return order; + }; + const e = Math.floor(k / m); + const order = shuffled(e); + if (m > 1 && order[0] === shuffled(e - 1)[m - 1]) [order[0], order[1]] = [order[1], order[0]]; + return j + slots * order[k - e * m]; +} + +/** + * Brightness multiplier at fraction u of one twinkle (0..1): exactly 1 at both ends, so a star + * joins and leaves at its base brightness. One swing, a little unrelated shimmer, and the + * asymmetric map below: dimming goes to TWINKLE_GAIN.dim, brightening only to .bright. + */ +export function twinkleGain( + u: number, + seed: { flip: boolean; ripple: number; phase: number } +): number { + if (u <= 0 || u >= 1) return 1; + const main = Math.sin(2 * Math.PI * u) * (seed.flip ? -1 : 1); + const shimmer = Math.sin(2 * Math.PI * seed.ripple * u + seed.phase) * Math.sin(Math.PI * u); + const s = Math.max(-1, Math.min(1, 0.85 * main + 0.15 * shimmer)); + return s >= 0 ? 1 + (TWINKLE_GAIN.bright - 1) * s : 1 + (1 - TWINKLE_GAIN.dim) * s; +} + +export interface Twinkling { + slot: number; + star: number; + /** Seconds since this twinkle started, and its length. */ + start: number; + period: number; + gain: number; +} + +/** The active set at time `t` seconds for a field of `stars` stars, in slot order. */ +export function activeAt(stars: number, t: number): Twinkling[] { + const slots = slotCount(stars); + return Array.from({ length: slots }, (_, j) => { + const period = slotPeriod(j); + const off = slotOffset(j); + const k = Math.floor((t - off) / period); + const start = off + k * period; + const seed = { + flip: hash(j, k, 3) < 0.5, + ripple: 2 + Math.floor(hash(j, k, 4) * 2), + phase: 2 * Math.PI * hash(j, k, 5), + }; + return { + slot: j, + star: partitionPick(stars, slots, j, k), + start, + period, + gain: twinkleGain((t - start) / period, seed), + }; + }); +} + +// ---- Drawing -------------------------------------------------------------------------------- + +/** Bake `stars` into `ctx` (canvas `w` x `h` device px, `scale` device px per CSS px) at base alpha. */ +export function drawStars( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + w: number, + h: number, + scale: number +) { + stars.forEach((s) => { + ctx.globalAlpha = s.alpha; + ctx.fillStyle = s.fill; + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} + +/** + * The overlay pass: clear, then for each active star whose gain is off base, paint over the baked + * star. Dimming covers it with the sky's own colour at (1 - gain), which leaves exactly + * alpha * gain of the star; brightening adds a second star on top. A star at base draws nothing. + */ +export function drawTwinkle( + ctx: CanvasRenderingContext2D, + stars: readonly Star[], + active: readonly Twinkling[], + w: number, + h: number, + scale: number +) { + ctx.clearRect(0, 0, w, h); + active.forEach((a) => { + const s = stars[a.star]; + if (!s || Math.abs(a.gain - 1) < 0.01) return; + if (a.gain < 1) { + ctx.globalAlpha = 1 - a.gain; + ctx.fillStyle = s.cover; + } else { + ctx.globalAlpha = Math.min(1, ((a.gain - 1) * s.alpha) / (1 - s.alpha)); + ctx.fillStyle = s.fill; + } + ctx.beginPath(); + ctx.arc(s.x * w, s.y * h, s.r * scale + (a.gain < 1 ? 1 : 0), 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.globalAlpha = 1; +} -- 2.47.3 From 077737830e3363b79cb6467f5827a3479bc03c54 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:36:18 +0000 Subject: [PATCH 03/24] scene: baked sky numbers and lighting/haze maths for a terrain tier Vendors the small set the terrain needs from Void's baked sky (PR #22, 7b4aa2f): sun elevation, the 7-stop gradient, sun.js light().direct, plus a sun azimuth computed from the same series (not in the bake). Lambert from the sun, ambient from the gradient zenith, exp(-k d) haze toward the gradient's horizon stop; a sun below the horizon lights nothing. Pure functions, no GL. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/skyData.ts | 44 ++++++ .../features/scene/terrain/terrainSky.test.ts | 138 ++++++++++++++++++ src/app/features/scene/terrain/terrainSky.ts | 82 +++++++++++ 3 files changed, 264 insertions(+) create mode 100644 src/app/features/scene/terrain/skyData.ts create mode 100644 src/app/features/scene/terrain/terrainSky.test.ts create mode 100644 src/app/features/scene/terrain/terrainSky.ts diff --git a/src/app/features/scene/terrain/skyData.ts b/src/app/features/scene/terrain/skyData.ts new file mode 100644 index 00000000..2b75f405 --- /dev/null +++ b/src/app/features/scene/terrain/skyData.ts @@ -0,0 +1,44 @@ +// The sky numbers the terrain scene lights and hazes with, vendored from Void's baked sky (GameCult/Void +// PR #22, branch void/sky-tokens, revision 7b4aa2f): `src/sky/baked.ts` (generated by scripts/bake-sky.mjs +// from src/sky/sun.js and src/sky/skyTokens.js) and `light()` in `src/sky/sun.js`. Both moments are +// 2026-09-22 at lat 45, lon 0. +// +// elevation, gradient copied from baked.ts (`baked.midnight` / `baked.noonday`). +// direct `light({time, lat, lon}).direct` from sun.js at that moment: sun colour at normal +// incidence, linear RGB, clear zenith sun ~ 1. baked.ts does not carry it. +// azimuth NOT in #22. Computed here from the same NOAA/Meeus series sun.js uses for the +// elevation (hour angle and declination), degrees clockwise from north. It is the +// one number in this file Void's bake does not produce; see the report on the table shape. +// +// These are copies, not a link: #22 is not in this tree, so nothing fails if Void re-bakes. When #22 lands, +// replace this module's literals with an import of `baked` (plus the fields above, if the bake adds them) and +// keep the exports. + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Seven sRGB hex stops, zenith first, horizon last. */ + gradient: readonly string[]; +} + +export const SKY: Record = { + midnight: { + elevation: -44.579021600011224, + azimuth: 2.4988773054640205, + direct: [0, 0, 0], + gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], + }, + noonday: { + elevation: 45.16994813502928, + azimuth: 182.58737070473097, + direct: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], + gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], + }, +}; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts new file mode 100644 index 00000000..44b77f36 --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -0,0 +1,138 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { describe, expect, it } from 'vitest'; +import { SKY } from './skyData'; +import { + ambientColour, + hazeColour, + hexToLinear, + shade, + skyNameFor, + sunColour, + sunDirection, + transmittance, +} from './terrainSky'; +import { skyColours } from '../ridge/skyColour'; + +const UP = [0, 1, 0] as const; +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; + +describe('baked sky numbers', () => { + it('keep the two moments apart: night sun below the horizon, noon sun well above', () => { + expect(SKY.midnight.elevation).toBeLessThan(0); + expect(SKY.noonday.elevation).toBeGreaterThan(30); + expect(SKY.midnight.gradient).toHaveLength(7); + expect(SKY.noonday.gradient).toHaveLength(7); + }); + + it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { + for (const d of Object.values(SKY)) { + const s = sunDirection(d); + expect(Math.hypot(...s)).toBeCloseTo(1, 9); + expect(s[1]).toBeCloseTo(Math.sin((d.elevation * Math.PI) / 180), 9); + } + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 0 })[2]).toBeCloseTo(-1, 9); + expect(sunDirection({ ...SKY.noonday, elevation: 0, azimuth: 180 })[2]).toBeCloseTo(1, 9); + }); + + it('hexToLinear decodes sRGB', () => { + expect(hexToLinear('#000000')).toEqual([0, 0, 0]); + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); + }); + + it('the default sky from skyColours() is a night one, as the stars see it', () => { + expect(skyNameFor(skyColours())).toBe('midnight'); + expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday'); + }); +}); + +describe('lighting', () => { + it('noonday lights an upward face with the sun; midnight has none', () => { + expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); + expect(sunColour(SKY.midnight)).toEqual([0, 0, 0]); + }); + + it('a sun below the horizon gives no negative or sun light on any normal, even with a lit colour', () => { + const forcedDown = { ...SKY.noonday, elevation: -30 }; + expect(sunColour(forcedDown)).toEqual([0, 0, 0]); + for (const n of [UP, [1, 0, 0], [0, -1, 0], [0, 0.6, -0.8]] as const) { + const c = shade(forcedDown, [1, 1, 1], n, 0); + for (const ch of c) expect(ch).toBeGreaterThanOrEqual(0); + } + }); + + it('the sun fades out through the horizon instead of switching', () => { + const at = (elevation: number) => luma(sunColour({ ...SKY.noonday, elevation })); + expect(at(-2)).toBe(0); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeCloseTo(at(60), 9); + }); + + it('shade never goes negative on any normal at either sky, at any distance', () => { + for (const d of Object.values(SKY)) + for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) + for (const dist of [0, 100, 5000]) + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist)) expect(ch).toBeGreaterThanOrEqual(0); + }); + + it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { + const toSun = sunDirection(SKY.noonday); + const away = [-toSun[0], 0, -toSun[2]] as const; + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0)); + expect(lit).toBeGreaterThan(3 * dark); + const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0); + expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black + expect(luma(m(UP))).toBeLessThan(0.05); + }); + + it('noon is much brighter than midnight, ambient included', () => { + expect(luma(ambientColour(SKY.noonday))).toBeGreaterThan(2 * luma(ambientColour(SKY.midnight))); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0))).toBeGreaterThan( + 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0)) + ); + }); + + it('noon ambient matches sun.js diffuse at that moment to within a few percent (the gain)', () => { + const a = ambientColour(SKY.noonday); + const diffuse = [0.07, 0.132, 0.27]; // light() at 2026-09-22T12:00Z, 45N 0E, revision 7b4aa2f + for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeGreaterThan(0.85); + for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeLessThan(1.2); + }); +}); + +describe('haze', () => { + it('transmittance falls monotonically from 1 toward 0', () => { + let prev = transmittance(0); + expect(prev).toBe(1); + for (const d of [10, 100, 400, 1000, 3000, 8000]) { + const t = transmittance(d); + expect(t).toBeLessThan(prev); + expect(t).toBeGreaterThan(0); + prev = t; + } + }); + + it('far surfaces take the horizon colour of the baked gradient, at both skies', () => { + for (const d of Object.values(SKY)) { + const far = shade(d, [0.9, 0.2, 0.1], UP, 100000); + const h = hazeColour(d); + for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(h[i], 6); + } + expect(hazeColour(SKY.noonday)).toEqual(hexToLinear('#e2f4ff')); + expect(hazeColour(SKY.midnight)).toEqual(hexToLinear('#484e64')); + }); + + it('haze colour differs between the two skies: pale day horizon, dim night one', () => { + expect(luma(hazeColour(SKY.noonday))).toBeGreaterThan(5 * luma(hazeColour(SKY.midnight))); + }); + + it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { + const d = SKY.noonday; + const h = luma(hazeColour(d)); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x)); + expect(dark(2000)).toBeGreaterThan(dark(200)); + expect(dark(2000)).toBeLessThan(h); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts new file mode 100644 index 00000000..8f255206 --- /dev/null +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -0,0 +1,82 @@ +// Lighting and haze for the terrain scene, as pure functions of one sky's baked numbers (./skyData.ts). +// No DOM, no GL. The fragment shader does the same arithmetic with these results as uniforms. +import { isNight } from '../ridge/stars'; +import type { SkyColours } from '../ridge/skyColour'; +import { SKY, SkyData, SkyName, Vec3 } from './skyData'; + +const RAD = Math.PI / 180; +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; + +/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ +export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); + +export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; + +/** sRGB hex `#rrggbb` to linear RGB 0..1. */ +export function hexToLinear(hex: string): Vec3 { + const ch = (at: number) => { + const s = parseInt(hex.slice(at, at + 2), 16) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return [ch(1), ch(3), ch(5)]; +} + +/** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ +export function sunDirection(d: SkyData): Vec3 { + const el = d.elevation * RAD; + const az = d.azimuth * RAD; + return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; +} + +/** + * The sun's colour as the ground sees it: baked `direct`, off at the horizon the way sun.js fades the disc + * (gone at -0.83 degrees, soft to 1.5). A sun below the horizon lights nothing. + */ +export function sunColour(d: SkyData): Vec3 { + const k = smooth(-0.83, 1.5, d.elevation); + return [d.direct[0] * k, d.direct[1] * k, d.direct[2] * k]; +} + +/** + * Ambient light on an upward surface, from the baked gradient's zenith stop. The gain is chosen so the + * noonday value (0.075, 0.14, 0.25) matches sun.js's own `diffuse` there (0.07, 0.13, 0.27); at midnight + * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. + */ +export const AMBIENT_GAIN = 0.25; +export function ambientColour(d: SkyData): Vec3 { + const z = hexToLinear(d.gradient[0]); + return [z[0] * AMBIENT_GAIN, z[1] * AMBIENT_GAIN, z[2] * AMBIENT_GAIN]; +} + +/** The colour distance fades to: the gradient's horizon stop, linear. */ +export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); + +/** Haze: transmittance T = exp(-k d) at distance d metres. */ +export const HAZE_K = 0.0012; +export const transmittance = (distance: number): number => + Math.exp(-HAZE_K * Math.max(0, distance)); + +/** + * Linear colour of a surface: lambert from the sun plus ambient (full on an upward face, half on a + * vertical one, none on a downward one: `0.5 + 0.5 n.y`), then hazed toward the horizon colour by distance. + * `normal` is a unit vector. Never negative, whatever the sun does. + */ +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number): Vec3 { + const sun = sunDirection(d); + const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); + const direct = sunColour(d); + const amb = ambientColour(d); + const hemi = 0.5 + 0.5 * normal[1]; + const haze = hazeColour(d); + const t = transmittance(distance); + return [0, 1, 2].map((i) => { + const lit = albedo[i] * (direct[i] * lambert + amb[i] * Math.max(0, hemi)); + return lit * t + haze[i] * (1 - t); + }) as unknown as Vec3; +} + +export const skyData = (name: SkyName): SkyData => SKY[name]; -- 2.47.3 From 5642acf2f9ea10097c92a7dbda518231592ad1ab Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:36:18 +0000 Subject: [PATCH 04/24] scene: terrain style, a baked Phacelle height map on a displaced grid lit by the baked sky style 'terrain' (WebGL2, tier-gated, dynamic import): height map baked once on the GPU into RGBA8 (promoted from the terrain-tier-spike), a displaced grid, the baked gradient as sky, baked sun/ambient/haze. Sky from option, ?sky=, or the stars' night notion. Cadence guard, pauses, context loss and compile failure follow the ridge scene. preview-config.js takes an optional style argument. Co-Authored-By: Claude Sonnet 5.5 --- scripts/preview-config.js | 7 +- src/app/features/scene/index.ts | 10 +- .../features/scene/terrain/Terrain.test.ts | 235 ++++++++++++ src/app/features/scene/terrain/Terrain.ts | 359 ++++++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 146 +++++++ .../scene/terrain/terrainWorld.test.ts | 81 ++++ .../features/scene/terrain/terrainWorld.ts | 126 ++++++ src/app/features/scene/tier.ts | 7 +- 8 files changed, 961 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/terrain/Terrain.test.ts create mode 100644 src/app/features/scene/terrain/Terrain.ts create mode 100644 src/app/features/scene/terrain/terrainShaders.ts create mode 100644 src/app/features/scene/terrain/terrainWorld.test.ts create mode 100644 src/app/features/scene/terrain/terrainWorld.ts diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 589f8efa..75746f98 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,12 +1,13 @@ // Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier] +// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier] [style] import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const tier = process.argv[2] ?? 'low'; +const style = process.argv[3]; const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); const config = JSON.parse(fs.readFileSync(file, 'utf8')); -config.backgroundScene = { ...config.backgroundScene, tier }; +config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); -console.log(`${file}: backgroundScene.tier = ${tier}`); +console.log(`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}`); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index a5b2de7d..c324b2d5 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -12,7 +12,7 @@ export type StartOptions = Omit Scene | null; try { - createScene = - config?.style === 'raymarch' - ? (await import('./Scene')).createScene - : (await import('./ridge/Ridge')).createRidgeScene; + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') + createScene = (await import('./terrain/Terrain')).createTerrainScene; + else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts new file mode 100644 index 00000000..8338721e --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-empty-function -- fake GL and DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainScene } from './Terrain'; +import { GRID, triangleCount } from './terrainWorld'; +import { SKY } from './skyData'; +import { ambientColour } from './terrainSky'; +import { startBackgroundScene } from '../index'; + +/** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ +function fakeGl(over: Record = {}) { + const calls: Record = {}; + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name in over) return over[name]; + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'getAttribLocation' || name === 'getUniformLocation') return () => 1; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return (...args: unknown[]) => { + (calls[name] ??= []).push(args); + return {}; + }; + }, + } + ); + return { gl, calls }; +} + +let rafQueue: Array<(t: number) => void>; +let visibility: string; +let search: string; +beforeEach(() => { + rafQueue = []; + visibility = 'visible'; + search = ''; + vi.stubGlobal('document', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get visibilityState() { + return visibility; + }, + createElement: () => ({ style: {}, remove() {} }), + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + get location() { + return { search }; + }, + setTimeout, + }); + vi.stubGlobal('performance', { now: () => 0 }); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + rafQueue = []; + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function canvasWith(gl: unknown) { + return { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: vi.fn() }, + getContext: vi.fn(() => gl), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as any; +} + +describe('createTerrainScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null without WebGL2, so the host keeps its CSS ground', () => { + const canvas = canvasWith(null); + expect(createTerrainScene({ canvas, tier: 'full' })).toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + }); + + it('returns null with no vertex texture units, or an incomplete bake target', () => { + expect( + createTerrainScene({ canvas: canvasWith(fakeGl({ getParameter: () => 0 }).gl), tier: 'full' }) + ).toBeNull(); + const { gl, calls } = fakeGl({ checkFramebufferStatus: () => 0 }); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })).toBeNull(); + expect(calls.deleteProgram).toHaveLength(3); + }); + + it('reports a shader that does not compile to onCompileFail and returns null', () => { + const { gl } = fakeGl({ getShaderParameter: () => false, getShaderInfoLog: () => 'bad' }); + const onCompileFail = vi.fn(); + expect(createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onCompileFail })).toBeNull(); + expect(onCompileFail).toHaveBeenCalledOnce(); + expect(onCompileFail.mock.calls[0][0].log).toContain('terrain height'); + }); + + it('bakes the height map once, then draws sky and grid each frame and keeps turning', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'low' })!; + expect(scene).not.toBeNull(); + const bakes = calls.drawArrays.length; + expect(bakes).toBe(1); + rafQueue.shift()!(16); + rafQueue.shift()!(32); + expect(calls.drawArrays).toHaveLength(bakes + 2); // one sky draw per frame + expect(calls.drawElements).toHaveLength(2); + expect(calls.drawElements[0][1]).toBe(triangleCount(GRID.low.grid) * 3); + expect(calls.texImage2D).toHaveLength(1); // the baked map is never re-uploaded + expect(rafQueue).toHaveLength(1); + scene.dispose(); + }); + + it("'still' draws one frame and queues nothing", () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!; + expect(calls.drawElements).toHaveLength(1); + expect(rafQueue).toHaveLength(0); + scene.dispose(); + }); + + it('lights with the sky chosen: option, then ?sky=, then the night default', () => { + const ambientUploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + const hit = calls.uniform3fv.filter((a) => (a[1] as ArrayLike).length === 3); + // uniform3fv order in draw(): right, up, forward, then sun, sunColour, ambient, haze (+ camera) + return hit.map((a) => Array.from(a[1] as ArrayLike)); + }; + const night = ambientUploaded({}); + const day = ambientUploaded({ sky: 'noonday' }); + const nightA = Array.from(ambientColour(SKY.midnight)); + const dayA = Array.from(ambientColour(SKY.noonday)); + const has = (all: number[][], v: number[]) => + all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(night, nightA)).toBe(true); + expect(has(night, dayA)).toBe(false); + expect(has(day, dayA)).toBe(true); + search = '?sky=noonday'; + expect(has(ambientUploaded({}), dayA)).toBe(true); + search = '?sky=garbage'; + expect(has(ambientUploaded({}), nightA)).toBe(true); + }); + + it('falls back to a still frame when the measured p95 is over 33 ms', () => { + const { gl, calls } = fakeGl(); + const onProbe = vi.fn(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full', onProbe })!; + let t = 0; + for (let i = 0; i < 400 && rafQueue.length; i += 1) rafQueue.shift()!((t += 50)); + expect(rafQueue).toHaveLength(0); + expect(onProbe).toHaveBeenLastCalledWith(expect.objectContaining({ still: true })); + expect(calls.drawElements.length).toBeLessThan(400); + scene.dispose(); + }); + + it('does not draw while the tab is hidden, or under reduced motion', () => { + const { gl, calls } = fakeGl(); + const scene = createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!; + const before = (calls.drawElements ?? []).length; + visibility = 'hidden'; + rafQueue.shift()!(16); + expect((calls.drawElements ?? []).length).toBe(before); + scene.dispose(); + vi.stubGlobal('window', { + ...(window as object), + matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), + }); + visibility = 'visible'; + const g2 = fakeGl(); + const still = createTerrainScene({ canvas: canvasWith(g2.gl), tier: 'full' })!; + expect(rafQueue).toHaveLength(0); + expect(g2.calls.drawElements).toHaveLength(1); // one frame, then still + still.dispose(); + }); + + it('a lost context stops the scene and tells the host', () => { + const { gl, calls } = fakeGl(); + const canvas = canvasWith(gl); + const onContextLost = vi.fn(); + createTerrainScene({ canvas, tier: 'full', onContextLost })!; + const handler = canvas.addEventListener.mock.calls.find( + (c: unknown[]) => c[0] === 'webglcontextlost' + )[1]; + handler({ preventDefault() {} }); + expect(onContextLost).toHaveBeenCalledOnce(); + const n = (calls.drawElements ?? []).length; + rafQueue.splice(0).forEach((f) => f(16)); + expect((calls.drawElements ?? []).length).toBe(n); + }); + + it('dispose frees its programs, buffers and texture and queues nothing more', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.deleteProgram).toHaveLength(3); + expect(calls.deleteBuffer).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(1); + expect(rafQueue).toHaveLength(0); + }); +}); + +describe("startBackgroundScene style 'terrain'", () => { + it('requests webgl2 and returns a scene once the tier is on', async () => { + const { gl } = fakeGl(); + const canvas = canvasWith(gl); + const scene = await startBackgroundScene({ tier: 'still', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext).toHaveBeenCalledWith('webgl2', expect.anything()); + scene!.dispose(); + }); + + it('stays off by default, and the default style is still the ridge (no WebGL2)', async () => { + const canvas = canvasWith(null); + expect(await startBackgroundScene({ style: 'terrain' }, {}, { canvas })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + const { gl } = fakeGl(); + const c2 = { ...canvasWith(gl), getContext: vi.fn(() => null) }; + await startBackgroundScene({ tier: 'still' }, {}, { canvas: c2 }); + expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); + }); +}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts new file mode 100644 index 00000000..2a81aa56 --- /dev/null +++ b/src/app/features/scene/terrain/Terrain.ts @@ -0,0 +1,359 @@ +import type { Scene, SceneOptions } from '../Scene'; +import { FrameSampler, FrameStats } from '../ridge/probe'; +import { skyColours } from '../ridge/skyColour'; +import { SkyName } from './skyData'; +import { + ambientColour, + hazeColour, + HAZE_K, + hexToLinear, + isSkyName, + skyData, + skyNameFor, + sunColour, + sunDirection, +} from './terrainSky'; +import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + FULLSCREEN_VERT, + GRID_FRAG, + GRID_VERT, + heightFragmentSource, + SKY_FRAG, +} from './terrainShaders'; + +export type TerrainOptions = Pick< + SceneOptions, + 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' +> & { + /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ + sky?: SkyName; + /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ + seed?: number; + /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ + probe?: boolean; + onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; +}; + +/** Same shape as Scene.ts's ShaderCompileError, redeclared so this chunk does not import the ray-march scene. */ +class ShaderCompileError extends Error { + constructor(readonly stage: 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: string): WebGLProgram { + const make = (type: number, src: string, which: string) => { + const s = gl.createShader(type); + if (!s) throw new Error('createShader failed'); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(s) ?? ''; + gl.deleteShader(s); + throw new ShaderCompileError('vertex', `terrain ${name} ${which} shader: ${log}`); + } + return s; + }; + const v = make(gl.VERTEX_SHADER, vs, 'vertex'); + const f = make(gl.FRAGMENT_SHADER, fs, 'fragment'); + const p = gl.createProgram(); + if (!p) throw new Error('createProgram failed'); + gl.attachShader(p, v); + gl.attachShader(p, f); + gl.linkProgram(p); + gl.deleteShader(v); + gl.deleteShader(f); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(p) ?? ''; + gl.deleteProgram(p); + throw new ShaderCompileError('vertex', `terrain ${name} link: ${log}`); + } + return p; +} + +/** The sky to use: the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +function chooseSky(opt: SkyName | undefined): SkyName { + if (opt) return opt; + const q = new URLSearchParams(window.location.search).get('sky'); + return isSkyName(q) ? q : skyNameFor(skyColours()); +} + +/** + * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the + * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an + * RGBA8 texture; each frame is two draws: the baked gradient sky, then a displaced grid lit by the baked sun and + * ambient and hazed toward the baked horizon colour. Cadence guard, pauses and the `?probe=1` readout match the + * ridge scene. + */ +export function createTerrainScene(opts: TerrainOptions): Scene | null { + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; + const { canvas } = opts; + const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + }) as WebGL2RenderingContext | null; + if (!gl) return null; + const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); + if (typeof units === 'number' && units < 1) return null; + + const sky = skyData(chooseSky(opts.sky)); + const spec = GRID[tier]; + + let skyProg: WebGLProgram; + let gridProg: WebGLProgram; + let bakeProg: WebGLProgram; + try { + bakeProg = program(gl, 'height', FULLSCREEN_VERT, heightFragmentSource()); + skyProg = program(gl, 'sky', FULLSCREEN_VERT, SKY_FRAG); + gridProg = program(gl, 'grid', GRID_VERT, GRID_FRAG); + } catch (e) { + opts.onCompileFail?.(e as ShaderCompileError); + return null; + } + + // The height map, baked once. + const heightTex = gl.createTexture(); + const fbo = gl.createFramebuffer(); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); + const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + const free: Array<() => void> = []; + const release = () => { + free.forEach((f) => f()); + gl.deleteTexture(heightTex); + gl.deleteFramebuffer(fbo); + gl.deleteProgram(bakeProg); + gl.deleteProgram(skyProg); + gl.deleteProgram(gridProg); + }; + if (!complete) { + release(); + return null; + } + gl.viewport(0, 0, spec.tex, spec.tex); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(bakeProg); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uSpan'), TERRAIN.span); + gl.uniform1f(gl.getUniformLocation(bakeProg, 'uTexels'), spec.tex); + gl.uniform2fv(gl.getUniformLocation(bakeProg, 'uSeed'), seedOffset(opts.seed ?? 0)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // The displaced grid. + const g = buildGrid(spec.grid); + const vao = gl.createVertexArray(); + const vbuf = gl.createBuffer(); + const ibuf = gl.createBuffer(); + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, vbuf); + gl.bufferData(gl.ARRAY_BUFFER, g.uv, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibuf); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, g.indices, gl.STATIC_DRAW); + gl.bindVertexArray(null); + free.push( + () => gl.deleteVertexArray(vao), + () => gl.deleteBuffer(vbuf), + () => gl.deleteBuffer(ibuf) + ); + + const gradient = new Float32Array(sky.gradient.flatMap((h) => [...hexToLinear(h)])); + const sun = [...sunDirection(sky)]; + const sunLight = [...sunColour(sky)]; + const ambient = [...ambientColour(sky)]; + const haze = [...hazeColour(sky)]; + const triangles = triangleCount(spec.grid); + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const sampler = new FrameSampler(); + let still = tier === 'still'; + let disposed = false; + let lost = false; + let raf = 0; + let readout: HTMLElement | undefined; + let resizeTimer = 0; + let turnsAtPause = 0; + let playedFrom: number | undefined; + + /** Size the canvas: low renders at 0.75 of the CSS size; full at device pixel ratio capped to 2. */ + const size = () => { + const dpr = Math.min(window.devicePixelRatio || 1, 2) * (tier === 'low' ? 0.75 : 1); + canvas.width = Math.max(1, Math.round((canvas.clientWidth || window.innerWidth) * dpr)); + canvas.height = Math.max(1, Math.round((canvas.clientHeight || window.innerHeight) * dpr)); + }; + + const draw = (turns: number) => { + if (lost) return; + gl.viewport(0, 0, canvas.width, canvas.height); + const v = view(canvas.width / canvas.height, turns % 1); + gl.disable(gl.DEPTH_TEST); + gl.useProgram(skyProg); + const us = (n: string) => gl.getUniformLocation(skyProg, n); + gl.uniform3fv(us('uGrad'), gradient); + gl.uniform3fv(us('uRight'), v.right); + gl.uniform3fv(us('uUp'), v.up); + gl.uniform3fv(us('uForward'), v.forward); + gl.uniform2f(us('uSize'), canvas.width, canvas.height); + gl.uniform1f(us('uTanHalf'), Math.tan(CAMERA.fov / 2)); + gl.drawArrays(gl.TRIANGLES, 0, 3); + + gl.enable(gl.DEPTH_TEST); + gl.clear(gl.DEPTH_BUFFER_BIT); + gl.useProgram(gridProg); + const u = (n: string) => gl.getUniformLocation(gridProg, n); + gl.uniformMatrix4fv(u('uViewProj'), false, v.viewProj); + gl.uniform3fv(u('uCamera'), v.eye); + gl.uniform1f(u('uSpan'), TERRAIN.span); + gl.uniform1i(u('uTex'), spec.tex); + gl.uniform3fv(u('uSun'), sun); + gl.uniform3fv(u('uSunColour'), sunLight); + gl.uniform3fv(u('uAmbient'), ambient); + gl.uniform3fv(u('uHaze'), haze); + gl.uniform1f(u('uHazeK'), HAZE_K); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, heightTex); + gl.uniform1i(u('uHeight'), 0); + gl.bindVertexArray(vao); + gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); + gl.bindVertexArray(null); + }; + + const moving = () => + !still && + !disposed && + !lost && + !(reducedMotion?.matches ?? false) && + document.visibilityState === 'visible'; + + const turnsNow = (now: number) => + turnsAtPause + (playedFrom === undefined ? 0 : (now - playedFrom) / (CAMERA.seconds * 1000)); + + const report = (stillNow: boolean) => { + const s = { ...sampler.stats(), still: stillNow, triangles }; + opts.onProbe?.(s); + if (readout) { + readout.textContent = `terrain · ${s.triangles} tris · n ${s.n} · median ${s.medianMs.toFixed( + 1 + )} ms · p95 ${s.p95Ms.toFixed(1)} ms${stillNow ? ' · STILL' : ''}`; + } + }; + + const settleToStill = () => { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + still = true; + cancelAnimationFrame(raf); + raf = 0; + draw(turnsAtPause); + report(true); + }; + + let sinceReport = 0; + // The first window is measured (guard against a slow device); after it the loop only draws. + let measuring = true; + const frame = (now: number) => { + raf = 0; + if (!moving()) return; + draw(turnsNow(now)); + if (!measuring) { + raf = requestAnimationFrame(frame); + return; + } + const full = sampler.push(now); + if (full) { + measuring = false; + if (sampler.slow()) { + settleToStill(); + return; + } + if (probe) report(false); + } else if (probe && now - sinceReport > 500) { + sinceReport = now; + report(false); + } + raf = requestAnimationFrame(frame); + }; + const wake = () => { + if (!moving()) { + if (playedFrom !== undefined) turnsAtPause = turnsNow(performance.now()); + playedFrom = undefined; + return; + } + if (raf) return; + playedFrom = performance.now(); + if (measuring) sampler.reset(); + raf = requestAnimationFrame(frame); + }; + + const onLost = (e: Event) => { + e.preventDefault(); + lost = true; + cancelAnimationFrame(raf); + raf = 0; + opts.onContextLost?.(); + }; + canvas.addEventListener('webglcontextlost', onLost); + + size(); + if (probe && canvas.parentElement) { + readout = document.createElement('div'); + Object.assign(readout.style, { + position: 'absolute', + left: '8px', + top: '8px', + font: '12px monospace', + color: '#fff', + background: 'rgba(0,0,0,.55)', + padding: '2px 6px', + borderRadius: '3px', + pointerEvents: 'none', + }); + canvas.parentElement.appendChild(readout); + } + if (still) settleToStill(); + else wake(); + if (!still && !moving()) draw(0); + + const onVisibility = () => { + sampler.reset(); + wake(); + }; + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed || lost) return; + size(); + draw(turnsNow(performance.now())); + sampler.reset(); + }, 250); + }; + document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + reducedMotion?.addEventListener?.('change', onVisibility); + + return { + login() { + // The camera is the same before and after. + }, + dispose() { + if (disposed) return; + disposed = true; + cancelAnimationFrame(raf); + clearTimeout(resizeTimer); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + reducedMotion?.removeEventListener?.('change', onVisibility); + canvas.removeEventListener('webglcontextlost', onLost); + readout?.remove(); + release(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts new file mode 100644 index 00000000..0fe8adc5 --- /dev/null +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -0,0 +1,146 @@ +// GLSL ES 3.00. The height map is baked once by CultMath's Phacelle noise (vendored, unedited, MPL-2.0: Rune +// Skovbo Johansen's Phacelle noise, https://blog.runevision.com/2026/01/) composed into a slope-directed gully +// filter after the description in his March 2026 erosion-filter post. The composition is ours, written from +// that description; it is not his filter and has not been compared against it (see harness terrainDemo.ts on +// the terrain-tier-spike branch, from which this is promoted). The height is packed in two bytes of an RGBA8 +// target, so no float colour-buffer extension is needed. +import cultmath from '../shaders/CultMath.glsl?raw'; +import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; +import { trimGlsl } from '../cultmathTrim'; +import { TERRAIN } from './terrainWorld'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; +const f1 = (x: number) => x.toFixed(1); + +export const FULLSCREEN_VERT = `${HEADER} +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const HEIGHT_STAGE = ` +uniform float uSpan; +uniform vec2 uSeed; +uniform float uTexels; +out vec4 o; + +const float BASE_AMP = 60.0; +const float BASE_FREQ = 1.0 / 520.0; +const float BASE_LIFT = 110.0; +const float CELL0 = 160.0; +const float SIDE_LEN = 3.0; +const float NORM = 0.5; +const float SLOPE_REF = 0.55; +const float DETAIL = 1.5; +const float AMP0 = 11.0; +const float HLO = 20.0, HHI = 200.0; + +float easeOut(float t) { t = clamp(t, 0.0, 1.0); return 1.0 - (1.0 - t) * (1.0 - t); } +float powInv(float t, float p) { return 1.0 - pow(1.0 - t, p); } + +float height(vec2 xz) { + vec4 fg = cultmath_fbm_grad(vec3(xz * BASE_FREQ, 0.0), 4, 2.0, 0.45); + float h = BASE_LIFT + BASE_AMP * fg.w; + vec2 g = BASE_AMP * BASE_FREQ * fg.xy; + float target = clamp((h - HLO) / (HHI - HLO) * 2.0 - 1.0, -1.0, 1.0); + float combi = 1.0; + float amp = AMP0; + float cell = CELL0; + for (int i = 0; i < OCTAVES; i++) { + float slope = length(g); + vec2 downhill = slope > 1e-4 ? -g / slope : vec2(1.0, 0.0); + vec2 across = vec2(-downhill.y, downhill.x); + CultPhasor ph = cultmath_phacelle(vec3(xz / cell, 0.5), vec3(across * SIDE_LEN, 0.0), 0.0, NORM); + float mask = easeOut(slope / SLOPE_REF) * combi; + float faded = mix(target, ph.cos.w, mask); + h += amp * faded; + g += amp * mask * ph.cos.xy / cell; + combi = powInv(combi, DETAIL) * (1.0 - abs(ph.sin.w)); + target = faded; + amp *= 0.5; + cell *= 0.5; + } + return h; +} + +void main() { + vec2 uv = gl_FragCoord.xy / vec2(uTexels); + float h = clamp(height((uv - 0.5) * uSpan + uSeed) / ${f1(TERRAIN.hmax)}, 0.0, 1.0); + float q = floor(h * 65535.0 + 0.5); + o = vec4(floor(q / 256.0) / 255.0, mod(q, 256.0) / 255.0, 0.0, 1.0); +}`; + +/** The height-bake fragment shader: header, the part of CultMath that Phacelle and the stage reach, Phacelle, the stage. */ +export function heightFragmentSource(): string { + const math = trimGlsl(cultmath, [phacelle, HEIGHT_STAGE]); + return `${HEADER}#define OCTAVES 4\n${math}\n${phacelle}\n${HEIGHT_STAGE}`; +} + +const FETCH = ` +uniform sampler2D uHeight; +uniform int uTex; +float hAt(ivec2 t) { + t = clamp(t, ivec2(0), ivec2(uTex - 1)); + vec4 s = texelFetch(uHeight, t, 0); + return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( + TERRAIN.hmax + )}; +}`; + +export const GRID_VERT = `${HEADER} +layout(location = 0) in vec2 a_uv; +uniform mat4 uViewProj; +uniform float uSpan; +out vec2 v_uv; +out vec3 v_world; +${FETCH} +void main() { + ivec2 t = ivec2(a_uv * float(uTex - 1) + 0.5); + // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(a_uv.x - 0.5), abs(a_uv.y - 0.5))); + float h = hAt(t) * rim; + v_uv = a_uv; + v_world = vec3((a_uv.x - 0.5) * uSpan, h, (a_uv.y - 0.5) * uSpan); + gl_Position = uViewProj * vec4(v_world, 1.0); +}`; + +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). +// The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. +export const GRID_FRAG = `${HEADER} +in vec2 v_uv; +in vec3 v_world; +uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze; +uniform float uSpan, uHazeK; +out vec4 o; +${FETCH} +void main() { + ivec2 t = ivec2(v_uv * float(uTex - 1) + 0.5); + float texel = uSpan / float(uTex - 1); + float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); + float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); + vec3 n = normalize(vec3(-dx, 2.0 * texel, -dz)); + float alt = clamp(v_world.y / 200.0, 0.0, 1.0); + vec3 rock = mix(vec3(0.07, 0.06, 0.05), vec3(0.28, 0.26, 0.22), alt); + vec3 grass = mix(vec3(0.03, 0.07, 0.025), vec3(0.14, 0.15, 0.07), alt); + vec3 albedo = mix(rock, grass, smoothstep(0.55, 0.9, n.y)); + vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + float T = exp(-uHazeK * length(v_world - uCamera)); + o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); +}`; + +// The sky behind: the gradient's seven stops, zenith first, by the view ray's elevation (horizon stop at and below the horizon). +export const SKY_FRAG = `${HEADER} +uniform vec3 uGrad[7]; +uniform vec3 uRight, uUp, uForward; +uniform vec2 uSize; +uniform float uTanHalf; +out vec4 o; +void main() { + vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; + vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); + float s = (1.0 - sqrt(clamp(d.y, 0.0, 1.0))) * 6.0; + int i = int(min(floor(s), 5.0)); + vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); + o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts new file mode 100644 index 00000000..5d21ba47 --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { describe, expect, it } from 'vitest'; +import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { heightFragmentSource } from './terrainShaders'; + +describe('seed', () => { + it('same seed, same offset, so the baked height map is the same; a different seed moves it', () => { + expect(seedOffset(7)).toEqual(seedOffset(7)); + expect(seedOffset(0)).toEqual([0, 0]); + expect(seedOffset(1)).not.toEqual(seedOffset(2)); + }); + + it('the height-bake source is a pure function of nothing but the world constants', () => { + expect(heightFragmentSource()).toBe(heightFragmentSource()); + const src = heightFragmentSource(); + expect(src.startsWith('#version 300 es')).toBe(true); + expect(src).toContain('cultmath_phacelle'); + expect(src).toContain(`/ ${TERRAIN.hmax.toFixed(1)}`); + expect(src).toContain('uSeed'); + }); +}); + +describe('grid', () => { + it('has (n+1)^2 vertices in 0..1 and 2n^2 triangles that index them all in range', () => { + const n = 8; + const g = buildGrid(n); + expect(g.uv.length).toBe((n + 1) * (n + 1) * 2); + expect(g.indices.length / 3).toBe(triangleCount(n)); + expect(Math.max(...g.indices)).toBe((n + 1) * (n + 1) - 1); + expect(Math.min(...g.uv)).toBe(0); + expect(Math.max(...g.uv)).toBe(1); + }); + + it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { + expect(triangleCount(GRID.low.grid)).toBeLessThan(40000); + expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); + expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); + }); +}); + +describe('camera', () => { + const project = (m: Float32Array, p: number[]) => { + const x = m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12]; + const y = m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13]; + const z = m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]; + const w = m[3] * p[0] + m[7] * p[1] + m[11] * p[2] + m[15]; + return { x: x / w, y: y / w, z: z / w, w }; + }; + + it('a point straight ahead lands in the middle of the screen, in front, inside the depth range', () => { + for (const turns of [0, 0.25, 0.6]) { + const v = view(0.46, turns); + const p = v.eye.map((e, i) => e + 800 * v.forward[i]); + const s = project(v.viewProj, p); + expect(s.w).toBeGreaterThan(0); + expect(s.x).toBeCloseTo(0, 5); + expect(s.y).toBeCloseTo(0, 5); + expect(Math.abs(s.z)).toBeLessThan(1); + } + }); + + it('looks down by the pitch, and one full turn comes back to the same heading', () => { + const a = view(1, 0); + const b = view(1, 1); + expect(a.forward[1]).toBeCloseTo(-Math.sin(CAMERA.pitch), 9); + for (let i = 0; i < 3; i += 1) expect(a.forward[i]).toBeCloseTo(b.forward[i], 9); + }); + + it('sits above the highest possible terrain, so the eye is never inside a hill', () => { + expect(CAMERA.eye).toBeGreaterThan(TERRAIN.hmax); + }); + + it('the basis is orthonormal', () => { + const v = view(0.5, 0.37); + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot(v.right, v.up)).toBeCloseTo(0, 9); + expect(dot(v.right, v.forward)).toBeCloseTo(0, 9); + expect(dot(v.up, v.forward)).toBeCloseTo(0, 9); + expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts new file mode 100644 index 00000000..e21a229e --- /dev/null +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -0,0 +1,126 @@ +// The terrain scene's fixed numbers, grid and camera, as pure functions. No DOM, no GL. +// The height field itself is a function of world position written in GLSL (terrainShaders.ts), baked once +// into a texture; the CPU owns only where it is looked at from, and the seed offset it is sampled at. + +/** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ +export const TERRAIN = { span: 3000, hmax: 256 }; + +/** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ +export const GRID = { + still: { grid: 128, tex: 512 }, + low: { grid: 128, tex: 512 }, + full: { grid: 192, tex: 1024 }, +} as const; + +/** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ +export const CAMERA = { + eye: 330, + pitch: (13 * Math.PI) / 180, + fov: (55 * Math.PI) / 180, + near: 10, + far: 6000, + /** Seconds for one full turn. */ + seconds: 420, +}; + +/** The height map's sample offset in metres for a seed: same seed, same terrain. Seed 0 is the origin. */ +export function seedOffset(seed: number): [number, number] { + const s = Math.trunc(seed); + return [(s * 7919) % 65536, (s * 104729) % 65536]; +} + +export const triangleCount = (grid: number): number => 2 * grid * grid; + +/** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ +export function buildGrid(grid: number): { uv: Float32Array; indices: Uint32Array } { + const uv = new Float32Array((grid + 1) * (grid + 1) * 2); + for (let j = 0, k = 0; j <= grid; j += 1) + for (let i = 0; i <= grid; i += 1, k += 2) { + uv[k] = i / grid; + uv[k + 1] = j / grid; + } + const indices = new Uint32Array(grid * grid * 6); + for (let j = 0, k = 0; j < grid; j += 1) + for (let i = 0; i < grid; i += 1, k += 6) { + const a = j * (grid + 1) + i; + indices.set([a, a + grid + 1, a + 1, a + 1, a + grid + 1, a + grid + 2], k); + } + return { uv, indices }; +} + +export interface View { + /** Column-major perspective * view. */ + viewProj: Float32Array; + eye: [number, number, number]; + /** Camera basis in world space, for the sky pass. */ + right: [number, number, number]; + up: [number, number, number]; + forward: [number, number, number]; +} + +const norm = (a: number[]): [number, number, number] => { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +}; +const cross = (a: number[], b: number[]): [number, number, number] => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +]; + +/** The camera `turns` full turns round (0..1 wraps), for a viewport aspect (width / height). */ +export function view(aspect: number, turns: number): View { + const yaw = 2 * Math.PI * turns; + const cp = Math.cos(CAMERA.pitch); + const forward = norm([Math.sin(yaw) * cp, -Math.sin(CAMERA.pitch), -Math.cos(yaw) * cp]); + const right = norm(cross(forward, [0, 1, 0])); + const up = cross(right, forward); + const eye: [number, number, number] = [0, CAMERA.eye, 0]; + const t = (a: number[]) => -(a[0] * eye[0] + a[1] * eye[1] + a[2] * eye[2]); + const v = [ + right[0], + up[0], + -forward[0], + 0, + right[1], + up[1], + -forward[1], + 0, + right[2], + up[2], + -forward[2], + 0, + t(right), + t(up), + -t(forward), + 1, + ]; + const f = 1 / Math.tan(CAMERA.fov / 2); + const { near: n, far } = CAMERA; + const p = [ + f / aspect, + 0, + 0, + 0, + 0, + f, + 0, + 0, + 0, + 0, + (far + n) / (n - far), + -1, + 0, + 0, + (2 * far * n) / (n - far), + 0, + ]; + const viewProj = new Float32Array(16); + for (let c = 0; c < 4; c += 1) + for (let r = 0; r < 4; r += 1) { + let s = 0; + for (let k = 0; k < 4; k += 1) s += p[k * 4 + r] * v[c * 4 + k]; + viewProj[c * 4 + r] = s; + } + return { viewProj, eye, right, up, forward }; +} diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index e2c82325..4a976081 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -15,8 +15,11 @@ export interface Capabilities { hardwareConcurrency?: number; } -/** 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and clouds. */ -export type SceneStyle = 'ridge' | 'raymarch'; +/** + * 'ridge': baked parallax ridges, no WebGL (default). 'raymarch': the sphere-traced world with its tree and + * clouds. 'terrain': a baked height map on a displaced grid, lit and hazed by the baked sky (WebGL2). + */ +export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; export interface BackgroundSceneConfig { tier?: SceneTier; -- 2.47.3 From 9e34664550bd8dc369ef9edd6efa580f3cafaccb Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 18:43:33 +0000 Subject: [PATCH 05/24] scene: terrain shading blended across texels, smoother sky horizon, lighter haze Preview on Starfire showed one flat normal per height-map texel (blocky ground) and a hard step in the sky gradient at the geometric horizon. Normals now blend the four surrounding texels; the sky's elevation mapping eases through the horizon; haze k 0.0012 -> 0.0007 so relief reads. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainShaders.ts | 19 ++++++++++++++----- src/app/features/scene/terrain/terrainSky.ts | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 0fe8adc5..1eff9c93 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -114,12 +114,21 @@ uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze; uniform float uSpan, uHazeK; out vec4 o; ${FETCH} -void main() { - ivec2 t = ivec2(v_uv * float(uTex - 1) + 0.5); - float texel = uSpan / float(uTex - 1); +// Unnormalised normal at one texel, central differences over its neighbours. +vec3 nAt(ivec2 t, float texel) { float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); - vec3 n = normalize(vec3(-dx, 2.0 * texel, -dz)); + return normalize(vec3(-dx, 2.0 * texel, -dz)); +} +void main() { + // The four texels around this fragment, blended, so the shading is continuous and not one flat square per texel. + vec2 p = v_uv * float(uTex - 1); + ivec2 t = ivec2(floor(p)); + vec2 f = fract(p); + float texel = uSpan / float(uTex - 1); + vec3 n = normalize(mix( + mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), + mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); float alt = clamp(v_world.y / 200.0, 0.0, 1.0); vec3 rock = mix(vec3(0.07, 0.06, 0.05), vec3(0.28, 0.26, 0.22), alt); vec3 grass = mix(vec3(0.03, 0.07, 0.025), vec3(0.14, 0.15, 0.07), alt); @@ -139,7 +148,7 @@ out vec4 o; void main() { vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); - float s = (1.0 - sqrt(clamp(d.y, 0.0, 1.0))) * 6.0; + float s = (1.0 - sqrt(smoothstep(-0.15, 1.0, d.y))) * 6.0; int i = int(min(floor(s), 5.0)); vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 8f255206..d6c699c3 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -56,7 +56,7 @@ export function ambientColour(d: SkyData): Vec3 { export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); /** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0012; +export const HAZE_K = 0.0007; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); -- 2.47.3 From 7216456a278828c0106a38990541d05b80c38e39 Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 18:46:39 +0000 Subject: [PATCH 06/24] sky: bake sun azimuth and colour, ambient with a night floor, horizon colour Co-Authored-By: Claude Sonnet 5.5 --- src/sky/baked.ts | 14 ++++++++++++++ src/sky/skyTokens.js | 13 ++++++++++++- src/sky/skyTokens.test.ts | 34 +++++++++++++++++++++++++++++++++- src/sky/sun.js | 22 ++++++++++++++++------ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 15e72c8f..ecaffbe5 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -8,6 +8,13 @@ export const baked = { lon: 0, }, elevation: -44.579021600011224, + sun: { + elevation_deg: -44.579021600011224, + azimuth_deg: 2.4988773054640205, + color_linear: [0, 0, 0], + }, + ambient_linear: [0.018, 0.024, 0.04], + horizon_hex: '#484e64', gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], tokens: { Background: { @@ -117,6 +124,13 @@ export const baked = { lon: 0, }, elevation: 45.16994813502928, + sun: { + elevation_deg: 45.16994813502928, + azimuth_deg: 182.58737070473103, + color_linear: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], + }, + ambient_linear: [0.07, 0.132, 0.27], + horizon_hex: '#e2f4ff', gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], tokens: { Background: { diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index 0df39c33..c234de12 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -1,7 +1,8 @@ // Midnight Sky and Noonday Sky: theme tokens as albedo x irradiance. // Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. // -// bakeSky('midnight' | 'noonday') -> { moment, elevation, gradient: [hex x GRADIENT_STOPS], tokens } +// bakeSky('midnight' | 'noonday') -> { moment, elevation, sun, ambient_linear, horizon_hex, +// gradient: [hex x GRADIENT_STOPS], tokens, ... } // // The sky is lit by sun.js at a fixed moment (clear weather: a build-time bake has no feed). // A surface is `albedo * irradiance`, where irradiance is the sun and sky on an upward-facing @@ -24,6 +25,11 @@ export const MOMENTS = { midnight: { time: '2026-09-22T00:00:00Z', lat: 45, lon: 0 }, }; +// Lowest ambient a baked sky reports, linear RGB. sun.js's night sky is ~0.004, which is +// starlight-true but too dark to see by on a screen; this lifts it to a bluish moonlit-skyglow +// level. Daylight ambient is far above it, so the floor only ever touches the night. +export const AMBIENT_FLOOR = [0.018, 0.024, 0.04]; + // Linear-RGB albedos of the things Yggdrasil is made of. EYEBALLED around measured ranges // (dark bark 0.05-0.1, birch bark ~0.6, leaves ~0.05 red/0.15 green/0.03 blue, wet soil ~0.1). export const ALBEDO = { @@ -193,6 +199,11 @@ export function bakeSky(name) { return { moment, elevation: l.elevation, + // Sun for consumers that light a scene themselves: direction in degrees (azimuth clockwise + // from north) and the direct light's colour at normal incidence (black while it is below the horizon). + sun: { elevation_deg: l.elevation, azimuth_deg: l.azimuth, color_linear: l.direct }, + ambient_linear: l.diffuse.map((v, i) => Math.max(v, AMBIENT_FLOOR[i])), + horizon_hex: gradient[GRADIENT_STOPS - 1], // last stop: the horizon end of the gradient gradient, tokens, bubbles, diff --git a/src/sky/skyTokens.test.ts b/src/sky/skyTokens.test.ts index 0cd6694c..78a29b03 100644 --- a/src/sky/skyTokens.test.ts +++ b/src/sky/skyTokens.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { AA, bakeSky, contrast } from './skyTokens.js'; +import { AA, AMBIENT_FLOOR, bakeSky, contrast } from './skyTokens.js'; +import { light } from './sun.js'; import { baked } from './baked'; const names = ['midnight', 'noonday'] as const; @@ -18,6 +19,37 @@ describe.each(names)('%s sky', (name) => { expect(name === 'noonday' ? sky.elevation : -sky.elevation).toBeGreaterThan(40); }); + it('carries the sun, an ambient light and a horizon colour for consumers that light a scene', () => { + const { sun } = sky; + expect(Object.keys(sun).sort()).toEqual(['azimuth_deg', 'color_linear', 'elevation_deg']); + expect(sun.elevation_deg).toBe(sky.elevation); + expect(sun.color_linear).toHaveLength(3); + sun.color_linear.forEach((v: number) => { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + }); + expect(sky.ambient_linear).toHaveLength(3); + expect(sky.horizon_hex).toMatch(/^#[0-9a-f]{6}$/); + expect(sky.horizon_hex).toBe(sky.gradient[sky.gradient.length - 1]); + }); + + it('puts the sun where Nibu expects it, azimuth clockwise from north', () => { + const azimuth: number = sky.sun.azimuth_deg; + expect(azimuth).toBeGreaterThanOrEqual(0); + expect(azimuth).toBeLessThan(360); + expect(Math.abs(azimuth - (name === 'noonday' ? 182.6 : 2.5))).toBeLessThan(1); + }); + + it('keeps ambient above the night floor and leaves daylight alone', () => { + sky.ambient_linear.forEach((v: number, i: number) => + expect(v).toBeGreaterThanOrEqual(AMBIENT_FLOOR[i]) + ); + if (name === 'noonday') { + sky.ambient_linear.forEach((v: number) => expect(v).toBeGreaterThan(0.05)); + expect(sky.ambient_linear).toEqual(light(sky.moment).diffuse); + } + }); + it('clears WCAG AA for text on every panel state', () => { ['Background', 'Surface', 'SurfaceVariant'].forEach((p) => states.forEach((s) => diff --git a/src/sky/sun.js b/src/sky/sun.js index d66f0f9a..55cad1ac 100644 --- a/src/sky/sun.js +++ b/src/sky/sun.js @@ -2,13 +2,14 @@ // no imports, so a build-time token script and a runtime shader can share it. // // light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) -// -> { elevation, direct: [r,g,b], diffuse: [r,g,b], wetness } +// -> { elevation, azimuth, direct: [r,g,b], diffuse: [r,g,b], wetness } // // time Date, epoch milliseconds, or an ISO string (read as UTC) // lat, lon degrees, north and east positive // cloud_cover 0..1 (Open-Meteo reports percent: divide by 100) // precipitation mm/h // elevation degrees above the horizon, no refraction +// azimuth degrees clockwise from north (0 N, 90 E, 180 S), 0..360, no refraction // direct sun colour at normal incidence, linear RGB. Clear zenith sun ~ 1. // The consumer multiplies by max(0, dot(normal, toSun)). // diffuse sky ambient on an upward surface, linear RGB @@ -28,8 +29,8 @@ const mix = (a, b, t) => a + (b - a) * t; const toMs = (time) => (time instanceof Date ? time.getTime() : typeof time === 'string' ? Date.parse(time) : Number(time)); -/** Sun elevation in degrees at a UTC moment and place. */ -export function sunElevation(time, lat, lon) { +/** Sun elevation and azimuth in degrees at a UTC moment and place. Azimuth runs clockwise from north. */ +export function sunPosition(time, lat, lon) { const d = toMs(time) / 86400000 - 10957.5; // days since J2000.0 (2000-01-01 12:00 UTC) const L = (280.46 + 0.9856474 * d) * RAD; // mean longitude const g = (357.528 + 0.9856003 * d) * RAD; // mean anomaly @@ -41,9 +42,18 @@ export function sunElevation(time, lat, lon) { const hourAngle = gmst + lon * RAD - ra; const phi = lat * RAD; const sinEl = Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(hourAngle); - return Math.asin(clamp(sinEl, -1, 1)) / RAD; + // Hour angle is westward-positive, so atan2 gives azimuth from south, westward; +180 turns it into + // clockwise from north. + const south = Math.atan2(Math.sin(hourAngle), Math.cos(hourAngle) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)); + return { + elevation: Math.asin(clamp(sinEl, -1, 1)) / RAD, + azimuth: (((south / RAD + 180) % 360) + 360) % 360, + }; } +/** Sun elevation in degrees at a UTC moment and place. */ +export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elevation; + // Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). // Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep // low sun orange rather than brown). @@ -62,7 +72,7 @@ const GREY_TINT = [0.95, 1, 1.08]; // cool overcast. EYEBALLED const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { - const elevation = sunElevation(time, lat, lon); + const { elevation, azimuth } = sunPosition(time, lat, lon); const cloud = clamp(cloud_cover, 0, 1); const rain = Math.max(0, precipitation); @@ -95,5 +105,5 @@ export function light({ time, lat, lon, cloud_cover = 0, precipitation = 0 }) { // 0 dry, ~0.63 at 1 mm/h, ~0.98 at 4 mm/h. EYEBALLED. const wetness = 1 - Math.exp(-rain); - return { elevation, direct, diffuse, wetness }; + return { elevation, azimuth, direct, diffuse, wetness }; } -- 2.47.3 From 4afd37e7dde785e2a6a8663930a72c39d204cc2a Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:25:02 +0000 Subject: [PATCH 07/24] preview: hash router and base-relative manifest link Previews are static dirs with no SPA fallback; a reload or shared link on /login/matrix.org was an nginx 404. preview-config now enables the hash router, and index.html's manifest link follows the base path. Co-Authored-By: Claude Sonnet 5.5 --- index.html | 2 +- scripts/preview-config.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 7242f309..ec7f4e39 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ - + diff --git a/scripts/preview-config.js b/scripts/preview-config.js index 75746f98..26f55b06 100644 --- a/scripts/preview-config.js +++ b/scripts/preview-config.js @@ -1,5 +1,6 @@ // Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json -// in the repo stays 'off'. Usage: node scripts/preview-config.js [tier] [style] +// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch +// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. Usage: node scripts/preview-config.js [tier] [style] import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -8,6 +9,7 @@ const tier = process.argv[2] ?? 'low'; const style = process.argv[3]; const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); const config = JSON.parse(fs.readFileSync(file, 'utf8')); +config.hashRouter = { enabled: true, basename: '/' }; config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) }; fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); console.log(`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}`); -- 2.47.3 From 19c1f21610e659da126f34af22b7fbdf5a4b1f0f Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:43:34 +0000 Subject: [PATCH 08/24] scene: terrainNormal reads the world-z slope from .y, not .z The noise is evaluated at vec3(xz * f, c), so .z is the slope along the held constant axis. Confirmed numerically (harness/scene/gradAxis.cjs): analytic .xy matches central differences for fbm and phacelle, .z does not. Only the ray-march scene's normals were affected; the grid tier already used .xy. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 1 + harness/scene/gradAxis.cjs | 30 +++++++++++++++++++++++++ src/app/features/scene/shaders/sdf.glsl | 4 ++-- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 harness/scene/gradAxis.cjs diff --git a/harness/scene/README.md b/harness/scene/README.md index ef735d2e..624d4b0b 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -6,6 +6,7 @@ Chromium. Not shipped, not run by CI. node bundle.cjs # esbuild bundle of entry.ts plus an index.html node drive.cjs compile # compile and link each stage, JSON report node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node gradAxis.cjs # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms diff --git a/harness/scene/gradAxis.cjs b/harness/scene/gradAxis.cjs new file mode 100644 index 00000000..48b96fa6 --- /dev/null +++ b/harness/scene/gradAxis.cjs @@ -0,0 +1,30 @@ +// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value), +// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point. +// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs /src/app/features/scene/shaders +const { chromium } = require('playwright'); const fs=require('fs'); +const S=process.argv[2]; +const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8'); +const fs_=`#version 300 es +precision highp float; precision highp int; +${cm} +${ph} +out vec4 o; +const float F=1.0/520.0; +float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;} +float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;} +void main(){ + vec2 p=vec2(1234.5,-777.7); float e=0.5; + int k=int(gl_FragCoord.x); + if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z + else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);} + else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);} + else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);} +}`; +(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']}); +const pg=await b.newPage(); +const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float'); +const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x}; +const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p); +const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3); +const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500)); +console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})(); diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl index 95e96bc2..fd5e6363 100644 --- a/src/app/features/scene/shaders/sdf.glsl +++ b/src/app/features/scene/shaders/sdf.glsl @@ -44,10 +44,10 @@ vec3 terrainNormal(vec2 xz) vec2 dir = r > 0.0 ? xz / r : vec2(0.0); vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; + vec2 g = m * W_AMP * W_FREQ * fg.xy + dm * W_AMP * f * dir; #if W_ERODE_ENABLED CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xy + dm * W_ERODE_AMP * ph.cos.w * dir; #endif return normalize(vec3(-g.x, 1.0, -g.y)); } -- 2.47.3 From 931f84fcd1aa7b82978394b2a725bf01fbdb5f3a Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:44:54 +0000 Subject: [PATCH 09/24] scene: procedural terrain albedo by normal and height, per baked sky Rock on steep faces, soil to scree to snow by height, snow only on gentle faces; no texture reads. Pure albedoAt()/albedoPalette() mirror the grid fragment shader; the palette is read from the baked sky (snow line and tint by sun elevation: cool haze at night, warm sun by day). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/Terrain.ts | 7 ++ .../scene/terrain/terrainAlbedo.test.ts | 103 ++++++++++++++++++ .../features/scene/terrain/terrainAlbedo.ts | 98 +++++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 31 ++++-- 4 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 src/app/features/scene/terrain/terrainAlbedo.test.ts create mode 100644 src/app/features/scene/terrain/terrainAlbedo.ts diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 2a81aa56..6f725b95 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -2,6 +2,7 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { skyColours } from '../ridge/skyColour'; import { SkyName } from './skyData'; +import { albedoPalette } from './terrainAlbedo'; import { ambientColour, hazeColour, @@ -170,6 +171,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const sunLight = [...sunColour(sky)]; const ambient = [...ambientColour(sky)]; const haze = [...hazeColour(sky)]; + const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); @@ -218,6 +220,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uHaze'), haze); gl.uniform1f(u('uHazeK'), HAZE_K); + gl.uniform3fv(u('uSoil'), palette.soil); + gl.uniform3fv(u('uScree'), palette.scree); + gl.uniform3fv(u('uRock'), palette.rock); + gl.uniform3fv(u('uSnow'), palette.snow); + gl.uniform1f(u('uSnowLine'), palette.snowLine); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.uniform1i(u('uHeight'), 0); diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts new file mode 100644 index 00000000..c637770d --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -0,0 +1,103 @@ +/* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ +import { describe, expect, it } from 'vitest'; +import { SKY } from './skyData'; +import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; +import { GRID_FRAG } from './terrainShaders'; + +const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => + a.every((v, i) => Math.abs(v - b[i]) < eps); +const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +// A point where the wobble is zero, so band edges sit exactly where the palette says. +const FLAT_X = Math.PI / 0.043; +const at = (sky: keyof typeof SKY, h: number, ny: number) => + albedoAt(albedoPalette(SKY[sky]), h, ny, FLAT_X, 0); + +describe('albedo by slope and height', () => { + it('the test point really has no wobble', () => { + expect(Math.abs(wobble(FLAT_X, 0))).toBeLessThan(1e-9); + }); + + it('steep faces are rock at every height, gentle low ones are soil, gentle high ones are snow', () => { + const p = albedoPalette(SKY.noonday); + for (const h of [10, 90, 160, 220]) expect(near(at('noonday', h, 0.3), p.rock)).toBe(true); + expect(near(at('noonday', 10, 1), p.soil)).toBe(true); + expect(near(at('noonday', 220, 1), p.snow)).toBe(true); + }); + + it('scree sits between soil and the snow line on gentle ground', () => { + const p = albedoPalette(SKY.noonday); + expect(near(at('noonday', ALBEDO.screeTo + 1, 1), p.scree)).toBe(true); + }); + + it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { + const l = [1, 0.8, 0.5].map((ny) => luma(at('noonday', 220, ny))); + expect(l[0]).toBeGreaterThan(l[1]); + expect(l[1]).toBeGreaterThan(l[2]); + }); + + it('is continuous: a small step in height or slope never jumps a channel by more than a tenth', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = 0; h < 250; h += 1) + for (let ny = 0.3; ny <= 1; ny += 0.05) { + const a = at(sky, h, ny); + const b = at(sky, h + 1, ny + 0.01); + a.forEach((v, i) => expect(Math.abs(v - b[i])).toBeLessThan(0.1)); + } + }); + + it('never returns a negative or a super-white channel', () => { + for (const sky of ['midnight', 'noonday'] as const) + for (let h = -20; h < 300; h += 7) + for (let ny = 0; ny <= 1; ny += 0.1) + for (const v of albedoAt(albedoPalette(SKY[sky]), h, ny, h * 3, ny * 91)) { + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThanOrEqual(1); + } + }); + + it('the wobble moves band edges but stays inside its stated amplitude', () => { + for (let i = 0; i < 400; i += 1) + expect(Math.abs(wobble(i * 13.7, i * -9.1))).toBeLessThanOrEqual(1); + }); +}); + +describe('per-sky palette', () => { + const mid = albedoPalette(SKY.midnight); + const noon = albedoPalette(SKY.noonday); + + it('midnight snows lower than noonday', () => { + expect(mid.snowLine).toBeLessThan(noon.snowLine); + }); + + it('midnight is tinted toward the cool horizon haze, noonday toward the warm sun', () => { + // blue share of the same material is higher at night + const blueShare = (c: readonly number[]) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(mid.scree)).toBeGreaterThan(blueShare(noon.scree)); + expect(blueShare(mid.snow)).toBeGreaterThan(blueShare(noon.snow)); + }); + + it('a sun at the horizon lands between the two palettes', () => { + const dusk = albedoPalette({ ...SKY.noonday, elevation: 2 }); + expect(dusk.snowLine).toBeLessThan(noon.snowLine); + expect(dusk.snowLine).toBeGreaterThan(mid.snowLine); + }); + + it('keeps every material in 0..1', () => { + for (const p of [mid, noon]) + for (const c of [p.soil, p.scree, p.rock, p.snow]) + c.forEach((v) => { + expect(v).toBeGreaterThan(0); + expect(v).toBeLessThanOrEqual(1); + }); + }); +}); + +describe('shader agrees with the pure function on its numbers', () => { + it('takes every band number from ALBEDO, and reads no texture for colour', () => { + expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); + expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); + expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); + // the only fetch in the fragment shader is the height map's + expect(GRID_FRAG.match(/texelFetch|texture\(/g)).toHaveLength(1); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts new file mode 100644 index 00000000..237f5338 --- /dev/null +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -0,0 +1,98 @@ +// Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader +// (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. +// Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky +// part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour +// as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. +import { SkyData, Vec3 } from './skyData'; +import { hazeColour, sunColour } from './terrainSky'; + +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const mix = (a: number, b: number, t: number) => a + (b - a) * t; + +/** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ +export const ALBEDO = { + /** soil fades to scree across these heights. */ + screeFrom: 55, + screeTo: 115, + /** half-width in metres of the snow line's fade. */ + snowBand: 30, + /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ + rockLo: 0.62, + rockHi: 0.88, + /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ + snowLo: 0.68, + snowHi: 0.9, + /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ + wobbleHeight: 14, + wobbleSlope: 0.05, +} as const; + +export interface AlbedoPalette { + soil: Vec3; + scree: Vec3; + rock: Vec3; + snow: Vec3; + /** Metres where snow is half in. */ + snowLine: number; +} + +const BASE = { + soil: [0.055, 0.06, 0.03], + scree: [0.2, 0.18, 0.155], + rock: [0.1, 0.09, 0.085], + snow: [0.72, 0.75, 0.8], +} as const; + +const SNOW_LINE_DAY = 150; +const SNOW_LINE_NIGHT = 105; + +const unit = (c: Vec3): Vec3 => { + const m = Math.max(c[0], c[1], c[2], 1e-6); + return [c[0] / m, c[1] / m, c[2] / m]; +}; + +/** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ +export function albedoPalette(d: SkyData): AlbedoPalette { + const cold = 1 - smooth(-6, 25, d.elevation); + const sun = unit(sunColour(d)); + const haze = unit(hazeColour(d)); + const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); + const k = mix(0.15, 0.5, cold); + const tinted = (c: Vec3): Vec3 => + [0, 1, 2].map((i) => c[i] * mix(1, tint[i], k)) as unknown as Vec3; + return { + soil: tinted(BASE.soil), + scree: tinted(BASE.scree), + rock: tinted(BASE.rock), + snow: tinted(BASE.snow), + snowLine: mix(SNOW_LINE_DAY, SNOW_LINE_NIGHT, cold), + }; +} + +/** Cheap two-sine value in -1..1 from world position; stands in for noise so no texture or hash is read. */ +export const wobble = (x: number, z: number): number => + Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); + +/** + * Linear albedo at a surface point. `height` in metres, `ny` the unit normal's y, `x`/`z` world metres. + * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow + * line, thinning to nothing as the face steepens. + */ +export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number, z: number): Vec3 { + const w = wobble(x, z); + const h = height + ALBEDO.wobbleHeight * w; + const flat = ny + ALBEDO.wobbleSlope * w; + const ground = (i: number) => + mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); + const snow = + smooth(p.snowLine - ALBEDO.snowBand, p.snowLine + ALBEDO.snowBand, h) * + smooth(ALBEDO.snowLo, ALBEDO.snowHi, flat); + const rock = 1 - smooth(ALBEDO.rockLo, ALBEDO.rockHi, flat); + return [0, 1, 2].map((i) => + mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) + ) as unknown as Vec3; +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 1eff9c93..0e1783e7 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,11 +7,13 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; +import { ALBEDO } from './terrainAlbedo'; import { TERRAIN } from './terrainWorld'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; const f1 = (x: number) => x.toFixed(1); +const lit = (x: number) => (Number.isInteger(x) ? x.toFixed(1) : String(x)); export const FULLSCREEN_VERT = `${HEADER} void main() { @@ -107,11 +109,22 @@ void main() { // Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). // The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. -export const GRID_FRAG = `${HEADER} +const ALBEDO_DEFINES = ` +#define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} +#define ALB_SCREE_TO ${lit(ALBEDO.screeTo)} +#define ALB_SNOW_BAND ${lit(ALBEDO.snowBand)} +#define ALB_ROCK_HI ${lit(ALBEDO.rockHi)} +#define ALB_ROCK_LO ${lit(ALBEDO.rockLo)} +#define ALB_SNOW_HI ${lit(ALBEDO.snowHi)} +#define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} +#define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} +#define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +`; +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze; -uniform float uSpan, uHazeK; +uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze, uSoil, uScree, uRock, uSnow; +uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} // Unnormalised normal at one texel, central differences over its neighbours. @@ -129,10 +142,14 @@ void main() { vec3 n = normalize(mix( mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); - float alt = clamp(v_world.y / 200.0, 0.0, 1.0); - vec3 rock = mix(vec3(0.07, 0.06, 0.05), vec3(0.28, 0.26, 0.22), alt); - vec3 grass = mix(vec3(0.03, 0.07, 0.025), vec3(0.14, 0.15, 0.07), alt); - vec3 albedo = mix(rock, grass, smoothstep(0.55, 0.9, n.y)); + // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). + float w = sin(v_world.x * 0.043 + v_world.z * 0.017) * sin(v_world.z * 0.051 - v_world.x * 0.023); + float h = v_world.y + ALB_WOBBLE_H * w; + float flat_ = n.y + ALB_WOBBLE_S * w; + vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); + float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); + float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); + vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); float T = exp(-uHazeK * length(v_world - uCamera)); o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); -- 2.47.3 From da12cb87085f207ee81dc3b6f724792213de4a5e Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:54:25 +0000 Subject: [PATCH 10/24] scene: tune terrain albedo bands, lighter haze, night ambient lift Bands moved so snow caps only the peaks and soil/scree/rock read; haze 0.0007 -> 0.0004 so the albedo is not washed out; ambient x4 at night so a moonlit face is readable on a phone. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 5 ++-- .../features/scene/terrain/terrainAlbedo.ts | 28 +++++++++---------- .../features/scene/terrain/terrainSky.test.ts | 10 +++++++ src/app/features/scene/terrain/terrainSky.ts | 12 ++++++-- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index c637770d..331b820a 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -24,9 +24,10 @@ describe('albedo by slope and height', () => { expect(near(at('noonday', 220, 1), p.snow)).toBe(true); }); - it('scree sits between soil and the snow line on gentle ground', () => { + it('soil grades to scree by height on gentle ground, halfway at the middle of the band', () => { const p = albedoPalette(SKY.noonday); - expect(near(at('noonday', ALBEDO.screeTo + 1, 1), p.scree)).toBe(true); + const half = p.soil.map((v, i) => (v + p.scree[i]) / 2); + expect(near(at('noonday', (ALBEDO.screeFrom + ALBEDO.screeTo) / 2, 1), half)).toBe(true); }); it('snow is shed as the face steepens: the same height goes snow, then mixed, then rock', () => { diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 237f5338..703f9f0c 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -4,7 +4,7 @@ // part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour // as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. import { SkyData, Vec3 } from './skyData'; -import { hazeColour, sunColour } from './terrainSky'; +import { coldness, hazeColour, sunColour } from './terrainSky'; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); const smooth = (a: number, b: number, x: number) => { @@ -16,16 +16,16 @@ const mix = (a: number, b: number, t: number) => a + (b - a) * t; /** Every number the shader shares: band edges in metres, slope edges as the normal's y (cosine of the slope angle). */ export const ALBEDO = { /** soil fades to scree across these heights. */ - screeFrom: 55, - screeTo: 115, + screeFrom: 80, + screeTo: 160, /** half-width in metres of the snow line's fade. */ - snowBand: 30, + snowBand: 22, /** n.y edges: faces with n.y below `rockLo` are all rock, above `rockHi` none. */ - rockLo: 0.62, - rockHi: 0.88, + rockLo: 0.78, + rockHi: 0.95, /** n.y edges: snow is shed entirely below `snowLo` and settles fully above `snowHi`. */ - snowLo: 0.68, - snowHi: 0.9, + snowLo: 0.74, + snowHi: 0.93, /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ wobbleHeight: 14, wobbleSlope: 0.05, @@ -41,14 +41,14 @@ export interface AlbedoPalette { } const BASE = { - soil: [0.055, 0.06, 0.03], - scree: [0.2, 0.18, 0.155], - rock: [0.1, 0.09, 0.085], + soil: [0.045, 0.08, 0.02], + scree: [0.3, 0.235, 0.16], + rock: [0.075, 0.062, 0.06], snow: [0.72, 0.75, 0.8], } as const; -const SNOW_LINE_DAY = 150; -const SNOW_LINE_NIGHT = 105; +const SNOW_LINE_DAY = 165; +const SNOW_LINE_NIGHT = 135; const unit = (c: Vec3): Vec3 => { const m = Math.max(c[0], c[1], c[2], 1e-6); @@ -57,7 +57,7 @@ const unit = (c: Vec3): Vec3 => { /** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ export function albedoPalette(d: SkyData): AlbedoPalette { - const cold = 1 - smooth(-6, 25, d.elevation); + const cold = coldness(d); const sun = unit(sunColour(d)); const haze = unit(hazeColour(d)); const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 44b77f36..b3b4d21e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { SKY } from './skyData'; import { + AMBIENT_GAIN, + NIGHT_LIFT, ambientColour, hazeColour, hexToLinear, @@ -46,6 +48,14 @@ describe('baked sky numbers', () => { }); }); +describe('night fill', () => { + it('lifts ambient at night by NIGHT_LIFT and leaves a high sun at the plain gain', () => { + const z = (d: typeof SKY.midnight) => hexToLinear(d.gradient[0]); + ambientColour(SKY.midnight).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.midnight)[i] * AMBIENT_GAIN * NIGHT_LIFT, 9)); + ambientColour(SKY.noonday).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.noonday)[i] * AMBIENT_GAIN, 9)); + }); +}); + describe('lighting', () => { it('noonday lights an upward face with the sun; midnight has none', () => { expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index d6c699c3..d0f41c40 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -47,16 +47,24 @@ export function sunColour(d: SkyData): Vec3 { * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. */ export const AMBIENT_GAIN = 0.25; + +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette and fill share. */ +export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); + +/** Extra ambient at night, so a moonlit face is readable and not black on a phone: x4 at full night, 1 by day. */ +export const NIGHT_LIFT = 4; + export function ambientColour(d: SkyData): Vec3 { const z = hexToLinear(d.gradient[0]); - return [z[0] * AMBIENT_GAIN, z[1] * AMBIENT_GAIN, z[2] * AMBIENT_GAIN]; + const k = AMBIENT_GAIN * (1 + (NIGHT_LIFT - 1) * coldness(d)); + return [z[0] * k, z[1] * k, z[2] * k]; } /** The colour distance fades to: the gradient's horizon stop, linear. */ export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); /** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0007; +export const HAZE_K = 0.0004; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); -- 2.47.3 From 054c584ebc90337b6f771cda9a6af8f27eff81bc Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 20:55:44 +0000 Subject: [PATCH 11/24] scene: stars in the terrain scene, a 2D overlay above the horizon on dark skies Reuses ridge/stars.ts (placement, tint, rolling 32-star twinkle) projected with the terrain camera onto an overlay cut to the sky; stops with the terrain loop. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 84 +++++++ src/app/features/scene/terrain/Terrain.ts | 5 + .../scene/terrain/terrainStars.test.ts | 207 ++++++++++++++++++ .../features/scene/terrain/terrainStars.ts | 157 +++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 src/app/features/scene/terrain/terrainStars.test.ts create mode 100644 src/app/features/scene/terrain/terrainStars.ts diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 8338721e..4578a58a 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -233,3 +233,87 @@ describe("startBackgroundScene style 'terrain'", () => { expect(c2.getContext).not.toHaveBeenCalledWith('webgl2', expect.anything()); }); }); + +describe('stars in the terrain scene', () => { + let overlays: any[]; + let arcs: number; + const withOverlays = () => { + overlays = []; + arcs = 0; + (document as any).createElement = () => { + const el: any = { + style: {}, + remove: vi.fn(), + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => (arcs += 1), + }), + }; + overlays.push(el); + return el; + }; + }; + const scene = (sky: 'midnight' | 'noonday', tier: 'still' | 'low' = 'low') => { + withOverlays(); + const canvas = canvasWith(fakeGl().gl); + const s = createTerrainScene({ canvas, tier, sky })!; + return { s, canvas }; + }; + + it('a midnight sky gets one star overlay beside the canvas, painted with the terrain', () => { + const { s, canvas } = scene('midnight'); + expect(overlays).toHaveLength(1); + expect(canvas.parentElement.appendChild).toHaveBeenCalledWith(overlays[0]); + rafQueue.shift()!(16); + expect(arcs).toBeGreaterThan(0); + s.dispose(); + expect(overlays[0].remove).toHaveBeenCalledOnce(); + }); + + it('a noonday sky gets no overlay and paints no stars', () => { + const { s } = scene('noonday'); + expect(overlays).toHaveLength(0); + expect(arcs).toBe(0); + s.dispose(); + }); + + it("'still' paints the stars once at base and queues nothing", () => { + const { s } = scene('midnight', 'still'); + expect(arcs).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); + s.dispose(); + }); + + it('a hidden tab or reduced motion paints no more stars than the first frame', () => { + visibility = 'hidden'; + const { s } = scene('midnight'); + const n = arcs; + expect(n).toBeGreaterThan(0); + expect(rafQueue).toHaveLength(0); // the terrain loop never starts, so nothing repaints the stars + s.dispose(); + visibility = 'visible'; + (window as any).matchMedia = () => ({ + matches: true, + addEventListener() {}, + removeEventListener() {}, + }); + const r = scene('midnight'); + expect(rafQueue).toHaveLength(0); + expect(arcs).toBe(n); + r.s.dispose(); + }); + + it('stars turn with the terrain: a later frame is painted at a later heading', () => { + let clock = 0; + vi.stubGlobal('performance', { now: () => (clock += 1000) }); + const { s } = scene('midnight'); + rafQueue.shift()!(16); + const first = arcs; + rafQueue.shift()!(1000); + expect(arcs).toBeGreaterThan(first); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 2a81aa56..94fd554a 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -21,6 +21,7 @@ import { heightFragmentSource, SKY_FRAG, } from './terrainShaders'; +import { createTerrainStars } from './terrainStars'; export type TerrainOptions = Pick< SceneOptions, @@ -172,6 +173,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const haze = [...hazeColour(sky)]; const triangles = triangleCount(spec.grid); + const stars = createTerrainStars(canvas, sky); const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); const sampler = new FrameSampler(); let still = tier === 'still'; @@ -224,6 +226,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.bindVertexArray(vao); gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); gl.bindVertexArray(null); + stars?.draw(turns % 1, moving() ? performance.now() / 1000 : undefined); }; const moving = () => @@ -331,6 +334,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { resizeTimer = window.setTimeout(() => { if (disposed || lost) return; size(); + stars?.resize(); draw(turnsNow(performance.now())); sampler.reset(); }, 250); @@ -353,6 +357,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { reducedMotion?.removeEventListener?.('change', onVisibility); canvas.removeEventListener('webglcontextlost', onLost); readout?.remove(); + stars?.dispose(); release(); }, }; diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts new file mode 100644 index 00000000..40020186 --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SKY } from './skyData'; +import { + createTerrainStars, + elevationOf, + ELEVATION_TOP, + FRAME_MS, + horizonY, + PERIOD, + project, + skyColoursOf, +} from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; +import { isNight } from '../ridge/stars'; +import { skyNameFor } from './terrainSky'; + +/** A 2D context that only counts: arcs painted and clears since the last reset. */ +function fakeCtx() { + const c = { + arcs: [] as Array<[number, number, number]>, + clears: 0, + alpha: 1, + alphas: [] as number[], + }; + const ctx: any = { + clearRect: () => { + c.clears += 1; + c.arcs = []; + c.alphas = []; + }, + beginPath() {}, + fill() { + c.alphas.push(ctx.globalAlpha); + }, + arc: (x: number, y: number, r: number) => c.arcs.push([x, y, r]), + globalAlpha: 1, + fillStyle: '', + }; + return { c, ctx }; +} + +let overlay: any; +let ctx2d: ReturnType; +let parent: any; +beforeEach(() => { + ctx2d = fakeCtx(); + overlay = { + style: {}, + width: 0, + height: 0, + setAttribute: vi.fn(), + getContext: vi.fn(() => ctx2d.ctx), + remove: vi.fn(), + }; + parent = { appendChild: vi.fn() }; + vi.stubGlobal('document', { createElement: () => overlay }); + vi.stubGlobal('window', { devicePixelRatio: 1, innerWidth: 390, innerHeight: 844 }); +}); +afterEach(() => vi.unstubAllGlobals()); + +const glCanvas = (w = 390, h = 844) => + ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); + +describe('which skies get stars', () => { + it("midnight is dark, noonday is not, by the ridge module's own test", () => { + expect(isNight(skyColoursOf(SKY.midnight))).toBe(true); + expect(isNight(skyColoursOf(SKY.noonday))).toBe(false); + expect(skyNameFor(skyColoursOf(SKY.noonday))).toBe('noonday'); + }); + + it('noonday gets no layer and no element', () => { + expect(createTerrainStars(glCanvas(), SKY.noonday)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); + + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + expect(parent.appendChild).toHaveBeenCalledWith(overlay); + s.dispose(); + expect(overlay.remove).toHaveBeenCalledOnce(); + }); + + it('is absent without a parent or a 2D context', () => { + expect( + createTerrainStars({ ...glCanvas(), parentElement: null } as any, SKY.midnight) + ).toBeNull(); + overlay.getContext = () => null; + expect(createTerrainStars(glCanvas(), SKY.midnight)).toBeNull(); + expect(parent.appendChild).not.toHaveBeenCalled(); + }); +}); + +describe('sky only', () => { + it('the overlay is as tall as the horizon line is low, never the whole screen', () => { + createTerrainStars(glCanvas(), SKY.midnight); + expect(overlay.height).toBe(Math.ceil(horizonY(844))); + expect(overlay.height).toBeLessThan(844 / 2); + expect(overlay.style.pointerEvents).toBe('none'); + }); + + it('a star at the horizon lands on the horizon line, one above it lands above', () => { + for (const aspect of [0.46, 1.78]) { + const h = 800; + const w = h * aspect; + const onLine = project(0.2, 0, w, h)!; + expect(onLine.y).toBeCloseTo(horizonY(h), 5); + expect(project(0.2, 0.05, w, h)!.y).toBeLessThan(horizonY(h)); + } + }); + + it('the camera eye clears the peaks, so the horizon line bounds the terrain', () => { + expect(CAMERA.eye).toBeGreaterThan(256); + }); + + it('every painted star is above the horizon line, at any heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + for (let k = 0; k < 40; k += 1) { + s.draw(k / 40); + for (const [, y] of ctx2d.c.arcs) expect(y).toBeLessThanOrEqual(overlay.height); + } + }); + + it('agrees with the camera the terrain draws: the projection matches view()', () => { + const w = 390; + const h = 844; + const turns = 0.137; + const v = view(w / h, turns); + const yaw = 2 * Math.PI * turns; + const az = yaw + 0.1; + const e = 0.15; + const d = [Math.sin(az) * Math.cos(e), Math.sin(e), -Math.cos(az) * Math.cos(e)]; + const dot = (a: number[], b: readonly number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const t = Math.tan(CAMERA.fov / 2); + const f = dot(d, v.forward); + const nx = dot(d, v.right) / f / (t * (w / h)); + const ny = dot(d, v.up) / f / t; + const at = project(0.1, e, w, h)!; + expect(at.x).toBeCloseTo(((nx + 1) / 2) * w, 6); + expect(at.y).toBeCloseTo(((1 - ny) / 2) * h, 6); + }); +}); + +describe('turning with the camera', () => { + it('stars are on screen, and slide sideways as the camera turns', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0); + const first = ctx2d.c.arcs.slice(); + expect(first.length).toBeGreaterThan(10); + s.draw(0.002); + const later = ctx2d.c.arcs; + expect(later).not.toEqual(first); + const m = (a: number[][]) => a.reduce((t, p) => t + p[0], 0) / a.length; + expect(m(later)).not.toBeCloseTo(m(first), 1); + }); + + it('repeats every PERIOD of heading', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.1); + const a = ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3))); + s.draw(0.1 + PERIOD / (2 * Math.PI)); + expect(ctx2d.c.arcs.map((p) => p.map((n) => Math.round(n * 1e3)))).toEqual(a); + }); + + it('the field fills the screen top and thins toward the horizon, inside the elevation band', () => { + expect(elevationOf(0)).toBeCloseTo(ELEVATION_TOP, 9); + expect(elevationOf(0.78)).toBeCloseTo(0, 9); + }); +}); + +describe('motion', () => { + it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const alphas = ctx2d.c.alphas.slice(); + s.draw(0.3); + expect(ctx2d.c.alphas).toEqual(alphas); + }); + + it('with the clock the twinklers swing away from base, and the repaint is throttled', () => { + const s = createTerrainStars(glCanvas(), SKY.midnight)!; + s.draw(0.3); + const still = ctx2d.c.alphas.slice(); + let moved = false; + for (let t = 1; t < 60 && !moved; t += 1.7) { + s.draw(0.3, t); + moved = ctx2d.c.alphas.some((a, i) => a !== still[i]); + } + expect(moved).toBe(true); + const clears = ctx2d.c.clears; + s.draw(0.3, 100); + s.draw(0.3, 100 + FRAME_MS / 4000); + expect(ctx2d.c.clears).toBe(clears + 1); + }); + + it('resize refits the canvas and the next draw repaints at once', () => { + const c = glCanvas(); + const s = createTerrainStars(c, SKY.midnight)!; + s.draw(0.1, 5); + c.clientHeight = 400; + s.resize(); + expect(overlay.height).toBe(Math.ceil(horizonY(400))); + const clears = ctx2d.c.clears; + s.draw(0.1, 5.001); + expect(ctx2d.c.clears).toBe(clears + 1); + }); +}); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts new file mode 100644 index 00000000..e568418f --- /dev/null +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -0,0 +1,157 @@ +// The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, shown only when the baked sky is dark. +// +// The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the +// horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak +// (TERRAIN.hmax), so terrain never rises above that line and a star above the horizon is never over terrain. +// +// Stars turn with the camera, as sky does: the ridge field's x becomes azimuth, repeated every PERIOD (so 400 +// stars fill a sky that only ever shows 55 deg to ~120 deg of it), and y becomes elevation from the horizon +// up to ELEVATION_TOP, the top of the screen. Placement is projected with the terrain's own camera (CAMERA, +// the yaw the terrain draws at), nothing is baked, and the whole overlay is redrawn at most every FRAME_MS: a +// visible star is a few arcs on a canvas a quarter of the screen tall. The stars stop when the terrain stops: +// the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) +// paints every star at base brightness. + +import type { SkyColours, Srgb } from '../ridge/skyColour'; +import { activeAt, isNight, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './skyData'; +import { CAMERA } from './terrainWorld'; + +/** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ +export const PERIOD = (2 * Math.PI) / 3; +/** Elevation of the field's top, radians: a little above the top edge of the screen at the camera's pitch. */ +export const ELEVATION_TOP = (16 * Math.PI) / 180; +/** The overlay repaints at most this often while the terrain turns (12 px/s on a phone: under half a pixel a step). */ +export const FRAME_MS = 33; +/** The twinkle schedule is re-read this often; the swings are 2 to 7 s. */ +const TWINKLE_MS = 50; + +const hex = (h: string): Srgb => + [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; + +/** The ridge star module's view of a baked terrain sky: zenith, the middle stop, and the horizon stop. */ +export function skyColoursOf(d: SkyData): SkyColours { + const g = d.gradient; + return { + top: hex(g[0]), + mid: hex(g[Math.floor(g.length / 2)]), + horizon: hex(g[g.length - 1]), + ground: [0, 0, 0], + }; +} + +/** Screen height in px below which there is no sky: where the horizon plane meets the image, a straight line. */ +export const horizonY = (height: number): number => + height * (0.5 - Math.tan(CAMERA.pitch) / Math.tan(CAMERA.fov / 2) / 2); + +export interface Placed { + x: number; + y: number; +} + +/** + * Where a star at azimuth `da` from the camera's heading and elevation `e` (radians) lands in a `w` x `h` px + * viewport, or null if it is behind or off the screen. The same camera as `view()` in terrainWorld.ts + * (forward, right and up are its basis written out for a camera with yaw and pitch only). + */ +export function project(da: number, e: number, w: number, h: number): Placed | null { + const p = CAMERA.pitch; + const ce = Math.cos(e); + const front = ce * Math.cos(p) * Math.cos(da) - Math.sin(e) * Math.sin(p); + if (front <= 0.01) return null; + const tanHalf = Math.tan(CAMERA.fov / 2); + const nx = (ce * Math.sin(da)) / front / (tanHalf * (w / h)); + const ny = (ce * Math.sin(p) * Math.cos(da) + Math.sin(e) * Math.cos(p)) / front / tanHalf; + if (Math.abs(nx) > 1.05) return null; + return { x: ((nx + 1) / 2) * w, y: ((1 - ny) / 2) * h }; +} + +/** Elevation in radians for a ridge-field height fraction y (0 zenith side .. SKY_STOPS.horizon at the horizon). */ +export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_STOPS.horizon); + +const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); + +export interface TerrainStars { + /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ + draw(turns: number, seconds?: number): void; + /** Re-fit the overlay to the canvas's current size; the next draw repaints. */ + resize(): void; + dispose(): void; +} + +/** The layer for `canvas`'s parent, or null: a day sky, no parent, no 2D context. */ +export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { + const colours = skyColoursOf(sky); + const parent = canvas.parentElement; + if (!parent || !isNight(colours)) return null; + const el = document.createElement('canvas'); + const ctx = el.getContext?.('2d'); + if (!ctx) return null; + el.setAttribute?.('aria-hidden', 'true'); + Object.assign(el.style, { position: 'absolute', left: '0', top: '0', pointerEvents: 'none' }); + parent.appendChild(el); + + let w = 1; + let h = 1; + let scale = 1; + let field: Star[] = []; + let lit: Array<{ star: Star; az: number; e: number }> = []; + let gains = new Map(); + let gainsAt = -Infinity; + let last = -Infinity; + + const fit = () => { + w = canvas.clientWidth || window.innerWidth; + h = canvas.clientHeight || window.innerHeight; + scale = Math.min(window.devicePixelRatio || 1, 2); + const sky = Math.max(1, Math.ceil(horizonY(h))); + el.width = Math.max(1, Math.round(w * scale)); + el.height = Math.max(1, Math.round(sky * scale)); + el.style.width = `${w}px`; + el.style.height = `${sky}px`; + field = makeStars(colours, w, h); + lit = field.map((star) => ({ star, az: star.x * PERIOD, e: elevationOf(star.y) })); + gains = new Map(); + gainsAt = -Infinity; + }; + + const paint = (turns: number, seconds: number | undefined) => { + ctx.clearRect(0, 0, el.width, el.height); + if (seconds !== undefined && seconds * 1000 - gainsAt >= TWINKLE_MS) { + gains = new Map(activeAt(field.length, seconds).map((a) => [a.star, a.gain])); + gainsAt = seconds * 1000; + } + const yaw = 2 * Math.PI * turns; + lit.forEach(({ star, az, e }, i) => { + const da = wrap(az - yaw); + const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * gain); + ctx.fillStyle = star.fill; + [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { + const at = project(d, e, w, h); + if (!at || at.y < 0 || at.y > el.height / scale) return; + ctx.beginPath(); + ctx.arc(at.x * scale, at.y * scale, star.r * scale, 0, 2 * Math.PI); + ctx.fill(); + }); + }); + ctx.globalAlpha = 1; + }; + + fit(); + return { + draw(turns, seconds) { + if (seconds !== undefined && seconds * 1000 - last < FRAME_MS) return; + last = seconds === undefined ? -Infinity : seconds * 1000; + paint(turns, seconds); + }, + resize() { + fit(); + last = -Infinity; + }, + dispose() { + el.remove(); + }, + }; +} -- 2.47.3 From 0386065f2c87eeabdb6a929b7cc127ca058b89c3 Mon Sep 17 00:00:00 2001 From: Void Date: Sat, 7 Nov 2026 20:57:22 +0000 Subject: [PATCH 12/24] sky: bake an atmospheric-scattering table (half float) for a WebGL2 sky shader scatter.js ray-marches a single-scattering Rayleigh+Mie atmosphere whose vertical optical depth is sun.js's TAU, and bakes two tables into baked.scatter: transmittance to space by view elevation (32x1) and sky radiance by azimuth from the sun x view elevation x sun elevation (7x32x12), RGB half floats, little-endian base64, value x 64. Layout, exact decode and a GLSL sampler are in src/sky/README.md. Tests pin layout, encoding, checksums and physical anchors (zenith transmittance equals exp(-TAU); blue noon, red low sun toward the sun, black below the planet-shadow limit). The per-sky sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear and horizon_hex were already baked in 7216456a; sun.js now exports TAU. Co-Authored-By: Claude Sonnet 5.5 --- scripts/bake-sky.mjs | 12 +- src/sky/README.md | 83 ++++++++++++++ src/sky/baked.ts | 28 +++++ src/sky/scatter.js | 249 ++++++++++++++++++++++++++++++++++++++++ src/sky/scatter.test.ts | 160 ++++++++++++++++++++++++++ src/sky/sun.js | 2 +- 6 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 src/sky/README.md create mode 100644 src/sky/scatter.js create mode 100644 src/sky/scatter.test.ts diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs index 4e9d015a..827110c2 100644 --- a/scripts/bake-sky.mjs +++ b/scripts/bake-sky.mjs @@ -1,10 +1,16 @@ -// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts. -// Run `npm run bake:sky` after changing sun.js, skyTokens.js or a moment. +// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the +// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`. +// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment. import { writeFileSync } from 'node:fs'; import { bakeSky } from '../src/sky/skyTokens.js'; +import { bakeScatter } from '../src/sky/scatter.js'; const out = new URL('../src/sky/baked.ts', import.meta.url); -const baked = { midnight: bakeSky('midnight'), noonday: bakeSky('noonday') }; +const baked = { + midnight: bakeSky('midnight'), + noonday: bakeSky('noonday'), + scatter: bakeScatter(), +}; writeFileSync( out, `// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` + diff --git a/src/sky/README.md b/src/sky/README.md new file mode 100644 index 00000000..7afc80a7 --- /dev/null +++ b/src/sky/README.md @@ -0,0 +1,83 @@ +# Baked sky + +`npm run bake:sky` (`scripts/bake-sky.mjs`) lights two fixed moments with `sun.js`, the single source of +the sun and the air, and writes `baked.ts`. `baked.midnight`, `baked.noonday` and `baked.scatter` are its +three keys. Everything here is linear RGB unless a name says `hex`. + +## Per sky: `baked.midnight`, `baked.noonday` + +| field | meaning | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `moment` | `{ time, lat, lon }` fed to `sun.js` (equinox, 45N, prime meridian; 00:00 and 12:00 UTC) | +| `elevation` | sun elevation in degrees at that moment: -44.6 midnight, +45.2 noonday | +| `sun.elevation_deg` | the same, for scene code; fade stars by this (and see below for twilight) | +| `sun.azimuth_deg` | degrees clockwise from north, 0..360 | +| `sun.color_linear` | direct sun colour at normal incidence, clear zenith ~ 1; multiply by `max(0, dot(n, toSun))` | +| `ambient_linear` | sky ambient on an upward surface, with a night floor of `AMBIENT_FLOOR` (a moonlit skyglow, not black); daylight is `sun.js`'s own `diffuse` | +| `horizon_hex` | colour of the last gradient stop, the horizon | +| `gradient` | 7 sRGB hex stops, zenith to horizon | +| `tokens`, `bubbles`, ... | theme colours (see `skyTokens.js`) | + +The two skies are single moments. To fade stars through twilight, take the elevation from `sun.js` at the +viewer's real time (`sunPosition(time, lat, lon).elevation`) rather than from `baked..sun`, which only +ever says -44.6 or +45.2. The scattering table below goes dark at -12 degrees and below (the planet's shadow +covers every sunlit air path), so a star fade between about -6 and -12 degrees follows it. + +## `baked.scatter`: atmospheric scattering for a WebGL2 shader + +A Hillaire-style (2020) single-scattering atmosphere, reduced to two small tables. The air is `sun.js`'s: +`TAU` (its zenith optical depth per channel) is the vertical optical depth here, split into a grey aerosol +part (0.02) and a Rayleigh part, so the transmittance table equals `exp(-TAU)` at the zenith. Planet radius +6360 km, atmosphere 100 km, scale heights 8 km (Rayleigh) and 1.2 km (Mie, g = 0.8, albedo 0.9), observer +100 m above the ground. Single scattering only: no ground bounce, no multiple scattering, no sun disc +(the consumer draws it from `sun.color_linear`). That makes dusk a little darker than the real thing. + +Constants: `planet_radius_km`, `atmosphere_height_km`, `observer_altitude_km`, `extinction_per_km` (RGB at the +observer, for aerial perspective: `exp(-k * distance_km)`). + +### Encoding (both tables) + +IEEE 754 half floats (16 bit), little-endian, base64 of the raw bytes, no header. The stored value is +`value * store_scale` (`store_scale` = 64; it lifts the dark end of twilight clear of the half-float +subnormals). Exact decode: for a 16-bit word `h`, `e = (h >> 10) & 31`, `m = h & 1023`, +`half = e == 0 ? m * 2^-24 : (1 + m / 1024) * 2^(e - 15)` (no infinities or NaNs are stored), and +`value = half / store_scale`. In WebGL2 upload as `RGB16F` / `HALF_FLOAT` from a `Uint16Array` over the +decoded bytes, and the GPU does the decode: sample, then divide by `store_scale`. Precision is 11 bits +(about 0.05%): the horizon glow does not step. + +### `transmittance`: `width` 32, `height` 1, 3 channels + +Transmittance from the observer to space along a view elevation, per channel. Texel `i` is the elevation +`-4 + 94 * (i / 31)^2` degrees (`viewElevation(i)` in `scatter.js`; the inverse is `viewIndex(deg)`): +finest at the horizon. Elevations that look at the ground (-4 to about -0.3) are 0. Sample it for the sun's +direct colour: at the sun's elevation, `sun.color_linear`-like attenuation, redder as it sets. + +### `sky_view`: `width` 7, `height` 32, `depth` 12, 3 channels + +Sky radiance per steradian for a sun of unit irradiance (multiply by your own sun colour and exposure). +One 3D texture, `x` fastest: + +- `x`: azimuth of the view away from the sun, 0, 30, ... 180 degrees (`azimuth_step_deg`). The sky is + symmetric about the sun's vertical plane, so use `abs` of the relative azimuth. +- `y`: view elevation, same axis as the transmittance table (`viewElevation(i)`). +- `z`: sun elevation, `sun_elevations_deg` = -18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90. Between them + interpolate linearly; `sunIndex(deg)` in `scatter.js` gives the fractional index. + +Element `((z * 32 + y) * 7 + x) * 3 + c` of the decoded array. Texel centres, for linear filtering: + +```glsl +uniform highp sampler3D uSky; // RGB16F, 7 x 32 x 12, LINEAR, CLAMP_TO_EDGE +uniform float uSunIndex; // sunIndex(sunElevationDeg), 0..11, from the CPU +vec3 skyRadiance(float viewElDeg, float azFromSunDeg) { + float y = sqrt(clamp((viewElDeg + 4.0) / 94.0, 0.0, 1.0)) * 31.0; + float x = clamp(abs(azFromSunDeg) / 180.0, 0.0, 1.0) * 6.0; + return texture(uSky, vec3((x + 0.5) / 7.0, (y + 0.5) / 32.0, (uSunIndex + 0.5) / 12.0)).rgb / 64.0; +} +``` + +Row width is 21 bytes, so set `gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)` before `texImage3D`. + +Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmittance`; about +twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the +layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, +black below the planet's shadow limit). diff --git a/src/sky/baked.ts b/src/sky/baked.ts index ecaffbe5..b732fbc1 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -233,4 +233,32 @@ export const baked = { ], link: '#2a4f27', }, + scatter: { + planet_radius_km: 6360, + atmosphere_height_km: 100, + observer_altitude_km: 0.1, + extinction_per_km: [0.020271980981493083, 0.02767884208799963, 0.044961518003181586], + format: 'half-float', + store_scale: 64, + view_elevation_deg: { + min: -4, + max: 90, + samples: 32, + }, + transmittance: { + width: 32, + height: 1, + channels: 3, + data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArkjBP0wqR02AR145qk9vS4BBtlCITWBGW1EcT3dJ1FE1UN5LL1K9UBdNdVIrUSdOq1KFURlP1lLOUe5P+VILUlRQFlM+UqVQLVNoUupQQFOLUidRUFOpUltRXlPCUohRaVPYUq5Rc1PqUs9Re1P4UupRgVMFUwFShlMPUxRSilMWUyJSjVMcUyxSj1MfUzNSj1MgUzVS', + }, + sky_view: { + sun_elevations_deg: [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90], + azimuth_step_deg: 30, + width: 7, + height: 32, + depth: 12, + channels: 3, + data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', + }, + }, } as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js new file mode 100644 index 00000000..5781c089 --- /dev/null +++ b/src/sky/scatter.js @@ -0,0 +1,249 @@ +// Atmospheric scattering tables, baked for a WebGL2 shader (layout: src/sky/README.md). +// Dependency-free apart from sun.js, so scripts/bake-sky.mjs and the tests run it under plain node. +// +// bakeScatter() -> { transmittance, sky_view, ... } with base64 RGB16F tables (half floats). +// +// A Hillaire-style single-scattering model (Rayleigh + Mie, spherical planet, observer 100 m up), +// reduced to two small tables: the transmittance of the whole atmosphere along a view direction, and +// the sky's radiance by sun elevation, view elevation and azimuth from the sun. sun.js stays the +// single source of the air: its zenith optical depths (TAU, per channel) are the vertical optical +// depth of this atmosphere, split into a grey aerosol part and a Rayleigh part. So the direct sun +// sun.js returns and the transmittance here agree at the zenith; only the air-mass law differs +// (Kasten-Young there, the spherical integral here). + +import { TAU } from './sun.js'; + +const RAD = Math.PI / 180; +const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x)); + +// Geometry (km) and scale heights. +export const PLANET_RADIUS_KM = 6360; +export const ATMOSPHERE_HEIGHT_KM = 100; +export const OBSERVER_ALTITUDE_KM = 0.1; +const H_RAYLEIGH = 8; +const H_MIE = 1.2; +const MIE_ALBEDO = 0.9; // single-scattering albedo of the aerosol (EYEBALLED) +const MIE_G = 0.8; // Henyey-Greenstein asymmetry (Hillaire 2020) +const MIE_TAU = 0.02; // grey aerosol vertical optical depth (EYEBALLED); the rest of TAU is Rayleigh + +// Vertical optical depth of an exponential layer of scale height H over the atmosphere. +const column = (H) => H * (1 - Math.exp(-ATMOSPHERE_HEIGHT_KM / H)); +const BETA_RAYLEIGH = TAU.map((tau) => (tau - MIE_TAU) / column(H_RAYLEIGH)); // per km at sea level +const BETA_MIE_EXT = MIE_TAU / column(H_MIE); +const BETA_MIE_SCA = BETA_MIE_EXT * MIE_ALBEDO; + +// Table axes. +export const SUN_ELEVATIONS_DEG = [-18, -12, -8, -5, -2, 0, 3, 8, 15, 25, 45, 90]; +export const VIEW_SAMPLES = 32; +export const AZIMUTH_SAMPLES = 7; // 0, 30, ... 180 degrees from the sun +export const AZIMUTH_STEP_DEG = 30; +export const VIEW_MIN_DEG = -4; +export const VIEW_MAX_DEG = 90; +// Tables hold IEEE 754 half floats, little-endian, holding value * STORE_SCALE. The scale lifts the +// dark end (twilight sky radiance is ~1e-4) clear of the half-float subnormals; the brightest +// stored value, a Mie peak near 0.3 x 64, is far below the format's 65504. +export const STORE_SCALE = 64; + +/** View elevation in degrees of sample index i of VIEW_SAMPLES: finest at the horizon. */ +export const viewElevation = (i) => + VIEW_MIN_DEG + (VIEW_MAX_DEG - VIEW_MIN_DEG) * (i / (VIEW_SAMPLES - 1)) ** 2; + +/** Fractional index into SUN_ELEVATIONS_DEG of a sun elevation (clamped to the table): the z texel coordinate. */ +export const sunIndex = (elevationDeg) => { + const e = clamp( + elevationDeg, + SUN_ELEVATIONS_DEG[0], + SUN_ELEVATIONS_DEG[SUN_ELEVATIONS_DEG.length - 1] + ); + const k = Math.min(SUN_ELEVATIONS_DEG.findIndex((x) => x > e) - 1, SUN_ELEVATIONS_DEG.length - 2); + const i = k < 0 ? SUN_ELEVATIONS_DEG.length - 2 : k; + return i + (e - SUN_ELEVATIONS_DEG[i]) / (SUN_ELEVATIONS_DEG[i + 1] - SUN_ELEVATIONS_DEG[i]); +}; + +/** Inverse of viewElevation: the fractional sample index of an elevation. */ +export const viewIndex = (elevationDeg) => + Math.sqrt(clamp((elevationDeg - VIEW_MIN_DEG) / (VIEW_MAX_DEG - VIEW_MIN_DEG), 0, 1)) * + (VIEW_SAMPLES - 1); + +/** Round a non-negative number to the nearest IEEE 754 half float (ties to even); returns its 16 bits. */ +export function toHalf(x) { + if (!(x > 0)) return 0; + const u = new Uint32Array(new Float32Array([x]).buffer)[0]; + const exp = ((u >> 23) & 0xff) - 127 + 15; + const mant = u & 0x7fffff; + if (exp >= 31) return 0x7bff; + let half; + let rem; + let halfway; + if (exp <= 0) { + if (exp < -10) return 0; + const m = mant | 0x800000; + const shift = 14 - exp; + half = m >> shift; + rem = m & ((1 << shift) - 1); + halfway = 1 << (shift - 1); + } else { + half = (exp << 10) | (mant >> 13); + rem = mant & 0x1fff; + halfway = 0x1000; + } + return rem > halfway || (rem === halfway && half & 1) ? half + 1 : half; +} + +/** The exact value of 16 half-float bits (finite values only). */ +export const fromHalf = (h) => { + const e = (h >> 10) & 31; + const m = h & 1023; + return e === 0 ? m * 2 ** -24 : (1 + m / 1024) * 2 ** (e - 15); +}; + +/** Stored 16 bits of a value, and the value back out of them. */ +export const encode = (v) => toHalf(v * STORE_SCALE); +export const decode = (h) => fromHalf(h) / STORE_SCALE; + +const extinction = (h) => { + const r = Math.exp(-h / H_RAYLEIGH); + const m = Math.exp(-h / H_MIE); + return BETA_RAYLEIGH.map((b) => b * r + BETA_MIE_EXT * m); +}; + +// Distance along ray (origin o, unit direction d) to the sphere of radius r centred on the origin +// of the planet: the far root, or the near root if `near`. Infinity when it misses or is behind. +function sphereHit(o, d, r, near) { + const b = o[0] * d[0] + o[1] * d[1] + o[2] * d[2]; + const c = o[0] * o[0] + o[1] * o[1] + o[2] * o[2] - r * r; + const disc = b * b - c; + if (disc < 0) return Infinity; + const sq = Math.sqrt(disc); + const t = near ? -b - sq : -b + sq; + return t > 0 ? t : Infinity; +} + +// Optical depth per channel from p along d to the top of the atmosphere; Infinity if the planet is in the way. +function opticalDepthToSpace(p, d, steps) { + if (sphereHit(p, d, PLANET_RADIUS_KM, true) !== Infinity) return null; + const top = sphereHit(p, d, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); + const dt = top / steps; + const depth = [0, 0, 0]; + for (let k = 0; k < steps; k += 1) { + const t = (k + 0.5) * dt; + const q = [p[0] + d[0] * t, p[1] + d[1] * t, p[2] + d[2] * t]; + const e = extinction(Math.hypot(...q) - PLANET_RADIUS_KM); + for (let c = 0; c < 3; c += 1) depth[c] += e[c] * dt; + } + return depth; +} + +const observer = [0, PLANET_RADIUS_KM + OBSERVER_ALTITUDE_KM, 0]; +const viewDirection = (elevationDeg, azimuthDeg) => { + const e = elevationDeg * RAD; + const a = azimuthDeg * RAD; + return [Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a)]; +}; +const sunDirection = (elevationDeg) => [ + Math.cos(elevationDeg * RAD), + Math.sin(elevationDeg * RAD), + 0, +]; + +/** Transmittance per channel from the observer along an elevation to space; zeros where the ground is in the way. */ +export function transmittance(elevationDeg) { + const depth = opticalDepthToSpace(observer, viewDirection(elevationDeg, 0), 64); + return depth ? depth.map((x) => Math.exp(-x)) : [0, 0, 0]; +} + +const rayleighPhase = (c) => (3 / (16 * Math.PI)) * (1 + c * c); +const miePhase = (c) => + (1 - MIE_G * MIE_G) / (4 * Math.PI * (1 + MIE_G * MIE_G - 2 * MIE_G * c) ** 1.5); + +/** + * Sky radiance per channel, per steradian, for a sun of unit irradiance (so the consumer multiplies by + * its own sun colour), in a view direction at an elevation and an azimuth measured from the sun. + * Single scattering, no ground bounce, no sun disc. + */ +export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const ground = sphereHit(observer, v, PLANET_RADIUS_KM, true); + const top = sphereHit(observer, v, PLANET_RADIUS_KM + ATMOSPHERE_HEIGHT_KM, false); + const end = Math.min(ground, top); + const steps = 24; + const dt = end / steps; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + for (let k = 0; k < steps; k += 1) { + const t = (k + 0.5) * dt; + const q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const h = Math.hypot(...q) - PLANET_RADIUS_KM; + const e = extinction(h); + for (let c = 0; c < 3; c += 1) along[c] += e[c] * dt; + const toSun = opticalDepthToSpace(q, s, 12); + if (toSun) { + const r = Math.exp(-h / H_RAYLEIGH); + const m = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + const scatter = BETA_RAYLEIGH[c] * r * pr + BETA_MIE_SCA * m * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * scatter * dt; + } + } + } + return radiance; +} + +const base64 = (halves) => { + const bytes = new Uint8Array(halves.length * 2); + halves.forEach((h, i) => { + bytes[i * 2] = h & 0xff; // little-endian + bytes[i * 2 + 1] = h >> 8; + }); + let s = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(s); +}; + +export function bakeScatter() { + const view = Array.from({ length: VIEW_SAMPLES }, (_, i) => viewElevation(i)); + + const t = new Uint16Array(VIEW_SAMPLES * 3); + view.forEach((e, i) => transmittance(e).forEach((v, c) => (t[i * 3 + c] = encode(v)))); + + // x = azimuth, y = view elevation, z = sun elevation, three half floats per texel: a 3D texture. + const sky = new Uint16Array(SUN_ELEVATIONS_DEG.length * VIEW_SAMPLES * AZIMUTH_SAMPLES * 3); + let at = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => + view.forEach((e) => { + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) { + skyRadiance(sun, e, a * AZIMUTH_STEP_DEG).forEach((v) => { + sky[at] = encode(v); + at += 1; + }); + } + }) + ); + + return { + planet_radius_km: PLANET_RADIUS_KM, + atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, + observer_altitude_km: OBSERVER_ALTITUDE_KM, + // Extinction per km at the observer, per channel, for aerial perspective: exp(-k * distance_km). + extinction_per_km: extinction(OBSERVER_ALTITUDE_KM), + format: 'half-float', + store_scale: STORE_SCALE, + view_elevation_deg: { min: VIEW_MIN_DEG, max: VIEW_MAX_DEG, samples: VIEW_SAMPLES }, + transmittance: { width: VIEW_SAMPLES, height: 1, channels: 3, data: base64(t) }, + sky_view: { + sun_elevations_deg: SUN_ELEVATIONS_DEG, + azimuth_step_deg: AZIMUTH_STEP_DEG, + width: AZIMUTH_SAMPLES, + height: VIEW_SAMPLES, + depth: SUN_ELEVATIONS_DEG.length, + channels: 3, + data: base64(sky), + }, + }; +} diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts new file mode 100644 index 00000000..9ebcb014 --- /dev/null +++ b/src/sky/scatter.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { + AZIMUTH_SAMPLES, + AZIMUTH_STEP_DEG, + STORE_SCALE, + SUN_ELEVATIONS_DEG, + VIEW_SAMPLES, + bakeScatter, + decode, + fromHalf, + skyRadiance, + sunIndex, + toHalf, + transmittance, + viewElevation, + viewIndex, +} from './scatter.js'; +import { TAU } from './sun.js'; +import { baked } from './baked'; + +const table = (b64: string): Uint16Array => { + const bin = atob(b64); + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); + return new Uint16Array(bytes.buffer); +}; +// Checksums of the baked halves, so a change to the model or the encoding is a deliberate re-pin. +const SKY_CHECKSUM = 622927107; +const TRANS_CHECKSUM = 1498461058; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const scatter = baked.scatter as any; +const sky = table(scatter.sky_view.data); +const trans = table(scatter.transmittance.data); +const sunIndex_ = (deg: number) => SUN_ELEVATIONS_DEG.indexOf(deg); +// Texel of the sky table: x = azimuth, y = view elevation, z = sun elevation; value = three halves. +const texel = (s: number, v: number, a: number) => + [0, 1, 2].map((c) => decode(sky[((s * VIEW_SAMPLES + v) * AZIMUTH_SAMPLES + a) * 3 + c])); + +describe('half floats', () => { + it('round-trip within half precision across the table range, ties to even', () => { + [6.1e-5, 1e-4, 0.0123, 0.3, 1, 20, 1000].forEach((x) => + expect(Math.abs(fromHalf(toHalf(x)) - x) / x).toBeLessThan(5.1e-4) + ); + expect(toHalf(0)).toBe(0); + expect(toHalf(1)).toBe(0x3c00); + expect(toHalf(2 ** -24)).toBe(1); // smallest subnormal + expect(toHalf(1 + 2 ** -11)).toBe(0x3c00); // exact tie goes to the even neighbour + expect(toHalf(1 + 3 * 2 ** -11)).toBe(0x3c02); + expect(fromHalf(0x7bff)).toBe(65504); + }); + + it('keeps twilight radiance, ~1e-4, within 0.1% thanks to the store scale', () => { + const x = 1.234e-4; + expect(Math.abs(decode(toHalf(x * STORE_SCALE)) - x) / x).toBeLessThan(1e-3); + }); +}); + +describe('sunIndex', () => { + it('is the fractional position of an elevation on the sun axis, clamped to the table', () => { + expect(sunIndex(-18)).toBe(0); + expect(sunIndex(90)).toBe(SUN_ELEVATIONS_DEG.length - 1); + expect(sunIndex(-100)).toBe(0); + expect(sunIndex(100)).toBe(SUN_ELEVATIONS_DEG.length - 1); + expect(sunIndex(0)).toBe(sunIndex_(0)); + expect(sunIndex(1.5)).toBeCloseTo(sunIndex_(0) + 0.5, 12); // halfway between 0 and 3 + expect(sunIndex(35)).toBeCloseTo(sunIndex_(25) + 0.5, 12); // halfway between 25 and 45 + }); +}); + +describe('scattering tables', () => { + it('are what scripts/bake-sky.mjs would write now (run npm run bake:sky if not)', () => { + expect(JSON.parse(JSON.stringify(bakeScatter()))).toEqual(JSON.parse(JSON.stringify(scatter))); + }); + + it('have the documented layout and size', () => { + expect(scatter.format).toBe('half-float'); + expect(scatter.sky_view).toMatchObject({ + width: AZIMUTH_SAMPLES, + height: VIEW_SAMPLES, + depth: SUN_ELEVATIONS_DEG.length, + channels: 3, + azimuth_step_deg: AZIMUTH_STEP_DEG, + }); + expect(sky.length).toBe(AZIMUTH_SAMPLES * VIEW_SAMPLES * SUN_ELEVATIONS_DEG.length * 3); + expect(trans.length).toBe(VIEW_SAMPLES * 3); + expect(sky.length * 2).toBe(16128); // bytes: the budget the README states + }); + + it('pin the view-elevation axis and its inverse', () => { + expect(viewElevation(0)).toBe(-4); + expect(viewElevation(VIEW_SAMPLES - 1)).toBe(90); + expect(viewElevation(6)).toBeCloseTo(-4 + 94 * (6 / 31) ** 2, 10); + [0, 5, 6, 17, 31].forEach((i) => expect(viewIndex(viewElevation(i))).toBeCloseTo(i, 9)); + }); + + it('transmittance to space agrees with sun.js at the zenith and falls toward the horizon', () => { + const top = VIEW_SAMPLES - 1; + [0, 1, 2].forEach((c) => + expect(Math.abs(decode(trans[top * 3 + c]) / Math.exp(-TAU[c]) - 1)).toBeLessThan(0.01) + ); + expect(transmittance(90).map((v, c) => v / Math.exp(-TAU[c]))).toSatisfy((r: number[]) => + r.every((x) => Math.abs(x - 1) < 0.01) + ); + // blue goes first: lower view, lower blue, and redder light at the horizon + let prev = Infinity; + for (let i = top; i >= 8; i -= 1) { + const blue = decode(trans[i * 3 + 2]); + expect(blue).toBeLessThanOrEqual(prev); + prev = blue; + } + expect(decode(trans[8 * 3]) / decode(trans[8 * 3 + 2])).toBeGreaterThan(10); // ~2 degrees up: sunset red + expect(decode(trans[0])).toBe(0); // -4 degrees looks at the ground + }); + + it('colour the daytime sky blue, and a low sun redder toward it than away from it', () => { + const noon = texel(sunIndex_(45), VIEW_SAMPLES - 1, 0); + expect(noon[2]).toBeGreaterThan(noon[1]); + expect(noon[1]).toBeGreaterThan(noon[0]); + const horizon = Math.round(viewIndex(5)); + const toward = texel(sunIndex_(3), horizon, 0); + const away = texel(sunIndex_(3), horizon, AZIMUTH_SAMPLES - 1); + expect(toward[0] / toward[2]).toBeGreaterThan(5); + expect(toward[0] / toward[2]).toBeGreaterThan(2 * (away[0] / away[2])); + expect(toward[0]).toBeGreaterThan(5 * away[0]); // the glow is around the sun + }); + + it('fade through twilight to black below the planet-shadow limit', () => { + const mid = Math.round(viewIndex(30)); + const lum = (s: number) => texel(sunIndex_(s), mid, 3).reduce((x, y) => x + y, 0); + expect(lum(8)).toBeGreaterThan(lum(3)); + expect(lum(3)).toBeGreaterThan(lum(-2)); + expect(lum(-2)).toBeGreaterThan(lum(-5)); + expect(lum(-5)).toBeGreaterThan(0); + expect(lum(-12)).toBe(0); + expect(lum(-18)).toBe(0); + }); + + it('hold the model to half-float precision, with no quantised steps along the horizon glow', () => { + // Every table texel off a handful of sampled ones is the model's own value to < 0.2%. + [ + [sunIndex_(3), 6, 0], + [sunIndex_(3), 9, 2], + [sunIndex_(0), 8, 0], + [sunIndex_(45), 20, 3], + [sunIndex_(-2), 12, 1], + ].forEach(([s, v, a]) => { + const direct = skyRadiance(SUN_ELEVATIONS_DEG[s], viewElevation(v), a * AZIMUTH_STEP_DEG); + texel(s, v, a).forEach((got, c) => expect(Math.abs(got / direct[c] - 1)).toBeLessThan(2e-3)); + }); + // Moving up from the horizon toward the sun, red drops smoothly: distinct, strictly decreasing. + const s = sunIndex_(3); + const reds = Array.from({ length: 8 }, (_, k) => texel(s, 7 + k, 0)[0]); + reds.slice(1).forEach((r, k) => expect(r).toBeLessThan(reds[k])); + expect(new Set(reds).size).toBe(reds.length); + }); + + it('are the same bytes as when pinned', () => { + const sum = (a: Uint16Array) => a.reduce((x, y) => (x * 31 + y) % 2147483647, 7); + expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); + }); +}); diff --git a/src/sky/sun.js b/src/sky/sun.js index 55cad1ac..3173eb4d 100644 --- a/src/sky/sun.js +++ b/src/sky/sun.js @@ -57,7 +57,7 @@ export const sunElevation = (time, lat, lon) => sunPosition(time, lat, lon).elev // Optical depth of the clear atmosphere at the zenith, per channel (R, G, B). // Rayleigh at 650/550/450 nm plus a little ozone and haze (EYEBALLED to keep // low sun orange rather than brown). -const TAU = [0.06, 0.12, 0.26]; +export const TAU = [0.06, 0.12, 0.26]; // Relative air mass, Kasten & Young 1989. Elevation in degrees, >= 0. const airMass = (el) => 1 / (Math.sin(el * RAD) + 0.50572 * Math.pow(el + 6.07995, -1.6364)); -- 2.47.3 From d2c2c279e2a2a1f06b9103e9ce67747f580c88af Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 21:00:56 +0000 Subject: [PATCH 13/24] scene: brighter, slightly larger terrain stars so they read on a phone Terrain overlay draws the ridge field at 2.4x alpha (capped at 1) and 1.3x radius; the dark-sky gate is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainStars.test.ts | 16 +++++++++++++++- src/app/features/scene/terrain/terrainStars.ts | 8 ++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts index 40020186..370b6843 100644 --- a/src/app/features/scene/terrain/terrainStars.test.ts +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SKY } from './skyData'; import { + BRIGHTEN, createTerrainStars, elevationOf, ELEVATION_TOP, @@ -12,7 +13,7 @@ import { skyColoursOf, } from './terrainStars'; import { CAMERA, view } from './terrainWorld'; -import { isNight } from '../ridge/stars'; +import { isNight, makeStars } from '../ridge/stars'; import { skyNameFor } from './terrainSky'; /** A 2D context that only counts: arcs painted and clears since the last reset. */ @@ -168,6 +169,19 @@ describe('turning with the camera', () => { }); }); +describe('brightness', () => { + it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + const want = makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => + Math.min(1, st.alpha * BRIGHTEN) + ); + ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); + // and it is brighter than the ridge field's own top alpha + const top = Math.max(...makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => st.alpha)); + expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); + }); +}); + describe('motion', () => { it('with no clock every star is at base and a repeat draw repaints; no twinkle', () => { const s = createTerrainStars(glCanvas(), SKY.midnight)!; diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts index e568418f..4fca72c9 100644 --- a/src/app/features/scene/terrain/terrainStars.ts +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -72,6 +72,10 @@ export const elevationOf = (y: number): number => ELEVATION_TOP * (1 - y / SKY_S const wrap = (a: number): number => a - PERIOD * Math.round(a / PERIOD); +/** The terrain overlay draws the ridge field's stars brighter and a little larger: a phone's small, hazy sky needs it to read. */ +export const BRIGHTEN = 2.4; +export const GROW = 1.3; + export interface TerrainStars { /** Paint for the camera `turns` into its turn (0..1). `seconds` is the clock while the terrain moves; absent: every star at base. */ draw(turns: number, seconds?: number): void; @@ -126,13 +130,13 @@ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): Ter lit.forEach(({ star, az, e }, i) => { const da = wrap(az - yaw); const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; - ctx.globalAlpha = Math.min(1, star.alpha * gain); + ctx.globalAlpha = Math.min(1, star.alpha * gain * BRIGHTEN); ctx.fillStyle = star.fill; [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { const at = project(d, e, w, h); if (!at || at.y < 0 || at.y > el.height / scale) return; ctx.beginPath(); - ctx.arc(at.x * scale, at.y * scale, star.r * scale, 0, 2 * Math.PI); + ctx.arc(at.x * scale, at.y * scale, star.r * GROW * scale, 0, 2 * Math.PI); ctx.fill(); }); }); -- 2.47.3 From c2858452e7a3f44b0d6f400ddd5e896011b6da96 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 23:40:43 +0000 Subject: [PATCH 14/24] scene: terrain sky and haze from Void's baked scattering table Drops the vendored terrain/skyData.ts and the computed azimuth. terrainSky.ts reads src/sky/baked.ts: sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear, horizon_hex. terrainScatter.ts decodes baked.scatter and uploads sky_view as an RGB16F 3D texture; SKY_FRAG and GRID_FRAG sample it (README sampler), the sky for the view ray, the haze for the horizon in the fragment's azimuth. - Sky: 1 - exp(-exposure * radiance) + night glow (midnight horizon colour, off as the sun nears the horizon) + sun disc; +-0.5/255 dither on the 8-bit output. - Stars fade by sun elevation (starGain: 1 below -12, 0 above -6) instead of a dark-sky gate; the field is placed against the midnight gradient. - ?sun=&az= (or the sun option) lights between the two bakes for dusk (skyAtSun). - Changed numbers, noted in scene/README.md: haze colour is the scattered sky (was the gradient's horizon stop), ambient is ambient_linear as baked (was gradient zenith x 0.25 x night lift up to 4), HAZE_K unchanged at 0.0004. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 25 +++ .../features/scene/terrain/Terrain.test.ts | 59 ++++-- src/app/features/scene/terrain/Terrain.ts | 82 ++++++-- src/app/features/scene/terrain/skyData.ts | 44 ---- .../scene/terrain/terrainAlbedo.test.ts | 6 +- .../features/scene/terrain/terrainAlbedo.ts | 7 +- .../scene/terrain/terrainScatter.test.ts | 88 ++++++++ .../features/scene/terrain/terrainScatter.ts | 94 +++++++++ .../features/scene/terrain/terrainShaders.ts | 46 +++-- .../features/scene/terrain/terrainSky.test.ts | 190 ++++++++++++------ src/app/features/scene/terrain/terrainSky.ts | 139 +++++++++---- .../scene/terrain/terrainStars.test.ts | 42 +++- .../features/scene/terrain/terrainStars.ts | 34 +++- 13 files changed, 644 insertions(+), 212 deletions(-) delete mode 100644 src/app/features/scene/terrain/skyData.ts create mode 100644 src/app/features/scene/terrain/terrainScatter.test.ts create mode 100644 src/app/features/scene/terrain/terrainScatter.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 473a92a1..5bf2ef59 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,6 +38,31 @@ Integration (a canvas under the app, `startBackgroundScene`, `login()` when the are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure callbacks and the creature sizing rule. Nothing has drawn a creature. +## Terrain scene (`style: 'terrain'`): the baked sky + +`terrain/` draws a Phacelle-displaced grid under a sky read from Void's bake (`src/sky/baked.ts`, layout in +`src/sky/README.md`); nothing is vendored. `terrainSky.ts` reads each moment's `sun.{elevation_deg, azimuth_deg, +color_linear}`, `ambient_linear` and `horizon_hex`; `terrainScatter.ts` decodes `baked.scatter` and uploads `sky_view` as an +RGB16F 3D texture (the transmittance table stays on the CPU, for the sun's colour at an elevation the bake does not hold). +Both fragment shaders sample it: `SKY_FRAG` for the sky, `GRID_FRAG` for the haze, which is the sky's colour at the horizon in the +fragment's azimuth, so far ground fades into the sky behind it at dusk too. + +Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az=` puts the sun anywhere between them +(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as +the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`. + +- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a + sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce, + no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange, + where the real one keeps a blue-violet zenith. Within `HORIZON_FLOOR` (1.5 degrees) of the horizon the sky takes one flat colour, + because the table dips toward the ground there and would draw a seam against the haze. +- Stars: faded by the sun's elevation, `starGain` 1 below -12 degrees (where the table goes dark), 0 above -6, smooth between; + the field is placed against the midnight gradient and no longer gated on the sky being dark. At a baked sky that is the same as + before (midnight shows, noonday does not). +- Changed numbers: haze colour (was the gradient's horizon stop; now the scattered sky), ambient (was the gradient's zenith stop + x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than + the lifted one). `HAZE_K` (0.0004 per metre) is unchanged. + ## Entry points - `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 4578a58a..446ab9cf 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; -import { SKY } from './skyData'; -import { ambientColour } from './terrainSky'; +import { SKY, skyAtSun } from './terrainSky'; +import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; /** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ @@ -134,26 +135,58 @@ describe('createTerrainScene', () => { }); it('lights with the sky chosen: option, then ?sky=, then the night default', () => { - const ambientUploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const uploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { const { gl, calls } = fakeGl(); createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); - const hit = calls.uniform3fv.filter((a) => (a[1] as ArrayLike).length === 3); - // uniform3fv order in draw(): right, up, forward, then sun, sunColour, ambient, haze (+ camera) - return hit.map((a) => Array.from(a[1] as ArrayLike)); + // every vec3 uniform draw() sets: right, up, forward, sun, sunColour, glow, ambient, camera, soil... + return calls.uniform3fv + .filter((a) => (a[1] as ArrayLike).length === 3) + .map((a) => Array.from(a[1] as ArrayLike)); }; - const night = ambientUploaded({}); - const day = ambientUploaded({ sky: 'noonday' }); - const nightA = Array.from(ambientColour(SKY.midnight)); - const dayA = Array.from(ambientColour(SKY.noonday)); + const night = uploaded({}); + const day = uploaded({ sky: 'noonday' }); + const nightA = Array.from(SKY.midnight.ambient); + const dayA = Array.from(SKY.noonday.ambient); const has = (all: number[][], v: number[]) => all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); expect(has(night, nightA)).toBe(true); expect(has(night, dayA)).toBe(false); expect(has(day, dayA)).toBe(true); search = '?sky=noonday'; - expect(has(ambientUploaded({}), dayA)).toBe(true); + expect(has(uploaded({}), dayA)).toBe(true); search = '?sky=garbage'; - expect(has(ambientUploaded({}), nightA)).toBe(true); + expect(has(uploaded({}), nightA)).toBe(true); + }); + + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { + const ambients = (opts: Record) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike)); + }; + const dusk = skyAtSun(-1, 300); + const has = (all: number[][], v: readonly number[]) => + all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), dusk.ambient)).toBe(true); + search = '?sky=noonday&sun=-1&az=300'; + expect(has(ambients({}), dusk.ambient)).toBe(true); + search = '?sun=garbage'; + expect(has(ambients({}), SKY.midnight.ambient)).toBe(true); + }); + + it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.texImage3D).toHaveLength(1); + const a = calls.texImage3D[0]; + expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); + expect(a[9]).toEqual(skyViewWords()); + expect(calls.deleteTexture).toHaveLength(2); + }); + + it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { + expect(SKY_FRAG).toContain('- 0.5'); + expect(SKY_FRAG).toContain('dither / 255.0'); }); it('falls back to a still frame when the measured p95 is over 33 ms', () => { @@ -208,7 +241,7 @@ describe('createTerrainScene', () => { createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); expect(calls.deleteProgram).toHaveLength(3); expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(1); + expect(calls.deleteTexture).toHaveLength(2); expect(rafQueue).toHaveLength(0); }); }); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 27718624..90d90ca2 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,17 +1,18 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { skyColours } from '../ridge/skyColour'; -import { SkyName } from './skyData'; import { albedoPalette } from './terrainAlbedo'; +import { sunIndex } from '../../../../sky/scatter.js'; +import { SKY_VIEW, skyViewWords } from './terrainScatter'; import { - ambientColour, - hazeColour, + glowColour, HAZE_K, - hexToLinear, isSkyName, + skyAtSun, + SkyData, skyData, + SkyName, skyNameFor, - sunColour, sunDirection, } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; @@ -30,6 +31,8 @@ export type TerrainOptions = Pick< > & { /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ sky?: SkyName; + /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ + sun?: { elevation: number; azimuth?: number }; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ @@ -74,18 +77,25 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opt: SkyName | undefined): SkyName { - if (opt) return opt; - const q = new URLSearchParams(window.location.search).get('sky'); - return isSkyName(q) ? q : skyNameFor(skyColours()); +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +function chooseSky(opts: Pick): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); } /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an - * RGBA8 texture; each frame is two draws: the baked gradient sky, then a displaced grid lit by the baked sun and - * ambient and hazed toward the baked horizon colour. Cadence guard, pauses and the `?probe=1` readout match the + * RGBA8 texture; each frame is two draws: the sky from the baked scattering table, then a displaced grid lit by the + * baked sun and ambient and hazed toward that sky at the horizon. Cadence guard, pauses and the `?probe=1` readout match the * ridge scene. */ export function createTerrainScene(opts: TerrainOptions): Scene | null { @@ -101,7 +111,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); if (typeof units === 'number' && units < 1) return null; - const sky = skyData(chooseSky(opts.sky)); + const sky = chooseSky(opts); const spec = GRID[tier]; let skyProg: WebGLProgram; @@ -123,6 +133,29 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + // The baked scattering table, RGB16F, uploaded as it is stored (half floats); the shader divides by the store scale. + const skyTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGB16F, + SKY_VIEW.width, + SKY_VIEW.height, + SKY_VIEW.depth, + 0, + gl.RGB, + gl.HALF_FLOAT, + skyViewWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; @@ -130,6 +163,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const release = () => { free.forEach((f) => f()); gl.deleteTexture(heightTex); + gl.deleteTexture(skyTex); gl.deleteFramebuffer(fbo); gl.deleteProgram(bakeProg); gl.deleteProgram(skyProg); @@ -167,11 +201,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { () => gl.deleteBuffer(ibuf) ); - const gradient = new Float32Array(sky.gradient.flatMap((h) => [...hexToLinear(h)])); const sun = [...sunDirection(sky)]; - const sunLight = [...sunColour(sky)]; - const ambient = [...ambientColour(sky)]; - const haze = [...hazeColour(sky)]; + const sunLight = [...sky.direct]; + const ambient = [...sky.ambient]; + const glow = [...glowColour(sky)]; + const sunTexel = sunIndex(sky.elevation); const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); @@ -201,7 +235,13 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.disable(gl.DEPTH_TEST); gl.useProgram(skyProg); const us = (n: string) => gl.getUniformLocation(skyProg, n); - gl.uniform3fv(us('uGrad'), gradient); + gl.uniform3fv(us('uSun'), sun); + gl.uniform3fv(us('uSunColour'), sunLight); + gl.uniform3fv(us('uGlow'), glow); + gl.uniform1f(us('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(us('uSky'), 1); gl.uniform3fv(us('uRight'), v.right); gl.uniform3fv(us('uUp'), v.up); gl.uniform3fv(us('uForward'), v.forward); @@ -220,7 +260,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uSun'), sun); gl.uniform3fv(u('uSunColour'), sunLight); gl.uniform3fv(u('uAmbient'), ambient); - gl.uniform3fv(u('uHaze'), haze); + gl.uniform3fv(u('uGlow'), glow); + gl.uniform1f(u('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(u('uSky'), 1); gl.uniform1f(u('uHazeK'), HAZE_K); gl.uniform3fv(u('uSoil'), palette.soil); gl.uniform3fv(u('uScree'), palette.scree); diff --git a/src/app/features/scene/terrain/skyData.ts b/src/app/features/scene/terrain/skyData.ts deleted file mode 100644 index 2b75f405..00000000 --- a/src/app/features/scene/terrain/skyData.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The sky numbers the terrain scene lights and hazes with, vendored from Void's baked sky (GameCult/Void -// PR #22, branch void/sky-tokens, revision 7b4aa2f): `src/sky/baked.ts` (generated by scripts/bake-sky.mjs -// from src/sky/sun.js and src/sky/skyTokens.js) and `light()` in `src/sky/sun.js`. Both moments are -// 2026-09-22 at lat 45, lon 0. -// -// elevation, gradient copied from baked.ts (`baked.midnight` / `baked.noonday`). -// direct `light({time, lat, lon}).direct` from sun.js at that moment: sun colour at normal -// incidence, linear RGB, clear zenith sun ~ 1. baked.ts does not carry it. -// azimuth NOT in #22. Computed here from the same NOAA/Meeus series sun.js uses for the -// elevation (hour angle and declination), degrees clockwise from north. It is the -// one number in this file Void's bake does not produce; see the report on the table shape. -// -// These are copies, not a link: #22 is not in this tree, so nothing fails if Void re-bakes. When #22 lands, -// replace this module's literals with an import of `baked` (plus the fields above, if the bake adds them) and -// keep the exports. - -export type SkyName = 'midnight' | 'noonday'; -export type Vec3 = readonly [number, number, number]; - -export interface SkyData { - /** Degrees above the horizon, no refraction. Negative: the sun is down. */ - elevation: number; - /** Degrees clockwise from north. */ - azimuth: number; - /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ - direct: Vec3; - /** Seven sRGB hex stops, zenith first, horizon last. */ - gradient: readonly string[]; -} - -export const SKY: Record = { - midnight: { - elevation: -44.579021600011224, - azimuth: 2.4988773054640205, - direct: [0, 0, 0], - gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], - }, - noonday: { - elevation: 45.16994813502928, - azimuth: 182.58737070473097, - direct: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], - gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], - }, -}; diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 331b820a..0136b5f3 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; -import { SKY } from './skyData'; +import { SKY } from './terrainSky'; import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; @@ -98,7 +98,7 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); - // the only fetch in the fragment shader is the height map's - expect(GRID_FRAG.match(/texelFetch|texture\(/g)).toHaveLength(1); + // 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)/g)).toHaveLength(1); }); }); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 703f9f0c..e3230a8b 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -1,10 +1,9 @@ // Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader // (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. // Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky -// part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour +// part reads the baked sky (./terrainSky.ts): the snow line drops and every material is tinted toward the sky's colour // as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. -import { SkyData, Vec3 } from './skyData'; -import { coldness, hazeColour, sunColour } from './terrainSky'; +import { coldness, hazeColour, SkyData, Vec3 } from './terrainSky'; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); const smooth = (a: number, b: number, x: number) => { @@ -58,7 +57,7 @@ const unit = (c: Vec3): Vec3 => { /** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ export function albedoPalette(d: SkyData): AlbedoPalette { const cold = coldness(d); - const sun = unit(sunColour(d)); + const sun = unit(d.direct); const haze = unit(hazeColour(d)); const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); const k = mix(0.15, 0.5, cold); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts new file mode 100644 index 00000000..782d980e --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-restricted-syntax -- tests walk table texels */ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { decode, viewElevation } from '../../../../sky/scatter.js'; +import { + SCATTER_GLSL, + scatterRadiance, + skyViewWords, + SKY_VIEW, + tableWords, + transmittanceAt, +} from './terrainScatter'; + +const S = baked.scatter.sky_view; + +describe('the table upload', () => { + it('decodes the baked base64 to one half-float word per channel per texel', () => { + const w = skyViewWords(); + expect(w.length).toBe(S.width * S.height * S.depth * S.channels); + expect(SKY_VIEW).toEqual({ width: S.width, height: S.height, depth: S.depth }); + expect(tableWords(baked.scatter.transmittance.data)).toHaveLength(32 * 3); + }); + + it('words are little-endian: the base64 bytes lo, hi make a word hi << 8 | lo', () => { + expect(Array.from(tableWords(btoa(String.fromCharCode(0x34, 0x12, 0xff, 0x7b))))).toEqual([ + 0x1234, 0x7bff, + ]); + }); +}); + +describe('scatterRadiance (the CPU twin of the sampler)', () => { + it('returns the stored texel exactly at a texel centre: layout x azimuth, y view, z sun', () => { + const w = skyViewWords(); + for (const [z, y, x] of [ + [10, 31, 0], + [8, 5, 3], + [5, 20, 6], + [11, 0, 2], + ]) { + const base = ((z * S.height + y) * S.width + x) * 3; + const got = scatterRadiance( + S.sun_elevations_deg[z], + viewElevation(y), + x * S.azimuth_step_deg + ); + got.forEach((v, c) => expect(v).toBeCloseTo(decode(w[base + c]), 9)); + } + }); + + it('interpolates between texels and clamps beyond the table', () => { + const a = scatterRadiance(25, 10, 0)[2]; + const b = scatterRadiance(45, 10, 0)[2]; + const mid = scatterRadiance(35, 10, 0)[2]; // 35 is halfway between 25 and 45 on the sun axis + expect(mid).toBeCloseTo((a + b) / 2, 9); + expect(scatterRadiance(500, 10, 400)).toEqual(scatterRadiance(90, 10, 180)); + expect(scatterRadiance(30, 10, -90)).toEqual(scatterRadiance(30, 10, 90)); + }); + + it('is black under the planet’s shadow limit and blue at a high noon zenith', () => { + scatterRadiance(-18, 30, 0).forEach((v) => expect(v).toBeLessThan(1e-6)); + const noon = scatterRadiance(45, 85, 90); + expect(noon[2]).toBeGreaterThan(1.5 * noon[0]); + }); +}); + +describe('transmittanceAt', () => { + it('is the zenith transmittance exp(-TAU) overhead and redder at the horizon', () => { + const z = transmittanceAt(90); + [0.06, 0.12, 0.26].forEach((tau, i) => expect(z[i]).toBeCloseTo(Math.exp(-tau), 2)); + const h = transmittanceAt(2); + expect(h[2] / h[0]).toBeLessThan(0.5 * (z[2] / z[0])); + }); + + it('is zero where the ground is in the way', () => { + expect(transmittanceAt(-4)).toEqual([0, 0, 0]); + }); +}); + +describe('the GLSL sampler', () => { + it('uses the layout the README documents: texel-centre coordinates over a 7 x 32 x 12 texture, decoded by the store scale', () => { + expect(SCATTER_GLSL).toContain('(x + 0.5) / 7.0'); + expect(SCATTER_GLSL).toContain('(y + 0.5) / 32.0'); + expect(SCATTER_GLSL).toContain('(uSunIndex + 0.5) / 12.0'); + expect(SCATTER_GLSL).toContain('/ 64.0'); + expect(SCATTER_GLSL).toContain('sqrt(clamp((el - (-4.0)) / 94.0, 0.0, 1.0)) * 31.0'); + expect(SCATTER_GLSL).toContain('* 6.0'); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts new file mode 100644 index 00000000..9149ac47 --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -0,0 +1,94 @@ +// The baked scattering tables (src/sky/baked.ts `scatter`, layout in src/sky/README.md) as the terrain scene +// uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and +// for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. +import { baked } from '../../../../sky/baked'; +import { decode, sunIndex, viewIndex } from '../../../../sky/scatter.js'; +import type { Vec3 } from './terrainSky'; + +const S = baked.scatter; +const SKY = S.sky_view; +const VIEW = S.view_elevation_deg; + +/** Raw little-endian half-float words of a base64 table, ready for `texImage3D(..., HALF_FLOAT, words)`. */ +export function tableWords(b64: string): Uint16Array { + const bytes = atob(b64); + return Uint16Array.from( + { length: bytes.length / 2 }, + (_, i) => bytes.charCodeAt(2 * i) + 256 * bytes.charCodeAt(2 * i + 1) + ); +} + +export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth } as const; +export const STORE_SCALE = S.store_scale; +export const skyViewWords = (): Uint16Array => tableWords(SKY.data); + +/** Decode a stored table to values, once. */ +const decoded = (data: string) => { + let values: Float32Array | undefined; + return (): Float32Array => { + if (!values) values = Float32Array.from(tableWords(data), decode); + return values; + }; +}; +const transmittanceTable = decoded(S.transmittance.data); +const skyTable = decoded(SKY.data); + +/** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ +export function transmittanceAt(elevationDeg: number): Vec3 { + const t = transmittanceTable(); + const f = Math.min(viewIndex(elevationDeg), VIEW.samples - 1); + const i = Math.min(Math.floor(f), VIEW.samples - 2); + const k = f - i; + return [0, 1, 2].map((c) => t[i * 3 + c] * (1 - k) + t[(i + 1) * 3 + c] * k) as unknown as Vec3; +} + +/** + * Sky radiance per steradian for a sun of unit irradiance, trilinear in (azimuth from the sun, view + * elevation, sun elevation) on texel centres, clamped at the edges: what the GPU's LINEAR sampler returns. + */ +export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDeg: number): Vec3 { + const t = skyTable(); + const at = [ + Math.min(Math.abs(azFromSunDeg) / 180, 1) * (SKY.width - 1), + viewIndex(viewElDeg), + sunIndex(sunElDeg), + ]; + const lo = at.map((v, a) => Math.min(Math.floor(v), [SKY.width, SKY.height, SKY.depth][a] - 2)); + const f = at.map((v, a) => v - lo[a]); + const out = [0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = (((lo[2] + dz) * SKY.height + lo[1] + dy) * SKY.width + lo[0] + dx) * 3; + for (let c = 0; c < 3; c += 1) out[c] += w * t[base + c]; + } + return [out[0], out[1], out[2]]; +} + +/** + * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree + * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the + * terrain's haze both take the colour here, flat to the horizon, and meet without a seam. + */ +export const HORIZON_FLOOR = 1.5; + +/** The sky shader's lookup, in GLSL: `skyScatter(viewDirection)` is unit-sun radiance, needs uSky, uSunIndex, uSun. */ +export const SCATTER_GLSL = ` +uniform highp sampler3D uSky; +uniform float uSunIndex; +uniform vec3 uSun; +vec3 skyScatter(vec3 d) { + float el = max(degrees(asin(clamp(d.y, -1.0, 1.0))), ${HORIZON_FLOOR.toFixed(1)}); + vec2 h = d.xz, s = uSun.xz; + float az = (length(h) < 1e-4 || length(s) < 1e-4) ? 0.0 + : degrees(acos(clamp(dot(normalize(h), normalize(s)), -1.0, 1.0))); + float y = sqrt(clamp((el - (${VIEW.min.toFixed(1)})) / ${(VIEW.max - VIEW.min).toFixed( + 1 +)}, 0.0, 1.0)) * ${(VIEW.samples - 1).toFixed(1)}; + float x = clamp(az / 180.0, 0.0, 1.0) * ${(SKY.width - 1).toFixed(1)}; + return texture(uSky, vec3((x + 0.5) / ${SKY.width}.0, (y + 0.5) / ${ + SKY.height +}.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 0e1783e7..8418e91a 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -8,6 +8,8 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; +import { SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, SKY_EXPOSURE } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -107,7 +109,8 @@ void main() { gl_Position = uViewProj * vec4(v_world, 1.0); }`; -// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient; hazed toward the +// scattered sky at the horizon in the ray's azimuth (SKY_COLOUR_GLSL). // The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. const ALBEDO_DEFINES = ` #define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} @@ -120,10 +123,20 @@ const ALBEDO_DEFINES = ` #define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} `; -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES} +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The +// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL} +uniform vec3 uGlow; +vec3 skyColour(vec3 d) { + return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + GLOW_ZENITH +)}, sqrt(clamp(d.y, 0.0, 1.0))); +}`; + +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze, uSoil, uScree, uRock, uSnow; +uniform vec3 uCamera, uSunColour, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} @@ -151,13 +164,21 @@ void main() { float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); - float T = exp(-uHazeK * length(v_world - uCamera)); - o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); + vec3 toSurface = v_world - uCamera; + float T = exp(-uHazeK * length(toSurface)); + vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); + o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); }`; -// The sky behind: the gradient's seven stops, zenith first, by the view ray's elevation (horizon stop at and below the horizon). -export const SKY_FRAG = `${HEADER} -uniform vec3 uGrad[7]; +// The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a +// dither of +-0.5/255 on the 8-bit output so the dusk glow does not band. +const DISC = { + inner: Math.cos((0.5 * Math.PI) / 180), + outer: Math.cos((1.1 * Math.PI) / 180), + gain: 6.0, +}; +export const SKY_FRAG = `${HEADER}${SKY_COLOUR_GLSL} +uniform vec3 uSunColour; uniform vec3 uRight, uUp, uForward; uniform vec2 uSize; uniform float uTanHalf; @@ -165,8 +186,9 @@ out vec4 o; void main() { vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); - float s = (1.0 - sqrt(smoothstep(-0.15, 1.0, d.y))) * 6.0; - int i = int(min(floor(s), 5.0)); - vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); - o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); + vec3 c = skyColour(d) + uSunColour * ${lit(DISC.gain)} * smoothstep(${DISC.outer}, ${ + DISC.inner +}, dot(d, uSun)); + float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5; + o = vec4(pow(c, vec3(1.0 / 2.2)) + dither / 255.0, 1.0); }`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index b3b4d21e..d8cb0129 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,29 +1,47 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { describe, expect, it } from 'vitest'; -import { SKY } from './skyData'; +import { baked } from '../../../../sky/baked'; import { - AMBIENT_GAIN, - NIGHT_LIFT, - ambientColour, + coldness, + glowColour, hazeColour, hexToLinear, + nightness, shade, + SKY, + skyAtSun, + skyColour, skyNameFor, - sunColour, sunDirection, transmittance, + Vec3, } from './terrainSky'; import { skyColours } from '../ridge/skyColour'; const UP = [0, 1, 0] as const; const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +/** Unit view direction at an elevation (degrees) and an azimuth clockwise from north. */ +const look = (el: number, az: number): Vec3 => { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; +}; -describe('baked sky numbers', () => { - it('keep the two moments apart: night sun below the horizon, noon sun well above', () => { +describe('the baked sky is read, not copied', () => { + it('SKY carries Void’s numbers: sun, ambient, horizon', () => { + for (const name of ['midnight', 'noonday'] as const) { + const b = baked[name]; + expect(SKY[name].elevation).toBe(b.sun.elevation_deg); + expect(SKY[name].azimuth).toBe(b.sun.azimuth_deg); + expect(SKY[name].direct).toEqual(b.sun.color_linear); + expect(SKY[name].ambient).toEqual(b.ambient_linear); + expect(SKY[name].horizon).toEqual(hexToLinear(b.horizon_hex)); + } + }); + + it('keeps the two moments apart: night sun below the horizon, noon sun well above', () => { expect(SKY.midnight.elevation).toBeLessThan(0); expect(SKY.noonday.elevation).toBeGreaterThan(30); - expect(SKY.midnight.gradient).toHaveLength(7); - expect(SKY.noonday.gradient).toHaveLength(7); }); it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { @@ -48,67 +66,118 @@ describe('baked sky numbers', () => { }); }); -describe('night fill', () => { - it('lifts ambient at night by NIGHT_LIFT and leaves a high sun at the plain gain', () => { - const z = (d: typeof SKY.midnight) => hexToLinear(d.gradient[0]); - ambientColour(SKY.midnight).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.midnight)[i] * AMBIENT_GAIN * NIGHT_LIFT, 9)); - ambientColour(SKY.noonday).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.noonday)[i] * AMBIENT_GAIN, 9)); +describe('a sun at any elevation (skyAtSun)', () => { + it('lights nothing under the horizon and fades in across the last degrees, as sun.js does', () => { + expect(skyAtSun(-2).direct).toEqual([0, 0, 0]); + expect(skyAtSun(-30).direct).toEqual([0, 0, 0]); + const at = (e: number) => luma(skyAtSun(e).direct); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeLessThan(at(60)); + }); + + it('agrees with the baked noon sun colour where they overlap, to a couple of percent', () => { + const want = SKY.noonday.direct; + const got = skyAtSun(SKY.noonday.elevation).direct; + got.forEach((v, i) => expect(Math.abs(v / want[i] - 1)).toBeLessThan(0.02)); + }); + + it('the sun goes red as it sets: less blue left in the direct light at 2 degrees than at 45', () => { + const lo = skyAtSun(2).direct; + const hi = skyAtSun(45).direct; + expect(lo[2] / lo[0]).toBeLessThan(0.6 * (hi[2] / hi[0])); + }); + + it('ambient and horizon run from the midnight bake to the noonday one as the sun climbs', () => { + expect(luma(skyAtSun(-30).ambient)).toBeCloseTo(luma(SKY.midnight.ambient), 9); + expect(luma(skyAtSun(60).ambient)).toBeCloseTo(luma(SKY.noonday.ambient), 9); + expect(luma(skyAtSun(5).ambient)).toBeGreaterThan(luma(SKY.midnight.ambient)); + expect(luma(skyAtSun(5).ambient)).toBeLessThan(luma(SKY.noonday.ambient)); + expect(skyAtSun(10, 270).azimuth).toBe(270); }); }); describe('lighting', () => { it('noonday lights an upward face with the sun; midnight has none', () => { - expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); - expect(sunColour(SKY.midnight)).toEqual([0, 0, 0]); - }); - - it('a sun below the horizon gives no negative or sun light on any normal, even with a lit colour', () => { - const forcedDown = { ...SKY.noonday, elevation: -30 }; - expect(sunColour(forcedDown)).toEqual([0, 0, 0]); - for (const n of [UP, [1, 0, 0], [0, -1, 0], [0, 0.6, -0.8]] as const) { - const c = shade(forcedDown, [1, 1, 1], n, 0); - for (const ch of c) expect(ch).toBeGreaterThanOrEqual(0); - } - }); - - it('the sun fades out through the horizon instead of switching', () => { - const at = (elevation: number) => luma(sunColour({ ...SKY.noonday, elevation })); - expect(at(-2)).toBe(0); - expect(at(0.3)).toBeGreaterThan(0); - expect(at(0.3)).toBeLessThan(at(1.5)); - expect(at(1.5)).toBeCloseTo(at(60), 9); + expect(luma(SKY.noonday.direct)).toBeGreaterThan(0.5); + expect(SKY.midnight.direct).toEqual([0, 0, 0]); }); it('shade never goes negative on any normal at either sky, at any distance', () => { - for (const d of Object.values(SKY)) + for (const d of [...Object.values(SKY), skyAtSun(-3), skyAtSun(1)]) for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) for (const dist of [0, 100, 5000]) - for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist)) expect(ch).toBeGreaterThanOrEqual(0); + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist, look(-5, 40))) + expect(ch).toBeGreaterThanOrEqual(0); }); it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { const toSun = sunDirection(SKY.noonday); const away = [-toSun[0], 0, -toSun[2]] as const; - const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0)); - const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0)); + const view = look(-5, 0); + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0, view)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0, view)); expect(lit).toBeGreaterThan(3 * dark); - const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0); + const m = (n: readonly [number, number, number]) => + shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black expect(luma(m(UP))).toBeLessThan(0.05); }); it('noon is much brighter than midnight, ambient included', () => { - expect(luma(ambientColour(SKY.noonday))).toBeGreaterThan(2 * luma(ambientColour(SKY.midnight))); - expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0))).toBeGreaterThan( - 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0)) + expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const view = look(-5, 0); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( + 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) ); }); - it('noon ambient matches sun.js diffuse at that moment to within a few percent (the gain)', () => { - const a = ambientColour(SKY.noonday); - const diffuse = [0.07, 0.132, 0.27]; // light() at 2026-09-22T12:00Z, 45N 0E, revision 7b4aa2f - for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeGreaterThan(0.85); - for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeLessThan(1.2); + it('the cold tint (albedo) is 0 with the sun up and 1 once it is below -6', () => { + expect(coldness(SKY.noonday)).toBe(0); + expect(coldness(SKY.midnight)).toBe(1); + expect(hazeColour(SKY.noonday)).toEqual(SKY.noonday.horizon); + }); +}); + +describe('the scattered sky', () => { + it('noon is blue overhead: more blue than red, much brighter than the night glow', () => { + const zenith = skyColour(SKY.noonday, [0, 1, 0]); + expect(zenith[2]).toBeGreaterThan(1.5 * zenith[0]); + expect(luma(zenith)).toBeGreaterThan(5 * luma(skyColour(SKY.midnight, [0, 1, 0]))); + zenith.forEach((v) => expect(v).toBeLessThan(1)); + }); + + it('a sun at the horizon reddens the sky toward it and darkens it away', () => { + const dusk = skyAtSun(-1, 270); + const toward = skyColour(dusk, look(3, 270)); + const away = skyColour(dusk, look(3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + }); + + it('under a deep night sun the tables are black and only the glow remains, brightest at the horizon', () => { + const d = SKY.midnight; + const horizon = skyColour(d, look(0, 180)); + const zenith = skyColour(d, [0, 1, 0]); + horizon.forEach((v, i) => expect(v).toBeCloseTo(d.horizon[i], 6)); + expect(luma(zenith)).toBeLessThan(luma(horizon)); + expect(luma(zenith)).toBeGreaterThan(0.5 * luma(horizon)); + }); + + it('the night glow fades out as the sun reaches the horizon', () => { + expect(nightness(-10)).toBe(1); + expect(nightness(5)).toBe(0); + expect(glowColour(SKY.noonday)).toEqual([0, 0, 0]); + expect(glowColour(SKY.midnight)).toEqual(SKY.midnight.horizon); + expect(luma(glowColour(skyAtSun(-2)))).toBeLessThan(luma(glowColour(skyAtSun(-6)))); + }); + + it('is continuous in the sun’s elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(skyColour(skyAtSun(e, 270), look(10, 200))); + for (let e = -20; e < 30; e += 0.5) { + expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.05); + } }); }); @@ -124,24 +193,33 @@ describe('haze', () => { } }); - it('far surfaces take the horizon colour of the baked gradient, at both skies', () => { - for (const d of Object.values(SKY)) { - const far = shade(d, [0.9, 0.2, 0.1], UP, 100000); - const h = hazeColour(d); - for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(h[i], 6); + it('far surfaces take the scattered sky at the horizon in their azimuth, at both skies', () => { + for (const d of [...Object.values(SKY), skyAtSun(2, 270)]) { + const far = shade(d, [0.9, 0.2, 0.1], UP, 100000, look(-3, 200)); + const sky = skyColour(d, look(0, 200)); + for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(sky[i], 6); } - expect(hazeColour(SKY.noonday)).toEqual(hexToLinear('#e2f4ff')); - expect(hazeColour(SKY.midnight)).toEqual(hexToLinear('#484e64')); + }); + + it('haze toward the dusk sun is warmer and brighter than haze away from it', () => { + const dusk = skyAtSun(-1, 270); + const toward = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 270)); + const away = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); }); it('haze colour differs between the two skies: pale day horizon, dim night one', () => { - expect(luma(hazeColour(SKY.noonday))).toBeGreaterThan(5 * luma(hazeColour(SKY.midnight))); + const far = (d: typeof SKY.midnight) => luma(shade(d, [0, 0, 0], UP, 1e6, look(-3, 180))); + expect(far(SKY.noonday)).toBeGreaterThan(5 * far(SKY.midnight)); }); it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { const d = SKY.noonday; - const h = luma(hazeColour(d)); - const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x)); + const view = look(-4, 200); + const h = luma(skyColour(d, look(0, 200))); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); expect(dark(2000)).toBeGreaterThan(dark(200)); expect(dark(2000)).toBeLessThan(h); }); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index d0f41c40..a7e38a39 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -1,8 +1,25 @@ -// Lighting and haze for the terrain scene, as pure functions of one sky's baked numbers (./skyData.ts). -// No DOM, no GL. The fragment shader does the same arithmetic with these results as uniforms. +// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's +// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. +import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; import type { SkyColours } from '../ridge/skyColour'; -import { SKY, SkyData, SkyName, Vec3 } from './skyData'; +import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Sky ambient on an upward surface, linear RGB; at night a moonlit floor, not black. */ + ambient: Vec3; + /** The horizon, linear RGB: the baked `horizon_hex`. */ + horizon: Vec3; +} const RAD = Math.PI / 180; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); @@ -10,6 +27,8 @@ const smooth = (a: number, b: number, x: number) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); }; +const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => + [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; /** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); @@ -25,6 +44,39 @@ export function hexToLinear(hex: string): Vec3 { return [ch(1), ch(3), ch(5)]; } +const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ + elevation: b.sun.elevation_deg, + azimuth: b.sun.azimuth_deg, + direct: b.sun.color_linear, + ambient: b.ambient_linear, + horizon: hexToLinear(b.horizon_hex), +}); + +export const SKY: Record = { + midnight: fromBaked(baked.midnight), + noonday: fromBaked(baked.noonday), +}; + +export const skyData = (name: SkyName): SkyData => SKY[name]; + +/** + * A sky for a sun at any elevation, between the two baked ones. The sun's colour is the baked transmittance at + * that elevation, faded out across the disc's last degrees the way sun.js does (gone at -0.83, whole by 1.5); + * ambient and horizon blend from the midnight bake to the noonday one as the sun climbs from -8 to 25 degrees. + */ +export function skyAtSun(elevation: number, azimuth: number = SKY.noonday.azimuth): SkyData { + const day = smooth(-8, 25, elevation); + const fade = smooth(-0.83, 1.5, elevation); + const t = transmittanceAt(Math.max(elevation, -4)); + return { + elevation, + azimuth, + direct: [t[0] * fade, t[1] * fade, t[2] * fade], + ambient: mixVec(SKY.midnight.ambient, SKY.noonday.ambient, day), + horizon: mixVec(SKY.midnight.horizon, SKY.noonday.horizon, day), + }; +} + /** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ export function sunDirection(d: SkyData): Vec3 { const el = d.elevation * RAD; @@ -32,59 +84,68 @@ export function sunDirection(d: SkyData): Vec3 { return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; } -/** - * The sun's colour as the ground sees it: baked `direct`, off at the horizon the way sun.js fades the disc - * (gone at -0.83 degrees, soft to 1.5). A sun below the horizon lights nothing. - */ -export function sunColour(d: SkyData): Vec3 { - const k = smooth(-0.83, 1.5, d.elevation); - return [d.direct[0] * k, d.direct[1] * k, d.direct[2] * k]; -} - -/** - * Ambient light on an upward surface, from the baked gradient's zenith stop. The gain is chosen so the - * noonday value (0.075, 0.14, 0.25) matches sun.js's own `diffuse` there (0.07, 0.13, 0.27); at midnight - * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. - */ -export const AMBIENT_GAIN = 0.25; - -/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette and fill share. */ +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette shares. */ export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); -/** Extra ambient at night, so a moonlit face is readable and not black on a phone: x4 at full night, 1 by day. */ -export const NIGHT_LIFT = 4; +/** The colour distance fades to when the air has nothing to scatter: the baked horizon. Albedo tints toward it at night. */ +export const hazeColour = (d: SkyData): Vec3 => d.horizon; -export function ambientColour(d: SkyData): Vec3 { - const z = hexToLinear(d.gradient[0]); - const k = AMBIENT_GAIN * (1 + (NIGHT_LIFT - 1) * coldness(d)); - return [z[0] * k, z[1] * k, z[2] * k]; +/** Sky radiance (see the sun's `skyScatter` in terrainScatter.ts) is for unit sun irradiance; this lifts it to the image. EYEBALLED. */ +export const SKY_EXPOSURE = 45; +/** How far the night glow dims from the horizon to the zenith (1 at the horizon). EYEBALLED. */ +export const GLOW_ZENITH = 0.75; + +/** 1 with the sun below -4 degrees, 0 above 4: where the midnight horizon colour stands in for light the single-scattering tables lack. */ +export const nightness = (elevation: number): number => 1 - smooth(-4, 4, elevation); + +/** The night glow colour: the midnight bake's horizon, fading out as the sun nears the horizon (whatever horizon the day blend gives `d`). */ +export const glowColour = (d: SkyData): Vec3 => { + const k = nightness(d.elevation); + const h = SKY.midnight.horizon; + return [h[0] * k, h[1] * k, h[2] * k]; +}; + +/** + * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering + * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits + * brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same. + */ +export function skyColour(d: SkyData, dir: Vec3): Vec3 { + const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const r = scatterRadiance(d.elevation, el, az); + const glow = glowColour(d); + const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); + return [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape + ) as unknown as Vec3; } -/** The colour distance fades to: the gradient's horizon stop, linear. */ -export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); - /** Haze: transmittance T = exp(-k d) at distance d metres. */ export const HAZE_K = 0.0004; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); /** - * Linear colour of a surface: lambert from the sun plus ambient (full on an upward face, half on a - * vertical one, none on a downward one: `0.5 + 0.5 n.y`), then hazed toward the horizon colour by distance. - * `normal` is a unit vector. Never negative, whatever the sun does. + * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from + * the sun plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. */ -export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number): Vec3 { +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { const sun = sunDirection(d); const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); - const direct = sunColour(d); - const amb = ambientColour(d); const hemi = 0.5 + 0.5 * normal[1]; - const haze = hazeColour(d); + const flat = Math.hypot(view[0], view[2]) || 1; + const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); const t = transmittance(distance); return [0, 1, 2].map((i) => { - const lit = albedo[i] * (direct[i] * lambert + amb[i] * Math.max(0, hemi)); + const lit = albedo[i] * (d.direct[i] * lambert + d.ambient[i] * Math.max(0, hemi)); return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } - -export const skyData = (name: SkyName): SkyData => SKY[name]; diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts index 370b6843..c1231bd8 100644 --- a/src/app/features/scene/terrain/terrainStars.test.ts +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SKY } from './skyData'; +import { SKY, skyAtSun } from './terrainSky'; import { BRIGHTEN, createTerrainStars, @@ -9,12 +9,12 @@ import { FRAME_MS, horizonY, PERIOD, + nightColours, project, - skyColoursOf, + starGain, } from './terrainStars'; import { CAMERA, view } from './terrainWorld'; import { isNight, makeStars } from '../ridge/stars'; -import { skyNameFor } from './terrainSky'; /** A 2D context that only counts: arcs painted and clears since the last reset. */ function fakeCtx() { @@ -64,10 +64,21 @@ const glCanvas = (w = 390, h = 844) => ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); describe('which skies get stars', () => { - it("midnight is dark, noonday is not, by the ridge module's own test", () => { - expect(isNight(skyColoursOf(SKY.midnight))).toBe(true); - expect(isNight(skyColoursOf(SKY.noonday))).toBe(false); - expect(skyNameFor(skyColoursOf(SKY.noonday))).toBe('noonday'); + it('the field is placed against the baked night gradient, which the ridge module calls dark', () => { + expect(isNight(nightColours())).toBe(true); + }); + + it('starGain: full below -12 degrees, none above -6, smooth and monotone between', () => { + expect(starGain(-44.6)).toBe(1); + expect(starGain(-12)).toBe(1); + expect(starGain(-6)).toBe(0); + expect(starGain(45.2)).toBe(0); + expect(starGain(-9)).toBeCloseTo(0.5, 9); + let prev = 1; + for (let e = -12; e <= -6; e += 0.25) { + expect(starGain(e)).toBeLessThanOrEqual(prev); + prev = starGain(e); + } }); it('noonday gets no layer and no element', () => { @@ -75,6 +86,17 @@ describe('which skies get stars', () => { expect(parent.appendChild).not.toHaveBeenCalled(); }); + it('twilight is a fade, not a gate: a sun at -9 degrees paints the stars at half alpha', () => { + createTerrainStars(glCanvas(), skyAtSun(-9))?.draw(0); + const half = Math.max(...ctx2d.c.alphas); + ctx2d.c.alphas = []; + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + expect(half).toBeGreaterThan(0); + expect(half / Math.max(...ctx2d.c.alphas)).toBeCloseTo(0.5, 6); + expect(createTerrainStars(glCanvas(), skyAtSun(-6.5))).not.toBeNull(); + expect(createTerrainStars(glCanvas(), skyAtSun(-6))).toBeNull(); + }); + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { const s = createTerrainStars(glCanvas(), SKY.midnight)!; expect(parent.appendChild).toHaveBeenCalledWith(overlay); @@ -172,12 +194,10 @@ describe('turning with the camera', () => { describe('brightness', () => { it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); - const want = makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => - Math.min(1, st.alpha * BRIGHTEN) - ); + const want = makeStars(nightColours(), 390, 844).map((st) => Math.min(1, st.alpha * BRIGHTEN)); ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); // and it is brighter than the ridge field's own top alpha - const top = Math.max(...makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => st.alpha)); + const top = Math.max(...makeStars(nightColours(), 390, 844).map((st) => st.alpha)); expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); }); }); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts index 4fca72c9..a7275062 100644 --- a/src/app/features/scene/terrain/terrainStars.ts +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -1,5 +1,5 @@ // The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the -// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, shown only when the baked sky is dark. +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, faded by the sun's elevation (starGain). // // The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the // horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak @@ -13,9 +13,10 @@ // the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) // paints every star at base brightness. +import { baked } from '../../../../sky/baked'; import type { SkyColours, Srgb } from '../ridge/skyColour'; -import { activeAt, isNight, makeStars, SKY_STOPS, Star } from '../ridge/stars'; -import { SkyData } from './skyData'; +import { activeAt, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './terrainSky'; import { CAMERA } from './terrainWorld'; /** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ @@ -30,9 +31,19 @@ const TWINKLE_MS = 50; const hex = (h: string): Srgb => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; -/** The ridge star module's view of a baked terrain sky: zenith, the middle stop, and the horizon stop. */ -export function skyColoursOf(d: SkyData): SkyColours { - const g = d.gradient; +/** + * How much of the starfield shows with the sun at `elevation` degrees: all of it below -12 (where the baked + * scattering goes dark: the planet's shadow covers every sunlit air path), none above -6 (civil twilight), a smooth + * fade between. Not a dark/day gate: a sun at -9 shows the stars at half. + */ +export function starGain(elevation: number): number { + const t = Math.min(1, Math.max(0, (elevation + 12) / 6)); + return 1 - t * t * (3 - 2 * t); +} + +/** The ridge star module's view of the night sky it places stars against: the baked midnight gradient's zenith, middle and horizon stops. */ +export function nightColours(): SkyColours { + const g = baked.midnight.gradient; return { top: hex(g[0]), mid: hex(g[Math.floor(g.length / 2)]), @@ -84,11 +95,12 @@ export interface TerrainStars { dispose(): void; } -/** The layer for `canvas`'s parent, or null: a day sky, no parent, no 2D context. */ +/** The layer for `canvas`'s parent, or null: the sun is too high for any star, no parent, no 2D context. */ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { - const colours = skyColoursOf(sky); + const colours = nightColours(); + const gain = starGain(sky.elevation); const parent = canvas.parentElement; - if (!parent || !isNight(colours)) return null; + if (!parent || gain <= 0) return null; const el = document.createElement('canvas'); const ctx = el.getContext?.('2d'); if (!ctx) return null; @@ -129,8 +141,8 @@ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): Ter const yaw = 2 * Math.PI * turns; lit.forEach(({ star, az, e }, i) => { const da = wrap(az - yaw); - const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; - ctx.globalAlpha = Math.min(1, star.alpha * gain * BRIGHTEN); + const twinkle = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * twinkle * BRIGHTEN) * gain; ctx.fillStyle = star.fill; [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { const at = project(d, e, w, h); -- 2.47.3 From e007514af3b8aac104f76995c13df06cc2f59d18 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 08:10:28 +0000 Subject: [PATCH 15/24] scene: terrain falls back to its sky alone on a 2D canvas where WebGL2 is missing createTerrainScene returned null without WebGL2, leaving plain dark ground. The door now mounts terrain/TerrainSky2D.ts on the same canvas: skyColour (the CPU twin of SKY_FRAG) painted once, the star layer on top when dark, same ?sky= ?sun= ?az= choice. chooseSky moves from Terrain.ts to terrainSky.ts so both scenes share one owner. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 + src/app/features/scene/index.ts | 14 +- src/app/features/scene/terrain/Terrain.ts | 27 +-- .../scene/terrain/TerrainSky2D.test.ts | 220 ++++++++++++++++++ .../features/scene/terrain/TerrainSky2D.ts | 83 +++++++ src/app/features/scene/terrain/terrainSky.ts | 18 ++ 6 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 src/app/features/scene/terrain/TerrainSky2D.test.ts create mode 100644 src/app/features/scene/terrain/TerrainSky2D.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 5bf2ef59..35075536 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -51,6 +51,11 @@ Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az= Scene | null; try { if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') - createScene = (await import('./terrain/Terrain')).createTerrainScene; - else createScene = (await import('./ridge/Ridge')).createRidgeScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const scene = createTerrainScene({ ...options, tier }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, tier }); + } else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 90d90ca2..3db90d3c 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,20 +1,9 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; -import { skyColours } from '../ridge/skyColour'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { - glowColour, - HAZE_K, - isSkyName, - skyAtSun, - SkyData, - skyData, - SkyName, - skyNameFor, - sunDirection, -} from './terrainSky'; +import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -77,20 +66,6 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opts: Pick): SkyData { - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); - return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); - } - if (opts.sky) return skyData(opts.sky); - const name = q.get('sky'); - return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); -} - /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts new file mode 100644 index 00000000..c3eb1e3d --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; +import { startBackgroundScene } from '../index'; + +let search: string; +let arcs: number; +let overlay: { remove: ReturnType }; +let windowListeners: Record void>; +beforeEach(() => { + search = ''; + arcs = 0; + windowListeners = {}; + vi.stubGlobal('document', { + createElement: () => { + overlay = { + remove: vi.fn(), + style: {}, + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => { + arcs += 1; + }, + }), + } as any; + return overlay; + }, + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + addEventListener: (n: string, f: () => void) => { + windowListeners[n] = f; + }, + removeEventListener: (n: string) => { + delete windowListeners[n]; + }, + get location() { + return { search }; + }, + setTimeout, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ +function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { + const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; + const ctx = { + createImageData: (width: number, height: number) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }), + putImageData: (image: any) => painted.push(image), + }; + const parentAppend = vi.fn(); + const canvas: any = { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: parentAppend }, + getContext: vi.fn((kind: string) => { + if (kind !== '2d') return opts.gl ?? null; + return opts.twoD === false ? null : ctx; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + return { canvas, painted, parentAppend }; +} + +const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => + Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); +const mean = (img: { data: Uint8ClampedArray }) => { + let s = 0; + for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; + return s / (img.data.length / 4) / 3; +}; + +describe('createTerrainSkyScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = canvasWith(); + expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { + const { canvas } = canvasWith({ twoD: false }); + expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); + }); + + it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(painted).toHaveLength(1); + const img = painted[0]; + expect(img.width).toBe(SKY_WIDTH); + expect(canvas.width).toBe(SKY_WIDTH); + expect(canvas.height).toBe(img.height); + for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); + const x = Math.floor(SKY_WIDTH / 2); + expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); + expect(mean(img)).toBeGreaterThan(20); + s.dispose(); + }); + + it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { + const noon = canvasWith(); + createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + const night = canvasWith(); + createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); + expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); + + const low = canvasWith(); + createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); + const down = canvasWith(); + createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); + expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); + + search = '?sun=30&az=0'; + const q0 = canvasWith(); + createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); + search = '?sun=30&az=180'; + const q1 = canvasWith(); + createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); + expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); + }); + + it('stars only on a dark sky', () => { + const night = canvasWith(); + const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; + expect(night.parentAppend).toHaveBeenCalledTimes(1); + expect(arcs).toBeGreaterThan(0); + a.dispose(); + expect(overlay.remove).toHaveBeenCalled(); + arcs = 0; + const day = canvasWith(); + createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + expect(day.parentAppend).not.toHaveBeenCalled(); + expect(arcs).toBe(0); + }); + + it('repaints on resize, and dispose stops listening', () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + ...(window as any), + setTimeout, + addEventListener: (window as any).addEventListener, + }); + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(windowListeners.resize).toBeDefined(); + windowListeners.resize(); + vi.advanceTimersByTime(300); + expect(painted).toHaveLength(2); + s.dispose(); + expect(windowListeners.resize).toBeUndefined(); + vi.useRealTimers(); + }); +}); + +describe('startBackgroundScene, terrain style', () => { + it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { + const { canvas, painted } = canvasWith({ gl: null }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); + expect(painted).toHaveLength(1); + scene!.dispose(); + }); + + it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return () => ({}); + }, + } + ); + Object.assign(document, { + addEventListener() {}, + removeEventListener() {}, + visibilityState: 'visible', + }); + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('performance', { now: () => 0 }); + (window as any).matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { canvas, painted } = canvasWith({ gl }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); + expect(painted).toHaveLength(0); + scene!.dispose(); + }); + + it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { + const { canvas } = canvasWith({ gl: null, twoD: false }); + expect( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) + ).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts new file mode 100644 index 00000000..e61c6fee --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -0,0 +1,83 @@ +import type { Scene } from '../Scene'; +import type { TerrainOptions } from './Terrain'; +import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { createTerrainStars } from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; + +/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ +export const SKY_WIDTH = 96; + +/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ +export type TerrainSkyOptions = Pick; + +/** + * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the + * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so + * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so + * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already + * holds a WebGL context and gives no 2D one. + */ +export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { + if ((opts.tier ?? 'full') === 'off') return null; + const { canvas } = opts; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const sky = chooseSky(opts); + const stars = createTerrainStars(canvas, sky); + const tanHalf = Math.tan(CAMERA.fov / 2); + let disposed = false; + let resizeTimer = 0; + + const paint = () => { + const w = SKY_WIDTH; + const cw = canvas.clientWidth || window.innerWidth; + const ch = canvas.clientHeight || window.innerHeight; + const h = Math.max(1, Math.round((w * ch) / cw)); + canvas.width = w; + canvas.height = h; + const v = view(cw / ch, 0); + const image = ctx.createImageData(w, h); + for (let y = 0; y < h; y += 1) { + const ny = 1 - ((y + 0.5) / h) * 2; + for (let x = 0; x < w; x += 1) { + const nx = ((x + 0.5) / w) * 2 - 1; + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] + ); + const len = Math.hypot(d[0], d[1], d[2]); + const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); + const at = (y * w + x) * 4; + for (let i = 0; i < 3; i += 1) { + image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); + } + image.data[at + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + stars?.draw(0); + }; + + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + stars?.resize(); + paint(); + }, 250); + }; + window.addEventListener('resize', onResize); + paint(); + + return { + login() { + // Nothing moves, so there is no pull-back to start. + }, + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(resizeTimer); + window.removeEventListener('resize', onResize); + stars?.dispose(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index a7e38a39..b77ddbf2 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -2,6 +2,7 @@ // bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; +import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; @@ -149,3 +150,20 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } + +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +export function chooseSky(opts: { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; +}): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); +} -- 2.47.3 From 1296f1833820f123ecdf8f0cc94c39edb87226f9 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 17:38:27 +0000 Subject: [PATCH 16/24] scene: terrain relief and contrast on a phone, a tier label Why it read hazy and flat at 390x844: the camera started with the noon sun straight behind it (no shading), the low tier sampled a 512 height map (6 m texels), plain exp(-k d) haze greyed the near ground, albedo had no grain, and the night ground sat at ambient 0.018 (near black). - camera starts with the noon sun 100 degrees off axis (START_TURNS; ?az= still moves the sun) - haze T = exp(-(k d)^1.6), k 0.0007: near ground clear, far ridges melt - low tier bakes 1024 texels - albedo grain (three-wave octaves, fine one fades with distance) and rock strata; shader-only cavity shading - night: moon from the sun's mirrored position, ambient lifted by nightness - tier label (full / low / still / sky-only) in preview builds or ?tierlabel=1 Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 14 +++--- src/app/features/scene/terrain/Terrain.ts | 24 +++++++-- .../features/scene/terrain/TerrainSky2D.ts | 10 ++-- .../scene/terrain/terrainAlbedo.test.ts | 30 +++++++++++- .../features/scene/terrain/terrainAlbedo.ts | 40 +++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 25 ++++++++-- .../features/scene/terrain/terrainSky.test.ts | 41 +++++++++++++++- src/app/features/scene/terrain/terrainSky.ts | 39 ++++++++++++--- .../features/scene/terrain/terrainWorld.ts | 2 +- .../features/scene/terrain/tierLabel.test.ts | 49 +++++++++++++++++++ src/app/features/scene/terrain/tierLabel.ts | 26 ++++++++++ 11 files changed, 272 insertions(+), 28 deletions(-) create mode 100644 src/app/features/scene/terrain/tierLabel.test.ts create mode 100644 src/app/features/scene/terrain/tierLabel.ts diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 446ab9cf..b61b632b 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; -import { SKY, skyAtSun } from './terrainSky'; +import { groundLight, SKY, skyAtSun } from './terrainSky'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; @@ -145,8 +145,8 @@ describe('createTerrainScene', () => { }; const night = uploaded({}); const day = uploaded({ sky: 'noonday' }); - const nightA = Array.from(SKY.midnight.ambient); - const dayA = Array.from(SKY.noonday.ambient); + const nightA = Array.from(groundLight(SKY.midnight).ambient); + const dayA = Array.from(groundLight(SKY.noonday).ambient); const has = (all: number[][], v: number[]) => all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); expect(has(night, nightA)).toBe(true); @@ -167,11 +167,13 @@ describe('createTerrainScene', () => { const dusk = skyAtSun(-1, 300); const has = (all: number[][], v: readonly number[]) => all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); - expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), dusk.ambient)).toBe(true); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), groundLight(dusk).ambient)).toBe( + true + ); search = '?sky=noonday&sun=-1&az=300'; - expect(has(ambients({}), dusk.ambient)).toBe(true); + expect(has(ambients({}), groundLight(dusk).ambient)).toBe(true); search = '?sun=garbage'; - expect(has(ambients({}), SKY.midnight.ambient)).toBe(true); + expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); }); it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 3db90d3c..d3c95ff3 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -3,7 +3,15 @@ import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; +import { + glowColour, + groundLight, + HAZE_K, + chooseSky, + SkyName, + START_TURNS, + sunDirection, +} from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -13,6 +21,7 @@ import { SKY_FRAG, } from './terrainShaders'; import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; export type TerrainOptions = Pick< SceneOptions, @@ -178,7 +187,9 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const sun = [...sunDirection(sky)]; const sunLight = [...sky.direct]; - const ambient = [...sky.ambient]; + const ground = groundLight(sky); + const ambient = [...ground.ambient]; + const heading = START_TURNS; const glow = [...glowColour(sky)]; const sunTexel = sunIndex(sky.elevation); const palette = albedoPalette(sky); @@ -206,7 +217,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const draw = (turns: number) => { if (lost) return; gl.viewport(0, 0, canvas.width, canvas.height); - const v = view(canvas.width / canvas.height, turns % 1); + const v = view(canvas.width / canvas.height, (turns + heading) % 1); gl.disable(gl.DEPTH_TEST); gl.useProgram(skyProg); const us = (n: string) => gl.getUniformLocation(skyProg, n); @@ -233,7 +244,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform1f(u('uSpan'), TERRAIN.span); gl.uniform1i(u('uTex'), spec.tex); gl.uniform3fv(u('uSun'), sun); - gl.uniform3fv(u('uSunColour'), sunLight); + gl.uniform3fv(u('uLightDir'), ground.dir); + gl.uniform3fv(u('uLight'), ground.colour); gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uGlow'), glow); gl.uniform1f(u('uSunIndex'), sunTexel); @@ -252,7 +264,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.bindVertexArray(vao); gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); gl.bindVertexArray(null); - stars?.draw(turns % 1, moving() ? performance.now() / 1000 : undefined); + stars?.draw((turns + heading) % 1, moving() ? performance.now() / 1000 : undefined); }; const moving = () => @@ -347,6 +359,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { }); canvas.parentElement.appendChild(readout); } + const removeLabel = showTierLabel(canvas, tier); if (still) settleToStill(); else wake(); if (!still && !moving()) draw(0); @@ -383,6 +396,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { reducedMotion?.removeEventListener?.('change', onVisibility); canvas.removeEventListener('webglcontextlost', onLost); readout?.remove(); + removeLabel?.(); stars?.dispose(); release(); }, diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index e61c6fee..0c19ec97 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -1,7 +1,8 @@ import type { Scene } from '../Scene'; import type { TerrainOptions } from './Terrain'; -import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { chooseSky, skyColour, START_TURNS, Vec3 } from './terrainSky'; import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; import { CAMERA, view } from './terrainWorld'; /** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ @@ -24,6 +25,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { if (!ctx) return null; const sky = chooseSky(opts); const stars = createTerrainStars(canvas, sky); + const heading = START_TURNS; const tanHalf = Math.tan(CAMERA.fov / 2); let disposed = false; let resizeTimer = 0; @@ -35,7 +37,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { const h = Math.max(1, Math.round((w * ch) / cw)); canvas.width = w; canvas.height = h; - const v = view(cw / ch, 0); + const v = view(cw / ch, heading); const image = ctx.createImageData(w, h); for (let y = 0; y < h; y += 1) { const ny = 1 - ((y + 0.5) / h) * 2; @@ -54,7 +56,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { } } ctx.putImageData(image, 0, 0); - stars?.draw(0); + stars?.draw(heading); }; const onResize = () => { @@ -67,6 +69,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { }; window.addEventListener('resize', onResize); paint(); + const removeLabel = showTierLabel(canvas, 'sky-only'); return { login() { @@ -77,6 +80,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { disposed = true; clearTimeout(resizeTimer); window.removeEventListener('resize', onResize); + removeLabel?.(); stars?.dispose(); }, }; diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 0136b5f3..385344d3 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,7 +1,7 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; import { SKY } from './terrainSky'; -import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; +import { ALBEDO, albedoAt, albedoPalette, grainAt, wobble } from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => @@ -98,7 +98,35 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); 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}`); // 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)/g)).toHaveLength(1); }); }); + +describe('grain', () => { + const samples = (rock: number, distance: number) => + Array.from({ length: 400 }, (_, i) => grainAt(i * 1.7, i * 0.9, 100 + i * 0.3, rock, distance)); + const spread = (v: number[]) => Math.max(...v) - Math.min(...v); + + it('varies the surface near the eye, never goes negative, and averages about 1', () => { + const v = samples(0, 50); + expect(spread(v)).toBeGreaterThan(0.4); + expect(Math.min(...v)).toBeGreaterThanOrEqual(0); + expect(v.reduce((a, b) => a + b, 0) / v.length).toBeGreaterThan(0.85); + }); + + it('drops its fine octave with distance, so far ground does not shimmer', () => { + const rangeOf = (d: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, i * 0.21, 100, 0, d))); + expect(rangeOf(900)).toBeLessThan(rangeOf(50)); + }); + + it('layers the rock: strata show only where the face is rock', () => { + const height = (rock: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 0, 100 + i * 0.05, rock, 2000))); + expect(height(1)).toBeGreaterThan(0.2); + expect(height(0)).toBeLessThan(1e-9); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index e3230a8b..d452dec8 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -28,6 +28,13 @@ export const ALBEDO = { /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ wobbleHeight: 14, wobbleSlope: 0.05, + /** 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, + /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ + fineNear: 250, + fineFar: 700, + strata: 0.22, } as const; export interface AlbedoPalette { @@ -95,3 +102,36 @@ export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) ) as unknown as Vec3; } + +/** + * The multiplier on albedo that gives the surface grain and the rock its layers, from world position (metres), + * height, `rock` (0..1, how much of the face is rock) and the distance from the eye. The shader's `ALB_GRAIN_*` block does the same arithmetic. + * The cavity term beside it in the shader reads the height map and has no twin here. + */ +export function grainAt( + x: number, + z: number, + height: number, + rock: number, + distance: number +): number { + const waves = (k: ReadonlyArray) => + k.reduce((sum, [kx, kz]) => sum + Math.sin(x * kx + z * kz), 0) / 3; + const coarse = waves([ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], + ]); + const fine = + waves([ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], + ]) * + (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(height * 0.9 + wobble(x, z) * 3); + return Math.max( + 0, + 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers + ); +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 8418e91a..04d0425b 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -9,7 +9,7 @@ import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; import { SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, SKY_EXPOSURE } from './terrainSky'; +import { GLOW_ZENITH, HAZE_POWER, SKY_EXPOSURE } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -122,6 +122,11 @@ const ALBEDO_DEFINES = ` #define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} #define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} +#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_STRATA ${lit(ALBEDO.strata)} +#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} +#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} `; // The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The // same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. @@ -136,7 +141,7 @@ vec3 skyColour(vec3 d) { export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSunColour, uAmbient, uSoil, uScree, uRock, uSnow; +uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} @@ -163,9 +168,21 @@ void main() { float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); - vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). + vec2 q = v_world.xz; + float coarse = (sin(dot(q, vec2(0.31, 0.27))) + sin(dot(q, vec2(-0.22, 0.35))) + sin(dot(q, vec2(0.38, -0.12)))) / 3.0; + float fine = (sin(dot(q, vec2(1.1, 0.7))) + sin(dot(q, vec2(-0.8, 1.2))) + sin(dot(q, vec2(1.3, -0.6)))) / 3.0; + 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); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. + int r = max(1, int(12.0 / texel)); + ivec2 c = ivec2(p + 0.5); + float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); + float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); + vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; - float T = exp(-uHazeK * length(toSurface)); + float T = exp(-pow(uHazeK * length(toSurface), ${lit(HAZE_POWER)})); vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); }`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index d8cb0129..9e5bc91e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -14,6 +14,10 @@ import { skyNameFor, sunDirection, transmittance, + groundLight, + HAZE_K, + HAZE_POWER, + START_TURNS, Vec3, } from './terrainSky'; import { skyColours } from '../ridge/skyColour'; @@ -121,14 +125,14 @@ describe('lighting', () => { const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black - expect(luma(m(UP))).toBeLessThan(0.05); + expect(luma(m(UP))).toBeLessThan(0.12); }); it('noon is much brighter than midnight, ambient included', () => { expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); const view = look(-5, 0); expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( - 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) + 3 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) ); }); @@ -224,3 +228,36 @@ describe('haze', () => { expect(dark(2000)).toBeLessThan(h); }); }); + +describe('ground light and haze', () => { + it("at night the ground has a moon above the horizon on the sun's mirrored side, and a lifted ambient; by day it is the sun", () => { + const night = groundLight(SKY.midnight); + expect(night.dir[1]).toBeGreaterThan(0.5); + expect(luma(night.colour)).toBeGreaterThan(0.03); + expect(luma(night.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const day = groundLight(SKY.noonday); + expect(Array.from(day.dir)).toEqual(Array.from(sunDirection(SKY.noonday))); + expect(Array.from(day.colour)).toEqual(Array.from(SKY.noonday.direct)); + expect(Array.from(day.ambient)).toEqual(Array.from(SKY.noonday.ambient)); + }); + + it('the ground light is continuous through sunset', () => { + const at = (e: number) => luma(groundLight(skyAtSun(e)).colour); + for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); + }); + + it('haze leaves the near ground nearly clear and takes the far ridges: a power law, not plain exp(-k d)', () => { + expect(transmittance(300)).toBeGreaterThan(0.85); + expect(transmittance(300)).toBeGreaterThan(Math.exp(-HAZE_K * 300)); + expect(transmittance(2500)).toBeLessThan(0.15); + expect(HAZE_POWER).toBeGreaterThan(1); + }); + + it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { + const yaw = 2 * Math.PI * START_TURNS; + const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; + const sun = sunDirection(SKY.noonday); + const along = (forward[0] * sun[0] + forward[2] * sun[2]) / Math.hypot(sun[0], sun[2]); + expect(Math.abs(along)).toBeLessThan(0.4); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index b77ddbf2..697896a3 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -128,25 +128,52 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { ) as unknown as Vec3; } -/** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0004; +/** + * Haze: transmittance T = exp(-(k d)^HAZE_POWER) at distance d metres. The power above 1 keeps the near and middle + * ground at its own colour and lets only the far ridges melt toward the sky; a plain exp(-k d) greyed everything. + */ +export const HAZE_K = 0.0007; +export const HAZE_POWER = 1.6; export const transmittance = (distance: number): number => - Math.exp(-HAZE_K * Math.max(0, distance)); + Math.exp(-((HAZE_K * Math.max(0, distance)) ** HAZE_POWER)); + +/** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ +export const NIGHT_AMBIENT_LIFT = 3; +/** Cool light from the mirror of the sun's below-horizon position, linear, scaled by `nightness`: gives the terrain a lit side at night. */ +export const MOON_COLOUR: Vec3 = [0.05, 0.07, 0.13]; + +/** The light on the ground: the sun, or at night the moon standing where the sun is mirrored above the horizon. Continuous through sunset. */ +export function groundLight(d: SkyData): { dir: Vec3; colour: Vec3; ambient: Vec3 } { + const sun = sunDirection(d); + const k = nightness(d.elevation); + return { + dir: [sun[0], Math.abs(sun[1]), sun[2]], + colour: [0, 1, 2].map((i) => d.direct[i] + MOON_COLOUR[i] * k) as unknown as Vec3, + ambient: [0, 1, 2].map((i) => d.ambient[i] * (1 + NIGHT_AMBIENT_LIFT * k)) as unknown as Vec3, + }; +} + +/** + * The heading the camera starts at, in turns from north: the noonday sun a hundred degrees to its right, so the light + * rakes across the relief instead of flattening it from behind. Fixed, so `?az=` still moves the sun in the picture. + */ +export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; /** * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from - * the sun plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. */ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { - const sun = sunDirection(d); + const light = groundLight(d); + const sun = light.dir; const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); const hemi = 0.5 + 0.5 * normal[1]; const flat = Math.hypot(view[0], view[2]) || 1; const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); const t = transmittance(distance); return [0, 1, 2].map((i) => { - const lit = albedo[i] * (d.direct[i] * lambert + d.ambient[i] * Math.max(0, hemi)); + const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index e21a229e..97577054 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -8,7 +8,7 @@ export const TERRAIN = { span: 3000, hmax: 256 }; /** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ export const GRID = { still: { grid: 128, tex: 512 }, - low: { grid: 128, tex: 512 }, + low: { grid: 128, tex: 1024 }, full: { grid: 192, tex: 1024 }, } as const; diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts new file mode 100644 index 00000000..3e9f3a48 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showTierLabel } from './tierLabel'; + +let search: string; +let appended: any[]; +const canvas = (parent: boolean) => + ({ + parentElement: parent + ? { + appendChild: (e: any) => appended.push(e), + } + : null, + } as any); + +beforeEach(() => { + search = ''; + appended = []; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); + vi.stubGlobal('document', { + createElement: () => ({ style: {}, remove: vi.fn(), textContent: '' }), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('showTierLabel', () => { + it('adds nothing in a normal build', () => { + expect(showTierLabel(canvas(true), 'full')).toBeNull(); + expect(appended).toHaveLength(0); + }); + + it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { + search = '?tierlabel=1'; + const remove = showTierLabel(canvas(true), 'sky-only'); + expect(appended).toHaveLength(1); + expect(appended[0].textContent).toBe('terrain · sky-only'); + remove!(); + expect(appended[0].remove).toHaveBeenCalled(); + }); + + it('has nowhere to go without a parent', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(false), 'full')).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts new file mode 100644 index 00000000..6e3a9236 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -0,0 +1,26 @@ +// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), +// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`. +const wanted = (): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + new URLSearchParams(window.location.search).get('tierlabel') === '1'; + +/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ +export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null { + const parent = canvas.parentElement; + if (!parent || !wanted()) return null; + const el = document.createElement('div'); + el.textContent = `terrain · ${text}`; + Object.assign(el.style, { + position: 'absolute', + right: '6px', + bottom: '6px', + font: '10px monospace', + color: 'rgba(255,255,255,.75)', + background: 'rgba(0,0,0,.4)', + padding: '1px 5px', + borderRadius: '3px', + pointerEvents: 'none', + }); + parent.appendChild(el); + return () => el.remove(); +} -- 2.47.3 From 685abbb19b341062c78702065ba91c8f3877468b Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 18:10:21 +0000 Subject: [PATCH 17/24] scene: terrain steep faces without streaks, smoother normals and silhouettes - albedo wobble, grain and strata are triplanar (weights |n|^4 over yz, xz, xy), so a cliff is not the floor pattern smeared down it - normals from bilinear height at a continuous position, stencil 1.5 texels each side - the grid is packed toward the camera at the patch centre (warpUv), 192 low / 256 full, vertex heights bilinear: near ridge silhouettes no longer step in 23 m blocks Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 54 ++++++++++++-- .../features/scene/terrain/terrainAlbedo.ts | 73 ++++++++++++------- .../features/scene/terrain/terrainShaders.ts | 49 +++++++------ .../scene/terrain/terrainWorld.test.ts | 29 +++++++- .../features/scene/terrain/terrainWorld.ts | 14 +++- 5 files changed, 162 insertions(+), 57 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 385344d3..33855158 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,7 +1,15 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; import { SKY } from './terrainSky'; -import { ALBEDO, albedoAt, albedoPalette, grainAt, wobble } from './terrainAlbedo'; +import { + ALBEDO, + albedoAt, + albedoPalette, + grainAt, + triWeights, + wobble, + wobbleAt, +} from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => @@ -9,8 +17,10 @@ const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; // A point where the wobble is zero, so band edges sit exactly where the palette says. const FLAT_X = Math.PI / 0.043; +// A normal for the xz plane alone (all the weight on it), carrying `ny` for the slope bands. +const facing = (ny: number) => [0, ny, 0] as const; const at = (sky: keyof typeof SKY, h: number, ny: number) => - albedoAt(albedoPalette(SKY[sky]), h, ny, FLAT_X, 0); + albedoAt(albedoPalette(SKY[sky]), h, facing(ny), FLAT_X, 0); describe('albedo by slope and height', () => { it('the test point really has no wobble', () => { @@ -50,7 +60,7 @@ describe('albedo by slope and height', () => { for (const sky of ['midnight', 'noonday'] as const) for (let h = -20; h < 300; h += 7) for (let ny = 0; ny <= 1; ny += 0.1) - for (const v of albedoAt(albedoPalette(SKY[sky]), h, ny, h * 3, ny * 91)) { + for (const v of albedoAt(albedoPalette(SKY[sky]), h, facing(ny), h * 3, ny * 91)) { expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeLessThanOrEqual(1); } @@ -105,9 +115,14 @@ describe('shader agrees with the pure function on its numbers', () => { }); }); +const UP = [0, 1, 0] as const; +const EAST = [1, 0, 0] as const; + describe('grain', () => { const samples = (rock: number, distance: number) => - Array.from({ length: 400 }, (_, i) => grainAt(i * 1.7, i * 0.9, 100 + i * 0.3, rock, distance)); + Array.from({ length: 400 }, (_, i) => + grainAt(i * 1.7, 100 + i * 0.3, i * 0.9, UP, rock, distance) + ); const spread = (v: number[]) => Math.max(...v) - Math.min(...v); it('varies the surface near the eye, never goes negative, and averages about 1', () => { @@ -119,14 +134,41 @@ describe('grain', () => { it('drops its fine octave with distance, so far ground does not shimmer', () => { const rangeOf = (d: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, i * 0.21, 100, 0, d))); + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, 100, i * 0.21, UP, 0, d))); expect(rangeOf(900)).toBeLessThan(rangeOf(50)); }); it('layers the rock: strata show only where the face is rock', () => { const height = (rock: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 0, 100 + i * 0.05, rock, 2000))); + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 100 + i * 0.05, 0, UP, rock, 2000))); expect(height(1)).toBeGreaterThan(0.2); expect(height(0)).toBeLessThan(1e-9); }); }); + +describe('triplanar projection', () => { + it('weights sum to 1 and go to the plane the face looks along', () => { + for (const n of [UP, EAST, [0.6, 0.6, 0.529], [0, 0, 1]] as const) { + const w = triWeights(n); + expect(w[0] + w[1] + w[2]).toBeCloseTo(1, 9); + } + expect(triWeights(UP)).toEqual([0, 1, 0]); + expect(triWeights(EAST)).toEqual([1, 0, 0]); + expect(triWeights([0.8, 0.5, 0.33])[0]).toBeGreaterThan(0.7); + }); + + it('a cliff facing east takes its pattern over (z, y), so it varies with height and not with x: no vertical smear', () => { + const col = (x: number, y: number) => grainAt(x, y, 40, EAST, 0, 50); + const alongY = Array.from({ length: 200 }, (_, i) => col(10, 50 + i * 0.7)); + const alongX = Array.from({ length: 200 }, (_, i) => col(i * 0.7, 50)); + expect(Math.max(...alongY) - Math.min(...alongY)).toBeGreaterThan(0.3); + expect(Math.max(...alongX) - Math.min(...alongX)).toBeLessThan(1e-9); + expect(wobbleAt(5, 80, 40, EAST)).toBeCloseTo(wobbleAt(500, 80, 40, EAST), 12); + expect(wobbleAt(5, 80, 40, EAST)).not.toBeCloseTo(wobbleAt(5, 120, 40, EAST), 3); + }); + + it('level ground takes its pattern over (x, z) alone, as before', () => { + expect(grainAt(12, 10, 30, UP, 0, 50)).toBeCloseTo(grainAt(12, 900, 30, UP, 0, 50), 12); + expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index d452dec8..c9f11ab4 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -79,19 +79,36 @@ export function albedoPalette(d: SkyData): AlbedoPalette { }; } -/** Cheap two-sine value in -1..1 from world position; stands in for noise so no texture or hash is read. */ +/** Cheap two-sine value in -1..1 from a 2D world position; stands in for noise so no texture or hash is read. */ export const wobble = (x: number, z: number): number => Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); /** - * Linear albedo at a surface point. `height` in metres, `ny` the unit normal's y, `x`/`z` world metres. + * How much each axis-aligned projection plane counts at a surface with unit normal `n`: weights for (yz, xz, xy), summing + * to 1, sharply favouring the plane the face looks along. Procedural patterns are evaluated on all three and blended by + * these (triplanar), so a cliff is not the floor pattern smeared down it. + */ +export function triWeights(n: Vec3): Vec3 { + const w = n.map((c) => Math.abs(c) ** 4); + const sum = w[0] + w[1] + w[2] || 1; + return [w[0] / sum, w[1] / sum, w[2] / sum]; +} + +/** `wobble` blended over the three projection planes, at a surface point with unit normal `n`. */ +export function wobbleAt(x: number, y: number, z: number, n: Vec3): number { + const w = triWeights(n); + return w[0] * wobble(z, y) + w[1] * wobble(x, z) + w[2] * wobble(x, y); +} + +/** + * Linear albedo at a surface point. `y` the height in metres, `n` the unit normal, `x`/`z` world metres. * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow * line, thinning to nothing as the face steepens. */ -export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number, z: number): Vec3 { - const w = wobble(x, z); - const h = height + ALBEDO.wobbleHeight * w; - const flat = ny + ALBEDO.wobbleSlope * w; +export function albedoAt(p: AlbedoPalette, y: number, n: Vec3, x: number, z: number): Vec3 { + const w = wobbleAt(x, y, z, n); + const h = y + ALBEDO.wobbleHeight * w; + const flat = n[1] + ALBEDO.wobbleSlope * w; const ground = (i: number) => mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); const snow = @@ -103,33 +120,39 @@ export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number ) as unknown as Vec3; } +const COARSE = [ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], +] as const; +const FINE = [ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], +] as const; +const waves = (k: ReadonlyArray, a: number, b: number) => + k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; + /** - * The multiplier on albedo that gives the surface grain and the rock its layers, from world position (metres), - * height, `rock` (0..1, how much of the face is rock) and the distance from the eye. The shader's `ALB_GRAIN_*` block does the same arithmetic. - * The cavity term beside it in the shader reads the height map and has no twin here. + * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit + * normal `n`, `rock` (0..1, how much of the face is rock) and the distance from the eye. Grain is triplanar like the + * wobble. The shader's `ALB_GRAIN_*` block does the same arithmetic. The cavity term beside it in the shader reads the + * height map and has no twin here. */ export function grainAt( x: number, + y: number, z: number, - height: number, + n: Vec3, rock: number, distance: number ): number { - const waves = (k: ReadonlyArray) => - k.reduce((sum, [kx, kz]) => sum + Math.sin(x * kx + z * kz), 0) / 3; - const coarse = waves([ - [0.31, 0.27], - [-0.22, 0.35], - [0.38, -0.12], - ]); - const fine = - waves([ - [1.1, 0.7], - [-0.8, 1.2], - [1.3, -0.6], - ]) * - (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); - const layers = Math.sin(height * 0.9 + wobble(x, z) * 3); + 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 layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); return Math.max( 0, 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 04d0425b..93670449 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -90,6 +90,12 @@ float hAt(ivec2 t) { return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( TERRAIN.hmax )}; +} +// The height at a point in texel units, bilinear over the four texels around it (the packed texture cannot be filtered by the GPU). +float hBil(vec2 p) { + ivec2 i = ivec2(floor(p)); + vec2 f = fract(p); + return mix(mix(hAt(i), hAt(i + ivec2(1, 0)), f.x), mix(hAt(i + ivec2(0, 1)), hAt(i + ivec2(1, 1)), f.x), f.y); }`; export const GRID_VERT = `${HEADER} @@ -100,12 +106,14 @@ out vec2 v_uv; out vec3 v_world; ${FETCH} void main() { - ivec2 t = ivec2(a_uv * float(uTex - 1) + 0.5); + // The grid is packed toward the centre, where the camera stands (terrainWorld.warpUv): s = 2u - 1, 0.5 + 0.5 (0.5 s + 0.5 s |s|). + vec2 s = a_uv * 2.0 - 1.0; + vec2 uv = 0.5 + 0.5 * (0.5 * s + 0.5 * s * abs(s)); // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. - float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(a_uv.x - 0.5), abs(a_uv.y - 0.5))); - float h = hAt(t) * rim; - v_uv = a_uv; - v_world = vec3((a_uv.x - 0.5) * uSpan, h, (a_uv.y - 0.5) * uSpan); + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(uv.x - 0.5), abs(uv.y - 0.5))); + float h = hBil(uv * float(uTex - 1)) * rim; + v_uv = uv; + v_world = vec3((uv.x - 0.5) * uSpan, h, (uv.y - 0.5) * uSpan); gl_Position = uViewProj * vec4(v_world, 1.0); }`; @@ -145,23 +153,21 @@ uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} -// Unnormalised normal at one texel, central differences over its neighbours. -vec3 nAt(ivec2 t, float texel) { - float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); - float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); - return normalize(vec3(-dx, 2.0 * texel, -dz)); -} +// 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; } void main() { - // The four texels around this fragment, blended, so the shading is continuous and not one flat square per texel. + // 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); - ivec2 t = ivec2(floor(p)); - vec2 f = fract(p); float texel = uSpan / float(uTex - 1); - vec3 n = normalize(mix( - mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), - mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); + float dx = hBil(p + vec2(1.5, 0.0)) - hBil(p - vec2(1.5, 0.0)); + float dz = hBil(p + vec2(0.0, 1.5)) - hBil(p - vec2(0.0, 1.5)); + vec3 n = normalize(vec3(-dx, 3.0 * texel, -dz)); + vec3 tw = pow(abs(n), vec3(4.0)); + tw /= tw.x + tw.y + tw.z; + vec2 qx = v_world.zy, qy = v_world.xz, qz = v_world.xy; // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). - float w = sin(v_world.x * 0.043 + v_world.z * 0.017) * sin(v_world.z * 0.051 - v_world.x * 0.023); + float w = tw.x * wob2(qx) + tw.y * wob2(qy) + tw.z * wob2(qz); float h = v_world.y + ALB_WOBBLE_H * w; float flat_ = n.y + ALB_WOBBLE_S * w; vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); @@ -169,15 +175,14 @@ void main() { float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). - vec2 q = v_world.xz; - float coarse = (sin(dot(q, vec2(0.31, 0.27))) + sin(dot(q, vec2(-0.22, 0.35))) + sin(dot(q, vec2(0.38, -0.12)))) / 3.0; - float fine = (sin(dot(q, vec2(1.1, 0.7))) + sin(dot(q, vec2(-0.8, 1.2))) + sin(dot(q, vec2(1.3, -0.6)))) / 3.0; + 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 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); // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. int r = max(1, int(12.0 / texel)); - ivec2 c = ivec2(p + 0.5); + ivec2 c = ivec2(floor(p + 0.5)); float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 5d21ba47..17935382 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -1,6 +1,15 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { describe, expect, it } from 'vitest'; -import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + buildGrid, + CAMERA, + GRID, + seedOffset, + TERRAIN, + triangleCount, + view, + warpUv, +} from './terrainWorld'; import { heightFragmentSource } from './terrainShaders'; describe('seed', () => { @@ -32,7 +41,7 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - expect(triangleCount(GRID.low.grid)).toBeLessThan(40000); + expect(triangleCount(GRID.low.grid)).toBeLessThan(80000); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); @@ -79,3 +88,19 @@ describe('camera', () => { expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); }); }); + +describe('warpUv', () => { + it('maps the patch onto itself, monotonic, centre fixed', () => { + expect(warpUv(0)).toBe(0); + expect(warpUv(1)).toBe(1); + expect(warpUv(0.5)).toBe(0.5); + for (let i = 0; i < 100; i += 1) expect(warpUv((i + 1) / 100)).toBeGreaterThan(warpUv(i / 100)); + }); + + it('packs the vertices toward the camera at the centre: finer there, coarser at the rim, than a plain grid', () => { + const plain = 1 / 192; + const step = (u: number) => warpUv(u + 1 / 192) - warpUv(u); + expect(step(0.5)).toBeLessThan(0.6 * plain); + expect(step(0.99)).toBeGreaterThan(1.3 * plain); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 97577054..fcc02390 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -8,8 +8,8 @@ export const TERRAIN = { span: 3000, hmax: 256 }; /** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ export const GRID = { still: { grid: 128, tex: 512 }, - low: { grid: 128, tex: 1024 }, - full: { grid: 192, tex: 1024 }, + low: { grid: 192, tex: 1024 }, + full: { grid: 256, tex: 1024 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ @@ -29,6 +29,16 @@ export function seedOffset(seed: number): [number, number] { return [(s * 7919) % 65536, (s * 104729) % 65536]; } +/** + * Where grid coordinate `u` (0..1 across the patch) lands in the patch, 0..1. The camera stands at the centre, so the + * grid is packed toward it: with s = 2u - 1 the spacing grows as 0.5 + |s| of the plain grid's, about half of it at the + * centre and half again as much at the rim. The vertex shader does the same arithmetic (terrainShaders.ts GRID_VERT). + */ +export const warpUv = (u: number): number => { + const s = 2 * u - 1; + return 0.5 + 0.5 * (0.5 * s + 0.5 * s * Math.abs(s)); +}; + export const triangleCount = (grid: number): number => 2 * grid * grid; /** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ -- 2.47.3 From c35ac7e5cd7b0660302ee47534ccc7680f4bce8c Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 18:16:47 +0000 Subject: [PATCH 18/24] scene: terrain haze is aerial perspective from a baked Hillaire camera volume Replaces the exp(-(k d)^1.6) haze with the camera volume of Hillaire, EGSR 2020 section 5.4 (and sebh/UnrealEngineSkyAtmosphere RenderCameraVolumePS): slices on the squared distribution, in-scatter integrated with each step's extinction analytic, alpha the grey opacity 1 - mean(T). Baked into baked.scatter.aerial_perspective by src/sky/scatter.js (RGBA16F 7 x 96 x 12, 86 KB), looked up in the terrain fragment shader (two fetches). Night: the moon lights the haze through the same volume, plus a deep blue starlight floor. Shortcuts, with units and constants, in src/sky/README.md: sun-relative indexing, 12 x 8 x 7 texels, path = scene metres x 8, ground ignored, single scattering, no multi-scattering LUT. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 13 +- src/app/features/scene/terrain/Terrain.ts | 33 ++++- .../scene/terrain/terrainAlbedo.test.ts | 2 +- .../scene/terrain/terrainScatter.test.ts | 55 ++++++++- .../features/scene/terrain/terrainScatter.ts | 87 +++++++++++++- .../features/scene/terrain/terrainShaders.ts | 28 +++-- .../features/scene/terrain/terrainSky.test.ts | 94 +++++++++------ src/app/features/scene/terrain/terrainSky.ts | 63 ++++++++-- src/sky/README.md | 22 ++++ src/sky/baked.ts | 12 ++ src/sky/scatter.js | 113 ++++++++++++++++++ src/sky/scatter.test.ts | 66 ++++++++++ 12 files changed, 521 insertions(+), 67 deletions(-) diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index b61b632b..01f2df11 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; import { groundLight, SKY, skyAtSun } from './terrainSky'; -import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; @@ -176,14 +176,17 @@ describe('createTerrainScene', () => { expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); }); - it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { + it('uploads the baked sky table and the aerial perspective volume once each, as half floats, to 3D textures', () => { const { gl, calls } = fakeGl(); createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); - expect(calls.texImage3D).toHaveLength(1); + expect(calls.texImage3D).toHaveLength(2); const a = calls.texImage3D[0]; expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); expect(a[9]).toEqual(skyViewWords()); - expect(calls.deleteTexture).toHaveLength(2); + const ap = calls.texImage3D[1]; + expect([ap[3], ap[4], ap[5]]).toEqual([AP_VOLUME.width, AP_VOLUME.height, AP_VOLUME.depth]); + expect(ap[9]).toEqual(apVolumeWords()); + expect(calls.deleteTexture).toHaveLength(3); }); it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { @@ -243,7 +246,7 @@ describe('createTerrainScene', () => { createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); expect(calls.deleteProgram).toHaveLength(3); expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(3); expect(rafQueue).toHaveLength(0); }); }); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index d3c95ff3..fdc53926 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -2,11 +2,11 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; -import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { + nightness, glowColour, groundLight, - HAZE_K, chooseSky, SkyName, START_TURNS, @@ -138,6 +138,26 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) ); + // The aerial perspective volume, RGBA16F, half floats as stored (src/sky/scatter.js). + const apTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGBA16F, + AP_VOLUME.width, + AP_VOLUME.height, + AP_VOLUME.depth, + 0, + gl.RGBA, + gl.HALF_FLOAT, + apVolumeWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); @@ -148,6 +168,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { free.forEach((f) => f()); gl.deleteTexture(heightTex); gl.deleteTexture(skyTex); + gl.deleteTexture(apTex); gl.deleteFramebuffer(fbo); gl.deleteProgram(bakeProg); gl.deleteProgram(skyProg); @@ -192,6 +213,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const heading = START_TURNS; const glow = [...glowColour(sky)]; const sunTexel = sunIndex(sky.elevation); + const night = nightness(sky.elevation); + const moonTexel = sunIndex(Math.abs(sky.elevation)); const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); @@ -252,7 +275,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); gl.uniform1i(u('uSky'), 1); - gl.uniform1f(u('uHazeK'), HAZE_K); + gl.uniform1f(u('uNight'), night); + gl.uniform1f(u('uMoonIndex'), moonTexel); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.uniform1i(u('uAP'), 2); gl.uniform3fv(u('uSoil'), palette.soil); gl.uniform3fv(u('uScree'), palette.scree); gl.uniform3fv(u('uRock'), palette.rock); diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 33855158..2314f758 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -111,7 +111,7 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); // 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)/g)).toHaveLength(1); + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); }); }); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts index 782d980e..c153eb5f 100644 --- a/src/app/features/scene/terrain/terrainScatter.test.ts +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -1,8 +1,21 @@ /* eslint-disable no-restricted-syntax -- tests walk table texels */ import { describe, expect, it } from 'vitest'; import { baked } from '../../../../sky/baked'; -import { decode, viewElevation } from '../../../../sky/scatter.js'; import { + aerialRay, + AP_ELEVATIONS, + AP_SLICES, + apElevation, + apSliceKm, + decode, + SUN_ELEVATIONS_DEG, + viewElevation, +} from '../../../../sky/scatter.js'; +import { + AERIAL_GLSL, + aerialAt, + AP_VOLUME, + apVolumeWords, SCATTER_GLSL, scatterRadiance, skyViewWords, @@ -86,3 +99,43 @@ describe('the GLSL sampler', () => { expect(SCATTER_GLSL).toContain('* 6.0'); }); }); + +describe('the aerial perspective volume as the terrain reads it', () => { + it('uploads RGBA half floats in the baked layout', () => { + const a = baked.scatter.aerial_perspective; + expect(apVolumeWords()).toHaveLength(a.width * a.height * a.depth * 4); + expect(AP_VOLUME).toEqual({ width: a.width, height: a.height, depth: a.depth }); + }); + + it('on a texel centre the CPU twin returns the baked ray exactly (to half-float precision)', () => { + const sunI = 8; + const ei = 5; + const slice = 6; + const want = aerialRay(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90)[slice]; + const got = aerialAt(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90, apSliceKm(slice)); + for (let c = 0; c < 3; c += 1) expect(got.L[c]).toBeCloseTo(want[c], 3); + expect(got.alpha).toBeCloseTo(want[3], 3); + }); + + it('between texels it is near the model: the filtering error is small against the signal', () => { + const want = aerialRay(10, -1.3, 75)[AP_SLICES - 2]; + const got = aerialAt(10, -1.3, 75, apSliceKm(AP_SLICES - 2)); + expect(Math.abs(got.alpha - want[3])).toBeLessThan(0.03); + expect(got.L[0] / want[0]).toBeGreaterThan(0.6); + expect(got.L[0] / want[0]).toBeLessThan(1.6); + }); + + it('fades to nothing at the observer, and clamps past the last slice', () => { + expect(aerialAt(45, -2, 90, 0)).toEqual({ L: [0, 0, 0], alpha: 0 }); + const last = aerialAt(45, -2, 90, apSliceKm(AP_SLICES - 1)); + const beyond = aerialAt(45, -2, 90, 1e6); + expect(beyond.alpha).toBeCloseTo(last.alpha, 9); + }); + + it('the shader lookup carries the same constants as the table', () => { + const a = baked.scatter.aerial_perspective; + expect(AERIAL_GLSL).toContain(`/ ${a.height}.0`); + expect(AERIAL_GLSL).toContain(`${a.max_km.toFixed(1)}`); + expect(AERIAL_GLSL).toContain(`${AP_ELEVATIONS}.0 +`); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts index 9149ac47..72ebfb5c 100644 --- a/src/app/features/scene/terrain/terrainScatter.ts +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -2,10 +2,20 @@ // uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and // for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. import { baked } from '../../../../sky/baked'; -import { decode, sunIndex, viewIndex } from '../../../../sky/scatter.js'; +import { + AP_DISTANCE_SCALE, + AP_ELEVATIONS, + AP_SLICES, + apElevationIndex, + apSliceIndex, + decode, + sunIndex, + viewIndex, +} from '../../../../sky/scatter.js'; import type { Vec3 } from './terrainSky'; const S = baked.scatter; +const AP = S.aerial_perspective; const SKY = S.sky_view; const VIEW = S.view_elevation_deg; @@ -22,6 +32,11 @@ export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth export const STORE_SCALE = S.store_scale; export const skyViewWords = (): Uint16Array => tableWords(SKY.data); +/** The aerial perspective volume (layout in src/sky/scatter.js): RGBA16F, x azimuth from the light, y slice-major block of elevations, z light elevation. */ +export const AP_VOLUME = { width: AP.width, height: AP.height, depth: AP.depth } as const; +export const apVolumeWords = (): Uint16Array => tableWords(AP.data); +export { AP_DISTANCE_SCALE }; + /** Decode a stored table to values, once. */ const decoded = (data: string) => { let values: Float32Array | undefined; @@ -32,6 +47,7 @@ const decoded = (data: string) => { }; const transmittanceTable = decoded(S.transmittance.data); const skyTable = decoded(SKY.data); +const apTable = decoded(AP.data); /** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ export function transmittanceAt(elevationDeg: number): Vec3 { @@ -67,6 +83,52 @@ export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDe return [out[0], out[1], out[2]]; } +/** + * Aerial perspective along a view ray: in-scatter luminance per channel (for a light of unit irradiance) and opacity + * (1 - mean transmittance), for a light at `lightElDeg`, a ray `viewElDeg` (0 and below) and `azDeg` from the light, + * `km` of baked path along it. Trilinear in (azimuth, elevation, light elevation) and linear between slices, the arithmetic the + * shader's `aerial()` does on the GPU; below the first slice it fades to nothing at the observer (Hillaire's near-slice fade). + */ +export function aerialAt( + lightElDeg: number, + viewElDeg: number, + azDeg: number, + km: number +): { L: Vec3; alpha: number } { + const t = apTable(); + const x = Math.min(Math.abs(azDeg) / 180, 1) * (AP.width - 1); + const e = apElevationIndex(viewElDeg); + const z = sunIndex(lightElDeg); + const texel = (slice: number): number[] => { + const lo = [ + Math.min(Math.floor(x), AP.width - 2), + Math.min(Math.floor(e), AP_ELEVATIONS - 2), + Math.min(Math.floor(z), AP.depth - 2), + ]; + const f = [x - lo[0], e - lo[1], z - lo[2]]; + const out = [0, 0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = + (((lo[2] + dz) * AP.height + slice * AP_ELEVATIONS + lo[1] + dy) * AP.width + lo[0] + dx) * + 4; + for (let c = 0; c < 4; c += 1) out[c] += w * t[base + c]; + } + return out; + }; + const f = apSliceIndex(km); + const fs = Math.min(Math.max(f, 0), AP_SLICES - 1); + const k = Math.min(Math.floor(fs), AP_SLICES - 2); + const a = texel(k); + const b = texel(k + 1); + const fade = Math.min(Math.max(f + 1, 0), 1); + const v = [0, 1, 2, 3].map((c) => (a[c] + (b[c] - a[c]) * (fs - k)) * fade); + return { L: [v[0], v[1], v[2]], alpha: v[3] }; +} + /** * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the @@ -92,3 +154,26 @@ vec3 skyScatter(vec3 d) { SKY.height }.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; }`; + +/** + * The aerial perspective lookup, in GLSL: `aerial(lightIndex, viewElDeg, azDeg, km)` is `aerialAt` on the GPU. Needs uAP. + * Each slice is one hardware-filtered fetch, kept inside its block of elevation rows so the filter never reads a + * neighbouring slice; two fetches and a lerp per call. + */ +export const AERIAL_GLSL = ` +uniform highp sampler3D uAP; +vec4 apSlice(float k, float e, float x, float z) { + return texture(uAP, vec3((x + 0.5) / ${AP.width}.0, (k * ${AP.elevations}.0 + e + 0.5) / ${ + AP.height +}.0, (z + 0.5) / ${AP.depth}.0)) / ${S.store_scale.toFixed(1)}; +} +vec4 aerial(float lightIndex, float viewElDeg, float azDeg, float km) { + float e = ${(AP.elevations - 1).toFixed( + 1 + )} * (1.0 - sqrt(clamp(-viewElDeg / ${(-AP.elevation_min_deg).toFixed(1)}, 0.0, 1.0))); + float x = clamp(abs(azDeg) / 180.0, 0.0, 1.0) * ${(AP.width - 1).toFixed(1)}; + float f = ${AP.slices}.0 * sqrt(max(km, 0.0) / ${AP.max_km.toFixed(1)}) - 1.0; + float fs = clamp(f, 0.0, ${(AP.slices - 1).toFixed(1)}); + float k = min(floor(fs), ${(AP.slices - 2).toFixed(1)}); + return mix(apSlice(k, e, x, lightIndex), apSlice(k + 1.0, e, x, lightIndex), fs - k) * clamp(f + 1.0, 0.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 93670449..12428d70 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -8,8 +8,8 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; -import { SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, HAZE_POWER, SKY_EXPOSURE } from './terrainSky'; +import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -146,11 +146,11 @@ vec3 skyColour(vec3 d) { )}, sqrt(clamp(d.y, 0.0, 1.0))); }`; -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} in vec2 v_uv; in vec3 v_world; uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; -uniform float uSpan, uHazeK, uSnowLine; +uniform float uSpan, uSnowLine, uNight, uMoonIndex; out vec4 o; ${FETCH} // Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). @@ -187,9 +187,23 @@ void main() { float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; - float T = exp(-pow(uHazeK * length(toSurface), ${lit(HAZE_POWER)})); - vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); - o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); + float dist = length(toSurface); + // Aerial perspective (terrainSky.aerialPerspective): the baked volume by this ray's elevation, azimuth from the light and path. + vec3 ray = toSurface / dist; + float el = min(degrees(asin(clamp(ray.y, -1.0, 1.0))), 0.0); + vec2 hz = ray.xz, sz = uSun.xz; + float az = (length(hz) < 1e-4 || length(sz) < 1e-4) ? 0.0 : degrees(acos(clamp(dot(normalize(hz), normalize(sz)), -1.0, 1.0))); + float km = dist * ${lit(AP_DISTANCE_SCALE / 1000)}; + vec4 ap = aerial(uSunIndex, el, az, km); + vec3 L = ap.rgb; + // The moon lights the haze from where the sun is mirrored above the horizon, sharing its azimuth; a starlight floor stays. + if (uNight > 0.0) L += uNight * vec3(${MOON_HAZE.map(lit).join( + ', ' + )}) * aerial(uMoonIndex, el, az, km).rgb; + vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( + lit +).join(', ')}); + o = vec4(pow(lit * (1.0 - ap.a) + haze, vec3(1.0 / 2.2)), 1.0); }`; // The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 9e5bc91e..5a8e0502 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,4 +1,5 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { AP_DISTANCE_SCALE } from './terrainScatter'; import { describe, expect, it } from 'vitest'; import { baked } from '../../../../sky/baked'; import { @@ -13,7 +14,8 @@ import { skyColour, skyNameFor, sunDirection, - transmittance, + aerialPerspective, + metresToKm, groundLight, HAZE_K, HAZE_POWER, @@ -185,47 +187,72 @@ describe('the scattered sky', () => { }); }); -describe('haze', () => { - it('transmittance falls monotonically from 1 toward 0', () => { - let prev = transmittance(0); - expect(prev).toBe(1); - for (const d of [10, 100, 400, 1000, 3000, 8000]) { - const t = transmittance(d); - expect(t).toBeLessThan(prev); - expect(t).toBeGreaterThan(0); - prev = t; +describe('aerial perspective', () => { + const ray = (el: number, az: number): Vec3 => look(el, az); + const along = (d: typeof SKY.midnight, el: number, az: number, m: number) => + aerialPerspective(d, ray(el, az), m); + + it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { + let prev = along(SKY.noonday, -2, 200, 0).alpha; + expect(prev).toBe(0); + for (const m of [10, 100, 400, 1000, 2000, 2500]) { + const a = along(SKY.noonday, -2, 200, m).alpha; + expect(a).toBeGreaterThan(prev); + expect(a).toBeLessThanOrEqual(1); + prev = a; } }); - it('far surfaces take the scattered sky at the horizon in their azimuth, at both skies', () => { - for (const d of [...Object.values(SKY), skyAtSun(2, 270)]) { - const far = shade(d, [0.9, 0.2, 0.1], UP, 100000, look(-3, 200)); - const sky = skyColour(d, look(0, 200)); - for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(sky[i], 6); - } + it('leaves the near ground its own colour and takes the far ridges', () => { + expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); + expect(along(SKY.noonday, -2, 200, 2000).alpha).toBeGreaterThan(0.3); }); - it('haze toward the dusk sun is warmer and brighter than haze away from it', () => { - const dusk = skyAtSun(-1, 270); - const toward = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 270)); - const away = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 90)); - const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { + const far = along(SKY.noonday, -2, 90, 2400).colour; + expect(far[2]).toBeGreaterThan(far[0]); + }); + + it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { + const dusk = skyAtSun(2, 270); + const toward = along(dusk, -1, 270, 2000).colour; + const away = along(dusk, -1, 90, 2000).colour; expect(luma(toward)).toBeGreaterThan(3 * luma(away)); - expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(toward[0]).toBeGreaterThan(toward[2]); }); - it('haze colour differs between the two skies: pale day horizon, dim night one', () => { - const far = (d: typeof SKY.midnight) => luma(shade(d, [0, 0, 0], UP, 1e6, look(-3, 180))); - expect(far(SKY.noonday)).toBeGreaterThan(5 * far(SKY.midnight)); + it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { + const c = along(SKY.midnight, -2, 0, 2400).colour; + expect(luma(c)).toBeGreaterThan(0.001); + expect(luma(c)).toBeLessThan(0.08); + expect(c[2]).toBeGreaterThan(2.5 * c[0]); + expect(luma(along(SKY.noonday, -2, 0, 2400).colour)).toBeGreaterThan(5 * luma(c)); }); - it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { + it('moonlight lights the night haze toward the moon more than away from it', () => { + const moon = groundLight(SKY.midnight).dir; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], 2400).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], 2400).colour; + expect(luma(toward)).toBeGreaterThan(luma(away)); + }); + + it('with distance a dark surface rises toward the haze and its own colour is lost', () => { const d = SKY.noonday; - const view = look(-4, 200); - const h = luma(skyColour(d, look(0, 200))); + const view = ray(-4, 200); const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(2000)).toBeGreaterThan(dark(200)); - expect(dark(2000)).toBeLessThan(h); + expect(dark(2000)).toBeGreaterThan(dark(100)); + const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; + expect(own(2000)).toBeLessThan(0.7 * own(100)); + }); + + it('is continuous in the sun elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, 2000).colour); + for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); + }); + + it('the metres-to-km law is the scene metres times the baked distance scale', () => { + expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); + expect(metresToKm(-5)).toBe(0); }); }); @@ -246,13 +273,6 @@ describe('ground light and haze', () => { for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); }); - it('haze leaves the near ground nearly clear and takes the far ridges: a power law, not plain exp(-k d)', () => { - expect(transmittance(300)).toBeGreaterThan(0.85); - expect(transmittance(300)).toBeGreaterThan(Math.exp(-HAZE_K * 300)); - expect(transmittance(2500)).toBeLessThan(0.15); - expect(HAZE_POWER).toBeGreaterThan(1); - }); - it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { const yaw = 2 * Math.PI * START_TURNS; const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 697896a3..2efef429 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -4,7 +4,13 @@ import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; -import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; +import { + AP_DISTANCE_SCALE, + aerialAt, + HORIZON_FLOOR, + scatterRadiance, + transmittanceAt, +} from './terrainScatter'; export type SkyName = 'midnight' | 'noonday'; export type Vec3 = readonly [number, number, number]; @@ -129,13 +135,48 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { } /** - * Haze: transmittance T = exp(-(k d)^HAZE_POWER) at distance d metres. The power above 1 keeps the near and middle - * ground at its own colour and lets only the far ridges melt toward the sky; a plain exp(-k d) greyed everything. + * Aerial perspective: the baked volume of src/sky/scatter.js (after Hillaire 2020, see there for what follows the paper + * and what does not), looked up by the view ray relative to the light and the metres to the surface. The surface keeps + * (1 - alpha) of its own colour (alpha = 1 - mean transmittance, a grey transmittance as the paper) and gains the in-scatter L, + * lifted by the sky's exposure and tone-mapped the way the sky is, so far ground meets the sky's colour. SHORTCUT: the path is + * the scene's metres times AP_DISTANCE_SCALE (a real atmosphere is nearly clear over 3 km). */ -export const HAZE_K = 0.0007; -export const HAZE_POWER = 1.6; -export const transmittance = (distance: number): number => - Math.exp(-((HAZE_K * Math.max(0, distance)) ** HAZE_POWER)); +export const metresToKm = (metres: number): number => + (Math.max(0, metres) / 1000) * AP_DISTANCE_SCALE; + +/** Moonlight's irradiance on the haze, relative to the sun's 1, at full night. EYEBALLED, far above a real moon (about 3e-6) as the sky exposure is. */ +export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; +/** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ +export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; + +/** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ +export function aerialPerspective( + d: SkyData, + dir: Vec3, + distance: number +): { colour: Vec3; alpha: number } { + const el = Math.min(0, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const km = metresToKm(distance); + const night = nightness(d.elevation); + const day = aerialAt(d.elevation, el, az, km); + const L = [...day.L]; + if (night > 0) { + // The moon stands where the sun is mirrored above the horizon (groundLight), so it shares the sun's azimuth. + const moon = aerialAt(Math.abs(d.elevation), el, az, km); + for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; + } + const colour = [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha + ) as unknown as Vec3; + return { colour, alpha: day.alpha }; +} /** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ export const NIGHT_AMBIENT_LIFT = 3; @@ -162,19 +203,17 @@ export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; /** * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), - * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. + * then aerial perspective (`aerialPerspective`). `normal` is a unit vector. Never negative. */ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { const light = groundLight(d); const sun = light.dir; const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); const hemi = 0.5 + 0.5 * normal[1]; - const flat = Math.hypot(view[0], view[2]) || 1; - const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); - const t = transmittance(distance); + const haze = aerialPerspective(d, view, distance); return [0, 1, 2].map((i) => { const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); - return lit * t + haze[i] * (1 - t); + return lit * (1 - haze.alpha) + haze.colour[i]; }) as unknown as Vec3; } diff --git a/src/sky/README.md b/src/sky/README.md index 7afc80a7..1f816a15 100644 --- a/src/sky/README.md +++ b/src/sky/README.md @@ -81,3 +81,25 @@ Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmi twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, black below the planet's shadow limit). + +### `aerial_perspective`: the camera volume for ground seen through the air + +After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020, section 5.4, and its +reference code (sebh/UnrealEngineSkyAtmosphere, `RenderCameraVolumePS` in `RenderSkyRayMarching.hlsl`). RGBA half floats +(same encoding, `store_scale` 64), `width` 7, `height` 96, `depth` 12, 4 channels; one 3D texture, `x` fastest: + +- `x`: azimuth of the view ray from the light, 0, 30, ... 180 degrees (as `sky_view`). +- `y`: `slice * 8 + elevation`: 12 slices, each a block of 8 view elevations, `-50 * (1 - i / 7)^2` degrees (finest at 0, + rays at or above the horizon are clamped to 0). Keep the filter inside a block: sample at `(slice * 8 + e + 0.5) / 96`, `e` in 0..7. +- `z`: light elevation, `sun_elevations_deg`, as `sky_view`. The same volume serves the moon: look up at the moon's elevation. +- Slice `k` is the far side of `max_km * ((k + 1) / 12)^2` km (the paper's squared distribution; `max_km` 20). Between slices interpolate + linearly; below slice 0 fade to zero at the observer, as the reference does for `Slice < 0.5`. +- RGB: in-scatter luminance per steradian for a light of unit irradiance. A: opacity `1 - mean(rgb transmittance)`, the paper's grey + transmittance. Apply as `surface * (1 - A) + L * exposure`. + +Follows the paper: the squared slice distribution, the in-scatter integral with each step's extinction integrated analytically +(`S (1 - exp(-e dt)) / e`), single scattering with Rayleigh and Mie (g 0.8) phase, and the grey opacity in alpha. +Shortcuts: sun-relative azimuth and elevation instead of screen position; 12 x 8 x 7 texels, not 32 x 32 x 32; path lengths are the +scene's metres times `distance_scale` (8), because a real atmosphere is nearly clear across a 3 km patch; the ray ignores the ground +(density held at its ground value, sun visibility tested from the ground sphere); no multi-scattering LUT (the paper's dusk fix); +observer at 100 m. Units: distances in km, radiance per unit sun irradiance, as `sky_view`. 86 KB as base64. diff --git a/src/sky/baked.ts b/src/sky/baked.ts index b732fbc1..0393fdbf 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -260,5 +260,17 @@ export const baked = { channels: 3, data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', }, + aerial_perspective: { + slices: 12, + elevations: 8, + elevation_min_deg: -50, + max_km: 20, + distance_scale: 8, + width: 7, + height: 96, + depth: 12, + channels: 4, + data: 'AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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', + }, }, } as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js index 5781c089..bd12b7e0 100644 --- a/src/sky/scatter.js +++ b/src/sky/scatter.js @@ -193,6 +193,87 @@ export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { return radiance; } +// ---- Aerial perspective ------------------------------------------------------------------------------------------ +// After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020 (section 5.4, the +// aerial perspective "camera volume"), and its reference implementation, sebh/UnrealEngineSkyAtmosphere +// Resources/RenderSkyRayMarching.hlsl `RenderCameraVolumePS`: a volume of AP_SLICES slices whose depth grows with the +// square of the slice index (`Slice *= Slice`), each texel holding the single-scattered luminance L accumulated along +// the view ray to that depth, and in alpha the opacity 1 - mean(rgb transmittance) (a grey transmittance, as the paper). +// Applied to a surface as out = surface * (1 - alpha) + L, with L the in-scatter along that ray. +// +// What differs from the paper, and why: +// - the volume is indexed by the view ray relative to the sun (azimuth from the sun, elevation) and sun elevation, not by +// screen position, because the terrain camera turns and the sky tables here are sun-relative too; +// - the path lengths are the scene's length times AP_DISTANCE_SCALE: a real atmosphere is nearly clear over the 3 km patch, +// so the baked path is that many times longer (EYEBALLED); it sets how fast far ridges go to the sky; +// - the ray ignores the ground: below the observer's altitude the density is held at its ground value, and the sun's +// visibility is tested from the ground sphere; the terrain is the ground; +// - single scattering with the sky tables' own air (no multi-scattering LUT, which the paper adds for dusk); +// - AP_SLICES = 12 slices, 8 view elevations and 7 azimuths, not 32 x 32 x 32 screen froxels, to stay phone-sized. +export const AP_SLICES = 12; +export const AP_ELEVATIONS = 8; +/** The view elevations the volume holds run from this down to 0 degrees, finest at 0: -AP_ELEVATION_MIN_DEG * (1 - i / 7)^2. */ +export const AP_ELEVATION_MIN_DEG = 50; +/** Scene metres a slice volume covers: the patch's far corner (about 2.1 km) with margin. */ +export const AP_SCENE_MAX_M = 2500; +export const AP_DISTANCE_SCALE = 8; +/** Physical kilometres the last slice stands for. */ +export const AP_MAX_KM = (AP_SCENE_MAX_M * AP_DISTANCE_SCALE) / 1000; +const AP_SUBSTEPS = 4; + +export const apElevation = (i) => -AP_ELEVATION_MIN_DEG * (1 - i / (AP_ELEVATIONS - 1)) ** 2; +/** Fractional elevation index of a view elevation in degrees (clamped to 0 degrees and below). */ +export const apElevationIndex = (deg) => + (AP_ELEVATIONS - 1) * (1 - Math.sqrt(clamp(-deg / AP_ELEVATION_MIN_DEG, 0, 1))); +/** Distance in km at the far side of slice k (k = 0 .. AP_SLICES - 1): the squared distribution. */ +export const apSliceKm = (k) => AP_MAX_KM * ((k + 1) / AP_SLICES) ** 2; +/** Fractional slice index of a distance in km: below 0 the volume fades to nothing at the observer. */ +export const apSliceIndex = (km) => AP_SLICES * Math.sqrt(Math.max(km, 0) / AP_MAX_KM) - 1; + +/** + * In-scatter luminance per channel (for a sun of unit irradiance) and opacity along a view ray from the observer, + * at the far side of each of AP_SLICES slices: [[r, g, b, alpha] x AP_SLICES]. + */ +export function aerialRay(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const out = []; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + let from = 0; + for (let k = 0; k < AP_SLICES; k += 1) { + const to = apSliceKm(k); + const dt = (to - from) / AP_SUBSTEPS; + for (let j = 0; j < AP_SUBSTEPS; j += 1) { + const t = from + (j + 0.5) * dt; + let q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const r = Math.hypot(...q); + if (r < PLANET_RADIUS_KM) q = q.map((x) => (x * PLANET_RADIUS_KM) / r); + const h = Math.max(r - PLANET_RADIUS_KM, 0); + const e = extinction(h); + const toSun = opticalDepthToSpace(q, s, 12); + const rm = Math.exp(-h / H_RAYLEIGH); + const mm = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + // The step's own extinction is integrated analytically (Hillaire eq. 6): S (1 - exp(-e dt)) / e. + const step = Math.exp(-e[c] * dt); + if (toSun) { + const scatter = BETA_RAYLEIGH[c] * rm * pr + BETA_MIE_SCA * mm * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * ((scatter * (1 - step)) / e[c]); + } + along[c] += e[c] * dt; + } + } + from = to; + const mean = along.reduce((a, x) => a + Math.exp(-x), 0) / 3; + out.push([...radiance, 1 - mean]); + } + return out; +} + const base64 = (halves) => { const bytes = new Uint8Array(halves.length * 2); halves.forEach((h, i) => { @@ -226,6 +307,26 @@ export function bakeScatter() { }) ); + // x = azimuth from the sun, y = elevation block k * AP_ELEVATIONS + i (slice-major), z = sun elevation: four half floats per texel (L rgb, alpha). + const aerial = new Uint16Array( + SUN_ELEVATIONS_DEG.length * AP_SLICES * AP_ELEVATIONS * AZIMUTH_SAMPLES * 4 + ); + let ap = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => { + const rays = Array.from({ length: AP_ELEVATIONS }, (_, i) => + Array.from({ length: AZIMUTH_SAMPLES }, (_, a) => + aerialRay(sun, apElevation(i), a * AZIMUTH_STEP_DEG) + ) + ); + for (let k = 0; k < AP_SLICES; k += 1) + for (let i = 0; i < AP_ELEVATIONS; i += 1) + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) + rays[i][a][k].forEach((x) => { + aerial[ap] = encode(x); + ap += 1; + }); + }); + return { planet_radius_km: PLANET_RADIUS_KM, atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, @@ -245,5 +346,17 @@ export function bakeScatter() { channels: 3, data: base64(sky), }, + aerial_perspective: { + slices: AP_SLICES, + elevations: AP_ELEVATIONS, + elevation_min_deg: -AP_ELEVATION_MIN_DEG, + max_km: AP_MAX_KM, + distance_scale: AP_DISTANCE_SCALE, + width: AZIMUTH_SAMPLES, + height: AP_SLICES * AP_ELEVATIONS, + depth: SUN_ELEVATIONS_DEG.length, + channels: 4, + data: base64(aerial), + }, }; } diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts index 9ebcb014..e0ea9023 100644 --- a/src/sky/scatter.test.ts +++ b/src/sky/scatter.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + AP_ELEVATIONS, + AP_MAX_KM, + AP_SLICES, + apElevation, + apElevationIndex, + apSliceIndex, + apSliceKm, + aerialRay, AZIMUTH_SAMPLES, AZIMUTH_STEP_DEG, STORE_SCALE, @@ -158,3 +166,61 @@ describe('scattering tables', () => { expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); }); }); + +describe('aerial perspective volume', () => { + const ap = scatter.aerial_perspective; + + it('has the documented layout and size: RGBA half floats, x azimuth, y slice-major elevations, z sun elevation', () => { + expect([ap.width, ap.height, ap.depth, ap.channels]).toEqual([ + AZIMUTH_SAMPLES, + AP_SLICES * AP_ELEVATIONS, + SUN_ELEVATIONS_DEG.length, + 4, + ]); + expect(table(ap.data)).toHaveLength(ap.width * ap.height * ap.depth * 4); + }); + + it("slices follow the paper's squared distribution, ending at the covered distance", () => { + expect(apSliceKm(AP_SLICES - 1)).toBeCloseTo(AP_MAX_KM, 9); + expect(apSliceKm(0)).toBeCloseTo(AP_MAX_KM / AP_SLICES ** 2, 9); + for (let k = 0; k < AP_SLICES; k += 1) expect(apSliceIndex(apSliceKm(k))).toBeCloseTo(k, 9); + expect(apSliceIndex(0)).toBeCloseTo(-1, 9); + }); + + it('elevations are finest at the horizon and the index inverts them', () => { + expect(apElevation(AP_ELEVATIONS - 1)).toBeCloseTo(0, 12); + for (let i = 0; i < AP_ELEVATIONS; i += 1) + expect(apElevationIndex(apElevation(i))).toBeCloseTo(i, 9); + expect(apElevation(AP_ELEVATIONS - 1) - apElevation(AP_ELEVATIONS - 2)).toBeLessThan( + apElevation(1) - apElevation(0) + ); + }); + + it("opacity grows with path, in-scatter grows with path, and a sun under the planet's shadow scatters nothing", () => { + const r = aerialRay(45, -2, 90); + for (let k = 1; k < AP_SLICES; k += 1) { + expect(r[k][3]).toBeGreaterThan(r[k - 1][3]); + expect(r[k][0]).toBeGreaterThan(r[k - 1][0]); + } + for (const slice of aerialRay(-18, -2, 0)) expect(slice.slice(0, 3)).toEqual([0, 0, 0]); + }); + + it('is brighter toward a low sun than away, and redder toward it (Rayleigh and Mie phase)', () => { + const toward = aerialRay(2, -2, 0)[AP_SLICES - 1]; + const away = aerialRay(2, -2, 180)[AP_SLICES - 1]; + expect(toward[0]).toBeGreaterThan(20 * away[0]); + expect(toward[0] / toward[2]).toBeGreaterThan(away[0] / away[2]); + }); + + it('opacity does not depend on the sun, only on the path', () => { + expect(aerialRay(45, -2, 0)[5][3]).toBeCloseTo(aerialRay(-18, -2, 90)[5][3], 12); + }); + + it("the baked table is the model's output: spot texels equal aerialRay, to half-float precision", () => { + const t = table(ap.data); + const at = (sun: number, slice: number, el: number, az: number, c: number) => + decode(t[((sun * ap.height + slice * AP_ELEVATIONS + el) * ap.width + az) * 4 + c]); + const r = aerialRay(SUN_ELEVATIONS_DEG[8], apElevation(6), 60)[7]; + for (let c = 0; c < 4; c += 1) expect(at(8, 7, 6, 2, c)).toBeCloseTo(r[c], 3); + }); +}); -- 2.47.3 From f7725545a53166b0c8aacb9ea388e716f781cf49 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 19:30:04 +0000 Subject: [PATCH 19/24] Move the terrain tier label to top-left under the preview banner It sat bottom right and overlapped the chat status line. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/tierLabel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts index 6e3a9236..0b2c79ec 100644 --- a/src/app/features/scene/terrain/tierLabel.ts +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -12,8 +12,8 @@ export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => v el.textContent = `terrain · ${text}`; Object.assign(el.style, { position: 'absolute', - right: '6px', - bottom: '6px', + left: '2px', + top: '16px', font: '10px monospace', color: 'rgba(255,255,255,.75)', background: 'rgba(0,0,0,.4)', -- 2.47.3 From 20cc2c682d10d2d6f8e2a0c1316bd13c50437a47 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 21:12:07 +0000 Subject: [PATCH 20/24] scene: far terrain fades into the sky's own horizon colour (night glow included) Haze was the Hillaire volume alone, ~40% opaque by 2 km and without the night glow the sky adds, so the far ground sat well off the sky at the horizon (night: sky 73,79,99 vs far ground 20,24,31). Past SKY_FADE the surface is now skyColour flat along the horizon in its ray's azimuth, in the CPU twin and the GLSL alike. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainShaders.ts | 7 ++- .../features/scene/terrain/terrainSky.test.ts | 43 +++++++++++++------ src/app/features/scene/terrain/terrainSky.ts | 15 ++++++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 12428d70..5fd01d7b 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -9,7 +9,7 @@ import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, STAR_FLOOR } from './terrainSky'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -203,7 +203,10 @@ void main() { vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( lit ).join(', ')}); - o = vec4(pow(lit * (1.0 - ap.a) + haze, vec3(1.0 / 2.2)), 1.0); + // Past SKY_FADE the surface is the sky at the horizon in this ray's azimuth, the colour the sky pass draws there (terrainSky.aerialPerspective). + float toSky = smoothstep(${lit(SKY_FADE[0])}, ${lit(SKY_FADE[1])}, dist); + vec3 horizon = skyColour(vec3(ray.x, 0.0, ray.z)); + o = vec4(pow(mix(lit * (1.0 - ap.a) + haze, horizon, toSky), vec3(1.0 / 2.2)), 1.0); }`; // The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 5a8e0502..016ee1ce 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -15,6 +15,7 @@ import { skyNameFor, sunDirection, aerialPerspective, + SKY_FADE, metresToKm, groundLight, HAZE_K, @@ -195,7 +196,7 @@ describe('aerial perspective', () => { it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { let prev = along(SKY.noonday, -2, 200, 0).alpha; expect(prev).toBe(0); - for (const m of [10, 100, 400, 1000, 2000, 2500]) { + for (const m of [10, 100, 400, 800, 1000, 1300]) { const a = along(SKY.noonday, -2, 200, m).alpha; expect(a).toBeGreaterThan(prev); expect(a).toBeLessThanOrEqual(1); @@ -205,34 +206,34 @@ describe('aerial perspective', () => { it('leaves the near ground its own colour and takes the far ridges', () => { expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); - expect(along(SKY.noonday, -2, 200, 2000).alpha).toBeGreaterThan(0.3); + expect(along(SKY.noonday, -2, 200, SKY_FADE[0]).alpha).toBeGreaterThan(0.1); }); it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { - const far = along(SKY.noonday, -2, 90, 2400).colour; + const far = along(SKY.noonday, -2, 90, SKY_FADE[0]).colour; expect(far[2]).toBeGreaterThan(far[0]); }); it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { const dusk = skyAtSun(2, 270); - const toward = along(dusk, -1, 270, 2000).colour; - const away = along(dusk, -1, 90, 2000).colour; + const toward = along(dusk, -1, 270, SKY_FADE[0]).colour; + const away = along(dusk, -1, 90, SKY_FADE[0]).colour; expect(luma(toward)).toBeGreaterThan(3 * luma(away)); expect(toward[0]).toBeGreaterThan(toward[2]); }); it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { - const c = along(SKY.midnight, -2, 0, 2400).colour; - expect(luma(c)).toBeGreaterThan(0.001); + const c = along(SKY.midnight, -2, 0, SKY_FADE[0]).colour; + expect(luma(c)).toBeGreaterThan(0.0003); expect(luma(c)).toBeLessThan(0.08); expect(c[2]).toBeGreaterThan(2.5 * c[0]); - expect(luma(along(SKY.noonday, -2, 0, 2400).colour)).toBeGreaterThan(5 * luma(c)); + expect(luma(along(SKY.noonday, -2, 0, SKY_FADE[0]).colour)).toBeGreaterThan(5 * luma(c)); }); it('moonlight lights the night haze toward the moon more than away from it', () => { const moon = groundLight(SKY.midnight).dir; - const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], 2400).colour; - const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], 2400).colour; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], SKY_FADE[0]).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], SKY_FADE[0]).colour; expect(luma(toward)).toBeGreaterThan(luma(away)); }); @@ -240,16 +241,32 @@ describe('aerial perspective', () => { const d = SKY.noonday; const view = ray(-4, 200); const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(2000)).toBeGreaterThan(dark(100)); + expect(dark(SKY_FADE[0])).toBeGreaterThan(dark(100)); const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; - expect(own(2000)).toBeLessThan(0.7 * own(100)); + expect(own(SKY_FADE[0])).toBeLessThan(0.9 * own(100)); }); it('is continuous in the sun elevation through dusk: no step between neighbours', () => { - const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, 2000).colour); + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, SKY_FADE[0]).colour); for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); }); + it('far terrain is the sky: past the fade it is the horizon sky colour in its azimuth, by day, dusk and night, whatever it was', () => { + for (const [d, az] of [ + [SKY.midnight, 0], + [skyAtSun(-1, 90), 90], + [skyAtSun(2, 270), 200], + [SKY.noonday, 120], + ] as const) { + const view = ray(-0.5, az); + const hz = skyColour(d, [view[0], 0, view[2]]); + const far = aerialPerspective(d, view, SKY_FADE[1]); + expect(far.alpha).toBe(1); + for (let i = 0; i < 3; i += 1) expect(far.colour[i]).toBeCloseTo(hz[i], 9); + expect(shade(d, [0.5, 0.5, 0.5], UP, SKY_FADE[1] + 500, view)).toEqual(far.colour); + } + }); + it('the metres-to-km law is the scene metres times the baked distance scale', () => { expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); expect(metresToKm(-5)).toBe(0); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 2efef429..4034fd93 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -149,6 +149,13 @@ export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; /** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; +/** + * Metres from the eye over which the terrain gives itself up to the sky: past the far end a surface is the sky's own + * colour at the horizon in its azimuth (`skyColour` flat along the horizon, night glow included), so the patch's rim + * meets the sky above and beyond it with no seam. The volume alone is only ~40% opaque by then. EYEBALLED. + */ +export const SKY_FADE: readonly [number, number] = [600, 1350]; + /** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ export function aerialPerspective( d: SkyData, @@ -172,10 +179,14 @@ export function aerialPerspective( const moon = aerialAt(Math.abs(d.elevation), el, az, km); for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; } + const horizon = skyColour(d, [dir[0], 0, dir[2]]); + const f = smooth(SKY_FADE[0], SKY_FADE[1], distance); const colour = [0, 1, 2].map( - (i) => 1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha + (i) => + (1 - f) * (1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha) + + f * horizon[i] ) as unknown as Vec3; - return { colour, alpha: day.alpha }; + return { colour, alpha: (1 - f) * day.alpha + f }; } /** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ -- 2.47.3 From 9b4d92c9de81638f31cc79660c150f9f100099a4 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 15:19:14 +0000 Subject: [PATCH 21/24] scene: terrain mesh as fine as the height map it is shaded from, cavity read bilinear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone shots of 'terrain · low' showed hard-edged facets on the near ground and stepped crests. Cause: the grid (192 quads low, 256 full, 7 m wide at the nearest ground) was coarser than the 2.9 m height texels the fragment shader reads its normals from, so gullies the polygons could not carry showed as hard shading edges. Measured by rendering the low tier at 192..384 quads against a 768-quad reference: mean abs error 1.7 / 1.1 / 0.7 / 0.5 on the day frame at 192 / 256 / 320 / 384; the warp's shape barely moved it. - GRID: still 192, low 320, full 384; a test holds every tier to a quad of at most two texels at the nearest ground the camera sees, and the tiers ordered by cost. The low triangle ceiling goes from 80k to 210k (205k used), unmeasured on a phone. - cavity: the shader read it from the nearest texel (blocks of one texel); it is now bilinear (hBil) at a continuous position, with a CPU twin (bilinearHeight, curvatureAt, cavityAt, CAVITY) and a test that shading is continuous across texel borders where the nearest-texel read jumps. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 50 +++++++++++++++++++ .../features/scene/terrain/terrainAlbedo.ts | 37 ++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 16 +++--- .../scene/terrain/terrainWorld.test.ts | 31 +++++++++++- .../features/scene/terrain/terrainWorld.ts | 12 +++-- 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 2314f758..17363282 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -5,6 +5,10 @@ import { ALBEDO, albedoAt, albedoPalette, + bilinearHeight, + CAVITY, + cavityAt, + curvatureAt, grainAt, triWeights, wobble, @@ -172,3 +176,49 @@ describe('triplanar projection', () => { expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); }); }); + +describe('cavity shading', () => { + // A texel grid of heights with one bump; `hAt` is the nearest texel, `bilinearAt` the continuous reading. + const bump = (i: number, j: number) => 6 * Math.exp(-((i - 20) ** 2 + (j - 20) ** 2) / 40); + const nearestAt = (px: number, pz: number) => bump(Math.floor(px + 0.5), Math.floor(pz + 0.5)); + // Largest change in curvature between neighbouring sample positions a hundredth of a texel apart, over a few texels. + const worstStep = (read: (px: number, pz: number) => number) => { + let worst = 0; + for (let px = 14; px < 26; px += 0.01) + worst = Math.max( + worst, + Math.abs(curvatureAt(read, px + 0.01, 20.3, 4) - curvatureAt(read, px, 20.3, 4)) + ); + return worst; + }; + + it('curvature is the mean of four neighbours at the radius minus the centre', () => { + expect(curvatureAt((px, pz) => px * 2 + pz, 7, 9, 3)).toBeCloseTo(0, 12); + expect(curvatureAt((px, pz) => (px - 5) ** 2 + (pz - 5) ** 2, 5, 5, 2)).toBeCloseTo(4, 12); + }); + + it('bilinear height reads the texel exactly at a texel centre and the mean at the middle of four', () => { + const t = (i: number, j: number) => i * 10 + j * 100; + expect(bilinearHeight(t, 3, 4)).toBe(430); + expect(bilinearHeight(t, 3.5, 4.5)).toBeCloseTo(485, 9); + }); + + it('shading from a bilinear read changes smoothly across texel borders; from the nearest texel it jumps', () => { + const smooth = worstStep((px, pz) => bilinearHeight(bump, px, pz)); + const blocky = worstStep(nearestAt); + expect(blocky).toBeGreaterThan(0.1); + expect(smooth).toBeLessThan(0.02); + }); + + it('the cavity factor darkens a hollow, lights a rise and leaves flat ground alone', () => { + expect(cavityAt(0)).toBe(1); + expect(cavityAt(CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + expect(cavityAt(-CAVITY.range)).toBeCloseTo(1 + CAVITY.light, 12); + expect(cavityAt(10 * CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + }); + + it('the grid shader reads the cavity through the bilinear height, the same constants as CAVITY', () => { + expect(GRID_FRAG).toContain(`#define CAV_RANGE ${CAVITY.range.toFixed(1)}`); + expect(GRID_FRAG).not.toMatch(/curv[^;]*hAt\(/); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index c9f11ab4..fef204ff 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -158,3 +158,40 @@ export function grainAt( 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers ); } + +/** The cavity term's numbers: curvature (metres) at which a hollow is fully shaded / a rise fully lit, and by how much. */ +export const CAVITY = { radius: 12, range: 12, dark: 0.55, light: 0.3 } as const; + +/** Height at a continuous texel position, bilinear over the four texels around it (the shader's `hBil`). */ +export function bilinearHeight( + texel: (i: number, j: number) => number, + px: number, + pz: number +): number { + const i = Math.floor(px); + const j = Math.floor(pz); + const fx = px - i; + const fz = pz - j; + return mix( + mix(texel(i, j), texel(i + 1, j), fx), + mix(texel(i, j + 1), texel(i + 1, j + 1), fx), + fz + ); +} + +/** Mean of the four neighbours `r` texels away minus the centre: positive in a hollow, negative on a rise. */ +export function curvatureAt( + height: (px: number, pz: number) => number, + px: number, + pz: number, + r: number +): number { + return ( + 0.25 * (height(px + r, pz) + height(px - r, pz) + height(px, pz + r) + height(px, pz - r)) - + height(px, pz) + ); +} + +/** The multiplier on lit colour for a curvature: a hollow sits in shade, a rise catches light (the shader's `cavity`). */ +export const cavityAt = (curv: number): number => + 1 - CAVITY.dark * clamp01(curv / CAVITY.range) + CAVITY.light * clamp01(-curv / CAVITY.range); diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 5fd01d7b..8e270d83 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,7 +7,7 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; -import { ALBEDO } from './terrainAlbedo'; +import { ALBEDO, CAVITY } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; @@ -135,6 +135,10 @@ const ALBEDO_DEFINES = ` #define ALB_STRATA ${lit(ALBEDO.strata)} #define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} #define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} +#define CAV_RADIUS ${lit(CAVITY.radius)} +#define CAV_RANGE ${lit(CAVITY.range)} +#define CAV_DARK ${lit(CAVITY.dark)} +#define CAV_LIGHT ${lit(CAVITY.light)} `; // The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The // same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. @@ -180,11 +184,11 @@ void main() { 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); - // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. - int r = max(1, int(12.0 / texel)); - ivec2 c = ivec2(floor(p + 0.5)); - float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); - float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light (terrainAlbedo.cavityAt). Shader-only, from the height map, + // read bilinear at a continuous position so it does not snap to a texel (that made the shading blocks of one texel, 3 m). + float cr = CAV_RADIUS / texel; + float curv = 0.25 * (hBil(p + vec2(cr, 0.0)) + hBil(p - vec2(cr, 0.0)) + hBil(p + vec2(0.0, cr)) + hBil(p - vec2(0.0, cr))) - hBil(p); + float cavity = 1.0 - CAV_DARK * clamp(curv / CAV_RANGE, 0.0, 1.0) + CAV_LIGHT * clamp(-curv / CAV_RANGE, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; float dist = length(toSurface); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 17935382..78ab2700 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -41,7 +41,8 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - expect(triangleCount(GRID.low.grid)).toBeLessThan(80000); + // 205k at 320 quads: a guess at what a phone carries (SwiftShader only, no phone frame time), kept as a ceiling so a bigger grid is a deliberate change. + expect(triangleCount(GRID.low.grid)).toBeLessThan(210000); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); @@ -89,6 +90,34 @@ describe('camera', () => { }); }); +describe('grid resolution', () => { + // The mesh must carry what the height map shows: where a quad is wider than a couple of texels the shading (read from the + // height map) has gullies the polygons cannot, and the near ground reads as hard-edged facets (the 'terrain · low' phone + // shots, 2026-11-09). Metres from the patch centre to the nearest ground the camera can see: the bottom view ray meets + // the highest terrain, hmax below the eye, at (eye - hmax) / tan(pitch + fov / 2). + const nearest = (CAMERA.eye - TERRAIN.hmax) / Math.tan(CAMERA.pitch + CAMERA.fov / 2); + const spacingAt = (grid: number, metres: number) => { + for (let i = 0; i < grid; i += 1) { + const x = (warpUv((i + 1) / grid) - 0.5) * TERRAIN.span; + if (x >= metres) return (warpUv((i + 1) / grid) - warpUv(i / grid)) * TERRAIN.span; + } + throw new Error('beyond the patch'); + }; + + it.each(['still', 'low', 'full'] as const)( + 'the %s tier has a quad no wider than two height texels at the nearest ground the camera can see', + (tier) => { + const { grid, tex } = GRID[tier]; + expect(spacingAt(grid, nearest)).toBeLessThanOrEqual((2 * TERRAIN.span) / (tex - 1)); + } + ); + + it('a dearer tier never has a coarser mesh', () => { + expect(GRID.low.grid).toBeGreaterThanOrEqual(GRID.still.grid); + expect(GRID.full.grid).toBeGreaterThanOrEqual(GRID.low.grid); + }); +}); + describe('warpUv', () => { it('maps the patch onto itself, monotonic, centre fixed', () => { expect(warpUv(0)).toBe(0); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index fcc02390..7296025b 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -5,11 +5,15 @@ /** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ export const TERRAIN = { span: 3000, hmax: 256 }; -/** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ +/** + * Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. The + * mesh is dense enough that a quad is at most two texels wide at the nearest ground (terrainWorld.test.ts); a coarser mesh + * under shading read from the height map shows as hard-edged facets and stepped crests. + */ export const GRID = { - still: { grid: 128, tex: 512 }, - low: { grid: 192, tex: 1024 }, - full: { grid: 256, tex: 1024 }, + still: { grid: 192, tex: 512 }, + low: { grid: 320, tex: 1024 }, + full: { grid: 384, tex: 1024 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ -- 2.47.3 From 78a7dbe9231adecabb6df2af15f362f310a2230d Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:17:35 +0000 Subject: [PATCH 22/24] auth: login card fill 50% -> 54% so 4.5:1 holds without backdrop-filter Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index b45539a6..9a4240f0 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,9 +56,9 @@ export const AuthFooter = style({ // signed-out ground is clear and the card is a translucent, blurred pane shaped like a message // bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The // --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. The fill is 50%: the 18px blur carries the readability, flattening the sky and -// ridges behind the text. Without the blur, text is 4.15:1 over the brightest backdrop (the horizon -// glow; 4.5:1 needs 54%) and >= 7.3:1 elsewhere in the scene. +// until it lands. The fill is 54%, the least that gives 4.5:1 text over the brightest backdrop (the +// horizon glow) without depending on the blur; it is >= 7.3:1 elsewhere in the scene. The 18px blur +// only softens the sky and ridges behind the text. // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -67,8 +67,8 @@ globalStyle(`${scene} ${AuthLayout}`, { }); globalStyle(`${scene} ${AuthCard}`, { backgroundColor: [ - 'rgba(13, 26, 38, 0.5)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 50%, transparent)', + 'rgba(13, 26, 38, 0.54)', + 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 54%, transparent)', ], color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, -- 2.47.3 From a773bc4932e081f3de6b763f8d9467cd1eef989b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:37:16 +0000 Subject: [PATCH 23/24] auth: Noonday Sky login card opaque, so text and link pass AA over any terrain Libby's read: at 54% fill Noonday text was 2.99:1 and the Register link 2.06:1 over shaded terrain. Dark text on a light bubble fails when a dark ground shows through, so Noonday's card now takes the bubble colour with no transparency. Midnight is unchanged (54%). WCAG 2.x ratios, computed (relative luminance, sRGB): Noonday, bubble #b9b6af, text #342d25, link #2a4f27, card over black / white: 54% fill: text 2.23 / 9.51, link 1.54 / 6.55 (fails over dark) 80% fill: text 4.36 / 7.86, link 3.00 / 5.42 (fails over dark) 90% fill: text 5.44 / 7.24, link 3.75 / 4.99 (link fails over dark) 100% fill: text 6.70, link 4.62, independent of backdrop and blur Midnight, bubble #0a090e, text #aeccff, link #609385, 54% fill: over black text 12.49, link 5.83; over white text 2.62, link 1.22 (white is not in the night scene; 4.5:1 over the horizon glow is an estimate, not measured) Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 9a4240f0..06339710 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,9 +56,12 @@ export const AuthFooter = style({ // signed-out ground is clear and the card is a translucent, blurred pane shaped like a message // bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The // --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. The fill is 54%, the least that gives 4.5:1 text over the brightest backdrop (the -// horizon glow) without depending on the blur; it is >= 7.3:1 elsewhere in the scene. The 18px blur +// until it lands. The fill is 54% in Midnight Sky (light text on a near-black bubble: 12.5:1 over a +// black backdrop; 54% was estimated as the least for 4.5:1 over the horizon glow). The 18px blur // only softens the sky and ridges behind the text. +// Noonday Sky is dark text on a light bubble, so the dark ground behind a translucent card is what +// fails it (Libby: 2.99:1 text, 2.06:1 Register link at 54%). Its card is opaque: bubble #b9b6af +// holds text #342d25 at 6.70:1 and the link #2a4f27 at 4.62:1 whatever is behind it, blur or not. // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -76,6 +79,9 @@ globalStyle(`${scene} ${AuthCard}`, { borderColor: 'rgba(183, 199, 217, 0.14)', backdropFilter: 'blur(18px)', }); +globalStyle(`${scene} body.noonday-sky ${AuthCard}`, { + backgroundColor: 'var(--sky-bubble, #b9b6af)', +}); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', }); -- 2.47.3 From ef961636c3ab345648ae917d284856a58574ba26 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:40:47 +0000 Subject: [PATCH 24/24] auth: opaque login card for every theme, own link colour off the sky themes The scene sky is one fixed dusk for every theme, so a translucent card put Midnight's link at 4.12/3.37 and Light's and Silver's at 2.49 (Libby). The card is now the theme's bubble colour with no transparency and no backdrop-filter, so text and link contrast no longer depend on the scene or a blur. Themes without --sky-bubble (Light, Silver, Dark, Butter) get link #99c7ff on the card (Dark and Butter already used that hue); Midnight and Noonday keep their own. WCAG 2.x ratios, computed, text / Register link on the card: Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 06339710..4fa42d74 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -53,15 +53,17 @@ export const AuthFooter = style({ }); // While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the -// signed-out ground is clear and the card is a translucent, blurred pane shaped like a message -// bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The -// --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. The fill is 54% in Midnight Sky (light text on a near-black bubble: 12.5:1 over a -// black backdrop; 54% was estimated as the least for 4.5:1 over the horizon glow). The 18px blur -// only softens the sky and ridges behind the text. -// Noonday Sky is dark text on a light bubble, so the dark ground behind a translucent card is what -// fails it (Libby: 2.99:1 text, 2.06:1 Register link at 54%). Its card is opaque: bubble #b9b6af -// holds text #342d25 at 6.70:1 and the link #2a4f27 at 4.62:1 whatever is behind it, blur or not. +// signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent: +// radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for +// every theme, so the card is opaque: its text and link contrast cannot depend on what the scene +// shows behind it, nor on a blur. The --sky-bubble variables come from the sky themes (Midnight and +// Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours. +// WCAG ratios of text / Register link on the card, per theme: +// Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 +// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 +// Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 +// The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are +// made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -69,18 +71,14 @@ globalStyle(`${scene} ${AuthLayout}`, { backgroundImage: 'none', }); globalStyle(`${scene} ${AuthCard}`, { - backgroundColor: [ - 'rgba(13, 26, 38, 0.54)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 54%, transparent)', - ], + backgroundColor: 'var(--sky-bubble, #0d1a26)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, // A faint inner edge, so a pane this clear still reads as a surface. borderColor: 'rgba(183, 199, 217, 0.14)', - backdropFilter: 'blur(18px)', }); -globalStyle(`${scene} body.noonday-sky ${AuthCard}`, { - backgroundColor: 'var(--sky-bubble, #b9b6af)', +globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { + vars: { '--tc-link': '#99c7ff' }, }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', -- 2.47.3