InkstoneAtelier · Takamatsu

The maker’s guide · 種明かし

How the water was made

Inkstone Atelier is a fiction, built to show that real-time physics can carry the feeling of craft. Everything on the front page is computed or drawn in the browser: no photographs, no video, no particle tricks. This page shows the working, with the actual code that ships.

A small fictional ink maker in Takamatsu, grinding ink sticks on stone since 1923. The hero is a live suminagashi marbling table: a real Navier-Stokes fluid simulation, velocity, pressure, and dye fields computed in WebGL2 fragment shaders, disturbed by the visitor’s cursor and touch, art-directed to read as ink in water rather than a tech demo. A “pull a print” interaction captures the current marbling as a downloadable image. Below, a paper-white broadsheet: the stone, the soot, the binding, three ink sticks, the atelier’s story.

Hard rules: palette locked to washi, carbon, and one vermilion; Shippori Mincho and Instrument Sans; fully procedural assets; smooth at 390px with touch; a composed static state under reduced motion; and two banned crutches, no particle systems and no precomputed video. The ink is a continuous simulated field or it does not ship.

1 · The solver

A classic stable-fluids loop runs every frame at two resolutions: velocity on a coarse grid, ink on a fine one. Advect velocity, breathe, confine vorticity a whisper, solve pressure with 26 Jacobi sweeps, project, then advect the two ink channels (carbon in R, vermilion in G, both RG16F half-float textures). This is the pressure sweep, verbatim from js/fluid.js:

const FS_JACOBI = `#version 300 es
precision highp float;
uniform sampler2D uPressure;
uniform sampler2D uDiv;
uniform vec2 uTexel;
in vec2 vUv;
out vec4 o;
void main(){
  float L = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
  float R = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
  float B = texture(uPressure, vUv - vec2(0.0, uTexel.y)).x;
  float T = texture(uPressure, vUv + vec2(0.0, uTexel.y)).x;
  float div = texture(uDiv, vUv).x;
  o = vec4((L + R + B + T - div) * 0.25, 0.0, 0.0, 1.0);
}`;

js/fluid.js · the Jacobi pressure pass

2 · Why the rings are rings

Suminagashi’s concentric rings come from alternating drops of ink and plain water at one point: each new drop shoulders everything older outward. A drop is an incompressible point source, and its displacement has an exact closed form, L’ = sqrt(L² + R²). The dye field is warped by the inverse map in one pass, then the solver owns every subsequent movement. Pigment drops are kept small and clear drops generous, because a fresh ring’s thickness is the pigment radius squared over twice the clear radius; the wrong ratio gives you a dartboard or nothing at all:

vec2 d = vUv - uPoint;
d.x *= uAspect;
float L = length(d);
float R2 = uR * uR;
vec2 srcUv = vUv;
if (L > uR) {
  float s = sqrt(max(L * L - R2, 0.0)) / L;
  vec2 back = d * s;
  srcUv = uPoint + vec2(back.x / uAspect, back.y);
}
vec4 field = texture(uSrc, srcUv);
float inside = 1.0 - smoothstep(uR - uFeather, uR + uFeather, L);
o = mix(field, vec4(uAdd, 0.0, 1.0), inside);

js/fluid.js · the drop operator, applied to the dye field

3 · Stillness that stays crisp

Semi-Lagrangian advection resamples the dye bilinearly every frame, so any residual current slowly blurs thin rings into fog. The fix that mattered most: when transport falls below a fraction of a texel, snap it to zero. Still water then holds its ink sharp for minutes, and motion only costs clarity while something is actually moving.

vec2 disp = uDt * vel * uVelTexel;
/* Below a fraction of a texel, transport is only resampling blur.
   Snap it to zero so still water holds its ink crisp for minutes. */
disp *= smoothstep(0.12, 0.35, length(disp) / uSnapTexel);
o = uDiss * texture(uSrc, vUv - disp);

js/fluid.js · the sub-texel snap inside the advection shader

4 · The breath and the comb

Perfect rings read as a vinyl record. Life comes from choreography: after the morning bloom, a slow stroke of stir impulses runs along a cubic bezier and folds half the pattern into mokume. The same path generator drives the idle behavior (every third idle beat, an unseen hand combs the vat) and the reduced-motion seed, which runs the whole ritual instantly and holds the final frame.

combStroke(p0, p1, p2, p3, { duration = 2.4, startAt = 0, force = 0.010 } = {}){
  const base = this.time + startAt;
  const N = 34;
  const bez = (t) => { ... };
  for (let i = 0; i < N; i++) {
    const t = i / (N - 1);
    const [x, y] = bez(t);
    const [x2, y2] = bez(Math.min(t + 0.03, 1));
    ...
    this.queue.push({ at: base + t * duration, x, y, kind: 'stir', ... });
  }
}

js/fluid.js · a stroke scheduled as stir impulses along a bezier

5 · Pulling a print

The sheet settles over the vat, and at full contact the current field is rendered once more at print resolution into an offscreen framebuffer, read back, flipped, and composed onto a proof band with the ledger number and the house seal. The vat keeps only 12% of its ink afterward: the paper drank the rest, which is exactly how a real pull works.

this.render({ fbo, w, h });
const px = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
...
// flip rows: GL reads bottom up
for (let y = 0; y < h; y++) {
  img.data.set(px.subarray((h - 1 - y) * row, (h - y) * row), y * row);
}

js/fluid.js · capture() renders, reads back, and hands over a canvas

6 · The seal

The vermilion square in the header is not decoration. Click it and the character 墨 is rasterized to a small canvas, uploaded as a texture, and pressed into the vermilion dye channel where it dissolves like a stamp in water. Every print carries the same mark in its corner.

vec2 local = (vUv - uPoint) / uSize + 0.5;
float inside = step(0.0, local.x) * step(local.x, 1.0)
             * step(0.0, local.y) * step(local.y, 1.0);
float glyph = inside * texture(uGlyph, vec2(local.x, 1.0 - local.y)).a;
vec2 ink = texture(uSrc, vUv).xy;
ink.y += glyph * uStrength;

js/fluid.js · the stamp pass, vermilion channel only

Fully procedural. No generated media, no photographs, no stock, no video.

  • The waterLive WebGL2 simulation, computed on your machine each visit. No two visitors see the same vat.
  • Paper grainSVG feTurbulence noise, inlined as data URIs: one film over the whole page, a long-fiber variant on the print sheet.
  • The ink sticksPure CSS: layered gradients, one glyph each, radial-gradient shadows. The vermilion seal on Cinnabar Gate is a 13px square.
  • Specimen and fallbackInline SVG: concentric circles distorted by feDisplacementMap turbulence, one seed for the kept proof sheet, another for the no-WebGL hero.
  • TypeShippori Mincho and Instrument Sans via Google Fonts, the only external requests on the page.
  • PrintsMade by you, composed in a 2D canvas at pull time: marbling, proof band, ledger number, seal. Yours to keep.

Three review passes, hostile on purpose, each shot at 1440 and 390 with a headless browser before and after. The honest log:

  • Velocity-driven rings failed twice. Drops pushed by radial impulses smeared into smoke; the closed-form drop operator replaced them and rings appeared on the third try.
  • The dartboard problem. Equal ink and clear radii made a target. Small pigment drops, generous clear drops, and jitter gave the tree-ring look.
  • The gray fog problem. An ambient force integrated into a permanent 46 texel-per-second swirl that dissolved older rings; fixed by near-zero ambient amplitude, faster velocity decay, and the sub-texel advection snap.
  • A NaN ate the vat. A stale constant name (dropPush, renamed mid-build) fed NaN into a velocity uniform when the seal stamped; advection spread it and the whole field went blank in under a second. Caught by probing field statistics after every scripted interaction, not by eye.
  • Too perfect is its own failure. Crisp concentric rings read as a vinyl record; the artisan’s breath stroke was added so every bloom folds a little differently.
  • Layout honesty. The fixed header collided with copy once scrolled (washi bar added); the H1 broke into four lines at 1440 (manual break, wider column); spec lines widowed single words (wider measure); the lying ink stick read as a UI progress bar (shorter, end-cap shading, rotated seal); the leaning stick floated mid-air (now tucked against its neighbor); a 260px void sat above the footer (halved).
  • The blank print. Early pulls could capture an empty vat because diluted ink fell below the display threshold and vanished; a pale wash zone in the display shader keeps thinned ink visible, the way real sumi dilutes instead of disappearing.

Return to the water