iT邦幫忙

2021 iThome 鐵人賽

DAY 25
0
Modern Web

如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起系列 第 25

陰影(下)

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 25 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。本章節講述的是如何透過 framebuffer 使 WebGL 預先計算資料到 texture,並透過這些預計算的資料製作鏡面、陰影效果,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

Day 24 渲染好深度並繪製到畫面上,可以看到中間一顆球的輪廓,並且在其頂部的地方顏色深度更深,表示更接近深度投影的投影面,接下來讓這個拍攝深度的目標移動到 framebuffer/texture 去,並且在渲染給使用者時使用

移動拍攝深度資訊的目標至 framebuffer

現在開始除了鏡面的 framebuffer 渲染之外又要多了光源投影,為了讓渲染到不同 framebuffer 之程式能夠在程式碼中比較好分辨,筆者建立一個 {} 區域來表示這個區域在做光源投影:

function render(app) {
  // ...

  { // lightProjection
    gl.useProgram(depthProgramInfo.program);

    twgl.bindFramebufferInfo(gl, framebuffers.lightProjection);
    gl.clear(gl.DEPTH_BUFFER_BIT);

    renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
    renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
  }
  
  // ...
}

把這個區域放置在鏡面 framebuffer 渲染前,畢竟在鏡面世界可以看到陰影。因為渲染到鏡面世界時與正式渲染都使用主要的 programInfo,把 gl.useProgram() 移動下來與設定全域 uniform 到此 programInfotwgl.setUniforms() 放在一起,同時也把鏡面世界的渲染用 {} 包起來:

 function render(app) {
   const {
     gl,
-    framebuffers,
+    framebuffers, textures,
     programInfo, depthProgramInfo,
     state,
   } = app;
  
-  gl.useProgram(programInfo.program);
-
   const lightProjectionViewMatrix = matrix4.multiply( /* ... */)
   // ...
   
   { // lightProjection
     // ...
   }
   
+  gl.useProgram(programInfo.program);
   twgl.setUniforms(programInfo, {
     u_worldViewerPosition: cameraMatrix.slice(12, 15),
     u_lightDirection: lightDirection,
     u_ambient: [0.4, 0.4, 0.4],
   });
  
-  twgl.bindFramebufferInfo(gl, framebuffers.mirror);
-  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+  { // mirror
+    twgl.bindFramebufferInfo(gl, framebuffers.mirror);
+    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  
-  renderBall(app, mirrorViewMatrix, programInfo);
+    renderBall(app, mirrorViewMatrix, programInfo);
+  }
   // ...
 }

最後是讓正式『畫』的程式回復使用 viewMatrix, programInfo:

 function render(app) {
   // ...
   { // mirror
     // ...
   }
   
   gl.bindFramebuffer(gl.FRAMEBUFFER, null);

   gl.canvas.width = gl.canvas.clientWidth;
   gl.canvas.height = gl.canvas.clientHeight;
   gl.viewport(0, 0, canvas.width, canvas.height);

-  gl.useProgram(depthProgramInfo.program);
-
-  renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
-  renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
+  renderBall(app, viewMatrix, programInfo);
+  renderGround(app, viewMatrix, mirrorViewMatrix, programInfo);
 }

計算是否在陰影下

這麼一來深度資訊就會存在 textures.lightProjection 中,接下來請參考這張圖:

using-light-projection

經過光源投影之後,B 點上的深度來自 A 點,如果從 C 進行光源投影同樣會到達 B 點的位置,但是深度將會比較深,我們可以利用這一點來檢查是否在陰影下,把 C 點投影到 B 點的原理其實跟 Day 23 鏡面計算 texture 位置一樣,將在 fragment shader 中得到的表面位置進行 framebuffer 的 view matrix 轉換,也就是 lightProjectionViewMatrix

把光源投影的 view 矩陣用名為 u_lightProjectionMatrix 的 uniform 傳入,並且在 vertex shader 中 transform 成 v_lightProjection 投影後的位置:

 uniform mat4 u_mirrorMatrix;
+uniform mat4 u_lightProjectionMatrix;

 // ...

 varying float v_depth;
+varying vec4 v_lightProjection;

 void main() {
   v_depth = gl_Position.z / gl_Position.w * 0.5 + 0.5;
+  v_lightProjection = u_lightProjectionMatrix * worldPosition;
 }

在 fragment shader 方面,接收 u_lightProjectionMatrix 以及 v_lightProjection,並且跟 v_mirrorTexcoord 一樣要除以 .w 使之與 clip space 中的位置相同,接著需要兩個深度:

  • v_lightProjection.z / v_lightProjection.w 計算而來的 lightToSurfaceDepth: 表示該點(可能為 A 或是 C 點)投影下去的深度
  • u_lightProjectionMap 查詢到的值:光源投影時該點的深度,也就是 B 點上的值
// ...
uniform sampler2D u_lightProjectionMap;
varying vec4 v_lightProjection;

void main() {
  // ...

  vec2 lightProjectionCoord =
    v_lightProjection.xy / v_lightProjection.w * 0.5 + 0.5;
  float lightToSurfaceDepth =
    v_lightProjection.z / v_lightProjection.w * 0.5 + 0.5;
  float lightProjectedDepth = texture2D(
    u_lightProjectionMap,
    lightProjectionCoord
  ).r;
}

除了 lightProjectionCoord* 0.5 + 0.5 以符合 texture 上的座標範圍外,v_lightProjection.z / v_lightProjection.w 在 clip space 為 -1 ~ +1,也要傳換成 0 ~ +1,以符合深度 texture 『顏色』的 channel 值域。資料準備就緒,進行深度比較:

float occulusion = lightToSurfaceDepth > lightProjectedDepth ? 0.5 : 0.0;

diffuseBrightness *= 1.0 - occulusion;
specularBrightness *= 1.0 - occulusion * 2.0;

筆者使用 occulusion 表示『有多少成的光源被遮住』,並設定成在陰影下時減少 50% 的散射光亮度以及全部反射光,結果長這樣:

shadow-too-sensitive

真的該有陰影的地方是有陰影了:

correct-shadow-regions

不過顯然陰影區域太大,而且球體上光照的區域也有一點一點的陰影,為什麼會這樣呢?儘管像是上方示意圖中的 A 點,光源投影下來的深度與後來重算的深度可能因為 GPU 計算過程中浮點數的微小差異而導致 lightToSurfaceDepth > lightProjectedDepth 成立,為了避免這個問體我們讓 lightToSurfaceDepth 必須比 lightProjectedDepth 還要大出一定的數值才判定為有陰影,筆者讓這個值為 0.01:

 void main() {
   // ..
-  float occulusion = lightToSurfaceDepth > lightProjectedDepth ? 0.5 : 0.0;
+  float occulusion = lightToSurfaceDepth > 0.01 + lightProjectedDepth ? 0.5 : 0.0;
 }

陰影功能就完成囉:

finished-shadow

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

好了,花了這麼多篇介紹光線相關的效果,從散射光、反射光到鏡面與陰影,這些效果加在一起可以製作出頗生動的畫面,不覺得上面的畫面蠻漂亮的嗎?在此同時本系列技術文章也將進入尾聲,下個章節將製作一個完整的場景作為完結作品:帆船與海


上一篇
陰影(上)
下一篇
3D 物件檔案 — .obj
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言