之前在 绘制他车参与物 用 TextGeometry 和 FontLoader 实现了 3d 文字,但其实能展示的内容比较有限,并且观察受视角影响,比较简单的解决方法是用 2d 的悬浮标签卡片(DOM 元素)来展示更多的信息,支持点击 3d 物体打开标签文本,并且能实时跟随物体。
Raycaster
光线投射 Raycaster[1]主要用于进行鼠标拾取,帮助我们在三维场景里计算出鼠标点击到的物体。因为在 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);
当鼠标移过 mesh 时,收集到当前鼠标的屏幕坐标,将其归一化为标准设备坐标(Normalized Device Coordinates,NDC)。这个转换过程可以参考下图,首先是明确 canvas 的标准设备坐标系是中点为(0,0),然后 x/y 轴范围在(-1,1)之间(和 canvas 坐标系是有差异的,比如 y 轴方向和归一化),然后再思考怎么将屏幕坐标系的坐标(下图蓝色)转换为标准设备坐标(下图红色)
渲染循环中更新射线,也就是更改 pointer,这条射线指的是从 camera 发出并指向 pointer 的射线
计算 3d 场景中与射线相交的所有物体 intersects,这里面会涉及到矩阵变化和投影计算
将经过的物体材质设置为红色。如下图的视椎体示例:
标签卡片
这个卡片主要是放在自车、参与物或障碍物上方,用于显示一些信息,比如自车或他车的 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 挂载到 cube 的 userData上(如果需要支持点击显示,那别放到 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 几个他车的数据,看下行驶后标签文本跟随的效果
目前还是纯前端模拟行驶动画,正常业务场景下应该是算法数据驱动,后面把数据链路和场景元素都完善了再补一个更准确的场景吧
最后
源码:
https://github.com/GitHubJackson/autopilot/tree/v0.1.8
原文:
https://blog.zhouweibin.top/autopilot/eight/
光线投射 Raycaster: https://threejs.org/docs/#api/zh/core/Raycaster
作者:_lucas
链接:https://blog.zhouweibin.top/autopilot/eight/
本文已获得作者授权,如需转载请联系作者!