scene: ridge heightfields, slope shading, endless world-x drift #81
8 changed files with 1186 additions and 269 deletions
|
|
@ -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)';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <out> ridge` builds a standalone page (`?tier=`, `?probe=1`).
|
||||
- Not yet measured on a phone: see the PR description.
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SceneOptions, 'canvas' | 'tier'> & {
|
||||
/** 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<SceneOptions, 'canvas' | 'tier'> & {
|
|||
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<typeof layerSize>;
|
||||
|
||||
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<typeof setTimeout> | 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
303
src/app/features/scene/ridge/ridgeBake.test.ts
Normal file
303
src/app/features/scene/ridge/ridgeBake.test.ts
Normal file
|
|
@ -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> = {}
|
||||
): 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));
|
||||
});
|
||||
});
|
||||
139
src/app/features/scene/ridge/ridgeBake.ts
Normal file
139
src/app/features/scene/ridge/ridgeBake.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue