iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Modern Web

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

鏡面效果

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

取得了 framebuffer 這個工具,把球體畫在在一幅畫中,已經完成鏡面效果所需要的基石,本篇來把鏡面效果實做出來

透過 TWGL 簡化建立 framebuffer 的程式碼

上篇建立 framebuffer 時直接使用 WebGL API 來建立 framebuffer,其實 TWGL 已經有時實做好一定程度的包裝,我們可以呼叫 twgl.createFramebufferInfo(),它會建立好 framebuffer, textures 並且為他們建立關聯:

framebuffers.mirror = twgl.createFramebufferInfo(
  gl,
  null, // attachments
  2048, // width
  2048, // height
);

attachments 讓開發者可以指定要寫入的 texture 的設定,例如說 gl.COLOR_ATTACHMENT0 所對應的顏色部份要寫入的 texture 的設定,筆者傳 null 讓 twgl 使用預設值建立一個顏色 texture 以及一個深度資訊 texture,因為接下來要實做的功能為鏡面,把此 framebuffer 命名為 framebuffers.mirror

那麼要怎麼取得自動建立的 texture 呢?嘗試用 Console 查看建立的物件 framebufferInfo 看起來像是這樣:

framebuffer-info-content

看起來就放在 attachments 下呢,那麼把 texture 指定到 textures 物件中以便之後取用:

textures.mirror = framebuffers.mirror.attachments[0];

值得注意的是,framebufferInfo 同時包含了長寬資訊,如果使用 twgl.bindFramebufferInfo() 來做 framebuffer 的切換,它同時會幫我們呼叫 gl.viewport() 調整渲染區域,因此在繪製階段也使用 twgl 所提供的工具:

 function render(app) {
   // ...

-  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers.fb.framebuffer);
-  gl.viewport(0, 0, framebuffers.fb.width, framebuffers.fb.height);
-  gl.clear(gl.COLOR_BUFFER_BIT);
+  twgl.bindFramebufferInfo(gl, framebuffers.mirror);
+  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

   renderBall(app, viewMatrix);
   // ...
 }

可以發現在 gl.clear() 時除了清除 gl.COLOR_BUFFER_BIT,也要清除gl.DEPTH_BUFFER_BIT,這是因為 twgl.createFramebufferInfo() 所建立的組合預設包含了一張深度資訊,這個資訊也得清除以避免第二次渲染到 framebuffer 時產生問題

繪製鏡像中的世界

目前在 framebuffer 中繪製的球體就是正常狀態下看到的球體,那麼要怎麼繪製『鏡像』的樣子呢?想像一個觀察著看著鏡面中的一顆球:

mirrored-camera

橘色箭頭為實際光的路線,把光線打直可以獲得一個鏡面中的觀察者看著真實世界(灰色箭頭與眼睛),因此繪製鏡像中的世界時,把相機移動到鏡面中拍一次,我們就獲得了鏡面世界的成像,準備好在繪製場景時使用

筆者為此章節實做的相機控制方式使用了不同於 matrix4.lookAt()cameraMatrix 產生方式:

const cameraMatrix = matrix4.multiply(
  matrix4.translate(...state.cameraViewing),
  matrix4.yRotate(state.cameraRotationXY[1]),
  matrix4.xRotate(state.cameraRotationXY[0]),
  matrix4.translate(0, 0, state.cameraDistance),
);

用白話文來說,目前的相機一開始在 [0, 0, 0] 看著 -z 方向,先往 +z 方向移動 state.cameraDistance、轉動 x 軸 state.cameraRotationXY[0]、轉動 y 軸 state.cameraRotationXY[1],這時相機會在半徑為 state.cameraDistance 的球體表面上看著原點,最後 state.cameraViewing 的平移是指移動相機所看的目標,如果使用 y = 0 形成的平面作為鏡面,只要讓轉動 x 軸時反向,就變成對應在鏡面中的相機,並且進而算出鏡面使用的 viewMatrix:

const mirrorCameraMatrix = matrix4.multiply(
  matrix4.translate(...state.cameraViewing),
  matrix4.yRotate(state.cameraRotationXY[1]),
  matrix4.xRotate(-state.cameraRotationXY[0]),
  matrix4.translate(0, 0, state.cameraDistance),
);

const mirrorViewMatrix = matrix4.multiply(
  matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
  matrix4.inverse(mirrorCameraMatrix),
);

接著讓地板為 y = 0 形成的平面,與球體一同向 +y 方向移動一單位:

 function renderBall(app, viewMatrix) {
   // ...
   const worldMatrix = matrix4.multiply(
-    matrix4.translate(0, 0, 0),
     matrix4.scale(1, 1, 1),
   );
   
   // ...
 }

 function renderGround(app, viewMatrix) {
   // ...
-  const worldMatrix = matrix4.multiply(
-    matrix4.translate(0, -1, 0),
-    matrix4.scale(10, 1, 10),
-  );
+  const worldMatrix = matrix4.scale(10, 1, 10);

   // ...
 }

最後在繪製鏡像中的世界時使用 mirrorViewMatrix:

 function render(app) {
   // ...

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

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

   // ...
 }

正式渲染鏡面時取用 texture 中對應的位置

儘管繪製出鏡面世界中的樣子,拍了一張鏡面世界的照片,但是要怎麼在正式『畫』的時候找到鏡面世界的照片對應的位置呢?請看下面這張圖:

mirror-texcoord

物件上的一個點 A,是經過物件自身 worldMatrix transform 的位置,再經過 mirrorViewMatrix transform 到鏡面世界的照片上(點 B);正式『畫』鏡面物件時,我們知道的是 C 點的位置(worldPosition),而這個點座落在 A 與 B 點之間,因此拿著 C 點做 mirrorViewMatrix transform 便可以獲得對應的 B 點,這時 B 點是 clip space 中的位置,只要再將此位置向量加一除以二就能得到 texture 上的位置囉

也就是說,在正式『畫』的時候也會需要 mirrorViewMatrix,uniform 命名為 u_mirrorMatrix,並且在 vertex shader 中計算出 B 點,透過 varying v_mirrorTexcoord 傳送給 fragment shader:

 // ...
+uniform mat4 u_mirrorMatrix;
+varying vec4 v_mirrorTexcoord;

 void main() {
   // ...

-  vec3 worldPosition = (u_worldMatrix * a_position).xyz;
-  v_surfaceToViewer = u_worldViewerPosition - worldPosition;
+  vec4 worldPosition = u_worldMatrix * a_position;
+  v_surfaceToViewer = u_worldViewerPosition - worldPosition.xyz;
+
+  v_mirrorTexcoord = u_mirrorMatrix * worldPosition;
 }

到 fragment shader,筆者打算讓鏡面世界的照片放在 u_diffuseMap,不過鏡面物體取用 texture 的方式將會與其他物件不同,因此加入一個 uniform u_useMirrorTexcoord 來控制是否要使用 v_mirrorTexcoord

 // ...
+uniform bool u_useMirrorTexcoord;
+varying vec4 v_mirrorTexcoord;

 void main() {
+  vec2 texcoord = u_useMirrorTexcoord ?
+    (v_mirrorTexcoord.xy / v_mirrorTexcoord.w) * 0.5 + 0.5 :
+    v_texcoord;
-  vec3 diffuse = u_diffuse + texture2D(u_diffuseMap, v_texcoord).rgb;
+  vec3 diffuse = u_diffuse + texture2D(u_diffuseMap, texcoord).rgb;
   vec3 ambient = u_ambient * diffuse;
-  vec3 normal = texture2D(u_normalMap, v_texcoord).xyz * 2.0 - 1.0;
+  vec3 normal = texture2D(u_normalMap, texcoord).xyz * 2.0 - 1.0;

   // ...
 }

可以注意到 u_useMirrorTexcoord 為 true 時,有個 (v_mirrorTexcoord.xy / v_mirrorTexcoord.w),為什麼要除以 .w 呢?還記得 Day 12 時,頂點位置在進入 clip space 之前,會把 gl_Position.x, gl_Position.y, gl_Position.z 都除以 gl_Position.w,而 varying v_mirrorTexcoord 當然就沒有這樣的行為了,我們得自己實做,然後 * 0.5 + 0.5 就是把 clip space 位置(-1 ~ +1)轉換成 texture 上的 texcoord (0 ~ +1)

完成 shader 的修改,剩下的就是把需要餵進去的 uniform 餵進去,並且在正式『
『畫』的時候也畫出球體:

 function render(app) {
   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);

+  renderBall(app, viewMatrix);
-  renderGround(app, viewMatrix);
+  renderGround(app, viewMatrix, mirrorViewMatrix);
 }
 
-function renderGround(app, viewMatrix) {
+function renderGround(app, viewMatrix, mirrorViewMatrix) {
   // ...

   twgl.setUniforms(programInfo, {
     // ...
-    u_diffuseMap: textures.fb,
+    u_diffuseMap: textures.mirror,
     // ...
+    u_useMirrorTexcoord: true,
+    u_mirrorMatrix: mirrorViewMatrix,
   });

   twgl.drawBufferInfo(gl, objects.ground.bufferInfo);
+
+  twgl.setUniforms(programInfo, {
+    u_useMirrorTexcoord: false,
+  });
 }

在最後還有特地把 u_useMirrorTexcoord 關閉,因為只有地板物件會需要這個特殊的模式,而 uniform 是跟著 program 的,畫完此物件立刻關閉可以避免影響到其他物件的渲染

鏡面效果就完成了:

mirror-demo

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


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

尚未有邦友留言

立即登入留言