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

之前在 绘制他车参与物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) {
  
  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. 当鼠标移过3d物体时,收集到当前鼠标的屏幕坐标,将其归一化为标准设备坐标(Normalized Device Coordinates,NDC)。这个转换过程可以参考下图,首先是明确webgl的标准设备坐标系是中点为(0,0),然后x/y轴范围在(-1,1)之间(和canvas坐标系是有差异的,比如y轴方向和归一化),然后再思考怎么将屏幕坐标系的坐标(下图蓝色)转换为标准设备坐标(下图红色)

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

标签卡片

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


const dom = document.createElement("div");
dom.setAttribute("id", egoCarLabelString);
dom.setAttribute("class", "label-box");

canvasContainer.appendChild(dom);





标签文本的样式参考,然后通过 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 的时候更新下宽高


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和速度等信息挂载上去



draw(datas: ICube[]) {
    
    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关联起来,方便后续查询并更新标签卡片内容。主体坐标转换的逻辑和自车的标签卡片是一样的,代码参考以下:



  triggerLabelBox() {
    const canvasContainer = window.canvasRef.container!;
    this.cubes.forEach((cube) => {
      
      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节点移除掉。判断可见的逻辑可以参考:


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) {
    
    window.canvasRef.container.removeChild(dom);
  } else {
    
  }
  

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

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

最后

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=21139,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?