前言

在繁忙的业务中,为了缩短设计和开发的周期,我们的 H5 小游戏更多的会采用 2D 的视觉风格,但总是一个风格是很无趣的,所以最近搞了一个 3D 物理游戏的需求,在开发的过程中遇到了不少问题,希望通过这篇文章将关于 Three.js、Cannon.js、模型、工具等基础知识、问题总结分享给大家。

开始 3D 项目之前,首先从选择 3D 框架开始,老牌引擎 Three.js 和微软的 Babylon.js 都不错,针对自己的项目需求选择一款即可,这次我主要针对更熟悉的 Three.js 来讲。

three.js and babylon.js

Three.js 基础概念

使用 Three.js 前,首先要理解以下几个核心概念:

Sence 场景

在 Three.js 中首先需要创建一个三维空间,我们称之为场景。

场景可以想象成是一个容器,里面存放着所有渲染的物体和使用的光源。

1
let scene = new THREE.Scene()

Axes 坐标轴

Three.js 采用的是右手坐标系,拇指、食指、中指分别表示 X、Y、Z 轴的方向。

axes

Camera 摄像机

摄像机就相当于我们的双眼,决定了能够在场景中的所见所得。

Three.js 中提供以下几种摄像机类型,最为常用的是 PerspectiveCamera 透视摄像机,其他了解下即可。

  • ArrayCamera 阵列摄像机
    • 一个 ArrayCamera 会包含多个子摄像机,通过这一组子摄像机渲染出实际效果,适用于 VR 场景。
  • CubeCamera 立方摄像机
    • 创建六个 PerspectiveCamera(透视摄像机),适用于镜面场景。
  • StereoCamera 立体相机
    • 双透视摄像机适用于 3D 影片、视差效果。
OrthographicCamera 正交摄像机

OrthographicCamera(正交摄像机)定义了一个矩形可视区域,物体只有在这个区域内才是可见的,另外物体无论距离摄像机是远或事近,物体都会被渲染成一个大小,所以这种摄像机类型适用于 2.5D 场景(例如斜 45 度游戏)。

OrthographicCamera

PerspectiveCamera 透视摄像机

最为常用的摄像机类型,模拟人眼的视觉,根据物体距离摄像机的距离,近大远小。默认情况下,摄像机的初始位置 X、Y、Z 都为 0,摄像机方向是从正 Z 轴向负 Z 轴看去。通过 NearFar 定义最近和最远的可视距离,Fov 定义可视的角度。

PerspectiveCamera

Mesh 网格

有了场景和摄像头就可以看到 3D 场景中的物体,场景中的我们最为常用的物体称为网格。

网格由两部分组成:几何体和材质

Geometry 几何体

记录了渲染一个 3D 物体所需要的基本数据:Face 面、Vertex 顶点等信息。

geometry

例如下面这个网格是由三角形组成,组成三角形的点称为顶点,组成的三角形称为面。

vertex_face

Material 材质

材质就像是物体的皮肤,决定了几何体的外表。
外表的定义可以让一个物体看起来是否有镜面金属感、暗淡、纯色、或是透明与否等效果。

material

Light 光源

光源相当于在密闭空间里的一盏灯,对于场景是必不可少的

在 Three.js 常用的有这几种光源:

light

AmbientLight 环境光源

属于基础光源,为场景中的所有物体提供一个基础亮度。

DirectionalLight 平行光源

效果类似太阳光,发出的光源都是平行的。

DirectionalLight

HemisphereLight 半球光源

只有圆球的半边会发出光源。

HemisphereLight

PointLight 点光源

一个点向四周发出光源,一般用于灯泡。

PointLight

SpotLight 聚光灯光源

一个圆锥体的灯光。

SpotLight

Shadow 阴影

另外要注意并不是每一种光源都能产生阴影,目前只有三种光源可以:

  • DirectionalLight 平行光源
  • PointLight 点光源
  • SpotLight 聚光灯光源

另外如果要开启模型的阴影的话,模型是由多个 Mesh 组成的,只开启父的 Mesh 的阴影是不行的,还需要遍历父 Mesh 下所有的子 Mesh 为其开启投射阴影 castShadow 和接收投射阴影 receiveShadow

1
2
3
4
5
6
7
// 遍历子 Mesh 开启阴影
object.traverse(function(child) {
if (child instanceof THREE.Mesh) {
child.castShadow = true
child.receiveShadow = true
}
})

glTF 模型格式

前面提到 Three.js 引擎支持的格式非常的多,我们最为常见的格式有 .obj + .mtl + .jpg/.png,但使用这种模型格式存在一个问题,.obj 是静态模型,不支持动画数据存储,无法使用模型的动画,所以我建议使用 glTF 这种模型格式。

glTF 模型格式介绍

传统的 3D 模型格式的设计理念更多是针对本地离线使用,所以这类 3D 模型格式没有针对下载速度或加载速度进行优化,文件大小往往会非常的大,随着 Web 端的兴起,对文件大小更为敏感的今天,我们该尝试别的模型格式了。

glTF 是由 Khronos Group 开发的 3D 模型文件格式,该格式的特点是最大程度的减少了 3D 模型文件的大小,提高了传输、加载以及解析 3D 模型文件的效率,并且它可扩展,可互操作。

第一版 glTF 1.0 于 2015 年 10 月 19 日发布,2017 年 6 月 5 日的 Web 3D 2017 大会发布了最终版本 glTF 2.0。

glTF 模型格式文件组成

gltf_assets

模型文件 .gltf

包含场景中节点层次结构、摄像机、网格、材质以及动画等描述信息。

二进制文件 .bin

包含几何、动画的数据以及其他基于缓冲区的数据,.bin 文件可以直接加载到 GPU 的缓冲区中从而不需要额外的解析,因此能够高效传输和快速加载。

材质贴图文件 .png/.jpg

3D 模型做凹凸贴图或普通贴图上所使用到文件。

glTF 模型格式导出

官方在 .gltf 格式导出上提供了多种建模软件的导出插件,比如有:

  • 3DS Max Exporter
  • Maya Exporter
  • Blender glTF 2.0 Exporter

正巧我们常用的 C4D 建模软件官方没有提供 C4D 的导出插件,所以我们使用 C4D 导出后再导入 Blender,通过 Blender 作为中转站导出 glTF 格式文件。

exporter

但由于两个建模软件之间的材质并不能相通,导出后的模型文件材质效果表现不佳,这是因为 Blender 有自己的一套材质流程系统,例如有 glTF Metallic RoughnessglTF Specular Glossiness,需在此基础之上重新贴材质后导出解决。

另外注意的一点 Blender 的坐标系与 Three.js 是不同的,Blender 会将 Z 和 Y 对调位置,在导出时要选择 Convert Z up to Y up 进行对调。

convert

Three.js 使用 glTF 模型

Three.js 中使用 glTF 格式需额外引入 GLTFLoader.js 加载器。

1
2
3
4
5
6
var gltfLoader = new THREE.gltfLoader()
gltfLoader.load('./assets/box.gltf', function(sence) {
var object = scene.gltf // 模型对象
scene.add(object) // 将模型添加到场景中
})

glTF 模型动画

Animation Clip 动画片段

前面提到 glTF 模型格式支持动画,模型动画可以使用 Blender 建模软件制作,通过 Blender 提供的时间轴编辑变形动画或者骨骼动画,每个动画可以编辑为一个 Action 动作,导出后使用 GLTFLoader 加载到 Three.js 中,可以拿到一个 animations 数组,animations 里包含了模型的每个动画 Action 动作。

animation_clip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let gltfLoader = new THREE.gltfLoader()
let mixer = null
gltfLoader.load('./assets/box.gltf', function(sence) {
let object = scene.gltf
let animations = sence.animations // 动画数据
if (animations && animations.length) {
mixer = new THREE.AnimationMixer(object) // 对动画进行控制
for (let i = 0; i < animations.length; i++) {
mixer.clipAction(animations[i]).play() // 播放所有动画
}
}
scene.add(object)
})
function update() {
let delta = clock.getDelta(mixer)
mixer.update(delta) // 更新动画片段
}
Tween 动画

对模型实现淡入淡出、缩放、位移、旋转等动画推荐使用 GSAP 来实现更为简便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let tween = new TimelineMax()
tween
.to(box.scale, 1, { // 从 1 缩放至 2,花费 1 秒
x: 2,
y: 2,
z: 2,
ease: Power0.easeInOut, // 速度曲线
onStart: function() {
// 监听动画开始
},
onUpdate: function() {
// 监听动画过程
},
onComplete: function() {
// 监听动画结束
}
})
.to(box.position, 1, { // 缩放结束后,位移 x 至 10,花费 1 秒
x: 10,
y: 0,
z: 0
})

Draco 3D 模型压缩工具

Draco 是一个用于压缩、解压缩 3D 几何网格和点云的开源库,为改善 3D 图形存储和传输而设计。

使用该工具可以对 glTF 格式进一步的压缩,会将 glTF 格式转为 .glb 格式,并且 .bin 压缩效果拔群,但是在 Three.js 中使用 .glb 格式需要引入额外的解析库,解析库文件包括 draco_decoder.js(791KB)、draco_decoder.wasm(323 KB)、draco_wasm_wrapper.js(64.3 KB)。所以更推荐当模型文件数量多,且文件较大时使用,否则得不偿失。

压缩使用 glTF Pipeline 工具,需要将三个种类的文件放在一起,执行命令行进行转换。

1
2
$ npm install -g gltf-pipeline // 安装 gltf-pipeline 工具
$ gltf-pipeline -i model.gltf -o model.glb // 指定某个 .gltf 文件转为 .glb 格式

Three.js 使用 .glb 格式引入 Draco 解码库

1
2
3
4
5
6
7
8
9
10
11
// 实例化 loader
let loader = new THREE.GLTFLoader()
// Draco 解码库
THREE.DRACOLoader.setDecoderPath('/examples/js/libs/draco')
loader.setDRACOLoader(new THREE.DRACOLoader())
// 加载 glTF 模型
loader.load('models/gltf/box.gltf', function(gltf) {
scene.add(gltf.scene)
})

Cannon.js 3D 物理引擎

目前在 Github 上搜索到的 3D 物理引擎库有 Cannon.js、Oimo.js、Ammo.js、Energy.js、Physijs 等等,大部分都已许久没有更新迭代了(长达好几年),项目的 Star 数量和 Issues 数量也不多,我们该如何选择?

Cannon.js、Oimo.js 和 Energy.js 作为 Babylon.js 的内置物理引擎,我们试着从这三个下手。

Energy.js:使用 C++ 编写转 JavaScript 的 3D 物理引擎,源码不可读,目前 Github 比较冷清。

Oimo.js:一款轻量级的 3D 物理引擎,文件大小 153 KB。

Cannon.js:完全使用 JavaScript 编写的优秀 3D 物理引擎,包含简单的碰撞检测、各种形状的摩擦力、弹力、约束等功能。

从综合性来看,我更偏向于 Cannon.js ,所以下面主要讲讲 Cannon.js。

Cannon.js 的特性

以下是 Cannon.js 的特性,基本可以满足大部分的 3D 物理开发场景。

cannon

使用 Cannon.js

我们以官方的一个平面加球刚体的例子来快速上手 Cannon.js。在线例子

cannon_demo

1、初始化物理世界

使用 Cannon.js 前需要创建 CANNON.World 对象,CANNON.World 对象是负责管理对象和模拟物理的中心。

创建完 CANNON.World 对象后,接着设置物理世界的重力,这里设置了负 Y 轴为 10 m/s²。

1
2
let world = new CANNON.World()
world.gravity.set(0, -10, 0)

Cannon.js 提供了 Broadphase、NaiveBroadphase 两种碰撞检测阶段,默认是 NaiveBroadphase。

1
world.broadphase = new CANNON.NaiveBroadphase()
2、创建动态球体

创建 Body 分三个步骤:

  • 创建形状
  • 为形状添加刚体
  • 将刚体添加到世界
1
2
3
4
5
6
7
8
let sphereShape = new CANNON.Sphere(1) // Step 1
let sphereBody = new CANNON.Body({ // Step 2
mass: 5,
position: new CANNON.Vec3(0, 10, 0),
shape: sphereShape
})
world.add(sphereBody) // Step 3

第一步创建了半径为 1 的球形,第二步创建球的刚体,如果刚体的 mass 属性设置为 0,刚体则会处于静止状态,静止的物体不会和其他静止的物体发生碰撞,我们这里为球的刚体设置了 5kg,球会处于动态的状态,会受的重力的影响而移动,会与其他物体发生碰撞。

3、创建静态平面和动态球体
1
2
3
4
5
6
7
8
9
10
11
// 平面 Body
let groundShape = new CANNON.Plane()
let groundBody = new CANNON.Body({
mass: 0,
shape: groundShape
})
// setFromAxisAngle 旋转 X 轴 -90 度
groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -1.5707963267948966)
world.add(groundBody)

创建平面形状,接着是刚体,这里设置了平面刚体的 mass 为 0,保证刚体处于静止状态。默认情况下平面的方向是朝向 Z 方向的(竖立着),可以通过 Body.quaternion.setFromAxisAngle 对平面进行旋转。

4、创建平面和球的网格

前面创建的刚体在场景中并没有实际的视觉效果,这一步创建平面、球的网格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 平面网格
let groundGeometry = new THREE.PlaneGeometry(20, 20, 32)
let groundMaterial = new THREE.MeshStandardMaterial({
color: 0x7f7f7f,
side: THREE.DoubleSide
})
let ground = new THREE.Mesh(groundGeometry, groundMaterial)
scene.add(ground)
// 球网格
let sphereGeometry = new THREE.SphereGeometry(1, 32, 32)
let sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xffff00 })
let sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
scene.add(sphere)
5、模拟世界

接着我们为物理世界开启持续更新,并且将创建的球刚体与球网格关联起来。

1
2
3
4
5
6
7
8
9
function update() {
requestAnimationFrame(update)
world.step(1 / 60)
if (sphere) {
sphere.position.copy(sphereBody.position)
sphere.quaternion.copy(sphereBody.quaternion)
}
}

通过这几步,一个简单的物理场景就完成了,另外更多官方例子可以点击这里,可以查看到 Cannon.js 各个约束、摩擦力、模拟汽车等特性的例子。

其他:

1、自定义物理材质需关联

还是上面的例子,现在场景中刚体的物理特性都为默认的,我希望球的恢复系数高一点,即掉落时弹跳的更高。首先需要通过 CANNON.Material 实例物理材质,刚体使用该物理材质,最后通过 CANNON.ContactMaterial 来定义两个刚体相遇后会发生什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 平面
let ground_cm = new CANNON.Material() // Step 1 : 实例 CANNON.Material
let groundBody = new CANNON.Body({
...
material: groundMaterial // Step 2 : 使用该物理材质
...
})
// 球
let sphere_cm = new CANNON.Material()
let sphereBody = new CANNON.Body({
...
material: sphere_cm
...
})
let sphere_ground = new CANNON.ContactMaterial(ground_cm, sphere_cm, { // Step 3 : 定义两个刚体相遇后会发生什么
friction: 1,
restitution: 0.4
})
world.addContactMaterial(sphere_ground) // Step 4 : 添加到世界中
2、刚体添加位移动画时需取消速度值

比如我使用 GSAP 库对某个刚体进行 Y 轴向上移动,在 update 阶段需要将刚体的重力加速度设置为 0,否则动画结束后刚体会出现向下砸的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
let tween = new TimelineMax()
tween.to(boxBody.position, 2, {
x: 0,
y: 10,
z: 0,
update: function() {
// 归 0 设置
boxBody.velocity.setZero()
boxBody.initVelocity.setZero()
boxBody.angularVelocity.setZero()
boxBody.initAngularVelocity.setZero()
}
})
3、只检测碰撞,不发生物理效果

允许只检测是否碰撞,实际不发生物理效果,需要为刚体添加以下属性:

1
boxBody.collisionResponse = false
4、缩放刚体

如果刚体需要缩放,则需要为刚体添加此属性,来更新刚体大小。

1
2
3
4
5
6
boxBody.updateMassProperties()
let tween = new TimelineMax()
tween.to(sphereBody.shapes[0], 2, {
radius: 0.2 // 缩放至 0.2
})

点击交互

在 3D 的世界中不能像我们在 DOM 中为一个节点绑定点击事件那么容易,在 Three.js 中提供了 THREE.Raycaster 方法处理点击交互,使用鼠标或者手指点击屏幕时,会将二维坐标进行转换,发射一条射线判断与哪个物体发生了碰撞,由此得知点击了哪个物体。点击这里官方例子

raycaster

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let raycaster = new THREE.Raycaster()
let mouse = new THREE.Vector2()
function onTouchEnd(ev) {
// 点击获取屏幕坐标
var event = ev.changedTouches[0]
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
raycaster.setFromCamera(mouse, camera)
let intersects = raycaster.intersectObjects(scene, true)
for (let i = 0; i < intersects.length; i++) {
console.log(intersects[i]) // 与射线发生碰撞的物体
}
}

性能方面

模型精细程度

在 Web 端由于性能的限制,在开发过程中要尽量避免做一些损耗性能较大的事情。

首先是模型的精细程度,在保证效果的前提下,尽量降低模型面的数量,也就是说采用低模模型,一些模型的凹凸褶皱感也可以通过凹凸贴图的方式去实现,越是复杂的模型在实时渲染的过程中就越占用手机性能。

光源与阴影

另外一方面光源、阴影也是占性能,尤其是阴影。光源一般会使用平行光或者聚光灯,这种光源照射在物体上更为真实,使用半球光会稍微提升帧数,但效果略差些,阴影效果前面提到过要遍历每一个子 Mesh 接收产生阴影 castShadow 和接收阴影 receiveShadow,这相当耗费性能,开启后对阴影的精细程度以及阴影类型进行参数优化,在 Android 系统性能不太好,iOS 系统基本能保证流畅运行,所以建议根据设备系统优化。

1
2
var n = navigator.userAgent
if (/iPad|iPhone|iPod/.test(n) && !window.MSStream) { } // 针对 iOS 系统使用阴影
抗锯齿与像素比

抗锯齿是让模型的边缘效果更加圆滑不粗糙,也会占用一些性能,默认是关闭的,视情况开启。

1
renderer.antialias = true // 开启抗锯齿

另外像素比 setPixelRatio,移动端由于 Retina 屏的缘故,一般会设置为 2,所以使用 window.devicePixelRatio 获取实际设备像素比动态设置的话,部分大屏手机的像素比有 3 的情况,所有会因为像素比过高造成性能问题。

1
2
renderer.setPixelRatio(2) // 推荐
renderer.setPixelRatio(window.devicePixelRatio) // 不推荐

工具推荐

最后推荐一些在开发过程中常用的工具:

OrbitControls 轨道控制器

OrbitControls 是用于调试 Camera 的方法,实例化后可以通过鼠标拖拽来旋转 Camera 镜头的角度,鼠标滚轮可以控制 Camera 镜头的远近距离,旋转和远近都会基于场景的中心点,在调试预览则会轻松许多。

1
new THREE.OrbitControls(camera, renderer.domElement)

OrbitControls

glTF Viewer 模型快速预览工具

在设计师建模完成导出后,设计师并不知道在 Three.js 最终会呈现一个什么效果或者开发者也想快速的查看模型是否存在问题,glTF 官方贴心的提供了一款快速预览的工具,提供了两个版本:Web 版本Desktop 版本

.gltf.bin.jpg/.png 文件拖拽到工具中,可以调试预览到模型的动画、变形目标、背景、线框模式、自动旋转、光源等功能。

gltf_viewer

Helper 相关调试模式
Camera Helper 摄像机调试模式

开启 Camera Helper 调试模式后,可以直观的看到 Camera 的 FovNeraFar 的参数效果。

camera_helper

1
2
3
4
let camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000)
let helper = new THREE.CameraHelper(camera)
scene.add(helper)
Light Helper 光源调试模式

聚光灯开启 Light Helper 调试模式后,可以直观的看到 distanceangle 的参数效果。

1
2
3
4
let light = new THREE.DirectionalLight(0xffffff)
let helper = new THREE.DirectionalLightHelper(0xffffff)
scene.add(helper)

light_helper

AxesHelper 坐标轴调试模式

AxesHelper 是在场景的中心点,添加一个坐标轴(红色:X 轴、绿色:Y 轴、蓝色:Z 轴),方便辨别方向。

axes_helper

1
2
let axesHelper = new THREE.AxesHelper(10)
scene.add(axesHelper)
Cannon.js 3D 物理引擎调试模式

Cannon.js 3D 物理引擎提供的调试模式需引入 Debug renderer for Three.js,可以将创建的物理盒子、球、平面等显示线框,便于在使用过程中实时查看效果。

before_after

1
2
3
4
5
6
let cannonDebugRenderer = new THREE.CannonDebugRenderer(scene, world)
function render() {
requestAnimationFrame(render)
cannonDebugRenderer.update() // Update the debug renderer
}
dat.GUI 图形用户界面调试工具

在开发过程中,常常需要对参数变量进行微调,针对这个 Three.js 提供了 dat.GUI,dat.GUI 是一个轻量级的图形用户界面调试工具,使用后在右上角会出现一个 GUI 可视化参数配置区域,通过修改数值来实时查看结果。

dat_gui

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let opts = {
x: 0,
y: 0,
scale: 1
}
let gui = new dat.GUI()
gui.add(opts, 'x', -3, 3)
gui.add(opts, 'y', -3, 3)
gui.add(opts, 'scale', 1, 3)
function loop() {
cube.position.x = opt.x
cube.position.y = opt.y
cube.scale.set(opts.scale, opts.scale, opts.scale)
requestAnimationFrame()
}
Stats 调试工具

Stats 工具可以实时查看:

FPS:最后一秒的帧数,越大越流畅

MS:渲染一帧需要的时间(毫秒),越低越好

MB:占用的内存信息

CUSTOM:自定义面板

stats

1
2
3
4
5
6
7
8
9
var stats = new Stats()
stats.showPanel(1)
document.body.appendChild(stats.dom)
function animate() {
requestAnimationFrame(animate)
}
requestAnimationFrame(animate)

尾巴

最后,希望本篇文章所讲到的内容能帮助你更好的开发 3D 项目。另外,如果你有更好的实现思路,也欢迎你在下方评论区留言,感谢您的阅读。

参考

感谢您的阅读,本文由 凹凸实验室 版权所有。如若转载,请注明出处:凹凸实验室(https://aotu.io/notes/2018/10/18/cannonjs/