Skip to main content

9 posts tagged with "自动驾驶"

View All Tags

· 18 min read

autopilot 需要数据才能跑起来,想了下自己造数据太麻烦了,所以基于 konva(canvas)做一个场景编辑器来造点数据,目标是通过编辑器编辑地图和基于地图编辑一个基础场景(自车+车道+环境车+行驶模拟),并在 autopilot 应用中跑起来

这里咱直接用成熟的 canvas 库 konva 来搭建页面,这个库可以先用官网的 editor-demo 体验下。konva 的图层层级结构分为 Stage -> Layer(层) (-> Group) -> Shape(元素),可以先熟悉下这几个概念,不熟的话可能比较难以看懂下面的示例代码

页面布局

稍微设计一下:中间画布+顶部操作栏+右侧属性面板。关于组件库的话,antd 够用了,如下图所示:

页面布局

暂时先只做一个地图和一个场景,后面再扩展下多对多的情况

数据管理

通过 mobxmobx-react-lite 来管理编辑器的数据。新建变量的时候,很多情况下你只需要一个全局变量而不是响应式变量,所以还是建议在 makeObservable 指定变量是否可观察(而不是一味 makeAutoObservable 虽然用这个有点爽),另外可以善用下observable.shallow 做浅层的观察,毕竟复杂的大对象在生成响应式对象时还是有一定开销的

// store/index.ts
import Konva from "konva";
import { makeObservable, observable, computed, action } from "mobx";

class EditorStore {
stage: IStage = {
ref: null, // 暂存konva画布实例
};
editMode = EditMode.Map; // 编辑模式,分为地图和场景编辑
isEdit = false; // 编辑锁
isDrawLine = false; // 画线锁
// 统一处理场景元素的绘制
drawCallForScene = (type: ESceneElement) => {
this.isEdit = true;
switch (type) {
case ESceneElement.Vehicle: {
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// 统一处理地图元素的绘制
drawCallForMap = (type: EMapElement) => {
this.isEdit = true;
switch (type) {
case EMapElement.Line: {
this.isDrawLine = true;
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// 在初始化画布时执行,挂载画布事件
initStage = () => {
const stage = this.stage.ref!;
const layer = stage.getLayers()[0];
// ...
};
// 地图
mapList = [];
currentMap = "";
// 场景
sceneList = [];
currentScene = "";
// 元素
selectedElement = null;
// 当前选中的元素的属性,基础属性包括位置、颜色、旋转、大小、名称等
selectedElementProps = null;

constructor() {
makeObservable(this, {
stage: observable.shallow,
editMode: observable,
// ...文章代码可能不全,感兴趣自行查阅github源码
});
}
}

初始化页面

新建一个 react 路由页面,初始化画布,监听画布事件比如点击、鼠标事件等,并自适应容器宽高

// scene-editor/index.tsx
const SceneEditor = observer(() => {
const { stage } = editorStore;
const containerRef = useRef<HTMLDivElement>(null);
const [shapes, setShapes] = useState<Konva.ShapeConfig[]>([]);
const [selectedId, setSelectedId] = useState<string>();

// 初始化画布
useEffect(() => {
if (containerRef.current) {
const container = containerRef.current;
stage.ref = new Konva.Stage({
container: containerRef.current,
width: container.clientWidth,
height: container.clientHeight,
draggable: true,
});
// 初始化图层
const layer = new Konva.Layer();
stage.ref.add(layer);
const resizeStage = () => {
stage.ref!.width(container.clientWidth);
stage.ref!.height(container.clientHeight);
// 批量绘制(放到同一帧绘制)
stage.ref!.batchDraw();
};
editorStore.initStage();
// 监听窗口变化
window.addEventListener("resize", resizeStage);
// 监听缩放
stage.ref.on("wheel", (e) => {
e.evt.preventDefault();
handleWheel(e.evt);
});
return () => {
stage.ref?.destroy();
window.removeEventListener("resize", resizeStage);
};
}
}, []);

return (
<div className="scene-editor">
{/* 顶部状态栏 */}
<Header />
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{/* 侧边栏 */}
<RightSider />
</div>
);
});

代码可能不全,可以查阅 github 源码

自车

自车默认位于原点位置,这里先简单用一个绿色矩形框来替代,在初始化的时候就自动添加到场景中。并且这里要先加一个元素控制器 Konva.Transformer,便于调整矩形框的大小和旋转角度

// store/index.ts
// ...
initStage = () => {
// ...
// 初始化控制器
this.transformer = new Konva.Transformer({
// 启用旋转
rotateEnabled: true,
// 设置旋转吸附角度
rotationSnaps: [0, 90, 180, 270],
});
layer.add(this.transformer);
// ...然后监听点击事件,在点击到具体shape的时候动态加上控制器
stage.on("click", (e) => {
if (e.target === stage) {
this.transformer!.nodes([]);
} else {
const target = e.target as Konva.Shape;
// 这里其实可以做下限制,有些元素可能是不允许编辑的
// 比如地图元素在场景编辑模式下是不可编辑的
// if (target !== this.currentLine) {
this.transformer!.nodes([target]);
this.selectedElement = target;
// }
}
});
};

自车相关的代码如下:

// store/index.ts
// ...
initStage = () => {
//...
// 绘制自车
const autoCar = createRect({
fill: "green",
x: 0,
y: 0,
width: 30,
height: 20,
strokeWidth: 1,
});
layer.add(autoCar);
};

自车和控制器

基础图形封装

其实 konva 本身已经封装了现成的图形绘制函数,这里是二次封装简化下代码量,基类代码如下:

import Konva from "konva";

type ShapeType = "rect" | "circle" | "line" | "polygon" | "triangle";

export function createRect(config: Konva.RectConfig) {
return createShape("rect", {
width: 100,
height: 80,
...config,
});
}

export function createCircle(config: Konva.CircleConfig) {
return createShape("circle", {
radius: config?.radius || 50,
...config,
});
}

export function createTriangle(config: Konva.ShapeConfig) {
return createShape("triangle", {
offset: { x: 0, y: -15 }, // 居中调整
...config,
});
}

/**
* 图形基类
*/
export function createShape(type: ShapeType, config: any) {
const defaults = {
x: 0,
y: 0,
fill: Konva.Util.getRandomColor(),
draggable: true,
stroke: "#333",
strokeWidth: 2,
};

const shapeConfig = { ...defaults, ...config };
let shape: Konva.Shape;

switch (type) {
case "rect":
shape = new Konva.Rect(shapeConfig);
break;
case "circle":
shape = new Konva.Circle({
radius: 50,
...shapeConfig,
});
break;
case "line":
shape = new Konva.Line({
points: [0, 0, 100, 100], // 默认对角线
lineCap: "round",
...shapeConfig,
});
break;
case "polygon":
shape = new Konva.RegularPolygon({
sides: 5, // 默认五边形
radius: 60,
...shapeConfig,
});
break;
case "triangle":
shape = new Konva.Shape({
sceneFunc: function (context: Konva.Context, shape: Konva.Shape) {
context.beginPath();
context.moveTo(0, -30);
context.lineTo(30, 30);
context.lineTo(-30, 30);
context.closePath();
context.fillStrokeShape(shape);
},
...shapeConfig,
});
break;
}
shape!.on("click", () => console.log(`${type} clicked`));
shape!.on("dragend", () => console.log(`${type} moved`));
return shape!;
}

交互实现

整体交互是先在场景编辑器编辑地图和交通场景,通过 localStorage 保存数据,然后通过指定按钮快速跳转到 autopilot 3d 页面查看仿真效果,可以翻到最后先看下录屏效果

地图交互

地图拖动。配置支持 draggable

new Konva.Stage({
container: containerRef.current,
width: container.clientWidth,
height: container.clientHeight,
draggable: true,
});

地图缩放。下面以鼠标滚轮缩放为例,基于鼠标位置做画布的缩放

// scene-editor/index.tsx
// 监听鼠标滚轮事件
const MIN_SCALE = 0.5; // 最小缩放比例
const MAX_SCALE = 3; // 最大缩放比例
const handleWheel = (e: any) => {
const stageRef = stage.ref!;
const step = 1.1;
const oldScale = stageRef.scaleX();
const pointer = stageRef.getPointerPosition()!;
let newScale = e.deltaY < 0 ? oldScale * step : oldScale / step;
// 有最大和最小范围限制
newScale = Math.min(Math.max(newScale, MIN_SCALE), MAX_SCALE);
const mousePointTo = {
x: (pointer.x - stageRef.x()) / oldScale,
y: (pointer.y - stageRef.y()) / oldScale,
};
stageRef.scale({ x: newScale, y: newScale });
const newPos = {
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
};
stageRef.position(newPos);
stageRef.batchDraw();
};
// ...

一键返回原点。可以在左上角加个悬浮按钮居中画布原点

// store/index.ts
focusOrigin = () => {
if (this.stage.ref) {
// 清除拖拽产生的位移偏差
this.stage.ref.x(0);
this.stage.ref.y(0);
const centerX = -this.stage.ref.width() / 2;
const centerY = -this.stage.ref.height() / 2;
this.stage.ref.offset({ x: centerX, y: centerY });
}
};

线段绘制

  • 支持绘制直线和虚线
  • 编辑过程加绘制锁,按 Q 键(Quit)退出线段绘制

这里需要在 initStage 监听鼠标点击和移动事件,具体看代码注释清晰点:

// store/index.ts
// 暂存的线段对象,用于绘制多段直线
// ...
isDrawLine = false;
currentLine: Konva.Line | null = null;
// 点击顶部操作栏的线段按钮,会加编辑锁,便于多段线段的绘制
drawCallForMap = (type: EMapElement) => {
this.isEdit = true;
switch (type) {
case EMapElement.Line: {
this.isDrawLine = true;
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// 在初始化画布时执行,挂载画布事件
initStage = () => {
const stage = this.stage.ref!;
const layer = stage.getLayers()[0];
// 点击时新建线段
stage.on("mousedown", () => {
if (this.isDrawLine) {
const pos = stage.getPointerPosition()!;
this.currentLine = new Konva.Line({
points: [pos.x, pos.y],
fill: "yellow",
stroke: "yellow",
strokeWidth: 2,
});
layer.add(this.currentLine);
}
});
// 移动鼠标可以选择下一段线段的落点
// TODO 这里其实可以做下节流
stage.on("mousemove", () => {
if (this.currentLine && this.isDrawLine) {
const pos = stage.getPointerPosition()!;
let newPoints = [];
if (this.currentLine.points().length > 2) {
newPoints = this.currentLine
.points()
.slice(0, -2)
.concat([pos.x, pos.y]);
} else {
newPoints = this.currentLine.points().concat([pos.x, pos.y]);
}
this.currentLine.points(newPoints);
layer.batchDraw();
}
});
// 监听键盘事件,这里设定在点击ESC键时结束线段的绘制
const onKeydown = (e: any) => {
if (e.key === "q") {
if (this.isDrawLine && this.currentLine) {
const newPoints = this.currentLine.points().slice(0, -2);
this.currentLine.points(newPoints);
layer.batchDraw();
this.isDrawLine = false;
this.currentLine = null;
}
}
};
window.addEventListener("keydown", onKeydown);
};

绘制多段线段

车道绘制

这里设计为三段式编辑,第一二次点击确定车道宽度,第三次点击确定长度,并且结束车道绘制。在第三次点击前有一个框体的高亮效果辅助定位范围

// store/index.ts
isDrawMapElement: EMapElement | null = null;
// 车道辅助绘制,计算当前点击的次数
drawLaneCount = 0;
// 当前车道,多边形对象
currentLane: Konva.Shape | null = null;
stage.on("mousedown", () => {
// ...
if (this.isDrawMapElement === EMapElement.Lane) {
// 第一次点击,先绘制宽度线
if (this.drawLaneCount === 0) {
this.currentLine = new Konva.Line({
points: [pointX, pointY],
fill: "yellow",
stroke: "yellow",
strokeWidth: 2,
});
layer.add(this.currentLine);
// 第二次点击,确定宽度
} else if (this.drawLaneCount === 1) {
this.currentLine!.points().push(pointX, pointY);
// 第三次点击,确定长度
} else if (this.drawLaneCount === 2) {
this.currentLane?.destroy();
const points = [
this.currentLine!.points()[0],
this.currentLine!.points()[1],
this.currentLine!.points()[2],
this.currentLine!.points()[3],
pointX,
pointY,
pointX -
(this.currentLine!.points()[2] - this.currentLine!.points()[0]),
pointY -
(this.currentLine!.points()[3] - this.currentLine!.points()[1]),
];
this.currentLane = new Konva.Shape({
// 顶点坐标数组
points,
fill: "yellow",
stroke: "green",
opacity: 0.2,
strokeWidth: 2,
sceneFunc: function (ctx, shape) {
const points = shape.getAttr("points");
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
ctx.closePath();
ctx.fillStrokeShape(shape);
},
});
layer.add(this.currentLane);
this.drawDone(EMapElement.Lane);
}
this.drawLaneCount++;
}
})
stage.on("mousemove", () => {
// ...
if (this.currentLine && this.isDrawMapElement === EMapElement.Lane) {
const pos = stage.getPointerPosition()!;
const pointX = stage.offset().x + pos.x;
const pointY = stage.offset().y + pos.y;
let newPoints = [];
if (this.drawLaneCount === 1) {
if (this.currentLine.points().length > 2) {
newPoints = this.currentLine
.points()
.slice(0, -2)
.concat([pointX, pointY]);
} else {
newPoints = this.currentLine.points().concat([pointX, pointY]);
}
this.currentLine.points(newPoints);
layer.batchDraw();
} else if (this.drawLaneCount === 2) {
this.currentLane?.destroy();
// NOTE 注意按逆时针排,和threejs自定义多边形点集一致
const points = [
this.currentLine!.points()[0],
this.currentLine!.points()[1],
this.currentLine!.points()[2],
this.currentLine!.points()[3],
pointX,
pointY,
pointX -
(this.currentLine!.points()[2] - this.currentLine!.points()[0]),
pointY -
(this.currentLine!.points()[3] - this.currentLine!.points()[1]),
];
this.currentLane = new Konva.Shape({
points,
fill: "yellow",
stroke: "green",
opacity: 0.2,
strokeWidth: 2,
sceneFunc: function (ctx, shape) {
const points = shape.getAttr("points");
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
ctx.closePath();
ctx.fillStrokeShape(shape);
},
});
layer.add(this.currentLane);
}
}
})
// 在这里统一处理绘制结束的逻辑
drawDone = (type: EMapElement | ESceneElement) => {
// ...
if (type === EMapElement.Lane) {
const contour = _.chunk(this.currentLane?.getAttr("points"), 2).map(
(item) =>
({
x: item[0],
y: item[1],
z: 0,
} as { x: number; y: number; z: number })
);
const data: IFreespace = {
id: "freespace" + this.mapElements.lanes.length,
contour,
};
this.mapElements.lanes.push(data);
this.currentLine?.destroy();
this.currentLane = null;
this.currentLine = null;
this.drawLaneCount = 0;
}
}

绘制车道

场景编辑

先支持他车的编辑,以矩形框替代,可以设置高度,看看这个交互是咋样的:

  1. 点击他车按钮
  2. 点击画布任意处新增他车矩形框
  3. 添加他车矩形框,矩形框支持旋转、改变大小和拖动
  4. 处于选中状态时,右侧表单自动回填属性,包括名称、宽高、位置等,可以修改,保存场景时会以最新的属性为准

添加代码参考:

// store/index.ts
// 当前正在绘制的场景元素
isDrawSceneElement: ESceneElement | null = null;
drawCallForScene = (type: ESceneElement) => {
this.isEdit = true;
switch (type) {
case ESceneElement.Vehicle: {
this.isDrawSceneElement = ESceneElement.Vehicle;
break;
}
default: {
break;
}
}
this.isEdit = false;
};
// ...
stage.on("click", (e) => {
if (e.target === stage) {
// ...
if (this.isDrawSceneElement === ESceneElement.Vehicle) {
const pos = stage.getPointerPosition()!;
const pointX = stage.offset().x + pos.x;
const pointY = stage.offset().y + pos.y;
const vehicle = createRect({
fill: "blue",
x: pointX,
y: pointY,
width: 40,
height: 30,
strokeWidth: 1,
// 居中
offsetX: 15,
offsetY: 10,
});
layer.add(vehicle);
layer.batchDraw();
// 自动选中
this.transformer!.nodes([vehicle]);
this.isDrawSceneElement = null;
}
} else {
// ...
}
});

添加他车

自车或他车路线编辑

这里先做成匀速的场景,后面可以扩展到更多行驶场景,当然这需要一定的算法 ~ 路线绘制的话,可以复用上面的画线逻辑

选中目标后,路线编辑按钮可点击,这个时候可以编辑对应的路线,路线起点是选中目标的原点,不过这里要注意加一下和他车或自车的关联。另外选中状态下,可以在右侧表单中设置速度

下面是绘制自车路线的示例,环境车其实也类似,注意和路线做下关联就行了:

绘制自车路线

和 autopilot 交互

顶部右侧有一个按钮,可以点击后跳转到 autopilot 页面,快速预览 3d 效果,先简单从 localStorage 读取数据。数据结构方面,2d 元素这里其实和 3d 元素只有一个 z 轴的区别,其他结构其实差不多,场景元素主要都是点集,这里注意下坐标轴方向(编辑器现在 y 轴是反向...有点别扭其实)

保存场景数据

需要遍历场景元素,转换成 autopilot 能接受的接口数据(json),场景数据参考下面的类型定义:

// 编辑器输出的场景数据
export interface ISceneData {
autoCar: {
pos: number[];
rotation: number;
path: number[][];
};
map: IMapElements;
scene: ISceneElements;
}
// 自车属性
export interface IAutoCar {
ref: Konva.Rect | null;
config: {
x: number;
y: number;
rotation: number;
};
path: number[][];
speed: number;
}
// 地图元素集合
export interface IMapElements {
lines: ILine[];
lanes: IFreespace[];
}
// 场景元素集合
export interface ISceneElements {
vehicles: ICube[];
obstacles: ICube[];
}

看下编辑器和 autopilot 初步联动的静态效果:

编辑器:

autopilot 俯视:

再把速度加上,这里就假定车子是匀速,然后车子朝向固定为前后俩点的方向,下面是自车速度的实现示例,当然实际业务研发中,路线主要是上游算法或地图给的点集数据,这里纯粹做个演示,然后他车的匀速行驶也类似:

// renderer/index.ts
// 模拟匀速行驶
// ...
if (path.length > 0) {
// 生成平滑曲线
const curve = new THREE.CatmullRomCurve3(
path.map((p) => new THREE.Vector3(p[0] / 100, -p[1] / 100, 0)),
false
);
const totalLength = curve.getLength();
// 总时长 ms
this.totalDuration = (totalLength / speed) * 1000;
// 拆分为5000个点
this.pathPoints = curve.getPoints(5000);
}
// ...
totalDuration = 0;
pathPoints: any[] = [];
startTime = 0;
currentIndex = 0;
mockAutoDrive() {
if (!this.startTime) this.startTime = performance.now();
const elapsed = performance.now() - this.startTime;
const progress = Math.min(elapsed / this.totalDuration, 1);
this.currentIndex = Math.floor(progress * (this.pathPoints.length - 1));
if (this.currentIndex < this.pathPoints.length - 1) {
const currentPoint = this.pathPoints[this.currentIndex];
const nextPoint = this.pathPoints[this.currentIndex + 1];
// 动态计算自车位置
this.egoCar!.group.position.set(currentPoint.x, -currentPoint.y, 0);
// 自车朝向
const dx = nextPoint.x - currentPoint.x;
const dy = nextPoint.y - currentPoint.y;
this.egoCar!.group.rotation.z = -Math.PI / 2 + Math.atan2(dx, dy);
}
}

sim

最后

现在元素还比较少,功能相对简单,后面复杂一点还要碰一碰算法和性能问题

anyway,麻雀虽小,bug 多多<抱拳>,keep learning and respect

· 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 — 摄像机视锥体远端面

最后

· 12 min read

有时候为了提高智驾 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 的一些动画实现,当然这种动效对于智驾并不是强需求,只是作为视觉体验的补充

· 12 min read

autopilot 系列更新到第五篇了,这一篇分享些跟自车相关的优化。关于 threejs 的模型加载,可以用 promise 封装一下,然后提供 abort 方法中断模型加载避免重叠问题,其次是模型加载速度的一些优化,比如 draco 压缩、缓存策略等,然后还有提供相机视角的切换,然后可以做个车灯光源提高下视觉效果

模型相关

自车用的是 gltf 模型,所以下面主要围绕这个格式来说

模型加载进度

默认模型加载器都有内置一个 DefaultLoadingManager,可以参考 GLTFLoader

// ...
loadEgoCar() {
gltfLoader.load(
carModel,
(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);
},
(xhr) => {
console.log((xhr.loaded / xhr.total) * 100 + "% loaded");
},
function (error) {
console.log(error);
}
);
}

THREE.LoadingManager 可以定制模型加载器

const manager = new THREE.LoadingManager();
manager.onLoad = () => {
console.log("===Loading complete!");
};
manager.onProgress = (url, loaded, total) => {
console.log("===loading", url, loaded, total);
};
const gltfLoader = new GLTFLoader(manager);

对于 GLTFLoader,它可能会加载多个资源(如网格、纹理、材质等),并且这些资源的加载可能是异步的,LoadingManageronProgress 回调中的 total 值可能只代表当前正在处理或已处理的特定资源的一部分或全部的总数据量,所以这里简单用 loaded/total 是不太准确的

加载函数

因为模型加载默认是回调函数,这里可以做个 promise 的封装,实现异步加载和渐进式显示

// src/helper/index.ts
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

export function loadGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}
// src/renderer/index.ts
// ...
loadEgoCar() {
loadGLTFWithPromise(carModel)
.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);
})
.catch((err) => console.log(err));
}

加载贴图

贴图也是异步加载的,这里和模型的加载可能存在顺序问题,可以用 Promise.allSettled 确保都模型和对应贴图加载完再更新。如果此时任一方加载失败了,那也可以在这里做个兜底处理,比如贴图换占位图或者提示加载模型失败。当然,如果贴图确实加载的很慢,那还是分开请求避免阻塞模型加载,更新贴图的逻辑其实类似的

// src/helper/index.ts
export function loadTexture(url: string): Promise<THREE.Texture> {
return new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(
url,
(texture) => {
resolve(texture);
},
undefined,
(error) => {
reject(error);
}
);
});
}
// ...
// 在没渲染之前,threejs默认会用占位图替代,避免报错
import testModal from "@/assets/models/xxx.glb";
import testTextures from "@/assets/textures/xxx.png";
Promise.allSettled([
loadGLTFWithPromise(testModal),
loadTexture(testTextures),
]).then((results) => {
const modelResult = results[0];
const textureResult = results[1];
if (modelResult.status === "fulfilled") {
if (textureResult.status === "fulfilled") {
modelResult.value.material.map = textureResult.value;
modelResult.value.material.needsUpdate = true;
}
return;
}
// 其他异常情况可以自行补充,比如提示模型加载失败或者贴图加载失败
// 实际可能有多个模型需要贴图
// model.traverse((child) => {
// if (child.isMesh) {
// model.material.map = texture;
// // 在下一帧应用更新
// model.material.needsUpdate = true;
// }
// });
});

中断异步加载

需要提供中断异步加载的功能,避免模型没加载完时用户又换成到其他模型,这种场景在有多个自车模型的时候会碰到,如果不中断可能造成模型重叠。因为之前用 promise 封装了,所以这里其实就等同于实现一个 promise 的 abort 方法

// 中断promise的辅助函数
export function abortWrapper(p1: Promise<any>) {
let abort;
const p2 = new Promise((resolve, reject) => (abort = reject));
const p = Promise.race([p1, p2]);
p.abort = abort;
return p;
}
// 比如可以调整下自车的加载逻辑
// ...
(this.egoCarLoader = abortWrapper(loadGLTFWithPromise(carModel))).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);
}
);
// ...
// 可能某个信号触发中断,直接调用abort, 这里用setTimeout模拟中断效果
setTimeout(() => {
this.egoCarLoader.abort();
}, 10);

但其实还不是很彻底,因为请求还是会正常进行和接收,那怎么中断请求?这一步倒不是很必要,也能做,但是得自行写一个模型加载器,然后通过 xhr 对象的 abort 方法或者 AbortController 来取消请求。可以跟踪下 threejs 的这个 issue,目前还是 open 状态,似乎有希望在未来版本中支持 abort

压缩

自车模型有 9M 左右,还是比较大,可以考虑压缩下模型,GLTF 模型可以通过高效的 Draco 算法来压缩几何数据,并且借助了 webAssembly 来加速计算,但是肯定有额外的解压耗时,一般都是值得的,当然你可以做下耗时对比

npm 全局安装 gltf-pipeline,然后到对应模型的目录下执行 gltf-pipeline -i xxx.glb -o xxx.glb -d。或者找个在线压缩 gltf 的地址快速压缩,比如 这个。如果经常要压缩一些模型的话,每次都这么手动处理肯定很麻烦,可以尝试用 node 写个批处理脚本

压缩后 9M 降到了 3.4M,体积减小还是很明显的。得到压缩后的文件后,那就该准备 DRACOLoader了。新增一个针对压缩后模型的加载函数

export function loadDracoGLTFWithPromise(url: string): Promise<GLTF> {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
// 设置解压相关文件的路径
dracoLoader.setDecoderPath(
// 将 three/examples/jsm/libs/draco/gltf/ 拷贝到 public 目录来用
"./draco-gltf/"
// 或者也可以直接用这个
// "https://threejs.org/examples/jsm/libs/draco/gltf/"
);
// 使用js方式解压
// dracoLoader.setDecoderConfig({ type: "js" });
// 初始化 initDecoder 解码器
dracoLoader.preload();
// 设置GLTFLoader使用的压缩器
loader.setDRACOLoader(dracoLoader);
loader.load(
url,
function (gltf) {
resolve(gltf);
},
// 加载进度回调
undefined,
function (error) {
reject(error);
}
);
});
}

缓存

一般场景里用到的模型,比如行人、障碍物、路标等都是经常会出现的,这个时候不能做重复加载,而是要尽可能复用。这里其实做个 map 对象,在接口数据里约定 type 来映射模型就行,可以在初始化界面的时候发起并行请求(Promise.all)获取模型,并保存到 map 对象上,后面可以直接通过 clone复用模型

因为有些识别出来的物体是会连续存在于多帧里的,可以用id唯一标识,这类非新增的物体就可以通过 id 找到模型并更新位置、大小、颜色等属性

其他关于模型的优化还有:

  • 纹理压缩
  • 建模的时候尽量减少顶点和面的数量,借助建模软件做网格简化和合并,从而实现减面
  • LOD 技术(Level of Detail),其实就是根据物体与相机的距离,动态选择渲染不同精度的模型。其实可以配合上述的纹理压缩和减面来做
  • CDN/......

有机会实践后再分享下吧 ~

相机优化

Threejs 相机有透视相机和正交相机。透视相机能模拟我们人眼所看到的景象,它是 web3d 中使用得最普遍的

camera分类

视角切换

因为 autopilot 这个应用主要是用于智驾算法调试,所以肯定涉及到视角切换,比如跟车视角、俯视横向纵向、又或者用户希望能自定义视角,那就需要设计这个视角切换的功能

先设计这几种视角:跟车、俯视横向、俯视纵向,其实实现很简单,就是改变相机的位置和方向。这里需要增加一个悬浮层,放一些交互按钮,注意悬浮层要加一个pointer-events: none来阻止鼠标行为,包括点击、悬停等交互,避免和 OrbitControls 交互冲突

// ...
switchCameraView(view: EViewType) {
this.cameraView = view;
switch (view) {
case EViewType.FollowCar: {
this.camera.up.set(0, 0, 1);
this.camera.position.set(-0.4, 4, 1.4);
break;
}
case EViewType.Overlook: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(0, -1, 0);
break;
}
case EViewType.OverlookVertical: {
this.camera.position.set(0, 0, 20);
this.camera.up.set(1, 0, 0);
break;
}
default:
break;
}
}

俯视视角

上面这个还只是简单的调了下位置,这里 OrbitControls 在俯视视角用起来还有点问题,后面得优化下

车灯

我们之前其实接触过光源,比如整体场景的环境光 AmbientLight 还有投向自车的直行光 DirectionLight,其他光源还有 SpotLight 聚光灯

光源分类

车灯主要就是借助聚光灯光源来模拟,如果自车模型包含灯模型,通过灯的位置来设置光源位置就更准确了。但如果自车位置会变的话,还需要结合自车位置和转向来计算光源目标

// ...
const target1 = new THREE.Object3D();
target1.position.set(0.1, -0.2, 0.3);
const light1 = new THREE.SpotLight("#fff", 1.2, 3, Math.PI / 6, 0.1);
light1.position.set(0.1, 0.2, 0.3);
light1.castShadow = true;
light1.target = target1;
this.scene.add(target1);
this.scene.add(light1);
const target2 = new THREE.Object3D();
target2.position.set(-0.1, -0.2, 0.3);
const light2 = new THREE.SpotLight("#fff", 1.2, 2, Math.PI / 6, 0.1);
light2.position.set(-0.1, 0.2, 0.3);
light2.castShadow = true;
light2.target = target2;
this.scene.add(target2);
this.scene.add(light2);

车灯效果

最后

· 16 min read

智驾场景地图上最重要的元素是啥?当属 Line 元素了,比如下图的车道线、规划线和预测线等(下图来自百度 apollo 公开课件,有点糊凑合看吧)

baidu-apollo

一些调整

先把相机位置调一下,因为在我们认知习惯里 z 轴应该垂直向上,水平面则是 x/y 轴,这个时候在不翻转场景的情况下,可以调整相机的 up属性,使其向上位置朝着 z 轴正向,这样一来给我们造成的视觉效果就是 z 轴垂直向上

camera和视椎体

但实际上还是符合右手定则,此时的 x 轴、y 轴和 z 轴如下图所示(蓝色向上是 z 轴正方向,红色向左是 x 轴正方向,绿色向屏幕内是 y 轴正方向)。部分修改代码如下:

carema.up.set(0, 0, 1);
// 注意调下相机位置确保看到自车
camera.position.set(-0.4, 4, 1.4);

threejs坐标轴z朝上

内置 Line

其实 threejs 也有内置的线条几何体,比如我们可以用 Line 实现几段基础的道路线,并在自车前方加一条简单的规划线

// ...
const points = [];
points.push(new THREE.Vector3(0.4, -20, 0));
points.push(new THREE.Vector3(0.4, 20, 0));
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: 0xffffff });
const line = new THREE.Line(geometry, material);
line.position.z = 0.1;
this.scene.add(line);
const points2 = [];
points2.push(new THREE.Vector3(-0.8, -20, 0));
points2.push(new THREE.Vector3(-0.8, 20, 0));
const geometry2 = new THREE.BufferGeometry().setFromPoints(points2);
const material2 = new THREE.LineBasicMaterial({ color: 0xffffff });
const line2 = new THREE.Line(geometry2, material2);
line2.position.z = 0.1;
this.scene.add(line2);
const points3 = [];
points3.push(new THREE.Vector3(-0.2, -20, 0));
points3.push(new THREE.Vector3(-0.2, 20, 0));
const geometry3 = new THREE.BufferGeometry().setFromPoints(points3);
// 虚线材质
const material3 = new THREE.LineDashedMaterial({
color: 0xffffff,
dashSize: 1, // 显示线段的大小,默认为3
gapSize: 0.5, // 间隙的大小,默认为1
});
const line3 = new THREE.Line(geometry3, material3);
line3.position.z = 0.1;
// 注意虚线必须调用这个函数
line3.computeLineDistances();
this.scene.add(line3);
// 自车规划线
const points4 = [];
points4.push(new THREE.Vector3(0, -10, 0));
points4.push(new THREE.Vector3(0, 0, 0));
const geometry4 = new THREE.BufferGeometry().setFromPoints(points4);
const material4 = new THREE.LineBasicMaterial({ color: 0xffff00 });
const line4 = new THREE.Line(geometry4, material4);
line4.position.z = 0.1;
this.scene.add(line4);

内置line实现实线和虚线示例

但这里发现规划线太细了,想要定义宽度 LineWidth 却发现没有效果,这里改用 Line2试试:

import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry, LineMaterial } from "three/examples/jsm/Addons.js";
// ...
// 规划线
const geometry4 = new LineGeometry();
geometry4.setPositions([0, -10, 0, 0, 0, 0]);
const material4 = new LineMaterial({
resolution: new THREE.Vector2(window.innerWidth, window.innerHeight),
color: 0xffff00,
linewidth: 20,
});
const line4 = new Line2(geometry4, material4);
line4.position.z = 0.1;
this.scene.add(line4);

先看下效果:

内置line2实现渐变色示例

看起来会有点像圆柱体,而且不随视角远近而改变大小,但其实我们期望的效果只需要车道保持平行的二维固定长度的线。车道线主要是实线和虚线以及多种颜色的组合,乍一看内置元素都还能勉强实现这些,其他内置线元素还有:

  • LineSegmentsTHREE.Line类似,但是可以通过一系列的点创建出多段线,可以调节线条宽度粗细,这个在 第二篇 一开始实现立方体的时候有用来绘制边框
  • LineLoop 首尾相连的线 ,可以形成一个闭合的图形,但也没法设置宽度粗细
  • CatmullRomCurve3 创建平滑的三维曲线

但后面突然算法找到你,说我们希望做条规划线,而且是渐变色的,渐变范围在某些点之间,可以用来表示速度变化趋势,那这个时候,内置的线元素就难办了。ok 总结一下,内置线元素有什么缺点:

  • 宽度定义比较难受
  • 实现虚线或者双线效果时处理数据会额外占用较多的 CPU 资源
  • 不支持渐变色、流光效果等

自定义 Line

我们其实可以自定义 Line 元素和 shader 材质来解决上面的这些限制,需要用到之前的 BufferGeometry 自定义几何体和 shaderMaterial。这里得稍微了解下 webgl 的渲染管线(图片来自 threejs 中文网):

webgl渲染管线

shaderMaterial

上图可以看到,先后经历了顶点着色器(vertex shaders)和片元着色器(fragment shaders),shader 代码用 GLSL 语言编写,是在 GPU 中执行的,其实有时候我们可以把一部分 CPU 的工作交给 GPU 来提升应用的性能

GLSL 入门可以参考 https://github.com/wshxbqq/GLSL-Card

这里有些属性要了解一下:

  • uniforms 传递给 shader 的参数,比如颜色值、透明度等
  • vertexShader 在顶点着色器中运行的代码片段
  • fragmentShader 在片元着色器中运行的代码片段

先画个双色的长方形看看:

// ...
const geometry = new THREE.PlaneGeometry(0.4, 1);
const shader = new THREE.ShaderMaterial({
uniforms: {
uColor: {
value: new THREE.Color("#ffff00"),
},
uColor1: {
value: new THREE.Color("orange"),
},
},
vertexShader: `
varying vec3 vPosition;
void main() {
vPosition = position;
// 计算顶点的位置,投影矩阵*模型视图矩阵*模型顶点坐标
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uColor;
uniform vec3 uColor1;
varying vec3 vPosition;
void main() {
gl_FragColor = vPosition.y < 0.0 ? vec4(uColor, 1.0) : vec4(uColor1, 1.0);
}`,
});
const plane = new THREE.Mesh(geometry, shader);
plane.position.y = -1;
plane.position.z = 0.1;
this.scene.add(plane);

shader实现双色长方形示例

实线

我们可以将线元素Line看成是一些三角形连接而成,然后宽度就是每个点往两边分别延伸一半,类似下图:

triangle-line

这里我们就需要先用 BufferGeometry 自定义 Line 元素,再把相关参数传入 shaderMaterial

先分别实现下基础的实线和虚线,老规矩先定下元素接口:

export interface ILine {
points: number[]; // 点集,[x,y,z]
color: string; // 颜色值
width: number; // 线宽
type: ELineType; // 线类型
}
export enum ELineType {
Solid = 0, // 实线
Dash = 1, // 虚线
Gradual = 10, // 渐变线
// 还可以扩展到双线、虚实线结合等
}

封装 Line,这里用到了 polyline-normals这个库,我们需要借助它来计算顶点的法向量,通过这个法向量和宽度来计算得到两边的顶点。但是这个库还没支持 ESModule,需要 require 引入,需要多安装一个 vite-plugin-commonjs插件来支持,同时修改vite.config.js 如下:

// pnpm i polyline-normals
// pnpm i -D vite-plugin-commonjs
// vite.config.js
import commonjs from "vite-plugin-commonjs";
// ...
export default defineConfig({
plugins: [react(), commonjs()],
// ...
});

接下来封装一下自定义的 Line 元素,BufferGeometry 自定义几何体在上一篇有比较多的内容,可以自行参考:

// src/renderer/line.ts
// ...
const getNormals = require("polyline-normals");

class Line {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

createGeometry(data: ILine, needDistance: boolean = false) {
const { points } = data;
const vertices: number[][] = [];
const indices: number[] = [];
const lineNormal: number[][] = [];
const lineMiter: number[][] = [];
const lineDistance: number[][] = [];
const lineAllDistance: number[][] = [];
const geometry = new THREE.BufferGeometry();
// 计算各个点的法向量
const normalsByPolyline = getNormals(points);
let indicesIdx = 0;
let index = 0;
let distance = 0;
points.forEach((point, i, list) => {
const idx = index;
if (i !== points.length - 1) {
// 添加索引以形成两个三角形
indices[indicesIdx++] = idx + 0;
indices[indicesIdx++] = idx + 1;
indices[indicesIdx++] = idx + 2;
indices[indicesIdx++] = idx + 2;
indices[indicesIdx++] = idx + 1;
indices[indicesIdx++] = idx + 3;
}
// 这里不用先计算,后面直接在shader里面借助GPU计算就行
vertices.push(point);
vertices.push(point);
});
normalsByPolyline.forEach((item: any) => {
const norm = item[0];
const miter = item[1];
lineNormal.push([norm[0], norm[1]], [norm[0], norm[1]]);
lineMiter.push([-miter], [miter]);
});
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices.flat(), 3)
);
geometry.setAttribute(
"lineNormal",
new THREE.Float32BufferAttribute(lineNormal.flat(), 2)
);
geometry.setAttribute(
"lineMiter",
new THREE.Float32BufferAttribute(lineMiter.flat(), 1)
);
geometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1));
return geometry;
}

draw(data: ILine) {
const { color = "#ffffff", width, type, endColor } = data;
let geometry;
let shader;
switch (type) {
case ELineType.Solid: {
geometry = this.createGeometry(data);
shader = getSolidLineShader({
width: width ?? 0.01,
color: color,
});
break;
}
case ELineType.Dash: {
// ...
break;
}
case ELineType.Gradual: {
// ...
break;
}
default:
break;
}
const plane = new THREE.Mesh(geometry, shader);
plane.position.z = 0.01;
this.scene.add(plane);
}
}

编写实线 shader 如下:

export function getSolidLineShader(option: any = {}) {
const material = new THREE.ShaderMaterial({
uniforms: {
thickness: { value: option.width ?? 0.1 },
opacity: { value: option.opacity ?? 1.0 },
diffuse: { value: new THREE.Color(option.color) },
},
vertexShader: `
uniform float thickness;
attribute float lineMiter;
attribute vec2 lineNormal;
void main() {
// 通过法线和宽度计算得出线段中点对应的两个顶点
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
}
`,
fragmentShader: `
uniform vec3 diffuse;
uniform float opacity;
void main() {
gl_FragColor = vec4(diffuse, opacity);
}
`,
});
material.side = THREE.BackSide;
material.transparent = true;
return material;
}

虚线

虚线和实线是同一个 BufferGeometry,只不过接口要加一些参数,然后要另写一个 shader,主要是实现虚线的逻辑不一样,需要额外定义属性 lineDistance表示顶点距起点的累积直线距离,其他逻辑都类似实线

稍微解释下这个计算逻辑:

  • 比如实线 3m,虚线 2m,长度 4m (累积直线距离)的点明显在虚线区域,做个取模 4%(3+2)=4
  • 这个时候算出 4 大于实线长度,说明在虚线区域,就将这个地方的点设置为透明
  • 同理如果是在 2 的区域,2 小于实线长度,说明在实线区域,就正常填色

虚线计算逻辑

增加lineDistance属性,参考代码如下:

// src/renderer/line.ts
// ...
// 新增一个needDistance参数,主要用于虚线和渐变线
createGeometry(data: ILine, needDistance: boolean = false) {
const lineDistance: number[][] = [];
points.forEach((point, i, list) => {
// ...
if (needDistance) {
let d = 0;
if (i > 0) {
// 计算两点之间的直线距离
d = getPointsDistance(
[point[0], point[1]],
[list[i - 1][0], list[i - 1][1]]
);
}
distance += d;
lineDistance.push([distance], [distance]);
}
});
if (needDistance) {
geometry.setAttribute(
"lineDistance",
new THREE.Float32BufferAttribute(lineDistance.flat(), 1)
);
}
}

接口变化如下:

export interface ILine {
points: number[]; // 点集
color: string;
width: number;
type: ELineType; // 默认是实线
dashConfig?: {
// 实线长度
solidLength?: number;
// 虚线长度
dashLength?: number;
};
}

编写虚线 shader 如下:

// 用于画单色虚线
export function getDashedLineShader(option: ILine) {
const material = new THREE.ShaderMaterial({
uniforms: {
thickness: { value: option.width ?? 0.1 },
opacity: { value: option.opacity ?? 1.0 },
diffuse: { value: new THREE.Color(option.color) },
// 虚线部分的长度
dashLength: { value: option?.dashInfo?.dashLength ?? 1.0 },
// 实线部分的长度
solidLength: { value: option?.dashInfo?.solidLength ?? 2.0 },
},
vertexShader: `
uniform float thickness;
attribute float lineMiter;
attribute vec2 lineNormal;
attribute float lineDistance;
varying float lineU;

void main() {
// 累积距离
lineU = lineDistance;
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
}
`,
fragmentShader: `
varying float lineU;
uniform vec3 diffuse;
uniform float opacity;
uniform float dashLength;
uniform float solidLength;

void main() {
// 取模
float lineUMod = mod(lineU, dashLength + solidLength);
// lineUMod>solidLength则返回0.0,说明在实线区域;否则返回1.0,说明在虚线区域
float dash = 1.0 - step(solidLength, lineUMod);
gl_FragColor = vec4(diffuse * vec3(dash), dash * opacity);
}
`,
});
material.transparent = true;
material.side = THREE.BackSide;
return material;
}

自定义shader绘制实线和虚线

ok 胜利近在眼前,目前这个针对直线支持最好,曲线的话,得多一些点集数据才能确保曲线平滑过渡,否则可能要用到贝塞尔曲线平滑一下...(不过一般来说也不会让前端去做平滑吧至少我没碰到)

渐变线

其实有两种方式,一种是发出多个线段,每个线段带一种颜色,造成一种“假”的渐变效果,其实有时候也足够满足算法需求了,这种相对简单点;第二种是给一段线段,然后在起点和终点之间做线性渐变。这里看下第二种咋实现,主要差异也是在接口和 shader 里,需要做下颜色的线性插值,可以借助之前的 lineDistance 和线段总长度 lineAllDistance 的比例来插值。lineAllDistance 是新增的属性,暂时先给每个点都加上,或许有大佬有更好的办法也可以给点建议,这里就不贴代码了,可以参考源码 ~

接口变化如下:

export interface ILine {
points: number[]; // 点集
color: string; // 拿来做起点颜色吧
width: number;
type: ELineType; // 默认是实线
endColor?: string; // 渐变色,作为终点颜色,color是起点颜色
}

编写 shader 如下:

// 渐变色
export function getGradientLineShader(option: any = {}) {
const material = new THREE.ShaderMaterial({
uniforms: {
thickness: { value: option.width ?? 0.1 },
opacity: { value: option.opacity ?? 1.0 },
diffuse: { value: new THREE.Color(option.color) },
endColor: { value: new THREE.Color(option.endColor) },
},
vertexShader: `
uniform float thickness;
attribute vec2 lineNormal;
attribute float lineMiter;
attribute float lineDistance;
attribute float lineAllDistance;
varying float lineU;
varying float lineAll;

void main() {
lineU = lineDistance;
lineAll = lineAllDistance;
vec3 pointPos = position.xyz + vec3(lineNormal * thickness / 2.0 * lineMiter, 0.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(pointPos, 1.0);
}
`,
fragmentShader: `
// 累积长度
varying float lineU;
varying float lineAll;
uniform float opacity;
uniform vec3 diffuse;
uniform vec3 endColor;

void main() {
vec3 aColor = (1.0-lineU/lineAll)*(diffuse-endColor)+endColor;
gl_FragColor =vec4(aColor, opacity);
}
`,
});
material.transparent = true;
material.side = THREE.DoubleSide;
return material;
}

自定义shader实现渐变色线条

数据自己 mock 的,想要线段更平滑就整多点数据吧 ~

最后

· 11 min read

除了自车和他车(其他车辆),还有一些规则的物体比如圆柱体、椎体等这些可以通过 threejs 内置的几何体来实现,但其实智驾场景里还有很多不规则物体,如果比较简单,比如多边形柱体这类就可以借助自定义几何体来实现,复杂点的就建议建模了

BufferGeometry

BufferGeometry 是 threejs 内置几何体的基类,通过此基类可以自定义几何体。老玩家可能见过Geometry,它和 BufferGeometry的区别是啥?新版 threejs 其实已经不推荐使用Geometry,但为了向后兼容也仍然保留该类,其实底层会自动将其转换为BufferGeometry,后者提供了更好的性能和更高效的内存使用

它主要有以下几个属性,可以通过 attributes访问:

  • position 顶点位置
  • normal 法线,也就是法向量,和光照有关
  • color 顶点颜色
  • uv 坐标,可以从贴图上提取像素映射到网格模型的几何体表面上
  • index 顶点索引。这个可以用来复用顶点数据,从而减少重复的顶点数据

几何体各个属性对应关系

在顶点 4 的地方,其实这里可以就算一个顶点索引,其实positionnormalcoloruv 是基于顶点相对应的,接着看段伪代码理解一下:

// 顶点位置
const positions = [
x1, y1, z1, // 顶点1
x2, y2, z2, // 顶点2
x3, y3, z3 // 顶点3
];
// 顶点颜色
const colors = [
r1, g1, b1, // 顶点1的颜色
r2, g2, b2, // 顶点2的颜色
r3, g3, b3 // 顶点3的颜色
];
// UV坐标
const uvs = [
u1, v1, // 顶点1的UV
u2, v2, // 顶点2的UV
u3, v3 // 顶点3的UV
];
// 索引,指定如何形成三角形,这里使用顶点0, 1, 2来形成一个三角形
const indices = [0, 1, 2];
// 将以上属性组合到BufferGeometry中
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(new THREE.Uint16BufferAttribute(indices, 1));
// 接着将这个geometry与material结合,创建mesh并添加到场景中

长方体

其实 BoxGeometry 就是自定义几何体的实现,threejs 做了层封装方便我们使用,有余力的同学建议可以去读一读源码。那怎么用自定义几何体的方式实现一个长方体呢?

上面坐标数组的表示其实比较难读,这里可以借助 js 的扩展运算符来提高代码的可读性,主要是确立各个顶点的位置,法向量这里是垂直于所在面的正面向上,然后设置下几何体的属性,再加下材质和 Mesh,代码参考如下:

const vertices = [
// 注意按逆时针方向排序
// 正面, 因为没用到纹理,这里先不管uv坐标
{ position: [-1, -1, 1], normal: [0, 0, 1] },
{ position: [1, -1, 1], normal: [0, 0, 1] },
{ position: [-1, 1, 1], normal: [0, 0, 1] },
{ position: [-1, 1, 1], normal: [0, 0, 1] },
{ position: [1, -1, 1], normal: [0, 0, 1] },
{ position: [1, 1, 1], normal: [0, 0, 1] },
// ...其他面类似处理
];
const positions = [];
const normals = [];
for (const vertex of vertices) {
positions.push(...vertex.position);
normals.push(...vertex.normal);
}
geometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(positions), 3)
// 等同于 new THREE.Float32BufferAttribute(positions, 3)
);
geometry.setAttribute(
"normal",
new THREE.BufferAttribute(new Float32Array(normals), 3)
);
const polygonMaterial = new THREE.MeshLambertMaterial({
transparent: true,
opacity: 0.2,
});
polygonMaterial.color.set(0, 1, 0);
const mesh = new THREE.Mesh(geometry, polygonMaterial);
this.scene.add(mesh);

这样一套方法实现下来,效果如下:

自定义立方体

自车包围的这一个立方体就是了,怎么有点像火影的尘遁?仔细看代码,其实会发现一个 BufferAttribute 对象,它能管理顶点着色器中的 Attribute 变量,通过此对象可以存储顶点位置信息、法线向量、顶点颜色等,并可以对其进行矩阵变换、拷贝、读写等

为了观察当前各个坐标轴方向,也可以引入 AxesHelper 协助观察坐标轴方向(如果你觉得右手定则麻烦的话),X 轴为红色,Y 轴为绿色,Z 轴为蓝色

const axes = new THREE.AxesHelper(1);
axes.position.y = 0.05;
this.scene.add(axes);

坐标轴辅助

顶点索引

通过 setIndex 可以设置索引,这个索引可以用来复用顶点数据,从而减少顶点数量。用上述例子来看,其实一个面只需要四个顶点,另外俩个是可以复用的,以正面为例:

const vertices = [
// 正面
{ position: [-1, -1, 1], normal: [0, 0, 1] }, // 顶点0
{ position: [1, -1, 1], normal: [0, 0, 1] }, // 顶点1
{ position: [-1, 1, 1], normal: [0, 0, 1] }, // 顶点2
// { position: [-1, 1, 1], normal: [0, 0, 1] }, // 复用顶点2
// { position: [1, -1, 1], normal: [0, 0, 1] }, // 复用顶点1
{ position: [1, 1, 1], normal: [0, 0, 1] }, // 顶点3
// ...
];
geometry.setIndex([
// 正面
0, 1, 2, 2, 1, 3,
// ...类似正面的方法设置下其他面
]);
const positions = [];
const normals = [];
// ...其他代码同上

使用后其实顶点 verticals 只需要设置 24 个顶点(每个面对应 4 个),如果不考虑法向量和 uv 坐标的话,甚至只需要 8 个。这么一套下来,麻烦吧 ~ 所以稍微复杂点的不规则物体,还是乖乖上建模吧

PolygonCylinder

这里的 PolygonCylinder 用来指代多边形柱体,也就是上下面是相等的多边形和指定高度组成的柱体,当然你如果想用 ExtrudeGeometry 来实现,也不是不行,就是搞复杂了,还会生成更多的顶点和面。下面还是想想办法怎么通过 BufferGeometry 实现一个多边形柱体,先定下图形接口:

export interface IPolygonCylinder {
id: string;
// 顶点,只需要底面几个顶点,顶面顶点通过高度可以计算出来
contour: IPos[];
// 高度
height: number;
color?: IColor;
}

类定义如下:

export class PolygonCylinder {
// ...
draw(data: IPolygonCylinder) {
const { contour, height, color = { r: 0, g: 0, b: 0 } } = data;
// 确保顶点顺序为逆时针
if (THREE.ShapeUtils.isClockWise(contour)) {
contour.reverse();
}
const vertices: number[][] = [];
const normals: number[][] = [];
const indexes: number[] = [];
// 索引辅助下标
let indexesIndex = 0;
// 总共的顶点数量 = 顶面顶点+底面顶点
// 确定顶面
for (let i = 0; i < contour.length; i++) {
const current = contour[i];
vertices.push([current.x, current?.y + height, current.z]);
normals.push([0, 1, 0]);
// 设置顶面索引, 底面一般看不到, 所以可以不用设置索引
// 三个点确定一个面, 注意按逆时针方向加入顶点索引
if (i >= 2) {
indexes[indexesIndex] = 0;
indexes[indexesIndex + 1] = i - 1;
indexes[indexesIndex + 2] = i;
indexesIndex += 3;
}
}
// 确定底面
for (let i = 0; i < contour.length; i++) {
const current = contour[i];
vertices.push([current.x, current.y, current.z]);
normals.push([-1, 0, -1]);
}
// 确定侧面, 这里复用下上下面的顶点就行
for (let topIndex = 0; topIndex < contour.length; topIndex++) {
const bottomIndex = topIndex + contour.length;
// 终点处理, 这里的topIndex+1==底面起点, bottomIndex就是底部终点
if (bottomIndex + 1 === 2 * contour.length) {
indexes[indexesIndex] = topIndex;
indexes[indexesIndex + 1] = bottomIndex;
indexes[indexesIndex + 2] = topIndex + 1;
indexes[indexesIndex + 3] = topIndex + 1;
indexes[indexesIndex + 4] = 0;
indexes[indexesIndex + 5] = topIndex;
} else {
// 一个面对应俩个三角形
indexes[indexesIndex] = topIndex;
indexes[indexesIndex + 1] = bottomIndex;
indexes[indexesIndex + 2] = bottomIndex + 1;
indexes[indexesIndex + 3] = bottomIndex + 1;
indexes[indexesIndex + 4] = topIndex + 1;
indexes[indexesIndex + 5] = topIndex;
}
indexesIndex += 6;
}
// 设置缓冲几何体属性
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices.flat(), 3)
);
// 自动计算法向量, 柱体结构不够清晰
// geometry.computeVertexNormals();
geometry.setAttribute(
"normal",
new THREE.Float32BufferAttribute(normals.flat(), 3)
);
geometry.index = new THREE.Uint16BufferAttribute(indexes, 1);
const polygonMaterial = new THREE.MeshLambertMaterial({
transparent: true,
opacity: 0.8,
});
polygonMaterial.color.setRGB(color.r, color.g, color.b);
const polygonMesh = new THREE.Mesh(geometry, polygonMaterial);
this.scene.add(polygonMesh);
}
}
export default PolygonCylinder;

isClockWise这个工具函数,可以用于判断一组点在二维平面上的投影方向是顺时针还是逆时针,有一些场景需要用到,比如填充多边形、计算几何体的方向等,如果是顺时针方向,需要对顶点做下逆序处理 reverse。如下图所示,对应红色三角形的索引为 topIndex > bottomIndex+1 > topIndex,顺序不重要,确保逆时针方向就行:

顶点索引方向

OpenGL 默认遵循右手法则,右手除拇指之外的四指根据点的逆时针握住,大拇指的方向即为法线方向,其逆时针的一面为正面,可以接受到光照;顺时针为反面,无法接受光照

mock 些数据看看效果:

多边形柱体

GL_INVALID_OPERATION: Vertex buffer is not big enough for the draw call

如果遇到这个报错,大概率顶点索引越界了,检查下索引和顶点是不是不匹配吧 ~

最后

· 8 min read

除了自车,那智驾里最常见的参与物就是他车了,比如下面这种绿色长方体(图片源于百度 apollo),然后上面可能会携带一些文字信息

apollo他车示例

实现长方体

怎么做一个长方体?其实很简单,直接用内置几何体 BoxGeometry+EdgesGeometry 来实现,注意为了方便观察,这里要把 Mesh 做成透明的,绘制代码参考:

const material = new THREE.MeshBasicMaterial({
color: 0x00ff00,
// 注意这里要设置一下,否则opacity不会生效
transparent: true,
opacity: 0.2,
});
const geometry = new THREE.BoxGeometry(0.4, 0.4, 1);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 0.2, -2);
this.scene.add(mesh);
const edges = new THREE.EdgesGeometry(geometry);
const edgesMaterial = new THREE.LineBasicMaterial({ color: 0x00ff00 });
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.copy(mesh.position);
this.scene.add(line);

他车

ok 有点模样了。接下来封装下这个 Cube 类,先定个基础的数据接口:

interface ICube {
id: number;
// 他车类别,比如BUS、TRUCK等
type: string;
// IPos和IColor定义可以参照上一篇文章或者github代码里找
position: IPos;
color: IColor;
width: number;
height: number;
length: number;
}

Cube 类参考如下:

// src/renderer/cube.ts
class Cube {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

draw(data: ICube) {
const { position, color, width, height, length } = data;
const material = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0.2,
});
// 如果一定要用setRGB设置颜色,建议直接让上游先除以255再给到前端,咱前端能不计算就不要计算
material.color.setRGB(color.r, color.g, color.b);
const geometry = new THREE.BoxGeometry(width, height, length);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(position.x, position.y, position.z ?? 0);
this.scene.add(mesh);
const edges = new THREE.EdgesGeometry(geometry);
const edgesMaterial = new THREE.LineBasicMaterial();
edgesMaterial.color.setRGB(color.r, color.g, color.b);
const line = new THREE.LineSegments(edges, edgesMaterial);
line.position.copy(mesh.position);
this.scene.add(line);
}
}

export default Cube;

顶部文字

他车其实携带了 id 和 type,为了直观看到这些属性,可以将他们绘制到对应的参与物顶部或其他容易观察的位置,可以用 TextGeometryFontLoader 实现 3d 文字。font 字体文件直接到 threejs 的 github 仓库 下载一个,我直接放 public 里了

老规矩,先定个基础接口:

export interface IText {
id: string;
// 字体大小
size: number;
color: IColor;
position: IPos;
content: string;
}

绘制函数参考如下:

// src/renderer/text.ts
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";

let font: any = null;
const fontLoader = new FontLoader();
fontLoader.load("gentilis_regular.typeface.json", (res) => {
font = res;
});

export function renderTextMesh(data: IText) {
const { content, color = { r: 1, g: 1, b: 1 }, position, size = 0.1 } = data;
const textGeo = new TextGeometry(content, {
font,
size,
depth: 0.01,
});
textGeo.computeBoundingBox();
const material = new THREE.MeshPhongMaterial();
material.color.setRGB(color.r, color.g, color.b);
const centerOffset =
-position.x * (textGeo.boundingBox!.max.x - textGeo.boundingBox!.min.x);
const textMesh = new THREE.Mesh(textGeo, material);
textMesh.position.set(centerOffset, position.y, position.z || 0);
textMesh.rotation.x = 0;
textMesh.rotation.y = Math.PI * 2;
return textMesh;
}

注意这里字体文件是异步加载的,在创建文字的时候需要先确保字体文件已经加载成功

然后怎么关联他车?注意 renderTextMesh 函数的返回值,在新建他车的时候,顺带新建一个 textMesh,并且在他车实例对象里保留这个 text 的引用,确保后续移除他车时能一并移除。在 cube 实例对象里追加下面的逻辑,具体位置看实际情况而定

// src/renderer/cube.ts
// ...
draw() {
// ...
this.scene.add(line);
// 绘制顶部文字
const text = id + "-" + type;
const textMesh = renderTextMesh({
id: text,
content: text,
position: {
x: mesh.position.x + width,
y: mesh.position.y + height / 2 + 0.1,
z: mesh.position.z,
},
});
this.scene.add(textMesh);
}

他车

文字缓存

这里针对 textMesh 可以做个缓存,因为大部分情况下他车在好几帧里面是重复的,对应的顶部文字也是相对固定的,毕竟 3d 文字渲染相对比较耗时,可以借助缓存直接复用对应的 Mesh 对象。因为 id+type 是唯一标识,可以作为缓存对象的 key,参考代码如下:

type TextCache = Record<string, THREE.Mesh>;
class Cube {
textCache: TextCache = {};
// ...
draw() {
// ...
if (this.textCache[text]) {
const textMesh = this.textCache[text];
mesh.textMesh = textMesh;
group.add(textMesh);
} else {
// ...
}
}
}

朝向箭头

这里可以用官方的三角箭头,但如果对各个视角的观察要求不是很高的话,其实这里只需要二维箭头,可以用 Shape来实现,在大量他车的场景下对渲染还是有一点点提升的。这里先做成固定长度的就行了,主要是提供一个方向的参数,也作为一个接口直接让上游控制箭头的绘制

// src/renderer/arrow.ts
export interface IArrow {
id: string;
// 箭头尾部坐标,可以确定方向和长度
endPoint: IPos;
origin: IPos;
length?: number;
// 颜色哈希值
hex?: string;
}

class Arrow {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

draw(data: IArrow) {
const arrowHelper = drawArrow(data);
this.scene.add(arrowHelper);
}
}

export default Arrow;

export function drawArrow(data: IArrow) {
const { origin, endPoint, hex = 0xffff00 } = data;
// 通过箭头起点和终点计算方向向量
const dir = new THREE.Vector3(
endPoint.x - origin.x,
endPoint.y - origin.y,
(endPoint.z ?? 0) - (origin.z ?? 0)
);
// 获取箭头长度
const length = dir.length();
const dirData = new THREE.Vector3(dir.x, dir.y, dir.z);
dirData.normalize();
const originPos = new THREE.Vector3(origin.x, origin.y, origin.z ?? 0);
const arrowHelper = new THREE.ArrowHelper(dirData, originPos, length, hex);
return arrowHelper;
}

其实可以把他车、文字和朝向箭头做成一个 Group,好处是减少 drawcall 和便于一起操作,比如移动、旋转和缩放等。Cube对象修改代码参考如下:

// src/renderer/cube.ts
draw(){
// ...
// 绘制顶部文字
const text = id + "-" + type;
const textMesh = renderTextMesh({
id: text,
content: text,
position: {
x: mesh.position.x + width,
y: mesh.position.y + height / 2 + 0.1,
z: mesh.position.z,
},
});
// 挂载到他车Mesh上
mesh.textMesh = textMesh;
group.add(textMesh);
// 绘制朝向箭头
const arrowMesh = drawArrow({
id: data.id + "-" + "arrow",
endPoint: {
// 注意:这里只是模拟数据,实际endPoint应该根据算法结果来确定
// 或者给个偏转角,前端做个计算,固定长度也行
x: mesh.position.x,
y: mesh.position.y,
z: mesh.position.z - length,
},
origin: {
x: mesh.position.x,
y: mesh.position.y,
z: mesh.position.z,
},
});
mesh.arrowMesh = arrowMesh;
group.add(arrowMesh);
this.scene.add(group);
}

mock 些数据来看看,效果如下:

他车

最后

如果还要追求更好的场景效果,也可以用参与物模型替代立方体。但其实参与物也不全是这种规则的立方体,还需要实现一些柱体、不规则立方体等以满足实际场景需求,而且有时候 3d 文字并不能提供足够的信息,这时候就需要考虑 2d 小卡片了

· 9 min read

智能驾驶业务里常用 web 3d 来可视化地图和传感器数据、显示路径规划结果等方法协助算法调试和仿真,可以用 threejs 来做,毕竟在国内社区相对活跃,也比较容易上手,效果类似下图: 示例1 示例2

当然以上图片都是客户端的版本,web3d 版本的 ui 其实并不会这么精致,毕竟只是服务于内部算法和研发。这个专栏纯属作者一时兴起并希望能产出一个麻雀虽小五脏俱全的行泊场景(简称人太闲),本文就先把自车的基础场景搭建起来

本专栏基于 three^0.167.1 版本

初始化项目

用 Vite 脚手架快速搭一个 react 项目用来调试

pnpm create vite autopilot --template react-ts

把 threejs 官网的例子稍微改下,加到项目里看看。新建一个 renderer 对象如下:

// src/renderer/index.ts
import * as THREE from "three";

class Renderer {
constructor() {
//
}

initialize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
const camera = new THREE.PerspectiveCamera(70, width / height, 0.01, 10);
camera.position.z = 1;
const scene = new THREE.Scene();
const geometry = new THREE.BoxGeometry(0.2, 0.2, 0.2);
const material = new THREE.MeshNormalMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
function animate(time: number) {
mesh.rotation.x = time / 2000;
mesh.rotation.y = time / 1000;
renderer.render(scene, camera);
}
}
}

export const myRenderer = new Renderer();
// App.tsx
import { useEffect } from "react";
import { myRenderer } from "./renderer";
import "./App.css";

function App() {
useEffect(() => {
myRenderer.initialize();
}, []);

return (
<>
<div id="my-canvas"></div>
</>
);
}

export default App;

加载自车

ok,跨出第一步了,接下来整辆自车(egoCar)

“自车”指的是自动驾驶汽车本身,它能够通过搭载的传感器、计算平台和软件系统实现自主导航和行驶

可以上 free3d 下载个免费的车辆模型,里面有很多种格式的,尽量找 gltf/glb 格式的(文件体积小,加载比较快)。

这里以加载 glb 格式的模型为例,可以先把模型文件放到 public 目录下,因为加载器相对网页的根路径(index.html)解析,而 public 目录在打包后会原封不动保存到根目录里

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const gltfLoader = new GLTFLoader();

class Renderer {
scene = new THREE.Scene();
// ...
loadEgoCar() {
gltfLoader.load("./su7.glb", (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
// ...
initialize() {
// ...
this.loadEgoCar();
}
}

但如果一定要放到 src/assets/models 目录里呢?然后通过 import 方式引入文件来用,那这么操作下来就会遇到这个报错(You may need to install appropriate plugins to handle the .glb file format, or if it's an asset, add "*/.glb" to assetsInclude in your configuration):

vite-import-glb-error

怎么解?在 vite``.config.ts 文件加入 assetsInclude。顺带把 vite 指定路径别名 alias 也支持一下

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 指定路径别名
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
assetsInclude: ["**/*.glb"],
});

node:url 如果提示没有该模块,先安装下@types/node,可能要重启下 vscode 才能生效

pnpm i @types/node -D

接下来就可以直接用 import 导入 glb 文件来用了

import carModel from "@/assets/models/su7.glb";

class Renderer {
// ...
loadEgoCar() {
gltfLoader.load(carModel, (gltf) => {
const car = gltf.scene;
car.scale.set(0.1, 0.1, 0.1);
this.scene.add(car);
});
}
}

OrbitControls

增加 OrbitControls 插件,便于调节自车视角,这个插件除了围绕目标点(默认是原点[0,0,0])旋转视角,还支持缩放(滚轮)和平移(鼠标右键,触摸板的话是双指长按)

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
class Renderer {
initialize() {
// ...
const controls = new OrbitControls(camera, renderer.domElement);
function animate() {
// ...
controls.update();
renderer.render(scene, camera);
}
}
}

自车暗

光源设置

看起来场景和自车都比较暗,咱们调下光源,加一个环境光 AmbientLight 和平行光 DirectionalLight,平行光位置放自车后上方,沿着自车方向(也就是原点方向)发射光源

// ...
// 没有特定方向,影响整个场景的明暗
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
// 平行光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(60, 80, 40);
scene.add(directionalLight);

自车亮

地面网格

增加坐标网格,提供一个水平的基准面,便于观察。这里需要新建一个 Grid 对象

// ...
// 50表示网格模型的尺寸大小,20表示纵横细分线条数量
const gridHelper = new THREE.GridHelper(50, 20);
scene.add(gridHelper);
// 顺带调高下相机位置
camera.position.set(0, 1, 1.8);
// 设置场景背景色(颜色值,透明度)
renderer.setClearColor(0x000000, 0.85);

地面网格

道路实现

这里先简单实现一段不规则道路,封装一个 freespace 对象,还要考虑它的不规则和带洞的可能,所以需要做好接口定义,其实数据主要是点集,一般这些点集都是地图上游发下来的,可能是 protobuf 或者 json 的格式

export interface IFreespace {
// 一般可以用于判断元素是否可复用
id: string;
position: IPos;
contour: IPos[];
// 洞可能有多个,所以这里应该设置成二维数组
holes?: IPos[][];
color?: IColor;
}
export interface IPos {
x: number;
y: number;
z?: number;
}
export interface IColor {
r: number;
g: number;
b: number;
a?: number;
}

因为只是一个平面形状,所以可以用 THREE.Shape 来实现,它可以和 ExtrudeGeometryShapeGeometry 一起使用来创建二维形状

// src/renderers/freespace.ts
class Freespace {
scene = new THREE.Scene();

constructor(scene: THREE.Scene) {
this.scene = scene;
}

draw(data: IFreespace) {
const {
contour,
holes = [],
color = { r: 0, g: 0, b: 0 },
position,
} = data;
if (contour.length < 3) {
return;
}
const shape = new THREE.Shape();
// 先绘制轮廓
// 设置起点
shape.moveTo(contour[0].x, contour[0].y);
contour.forEach((item) => shape.lineTo(item.x, item.y));
// 绘制洞
holes.forEach((item) => {
if (item.length < 3) {
return;
}
const path = new THREE.Path();
path.moveTo(item[0].x, item[0].y);
item.forEach((subItem) => {
path.lineTo(subItem.x, subItem.y);
});
// 注意这一步
shape.holes.push(path);
});
const shapeGeometry = new THREE.ShapeGeometry(shape);
const material = new THREE.MeshPhongMaterial();
// 注意:setRGB传参颜色值需要介于0-1之间
material.color.setRGB(color.r / 255, color.g / 255, color.b / 255);
material.opacity = color.a || 1;
const mesh = new THREE.Mesh(shapeGeometry, material);
mesh.position.set(position.x, position.y, position.z || 0);
mesh.rotateX(-Math.PI / 2);
this.scene.add(mesh);
}
}

export default Freespace;

ok 先用 mock 的数据画一段带洞的直道,加在 initialize 代码后就行,其实道路上还应该有一些交通标线,后面再加上吧

freespace

最后再监听下界面的 resize 事件,使其能根据容器实际大小变化动态调整场景

  // ...
constructor() {
// 初始化渲染对象
this.renderers = {
freespace: new Freespace(this.scene),
};
}
initialize() {
// ...
this.loadEgoCar();
this.registerDefaultEvents();
// mock
this.mockData();
}
mockData() {
this.renderers.freespace.draw(freespaceData1);
}
// 监听resize事件
registerDefaultEvents() {
window.addEventListener("resize", this.onResize.bind(this), false);
}
unmountDefaultEvents() {
window.removeEventListener("resize", this.onResize.bind(this), false);
}
onResize() {
const container = document.getElementById("my-canvas")!;
const width = container.offsetWidth,
height = container.offsetHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}

最后

ok 先到这了,主要是先把项目搭起来,给专栏开个头,后面会继续分享下更多地图和感知元素以及他车、行人、障碍物等效果的实现