第十二章为场景添加物理和声音

在本章中,我们将介绍Rapier,这是另一个可以用来扩展Three.js基本功能的库。Rapier是一个可以将物理引入3D场景的库。我们所说的物理学是指你的物体受到重力的影响——它们可以相互碰撞,可以通过施加脉冲来移动,并且可以通过不同类型的关节来限制它们的移动。除了物理学,我们还将研究Three.js如何帮助您在场景中添加空间声音。

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

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

在你的场景中添加物理学和声音

可用的物理引擎

有许多不同的开源JavaScript物理引擎可用。不过,它们中的大多数并没有得到积极的开发。然而,Rapier 正在积极开发中。Rapier是用Rust编写的,并被交叉编译为JavaScript,因此您可以在浏览器中使用它。如果您选择使用任何其他库,本章中的信息仍然有用,因为大多数库使用的方法与本章中演示的方法相同。因此,尽管使用的实现、类和函数可能不同,但无论您选择什么物理库,本章中显示的概念和设置在很大程度上都是适用的。

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

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

image-20230603202442935

图12.1:简单的 Rapier 物理现象

首先,我们创建了一个非常基本的场景,在这个场景中,一个立方体落下并击中一个平面。你可以通过查看physics-setup.html示例来看到这个示例:当你打开这个示例时,你会看到立方体慢慢下降,撞到灰色水平面的角落,然后从上面反弹。我们本可以在不使用物理引擎的情况下通过更新立方体的位置和旋转并编程它应该如何反应来实现这一点。然而,这很难做到,因为我们需要确切地知道它何时击中,击中哪里,以及立方体在击中后应该如何旋转。有了Rapier,我们只需要配置物理世界,Rapier就会准确地计算场景中的对象会发生什么。

在我们可以配置我们的模型以使用Rapier引擎之前,我们需要在我们的项目中安装Rapier(我们已经完成了这项工作,所以如果您正在试验本书中提供的示例,则不必这样做):

$ yarn add @dimforge/rapier3d

一旦添加,我们需要将Rapier导入到我们的项目中。这与我们看到的正常导入略有不同,因为Rapier需要加载额外的WebAssembly资源。这是必要的,因为Rapier库是用Rust语言开发的,并编译成WebAssembly,这样它也可以在网络上使用。要使用Rapier,我们需要这样包装我们的脚本:

import * as THREE from 'three'
import { RigidBodyType } from '@dimforge/rapier3d'
// maybe other imports
import('@dimforge/rapier3d').then((RAPIER) => {
 // the code
}

最后一条导入语句将异步加载Rapier库,并在加载和解析所有数据后调用回调。在剩下的代码中,您可以直接调用RAPIER对象来访问特定于RAPIER的功能

要与Rapier建立场景,我们需要做以下几件事:

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

Rapier 在线文档

在这本书中,我们将了解 Rapier 的各种特性。我们不会探究 Rapier 提供的全套功能,因为这本身就可以填满一本书。有关 Rapier 的更多信息,请点击此处:https://rapier.rs/docs/.

让我们浏览一下这些步骤,看看如何将其与您已经熟悉的 Three.js 对象结合起来。

设置世界并创建描述

我们需要做的第一件事就是创造一个我们正在模拟的世界:

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

这是一个简单的代码,我们创建一个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实例(立方体)定位在地板曲面的角上方。接下来,我们需要创建RigidBodyDesc和ColliderDesc对象,它们表示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.Fixed创建一个RigidBodyDesc。固定的刚体意味着Rapier不允许改变这个物体的位置或旋转,所以当另一个物体撞击它时,这个物体不会受到重力的影响或四处移动。通过调用world.createRigidBody,我们将其添加到Rapier已知的世界中,以便Rapier在进行计算时可以将此对象考虑在内。然后,我们使用setTranslation将RigidBody放在与Three.js地板相同的位置。setTranslation函数采用一个名为wakeUp的可选额外参数。如果RigidBody正在休眠(如果它长时间没有移动会发生什么),那么为wakeUp属性传递true可以确保Rapier在确定它所知道的所有对象的新位置时将RigidBody考虑在内。

我们仍然需要定义这个物体的形状,以便Rapier能够判断它何时与另一个物体碰撞。为此,我们使用Rapier.ColliderDesc.suboid函数来指定形状。对于长方体函数,Rapier期望形状由一半宽度、一半高度和一半深度定义。最后一步是将这个对撞机添加到世界上,并将其连接到地板上。为此,我们使用world.createCollider函数。

在这一点上,我们已经在Rapier世界中定义了地板,它对应于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 可以改变它的位置或翻转。

由 Rapier 提供的附加刚体类型

除了动态和固定刚体类型外,Rapier还提供了一种基于KinematicPosition-Based类型,用于管理对象的位置,或一种基于运动学速度的类型,用于自己管理对象的速度。有关这方面的更多信息,请点击此处: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)
 )
 }

在我们的渲染循环中,我们有正常的Three.js元素,以确保我们使用requestAnimationFrame渲染每个步骤。除此之外,我们调用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示例来查看该示例:

image-20230603204418521

图12.2-没有重力时站立不动

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

image-20230603204540121

图12.3-多米诺骨牌在第一次倒下后倒下

用Rapier创造这个真的很简单。我们只需要创建代表多米诺骨牌的Three.js对象,创建相关的Rapier RigidBody和Collider元素,并确保对Rapier对象的更改由Three.jss对象反映出来。

首先,让我们快速了解一下我们是如何创建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函数让它们“look”圆心。所有的多米诺骨牌都被添加到一个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对象。为了确保我们可以在渲染循环中访问dominoCollide和domoRigidBody实例,我们将它们添加到传入网格的userData属性中。

这里的最后一步是更新渲染循环中的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),对于每个多米诺骨牌(它们是名为多米诺骨牌的THREE.Group的子多米诺骨牌),我们根据存储在该网格的用户数据信息中的刚体对象更新THREE.Mesh实例的位置和旋转。

在我们继续讨论对撞机提供的最重要的特性之前,我们将快速了解重力如何影响这个场景。打开此示例时,在右侧菜单的帮助下,可以更改世界的重力。你可以用它来实验多米诺骨牌对不同重力设置的反应。例如,以下示例显示了在所有多米诺骨牌倒下后,我们沿x轴和z轴增加重力的情况:

image-20230603205200266

图12.4-不同重力设置下的多米诺骨牌

在下一节中,我们将展示设置摩擦和恢复对 Rapier 物体的效果

使用修复和摩擦力

在下一个例子中,我们将更深入地了解Rapier提供的对撞机的恢复和摩擦特性。

恢复是定义一个物体与另一个物体碰撞后保持多少能量的特性。你可以把它看得有点像弹性。网球的复原率很高,而砖块的复原率较低。

摩擦力定义了一个物体在另一个物体上滑动的容易程度。当高摩擦的物体在另一个物体上移动时,速度会很快减慢,而低摩擦的物体则很容易滑动。像冰这样的东西摩擦力很低,而砂纸的摩擦力很高。

我们可以在构建RAPIER.ColliderDesc对象的过程中设置这些属性,也可以在使用(world.createCollider(…)函数创建了对撞机之后设置这些属性。在查看代码之前,我们将先看一下示例。对于collapsers-properties.html示例,您将看到一个可以将形状放入其中的大框:

image-20230603214030431

图12.5-要放置形状的空框

使用右边的菜单,您可以放置球体和立方体形状,并设置所添加对象的摩擦度和恢复度。对于第一个场景,我们将添加大量具有高摩擦力的立方体。

image-20230603214236079

图12.6-高摩擦立方体盒

你在这里看到的是,即使盒子在轴上移动,立方体也几乎没有移动。这是因为立方体本身有很高的摩擦力。如果你尝试低摩擦,你会看到盒子会在盒子的底部滑动。

要设置摩擦力,你所要做的就是:

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,您可以将其设置为以下值:

  • CoefficientCombineRule.Average:使用两个系数的平均值
  • CoefficientCombineRule.Min:使用两个系数中的最小值
  • CoefficientCombineRule.Multiply:使用两个系数的乘积
  • CoefficientCombineRule.Max:使用两个系数中的最大值

为了探索恢复是如何工作的,我们可以使用这个相同的例子(colliers-properties.html):

image-20230603214628692

图12.7-具有高修复度的球体的盒子

为了探索恢复是如何工作的,我们可以使用相同的示例(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函数

碰撞器具有其他属性,可用于微调碰撞器如何与世界的Rapier视图交互,以及对象碰撞时发生的情况。剑杆本身提供了非常好的文档。特别是对于碰撞器,您可以在此处找到文档:https://rapier.rs/docs/user_guides/javascript/colliders#restitution.

Rapier 支持的形状

Rapier 提供了许多可用于包裹几何体的形状。在本节中,我们将引导您浏览所有可用的 Rapier 形状,并通过示例演示这些网格。请注意,要使用这些形状,您需要调用RAPIER.collideresc.roundCuboid、RAPIER.collideresc.ball等。

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

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

除了这些形状之外,Rapier 还为其中一些形状提供了一个附加的圆形变体:圆形长方体、圆形圆柱体、圆形圆锥、圆形凸面和圆形凸面网格。

我们提供了另一个例子,在这个例子中,您可以看到这些形状是什么样子,以及它们彼此碰撞时如何交互。打开shapes.html示例以查看此操作:

image-20230603215228954

图12.8-heightfield 对象顶部的形状

打开此示例时,将看到一个空的heightfield对象。使用右边的菜单,可以添加不同的形状,它们将相互碰撞,并与heightfieldinstance发生碰撞。同样,可以为要添加的对象设置特定的恢复和摩擦值。由于我们已经在前面的章节中解释了如何在Rapier中添加形状,并确保更新了Three.js中相应的形状,因此这里不详细介绍如何从前面的列表中创建形状。有关代码,请查看本章源代码中的shapes.js文件。

在我们继续讨论关节部分之前的最后一个注意事项是:当我们想要描述简单的形状(例如,球或立方体)时,Rapier定义此模型的方式和Three.js定义此模型的方式基本相同。因此,当这种对象与另一个对象碰撞时,它看起来是正确的。当我们有更复杂的形状时,如本例中的heightmap实例,Three.js如何解释和插值这些点到heightmapinstance,以及Rapier如何执行这些操作,可能会有细微的差异。通过查看shapes.htmlexample,添加许多不同的形状,然后查看heightfield的下面,您可以自己看到这一点:

image-20230603215417933

图12.9-heightfield底部

在这里你可以看到,我们可以看到不同物体的一小部分穿过高度图。原因是Rapier确定heightmap的确切形状的方法与Three.js不同。换句话说,Rapier认为heightmap看起来与Three.js略有不同。因此,当它确定碰撞时特定形状的位置时,可能会产生这样的小细节。但是,通过调整大小或创建更简单的对象,可以轻松避免这种情况。

使用关节限制对象的移动

到目前为止,我们已经看到一些基本的物理学在发挥作用。我们已经看到了各种形状对重力、摩擦力和恢复力的反应,以及这对碰撞的影响。Rapier还提供了高级构造,允许您限制对象的移动。在Rapier中,这些物体被称为关节。以下列表概述了Rapier中可用的接头:

  • Fixed joint 固定关节:固定关节可确保两个物体不会相对移动。这意味着这两个对象之间的距离和旋转将始终相同。
  • Spherical joint 球形关节:球形接头可确保两个物体之间的距离保持不变。然而,这些物体可以在所有三个轴上相互移动。
  • Revolute joint 旋转关节:有了这个关节,两个物体之间的距离保持不变,并且它们可以在一个轴上旋转——例如,方向盘,它只能旋转围绕单个轴。
  • Prismatic joint 棱柱关节:与旋转关节类似,但这次,对象之间的旋转是固定的,对象可以在单个轴上移动。这会导致滑动效应——例如,升降机向上移动。

在下面的部分中,我们将探讨这些关节,并在示例中看到它们的作用。

使用固定关节连接两个对象

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

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

image-20230605164620589

图12.10–连接两个关节的固定关节

正如您在本例中看到的,两个立方体作为一个整体移动。之所以会发生这种情况,是因为它们通过固定的关节连接。要设置它,我们首先必须创建两个 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:00}的第二个对象,其中第一个对象使用{w:1.0,x:00,y:00,z:0.0}的四元数旋转,并且第二个物体旋转相同的量–{w:1.0x:0.0y:0.0z:00}。我们现在唯一需要做的就是告诉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 }
 )

这将导致两个立方体被场景中心的地板卡住:

image-20230605164620589

图12.11 -连接两个立方体的固定接头

我们列表中的下一个是球形关节

使用球形关节连接对象

球形关节允许两个对象围绕彼此自由移动,同时在这些对象之间保持相同的距离。这可以用于布娃娃效果,或者,正如我们在本例中所做的那样,创建一个链(spherejoint.html):

图12.12–通过球形接头连接的多个球体

图12.12–通过球形接头连接的多个球体

正如你在这个例子中看到的,我们已经连接了大量的球体来创建一个球体链。当这些球体在中间撞击圆柱体时,它们会环绕并缓慢地从圆柱体上滑落。您可以看到,虽然这些球体之间的方向根据碰撞而变化,但球体之间的绝对距离保持不变。因此,为了设置这个例子,我们使用 RigidBody 和 Collider 创建了许多球体,类似于前面的例子。对于每组两个球体,我们还创建一个关节,如下所示:

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 world。

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

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

使用旋转关节限制旋转

使用旋转关节,可以很容易地创建围绕单个轴旋转的齿轮、轮子和扇形结构。解释这一点的最简单方法是查看revolute-joint.html示例:

图12.13:立方体在掉落到旋转杆上之前

图12.13:立方体在掉落到旋转杆上之前

在图12.13中,您可以看到一个紫色立方体悬停在绿色条的上方。当在y方向启用重力时,立方体将落在绿色条的顶部。绿色条的中心使用旋转接头连接到中间的固定立方体。结果是,由于紫色立方体的重量,这个绿色条现在将缓慢旋转:

图12.14–杆对一端重量的响应

图12.14–杆对一端重量的响应

为了使旋转关节工作,我们再次需要两个刚体。灰色立方体的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)

这意味着,无论施加在刚体上的任何力如何,刚体都将始终处于同一位置。绿条的定义如下:

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 将慢慢降低刚体的旋转速度。在我们的示例中,这意味着杆将在一段时间后慢慢停止旋转。

我们落下的盒子看起来是这样的:

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 连接的位置(遵循与固定关节相同的思想)。最后一个参数定义了实体可以相对于彼此旋转的矢量。由于我们的第一个 RigidBody 是固定的,所以只有绿色条可以旋转。

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

使用棱柱关节将运动限制在单个轴上

棱柱关节将对象的移动限制在一个轴上。这在以下示例(prismary.html)中得到了演示,其中红色立方体的移动仅限于一个轴:

图12.15-红色立方体仅限于一个轴

图12.15-红色立方体仅限于一个轴

在本例中,我们使用上一例中的旋转关节在绿色条上抛出一个立方体。这将导致绿色条在中心绕其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:00,y:00,z:3}。最后,我们定义了允许对象移动的轴。在这种情况下,我们定义了{x:1,y:0,z:0},这意味着它可以沿着x轴移动。

使用关节电机围绕其允许的轴移动对象

球形、旋转和棱柱形关节也支持一种叫做马达的东西。使用电动机,可以沿其允许的轴移动刚体。我们在这些例子中没有显示这一点,但通过使用电机,您可以添加自动移动的齿轮,或者创建一辆汽车,在电机的帮助下使用旋转接头移动车轮。有关电机的更多信息,请参阅Rapier文档的相关章节:https://rapier.rs/docs/user_guides/javascript/joints#joint-motors。

正如我们在用Rapier创建一个基本的Three.js场景一节中提到的,我们只触及了Rapier可能实现的表面。Rapier是一个广泛的库,具有许多允许微调的功能,并且应该为大多数可能需要物理引擎的情况提供支持。该图书馆正在积极开发中,在线文档非常好。

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

我们主要研究了3D模型以及如何在Three.js中渲染它们。然而,Three.js也提供了对3D声音的支持。在下一节中,我们将向您展示如何向Three.js场景添加定向声音的示例。

将声源添加到场景中

到目前为止,我们已经讨论了几个相关的主题,我们已经准备好了许多素材来创建美丽的场景、游戏和其他3D可视化。然而,我们还没有展示的是如何将声音添加到Three.js场景中。在本节中,我们将看到两个Three.js对象,它们允许您将声音源添加到场景中。这尤其有趣,因为这些声源对相机的位置做出了响应:

  • 声源和摄像头之间的距离决定了声源的音量
  • 摄像头左侧和右侧的位置分别决定左侧扬声器和右侧扬声器的音量

解释这一点的最好方法是看到这一点。在浏览器中打开audio.html示例,您将看到第9章“动画和移动相机”中的场景:

图12.16–带有音频元素的场景

图12.16–带有音频元素的场景

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

水的声音来自你起始位置后面的水车,羊的声音来自右边的羊群,牛的声音集中在两头牛拉犁上。如果你使用控件在场景中移动,你会注意到声音会根据你所处的位置而变化——你离羊越近,你就能更好地听到它们,当你向左移动时,牛的声音会更大。这就是所谓的位置音频,音量和方向用于确定如何播放声音。

实现这一点只需要少量的代码。我们需要做的第一件事是定义一个THREE.AudioListener对象,并将其添加到THREE.PerspectiveCamera中:

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

接下来,我们需要创建一个THREE.Mesh(或THREE.Object3D)实例,并向该网格添加一个THRE.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(posSound3)

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

  • setRefDistance(设置参考距离):这决定了声音音量将减小的距离。
  • setLoop:默认情况下,一个声音播放一次。通过将此属性设置为true,声音将循环。
  • setRolloffFactor:这决定了当你离开声源时音量下降的速度。

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

总结

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

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

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