大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 19 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。繪製出簡易的 3D 場景後,本章節加入光照效果使得成像更加真實,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
有了散射光的計算,物體的表面根據有沒有被光照射到而顯示;而本篇將介紹計算上較為複雜的 specular 反射光,筆者覺得加上這個效果之後,物體就可以呈現金屬、或是光滑表面的質感,開始跳脫死板的顏色,接下來以此畫面為目標:
在反射光的 wiki 中,反射光的意示圖如下:
入射角與反射角的角度相同,也就是 θi
與 θr
相同,在本篇實做目標擷圖中,其中球體上的白色反光區域,就是光線入射角與反射角角度很接近的地方;而在 fragment shader 內,與計算散射時一樣,與其計算角度,不如利用單位向量的內積,先計算光線方向反向 surfaceToLightDirection
與表面到相機方向 surfaceToViewerDirection
的『中間向量』,也就是 surfaceToLightDirection
與 surfaceToViewerDirection
兩個向量箭頭頂點的中間位置延伸而成的單位向量 halfVector
,再拿 halfVector
與法向量做內積得到反射光的明暗度:
為了知道表面 O 點到相機方向,我們要在 shader 內計算出表面的位置,也就是只有經過 worldMatrix
做 transform 的位置,因此除了同時包含 worldMatrix
與 viewMatrix
的 u_matrix
之外,也得傳 worldMatrix
,我們就叫這個 uniform u_worldMatrix
;另外也需要傳送相機的位置進去 u_worldViewerPosition
:
function render(app) {
// ...
twgl.setUniforms(programInfo, {
+ u_worldViewerPosition: state.cameraPosition,
u_lightDir: state.lightDir,
});
// ...
{ // both ball and ground
// ...
const worldMatrix = matrix4.multiply(/* ... */);
twgl.setUniforms(programInfo, {
u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
+ u_worldMatrix: worldMatrix,
u_normalMatrix: matrix4.transpose(matrix4.inverse(worldMatrix)),
u_diffuse: [0, 0, 0],
u_texture: textures.steel,
});
// ...
}
}
應該不難想像,面上某個點的『表面到相機方向』是三角形頂點到相機方向的中間值,符合 Day 5 的 varying 特性,也就是說我們可以讓這個方向由頂點計算出來,用 varying 傳送給 fragment shader,fragment shader 收到的表面到相機方向就會是平滑補間後的結果,筆者把這個方向叫做 v_surfaceToViewer
。有了 u_worldMatrix
以及 u_worldViewerPosition
,計算出 v_surfaceToViewer
:
attribute vec4 a_position;
attribute vec2 a_texcoord;
attribute vec4 a_normal;
uniform mat4 u_matrix;
+uniform mat4 u_worldMatrix;
uniform mat4 u_normalMatrix;
+uniform vec3 u_worldViewerPosition;
varying vec2 v_texcoord;
varying vec3 v_normal;
+varying vec3 v_surfaceToViewer;
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 worldPosition = (u_worldMatrix * a_position).xyz;
+ v_surfaceToViewer = u_worldViewerPosition - worldPosition;
}
照著上方所說得來實做,並讓結果 specular
直接加在顏色的所有 channel 上:
precision highp float;
uniform vec3 u_diffuse;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;
varying vec2 v_texcoord;
varying vec3 v_normal;
+varying vec3 v_surfaceToViewer;
void main() {
vec3 diffuse = u_diffuse + texture2D(u_texture, v_texcoord).rgb;
vec3 normal = normalize(v_normal);
vec3 surfaceToLightDir = normalize(-u_lightDir);
float diffuseLight = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
- gl_FragColor = vec4(diffuse * diffuseLight, 1);
+
+ vec3 surfaceToViewerDirection = normalize(v_surfaceToViewer);
+ vec3 halfVector = normalize(surfaceToLightDir + surfaceToViewerDirection);
+ float specularBrightness = dot(halfVector, normal);
+
+ gl_FragColor = vec4(
+ diffuse * diffuseLight + specularBrightness,
+ 1
+ );
}
先是把 v_surfaceToViewer
轉成單位向量,而 surfaceToLightDir
在先前已經有算出來為單位向量,兩者長度皆為 1
,加在一起除以二可以得到『中間向量』,但是之後也得轉換成為單位向量,除以二的步驟就可以省略,因此平均向量 halfVector
這樣算:normalize(surfaceToLightDir + surfaceToViewerDirection)
,最後與法向量做內積
如果存檔去看渲染結果,看起來像是這樣:
顯然跟目標擷圖不一樣,為什麼呢?想想看,如果 halfVector
與法向量相差 60 度,那麼我們做完內積之後,可以獲得 0.5 的 specular,這樣的反射範圍顯然太大,我們希望內積之後的值非常接近 1 才能讓 specular 有值,再套上 n 次方可以做到這件事,假設 n 為 40,那麼線圖看起來像是這樣,接近 0.9 時數值才開始明顯大於 0:
本圖擷取自此:https://www.desmos.com/calculator/yfa2jzzejm,讀者可以來這邊拉左方的 n 值感受一下
其實這個 n 的值可以根據不同物件材質而有所不同,因此加上 u_specularExponent
來控制,同時也加入控制反射光顏色的 uniform,稱為 u_specular
,筆者在此順便在 state 中加入對球體、地板不同的 specularExponent:
async function setup() {
// ...
return {
// ...
state: {
// ...
+ ballSpecularExponent: 40,
+ groundSpecularExponent: 100,
},
time: 0,
}
}
並且把 uniform 傳送設定好,反射光設定成白光:
function render(app) {
// ...
twgl.setUniforms(programInfo, {
u_worldViewerPosition: state.cameraPosition,
u_lightDir: state.lightDir,
+ u_specular: [1, 1, 1],
});
// ...
{ // ball
// ...
const worldMatrix = matrix4.multiply(/* ... */);
twgl.setUniforms(programInfo, {
// ...
u_diffuse: [0, 0, 0],
u_texture: textures.steel,
+ u_specularExponent: state.ballSpecularExponent,
});
// ...
}
{ // ground
// ...
const worldMatrix = matrix4.multiply(/* ... */);
twgl.setUniforms(programInfo, {
// ...
u_diffuse: [0, 0, 0],
u_texture: textures.steel,
+ u_specularExponent: state.groundSpecularExponent,
});
// ...
}
最後實做到 fragment shader 內,pow()
GLSL 有內建,同時也加上 clamp()
避免數值跑到負的:
precision highp float;
uniform vec3 u_diffuse;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;
+uniform vec3 u_specular;
+uniform float u_specularExponent;
varying vec2 v_texcoord;
varying vec3 v_normal;
varying vec3 v_surfaceToViewer;
void main() {
vec3 diffuse = u_diffuse + texture2D(u_texture, v_texcoord).rgb;
vec3 normal = normalize(v_normal);
vec3 surfaceToLightDir = normalize(-u_lightDir);
float diffuseBrightness = clamp(dot(surfaceToLightDir, normal), 0.0, 1.0);
vec3 surfaceToViewerDirection = normalize(v_surfaceToViewer);
vec3 halfVector = normalize(surfaceToLightDir + surfaceToViewerDirection);
- float specularBrightness = dot(halfVector, normal);
+ float specularBrightness = clamp(pow(dot(halfVector, normal), u_specularExponent), 0.0, 1.0);
gl_FragColor = vec4(
- diffuse * diffuseLight + specularBrightness,
+ diffuse * diffuseLight +
+ u_specular * specularBrightness,
1
);
如果把 HTML 對於 ballSpecularExponent
, groundSpecularExponent
的控制加上,便可以動態調整反射光的區域:
本篇的完整程式碼可以在這邊找到: