← tilings-projection-method

Canonical Projection Tiling (4 → 2)

Quasiperiodic tiling by 4D cut-and-project: move the pointer to rotate the projection plane, pinch or ctrl+scroll to zoom, click to freeze.

Window shift
2 -2
-2 2
Free edge
2 0
0 2
Twist (edge 2 vs pointer)
2 0
0 2

Source

common.glsl

// 4to2CPT — quasiperiodic tilings by 4D cut-and-project, "nearest face"
// method: for each of the 6 axis pairs, find which projected square face
// (tile) the pixel lands in. Yields the tile's identity, so tiles can be
// filled, coloured and shaded individually.
//
// The face-finding logic comes from knighty's shader (April 2018):
//   "Ammann-Beenker"            https://www.shadertoy.com/view/MddfzH
//   related "Cut n'project"     https://www.shadertoy.com/view/XdtBzH
// The first has the line "Free stuff" and the second "License: Free"

// The cut plane — projection basis plus lattice offset — as a single value.
struct cutPlane {
    vec4 u;   // dual basis of (wu, wv) in the same plane — embeds screen
    vec4 v;   //   coords into 4D (embed) and solves the section
    vec4 wu;  // drawing rows: column k of (wu, wv) is the exact on-screen
    vec4 wv;  //   image of lattice edge e_k (WYSIWYG edge control)
    vec4 o;   // cut-plane offset relative to the lattice, reduced mod 1 (see image.glsl)
};

// Result of the per-pixel tile search.
struct hitTile {
    float dist;       // signed distance to the tile boundary (negative = inside)
    ivec2 sides;      // the axis pair (i,j) whose square face was hit
    vec2  posInTile;  // position within the unit-square tile
    vec4  cell;       // the face's corner in 4D lattice coordinates (integer
                      // valued) — the tile's identity, invariant under changes
                      // of projection direction
    vec2  heights;    // perpendicular heights of the drawn tile, per edge
                      // pair — converts tile-coord distances to physical units
};

// A 2D point z on the cut plane, embedded back into 4D.
vec4 embed(vec2 z, cutPlane cp) { return cp.o + z.x * cp.u + z.y * cp.v; }

// Signed distance (physical units, negative = inside) from a (in unit-square
// coords) to the tile's boundary, with corners rounded off by radius r.
// f = the parallelogram's perpendicular height per axis: scaling each
// coordinate distance by its own height makes this exact — equal grout width
// on short and long edges, and correct behaviour in the corners.
// r = 0 gives the sharp tile: section() uses that for tile acceptance, so
// ownership stays a partition. image.glsl draws with r = corner radius; the
// corner pockets (inside the sharp tile, outside the rounded one) come out
// positive there and render as mortar.
float tileDist(vec2 a, vec2 f, float r) {
    vec2 q = (abs(a - 0.5) - 0.5) * f + r;
    return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r;
}

// Tangent-space diffuse lighting for the (i,j) tile. The tile is a 2-face in
// 4D (codimension 2), so it has no single normal — instead, the illuminating
// part of the light L is its component orthogonal to the tile's tangent plane.
// That plane is spanned by the lattice axes e_i and e_j, so the orthogonal
// component is just L with those two coordinates removed. For unit L this is
// 1 when L is orthogonal to the tile and 0 when L lies in the tile's plane
// (grazing) — the codimension-2 analogue of Lambert's max(0, dot(N, L)).
float faceLight(int i, int j, vec4 L) {
    L[i] = 0.0;
    L[j] = 0.0;
    return length(L);
}

// ---- Glazed-tile shading ----------------------------------------------------
// Tiles are shaded as glazed ceramic: a matte coloured body under a thin clear
// coat. Everything runs in the tile's local 3D frame — x,y are the tile's
// lattice-square coordinates (posInTile), z points out of the wall. The 4D
// light L lands in that frame as vec3(L[i], L[j], faceLight(i,j,L)), which is
// unit whenever L is: the in-plane components grade the shading across the
// pillowed tile, the orthogonal part lights it face-on. On a flat tile
// (bump = 0) this reduces exactly to the tangent-space model above.

// Cheap hashes and smooth value noise for the glaze finish.
float hash21(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

float hash41(vec4 p) {
    p = fract(p * vec4(123.34, 456.21, 789.92, 234.55));
    p += dot(p, p.wzxy + 45.32);
    return fract((p.x + p.y) * (p.z + p.w));
}

float vnoise(vec2 p) {
    vec2 i = floor(p), f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(mix(hash21(i),                 hash21(i + vec2(1, 0)), u.x),
               mix(hash21(i + vec2(0, 1)),    hash21(i + vec2(1, 1)), u.x), u.y);
}

// Normal of a pillowed tile: flat in the middle, rolling off near the rim.
// p = posInTile in [0,1]^2; bump scales the pillow height.
vec3 tileNormal(vec2 p, float bump) {
    vec2 s  = clamp(2.0 * p - 1.0, -1.0, 1.0);      // [-1,1] across the tile
    vec2 s2 = s * s;
    vec2 s4 = s2 * s2;
    vec2 s8 = s4 * s4;
    vec2 s16 = s8 * s8;
    // height h = (1 - s.x^16)(1 - s.y^16); grad is proportional to
    // (dh/ds.x, dh/ds.y) — the constant factor is absorbed into bump
    vec2 grad = -8.0 * s8 * s4 * s2 * s * (1.0 - s16.yx);
    return normalize(vec3(-bump * grad, 1.0));
}

// Glazed-ceramic BSDF: Schlick Fresnel (F0 = 0.04, glaze IOR ~1.5) splits the
// light between the coat's specular lobe and the diffuse body beneath it.
// Viewed head-on (v = +z); gloss in [0,1] sets the highlight tightness; amb
// is the ambient floor — at 0 the shading is purely directional.
vec3 glazeBSDF(vec3 albedo, vec3 n, vec3 l, float gloss, float amb) {
    const vec3 v = vec3(0.0, 0.0, 1.0);
    vec3 h = normalize(l + v);
    float ndl = max(dot(n, l), 0.0);
    float fres = 0.04 + 0.96 * pow(1.0 - clamp(dot(h, v), 0.0, 1.0), 5.0);
    float shin = exp2(mix(3.0, 10.0, gloss));
    float spec = fres * (shin + 2.0) * 0.125 * pow(max(dot(n, h), 0.0), shin) * ndl;
    spec /= 1.0 + spec;   // Reinhard: highlights roll off instead of clipping
    vec3  body = albedo * (1.0 - fres) * mix(amb, 1.0, ndl);
    return body + vec3(spec);
}

// Matte mortar for the grout channel between tiles. pit = position in the
// tile, f = the tile's perpendicular heights (tile coords -> physical), w =
// the channel half-width. The mortar is a shallow cove — flat mid-channel,
// curving up to meet each tile rim — lit with Lambert only: the matte finish
// against the glossy glaze is what makes the tiles read as glazed. A soft AO
// term tucks a shadow under the pillowed rims.
vec3 groutShade(vec2 pit, vec2 f, float w, vec3 l, vec2 seed, float noiseAmt, float amb) {
    // Physical distance to each edge pair (f = the tile's perpendicular
    // heights). t: how far the mortar has climbed from the channel centre
    // line (0) toward the tile rim (1), per axis.
    vec2 q = pit - 0.5;
    vec2 a = (0.5 - abs(q)) * f;
    float ww = max(w, 1e-4);
    vec2 t = clamp(a / ww, 0.0, 1.0);

    // Rounded (tooled) mortar joint: slope zero on the centre line, zero
    // again where it meets the rim, peaking in between — 4t(1-t). Axes whose
    // channel the point is outside have t = 1 and drop out, so corners blend
    // the two edges' tilts on their own.
    vec2 climb = sign(q) * 4.0 * t * (1.0 - t);
    vec3 n = normalize(vec3(0.5 * climb, 1.0));

    // Sandy speckle, anchored to the tile like the glaze noise.
    float sand = mix(1.0, 0.80 + 0.40 * vnoise(pit * 60.0 + seed), noiseAmt);

    // Soft shadow tucked under the rims, from the true edge distance.
    float rim = min(t.x, t.y);
    float ao = 1.0 - 0.30 * rim * rim;

    vec3 mortar = vec3(0.92, 0.90, 0.85) * sand;
    return mortar * ao * mix(min(amb, 1.0), 1.0, max(dot(n, l), 0.0));
}

// Test the square face of orientation (i,j); s = (±1,±1) selects one of the four
// neighbouring faces around the nearest vertex.
hitTile section(int i, int j, vec4 p, vec4 ip, mat2 m, vec2 f, vec2 s, cutPlane cp) {
    // Face corner along axes i and j; the face centre sits 0.5 further along both.
    float ci = ip[i] + 0.5 * (s.x - 1.0);
    float cj = ip[j] + 0.5 * (s.y - 1.0);

    // Face centre in the (i,j) plane, mapped back to a 2D point on the cut plane.
    vec2 z = (vec2(ci, cj) + 0.5 - vec2(cp.o[i], cp.o[j])) * m;

    // Full 4D corner: the face's other coordinates may belong to a
    // neighbouring lattice cell, so round them at the face centre and pin
    // axes i, j. This is also the tile's 4D identity (hitTile.cell).
    vec4 corner = floor(embed(z, cp) + 0.5);
    corner[i] = ci;
    corner[j] = cj;

    vec4 ofs = p - corner;   // pixel's 4D offset from the face corner
    vec2 pit = m * vec2(dot(ofs, cp.u), dot(ofs, cp.v));
    return hitTile(tileDist(pit, f, 0.0), ivec2(i, j), pit, corner, f);
}

// Find which tile the pixel's cut-plane point lies in. Returns as soon as a
// containing tile is found (dist < 0).
hitTile DE(vec2 z, cutPlane cp) {
    vec4 p = embed(z, cp);
    vec4 ip = floor(p + 0.5);   // nearest lattice vertex

    for (int i = 0; i < 3; ++i)
    for (int j = i + 1; j < 4; ++j) {
        // 2x2 block of the dual basis for axes (i,j) — solves the section —
        // and of the drawing rows — the tile's actual on-screen edges.
        mat2 m  = mat2(vec2(cp.u[i],  cp.v[i]),  vec2(cp.u[j],  cp.v[j]));
        mat2 mw = mat2(vec2(cp.wu[i], cp.wv[i]), vec2(cp.wu[j], cp.wv[j]));

        // Skip near-degenerate orientations (parallel drawn edges -> zero-area
        // sliver tiles). Happens at some pointer/slider positions.
        float area = abs(mw[0].x * mw[1].y - mw[0].y * mw[1].x);
        if (area < 1e-4) continue;

        // Perpendicular heights of the drawn tile, per edge pair (area /
        // opposite edge length) — screen metric, so from the drawing rows;
        // m becomes its inverse.
        vec2 f = vec2(area / length(mw[1]), area / length(mw[0]));
        m = inverse(m);

        vec2 s = vec2(1.0, -1.0);
        hitTile d = section(i, j, p, ip, m, f, s.xx, cp); if (d.dist < 0.0) return d;
        d         = section(i, j, p, ip, m, f, s.xy, cp); if (d.dist < 0.0) return d;
        d         = section(i, j, p, ip, m, f, s.yx, cp); if (d.dist < 0.0) return d;
        d         = section(i, j, p, ip, m, f, s.yy, cp); if (d.dist < 0.0) return d;
    }
    // No containing tile found (rare).
    return hitTile(0.0, ivec2(0), vec2(0), vec4(0), vec2(1));
}

image.glsl

// 4to2CPT — see common.glsl for the method and credit (knighty, 2018).

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 pos = scale * (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

    // Drawing rows = the physical rows of uB (transpose turns them into
    // columns). Column k of (w0, w1) is the EXACT on-screen image of lattice
    // edge e_k: e2 pinned at (1,0), e3 = uFreeEdge, e0 = pointer, e1 =
    // pointer * uTwist (see buildB).
    mat4 Bt = transpose(uB[0]);
    vec4 w0 = Bt[0];
    vec4 w1 = Bt[1];

    // Dual basis of (w0, w1) in their common plane: (u.w0, u.w1) = (1, 0) and
    // (v.w0, v.w1) = (0, 1). embed() maps screen coords through the dual, which
    // is what makes the drawn edges land exactly on the columns of (w0, w1) —
    // WYSIWYG, no Gram-Schmidt "correction". Always well-defined: with e2
    // pinned and v2 > 0 the two rows can never be parallel.
    //
    // o slides the lattice under the fixed plane and window. The winShift
    // uniform moves it along uBI * (0,0,winShift) — displacements that draw to
    // zero on screen, so the tiling rearranges without translating. The tiling
    // is exactly periodic in o, so fract() keeps it bounded.
    float g00 = dot(w0, w0), g01 = dot(w0, w1), g11 = dot(w1, w1);
    float gi = 1.0 / (g00 * g11 - g01 * g01);
    cutPlane cp = cutPlane(
        (g11 * w0 - g01 * w1) * gi,
        (g00 * w1 - g01 * w0) * gi,
        w0, w1,
        fract(vec4(0.001, 0.0002, -0.003, 0.00001)
              + uBI[0] * vec4(0.0, 0.0, winShift)));

    hitTile d = DE(pos, cp);
    // Distance into the round-cornered tile — tileDist is an SDF (negative
    // inside), so negate. Negative de = the corner pockets, which render as
    // mortar.
    float de = -tileDist(d.posInTile, d.heights, corner);

    // Base tile colour.
    vec3 fill = uTileColor;

    // Noise to sell the glaze, keyed to 4D position: d.cell (the face's
    // lattice corner) fixes each tile's fired tone and noise seed, and
    // posInTile samples within the face — together the true 4D coordinates
    // of the point on the tile. Change the projection direction and the
    // tiles carry their noise with them.
    float tone = 0.85 + 0.30 * hash41(d.cell);
    vec2 seed = 100.0 * vec2(hash41(d.cell + 0.7), hash41(d.cell + 2.3));
    float mottle = 0.90 + 0.20 * vnoise(d.posInTile * 3.0 + seed);
    fill *= mix(1.0, tone * mottle, noise);

    // Glazed-tile lighting (see common.glsl): move the 4D light into the
    // tile's local frame — in-plane components (they vary the shading across
    // the pillowed tile) plus the orthogonal part as z — and evaluate the
    // clear-coat BSDF against the pillow normal.
    vec4 L = uLight / max(length(uLight), 1e-6);
    vec3 l3 = vec3(L[d.sides.x], L[d.sides.y],
                   faceLight(d.sides.x, d.sides.y, L));
    vec3 n = tileNormal(d.posInTile, bump);

    // Orange peel: the glaze surface is never optically flat, so a gentle
    // high-frequency waviness dapples the specular highlight. Sampled in the
    // tile's own face coordinates (same 4D anchoring as the mottle), which is
    // also the frame the normal lives in.
    vec2 np = d.posInTile * 12.0 + seed;
    vec2 dn = vec2(vnoise(np + vec2(0.04, 0.0)) - vnoise(np - vec2(0.04, 0.0)),
                   vnoise(np + vec2(0.0, 0.04)) - vnoise(np - vec2(0.0, 0.04)));
    n = normalize(vec3(n.xy + 2.0 * noise * dn, n.z));

    fill = glazeBSDF(fill, n, l3, gloss, ambient);

    // Grout: matte recessed mortar in the channel between tiles (see
    // groutShade in common.glsl), lit by the same tile-frame light, but with
    // higher ambient light (+0.7). Noise is increased relative to variation
    // on tile colour.
    float px = scale/iResolution.y;
    float fillMask = smoothstep(grout, grout + 2.0 * px, de); // 0 in channel, 1 on tile
    vec3 mortar = groutShade(d.posInTile, d.heights, grout, l3, seed, 0.5, ambient+0.7);

    vec3 col = mix(mortar, fill, fillMask);

    fragColor = vec4(sqrt(max(col, 0.0)), 1.0); // rough gamma
}

4to2CPT/script.js

// 4to2CPT — JS-side per-frame matrix setup.
//
// The pointer position and the uFreeEdge/uTwist sliders drive the 4D
// cut-and-project basis B (and its inverse BI). Both are constant across a
// frame, so they are built once here and shipped as mat4 UBO uniforms:
// uB[0] gives the shader its cut-plane basis (physical rows), uBI[0] turns
// the winShift uniform into window-plane displacements.

import { inv, qr, transpose, flatten } from 'mathjs';

// Normalized pointer position over the canvas: x,y in [0,1], origin
// bottom-left. Starts at v1 = v2 = sqrt(2)/2 — with the default edge sliders
// that is exactly the octagonal Ammann–Beenker tiling, so the first frame
// shows a classic tiling before any interaction.
let mx = 0.3536, my = 0.3485;

// Click-to-lock: while true, pointer moves are ignored, so the tiling can be
// held still while reaching for the sliders (or screenshotting).
let locked = false;

// Pinch zoom (touch or trackpad): handlers accumulate a multiplicative factor
// which onFrame() applies to the 'scale' uniform. The Scale slider is hidden
// in config.json — gestures own the zoom (setUniformValue would not move the
// slider knob, so a visible slider would go stale).
let zoomPending = 1.0;
const pinchPts = new Map(); // active pointers on the canvas (id -> [x, y])
let pinchDist = 0;          // finger distance at the last two-finger sample
let pinching = false;       // 2 fingers seen; blocks steering until all lift

let canvas = null;
let listening = false;

// Last valid inverse, kept as a fallback for frames where the sliders/pointer
// make B singular (mathjs inv() throws) or numerically blow up.
let lastBI = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];

function findCanvas() {
  if (canvas && canvas.isConnected) return canvas;
  canvas = document.querySelector('#app canvas') || document.querySelector('canvas');
  if (canvas) {
    canvas.style.touchAction = 'none'; // touch drag steers the tiling, not the page
    canvas.style.cursor = locked ? 'default' : 'crosshair';
  }
  return canvas;
}

// The canvas if the event falls inside it, else null.
function overCanvas(e) {
  const c = findCanvas();
  if (!c) return null;
  const r = c.getBoundingClientRect();
  if (e.clientX < r.left || e.clientX > r.right ||
      e.clientY < r.top || e.clientY > r.bottom) return null;
  return c;
}

function startListening() {
  if (listening || typeof window === 'undefined') return;
  listening = true;
  // pointermove covers mouse, pen and touch alike.
  window.addEventListener('pointermove', (e) => {
    if (pinchPts.has(e.pointerId)) pinchPts.set(e.pointerId, [e.clientX, e.clientY]);
    // Two fingers down = pinch zoom (works even while locked — zoom is a view
    // change, not a projection change). Fingers apart = zoom in = smaller
    // scale. pinching stays set until all fingers lift, so the last finger
    // off a pinch can't steer the tiling somewhere unexpected.
    if (pinchPts.size >= 2) {
      const [a, b] = [...pinchPts.values()];
      const dist = Math.hypot(a[0] - b[0], a[1] - b[1]);
      if (pinchDist > 0 && dist > 0) zoomPending *= pinchDist / dist;
      pinchDist = dist;
      return;
    }
    if (locked || pinching) return;
    const c = findCanvas();
    if (!c) return;
    const r = c.getBoundingClientRect();
    if (r.width === 0 || r.height === 0) return;
    const x = (e.clientX - r.left) / r.width;
    const y = 1.0 - (e.clientY - r.top) / r.height; // shader y is bottom-up
    if (x < 0 || x > 1 || y < 0 || y > 1) return;    // only track over the canvas
    mx = x;
    my = y;
  });
  window.addEventListener('pointerdown', (e) => {
    if (!overCanvas(e)) return;
    pinchPts.set(e.pointerId, [e.clientX, e.clientY]);
    if (pinchPts.size >= 2) pinching = true;
    pinchDist = 0; // re-measure at the next move
  });
  const liftPointer = (e) => {
    pinchPts.delete(e.pointerId);
    pinchDist = 0;
    if (pinchPts.size === 0) pinching = false;
  };
  window.addEventListener('pointerup', liftPointer);
  window.addEventListener('pointercancel', liftPointer);
  // Trackpad pinch arrives as ctrl+wheel in Chrome/Firefox/Edge (and gives
  // mouse users ctrl+scroll zoom for free). preventDefault stops the
  // browser's own page zoom, so the listener must be non-passive.
  window.addEventListener('wheel', (e) => {
    if (!e.ctrlKey || !overCanvas(e)) return;
    e.preventDefault();
    const dy = e.deltaMode === 1 ? 15 * e.deltaY : e.deltaY; // lines -> ~px
    zoomPending *= Math.exp(0.01 * dy);
  }, { passive: false });
  // Desktop Safari reports trackpad pinch through gesture events instead;
  // e.scale is cumulative since gesturestart.
  if (typeof GestureEvent !== 'undefined') {
    let gScale = 1;
    window.addEventListener('gesturestart', (e) => {
      if (!overCanvas(e)) return;
      e.preventDefault();
      gScale = e.scale;
    });
    window.addEventListener('gesturechange', (e) => {
      if (!overCanvas(e)) return;
      e.preventDefault();
      if (e.scale > 0) zoomPending *= gScale / e.scale;
      gScale = e.scale;
    });
  }
  // Click (or tap) on the canvas toggles the lock. Using 'click' rather than
  // pointerdown keeps touch drags free to steer: a drag suppresses the click,
  // only a deliberate tap toggles. The cursor signals the state.
  window.addEventListener('click', (e) => {
    const c = overCanvas(e);
    if (!c) return;
    locked = !locked;
    c.style.cursor = locked ? 'default' : 'crosshair';
  });
}

// The basis B as row-major nested rows.
// Physical rows (0-1): projected edge images — e0 from the pointer (v1, v2),
// e1 = its complex product with eB (the uTwist uniform), e2 pinned at (1, 0)
// as the gauge, e3 = eA (the uFreeEdge uniform).
// Perp rows (2-3): an orthonormal basis of the window plane. The full QR of
// [r0; r1]^T has, in columns 2-3 of Q, exactly the orthonormal complement of
// the physical rows — orthogonal to both for every slider/pointer position.
function buildB(v1, v2, eA, eB) {
  const r0 = [v1, v1*eB[0] - v2*eB[1], 1, eA[0]];
  const r1 = [v2, v2*eB[0] + v1*eB[1], 0, eA[1]];

  const Q = qr(transpose([r0, r1])).Q;
  return [r0, r1, Q.map(row => row[2]), Q.map(row => row[3])];
}

// row-major nested -> mat4 column-major flat (GLSL layout)
const toGLSL = (rows) => flatten(transpose(rows));

export function setup() {
  startListening();
}

export function onFrame(engine) {
  startListening(); // canvas may not have existed at setup() time

  const v1 = 2. * mx;
  // +0.01 keeps v2 > 0, so the physical rows of B can never become parallel —
  // the guarantee image.glsl's dual-basis construction relies on.
  const v2 = 2. * my + 0.01;

  // Edge sliders (see buildB); fallbacks match the config.json defaults.
  const eA = engine.getUniformValue('uFreeEdge') ?? [0, 1];
  const eB = engine.getUniformValue('uTwist') ?? [0, 1];

  // Apply pinch/trackpad zoom accumulated since the last frame; bounds match
  // the scale uniform's min/max in config.json.
  if (zoomPending !== 1.0) {
    const s = engine.getUniformValue('scale') ?? 12.0;
    engine.setUniformValue('scale', Math.min(40, Math.max(4, s * zoomPending)));
    zoomPending = 1.0;
  }

  const B = buildB(v1, v2, eA, eB);
  let BI = null;
  try {
    BI = toGLSL(inv(B));
    if (!BI.every(Number.isFinite)) BI = null;
  } catch {
    BI = null;
  }
  if (BI) lastBI = BI; else BI = lastBI;

  engine.setUniformValue('uB', toGLSL(B));
  engine.setUniformValue('uBI', BI);
}