大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 21 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。繪製出簡易的 3D 場景後,本章節加入光照效果使得成像更加真實,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
在尋找 3D 材質素材時,找到的素材包含的檔案常常不只有材質本身的顏色(diffuse 用),筆者撰寫這個章節所使用的 Commission - Medieval (其中之 steel), 2048² wooden texture,分別包含了一個 normal map:
先前物件表面的法向量由頂點決定,因為 varying 的『平滑補間』,使得光線照射物體時看起來很平順,而這些 normal map 則是可以讓物件對於光具有更多表面的細節,看起來更真實、細緻
上面兩張『圖片』,使用 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;
+ }
在 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 14 的 matrix4.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;
}
vec3 normal = normalize((u_normalMatrix * a_normal).xyz);
計算原本 vertex normal 要進行的旋轉vec3 normalMatrixI
為 vec3(0, 1, 0)
與 normal
的外積,但是為了避免 normal
為 vec3(0, 1, 0)
導致外積不出結果,遇到這樣的狀況時直接使得 normalMatrixI
為 vec3(1, 0, 0)
vec3 normalMatrixJ
為 normal
與 normalMatrixI
的外積normalMatrixI
, normalMatrixJ
, normal
作為變換矩陣的『基本矢量』,組成的矩陣 v_normalMatrix
,可以把 normal map 法向量 transform 成以 vertex 法向量為基礎之向量在 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,
});
// ...
}
存檔重整之後,可以看到因為木質地板光澤有更多細節,使得這個『平面』立體了起來:
這時因為有複數個 texture 在 fragment shader 中使用,u_texture
開始顯得不知道是指哪一個,因此筆者把這個 uniform 改名為 u_diffuseMap
,畢竟他是負責 diffuse 顏色的;在 2048² wooden texture 這個材質中有提供 specular map,因此也順便實做 u_specularMap
,運作方式類似 u_diffuseMap
,完整程式碼可以在這邊找到:
在場景中加入光以及物體表面上的散射、反射光之後,物體是否看起來更加真實了呢?針對光的討論差不多就到這邊,既然有了光,那麼影子呢?在實做陰影之前,要先學會如何讓 WebGL 渲染到 texture 上,使我們可以請 GPU 先進行一些運算,並在實際渲染畫面時取用先運算好的資料