diff --git a/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts index 931a1ad4..3606d14f 100644 --- a/harness/scene/ridgeDemo.ts +++ b/harness/scene/ridgeDemo.ts @@ -1,12 +1,29 @@ // Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the -// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped. +// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from +// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks` +// for a driver to read. Not shipped. import { startBackgroundScene } from '../../src/app/features/scene'; import type { SceneTier } from '../../src/app/features/scene'; const q = new URLSearchParams(location.search); const tier = (q.get('tier') ?? 'full') as SceneTier; const canvas = document.getElementById('c') as HTMLCanvasElement; -startBackgroundScene({ tier }, {}, { canvas }).then((scene) => { - (window as unknown as { scene: unknown }).scene = scene; +const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number }; +w.bakes = []; +w.longTasks = []; +try { + new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({ + entryTypes: ['longtask'], + }); +} catch { + // No long-task entries in this browser. +} +const t0 = performance.now(); +startBackgroundScene({ tier }, {}, { + canvas, + onBake: (b: unknown) => w.bakes.push(b), +} as never).then((scene) => { + w.firstViewMs = performance.now() - t0; + w.scene = scene; document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)'; }); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index fa7565b0..957f1a42 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -4,13 +4,22 @@ config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene. -- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or - null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's - value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain - 2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between - layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a - CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame. - The host canvas and the layers' parent must be positioned and the canvas should fill the parent. +- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, onBake?, sky? })` returns `{ login, dispose }` or + null (tier `off`, no 2D context). Each of four ridge layers is a function of world-x (`ridge/ridgeWorld.ts` `profile`: + ridged multifractal value noise, each octave folded at its crest and weighted by the previous one, domain-warped; one + function, a parameter row per layer; nothing repeats). World-x is baked in chunks (1.2 viewport widths, integer pixels) + onto plain 2D canvases over a baked sky gradient on the host canvas. Per layer a track holds three chunk canvases: two + the window can reach and one ahead. The first view bakes only the first chunk of each layer; the others are baked in + tasks of about 6 ms (`setTimeout`; a chunk is baked, put on a canvas with its in-scatter, and blurred in column bands, each sized from the last band's time). Per frame the only motion is the + compositor sliding the track with a CSS transform (a Web Animation of `translate3d`, one chunk long, `fill: forwards`, + 150-420 s per viewport width, farther is slower); when it finishes the chunks move one slot left and the track restarts + at 0 in the same task (no jump), and the canvas that left is baked with the next stretch of world. A second animation + sways the whole rig (up to 5% of the width sideways, 3% of the height down, 150 s loop): the slow yaw and pitch. + No script runs per frame. The host canvas and the layers' parent must be positioned and the canvas should fill the parent. +- `ridge/ridgeBake.ts` is the pixel function (pure, no DOM): per column the crest's slope gives a normal, lit against the + sun (`sunDirection`: x = 0.9 sin(2 pi \* `sunAzimuth`), so the sun is toward +x for azimuth 0 to 0.5, strongest at 0.25, toward -x for 0.5 to 1; with no `sunAzimuth` the face lighting falls back to 0.3 and the in-scatter is off); the face under it has a light-to-haze gradient, erosion + streaks (a folded noise at gully width, darker with steepness) and a lit rim on the crest. Contrast scales with + `transmittance`; the base colour, haze and blur are the aerial perspective below, not redone. - Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played. `tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements. - Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over @@ -23,8 +32,9 @@ means "keep the CSS ground". Everything below this section describes the opt-in in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is `T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1, via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the - sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer - rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light. + sun's azimuth in world-x (two incommensurate cosines, so not periodic). Nothing runs per frame. The in-scatter lobe is + painted into each chunk, so it drifts with the layer rather than staying fixed under the sun; that is a known + approximation until the sun is a real, fixed light. - `harness/scene/bundle.cjs ridge` builds a standalone page (`?tier=`, `?probe=1`). - Not yet measured on a phone: see the PR description. diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index adee2717..864491f1 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRidgeScene } from './Ridge'; -import { fbmPeriodic, valueNoisePeriodic, hash2 } from './noise'; +import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; +import { valueNoise, hash2 } from './noise'; import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe'; import { AERIAL, - FBM, LAYERS, edgeBlur, hazeColour, @@ -23,58 +23,37 @@ describe('noise', () => { it('is deterministic and in [0, 1)', () => { expect(hash2(3, 5)).toBe(hash2(3, 5)); for (let i = 0; i < 200; i += 1) { - const v = valueNoisePeriodic(i * 0.37, 2, 7); + const v = valueNoise(i * 0.37 - 20, 2); expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeLessThan(1); - } - }); - - it('wraps: the lattice repeats every `period` cells', () => { - for (const x of [0, 0.4, 3.9, 6.99]) { - expect(valueNoisePeriodic(x + 7, 4, 7)).toBeCloseTo(valueNoisePeriodic(x, 4, 7), 12); + expect(valueNoise(i * 0.37 - 20, 2)).toBe(v); } }); it('different rows are different profiles', () => { - expect(valueNoisePeriodic(1.5, 0, 9)).not.toBeCloseTo(valueNoisePeriodic(1.5, 1, 9), 3); + expect(valueNoise(1.5, 0)).not.toBeCloseTo(valueNoise(1.5, 1), 3); }); }); describe('ridge profile', () => { - it('every layer is periodic in t, so a drift of one period loops seamlessly', () => { + it('stays in [0, 1] for any world-x, negative and far included, and layers differ', () => { for (const spec of LAYERS) { - for (const t of [0, 0.13, 0.5, 0.97]) { - expect(Number.isFinite(profile(spec, t))).toBe(true); - } - // the end of one period meets the start of the next - expect(profile(spec, 1 - 1e-9)).toBeCloseTo(profile(spec, 0), 3); - } - }); - - it('every octave count is an integer lattice, which periodicity needs', () => { - for (const spec of LAYERS) { - for (let k = 0; k < FBM.octaves; k += 1) { - expect(Number.isInteger(spec.baseCells * FBM.lacunarity ** k)).toBe(true); - } - } - }); - - it('stays in [0, 1] and layers differ', () => { - for (const spec of LAYERS) { - for (let i = 0; i < 100; i += 1) { - const p = profile(spec, i / 100); - expect(p).toBeGreaterThanOrEqual(0); - expect(p).toBeLessThanOrEqual(1); + for (let i = -300; i < 300; i += 7) { + for (const off of [0, 1234.5, 98765.25]) { + const p = profile(spec, i / 50 + off); + expect(p).toBeGreaterThanOrEqual(0); + expect(p).toBeLessThanOrEqual(1); + } } } expect(profile(LAYERS[0], 0.3)).not.toBeCloseTo(profile(LAYERS[1], 0.3), 3); }); - it('ridge tops lie inside the layer band and rise with farther base', () => { + it('ridge tops lie inside the layer band', () => { const size = layerSize(400, 800, 1); for (const spec of LAYERS) { - for (let x = 0; x < size.period; x += 17) { - const y = ridgeY(spec, x, size.period, size.height, 800); + for (let x = -500; x < 5 * size.period; x += 17) { + const y = ridgeY(spec, x / size.period, size.height, 800); expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(size.height); } @@ -95,9 +74,9 @@ describe('ridge profile', () => { ] as const) { const z = layerSize(w, h, s); expect( - Number.isInteger(z.period) && Number.isInteger(z.width) && Number.isInteger(z.height) + Number.isInteger(z.period) && Number.isInteger(z.chunk) && Number.isInteger(z.height) ).toBe(true); - expect(z.width).toBe(2 * z.period); + expect(z.chunk).toBeGreaterThan(z.period); } }); }); @@ -147,14 +126,16 @@ describe('aerial perspective', () => { expect(edgeBlur(far)).toBeLessThanOrEqual(AERIAL.blurPx); }); - it('in-scatter peaks at the sun azimuth, is periodic, faint, grows with distance, and is off without a sun', () => { + it('in-scatter is faint, grows with distance, is off without a sun, and never repeats', () => { const az = 0.3; const s = { ...sky, sunAzimuth: az }; - expect(scatterAlpha(far, s, az)).toBeGreaterThan(scatterAlpha(far, s, az + 0.5)); - expect(scatterAlpha(far, s, 0)).toBeCloseTo(scatterAlpha(far, s, 1), 12); - expect(scatterAlpha(far, s, az)).toBeLessThanOrEqual(AERIAL.scatter); + const lobe = (t: number) => scatterAlpha(far, s, t); + expect(lobe(az * 1.9)).toBeGreaterThan(lobe(az * 1.9 + 0.95)); + for (const period of [1, 1.9, 3.8, 5.7, 19]) + expect(Math.abs(lobe(0.7 + period) - lobe(0.7))).toBeGreaterThan(1e-4); + expect(lobe(az * 1.9)).toBeLessThanOrEqual(AERIAL.scatter); expect(scatterAlpha(near, s, az)).toBe(0); - expect(scatterAlpha(LAYERS[1], s, az)).toBeLessThan(scatterAlpha(far, s, az)); + expect(scatterAlpha(LAYERS[1], s, az * 1.9)).toBeLessThan(scatterAlpha(far, s, az * 1.9)); expect(scatterAlpha(far, { ...sky, sunAzimuth: undefined }, az)).toBe(0); }); }); @@ -237,8 +218,21 @@ function fakeEl(tag = 'div') { this.children.push(c); return c; }, - animate: vi.fn(() => { - const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() }; + animate: vi.fn((keyframes: any[], options: any) => { + const a: any = { + currentTime: 0, + keyframes, + options, + onfinish: null, + play: vi.fn(), + pause: vi.fn(), + cancel: vi.fn(), + /** The animation reaches its end: what the compositor's finish event does. */ + finish() { + a.currentTime = options.duration; + a.onfinish?.(); + }, + }; el.anims.push(a); return a; }), @@ -256,6 +250,10 @@ const DAY: SkyColours = { sunAzimuth: 0.3, }; const draws: string[] = []; +/** Every ImageData put on any canvas, in order. */ +const puts: any[] = []; +/** For every drawImage: the source canvas's width as it was at the moment of the draw. */ +const drawnWidths: number[] = []; function ctx(withFilter = false) { const c: any = { globalCompositeOperation: 'source-over', @@ -263,6 +261,16 @@ function ctx(withFilter = false) { fillRect() { draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky'); }, + save() {}, + restore() {}, + rect() {}, + clip() {}, + createImageData: (w: number, h: number) => ({ + data: new Uint8ClampedArray(w * h * 4), + width: w, + height: h, + }), + putImageData: (img: any) => puts.push(img), beginPath() {}, moveTo() {}, lineTo() {}, @@ -280,7 +288,10 @@ function ctx(withFilter = false) { if (c.arcing) c.arcing = false; else draws.push('fill'); }, - drawImage: () => draws.push('flatten'), + drawImage: (src: any) => { + drawnWidths.push(src.width); + draws.push('flatten'); + }, set fillStyle(_v: unknown) {}, }; if (withFilter) c.filter = 'none'; @@ -295,6 +306,8 @@ let search: string; beforeEach(() => { rafQueue = []; draws.length = 0; + puts.length = 0; + drawnWidths.length = 0; search = ''; motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }; doc = Object.assign(new EventTarget(), { @@ -330,6 +343,11 @@ function host() { parent.appendChild(canvas); return { parent, canvas }; } +/** The rig holds one track per layer, farthest first; each track holds three chunk canvases. */ +const rigOf = (parent: any) => parent.children.find((c: any) => c.tag === 'div'); +const tracksOf = (parent: any): any[] => rigOf(parent).children; +const animsOf = (parent: any): any[] => + [rigOf(parent), ...tracksOf(parent)].flatMap((e: any) => e.anims); const runFrames = (dt: number, n: number, from = 0) => { let t = from; for (let i = 0; i < n && rafQueue.length; i += 1) { @@ -353,19 +371,25 @@ describe('createRidgeScene', () => { expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull(); }); - it('bakes the sky once and paints four layers at integer sizes, farthest first', () => { + it('bakes the sky once, and per layer only the first chunk before the first frame, at integer sizes, farthest first', () => { const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const baked: any[] = []; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => baked.push(b) })!; expect(s).not.toBeNull(); - const layers = parent.children.slice(1); - expect(layers).toHaveLength(LAYERS.length); - layers.forEach((l: any) => { - expect(Number.isInteger(l.width) && Number.isInteger(l.height)).toBe(true); - expect(l.width).toBe(canvas.width * 2); // DPR 2: layer is two periods wide - expect(l.animate).toHaveBeenCalledTimes(1); + const tracks = tracksOf(parent); + expect(tracks).toHaveLength(LAYERS.length); + tracks.forEach((t: any) => { + expect(t.children).toHaveLength(3); + expect(t.animate).toHaveBeenCalledTimes(1); + t.children.forEach((c: any) => { + expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true); + expect(c.width).toBe(layerSize(400, 800, 2).chunk); + }); }); + expect(baked.map((b) => [b.layer, b.chunk, b.first])).toEqual( + LAYERS.map((l) => [l.index, 0, true]) + ); expect(draws.filter((d) => d === 'sky')).toHaveLength(1); - expect(draws.filter((d) => d === 'fill')).toHaveLength(LAYERS.length); s.dispose(); }); @@ -403,8 +427,9 @@ describe('createRidgeScene', () => { motion.matches = true; const { canvas } = host(); 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); + const anims = animsOf(canvas.parentElement); + expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway + expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true); anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled()); expect(rafQueue).toHaveLength(0); s.dispose(); @@ -413,7 +438,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', sky: DAY })!; - const anims = parent.children.slice(1).flatMap((l: any) => l.anims); + const anims = animsOf(parent); anims.forEach((a: any) => a.pause.mockClear()); doc.visibilityState = 'hidden'; doc.dispatchEvent(new Event('visibilitychange')); @@ -442,7 +467,7 @@ describe('createRidgeScene', () => { const { parent, canvas } = host(); const before = parent.children.length; const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - expect(parent.children.length).toBe(before + LAYERS.length); + expect(parent.children.length).toBe(before + 1); // the rig runFrames(16.7, 300); expect(rafQueue.length).toBe(0); s.dispose(); @@ -466,7 +491,7 @@ describe('createRidgeScene', () => { (window as any).innerHeight = 800; window.dispatchEvent(new Event('resize')); await new Promise((r) => setTimeout(r, 300)); - expect(parent.children.slice(1)).toHaveLength(LAYERS.length); + expect(tracksOf(parent)).toHaveLength(LAYERS.length); expect(draws.filter((d) => d === 'sky')).toHaveLength(1); s!.dispose(); expect(parent.children).toEqual([canvas]); @@ -475,7 +500,7 @@ describe('createRidgeScene', () => { it('dispose cancels the loop, the animations and the layers', () => { const { parent, canvas } = host(); const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; - const anims = parent.children.slice(1).flatMap((l: any) => l.anims); + const anims = animsOf(parent); s.dispose(); anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled()); expect(parent.children).toEqual([canvas]); @@ -501,16 +526,6 @@ describe('the door', () => { }); }); -describe('fbm', () => { - it('is normalised into [0, 1]', () => { - for (let i = 0; i < 100; i += 1) { - const v = fbmPeriodic(i / 100, 0, 3, FBM); - expect(v).toBeGreaterThanOrEqual(0); - expect(v).toBeLessThanOrEqual(1); - } - }); -}); - 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(); @@ -533,7 +548,7 @@ describe('aerial perspective in the scene', () => { s.dispose(); }); - it('softens with the canvas filter where it exists, blur scaled by DPR, tiled so the wrap does not fade', () => { + it('softens with the canvas filter where it exists, blur scaled by DPR, from pixels baked past the chunk edges', () => { const { parent, canvas } = host(); const filters: string[] = []; const orig = (document as any).createElement; @@ -557,7 +572,7 @@ describe('aerial perspective in the scene', () => { 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 - expect(parent.children).toHaveLength(1 + LAYERS.length); + expect(parent.children).toHaveLength(2); // sky canvas and the rig s.dispose(); }); @@ -591,9 +606,8 @@ describe('the starfield in the scene', () => { 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); + expect(parent.children).toHaveLength(3); const before = baked(); runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps expect(baked()).toBe(before); @@ -609,7 +623,7 @@ describe('the starfield in the scene', () => { const { parent, canvas } = host(); const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; expect(count('star')).toBe(0); - expect(parent.children).toHaveLength(1 + LAYERS.length); + expect(parent.children).toHaveLength(2); runFrames(16.7, 60); expect(count('clear')).toBe(0); s.dispose(); @@ -688,3 +702,113 @@ describe('the starfield in the scene', () => { expect(rafQueue).toHaveLength(0); }); }); + +describe('endless drift: chunks baked on demand and recycled', () => { + const size = layerSize(400, 800, 2); // the fake host: 400 x 800 CSS px at DPR 2 + const until = async (cond: () => boolean) => { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > 10000) throw new Error('timed out waiting for the background bakes'); + await new Promise((r) => setTimeout(r, 5)); + } + }; + /** The pixels chunk `m` of layer `li` must hold: the pure bake of its world columns, no blur. */ + const expected = (li: number, m: number) => { + const p: BakeParams = { + spec: LAYERS[li], + sky: DAY, + period: size.period, + scale: 2, + viewH: 1600, + rows: size.height, + bandH: size.height, + px0: m * size.chunk, + cols: size.chunk, + }; + const out = new Uint8ClampedArray(p.cols * p.rows * 4); + bakeSlice(out, p, layerPaint(p.spec, DAY), 0, p.cols); + return Buffer.from(out.buffer); + }; + /** Each distinct ImageData put, in order of first appearance: one per baked chunk. */ + const baked = () => [...new Set(puts)].map((img) => Buffer.from(img.data.buffer)); + const start = () => { + const { parent, canvas } = host(); + const records: any[] = []; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => records.push(b) })!; + return { parent, s, records }; + }; + + it('bakes every chunk from its own world columns: no offset, the next chunk continues the last', async () => { + const { s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + // First view: chunk 0 of each layer; then, per layer in queue order, chunks 1 and 2. + const order = [ + ...LAYERS.map((l) => [l.index, 0]), + ...LAYERS.flatMap((l) => [ + [l.index, 1], + [l.index, 2], + ]), + ]; + expect(records.map((r) => [r.layer, r.chunk])).toEqual(order); + const imgs = baked(); + expect(imgs).toHaveLength(order.length); + order.forEach(([li, m], i) => expect(imgs[i].equals(expected(li, m))).toBe(true)); + s.dispose(); + }); + + it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => { + const { parent, s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + const track = tracksOf(parent)[0]; + const [e0, e1, e2] = track.children; + const first = track.anims[0]; + // One chunk of drift, not one viewport: the track slides by the width of a chunk. + expect(first.keyframes[1].transform).toContain(`${-size.chunk / 2}px`); + expect(track.animate).toHaveBeenCalledTimes(1); + first.finish(); + expect(first.cancel).toHaveBeenCalled(); + expect(track.animate).toHaveBeenCalledTimes(2); + const w = size.chunk / 2; + expect([e1.style.left, e2.style.left, e0.style.left]).toEqual(['0px', `${w}px`, `${2 * w}px`]); + expect(track.anims[1].keyframes[1].transform).toContain(`${-w}px`); + s.dispose(); + }); + + it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => { + const { parent, s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + const track = tracksOf(parent)[0]; + const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk); + for (let m = 3; m <= 5; m += 1) { + track.anims[track.anims.length - 1].finish(); + await until(() => done().includes(m)); + } + expect(done()).toEqual([0, 1, 2, 3, 4, 5]); + // The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one. + const imgs = baked(); + const layer0 = imgs.filter((_, i) => records[i].layer === 0); + [3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true)); + expect(layer0[3].equals(layer0[0])).toBe(false); + s.dispose(); + }); + + it('with the blur, each chunk is blurred from a scratch canvas of its own width: seams stay opaque and exact', async () => { + const orig = (document as any).createElement; + (document as any).createElement = (tag: string) => { + const el = orig(tag); + el.getContext = vi.fn(() => ctx(true)); + return el; + }; + const { s, records } = start(); + await until(() => records.length === 3 * LAYERS.length); + const want = new Set( + LAYERS.filter((l) => edgeBlur(l) > 0).map( + (l) => size.chunk + 2 * Math.ceil(3 * edgeBlur(l) * 2) + ) + ); + expect(want.size).toBeGreaterThan(1); // the layers' pads differ, which is what a shared canvas gets wrong + // Every blur reads a source exactly as wide as that chunk's padded bake, at the moment of the draw. + expect(new Set(drawnWidths)).toEqual(want); + s.dispose(); + }); +}); diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index fd40a967..72985d4c 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -1,18 +1,20 @@ +/* eslint-disable no-param-reassign -- the bake writes into the canvases and layer records it is handed */ import type { Scene, SceneOptions } from '../Scene'; import { overlayRequested } from '../debugOverlays'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; -import { - LAYERS, - LayerSpec, - edgeBlur, - hazeColour, - layerColour, - layerSize, - ridgeY, - scatterAlpha, -} from './ridgeWorld'; +import { bakeSlice, BakeParams, layerPaint } from './ridgeBake'; +import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld'; + +/** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */ +export interface BakeRecord { + layer: number; + chunk: number; + ms: number; + maxTaskMs: number; + first: boolean; +} export type RidgeOptions = Pick & { /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */ @@ -23,15 +25,11 @@ export type RidgeOptions = Pick & { onProbe?: (s: FrameStats & { still: boolean }) => void; /** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */ sky?: SkyColours; + /** Called after each chunk is baked (a chunk is one layer's stretch of world-x): the cost record. */ + onBake?: (b: BakeRecord) => void; }; const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`; -const mix = (a: Srgb, b: Srgb, t: number): Srgb => [ - Math.round(a[0] + (b[0] - a[0]) * t), - Math.round(a[1] + (b[1] - a[1]) * t), - Math.round(a[2] + (b[2] - a[2]) * t), -]; - function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null { return c.getContext('2d'); } @@ -49,95 +47,202 @@ function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean { return true; } +const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +type Size = ReturnType; + const SCATTER_STOPS = 16; -/** Warm in-scatter over the silhouette only (source-atop), as a horizontal gradient that repeats each period. */ +/** Warm in-scatter over the silhouette only (source-atop): a horizontal gradient over the chunk's world-x. */ function scatter( ctx: CanvasRenderingContext2D, spec: LayerSpec, sky: SkyColours, - size: { period: number; width: number; height: number } + widthPx: number, + heightPx: number, + t0: number, + tPerPx: number, + bandX = 0, + bandW = widthPx ) { - if (sky.sunAzimuth === undefined || scatterAlpha(spec, sky, sky.sunAzimuth) <= 0) return; - const g = ctx.createLinearGradient(0, 0, size.width, 0); + if (sky.sunAzimuth === undefined || transmittance(spec) >= 1) return; // the nearest layer takes none + const g = ctx.createLinearGradient(0, 0, widthPx, 0); const [r, gr, b] = sky.horizon; - for (let k = 0; k <= 2 * SCATTER_STOPS; k += 1) { - const t = (k % SCATTER_STOPS) / SCATTER_STOPS; - g.addColorStop(k / (2 * SCATTER_STOPS), `rgba(${r},${gr},${b},${scatterAlpha(spec, sky, t)})`); + for (let k = 0; k <= SCATTER_STOPS; k += 1) { + const a = scatterAlpha(spec, sky, t0 + (tPerPx * widthPx * k) / SCATTER_STOPS); + g.addColorStop(k / SCATTER_STOPS, `rgba(${r},${gr},${b},${a})`); } ctx.globalCompositeOperation = 'source-atop'; ctx.fillStyle = g; - ctx.fillRect(0, 0, size.width, size.height); + ctx.fillRect(bandX, 0, bandW, heightPx); ctx.globalCompositeOperation = 'source-over'; } -/** - * Soften the silhouette's edge by `px` once, where the browser's 2D canvas supports `filter` (not - * Safari, which keeps the sharp edge). One period is tiled past both ends and the bottom row extended below, - * then blurred in a single draw, so the blur sees the wrap and the ground instead of fading at borders. - */ -function soften(src: HTMLCanvasElement, px: number, period: number): HTMLCanvasElement { - const probe = context2d(src); - if (!probe || px <= 0 || !('filter' in probe)) return src; - const pad = Math.ceil(3 * px); - // The unfiltered tiling: blurring tile by tile would fade each tile's own border. - const tiled = document.createElement('canvas'); - tiled.width = src.width + 2 * period; - tiled.height = src.height + pad; - const tctx = context2d(tiled); - const out = document.createElement('canvas'); - out.width = src.width; - out.height = src.height; - const ctx = context2d(out); - if (!tctx || !ctx) return src; - for (let k = 0; k < 4; k += 1) { - tctx.drawImage(src, 0, 0, period, src.height, k * period, 0, period, src.height); - tctx.drawImage(src, 0, src.height - 1, period, 1, k * period, src.height, period, pad); - } - ctx.filter = `blur(${px}px)`; - ctx.drawImage(tiled, -period, 0); - ctx.filter = 'none'; - return out; +/** A chunk's bake in slices: `step(budgetMs)` returns true once the chunk is on its canvas. */ +interface Job { + step(budgetMs: number): boolean; } -/** One ridge layer, painted once: a filled silhouette two periods wide on a plain 2D canvas. */ -function paintLayer( +/** + * One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`, in three phases of + * column bands, each band sized to the task budget by measuring the last one: bake the pixels + * (`bakeSlice`), put them on a canvas with the in-scatter, and (where the 2D `filter` exists) blur them + * into `el`. The blur needs neighbours, so the pixels are baked `pad` columns past both ends and + * the bottom into a scratch canvas this job owns, and each blurred band reads `pad` columns either side of + * itself: seams show no fade. The scratch is made when the job first runs and dropped when it ends. + */ +function chunkJob( + el: HTMLCanvasElement, + makeCanvas: () => HTMLCanvasElement, spec: LayerSpec, sky: SkyColours, - size: { period: number; width: number; height: number }, + size: Size, viewH: number, - scale: number -): HTMLCanvasElement | null { - const el = document.createElement('canvas'); - el.width = size.width; - el.height = size.height; + scale: number, + m: number, + record: (ms: number, maxTaskMs: number) => void +): Job | null { const ctx = context2d(el); if (!ctx) return null; - const top: number[] = []; - for (let x = 0; x < size.period; x += 1) - top.push(ridgeY(spec, x, size.period, size.height, viewH)); - const c = layerColour(spec, sky); - const g = ctx.createLinearGradient(0, 0, 0, size.height); - g.addColorStop(0, css(c)); - g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth)))); - ctx.fillStyle = g; - ctx.beginPath(); - ctx.moveTo(0, size.height); - for (let x = 0; x <= size.width; x += 1) ctx.lineTo(x, top[x % size.period]); - ctx.lineTo(size.width, size.height); - ctx.closePath(); - ctx.fill(); - scatter(ctx, spec, sky, size); - return soften(el, edgeBlur(spec) * scale, size.period); + const blur = edgeBlur(spec) * scale; + const pad = blur > 0 && 'filter' in ctx ? Math.ceil(3 * blur) : 0; + const cols = size.chunk + 2 * pad; + const rows = size.height + pad; + const params: BakeParams = { + spec, + sky, + period: size.period, + scale, + viewH, + rows, + bandH: size.height, + px0: m * size.chunk - pad, + cols, + }; + const paint = layerPaint(spec, sky); + const t0World = params.px0 / size.period; + let scratch: HTMLCanvasElement | undefined; + let sctx: CanvasRenderingContext2D | null = ctx; + let img: ImageData | undefined; + /** 0 bake, 1 put and in-scatter, 2 blur into el; a chunk with no blur ends after 1. */ + let phase = 0; + let pos = 0; + let sliceCols = 32; + let total = 0; + let longest = 0; + const last = pad > 0 ? 2 : 1; + const start = (): boolean => { + if (pad > 0) { + scratch = makeCanvas(); + scratch.width = cols; + scratch.height = rows; + sctx = context2d(scratch); + } else { + el.width = size.chunk; + el.height = size.height; + } + if (!sctx) return false; + img = sctx.createImageData(cols, rows); + return true; + }; + const work = (x: number, n: number) => { + if (!img || !sctx) return; + if (phase === 0) bakeSlice(img.data, params, paint, x, x + n); + else if (phase === 1) { + sctx.putImageData(img, 0, 0, x, 0, n, rows); + scatter(sctx, spec, sky, cols, rows, t0World, 1 / size.period, x, n); + } else { + // One draw: a blurred draw cut into bands costs more in the margins than it saves in task length. + ctx.drawImage(scratch as HTMLCanvasElement, -pad, 0); + } + }; + return { + step(budgetMs) { + const t0 = clock(); + // Allocating the canvases and the pixel buffer is a task of its own. + if (!img) { + if (!start()) return true; + if (Number.isFinite(budgetMs)) { + total += clock() - t0; + longest = Math.max(longest, clock() - t0); + return false; + } + } + while (phase <= last && clock() - t0 < budgetMs) { + const end = phase === 2 ? size.chunk : cols; + if (pos === 0 && phase === 2) { + el.width = size.chunk; + el.height = size.height; + ctx.filter = `blur(${blur}px)`; + } + const n = phase === 2 ? end : Math.min(end - pos, sliceCols); + const s0 = clock(); + work(pos, n); + pos += n; + const dt = Math.max(clock() - s0, 0.02); + sliceCols = Number.isFinite(budgetMs) + ? Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt))) + : cols; + if (pos >= end) { + if (phase === 2) ctx.filter = 'none'; + phase += 1; + pos = 0; + if (phase === 1 || phase === 2) sliceCols = Number.isFinite(budgetMs) ? 64 : cols; + } + } + const dt = clock() - t0; + total += dt; + longest = Math.max(longest, dt); + const done = phase > last; + if (done) { + img = undefined; + if (scratch) { + scratch.width = 0; + scratch.height = 0; + } + scratch = undefined; + record(total, longest); + } + return done; + }, + }; +} + +interface Chunk { + el: HTMLCanvasElement; + /** World chunk index it holds or is being baked to hold. */ + id: number; + job?: Job; + ready: boolean; +} + +/** One layer: a track that drifts by one chunk per animation, holding three chunk canvases. */ +interface LayerState { + spec: LayerSpec; + track: HTMLElement; + /** Slot 0 and 1 are what the window can see; slot 2 is the one ahead, baked in the background. */ + slots: Chunk[]; + anim?: Animation; } interface Built { - layers: HTMLCanvasElement[]; - anims: Animation[]; + rig: HTMLElement; + layers: LayerState[]; + sway?: Animation; + size: Size; + /** CSS px to canvas px, and the viewport's height in canvas px, as baked. */ + scale: number; + viewH: number; + /** Makes a canvas, bound to the document the scene was built in. */ + makeCanvas: () => HTMLCanvasElement; /** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */ twinkle?: HTMLCanvasElement; } +/** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */ +const SWAY_SECONDS = 150; +const SLICE_BUDGET_MS = 6; + /** 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; @@ -162,6 +267,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { let twRaf = 0; let twLast = 0; let still = tier === 'still'; + let jobTimer: ReturnType | undefined; + let queue: Chunk[] = []; + let generation = 0; let disposed = false; let raf = 0; let readout: HTMLElement | undefined; @@ -175,8 +283,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }; const teardownLayers = () => { - built?.anims.forEach((a) => a.cancel()); - built?.layers.forEach((l) => l.remove()); + generation += 1; + clearTimeout(jobTimer); + jobTimer = undefined; + queue = []; + built?.sway?.cancel(); + built?.layers.forEach((l) => l.anim?.cancel()); + built?.rig.remove(); built?.twinkle?.remove(); cancelAnimationFrame(twRaf); twRaf = 0; @@ -190,6 +303,109 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1; }; + /** + * Start baking chunk `c` of layer `l`. `first` runs it to the end in this task (the first view); + * otherwise it joins the queue, which `pump` works through in short tasks. False when the canvas has no 2D context. + */ + const bake = (l: LayerState, c: Chunk, first: boolean): boolean => { + if (!built) return false; + const { scale, viewH, makeCanvas } = built; + const job = chunkJob( + c.el, + makeCanvas, + l.spec, + sky, + built.size, + viewH, + scale, + c.id, + (ms, maxTaskMs) => { + c.ready = true; + c.job = undefined; + opts.onBake?.({ layer: l.spec.index, chunk: c.id, ms, maxTaskMs, first }); + } + ); + if (!job) return false; + c.ready = false; + c.job = job; + if (first) job.step(Infinity); + else queue.push(c); + return true; + }; + + const pump = () => { + if (jobTimer !== undefined || !queue.length) return; + const gen = generation; + jobTimer = setTimeout(() => { + jobTimer = undefined; + if (gen !== generation) return; + const c = queue[0]; + if (!c.job || c.job.step(SLICE_BUDGET_MS)) queue.shift(); + pump(); + }, 0); + }; + + /** Bake a still-queued chunk to the end now: only when the drift has caught up with the background work. */ + const flush = (c: Chunk) => { + while (c.job && !c.job.step(Infinity)); + queue = queue.filter((q) => q !== c); + }; + + const chunkSeconds = (spec: LayerSpec, size: Size) => (spec.seconds * size.chunk) / size.period; + + /** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */ + const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => { + if (!built) return; + const { size, scale } = built; + l.anim = l.track.animate?.( + [ + { transform: 'translate3d(0,0,0)' }, + { transform: `translate3d(${-size.chunk / scale}px,0,0)` }, + ], + { duration: chunkSeconds(l.spec, size) * 1000, easing: 'linear', fill: 'forwards' } + ); + if (l.anim) l.anim.onfinish = () => onEnd(l); + }; + + /** + * The window has drifted one chunk. Slot 1 is now slot 0 and slot 2 slot 1, at the same screen place + * (the track restarts at 0 and the chunks move one slot left in the same task, so nothing jumps); the + * canvas that left becomes slot 2 and is baked with the world one chunk further on, off the critical path. + */ + const rotate = (l: LayerState) => { + if (!built || !built.layers.includes(l)) return; + const [s0, s1, s2] = l.slots; + flush(s1); + flush(s2); + l.slots = [s1, s2, s0]; + s0.id = s2.id + 1; + const { size, scale } = built; + l.slots.forEach((c, i) => { + c.el.style.left = `${(i * size.chunk) / scale}px`; + }); + l.anim?.cancel(); + startDrift(l, rotate); + bake(l, s0, false); + pump(); + syncPlayState(); + }; + + /** The camera's slow yaw and pitch: the rig eases sideways (up to 5% of the width) and down (up to 3% of the height) and back, in a loop. */ + const startSway = (rig: HTMLElement, w: number, h: number) => { + const x = 0.05 * w; + const y = 0.03 * h; + const at = (fx: number, fy: number) => `translate3d(${-x * fx}px,${y * fy}px,0)`; + return rig.animate?.( + [ + { transform: at(0, 0), offset: 0 }, + { transform: at(0.55, 1), offset: 0.35 }, + { transform: at(1, 0.3), offset: 0.7 }, + { transform: at(0, 0), offset: 1 }, + ], + { duration: SWAY_SECONDS * 1000, iterations: Infinity, easing: 'ease-in-out' } + ); + }; + /** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */ const build = (): boolean => { teardownLayers(); @@ -200,7 +416,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { canvas.width = size.period; 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); @@ -223,39 +438,73 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { 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'); - Object.assign(el.style, { + // The rig carries the camera's sway; each layer's track carries its drift; chunks sit on the track. + const rig = document.createElement('div'); + rig.setAttribute('aria-hidden', 'true'); + Object.assign(rig.style, { + position: 'absolute', + left: '0', + bottom: '0', + width: '100%', + height: '100%', + pointerEvents: 'none', + willChange: 'transform', + }); + const layers: LayerState[] = LAYERS.map((spec) => { + const track = document.createElement('div'); + Object.assign(track.style, { position: 'absolute', left: '0', bottom: '0', - width: `${size.width / scale}px`, + width: `${size.period / scale}px`, height: `${size.height / scale}px`, - pointerEvents: 'none', willChange: 'transform', }); - layers.push(el); + const slots: Chunk[] = [0, 1, 2].map((i) => { + const el = document.createElement('canvas'); + el.width = size.chunk; + el.height = size.height; + Object.assign(el.style, { + position: 'absolute', + bottom: '0', + width: `${size.chunk / scale}px`, + height: `${size.height / scale}px`, + }); + return { el, id: i, ready: false }; + }); + return { spec, track, slots }; + }); + const doc = document; + built = { + rig, + layers, + size, + scale, + viewH: canvas.height, + makeCanvas: () => doc.createElement('canvas'), + twinkle, + }; + // First view: only slot 0 of each layer, in this task. The window sees nothing else for the first + // fifth of a chunk's drift; slots 1 and 2 are baked in short tasks behind it. + if (!layers.every((l) => bake(l, l.slots[0], true))) { + teardownLayers(); + return false; } + layers.forEach((l) => { + l.slots.forEach((c, i) => { + c.el.setAttribute('aria-hidden', 'true'); + c.el.style.left = `${(i * size.chunk) / scale}px`; + l.track.appendChild(c.el); + }); + rig.appendChild(l.track); + bake(l, l.slots[1], false); + bake(l, l.slots[2], false); + }); // DOM order is paint order: farthest first, so the nearest ridge ends up on top. - layers.forEach((l) => parent.appendChild(l)); - const anims = layers - .map((el, i) => - el.animate?.( - [ - { transform: 'translate3d(0,0,0)' }, - { transform: `translate3d(${-size.period / scale}px,0,0)` }, - ], - { duration: LAYERS[i].seconds * 1000, iterations: Infinity, easing: 'linear' } - ) - ) - .filter((a): a is Animation => !!a); - built = { layers, anims, twinkle }; + parent.appendChild(rig); + layers.forEach((l) => startDrift(l, rotate)); + built.sway = startSway(rig, w, h); + pump(); syncPlayState(); return true; }; @@ -288,7 +537,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { }; function syncPlayState() { - built?.anims.forEach((a) => (moving() ? a.play() : a.pause())); + const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : []; + anims.forEach((a) => (moving() ? a?.play() : a?.pause())); if (!built?.twinkle) return; if (moving()) { if (!twRaf) twRaf = requestAnimationFrame(twTick); @@ -305,13 +555,15 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { still = true; const ctx = context2d(canvas); if (ctx && built) { - const { anims } = built; - built.layers.forEach((l, i) => { - // Freeze where the drift is, so the swap to a still does not jump. - const a = anims[i]; - const t = Number(a?.currentTime ?? 0); - const turns = (t % (LAYERS[i].seconds * 1000)) / (LAYERS[i].seconds * 1000); - ctx.drawImage(l, -Math.round(turns * canvas.width), canvas.height - l.height); + const { size } = built; + built.layers.forEach((l) => { + // Freeze where the drift is, so the swap to a still does not jump; whatever is baked and in view. + const dur = chunkSeconds(l.spec, size) * 1000; + const p = Math.min(1, Math.max(0, Number(l.anim?.currentTime ?? 0) / dur)); + const x = -Math.round(p * size.chunk); + l.slots.slice(0, 2).forEach((c, i) => { + if (c.ready) ctx.drawImage(c.el, x + i * size.chunk, canvas.height - size.height); + }); }); } teardownLayers(); diff --git a/src/app/features/scene/ridge/noise.ts b/src/app/features/scene/ridge/noise.ts index 77af507d..20d97a4d 100644 --- a/src/app/features/scene/ridge/noise.ts +++ b/src/app/features/scene/ridge/noise.ts @@ -1,7 +1,7 @@ -// CultMath's value noise and fbm on the CPU, in one dimension and periodic. Clean-room: the -// algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not copied -// from any other terrain. Doubles instead of floats, so values differ from the GLSL in the last -// digits; nothing here has to match a shader. +// CultMath's value noise on the CPU, in one dimension, over the whole line (no period). Clean-room: +// the algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not +// copied from any other terrain. Doubles instead of floats, so values differ from the GLSL in the +// last digits; nothing here has to match a shader. const frac = (v: number): number => v - Math.floor(v); @@ -12,40 +12,14 @@ export const hash2 = (x: number, y: number): number => const smooth = (t: number): number => t * t * (3 - 2 * t); /** - * `cultmath_value_noise` along row `row` of the lattice, with the lattice wrapping every `period` - * cells: noise(x + period) === noise(x). `row` is an integer, so the vertical blend of the 2D - * original is the identity. + * `cultmath_value_noise` along row `row` of the lattice, in [0, 1). `row` is an integer, so the + * vertical blend of the 2D original is the identity. The lattice is hashed per cell, never wrapped: + * a ridge is a function of world-x, with no period for the eye to find. */ -export function valueNoisePeriodic(x: number, row: number, period: number): number { +export function valueNoise(x: number, row: number): number { const cell = Math.floor(x); const u = smooth(x - cell); - const wrap = (c: number) => ((c % period) + period) % period; - const a = hash2(wrap(cell), row); - const b = hash2(wrap(cell + 1), row); + const a = hash2(cell, row); + const b = hash2(cell + 1, row); return a + (b - a) * u; } - -export interface FbmShape { - octaves: number; - lacunarity: number; - gain: number; -} - -/** - * Fractal sum in [0, 1]. `t` runs 0..1 across one period; the first octave has `baseCells` cells - * per period and each next one `lacunarity` times more, so `baseCells * lacunarity^k` must be an - * integer for the sum to be periodic (checked by the caller's config, not here). - */ -export function fbmPeriodic(t: number, row: number, baseCells: number, s: FbmShape): number { - let sum = 0; - let norm = 0; - let amp = 1; - let cells = baseCells; - for (let k = 0; k < s.octaves; k += 1) { - sum += amp * valueNoisePeriodic(t * cells, row + k * 13, cells); - norm += amp; - amp *= s.gain; - cells *= s.lacunarity; - } - return sum / norm; -} diff --git a/src/app/features/scene/ridge/ridgeBake.test.ts b/src/app/features/scene/ridge/ridgeBake.test.ts new file mode 100644 index 00000000..7c452954 --- /dev/null +++ b/src/app/features/scene/ridge/ridgeBake.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from 'vitest'; +import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake'; +import { LACUNARITY, LAYERS, profile } from './ridgeWorld'; +import { skyColours, SkyColours } from './skyColour'; + +const SKY: SkyColours = { ...skyColours(), sunAzimuth: 0.3 }; // sun toward +x +const PERIOD = 400; +const VIEW_H = 800; +const BAND = 480; + +const params = ( + li: number, + px0: number, + cols: number, + over: Partial = {} +): BakeParams => ({ + spec: LAYERS[li], + sky: SKY, + period: PERIOD, + scale: 1, + viewH: VIEW_H, + rows: BAND, + bandH: BAND, + px0, + cols, + ...over, +}); + +function bake(p: BakeParams, slices: number[] = [p.cols]): Uint8ClampedArray { + const out = new Uint8ClampedArray(p.cols * p.rows * 4); + const paint = layerPaint(p.spec, p.sky); + let c = 0; + for (const n of slices) { + bakeSlice(out, p, paint, c, Math.min(p.cols, c + n)); + c += n; + } + return out; +} + +const lum = (d: Uint8ClampedArray, cols: number, x: number, y: number) => { + const o = (y * cols + x) * 4; + return 0.2126 * d[o] + 0.7152 * d[o + 1] + 0.0722 * d[o + 2]; +}; +/** First row of column x whose alpha is full. */ +const solidRow = (d: Uint8ClampedArray, cols: number, x: number) => { + let y = 0; + while (y < BAND && d[(y * cols + x) * 4 + 3] < 255) y += 1; + return y; +}; + +describe('the field is a function of world-x', () => { + it('is deterministic, and defined and bounded at any world-x', () => { + for (const spec of LAYERS) { + for (const t of [-40.3, 0, 0.5, 17.77, 5000.123]) { + expect(profile(spec, t)).toBe(profile(spec, t)); + expect(profile(spec, t)).toBeGreaterThanOrEqual(0); + expect(profile(spec, t)).toBeLessThanOrEqual(1); + } + } + }); + + it('has no repeat at the old period (1 viewport) or its multiples: the profile differs from itself shifted', () => { + for (const spec of LAYERS) { + for (const lag of [1, 2, 3, 0.5]) { + let diff = 0; + for (let i = 0; i < 400; i += 1) + diff += Math.abs(profile(spec, i / 100) - profile(spec, i / 100 + lag)); + expect(diff / 400).toBeGreaterThan(0.04); + } + } + }); + + it('the same world column bakes to the same pixels whichever chunk or slice paints it', () => { + for (const li of [0, 3]) { + const whole = bake(params(li, 1000, 200)); + const sliced = bake(params(li, 1000, 200), [37, 64, 1000]); + expect(Array.from(sliced)).toEqual(Array.from(whole)); + // A chunk starting 120 columns later sees the same pixels for the columns it shares. + const shifted = bake(params(li, 1120, 80)); + for (const x of [0, 17, 79]) { + for (const y of [0, 100, 300, BAND - 1]) { + const a = (y * 200 + 120 + x) * 4; + const b = (y * 80 + x) * 4; + expect(Array.from(shifted.slice(b, b + 4))).toEqual(Array.from(whole.slice(a, a + 4))); + } + } + } + }); + + it('is continuous across a chunk seam: the crest steps no more at the seam than elsewhere', () => { + const chunk = Math.round(PERIOD * 1.2); + for (const li of [0, 1, 2, 3]) { + const p = params(li, chunk - 40, 80); + const d = bake(p); + let worst = 0; + for (let x = 1; x < 80; x += 1) + worst = Math.max(worst, Math.abs(solidRow(d, 80, x) - solidRow(d, 80, x - 1))); + const seam = Math.abs(solidRow(d, 80, 40) - solidRow(d, 80, 39)); + expect(seam).toBeLessThanOrEqual(Math.max(worst, 1)); + // and the seam columns equal a chunk baked from the other side + const other = bake(params(li, chunk - 40, 80, { cols: 80 })); + expect(Array.from(other)).toEqual(Array.from(d)); + } + }); +}); + +describe('shading from the slope normal', () => { + const sun = sunDirection(SKY); + + it('the sun is toward +x for azimuth 0.3', () => { + expect(sun[0]).toBeGreaterThan(0.5); + }); + + it('a face looking at the sun is lit more than one looking away', () => { + // slope < 0: the crest falls toward +x, so the face looks at +x, at the sun. + for (const s of [0.2, 0.6, 1.5]) expect(faceLight(-s, sun)).toBeGreaterThan(faceLight(s, sun)); + expect(faceLight(1.5, sun)).toBeLessThan(0.2); + expect(faceLight(-1.5, sun)).toBeGreaterThan(0.6); + }); + + it('in the baked pixels the lit side is brighter than the shadow side', () => { + for (const li of [0, 1, 2]) { + const cols = 900; + const p = params(li, 0, cols); + const d = bake(p); + const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2; // rows up per column + const litX: number[] = []; + const shadeX: number[] = []; + for (let x = 2; x < cols - 2; x += 1) { + if (slope(x) < -0.8) litX.push(x); + if (slope(x) > 0.8) shadeX.push(x); + } + expect(litX.length).toBeGreaterThan(5); + expect(shadeX.length).toBeGreaterThan(5); + const mean = (xs: number[]) => + xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) / xs.length; + expect(mean(litX)).toBeGreaterThan(mean(shadeX)); + } + }); + + it('a lit crest rim: the crest pixels are brighter than the face a little below, on both sides', () => { + for (const li of [0, 1, 2, 3]) { + const cols = 600; + const d = bake(params(li, 300, cols)); + let rimmed = 0; + let n = 0; + for (let x = 3; x < cols - 3; x += 1) { + const y = solidRow(d, cols, x); + if (y + 40 >= BAND) continue; + n += 1; + if (lum(d, cols, x, y + 1) > lum(d, cols, x, y + 30)) rimmed += 1; + } + expect(n).toBeGreaterThan(100); + expect(rimmed / n).toBeGreaterThan(0.8); + } + }); + + it('the foot of the face is hazier than its top: it moves toward the haze colour', () => { + const cols = 300; + const p = params(0, 0, cols); + const d = bake(p); + const haze = layerPaint(p.spec, p.sky).haze; + const dist = (x: number, y: number) => { + const o = (y * cols + x) * 4; + return Math.hypot(d[o] - haze[0], d[o + 1] - haze[1], d[o + 2] - haze[2]); + }; + let nearer = 0; + let n = 0; + for (let x = 0; x < cols; x += 1) { + const y = solidRow(d, cols, x); + if (y + 200 >= BAND) continue; + n += 1; + if (dist(x, y + 200) < dist(x, y + 20)) nearer += 1; + } + expect(n).toBeGreaterThan(50); + expect(nearer / n).toBeGreaterThan(0.9); + }); + + it('erosion streaks run down the face: brightness varies across columns at one depth, and not without them', () => { + const cols = 500; + const spread = (gully: number) => { + const d = bake(params(2, 0, cols, { spec: { ...LAYERS[2], gully } })); + // The same depth below the crest in every column, in the lit-independent part (gullies darken, lit is column-wise). + const v: number[] = []; + for (let x = 0; x < cols; x += 1) { + const y = solidRow(d, cols, x); + if (y + 90 < BAND) v.push(lum(d, cols, x, y + 80) / Math.max(1, lum(d, cols, x, y + 8))); + } + const m = v.reduce((a, b) => a + b, 0) / v.length; + return v.reduce((a, b) => a + (b - m) ** 2, 0) / v.length; + }; + expect(spread(LAYERS[2].gully)).toBeGreaterThan(spread(0) * 1.2); + }); +}); + +describe('bake cost', () => { + it('is recorded, not asserted: one chunk of every layer at 1280 x 720', () => { + const period = 1280; + const rows = Math.round(720 * 0.6); + const ms = LAYERS.map((spec) => { + const p: BakeParams = { + spec, + sky: SKY, + period, + scale: 1, + viewH: 720, + rows, + bandH: rows, + px0: 0, + cols: Math.round(period * 1.2), + }; + const t0 = performance.now(); + bake(p); + return performance.now() - t0; + }); + expect(ms.every((v) => Number.isFinite(v) && v >= 0)).toBe(true); + console.info( + `ridge bake ms per layer (node, 1 chunk, 1280x720): ${ms.map((v) => v.toFixed(1)).join(' ')}` + ); + }); +}); + +describe('the ridged multifractal core, each rule on its own', () => { + const one = { ...LAYERS[0], octaves: 1, warp: 0, crag: 1 }; + + it('folds each octave at its crest: a creased, sharp ridge line, not a smooth swell', () => { + // Largest second difference over a fine scan. A smooth curve of this size stays in the hundreds; + // a fold leaves a crease where the noise crosses its midpoint. + const h = 1e-3; + let worst = 0; + for (let i = 1; i < 20000; i += 1) { + const t = i * h; + worst = Math.max( + worst, + Math.abs(profile(one, t + h) - 2 * profile(one, t) + profile(one, t - h)) / (h * h) + ); + } + expect(worst).toBeGreaterThan(2000); + }); + + it('domain-warps: the same layer without its warp is a measurably different line', () => { + const spec = LAYERS[1]; + let diff = 0; + for (let i = 0; i < 2000; i += 1) + diff += Math.abs(profile(spec, i / 100) - profile({ ...spec, warp: 0 }, i / 100)); + expect(diff / 2000).toBeGreaterThan(0.02); + }); + + it('weights each octave by the previous one: where the first octave is low, the later ones add almost nothing', () => { + const lo = { ...LAYERS[1], octaves: 1, warp: 0 }; + const hi = { ...LAYERS[1], octaves: 5, warp: 0 }; + let valley = 0; + let nv = 0; + let peak = 0; + let np = 0; + for (let i = 0; i < 10000; i += 1) { + const a = profile(lo, i / 500); + const d = Math.abs(profile(hi, i / 500) - a); + if (a < 0.12) { + valley += d; + nv += 1; + } else if (a > 0.35) { + peak += d; + np += 1; + } + } + expect(nv).toBeGreaterThan(200); + expect(np).toBeGreaterThan(200); + expect(valley / nv).toBeLessThan(0.25 * (peak / np)); + }); + + it('octave lattices do not line up: the lacunarity is not a power of two', () => { + expect(Number.isInteger(Math.log2(LACUNARITY))).toBe(false); + expect(Number.isInteger(LACUNARITY)).toBe(false); + }); + + it('distance flattens relief: contrast falls from the nearest layer to the farthest', () => { + const c = LAYERS.map((spec) => layerPaint(spec, SKY).contrast); + expect(c[LAYERS.length - 1]).toBe(1); + for (let i = 1; i < c.length; i += 1) expect(c[i]).toBeGreaterThan(c[i - 1]); + // and it shows in the pixels: the lit/shadow difference is smaller on the farthest layer than the nearest + const gap = (li: number) => { + const cols = 700; + const d = bake(params(li, 0, cols)); + const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2; + const avg = (pick: (s: number) => boolean) => { + const xs = Array.from({ length: cols - 4 }, (_, k) => k + 2).filter((x) => pick(slope(x))); + return ( + xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) / + Math.max(1, xs.length) + ); + }; + return ( + Math.abs(avg((s) => s < -0.8) - avg((s) => s > 0.8)) / + Math.max( + 1, + avg(() => true) + ) + ); + }; + expect(gap(0)).toBeLessThan(gap(3)); + }); +}); diff --git a/src/app/features/scene/ridge/ridgeBake.ts b/src/app/features/scene/ridge/ridgeBake.ts new file mode 100644 index 00000000..3d47526c --- /dev/null +++ b/src/app/features/scene/ridge/ridgeBake.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-param-reassign -- `bakeSlice` fills the caller's pixel buffer */ +// The pixels of a ridge chunk, as a pure function: no DOM, no canvas. Each column is lit from the +// slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze +// gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast +// by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone. +import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld'; +import type { SkyColours, Srgb } from './skyColour'; + +export type Vec3 = readonly [number, number, number]; + +/** Unit vector toward the sun: x across the screen (from the azimuth), y up, z toward the viewer. */ +export function sunDirection(sky: SkyColours): Vec3 { + const az = sky.sunAzimuth ?? 0.3; + const x = 0.9 * Math.sin(2 * Math.PI * az); + const l = Math.hypot(x, 0.35, 0.45); + return [x / l, 0.35 / l, 0.45 / l]; +} + +/** How steeply the crest's slope tilts the face's normal sideways. */ +const TILT = 1.2; + +/** + * Sun light on the face under a crest whose silhouette has `slope` (rows per column, positive when the + * crest rises toward +x, so the face looks toward -x). 0 in full shadow .. 1 head-on to the sun. + */ +export function faceLight(slope: number, sun: Vec3): number { + const nx = -slope * TILT; + const inv = 1 / Math.hypot(nx, 0.55, 0.85); + const d = (nx * sun[0] + 0.55 * sun[1] + 0.85 * sun[2]) * inv; + return Math.min(1, Math.max(0, (d - 0.2) / 0.7)); +} + +export interface BakeParams { + spec: LayerSpec; + sky: SkyColours; + /** Pixels in one viewport width: the unit of world-x. */ + period: number; + /** CSS px to canvas px. */ + scale: number; + /** Viewport height in canvas px. */ + viewH: number; + /** Rows of the pixel buffer (the band plus any padding below it). */ + rows: number; + /** Row count of the visible band; the crest is measured from the top of it. */ + bandH: number; + /** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */ + px0: number; + /** Columns of the buffer. */ + cols: number; +} + +const clamp01 = (v: number): number => Math.min(1, Math.max(0, v)); +const smoothstep = (a: number, b: number, v: number): number => { + const t = clamp01((v - a) / (b - a)); + return t * t * (3 - 2 * t); +}; +const SUN_WHITE: Srgb = [255, 238, 205]; + +/** Everything `bakeSlice` needs per layer, computed once. */ +export interface LayerPaint { + base: Srgb; + haze: Srgb; + warm: Srgb; + contrast: number; + sun: Vec3; +} + +export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint { + const warm: Srgb = [ + (sky.horizon[0] + SUN_WHITE[0]) / 2, + (sky.horizon[1] + SUN_WHITE[1]) / 2, + (sky.horizon[2] + SUN_WHITE[2]) / 2, + ]; + return { + base: layerColour(spec, sky), + haze: hazeColour(sky), + warm, + // Distance flattens relief: the same transmittance that thins the colour. + contrast: 0.35 + 0.65 * transmittance(spec), + sun: sunDirection(sky), + }; +} + +/** Crest row (float, from the top of the band) of every buffer column from c0 - 1 to c1 (inclusive). */ +function crestRows(p: BakeParams, c0: number, c1: number): Float64Array { + const out = new Float64Array(c1 - c0 + 2); + const amp = p.spec.relief * p.viewH; + const lift = p.spec.base * p.viewH; + for (let c = c0 - 1; c <= c1; c += 1) { + const top = Math.min(p.bandH, lift + amp * profile(p.spec, (p.px0 + c) / p.period)); + out[c - c0 + 1] = p.bandH - top; + } + return out; +} + +/** + * Paint columns [c0, c1) of the chunk into `out` (RGBA, `p.cols` wide, `p.rows` tall, zeroed). One + * call is a slice of a chunk, so a caller can keep each task short. Seams are exact: a column's pixels + * depend only on its world pixel, never on where the slice or chunk begins. + */ +export function bakeSlice( + out: Uint8ClampedArray, + p: BakeParams, + paint: LayerPaint, + c0: number, + c1: number +): void { + const { spec } = p; + const crest = crestRows(p, c0, c1); + const faceH = 0.45 * p.viewH; + const rim = 2.2 * p.scale; + const { base, haze, warm, contrast, sun } = paint; + for (let c = c0; c < c1; c += 1) { + const cy = crest[c - c0 + 1]; + const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column + const lit = faceLight(slope, sun); + const steep = 0.45 + 0.55 * clamp01(Math.abs(slope) * 0.8); + const gl = gullyAt(spec, (p.px0 + c) / p.scale) * steep; + const y0 = Math.max(0, Math.floor(cy)); + for (let y = y0; y < p.rows; y += 1) { + const d = y + 0.5 - cy; + const dn = clamp01(d / faceH); + const cover = clamp01(y + 1 - cy); + const gully = spec.gully * gl * smoothstep(0, 0.2, dn); + // Shade the ground colour by the sun; the lit warm light fades down the face and in the gullies. + const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully); + const warmK = 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully); + const rimK = clamp01(1 - d / rim) * contrast * (0.3 + 0.7 * lit) * 0.6; + const mist = spec.mist * smoothstep(0, 1, dn); + const o = (y * p.cols + c) * 4; + for (let i = 0; i < 3; i += 1) { + let v = base[i] * shade + warm[i] * (warmK + rimK); + v += (haze[i] - v) * mist; + out[o + i] = v; + } + out[o + 3] = cover * 255; + } + } +} diff --git a/src/app/features/scene/ridge/ridgeWorld.ts b/src/app/features/scene/ridge/ridgeWorld.ts index 048d1b50..569b43bd 100644 --- a/src/app/features/scene/ridge/ridgeWorld.ts +++ b/src/app/features/scene/ridge/ridgeWorld.ts @@ -1,39 +1,137 @@ // Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM. -import { fbmPeriodic, FbmShape } from './noise'; +// A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped, +// one function for every layer with the parameters below. Nothing in it repeats. +import { valueNoise } from './noise'; import type { Srgb, SkyColours } from './skyColour'; export interface LayerSpec { /** 0 farthest. Also the noise row, so layers never share a profile. */ index: number; - /** Lattice cells across one period for the first octave; the fbm octaves scale it by `lacunarity`. */ + /** Lattice cells per viewport width for the first octave; each octave multiplies it by LACUNARITY. */ baseCells: number; - /** Peak-to-valley height of the silhouette, as a fraction of the layer band. */ + /** Peak-to-valley height of the silhouette, as a fraction of the viewport height. */ relief: number; - /** 0 hills (plain fbm) .. 1 crags (ridged: 1 - |2n - 1|). */ + /** 0 rounded hills .. 1 sharp crags: how tightly each octave is folded at its crest. */ crag: number; /** Ridge top at its lowest point, fraction of the viewport height up from the bottom. */ base: number; /** 0 = the layer is the horizon haze, 1 = the nearest ground colour. */ depth: number; - /** Seconds for the layer to drift one period. Farther layers are slower. */ + /** Seconds for the layer to drift one viewport width. Farther layers are slower. */ seconds: number; + /** Octaves of the ridged multifractal; the bake cost is linear in it. */ + octaves: number; + /** Domain warp, in cells of the first octave. */ + warp: number; + /** 0..1 depth of the erosion streaks down the face. */ + gully: number; + /** Width of one gully, CSS px. */ + gullyPx: number; + /** 0..1 how far the foot of the face dissolves into the valley mist. */ + mist: number; } -/** Four layers. `lacunarity` 2 with these base cell counts keeps every octave an integer. */ +/** Four layers: one function, four parameter rows. */ export const LAYERS: readonly LayerSpec[] = [ - { index: 0, baseCells: 3, relief: 0.24, crag: 0.8, base: 0.34, depth: 0.12, seconds: 420 }, - { index: 1, baseCells: 4, relief: 0.2, crag: 0.55, base: 0.26, depth: 0.38, seconds: 300 }, - { index: 2, baseCells: 5, relief: 0.16, crag: 0.3, base: 0.17, depth: 0.66, seconds: 210 }, - { index: 3, baseCells: 6, relief: 0.12, crag: 0.1, base: 0.08, depth: 1, seconds: 150 }, + { + index: 0, + baseCells: 2.2, + relief: 0.24, + crag: 0.85, + base: 0.34, + depth: 0.12, + seconds: 420, + octaves: 5, + warp: 0.6, + gully: 0.3, + gullyPx: 16, + mist: 0.35, + }, + { + index: 1, + baseCells: 3.0, + relief: 0.2, + crag: 0.7, + base: 0.26, + depth: 0.38, + seconds: 300, + octaves: 5, + warp: 0.55, + gully: 0.5, + gullyPx: 20, + mist: 0.3, + }, + { + index: 2, + baseCells: 3.8, + relief: 0.16, + crag: 0.5, + base: 0.17, + depth: 0.66, + seconds: 210, + octaves: 5, + warp: 0.5, + gully: 0.6, + gullyPx: 24, + mist: 0.22, + }, + { + index: 3, + baseCells: 4.6, + relief: 0.12, + crag: 0.3, + base: 0.08, + depth: 1, + seconds: 150, + octaves: 4, + warp: 0.45, + gully: 0.7, + gullyPx: 30, + mist: 0.16, + }, ]; -export const FBM: FbmShape = { octaves: 5, lacunarity: 2, gain: 0.5 }; +/** Not an integer power of two, so no octave's lattice lines up with another's. */ +export const LACUNARITY = 2.07; +/** Each octave is weighted by the previous octave's folded signal, times this, clamped to 1. */ +export const WEIGHT_GAIN = 2; +const AMP_GAIN = 0.5; +/** Calibration: the weighted sum's typical top is about 1 / this. */ +const PEAK_NORM = 1.25; -/** Silhouette height in [0, 1] at `t` (0..1 across one period); periodic in t. */ +/** Silhouette height in [0, 1] at world-x `t` (viewport widths); defined for every real t. */ export function profile(spec: LayerSpec, t: number): number { - const n = fbmPeriodic(t, spec.index * 101, spec.baseCells, FBM); - const crag = 1 - Math.abs(2 * n - 1); - return n + (crag - n) * spec.crag; + const row = spec.index * 101; + const wt = + t + (spec.warp * (2 * valueNoise(t * spec.baseCells * 0.7, row + 97) - 1)) / spec.baseCells; + const e = 0.03 + (1 - spec.crag) * 0.4; + const top = Math.sqrt(1 + e * e); + let sum = 0; + let norm = 0; + let amp = 1; + let f = spec.baseCells; + let w = 1; + for (let k = 0; k < spec.octaves; k += 1) { + const v = 2 * valueNoise(wt * f, row + k * 13) - 1; + // Fold: 1 at the zero crossing of the noise, 0 at its extremes; `e` rounds the crease. + let sig = 1 - Math.sqrt(v * v + e * e) / top; + sig *= sig; + sig *= w; + w = Math.min(1, sig * WEIGHT_GAIN); + sum += sig * amp; + norm += amp; + amp *= AMP_GAIN; + f *= LACUNARITY; + } + // A soft swell under the folds, so the valleys between sharp crests are not flat floors. + const swell = valueNoise(wt * spec.baseCells * 0.6, row + 211); + return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell); +} + +/** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */ +export function gullyAt(spec: LayerSpec, wxCss: number): number { + const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1); + return g * g; } /** The distance haze the far layers dissolve into: between the mid sky and the horizon, not the horizon glow itself. */ @@ -75,32 +173,32 @@ export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb { export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec)); /** - * Alpha of the warm sun-side in-scatter at `t` (0..1 across one period; periodic). Zero when the sky has - * no sun; grows with distance, peaks at the sun's azimuth. + * Alpha of the warm sun-side in-scatter at world-x `t` (viewport widths). Zero when the sky has no + * sun; grows with distance. The lobe is the product of two cosines with incommensurate wavelengths + * (1.9 and 1.9 * golden ratio), so it is not periodic; its peaks are where the sun's azimuth puts them. */ export function scatterAlpha(spec: LayerSpec, sky: SkyColours, t: number): number { if (sky.sunAzimuth === undefined) return 0; - const lobe = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t - sky.sunAzimuth)); - return AERIAL.scatter * (1 - transmittance(spec)) * lobe * lobe; + const a = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t / 1.9 - sky.sunAzimuth)); + const b = 0.75 + 0.25 * Math.cos((2 * Math.PI * t) / (1.9 * 1.618034)); + return AERIAL.scatter * (1 - transmittance(spec)) * a * a * b; } -/** Integer pixel sizes of a layer for a viewport: one period wide, twice that painted, so a drift of one period loops. */ +/** Chunk width as a multiple of the viewport width: room for the yaw sway on top of the viewport. */ +export const CHUNK_OVER = 1.2; + +/** + * Integer pixel sizes for a viewport. `period` is one viewport width, the unit of world-x; a chunk + * is `chunk` pixels of world, `height` the band a layer is painted in. + */ export function layerSize(cssW: number, cssH: number, scale: number) { const period = Math.round(cssW * scale); const band = Math.round(cssH * scale * 0.6); - return { period, width: period * 2, height: band }; + return { period, chunk: Math.round(period * CHUNK_OVER), height: band }; } -/** Ridge-top y (canvas pixels, from the top of the layer's band) for pixel column x. */ -export function ridgeY( - spec: LayerSpec, - x: number, - period: number, - bandH: number, - viewH: number -): number { - const amp = spec.relief * viewH; - const bottomOffset = spec.base * viewH; // lowest the top sits above the viewport bottom - const topFromBottom = bottomOffset + amp * profile(spec, (x % period) / period); +/** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */ +export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number): number { + const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t); return bandH - Math.min(bandH, topFromBottom); }