大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 6 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
有在玩遊戲的讀著們在討論一款 3D 遊戲的時候,可能有提到遊戲內的『3D 貼圖』,遊戲 3D 物件表面常常不會是純色或是漸層單調的樣子,而是有一張圖片貼在這個物件表面的感覺,所以才叫做『3D 貼圖』吧,而且也可以用在圖案重複的『材質』顯示上,因此在英文叫做 texture。雖然現在還完全沒有進入 3D 的部份,但是 3D 貼圖/texture 追根究底得有個方法在 WebGL 裡面取用、顯示圖片,本篇抽離 3D 的部份,來介紹在 WebGL 取用、顯示圖片的方式
02-texture-2d.html
/ 02-texture-2d.js
本篇開始將使用新的 .html
作為開始,起始點完整程式碼可以在這邊找到:github.com/pastleo/webgl-ironman/commit/75179fb,筆者將 createShader
, createProgram
移動到工具箱 lib/utils.js
,裡面有 loadImage
用來下載並回傳 Image
元素(注意,是 async function),並且 position
與 Day 5 相同使用 pixel 座標定位,看起來像是這樣:
這一個灰色的正方形是由兩個三角形組成的,讀者可以在 02-texture-2d.js
的 gl.bufferData()
中看到每個頂點後面有一個註解字母,其對應了下面這張示意圖:
接下來以此為起點,讓灰色方形區域顯示圖片
建立之前,把來源圖片下載好,直接呼叫 loadImage
並傳入圖片網址,因為牽扯到非同步,這邊得用 await
(也是因此得寫在 async function main()
內):
const image = await loadImage('https://i.imgur.com/ISdY40yh.jpg');
這張圖片是筆者的大頭貼:
接著建立、bind(對準)並設定 texture:
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0, // level
gl.RGB, // internalFormat
gl.RGB, // format
gl.UNSIGNED_BYTE, // type
image, // data
);
可以發現 gl.createTexture()
/ gl.bindTexture()
這個組合與 gl.createBuffer()
/ gl.bindBuffer()
這個組合神似,建立並且把 gl.TEXTURE_2D
目標對準 texture;接下來設定 texture 資料 gl.texImage2D()
:
level
: 對於一個 texture 其實有許多縮放等級,與接下來的 gl.generateMipmap
有一定的關係,不過這邊通常是填 0
表示輸入的是原始尺寸/最大張的圖internalFormat
, format
, type
: 根據 mdn 文件,format
在 WebGL1 必須與 internalFormat
相同,從文件中 internalformat 下方的表格可以看到有哪些選項可以填,顯然來源圖片有 RGB 三個 channel,想當然爾 format 選 RGB
,而 type
沒有特別的需求選擇 UNSIGNED_BYTE
最後 data
直接把 image
元素給進去,圖片就以 RGB 的格式輸入到 GPU 內 texture 的 level: 0
位置上
讀者可能對 level
的部份很納悶,可以想像一下,在 3D 的世界中鏡頭可能距離貼圖很遙遠,顯示時為了效率沒辦法當下做完整圖片的縮放,因此會事先把各個尺寸的縮圖做好放在記憶體裡,這樣的東西叫做 mipmap,而 level
表示縮放的等級,0
表示沒有縮放的版本。所以開發者得自己把各個縮放尺寸做好分別輸入嗎?幸好 WebGL 有內建方法一行對著目前的 texture 產生所有尺寸:
gl.generateMipmap(gl.TEXTURE_2D);
回想 fragment shader 的運作方式:在每個 pixel 運算其顏色。那麼如果要顯示 texture,就會變成『在每個 pixel 運算時從 texture 圖片上的某個位置取出其顏色來輸出』。在 GLSL 中可以透過 uniform 傳輸一種叫做 Sampler2D
的資料型別:
uniform sampler2D u_texture;
把這個 uniform 變數叫做 u_texture
,其實就是 texture。接著 GLSL 的內建 function texture2D()
可以進行上面所說的『從 texture 圖片上的某個位置取出其顏色』:
gl_FragColor = texture2D(u_texture, v_texcoord);
v_texcoord
即為『某個位置』,既然是 sampler2D
,v_texcoord
型別必須是 vec2
表示在 texture 圖片上的 (x, y)
座標,並且 (0.0, 0.0)
為圖片左上角, (1.0, 1.0)
為圖片右下角。腦筋快的應該已經想到,v_texcoord
是一個 varying,因為每個頂點之間所要取用的 texture 圖片座標是連續、平滑的。最後完整的 fragment shader 長這樣:
precision mediump float;
varying vec2 v_texcoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texcoord);
}
v_texcoord
與 Day 5 類似,既然 fragment shader 需要 varying,因此得在 vertex shader 提供 varying,vertex shader 又需要從 attribute 取得 texture 各個頂點需要取用的座標,對 vertex shader 加上這幾行:
attribute vec2 a_position;
+attribute vec2 a_texcoord;
uniform vec2 u_resolution;
+varying vec2 v_texcoord;
+
void main() {
gl_Position = vec4(
a_position / u_resolution * vec2(2, -2) + vec2(-1, 1),
0, 1
);
+ v_texcoord = a_texcoord;
}
取得 a_texcorrd
attribute 位置、並設定 buffer, vertex attribute array:
const texcoordAttributeLocation = gl.getAttribLocation(program, 'a_texcoord');
// ...
// a_texcoord
const texcoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
gl.enableVertexAttribArray(texcoordAttributeLocation);
gl.vertexAttribPointer(
texcoordAttributeLocation,
2, // size
gl.FLOAT, // type
false, // normalize
0, // stride
0, // offset
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
0, 0, // A
1, 0, // B
1, 1, // C
0, 0, // D
1, 1, // E
0, 1, // F
]),
gl.STATIC_DRAW,
);
因為 texture (0.0, 0.0) 為圖片左上角, (1.0, 1.0) 為圖片右下角,在 gl.bufferData()
對於每個頂點的填入的 texcoord
示意圖如下:
整張 texture 應該是個巨大的陣列資料,但是與 array buffer 不同,texture 必須提供隨機存取(random access),意思是說 fragment shader 不論在哪個 pixel 都可以取用 texture 任意位置的資料;texture 又是用 uniform 類似指標的方式提供給 shader 使用,可能 texture 在 GPU 上有特別的硬體做處理
首先一樣取得 uniform 位置:
const textureUniformLocation = gl.getUniformLocation(program, 'u_texture');
然後設定把 texture 啟用在一個『通道』,並把這個通道的編號傳入 uniform:
// after gl.useProgram()...
const textureUnit = 0;
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.activeTexture(gl.TEXTURE0 + textureUnit);
gl.uniform1i(textureUniformLocation, textureUnit);
textureUnit
為通道的編號,設定為 0
使用第一個通道gl.bindTexture
把目標指向建立好的 texture
,如果有其他 texture 導致目標更換時,這邊要把目標設定正確,雖然本篇只有一個 texture 就是了gl.activeTexture()
啟用通道並把目標 texture 設定到通道上,這邊還有神奇的 gl.TEXTURE0 + textureUnit
寫法;讀者可以嘗試在 Console 輸入 gl.TEXTURE1 - gl.TEXTURE0
(1
),或是 gl.TEXTURE5 - gl.TEXTURE2
(3
),就可以知道為什麼可以用 +
共用 textureUnit
指定通道了gl.uniform1i
傳的是 1 個整數,把通道的編號傳入,在 fragment shader 中就會直接被反應成 sampler2D
一切順利的話,就可以看到圖片出現在 canvas 裡頭,fragment shader 成功地『在每個 pixel 運算時從 texture 圖片上的某個位置取出其顏色來輸出』:
讀者如果有興趣,可以修改 texcoord
的數字感受一下 texture2D()
,像是把 C 點 texcoord
改成 (0.8, 0.8) 就變成這樣:
本篇的完整程式碼可以在這邊找到:
但是關於 texture 其實還有許多細節,待下篇再來繼續討論