了解 Canvas2D 渲染功能实现与设计,在使用时知其所以然,在创造时有所借鉴。从以下这四个方面来分析 Fabric.js
- 对象模型处理 (使用及设计实现)
- 图形变换处理 (canvas 与 object 变换)
- 光标交互处理 (目标检测与事件处理)
- 画布与对象渲染处理 (多层结构与多阶段绘制)
系列目录
- Canvas2D 渲染库简析:(一)Fabric
- Canvas2D 渲染库简析:(二)Konva
- Canvas2D 渲染库简析:(三)Pixi
Canvas 框架们
与 Canvas 有关的框架有许多,它们具有不同的特点和使用场景:
- 游戏开发与交互艺术:EaselJS, P5.js
- 2D 渲染库:Fabric.js, Konva.js
- 3D 渲染库:Three.js
- 特殊场景与用途:heatmap.js, Paper.js
这里主要分析 Canvas2D 渲染框架中的两个:Fabric.js和Konva.js
Fabric
一个出现将近十年的经典 Canvas 图形库,最初由一个商品编辑器的产品发展而来,它所做的主要工作:
- 将绘制的元素以对象模型的方式进行封装后提供给使用者,方便操作的执行与状态的管理
- 增强了交互部分,可以方便的实现分组与操作对象的绑定
- 添加了丰富的事件,可以用来控制对象及画布不同时刻的行为
- …
本文所用 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);
}
}
})
复制代码
See the Pen fabric-object by yrq110 (@yrq110) on CodePen.
在对象模型的创建过程中,fabric 将属性、渲染方法等绑定在了一起。
对象模型的设计
在对象模型的实现中,主要有以下几个元素:
- 类的创建与继承:createClass
- 对象模型的父类:fabric.Object
- 其他方法的混入:object.extend
createClass
这一部分的代码用于框架中所有对象类与功能类的实现,由于年代久远,用的方式比较古老。
以 createClass 为例:
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 绘制中必不可少的一环,尤其是有大量元素的复杂场景下。
变换矩阵基础
一般二维空间下变换的齐次矩阵如下 (右乘):
对应使用 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) 时该值会发生改变,会影响它所包含的所有对象的渲染:
renderCanvas: function(ctx, objects) {
var v = this.viewportTransform;
ctx.save();
ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
this._renderObjects(ctx, objects);
ctx.restore();
}
复制代码
相关的 zoom/pan 操作对视口变换矩阵的修改:
zoomToPoint: function (point, value) {
var before = point, vpt = this.viewportTransform.slice(0);
point = transformPoint(point, invertTransform(this.viewportTransform));
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);
vpt[4] = -point.x;
vpt[5] = -point.y;
return this.setViewportTransform(vpt);
},
复制代码
若在屏幕上存在一个点,想将其位置转换为变换后的 canvas 上的真实坐标,可以利用这个属性矩阵进行计算:
newPoint = fabric.util.transformPoint(P, canvas.viewportTransform);
复制代码
fabric.Object 的变换
变换操作与变换属性
要执行 object 的变换有两种方法:通过调用自身的变换方法和通过控制器上的交互。
其中控制器指的是 object 包围盒矩形上可以拖拽的按钮,可以在上面的例子中选中图形后看到。
- 调用 object 方法
object 中会使用一些属性来保存变换相关的值,比如 angle, scaleX 等。在 object 上执行执行如 rotate() 等变换方法时会修改变换对应的属性值: ```
rotate: function(angle) { var shouldCenterOrigin = (this.originX !== ‘center’ || this.originY !== ‘center’) && this.centeredRotation;
if (shouldCenterOrigin) { this._setOriginToCenter(); } this.set(‘angle’, angle); return this; }, 复制代码
2.
控制器上的交互
<br />控制器交互处理过程中最终会调用 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;
if (angle < 0) { angle = 360 + angle; } angle %= 360;
target.angle = angle;
target.setPositionByOrigin(constraintPosition, t.originX, t.originY); return hasRotated; }, 复制代码
**变换矩阵**
触发变换后,对象的变换属性发生了变化。利用这些属性可以计算出最终的复合变换矩阵,即使用 object 上的 calcOwnMatrix() 或 calcTransformMatrix()。在它们中会调用 composeMatrix,利用这些属性来计算对象的复合变换矩阵:
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; }, 复制代码
除此之外,还有计算不同类型操作 (如旋转、平移) 对应变换矩阵的函数等,这些方法用于控制器坐标变换等方法中。
calcRotateMatrix: function(options) {
var theta = fabric.util.degreesToRadians(options.angle), cos = fabric.util.cos(theta), sin = fabric.util.sin(theta); return [cos, sin, -sin, cos, 0, 0]; },
_calcTranslateMatrix: function() { var center = this.getCenterPoint(); return [1, 0, 0, 1, center.x, center.y]; }, 复制代码
计算控制器坐标变换位置:
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);
var coords = {tl: tl, tr: tr, br: br, bl: bl}; return coords; }, 复制代码
**操作控制器引起对象变换的处理流程**
1.
光标按下时,获取当前激活对象或者获取新对象并保存其当前的变换
<br />__onMouseDown => setActiveObject | _setupCurrentTransform
2.
选中对象时移动光标,执行变换函数
<br />__onMouseMove => _transformObject => _performTransformAction
3.
执行 canvas 对象上的变换函数,修改 object 上的对应变换属性,并请求渲染新内容
<br />_rotateObject | _scaleObject | OTHER_ACTION => requestRenderAll
<a name="83b5a2c9"></a>
### 光标交互处理
由于绘制的对象模型不是以 dom 元素的形式添加的,因此无法直接监听绘制元素的鼠标事件,只能通过监听所在 canvas 元素的鼠标事件来向下分发。
以 mousemove 为例,当 canvas 元素上监听到 mousemove 事件后,会经过以下处理流程:
1. 画布监听到鼠标事件
2. 判断绘制笔画模式 **isDrawingMode**
3. 判断变换状态 **_currentTransform**
4. 检测命中的目标对象 **findTarget**
5. 设置光标样式,在目标对象上分发合成事件 **_setCursorFromEvent && _fireOverOutEvents**
其中最关键的是第 4 和第 5 步,即目标对象的检测和事件的分发
**目标对象检测**
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` 一系列的流程得到包含当前点位置的目标对象,其中的关键是[点是否在多边形内](https://web.cs.ucdavis.edu/~okreylos/TAship/Spring2000/PointInPolygon.html)的判断,即光标与物体的碰撞检测,感兴趣的话可以看看链接的资料。
**事件处理与分发**
当检测到事件,获得目标对象后,就要进行事件的分发。
以 mousemove 事件为例:
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;
if (outFires) { canvasEvtOut && this.fire(canvasEvtOut, outOpt); oldTarget.fire(config.evtOut, outOpt); }
if (inFires) { canvasEvtIn && this.fire(canvasEvtIn, inOpt); target.fire(config.evtIn, inOpt); } }, 复制代码
可以看到,在这个事件处理函数中:
- 将 mouseout 和 mosueover 事件进行合成处理
- 在 canvas 和目标对象上分别触发对应事件
- 根据是否存在 oldTarget 与新的 target 来触发鼠标 in 与 out 时的事件
<a name="cf292311"></a>
### 画布与对象渲染处理
渲染的处理主要分为两步: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 上的渲染的:
-
对象处理
<br />设置对象属性, 计算边界点用于拖拽变换, 触发事件
_onObjectAdded = function(obj) { this.stateful && obj.setupState();
obj._set(‘canvas’, this);
obj.setCoords();
this.fire(‘object:added’, { target: obj }); obj.fire(‘added’); } 复制代码
-
canvas 中的 render 流程
1.
requestRenderAll(): 根据渲染状态执行 rAF 动画
2.
renderAll(): 将当前对象绘制到上下文容器中的 canvas 上
- static_canvas: 将对象与容器传入renderCanvas()
- canvas: 渲染双层canvas<br />
复制代码
3.
renderCanvas(): 主要的渲染函数,具体流程见多层绘制处理
-
object 中的 render 流程
1.
首先会执行对象模型基类 object 的 render 函数
render: function(ctx) { ctx.save()
this._setupCompositeOperation(ctx);
this.drawSelectionBackground(ctx);
this.transform(ctx);
this._setOpacity(ctx); this._setShadow(ctx, this);
this.drawObject(ctx); ctx.restore() } 复制代码
2.
在 drawObject 中会进行属性设置与绘制
this._renderBackground(ctx); this._setStrokeStyles(ctx, this); this._setFillStyles(ctx, this);
this._render(ctx); this._drawClipPath(ctx); this.fill = originalFill; this.stroke = originalStroke; 复制代码
<a name="3021db47"></a>
#### 多层 canvas 结构与多阶段绘制
**html 的变化**
在使用 Frabic 前编写的 htm 如下:
创建 fabric canvas 实例后变成如下:
**多层 canvas 结构:2+1**
Fabric 在[canvas 类](https://github.com/fabricjs/fabric.js/blob/master/src/canvas.class.js)中设计了两个屏上 canvas 层:**lower_canvas**与**upper_canvas**,与一个隐藏层:**cache_canvas**。
| canvas 层 | 上下文对象 | 是否可见 | 作用 |
| --- | --- | --- | --- |
| upper_canvas | contextTop | 是 | 监听光标事件,绘制笔刷 (brush),自由绘制 (free_drawing) 的区域 |
| lower_canvas | contextContainer | 是 | 绘制静态对象 (objects),主要内容绘制 (main_drawing) 区域 |
| cache_canvas | contextCache | 否 | 用于目标检测,在[_checkTarget()](https://github.com/fabricjs/fabric.js/blob/master/src/canvas.class.js#L1243)中使用 |
**多阶段绘制**
在[renderCanvas()](https://github.com/fabricjs/fabric.js/blob/master/src/static_canvas.class.js#L927)处理过程中具有如下绘制步骤:
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 则没有这个流程_
<a name="62d7c6a8"></a>
#### 图片滤镜处理
Fabric 还提供了多种图片滤镜的功能,可选择 webgl 与 canvas 两种 backend。
在每种滤镜中,均包含了两种 backend 所需的属性和函数,根据设置的渲染方式执行对应的函数。以取反色的滤镜 (invert filter) 为例:
filters.Invert = createClass(filters.BaseFilter, { type: ‘Invert’,
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.
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部分略有说明,年代的久远貌似没有影响这些。
- 碰撞检测 (逐像素与曲线) PS: 曲线碰撞检测可以使用 Path2D + Canvas API(isPointInStroke & isPointInPath) 的方式来实现
- 图表
- 精灵动画
- 3D 渲染 (推荐使用 Three.js)
多层结构的思考
当编辑器的交互功能较为复杂时,也许会在 upper_canvas 上同时存在多种 free_drawing 或者其他类型的对象,当这些元素通过 rAF 动画绘制在 upper_canvas 时,在渲染与刷新的过程中未免会产生大量多余重复的绘制。
若为第三方扩展或者是自己实现的话,可以采用多层 offscreen的方式 (document.createElement('canvas')),在 rAF 更新时仅更新对应 offscreen canvas 上的内容,接着在 upper_canvas 上进行内容合成,这样一来其他动态类型元素所在的 offscreen 层的内容则无需重新绘制,可以减轻一些渲染压力。
除此之外,也可以尝试使用OffscreenCanvas 对象 + worker的方法进行优化。
