SharpMeow commited on
Commit
6dde6ff
·
1 Parent(s): f6dcaa7

Destroy mode: UV damage mask with tear, crush, burn and water at any unroll amount; roll up; size-dependent debris

Browse files
Files changed (4) hide show
  1. app.js +157 -20
  2. damage.js +417 -0
  3. index.html +8 -3
  4. tear.js +14 -15
app.js CHANGED
@@ -8,6 +8,7 @@ import { createTear } from './tear.js';
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
 
12
  const $ = (id) => document.getElementById(id);
13
  const params = new URLSearchParams(location.hash.slice(1));
@@ -254,9 +255,11 @@ const shared = {
254
  uRot: { value: 0 }, uRoll: { value: new THREE.Vector3() },
255
  uSweep: { value: 999 }, uLayer: { value: 1 }, uGpuMix: { value: 0 },
256
  uTime: { value: 0 }, uLift: { value: 3.5 },
 
257
  };
258
 
259
  const VERT = /* glsl */`
 
260
  attribute vec2 aLocal;
261
  uniform float uP, uSoft, uRot, uLift;
262
  uniform vec3 uRoll, uFlatO;
@@ -274,6 +277,8 @@ void main() {
274
  vec3 pf = uFlatO + vec3(aLocal.x * uSize.x, -aLocal.y * uSize.y, 0.0);
275
  vec3 p = mix(pc, pf, e);
276
  p.z -= sin(3.14159265 * e) * uLift;
 
 
277
  vN = normalize(mix(nc, vec3(0.0, 0.0, 1.0), e));
278
  vLocal = aLocal;
279
  vE = e;
@@ -335,16 +340,20 @@ void main() {
335
  gl_FragColor = vec4(col, 1.0);
336
  }`;
337
 
338
- const PICK_FRAG = /* glsl */`
339
- uniform float uId;
 
340
  uniform sampler2D uCT;
341
  uniform vec4 uAtlas;
342
  varying vec2 vLocal;
 
343
  void main() {
344
  vec2 auv = uAtlas.xy + vLocal * (uAtlas.zw - uAtlas.xy);
345
  if (texture2D(uCT, auv).r < 0.012) discard;
346
- gl_FragColor = vec4(uId / 255.0, 0.0, 0.0, 1.0);
 
347
  }`;
 
348
 
349
  // windings in peel order: outermost first
350
  const wins = ORDER.map((o) => M.windings.find((w) => w.id === o.id));
@@ -456,9 +465,10 @@ for (const [j, w] of wins.entries()) {
456
  uAtlas: { value: new THREE.Vector4(...w.atlas) },
457
  uGpuTex: { value: gpuTex[w.id] || blank }, uGpuOn: { value: gpuIds.has(w.id) ? 1 : 0 },
458
  uHi: { value: 0 }, uId: { value: j + 1 },
 
459
  };
460
  const mat = new THREE.ShaderMaterial({
461
- uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: FRAG, side: THREE.DoubleSide,
462
  });
463
  const pickMat = new THREE.ShaderMaterial({
464
  uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: PICK_FRAG, side: THREE.DoubleSide,
@@ -479,18 +489,25 @@ for (const m of meshes) {
479
  }
480
  const stripZ = -(Rmax + 3.5);
481
  for (const m of meshes) m.userData.own.uFlatO.value.z = stripZ;
 
 
 
 
482
 
483
  // ---------------------------------------------------------------- tear apart (just for fun)
484
 
485
  let tear = null, tearOn = false;
486
  function setTear(on) {
487
  tearOn = on;
488
- if (on && !tear) tear = createTear({ THREE, scene, camera, controls, canvas, meshes, shared, FRAG, stripZ, ctImage: texCT.image, MOBILE });
489
  for (const m of meshes) m.visible = !on;
490
  if (tear) tear.group.visible = on;
491
- $('btnTear').classList.toggle('on', on);
492
- $('btnTear').setAttribute('aria-pressed', on);
493
- for (const id of ['btnReset', 'btnCrush', 'btnShake', 'btnSound']) $(id).hidden = !on;
 
 
 
494
  document.body.classList.toggle('tearing', on);
495
  if (on) {
496
  introDone(); autoplayAt = 0; setPlaying(false);
@@ -581,14 +598,28 @@ function setPlaying(v) {
581
  // replay from the end: rewind smoothly from where it is, never jump
582
  if (v && target >= 0.999) setTarget(0, 'play');
583
  }
584
- $('play').onclick = () => { introDone(); autoplayAt = 0; setPlaying(!playing); };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  const scrub = $('scrub');
586
  let scrubbing = false;
587
  function scrubTo(ev) {
588
  const r = scrub.getBoundingClientRect();
589
  setTarget((ev.clientX - r.left) / r.width, 'drag');
590
  }
591
- scrub.addEventListener('pointerdown', (e) => { introDone(); autoplayAt = 0; scrubbing = true; setPlaying(false); scrub.setPointerCapture(e.pointerId); scrubTo(e); });
592
  scrub.addEventListener('pointermove', (e) => { if (scrubbing) scrubTo(e); });
593
  scrub.addEventListener('pointerup', () => { scrubbing = false; setTarget(target, 'drag'); saveHash(); });
594
  scrub.addEventListener('pointercancel', () => { scrubbing = false; setTarget(target, 'drag'); });
@@ -741,7 +772,7 @@ function pick(clientX, clientY) {
741
  let hover = null;
742
  let hoverAt = 0;
743
  let down = null;
744
- canvas.addEventListener('pointerdown', (e) => { if (tearOn) return; down = { x: e.clientX, y: e.clientY, t: performance.now() }; });
745
  canvas.addEventListener('pointerup', (e) => {
746
  if (!down || tearOn) return;
747
  const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y);
@@ -752,7 +783,7 @@ canvas.addEventListener('pointerup', (e) => {
752
  down = null;
753
  });
754
  canvas.addEventListener('pointermove', (e) => {
755
- if (MOBILE || e.buttons || tearOn) return;
756
  const now = performance.now();
757
  if (now - hoverAt < 70) return;
758
  hoverAt = now;
@@ -997,14 +1028,114 @@ $('btnShot').onclick = () => {
997
  }, 'image/png');
998
  };
999
 
1000
- $('btnTear').onclick = () => setTear(!tearOn);
1001
- $('btnReset').onclick = () => tear && tear.reset();
1002
- $('btnCrush').onclick = () => {
1003
- const on = $('btnCrush').getAttribute('aria-pressed') !== 'true';
1004
- $('btnCrush').setAttribute('aria-pressed', on);
1005
- $('btnCrush').classList.toggle('on', on);
1006
- tear && tear.setCrush(on);
1007
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  $('btnShake').onclick = () => tear && tear.shake();
1009
  $('btnSound').onclick = () => {
1010
  const on = $('btnSound').getAttribute('aria-pressed') !== 'true';
@@ -1052,6 +1183,12 @@ function frame() {
1052
  }
1053
  if (!intro && autoplayAt && (playing || scrubbing || t > 0)) autoplayAt = 0;
1054
 
 
 
 
 
 
 
1055
  if (playing) {
1056
  setTarget(target + dt / DURATION, 'play');
1057
  if (target >= 1) setPlaying(false);
 
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 { createDamage, injectFrag, DMG_HEAD } from './damage.js';
12
 
13
  const $ = (id) => document.getElementById(id);
14
  const params = new URLSearchParams(location.hash.slice(1));
 
255
  uRot: { value: 0 }, uRoll: { value: new THREE.Vector3() },
256
  uSweep: { value: 999 }, uLayer: { value: 1 }, uGpuMix: { value: 0 },
257
  uTime: { value: 0 }, uLift: { value: 3.5 },
258
+ uDmgT: { value: 0 }, uPickW: { value: 0 },
259
  };
260
 
261
  const VERT = /* glsl */`
262
+ uniform sampler2D uDmg; uniform float uDmgOn;
263
  attribute vec2 aLocal;
264
  uniform float uP, uSoft, uRot, uLift;
265
  uniform vec3 uRoll, uFlatO;
 
277
  vec3 pf = uFlatO + vec3(aLocal.x * uSize.x, -aLocal.y * uSize.y, 0.0);
278
  vec3 p = mix(pc, pf, e);
279
  p.z -= sin(3.14159265 * e) * uLift;
280
+ // soaked papyrus softens and sags
281
+ if (uDmgOn > 0.5) { float wv = texture2D(uDmg, aLocal).a; p.y -= wv * wv * 1.8; p.z += wv * 0.6; }
282
  vN = normalize(mix(nc, vec3(0.0, 0.0, 1.0), e));
283
  vLocal = aLocal;
284
  vE = e;
 
340
  gl_FragColor = vec4(col, 1.0);
341
  }`;
342
 
343
+ // picking: winding id and surface UV (or world position when uPickW is set); holes in the damage mask let picks through to deeper wraps
344
+ const PICK_FRAG = DMG_HEAD + /* glsl */`
345
+ uniform float uId, uPickW;
346
  uniform sampler2D uCT;
347
  uniform vec4 uAtlas;
348
  varying vec2 vLocal;
349
+ varying vec3 vW;
350
  void main() {
351
  vec2 auv = uAtlas.xy + vLocal * (uAtlas.zw - uAtlas.xy);
352
  if (texture2D(uCT, auv).r < 0.012) discard;
353
+ if (uDmgOn > 0.5 && texture2D(uDmg, vLocal).r * 1.08 + (dFib(vLocal * uDmgMM) - 0.5) * 0.55 > 0.5) discard;
354
+ gl_FragColor = uPickW > 0.5 ? vec4(vW, 1.0) : vec4(uId / 255.0, vLocal, 1.0);
355
  }`;
356
+ const FRAG_D = injectFrag(FRAG, 'vec3 n = normalize(vN);');
357
 
358
  // windings in peel order: outermost first
359
  const wins = ORDER.map((o) => M.windings.find((w) => w.id === o.id));
 
465
  uAtlas: { value: new THREE.Vector4(...w.atlas) },
466
  uGpuTex: { value: gpuTex[w.id] || blank }, uGpuOn: { value: gpuIds.has(w.id) ? 1 : 0 },
467
  uHi: { value: 0 }, uId: { value: j + 1 },
468
+ uDmg: { value: blank }, uDmgOn: { value: 0 }, uDmgMM: { value: new THREE.Vector2(w.widthMM, w.heightMM) },
469
  };
470
  const mat = new THREE.ShaderMaterial({
471
+ uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: FRAG_D, side: THREE.DoubleSide,
472
  });
473
  const pickMat = new THREE.ShaderMaterial({
474
  uniforms: { ...shared, ...own }, vertexShader: VERT, fragmentShader: PICK_FRAG, side: THREE.DoubleSide,
 
489
  }
490
  const stripZ = -(Rmax + 3.5);
491
  for (const m of meshes) m.userData.own.uFlatO.value.z = stripZ;
492
+ let groundY = Infinity;
493
+ for (const m of meshes) groundY = Math.min(groundY, m.userData.own.uFlatO.value.y - m.userData.w.heightMM);
494
+ groundY -= 6;
495
+ const damage = createDamage({ THREE, scene, meshes, MOBILE, ground: () => groundY, uDmgT: shared.uDmgT });
496
 
497
  // ---------------------------------------------------------------- tear apart (just for fun)
498
 
499
  let tear = null, tearOn = false;
500
  function setTear(on) {
501
  tearOn = on;
502
+ if (on && !tear) tear = createTear({ THREE, scene, camera, controls, canvas, meshes, shared, FRAG: FRAG_D, stripZ, ctImage: texCT.image, MOBILE, damage });
503
  for (const m of meshes) m.visible = !on;
504
  if (tear) tear.group.visible = on;
505
+ $('btnSandbox').classList.toggle('on', on);
506
+ $('btnSandbox').setAttribute('aria-pressed', on);
507
+ $('btnShake').hidden = !on;
508
+ for (const id of ['toolBurn', 'toolWater']) $(id).disabled = on;
509
+ if (on && (tool === 'burn' || tool === 'water')) setTool('tear');
510
+ if (tear) tear.setCrush(tool === 'crush');
511
  document.body.classList.toggle('tearing', on);
512
  if (on) {
513
  introDone(); autoplayAt = 0; setPlaying(false);
 
598
  // replay from the end: rewind smoothly from where it is, never jump
599
  if (v && target >= 0.999) setTarget(0, 'play');
600
  }
601
+ $('play').onclick = () => { introDone(); autoplayAt = 0; setRolling(false); setPlaying(!playing); };
602
+ // roll it back up: a steady rewind to fully rolled, any time
603
+ let rolling = false;
604
+ function setRolling(v) {
605
+ rolling = v;
606
+ $('btnRollUp').textContent = v ? 'Stop' : 'Roll up';
607
+ $('btnRollUp').classList.toggle('on', v);
608
+ }
609
+ $('btnRollUp').onclick = () => {
610
+ introDone(); autoplayAt = 0;
611
+ if (rolling) { setRolling(false); return; }
612
+ if (tearOn) setTear(false);
613
+ setPlaying(false);
614
+ if (target > 0.0005) setRolling(true);
615
+ };
616
  const scrub = $('scrub');
617
  let scrubbing = false;
618
  function scrubTo(ev) {
619
  const r = scrub.getBoundingClientRect();
620
  setTarget((ev.clientX - r.left) / r.width, 'drag');
621
  }
622
+ scrub.addEventListener('pointerdown', (e) => { introDone(); autoplayAt = 0; setRolling(false); scrubbing = true; setPlaying(false); scrub.setPointerCapture(e.pointerId); scrubTo(e); });
623
  scrub.addEventListener('pointermove', (e) => { if (scrubbing) scrubTo(e); });
624
  scrub.addEventListener('pointerup', () => { scrubbing = false; setTarget(target, 'drag'); saveHash(); });
625
  scrub.addEventListener('pointercancel', () => { scrubbing = false; setTarget(target, 'drag'); });
 
772
  let hover = null;
773
  let hoverAt = 0;
774
  let down = null;
775
+ canvas.addEventListener('pointerdown', (e) => { if (tearOn || destroyOn) return; down = { x: e.clientX, y: e.clientY, t: performance.now() }; });
776
  canvas.addEventListener('pointerup', (e) => {
777
  if (!down || tearOn) return;
778
  const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y);
 
783
  down = null;
784
  });
785
  canvas.addEventListener('pointermove', (e) => {
786
+ if (MOBILE || e.buttons || tearOn || destroyOn) return;
787
  const now = performance.now();
788
  if (now - hoverAt < 70) return;
789
  hoverAt = now;
 
1028
  }, 'image/png');
1029
  };
1030
 
1031
+ // ---------------------------------------------------------------- Destroy: tear, crush, burn and soak at any unroll amount
1032
+ let destroyOn = false, tool = 'tear', act = null;
1033
+ function setTool(tl) {
1034
+ tool = tl;
1035
+ for (const b of document.querySelectorAll('.chip.tool')) { const on = b.dataset.tool === tl; b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
1036
+ document.body.classList.remove('tool-tear', 'tool-crush', 'tool-burn', 'tool-water');
1037
+ if (destroyOn) document.body.classList.add(`tool-${tl}`);
1038
+ if (tear) tear.setCrush(tl === 'crush');
1039
+ }
1040
+ function setDestroy(on) {
1041
+ destroyOn = on;
1042
+ $('btnTear').classList.toggle('on', on);
1043
+ $('btnTear').setAttribute('aria-pressed', on);
1044
+ for (const id of ['toolTear', 'btnCrush', 'toolBurn', 'toolWater', 'btnReset', 'btnSandbox', 'btnSound']) $(id).hidden = !on;
1045
+ document.body.classList.toggle('destroy', on);
1046
+ if (!on && tearOn) setTear(false);
1047
+ if (!on) $('btnShake').hidden = true;
1048
+ setTool(tool);
1049
+ if (on) { introDone(); autoplayAt = 0; $('tip').hidden = true; if (hover) { hover.userData.own.uHi.value = 0; hover = null; } }
1050
+ }
1051
+ $('btnTear').onclick = () => setDestroy(!destroyOn);
1052
+ for (const b of document.querySelectorAll('.chip.tool')) b.onclick = () => setTool(b.dataset.tool);
1053
+ $('btnSandbox').onclick = () => setTear(!tearOn);
1054
+ $('btnReset').onclick = () => { damage.reset(); if (tear) tear.reset(); };
1055
+
1056
+ // pick the surface under the pointer: winding, UV on it and world position, through any holes already made
1057
+ const pickF = new THREE.WebGLRenderTarget(1, 1, { type: THREE.FloatType });
1058
+ const pickFB = new Float32Array(4);
1059
+ function pickHit(x, y) {
1060
+ const dpr = renderer.getPixelRatio();
1061
+ camera.setViewOffset(innerWidth * dpr, innerHeight * dpr, x * dpr, y * dpr, 1, 1);
1062
+ for (const m of meshes) m.material = m.userData.pickMat;
1063
+ for (const o of damage.objects) o.visible = false;
1064
+ renderer.setRenderTarget(pickF);
1065
+ renderer.setClearColor(0x000000, 0);
1066
+ shared.uPickW.value = 0;
1067
+ renderer.clear(); renderer.render(scene, camera);
1068
+ renderer.readRenderTargetPixels(pickF, 0, 0, 1, 1, pickFB);
1069
+ const id = Math.round(pickFB[0] * 255), u = pickFB[1], v = pickFB[2];
1070
+ let world = null;
1071
+ if (id) {
1072
+ shared.uPickW.value = 1;
1073
+ renderer.clear(); renderer.render(scene, camera);
1074
+ renderer.readRenderTargetPixels(pickF, 0, 0, 1, 1, pickFB);
1075
+ world = new THREE.Vector3(pickFB[0], pickFB[1], pickFB[2]);
1076
+ }
1077
+ shared.uPickW.value = 0;
1078
+ renderer.setRenderTarget(null);
1079
+ renderer.setClearColor(0x030303, 1);
1080
+ for (const m of meshes) m.material = m.userData.mat;
1081
+ for (const o of damage.objects) o.visible = true;
1082
+ camera.clearViewOffset();
1083
+ return id && meshes[id - 1] ? { m: meshes[id - 1], u, v, world } : null;
1084
+ }
1085
+ function applyTool(first) {
1086
+ const h = pickHit(act.x, act.y);
1087
+ if (!h) { act.last = null; return; }
1088
+ const now = performance.now();
1089
+ if (act.tool === 'tear') {
1090
+ const l = act.last;
1091
+ if (l && l.m === h.m) {
1092
+ const mm = Math.hypot((h.u - l.u) * h.m.userData.w.widthMM, (h.v - l.v) * h.m.userData.w.heightMM);
1093
+ if (mm < 0.4) return;
1094
+ if (mm < 18) { damage.tear(h.m, l.u, l.v, h.u, h.v); damage.debris(h.world, 0.8, mm > 2 ? 2 : 1, 'tear'); }
1095
+ }
1096
+ act.last = h;
1097
+ } else if (act.tool === 'crush') {
1098
+ // hold to press harder: a wider bite, and as the outer wrap gives way the next pick lands on the wrap beneath
1099
+ const hold = (now - act.t0) / 1000;
1100
+ const r = 1.5 + 1.2 * Math.min(hold, 2.5);
1101
+ damage.crush(h.m, h.u, h.v, r, 0.5 + 0.15 * Math.min(hold, 2));
1102
+ damage.debris(h.world, r, MOBILE ? 2 : 4);
1103
+ } else if (act.tool === 'burn') {
1104
+ if (first) damage.ignite(h.m, h.u, h.v, h.world);
1105
+ } else if (act.tool === 'water') {
1106
+ damage.wetAt(h.m, h.u, h.v, 3.0, first ? 0.45 : 0.22);
1107
+ damage.drops(h.world, first ? 3 : 1);
1108
+ }
1109
+ }
1110
+ function touchIcon(e) {
1111
+ const d = document.createElement('div');
1112
+ d.className = `touch-pop ${tool}`;
1113
+ d.style.left = `${e.clientX}px`; d.style.top = `${e.clientY}px`;
1114
+ document.body.appendChild(d);
1115
+ setTimeout(() => d.remove(), 650);
1116
+ }
1117
+ canvas.addEventListener('pointerdown', (e) => {
1118
+ if (!destroyOn || tearOn || e.button > 0) return;
1119
+ act = { tool, x: e.clientX, y: e.clientY, t0: performance.now(), tick: performance.now(), last: null };
1120
+ const h = pickHit(e.clientX, e.clientY);
1121
+ if (!h) { act = null; return; }
1122
+ e.stopImmediatePropagation();
1123
+ controls.enabled = false;
1124
+ canvas.setPointerCapture(e.pointerId);
1125
+ autoplayAt = 0; setPlaying(false); setRolling(false);
1126
+ document.body.classList.add('pressing');
1127
+ if (e.pointerType === 'touch') touchIcon(e);
1128
+ applyTool(true);
1129
+ }, true);
1130
+ canvas.addEventListener('pointermove', (e) => {
1131
+ if (!act) return;
1132
+ act.x = e.clientX; act.y = e.clientY;
1133
+ if (act.tool === 'tear' && performance.now() - act.tick > 24) { act.tick = performance.now(); applyTool(false); }
1134
+ });
1135
+ const endAct = () => { if (!act) return; act = null; controls.enabled = true; document.body.classList.remove('pressing'); };
1136
+ canvas.addEventListener('pointerup', endAct);
1137
+ canvas.addEventListener('pointercancel', endAct);
1138
+ document.body.classList.add('js-tools');
1139
  $('btnShake').onclick = () => tear && tear.shake();
1140
  $('btnSound').onclick = () => {
1141
  const on = $('btnSound').getAttribute('aria-pressed') !== 'true';
 
1183
  }
1184
  if (!intro && autoplayAt && (playing || scrubbing || t > 0)) autoplayAt = 0;
1185
 
1186
+ if (rolling) {
1187
+ setTarget(target - dt / 7, 'play');
1188
+ if (target <= 0) setRolling(false);
1189
+ }
1190
+ if (act && (act.tool === 'crush' || act.tool === 'water') && now - act.tick > (act.tool === 'crush' ? 70 : 90)) { act.tick = now; applyTool(false); }
1191
+ damage.update(dt, shared.uTime.value);
1192
  if (playing) {
1193
  setTarget(target + dt / DURATION, 'play');
1194
  if (target >= 1) setPlaying(false);
damage.js ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Destroy mode: damage painted into a high-resolution mask in each winding's UV space, so holes, cracks, embers
2
+ // and wet patches follow the surface as it rolls and unrolls. The visual edge is an alpha test in the fragment
3
+ // shader against that smooth field plus fibre-direction noise (no per-triangle removal, so no staircase).
4
+ // Channels: R missing (crushed, torn, burnt), G cracks, B ember heat, A wetness.
5
+ // Particles (dust, fragments, flakes, smoke, ash, drops, puddles) live in their own capped system.
6
+
7
+ export const DMG_HEAD = /* glsl */`
8
+ uniform sampler2D uDmg; uniform float uDmgOn; uniform vec2 uDmgMM; uniform float uDmgT;
9
+ float dHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
10
+ float dNoise(vec2 p) {
11
+ vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
12
+ return mix(mix(dHash(i), dHash(i + vec2(1.0, 0.0)), f.x), mix(dHash(i + vec2(0.0, 1.0)), dHash(i + vec2(1.0, 1.0)), f.x), f.y);
13
+ }
14
+ // papyrus fibres run along the sheet: noise stretched along x, fine across y
15
+ float dFib(vec2 mm) {
16
+ vec2 p = mm * vec2(0.7, 6.5);
17
+ return 0.5 * dNoise(p) + 0.3 * dNoise(p * 2.13 + 3.7) + 0.2 * dNoise(mm * 6.0 + 11.0);
18
+ }
19
+ `;
20
+ // after the surface normal is known: alpha test against the field
21
+ export const DMG_CUT = /* glsl */`
22
+ vec4 dd = vec4(0.0); float dTh = 0.0, dFibV = 0.5;
23
+ if (uDmgOn > 0.5) {
24
+ dd = texture2D(uDmg, vLocal);
25
+ dFibV = dFib(vLocal * uDmgMM);
26
+ dTh = dd.r * 1.08 + (dFibV - 0.5) * 0.55;
27
+ if (dTh > 0.5) discard;
28
+ }
29
+ `;
30
+ // before the final colour: charred band, lifted lip and its shadow, cracks, wet gloss, embers
31
+ export const DMG_EDGE = /* glsl */`
32
+ if (uDmgOn > 0.5) {
33
+ float charK = smoothstep(0.40, 0.495, dTh);
34
+ float lipK = smoothstep(0.27, 0.37, dTh) * (1.0 - charK);
35
+ float shadow = smoothstep(0.38, 0.7, texture2D(uDmg, vLocal + vec2(-0.3, 0.45) / uDmgMM).r) * (1.0 - charK);
36
+ col *= 1.0 + 0.32 * lipK;
37
+ col *= 1.0 - 0.5 * shadow;
38
+ float crack = dd.g * smoothstep(0.35, 0.75, dFibV + dd.g * 0.35);
39
+ col = mix(col, vec3(0.009, 0.006, 0.005), clamp(charK * 0.92 + crack * 0.9, 0.0, 1.0));
40
+ float wet = dd.a;
41
+ col *= 1.0 - 0.6 * wet;
42
+ vec3 dH = normalize(normalize(vec3(-0.35, 0.75, 0.55)) + normalize(cameraPosition - vW));
43
+ col += wet * pow(abs(dot(normalize(vN), dH)), 70.0) * vec3(0.6, 0.56, 0.5);
44
+ float flick = 0.7 + 0.3 * sin(uDmgT * 2.3 + dFibV * 23.0) * sin(uDmgT * 1.1 + dFibV * 7.0);
45
+ float emb = dd.b * flick;
46
+ col += vec3(1.0, 0.30, 0.05) * emb * (0.6 + 2.0 * charK) * 1.6;
47
+ }
48
+ `;
49
+ export function injectFrag(frag, cutAfter) {
50
+ if (!frag.includes(cutAfter)) throw new Error('damage: shader anchor missing');
51
+ return DMG_HEAD + frag.replace(cutAfter, cutAfter + DMG_CUT).replace('gl_FragColor = vec4(col, 1.0);', DMG_EDGE + ' gl_FragColor = vec4(col, 1.0);');
52
+ }
53
+
54
+ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT }) {
55
+ const RES = MOBILE ? 1024 : 2048;
56
+ const masks = new Map();
57
+ const shared = { uDmgT: uDmgT || { value: 0 } };
58
+ const own = (m) => m.userData.own;
59
+ function mask(m) {
60
+ let k = masks.get(m);
61
+ if (k) return k;
62
+ const { w } = m.userData;
63
+ const W = RES, H = Math.max(64, Math.round((RES * w.heightMM) / w.widthMM));
64
+ const data = new Uint8Array(W * H * 4);
65
+ const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat);
66
+ tex.minFilter = tex.magFilter = THREE.LinearFilter;
67
+ tex.generateMipmaps = false; tex.flipY = false; tex.needsUpdate = true;
68
+ k = { m, W, H, data, tex, pxmm: W / w.widthMM, dirty: true, embers: [], wet: [], world: null };
69
+ masks.set(m, k);
70
+ own(m).uDmg.value = tex; own(m).uDmgOn.value = 1;
71
+ return k;
72
+ }
73
+ const at = (k, u, v, c) => {
74
+ const x = Math.min(k.W - 1, Math.max(0, Math.round(u * k.W))), y = Math.min(k.H - 1, Math.max(0, Math.round(v * k.H)));
75
+ return k.data[(y * k.W + x) * 4 + c] / 255;
76
+ };
77
+ // a soft elliptical brush, wider along the fibres; amount adds to one channel
78
+ function blob(k, u, v, rMM, c, amount, aspect = 1.45) {
79
+ const cx = u * k.W, cy = v * k.H, rx = rMM * k.pxmm * aspect, ry = rMM * k.pxmm;
80
+ const x0 = Math.max(0, Math.floor(cx - rx)), x1 = Math.min(k.W - 1, Math.ceil(cx + rx));
81
+ const y0 = Math.max(0, Math.floor(cy - ry)), y1 = Math.min(k.H - 1, Math.ceil(cy + ry));
82
+ const d = k.data, W = k.W, a = amount * 255;
83
+ for (let y = y0; y <= y1; y++) {
84
+ const dy = (y - cy) / ry, dy2 = dy * dy;
85
+ for (let x = x0; x <= x1; x++) {
86
+ const dx = (x - cx) / rx, q = dx * dx + dy2;
87
+ if (q >= 1) continue;
88
+ const f = (1 - q) * (1 - q);
89
+ const i = (y * W + x) * 4 + c;
90
+ d[i] = Math.min(255, d[i] + a * f);
91
+ }
92
+ }
93
+ k.dirty = true;
94
+ }
95
+ function line(k, u0, v0, u1, v1, rMM, c, amount, wobble) {
96
+ const mm = Math.hypot((u1 - u0) * k.W, (v1 - v0) * k.H) / k.pxmm;
97
+ const n = Math.max(1, Math.ceil(mm / (rMM * 0.6)));
98
+ const nx = -(v1 - v0) * k.H, ny = (u1 - u0) * k.W, nl = Math.hypot(nx, ny) || 1;
99
+ for (let i = 0; i <= n; i++) {
100
+ const f = i / n, j = wobble ? (Math.sin(f * 9.1 + u0 * 50) * 0.6 + (Math.random() - 0.5)) * wobble * k.pxmm : 0;
101
+ blob(k, u0 + (u1 - u0) * f + (nx / nl) * j / k.W, v0 + (v1 - v0) * f + (ny / nl) * j / k.H, rMM, c, amount, 1.15);
102
+ }
103
+ }
104
+ const mmToUV = (k, dx, dy) => [dx * k.pxmm / k.W, dy * k.pxmm / k.H];
105
+
106
+ // ------------------------------------------------------------ tools
107
+ function crush(m, u, v, rMM, strength) {
108
+ const k = mask(m);
109
+ const wet = at(k, u, v, 3);
110
+ const s = strength * (1 + 1.6 * wet); // soaked papyrus gives way
111
+ blob(k, u, v, rMM, 0, 0.95 * s);
112
+ for (let i = 0; i < 5; i++) {
113
+ const a = Math.random() * 6.283, r = rMM * (0.5 + Math.random() * 0.6);
114
+ const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r * 0.7);
115
+ blob(k, u + du, v + dv, rMM * (0.35 + Math.random() * 0.35), 0, 0.7 * s);
116
+ }
117
+ // radial cracks into the surrounding sheet
118
+ const nc = 2 + (Math.random() * 3 | 0);
119
+ for (let i = 0; i < nc; i++) {
120
+ const a = Math.random() * 6.283, l = rMM * (1.2 + Math.random() * 1.6);
121
+ const [a0, b0] = mmToUV(k, Math.cos(a) * rMM * 0.7, Math.sin(a) * rMM * 0.7);
122
+ const [a1, b1] = mmToUV(k, Math.cos(a) * l * 1.4, Math.sin(a) * l * 0.6);
123
+ line(k, u + a0, v + b0, u + a1, v + b1, 0.22, 1, 0.9, 0.25);
124
+ }
125
+ }
126
+ function tear(m, u0, v0, u1, v1) {
127
+ const k = mask(m);
128
+ const wet = at(k, u1, v1, 3);
129
+ line(k, u0, v0, u1, v1, 0.32 + wet * 0.4, 0, 1.0, 0.35);
130
+ line(k, u0, v0, u1, v1, 1.1, 1, 0.35, 0.6);
131
+ }
132
+ function wetAt(m, u, v, rMM, amount) {
133
+ const k = mask(m);
134
+ blob(k, u, v, rMM, 3, amount, 1.25);
135
+ k.wet.push({ u, v, r: rMM, t: 0 });
136
+ if (k.wet.length > 200) k.wet.shift();
137
+ // water puts embers out
138
+ for (const e of k.embers) if (Math.hypot((e.u - u) * k.W, (e.v - v) * k.H) / k.pxmm < rMM * 1.3) e.life = 0;
139
+ }
140
+ let emberCount = 0;
141
+ const EMAX = MOBILE ? 140 : 360;
142
+ function ignite(m, u, v, world) {
143
+ const k = mask(m);
144
+ k.world = world.clone();
145
+ k.burnR = 0;
146
+ for (let i = 0; i < 4; i++) addEmber(k, u, v, 0);
147
+ }
148
+ function addEmber(k, u, v, gen) {
149
+ if (emberCount >= EMAX || u < 0 || v < 0 || u > 1 || v > 1) return;
150
+ if (at(k, u, v, 3) > 0.35) return; // too wet to catch
151
+ if (at(k, u, v, 0) > 0.6) return; // already gone
152
+ k.embers.push({ u, v, gen, life: 2.5 + Math.random() * 3.0, age: 0, next: 0.4 + Math.random() * 0.8 });
153
+ emberCount++;
154
+ }
155
+
156
+ // ------------------------------------------------------------ particles
157
+ const PMAX = MOBILE ? 700 : 1800;
158
+ const pPos = new Float32Array(PMAX * 3), pVel = new Float32Array(PMAX * 3), pCol = new Float32Array(PMAX * 4), pSize = new Float32Array(PMAX);
159
+ const pSleep = new Uint8Array(PMAX), pType = new Uint8Array(PMAX), pLife = new Float32Array(PMAX), pAge = new Float32Array(PMAX), pSeed = new Float32Array(PMAX);
160
+ let pHead = 0;
161
+ const pGeo = new THREE.BufferGeometry();
162
+ pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage));
163
+ pGeo.setAttribute('aCol', new THREE.BufferAttribute(pCol, 4).setUsage(THREE.DynamicDrawUsage));
164
+ pGeo.setAttribute('aSize', new THREE.BufferAttribute(pSize, 1).setUsage(THREE.DynamicDrawUsage));
165
+ const pMat = new THREE.ShaderMaterial({
166
+ transparent: true, depthWrite: false,
167
+ uniforms: { uScale: { value: 400 } },
168
+ vertexShader: /* glsl */`attribute vec4 aCol; attribute float aSize; uniform float uScale; varying vec4 vC;
169
+ void main() { vC = aCol; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv;
170
+ gl_PointSize = aCol.a <= 0.0 ? 0.0 : max(1.0, aSize * uScale / -mv.z); }`,
171
+ fragmentShader: /* glsl */`varying vec4 vC;
172
+ void main() { float d = length(gl_PointCoord - 0.5) * 2.0; float a = smoothstep(1.0, 0.25, d) * vC.a; if (a < 0.01) discard; gl_FragColor = vec4(vC.rgb, a); }`,
173
+ });
174
+ const points = new THREE.Points(pGeo, pMat);
175
+ points.frustumCulled = false;
176
+ scene.add(points);
177
+ // types: 0 dust, 1 ash, 2 smoke, 3 drop, 4 spark
178
+ function spawnP(type, x, y, z, vx, vy, vz, size, life, r, g, b, a) {
179
+ const i = pHead++ % PMAX;
180
+ pSleep[i] = 0; pType[i] = type; pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z;
181
+ pVel[i * 3] = vx; pVel[i * 3 + 1] = vy; pVel[i * 3 + 2] = vz;
182
+ pSize[i] = size; pLife[i] = life; pAge[i] = 0; pSeed[i] = Math.random() * 100;
183
+ pCol[i * 4] = r; pCol[i * 4 + 1] = g; pCol[i * 4 + 2] = b; pCol[i * 4 + 3] = a;
184
+ }
185
+ // fragments and flakes: tumbling instanced shards
186
+ const FMAX = MOBILE ? 220 : 600;
187
+ const shard = new THREE.BufferGeometry();
188
+ {
189
+ const v = [0, 0, 0], n = 6;
190
+ for (let i = 0; i <= n; i++) { const a = (i / n) * 6.283, r = 0.5 * (0.6 + 0.4 * Math.abs(Math.sin(i * 2.7 + 1.3))); v.push(Math.cos(a) * r, Math.sin(a) * r * 0.7, (i % 2) * 0.06); }
191
+ const idx = []; for (let i = 1; i <= n; i++) idx.push(0, i, i + 1);
192
+ shard.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); shard.setIndex(idx); shard.computeVertexNormals();
193
+ }
194
+ const fMesh = new THREE.InstancedMesh(shard, new THREE.MeshStandardMaterial({ color: 0x2a1d14, roughness: 0.9, metalness: 0, side: THREE.DoubleSide }), FMAX);
195
+ fMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
196
+ fMesh.frustumCulled = false;
197
+ fMesh.count = 0;
198
+ scene.add(fMesh);
199
+ const light = new THREE.HemisphereLight(0xfff0dd, 0x221810, 1.6);
200
+ scene.add(light);
201
+ const fPos = new Float32Array(FMAX * 3), fVel = new Float32Array(FMAX * 3), fRot = new Float32Array(FMAX * 3), fSpin = new Float32Array(FMAX * 3);
202
+ const fSize = new Float32Array(FMAX), fAge = new Float32Array(FMAX), fRest = new Uint8Array(FMAX), fLive = new Uint8Array(FMAX);
203
+ let fHead = 0;
204
+ function spawnF(x, y, z, vx, vy, vz, size) {
205
+ const i = fHead++ % FMAX;
206
+ fLive[i] = 1; fRest[i] = 0; fAge[i] = 0; fSize[i] = size;
207
+ fPos[i * 3] = x; fPos[i * 3 + 1] = y; fPos[i * 3 + 2] = z;
208
+ fVel[i * 3] = vx; fVel[i * 3 + 1] = vy; fVel[i * 3 + 2] = vz;
209
+ for (let c = 0; c < 3; c++) { fRot[i * 3 + c] = Math.random() * 6.3; fSpin[i * 3 + c] = (Math.random() - 0.5) * 14; }
210
+ fMesh.count = Math.max(fMesh.count, Math.min(FMAX, fHead));
211
+ }
212
+ // puddles on the floor
213
+ const QMAX = 40;
214
+ const qMesh = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 28).rotateX(-Math.PI / 2),
215
+ new THREE.MeshStandardMaterial({ color: 0x0b0d10, roughness: 0.08, metalness: 0.2, transparent: true, opacity: 0.85 }), QMAX);
216
+ qMesh.frustumCulled = false; qMesh.count = 0;
217
+ scene.add(qMesh);
218
+ const puddles = [];
219
+ function feedPuddle(x, z, vol) {
220
+ let p = puddles.find((q) => Math.hypot(q.x - x, q.z - z) < q.r + 3);
221
+ if (!p) { if (puddles.length >= QMAX) puddles.shift(); p = { x, z, vol: 0, r: 0 }; puddles.push(p); }
222
+ p.vol += vol;
223
+ }
224
+
225
+ // the debris a hit makes, by size: fine dust hangs, fragments flutter, flakes drop
226
+ function debris(world, rMM, n, kind = 'crush') {
227
+ const { x, y, z } = world;
228
+ for (let i = 0; i < n; i++) {
229
+ const a = Math.random() * 6.283, r = Math.random() * rMM;
230
+ const big = Math.random() < 0.25;
231
+ spawnF(x + Math.cos(a) * r, y + Math.sin(a) * r * 0.6, z + 0.4,
232
+ Math.cos(a) * (20 + Math.random() * 50), 10 + Math.random() * 40, 20 + Math.random() * 50,
233
+ big ? 1.1 + Math.random() * 1.5 : 0.35 + Math.random() * 0.6);
234
+ }
235
+ const nd = kind === 'tear' ? n * 2 : n * 5;
236
+ for (let i = 0; i < nd; i++) {
237
+ const a = Math.random() * 6.283, sp = 15 + Math.random() * 35; // a little puff outward
238
+ const c = 0.10 + Math.random() * 0.06;
239
+ spawnP(0, x + (Math.random() - 0.5) * rMM, y + (Math.random() - 0.5) * rMM, z + 0.5,
240
+ 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);
241
+ }
242
+ }
243
+ function drops(world, n) {
244
+ for (let i = 0; i < n; i++) {
245
+ const x = world.x + (Math.random() - 0.5) * 3, z = world.z + (Math.random() - 0.5) * 3;
246
+ spawnP(3, x, world.y + 25 + Math.random() * 20, z, 0, -60, 0, 0.9 + Math.random() * 0.5, 3, 0.55, 0.65, 0.75, 0.9);
247
+ if (Math.random() < 0.5) spawnP(3, x, world.y - 1, z, 0, -5, 0, 0.7, 4, 0.45, 0.55, 0.65, 0.85); // drips off the sheet
248
+ }
249
+ }
250
+
251
+ // ------------------------------------------------------------ update
252
+ const tmpM = new THREE.Matrix4(), tmpQ = new THREE.Quaternion(), tmpE = new THREE.Euler(), tmpS = new THREE.Vector3(), tmpP = new THREE.Vector3();
253
+ let burnTick = 0, wetTick = 0, upTick = 0, decayTick = 0;
254
+ function update(dt, time) {
255
+ shared.uDmgT.value = time;
256
+ // burning: slow ember creep, faster along the fibres, char behind it, smoke and ash above
257
+ burnTick += dt;
258
+ if (burnTick > 0.1) {
259
+ const h = burnTick; burnTick = 0;
260
+ for (const k of masks.values()) {
261
+ if (!k.embers.length) continue;
262
+ const keep = [];
263
+ for (const e of k.embers) {
264
+ e.age += h; e.life -= h; e.next -= h;
265
+ blob(k, e.u, e.v, 0.9, 2, 0.20 * h * 10 * 0.12);
266
+ blob(k, e.u, e.v, 0.7, 0, 0.05 * h * 10 * 0.1 + (e.age > 1.5 ? 0.03 : 0));
267
+ if (e.next <= 0 && e.gen < 40) {
268
+ e.next = 0.6 + Math.random() * 1.0;
269
+ const along = Math.random() < 0.7, s = 0.6 + Math.random() * 0.8, a = Math.random() < 0.5 ? -1 : 1;
270
+ const [du, dv] = mmToUV(k, along ? a * s * 1.4 : (Math.random() - 0.5) * s, along ? (Math.random() - 0.5) * s * 0.4 : a * s * 0.7);
271
+ if (Math.random() < 0.93 - e.gen * 0.012) addEmber(k, e.u + du, e.v + dv, e.gen + 1);
272
+ }
273
+ if (e.life > 0) keep.push(e);
274
+ else { blob(k, e.u, e.v, 1.0, 0, 0.55); emberCount--; if (k.world && Math.random() < 0.5) spawnF(k.world.x + (Math.random() - 0.5) * 6, k.world.y + (Math.random() - 0.5) * 4, k.world.z + 0.3, (Math.random() - 0.5) * 10, 5, 4, 0.3 + Math.random() * 0.4); }
275
+ }
276
+ k.embers = keep;
277
+ if (k.world && keep.length) {
278
+ const spread = Math.min(20, 2 + Math.sqrt(keep.length) * 0.9);
279
+ for (let i = 0; i < Math.min(3, keep.length / 6 + 1); i++) {
280
+ const g = 0.16 + Math.random() * 0.1;
281
+ spawnP(2, k.world.x + (Math.random() - 0.5) * spread, k.world.y + (Math.random() - 0.5) * spread * 0.6, k.world.z + 1,
282
+ (Math.random() - 0.5) * 6, 8 + Math.random() * 6, 4 + Math.random() * 4, 2.5 + Math.random() * 2, 4 + Math.random() * 2, g, g * 0.95, g * 0.9, 0.35);
283
+ }
284
+ if (Math.random() < 0.6) spawnP(1, k.world.x + (Math.random() - 0.5) * spread, k.world.y + (Math.random() - 0.5) * spread * 0.6, k.world.z + 1,
285
+ (Math.random() - 0.5) * 8, 12 + Math.random() * 10, 3, 0.5 + Math.random() * 0.6, 6, 0.32, 0.30, 0.28, 0.85);
286
+ if (Math.random() < 0.25) spawnP(4, k.world.x + (Math.random() - 0.5) * spread, k.world.y, k.world.z + 1, (Math.random() - 0.5) * 10, 25 + Math.random() * 15, 3, 0.25, 1.2, 1.0, 0.45, 0.1, 1.0);
287
+ }
288
+ }
289
+ }
290
+ // ember glow cools where nothing burns any more
291
+ decayTick += dt;
292
+ if (decayTick > 0.25) {
293
+ const f = Math.exp(-decayTick * 0.6); decayTick = 0;
294
+ for (const k of masks.values()) {
295
+ if (!k.hot && !k.embers.length) continue;
296
+ const d = k.data; let any = false;
297
+ for (let i = 2; i < d.length; i += 4) if (d[i]) { d[i] = d[i] * f; any = true; }
298
+ k.hot = any; k.dirty = true;
299
+ }
300
+ }
301
+ // soaked spots slowly dissolve and delaminate
302
+ wetTick += dt;
303
+ if (wetTick > 0.4) {
304
+ wetTick = 0;
305
+ for (const k of masks.values()) for (const w of k.wet) {
306
+ w.t += 0.4;
307
+ if (w.t < 2.5 || at(k, w.u, w.v, 3) < 0.55 || Math.random() > 0.35) continue;
308
+ const a = Math.random() * 6.283, r = Math.random() * w.r * 0.8;
309
+ const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r);
310
+ blob(k, w.u + du, w.v + dv, 0.5 + Math.random() * 0.6, 0, 0.12);
311
+ if (Math.random() < 0.3) blob(k, w.u + du, w.v + dv, 1.2, 1, 0.15);
312
+ }
313
+ }
314
+ // texture uploads, throttled
315
+ upTick += dt;
316
+ if (upTick > (MOBILE ? 0.09 : 0.05)) {
317
+ upTick = 0;
318
+ for (const k of masks.values()) if (k.dirty) { k.tex.needsUpdate = true; k.dirty = false; }
319
+ }
320
+ stepParticles(dt, time);
321
+ }
322
+ function stepParticles(dt, time) {
323
+ const G = ground();
324
+ for (let i = 0; i < PMAX; i++) {
325
+ if (pCol[i * 4 + 3] <= 0) continue;
326
+ pAge[i] += dt;
327
+ const t = pType[i], o = i * 3, life = pLife[i];
328
+ if (pAge[i] > life) { pCol[i * 4 + 3] = 0; continue; }
329
+ if (pSleep[i]) { // landed: asleep on the floor, only fading
330
+ pCol[i * 4 + 3] = Math.min(pCol[i * 4 + 3], Math.max(0, 1 - pAge[i] / life) * 0.9);
331
+ continue;
332
+ }
333
+ let g, k, turb;
334
+ if (t === 0) { g = 22; k = 3.2; turb = 10; } // dust: terminal velocity about 7 mm/s
335
+ else if (t === 1) { g = 6; k = 1.6; turb = 12; } // ash: warm air carries it up first
336
+ else if (t === 2) { g = -14; k = 1.1; turb = 14; } // smoke rises
337
+ else if (t === 3) { g = 520; k = 0.3; turb = 0; } // water drops
338
+ else { g = 40; k = 1.2; turb = 6; } // sparks
339
+ const x = pPos[o], y = pPos[o + 1], z = pPos[o + 2], s = pSeed[i], tt = time * 0.8;
340
+ const tx = Math.sin(0.11 * y + tt + s) + Math.sin(0.09 * z - 0.7 * tt + 1.3);
341
+ const ty = Math.sin(0.10 * z + 0.6 * tt + 2.1 + s) + Math.sin(0.12 * x - 0.8 * tt);
342
+ const tz = Math.sin(0.1 * x + 0.75 * tt + 0.7) + Math.sin(0.09 * y + 0.5 * tt + 3.0 + s);
343
+ const e = Math.exp(-k * dt);
344
+ pVel[o] = pVel[o] * e + tx * turb * dt;
345
+ pVel[o + 1] = pVel[o + 1] * e + (ty * turb * 0.6 - g) * dt;
346
+ pVel[o + 2] = pVel[o + 2] * e + tz * turb * dt;
347
+ pPos[o] += pVel[o] * dt; pPos[o + 1] += pVel[o + 1] * dt; pPos[o + 2] += pVel[o + 2] * dt;
348
+ const fade = 1 - pAge[i] / life;
349
+ // guard: anything broken is recycled
350
+ if (!Number.isFinite(pPos[o] + pPos[o + 1] + pPos[o + 2] + pVel[o] + pVel[o + 1] + pVel[o + 2])) { pCol[i * 4 + 3] = 0; continue; }
351
+ if (pPos[o + 1] <= G) {
352
+ if (t === 3) { feedPuddle(pPos[o], pPos[o + 2], 1); pCol[i * 4 + 3] = 0; continue; }
353
+ if (t === 2 || t === 4) { pCol[i * 4 + 3] = 0; continue; }
354
+ // dust and ash land: snap to the floor, stop, sleep
355
+ pPos[o + 1] = G + 0.05; pVel[o] = pVel[o + 1] = pVel[o + 2] = 0; pSleep[i] = 1;
356
+ pLife[i] = pAge[i] + 6 + Math.random() * 4;
357
+ continue;
358
+ }
359
+ if (t === 2) { pSize[i] += dt * 2.2; pCol[i * 4 + 3] = 0.32 * fade * Math.min(1, pAge[i] * 3); }
360
+ else if (t === 4) pCol[i * 4 + 3] = fade;
361
+ else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9);
362
+ }
363
+ pGeo.attributes.position.needsUpdate = pGeo.attributes.aCol.needsUpdate = pGeo.attributes.aSize.needsUpdate = true;
364
+ // fragments flutter and tumble with lift; big flakes drop
365
+ for (let i = 0; i < fMesh.count; i++) {
366
+ const o = i * 3;
367
+ if (!fLive[i]) { tmpM.makeScale(0, 0, 0); fMesh.setMatrixAt(i, tmpM); continue; }
368
+ fAge[i] += dt;
369
+ if (!Number.isFinite(fPos[o] + fPos[o + 1] + fPos[o + 2])) { fLive[i] = 0; continue; }
370
+ if (!fRest[i]) {
371
+ const big = fSize[i] > 1.0;
372
+ const g = big ? 380 : 160, k = big ? 0.6 : 3.0;
373
+ const tilt = Math.abs(Math.sin(fRot[o])), flut = big ? 0 : 55;
374
+ const e = Math.exp(-k * (1 + 1.5 * tilt) * dt);
375
+ fVel[o] = fVel[o] * e + Math.sin(fAge[i] * 7 + i) * flut * dt;
376
+ fVel[o + 1] = fVel[o + 1] * e - g * dt;
377
+ fVel[o + 2] = fVel[o + 2] * e + Math.cos(fAge[i] * 5.3 + i) * flut * 0.6 * dt;
378
+ for (let c = 0; c < 3; c++) { fPos[o + c] += fVel[o + c] * dt; fRot[o + c] += fSpin[o + c] * dt; }
379
+ if (fPos[o + 1] < ground() + 0.1) { fPos[o + 1] = ground() + 0.1; fRest[i] = 1; fRot[o] = Math.PI / 2 + (Math.random() - 0.5) * 0.3; }
380
+ }
381
+ const fadeS = fAge[i] > 25 ? Math.max(0, 1 - (fAge[i] - 25) / 5) : 1;
382
+ if (fadeS <= 0) fLive[i] = 0;
383
+ tmpE.set(fRot[o], fRot[o + 1], fRot[o + 2]);
384
+ tmpQ.setFromEuler(tmpE);
385
+ tmpS.setScalar(fSize[i] * fadeS);
386
+ tmpP.set(fPos[o], fPos[o + 1], fPos[o + 2]);
387
+ tmpM.compose(tmpP, tmpQ, tmpS);
388
+ fMesh.setMatrixAt(i, tmpM);
389
+ }
390
+ fMesh.instanceMatrix.needsUpdate = true;
391
+ // puddles spread and slowly soak away
392
+ const G2 = ground() + 0.05;
393
+ qMesh.count = puddles.length;
394
+ puddles.forEach((p, i) => {
395
+ p.vol *= Math.exp(-dt * 0.02);
396
+ const want = Math.sqrt(p.vol) * 1.4;
397
+ p.r += (want - p.r) * Math.min(1, dt * 2);
398
+ tmpM.compose(tmpP.set(p.x, G2, p.z), tmpQ.identity(), tmpS.set(p.r, 1, p.r * 0.8));
399
+ qMesh.setMatrixAt(i, tmpM);
400
+ });
401
+ qMesh.instanceMatrix.needsUpdate = true;
402
+ }
403
+
404
+ function reset() {
405
+ for (const k of masks.values()) { own(k.m).uDmgOn.value = 0; own(k.m).uDmg.value = blankTex; k.tex.dispose(); }
406
+ masks.clear();
407
+ emberCount = 0;
408
+ pCol.fill(0); fLive.fill(0); fMesh.count = 0; fHead = 0; puddles.length = 0; qMesh.count = 0;
409
+ }
410
+ const blankTex = new THREE.DataTexture(new Uint8Array(4), 1, 1); blankTex.needsUpdate = true;
411
+ const stats = () => ({ masks: masks.size, embers: emberCount, particles: [...pCol].filter((_, i) => i % 4 === 3).filter((a) => a > 0).length, shards: fLive.reduce((a, b) => a + b, 0), puddles: puddles.length });
412
+ // map a material-space hit (mm) from the flat sandbox onto the same mask
413
+ function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); }
414
+ 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); }
415
+ const objects = [points, fMesh, qMesh];
416
+ return { objects, shared, blankTex, crush, tear, wetAt, ignite, debris, drops, update, reset, 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) };
417
+ }
index.html CHANGED
@@ -94,9 +94,13 @@ if (!/(^|[#&])s=/.test(location.hash)) ['scrolls/index.json', 'scrolls/PHerc0139
94
  <p class="layer-hint" id="layerHint" aria-live="polite">Published ink (2.4 µm): Vesuvius Challenge detection, 2.4 µm scan, all windings.</p>
95
  <details class="layer-why" id="layerWhy"><summary>Why two ink layers?</summary><p>Published ink is the Vesuvius Challenge result on the fine 2.4 µm scan. Our ink model (9 µm) is the open ink_9um model run by us on a scan about 4x coarser, so it is blurrier. It shows what an open model can recover from cheaper scans, as a baseline you can check.</p></details>
96
  <div class="seg tear-seg">
97
- <button class="chip" id="btnTear" aria-pressed="false" title="Rip the carbonized papyrus: brittle, layered and fused like the real scrolls. Just for fun, not a real unrolling.">Tear apart</button>
98
- <button class="chip" id="btnReset" hidden title="Put every sheet back">Reset</button>
99
- <button class="chip" id="btnCrush" hidden aria-pressed="false" title="Press or drag to mash the papyrus into flakes, crumbs and dust">Crush</button>
 
 
 
 
100
  <button class="chip" id="btnShake" hidden title="Knock the table: loosens random bits">Shake</button>
101
  <button class="chip" id="btnSound" hidden aria-pressed="false" title="Soft crackle when it breaks">Sound off</button>
102
  </div>
@@ -121,6 +125,7 @@ if (!/(^|[#&])s=/.test(location.hash)) ['scrolls/index.json', 'scrolls/PHerc0139
121
  <button id="play" class="play" aria-label="Unroll">
122
  <svg viewBox="0 0 24 24" width="16" height="16" id="playIcon"><path d="M7 5l12 7-12 7z" fill="currentColor"/></svg>
123
  </button>
 
124
  <div class="scrub" id="scrub">
125
  <input type="range" id="scrubIn" class="scrub-in" min="0" max="1000" step="1" value="0" aria-label="Unroll position">
126
  <div class="ticks" id="ticks"></div>
 
94
  <p class="layer-hint" id="layerHint" aria-live="polite">Published ink (2.4 µm): Vesuvius Challenge detection, 2.4 µm scan, all windings.</p>
95
  <details class="layer-why" id="layerWhy"><summary>Why two ink layers?</summary><p>Published ink is the Vesuvius Challenge result on the fine 2.4 µm scan. Our ink model (9 µm) is the open ink_9um model run by us on a scan about 4x coarser, so it is blurrier. It shows what an open model can recover from cheaper scans, as a baseline you can check.</p></details>
96
  <div class="seg tear-seg">
97
+ <button class="chip" id="btnTear" aria-pressed="false" title="Destroy: tear, crush, burn or soak the scroll, rolled up or unrolled. Damage stays as you roll and unroll. Just for fun, not a real unrolling. (T)">Destroy</button>
98
+ <button class="chip tool" id="toolTear" data-tool="tear" hidden aria-pressed="false" title="Drag across the papyrus to rip it (X)">Tear</button>
99
+ <button class="chip tool" id="btnCrush" data-tool="crush" hidden aria-pressed="false" title="Press to crumble it; hold to press harder and go deeper into the roll (C)">Crush</button>
100
+ <button class="chip tool" id="toolBurn" data-tool="burn" hidden aria-pressed="false" title="Click to ignite: embers creep slowly along the fibres (B)">Burn</button>
101
+ <button class="chip tool" id="toolWater" data-tool="water" hidden aria-pressed="false" title="Press or hold to drip and pour water: it darkens, softens, weakens and slowly dissolves the papyrus (W)">Water</button>
102
+ <button class="chip" id="btnReset" hidden title="Undo all damage and clear the debris">Reset</button>
103
+ <button class="chip" id="btnSandbox" hidden aria-pressed="false" title="Lay the strip flat with full fracture physics: grab to rip, Crush to crumble">Sandbox</button>
104
  <button class="chip" id="btnShake" hidden title="Knock the table: loosens random bits">Shake</button>
105
  <button class="chip" id="btnSound" hidden aria-pressed="false" title="Soft crackle when it breaks">Sound off</button>
106
  </div>
 
125
  <button id="play" class="play" aria-label="Unroll">
126
  <svg viewBox="0 0 24 24" width="16" height="16" id="playIcon"><path d="M7 5l12 7-12 7z" fill="currentColor"/></svg>
127
  </button>
128
+ <button id="btnRollUp" class="chip roll-up" title="Roll it back up (U)">Roll up</button>
129
  <div class="scrub" id="scrub">
130
  <input type="range" id="scrubIn" class="scrub-in" min="0" max="1000" step="1" value="0" aria-label="Unroll position">
131
  <div class="ticks" id="ticks"></div>
tear.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // "Tear apart": a just-for-fun brittle fracture toy on top of the unrolled strip.
2
  // Not a real unrolling. Position based dynamics (Verlet + distance constraints) on typed arrays,
3
  // with real cracks: a crack is a jagged polyline in the sheet's own material space that grows from
@@ -48,7 +49,7 @@ const P = {
48
  };
49
 
50
  export function createTear(ctx) {
51
- const { THREE, scene, camera, controls, canvas, meshes, shared, FRAG, stripZ, ctImage } = ctx;
52
  const MOBILE = ctx.MOBILE;
53
  const cell = MOBILE ? P.cellMobile : P.cell;
54
 
@@ -192,7 +193,7 @@ void main() {
192
  .replace('gl_FragColor = vec4(col, 1.0);',
193
  'col = mix(col, papy * 0.8 + vec3(0.03, 0.022, 0.015), vStrand * 0.7);\n col = mix(col, vec3(0.022, 0.016, 0.012) * (0.6 + 0.4 * diff), vRim);\n gl_FragColor = vec4(col, 1.0);');
194
  // verso: matte brown-black charcoal with a faint sheen, fibres from the CT
195
- const VERSO = /* glsl */`
196
  uniform sampler2D uCT; uniform vec4 uAtlas;
197
  varying vec2 vLocal; varying vec3 vN; varying vec3 vW; varying float vRim; varying float vStrand;
198
  void main() {
@@ -200,18 +201,20 @@ void main() {
200
  float ct = texture2D(uCT, auv).r;
201
  if (ct < 0.012 && vRim < 0.5) discard;
202
  vec3 n = normalize(vN), v = normalize(cameraPosition - vW), l = normalize(vec3(-0.35, 0.75, 0.55));
 
203
  float diff = 0.25 + 0.75 * abs(dot(n, l));
204
  float spec = pow(max(dot(reflect(-l, n * sign(dot(n, v))), v), 0.0), 24.0) * 0.10;
205
  vec3 col = mix(vec3(0.018, 0.013, 0.010), vec3(0.16, 0.11, 0.075), pow(ct, 1.4)) * diff + spec;
206
  col = mix(col, col * 1.25 + vec3(0.02, 0.014, 0.01), vStrand);
207
  col = mix(col, vec3(0.012, 0.009, 0.007) * (0.6 + 0.4 * diff), vRim);
 
208
  gl_FragColor = vec4(col, 1.0);
209
  }`;
210
  const mats = (s, L) => {
211
  const own = s.m.userData.own;
212
  return L === 0
213
- ? new THREE.ShaderMaterial({ uniforms: { ...shared, uAtlas: own.uAtlas, uGpuTex: own.uGpuTex, uGpuOn: own.uGpuOn, uHi: { value: 0 } }, vertexShader: VERT, fragmentShader: RECTO, side: THREE.DoubleSide })
214
- : new THREE.ShaderMaterial({ uniforms: { uCT: shared.uCT, uAtlas: own.uAtlas }, vertexShader: VERT, fragmentShader: VERSO, side: THREE.DoubleSide });
215
  };
216
  // coarse mesh: the physics grid itself, used until a sheet cracks
217
  for (const s of sheets) for (const lay of s.lay) {
@@ -530,6 +533,7 @@ void main() {
530
  if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); }
531
  if (!lay.seg) { lay.seg = new Float32Array(4096); lay.nseg = 0; lay.hash = []; }
532
  if (lay.nseg * 4 + 4 > lay.seg.length) { const g = new Float32Array(lay.seg.length * 2); g.set(lay.seg); lay.seg = g; }
 
533
  const id = lay.nseg++, o = id * 4;
534
  lay.seg[o] = ax; lay.seg[o + 1] = ay; lay.seg[o + 2] = bx; lay.seg[o + 3] = by;
535
  hashCells(s, ax, ay, bx, by, 0, (c) => { (lay.hash[c] ||= []).push(id); });
@@ -777,6 +781,7 @@ void main() {
777
  const o = k * 3, f = Math.exp(-1.8 * dt);
778
  dv[o] *= f; dv[o + 1] *= f; dv[o + 2] *= f;
779
  dp[o] += dv[o] * dt; dp[o + 1] += dv[o + 1] * dt; dp[o + 2] += dv[o + 2] * dt;
 
780
  if (dl[k] <= 0) dp[o + 1] = -1e5;
781
  }
782
  flakeGeo.attributes.position.needsUpdate = true;
@@ -1149,6 +1154,7 @@ void main() {
1149
  crumbs.instanceMatrix.needsUpdate = true;
1150
  }
1151
  function spawnDust(x, y, z) {
 
1152
  const k = dHead++ % DU;
1153
  dp[k * 3] = x; dp[k * 3 + 1] = y; dp[k * 3 + 2] = z + 0.5;
1154
  dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 8 + Math.random() * 20; dv[k * 3 + 2] = 10 + Math.random() * 25;
@@ -1164,17 +1170,10 @@ void main() {
1164
  const R0 = 2.0 + Math.random() * 1.6;
1165
  const ph1 = Math.random() * 6.3, ph2 = Math.random() * 6.3;
1166
  const rAt = (a) => R0 * (0.78 + 0.16 * Math.sin(3 * a + ph1) + 0.1 * Math.sin(7 * a + ph2) + 0.06 * Math.random());
1167
- // remove the ragged disc from the render mesh (triangle granularity gives a crumbly edge)
1168
- let removed = 0;
1169
- hashCells(s, hx - R0 * 1.2, hy - R0 * 1.2, hx + R0 * 1.2, hy + R0 * 1.2, 0, (c) => {
1170
- for (const t of F.cellTris[c]) {
1171
- if (F.kd[t] === 3) continue;
1172
- const dx = F.ref[t * 2] - hx, dy = F.ref[t * 2 + 1] - hy;
1173
- if (dx * dx + dy * dy < rAt(Math.atan2(dy, dx)) ** 2) { F.kd[t] = 3; removed++; }
1174
- }
1175
- });
1176
- if (!removed) return 0;
1177
- F.attrsDirty = true;
1178
  // physics: nodes inside are gone; their links let go, the rim is free to flake further
1179
  for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) {
1180
  const n = node(s, lay.L, i, j);
 
1
+ import { DMG_HEAD as DMG_HEAD_T, DMG_CUT as DMG_CUT_T, DMG_EDGE as DMG_EDGE_T } from './damage.js';
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
 
49
  };
50
 
51
  export function createTear(ctx) {
52
+ const { THREE, scene, camera, controls, canvas, meshes, shared, FRAG, stripZ, ctImage, damage } = ctx;
53
  const MOBILE = ctx.MOBILE;
54
  const cell = MOBILE ? P.cellMobile : P.cell;
55
 
 
193
  .replace('gl_FragColor = vec4(col, 1.0);',
194
  'col = mix(col, papy * 0.8 + vec3(0.03, 0.022, 0.015), vStrand * 0.7);\n col = mix(col, vec3(0.022, 0.016, 0.012) * (0.6 + 0.4 * diff), vRim);\n gl_FragColor = vec4(col, 1.0);');
195
  // verso: matte brown-black charcoal with a faint sheen, fibres from the CT
196
+ const VERSO = DMG_HEAD_T + /* glsl */`
197
  uniform sampler2D uCT; uniform vec4 uAtlas;
198
  varying vec2 vLocal; varying vec3 vN; varying vec3 vW; varying float vRim; varying float vStrand;
199
  void main() {
 
201
  float ct = texture2D(uCT, auv).r;
202
  if (ct < 0.012 && vRim < 0.5) discard;
203
  vec3 n = normalize(vN), v = normalize(cameraPosition - vW), l = normalize(vec3(-0.35, 0.75, 0.55));
204
+ ${DMG_CUT_T}
205
  float diff = 0.25 + 0.75 * abs(dot(n, l));
206
  float spec = pow(max(dot(reflect(-l, n * sign(dot(n, v))), v), 0.0), 24.0) * 0.10;
207
  vec3 col = mix(vec3(0.018, 0.013, 0.010), vec3(0.16, 0.11, 0.075), pow(ct, 1.4)) * diff + spec;
208
  col = mix(col, col * 1.25 + vec3(0.02, 0.014, 0.01), vStrand);
209
  col = mix(col, vec3(0.012, 0.009, 0.007) * (0.6 + 0.4 * diff), vRim);
210
+ ${DMG_EDGE_T}
211
  gl_FragColor = vec4(col, 1.0);
212
  }`;
213
  const mats = (s, L) => {
214
  const own = s.m.userData.own;
215
  return L === 0
216
+ ? new THREE.ShaderMaterial({ uniforms: { ...shared, uAtlas: own.uAtlas, uGpuTex: own.uGpuTex, uGpuOn: own.uGpuOn, uHi: { value: 0 }, uDmg: own.uDmg, uDmgOn: own.uDmgOn, uDmgMM: own.uDmgMM }, vertexShader: VERT, fragmentShader: RECTO, side: THREE.DoubleSide })
217
+ : new THREE.ShaderMaterial({ uniforms: { uCT: shared.uCT, uAtlas: own.uAtlas, uDmg: own.uDmg, uDmgOn: own.uDmgOn, uDmgMM: own.uDmgMM, uDmgT: shared.uDmgT }, vertexShader: VERT, fragmentShader: VERSO, side: THREE.DoubleSide });
218
  };
219
  // coarse mesh: the physics grid itself, used until a sheet cracks
220
  for (const s of sheets) for (const lay of s.lay) {
 
533
  if (!s.cracked) { s.cracked = true; for (const l of s.lay) makeFine(l); }
534
  if (!lay.seg) { lay.seg = new Float32Array(4096); lay.nseg = 0; lay.hash = []; }
535
  if (lay.nseg * 4 + 4 > lay.seg.length) { const g = new Float32Array(lay.seg.length * 2); g.set(lay.seg); lay.seg = g; }
536
+ if (damage && lay.L === 0) damage.tearMM(s.m, ax, ay, bx, by); // ragged, fibrous lips along the cut
537
  const id = lay.nseg++, o = id * 4;
538
  lay.seg[o] = ax; lay.seg[o + 1] = ay; lay.seg[o + 2] = bx; lay.seg[o + 3] = by;
539
  hashCells(s, ax, ay, bx, by, 0, (c) => { (lay.hash[c] ||= []).push(id); });
 
781
  const o = k * 3, f = Math.exp(-1.8 * dt);
782
  dv[o] *= f; dv[o + 1] *= f; dv[o + 2] *= f;
783
  dp[o] += dv[o] * dt; dp[o + 1] += dv[o + 1] * dt; dp[o + 2] += dv[o + 2] * dt;
784
+ if (dp[o + 1] < ground) { dp[o + 1] = ground; dv[o] = dv[o + 1] = dv[o + 2] = 0; } // never through the floor
785
  if (dl[k] <= 0) dp[o + 1] = -1e5;
786
  }
787
  flakeGeo.attributes.position.needsUpdate = true;
 
1154
  crumbs.instanceMatrix.needsUpdate = true;
1155
  }
1156
  function spawnDust(x, y, z) {
1157
+ if (damage) { damage.dust(x, y, z); return; } // size-dependent drag and drift, shared with the rolled view
1158
  const k = dHead++ % DU;
1159
  dp[k * 3] = x; dp[k * 3 + 1] = y; dp[k * 3 + 2] = z + 0.5;
1160
  dv[k * 3] = (Math.random() - 0.5) * 30; dv[k * 3 + 1] = 8 + Math.random() * 20; dv[k * 3 + 2] = 10 + Math.random() * 25;
 
1170
  const R0 = 2.0 + Math.random() * 1.6;
1171
  const ph1 = Math.random() * 6.3, ph2 = Math.random() * 6.3;
1172
  const rAt = (a) => R0 * (0.78 + 0.16 * Math.sin(3 * a + ph1) + 0.1 * Math.sin(7 * a + ph2) + 0.06 * Math.random());
1173
+ // the visible hole is drawn by the damage mask in UV space: a smooth, fibrous edge with no triangle staircase.
1174
+ // physics stays separate: the nodes inside still let go below
1175
+ if (damage) damage.crushMM(s.m, hx, hy, R0 * 1.15);
1176
+ const removed = Math.round((Math.PI * R0 * R0) / (P.renderCell * P.renderCell * 0.5));
 
 
 
 
 
 
 
1177
  // physics: nodes inside are gone; their links let go, the rim is free to flake further
1178
  for (let i = 0; i < s.R; i++) for (let j = 0; j < s.C; j++) {
1179
  const n = node(s, lay.L, i, j);