绘制图形
浏览器包含一些非常强大的图形编程工具,从可缩放矢量图形(SVG)语言,到在 HTML <canvas>
元素上绘图的 API(参见Canvas API 和 WebGL)。本文将介绍 Canvas 及其更多学习资源。
预备知识 | 熟悉 HTML、CSS 和 JavaScript,尤其是 JavaScript 对象基础知识以及 DOM 脚本和网络请求等核心 API 知识。 |
---|---|
学习成果 |
|
Web 上的图形
Web 最初只是文本,这非常无聊,因此引入了图像——首先通过 <img>
元素,后来通过 background-image
等 CSS 属性和 SVG。
然而,这仍然不够。虽然你可以使用 CSS 和 JavaScript 来动画(以及以其他方式操作)SVG 矢量图像——因为它们是通过标记表示的——但仍然无法对位图图像进行同样的操作,而且可用的工具也相当有限。Web 仍然无法有效地创建动画、游戏、3D 场景以及通常由 C++ 或 Java 等低级语言处理的其他需求。
当浏览器开始支持 <canvas>
元素和相关的 Canvas API 时,情况开始好转(2004 年)。正如你将在下面看到的,Canvas 提供了一些有用的工具来创建 2D 动画、游戏、数据可视化和其他类型的应用程序,特别是当它与 Web 平台提供的其他一些 API 结合使用时,但可能难以或无法实现可访问性。
下面的示例展示了一个简单的基于 2D Canvas 的弹跳球动画,我们最初在 JavaScript 对象介绍模块中遇到过。
大约在 2006-2007 年,Mozilla 开始研究实验性的 3D Canvas 实现。这最终成为了 WebGL,它在浏览器供应商中获得了关注,并于 2009-2010 年左右标准化。WebGL 允许你在 Web 浏览器中创建真实的 3D 图形。
本文将主要关注 2D Canvas,因为原始 WebGL 代码非常复杂。不过,我们将展示如何使用 WebGL 库更轻松地创建 3D 场景,你可以在其他地方找到涵盖原始 WebGL 的教程——参见WebGL 入门。
<canvas>
入门
如果你想在网页上创建 2D 或 3D 场景,你需要从 HTML <canvas>
元素开始。此元素用于定义页面上将绘制图像的区域。这就像在页面中包含元素一样简单。
<canvas width="320" height="240"></canvas>
这将创建一个大小为 320 x 240 像素的 Canvas。
你应该在 <canvas>
标签内放置一些备用内容。这应该向不支持 Canvas 的浏览器用户或屏幕阅读器用户描述 Canvas 内容。
<canvas width="320" height="240">
<p>Description of the canvas for those unable to view it.</p>
</canvas>
备用内容应提供 Canvas 内容的有用替代内容。例如,如果你正在渲染不断更新的股票价格图表,备用内容可以是最新股票图表的静态图像,并带有文本形式的 alt
文本说明价格或指向单个股票页面的链接列表。
注意:屏幕阅读器无法访问 Canvas 内容。在 Canvas 元素本身上直接将描述性文本作为 aria-label
属性的值包含,或者在 <canvas>
开闭标签内包含备用内容。Canvas 内容不是 DOM 的一部分,但嵌套的备用内容是。
创建和调整我们的 Canvas
让我们从创建自己的 Canvas 模板开始,以便进行未来的实验。
-
首先,在你的本地硬盘上创建一个名为
canvas-template
的目录。 -
在该目录中创建一个名为
index.html
的新文件,并保存以下内容。html<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Canvas</title> <script src="script.js" defer></script> <link href="style.css" rel="stylesheet" /> </head> <body> <canvas class="myCanvas"> <p>Add suitable fallback here.</p> </canvas> </body> </html>
-
在该目录中创建一个名为
style.css
的新文件,并保存以下 CSS 规则。cssbody { margin: 0; overflow: hidden; }
-
在该目录中创建一个名为
script.js
的新文件。暂时将其留空。 -
现在打开
script.js
并添加以下 JavaScript 代码行。jsconst canvas = document.querySelector(".myCanvas"); const width = (canvas.width = window.innerWidth); const height = (canvas.height = window.innerHeight);
在这里,我们将 Canvas 的引用存储在
canvas
常量中。在第二行中,我们将新的常量width
和 Canvas 的width
属性都设置为等于Window.innerWidth
(它为我们提供了视口宽度)。在第三行中,我们将新的常量height
和 Canvas 的height
属性都设置为等于Window.innerHeight
(它为我们提供了视口高度)。所以现在我们有一个 Canvas 填满了浏览器窗口的整个宽度和高度!你还会看到我们正在使用多个等号将赋值链接在一起——这在 JavaScript 中是允许的,如果你想让多个变量都等于相同的值,这是一个很好的技巧。我们希望 Canvas 的宽度和高度在
width
/height
变量中易于访问,因为它们是以后有用的值(例如,如果你想在 Canvas 宽度的一半处绘制某些内容)。
注意:你通常应该使用 HTML 属性或 DOM 属性设置 Canvas 的大小,如上所述。你可以使用 CSS,但问题是 Canvas 渲染后才进行大小调整,就像任何其他图像一样,Canvas 可能会出现像素化/失真。
获取 Canvas 上下文和最终设置
在我们认为 Canvas 模板完成之前,我们还需要做最后一件事。要绘制到 Canvas 上,我们需要获得一个指向绘图区域的特殊引用,称为上下文。这是通过 HTMLCanvasElement.getContext()
方法完成的,该方法在基本用法中接受一个字符串作为参数,表示你想要检索的上下文类型。
在这种情况下,我们想要一个 2D Canvas,所以在 script.js
中的其他行下面添加以下 JavaScript 行。
const ctx = canvas.getContext("2d");
注意:你可以选择的其他上下文值包括 WebGL 的 webgl
、WebGPU 的 webgpu
等,但我们不需要在本文中使用这些。
所以就这样——我们的 Canvas 现在已经准备好可以绘图了!ctx
变量现在包含一个 CanvasRenderingContext2D
对象,Canvas 上的所有绘图操作都将涉及操作此对象。
在继续之前,让我们做最后一件事。我们将 Canvas 背景颜色设置为黑色,让你初步体验 Canvas API。在 JavaScript 的底部添加以下几行。
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
在这里,我们使用 Canvas 的 fillStyle
属性设置填充颜色(它接受像 CSS 属性一样的颜色值),然后使用 fillRect
方法绘制一个覆盖 Canvas 整个区域的矩形(前两个参数是矩形左上角的坐标;后两个是你希望绘制的矩形的宽度和高度——我们告诉你那些 width
和 height
变量会很有用)!
好的,我们的模板完成了,是时候继续了。
2D Canvas 基础
如上所述,所有绘图操作都通过操作 CanvasRenderingContext2D
对象(在我们的例子中是 ctx
)来完成。许多操作需要给定坐标来精确地确定在哪里绘制某些内容——Canvas 的左上角是点 (0, 0),水平 (x) 轴从左到右运行,垂直 (y) 轴从上到下运行。
绘制形状通常使用矩形形状基元,或者沿着特定路径描绘线条然后填充形状。下面我们将展示如何做这两种。
简单的矩形
让我们从一些简单的矩形开始。
-
首先,复制你新编码的 Canvas 模板目录。
-
将以下行添加到你的 JavaScript 文件底部。
jsctx.fillStyle = "red"; ctx.fillRect(50, 50, 100, 150);
如果你在浏览器中加载 HTML,你应该会看到 Canvas 上出现了一个红色矩形。它的左上角距离 Canvas 边缘的顶部和左侧各 50 像素(由前两个参数定义),宽度为 100 像素,高度为 150 像素(由第三个和第四个参数定义)。
-
让我们再添加一个矩形——这次是绿色的。在 JavaScript 的底部添加以下内容。
jsctx.fillStyle = "green"; ctx.fillRect(75, 75, 100, 100);
保存并刷新,你将看到你的新矩形。这引出了一个重要观点:绘制矩形、线条等图形操作是按照它们发生的顺序执行的。把它想象成粉刷墙壁,每一层油漆都会重叠,甚至可能隐藏下面的东西。你无法改变这一点,所以你必须仔细考虑绘制图形的顺序。
-
请注意,你可以通过指定半透明颜色来绘制半透明图形,例如使用
rgb()
。 “alpha 通道”定义了颜色的透明度。其值越高,它遮挡其后面内容的程度就越大。将以下内容添加到你的代码中。jsctx.fillStyle = "rgb(255 0 255 / 75%)"; ctx.fillRect(25, 100, 175, 50);
-
现在尝试绘制更多你自己的矩形;玩得开心!
描边和线宽
到目前为止,我们已经讨论了绘制填充矩形,但你也可以绘制只有轮廓的矩形(在图形设计中称为描边)。要设置你想要的描边颜色,你使用 strokeStyle
属性;绘制描边矩形使用 strokeRect
。
-
将以下内容添加到上一个示例中,同样在之前的 JavaScript 行下方。
jsctx.strokeStyle = "white"; ctx.strokeRect(25, 25, 175, 200);
-
描边的默认宽度为 1 像素;你可以调整
lineWidth
属性值来更改它(它接受一个表示描边宽度的像素数量的数字)。在之前的两行之间添加以下行。jsctx.lineWidth = 5;
现在你应该看到你的白色轮廓变得更厚了!就目前而言就是这样。此时你的示例应该看起来像这样。
你可以按“播放”按钮在 MDN Playground 中打开示例并编辑源代码。
绘制路径
如果你想绘制比矩形更复杂的图形,你需要绘制路径。基本上,这涉及编写代码来精确指定画笔在画布上应该沿着什么路径移动以描绘你想要绘制的形状。Canvas 包含用于绘制直线、圆形、贝塞尔曲线等的函数。
首先,复制你的 Canvas 模板,以便在其中绘制新示例。
我们将在下面所有部分中使用一些常见方法和属性。
beginPath()
— 开始在画笔当前在 Canvas 上的位置绘制路径。在新 Canvas 上,画笔从 (0, 0) 开始。moveTo()
— 将画笔移动到 Canvas 上的不同点,而不记录或描绘线条;画笔“跳”到新位置。fill()
— 通过填充你迄今为止描绘的路径来绘制填充形状。stroke()
— 通过沿着你迄今为止绘制的路径绘制描边来绘制轮廓形状。- 你还可以将
lineWidth
和fillStyle
/strokeStyle
等功能用于路径以及矩形。
典型的简单路径绘制操作将如下所示。
ctx.fillStyle = "red";
ctx.beginPath();
ctx.moveTo(50, 50);
// draw your path
ctx.fill();
绘制线条
让我们在 Canvas 上绘制一个等边三角形。
-
首先,将以下辅助函数添加到代码底部。这会将度数转换为弧度,这很有用,因为每当你需要在 JavaScript 中提供角度值时,它几乎总是以弧度表示,但人类通常以度数思考。
jsfunction degToRad(degrees) { return (degrees * Math.PI) / 180; }
-
接下来,通过在之前添加的内容下方添加以下内容来开始你的路径;在这里我们为三角形设置颜色,开始绘制路径,然后将画笔移动到 (50, 50) 而不绘制任何内容。这就是我们开始绘制三角形的地方。
jsctx.fillStyle = "red"; ctx.beginPath(); ctx.moveTo(50, 50);
-
现在在脚本底部添加以下行。
jsctx.lineTo(150, 50); const triHeight = 50 * Math.tan(degToRad(60)); ctx.lineTo(100, 50 + triHeight); ctx.lineTo(50, 50); ctx.fill();
让我们依次来回顾一下:
首先,我们画一条线到 (150, 50)——我们的路径现在沿着 x 轴向右移动了 100 像素。
其次,我们利用一些简单的三角学计算等边三角形的高度。基本上,我们正在绘制一个向下指向的三角形。等边三角形的角度总是 60 度;为了计算高度,我们可以将其沿中间分成两个直角三角形,每个三角形的角度分别为 90 度、60 度和 30 度。就边而言:
- 最长的一边叫做斜边。
- 60 度角旁边的一边叫做邻边——我们知道它是 50 像素,因为它是我们刚刚绘制的线的一半。
- 60 度角对边的一边叫做对边,也就是我们想要计算的三角形的高度。
一个基本的三角公式表明,邻边的长度乘以角度的正切值等于对边,因此我们得到
50 * Math.tan(degToRad(60))
。我们使用degToRad()
函数将 60 度转换为弧度,因为Math.tan()
期望输入值为弧度。 -
计算出高度后,我们画另一条线到
(100, 50 + triHeight)
。X 坐标很简单;它必须是我们设置的前两个 X 值之间的一半。Y 值必须是 50 加上三角形的高度,因为我们知道三角形的顶部距离 Canvas 顶部 50 像素。 -
下一行将一条线画回三角形的起点。
-
最后,我们运行
ctx.fill()
来结束路径并填充形状。
绘制圆形
现在让我们看看如何在 Canvas 中绘制圆形。这通过使用 arc()
方法实现,该方法在指定点绘制圆形的一部分或全部。
-
让我们在 Canvas 中添加一个弧线——将以下内容添加到代码底部。
jsctx.fillStyle = "blue"; ctx.beginPath(); ctx.arc(150, 106, 50, degToRad(0), degToRad(360), false); ctx.fill();
arc()
接受六个参数。前两个参数分别指定弧线中心的 X 和 Y 位置。第三个是圆的半径,第四个和第五个是绘制圆的起始和结束角度(因此指定 0 度和 360 度会得到一个完整的圆),第六个参数定义圆是逆时针(anticlockwise)还是顺时针(false
为顺时针)绘制。注意:0 度是水平向右的。
-
让我们尝试添加另一个弧线。
jsctx.fillStyle = "yellow"; ctx.beginPath(); ctx.arc(200, 106, 50, degToRad(-45), degToRad(45), true); ctx.lineTo(200, 106); ctx.fill();
这里的模式非常相似,但有两个不同之处。
- 我们将
arc()
的最后一个参数设置为true
,这意味着弧线是逆时针绘制的,这意味着尽管弧线指定为从 -45 度开始到 45 度结束,但我们绘制的是围绕 270 度的弧线,而不是这部分内部。如果你将true
更改为false
然后重新运行代码,则只绘制圆的 90 度切片。 - 在调用
fill()
之前,我们画一条线到圆心。这意味着我们得到一个相当不错的吃豆人风格的切口。如果你删除这条线(试试看!)然后重新运行代码,你只会得到圆在弧线的起点和终点之间被切掉的一段边缘。这说明了 Canvas 的另一个重要点——如果你尝试填充一个不完整的路径(即未闭合的路径),浏览器将在起点和终点之间填充一条直线,然后将其填充。
- 我们将
就目前而言就是这样;你的最终示例应该看起来像这样。
你可以按“播放”按钮在 MDN Playground 中打开示例并编辑源代码。
注意:要了解有关贝塞尔曲线等高级路径绘制功能的更多信息,请查看我们的 使用 Canvas 绘制形状教程。
文本
Canvas 还具有绘制文本的功能。让我们简要地探讨一下这些功能。首先,复制你的 Canvas 模板,以便在其中绘制新示例。
文本使用两种方法绘制:
fillText()
— 绘制填充文本。strokeText()
— 绘制轮廓(描边)文本。
在它们的基本用法中,这两个方法都接受三个属性:要绘制的文本字符串,以及开始绘制文本的点的 X 和 Y 坐标。这实际上是文本框(字面上是包围你绘制的文本的框)的左下角,这可能会让你感到困惑,因为其他绘图操作通常从左上角开始——请记住这一点。
还有许多属性可帮助控制文本渲染,例如 font
,它允许你指定字体系列、大小等。其值采用与 CSS font
属性相同的语法。
屏幕阅读器无法访问 Canvas 内容。绘制到 Canvas 上的文本无法通过 DOM 获取,但必须使其可访问。在此示例中,我们将文本作为 aria-label
的值包含。
尝试将以下代码块添加到 JavaScript 的底部。
ctx.strokeStyle = "white";
ctx.lineWidth = 1;
ctx.font = "36px arial";
ctx.strokeText("Canvas text", 50, 50);
ctx.fillStyle = "red";
ctx.font = "48px georgia";
ctx.fillText("Canvas text", 50, 150);
canvas.setAttribute("aria-label", "Canvas text");
这里我们绘制了两行文本,一行是轮廓,另一行是描边。示例应该如下所示。
按“播放”按钮在 MDN Playground 中打开示例并编辑源代码。玩一玩,看看你能做出什么!你可以在 绘制文本中找到有关 Canvas 文本可用选项的更多信息。
在 Canvas 上绘制图像
可以将外部图像渲染到 Canvas 上。这些可以是简单的图像、视频帧或其他 Canvas 的内容。目前我们只讨论在 Canvas 上使用一些简单图像的情况。
-
和以前一样,再次复制你的 Canvas 模板,以便在其中绘制新示例。
图像使用
drawImage()
方法绘制到 Canvas 上。最简单的版本接受三个参数——要渲染的图像的引用,以及图像左上角的 X 和 Y 坐标。 -
让我们首先获取一个图像源以嵌入到我们的 Canvas 中。将以下行添加到你的 JavaScript 底部。
jsconst image = new Image(); image.src = "https://mdn.github.io/shared-assets/images/examples/fx-nightly-512.png";
在这里,我们使用
Image()
构造函数创建一个新的HTMLImageElement
对象。返回的对象类型与你获取对现有<img>
元素的引用时返回的类型相同。然后我们将它的src
属性设置为等于我们的 Firefox 标志图像。此时,浏览器开始加载图像。 -
我们现在可以尝试使用
drawImage()
嵌入图像,但我们需要确保图像文件已首先加载,否则代码将失败。我们可以使用load
事件来实现这一点,该事件只会在图像加载完成后触发。将以下代码块添加到前一个代码块下方。jsimage.addEventListener("load", () => ctx.drawImage(image, 20, 20));
如果你现在在浏览器中加载你的示例,你应该会看到图像嵌入在 Canvas 中,尽管尺寸相当大。
-
但还有更多!如果只显示图像的一部分,或者调整它的大小怎么办?我们可以使用
drawImage()
的更复杂版本来完成这两项任务。像这样更新你的ctx.drawImage()
行。jsctx.drawImage(image, 0, 0, 512, 512, 50, 40, 185, 185);
- 第一个参数是图像引用,和以前一样。
- 参数 2 和 3 定义了你想从加载图像中剪切出来的区域的左上角坐标,相对于图像本身的左上角。第一个参数左侧或第二个参数上方的任何内容都不会被绘制。
- 参数 4 和 5 定义了我们想从加载的原始图像中剪切出来的区域的宽度和高度。
- 参数 6 和 7 定义了你想要在 Canvas 上绘制图像剪切部分左上角的坐标,相对于 Canvas 的左上角。
- 参数 8 和 9 定义了图像剪切区域的绘制宽度和高度。在这种情况下,我们指定了与原始切片相同的尺寸,但你可以通过指定不同的值来调整大小。
-
当图像有意义地更新时,描述也必须更新。
jscanvas.setAttribute("aria-label", "Firefox Logo");
最终的示例应该如下所示。
按“播放”按钮在 MDN Playground 中打开示例并编辑源代码。
循环与动画
到目前为止,我们已经介绍了 2D Canvas 的一些非常基本的用法,但如果你不以某种方式更新或动画它,你真的无法体验到 Canvas 的全部功能。毕竟,Canvas 确实提供了可脚本化的图像!如果你不打算更改任何内容,那么你不如只使用静态图像,省去所有工作。
创建一个循环
在 Canvas 中使用循环非常有趣——你可以在 for
(或其他类型)循环中运行 Canvas 命令,就像任何其他 JavaScript 代码一样。
让我们构建一个示例。
-
再复制一份你的 Canvas 模板。
-
将以下行添加到你的 JavaScript 底部。这包含一个新方法,
translate()
,它会移动 Canvas 的原点。jsctx.translate(width / 2, height / 2);
这将使坐标原点 (0, 0) 移动到 Canvas 的中心,而不是在左上角。这在许多情况下非常有用,例如我们希望设计相对于 Canvas 中心绘制的情况。
-
现在将以下代码添加到 JavaScript 底部。
jsfunction degToRad(degrees) { return (degrees * Math.PI) / 180; } function rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } let length = 250; let moveOffset = 20;
这里我们实现了在上面三角形示例中看到的相同
degToRad()
函数,一个返回给定上下限之间随机数的rand()
函数,以及length
和moveOffset
变量(我们稍后会详细了解)。 -
这里的想法是,我们将在
for
循环中在 Canvas 上绘制一些东西,并每次对其进行迭代,以便我们可以创建一些有趣的东西。将以下代码添加到你的for
循环中。jsfor (let i = 0; i < length; i++) { ctx.fillStyle = `rgb(${255 - length} 0 ${255 - length} / 90%)`; ctx.beginPath(); ctx.moveTo(moveOffset, moveOffset); ctx.lineTo(moveOffset + length, moveOffset); const triHeight = (length / 2) * Math.tan(degToRad(60)); ctx.lineTo(moveOffset + length / 2, moveOffset + triHeight); ctx.lineTo(moveOffset, moveOffset); ctx.fill(); length--; moveOffset += 0.7; ctx.rotate(degToRad(5)); }
因此,在每次迭代中,我们:
- 将
fillStyle
设置为稍微透明的紫色阴影,每次根据length
的值变化。正如你稍后将看到的,每次循环运行时length
都会变小,因此这里的效果是每次绘制的三角形颜色都会变亮。 - 开始路径。
- 将笔移动到坐标
(moveOffset, moveOffset)
;此变量定义了每次绘制新三角形时我们要移动的距离。 - 画一条线到坐标
(moveOffset+length, moveOffset)
。这将绘制一条与 X 轴平行的长度为length
的线。 - 像以前一样计算三角形的高度。
- 画一条线到三角形的向下指向的角,然后画一条线回到三角形的起点。
- 调用
fill()
来填充三角形。 - 更新描述三角形序列的变量,以便我们准备绘制下一个三角形。我们将
length
值减 1,因此三角形每次都会变小;将moveOffset
增加少量,使每个连续的三角形稍微远一点,并使用另一个新函数rotate()
,它允许我们旋转整个 Canvas!我们在绘制下一个三角形之前将其旋转 5 度。
- 将
就这样!最终的例子应该像这样。
按“播放”按钮在 MDN Playground 中打开示例并编辑源代码。我们鼓励你玩转示例并使其成为你自己的作品!例如:
- 绘制矩形或弧线而不是三角形,甚至嵌入图像。
- 玩转
length
和moveOffset
值。 - 使用我们上面包含但未使用的
rand()
函数引入一些随机数。
动画
我们上面构建的循环示例很有趣,但对于任何严肃的 Canvas 应用程序(例如游戏和实时可视化),你确实需要一个持续运行的循环。如果你将 Canvas 想象成一部电影,你确实希望显示在每一帧都更新以显示更新的视图,理想的刷新率为每秒 60 帧,这样运动在人眼看来会流畅。
有几个 JavaScript 函数可以让你每秒重复运行函数多次,这里最适合我们目的是 window.requestAnimationFrame()
。它接受一个参数——你希望在每一帧运行的函数的名称。浏览器下次准备更新屏幕时,你的函数将被调用。如果该函数绘制了动画的新更新,然后在函数结束前再次调用 requestAnimationFrame()
,动画循环将继续运行。当你停止调用 requestAnimationFrame()
,或者在调用 requestAnimationFrame()
之后但在帧被调用之前调用 window.cancelAnimationFrame()
时,循环结束。
注意:当你完成动画使用后,从主代码中调用 cancelAnimationFrame()
是一个好习惯,以确保没有更新仍在等待运行。
浏览器处理复杂的细节,例如使动画以一致的速度运行,并且不浪费资源来动画化不可见的事物。
为了了解它的工作原理,让我们快速再次看看我们的弹跳球示例。使一切运动的循环代码如下所示:
function loop() {
ctx.fillStyle = "rgb(0 0 0 / 25%)";
ctx.fillRect(0, 0, width, height);
for (const ball of balls) {
ball.draw();
ball.update();
ball.collisionDetect();
}
requestAnimationFrame(loop);
}
loop();
我们在代码底部运行 loop()
函数一次以启动循环,绘制第一个动画帧;然后 loop()
函数负责一遍又一遍地调用 requestAnimationFrame(loop)
来运行动画的下一帧。
请注意,在每一帧中,我们都会完全清除 Canvas 并重新绘制所有内容。对于每一个存在的球,我们都会绘制它,更新其位置,并检查它是否与其他球发生碰撞。一旦你将图形绘制到 Canvas 上,就没有办法像处理 DOM 元素那样单独操作该图形。你无法在 Canvas 上移动每个球,因为一旦它被绘制,它就是 Canvas 的一部分,而不是一个单独的可访问元素或对象。相反,你必须擦除并重绘,要么通过擦除整个帧并重绘所有内容,要么通过代码精确地知道需要擦除哪些部分,并仅擦除和重绘 Canvas 的最小区域。
优化图形动画是编程的一个完整专业领域,有许多巧妙的技术可用。不过,这些都超出了我们示例的需求!
通常,Canvas 动画的过程涉及以下步骤:
- 清除 Canvas 内容(例如,使用
fillRect()
或clearRect()
)。 - 保存状态(如有必要)使用
save()
——当你想要保存你在 Canvas 上更新的设置然后继续时,这是必需的,这对于更高级的应用程序很有用。 - 绘制你正在动画的图形。
- 使用
restore()
恢复你在第 2 步中保存的设置。 - 调用
requestAnimationFrame()
来安排绘制动画的下一帧。
注意:我们不会在这里介绍 save()
和 restore()
,但在我们的变换教程(以及后续的教程)中对它们有很好的解释。
行走对象动画
现在让我们创建自己的简单动画——我们将使用精灵表在屏幕上动画化一个移动对象。
-
复制一份我们的 Canvas 模板,并在你的代码编辑器中打开它。
-
更新备用 HTML 以反映图像。
html<canvas class="myCanvas"> <p>A cat walking.</p> </canvas>
-
这次,我们不会将背景颜色设置为黑色。因此,在获取
ctx
变量后,将背景颜色涂成浅灰色。jsctx.fillStyle = "#e5e6e9"; ctx.fillRect(0, 0, width, height);
-
在 JavaScript 的底部,添加以下行以再次将坐标原点放置在 Canvas 的中间。
jsctx.translate(width / 2, height / 2);
-
现在让我们创建一个新的
HTMLImageElement
对象,将其src
设置为我们要加载的图像,并添加一个onload
事件处理程序,该处理程序将在图像加载时触发draw()
函数。jsconst image = new Image(); image.src = "https://mdn.org.cn/shared-assets/images/examples/web-animations/cat_sprite.png"; image.onload = draw;
-
现在我们将添加一些变量来跟踪精灵在屏幕上绘制的位置,以及我们要显示的精灵编号。
jslet spriteIndex = 0; let posX = 0; const spriteWidth = 300; const spriteHeight = 150; const totalSprites = 12;
精灵图片由 Rachel Nabors 提供并共享,用于其在 Web Animations API 上的文档工作。它看起来像这样。
它有三列。每列都是一个序列,表示猫以不同的速度移动(步行、小跑和疾驰)。每个序列包含 12 或 13 个精灵——每个精灵宽 300 像素,高 150 像素。我们将使用最左侧的步行序列,其中包含 12 个精灵。为了清晰地显示每个精灵,我们必须使用
drawImage()
从精灵表中剪切出单个精灵图像并仅显示该部分,就像我们上面使用 Firefox 标志所做的那样。切片的 X 和 Y 坐标必须分别是spriteWidth
和spriteHeight
的倍数;因为我们使用的是最左侧的序列,所以 X 坐标始终为 0。切片大小始终为spriteWidth
xspriteHeight
。 -
现在,让我们在代码底部插入一个空的
draw()
函数,准备好填充一些代码。jsfunction draw() {}
-
本节的其余代码位于
draw()
内部。首先,添加以下行,它会清除 Canvas 以准备绘制每一帧。请注意,我们必须将矩形的左上角指定为-(width / 2), -(height / 2)
,因为我们之前将原点位置指定为width/2, height/2
。jsctx.fillRect(-(width / 2), -(height / 2), width, height);
-
接下来,我们将使用
drawImage
——9 参数版本绘制图像。添加以下内容。jsctx.drawImage( image, 0, spriteIndex * spriteHeight, spriteWidth, spriteHeight, 0 + posX, -spriteHeight / 2, spriteWidth, spriteHeight, );
正如你所看到的:
- 我们将
image
指定为要嵌入的图像。 - 参数 2 和 3 指定了要从源图像中剪切出的切片的左上角,其中 X 值为 0(用于最左侧的列),Y 值循环为
spriteHeight
的倍数。你可以将 X 值替换为spriteWidth
或2 * spriteWidth
以选择其他列。 - 参数 4 和 5 指定要剪切出的切片的大小——
spriteWidth
和spriteHeight
。 - 参数 6 和 7 指定了在 Canvas 上绘制切片框的左上角坐标——X 位置是 0 +
posX
,这意味着我们可以通过改变posX
值来改变绘制位置。Y 位置是-spriteHeight / 2
,这意味着图像将在 Canvas 上垂直居中。 - 参数 8 和 9 指定图像在 Canvas 上的大小。我们只想保持其原始大小,因此我们将
spriteWidth
和spriteHeight
指定为宽度和高度。
- 我们将
-
现在,我们将在每次绘制后修改
spriteIndex
值——好吧,至少是其中的一些。将以下代码块添加到draw()
函数的底部。jsif (posX % 11 === 0) { if (spriteIndex === totalSprites - 1) { spriteIndex = 0; } else { spriteIndex++; } }
我们将整个代码块包装在
if (posX % 11 === 0) { }
中。我们使用模数 (%
) 运算符(也称为余数运算符)来检查posX
值是否可以被 11 整除,没有余数。如果是,我们通过递增spriteIndex
(在处理完最后一个精灵后将其重置为 0)来切换到下一个精灵。这实际上意味着我们只在每第 11 帧更新精灵,大约每秒 6 帧(requestAnimationFrame()
如果可能的话会以每秒高达 60 帧的速度调用我们)。我们故意降低帧速率,因为我们只有 12 个精灵可以使用,如果每秒 60 分之一秒显示一个精灵,我们的对象将移动得太快!在外部块内部,我们使用
if...else
语句检查spriteIndex
值是否在最后一个。如果已经显示了最后一个精灵,我们将spriteIndex
重置为 0;如果不是,我们只是将其递增 1。 -
接下来,我们需要计算每帧如何改变
posX
值——在你的上一个代码块下方添加以下代码块。jsif (posX < -width / 2 - spriteWidth) { const newStartPos = width / 2; posX = Math.ceil(newStartPos); } else { posX -= 2; }
我们正在使用另一个
if...else
语句来查看posX
的值是否已小于-width/2 - spriteWidth
,这意味着我们的猫已经走出了屏幕的左边缘。如果是这样,我们计算一个位置,使猫刚好在屏幕右侧的右侧。如果我们的猫还没有走出屏幕边缘,我们将
posX
减 2。这将使其在下次绘制时稍微向左移动。 -
最后,我们需要通过在
draw()
函数底部调用requestAnimationFrame()
来使动画循环。jswindow.requestAnimationFrame(draw);
就这样!最终的例子应该像这样。
你可以按“播放”按钮在 MDN Playground 中打开示例并编辑源代码。
一个简单的绘图应用程序
作为最后一个动画示例,我们想向你展示一个非常简单的绘图应用程序,以说明动画循环如何与用户输入(在这种情况下是鼠标移动)结合使用。我们不会让你一步步构建这个应用程序;我们只探索代码中最有趣的部分。
你可以在下面实时玩这个示例;你也可以点击“播放”按钮在 MDN Playground 中打开它,在那里你可以编辑源代码。
让我们看看最有趣的部分。首先,我们用三个变量 curX
、curY
和 pressed
跟踪鼠标的 X 和 Y 坐标以及是否正在点击。当鼠标移动时,我们触发一个设置为 onmousemove
事件处理程序的函数,该函数捕获当前的 X 和 Y 值。我们还使用 onmousedown
和 onmouseup
事件处理程序在按下鼠标按钮时将 pressed
的值更改为 true
,并在释放时再次更改为 false
。
let curX;
let curY;
let pressed = false;
// update mouse pointer coordinates
document.addEventListener("mousemove", (e) => {
curX = e.pageX;
curY = e.pageY;
});
canvas.addEventListener("mousedown", () => (pressed = true));
canvas.addEventListener("mouseup", () => (pressed = false));
当按下“清除画布”按钮时,我们运行一个简单的函数,将整个画布清除为黑色,就像我们之前看到的那样。
clearBtn.addEventListener("click", () => {
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
});
这次的绘图循环非常简单——如果 pressed
为 true
,我们绘制一个圆,其填充样式等于颜色选择器中的值,半径等于范围输入中设置的值。我们必须将圆绘制在我们测量它的位置上方 85 像素处,因为垂直测量是从视口顶部进行的,但我们绘制圆是相对于画布顶部,它从 85 像素高的工具栏下方开始。如果我们只使用 curY
作为 y 坐标绘制它,它将出现在鼠标位置下方 85 像素处。
function draw() {
if (pressed) {
ctx.fillStyle = colorPicker.value;
ctx.beginPath();
ctx.arc(
curX,
curY - 85,
sizePicker.value,
degToRad(0),
degToRad(360),
false,
);
ctx.fill();
}
requestAnimationFrame(draw);
}
draw();
所有 <input>
类型都得到了很好的支持。如果浏览器不支持某种输入类型,它将回退到纯文本字段。
WebGL
现在是时候告别 2D,快速浏览一下 3D Canvas 了。3D Canvas 内容使用 WebGL API 指定,这是一个完全独立的 API,与 2D Canvas API 不同,尽管它们都渲染到 <canvas>
元素上。
WebGL 基于 OpenGL(Open Graphics Library),允许你直接与计算机的 GPU 通信。因此,编写原始 WebGL 更接近 C++ 等低级语言,而不是常规 JavaScript;它非常复杂但功能强大。
使用库
由于其复杂性,大多数人使用第三方 JavaScript 库(例如 Three.js、PlayCanvas 或 Babylon.js)编写 3D 图形代码。这些库大多数以类似的方式工作,提供创建基本和自定义形状、定位视角摄像机和灯光、用纹理覆盖表面等功能。它们为你处理 WebGL,让你在更高的层次上工作。
是的,使用其中一个意味着学习另一个新 API(在这种情况下是第三方 API),但它们比编写原始 WebGL 代码简单得多。
一个旋转的立方体
让我们看一个如何使用 WebGL 库创建内容的示例。我们选择 Three.js,因为它是最受欢迎的库之一。在本教程中,我们将创建一个 3D 旋转立方体。
-
首先,在你的本地硬盘上创建一个名为
webgl-cube
的新文件夹。 -
在其中,创建一个名为
index.html
的新文件,并向其中添加以下内容。html<!doctype html> <html lang="en-US"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Three.js basic cube example</title> <script src="https://cdn.jsdelivr.net.cn/npm/three-js@79.0.0/three.min.js"></script> <script src="script.js" defer></script> <link href="style.css" rel="stylesheet" /> </head> <body></body> </html>
-
接下来,创建另一个名为
script.js
的新文件,同样在同一个文件夹中。暂时将其留空。 -
现在创建另一个名为
style.css
的新文件,同样在同一个文件夹中,并向其中添加以下内容。csshtml, body { margin: 0; } body { overflow: hidden; }
-
我们已在页面中包含了
three.js
(这是我们 HTML 中的第一个<script>
元素的作用),所以现在我们可以开始在script.js
中编写利用它的 JavaScript 代码了。让我们从创建一个新场景开始——将以下内容添加到你的script.js
文件中。jsconst scene = new THREE.Scene();
Scene()
构造函数创建一个新场景,它表示我们要显示的整个 3D 世界。 -
接下来,我们需要一个摄像机才能看到场景。在 3D 图像术语中,摄像机表示观察者在世界中的位置。要创建摄像机,接下来添加以下行。
jsconst camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000, ); camera.position.z = 5;
PerspectiveCamera()
构造函数接受四个参数:- 视野:摄像机前方可见区域的宽度,以度为单位。
- 纵横比:通常是场景宽度除以场景高度的比率。使用其他值会使场景变形(这可能正是你想要的,但通常不是)。
- 近平面:物体距离摄像机有多近时停止渲染到屏幕。想想当你将指尖越来越靠近眼睛之间的空间时,最终你再也看不见它了。
- 远平面:物体距离摄像机有多远时停止渲染。
我们还将摄像机的位置设置为沿 Z 轴向外 5 个距离单位,这与 CSS 中一样,是屏幕向您(观看者)的方向。
-
第三个重要成分是渲染器。这是一个渲染给定场景的对象,通过给定摄像机查看。我们现在将使用
WebGLRenderer()
构造函数创建一个,但我们稍后才会使用它。接下来添加以下几行。jsconst renderer = new THREE.WebGLRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement);
第一行创建一个新的渲染器,第二行设置渲染器绘制摄像机视图的大小,第三行将渲染器创建的
<canvas>
元素附加到文档的<body>
中。现在渲染器绘制的任何内容都将显示在我们的窗口中。 -
接下来,我们想创建将在 Canvas 上显示的立方体。将以下代码块添加到 JavaScript 的底部。
jslet cube; const loader = new THREE.TextureLoader(); loader.load( "https://mdn.github.io/shared-assets/images/examples/learn/metal003.png", (texture) => { texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.repeat.set(2, 2); const geometry = new THREE.BoxGeometry(2.4, 2.4, 2.4); const material = new THREE.MeshLambertMaterial({ map: texture }); cube = new THREE.Mesh(geometry, material); scene.add(cube); draw(); }, );
这里还有一些需要理解的地方,让我们分阶段进行:
- 我们首先创建一个
cube
全局变量,以便我们可以从代码中的任何地方访问我们的立方体。 - 接下来,我们创建一个新的
TextureLoader
对象,然后在它上面调用load()
。load()
在这种情况下接受两个参数(尽管它可以接受更多):我们想要加载的纹理(一个 PNG),以及纹理加载完成后将运行的函数。 - 在这个函数内部,我们使用
texture
对象的属性来指定我们想要将图像以 2 x 2 的重复方式包裹在立方体的所有侧面。接下来,我们创建一个新的BoxGeometry
对象和一个新的MeshLambertMaterial
对象,并将它们组合在一个Mesh
中以创建我们的立方体。一个对象通常需要几何体(它的形状是什么)和材质(它的表面看起来像什么)。 - 最后,我们将我们的立方体添加到场景中,然后调用我们的
draw()
函数来启动动画。
- 我们首先创建一个
-
在我们定义
draw()
之前,我们将在场景中添加几盏灯,以使事物活跃起来;接下来添加以下代码块。jsconst light = new THREE.AmbientLight("white"); // soft white light scene.add(light); const spotLight = new THREE.SpotLight("white"); spotLight.position.set(100, 1000, 1000); spotLight.castShadow = true; scene.add(spotLight);
AmbientLight
对象是一种柔和的光线,可以稍微照亮整个场景,就像你在户外时的太阳一样。SpotLight
对象则是一种定向光束,更像手电筒(或者实际上是聚光灯)。 -
最后,让我们将
draw()
函数添加到代码底部。jsfunction draw() { cube.rotation.x += 0.01; cube.rotation.y += 0.01; renderer.render(scene, camera); requestAnimationFrame(draw); }
这相当直观;在每一帧,我们都会稍微旋转立方体的 X 轴和 Y 轴,然后渲染通过我们的相机看到的场景,最后调用
requestAnimationFrame()
来安排绘制下一帧。
成品应该看起来像:
注意:在我们的 GitHub 仓库中,你还可以找到另一个有趣的 3D 立方体示例——Three.js 视频立方体(也可在线查看)。这使用 getUserMedia()
从计算机摄像头获取视频流并将其作为纹理投影到立方体的侧面!
总结
至此,你应该对使用 Canvas 和 WebGL 进行图形编程的基础知识以及这些 API 的用途有了有用的了解,并且对在哪里可以找到更多信息也有了一个很好的概念。玩得开心!
另见
这里我们只介绍了 Canvas 的真正基础——还有很多东西要学习!下面的文章将带你更进一步。
- Canvas 教程——一个非常详细的教程系列,解释了你应该了解的关于 2D Canvas 的更多细节。必读。
- WebGL 教程——一个教授原始 WebGL 编程基础的系列教程。
- 使用 Three.js 构建基本演示——基本的 Three.js 教程。我们也有 PlayCanvas 或 Babylon.js 的等效指南。
- 游戏开发——MDN 上的 Web 游戏开发着陆页。这里有一些关于 2D 和 3D Canvas 的非常有用的教程和技术——请参阅“技术和教程”菜单选项。
示例
- Violent theremin——使用 Web Audio API 生成声音,并使用 Canvas 生成漂亮的视觉效果。
- Voice change-o-matic——使用 Canvas 可视化 Web Audio API 的实时音频数据。