Canvas-系列
Canvas 2D 基础
Canvas 2D 进阶
Canvas 2D 贝塞尔曲线
Canvas 2D 事件
开发了一个 Canvas 2D 渲染引擎

前言

写一个 Canvas 2D 渲染引擎,让绘制更简单些。

参考:
Canvas的高级绘制技巧和事件系统
canvas进阶——如何实现canvas的事件系统
聊聊Canvas事件机制相关 (非API层,偏框架设计方面)
【前端】从零开始的Canvas事件处理系统(上)
从0-1入门数据可视化-canvas事件系统
如何实现一个canvas渲染引擎
canvas核心技术-如何实现碰撞检测
二、Canvas进阶-9、碰撞检测
“等一下,我碰!”——常见的2D碰撞检测
浅谈 Canvas 渲染引擎
手把手教你打造一款轻量级canvas渲染引擎
leaferjs,全新的 Canvas 渲染引擎

基本架构

Canvas 的原生 API 是非常底层的,所有图形不分层级、父子关系依次覆盖绘制。

为了方便绘制,一个 Canvas 引擎应该如下设计:

  1. 数据结构:仿照 DOM 树结构,设计出层级关系的图形树结构。
  2. 层级关系:抽象出节点,具有坐标位置、样式等基础属性,节点之间可以有父子、兄弟关系。
  3. 组:一个节点及其子节点的集合,可以看作一个组。组也是一个节点,可以作为其他组的子节点。各种图形类都继承自组。
  4. 绘制顺序:祖先节点最先绘制,后代节点后绘制,兄弟节点由 z-index 控制,视觉上子元素在上层。
  5. 定位:子节点完全依赖父节点进行定位,相当于 position:static。
  6. 锚点:在canvas中一切变换都是相对于原点进行的,所以需要实现锚点来确定变换的中心。
  7. 封装:将 Canvas API 的封装成更简单、清晰的形式,贴近于我们熟悉的 DOM,屏蔽底层绘制细节。
  8. 性能:异步批量渲染、离屏渲染、脏区渲染。
  9. 包围盒:AABB、OBB、碰撞检测。这是为了知道每个节点的位置和大小,以便处理图形相交、事件等。
  10. 排版系统:成熟的 canvas 库一般具有排版系统,可以脱离坐标系进行布局。
  11. 多画布:大型的应用可能需要多个画布,就如 Konva,它抽象出了一个舞台 Stage,包含多个画布 Layer,每个 Layer 可以包含多个节点 Node。

qx-canvas

项目地址:qx-canvas

花了一星期时间完成,虽然还没用过 pixijs,但 api 格式是仿照其设计的。

实现了基础图形和路径的绘制、以组为基本渲染单元的树结构、完善的碰撞检测、具有传播机制的事件系统。可以像 DOM 一样操作 Canvas 图形。

项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
├───📁 display/ # 布局管理,树结构
│ ├───📄 group.ts # 组
│ ├───📄 index.ts
│ └───📄 node.ts # 节点
├───📁 events/ # 事件
│ ├───📄 eventAdmin.ts # 事件管理器
│ ├───📄 eventClient.ts # 事件中心
│ ├───📄 eventObject.ts # 事件对象
│ ├───📄 eventSystem.ts # 事件系统
│ └───📄 index.ts
├───📁 graphics/ # 图案
│ ├───📁 style/ # 上下文样式
│ │ ├───📄 fill.ts # 填充样式
│ │ ├───📄 index.ts
│ │ └───📄 line.ts # 描边样式
│ ├───📄 graphics.ts # 图案类
│ ├───📄 graphicsData.ts # 图形数据
│ └───📄 index.ts
├───📁 math/ # 数学相关
│ ├───📄 bezier.ts # 贝塞尔曲线
│ ├───📄 constant.ts # 常量
│ ├───📄 index.ts
│ ├───📄 matrix.ts # 3*3矩阵
│ ├───📄 point.ts # 点
│ └───📄 transform.ts # 变换矩阵
├───📁 renderer/ # 渲染相关
│ ├───📄 canvasRenderer.ts # canvas2d渲染器
│ ├───📄 index.ts
│ └───📄 renderer.ts # 渲染器
├───📁 shapes/ # 各种可绘制的基础图形
│ ├───📄 circle.ts # 圆
│ ├───📄 ellipse.ts # 椭圆
│ ├───📄 image.ts # 图像
│ ├───📄 index.ts
│ ├───📄 path.ts # 路径
│ ├───📄 polygon.ts # 多边形
│ ├───📄 rectangle.ts # 矩形
│ ├───📄 roundRect.ts # 圆角矩形
│ ├───📄 shape.ts # 图形抽象类
│ └───📄 text.ts # 文本
├───📁 test/ # 测试
│ └───📄 index.ts
├───📁 types/ # 类型
│ ├───......
├───📁 utils/ # 工具函数
│ └───📄 index.ts
├───📄 app.ts # 库入口
└───📄 index.ts # 打包入口

快速开始

安装:

1
pnpm i qx-canvas -S

canvas 元素传入 App 类,即可创建一个 App 实例。App 用于管理整个 canvas 及其事件系统。

1
2
3
4
import * as QxCanvas from "qx-canvas";
const app = new QxCanvas.App({
canvas: document.querySelector("canvas")!,
});

接着使用 Graphics 类创建图案对象,并挂载到 app.stage 舞台(根节点)上。

绘制图形:
Graphics 具有一系列绘制基础图形的方法,它们以 draw 开头,如 drawRectdrawCircledrawLine 等。
但你应该先调用 beginFillbeginLine 方法,以表明开始填充描边,并传入样式参数。

添加事件:
你可以为图案对象添加事件监听器,如 clickmousedownmousemove 等,并具有和 DOM 一样的事件传播机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const rect1 = new QxCanvas.Graphics()
// `Graphics` 的所有方法都支持链式调用
.beginFill({
color: "blue",
})
.drawRect(200, 0, 100, 100)
.setPivot(200, 0) // 设置变换中心
.setRotation(45) // 顺时针旋转45度
.setScale(2, 2) // 放大两倍
.setAlpha(0.5) // 透明度
.setCursor("pointer") // 鼠标样式
.addEventListener("click", (e) => {
// 若你需要在异步环境中使用事件对象,需要调用 clone 方法,以拷贝其副本。
console.log(e.clone());
});
app.stage.add(rect1);

app.stage舞台、根节点,所有 Graphics 图形都是其后代节点,其类型为 Group 表示的概念。

Graphics 继承自 Group,也就是说,所有图形节点本身也是,可以添加子节点,以此形成树结构。

1
2
3
4
5
6
7
const circle1 = new QxCanvas.Graphics()
.beginLine({
width: 5,
})
.setPosition(0, 100) // 设置位置偏移量
.drawCircle(200, 0, 20);
rect1.add(circle1); // 作为rect1的子节点

在同一个组中,其透明度、变换(旋转、缩放等)是共享叠加的,如:父节点的变换叠加上子节点自身的局部变换,形成子节点最终的全局变换效果。

Graphics 除了可以绘制基础图形,还可以绘制路径,这和原生 Canvas 类似,不过和绘制图形一样,需要提前调用 beginFillbeginLine 方法。

1
2
3
4
5
6
7
const path1 = new QxCanvas.Graphics()
.beginLine()
.moveTo(100, 100)
.lineTo(200, 200)
.lineTo(300, 100)
.closePath();
app.stage.add(path1);

API 文档

较为详细的 API 文档。

App

App 类是整个引擎的入口,用于管理整个 canvas 及其事件系统。

1
2
3
const app = new QxCanvas.App({
canvas: document.querySelector("canvas")!,
});

参数:

1
2
3
4
5
6
7
8
9
10
11
export interface IAppOptions {
// 传入的canvas元素
canvas: HTMLCanvasElement;
// canvas 元素宽高
width?: number;
height?: number;
// 背景颜色
backgroundColor?: string;
// 背景透明度
backgroundAlpha?: number;
}

属性和方法:

1
2
3
4
5
6
7
8
declare class App<T extends IContext["ctx"] = CanvasRenderingContext2D> {
readonly stage: Group; // 舞台,根节点
constructor(options: IAppOptions);
clear(): void; // 清空舞台
resize(width: number, height: number): void; // 调整canvas大小
get ctx(): T; // 获取canvas上下文
get canvas(): HTMLCanvasElement; // 获取canvas元素
}

Node

Node 类是所有节点的基类,具有一些基础的属性和方法。(内部类,未向外暴露)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
declare abstract class Node extends EventClient {
visible: boolean; // 节点是否可见
readonly transform: Transform; // 变换对象
cursor: Cursor; // 鼠标样式
hitArea: Shape | null; // 自定义碰撞图形(区域)
parent: this | null; // 父节点
readonly children: this[]; // 子节点列表
id: string; // 节点id
class: string[]; // 节点类名
get zIndex(): number; // 获取节点层级
set zIndex(value: number); // 设置节点层级

// 一些操作属性的方法,返回this,方便链式调用
setZIndex(index: number): this; // 设置节点层级
setAlpha(alpha: number): this; // 设置透明度
setVisible(visible: boolean): this; // 设置可见性
setCursor(cursor: Cursor): this; // 设置鼠标样式
setHitArea(hitArea: Shape): this; // 设置碰撞区域
setId(id: string): this; // 设置节点id
setClass(className: string): this; // 覆盖设置类名
addClass(className: string): this; // 添加类名
removeClass(className: string): this; // 移除类名
setScale(x: number, y: number): this; // 设置缩放
setRotation(rotation: number): this; // 设置旋转角度
setPosition(x: number, y: number): this; // 设置位置偏移量
setPivot(x: number, y: number): this; // 设置变换中心
setSkew(x: number, y: number): this; // 设置倾斜角度

// 添加事件监听器
addEventListener(type: EventType, listener: EventListener, options?: boolean | EventOptions): this;
// 移除事件监听器
removeEventListener(type: EventType, listener: EventListener, capture?: boolean): this;

// 根据id或class查找子节点
findById(id: string): this | null;
findByClass(className: string): this[];
}

Group

Group 类是对象,继承自 Node,用于管理子节点。(内部类,未向外暴露)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
declare class Group extends Node {
add(child: this | this[]): this; // 添加子节点
remove(child: this): this; // 移除子节点
removeChildren(): this; // 移除所有子节点
removeSelf(): this; // 从父节点移除自身
destroy(): void; // 销毁节点,移除自身及所有子节点
getSpreadPath(): Group[]; // 获取传播路径,即从根节点到自身的路径

// 生命周期
onBeforeMount(handler: (item: this) => void): this;
onMounted(handler: (item: this) => void): this;
onBeforeRender(handler: (item: this, renderer: CanvasRenderer) => void): this;
onRendering(handler: (item: this, renderer: CanvasRenderer) => void): this;
onRendered(handler: (item: this, renderer: CanvasRenderer) => void): this;
onBeforeUnmount(handler: (item: this) => void): this;
onUnmounted(handler: (item: this) => void): this;
}

Graphics

Graphics 类是图案对象,继承自 Group,用于绘制图形、路径,以及绑定事件监听器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
declare class Graphics extends Group {
readonly graphicsDataList: GraphicsData[];
// 开始填充或描边
beginFill(style?: Partial<FillStyleType>): this;
beginLine(style?: Partial<LineStyleType>): this;
// 结束填充或描边
endFill(): this;
endLine(): this;

// 判断点是否在图形内
contains(p: Point): boolean;

// 绘制基础图形
// 矩形
drawRect(x: number, y: number, width: number, height: number): this;
// 圆
drawCircle(x: number, y: number, radius: number): this;
// 椭圆
drawEllipse(x: number, y: number, radiusX: number, radiusY: number): this;
// 圆角矩形
drawRoundRect(x: number, y: number, width: number, height: number, radius: number): this;
// 多边形
drawPolygon(points: number[]): this;
// 文本
drawText(text: string, x: number, y: number, textStyle?: TextStyle): this;

// 绘制路径
moveTo(x: number, y: number): this;
lineTo(x: number, y: number): this;
// 绘制任意数量控制点的贝塞尔曲线,指定 t 参数,绘制一定曲线阶段,accuracy 参数指定精度
bezierCurveTo(controlPoints: number[], t?: number, accuracy?: number): this;
closePath(): this;
// 开始新路径
beginPath(): this;
// 清空路径
clearPath(): this;
// 开始剪切
beginClip(): this;
// 结束剪切
endClip(): this;

// 设置遮罩,将另一个图案对象作为遮罩
// x、y、width、height,设置遮罩作用区域。type,设置遮罩的合成方式
setMask(x: number, y: number, width: number, height: number, graphics: Graphics, type?: GlobalCompositeOperation): this;
}

生命周期

每个图案对象 GraphicsGroup)都具有生命周期,以便在特定的时机执行特定的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const s = new QxCanvas.Graphics()
s.onBeforeMount((item) => {
console.log("before mount");
})
.onMounted((item) => {
console.log("mounted");
})
.onBeforeRender((item, renderer) => {
console.log("before render");
})
.onRendering((item, renderer) => {
console.log("rendering");
})
.onRendered((item, renderer) => {
console.log("after render");
app.stage.remove(s);
})
.onBeforeUnmount((item) => {
console.log("before unmount");
})
.onUnmounted((item) => {
console.log("unmounted");
});

顺序如下:

  1. onBeforeMount:挂载到父节点前触发。
  2. onMounted:挂载到父节点后触发。
  3. onBeforeRender:每帧渲染前触发。
  4. onRendering:每帧渲染时触发,此时节点自身已经渲染完毕,但子节点还未渲染。
  5. onRendered:每帧渲染后触发,此时节点及其子节点都已经渲染完毕。
  6. onBeforeUnmount:从父节点移除前触发。
  7. onUnmounted:从父节点移除后触发。

案例

使用 qx-canvas 实现一些简单的案例。

有了渲染引擎,写这些小Demo就非常方便且简单了。

绘画板

可以选择画笔颜色的绘画板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const color = document.querySelector("#color") as HTMLInputElement;

const s1 = new QxCanvas.Graphics();

app.stage.add(s1).addEventListener("mousedown", (e) => {
s1.beginLine({ width: 2, color: color.value });
const onMove = (e: any) => {
s1.lineTo(e.global.x, e.global.y);
};
app.stage.addEventListener("mousemove", onMove);
app.stage.addEventListener(
"mouseup",
() => {
app.stage.removeEventListener("mousemove", onMove);
},
{ once: true }
);
});

任意图形拖动

使用 setPosition 设置位置偏移量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
a.addEventListener("mousedown", (e) => {
const mouseDownPoint = e.global.clone();
const { x, y } = a.transform.position;
const onMove = (e: any) => {
const movePoint = e.global.clone();
const dx = movePoint.x - mouseDownPoint.x;
const dy = movePoint.y - mouseDownPoint.y;
a.setPosition(x + dx, y + dy);
};
app.stage.addEventListener("mousemove", onMove);
app.stage.addEventListener(
"mouseup",
() => {
app.stage.removeEventListener("mousemove", onMove);
},
{ once: true }
);
});

刮刮乐

利用遮罩和自由绘画实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const mask = new QxCanvas.Graphics().beginLine({ width: 12 });

const bg = new QxCanvas.Graphics()
.beginFill({ color: "#ccc" })
.drawRect(0, 0, app.canvas.width, app.canvas.height)
.setMask(0, 0, app.canvas.width, app.canvas.height, mask, "destination-out")
.addEventListener("mousedown", (e) => {
mask.moveTo(e.global.x, e.global.y);
const onMove = (e: any) => {
mask.lineTo(e.global.x, e.global.y);
};
bg.addEventListener("mousemove", onMove);
bg.addEventListener("mouseup", () => {
bg.removeEventListener("mousemove", onMove);
});
});

app.stage.add(bg);