用pixi.js实现fabric.js(四):StaticCanvas

友情提示

  • 请先看这里👉前言
  • 相关代码👉地址,本文相关的代码在static-canvas分支上,请切到这个分支查看代码
  • 本系列文章提到的fabric均为v7版本,pixijs均为v8版本

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了。

相关推荐
烬羽13 小时前
《读<JavaScript语言精粹>第3章,我整理了6个必须掌握的对象核心知识点》
前端
GuWenyue13 小时前
从零搭建用户管理系统!60分钟搞定RESTful接口+Bootstrap语义化首页
前端·后端
超人气王13 小时前
JavaScripts入门篇————js原型的底层原理
前端·javascript
蜡笔小电芯13 小时前
【Electron】第1章—新建工程(基于 Electron + Vite + JavaScript)
前端·javascript·electron
_xaboy13 小时前
开源Vue组件 FormCreate 使用组件内部方法校验
前端·vue.js·开源
审判长烧鸡13 小时前
【AI问答/前端】前端满天过海局(一)
前端·vue·浏览器
张元清13 小时前
React 指针 Hook:Hover、长按、双击、刮擦和点击外部,告别那些经典 bug
前端·javascript·面试
Csvn13 小时前
前端技术 - WebAssembly
前端·d3.js
咕噜咕噜啦啦13 小时前
从spring到spring boot——JAVA项目开发
java·前端·spring boot·后端·spring