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

相关推荐
大圣编程24 分钟前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang25 分钟前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆1 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜2 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞3 小时前
异步HttpModule的实现方式
java·服务器·前端
丹宇码农5 小时前
把 HLS 字幕玩出花:zwPlayer 如何让 M3U8 视频支持全文搜索、翻译与码率自适应
前端·javascript·音视频·hls·视频播放器
2501_943782356 小时前
【共创季稿事节】猜数字游戏:二分法思维与交互式反馈
前端·游戏·microsoft·harmonyos·鸿蒙·鸿蒙系统
GV191rLvq6 小时前
基于Socket实现的最简单的Web服务器【ASP.NET原理分析】
服务器·前端·asp.net
吠品6 小时前
LangChain 里 tool_call_id 为空?一次 MCP 工具集成的排查记录
前端