Canvas绘图
Canvas的意义
随着前端的不断发展,页面特效越来越炫酷,W3C组织也不断退出新的CSS特性:例如各种渐变,瀑布流布局,各种阴影,但是随着需求越来越花哨,W3C表示:我去你妈的,你自己画去吧。
于是浏览器就暴露出了Canvas API让用户自己实现各种炫酷的效果。
学习过浏览器的渲染过程,我们可以知道其实浏览器的窗口本身就是一个画布,他根据DOM和CSSOM不断得生成绘制指令来重绘页面。
Canvas其实就是浏览器将绘制指令封装成API给用户进行调用,这也是为什么Canvas的性能要比直接操作DOM的性能更高的原因。
这就是Canvas存在的意义,可以自定义炫酷的效果,可以有比DOM操作更好的性能。
图形绘制API
坐标系
讲绘图之前先讲解一下坐标系。
Canvas的坐标系与浏览器的坐标系相同,都是以左上角为原点,向右为x轴,向下为y轴。
上下文
想用Canvas进行绘制,首先需要拿到Canvas的上下文,它就相当于一个画笔,可以发出各种绘制指令。
<canvas id="canvas" />
const canvas = document.querySelector("#canvas");
const ctx = canvas.getContext('2d');
这个 ctx
就是canvas的上下文,这个上下文共有4种类型:
- 2d: 绘制2d图形
- bitmaprenderer: 绘制位图
- webgl: 绘制3d图形,只在实现WebGL1的浏览器种可用
- webgl2: 绘制3d图形,只在实现WebGL2的浏览器种可用
这里只学习最基本的2d图形绘制。
绘制指令
canvas支持4种图形的绘制
- 直线和矩形
- 曲线和椭圆
- 文本
- 图片
不管绘制哪种图形,都是按照下面的步骤发出绘图指令,和AI还有PS的逻辑很相似:
- 开启路径
- 设置着色(描边和填充)
- 设置路径
- 闭合路径
- 绘制
ctx.beginPath(); // 开启路径
ctx.strokeStyle = '#aaaaaa'; // 设置描边颜色
ctx.fillStyle = '#111111'; // 设置填充颜色
// ... 若干绘图指令
ctx.closePath(); // 闭合路径,可选的,也可以不闭合,绘制一条开放的路径,如果使用填充指令来绘制的话,路径会自动闭合。
ctx.fill(); // 填充
ctx.stroke(); // 描边
直线绘制
绘制一个三角形演示一下
ctx.moveTo(100, 100); // 移动画笔到100, 100的位置
ctx.lineTo(100, 200); // 从当前位置向100, 200画一条路径
ctx.lineTo(200, 200);
ctx.closePath(); // 闭合路径形成完整的三角形
ctx.stroke(); // 根据路径描边
矩形绘制
矩形的绘制有三种API
- 绘制矩形的路径
- 绘制带描边的矩形
- 绘制带填充的矩形
三种API不做演示,剩余的圆形,曲线等API也不做演示,可自行查阅文档。
Canvas 教程 - Web API 接口参考 | MDN (mozilla.org)
案例
利用图形API可以制作一个粒子连线效果。
首先定义一个类,用于表示粒子点:
class Point {
constructor() {
this.r = 8;
this.x = getRandomInt(canvas.width - this.r, this.r);
this.y = getRandomInt(canvas.height - this.r, this.r);
}
draw() {
ctx.beginPath();
ctx.fillStyle = 'rgba(11, 11, 11, 255)';
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
}
}
然后再定义一个类,用于表示粒子图
class Graph {
constructor(count = 30, maxDis = 200) {
this.maxDis = maxDis;
this.points = new Array(30).fill(null).map(item => new Point());
}
draw() {
for(let i = 0; i < this.points.length; i++) {
const p = this.points[i];
p.draw();
for(let j = i+1; j < this.points.length; j++) {
const p2 = this.points[j];
const d = Math.sqrt((p.x - p2.x) ** 2 + (p.y - p2.y) ** 2);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y);
ctx.closePath();
ctx.strokeStyle = `rgba(11, 11, 11, ${255 * (this.maxDis - d) / this.maxDis})`;
ctx.stroke();
}
}
}
}
function getRandomInt(max, min = 0) {
return Math.floor( Math.random() * (max - min + 1) ) + min;
}
加上最后的代码,就可以实现一副一直抽搐的粒子图。
function main() {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
new Graph().draw();
requestAnimationFrame(main);
}
main();
清晰度问题
随着页面的放大,Canvas绘制的图形会变得模糊。
要解决这个问题,首先要知道,其实Canvas绘制的结果,就是一张图片。
图像有两种尺寸,一种是自然尺寸,一种是样式尺寸。
自然尺寸就是图像原本的大小,样式尺寸是通过css或者JS设置的尺寸。
图片随着放大会变得模糊,是因为它的样式尺寸大于他的自然尺寸,使得原来的一个像素点需要两个或者更多像素点来显示,但是图片本身并没有包含这么多信息,因此就会变得模糊。
但是Canvas的尺寸是可以自己设置的,绘制的图形大小也是可以自己设置的,也就是自然尺寸是可以更改的。所以利用这个特性,我们只要满足 自然尺寸 = 样式尺寸 * 缩放倍率
,就可以做到Canvas不会模糊。
使用下面的代码可以在页面上绘制一个圆
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
function init() {
ctx.width = 200;
ctx.height = 200;
}
init();
ctx.beginPath();
const r = 80;
ctx.arc(canvas.width / 2, canvas.height / 2, r, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 10; // 设置canvas线宽
ctx.stroke();
通过这一段代码可以绘制初始化一个200 * 200的canvas页面,并在canvas的中央绘制了一个半径为80的圆。
现在的代码,当浏览器的缩放倍率变化时,圆会变得模糊。
我们可以修改代码,使得 自然尺寸 = 样式尺寸 * 缩放倍率
这个等式永远成立,只要这个等式成立,图像一定是清晰的。
缩放倍率变化后,可以修改Canvas的自然尺寸使得图像依然清晰。
// 修改原始尺寸
function init() {
// devicePixelRatio 可以获取当前浏览器的放大倍率
canvas.width = 200 * devicePixelRatio; // 样式尺寸 * 缩放倍率 = 原始尺寸
canvas.height = 200 * devicePixelRatio;
}
如果想让canvas上绘制的内容也跟着放大和缩小,只需要让绘制指令中的数值也跟着放大和缩小。
ctx.beginPath();
const r = 80 * devicePixelRatio;
ctx.arc(canvas.width / 2, canvas.height / 2, r, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 10 * devicePixelRatio; // 设置canvas线宽
ctx.stroke();
动画
使用Canvas来做动画,原理很简单,就是每间隔一段时间重新绘制Canvas。
因为JS的定时器不准确,计时间隔小的时候可能会出现掉帧的现象,所以使用 requestAnimationFrame()
更合适。
有点类似递归,这样写就可以让浏览器每次刷新时都重新绘制Canvas。
function draw() {
// 若干绘制指令
reuqestAnimationFrame(draw);
}
文字绘制–代码雨效果
canvas绘制文字的方式很简单。只有下面的几个API
// 设置字体和对齐方式
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.font = fontSize + 'px Verdana';
// 填充或者描边文字
ctx.fillText(text, x, y);
ctx.strokeText(text, x, y);
有了这几个API,我们可以制作一个代码雨效果,下面是实现的代码。
// 文字绘制函数
function draw() {
// 逐行绘制文字,绘制每行文字之前先绘制一层浅浅的遮罩来降低原有文字的不透明度
// 每次绘制文字之前
ctx.fillStyle = '#ffffff20';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'green';
for(let i = 0; i < col; i++) {
// 随机获取一个字符,并绘制,每列绘制一个
// rows数组存储的是要绘制的文字的y坐标,有多少列就有多少项
ctx.fillText(getRandomChar(), i * fontSize, rows[i]);
// 当文字的y坐标同时满足两项时才将文字绘制的y坐标清0
if(rows[i] > canvas.height && Math.random() > 0.99) {
rows[i] = 0;
} else {
rows[i] += fontSize;
}
}
}
// 使用定时器频繁绘制,这里不使用requestAnimationFrame的原因是帧率太高。
setInterval(() => {
draw()
}, 50);
draw();
// 辅助函数,获取一个随机字符
function getRandomChar() {
const chars = '0123456789qwertyuiopasdfghjklzxcvbnm'.split('');
const index = Math.floor( Math.random() * chars.length );
return chars[index];
}
图片绘制–魔棒效果
图像相关的CanvasAPI如下:
ctx.drawImage(img, x, y); // 在canvas中绘制图像
ctx.getImageData(x, y, width, height); // 获取Canvas的像素信息
ctx.putImageData(imageData, x, y); // 根据像素信息绘制Canvas
imageData.data.set(greenColor, i); // 设置Canvas的像素信息
canvas不仅可以将图片绘制出来,甚至还可以拿到像素点的信息。
function init() {
const img = new Image();
img.src = '';
// 加载完成时间,当图像加载完成后执行
img.onload((e) => {
// 设置canvas尺寸与图片一致
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0); // 在Canvas中绘制图片
})
}
canvas.addEventListener('click', (e) => {
// 获取用户点击的位置在Canvas中的坐标;
const x = e.offsetX;
const y = e.offsetY;
// 取出Canvas的像素信息,这是一个大数组,每四项为一组,分别代表一个像素点的RGBA值
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
})
如果我们想做到点击后修改图片中的颜色,那我们需要先定义一些辅助函数,下面的两个辅助函数能帮助我们更方便的查找Canvas中的颜色。
// 坐标转下标
function point2Index() {
return (y * canvas.width + x) * 4;
}
// 根据坐标获取像素点信息
function getColor(x, y, imageData) {
const i = point2Index(x, y);
return [
imageData[i],
imageData[i+1],
imageData[i+2],
imageData[i+3]
]
}
如果我希望我点击的位置变为绿色,那我们可以修改对应位置的RGBA值,然后将新的像素信息交给Canvas去绘制。
canvas.addEventListener('click', (e) => {
// 获取用户点击的位置在Canvas中的坐标;
const x = e.offsetX;
const y = e.offsetY;
// 取出Canvas的像素信息,这是一个大数组,每四项为一组,分别代表一个像素点的RGBA值
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const clickColor = getColor(x, y, imageData);
const greenColor = [0, 255, 0, 255]
function _changeColor(x, y) {
const i = point2Index(x, y);
// 设置ImageData中的值
imageData.data.set(greenColor, i);
}
_changeColor(x, y);
// 上面修改的只是内存中的像素点信息,通过putImageData将数组应用到Canvas中。
ctx.putImageData(imageData, 0, 0);
})
// 因为需要多次获取Canvas中的像素信息,浏览器发出了警告,建议我们加上一个配置
const ctx = canvas.getContext('2d', {
willReadFrequently: true // 告诉浏览器将会频繁的读取像素信息,需要做相应优化
})
需求还可以进一步提升,例如我需要像PS的魔棒一样,点击后,相邻相似的颜色都会改变。
我们只需要改写一下 _changeColor()
函数,递归得调用它即可。
function _changeColor() {
// 如果超出边界,停止递归
if(x<0 || x>canvas.width || y<0 || y>canvas.height) {
return;
}
const color = getColor(x, y, imageData);
// 如果颜色差值较大,则停止递归
if(diffColor(color, clickColor) > 100) {
return;
}
// 已经被改为绿色,不再更改
if(diffColor(color, greenColor) === 0) {
return;
}
const i = point2Index(x, y);
// 设置ImageData中的值
imageData.data.set(greenColor, i);
// 递归调用,如果图片比较大,递归可能会栈溢出,可以改用循环来写。
_changeColor(x + 1, y);
_changeColor(x - 1, y);
_changeColor(x, y + 1);
_changeColor(x, y - 1);
}
// 辅助函数,返回两个函数的颜色差值
function diffColor(color1, color2) {
return Math.abs(color1[0] - color2[0]) +
Math.abs(color1[1] - color2[1]) +
Math.abs(color1[2] - color2[2]) +
Math.abs(color1[3] - color2[3])
}
绘制和拖拽
有了前面的Canvas基础,可以来做两个综合案例。
下面制作一个使用画笔图板和一个图形画板,Canvas可以做的效果很多,主要是制作的思路。