用pixijs实现fabricjs(三):对象继承链和自定义对象

友情提示

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

1. 前言

上一章,我们完成了fabric对象的继承链上的ObjectGeometry类,这个类包含了对象的基础信息, 包括位置信息(left、top)、缩放信息(scale)、旋转信息(angle)等,但是这个类只是这条长长的继承链上的其中一环,fabric将对象的其他功能分散到了这条继承链上的其他类上,所以在这一章里,我们将继续完成这条继承链,并讲述fabric的内置对象是如何实现的。

2. 对象的继承链

从ObjectGeometry类出发,讲述fabric对象的继承链的实现

2.1 InnerObject

这个对象继承自ObjectGeometry,在fabric中,这个类叫做FabricObject,但是这个名字和另一个FabricObject重名了,所以这里给他改一下名字,叫做InnerObject。
ObjectGeometry类存放了对象的基本属性,它的作用是记录元素的位置信息(left、top)、缩放信息(scale)、旋转信息(angle)等,然后用特定的方式把他们组合起来,得到一个localTransform矩阵;而InnerObject则存放了对象的绘制属性,比如透明度(opacity),填充色(fill),描边色(stroke),render和_render函数等,它决定了这个对象将会如何被绘制出来。

2.1.1 constructor

首先设置对象的默认属性值,然后把参数中的属性值设置给对象。

代码:

ts 复制代码
export class InnerObject extends ObjectGeometry {
  // ...
  constructor(options?: Props) {
    super();
    Object.assign(this, FabricObject.ownDefaults);
    this.setOptions(options);
  }
}

有一个点需要注意,fabric v6的默认锚点是LEFT、TOP,而v7的锚点默认是CENTER、CENTER。

2.1.2 _render

执行绘制逻辑,通过重写这个函数来实现自定义绘制逻辑,这个函数默认为空。

ts 复制代码
export class InnerObject extends ObjectGeometry {
  // ...
  _render(_ctx: CanvasRenderingContext2D) {
    // placeholder to be overridden
  }
}

2.1.3 transform

计算出自身的localTransform

fabric的这个函数会先计算出自身的localTransform,然后调用ctx.transform把自身的localTransform叠加到当前的变换矩阵,实现了worldTransform的效果,但是,我们不必调用ctx.transform,因为pixi会帮我们计算出worldTransform。

fabric的代码:

ts 复制代码
export class InnerObject extends ObjectGeometry {
  // ...
  transform(ctx: CanvasRenderingContext2D) {
    const needFullTransform =
      (this.group && !this.group._transformDone) ||
      (this.group && this.canvas && ctx === (this.canvas as Canvas).contextTop);
    const m = this.calcTransformMatrix(!needFullTransform);
    ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
  }
}

我们的实现:

ts 复制代码
export class InnerObject extends ObjectGeometry {
  // ...
  transform(ctx: CanvasRenderingContext2D) {
    this.calcOwnMatrix();
  }
}

2.1.4 render

这个函数的内容包含了transform函数和_render函数,它的作用是:先判断该对象是否应该被渲染(是否visible),如果应该渲染,则计算该对象的localTransform,然后再渲染。

既然我们是在用pixijs实现fabricjs,那么这个函数也需要作出相应的改造:

  • 判断对象是否应该渲染,我们依然沿用fabric的isNotVisible函数,如果不应该渲染,则将pixiContent的visible设置为false,达到不渲染的效果。
  • 用pixi的alpha代替fabric的opacity
  • fakeCtx模拟ctx

代码:

ts 复制代码
export class InnerObject extends ObjectGeometry {
  // ...
  render(ctx: CanvasRenderingContext2D) {
    this.pixiContent.visible = false;

    if (this.isNotVisible()) return;

    this.pixiContent.visible = true;

    this.transform(ctx);
    this.pixiContent.alpha = this.opacity ?? 1;

    this.drawObject(ctx);

    this.dirty = false;
  }

  drawObject(ctx: CanvasRenderingContext2D) {
    if (!this.dirty) return;

    const originalFill = this.fill;
    const originalStroke = this.stroke;
    (ctx as FakeCanvasRenderingContext2D).bindObjectGeometry(this);
    this._renderBackground(ctx);
    this._render(ctx);
    this.fill = originalFill;
    this.stroke = originalStroke;
  }
}

2.1.5 InnerObject的其他函数

其他函数可以参考fabric,这块代码可以不用动。

2.2 InteractiveFabricObject

这个类继承自InnerObject,ObjectGeometry承载了元素的基本信息,InnerObject承载了对象的绘制信息,而InteractiveFabricObject则承载了元素的交互信息。

这个类承载的功能,只有在实现了Canvas类(fabric的可交互画布)后,才会用到,在实现Canvas类之前我们还需要先实现StaticCanvas类,所以短时间内我们还用不到InteractiveFabricObject这个类,所以等我们实现Canvas类的时候再来实现InteractiveFabricObject这个类。

2.3 FabricObject

这个类继承自InteractiveFabricObject,fabric仅对这个类扩展了一些svg相关的函数,抛开这一点,它和InteractiveFabricObject类的功能是一样的,仅仅是名字不同。

3. 内置对象的实现

fabric内置了很多简单对象,让我们不必所有对象都通过自定义对象的方式实现。接下来我们会实现这些对象。但是,需要注意的是:纵使是简单对象,fabric也给他们内置了一系列的函数或者属性,来丰富它的功能,让用户有更多操作空间,这里我们重点关注这些简单对象的核心功能,而不会去过多关注这些边缘功能

3.1 Circle

画一个圆

核心代码是ctx.arc()

代码:

ts 复制代码
class Circle extends FabricObject {
  radius = 0;
  startAngle = 0;
  endAngle = 360;
  counterClockwise = false;
  _render(ctx: CanvasRenderingContext2D): void {
    ctx.beginPath();
    ctx.arc(
      0,
      0,
      this.radius,
      degreesToRadians(this.startAngle),
      degreesToRadians(this.endAngle),
      this.counterClockwise,
    );
    this._renderPaintInOrder(ctx);
  }
}

3.2 Rect

画一个矩形

这里并不会用ctx.rect来绘制,因为要考虑x、y方向的圆角,所以采用了ctx.moveTo、ctx.lineTo来绘制直边,用ctx.bezierCurveTo来绘制圆角。

代码:

ts 复制代码
class Rect extends FabricObject {
  rx = 0;
  ry = 0;
  _render(ctx: CanvasRenderingContext2D) {
    const { width: w, height: h } = this;
    const x = -w / 2;
    const y = -h / 2;
    const rx = this.rx ? Math.min(this.rx, w / 2) : 0;
    const ry = this.ry ? Math.min(this.ry, h / 2) : 0;
    const isRounded = rx !== 0 || ry !== 0;

    ctx.beginPath();

    ctx.moveTo(x + rx, y);

    ctx.lineTo(x + w - rx, y);
    isRounded &&
      ctx.bezierCurveTo(
        x + w - kRect * rx,
        y,
        x + w,
        y + kRect * ry,
        x + w,
        y + ry,
      );

    ctx.lineTo(x + w, y + h - ry);
    isRounded &&
      ctx.bezierCurveTo(
        x + w,
        y + h - kRect * ry,
        x + w - kRect * rx,
        y + h,
        x + w - rx,
        y + h,
      );

    ctx.lineTo(x + rx, y + h);
    isRounded &&
      ctx.bezierCurveTo(
        x + kRect * rx,
        y + h,
        x,
        y + h - kRect * ry,
        x,
        y + h - ry,
      );

    ctx.lineTo(x, y + ry);
    isRounded &&
      ctx.bezierCurveTo(x, y + kRect * ry, x + kRect * rx, y, x + rx, y);

    ctx.closePath();

    this._renderPaintInOrder(ctx);
  }
}

3.3 Triangle

画一个三角形

用ctx.moveTo和ctx.lineTo绘制一个三角形路径然后fill或者stroke

代码:

ts 复制代码
class Triangle extends FabricObject {
  _render(ctx: CanvasRenderingContext2D) {
    const widthBy2 = this.width / 2,
      heightBy2 = this.height / 2;

    ctx.beginPath();
    ctx.moveTo(-widthBy2, heightBy2);
    ctx.lineTo(0, -heightBy2);
    ctx.lineTo(widthBy2, heightBy2);
    ctx.closePath();

    this._renderPaintInOrder(ctx);
  }
}

3.4 Ellipse

画一个椭圆

这里并没有直接使用ctx.ellipse来绘制椭圆,而是用了ctx.arc函数,在使用ctx.arc函数之前,调用ctx.transform来叠加一个y方向的压缩矩阵,把自身'压扁',这样圆就变成椭圆了。

代码:

ts 复制代码
class Ellipse extends FabricObject {
  rx = 0;
  ry = 0;
  _render(ctx: CanvasRenderingContext2D) {
    ctx.beginPath();
    ctx.save();
    ctx.transform(1, 0, 0, this.ry / this.rx, 0, 0);
    ctx.arc(0, 0, this.rx, 0, Math.PI * 2, false);
    this._renderPaintInOrder(ctx);
    ctx.restore();
  }
}

3.5 Line

画一条线

line这个图元不用设置width、height,它会根据x1、y1、x2、y2属性自动计算出width、height。

代码:

ts 复制代码
class Line extends FabricObject {
  // ...
  calcLinePoints(): {
    x1: number;
    x2: number;
    y1: number;
    y2: number;
  } {
    const { x1: _x1, x2: _x2, y1: _y1, y2: _y2, width, height } = this;
    const xMult = _x1 <= _x2 ? -1 : 1,
      yMult = _y1 <= _y2 ? -1 : 1,
      x1 = (xMult * width) / 2,
      y1 = (yMult * height) / 2,
      x2 = (xMult * -width) / 2,
      y2 = (yMult * -height) / 2;

    return {
      x1,
      x2,
      y1,
      y2,
    };
  }
  _render(ctx: CanvasRenderingContext2D) {
    ctx.beginPath();

    const p = this.calcLinePoints();
    ctx.moveTo(p.x1, p.y1);
    ctx.lineTo(p.x2, p.y2);

    ctx.lineWidth = this.strokeWidth;

    const origStrokeStyle = ctx.strokeStyle;
    ctx.strokeStyle = this.stroke ?? ctx.fillStyle;
    this.stroke && this._renderStroke(ctx);
    ctx.strokeStyle = origStrokeStyle;
  }
}

3.6 Polyline

画一个多边形,这个类是Polygon类的父类,多边形的逻辑放在这个类里,而不是Polygon类里

这个类的构造函数接受一个数组,代表多边形的所有顶点,它和Line有点类似,会自动计算出width、height,所以我们不用显式指定它们。

它的绘制逻辑也比较简单,就是不断地moveTo、lineTo然后fill或者stroke,但是它的计算计算宽高的逻辑比较长,这里就不贴了(可以去这里看源码),只贴核心的绘制代码:

ts 复制代码
class Polyline extends FabricObject{
  // ...
  _render(ctx: CanvasRenderingContext2D) {
    const len = this.points.length,
      x = this.pathOffset.x,
      y = this.pathOffset.y;

    if (!len || isNaN(this.points[len - 1].y)) {
      // do not draw if no points or odd points
      // NaN comes from parseFloat of a empty string in parser
      return;
    }
    ctx.beginPath();
    ctx.moveTo(this.points[0].x - x, this.points[0].y - y);
    for (let i = 0; i < len; i++) {
      const point = this.points[i];
      ctx.lineTo(point.x - x, point.y - y);
    }
    !this.isOpen() && ctx.closePath();
    this._renderPaintInOrder(ctx);
  }
}

3.7 Polygon

绘制一个多边形,这个类继承自Polyline,并且,它基本没有功能的扩展,可以理解为它等同于Polyline

ts 复制代码
export class Polygon extends Polyline {
  protected isOpen() {
    return false;
  }
}

3.8 Path

基于SVG的绘制命令,绘制一个path

fabric内置了一套工具集,用来解析绘制命令,然后调用对应的函数(ctx.lineTo、ctx.moveTo、ctx.bezierCurveTo、ctx.quadraticCurveTo),把这个svg path绘制出来。

ts 复制代码
class Path extends FabricObject{
  _renderPathCommands(ctx: CanvasRenderingContext2D) {
    const l = -this.pathOffset.x,
      t = -this.pathOffset.y;

    ctx.beginPath();

    for (const command of this.path) {
      switch (
        command[0] // first letter
      ) {
        case 'L': // lineto, absolute
          ctx.lineTo(command[1] + l, command[2] + t);
          break;

        case 'M': // moveTo, absolute
          ctx.moveTo(command[1] + l, command[2] + t);
          break;

        case 'C': // bezierCurveTo, absolute
          ctx.bezierCurveTo(
            command[1] + l,
            command[2] + t,
            command[3] + l,
            command[4] + t,
            command[5] + l,
            command[6] + t
          );
          break;

        case 'Q': // quadraticCurveTo, absolute
          ctx.quadraticCurveTo(
            command[1] + l,
            command[2] + t,
            command[3] + l,
            command[4] + t
          );
          break;

        case 'Z':
          ctx.closePath();
          break;
      }
    }
  }
  _render(ctx: CanvasRenderingContext2D) {
    this._renderPathCommands(ctx);
    this._renderPaintInOrder(ctx);
  }
}

3.9 Image

绘制一张图片

核心绘制逻辑是用ctx.drawImage函数来绘制一张图片。

Image类也支持stroke,它的stroke路径是围绕图片一周的一个矩形。

代码:

ts 复制代码
  _render(ctx: CanvasRenderingContext2D) {
    ctx.imageSmoothingEnabled = this.imageSmoothing;
    this._renderPaintInOrder(ctx);
  }
  protected _renderStroke(ctx: CanvasRenderingContext2D): void {
    this._stroke(ctx);
    super._renderStroke(ctx);
  }
  _stroke(ctx: CanvasRenderingContext2D) {
    if (!this.stroke || this.strokeWidth === 0) {
      return;
    }
    const w = this.width / 2,
      h = this.height / 2;
    ctx.beginPath();
    ctx.moveTo(-w, -h);
    ctx.lineTo(w, -h);
    ctx.lineTo(w, h);
    ctx.lineTo(-w, h);
    ctx.lineTo(-w, -h);
    ctx.closePath();
  }
  _renderFill(ctx: CanvasRenderingContext2D) {
    const elementToDraw = this._element;
    if (!elementToDraw) {
      return;
    }
    const scaleX = this._filterScalingX,
      scaleY = this._filterScalingY,
      w = this.width,
      h = this.height,
      // crop values cannot be lesser than 0.
      cropX = Math.max(this.cropX, 0),
      cropY = Math.max(this.cropY, 0),
      elWidth =
        (elementToDraw as HTMLImageElement).naturalWidth || elementToDraw.width,
      elHeight =
        (elementToDraw as HTMLImageElement).naturalHeight ||
        elementToDraw.height,
      sX = cropX * scaleX,
      sY = cropY * scaleY,
      // the width height cannot exceed element width/height, starting from the crop offset.
      sW = Math.min(w * scaleX, elWidth - sX),
      sH = Math.min(h * scaleY, elHeight - sY),
      x = -w / 2,
      y = -h / 2,
      maxDestW = Math.min(w, elWidth / scaleX - cropX),
      maxDestH = Math.min(h, elHeight / scaleY - cropY);

    elementToDraw &&
      ctx.drawImage(elementToDraw, sX, sY, sW, sH, x, y, maxDestW, maxDestH);
  }

3.10 Group

组元素,Group让我们可以把一堆元素当作一个整体来进行操作。

组是所有渲染引擎都会提供的一个概念,fabric里面的组是Group,pixi里面的组是Container

3.10.1 constructor

依然是用pixiContent来代表元素自身的pixi内容,但是这里加入了一个subPixiContent,它跟pixiContent一样,是一个pixi的Container,这个subPixiContent的目的是:隔绝Group自身的绘制内容Group的子元素内容

diff 复制代码
class Group extends FabricObject {
  //...
  constructor(objects: FabricObject[] = [], options: Partial<GroupProps> = {}) {
    // ...

+   this.pixiContent.removeChildren();
+   this.pixiContent.label = 'group';
+   this.subPixiContent = new Container({
+     children: [new Graphics()],
+   });
+   this.pixiContent.addChild(this.subPixiContent);

    // ...
  }
}

3.10.2 添加元素到Group中

用Group的pixiContent来add子元素的pixiContent

diff 复制代码
class Group extends FabricObject {
  //...
  private _enterGroup(object: FabricObject, removeParentTransform?: boolean) {
    // ...
+   this.pixiContent.addChild(object.pixiContent);
  }
}

3.10.3 add函数

add函数用来动态地把其他元素添加到Group里(注意:constructor里并不会调用这个函数)

在将一个元素add到一个Group里时,它的worldTransform就变了(自身的worldTransform = 自身的localTransform 叠加 父元素的worldTransform),导致的结果就是,元素在画布上的位置信息(旋转、缩放、平移)变了,但是fabric并不想让我们看到这种效果,fabric希望调用add函数后,这个元素在画布上看起来原封不动,这样的话使用起来会更加方便。

要实现这种效果,可以从代数的角度来看待这个问题:

设元素的localTransform为l,元素的worldTransform为w,Group的worldTransform为gw,在元素被添加到Group里之前,有w = l,因为这个时候元素没有父元素;在把元素添加到Group里后,有w = gw x l,为了保证元素的原封不动,就要让w保持不变,所以有l(before) = w = gw x l(after),l(before)为元素被添加到Group里之前的localTransform,而l(after)表示元素被添加到Group后的localTransform,l(after)也是我们要求的值,实际上我们就是通过修改元素的l来实现原封不动的效果。

我们继续看l(before) = w = gw x l(after),l(before)等式,l(before)是已知的,w、gw也是已知的,把等式的两边乘以gw的逆矩阵(),就可以得到l(after):

<math xmlns="http://www.w3.org/1998/Math/MathML"> g w − 1 gw^{-1} </math>gw−1 x l(before) = <math xmlns="http://www.w3.org/1998/Math/MathML"> g w − 1 gw^{-1} </math>gw−1 x gw x l(after) = l(after)

具体代码:

diff 复制代码
class Group extends FabricObject {
  //...
+ // add函数里面会调用_enterGroup函数
  private _enterGroup(object: FabricObject, removeParentTransform?: boolean) {
    // ...
+   applyTransformToObject(
+     object,
+     multiplyTransformMatrices(
+       invertTransform(this.calcTransformMatrix()),
+       object.calcTransformMatrix(),
+     ),
+   );
  }
}

3.10.4 remove函数

这个函数用来将Group里的某个元素移除出去,和上面的add函数同样,元素被移除出去之后,在画布上看起来要原封不动

这次,我们的等式变成了:gw x l(before) = w = l(after),l(before)为元素被移除前的localTransform,l(after)为元素从Group里被移除后的localTransform,这里也不用叠加一个逆矩阵了,我们可以直接得出l(after) = gw x l(before)

具体代码: 具体代码:

diff 复制代码
class Group extends FabricObject {
  //...
+ // remove函数里面会调用_exitGroup函数
  protected _exitGroup(object: FabricObject, removeParentTransform?: boolean) {
    // ...
+   applyTransformToObject(
+     object,
+     multiplyTransformMatrices(
+       this.calcTransformMatrix(),
+       object.calcTransformMatrix(),
+     ),
+   );
  }
}

3.10.5 render函数

由于Group是一个嵌套结构,所以它的render函数不能只render自身了,而是需要render自身+递归render子元素

diff 复制代码
class Group extends FabricObject {
  //...
+ // render函数里会调用drawObject函数
+ drawObject(ctx: CanvasRenderingContext2D) {
+   this._renderBackground(ctx);
+   for (let i = 0; i < this._objects.length; i++) {
+     const obj = this._objects[i];
+     obj.render(ctx);
+   }
+ }
}

4. 测试一下

测试方式,new一堆内置对象,放到画布上,查看效果。

测试代码:codesandbox地址

效果:

对比fabric的效果:

可以看到效果是一样的。

相关推荐
biubiubiu_LYQ1 小时前
萌新小白基础篇之JS预编译
javascript
渐儿1 小时前
Electron 实操开发文档
前端
小则又沐风a1 小时前
深入了解进程概念 第二章
java·linux·服务器·前端
亲亲小宝宝鸭1 小时前
微前端方案探索:qiankun
前端·微服务
渐儿1 小时前
跨端框架实操开发文档:Electron / Tauri / React Native
前端
ZC跨境爬虫1 小时前
跟着 MDN 学 HTML day_60:(表单与按钮技能测试实战)
服务器·前端·javascript·数据库·ui·html
lihaozecq1 小时前
做 Agent SDK 必须支持的插件能力:8 个钩子搞定横切关注点
前端·agent·ai编程
秦歌6661 小时前
Agent Skills详解
服务器·前端·数据库
ljt27249606611 小时前
Vue笔记(四)--组件基础
前端·vue.js·笔记