threejs 仿抖音漂移停车特效

最近刷到了抖音的漂移停车 2 的视频,感觉还蛮有趣的

抖音原视频

乍一看,实现这个漂移停车的效果需要一些东西:

  • 一辆一直往前开的小车和一个停车点,这里就做成一个小车库吧
  • 漂移停车逻辑。这个小游戏是通过往左往右触屏滑动来刹车,附带了转向
  • 和车库的碰撞处理
  • 停车后的计分逻辑

之前的文章实现了 3d 场景和可以凑合开的一辆小车,咱们拿来接着用一下

行车物理模拟

其实之前自己实现的自车行驶超级简单,加减速、转弯都做的比较粗糙,这里引入物理库 cannon-es(cannon.js 的增强版)来帮忙做这块逻辑。物理库的作用其实就是模拟一些真实的物理效果,比如行车、物理碰撞、重力等。具体 api 文档 戳这里,不过只有英文文档

npm install cannon-es

先初始化一个物理世界,其实和 threejs 场景的初始化有点像,之后也是需要将物理世界的物体和 threejs 的物体一一对应地关联起来,比如这里的地面、小车和车库,这样后面物理库做计算后,再将作用后的物体的位置信息赋值到 threejs 对应物体的属性上,最后通过循环渲染(animate)就能模拟行车场景了

import * as CANNON from "cannon-es";
// ...
const world = new CANNON.World();
// 物理世界预处理,这个可以快速排除明显不发生碰撞的物体对,提高模拟效率
world.broadphase = new CANNON.SAPBroadphase(world);
// 物理世界的重力向量
world.gravity.set(0, -9.8, 0);
// 刚体之间接触面的默认摩擦系数
world.defaultContactMaterial.friction = 0;

小车对象

cannon-esRaycastVehicle 类可以辅助我们管理物理世界的小车对象,它提供了很多蛮好用的 api,不仅可以帮助我们更好地管理车轮,而且能很好地根据地形运动

物理世界物体的基本要素有形状(常见的有Box长方体/Plane平面/Sphere球体)、材质 Material 和刚体 Body,类比 threejs 中的几何体、材质和 Mesh。创建刚体后别忘了将它添加到物理世界里,和 threejs 将物体添加到 scene 场景里类似

// 创建小车底盘形状,这里就是一个长方体
const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.3, 2));
// 创建质量为150kg的小车刚体。物理世界的质量单位是kg
const chassisBody = new CANNON.Body({ mass: 150 });
// 关联刚体和形状
chassisBody.addShape(chassisShape);
// 设定刚体位置
chassisBody.position.set(0, 0.4, 0);
// 基于小车底盘创建小车对象
const vehicle = new CANNON.RaycastVehicle({
  chassisBody,
  // 定义车辆的方向轴(0:x轴,1:y轴,2:z轴),让它符合右手坐标系
  // 车辆右侧
  indexRightAxis: 0,
  // 车辆上方
  indexUpAxis: 1,
  // 车辆前进方向
  indexForwardAxis: 2,
});
// 将小车添加到物理世界里,类比 threejs 的 scene.add()
vehicle.addToWorld(world);

四个车轮

接下来定义下车轮对象,用到了 Cylinder这种圆柱体的形状,然后要注意做好旋转值 Quaternion 的调整。这部分会稍微复杂些,可以耐心看下注释:

// 车轮配置,详情配置参考 https://pmndrs.github.io/cannon-es/docs/classes/RaycastVehicle.html#addWheel
const options = {
  radius: 0.4, // 轮子半径
  directionLocal: new CANNON.Vec3(0, -1, 0), // 轮子方向向量,指轮子从中心点出发的旋转方向
  suspensionStiffness: 45,
  suspensionRestLength: 0.4,
  frictionSlip: 5, // 滑动摩擦系数
  dampingRelaxation: 2.3,
  dampingCompression: 4.5,
  maxSuspensionForce: 200000,
  rollInfluence: 0.01,
  axleLocal: new CANNON.Vec3(-1, 0, 0),
  chassisConnectionPointLocal: new CANNON.Vec3(1, 1, 0),
  maxSuspensionTravel: 0.25,
  customSlidingRotationalSpeed: -30,
  useCustomSlidingRotationalSpeed: true,
};
const axlewidth = 0.7;
// 设置第一个车轮的连接点位置
options.chassisConnectionPointLocal.set(axlewidth, 0, -1);
// 按指定配置给小车添加第一个车轮,其他车轮类似
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, -1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(axlewidth, 0, 1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, 1);
vehicle.addWheel(options);
// 四个车轮
const wheelBodies: CANNON.Body[] = [];
const wheelVisuals: THREE.Mesh[] = [];
vehicle.wheelInfos.forEach(function (wheel) {
  const shape = new CANNON.Cylinder(
    wheel.radius,
    wheel.radius,
    wheel.radius / 2,
    20
  );
  const body = new CANNON.Body({ mass: 1, material: wheelMaterial });
  // 刚体可以是动态(DYNAMIC)、静态(STATIC)或运动学(KINEMATIC)
  body.type = CANNON.Body.KINEMATIC;
  // 0表示这个刚体将与所有其他未设置特定过滤组的刚体进行碰撞检测
  body.collisionFilterGroup = 0;
  // 使用setFromEuler方法将欧拉角转换为四元数,欧拉角的值为-Math.PI / 2(即-90度或-π/2弧度)
  const quaternion = new CANNON.Quaternion().setFromEuler(-Math.PI / 2, 0, 0);
  body.addShape(shape, new CANNON.Vec3(), quaternion);
  wheelBodies.push(body);
  // 创建3d世界的车轮对象
  const geometry = new THREE.CylinderGeometry(
    wheel.radius,
    wheel.radius,
    0.4,
    32
  );
  const material = new THREE.MeshPhongMaterial({
    color: 0xd0901d,
    emissive: 0xaa0000,
    flatShading: true,
    side: THREE.DoubleSide,
  });
  const cylinder = new THREE.Mesh(geometry, material);
  cylinder.geometry.rotateZ(Math.PI / 2);
  wheelVisuals.push(cylinder);
  scene.add(cylinder);
});

这一步很关键,需要在每次物理模拟计算结束后 (postStep事件的回调函数) 更新车轮的位置和转角

// ...
world.addEventListener("postStep", function () {
  for (let i = 0; i < vehicle.wheelInfos.length; i++) {
    vehicle.updateWheelTransform(i);
    const t = vehicle.wheelInfos[i].worldTransform;
    // 更新物理世界车轮对象的属性
    wheelBodies[i].position.copy(t.position);
    wheelBodies[i].quaternion.copy(t.quaternion);
    // 更新3d世界车轮对象的属性
    wheelVisuals[i].position.copy(t.position);
    wheelVisuals[i].quaternion.copy(t.quaternion);
  }
});

车辆行驶和转向

监听键盘事件,按下上下方向键给一个前后的引擎动力,按下左右方向键给车轮一个转角值

// 引擎动力值
const engineForce = 3000;
// 转角值
const maxSteerVal = 0.7;
// 刹车作用力
const brakeForce = 20;
// ...
// 刹车
function brakeVehicle() {
  // 四个车轮全部加刹车作用力
  vehicle.setBrake(brakeForce, 0);
  vehicle.setBrake(brakeForce, 1);
  vehicle.setBrake(brakeForce, 2);
  vehicle.setBrake(brakeForce, 3);
}
function handleNavigate(e: any) {
  if (e.type != "keydown" && e.type != "keyup") {
    return;
  }
  const isKeyup = e.type === "keyup";
  switch (e.key) {
    case "ArrowUp":
      // 给第2/3个车轮加引擎动力
      vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 2);
      vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 3);
      break;
    case "ArrowDown":
      vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 2);
      vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 3);
      break;
    case "ArrowLeft":
      // 设置车轮转角
      vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 2);
      vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 3);
      break;
    case "ArrowRight":
      vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 2);
      vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 3);
      break;
  }
  brakeVehicle();
}
window.addEventListener("keydown", handleNavigate);
window.addEventListener("keyup", handleNavigate);

然后在每一帧里重新计算物体的物理值,并赋值给 3d 世界的小车属性,就可以实现行车效果

function updatePhysics() {
  world.step(1 / 60);
  egoCar.position.copy(chassisBody.position);
  egoCar.quaternion.copy(chassisBody.quaternion);
}
// ...
const animate = () => {
  stats.begin();
  // ...
  updatePhysics();
  // ...
  stats.end();
  requestAnimationFrame(animate);
};
animate();

地面优化

地面看起来太光滑,显得有点假,咱们先给地面加上有磨砂质感的纹理贴图,同时隐藏掉辅助网格

// ...
// 加载纹理贴图
textureLoader.load("/gta/floor.jpg", (texture) => {
  const planeMaterial = new THREE.MeshLambertMaterial({
    // 将贴图对象赋值给材质
    map: texture,
    side: THREE.DoubleSide,
  });
  const plane = new THREE.Mesh(planeGeometry, planeMaterial);
  // 地面接受阴影
  plane.receiveShadow = true;
  plane.rotation.x = Math.PI / 2;
  scene.add(plane);
});

加载完贴图,生成 3d 场景的地面对象后,别忘了创建地面刚体并关联。这里还要定义地面刚体的物理材质,类比 threejs 的材质,会影响不同刚体之间摩擦和反弹的效果

// ...
// 定义地板的物理材质
const groundMaterial = new CANNON.Material("groundMaterial");
// 定义车轮的物理材质,其实之前代码用过了,可以留意下
const wheelMaterial = new CANNON.Material("wheelMaterial");
// 定义车轮和地板之间接触面的物理关联,在这里定义摩擦反弹等系数
const wheelGroundContactMaterial = new CANNON.ContactMaterial(
  wheelMaterial,
  groundMaterial,
  {
    // 摩擦系数
    friction: 0.5,
    // 反弹系数,0表示没有反弹
    restitution: 0,
  }
);
world.addContactMaterial(wheelGroundContactMaterial);
// ...
textureLoader.load("/gta/floor.jpg", (texture) => {
  // ...
  // 地面刚体
  const q = plane.quaternion;
  const planeBody = new CANNON.Body({
    // 0说明物体是静止的,发生物理碰撞时不会相互移动
    mass: 0,
    // 应用接触面材质
    material: groundMaterial,
    shape: new CANNON.Plane(),
    // 和3d场景的旋转值保持一致。在Cannon.js中,刚体的旋转可以通过四元数来表示,而不是传统的欧拉角或轴角表示法
    quaternion: new CANNON.Quaternion(-q._x, q._y, q._z, q._w),
  });
  world.addBody(planeBody);
});

这回开起来可顺畅许多了,场景和自车旋转也变得更自然一些,感谢开源 ~

新车

搭建车库

咱就搭个棚,一个背景墙、两个侧边墙、加一个屋顶和地板,其实都是些立方体,拼装成网格对象 Mesh 后,按照一定的位置和旋转拼在一起组成小车库,参考代码:

createParkingHouse() {
    if (!this.scene || !this.world) return;
    // 创建背景墙
    const background = new THREE.Mesh(
      new THREE.BoxGeometry(3, 4, 0.1),
      new THREE.MeshBasicMaterial({ color: 0xcccccc })
    );
    background.position.set(0, 0, -53);
    this.scene.add(background);
    // 创建侧墙
    const sider1 = new THREE.Mesh(
      new THREE.BoxGeometry(6, 4, 0.3),
      new THREE.MeshBasicMaterial({ color: 0xcccccc })
    );
    sider1.rotation.y = Math.PI / 2;
    sider1.position.set(-1.5, 0.1, -50);
    this.scene.add(sider1);
    const sider2 = new THREE.Mesh(
      new THREE.BoxGeometry(6, 4, 0.3),
      new THREE.MeshBasicMaterial({ color: 0xcccccc })
    );
    sider2.rotation.y = Math.PI / 2;
    sider2.position.set(1.5, 0.1, -50);
    this.scene.add(sider2);
    // 创建屋顶
    const roof = new THREE.Mesh(
      new THREE.BoxGeometry(3, 6, 0.1),
      new THREE.MeshBasicMaterial({
        color: 0xcccccc,
        // 注意:这个值不为true的话,设置opacity是没用的
        transparent: true,
        opacity: 0.8,
      })
    );
    roof.rotation.x = Math.PI / 2;
    roof.position.set(0, 2, -50);
    this.scene.add(roof);
    // 创建地板
    const floor = new THREE.Mesh(
      new THREE.BoxGeometry(3, 6, 0.1),
      new THREE.MeshBasicMaterial({ color: 0x666666 })
    );
    floor.rotation.x = Math.PI / 2;
    floor.position.set(0, 0.1, -50);
    this.scene.add(floor);
}

好了,一个稍微有点模样的小车库就大功告成

小车库

创建车库刚体

先加个背景墙的物理刚体

createParkingHouse() {
    if (!this.scene || !this.world) return;
    // 创建背景墙
    const background = new THREE.Mesh(
      new THREE.BoxGeometry(3, 4, 0.1),
      new THREE.MeshBasicMaterial({ color: 0xcccccc })
    );
    background.position.set(0, 0, -53);
    this.scene.add(background);
    // 创建侧墙
    // ...
    // physic
    const houseShape = new CANNON.Box(new CANNON.Vec3(1.5, 4, 0.1));
    const houseBody = new CANNON.Body({ mass: 0 });
    houseBody.addShape(houseShape);
    houseBody.position.set(0, 0, -53);
    this.world.addBody(houseBody);
}
// ...

其他的墙体类似的处理,屋顶先不管吧,小车应该也够不着。来,先撞一下试试

撞墙模拟

漂移停车

其实达到一定速度,通过方向键就能做一个甩尾漂移倒车入库

  1. 提供一个弹射的初始动力
// ...
animate();
setTimeout(() => {
  // 给后轮上点动力
  vehicle.applyEngineForce(2000, 2);
  vehicle.applyEngineForce(2000, 3);
}, 100);
  1. 电脑端根据方向键触发漂移,这里注意要消除后轮的动力
// ...
case "ArrowLeft":
  vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 2);
  vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 3);
  // 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
  vehicle.applyEngineForce(0, 2);
  vehicle.applyEngineForce(0, 3);
  break;
case "ArrowRight":
  vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 2);
  vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 3);
  // 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
  vehicle.applyEngineForce(0, 2);
  vehicle.applyEngineForce(0, 3);
break;
// ...
  1. 移动端根据触屏方向触发。需要注意此时要把相机控制器关掉,避免和触屏操作冲突。计算触发方向的逻辑参考
// 计算划过的角度
function getAngle(angx: number, angy: number) {
  return (Math.atan2(angy, angx) * 180) / Math.PI;
}
// 计算触屏方向
function getDirection(
  startx: number,
  starty: number,
  endx: number,
  endy: number
): ESlideDirection {
  const angx = endx - startx;
  const angy = endy - starty;
  let result = ESlideDirection.None;
  if (Math.abs(angx) < 2 && Math.abs(angy) < 2) {
    return result;
  }
  const angle = getAngle(angx, angy);
  if (angle >= -135 && angle <= -45) {
    result = ESlideDirection.Top;
  } else if (angle > 45 && angle < 135) {
    result = ESlideDirection.Bottom;
  } else if (
    (angle >= 135 && angle <= 180) ||
    (angle >= -180 && angle < -135)
  ) {
    result = ESlideDirection.Left;
  } else if (angle >= -45 && angle <= 45) {
    result = ESlideDirection.Right;
  }
  return result;
}
let startx = 0;
let starty = 0;
document.addEventListener("touchstart", (e) => {
  startx = e.touches[0].pageX;
  starty = e.touches[0].pageY;
});
document.addEventListener("touchend", function (e) {
  const endx = e.changedTouches[0].pageX;
  const endy = e.changedTouches[0].pageY;
  const direction = getDirection(startx, starty, endx, endy);
  // 根据方向做转向和刹车的处理,和上面电脑侧左右键的逻辑一致就行了
  // ...
});

计算分数

根据小车和车库角度偏差和中心点偏差来综合得分,这里就不细究了,浅浅定个规则:

  • 不入库或没倒车:0 分
  • 其他情况:50 分 + 角度分(20x 比例) + 中心分(30x 比例)

车停住后,先算出分数,再加个数字递增的效果,用 setInterval 实现就好了。不过这里要注意用回调函数的方式更新 state 值,避免闭包引起值不更新的问题

计分组件实现代码参考:

export const Overlay = observer(() => {
  const [score, setScore] = useState(0);
  useEffect(() => {
    if (vehicleStore.score) {
      // 计分动画
      const timer = setInterval(() => {
        // 回调方式更新state
        setScore((score) => {
          if (score + 1 === vehicleStore.score) {
            clearInterval(timer);
          }
          return score + 1;
        });
      }, 10);
    }
  }, [vehicleStore.score]);

  if (!vehicleStore.isStop) {
    return null;
  }

  return (
    <div className={styles["container"]}>
      <div className={styles["score-box"]}>
        <div className={styles["score-desc"]}>得分</div>
        <div>{score}</div>
      </div>
    </div>
  );
});

那么问题来了,怎么监听它停下了?可以加一个速度的阈值 velocityThreshold,如果小车刚体的速度低于这个阈值就判定小车停下了。然后通过 mobx 状态库建立一个 vehicleStore,主要是维护 isStop(是否停止) 和 score(分数) 这两个变量,变化后自动通知计分组件更新,这部分逻辑可以参考源码实现 ~

// ...
const velocityThreshold = 0.01;
function updatePhysics() {
  world.step(1 / 60);
  // ...
  // 检查刚体的速度,小于阈值视为停止
  if (
    chassisBody.velocity.length() < velocityThreshold &&
    // 停车标识
    !vehicleStore.isStop
  ) {
    console.log("小车已经停止");
    vehicleStore.stop();
    // 触发计分逻辑,自行参考源码
    // ...
    vehicleStore.setScore(score);
  }
}
// ...

最终效果

在线体验,手机可以扫码快速体验(首次加载有点卡,多刷新下看看,后面再琢磨优化下叭 ~)

体验二维码

传送门