友情提示
1. 前言
StaticCanvas是fabric提供的静态画布,仅用来绘制内容,而无法与元素进行交互,未来要实现的Canvas(可交互画布)基于这个类,本文将解析如何用pixi实现这个类。
为了保留fabric的原汁原味,所有type、interface等类型相关的内容,都会直接从fabric中copy,并且,部分功能逻辑性的代码也可以直接copy fabric的代码。
2. 功能实现
2.1 ctx(CanvasRenderingContext2D)
由于要从fabric的canvas2D切换到pixi的webgl,所以没有ctx这个东西了,但是,如同我一直提到的,我们要保留fabric的原汁原味,所以,我们要保证用户使用起这个渲染引擎来,能够像使用fabric一样,所以,我们用FakeCanvasRenderingContext2D来替换CanvasRenderingContext2D。如果你不知道FakeCanvasRenderingContext2D是什么,可以看这里👉用pixi.js实现fabric.js(一):FakeCanvasRenderingContext2D
ts
export class StaticCanvas {
// ...
constructor(...){
//...
this.ctx = new FakeCanvasRenderingContext2D(this.canvasElement);
//...
}
}
2.2 初始化Application
可以简单的理解为,pixi的Application对应fabric的StaticCanvas
pixi的Application是异步的,所以它的初始化返回结果是一个promise,这个promise resolve了,才代表我们可以开始使用pixiApp了
ts
export class StaticCanvas {
// ...
protected pixiApp = new Application();
// ...
private async initPixiApp(): Promise<void> {
this.canvasElement.style.width = this.width + 'px';
this.canvasElement.style.height = this.height + 'px';
await this.pixiApp.init({
width: this.width,
height: this.height,
backgroundColor: this.backgroundColor,
preference: 'webgl',
autoStart: false,
canvas: this.canvasElement,
resolution: window.devicePixelRatio,
antialias: true,
preserveDrawingBuffer: true, // 保留绘制缓冲区,使 toDataURL/toBlob 能够正确导出图像
});
this.pixiAppInited = true;
this.pixiApp.stage.label = 'stage';
this.initZIndex();
this.requestRenderAll();
}
}
2.3 根元素
所有渲染引擎都会维护一个根容器,元素只有被添加到这个容器里,才能被绘制出来。
pixi的根容器是stage,但是我们最好不要把stage当作根容器来用,因为未来可能要添加一些固定在页面的某个位置、不受画布的缩放影响的元素,如果把stage当作根容器来用,那么处理这些元素将会比较麻烦。
这里我们新建一个Container,作为画布的根元素,然后把这个元素添加到stage上。
ts
export class StaticCanvas {
//...
protected root = new Container();
constructor(...){
// ...
this.pixiApp.stage.addChild(this.root);
}
}
2.3 add函数
这个函数用来将对象添加到画布上,可以使用pixi的addChild来添加元素
ts
export class StaticCanvas {
//...
add(...objects: FabricObject[]) {
const size = super.add(...objects);
// pixi
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
this.root.addChild(obj.pixiContent);
}
// ...
}
}
insertAt函数和remove函数分别用来在指定index插入元素以及移除元素,这两个函数的处理方式和add函数类似,分别用pixi的addChildAt函数和removeChild函数来实现。
2.4 变换矩阵的叠加
在第二章中,我们已经知道了如何计算出元素的localTransform,在渲染的时候,我们还需要根据localTransform计算出元素的worldTransform。
fabric和pixi计算worldTransform的原理都是一样的,都是:localTransform矩阵叠加父元素的worldTransform矩阵,但是它们二者的演绎方式是不一样的,fabirc用了canvas2D原生的方式来做这件事情,而pixi自己实现了矩阵计算逻辑。
2.4.1 ctx.transform函数
这个函数的作用,就是将当前的线性空间的变换矩阵append(右乘)一个变换矩阵,它跟ctx.setTransform的不同是:ctx.setTransform会直接覆盖当前线性空间的变换矩阵。
2.4.2 fabric的方式
fabric通过ctx.save(),ctx.restore(),ctx.transform()这3个api来实现变换矩阵的叠加,在进行深度优先遍历(前序遍历)对象树的过程中,fabric会不断深入这颗对象树,每次往下一层,fabric会先用ctx.save()保存当前的矩阵状态,然后用ctx.transform()来把子元素的localTransform append到当前的变换矩阵里,这样就实现了localTransform与父元素worldTransform的叠加,最后在回溯的时候,调用ctx.restore()来把变换矩阵恢复到当前节点的worldTransform,防止状态丢失。
2.4.3 pixi的方式
因为canvas2D的api的设计原因,fabric的计算变换矩阵逻辑和渲染逻辑是同步进行的,pixi则没有这个限制,pixi的计算和渲染是分开进行的,在深度优先遍历对象树的过程中,pixi会不断深入这棵对象树,每次往下一层,pixi会用当前节点的localTransform叠加父元素的worldTransform,这样就得到了当前元素的worldTransform,接着继续深入这颗对象树,执行同样的逻辑,直到遍历结束,就得到了所有节点的worldTransform。
2.5 renderCanvas函数
这个函数的作用是:将画布上(或者根容器上)的所有元素绘制出来
2.5.1 设置根容器的变换矩阵
StaticCanvas上挂载了一个viewportTransform属性,用来快速设置画布的变换矩阵,这个属性对于编辑器项目非常有用,因为编辑器会经常移动、缩放画布。
fabric用ctx.transform函数来设置根容器的变换矩阵
我们可以用pixi的setFromMatrix来替代这个效果:
ts
const v = this.viewportTransform;
this.root.setFromMatrix(new Matrix(v[0], v[1], v[2], v[3], v[4], v[5]));
2.5.2 绘制元素
前面已经用add函数,将元素添加到画布上了(root),但是,元素的绘制逻辑是存放在起render()函数里的,所以要调用所有元素的render()函数,对象的内容才会被绘制出来
ts
export class StaticCanvas {
//...
renderCanvas(ctx: CanvasRenderingContext2D, objects: FabricObject[]) {
// ...
for (let i = 0; i < objects.length; i++) {
const obj = objects[i];
obj.render(ctx);
}
// ...
}
}
在这里,StaticCanvas会把ctx对象作为参数,依次调用所有对象的render函数,ctx对象最终会被传递到对象的_render函数,所有的绘制逻辑都将在这个函数里进行,如果要实现自定义对象,也是通过重写这个函数来实现。
2.5.3 bindObjectGeometry函数
在renderCanvas函数中,StaticCanvas会遍历所有对象,用ctx对象作为参数调用对象的render函数,ctx对象经过层层传递,最终会到达对象的_render函数,在这个函数里,会调用ctx对象上一系列的绘制函数(如fill、stroke等),把对象绘制出来,以上是fabric的逻辑。
但是这个项目并不像fabric那样,有着真正的ctx对象,而是用fakeCtx对象(FakeCanvasRenderingContext2D)来替代了ctx对象,这两者的使用逻辑并不是一样的,ctx对象绑定了一个canvas元素,调用ctx对象的绘制api后可以直接把内容绘制到画布上,而fakeCtx对象并不是绑定canvas元素的,它的绘制也不会直接操作画布,而是操作元素的pixi内容,所以,在操作之前,它需要绑定一个对象。
fakeCtx对象的bindObjectGeometry函数用来绑定一个对象(ObjectGeometry),绑定后,调用fakeCtx的绘制api,就会操作这个对象的pixi内容,达到绘制的目的。
ts
export class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
//...
private objectGeometry!: ObjectGeometry;
private curGraphics!: Graphics;
bindObjectGeometry(obj: ObjectGeometry) {
obj.clearPixiContent();
this.objectGeometry = obj;
this.curGraphics = obj.getCurGraphics();
}
}
2.5.4 绘制background和overlay
_renderBackgroundOrOverlay函数用来绘制background和overlay,它是基于ctx的,这里可以直接用pixi的Graphics替代一下
ts
export class StaticCanvas {
//...
private _renderBackgroundOrOverlay(
ctx: CanvasRenderingContext2D,
property: 'background' | 'overlay'
) {
const fill = this[`${property}Color`];
const object = this[`${property}Image`];
const v = this.viewportTransform;
const needsVpt = this[`${property}Vpt`];
const grap = this[`${property}ColorGraphics`];
const img = this[`${property}ImageContainer`];
if (fill) {
this.backgroundColorGraphics
.clear()
.rect(0, 0, this.width, this.height)
.fill({ color: this.backgroundColor });
} else {
grap.visible = false;
}
if (object) {
(ctx as FakeCanvasRenderingContext2D).bindObjectGeometry(object);
if (needsVpt) {
ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
}
object.render(ctx);
if (img !== object.pixiContent) {
if (img) {
this.pixiApp.stage.removeChild(img);
}
this.pixiApp.stage.addChild(object.pixiContent);
object.pixiContent.zIndex =
property === 'background'
? StageZIndex.BackgroundImage
: StageZIndex.OverlayImage;
object.pixiContent.eventMode = 'none';
this[`${property}ImageContainer`] = object.pixiContent;
}
} else {
if (img) {
this.pixiApp.stage.removeChild(img);
this[`${property}ImageContainer`] = undefined;
}
}
}
}
2.5.5 真正的绘制
ctx的绘制api调用之后,会立即将内容渲染到画布上,但是pixi不同,pixi在执行Graphics的绘制相关的api时,并不会立即将内容渲染到画布上,而只是会记录图形的顶点,只有执行render函数之后(手动或自动),内容才会真正地被绘制出来,在这里我们将会手动调用pixi的render函数,将内容绘制到画布上。
ts
export class StaticCanvas {
//...
renderCanvas(ctx: CanvasRenderingContext2D, objects: FabricObject[]) {
// ...
const pixiRenderer = this.pixiApp.renderer;
const stage = this.pixiApp.stage;
pixiRenderer.render({ container: stage, clear: true });
// ...
}
}
2.6 toCanvasElement函数
这个函数用来将当前画布的内容保存下来放到一张canvas上,有点类似于截图功能,后续的画布转Blob,画布转dataUrl都会基于这个函数。
ts
export class StaticCanvas {
//...
toCanvasElement(multiplier = 1): HTMLCanvasElement {
const factor =
multiplier * (this.enableRetinaScaling ? window.devicePixelRatio : 1);
// 创建一个逻辑尺寸的临时 canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.style.width = `${this.width}px`;
tempCanvas.style.height = `${this.height}px`;
tempCanvas.width = this.width * factor;
tempCanvas.height = this.height * factor;
const tempCtx = tempCanvas.getContext('2d')!;
tempCtx.scale(factor, factor);
// 将高分辨率 canvas 缩放绘制到临时 canvas
tempCtx.drawImage(
this.canvasElement,
0,
0,
this.canvasElement.width,
this.canvasElement.height,
0,
0,
this.width,
this.height
);
return tempCanvas;
}
}
2.7 lowerCanvasEl和upperCanvasEl
fabric会创建2个canvas元素,一个用来渲染(lowerCanvasEl),一个用来响应事件(upperCanvasEl),由于pixi已经有了事件系统,所以这里没有必要再像fabric那样创建一个专门用来处理事件系统的canvas了。
lowerCanvasEl和upperCanvasEl将会返回同一个canvas。
ts
export class StaticCanvas {
//...
get lowerCanvasEl(): HTMLCanvasElement {
return this.canvasElement;
}
get upperCanvasEl(): HTMLCanvasElement {
// console.error('没有upperCanvasEl,返回的依然是lowerCanvasEl');
return this.canvasElement;
}
}
2.8 clearContext函数
这个函数用来将画布内容清空,fabric用ctx.clearRect()来清空画布内容,可以用pixi的clear函数来替代这个效果
ts
export class StaticCanvas {
//...
clearContext(ctx: CanvasRenderingContext2D) {
if (this.pixiAppInited) {
const pixiRenderer = this.pixiApp.renderer;
pixiRenderer.clear();
}
}
}
2.9 setDimensions函数
这个函数用来设置画布的宽高,在拖动调整页面尺寸的时候,可以使用这个函数让画布适配页面的新尺寸。
fabric里,调用这个函数之后,会设置canvas的width和height,然后通过ctx.scale()让画布适配指定的分辨率,代码👉地址。
我们可以用pixi的resize函数来替代这个逻辑:
ts
export class StaticCanvas {
//...
setDimensions(dimensions: { width: number; height: number }) {
const { width, height } = dimensions;
this.width = width;
this.height = height;
this.canvasElement.style.width = this.width + 'px';
this.canvasElement.style.height = this.height + 'px';
this.pixiApp.renderer.resize(width, height);
this.calcOffset();
this.requestRenderAll();
}
}
3.StaticCanvas的其他函数
除了上述讲到的函数之外,fabric的StaticCanvas还提供了一些其他的函数来满足不同的诉求,这里并没有100%实现所有的函数,如果大家有需求,可以去参考fabric的源码,自己实现。
4. 测试一下
4.1 fabric官方demo
fabric官网提供了一些对象的demo,如下:

其代码存放在这里:github.com/fabricjs/fa...
可以从上述地址copy一部分代码过来,修改一下对象的left、top,就可以作为我们的测试demo了。然后,为了方便用户查看,我在canvas元素上绑定了一系列的事件,让用户能够缩放、平移画布;我还在底部加入了一个radio,让用户可以在pixi、fabric两者之间切换,以对比效果。
测试代码 👉 codesandbox地址(建议从codesandbox打开新窗口观看)
pixijs-fabric效果:

fabric效果:

可以看到,两者渲染出来的东西是一样的。
4.2 性能测试
在画布上绘制15000个矩形,对比fabric的性能和pixijs-fabric的性能,这个测试主要是强调pixijs强大的渲染性能。
测试代码:
ts
const canvas = new StaticCanvas(undefined, {
width: 1200,
height: 700,
backgroundColor: '#85C8F2',
});
const wrapper = document.getElementById('wrapper');
wrapper?.appendChild(canvas.lowerCanvasEl);
for (let i = 0; i < 15000; i++) {
const rect = new Rect({
left: Math.random() * 1200,
top: Math.random() * 700,
width: 30 + Math.random() * 20,
height: 15 + Math.random() * 20,
fill: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(
Math.random() * 256,
)}, ${Math.floor(Math.random() * 256)})`,
});
canvas.add(rect);
}
canvas.requestRenderAll();
测试代码地址 👉 codesandbox地址
通过chrome的提供的性能分析工具来对比两者的渲染性能。
pixijs-fabric效果:

从火炬图上可以看到,每个task的时长为18ms左右,帧率维持在了稳定60FPS满帧。
fabric效果:

从火炬图上可以看到,使用fabric后,帧率开始崩盘,每一个task的时长来到了600ms左右,帧率维持在不到2FPS,画布处于崩溃不可用状态。
从这个对比结果,我们可以看到fabric的一个巨大缺陷:性能非常差,就算渲染矩形数来到1000个,它的帧率也比不过pixijs渲染15000个矩形的帧率,如果你的编辑器节点数量非常少,只有几百个,那么使用fabric倒也没什么问题,如果节点数量上千,编辑器就会开始严重卡顿了,如果来到5000甚至上万的级别,fabric就开始卡成PPT了。