Skip to main content

2 posts tagged with "camera"

View All Tags

· 12 min read

之前在 绘制他车参与物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 几个他车的数据,看下行驶后标签文本跟随的效果

标签文本跟随动画

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

最后

  • 仓库地址
  • 原文地址

· 8 min read

在智驾场景里,元素的坐标数据可能基于自车坐标系(自车不动)或者原点坐标系(自车动)。为了简单点,autopilot 先基于原点坐标系来 mock 道路和障碍物等,然后回基于 tween 来做行驶的动画演示,既然是原点坐标系,那意味着自车需要实时更新位置信息和偏转方向,所以就需要实现跟车相机

跟车相机

跟车相机朝向约定为 x 轴正向和 y 轴正向,这样在俯视视角正符合二维坐标轴的情况,便于后面 mock 行车的数据

camera.up.set(0, 0, 1);
camera.position.set(-4, -0.4, 1.4);

自车 Group

这里先将自车携带的元素加到一个 Group里,比如车灯、扩散光环等,便于在自车更新位置和朝向的时候统一更新

export default class EgoCar {
group = new THREE.Group();

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 / 2);
// 车灯和扩散光环类似,通过group.add加入
this.group.add(car);
this.scene.add(this.group);
});
}
}

lookAt

在渲染循环里动态更新相机位置,始终位于相机后上方,用 lookAt 让相机始终朝向目标,然后用 tween 模拟一段向前行驶的动画

// ...
const egoCar = new EgoCar(scene);
this.camera.lookAt(egoCar.group.position);
// ...
function animate() {
controls.update();
camera.position.x = egoCar.group.position.x - 5;
camera.position.y = egoCar.group.position.y - 0.4;
camera.lookAt(egoCar.group.position);
renderer.render(scene, camera);
}
// ...
// 模拟向前行驶
runEgoCar() {
if (this.egoCar) {
const animate = new Tween(this.egoCar.group.position)
.to({ x: 10, y: 0, z: 0 }, 5000)
.easing(Easing.Quadratic.InOut)
.start();
setInterval(() => {
animate.update();
}, 50);
}
}

但这里后发现OrbitControls并不能正确转动了,因为在渲染循环里动态改了相机位置,导致控制相机也始终固定在相机那个位置

camera.position.x = egoCar.group.position.x - 5;
camera.position.y = egoCar.group.position.y - 0.4;

这里可以尝试加一个 fakeCamera 作为控制器的辅助相机,camera 始终与其同步,比如旋转相机时更新相机的顺序:fakeCamera > camera,然后再根据自车运动距离进一步更新相机位置,并且可以把更新相机的函数抽出来

// ...
function updateCamera() {
const position = egoCar.group.position;
// 将 fakeCamera 的属性同步给 camera
camera.copy(fakeCamera);
const x = fakeCamera.position.x;
const y = fakeCamera.position.y;
// 相机和自车保持一个固定的偏移
camera.position.x = position.x + x;
camera.position.y = position.y + y;
}
function animate() {
updateCamera();
controls.update();
renderer.render(scene, camera);
}

自车直线行驶

转向

一般情况下可以从规控数据中拿到自车偏转角 yaw,可以转成 x/y 轴平面的弧度值。同样这里先用 tween 模拟一下自车转向,需要加多几组动画。这里怎么统一更新 tween 动画?类似于 THREE.Group,tween 也支持 Group,可以统一管理一组动画的更新,在旧版本的 tween 可以直接用 TWEEN.update,新版已经标记为弃用了

const tweenGroup = new TWEEN.Group();
// ...
const animate = () => {
this.updateCamera();
// 统一更新动画
tweenGroup.update();
this.controls!.update();
this.renderer.render(scene, camera);
};
// ...
runEgoCar() {
if (this.egoCar) {
const animate2 = new Tween(this.egoCar.group.position)
.to(
{
y: -0.5,
},
2000
)
.start();
const animate = new Tween(this.egoCar.group.position)
.delay(500)
.to(
{
x: 10,
},
5000
)
.easing(Easing.Quadratic.In)
.start();
const rotationAnimate = new Tween(this.egoCar.group.rotation)
.to(
{
z: -Math.PI / 4,
},
1200
)
.start()
.onComplete(() => {
const rotationAnimate2 = new Tween(this.egoCar!.group.rotation)
.to(
{
z: 0,
},
1600
)
.start();
tweenGroup.add(rotationAnimate2);
});
tweenGroup.add(animate, animate2, rotationAnimate);
}
}

更新自车转向的时候,相机也应该有一个同样的偏转,目标就是让相机能和自车保持相对静止。这里主要就是做一些正余弦计算来获取更新后的相机位置,计算逻辑参考代码:

const fakeCameraDirection = new THREE.Vector3();
// ...
updateCamera = () => {
// 将 fakeCamera 的属性同步给 camera,也就是旋转或缩放场景后更新的相机属性
this.camera.copy(this.fakeCamera);
const position = this.egoCar.group.position;
const rotation = this.egoCar.group.rotation;
const x = this.fakeCamera.position.x;
const y = this.fakeCamera.position.y;
// 获取相机视线的方向向量
this.fakeCamera.getWorldDirection(fakeCameraDirection);
// 计算相机方向在xy平面上的弧度值
const directionTheta = Math.atan2(
fakeCameraDirection.y,
fakeCameraDirection.x
);
const camera2egocarDistance = Math.sqrt(x * x + y * y);
this.camera.position.x =
position.x - camera2egocarDistance * Math.cos(rotation.z + directionTheta);
this.camera.position.y =
position.y - camera2egocarDistance * Math.sin(rotation.z + directionTheta);
this.camera.lookAt(position.x, position.y, position.z);
};

自车转向

更新视角

主要是跟车视角和俯视视角的切换,或者可以记住用户自定义的视角。这里先看下怎么支持做这个切换,之前其实简单加过一版视角切换,但因为我们新增了 fakeCamera 所以这里要更新下实现逻辑

// src/renderer/index.ts
// ...
switchCameraView(view = EViewType.FollowCar) {
this.cameraView = view;
switch (view) {
// 跟车
case EViewType.FollowCar: {
this.resetFakeCamera();
this.fakeCamera.position.set(-4, -0.4, 1.4);
break;
}
// 俯视横向
case EViewType.Overlook: {
this.resetFakeCamera();
this.fakeCamera.position.set(0, 0, 20);
break;
}
// 俯视纵向
case EViewType.OverlookVertical: {
this.resetFakeCamera();
this.fakeCamera.position.set(0, 0, 20);
this.controls.rotate(Math.PI / 2);
break;
}
default:
break;
}
}

但是新版的 controls 没有直接暴露 rotate 方法,因为我之前用的 three 旧版本有提供这个方法,可以很方便地改变控制相机的方向,暂时还没找到平替的方法(知道的大佬可以帮忙解答一下 thx~ three 官方也有相关issueissue2)这里我先直接改的源文件,增加了一个 rotate 方法,文件位置在src/helper/three/OrbitControls.js,然后再重新引入

// src/helper/OrbitControls.js
// ...
this.rotate = function (degrees) {
rotateLeft(degrees);
this.update();
};
// src/renderer/index.ts
import { OrbitControls } from "../helper/three/OrbitControls.js";

自车转向

观察相机

需要一个辅助相机,然后借助 CameraHelper来观察我们正在用的透视相机

// 辅助相机
const camera2 = new THREE.PerspectiveCamera(45, width / height, 0.01, 1000);
camera2.position.set(-10, -5, 4);
// camera2.lookAt(0, 0, 0);
camera2.up.set(0, 0, 1);
// 观察原有相机
const cameraHelper = new THREE.CameraHelper(camera);
scene.add(cameraHelper);
const controls = new OrbitControls(camera2, renderer.domElement);
this.controls = controls;
const animate = () => {
// ...
this.renderer.render(scene, camera2);
};

观察相机

PerspectiveCamera(fov: number, aspect: number, near: number, far: number)可以调节相机参数直观地看看效果,这里也可以换成正交相机试试

  • fov — 摄像机视锥体垂直视野角度
  • aspect — 摄像机视锥体长宽比
  • near — 摄像机视锥体近端面
  • far — 摄像机视锥体远端面

最后