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