用 threejs 制作一款简单的赛车游戏
最近业务比较忙,但是我们追求 3D 世界的脚步不能停下来~某天在路上看到一辆辆飞驰而过的汽车,想到要不要弄一个赛车类的游戏
没有再用原生,而是使用了 threejs,毕竟大点的 3D 项目,再用原生就是自己给自己找麻烦了……
本文从 0 到 1 讲解了这个游戏的开发过程,其中没有专门的介绍 webgl 和 threejs,没有基础的同学可以结合 threejs 文档一起看,或者先学习一下 webgl 的基础知识~
游戏地址如下:
https://vorshen.github.io/simpleCar/
操作如下:
w 前进
a、d 左右转
space 减速可漂移
目前游戏的碰撞检测没有做完 (后续会更新进行完善),只会进行汽车左边与赛道中两条边进行碰撞检测。具体哪里下面会说~大家也可以通过亲自试玩来找到哪两条边
下面我们就来从 0 到 1 去实现这个赛车游戏~
注意:文中出现的代码片段是有作用域的!!为了配合讲解上下文而删减了其他的内容!完整代码地址如下:
https://github.com/vorshen/simpleCar
1、游戏准备
首先我们要选择做一款什么游戏,如果是公司级的游戏项目,那开发基本是没有选择权的。自己做着练手那就可以按自己喜好来了。我之所以选择赛车来举例子:
首先是因为赛车游戏比较简单,没有过多的素材要求。毕竟是个人开发,没有专门的设计大大提供模型,模型得自己去找。
其次是赛车游戏简单闭环的成本低,有车,有跑道,能跑起来其实就是一款最简单的游戏了
所以最终就决定了做一款一切从简的赛车游戏,接下来我们要寻找素材
2、素材准备
在网上扒了很久,找到了一款不错的汽车 obj 文件,贴图啥的都有,不过有的颜色还没上,用 blender 进行补齐一下
汽车素材有了,接下来就是赛道的。赛道最早的想法是动态生成,类似之前那个迷宫游戏一样
正规的赛车游戏肯定没法动态生成,因为赛道都需要定制的,有很多细节的东西,比如贴图风景之类的。
我们这个练手项目追求不了那么酷炫,所以可以考虑一下动态生成。
动态生成的好处就是每次刷新后玩都是一个新的地图,可能新鲜度会高一些。
动态生成的也有两种玩法,一种是用一块板不停的去平铺,板的顶点信息
[-1,0,1, 1,0,1, 1,0,-1, -1,0,-1]
用俯视图看起来就是下面这样
但是这个有一个很不好的,就是弯道太粗糙了,每个弯道都是直角,不怎么好看。就换一个方案
obj 建两个模型,分别是直道、转弯,如图
然后这两个个模型不停的去平铺
用 2D 看起来就像下面这样
看起来这个是可行的,但是!真实实现之后发现还是不好!
首先赛道没法回头了,因为我们 y 轴是固定的,没有上下坡的概念。一旦赛道回头新的道路碰到已有的道路就会乱,变成岔路的感觉
其次针对随机要做很多的控制,否则可能出现弯道过于频繁,如图
兼容了一会,发现很是操蛋,所以决定还是自己建一个赛道模型,自己动手丰衣足食,如图
再次安利下 blender 还是很好用的~
在这里设计赛道的时候有一个弯道设计的太难了,不减速无法无碰撞过弯……相信试玩一圈肯定能找到是哪一个弯~
3、threejs
准备工作都弄完了,接下来就是撸代码啦
不知道之前原生 webgl 开发大家还记得不,很繁琐对不对,这次我们用了 threejs,可就方便很多了。不过还是要说一下,推荐先把原生 webgl 弄熟一些再去接触 threejs,否则可能会有很大的依赖性,而且对图形学的一些基础会不牢固。
我们第一步创建整个场景世界
1
2
3
4
5
6
7
8
9
10
11
12
13
|
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(90, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.z = 0;
camera.position.x = 0;
var webGLRenderer = new THREE.WebGLRenderer();
webGLRenderer.setPixelRatio(window.devicePixelRatio);
webGLRenderer.setSize(window.innerWidth, window.innerHeight);
webGLRenderer.setClearColor(0x0077ec, 1);
|
这些是使用 threejs 必须要有的,比我们自己原生去创建 program,shader,再各种编译绑定方便了很多
接下来我们要把模型给导入进去。上次有写过一个简单的 objLoader,这次我们用 threejs 自带的。
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
|
var mtlLoader = new THREE.MTLLoader();
mtlLoader.setPath(‘./assets/’); // 路径
mtlLoader.load(‘car4.mtl’, function(materials) {
// 材质完成之后再导入obj
materials.preload();
var objLoader = new THREE.OBJLoader();
objLoader.setMaterials(materials);
objLoader.setPath(‘./assets/’);
objLoader.load(‘car4.obj’, function(object) {
car = object;
car.children.forEach(function(item) {
item.castShadow = true;
});
car.position.z = –20;
car.position.y = –5;
params.scene.add(car);
self.car = car;
params.cb();
}, function() {
console.log(‘progress’);
}, function() {
console.log(‘error’);
});
});
|
首先加载 mtl 文件,生成材质之后再加载 obj 文件,非常的方便。注意这里我们把赛车加入到场景之后要调整一下 position.zy,地面在我们这个世界中 y 轴坐标为-5
上一段代码可以看出摄像机开始的 z 坐标为 0,我们将赛车的 z 坐标初始设置为-20
同理再导入赛道文件,此时我们访问的话,会发现一片漆黑,如图
这是为什么呢?
神说要有光!
本身赛道和赛车是没有颜色的,需要用材质+光出现颜色。原生 webgl 中制作光也比较麻烦,还需要写 shader,threejs 又是很方便啦
我们只需要如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
var dirLight = new THREE.DirectionalLight(0xccbbaa, 0.5, 100);
dirLight.position.set(–120, 500, –0);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1000; // default
dirLight.shadow.mapSize.height = 1000; // default
dirLight.shadow.camera.near = 2;
dirLight.shadow.camera.far = 1000;
dirLight.shadow.camera.left = –50;
dirLight.shadow.camera.right = 50;
dirLight.shadow.camera.top = 50;
dirLight.shadow.camera.bottom = –50;
scene.add(dirLight);
var light = new THREE.AmbientLight( 0xccbbaa, 0.1 );
scene.add( light );
|
刷新一下我们整个是世界亮堂堂起来了!(注意,这里我们用的是环境光+平行光,后续我们会改成其他的光,原因也会给出),可是是不是少了一点什么?对!还少了阴影
但是阴影我们放在下一节再说,因为这里阴影的处理没有光那么简单
抛开阴影,我们可以理解为一个静态的世界已经完成了,有赛车有赛道
下面来做事件处理
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
|
document.body.addEventListener(‘keydown’, function(e) {
switch(e.keyCode) {
case 87: // w
car.run = true;
break;
case 65: // a
car.rSpeed = 0.02;
break;
case 68: // d
car.rSpeed = –0.02;
break;
case 32: // space
car.brake();
break;
}
});
document.body.addEventListener(‘keyup’, function(e) {
switch(e.keyCode) {
case 87: // w
car.run = false;
break;
case 65: // a
car.rSpeed = 0;
break;
case 68: // d
car.rSpeed = 0;
break;
case 32: // space
car.cancelBrake();
break;
}
});
|
我们没有用键盘事件相关的库,就几个键,自己裸写一下。代码应该还是很好懂的
按下 w 就意味着踩油门,car 的 run 属性置为 true,tick 中就要进行加速;同理 a 按下修改了 rSpeed,在 tick 中 car 的 rotation 将会有所变化
代码如下:
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
|
if(this.run) {
this.speed += this.acceleration;
if(this.speed > this.maxSpeed) {
this.speed = this.maxSpeed;
}
} else {
this.speed -= this.deceleration;
if(this.speed < 0) {
this.speed = 0;
}
}
var speed = –this.speed;
if(speed === 0) {
return ;
}
var rotation = this.dirRotation += this.rSpeed;
var speedX = Math.sin(rotation) * speed;
var speedZ = Math.cos(rotation) * speed;
this.car.rotation.y = rotation;
this.car.position.z += speedZ;
this.car.position.x += speedX;
|
很方便,配合一些数学计算去修改 car 的 rotation、position 就 ok 了,比原生 webgl 自己实现各种变换矩阵方便多了,不过要知道 threejs 底层也还是通过 matrix 去变化的。
简单总结一下这一节,我们用 threejs 去完成了整个世界的布局,然后通过键盘事件让汽车也可以动起来了,不过我们还缺少很多东西。
4、特性功能
这节主要说的是 threejs 无法实现或者 threejs 无法简单实现的功能。先总结一下第三节结束之后,我们还欠缺的能力
a、摄像机跟随
b、轮胎细节
c、阴影
d、碰撞检测
e、漂移
下面一一道来
摄像机跟随
刚才我们成功让赛车移动了起来,但是我们的视角没有动,车仿佛在渐渐远离我们。视角是由摄像机控制的,之前我们创建了一个摄像机,现在我们要让它跟随着赛车运动。摄像机和赛车的关系如下面这两幅图
也就是说
摄像机的 rotation 和赛车的 rotation 是对应的,但是赛车无论转向 (rotation) 还是移动 (position) 也都得去改变摄像机的 position!这个对应关系要弄清楚
1
2
3
4
|
camera.rotation.y = rotation;
camera.position.x = this.car.position.x + Math.sin(rotation) * 20;
camera.position.z = this.car.position.z + Math.cos(rotation) * 20;
|
在 car 的 tick 方法中,根据 car 本身的 position 和 rotation,去算出 camera 的位置,20 就是当赛车没有旋转时,摄像机和赛车的距离 (第三节开头有说过)。代码结合上面的图一起理解好点
这样就实现了摄像机的跟随
轮胎细节
轮胎细节需要是为了体验出偏航角时的真实性,不知道偏航角没有关系,就理解为漂移时的真实性就好了,如下图
其实普通转向的时候,也是应该轮胎先行,车身再动,但我们这边由于视角的问题就省略掉了
这里核心就是车身方向和轮胎方向的不一致。不过这时候坑爹的就来了,threejs 的 rotation 比较僵硬,它无法指定任意旋转轴,要么就是用 rotation.xyz 的方式旋转坐标轴,要么就是 rotateOnAxis 的方式选择一条通过原点的轴进行旋转。所以我们只能对轮胎进行随车旋转,无法自转。如图
那么我们想自转,首先需要把轮胎模型给单独抽出来,变成这样,如图
然后我们发现,自转可以了,随车旋转没了……那么我们就要建立一个父级关系,随车的旋转是父级去做,自转是轮胎本身去做的
代码如下
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
|
mtlLoader.setPath(‘./assets/’);
mtlLoader.load(params.mtl, function(materials) {
materials.preload();
var objLoader = new THREE.OBJLoader();
objLoader.setMaterials(materials);
objLoader.setPath(‘./assets/’);
objLoader.load(params.obj, function(object) {
object.children.forEach(function(item) {
item.castShadow = true;
});
var wrapper = new THREE.Object3D(); // 父级
wrapper.position.set(0,–5,–20); // 设置轮胎本身在父级中的位置
wrapper.add(object);
object.position.set(params.offsetX, 0, params.offsetZ);
scene.add(wrapper);
self.wheel = object;
self.wrapper = wrapper;
}, function() {
console.log(‘progress’);
}, function() {
console.log(‘error’);
});
});
……
this.frontLeftWheel.wrapper.rotation.y = this.realRotation; // 随着车身一起的旋转
this.frontRightWheel.wrapper.rotation.y = this.realRotation;
this.frontLeftWheel.wheel.rotation.y = (this.dirRotation – this.realRotation) / 2; // 偏航角产生的自转
this.frontRightWheel.wheel.rotation.y = (this.dirRotation – this.realRotation) / 2;
|
图的演示是这样的
阴影
之前我们把阴影给跳过了,说没有光那么简单。其实阴影在 threejs 中实现,本身比 webgl 原生实现简单了好几个 level。
看下 threejs 中阴影的实现,需要三步
1、光源计算阴影
2、物体计算阴影
3、物体承载阴影
这三步就可以让你的场景中出现阴影
如下代码:
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
|
dirLight.castShadow = true; // 光源计算阴影
dirLight.shadow.mapSize.width = 1000;
dirLight.shadow.mapSize.height = 1000;
dirLight.shadow.camera.near = 2;
dirLight.shadow.camera.far = 1000;
dirLight.shadow.camera.left = –50;
dirLight.shadow.camera.right = 50;
dirLight.shadow.camera.top = 50;
dirLight.shadow.camera.bottom = –50;
……
objLoader.load(‘car4.obj’, function(object) {
car = object;
car.children.forEach(function(item) {
item.castShadow = true; // 物体(赛车)计算阴影
});
……
objLoader.load(‘ground.obj’, function(object) {
object.children.forEach(function(item) {
item.receiveShadow = true; // 物体(地面)承载阴影
});
|
但是!我们这里是动态阴影,可以理解为整个场景都要在不断的变化。这样 threejs 中阴影就麻烦一些了,需要我们进行一些额外的处理。
首先我们知道我们的光是平行光,平行光可以看成太阳光,覆盖整个场景的。但是阴影不行啊,阴影需要通过正射矩阵去算的!那么问题来了,我们整个场景非常的大,正射矩阵如果想覆盖整个场景,你的帧缓冲图也非常的大,否则阴影会很不真实。其实无需考虑到这一步,因为帧缓冲图压根就不能那么大,一定会卡成狗。
那怎么办?我们就得动态的去改变正射矩阵!
整个过程可以理解为这样的
1
2
3
4
5
6
7
8
9
10
|
var tempX = this.car.position.x + speedX;
var tempZ = this.car.position.z + speedZ;
this.light.shadow.camera.left = (tempZ–50+20) >> 0;
this.light.shadow.camera.right = (tempZ+50+20) >> 0;
this.light.shadow.camera.top = (tempX+50) >> 0;
this.light.shadow.camera.bottom = (tempX–50) >> 0;
this.light.position.set(–120+tempX, 500, tempZ);
this.light.shadow.camera.updateProjectionMatrix();
|
我们只考虑到了赛车在地面的阴影,所以正射矩阵只保证可以完整包含赛车就可以了。墙壁没有去考虑,其实按完美来说墙壁也应该有阴影的,需要把正射矩阵拉大一点
但是!threejs 中平行光就没有镜面反射的效果了,整个赛车晓得不够生动,所以我就尝试把平行光改成了点光源(路灯的感觉?),然后让点光源也一直跟随着赛车
1
2
3
4
5
6
7
8
9
10
|
var pointLight = new THREE.PointLight(0xccbbaa, 1, 0, 0);
pointLight.position.set(–10, 20, –20);
pointLight.castShadow = true;
scene.add(pointLight);
……
this.light.position.set(–10+tempX, 20, tempZ);
this.light.shadow.camera.updateProjectionMatrix();
|
这样看起来整体就好了很多,之前说的更换光类型原因也就是在这~
碰撞检测
不知道大家找到了哪几条边有碰撞检测了没,其实是这几条边~
红色的这几条边和赛车的右边有碰撞检测,不过碰撞检测做的很随意,一旦碰上了就当作撞毁……直接速度置 0 重新出现了
确实是偷懒,因为碰撞检测好搞,但这种赛车碰撞反馈在不接入物理引擎的情况下实在不好搞,要考虑很多,如果单纯看成一个圆就会方便很多
所以我这次先给大家说碰撞检测,如果想有很好的反馈……还是接入成熟的物理引擎比较好
赛车和赛道的碰撞检测,我们先得把 3D 转成 2D 去看,因为我们这边也没什么障碍物上下坡啥的,简单嘛
2D 碰撞,我们可以去检测赛车的左右边和障碍物的边
首先我们有了赛道的 2D 数据,再去动态获得赛车的左右边,拿去检测
获取左边的代码
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
|
var tempA = –(this.car.rotation.y + 0.523);
this.leftFront.x = Math.sin(tempA) * 8 + tempX;
this.leftFront.y = Math.cos(tempA) * 8 + tempZ;
tempA = –(this.car.rotation.y + 2.616);
this.leftBack.x = Math.sin(tempA) * 8 + tempX;
this.leftBack.y = Math.cos(tempA) * 8 + tempZ;
……
Car.prototype.physical = function() {
var i = 0;
for(; i < outside.length; i += 4) {
if(isLineSegmentIntr(this.leftFront, this.leftBack, {
x: outside[i],
y: outside[i+1]
}, {
x: outside[i+2],
y: outside[i+3]
})) {
return i;
}
}
return –1;
};
|
这个和摄像头的概念有点类型,不过数学计算上麻烦一些
线和线的碰撞检测我们就用三角形面积法,最快的线与线碰撞检测
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
function isLineSegmentIntr(a, b, c, d) {
// console.log(a, b);
var area_abc = (a.x – c.x) * (b.y – c.y) – (a.y – c.y) * (b.x – c.x);
var area_abd = (a.x – d.x) * (b.y – d.y) – (a.y – d.y) * (b.x – d.x);
if(area_abc * area_abd > 0) {
return false;
}
var area_cda = (c.x – a.x) * (d.y – a.y) – (c.y – a.y) * (d.x – a.x);
var area_cdb = area_cda + area_abc – area_abd ;
if(area_cda * area_cdb > 0) {
return false;
}
return true;
}
|
碰上之后呢?虽然我们没有完美的反馈,但是基本的也应该有啊,我们将速度置 0 重新出现,总得把赛车方向重置正确对不对?否则玩家就一直在撞了……重置方向的用赛车本来的方向向量,去投影到碰撞边,得出的向量就是重置的方向
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
|
function getBounceVector(obj, w) {
var len = Math.sqrt(w.vx * w.vx + w.vy * w.vy);
w.dx = w.vx / len;
w.dy = w.vy / len;
w.rx = –w.dy;
w.ry = w.dx;
w.lx = w.dy;
w.ly = –w.dx;
var projw = getProjectVector(obj, w.dx, w.dy);
var projn;
var left = isLeft(w.p0, w.p1, obj.p0);
if(left) {
projn = getProjectVector(obj, w.rx, w.ry);
} else {
projn = getProjectVector(obj, w.lx, w.ly);
}
projn.vx *= –0.5;
projn.vy *= –0.5;
return {
vx: projw.vx + projn.vx,
vy: projw.vy + projn.vy,
};
}
function getProjectVector(u, dx, dy) {
var dp = u.vx * dx + u.vy * dy;
return {
vx: (dp * dx),
vy: (dp * dy)
};
}
|
漂移
赛车没有漂移,就好像就是打开一款网络游戏发现网线断了一样
我们这边不去考虑漂移过弯和正常过弯到底哪个快,有兴趣的同学可以查一查,还挺有意思的
先说明三点结论
1、漂移赛车游戏的核心之一 (帅),不做不行
2、漂移一大核心是出弯方向更佳,不需要扭动车头 (其他好处和坏处略,因为这个在视觉上看起来是最直观的)
3、网上没有现成很好用的漂移算法 (不考虑 unity),所以需要我们来模拟漂移
模拟的话,我们就先要知道漂移的原理,还记得之前我们说的偏航角么?偏航角就是漂移在视觉上的体验
规范一点说偏航角就是赛车运动方向和车头朝向方向不一致时,差异的角就叫做偏航角
所以我们的模拟漂移呢,需要做到两步
1、产生偏航角,在视觉上让玩家感受到漂移
2、出弯方向正确,在真实性上让玩家感受到。总不至于玩家一个漂移后发现过弯更难受……
下面就针对这两点进行模拟,其实知道了目的,还是很好模拟的
偏航角的产生,我们就要去维护两个方向,一个是车身真正的旋转方向 realRotation,一个是赛车真正的运动方向 dirRotation(摄像机跟随的也是这个!)
在平时这两个值都是一样的,但是一旦用户按下 space,就要开始有所变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
var time = Date.now();
this.dirRotation += this.rSpeed;
this.realRotation += this.rSpeed;
var rotation = this.dirRotation;
if(this.isBrake) {
this.realRotation += this.rSpeed * (this.speed / 2);
}
this.car.rotation.y = this.realRotation;
this.frontLeftWheel.wrapper.rotation.y = this.realRotation;
this.frontRightWheel.wrapper.rotation.y = this.realRotation;
this.frontLeftWheel.wheel.rotation.y = (this.dirRotation – this.realRotation) / 2;
this.frontRightWheel.wheel.rotation.y = (this.dirRotation – this.realRotation) / 2;
camera.rotation.y = this.dirRotation;
|
此时偏航角已经产生
用户松开 space 的时候,两个方向要开始统一,此时切记一定是 dirRotation 朝着 realRotation 统一的,否则漂移出弯的意义就没啦
1
2
3
4
5
6
7
8
9
10
|
var time = Date.now();
if(this.isBrake) {
this.realRotation += this.rSpeed * (this.speed / 2);
} else {
if(this.realRotation !== this.dirRotation) {
this.dirRotation += (this.realRotation – this.dirRotation) / 20000 * (this.speed) * (time – this.cancelBrakeTime); // 和速度时间有关系,参数可以尝试调整
}
}
|
结尾
时间关系,写的不是那么细节,不过核心的地方基本上也都写了,如果有问题可以留言讨论~
这个游戏还是有非常多的不足与缺陷,后续我还会优化完善,感兴趣的同学可以持续关注~
感谢您的阅读~
来源:http://www.alloyteam.com/2017/09/13139/
WEBGL学习网 » 用 threejs 制作一款简单的赛车游戏