大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 20 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。繪製出簡易的 3D 場景後,本章節加入光照效果使得成像更加真實,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
從無限遠照射場景的平行光適合用來模擬太陽這類型的光源,如果是室內的燈泡光源呢?本篇將在場景中加入一個黃色自發光燈泡,並把平行光改成以這顆燈泡作為點光源
在平行光的環境下,所有位置的光線方向都一樣,因此只需要一個 uniform u_lightDir 便可以,但是在點光源的情況下會因為頂點/表面位置不同而有不同的光線方向,而光線方向可以透過 vertex shader 計算,並利用平滑補間使得 fragment shader 得到對應表面所街收到的光線方向,因此在 vertex shader 中使用 uniform 接收光源位置 u_worldLightPosition,並且計算出光線方向使用 varying v_surfaceToLight 傳給 fragment shader 使用:
 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;
+uniform vec3 u_worldLightPosition;
  
 varying vec2 v_texcoord;
 varying vec3 v_normal;
 varying vec3 v_surfaceToViewer;
+varying vec3 v_surfaceToLight;
 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;
+  v_surfaceToLight = u_worldLightPosition - worldPosition;
 }
在 fragment shader 上做的事情不會很困難,就只是從 u_lightDir 改用 v_surfaceToLight
 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;
+varying vec3 v_surfaceToLight;
  
 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 surfaceToLightDirection = normalize(v_surfaceToLight);
+  float diffuseBrightness = clamp(dot(surfaceToLightDirection, normal), 0.0, 1.0);
  
   vec3 surfaceToViewerDirection = normalize(v_surfaceToViewer);
-  vec3 halfVector = normalize(surfaceToLightDir + surfaceToViewerDirection);
+  vec3 halfVector = normalize(surfaceToLightDirection + surfaceToViewerDirection);
   float specularBrightness = clamp(pow(dot(halfVector, normal), u_specularExponent), 0.0, 1.0);
   gl_FragColor = vec4(
     diffuse * diffuseBrightness +
     u_specular * specularBrightness,
     1
   );
 }
筆者設定光源的初始位置在 [0, 2, 0],並且設定該設定的 uniform:
 async function setup() {
   // ...
   return {
     // ...
     state: {
-      lightDir: [0, -1, 0],
+      lightPosition: [0, 2, 0],
     }
   }
 }
 function render(app) {
   twgl.setUniforms(programInfo, {
     u_worldViewerPosition: state.cameraPosition,
-    u_lightDir: state.lightDir,
+    u_worldLightPosition: state.lightPosition,
     u_specular: [1, 1, 1],
 });
同時也將從 DOM 進行的使用者控制部份調整好,程式碼比較瑣碎筆者就不列了,改完之後可以調整光源的 y 軸位置,觀察接近地面時反射光的表現:

我們就用小球來表示點光源的位置,可以重複使用現有的 objects.ball 物件,因此不需要修改 setup(),直接在 render() 多渲染一次 objects.ball,並利用 worldMatrix 使得物件縮小且平移至光源位置:
function render(app) {
  // ...
  { // light bulb
    gl.bindVertexArray(objects.ball.vao);
    const worldMatrix = matrix4.multiply(
      matrix4.translate(...state.lightPosition),
      matrix4.scale(0.1, 0.1, 0.1),
    );
    twgl.setUniforms(programInfo, {
      u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
      u_worldMatrix: worldMatrix,
      u_normalMatrix: matrix4.transpose(matrix4.inverse(worldMatrix)),
      u_diffuse: [1, 1, 1],
      u_texture: textures.nil,
      u_specularExponent: 1000,
    });
    twgl.drawBufferInfo(gl, objects.ball.bufferInfo);
  }
  // ...
}
雖然筆者把燈泡的 u_diffuse 給上 [1, 1, 1],但是因為光源在球體內部,因此燈泡球體呈現黑色:

為了讓燈泡球體有顏色,我們可以在 fragment shader 中加上一個 uniform,計算 gl_FragColor 時直接加上這個顏色,這個顏色即為自發光,變數名稱命名為 u_emissive:
 precision highp float;
 uniform sampler2D u_texture;
 uniform vec3 u_specular;
 uniform float u_specularExponent;
+uniform vec3 u_emissive;
 // ...
 void main() {
   // ...
   gl_FragColor = vec4(
     diffuse * diffuseBrightness +
-    u_specular * specularBrightness,
+    u_specular * specularBrightness +
+    u_emissive,
     1
   );
 }
接下來對各個物件指定自發光顏色,筆者讓原本的球體也有一點點的亮度 [0.15, 0.15, 0.15],而原本的燈泡就給黃色 [1, 1, 0]:
 function render(app) {
   // ...
   
   { // ball
     twgl.setUniforms(programInfo, {
       // ...
+      u_emissive: [0.15, 0.15, 0.15],
     });
   }
   
   { // light bulb
     twgl.setUniforms(programInfo, {
       // ...
+      u_emissive: [1, 1, 0],
     });
   }
   { // ground
     twgl.setUniforms(programInfo, {
       // ...
+      u_emissive: [0, 0, 0],
     });
   }
 }
今天的目標就完成啦:

事實上,物件『材質』對於光的反應、產生的顏色很可能遠不只這個系列文所提到的散射光、反射光、自發光,以這篇讀取 .obj/.mtl 3D 模型材質資料的文章來看,至少就還有環境光(ambient)等等
本篇完整的程式碼可以在這邊找到: