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