绘制图形

浏览器包含一些非常强大的图形编程工具,从可缩放矢量图形(SVG)语言到在 HTML <canvas> 元素上绘图的 API(参见 Canvas APIWebGL)。本文将介绍 canvas,并提供进一步的资源供您了解更多信息。

先决条件 JavaScript 基础知识(参见 第一步构建块JavaScript 对象)、客户端 API 基础知识
目标 学习使用 JavaScript 在 <canvas> 元素上绘图的基础知识。

Web 上的图形

正如我们在 HTML 多媒体和嵌入 模块中所讨论的,Web 最初只是文本,这非常无聊,因此引入了图像——首先通过 <img> 元素,后来通过 CSS 属性(如 background-imageSVG)引入。

然而,这仍然不够。虽然您可以使用 CSSJavaScript 来动画化(以及以其他方式操作)SVG 矢量图像——因为它们由标记表示——但仍然无法对位图图像执行相同的操作,并且可用的工具相当有限。Web 仍然无法有效地创建动画、游戏、3D 场景和其他通常由 C++ 或 Java 等较低级语言处理的需求。

当浏览器开始在 2004 年支持 <canvas> 元素和相关的 Canvas API 时,这种情况开始改善。正如您将在下面看到的,canvas 提供了一些用于创建 2D 动画、游戏、数据可视化和其他类型应用程序的有用工具,尤其是在与 Web 平台提供的其他一些 API 结合使用时,但可能难以或无法使其无障碍。

下面的示例显示了一个简单的基于 canvas 的 2D 弹跳球动画,我们最初在 介绍 JavaScript 对象 模块中遇到了它。

大约在 2006 年至 2007 年,Mozilla 开始着手开发实验性的 3D canvas 实现。这成为了 WebGL,它在浏览器供应商中获得了关注,并在 2009 年至 2010 年左右实现了标准化。WebGL 允许您在 Web 浏览器中创建真实的 3D 图形;下面的示例显示了一个简单的旋转 WebGL 立方体。

本文将主要关注 2D canvas,因为原始 WebGL 代码非常复杂。但是,我们将展示如何使用 WebGL 库更轻松地创建 3D 场景,您可以在其他地方找到涵盖原始 WebGL 的教程——请参阅 WebGL 入门

主动学习:开始使用 <canvas>

如果要在网页上创建 2D 或 3D 场景,则需要从 HTML <canvas> 元素开始。此元素用于定义页面上将绘制图像的区域。这就像在页面上包含该元素一样简单。

html
<canvas width="320" height="240"></canvas>

这将在页面上创建一个大小为 320x240 像素的画布。

您应该在 <canvas> 标记内放置一些后备内容。这应该向不支持 canvas 的浏览器用户或屏幕阅读器用户描述 canvas 内容。

html
<canvas width="320" height="240">
  <p>Description of the canvas for those unable to view it.</p>
</canvas>

后备内容应为 canvas 内容提供有用的替代内容。例如,如果您正在渲染股票价格的不断更新的图表,则后备内容可以是最新股票图表的静态图像,并使用 alt 文本以文本或指向各个股票页面的链接列表的形式说明价格。

注意:屏幕阅读器无法访问 Canvas 内容。将描述性文本作为 aria-label 属性的值直接包含在 canvas 元素本身中,或者包含放置在 <canvas> 标记的开始和结束标记之间的后备内容。Canvas 内容不是 DOM 的一部分,但嵌套的后备内容是。

创建和调整画布大小

让我们首先创建自己的画布,以便我们将来在上面进行实验。

  1. 首先,创建 0_canvas_start 目录的本地副本。它包含三个文件。
    • "index.html"
    • "script.js"
    • "style.css"
  2. 打开 "index.html",并将以下代码添加到其中,紧靠在 <body> 标记的开始标记下方。
    html
    <canvas class="myCanvas">
      <p>Add suitable fallback here.</p>
    </canvas>
    
    我们已向 <canvas> 元素添加了一个 class,以便在页面上有多个画布时更容易选择它,但我们现在已删除了 widthheight 属性(如果需要,您可以添加回它们,但我们将在下面的部分中使用 JavaScript 设置它们)。没有显式宽度和高度的画布默认宽度为 300 像素,高度为 150 像素。
  3. 现在打开 "script.js" 并添加以下 JavaScript 代码行。
    js
    const canvas = document.querySelector(".myCanvas");
    const width = (canvas.width = window.innerWidth);
    const height = (canvas.height = window.innerHeight);
    
    在这里,我们将对画布的引用存储在 canvas 常量中。在第二行中,我们将一个新的常量 width 和画布的 width 属性都设置为 Window.innerWidth(这将为我们提供视口宽度)。在第三行中,我们将一个新的常量 height 和画布的 height 属性都设置为 Window.innerHeight(这将为我们提供视口高度)。因此,现在我们有一个画布,它填满了浏览器窗口的整个宽度和高度!您还会看到我们正在使用多个等号将赋值链接在一起——这在 JavaScript 中是允许的,如果您希望使多个变量都等于同一个值,这是一个好方法。我们希望使画布的宽度和高度在 width/height 变量中易于访问,因为它们是有用的值,可供以后使用(例如,如果您想在画布宽度的一半处精确绘制某些内容)。

注意:通常应使用 HTML 属性或 DOM 属性设置图像的大小,如上所述。您可以使用 CSS,但问题在于大小调整是在 canvas 渲染后完成的,就像任何其他图像一样(渲染的 canvas 只是一个图像),图像可能会变得像素化/失真。

获取 canvas 上下文和最终设置

在我们可以认为我们的 canvas 模板已完成之前,我们还需要做最后一件事。要在 canvas 上绘制,我们需要获取对绘图区域的特殊引用,称为上下文。这是使用 HTMLCanvasElement.getContext() 方法完成的,该方法对于基本用法将单个字符串作为参数,表示您想要检索的上下文类型。

在这种情况下,我们想要一个 2d canvas,因此在 "script.js" 中的其他 JavaScript 代码行下方添加以下代码行。

js
const ctx = canvas.getContext("2d");

注意:您可以选择的其他上下文值包括用于 WebGL 的 webgl、用于 WebGL 2 的 webgl2 等,但我们本文不需要这些值。

就是这样——我们的画布现在已准备好进行绘制!ctx 变量现在包含一个 CanvasRenderingContext2D 对象,并且 canvas 上的所有绘图操作都将涉及操作此对象。

在我们继续之前,让我们再做最后一件事。我们将 canvas 背景颜色设置为黑色,让您初步体验 canvas API。在 JavaScript 代码的底部添加以下几行。

js
ctx.fillStyle = "rgb(0 0 0)";
ctx.fillRect(0, 0, width, height);

在这里,我们使用 canvas 的 fillStyle 属性设置填充颜色(这与 CSS 属性一样接受 颜色值),然后使用 fillRect 方法绘制一个覆盖 canvas 整个区域的矩形(前两个参数是矩形左上角的坐标;后两个参数是要绘制矩形的宽度和高度——我们告诉过您这些 widthheight 变量很有用)!

好的,我们的模板完成了,是时候继续了。

2D canvas 基础

如上所述,所有绘图操作都是通过操作 CanvasRenderingContext2D 对象(在本例中为 ctx)来完成的。许多操作需要给出坐标来精确确定绘制某个内容的位置——canvas 的左上角是点 (0, 0),水平 (x) 轴从左到右延伸,垂直 (y) 轴从上到下延伸。

Gridded graph paper with small squares covering its area with a steelblue square in the middle. The top left corner of the canvas is point (0, 0) of the canvas x-axis and y-axis. The horizontal (x) axis runs from left to right denoting the width, and the vertical (y) axis runs from top to bottom denotes the height. The top left corner of the blue square is labeled as being a distance of x units from the y-axis and y units from the x-axis.

绘制形状通常是使用矩形形状基元,或者沿着特定路径描线然后填充形状来完成的。下面我们将展示如何执行这两项操作。

简单矩形

让我们从一些简单的矩形开始。

  1. 首先,复制您新编码的 canvas 模板(或者如果您没有按照上述步骤操作,则创建 1_canvas_template 目录的本地副本)。
  2. 接下来,将以下几行添加到 JavaScript 代码的底部。
    js
    ctx.fillStyle = "rgb(255 0 0)";
    ctx.fillRect(50, 50, 100, 150);
    
    如果保存并刷新,您应该会看到 canvas 上出现了一个红色矩形。它的左上角距 canvas 边缘的顶部和左侧 50 像素(由前两个参数定义),宽度为 100 像素,高度为 150 像素(由第三和第四个参数定义)。
  3. 让我们再添加一个矩形——这次是绿色的。在 JavaScript 代码的底部添加以下内容。
    js
    ctx.fillStyle = "rgb(0 255 0)";
    ctx.fillRect(75, 75, 100, 100);
    
    保存并刷新,您将看到新的矩形。这提出了一个重要观点:绘制矩形、线条等图形操作的执行顺序与其出现的顺序相同。可以将其想象成粉刷墙壁,每一层油漆都会重叠,甚至可能隐藏下面的油漆。您无法更改此操作,因此必须仔细考虑绘制图形的顺序。
  4. 请注意,您可以通过指定半透明颜色来绘制半透明图形,例如使用 rgb()。 “alpha 通道”定义颜色的透明度。其值越高,它遮挡后面内容的能力就越强。将以下内容添加到您的代码中。
    js
    ctx.fillStyle = "rgb(255 0 255 / 75%)";
    ctx.fillRect(25, 100, 175, 50);
    
  5. 现在尝试自己绘制一些矩形;尽情享受吧!

描边和线宽

到目前为止,我们已经了解了如何绘制填充矩形,但您也可以绘制仅为轮廓的矩形(在图形设计中称为描边)。要设置所需的描边颜色,请使用 strokeStyle 属性;绘制描边矩形是使用 strokeRect 完成的。

  1. 将以下内容添加到前面的示例中,同样在前面的 JavaScript 代码行下方。
    js
    ctx.strokeStyle = "rgb(255 255 255)";
    ctx.strokeRect(25, 25, 175, 200);
    
  2. 描边的默认宽度为 1 像素;您可以调整 lineWidth 属性值以更改此值(它接受一个表示描边宽度(以像素为单位)的数字)。在前面的两行代码之间添加以下代码行。
    js
    ctx.lineWidth = 5;
    

现在您应该会看到白色轮廓变得更粗了!现在就到这里。此时,您的示例应如下所示。

注意:完成的代码可在 GitHub 上获取,网址为 2_canvas_rectangles

绘制路径

如果要绘制比矩形更复杂的内容,则需要绘制路径。基本上,这涉及编写代码以精确指定笔应在 canvas 上沿哪个路径移动以描绘所需的形状。Canvas 包含用于绘制直线、圆形、贝塞尔曲线等的功能。

让我们从创建 canvas 模板(1_canvas_template)的新副本开始,以便在其中绘制新的示例。

我们将在以下所有部分中使用一些常见的方法和属性。

  • beginPath() — 在画布上笔当前所在的位置开始绘制路径。在新画布上,笔从 (0, 0) 开始。
  • moveTo() — 将笔移动到画布上的另一个点,而不记录或描绘线条;笔“跳跃”到新位置。
  • fill() — 通过填充迄今为止所描绘的路径来绘制填充形状。
  • stroke() — 通过沿着迄今为止所绘制的路径绘制描边来绘制轮廓形状。
  • 您还可以将诸如 lineWidthfillStyle/strokeStyle 等功能与路径以及矩形一起使用。

一个典型的简单路径绘制操作看起来像这样

js
ctx.fillStyle = "rgb(255 0 0)";
ctx.beginPath();
ctx.moveTo(50, 50);
// draw your path
ctx.fill();

绘制线条

让我们在画布上绘制一个等边三角形。

  1. 首先,将以下辅助函数添加到代码底部。这将度数转换为弧度,这很有用,因为无论何时需要在 JavaScript 中提供角度值,它几乎总是以弧度为单位,但人类通常以度数为单位思考。
    js
    function degToRad(degrees) {
      return (degrees * Math.PI) / 180;
    }
    
  2. 接下来,通过在之前添加内容的下方添加以下内容开始您的路径;在这里,我们为三角形设置颜色,开始绘制路径,然后将笔移动到 (50, 50) 而不绘制任何内容。那就是我们将开始绘制三角形的位置。
    js
    ctx.fillStyle = "rgb(255 0 0)";
    ctx.beginPath();
    ctx.moveTo(50, 50);
    
  3. 现在将以下行添加到脚本底部
    js
    ctx.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() 期望以弧度为单位的输入值。
  4. 计算出高度后,我们绘制另一条线到 (100, 50 + triHeight)。X 坐标很简单;它必须位于我们设置的前两个 X 值的中间。另一方面,Y 值必须是 50 加上三角形高度,因为我们知道三角形的顶部距画布顶部 50 像素。
  5. 下一行绘制一条返回到三角形起点的线。
  6. 最后,我们运行 ctx.fill() 以结束路径并填充形状。

绘制圆形

现在让我们看看如何在画布上绘制圆形。这是使用 arc() 方法完成的,该方法在指定点绘制圆形的所有或部分。

  1. 让我们在画布上添加一个圆弧——将以下内容添加到代码底部
    js
    ctx.fillStyle = "rgb(0 0 255)";
    ctx.beginPath();
    ctx.arc(150, 106, 50, degToRad(0), degToRad(360), false);
    ctx.fill();
    
    arc() 接受六个参数。前两个指定圆弧中心的坐标(分别为 X 和 Y)。第三个是圆的半径,第四个和第五个是绘制圆形的起始和结束角度(因此指定 0 和 360 度会给我们一个完整的圆),第六个参数定义圆是否应逆时针绘制(逆时针)或顺时针绘制(false 为顺时针)。

    注意:0 度为水平向右。

  2. 让我们尝试添加另一个圆弧
    js
    ctx.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() 之前,我们绘制一条到圆心的线。这意味着我们得到了相当漂亮的吃豆人风格的剪切效果。如果您删除此行(试试看!)然后重新运行代码,您将只获得圆形边缘在圆弧的起始点和结束点之间被切掉的部分。这说明了画布的另一个重要点——如果您尝试填充不完整的路径(即未闭合的路径),浏览器会在起始点和结束点之间填充一条直线,然后将其填充。

暂时就这样了;您的最终示例应如下所示

注意:完整的代码可在 GitHub 上找到,网址为 3_canvas_paths

注意:要了解有关高级路径绘制功能(如贝塞尔曲线)的更多信息,请查看我们的 使用画布绘制形状 教程。

文本

Canvas 还具有绘制文本的功能。让我们简要探讨一下这些功能。首先,制作画布模板的另一个全新副本(1_canvas_template),并在其中绘制新示例。

文本使用两种方法绘制

这两者在其基本用法中都采用三个属性:要绘制的文本字符串以及开始绘制文本的点的 X 和 Y 坐标。这相当于文本框左下角(从字面上看,围绕您绘制的文本的框),这可能会让您感到困惑,因为其他绘图操作倾向于从左上角开始——请记住这一点。

还有一些属性可以帮助控制文本渲染,例如 font,它允许您指定字体系列、大小等。它以与 CSS font 属性相同的语法作为其值。

屏幕阅读器无法访问画布内容。绘制到画布上的文本无法供 DOM 使用,但必须使其可用才能使其可访问。在此示例中,我们将文本包含为 aria-label 的值。

尝试将以下代码块添加到 JavaScript 底部

js
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");

在这里,我们绘制了两行文本,一行是轮廓,另一行是描边。最终示例应如下所示

注意:完整的代码可在 GitHub 上找到,网址为 4_canvas_text

玩一玩,看看你能想出什么!您可以在 绘制文本 中找到有关画布文本可用选项的更多信息。

将图像绘制到画布上

可以将外部图像渲染到画布上。这些可以是简单的图像、视频中的帧或其他画布的内容。目前,我们只关注在画布上使用一些简单图像的情况。

  1. 与之前一样,制作画布模板的另一个全新副本(1_canvas_template),并在其中绘制新示例。图像使用 drawImage() 方法绘制到画布上。最简单的版本采用三个参数——要渲染的图像的引用以及图像左上角的 X 和 Y 坐标。
  2. 让我们首先获取一个要嵌入画布的图像源。将以下行添加到 JavaScript 底部
    js
    const image = new Image();
    image.src = "firefox.png";
    
    在这里,我们使用 Image() 构造函数创建了一个新的 HTMLImageElement 对象。返回的对象与获取对现有 <img> 元素的引用的情况相同。然后,我们将它的 src 属性设置为我们的 Firefox 徽标图像。此时,浏览器开始加载图像。
  3. 我们现在可以尝试使用 drawImage() 嵌入图像,但我们需要确保图像文件已先加载,否则代码将失败。我们可以使用 load 事件来实现这一点,该事件仅在图像加载完成后才会触发。在前面一个代码块下方添加以下代码块
    js
    image.addEventListener("load", () => ctx.drawImage(image, 20, 20));
    
    如果您现在在浏览器中加载示例,您应该会看到图像嵌入到画布中。
  4. 但还有更多!如果我们只想显示图像的一部分,或调整其大小呢?我们可以使用更复杂的 drawImage() 版本同时做到这两点。像这样更新您的 ctx.drawImage()
    js
    ctx.drawImage(image, 20, 20, 185, 175, 50, 50, 185, 175);
    
    • 第一个参数与之前一样,是图像引用。
    • 参数 2 和 3 定义要从加载的图像中剪切出的区域的左上角的坐标,相对于图像本身的左上角。第一个参数左侧或第二个参数上方的任何内容都不会被绘制。
    • 参数 4 和 5 定义我们要从加载的原始图像中剪切出的区域的宽度和高度。
    • 参数 6 和 7 定义要绘制图像剪切部分的左上角的坐标,相对于画布的左上角。
    • 参数 8 和 9 定义绘制图像剪切区域的宽度和高度。在这种情况下,我们指定了与原始切片相同的尺寸,但您可以通过指定不同的值来调整其大小。
  5. 当图像有意义地更新时,可访问描述 也必须更新。
    js
    canvas.setAttribute("aria-label", "Firefox Logo");
    

最终示例应如下所示

注意:完整的代码可在 GitHub 上找到,网址为 5_canvas_images

循环和动画

到目前为止,我们已经介绍了 2D 画布的一些非常基本的使用方法,但实际上,除非您以某种方式更新或动画化画布,否则您将无法体验到画布的全部功能。毕竟,画布确实提供了可脚本化的图像!如果您不打算更改任何内容,那么您不妨只使用静态图像,并节省所有工作。

创建循环

在画布中使用循环很有趣——您可以像任何其他 JavaScript 代码一样,在 for(或其他类型)循环内运行画布命令。

让我们构建一个简单的示例。

  1. 制作画布模板的另一个全新副本(1_canvas_template)并在代码编辑器中打开它。
  2. 在 JavaScript 文件的底部添加以下代码行。这包含一个新的方法,translate(),它可以移动画布的原点。
    js
    ctx.translate(width / 2, height / 2);
    
    这会导致坐标原点 (0, 0) 移动到画布的中心,而不是位于左上角。这在许多情况下非常有用,就像这个例子一样,我们希望我们的设计相对于画布的中心绘制。
  3. 现在在 JavaScript 文件的底部添加以下代码
    js
    function 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;
    
    for (let i = 0; i < length; i++) {}
    
    这里我们实现了与上面三角形示例中相同的 degToRad() 函数,一个返回给定下限和上限之间随机数的 rand() 函数,lengthmoveOffset 变量(稍后我们将了解更多信息),以及一个空的 for 循环。
  4. 这里的想法是在 for 循环内部在画布上绘制一些内容,并在每次迭代时对其进行更新,以便我们能够创建一些有趣的东西。在 for 循环内部添加以下代码
    js
    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(),它允许我们旋转整个画布!我们在绘制下一个三角形之前将其旋转 5 度。

就是这样!最终示例应如下所示

此时,我们希望鼓励您尝试此示例并使其成为您自己的!例如

  • 绘制矩形或弧线而不是三角形,甚至嵌入图像。
  • 尝试使用 lengthmoveOffset 值。
  • 使用上面包含但未使用过的 rand() 函数引入一些随机数。

注意:完整的代码可在 GitHub 上找到,网址为 6_canvas_for_loop

动画

我们上面构建的循环示例很有趣,但对于任何严肃的画布应用程序(例如游戏和实时可视化),您都需要一个持续运行的循环。如果您将画布想象成一部电影,您确实希望显示屏在每一帧上更新以显示更新的视图,理想的刷新率为每秒 60 帧,以便运动看起来对人眼来说非常流畅。

有一些 JavaScript 函数允许您重复运行函数,每秒运行多次,对于我们的目的,最好的是 window.requestAnimationFrame()。它接受一个参数——您希望为每一帧运行的函数的名称。浏览器下次准备好更新屏幕时,您的函数将被调用。如果该函数将新更新绘制到动画中,然后在函数结束之前再次调用 requestAnimationFrame(),则动画循环将继续运行。当您停止调用 requestAnimationFrame() 或在调用 requestAnimationFrame() 后但在调用该帧之前调用 window.cancelAnimationFrame() 时,循环结束。

注意:当您完成使用动画时,最好从您的主代码中调用 cancelAnimationFrame(),以确保没有更新仍在等待运行。

浏览器会处理复杂的细节,例如使动画以一致的速度运行,并且不会浪费资源来动画化不可见的内容。

为了了解它的工作原理,让我们快速再次查看我们的弹球示例(查看实时效果,还可以查看 源代码)。使所有内容保持移动的循环代码如下所示

js
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) 来运行动画的下一帧,一次又一次。

请注意,在每一帧上,我们都完全清除画布并重新绘制所有内容。对于存在的每个球,我们都会绘制它,更新其位置,并检查它是否与任何其他球发生碰撞。一旦您将图形绘制到画布上,就没有办法像操作 DOM 元素那样单独操作该图形。您无法在画布上移动每个球,因为一旦绘制,它就成为画布的一部分,而不是一个单独的可访问的元素或对象。相反,您必须擦除并重新绘制,可以通过擦除整个帧并重新绘制所有内容,或者通过编写代码来准确知道哪些部分需要擦除,并且只擦除和重新绘制画布所需的最小区域来实现。

优化图形动画是编程的整个专业领域,有很多巧妙的技术可用。不过,对于我们的示例来说,这些超出了我们的需要!

一般来说,执行画布动画的过程涉及以下步骤

  1. 清除画布内容(例如,使用 fillRect()clearRect())。
  2. 使用 save() 保存状态(如果需要)——当您希望在继续之前保存已在画布上更新的设置时,需要这样做,这对于更高级的应用程序很有用。
  3. 绘制您要设置动画的图形。
  4. 使用 restore() 还原您在步骤 2 中保存的设置
  5. 调用 requestAnimationFrame() 以安排绘制动画的下一帧。

注意:我们这里不介绍 save()restore(),但它们在我们 变换 教程(及其后续教程)中得到了很好的解释。

简单的角色动画

现在让我们创建自己的简单动画——我们将从某个非常棒的复古电脑游戏中获取一个角色,让他穿过屏幕。

  1. 制作画布模板的另一个全新副本(1_canvas_template)并在代码编辑器中打开它。
  2. 更新内部 HTML 以反映图像
    html
    <canvas class="myCanvas">
      <p>A man walking.</p>
    </canvas>
    
  3. 在 JavaScript 文件的底部,添加以下代码行以再次使坐标原点位于画布的中间
    js
    ctx.translate(width / 2, height / 2);
    
  4. 现在让我们创建一个新的 HTMLImageElement 对象,将其 src 设置为我们想要加载的图像,并添加一个 onload 事件处理程序,当图像加载完成后,它将触发 draw() 函数
    js
    const image = new Image();
    image.src = "walk-right.png";
    image.onload = draw;
    
  5. 现在我们将添加一些变量来跟踪要在屏幕上绘制精灵的位置,以及我们想要显示的精灵编号。
    js
    let sprite = 0;
    let posX = 0;
    
    让我们解释一下精灵图图像(我们已从 Mike Thomas 的 使用 CSS 动画的行走循环 CodePen 中礼貌地借用)。该图像如下所示:一个精灵图,包含六个像素化角色的精灵图像,从右侧的不同时刻向前走一步。该角色穿着白色衬衫,带有天蓝色纽扣,黑色裤子和黑色鞋子。每个精灵宽 102 像素,高 148 像素。 它包含六个构成整个行走序列的精灵——每个精灵宽 102 像素,高 148 像素。为了清晰地显示每个精灵,我们将不得不使用 drawImage() 从精灵图中切出一个单独的精灵图像,并仅显示该部分,就像我们上面对 Firefox 徽标所做的那样。切片的 X 坐标必须是 102 的倍数,Y 坐标始终为 0。切片大小始终为 102x148 像素。
  6. 现在让我们在代码底部插入一个空的 draw() 函数,准备填充一些代码
    js
    function draw() {}
    
  7. 本节中的其余代码位于 draw() 内部。首先,添加以下代码行,它将清除画布以准备绘制每一帧。请注意,我们必须将矩形的左上角指定为 -(width/2), -(height/2),因为我们之前将原点位置指定为 width/2, height/2
    js
    ctx.fillRect(-(width / 2), -(height / 2), width, height);
    
  8. 接下来,我们将使用 drawImage 绘制我们的图像——9 参数版本。添加以下内容
    js
    ctx.drawImage(image, sprite * 102, 0, 102, 148, 0 + posX, -74, 102, 148);
    
    如您所见
    • 我们将 image 指定为要嵌入的图像。
    • 参数 2 和 3 指定要从源图像中切出的切片的左上角,其中 X 值为 sprite 乘以 102(其中 sprite 是 0 到 5 之间的精灵编号),Y 值始终为 0。
    • 参数 4 和 5 指定要切出的切片的大小——102 像素 x 148 像素。
    • 参数 6 和 7 指定要将切片绘制到画布上的框的左上角——X 位置为 0 + posX,这意味着我们可以通过更改 posX 值来更改绘制位置。
    • 参数 8 和 9 指定画布上图像的大小。我们只想保留其原始大小,因此我们将 102 和 148 指定为宽度和高度。
  9. 现在,我们将更改每次绘制后的 sprite 值——好吧,至少在其中一些之后。将以下代码块添加到 draw() 函数的底部
    js
    if (posX % 13 === 0) {
      if (sprite === 5) {
        sprite = 0;
      } else {
        sprite++;
      }
    }
    
    我们将整个代码块包装在if (posX % 13 === 0) { }中。我们使用模运算符(%)(也称为余数运算符)来检查posX的值是否可以被13整除且没有余数。如果是,我们通过递增sprite来移动到下一个精灵(在我们完成精灵#5后,回绕到0)。这实际上意味着我们仅在每第13帧更新精灵,或者大约每秒5帧(requestAnimationFrame()如果可能,每秒最多调用我们60次)。我们故意降低帧率,因为我们只有六个精灵可以使用,如果我们每60分之一秒显示一个精灵,我们的角色移动速度会太快!在外部代码块内,我们使用if...else语句来检查sprite的值是否为5(最后一个精灵,因为精灵编号从0到5)。如果我们已经显示了最后一个精灵,我们将sprite重置回0;否则,我们只将其递增1。
  10. 接下来,我们需要计算出如何在每一帧更改posX的值——将以下代码块添加到您上一个代码块的下方。
    js
    if (posX > width / 2) {
      let newStartPos = -(width / 2 + 102);
      posX = Math.ceil(newStartPos);
      console.log(posX);
    } else {
      posX += 2;
    }
    
    我们正在使用另一个if...else语句来查看posX的值是否已大于width/2,这意味着我们的角色已经走出了屏幕的右侧边缘。如果是,我们计算一个位置,该位置将使角色正好位于屏幕左侧的左侧。如果我们的角色尚未走出屏幕边缘,我们将posX递增2。这将使他在我们下次绘制他时向右移动一点。
  11. 最后,我们需要通过在draw()函数底部调用requestAnimationFrame()来使动画循环
    js
    window.requestAnimationFrame(draw);
    

就是这样!最终示例应如下所示

注意:完整的代码可在GitHub上找到,网址为7_canvas_walking_animation

一个简单的绘图应用程序

作为最后的动画示例,我们想向您展示一个非常简单的绘图应用程序,以说明如何将动画循环与用户输入(在本例中为鼠标移动)结合起来。我们不会让您一步步构建它;我们只会探讨代码中最有趣的部分。

此示例可在GitHub上找到,网址为8_canvas_drawing_app,您可以在下面进行实时体验。

让我们看看最有趣的部分。首先,我们使用三个变量跟踪鼠标的X和Y坐标以及是否被点击:curXcurYpressed。当鼠标移动时,我们触发一个设置为onmousemove事件处理程序的函数,该函数捕获当前的X和Y值。我们还使用onmousedownonmouseup事件处理程序,当鼠标按钮被按下时将pressed的值更改为true,当释放时再更改回false

js
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));

当按下“清除画布”按钮时,我们运行一个简单的函数,将整个画布清除回黑色,就像我们之前看到的那样。

js
clearBtn.addEventListener("click", () => {
  ctx.fillStyle = "rgb(0 0 0)";
  ctx.fillRect(0, 0, width, height);
});

这次的绘图循环非常简单——如果pressedtrue,我们绘制一个圆圈,其填充样式等于颜色选择器中的值,半径等于范围输入中设置的值。我们必须在测量到的位置上方85像素处绘制圆圈,因为垂直测量是从视口顶部进行的,但我们相对于画布顶部绘制圆圈,画布顶部位于85像素高的工具栏下方。如果我们仅使用curY作为y坐标绘制它,它将出现在鼠标位置下方85像素处。

js
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画布了。3D画布内容使用WebGL API指定,它是一个与2D画布API完全独立的API,即使它们都渲染到<canvas>元素上。

WebGL基于OpenGL(开放图形库),并允许您直接与计算机的GPU通信。因此,编写原始WebGL更接近于C++等低级语言,而不是普通的JavaScript;它非常复杂,但功能强大。

使用库

由于其复杂性,大多数人使用第三方JavaScript库(例如Three.jsPlayCanvasBabylon.js)编写3D图形代码。这些库中的大多数工作方式都类似,提供创建基本形状和自定义形状、定位观察相机和灯光、用纹理覆盖表面等功能。它们为您处理WebGL,让您可以在更高级别上工作。

是的,使用其中一个意味着学习另一个新的API(在这种情况下是第三方API),但它们比编写原始WebGL简单得多。

重新创建我们的立方体

让我们看一个使用WebGL库创建事物的简单示例。我们将选择Three.js,因为它是最流行的库之一。在本教程中,我们将创建我们之前看到的3D旋转立方体。

  1. 首先,在新的文件夹中创建threejs-cube/index.html的本地副本,然后在同一文件夹中保存metal003.png的副本。这是我们稍后将用作立方体表面纹理的图像。
  2. 接下来,创建一个名为script.js的新文件,同样在与之前相同的文件夹中。
  3. 接下来,您需要安装Three.js库。您可以按照使用Three.js构建基本演示中描述的环境设置步骤操作,以便使Three.js按预期工作。
  4. 现在我们已将three.js附加到我们的页面,我们可以开始在script.js中编写使用它的JavaScript代码了。让我们首先创建一个新的场景——将以下内容添加到您的script.js文件中
    js
    const scene = new THREE.Scene();
    
    Scene()构造函数创建一个新的场景,它表示我们尝试显示的整个3D世界。
  5. 接下来,我们需要一个相机以便我们可以看到场景。在3D图像术语中,相机表示观察者在世界中的位置。要创建相机,请在接下来添加以下几行代码
    js
    const camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000,
    );
    camera.position.z = 5;
    
    PerspectiveCamera()构造函数接受四个参数
    • 视野:相机前方应在屏幕上可见的区域宽度(以度为单位)。
    • 纵横比:通常,这是场景宽度除以场景高度的比率。使用其他值会扭曲场景(这可能是您想要的,但通常不是)。
    • 近平面:对象在多靠近相机时我们才会停止将其渲染到屏幕上。想想当您将指尖越来越靠近双眼之间的空间时,最终您将无法再看到它。
    • 远平面:物体在多远离相机时才会停止渲染。
    我们还将相机的坐标位置设置为Z轴向外5个距离单位,这与CSS类似,是从屏幕向您(查看者)方向。
  6. 第三个重要的组成部分是渲染器。这是一个渲染给定场景的对象,如同通过给定相机所见。我们现在将使用WebGLRenderer()构造函数创建一个渲染器,但我们稍后再使用它。在接下来添加以下几行代码
    js
    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(renderer.domElement);
    
    第一行创建了一个新的渲染器,第二行设置了渲染器绘制相机视图的大小,第三行将渲染器创建的<canvas>元素附加到文档的<body>中。现在,渲染器绘制的任何内容都将显示在我们的窗口中。
  7. 接下来,我们要创建将在画布上显示的立方体。在JavaScript底部添加以下代码块
    js
    let cube;
    
    const loader = new THREE.TextureLoader();
    
    loader.load("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对象的属性来指定我们希望将图像的2x2重复包裹在立方体的各个面上。接下来,我们创建一个新的BoxGeometry对象和一个新的MeshLambertMaterial对象,并将它们组合在一个Mesh中以创建我们的立方体。一个对象通常需要一个几何体(它的形状)和一个材质(它的表面外观)。
    • 最后,我们将立方体添加到场景中,然后调用我们的draw()函数以开始动画。
  8. 在开始定义draw()之前,我们将向场景中添加几个灯光,以使场景更生动一些;在接下来添加以下代码块
    js
    const light = new THREE.AmbientLight("rgb(255 255 255)"); // soft white light
    scene.add(light);
    
    const spotLight = new THREE.SpotLight("rgb(255 255 255)");
    spotLight.position.set(100, 1000, 1000);
    spotLight.castShadow = true;
    scene.add(spotLight);
    
    AmbientLight对象是一种软光,可以稍微照亮整个场景,就像您在户外时的阳光一样。SpotLight对象另一方面,是定向光束,更像是手电筒/火把(或实际上是聚光灯)。
  9. 最后,让我们将我们的draw()函数添加到代码的底部
    js
    function draw() {
      cube.rotation.x += 0.01;
      cube.rotation.y += 0.01;
      renderer.render(scene, camera);
    
      requestAnimationFrame(draw);
    }
    
    这相当直观;在每一帧上,我们都会围绕立方体的X轴和Y轴稍微旋转一下,然后渲染相机看到的场景,最后调用requestAnimationFrame()来安排绘制下一帧。

让我们快速回顾一下最终产品应该是什么样子

您可以在GitHub上找到完整的代码

注意:在我们的 GitHub 仓库中,您还可以找到另一个有趣的 3D 立方体示例——Three.js 视频立方体也可以在线查看)。它使用 getUserMedia() 从电脑网络摄像头获取视频流,并将其作为纹理投影到立方体的侧面!

总结

至此,您应该对使用 Canvas 和 WebGL 进行图形编程的基础知识以及您可以使用这些 API 做什么有了有用的了解,并且对在哪里获取更多信息也有了很好的了解。玩得开心!

另请参阅

在这里,我们只介绍了 canvas 的真正基础知识——还有很多东西需要学习!以下文章将带您进一步了解。

  • Canvas 教程——一个非常详细的教程系列,详细解释了您应该了解的关于 2D canvas 的知识,比这里介绍的要详细得多。必读。
  • WebGL 教程——一个教授原始 WebGL 编程基础知识的系列教程。
  • 使用 Three.js 构建基本演示——Three.js 基础教程。我们也为 PlayCanvasBabylon.js 提供了等效的指南。
  • 游戏开发——MDN 上 Web 游戏开发的登录页面。这里有一些非常有用的教程和技巧,与 2D 和 3D canvas 相关——请参阅“技巧”和“教程”菜单选项。

示例

  • 暴力电颤琴——使用 Web Audio API 生成声音,并使用 canvas 生成相应的漂亮可视化效果。
  • 变声器——使用 canvas 可视化来自 Web Audio API 的实时音频数据。