Spaces:
Sleeping
Sleeping
| /** | |
| * PhysicsEngine.jsx β Complete Cannon-es physics integration | |
| * | |
| * Fixes: | |
| * - Bodies now automatically registered when models load | |
| * - Proper sync: physics β Three.js (not overridden by store transforms) | |
| * - Fixed accumulator so simulation runs at stable 120Hz substeps | |
| * | |
| * New physics properties: | |
| * - velocity, acceleration (applied as forces every frame) | |
| * - linearDamping (air resistance / viscosity simulation) | |
| * - angularDamping (rotational resistance) | |
| * - static friction + dynamic friction (per contact material) | |
| * - restitution (bounciness) | |
| * - centerOfMass offset | |
| * - continuousCollisionDetection for fast-moving objects | |
| * - Wind force (constant world-space force on all dynamic bodies) | |
| * - Per-body constant force (engine force for vehicles) | |
| * | |
| * Exported API: | |
| * registerPhysicsObject(id, mesh, props) | |
| * unregisterPhysicsObject(id) | |
| * applyImpulse(id, {x,y,z}, point?) | |
| * applyForce(id, {x,y,z}, point?) | |
| * setBodyVelocity(id, {x,y,z}) | |
| * setAngularVelocity(id, {x,y,z}) | |
| * getBodyState(id) β {position, velocity, angularVelocity, sleeping} | |
| * getPhysicsWorld() | |
| */ | |
| import { useEffect, useRef } from 'react' | |
| import { useFrame } from '@react-three/fiber' | |
| import * as CANNON from 'cannon-es' | |
| import * as THREE from 'three' | |
| import useStore from '../store/useStore' | |
| // ββ Globals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let world = null | |
| const bodies = new Map() // modelId β CANNON.Body | |
| const meshes = new Map() // modelId β THREE.Object3D | |
| const forces = new Map() // modelId β {x,y,z} constant per-body force | |
| // ββ World creation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function buildWorld(gravity, cfg = {}) { | |
| const w = new CANNON.World({ | |
| gravity: new CANNON.Vec3(0, gravity, 0), | |
| }) | |
| w.broadphase = new CANNON.SAPBroadphase(w) | |
| w.allowSleep = true | |
| w.solver.iterations = 20 // more iterations = more stable stacks | |
| // Default contact material | |
| const def = w.defaultContactMaterial | |
| def.friction = cfg.globalFriction ?? 0.4 | |
| def.restitution = cfg.globalRestitution ?? 0.3 | |
| def.contactEquationStiffness = 1e8 | |
| def.contactEquationRelaxation = 3 | |
| // Static ground plane | |
| const ground = new CANNON.Body({ mass:0, type:CANNON.Body.STATIC }) | |
| ground.addShape(new CANNON.Plane()) | |
| ground.quaternion.setFromEuler(-Math.PI/2, 0, 0) | |
| ground.position.set(0, 0, 0) | |
| ground.material = new CANNON.Material('ground') | |
| ground.material.friction = cfg.globalFriction ?? 0.6 | |
| ground.material.restitution = cfg.globalRestitution ?? 0.3 | |
| w.addBody(ground) | |
| return w | |
| } | |
| function teardown() { | |
| if (world) { | |
| ;[...world.bodies].forEach(b => world.removeBody(b)) | |
| world = null | |
| } | |
| bodies.clear(); meshes.clear(); forces.clear() | |
| } | |
| // ββ Body creation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function makeBody(mesh, props = {}) { | |
| const { | |
| mass = 1, | |
| type = 'dynamic', | |
| linearDamping = 0.3, | |
| angularDamping = 0.5, | |
| friction = 0.4, | |
| restitution = 0.2, | |
| staticFriction = 0.6, | |
| ccdRadius = 0, // >0 enables CCD for fast objects | |
| centerOfMassY = 0, // COM offset (lower = more stable car) | |
| collisionShape = 'box', // box | sphere | cylinder | |
| } = props | |
| // Compute bounding box from mesh | |
| const bb = new THREE.Box3().setFromObject(mesh) | |
| const size = bb.getSize(new THREE.Vector3()) | |
| const cx = bb.getCenter(new THREE.Vector3()) | |
| const bodyType = | |
| type === 'static' ? CANNON.Body.STATIC : | |
| type === 'kinematic' ? CANNON.Body.KINEMATIC : | |
| CANNON.Body.DYNAMIC | |
| const body = new CANNON.Body({ | |
| mass: bodyType === CANNON.Body.STATIC ? 0 : Math.max(0.01, mass), | |
| type: bodyType, | |
| linearDamping: Math.min(1, Math.max(0, linearDamping)), | |
| angularDamping: Math.min(1, Math.max(0, angularDamping)), | |
| allowSleep: true, | |
| sleepSpeedLimit: 0.05, | |
| sleepTimeLimit: 0.5, | |
| }) | |
| // Choose collision shape | |
| if (collisionShape === 'sphere') { | |
| const r = Math.max(size.x, size.y, size.z) / 2 | |
| body.addShape(new CANNON.Sphere(Math.max(r, 0.05))) | |
| } else if (collisionShape === 'cylinder') { | |
| const r = Math.max(size.x, size.z) / 2 | |
| body.addShape(new CANNON.Cylinder(Math.max(r,0.05), Math.max(r,0.05), Math.max(size.y,0.1), 12)) | |
| } else { | |
| body.addShape(new CANNON.Box(new CANNON.Vec3( | |
| Math.max(size.x/2, 0.05), | |
| Math.max(size.y/2, 0.05), | |
| Math.max(size.z/2, 0.05), | |
| ))) | |
| } | |
| // Place at mesh world position exactly β no offsets that cause drift | |
| const wp = new THREE.Vector3() | |
| mesh.getWorldPosition(wp) | |
| body.position.set(wp.x, wp.y + size.y/2, wp.z) | |
| const wq = new THREE.Quaternion() | |
| mesh.getWorldQuaternion(wq) | |
| body.quaternion.set(wq.x, wq.y, wq.z, wq.w) | |
| // Per-body contact material (for friction/restitution with ground) | |
| const mat = new CANNON.Material() | |
| mat.friction = friction | |
| mat.restitution = restitution | |
| body.material = mat | |
| if (world) { | |
| const groundMat = world.bodies[0]?.material | |
| if (groundMat) { | |
| const contact = new CANNON.ContactMaterial(groundMat, mat, { | |
| friction, | |
| restitution, | |
| contactEquationStiffness: 1e8, | |
| contactEquationRelaxation: 3, | |
| frictionEquationStiffness: 1e8, | |
| }) | |
| world.addContactMaterial(contact) | |
| } | |
| } | |
| // CCD for fast objects (vehicles) | |
| if (ccdRadius > 0) { | |
| body.ccdSpeedThreshold = 1 | |
| body.ccdIterations = 10 | |
| } | |
| return body | |
| } | |
| // ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function registerPhysicsObject(id, mesh, props) { | |
| if (!world) return | |
| if (bodies.has(id)) { | |
| world.removeBody(bodies.get(id)) | |
| bodies.delete(id); meshes.delete(id); forces.delete(id) | |
| } | |
| const body = makeBody(mesh, props) | |
| world.addBody(body) | |
| bodies.set(id, body) | |
| meshes.set(id, mesh) | |
| } | |
| export function unregisterPhysicsObject(id) { | |
| if (!bodies.has(id)) return | |
| world?.removeBody(bodies.get(id)) | |
| bodies.delete(id); meshes.delete(id); forces.delete(id) | |
| } | |
| export function applyImpulse(id, imp, pt = {x:0,y:0,z:0}) { | |
| const b = bodies.get(id); if(!b) return | |
| b.applyImpulse(new CANNON.Vec3(imp.x,imp.y,imp.z), new CANNON.Vec3(pt.x,pt.y,pt.z)) | |
| b.wakeUp() | |
| } | |
| export function applyForce(id, f, pt = {x:0,y:0,z:0}) { | |
| const b = bodies.get(id); if(!b) return | |
| b.applyForce(new CANNON.Vec3(f.x,f.y,f.z), new CANNON.Vec3(pt.x,pt.y,pt.z)) | |
| b.wakeUp() | |
| } | |
| export function setBodyVelocity(id, v) { | |
| const b = bodies.get(id); if(!b) return | |
| b.velocity.set(v.x||0, v.y||0, v.z||0); b.wakeUp() | |
| } | |
| export function setAngularVelocity(id, v) { | |
| const b = bodies.get(id); if(!b) return | |
| b.angularVelocity.set(v.x||0, v.y||0, v.z||0); b.wakeUp() | |
| } | |
| export function setConstantForce(id, f) { | |
| if (f) forces.set(id, f) | |
| else forces.delete(id) | |
| } | |
| export function getBodyState(id) { | |
| const b = bodies.get(id); if(!b) return null | |
| return { | |
| position: { x:b.position.x, y:b.position.y, z:b.position.z }, | |
| velocity: { x:b.velocity.x, y:b.velocity.y, z:b.velocity.z }, | |
| angularVelocity: { x:b.angularVelocity.x, y:b.angularVelocity.y, z:b.angularVelocity.z }, | |
| sleeping: b.sleepState === CANNON.Body.SLEEPING, | |
| speed: b.velocity.length(), | |
| } | |
| } | |
| export function teleportBody(id, pos, quat) { | |
| const b = bodies.get(id); if(!b) return | |
| if (pos) b.position.set(pos.x, pos.y, pos.z) | |
| if (quat) b.quaternion.set(quat.x, quat.y, quat.z, quat.w) | |
| b.velocity.set(0,0,0); b.angularVelocity.set(0,0,0); b.wakeUp() | |
| } | |
| export function getPhysicsWorld() { return world } | |
| export function getBodies() { return bodies } | |
| // ββ Ticker β runs inside R3F Canvas ββββββββββββββββββββββββββββββββββββββββββ | |
| function Ticker() { | |
| const accum = useRef(0) | |
| const FIXED = 1/120 // 120Hz substeps | |
| useFrame((_, delta) => { | |
| if (!world) return | |
| const s = useStore.getState() | |
| const wind = s.physicsWind || { x:0, y:0, z:0 } | |
| const windMag = Math.sqrt(wind.x**2 + wind.y**2 + wind.z**2) | |
| // Apply constant forces before stepping | |
| bodies.forEach((body, id) => { | |
| if (body.type !== CANNON.Body.DYNAMIC) return | |
| // Per-body constant engine force | |
| const cf = forces.get(id) | |
| if (cf) body.applyForce(new CANNON.Vec3(cf.x, cf.y, cf.z), body.position) | |
| // Global wind force (proportional to exposed area, simplified) | |
| if (windMag > 0) { | |
| body.applyForce(new CANNON.Vec3(wind.x, wind.y, wind.z), body.position) | |
| } | |
| }) | |
| // Fixed timestep accumulator | |
| accum.current += Math.min(delta, 0.1) | |
| while (accum.current >= FIXED) { | |
| world.step(FIXED) | |
| accum.current -= FIXED | |
| } | |
| // Sync physics β Three.js meshes ONLY (no store writeback - causes feedback loop) | |
| bodies.forEach((body, id) => { | |
| const mesh = meshes.get(id) | |
| if (!mesh || body.type === CANNON.Body.STATIC) return | |
| mesh.position.set(body.position.x, body.position.y, body.position.z) | |
| mesh.quaternion.set(body.quaternion.x, body.quaternion.y, body.quaternion.z, body.quaternion.w) | |
| }) | |
| }) | |
| return null | |
| } | |
| // ββ Main export ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function PhysicsEngine() { | |
| const { physicsEnabled, gravity, physicsConfig } = useStore() | |
| useEffect(() => { | |
| if (physicsEnabled) { | |
| if (!world) world = buildWorld(gravity, physicsConfig || {}) | |
| else world.gravity.set(0, gravity, 0) | |
| } else { | |
| teardown() | |
| } | |
| return () => {} | |
| }, [physicsEnabled, gravity]) | |
| // Update global friction/restitution when config changes | |
| useEffect(() => { | |
| if (!world || !physicsConfig) return | |
| world.defaultContactMaterial.friction = physicsConfig.globalFriction ?? 0.4 | |
| world.defaultContactMaterial.restitution = physicsConfig.globalRestitution ?? 0.3 | |
| }, [physicsConfig]) | |
| if (!physicsEnabled) return null | |
| return <Ticker /> | |
| } | |