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);
}