glb-studio / src /components /PhysicsEngine.jsx
varunm2004's picture
deploy: 2-stage Dockerfile, npm build on HF
458a46f verified
/**
* 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 />
}