Give it a URL.

Fabric

The page is hanging from its top edge. Move the pointer across it, or press to grab a handful.

Teardown

Give it a website and take the website apart.

Every website is a flat picture that has agreed to hold still. This one does not agree.

The page arrives as a screenshot stretched over a grid of points that can be pushed around in three dimensions, and a grid of cells that can come off it entirely. That split is the whole model. Points are shared between neighbouring cells, so the sheet deforms as one surface, which is why the cloth folds. Cells are what tear away, which is why a gunshot leaves a hole.

There is no three.js here. It draws one thing, a texture over a mesh, and the whole of that is a vertex buffer, two shaders and a draw call.

The screenshot comes through this site's own API route rather than from the service that renders it. A texture from another origin taints the canvas, and every attempt to read a pixel after that throws.

import { STRIDE } from "./renderer";

/*
 * The page, as a physical object.
 *
 * One data structure serves every effect, which is what keeps five simulations
 * from becoming five programs. It is a grid of points that can be pushed around
 * in three dimensions, and a grid of cells that can be torn off it. Cloth moves
 * the points and never touches the cells; a gunshot does both; a shatter leaves
 * the points alone and moves the cells in rigid groups.
 *
 * Points are shared between neighbouring cells, so the sheet deforms as one
 * surface. Cells are what come away. That split is the whole model.
 */

export type Shard = {
  /* Where the group turns about, fixed at the moment it broke off. */
  cx: number;
  cy: number;
  dx: number;
  dy: number;
  vx: number;
  vy: number;
  rot: number;
  vr: number;
  /* Applied to every cell in the group, so a shard fades as one piece. */
  alpha: number;
};

export type Sheet = {
  cols: number;
  rows: number;
  width: number;
  height: number;

  /* Points • (cols + 1) × (rows + 1). z is toward the viewer. */
  x: Float32Array;
  y: Float32Array;
  z: Float32Array;
  /* The previous frame, which is where velocity is kept in a Verlet integrator. */
  px: Float32Array;
  py: Float32Array;
  pz: Float32Array;
  /* Where the point belongs when nothing is happening to it. */
  rx: Float32Array;
  ry: Float32Array;
  pinned: Uint8Array;
  /* Recomputed each frame from the z gradient. One float per point. */
  shade: Float32Array;

  /* Cells • cols × rows. */
  alpha: Float32Array;
  /** A cell that has left the sheet and is now its own little rigid body. */
  loose: Uint8Array;
  lx: Float32Array;
  ly: Float32Array;
  lvx: Float32Array;
  lvy: Float32Array;
  lrot: Float32Array;
  lvr: Float32Array;
  /** Which rigid group a cell belongs to, or -1 for none. */
  group: Int32Array;

  shards: Shard[];
};

export const POINT_DAMPING = 0.985;

export function pointIndex(sheet: Sheet, i: number, j: number) {
  return j * (sheet.cols + 1) + i;
}

export function createSheet(cols: number, rows: number): Sheet {
  const points = (cols + 1) * (rows + 1);
  const cells = cols * rows;

  const sheet: Sheet = {
    cols,
    rows,
    width: 1,
    height: 1,
    x: new Float32Array(points),
    y: new Float32Array(points),
    z: new Float32Array(points),
    px: new Float32Array(points),
    py: new Float32Array(points),
    pz: new Float32Array(points),
    rx: new Float32Array(points),
    ry: new Float32Array(points),
    pinned: new Uint8Array(points),
    shade: new Float32Array(points),
    alpha: new Float32Array(cells),
    loose: new Uint8Array(cells),
    lx: new Float32Array(cells),
    ly: new Float32Array(cells),
    lvx: new Float32Array(cells),
    lvy: new Float32Array(cells),
    lrot: new Float32Array(cells),
    lvr: new Float32Array(cells),
    group: new Int32Array(cells),
    shards: [],
  };

  return sheet;
}

/** Lay the sheet flat over a box of this size, undoing anything done to it. */
export function resetSheet(sheet: Sheet, width: number, height: number) {
  sheet.width = width;
  sheet.height = height;

  const { cols, rows } = sheet;
  for (let j = 0; j <= rows; j++) {
    for (let i = 0; i <= cols; i++) {
      const p = j * (cols + 1) + i;
      const x = (i / cols) * width;
      const y = (j / rows) * height;
      sheet.rx[p] = x;
      sheet.ry[p] = y;
      sheet.x[p] = x;
      sheet.y[p] = y;
      sheet.z[p] = 0;
      sheet.px[p] = x;
      sheet.py[p] = y;
      sheet.pz[p] = 0;
      sheet.pinned[p] = 0;
      sheet.shade[p] = 1;
    }
  }

  sheet.alpha.fill(1);
  sheet.loose.fill(0);
  sheet.lx.fill(0);
  sheet.ly.fill(0);
  sheet.lvx.fill(0);
  sheet.lvy.fill(0);
  sheet.lrot.fill(0);
  sheet.lvr.fill(0);
  sheet.group.fill(-1);
  sheet.shards.length = 0;
}

/*
 * Verlet, in three dimensions.
 *
 * Position-based rather than force-based because everything here is a
 * constraint • a cloth link holds two points a fixed distance apart, a poster
 * pulls itself back to where it was hanging. Both are far easier to write as
 * "move the point there" than as a force, and Verlet lets velocity survive that
 * without ever being stored.
 */
export function integrate(
  sheet: Sheet,
  dt: number,
  gravity: number,
  damping: number,
) {
  const gy = gravity * dt * dt;
  for (let p = 0; p < sheet.x.length; p++) {
    if (sheet.pinned[p]) continue;

    const vx = (sheet.x[p] - sheet.px[p]) * damping;
    const vy = (sheet.y[p] - sheet.py[p]) * damping;
    const vz = (sheet.z[p] - sheet.pz[p]) * damping;

    sheet.px[p] = sheet.x[p];
    sheet.py[p] = sheet.y[p];
    sheet.pz[p] = sheet.z[p];

    sheet.x[p] += vx;
    sheet.y[p] += vy + gy;
    sheet.z[p] += vz;
  }
}

/** Drag every point back toward where it started. A poster, rather than a flag. */
export function springToRest(sheet: Sheet, pull: number) {
  for (let p = 0; p < sheet.x.length; p++) {
    if (sheet.pinned[p]) continue;
    sheet.x[p] += (sheet.rx[p] - sheet.x[p]) * pull;
    sheet.y[p] += (sheet.ry[p] - sheet.y[p]) * pull;
    sheet.z[p] += (0 - sheet.z[p]) * pull;
  }
}

/*
 * Structural links, relaxed in place. Two passes over the grid rather than a
 * list of constraint objects: the neighbours of a point are its index plus one
 * and its index plus a row, so the list would be a slower way of writing the
 * same two loops.
 */
export function solveLinks(
  sheet: Sheet,
  iterations: number,
  stiffness: number,
) {
  const { cols, rows } = sheet;
  const stepX = sheet.width / cols;
  const stepY = sheet.height / rows;

  for (let pass = 0; pass < iterations; pass++) {
    for (let j = 0; j <= rows; j++) {
      for (let i = 0; i <= cols; i++) {
        const a = j * (cols + 1) + i;
        if (i < cols) relax(sheet, a, a + 1, stepX, stiffness);
        if (j < rows) relax(sheet, a, a + cols + 1, stepY, stiffness);
      }
    }
  }
}

function relax(
  sheet: Sheet,
  a: number,
  b: number,
  rest: number,
  stiffness: number,
) {
  const dx = sheet.x[b] - sheet.x[a];
  const dy = sheet.y[b] - sheet.y[a];
  const dz = sheet.z[b] - sheet.z[a];
  const distance = Math.hypot(dx, dy, dz);
  if (distance < 1e-6) return;

  /*
   * Halved, then shared out by how free each end is. A link with one pinned
   * end has to move the other end by the whole correction or the pin drags.
   */
  const correction = ((distance - rest) / distance) * stiffness;
  const freeA = sheet.pinned[a] ? 0 : 1;
  const freeB = sheet.pinned[b] ? 0 : 1;
  const total = freeA + freeB;
  if (total === 0) return;

  const shareA = (freeA / total) * correction;
  const shareB = (freeB / total) * correction;

  sheet.x[a] += dx * shareA;
  sheet.y[a] += dy * shareA;
  sheet.z[a] += dz * shareA;
  sheet.x[b] -= dx * shareB;
  sheet.y[b] -= dy * shareB;
  sheet.z[b] -= dz * shareB;
}

/*
 * One light, from over your left shoulder, and no ambient occlusion or
 * specular anywhere near it. A screenshot is already a picture of a lit thing •
 * all this has to do is say which way the surface is now facing, so a fold
 * reads as a fold. Anything more and the page starts looking like a render of
 * a page.
 */
const LIGHT = { x: -0.36, y: -0.48, z: 0.8 };

export function shadeFromDepth(sheet: Sheet, relief: number) {
  const { cols, rows } = sheet;
  const stepX = sheet.width / cols;
  const stepY = sheet.height / rows;

  for (let j = 0; j <= rows; j++) {
    for (let i = 0; i <= cols; i++) {
      const p = j * (cols + 1) + i;
      const right = i < cols ? p + 1 : p;
      const left = i > 0 ? p - 1 : p;
      const below = j < rows ? p + cols + 1 : p;
      const above = j > 0 ? p - cols - 1 : p;

      // Central differences, so the gradient is not biased toward one corner.
      const gx = (sheet.z[right] - sheet.z[left]) / (stepX * 2);
      const gy = (sheet.z[below] - sheet.z[above]) / (stepY * 2);

      const nx = -gx * relief;
      const ny = -gy * relief;
      const length = Math.hypot(nx, ny, 1);
      const lit = (nx * LIGHT.x + ny * LIGHT.y + LIGHT.z) / (length || 1);

      // 0.55 of the light is ambient, so a face turned away goes dim, never black.
      sheet.shade[p] = Math.min(1.35, Math.max(0.35, 0.55 + lit * 0.55));
    }
  }
}

/** Weak perspective. Enough for a fold to have a near side, and no more. */
const FOCAL = 1100;

/*
 * Written straight into the renderer's buffer rather than into an intermediate
 * of its own. This runs sixty times a second over every corner of every cell,
 * and a second copy of that array is a second pass over it.
 */
export function writeVertices(sheet: Sheet, out: Float32Array) {
  const { cols, rows, width, height } = sheet;
  const halfW = width / 2;
  const halfH = height / 2;
  const rowStride = cols + 1;

  // The four corners of the cell being written, projected but not yet moved.
  const cornerX = [0, 0, 0, 0];
  const cornerY = [0, 0, 0, 0];
  const cornerShade = [0, 0, 0, 0];

  for (let j = 0; j < rows; j++) {
    for (let i = 0; i < cols; i++) {
      const c = j * cols + i;
      const base = c * 4 * STRIDE;
      const alpha = sheet.alpha[c];

      if (alpha <= 0.004) {
        // Left where it was, invisible. The fragment shader discards it, and
        // writing four positions for a cell nobody will see is wasted work.
        out[base + 5] = 0;
        out[base + STRIDE + 5] = 0;
        out[base + STRIDE * 2 + 5] = 0;
        out[base + STRIDE * 3 + 5] = 0;
        continue;
      }

      const p00 = j * rowStride + i;
      const corners = [p00, p00 + 1, p00 + rowStride, p00 + rowStride + 1];

      for (let k = 0; k < 4; k++) {
        const p = corners[k];
        const scale = FOCAL / (FOCAL - sheet.z[p]);
        cornerX[k] = halfW + (sheet.x[p] - halfW) * scale;
        cornerY[k] = halfH + (sheet.y[p] - halfH) * scale;
        cornerShade[k] = sheet.shade[p];
      }

      const group = sheet.group[c];
      if (group >= 0) {
        const shard = sheet.shards[group];
        const cos = Math.cos(shard.rot);
        const sin = Math.sin(shard.rot);
        for (let k = 0; k < 4; k++) {
          const dx = cornerX[k] - shard.cx;
          const dy = cornerY[k] - shard.cy;
          cornerX[k] = shard.cx + shard.dx + dx * cos - dy * sin;
          cornerY[k] = shard.cy + shard.dy + dx * sin + dy * cos;
        }
      } else if (sheet.loose[c]) {
        // A single cell turns about its own middle, which it has to be told,
        // because the middle moved with it.
        const midX = (cornerX[0] + cornerX[1] + cornerX[2] + cornerX[3]) / 4;
        const midY = (cornerY[0] + cornerY[1] + cornerY[2] + cornerY[3]) / 4;
        const cos = Math.cos(sheet.lrot[c]);
        const sin = Math.sin(sheet.lrot[c]);
        for (let k = 0; k < 4; k++) {
          const dx = cornerX[k] - midX;
          const dy = cornerY[k] - midY;
          cornerX[k] = midX + sheet.lx[c] + dx * cos - dy * sin;
          cornerY[k] = midY + sheet.ly[c] + dx * sin + dy * cos;
        }
      }

      const u0 = i / cols;
      const u1 = (i + 1) / cols;
      const v0 = j / rows;
      const v1 = (j + 1) / rows;

      write(out, base, cornerX[0], cornerY[0], u0, v0, cornerShade[0], alpha);
      write(
        out,
        base + STRIDE,
        cornerX[1],
        cornerY[1],
        u1,
        v0,
        cornerShade[1],
        alpha,
      );
      write(
        out,
        base + STRIDE * 2,
        cornerX[2],
        cornerY[2],
        u0,
        v1,
        cornerShade[2],
        alpha,
      );
      write(
        out,
        base + STRIDE * 3,
        cornerX[3],
        cornerY[3],
        u1,
        v1,
        cornerShade[3],
        alpha,
      );
    }
  }
}

function write(
  out: Float32Array,
  at: number,
  x: number,
  y: number,
  u: number,
  v: number,
  shade: number,
  alpha: number,
) {
  out[at] = x;
  out[at + 1] = y;
  out[at + 2] = u;
  out[at + 3] = v;
  out[at + 4] = shade;
  out[at + 5] = alpha;
}

/** Move every cell that has come away from the sheet. Shared by three effects. */
export function integrateLoose(sheet: Sheet, dt: number, gravity: number) {
  for (let c = 0; c < sheet.alpha.length; c++) {
    if (!sheet.loose[c] || sheet.alpha[c] <= 0) continue;
    sheet.lvy[c] += gravity * dt;
    sheet.lx[c] += sheet.lvx[c] * dt;
    sheet.ly[c] += sheet.lvy[c] * dt;
    sheet.lrot[c] += sheet.lvr[c] * dt;
    // Air, roughly. Debris that keeps its speed forever reads as weightless.
    sheet.lvx[c] *= 0.99;
    sheet.lvr[c] *= 0.99;
  }
}

/** The middle of a cell, in the sheet's rest coordinates. */
export function cellCentre(sheet: Sheet, c: number) {
  const i = c % sheet.cols;
  const j = (c / sheet.cols) | 0;
  return {
    x: ((i + 0.5) / sheet.cols) * sheet.width,
    y: ((j + 0.5) / sheet.rows) * sheet.height,
  };
}