关于canvas的使用经验及深入学习总结,主要有以下几个方面:

  1. 基础绘图、平滑曲线和交互变换
  2. 状态管理及图像处理
  3. 性能优化与绘制原理

如果不小心点开了不想看什么啰嗦的基础和认为无用的原理,只想搞钱看一些实现效果的话,可以看看这一篇

基础绘图

如果要在canvas中画一个如下这样的小房子,对于多数开发者来说不在话下:

在画房子的过程中,主要使用了三类Canvas API

  1. 图形绘制

    fillRect, strokeRect, arc, drawImage…

  2. 样式属性

    fillStyle, strokeStyle, lineWidth, lineCap…

  3. 路径相关

    moveTo, lineTo, arcTo, ellipse, closePath…

使用这些API可以完成大部分简单的canvas绘制任务,比如海报中的元素绘制,基础图形和线条绘制等等。

曲线绘制

canvas提供了两种绘制曲线的API:quadraticCurveTobezierCurveTo

  • quadraticCurveTo: 用于绘制二次贝塞尔曲线,需要四个参数:一个控制点与终点的坐标
  • bezierCurveTo: 用于绘制三次贝塞尔曲线,需要六个参数:两个控制点与终点的坐标

在上面的例子中,蓝色表示曲线的起始与终止点红色表示控制点

若使用原生的曲线API,需要每次传入特定的控制点坐标。在已知一个曲线所经过的关键点的情况下,想画一条平滑曲线就比较困难了,需要手动计算上面的控制点,或使用其他曲线插值方法。

经过关键点的平滑曲线

笔者查阅了一些平滑曲线的插值与绘制算法,并结合canvas所提供的API,封装了一个用于绘制平滑曲线的迷你库:@cvsfun/curve

通过调用该库的draw()函数,仅需传入需要经过的关键点坐标即可绘制出一条平滑曲线,在库的内部会自动计算控制点数据,绘制调用的API即为上述两种曲线API,无其他依赖。

在上面的例子中,蓝色表示曲线经过的关键点红色表示内部计算得到的控制点

除此之外,该函数还提供了可以设置闭合曲线关键点的曲线类型切换的属性,并且有可以返回计算所得到控制点数据的API,可作其他用途。

一般来说,更加复杂的canvas项目通常都离不开使用键鼠进行元素操作的相关功能,即增强用户体验的交互部分。并且这些功能也多多少少常常涉及一些画布元素的变换。

交互与变换

添加鼠标交互

以鼠标交互为例,若要添加这种交互,仅需要监听一些鼠标事件,并对事件数据进行处理与绘制即可。

数据获取画布绘制这两个部分有不同的处理方法,下面以笔刷绘制为例。

在鼠标事件中获取数据并进行绘制

在鼠标事件中取得坐标数据,经过一些处理与逻辑判断后,使用绘制API在canvas上进行实时绘制。

可以看到,fabric.js中使用的即为这种方法:

// fabric.js/src/brushes/pencil_brush.class.js
onMouseMove: function(pointer, options) {
  ...
  var points = this._points, length = points.length, ctx = this.canvas.contextTop;
  // draw the curve update
  this._saveAndTransform(ctx);
  if (this.oldEnd) {
    ctx.beginPath();
    ctx.moveTo(this.oldEnd.x, this.oldEnd.y);
  }
  this.oldEnd = this._drawSegment(ctx, points[length - 2], points[length - 1], true);
  ctx.stroke();
  ctx.restore();
  ...
},

在鼠标事件中获取数据,在帧动画函数中绘制

分离了数据操作画布绘制两个操作。在鼠标事件中取得坐标数据,经过一些处理后保存在全局变量中,在requestAnimationFrame所执行的帧动画函数中执行绘制操作。

这里需要提一点的是,浏览器在处理mousemove事件时,它的具体触发频率并没有在规范中写明,原因是:

The frequency rate of events while the pointing device is moved is implementation-, device-, and platform-specific, but multiple consecutive mousemove events SHOULD be fired for sustained pointer-device movement, rather than a single event for each instance of mouse movement. Implementations are encouraged to determine the optimal frequency rate to balance responsiveness with performance.

通过在devtools中的观察,PC端Chrome中的实现是每一帧触发一次

在上述的例子中,如果查看每一帧内的Task,是这个样子:

可以看到,先处理了mosuemove事件计算坐标,接着执行帧动画函数执行了渲染函数。

变换

context变换

在canvas中使用变换主要有两种方式:

  1. 使用translate, scalerotate三种变换API

    ctx.translate(100,0 );
    ctx.scale(2, 1);
    ctx.rotate(45 * Math.PI / 180);
    
  2. 使用自定义的变换矩阵,传入setTransform中应用变换

    基础变换API中没有斜切变换(skew),可以通过自定义矩阵来实现

    ctx.setTransform(a,b,c,d,e,f);
    

    对应的变换矩阵为: $$ \begin {bmatrix} a & c & e \newline b & d & f \newline 0 & 0 & 1 \newline \end {bmatrix} $$

坐标变换

在canvas上实际使用变换时,发现存在一些问题:

  1. 使用缩放变换方法会出现图案失真,有时在手动绘制图案时不希望发生失真,想使用成比例的关键点的位置,类似SVG中的原理
  2. 当需要联动多个层和多个图案时的复合变换会出现不符合预期的不协调现象,需要管理好变换的状态栈

因此,如果想自己控制绘制关键点的变换,需要达成以下目标:

  1. 可以通过基础变换的特征数值计算出复合变换矩阵
  2. 可以将变换矩阵应用到坐标点上计算变换后的位置
  3. 同时可以将变换矩阵直接用于原生的变换API

@cvsfun/transform是一个对变换操作提供矩阵数据支持的库,包含基础变换及坐标变换的API,内部使用mathjs进行矩阵运算。

使用方法如下面的例子所示:

import { T, S, R, getTransformCoordinate, getTransformMatrix, matToCvsMat } from "@cvsfun/transform";
let [x, y] = [10, 20];
// 基础变换列表
let transforms = [ 
  T(100, 20),
  S(2, 1),
  R(30)
]
// 计算复合变换矩阵
let transformMatrix = getTransformMatrix(transforms); // 返回结果为一个二维数组,若第二参数传true,则为求解逆阵
// 计算原始点经过变换后点的坐标
let [newX, newY] = getTransformCoordinate(x, y, transformMatrix)
// 将变换矩阵转换成setTransform所需的参数
ctx.setTransform(...matToCvsMat(transformMatrix));

状态管理

canvas context中的状态

在使用canvas的过程中,canvas context中会保持一些状态信息,这些信息主要分为三类:

  1. 变换矩阵 - 当前的变换矩阵
  2. 裁剪相关 - 当前clip操作的裁剪区域(dirty zone)
  3. 样式属性 - 当前一些属性:笔触宽度、颜色、线段连接方式、全局透明度等

而以下两类数据不会保存到context中:

  1. 路径相关 - 当前路径为持久化数据,仅能通过beginPath()方法重置
  2. bitmap - 为绑定在canvas上的属性

状态控制: save() 和 restore()

每个context都会使用一个栈结构来保存绘制状态信息,通过使用如下两种API对状态信息栈进行操作:

  • save(): 将当前上下文的状态信息压入栈中
  • restore(): 将栈顶的状态信息推出,恢复该状态的context

下面是一个状态控制例子:

从左向右一次画了5个样式不同的矩形,可以通过样式变化看出对状态栈的操作。

Path2D

刚才说到,context中不会保存路径相关的状态信息,那么有木有其他法子来获取路径数据呢?

除了自己手动记录相关数据的笨办法外,canvas提供了一个Path2D接口,在它的对象上可以执行与context相同的路径相关API。

下面的这两段线条是分别使用context和Path2D这两种方式绘制的:

看完使用再来看看它的兼容性

除了IE以外基本上是全部支持的,如果不需要兼容老版本可以放心使用。

那么用了Path2D对象有什么好处吗?主要有以下几点:

  1. 可以保存路径信息,用于后续的其他处理,如绘制了多个多段曲线或路径。
  2. 可以在isPointInPath等接口中使用,用于碰撞检测等场景。
  3. 当交互或操作复杂时,可以简化代码,增强路径操作部分的可读性

碰撞检测

context上提供了两个用于判断一个点是否位于一段路径的包围区域(nozero模式)中或笔触轨迹上的API:isPointInPath()isPointInStroke()

它们均有两种用法:

  1. 检测一点是否在当前路径的相关区域上 f(x, y),结合(x,y)与当前context上的路径进行判断
  2. 检测一点是否在指定路径的相关区域上 f(path, x, y),结合(x,y)与传入的指定路径path进行判断

isPointInStroke为例,看看下面这个例子:

当光标悬浮在直线上就会绘制红色小圆。

图像处理

主要是一些实现效果,可以看看这篇介绍,包含但不限于如下:

  • mask遮罩合成
  • 橡皮擦效果
  • SVG路径绘制
  • 不受层叠加深影响的统一透明度

性能优化

多层与offscreen canvas

当一个canvas上:

  1. 元素众多,种类繁多时
  2. 功能需求不同时,有的要求不失真,有的需要有交互,有的需要全局透明度等

可以考虑使用多层canvas的结构。并且对于多层canvas的内容,可以分为onscreenoffscreen,前者指的是在屏幕上的canvas元素,在html中存在对应标签,后者为使用createElement创建的DOM元素(不是OffscreenCanvas接口),不会渲染在屏幕上。

看看下面这个透明度的例子:

其中先在offscreen的canvas中绘制了不透明的多个图形,接着将它的内容绘制在了onscreen的canvas上,并应用了全局透明度属性。

当元素与层数增多时,在每次重绘时只需要修改指定层上指定元素的内容,提高canvas绘制的性能。而且对于分层内容也较易管理,可以把同一类的元素放在同一层上进行渲染与操作。

部分重绘

  • 复用尽可能多的内容

    比如在需要更新部分内容的时候使用clearRect(x, y, w, h)擦除指定区域像素进行重绘,而不是清空所有内容

  • 有利于提高执行动画时的性能

// Re-render full content
context.fillRect(0, 0, canvas.width, canvas.height);
// Re-render area which need updated
context.fillRect(last.x, last.y, canvas.width, canvas.height);

清空canvas的三种方法: clearRect()fillRect()重设尺寸

GPU计算

当JS中有大量的数学运算任务时,除了使用WebWorker多线程等优化性能的方法外,还可以将它们交给GPU去跑。

使用GPU.js,可以将一些"简单"的js函数转换成GLSL语言并在着色器中使用GPU执行。

以alpha通道融合的例子为例,下面为原始的JS代码

let mask = [255, 255, 230, 200, ...]; // alpha channel data
let pixelData = resultMaskCtx.getImageData(0, 0, maskWidth, maskHeight);
let size = maskWidth * maskHeight;
for (let i = 0; i < size; i++) {
  if (mask[i] !== 255) {
    pixelData.data[(i + 1) * 4 - 1] = mask[i];
  }
}
resultMaskCtx.putImageData(pixelData, 0, 0);

将这个任务转换成GPU.js的形式:

import { GPU } from "gpu.js"
...
const compositeMaskWithGPU = (src, mask) => {
  const size = src.length;
  const gpu = new GPU();
  const mainGPU = gpu
    .createKernel(function(src, mask) {
      let data = src[this.thread.x];
      if ((this.thread.x + 1) % 4 === 0) data = mask[(this.thread.x + 1) / 4 - 1];
      return data;
    })
    .setOutput([size])
  const result = mainGPU(src, mask);
  return Uint8ClampedArray.from(result); // 这里转换成Uint8ClampedArray是由于ImageData的数据格式要求
}
...
let mask = [255, 255, 230, 200, ...]; // alpha channel data
let pixelData = resultMaskCtx.getImageData(0, 0, maskWidth, maskHeight);
let size = maskWidth * maskHeight;
const data = this.compositeMaskWithGPU(pixelData.data, mask);
const resultImageData = new ImageData(data, resultMaskLayer.width, resultMaskLayer.height)
resultMaskCtx.putImageData(resultImageData, 0, 0);

上面的例子仅为了展示两种方式写法上的不同,在这里的改写并没有带来性能上的提升。不过当运算较为复杂时有较为明显的性能提升,可以看看GPU.js的benchmark

当操作canvas的像素数据时,可能会进行比较复杂的CPU密集型运算,用这个库的话可以带来更好的性能。

其他

其他的优化点:

  • 为避免次像素渲染(subpixel rendering),使用整形作为绘制时使用的坐标值,尽管部分浏览器有抗锯齿特性
  • 使用window.requestAnimationFrame执行动画而不是window.setInterval
  • 减少不必要的canvas状态变化

绘制原理

浏览器的渲染流水线是一个比较复杂的过程,在这里仅关注canvas图形绘制方面的原理,深究一下那些圆和方块和曲线到底是谁计算和画出来的

谁负责Canvas的绘制

以canvas为目标,以chrome为入口,来看看它引擎盖里面都有什么东西:

  1. Chrome - 2008

    Google Web浏览器,家喻户晓,无需多言

  2. Chromium - 2008

    Chrome背后的开源项目,包含Chromium和Chromium OS

  3. Blink - 2013

    Chromium所使用的浏览器引擎,为Webkit中Webcore组件的一个分支,作为Chromium计划的一部分,在Chrome28版本之后使用。

  4. Skia - 2005

    开源的跨平台2D图形库,具有图形绘制、路径与几何计算、结果导出、硬件加速等功能,HTML5 Canvas的相关接口均可以追溯到Skia中的对应API和模块,比如PathKit、SkCanvas等。

  5. GPU(OpenGL) / CPU(PDF)

    GPU加速与指定设备导出等

Skia图形库

Skia不仅作为Chrome的底层图形库,也是Android、Firefox和Flutter等项目的图形库。

Skia API

以下面几个Skia Class为例,从它们的方法中可以看到到很多Canvas API的影子,甚至是完全一样。

SkCanvas

  • 上下文:getGrContext, save, restore
  • 像素: peek/read/writePixels
  • 变换:translate, rotate, scale, skew
  • 绘制:drawPoints, drawLine, drawCircle

SkPaint

  • 笔触:setStrokeCap, setStrokeJoin
  • 填充:getFillPath, kFill_Style
  • 文字:getTextSize, measureText
  • 效果:setPathEffect, setBlendMode

SkPath

  • 路径:moveTo, lineTo, quadTo, conicTo, cubicTo, arcTo
  • 图形路径:addRect, addOval, addCircle

SkGeometry

  • 多种曲线的插值求解:二次贝塞尔(quad)、二次NURBS(conic)、三次贝塞尔(cubic)

Skia绘制

Chromium中:

绘制层

  • Skia中有很多类,最底层的类是SkCanvas,它包含了所有绘制所需的方法。
  • SkCanvas会将图形绘制到一个内部的SkDevice对象上,该对象维护一个SkBitmap对象,用来存储比特数据。
  • 还有针对平台的SkPlatformCanvas,允许使用一些特定平台的API。它的后台是SkPlatformDevice,包含Skia与Windows的共享内存,可以在上面创建一个保存比特数据的HBITMAP并在HDC(GDI句柄)上绘制。

应用层

  • 在Webkit层(即Blink)上仅使用SkCanvasSkPlatformCanvas这两个对象
  • 对于在浏览器进程中所执行的高层代码,提供了gfx::Canvas类,它封装了SkCanvas,并包含文字绘制与通用图元绘制的功能

渲染流程

  1. 指令处理

    SkCanvas -> MCRec -> DeviceCM -> SkBaseDevice -> SkPDFDevice|SkBitmapDevice|SkGpuDevice

    解析绘制指令,根据状态栈操作取得状态信息

  2. 解析处理

    SkBitmapDevice -> SkDraw -> SkScan|SkDraw1Glyph

    根据绘制方式,计算变换坐标,解析绘制图元,并进行抗锯齿等其他处理

  3. 渲染处理

    SkBlitter -> SkBlitRow|SkShader

    渲染指定区域,填充像素,添加其他效果(透明度或抖动处理)

在Skia中画一个矩形

  1. 定义一个Bitmap - SkBitmap
  2. 分配Bitmap所占用的空间
  3. 指定输出设备或上下文 - SkCanvas|SkDevice
  4. 设置绘制风格 - SkPaint
  5. 执行绘制操作 - SkCanvas::drawRect

Transform Matrix

闲来无事突然想到,css中transform与canvas中transform同样是矩阵变换,它们的处理有什么不同呢?

CSS Transform属性的处理中,以translate变换为例:

TransformationMatrix& TransformationMatrix::Translate(double tx, double ty) {
  matrix_[3][0] += tx * matrix_[0][0] + ty * matrix_[1][0];
  matrix_[3][1] += tx * matrix_[0][1] + ty * matrix_[1][1];
  matrix_[3][2] += tx * matrix_[0][2] + ty * matrix_[1][2];
  matrix_[3][3] += tx * matrix_[0][3] + ty * matrix_[1][3];
  return *this;
}

其中matrx_TransformationMatrix这个类的成员变量,表示当前的变换矩阵。可以看到对于基础变换直接在matrx_(4✖4二维数组)上进行了变换的融合操作(左乘)。

这个操作是Blink引擎负责计算的,用于之后的元素渲染与绘制。而Canvas Transform的话理所当然的在Skia中进行处理,同样以translate为例:

void SkMatrix::setTranslate(SkScalar dx, SkScalar dy) {
  ...
  fMat[kMTransX] = dx;
  fMat[kMTransY] = dy;

  fMat[kMScaleX] = fMat[kMScaleY] = fMat[kMPersp2] = 1;
  fMat[kMSkewX]  = fMat[kMSkewY] =
  fMat[kMPersp0] = fMat[kMPersp1] = 0;
  ...
}

其中fMat为一个长度为9的一位数组,这个平移变换中并不是直接在当前的变换矩阵上进行操作,而是在一个临时变量上赋值操作。

在这个类中还提供了concat融合方法:

void SkMatrix::setConcat(const SkMatrix& a, const SkMatrix& b) {
  ...
  SkMatrix tmp;
  ...
  tmp.fMat[kMScaleX] = muladdmul(a.fMat[kMScaleX],b.fMat[kMScaleX],a.fMat[kMSkewX],b.fMat[kMSkewY]);
  tmp.fMat[kMSkewX]  = muladdmul(a.fMat[kMScaleX],b.fMat[kMSkewX],a.fMat[kMSkewX],b.fMat[kMScaleY]);
  tmp.fMat[kMTransX] = muladdmul(a.fMat[kMScaleX],b.fMat[kMTransX],a.fMat[kMSkewX],b.fMat[kMTransY]) + a.fMat[kMTransX];

  tmp.fMat[kMSkewY]  = muladdmul(a.fMat[kMSkewY],b.fMat[kMScaleX],a.fMat[kMScaleY],b.fMat[kMSkewY]);
  tmp.fMat[kMScaleY] = muladdmul(a.fMat[kMSkewY],b.fMat[kMSkewX],a.fMat[kMScaleY],b.fMat[kMScaleY]);
  tmp.fMat[kMTransY] = muladdmul(a.fMat[kMSkewY],b.fMat[kMTransX],a.fMat[kMScaleY],b.fMat[kMTransY]) + a.fMat[kMTransY];

  tmp.fMat[kMPersp0] = 0;
  tmp.fMat[kMPersp1] = 0;
  tmp.fMat[kMPersp2] = 1;
  ...
  *this = tmp;
}

可以看到在该方法中进行了变换矩阵的乘法运算,计算出融合后的矩阵。也许是由于跨平台和一些历史原因,Skia所提供的transform API与命名方式与blink中的对应部分差异较大,在这种类似的模块中并没有统一的风格。


到这里为止canvas之旅算是告一段落了,应该对canvas的使用及它的内涵有个大致的了解了,回见!


笔者水平有限,若有纰漏之处,望在回复评论中指出。

参考