Skip to main content

添加物理和声音到您的场景

在本章中,我们将介绍 Rapier,另一个可用于扩展 Three.js 基本功能的库。 Rapier 是一个库,允许您将物理引入到您的 3D 场景中。 通过物理,我们是指您的对象受到重力的影响 - 它们可以相互碰撞, 可以通过施加冲量来移动,并且可以通过不同类型的关节在运动中受到约束。 除了物理,我们还将看看 Three.js 如何帮助您向场景添加空间声音。

在本章中,我们将讨论以下主题:

  • 创建一个 Rapier 场景,其中的对象受到重力的影响,并且可以相互碰撞
  • 演示如何更改场景中对象的摩擦和恢复(弹性)
  • 解释 Rapier 支持的各种形状以及如何使用它们
  • 演示如何通过组合简单形状来创建复合形状
  • 演示高度场如何允许您模拟复杂的形状
  • 通过使用关节将对象的运动限制在连接到其他对象
  • 向场景添加声源,其声音音量和方向基于与摄像机的距离
note

可用的物理引擎

有许多不同的开源 JavaScript 物理引擎可用。 其中大多数都不在积极开发中。 然而,Rapier 正在积极开发。 Rapier 使用 Rust 编写, 并且跨编译为 JavaScript,因此您可以在浏览器中使用它。 如果选择使用其中的任何其他库,则本章中的信息仍将有用, 因为大多数库使用与本章演示的相同的方法。 因此,虽然使用的实现和类和函数可能不同, 但本章显示的概念和设置在很大程度上都适用于选择的物理库。

使用 Rapier 创建基本的 Three.js 场景

为了开始,我们创建了一个非常基本的场景,在该场景中,一个立方体下降并撞击一个平面。 您可以通过查看 physics-setup.html 示例来查看此示例:

打开此示例时,您将看到立方体缓慢下降,撞击灰色水平平面的角落并从中弹开。 我们本可以在没有使用物理引擎的情况下完成这个过程,通过更新立方体的位置和旋转, 并编写其应该如何反应的程序。 但这相当难做,因为我们需要准确知道何时碰撞, 碰撞在哪里以及立方体在碰撞后应该如何旋转。 使用 Rapier,我们只需配置物理世界,Rapier 就会精确计算场景中对象的发生情况。

在我们可以配置我们的模型以使用 Rapier 引擎之前, 我们需要在项目中安装 Rapier(我们已经执行了此操作, 因此如果您正在尝试本书提供的示例,则无需执行此操作):

$ yarn add @dimforge/rapier3d

添加后,我们需要将 Rapier 导入到我们的项目中。 这与我们之前看到的常规导入略有不同, 因为 Rapier 需要加载其他 WebAssembly 资源。 这是因为 Rapier 库是用 Rust 语言开发的, 并且编译成 WebAssembly,以便也可以在 Web 上使用。 为了使用 Rapier,我们需要像这样包装我们的脚本:

import * as THREE from 'three'
import { RigidBodyType } from '@dimforge/rapier3d'
// 可能还有其他导入
import('@dimforge/rapier3d').then((RAPIER) => {
  // 代码
})

这最后的导入语句将异步加载 Rapier 库,并在所有数据都已加载和解析时调用回调。 在代码的其余部分,您只需调用 RAPIER 对象以访问 Rapier 特定的功能。

要使用 Rapier 设置场景,我们需要完成以下几个步骤:

  1. 创建 Rapier 世界。 这定义了我们正在模拟的物理世界,并允许我们定义将应用于此世界中对象的重力。
  2. 对于要使用 Rapier 模拟的每个对象,必须定义一个 RigidBodyDesc。 这定义了场景中对象的位置和旋转(以及一些其他属性)。 通过将此描述添加到 World 实例,您将获得一个 RigidBody。
  3. 接下来,您可以通过创建 ColliderDesc 对象告诉 Rapier 要添加的对象的形状。 这将告诉 Rapier 您的对象是立方体、球体、圆锥体或其他形状;它有多大; 它与其他对象相比有多少摩擦;以及它有多弹。 然后,将此描述与先前创建的 RigidBody 结合起来创建 Collider 实例。
  4. 在我们的动画循环中,我们现在可以调用 world.step(), 这使 Rapier 计算其知道的所有 RigidBody 对象的新位置和旋转。
note

在线 Rapier 文档

在本书中,我们将查看 Rapier 的各种属性。 我们不会探讨 Rapier 提供的全部功能,因为那可能填满一本书。 有关 Rapier 的更多信息,请访问:https://rapier.rs/docs/

让我们走过这些步骤,看看如何将其与您已经熟悉的 Three.js 对象结合使用。

设置世界并创建描述

我们需要做的第一件事是创建我们正在模拟的 World:

const gravity = { x: 0.0, y: -9.81, z: 0.0 }
const world = new RAPIER.World(gravity)

这是一段简单直接的代码,我们在其中创建了一个 World, 它在 y 轴上有 -9.81 的重力。 这类似于地球上的重力。

接下来,让我们定义在示例中看到的 Three.js 对象: 一个下落的立方体和它撞击的地板:

const floor = new THREE.Mesh(
  new THREE.BoxGeometry(5, 0.25, 5),
  new THREE.MeshStandardMaterial({color: 0xdddddd})
)
floor.position.set(2.5, 0, 2.5)
const sampleMesh = new THREE.Mesh(
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.MeshNormalMaterial()
)
sampleMesh.position.set(0, 4, 0)
scene.add(floor)
scene.add(sampleMesh)

这里没有什么新的。我们只是定义了两个 THREE.Mesh 对象, 并将 sampleMesh 实例(即立方体)放置在地板表面的角落上。 接下来,我们需要创建 RigidBodyDescColliderDesc 对象, 它们在 Rapier 的世界中表示 THREE.Mesh 对象。 我们将从简单的开始,即地板:

const floorBodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Fixed)
const floorBody = world.createRigidBody(floorBodyDesc)
floorBody.setTranslation({ x: 2.5, y: 0, z: 2.5 })
const floorColliderDesc = RAPIER.ColliderDesc.cuboid
  (2.5, 0.125, 2.5)
world.createCollider(floorColliderDesc, floorBody)

在这里,首先, 我们创建了一个具有单一参数 RigidBodyType.FixedRigidBodyDesc。 固定刚体意味着 Rapier 不允许更改此对象的位置或旋转,因此当其他对象撞击它时, 此对象不会受到重力的影响或移动。 通过调用 world.createRigidBody,我们将其添加到 Rapier 知道的世界中, 以便 Rapier 在进行计算时考虑到这个对象。 然后,我们使用 setTranslationRigidBody 放置在与我们的 Three.js 地板相同的位置。 setTranslation 函数接受一个名为 wakeUp 的可选额外参数。 如果 RigidBody 正在睡眠(如果它长时间未移动), 通过为 wakeUp 属性传递 true, 确保 Rapier 在确定所有它知道的对象的新位置时将 RigidBody 纳入考虑。

我们仍然需要定义此对象的形状,以便 Rapier 在碰撞到另一个对象时能够识别。 为此,我们使用 Rapier.ColliderDesc.cuboid 函数,其中我们指定形状。 对于 cuboid 函数,Rapier 期望形状由半宽度、半高度和半深度定义。 执行的最后一步是将此碰撞器添加到世界并将其连接到地板上。 为此,我们使用 world.createCollider 函数。

此时,我们已经在 Rapier 世界中定义了 floor, 它对应于我们 Three.js 场景中的地板。 现在,我们以相同的方式定义将下落的立方体:

const rigidBodyDesc = new RAPIER.RigidBodyDesc
(RigidBodyType.Dynamic)
    .setTranslation(0, 4, 0)
const rigidBody = world.createRigidBody(rigidBodyDesc)
const rigidBodyColliderDesc = RAPIER.ColliderDesc.cuboid
  (0.5, 0.5, 0.5)
const rigidBodyCollider = world.createCollider
  (rigidBodyColliderDesc, rigidBody)
rigidBodyCollider.setRestitution(1)

这段代码片段与前一个类似 - 我们只是为 Rapier 创建了与我们 Three.js 场景中的对象对应的相关对象。 这里的主要变化是我们使用了 RigidBodyType.Dynamic 实例。 这意味着此对象可以完全由 Rapier 管理。 Rapier 可以更改其位置或其翻译。

note

Rapier 提供的其他刚体类型

除了 DynamicFixed 之外, Rapier 还提供了 KinematicPositionBased 类型, 用于管理对象的位置,或KinematicVelocityBased 类型, 用于自行管理对象的速度。 有关更多信息,请访问:https://rapier.rs/docs/user_guides/javascript/rigid_bodies

渲染场景并模拟世界

剩下的工作是渲染 Three.js 对象,模拟世界, 并确保由 Rapier 管理的对象的位置与 Three.js 网格的位置对应:

const animate = (renderer, scene, camera) => {
  // 基本动画循环
  requestAnimationFrame(() => animate(renderer, scene,
    camera))
  renderer.render(scene, camera)
  world.step()
  // 将位置从 Rapier 复制到 Three.js
  const rigidBodyPosition = rigidBody.translation()
  sampleMesh.position.set(
    rigidBodyPosition.x,
    rigidBodyPosition.y,
    rigidBodyPosition.z)
  // 将旋转从 Rapier 复制到 Three.js
  const rigidBodyRotation = rigidBody.rotation()
  sampleMesh.rotation.setFromQuaternion(
    new THREE.Quaternion(rigidBodyRotation.x,
      rigidBodyRotation.y, rigidBodyRotation.z, rigidBodyRotation.w)
  )
}

在我们的渲染循环中, 我们使用 requestAnimationFrame 确保每一步都渲染 Three.js 元素。 除此之外,我们调用 world.step() 函数来触发 Rapier 中的计算。 这将更新其知道的所有对象的位置和旋转。

接下来,我们需要确保这些新计算的位置也反映在 Three.js 对象上。 为此,我们只需获取 Rapier 世界中对象的当前位置(rigidBody.translation()), 并将 Three.js 网格的位置设置为该函数的结果。 对于旋转,我们通过首先在 rigidBody 上调用 rotation(), 然后将该旋转应用于我们的 Three.js 网格,做相同的操作。 Rapier 使用四元数来定义旋转, 因此我们需要在将该旋转应用于 Three.js 网格之前进行此转换。

这就是您需要做的全部。以下各节中的所有示例都使用相同的方法:

  • 设置 Three.js 场景
  • 在 Rapier 世界中设置类似的对象集
  • 确保在每个步骤之后,Three.js 场景和 Rapier 世界的位置和旋转再次对齐

在下一节中,我们将扩展此示例, 并向您展示当对象在 Rapier 世界中发生碰撞时它们如何相互作用。

在 Rapier 中模拟多米诺骨牌

以下示例是基于我们在“设置世界并创建描述”部分中讨论的相同核心概念构建的。 可以通过打开 dominos.html 示例查看此示例:

在这里,您可以看到我们创建了一个简单的地板,上面放置了许多多米诺骨牌。 如果您仔细观察,可以看到这些多米诺骨牌的第一个实例有点倾斜。 如果我们在右侧的菜单上启用了 y 轴上的重力,您会看到第一个多米诺骨牌倒下, 撞到下一个,依此类推,直到所有多米诺骨牌都倒下:

使用 Rapier 创建这个场景非常简单。 我们只需要创建代表多米诺骨牌的 Three.js 对象, 创建相关的 Rapier RigidBodyCollider 元素, 并确保对 Rapier 对象的更改会反映在 Three.js 对象上。

首先,让我们快速看看如何创建 Three.js 多米诺骨牌:

const createDominos = () => {
    const getPoints = () => {
      const points = []
      const r = 2.8; const cX = 0; const cY = 0
      let circleOffset = 0
      for (let i = 0; i < 1200; i += 6 + circleOffset) {
        circleOffset = 1.5 * (i / 360)
        const x = (r / 1440) * (1440 - i) * Math.cos(i *
          (Math.PI / 180)) + cX
const z = (r / 1440) * (1440 - i) * Math.sin(i *
          (Math.PI / 180)) + cY
        const y = 0
        points.push(new THREE.Vector3(x, y, z))
      }
      return points
    }
    const stones = new Group()
    stones.name = 'dominos'
    const points = getPoints()
    points.forEach((point, index) => {
      const colors = [0x66ff00, 0x6600ff]
      const stoneGeom = new THREE.BoxGeometry
        (0.05, 0.5, 0.2)
      const stone = new THREE.Mesh(
        stoneGeom,
        new THREE.MeshStandardMaterial({color: colors[index
        % colors.length], transparent: true, opacity: 0.8})
      )
      stone.position.copy(point)
      stone.lookAt(new THREE.Vector3(0, 0, 0))
      stones.add(stone)
    })
    return stones
  }

在这段代码片段中,我们使用 getPoints 函数确定多米诺骨牌的位置。 该函数返回一个 THREE.Vector3 对象列表,表示各个石头的位置。 每个石头沿着从中心向外的螺旋放置。 接下来,使用这些点在相同的位置创建了一些 THREE.BoxGeometry 对象。 为了确保多米诺骨牌的方向正确,我们使用 lookAt 函数让它们“朝向”圆心。 所有多米诺骨牌都添加到一个 THREE.Group 对象中, 然后将其添加到 THREE.Scene 实例中(这在代码片段中未显示)。 现在,我们有了我们的一组 THREE.Mesh 对象,我们可以创建相应的 Rapier 对象:

const rapierDomino = (mesh) => {
  const stonePosition = mesh.position
  const stoneRotationQuaternion = new THREE.Quaternion().
    setFromEuler(mesh.rotation)
  const dominoBodyDescription = new RAPIER.RigidBodyDesc
    (RigidBodyType.Dynamic)
    .setTranslation(stonePosition.x, stonePosition.y,
      stonePosition.z)
    .setRotation(stoneRotationQuaternion))
    .setCanSleep(false)
    .setCcdEnabled(false)
  const dominoRigidBody = world.createRigidBody
    (dominoBodyDescription)
  const geometryParameters = mesh.geometry.parameters
  const dominoColliderDesc = RAPIER.ColliderDesc.cuboid(
    geometryParameters.width / 2,
    geometryParameters.height / 2,
    geometryParameters.depth / 2
  )
  const dominoCollider = world.createCollider
    (dominoColliderDesc, dominoRigidBody)
  mesh.userData.rigidBody = dominoRigidBody
  mesh.userData

.collider = dominoCollider
}

此代码与“设置世界并创建描述”部分中的代码相似。 在这里,我们获取传入的 THREE.Mesh 实例的位置和旋转, 并使用该信息创建相关的 Rapier 对象。 为了确保我们可以在渲染循环中访问 dominoColliderdominoRigidBody 实例, 我们将它们添加到传入的 meshuserData 属性中。

最后一步是在渲染循环中更新 THREE.Mesh 对象:

const animate = (renderer, scene, camera) => {
  requestAnimationFrame(() => animate(renderer, scene,
      camera))
  renderer.render(scene, camera)
  world.step()
  const dominosGroup = scene.getObjectByName('dominos')
  dominosGroup.children.forEach((domino) => {
    const dominoRigidBody = domino.userData.rigidBody
    const position = dominoRigidBody.translation()
    const rotation = dominoRigidBody.rotation()
    domino.position.set(position.x, position.y,
        position.z)
    domino.rotation.setFromQuaternion(new
        THREE.Quaternion(rotation.x, rotation.y,
          rotation.z, rotation.w))
  })
  }

在每个循环中,我们告诉 Rapier 计算世界的下一个状态(world.step), 并对于每个多米诺骨牌(它们是名为 dominosTHREE.Group 的子元素), 我们根据存储在该网格的 userdata 信息中的 RigidBody 对象更新 THREE.Mesh 实例的位置和旋转。 在我们继续查看碰撞器提供的最重要的属性之前,我们将简要查看重力如何影响此场景。

当您打开此示例并使用右侧的菜单时,您可以更改世界的重力。 您可以使用此功能来尝试多米诺骨牌对不同重力设置的响应。 例如,以下示例显示了在所有多米诺骨牌都倒下后, 我们增加了 x 轴和 z 轴方向上的重力的情况: 在下一节中,我们将展示设置摩擦力和弹性对 Rapier 对象的影响。

处理弹性和摩擦力

在下一个示例中,我们将更仔细地查看由 Rapier 提供的 Collider 的弹性和摩擦力属性。

弹性是定义物体在与另一个物体碰撞后保留多少能量的属性。 您可以将其视为弹性。网球具有较高的弹性,而砖块具有较低的弹性。

摩擦力定义了一个物体在另一个物体上滑动的难易程度。 具有高摩擦力的物体在在另一个物体上移动时迅速减速, 而具有低摩擦力的物体可以轻松滑动。冰具有低摩擦力,而砂纸具有高摩擦力。

我们可以在构建 RAPIER.ColliderDesc 对象时设置这些属性, 或者在使用 (world.createCollider(...)) 函数创建了碰撞器后进行设置。 在查看代码之前,让我们先看看示例。对于 colliders-properties.html 示例, 您将看到一个大箱子,您可以将形状放入其中:

通过右侧的菜单,您可以放入球形和立方体形状,并设置添加的物体的摩擦力和弹性。 对于第一种情况,我们将添加大量具有高摩擦力的立方体。

您在这里看到的是,即使箱子在其轴周围移动,立方体几乎不会移动。 这是因为立方体本身具有非常高的摩擦力。 如果您尝试将其摩擦力设置为低,您会看到立方体将在箱子底部滑动。

设置摩擦力,您只需执行以下操作:

const rigidBodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
const rigidBody = world.createRigidBody(rigidBodyDesc)
const rigidBodyColliderDesc = RAPIER.ColliderDesc.ball(0.2)
const rigidBodyCollider = world.createCollider
  (rigidBodyColliderDesc, rigidBody)
rigidBodyCollider.setFriction(0.5)

Rapier 还提供了另一种控制摩擦力的方式, 即通过使用 setFrictionCombineRule 函数设置组合规则。 这告诉 Rapier 如何组合碰撞的两个对象的摩擦力(在我们的示例中,是箱子底部和立方体)。 使用 Rapier,您可以将此设置为以下值:

  • CoefficientCombineRule.Average:使用两个系数的平均值
  • CoefficientCombineRule.Min:使用两个系数中的最小值
  • CoefficientCombineRule.Multiply:使用两个系数的乘积
  • CoefficientCombineRule.Max:使用两个系数中的最大值 要探索弹性的工作原理,我们可以使用相同的示例(colliders-properties.html):

在这里,我们增加了球体的弹性。结果是它们现在在箱子中添加或撞击墙壁时会弹跳。 要设置弹性,您使用与设置摩擦力相同的方法:

const rigidBodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
const rigidBody = world.createRigidBody(rigidBodyDesc)
const rigidBodyColliderDesc = RAPIER.ColliderDesc.ball(0.2)
const rigidBodyCollider = world.createCollider
  (rigidBodyColliderDesc, rigidBody)
rigidBodyCollider.setRestitution(0.9)

Rapier 还允许您设置物体相互碰撞时如何计算它们的弹性属性。 您可以使用相同的值,但这次使用 setRestitutionCombineRule 函数。

Collider 还具有其他属性, 可用于微调碰撞器与 Rapier 世界的交互以及物体碰撞时发生的情况。 Rapier 本身为此提供了非常好的文档。 具体针对碰撞器,您可以在此找到文档:https://rapier.rs/docs/user_guides/javascript/colliders#restitution

Rapier 支持的形状

Rapier 提供了许多形状,您可以使用这些形状包装您的几何图形。 在本节中,我们将为您介绍所有可用的 Rapier 形状,并通过示例演示这些网格。 请注意,要使用这些形状, 您需要调用 RAPIER.ColliderDesc.roundCuboidRAPIER.ColliderDesc.ball 等等。

Rapier 提供了3D形状和2D形状。我们只会查看 Rapier 提供的3D形状:

  • ball:球形状,通过设置球体的半径来配置
  • capsule:胶囊形状,由胶囊的半高和半径定义
  • cuboid:简单的立方体形状,通过传入形状的半宽度、半高度和半深度来定义
  • heightfield:高度场是一个形状,其中每个提供的值定义了3D平面的高度
  • cylinder:圆柱形状,由圆柱的半高和半径定义
  • cone:圆锥形状,由圆柱底部的半高和半径定义
  • convexHull:凸包是包围传入点的最小形状
  • convexMesh:凸网格还需要一些点,但假定这些点已经形成凸包, 因此 Rapier 不会进行任何计算来确定较小的形状

除了这些形状外,Rapier 还为其中的一些形状提供了附加的圆角变体: roundCuboidroundCylinderroundConeroundConvexHullroundConvexMesh

我们提供了另一个示例, 您可以在其中查看这些形状的外观以及它们在彼此碰撞时的相互作用。 打开 shapes.html 示例以查看实际效果:

在打开此示例时,您将看到一个空的高度场对象。 通过右侧的菜单,您可以添加不同的形状,它们将互相碰撞以及与高度场实例碰撞。 再次强调,您可以为添加的对象设置特定的弹性和摩擦力值。 由于我们已经在前面的部分解释了如何在 Rapier 中添加形状并确保相应的 Three.js 中的形状得到更新,我们将不在此详细介绍如何从前述列表中创建形状。 有关代码,请查看本章节源代码中的 shapes.js 文件。

在我们转到关节部分之前, 最后一点需要注意——当我们想要描绘简单的形状(例如球体或立方体)时, Rapier 定义这个模型的方式和 Three.js 定义的方式几乎相同。 因此,当这种类型的对象与另一个对象发生碰撞时,它看起来是正确的。 但是,当涉及到更复杂的形状, 例如本例中的高度图实例时, Three.js 在解释和插值这些点到高度图实例时可能存在细微差异, 而 Rapier 则以不同的方式进行。 您可以通过查看 shapes.html 示例并添加许多不同的形状, 然后查看高度场的底部来自行验证:

在这里,您可以看到各种对象的小部分穿过了高度图。 原因是 Rapier 确定高度图的确切形状的方式与 Three.js 不同。 换句话说,Rapier 认为高度图看起来略有不同于 Three.js。 因此,在确定物体碰撞时它们的具体位置时,可能会出现此类小细节。 然而,通过调整尺寸或创建更简单的对象,可以轻松避免这种情况。

到目前为止,我们已经讨论了重力和碰撞。 Rapier 还提供了一种通过使用关节来限制刚体的运动和旋转的方式。 我们将通过使用关节来解释 Rapier 是如何实现这一点的。

使用关节限制物体的运动

到目前为止,我们已经看到了一些基本的物理效果。我们已经看到各种形状如何对重力、摩擦和弹性作出响应,以及这如何影响碰撞。Rapier 还提供了高级结构,允许您限制对象的运动。在Rapier中,这些对象被称为关节。以下列表概述了Rapier中可用的关节:

  • 固定关节:固定关节确保两个物体相对于彼此不移动。这意味着这两个物体之间的距离和旋转将始终相同。
  • 球形关节:球形关节确保两个物体之间的距离保持不变。然而,物体可以在所有三个轴上相互移动。
  • 旋转关节:通过此关节,两个物体之间的距离保持不变,并且它们被允许绕单一轴旋转,例如方向盘,它只能围绕单一轴旋转。
  • 棱柱关节:与旋转关节类似,但这次,物体之间的旋转是固定的,物体可以沿单一轴移动。这导致了一个滑动效果,例如上升的电梯。

在接下来的几节中,我们将探讨这些关节并在示例中看到它们的实际效果。

使用固定关节连接两个物体

最简单的关节之一是固定关节。 使用此关节,您可以连接两个物体,并且它们将保持在创建此关节时指定的相同距离和方向。

这在 fixed-joint.html 示例中展示:

正如您在此示例中所见,这两个立方体一起移动。 这是因为它们通过一个固定关节连接在一起。 为了设置这一点,我们首先必须创建两个RigidBody对象和两个Collider对象, 正如我们在前面的部分中已经看到的。 接下来,我们需要做的是连接这两个物体。 为此,我们首先需要定义JointData

let params = RAPIER.JointData.fixed(
  { x: 0.0, y: 0.0, z: 0.0 },
  { w: 1.0, x: 0.0, y: 0.0, z: 0.0 },
  { x: 2.0, y: 0.0, z: 0.0 },
  { w: 1.0, x: 0.0, y: 0.0, z: 0.0 }
)

这意味着我们将第一个物体连接到位置为 { x: 0.0, y: 0.0, z: 0.0 }(其中心)的第二个物体, 该物体位于{ x: 2.0, y: 0.0, z: 0.0 }处, 其中第一个物体使用四元数{ w: 1.0, x: 0.0, y: 0.0, z: 0.0 }旋转, 第二个物体也以相同的角度旋转 - { w: 1.0, x: 0.0, y: 0.0, z: 0.0 }。 现在我们唯一需要做的是告诉Rapier世界这个关节以及它应用于哪些RigidBody对象:

world.createImpulseJoint(params, rigidBody1, rigidBody2, true)

这里的最后一个属性定义了RigidBody是否应因为这个关节而被唤醒。 当RigidBody没有移动几秒钟时,它可以被放入睡眠状态。 对于关节,通常最好将其设置为true, 因为这确保如果我们附加关节的RigidBody对象之一正在休眠, RigidBody将唤醒。

另一种查看这个关节实际效果的好方法是使用以下参数:

let params = RAPIER.JointData.fixed(
  { x: 0.0, y: 0.0, z: 0.0 },
  { w: 1.0, x: 0.0, y: 0.0, z: 0.0 },
  { x: 2.0, y: 2.0, z: 2.0 },
  { w: 0.3, x: 1, y: 1, z: 1 }
)

这将导致两个立方体在场景中心的地板上停滞不前: 我们列表中的下一个是球形关节。

使用球形关节连接物体

球形关节允许两个物体在彼此周围自由移动,同时保持这些物体之间的相同距离。 这可以用于布娃娃效果,或者如我们在这个示例中所做的, 创建一个链条(sphere-joint.html):

正如您在此示例中所见,我们连接了大量的球体以创建一个球体链。 当这些球体撞击中间的圆柱体时,它们将绕过并缓慢地滑离该圆柱体。 您可以看到尽管这些球体之间的方向会根据它们的碰撞而改变, 但球体之间的绝对距离保持不变。 因此,为了设置此示例,我们创建了许多使用RigidBodyCollider的球体, 类似于前面的示例。

对于每组两个球体,我们还创建了这样的关节:

const createChain = (beads) => {
  for (let i = 1; i < beads.length; i++) {
    const previousBead = beads[i - 1].userData.rigidBody
    const thisBead = beads[i].userData.rigidBody
    const positionPrevious = beads[i - 1].position
    const positionNext = beads[i].position
    const xOffset = Math.abs(positionNext.x – positionPrevious.x)
    const params = RAPIER.JointData.spherical(
      { x: 0, y: 0, z: 0 },
      { x: xOffset, y: 0, z: 0 }
    )
    world.createImpulseJoint(params, thisBead, previousBead, true)
  }
}

您可以看到我们使用RAPIER.JointData.spherical创建了一个关节。 这里的参数定义了第一个物体的位置{ x: 0, y: 0, z: 0 }, 以及第二个物体的相对位置{ x: xOffset, y: 0, z: 0 }。 我们为所有物体都这样做, 并使用world.createImpulseJoint(params, thisBead, previousBead, true)将关节添加到rapier世界中。

结果是,我们得到了一个使用这些球形关节连接的球体链。

下一个关节,旋转关节, 允许我们通过指定一个物体相对于另一个物体允许绕其旋转的单一轴来限制两个物体的运动。

使用旋转关节限制旋转

使用旋转关节,可以轻松创建围绕单一轴旋转的齿轮、车轮和类似风扇的结构。 最简单的解释是查看 revolute-joint.html 示例:

在图12.13中,您可以看到一个紫色的立方体悬浮在绿色条上方。 当您在y方向启用重力时,立方体将掉落到绿色条的顶部。 这个绿色条的中心通过旋转关节连接到中间的固定立方体。 结果是这个绿色条现在会因为紫色立方体的重量而缓慢旋转:

要使旋转关节起作用,我们再次需要两个刚体。 灰色立方体的Rapier部分定义如下:

const bodyDesc = new RAPIER.RigidBodyDesc(RigidBodyType.Fixed)
const body = world.createRigidBody(bodyDesc)
const colliderDesc = RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5)
const collider = world.createCollider(colliderDesc, body)

这意味着这个RigidBody将始终处于相同的位置,无论对其施加多大的力。 绿色条定义如下:

const bodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
  .setCanSleep(false)
  .setTranslation(-1, 0, 0)
  .setAngularDamping(0.1)
const body = world.createRigidBody(bodyDesc)
const colliderDesc = RAPIER.ColliderDesc.cuboid(0.25, 0.05, 2)
const collider = world.createCollider(colliderDesc, body)

这里没有什么特别的,但我们引入了一个新属性 angularDamping。 使用角阻尼,Rapier将逐渐减小RigidBody的旋转速度。 在我们的例子中,这意味着条将在一段时间后缓慢停止旋转。

而我们要投掷的箱子如下:

const bodyDesc = new RAPIER.RigidBodyDesc
  (RigidBodyType.Dynamic)
  .setCanSleep(false)
  .setTranslation(-1, 1, 1)
const body = world.createRigidBody(bodyDesc)
const colliderDesc = RAPIER.ColliderDesc.cuboid(0.1, 0.1, 0.1)
const collider = world.createCollider(colliderDesc, body)

因此,此时我们已经定义了RigidBody。 现在,我们可以连接固定箱子与绿色条:

const params = RAPIER.JointData.revolute(
  { x: 0.0, y: 0, z: 0 },
  { x: 1.0, y: 0, z: 0 },
  { x: 1, y: 0, z: 0 }
)
let joint = world.createImpulseJoint(params, fixedCubeBody, greenBarBody, true)

前两个参数确定连接两个刚体的位置(与固定关节的概念相同)。 最后一个参数定义了两个刚体之间可以绕其旋转的向量。 由于我们的第一个RigidBody是固定的,所以只有绿色条可以旋转。

Rapier支持的最后一种关节是棱柱关节。

使用棱柱关节将物体限制在单一轴上移动

棱柱关节限制了物体在单一轴上的移动。 这在下面的示例(prismatic.html)中演示,其中红色立方体的运动受限于单一轴:

在这个示例中,我们使用前面示例中的旋转关节将一个立方体投掷到绿色条上。 这将导致绿色条绕其y轴在中心旋转并撞击红色立方体。 这个立方体被限制只能沿着单一轴移动,您将看到它沿着该轴移动。

要创建此示例的关节,我们使用了以下代码片段:

const prismaticParams = RAPIER.JointData.prismatic(
  { x: 0.0, y: 0.0, z: 0 },
  { x: 0.0, y: 0.0, z: 3 },
  { x: 1, y: 0, z: 0 }
)
prismaticParams.limits = [-2, 2]
prismaticParams.limitsEnabled = true
world.createImpulseJoint(prismaticParams, fixedCubeBody, redCubeBody, true)

我们再次首先定义了 fixedCubeBody 的位置({ x: 0.0, y: 0.0, z: 0 }), 它定义了我们相对于其移动的对象。 然后,我们定义了我们的立方体的位置 - { x: 0.0, y: 0.0, z: 3 }。 最后,我们定义了对象被允许沿其移动的轴。 在这种情况下,我们定义为 { x: 1, y: 0, z: 0 },这意味着它只能沿其x轴移动。

note

使用关节马达沿其允许的轴移动物体

球形、旋转和棱柱关节还支持一种称为马达的东西。 使用马达,您可以沿着允许的轴移动刚体。 在这些示例中我们没有展示这个,但是通过使用马达, 您可以添加自动移动的齿轮或通过马达使用旋转关节移动车轮制作汽车。 有关马达的更多信息, 请参见Rapier文档的相关部分: 链接

正如我们在“使用Rapier创建基本的Three.js场景”部分中提到的, 我们只是触及了Rapier可能性的表面。 Rapier是一个功能强大的库,具有许多功能,可以进行精细调整, 并且应该支持您可能需要物理引擎的大多数情况。 该库正在积极开发中,网络文档非常出色。

通过本章的示例和在线文档,您应该能够将Rapier整合到您自己的场景中, 即使是未在本章中解释的功能也是如此。

我们主要关注了3D模型以及如何在Three.js中渲染它们。 但是,Three.js还支持3D声音。 在下一节中,我们将向您展示如何在Three.js场景中添加定向声音。

向场景中添加声音源

到目前为止,我们已经讨论了一些相关的主题, 我们已经有了许多元素,可以创建漂亮的场景、游戏和其他3D可视化。 然而,我们还没有展示如何向Three.js场景添加声音。 在本节中,我们将看看两个Three.js对象,允许您向场景添加声音源。 这是特别有趣的,因为这些声音源会根据相机的位置做出响应:

  • 声音源和相机之间的距离决定了声音源的音量
  • 相机左侧和右侧的位置分别确定了左侧扬声器和右侧扬声器的声音音量

最好的解释是亲自看看。在浏览器中打开 audio.html 示例, 您将看到第9章《动画和移动相机》中的场景:

这个示例使用我们在第9章中看到的第一人称控制, 因此您可以使用箭头键与鼠标结合来在场景中移动。 由于浏览器不再自动支持启动音频, 首先点击右侧菜单中的 enableSounds 按钮来打开声音。 这样做后,您将听到附近某处的水声音 - 还能听到远处的一些牛和绵羊声音。

水声是来自您起始位置后面的水车,羊的声音来自右侧的羊群, 而牛的声音则集中在拉犁的两只公牛上。 如果您使用控制来在场景中移动, 您将注意到声音会根据您的位置而改变 - 越靠近羊群, 您将越能听到它们,而在向左移动时,牛的声音会更大。 这被称为定位音频,其中使用音量和方向来确定如何播放声音。

实现这只需要一小段代码。 首先,我们需要定义一个 THREE.AudioListener 对象并将其添加到 THREE.PerspectiveCamera 中:

const listener = new THREE.AudioListener();
camera.add(listener);

接下来,我们需要创建一个 THREE.Mesh(或 THREE.Object3D)实例, 并向该网格添加一个 THREE.PositionalAudio 对象。 这将确定此特定声音的源位置:

const mesh1 = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial({ visible: false }));
mesh1.position.set(-4, -2, 10);
scene.add(mesh1);
const posSound1 = new THREE.PositionalAudio(listener);
const audioLoader = new THREE.AudioLoader();
audioLoader.load('/assets/sounds/water.mp3', function(buffer) {
posSound1.setBuffer(buffer);
posSound1.setRefDistance(1);
posSound1.setRolloffFactor(3);
posSound1.setLoop(true);
mesh1.add(posSound1);
});

从这段代码片段中可以看到, 首先我们创建了一个标准的 THREE.Mesh 实例。 接下来,我们创建了一个 THREE.PositionalAudio 对象, 将其连接到之前创建的 THREE.AudioListener 对象。 最后,我们添加音频并配置一些属性,定义声音的播放方式和行为:

  • setRefDistance:确定声音在离对象多远的地方将减小音量。
  • setLoop:默认情况下,声音只播放一次。将此属性设置为 true,声音将循环播放。
  • setRolloffFactor:确定随着您远离声音源时音量下降的速度。

在内部,Three.js 使用 Web Audio API(http://webaudio.github.io/web-audio-api/)来播放声音并确定正确的音量。 并非所有浏览器都支持此规范。 目前最好的支持来自 Chrome 和 Firefox。

总结

在本章中,我们探讨了如何通过添加物理引擎扩展 Three.js 的基本3D功能。 为此,我们使用了 Rapier 库,它允许您向场景和对象添加重力, 使对象相互作用并在碰撞时反弹,并使用关节限制对象相对于彼此的运动。

除此之外,我们还向您展示了 Three.js 如何支持3D音效。 我们创建了一个场景, 其中使用 THREE.PositionalAudioTHREE.AudioListener 对象添加了位置声音。

尽管我们现在已经涵盖了 Three.js 提供的所有核心功能, 但还有两章专门探讨了一些与 Three.js 结合使用的外部工具和库。 在下一章中,我们将深入了解 Blender,并了解如何使用 Blender 的功能, 例如烘焙阴影、编辑 UV 映射以及在 Blender 和 Three.js 之间交换模型。