Going deeper: the API surface
For people who want more than the ambient default — building their own sequences, their own rules.
For when the defaults aren’t enough. The library keeps its curated
building blocks — looks, camera shots, verified presets — in tagged
registries, and exposes the primitives to combine them. Each page here
takes one surface and shows it working; the registry pages share a
small tag-filter picker from examples/shared/.
1. Looks — examples/21_Looks/
What you’ll have: every NamedLook in the LOOKS registry, browsable
and applied two ways.
Needs: npm i three @krusenstiern/aizawa-attractor.
examples/21_Looks/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- script-src stays 'self' (contract invariant 6). style-src allows
inline because vite dev injects <style> tags; production output
is a linked stylesheet only. ws: is the vite dev HMR socket. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
/>
<meta name="color-scheme" content="dark" />
<title>Aizawa — looks</title>
</head>
<body>
<canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<p id="notice" role="status" aria-live="polite"></p>
<aside id="panel">
<h1>LOOKS</h1>
<p class="lede">
The curated look registry — every
<code>NamedLook</code> is a complete <code>LookSpec</code>.
<code>morph</code> applies it via <code>transitionToLook(look)</code>,
<code>instant</code> via <code>setLook(look.options)</code>.
</p>
<div class="controls" role="radiogroup" aria-label="Apply mode">
<label
><input type="radio" name="mode" value="morph" checked /> morph</label
>
<label
><input type="radio" name="mode" value="instant" /> instant</label
>
</div>
<h2>Looks</h2>
<div id="picker"></div>
<p class="status" id="status" role="status"></p>
</aside>
<figure id="fallback" hidden>
<img
src="../15_Ambient/poster.svg"
alt="The Aizawa attractor rendered as a static image"
/>
<figcaption>
Interactive rendering needs WebGL2, which this browser does not provide
— showing a still instead.
</figcaption>
</figure>
<script type="module" src="src/main.ts"></script>
</body>
</html>examples/21_Looks/src/main.tsimport type {
AttractorRenderer,
LookTag,
NamedLook,
} from '@krusenstiern/aizawa-attractor/three';
import {
gateMotion,
loadsRenderer,
pageChrome,
} from '../../shared/src/page.js';
import { createPicker, type Picker } from '../../shared/src/picker.js';
import { must } from '../../shared/src/dom.js';
import '../../shared/src/style.css';
// The closed LookTag vocabulary, in display order — the picker builds
// one AND-filter chip per entry.
const LOOK_TAGS: readonly LookTag[] = [
'dark',
'bright',
'warm',
'cool',
'mono',
'neon',
];
const { canvas, notice, status, showFallback } = pageChrome();
const onContextChange = (lost: boolean): void => {
notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};
function wireScene(
renderer: AttractorRenderer,
looks: readonly NamedLook[],
): void {
const morph = (): boolean =>
must(
document.querySelector<HTMLInputElement>('input[name="mode"]:checked'),
'mode radio',
).value === 'morph';
let current = looks[0]!.name;
const picker: Picker = createPicker(
must(document.querySelector<HTMLElement>('#picker'), 'div#picker'),
LOOK_TAGS,
looks,
(look) => {
// The registry payload goes to transitionToLook; setLook takes
// its `.options` — a NamedLook itself is rejected with a
// TypeError by design.
if (morph()) renderer.transitionToLook(look);
else renderer.setLook(look.options);
current = look.name;
picker.select(look.name);
},
);
picker.select(current);
// Blend state is renderer stats — poll at UI rates, never per frame.
setInterval(() => {
const stats = renderer.stats();
status.textContent =
`${current} — ${stats.lookTransitioning ? 'blending' : 'settled'}` +
` · lookBlendTicks ${String(stats.lookBlendTicks)}`;
}, 250);
}
if (!loadsRenderer()) {
showFallback();
} else {
try {
const { LOOKS, startAttractor } =
await import('@krusenstiern/aizawa-attractor/three');
const handle = startAttractor(canvas, {
onContextChange,
autoplay: false,
});
if (handle === null) {
showFallback();
} else {
gateMotion(handle, () => {
handle.renderer.renderStill();
});
wireScene(handle.renderer, LOOKS);
}
} catch {
showFallback();
}
}What just happened: picking a look applies it instantly
(setLook(look.options)) or morphing (transitionToLook(look) —
blended over lookBlendTicks); stats().lookTransitioning reports a
blend in flight.
Next: shots.
2. Shots — examples/22_Shots/
What you’ll have: every NamedShot playing on a camera tour the page
owns — grab the canvas to inspect.
Needs: npm i three @krusenstiern/aizawa-attractor.
examples/22_Shots/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- script-src stays 'self' (contract invariant 6). style-src allows
inline because vite dev injects <style> tags; production output
is a linked stylesheet only. ws: is the vite dev HMR socket. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
/>
<meta name="color-scheme" content="dark" />
<title>Aizawa — shots</title>
</head>
<body>
<canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<p id="notice" role="status" aria-live="polite"></p>
<aside id="panel">
<h1>NAMED_SHOTS</h1>
<p class="lede">
The curated camera-shot registry — each
<code>NamedShot.shot</code> plays on a <code>CameraTour</code> the page
owns (renderer created with <code>cameraTour: false</code>). Grab the
canvas to inspect; resume to rejoin the path.
</p>
<div class="controls">
<button type="button" id="tour-toggle">pause shot</button>
</div>
<h2>Shots</h2>
<div id="picker"></div>
<p class="status" id="status" role="status"></p>
</aside>
<figure id="fallback" hidden>
<img
src="../15_Ambient/poster.svg"
alt="The Aizawa attractor rendered as a static image"
/>
<figcaption>
Interactive rendering needs WebGL2, which this browser does not provide
— showing a still instead.
</figcaption>
</figure>
<script type="module" src="src/main.ts"></script>
</body>
</html>examples/22_Shots/src/main.tsimport type { NamedShot, ShotTag } from '@krusenstiern/aizawa-attractor/three';
import {
gateMotion,
loadsRenderer,
pageChrome,
} from '../../shared/src/page.js';
import { createTicker } from '../../shared/src/ticker.js';
import { createPicker } from '../../shared/src/picker.js';
import { must } from '../../shared/src/dom.js';
import '../../shared/src/style.css';
// The closed ShotTag vocabulary, in display order.
const SHOT_TAGS: readonly ShotTag[] = [
'orbit',
'flythrough',
'close',
'wide',
'hold',
'drift',
];
const { canvas, notice, status, showFallback } = pageChrome();
const onContextChange = (lost: boolean): void => {
notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};
if (!loadsRenderer()) {
showFallback();
} else {
try {
const { NAMED_SHOTS, createAttractorRenderer, createCameraTour } =
await import('@krusenstiern/aizawa-attractor/three');
// `cameraTour: false` — the tour is page-owned, so picking a shot
// just swaps the CameraTour; the renderer and scene stay.
const renderer = createAttractorRenderer(canvas, {
cameraTour: false,
resize: 'auto',
onContextChange,
});
if (renderer === null) {
showFallback();
} else {
let tour = createCameraTour(renderer.camera, renderer.controls, {
shots: [NAMED_SHOTS[0]!.shot],
loop: true,
});
// Deterministic drive — one sim tick + one camera update per
// 1/60 s slot, one present per frame.
const ticker = createTicker(
() => {
renderer.step(1);
tour.update();
},
() => {
renderer.present();
},
);
const toggle = must(
document.querySelector<HTMLButtonElement>('#tour-toggle'),
'button#tour-toggle',
);
const syncToggle = (): void => {
toggle.textContent = tour.playing ? 'pause shot' : 'resume shot';
};
toggle.addEventListener('click', () => {
if (tour.playing) tour.stop();
else tour.play();
syncToggle();
});
// Grab-to-inspect: the owned-tour twin of takeOverOnGrab — a
// grab stops the tour, OrbitControls (already enabled) drives
// until resume.
const grab = (): void => {
if (tour.playing) {
tour.stop();
syncToggle();
notice.show('Camera released — resume to rejoin the shot');
}
};
canvas.addEventListener('pointerdown', grab);
canvas.addEventListener('wheel', grab, { passive: true });
const describe = (shot: NamedShot): string =>
`${shot.name} — ${String(Math.round(shot.shot.durationTicks / 60))}s` +
' loop · drag to inspect';
const picker = createPicker(
must(document.querySelector<HTMLElement>('#picker'), 'div#picker'),
SHOT_TAGS,
NAMED_SHOTS,
(named) => {
tour = createCameraTour(renderer.camera, renderer.controls, {
shots: [named.shot],
loop: true,
});
picker.select(named.name);
syncToggle();
status.textContent = describe(named);
},
);
picker.select(NAMED_SHOTS[0]!.name);
status.textContent = describe(NAMED_SHOTS[0]!);
gateMotion(ticker, () => {
renderer.renderStill();
});
}
} catch {
showFallback();
}
}What just happened: the renderer is created with cameraTour: false,
then createCameraTour(renderer.camera, renderer.controls, …) drives
whichever shot you pick. Grabbing the canvas stops the tour — the
owned-tour twin of takeOverOnGrab.
Next: presets.
3. Presets — examples/23_Presets/
What you’ll have: every VERIFIED_PRESETS entry, browsable — morph the
attractor itself.
Needs: npm i three @krusenstiern/aizawa-attractor.
examples/23_Presets/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- script-src stays 'self' (contract invariant 6). style-src allows
inline because vite dev injects <style> tags; production output
is a linked stylesheet only. ws: is the vite dev HMR socket. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
/>
<meta name="color-scheme" content="dark" />
<title>Aizawa — presets</title>
</head>
<body>
<canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<p id="notice" role="status" aria-live="polite"></p>
<aside id="panel">
<h1>VERIFIED_PRESETS</h1>
<p class="lede">
The five verified-chaotic parameter sets, tagged by character. Picking
one fires <code>transitionToPreset(preset)</code> — a retargeting morph
over <code>transitionTicks</code>, never a hard cut.
</p>
<div class="controls">
<button type="button" id="random">random preset</button>
</div>
<h2>Presets</h2>
<div id="picker"></div>
<p class="status" id="status" role="status"></p>
</aside>
<figure id="fallback" hidden>
<img
src="../15_Ambient/poster.svg"
alt="The Aizawa attractor rendered as a static image"
/>
<figcaption>
Interactive rendering needs WebGL2, which this browser does not provide
— showing a still instead.
</figcaption>
</figure>
<script type="module" src="src/main.ts"></script>
</body>
</html>examples/23_Presets/src/main.tsimport type {
AttractorRenderer,
Preset,
PresetTag,
} from '@krusenstiern/aizawa-attractor/three';
import {
gateMotion,
loadsRenderer,
pageChrome,
} from '../../shared/src/page.js';
import { createPicker, type Picker } from '../../shared/src/picker.js';
import { must } from '../../shared/src/dom.js';
import '../../shared/src/style.css';
// The closed PresetTag vocabulary, in display order.
const PRESET_TAGS: readonly PresetTag[] = [
'calm',
'chaotic',
'sparse',
'dense',
'slow',
'fast',
];
const { canvas, notice, status, showFallback } = pageChrome();
const onContextChange = (lost: boolean): void => {
notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};
function wireScene(
renderer: AttractorRenderer,
presets: readonly Preset[],
matchPreset: (params: Preset['params']) => Preset | undefined,
): void {
// The picker sees {name, tags}; preset tags live on `meta`.
const entries = presets.map((preset) => ({
preset,
name: preset.name,
tags: preset.meta?.tags ?? [],
}));
const picker: Picker = createPicker(
must(document.querySelector<HTMLElement>('#picker'), 'div#picker'),
PRESET_TAGS,
entries,
(entry) => {
renderer.transitionToPreset(entry.preset);
picker.select(entry.name);
},
);
picker.select(presets[0]!.name);
must(
document.querySelector<HTMLButtonElement>('#random'),
'button#random',
).addEventListener('click', () => {
const picked = renderer.transitionToRandomPreset();
picker.select(picked.name);
notice.show('morphing → ' + picked.name);
});
// Preset identity and morph state are renderer stats — poll at UI
// rates. `matchPreset` names the params the sim has settled on.
setInterval(() => {
const stats = renderer.stats();
const settled = matchPreset(stats.params)?.name ?? 'custom';
const target = stats.targetPreset?.name ?? '—';
status.textContent =
`settled on ${settled} · target ${target}` +
(stats.transitioning ? ' · morphing' : ' · at rest') +
` · dead ticks ${String(stats.deadTicks)}`;
}, 250);
}
if (!loadsRenderer()) {
showFallback();
} else {
try {
const { VERIFIED_PRESETS, matchPreset, startAttractor } =
await import('@krusenstiern/aizawa-attractor/three');
const handle = startAttractor(canvas, {
onContextChange,
autoplay: false,
});
if (handle === null) {
showFallback();
} else {
gateMotion(handle, () => {
handle.renderer.renderStill();
});
wireScene(handle.renderer, VERIFIED_PRESETS, matchPreset);
}
} catch {
showFallback();
}
}What just happened: picking fires transitionToPreset(preset) — a
retargeting morph over transitionTicks, never a hard cut — or
transitionToRandomPreset for a rescue-pool pick. matchPreset names
the params the sim settles on.
Next: a run.
4. A run — examples/24_Runs/
What you’ll have: presets × shots sequenced into a Tour, played by
the normative rules — with a look per segment.
Needs: npm i three @krusenstiern/aizawa-attractor.
examples/24_Runs/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- script-src stays 'self' (contract invariant 6). style-src allows
inline because vite dev injects <style> tags; production output
is a linked stylesheet only. ws: is the vite dev HMR socket. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
/>
<meta name="color-scheme" content="dark" />
<title>Aizawa — a run</title>
</head>
<body>
<canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<p id="notice" role="status" aria-live="polite"></p>
<aside id="panel">
<h1>A run</h1>
<p class="lede">
Presets × shots sequence into a <code>Tour</code>; a page-owned look
schedule aligns one <code>NamedLook</code> per segment and fires
<code>transitionToLook</code> on every segment's first tick. Played by
the normative rules — <code>optionsForTour</code> +
<code>locateTourTick</code> + <code>step</code>/<code>present</code>.
</p>
<div class="controls">
<button type="button" id="play">play</button>
<button type="button" id="restart">restart</button>
</div>
<div class="progress"><div id="progress-bar"></div></div>
<h2>Program</h2>
<ol class="program" id="program"></ol>
<p class="status" id="status" role="status"></p>
</aside>
<figure id="fallback" hidden>
<img
src="../15_Ambient/poster.svg"
alt="The Aizawa attractor rendered as a static image"
/>
<figcaption>
Interactive rendering needs WebGL2, which this browser does not provide
— showing a still instead.
</figcaption>
</figure>
<script type="module" src="src/main.ts"></script>
</body>
</html>examples/24_Runs/src/main.tsimport type {
AttractorRenderer,
NamedLook,
Tour,
} from '@krusenstiern/aizawa-attractor/three';
import { loadsRenderer, pageChrome } from '../../shared/src/page.js';
import { createTicker } from '../../shared/src/ticker.js';
import { must } from '../../shared/src/dom.js';
import { boundaryFor } from './app/boundaries.js';
import '../../shared/src/style.css';
const { canvas, notice, status, showFallback } = pageChrome();
const onContextChange = (lost: boolean): void => {
notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};
if (!loadsRenderer()) {
showFallback();
} else {
try {
const {
DEFAULT_TOUR,
createAttractorRenderer,
findLook,
findPreset,
findShot,
locateTourTick,
optionsForTour,
tourDurationTicks,
} = await import('@krusenstiern/aizawa-attractor/three');
// The authored run: preset × shot per segment (the document), a
// page-owned look aligned to each segment (never in the document).
const picks = [
{ preset: 'personal-default', shot: 'drift', look: 'glacier' },
{ preset: 'variant-b', shot: 'core-crossing', look: 'ember' },
{ preset: 'variant-a', shot: 'overhead', look: 'aurora' },
{ preset: 'variant-c', shot: 'close-swing', look: 'paper' },
];
const tour: Tour = {
v: 1,
name: 'registry-run',
sim: { ...DEFAULT_TOUR.sim, seed: 7 },
segments: picks.map((pick) => ({
preset: findPreset(pick.preset)!,
shot: findShot(pick.shot)!.shot,
})),
};
const schedule: NamedLook[] = picks.map((pick) => findLook(pick.look)!);
const totalTicks = tourDurationTicks(tour);
const programItems = picks.map((pick, index) => {
const item = document.createElement('li');
const segment = tour.segments[index]!;
const seconds = Math.round(segment.shot.durationTicks / 60);
const name = document.createElement('span');
name.className = 'seg-name';
name.textContent = `${pick.preset} × ${pick.shot}`;
item.append(
name,
document.createTextNode(` → look ${pick.look} · ${String(seconds)}s`),
);
return item;
});
must(
document.querySelector<HTMLOListElement>('#program'),
'ol#program',
).append(...programItems);
const playButton = must(
document.querySelector<HTMLButtonElement>('#play'),
'button#play',
);
const progressBar = must(
document.querySelector<HTMLElement>('#progress-bar'),
'div#progress-bar',
);
let renderer: AttractorRenderer | null = null;
let tick = 0;
let prevIndex = -1;
let finished = false;
const markProgram = (index: number): void => {
programItems.forEach((item, i) => {
if (i === index) item.dataset.current = '';
else delete item.dataset.current;
});
const pick = picks[index]!;
status.textContent =
`segment ${String(index + 1)}/${String(picks.length)} — ` +
`${pick.preset} × ${pick.shot} · look ${pick.look}`;
};
// A run is one renderer: optionsForTour pins seed, segment-0
// params/context, sim shape and the non-looping shot sequence;
// orbit stays player-owned (grabbing is off — the camera is
// authored content here).
const fresh = (): AttractorRenderer | null => {
renderer?.dispose();
renderer = createAttractorRenderer(canvas, {
...optionsForTour(tour),
resize: 'auto',
orbit: { takeOverOnGrab: false },
onContextChange,
});
tick = 0;
prevIndex = -1;
finished = false;
programItems.forEach((item) => delete item.dataset.current);
return renderer;
};
const ticker = createTicker(
() => {
const at = locateTourTick(tour, tick);
if (at === null || renderer === null) {
ticker.stop();
finished = true;
playButton.textContent = 'play';
status.textContent = 'run finished — play restarts';
return;
}
if (at.index !== prevIndex) {
// Segment's first tick: morph params when they differ
// (rule 2), blend the scheduled look unconditionally
// (rule 5) — before advancing.
const { params, entry } = boundaryFor(tour, schedule, at.index);
prevIndex = at.index;
if (params !== null) renderer.transitionTo(params);
renderer.transitionToLook(entry);
markProgram(at.index);
}
renderer.step(1);
tick += 1;
},
() => {
renderer?.present();
progressBar.style.width = `${((tick / totalTicks) * 100).toFixed(1)}%`;
},
);
playButton.addEventListener('click', () => {
if (finished && fresh() === null) {
showFallback();
return;
}
if (ticker.running) {
ticker.stop();
playButton.textContent = 'play';
} else {
ticker.start();
playButton.textContent = 'pause';
}
});
must(
document.querySelector<HTMLButtonElement>('#restart'),
'button#restart',
).addEventListener('click', () => {
const next = fresh();
if (next === null) {
showFallback();
return;
}
next.renderStill();
ticker.stop();
playButton.textContent = 'play';
status.textContent = 'reset — press play';
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && ticker.running) {
ticker.stop();
playButton.textContent = 'play';
}
});
// Frame 0 as the page's poster — the run itself only ever plays on
// an explicit press, which doubles as the reduced-motion path.
const first = fresh();
if (first === null) {
showFallback();
} else {
first.renderStill();
status.textContent =
`4 segments · ${String(Math.round(totalTicks / 60))}s ` +
'· seed 7 — press play';
}
} catch {
showFallback();
}
}examples/24_Runs/src/app/boundaries.tsimport {
paramsEqual,
type AizawaParameters,
type Tour,
} from '@krusenstiern/aizawa-attractor/core';
/** What the player fires on a segment's first tick. */
export interface SegmentBoundary<T> {
/**
* `transitionTo` payload — non-null only on segment `i > 0` whose
* params differ structurally from segment `i−1`'s (playback rule 2).
*/
readonly params: AizawaParameters | null;
/**
* `transitionToLook` payload — fired unconditionally on every
* segment's first tick, index 0 included (playback rule 5). Generic:
* the schedule is player-owned — `NamedLook`, `LookOptions`, or a
* test double.
*/
readonly entry: T;
}
/**
* The boundary actions for `tour.segments[index]` under `schedule` —
* the run's look schedule aligns to segment index and is never part of
* the document (invariant 6).
*/
export function boundaryFor<T>(
tour: Tour,
schedule: readonly T[],
index: number,
): SegmentBoundary<T> {
const segment = tour.segments[index];
const entry = schedule[index];
if (segment === undefined || entry === undefined) {
throw new RangeError(`no segment/schedule entry at index ${index}`);
}
const previous = tour.segments[index - 1];
const params =
index > 0 &&
previous !== undefined &&
!paramsEqual(previous.preset.params, segment.preset.params)
? segment.preset.params
: null;
return { params, entry };
}What just happened: optionsForTour pins the run options,
locateTourTick maps each tick to its segment, transitionTo fires at
param boundaries, step(1) + present() advance and draw — and a
page-owned look schedule fires transitionToLook on every segment’s
first tick. The look schedule is never part of the document.
Next: a developed start.
5. A developed start — examples/25_Developed/
What you’ll have: the same ambient scene — but the first frame is
already developed, and deadTicks stays zero.
Needs: npm i three @krusenstiern/aizawa-attractor.
examples/25_Developed/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- script-src stays 'self' (contract invariant 6). style-src allows
inline because vite dev injects <style> tags; production output
is a linked stylesheet only. ws: is the vite dev HMR socket. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
/>
<meta name="color-scheme" content="dark" />
<title>Aizawa — developed</title>
</head>
<body>
<canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<p id="notice" role="status" aria-live="polite"></p>
<aside id="panel">
<h1>A developed start</h1>
<p class="lede">
Every fresh run starts collapsed — heads seed inside the dead-run
thresholds and need ~10 s of fast-forward to bloom (watch
<code>15_Ambient</code> for the signature). Raising
<code>simulation.warmupSteps</code> past that phase burns it
synchronously at construction: the first frame is already developed and
<code>deadTicks</code> never climbs.
</p>
<p class="status" id="status" role="status"></p>
</aside>
<figure id="fallback" hidden>
<img
src="../15_Ambient/poster.svg"
alt="The Aizawa attractor rendered as a static image"
/>
<figcaption>
Interactive rendering needs WebGL2, which this browser does not provide
— showing a still instead.
</figcaption>
</figure>
<script type="module" src="src/main.ts"></script>
</body>
</html>examples/25_Developed/src/main.tsimport type { AttractorRenderer } from '@krusenstiern/aizawa-attractor/three';
import {
gateMotion,
loadsRenderer,
pageChrome,
} from '../../shared/src/page.js';
import '../../shared/src/style.css';
// The default 1200-step pre-roll covers only the collapse; re-spread
// across the attractor needs ~15–20k steps on top of it (observed
// range, not a bound per seed). Warmup runs no dead-run detection —
// steps are steps — so the whole phase burns before the first tick.
const DEVELOPED_WARMUP_STEPS = 20_000;
const { canvas, notice, status, showFallback } = pageChrome();
const onContextChange = (lost: boolean): void => {
notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};
function wireStatus(renderer: AttractorRenderer): void {
setInterval(() => {
const stats = renderer.stats();
status.textContent =
`warmup ${String(DEVELOPED_WARMUP_STEPS)} pre-burned` +
` · steps ${String(stats.steps)} · ticks ${String(stats.ticks)}` +
` · dead ticks ${String(stats.deadTicks)}`;
}, 250);
}
if (!loadsRenderer()) {
showFallback();
} else {
try {
const { startAttractor } =
await import('@krusenstiern/aizawa-attractor/three');
const handle = startAttractor(canvas, {
simulation: { warmupSteps: DEVELOPED_WARMUP_STEPS },
onContextChange,
autoplay: false,
});
if (handle === null) {
showFallback();
} else {
gateMotion(handle, () => {
handle.renderer.renderStill();
});
wireStatus(handle.renderer);
}
} catch {
showFallback();
}
}What just happened: every run drains warmupSteps synchronously
before its first tick, and dead-run detection never runs inside the
pre-roll. The default 1200 covers only the collapse; 20000 lets the
same chaotic divergence the fast-forward would chase re-spread the
heads before the first presented frame. One option, one blocking
drain, a developed start — decided behaviour, not a workaround
(open-decisions.md: A). Prefer keeping the bloom?
rescue.speedupSteps compresses it; a saved state snapshot skips
it bit-exact — the library README’s “The collapsed start” section
lists every lever.
— graduation: the library README.