繪製圖形

瀏覽器包含一些非常強大的圖形程式設計工具,從可縮放向量圖形(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 的即時音訊資料。