使用 Cannon.js 实现物理模拟效果
概述
Cannon.js 是一个开源的 JavaScript 物理引擎,可以模拟刚体(rigid body)的碰撞、重力、约束等物理行为。
它在处理碰撞检测、约束求解、弹性、摩擦力等方面都非常高效,让我们可以在浏览器里用相对简单的代码实现逼真的物理效果。
Three.js 负责三维场景的渲染与可视化,Cannon.js 则专注于物理模拟。
两者结合后,一方面,Three.js 让我们能把物理模拟的结果渲染为精致的三维画面;另一方面,Cannon.js 提供的真实物理效果能让 Three.js 的场景更加逼真、有互动感。
简单来说,Three.js+物理引擎=更逼真、更好玩的三维世界。
核心功能和特点:
- 刚体模拟:支持刚体的重力、速度、加速度以及碰撞行为。
- 碰撞检测:提供精准的碰撞检测,包括球体、盒子、平面等多种形状。
- 力学计算:支持施加外力、模拟弹性、摩擦力等多种物理现象。
- 优化性能:支持休眠机制和多种碰撞检测算法,提升物理计算效率。
使用场景:
- 创建交互式游戏中的物理效果,例如角色运动、物体掉落等。
- 模拟机械结构的动态行为,如齿轮、滑块。
- 在建筑仿真中实现结构受力分析。
Cannon.js 依赖安装
npm install cannon
Cannon.js 核心功能详解
1. 创建物理世界
物理世界是 Cannon.js 的核心,它是所有物理运算的基础。在 Cannon.js 中,World
是所有物理对象和规则的统筹管理者。它包含重力、碰撞检测、约束等各种物理要素。所有刚体都会被加入到这个世界中,之后进行物理模拟并更新状态。
物理世界离不开重力。我们需要设置 world.gravity
,让 Cannon.js 知道所有刚体该往哪个方向坠落。另外,为了在碰撞时获得更准确的效果,Cannon.js 提供了迭代器设置,如 world.solver.iterations
,它决定物理引擎对碰撞进行求解时迭代计算的次数。
CANNON.World 的核心属性和方法:
gravity
: 设置世界的重力方向和大小,通常通过Vec3
向量来定义,例如world.gravity.set(0, -9.82, 0)
表示沿 y 轴向下的重力。allowSleep
: 是否允许物体进入休眠状态,提高性能。默认值为false
。broadphase
: 使用碰撞检测算法,例如NaiveBroadphase
或SAPBroadphase
。step
: 用于更新物理世界的方法,接收时间步长和子步数。
在下面的代码里,我们先创建一个 Cannon.js 的世界,然后定义了重力,让物体朝 y 轴负方向掉落。同时我们会设置一些迭代器参数,以确保在碰撞时有更准确的结果。这样你就获得了一个最基础的物理引擎舞台,所有的物体都会在这个舞台里被物理规则驱动、计算。
// 创建物理世界实例
const world = new CANNON.World();
// 设置重力,y轴方向为 -9.82 m/s^2
world.gravity.set(0, -9.82, 0); // 模拟和地球相近的重力加速度
// 允许物体进入休眠状态,提高性能
world.allowSleep = true;
// 设置碰撞检测算法
world.broadphase = new CANNON.NaiveBroadphase();
2. 添加刚体
在 Cannon.js 中,我们一般通过 Body
来定义物体的物理属性(质量、摩擦、弹性等),再结合特定的 Shape
(例如 Sphere、Box、Plane
等)来描述它的形状。形状会影响碰撞检测算法以及物体的运动行为,比如球体的碰撞行为和方盒子就不一样。
在 Cannon.js 中,刚体由 CANNON.Body
类创建,该类提供了多种属性来控制物体的行为。
在实际应用里,我们通常会为 Cannon.js 里的刚体创建相对应的 Three.js 网格(Mesh)。这样做是为了:
- Cannon.js 里负责计算物体的物理行为。
- Three.js 里负责将该物体的三维模型渲染出来。
两者之间通过位置和旋转等参数来保持同步。
CANNON.Body 的主要属性和方法:
mass
: 刚体的质量,单位为千克(kg)。设置为 0 时,该刚体为静态物体。position
: 用于设置刚体在物理世界中的初始位置。velocity
: 控制刚体的线速度,决定其移动方向和速度。angularVelocity
: 控制刚体的角速度。material
: 用于设置刚体的材质属性,可影响碰撞的摩擦力和弹性。linearDamping
: 线性阻尼,用于模拟空气阻力等效果,值在 0(无阻尼)到 1(完全阻尼)之间。angularDamping
: 角阻尼,控制刚体旋转的衰减效果。
设置属性如 material
和 linearDamping
十分重要。例如,material
可以结合 CANNON.ContactMaterial
来定义碰撞时的摩擦力和弹性,从而提高模拟的真实感;而 linearDamping
则可以防止物体因惯性不断移动。
创建一个球形刚体,设置其材质、线性阻尼等属性,并添加到物理世界。
// 创建球体形状
const sphereShape = new CANNON.Sphere(1); // 半径为 1
// 定义刚体属性
const sphereMaterial = new CANNON.Material("sphereMaterial");
const sphereBody = new CANNON.Body({
mass: 1, // 质量为 1 kg
shape: sphereShape,
material: sphereMaterial, // 设置材质
linearDamping: 0.2, // 设置线性阻尼
});
// 设置初始位置
sphereBody.position.set(0, 10, 0);
// 将刚体添加到物理世界
world.addBody(sphereBody);
3. 为地板添加物理碰撞属性
虽然我们在 Three.js 场景里画了一个 plane 当地板,但如果不在 Cannon.js 里再加一个对应的平面刚体,它在物理世界中就不存在,球体会无限坠落。 以下代码创建了一个质量为 0 的平面刚体,并设置它的位置和朝向。质量为 0 代表这个物体不会被重力影响,它是一个“静止不动”的碰撞体,用来接住球体。
mass: 0
:物体不会受到重力影响,也就是一个静止的碰撞体。CANNON.Plane()
:一个无限延伸的平面碰撞形状。quaternion.setFromAxisAngle(轴, 角度)
:用于旋转刚体,和 Three.js 中的rotateX()
功能相似,只是这里用四元数来描述旋转。
// 创建一个平面形状
const groundShape = new CANNON.Plane();
const groundBody = new CANNON.Body({
mass: 0, // 质量为 0 的物体不会移动
});
// 将平面形状添加到刚体,并设置方向
groundBody.addShape(groundShape);
// 让平面朝上,所以绕 x 轴旋转 -90°
groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
// 把地板刚体加入物理世界
world.addBody(groundBody);
4. 将物理模拟与 Three.js 渲染同步
更新循环
物理世界中的物体位置每一帧都会变动。我们需要用一个“循环”去做两件事:
- 更新 Cannon.js 物理世界(让物体位置、速度等信息随着时间变化)。
- 将更新后的坐标同步到 Three.js 的网格上。
timeStep 的意义
模拟时,我们通常会传入一个时间步长(timeStep),用来告诉物理引擎“模拟经过了多少时间”。如果 timeStep 不稳定,模拟就会出现抖动、结果不准确等问题。所以我们一般会固定使用一个小的时间步,例如 1/60 秒(也就是 60 FPS)。
CANNON.World.step 方法的参数解释:
timeStep
: 每次更新的时间间隔,通常为 1/60 秒(即每秒 60 帧)。较小的时间步长可以提高模拟的准确性,但会增加计算量。substeps
: 子步数,用于在一个时间步长内细分更新过程,提升模拟稳定性。适合高速度或小物体场景,例如world.step(1/60, undefined, 10)
表示每个时间步长分成 10 个子步。
增加 substeps
有助于解决高速物体穿透问题,但过高的值会导致性能下降。
下面代码展示一个简单的动画循环:
- 每帧都调用
world.step(fixedTimeStep)
来更新物理状态; - 将更新后的刚体位置和四元数赋给对应的 Three.js 网格;
- 最后让 renderer 渲染场景。
这样就完成了“物理 -> 可视化”的同步,球体在 Cannon.js 里滚动,Three.js 就会看到滚动后的样子
const clock = new THREE.Clock(); // 用于获取时间间隔
const fixedTimeStep = 1 / 60; // 1/60 秒
function animate() {
requestAnimationFrame(animate);
// 获取上一帧到当前帧的时间
const delta = clock.getDelta();
// 让物理世界步进
// 参数:固定时间步、子步进次数(可选)
world.step(fixedTimeStep, delta, 3);
// 同步物理世界与 Three.js 世界
sphereMesh.position.copy(sphereBody.position);
sphereMesh.quaternion.copy(sphereBody.quaternion);
// 继续渲染
renderer.render(scene, camera);
}
animate();
clock.getDelta()
:Three.js 提供的方法,返回两帧之间的时间(秒)。world.step(fixedTimeStep, delta, 3)
:Cannon.js 的物理步进。- fixedTimeStep:物理模拟中使用的理想固定时长
- delta:实际帧间隔,可用于在帧率不稳定的情况下进行补偿
- 3:子步进次数,若帧率过低,为了保持模拟稳定,会做更多子步进
sphereMesh.position.copy(sphereBody.position)
:将 Cannon.js 刚体的位置赋给 Three.js 网格。sphereMesh.quaternion.copy(sphereBody.quaternion)
:保持旋转同步,否则会出现可视化与物理不匹配的现象。
Three.js 与 Cannon.js 的结合应用
初始化 Three.js 场景、相机和渲染器
创建一个最简单的 Three.js 场景,包括相机、场景、渲染器,以及一个用于视觉参考的平面。这样我们就拥有了一个基础的三维可视化环境,后续才能把物理引擎的结果在这里“看”出来。
// 创建 Three.js 场景和相机
const scene = new THREE.Scene();
// 透视相机
const camera = new THREE.PerspectiveCamera(
75, // 视野角度(FOV)
window.innerWidth / window.innerHeight, // 纵横比
0.1, // 近截面
1000 // 远截面
);
camera.position.set(0, 5, 15);
// 创建渲染器并添加到页面
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 添加一个简单的环境光,让场景不至于黑漆漆
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// 添加一个平面,作为地板
const planeGeometry = new THREE.PlaneGeometry(50, 50);
const planeMaterial = new THREE.MeshPhongMaterial({ color: 0xaaaaaa });
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.rotateX(-Math.PI / 2); // 让平面水平
scene.add(plane);
创建 Three.js 和 Cannon.js 的同步对象
在物理模拟中,Three.js 负责渲染三维场景,而 Cannon.js 负责物理计算。为了确保视觉效果与物理模拟一致,我们需要将物理引擎计算出的刚体位置和方向同步到 Three.js 的网格对象上。
同步信息的意义:
- 位置同步:确保物体在三维场景中显示的位置与物理世界中的位置一致。
- 旋转同步:将物理世界中的四元数旋转信息同步到 Three.js,使物体的姿态一致。
为了确保物理模拟与三维渲染一致,首先创建一个球体网格对象,然后通过 syncPhysics
函数将物理世界中计算出的刚体位置和旋转信息同步到该网格对象中。这种同步可以确保场景中的视觉效果与物理运算保持一致。
// 创建球体网格
const sphereGeometry = new THREE.SphereGeometry(1, 32, 32); // 半径 1,分段数 32
const sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 }); // 红色材质
const sphereMesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
scene.add(sphereMesh);
// 同步物理世界和 Three.js 的网格
function syncPhysics() {
// 将刚体的位置同步到网格对象
sphereMesh.position.copy(sphereBody.position);
// 将刚体的旋转四元数同步到网格对象
sphereMesh.quaternion.copy(sphereBody.quaternion);
}
4. 动画循环
渲染场景并更新物理世界是将物理模拟与三维视觉效果结合的关键步骤。在这个过程中,world.step
用于更新物理世界的状态,syncPhysics
确保刚体的位置和旋转信息与网格对象同步,renderer.render
则将更新后的三维场景渲染到屏幕上。
// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 动画循环
function animate() {
requestAnimationFrame(animate);
// 更新物理世界
world.step(timeStep);
// 同步物理世界和 Three.js
syncPhysics();
// 渲染场景
renderer.render(scene, camera);
}
animate();
完整示例代码
以下是一个将 Three.js 和 Cannon.js 结合的完整代码示例。效果如下:
- 创建场景、相机、渲染器
使用new THREE.Scene()
、new THREE.PerspectiveCamera()
和new THREE.WebGLRenderer()
搭建三维环境,并通过renderer.setSize()
绑定到浏览器窗口。 - 添加光源、地面网格和球体网格
在场景中调用scene.add()
添加AmbientLight
、PointLight
等光源,使用new THREE.PlaneGeometry()
和new THREE.SphereGeometry()
生成对应的网格(Mesh)可视化地面与球体。 - 初始化物理世界并创建刚体
借助new CANNON.World()
设定gravity
,用new CANNON.Material()
与ContactMaterial()
设置restitution
和friction
,再通过new CANNON.Body()
为平面与球体创建刚体。 - 动画循环计算与渲染同步
在animate()
中调用world.step()
更新物理状态,通过sphereMesh.position.copy(sphereBody.position)
同步位置,最后由renderer.render(scene, camera)
完成渲染。
// 引入必要库
import * as THREE from "three";
import * as CANNON from "cannon";
// 初始化物理世界
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);
// 创建物理材质
const groundMaterial = new CANNON.Material("ground");
const sphereMaterial = new CANNON.Material("sphere");
// 创建接触材质
const contactMaterial = new CANNON.ContactMaterial(groundMaterial, sphereMaterial, {
restitution: 0.7, // 反弹系数
friction: 0.3, // 摩擦系数
});
world.addContactMaterial(contactMaterial);
// 创建地面刚体
const groundShape = new CANNON.Plane();
const groundBody = new CANNON.Body({
mass: 0,
shape: groundShape,
material: groundMaterial, // 应用物理材质
});
groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
world.addBody(groundBody);
// 创建球体刚体
const sphereShape = new CANNON.Sphere(1);
const sphereBody = new CANNON.Body({
mass: 1,
shape: sphereShape,
material: sphereMaterial, // 应用物理材质
});
sphereBody.position.set(0, 10, 0);
world.addBody(sphereBody);
// 初始化 Three.js 场景
const scene = new THREE.Scene();
// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0xffffff, 1);
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
// 创建地面网格
const groundGeometry = new THREE.PlaneGeometry(30, 30);
const groundMeshMaterial = new THREE.MeshStandardMaterial({
color: 0x808080,
side: THREE.DoubleSide,
});
const groundMesh = new THREE.Mesh(groundGeometry, groundMeshMaterial);
groundMesh.rotation.x = -Math.PI / 2;
scene.add(groundMesh);
// 创建球体网格
const sphereGeometry = new THREE.SphereGeometry(1, 32, 32);
const sphereMeshMaterial = new THREE.MeshStandardMaterial({ color: 0x3498db });
const sphereMesh = new THREE.Mesh(sphereGeometry, sphereMeshMaterial);
scene.add(sphereMesh);
// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 时间步长
const timeStep = 1 / 60;
// 动画循环
function animate() {
requestAnimationFrame(animate);
world.step(timeStep);
sphereMesh.position.copy(sphereBody.position);
sphereMesh.quaternion.copy(sphereBody.quaternion);
renderer.render(scene, camera);
}
// 初始化摄像机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 15);
camera.lookAt(0, 0, 0);
scene.add(camera);
animate();
总结
通过本教程,我们知道了以下内容:
- Three.js 场景搭建:从初始化场景到添加光源,为物理模拟提供了直观的三维展示平台。
- Cannon.js 物理世界创建:学会了定义刚体、设置物理属性,并实现物体的碰撞和运动模拟。
- 物理与渲染的同步:理解了如何将物理引擎的计算结果同步到三维模型中,实现了视觉与物理效果的一致性。
代码
github
https://github.com/calmound/threejs-demo/tree/main/cannon (opens in a new tab)
gitee
https://gitee.com/calmound/threejs-demo/tree/main/cannon (opens in a new tab)