SharpMeow commited on
Commit
78b3d8f
·
1 Parent(s): 97b9983

Realism pass for carbonized papyrus, island and outlier prune for unroll morph, docs/REALISM.md, cache bust d

Browse files
Files changed (6) hide show
  1. README.md +2 -0
  2. app.js +91 -6
  3. damage.js +33 -27
  4. docs/REALISM.md +23 -0
  5. index.html +7 -7
  6. tear.js +23 -10
README.md CHANGED
@@ -46,6 +46,8 @@ Just for fun, not a real unrolling. Press **Destroy** (T) to tear (X), crush (C)
46
 
47
  **Crumble** (K, or `unrollView.crumble()` in the console) breaks the whole scroll up from its edges inward over about four seconds; the pieces are pulled in toward the middle and fall into a heap of dust on the floor. **Reset** restores the scroll and clears all damage, debris and the heap. Particle and fragment counts stay capped (about 1,800 particles, 600 fragments and 2,600 heap grains on desktop, less on phones).
48
 
 
 
49
  For testing, `unrollView.damageStats()` reports the counts, the floor height, `stillAirMax` (longest any debris hung still above the floor, should stay 0) and `stillMax`.
50
 
51
  ## Rebuilding the data
 
46
 
47
  **Crumble** (K, or `unrollView.crumble()` in the console) breaks the whole scroll up from its edges inward over about four seconds; the pieces are pulled in toward the middle and fall into a heap of dust on the floor. **Reset** restores the scroll and clears all damage, debris and the heap. Particle and fragment counts stay capped (about 1,800 particles, 600 fragments and 2,600 heap grains on desktop, less on phones).
48
 
49
+ Why the tools behave as they do, with sources: [docs/REALISM.md](docs/REALISM.md).
50
+
51
  For testing, `unrollView.damageStats()` reports the counts, the floor height, `stillAirMax` (longest any debris hung still above the floor, should stay 0) and `stillMax`.
52
 
53
  ## Rebuilding the data
app.js CHANGED
@@ -4,12 +4,12 @@ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
4
  import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
5
  import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
6
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
7
- import { createTear } from './tear.js?v=20261016b';
8
  import { loadRegistry, listLocal, getLocal, localKey, remoteSource, fileSource, findManifest, buildMeshScroll, decodeImage } from './lib/scrolls.js';
9
  import { validateManifest, peelOrder, layerWindings, layerFile, roleOf, fill, REGISTRY_LIMITS, LOCAL_LIMITS } from './lib/manifest.js';
10
  import { setupOpenOwn } from './lib/openown.js';
11
  import { unpackInt16 } from './lib/formats.js';
12
- import { createDamage, injectFrag, DMG_HEAD } from './damage.js?v=20261016b';
13
 
14
  const $ = (id) => document.getElementById(id);
15
  const params = new URLSearchParams(location.hash.slice(1));
@@ -345,7 +345,8 @@ void main() {
345
  vec3 p = mix(pc, pf, e);
346
  p.z -= sin(3.14159265 * e) * uLift;
347
  // soaked papyrus softens and sags
348
- if (uDmgOn > 0.5) { float wv = texture2D(uDmg, aLocal).a; p.y -= wv * wv * 1.8; p.z += wv * 0.6; }
 
349
  vN = normalize(mix(nc, vec3(0.0, 0.0, 1.0), e));
350
  vLocal = aLocal;
351
  vE = e;
@@ -432,11 +433,39 @@ const meshes = [];
432
  const decim = MOBILE ? 2 : 1;
433
  let Rmax = 0;
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  function meshGeometry(w) {
436
  const geo = new THREE.BufferGeometry();
437
  geo.setAttribute('position', new THREE.BufferAttribute(w.pos, 3));
438
  geo.setAttribute('aLocal', new THREE.BufferAttribute(w.loc, 2));
439
- geo.setIndex(new THREE.BufferAttribute(w.index, 1));
440
  geo.computeVertexNormals();
441
  geo.computeBoundingSphere();
442
  return { geo, ytop: w.ytop, colAng: w.colAng };
@@ -506,6 +535,31 @@ function gridGeometry(w) {
506
  }
507
  solid[r * C + c] = n >= 6 ? 1 : 0;
508
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
509
  const ok = (p, q, r) => solid[p] && solid[q] && solid[r] && d2(p, q) < t2 && d2(q, r) < t2 && d2(p, r) < t2;
510
  for (let r = 0; r + decim < R; r += decim) {
511
  for (let c = 0; c + decim < C; c += decim) {
@@ -517,7 +571,7 @@ function gridGeometry(w) {
517
  const geo = new THREE.BufferGeometry();
518
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
519
  geo.setAttribute('aLocal', new THREE.BufferAttribute(loc, 2));
520
- geo.setIndex(idx);
521
  geo.computeVertexNormals();
522
  geo.computeBoundingSphere();
523
  return { geo, ytop, colAng };
@@ -613,7 +667,7 @@ function sheetBottom() {
613
  if (p < 1) lo = Math.min(lo, rolledMin + shared.uRoll.value.y);
614
  if (p > 0) lo = Math.min(lo, flatMin);
615
  }
616
- return lo - 1.8; // soaked papyrus sags up to 1.8 mm
617
  }
618
  let groundY = NaN;
619
  function updateGround(dt) {
@@ -1466,6 +1520,37 @@ window.unrollView = {
1466
  introActive: () => intro,
1467
  skipIntro: () => { if (!intro) return false; introDone(); autoplayAt = performance.now() + 150; $('skipIntro')?.remove(); return true; },
1468
  damageStats: () => damage.stats(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1469
  sandboxStats: () => (tear ? tear.stats() : null),
1470
  sandboxSim: (n) => { if (!tear) return null; for (let i = 0; i < n; i++) { tear.step(1 / 60); damage.update(1 / 60, shared.uTime.value += 1 / 60, 1 / 60); } return tear.stats(); },
1471
  crumble: () => crumble(), // owner feature: crumble the whole scroll to dust; Reset restores it
 
4
  import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
5
  import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
6
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
7
+ import { createTear } from './tear.js?v=20261016g';
8
  import { loadRegistry, listLocal, getLocal, localKey, remoteSource, fileSource, findManifest, buildMeshScroll, decodeImage } from './lib/scrolls.js';
9
  import { validateManifest, peelOrder, layerWindings, layerFile, roleOf, fill, REGISTRY_LIMITS, LOCAL_LIMITS } from './lib/manifest.js';
10
  import { setupOpenOwn } from './lib/openown.js';
11
  import { unpackInt16 } from './lib/formats.js';
12
+ import { createDamage, injectFrag, DMG_HEAD } from './damage.js?v=20261016g';
13
 
14
  const $ = (id) => document.getElementById(id);
15
  const params = new URLSearchParams(location.hash.slice(1));
 
345
  vec3 p = mix(pc, pf, e);
346
  p.z -= sin(3.14159265 * e) * uLift;
347
  // soaked papyrus softens and sags
348
+ // soaked charcoal swells a little and slumps where it turns to mush; it does not sag like wet paper
349
+ if (uDmgOn > 0.5) { float wv = texture2D(uDmg, aLocal).a; p.z += wv * 0.35; p.y -= wv * wv * 0.35; }
350
  vN = normalize(mix(nc, vec3(0.0, 0.0, 1.0), e));
351
  vLocal = aLocal;
352
  vE = e;
 
433
  const decim = MOBILE ? 2 : 1;
434
  let Rmax = 0;
435
 
436
+ // Keep only the real sheet: drop triangle islands (connected through shared vertices) that are tiny next to the
437
+ // largest piece of the winding. Such islands are stray bits of the trace, often above the top edge, that float
438
+ // free of the sheet once it is unrolled flat. The vertices themselves are untouched, so every kept triangle maps
439
+ // rolled to flat exactly as before.
440
+ function pruneIslands(index, nVerts, id, minFrac = 0.02, minTris = 120) {
441
+ const nt = index.length / 3, parent = new Int32Array(nVerts);
442
+ for (let i = 0; i < nVerts; i++) parent[i] = i;
443
+ const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
444
+ for (let t = 0; t < nt; t++) {
445
+ const a = find(index[t * 3]), b = find(index[t * 3 + 1]), c = find(index[t * 3 + 2]);
446
+ if (a !== b) parent[a] = b;
447
+ const b2 = find(b); if (find(c) !== b2) parent[find(c)] = b2;
448
+ }
449
+ const count = new Map();
450
+ for (let t = 0; t < nt; t++) { const r = find(index[t * 3]); count.set(r, (count.get(r) || 0) + 1); }
451
+ let big = 0; for (const v of count.values()) big = Math.max(big, v);
452
+ const keepMin = Math.min(big, Math.max(minTris, big * minFrac));
453
+ const out = [];
454
+ let dropped = 0;
455
+ for (let t = 0; t < nt; t++) {
456
+ if (count.get(find(index[t * 3])) >= keepMin) out.push(index[t * 3], index[t * 3 + 1], index[t * 3 + 2]);
457
+ else dropped++;
458
+ }
459
+ islandStats.dropped += dropped; islandStats.kept += out.length / 3;
460
+ if (dropped) islandStats.windings[id] = dropped;
461
+ return out;
462
+ }
463
+ const islandStats = { dropped: 0, kept: 0, windings: {}, opened: {} };
464
  function meshGeometry(w) {
465
  const geo = new THREE.BufferGeometry();
466
  geo.setAttribute('position', new THREE.BufferAttribute(w.pos, 3));
467
  geo.setAttribute('aLocal', new THREE.BufferAttribute(w.loc, 2));
468
+ geo.setIndex(pruneIslands(w.index, w.loc.length / 2, w.id));
469
  geo.computeVertexNormals();
470
  geo.computeBoundingSphere();
471
  return { geo, ytop: w.ytop, colAng: w.colAng };
 
535
  }
536
  solid[r * C + c] = n >= 6 ? 1 : 0;
537
  }
538
+ // morphological opening (erode to points with at least 8 of their 9 neighbours, grow back one step): spikes and
539
+ // hairlines one or two samples wide, which the trace ran off the real edge, are removed; the sheet keeps its shape
540
+ const OPEN = 9;
541
+ {
542
+ const er = new Uint8Array(R * C);
543
+ for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) {
544
+ if (!solid[r * C + c]) continue;
545
+ let n = 0;
546
+ for (let dr = -decim; dr <= decim; dr += decim) for (let dc = -decim; dc <= decim; dc += decim) {
547
+ const rr = r + dr, cc = c + dc;
548
+ if (rr >= 0 && cc >= 0 && rr < R && cc < C && solid[rr * C + cc]) n++;
549
+ }
550
+ er[r * C + c] = n >= OPEN ? 1 : 0;
551
+ }
552
+ for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) {
553
+ if (!solid[r * C + c]) continue;
554
+ let any = 0;
555
+ for (let dr = -decim; dr <= decim && !any; dr += decim) for (let dc = -decim; dc <= decim; dc += decim) {
556
+ const rr = r + dr, cc = c + dc;
557
+ if (rr >= 0 && cc >= 0 && rr < R && cc < C && er[rr * C + cc]) { any = 1; break; }
558
+ }
559
+ if (!any) { const q = islandStats.opened[w.id] || (islandStats.opened[w.id] = { n: 0, u: 0, v: 1 }); q.n++; if (loc[(r * C + c) * 2 + 1] < q.v) { q.v = +loc[(r * C + c) * 2 + 1].toFixed(3); q.u = +loc[(r * C + c) * 2].toFixed(3); } }
560
+ solid[r * C + c] = any;
561
+ }
562
+ }
563
  const ok = (p, q, r) => solid[p] && solid[q] && solid[r] && d2(p, q) < t2 && d2(q, r) < t2 && d2(p, r) < t2;
564
  for (let r = 0; r + decim < R; r += decim) {
565
  for (let c = 0; c + decim < C; c += decim) {
 
571
  const geo = new THREE.BufferGeometry();
572
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
573
  geo.setAttribute('aLocal', new THREE.BufferAttribute(loc, 2));
574
+ geo.setIndex(pruneIslands(idx, R * C, w.id));
575
  geo.computeVertexNormals();
576
  geo.computeBoundingSphere();
577
  return { geo, ytop, colAng };
 
667
  if (p < 1) lo = Math.min(lo, rolledMin + shared.uRoll.value.y);
668
  if (p > 0) lo = Math.min(lo, flatMin);
669
  }
670
+ return lo - 0.4; // soaked papyrus slumps a little
671
  }
672
  let groundY = NaN;
673
  function updateGround(dt) {
 
1520
  introActive: () => intro,
1521
  skipIntro: () => { if (!intro) return false; introDone(); autoplayAt = performance.now() + 150; $('skipIntro')?.remove(); return true; },
1522
  damageStats: () => damage.stats(),
1523
+ viewFlat: (id, u, v, d = 40, tt = null) => { const m = meshes.find((q) => q.userData.w.id === id); if (!m) return null; const { start, w, own } = m.userData; const x = tt ?? Math.min(1, (start + w.widthMM) / L + 0.004); introDone(); autoplayAt = 0; setPlaying(false); t = tShown = x; setTarget(x, 'hash'); applyT(x, 0); mode = 'shot'; fly = null; const F = own.uFlatO.value; controls.target.set(F.x + u * w.widthMM, F.y - v * w.heightMM, F.z); camera.position.copy(controls.target).add(new THREE.Vector3(0, -d * 0.15, d)); controls.update(); return x; },
1524
+ islandStats: () => islandStats,
1525
+ setT: (x) => { introDone(); autoplayAt = 0; setPlaying(false); t = tShown = x; setTarget(x, 'hash'); const st = applyT(x, 0); const f = framing(st); controls.target.copy(f); camera.position.copy(f).add(rollOffset()); fly = null; return st.cur.userData.w.id; },
1526
+ // test hook: the worst stretch of any triangle mid-unroll, relative to its rolled and flat size (1 = none)
1527
+ morphAudit: (lim = 4) => { const out = { tot: 0, bad: 0, worst: 0, ex: [] }, c = Math.cos(shared.uRot.value), sn = Math.sin(shared.uRot.value), q = new Float32Array(9);
1528
+ for (const m of meshes) { const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, I = g.index.array, own = m.userData.own, W = own.uSize.value.x, H = own.uSize.value.y, F = own.uFlatO.value, soft = own.uSoft.value;
1529
+ for (let t = 0; t < I.length; t += 3) { out.tot++;
1530
+ const uc = (A[I[t] * 2] + A[I[t + 1] * 2] + A[I[t + 2] * 2]) / 3, uP = (uc + 0.5 * soft) / (1 + soft), Rx = m.userData.start + uP * W, Rz = 0;
1531
+ let base = 0, mo = 0;
1532
+ for (let v = 0; v < 3; v++) { const i = I[t + v], u = A[i * 2], vv = A[i * 2 + 1];
1533
+ const e0 = Math.min(1, Math.max(0, (uP * (1 + soft) - u) / soft)), e = e0 * e0 * (3 - 2 * e0);
1534
+ const cx = P[i * 3] * c + P[i * 3 + 2] * sn + Rx, cy = P[i * 3 + 1], cz = -P[i * 3] * sn + P[i * 3 + 2] * c + Rz;
1535
+ const fx = F.x + u * W, fy = F.y - vv * H, fz = F.z;
1536
+ q[v * 3] = cx + (fx - cx) * e; q[v * 3 + 1] = cy + (fy - cy) * e; q[v * 3 + 2] = cz + (fz - cz) * e - Math.sin(Math.PI * e) * shared.uLift.value; }
1537
+ for (let e = 0; e < 3; e++) { const a = I[t + e], b = I[t + (e + 1) % 3], f = (e + 1) % 3;
1538
+ base = Math.max(base, Math.hypot(P[a * 3] - P[b * 3], P[a * 3 + 1] - P[b * 3 + 1], P[a * 3 + 2] - P[b * 3 + 2]), Math.hypot((A[a * 2] - A[b * 2]) * W, (A[a * 2 + 1] - A[b * 2 + 1]) * H));
1539
+ mo = Math.max(mo, Math.hypot(q[e * 3] - q[f * 3], q[e * 3 + 1] - q[f * 3 + 1], q[e * 3 + 2] - q[f * 3 + 2])); }
1540
+ const r = mo / (base + 0.05);
1541
+ if (r > out.worst) out.worst = r;
1542
+ if (mo > lim && r > 3) { out.bad++; if (out.ex.length < 6) out.ex.push([m.userData.w.id, t / 3, +mo.toFixed(1), +base.toFixed(2), +uc.toFixed(3)]); } } }
1543
+ out.worst = +out.worst.toFixed(1); return out; },
1544
+ // test hook: triangles whose flat (unrolled) size is far from their rolled size stretch into spikes while unrolling
1545
+ geomAudit: (ratio = 3) => { let bad = 0, worst = 0, tot = 0; const ex = [];
1546
+ for (const m of meshes) { const g = m.geometry, P = g.attributes.position.array, A = g.attributes.aLocal.array, I = g.index.array, W = m.userData.w.widthMM, H = m.userData.w.heightMM;
1547
+ for (let t = 0; t < I.length; t += 3) { tot++; let rf = 0;
1548
+ for (let e = 0; e < 3; e++) { const a = I[t + e], b = I[t + (e + 1) % 3];
1549
+ const d3 = Math.hypot(P[a * 3] - P[b * 3], P[a * 3 + 1] - P[b * 3 + 1], P[a * 3 + 2] - P[b * 3 + 2]);
1550
+ const df = Math.hypot((A[a * 2] - A[b * 2]) * W, (A[a * 2 + 1] - A[b * 2 + 1]) * H);
1551
+ rf = Math.max(rf, (Math.max(d3, df) + 0.05) / (Math.min(d3, df) + 0.05)); }
1552
+ if (rf > ratio) { bad++; if (ex.length < 5) ex.push([m.userData.w.id, t / 3, +rf.toFixed(1)]); } worst = Math.max(worst, rf); } }
1553
+ return { tot, bad, worst: +worst.toFixed(1), ex }; },
1554
  sandboxStats: () => (tear ? tear.stats() : null),
1555
  sandboxSim: (n) => { if (!tear) return null; for (let i = 0; i < n; i++) { tear.step(1 / 60); damage.update(1 / 60, shared.uTime.value += 1 / 60, 1 / 60); } return tear.stats(); },
1556
  crumble: () => crumble(), // owner feature: crumble the whole scroll to dust; Reset restores it
damage.js CHANGED
@@ -55,13 +55,13 @@ export const DMG_EDGE = /* glsl */`
55
  col *= 1.0 - 0.45 * smoothstep(0.1, 0.5, dd.g);
56
  float crack = dd.g * smoothstep(0.35, 0.75, dFibV + dd.g * 0.35);
57
  col = mix(col, vec3(0.010, 0.007, 0.005) * (0.6 + 0.8 * dFibV), clamp(charK * 0.97 + crack * 0.9, 0.0, 1.0));
 
58
  float wet = dd.a;
59
- col *= 1.0 - 0.8 * wet;
60
- vec3 dH = normalize(normalize(vec3(-0.35, 0.75, 0.55)) + normalize(cameraPosition - vW));
61
- col += wet * (pow(abs(dot(normalize(vN), dH)), 40.0) * vec3(0.9, 0.85, 0.78) + pow(1.0 - abs(dot(normalize(vN), normalize(cameraPosition - vW))), 4.0) * vec3(0.12, 0.12, 0.13));
62
- float flick = 0.7 + 0.3 * sin(uDmgT * 2.3 + dFibV * 23.0) * sin(uDmgT * 1.1 + dFibV * 7.0);
63
  float emb = dd.b * flick;
64
- col += vec3(1.0, 0.30, 0.05) * emb * (0.6 + 2.0 * charK) * 1.6;
65
  }
66
  `;
67
  export function injectFrag(frag, cutAfter) {
@@ -192,6 +192,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
192
  const pPos = new Float32Array(PMAX * 3), pVel = new Float32Array(PMAX * 3), pCol = new Float32Array(PMAX * 4), pSize = new Float32Array(PMAX);
193
  const pSleep = new Uint8Array(PMAX), pType = new Uint8Array(PMAX), pLife = new Float32Array(PMAX), pAge = new Float32Array(PMAX), pSeed = new Float32Array(PMAX);
194
  let pHead = 0;
 
195
  const pGeo = new THREE.BufferGeometry();
196
  pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage));
197
  pGeo.setAttribute('aCol', new THREE.BufferAttribute(pCol, 4).setUsage(THREE.DynamicDrawUsage));
@@ -214,7 +215,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
214
  pSleep[i] = 0; pType[i] = type; pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z;
215
  pVel[i * 3] = vx; pVel[i * 3 + 1] = vy; pVel[i * 3 + 2] = vz;
216
  pSize[i] = size; pLife[i] = life; pAge[i] = 0; pSeed[i] = Math.random() * 100;
217
- pCol[i * 4] = r; pCol[i * 4 + 1] = g; pCol[i * 4 + 2] = b; pCol[i * 4 + 3] = a;
218
  }
219
  // fragments and flakes: tumbling instanced shards
220
  const FMAX = MOBILE ? 220 : 600;
@@ -225,7 +226,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
225
  const idx = []; for (let i = 1; i <= n; i++) idx.push(0, i, i + 1);
226
  shard.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); shard.setIndex(idx); shard.computeVertexNormals();
227
  }
228
- const fMesh = new THREE.InstancedMesh(shard, new THREE.MeshStandardMaterial({ color: 0x2a1d14, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }), FMAX);
229
  fMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
230
  fMesh.frustumCulled = false;
231
  fMesh.count = 0;
@@ -253,7 +254,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
253
  // puddles on the floor
254
  const QMAX = 40;
255
  const qMesh = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 28).rotateX(-Math.PI / 2),
256
- new THREE.MeshStandardMaterial({ color: 0x0b0d10, roughness: 0.08, metalness: 0.2, transparent: true, opacity: 0.85 }), QMAX);
257
  qMesh.frustumCulled = false; qMesh.count = 0;
258
  scene.add(qMesh);
259
  const puddles = [];
@@ -268,17 +269,17 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
268
  const { x, y, z } = world;
269
  for (let i = 0; i < n; i++) {
270
  const a = Math.random() * 6.283, r = Math.random() * rMM;
271
- const big = Math.random() < 0.25;
272
  spawnF(x + Math.cos(a) * r, y + Math.sin(a) * r * 0.6, z + 0.4,
273
- Math.cos(a) * (20 + Math.random() * 50), 10 + Math.random() * 40, 20 + Math.random() * 50,
274
- big ? 1.1 + Math.random() * 1.5 : 0.35 + Math.random() * 0.6);
275
  }
276
- const nd = kind === 'tear' ? n * 2 : n * 5;
277
  for (let i = 0; i < nd; i++) {
278
- const a = Math.random() * 6.283, sp = 15 + Math.random() * 35; // a little puff outward
279
- const c = 0.10 + Math.random() * 0.06;
280
  spawnP(0, x + (Math.random() - 0.5) * rMM, y + (Math.random() - 0.5) * rMM, z + 0.5,
281
- Math.cos(a) * sp, Math.sin(a) * sp * 0.6 + 6, 8 + Math.random() * 25, 0.25 + Math.random() * 0.5, 5 + Math.random() * 4, c * 1.25, c * 0.95, c * 0.75, 0.75);
282
  }
283
  }
284
  function drops(world, n) {
@@ -338,7 +339,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
338
  const top = cr.H * (0.3 + 0.7 * f) * Math.pow(Math.max(0, 1 - r / cr.R), 1.6);
339
  hH[i] = top * Math.pow(Math.random(), 0.6) + 0.2;
340
  hPos[i * 3] = cr.cx + Math.cos(a) * r; hPos[i * 3 + 2] = cr.cz + Math.sin(a) * r * 0.85;
341
- const c = 0.06 + Math.random() * 0.12, warm = Math.random() < 0.15 ? 0.05 : 0;
342
  hCol[i * 4] = c * 1.3 + warm; hCol[i * 4 + 1] = c; hCol[i * 4 + 2] = c * 0.75; hCol[i * 4 + 3] = 0;
343
  hSize[i] = 0.9 + Math.random() * 1.4 + (1 - r / cr.R) * 0.8; // coarser in the core
344
  }
@@ -365,7 +366,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
365
  const ix = cr.cx - cP.x, iz = cr.cz - cP.z, il = Math.hypot(ix, iz) || 1, pull = 25 + Math.random() * 45;
366
  if (q < nF) spawnF(cP.x, cP.y, cP.z, ix / il * pull, Math.random() * 15, iz / il * pull, 0.35 + Math.random() * (Math.random() < 0.2 ? 1.6 : 0.6));
367
  else {
368
- const c = 0.09 + Math.random() * 0.07;
369
  spawnP(0, cP.x, cP.y, cP.z, ix / il * pull * 0.6, 4 + Math.random() * 10, iz / il * pull * 0.6, 0.3 + Math.random() * 0.5, 3 + Math.random() * 2, c * 1.25, c * 0.95, c * 0.75, 0.75);
370
  }
371
  }
@@ -402,7 +403,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
402
  blob(k, e.u, e.v, 2.6, 2, Math.min(1, 0.05 * q));
403
  blob(k, e.u, e.v, 0.8, 0, Math.min(1, (e.age > 1.2 ? 0.05 : 0.01) * q));
404
  for (let rep = 0; rep < 3 && e.next <= 0 && e.gen < 40; rep++) {
405
- e.next += 0.25 + Math.random() * 0.45;
406
  const along = Math.random() < 0.7, s = 0.6 + Math.random() * 0.8, a = Math.random() < 0.5 ? -1 : 1;
407
  const [du, dv] = mmToUV(k, along ? a * s * 1.9 : (Math.random() - 0.5) * s * 1.4, along ? (Math.random() - 0.5) * s * 0.6 : a * s * 1.0);
408
  if (Math.random() < 0.97 - e.gen * 0.01) addEmber(k, e.u + du, e.v + dv, e.gen + 1);
@@ -421,11 +422,11 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
421
  for (let i = 0; i < Math.min(3, keep.length / 6 + 1); i++) {
422
  const g = 0.16 + Math.random() * 0.1;
423
  if (pick()) spawnP(2, sp.x, sp.y, sp.z,
424
- (Math.random() - 0.5) * 6 + sn.x * 4, 8 + Math.random() * 6, (Math.random() - 0.5) * 4 + sn.z * 4, 3 + Math.random() * 3, 4 + Math.random() * 3, g * 1.8, g * 1.75, g * 1.7, 0.5);
425
  }
426
  if (Math.random() < 0.6 && pick()) spawnP(1, sp.x, sp.y, sp.z,
427
- (Math.random() - 0.5) * 8 + sn.x * 3, 12 + Math.random() * 10, sn.z * 3, 0.5 + Math.random() * 0.6, 6, 0.32, 0.30, 0.28, 0.85);
428
- if (Math.random() < 0.25 && pick()) spawnP(4, sp.x, sp.y, sp.z, (Math.random() - 0.5) * 10 + sn.x * 6, 25 + Math.random() * 15, sn.z * 6, 0.25, 1.2, 1.0, 0.45, 0.1, 1.0);
429
  }
430
  }
431
  }
@@ -450,8 +451,13 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
450
  if (w.t < 2.5 || at(k, w.u, w.v, 3) < 0.55 || Math.random() > 0.35) continue;
451
  const a = Math.random() * 6.283, r = Math.random() * w.r * 0.8;
452
  const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r);
453
- blob(k, w.u + du, w.v + dv, 0.5 + Math.random() * 0.6, 0, 0.12);
454
- if (Math.random() < 0.3) blob(k, w.u + du, w.v + dv, 1.2, 1, 0.15);
 
 
 
 
 
455
  }
456
  }
457
  // texture uploads, throttled
@@ -476,7 +482,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
476
  continue;
477
  }
478
  let g, k, turb;
479
- if (t === 0) { g = 22; k = 3.2; turb = 10; } // dust: terminal velocity about 7 mm/s
480
  else if (t === 1) { g = 6; k = 1.6; turb = 12; } // ash: warm air carries it up first
481
  else if (t === 2) { g = -14; k = 1.1; turb = 14; } // smoke rises
482
  else if (t === 3) { g = 520; k = 0.3; turb = 0; } // water drops
@@ -501,8 +507,8 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
501
  pLife[i] = pAge[i] + REST_HOLD + REST_FADE + Math.random() * 0.5;
502
  continue;
503
  }
504
- if (t === 2) { pSize[i] += dt * 2.6; pCol[i * 4 + 3] = 0.42 * fade * Math.min(1, pAge[i] * 2); }
505
- else if (t === 4) pCol[i * 4 + 3] = fade;
506
  else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9);
507
  }
508
  // watchdog for particles: visible, not asleep on the floor, and not moving
@@ -590,5 +596,5 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCru
590
  function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); }
591
  function tearMM(m, ax, ay, bx, by) { const w = m.userData.w; const k = mask(m); line(k, ax / w.widthMM, ay / w.heightMM, bx / w.widthMM, by / w.heightMM, 0.28, 0, 0.75, 0.3); line(k, ax / w.widthMM, ay / w.heightMM, bx / w.widthMM, by / w.heightMM, 0.8, 1, 0.3, 0.4); }
592
  const objects = [points, fMesh, qMesh, heapPts];
593
- return { objects, shared, blankTex, bruise, crush, tear, wetAt, ignite, debris, drops, update, reset, stats, crumble, simulate: (n, dt) => { for (let i = 0; i < n; i++) update(dt, shared.uDmgT.value + dt, dt); return stats(); }, crushMM, tearMM, wetness: (m, u, v) => (masks.has(m) ? at(masks.get(m), u, v, 3) : 0), dust: (x, y, z) => debris({ x, y, z }, 1.5, 0, 'tear') || spawnP(0, x, y, z, (Math.random() - 0.5) * 30, 8 + Math.random() * 12, 8 + Math.random() * 15, 0.3 + Math.random() * 0.4, 6, 0.13, 0.1, 0.08, 0.75) };
594
  }
 
55
  col *= 1.0 - 0.45 * smoothstep(0.1, 0.5, dd.g);
56
  float crack = dd.g * smoothstep(0.35, 0.75, dFibV + dd.g * 0.35);
57
  col = mix(col, vec3(0.010, 0.007, 0.005) * (0.6 + 0.8 * dFibV), clamp(charK * 0.97 + crack * 0.9, 0.0, 1.0));
58
+ // wet charcoal: soaks up the water and goes matt black-brown, no sheen
59
  float wet = dd.a;
60
+ col = mix(col, col * vec3(0.16, 0.13, 0.11), smoothstep(0.0, 0.7, wet));
61
+ // smouldering charcoal: a dim, slowly breathing deep orange in the char band, no flame
62
+ float flick = 0.75 + 0.25 * sin(uDmgT * 0.9 + dFibV * 23.0) * sin(uDmgT * 0.43 + dFibV * 7.0);
 
63
  float emb = dd.b * flick;
64
+ col += vec3(0.85, 0.22, 0.04) * emb * emb * (0.25 + 1.1 * charK) * 0.9;
65
  }
66
  `;
67
  export function injectFrag(frag, cutAfter) {
 
192
  const pPos = new Float32Array(PMAX * 3), pVel = new Float32Array(PMAX * 3), pCol = new Float32Array(PMAX * 4), pSize = new Float32Array(PMAX);
193
  const pSleep = new Uint8Array(PMAX), pType = new Uint8Array(PMAX), pLife = new Float32Array(PMAX), pAge = new Float32Array(PMAX), pSeed = new Float32Array(PMAX);
194
  let pHead = 0;
195
+ const pA0 = new Float32Array(PMAX); // spawn opacity: fades scale from it, so faint smoke stays faint
196
  const pGeo = new THREE.BufferGeometry();
197
  pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage));
198
  pGeo.setAttribute('aCol', new THREE.BufferAttribute(pCol, 4).setUsage(THREE.DynamicDrawUsage));
 
215
  pSleep[i] = 0; pType[i] = type; pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z;
216
  pVel[i * 3] = vx; pVel[i * 3 + 1] = vy; pVel[i * 3 + 2] = vz;
217
  pSize[i] = size; pLife[i] = life; pAge[i] = 0; pSeed[i] = Math.random() * 100;
218
+ pCol[i * 4] = r; pCol[i * 4 + 1] = g; pCol[i * 4 + 2] = b; pCol[i * 4 + 3] = a; pA0[i] = a;
219
  }
220
  // fragments and flakes: tumbling instanced shards
221
  const FMAX = MOBILE ? 220 : 600;
 
226
  const idx = []; for (let i = 1; i <= n; i++) idx.push(0, i, i + 1);
227
  shard.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); shard.setIndex(idx); shard.computeVertexNormals();
228
  }
229
+ const fMesh = new THREE.InstancedMesh(shard, new THREE.MeshStandardMaterial({ color: 0x15110e, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }), FMAX);
230
  fMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
231
  fMesh.frustumCulled = false;
232
  fMesh.count = 0;
 
254
  // puddles on the floor
255
  const QMAX = 40;
256
  const qMesh = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 28).rotateX(-Math.PI / 2),
257
+ new THREE.MeshStandardMaterial({ color: 0x0a0807, roughness: 0.7, metalness: 0, transparent: true, opacity: 0.9 }), QMAX); // black sludge
258
  qMesh.frustumCulled = false; qMesh.count = 0;
259
  scene.add(qMesh);
260
  const puddles = [];
 
269
  const { x, y, z } = world;
270
  for (let i = 0; i < n; i++) {
271
  const a = Math.random() * 6.283, r = Math.random() * rMM;
272
+ const big = Math.random() < 0.1; // charcoal powders: a few flakes, mostly small bits
273
  spawnF(x + Math.cos(a) * r, y + Math.sin(a) * r * 0.6, z + 0.4,
274
+ Math.cos(a) * (6 + Math.random() * 16), 2 + Math.random() * 10, 6 + Math.random() * 16,
275
+ big ? 0.9 + Math.random() * 1.0 : 0.25 + Math.random() * 0.45);
276
  }
277
+ const nd = kind === 'tear' ? n * 4 : n * 10;
278
  for (let i = 0; i < nd; i++) {
279
+ const a = Math.random() * 6.283, sp = 6 + Math.random() * 18; // a soft puff of black dust
280
+ const c = 0.035 + Math.random() * 0.035;
281
  spawnP(0, x + (Math.random() - 0.5) * rMM, y + (Math.random() - 0.5) * rMM, z + 0.5,
282
+ Math.cos(a) * sp, Math.sin(a) * sp * 0.6 + 3, 4 + Math.random() * 10, 0.25 + Math.random() * 0.5, 10 + Math.random() * 6, c * 1.15, c, c * 0.9, 0.8);
283
  }
284
  }
285
  function drops(world, n) {
 
339
  const top = cr.H * (0.3 + 0.7 * f) * Math.pow(Math.max(0, 1 - r / cr.R), 1.6);
340
  hH[i] = top * Math.pow(Math.random(), 0.6) + 0.2;
341
  hPos[i * 3] = cr.cx + Math.cos(a) * r; hPos[i * 3 + 2] = cr.cz + Math.sin(a) * r * 0.85;
342
+ const c = 0.03 + Math.random() * 0.07, warm = Math.random() < 0.1 ? 0.02 : 0;
343
  hCol[i * 4] = c * 1.3 + warm; hCol[i * 4 + 1] = c; hCol[i * 4 + 2] = c * 0.75; hCol[i * 4 + 3] = 0;
344
  hSize[i] = 0.9 + Math.random() * 1.4 + (1 - r / cr.R) * 0.8; // coarser in the core
345
  }
 
366
  const ix = cr.cx - cP.x, iz = cr.cz - cP.z, il = Math.hypot(ix, iz) || 1, pull = 25 + Math.random() * 45;
367
  if (q < nF) spawnF(cP.x, cP.y, cP.z, ix / il * pull, Math.random() * 15, iz / il * pull, 0.35 + Math.random() * (Math.random() < 0.2 ? 1.6 : 0.6));
368
  else {
369
+ const c = 0.035 + Math.random() * 0.04;
370
  spawnP(0, cP.x, cP.y, cP.z, ix / il * pull * 0.6, 4 + Math.random() * 10, iz / il * pull * 0.6, 0.3 + Math.random() * 0.5, 3 + Math.random() * 2, c * 1.25, c * 0.95, c * 0.75, 0.75);
371
  }
372
  }
 
403
  blob(k, e.u, e.v, 2.6, 2, Math.min(1, 0.05 * q));
404
  blob(k, e.u, e.v, 0.8, 0, Math.min(1, (e.age > 1.2 ? 0.05 : 0.01) * q));
405
  for (let rep = 0; rep < 3 && e.next <= 0 && e.gen < 40; rep++) {
406
+ e.next += 0.6 + Math.random() * 1.0; // smouldering creeps slowly
407
  const along = Math.random() < 0.7, s = 0.6 + Math.random() * 0.8, a = Math.random() < 0.5 ? -1 : 1;
408
  const [du, dv] = mmToUV(k, along ? a * s * 1.9 : (Math.random() - 0.5) * s * 1.4, along ? (Math.random() - 0.5) * s * 0.6 : a * s * 1.0);
409
  if (Math.random() < 0.97 - e.gen * 0.01) addEmber(k, e.u + du, e.v + dv, e.gen + 1);
 
422
  for (let i = 0; i < Math.min(3, keep.length / 6 + 1); i++) {
423
  const g = 0.16 + Math.random() * 0.1;
424
  if (pick()) spawnP(2, sp.x, sp.y, sp.z,
425
+ (Math.random() - 0.5) * 6 + sn.x * 4, 8 + Math.random() * 6, (Math.random() - 0.5) * 4 + sn.z * 4, 3 + Math.random() * 3, 4 + Math.random() * 3, g * 1.8, g * 1.75, g * 1.7, 0.22);
426
  }
427
  if (Math.random() < 0.6 && pick()) spawnP(1, sp.x, sp.y, sp.z,
428
+ (Math.random() - 0.5) * 8 + sn.x * 3, 12 + Math.random() * 10, sn.z * 3, 0.4 + Math.random() * 0.5, 8, 0.58, 0.56, 0.53, 0.8); // light grey ash
429
+ if (Math.random() < 0.02 && pick()) spawnP(4, sp.x, sp.y, sp.z, (Math.random() - 0.5) * 10 + sn.x * 6, 25 + Math.random() * 15, sn.z * 6, 0.18, 0.7, 0.8, 0.3, 0.06, 0.7); // a rare dull spark
430
  }
431
  }
432
  }
 
451
  if (w.t < 2.5 || at(k, w.u, w.v, 3) < 0.55 || Math.random() > 0.35) continue;
452
  const a = Math.random() * 6.283, r = Math.random() * w.r * 0.8;
453
  const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r);
454
+ blob(k, w.u + du, w.v + dv, 0.5 + Math.random() * 0.6, 0, 0.2);
455
+ if (Math.random() < 0.5) blob(k, w.u + du, w.v + dv, 1.2, 1, 0.2); // layers lift apart along the fibres
456
+ // the mush lets go: black sludge drips and a few sodden fragments drop from where it is on the sheet
457
+ if (surfacePoint && surfacePoint(k.m, w.u + du, w.v + dv, sp)) {
458
+ spawnP(3, sp.x, sp.y - 0.3, sp.z + 0.2, (Math.random() - 0.5) * 3, -4, 0, 0.9 + Math.random() * 0.6, 4, 0.05, 0.04, 0.035, 0.95);
459
+ if (Math.random() < 0.35) spawnF(sp.x, sp.y, sp.z + 0.2, (Math.random() - 0.5) * 4, -2, 1, 0.3 + Math.random() * 0.5);
460
+ }
461
  }
462
  }
463
  // texture uploads, throttled
 
482
  continue;
483
  }
484
  let g, k, turb;
485
+ if (t === 0) { g = 9; k = 3.6; turb = 8; } // charcoal dust, very light: settles at about 2.5 mm/s
486
  else if (t === 1) { g = 6; k = 1.6; turb = 12; } // ash: warm air carries it up first
487
  else if (t === 2) { g = -14; k = 1.1; turb = 14; } // smoke rises
488
  else if (t === 3) { g = 520; k = 0.3; turb = 0; } // water drops
 
507
  pLife[i] = pAge[i] + REST_HOLD + REST_FADE + Math.random() * 0.5;
508
  continue;
509
  }
510
+ if (t === 2) { pSize[i] += dt * 2.6; pCol[i * 4 + 3] = pA0[i] * 0.84 * fade * Math.min(1, pAge[i] * 2); }
511
+ else if (t === 4) pCol[i * 4 + 3] = fade * pA0[i];
512
  else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9);
513
  }
514
  // watchdog for particles: visible, not asleep on the floor, and not moving
 
596
  function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); }
597
  function tearMM(m, ax, ay, bx, by) { const w = m.userData.w; const k = mask(m); line(k, ax / w.widthMM, ay / w.heightMM, bx / w.widthMM, by / w.heightMM, 0.28, 0, 0.75, 0.3); line(k, ax / w.widthMM, ay / w.heightMM, bx / w.widthMM, by / w.heightMM, 0.8, 1, 0.3, 0.4); }
598
  const objects = [points, fMesh, qMesh, heapPts];
599
+ return { objects, shared, blankTex, bruise, crush, tear, wetAt, ignite, debris, drops, update, reset, stats, crumble, simulate: (n, dt) => { for (let i = 0; i < n; i++) update(dt, shared.uDmgT.value + dt, dt); return stats(); }, crushMM, tearMM, wetness: (m, u, v) => (masks.has(m) ? at(masks.get(m), u, v, 3) : 0), dust: (x, y, z) => debris({ x, y, z }, 1.5, 0, 'tear') || spawnP(0, x, y, z, (Math.random() - 0.5) * 30, 8 + Math.random() * 12, 8 + Math.random() * 15, 0.3 + Math.random() * 0.4, 10, 0.05, 0.043, 0.038, 0.8) };
600
  }
docs/REALISM.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # How Destroy mode behaves, and why
2
+
3
+ The scroll is carbonized papyrus. The eruption heated the rolls to combustion temperature with no oxygen around them, so they became something close to charcoal: black, light, brittle and layered. Sheets that were pressed together during manufacture and burial are fused in places, and in other places they have bubbled or come apart. The tools are tuned to that material and not to paper. This is for fun, not a simulation of conservation practice.
4
+
5
+ ## What the sources say
6
+
7
+ - Carbonization left the rolls "similar to pieces of burnt wood, very fragile and extremely difficult to unroll without losing large portions of the scroll" (*A computational platform for the virtual unfolding of Herculaneum Papyri*, Scientific Reports, <https://www.nature.com/articles/s41598-020-80458-z>).
8
+ - The rolls are "blackened and brittle like charcoal ... easily falling to pieces with light contact". Carbonization "often causes these sheets to bubble or delaminate" (Parsons et al., EduceLab-Scrolls, 2023, <https://arxiv.org/abs/2304.02084>).
9
+ - Physical opening, whether by Piaggio's machine, by cutting the hard outer *scorza*, or by the 1980s Oslo method, broke the rolls into fragments and lost text (Mocella et al., *Nature Communications* 2015, <https://www.nature.com/articles/ncomms6895>; *Analysis of Herculaneum Papyri with X-Ray Computed Tomography*, micro-CT of P.Herc.Paris 3 and 4, <https://www.ndt.net/article/art2011/papers/FIELD%20-%20M%2014.pdf>).
10
+ - Liquids did damage. Over the centuries the rolls were immersed in water, and some were painted with mercury, which turned them to mush (the micro-CT paper above; BYU Magazine, "A Library of Mud and Ashes", <https://magazine.byu.edu/article/a-library-of-mud-and-ashes/>). Humidity speeds the decay of charred papyrus (*Digitization and Digital Preservation of P.Herc. 817*, BYU thesis, <https://scholarsarchive.byu.edu/cgi/viewcontent.cgi?article=11264&context=etd>).
11
+ - Charcoal smoulders rather than flames. It glows dull orange at the burning front, shrinks and cracks, and leaves light grey ash with little smoke. That is general combustion behaviour of char, used here as a qualitative guide.
12
+
13
+ ## What each tool does
14
+
15
+ | Tool | Behaviour |
16
+ | --- | --- |
17
+ | Tear | No stretching or peeling. A grab snaps and crumbles the sheet at the grab point into small flakes and black dust, and a crack runs off along the fibres of that layer. Fractures follow the fibre direction and existing cracks. Fused layers let go easily (lower glue strength). |
18
+ | Crush | Powders into fine black dust and a few small flakes with very little springback. |
19
+ | Burn | Smoulders: a dim, slowly breathing deep orange glow in the charred band creeps slowly along the fibres. Edges char and break away, flakes drop, faint smoke rises, and the ash is light grey. Sparks are rare and dull. |
20
+ | Water | Darkens to a matt black-brown with no gloss. The sheet swells slightly and slumps a little, with no paper-like sag. Layers lift apart, and soaked spots dissolve into black sludge that drips and drops sodden fragments onto a dark puddle. |
21
+ | Crumble | The whole scroll breaks up from its edges inward over about four seconds and collapses into a heap of black dust and flakes. |
22
+
23
+ Dust is dark charcoal and very light. It settles at about 2.5 mm/s and drifts on air currents. The only light source is the smouldering embers.
index.html CHANGED
@@ -26,9 +26,9 @@
26
  <link rel="preconnect" href="https://fonts.googleapis.com">
27
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
28
  <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
29
- <link rel="stylesheet" href="style.css?v=20261016f">
30
- <link rel="stylesheet" href="bench.css?v=20261016f">
31
- <link rel="stylesheet" href="ui.css?v=20261016f">
32
  <!-- start the big downloads and the 3D library in parallel with the page instead of one after another -->
33
  <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" crossorigin>
34
  <script>
@@ -202,9 +202,9 @@ if (!/(^|[#&])s=/.test(location.hash)) ['scrolls/index.json', 'scrolls/PHerc0139
202
  </div>
203
  </section>
204
 
205
- <script type="module" src="app.js?v=20261016f"></script>
206
- <script type="module" src="polish.js?v=20261016f"></script>
207
- <script type="module" src="lib/bench.js?v=20261016f"></script>
208
- <script type="module" src="ui.js?v=20261016f"></script>
209
  </body>
210
  </html>
 
26
  <link rel="preconnect" href="https://fonts.googleapis.com">
27
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
28
  <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
29
+ <link rel="stylesheet" href="style.css?v=20261016g">
30
+ <link rel="stylesheet" href="bench.css?v=20261016g">
31
+ <link rel="stylesheet" href="ui.css?v=20261016g">
32
  <!-- start the big downloads and the 3D library in parallel with the page instead of one after another -->
33
  <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" crossorigin>
34
  <script>
 
202
  </div>
203
  </section>
204
 
205
+ <script type="module" src="app.js?v=20261016g"></script>
206
+ <script type="module" src="polish.js?v=20261016g"></script>
207
+ <script type="module" src="lib/bench.js?v=20261016g"></script>
208
+ <script type="module" src="ui.js?v=20261016g"></script>
209
  </body>
210
  </html>
tear.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DMG_HEAD as DMG_HEAD_T, DMG_CUT as DMG_CUT_T, DMG_EDGE as DMG_EDGE_T } from './damage.js?v=20261016b';
2
  // "Tear apart": a just-for-fun brittle fracture toy on top of the unrolled strip.
3
  // Not a real unrolling. Position based dynamics (Verlet + distance constraints) on typed arrays,
4
  // with real cracks: a crack is a jagged polyline in the sheet's own material space that grows from
@@ -26,20 +26,20 @@ const P = {
26
  layerGap: 0.22, // mm between recto and verso
27
  thickness: 0.15, // mm, dark torn edge face
28
  gravity: 9810, // mm/s^2
29
- drag: 1.6, // 1/s velocity loss for sheet nodes (low density)
30
  substeps: 1,
31
  iters: 6,
32
  strainAlong: 0.009, // failure strain along the fibres of a layer (crack runs along fibres)
33
  strainAcross: 0.018,
34
  jitter: 0.45, // +/- fraction of random scatter in thresholds
35
  bendCrackDeg: 14, // fold angle at a node that cracks it
36
- glueGap: 0.55, // mm of layer separation that delaminates recto from verso
37
  fuseStrain: 0.012, // adhesion between neighbouring windings
38
  peelFrac: 0.85, // a stuck node lets go of the backing above this fraction of its failure strain
39
- peelProb: 0.25,
40
  shatterSpeed: 700, // mm/s impact speed that shatters links at a node
41
  tipGrow: 0.8, // a crack tip advances while nearby links are above this fraction of failure
42
- fibreBias: 0.62, // how strongly cracks follow the fibres of their layer
43
  turnJitter: 0.38, // radians of random wander per step
44
  branch: 0.025, // chance per step that a crack forks
45
  stepsPerTip: 4, // propagation steps per tip per frame, at most
@@ -1251,14 +1251,14 @@ ${DMG_EDGE_T}
1251
  dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 8 + Math.random() * 20; dv[k * 3 + 2] = 10 + Math.random() * 25;
1252
  dl[k] = 1;
1253
  }
1254
- function crushAt(lay, k0, point) {
1255
  const s = lay.s;
1256
  if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); }
1257
  const F = lay.F;
1258
  const mx = matX[k0] + (Math.random() - 0.5) * s.cw * 0.6, my = matY[k0] + (Math.random() - 0.5) * s.ch * 0.6;
1259
  // map the actual hit point into material space using the nearest node's displacement
1260
  const hx = mx + (point.x - pos[k0 * 3]) * 0.9, hy = my - (point.y - pos[k0 * 3 + 1]) * 0.9;
1261
- const R0 = 2.0 + Math.random() * 1.6;
1262
  const ph1 = Math.random() * 6.3, ph2 = Math.random() * 6.3;
1263
  const rAt = (a) => R0 * (0.78 + 0.16 * Math.sin(3 * a + ph1) + 0.1 * Math.sin(7 * a + ph2) + 0.06 * Math.random());
1264
  // the visible hole is drawn by the damage mask in UV space: a smooth, fibrous edge with no triangle staircase.
@@ -1285,14 +1285,15 @@ ${DMG_EDGE_T}
1285
  const w = worldAt(lay, hx, hy);
1286
  let made = 0;
1287
  while (left > 0 && made < 40) {
1288
- const sz = Math.random() < 0.3 ? 0.9 + Math.random() * 1.4 : 0.25 + Math.random() * 0.6;
1289
  left -= sz * sz * 0.6;
1290
  const a = Math.random() * 6.3, r = Math.random() * R0;
1291
  addCrumb(w[0] + Math.cos(a) * r, w[1] + Math.sin(a) * r, w[2] + 0.3 + Math.random() * 0.4,
1292
- Math.cos(a) * (30 + Math.random() * 80), Math.random() * 60, 40 + Math.random() * 90, sz);
 
1293
  made++;
1294
  }
1295
- for (let q = 0; q < 3; q++) spawnDust(w[0] + (Math.random() - 0.5) * R0, w[1] + (Math.random() - 0.5) * R0, w[2]);
1296
  active[sheetOf[k0]] = 1; calm[sheetOf[k0]] = 0; s.islandDirty = true;
1297
  crunch();
1298
  breaks++;
@@ -1336,6 +1337,18 @@ ${DMG_EDGE_T}
1336
  e.stopImmediatePropagation();
1337
  return;
1338
  }
 
 
 
 
 
 
 
 
 
 
 
 
1339
  grabbed = best;
1340
  active[sheetOf[best]] = 1; calm[sheetOf[best]] = 0;
1341
  // pinch everything within about 1.6 nodes of the hit, on the layer that was touched
 
1
+ import { DMG_HEAD as DMG_HEAD_T, DMG_CUT as DMG_CUT_T, DMG_EDGE as DMG_EDGE_T } from './damage.js?v=20261016g';
2
  // "Tear apart": a just-for-fun brittle fracture toy on top of the unrolled strip.
3
  // Not a real unrolling. Position based dynamics (Verlet + distance constraints) on typed arrays,
4
  // with real cracks: a crack is a jagged polyline in the sheet's own material space that grows from
 
26
  layerGap: 0.22, // mm between recto and verso
27
  thickness: 0.15, // mm, dark torn edge face
28
  gravity: 9810, // mm/s^2
29
+ drag: 2.4, // 1/s velocity loss for sheet nodes (low density)
30
  substeps: 1,
31
  iters: 6,
32
  strainAlong: 0.009, // failure strain along the fibres of a layer (crack runs along fibres)
33
  strainAcross: 0.018,
34
  jitter: 0.45, // +/- fraction of random scatter in thresholds
35
  bendCrackDeg: 14, // fold angle at a node that cracks it
36
+ glueGap: 0.35, // mm of layer separation that delaminates recto from verso
37
  fuseStrain: 0.012, // adhesion between neighbouring windings
38
  peelFrac: 0.85, // a stuck node lets go of the backing above this fraction of its failure strain
39
+ peelProb: 0.08, // brittle: it snaps rather than peels
40
  shatterSpeed: 700, // mm/s impact speed that shatters links at a node
41
  tipGrow: 0.8, // a crack tip advances while nearby links are above this fraction of failure
42
+ fibreBias: 0.75, // how strongly cracks follow the fibres of their layer
43
  turnJitter: 0.38, // radians of random wander per step
44
  branch: 0.025, // chance per step that a crack forks
45
  stepsPerTip: 4, // propagation steps per tip per frame, at most
 
1251
  dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 8 + Math.random() * 20; dv[k * 3 + 2] = 10 + Math.random() * 25;
1252
  dl[k] = 1;
1253
  }
1254
+ function crushAt(lay, k0, point, rScale = 1) {
1255
  const s = lay.s;
1256
  if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); }
1257
  const F = lay.F;
1258
  const mx = matX[k0] + (Math.random() - 0.5) * s.cw * 0.6, my = matY[k0] + (Math.random() - 0.5) * s.ch * 0.6;
1259
  // map the actual hit point into material space using the nearest node's displacement
1260
  const hx = mx + (point.x - pos[k0 * 3]) * 0.9, hy = my - (point.y - pos[k0 * 3 + 1]) * 0.9;
1261
+ const R0 = (2.0 + Math.random() * 1.6) * rScale;
1262
  const ph1 = Math.random() * 6.3, ph2 = Math.random() * 6.3;
1263
  const rAt = (a) => R0 * (0.78 + 0.16 * Math.sin(3 * a + ph1) + 0.1 * Math.sin(7 * a + ph2) + 0.06 * Math.random());
1264
  // the visible hole is drawn by the damage mask in UV space: a smooth, fibrous edge with no triangle staircase.
 
1285
  const w = worldAt(lay, hx, hy);
1286
  let made = 0;
1287
  while (left > 0 && made < 40) {
1288
+ const sz = (Math.random() < 0.12 ? 0.8 + Math.random() * 1.0 : 0.2 + Math.random() * 0.45) * rScale;
1289
  left -= sz * sz * 0.6;
1290
  const a = Math.random() * 6.3, r = Math.random() * R0;
1291
  addCrumb(w[0] + Math.cos(a) * r, w[1] + Math.sin(a) * r, w[2] + 0.3 + Math.random() * 0.4,
1292
+ // charcoal powders under a press: pieces barely spring back, they slump out and fall
1293
+ Math.cos(a) * (8 + Math.random() * 22), Math.random() * 12, 6 + Math.random() * 20, sz);
1294
  made++;
1295
  }
1296
+ 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
1297
  active[sheetOf[k0]] = 1; calm[sheetOf[k0]] = 0; s.islandDirty = true;
1298
  crunch();
1299
  breaks++;
 
1337
  e.stopImmediatePropagation();
1338
  return;
1339
  }
1340
+ // carbonized papyrus does not stretch or peel under fingers: it snaps and crumbles where it is grabbed
1341
+ crushAt(lay, best, hit.point, 0.55);
1342
+ for (let q = 0; q < 2; q++) { // and a snap runs off along the fibres of this layer
1343
+ const along = L === 0 ? [1, 0] : [0, 1], sg = q ? -1 : 1;
1344
+ seedCrack(lay, matX[best] + along[0] * sg * 1.2, matY[best] + along[1] * sg * 1.2, along[0] * sg, along[1] * sg);
1345
+ }
1346
+ if (!valid[best]) { // the grab point itself went to dust: hold the nearest piece that is left
1347
+ let nb = -1, nd = Infinity;
1348
+ 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; } }
1349
+ if (nb < 0) return;
1350
+ best = nb;
1351
+ }
1352
  grabbed = best;
1353
  active[sheetOf[best]] = 1; calm[sheetOf[best]] = 0;
1354
  // pinch everything within about 1.6 nodes of the hit, on the layer that was touched