iT邦幫忙

2021 iThome 鐵人賽

DAY 21
0
Modern Web

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

Normal Map

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 21 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。繪製出簡易的 3D 場景後,本章節加入光照效果使得成像更加真實,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

在尋找 3D 材質素材時,找到的素材包含的檔案常常不只有材質本身的顏色(diffuse 用),筆者撰寫這個章節所使用的 Commission - Medieval (其中之 steel), 2048² wooden texture,分別包含了一個 normal map:

steel-normal-map

wood-normal-map

先前物件表面的法向量由頂點決定,因為 varying 的『平滑補間』,使得光線照射物體時看起來很平順,而這些 normal map 則是可以讓物件對於光具有更多表面的細節,看起來更真實、細緻

Normal 法向量之 Texture

上面兩張『圖片』,使用 texture 的方式載入,觀察其顏色的 RGB 數值會發現大部分的 RGB 數值都相當接近 [127, 127, 255],減掉 127 再除以 128 會得到介於 -1 ~ +1 之間的數,這時與其說是顏色,一個 RGB 表示的其實是 [x, y, z] 單位向量來表示該表面位置的法向量,而且絕大部分的區域都是 [0, 0, 1] 指向 +z,整張 texture 稱為 normal map

除了使用 setup() 讀取這兩張圖之外,也加入一個 null normal map,如果有物件不使用 normal map 時使用,使表面法向量一律指向 +z,RGB 值輸入 [127, 127, 255]:

 async function setup() {
   // ...
   const textures = Object.fromEntries(
     await Promise.all(Object.entries({
       wood: 'https://i.imgur.com/SJdQ7Twh.jpg',
       steel: 'https://i.imgur.com/vqKuF5Ih.jpg',
+      woodNormal: 'https://i.imgur.com/f6JzpIUh.jpg',
+      steelNormal: 'https://i.imgur.com/tEOKqebh.jpg',
     }).map(async ([name, url]) => {
       // ...
     }))
   );
   
   // ...
  
+  { // null normal texture
+    const texture = gl.createTexture();
+    gl.bindTexture(gl.TEXTURE_2D, texture);
+
+    gl.texImage2D(
+      gl.TEXTURE_2D,
+      0, // level
+      gl.RGBA, // internalFormat
+      1, // width
+      1, // height
+      0, // border
+      gl.RGBA, // format
+      gl.UNSIGNED_BYTE, // type
+      new Uint8Array([
+        127, 127, 255, 255
+      ])
+    );
+
+    textures.nilNormal = texture;
+  }

Normal Map Transform

Day 17 中,我們有處理了 vertex attribue 中法向量的旋轉,但是現在得在原本 vertex 法向量的基礎上,再加上一層 normal map,也就是說 normal map 的 +z 要轉換成 vertex 的法向量;舉一個例子,如果有一個 vertex 資料形成之三角形的 normal 為 [1, 0, 0],而一個 fragment shader 取到從 normal map 取到表示的法向量為 [0, 0, 1],必須把這個法向量轉換成 [1, 0, 0]

為了做這樣的 transform,筆者閱讀 learnopengl.com 的 normal mapping 的文章 後得知這個轉換很像 Day 14matrix4.lookAt(),但是不太偏好為所有三角形資料計算、輸入 tangent 以及 bitangents,因此嘗試直接在 vertex shader 內實做傳入 up 為 [0, 1, 0]matrix4.lookAt(),並且把產生的矩陣以 varying mat3 v_normalMatrix 傳送到 fragment shader:

 varying vec2 v_texcoord;
-varying vec3 v_normal;
 varying vec3 v_surfaceToViewer;
 varying vec3 v_surfaceToLight;
  
+varying mat3 v_normalMatrix;
+
 void main() {
   gl_Position = u_matrix * a_position;
   v_texcoord = vec2(a_texcoord.x, 1.0 - a_texcoord.y);
-  v_normal = (u_normalMatrix * a_normal).xyz;
+
+  vec3 normal = normalize((u_normalMatrix * a_normal).xyz);
+  vec3 normalMatrixI = normal.y >= 1.0 ? vec3(1, 0, 0) : normalize(cross(vec3(0, 1, 0), normal));
+  vec3 normalMatrixJ = normalize(cross(normal, normalMatrixI));
+
+  v_normalMatrix = mat3(
+    normalMatrixI,
+    normalMatrixJ,
+    normal
+  );
+
   vec3 worldPosition = (u_worldMatrix * a_position).xyz;
   vec3 worldPosition = (u_worldMatrix * a_position).xyz;
   v_surfaceToViewer = u_worldViewerPosition - worldPosition;
   v_surfaceToLight = u_worldLightPosition - worldPosition;
 }
  1. 首先 vec3 normal = normalize((u_normalMatrix * a_normal).xyz); 計算原本 vertex normal 要進行的旋轉
  2. 原則上 vec3 normalMatrixIvec3(0, 1, 0)normal 的外積,但是為了避免 normalvec3(0, 1, 0) 導致外積不出結果,遇到這樣的狀況時直接使得 normalMatrixIvec3(1, 0, 0)
  3. vec3 normalMatrixJnormalnormalMatrixI 的外積
  4. 這麼一來,normalMatrixI, normalMatrixJ, normal 作為變換矩陣的『基本矢量』,組成的矩陣 v_normalMatrix,可以把 normal map 法向量 transform 成以 vertex 法向量為基礎之向量

在 fragment shader 對 normal map 進行 transform

在 fragment shader 內,從 u_normalMap 讀取法向量之後也得來進行矩陣運算了:

 precision highp float;
  
 uniform vec3 u_diffuse;
 uniform sampler2D u_texture;
 uniform vec3 u_specular;
 uniform float u_specularExponent;
 uniform vec3 u_emissive;
  
+uniform sampler2D u_normalMap;
+
 varying vec2 v_texcoord;
-varying vec3 v_normal;
 varying vec3 v_surfaceToViewer;
 varying vec3 v_surfaceToLight;
  
+varying mat3 v_normalMatrix;
+
 void main() {
   vec3 diffuse = u_diffuse + texture2D(u_texture, v_texcoord).rgb;
-  vec3 normal = normalize(v_normal);
+  vec3 normal = texture2D(u_normalMap, v_texcoord).xyz * 2.0 - 1.0;
+  normal = normalize(v_normalMatrix * normal);
   vec3 surfaceToLightDirection = normalize(v_surfaceToLight);
   float diffuseBrightness = clamp(dot(surfaceToLightDirection, normal), 0.0, 1.0);

值得注意的是,在 normal map 的原始資料輸入 [127, 127, 255] 作為法向量 [0, 0, 1],但在 texture2D(u_normalMap, v_texcoord).xyz 取出資料時會得到 [0.5, 0.5, 1],因此乘以 2 減 1

最後當然得在對應的物件渲染時設定好 u_normalMap 要使用的 normal map:

 function render(app) {
   // ...
   { // ball
     // ...
     twgl.setUniforms(programInfo, {
       // ...
+      u_normalMap: textures.steelNormal,
     });
     // ...
   }
   
   { // light bulb
     // ...
     twgl.setUniforms(programInfo, {
       // ...
+      u_normalMap: textures.nilNormal,
     });
     // ...
   }

   { // ground
     // ...
     twgl.setUniforms(programInfo, {
       // ...
+      u_normalMap: textures.woodNormal,
     });
     // ...
   }

存檔重整之後,可以看到因為木質地板光澤有更多細節,使得這個『平面』立體了起來:

normal-map-implemented

這時因為有複數個 texture 在 fragment shader 中使用,u_texture 開始顯得不知道是指哪一個,因此筆者把這個 uniform 改名為 u_diffuseMap,畢竟他是負責 diffuse 顏色的;在 2048² wooden texture 這個材質中有提供 specular map,因此也順便實做 u_specularMap,運作方式類似 u_diffuseMap,完整程式碼可以在這邊找到:

在場景中加入光以及物體表面上的散射、反射光之後,物體是否看起來更加真實了呢?針對光的討論差不多就到這邊,既然有了光,那麼影子呢?在實做陰影之前,要先學會如何讓 WebGL 渲染到 texture 上,使我們可以請 GPU 先進行一些運算,並在實際渲染畫面時取用先運算好的資料


上一篇
點光源與自發光
下一篇
Framebuffer
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言