threejs实现跟随物体的2d标签文本

之前在 绘制他车参与物TextGeometryFontLoader 实现了 3d 文字,但其实能展示的内容比较有限,并且观察受视角影响,比较简单的解决方法是用 2d 的悬浮标签卡片(DOM 元素)来展示更多的信息,支持点击 3d 物体打开标签文本,并且能实时跟随物体

Raycaster

光线投射 Raycaster主要用于进行鼠标拾取,帮助我们在三维场景里计算出鼠标点击到的物体。因为在 threejs 场景里面渲染一个物体是三维形式的,但是最终展示在屏幕上都是二维的,这里是先将三维的世界坐标经过矩阵变换和投影计算,最终算出它在屏幕上对应的位置,主要方法是 raycaster.intersectObjects(objects: Array,recursive:Boolean,optionalTarget:Array)。当第二个参数设置为true时,intersectObjects方法会递归检查传入对象的所有后代对象,不仅检查传入的直接对象,还会检查该对象的所有子对象等

从下面这段官方示例出发:

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function onPointerMove(event) {
  // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1,1)
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
function render() {
  // 通过相机和鼠标位置更新射线
  raycaster.setFromCamera(pointer, camera);
  // 计算出和射线相交的物体
  const intersects = raycaster.intersectObjects(scene.children);
  for (let i = 0; i < intersects.length; i++) {
    intersects[i].object.material.color.set(0xff0000);
  }
  renderer.render(scene, camera);
}
window.addEventListener("pointermove", onPointerMove);
  1. 当鼠标移过 mesh 时,收集到当前鼠标的屏幕坐标,将其归一化为标准设备坐标(Normalized Device Coordinates,NDC)。这个转换过程可以参考下图,首先是明确 canvas 的标准设备坐标系是中点为(0,0),然后 x/y 轴范围在(-1,1)之间(和 canvas 坐标系是有差异的,比如 y 轴方向和归一化),然后再思考怎么将屏幕坐标系的坐标(下图蓝色)转换为标准设备坐标(下图红色)

屏幕坐标系和标准设备坐标系

  1. 渲染循环中更新射线,也就是更改 pointer,这条射线指的是从 camera 发出并指向 pointer 的射线
  2. 计算 3d 场景中与射线相交的所有物体 intersects,这里面会涉及到矩阵变化和投影计算
  3. 将经过的物体材质设置为红色。如下图的视椎体示例:

射线图示

标签卡片

这个卡片主要是放在自车、参与物或障碍物上方,用于显示一些信息,比如自车或他车的 id、类型、速度和大小等信息。和上面的示例一样,主要实现原理是世界坐标和屏幕坐标的互相转换,然后用携带指定样式的 div 来显示那些文本信息,并且在实时场景下,能跟随在参与物的上方

// dom节点操作
const dom = document.createElement("div");
dom.setAttribute("id", egoCarLabelString);
dom.setAttribute("class", "label-box");
// 往canvas画布添加绝对定位的悬浮dom
canvasContainer.appendChild(dom);
// 移除dom
// canvasContainer.appendChild(dom);
// 如果不是第一次生成,只需要调整display就行
// dom.style.display = 'none' | 'block'
// ...

标签文本的样式参考,然后通过 translate 实现移动,可以实现标签文本跟随物体的效果

/* 标签文本样式 */
.label-box {
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  padding: 2px;
  color: #fff;
  font-size: 10px;
  border-radius: 2px;
  background-color: rgba(0, 0, 0, 0.6);
}

全局变量

简单点直接挂载到 window 变量上(后面计划引入 mobx 来维护全局 store),这里先存一下画布的 dom 节点和宽高信息,然后别忘了在页面 resize 的时候更新下宽高

// src/renderer/index.ts
initialize() {
    const container = document.getElementById("my-canvas");
    const width = container.offsetWidth,
      height = container.offsetHeight;
    window.canvasRef = {
      container,
      width,
      height,
    };
}
// ...
window.addEventListener("resize", this.onResize, false);
onResize() {
    const container = document.getElementById("my-canvas");
    const width = container.offsetWidth,
      height = container.offsetHeight;
    // 更新画布宽高
    window.canvasRef.width = width;
    window.canvasRef.height = height;
}

点击显示

比如我们要在 3d 场景的自车附近支持点击打开一个展示自车详细信息的标签卡片,先监听 canvas 节点的点击事件:

export default class EgoCar {
  constructor(scene: THREE.Scene) {
    this.scene = scene;
    this.initialze();
    this.clickObject = this.clickObject.bind(this);
    window.canvasRef.container.addEventListener("click", this.clickObject);
  }
  clickObject() {}
  // ...
}

然后在点击事件里判断射线和自车是否相交,是的话将 label 卡片显示出来,显示出来后加个状态锁 showLabel,说明当前已打开标签卡片,如果再点击则视为关闭标签卡片,所以其实会有两次坐标转换:

  • 点击自车时,从屏幕坐标转世界坐标,才能判断是否点击到了自车
  • 标签显示时,从世界坐标转屏幕坐标,让标签卡片显示在正确的屏幕位置

当然你也可以用 CSS2DRenderer 这个扩展库来简化上述坐标转换的代码

// ...
showLabel = false;
// 自车详细信息
carData = {
    name: "egoCar",
    velocity: {
      x: 10,
      y: 20,
    },
};
// ...
  clickObject(e: any) {
    const canvasRef = window.canvasRef;
    const mouseVector = new THREE.Vector2();
    const raycaster = new THREE.Raycaster();
    mouseVector.x = (e.offsetX / canvasRef.width) * 2 - 1;
    mouseVector.y = -(e.offsetY / canvasRef.height) * 2 + 1;
    raycaster.setFromCamera(mouseVector, this.camera);
    // 第二个参数是指是否递归检查
    const intersects = raycaster.intersectObjects(this.car.children, true);
    if (intersects.length > 0) {
      this.triggerLabelBox();
    }
  }

  triggerLabelBox() {
    const canvasContainer = this.container!;
    const dom = document.getElementById(egoCarLabelString);
    if (!dom) {
      const newBox = document.createElement("div");
      newBox.setAttribute("id", egoCarLabelString);
      newBox.setAttribute("class", "label-box");
      canvasContainer.appendChild(newBox);
      this.updateLabelBox(newBox);
      this.showLabel = true;
    } else {
      if (this.showLabel) {
        dom.style.display = "block";
        this.updateLabelBox(dom);
      } else {
        dom.style.display = "none";
      }
    }
  }

  updateLabelBox(dom: HTMLElement) {
    const canvasRef = window.canvasRef;
    const x = this.group.position.x;
    const y = this.group.position.y;
    const vector = new THREE.Vector3(x, y, 0.1);
    // 将世界坐标转为标准设备坐标
    vector.project(this.camera);
    const w = canvasRef.width / 2;
    const h = canvasRef.height / 2;
    const offsetX = Math.round(vector.x * w + w);
    const offsetY = Math.round(-vector.y * h + h);
    dom.innerText = `${this.carData.name}\nvx:${this.carData.velocity.x} vy:${this.carData.velocity.y}`;
    dom.style.transform = `translate(${offsetX}px,${offsetY}px)`;
  }

自车标签文本

自动显示

先关联下他车 id 和对应的cube,先将 id 挂载到 cubeuserData上(如果需要支持点击显示,那别放到 cube 对象上,因为它是一个 Group,射线会检测不出来,这时候可以放到 cube 的第一个子 mesh 上)然后可以把需要显示到标签文本的信息比如长宽高、type 和速度等信息挂载上去

// src/renderer/cube.ts
// ...
draw(datas: ICube[]) {
    // 遍历创建cube group
    datas.forEach((data) => {
        const group = new THREE.Group();
        // ...
        group.userData.id = data.id;
        group.userData.type = data.type;
        group.userData.width = data.width;
        group.userData.height = data.height;
        this.scene.add(group);
    })
 }

他车参与物在道路场景里是经常变化的,它们也可以展示一些标签卡片,并且随着参与物位置的变化实时变化标签卡片的位置。不过他车可能还会多一些展示信息比如 id 和类别等,并且这里需要将他车 id 和标签卡片的 dom id 关联起来,方便后续查询并更新标签卡片内容。主体坐标转换的逻辑和自车的标签卡片是一样的,代码参考以下:

// src/renderer/cube.ts
// ...
  triggerLabelBox() {
    const canvasContainer = window.canvasRef.container!;
    this.cubes.forEach((cube) => {
      // 关联他车id和标签文本的dom节点,便于后续查询和更新
      const dom = document.getElementById(`cube-label-${cube.id}`);
      if (!dom) {
        const newBox = document.createElement("div");
        newBox.setAttribute("id", `cube-label-${cube.id}`);
        newBox.setAttribute("class", "label-box");
        canvasContainer.appendChild(newBox);
        this.updateLabelBox();
      } else {
        dom.style.display = "block";
        this.updateLabelBox();
      }
    });
  }

  updateLabelBox() {
    const canvasRef = window.canvasRef;
    this.cubes.forEach((cube) => {
      const dom = document.getElementById(`cube-label-${cube.id}`);
      if (dom) {
        const x = cube.position.x;
        const y = cube.position.y;
        const vector = new THREE.Vector3(x, y, 0.1);
        // 将世界坐标转为标准设备坐标
        vector.project(this.camera);
        const w = canvasRef.width / 2;
        const h = canvasRef.height / 2;
        const offsetX = Math.round(vector.x * w + w);
        const offsetY = Math.round(-vector.y * h + h);
        dom.innerText = `${cube.userData.id}-${cube.userData.type}\nsize:[1.3,2.4,1.2]`;
        dom.style.transform = `translate(${offsetX}px,${offsetY}px)`;
      }
    });
  }

但这里要注意下他车数量可能很多,会造成 dom 节点过多且经常回流重绘的情况,这里最起码需要确保的一点是,在他车或障碍物不可见的时候,将对应的标签卡片的 dom 节点移除掉。判断可见的逻辑可以参考:

// 1.有些他车不在视椎体范围内,但仍然有数据,可以把标签文本移除掉
const vector = new THREE.Vector3(x, y, height / 2);
const temp = vector
  .applyMatrix4(this.camera.matrixWorldInverse)
  .applyMatrix4(this.camera.projectionMatrix);
if (Math.abs(temp.x) > 1 || Math.abs(temp.y) > 1 || Math.abs(temp.z) > 1) {
  // 在视野外,移除对应dom节点
  window.canvasRef.container.removeChild(dom);
} else {
  // 在视野内,更新文本
}
// 2.上游数据主动将他车移除掉的时候,也要同步做下移除dom节点

ok,mock 几个他车的数据,看下行驶后标签文本跟随的效果

标签文本跟随动画

目前还是纯前端模拟行驶动画,正常业务场景下应该是算法数据驱动,后面把数据链路和场景元素都完善了再补一个更准确的场景吧

最后

  • 仓库地址
  • 原文地址