NEMAWASHI LAB

Riso

three.jsGLSL

A Risograph still life where nothing is lit in the usual sense: a wrapped-lambert term plus a Fresnel rim collapses to a single ink density per fragment, and that density is spent as one of two screen-space patterns. Stipple thresholds a stochastic hash (optionally clumped into value noise) against the density and layers a low-frequency blotch pass on top, so the darks clot the way real ink does; the alternative is a 45-degree dot screen whose dot radius is sqrt(density). The per-surface mode lets each fragment choose for itself -- if its normal faces the object's own uCenter, it reads as an inner or flat facet and flips to halftone, so the stump's bark stipples while its cut face screens. The subject is the oldest still life in computer graphics -- a ball, a cone, a cube and the Utah teapot (three's TeapotGeometry) -- with the washi mark extruded from the brand outline and standing on the cube as its plinth; the teapot runs in auto mode, so the inside of its spout and handle pick up the screen. Highlights speckle in a second Riso ink rather than white, four colourways swap the spot inks, and every knob on the panel -- light angle, wrap, gamma, grain size, clumping, blotch, screen pitch -- is a uniform on one shared object, so nothing rebuilds when you drag a slider.

Source

RisoCanvas.tsx482 lines
"use client";

import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader.js";
import { TeapotGeometry } from "three/examples/jsm/geometries/TeapotGeometry.js";
import DraggablePanel from "../DraggablePanel";
import { PanelSelect, PanelToggle } from "../_kansei/controls";

/* Riso — a Risograph still life shaded entirely by screen-space ink.
   Nothing here is lit in the usual sense: a wrapped-lambert term plus a rim
   is collapsed to a single density, and that density is spent as ink in one
   of two ways. Stipple thresholds a stochastic hash against the density (with
   a low-frequency blotch layer so the darks clot like real ink), the screen
   lays a 45-degree dot lattice. "Per surface" lets each fragment choose:
   a normal that faces the object's own uCenter reads as an inner/flat facet
   and flips to halftone, everything convex stays stipple. Highlights get a
   speckle of the second ink instead of white.

   The subject is the oldest still life in computer graphics — a ball, a cone,
   a cube and the Utah teapot — with the washi mark standing on the cube as if
   it were a plinth. Every knob on the panel is a uniform. */

// The brand mark, same source string as components/ChromeBird.tsx.
const SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 172.3 102.82">
<path d="M172.16,93.53L149.68,6.54c-.69-2.68-3.69-4.04-6.16-2.78l-43.8,22.22c-6.53-3.9-4.43-8.94-3.57-9.82,2.12-2.13,7.16-.41,7.16-.41-.37-6.29-3.81-13.96-13.06-15.52-9.25-1.55-13.13,4.94-14.15,15.85-.88,9.43-3.16,9.88-3.48,9.89h-.04S28.78,3.75,28.78,3.75c-2.47-1.25-5.47.1-6.16,2.78L.14,93.53c-.64,2.49,1.01,4.99,3.55,5.37l8.76,1.34c2.18.33,4.26-1.02,4.84-3.15l2.93-19.38,10.87,5.83c2.45.49,4.8-1.18,5.15-3.66l1.95-20.05,10.44,5.2c2.21.22,4.23-1.26,4.68-3.44l1.93-18.43,13.13,6.4,5.44,24.37c.34,1.51.11,3.09-.64,4.44l-11.31,20.46c-.29,2.1,1.34,3.98,3.47,3.98h41.66c2.12,0,3.75-1.87,3.47-3.98l-11.31-20.46c-.75-1.35-.97-2.93-.64-4.44l5.44-24.37,13.13-6.4,1.93,18.43c.45,2.17,2.47,3.66,4.68,3.44l10.44-5.2,1.95,20.05c.34,2.47,2.7,4.15,5.15,3.66l10.87-5.83,2.93,19.38c.58,2.13,2.66,3.48,4.84,3.15l8.76-1.34c2.54-.39,4.19-2.89,3.55-5.37ZM89.85,6.8c.76-1.48,3.36-1.86,5.83-.85,2.46,1.01,3.85,3.03,3.09,4.51-.76,1.48-3.36,1.86-5.83.85-2.46-1.01-3.85-3.03-3.09-4.51Z"/>
<circle cx="95.49" cy="8.72" r="2.07"/>
</svg>`;

// ---------------------------------------------------------------------------
// Inks — Riso spot colours on the site's cream paper
// ---------------------------------------------------------------------------

const PALETTE_NAMES = ["clay", "blue", "wine", "ink"] as const;
type PaletteName = (typeof PALETTE_NAMES)[number];

/** Each part gets [base, highlight tint]; the ink that prints the darks is uInk. */
const PALETTES: Record<PaletteName, { main: [string, string]; bird: [string, string]; accent: [string, string] }> = {
  // clay solids, moss mark + teapot — the two-ink default
  clay: {
    main: ["#c9643a", "#f0b394"],
    bird: ["#5f7a4a", "#b9c9a0"],
    accent: ["#6b7f4e", "#c2cfa6"],
  },
  // federal blue solids, sunflower mark + teapot
  blue: {
    main: ["#3d5588", "#aab7d8"],
    bird: ["#e2ae35", "#f7e0a3"],
    accent: ["#e2ae35", "#f7e0a3"],
  },
  // burgundy solids, teal mark + teapot
  wine: {
    main: ["#914e72", "#dcaac4"],
    bird: ["#00838a", "#93d1d3"],
    accent: ["#00838a", "#93d1d3"],
  },
  // single-ink print
  ink: {
    main: ["#2a2621", "#9a948a"],
    bird: ["#2a2621", "#9a948a"],
    accent: ["#2a2621", "#9a948a"],
  },
};

const PAPER_LIGHT = "#efe7d6";
const PAPER_DARK = "#17140f";
const INK = "#1a1814";

const PATTERN_NAMES = ["per surface", "stipple", "screen"] as const;
type PatternName = (typeof PATTERN_NAMES)[number];

const VIEW_NAMES = ["final", "shade", "normals", "flat"] as const;
type ViewName = (typeof VIEW_NAMES)[number];

// ---------------------------------------------------------------------------
// Shared shader uniforms (the knobs) — one set per mounted canvas
// ---------------------------------------------------------------------------

function makeUniforms() {
  return {
    uL: { value: new THREE.Vector3() },
    uInk: { value: new THREE.Color(INK) },
    uWrap: { value: 0.4 }, uRim: { value: 0.18 },
    uGamma: { value: 1.0 }, uLo: { value: 0.16 }, uHi: { value: 0.86 }, uMax: { value: 0.96 },
    uGrain: { value: 1.0 }, uClump: { value: 0.3 }, uBlot: { value: 0.55 }, uCell: { value: 4.5 }, uHl: { value: 0.5 },
    uPatOverride: { value: -1 }, uDebug: { value: 0 }, uPx: { value: 1 },
  };
}
type SharedUniforms = ReturnType<typeof makeUniforms>;

const VERT = /* glsl */`
  varying vec3 vN; varying vec3 vW;
  void main(){
    vN = normalize(mat3(modelMatrix) * normal);
    vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz;
    gl_Position = projectionMatrix * viewMatrix * w;
  }`;

const FRAG = /* glsl */`
  precision highp float;
  uniform vec3 uBase, uTint, uL, uCenter, uInk; uniform float uPattern;   // uPattern: 0 stipple, 1 halftone, 2 auto (inner walls -> halftone)
  uniform float uWrap, uRim, uGamma, uLo, uHi, uMax, uGrain, uClump, uBlot, uCell, uHl, uPatOverride, uDebug, uPx;
  varying vec3 vN; varying vec3 vW;
  float hash(vec2 p){ vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
  float vnoise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);
    return mix(mix(hash(i), hash(i+vec2(1,0)), f.x), mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y); }
  void main(){
    vec3 N = normalize(vN);
    vec3 V = normalize(cameraPosition - vW);
    float ndl = dot(N, uL);
    float shade = clamp(mix(ndl, ndl * 0.5 + 0.5, uWrap), 0.0, 1.0);
    shade += uRim * pow(1.0 - max(dot(N, V), 0.0), 3.0) * max(ndl + 0.6, 0.0);
    float v = pow(clamp(shade, 0.0, 1.0), uGamma);
    float d = 1.0 - v;
    float density = smoothstep(uLo, uHi, d) * uMax;
    vec2 px = gl_FragCoord.xy / uPx;
    float pat = uPattern;
    if (pat > 1.5) pat = dot(N, normalize(vW - uCenter)) < -0.25 ? 1.0 : 0.0;   // facing the object's own centre = inner wall
    if (uPatOverride >= 0.0) pat = uPatOverride;
    float dark;
    if (pat < 0.5) {
      float g = mix(hash(floor(px / uGrain)), vnoise(px * 0.18), uClump);       // fine stochastic stipple
      float blob = 0.55 * vnoise(px * 0.075) + 0.45 * vnoise(px * 0.15 + 3.7); // irregular ink blotches in the darks
      dark = max(step(g, density), step(1.0 - density * uBlot * 1.15, blob) * step(0.2, density));
    } else {
      float s = 0.7071; vec2 r = vec2(px.x * s - px.y * s, px.x * s + px.y * s);
      vec2 c = fract(r / uCell) - 0.5;
      dark = step(length(c), sqrt(density) * 0.62);                            // 45-degree dot screen
    }
    float hiDensity = smoothstep(0.82, 1.0, v) * uHl;
    float hi = step(1.0 - hiDensity, hash(floor(px / uGrain) + 7.3));
    vec3 col = mix(uBase, uTint, hi);
    col = mix(col, uInk, dark);
    if (uDebug > 2.5) col = uBase; else if (uDebug > 1.5) col = N * 0.5 + 0.5; else if (uDebug > 0.5) col = vec3(v);
    gl_FragColor = vec4(col, 1.0);
    #include <colorspace_fragment>
  }`;

function riso(U: SharedUniforms, pattern: number) {
  return new THREE.ShaderMaterial({
    vertexShader: VERT, fragmentShader: FRAG,
    uniforms: Object.assign({ uBase: { value: new THREE.Color() }, uTint: { value: new THREE.Color() }, uPattern: { value: pattern }, uCenter: { value: new THREE.Vector3() } }, U),
  });
}

// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------

interface Api {
  U: SharedUniforms;
  materials: THREE.ShaderMaterial[];
    controls: OrbitControls;
  camera: THREE.PerspectiveCamera;
  scene: THREE.Scene;
  bg: THREE.Color;
  setLight: (azDeg: number, elDeg: number) => void;
  setPalette: (name: PaletteName) => void;
  HOME: { pos: THREE.Vector3; tgt: THREE.Vector3 };
  resumeDrift: () => void;
}

export default function RisoCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);

  const [az, setAz] = useState(35);
  const [el, setEl] = useState(24);
  const [wrap, setWrap] = useState(0.4);
  const [rim, setRim] = useState(0.18);
  const [gamma, setGamma] = useState(1.0);
  const [lo, setLo] = useState(0.16);
  const [hi, setHi] = useState(0.86);
  const [maxInk, setMaxInk] = useState(0.96);
  const [grain, setGrain] = useState(1.0);
  const [clump, setClump] = useState(0.3);
  const [blot, setBlot] = useState(0.55);
  const [cell, setCell] = useState(4.5);
  const [hl, setHl] = useState(0.5);

  const [palette, setPalette] = useState<PaletteName>("clay");
  const [pattern, setPattern] = useState<PatternName>("per surface");
  const [view, setView] = useState<ViewName>("final");
  const [spin, setSpin] = useState(false);
  const [darkPaper, setDarkPaper] = useState(false);

  // ── scene — built once ───────────────────────────────────────────────────
  useEffect(() => {
    const canvas = canvasRef.current;
    const host = wrapRef.current;
    if (!canvas || !host) return;

    const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.NoToneMapping;

    const bg = new THREE.Color(PAPER_LIGHT);
    const scene = new THREE.Scene();
    scene.background = bg;

    const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100);
    const HOME = { pos: new THREE.Vector3(0.3, 3.2, 12.8), tgt: new THREE.Vector3(0.3, 0.55, 0) };
    camera.position.copy(HOME.pos);

    const controls = new OrbitControls(camera, canvas);
    controls.target.copy(HOME.tgt);
    controls.enableDamping = true;
    controls.dampingFactor = 0.07;
    controls.minDistance = 4;
    controls.maxDistance = 25;
    controls.autoRotateSpeed = 1.2;

    const U = makeUniforms();
    const setLight = (azDeg: number, elDeg: number) => {
      const a = azDeg * Math.PI / 180, e = elDeg * Math.PI / 180;
      U.uL.value.set(Math.sin(a) * Math.cos(e), Math.sin(e), Math.cos(a) * Math.cos(e)).normalize();
    };
    setLight(35, 24);

    const matBall = riso(U, 2);
    const matCone = riso(U, 2);
    const matCube = riso(U, 2);
    const matPot = riso(U, 2);     // auto: the inside of the spout and handle screen
    const matBird = riso(U, 1);    // flat printed plate
    const mains = [matBall, matCone, matCube];
    const materials = [...mains, matPot, matBird];
    const geometries: THREE.BufferGeometry[] = [];

    const root = new THREE.Group();
    scene.add(root);
    const put = (
      geo: THREE.BufferGeometry, mat: THREE.ShaderMaterial,
      pos: [number, number, number], rot: [number, number, number] = [0, 0, 0],
    ) => {
      geometries.push(geo);
      const m = new THREE.Mesh(geo, mat);
      m.position.set(...pos);
      m.rotation.set(...rot);
      root.add(m);
      return m;
    };

    // — the still life, all resting on y = 0: ball left, cone behind, the cube
    //   in front as a plinth, the Utah teapot right —
    const ball = put(new THREE.SphereGeometry(1.2, 96, 64), matBall, [-1.55, 1.2, 0.1]);
    const cone = put(new THREE.ConeGeometry(0.9, 3.4, 96), matCone, [-0.35, 1.7, -2.9]);
    const cube = put(new THREE.BoxGeometry(1.5, 1.1, 1.5), matCube, [0.75, 0.55, 1.15], [0, -0.35, 0]);
    const potGeo = new TeapotGeometry(0.85, 12);
    potGeo.computeBoundingBox();
    const potBox = potGeo.boundingBox!;
    const pot = put(potGeo, matPot, [2.55, -potBox.min.y, -0.7], [0, -0.55, 0]);

    // — the washi mark on the plinth —
    const paths = new SVGLoader().parse(SVG).paths;
    const shapes: THREE.Shape[] = [];
    for (const p of paths) shapes.push(...SVGLoader.createShapes(p));
    const birdGeo = new THREE.ExtrudeGeometry(shapes, {
      depth: 14, bevelEnabled: true, bevelThickness: 1.2, bevelSize: 1.2, bevelSegments: 3, curveSegments: 12,
    });
    birdGeo.rotateX(Math.PI); // SVG y points down
    birdGeo.center();
    birdGeo.computeBoundingBox();
    const birdBox = birdGeo.boundingBox!;
    const birdScale = 1.9 / (birdBox.max.x - birdBox.min.x);
    const birdH = (birdBox.max.y - birdBox.min.y) * birdScale;
    const bird = put(birdGeo, matBird, [cube.position.x, 1.1 + birdH / 2 - 0.02, cube.position.z], [0, -0.35, 0]);
    bird.scale.setScalar(birdScale);

    // Hand every auto material the centre its pattern test measures against.
    root.updateMatrixWorld(true);
    ball.getWorldPosition(matBall.uniforms.uCenter.value);
    cone.getWorldPosition(matCone.uniforms.uCenter.value);
    cube.getWorldPosition(matCube.uniforms.uCenter.value);
    pot.getWorldPosition(matPot.uniforms.uCenter.value).y += (potBox.max.y - potBox.min.y) * 0.45;
    bird.getWorldPosition(matBird.uniforms.uCenter.value);

    const setPalette = (name: PaletteName) => {
      const p = PALETTES[name];
      for (const m of mains) {
        m.uniforms.uBase.value.set(p.main[0]);
        m.uniforms.uTint.value.set(p.main[1]);
      }
      matBird.uniforms.uBase.value.set(p.bird[0]);
      matBird.uniforms.uTint.value.set(p.bird[1]);
      matPot.uniforms.uBase.value.set(p.accent[0]);
      matPot.uniforms.uTint.value.set(p.accent[1]);
    };
    setPalette("clay");

    api.current = {
      U, materials,
      controls, camera, scene, bg, setLight, setPalette, HOME,
      resumeDrift: () => {}, // replaced once the loop below exists
    };

    let disposed = false;

    const resize = () => {
      const w = host.clientWidth || 1, h = host.clientHeight || 1;
      renderer.setSize(w, h, false);
      camera.aspect = w / h;
      camera.updateProjectionMatrix();
      U.uPx.value = renderer.getPixelRatio();
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(host);

    // Idle: the whole print breathes a few degrees of yaw — enough life for
    // the index preview without a drag.
    // Idle drift: the camera pushes slowly in and out (14% over 10s) while
    // the print yaws a few degrees — a still life that breathes rather than
    // a turntable. The first grab of the orbit ends it; "reset camera" starts
    // it again. Off while the spin toggle owns the camera.
    let t0 = performance.now();
    let drifting = true;
    const onGrab = () => { drifting = false; };
    controls.addEventListener("start", onGrab);
    const resumeDrift = () => { drifting = true; t0 = performance.now(); };
    renderer.setAnimationLoop(() => {
      if (disposed) return;
      const t = (performance.now() - t0) / 1000;
      root.rotation.y = Math.sin(t * 0.35) * 0.07;
      if (drifting && !controls.autoRotate) {
        const k = 0.14 * (1 - Math.cos((t * Math.PI * 2) / 10)) / 2;
        camera.position.copy(HOME.pos).lerp(HOME.tgt, k);
      }
      controls.update();
      renderer.render(scene, camera);
    });
    api.current!.resumeDrift = resumeDrift;

    return () => {
      disposed = true;
      controls.removeEventListener("start", onGrab);
      api.current = null;
      renderer.setAnimationLoop(null);
      ro.disconnect();
      controls.dispose();
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
    };
  }, []);

  // ── knobs -> uniforms ────────────────────────────────────────────────────
  useEffect(() => {
    const a = api.current;
    if (!a) return;
    a.U.uWrap.value = wrap;
    a.U.uRim.value = rim;
    a.U.uGamma.value = gamma;
    a.U.uLo.value = lo;
    a.U.uHi.value = hi;
    a.U.uMax.value = maxInk;
    a.U.uGrain.value = grain;
    a.U.uClump.value = clump;
    a.U.uBlot.value = blot;
    a.U.uCell.value = cell;
    a.U.uHl.value = hl;
    a.U.uPatOverride.value = PATTERN_NAMES.indexOf(pattern) - 1;
    a.U.uDebug.value = VIEW_NAMES.indexOf(view);
  }, [wrap, rim, gamma, lo, hi, maxInk, grain, clump, blot, cell, hl, pattern, view]);

  useEffect(() => {
    api.current?.setLight(az, el);
  }, [az, el]);

  useEffect(() => {
    api.current?.setPalette(palette);
  }, [palette]);

  useEffect(() => {
    const a = api.current;
    if (a) a.controls.autoRotate = spin;
  }, [spin]);

  useEffect(() => {
    const a = api.current;
    if (a) a.bg.set(darkPaper ? PAPER_DARK : PAPER_LIGHT);
  }, [darkPaper]);

  const resetCamera = () => {
    const a = api.current;
    if (!a) return;
    a.camera.position.copy(a.HOME.pos);
    a.controls.target.copy(a.HOME.tgt);
    a.controls.update();
    a.resumeDrift();
  };

  const isLight = !darkPaper;

  return (
    <div ref={wrapRef} className="fixed inset-0 overflow-hidden select-none" style={{ background: darkPaper ? PAPER_DARK : PAPER_LIGHT }}>
      <canvas ref={canvasRef} className="absolute inset-0 block w-full h-full" />

      {/* the poster line, printed in the ink: KT Flux at the serif end of its
          axis for the display, the sans end tracked out for the caption */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 text-center"
        style={{ bottom: "5%", color: darkPaper ? PAPER_LIGHT : INK, fontFamily: "var(--font-flux), Georgia, serif" }}
      >
        <div
          style={{
            fontSize: "clamp(36px, 6.4vmin, 84px)",
            lineHeight: 0.96,
            letterSpacing: "-0.01em",
            fontVariationSettings: "'wght' 300, 'SRIF' 500",
            textWrap: "balance",
          }}
        >
          Nemawashi
          <br />
          Riso Lab
        </div>
        <div
          style={{
            marginTop: 14,
            fontSize: 11,
            letterSpacing: "0.14em",
            textTransform: "uppercase",
            opacity: 0.55,
            fontVariationSettings: "'wght' 300, 'SRIF' 0",
          }}
        >
          ball · cone · cube · teapot · washi — stipple shader on three.js primitives
        </div>
      </div>
      <DraggablePanel
        isLight={isLight}
        controls={[
          { label: "azimuth", value: az, set: setAz, min: -180, max: 180, step: 1 },
          { label: "elevation", value: el, set: setEl, min: -20, max: 85, step: 1 },
          { label: "wrap", value: wrap, set: setWrap, min: 0, max: 1, step: 0.01 },
          { label: "rim", value: rim, set: setRim, min: 0, max: 1, step: 0.01 },
          { label: "gamma", value: gamma, set: setGamma, min: 0.4, max: 2.5, step: 0.01 },
          { label: "grain from", value: lo, set: setLo, min: 0, max: 0.9, step: 0.01 },
          { label: "solid at", value: hi, set: setHi, min: 0.2, max: 1, step: 0.01 },
          { label: "max ink", value: maxInk, set: setMaxInk, min: 0, max: 1, step: 0.01 },
          { label: "grain px", value: grain, set: setGrain, min: 0.6, max: 4, step: 0.05 },
          { label: "clump", value: clump, set: setClump, min: 0, max: 1, step: 0.01 },
          { label: "blotch", value: blot, set: setBlot, min: 0, max: 1, step: 0.01 },
          { label: "screen px", value: cell, set: setCell, min: 2.5, max: 14, step: 0.5 },
          { label: "highlight", value: hl, set: setHl, min: 0, max: 1, step: 0.01 },
        ]}
      >
        <PanelSelect label="colourway" value={palette} options={PALETTE_NAMES} set={setPalette} isLight={isLight} />
        <PanelSelect label="pattern" value={pattern} options={PATTERN_NAMES} set={setPattern} isLight={isLight} />
        <PanelSelect label="view" value={view} options={VIEW_NAMES} set={setView} isLight={isLight} />
        <PanelToggle label="spin" value={spin} set={setSpin} isLight={isLight} />
        <PanelToggle label="dark paper" value={darkPaper} set={setDarkPaper} isLight={isLight} />
        <button
          onClick={resetCamera}
          style={{
            fontSize: 10,
            letterSpacing: "0.06em",
            textTransform: "uppercase",
            color: isLight ? "#fff" : "#1a1a2e",
            opacity: 0.7,
            background: "none",
            border: 0,
            padding: 0,
            cursor: "pointer",
            fontFamily: "inherit",
            fontVariationSettings: "'wght' 400, 'SRIF' 100",
          }}
        >
          reset camera
        </button>
      </DraggablePanel>
    </div>
  );
}

NEMAWASHI — Kotaro Abe