Canvas2D渲染库简析:(一)Fabric
了解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);
}
}
})
在对象模型的创建过程中,fabric将属性、渲染方法等绑定在了一起。
对象模型的设计
在对象模型的实现中,主要有以下几个元素:
- 类的创建与继承:createClass
- 对象模型的父类:fabric.Object
- 其他方法的混入: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包围盒矩形上可以拖拽的按钮,可以在上面的例子中选中图形后看到。
-
调用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; },
-
控制器上的交互
控制器交互处理过程中最终会调用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;
},
操作控制器引起对象变换的处理流程
-
光标按下时,获取当前激活对象或者获取新对象并保存其当前的变换
__onMouseDown => setActiveObject | _setupCurrentTransform
-
选中对象时移动光标,执行变换函数
__onMouseMove => _transformObject => _performTransformAction
-
执行canvas对象上的变换函数,修改object上的对应变换属性,并请求渲染新内容
_rotateObject | _scaleObject | OTHER_ACTION => requestRenderAll
光标交互处理
由于绘制的对象模型不是以dom元素的形式添加的,因此无法直接监听绘制元素的鼠标事件,只能通过监听所在canvas元素的鼠标事件来向下分发。
以mousemove为例,当canvas元素上监听到mousemove事件后,会经过以下处理流程:
- 画布监听到鼠标事件
- 判断绘制笔画模式 isDrawingMode
- 判断变换状态 _currentTransform
- 检测命中的目标对象 findTarget
- 设置光标样式,在目标对象上分发合成事件 _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流程
- requestRenderAll(): 根据渲染状态执行rAF动画
- renderAll(): 将当前对象绘制到上下文容器中的canvas上 * static_canvas: 将对象与容器传入renderCanvas() * canvas: 渲染双层canvas
- renderCanvas(): 主要的渲染函数,具体流程见多层绘制处理
-
object中的render流程
-
首先会执行对象模型基类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() }
-
在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_canvas与upper_canvas,与一个隐藏层:cache_canvas。
canvas层 | 上下文对象 | 是否可见 | 作用 |
---|---|---|---|
upper_canvas | contextTop | 是 | 监听光标事件,绘制笔刷(brush),自由绘制(free_drawing)的区域 |
lower_canvas | contextContainer | 是 | 绘制静态对象(objects),主要内容绘制(main_drawing)区域 |
cache_canvas | contextCache | 否 | 用于目标检测,在_checkTarget()中使用 |
多阶段绘制
在renderCanvas()处理过程中具有如下绘制步骤:
- dom容器,即#wrapper元素
- 绘制背景色,canvas backgroundColor
- 绘制背景图片,canvas backgroundImage
- 绘制图形对象,canvas objects
- 绘制图形对象控制器,canvas objects' controls
- 绘制图形对象选择器,canvas object selection
- 绘制裁剪区域层,canvas clipped area
- 绘制层叠图像,canvas overlay image
- 渲染完成回调,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部分略有说明,年代的久远貌似没有影响这些。
- 碰撞检测(逐像素与曲线) 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的方法进行优化。
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/front-end/dive-into-2d-canvas-framework-i-fabric/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。