iT邦幫忙

2021 iThome 鐵人賽

DAY 19
0

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

有了散射光的計算,物體的表面根據有沒有被光照射到而顯示;而本篇將介紹計算上較為複雜的 specular 反射光,筆者覺得加上這個效果之後,物體就可以呈現金屬、或是光滑表面的質感,開始跳脫死板的顏色,接下來以此畫面為目標:

specular-target

反射光的計算方法與所需要的資料

在反射光的 wiki 中,反射光的意示圖如下:

specualr-reflection

入射角與反射角的角度相同,也就是 θiθr 相同,在本篇實做目標擷圖中,其中球體上的白色反光區域,就是光線入射角與反射角角度很接近的地方;而在 fragment shader 內,與計算散射時一樣,與其計算角度,不如利用單位向量的內積,先計算光線方向反向 surfaceToLightDirection 與表面到相機方向 surfaceToViewerDirection 的『中間向量』,也就是 surfaceToLightDirectionsurfaceToViewerDirection 兩個向量箭頭頂點的中間位置延伸而成的單位向量 halfVector,再拿 halfVector 與法向量做內積得到反射光的明暗度:

為了知道表面 O 點到相機方向,我們要在 shader 內計算出表面的位置,也就是只有經過 worldMatrix 做 transform 的位置,因此除了同時包含 worldMatrixviewMatrixu_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,
     });
     // ...
   }
 }

Vertex Shader: 計算表面到相機方向

應該不難想像,面上某個點的『表面到相機方向』是三角形頂點到相機方向的中間值,符合 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;
 }

Fragment Shader: 實做反射光計算

照著上方所說得來實做,並讓結果 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),最後與法向量做內積

縮小反射範圍

如果存檔去看渲染結果,看起來像是這樣:

rendering-without-pow

顯然跟目標擷圖不一樣,為什麼呢?想想看,如果 halfVector 與法向量相差 60 度,那麼我們做完內積之後,可以獲得 0.5 的 specular,這樣的反射範圍顯然太大,我們希望內積之後的值非常接近 1 才能讓 specular 有值,再套上 n 次方可以做到這件事,假設 n 為 40,那麼線圖看起來像是這樣,接近 0.9 時數值才開始明顯大於 0:

40-pow

本圖擷取自此: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 的控制加上,便可以動態調整反射光的區域:

specular-live-adjustment

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


上一篇
Indexed Element、請 TWGL 替程式碼減肥
下一篇
點光源與自發光
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言