Destroy debris: landed shards and dust fade out soon after landing instead of resting 25 to 30 s on the undrawn floor, where they read as hanging in mid-air; reset clears every instanced slot; watchdog retires anything drawn and unmoving for 2.5 s; damageStats reports stillMax, stillAirMax, retired
Browse files
app.js
CHANGED
|
@@ -1276,6 +1276,7 @@ requestAnimationFrame(() => loadOverlay());
|
|
| 1276 |
// camera hooks for the keyboard shortcuts in polish.js
|
| 1277 |
window.unrollView = {
|
| 1278 |
damageStats: () => damage.stats(),
|
|
|
|
| 1279 |
reset() {
|
| 1280 |
introDone(); autoplayAt = 0;
|
| 1281 |
if (mode === 'overview') flyTo(...overview(), 1.4);
|
|
|
|
| 1276 |
// camera hooks for the keyboard shortcuts in polish.js
|
| 1277 |
window.unrollView = {
|
| 1278 |
damageStats: () => damage.stats(),
|
| 1279 |
+
damageSim: (n, dt) => damage.simulate(n, dt), // test hook: step the debris without waiting for frames
|
| 1280 |
reset() {
|
| 1281 |
introDone(); autoplayAt = 0;
|
| 1282 |
if (mode === 'overview') flyTo(...overview(), 1.4);
|
damage.js
CHANGED
|
@@ -211,9 +211,16 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT }) {
|
|
| 211 |
const fPos = new Float32Array(FMAX * 3), fVel = new Float32Array(FMAX * 3), fRot = new Float32Array(FMAX * 3), fSpin = new Float32Array(FMAX * 3);
|
| 212 |
const fSize = new Float32Array(FMAX), fAge = new Float32Array(FMAX), fRest = new Uint8Array(FMAX), fLive = new Uint8Array(FMAX);
|
| 213 |
let fHead = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
function spawnF(x, y, z, vx, vy, vz, size) {
|
| 215 |
const i = fHead++ % FMAX;
|
| 216 |
-
fLive[i] = 1; fRest[i] = 0; fAge[i] = 0; fSize[i] = size;
|
| 217 |
fPos[i * 3] = x; fPos[i * 3 + 1] = y; fPos[i * 3 + 2] = z;
|
| 218 |
fVel[i * 3] = vx; fVel[i * 3 + 1] = vy; fVel[i * 3 + 2] = vz;
|
| 219 |
for (let c = 0; c < 3; c++) { fRot[i * 3 + c] = Math.random() * 6.3; fSpin[i * 3 + c] = (Math.random() - 0.5) * 14; }
|
|
@@ -366,20 +373,27 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT }) {
|
|
| 366 |
if (t === 2 || t === 4) { pCol[i * 4 + 3] = 0; continue; }
|
| 367 |
// dust and ash land: snap to the floor, stop, sleep
|
| 368 |
pPos[o + 1] = G + 0.05; pVel[o] = pVel[o + 1] = pVel[o + 2] = 0; pSleep[i] = 1;
|
| 369 |
-
pLife[i] = pAge[i] +
|
| 370 |
continue;
|
| 371 |
}
|
| 372 |
if (t === 2) { pSize[i] += dt * 2.6; pCol[i * 4 + 3] = 0.42 * fade * Math.min(1, pAge[i] * 2); }
|
| 373 |
else if (t === 4) pCol[i * 4 + 3] = fade;
|
| 374 |
else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9);
|
| 375 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
pGeo.attributes.position.needsUpdate = pGeo.attributes.aCol.needsUpdate = pGeo.attributes.aSize.needsUpdate = true;
|
| 377 |
// fragments flutter and tumble with lift; big flakes drop
|
| 378 |
for (let i = 0; i < fMesh.count; i++) {
|
| 379 |
const o = i * 3;
|
| 380 |
-
if (!fLive[i]) {
|
| 381 |
fAge[i] += dt;
|
| 382 |
-
if (!Number.isFinite(fPos[o] + fPos[o + 1] + fPos[o + 2])) { fLive[i] = 0; continue; }
|
| 383 |
if (!fRest[i]) {
|
| 384 |
const big = fSize[i] > 1.0;
|
| 385 |
const g = big ? 380 : 160, k = big ? 0.6 : 3.0;
|
|
@@ -391,8 +405,10 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT }) {
|
|
| 391 |
for (let c = 0; c < 3; c++) { fPos[o + c] += fVel[o + c] * dt; fRot[o + c] += fSpin[o + c] * dt; }
|
| 392 |
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; }
|
| 393 |
}
|
| 394 |
-
|
| 395 |
-
if (
|
|
|
|
|
|
|
| 396 |
tmpE.set(fRot[o], fRot[o + 1], fRot[o + 2]);
|
| 397 |
tmpQ.setFromEuler(tmpE);
|
| 398 |
tmpS.setScalar(fSize[i] * fadeS);
|
|
@@ -400,6 +416,7 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT }) {
|
|
| 400 |
tmpM.compose(tmpP, tmpQ, tmpS);
|
| 401 |
fMesh.setMatrixAt(i, tmpM);
|
| 402 |
}
|
|
|
|
| 403 |
fMesh.instanceMatrix.needsUpdate = true;
|
| 404 |
// puddles spread and slowly soak away
|
| 405 |
const G2 = ground() + 0.05;
|
|
@@ -414,18 +431,38 @@ export function createDamage({ THREE, scene, meshes, MOBILE, ground, uDmgT }) {
|
|
| 414 |
qMesh.instanceMatrix.needsUpdate = true;
|
| 415 |
}
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
function reset() {
|
| 418 |
for (const k of masks.values()) { own(k.m).uDmgOn.value = 0; own(k.m).uDmg.value = blankTex; k.tex.dispose(); }
|
| 419 |
masks.clear();
|
| 420 |
emberCount = 0;
|
| 421 |
-
pCol.fill(0); fLive.fill(0);
|
|
|
|
|
|
|
| 422 |
}
|
| 423 |
const blankTex = new THREE.DataTexture(new Uint8Array(4), 1, 1); blankTex.needsUpdate = true;
|
| 424 |
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 }; };
|
| 425 |
-
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
|
|
|
|
| 426 |
// map a material-space hit (mm) from the flat sandbox onto the same mask
|
| 427 |
function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); }
|
| 428 |
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); }
|
| 429 |
const objects = [points, fMesh, qMesh];
|
| 430 |
-
return { objects, shared, blankTex, bruise, 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) };
|
| 431 |
}
|
|
|
|
| 211 |
const fPos = new Float32Array(FMAX * 3), fVel = new Float32Array(FMAX * 3), fRot = new Float32Array(FMAX * 3), fSpin = new Float32Array(FMAX * 3);
|
| 212 |
const fSize = new Float32Array(FMAX), fAge = new Float32Array(FMAX), fRest = new Uint8Array(FMAX), fLive = new Uint8Array(FMAX);
|
| 213 |
let fHead = 0;
|
| 214 |
+
// Settle rule: nothing may hang still where it is drawn. In the rolled view the floor is not drawn, so a piece
|
| 215 |
+
// resting on it reads as hanging in mid-air; landed pieces therefore fade out soon after they land, and a
|
| 216 |
+
// watchdog retires anything that has been drawn without moving for STILL_MAX seconds.
|
| 217 |
+
const REST_HOLD = 1.0, REST_FADE = 1.2, STILL_MAX = 2.5;
|
| 218 |
+
const fRestT = new Float32Array(FMAX), fStill = new Float32Array(FMAX), fLast = new Float32Array(FMAX * 3), pStill = new Float32Array(PMAX);
|
| 219 |
+
let auditMax = 0, auditAir = 0, retired = 0;
|
| 220 |
+
const hide = (i) => { tmpM.makeScale(0, 0, 0); fMesh.setMatrixAt(i, tmpM); };
|
| 221 |
function spawnF(x, y, z, vx, vy, vz, size) {
|
| 222 |
const i = fHead++ % FMAX;
|
| 223 |
+
fLive[i] = 1; fRest[i] = 0; fAge[i] = 0; fSize[i] = size; fRestT[i] = 0; fStill[i] = 0;
|
| 224 |
fPos[i * 3] = x; fPos[i * 3 + 1] = y; fPos[i * 3 + 2] = z;
|
| 225 |
fVel[i * 3] = vx; fVel[i * 3 + 1] = vy; fVel[i * 3 + 2] = vz;
|
| 226 |
for (let c = 0; c < 3; c++) { fRot[i * 3 + c] = Math.random() * 6.3; fSpin[i * 3 + c] = (Math.random() - 0.5) * 14; }
|
|
|
|
| 373 |
if (t === 2 || t === 4) { pCol[i * 4 + 3] = 0; continue; }
|
| 374 |
// dust and ash land: snap to the floor, stop, sleep
|
| 375 |
pPos[o + 1] = G + 0.05; pVel[o] = pVel[o + 1] = pVel[o + 2] = 0; pSleep[i] = 1;
|
| 376 |
+
pLife[i] = pAge[i] + REST_HOLD + REST_FADE + Math.random() * 0.5;
|
| 377 |
continue;
|
| 378 |
}
|
| 379 |
if (t === 2) { pSize[i] += dt * 2.6; pCol[i * 4 + 3] = 0.42 * fade * Math.min(1, pAge[i] * 2); }
|
| 380 |
else if (t === 4) pCol[i * 4 + 3] = fade;
|
| 381 |
else if (t !== 3) pCol[i * 4 + 3] = Math.min(0.8, fade * 1.6) * (t === 0 ? 0.75 : 0.9);
|
| 382 |
}
|
| 383 |
+
// watchdog for particles: visible, not asleep on the floor, and not moving
|
| 384 |
+
for (let i = 0; i < PMAX; i++) {
|
| 385 |
+
if (pCol[i * 4 + 3] <= 0 || pSleep[i]) { pStill[i] = 0; continue; }
|
| 386 |
+
const sp = Math.abs(pVel[i * 3]) + Math.abs(pVel[i * 3 + 1]) + Math.abs(pVel[i * 3 + 2]);
|
| 387 |
+
pStill[i] = sp < 0.05 ? pStill[i] + dt : 0;
|
| 388 |
+
if (pStill[i] > STILL_MAX) { pCol[i * 4 + 3] = 0; pStill[i] = 0; retired++; }
|
| 389 |
+
}
|
| 390 |
pGeo.attributes.position.needsUpdate = pGeo.attributes.aCol.needsUpdate = pGeo.attributes.aSize.needsUpdate = true;
|
| 391 |
// fragments flutter and tumble with lift; big flakes drop
|
| 392 |
for (let i = 0; i < fMesh.count; i++) {
|
| 393 |
const o = i * 3;
|
| 394 |
+
if (!fLive[i]) { hide(i); continue; }
|
| 395 |
fAge[i] += dt;
|
| 396 |
+
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; }
|
| 397 |
if (!fRest[i]) {
|
| 398 |
const big = fSize[i] > 1.0;
|
| 399 |
const g = big ? 380 : 160, k = big ? 0.6 : 3.0;
|
|
|
|
| 405 |
for (let c = 0; c < 3; c++) { fPos[o + c] += fVel[o + c] * dt; fRot[o + c] += fSpin[o + c] * dt; }
|
| 406 |
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; }
|
| 407 |
}
|
| 408 |
+
// landed: lie a moment, then fade; airborne pieces still fade by age as a backstop
|
| 409 |
+
if (fRest[i]) fRestT[i] += dt;
|
| 410 |
+
const fadeS = Math.min(fAge[i] > 25 ? 1 - (fAge[i] - 25) / 5 : 1, fRest[i] ? 1 - (fRestT[i] - REST_HOLD) / REST_FADE : 1, 1);
|
| 411 |
+
if (fadeS <= 0) { fLive[i] = 0; hide(i); continue; }
|
| 412 |
tmpE.set(fRot[o], fRot[o + 1], fRot[o + 2]);
|
| 413 |
tmpQ.setFromEuler(tmpE);
|
| 414 |
tmpS.setScalar(fSize[i] * fadeS);
|
|
|
|
| 416 |
tmpM.compose(tmpP, tmpQ, tmpS);
|
| 417 |
fMesh.setMatrixAt(i, tmpM);
|
| 418 |
}
|
| 419 |
+
audit(dt);
|
| 420 |
fMesh.instanceMatrix.needsUpdate = true;
|
| 421 |
// puddles spread and slowly soak away
|
| 422 |
const G2 = ground() + 0.05;
|
|
|
|
| 431 |
qMesh.instanceMatrix.needsUpdate = true;
|
| 432 |
}
|
| 433 |
|
| 434 |
+
// Audit what is actually drawn, from the instance matrices themselves, so a stale or orphaned slot counts too:
|
| 435 |
+
// a slot with a visible scale whose position has not changed. Past STILL_MAX it is hidden and freed.
|
| 436 |
+
function audit(dt) {
|
| 437 |
+
const a = fMesh.instanceMatrix.array, G = ground();
|
| 438 |
+
let mx = 0, air = 0;
|
| 439 |
+
for (let i = 0; i < FMAX; i++) {
|
| 440 |
+
const j = i * 16, o = i * 3;
|
| 441 |
+
const drawn = i < fMesh.count && a[j] * a[j] + a[j + 1] * a[j + 1] + a[j + 2] * a[j + 2] > 1e-8;
|
| 442 |
+
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;
|
| 443 |
+
fLast[o] = a[j + 12]; fLast[o + 1] = a[j + 13]; fLast[o + 2] = a[j + 14];
|
| 444 |
+
fStill[i] = drawn && !moved && dt > 0 ? fStill[i] + dt : drawn && !moved ? fStill[i] : 0;
|
| 445 |
+
if (fStill[i] > STILL_MAX) { fLive[i] = 0; hide(i); fStill[i] = 0; retired++; continue; }
|
| 446 |
+
if (fStill[i] > mx) mx = fStill[i];
|
| 447 |
+
if (fStill[i] > 0 && a[j + 13] > G + 0.5) air = Math.max(air, fStill[i]);
|
| 448 |
+
}
|
| 449 |
+
auditMax = Math.max(auditMax, mx); auditAir = Math.max(auditAir, air);
|
| 450 |
+
}
|
| 451 |
function reset() {
|
| 452 |
for (const k of masks.values()) { own(k.m).uDmgOn.value = 0; own(k.m).uDmg.value = blankTex; k.tex.dispose(); }
|
| 453 |
masks.clear();
|
| 454 |
emberCount = 0;
|
| 455 |
+
pCol.fill(0); fLive.fill(0); pStill.fill(0); fStill.fill(0);
|
| 456 |
+
for (let i = 0; i < FMAX; i++) hide(i); // no stale matrices left in the pool
|
| 457 |
+
fMesh.instanceMatrix.needsUpdate = true; fMesh.count = 0; fHead = 0; puddles.length = 0; qMesh.count = 0;
|
| 458 |
}
|
| 459 |
const blankTex = new THREE.DataTexture(new Uint8Array(4), 1, 1); blankTex.needsUpdate = true;
|
| 460 |
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 }; };
|
| 461 |
+
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,
|
| 462 |
+
stillMax: +auditMax.toFixed(2), stillAirMax: +auditAir.toFixed(2), retired });
|
| 463 |
// map a material-space hit (mm) from the flat sandbox onto the same mask
|
| 464 |
function crushMM(m, xMM, yMM, rMM) { const w = m.userData.w; crush(m, xMM / w.widthMM, yMM / w.heightMM, rMM, 1.0); }
|
| 465 |
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); }
|
| 466 |
const objects = [points, fMesh, qMesh];
|
| 467 |
+
return { objects, shared, blankTex, bruise, crush, tear, wetAt, ignite, debris, drops, update, reset, stats, 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, 6, 0.13, 0.1, 0.08, 0.75) };
|
| 468 |
}
|