二维码

第三章 绘制图像

本章摘要

绘制图像是图形开发基石,本章聚焦Canvas上的图像绘制技术。从基础加载到高级切片、异步加载,掌握drawImage()方法,理解异步加载特性,学会在图像上叠加文本。同时,探讨图像平铺技巧,提升图形开发能力。

图形系统开发实战课程 - 基础篇 绘制图像

第三章 绘制图像

   图像也称为栅格图像,是由扫描仪、摄像机等输入设备捕捉实际的画面产生的数字图像。通常指由像素点阵构成的位图,即图像元素由像素构成,其位置和形状会随着图像尺寸的改变而改变,放大或缩小会导致失真。

   图形是指由外部轮廓线条构成的矢量图,通常由计算机绘制直线、圆、矩形、曲线、多边形等构成。

   本章讲述的绘制图像是指在Canvas画布中绘制图像的方法,本章的内容包括:

  • 按图像大小绘制图像
  • 指定大小绘制图像
  • 绘制局部图像(切片)
  • 异步加载图像

  画布渲染上下文提供了drawImage()方法,可直接在画布上绘制图像,该方法提供了三种绘制图像的方式:

1
2
3
4
5
6
7
8
// 绘制图像
ctx.drawImage(image, dx, dy);

// 指定大小绘制图像
ctx.drawImage(image, dx, dy, dWidth, dHeight);

// 绘制图像的一部分(切片)
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

接下来的内容我们详细讲解这几种图形的绘制方式。

1. 按图像大小绘制图像

  浏览器支持的任意格式的外部图片都可以作为数据源绘制到画布中,比如 PNG、GIF、JPEG或者SVG。你甚至可以将同一个页面中其他 Canvas 元素生成的图片作为图片源。我们先看一个示例:

运行效果

  这个示例是在网页中使用<img>标签加载了一个svg文件,Canvas通过渲染上下文提供了drawImage()方法将此图片绘制到了画布中,其源代码如下:

{.line-numbers}
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
<!DOCTYPE html>
<html>
<head>
<title>绘制基本图形(Image1)</title>
<meta charset="UTF-8">
<script src="./js/helper.js"></script>
</head>

<body style="overflow: hidden; margin:10px;">
<img id="imgSource" src="./images/abc.svg" style="display:none;" />
<canvas id="canvas" width="250" height="250" style="border:solid 1px #CCCCCC; box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.5);" ></canvas>
</body>
<script>
imgSource.onload = function() {
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10, ctx);

// 在画布中绘制图像
let image = document.getElementById("imgSource");
ctx.drawImage(image, 50, 50);
}
</script>
</html>

  在这个示例中,我们指定了所需绘制的位图和该位图在画布中的坐标位置,Canvas按照原图的宽和高将位图绘制到了画布中。其方法参数如下:

1
2
// 绘制图像
ctx.drawImage(image, dx, dy);

  我们在画布中显示一下坐标的位置(如下图),可以看出(dx,dy)指的是位图左上角的位置。

运行效果

2. 指定大小绘制图像

  接下来我们看看绘制绘图的第二种方式:

1
2
// 指定大小绘制图像
ctx.drawImage(image, dx, dy, dWidth, dHeight);

  这种方式可以指定位图的大小,当dWidth和dHeight比原图小时就可以实现缩小的效果,反之则可以实现放大的效果;当宽高比和原图不一致时就会出现拉伸变形,我们执行一下程序,效果如下图所示:

运行效果

  我们在画布中显示一下坐标的位置(如下图),注意观察一下新的宽度和高度dWidth和dHeight。

运行效果

其源代码如下:

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
imgSource.onload = function() {
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10, ctx);

// 原图大小
let image = document.getElementById("imgSource");
ctx.drawImage(image, 50, 50);

// 缩小的图像
ctx.drawImage(image, 50, 250, 100, 100);

// 拉伸变形的图像
ctx.drawImage(image, 200, 250, 100, 50);

// 放大的图像
ctx.drawImage(image, 350, 50, 300, 300);
}
</script>

3. 绘制局部图像(切片)

  接下来我们看看绘制绘图的第三种方式:

1
2
// 绘制图像的一部分(切片)
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

  这种方式可以绘制原图的局部图像,先看一个示例(如下图):

运行效果

  这张图是原图中的一个局部,将原图和这个局部图形对比看一下(如下图):

运行效果

  • sx和sy: 指的是在原图的起点坐标
  • sWidth和sHeight: 指的是从原图的起点坐标开始的一个矩形范围(宽和高)
  • dx和dy: 指的是绘制在画布中的坐标
  • dWidth和dHeight: 指的是绘制到画布中的大小(宽和高)

  绘制据局部图像的源代码如下:

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
imgSource.onload = function() {
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10, ctx);

// 绘制图像局部
let image = document.getElementById("imgSource");
ctx.drawImage(image, 50, 0, 50, 50, 20, 20, 360, 360);
}
</script>

  绘制局部图像的应用非常广泛,在网页样式中我们也会经常用到。例如为了提高页面的加载效率,设计人员通常将页面中的多个图标合成到一个位图中,在使用时指定这个位图的位置和大小,这种方式可以减少页面访问WEB服务器的次数,从而减少页面与WEB服务器的连接请求,不仅仅减轻了WEB服务器的压力,而且提高了客户端页面的响应效率。

  下面这个示例实现了类似功能:

运行效果

{.line-numbers}
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
<script>
imgSource.onload = function() {
// 从页面中获取画布对象1
let canvas1 = document.getElementById('canvas1');
// 从画布中获取“2D渲染上下文”对象
let ctx1 = canvas1.getContext('2d');
// 绘制原图
let image = document.getElementById("imgSource");
ctx1.drawImage(image, 20, 20);

// 从页面中获取画布对象2
let canvas2 = document.getElementById('canvas2');
// 从画布中获取“2D渲染上下文”对象
let ctx2 = canvas2.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10, ctx2);

// 绘制第一行的图标
ctx2.drawImage(canvas1, 24, 24, 48, 48, 40, 40, 72, 72);
ctx2.drawImage(canvas1, 24, 24 + 48*1, 48, 48, 140, 40, 72, 72);
ctx2.drawImage(canvas1, 24, 24 + 48*2, 48, 48, 240, 40, 72, 72);
ctx2.drawImage(canvas1, 24, 24 + 48*3, 48, 48, 340, 40, 72, 72);

// 绘制第二行的图标
ctx2.drawImage(canvas1, 24, 24 + 48*4, 48, 48, 40, 140, 72, 72);
ctx2.drawImage(canvas1, 24, 24 + 48*4, 48, 48, 140, 140, 72, 72);
ctx2.drawImage(canvas1, 24, 24 + 48*5, 48, 48, 240, 140, 72, 72);
ctx2.drawImage(canvas1, 24, 24 + 48*6, 48, 48, 340, 140, 72, 72);

// 绘制第三行的图标
ctx2.drawImage(canvas1, 24, 24 + 48*4, 48, 48, 240, 330, 30, 30);
ctx2.drawImage(canvas1, 24, 24 + 48*4, 48, 48, 280, 330, 30, 30);
ctx2.drawImage(canvas1, 24, 24 + 48*4, 48, 48, 320, 330, 30, 30);
ctx2.drawImage(canvas1, 24, 24 + 48*4, 48, 48, 360, 330, 30, 30);
ctx2.drawImage(canvas1, 24, 24 + 48*5, 48, 48, 400, 330, 30, 30);
}
</script>

  这个示例中左侧的Canvas使用<img>位图作为数据源,右侧的Canvas是从左侧的Canvas中裁剪了一些局部,形成了更加灵活的效果。

4. 异步加载图像

  通过上面的几个示例,可以看出绘制图像时均使用了页面中的 <img>元素作为绘制图像的数据源,如果页面中没有加载的图像,或者我们仅仅知道图像的下载地址,那么如何将此图像绘制到Canvas画布中呢?

  答案是:我们可以用js脚本创建一个新的HTMLImageElement对象。其示例代码如下:

1
2
let image = new Image(); // 创建一个<img>元素
image.src = "myImage.png"; // 设置图片源地址

  当脚本执行后,图片开始装载。若此时就调用drawImage(),由于图片没装载完,绘制图像将会失败(在一些旧的浏览器中可能会抛出异常)。因此需要在HTMLImageElement对象的onload事件后加载使用这个图片,其用法如下:

1
2
3
4
5
let image = new Image(); // 创建一个<img>元素
image.onload = function () {
// 在此执行 drawImage() 语句
};
image.src = "myImage.png"; // 设置图片源地址

  我们看一个具体示例:

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10);

// 加载并绘制图像
let image = new Image();
image.onload = function () {
// 此时才可在画布中绘制图像
ctx.drawImage(image, 10, 10, 380, 380);
}
image.src = "./images/ma.png";
</script>

  其运行效果是这样的:
运行效果

  这里我提一个问题,继续上面的代码,在其后添加绘制文本的代码(源代码如下),能否实现在图片上绘制文本的效果呢?

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script>
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10);

// 加载并绘制图像
let image = new Image();
image.onload = function () {
// 在画布中绘制图像
ctx.drawImage(image, 10, 10, 380, 380);
}
image.src = "./images/ma.png";

// 绘制文本
ctx.textBaseline = "top";
ctx.font = "30px 黑体";
ctx.fillStyle="white";
ctx.fillText("在图片上叠加文字", 30, 20)
</script>

能否实现这样的效果?
运行效果

  有异步编程的小伙伴很快就会看出来,这段代码是没法达到上述效果的,其原因是位图的加载是一个异步加载过程,在绘制文本的时候由于位图没有加载完成,因此执行的结果是先执行“绘制文本”的代码,在位图加载完毕之后执行onload中的代码,从而得到的结果是图像将会覆盖住文本的内容。

  只需在onload()事件后绘制文本的代码,就能实现上述效果,源代码如下:

{.line-numbers}
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
<script>
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10);

// 加载并绘制图像
let image = new Image();
image.onload = function () {
draw();
}
image.src = "./images/ma.png";

function draw() {
// 在画布中绘制图像
ctx.drawImage(image, 10, 10, 380, 380);
// 绘制文本(在onload()事件中执行本段代码)
ctx.textBaseline = "top";
ctx.font = "30px 黑体";
ctx.fillStyle="white";
ctx.fillText("在图片上叠加文字", 30, 20)
}
</script>

  在实际的引用中,我们可能需要加载多张图片,且有些图片之间是存在先后顺序的,例如在地图应用中,需先加载地图背景,然后在地图背景上标注一些位置和绘制一些形状。运行效果如下图所示:

运行效果

   绘制这样的图形其难点是要控制好代码执行的顺序,本示例中需注意的是在背景地图加载完成后,加载标记的图片,其代码如下:

{.line-numbers}
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<script>
// 从页面中获取画布对象
let canvas = document.getElementById('canvas');
// 从画布中获取“2D渲染上下文”对象
let ctx = canvas.getContext('2d');
// 绘制背景网格
drawGrid("lightgray", 10, 10);

// 绘制背景地图
drawImage("./images/map_bg1.png", function (image) {
// 在画布中绘制图像
ctx.drawImage(image, 10, 10);
// 在绘制背景地图之后绘制切片网格
drawTileGrid();
// 在绘制背景地图之后绘制标记图标
drawMarker();
})

// 绘制标记图标
function drawMarker() {
let path = "./images/marker/";
let offset = [10, 18];
let marks = [
{ x: 187, y: 99, name: "marker_1.png" },
{ x: 380, y: 101, name: "marker_2.png" },
{ x: 538, y: 100, name: "marker_3.png" },
{ x: 715, y: 217, name: "marker_4.png" },
{ x: 531, y: 379, name: "marker_5.png" },
{ x: 178, y: 392, name: "marker_6.png" },
{ x: 64, y: 272, name: "marker_7.png" },
{ x: 521, y: 548, name: "marker_8.png" },
{ x: 522, y: 250, name: "marker_9.png" },
{ x: 348, y: 457, name: "marker.png" },
];

marks.forEach(mark => {
drawImage(path + mark.name, function (image) {
let x = mark.x - offset[0];
let y = mark.y - offset[1];
// 绘制某个标记图标
ctx.drawImage(image, x, y);
// 绘制切片网格
drawTileGrid();
})
})
}

// 绘制切片网格
function drawTileGrid() {
let size = 170;
ctx.beginPath();
ctx.strokeStyle = "#A2A2A2";
for (let i = 0; i < canvas.height / size; i++) {
ctx.moveTo(0, i * size);
ctx.lineTo(canvas.width, i * size)
}
for (let i = 0; i < canvas.width / size; i++) {
ctx.moveTo(i * size, 0);
ctx.lineTo(i * size, canvas.height);
}
ctx.stroke();
}

// 异步加载图片
function drawImage(src, callback) {
let image = new Image();
image.onload = function () {
callback(image);
}
image.src = src;
}
</script>

  ES6提供了Promise()对象,可以更方便的控制异步加载顺序,等这个系列的文章写完之后,我在专门写一篇这方面的介绍文档。

5 本章小结

  本节讲解了在Canvas绘制图像的三种方式,并讲解绘制异步加载图形的方法等内容,本节内容使用了Canvas 2D API以下方法:

参数名 说明
drawImage(image, dx, dy) 绘制图像
drawImage(image, dx, dy, dWidth, dHeight)) 指定大小绘制图像
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) 绘制局部图像
Image() 创建一个新的HTMLImageElement对象

练习一下

绘制图像

在800*600的画布中绘制一张照片,要求将这张照片完全铺满在整个画布上。

平铺

  下面这张图形是一张大小为150*150像素的太阳花图像。

运行效果

  要求在一个大小为800*420大小的画布中,保持太阳花的原始大小平铺整个画布,其效果如下图所示,后续的章节中将会讲述到在几何图形中填充这种平铺图像的绘制方法,今天先动手在画布中平铺这个图像吧。

运行效果

本文为“图形开发学院”(www.graphanywhere.com)网站原创文章,遵循CC BY-NC-ND 4.0版权协议,商业转载请联系作者获得授权,非商业转载请附上原文出处链接及本声明。

历史发布版本

第1版发布时间:2023-11-01
第2版发布时间:2024-06-05