前文 基于 threejs 和 cannon-es 实现了一个简单的空中飞车小游戏,本文基于之前的场景继续完善一下,通过增加一些附带物理特性的 3d 障碍物提高一下游戏的难度,实现空中飞车 2.0 -.-||
初始化
先串讲下 3d 场景和物理世界的初始化
3D 场景
先创建好基础的三件套:场景 THREE.Scene
、相机 THREE.Camera
和渲染器 THREE.WebGLRenderer
,然后加一些环境光或其他光源,再调整下相机的位置,使其始终跟随自车,最后做一个循环的动画来更新场景,我这里限制了 60fps。代码参考如下:
init () {
const container = document.getElementById(initPayload.container);
const style = container!.getBoundingClientRect();
const width = style.width;
const height = style.height;
/***** 创建场景 *****/
const scene = new THREE.Scene();
/***** 创建环境光和其他光源 *****/
const ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.castShadow = true;
// 设置方向光源位置
directionalLight.position.set(15, 30, 25);
scene.add(directionalLight);
/***** 创建一个具有透视效果的摄像机 *****/
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 800);
this.camera = camera;
camera.position.x = 0;
/***** 创建一个 WebGL 渲染器 *****/
const renderer = new THREE.WebGLRenderer({
// 开启抗锯齿
antialias: true,
});
// 将渲染器的输出(canvas节点)插入到 body 中
document.body.appendChild(renderer.domElement);
// ......
const cameraOffsetY = 4;
const cameraOffsetZ = 16;
// 初始化事件,比如键盘方向键的逻辑
this.initEvents();
// 帧率监控
const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);
const animate = () => {
if (!this.world || !this.scene) {
return;
}
stats.begin();
// 相机跟随自车
camera.position.y = egoCar.position.y + cameraOffsetY;
camera.position.z = egoCar.position.z + cameraOffsetZ;
camera.lookAt(egoCar.position);
renderer.render(scene, camera);
stats.end();
};
animate();
setInterval(() => {
animate();
}, 16);
}
物理世界
借助 cannon-es
,添加物体时需要加到 world
上,和 threejs 需要将物体添加到 scene
类似,然后监听物理世界的变化,动态更新 3d 场景物体的位置和方向信息等。这里留意下 physicObjects
这个数组,存放了所有设置了物理属性的 3d 场景物体,然后物体通过 physicBody
引用刚体
initConnonWorld() {
const world = new CANNON.World();
world.broadphase = new CANNON.SAPBroadphase(world);
// 设定重力
world.gravity.set(0, -9.8, 0);
world.defaultContactMaterial.friction = 0;
this.world = world;
}
// ...
// 存放所有实现了物理关系的3d场景物体
physicObjects: any[] = [];
// 物理计算
updatePhysics() {
if (!this.world) {
return;
}
// 对应3d场景的渲染帧率
this.world.step(1 / 60);
this.physicObjects.forEach((obj) => {
// 将物理世界计算后的位置和旋转信息赋值给3d场景物体本身,从而实现物理效果
obj.position.copy(obj.physicBody.position);
obj.quaternion.copy(obj.physicBody.quaternion);
});
}
// ...
const animate = () => {
// ...
renderer.render(scene, camera);
this.updatePhysics();
// ...
};
此前已经实现了小车和一些道路,小白最好参考下 专栏 之前的文章,然后自车物体和行驶部分的逻辑可以参考之前的文章和源码 - threejs 仿抖音漂移特效,这里就不赘述啦 ~
道路
本文会在之前搭建的场景的基础上扩展,新增三段道路,分别加些障碍物,比如雾墙、滚落巨球和大摆锤等,初始界面如下:
车道
这一版车道做了简单的封装,将网格对象 THREE.Mesh
和刚体 CANNON.Body
的关联逻辑放在一起,可以针对车道位置和车道线做一些定制,参考代码如下:
export interface IRoadObjPayload extends IObjPayload {
// 车道旋转角度
rotation: number[];
// 车道设定为一个box,size是长宽高
size: number[];
// 是否显示车道线
hasLines?: boolean;
}
drawRoad(payload: IRoadObjPayload) {
const { pos, rotation, size, hasLines = true } = payload;
// 创建3d道路对象
const roadMaterial = new THREE.MeshLambertMaterial({
color: "#8c8585",
side: THREE.BackSide,
});
const roadGeometry = new THREE.BoxGeometry(size[0], size[1], size[2]);
const roadMesh = new THREE.Mesh(roadGeometry, roadMaterial);
roadMesh.receiveShadow = true;
roadMesh.rotation.x = rotation[0];
roadMesh.position.set(pos[0], pos[1], pos[2]);
this.scene!.add(roadMesh);
// 创建物理刚体
const q4 = roadMesh.quaternion;
const roadShape = new CANNON.Box(
new CANNON.Vec3(size[0] / 2, size[1] / 2, size[2])
);
const roadBody = new CANNON.Body({ mass: 0 });
roadBody.addShape(roadShape);
roadBody.position.set(pos[0], pos[1], pos[2]);
roadBody.quaternion = new CANNON.Quaternion(q4._x, q4._y, q4._z, q4._w);
this.world!.addBody(roadBody);
roadMesh.physicBody = roadBody;
// 绘制车道线
if (hasLines) {
// 绘制虚线,这个函数逻辑参考后面提到的车道线的内容
this.drawLine(
{
width: 0.2,
pos: [pos[0], pos[1] + 0.1, pos[2]],
// 是否为虚线
dash: true,
},
roadMesh
);
this.drawLine(
{
width: 0.2,
pos: [-size[0] / 2 + 0.2, pos[1] + 0.1, pos[2]],
},
roadMesh
);
this.drawLine(
{
width: 0.2,
pos: [size[0] / 2 - 0.2, pos[1] + 0.1, pos[2]],
},
roadMesh
);
}
return roadMesh;
}
车道线
在道路上简单加些实线和虚线,让道路更真实一点,用 THREE.PlaneGeometry
实现就行。然后要实现虚线的话,需要分成几段(dashNum
)来绘制,每段间隔多少 m(dashOffset
),这些可以作为参数传入车道线的创建函数。这里要注意和车道 road 对象关联,比如车道线长度和车道长度保持一致、车道线对象倾斜度和 road 对齐等
this.drawLine(
{
// 线宽
size: 0.2,
// 起点(相对于road)。这一版暂时不支持斜线,感兴趣的可以自行再发挥下~
pos: [0, 0.1, 0],
},
// 传入3d场景的车道对象
road1
);
//...
// 绘制车道线 drawLine(lineInfo: ILineInfo, road: THREE.Mesh) {
const { pos, width, dash, dashNum = 10, dashOffset = 2 } = lineInfo;
// 获取道路 Mesh 的宽高信息
const roadSize = new THREE.Vector3();
const roadBox = new THREE.Box3().setFromObject(road);
roadBox.getSize(roadSize);
const lineMaterial = new THREE.MeshBasicMaterial({
color: lineInfo.color ?? 0xffffff,
side: THREE.DoubleSide,
});
// 绘制虚线
if (dash) {
const length = roadSize.z / dashNum - dashOffset;
// 表示整段虚线中,其中一段实线的起始位置
let nextZ = pos[2] + roadSize.z / 2 - dashOffset;
for (let i = 0; i < dashNum; i++) {
const lineGeometry = new THREE.PlaneGeometry(width, length);
const line = new THREE.Mesh(lineGeometry, lineMaterial);
line.position.set(pos[0], pos[1], nextZ);
if (road?.quaternion) {
// 车道线的旋转角度和车道对齐
line.rotation.setFromQuaternion(road.quaternion);
} else {
// 车道线默认是水平方向
line.rotation.x = Math.PI / 2;
}
this.scene!.add(line);
// 计算下一端实线的起点
nextZ = nextZ - (length + dashOffset);
}
// 绘制实线
} else {
const lineGeometry = new THREE.PlaneGeometry(width, roadSize.z);
const line = new THREE.Mesh(lineGeometry, lineMaterial);
line.position.set(pos[0], pos[1], pos[2]);
if (road?.quaternion) {
line.rotation.setFromQuaternion(road.quaternion);
} else {
line.rotation.x = Math.PI / 2;
}
this.scene!.add(line);
}
}
// 线条参数
export interface ILineInfo {
// 线条宽度
width: number;
// 起始位置
pos: number[];
// 默认白色
color?: string;
// 是否虚线,默认实线
dash?: boolean;
// 虚线间隔,默认2
dashOffset?: number;
// 虚线段数,默认10段
dashNum?: number;
}
一些障碍物
雾墙
首先得找张合适的烟雾图片做材质的纹理贴图,注意背景要选透明的,然后沿着 z 轴叠多几层平面几何体,做成一个半透明的雾墙。然后加点烟雾扩散的动画,通过在渲染循环 animate
里动态调整雾墙对象 scale
的大小来模拟实现。当然,你还可以尝试加些位移或者其他变化,让这个烟雾效果更真实点
interface IObjPayload {
// 位置
pos: number[];
}
const textureLoader = new THREE.TextureLoader();
// ...
// 绘制雾墙
fogWalls: THREE.Mesh[] = [];
drawFogWalls(payload: IObjPayload) {
const { pos } = payload;
// 加载纹理贴图
const texture = textureLoader.load("/gta/cloud2.png");
const material = new THREE.MeshLambertMaterial({
map: texture,
transparent: true,
opacity: 0.8,
});
for (let i = 0; i < 10; i++) {
const smokeGeo = new THREE.PlaneGeometry(20, 20);
const mesh = new THREE.Mesh(smokeGeo, material);
mesh.position.set(pos[0], pos[1], pos[2] - i * 0.05);
mesh.rotation.z = Math.random() * Math.PI * 2;
this.scene!.add(mesh);
this.fogWalls.push(mesh);
}
}
// 做个简单的动画,每帧里遍历每一层,做个简单的扩散效果
updateFog() {
this.fogWalls.forEach((mesh) => {
mesh.scale.set(
mesh.scale.x + 0.0005,
mesh.scale.y + 0.0005,
mesh.scale.z
);
});
}
// ...
const animate = () => {
// ...
this.updateFog();
renderer.render(scene, camera);
// ...
};
路障
这里做几个正方体盒子叠起来,然后自车开过去有个撞击效果。用 CANNON.Box
和 THREE.BoxGeometry
就可以实现,注意刚体质量设置小一点,车子总得撞的过去。顺便用 THREE.EdgesGeometry
加下盒子边框。至于盒子叠放逻辑,这里就简单参考金字塔来叠了,10 个盒子对应 1234 的居中叠放方式
drawObstacles(payload: IObjPayload) {
const { pos } = payload;
const boxSize = 1.6;
const geometry = new THREE.BoxGeometry(boxSize, boxSize, boxSize);
const material = new THREE.MeshLambertMaterial({
color: "yellow",
});
const mesh = new THREE.Mesh(geometry, material);
// 加边框
const box = geometry.clone();
const edges = new THREE.EdgesGeometry(box);
const edgesMaterial = new THREE.LineBasicMaterial({
color: 0x333333,
});
const line = new THREE.LineSegments(edges, edgesMaterial);
// 组合起来
const group = new THREE.Group();
group.add(line, mesh);
const startPos = [pos[0], pos[1], pos[2]];
let newPos = [...startPos];
for (let i = 0; i < 10; i++) {
if (i < 4) {
newPos = [startPos[0] + i * 2, startPos[1] + boxSize, startPos[2]];
} else if (i < 7) {
newPos = [
startPos[0] + (i - 3.5) * 2,
startPos[1] + boxSize * 2,
startPos[2],
];
} else if (i < 9) {
newPos = [
startPos[0] + (i - 6) * 2,
startPos[1] + boxSize * 3,
startPos[2],
];
} else {
newPos = [startPos[0] + 3, startPos[1] + boxSize * 4, startPos[2]];
}
// 复制 mesh 对象,可以直接复用顶点数据,而不需要计算新的顶点
const obstacle = group.clone();
obstacle.position.set(newPos[0], newPos[1], newPos[2]);
this.scene!.add(obstacle);
// 创建刚体,并关联3d物体
const q3 = obstacle.quaternion;
const roadShape3 = new CANNON.Box(
new CANNON.Vec3(boxSize / 2, boxSize / 2, boxSize / 2)
);
const roadBody3 = new CANNON.Body({ mass: 3 });
roadBody3.addShape(roadShape3);
roadBody3.position.set(newPos[0], newPos[1], newPos[2]);
roadBody3.quaternion = new CANNON.Quaternion(q3._x, q3._y, q3._z, q3._w);
this.world!.addBody(roadBody3);
// 关联物理刚体
obstacle.physicBody = roadBody3;
// 更新 physicObjects
this.physicObjects.push(obstacle);
}
}
注意这两段很重要 :
obstacle.physicBody = roadBody3;
。3d 障碍物对象关联对应的刚体this.physicObjects.push(obstacle);
。physicObjects
其实存放了所有附带物理属性的 3d 物体,这样后续可以在渲染循环里通过遍历这个数组,从关联的刚体physicBody
取到物理计算后的新值,赋值给 3d 物体,从而实现物理效果
滚落的球体
通过 THREE.SphereGeometry
和 **CANNON.Sphere
来实现球体和对应的刚体。因为在有倾斜度的道路上,小球在重力作用下是会向下滚动的,不过需要在自车到达一定位置后才触发滚动,一开始先把巨球设定为静止。小球的质量也比较大,自车被撞到后会明显减速,所以经过时要小心点
同样,这里也做个函数封装,将 3d 球体和刚体关联起来
drawBalls(payload: IObjPayload) {
const { pos } = payload;
const geometry = new THREE.SphereGeometry(2);
const material = new THREE.MeshLambertMaterial({
color: "brown",
});
const obstacle = new THREE.Mesh(geometry, material);
obstacle.position.set(pos[0], pos[1], pos[2]);
this.scene!.add(obstacle);
const q3 = obstacle.quaternion;
const roadShape3 = new CANNON.Sphere(2);
const roadBody3 = new CANNON.Body({ mass: 200 });
roadBody3.addShape(roadShape3);
roadBody3.position.set(pos[0], pos[1], pos[2]);
roadBody3.quaternion = new CANNON.Quaternion(q3._x, q3._y, q3._z, q3._w);
obstacle.physicBody = roadBody3;
this.balls = [obstacle];
}
设定规则是自车到达一定位置时再触发巨球的滚动,之前都是静止。那其实就是自车行驶到指定位置的时候再将巨球加入物理世界就行
const animate = () => {
// ...
if (egoCar.position.z <= -10) {
this.balls.forEach((ball) => {
this.world!.addBody(ball.physicBody);
this.physicObjects.push(ball);
});
}
this.updatePhysics();
// ...
};
疯狂大摆锤
做个大摆锤,先用两个圆柱体 THREE.CylinderGeometry
实现一个悬挂空中的大摆锤,一个圆柱体做杆,一个圆柱体做锤,新建锤的刚体 CANNON.Cylinder
。注意锤的刚体要设置质量 mass 为 0,确保不受重力影响,才能悬挂在空中
drawHammer(payload: IHammerPayload) {
const { pos, startAngle = -Math.PI / 2, duration = 1000 } = payload;
const geometry = new THREE.CylinderGeometry(0.2, 0.2, 13, 10);
const material = new THREE.MeshLambertMaterial({
color: "gray",
});
const mesh1 = new THREE.Mesh(geometry, material);
mesh1.position.set(-2, -6, -5);
const geometry2 = new THREE.CylinderGeometry(1, 1, 4, 10);
const material2 = new THREE.MeshLambertMaterial({
color: "gray",
});
const mesh2 = new THREE.Mesh(geometry2, material2);
mesh2.position.set(-2, -13.5, -5);
mesh2.rotation.z = Math.PI / 2;
const group = new THREE.Group();
group.add(mesh1, mesh2);
group.position.set(pos[0], pos[1], pos[2]);
group.rotation.z = startAngle;
this.scene!.add(group);
// 创建刚体
const q3 = mesh2.quaternion;
const roadShape = new CANNON.Cylinder(1, 1, 4, 10);
// 注意:设置mass为0,摆锤才可以悬浮在空中,不受重力影响
const roadBody = new CANNON.Body({ mass: 0 });
roadBody.addShape(roadShape);
roadBody.position.set(mesh2.position.x, mesh2.position.y, mesh2.position.z);
roadBody.quaternion = new CANNON.Quaternion(q3._x, q3._y, q3._z, q3._w);
this.world!.addBody(roadBody);
mesh2.physicBody = roadBody;
}
最后再用 tween.js
实现摆锤的动画,别忘了在渲染循环里执行动画更新 TWEEN.update()
。这里整个效果实现的链路是:tween 平滑更新摆锤旋转角度 —> 获取最新的锤体的位置信息和旋转角度,更新刚体数据 —> 物理计算后再更新 3d 场景其他物体的位置和旋转角度,比如摆锤和自车相撞的效果
drawHammer(payload: IHammerPayload) {
// ...
// 摆锤动画
const tweenStart = new TWEEN.Tween(group.rotation)
// 终态
.to({ z: Math.PI / 2 }, duration)
// 延迟500ms再触发动画
.delay(500)
// 不断重复动画
.repeat(Infinity)
// 初态和终态之间平滑地来回变化
.yoyo(true)
.onUpdate((data) => {
// NOTE 注意这里要重新计算mesh2的位置信息,因为mesh2的position是相对group的,并不会变化
group.updateMatrixWorld(true);
const child = group.children[1];
const globalPosition = new THREE.Vector3();
const globalQuaternion = new THREE.Quaternion();
const pos = child.getWorldPosition(globalPosition);
const q = child.getWorldQuaternion(globalQuaternion);
// 更新刚体数据
roadBody.quaternion = new CANNON.Quaternion(q._x, q._y, q._z, q._w);
roadBody.position.set(pos.x, pos.y, pos.z);
})
.start();
}
// ...
const animate = () => {
// ...
this.updatePhysics();
TWEEN.update();
// ...
};
游戏逻辑
游戏结束
小车到达终点,也就是 z 值达到对应的值并且此时小车还在道路上,就说明到达终点,停住车并提示到达终点,过 5 秒后自动重新开始;或者掉落到某个边界值就显示游戏结束,过 5 秒后自动重新开始。这里借助了 mobx
做一个数据响应来触发提示。逻辑比较简单,可以自行参考源码 ~
写在最后
体验地址。期待一下 3.0 ?