前言
写一个 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 图形。
项目结构:
├───📁 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 # 打包入口快速开始
安装:
pnpm i qx-canvas -S将 canvas 元素传入 App 类,即可创建一个 App 实例。App 用于管理整个 canvas 及其事件系统。
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 一样的事件传播机制。
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,也就是说,所有图形节点本身也是组,可以添加子节点,以此形成树结构。
const circle1 = new QxCanvas.Graphics() .beginLine({ width: 5, }) .setPosition(0, 100) // 设置位置偏移量 .drawCircle(200, 0, 20);rect1.add(circle1); // 作为rect1的子节点在同一个组中,其透明度、变换(旋转、缩放等)是共享叠加的,如:父节点的变换叠加上子节点自身的局部变换,形成子节点最终的全局变换效果。
Graphics 除了可以绘制基础图形,还可以绘制路径,这和原生 Canvas 类似,不过和绘制图形一样,需要提前调用 beginFill 或 beginLine 方法。
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 及其事件系统。
const app = new QxCanvas.App({ canvas: document.querySelector("canvas")!,});参数:
export interface IAppOptions { // 传入的canvas元素 canvas: HTMLCanvasElement; // canvas 元素宽高 width?: number; height?: number; // 背景颜色 backgroundColor?: string; // 背景透明度 backgroundAlpha?: number;}属性和方法:
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 类是所有节点的基类,具有一些基础的属性和方法。(内部类,未向外暴露)
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,用于管理子节点。(内部类,未向外暴露)
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,用于绘制图形、路径,以及绑定事件监听器。
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;}生命周期
每个图案对象 Graphics(Group)都具有生命周期,以便在特定的时机执行特定的操作。
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就非常方便且简单了。
绘画板
可以选择画笔颜色的绘画板。
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 设置位置偏移量。
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 } );});刮刮乐
利用遮罩和自由绘画实现。
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);
评论