Kiln & Cinder

Colophon · how this was built

Thrown in JavaScript, fired in a fragment shader

Every pot on the main page is rebuilt from a six-point profile the moment you pull on it. This page is the wheel taken apart on the bench: the brief, the four tricks that matter, and an honest log of the three passes it took.

The brief, as received

Site 04 of the fable-25 portfolio: Kiln & Cinder Ceramics, a fictional two-person stoneware studio. The direction was binding:

Concept
The hero is a working wheel — a lathe-generated pot you can reshape, with the mesh rebuilding live at 60 fps from draggable control points on an SVG overlay.
Palette
terracotta #b4552d cream slip #efe6d8 celadon #9db8a4 char #33251c
Type
Crimson Pro for the editorial voice, Karla for the workshop labels. Both vendored as variable latin woff2 from fontsource — no CDN, no missing weights to adapt around.
Techniques demanded
Glaze shader with procedural speckle, a banded dip line, noise-perturbed drips; three named glazes (Celadon Mist / Tenmoku / Salt White); wheel spin with real friction; a fired/unfired toggle that regrades lighting and gloss.
One thing to prove
That interactive 3D can feel like craft, not tech demo — clay-like warmth in materials and motion.

1 · The lathe that rebuilds while you hold it

The pot is one indexed BufferGeometry with fixed-size buffers: 53 profile rings × 97 radial columns. Six control points run through a Catmull-Rom spline; dragging a handle rewrites the positions, analytic normals and curvature in place — no allocation, no computeVertexNormals(), no seam artefact, every frame your hand is on it.

js/main.js — rebuild()
for (let i = 0; i < n; i++) {
  const v = al[i] / total;              // arc-length V, so speckle sits evenly
  for (let j = 0; j < COLS; j++) {
    const u = j / SEG, phi = u * Math.PI * 2;
    const s = Math.sin(phi), c = Math.cos(phi);
    const o = i * COLS + j;
    pos[o * 3] = pts[i].r * s; pos[o * 3 + 1] = pts[i].y; pos[o * 3 + 2] = pts[i].r * c;
    nor[o * 3] = nx[i] * s;    nor[o * 3 + 1] = ny[i];    nor[o * 3 + 2] = nx[i] * c;
    uvA[o * 2] = u; uvA[o * 2 + 1] = v;
    arc[o] = v; cur[o] = kap[i];        // curvature rides along as an attribute
  }
}
geo.attributes.position.needsUpdate = true;

The per-ring curvature kap is the profile’s turning rate, smoothed once. The shader turns it into glaze behaviour: convex edges break thin, concave hollows pool thick — which is what glaze actually does on a form.

glaze shader — where the form has an opinion
float pool = clamp(-vCurv * 1.4, 0.0, 1.0);   // hollows: glaze runs thick
float brk  = clamp(vCurv * 1.15, 0.0, 1.0);   // proud edges: glaze thins
brk = max(brk, smoothstep(0.962, 1.0, vArc) * 0.9);  // the rim always breaks
vec3 glaze = mix(uBodyA, uBodyB, clamp(pool * 0.9 + roll * 0.5, 0.0, 1.0));
glaze = mix(glaze, uBreak, brk * 0.8);

2 · The dip line, the drips, the fire

Pots are dipped, so the glaze stops near the foot on a wobbling line, and a few columns of it keep going — drips are hash-picked around the circumference and hang by their own random length. Everything lives in object space, so the marks turn with the pot, the way they should.

js/main.js — POT_FRAG
float wob  = (noise(vec2(ang * 22.0, uSeed * 7.0)) - 0.5) * 0.55;
float col0 = ang * 18.0 + uSeed * 31.0;
float cell = floor(col0);
float pick = hash(vec2(cell, uSeed * 13.0));
float within = 1.0 - abs(fract(col0) - 0.5) * 2.0;
float drip = step(0.78, pick) * smoothstep(0.45, 1.0, within)
           * (0.5 + 2.6 * hash(vec2(cell, 3.7)));
float cut  = uDipY + wob - drip;
float glz  = smoothstep(cut, cut + 0.22, vY);

A single uniform, uFired, is the kiln. At 0 the pot is greenware: chalky raw slip, matte, lit by soft north light. At 1 the same recipe matures — colour deepens, iron speckle burns through as round jittered spots, gloss and a warm key light arrive, and the flame side toasts. Every firing re-rolls the seed and the flame angle, because no two come out alike.

js/main.js — one uniform regrades the whole scene
vec3 keyC = mix(vec3(0.985, 0.965, 0.93),   // shed light, before
               vec3(1.06, 0.92, 0.78),      // kiln-warm key, after
               uFired);
dif = pow(dif, mix(1.5, 1.7, uFired));      // contrast firms up
float glossAmt = uGloss * (0.05 + 0.95 * uFired) * glz;
float spec = pow(clamp(dot(Np, normalize(Lk + V)), 0.0, 1.0),
                 mix(26.0, 130.0, uFired)) * glossAmt;

3 · Friction, wobble, and clay that gives

The wheel has angular velocity, not an animation. A hand on a handle spins it toward working speed; released, it loses energy to viscous drag plus a constant bearing term, then settles to a tick-over. Flick the pot itself and it carries your momentum.

js/main.js — the integrator
if (state.shaping || state.flick) {
  state.omega = lerp(state.omega, WORK_OMEGA, 1 - Math.exp(-2.6 * dt));
} else {
  state.omega *= Math.exp(-0.85 * dt);                    // viscous drag
  state.omega -= Math.sign(state.omega)
               * Math.min(Math.abs(state.omega), 0.12 * dt); // bearing friction
  if (Math.abs(state.omega) < IDLE_OMEGA) {
    state.omega = lerp(state.omega, IDLE_OMEGA, 1 - Math.exp(-0.5 * dt));
  }
}
state.rot += state.omega * dt;

Two small physical lies in the clay’s favour: re-wedging drops the lump slightly off-centre (an eccentricity that decays as it re-trues), and past about 40 rpm the greenware belly swells a few hundred microns of centrifugal give — gone when the wheel slows, and never once it’s fired.

4 · SVG handles mapped onto a 3D silhouette

The handles are SVG circles, but they live on the pot’s silhouette. Each frame the profile points are projected along the camera’s horizontal right-vector; dragging runs the same mapping backwards by raycasting onto the vertical plane the profile lives in. One basis both ways, so the dot is always exactly on the edge it edits. Handles are also keyboard sliders — arrow keys pull the wall in centimetre steps.

js/main.js — pointer → profile plane
ray.setFromCamera(ndc, camera);
camera.getWorldDirection(planeN); planeN.y = 0; planeN.normalize();
const plane = new THREE.Plane(planeN, 0);      // through the pot's axis
if (!ray.ray.intersectPlane(plane, hit)) return null;
return { r: hit.dot(rightH), y: hit.y };       // back to (radius, height) in cm

Provenance

Every pixel is computed. No photography, no stock, no generated imagery: the pot, wheel and shadows are Three.js with hand-written GLSL; the glaze tiles, maker’s marks and firing chart are inline SVG; the clay-dust grain and stamp wobble are SVG feTurbulence filters; the favicon is a data-URI SVG. Crimson Pro and Karla come from Google Fonts via the fontsource packages, subsetted to latin and vendored locally as variable woff2. Three.js r185 is served from this portfolio’s shared local /vendor/three/. The firing schedule, the studio, Rosa and Ezra are fiction written for this brief; the cone temperatures and soda-firing ritual are true to the craft. No external requests at runtime, no analytics.

Accessibility and motion: one h1, landmarks throughout, the canvas carries a described role, the handles are real keyboard sliders, and prefers-reduced-motion stills the idle spin, the kiln flash, the parallax and the reveals while leaving every control working — audited by hand against the stylesheet and the module, stated here as required.

The critique log — three passes, as they happened

Pass one render → wince → fix

  • The console was screaming. A key mismatch (gt['uBodyA'] against a table keyed A/B/BR) threw a TypeError every frame. Caught by the harness before any judging of pixels.
  • The greenware pot read as white plastic. Chalk colours carried no pigment and the wrap-lighting was flat. Repigmented the raw slips, firmed the diffuse curve, added contact occlusion so the pot sits on the wheel instead of hovering.
  • The wheel head was a chocolate pancake — oversized, muddy, prouder than the pot. Shrunk, lightened, given a slip-smear ring.
  • Rail had a dead double-bordered cell; glaze rows had a void between text and button; the ticket contradicted the copy ("a serious mug" for the default); "the pot on the right" was a lie on mobile; mobile framing left a third of the stage empty. All fixed.
  • Upgrade: throwing rings — finger ridges in the diffuse and specular normals, so the wall reads thrown, not extruded.

Pass two the rings fought back

  • The upgrade overshot: corduroy. The rings came out dense and loud enough to read as a machine texture — the exact opposite of their job. Dropped the frequency by two thirds, dropped the amplitude, wrapped them in a noise envelope so they come and go the way a hand does.
  • The speckle was square confetti. A raw cell-hash gives axis-aligned pixels; iron spots are round. Rewrote it as jittered cell centres with random radii and per-spot strength.
  • Fired celadon was mint ice-cream → deepened toward stone; the fill light was giving plastic → calmed; the head still shouted → darker, full slip ring instead of a patchy smear.
  • Upgrade: every firing is its own weather — toggling to fired re-rolls the speckle seed and flame side and lands with a brief kiln-flash in the key light.

Pass three the details that were lying

  • The crash-cool line jumped the queue. The firing curve draws with scroll, but its dashed cool-down tail was visible from the start, orphaned at the top right of the chart. Gated it to appear only when the curve reaches cone 10.
  • Verified with the harness: greenware tenmoku (raw brown slip over pale stoneware — correct and slightly ugly, which is honest), fired celadon with drips and toast, chart mid-draw, visit/footer rhythm, mobile top and bottom. Console and network clean on every shot.
  • Upgrade: centrifugal give — above working speed the greenware belly swells slightly and recovers as friction wins. Fired pots, being done moving, refuse.