上一节中,我们已经创建 3D 世界和游戏主角。在本节中我们需要创建跑道和 NPC(障碍物),以及碰撞检测。
创建跑道
首先,我们设定跑道是一个没有厚度属性的平面模型,而 Three.js 已经内置提供了这样一种平面模型: PlaneGeometry
类。
该类接受 4 个参数:width
,height
,widthSegments
,heightSegments
,分别表示宽度,高度,宽度分段数,以及高度分段数。宽度/高度分段数决定了该平面由小三角形组成的个数,默认值为1
,分段数越多,平面的细节越细腻。
创建跑道模型、材质和网格:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
const RACETRACK_SEGMENTS = 5; export const SEGMENT_WIDTH = PLAYER.width + 2; export const RACETRACK = { width: SEGMENT_WIDTH * RACETRACK_SEGMENTS, height: CAMERA.far, y: -PLAYER.height / 2, segments: RACETRACK_SEGMENTS, segmentWidth: SEGMENT_WIDTH, };
|
1 2 3 4 5 6 7 8
| const geometry = new THREE.PlaneGeometry(RACETRACK.width, RACETRACK.height); const material = new THREE.MeshBasicMaterial({ color: 0x999999, transparent: true, opacity: 0.4, }); const plane = new THREE.Mesh(racetrackGeometry, racetrackMaterial);
|
此时,跑道是平行于屏幕的,我们需要把它绕 x 轴旋转90°
,使它垂直于屏幕。而且旋转后,跑道的长度有接近一半的大小是我们看不到的,出于优化性能,我可以尽可能地减少跑道的长度,所以旋转后,可以把跑道沿z
轴位移- RACETRACK.height / 2
,但需要补偿摄影机的位置,另外跑道的y
轴需要位移- PLAYER.height / 2
,使游戏主角刚好落在跑道平面上:
1 2 3
| plane.rotateX(THREE.Math.degToRad(-90)); plane.position.set(0, RACETRACK.y, -RACETRACK.height / 2 + CAMERA.z);
|
最后把跑道plane
添加到舞台scene
中,我们就可以看到这样的效果了:

添加雾化效果
目前的游戏效果看来,如果给舞台添加雾化效果的话,不仅方便我们处理跑道的边界问题,而且给游戏添加一点神秘的效果。添加雾化效果是使用 THRRE.Scene 的fog
属性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
const BOUNDARY = { zStart: CAMERA.z, zEnd: -CAMERA.far + CAMERA.z, };
const VISIBLE_AREA = { start: -PLAYER.depth / 2, end: Math.abs(BOUNDARY.zEnd), };
|
1 2
| scene.fog = new THREE.Fog(this.backgroundColor, VISIBLE_AREA.start, VISIBLE_AREA.end);
|
Fog
接收 3 个参数,分别代表雾的颜色,雾化起始点(正值),雾化结束点(正值),从起始点到结束点,雾的密度会越来越大。
给舞台添加雾化效果后是这样的:

添加跑道纹理效果
目前跑道是纯色的,显得有点单调,下面我们就给跑道添加纹理图片:

在 Threejs 中,纹理贴图是在材质中指定的:
1 2 3 4 5 6 7 8 9 10 11 12 13
| const getTexture = (): THREE.Texture { const image: any = wx.createImage(); const texture = new THREE.Texture(image); image.onload = () => { texture.needsUpdate = true; }; image.src = 'assets/img/racetrack-texture.png'; return texture; } const racetrackMaterial = new THREE.MeshBasicMaterial({ map: texture, });
|
上面的代码中,getTexture
方法是通过 wx.createImage 加载图片源,再通过 THREE.Texture(image)生成纹理贴图,值得注意的是,在图片加载完成后,需要修改纹理贴图的needsUpdate
,表示需要更新纹理贴图,否则贴图将不能正常显示。

添加 NPC
NPC
(Non-Player Character)是游戏的一个术语,意思是非玩家角色。
在这个跑酷游戏中,我们给 NPC 定义为游戏的障碍物,在游戏中具备以下特征:
- 定时且无限创建
- 具备移动能力
- 与玩家角色会发生碰撞

创建 NPC
首先我们需要创建一个 NPC,而在游戏过程中,定时地实例化这个类,新建 NPC 类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| export interface INPC { mesh: THREE.Mesh; }
export default class NPC implements INPC{ public mesh: THREE.Mesh; public render() { const boxGeometry = new THREE.BoxGeometry(NPC_CONFIG.width, NPC_CONFIG.height, NPC_CONFIG.depth); const boxMaterial = new THREE.MeshBasicMaterial({ color: 0x34495e, transparent: true, opacity: 0.75, }); const box = new THREE.Mesh(boxGeometry, boxMaterial); const position = this.generatePosition(); box.position.set( position.x, position.y, position.z, ); return box; } constructor() { this.mesh = this.render(); } private generatePosition() { const size = this.size; return { x: RACETRACK.width / RACETRACK.SEGMENTS * util.rnd(-2, 2, true), y: size.height / 2 - PLAYER.height / 2, z: BOUNDARY.zEnd - size.depth / 2, }; } }
|
然后实例化 NPC 并且添加到舞台中:
1 2 3
| const npc = new NPC(); scene.add(npc.mesh);
|
由于我们创建 npc 时,把它移到了相机的可视范围的最远处,所以舞台上并不能看到它,因此我们还需要实现另外一个动画功能:移动
。
移动动画
我们知道,在跑酷游戏中,障碍物一般情况下,都是从跑道的最远处沿着z 轴慢慢地靠近摄影机,所以实现该动画并不能,改变障碍物元素的position.z
即可。
我们知道动画的基本原理是:许多静止的画面,在一定的播放频率逐帧播放,就是我们常说的 FPS,一般来讲,FPS 达到 60 帧/秒,对用户肉眼的感知来说,画面已经非常流畅了。微信小游戏提供了一个跟浏览器一样的渲染动画 API:requestAnimationFrame
,我们可以利用它做动画渲染:
1 2 3 4 5 6 7
|
ticker() { npc.move(); window.requestAnimationFrame(this.ticker.bind(this)); }
|
实现 npc 的move
方法:
1 2 3 4 5
|
public move() { this.mesh.position.z += 1; }
|
此时我们能看到 NPC 已经能动起来了。

不过看起来移动速度太慢啊,不够刺激啊,所以还需要调整一下移动的速度:speed
,这个数值是如何确定的呢?首先我们初始化speed
为0.3
,fps
为60
,假设我们设定 npc 的移动速度为,1 秒内从摄影机最远处移动到摄影机后面,那么移动的计算公式就出来了:
moveDistance = speed * Math.abs(CAMERA.far) / fps;
因此我们需要修改一下代码:
1 2 3 4 5 6 7 8 9 10 11
| update() { npc.move(1, 60); }
public move(speed, fps) { const moveDistance = speed * Math.abs(CAMERA.far) / fps; this.mesh.position.z += moveDistance; }
|
经过调整后,npc 移动的速度已经调整为花 0.3 秒(被 speed 值影响,原本是 1 秒)的时间从屏幕出现到结束:

使用对象池创建多个 NPC
把这个内容要放在移动动画之后,是因为我们添加多个 NPC 时跟动画有一定的关联性。接下来我们设定每个 NPC 位移 1/3 的距离后,即添加下一个 NPC,所以我们需要新增一个方法去创建 NPC:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| private npcs: INPC[] = []; private frame: number = 0; ticker() { this.frame += 1; this.moveNPC(); ... }
addNPC() { const npc = new NPC(); this.scene.add(npc.mesh); this.npcs.push(npc); }
moveNPC() { if (this.npcs.length) { this.npcs.forEach((npc) => { npc.move({ game: this, add: () => { this.addNPC(); }, }); }); } }
public move(params: { /** 游戏类 */ game: IGame; /** 添加NPC回调 */ add: () => void; }) { if (this.mesh.position.z > thirdDistance ) { add(); } }
|
从上面的代码可以看出,只要游戏进行中,每一个 NPC 只要位移了 1/3 距离,那么就会实例化一次 NPC 类,我们知道频繁实例化对象会消耗 CPU 的计算。在游戏开发模式中,有个设计模式经常被使用到的,它就是对象池
,也就是实例化后的 npc 应该被存储到对象池中,需要的时候,我们不需要重新实例化,只要调整一下对象的属性,就可以重新使用了,从而提升代码性能。接下来我们要实现对象池,即新建一个Pool
类(来自微信小游戏打飞机模板的Pool
类,这里只是翻译成 Typescript 版本):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| const __ = { poolDic: Symbol('poolDic'), };
export interface IPool { getItemByClass: <T>(name: string, className: new () => T) => T; recover: (name: string, instance: object) => void; }
export default class Pool implements IPool{ constructor() { this[__.poolDic] = {}; }
getPoolBySign(name) { return this[__.poolDic][name] || (this[__.poolDic][name] = []); }
getItemByClass(name, className) { const pool = this.getPoolBySign(name); return pool.length ? pool.shift() : new className(); }
recover(name, instance) { this.getPoolBySign(name).push(instance); } }
|
此时我们实例化 NPC 类的方式需要通过 Pool 的getItemByClass
函数来实现:
1 2
| const npc = this.pool.getItemByClass('npc', NPC);
|
那么我何时回收 NPC 对象到对象池呢?没错,就是在 NPC 运动到屏幕之外时。因此,我们需要在move
方法新增一个越界回调out
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| npc.move({ game: this, add: () => { this.addNPC(); }, out: () => { npc.reset(); this.pool.recover('npc', npc); this.npcs.splice(index, 1); }, });
|
最后我们可以看到,虽然 npc 元素在游戏中出现了很多次,但只实例化了 1 次,也就是只调用了一次render
,这极大的优化游戏的性能:

碰撞检测
这是我们本小节需要实现的最后一个功能:碰撞检测
,即运动的 npc 会与玩家角色发生碰撞。因为该游戏对碰撞的精度要求比较低,因此我们采用AABB包围盒
的算法进行碰撞检测。计算公式:
(A.minX <= B.maxX && A.maxX >= B.minX) &&
(A.minY <= B.maxY && A.maxY >= B.minY) &&
(A.minZ <= B.maxZ && A.maxZ >= B.minZ);
上面的公式表示 A 与 B 发生了碰撞了。
在 THREE.js 中,内置了方法去包裹一个 3D 立方体:
1 2 3
| const npcMeshAABB = new THREE.Box3().setFromObject(npcMesh); const playerMeshAABB = new THREE.Box3().setFromObject(playerMesh);
|
因此判断碰撞的代码是这样的:
1 2 3 4 5 6 7 8 9 10 11 12
| function testAABB() { return ( A.min.x <= B.max.x && A.max.x >= B.min.x && A.min.y <= B.max.y && A.max.y >= B.min.y && A.min.z <= B.max.z && A.max.z >= B.min.z ); } const crashedTest = testAABB(npcAABB, playerAABB);
|

但是 AABB 包围黑测试法普遍存在的一个问题:高速运动的物体,在 2 帧之间两个物体已经发生了碰撞并穿透过去,这时候是无法捕捉到碰撞事件的。

因此我们还需要处理一下高速运动情况,假设speed
大于 2 时算是高速运动,满足下面的公式即算下一帧发生碰撞:
npc 的 z 坐标的最大边 + 移动距离 moveDistance >= player 的 z 坐标的最小边
1 2 3 4 5
| let crashedAtNextTicker = false; if (game.speed > 2 && npcMeshAABB.max.z + moveDistance >= playerMeshAABB.min.z) { crashedAtNextTicker = true; }
|
另外,3D 的碰撞检测有很多实现方式,比如 Threejs 的Raycaster
射线碰撞检测,虽然精度很高,但出于性能考虑,在这个游戏中不采用这种方案,感兴趣的可以查看:https://segmentfault.com/a/1190000009858873。除算法外,优化碰撞检测的性能也可根据实际情况进行调优,比如在这个游戏中,npc 和主角不在同个跑道时,是没必要进行碰撞检测的。
优化效果
目前来说,整体的效果还是比较单调的,我们再给游戏元素稍加一些修饰:
- 新增主角/NPC 元素的边框效果
- 新增 NPC 种类
- 新增跑道纹理动画(使用 texture.offset.y)
具体实现这里不再做累述了,我们可以看到优化之后的效果就好很多了:

总结
在本小节中,我们已经顺利完成跑道的创建和雾化、纹理效果,还有 NPC 的创建、移动动画,碰撞检测等。代码篇幅比较大,新增了许多代码模块,详细的项目结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| ./src ├── Game │ ├── NPC // NPC角色 │ │ ├── box.ts // 正方形 │ │ ├── cone.ts // 锥形 │ │ └── index.ts │ ├── Player // 游戏主角 │ │ └── index.ts │ ├── Pool // 对象池 │ │ └── index.ts │ ├── Racetrack // 跑道 │ │ └── index.ts │ ├── camera // 摄影机 │ │ └── index.ts │ ├── constant.ts // 常量 │ ├── helper │ │ ├── axes.ts // 辅助坐标系 │ │ └── orbitControls.ts // 摄影机轨道控制器 │ ├── index.ts │ ├── renderer // WebGL渲染器 │ │ └── index.ts │ ├── scene // 场景 │ │ └── index.ts │ └── util // 工具 │ └── index.ts ├── index.ts // 入口 └── lib ├── weapp-adapter-extend // weapp-adapter的扩展,新增window的方法 │ ├── index.js │ └── window.js └── weapp-adapter.js // 模拟BOM,DOM
|
代码::https://github.com/inarol/rungame/tree/section2
[本文谢绝转载,谢谢]