Skip to main content

4 posts tagged with "canvas"

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

· 20 min read

最近在使用 fabric.js 做 canvas 相关的开发,搜集了蛮多资料,但是有点分散,所以想整合一下,方便参考,下面也只是列举一些常用的 api,更详细的内容还是要参考官方文档(ps: 官方文档 不太友好 emmm...)

简单介绍

Fabric.js 是一个强大而简单的 canvas 库,提供了交互式对象模型、多种易用的 api 和 SVG 解析器等。比较明显的缺点是不支持 webGL,所以不太适合渲染巨量图形的场景,可以另外尝试 pixi.js。结合 typescript 开发的话,需要下载声明文件 @types/fabric

常用对象

  • Point
  • 线条 Line、多段线 Polyline
  • 矩形 Rect、多边形 Polygon
  • 圆形 Circle
  • 三角形 Triangle
  • 文本 Text(IText)
  • 图像 Image
  • 分组 Group

对象常用属性汇总

  • left 横坐标;top 纵坐标
  • width 宽度;height 高度
  • originX 对象转换的水平原点;originY 对象转换的垂直原点
  • scaleX 水平方向缩放倍数;scaleY 垂直方向缩放倍数
  • angle 偏转角度;snapAngle 设置对象在旋转时锁定的角度
  • stroke 图形线条颜色;strokeWidth 线条宽度
  • fill 对象的填充色;backgroundColor 对象的背景色
  • selectable 对象是否可选中
  • hasControls 值为 false 时无法对对象进行旋转和拉伸
  • lockRotation 是否禁止旋转对象
  • lockMovementXlockMovementY 是否禁止移动对象
  • lockScaleXlockScaleY 是否禁止缩放对象
  • fontFamily 字体;fontSize 字号
  • type 标记对象类型

基础使用

import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';
fabric.Object.prototype.originX = 'center';
fabric.Object.prototype.originY = 'center';
const CANVAS_ID = 'map-canvas';

class MyCanvas {
initialize () {
let myCanvas = new fabric.Canvas(CANVAS_ID);
myCanvas.selection = false;
myCanvas.setWidth(canvasWidth);
myCanvas.setHeight(canvasHeight);
// 鼠标事件监听
canvas.on('mouse:wheel', (evt) => {
// actionManager 是一个事件管理器,统筹所有的用户操作和事件订阅
// rafThrottle 是节流函数
rafThrottle(() = actionManager.handleMouseWheel(evt));
});
canvas.on('mouse:move', (evt) => {
rafThrottle(() => actionManager.handleMouseMove(evt));
});
// 对象选中监听
canvas.on('selection:created', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:updated', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:cleared', (e: any) => {
actionManager.handleObjectUnSelect();
});
canvas.on('object:moving', (e: any) => {
actionManager.handleObjectMove(e);
});
// 对象变动监听,包括位置、大小、角度等变化的监听
canvas.on('object:modified', (e: any) => {
actionManager.handleObjectModified(e);
});
}
}
// ...

入口组件初始化 MyCanvas

useEffect(() => {
new MyCanvas().initialize()
}, [])
// ...
<canvas id={CANVAS_ID}>Map canvas</canvas>

常用方法

画布

import { fabric } from "fabric";
import { Canvas } from "fabric/fabric-impl";

//创建画布
const myCanvas = new fabric.Canvas("myCanvas");
// myCanvas.setBackgroundColor();
// myCanvas.setWidth();
// myCanvas.setHeight();
//标识画布中元素选中时,是否还按原有的层级位置展示
myCanvas.preserveObjectStacking = true;
// 重新渲染一遍画布,当画布中的对象有变更,在最后显示的时候,需要执行一次该操作
myCanvas.renderAll();
// 清除画布中所有对象:
myCanvas.clear();
// 只绘制视图区域的元素
myCanvas.skipOffscreen = true;

/**
* 设置对象位置的基准参考位置为自身中心点
*/
fabric.Object.prototype.originX = "center";
fabric.Object.prototype.originY = "center";

/**
* 设置元素选中框的样式
*/
// 边角节点大小
fabric.Object.prototype.cornerSize = 6;
// 边角节点背景透明 false
// fabric.Object.prototype.transparentCorners = false;
// 边框颜色
// fabric.Object.prototype.borderColor = '#ccc';
// 角节点内部颜色
// fabric.Object.prototype.cornerColor = '#fff';
// 角节点边框颜色
// fabric.Object.prototype.cornerStrokeColor = '#ccc';

对象

// 获得画布上的所有对象:
const items = myCanvas.getObjects();

// 设置画布中的对象的某个属性值,比如第 0 个对象的 id
const items = myCanvas.getObjects();
tems[0].id ="items_id0" 或 items[0].set("id","items_id0")

// 获得画布中对象的某个属性,比如 第0 个对象的 id
const items = myCanvas.getObjects();
items[0].id;
// or items[0].get("id");

// 对象按指定位置放置
const t = myCanvas.getActiveObject();
t.center();
t.centerH(); // 水平居中
t.centerV(); // 垂直居中

// 设置对象属性,如 rect.set({top: 50,left:100})
object.set()
// 缩放对象
object.scale()
// 旋转对象
object.rotate()
// 返回对象的坐标和边界信息
object.getBoundingRect()
// 当对象修改了坐标、长宽、缩放、角度、倾斜程度等可能改变对象位置的属性时需要通过该方法重新计算位置
object.setCoords()
// 检查对象是否与另一个对象相交
object.intersectsWithObject(other)

// 加载图片时图片缩放到指定的大小
fabric.Image.fromURL(image_src, function(img) {
img.set({
left:tmp_left,
top:tmp_top,
centeredScaling:true,
cornerSize: 7,
cornerColor: "#9cb8ee",
transparentCorners: false,
});
img.scaleToWidth(image_width);
img.scaleToHeight(image_height);
myCanvas.add(img).setActiveObject(img);
});

活动对象

// 设置画布上的某个对象为活动对象
const items = myCanvas.getObjects();
myCanvas.setActiveObject(items[i]);

// 获得画布上的活动对象
myCanvas.getActiveObject();

// 取消画布中的所有对象的选中状态
myCanvas.discardActiveObject(); // 如果这样不生效,可以使用 myCanvas.discardActiveObject().renderAll();

// 清除画布中的活动对象:
const t = canvas.getActiveObject();
myCanvas.remove(t);

// 设置活动对象在画布中的层级
const t = canvas.getActiveObject();
myCanvas.sendBackwards(t); // 向下跳一层
myCanvas.sendToBack(t); // 向下跳底层:
myCanvas.bringForward(t); // 向上跳一层:
myCanvas.bringToFront(t); // 向上跳顶层:
// or
t.sendBackwards();
t.sendToBack();
t.bringForward();
t.bringToFront();

常用事件

// 选中事件
myCanvas.on("selection:created", function (options) {});
myCanvas.on("selection:updated", function (options) {});
// 取消选中
myCanvas.on("selection:cleared", function (options) {});

// 对象移动事件
myCanvas.on("object:moving", function (options) {});
// 对象旋转事件
myCanvas.on("object:rotating", function (options) {});
// 对象缩放事件
myCanvas.on("object:scaling", function (options) {});
// 对象变动监听,包括位置、大小、角度等变化的监听
myCanvas.on("object:modified", function (e) {});
// 点击事件监听
myCanvas.on("mouse:down", function (e) {});
myCanvas.on("mouse:up", function (e) {});
// 文本编辑事件
myCanvas.on("text:changed", function (options) {});

场景

总结一些常用场景的实现方法

移动画布

// ...
this.canvas.on("mouse:move", this.handleMouseMove.bind(this))
handleMouseMove(event) {
const e = event.e as MouseEvent;
// ...
const delta = new fabric.Point(e.movementX, e.movementY);
canvas.relativePan(delta);
}

缩放画布

/**
* @description 从鼠标处缩放地图
* @param {Canvas} canvas
* @param {WheelEvent} e
*/
function zoomMapCenter(canvas: Canvas, e: WheelEvent) {
let zoom = 1;
// 控制缩放范围
zoom *= 0.999 ** e.deltaY;
if (zoom > 20) zoom = 20;
if (zoom < 0.01) zoom = 0.01;

canvas.zoomToPoint(
{
x: e.offsetX,
y: e.offsetY,
},
canvas.getZoom() * zoom
);
// updateMapOffset 更新地图偏移值
// adaptStrokeWidth 保持线条宽度
}

/**
* @description 从中心点放大地图
* @param {Canvas} canvas
* @param {number} [zoom=1.1]
*/
function zoomInMap(canvas: Canvas, zoom: number = 1.1) {
if (zoom > 20) zoom = 20;
canvas.zoomToPoint(
{ x: (canvas.width ?? 0) / 2, y: (canvas.height ?? 0) / 2 },
canvas.getZoom() * zoom
);
// updateMapOffset 更新地图偏移值
// adaptStrokeWidth 保持线条宽度
}

/**
* @description 从中心点缩小地图
* @param {Canvas} canvas
* @param {number} [zoom=0.9]
*/
function zoomOutMap(canvas: Canvas, zoom: number = 0.9) {
if (zoom < 0.01) zoom = 0.01;
canvas.zoomToPoint(
{ x: (canvas.width ?? 0) / 2, y: (canvas.height ?? 0) / 2 },
canvas.getZoom() * zoom
);
// updateMapOffset 更新地图偏移值
// adaptStrokeWidth 保持线条宽度
}

缩放时保持线条宽度

function adaptStrokeWidth(canvas: Canvas, width: number = 2) {
let strokeWidth = width / canvas.getZoom();
for (let object of canvas.getObjects()) {
if (object.strokeWidth) {
object.set("strokeWidth", strokeWidth);
if (object.strokeDashArray && object.strokeDashArray.length > 0) {
// 有虚线的话,还要做下调整
object.set("strokeDashArray", [strokeWidth * 5, strokeWidth * 2]);
}
}
}
}

操作后的鼠标定位

在对画布进行移动、缩放操作时,重新添加图形,会发现位置不准确的问题,其实就是偏移量的问题,因为画布尺寸并没有发生改变

1.首先要设置偏移量对象,记录最后一次的偏移量和缩放比例

const mapOffset = { x: 0, y: 0, zoom: 1 };

2.移动画布时累加偏移量

const mouseMoveEvent = (e) => {
// ...
mapOffset.x += e.e.movementX / mapOffset.zoom;
mapOffset.y += e.e.movementY / mapOffset.zoom;
};

3.缩放画布时计算偏移量和保存最新的缩放系数

const mouseWheelEvent = (e) => {
// ...
if (zoom === mapOffset.zoom) return;
const [x, y] = [e.offsetX, e.offsetY];
// 计算缩放时产生的偏移量
mapOffset.x += x / zoom - x / mapOffset.zoom;
mapOffset.y += y / zoom - y / mapOffset.zoom;
// 缩放操作
const zoomPoint = new fabric.Point(x, y);
myCanvas.zoomToPoint(zoomPoint, zoom);
mapOffset.zoom = zoom;
};

接下来新增图形时,通过以下代码可以正确获取到当前节点在画布中的真实位置

// ...
const mouseClickEvent = (e) => {
// ...
const [left, top] = [
e.pointer.x / mapOffset.zoom - mapOffset.x
e.pointer.y / mapOffset.zoom - mapOffset.y
];
}

划线

两点划线

这个两点划线的意思是先点击固定起点,另一边伺机而定。这个思路其实很简单,需要两个辅助变量 mouseFrommouseTo,和一个锁 isDrawingLine

  1. 触发第一次点击事件,赋值 mouseFrom,添加 Line
  2. 触发鼠标移动事件,赋值 mouseTo,然后修改 Line 的属性重绘画布,开锁
  3. 触发第二次点击事件,赋值 mouseTo,上锁

连续划线

其实思路和两点划线类似,只是将 Line 换成 Polyline,然后在鼠标移动过程中不断的重绘最后一段线,参考下面的代码:

// ...
object.set({
points: [
...object.points.slice(0, -1),
{ x, y },
],
});
canvas.requestRenderAll();

Polyline 会有 bug,在缩放或平移时,如果 Polyline 对象超出可视范围时会导致路线消失。解决办法有两个

  • 【推荐】改用多段 Line 的连续划线思路。其实就是用一个二维数组表示路径,每个元素是一段 Line,再增加或修改路径中某段路线的时候就很方便,不需要全量重绘
  • 缩放或平移的时候去除 offscreen 限制(设置 screen.skipOffscreen = false),但是会导致缩放或平移卡顿

绘制带洞多边形

带洞多边形其实就是在 Polygon 的基础上支持在内部绘制多个 Polygon,可以扩展 Polygon 来实现一个基础对象

import { fabric } from 'fabric';
type TPos = { x: number; y: number; z?: number };
export class FreespacePolygon extends fabric.Polygon {
fillRule: 'evenodd' | 'nonzero' = 'evenodd';
// NOTE: 注意这里是点集的二维数组,第一个为外边框,其余都是内洞
holes: TPos[][] = [];
constructor(paths: TPos[][], options?: fabric.IPolylineOptions) {
if (!paths) {
return;
}
const pathPoints = paths.map((points) => {
return points.map((point) => new fabric.Point(point.x, point.y));
});
const [outer, ...holes] = pathPoints;
super(outer, options);
this.holes = holes;
}
holesRender(ctx: CanvasRenderingContext2D) {
const { x, y } = this.pathOffset;
this.holes.forEach((hole) => {
const len = hole.length;
ctx.moveTo(hole[0].x - x, hole[0].y - y);
for (let i = 0; i < len; i += 1) {
const point = hole[i];
ctx.lineTo(point.x - x, point.y - y);
}
ctx.closePath();
});
}
_render(ctx: CanvasRenderingContext2D) {
if (!(this as any).commonRender(ctx)) {
return;
}
ctx.closePath();
this.holesRender(ctx);
this._renderPaintInOrder(ctx);
}
}

除了传参不同,其余使用方式同 Polygon

绘制路线曲线

可以通过两种方式绘制曲线用以平滑路径

  • 贝塞尔曲线
  • Catmull-Rom
type TPos = { x: number, y: number, z?: number };
type TGeometry = {
alt?: number,
lat: number,
lng: number,
};
class Vector {
x = 0;
y = 0;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
/**
* 计算曲线路径
*/
class CatmullRom {
pointsNum = 15;
interpolatedPosition(P0: TPos, P1: TPos, P2: TPos, P3: TPos, u: number) {
const u3 = u * u * u;
const u2 = u * u;
const f1 = -0.5 * u3 + u2 - 0.5 * u;
const f2 = 1.5 * u3 - 2.5 * u2 + 1.0;
const f3 = -1.5 * u3 + 2.0 * u2 + 0.5 * u;
const f4 = 0.5 * u3 - 0.5 * u2;
const x = P0.x * f1 + P1.x * f2 + P2.x * f3 + P3.x * f4;
const y = P0.y * f1 + P1.y * f2 + P2.y * f3 + P3.y * f4;
return new Vector(x, y);
}
getPoints(controlPoints: TGeometry[]) {
const points = [];
const len = controlPoints.length;
const head = {
lng: 2 * controlPoints[0].lng - controlPoints[1].lng,
lat: 2 * controlPoints[0].lat - controlPoints[1].lat,
};
const tail = {
lng: 2 * controlPoints[len - 1].lng - controlPoints[len - 2].lng,
lat: 2 * controlPoints[len - 1].lat - controlPoints[len - 2].lat,
};
let newControlPoints = controlPoints.slice(0);
newControlPoints.splice(0, 0, head);
newControlPoints.push(tail);
for (let i = 0; i < newControlPoints.length - 3; i++) {
const p0 = new Vector(newControlPoints[i].lng, newControlPoints[i].lat);
const p1 = new Vector(
newControlPoints[i + 1].lng,
newControlPoints[i + 1].lat
);
const p2 = new Vector(
newControlPoints[i + 2].lng,
newControlPoints[i + 2].lat
);
const p3 = new Vector(
newControlPoints[i + 3].lng,
newControlPoints[i + 3].lat
);
const max = this.pointsNum;
for (let j = 0; j <= max; j++) {
const p = this.interpolatedPosition(p0, p1, p2, p3, j / max);
points.push({ lng: p.x, lat: p.y });
}
}
return points;
}
}
const CATMULL = new CatmullRom();
export default CATMULL;

使用的话很方便,调用 getPoints,把路径点集传入,就可以得到转换后的曲线点集

const curvePoints = CATMULL.getPoints(points);
// 之后将 curvePoints 作为参数重新绘制路线即可

对象层叠

有时候会遇到多个对象层叠的情况,这个时候需要将点到的对象放到最上层便于操作

性能优化

内置优化

其实 Fabric.js 在 canvas 基础上已做了很多优化,比如 requestRenderAll、只渲染视图内的对象(skipOffscreen)、skipTargetFind、分层(上层负责响应交互事件,下层负责绘制画布)、批量绘制等,后续有时间可以研究研究源码,看看具体是怎么优化的。当绘制元素多了之后,就还需要考虑其他优化方向:

本地缓存

空间换时间,我实际遇到的项目是可以用 indexDB 缓存地图数据(较大的 json 对象),通过地图数据中的 md5 值来检查缓存的有效性。我也封装了一个工具 Hook,代码如下:

import { useRef, useState } from 'react';
import { ISOTimeStamp, TPlainObject } from '@/types';
import { reject } from 'lodash';
type TObjectStore = any;
const SIM_EDITOR_DB_NAME = 'editor_default_db';
const SIM_EDITOR_STORE_NAME = 'default';
/**
* @description 创建数据库
*/
function initIndexDB(name: string = SIM_EDITOR_DB_NAME, versionNum = 1) {
const request = window.indexedDB.open(name, versionNum);
return request;
}
export interface IData {
mapKey: string;
md5Sum: string;
data: TPlainObject | string;
createTime: ISOTimeStamp;
}
export function useIndexDB({
dbname = SIM_EDITOR_DB_NAME,
storeName = SIM_EDITOR_STORE_NAME,
index = 'key',
}) {
const objectStoreRef = useRef<IDBObjectStore>();
const request = initIndexDB(dbname);
const [isUpdate, setIsUpdate] = useState<number>(0);
request.onupgradeneeded = () => {
// NOTE 首次创建和db version变化才会执行这个函数;这个场景暂时没必要维护版本
const db = request.result;
// 创建存储空间
const objectStore = db.createObjectStore(storeName, {
keyPath: 'key',
autoIncrement: true,
});
// 创建索引
objectStore.createIndex(index, index, { unique: true });
};
request.onerror = () => {
console.error('indexDB init error');
};
// 重新获取数据
function refresh() {
setIsUpdate(isUpdate + 1);
}
/**
* @description 查询全部数据
*/
function findAll(): Promise<IData[] | null> {
console.log('===查询全部数据');
console.time('findAll');
return new Promise((resolve, reject) => {
initIndexDBStore()
.then((objectStore) => {
const getRequest = objectStore.getAll();
getRequest.onsuccess = () => {
if (getRequest.result) {
console.timeEnd('findAll');
resolve(getRequest.result);
} else {
console.log('not found');
resolve(null);
}
};
getRequest.onerror = (err: Error) => {
console.error('getRequest error');
reject(err);
};
})
.catch((err) => {
console.error('indexDB getAll error', err);
reject('indexDB error');
});
});
}
/**
* @description 创建事务,要通过事务操作数据库
*/
function initIndexDBStore(): Promise<TObjectStore> {
return new Promise((resolve, reject) => {
const openRequest = window.indexedDB.open(dbname);
openRequest.onsuccess = () => {
const db = openRequest.result;
const transaction = db.transaction(
[SIM_EDITOR_STORE_NAME],
'readwrite',
);
objectStoreRef.current = transaction.objectStore(SIM_EDITOR_STORE_NAME);
// const index = objectStoreRef.current.index('key');
resolve(objectStoreRef.current);
};
openRequest.onerror = (err) => {
console.error('openRequest error');
reject(err);
};
});
}
/**
* @description 查询数据
*/
function find(key: string): Promise<IData | null> {
console.log('===find, key: ', key);
console.time('find');
return new Promise((resolve, reject) => {
initIndexDBStore()
.then((objectStore) => {
const getRequest = objectStore.get(key);
getRequest.onsuccess = () => {
// console.log("===find getRequest: ", getRequest);
if (getRequest.result) {
console.timeEnd('find');
resolve(getRequest.result);
} else {
console.log('not found');
resolve(null);
}
};
getRequest.onerror = (err: Error) => {
console.error('getRequest error');
reject(err);
};
})
.catch((err) => {
console.error('indexDB get error', err);
reject('indexDB error');
});
});
}
/**
* @description 新增数据
*/
function add(
key: string,
md5Sum: string,
data: TPlainObject | string,
createTime: number,
) {
return new Promise((resolve, reject) => {
initIndexDBStore().then((objectStore) => {
const addRequest = objectStore.add({ key, data, createTime, md5Sum });
addRequest.onsuccess = (res: any) => {
console.log('add success');
refresh();
resolve(res.result);
};
addRequest.onerror = (err: Error) => {
console.error('addRequest error');
reject(err);
};
});
}).catch((err) => {
console.error('indexDB add error', err);
reject('indexDB error');
});
}
/**
* @description 删除数据
*/
function remove(key: string, isRefresh = true) {
return new Promise((resolve, reject) => {
initIndexDBStore().then((objectStore) => {
const deleteRequest = objectStore.delete(key);
deleteRequest.onsuccess = (res: any) => {
console.log('delete success');
isRefresh && refresh();
resolve(res.result);
};
deleteRequest.onerror = (err: Error) => {
console.error('deleteRequest error');
reject(err);
};
});
}).catch((err) => {
console.error('indexDB delete error', err);
reject('indexDB error');
});
}
/**
* @description 删除全部数据
*/
function removeAll() {
return new Promise((resolve, reject) => {
initIndexDBStore().then((objectStore) => {
const clearRequest = objectStore.clear();
clearRequest.onsuccess = (res: any) => {
console.log(`clear success`);
resolve(res.result); // should be undefined
};
clearRequest.onerror = (err: Error) => {
console.error('clearRequest error');
reject(err);
};
});
}).catch((err) => {
console.error('indexDB clear error', err);
reject('indexDB error');
});
}
/**
* @description 清理过期数据
*/
function clearExpirationJsons(jsons: any[]) {
return Promise.all(jsons.map((json) => remove(json.key, false)));
}
return {
indexdb: {
findAll,
find,
remove,
removeAll,
add,
},
};
}

注意:IndexDB 在查询的时候会去读取整个对象,有一个解析耗时,比如如果只是检查是否失效,建议可以自己设计个关联对象,只保存 key 和 md5 来判定

高频操作优化

主要是做下函数节流

const lock: Record<string, boolean> = {};
export function rafThrottle(callback = (time: number) => {}, key = "default") {
if (lock[key]) {
return false;
}
lock[key] = true;
window.requestAnimationFrame((time) => {
lock[key] = false;
callback(time);
});
return true;
}

其他优化

  • 异步绘制。分量绘制
  • 分片渲染。每次渲染间隔几毫秒,会增加总的渲染时长,但可以降低页面的卡顿感
  • 考虑临时分层。当我们在复杂的图层中拖动物体时,为了避免重绘该图层,可以将拖动的物体单独移到另外一层中渲染,当拖拽结束时再将物体移动回原来的图层
  • WebGL 实现 2D 渲染,可以考虑使用 pixijs

最后

持续更新 ing...

参考

· 6 min read

现在开发项目,提到图表,十有八九都会选择 echarts,不论是使用、性能或者社区活跃度都不错。

使用

react 项目可以使用 echarts-for-react^3 版本对应 echarts^5

import ReactECharts from "echarts-for-react";

const option = {};

<ReactECharts
option={option}
notMerge={true}
lazyUpdate={true}
onChartReady={this.onChartReadyCallback}
onEvents={EventsDict}
/>;

绝大部分项目都是只需要绘制小数据量的图表,外加一些动画或者悬浮交互,这些通过调用 api 就能满足需求。当然,api 也确实有很多,建议先熟悉初始配置、setOption 、事件,之后再根据具体图表类型再进一步熟悉和应用

文档链接

遇到的问题

实际上我遇到的问题,可以归纳为 高频数据下发,数据量不保证,前端页面需要确保曲线图绘制的实时性。这里涉及到三点可能导致卡顿的问题

  • 高频数据
  • 大对象数据解析
  • 绘制曲线图

解决办法主要是以下几点:

1.数据解析角度

  • 减小数据量

  • 后端(c++)解析数据,相比 js 解析数据肯定要更快

  • web worker。分担主线程解析和生成曲线数据的压力

  • 分流。可在后端或者 worker 中处理,按固定频率绘制曲线

    2.绘制角度

  • 降采样。不适配需求,当然 echarts 有很好的降采样算法,单看趋势的话,其实完全可以试试 ~

  • 去除动画,减少绘制耗时

  • 设置 showSymbol 为 false,即减少绘制元素,包括不必要的悬浮框等

  • 只渲染固定范围,控制耗时

最终的优化方案其实就用到了上面提到的几点:

  • 后端处理数据,转换成前端绘制需要的曲线数据,做好节流(比如 1s 绘制一次)
  • 前端接收 json 并解析
  • 维护一个队列,控制只绘制固定范围(用户自定义)的曲线,将耗时控制在 30ms 以内(不用 worker 是因为这一步处理耗时并不多)
  • 前端去除多余的动画、绘制元素以确保不产生无用的耗时

其他优化思路,待验证

  • 升级 echarts5,使用脏矩形优化,初步对比优化效果不明显。可能是数据量还没达到百万级别?
  • Web Worker + Transferable Objects
  • 增量渲染 appendData
  • 离屏渲染
  • canvas 分层

这些思路可能还要研究源码才能验证,因为经过之前的优化方案已经足以满足需求也就不再继续研究了。有空的话接着学习下 ~

按需引入

默认的引入方式会引入 echarts 中所有的图表和组件,建议按需引入

// 默认引入方式
import * as echarts from "echarts";

按需引入,详情参考 按需引入 ECharts 图表和组件,主要代码如下:

// 引入 echarts 核心模块
import * as echarts from "echarts/core";
import { BarChart } from "echarts/charts";
import { TitleComponent, TooltipComponent } from "echarts/components";
import { LabelLayout } from "echarts/features";
// NOTE: 必须引入渲染器
import { CanvasRenderer } from "echarts/renderers";
// 注册组件
echarts.use([
TitleComponent,
TooltipComponent,
BarChart,
LabelLayout,
CanvasRenderer,
]);
// 接下来的使用就跟之前一样了
const myChart = echarts.init(document.getElementById("chart"));
myChart.setOption({
// ...
});

echarts-for-react 按需引入(对于 echarts5)

import ReactEChartsCore from "echarts-for-react/lib/core";
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
TitleComponent,
DatasetComponent,
} from "echarts/components";
// NOTE: 必须引入渲染器
import {
CanvasRenderer,
// SVGRenderer,
} from "echarts/renderers";

echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
LineChart,
CanvasRenderer,
]);

const option = {};

<ReactEChartsCore
echarts={echarts}
option={option}
notMerge={true}
lazyUpdate={true}
onChartReady={this.onChartReadyCallback}
onEvents={EventsDict}
/>;

echarts 5 以下版本的按需引入参考文档(搜 With Echarts.js v3 or v4:) https://github.com/hustcc/echarts-for-react/

图表对比

实际上我用过的图表库很少,所以这里只是简单提一嘴..更详细的 benchmark 还需要自行搜索资料和验证 ~

  • echarts。不论是图表类型、api、使用体验和性能都是目前最佳,但是封装程度非常高,所以很难对其进一步抽象和封装,表现为定制化能力差
  • G2。对比前者,在数据对图形的控制上要更灵活,即定制化能力更强
  • biz-charts。基于 G2 进一步封装,更易于使用

在这版需求中,其实我基本上复刻了 webviz 的曲线图组件,支持实时和离线场景,后续可以尝试封装成通用组件,并提供下借鉴经验。可以期待下哈 ~

· 11 min read

基于 canvas,我们可以通过 js 绘制 2D 图形,而同样使用 canvas 元素的 WebGL API 则可以用于绘制硬件加速的 2D 和 3D 图形。现在对 canvas 的应用很普遍,比如画板工具、图表绘制(echarts)、处理视频、图片/富文本编辑器、excel、游戏等

基本使用

在 react 项目中简单画个矩形如下:

function App() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
function drawRect() {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 150, 100);
}
return (
<div>
<canvas ref={canvasRef} width={300} height={200}>
你的浏览器不支持canvas,请升级浏览器{" "}
</canvas>
<button onClick={drawRect}>render</button>
</div>
);
}

Canvas 元素默认的 width 和 height 属性值是 300 和 150 像素,如果直接使用样式类或者 style 属性来设置宽高时,如果新设置的宽度和高度与默认值不同,就会导致 Canvas 元素被缩放

canvas 创造了一个固定大小的画布,它公开了一个或多个渲染上下文,其可以用来绘制和处理要展示的内容。所以要绘制图形前,需要先获取到 渲染上下文

const ctx = canvas.getContext("2d");
// 可以用以下方法检测浏览器对canvas的支持性
// if (canvas.getContext){}

图形绘制

Canvas 使用的是 W3C 坐标系,坐标空间参考如下:

普通图形

canvas 支持两种形式的图形绘制:矩形和路径(由一系列点连成的线段)

绘制矩形

// 绘制填充矩形
ctx.fillRect(x, y, width, height);
// 绘制矩形边框
ctx.strokeRect(x, y, width, height);
// 清除矩形区域
ctx.clearRect(x, y, width, height);
// 或者用下面的方法绘制
// ctx.rect(x, y, w, h);
// ctx.stroke();
// ctx.fill();
// 拐角样式,默认尖角/斜角 bevel/圆角 round
// cxt.lineJoin = 'bevel'

绘制直线

ctx.beginPath();
// 定义直线的起点坐标
ctx.moveTo(10, 10);
// 定义直线的终点坐标
ctx.lineTo(50, 10);
// 沿着坐标点顺序的路径绘制直线
// 设置虚线
// cxt.setLineDash([10, 20]) // 10px间隔20px
ctx.stroke();
// 关闭当前的绘制路径
ctx.closePath();

绘制三角形

ctx.beginPath();
ctx.moveTo(75, 50);
ctx.lineTo(100, 75);
ctx.lineTo(100, 25);
// 会自动让所有形状闭合,区别与stroke
ctx.fill();

绘制圆

ctx.beginPath();
ctx.arc(100, 75, 50, 0, 2 * Math.PI);
ctx.stroke();
ctx.closePath();
// ctx.arc(x, y, r, sAngle, eAngle, counterclockwise);
// x,y:圆心坐标; r:半径大小;
// sAngle:起始角,以弧度计(弧的圆形的三点钟位置是 0 度)
// eAngel:结束角,以弧度计
// counterclockwise:可选。规定应该逆时针还是顺时针绘图。False = 顺时针,true = 逆时针

设置阴影

// 水平位移
ctx.shadowOffsetX = 10;
// 垂直位移
ctx.shadowOffsetY = 10;
// 设置模糊度
ctx.shadowBlur = 5;
// 设置阴影颜色
ctx.shadowColor = "rgba(0,0,0,0.5)";
// ctx.fillstyle = "#000"
// ctx.fillRect(100, 100, 100, 100)

阴影绘制是比较耗费性能的,尽量禁用阴影,其他优化手段有缓存阴影(预定义 canvas 渲染上下文)和优化阴影属性,尽可能使用较小的阴影模糊半径和偏移量,从而减少计算量。同时,使用 rgba() 颜色表示法,而不是十六进制颜色表示法,因为 rgba() 颜色表示法支持 alpha 透明度属性,而十六进制颜色表示法则不支持

不得不说,原生的 api 挺不方便的,所以大部分情况我们都会用 canvas 的封装库或者自己封装一些常用的绘制函数,减小使用成本

文本

const text = "Hello world";
ctx.font = "48px serif";
// 文本对齐方式
ctx.textAlign = "center";
// 填充颜色
ctx.fillStyle = "#000";
// 设置内容和坐标
ctx.fillText(text, 10, 50);
// 空心字
// ctx.strokeText(text, 10, 50);
// 获取文本宽度
// ctx.measureText(text).width

绘制文本都是比较耗费性能的,可以尽量避免,优化策略有缓存文本(offScreenCanvas 或 image 缓存)、禁止文本变换、使用字体缓存(在 DOM 上缓存 font)

加载图片

可以通过 drawImage 加载图片

// 正常加载图片
ctx.drawImage(image, x, y, width, height);
// 图片裁减,s前缀参数标识了裁减的范围
context.drawImage(img, sx, sy, swidth, sheight, x, y, width, height);

图片源可以是 HTMLImageElement、HTMLVideoElement、HTMLCanvasElement、ImageBitmap。ImageBitmap 是一个高性能的位图,创建过程是异步的(createImageBitmap,在另一个线程创建,不阻塞主线程),它可以从上述的所有源以及其他几种源中生成。并且可以在 worker 中创建,然后借助离屏 canvas 实现低延迟的绘制

加载视频的原理其实也是基于 drawImage。更多详情可以参考 使用图像

动画

动画原理其实就是不断的清空画布和重绘,可以用 requestAnimationFrame 来实现

为了避免动画卡顿,还需要尽可能减少动画的前置计算逻辑,可以分享个我们实际项目中的小车行驶场景,要通过一系列参数和算法计算出多辆小车的下一个位置,如果是前端计算,耗时会比较长,也就不可避免要造成卡顿,最终方案是后端同事封装了 c++函数,然后我们再将其封装成 wasm 模块供前端调用,从而减小计算耗时

优化

监控帧率

可以借助三方库 stats.jsreact-stats。自己实现的话,需要计算 1s 内的帧数得出 fps,可以参考以下代码:

var fps = 0;
var startTime = performance.now();

function loop(timestamp) {
var progress = timestamp - startTime;
fps++;
if (progress >= 1000) {
console.log(fps + "fps");
fps = 0;
startTime = timestamp;
}
window.requestAnimationFrame(loop);
}
window.requestAnimationFrame(loop);

性能优化

可以先看下 MDN 的优化建议

常见的性能优化手段如下:

  • requestAnimationFrame 优化高频重绘
  • 分层绘制
  • 可视范围画布
  • 脏矩形优化(局部重绘)
  • 离屏 canvas
  • ...

这一块后面进阶篇再仔细探究吧,感觉还是挺有趣的

三方库

我用的比较多的是 fabricjs,是一个比较出名的一个 canvas 库,不过国内用的人很少。该库封装了大量的图形对象,声明式绘制图形,然后也做了性能优化,比如分层绘制、offscreen、objectCache、脏矩形优化等,这些后面会整理篇 canvas 的进阶文章来串讲下

先定义一个全局的 Fabric 对象

import { fabric } from 'fabric';
import { Canvas } from 'fabric/fabric-impl';
const CANVAS_ID = 'map-canvas';

class MyCanvas {
initialize () {
let myCanvas = new fabric.Canvas(CANVAS_ID);
myCanvas.selection = false;
myCanvas.setWidth(canvasWidth);
myCanvas.setHeight(canvasHeight);
// 鼠标事件监听
canvas.on('mouse:wheel', (evt) => {
// actionManager 是一个事件管理器,统筹所有的用户操作和事件订阅
// rafThrottle 是节流函数
rafThrottle(() = actionManager.handleMouseWheel(evt));
});
canvas.on('mouse:move', (evt) => {
rafThrottle(() => actionManager.handleMouseMove(evt));
});
// 对象选中监听
canvas.on('selection:created', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:updated', (e: any) => {
actionManager.handleObjectSelect(e);
});
canvas.on('selection:cleared', (e: any) => {
actionManager.handleObjectUnSelect();
});
canvas.on('object:moving', (e: any) => {
actionManager.handleObjectMove(e);
});
// 对象变动监听,包括位置、大小、角度等变化的监听
canvas.on('object:modified', (e: any) => {
actionManager.handleObjectModified(e);
});
}
}

在组件中使用

useEffect(() => {
new MyCanvas().initialize()
}, [])
// ...
<canvas id={CANVAS_ID}>Map canvas</canvas>

更多使用参考我之前的总结 https://blog.zhouweibin.top/fabric/fabric-practice/

其他三方库

  • Konva.js
  • p5.js
  • Paper.js

基本没有用过,就不深入探究之间的区别了

参考

常见问题

对比 DOM

参考 https://zhuanlan.zhihu.com/p/143829714

对比 SVG

参考 https://github.com/abcrun/abcrun.github.com/issues/13

1px 线条模糊

本质原因是 Canvas 元素默认的坐标系是以像素的中心点为基准的,比如你尝试在 (0.5, 0.5)(或者其他像素点的中心点)处绘制一条 1px 的线,那么实际上它会被绘制成两条跨越了两个像素的线段,而不是你期望的一条 1px 线。解决方法:

  • translate 将绘制区域向右移动半个像素的距离
  • 虚线绘制
  • globalCompositeOperation

文字锯齿

  • 使用 CSS 进行抗锯齿处理 <canvas id="myCanvas" style="image-rendering: optimizeSpeed; -webkit-font-smoothing: antialiased;"></canvas>
  • 使用 textBaseline 属性对齐锚点 context.textBaseline = "middle"; // 设置对齐锚点为垂直方向的中心点
  • 如果需要对较小字体进行平滑处理,也可以考虑使用 WebFont 或 SVG 标签等技术来达到最佳效果。

cavans 污染

  • 只从可信的源获取图像数据,并且仅允许 trusted-image-source 进行绘制
  • 限制 Canvas 对外部的访问权限,只授权访问某些特定的域或者协议
  • 禁止用户输入代码并在 Canvas 上执行,或者在执行前先进行验证或者过滤,确保输入的内容不会对 Canvas 造成不良影响

推荐资源