Three.js 进阶之旅:物理效果-碰撞和声音 💥

4,415 阅读19分钟

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

摘要

本文内容主要汇总如何在 Three.js 创建的 3D 世界中添加物理效果,使其更加真实。所谓物理效果指的是对象会有重力,它们可以相互碰撞,施加力之后可以移动,而且通过铰链和滑块还可以在移动过程中在对象上施加约束。 通过本文的阅读,你将学习到如何使用 Cannon.jsThree.js 中创建一个 3D 物理世界,并在物理世界更新对象、联系材质、施加外力、处理多个物体中添加物体之间的碰撞效果,通过检测碰撞激烈程度来添加撞击声音等。

效果

本文最终将实现如下所示的效果,点击 DAT.GUI 中创建立方体 🟦 和球体 🟡 的按钮,对应的物体将在拥有重力的三维世界中坠落,物体与地面及物体与物体之间发生碰撞时可以产生与碰撞强度匹配的撞击音频 🔊,点击重置按钮,创建的物体将被清除。

打开以下链接,在线预览效果,大屏访问效果更佳。

本专栏系列代码托管在 Github 仓库【threejs-odessey】后续所有目录也都将在此仓库中更新

🔗 代码仓库地址:git@github.com:dragonir/threejs-ode…

码上掘金

原理

专栏之前的原理和示例学习中,我们已经可以使用光照、阴影、Raycaster 等特性生成一些简单的物理效果,但是如果需要实现像物体张力、摩擦力、拉伸、反弹等物理效果时,我们可以使用一些专业的物理特性开源库来实现。

为了实现物理效果,我们将在 Three.js 中创建一个物理世界,它纯粹是理论性质的,我们无法直接看到它,但是在其中,三维物体将产生掉落、碰撞、摩擦、滑动等物理特性。具体原理是当我们在 Three.js 中创建一个网格模型时,同时会将其添加到物理世界中,在每一帧渲染任何内容之前我们会告诉物理世界如何自行更新,然后我们将获取物理世界中更新的位移和旋转坐标数据,将其应用到 Three.js 三维网格中。

已经有很多功能完备的物理特性库,我们就没必要重复造轮子了。物理特性库可以分为 2D 库和 3D 库,虽然我们是使用 Three.js 开发三维功能,但是有些 2D库 在三维世界中同样是适用的而且它们的性能会更好,如果我们需要开发的物理功能是碰撞类的,则可以使用 2D 库,比如Ouigo Let's play就是一个使用 2D 库开发的优秀示例。下面是一些常用的物理特性库。

对于 3D 物理库,主要有以下三个:

对于 2D 物理库,有很多,下面列出了比较流行的几个:

本文内容及示例将使用 Cannon.js 库,因为它更容易理解和使用,对于其他库,使用原理基本上是一样的,大家感兴趣的话可以自行尝试。

Cannon.js

Cannon.js 是一个 3D 物理引擎,通过为物体赋予真实的物理属性的方式来计算运动、旋转和碰撞检测。Cannon.js 相较于其他常见的物理引擎来说,比较轻量级而且完全通过 JavaScript 来实现。主要有以下特性:

  • 刚体动力学
  • 离散碰撞检测
  • 接触、摩擦和恢复
  • 点到点约束、铰链约束、锁紧装置约束等
  • Gauss-Seidel 约束求解器与孤岛分割算法
  • 碰撞过滤
  • 刚体休眠
  • 实验性 SPH 流体支持
  • 各种形状和碰撞算法

Cannon-es

Cannon.js 库已经多年没有更新了,但是另一库 Cannon-es 克隆了原仓库并致力于长期更新维护新的仓库,可以像下面这样安装并使用,Cannon-es 用法和 Cannon.js 用法是完全一致的。

实现

🚩 本文示例及相关教程翻译并整理自 three.js journey 相关课程。

开始

安装并引入

npm install cannon --save
// 或
npm install --save cannon-es
import CANNON from 'cannon';
// 或
import * as CANNON from 'cannon-es';

初始化场景是一个平面 🟩 和一个球体 🟡,为了更好观察物理特性,已经开启了阴影效果。

我们可以使用 WebGL 创建一个无重力的太空场景,但是为了模拟地球环境 🌏 ,就需要添加重力,在 Cannon.js 中可以通过修改 gravity 属性值来实现,它是一个 Cannon.js Vec3 值,和 Three.js 中的 Vector3 一样,它包含 xyz 属性且拥有一个 set(...) 方法

world.gravity.set(0, -9.82, 0);

我们使用 -9.82 作为重力的 y 值,是因为它是地球的重力系数,如果你想让物理坠落的更慢或者想创建一个火星重力环境 🪐 ,就可以把它改为其他数值。

基础

世界

首先,我们需要创建一个 Cannon.js 世界:

const world = new CANNON.World();

对象

我们在场景中已经创建了一个球体,现在来在 Cannon.js 世界中创建一个球体。为了实现它,我们首先必须创建一个刚体Body,刚体是一种简单的对象,可以坠落和其他刚体产生碰撞。创建刚提前,我们首先需要决定刚体的形状,有很多形状可选,比如 BoxCylinderPlane 等,我们创建一个和 Three.js 中球体相同半径的球状刚体

const sphereShape = new CANNON.Sphere(0.5);

然后,创建一个初始化 mass 质量及 position 位置的 Body 刚体:

const sphereBody = new CANNON.Body({
  mass: 1,
  position: new CANNON.Vec3(0, 3, 0),
  shape: sphereShape
});

最后,我们通过 addBody(...) 方法将创建的刚体添加到世界中:

world.addBody(sphereBody);

此时查看页面可以看到没有任何效果,我们还需要更新 Cannon.js 世界和 Three.js 球体坐标。为更新物理世界world,我们必须使用时间步长step(...)方法。

更新

现在需要实现更新 Cannon.js 世界和 Three.js 场景。此时我们需要使用 step(...) 方法,为了使其生效,必须提供一个固定时间步长、自上次调用函数以来经过的时间、以及每个函数调用可执行的最大固定步骤数作为参数。

step(dt, [timeSinceLastCalled], [maxSubSteps=10])
  • dt:固定时间戳,要使用的固定时间步长
  • [timeSinceLastCalled]:自上次调用函数以来经过的时间
  • [maxSubSteps=10]:每个函数调用可执行的最大固定步骤数

🚩 关于时间步长原理,可查看此文章

在动画函数中,我们希望以 60fps 运行,因此将第一个参数设置为 1/60,这个设置在更高或更低帧率的情况下都能以相同速度运行;对于第二个参数,我们需要计算自上一帧以来经过了多少时间,通过将前一帧的 elapsedTime 减去当前 elapsedTime 来获得,不要直接使用 Clock 类中的 getDelta() 方法,因为无法得到预期的结果还会弄乱内部逻辑;第三个迭代参数,可以随便设置一个值,运行体验是否丝滑并不重要。

const clock = new THREE.Clock();
let oldElapsedTime = 0;

const tick = () => {
  const elapsedTime = clock.getElapsedTime();
  const deltaTime = elapsedTime - oldElapsedTime;
  oldElapsedTime = elapsedTime;
  //更新物理世界
  world.step(1/60,deltaTime,3)
  controls.update()
  renderer.render(scene, camera)
  window.requestAnimationFrame(tick)
}

此时查看页面,看起来仍然没有变化,但实际上物理世界中的球体刚体 sphereBody 正在不断下坠,可以通过如下的打印日志 📜 可以观察到。

console.log(sphereBody.position.y);

现在我们需要使用物理世界的 sphereBody 刚体坐标来更新 Three.js 中的球体,可以使用如下两种方法实现该功能:

// 方法一
sphere.position.x = sphereBody.position.x;
sphere.position.y = sphereBody.position.y;
sphere.position.z = sphereBody.position.z;
// 方法二
sphere.position.copy(sphereBody.position);

🚩 copy方法在 Vector2、Vector3、Euler、Quaternion 甚至 Material、Object3D、Geometry 等类中都是可用的。

此时就能看到小球 🟡 坠落的效果,但是它直接穿过了地面,因为现在仅在 Three.js 场景中添加了地面,而没有在 Cannon.js 物理世界中创建地面的刚体。

现在我们使用平面形状 Plane 来创建地面刚体,地面不应该受到物理世界重力的影响而下沉,它应该是保持静止不动的,我们可以通过如下方法将 mass 设置为 0 来实现:

const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body();
floorBody.mass = 0;
floorBody.addShape(floorShape);
world.addBody(floorBody);

此时你会发现小球 🟡 坠落的方向变了,并不是我们预期的结果,它应该落到地面上。因为物理世界中添加的平面是面向相机 📷 的,我们需要像在 Three.js 中旋转平面一样对它进行旋转。在 Cannon.js 中,我们只能使用四元数 Quaternion 来对刚体进行旋转,可以通过 setFromAxisAngle(...) 方法:

  • 第一个参数是旋转轴
  • 第二个参数是角度
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5);

现在可以看到小球 🟡 从高处下落并且停在地面上,因为地面是静止不动的,因此我们不需要使用 Cannon.js 中的地面来更新 Three.js 中的地面。

联系材质

从上图可以观察到,小球 🟡 坠落到地面后并没有反复弹跳,我们可以通过修改设置 Cannon.js 中的 MaterialContactMaterial添加摩擦和弹跳效果。

一个 Material 仅仅是一个类,你可以用它创建一种材质并命名后将它关联到 Body 刚体上,对于场景中所有的材质,都可以通过此方法进行创建。比如,假设世界中的所有物体都是塑料材质的,此时你只需创建一种材质即可,可以将它命名为 defaultplastic;如果场景中地面和小球是不同材质的,就需要根据它们的类型创建多种材质。下面我们为示例中的两类物体分别创建名为混凝土 concrete 和 塑料 plastic 的材质:

const concreteMaterial = new CANNON.Material('concrete');
const plasticMaterial = new CANNON.Material('plastic');

接下来,我们使用创建的两种材质来创建联系材质 ContactMaterial,它是两种材质的组合,包含对象碰撞时的属性。然后使用 addContactMaterial(...) 方法将它添加到世界中:

const concretePlasticContactMaterial = new CANNON.ContactMaterial(
  concreteMaterial,
  plasticMaterial,
  {
    friction: 0.1,
    restitution: 0.7
  }
)
world.addContactMaterial(concretePlasticContactMaterial)
ContactMaterial (material1, material2 , [options])
  • 前两个参数是材质
  • 第三个参数是碰撞属性对象,包含摩擦系数和恢复系数,两者的默认值均为 0.3

接着我们将创建好的 Material 应用到 Body 上,可以在实例化主体时直接传递材质,也可以在实例化之后使用材质属性传递材质。现在可以看到小球 🟡 下落后在停止之前会返回弹跳多次:

const sphereBody = new CANNON.Body({
  material: plasticMaterial
})
// 或者
const floorBody = new CANNON.Body()
floorBody.material = concreteMaterial

场景中一般会有多种材质 Materials 的物体,为每种两两组合创建 ContactMaterial 会费时费解,为了简化这一操作,我们来使用一种默认材质来替换创建联系材质时的两种材质,并将它应用到所有刚体上:

const defaultMaterial = new CANNON.Material('default');
const defaultContactMaterial = new CANNON.ContactMaterial(
  defaultMaterial,
  defaultMaterial,
  {
    friction: 0.1,
    restitution: 0.7
  }
);
world.addContactMaterial(defaultContactMaterial)l
sphereBody.material = defaultMaterial;
floorBody.material = defaultMaterial;

可以观察到效果是相同的。或者我们直接设置世界的默认联系材质defaultContactMaterial 属性,然后移除 sphereBodyfloorBodymaterial 属性,这样世界中的所有材质就都是相同的默认材质

world.defaultContactMaterial = defaultContactMaterial;

施加外力

对一个刚体 Body 有以下几种施加外力的方法:

  • applyForce(force, worldPoint):从空间中的一个特殊点对刚体施加力(不一定在刚体的表面),比如就像风推动所有物体一样,或微弱但突然的力推向多米诺骨牌,或者像强烈且突然的力把愤怒的小鸟推向城堡一样。
    • force:力的大小 Vec3
    • worldPoint:施加力的世界点 Vec3
  • applyImpulse:类似于 applyForce,但它不是因为增加导致加速度改变,而是直接作用于加速度。
  • applyLocalForce(force, localPoint):与 applyForce 相同,但是坐标系是刚体的局部坐标,即 (0, 0, 0) 将是刚体的中点,从物体的内部施力。
    • force:要应用的力向量 Vec3
    • localPoint:刚体中中要施加力的局部点 Vec3
  • applyLocalImpulse:与 applyImpulse 相同,但是坐标系是刚体的局部坐标,即从物体的内部施力。

现在我们使用 applyLocalForce(...) 来为小球刚体 sphereBody 开始时施加一个小冲击力:

sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0));

可以看到小球 🟡 向右弹跳并滚动。

现在我们使用 applyForce(...) 方法来施加一点风力 🌬 ,因为风是永久性的,因此在更新 World 之前,我们需要将这种力施加到每一帧。要正确应用此力,受力点应该是小球的位置 sphereBody.position

const tick = () => {
  // ...
  sphereBody.applyForce(new CANNON.Vec3(- 0.5, 0, 0), sphereBody.position)
  world.step(1 / 60, deltaTime, 3)
  // ...
}

处理多个物体

对一个或两个物体添加物理效果比较简单,但是为很多个物体都按上述方法添加就会非常复杂,我们需要添加一个自动化处理方法

自动处理函数

首先,移除或注释掉 Cannon.js 世界和 Three.js 中的球体,还有动画函数 tick() 中球体的设置,然后创建一个 createSphere 方法来生成小球:

const createSphere = (radius, position) => {
  // Three.js mesh
  const mesh = new THREE.Mesh(
    new THREE.SphereGeometry(radius, 20, 20),
    new THREE.MeshStandardMaterial({
      metalness: 0.4,
      roughness: 0.4,
      color: 0xfffc00
    })
  );
  mesh.castShadow = true;
  mesh.position.copy(position);
  scene.add(mesh);

  // Cannon.js body
  const shape = new CANNON.Sphere(radius);
  const body = new CANNON.Body({
    mass: 1,
    position: new CANNON.Vec3(0, 3, 0),
    shape: shape,
    material: defaultMaterial
  });
  body.position.copy(position);
  world.addBody(body);
}

接着使用如下方法来创建一个小球 🟡 ,其中 position 参数不必是 Three.js 中的 Vector3 或者 Cannon.js 中的 Vec3,只需使用 x, y ,z 即可:

createSphere(0.5, { x: 0, y: 3, z: 0 });

可以看到地面顶部的创建的小球,但是由于我们移除了将 Cannon.js 世界中小球的 position 拷贝到 Three.js 中的方法,现在的小球暂时没有物理下坠效果

使用一个对象数组

为了使批量创建的小球得到更新,我们使用一个数组 objectsToUpdate 在创建函数中保存它们:

const objectsToUpdate = [];
const createSphere = (radius, position) => {
  // ...
  objectsToUpdate.push({
    mesh,
    body
  });
}

然后在动画方法 tick() 中批量将小球的 body.position 拷贝到 mesh.position

const tick = () => {
  // ...
  for (const object of objectsToUpdate) {
    object.mesh.position.copy(object.body.position);
  }
}

此时,批量创建的小球 🟡 也有物理效果了。

添加Dat.GUI

为了方便调试,我们给页面按如下方式添加 Dat.GUI 调试工具,并添加一个 createSphere 来在场景中创建多个小球:

const gui = new dat.GUI();
const debugObject = {};
debugObject.createSphere = () => {
// 使用随机数创建随机大小和位置的小球
createSphere(
  Math.random() * 0.5,
  {
    x: (Math.random() - 0.5) * 3,
    y: 3,
    z: (Math.random() - 0.5) * 3
  }
)
}
gui.add(debugObject, 'createSphere');

优化

因为 Three.js 网格 Meshgeometrymaterial 都是一样的,我们应该将其移出 createSphere 方法,由于我们使用 radius 来创建几何体的,为了兼容之前的方法,我们可以按如下方式将 SphereGeometry 半径设置为 1,并使用 scale 来调整几何体的大小,得到的结果和上面是一致的,但是性能得以提升

const sphereGeometry = new THREE.SphereGeometry(1, 20, 20);
const sphereMaterial = new THREE.MeshStandardMaterial({
  metalness: 0.4,
  roughness: 0.4,
  color: 0xfffc00
});

const createSphere = (radius, position) => {
  const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
  mesh.castShadow = true;
  mesh.scale.set(radius, radius, radius);
  mesh.position.copy(position);
  scene.add(mesh)
// ...
}

添加立方体

现在我们使用相同的流程添加一个创建立方体 🟦 的方法 createBox,其中传入的参数将是 widthheightdepthposition。需要注意的是,Cannon.js 中创建BoxThree.js 创建 Box 不同,在 Three.js 中,创建几何体BoxBufferGeometry 只需要直接提供立方体的宽高深就行,但是在Cannon.js中,它是根据立方体对角线距离的一半来计算生成形状,因此其宽高深必须乘以0.5

// 创建立方体
const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
const boxMaterial = new THREE.MeshStandardMaterial({
  metalness: 0.4,
  roughness: 0.4,
  color: 0x0091ff
})
const createBox = (width, height, depth, position) => {
  // Three.js 网格
  const mesh = new THREE.Mesh(boxGeometry, boxMaterial);
  mesh.scale.set(width, height, depth);
  mesh.castShadow = true;
  mesh.position.copy(position);
  scene.add(mesh);
  // Cannon.js 刚体
  const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5))
  const body = new CANNON.Body({
    mass: 1,
    position: new CANNON.Vec3(0, 3, 0),
    shape: shape,
    material: defaultMaterial
  })
  body.position.copy(position);
  world.addBody(body);
  // 保存在更新对象数组中
  objectsToUpdate.push({ mesh, body });
}

createBox(1, 1.5, 2, { x: 0, y: 3, z: 0 });

// 添加到DAT.GUI
debugObject.createBox = () => {
  createBox(
    Math.random(),
    Math.random(),
    Math.random(),
    {
      x: (Math.random() - 0.5) * 3,
      y: 3,
      z: (Math.random() - 0.5) * 3
    }
  )
}
gui.add(debugObject, 'createBox');

先移除创建小球的方法,页面运行可以得到如下的结果:

现在可以创建随机的立方体了,但是看起来有点奇怪不太逼真是不是?因为立方体掉下来后没有翻转,原因是 Three.js 中的网格没有像 Cannon.js 中的刚体一样旋转,在球体的示例中我们没有发现是因为无论球体是否旋转都是和原来一样的,而在立方体中不一样。我们可以通过如下将刚体的 quaternion 属性拷贝到网格的 quaternion 属性来实现,就像之前拷贝位置属性 position 一样:

const tick = () => {
  // ...
  for (const object of objectsToUpdate) {
    object.mesh.position.copy(object.body.position);
    object.mesh.quaternion.copy(object.body.quaternion);
  }
  // ...
}

现在立方体 🟦 坠落时的旋转也正常了。

性能优化

Broadphase

测试物体之间的碰撞时,一种方法是检测一个刚体与另外所有其他刚体之间的碰撞,虽然这一操作很容易实现,但是非常耗费性能。此时就需要 Broadphase,它会在测试之前对刚体进行粗略的分类,想象一下,两堆相距很远的立方体,为什么要用一堆立方体来测试另一堆立方体之间的碰撞关系能,它们相距很远,不会发生碰撞,因此就没必要测试来耗费性能。

Cannon.js 中共有 3Broadphase 算法:

  • NaiveBroadphase:测试每个刚体与其他所有刚体之间的碰撞,默认算法。
  • GridBroadphase: 使用四边形栅格覆盖 world,仅针对同一栅格或相邻栅格中的其他刚体进行碰撞测试。
  • SAPBroadphase:扫描剪枝算法,在多个步骤的任意轴上测试刚体。

NaiveBroadphase 是默认检测方法,但是推荐使用 SAPBroadphase 算法,虽然这种算法有时可能会产生检测不会发生碰撞的错误,但是它的检测速度非常快。通过如下方式,简单设置 world.broadphase 属性即可修改碰撞检测算法:

world.broadphase = new CANNON.SAPBroadphase(world);

Sleep 💤

即使我们使用改进的 Broadphase 碰撞检测算法,有可能所有的刚体都会被检测,即使是那些不再发生移动的刚体。此时我们可以使用称为 Sleep 的特性,当刚体的速度逐渐变小不再发生移动,它就会进入睡眠状态,此时就不会对它进行碰撞检测,除非使用代码让其施加一个足够的力再次运动或有其它的刚体击中它。可以通过对 world 设置 allowSleep 属性为 true 来实现:

world.allowSleep = true;

你也可以使用 sleepSpeedLimitsleepTimeLimit 属性对睡眠速度和时间进行详细设置,但是一般不会改变默认值。

事件

可以对刚体的事件进行监听,比如你想在物体发生碰撞时播放呻吟或者在射击游戏中检测是否命中敌人等情况下是非常有用的。你可以在刚体上监听 colidesleepwakeup 等事件。

现在,我们来实现一下当场景中的小球 🟡 和立方体 🟦 互相之间发生碰撞时播放声音 🔊 的功能。首先在 JavaScript 中创建音频,并添加一个方法来播放它。

🚩 有些浏览器比如 Chrome 默认会静音 🔕 除非用户与页面发生交互,例如点击任意区域,所以不要担心首次加载时不播放声音的问题

const hitSound = new Audio('/sounds/hit.mp3');
const playHitSound = () => {
  // 播放时间重置为0,解决多次调用时声音间断问题
  hitSound.currentTime = 0
  hitSound.play()
}

然后在创建立方体方法 createBox 中调用:

const createBox = (width, height, depth, position) => {
  // ...
  body.addEventListener('collide', playHitSound);
  // ...
}

此时,当立方体 🟦 撞击到地面或相互碰撞时可以听到撞击声音 🔊,看起来似乎是正确的,但是当添加多个立方体时,我们会听到很多立方体之间相互撞击的声音是一样的,而现实中的声音应该是根据声音随着立方体之间的撞击程度而不同,撞击程度足够小的话就听不到声音。为了获取撞击的强度,我们需要获取撞击信息,可以通过如下给 playHitSound 方法添加参数 collision 的方式来获取撞击信息:

const playHitSound = (collision) => {
const impactStrength = collision.contact.getImpactVelocityAlongNormal();
  // 只有撞击强度足够大时才播放撞击音频
  if (impactStrength > 1.5) {
    // 为了更加真实,可以给音量添加一些随机性
    hitSound.volume = Math.random();
    hitSound.currentTime = 0;
    hitSound.play();
  }
}

然后在创建球体的方法 createSphere 中同样调用播放撞击音频方法:

const createSphere = (radius, position) => {
  // ...
  body.addEventListener('collide', playHitSound)
  // ...
}

移除物体

当页面上添加过多物体时,我们可以通过在 Dat.GUI 添加一个重置按钮来移除已添加的物体,通过遍历 objectsToUpdate 数组,将每个数组项对应的的 object.body 从 物理世界 world 中移除,将 object.meshThree.js 场景中移除,并清除 collide 碰撞事件的 eventListener

debugObject.reset = () => {
  for (const object of objectsToUpdate) {
    object.body.removeEventListener('collide', playHitSound);
    world.removeBody(object.body);
    scene.remove(object.mesh);
  }
}
gui.add(debugObject, 'reset');

总结

本文中主要包含的知识点包括:

  • Three.js 中添加物理效果基本原理
  • 常用 3D2D 物理物理引擎汇总
  • Cannon.jsCannon-es 安装与引用
  • 物理世界创建、对象更新、联系材质、施加外力、处理多个物体
  • 碰撞事件监听、音频添加
  • 性能优化、物理世界移除物体等

想了解其他前端知识或其他未在本文中详细描述的Web 3D开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍

附录

参考