threejs动画和自动驾驶的那些事

有时候为了提高智驾 3d 场景的视觉体验,可能会提一些动画的需求,比如扩散光环、雷达波、避障警告和泊车指示等,这些其实涉及到 threejs 渲染循环、补间动画、场景亮度和相机等的配合

动画最基础的概念是 关键帧,每个关键帧由三部分组成:时间、属性和值

例如定义下面几帧,针对 mesh 某些属性值做一些变化:

  • 在 0 秒 position是(0,0,0)
  • 在 3 秒 position是(1,0,0) scale是(2,2,2)
  • 在 6 秒 position是(2,0,0) scale是(3,3,3)

这个动画就是物体随着时间沿着 x 轴平移并且慢慢变大。动画说白了其实就是一帧帧图像组合起来的,只要帧率够高,人的眼睛就感觉不到卡顿。其实我们这个 autopilot 应用本身就有一个渲染循环,所以可以根据时间、距离等值的变化来动态修改几何体或材质的属性从而实现一些动画效果,比如让一个 mesh 绕 z 轴自旋转:

// ...
renderer.setAnimationLoop(animate);
function animate() {
  controls.update();
  // 每帧绕z轴旋转0.01弧度
  mesh.rotateZ(0.01);
  renderer.render(scene, camera);
}

而为了确保动画效果平滑,我们可以借助 tween.js来做补间动画,这是 官方使用 tween.js 的示例。下文都是用 tween.js 来做补间动画,注意别装错包了 pnpm i @tweenjs/tween.js

扩散光环

可以在自车底部用一个带渐变色的扩散光环表示自车处于智驾状态,通过光环颜色、渐变程度或者其他样式可以达到区分不同功能状态的效果

贴图版本

这个方法就比较简单了,通过创建一个 CircleGeometry 几何体,然后把贴图加载上去,动态调整大小 scale 和透明度 opacity 来实现扩散效果。先封装一下自车类,便于后续针对自车增加功能。这里加载模型和贴图的函数都是之前封装过的,如果代码理解起来有些吃力建议先看下专栏之前的文章

// src/renderer/egoCar/index.ts
import * as THREE from "three";
import carModelWithDraco from "@/assets/models/su7-draco.glb";
import haloImg from "@/assets/textures/halo.png";
import { abortWrapper } from "../../helper/promise";
import { loadDracoGLTFWithPromise, loadTexture } from "../../helper";

export default class EgoCar {
  scene = new THREE.Scene();
  constructor(scene: THREE.Scene) {
    this.scene = scene;
    this.initialze();
  }

  loadEgoCar() {
    const loadEgoCar = abortWrapper(
      loadDracoGLTFWithPromise(carModelWithDraco)
    );
    return loadEgoCar.then((gltf) => {
      const car = gltf.scene;
      car.scale.set(0.1, 0.1, 0.1);
      car.rotateX(Math.PI / 2);
      car.rotateY(Math.PI);
      this.scene.add(car);
    });
  }

  async initialze() {
    await this.loadEgoCar();
    await this.drawDynamicHalo();
  }

  // 绘制光环
  async drawDynamicHalo() {
    const egoCarHalo = await loadTexture(haloImg);
    const geometry = new THREE.CircleGeometry(1, 32);
    const material = new THREE.MeshBasicMaterial({
      map: egoCarHalo,
      transparent: true,
    });
    const mesh = new THREE.Mesh(geometry, material);
    mesh.position.z = 0.02;
    this.scene.add(mesh);
  }
}

接下来做个动画,用 tween 做个补间动画,通过调整光环半径和透明度,从而实现动态的扩散光环

async drawDynamicHalo() {
    // ...
    const tweenScale = new Tween(mesh.scale)
      .to({ x: 6, y: 6, z: 1 }, 1000)
      .easing(Easing.Quadratic.In)
      .start();
    // 光环消失前做个透明度渐变
    const tweenOpacity = new Tween(mesh.material).to({ opacity: 0.1 }, 500);
    // 衔接两种补间动画
    tweenScale.chain(tweenOpacity);
    tweenOpacity.chain(tweenScale);
    // 先用定时器做更新,不过还是建议放到渲染循环里
    setInterval(() => {
      tweenScale.update();
      tweenOpacity.update();
    }, 50);
}

贴图版本的扩散光环

感觉 tween 的链式调用好优雅呀 ~ 有 jq 那味

旋转角度时,会发现下方的车道线会消失,这是什么情况?其实是深度测试的问题,这里把光环材质的 depthWrite 属性设置为 false 可以解决,这个值表示渲染这个材质对深度缓冲区无影响,也就解决了遮挡问题。进一步了解深度测试可以参考 这篇文章

shader 版本

贴图的版本其实比较简单,在纯展示的场景下可以应付,不过如果需要变色或者做局部变形的,建议用自定义 shader 来实现。对于自定义 shader 材质和 glsl 语言不熟悉的,可以参考下我之前写的 threejs 自定义 shader 实现线元素。和一开始贴图的效果类似,最外层是传入的颜色,然后往中间做个透明度的线性变化。这里用到了 smoothstep 函数,它可以用来生成 0 到 1 的平滑过渡值

export function getHaloShader(option: {
  // 最大半径
  radius?: number;
  opacity?: number;
  // 颜色定义
  color?: string;
}) {
  const material = new THREE.ShaderMaterial({
    uniforms: {
      radius: { value: option.radius ?? 1 },
      opacity: { value: option.opacity ?? 1.0 },
      color: {
        value: option.color ?? new THREE.Color("#00ffff"),
      },
    },
    vertexShader: `
      varying vec2 vUv;
      void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
    fragmentShader: `
      uniform vec3 color;
      uniform float opacity;
      varying vec2 vUv;
      void main() {
        float radius = length(vUv - 0.5); // uv坐标到中心的距离
        float alpha = smoothstep(0.36, 0.5, radius) * opacity;
        gl_FragColor = vec4(color, alpha);
      }
    `,
  });
  material.transparent = true;
  material.side = THREE.FrontSide;
  return material;
}

自车shader光环

ok 很像了,接下来搬出 tween.js 实现补间动画,把刚才那段代码加回去就行了

// ...
const tweenScale = new Tween(mesh.scale)
  .to({ x: 6, y: 6, z: 1 }, 1000)
  .easing(Easing.Quadratic.In)
  .start()
  .onStart(() => {
    mesh.material.opacity = 1;
  });
const tweenOpacity = new Tween(mesh.material).to({ opacity: 0.1 }, 300);
// 衔接两种补间动画
tweenScale.chain(tweenOpacity);
tweenOpacity.chain(tweenScale);
// 建议放到渲染循环里
setInterval(() => {
  tweenScale.update();
  tweenOpacity.update();
}, 50);

自车shader光环

但实际上还能做下优化,多加几个光圈,类似雷达波效果,这里先加三层光圈就行了,后面俩道光环滞后一定时间(tween 的 start 方法可以指定滞后时间)再开始做动画。这糙代码做个参考就行,其实还能做下封装 ~

// ...
const tweenScale = new Tween(mesh.scale)
  .to({ x: 12, y: 12, z: 1 }, 3000)
  .start(500)
  .onStart(() => {
    mesh.material.uniforms.opacity.value = 1;
  });
// 注意自定义shaderMaterial得修改uniforms的opacity值
const tweenOpacity = new Tween(mesh.material.uniforms.opacity).to(
  { value: 0.1 },
  500
);
const tweenScale2 = new Tween(mesh2.scale)
  .to({ x: 12, y: 12, z: 1 }, 3000)
  .start(2000)
  .onStart(() => {
    mesh2.material.uniforms.opacity.value = 1;
  });
const tweenOpacity2 = new Tween(mesh2.material.uniforms.opacity).to(
  { value: 0.1 },
  500
);
const tweenScale3 = new Tween(mesh3.scale)
  .to({ x: 12, y: 12, z: 1 }, 3000)
  .start(3000)
  .onStart(() => {
    mesh3.material.uniforms.opacity.value = 1;
  });
const tweenOpacity3 = new Tween(mesh3.material.uniforms.opacity).to(
  { value: 0.1 },
  500
);
// 衔接缩放和透明度的动画
tweenScale.chain(tweenOpacity);
tweenOpacity.chain(tweenScale);
tweenScale2.chain(tweenOpacity2);
tweenOpacity2.chain(tweenScale2);
tweenScale3.chain(tweenOpacity3);
tweenOpacity3.chain(tweenScale3);
// 可以直接用定时器做更新
setInterval(() => {
  tweenScale.update();
  tweenOpacity.update();
  tweenScale2.update();
  tweenOpacity2.update();
  tweenScale3.update();
  tweenOpacity3.update();
}, 50);

shader雷达波

这里抛个问题,怎么实现特定角度范围的雷达波?后面有机会再写写

行人动画

先要理解 threejs 的动画系统 的几个概念:关键帧 >> 轨迹(帧集合)>> 动画片段(AnimationClip) >> 动画播放控制(AnimationMixer) ,主要是动画片段 AnimationClip 和关键帧轨道 KeyframeTrack 的理解。可以参考 animation-system 解释的很详细。我们可以去官网找一个人物模型来模拟 https://threejs.org/examples/#webgl_animation_multiple。有几点需要注意:

  • 建模时需要设置动画片段,导出后动画信息存在于模型的 animations (AnimationClip 数组)上。但并不是所有模型都支持设置动画,比如 obj 就不支持
  • 这个模型主要有三个动画片段(站立呼吸/行走/奔跑),将它们一块展示出来看看,这里需要用 SkeletonUtils clone 来复制模型,因为模型自带的 clone 可能会导致骨骼与网格之间的关联丢失或出错

接下来封装一下行人的类:

// src/renderer/robot.ts
import * as THREE from "three";
import robotModel from "@/assets/models/robot.glb";
import { loadGLTFWithPromise } from "../helper";
import { SkeletonUtils } from "three/examples/jsm/Addons.js";

export default class Robot {
  scene = new THREE.Scene();
  renderer = new THREE.WebGLRenderer();
  skeleton: any = null;
  mixer: any = null;
  mixers: any[] = [];
  actions: any[] = [];
  clock = new THREE.Clock();
  constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {
    this.scene = scene;
    this.renderer = renderer;
    this.initialze();
  }

  loadRobotModel() {
    let self = this;
    const loadEgoCar = loadGLTFWithPromise(robotModel);
    return loadEgoCar.then((gltf) => {
      const robot = gltf.scene;
      robot.scale.set(0.15, 0.15, 0.15);
      robot.position.set(0.5, -0.5, 0.02);
      robot.rotateX(Math.PI / 2);
      const clips = gltf.animations;
      robot.traverse(function (object) {
        // @ts-ignore
        if (object.isMesh) object.castShadow = true;
      });
      // ...
    });
  }
  async initialze() {
    await this.loadRobotModel();
  }
}

把 animations 数据打印出来看看

animation-data

可以看到,这里总共包含了四个动画片段,TPose 那个大家有兴趣的话可以实践下看看是啥动作 ~ 这里基本上跟之前讲的一样,层级结构从上至下就是动画片段、帧轨迹、关键帧。那怎么把这个动画片段播放出来,这里就需要借助 THREE.AnimationMixer,传入模型后,配合 clipAction可以做对应模型一个或多个动画片段的播放控制

// ...
let self = this;
const model1 = SkeletonUtils.clone(robot);
const model2 = SkeletonUtils.clone(robot);
const model3 = SkeletonUtils.clone(robot);
const mixer1 = new THREE.AnimationMixer(model1);
const mixer2 = new THREE.AnimationMixer(model2);
const mixer3 = new THREE.AnimationMixer(model3);
model1.position.x = -1;
model2.position.y = -1;
model3.position.y = 1;
mixer1.clipAction(clips[0]).play(); // idle
mixer2.clipAction(clips[1]).play(); // run
mixer3.clipAction(clips[3]).play(); // walk
this.scene.add(model1, model2, model3);
this.mixers.push(mixer1, mixer2, mixer3);
// 建议放到渲染循环里
setInterval(() => {
  animate();
}, 50);
function animate() {
  const delta = self.clock.getDelta();
  for (const mixer of self.mixers) mixer.update(delta);
}

行人动画

最后

emm 这篇肝了蛮久,主要分享了下智驾 3d 场景里面可能涉及的关于 threejs 的一些动画实现,当然这种动效对于智驾并不是强需求,只是作为视觉体验的补充