// physics.js — Rapier (WASM) destruction. One rigid body per smashable prop, // box colliders auto-sized from the GLB bounding box (mirrors the Godot lane's // `_body_local_aabb`). Implements: // • a support-graph cascade — fixed bodies hold up others; smashing a support // wakes its dependents to dynamic ("knock the legs, it pancakes"). // • a pre-fractured swap — when a prop ships a `.fractured.glb` (Lane 2 // contract #2) we hide the intact mesh and spawn each chunk as a short-lived // dynamic body; brittle props with no fracture get generative shards. // • a debris cap with sleep/despawn so JS GC never becomes the ceiling. import * as THREE from 'three'; import RAPIER from '@dimforge/rapier3d-compat'; let ready = false; export async function initPhysics() { if (!ready) { await RAPIER.init(); ready = true; } return RAPIER; } const _box = new THREE.Box3(); const _size = new THREE.Vector3(); const _center = new THREE.Vector3(); const _q = new THREE.Quaternion(); const _v = new THREE.Vector3(); // AABB of an object in its OWN local frame — its position/rotation are excluded // (so the box is what the body's collider needs), but scale is kept (a scaled // prop wants a scaled collider). setFromObject alone returns a world-space box, // which would double-offset a prop placed away from the origin. const _sp = new THREE.Vector3(), _sq = new THREE.Quaternion(); export function localAABB(obj) { _sp.copy(obj.position); _sq.copy(obj.quaternion); obj.position.set(0, 0, 0); obj.quaternion.identity(); obj.updateWorldMatrix(true, true); _box.setFromObject(obj); obj.position.copy(_sp); obj.quaternion.copy(_sq); obj.updateWorldMatrix(true, true); _box.getSize(_size); _box.getCenter(_center); return { size: _size.clone(), center: _center.clone() }; } export function createPhysics(scene) { const world = new RAPIER.World({ x: 0, y: -12.5, z: 0 }); const eventQueue = new RAPIER.EventQueue(true); // every prop we sim: { body, mesh, material, kind, collHandle, ... } const props = []; const byCollider = new Map(); // collider.handle -> prop const debris = []; // short-lived shards { body, mesh, ttl } const MAX_DEBRIS = 140; // ---- factory: a simulated box body that drives a mesh ---- function boxColliderDesc(size, center, { restitution = 0.15, friction = 0.85, density = 1 } = {}) { return RAPIER.ColliderDesc .cuboid(Math.max(0.02, size.x / 2), Math.max(0.02, size.y / 2), Math.max(0.02, size.z / 2)) .setTranslation(center.x, center.y, center.z) .setRestitution(restitution).setFriction(friction).setDensity(density); } // Register a mesh as a smashable prop. `fixed` props are supports until disturbed. function addProp(mesh, opts = {}) { const { material = 'wood', kind = 'prop', fixed = false, mass = 1, brittle = false, fractured = null, grabbable = false } = opts; const shatterable = opts.shatterable ?? (brittle || kind === 'crate' || kind === 'bin'); const { size, center } = localAABB(mesh); const p = mesh.position, q = mesh.quaternion; const desc = (fixed ? RAPIER.RigidBodyDesc.fixed() : RAPIER.RigidBodyDesc.dynamic()) .setTranslation(p.x, p.y, p.z) .setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }) .setLinearDamping(0.12).setAngularDamping(0.25) .setCcdEnabled(true); const body = world.createRigidBody(desc); const cd = boxColliderDesc(size, center, { restitution: brittle ? 0.05 : 0.18, friction: 0.9, density: mass / Math.max(0.03, size.x * size.y * size.z), }).setActiveEvents(RAPIER.ActiveEvents.CONTACT_FORCE_EVENTS) .setContactForceEventThreshold(brittle ? 6 : 14); const coll = world.createCollider(cd, body); const prop = { body, mesh, coll, material, kind, brittle, fractured, grabbable, shatterable, fixed, size: size.clone(), center: center.clone(), supports: [], supportedBy: [], smashed: false, dynamicSince: fixed ? -1 : 0, }; byCollider.set(coll.handle, prop); props.push(prop); mesh.userData.prop = prop; return prop; } // Static world geometry (floor / walls) — colliders only, no mesh sync. function addStaticBox(center, half, friction = 0.95) { const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(center.x, center.y, center.z)); world.createCollider(RAPIER.ColliderDesc.cuboid(half.x, half.y, half.z).setFriction(friction), body); return body; } // Declare "b rests on a" — smashing/toppling a frees b to fall. function support(a, b) { a.supports.push(b); b.supportedBy.push(a); } function wake(prop) { if (prop.smashed) return; if (prop.fixed) { prop.fixed = false; prop.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true); prop.dynamicSince = tNow; } prop.body.wakeUp(); } // Cascade: when a support is destroyed/toppled, its dependents go dynamic. function cascade(prop) { for (const dep of prop.supports) { if (dep.fixed && !dep.smashed) { wake(dep); // a little nudge so the pancake reads immediately dep.body.applyImpulse({ x: (Math.random() - 0.5) * 0.6, y: -0.3, z: (Math.random() - 0.5) * 0.6 }, true); } } } // ---- shattering ---- // hide the intact mesh, spawn chunks (from a fractured template if present, // else generative cubes), each a short-lived dynamic body inheriting velocity. function shatter(prop, hitDir, power) { if (prop.smashed) return; prop.smashed = true; // inherit the intact body's velocity for the debris let lin = { x: 0, y: 0, z: 0 }; try { const lv = prop.body.linvel(); lin = { x: lv.x, y: lv.y, z: lv.z }; } catch (e) {} const origin = prop.body.translation(); const rot = prop.body.rotation(); // free any dependents first (support gone) cascade(prop); // remove the intact body + mesh byCollider.delete(prop.coll.handle); world.removeRigidBody(prop.body); prop.mesh.visible = false; if (prop.mesh.parent) prop.mesh.parent.remove(prop.mesh); const idx = props.indexOf(prop); if (idx >= 0) props.splice(idx, 1); const chunks = prop.fractured ? spawnFracturedChunks(prop, origin, rot) : spawnGenerativeShards(prop, origin); // kick every chunk outward + inherit velocity for (const ch of chunks) { const dir = _v.set(ch.mesh.position.x - origin.x, ch.mesh.position.y - origin.y, ch.mesh.position.z - origin.z); if (dir.lengthSq() < 1e-4) dir.set(hitDir.x, 0.4, hitDir.z); dir.normalize(); const s = (1.2 + power * 2.2); ch.body.setLinvel({ x: lin.x + dir.x * s + hitDir.x * power * 2, y: lin.y + Math.abs(dir.y) * s + 1.5, z: lin.z + dir.z * s + hitDir.z * power * 2 }, true); ch.body.setAngvel({ x: (Math.random() - 0.5) * 12, y: (Math.random() - 0.5) * 12, z: (Math.random() - 0.5) * 12 }, true); } return chunks; } const _shardGeo = new THREE.BoxGeometry(1, 1, 1); function spawnGenerativeShards(prop, origin) { const out = []; const n = prop.brittle ? 7 : 5; const s = prop.size; const baseColor = new THREE.Color(prop.mesh.userData.tintHex || 0x8a5a2b); for (let i = 0; i < n; i++) { const sx = s.x * (0.22 + Math.random() * 0.3); const sy = s.y * (0.22 + Math.random() * 0.3); const sz = s.z * (0.22 + Math.random() * 0.3); const mat = new THREE.MeshStandardMaterial({ color: baseColor.clone().offsetHSL(0, 0, (Math.random() - 0.5) * 0.1), roughness: 0.85 }); const mesh = new THREE.Mesh(_shardGeo, mat); mesh.scale.set(sx, sy, sz); const px = origin.x + (Math.random() - 0.5) * s.x * 0.5; const py = origin.y + prop.center.y + (Math.random() - 0.5) * s.y * 0.5; const pz = origin.z + (Math.random() - 0.5) * s.z * 0.5; mesh.position.set(px, py, pz); scene.add(mesh); const body = world.createRigidBody( RAPIER.RigidBodyDesc.dynamic().setTranslation(px, py, pz).setLinearDamping(0.2).setAngularDamping(0.4)); world.createCollider(RAPIER.ColliderDesc.cuboid(sx / 2, sy / 2, sz / 2).setRestitution(0.2).setFriction(0.8).setDensity(0.6), body); const d = { body, mesh, ttl: 3.5 + Math.random() * 2 }; debris.push(d); out.push(d); } trimDebris(); return out; } function spawnFracturedChunks(prop, origin, rot) { const out = []; const tmpl = prop.fractured; // THREE.Object3D whose direct children are chunk_* meshes _q.set(rot.x, rot.y, rot.z, rot.w); for (const child of tmpl.children) { if (!/^chunk/i.test(child.name)) continue; const mesh = child.clone(true); // chunk origin is its own centroid, in the intact model's local space const local = new THREE.Vector3().copy(child.position); local.applyQuaternion(_q); const px = origin.x + local.x, py = origin.y + local.y, pz = origin.z + local.z; mesh.position.set(px, py, pz); mesh.quaternion.copy(_q); scene.add(mesh); const { size, center } = localAABB(child); const body = world.createRigidBody( RAPIER.RigidBodyDesc.dynamic().setTranslation(px, py, pz) .setRotation({ x: rot.x, y: rot.y, z: rot.z, w: rot.w }) .setLinearDamping(0.2).setAngularDamping(0.4)); world.createCollider( RAPIER.ColliderDesc.cuboid(Math.max(0.01, size.x / 2), Math.max(0.01, size.y / 2), Math.max(0.01, size.z / 2)) .setTranslation(center.x, center.y, center.z).setRestitution(0.15).setFriction(0.85).setDensity(0.7), body); const d = { body, mesh, ttl: 4 + Math.random() * 2 }; debris.push(d); out.push(d); } // if the template had no usable chunks, fall back so a smash never no-ops return out.length ? out : spawnGenerativeShards(prop, origin); } function trimDebris() { // remove settled/oldest debris beyond the cap so body count stays bounded while (debris.length > MAX_DEBRIS) { const d = debris.shift(); removeDebris(d); } } function removeDebris(d) { try { world.removeRigidBody(d.body); } catch (e) {} if (d.mesh) { if (d.mesh.parent) d.mesh.parent.remove(d.mesh); if (d.mesh.material && d.mesh.material.dispose) d.mesh.material.dispose(); } } // ---- knock a prop dynamic (non-brittle smash): topple + cascade, no shards ---- function knock(prop, hitDir, power) { if (prop.smashed) return; wake(prop); cascade(prop); const c = prop.center; prop.body.applyImpulseAtPoint( { x: hitDir.x * power * 6, y: 1.2 + power * 2, z: hitDir.z * power * 6 }, { x: prop.body.translation().x + c.x * 0.6, y: prop.body.translation().y + prop.size.y * 0.7, z: prop.body.translation().z + c.z * 0.6 }, true); prop.body.applyTorqueImpulse({ x: (Math.random() - 0.5) * power * 2, y: (Math.random() - 0.5), z: (Math.random() - 0.5) * power * 2 }, true); } // ---- per-frame ---- let tNow = 0; let onSmash = null; // callback(prop, worldPos, power) function setSmashHandler(fn) { onSmash = fn; } function step(dt, frozen) { tNow += dt; if (!frozen) { world.timestep = Math.min(1 / 30, Math.max(1 / 240, dt)); world.step(eventQueue); // contact-force events → smashes. Threshold set per collider above. eventQueue.drainContactForceEvents(ev => { const a = byCollider.get(ev.collider1()); const b = byCollider.get(ev.collider2()); const mag = ev.totalForceMagnitude(); for (const prop of [a, b]) { if (!prop || prop.smashed || prop.fixed) continue; // calibrated to measured impacts: a thrown record onto the floor is // ~30, a hard crate slam is higher; gentle settling is a few N. const power = Math.min(1.5, mag / 45); if (power < 0.14) continue; // ignore gentle settling contacts const t = prop.body.translation(); const wp = new THREE.Vector3(t.x + prop.center.x, t.y + prop.center.y, t.z + prop.center.z); // brittle (vinyl/glass) shatters on any real impact; crates/bins need a hard slam const willShatter = prop.shatterable && power > (prop.brittle ? 0.2 : 0.9); if (willShatter) shatter(prop, { x: 0, y: 0, z: 0 }, power); if (onSmash) onSmash(prop, wp, power, willShatter); } }); } // sync dynamic prop meshes for (const prop of props) { if (prop.fixed) continue; const t = prop.body.translation(), r = prop.body.rotation(); prop.mesh.position.set(t.x, t.y, t.z); prop.mesh.quaternion.set(r.x, r.y, r.z, r.w); } // sync + age debris for (let i = debris.length - 1; i >= 0; i--) { const d = debris[i]; const t = d.body.translation(), r = d.body.rotation(); d.mesh.position.set(t.x, t.y, t.z); d.mesh.quaternion.set(r.x, r.y, r.z, r.w); d.ttl -= dt; // despawn when its life is up, or once it's settled and off the cap budget if (d.ttl <= 0 || (d.body.isSleeping() && d.ttl < 2 && debris.length > MAX_DEBRIS * 0.6)) { removeDebris(d); debris.splice(i, 1); } } } return { world, RAPIER, addProp, addStaticBox, support, shatter, knock, wake, cascade, step, setSmashHandler, propAt(mesh) { let o = mesh; while (o) { if (o.userData && o.userData.prop) return o.userData.prop; o = o.parent; } return null; }, get props() { return props; }, get debrisCount() { return debris.length; }, get propCount() { return props.length; }, }; }