最近因爲工做須要,開始學習 WebGL 相關的知識。這篇文章的目的就是記錄下學習過程當中的一些知識概念,並實現一個簡單的 demo,幫助你們快速理解 webgl 的概貌並上手開發。最後會分享本身對於 webgl 的幾點想法,給有須要的人提供參考。javascript
WebGL 全稱 Web Graphics Library,是一種支持 3D 的繪圖技術,爲 web 開發者提供了一套 3D 圖形相關的接口。經過這些接口,開發者能夠直接跟 GPU 進行通訊。html
WebGL 程序分爲 2 部分:前端
着色器程序接收 CPU 傳過來的數據,並進行必定處理,最終渲染成豐富多彩的應用樣式。java
WebGL 能繪製的基本圖元只有 3 種,分別是點
、線段
、三角形
,對應了物理世界中的點線面。全部複雜的圖形或者立方體,都是先用點
組成基本結構,而後用三角形
將這些點構成的平面填充起來,最後由多個平面組成立方體。git
因此,咱們須要從構建頂點數據開始。頂點座標通常還須要通過一些轉換步驟,纔可以變成符合裁剪座標系
的數據。這些轉換步驟,咱們能夠用矩陣來表示。把變換矩陣和初始頂點信息傳給 GPU,大體處理步驟以下:github
咱們能夠用 GLSL 編程控制的是頂點着色器
和片元着色器
這 2 步。這一套相似於流水線的渲染過程,在業界被稱爲渲染管線
。web
(圖片來自掘金小冊:WebGL 入門與實踐,這是一份不錯的入門學習資料,推薦一下)ajax
這一部分,我會帶領你們一步一步建立一個會旋轉的正方體,幫助你們上手 webgl 開發。編程
WebGL 開發的準備工做相似於 canvas 開發,須要準備一個 html 文檔,幷包含<canvas>
標籤,只不過調用getContext
時傳入的參數不是2d
,而是webgl
。canvas
另外,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_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)
,也就是白色。渲染後效果以下:
成功渲染一個點以後,咱們已經對於 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);
這樣,一個三角形就繪製出來了,看到的效果應該是這樣:
正方形並非 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 ];
效果以下:
能夠看到,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 的傳遞邏輯便可
這樣繪製出來的圖形效果是:
這裏有 2 個問題:
Q:爲何是長方形,而不是正方體?
gl_Position
的值以後,除了把裁剪座標
轉換成NDC
座標以外,還會根據畫布的寬高進行一次視口變換
,畫布的寬高比不一樣,渲染出來的效果就不一樣。要解決這個問題,須要使用投影變換
對座標先處理一道。通過投影變換
,咱們再任何尺寸的畫布上看到的都會是一個正方形,也就是正方體的一個面,這時候咱們再讓正方體旋轉起來,就能夠看到它的全部面了。Q:根據設置的顏色,前面
對應的色值是rgba(255, 0, 0, 1)
,也就是紅色,爲何看到的是綠色?
左手座標系
,也就是 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>
效果以下:
完整代碼請參考這裏
Three.js
、Babylon
之類。若是隻是爲了完成效果,直接引入庫就能夠了。可是若是是爲了深刻學習 webgl 開發,仍是要了解原生的寫法。