By hand: vanilla HTML, CSS & JavaScript
For the curious — people who want to understand what the attractor actually is, or who enjoy seeing how far one file can go.
Yes — you can draw this by hand, with nothing but vanilla HTML, CSS and JavaScript, and that is perfectly okay. The equations are genuinely small. We’ll start with the tiniest version that still draws the real shape, then get closer and closer to what the library renders — until you can see exactly where the library earns its keep.
1. The whole attractor in one file — examples/01_Vanilla/
What you’ll have: a single HTML file that draws the real Aizawa shape — open it in a browser and it runs. No tooling at all; any static server works too.
examples/01_Vanilla/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aizawa attractor — no library</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #070a12; /* the site look's near-black blue */
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.back-link {
position: fixed;
top: 0.8rem;
left: 0.9rem;
color: #7982a9;
font:
0.8rem system-ui,
sans-serif;
text-decoration: none;
}
.back-link:hover {
color: #59d4ff;
}
</style>
</head>
<body>
<canvas id="aizawaCanvas"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<script>
const canvas = document.getElementById('aizawaCanvas');
const ctx = canvas.getContext('2d');
const fit = () => {
canvas.width = innerWidth;
canvas.height = innerHeight;
};
addEventListener('resize', fit);
fit();
// DEFAULT_PRESET from the library — named after its own `params`
// (alpha…zeta, epsilon already the effective ODE coefficient).
// DEFAULT_CONTEXT's model-time step is dt — the same attractor
// the /three renderer draws.
const alpha = 0.95;
const beta = 0.3;
const gamma = 0.65;
const delta = 3.5;
const epsilon = 0.0625;
const zeta = 0.1;
const dt = 0.0125;
// Each point integrates the ODE and strokes the segment to its
// previous screen position; hue sweeps the site look's trail
// gradient #59d4ff → #c77dff.
const points = Array.from({ length: 1200 }, () => ({
x: (Math.random() - 0.5) * 0.1,
y: (Math.random() - 0.5) * 0.1,
z: 0.1 + Math.random() * 0.1,
hue: 193 + Math.random() * 81,
}));
let angle = 0;
(function frame() {
// Translucent #070a12 fades old segments into soft trails —
// lower alpha, longer tails.
ctx.fillStyle = 'rgba(7,10,18,0.02)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
angle += 0.002;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const zoom = Math.min(canvas.width, canvas.height) * 0.6;
for (const point of points) {
// Integrate one ODE step per frame — the renderer's tick
// pacing.
const dx = (point.z - beta) * point.x - delta * point.y;
const dy = delta * point.x + (point.z - beta) * point.y;
const dz =
gamma +
alpha * point.z -
point.z ** 3 / 3 -
(point.x ** 2 + point.y ** 2) * (1 + epsilon * point.z) +
zeta * point.z * point.x ** 3;
point.x += dx * dt;
point.y += dy * dt;
point.z += dz * dt;
// Rotate around Y, then perspective-project (camera z = 3.5).
const rotX = point.x * cos - point.z * sin;
const rotZ = point.x * sin + point.z * cos;
const scale = zoom / (rotZ + 3.5);
const screenX = canvas.width / 2 + rotX * scale;
const screenY = canvas.height / 2 + point.y * scale;
// Stroke the segment from the last screen position.
if (point.lastX !== undefined) {
ctx.strokeStyle = `hsla(${point.hue}, 100%, 70%, 0.7)`;
ctx.beginPath();
ctx.moveTo(point.lastX, point.lastY);
ctx.lineTo(screenX, screenY);
ctx.stroke();
}
point.lastX = screenX;
point.lastY = screenY;
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>What just happened: six equations get stepped forward once per frame,
each point strokes a segment on a 2D canvas, and a translucent fill
fades old segments into trails. The constants are the library’s
DEFAULT_PRESET, so this is the same attractor the renderer draws —
the baseline everything else is measured against.
Next: the renderer look, still by hand.
2. The renderer look, still by hand — examples/02_CanvasLook/
What you’ll have: the same single file, now drawn the way the library draws — stored trails, glowing strokes, a moving camera.
examples/02_CanvasLook/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aizawa attractor — the renderer look, no library</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #070a12; /* the site look's near-black blue */
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.back-link {
position: fixed;
top: 0.8rem;
left: 0.9rem;
color: #7982a9;
font:
0.8rem system-ui,
sans-serif;
text-decoration: none;
}
.back-link:hover {
color: #59d4ff;
}
</style>
</head>
<body>
<canvas id="aizawaCanvas"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<script>
const canvas = document.getElementById('aizawaCanvas');
const ctx = canvas.getContext('2d');
const fit = () => {
canvas.width = innerWidth * devicePixelRatio;
canvas.height = innerHeight * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
};
addEventListener('resize', fit);
fit();
// Same constants as 01_Vanilla: DEFAULT_PRESET's params plus
// DEFAULT_CONTEXT's model-time step.
const alpha = 0.95;
const beta = 0.3;
const gamma = 0.65;
const delta = 3.5;
const epsilon = 0.0625;
const zeta = 0.1;
const dt = 0.0125;
const step = (p) => {
const dx = (p.z - beta) * p.x - delta * p.y;
const dy = delta * p.x + (p.z - beta) * p.y;
const dz =
gamma +
alpha * p.z -
p.z ** 3 / 3 -
(p.x ** 2 + p.y ** 2) * (1 + epsilon * p.z) +
zeta * p.z * p.x ** 3;
p.x += dx * dt;
p.y += dy * dt;
p.z += dz * dt;
};
// What the renderer keeps that 01 doesn't: every orbit remembers
// its last LENGTH positions (the lib's ambient shape is 97 trails
// x 1024) and the whole filament is re-stroked every frame — that
// memory, not a fading framebuffer, is the look. Each head seeds
// uniform in [-0.5, 0.5)^3 like OrbitTrail, then warms up until
// the ring is full — the lib's pre-roll.
const TRAILS = 80;
const LENGTH = 640;
const trails = Array.from({ length: TRAILS }, () => {
const p = {
x: Math.random() - 0.5,
y: Math.random() - 0.5,
z: Math.random() - 0.5,
};
const ring = new Float32Array(LENGTH * 3);
for (let i = 0; i < LENGTH; i++) {
step(p);
ring.set([p.x, p.y, p.z], i * 3);
}
// Per-trail look — the renderer's applyTrailLook: colour mix
// along the site gradient #59d4ff → #c77dff, opacity in
// [0.3, 0.7].
const mix = Math.random();
return {
p,
ring,
head: LENGTH - 1,
screen: new Float32Array(LENGTH * 2),
color: `rgb(${Math.round(89 + 110 * mix)},${Math.round(
212 - 87 * mix,
)},255)`,
alpha: 0.3 + 0.4 * Math.random(),
};
});
// Four of the lib's DEFAULT_CAMERA_SHOTS — the ambient tour, with
// durationTicks / 60 as seconds. Position and look-at target both
// blend under the tour's smoothstep.
const SHOTS = [
{
s: 19,
fp: [-1.61, 1.47, -2.63],
ft: [0.14, -0.1, 0.85],
tp: [-1.17, 1.25, -2.71],
tt: [0.15, -0.07, 0.86],
},
{
s: 25,
fp: [0.42, 2.37, 0.28],
ft: [0, 0, 0],
tp: [0.55, 2.34, 0.36],
tt: [0, 0, 0],
},
{
s: 38,
fp: [-1.58, 0.03, -1.48],
ft: [0, 0, 0],
tp: [-1.29, -0.01, -1.01],
tt: [0, 0, 0],
},
{
s: 25,
fp: [0.38, -0.24, 2.17],
ft: [0, 0, 0],
tp: [0.55, -0.15, 1.97],
tt: [0, 0, 0],
},
];
const TOUR = SHOTS.reduce((n, s) => n + s.s, 0);
const blend = (a, b, u) => a + (b - a) * u;
// The renderer simulates at a fixed 60 ticks/s whatever the
// display rate — same accumulator here.
let acc = 0;
let last = performance.now();
(function frame(now) {
acc += Math.min((now - last) / 1000, 0.1) * 60;
last = now;
while (acc >= 1) {
for (const t of trails) {
step(t.p);
t.head = (t.head + 1) % LENGTH;
t.ring.set([t.p.x, t.p.y, t.p.z], t.head * 3);
}
acc--;
}
// Camera pose for this moment of the tour.
let t = (now / 1000) % TOUR;
let shot = SHOTS[0];
for (const s of SHOTS) {
if (t < s.s) {
shot = s;
break;
}
t -= s.s;
}
const u = t / shot.s;
const e = u * u * (3 - 2 * u);
const px = blend(shot.fp[0], shot.tp[0], e);
const py = blend(shot.fp[1], shot.tp[1], e);
const pz = blend(shot.fp[2], shot.tp[2], e);
const tx = blend(shot.ft[0], shot.tt[0], e);
const ty = blend(shot.ft[1], shot.tt[1], e);
const tz = blend(shot.ft[2], shot.tt[2], e);
// Look-at basis: forward toward the target, then
// right = forward x up and true-up = right x forward.
let fx = tx - px;
let fy = ty - py;
let fz = tz - pz;
const fl = Math.hypot(fx, fy, fz);
fx /= fl;
fy /= fl;
fz /= fl;
const rl = Math.hypot(fx, fz);
const rx = -fz / rl;
const rz = fx / rl;
const ux = -rz * fy;
const uy = rz * fx - rx * fz;
const uz = rx * fy;
const cx = innerWidth / 2;
const cy = innerHeight / 2;
const focal = cy; // fov 90 like the renderer: (h/2) / tan(45°)
ctx.fillStyle = '#070a12';
ctx.fillRect(0, 0, innerWidth, innerHeight);
// 'lighter' is Canvas2D's additive blending; a wide faint pass
// under a sharp one is the poor man's bloom.
ctx.globalCompositeOperation = 'lighter';
for (const t of trails) {
// Project the ring oldest → newest; NaN marks a point behind
// the near plane so the pen lifts instead of streaking.
for (let i = 0; i < LENGTH; i++) {
const o = ((t.head + 1 + i) % LENGTH) * 3;
const dx = t.ring[o] - px;
const dy = t.ring[o + 1] - py;
const dz = t.ring[o + 2] - pz;
const z = dx * fx + dy * fy + dz * fz;
t.screen[i * 2] =
z < 0.02 ? NaN : cx + (dx * rx + dz * rz) * (focal / z);
t.screen[i * 2 + 1] =
z < 0.02 ? NaN : cy - (dx * ux + dy * uy + dz * uz) * (focal / z);
}
ctx.strokeStyle = t.color;
for (const [w, a] of [
[3, t.alpha * 0.25],
[1, t.alpha],
]) {
ctx.lineWidth = w;
ctx.globalAlpha = a;
ctx.beginPath();
let pen = false;
for (let i = 0; i < LENGTH; i++) {
const x = t.screen[i * 2];
const y = t.screen[i * 2 + 1];
if (Number.isNaN(x)) {
pen = false;
} else if (pen) {
ctx.lineTo(x, y);
} else {
ctx.moveTo(x, y);
pen = true;
}
}
ctx.stroke();
}
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
requestAnimationFrame(frame);
})(performance.now());
</script>
</body>
</html>What just happened: every orbit keeps its last positions in a ring
buffer and the whole filament is re-stroked per frame — memory, not a
fading framebuffer. Additive strokes carry the #59d4ff → #c77dff
gradient, a faint wide pass fakes the bloom, and four of the library’s
camera shots drive a look-at projection. ~140 lines more than stop 1;
still zero dependencies.
Next: as close as vanilla gets.
3. As close as vanilla gets — examples/03_WebGL/
What you’ll have: the library’s look, approximated in raw WebGL2 — plus an honest list of what a file cannot reasonably do.
examples/03_WebGL/index.html<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aizawa attractor — raw WebGL2, no library</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #070a12; /* the site look's near-black blue */
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
.back-link {
position: fixed;
top: 0.8rem;
left: 0.9rem;
color: #7982a9;
font:
0.8rem system-ui,
sans-serif;
text-decoration: none;
}
.back-link:hover {
color: #59d4ff;
}
/* The point of the stop: what a raw WebGL file does NOT get you. */
#missing {
position: fixed;
left: 0.9rem;
bottom: 0.9rem;
max-width: 22rem;
margin: 0;
padding: 0.7rem 0.9rem;
border-radius: 0.5rem;
background: rgb(10 10 18 / 0.75);
color: #9aa2c4;
font:
0.78rem/1.5 system-ui,
sans-serif;
}
#missing h1 {
margin: 0 0 0.3rem;
font-size: 0.8rem;
color: #dfe4f8;
}
#missing ul {
margin: 0;
padding-left: 1.1rem;
}
#fallback {
position: fixed;
inset: 0;
margin: 0;
display: grid;
place-items: center;
color: #cfd2e0;
text-align: center;
font:
0.9rem system-ui,
sans-serif;
}
#fallback[hidden] {
display: none;
}
#fallback img {
max-width: min(90vw, 900px);
max-height: 75vh;
}
#fallback figcaption {
position: fixed;
bottom: 2rem;
left: 50%;
translate: -50% 0;
max-width: 30rem;
}
</style>
</head>
<body>
<canvas id="aizawaCanvas"></canvas>
<a class="back-link" href="../..">← back to the tutorial</a>
<aside id="missing">
<h1>What's still missing</h1>
<ul>
<li>
bloom — <code>lineWidth</code> is clamped to 1 on most stacks, so
02_CanvasLook's wide-stroke fake does not port
</li>
<li>fog</li>
<li>context-loss recovery</li>
<li>resize edge cases</li>
<li>reduced-motion gating</li>
</ul>
</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>
const canvas = document.getElementById('aizawaCanvas');
const gl = canvas.getContext('webgl2');
if (gl === null) {
// No WebGL2 → the same poster the library pages fall back to.
canvas.hidden = true;
document.getElementById('missing').hidden = true;
document.getElementById('fallback').hidden = false;
} else {
// Same constants as 01_Vanilla: DEFAULT_PRESET's params plus
// DEFAULT_CONTEXT's model-time step — the same attractor the
// /three renderer draws.
const alpha = 0.95;
const beta = 0.3;
const gamma = 0.65;
const delta = 3.5;
const epsilon = 0.0625;
const zeta = 0.1;
const dt = 0.0125;
const step = (p) => {
const dx = (p.z - beta) * p.x - delta * p.y;
const dy = delta * p.x + (p.z - beta) * p.y;
const dz =
gamma +
alpha * p.z -
p.z ** 3 / 3 -
(p.x ** 2 + p.y ** 2) * (1 + epsilon * p.z) +
zeta * p.z * p.x ** 3;
p.x += dx * dt;
p.y += dy * dt;
p.z += dz * dt;
};
// Projection moves to the GPU: the page computes a look-at
// basis per frame, the vertex shader does the perspective
// divide (w = view depth; fov 90 → focal length 1).
const VERT = `#version 300 es
layout(location = 0) in vec3 aPos;
uniform vec3 uCamPos, uFwd, uRight, uUp;
uniform float uAspect;
void main() {
vec3 d = aPos - uCamPos;
// Near clamp: points behind the camera fly off-screen
// instead of flipping the projection — no pen-lift here.
float z = max(dot(d, uFwd), 0.02);
gl_Position = vec4(
dot(d, uRight) / uAspect, dot(d, uUp), 0.0, z);
}`;
const FRAG = `#version 300 es
precision mediump float;
uniform vec4 uColor;
out vec4 frag;
void main() { frag = uColor; }`;
const compile = (type, src) => {
const shader = gl.createShader(type);
gl.shaderSource(shader, src);
gl.compileShader(shader);
return shader;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
const uniform = (name) => gl.getUniformLocation(program, name);
const uCamPos = uniform('uCamPos');
const uFwd = uniform('uFwd');
const uRight = uniform('uRight');
const uUp = uniform('uUp');
const uAspect = uniform('uAspect');
const uColor = uniform('uColor');
// What the GPU buys over Canvas2D: the ring buffer lives in a
// VBO — one bufferSubData per tick uploads the newest point,
// and two LINE_STRIP draws walk it oldest → newest without
// re-sending the trail. Same shape as 02_CanvasLook (80×640).
const TRAILS = 80;
const LENGTH = 640;
const vertex = new Float32Array(3);
const trails = Array.from({ length: TRAILS }, () => {
const p = {
x: Math.random() - 0.5,
y: Math.random() - 0.5,
z: Math.random() - 0.5,
};
const ring = new Float32Array(LENGTH * 3);
for (let i = 0; i < LENGTH; i++) {
step(p);
ring.set([p.x, p.y, p.z], i * 3);
}
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, ring, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
// Per-trail look — the renderer's applyTrailLook: colour mix
// along the site gradient #59d4ff → #c77dff, opacity in
// [0.3, 0.7].
const mix = Math.random();
return {
p,
vao,
vbo,
head: LENGTH - 1,
color: [
(89 + 110 * mix) / 255,
(212 - 87 * mix) / 255,
1,
0.3 + 0.4 * Math.random(),
],
};
});
// The same four-shot subset of the lib's DEFAULT_CAMERA_SHOTS
// as 02_CanvasLook — the ambient tour, durationTicks / 60 as
// seconds, position and look-at target blended smoothstep.
const SHOTS = [
{
s: 19,
fp: [-1.61, 1.47, -2.63],
ft: [0.14, -0.1, 0.85],
tp: [-1.17, 1.25, -2.71],
tt: [0.15, -0.07, 0.86],
},
{
s: 25,
fp: [0.42, 2.37, 0.28],
ft: [0, 0, 0],
tp: [0.55, 2.34, 0.36],
tt: [0, 0, 0],
},
{
s: 38,
fp: [-1.58, 0.03, -1.48],
ft: [0, 0, 0],
tp: [-1.29, -0.01, -1.01],
tt: [0, 0, 0],
},
{
s: 25,
fp: [0.38, -0.24, 2.17],
ft: [0, 0, 0],
tp: [0.55, -0.15, 1.97],
tt: [0, 0, 0],
},
];
const TOUR = SHOTS.reduce((n, s) => n + s.s, 0);
const blend = (a, b, u) => a + (b - a) * u;
const fit = () => {
canvas.width = innerWidth * devicePixelRatio;
canvas.height = innerHeight * devicePixelRatio;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uAspect, innerWidth / innerHeight);
};
addEventListener('resize', fit);
fit();
// 'lighter' on the GPU — additive blending replaces the
// composite-op trick.
gl.clearColor(7 / 255, 10 / 255, 18 / 255, 1);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
// The renderer simulates at a fixed 60 ticks/s whatever the
// display rate — same accumulator as 02_CanvasLook.
let acc = 0;
let last = performance.now();
(function frame(now) {
acc += Math.min((now - last) / 1000, 0.1) * 60;
last = now;
while (acc >= 1) {
for (const t of trails) {
step(t.p);
t.head = (t.head + 1) % LENGTH;
vertex.set([t.p.x, t.p.y, t.p.z]);
gl.bindBuffer(gl.ARRAY_BUFFER, t.vbo);
gl.bufferSubData(gl.ARRAY_BUFFER, t.head * 12, vertex);
}
acc--;
}
// Camera pose for this moment of the tour — the same
// look-at basis as 02_CanvasLook, uploaded as uniforms.
let t = (now / 1000) % TOUR;
let shot = SHOTS[0];
for (const s of SHOTS) {
if (t < s.s) {
shot = s;
break;
}
t -= s.s;
}
const u = t / shot.s;
const e = u * u * (3 - 2 * u);
const px = blend(shot.fp[0], shot.tp[0], e);
const py = blend(shot.fp[1], shot.tp[1], e);
const pz = blend(shot.fp[2], shot.tp[2], e);
const tx = blend(shot.ft[0], shot.tt[0], e);
const ty = blend(shot.ft[1], shot.tt[1], e);
const tz = blend(shot.ft[2], shot.tt[2], e);
let fx = tx - px;
let fy = ty - py;
let fz = tz - pz;
const fl = Math.hypot(fx, fy, fz);
fx /= fl;
fy /= fl;
fz /= fl;
const rl = Math.hypot(fx, fz);
// right = forward × up, true-up = right × forward — the
// same basis math as 02_CanvasLook, uploaded as uniforms.
const rx = -fz / rl;
const rz = fx / rl;
gl.uniform3f(uCamPos, px, py, pz);
gl.uniform3f(uFwd, fx, fy, fz);
gl.uniform3f(uRight, rx, 0, rz);
gl.uniform3f(uUp, -rz * fy, rz * fx - rx * fz, rx * fy);
gl.clear(gl.COLOR_BUFFER_BIT);
for (const t of trails) {
gl.bindVertexArray(t.vao);
gl.uniform4fv(uColor, t.color);
// The ring's wrap becomes two strips: head+1 … LENGTH-1
// is oldest, 0 … head newest.
gl.drawArrays(gl.LINE_STRIP, t.head + 1, LENGTH - t.head - 1);
gl.drawArrays(gl.LINE_STRIP, 0, t.head + 1);
}
gl.bindVertexArray(null);
requestAnimationFrame(frame);
})(performance.now());
}
</script>
</body>
</html>What just happened: the ring buffers moved into GPU memory, line strips
draw with additive blending, and the perspective divide happens in the
vertex shader. The “what’s still missing” list on the page is the point
of the stop: bloom (lineWidth is clamped to 1 on most stacks), fog,
context-loss recovery, resize edge cases and reduced-motion gating are
all honestly hard — which is exactly why the library exists.
Next: the library, batteries included.
And that’s about as far as a file should go. Everything from here — bloom, fog, context loss, motion preferences — is plumbing the library has already solved. The next way in is one function call.