WebGL 初印象

webgl.jpg

最近因爲工做須要,開始學習 WebGL 相關的知識。這篇文章的目的就是記錄下學習過程當中的一些知識概念,並實現一個簡單的 demo,幫助你們快速理解 webgl 的概貌並上手開發。最後會分享本身對於 webgl 的幾點想法,給有須要的人提供參考。javascript

WebGL 全稱 Web Graphics Library,是一種支持 3D 的繪圖技術,爲 web 開發者提供了一套 3D 圖形相關的接口。經過這些接口,開發者能夠直接跟 GPU 進行通訊。html

WebGL 程序分爲 2 部分:前端

  • 使用 Javascript 編寫的運行在 CPU 的程序
  • 使用 GLSL 編寫的運行在 GPU 的着色器程序

着色器程序接收 CPU 傳過來的數據,並進行必定處理,最終渲染成豐富多彩的應用樣式。java

渲染流程

WebGL 能繪製的基本圖元只有 3 種,分別是線段三角形,對應了物理世界中的點線面。全部複雜的圖形或者立方體,都是先用組成基本結構,而後用三角形將這些點構成的平面填充起來,最後由多個平面組成立方體。git

因此,咱們須要從構建頂點數據開始。頂點座標通常還須要通過一些轉換步驟,纔可以變成符合裁剪座標系的數據。這些轉換步驟,咱們能夠用矩陣來表示。把變換矩陣和初始頂點信息傳給 GPU,大體處理步驟以下:github

  1. 頂點着色器:根據變換矩陣和初始頂點信息進行運算,獲得裁剪座標。這個計算過程也能夠放到 js 程序中作,可是這樣就不能充分利用 GPU 的並行計算優點了。
  2. 圖元裝配:使用三角形圖元裝配頂點區域。
  3. 光柵化:用沒有顏色的像素填充圖形區域。
  4. 片元着色器:爲像素着色。

咱們能夠用 GLSL 編程控制的是頂點着色器片元着色器這 2 步。這一套相似於流水線的渲染過程,在業界被稱爲渲染管線web

process
(圖片來自掘金小冊:WebGL 入門與實踐,這是一份不錯的入門學習資料,推薦一下)ajax

開始創做

這一部分,我會帶領你們一步一步建立一個會旋轉的正方體,幫助你們上手 webgl 開發。編程

準備工做

WebGL 開發的準備工做相似於 canvas 開發,須要準備一個 html 文檔,幷包含<canvas>標籤,只不過調用getContext時傳入的參數不是2d,而是webglcanvas

另外,webgl 還須要使用 GLSL 進行頂點着色器和片元着色器編程。

下面是準備好的 html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <canvas id="canvas"></canvas>
  <script>
    // 頂點着色器代碼
    const vertexShaderSource = `
      // 編寫 glsl 代碼
    `;
    // 片元着色器代碼
    const fragmentShaderSource = `
      // 編寫 glsl 代碼
    `;
    
    // 根據源代碼建立着色器對象
    function createShader(gl, type, source) {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return shader;
    }
    
    // 獲取 canvas 並設置尺寸
    const canvas = document.querySelector('#canvas');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    // 獲取 webgl 上下文
    const gl = canvas.getContext('webgl');
    
    // 建立頂點着色器對象
    const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
    // 建立片元着色器對象
    const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
    // 建立 webgl 程序對象
    const program = gl.createProgram();
    // 綁定頂點着色器
    gl.attachShader(program, vertexShader);
    // 綁定片元着色器
    gl.attachShader(program, fragmentShader);
    // 連接程序
    gl.linkProgram(program);
    // 使用程序
    gl.useProgram(program);
  </script>
</body>
</html>

幾乎每一行代碼都加了註釋,應該能看懂了。這裏再單獨說一下着色器源代碼,上面的示例中,咱們預留了一個字符串模板,用於編寫着色器的 GLSL 代碼。實際上,只要在建立着色器對象的時候,能把着色器代碼做爲字符串傳入createShader方法就行,不論是直接從 js 變量中獲取,仍是經過 ajax 從遠端獲取。

目前爲止,咱們已經開始調用了 webgl 相關的 js api(各 api 具體用法請翻閱MDN),可是這些代碼還不能渲染出任何畫面。

這部分咱們嘗試渲染一個固定位置的點。先從頂點着色器開始:

void main() {
  gl_PointSize = 5.0;
  gl_Position = vec4(0, 0, 0, 1);
}

這部分是 GLSL 代碼,相似於 C 語言,解釋下含義:

  • 要執行的代碼包裹在main函數中
  • gl_PointSize 表示點的尺寸
  • gl_Position是全局變量,用於定義頂點的座標,vec4表示一個四位向量,前三位是x/y/z軸數值,取值區間均爲0-1,最後一位是齊次份量,是 GPU 用來從裁剪座標系轉換到NDC座標系的,咱們設置爲 1 就行。

接着寫片元着色器:

void main() {
  gl_FragColor = vec4(1, 0, 0, 1);
}

gl_FragColor表示要爲像素填充的顏色,後面的四維向量相似於 CSS 中的rgba,只不過rgb的值從0-255等比縮放爲0-1,最後一位表明不透明度。

最後,咱們來完善一下 js 代碼:

// 設置清空 canvas 畫布的顏色
gl.clearColor(1, 1, 1, 1);
// 清空畫布
gl.clear(gl.COLOR_BUFFER_BIT);
// 繪製一個點
gl.drawArrays(gl.POINTS, 0, 1);

clearColor設置爲1,1,1,1,至關於rgba(255, 255, 255, 1),也就是白色。渲染後效果以下:

dot

三角形

成功渲染一個點以後,咱們已經對於 webgl 的渲染流程有必定了解。三角形也是 webgl 基本圖元之一,要渲染三角形,咱們能夠指定三角形 3 個頂點的座標,而後指定繪製類型爲三角形。

以前的示例只渲染一個頂點,用gl_Position接受一個頂點的座標,那麼如何指定 3 個頂點座標呢?這裏咱們須要引入緩衝區的機制,在 js 中指定 3 個頂點的座標,而後經過緩衝區傳遞給 webgl。

先改造下頂點着色器:

// 設置浮點數精度
precision mediump float;
// 接受 js 傳過來的座標
attribute vec2 a_Position;
void main() {
  gl_Position = vec4(a_Position, 0, 1);
}

attribute能夠聲明在頂點着色器中,js 能夠向attribute傳遞數據。這裏咱們聲明瞭一個二維向量a_Position,用來表示點的x/y座標,z軸統一爲 0。

另外,咱們把gl_PointSize的賦值去掉了,由於咱們此次要渲染的是三角形,不是點。

片元着色器暫時不須要改動。

接着咱們改造下 js 部分。

const points = [
  -0.5, 0, // 第 1 個頂點
  0.5, 0, // 第 2 個頂點
  0, 0.5 // 第 3 個頂點
];

// 建立 buffer
const buffer = gl.createBuffer();
// 綁定buffer爲當前緩衝區
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 獲取程序中的 a_Position 變量
const a_Position = gl.getAttribLocation(program, 'a_Position');
// 激活 a_Position
gl.enableVertexAttribArray(a_Position);
// 指定 a_Position 從 buffer 獲取數據的方式
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 給 buffer 灌數據
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);
// 設置清空 canvas 畫布的顏色
gl.clearColor(1, 1, 1, 1);
// 清空畫布
gl.clear(gl.COLOR_BUFFER_BIT);
// 繪製三角形
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

這樣,一個三角形就繪製出來了,看到的效果應該是這樣:

triangle

正方形

正方形並非 WebGL 的基本圖元之一,咱們要如何繪製呢?答案就是用 2 個三角形拼接。在上面繪製三角形的代碼基礎上改動就很容易了,把 3 個頂點改成 6 個頂點,表示 2 個三角形就行

const points = [
  -0.2, 0.2, // p1
  -0.2, -0.2, // p2
  0.2, -0.2, // p3
  0.2, -0.2, // p4
  0.2, 0.2, // p5
  -0.2, 0.2 // p6
];

效果以下:
rect

能夠看到,p3 和 p4,p1 和 p6,實際上是重合的。這裏可使用索引來減小重複點的聲明。咱們再次改造下 js 代碼

const points = [
  -0.2, 0.2, // p1
  -0.2, -0.2, // p2
  0.2, -0.2, // p3
  0.2, 0.2, // p4
];

// 根據 points 中的 index 設置索引
const indices = [
  0, 1, 2, // 第一個三角形
  2, 3, 0 // 第二個三角形
];

// 建立 buffer
const buffer = gl.createBuffer();
// 綁定buffer爲當前緩衝區
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 獲取程序中的 a_Position 變量
const a_Position = gl.getAttribLocation(program, 'a_Position');
// 激活 a_Position
gl.enableVertexAttribArray(a_Position);
// 指定 a_Position 從 buffer 獲取數據的方式
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 給 buffer 灌數據
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);

// 建立索引 buffer
const indicesBuffer = gl.createBuffer();
// 綁定索引 buffer
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indicesBuffer);
// 灌數據
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);

// 設置清空 canvas 畫布的顏色
gl.clearColor(1, 1, 1, 1);
// 清空畫布
gl.clear(gl.COLOR_BUFFER_BIT);
// 繪製三角形
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

效果與以前用 6 個點同樣。千萬別小看這裏 2 個點的優化,在大型項目中,複雜圖形每每由成千上萬個點構成,使用索引能對內存佔用進行有效的優化。

正方體

正方體由 6 個正方形組成,共 8 個頂點,咱們只要構建出這 8 個頂點的位置,而後用三角形圖元把它繪製出來就好了。爲了加以區分各個平面,咱們使用不一樣的顏色的來繪製每一個面。

先改動下頂點着色器,用來接收頂點的顏色信息。

// 設置浮點數精度
precision mediump float;
// 接受 js 傳過來的座標
attribute vec3 a_Position;
// 接收 js 傳過來的顏色
attribute vec4 a_Color;
// 透傳給片元着色器
varying vec4 v_Color;
void main() {
  gl_Position = vec4(a_Position, 1);
  v_Color = a_Color;
}

這裏新增了 2 個變量,a_Color相似於a_Position,能夠接收 js 傳過來的頂點顏色信息,可是顏色最終是在片元着色器中使用的,因此咱們要經過v_Color透傳出去。varying類型變量就是用於在頂點着色器和片元着色器之間傳遞數據。另外,a_Position咱們改成了三維向量,由於須要制定 z 座標。

接下來是片元着色器:

// 設置浮點數精度
precision mediump float;
// 接收頂點着色器傳來的顏色信息
varying vec4 v_Color;
void main() {
  gl_FragColor = v_Color / vec4(255, 255, 255, 1);
}

除了接收v_Color以外,咱們還把v_Color進行了處理,這樣在 js 中咱們就可使用最原始的rgba值,而後在 GPU 中計算獲得真正的gl_FragColor,充分利用了 GPU 的並行計算優點。

如今,咱們能夠在 js 中構建正方體的頂點信息了。

/**
 * 建立一個立方體,返回 points,indices,colors
 * 
 * @params width 寬度
 * @params height 高度
 * @params depth 深度
 */
function createCube(width, height, depth) {
  const baseX = width / 2;
  const baseY = height / 2;
  const baseZ = depth / 2;

  /*
        7 ---------- 6
       /|          / |
      / |         /  | 
    3 --|-------- 2  |
    |   4 --------|- 5
    |  /          |  /
    | /           | / 
    |/            |/ 
    0 ----------- 1
  */
  const facePoints = [
    [-baseX, -baseY, baseZ], // 頂點0
    [baseX, -baseY, baseZ], // 頂點1
    [baseX, baseY, baseZ], // 頂點2
    [-baseX, baseY, baseZ], // 頂點3
    [-baseX, -baseY, -baseZ], // 頂點4
    [baseX, -baseY, -baseZ], // 頂點5
    [baseX, baseY, -baseZ], // 頂點6
    [-baseX, baseY, -baseZ], // 頂點7
  ];
  const faceColors = [
    [255, 0, 0, 1], // 前面
    [0, 255, 0, 1], // 後面
    [0, 0, 255, 1], // 左面
    [255, 255, 0, 1], // 右面
    [0, 255, 255, 1], // 上面
    [255, 0, 255, 1] // 下面
  ];
  const faceIndices = [
    [0, 1, 2, 3], // 前面
    [4, 5, 6, 7], // 後面
    [0, 3, 7, 4], // 左面
    [1, 5, 6, 2], // 右面
    [3, 2, 6, 7], // 上面
    [0, 1, 5, 4], // 下面
  ];

  let points = [];
  let colors = [];
  let indices = [];

  for (let i = 0; i < 6; i++) {
    const currentFaceIndices = faceIndices[i];
    const currentFaceColor = faceColors[i];
    for (let j = 0; j < 4; j++) {
      const pointIndice = currentFaceIndices[j];
      points = points.concat(facePoints[pointIndice]);
      colors = colors.concat(currentFaceColor);
    }
    const offset = 4 * i;
    indices.push(offset, offset + 1, offset + 2);
    indices.push(offset, offset + 2, offset + 3);
  }

  return {
    points, colors, indices
  };
}

const { points, colors, indices } = createCube(0.6, 0.6, 0.6);

// 下面與繪製正方形基本一致,僅需增長 colors 的傳遞邏輯便可

這樣繪製出來的圖形效果是:

rect1

這裏有 2 個問題:

  • Q:爲何是長方形,而不是正方體?

    • A:GPU 拿到賦值給gl_Position的值以後,除了把裁剪座標轉換成NDC座標以外,還會根據畫布的寬高進行一次視口變換,畫布的寬高比不一樣,渲染出來的效果就不一樣。要解決這個問題,須要使用投影變換對座標先處理一道。通過投影變換,咱們再任何尺寸的畫布上看到的都會是一個正方形,也就是正方體的一個面,這時候咱們再讓正方體旋轉起來,就能夠看到它的全部面了。
  • Q:根據設置的顏色,前面對應的色值是rgba(255, 0, 0, 1),也就是紅色,爲何看到的是綠色?

    • A:裁剪座標系遵循左手座標系,也就是 z 軸正方向是指向屏幕裏面,因此這裏咱們看到的實際上是正方體的後面,就是rgba(0, 255, 0, 1)綠色了。

接下來咱們增長一些矩陣計算工具,用於計算正交投影旋轉等效果對應的座標。

先修改下頂點着色器,增長一個變量用於引入座標轉換矩陣

// 設置浮點數精度
precision mediump float;
// 接受 js 傳過來的座標
attribute vec3 a_Position;
// 接收 js 傳過來的顏色
attribute vec4 a_Color;
// 透傳給片元着色器
varying vec4 v_Color;
// 轉換矩陣
uniform mat4 u_Matrix;
void main() {
  gl_Position = u_Matrix * vec4(a_Position, 1);
  v_Color = a_Color;
}

矩陣計算工具咱們直接引入別人寫好的:

<script src="matrix.js"></script>
<script>
  // 前面的代碼都同樣,我就不重複貼了
  // 增長以下計算矩陣代碼
  const aspect = canvas.width / canvas.height;
  const projectionMatrix = matrix.ortho(-aspect * 4, aspect * 4, -4, 4, 100, -100);
  const dstMatrix = matrix.identity();
  const tmpMatrix = matrix.identity();
  let xAngle = 0;
  let yAngle = 0;
  
  function deg2radians(deg) {
      return Math.PI / 180 * deg;
    }
  
  gl.clearColor(1, 1, 1, 1);
  const u_Matrix = gl.getUniformLocation(program, 'u_Matrix');
  
  function render() {
    xAngle += 1;
    yAngle += 1;
    // 先繞 Y 軸旋轉矩陣
    matrix.rotationY(deg2radians(yAngle), dstMatrix);
    // 再繞 X 軸旋轉
    matrix.multiply(dstMatrix, matrix.rotationX(deg2radians(xAngle), tmpMatrix), dstMatrix);
    // 模型投影矩陣
    matrix.multiply(projectionMatrix, dstMatrix, dstMatrix);
    // 給 GPU 傳遞矩陣
    gl.uniformMatrix4fv(u_Matrix, false, dstMatrix);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
    // 讓立方體動起來
    requestAnimationFrame(render);
  }
  
  render();
</script>

效果以下:

ball

完整代碼請參考這裏

談談感覺

  • 原生的 webgl 編程仍是比較繁瑣,好在業內已經有一些優秀的庫能夠直接用,好比Three.jsBabylon之類。若是隻是爲了完成效果,直接引入庫就能夠了。可是若是是爲了深刻學習 webgl 開發,仍是要了解原生的寫法。
  • 要想學好 webgl 開發,只知道 api 和調用流程是不行的,還須要學習計算機圖形學、線性代數等,明白各個幾何變換如何用矩陣表示出來。例如上面的正交投影變換,應該有不少人沒看懂。
  • WebGL 是基於 OpenGL 的,可是如今又出現了一個 Vulkan,大有取代 OpenGL 的意思。我以爲大可沒必要驚慌,仍是要打好本身的基礎,無論編程語言如何變化,計算機圖形學、線性代數等基礎學科知識不會變。
  • WebGL 開發目前在前端領域還屬於偏小衆的技術,可是隨着 5G 的發展,將來應用的交互形式還會不斷進化,3d 早晚會變爲常態,到那時 3d 效果開發應該會成爲前端的必備技能。
相關文章
相關標籤/搜索