1. 从零搭建Three.js与Cannon.js开发环境
第一次接触3D物理交互开发时,我被各种配置搞得晕头转向。现在回想起来,其实只需要掌握几个关键步骤就能快速搭建开发环境。这里我推荐使用Vite作为构建工具,它比Webpack配置简单得多,特别适合新手快速上手。
首先创建一个干净的Vue项目(React同理):
npm create vite@latest threejs-cannonjs-demo --template vue-ts cd threejs-cannonjs-demo npm install three @types/three cannon-es安装完基础依赖后,我们需要处理一个关键问题:TypeScript类型定义。cannon-es的类型定义需要额外安装,这里有个小坑我踩过 - 直接npm install @types/cannon-es会报错,正确的做法是:
npm install @types/cannon接下来在main.ts中初始化基础场景。我习惯把Three.js的渲染器、相机等核心对象放在Vue的provide/inject中,这样组件树中的任何地方都能访问到:
// main.ts import { createApp } from 'vue' import App from './App.vue' import * as THREE from 'three' const app = createApp(App) // 初始化基础Three.js环境 const renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(window.innerWidth, window.innerHeight) const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) const scene = new THREE.Scene() app.provide('three', { renderer, camera, scene }) document.body.appendChild(renderer.domElement) app.mount('#app')2. 物理世界与渲染世界的同步原理
刚开始用Cannon.js时,最让我困惑的是为什么要在两个地方创建物体 - 既要在Cannon.js的物理世界创建刚体,又要在Three.js的场景中创建网格。后来才明白这是典型的ECS(实体-组件-系统)架构思想。
物理引擎只负责计算物体的位置、旋转等物理状态,而Three.js负责将这些状态可视化。两者通过每帧的同步实现联动。这里分享一个实用的同步模式:
const physicsBodies: CANNON.Body[] = [] // 存储所有物理刚体 const visualMeshes: THREE.Mesh[] = [] // 存储所有可视化网格 // 创建物理刚体 const sphereShape = new CANNON.Sphere(0.5) const sphereBody = new CANNON.Body({ mass: 1, shape: sphereShape, position: new CANNON.Vec3(0, 5, 0) }) world.addBody(sphereBody) physicsBodies.push(sphereBody) // 创建可视化网格 const sphereGeometry = new THREE.SphereGeometry(0.5) const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }) const sphereMesh = new THREE.Mesh(sphereGeometry, sphereMaterial) scene.add(sphereMesh) visualMeshes.push(sphereMesh) // 动画循环中同步状态 function animate() { requestAnimationFrame(animate) const delta = clock.getDelta() world.step(delta) // 更新物理世界 // 同步所有物体状态 for(let i = 0; i < physicsBodies.length; i++) { visualMeshes[i].position.copy(physicsBodies[i].position) visualMeshes[i].quaternion.copy(physicsBodies[i].quaternion) } renderer.render(scene, camera) }这种模式的优势在于:
- 物理计算与渲染解耦
- 方便批量处理大量物体
- 性能更好,避免每帧都创建新对象
3. 刚体碰撞的实战技巧
在实现物体碰撞时,新手常会遇到物体穿透或者反弹不自然的问题。经过多次实践,我总结出几个关键参数需要特别注意:
质量(mass)的设置:
- 静态物体(如地面)mass设为0
- 动态物体mass建议从1开始尝试
- 过大的mass值会导致物体"太重"难以推动
碰撞形状的选择:
// 常用碰撞形状及性能对比 const shapes = { sphere: new CANNON.Sphere(0.5), // 性能最好 box: new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5)), cylinder: new CANNON.Cylinder(0.5, 0.5, 1, 8), // 边数越少性能越好 convex: new CANNON.ConvexPolyhedron(vertices, faces) // 最耗性能 }解决物体穿透的实用方案:
- 增加物理模拟的步频:
world.step(1/60, delta, 3) // 最大子步数设为3- 减小碰撞检测的间隔:
world.broadphase = new CANNON.NaiveBroadphase()- 使用连续碰撞检测(CCD):
sphereBody.collisionResponse = true sphereBody.ccdSpeedThreshold = 0.5 sphereBody.ccdIterations = 5一个完整的碰撞场景示例:
// 创建倾斜平面 const planeShape = new CANNON.Box(new CANNON.Vec3(5, 0.1, 5)) const planeBody = new CANNON.Body({ type: CANNON.Body.STATIC, shape: planeShape, position: new CANNON.Vec3(0, 0, 0) }) planeBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -0.2) world.addBody(planeBody) // 创建多个随机球体 for(let i = 0; i < 10; i++) { const radius = 0.2 + Math.random() * 0.3 const sphereShape = new CANNON.Sphere(radius) const sphereBody = new CANNON.Body({ mass: 0.3, shape: sphereShape, position: new CANNON.Vec3( Math.random() * 4 - 2, 5 + Math.random() * 3, Math.random() * 4 - 2 ) }) world.addBody(sphereBody) physicsBodies.push(sphereBody) // 可视化部分... }4. 高级物理材质与交互效果
要让3D场景的物理效果更真实,必须掌握材质系统的使用。Cannon.js的材质系统可以模拟现实世界中的各种物理特性,我通过实验总结了这些参数的实用范围:
摩擦系数(friction):
- 0:完全光滑(如冰面)
- 0.3-0.6:常见材质(木制、金属)
- 0.8-1:高摩擦(橡胶、粗糙表面)
弹性系数(restitution):
- 0:完全无弹性
- 0.5:适中弹性(篮球)
- 0.9-1:超级弹性(弹力球)
创建自定义材质并设置接触响应的完整流程:
// 1. 创建两种材质 const iceMaterial = new CANNON.Material("ice") iceMaterial.friction = 0.1 const rubberMaterial = new CANNON.Material("rubber") rubberMaterial.friction = 0.8 rubberMaterial.restitution = 0.7 // 2. 定义材质间的交互规则 const iceRubberContact = new CANNON.ContactMaterial( iceMaterial, rubberMaterial, { friction: 0.3, // 混合摩擦系数 restitution: 0.5 // 混合弹性系数 } ) world.addContactMaterial(iceRubberContact) // 3. 应用到刚体 const iceFloor = new CANNON.Body({ shape: new CANNON.Plane(), material: iceMaterial, type: CANNON.Body.STATIC }) const rubberBall = new CANNON.Body({ shape: new CANNON.Sphere(0.5), material: rubberMaterial, mass: 1, position: new CANNON.Vec3(0, 5, 0) })实现用户交互的实用技巧:
// 鼠标拾取物体 function setupMouseInteraction() { const raycaster = new THREE.Raycaster() const mouse = new THREE.Vector2() window.addEventListener('click', (event) => { // 计算鼠标位置归一化坐标 mouse.x = (event.clientX / window.innerWidth) * 2 - 1 mouse.y = -(event.clientY / window.innerHeight) * 2 + 1 // 发射射线 raycaster.setFromCamera(mouse, camera) const intersects = raycaster.intersectObjects(visualMeshes) if(intersects.length > 0) { // 找到对应的物理刚体 const index = visualMeshes.indexOf(intersects[0].object as THREE.Mesh) const body = physicsBodies[index] // 施加冲击力 body.applyImpulse( new CANNON.Vec3(0, 5, 0), new CANNON.Vec3(0, 0, 0) ) } }) }5. 性能优化实战经验
当场景中的物体超过100个时,性能问题就会突显。经过多个项目的磨练,我总结出这些优化方案特别有效:
1. 碰撞检测优化:
// 使用更高效的Broadphase算法 world.broadphase = new CANNON.SAPBroadphase(world) // 设置碰撞检测的边界 world.broadphase.useBoundingBoxes = true world.broadphase.dirty = true2. 渲染优化技巧:
- 对静态物体使用相同的材质
- 对大量相似物体使用InstancedMesh
- 合理设置相机的far/near参数
3. 物理模拟优化:
// 调整求解器迭代次数 world.solver.iterations = 7 // 默认10,越小性能越好但精度越低 // 启用睡眠模式 world.allowSleep = true // 使用固定时间步长 let lastTime = 0 function animate(time) { const delta = Math.min((time - lastTime) / 1000, 0.1) // 最大delta限制 world.step(1/60, delta, 3) lastTime = time // ...渲染逻辑 }4. 对象池模式:对于需要频繁创建销毁的物体,使用对象池可以大幅提升性能:
class PhysicsObjectPool { private freeList: CANNON.Body[] = [] createBody(options: BodyOptions): CANNON.Body { if(this.freeList.length > 0) { const body = this.freeList.pop() // 重置body状态 body.position.copy(options.position) body.velocity.set(0, 0, 0) return body } // 新建刚体 return new CANNON.Body(options) } releaseBody(body: CANNON.Body) { world.removeBody(body) this.freeList.push(body) } }6. 复杂交互场景构建
结合Three.js的动画系统和Cannon.js的物理引擎,可以创造出令人惊艳的交互效果。这里分享一个多米诺骨牌效应的实现方案:
// 创建多米诺骨牌 const dominoShape = new CANNON.Box(new CANNON.Vec3(0.1, 0.5, 1)) const dominoMaterial = new CANNON.Material("domino") for(let i = 0; i < 20; i++) { const domino = new CANNON.Body({ mass: 0.5, shape: dominoShape, position: new CANNON.Vec3(i * 0.6, 0.5, 0), material: dominoMaterial }) domino.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), Math.PI/2) world.addBody(domino) // 可视化部分... } // 创建触发球 const ball = new CANNON.Body({ mass: 2, shape: new CANNON.Sphere(0.3), position: new CANNON.Vec3(-2, 2, 0) }) world.addBody(ball) // 添加初始力 setTimeout(() => { ball.applyImpulse(new CANNON.Vec3(5, 0, 0), new CANNON.Vec3(0, 0, 0)) }, 1000)实现绳索/约束的进阶技巧:
// 创建两个固定点 const fixedBody = new CANNON.Body({ mass: 0 }) const startPoint = new CANNON.Vec3(-3, 5, 0) const endPoint = new CANNON.Vec3(3, 5, 0) // 创建多个连接的刚体 const segmentCount = 10 const segments: CANNON.Body[] = [] for(let i = 0; i < segmentCount; i++) { const segment = new CANNON.Body({ mass: 0.1, shape: new CANNON.Sphere(0.1), position: new CANNON.Vec3( startPoint.x + (endPoint.x - startPoint.x) * i / (segmentCount - 1), startPoint.y, startPoint.z ) }) world.addBody(segment) segments.push(segment) // 添加约束 if(i > 0) { const constraint = new CANNON.DistanceConstraint( segments[i-1], segment, 0.5 // 最大距离 ) world.addConstraint(constraint) } } // 固定两端 world.addConstraint(new CANNON.PointToPointConstraint( fixedBody, new CANNON.Vec3(startPoint.x, startPoint.y, startPoint.z), segments[0], new CANNON.Vec3(0, 0, 0) )) world.addConstraint(new CANNON.PointToPointConstraint( fixedBody, new CANNON.Vec3(endPoint.x, endPoint.y, endPoint.z), segments[segmentCount-1], new CANNON.Vec3(0, 0, 0) ))7. 调试与问题排查指南
开发物理交互场景时,调试往往比编码更耗时。这些工具和方法帮我节省了大量时间:
1. 物理调试渲染器:
import { CannonDebugRenderer } from 'cannon-es-debugger' const cannonDebugRenderer = new CannonDebugRenderer(scene, world) function animate() { // ...其他逻辑 cannonDebugRenderer.update() // 每帧更新调试渲染 }2. 性能监测面板:
import Stats from 'stats.js' const stats = new Stats() stats.showPanel(0) // 0: fps, 1: ms, 2: mb document.body.appendChild(stats.dom) function animate() { stats.begin() // ...主逻辑 stats.end() }3. 常见问题排查表:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 物体穿透 | 时间步长太大 | 减小world.step()的delta值 |
| 物体抖动 | 质量差异过大 | 调整mass值使相近 |
| 性能骤降 | 复杂碰撞形状 | 改用简单碰撞形状或减少面数 |
| 反弹不正常 | 弹性系数过高 | 降低restitution值 |
| 物体不移动 | mass设为0 | 动态物体mass需大于0 |
4. 实用调试代码片段:
// 实时修改物理参数 gui.add(world.gravity, 'y', -20, 0).name('重力大小') gui.add(world.solver, 'iterations', 1, 20).name('求解器迭代') // 打印物体状态 console.log(body.position, body.velocity) // 暂停物理模拟 let physicsPaused = false window.addEventListener('keydown', (e) => { if(e.key === ' ') physicsPaused = !physicsPaused })8. 项目架构与最佳实践
经过多个3D物理项目的迭代,我总结出这套可维护的项目架构:
src/ ├── assets/ # 静态资源 ├── components/ # 通用3D组件 │ ├── PhysicsObject.vue # 物理对象基类 │ ├── Domino.vue # 具体物体实现 │ └── Ball.vue ├── composables/ # 逻辑复用 │ ├── usePhysics.ts # 物理引擎封装 │ └── useRenderer.ts # 渲染逻辑 ├── scenes/ # 场景管理 │ ├── MainScene.ts # 主场景 │ └── GameScene.ts └── utils/ # 工具函数 ├── physicsUtils.ts # 物理辅助 └── threeUtils.ts # Three.js辅助关键实现代码示例(使用Composition API):
// usePhysics.ts export default function usePhysics() { const world = new CANNON.World() world.gravity.set(0, -9.82, 0) // 添加默认接触材质 const defaultMaterial = new CANNON.Material("default") const defaultContact = new CANNON.ContactMaterial( defaultMaterial, defaultMaterial, { friction: 0.3, restitution: 0.3 } ) world.addContactMaterial(defaultContact) // 物理更新循环 const updatePhysics = (delta: number) => { world.step(delta) } return { world, updatePhysics } } // PhysicsObject.vue export default defineComponent({ props: { position: { type: Object as PropType<THREE.Vector3>, default: () => new THREE.Vector3() } }, setup(props) { const { world } = inject('physics')! const { scene } = inject('three')! const body = new CANNON.Body({ mass: 1, shape: new CANNON.Sphere(0.5), position: new CANNON.Vec3(...props.position.toArray()) }) const mesh = new THREE.Mesh( new THREE.SphereGeometry(0.5), new THREE.MeshBasicMaterial({ color: 0xff0000 }) ) onMounted(() => { world.addBody(body) scene.add(mesh) }) onUnmounted(() => { world.removeBody(body) scene.remove(mesh) }) return { body, mesh } } })在大型项目中,这种架构的优势非常明显:
- 物理逻辑与渲染逻辑分离
- 组件化开发,复用性高
- 类型安全,维护方便
- 场景切换时资源自动清理