状态管理 canvas 是根据状态来绘图的
状态:
描边/填充样式:strokeStyle, fillStyle, globalAlpha
线的样式:lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset
阴影:shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor,
字体样式:font, textAlign, textBaseline, direction
平滑质量:imageSmoothingEnabled
合成属性:globalCompositeOperation
当前变形
当前裁剪路径
Canvas 维护了一个状态栈 :
save()
将当前状态压入栈中。
restore()
弹出栈顶状态,恢复之前的状态。
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 ctx.fillStyle = 'red' ctx.strokeStyle = 'blue' ctx.lineWidth = 6 ctx.save () ctx.beginPath () ctx.rect (50 , 50 , 100 , 50 ) ctx.fill () ctx.stroke () ctx.fillStyle = 'green' ctx.strokeStyle = 'red' ctx.lineWidth = 10 ctx.beginPath () ctx.rect (200 , 50 , 100 , 50 ) ctx.fill () ctx.stroke () ctx.restore () ctx.beginPath () ctx.rect (350 , 50 , 100 , 50 ) ctx.fill () ctx.stroke ()
变形 变形本质是对坐标系 的变换,可以实现旋转、缩放、平移等效果。
变形效果只影响后续的绘制,不会影响之前的绘制。
平移 translate translate(x, y)
移动画布的原点到原来的 (x, y)
,即整个坐标系平移了 (x, y)
。
图形会根据新的原点重新绘制。视觉上看,图形在原来的位置上移动了 (x, y)
。
旋转 rotate rotate(angle)
以原点为中心旋转画布,angle
旋转的弧度 ,正值顺时针。
缩放 scale scale(x, y)
缩放画布,x
为水平缩放比例,y
为垂直缩放比例。
如果是负数,镜像反转。
transform(a, b, c, d, e, f)
变换矩阵,可多次调用叠加变换效果。setTransform(a, b, c, d, e, f)
重新设置(覆盖)当前的变形效果,包括 translate、rotate 等。resetTransform()
重置变换矩阵为单位矩阵。等同于 setTransform(1, 0, 0, 1, 0, 0)
。
变换矩阵的描述 1 2 3 4 5 6 [ 𝑎 𝑐 𝑒 𝑏 𝑑 𝑓 0 0 1 ] [ x' ] [ a c e ] [ x ] [a * x + c * y + e] [ y' ] = [ b d f ] * [ y ] = [b * x + d * y + f] [ 1 ] [ 0 0 1 ] [ 1 ] [ 1 ]
参数:
a (m11)
水平缩放,默认 1。
b (m12)
竖直错切,默认 0。
c (m21)
水平错切,默认 0。
d (m22)
垂直缩放,默认 1。
e (dx)
水平移动,默认 0。
f (dy)
垂直移动,默认 0。
常见效果 1、移动: 控制 e、f 参数。
1 2 3 [ x' ] [ 1 0 e ] [ x ] [ x + e ] [ y' ] = [ 0 1 f ] * [ y ] = [ y + f ] [ 1 ] [ 0 0 1 ] [ 1 ] [ 1 ]
2、缩放: 控制 a、d 参数。
1 2 3 [ x' ] [ a 0 0 ] [ x ] [ a * x ] [ y' ] = [ 0 d 0 ] * [ y ] = [ d * y ] [ 1 ] [ 0 0 1 ] [ 1 ] [ 1 ]
3、错切: 控制 b、c 参数。 b 水平错切,即像素的y值不变,x的值随着y的增加,平移距离越来越多,变成斜线 c 竖直错切,即像素的x值不变,y的值随着x的增加,平移距离越来越多,变成斜线
1 2 3 [ x' ] [ 1 c 0 ] [ x ] [ x + c * y ] [ y' ] = [ b 1 0 ] * [ y ] = [ y + b * x ] [ 1 ] [ 0 0 1 ] [ 1 ] [ 1 ]
4、旋转: 需要控制 a、b、c、d 参数 如图所示,推导旋转的公式:
1 2 3 4 x0 = r * cos α y0 = r * sin α x = r * cos(α+θ) = r * cos α * cos θ - r * sin α * sin θ = x0 * cos θ - y0 * sin θ y = r * sin(α+θ) = r * sin α * cos θ + r * cos α * sin θ = y0 * cos θ + x0 * sin θ
最终推导的公式刚好可以使用变换矩阵表示
1 2 3 [ x' ] [ cos(θ) -sin(θ) 0 ] [ x ] [ cos(θ) * x - sin(θ) * y ] [ y' ] = [ sin(θ) cos(θ) 0 ] * [ y ] = [ sin(θ) * x + cos(θ) * y ] [ 1 ] [ 0 0 1 ] [ 1 ] [ 1 ]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function rotate (angle ) { angle = (angle * Math .PI ) / 180 ; const s = Math .sin (angle); const c = Math .cos (angle); ctx.transform (c, s, -s, c, 0 , 0 ); } ctx.translate (200 , 200 ); for (let i = 0 ; i <= 18 ; i++) { ctx.save (); rotate (i * 10 ); ctx.globalAlpha = 1 / 18 * i ctx.fillRect (0 , 0 , 100 , 100 ); ctx.strokeRect (0 , 0 , 100 , 100 ); ctx.restore (); }
合成 globalCompositeOperation
设置在绘制新图形时应用的合成操作的类型。
即绘制新图形时,如何与画布上已有的图形 进行叠加。详见文档
展示所有合成类型:
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 const blendModes = [ "source-over" , "source-in" , "source-out" , "source-atop" , "destination-over" , "destination-in" , "destination-out" , "destination-atop" , "lighter" , "copy" , "xor" , "multiply" , "screen" , "overlay" , "darken" , "lighten" , "color-dodge" , "color-burn" , "hard-light" , "soft-light" , "difference" , "exclusion" , "hue" , "saturation" , "color" , "luminosity" , ]; blendModes.forEach ((blendMode, i ) => { const box = document .createElement ("div" ); const canvas = document .createElement ("canvas" ); canvas.width = 250 ; canvas.height = 250 ; const ctx = canvas.getContext ("2d" ); ctx.fillStyle = "rgba(255, 0, 0, 1)" ; ctx.fillRect (0 , 0 , 200 , 150 ); ctx.globalCompositeOperation = blendMode; ctx.fillStyle = "rgba(0, 255, 0, 1)" ; ctx.fillRect (0 , 50 , 100 , 200 ); ctx.fillStyle = "rgba(0, 0, 255, 1)" ; ctx.fillRect (50 , 100 , 100 , 100 ); const p = document .createElement ("p" ); p.textContent = i + 1 + "、" + blendMode; box.appendChild (p); box.appendChild (canvas); document .body .appendChild (box); });
类型 下面每四个一组,介绍不同类型的合成:
source-over
默认 。新图形绘制在原有图形上。
source-in
仅绘制新图形与目标画布重叠的地方,其它都是透明的。
source-out
在不与现有画布内容重叠的地方绘制新图形。
source-atop
新图形绘制在原有图形上,但只在与现有画布内容重叠的地方绘制新图形。
destination-over
新图形绘制在原有图形下方。
destination-in
仅保留现有画布内容和新形状重叠的部分。其他的都是透明的。
destination-out
仅保留现有画布内容和新形状不重叠的部分。
destination-atop
仅保留现有画布内容和新形状重叠的部分。新形状是在现有画布内容的后面绘制的。
lighter
重叠部分颜色值相加。
copy
只显示新图形。
xor
重叠处变为透明,其他地方正常绘制。
multiply
重叠部分颜色值相乘。更黑暗。
screen
重叠部分像素被倒转、相乘、再倒转,结果更亮(与 multiply 相反)。
overlay
multiply 和 screen 的结合。原本暗的地方更暗,原本亮的地方更亮。
darken
保留两个图层中最暗的像素。
lighten
保留两个图层中最亮的像素。
color-dodge
将底层除以顶层的反置。
color-burn
将反置的底层除以顶层,然后将结果反过来。
hard-light
类似于 overlay,multiply 和 screen 的结合——但上下图层互换了。
soft-light
柔和版本的 hard-light。不会导致纯黑或纯白。
difference
从顶层减去底层(或反之亦然),始终得到正值。
exclusion
与 difference 类似,但对比度较低。
hue
保留底层的亮度(luma)和色度(chroma),同时采用顶层的色调(hue)。
saturation
保留底层的亮度和色调,同时采用顶层的色度。
color
保留了底层的亮度,同时采用了顶层的色调和色度。
luminosity
保持底层的色调和色度,同时采用顶层的亮度。
前面几个还好理解,后面的,用到再说吧,看不懂。
裁剪 clip clip()
将当前创建的路径作为剪切路径。fillRule
为填充规则 。
1 2 3 void ctx.clip ();void ctx.clip (fillRule);void ctx.clip (path, fillRule);
剪裁只对后续的绘制生效。每次裁剪前应该 save()
保存状态,以便后续恢复。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ctx.fillRect (0 , 0 , 100 , 100 ); ctx.beginPath (); ctx.moveTo (100 , 100 ); ctx.lineTo (150 , 150 ); ctx.lineTo (150 , 100 ); ctx.closePath (); ctx.save (); ctx.clip (); ctx.stroke (); ctx.fillRect (0 , 0 , 130 , 130 ); ctx.restore (); ctx.fillRect (150 , 0 , 100 , 100 );
填充规则 fillRule
填充规则,一种算法,决定点是在路径内还是在路径外,即某一区域是否被填充。
nonzero 非零环绕规则,默认 。
evenodd 奇偶环绕规则。
两个规则的差异体现在交叉点 的处理上。
fill、clip、isPointinPath 等方法可以传入 fillRule,以指定填充规则。
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 28 29 30 31 32 33 34 35 36 37 38 const t1 = new Path2D ();t1.moveTo (100 , 100 ); t1.lineTo (220 , 120 ); t1.lineTo (160 , 220 ); t1.closePath (); const t2 = new Path2D ();t2.moveTo (100 , 100 ); t2.lineTo (160 , 80 ); t2.lineTo (220 , 220 ); t2.closePath (); const path = new Path2D ();path.addPath (t1); path.addPath (t2); ctx.font = "20px 黑体" ; ctx.fillText ("分别绘制三角形" , 100 , 50 ); ctx.fillStyle = "red" ; ctx.fill (t1); ctx.fillStyle = "blue" ; ctx.fill (t2); ctx.fillStyle = "black" ; ctx.translate (200 , 0 ); ctx.fillText ("nonzero" , 120 , 50 ); ctx.fill (path, "nonzero" ); ctx.translate (200 , 0 ); ctx.fillText ("evenodd" , 120 , 50 ); ctx.fill (path, "evenodd" )
两个三角形路径均是顺时针。
nonzero nonzero 非零环绕规则,是默认的填充规则。顺+1逆-1,非0填。
规则: 选取任一区域内一点,发射一条无限长的射线,起始值为0,射线会和路径相交,如果路径方向和射线方向形成的是顺时针 方向则+1,如果是逆时针 方向则-1,最后如果数值为0 ,则是路径的外部,不填充,如果非0 ,则是路径的内部,填充。
evenodd evenodd 奇偶环绕规则。奇填偶不填。
规则: 起始值为0,射线会和路径相交,每交叉一条路径,计数+1,最后看总计算数值,如果是奇数 ,则是路径内部,如果是偶数 ,则是是路径外部。
控制像素 getImageData()
获取画布上指定矩形的像素数据。putImageData()
将 ImageData 数据(图片像素数据)绘制到画布上。createImageData()
创建一个新的、空白的 ImageData 对象。
获取像素数据 getImageData(x, y, width, height)
获取画布上指定矩形的像素数据。
返回 ImageData
对象,包含指定矩形的像素数据、宽度和高度。
1 2 3 4 5 6 7 const img = new Image ();img.src = "1.png" ; img.onload = () => { ctx.drawImage (img, 0 , 0 ); const imgData = ctx.getImageData (0 , 0 , img.width , img.height ); console .log (imgData); }
1 2 3 4 5 ImageData {data : Uint8ClampedArray (913936 ), width : 478 , height : 478 , colorSpace : 'srgb' } data : Uint8ClampedArray (913936 ) [12 , 83 , 161 , 255 , 12 , 83 , 161 , …] colorSpace : "srgb" height : 478 width : 478
data 是一个二维数组,每个元素的取值范围是 0 - 255 的整数,每四个元素 表示一个像素的 RGBA 值。
1 2 data : [[r1, g1, b1, a1, r2, g2, b2, a2, ......],...]
设置像素数据 putImageData()
将 ImageData 数据(图片像素数据)绘制到画布上。
1 2 3 void ctx.putImageData (imagedata, dx, dy);void ctx.putImageData (imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);
修改图像透明度案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const img = new Image ();img.src = "1.png" ; img.onload = () => { ctx.drawImage (img, 0 , 0 ); const imgData = ctx.getImageData (0 , 0 , img.width , img.height ); changeAlpha (imgData, 0.5 ); ctx.clearRect (0 , 0 , canvas.width , canvas.height ); ctx.putImageData (imgData, 0 , 0 ); } function changeAlpha (imgData, alpha ) { const data = imgData.data ; for (let i = 0 ; i < data.length ; i += 4 ) { data[i + 3 ] = data[i + 3 ] * alpha; } }
知道像素信息,还可以做滤镜效果,这需要不同的算法,后续再说。
动画 canvas 动画的本质是擦掉重画 和几何变换 。
requestAnimationFrame()
是做动画的核心 API。当系统准备好了重绘条件的时候,才调用绘制动画帧。一般每秒钟回调函数执行 60 次。
下面的案例涵盖了 canvas 动画的基本概念,如速度、边界、重复绘制。
自由落体小球 1、绘制小球: 首先我们需要一个小球,它具有 draw 方法,能把自己画出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Ball { constructor (ctx, x, y, radius, color ) { this .ctx = ctx; this .x = x; this .y = y; this .radius = radius; this .color = color; } draw ( ) { ctx.beginPath (); ctx.arc (this .x , this .y , this .radius , 0 , Math .PI * 2 ); ctx.fillStyle = this .color ; ctx.fill (); } } const ball = new Ball (ctx, 50 , 50 , 20 , "blue" );ball.draw ();
2、速率: 传入两个方向的速度,并添加一个 move 方法,使小球动起来。
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 28 class Ball { constructor (ctx, x, y, radius, color, vx, vy ) { this .ctx = ctx; this .x = x; this .y = y; this .radius = radius; this .color = color; this .vx = vx; this .vy = vy; } draw ( ) { ctx.clearRect (0 , 0 , canvas.width , canvas.height ); ctx.beginPath (); ctx.arc (this .x , this .y , this .radius , 0 , Math .PI * 2 ); ctx.fillStyle = this .color ; ctx.fill (); } move ( ) { this .x += this .vx ; this .y += this .vy ; this .draw (); window .requestAnimationFrame (() => this .move ()); } } const ball = new Ball (ctx, 50 , 50 , 20 , "blue" , 5 , 5 );ball.draw (); ball.move ();
3、边界: 当小球触碰到边界时,需要反弹,即将某个方向的速度取反。
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 28 29 30 31 32 33 34 35 class Ball { constructor (ctx, x, y, radius, color, vx, vy ) { this .ctx = ctx; this .x = x; this .y = y; this .radius = radius; this .color = color; this .vx = vx; this .vy = vy; } draw ( ) { ctx.clearRect (0 , 0 , canvas.width , canvas.height ); ctx.beginPath (); ctx.arc (this .x , this .y , this .radius , 0 , Math .PI * 2 ); ctx.fillStyle = this .color ; ctx.fill (); } move ( ) { this .x += this .vx ; this .y += this .vy ; if (this .x + this .radius > canvas.width || this .x - this .radius < 0 ) { this .vx = -this .vx ; } if (this .y + this .radius > canvas.height || this .y - this .radius < 0 ) { this .vy = -this .vy ; } this .draw (); window .requestAnimationFrame (() => this .move ()); } } const ball = new Ball (ctx, 50 , 50 , 20 , "blue" , 5 , 5 );ball.move ();
4、加速度: 为了让小球自由落体,还需要引入y轴加速度,每次移动时 vy 乘以 0.99,让速度小幅度减小,模拟了速度的损失,再加上 0.25,模拟重力,实现下落时,速度不断增大,而上升时不断减小。
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 class Ball { constructor (ctx, x, y, radius, color, vx, vy ) { this .ctx = ctx; this .x = x; this .y = y; this .radius = radius; this .color = color; this .vx = vx; this .vy = vy; } draw ( ) { ctx.clearRect (0 , 0 , canvas.width , canvas.height ); ctx.beginPath (); ctx.arc (this .x , this .y , this .radius , 0 , Math .PI * 2 ); ctx.fillStyle = this .color ; ctx.fill (); } move ( ) { if (Math .abs (ball.vy ) < 0.1 && ball.y === canvas.height - ball.radius ) { return ; } ball.vy *= 0.99 ; ball.vy += 0.25 ; this .x += this .vx ; this .y += this .vy ; if (this .x + this .radius > canvas.width ) { this .vx = -this .vx ; this .x = canvas.width - this .radius ; } if (this .x - this .radius < 0 ) { this .vx = -this .vx ; this .x = this .radius ; } if (this .y + this .radius > canvas.height ) { this .vy = -this .vy ; this .y = canvas.height - this .radius ; } if (this .y - this .radius < 0 ) { this .vy = -this .vy ; this .y = this .radius ; } this .draw (); window .requestAnimationFrame (() => this .move ()); } } const ball = new Ball (ctx, 50 , 50 , 20 , "blue" , 5 , 5 );ball.move ();
5、拖尾效果: 一个取巧的办法是不再清空画布,而是使用带透明度的矩形覆盖。
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 class Ball { constructor (ctx, x, y, radius, color, vx, vy ) { this .ctx = ctx; this .x = x; this .y = y; this .radius = radius; this .color = color; this .vx = vx; this .vy = vy; } draw ( ) { ctx.beginPath (); ctx.arc (this .x , this .y , this .radius , 0 , Math .PI * 2 ); ctx.fillStyle = this .color ; ctx.fill (); } move ( ) { if (Math .abs (ball.vy ) < 0.1 && ball.y === canvas.height - ball.radius ) { ctx.clearRect (0 , 0 , canvas.width , canvas.height ); this .draw (); return ; } ctx.fillStyle = "rgba(255, 255, 255, 0.5)" ; ctx.fillRect (0 , 0 , canvas.width , canvas.height ); ball.vy *= 0.99 ; ball.vy += 0.25 ; this .x += this .vx ; this .y += this .vy ; if (this .x + this .radius > canvas.width ) { this .vx = -this .vx ; this .x = canvas.width - this .radius ; } if (this .x - this .radius < 0 ) { this .vx = -this .vx ; this .x = this .radius ; } if (this .y + this .radius > canvas.height ) { this .vy = -this .vy ; this .y = canvas.height - this .radius ; } if (this .y - this .radius < 0 ) { this .vy = -this .vy ; this .y = this .radius ; } this .draw (); window .requestAnimationFrame (() => this .move ()); } } const ball = new Ball (ctx, 50 , 50 , 20 , "blue" , 5 , 5 );ball.move ();
优化问题 这里记录一些 canvas 绘图时的优化问题。
1px 问题 就像之前做的那样,绘制 1-10px 的线条。
可以发现 1px 和 2px 线条在宽度上没区别,但 1px 是灰色且模糊,还有奇数宽度的线条边缘也模糊。
这是 canvas 的栅格和绘制策略导致的: 栅格化的画布,其整数坐标位于两个像素之间 ,绘制时会从坐标位置开始向两侧扩散像素 ,当坐标位置和线条(局部图形)宽度不合理时(如整数坐标奇数宽度),就会有一个将要绘制的像素被扩散到两个实际像素中,各绘制 0.5px,但物理像素不可分割 ,这时会做抗锯齿 和近似处理 ,两个像素都显示,1px 变成了 2px、黑色变成了灰色且模糊。
既然有 0.5px 的扩散,那解决方法也很简单,在需要时 坐标位置偏移 0.5px 即可。
不建议直接使用 translate 偏移整个画布,可能会导致一个图形正常了,而另一个图形反而不正常。如下图,1px 正常了,但 2px 边缘反而模糊了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ctx.font = "20px 黑体" ; ctx.fillText ("未偏移" , 150 , 30 ); ctx.fillText ("1px" , 70 , 100 ); ctx.strokeRect (50 , 50 , 100 , 100 ); ctx.lineWidth = 2 ; ctx.fillText ("2px" , 220 , 100 ); ctx.strokeRect (200 , 50 , 100 , 100 ); ctx.translate (0.5 , 0.5 ); ctx.fillText ("偏移 0.5px" , 450 , 30 ); ctx.lineWidth = 1 ; ctx.fillText ("1px" , 370 , 100 ); ctx.strokeRect (350 , 50 , 100 , 100 ); ctx.lineWidth = 2 ; ctx.fillText ("2px" , 520 , 100 ); ctx.strokeRect (500 , 50 , 100 , 100 );
最佳实践: 将画布大小设置为元素大小的整数倍,这样等于将图形缩小了整数倍,再使用 scale
扩大坐标系同样倍数让图形显示预期大小,这样就能正常画出任意宽度的线条。副作用较小,是比较好的做法。
1 2 3 4 5 6 7 const width = 800 ;const height = 650 ;canvas.style .width = `${width} px` ; canvas.style .height = `${height} px` ; canvas.width = 2 * width; canvas.height = 2 * height; ctx.scale (2 , 2 )
物理意义的 1px 一个栅格对应一个逻辑像素,但在高清屏幕上,一个逻辑像素会对应多个物理像素,若需要画出真正的 1px,还需要根据设备像素比 进行缩放。
window.devicePixelRatio
返回当前显示设备的物理像素分辨率与 CSS 像素分辨率之比。
1 2 3 4 5 6 const width = 800 ;const height = 650 ;canvas.style .width = `${width} px` ; canvas.style .height = `${height} px` ; canvas.width = window .devicePixelRatio * width; canvas.height = window .devicePixelRatio * height;
清晰与模糊问题 除了 1px 问题会导致图像模糊,浏览器的放大倍率也会导致图像模糊。
window.devicePixelRatio
返回当前显示设备的物理像素分辨率与 CSS 像素分辨率之比。文档 。
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 28 29 30 31 function createDprInit ( ) { const width = canvas.width ; const height = canvas.height ; return () => { const dpr = window .devicePixelRatio || 1 ; canvas.style .width = width + "px" ; canvas.style .height = height + "px" ; canvas.width = width * dpr; canvas.height = height * dpr; ctx.scale (dpr, dpr); }; } const dprInit = createDprInit ();dprInit ();function draw ( ) { ctx.clearRect (0 , 0 , canvas.style .width , canvas.style .height ); ctx.fillStyle = "red" ; ctx.fillRect (0 , 0 , 100 , 100 ); ctx.beginPath (); ctx.rect (150 , 0 , 100 , 100 ); ctx.stroke (); } draw ();window .addEventListener ("resize" , () => { dprInit (); draw (); });
由于现代设备的高清屏幕,默认 devicePixelRatio
通常大于 1,所以这么做还能顺带解决 1px 问题。
模糊的根本原因: canvas 本质是一张绘制好的图片,所以和图片的模糊是同一个问题。
图片在拍摄完成后具有原始尺寸,对应于 canvas 就是画布尺寸,当图片被放入一个大于其原始尺寸的容器中时,图片会被放大,导致原始尺寸的一个像素实际被多个像素显示,这样就会导致图片模糊。