基于 Canvas 绘制图形


Canvas 中的图形

<canvas> 元素自身是没有任何外观的,但是它在文档中创建一个画板,同时还提供了很多强大的绘制客户端图形的 JavaScript API。

<canvas> 和 SVG 之间一个重要的区别是:使用 Canvas 来绘制图形是通过调用它提供的方法而使用 SVG 绘制图形是通过构建一棵 XML 元素树来是实现的,两者各有利弊。

大部分画布绘制 API 都不是在 <canvas> 元素自身上定义的,而是定义在一个「绘制上下文」对象上,获取该对象可以通过调用画布的 getContext() 方法,传递一个「2d」参数,会获得一个 CanvasRenderingContext2D 对象,使用该对象就可以在画布上绘制二维图形,我们可以将其看作「画笔」,一个画布上只能有一个画笔。

注:传递一个「webgl」参数给 getContext() 方法会获得一个用于绘制3D 图形的上下文对象。

下面是一个使用画布 API 的简单例子:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Canvas</title>
</head>
<body>
这是一个红色的正方形:
<canvas id="square" width="10" height="10"></canvas>
这是一个蓝色的圆形:
<canvas id="circle" width="10" height="10"></canvas>
<script>
    var canvas = document.getElementById("square");
    var context = canvas.getContext("2d");
    context.fillStyle = "#f00";
    context.fillRect(0, 0, 10, 10);
    
    canvas = document.getElementById("circle");
    context = canvas.getContext("2d");
    context.beginPath();
    context.arc(5, 5, 5, 0, 2 * Math.PI, true);
    context.fillStyle = "#00f";
    context.fill();
</script>
</body>
</html>

绘制线段和填充多边形

首先需要准备好画布和画笔(画布默认大小是 300 * 150,单位是像素):

var canvas = document.getElementById("my_canvas");  // 画布
var context = canvas.getContext("2d");  // 画笔

然后作画:

context.beginPath();  // 提笔
context.moveTo(50, 50);   // 起步
context.lineTo(100, 100);   // 第一条线
context.lineTo(50, 100);   // 第二条线
context.closePath();       // 让路径闭合

context.fillStyle = "#fff";   // 填充颜色
context.strokeStyle = "#000";  // 边框颜色
context.lineWidth = 5;  // 边框宽度
context.fill();   // 填充(默认用黑色填充)
context.stroke();  // 勾勒边框

效果如下:

非零绕数原则:

按照这个原则可以实现圆环效果:

圆环上蓝色区域内的点都在路径内,圆环白色区域内的点(如绿色点)在路径外:

var canvas = document.getElementById("my_canvas");
// 设置画布大小
canvas.width = 800;
canvas.height = 600;
var context = canvas.getContext("2d");

// 设置阴影
context.shadowColor = "#555";
context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 2;

// 内环(顺时针)
context.arc(200, 150, 100, 0, Math.PI * 2 ,false);
// 外环(逆时针)
context.arc(200, 150, 115, 0, Math.PI * 2 ,true);
// 圆环填充色
context.fillStyle = "#0aa";
context.fill();

下面是效果图:

图形属性

画布 API 在 CanvasRenderingContext2D 对象(画笔)上定义了15个图形属性:

尽管一张画布上只能有一支画笔,但是允许保存当前图形状态,这样就可以通过在不同状态间切换模拟出多支画笔的效果。但是需要注意的一点是当前定义的路径以及不属于图形状态的当前点都不能保存和恢复。

// 恢复最后一次保存的图形状态,但是让该状态从栈中弹出
CanvasRenderingContext2D.prototype.revert = function () {
    this.restore();
    this.save();
    return this;
};
    
// 通过 o 对象的属性来设置图形属性
// 或者如果没有提供此参数,就以对象的方式返回当前属性
CanvasRenderingContext2D.prototype.attrs = function () {
    if (o) {
        for (var a in o) {
            this[a] = o[a];
        }
        return this;
    } else {
        return {
            fillStyle: this.fillStyle,
            font: this.font,
            globalAlpha: this.globalAlpha,
            globalCompositeOperation: this.globalCompositeOperation,
            lineCap: this.lineCap,
            lineJoin: this.lineJoin,
            lineWidth: this.lineWidth,
            miterLimit: this.miterLimit,
            textAlign: this.textAlign,
            textBaseline: this.textBaseline,
            shadowBlur: this.shadowBlur,
            shadowColor: this.shadowColor,
            shadowOffsetX: this.shadowOffsetX,
            shadowOffsetY: this.shadowOffsetY,
            strokeStyle: this.strokeStyle
        };
    }
};

画布的尺寸和坐标

<canvas> 元素的 widthheight 或者对应对象的宽度和高度属性决定了画布的宽度和高度。画布的尺寸是不能随意更改的,除非完全重置画布,重置宽度和高度属性都会清空整个画布。

坐标系变换

默认坐标系是以画布左上角为坐标原点 (0,0),每个坐标点都对应一个CSS像素。除了默认坐标系以外,每个画布还有一个「当前变换矩阵」,作为图形状态的一部分。当前变换矩阵用来将指定坐标转换为默认坐标系中的等价坐标。

我们可以通过移动(translate)、旋转(rotate)和缩放(scale)等操作实现坐标系变换:

坐标变换使用示例:科赫雪花

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>科赫雪花</title>
    </head>
    <body>
        <canvas id="snow_flake" width="800" height="600"></canvas>
        <script>
            var canvas = document.getElementById("snow_flake");
            var context = canvas.getContext("2d");
            
            var deg = Math.PI / 180;  // 用于角度到弧度的转化
            
            // 在画笔c中以左下角的点(x,y)和边长len绘制一个n级别的科赫雪花
            function snowflake(context, n, x, y, len) {
                context.save();
                context.translate(x, y);  // 将原点变换为起始点
                context.moveTo(0, 0);
                leg(n);
                context.rotate(-120 * deg);
                leg(n);
                context.rotate(-120 * deg);
                leg(n);
                context.closePath();
                context.restore();
            
                function leg(n) {
                    context.save();
                    if (n === 0) {
                        context.lineTo(len, 0);  // 绘制一条水平线段
                    } else {
                        context.scale(1/3, 1/3);
                        leg(n - 1);
                        context.rotate(60 * deg);
                        leg(n - 1);
                        context.rotate(-120 * deg);
                        leg(n - 1);
                        context.rotate(60 * deg);
                        leg(n - 1);
                    }
                    context.restore();
                    context.translate(len, 0);
                }
            }
            
            snowflake(context, 0, 5, 115, 125);
            snowflake(context, 1, 145, 115, 125);
            snowflake(context, 2, 285, 115, 125);
            snowflake(context, 3, 425, 115, 125);
            snowflake(context, 4, 565, 115, 125);
            
            context.stroke();
            
        </script>
    </body>
</html>

效果图:

绘制和填充曲线

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Canvas</title>
    </head>
    <body>
        <canvas id="my_canvas" width="800" height="600"></canvas>
        <script>
            var canvas = document.getElementById("my_canvas");
            var context = canvas.getContext("2d");
            
            function rads(x) {
                return Math.PI * x / 180;
            }
            
            context.beginPath();
            // 圆心位于(75,100),半径为50,画一个360°的弧,也就是圆
            context.arc(75, 100, 50, 0, rads(360), false);
            
            context.moveTo(200, 100);
            context.arc(200, 100, 50, rads(-60), rads(0), false);
            context.closePath();
            
            context.moveTo(325, 100);
            // 顺时针
            context.arc(325, 100, 50, rads(-60), rads(0), true);
            context.closePath();
            
            // 绘制圆角
            context.moveTo(450, 50);
            // 右上角
            context.arcTo(500, 50, 500, 150, 30);
            // 右下角
            context.arcTo(500, 150, 400, 150, 20);
            // 左下角
            context.arcTo(400, 150, 400, 50, 10);
            // 左上角
            context.arcTo(400, 50, 500, 50, 0);
            context.closePath();
            
            // 二次贝塞尔曲线
            context.moveTo(75, 250);
            context.quadraticCurveTo(100, 200, 175, 250);
            context.fillRect(100-3, 200-3, 6, 6);
            
            // 三次贝塞尔曲线
            context.moveTo(200, 250);
            context.bezierCurveTo(220, 220, 280, 280, 300, 250);
            context.fillRect(220-3, 220-3, 6, 6);
            context.fillRect(280-3, 280-3, 6, 6);
            
            context.fillStyle = "#aaa";
            context.lineWidth = 5;
            context.fill();
            context.stroke();
            
        </script>
    </body>
</html>

效果图:

矩形

画笔提供四个绘制矩形的方法:

  • fillRect:矩形
  • strokeRect:矩形框
  • clearRect:忽略fillStyle设置的fillRect
  • rect:会对当前路径产生影响

颜色、透明度、渐变以及图案

CSS3 颜色语法允许对不透明度进行设置(rgba、hsla,a代表透明度),画笔属性 globalAlpha 也可以用于设置不透明度。

如果不想使用纯色,还可以通过渐变和重复图片来填充和勾勒路径。

要用背景图片的图案而不是颜色来填充或者勾勒,可以将 fillStyle 或者 strokeStyle 属性设置为 CanvasPattern 对象,该对象可以通过调用上下文对象的 createPattern() 方法返回:

var image = document.getElementById("myimage");
context.fillStyle = context.createPattern(image, "repeat");

还可以将一个 <canvas> 元素作为另一个 <canvas> 元素的背景图案:

var offscreen = document.createElement("canvas");
offscreen.width = offscreen.height = 10;
offscreen.getContext("2d").strokeRect(0, 0, 6, 6);
var pattern = context.createPattern(offscreen, "repeat");

要使用渐变色来进行填充获勾勒,可以将 fillStyle 或者 strokeStyle 属性设置为 CanvasGradient 对象,该对象可以通过调用上下文对象上的 createLinearGradient()createRadialGradient() 方法返回:

var canvas = document.getElementById("my_canvas");
var context = canvas.getContext("2d");

var offscreen = document.createElement("canvas");
offscreen.width = offscreen.height = 10;
offscreen.getContext("2d").strokeRect(0, 0, 6, 6);
var pattern = context.createPattern(offscreen, "repeat");

// 一个线性渐变,沿画布对角线
var bgfade = context.createLinearGradient(0, 0, canvas.width, canvas.height);
bgfade.addColorStop(0.0, "#88f");
bgfade.addColorStop(1.0, "#fff");

// 两个同心圆之间的线性渐变
var peekhole = context.createRadialGradient(200, 200, 100, 200, 200, 200);
peekhole.addColorStop(0.0, "transparent");
peekhole.addColorStop(0.7, "rgba(100, 100, 100, .9)");
peekhole.addColorStop(1.0, "rgba(0, 0, 0, 0)");

context.fillStyle = bgfade;
context.fillRect(0, 0, 400, 400);
context.strokeStyle = pattern;
context.lineWidth = 50;
context.strokeRect(50, 50, 300, 300);
context.fillStyle = peekhole;
context.fillRect(0, 0, 400, 400);

效果图:

线段绘制相关属性

  • lineWidth:默认值是1,指定线条宽度
  • lineCap:指定了一个未封闭的子路径段的端点如何「封顶」(butt、square、round)
  • lineJoin:指定了子路径顶点之间如何连接(miter、round、bevel)
  • miterLimit:只有当 lineJoin 属性是 miter 时才起作用,指定斜接部分长度的上限

文本

要在画布上绘制文本,通常结合使用 fillText() 方法及 fillStyle 属性。要想在大号字体上加特效,可以使用 strokeText() 方法,该方法会在每个字形外边绘制轮廓。

除此 fillStyle 属性之外,还有其他一些文本专用属性:

  • font:指定字体
  • textAlign:指定文本水平对齐方式,默认值是「start」
  • textBaseline:指定文本垂直对其方式,默认值是「alphabetic」

在文本显示前还可以通过画笔提供的 measureText() 方法获取文本宽度。

裁剪

我们可以通过 clip() 方法来定义一个裁剪区域,定义裁剪区域之后在该区域之外将不会绘制任何内容。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Canvas</title>
    </head>
    <body>
        <canvas id="my_canvas" width="800" height="600"></canvas>
        <script>
            var canvas = document.getElementById("my_canvas");
            var context = canvas.getContext("2d");
            
            context.font = "bold 60pt sans-serif";
            context.lineWidth = 2;
            context.strokeStyle = "#000";
            
            context.strokeRect(175, 25, 50, 325);  // 绘制矩形
            context.strokeText("<canvas>", 15, 330); // 绘制文本
            
            polygon(context, 3, 200, 225, 200);  // 大三角形
            polygon(context, 3, 200, 225, 100, 0, true); // 小三角形
            
            // 将该区域定义为裁剪区域
            context.clip();
            
            // 裁剪区域外
            context.lineWidth = 10;
            context.stroke();
            
            // 填充在裁剪区域内的矩形和文本部分
            context.fillStyle = "#aaa";
            context.fillRect(175, 25, 50, 325);
            context.fillStyle = "#888";
            context.fillText("<canvas>", 15, 330);
            
            function polygon(c, n, x, y, r, angle, counterclockwise) {
                angle = angle || 0;
                counterclockwise = counterclockwise || false;
                c.moveTo(x + r * Math.sin(angle), y - r * Math.cos(angle));
                var delta = 2 * Math.PI / n;
                for (var i = 1; i < n; i++) {
                    angle += counterclockwise ? -delta : delta;
                    c.lineTo(x + r * Math.sin(angle), y - r * Math.cos(angle));
                }
                c.closePath();
            }
        </script>
    </body>
</html>

由于没有提供重置裁剪区域的方法,因此在调用 clip() 方法之前通常要调用 save() 方法,以便之后恢复到未裁剪区域。

阴影

画笔定义了四个图形属性用于控制绘制下拉阴影:

  • shadowColor:阴影颜色,默认是完全透明的黑色,所以没设置这个属性的话,阴影是不可见的
  • shadowOffsetX、shadowOffsetY:阴影的X轴和Y轴偏移量,默认值是0,所以如果都没有设置的话阴影也是不可见的
  • shadowBlur:指定阴影边缘的模糊程度,默认值为0

示例代码:

<!DOCTYPE html>
<html>
    <head>
       <meta charset="UTF-8">
       <title>Canvas</title>
    </head>
    <body>
        <canvas id="my_canvas" width="800" height="600"></canvas>
        <script>
           var canvas = document.getElementById("my_canvas");
           var context = canvas.getContext("2d");
            
           context.shadowColor = "rgba(100,100,100,.4)";
           context.shadowOffsetX = context.shadowOffsetY = 3;
           context.shadowBlur = 5;
            
           context.lineWidth = 10;
           context.strokeStyle = "blue";
           context.strokeRect(100, 100, 300, 300);
           context.font = "Bold 36pt Helvetica";
           context.fillText("Hello World", 115, 225);
            
           context.shadowOffsetX = context.shadowOffsetY = 20;
           context.shadowBlur = 10;
           context.fillStyle = "red";
           context.fillRect(50, 25, 200, 65);
        </script>
    </body>
</html>

效果图:

图片

除了矢量图形(路径、线段等)之外,画布 API 还支持位图图片。drawImage() 用于将源图片的像素内容复制到画布上,还可以对图片进行缩放和旋转:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>Canvas</title>
    </head>
    <body>
        <canvas id="my_canvas" width="800" height="600"></canvas>
        <script>
            var canvas = document.getElementById("my_canvas");
            var context = canvas.getContext("2d");
            
            // 画一条线段
            context.moveTo(5, 5);
            context.lineTo(45, 45);
            context.lineWidth = 8;
            context.lineCap = "round";
            context.stroke();
            
            // 定义一个变换
            context.translate(50, 100);
            context.rotate(-45 * Math.PI / 180);
            context.scale(10, 10);
            
            // 使用 drawImage() 方法来复制该线段
            context.drawImage(context.canvas,
                0, 0, 50, 50,     // 源矩形区域:未变换
                0, 0, 50, 50);    // 目标矩形区域:变换过
        </script>
    </body>
</html>

除了能够将一张图片绘制到一张画布中之外,还能使用 toDataURL() 方法将画布中的内容导出为一张图片。该方法由画布元素自身提供,而不是画笔对象,该方法不需任何参数(支持传入图片 MIME 类型作为第一个参数),将画布内容以 PNG 图片的形式返回,同时编码成一个字符串数据,用 URL 表示,返回的 URL 可以在 <img> 元素中使用:

var img = document.createElement("img");
img.src = canvas.toDataURL();
document.body.appendChild(img);

合成

我们可以通过设置 globalCompositeOperation 属性来设置合成的方式:

  • source-over: 表示将源像素绘制在目标像素上
  • copy: 表示合并关闭,源像素将原封不动地复制到画布上,忽略目标像素
  • destination-over: 表示将新的源像素绘制在已有目标像素的下面

以上是三种最常见的合成类型,实际上有11种类型(正方形是目标,圆形是源):

注:不同浏览器的合成方式不同,无法做到兼容。

像素操作

使用 getImageData() 方法会返回一个 ImageData 对象,该对象表示画布矩形区域中的原始像素信息。使用 createImageData() 方法可以创建一个空的 ImageData 对象,ImageData 中的像素是可写的,可以对其进行设置后再通过 putImageData() 方法将这些像素复制回画布中。

// 用 ImageData 实现动态模糊
function smear(c, n, x, y, w, h) {
    // 获取矩形区域的ImageData
    var pixels = c.getImageData(x, y, w, h);
    
    // 如果需要输出缓冲区,可以创建一个新的ImageData对象来存储变换后的像素值
    var output_pixels = c.createImageData(pixels);
    
    var width = pixels.width, height = pixels.height;
    
    // data 变量包含所有原始的像素信息,每个像素占据4个字节(R、G、B、A)
    var data = pixels.data;
    
    // 每一行第一个像素之后的像素都通过将其色值替换成(色素值的1/n+原色素值的m/n)
    var m = n - 1;
    for (var row = 0; row < height; row++) {
        var i = row * width * 4 + 4;
        for (var col = 1; col < width; col++, i+=4) {
            data[i] = (data[i] + data[i-4]*m) / n;      // Red
            data[i+1] = (data[i+1] + data[i-3]*m) / n;  // Green
            data[i+2] = (data[i+2] + data[i-2]*m) / n;  // Blue
            data[i+3] = (data[i+3] + data[i-1]*m) / n;  // Alpha
        }
    }
    
    // 将涂抹过的图片复制回画布相同的位置
    c.putImageData(pixels, x, y);
}

命中检测

isPointPath() 确定一个指定的点是否落在当前路径中。该方法对命中检测很有帮助,比如可用于检测鼠标点击事件是否发生在指定的形状上:

function hitpath(context, event) {
    var canvas = context.canvas;
    
    // 获取画布尺寸和位置
    var bb = canvas.getBoundingClientRect();
    
    // 将鼠标事件坐标转换为画布坐标
    var x = (event.clientX - bb.left) * (canvas.width / bb.width);
    var y = (event.clientY - bb.top) * (canvas.height / bb.height);
    
    return context.isPointInPath(x, y);
}

除了基于路径的命中检测外,还可以使用 getImageData() 方法检测鼠标点下的像素是否已经绘制过了:

function hitpaint(context, event) {
    var canvas = context.canvas;
    var bb = canvas.getBoundingClientRect();
    var x = (event.clientX - bb.left) * (canvas.width / bb.width);
    var y = (event.clientY - bb.top) * (canvas.height / bb.height);
    
    // 获取像素
    var pixels = canvas.getImageData(x, y, 1, 1);
    
    // 点下的像素非透明表示命中
    for (var i = 3; i < pixels.data.length; i++) {
        if (pixels.data[i] !==0 )
            return true;
    }
    
    return false;
}

点赞 取消点赞 收藏 取消收藏

<< 上一篇: SVG:可伸缩的矢量图形

>> 下一篇: 地理位置