canvas随手记(持续)

  • start date: 2018-08-27 13:00:45

1.canvas的width/height属性

canvas的width/height属性定义了canvas元素的尺寸(渲染上下文的尺寸),默认为widht: 300px。height: 150px。

需要注意的是,canvas元素上的width和height属性不等同于canvas元素的css样式的属性。这是因为css属性中的宽高影响canvas在页面上呈现的大小,而HTML属性中的宽高则决定了canvas的坐标系。为了区分它们,我们称canvas的HTML属性宽高位画布宽高,css样式宽高位样式宽高。

在实际绘制的时候,如果我们不设置canvas元素的样式,那么canvas元素的画布宽高就会等于它的样式宽高的像素值。

为何这样处理呢?因为画布宽高决定了可视区域的坐标范围,所以canvas将画布宽高和样式宽高分开的做法能更方便地适配不同的显示设备。

2.canvas上下文的坐标系统

2D渲染上下文采用平面的笛卡尔坐标系统,左上角为圆点(0, 0),向右移动时,x坐标值会增加;向下移动时,y坐标值会增加。

coordinate.png

3.矩形绘制fillRect/strokeRect

fillRect绘制一个矩形并给它填充颜色;strokeRect绘制一个矩形并绘制边框。

4.角度单位

canvas的角度是以弧度为单位的,如360°->。计算公式

1
2
let degrees = 1;	// 1°
let radians = degrees * (Math.PI / 180); // 弧度

radians.png

5.画圆Canvas arc()函数

1
context.arc(x, y, r, sAngle, eAngle, counterclockwise);

参数

  • x:圆的中心的 x 坐标。
  • y:圆的中心的 y 坐标。
  • r:圆的半径。
  • sAngle:起始角,以弧度计。(弧的圆形的三点钟位置是 0 度)。
  • eAngle:结束角,以弧度计。
  • counterclockwise:可选。规定应该逆时针还是顺时针绘图。False = 顺时针,true = 逆时针。

counterclockwise虽然是可选的,但如果不传入这个参数,Firefox会抛出一个错误。

6.用于描述画布绘图状态的全部属性为:变换矩形、裁剪区域(clipping region)、globalAlpha、globalCompositeOperation、strokeStyle、fillStyle、lineWidth、lineCap、lineJoin、miterLimit、shadowOffsetX、shadowOffsetY、shadowBlur、shadowColor、font、textAlign、textBaseline。

画布的当前路径和当前位图并不属于状态。

7.变换矩形

所有变形方法(translate/scale/rotate)都会影响变换矩形。变换矩形就是一组数字,他们各自描述一个特定变形类型。矩阵分成多个列和行;在画布中,你使用的是一个3*3矩阵。

transformRect.png

一个新的2D渲染上下文将包含一个全新的变换矩阵,即单位矩阵(identity matrix)

identitymatrix.png

transform

canvas transform方法有6个参数,分别对应变换矩阵的每一个值:

1
context.transform(xScale, ySkew, xSkew, yScale, xTrans, yTrans);

setTransform

setTransform的作用是将矩阵重置为单位矩阵,然后按照6个参数执行变形。

8.合成属性globalCompositeOperation

  • ‘source-over’:默认值,表示绘制的图形(源)将画在现有画布(目标)之上;
  • ‘destination-over’:表示目标在源之上;
  • ‘source-atop’:表示源绘制在目标之上,但重叠区域上源是透明的目标不透明;
  • ‘destination-atop’:表示目标绘制在源之上,但在重叠区域上目标是透明的源不透明;
  • ‘source-in’:表示在源和目标重叠的区域只绘制源,但不重叠的部分变成透明的;
  • ‘destination-in’:表示在源和目标重叠的区域只绘制目标,但不重叠的部分变成透明的;
  • ‘source-out’:表示在与目标不重叠的区域绘制源,其他部分变成透明的;
  • ‘destination-out’:表示在与源不重叠的区域保留目标,其他部分变成透明的;
  • ‘lighter’:表示如果源与目标重叠,就将两者的颜色值相加(最大值为白色255)。
  • ‘copy’:表示只绘制源,覆盖目标。
  • ‘xor’:表示只绘制不重叠的源与目标区域。所有重叠的部分都变成透明的;

9.阴影和渐变属性

阴影

  • shadowBlur、shadowOffsetX、shadowOffsetY、shadowColor。

渐变方法

  • createLinearGradient、addColorStop

10.贝塞尔曲线

canvas中Bezier curve方法:quadraticCurveTo(二次)、bezierCurveTo(三次)。

这两种贝塞尔曲线都是通过控制点将一条直线变成曲线。二次贝塞尔曲线只有一个控制点,这意味着线条中只有一次弯曲;而三次贝塞尔曲线则有两个控制点,这意味着一条线中会有两次弯曲。

bezier.png

11.drawImage之前需要确认图像已经完全加载。


1
2
3
4
5
6
let _img = new Image();
_img.onload = function () {
ctx.drawImage(_img, 0, 0, 100, 100);
};

_img.src = 'http://blog.michealwayne.cn/test.png'

12.ImageData像素信息集

通过访问2D渲染上下文的各个像素,我们就能得到每一个像素的颜色和阿尔法值等信息。

在画布中访问像素的方法是getImageData()。

1
context.getImageData(x, y, width, height);

这个方法接受4个参数:要访问的像素区域原点坐标(x, y)、像素区域的宽度和高度。

返回的ImageData包含3个属性:

  • width:访问像素区域的宽度
  • height:访问像素区域的高度
  • data:包含所访问区域中全部像素信息的CanvasPixelArray

其中data属性是一个一维数组。数组中每个像素用4个整数值表示,范围从0至255,分半表示红、绿、蓝和阿尔法值(rgba)

imagedata.png

当然我们也可以处理或自定义实现像素数组,然后通过putImageData方法进行图片绘制或创建。

反转颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
let imagedata = context.getImageData(0, 0, canvas.width, canvas.height);
let pixels = imagedata.data;

let numPixels = pixels.length;
ctx.clearRect(0, 0, canvas.width, canvas.height);

for (let i = 0; i < numPixels; i++) {
pixels[i * 4] = 255 - pixels[i * 4]; // 红
pixels[i * 4 + 1] = 255 - pixels[i * 4 + 1]; // 绿
pixels[i * 4 + 2] = 255 - pixels[i * 4 + 2]; // 蓝
}

ctx.putImageData(imagedata, 0, 0);

灰度

1
2
3
4
5
6
for (let i = 0; i < numPixels; i++) {
var average = (pixels[i * 4] + pixels[i * 4 + 1] + pixels[i * 4 + 2]) / 3;
pixels[i * 4] = average; // 红
pixels[i * 4 + 1] = average; // 绿
pixels[i * 4 + 2] = average; // 蓝
}

像素化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let numTileRows = 20;
let numTileCols = 20;

let tileWidth = imagedata.width / numTileCols;
let tileHeight = imagedata.height / numTileRows;

for (let r = 0; r < numTileRows; r++) {
for (let c = 0; c < numTileCols; c++) {
let x = (c * tileWidth) + (tileWidth / 2);
let y = (r * tileHeight) + (tileHeight / 2);

let pos = Math.floor(y) * (imagedata.width * 4) + Math.floor(x) * 4;

ctx.fillStyle = `rgb(${pixels[pos]}, ${pixels[pos + 1]}, ${pixels[pos + 2]})`;
ctx.fillRect(x - tileWidth / 2, y - tileHeight / 2, tileWidth, tileHeight);
}
}

100100的 canvas 占多少内存?

1
2
// 比如我们取(1, 1)像素点的数据
context.getImageData(1, 1, 1, 1); // [200, 1, 2, 3]

可以看出其实像素信息使用 Uint8 来存储的,数组长度为 4, Uint8 占用内存为 1 个字节, 因此一共是 4 个字节,所以答案就是一个像素的 Canvas 占内存是4Byte。

所以 100 100 canvas 占的内存是 100 100 * 4 bytes = 40,000 bytes。

Uint8ClampedArray 描述了一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。

13.自制拾色器

1
2
3
4
5
6
7
8
9
canvas.onclick = e => {
let canvasOffset = canvas.offset();
let canvasX = Math.floor(e.pageX - canvasOffset.left);
let canvasY = Math.floor(e.pageY - canvasOffset.top);

let imagedata = context.getImageData(canvasX, canvasY, 1, 1);
let pixel = imagedata.data;
console.log(`rgba(${pixel[0]}, ${pixel[1]}, ${pixel[2]}, ${pixel[3]})`)
};

14.图像限制

如果图像与控制画布的js不在同一个域名,那么画布对于访问这个图像的像素级数据会有严格的限制。

15.动画循环

动画循环是创建动画效果的基础。它的三要素是:更新需要绘制的对象、清除画布、在画布上重新绘制对象。

animatecircle.png

在没有requestAnimationFrame的时候请使用33ms

因为动画在每秒钟需要的帧数通常介于25到30帧之间,1000/30≈33

16.canvas图片限制及启用CORS

在canvas画布中使用跨域图片,此时会污染画布,而且画布一旦被污染,你就无法读取其数据。例如你不能再使用画布的 toBlob(), toDataURL() 或 getImageData() 方法,调用它们会抛出安全错误。

这种机制可以避免未经许可拉取远程网站信息而导致的用户隐私泄露。

HTML 规范中图片有一个 crossorigin 属性,结合合适的 CORS 响应头,就可以实现在画布中使用跨域 元素的图像。

crossOrigin属性

在HTML5中,一些 HTML 元素提供了对 CORS 的支持, 例如 <img><video> 均有一个跨域属性 (crossOrigin property),它允许你配置元素获取数据的 CORS 请求。 这些属性是枚举的,并具有以下可能的值:

  • anonymous:对此元素的CORS请求将不设置凭据标志。
  • use-credentials:对此元素的CORS请求将设置凭证标志; 这意味着请求将提供凭据。

启用CORS

CSRF(Cross-site request forgery)跨站请求伪造,也被称为“One Click Attack”或者Session Riding,通常缩写为CSRF或者XSRF。

你必须有一个可以对图片响应正确 Access-Control-Allow-Origin 响应头的服务器。如

1
2
3
4
5
6
7
8
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
<FilesMatch "\.(cur|gif|ico|jpe?g|png|svgz?|webp)$">
SetEnvIf Origin ":" IS_CORS
Header set Access-Control-Allow-Origin "*" env=IS_CORS
</FilesMatch>
</IfModule>
</IfModule>

配置完毕后,你就可以将这些图片保存到 DOM 存储 中了,就像这些图片在你自己域名之下一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var img = new Image,
canvas = document.createElement("canvas"),
ctx = canvas.getContext("2d"),
src = "http://example.com/image"; // insert image url here

img.crossOrigin = "Anonymous";

img.onload = function() {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage( img, 0, 0 );
localStorage.setItem( "savedImageData", canvas.toDataURL("image/png") );
}
img.src = src;
// make sure the load event fires for cached images too
if ( img.complete || img.complete === undefined ) {
img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==";
img.src = src;
}

17.移动端适配

视网膜屏幕模糊的解决方法

比如我们要画一个矩形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<style>
#canvas {
width: 400px;
height: 300px;
}
</style>
<canvas id="canvas" width="400" height="300"></canvas>
<script>
const canvas = document.getElementById('canvas'),
ctx = canvas.getContext('2d');
ctx.strokeStyle = '#f00';
ctx.lineWdith = 1;
ctx.strokeRect(10, 10, 100, 100);
</script>

iphone效果:
p-mobile1.jpg

发现在手机上显示至少有两个像素宽。这是因为现代移动设备通常都具备视网膜显示屏,对应物理像素和实际逻辑像素点的像素比。比如iphone6,它的宽度逻辑像素是375px,而实际物理像素分辨率是750px,因此原先需要绘制一个像素宽度的宽,现在会用两个像素来绘制。

再来看canvas的css宽高和上下文宽高。

css宽高比较明显,即css中的width和height,分别是400px和300px。而实际canvas标签中的width和height属性是canvas绘图上下文(绘图区域)的宽高,当不设置canvas的css宽高时,canvas会将width和height的值作为css宽高。

因此在这个canvas节点上,元素对应的实际逻辑像素其实是width 800px和height 600px,然而它的绘图上下文只有width 400px和height 600px,产生了变形,因此它的显示宽度会粗很多,甚至模糊。

canvas绘图时,会从两个物理像素点中间位置开始绘制并向两边扩散0.5个物理像素。当设备像素比为1时,一个1px的线条实际上占据了两个物理像素(每个像素实际上只占一半),由于不存在0.5个像素,所以这两个像素本来不应该被绘制的部分也被绘制了,于是1物理像素的线条变成了2物理像素,视觉上就造成了模糊。

为此浏览器提供了全局属性devicePixelRatio,即设备屏幕像素比。如

1
console.log(window.devicePixelRatio);	// iphone6: 2

由此我们可以想出解决方案1:根据devicePixelRatio来动态生成canvas的绘图上下文宽高即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<style>
#canvas {
width: 400px;
height: 300px;
}
</style>
<canvas id="canvas"></canvas>
<script>
const canvas = document.getElementById('canvas');
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
let width = canvas.width,
height = canvas.height;

canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;

let ctx = canvas.getContext('2d')
ctx.scale(devicePixelRatio, devicePixelRatio);

canvas.style.width = width + 'px';
canvas.style.height = height + 'px';

ctx.strokeStyle = '#f00';
ctx.lineWdith = '1px';
ctx.strokeRect(10, 10, 100, 100);
</script>

我们可以封装成方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @function retinaScale
* @description 适配移动端机型,for web
* @param {canvas object} canvas
* @param {object} ctx canvas context
* @return {number} retina pixel ratio
*/
export function retinaScale (canvas, ctx) {
const pixelRatio = window.devicePixelRatio || 1;

if (pixelRatio === 1) return false;
let width = canvas.width,
height = canvas.height;

canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
ctx.scale(pixelRatio, pixelRatio);

canvas.style.width = width + 'px';
canvas.style.height = height + 'px';

return pixelRatio;
}

1px出现模糊的解决方法

先上解决方法

1
2
cxt.moveTo(x + 0.3,y + 0.3);	// 小数
cxt.lineTo(x + 0.3, y + 0.3)

这样确实可以让线重新变回清晰的状态
因为把绘制线条封装成一个函数,绘制多条线,多次调用该函数,出现有的线是1px,有的线是2px。

原因

canvas每条线都有一条无限细的中线,线由中线两个伸展。例如:
当起点是2px时,中线在2px的地方,向左延伸0.5px,向右延伸0.5px,所以这条线应该在1.5px到2.5px的地方,但实际上计算机的最小像素是1px,所以canvas会取一个折中的方法,分别再向左右延伸0.5px,颜色变成原来的一半,所以实际效果看起来变成了2px模糊的线条。

1px.png

18.性能优化

造成卡顿的原因

我们在一帧中需要完成计算渲染,这两个阶段都可以引起卡顿。

    1. 由计算过程产生的卡顿,一般是一次性发生的。计算通常用于实现业务逻辑、坐标计算、对象状态等。
    1. 由渲染过程产生的卡顿,一般称之为掉帧,它是周期性发生的。渲染过程本质上也有两个过程:
      • 1) js调用DOM API 及 Canvas API 进行渲染。
      • 2) GPU渲染进程把渲染后的结果呈现在屏幕上的过程。

我们以60FPS帧速率为例,每帧16ms,那么在这短短的16ms中你需要完成 :上面的步骤1与2.1。如果不能完成,就会导致浏览器js主线程的阻塞。
但是渲染的开支比计算大几个数量级,那么其实我们的目标就缩小了,除非你的算法非常耗时,那么主要优化渲染性能。

优化手段

  • 1.减少API操作:比如在动画流程中,我们善用save/restore可以有效避免重复赋值,但也要小心的是不要滥用s/r,他们也是有开销的。在开发中,为了减少渲染开销,尽量减少对ctx的赋值操作,可以考虑把功能实现转嫁给计算解决。
  • 2.优化渲染呈现:减少渲染开销主要是:离屏双缓冲分层,这三种方法也经常混淆。
    • 1) 离屏:离屏本质上是我们在内存里又开了一块canvas画布,但是又不把他插入到DOM中去,因为在内存中进行渲染和绘制略快于我们视野内的canvas。
    • 2) 双缓冲:获取到页面中的Canvas对象之后,创建了一个与页面Canvas同样大小的Canvas对象。绘图时先将图像绘制到缓冲Canvas中,等到每一桢的图像绘制完全后在把整个缓冲Canvas绘制到页面Canvas中。前景、中景、远景的视差通过控制移动速度来实现。整个动画的绘制部分只使用了Context对象的drawImage()方法。但是如果你实际去使用双缓冲,把画好的整个离屏Canvas绘制到视野Canvas,你会发现性能反而比之前差得多。因为各大浏览器厂商在实现canvas元素的时候,已经使用了双缓冲机制, 开发者再实现一遍反而会导致性能的下降,而且这没有任何的好处。
    • 3) 分层:像PS一样,不同图层负责不同功能,背景层可以只渲染不擦除,来完成减少开销。
  • 3.计算优化:把 生成->计算->渲染 分开,就像玩游戏最开始要有loading一样,我们需要把生产对象的过程从渲染周期中抽离出来,减少动画过程中的开销。
    • 1) web worker:它可以为你开一条系统进程来处理你的计算,从而根本上的解决计算开销。除此之外还可以尝试一下WebAssembly
    • 2) 分步处理:把计算步骤分割到各个raf中,每次完成一部分,来达到消除开销的目的。
1
2
3
4
5
6
7
8
9
let canvas = document.getElementById('canvas');
let context = canvas.getContext('2d');
let bufferCanvas = document.createElement('canvas');
bufferCanvas.width = canvas.width;
bufferCanvas.height = canvas.height;

let bufferContext = bufferCanvas.getContext('2d');

context.drawImage(bufferCanvas,0,0,canvas.widgth,canvas.height,0,0,canvas.width,canvas.height);

*浏览器双缓冲机制

浏览器在处理对Canvas 2D和WebGL的加速时,他们的绘制会直接使用GPU,所以他们一般会有一个[GL FBO](FrameBufferObject)作为自己的缓存(作用上类似离屏),而我们绘制的内容实际上是在FBO上绘制的,你看到的实际上是这个缓冲区的复制。

那么,该怎么用双缓冲呢?
双缓冲的正确兹识应该是配合drawImage将离屏的某个图块复制到视野canvas,这是可以提高绘制效率的。