前言
autopilot 需要数据才能跑起来,想了下自己造数据太麻烦了,所以基于 konva(canvas)做一个场景编辑器来造点数据,目标是通过编辑器编辑地图和基于地图编辑一个基础场景(自车+车道+环境车+行驶模拟),并在 autopilot 应用中跑起来
这里咱直接用成熟的 canvas 库 konva
来搭建页面,这个库可以先用官网的 editor-demo 体验下。konva 的图层层级结构分为 Stage -> Layer(层) (-> Group) -> Shape(元素)
,可以先熟悉下这几个概念,不熟的话可能比较难以看懂下面的示例代码
页面布局
稍微设计一下:中间画布+顶部操作栏+右侧属性面板。关于组件库的话,antd 够用了,如下图所示:
暂时先只做一个地图和一个场景,后面再扩展下多对多的情况
数据管理
通过 mobx
和 mobx-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; } }
场景编辑
先支持他车的编辑,以矩形框替代,可以设置高度,看看这个交互是咋样的:
点击他车按钮
点击画布任意处新增他车矩形框
添加他车矩形框,矩形框支持旋转、改变大小和拖动
处于选中状态时,右侧表单自动回填属性,包括名称、宽高、位置等,可以修改,保存场景时会以最新的属性为准
添加代码参考:
// 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); } }