大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 17 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
在這個章節中將會加入『光』的元素,使得物體在有光照射的時候才會有顏色,並利用上個章節提到的 twgl 讓程式碼可以寫的比較愉快,最後加入反射光的計算,使得渲染的成像更真實一些
04-lighting.html
/ 04-lighting.js
這個章節使用新的一組 .html
/ .js
作為開始,完整程式碼可以在這邊找到:github.com/pastleo/webgl-ironman/commit/2d72d48,這次的起始點跑起來就有一個木質地板跟一顆球:
雖然是 CC0,不過筆者還是標注一下好了,這個場景中使用到的 texture 是在 opengameart.org 找到的:Commission - Medieval, 2048² wooden texture
需要複習『在 WebGL 裡頭使用圖片 (texture) 進行繪製』的話,請參考 Day 6 的內容,在這個起始點也拿 Day 14 實做的相機控制過來,完整的 live 版本在此:
目前木質地板與球只是按照原本 texture 上的顏色進行繪製,本篇的目標是加入一個從無限遠的地方照射過來的白色平行光 (directional light),並且在物體表面計算『散射 (diffuse)』之後從任意角度觀察到的顏色,在維基百科上的這張圖表示了散射光的方向:
因為是白光,所以散射之後的顏色其實就是原本的顏色經過一個明暗度的處理,而明暗度要怎麼計算呢?筆者畫了下方的意示圖嘗試解釋,首先,如果是在光照不到的區域,像是紅色面,與光平行或是背對著光,那麼就會是全黑;被照射到的區域,如綠色與藍色面,因為一個單位的光通量在與垂直的面上可以形成較小的區域(在綠色面上的橘色線段較藍色面短),一個單位的面積獲得的光通量就比較高,因此綠色面比藍色面來的更亮
總和以上,入射角越垂直面接近法向量,明暗度越高,不過在 fragment shader 內,把向量算回角度再做比較會太傷本,我們可以取光方向反向的單位向量,再與法向量(也必須是單位向量)做內積,這樣一來會得到 -1 ~ +1 之間的值表示明暗度;幸好 twgl.primitives
產生的資料不只有 position, texcoord,還有 normal,也就是法向量,場景中的球以及地板都是使用 TWGL 生成的,這邊就先來把 normal 傳入 vertex shader 內:
const vertexShaderSource = `
attribute vec4 a_position;
attribute vec2 a_texcoord;
+attribute vec4 a_normal;
// ...
void main() {
// ...
}
`;
const attributes = {
// ...
+ normal: gl.getAttribLocation(program, 'a_normal'),
};
async function setup() {
// ...
{ // both ball and ground
// ...
// a_normal
buffers.normal = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.normal);
gl.enableVertexAttribArray(attributes.normal);
gl.vertexAttribPointer(
attributes.normal,
attribs.normal.numComponents, // size
gl.FLOAT, // type
false, // normalize
0, // stride
0, // offset
);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(attribs.normal),
gl.STATIC_DRAW,
);
// ...
}
// ...
}
計算明暗度會在面投影到螢幕的每個 pixel 上進行,也就是 fragment shader,vertex shader 主要的工作是把 a_normal
pass 到 fragment shader,但是有一個問題:物體會旋轉,我們讓頂點的位置透過 u_matrix
做 transform,假設有一個物體轉了 90 度,那麼法向量也應該一起轉 90 度才對
但是我們不能直接讓 a_normal
與 u_matrix
相乘得到旋轉後的結果,不僅因為 u_matrix
可能包含了平移、縮放資訊,還有投影到螢幕上的 transform,因此要多傳送一個矩陣,這個矩陣只包含了 worldMatrix
(物件本身的 transform 矩陣)的旋轉。至於從 worldMatrix
中抽取只包含旋轉的矩陣,在下面這兩個網頁中有一些數學方法導出接下來的公式:
筆者嘗試理解並統整成這份筆記,結論是:把 worldMatrix
取反矩陣,再取轉置矩陣,就可以得到 transform 法向量用的矩陣 -- 也就是只包含旋轉的 worldMatrix
在 lib/matrix.js
中已經有 matrix4.inverse()
,要補的是 matrix4.transpose()
,根據其定義,實做並不難:
export const matrix4 = {
// ...
transpose: m => {
return [
m[0], m[4], m[8], m[12],
m[1], m[5], m[9], m[13],
m[2], m[6], m[10], m[14],
m[3], m[7], m[11], m[15],
];
},
// ...
}
假設待會實做在 vertex shader 內轉換 normal 的矩陣叫做 u_normalMatrix
,在 setup()
中先取得 uniform
位置:
async function setup() {
// ...
const uniforms = {
// ...
normalMatrix: gl.getUniformLocation(program, 'u_normalMatrix'),
// ...
};
// ...
}
在 render()
這邊計算物件的 worldMatrix
後,依照上面講的公式實做計算 u_normalMatrix
:
function render(app) {
// ...
{ // both ball and ground
// const worldMatrix = matrix4.multiply( ... )
gl.uniformMatrix4fv(
uniforms.normalMatrix,
false,
matrix4.transpose(matrix4.inverse(worldMatrix)),
);
}
// ...
}
在 vertex shader 內就可以直接相乘,並透過 varying v_normal
傳送到 fragment shader:
attribute vec4 a_position;
attribute vec2 a_texcoord;
+attribute vec4 a_normal;
uniform mat4 u_matrix;
+uniform mat4 u_normalMatrix;
varying vec2 v_texcoord;
+varying vec3 v_normal;
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;
}
為了方便調整光線方向觀察不同方向的成像,筆者加入 uniform u_lightDir
,並且一開始讓他直直向下(-y 方向) 照射:
async function setup() {
// ...
const uniforms = {
// ...
lightDir: gl.getUniformLocation(program, 'u_lightDir'),
// ...
};
// ...
return {
// ...
state: {
// ...
lightDir: [0, -1, 0],
},
};
}
因為整個場景的光線方向都是固定的,因此在 render()
物件以外的範圍設定 u_lightDir
:
function render(app) {
// ...
gl.uniform3f(uniforms.lightDir, ...state.lightDir);
// ...
}
最後終於可以來寫 fragment shader 實做計算明暗度計算:
precision highp float;
uniform vec3 u_color;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;
varying vec2 v_texcoord;
varying vec3 v_normal;
void main() {
vec3 color = u_color + texture2D(u_texture, v_texcoord).rgb;
vec3 normal = normalize(v_normal);
vec3 surfaceToLightDir = normalize(-u_lightDir);
float colorLight = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
gl_FragColor = vec4(color * colorLight, 1);
}
main()
的第 2 行使用 glsl 內建的 normalize function 計算 v_normal
的單位向量,因為從 vertex shader 過來的 varying 經過『補間』處理可能導致不是單位向量,第 13 行計算『表面到光源』的方向,同樣使之為單位向量
main()
的第 4 行大概就是本篇最關鍵的一行,如同上方講的使用 glsl 的內積 function 計算明暗度:dot(surfaceToLightDir, normal)
,不過為了避免數值跑到負的,再套上 glsl 的 clamp 把範圍限制在 0~1 之間,最後乘上原本的顏色 color
,把明暗度套用上去,存檔重整後:
筆者稍微把視角往右上角調整了一下,可以看到球體的因為向著正下方的光線,只有上方比較亮,而地板因為原本就是向上的,所以就沒有變化
到了這邊其實應該把 color
改名成 diffuse
,因為一個物體其實可以分成不同種類的光對其表面產生的顏色,今天實做的是散射光,之後還會有反射光、自發光等;同時筆者也加上光線方向的使用者控制,完整程式碼可以在這邊找到:
光源方向調整起來像是這樣: