开发了一个 Canvas 2D 渲染引擎
发表于更新于
字数总计:2.9k阅读时长:11分钟阅读量:
前言
写一个 Canvas 2D 渲染引擎,让绘制更简单些。
参考:
Canvas的高级绘制技巧和事件系统
canvas进阶——如何实现canvas的事件系统
聊聊Canvas事件机制相关 (非API层,偏框架设计方面)
【前端】从零开始的Canvas事件处理系统(上)
从0-1入门数据可视化-canvas事件系统
如何实现一个canvas渲染引擎
canvas核心技术-如何实现碰撞检测
二、Canvas进阶-9、碰撞检测
“等一下,我碰!”——常见的2D碰撞检测
浅谈 Canvas 渲染引擎
手把手教你打造一款轻量级canvas渲染引擎
leaferjs,全新的 Canvas 渲染引擎
基本架构
Canvas 的原生 API 是非常底层的,所有图形不分层级、父子关系依次覆盖绘制。
为了方便绘制,一个 Canvas 引擎应该如下设计:
- 数据结构:仿照 DOM 树结构,设计出层级关系的图形树结构。
- 层级关系:抽象出节点,具有坐标位置、样式等基础属性,节点之间可以有父子、兄弟关系。
- 组:一个节点及其子节点的集合,可以看作一个组。组也是一个节点,可以作为其他组的子节点。各种图形类都继承自组。
- 绘制顺序:祖先节点最先绘制,后代节点后绘制,兄弟节点由 z-index 控制,视觉上子元素在上层。
- 定位:子节点完全依赖父节点进行定位,相当于 position:static。
- 锚点:在canvas中一切变换都是相对于原点进行的,所以需要实现锚点来确定变换的中心。
- 封装:将 Canvas API 的封装成更简单、清晰的形式,贴近于我们熟悉的 DOM,屏蔽底层绘制细节。
- 性能:异步批量渲染、离屏渲染、脏区渲染。
- 包围盒:AABB、OBB、碰撞检测。这是为了知道每个节点的位置和大小,以便处理图形相交、事件等。
- 排版系统:成熟的 canvas 库一般具有排版系统,可以脱离坐标系进行布局。
- 多画布:大型的应用可能需要多个画布,就如 Konva,它抽象出了一个舞台 Stage,包含多个画布 Layer,每个 Layer 可以包含多个节点 Node。
qx-canvas
项目地址:qx-canvas
花了一星期时间完成,虽然还没用过 pixijs,但 api 格式是仿照其设计的。
实现了基础图形和路径的绘制、以组为基本渲染单元的树结构、完善的碰撞检测、具有传播机制的事件系统。可以像 DOM 一样操作 Canvas 图形。
项目结构:
| 12
 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 # 打包入口
 
 | 
快速开始
安装:
将 canvas 元素传入 App 类,即可创建一个 App 实例。App 用于管理整个 canvas 及其事件系统。
| 12
 3
 4
 
 | import * as QxCanvas from "qx-canvas";const app = new QxCanvas.App({
 canvas: document.querySelector("canvas")!,
 });
 
 | 
接着使用 Graphics 类创建图案对象,并挂载到 app.stage 舞台(根节点)上。
绘制图形:
Graphics 具有一系列绘制基础图形的方法,它们以 draw 开头,如 drawRect、drawCircle、drawLine 等。
但你应该先调用 beginFill 或 beginLine 方法,以表明开始填充或描边,并传入样式参数。
添加事件:
你可以为图案对象添加事件监听器,如 click、mousedown、mousemove 等,并具有和 DOM 一样的事件传播机制。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | const rect1 = new QxCanvas.Graphics()
 .beginFill({
 color: "blue",
 })
 .drawRect(200, 0, 100, 100)
 .setPivot(200, 0)
 .setRotation(45)
 .setScale(2, 2)
 .setAlpha(0.5)
 .setCursor("pointer")
 .addEventListener("click", (e) => {
 
 console.log(e.clone());
 });
 app.stage.add(rect1);
 
 | 
app.stage 是舞台、根节点,所有 Graphics 图形都是其后代节点,其类型为 Group 表示组的概念。
Graphics 继承自 Group,也就是说,所有图形节点本身也是组,可以添加子节点,以此形成树结构。
| 12
 3
 4
 5
 6
 7
 
 | const circle1 = new QxCanvas.Graphics().beginLine({
 width: 5,
 })
 .setPosition(0, 100)
 .drawCircle(200, 0, 20);
 rect1.add(circle1);
 
 | 
在同一个组中,其透明度、变换(旋转、缩放等)是共享叠加的,如:父节点的变换叠加上子节点自身的局部变换,形成子节点最终的全局变换效果。
Graphics 除了可以绘制基础图形,还可以绘制路径,这和原生 Canvas 类似,不过和绘制图形一样,需要提前调用 beginFill 或 beginLine 方法。
| 12
 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 及其事件系统。
| 12
 3
 
 | const app = new QxCanvas.App({canvas: document.querySelector("canvas")!,
 });
 
 | 
参数:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | export interface IAppOptions {
 canvas: HTMLCanvasElement;
 
 width?: number;
 height?: number;
 
 backgroundColor?: string;
 
 backgroundAlpha?: number;
 }
 
 | 
属性和方法:
| 12
 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;
 get ctx(): T;
 get canvas(): HTMLCanvasElement;
 }
 
 | 
Node
Node 类是所有节点的基类,具有一些基础的属性和方法。(内部类,未向外暴露)
| 12
 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;
 class: string[];
 get zIndex(): number;
 set zIndex(value: number);
 
 
 setZIndex(index: number): this;
 setAlpha(alpha: number): this;
 setVisible(visible: boolean): this;
 setCursor(cursor: Cursor): this;
 setHitArea(hitArea: Shape): this;
 setId(id: string): this;
 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;
 
 
 findById(id: string): this | null;
 findByClass(className: string): this[];
 }
 
 | 
Group
Group 类是组对象,继承自 Node,用于管理子节点。(内部类,未向外暴露)
| 12
 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,用于绘制图形、路径,以及绑定事件监听器。
| 12
 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;
 
 bezierCurveTo(controlPoints: number[], t?: number, accuracy?: number): this;
 closePath(): this;
 
 beginPath(): this;
 
 clearPath(): this;
 
 beginClip(): this;
 
 endClip(): this;
 
 
 
 setMask(x: number, y: number, width: number, height: number, graphics: Graphics, type?: GlobalCompositeOperation): this;
 }
 
 | 
生命周期
每个图案对象 Graphics(Group)都具有生命周期,以便在特定的时机执行特定的操作。
| 12
 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");
 });
 
 | 
顺序如下:
- onBeforeMount:挂载到父节点前触发。
- onMounted:挂载到父节点后触发。
- onBeforeRender:每帧渲染前触发。
- onRendering:每帧渲染时触发,此时节点自身已经渲染完毕,但子节点还未渲染。
- onRendered:每帧渲染后触发,此时节点及其子节点都已经渲染完毕。
- onBeforeUnmount:从父节点移除前触发。
- onUnmounted:从父节点移除后触发。
案例
使用 qx-canvas 实现一些简单的案例。
有了渲染引擎,写这些小Demo就非常方便且简单了。
绘画板
可以选择画笔颜色的绘画板。
| 12
 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 设置位置偏移量。
| 12
 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 }
 );
 });
 
 | 
刮刮乐
利用遮罩和自由绘画实现。

| 12
 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);
 
 |