Realism pass for carbonized papyrus, island and outlier prune for unroll morph, docs/REALISM.md, cache bust d
Browse files- README.md +2 -0
- app.js +91 -6
- damage.js +33 -27
- docs/REALISM.md +23 -0
- index.html +7 -7
- 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=
|
| 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=
|
| 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 |
-
|
|
|
|
| 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(
|
| 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 -
|
| 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
|
| 60 |
-
|
| 61 |
-
|
| 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(
|
| 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:
|
| 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:
|
| 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.
|
| 272 |
spawnF(x + Math.cos(a) * r, y + Math.sin(a) * r * 0.6, z + 0.4,
|
| 273 |
-
Math.cos(a) * (
|
| 274 |
-
big ?
|
| 275 |
}
|
| 276 |
-
const nd = kind === 'tear' ? n *
|
| 277 |
for (let i = 0; i < nd; i++) {
|
| 278 |
-
const a = Math.random() * 6.283, sp =
|
| 279 |
-
const c = 0.
|
| 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 +
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 428 |
-
if (Math.random() < 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.
|
| 454 |
-
if (Math.random() < 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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.
|
| 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,
|
| 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=
|
| 30 |
-
<link rel="stylesheet" href="bench.css?v=
|
| 31 |
-
<link rel="stylesheet" href="ui.css?v=
|
| 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=
|
| 206 |
-
<script type="module" src="polish.js?v=
|
| 207 |
-
<script type="module" src="lib/bench.js?v=
|
| 208 |
-
<script type="module" src="ui.js?v=
|
| 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=
|
| 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:
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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 |
-
|
|
|
|
| 1293 |
made++;
|
| 1294 |
}
|
| 1295 |
-
for (let q = 0; q <
|
| 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
|