用pixijs实现fabricjs(二):对象的基础位置信息

友情提示

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

1. 前言

每种渲染引擎,都会封装某种代表对象的class,这个对象代表画布上的一个基本元素,它可以是一个矩形、圆、图片,可以是一堆对象的嵌套(group),总之,我们会将需要绘制的内容,转换为渲染引擎提供的对象类的实例,然后渲染引擎就会把这些对象全部绘制出来,得到我们想要的结果。

Fabric提供的对象类是FabricObject,其内部提供的Rect、Circle、Image等基础图元,都是继承自这个类,如果我们要实现自己的自定义图元,也需要继承这个类。

但是,本文的主题不在于FabricObject类,而是ObjectGeometry类,FabricObject有着非常长的继承链,fabric将各种不同的属性、方法存放在了这条继承链的不同类上,而对象基本信息(位置信息、缩放信息、锚点信息等)存放在了ObjectGeometry类上,本文的主题就是介绍这些基本属性的实现,以及如何用pixi来代替这些实现。

2. 对象基本信息的设计

fabric对基本信息的设计逻辑和pixi是不一样的,为了复刻fabric,我们不能直接使用pixi的那套设计逻辑,而是需要作出相应的适配。

2.1 left、top

相当于pixi的元素的obj.position.x,obj.position.y

2.2 width、height

fabric并不会根据绘制内容来自动计算出某个元素的宽高,而是需要我们手动给元素指定一个宽高(这点和pixi不一样,pixi的元素的widht、height是根据绘制的内容自动计算出来的),而且,在fabric中,width和height会影响元素锚点的定位

2.3 flipX、flipY

这是两个boolean类型的值,代表是否应该翻转对象,如果转换成pixi代码,则类似于: obj.scale.set(flipX ? -1 : 1, flipY ? -1 : 1)

2.4 scaleX、scaleY

相当于pixi的obj.scale.x和obj.scale.y

2.5 skewX、skewY

fabric的skew和pixi的skew是不一样的,fabric的skew相当于浏览器的skew(css的transform: skew()),可以看一下这篇文章的3.5.2节,介绍了pixi和浏览器对skew的处理👉juejin.cn/post/732339...

2.6 originX、originY

代表元素的锚点的位置

fabric内部对其的定义如下:

ts 复制代码
export type TOriginX = 'center' | 'left' | 'right' | number;
export type TOriginY = 'center' | 'top' | 'bottom' | number;

3个枚举类型,让我们可以快速设置锚点的位置(左上、左下、中心等),重点是最后的那个number类型,他是一个百分比值,0-1代表0%到100%,这个百分比参照的是元素的width和height,所以上面说了,width和height是会影响元素的锚点的定位的。而且,其实这几个枚举值也会转换成一个number,center会被转换成0.5,left/top、right/bottom分别是0和1。

2.7 angle

和pixi的angle是一样的,都是角度制

3. localTransform的计算

3.1 localTransfrom概念

localTransform是pixi里的一个概念,但是它也是一个通用的概念,它代表了元素的所有基本位置属性的集合,元素的position、scale、rotation、skew、锚点等属性,都可以转换成一个变换矩阵,把这些变换矩阵叠加起来,就得到了元素的localTransform,而这个叠加的顺序,并没有一个固定的标准,不同的渲染引擎可能有不同的顺序,为了复刻fabric,我们要遵循fabric的顺序。

3.2 calcOwnMatrix函数

ObjectGeometry类上挂载了一个calcOwnMatrix()用来计算元素的localTransform,这里会解析一下这个函数的计算过程,看看fabric是如何计算localTransform的。

3.2.1 第一步:计算translateX和、translateY

translateX和、translateY代表的是:经历过平移(left、top)、缩放(scaleX、scaleY)、旋转(angle)之后的元素的中心点的坐标

fabric会用getRelativeCenterPoint函数来计算出translateX和、translateY,这个函数的调用链路非常长,其大概逻辑如下:

  1. 调用_getTransformedDimensions函数,计算元素的真实尺寸(经过缩放、skew后的尺寸)
  2. 计算originX和originY距离中心点的距离,这是一个百分比值,这个百分比值乘以第1步中的到的尺寸,就得到了真实的距离,真实距离加上left、top,就得到了元素实际上的中心点坐标,代码👉地址
  3. 对第2步中得到的中心坐标,作一个旋转处理(基于angle),得到经过rotale之后的中心坐标,代码👉地址

经过了上述3步,就得到了translateX和、translateY

3.2.2 第二步:得到各个属性对应的变换矩阵

目前,我们已经得到了angle、translateX、translateY、scaleX、scaleY、skewX、skewY、flipX、flipY,接下来就是把这些属性转换成对应的变换矩阵

3.2.2.1 angle

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ c o s ( d e g r e e s T o R a d i a n s ( a n g l e ) ) − s i n ( d e g r e e s T o R a d i a n s ( a n g l e ) ) 0 s i n ( d e g r e e s T o R a d i a n s ( a n g l e ) ) c o s ( d e g r e e s T o R a d i a n s ( a n g l e ) ) 0 0 0 1 ] \left[ \begin{matrix} cos(degreesToRadians(angle)) & -sin(degreesToRadians(angle)) & 0 \\ sin(degreesToRadians(angle)) & cos(degreesToRadians(angle)) & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] </math> cos(degreesToRadians(angle))sin(degreesToRadians(angle))0−sin(degreesToRadians(angle))cos(degreesToRadians(angle))0001

其中,degreesToRadians函数用来将角度值转换成弧度制

3.2.2.2 translateX、translateY

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 0 t r a n s l a t e X 0 1 t r a n s l a t e Y 0 0 1 ] \left[ \begin{matrix} 1 & 0 & translateX \\ 0 & 1 & translateY \\ 0 & 0 & 1 \\ \end{matrix} \right] </math> 100010translateXtranslateY1

3.2.2.3 scaleX、scaleY、flipX、flipY

在fabric中,flip被并入到和scale一起处理了
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ f l i p X ? − s c a l e X : s c a l e X 0 0 0 f l i p Y ? − s c a l e Y : s c a l e Y 0 0 0 1 ] \left[ \begin{matrix} flipX ? -scaleX : scaleX & 0 & 0 \\ 0 & flipY ? -scaleY : scaleY & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] </math> flipX?−scaleX:scaleX000flipY?−scaleY:scaleY0001

3.2.2.4 skewX、skewY

fabric并没有把skewX和skewY合并到一个矩阵里进行处理,而是分成了2个矩阵处理

fabric的处理顺序如下:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 M a t h . t a n ( s k e w X ) 0 0 1 0 0 0 1 ] × [ 1 0 0 M a t h . t a n ( s k e w Y ) 1 0 0 0 1 ] \left[ \begin{matrix} 1 & Math.tan(skewX) & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \times \left[ \begin{matrix} 1 & 0 & 0 \\ Math.tan(skewY) & 1 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] </math> 100Math.tan(skewX)10001 × 1Math.tan(skewY)0010001

这里需要注意的是:分为2个矩阵处理,跟直接在1个矩阵里处理,得到的结果是不一样的,有如下不等式:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 M a t h . t a n ( s k e w X ) 0 0 1 0 0 0 1 ] × [ 1 0 0 M a t h . t a n ( s k e w Y ) 1 0 0 0 1 ] ≠ [ 0 M a t h . t a n ( s k e w X ) 0 M a t h . t a n ( s k e w Y ) 0 0 0 0 1 ] \left[ \begin{matrix} 1 & Math.tan(skewX) & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \times \left[ \begin{matrix} 1 & 0 & 0 \\ Math.tan(skewY) & 1 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \neq \\ \left[ \begin{matrix} 0 & Math.tan(skewX) & 0 \\ Math.tan(skewY) & 0 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] </math> 100Math.tan(skewX)10001 × 1Math.tan(skewY)0010001 = 0Math.tan(skewY)0Math.tan(skewX)00001

就算左侧两个矩阵调换顺序,也是不相等的

3.2.3 第三步:compose

得到了所有变换矩阵之后,接下来就是把它们compose起来,得到最终的localTransform,这个过程在composeMatrix函数中进行

fabric按照如下顺序进行compose:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> [ 1 0 t r a n s l a t e X 0 1 t r a n s l a t e Y 0 0 1 ] × [ c o s ( d e g r e e s T o R a d i a n s ( a n g l e ) ) − s i n ( d e g r e e s T o R a d i a n s ( a n g l e ) ) 0 s i n ( d e g r e e s T o R a d i a n s ( a n g l e ) ) c o s ( d e g r e e s T o R a d i a n s ( a n g l e ) ) 0 0 0 1 ] × [ f l i p X ? − s c a l e X : s c a l e X 0 0 0 f l i p Y ? − s c a l e Y : s c a l e Y 0 0 0 1 ] × [ 1 M a t h . t a n ( s k e w X ) 0 0 1 0 0 0 1 ] × [ 1 0 0 M a t h . t a n ( s k e w Y ) 1 0 0 0 1 ] \left[ \begin{matrix} 1 & 0 & translateX \\ 0 & 1 & translateY \\ 0 & 0 & 1 \\ \end{matrix} \right] \times \\ \left[ \begin{matrix} cos(degreesToRadians(angle)) & -sin(degreesToRadians(angle)) & 0 \\ sin(degreesToRadians(angle)) & cos(degreesToRadians(angle)) & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \times \\ \left[ \begin{matrix} flipX ? -scaleX : scaleX & 0 & 0 \\ 0 & flipY ? -scaleY : scaleY & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \times \\ \left[ \begin{matrix} 1 & Math.tan(skewX) & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] \times \\ \left[ \begin{matrix} 1 & 0 & 0 \\ Math.tan(skewY) & 1 & 0 \\ 0 & 0 & 1 \\ \end{matrix} \right] </math> 100010translateXtranslateY1 × cos(degreesToRadians(angle))sin(degreesToRadians(angle))0−sin(degreesToRadians(angle))cos(degreesToRadians(angle))0001 × flipX?−scaleX:scaleX000flipY?−scaleY:scaleY0001 × 100Math.tan(skewX)10001 × 1Math.tan(skewY)0010001

至此,元素的localTransform就得出来了

4. 元素接入pixi

这个项目的渲染,最终还是由pixi来实现的

4.1 pixiContent属性

我们将在ObjectGeometry类上挂载一个pixiContent属性,这是一个pixi的Container实例,里面存放了一堆pixi Graphics实例,在调用render函数后,会将绘制的所有内容转换成Graphics实例,存放在这个Container实例里

ts 复制代码
public pixiContent = new Container({ children: [new Graphics()] });

4.2 将上面计算出来的localTransform设置到pixi元素里

在第3节我们用fabric的方式计算出了localTransform,接下来就是把这个localTransform设置给pixi元素

可以直接用pixi的setFromMatrix函数,把计算出的localTransform设置给pixi元素

ts 复制代码
const value = composeMatrix(...); // 计算出来的localTransform
this.pixiContent.setFromMatrix(new Matrix(value[0], value[1], value[2], value[3], value[4], value[5]))

4.3 worldTransform

在渲染之前,pixi会根据localTransform自动计算worldTransform(可以参考这篇文章👉如何实现一个Canvas渲染引擎(一):节点和层级关系的3.5.3节),所以这一块我们完全不用做更改,pixijs会帮我们做好计算工作

5. 对象的渲染(render和_render函数)

我们构建好的对象,会以一棵对象树的形式(多叉树)组合起来,然后交给渲染引擎进行渲染,基本上所有渲染引擎都遵循这个逻辑,fabric也不例外,渲染引擎会在requestAnimationFrame里面,前序遍历这棵对象树,将每个对象依次绘制出来。fabric在进行这个遍历的过程中,会执行每个对象的render函数,render函数接收ctx作为参数,调用ctx的api,将对象自身的内容绘制出来。

5.1 render函数

fabric的render函数里,会执行以下逻辑(简化后):

  • 计算当前元素的localTransform,然后调用ctx.transform把这个localTransform叠加到当前的变换矩阵中👉代码
  • 执行_render函数(注意这个render带了下划线),将元素绘制出来

_render函数才是真正的执行渲染 逻辑,而render函数是执行应用线性变换+渲染的逻辑,两者是一个包含的关系。

在用pixi实现的时候,我们也会遵循这个过程,实现render函数_render函数,不同之处是:fabric的render函数的参数,是真正的CanvasRenderingContext2D,而我们的render函数的参数是FakeCanvasRenderingContext2D。

5.2 _render函数

一般情况下,我们通过重写某个函数,来实现自定义对象的逻辑,在fabric里也是一样,但是,我们并不是重写render函数,而是重写_render函数,这两个函数一个带有下划线,一个不带下划线,需要注意这点,把它们区分开来(注意,本文的主题是ObjectGeometry,但是render函数_render函数都没有挂载在ObjectGeometry类上,而是挂载在ObjectGeometry类的子类上)。

_render函数默认是一个空函数,里面的内容需要我们自己去实现,这里我们可以看一下fabric的内置对象之一:圆,对这个函数的实现:

ts 复制代码
export class Circle{
  // ...
  _render(ctx: CanvasRenderingContext2D) {
    ctx.beginPath();
    ctx.arc(
      0,
      0,
      this.radius,
      degreesToRadians(this.startAngle),
      degreesToRadians(this.endAngle),
      this.counterClockwise,
    );
    this._renderPaintInOrder(ctx);
  }
}

其核心内容就是调用了ctx.arc()来绘制一个圆路径,然后将其fill(或者stroke)。

6. 元素的其他信息

上面说了,FabricObject有着非常长的继承链,fabric将对象需要的所有属性和方法都放在了这条继承链上的不同类上,我们在用pixi实现fabric的过程中,并不需要完成这条继承链上的所有内容,其中部分内容可以直接copy fabric的代码,这样即减少了工作量,又保留了fabric的原汁原味。

7. 测试一下

7.1 测试流程

在FakeCanvasRenderingContext2D上实现了一个bindObjectGeometry函数,用来绑定一个ObjectGeometry的实例,代表接下来调用fakeCtx上的api,都会操作这个实例的内容:

ts 复制代码
export class FakeCanvasRenderingContext2D implements CanvasRenderingContext2D {
  // ...
  bindObjectGeometry(obj: ObjectGeometry) {
    obj.clearPixiContent();
    this.objectGeometry = obj;
    this.curGraphics = obj.getCurGraphics();
  }
}

实现一个画矩形的自定义类,继承ObjectGeometry,并重写_render函数:

ts 复制代码
class Obj1 extends ObjectGeometry {
  _render(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = this.fill;
    ctx.strokeStyle = this.stroke;
    ctx.lineWidth = this.strokeWidth;
    ctx.rect(-this.width / 2, -this.height / 2, this.width, this.height);
    ctx.fill();
    ctx.stroke();
  }
}

new一个obj,然后设置它的一些属性:

ts 复制代码
const obj1 = new Obj1();
obj1.set({
  left: 300,
  top: 300,
  width: 100,
  height: 200,
  fill: 'blue',
  stroke: 'green',
  scaleX: 2,
  scaleY: 1.5,
  skewX: 15,
  skewY: 10,
  strokeWidth: 5,
  angle: 80,
});

bind这个对象,然后执行render函数:

ts 复制代码
fakeCtx.bindObjectGeometry(obj1);
obj1.render(fakeCtx);

将该对象的pixiContent添加到pixi的stage上:

ts 复制代码
app.stage.addChild(obj1.pixiContent);

效果:

CodeSandbox地址

7.2 对比一下fabric的效果

代码:

ts 复制代码
class Obj1 extends FabricObject {
  _render(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = this.fill
    ctx.strokeStyle = this.stroke
    ctx.lineWidth = this.strokeWidth
    ctx.rect(-this.width / 2, -this.height / 2, this.width, this.height)
    ctx.fill()
    ctx.stroke()
  }
}

const canvas = new StaticCanvas('fabric', {
  width,
  height,
  backgroundColor: 'black',
  renderOnAddRemove: true
})

const obj1 = new Obj1()
obj1.set({
  left: 300,
  top: 300,
  width: 100,
  height: 200,
  fill: 'blue',
  stroke: 'green',
  scaleX: 2,
  scaleY: 1.5,
  skewX: 15,
  skewY: 10,
  strokeWidth: 5,
  angle: 80
})

canvas.add(obj1)

效果: 可以看到,我们实现的效果和fabric是一模一样的。

相关推荐
Alice-YUE1 小时前
【无标题】
开发语言·javascript·ecmascript
淸湫1 小时前
项目中使用了全局权限管理,请详细描述如何通过Vue Router的路由守卫来实现全局权限控制?
前端·vue.js
Twsit丶1 小时前
ECMAScript 常用特性整理(ES6 — ES13)
javascript
雪铃儿1 小时前
Shorebird 之外,Flutter Android 热更新还有什么选择
android·前端
李剑一1 小时前
前端必看 | Vue 刷新页面,生命周期钩子直接 "罢工",原来问题在这?90% 开发者都栽过!
前端·vue.js
閞杺哋笨小孩1 小时前
域名驱动多租户入驻:后台配置 + 前端解析
前端·vue.js
TeamDev1 小时前
在 Excel 加载项中嵌入 Web 视图
前端·后端·.net
悠哉摸鱼大王1 小时前
cesium学习(一)-基本概念
前端·cesium
LinDaiDai_霖呆呆1 小时前
大白话介绍大模型的一些底层原理,看完终于能跟人聊两句了
前端·人工智能·面试