// 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) }; }