一個基本的 2D WebGL 動畫示例

在這個 WebGL 示例中,我們建立了一個 canvas,並在其中使用 WebGL 渲染一個旋轉的正方形。我們用來表示場景的座標系與 canvas 的座標系相同。也就是說,(0, 0) 位於左上角,右下角位於 (600, 460)。

旋轉正方形示例

讓我們一步步來完成旋轉正方形的示例。

頂點著色器

首先,我們來看頂點著色器。它的任務,一如既往,是將我們用於場景的座標轉換為裁剪空間座標(即,(0, 0) 位於上下文中心,每個軸從 -1.0 到 1.0 的系統,無論上下文的實際大小如何)。

html
<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec2 aVertexPosition;

  uniform vec2 uScalingFactor;
  uniform vec2 uRotationVector;

  void main() {
    vec2 rotatedPosition = vec2(
      aVertexPosition.x * uRotationVector.y +
            aVertexPosition.y * uRotationVector.x,
      aVertexPosition.y * uRotationVector.y -
            aVertexPosition.x * uRotationVector.x
    );

    gl_Position = vec4(rotatedPosition * uScalingFactor, 0.0, 1.0);
  }
</script>

主程式與我們共享屬性 aVertexPosition,這是頂點在其使用的任何座標系中的位置。我們需要轉換這些值,以便位置的兩個分量都在 -1.0 到 1.0 的範圍內。這可以透過乘以基於上下文的 縱橫比 的縮放因子來輕鬆完成。我們稍後會看到這個計算。

我們也在旋轉形狀,並可以在這裡進行,透過應用變換。我們將首先這樣做。頂點的旋轉位置是透過應用 JavaScript 程式碼中找到的 uniform uRotationVector 來計算的。

然後,透過將旋轉後的位置乘以 JavaScript 程式碼在 uScalingFactor 中提供的縮放向量來計算最終位置。由於我們是在 2D 中繪製,zw 的值分別固定為 0.0 和 1.0。

標準 WebGL 全域性變數 gl_Position 然後被設定為轉換和旋轉後的頂點位置。

片段著色器

接下來是片段著色器。它的作用是返回正在渲染的形狀中每個畫素的顏色。由於我們繪製的是一個沒有紋理、沒有光照的實心物件,這非常簡單。

html
<script id="fragment-shader" type="x-shader/x-fragment">
  #ifdef GL_ES
    precision highp float;
  #endif

  uniform vec4 uGlobalColor;

  void main() {
    gl_FragColor = uGlobalColor;
  }
</script>

首先指定 float 型別的精度,這是必需的。然後我們將全域性變數 gl_FragColor 設定為 uniform 變數 uGlobalColor 的值,它由 JavaScript 程式碼設定為用於繪製正方形的顏色。

HTML

HTML 僅包含我們將獲取 WebGL 上下文的 <canvas> 元素。

html
<canvas id="gl-canvas" width="600" height="460">
  Oh no! Your browser doesn't support canvas!
</canvas>

全域性變數和初始化

首先是全域性變數。我們在這裡不討論它們;相反,我們將在程式碼中使用它們時進行討論。

js
const glCanvas = document.getElementById("gl-canvas");
const gl = glCanvas.getContext("webgl");

const shaderSet = [
  {
    type: gl.VERTEX_SHADER,
    id: "vertex-shader",
  },
  {
    type: gl.FRAGMENT_SHADER,
    id: "fragment-shader",
  },
];

const shaderProgram = buildShaderProgram(shaderSet);

// Aspect ratio and coordinate system details
const aspectRatio = glCanvas.width / glCanvas.height;
const currentRotation = [0, 1];
const currentScale = [1.0, aspectRatio];

// Vertex information
const vertexArray = new Float32Array([
  -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, -0.5,
]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);
const vertexNumComponents = 2;
const vertexCount = vertexArray.length / vertexNumComponents;

// Rendering data shared with the scalers.
let uScalingFactor;
let uGlobalColor;
let uRotationVector;
let aVertexPosition;

// Animation timing
let previousTime = 0.0;
const degreesPerSecond = 90.0;
let currentAngle = 0.0;

animateScene();

在獲取 WebGL 上下文 gl 後,我們需要開始構建著色器程式。這裡,我們使用了一種可以輕鬆新增多個著色器的程式碼。陣列 shaderSet 包含一個物件列表,每個物件描述一個要編譯到程式中的著色器函式。每個函式都有一個型別(gl.VERTEX_SHADERgl.FRAGMENT_SHADER)和一個 ID(包含著色器程式碼的 <script> 元素的 ID)。

著色器集被傳遞給函式 buildShaderProgram(),該函式返回已編譯和連結的著色器程式。我們將在下一步中介紹它的工作原理。

著色器程式構建完成後,我們透過將上下文的寬度除以高度來計算其縱橫比。然後我們將動畫的當前旋轉向量設定為 [0, 1],並將縮放向量設定為 [1.0, aspectRatio]。正如我們在頂點著色器中看到的,縮放向量用於將座標縮放到 -1.0 到 1.0 的範圍。

接下來建立頂點陣列,作為一個 Float32Array,每個要繪製的三角形有六個座標(三個 2D 頂點),總共 12 個值。

正如你所見,我們對每個軸使用 -1.0 到 1.0 的座標系。你可能會問,為什麼我們還需要進行任何調整?這是因為我們的上下文不是正方形。我們使用的是一個寬度為 600 畫素,高度為 460 畫素的上下文。這兩個維度都被對映到 -1.0 到 1.0 的範圍。由於兩個軸的長度不相等,如果我們不調整其中一個軸的值,正方形就會在一個方向或另一個方向上被拉伸。所以我們需要規範化這些值。

建立頂點陣列後,我們透過呼叫 gl.createBuffer() 來建立一個新的 GL 緩衝區來包含它們。我們透過呼叫 gl.bindBuffer() 將標準的 WebGL 陣列緩衝區引用繫結到該緩衝區,然後使用 gl.bufferData() 將頂點資料複製到緩衝區中。指定了用法提示 gl.STATIC_DRAW,告訴 WebGL 資料將只設置一次,永遠不會修改,但會反覆使用。這使得 WebGL 可以根據這些資訊考慮任何可以提高效能的最佳化。

在將頂點資料提供給 WebGL 後,我們將 vertexNumComponents 設定為每個頂點中的分量數(2,因為它們是 2D 頂點),並將 vertexCount 設定為頂點列表中頂點的數量。

然後,當前旋轉角度(以度為單位)設定為 0.0,因為我們還沒有進行任何旋轉,並將旋轉速度(以每螢幕重新整理週期(通常為 60 FPS)的度為單位)設定為 6。

最後,呼叫 animateScene() 來渲染第一幀並安排下一幀動畫的渲染。

編譯和連結著色器程式

buildShaderProgram() 函式接受一個物件陣列作為輸入,這些物件描述了一組要編譯並連結到著色器程式中的著色器函式,並在著色器程式構建和連結完成後返回該程式。

js
function buildShaderProgram(shaderInfo) {
  const program = gl.createProgram();

  shaderInfo.forEach((desc) => {
    const shader = compileShader(desc.id, desc.type);

    if (shader) {
      gl.attachShader(program, shader);
    }
  });

  gl.linkProgram(program);

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.log("Error linking shader program:");
    console.log(gl.getProgramInfoLog(program));
  }

  return program;
}

首先,呼叫 gl.createProgram() 來建立一個新的、空的 GLSL 程式。

然後,對於指定著色器列表中的每個著色器,我們呼叫一個 compileShader() 函式來編譯它,並將要構建的著色器函式的 ID 和型別傳遞給它。每個物件都包含,如前所述,著色器程式碼所在的 <script> 元素的 ID 以及著色器型別。透過將編譯後的著色器傳遞給 gl.attachShader() 來將其附加到著色器程式。

注意:實際上,我們還可以更進一步,檢查 <script> 元素的 type 屬性的值來確定著色器型別。

所有著色器都編譯完成後,使用 gl.linkProgram() 來連結程式。

如果在連結程式時發生錯誤,錯誤訊息將被記錄到控制檯。

最後,編譯後的程式被返回給呼叫者。

編譯單個著色器

下面的 compileShader() 函式由 buildShaderProgram() 呼叫以編譯單個著色器。

js
function compileShader(id, type) {
  const code = document.getElementById(id).firstChild.nodeValue;
  const shader = gl.createShader(type);

  gl.shaderSource(shader, code);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.log(
      `Error compiling ${
        type === gl.VERTEX_SHADER ? "vertex" : "fragment"
      } shader:`,
    );
    console.log(gl.getShaderInfoLog(shader));
  }
  return shader;
}

程式碼透過獲取指定 ID 的 <script> 元素中文字節點的值來從 HTML 文件中獲取。然後使用 gl.createShader() 建立一個指定型別的新著色器。

透過將原始碼傳遞給 gl.shaderSource(),將原始碼傳送到新著色器,然後使用 gl.compileShader() 編譯著色器。

編譯錯誤會被記錄到控制檯。注意使用 模板字面量 字串將正確的著色器型別字串插入到生成的訊息中。實際的錯誤詳細資訊透過呼叫 gl.getShaderInfoLog() 來獲取。

最後,編譯後的著色器被返回給呼叫者(即 buildShaderProgram() 函式)。

繪製和動畫場景

呼叫 animateScene() 函式來渲染每個動畫幀。

js
function animateScene() {
  gl.viewport(0, 0, glCanvas.width, glCanvas.height);
  gl.clearColor(0.8, 0.9, 1.0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  const radians = (currentAngle * Math.PI) / 180.0;
  currentRotation[0] = Math.sin(radians);
  currentRotation[1] = Math.cos(radians);

  gl.useProgram(shaderProgram);

  uScalingFactor = gl.getUniformLocation(shaderProgram, "uScalingFactor");
  uGlobalColor = gl.getUniformLocation(shaderProgram, "uGlobalColor");
  uRotationVector = gl.getUniformLocation(shaderProgram, "uRotationVector");

  gl.uniform2fv(uScalingFactor, currentScale);
  gl.uniform2fv(uRotationVector, currentRotation);
  gl.uniform4fv(uGlobalColor, [0.1, 0.7, 0.2, 1.0]);

  gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

  aVertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");

  gl.enableVertexAttribArray(aVertexPosition);
  gl.vertexAttribPointer(
    aVertexPosition,
    vertexNumComponents,
    gl.FLOAT,
    false,
    0,
    0,
  );

  gl.drawArrays(gl.TRIANGLES, 0, vertexCount);

  requestAnimationFrame((currentTime) => {
    const deltaAngle =
      ((currentTime - previousTime) / 1000.0) * degreesPerSecond;

    currentAngle = (currentAngle + deltaAngle) % 360;

    previousTime = currentTime;
    animateScene();
  });
}

為了繪製動畫幀,首先需要做的是將背景清除為所需的顏色。在這種情況下,我們根據 <canvas> 的大小設定視口,呼叫 clearColor() 設定清除內容時使用的顏色,然後我們使用 clear() 清除緩衝區。

接下來,透過將當前角度(以度為單位,currentAngle)轉換為 弧度 來計算當前旋轉向量,然後將旋轉向量的第一個分量設定為該值的 正弦,第二個分量設定為 餘弦currentRotation 向量現在是位於角度 currentAngle單位圓 上的點的位置。

呼叫 useProgram() 來啟用我們之前建立的 GLSL 著色程式。然後我們使用(透過 getUniformLocation())獲取用於在 JavaScript 程式碼和著色器之間共享資訊的每個 uniform 的位置。

名為 uScalingFactor 的 uniform 被設定為之前計算的 currentScale 值;正如你可能記得的,這個值用於根據上下文的縱橫比調整座標系。這是使用 uniform2fv() 完成的(因為這是一個 2 值浮點向量)。

uRotationVector 使用 uniform2fv() 設定為當前旋轉向量 (currentRotation)

uGlobalColor 使用 uniform4fv() 設定為我們希望用於繪製正方形的顏色。這是一個 4 分量浮點向量(分別代表紅色、綠色、藍色和 alpha)。

現在所有這些都完成了,我們可以設定頂點緩衝區並繪製我們的形狀。首先,用於繪製形狀三角形的頂點緩衝區透過呼叫 bindBuffer() 來設定。然後透過呼叫 getAttribLocation() 從著色器程式中獲取頂點位置屬性的索引。

現在頂點位置屬性的索引在 aVertexPosition 中可用,我們呼叫 enableVertexAttribArray() 來啟用位置屬性,以便著色器程式(特別是頂點著色器)可以使用它。

然後透過呼叫 vertexAttribPointer() 將頂點緩衝區繫結到 aVertexPosition 屬性。這一步並不明顯,因為這個繫結幾乎是一個副作用。但結果是,訪問 aVertexPosition 現在會從頂點緩衝區獲取資料。

在我們的形狀的頂點緩衝區和用於逐個將頂點傳遞到頂點著色器的 aVertexPosition 屬性之間建立關聯後,我們就可以透過呼叫 drawArrays() 來繪製形狀了。

此時,幀已繪製。剩下要做的就是安排繪製下一幀。這在這裡是透過呼叫 requestAnimationFrame() 來完成的,它請求在下一次瀏覽器準備好更新螢幕時執行一個回撥函式。

我們的 requestAnimationFrame() 回撥接收一個引數 currentTime,它指定了幀開始繪製的時間。我們使用它、上次幀繪製儲存的時間 previousTime 以及正方形應該旋轉的每秒度數 (degreesPerSecond) 來計算 currentAngle 的新值。然後更新 previousTime 的值,並呼叫 animateScene() 來繪製下一幀(進而安排下一幀的繪製,週而復始)。

結果

這是一個相當簡單的示例,因為它只繪製了一個簡單的物件,但這裡使用的概念可以擴充套件到更復雜的動畫。

另見