空中飞车2.0

前文 基于 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.BoxTHREE.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 ?