iT邦幫忙

2021 iThome 鐵人賽

DAY 28
1
Modern Web

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

繪製 Skybox

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 28 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,在本系列文的最後章節將製作一個完整的場景作為完結作品:帆船與海,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

Day 27 建立 cube texture 並把圖片資料下載好,本篇的主要目標就是把 skybox 繪製上去,同時我們也可以讓海面反射天空,使得場景更栩栩如生

Skybox Shader

因為 skybox 是『背景』,因此 skybox 這個『物件』最終到達 clip space 時應該落在距離觀察位置最遠但是還看得到的地方,回想 Day 12 這邊提到成像時投影到 z = -1 平面、看著 +z 方向,那麼離觀察最遠的平面為 z = 1,而且為了填滿整個畫面,skybox 物件在 clip space 中即為 x, y 範圍於 -1 ~ +1 的 z = 1 平面,剛好 twgl.primitives.createXYQuadVertices() 幾乎可以直接做出我們需要的這個平面,就差在他沒有 z 的值

這樣聽起來物件的頂點應該是不需要 transform,就只是輸出到 gl_Position 的 z 需要設定成 1,比較需要操心的是對 cube texture 取樣時的 normal 法向量,我們將從 clip space 頂點位置出發,透過『某種 transform』指向使用者觀看區域的邊界頂點,接下來就跟一般 texture 一樣利用 varying 補間得到每個 pixel 取用 cube texture 的 normal 法向量

綜合以上,skybox 之 vertex shader 實做:

attribute vec2 a_position;
uniform mat4 u_matrix;

varying vec3 v_normal;

void main() {
  gl_Position = vec4(a_position, 1, 1);
  v_normal = (u_matrix * gl_Position).xyz;
}

twgl.primitives.createXYQuadVertices() 頂點將輸入到 a_position,我們只需要其 x, y 資料就好因此設定成 vec2gl_Position 照著上面所說直接輸出並設定 z 為 1;u_matrix 變成轉換成 normal 的矩陣,轉換好透過 v_normal 給 fragment shader 使用。fragment shader 的部份就變得很簡單,純粹透過 v_normal 把顏色從 cube texture 中取出即可:

precision highp float;

varying vec3 v_normal;

uniform samplerCube u_skyboxMap;

void main() {
  gl_FragColor = textureCube(u_skyboxMap, normalize(v_normal));
}

分別把這兩個 shader 原始碼寫在 literals string 並稱為 skyboxVS, skyboxFS,接著建立對應的 programInfo 並放在 app.skyboxProgramInfo:

 async function setup() {
   // ...
   const oceanProgramInfo = twgl.createProgramInfo(gl, [vertexShaderSource, oceanFragmentShaderSource]);
+  const skyboxProgramInfo = twgl.createProgramInfo(gl, [skyboxVS, skyboxFS]);

   // ...

   return {
     gl,
-    programInfo, depthProgramInfo, oceanProgramInfo,
+    programInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
     textures, framebuffers, objects,
     // ...
   }
 }

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

   // ...

 }

最後把 skybox 『物件』建立好,別忘了其 VAO 要使 buffer 與新的 skyboxProgramInfo 綁定:

async function setup() {
  // ...

  { // skybox
    const attribs = twgl.primitives.createXYQuadVertices()
    const bufferInfo = twgl.createBufferInfoFromArrays(gl, attribs);
    const vao = twgl.createVAOFromBufferInfo(gl, skyboxProgramInfo, bufferInfo);

    objects.skybox = {
      attribs,
      bufferInfo,
      vao,
    };
  }

  // ...
}

取得 Cube texture normal 之『某種 transform』

法向量將從 clip space 頂點位置出發,從上方 vertex shader 實做可以看到直接從 gl_Position 出發,也就是這四個位置(實際上為兩個三角形六個頂點):

[-1, -1,  1]
[ 1, -1,  1]
[-1,  1,  1]
[ 1,  1,  1]

理論上我們是可以透過 app.state.cameraRotationXYcameraViewing, cameraDistance 是對平移的控制,可以無視)算出對應的 transform,但是另一個方式是透過現成的 viewMatrix,這樣的話視角不使用 app.state.cameraRotationXY 時也可以通用

下圖為場景位置到 clip space 轉換從正上方俯瞰的示意圖,原本使用 viewMatrix 是要把場景中透過 worldMatrix 轉換的物件投影到 clip space,也就是下圖中橘色箭頭方向,但是看著上方四個點的座標,現在的出發點是 clip space 中的位置(下圖黑點),如果轉換成觀察者所能看到最遠平面的四個角落(下圖深藍綠色點),這樣一來再單位矩陣化便成為 cube texture 取樣時所需的 normal 法向量,而這樣的轉換在下圖中為同黑色箭頭,稍微想一下,這個動作其實是把 clip space 轉換回場景位置,那麼說也就是 viewMatrix 的『反向』 -- 它的反矩陣

clip-space-to-world-normal

不過 viewMatrix 會包含平移,需要將平移效果移除,我們把 viewMatrix 拆開來看:

viewMatrix = 
  matrix4.perspective(...) * matrix4.inverse(cameraMatrix)

平移會來自於 matrix4.inverse(cameraMatrix),而平移為 4x4 矩陣中最後一行的前三個元素,只要將之設定為 0 即可。綜合以上,轉換成 normal 所需要的矩陣 u_matrix 計算方式為:

inversedCameraMatrix = matrix4.inverse(cameraMatrix)
u_matrix = inverse(
  matrix4.perspective(...) *
    [
      ...inversedCameraMatrix[0..3],
      ...inversedCameraMatrix[4..7],
      ...inversedCameraMatrix[8..11],
      0, 0, 0, inversedCameraMatrix[15]
    ]
)

這邊 inversedCameraMatrix[0..3] 表示取得 inversedCameraMatrix 的 0, 1, 2, 3 的元素

繼續實做之前,在程式碼中將 viewMatrix 的兩個矩陣獨立成 projectionMatrix 以及 inversedCameraMatrix:

 function render(app) {
   // ...
+  const projectionMatrix = matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000);
+
   // ...
   const cameraMatrix = matrix4.multiply(
     // ...
   );
-
-  const viewMatrix = matrix4.multiply(
-    matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
-    matrix4.inverse(cameraMatrix),
-  );
+  const inversedCameraMatrix = matrix4.inverse(cameraMatrix);
+  const viewMatrix = matrix4.multiply(projectionMatrix, inversedCameraMatrix);

實做 skybox 的繪製

與其他物件一樣,建立一個 function 來實做 skybox 的繪製,並接收拆分出來的 projectionMatrix 以及 inversedCameraMatrix

function renderSkybox(app, projectionMatrix, inversedCameraMatrix) {
  const { gl, skyboxProgramInfo, objects, textures } = app;
  gl.bindVertexArray(objects.skybox.vao);
}

照著上方所描述的 u_matrix 計算公式實做,並且把 skybox cube texture 傳入 uniform:

function renderSkybox(app, projectionMatrix, inversedCameraMatrix) {
  // ...
  twgl.setUniforms(skyboxProgramInfo, {
    u_skyboxMap: textures.skybox,
    u_matrix: matrix4.inverse(
      matrix4.multiply(
        projectionMatrix,
        [
          ...inversedCameraMatrix.slice(0, 12),
          0, 0, 0, inversedCameraMatrix[15], // remove translation
        ],
      ),
    ),
  });
}

uniform 輸入完成,進行『畫』這個動作之前還有一件事,在 clip space z = 1 因為沒有『小於』最遠深度 z = 1 而不會被判定在 clip space,所以需要設定成『小於等於』,並在『畫』完之後設定回來避免影響到其他物件的繪製:

function renderSkybox(app, projectionMatrix, inversedCameraMatrix) {
  // ...

  gl.depthFunc(gl.LEQUAL);
  twgl.drawBufferInfo(gl, objects.skybox.bufferInfo);
  gl.depthFunc(gl.LESS); // reset to default
}

最後在 render() 中切換好 shader 並呼叫 renderSkybox():

 function render(app) {
   // ...
+  gl.useProgram(skyboxProgramInfo.program);
+  renderSkybox(app, projectionMatrix, inversedCameraMatrix);
 }

拉低視角,就可以看到天空囉:

skybox-rendered

使海面反射 skybox

說到底就是要在繪製鏡像世界時繪製 skybox,在鏡像世界有個自己的 viewMatrix 叫做 reflectionMatrix,我們也必須把他拆開來:

 function render(app) {
   // ...
   const reflectionCameraMatrix = matrix4.multiply(
     // ...
   );
-
-  const reflectionMatrix = matrix4.multiply(
-    matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
-    matrix4.inverse(reflectionCameraMatrix),
-  );
+  const inversedReflectionCameraMatrix = matrix4.inverse(reflectionCameraMatrix);
+  const reflectionMatrix = matrix4.multiply(projectionMatrix, inversedReflectionCameraMatrix);

projectionMatrix 跟第一次拆出來的相同,直接共用即可

接下來就是切換 shader 並且在繪製鏡像世界時呼叫 renderSkybox(),同時也因為海面會反射整個天空,就不需要自帶顏色了:

 function render(app) {
   // ...

   { // reflection
     // ...
     renderBoat(app, reflectionMatrix, programInfo);
+
+    gl.useProgram(skyboxProgramInfo.program);
+    renderSkybox(app, projectionMatrix, inversedReflectionCameraMatrix);
   }

   gl.bindFramebuffer(gl.FRAMEBUFFER, null);

   twgl.resizeCanvasToDisplaySize(gl.canvas, state.resolutionRatio);
   gl.viewport(0, 0, canvas.width, canvas.height);

+  gl.useProgram(programInfo.program);
+
   renderBoat(app, viewMatrix, programInfo);
   // ...
 }

 // ...
 
 function renderOcean(app, viewMatrix, reflectionMatrix, programInfo) {
   // ...

   twgl.setUniforms(programInfo, {
     // ...
-    u_diffuse: [45/255, 141/255, 169/255],
+    u_diffuse: [0, 0, 0],
     // ...
   });

   // ...
 }

海面變成天空的鏡子,晴朗天氣的部份也就完成了:

ocean-reflecting-sunny-sky

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


上一篇
.obj 之繪製 & Skybox
下一篇
半透明的文字看板
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言