了解Canvas2D渲染功能实现与设计,在使用时知其所以然,在创造时有所借鉴。从以下这四个方面来分析Fabric.js

  • 对象模型处理(使用及设计实现)
  • 图形变换处理(canvas与object变换)
  • 光标交互处理(目标检测事件处理)
  • 画布与对象渲染处理(多层结构多阶段绘制)

系列文章

Canvas框架们

与Canvas有关的框架有许多,它们具有不同的特点和使用场景:

  • 游戏开发与交互艺术:EaselJS, P5.js
  • 2D渲染库:Fabric.js, Konva.js
  • 3D渲染库:Three.js
  • 特殊场景与用途:heatmap.js, Paper.js

这里主要分析Canvas2D渲染框架中的两个:Fabric.jsKonva.js

Fabric

一个出现将近十年的经典Canvas图形库,最初由一个商品编辑器的产品发展而来,它所做的主要工作:

  1. 将绘制的元素以对象模型的方式进行封装后提供给使用者,方便操作的执行与状态的管理
  2. 增强了交互部分,可以方便的实现分组与操作对象的绑定
  3. 添加了丰富的事件,可以用来控制对象及画布不同时刻的行为

本文所用Fabric.js版本为3.5.1

从下面这几个方面来分析下Fabric中的主要功能设计:

  • 对象模型处理
  • 图形变换处理
  • 光标交互处理
  • 画布与对象渲染处理

对象模型处理

对象模型的使用方式

在创建对象模型部分主要有两种方式:使用基础图形与自定义图形。

1.基础图形

传入Rect的属性创建一个Fabric提供的矩形对象

let redRect = new fabric.Rect({ top: 100, left: 0, width: 80, height: 50, fill: 'red' });

2.自定义图形

在自定义图形对象时,需要实现initialize()_render()方法,前者用于属性与配置初始化,后者用于图形渲染。在添加到fabric的canvas实例上时会自动调用。

fabric.RainbowText = fabric.util.createClass(fabric.Object, {
  type: 'rainbow-text',
  colors: ["red",'rgb(217,31,38)', 'rgb(226,91,14)', 'rgb(245,221,8)', 'rgb(5,148,68)', 'rgb(2,135,206)', 'rgb(4,77,145)', 'rgb(42,21,113)'],
  initialize: function (options) {
    options = options || {};
    this.callSuper('initialize', options);
    this.text = options.text || 'Rainbow Text';
    this.width = this.text.length * this.fontSize * 0.6;
    this.height = this.fontSize * 1.5;
    this.
  },
  _render: function (ctx) {
    ctx.font = "30px Sans";
    for(let i=this.colors.length; i>0; i--) {
      ctx.fillStyle = this.colors[i];
      ctx.fillText(this.text || 'Hello world', (i + 1) * 3, (i + 1) * 3);
    }
  }
})

在对象模型的创建过程中,fabric将属性、渲染方法等绑定在了一起。

对象模型的设计

在对象模型的实现中,主要有以下几个元素:

  1. 类的创建与继承:createClass
  2. 对象模型的父类:fabric.Object
  3. 其他方法的混入:object.extend

createClass

这一部分的代码用于框架中所有对象类与功能类的实现,由于年代久远,用的方式比较古老。

以createClass为例:

// src/utils/lang_class.js
function createClass() {
    var parent = null,
        properties = slice.call(arguments, 0);
    // 将第一个参数作为父类
    if (typeof properties[0] === 'function') {
      parent = properties.shift();
    }
    // 声明子类
    function klass() {
      this.initialize.apply(this, arguments);
    }
    klass.superclass = parent;
    klass.subclasses = [];
    // 若存在父类则继承原型对象
    if (parent) {
      Subclass.prototype = parent.prototype;
      klass.prototype = new Subclass();
      parent.subclasses.push(klass);
    }
    // 将父类方法与自定义方法绑定到子类上
    for (var i = 0, length = properties.length; i < length; i++) {
      addMethods(klass, properties[i], parent);
    }
    if (!klass.prototype.initialize) {
      klass.prototype.initialize = function(){};
    }
    klass.prototype.constructor = klass;
    klass.prototype.callSuper = callSuper;
    return klass;
  }

fabric.Object

在这个"类"中定义了一个图形对象拥有的主要属性及方法。

其中属性分为状态属性缓存属性

  • 状态属性:图形的各种状态
    • 几何与变换:如top width scaleX flipX transformMatrix
    • 描边相关: 如stroke strokeWidth strokeDashArray
    • 样式相关: 如opacity fill globalCompositeOperation shadow
  • 缓存属性:在状态属性中会被缓存的属性
    • 为状态属性中的子集:如fill stroke width

而方法则主要分为属性方法绘制方法缓存方法

  • 属性方法:直接或间接的修改与获取当前属性,如变换rotate(),样式setColor()
  • 绘制方法:用于渲染,如render()等其他辅助方法
  • 辅助方法:用于克隆对象实例clone()、canvas元素转换()、序列化等等

这样一来,图形的渲染及属性的设置已经有了,还需要的是交互事件的绑定、变换的处理等等。

图形变换处理

如何处理变换是canvas绘制中必不可少的一环,尤其是有大量元素的复杂场景下。

变换矩阵基础

一般二维空间下变换的齐次矩阵如下(右乘):

$$ \left[ \begin{array}{ccc} a & c & e ; b & d & f ; 0 & 0 & 1 \end{array} \right] $$

对应使用Canvas API的话如下:

canvas.transform(a,b,c,d,e,f);

属性与方法

官方文档中所述,主要提供了如下与变换有关的属性与方法:

  • Canvas
    • 视口变换矩阵: viewportTransform;
  • Objects
    • 对象变换矩阵: calcOwnMatrix();
    • 结合分组变换: calcTransformMatrix();
  • Utils
    • 根据变换矩阵变换点的坐标: transformPoint(point, matrix);
    • 矩阵乘法: multiplyTransformMatrices(matrix, matrix);
    • 逆阵求解: invertTransform(matrix);
    • 将变换矩阵解析为属性对象: qrDecompose(matrix);

下面分析下canvas与object类中分别做了哪些与变换有关的工作。

fabric.Canvas的变换

在canvas类中通过viewportTransform来保存当前的视口变换矩阵,当在canvas上执行拖拽或缩放操作(zoome/pan)时该值会发生改变,会影响它所包含的所有对象的渲染:

// src/static_canvas.class.js
renderCanvas: function(ctx, objects) {
  var v = this.viewportTransform;
  ctx.save();
  //apply viewport transform once for all rendering process
  ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
  this._renderObjects(ctx, objects);
  ctx.restore();
}

相关的zoom/pan操作对视口变换矩阵的修改:

// src/static_canvas.class.js
zoomToPoint: function (point, value) {
  var before = point, vpt = this.viewportTransform.slice(0);
  point = transformPoint(point, invertTransform(this.viewportTransform));
  // 对scaleX与scaleY赋值
  vpt[0] = value;
  vpt[3] = value;
  var after = transformPoint(point, vpt);
  // 得到缩放前后的位移差
  vpt[4] += before.x - after.x;
  vpt[5] += before.y - after.y;
  return this.setViewportTransform(vpt);
},
absolutePan: function (point) {
  var vpt = this.viewportTransform.slice(0);
  // 拖拽产生的x与y的变化
  vpt[4] = -point.x;
  vpt[5] = -point.y;
  return this.setViewportTransform(vpt);
},

若在屏幕上存在一个点,想将其位置转换为变换后的canvas上的真实坐标,可以利用这个属性矩阵进行计算:

newPoint = fabric.util.transformPoint(P, canvas.viewportTransform);

fabric.Object的变换

变换操作与变换属性

要执行object的变换有两种方法:通过调用自身的变换方法和通过控制器上的交互

其中控制器指的是object包围盒矩形上可以拖拽的按钮,可以在上面的例子中选中图形后看到。

  1. 调用object方法

    object中会使用一些属性来保存变换相关的值,比如angle, scaleX等。在object上执行执行如rotate()等变换方法时会修改变换对应的属性值:

    // src/shapes/object.class.js
    rotate: function(angle) {
      var shouldCenterOrigin = (this.originX !== 'center' || this.originY !== 'center') && this.centeredRotation;
      // 以中心为变换基准点
      if (shouldCenterOrigin) {
        this._setOriginToCenter();
      }
      this.set('angle', angle);
      return this;
    },
    
  2. 控制器上的交互

    控制器交互处理过程中最终会调用canvas上的object变换方法,如_rotateObject:

    _rotateObject: function (x, y) {
      var t = this._currentTransform,
          target = t.target, constraintPosition,
          constraintPosition = target.translateToOriginPoint(target.getCenterPoint(), t.originX, t.originY);
      var lastAngle = atan2(t.ey - constraintPosition.y, t.ex - constraintPosition.x),
          curAngle = atan2(y - constraintPosition.y, x - constraintPosition.x),
          angle = radiansToDegrees(curAngle - lastAngle + t.theta),
          hasRotated = true;
      // ...
      // normalize angle to positive value
      if (angle < 0) {
        angle = 360 + angle;
      }
      angle %= 360;
      // rotation only happen here
      target.angle = angle;
      // Make sure the constraints apply
      target.setPositionByOrigin(constraintPosition, t.originX, t.originY);
      return hasRotated;
    },
    

变换矩阵

触发变换后,对象的变换属性发生了变化。利用这些属性可以计算出最终的复合变换矩阵,即使用object上的calcOwnMatrix()或calcTransformMatrix()。在它们中会调用composeMatrix,利用这些属性来计算对象的复合变换矩阵:

// src/util/misc.js
composeMatrix: function(options) {
  // 平移处理
  var matrix = [1, 0, 0, 1, options.translateX || 0, options.translateY || 0],
      multiply = fabric.util.multiplyTransformMatrices;
  // 旋转处理
  if (options.angle) {
    matrix = multiply(matrix, fabric.util.calcRotateMatrix(options));
  }
  // 缩放&仿射处理
  if (options.scaleX || options.scaleY || options.skewX || options.skewY || options.flipX || options.flipY) {
    matrix = multiply(matrix, fabric.util.calcDimensionsMatrix(options));
  }
  return matrix;
},

除此之外,还有计算不同类型操作(如旋转、平移)对应变换矩阵的函数等,这些方法用于控制器坐标变换等方法中。

// src/utils/misc.js
calcRotateMatrix: function(options) {
  // 取得angle属性,将角度转换为弧度,计算scale与skew值
  var theta = fabric.util.degreesToRadians(options.angle),
      cos = fabric.util.cos(theta),
      sin = fabric.util.sin(theta);
  return [cos, sin, -sin, cos, 0, 0];
},
// src/mixins/object_geometry.mixin.js
_calcTranslateMatrix: function() {
  var center = this.getCenterPoint();
  return [1, 0, 0, 1, center.x, center.y];
},

计算控制器坐标变换位置:

// src/mixins/object_geometry.mixin.js
calcCoords: function(absolute) {
  var rotateMatrix = this._calcRotateMatrix(),
      translateMatrix = this._calcTranslateMatrix(),
      startMatrix = multiplyMatrices(translateMatrix, rotateMatrix),
      vpt = this.getViewportTransform(),
      finalMatrix = absolute ? startMatrix : multiplyMatrices(vpt, startMatrix),
      dim = this._getTransformedDimensions(),
      w = dim.x / 2, h = dim.y / 2,
      tl = transformPoint({ x: -w, y: -h }, finalMatrix),
      tr = transformPoint({ x: w, y: -h }, finalMatrix),
      bl = transformPoint({ x: -w, y: h }, finalMatrix),
      br = transformPoint({ x: w, y: h }, finalMatrix);
  // corners
  var coords = {tl: tl, tr: tr, br: br, bl: bl};
  return coords;
},

操作控制器引起对象变换的处理流程

  1. 光标按下时,获取当前激活对象或者获取新对象并保存其当前的变换

    __onMouseDown => setActiveObject | _setupCurrentTransform

  2. 选中对象时移动光标,执行变换函数

    __onMouseMove => _transformObject => _performTransformAction

  3. 执行canvas对象上的变换函数,修改object上的对应变换属性,并请求渲染新内容

    _rotateObject | _scaleObject | OTHER_ACTION => requestRenderAll

光标交互处理

由于绘制的对象模型不是以dom元素的形式添加的,因此无法直接监听绘制元素的鼠标事件,只能通过监听所在canvas元素的鼠标事件来向下分发。

以mousemove为例,当canvas元素上监听到mousemove事件后,会经过以下处理流程:

  1. 画布监听到鼠标事件
  2. 判断绘制笔画模式 isDrawingMode
  3. 判断变换状态 _currentTransform
  4. 检测命中的目标对象 findTarget
  5. 设置光标样式,在目标对象上分发合成事件 _setCursorFromEvent && _fireOverOutEvents

其中最关键的是第4和第5步,即目标对象的检测和事件的分发

目标对象检测

// src/canvas.class.js
findTarget: function (e, skipGroup) {
  // 取得光标数据与激活对象
  var pointer = this.getPointer(e, true),
      aObjects = this.getActiveObjects();
  // 若存在激活中的对象,则再判断他们的分组和顶点选择情况
  if (aObjects.length > 1 && !skipGroup && activeObject === this._searchPossibleTargets([activeObject], pointer)) {
    return activeObject;
  }
  // -------------------------
  // 其他关于当前激活对象的条件判断
  // -------------------------
  // 接着寻找新的激活对象
  var target = this._searchPossibleTargets(this._objects, pointer);
  if (e[this.altSelectionKey] && target && activeTarget && target !== activeTarget) {
    target = activeTarget;
    this.targets = activeTargetSubs;
  }
  return target;
}

进入findTarget后,会通过_searchPossibleTargets => _checkTarget => containsPoint => Object.containsPoint => _findCrossPoints 一系列的流程得到包含当前点位置的目标对象,其中的关键是点是否在多边形内的判断,即光标与物体的碰撞检测,感兴趣的话可以看看链接的资料。

事件处理与分发

当检测到事件,获得目标对象后,就要进行事件的分发。

以mousemove事件为例:

// src/mixins/canvas_events.mixin.js
fireSyntheticInOutEvents: function(target, e, config) {
  var inOpt, outOpt, oldTarget = config.oldTarget, outFires, inFires,
      targetChanged = oldTarget !== target, canvasEvtIn = config.canvasEvtIn, canvasEvtOut = config.canvasEvtOut;
  if (targetChanged) {
    inOpt = { e: e, target: target, previousTarget: oldTarget };
    outOpt = { e: e, target: oldTarget, nextTarget: target };
  }
  inFires = target && targetChanged;
  outFires = oldTarget && targetChanged;
  // 若oldTarget存在且目标改变
  if (outFires) {
    canvasEvtOut && this.fire(canvasEvtOut, outOpt);
    oldTarget.fire(config.evtOut, outOpt);
  }
  // 若存在新的target
  if (inFires) {
    canvasEvtIn && this.fire(canvasEvtIn, inOpt);
    target.fire(config.evtIn, inOpt);
  }
},

可以看到,在这个事件处理函数中:

  • 将mouseout和mosueover事件进行合成处理
  • 在canvas和目标对象上分别触发对应事件
  • 根据是否存在oldTarget与新的target来触发鼠标in与out时的事件

画布与对象渲染处理

渲染的处理主要分为两步:canvas上的分层处理与object中的自定义渲染。

在上面的例子我们添加过一个示例矩形:

let canvas = new fabric.Canvas('c');
let redRect = new fabric.Rect({ top: 70, left: 10, width: 220, height: 8, fill: 'red' });
canvas.add(redRect);

现在来看看Fabric是如何处理它在canvas上的渲染的:

  • 对象处理

    设置对象属性, 计算边界点用于拖拽变换, 触发事件

    // src/static_canvas.class.js
    _onObjectAdded = function(obj) {
      this.stateful && obj.setupState();
      // 绑定canvas属性
      obj._set('canvas', this);
      // 设置包围矩形四角坐标,用于拖拽变换
      obj.setCoords();
      // 在canvas与对象上触发事件
      this.fire('object:added', { target: obj });
      obj.fire('added');
    }
    
  • canvas中的render流程

    1. requestRenderAll(): 根据渲染状态执行rAF动画
    2. renderAll(): 将当前对象绘制到上下文容器中的canvas上 * static_canvas: 将对象与容器传入renderCanvas() * canvas: 渲染双层canvas
    3. renderCanvas(): 主要的渲染函数,具体流程见多层绘制处理
  • object中的render流程

    1. 首先会执行对象模型基类object的render函数

      // src/shapes/object.class.js
      render: function(ctx) {
        ctx.save()
        // 应用融合选项(globalCompositeOperation)
        this._setupCompositeOperation(ctx);
        // 绘制表示选中区域的矩形
        this.drawSelectionBackground(ctx); 
        // 在ctx上应用变换矩阵
        this.transform(ctx); 
        // 设置透明度(gloablAlpha)与阴影(shadow*属性)
        this._setOpacity(ctx); 
        this._setShadow(ctx, this);
        // 绘制对象的主函数
        this.drawObject(ctx);
        ctx.restore()
      }
      
    2. 在drawObject中会进行属性设置与绘制

      // src/shapes/object.class.js
      this._renderBackground(ctx);
      this._setStrokeStyles(ctx, this);
      this._setFillStyles(ctx, this);
      // 自定义对象实现的_render()渲染方法会在这里被执行
      this._render(ctx);
      this._drawClipPath(ctx);
      this.fill = originalFill;
      this.stroke = originalStroke;
      

多层canvas结构与多阶段绘制

html的变化

在使用Frabic前编写的htm如下:

<div id="wrapper">
  <canvas id="c" class="my-class"></canvas>
</div>

创建fabric canvas实例后变成如下:

<div id="wrapper">
  <div class="canvas-container">
    <canvas id="c" class="lower-canvas my-class"></canvas>
    <canvas class="upper-canvas my-class"></canvas>
  </div>
</div>

多层canvas结构:2+1

Fabric在canvas类中设计了两个屏上canvas层:lower_canvasupper_canvas,与一个隐藏层:cache_canvas

canvas层 上下文对象 是否可见 作用
upper_canvas contextTop 监听光标事件,绘制笔刷(brush),自由绘制(free_drawing)的区域
lower_canvas contextContainer 绘制静态对象(objects),主要内容绘制(main_drawing)区域
cache_canvas contextCache 用于目标检测,在_checkTarget()中使用

多阶段绘制

renderCanvas()处理过程中具有如下绘制步骤:

  1. dom容器,即#wrapper元素
  2. 绘制背景色,canvas backgroundColor
  3. 绘制背景图片,canvas backgroundImage
  4. 绘制图形对象,canvas objects
  5. 绘制图形对象控制器,canvas objects' controls
  6. 绘制图形对象选择器,canvas object selection
  7. 绘制裁剪区域层,canvas clipped area
  8. 绘制层叠图像,canvas overlay image
  9. 渲染完成回调,after:render callback

注意:fabric.Canvas具有以上处理流程,而它的父类StaticCanvas则没有这个流程

图片滤镜处理

Fabric还提供了多种图片滤镜的功能,可选择webgl与canvas两种backend。

在每种滤镜中,均包含了两种backend所需的属性和函数,根据设置的渲染方式执行对应的函数。以取反色的滤镜(invert filter)为例:

filters.Invert = createClass(filters.BaseFilter, /** @lends fabric.Image.filters.Invert.prototype */ {
  type: 'Invert',
  // WebGLBackend: 在片元着色器中执行取反色操作
  fragmentSource: 'precision highp float;\n' +
    'uniform sampler2D uTexture;\n' +
    'uniform int uInvert;\n' +
    'varying vec2 vTexCoord;\n' +
    'void main() {\n' +
      'vec4 color = texture2D(uTexture, vTexCoord);\n' +
      'if (uInvert == 1) {\n' +
        'gl_FragColor = vec4(1.0 - color.r,1.0 -color.g,1.0 -color.b,color.a);\n' +
      '} else {\n' +
        'gl_FragColor = color;\n' +
      '}\n' +
    '}',
    * Apply the Invert operation to a Uint8Array representing the pixels of an image.
  // Canvas2dFilterBackend: 在表示图像像素数据的Uint8Array对象上执行取反色操作
  applyTo2d: function(options) {
    var imageData = options.imageData,
        data = imageData.data, i,
        len = data.length;
    for (i = 0; i < len; i += 4) {
      data[i] = 255 - data[i];
      data[i + 1] = 255 - data[i + 1];
      data[i + 2] = 255 - data[i + 2];
    }
  },
  // ...
});

不足与改进

官方的评价

对于自身不擅长的地方,官方在GitHub wiki中的not so good部分略有说明,年代的久远貌似没有影响这些。

  1. 碰撞检测(逐像素与曲线) PS: 曲线碰撞检测可以使用Path2D + Canvas API(isPointInStroke & isPointInPath)的方式来实现
  2. 图表
  3. 精灵动画
  4. 3D渲染(推荐使用Three.js)

多层结构的思考

当编辑器的交互功能较为复杂时,也许会在upper_canvas上同时存在多种free_drawing或者其他类型的对象,当这些元素通过rAF动画绘制在upper_canvas时,在渲染与刷新的过程中未免会产生大量多余重复的绘制。

若为第三方扩展或者是自己实现的话,可以采用多层offscreen的方式(document.createElement('canvas')),在rAF更新时仅更新对应offscreen canvas上的内容,接着在upper_canvas上进行内容合成,这样一来其他动态类型元素所在的offscreen层的内容则无需重新绘制,可以减轻一些渲染压力。

除此之外,也可以尝试使用OffscreenCanvas对象+worker的方法进行优化。

参考