unroll / tear.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
76.2 kB
import { DMG_HEAD as DMG_HEAD_T, DMG_CUT as DMG_CUT_T, DMG_EDGE as DMG_EDGE_T } from './damage.js?v=20261016g';
// "Tear apart": a just-for-fun brittle fracture toy on top of the unrolled strip.
// Not a real unrolling. Position based dynamics (Verlet + distance constraints) on typed arrays,
// with real cracks: a crack is a jagged polyline in the sheet's own material space that grows from
// its tips. Physics links it crosses break (so nodes on each side separate: no material is removed),
// and a much finer render mesh is cut along the polyline and skinned to the physics nodes on its side.
//
// Material model, qualitative, for carbonized Herculaneum papyrus:
// - Charcoal-like and brittle, falls to pieces with light contact (Parker et al. 2023, EduceLab-Scrolls,
// arXiv:2304.02084; Mocella et al. 2015, Nat. Commun. 6:5895). So: very stiff in stretch and bend,
// failure at about 1 to 2 % strain, and a small bend angle before cracking. Carbonized plant matter
// (charcoal) generally fails near 1 % strain; we use 0.9 % along fibers and 1.8 % across them.
// - Papyrus is two cross-ply fiber layers pressed together; carbonization makes them bubble and
// delaminate (EduceLab-Scrolls, sec. 1). So: recto (horizontal fibers) and verso (vertical fibers)
// are separate sheets joined by breakable glue, each with its own cracks, biased along its fibers.
// - Adjacent windings are pressed together and often fused, worst in the outer scorza
// (Bettuzzi et al. 2021, Sci. Rep. 11:1727). So: neighboring windings share breakable adhesion links,
// and every sheet starts stuck to the rest of the roll (an invisible backing it peels away from).
// - Low density: about 0.3 to 0.5 g/cm3 for charcoal, so strong air drag and flutter for small flakes.
// The numbers are chosen to match that behavior, not measured on these scrolls.
const P = {
cell: 3.8, // mm between physics nodes (desktop): about 18 x 18 per winding and layer
cellMobile: 4.6,
renderCell: 0.95, // mm, render mesh triangle size once a sheet cracks (about 70 x 70 per layer, vs 18 x 18 physics)
layerGap: 0.22, // mm between recto and verso
thickness: 0.15, // mm, dark torn edge face
gravity: 9810, // mm/s^2
drag: 2.4, // 1/s velocity loss for sheet nodes (low density)
substeps: 1,
iters: 6,
strainAlong: 0.009, // failure strain along the fibers of a layer (crack runs along fibers)
strainAcross: 0.018,
jitter: 0.45, // +/- fraction of random scatter in thresholds
bendCrackDeg: 14, // fold angle at a node that cracks it
glueGap: 0.35, // mm of layer separation that delaminates recto from verso
fuseStrain: 0.012, // adhesion between neighboring windings
peelFrac: 0.85, // a stuck node lets go of the backing above this fraction of its failure strain
peelProb: 0.08, // brittle: it snaps rather than peels
shatterSpeed: 700, // mm/s impact speed that shatters links at a node
tipGrow: 0.8, // a crack tip advances while nearby links are above this fraction of failure
fiberBias: 0.75, // how strongly cracks follow the fibers of their layer
turnJitter: 0.38, // radians of random wander per step
branch: 0.025, // chance per step that a crack forks
stepsPerTip: 4, // propagation steps per tip per frame, at most
stepBudget: 100, // propagation steps per frame, all cracks
maxFlakes: 1800,
maxDust: 260,
};
export function createTear(ctx) {
const { THREE, scene, camera, controls, canvas, meshes, shared, FRAG, stripZ, ctImage, damage } = ctx;
const MOBILE = ctx.MOBILE;
const cell = MOBILE ? P.cellMobile : P.cell;
// CT mask: which nodes sit on traced papyrus
const mc = document.createElement('canvas');
const MW = 2048, MH = Math.round(2048 * ctImage.height / ctImage.width);
mc.width = MW; mc.height = MH;
const mg = mc.getContext('2d', { willReadFrequently: true });
mg.drawImage(ctImage, 0, 0, MW, MH);
const md = mg.getImageData(0, 0, MW, MH).data;
const onPapyrus = (atlas, u, v) => {
const x = Math.round((atlas[0] + u * (atlas[2] - atlas[0])) * (MW - 1));
const y = Math.round((atlas[1] + v * (atlas[3] - atlas[1])) * (MH - 1));
let m = 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const xx = Math.min(MW - 1, Math.max(0, x + dx)), yy = Math.min(MH - 1, Math.max(0, y + dy));
m = Math.max(m, md[(yy * MW + xx) * 4]);
}
return m > 3;
};
// ------------------------------------------------------------ build nodes
const sheets = [];
let N = 0;
let ground = Infinity;
for (const m of meshes) {
const { w, own } = m.userData;
const W = w.widthMM, H = w.heightMM;
const C = Math.max(4, Math.round(W / cell) + 1), R = Math.max(4, Math.round(H / cell) + 1);
const o = own.uFlatO.value;
const s = { m, w, R, C, x0: o.x, y0: o.y, W, H, cw: W / (C - 1), ch: H / (R - 1), base: [N, N + R * C], atlas: own.uAtlas.value.toArray() };
s.lay = [0, 1].map((L) => ({ s, L, base: s.base[L], z: stripZ - L * P.layerGap, fib: L === 0 ? [1, 0] : [0, 1] }));
sheets.push(s);
N += 2 * R * C;
ground = Math.min(ground, o.y - H);
}
ground -= 8; // floor close below the strip, so fallen pieces and dust stay in view
const pos = new Float32Array(N * 3), prev = new Float32Array(N * 3), rest = new Float32Array(N * 3);
const inv = new Float32Array(N), stuck = new Uint8Array(N), valid = new Uint8Array(N);
const sheetOf = new Int16Array(N);
const matX = new Float32Array(N), matY = new Float32Array(N); // material coordinates, mm
const uv = new Float32Array(N * 2);
sheets.forEach((s, si) => {
for (let L = 0; L < 2; L++) for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) {
const k = s.base[L] + i * s.C + j;
const u = j / (s.C - 1), v = i / (s.R - 1);
uv[k * 2] = u; uv[k * 2 + 1] = v;
matX[k] = u * s.W; matY[k] = v * s.H;
rest[k * 3] = s.x0 + matX[k];
rest[k * 3 + 1] = s.y0 - matY[k];
rest[k * 3 + 2] = stripZ - L * P.layerGap;
valid[k] = onPapyrus(s.atlas, u, v) ? 1 : 0;
sheetOf[k] = si;
}
});
const validRest = valid.slice(); // crushing removes nodes; Reset brings them back
// ------------------------------------------------------------ constraints
// kinds: 0 structural along fiber, 1 structural across fiber, 2 shear, 3 bend, 4 glue, 5 fuse
const A = [], B = [], K = [], T = [], Lk = [];
const edge = new Map();
const add = (a, b, kind, thr) => {
if (!valid[a] || !valid[b]) return -1;
const id = A.length;
A.push(a); B.push(b); K.push(kind); T.push(thr); Lk.push(null);
edge.set(a < b ? a * N + b : b * N + a, id);
return id;
};
const eid = (a, b) => edge.get(a < b ? a * N + b : b * N + a) ?? -1;
const jit = () => 1 + (Math.random() * 2 - 1) * P.jitter;
const cosCrack = Math.cos((P.bendCrackDeg * Math.PI) / 180 / 2);
sheets.forEach((s) => {
s.c0 = A.length;
for (let L = 0; L < 2; L++) {
const id = (i, j) => s.base[L] + i * s.C + j;
// recto fibers run horizontally (along j); verso fibers vertically (along i).
// A layer cracks along its fibers, so the links across the fibers fail first.
const hStrain = L === 0 ? P.strainAcross : P.strainAlong;
const vStrain = L === 0 ? P.strainAlong : P.strainAcross;
for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) {
if (j + 1 < s.C) add(id(i, j), id(i, j + 1), L === 0 ? 0 : 1, 1 + hStrain * jit());
if (i + 1 < s.R) add(id(i, j), id(i + 1, j), L === 0 ? 1 : 0, 1 + vStrain * jit());
}
for (let i = 0; i + 1 < s.R; i++) for (let j = 0; j + 1 < s.C; j++) {
add(id(i, j), id(i + 1, j + 1), 2, 1 + 1.6 * P.strainAcross * jit());
add(id(i, j + 1), id(i + 1, j), 2, 1 + 1.6 * P.strainAcross * jit());
}
for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) {
if (j + 2 < s.C) add(id(i, j), id(i, j + 2), 3, cosCrack);
if (i + 2 < s.R) add(id(i, j), id(i + 2, j), 3, cosCrack);
}
}
for (let k = 0; k < s.R * s.C; k++) add(s.base[0] + k, s.base[1] + k, 4, P.glueGap * jit());
s.c1 = A.length;
});
const f0 = A.length;
for (let n = 0; n + 1 < sheets.length; n++) {
const s = sheets[n], q = sheets[n + 1];
for (let L = 0; L < 2; L++) for (let i = 0; i < s.R; i++) {
const y = s.y0 - (i / (s.R - 1)) * s.H;
const qi = Math.round(((q.y0 - y) / q.H) * (q.R - 1));
if (qi < 0 || qi >= q.R) continue;
add(s.base[L] + i * s.C + s.C - 1, q.base[L] + qi * q.C, 5, 1 + P.fuseStrain * jit());
}
}
const NC = A.length;
const ca = Int32Array.from(A), cb = Int32Array.from(B), kind = Uint8Array.from(K), thr = Float32Array.from(T);
const len0 = new Float32Array(NC), alive = new Uint8Array(NC);
// slow strain (own weight, steady sag) creeps into a baseline; brittle failure is driven by sudden strain
const creep = new Float32Array(NC);
for (let c = 0; c < NC; c++) {
const a = ca[c] * 3, b = cb[c] * 3;
len0[c] = Math.hypot(rest[a] - rest[b], rest[a + 1] - rest[b + 1], rest[a + 2] - rest[b + 2]);
}
// node -> in-layer links (structural, shear, bend), for crack crossing and stress sampling
const deg = new Int32Array(N + 1);
for (let c = 0; c < NC; c++) if (kind[c] <= 3) { deg[ca[c] + 1]++; deg[cb[c] + 1]++; }
for (let i = 0; i < N; i++) deg[i + 1] += deg[i];
const adj = new Int32Array(deg[N]);
{ const f = deg.slice(); for (let c = 0; c < NC; c++) if (kind[c] <= 3) { adj[f[ca[c]]++] = c; adj[f[cb[c]]++] = c; } }
// ------------------------------------------------------------ rendering
const group = new THREE.Group();
group.visible = false;
scene.add(group);
const VERT = /* glsl */`
attribute vec2 aLocal;
attribute float aKind;
varying vec2 vLocal; varying vec3 vN; varying vec3 vW; varying float vE; varying float vRim; varying float vStrand;
void main() {
vLocal = aLocal; vN = normalize(normal); vE = 1.0;
vRim = aKind == 2.0 ? 1.0 : 0.0; vStrand = aKind == 1.0 ? 1.0 : 0.0;
vec4 w = modelMatrix * vec4(position, 1.0); vW = w.xyz;
gl_Position = projectionMatrix * viewMatrix * w;
}`;
// recto: the page's own shader (ink and X-ray overlays), plus dark torn edge faces and fiber strands
const RECTO = FRAG
.replace('varying float vE;', 'varying float vE;\nvarying float vRim;\nvarying float vStrand;')
.replace('if (ct < 0.012 && ink < 0.02) discard;', 'if (ct < 0.012 && ink < 0.02 && vRim < 0.5) discard;')
.replace('gl_FragColor = vec4(col, 1.0);',
'col = mix(col, papy * 0.8 + vec3(0.03, 0.022, 0.015), vStrand * 0.7);\n col = mix(col, vec3(0.022, 0.016, 0.012) * (0.6 + 0.4 * diff), vRim);\n gl_FragColor = vec4(col, 1.0);');
// verso: matte brown-black charcoal with a faint sheen, fibers from the CT
const VERSO = DMG_HEAD_T + /* glsl */`
uniform sampler2D uCT; uniform vec4 uAtlas;
varying vec2 vLocal; varying vec3 vN; varying vec3 vW; varying float vRim; varying float vStrand;
void main() {
vec2 auv = uAtlas.xy + vLocal * (uAtlas.zw - uAtlas.xy);
float ct = texture2D(uCT, auv).r;
if (ct < 0.012 && vRim < 0.5) discard;
vec3 n = normalize(vN), v = normalize(cameraPosition - vW), l = normalize(vec3(-0.35, 0.75, 0.55));
${DMG_CUT_T}
float diff = 0.25 + 0.75 * abs(dot(n, l));
float spec = pow(max(dot(reflect(-l, n * sign(dot(n, v))), v), 0.0), 24.0) * 0.10;
vec3 col = mix(vec3(0.018, 0.013, 0.010), vec3(0.16, 0.11, 0.075), pow(ct, 1.4)) * diff + spec;
col = mix(col, col * 1.25 + vec3(0.02, 0.014, 0.01), vStrand);
col = mix(col, vec3(0.012, 0.009, 0.007) * (0.6 + 0.4 * diff), vRim);
${DMG_EDGE_T}
gl_FragColor = vec4(col, 1.0);
}`;
const mats = (s, L) => {
const own = s.m.userData.own;
return L === 0
? new THREE.ShaderMaterial({ uniforms: { ...shared, uAtlas: own.uAtlas, uGpuTex: own.uGpuTex, uGpuOn: own.uGpuOn, uHi: { value: 0 }, uDmg: own.uDmg, uDmgOn: own.uDmgOn, uDmgMM: own.uDmgMM }, vertexShader: VERT, fragmentShader: RECTO, side: THREE.DoubleSide })
: new THREE.ShaderMaterial({ uniforms: { uCT: shared.uCT, uAtlas: own.uAtlas, uDmg: own.uDmg, uDmgOn: own.uDmgOn, uDmgMM: own.uDmgMM, uDmgT: shared.uDmgT }, vertexShader: VERT, fragmentShader: VERSO, side: THREE.DoubleSide });
};
// coarse mesh: the physics grid itself, used until a sheet cracks
for (const s of sheets) for (const lay of s.lay) {
const n = s.R * s.C, b = lay.base;
const geo = new THREE.BufferGeometry();
const pa = new THREE.BufferAttribute(pos.subarray(b * 3, (b + n) * 3), 3);
pa.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position', pa);
geo.setAttribute('aLocal', new THREE.BufferAttribute(uv.slice(b * 2, (b + n) * 2), 2));
geo.setAttribute('aKind', new THREE.BufferAttribute(new Float32Array(n), 1));
const ix = [];
for (let i = 0; i + 1 < s.R; i++) for (let j = 0; j + 1 < s.C; j++) {
const p00 = i * s.C + j, p01 = p00 + 1, p10 = p00 + s.C, p11 = p10 + 1;
if (!valid[b + p00] || !valid[b + p01] || !valid[b + p10] || !valid[b + p11]) continue;
ix.push(p00, p10, p01, p01, p10, p11);
}
geo.setIndex(ix);
const mesh = new THREE.Mesh(geo, mats(s, lay.L));
mesh.frustumCulled = false;
mesh.userData = { lay };
group.add(mesh);
lay.coarse = mesh;
}
// ------------------------------------------------------------ fine render mesh, cut along cracks
// A triangle soup in material space (mm). Each vertex is skinned to physics nodes on its own side of
// every crack: bilinear in its cell when all four corners are reachable without crossing a crack,
// otherwise affine on the three nearest reachable nodes, otherwise rigidly to the nearest one.
// kinds: 0 face, 1 fiber strand, 2 torn edge face (offset along the normal: gives thickness)
const cellOfXY = (s, x, y) => {
const ci = Math.min(s.R - 2, Math.max(0, Math.floor(y / s.ch)));
const cj = Math.min(s.C - 2, Math.max(0, Math.floor(x / s.cw)));
return ci * (s.C - 1) + cj;
};
function makeFine(lay) {
const s = lay.s;
const nx = Math.max(8, Math.round(s.W / P.renderCell)), ny = Math.max(8, Math.round(s.H / P.renderCell));
const cap = nx * ny * 2 + 24000;
const F = {
n: 0, cap,
tm: new Float32Array(cap * 6), // material xy of 3 vertices
ref: new Float32Array(cap * 2), // the point whose side decides the binding
kd: new Uint8Array(cap),
off: new Float32Array(cap * 3), // per vertex: thickness offset along the normal
bi: new Int32Array(cap * 12), bw: new Float32Array(cap * 12), bo: new Float32Array(cap * 9),
nsg: new Float32Array(cap * 3), // per vertex: sign so the edge face goes toward the back
cellTris: Array.from({ length: (s.R - 1) * (s.C - 1) }, () => []),
dirty: new Set(),
};
const geo = new THREE.BufferGeometry();
F.pa = new THREE.BufferAttribute(new Float32Array(cap * 9), 3).setUsage(THREE.DynamicDrawUsage);
F.la = new THREE.BufferAttribute(new Float32Array(cap * 6), 2).setUsage(THREE.DynamicDrawUsage);
F.ka = new THREE.BufferAttribute(new Float32Array(cap * 3), 1).setUsage(THREE.DynamicDrawUsage);
F.na = new THREE.BufferAttribute(new Float32Array(cap * 9), 3).setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('normal', F.na);
geo.setAttribute('position', F.pa); geo.setAttribute('aLocal', F.la); geo.setAttribute('aKind', F.ka);
F.geo = geo;
const mesh = new THREE.Mesh(geo, lay.coarse.material);
mesh.frustumCulled = false;
mesh.userData = { lay, fine: true };
group.add(mesh);
F.mesh = mesh;
lay.F = F;
const dx = s.W / nx, dy = s.H / ny;
for (let i = 0; i < ny; i++) for (let j = 0; j < nx; j++) {
const x0 = j * dx, y0 = i * dy, x1 = x0 + dx, y1 = y0 + dy;
addTri(lay, x0, y0, x0, y1, x1, y0, 0);
addTri(lay, x1, y0, x0, y1, x1, y1, 0);
}
for (let t = 0; t < F.n; t++) bindTri(lay, t);
F.attrsDirty = true;
lay.coarse.visible = false;
}
function addTri(lay, ax, ay, bx, by, cx, cy, k, rx, ry, offs) {
const F = lay.F, s = lay.s;
if (F.n >= F.cap) return -1;
const t = F.n++;
setTri(lay, t, ax, ay, bx, by, cx, cy, k, rx, ry, offs);
// register in every physics cell the triangle overlaps
const ci0 = Math.max(0, Math.floor(Math.min(ay, by, cy) / s.ch)), ci1 = Math.min(s.R - 2, Math.floor(Math.max(ay, by, cy) / s.ch));
const cj0 = Math.max(0, Math.floor(Math.min(ax, bx, cx) / s.cw)), cj1 = Math.min(s.C - 2, Math.floor(Math.max(ax, bx, cx) / s.cw));
for (let i = ci0; i <= ci1; i++) for (let j = cj0; j <= cj1; j++) F.cellTris[i * (s.C - 1) + j].push(t);
return t;
}
function setTri(lay, t, ax, ay, bx, by, cx, cy, k, rx, ry, offs) {
const F = lay.F, s = lay.s, o = t * 6;
F.tm[o] = ax; F.tm[o + 1] = ay; F.tm[o + 2] = bx; F.tm[o + 3] = by; F.tm[o + 4] = cx; F.tm[o + 5] = cy;
F.ref[t * 2] = rx ?? (ax + bx + cx) / 3; F.ref[t * 2 + 1] = ry ?? (ay + by + cy) / 3;
F.kd[t] = k;
F.off[t * 3] = offs ? offs[0] : 0; F.off[t * 3 + 1] = offs ? offs[1] : 0; F.off[t * 3 + 2] = offs ? offs[2] : 0;
const la = F.la.array;
for (let v = 0; v < 3; v++) { la[o + v * 2] = F.tm[o + v * 2] / s.W; la[o + v * 2 + 1] = F.tm[o + v * 2 + 1] / s.H; F.ka.array[t * 3 + v] = k; }
F.attrsDirty = true;
}
const node = (s, L, i, j) => s.base[L] + i * s.C + j;
function reach(lay, x, y, n) { return valid[n] && !crossesCrack(lay, x, y, matX[n], matY[n]); }
function bindTri(lay, t) {
const F = lay.F, s = lay.s, L = lay.L;
const rx = F.ref[t * 2], ry = F.ref[t * 2 + 1];
const ci = Math.min(s.R - 2, Math.max(0, Math.floor(ry / s.ch))), cj = Math.min(s.C - 2, Math.max(0, Math.floor(rx / s.cw)));
const c4 = [node(s, L, ci, cj), node(s, L, ci, cj + 1), node(s, L, ci + 1, cj), node(s, L, ci + 1, cj + 1)];
const o12 = t * 12, o9 = t * 9, o3 = t * 3;
const noCrack = !lay.nseg;
if (c4.every((n) => (noCrack ? valid[n] : reach(lay, rx, ry, n)))) {
for (let v = 0; v < 3; v++) {
const fx = F.tm[t * 6 + v * 2] / s.cw - cj, fy = F.tm[t * 6 + v * 2 + 1] / s.ch - ci;
const w = [(1 - fx) * (1 - fy), fx * (1 - fy), (1 - fx) * fy, fx * fy];
for (let q = 0; q < 4; q++) { F.bi[o12 + v * 4 + q] = c4[q]; F.bw[o12 + v * 4 + q] = w[q]; }
F.bo[o9 + v * 3] = F.bo[o9 + v * 3 + 1] = F.bo[o9 + v * 3 + 2] = 0;
F.nsg[o3 + v] = 1; // corners in order c00, c01, c10: rest normal is -z, the back
}
return;
}
// nearest reachable nodes in the 4 x 4 block around the cell
const cand = [];
for (let i = ci - 1; i <= ci + 2; i++) for (let j = cj - 1; j <= cj + 2; j++) {
if (i < 0 || j < 0 || i >= s.R || j >= s.C) continue;
const n = node(s, L, i, j);
if (!reach(lay, rx, ry, n)) continue;
cand.push([n, (matX[n] - rx) ** 2 + (matY[n] - ry) ** 2]);
}
cand.sort((a, b) => a[1] - b[1]);
let a = -1, b = -1, c = -1;
if (cand.length) a = cand[0][0];
if (cand.length > 1) b = cand[1][0];
for (let q = 2; q < cand.length && c < 0; q++) {
const n = cand[q][0];
const ar = (matX[b] - matX[a]) * (matY[n] - matY[a]) - (matY[b] - matY[a]) * (matX[n] - matX[a]);
if (Math.abs(ar) > 0.25 * s.cw * s.ch) c = n;
}
for (let v = 0; v < 3; v++) {
const x = F.tm[t * 6 + v * 2], y = F.tm[t * 6 + v * 2 + 1];
const bi = o12 + v * 4;
F.bw[bi] = F.bw[bi + 1] = F.bw[bi + 2] = F.bw[bi + 3] = 0;
F.bi[bi] = F.bi[bi + 1] = F.bi[bi + 2] = F.bi[bi + 3] = Math.max(0, a);
F.bo[o9 + v * 3] = F.bo[o9 + v * 3 + 1] = F.bo[o9 + v * 3 + 2] = 0;
F.nsg[o3 + v] = 0;
if (c >= 0) {
const x1 = matX[a], y1 = matY[a], x2 = matX[b], y2 = matY[b], x3 = matX[c], y3 = matY[c];
const den = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3);
const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / den;
const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / den;
F.bi[bi] = a; F.bi[bi + 1] = b; F.bi[bi + 2] = c;
F.bw[bi] = l1; F.bw[bi + 1] = l2; F.bw[bi + 2] = 1 - l1 - l2;
// orient the edge-face offset toward the back at rest
const ux = rest[b * 3] - rest[a * 3], uy = rest[b * 3 + 1] - rest[a * 3 + 1];
const vx = rest[c * 3] - rest[a * 3], vy = rest[c * 3 + 1] - rest[a * 3 + 1];
F.nsg[o3 + v] = ux * vy - uy * vx < 0 ? 1 : -1;
} else if (a >= 0) {
// rigid: follow the nearest node on this side
F.bw[bi] = 1;
F.bo[o9 + v * 3] = s.x0 + x - rest[a * 3];
F.bo[o9 + v * 3 + 1] = s.y0 - y - rest[a * 3 + 1];
F.bo[o9 + v * 3 + 2] = lay.z - rest[a * 3 + 2];
}
// no reachable node: weights stay 0, the triangle collapses out of sight
}
}
function skin(lay) {
const F = lay.F, p = F.pa.array;
const nv = F.n * 3;
for (let v = 0; v < nv; v++) {
const bi = v * 4;
const n0 = F.bi[bi] * 3, n1 = F.bi[bi + 1] * 3, n2 = F.bi[bi + 2] * 3, n3 = F.bi[bi + 3] * 3;
const w0 = F.bw[bi], w1 = F.bw[bi + 1], w2 = F.bw[bi + 2], w3 = F.bw[bi + 3];
let x = w0 * pos[n0] + w1 * pos[n1] + w2 * pos[n2] + w3 * pos[n3] + F.bo[v * 3];
let y = w0 * pos[n0 + 1] + w1 * pos[n1 + 1] + w2 * pos[n2 + 1] + w3 * pos[n3 + 1] + F.bo[v * 3 + 1];
let z = w0 * pos[n0 + 2] + w1 * pos[n1 + 2] + w2 * pos[n2 + 2] + w3 * pos[n3 + 2] + F.bo[v * 3 + 2];
const off = F.off[v];
if (off && F.nsg[v]) {
const ux = pos[n1] - pos[n0], uy = pos[n1 + 1] - pos[n0 + 1], uz = pos[n1 + 2] - pos[n0 + 2];
const vx = pos[n2] - pos[n0], vy = pos[n2 + 1] - pos[n0 + 1], vz = pos[n2 + 2] - pos[n0 + 2];
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
const l = Math.hypot(nx, ny, nz) || 1, k = (off * F.nsg[v]) / l;
x += nx * k; y += ny * k; z += nz * k;
} else if (off) z -= off;
p[v * 3] = x; p[v * 3 + 1] = y; p[v * 3 + 2] = z;
}
// guard: a triangle stretched past 2.2x its rest size, or with a bad number, is a binding glitch: hide it.
// Then flat normals, written straight into the attribute (cheaper than computeVertexNormals).
const m = F.tm, nr = F.na.array;
for (let t = 0; t < F.n; t++) {
const o = t * 9, q = t * 6;
const thick = F.kd[t] === 2 ? P.thickness : 0;
let bad = F.kd[t] === 3; // crushed away
// bound to a node that is gone (crushed or retired as debris) or to nothing at all: that node no longer moves,
// so the triangle would hang frozen in mid-air
for (let q = t * 12, qe = q + 12; q < qe && !bad; q++) if (F.bw[q] !== 0 && !valid[F.bi[q]]) bad = true;
if (!bad && F.bw[t * 12] === 0 && F.bw[t * 12 + 1] === 0 && F.bw[t * 12 + 2] === 0 && F.bw[t * 12 + 3] === 0) bad = true;
for (let e = 0; e < 3 && !bad; e++) {
const i = e, j = e === 2 ? 0 : e + 1;
const rx = m[q + i * 2] - m[q + j * 2], ry = m[q + i * 2 + 1] - m[q + j * 2 + 1];
const r = Math.sqrt(rx * rx + ry * ry) + thick;
const dx = p[o + i * 3] - p[o + j * 3], dy = p[o + i * 3 + 1] - p[o + j * 3 + 1], dz = p[o + i * 3 + 2] - p[o + j * 3 + 2];
const lim = 2.2 * r + 0.05; // brittle: real carbon never stretches this far, so it is a glitch
if (!(dx * dx + dy * dy + dz * dz <= lim * lim)) bad = true; // also catches NaN
}
if (bad) {
if (!Number.isFinite(p[o] + p[o + 1] + p[o + 2])) { p[o] = p[o + 1] = p[o + 2] = 0; }
for (let v = 1; v < 3; v++) { p[o + v * 3] = p[o]; p[o + v * 3 + 1] = p[o + 1]; p[o + v * 3 + 2] = p[o + 2]; }
}
const ux = p[o + 3] - p[o], uy = p[o + 4] - p[o + 1], uz = p[o + 5] - p[o + 2];
const vx = p[o + 6] - p[o], vy = p[o + 7] - p[o + 1], vz = p[o + 8] - p[o + 2];
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
nx /= l; ny /= l; nz /= l;
nr[o] = nr[o + 3] = nr[o + 6] = nx; nr[o + 1] = nr[o + 4] = nr[o + 7] = ny; nr[o + 2] = nr[o + 5] = nr[o + 8] = nz;
}
F.na.needsUpdate = true;
F.pa.needsUpdate = true;
F.geo.setDrawRange(0, nv);
if (F.attrsDirty) { F.la.needsUpdate = true; F.ka.needsUpdate = true; F.attrsDirty = false; }
}
// ------------------------------------------------------------ cracks
// Per layer: segments in material space, hashed by physics cell, and growing tips.
const segX = (ax, ay, bx, by, cx, cy, dx, dy) => {
const d1 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx);
const d2 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx);
if ((d1 > 0) === (d2 > 0) || d1 === 0 || d2 === 0) return false;
const d3 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
const d4 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax);
return (d3 > 0) !== (d4 > 0) && d3 !== 0 && d4 !== 0;
};
function hashCells(s, ax, ay, bx, by, pad, fn) {
const ci0 = Math.max(0, Math.floor(Math.min(ay, by) / s.ch) - pad), ci1 = Math.min(s.R - 2, Math.floor(Math.max(ay, by) / s.ch) + pad);
const cj0 = Math.max(0, Math.floor(Math.min(ax, bx) / s.cw) - pad), cj1 = Math.min(s.C - 2, Math.floor(Math.max(ax, bx) / s.cw) + pad);
for (let i = ci0; i <= ci1; i++) for (let j = cj0; j <= cj1; j++) fn(i * (s.C - 1) + j, i, j);
}
function crossesCrack(lay, ax, ay, bx, by) {
if (!lay.nseg) return false;
const s = lay.s, sg = lay.seg;
let hit = false;
hashCells(s, ax, ay, bx, by, 0, (c) => {
if (hit) return;
const l = lay.hash[c];
if (!l) return;
for (let q = 0; q < l.length; q++) {
const o = l[q] * 4;
if (segX(ax, ay, bx, by, sg[o], sg[o + 1], sg[o + 2], sg[o + 3])) { hit = true; return; }
}
});
return hit;
}
function firstCrossing(lay, ax, ay, bx, by) {
// nearest point where a new step would run into an existing crack (cracks join, they do not cross)
if (!lay.nseg) return null;
const s = lay.s, sg = lay.seg;
let best = null, bt = 2;
hashCells(s, ax, ay, bx, by, 0, (c) => {
const l = lay.hash[c];
if (!l) return;
for (const q of l) {
const o = q * 4;
if (!segX(ax, ay, bx, by, sg[o], sg[o + 1], sg[o + 2], sg[o + 3])) continue;
const ex = bx - ax, ey = by - ay, fx = sg[o + 2] - sg[o], fy = sg[o + 3] - sg[o + 1];
const den = ex * fy - ey * fx;
const tt = ((sg[o] - ax) * fy - (sg[o + 1] - ay) * fx) / den;
if (tt < bt) { bt = tt; best = [ax + ex * tt, ay + ey * tt]; }
}
});
return best;
}
function worldAt(lay, x, y) {
const s = lay.s, L = lay.L;
const ci = Math.min(s.R - 2, Math.max(0, Math.floor(y / s.ch))), cj = Math.min(s.C - 2, Math.max(0, Math.floor(x / s.cw)));
const fx = x / s.cw - cj, fy = y / s.ch - ci;
const n = [node(s, L, ci, cj), node(s, L, ci, cj + 1), node(s, L, ci + 1, cj), node(s, L, ci + 1, cj + 1)];
const w = [(1 - fx) * (1 - fy), fx * (1 - fy), (1 - fx) * fy, fx * fy];
const r = [0, 0, 0];
for (let q = 0; q < 4; q++) for (let d = 0; d < 3; d++) r[d] += w[q] * pos[n[q] * 3 + d];
return r;
}
function splitTri(lay, t, px, py, nx, ny, ax, ay, bx, by) {
// cut triangle t by the line of segment a-b, if the segment actually reaches into it
const F = lay.F, o = t * 6, m = F.tm;
const d = [0, 1, 2].map((v) => (m[o + v * 2] - px) * nx + (m[o + v * 2 + 1] - py) * ny);
const eps = 1e-5;
if ((d[0] > eps && d[1] > eps && d[2] > eps) || (d[0] < -eps && d[1] < -eps && d[2] < -eps)) return;
// segment vs triangle overlap: an endpoint inside, or crossing an edge
const V = [[m[o], m[o + 1]], [m[o + 2], m[o + 3]], [m[o + 4], m[o + 5]]];
const inside = (x, y) => {
const s1 = (V[1][0] - V[0][0]) * (y - V[0][1]) - (V[1][1] - V[0][1]) * (x - V[0][0]);
const s2 = (V[2][0] - V[1][0]) * (y - V[1][1]) - (V[2][1] - V[1][1]) * (x - V[1][0]);
const s3 = (V[0][0] - V[2][0]) * (y - V[2][1]) - (V[0][1] - V[2][1]) * (x - V[2][0]);
return (s1 >= 0 && s2 >= 0 && s3 >= 0) || (s1 <= 0 && s2 <= 0 && s3 <= 0);
};
if (!inside(ax, ay) && !inside(bx, by) &&
!segX(ax, ay, bx, by, V[0][0], V[0][1], V[1][0], V[1][1]) &&
!segX(ax, ay, bx, by, V[1][0], V[1][1], V[2][0], V[2][1]) &&
!segX(ax, ay, bx, by, V[2][0], V[2][1], V[0][0], V[0][1])) return;
// lone vertex on one side
const sg = d.map((x) => (x > eps ? 1 : x < -eps ? -1 : 0));
let lone = -1;
for (let v = 0; v < 3; v++) if (sg[v] !== 0 && sg[v] !== sg[(v + 1) % 3] && sg[v] !== sg[(v + 2) % 3]) lone = v;
if (lone < 0) return; // the line runs through a vertex: nothing to cut
const Lv = V[lone], X = V[(lone + 1) % 3], Y = V[(lone + 2) % 3];
const dl = d[lone], dX = d[(lone + 1) % 3], dY = d[(lone + 2) % 3];
const k = F.kd[t], offs = [F.off[t * 3 + lone], F.off[t * 3 + (lone + 1) % 3], F.off[t * 3 + (lone + 2) % 3]];
if (k !== 0) return; // only faces get cut; strands and edge faces stay whole
const lerp = (P0, P1, a, b) => { const tt = a / (a - b); return [P0[0] + (P1[0] - P0[0]) * tt, P0[1] + (P1[1] - P0[1]) * tt]; };
if (sg[(lone + 1) % 3] === 0 || sg[(lone + 2) % 3] === 0) {
// the line passes through one other vertex: split in two
const Z = sg[(lone + 1) % 3] === 0 ? Y : X;
const I = sg[(lone + 1) % 3] === 0 ? lerp(Lv, Y, dl, dY) : lerp(Lv, X, dl, dX);
if (sg[(lone + 1) % 3] === 0) { setTri(lay, t, Lv[0], Lv[1], X[0], X[1], I[0], I[1], 0); addTri(lay, I[0], I[1], X[0], X[1], Y[0], Y[1], 0); }
else { setTri(lay, t, Lv[0], Lv[1], I[0], I[1], Y[0], Y[1], 0); addTri(lay, I[0], I[1], X[0], X[1], Y[0], Y[1], 0); }
void Z; void offs;
return;
}
const I1 = lerp(Lv, X, dl, dX), I2 = lerp(Lv, Y, dl, dY);
setTri(lay, t, Lv[0], Lv[1], I1[0], I1[1], I2[0], I2[1], 0);
addTri(lay, I1[0], I1[1], X[0], X[1], Y[0], Y[1], 0);
addTri(lay, I1[0], I1[1], Y[0], Y[1], I2[0], I2[1], 0);
}
let segBudget = 0;
function addSeg(lay, ax, ay, bx, by) {
const s = lay.s;
if (lay.nseg > 2400) return; // a sheet that has cracked this much is in pieces already
if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); }
if (!lay.seg) { lay.seg = new Float32Array(4096); lay.nseg = 0; lay.hash = []; }
if (lay.nseg * 4 + 4 > lay.seg.length) { const g = new Float32Array(lay.seg.length * 2); g.set(lay.seg); lay.seg = g; }
if (damage && lay.L === 0) damage.tearMM(s.m, ax, ay, bx, by); // ragged, fibrous lips along the cut
const id = lay.nseg++, o = id * 4;
lay.seg[o] = ax; lay.seg[o + 1] = ay; lay.seg[o + 2] = bx; lay.seg[o + 3] = by;
hashCells(s, ax, ay, bx, by, 0, (c) => { (lay.hash[c] ||= []).push(id); });
// physics: every in-layer link the crack crosses breaks; the nodes on each side are free to part
const L = lay.L;
hashCells(s, ax, ay, bx, by, 1, (c, i, j) => {
for (const n of [node(s, L, i, j), node(s, L, i, j + 1), node(s, L, i + 1, j), node(s, L, i + 1, j + 1)]) {
for (let e = deg[n]; e < deg[n + 1]; e++) {
const l = adj[e];
if (!alive[l]) continue;
const p = ca[l], q = cb[l];
if (segX(ax, ay, bx, by, matX[p], matY[p], matX[q], matY[q])) {
alive[l] = 0;
}
}
}
});
// render: cut the faces along the segment, then rebind everything nearby
const F = lay.F;
const len = Math.hypot(bx - ax, by - ay) || 1e-6;
const nx = -(by - ay) / len, ny = (bx - ax) / len;
const near = new Set();
hashCells(s, ax, ay, bx, by, 0, (c) => { for (const t of F.cellTris[c]) near.add(t); });
for (const t of near) splitTri(lay, t, ax, ay, nx, ny, ax, ay, bx, by);
// dark torn edge faces on both sides, and fiber strands sticking out of the edge
for (const sd of [1, -1]) {
const rx = (ax + bx) / 2 + nx * sd * 0.06, ry = (ay + by) / 2 + ny * sd * 0.06;
addTri(lay, ax, ay, bx, by, bx, by, 2, rx, ry, [0, 0, P.thickness]);
addTri(lay, ax, ay, bx, by, ax, ay, 2, rx, ry, [0, P.thickness, P.thickness]);
const nStr = Math.random() < len * 1.6 ? 1 + (Math.random() < 0.35 ? 1 : 0) : 0;
for (let q = 0; q < nStr; q++) {
const tt = Math.random();
const bxp = ax + (bx - ax) * tt, byp = ay + (by - ay) * tt;
// fibers cross the gap along the layer's fiber direction when it cuts the edge, else straight out
let fx = lay.fib[0], fy = lay.fib[1];
const dn = fx * nx + fy * ny;
if (Math.abs(dn) < 0.3) { fx = nx; fy = ny; }
const sgn = (fx * nx + fy * ny) * -sd > 0 ? 1 : -1;
const ang = (Math.random() - 0.5) * 0.5;
const ex = (fx * Math.cos(ang) - fy * Math.sin(ang)) * sgn, ey = (fx * Math.sin(ang) + fy * Math.cos(ang)) * sgn;
const Lf = 0.25 + Math.random() * Math.random() * 1.3, wd = 0.05 + Math.random() * 0.09;
const tx = (bx - ax) / len, ty = (by - ay) / len;
addTri(lay, bxp - tx * wd, byp - ty * wd, bxp + tx * wd, byp + ty * wd, bxp + ex * Lf, byp + ey * Lf, 1,
bxp + nx * sd * 0.08, byp + ny * sd * 0.08);
}
}
hashCells(s, ax, ay, bx, by, 2, (c) => F.dirty.add(c));
// other layer may need its fine mesh rebound too (glue keeps it, nothing else to do)
// carbon flakes and crumbs along the crack, in proportion to its length
const nf = Math.floor(len * 0.9 + Math.random());
if (nf) {
const w = worldAt(lay, (ax + bx) / 2, (ay + by) / 2);
const k = node(s, L, Math.min(s.R - 1, Math.round(ay / s.ch)), Math.min(s.C - 1, Math.round(ax / s.cw))) * 3;
spawnDebris(w[0], w[1], w[2], (pos[k] - prev[k]) * 60, (pos[k + 1] - prev[k + 1]) * 60, (pos[k + 2] - prev[k + 2]) * 60, nf);
}
breaks++;
crackle(len);
}
function rebindDirty(lay) {
const F = lay.F;
if (!F || !F.dirty.size) return;
const seen = new Set();
for (const c of F.dirty) for (const t of F.cellTris[c]) if (!seen.has(t)) { seen.add(t); bindTri(lay, t); }
F.dirty.clear();
}
// one propagation step: jagged, wandering, biased along the fibers of the layer
function stepPoly(lay, tip, len) {
const s = lay.s;
let x1 = tip.x + tip.dx * len, y1 = tip.y + tip.dy * len;
let dead = false;
if (x1 < 0 || y1 < 0 || x1 > s.W || y1 > s.H) {
x1 = Math.min(s.W, Math.max(0, x1)); y1 = Math.min(s.H, Math.max(0, y1)); dead = true;
}
const j = firstCrossing(lay, tip.x, tip.y, x1, y1);
if (j) { x1 = j[0]; y1 = j[1]; dead = true; }
// fractal edge: split the step in three with decreasing sideways noise
const nx = -tip.dy, ny = tip.dx;
const a1 = (Math.random() - 0.5) * len * 0.36, a2 = (Math.random() - 0.5) * len * 0.36;
const pts = [[tip.x, tip.y],
[tip.x + (x1 - tip.x) / 3 + nx * a1, tip.y + (y1 - tip.y) / 3 + ny * a1],
[tip.x + (2 * (x1 - tip.x)) / 3 + nx * a2, tip.y + (2 * (y1 - tip.y)) / 3 + ny * a2],
[x1, y1]];
if (dead) { pts[1] = [tip.x + (x1 - tip.x) / 3, tip.y + (y1 - tip.y) / 3]; pts[2] = [tip.x + (2 * (x1 - tip.x)) / 3, tip.y + (2 * (y1 - tip.y)) / 3]; }
for (let q = 0; q < 3; q++) {
const [ax, ay] = pts[q], [bx, by] = pts[q + 1];
// a little finer wiggle inside each third
const mx = (ax + bx) / 2 + (Math.random() - 0.5) * 0.08, my = (ay + by) / 2 + (Math.random() - 0.5) * 0.08;
if (!dead && (crossesCrack(lay, ax, ay, mx, my) || crossesCrack(lay, mx, my, bx, by))) { tip.dead = true; addSeg(lay, ax, ay, bx, by); return; }
addSeg(lay, ax, ay, mx, my);
addSeg(lay, mx, my, bx, by);
}
tip.x = x1; tip.y = y1;
if (dead) tip.dead = true;
}
function stressAt(lay, x, y) {
// links around the tip: the worst one decides whether the crack runs, all of them its direction
const s = lay.s, L = lay.L;
const ci = Math.floor(y / s.ch), cj = Math.floor(x / s.cw);
let worst = 0, sxx = 0, sxy = 0, syy = 0;
const r2 = (1.6 * Math.max(s.cw, s.ch)) ** 2;
for (let i = ci - 1; i <= ci + 2; i++) for (let j = cj - 1; j <= cj + 2; j++) {
if (i < 0 || j < 0 || i >= s.R || j >= s.C) continue;
const n = node(s, L, i, j);
for (let e = deg[n]; e < deg[n + 1]; e++) {
const l = adj[e];
if (ca[l] !== n || !alive[l] || kind[l] > 2) continue;
const b = cb[l];
const mx = (matX[n] + matX[b]) / 2 - x, my = (matY[n] + matY[b]) / 2 - y;
if (mx * mx + my * my > r2) continue;
const ao = n * 3, bo = b * 3;
const dx = pos[bo] - pos[ao], dy = pos[bo + 1] - pos[ao + 1], dz = pos[bo + 2] - pos[ao + 2];
const st = Math.sqrt(dx * dx + dy * dy + dz * dz) / len0[l] - 1 - 0.9 * creep[l];
if (st <= 0) continue;
worst = Math.max(worst, st / (thr[l] - 1));
const ux = matX[b] - matX[n], uy = matY[b] - matY[n], ul = Math.hypot(ux, uy);
const wgt = st / (1 + (mx * mx + my * my) / r2);
sxx += wgt * ux * ux / (ul * ul); sxy += wgt * ux * uy / (ul * ul); syy += wgt * uy * uy / (ul * ul);
}
}
// principal tensile direction of the 2 x 2 tensor
const ang = 0.5 * Math.atan2(2 * sxy, sxx - syy);
return { worst, ex: Math.cos(ang), ey: Math.sin(ang) };
}
function turn(lay, tip, ex, ey) {
// crack runs perpendicular to the pull, keeps its heading, leans along the fibers, wanders
let cx = -ey, cy = ex;
if (cx * tip.dx + cy * tip.dy < 0) { cx = -cx; cy = -cy; }
let fx = lay.fib[0], fy = lay.fib[1];
if (fx * tip.dx + fy * tip.dy < 0) { fx = -fx; fy = -fy; }
const b = P.fiberBias;
let dx = cx * (1 - b) + fx * b + tip.dx * 0.9, dy = cy * (1 - b) + fy * b + tip.dy * 0.9;
const a = (Math.random() + Math.random() + Math.random() - 1.5) * P.turnJitter * 1.4;
const l = Math.hypot(dx, dy) || 1;
dx /= l; dy /= l;
tip.dx = dx * Math.cos(a) - dy * Math.sin(a); tip.dy = dx * Math.sin(a) + dy * Math.cos(a);
}
function seedCrack(lay, x, y, dx, dy) {
if (!lay.tips) lay.tips = [];
if (lay.tips.length > 160) return;
const l = Math.hypot(dx, dy) || 1;
dx /= l; dy /= l;
const a = { x, y, dx, dy }, b = { x, y, dx: -dx, dy: -dy };
lay.tips.push(a, b);
// the seed itself: a short step each way, so it crosses the overloaded link
stepPoly(lay, a, 0.5 + Math.random() * 0.4);
stepPoly(lay, b, 0.5 + Math.random() * 0.4);
}
function nearTip(lay, x, y, r) {
if (!lay.tips) return false;
for (const t of lay.tips) if (!t.dead && (t.x - x) ** 2 + (t.y - y) ** 2 < r * r) return true;
return false;
}
function growCracks() {
segBudget = P.stepBudget;
for (const s of sheets) {
if (!s.cracked) continue;
for (const lay of s.lay) {
if (!lay.tips) continue;
for (let q = 0; q < lay.tips.length && segBudget > 0; q++) {
const tip = lay.tips[q];
for (let k = 0; k < P.stepsPerTip && !tip.dead && segBudget > 0; k++) {
const st = stressAt(lay, tip.x, tip.y);
if (st.worst < P.tipGrow) break;
turn(lay, tip, st.ex, st.ey);
stepPoly(lay, tip, 0.35 + Math.random() * 0.55);
segBudget--;
if (Math.random() < P.branch && lay.tips.length < 160) {
const a = (Math.random() < 0.5 ? -1 : 1) * (0.45 + Math.random() * 0.45);
lay.tips.push({ x: tip.x, y: tip.y, dx: tip.dx * Math.cos(a) - tip.dy * Math.sin(a), dy: tip.dx * Math.sin(a) + tip.dy * Math.cos(a) });
}
}
}
lay.tips = lay.tips.filter((t) => !t.dead);
}
}
}
// ------------------------------------------------------------ debris: carbon flakes and dust
const FL = P.maxFlakes, DU = P.maxDust;
const fp = new Float32Array(FL * 3).fill(-1e5), fv = new Float32Array(FL * 3), fs = new Float32Array(FL), fph = new Float32Array(FL);
let fHead = 0;
const flakeGeo = new THREE.BufferGeometry();
flakeGeo.setAttribute('position', new THREE.BufferAttribute(fp, 3).setUsage(THREE.DynamicDrawUsage));
flakeGeo.setAttribute('aSize', new THREE.BufferAttribute(fs, 1).setUsage(THREE.DynamicDrawUsage));
flakeGeo.setAttribute('aPh', new THREE.BufferAttribute(fph, 1));
const flakes = new THREE.Points(flakeGeo, new THREE.ShaderMaterial({
uniforms: { uScale: { value: 1 } },
vertexShader: `attribute float aSize; attribute float aPh; varying float vPh; uniform float uScale;
void main(){ vPh = aPh; vec4 mv = modelViewMatrix * vec4(position,1.0); gl_Position = projectionMatrix * mv; gl_PointSize = aSize * uScale / -mv.z; }`,
fragmentShader: `varying float vPh;
void main(){ vec2 d = gl_PointCoord - 0.5; float a = atan(d.y, d.x) * 3.0 + vPh * 6.28;
float r = 0.36 + 0.12 * sin(a) + 0.06 * sin(a * 2.3); if (length(d) > r) discard;
vec3 c = mix(vec3(0.06,0.045,0.032), vec3(0.22,0.16,0.11), fract(vPh * 7.3)); gl_FragColor = vec4(c, 1.0); }`,
}));
flakes.frustumCulled = false;
group.add(flakes);
const dp = new Float32Array(DU * 3).fill(-1e5), dv = new Float32Array(DU * 3), dl = new Float32Array(DU);
let dHead = 0;
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute('position', new THREE.BufferAttribute(dp, 3).setUsage(THREE.DynamicDrawUsage));
dustGeo.setAttribute('aLife', new THREE.BufferAttribute(dl, 1).setUsage(THREE.DynamicDrawUsage));
const dust = new THREE.Points(dustGeo, new THREE.ShaderMaterial({
uniforms: { uScale: { value: 1 } }, transparent: true, depthWrite: false,
vertexShader: `attribute float aLife; varying float vL; uniform float uScale;
void main(){ vL = aLife; vec4 mv = modelViewMatrix * vec4(position,1.0); gl_Position = projectionMatrix * mv; gl_PointSize = (1.0 + (1.0 - aLife) * 2.5) * 9.0 * uScale / -mv.z; }`,
fragmentShader: `varying float vL;
void main(){ float d = length(gl_PointCoord - 0.5) * 2.0; float a = smoothstep(1.0, 0.0, d) * vL * 0.55; if (a < 0.01) discard;
gl_FragColor = vec4(vec3(0.34,0.29,0.24), a); }`,
}));
dust.frustumCulled = false;
group.add(dust);
let spawned = 0;
function spawnDebris(x, y, z, vx, vy, vz, n) {
for (let i = 0; i < n; i++) {
const k = fHead++ % FL;
fp[k * 3] = x + (Math.random() - 0.5) * cell; fp[k * 3 + 1] = y + (Math.random() - 0.5) * cell; fp[k * 3 + 2] = z + 0.5;
fv[k * 3] = vx * 0.4 + (Math.random() - 0.5) * 160; fv[k * 3 + 1] = vy * 0.4 + Math.random() * 120; fv[k * 3 + 2] = vz * 0.4 + Math.random() * 140;
fs[k] = 4 + Math.random() * Math.random() * 26; // mostly crumbs, a few flakes
fph[k] = Math.random();
}
if (Math.random() < 0.5) {
const k = dHead++ % DU;
dp[k * 3] = x; dp[k * 3 + 1] = y; dp[k * 3 + 2] = z + 1;
dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 10 + Math.random() * 20; dv[k * 3 + 2] = 20 + Math.random() * 25;
dl[k] = 1;
}
spawned += n;
}
function stepDebris(dt, time) {
for (let k = 0; k < FL; k++) {
const o = k * 3;
if (fp[o + 1] < -1e4) continue;
if (fp[o + 1] <= ground + 0.2) { // landed: shrink away (same hold and fade as everything else)
fp[o + 1] = ground + 0.2;
fs[k] *= Math.exp(-dt * 2.5); if (fs[k] < 0.6) fp[o + 1] = -1e5;
continue;
}
// tiny and light: fall fast at first, then terminal velocity with a little flutter
const drag = 2.5 + 40 / fs[k];
fv[o + 1] -= P.gravity * 0.35 * dt;
fv[o] += Math.sin(time * 9 + fph[k] * 40) * 260 * dt * (8 / fs[k]);
fv[o + 2] += Math.cos(time * 7 + fph[k] * 30) * 160 * dt * (8 / fs[k]);
const f = Math.exp(-drag * dt);
fv[o] *= f; fv[o + 1] *= f; fv[o + 2] *= f;
fp[o] += fv[o] * dt; fp[o + 1] += fv[o + 1] * dt; fp[o + 2] += fv[o + 2] * dt;
}
for (let k = 0; k < DU; k++) {
if (dl[k] <= 0) continue;
dl[k] -= dt * 0.55;
const o = k * 3, f = Math.exp(-1.8 * dt);
dv[o] *= f; dv[o + 1] *= f; dv[o + 2] *= f;
dp[o] += dv[o] * dt; dp[o + 1] += dv[o + 1] * dt; dp[o + 2] += dv[o + 2] * dt;
if (dp[o + 1] < ground) { dp[o + 1] = ground; dv[o] = dv[o + 1] = dv[o + 2] = 0; } // never through the floor
if (dl[k] <= 0) dp[o + 1] = -1e5;
}
flakeGeo.attributes.position.needsUpdate = true;
flakeGeo.attributes.aSize.needsUpdate = true;
dustGeo.attributes.position.needsUpdate = true;
dustGeo.attributes.aLife.needsUpdate = true;
}
// ------------------------------------------------------------ sound: soft crackle, off by default
let audio = null, soundOn = false, lastSnd = 0, pendingSnd = 0;
function crackle(n) {
if (!soundOn) return;
pendingSnd += n;
const now = performance.now();
if (now - lastSnd < 45) return;
lastSnd = now;
if (!audio) {
audio = new AudioContext();
const len = Math.round(audio.sampleRate * 0.06);
audio.buf = audio.createBuffer(1, len, audio.sampleRate);
const d = audio.buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 6) * (Math.random() < 0.3 ? 1 : 0.25);
}
const src = audio.createBufferSource();
src.buffer = audio.buf;
src.playbackRate.value = 0.7 + Math.random() * 0.8;
const bp = audio.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 2500 + Math.random() * 2500; bp.Q.value = 0.9;
const g = audio.createGain();
g.gain.value = Math.min(0.12, 0.025 + pendingSnd * 0.004);
src.connect(bp).connect(g).connect(audio.destination);
src.start();
pendingSnd = 0;
}
// ------------------------------------------------------------ simulation
const active = new Uint8Array(sheets.length);
const calm = new Uint16Array(sheets.length); // frames a sheet has been still: it sleeps after a second
let grabbed = -1;
const grabTarget = new THREE.Vector3();
const grabVel = new THREE.Vector3();
const hand = new THREE.Vector3();
let pinch = [], pinchOff = new Float32Array(0);
let breaks = 0;
const layOfNode = (k) => { const s = sheets[sheetOf[k]]; return s.lay[k >= s.base[1] ? 1 : 0]; };
function reset() {
pos.set(rest); prev.set(rest);
inv.fill(0); stuck.fill(1);
for (let c = 0; c < NC; c++) alive[c] = 1;
creep.fill(0);
active.fill(0);
fp.fill(-1e5); dp.fill(-1e5); dl.fill(0);
grabbed = -1; pinch = []; controls.enabled = true;
for (const s of sheets) {
s.cracked = false;
for (const lay of s.lay) {
if (lay.F) { group.remove(lay.F.mesh); lay.F.geo.dispose(); lay.F = null; }
lay.seg = null; lay.nseg = 0; lay.hash = null; lay.tips = null;
lay.coarse.visible = true;
lay.coarse.geometry.attributes.position.needsUpdate = true;
lay.coarse.geometry.computeVertexNormals();
}
}
stepDebris(0, 0);
valid.set(validRest);
clive.fill(0); cCount = 0; crumbs.count = 0;
}
function unstick(k) {
if (!stuck[k]) return;
stuck[k] = 0; inv[k] = 1;
active[sheetOf[k]] = 1;
calm[sheetOf[k]] = 0;
}
function breakLink(c) {
// glue and fuse links simply let go; in-layer links fail by starting a crack across them
if (!alive[c]) return;
const kd = kind[c];
if (kd >= 4) { alive[c] = 0; return; }
const lay = layOfNode(ca[c]);
const a = ca[c], b = cb[c];
let mx = (matX[a] + matX[b]) / 2, my = (matY[a] + matY[b]) / 2;
let ux = matX[b] - matX[a], uy = matY[b] - matY[a];
if (kd === 3) {
// a fold: crack across the bend near its middle node
mx = matX[a] + ux * (0.35 + Math.random() * 0.3); my = matY[a] + uy * (0.35 + Math.random() * 0.3);
}
mx += (Math.random() - 0.5) * 0.3; my += (Math.random() - 0.5) * 0.3;
seedCrack(lay, mx, my, -uy, ux);
if (alive[c]) alive[c] = 0; // the seed missed it (it ran through a node): let it go anyway
}
function shatterAt(k, speed) {
// impact: a small star of cracks from the point of contact
const lay = layOfNode(k);
const n = Math.min(3, 1 + Math.floor(speed / P.shatterSpeed));
for (let q = 0; q < n; q++) {
if (Math.random() > 0.6) continue;
const a = Math.random() * Math.PI * 2;
seedCrack(lay, matX[k] + Math.cos(a) * 0.4, matY[k] + Math.sin(a) * 0.4, Math.cos(a + 1.3), Math.sin(a + 1.3));
}
}
function integrate(h, time) {
const g = P.gravity * h * h, damp = Math.exp(-P.drag * h);
for (let si = 0; si < sheets.length; si++) {
if (!active[si]) continue;
const s = sheets[si], k0 = s.base[0], k1 = s.base[1] + s.R * s.C;
let vmax = 0;
for (let k = k0; k < k1; k++) {
if (!inv[k] || !valid[k]) continue;
const o = k * 3;
vmax = Math.max(vmax, Math.abs(pos[o] - prev[o]) + Math.abs(pos[o + 1] - prev[o + 1]) + Math.abs(pos[o + 2] - prev[o + 2]));
const vx = (pos[o] - prev[o]) * damp, vy = (pos[o + 1] - prev[o + 1]) * damp, vz = (pos[o + 2] - prev[o + 2]) * damp;
prev[o] = pos[o]; prev[o + 1] = pos[o + 1]; prev[o + 2] = pos[o + 2];
const fl = 0.00006 * Math.sin(time * 5 + k * 0.37);
pos[o] += vx + fl; pos[o + 1] += vy - g; pos[o + 2] += vz + fl * 0.6;
}
s.vmax = Math.max(s.vmax || 0, vmax);
}
}
const impacts = [];
function collide(h) {
for (let si = 0; si < sheets.length; si++) {
if (!active[si]) continue;
const s = sheets[si], k0 = s.base[0], k1 = s.base[1] + s.R * s.C;
for (let k = k0; k < k1; k++) {
if (!inv[k]) continue;
const o = k * 3;
if (pos[o + 2] < stripZ - 2) pos[o + 2] = stripZ - 2;
if (pos[o + 1] < ground) {
const vy = (pos[o + 1] - prev[o + 1]) / h;
pos[o + 1] = ground;
prev[o] = pos[o] - (pos[o] - prev[o]) * 0.3;
prev[o + 2] = pos[o + 2] - (pos[o + 2] - prev[o + 2]) * 0.3;
prev[o + 1] = ground;
if (-vy > 1.5 * P.shatterSpeed && impacts.length < 4) impacts.push([k, -vy]);
}
}
}
}
function solve() {
const run = (c0, c1) => {
for (let c = c0; c < c1; c++) {
if (!alive[c]) continue;
const a = ca[c], b = cb[c], wa = inv[a], wb = inv[b], ws = wa + wb;
if (ws === 0) continue;
const ao = a * 3, bo = b * 3;
const dx = pos[bo] - pos[ao], dy = pos[bo + 1] - pos[ao + 1], dz = pos[bo + 2] - pos[ao + 2];
const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-9;
const f = (d - len0[c]) / (d * ws);
pos[ao] += dx * f * wa; pos[ao + 1] += dy * f * wa; pos[ao + 2] += dz * f * wa;
pos[bo] -= dx * f * wb; pos[bo + 1] -= dy * f * wb; pos[bo + 2] -= dz * f * wb;
}
};
for (let si = 0; si < sheets.length; si++) if (active[si]) run(sheets[si].c0, sheets[si].c1);
run(f0, NC);
}
function check() {
let seeds = 0;
const test = (c0, c1) => {
for (let c = c0; c < c1; c++) {
if (!alive[c]) continue;
const a = ca[c], b = cb[c];
const kd = kind[c];
if (kd === 5 && !active[sheetOf[a]] && !active[sheetOf[b]]) continue;
const ao = a * 3, bo = b * 3;
const dx = pos[bo] - pos[ao], dy = pos[bo + 1] - pos[ao + 1], dz = pos[bo + 2] - pos[ao + 2];
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (kd === 3) {
if (d / len0[c] < thr[c] && seeds < 6) {
const lay = layOfNode(a);
if (!nearTip(lay, (matX[a] + matX[b]) / 2, (matY[a] + matY[b]) / 2, 2.5 * cell)) { breakLink(c); seeds++; }
}
continue;
}
if (kd === 4) {
if (d - len0[c] > thr[c]) alive[c] = 0; // delamination
else if (d - len0[c] > thr[c] * P.peelFrac) { if (stuck[a]) unstick(a); if (stuck[b]) unstick(b); }
continue;
}
const st0 = d / len0[c] - 1;
creep[c] += (Math.max(0, st0) - creep[c]) * 0.03;
const r = 1 + st0 - 0.9 * creep[c];
const fail = thr[c] - 1;
if (r - 1 > fail * P.peelFrac && (stuck[a] || stuck[b])) {
if (Math.random() < P.peelProb) { if (stuck[a]) unstick(a); if (stuck[b]) unstick(b); }
if (kd === 5) { if (!stuck[a]) unstick(b); if (!stuck[b]) unstick(a); }
}
if (r > thr[c]) {
if (kd === 5) { alive[c] = 0; continue; }
// a crack starts here unless a running crack tip is already close (it will get here)
const lay = layOfNode(a);
const near = nearTip(lay, (matX[a] + matX[b]) / 2, (matY[a] + matY[b]) / 2, 2.5 * cell);
if ((!near || r - 1 > 3 * fail) && seeds < 6) { breakLink(c); seeds++; }
}
}
};
for (let si = 0; si < sheets.length; si++) if (active[si]) test(sheets[si].c0, sheets[si].c1);
test(f0, NC);
}
// ------------------------------------------------------------ loose bits
// Cracks and crushing cut the sheet into pieces. A piece too small to read as papyrus (a few nodes, often a
// jagged clump of overlapping triangles) becomes crumbs that fall, settle and fade like all other debris; a
// small piece still glued in place by a stuck node lets go and falls, so nothing is left hanging in mid-air.
const ISLAND_MIN = 48; // nodes in both layers together
const compId = new Int32Array(N).fill(-1), queue = new Int32Array(N);
let islandsRetired = 0, islandsReleased = 0;
function sweepIslands(si, measure = false) {
let floating = 0;
const s = sheets[si], k0 = s.base[0], k1 = s.base[1] + s.R * s.C;
let total = 0;
for (let k = k0; k < k1; k++) { compId[k] = -1; if (valid[k]) total++; }
let tag = 0;
for (let k = k0; k < k1; k++) {
if (!valid[k] || compId[k] >= 0) continue;
let qh = 0, qt = 0, stuckN = 0, minY = Infinity;
queue[qt++] = k; compId[k] = tag;
while (qh < qt) {
const n = queue[qh++];
if (stuck[n]) stuckN++;
if (pos[n * 3 + 1] < minY) minY = pos[n * 3 + 1];
// in-layer structural and shear links, plus the glue to the other layer
for (let e = deg[n]; e < deg[n + 1]; e++) {
const c = adj[e]; if (!alive[c] || kind[c] > 2) continue;
const m = ca[c] === n ? cb[c] : ca[c];
if (valid[m] && compId[m] < 0) { compId[m] = tag; queue[qt++] = m; }
}
const other = n < s.base[1] ? n + (s.base[1] - s.base[0]) : n - (s.base[1] - s.base[0]);
if (valid[other] && compId[other] < 0) {
const c = eid(n, other);
if (c >= 0 && alive[c]) { compId[other] = tag; queue[qt++] = other; }
}
}
tag++;
if (!stuckN && minY > ground + 1) floating++; // a free piece entirely off the floor
if (measure) continue;
if (qt < ISLAND_MIN) {
// too small: turn it into crumbs at its own position and speed
let made = 0;
for (let q = 0; q < qt; q++) {
const n = queue[q], o = n * 3;
if (made < 12 && q % 3 === 0) {
const h = 1 / 60;
addCrumb(pos[o], pos[o + 1], pos[o + 2] + 0.2, (pos[o] - prev[o]) / h * 0.02, (pos[o + 1] - prev[o + 1]) / h * 0.02, (pos[o + 2] - prev[o + 2]) / h * 0.02,
0.3 + Math.random() * 0.6);
made++;
}
valid[n] = 0; inv[n] = 0; stuck[n] = 0;
for (let e = deg[n]; e < deg[n + 1]; e++) alive[adj[e]] = 0;
const c = eid(n, n < s.base[1] ? n + (s.base[1] - s.base[0]) : n - (s.base[1] - s.base[0]));
if (c >= 0) alive[c] = 0;
}
islandsRetired++; active[si] = 1; calm[si] = 0;
} else if (stuckN && qt < total * 0.12) {
// a small piece held only by a few glued nodes: free it so it falls to the floor
for (let q = 0; q < qt; q++) { const n = queue[q]; if (stuck[n]) { stuck[n] = 0; inv[n] = 1; } }
active[si] = 1; calm[si] = 0;
islandsReleased++;
}
}
return floating;
}
// audit: free pieces entirely off the floor on sheets that are asleep (they would hang in mid-air)
function hangingNodes() {
let n = 0;
for (let si = 0; si < sheets.length; si++) if (!active[si] && sheets[si].cracked) n += sweepIslands(si, true);
return n;
}
let time = 0, frameNo = 0;
let simMs = 0, lastMs = 0, prof = null;
function step(dt) {
const t0 = performance.now();
frameNo++;
dt = Math.min(dt, 1 / 30);
const h = dt / P.substeps;
for (let s = 0; s < P.substeps; s++) {
time += h;
integrate(h, time);
if (grabbed >= 0) {
hand.lerp(grabTarget, 0.5);
for (let n = 0; n < pinch.length; n++) {
const o = pinch[n] * 3;
prev[o] = pos[o]; prev[o + 1] = pos[o + 1]; prev[o + 2] = pos[o + 2];
pos[o] = hand.x + pinchOff[n * 3]; pos[o + 1] = hand.y + pinchOff[n * 3 + 1]; pos[o + 2] = hand.z + pinchOff[n * 3 + 2];
}
}
for (let i = 0; i < P.iters; i++) solve();
collide(h);
}
const tA = performance.now();
check();
const tB = performance.now();
for (const [k, v] of impacts) shatterAt(k, v);
impacts.length = 0;
growCracks();
const tC = performance.now();
// every half second, on sheets that have cracked: retire loose bits, free small held pieces
if (frameNo % 30 === 0) for (let si = 0; si < sheets.length; si++) if (sheets[si].cracked && (active[si] || sheets[si].islandDirty)) { sweepIslands(si); sheets[si].islandDirty = false; }
stepDebris(dt, time);
stepCrumbs(dt, time);
for (let si = 0; si < sheets.length; si++) {
const s = sheets[si];
if (active[si]) {
const still = (s.vmax || 0) < 0.004 && (grabbed < 0 || sheetOf[grabbed] !== si);
calm[si] = still ? calm[si] + 1 : 0;
s.vmax = 0;
}
const wake = active[si] || s.lay.some((l) => l.F && (l.F.dirty.size || l.F.attrsDirty));
if (!wake) continue;
// sleep only when still and nothing free is left off the floor (a piece caught at the top of a bounce keeps falling)
if (active[si] && calm[si] > 60) { const fl = sheets[si].cracked ? sweepIslands(si) : 0; if (calm[si] > 60 && !fl) { active[si] = 0; calm[si] = 0; } else if (fl) calm[si] = 0; }
for (const lay of s.lay) {
if (lay.F) {
rebindDirty(lay);
skin(lay);
} else {
const g = lay.coarse.geometry;
g.attributes.position.needsUpdate = true;
if ((frameNo + si) % 2 === 0 || !active[si]) g.computeVertexNormals();
}
}
}
const sc = (innerHeight * 0.5) / Math.tan((camera.fov * Math.PI) / 360);
flakes.material.uniforms.uScale.value = sc * 0.06;
dust.material.uniforms.uScale.value = sc * 0.06;
lastMs = performance.now() - t0;
prof = { phys: +(tA - t0).toFixed(2), check: +(tB - tA).toFixed(2), grow: +(tC - tB).toFixed(2), render: +(performance.now() - tC).toFixed(2) };
simMs = simMs * 0.95 + (performance.now() - t0) * 0.05;
}
// ------------------------------------------------------------ crush: mash it into flakes, crumbs and dust
// Carbonized papyrus does not tear like cloth under a press: it crumbles. A press removes a ragged disc of
// sheet and turns it into GPU-instanced flakes; flakes that land hard break into smaller fragments, and the
// smallest end as dust. Capped so phones keep their frame rate.
const CMAX = MOBILE ? 450 : 1400;
const cp = new Float32Array(CMAX * 3), cv = new Float32Array(CMAX * 3), csz = new Float32Array(CMAX);
const crot = new Float32Array(CMAX * 3), cav = new Float32Array(CMAX * 3), clive = new Uint8Array(CMAX), crest = new Uint8Array(CMAX);
let cCount = 0, crushOn = false, crushing = false, lastCrush = 0;
// settle rule shared with the main view (damage.js): lie still for REST_HOLD s, then fade out over REST_FADE s
const REST_HOLD = 1.0, REST_FADE = 1.2;
const crestT = new Float32Array(CMAX), cStill = new Float32Array(CMAX);
let crumbStillAirMax = 0;
const flakeShape = new THREE.BufferGeometry();
{
// an irregular, slightly bent flake: a fan of 7 triangles
const v = [0, 0, 0];
const n = 7;
for (let i = 0; i <= n; i++) {
const a = (i / n) * Math.PI * 2, r = 0.5 * (0.65 + 0.35 * Math.sin(i * 2.7 + 1.3) * Math.sin(i * 1.3));
v.push(Math.cos(a) * r, Math.sin(a) * r * 0.75, (i % 2) * 0.05);
}
const idx = [];
for (let i = 1; i <= n; i++) idx.push(0, i, i + 1);
flakeShape.setAttribute('position', new THREE.Float32BufferAttribute(v, 3));
flakeShape.setIndex(idx);
flakeShape.computeVertexNormals();
}
const crumbMat = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
vertexShader: /* glsl */`
varying vec3 vN; varying vec3 vW; varying float vTone;
void main() {
mat4 m = modelMatrix * instanceMatrix;
vec4 w = m * vec4(position, 1.0); vW = w.xyz;
vN = normalize(mat3(m) * normal);
vTone = fract(sin(dot(instanceMatrix[3].xyz, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
gl_Position = projectionMatrix * viewMatrix * w;
}`,
fragmentShader: /* glsl */`
varying vec3 vN; varying vec3 vW; varying float vTone;
void main() {
vec3 n = normalize(vN), v = normalize(cameraPosition - vW), l = normalize(vec3(-0.35, 0.75, 0.55));
n *= sign(dot(n, v));
float diff = 0.3 + 0.7 * max(dot(n, l), 0.0);
// black carbon with a faint glassy sheen on fresh fracture faces
float spec = pow(max(dot(reflect(-l, n), v), 0.0), 24.0) * 0.38;
vec3 base = mix(vec3(0.035, 0.026, 0.019), vec3(0.17, 0.12, 0.085), vTone);
diff += 0.25 * pow(max(1.0 - abs(dot(n, v)), 0.0), 2.0); // rim light so dark flakes read against the dark floor
gl_FragColor = vec4(base * diff + spec, 1.0);
}`,
});
const crumbs = new THREE.InstancedMesh(flakeShape, crumbMat, CMAX);
crumbs.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
crumbs.frustumCulled = false;
crumbs.count = 0;
group.add(crumbs);
const _m = new THREE.Matrix4(), _q = new THREE.Quaternion(), _e = new THREE.Euler(), _p = new THREE.Vector3(), _s = new THREE.Vector3();
function addCrumb(x, y, z, vx, vy, vz, size) {
let k = -1;
if (cCount < CMAX) k = cCount++;
else { for (let i = 0; i < CMAX; i++) if (!clive[i]) { k = i; break; } }
if (k < 0) k = Math.floor(Math.random() * CMAX); // full: recycle a random one (usually a settled crumb)
clive[k] = 1; crest[k] = 0; crestT[k] = 0; cStill[k] = 0;
cp[k * 3] = x; cp[k * 3 + 1] = y; cp[k * 3 + 2] = z;
cv[k * 3] = vx; cv[k * 3 + 1] = vy; cv[k * 3 + 2] = vz;
csz[k] = size;
crot[k * 3] = Math.random() * 6.3; crot[k * 3 + 1] = Math.random() * 6.3; crot[k * 3 + 2] = Math.random() * 6.3;
const spin = 4 + 10 / size;
cav[k * 3] = (Math.random() - 0.5) * spin; cav[k * 3 + 1] = (Math.random() - 0.5) * spin; cav[k * 3 + 2] = (Math.random() - 0.5) * spin;
}
function stepCrumbs(dt, time) {
if (!cCount) return;
for (let k = 0; k < cCount; k++) {
if (!clive[k]) { _m.makeScale(0, 0, 0); crumbs.setMatrixAt(k, _m); continue; }
const o = k * 3, sz = csz[k];
if (!crest[k]) {
// light and flat: quick fall, then terminal speed set by size, with a tumbling flutter
cv[o + 1] -= P.gravity * 0.45 * dt;
const drag = 1.5 + 2.2 / sz, f = Math.exp(-drag * dt);
cv[o] = cv[o] * f + Math.sin(time * 7 + k) * 40 * dt / sz;
cv[o + 1] *= f;
cv[o + 2] = cv[o + 2] * f + Math.cos(time * 5 + k * 1.7) * 30 * dt / sz;
cp[o] += cv[o] * dt; cp[o + 1] += cv[o + 1] * dt; cp[o + 2] += cv[o + 2] * dt;
crot[o] += cav[o] * dt; crot[o + 1] += cav[o + 1] * dt; crot[o + 2] += cav[o + 2] * dt;
if (cp[o + 1] < ground + sz * 0.1) {
const vy = -cv[o + 1];
cp[o + 1] = ground + sz * 0.1;
if (vy > 260 && sz > 0.45 && Math.random() < 0.7) {
// hits the floor hard: breaks into smaller fragments, the finest go up as dust
const n = 2 + Math.floor(Math.random() * 2);
for (let q = 0; q < n; q++) addCrumb(cp[o], cp[o + 1] + 0.1, cp[o + 2], (Math.random() - 0.5) * 120, vy * 0.15 * Math.random(), (Math.random() - 0.5) * 120, sz * (0.35 + Math.random() * 0.25));
spawnDust(cp[o], cp[o + 1], cp[o + 2]);
clive[k] = 0;
continue;
}
if (sz < 0.3) { spawnDust(cp[o], cp[o + 1], cp[o + 2]); clive[k] = 0; continue; }
// settle: lie flat, slide a little
crest[k] = 1;
crot[o] = Math.PI / 2 + (Math.random() - 0.5) * 0.3; crot[o + 1] = (Math.random() - 0.5) * 0.3;
cv[o] *= 0.2; cv[o + 2] *= 0.2;
}
} else {
if (Math.abs(cv[o]) + Math.abs(cv[o + 2]) > 1) {
cp[o] += cv[o] * dt; cp[o + 2] += cv[o + 2] * dt;
cv[o] *= Math.exp(-8 * dt); cv[o + 2] *= Math.exp(-8 * dt);
}
crestT[k] += dt;
if (crestT[k] > REST_HOLD + REST_FADE) { clive[k] = 0; _m.makeScale(0, 0, 0); crumbs.setMatrixAt(k, _m); continue; }
}
// audit: airborne and not moving
if (!crest[k]) { cStill[k] = Math.abs(cv[o]) + Math.abs(cv[o + 1]) + Math.abs(cv[o + 2]) < 0.05 ? cStill[k] + dt : 0; crumbStillAirMax = Math.max(crumbStillAirMax, cStill[k]); if (cStill[k] > 2.5) { clive[k] = 0; _m.makeScale(0, 0, 0); crumbs.setMatrixAt(k, _m); continue; } }
const fadeC = crest[k] ? Math.min(1, Math.max(0, 1 - (crestT[k] - REST_HOLD) / REST_FADE)) : 1;
_e.set(crot[o], crot[o + 1], crot[o + 2]);
_q.setFromEuler(_e);
_p.set(cp[o], cp[o + 1], cp[o + 2]);
_s.set(sz * fadeC, sz * fadeC, sz * fadeC);
_m.compose(_p, _q, _s);
crumbs.setMatrixAt(k, _m);
}
crumbs.count = cCount;
crumbs.instanceMatrix.needsUpdate = true;
}
function spawnDust(x, y, z) {
if (damage) { damage.dust(x, y, z); return; } // size-dependent drag and drift, shared with the rolled view
const k = dHead++ % DU;
dp[k * 3] = x; dp[k * 3 + 1] = y; dp[k * 3 + 2] = z + 0.5;
dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 8 + Math.random() * 20; dv[k * 3 + 2] = 10 + Math.random() * 25;
dl[k] = 1;
}
function crushAt(lay, k0, point, rScale = 1) {
const s = lay.s;
if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); }
const F = lay.F;
const mx = matX[k0] + (Math.random() - 0.5) * s.cw * 0.6, my = matY[k0] + (Math.random() - 0.5) * s.ch * 0.6;
// map the actual hit point into material space using the nearest node's displacement
const hx = mx + (point.x - pos[k0 * 3]) * 0.9, hy = my - (point.y - pos[k0 * 3 + 1]) * 0.9;
const R0 = (2.0 + Math.random() * 1.6) * rScale;
const ph1 = Math.random() * 6.3, ph2 = Math.random() * 6.3;
const rAt = (a) => R0 * (0.78 + 0.16 * Math.sin(3 * a + ph1) + 0.1 * Math.sin(7 * a + ph2) + 0.06 * Math.random());
// the visible hole is drawn by the damage mask in UV space: a smooth, fibrous edge with no triangle staircase.
// physics stays separate: the nodes inside still let go below
if (damage) damage.crushMM(s.m, hx, hy, R0 * 1.15);
const removed = Math.round((Math.PI * R0 * R0) / (P.renderCell * P.renderCell * 0.5));
// physics: nodes inside are gone; their links let go, the rim is free to flake further
for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) {
const n = node(s, lay.L, i, j);
if (!valid[n]) continue;
const dx = matX[n] - hx, dy = matY[n] - hy;
if (dx * dx + dy * dy > rAt(Math.atan2(dy, dx)) ** 2) continue;
valid[n] = 0; inv[n] = 0;
for (let e = deg[n]; e < deg[n + 1]; e++) alive[adj[e]] = 0;
}
hashCells(s, hx - R0, hy - R0, hx + R0, hy + R0, 2, (c) => F.dirty.add(c));
if (Math.random() < 0.5) {
const a = Math.random() * 6.3;
seedCrack(lay, hx + Math.cos(a) * R0, hy + Math.sin(a) * R0, Math.cos(a), Math.sin(a));
}
// the removed area becomes flakes and crumbs: a few big, many small (roughly area conserving)
const area = removed * P.renderCell * P.renderCell * 0.5;
let left = area;
const w = worldAt(lay, hx, hy);
let made = 0;
while (left > 0 && made < 40) {
const sz = (Math.random() < 0.12 ? 0.8 + Math.random() * 1.0 : 0.2 + Math.random() * 0.45) * rScale;
left -= sz * sz * 0.6;
const a = Math.random() * 6.3, r = Math.random() * R0;
addCrumb(w[0] + Math.cos(a) * r, w[1] + Math.sin(a) * r, w[2] + 0.3 + Math.random() * 0.4,
// charcoal powders under a press: pieces barely spring back, they slump out and fall
Math.cos(a) * (8 + Math.random() * 22), Math.random() * 12, 6 + Math.random() * 20, sz);
made++;
}
for (let q = 0; q < 8; q++) spawnDust(w[0] + (Math.random() - 0.5) * R0, w[1] + (Math.random() - 0.5) * R0, w[2]); // mostly black dust
active[sheetOf[k0]] = 1; calm[sheetOf[k0]] = 0; s.islandDirty = true;
crunch();
breaks++;
return made;
}
// crumbling sound: a short burst of fine crackles, only when Sound is on
function crunch() {
if (!soundOn) return;
for (let i = 0; i < 4; i++) setTimeout(() => { lastSnd = 0; crackle(2 + Math.random() * 3); }, i * 18 + Math.random() * 12);
}
const pickables = group.children;
// ------------------------------------------------------------ interaction
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
const plane = new THREE.Plane();
let lastMove = null;
function toNdc(e) { ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); }
function onDown(e) {
if (!group.visible || e.button > 0) return;
toNdc(e);
ray.setFromCamera(ndc, camera);
const pk = pickables.filter((m) => m.isMesh && m.visible);
for (const m of pk) m.geometry.computeBoundingSphere();
const hit = ray.intersectObjects(pk, false)[0];
if (!hit) return;
const lay = hit.object.userData.lay, s = lay.s, L = lay.L;
let best = -1, bd = Infinity;
for (let k = lay.base; k < lay.base + s.R * s.C; k++) {
if (!valid[k]) continue;
const o = k * 3;
const d = (pos[o] - hit.point.x) ** 2 + (pos[o + 1] - hit.point.y) ** 2 + (pos[o + 2] - hit.point.z) ** 2;
if (d < bd) { bd = d; best = k; }
}
if (best < 0) return;
if (crushOn) {
crushing = true; lastCrush = performance.now();
crushAt(lay, best, hit.point);
controls.enabled = false;
try { canvas.setPointerCapture(e.pointerId); } catch {} // synthetic API pointers have no capture
e.stopImmediatePropagation();
return;
}
// carbonized papyrus does not stretch or peel under fingers: it snaps and crumbles where it is grabbed
crushAt(lay, best, hit.point, 0.55);
for (let q = 0; q < 2; q++) { // and a snap runs off along the fibers of this layer
const along = L === 0 ? [1, 0] : [0, 1], sg = q ? -1 : 1;
seedCrack(lay, matX[best] + along[0] * sg * 1.2, matY[best] + along[1] * sg * 1.2, along[0] * sg, along[1] * sg);
}
if (!valid[best]) { // the grab point itself went to dust: hold the nearest piece that is left
let nb = -1, nd = Infinity;
for (let k = lay.base; k < lay.base + s.R * s.C; k++) { if (!valid[k]) continue; const d = (matX[k] - matX[best]) ** 2 + (matY[k] - matY[best]) ** 2; if (d < nd) { nd = d; nb = k; } }
if (nb < 0) return;
best = nb;
}
grabbed = best;
active[sheetOf[best]] = 1; calm[sheetOf[best]] = 0;
// pinch everything within about 1.6 nodes of the hit, on the layer that was touched
pinch = []; const offs = [];
// an irregular blob, longer along the layer's fibers, so the torn piece is not a neat disc
const bo = best * 3, fib = lay.fib, ph = Math.random() * 6.3;
for (let k = s.base[L]; k < s.base[L] + s.R * s.C; k++) {
if (!valid[k]) continue;
const o = k * 3, dx = pos[o] - pos[bo], dy = pos[o + 1] - pos[bo + 1], dz = pos[o + 2] - pos[bo + 2];
const along = Math.abs(dx * fib[0] - dy * fib[1]), across = Math.abs(dx * fib[1] + dy * fib[0]);
const a = Math.atan2(dy, dx);
const r = cell * (1.25 + 0.45 * Math.sin(2 * a + ph) + 0.3 * Math.sin(5 * a + ph * 2) + 0.35 * Math.random());
if ((along / 1.45) ** 2 + across ** 2 + dz * dz > r * r) continue;
pinch.push(k); offs.push(dx, dy, dz);
unstick(k); inv[k] = 0; // held by the pointer
}
pinchOff = Float32Array.from(offs);
hand.set(pos[bo], pos[bo + 1], pos[bo + 2]);
grabTarget.copy(hand);
plane.setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).negate(), hit.point);
controls.enabled = false;
try { canvas.setPointerCapture(e.pointerId); } catch {} // synthetic API pointers have no capture
lastMove = { t: performance.now(), p: hit.point.clone() };
grabVel.set(0, 0, 0);
e.stopImmediatePropagation();
}
function onMove(e) {
if (crushing) {
// dragging while crushing grinds along the path, a few presses per second
const now = performance.now();
if (now - lastCrush < 45) return;
lastCrush = now;
toNdc(e);
ray.setFromCamera(ndc, camera);
const pk = pickables.filter((m) => m.isMesh && m.visible && m !== crumbs);
for (const m of pk) m.geometry.computeBoundingSphere();
const hit = ray.intersectObjects(pk, false)[0];
if (!hit) return;
const lay = hit.object.userData.lay, s = lay.s;
let best = -1, bd = Infinity;
for (let k = lay.base; k < lay.base + s.R * s.C; k++) {
const o = k * 3;
const d = (pos[o] - hit.point.x) ** 2 + (pos[o + 1] - hit.point.y) ** 2 + (pos[o + 2] - hit.point.z) ** 2;
if (d < bd) { bd = d; best = k; }
}
if (best >= 0) crushAt(lay, best, hit.point);
return;
}
if (grabbed < 0) return;
toNdc(e);
ray.setFromCamera(ndc, camera);
const p = new THREE.Vector3();
if (!ray.ray.intersectPlane(plane, p)) return;
// keep the pull in front of the backing
p.z = Math.max(p.z, stripZ - 1);
const now = performance.now();
const dtm = Math.max(1, now - lastMove.t) / 1000;
grabVel.lerp(p.clone().sub(lastMove.p).divideScalar(dtm), 0.5);
lastMove = { t: now, p: p.clone() };
grabTarget.copy(p);
}
function onUp() {
if (crushing) { crushing = false; controls.enabled = true; return; }
if (grabbed < 0) return;
// a fling: hand the pointer velocity to the pinched patch (capped), the links decide what rips
const v = grabVel.clampLength(0, 4000);
const h = 1 / 60 / P.substeps;
for (const k of pinch) {
const o = k * 3;
inv[k] = 1;
prev[o] = pos[o] - v.x * h; prev[o + 1] = pos[o + 1] - v.y * h; prev[o + 2] = pos[o + 2] - v.z * h;
}
pinch = [];
grabbed = -1;
controls.enabled = true;
}
canvas.addEventListener('pointerdown', onDown, true);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);
function shake() {
// a knock on the table: a random set of nodes lets go and gets a kick
// only the sheets in view get knocked: keeps the frame rate up and the effect where you are looking
const cx = controls.target.x;
const near = sheets.map((s, si) => [Math.abs(s.x0 + s.W / 2 - cx), si]).sort((a, b) => a[0] - b[0]).slice(0, 4).map((x) => x[1]);
for (const si of near) {
const s = sheets[si];
const kick = 250 + Math.random() * 500;
for (let k = s.base[0]; k < s.base[1] + s.R * s.C; k++) {
if (!valid[k] || Math.random() > 0.05) continue;
unstick(k);
const o = k * 3, hh = 1 / 60 / P.substeps;
prev[o] = pos[o] - (Math.random() - 0.5) * kick * hh;
prev[o + 1] = pos[o + 1] - Math.random() * kick * 0.5 * hh;
prev[o + 2] = pos[o + 2] - Math.random() * kick * hh;
}
}
}
reset();
return {
group, step, reset, shake,
setCrush(v) { crushOn = v; },
setSound(v) { soundOn = v; if (v && audio && audio.state === 'suspended') audio.resume(); },
stats: () => ({ crumbs: clive.reduce((a, b) => a + b, 0), crumbStillAirMax: +crumbStillAirMax.toFixed(2), islandsRetired, islandsReleased, hanging: hangingNodes(), nodes: N, links: NC, activeSheets: active.reduce((a, b) => a + b, 0), simMs: +simMs.toFixed(2), lastMs: +lastMs.toFixed(2), prof, breaks, debris: spawned }),
isGrabbing: () => grabbed >= 0,
};
}