Destroy mode: UV damage mask with tear, crush, burn and water at any unroll amount; roll up; size-dependent debris
Browse files
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 |
-
|
| 339 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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:
|
| 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 |
-
$('
|
| 492 |
-
$('
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 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="
|
| 98 |
-
<button class="chip" id="
|
| 99 |
-
<button class="chip" id="btnCrush" hidden aria-pressed="false" title="Press
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 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);
|