iT邦幫忙

2021 iThome 鐵人賽

DAY 24
0
Modern Web

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

陰影(上)

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

有了 framebuffer 的幫助,我們可以動用 GPU 的力量事先運算,在正式繪製畫面時使用。繼鏡面完成之後,根據 Day 22 所說,另一個 framebuffer 的應用是陰影,接下來就來介紹如何製作出陰影效果

如何『拍攝』深度照片

陰影的產生是因為物體表面到光源之間有其他物體而被遮住,為了得知有沒有被遮住,我們可以從光源出發『拍攝』一次場景,從上篇改用 twgl 時有提到 framebuffer 也可以包含深度資訊,實際繪製畫面時就可以利用深度資訊來得知是否在陰影下

shadow

但是目前的光源是平行光,這樣要怎麼拍攝?首先,利用 Day 11 的 Orthogonal 3D 投影,如果光線是直直往 +y 的方向與地面垂直倒是蠻容易想像的,不過如果不是的時候,那麼感覺拍攝的範圍就沒辦法很大(淡藍色區域為投影區域,藍色面為成像面):

orthogonal-light-projection

筆者想到在矩陣運算中還有一個叫做 shear,可以把一個空間中的矩形轉換成平行四邊形,透過這個工具,可以使得投影區域為平行四邊形:

shear-orthogonal-light-projection

建立存放深度資訊的 texture

如果去看 twgl.createFramebufferInfo() 預設建立的 framebuffer 與 textures 組合,可以看到一個存放顏色的 texture,但是另一個存放深度資訊卻不是 texture,是一個叫做 WebGLRenderbuffer 的東西:

WebGLTexture-and-WebGLRenderbuffer

經過測試,WebGLRenderbuffer 無法當成 texture 使用,為了建立能放深度資訊的 texture,需要 WebGL extension WEBGL_depth_texture,跟 Day 16 的 VAO 功能一樣,不是 WebGL spec 的一部分,幸好 WEBGL_depth_texture 在主流瀏覽器中都有支援,只是需要寫一點程式來啟用:

async function setup() {
  const gl = canvas.getContext('webgl');
  // ...

  const webglDepthTexExt = gl.getExtension('WEBGL_depth_texture');
  if (!webglDepthTexExt) {
    throw new Error('Your browser does not support WebGL ext: WEBGL_depth_texture')
  }

  // ...
}

啟用後,建立 framebuffer-texture 時便可指定 texture 的格式為 gl.DEPTH_COMPONENT 存放深度資訊,筆者將此 framebuffer-texture 命名為 lightProjection:

async function setup() {
  // ...

  framebuffers.lightProjection = twgl.createFramebufferInfo(gl, [{
    attachmentPoint: gl.DEPTH_ATTACHMENT,
    format: gl.DEPTH_COMPONENT,
  }], 2048, 2048);
  textures.lightProjection = framebuffers.lightProjection.attachments[0];

  // ...
}

建立一組拍攝深度用的 shader

在拍攝深度時,顏色計算就變成多餘的,同時為了預覽深度照片的成像,因此建立了一個簡單的 fragment shader,待會會與現有的 vertexShaderSource 連結:

precision highp float;

varying float v_depth;

void main() {
  gl_FragColor = vec4(v_depth, v_depth, v_depth, 1);
}

可以看到這個 fragment shader 需要 varying v_depth,因此在 vertex shader 中輸出:

+varying float v_depth;

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

因為 gl_Position.z / gl_Position.w clip space 中的範圍是 -1 ~ +1,因此 * 0.5 + 0.5 使之介於 0 ~ +1 用於顏色輸出,並且使用 twgl.createProgramInfo() 建立 depthProgramInfo:

 async function setup() {
   // ...
+  const depthProgramInfo = twgl.createProgramInfo(gl, 
+    [vertexShaderSource, depthFragmentShaderSource]
+  );

   return {
     gl,
-    programInfo,
+    programInfo, depthProgramInfo,
     // ...
   }
 }

產生 light projection 用的 transform matrix

現有的光線方向向量是由 state.lightRotationXY 所控制,根據產生程式:

const lightDirection = matrix4.transformVector(
  matrix4.multiply(
    matrix4.yRotate(state.lightRotationXY[1]),
    matrix4.xRotate(state.lightRotationXY[0]),
  ),
  [0, -1, 0, 1],
).slice(0, 3);

光線一開始向著 -y 方向,接著旋轉 x 軸 state.lightRotationXY[0] 以及 y 軸 state.lightRotationXY[1],場景物件放置在 xz 平面上,因此 shear 時使用的角度為旋轉 x 軸的 state.lightRotationXY[0],整個 transform 經過以下步驟:

  1. 移動視角,因 matrix4.projection() 捕捉的正面看著 +z,需要先旋轉使之看著 -y,接著旋轉 y 軸 state.lightRotationXY[1],這兩個轉換就是 Day 13 的視角 transform,需要做反矩陣
  2. shearing,同樣因為 matrix4.projection() 捕捉的正面看著 +z,依據角度偏移 y 值:y' = y + z * tan(state.lightRotationXY[0])
  3. 使用 matrix4.projection() 進行投影,捕捉場景中 xz 介於 0 ~ 20,y (深度)介於 0 ~ 10 的物件
  4. matrix4.projection() 會把原點偏移到左上,透過 matrix4.translate(1, -1, 0) 轉換回來,最後捕捉場景中 xz 介於 -10 ~ +10,y 介於 -5 ~ +5 的物件

把這些 transform 通通融合進 lightProjectionViewMatrix:

function render(app) {
  // ...
  const lightProjectionViewMatrix = matrix4.multiply(
    matrix4.translate(1, -1, 0),
    matrix4.projection(20, 20, 10),
    [ // shearing
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, Math.tan(state.lightRotationXY[0]), 1, 0,
      0, 0, 0, 1,
    ],
    matrix4.inverse(
      matrix4.multiply(
        matrix4.yRotate(state.lightRotationXY[1]),
        matrix4.xRotate(degToRad(90)),
      )
    ),
  );

  // ...
}

視覺化深度到畫面上看看

因為現在有多個 program,得在 renderBall() 以及 renderGround() 時指定使用的 program,因此加入 programInfo 參數到這兩個 function

-function renderBall(app, viewMatrix) {
-  const { gl, programInfo, textures, objects } = app;
+function renderBall(app, viewMatrix, programInfo) {
+  const { gl, textures, objects } = app;
   // ...
 }

-function renderGround(app, viewMatrix, mirrorViewMatrix) {
-  const { gl, programInfo, textures, objects } = app;
+function renderGround(app, viewMatrix, mirrorViewMatrix, programInfo) {
+  const { gl, textures, objects } = app;
   // ...
 }

並且修改現有渲染到畫面上的流程使用 depthProgramInfo 以及 lightProjectionViewMatrix:

 function render(app) {
   const {
     gl,
     framebuffers,
-    programInfo,
+    programInfo, depthProgramInfo,
     state,
   } = app;

   twgl.bindFramebufferInfo(gl, framebuffers.mirror);
   gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

-  renderBall(app, mirrorViewMatrix);
+  renderBall(app, mirrorViewMatrix, programInfo);

   gl.bindFramebuffer(gl.FRAMEBUFFER, null);
   // ...

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

我們就獲得了灰階的深度視覺化:

visualized-depth

至於回到正式『畫』時使用這些資訊繪製陰影的部份,將在下篇繼續實做,本篇的完整程式碼可以在這邊找到:


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

尚未有邦友留言

立即登入留言