unroll / damage.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
36 kB
// Destroy mode: damage painted into a high-resolution mask in each winding's UV space, so holes, cracks, embers
// and wet patches follow the surface as it rolls and unrolls. The visual edge is an alpha test in the fragment
// shader against that smooth field plus fiber-direction noise (no per-triangle removal, so no staircase).
// Channels: R missing (crushed, torn, burnt), G cracks, B ember heat, A wetness.
// Particles (dust, fragments, flakes, smoke, ash, drops, puddles) live in their own capped system.
export const DMG_HEAD = /* glsl */`
uniform sampler2D uDmg; uniform float uDmgOn; uniform vec2 uDmgMM; uniform float uDmgT;
uniform float uCrumble; // Crumble: front from the sheet edges inward, 0 none, past 1.15 all gone
float dHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float dNoise(vec2 p) {
vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f);
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);
}
// papyrus fibers run along the sheet: noise stretched along x, fine across y
float dFib(vec2 mm) {
vec2 p = mm * vec2(0.7, 6.5);
return 0.5 * dNoise(p) + 0.3 * dNoise(p * 2.13 + 3.7) + 0.2 * dNoise(mm * 6.0 + 11.0);
}
// the damage field the alpha test uses: smooth mask, fiber noise, and a coarser ragged term for tufts and bites
float dField(float r, float fib, vec2 mm) {
float g = smoothstep(0.05, 0.35, r); // no noise far from damage, so no stray pinholes
return r * 1.1 + ((fib - 0.5) * 0.7 + (dNoise(mm * vec2(0.9, 1.6) + 5.3) - 0.5) * 0.3) * g;
}
// how far into the sheet a point is, 0 at any edge and 1 at the middle, made ragged by the fibers
float dCrumbleD(vec2 uv, vec2 mm) {
float e = min(min(uv.x, 1.0 - uv.x) * mm.x, min(uv.y, 1.0 - uv.y) * mm.y) / (0.5 * min(mm.x, mm.y));
return clamp(e, 0.0, 1.0) + (dFib(uv * mm) - 0.5) * 0.22 + (dNoise(uv * mm * 0.35 + 9.1) - 0.5) * 0.18;
}
`;
// after the surface normal is known: alpha test against the field
export const DMG_CUT = /* glsl */`
vec4 dd = vec4(0.0); float dTh = 0.0, dFibV = 0.5, dCr = 0.0;
if (uCrumble > 0.0) {
float cd = dCrumbleD(vLocal, uDmgMM) - uCrumble;
if (cd < 0.0) discard;
dCr = 1.0 - smoothstep(0.0, 0.07, cd); // a dark crumbling rim just ahead of the front
}
if (uDmgOn > 0.5) {
dd = texture2D(uDmg, vLocal);
dFibV = dFib(vLocal * uDmgMM);
dTh = dd.r > 0.004 ? dField(dd.r, dFibV, vLocal * uDmgMM) : 0.0;
if (dTh > 0.5) discard;
}
`;
// before the final color: charred band, lifted lip and its shadow, cracks, wet gloss, embers
export const DMG_EDGE = /* glsl */`
if (dCr > 0.0) col = mix(col, vec3(0.03, 0.022, 0.016), dCr * 0.85);
if (uDmgOn > 0.5) {
float charK = smoothstep(0.16, 0.495, dTh);
float lipK = smoothstep(0.08, 0.2, dTh) * (1.0 - charK);
float shadow = smoothstep(0.38, 0.7, texture2D(uDmg, vLocal + vec2(-0.3, 0.45) / uDmgMM).r) * (1.0 - charK);
col *= 1.0 + 0.32 * lipK;
col *= 1.0 - 0.6 * shadow;
col *= 1.0 - 0.45 * smoothstep(0.1, 0.5, dd.g);
float crack = dd.g * smoothstep(0.35, 0.75, dFibV + dd.g * 0.35);
col = mix(col, vec3(0.010, 0.007, 0.005) * (0.6 + 0.8 * dFibV), clamp(charK * 0.97 + crack * 0.9, 0.0, 1.0));
// wet charcoal: soaks up the water and goes matt black-brown, no sheen
float wet = dd.a;
col = mix(col, col * vec3(0.16, 0.13, 0.11), smoothstep(0.0, 0.7, wet));
// smoldering charcoal: a dim, slowly breathing deep orange in the char band, no flame
float flick = 0.75 + 0.25 * sin(uDmgT * 0.9 + dFibV * 23.0) * sin(uDmgT * 0.43 + dFibV * 7.0);
float emb = dd.b * flick;
col += vec3(0.85, 0.22, 0.04) * emb * emb * (0.25 + 1.1 * charK) * 0.9;
}
`;
export function injectFrag(frag, cutAfter) {
if (!frag.includes(cutAfter)) throw new Error('damage: shader anchor missing');
return DMG_HEAD + frag.replace(cutAfter, cutAfter + DMG_CUT).replace('gl_FragColor = vec4(col, 1.0);', DMG_EDGE + ' gl_FragColor = vec4(col, 1.0);');
}
export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT, uCrumble, surfacePoint }) {
const RES = MOBILE ? 1024 : 2048;
const masks = new Map();
const shared = { uDmgT: uDmgT || { value: 0 }, uCrumble: uCrumble || { value: 0 } };
const own = (m) => m.userData.own;
function mask(m) {
let k = masks.get(m);
if (k) return k;
const { w } = m.userData;
const W = RES, H = Math.max(64, Math.round((RES * w.heightMM) / w.widthMM));
const data = new Uint8Array(W * H * 4);
const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat);
tex.minFilter = tex.magFilter = THREE.LinearFilter;
tex.generateMipmaps = false; tex.flipY = false; tex.needsUpdate = true;
k = { m, W, H, data, tex, pxmm: W / w.widthMM, dirty: true, embers: [], wet: [], world: null };
masks.set(m, k);
own(m).uDmg.value = tex; own(m).uDmgOn.value = 1;
return k;
}
const at = (k, u, v, c) => {
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)));
return k.data[(y * k.W + x) * 4 + c] / 255;
};
// a soft elliptical brush, wider along the fibers; amount adds to one channel
function blob(k, u, v, rMM, c, amount, aspect = 1.45) {
const cx = u * k.W, cy = v * k.H, rx = rMM * k.pxmm * aspect, ry = rMM * k.pxmm;
const x0 = Math.max(0, Math.floor(cx - rx)), x1 = Math.min(k.W - 1, Math.ceil(cx + rx));
const y0 = Math.max(0, Math.floor(cy - ry)), y1 = Math.min(k.H - 1, Math.ceil(cy + ry));
const d = k.data, W = k.W, a = amount * 255;
for (let y = y0; y <= y1; y++) {
const dy = (y - cy) / ry, dy2 = dy * dy;
for (let x = x0; x <= x1; x++) {
const dx = (x - cx) / rx, q = dx * dx + dy2;
if (q >= 1) continue;
const f = (1 - q) * (1 - q);
const i = (y * W + x) * 4 + c;
d[i] = Math.min(255, d[i] + a * f);
}
}
k.dirty = true;
}
function line(k, u0, v0, u1, v1, rMM, c, amount, wobble) {
const mm = Math.hypot((u1 - u0) * k.W, (v1 - v0) * k.H) / k.pxmm;
const n = Math.max(1, Math.ceil(mm / (rMM * 0.6)));
const nx = -(v1 - v0) * k.H, ny = (u1 - u0) * k.W, nl = Math.hypot(nx, ny) || 1;
for (let i = 0; i <= n; i++) {
const f = i / n, j = wobble ? (Math.sin(f * 9.1 + u0 * 50) * 0.6 + (Math.random() - 0.5)) * wobble * k.pxmm : 0;
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);
}
}
const mmToUV = (k, dx, dy) => [dx * k.pxmm / k.W, dy * k.pxmm / k.H];
// ------------------------------------------------------------ tools
function crush(m, u, v, rMM, strength) {
const k = mask(m);
const wet = at(k, u, v, 3);
const s = strength * (1 + 1.6 * wet); // soaked papyrus gives way
blob(k, u, v, rMM, 0, 0.95 * s);
for (let i = 0; i < 5; i++) {
const a = Math.random() * 6.283, r = rMM * (0.5 + Math.random() * 0.6);
const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r * 0.7);
blob(k, u + du, v + dv, rMM * (0.35 + Math.random() * 0.35), 0, 0.7 * s);
}
// radial cracks into the surrounding sheet
const nc = 2 + (Math.random() * 3 | 0);
for (let i = 0; i < nc; i++) {
const a = Math.random() * 6.283, l = rMM * (1.2 + Math.random() * 1.6);
const [a0, b0] = mmToUV(k, Math.cos(a) * rMM * 0.7, Math.sin(a) * rMM * 0.7);
const [a1, b1] = mmToUV(k, Math.cos(a) * l * 1.4, Math.sin(a) * l * 0.6);
line(k, u + a0, v + b0, u + a1, v + b1, 0.22, 1, 0.9, 0.25);
}
}
function bruise(m, u, v, rMM) {
const k = mask(m);
blob(k, u, v, rMM, 1, 0.22, 1.3);
}
function tear(m, u0, v0, u1, v1) {
const k = mask(m);
const wet = at(k, u1, v1, 3);
line(k, u0, v0, u1, v1, 0.6 + wet * 0.5, 0, 0.9, 0.45);
line(k, u0, v0, u1, v1, 1.1, 1, 0.35, 0.6);
}
function wetAt(m, u, v, rMM, amount) {
const k = mask(m);
blob(k, u, v, rMM, 3, amount, 1.25);
k.wet.push({ u, v, r: rMM, t: 0 });
if (k.wet.length > 200) k.wet.shift();
// water puts embers out
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;
}
let emberCount = 0;
// where an ember is now: its UV mapped through the current roll onto the sheet, nudged off the surface along the
// normal. Falls back to the ignition point only when no surface mapping is available.
const sp = new THREE.Vector3(), sn = new THREE.Vector3();
let emberSpawns = 0, emberFallback = 0;
function emberAt(k, e, lift) {
if (surfacePoint && surfacePoint(k.m, e.u, e.v, sp, sn)) {
if (sn.z < 0) sn.multiplyScalar(-1); // toward the open side the camera usually faces
sp.addScaledVector(sn, lift); emberSpawns++; return true;
}
if (!k.world) return false;
sp.copy(k.world); sp.z += lift; sn.set(0, 0, 1); emberFallback++; return true;
}
const EMAX = MOBILE ? 140 : 360;
function ignite(m, u, v, world) {
const k = mask(m);
k.world = world.clone();
k.burnR = 0;
for (let i = 0; i < 7; i++) { const a = i * 0.9, [du, dv] = mmToUV(k, Math.cos(a) * 0.8, Math.sin(a) * 0.5); addEmber(k, u + du, v + dv, 0); }
}
function addEmber(k, u, v, gen) {
if (emberCount >= EMAX || u < 0 || v < 0 || u > 1 || v > 1) return;
if (at(k, u, v, 3) > 0.35) return; // too wet to catch
if (at(k, u, v, 0) > 0.6) return; // already gone
k.embers.push({ u, v, gen, life: 4 + Math.random() * 4, age: 0, next: 0.3 + Math.random() * 0.6 });
emberCount++;
}
// ------------------------------------------------------------ particles
const PMAX = MOBILE ? 700 : 1800;
const pPos = new Float32Array(PMAX * 3), pVel = new Float32Array(PMAX * 3), pCol = new Float32Array(PMAX * 4), pSize = new Float32Array(PMAX);
const pSleep = new Uint8Array(PMAX), pType = new Uint8Array(PMAX), pLife = new Float32Array(PMAX), pAge = new Float32Array(PMAX), pSeed = new Float32Array(PMAX);
let pHead = 0;
const pA0 = new Float32Array(PMAX); // spawn opacity: fades scale from it, so faint smoke stays faint
const pGeo = new THREE.BufferGeometry();
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3).setUsage(THREE.DynamicDrawUsage));
pGeo.setAttribute('aCol', new THREE.BufferAttribute(pCol, 4).setUsage(THREE.DynamicDrawUsage));
pGeo.setAttribute('aSize', new THREE.BufferAttribute(pSize, 1).setUsage(THREE.DynamicDrawUsage));
const pMat = new THREE.ShaderMaterial({
transparent: true, depthWrite: false,
uniforms: { uScale: { value: 400 } },
vertexShader: /* glsl */`attribute vec4 aCol; attribute float aSize; uniform float uScale; varying vec4 vC;
void main() { vC = aCol; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv;
gl_PointSize = aCol.a <= 0.0 ? 0.0 : max(1.0, aSize * uScale / -mv.z); }`,
fragmentShader: /* glsl */`varying vec4 vC;
void main() { float d = length(gl_PointCoord - 0.5) * 2.0; float a = smoothstep(1.0, 0.0, d) * smoothstep(1.0, 0.0, d) * vC.a * 1.3; if (a < 0.01) discard; gl_FragColor = vec4(vC.rgb, a); }`,
});
const points = new THREE.Points(pGeo, pMat);
points.frustumCulled = false;
scene.add(points);
// types: 0 dust, 1 ash, 2 smoke, 3 drop, 4 spark
function spawnP(type, x, y, z, vx, vy, vz, size, life, r, g, b, a) {
const i = pHead++ % PMAX;
pSleep[i] = 0; pType[i] = type; pPos[i * 3] = x; pPos[i * 3 + 1] = y; pPos[i * 3 + 2] = z;
pVel[i * 3] = vx; pVel[i * 3 + 1] = vy; pVel[i * 3 + 2] = vz;
pSize[i] = size; pLife[i] = life; pAge[i] = 0; pSeed[i] = Math.random() * 100;
pCol[i * 4] = r; pCol[i * 4 + 1] = g; pCol[i * 4 + 2] = b; pCol[i * 4 + 3] = a; pA0[i] = a;
}
// fragments and flakes: tumbling instanced shards
const FMAX = MOBILE ? 220 : 600;
const shard = new THREE.BufferGeometry();
{
const v = [0, 0, 0], n = 6;
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); }
const idx = []; for (let i = 1; i <= n; i++) idx.push(0, i, i + 1);
shard.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); shard.setIndex(idx); shard.computeVertexNormals();
}
const fMesh = new THREE.InstancedMesh(shard, new THREE.MeshStandardMaterial({ color: 0x15110e, roughness: 0.95, metalness: 0, side: THREE.DoubleSide }), FMAX);
fMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
fMesh.frustumCulled = false;
fMesh.count = 0;
scene.add(fMesh);
const light = new THREE.HemisphereLight(0xfff0dd, 0x221810, 1.6);
scene.add(light);
const fPos = new Float32Array(FMAX * 3), fVel = new Float32Array(FMAX * 3), fRot = new Float32Array(FMAX * 3), fSpin = new Float32Array(FMAX * 3);
const fSize = new Float32Array(FMAX), fAge = new Float32Array(FMAX), fRest = new Uint8Array(FMAX), fLive = new Uint8Array(FMAX);
let fHead = 0;
// Settle rule: nothing may hang still where it is drawn. In the rolled view the floor is not drawn, so a piece
// resting on it reads as hanging in mid-air; landed pieces therefore fade out soon after they land, and a
// watchdog retires anything that has been drawn without moving for STILL_MAX seconds.
const REST_HOLD = 1.0, REST_FADE = 1.2, STILL_MAX = 2.5;
const fRestT = new Float32Array(FMAX), fStill = new Float32Array(FMAX), fLast = new Float32Array(FMAX * 3), pStill = new Float32Array(PMAX);
let auditMax = 0, auditAir = 0, retired = 0;
const hide = (i) => { tmpM.makeScale(0, 0, 0); fMesh.setMatrixAt(i, tmpM); };
function spawnF(x, y, z, vx, vy, vz, size) {
const i = fHead++ % FMAX;
fLive[i] = 1; fRest[i] = 0; fAge[i] = 0; fSize[i] = size; fRestT[i] = 0; fStill[i] = 0;
fPos[i * 3] = x; fPos[i * 3 + 1] = y; fPos[i * 3 + 2] = z;
fVel[i * 3] = vx; fVel[i * 3 + 1] = vy; fVel[i * 3 + 2] = vz;
for (let c = 0; c < 3; c++) { fRot[i * 3 + c] = Math.random() * 6.3; fSpin[i * 3 + c] = (Math.random() - 0.5) * 14; }
fMesh.count = Math.max(fMesh.count, Math.min(FMAX, fHead));
}
// puddles on the floor
const QMAX = 40;
const qMesh = new THREE.InstancedMesh(new THREE.CircleGeometry(1, 28).rotateX(-Math.PI / 2),
new THREE.MeshStandardMaterial({ color: 0x0a0807, roughness: 0.7, metalness: 0, transparent: true, opacity: 0.9 }), QMAX); // black sludge
qMesh.frustumCulled = false; qMesh.count = 0;
scene.add(qMesh);
const puddles = [];
function feedPuddle(x, z, vol) {
let p = puddles.find((q) => Math.hypot(q.x - x, q.z - z) < q.r + 3);
if (!p) { if (puddles.length >= QMAX) puddles.shift(); p = { x, z, vol: 0, r: 0 }; puddles.push(p); }
p.vol += vol;
}
// the debris a hit makes, by size: fine dust hangs, fragments flutter, flakes drop
function debris(world, rMM, n, kind = 'crush') {
const { x, y, z } = world;
for (let i = 0; i < n; i++) {
const a = Math.random() * 6.283, r = Math.random() * rMM;
const big = Math.random() < 0.1; // charcoal powders: a few flakes, mostly small bits
spawnF(x + Math.cos(a) * r, y + Math.sin(a) * r * 0.6, z + 0.4,
Math.cos(a) * (6 + Math.random() * 16), 2 + Math.random() * 10, 6 + Math.random() * 16,
big ? 0.9 + Math.random() * 1.0 : 0.25 + Math.random() * 0.45);
}
const nd = kind === 'tear' ? n * 4 : n * 10;
for (let i = 0; i < nd; i++) {
const a = Math.random() * 6.283, sp = 6 + Math.random() * 18; // a soft puff of black dust
const c = 0.035 + Math.random() * 0.035;
spawnP(0, x + (Math.random() - 0.5) * rMM, y + (Math.random() - 0.5) * rMM, z + 0.5,
Math.cos(a) * sp, Math.sin(a) * sp * 0.6 + 3, 4 + Math.random() * 10, 0.25 + Math.random() * 0.5, 10 + Math.random() * 6, c * 1.15, c, c * 0.9, 0.8);
}
}
function drops(world, n) {
for (let i = 0; i < n; i++) {
const x = world.x + (Math.random() - 0.5) * 3, z = world.z + (Math.random() - 0.5) * 3;
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);
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
}
}
// ------------------------------------------------------------ Crumble: the whole scroll to a heap of dust
// The sheet goes from its edges inward (uCrumble drives a discard in the shaders, so no mask texels are touched);
// the band that has just gone is turned into flakes and dust that are pulled in toward the middle and fall onto
// a heap on the floor. The heap is its own capped point cloud and stays until Reset.
const CR_TIME = 4.2, CR_END = 1.15;
const HMAX = MOBILE ? 900 : 2600;
const hPos = new Float32Array(HMAX * 3), hCol = new Float32Array(HMAX * 4), hSize = new Float32Array(HMAX), hH = new Float32Array(HMAX);
let hCount = 0;
const hGeo = new THREE.BufferGeometry();
hGeo.setAttribute('position', new THREE.BufferAttribute(hPos, 3).setUsage(THREE.DynamicDrawUsage));
hGeo.setAttribute('aCol', new THREE.BufferAttribute(hCol, 4).setUsage(THREE.DynamicDrawUsage));
hGeo.setAttribute('aSize', new THREE.BufferAttribute(hSize, 1).setUsage(THREE.DynamicDrawUsage));
hGeo.setDrawRange(0, 0);
const heapPts = new THREE.Points(hGeo, new THREE.ShaderMaterial({
transparent: true, depthWrite: false, uniforms: { uScale: { value: 400 } },
vertexShader: /* glsl */`attribute vec4 aCol; attribute float aSize; uniform float uScale; varying vec4 vC;
void main() { vC = aCol; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_Position = projectionMatrix * mv;
gl_PointSize = max(1.0, aSize * uScale / -mv.z); }`,
fragmentShader: /* glsl */`varying vec4 vC;
void main() { float d = length(gl_PointCoord - 0.5) * 2.0; if (d > 1.0) discard; float a = (1.0 - d * d) * vC.a; gl_FragColor = vec4(vC.rgb * (1.1 - 0.4 * d), a); }`,
}));
heapPts.frustumCulled = false;
scene.add(heapPts);
let cr = null; // { t, cx, cz, R, H, wins }
const cP = new THREE.Vector3(), cN = new THREE.Vector3();
function crumble() {
if (cr || !surfacePoint) return false;
// where the scroll is now: center and spread of the drawn sheet, for the heap
let n = 0, sx = 0, sz = 0, xs = [], zs = [];
for (const m of meshes) for (let i = 0; i < 9; i++) {
if (!surfacePoint(m, 0.1 + 0.8 * (i % 3) / 2, 0.1 + 0.8 * ((i / 3) | 0) / 2, cP)) continue;
n++; sx += cP.x; sz += cP.z; xs.push(cP.x); zs.push(cP.z);
}
if (!n) return false;
const cx = sx / n, cz = sz / n;
let spread = 0; for (let i = 0; i < n; i++) spread = Math.max(spread, Math.hypot(xs[i] - cx, (zs[i] - cz)));
const R = Math.min(34, Math.max(12, spread * 0.12));
cr = { t: 0, cx, cz, R, H: R * 0.75, last: 0, done: false };
for (const k of masks.values()) { emberCount -= k.embers.length; k.embers = []; } // nothing left to burn
return true;
}
function addHeapGrain(f) {
if (hCount >= HMAX) return;
const i = hCount++, a = Math.random() * 6.283;
// a mound: dense and tall in the middle, thin at the rim; it grows wider and taller as the scroll comes down
const r = cr.R * (0.35 + 0.65 * f) * Math.pow(Math.random(), 0.8) * (0.9 + Math.random() * 0.2);
const top = cr.H * (0.3 + 0.7 * f) * Math.pow(Math.max(0, 1 - r / cr.R), 1.6);
hH[i] = top * Math.pow(Math.random(), 0.6) + 0.2;
hPos[i * 3] = cr.cx + Math.cos(a) * r; hPos[i * 3 + 2] = cr.cz + Math.sin(a) * r * 0.85;
const c = 0.03 + Math.random() * 0.07, warm = Math.random() < 0.1 ? 0.02 : 0;
hCol[i * 4] = c * 1.3 + warm; hCol[i * 4 + 1] = c; hCol[i * 4 + 2] = c * 0.75; hCol[i * 4 + 3] = 0;
hSize[i] = 0.9 + Math.random() * 1.4 + (1 - r / cr.R) * 0.8; // coarser in the core
}
function stepCrumble(wall, dt) {
if (!cr) return;
const G = ground();
if (!cr.done) {
cr.t += wall;
const f = Math.min(1, cr.t / CR_TIME), front = CR_END * (f * f * (3 - 2 * f));
const prev = shared.uCrumble.value;
shared.uCrumble.value = Math.max(0.001, front);
// pieces from the band that just went: each starts on the sheet where it was, falls in toward the middle
const band = Math.max(0.004, front - prev);
const nF = Math.min(18, Math.round(wall * 120)), nD = Math.min(40, Math.round(wall * 300));
for (let q = 0; q < nF + nD; q++) {
const m = meshes[(Math.random() * meshes.length) | 0], w = m.userData.w;
const mx = w.widthMM, my = w.heightMM, hm = 0.5 * Math.min(mx, my);
// a UV at edge distance in [prev, front): pick the side, then the depth in from it
const dd = Math.min(1, prev + Math.random() * band) * hm, side = (Math.random() * 4) | 0, t = Math.random();
let u, v;
if (side === 0) { u = dd / mx; v = t; } else if (side === 1) { u = 1 - dd / mx; v = t; }
else if (side === 2) { u = t; v = dd / my; } else { u = t; v = 1 - dd / my; }
if (!surfacePoint(m, u, v, cP, cN)) continue;
const ix = cr.cx - cP.x, iz = cr.cz - cP.z, il = Math.hypot(ix, iz) || 1, pull = 25 + Math.random() * 45;
if (q < nF) spawnF(cP.x, cP.y, cP.z, ix / il * pull, Math.random() * 15, iz / il * pull, 0.35 + Math.random() * (Math.random() < 0.2 ? 1.6 : 0.6));
else {
const c = 0.035 + Math.random() * 0.04;
spawnP(0, cP.x, cP.y, cP.z, ix / il * pull * 0.6, 4 + Math.random() * 10, iz / il * pull * 0.6, 0.3 + Math.random() * 0.5, 3 + Math.random() * 2, c * 1.25, c * 0.95, c * 0.75, 0.75);
}
}
// the heap fills in step with what has come down
const want = Math.floor(HMAX * f);
while (hCount < want) addHeapGrain(f);
if (f >= 1) { cr.done = true; shared.uCrumble.value = CR_END + 0.5; }
}
// grains settle in: fade up over half a second, sit on the floor wherever it is
for (let i = 0; i < hCount; i++) {
hPos[i * 3 + 1] = G + hH[i];
if (hCol[i * 4 + 3] < 0.95) hCol[i * 4 + 3] = Math.min(0.95, hCol[i * 4 + 3] + dt * 2);
}
hGeo.setDrawRange(0, hCount);
hGeo.attributes.position.needsUpdate = hGeo.attributes.aCol.needsUpdate = hGeo.attributes.aSize.needsUpdate = true;
}
// ------------------------------------------------------------ update
const tmpM = new THREE.Matrix4(), tmpQ = new THREE.Quaternion(), tmpE = new THREE.Euler(), tmpS = new THREE.Vector3(), tmpP = new THREE.Vector3();
let burnTick = 0, wetTick = 0, upTick = 0, decayTick = 0;
function update(dt, time, wall = dt) {
shared.uDmgT.value = time;
// burning: slow ember creep, faster along the fibers, char behind it, smoke and ash above
burnTick += wall;
if (burnTick > 0.1) {
const h = burnTick; burnTick = 0;
const q = h / 0.1; // per-tick amounts are tuned for 0.1 s; scale so slow frames burn at the same speed
for (const k of masks.values()) {
if (!k.embers.length) continue;
const keep = [];
for (const e of k.embers) {
e.age += h; e.life -= h; e.next -= h;
blob(k, e.u, e.v, 1.4, 2, Math.min(1, 0.22 * q));
blob(k, e.u, e.v, 2.6, 2, Math.min(1, 0.05 * q));
blob(k, e.u, e.v, 0.8, 0, Math.min(1, (e.age > 1.2 ? 0.05 : 0.01) * q));
for (let rep = 0; rep < 3 && e.next <= 0 && e.gen < 40; rep++) {
e.next += 0.6 + Math.random() * 1.0; // smoldering creeps slowly
const along = Math.random() < 0.7, s = 0.6 + Math.random() * 0.8, a = Math.random() < 0.5 ? -1 : 1;
const [du, dv] = mmToUV(k, along ? a * s * 1.9 : (Math.random() - 0.5) * s * 1.4, along ? (Math.random() - 0.5) * s * 0.6 : a * s * 1.0);
if (Math.random() < 0.97 - e.gen * 0.01) addEmber(k, e.u + du, e.v + dv, e.gen + 1);
}
if (e.life > 0) keep.push(e);
else {
blob(k, e.u, e.v, 1.3, 0, 0.6); emberCount--;
// the burnt-out spot drops a flake from where it is on the sheet now
if (Math.random() < 0.5 && emberAt(k, e, 0.3)) spawnF(sp.x, sp.y, sp.z, (Math.random() - 0.5) * 10 + sn.x * 4, 5, (Math.random() - 0.5) * 4 + sn.z * 4, 0.3 + Math.random() * 0.4);
}
}
k.embers = keep;
if (keep.length) {
// smoke, ash and sparks rise from live embers, each from that ember's current spot on the deformed sheet
const pick = () => emberAt(k, keep[(Math.random() * keep.length) | 0], 1);
for (let i = 0; i < Math.min(3, keep.length / 6 + 1); i++) {
const g = 0.16 + Math.random() * 0.1;
if (pick()) spawnP(2, sp.x, sp.y, sp.z,
(Math.random() - 0.5) * 6 + sn.x * 4, 8 + Math.random() * 6, (Math.random() - 0.5) * 4 + sn.z * 4, 3 + Math.random() * 3, 4 + Math.random() * 3, g * 1.8, g * 1.75, g * 1.7, 0.22);
}
if (Math.random() < 0.6 && pick()) spawnP(1, sp.x, sp.y, sp.z,
(Math.random() - 0.5) * 8 + sn.x * 3, 12 + Math.random() * 10, sn.z * 3, 0.4 + Math.random() * 0.5, 8, 0.58, 0.56, 0.53, 0.8); // light gray ash
if (Math.random() < 0.02 && pick()) spawnP(4, sp.x, sp.y, sp.z, (Math.random() - 0.5) * 10 + sn.x * 6, 25 + Math.random() * 15, sn.z * 6, 0.18, 0.7, 0.8, 0.3, 0.06, 0.7); // a rare dull spark
}
}
}
// ember glow cools where nothing burns any more
decayTick += wall;
if (decayTick > 0.25) {
const f = Math.exp(-decayTick * 0.6); decayTick = 0;
for (const k of masks.values()) {
if (!k.hot && !k.embers.length) continue;
const d = k.data; let any = false;
for (let i = 2; i < d.length; i += 4) if (d[i]) { d[i] = d[i] * f; any = true; }
k.hot = any; k.dirty = true;
}
}
// soaked spots slowly dissolve and delaminate
wetTick += wall;
if (wetTick > 0.4) {
wetTick = 0;
for (const k of masks.values()) for (const w of k.wet) {
w.t += 0.4;
if (w.t < 6) blob(k, w.u, w.v, w.r * (1 + w.t * 0.08), 3, 0.05, 1.6); // water wicks out along the fibers
if (w.t < 2.5 || at(k, w.u, w.v, 3) < 0.55 || Math.random() > 0.35) continue;
const a = Math.random() * 6.283, r = Math.random() * w.r * 0.8;
const [du, dv] = mmToUV(k, Math.cos(a) * r, Math.sin(a) * r);
blob(k, w.u + du, w.v + dv, 0.5 + Math.random() * 0.6, 0, 0.2);
if (Math.random() < 0.5) blob(k, w.u + du, w.v + dv, 1.2, 1, 0.2); // layers lift apart along the fibers
// the mush lets go: black sludge drips and a few sodden fragments drop from where it is on the sheet
if (surfacePoint && surfacePoint(k.m, w.u + du, w.v + dv, sp)) {
spawnP(3, sp.x, sp.y - 0.3, sp.z + 0.2, (Math.random() - 0.5) * 3, -4, 0, 0.9 + Math.random() * 0.6, 4, 0.05, 0.04, 0.035, 0.95);
if (Math.random() < 0.35) spawnF(sp.x, sp.y, sp.z + 0.2, (Math.random() - 0.5) * 4, -2, 1, 0.3 + Math.random() * 0.5);
}
}
}
// texture uploads, throttled
upTick += dt;
if (upTick > (MOBILE ? 0.09 : 0.05)) {
upTick = 0;
for (const k of masks.values()) if (k.dirty) { k.tex.needsUpdate = true; k.dirty = false; }
}
stepCrumble(Math.min(wall, 0.25), dt);
stepParticles(dt, time);
}
function stepParticles(dt, time) {
const G = ground();
for (let i = 0; i < PMAX; i++) {
if (pCol[i * 4 + 3] <= 0) continue;
pAge[i] += dt;
const t = pType[i], o = i * 3, life = pLife[i];
if (pAge[i] > life) { pCol[i * 4 + 3] = 0; continue; }
if (pSleep[i]) { // landed: asleep on the floor (which follows the scroll), only fading
pPos[o + 1] = G + 0.05;
pCol[i * 4 + 3] = Math.min(pCol[i * 4 + 3], Math.max(0, 1 - pAge[i] / life) * 0.9);
continue;
}
let g, k, turb;
if (t === 0) { g = 9; k = 3.6; turb = 8; } // charcoal dust, very light: settles at about 2.5 mm/s
else if (t === 1) { g = 6; k = 1.6; turb = 12; } // ash: warm air carries it up first
else if (t === 2) { g = -14; k = 1.1; turb = 14; } // smoke rises
else if (t === 3) { g = 520; k = 0.3; turb = 0; } // water drops
else { g = 40; k = 1.2; turb = 6; } // sparks
const x = pPos[o], y = pPos[o + 1], z = pPos[o + 2], s = pSeed[i], tt = time * 0.8;
const tx = Math.sin(0.11 * y + tt + s) + Math.sin(0.09 * z - 0.7 * tt + 1.3);
const ty = Math.sin(0.10 * z + 0.6 * tt + 2.1 + s) + Math.sin(0.12 * x - 0.8 * tt);
const tz = Math.sin(0.1 * x + 0.75 * tt + 0.7) + Math.sin(0.09 * y + 0.5 * tt + 3.0 + s);
const e = Math.exp(-k * dt);
pVel[o] = pVel[o] * e + tx * turb * dt;
pVel[o + 1] = pVel[o + 1] * e + (ty * turb * 0.6 - g) * dt;
pVel[o + 2] = pVel[o + 2] * e + tz * turb * dt;
pPos[o] += pVel[o] * dt; pPos[o + 1] += pVel[o + 1] * dt; pPos[o + 2] += pVel[o + 2] * dt;
const fade = 1 - pAge[i] / life;
// guard: anything broken is recycled
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; }
if (pPos[o + 1] <= G) {
if (t === 3) { feedPuddle(pPos[o], pPos[o + 2], 1); pCol[i * 4 + 3] = 0; continue; }
if (t === 2 || t === 4) { pCol[i * 4 + 3] = 0; continue; }
// dust and ash land: snap to the floor, stop, sleep
pPos[o + 1] = G + 0.05; pVel[o] = pVel[o + 1] = pVel[o + 2] = 0; pSleep[i] = 1;
pLife[i] = pAge[i] + REST_HOLD + REST_FADE + Math.random() * 0.5;
continue;
}
if (t === 2) { pSize[i] += dt * 2.6; pCol[i * 4 + 3] = pA0[i] * 0.84 * fade * Math.min(1, pAge[i] * 2); }
else if (t === 4) pCol[i * 4 + 3] = fade * pA0[i];
else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9);
}
// watchdog for particles: visible, not asleep on the floor, and not moving
for (let i = 0; i < PMAX; i++) {
if (pCol[i * 4 + 3] <= 0 || pSleep[i]) { pStill[i] = 0; continue; }
const sp = Math.abs(pVel[i * 3]) + Math.abs(pVel[i * 3 + 1]) + Math.abs(pVel[i * 3 + 2]);
pStill[i] = sp < 0.05 ? pStill[i] + dt : 0;
if (pStill[i] > STILL_MAX) { pCol[i * 4 + 3] = 0; pStill[i] = 0; retired++; }
}
pGeo.attributes.position.needsUpdate = pGeo.attributes.aCol.needsUpdate = pGeo.attributes.aSize.needsUpdate = true;
// fragments flutter and tumble with lift; big flakes drop
for (let i = 0; i < fMesh.count; i++) {
const o = i * 3;
if (!fLive[i]) { hide(i); continue; }
fAge[i] += dt;
if (!Number.isFinite(fPos[o] + fPos[o + 1] + fPos[o + 2] + fVel[o] + fVel[o + 1] + fVel[o + 2] + fRot[o] + fRot[o + 1] + fRot[o + 2])) { fLive[i] = 0; hide(i); continue; }
if (!fRest[i]) {
const big = fSize[i] > 1.0;
const g = big ? 380 : 160, k = big ? 0.6 : 3.0;
const tilt = Math.abs(Math.sin(fRot[o])), flut = big ? 0 : 55;
const e = Math.exp(-k * (1 + 1.5 * tilt) * dt);
fVel[o] = fVel[o] * e + Math.sin(fAge[i] * 7 + i) * flut * dt;
fVel[o + 1] = fVel[o + 1] * e - g * dt;
fVel[o + 2] = fVel[o + 2] * e + Math.cos(fAge[i] * 5.3 + i) * flut * 0.6 * dt;
for (let c = 0; c < 3; c++) { fPos[o + c] += fVel[o + c] * dt; fRot[o + c] += fSpin[o + c] * dt; }
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; }
}
// landed: lie a moment, then fade; airborne pieces still fade by age as a backstop
if (fRest[i]) { fRestT[i] += dt; fPos[o + 1] = ground() + 0.1; }
const fadeS = Math.min(fAge[i] > 25 ? 1 - (fAge[i] - 25) / 5 : 1, fRest[i] ? 1 - (fRestT[i] - REST_HOLD) / REST_FADE : 1, 1);
if (fadeS <= 0) { fLive[i] = 0; hide(i); continue; }
tmpE.set(fRot[o], fRot[o + 1], fRot[o + 2]);
tmpQ.setFromEuler(tmpE);
tmpS.setScalar(fSize[i] * fadeS);
tmpP.set(fPos[o], fPos[o + 1], fPos[o + 2]);
tmpM.compose(tmpP, tmpQ, tmpS);
fMesh.setMatrixAt(i, tmpM);
}
audit(dt);
fMesh.instanceMatrix.needsUpdate = true;
// puddles spread and slowly soak away
const G2 = ground() + 0.05;
qMesh.count = puddles.length;
puddles.forEach((p, i) => {
p.vol *= Math.exp(-dt * 0.02);
const want = Math.sqrt(p.vol) * 1.4;
p.r += (want - p.r) * Math.min(1, dt * 2);
tmpM.compose(tmpP.set(p.x, G2, p.z), tmpQ.identity(), tmpS.set(p.r, 1, p.r * 0.8));
qMesh.setMatrixAt(i, tmpM);
});
qMesh.instanceMatrix.needsUpdate = true;
}
// Audit what is actually drawn, from the instance matrices themselves, so a stale or orphaned slot counts too:
// a slot with a visible scale whose position has not changed. Past STILL_MAX it is hidden and freed.
function audit(dt) {
const a = fMesh.instanceMatrix.array, G = ground();
let mx = 0, air = 0;
for (let i = 0; i < FMAX; i++) {
const j = i * 16, o = i * 3;
const drawn = i < fMesh.count && a[j] * a[j] + a[j + 1] * a[j + 1] + a[j + 2] * a[j + 2] > 1e-8;
const moved = Math.abs(a[j + 12] - fLast[o]) + Math.abs(a[j + 13] - fLast[o + 1]) + Math.abs(a[j + 14] - fLast[o + 2]) > 1e-4;
fLast[o] = a[j + 12]; fLast[o + 1] = a[j + 13]; fLast[o + 2] = a[j + 14];
fStill[i] = drawn && !moved && dt > 0 ? fStill[i] + dt : drawn && !moved ? fStill[i] : 0;
if (fStill[i] > STILL_MAX) { fLive[i] = 0; hide(i); fStill[i] = 0; retired++; continue; }
if (fStill[i] > mx) mx = fStill[i];
if (fStill[i] > 0 && a[j + 13] > G + 0.5) air = Math.max(air, fStill[i]);
}
auditMax = Math.max(auditMax, mx); auditAir = Math.max(auditAir, air);
}
function reset() {
for (const k of masks.values()) { own(k.m).uDmgOn.value = 0; own(k.m).uDmg.value = blankTex; k.tex.dispose(); }
masks.clear();
emberCount = 0;
cr = null; shared.uCrumble.value = 0; hCount = 0; hGeo.setDrawRange(0, 0);
pCol.fill(0); fLive.fill(0); pStill.fill(0); fStill.fill(0);
for (let i = 0; i < FMAX; i++) hide(i); // no stale matrices left in the pool
fMesh.instanceMatrix.needsUpdate = true; fMesh.count = 0; fHead = 0; puddles.length = 0; qMesh.count = 0;
}
const blankTex = new THREE.DataTexture(new Uint8Array(4), 1, 1); blankTex.needsUpdate = true;
const belowFloor = () => { const G = ground(); let n = 0, sleeping = 0; for (let i = 0; i < PMAX; i++) if (pCol[i * 4 + 3] > 0) { if (pPos[i * 3 + 1] < G - 0.01) n++; if (pSleep[i]) sleeping++; } return { n, sleeping }; };
const stats = () => ({ floor: belowFloor(), 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, crumble: cr ? +shared.uCrumble.value.toFixed(3) : 0, crumbleDone: !!(cr && cr.done), heap: hCount, ground: +ground().toFixed(2), emberSpawns, emberFallback,
stillMax: +auditMax.toFixed(2), stillAirMax: +auditAir.toFixed(2), retired });
// map a material-space hit (mm) from the flat sandbox onto the same mask
function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); }
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); }
const objects = [points, fMesh, qMesh, heapPts];
return { objects, shared, blankTex, bruise, crush, tear, wetAt, ignite, debris, drops, update, reset, stats, crumble, simulate: (n, dt) => { for (let i = 0; i < n; i++) update(dt, shared.uDmgT.value + dt, dt); return stats(); }, crushMM, tearMM, wetness: (m, u, v) => (masks.has(m) ? at(masks.get(m), u, v, 3) : 0), dust: (x, y, z) => debris({ x, y, z }, 1.5, 0, 'tear') || spawnP(0, x, y, z, (Math.random() - 0.5) * 30, 8 + Math.random() * 12, 8 + Math.random() * 15, 0.3 + Math.random() * 0.4, 10, 0.05, 0.043, 0.038, 0.8) };
}