介绍
物理是WebGL可以添加到项目体验中最酷的功能之一。人们喜欢真实物理感的物体,看到它们碰撞、倒塌、坠落和弹跳,就像我的作品集一样: https: //bruno-simon.com/
有很多方法可以将物理功能添加到您的项目中,这取决于您想要实现的目标。您可以使用一些数学和解决方案(例如Raycaster)来创建自己的物理学
理论
这个想法很简单。我们将创建一个物理世界。这个物理世界是纯理论的。在这个物理世界上,东西会产生掉落、碰撞、摩擦、滑动等等交互。
当我们创建一个 Three.js 网格时,我们还将在物理世界中创建该网格的一个物理版本。如果我们在 Three.js 中创建一个 Box,我们也会在物理世界中创建一个Box框。
然后,在每一帧上,在渲染任何东西之前,我们告诉物理世界进行自我更新;我们获取物理对象的坐标(位置和旋转)并将它们应用于相应的 Three.js 网格。
就是这么简单的原理。这里最困难的是将我们的代码组织成一个合理的结构。这是一个完全和原本文件路径分开的路径部分。每个开发人员都会有自己的习惯,这也取决于你想做什么以及你想把这个物理世界变得多复杂。
首先,我们将简单地创建球体和盒子。
物理功能依赖库
物理功能有多个可用的库。首先,您必须决定是需要 3D 库还是 2D 库。虽然您可能认为它必须是一个 3D 库,因为 Three.js 完全是关于 3D 的,但您可能错了。2D 库通常性能更高,如果您可以总结 2D 碰撞的物理经验,则最好使用 2D 库。
举一个例子是如果你想创建一个类似⚾️弹球游戏。球可以在墙上碰撞和弹跳,您就可以使用 2D 库将所有东西投影到二维平面上。您可以将球设计成物理世界中的圆圈,而墙壁是简单的矩形。事实上,这么做您将无法通过击球底部来使球跳过其他球。
像这样完成的项目的一个很好的例子是Merci Michel的Ouigo Let’s play。他们使用了 2D 物理库,因为每个碰撞和动画都可以在 2D 空间中表示。
3D物理
对于 3D 物理,主要有三个库:
ammo.js
- 网站: http: //schteppe.github.io/ammo.js-demos/
- Git 存储库:https://github.com/kripken/ammo.js/
- 文档:无文档
- Bullet 的直接 JavaScript 端口(用 C++ 编写的物理引擎)
- 体积大有点重
- 社区仍然在更新
cannon.js
- 网站: https: //schteppe.github.io/cannon.js/
- Git 存储库: https: //github.com/schteppe/cannon.js
- 文档: http: //schteppe.github.io/cannon.js/docs/
- 比 Ammo.js 更轻
- 比 Ammo.js 更容易实现
- 主要由一名开发人员维护
- 多年未更新
- 有一个维护的叉子
Oimo.js
- 网站: https: //lo-th.github.io/Oimo.js/
- Git 存储库:https://github.com/lo-th/Oimo.js
- 文档:http://lo-th.github.io/Oimo.js/docs.html
- 比 Ammo.js 更轻
- 比 Ammo.js 更容易实现
- 主要由一名开发人员维护
- 2年没更新了
2D物理
对于 2D 物理,有很多库,但这里是最流行的:
matter.js
- 网站: https: //brm.io/matter-js/
- Git 存储库: https: //github.com/liabru/matter-js
- 文档: https: //brm.io/matter-js/docs/
- 主要由一名开发人员维护
- 还是有点更新
P2.js
- 网站: https: //schteppe.github.io/p2.js/
- Git 存储库: https: //github.com/schteppe/p2.js
- 文档: http: //schteppe.github.io/p2.js/docs/
- 主要由一名开发人员维护(与 Cannon.js 相同)
- 2年没更新了
planck.js
- 网站: https: //piqnt.com/planck.js/
- Git 存储库: https: //github.com/shakiba/planck.js
- 文档: https: //github.com/shakiba/planck.js/tree/master/docs
- 主要由一名开发人员维护
- 现在还在更新
Box2D.js
- 网站:http://kripken.github.io/box2d.js/demo/webgl/box2d.html
- Git 存储库: https: //github.com/kripken/box2d.js/
- 文档:无文档
- 主要由一名开发人员维护(与 Ammo.js 相同)
- 现在还在更新
我们不会在本课中使用 2D 库,但 2D 库代码与 3D 库代码非常相似。主要区别在于您必须更新的轴。
已经有尝试将 Three.js 与Physijs等库结合起来的解决方案。尽管如此,我们不会使用这些已经做好封装的现成解决方案,我们要手动结合物理库来获得更好的学习体验并更好地理解内部运行的逻辑。
虽然 Ammo.js 是最常用的库,尤其是在 Three.js中,正如您在示例中看到的那样,我们将选择 Cannon.js。这个库在我们的项目中实现起来更舒服,也更容易使用。
导入 Cannon.js
要将 Cannon.js
添加到我们的项目中,我们首先需要添加依赖项。
在您的终端的项目文件夹中,运行此命令npm install --save cannon
。
我们现在可以使用经典的 JavaScript 在我们的 JavaScript 中import
导入 Cannon.js :
import CANNON from 'cannon'
我们需要的一切都在CANNON
变量中可用。
设置
我们的启动器由平面上的一个球体组成,并且出于美学原因已经启用了阴影。
基础
世界
首先,我们需要创建一个 Cannon.js世界:
/*** Physics*/
const world = new CANNON.World()
现在我们获得了一个,感觉在没有重力漂浮在太空中的 WebGL 体验感,让我们增加重力脚踏实地。您可以使用Cannon.js Vec3 的 gravity
属性更改重力。
**Cannon.js **Vec3就像 Three.js Vector3一样。它也有**x**
、**y**
和**z**
属性,还有一个**set(...)**
方法:
world.gravity.set(0, - 9.82, 0)
我们把第二个参数值 改为 - 9.82
是因为,- 9.82
它是地球上的重力常数,但如果您想让物体下落得更慢或者如果您的场景发生在火星上,您可以使用其他重力值。
目的
因为我们的场景中已经有了一个球体,所以让我们在 Cannon.js World中也创建一个球体。
为此,我们必须创建一个Body。Body是会掉落的并与其他物体碰撞的。
在我们创建一个Body之前,我们必须决定一个形状。有许多可用的基本形状,如Box、Cylinder、Plane等。我们将选择一个与 Three.js 球体具有相同半径的Sphere :
const sphereShape = new CANNON.Sphere(0.5)
然后我们可以创建我们的body并指定质量和位置:
const sphereBody = new CANNON.Body({mass: 1,position: new CANNON.Vec3(0, 3, 0),shape: sphereShape
})
最后,我们可以将Body 通过addBody(...)
添加到世界中:
world.addBody(sphereBody)
现在页面里什么都没有发生,因为我们仍然需要更新我们的 Cannon.js 世界并相应地更新我们的 Three.js 球体。
更新 Cannon.js 世界和 Three.js 场景
要更新我们的world世界,我们必须使用step(...)
. 该方法底层的代码很难理解,我们不会在本课中对其进行解释,但您可以在本文中找到更多相关信息。
要让它工作,您必须提供一个固定的时间步长、自上一步以来经过了多少时间,以及世界world可以应用多少次迭代来赶上潜在的延迟。
我们不会解释什么是时间步长,但我们希望体验以 60fps 的速度运行,所以我们将使用1 / 60来表示. 别担心,在帧率更高和更低的设备上,体验将以相同的速度运行。
迭代次数由你决定,但体验是否流畅就没那么重要了。
对于三角洲时间,它有点复杂。我们需要计算自上一帧以来经过了多少时间。不要使用Clock类中的getDelta()
方法。你不会得到预期的结果,而且你会搞乱类的内部逻辑。
为了获得正确的增量时间,我们需要从前一帧elapsedTime
减去当前帧elapsedTime
获得:
const clock = new THREE.Clock()
let oldElapsedTime = 0const tick = () =>
{const elapsedTime = clock.getElapsedTime()const deltaTime = elapsedTime - oldElapsedTimeoldElapsedTime = elapsedTime// ...
}
我们终于可以更新我们的世界了:
const tick = () =>
{// ...// Update physicsworld.step(1 / 60, deltaTime, 3)
}
似乎没有任何东西在移动。其实现实是我们的sphereBody
正在无限的堕入深渊,只是因为相机一直跟着物体坠落所以你难以发现,你可以通过在更新world世界后记录它的位置来看到:
world.step(1 / 60, deltaTime, 3)console.log(sphereBody.position.y)
我们现在需要做的是使用sphereBody
坐标更新我们的sphere
。 Three.js 有两种方法可以做到这一点。您可以单独更新每个position
属性:
sphere.position.x = sphereBody.position.xsphere.position.y = sphereBody.position.ysphere.position.z = sphereBody.position.z
或者您可以使用以下方法将所有属性作为一个复制copy(...)
:
sphere.position.copy(sphereBody.position)
copy(...)
在许多类中可用,例如Vector2、Vector3、Euler、Quaternion,甚至类如Material、Object3D、Geometry等。
你最终应该看到你的项目中球体正在自由落体。问题是我们的球体似乎从地板上掉了下来。这是因为该地板存在于 Three.js 场景中,但不存在于 Cannon.js 世界中。
我们可以使用Plane形状简单地添加一个新的Body,但我们不希望我们的地板受到重力影响而掉落。换句话说,我们希望我们的地板是静态的。要使Body静态,请将其设置为:mass = 0
const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body()
floorBody.mass = 0
floorBody.addShape(floorShape)
world.addBody(floorBody)
如您所见,这次我们的做法大不相同。我们创建了一个没有参数的Body ,然后我们设置了这些参数。结果是一样的,我们这样做的唯一原因是为了上课讲解。一件有趣的事情是您可以创建一个由多个Shapes组成的Body。它对于复杂但坚固的物体很有用。
您应该看到球体朝一个方向(可能朝向相机)跳跃。这不是预期的结果。原因是我们的地板plane默认正对着相机。我们需要像在 Three.js 中旋转地板一样旋转它让他转到离开相机的区域。
使用 Cannon.js 进行旋转比使用 Three.js 稍微困难一些,因为您必须使用Quaternion来实现。有多种旋转Body的方法,但必须使用其quaternion
属性。我们将使用setFromAxisAngle(...)
方法旋转body.
第一个参数是一个轴。您可以将其想象成穿过身体的一根线。第二个参数是角度。这是你围绕这条线旋转身体的角度。
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5)
我们将轴设置为负轴(相对于相机的左侧)穿过身体的线,并将x
角度设置为(四分之一圆)。Math.PI * 0.5
您现在应该看到球体下落然后停在地板上。
我们不需要用 Cannon.js 地板更新 Three.js 地板,因为这个对象不会再移动了。
ContactMaterial 关联材料
如您所见,球落地后基本不会弹跳。这是默认行为,我们可以使用Material(不是 Three.js 中的 Material)和ContactMaterial来让它变的富有弹性。
材料只是一个参考。您可以给它起一个名字并将它与一个Body相关联。然后为场景中的每种材质创建一个材质。
如果场景中有多种材质,假设一种木料材质用于地板,一种金属材质用于球。然后,您应该创建各种材质并为它们命名,例如'concrete'
和'plastic'
。
(假设你世界里的一切都是塑料材质制成的。在这种情况下,您只需创建一种材料并将其命名为'default
’即可。)
你可以给他们互相关联到'ground'
和'ball'
中。尽管如此,如果您想对墙壁和立方体等其他对象使用相同的材质,都名为'ground'
即可.
在创建球体和地板之前,创建这两个材质:
const concreteMaterial = new CANNON.Material('concrete')
const plasticMaterial = new CANNON.Material('plastic')
现在我们有了Material,我们必须创建一个ContactMaterial。它是两种材质的组合,用来关联两种材料并模拟💥碰撞发生,包含对象发生碰撞时的属性。
前两个参数是Materials。第三个参数是一个**{}**
包含两个重要属性的对象:**friction**
系数(摩擦系数)和**restitution**
系数(弹跳系数)——两者的默认值为0.3.
创建后,使用以下方法addContactMaterial(...)
将ContactMaterial添加到世界:
const concretePlasticContactMaterial = new CANNON.ContactMaterial(concreteMaterial,plasticMaterial,{friction: 0.1,restitution: 0.7}
)
world.addContactMaterial(concretePlasticContactMaterial)
混凝土和塑料材质之间没有太大的摩擦力,但是如果你让一个橡胶球落在混凝土地板上,你会看到它会反弹的很高。
我们现在可以在身体上使用我们的材质。您可以在实例化Body时或在material
属性之后直接传递材质。我们做这两件事:
const sphereBody = new CANNON.Body({// ...material: plasticMaterial
})// ...const floorBody = new CANNON.Body()
floorBody.material = concreteMaterial
在停止之前,您应该看到球反弹了很多次。我们看不到friction
动作,因为我们的球完全笔直地落在我们的地板上,而且球大部分时间都在空中。
拥有不同的材料并为每种组合创建一个接触材料可能会令人费解。为了简化一切,让我们将两种材质替换为默认材质,并将其用于每个Bodies:
const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(defaultMaterial,defaultMaterial,{friction: 0.1,restitution: 0.7}
)
world.addContactMaterial(defaultContactMaterial)// ...const sphereBody = new CANNON.Body({// ...material: defaultMaterial
})// ...floorBody.material = defaultMaterial
我们应该得到相同的结果。
我们可以更进一步,将我们的材质设置为World的默认材质。为此,只需将defaultContactMaterial
分配给world.defaultContactMaterial
属性:
world.defaultContactMaterial = defaultContactMaterial
我们现在可以删除或注释floorBody
的sphereBody
材料分配。
施力
有很多方法可以对Body施加力:
- applyForce从空间中的指定点(不一定在Body的表面)向Body施加一个力,就像风一直将所有东西推一点点,可以是多米诺骨牌穿导的推力,可以是更大爆发力让愤怒的小鸟飞向敌人的城堡。
- applyImpulse与applyForce类似,但它不是自增导致速度变化的力,而是直接应用于速度。
- applyLocalForce与applyForce相同,但坐标是Body的内部中心坐标(意味着它将是Body0, 0, 0的中心)。
- applyLocalImpulse与applyImpulse相同,但坐标是Body的内部中心坐标。
因为用“力”的方式会造成速度的变化,我们还是不要用“冲量”的方式
让我们在开始时applyLocalForce(...)
对我们的sphereBody
施加一个小的力推动它:
sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))
您可以看到球向右弹跳并滚动。
现在让我们使用applyForce(...)
施加一些风的感觉。因为风是永久性的,所以我们应该在更新World之前将此力应用于每一帧。要正确施加此力,重点应该是**sphereBody.position**
:
const tick = () =>
{// ...// Update physicssphereBody.applyForce(new CANNON.Vec3(- 0.5, 0, 0), sphereBody.position)world.step(1 / 60, deltaTime, 3)// ...
}
处理多个对象
处理一两个对象很容易,但管理几十个对象可能会很麻烦。我们需要稍微优化一下让多个对象也能同时管理。
首先,删除或注释sphere
、 sphereShape
和 sphereBody
的相关代码。
自动化功能
首先,让我们改进我们创建球体的方式,该函数将同时包括 Three.js 和 Cannon.js 创建球体的方法。
作为此函数的参数,我们向函数传递radius
和position
两个值,但你也可以再随意添加其他参数,例如mass
、material
、subdivisions
等。
/*** Utils*/
const createSphere = (radius, position) =>
{
}
现在我们可以创建:
Three.js网格:
const createSphere = (radius, position) =>
{// Three.js meshconst mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 20, 20),new THREE.MeshStandardMaterial({metalness: 0.3,roughness: 0.4,envMap: environmentMapTexture,envMapIntensity: 0.5}))mesh.castShadow = truemesh.position.copy(position)scene.add(mesh)
}
和 Cannon.js主体:
const createSphere = (radius, position) =>
{// ...// Cannon.js bodyconst 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)
}
我们可以删除之前创建的球体并调用createSphere(...)
(在创建 Cannon.js 世界和 Three.js 场景之后)。不要忘记删除tick()
函数中的球体更新代码:
createSphere(0.5, { x: 0, y: 3, z: 0 })
如您所见,位置不必是 Three.js Vector3或 Cannon.js Vec3 两个标准中心点 ,我们可以简单地使用具有x``,y
和z
属性的对象(对我们来说很幸运)。
您应该看到球体漂浮在地板上方,但不幸的是,它不再移动了。这是完全正常的,因为我们刚才注释或者删除了将 Cannon.js Body 的position
属性应用于 Three.js Mesh 的 position
属性的代码。
使用对象数组
为了处理这个问题,我们将创建一个包含所有需要更新的对象的数组。然后我们将对象内新创建的Mesh和Body添加到该数组:
const objectsToUpdate = []const createSphere = (radius, position) =>
{// ...// Save in objects to updateobjectsToUpdate.push({mesh: mesh,body: body})
}
您可以这样优化,重写最后一部分(JavaScript 中变量名相同时无需指定属性):
objectsToUpdate.push({ mesh, body })
我们现在可以在tick()
函数内循环遍历该数组(在我们更新世界之后)并将每个数组body.position
数值复制到mesh.position
属性里:
const tick = () =>
{// ...world.step(1 / 60, deltaTime, 3)for(const object of objectsToUpdate){object.mesh.position.copy(object.body.position)}
}
球体应该再次开始下降。
添加到 Dat.GUI
现在我们可以向我们的 Dat.GUI 添加一个按钮createSphere
。问题是使用该gui.add(...)
方法时第一个参数应该是一个对象,第二个参数应该是一个属性名。不幸的是,我们的createSphere
是个函数,不是一个对象,而且还需要向它传递参数。这种情况经常会发生。一个不错的解决方案是我们再创建一个对象,其唯一目的是将那些丢失的功能作为属性:
const gui = new dat.GUI()
const debugObject = {}
然后在需要时向其添加函数(在createSphere
创建函数之后):
debugObject.createSphere = () =>
{createSphere(0.5, { x: 0, y: 3, z: 0 })
}
最后,我们可以将这个新createSphere
属性添加到 Dat.GUI
:
gui.add(debugObject, 'createSphere')
如果您单击新创建的createSphere
按钮,您应该会看到球体相互重叠。这是由于球体在完全相同的位置弹出。让我们添加一些随机性即可防止球体重叠了:
debugObject.createSphere = () =>
{createSphere(Math.random() * 0.5,{x: (Math.random() - 0.5) * 3,y: 3,z: (Math.random() - 0.5) * 3})
}
像下雨了一样!
为了尽量不要烧毁你的电脑;此代码需要优化。
优化
因为Three.js Mesh的几何体和材质是一样的,我们应该把它们从createSphere
函数中提取出来。问题是我们正在根据半径radius
来创建我们的几何体。一个简单的解决方案是将SphereGeometry的半径radius
固定为1
然后缩放Mesh:
const sphereGeometry = new THREE.SphereGeometry(1, 20, 20)
const sphereMaterial = new THREE.MeshStandardMaterial({metalness: 0.3,roughness: 0.4,envMap: environmentMapTexture,envMapIntensity: 0.5
})
const createSphere = (radius, position) =>
{// Three.js meshconst mesh = new THREE.Mesh(sphereGeometry, sphereMaterial)mesh.castShadow = truemesh.scale.set(radius, radius, radius)mesh.position.copy(position)scene.add(mesh)// ...
}
这样材质都是相同的,你应该得到和之前相同的结果并且大大优化了性能。
添加立方体
现在我们的球体运行良好,让我们用立方体也进行一次相同的实现过程。
要创建一个立方体,我们必须使用一个BoxGeometry和一个Box形状。当心; 参数不一样。BoxGeometry需要一个width
、一个height
和一个depth
。与此同时,一个Box形状需要一个halfExtents
. 它由Vec3 表示,该 Vec3对应于从框的中心开始并连接该框角之一的段:
// Create box
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxMaterial = new THREE.MeshStandardMaterial({metalness: 0.3,roughness: 0.4,envMap: environmentMapTexture,envMapIntensity: 0.5
})
const createBox = (width, height, depth, position) =>
{// Three.js meshconst mesh = new THREE.Mesh(boxGeometry, boxMaterial)mesh.scale.set(width, height, depth)mesh.castShadow = truemesh.position.copy(position)https://www.yuque.com/channel1/wvnr6v/dtdmh6s06vgyxn6p/edit#kKBqSscene.add(mesh)// Cannon.js bodyconst 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)// Save in objectsobjectsToUpdate.push({ mesh, body })
}createBox(1, 1.5, 2, { x: 0, y: 3, z: 0 })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')
不要忘记删除第一个createSphere(...)
调用,否则您将同时在同一位置创建球体和长方体,这可能会变得混乱。
如果你点击Dat.GUI 的createBox
按钮, 您应该会看到一个盒子掉落并突然弹跳平移向地板。它看起来不太正常。
我们忘记了一件重要的事情:我们的网格没有旋转。这里发生的事情应该是盒子在地板上弹跳起来并倒向一边。但我们所能看到的只是盒子跳起来并一直立着移动了起来(很怪异),因为 Three.js网格不像 Cannon.js主体那样可以进行旋转,所以立方体就一直立着运动了。
我们之前没有看到这个问题,因为我们使用的是球体,无论我们是否旋转它们,它们落地后的物理表现看起来都一样(其实他们都不会旋转,这不正确)。
我们可以通过将Body quaternion
复制到Mesh quaternion
来解决这个问题,就像我们复制position
时一样:
const tick = () =>
{// ...for(const object of objectsToUpdate){object.mesh.position.copy(object.body.position)object.mesh.quaternion.copy(object.body.quaternion)}// ...
}
箱子现在应该会在落地后倒下了!您可以根据需要创建球体和盒子。一如既往,尽量不要烧毁你的电脑显卡。
性能 Performance
广相 broadphase
在测试对象之间的碰撞时,一种天真的方法是测试每个Body与其他每个Body 的对比。虽然这很容易做到,但在性能方面代价高昂。
这就是 broadphase 出现的地方。broadphase在测试之前对身体进行粗略的分类。想象一下,两堆箱子彼此远离。你为什么要用一堆的盒子和另一堆的盒子进行测试?它们相距太远,不会发生碰撞。
Cannon.js 中有 3 种 broadphase 算法可用:
- NaiveBroadphase : 测试每一个身体对抗每一个其他身体
- GridBroadphase : Quadrilles the world 并且仅在同一个网格框或邻居的网格框中针对其他主体测试主体。
- SAPBroadphase(broadphase 扫描和修剪 ):在多个步骤中测试任意轴上的主体。
broadphase 默认的算法 是NaiveBroadphase,我建议你切换到SAPBroadphase。使用这个 broadphase 可能会产生物体不发生碰撞的错误行为,但这种情况很少见,并且它涉及到做一些事情,比如非常快速地移动物体时导致不发生碰撞。
要切换到SAPBroadphase,只需在属性中对其进行实例化world.broadphase
,并使用相同的世界作为参数:
world.broadphase = new CANNON.SAPBroadphase(world)
休眠 Sleep
就算我们使用改进的 broadphase 算法,我们所有的身体还是都会被物理测试浪费了性能。那些不再移动的身体,我们可以使用sleep
称为睡眠的功能。
当Body速度变得非常慢时(在您看不到它移动的点),Body可能会休眠并且不会被测试,除非通过代码对其施加足够的力或者如果另一个Body击中它。
要激活此功能,只需将[World](http://schteppe.github.io/cannon.js/docs/classes/World.html).allowSleep
属性设置为true :
world.allowSleep = true
您还可以使用sleepSpeedLimit
和sleepTimeLimit
属性控制Body入睡的范围,但我们这节课不会更改这些。
事件
您可以在Body上收听事件。如果你想做一些事情,比如在物体碰撞时播放声音,或者如果你想知道子弹发射是否碰到了敌人,这会很有用。
您可以收听Body上的事件,例如'colide'
,'sleep'
或'wakeup'
。
当我们的球体和盒子与任何物体发生碰撞时,让我们播放撞击声。首先,在原生 JavaScript 中创建声音并创建一个播放声音的函数。
某些浏览器(如 Chrome)会阻止播放声音,除非用户与页面进行了交互(例如单击任何地方),因此如果您没有听到第一个声音,请不要担心。
/*** Sounds*/
const hitSound = new Audio('/sounds/hit.mp3')const playHitSound = () =>
{hitSound.play()
}
只是播放声音有点牵强,但我们稍后会为该功能添加更多内容。
现在,让我们来听听'collide'
关于Bodies的事件。我们将只关注createBox
函数,并在完成后将其添加到createSphere
函数中。
现在,监听碰撞事件并使用该playHitSound
函数作为回调:
const createBox = (width, height, depth, position) =>
{// ...body.addEventListener('collide', playHitSound)// ...
}
当立方体接触地面或立方体碰撞时,您应该会听到撞击声。如果您使用的是 Chrome,请不要忘记在框落地之前点击页面,因为如果尚未发生用户交互,Chrome 会拒绝播放声音。
声音似乎还不错。不幸的是,当我们添加多个框时,事情变得非常奇怪,那个声音像是犯病了一样一直哒哒哒哒。
第一个问题是,当我们在调用hitSound.play()
播放声音时,没有任何反应,因为它已经在播放了。我们可以通过将声音currentTime
重置为属性来解决这个0问题:
const playHitSound = () =>
{hitSound.currentTime = 0hitSound.play()
}
虽然这在物体掉落开始时比较好,但即使一个立方体轻微接触另一个立方体,我们也会听到太多的撞击声。我们需要知道影响力有多强,如果不够强,我们就什么都不播放才行。
要获得冲击强度,我们首先需要获得有关碰撞的信息。我们可以通过向'collide'
回调(这是我们的playHitSound函数)添加一个参数来做到这一点:
const playHitSound = (collision) =>
{console.log(collision)// ...
}
该collision
变量现在包含大量碰撞信息。可以通过调用属性getImpactVelocityAlongNormal()
上的方法来找到冲击强度contact
:
const playHitSound = (collision) =>
{console.log(collision.contact.getImpactVelocityAlongNormal())// ...
}
如果您查看日志,您应该会看到一个数字。冲击力越强,数值越高。
我们测试impactStrength
该值并仅在足够强的情况下播放声音:
const playHitSound = (collision) =>
{const impactStrength = collision.contact.getImpactVelocityAlongNormal()if(impactStrength > 1.5){hitSound.currentTime = 0hitSound.play()}
}
为了更加真实,我们可以为音量添加一些随机性:
const playHitSound = (collision) =>
{const impactStrength = collision.contact.getImpactVelocityAlongNormal()if(impactStrength > 1.5){hitSound.volume = Math.random()hitSound.currentTime = 0hitSound.play()}
}
如果我们想更加完善这个功能,我们可以有多个略有不同的击打声音。为了防止同时播放太多声音,我们可以添加一个非常短的延迟,使声音在播放一次后无法再次播放。
我们不会在本课中做这些,但请随意尝试。
让我们将createBox
函数中使用的代码复制到createSphere
函数中:
const createSphere = (radius, position) =>
{// ...body.addEventListener('collide', playHitSound)// ...
}
移除物体 Remove things
让我们添加一个reset
按钮。
创建一个reset
函数并将其添加到您的 Dat.GUI 中,就像我们对createBox
和 createSphere
所做的那样:
// Reset
debugObject.reset = () =>
{console.log('reset')
}
gui.add(debugObject, 'reset')
现在,让我们循环遍历objectsToUpdate
数组中的每个对象。然后从object.body
中删除world
和从 object.mesh
中删除 scene
。另外,不要忘记像在本机 JavaScript 中那样删除 eventListener
:
debugObject.reset = () =>
{for(const object of objectsToUpdate){// Remove bodyobject.body.removeEventListener('collide', playHitSound)world.removeBody(object.body)// Remove meshscene.remove(object.mesh)}
}
我们还需要清空objectsToUpdate
数组。在 JS 中有许多清空数组的神奇方法,其中之一是用splice
方法将其内容替换为空:
debugObject.reset = () =>
{// ...objectsToUpdate.splice(0, objectsToUpdate.length)
}
就是这样。您可以单击reset
按钮删除所有内容。
使用 Cannon.js 走得更远
虽然我们介绍了基础知识并且您已经可以做很多事情,但这里有一些需要改进的地方。
约束条件
顾名思义,约束可以在两个主体之间启用约束。我们不会在本课中介绍这些内容,但这是约束列表:
- HingeConstraint:就像门铰链一样。
- DistanceConstraint:强制物体彼此保持一定距离。
- LockConstraint:合并实体,就像它们是一件一样。
- PointToPointConstraint:将主体粘附到特定点。
类、方法、属性和事件
有许多类,每个类都有不同的方法、属性和事件。尝试至少浏览一次所有这些以了解它们的存在。它可能会为您在未来的项目中节省一些时间。
例子
文档并不完美。如果您花一些时间在演示和研究中以了解如何开发,将会有所帮助。许多人可能遇到了您可能遇到的问题。不要犹豫,依靠社区。
多线程workers
运行物理模拟需要时间。执行这项工作的计算机组件是 CPU。当你运行 Three.js、Cannon.js、你的代码逻辑等时,一切都由你 CPU 中的同一个线程完成。如果有太多事情要做(例如物理模拟中的对象太多),该线程会很快过载,从而导致帧速率下降。
正确的解决方案是使用多线程。Workers 允许您将一部分代码放在不同的线程中以分散负载。然后您可以从该代码发送和接收数据。它可以显著提高性能。
问题是代码必须明显分开防止竞争资源。您可以在页面源代码中找到一个很好的简单示例。
Cannon-es
正如我们之前所说,Cannon.js 多年未更新。幸运的是,有些人 fork 了存储库并开始进行更新。多亏了他们,我们才能访问更好且维护得更好的 Cannon.js 版本:
- Git 存储库: https: //github.com/pmndrs/cannon-es
- NPM 页面:https://www.npmjs.com/package/cannon-es
要使用此版本而不是原始版本,请在项目文件夹中打开终端(或关闭服务器),删除之前的 cannon.js 依赖项npm uninstall --save cannon
。
至于cannon-es
,您可以安装最新版本并npm install --save cannon-es
更改您在代码中导入 Cannon.js 的方式:
import * as CANNON from 'cannon-es'
一切都应该像以前一样工作。您可以在Git 存储库页面上查看版本改动。
最新版本应该可以作为直接替代品,但如果出现错误,您可以使用更具体的版本,如(0.20测试过的)通过运行npm install --save cannon-es@0.20
.
Ammo.js
我们使用 Cannon.js 是因为该库易于实施和理解。它最大的竞争对手之一是 Ammo.js。虽然在您的项目中更难使用和实施,但您可能会对以下功能感兴趣:
- 它是 Bullet 的一个移植版,Bullet 是一个众所周知且运行良好的物理引擎,用 C++ 编写。
- 它具有 WebAssembly (wasm) 支持。WebAssembly 是大多数最新浏览器都支持的低级语言。因为它是低级别的,所以它具有更好的性能。
- 它更受欢迎,您可以找到更多 Three.js 的示例。
- 它支持更多功能。
如果您需要最佳性能或在您的项目中具有特定功能,您可能应该选择 Ammo.js 而不是 Cannon.js。
Physijs
Physijs 简化了 Three.js 项目中物理的实现。它使用 Ammo.js 并原生支持 workers。
- 网站: https: //chandlerprall.github.io/Physijs/
- Git 存储库:https://github.com/chandlerprall/Physijs
- 文档: https: //github.com/chandlerprall/Physijs/wiki
您无需创建 Three.js 对象和物理对象,而是同时创建两者即可:
box = new Physijs.BoxMesh(new THREE.CubeGeometry(5, 5, 5),new THREE.MeshBasicMaterial({ color: 0x888888 })
)
scene.add(box)
Physijs 会处理剩下的事情。
虽然它很吸引人,尤其是对于初学者来说,但当您尝试做该库不支持的事情时,事情就会变得复杂。查找错误的来源也可能很麻烦,因为封装过头了。
就像 Ammo.js 一样,花点时间想想用哪个物理库是您项目的最佳解决方案。