开发了一个 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 图形。
项目结构:
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 # 打包入口
|
快速开始
安装:
将 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 开头,如 drawRect
、drawCircle
、drawLine
等。
但你应该先调用 beginFill
或 beginLine
方法,以表明开始填充或描边,并传入样式参数。
添加事件:
你可以为图案对象添加事件监听器,如 click
、mousedown
、mousemove
等,并具有和 DOM 一样的事件传播机制。
1 2 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
,也就是说,所有图形节点本身也是组,可以添加子节点,以此形成树结构。
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);
|
在同一个组中,其透明度、变换(旋转、缩放等)是共享叠加的,如:父节点的变换叠加上子节点自身的局部变换,形成子节点最终的全局变换效果。
Graphics
除了可以绘制基础图形,还可以绘制路径,这和原生 Canvas 类似,不过和绘制图形一样,需要提前调用 beginFill
或 beginLine
方法。
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: HTMLCanvasElement; 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; get ctx(): T; get canvas(): HTMLCanvasElement; }
|
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; 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
,用于管理子节点。(内部类,未向外暴露)
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; 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
)都具有生命周期,以便在特定的时机执行特定的操作。
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"); });
|
顺序如下:
onBeforeMount
:挂载到父节点前触发。
onMounted
:挂载到父节点后触发。
onBeforeRender
:每帧渲染前触发。
onRendering
:每帧渲染时触发,此时节点自身已经渲染完毕,但子节点还未渲染。
onRendered
:每帧渲染后触发,此时节点及其子节点都已经渲染完毕。
onBeforeUnmount
:从父节点移除前触发。
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);
|