iT邦幫忙

2021 iThome 鐵人賽

DAY 6
0
Modern Web

如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起系列 第 6

在 WebGL 取用、顯示圖片 - Textures

大家好,我是西瓜,你現在看到的是 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),並且 positionDay 5 相同使用 pixel 座標定位,看起來像是這樣:

02-texture-2d-start

這一個灰色的正方形是由兩個三角形組成的,讀者可以在 02-texture-2d.jsgl.bufferData() 中看到每個頂點後面有一個註解字母,其對應了下面這張示意圖:

square-vertices

接下來以此為起點,讓灰色方形區域顯示圖片

建立 WebGL texture

建立之前,把來源圖片下載好,直接呼叫 loadImage 並傳入圖片網址,因為牽扯到非同步,這邊得用 await(也是因此得寫在 async function main() 內):

const image = await loadImage('https://i.imgur.com/ISdY40yh.jpg');

這張圖片是筆者的大頭貼:

pastleo

接著建立、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);

如何在 shader 中使用 texture

回想 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 即為『某個位置』,既然是 sampler2Dv_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);
}

Varying 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 示意圖如下:

position-texcoord

提供 texture 給 shader 使用

整張 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 指定通道了
  • Day 4 介紹 uniform 提到對於每種資料型別都有一個傳入 function,gl.uniform1i 傳的是 1 個整數,把通道的編號傳入,在 fragment shader 中就會直接被反應成 sampler2D

一切順利的話,就可以看到圖片出現在 canvas 裡頭,fragment shader 成功地『在每個 pixel 運算時從 texture 圖片上的某個位置取出其顏色來輸出』:

texture-result

讀者如果有興趣,可以修改 texcoord 的數字感受一下 texture2D(),像是把 C 點 texcoord 改成 (0.8, 0.8) 就變成這樣:

texture-0.8

本篇的完整程式碼可以在這邊找到:

但是關於 texture 其實還有許多細節,待下篇再來繼續討論


上一篇
Varying - fragment shader 之資料
下一篇
More About Textures
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言