scene: ridge chunk jobs own their scratch canvas; tests for the endless drift and the ridged core
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m6s
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m6s
Soul pass fixes. The shared scratch canvas was resized when a job was created but read in a deferred task, so chunks of layers with a different blur pad got a faded right edge. Each job now makes its own scratch when it first runs and drops it when done. A chunk is baked, put and scatter-filled in column bands, the blur is one draw; allocation is a task of its own. rotate no longer needs a forward reference (startDrift takes the finish callback). Tests: the fake animation can finish, so the rotation is tested at scene level (world columns per chunk, slot repositioning, restart, recycled chunk id, one chunk of drift distance, per-job scratch width at the moment of each blur). The ridged core has direct tests for the fold, the warp, the previous-octave weighting, the lacunarity and contrast by distance. README sun statement matches sunDirection. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9391e4753f
commit
d172c4eda3
4 changed files with 325 additions and 75 deletions
|
|
@ -10,14 +10,14 @@ means "keep the CSS ground". Everything below this section describes the opt-in
|
|||
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`, columns per task sized from the last slice's time). Per frame the only motion is the
|
||||
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` from `sunAzimuth`: +x for azimuth 0.25-0.5); the face under it has a light-to-haze gradient, erosion
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRidgeScene } from './Ridge';
|
||||
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
|
||||
import { valueNoise, hash2 } from './noise';
|
||||
import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe';
|
||||
import {
|
||||
|
|
@ -217,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;
|
||||
}),
|
||||
|
|
@ -236,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',
|
||||
|
|
@ -243,12 +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() {},
|
||||
putImageData: (img: any) => puts.push(img),
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
|
|
@ -266,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';
|
||||
|
|
@ -281,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(), {
|
||||
|
|
@ -675,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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ function scatter(
|
|||
widthPx: number,
|
||||
heightPx: number,
|
||||
t0: number,
|
||||
tPerPx: number
|
||||
tPerPx: number,
|
||||
bandX = 0,
|
||||
bandW = widthPx
|
||||
) {
|
||||
if (sky.sunAzimuth === undefined || transmittance(spec) >= 1) return; // the nearest layer takes none
|
||||
const g = ctx.createLinearGradient(0, 0, widthPx, 0);
|
||||
|
|
@ -72,7 +74,7 @@ function scatter(
|
|||
}
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, widthPx, heightPx);
|
||||
ctx.fillRect(bandX, 0, bandW, heightPx);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
|
|
@ -82,14 +84,16 @@ interface Job {
|
|||
}
|
||||
|
||||
/**
|
||||
* One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`. The pixels come from
|
||||
* `bakeSlice`, a slice of columns per call, sized to the task budget by measuring the last one. A blur
|
||||
* (where the 2D `filter` exists) needs neighbours, so the pixels are baked `pad` columns past both
|
||||
* ends and bottom, then blurred once into the chunk canvas: seams show no fade.
|
||||
* 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,
|
||||
scratch: HTMLCanvasElement,
|
||||
makeCanvas: () => HTMLCanvasElement,
|
||||
spec: LayerSpec,
|
||||
sky: SkyColours,
|
||||
size: Size,
|
||||
|
|
@ -104,16 +108,6 @@ function chunkJob(
|
|||
const pad = blur > 0 && 'filter' in ctx ? Math.ceil(3 * blur) : 0;
|
||||
const cols = size.chunk + 2 * pad;
|
||||
const rows = size.height + pad;
|
||||
const sctx = pad > 0 ? context2d(scratch) : ctx;
|
||||
if (!sctx) return null;
|
||||
if (pad > 0) {
|
||||
scratch.width = cols;
|
||||
scratch.height = rows;
|
||||
} else {
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
}
|
||||
const img = sctx.createImageData(cols, rows);
|
||||
const params: BakeParams = {
|
||||
spec,
|
||||
sky,
|
||||
|
|
@ -126,62 +120,89 @@ function chunkJob(
|
|||
cols,
|
||||
};
|
||||
const paint = layerPaint(spec, sky);
|
||||
let next = 0;
|
||||
let stage = 0;
|
||||
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();
|
||||
let done = false;
|
||||
const baking = next < cols;
|
||||
const putNow = stage === 0;
|
||||
while (next < cols && clock() - t0 < budgetMs) {
|
||||
const n = Math.min(cols - next, sliceCols);
|
||||
const s0 = clock();
|
||||
bakeSlice(img.data, params, paint, next, next + n);
|
||||
next += n;
|
||||
const dt = Math.max(clock() - s0, 0.02);
|
||||
if (Number.isFinite(budgetMs))
|
||||
sliceCols = Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt)));
|
||||
else sliceCols = cols;
|
||||
}
|
||||
// Finishing is its own tasks, so no one task is longer than a slice: put and in-scatter, then the blur.
|
||||
// A caller with no budget (the first view) runs it all in this one.
|
||||
if (next >= cols && (!Number.isFinite(budgetMs) || !baking)) {
|
||||
if (stage === 0) {
|
||||
if (pad > 0) {
|
||||
sctx.putImageData(img, 0, 0);
|
||||
scatter(sctx, spec, sky, cols, rows, params.px0 / size.period, 1 / size.period);
|
||||
} else {
|
||||
ctx.putImageData(img, 0, 0);
|
||||
scatter(
|
||||
ctx,
|
||||
spec,
|
||||
sky,
|
||||
size.chunk,
|
||||
size.height,
|
||||
params.px0 / size.period,
|
||||
1 / size.period
|
||||
);
|
||||
}
|
||||
stage = 1;
|
||||
done = pad === 0;
|
||||
// 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;
|
||||
}
|
||||
if (stage === 1 && !done && (!Number.isFinite(budgetMs) || !putNow)) {
|
||||
}
|
||||
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)`;
|
||||
ctx.drawImage(scratch, -pad, 0);
|
||||
ctx.filter = 'none';
|
||||
done = true;
|
||||
}
|
||||
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);
|
||||
if (done) record(total, longest);
|
||||
const done = phase > last;
|
||||
if (done) {
|
||||
img = undefined;
|
||||
if (scratch) {
|
||||
scratch.width = 0;
|
||||
scratch.height = 0;
|
||||
}
|
||||
scratch = undefined;
|
||||
record(total, longest);
|
||||
}
|
||||
return done;
|
||||
},
|
||||
};
|
||||
|
|
@ -212,6 +233,8 @@ interface Built {
|
|||
/** 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;
|
||||
}
|
||||
|
|
@ -247,7 +270,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
let jobTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let queue: Chunk[] = [];
|
||||
let generation = 0;
|
||||
let scratch: HTMLCanvasElement | undefined;
|
||||
let disposed = false;
|
||||
let raf = 0;
|
||||
let readout: HTMLElement | undefined;
|
||||
|
|
@ -265,7 +287,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
clearTimeout(jobTimer);
|
||||
jobTimer = undefined;
|
||||
queue = [];
|
||||
scratch = undefined;
|
||||
built?.sway?.cancel();
|
||||
built?.layers.forEach((l) => l.anim?.cancel());
|
||||
built?.rig.remove();
|
||||
|
|
@ -288,10 +309,10 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
*/
|
||||
const bake = (l: LayerState, c: Chunk, first: boolean): boolean => {
|
||||
if (!built) return false;
|
||||
const { scale, viewH } = built;
|
||||
const { scale, viewH, makeCanvas } = built;
|
||||
const job = chunkJob(
|
||||
c.el,
|
||||
scratch as HTMLCanvasElement,
|
||||
makeCanvas,
|
||||
l.spec,
|
||||
sky,
|
||||
built.size,
|
||||
|
|
@ -333,7 +354,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
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) => {
|
||||
const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => {
|
||||
if (!built) return;
|
||||
const { size, scale } = built;
|
||||
l.anim = l.track.animate?.(
|
||||
|
|
@ -343,7 +364,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
],
|
||||
{ duration: chunkSeconds(l.spec, size) * 1000, easing: 'linear', fill: 'forwards' }
|
||||
);
|
||||
if (l.anim) l.anim.onfinish = () => rotate(l);
|
||||
if (l.anim) l.anim.onfinish = () => onEnd(l);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -363,7 +384,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
c.el.style.left = `${(i * size.chunk) / scale}px`;
|
||||
});
|
||||
l.anim?.cancel();
|
||||
startDrift(l);
|
||||
startDrift(l, rotate);
|
||||
bake(l, s0, false);
|
||||
pump();
|
||||
syncPlayState();
|
||||
|
|
@ -429,7 +450,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
});
|
||||
scratch = document.createElement('canvas');
|
||||
const layers: LayerState[] = LAYERS.map((spec) => {
|
||||
const track = document.createElement('div');
|
||||
Object.assign(track.style, {
|
||||
|
|
@ -442,6 +462,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
});
|
||||
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',
|
||||
|
|
@ -452,7 +474,16 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
});
|
||||
return { spec, track, slots };
|
||||
});
|
||||
built = { rig, layers, size, scale, viewH: canvas.height, twinkle };
|
||||
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))) {
|
||||
|
|
@ -471,7 +502,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
});
|
||||
// DOM order is paint order: farthest first, so the nearest ridge ends up on top.
|
||||
parent.appendChild(rig);
|
||||
layers.forEach((l) => startDrift(l));
|
||||
layers.forEach((l) => startDrift(l, rotate));
|
||||
built.sway = startSway(rig, w, h);
|
||||
pump();
|
||||
syncPlayState();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake';
|
||||
import { LAYERS, profile } from './ridgeWorld';
|
||||
import { LACUNARITY, LAYERS, profile } from './ridgeWorld';
|
||||
import { skyColours, SkyColours } from './skyColour';
|
||||
|
||||
const SKY: SkyColours = { ...skyColours(), sunAzimuth: 0.3 }; // sun toward +x
|
||||
|
|
@ -219,3 +219,85 @@ describe('bake cost', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue