iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0
Modern Web

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

Framebuffer

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

在上個章節的最後我們不僅有散射光、反射光,還有使得物體表面有更多凹凸細節的 normal map,筆者從這個實做成果再進行修改,材質改用 Commission - Medievalscale,並且加上環境光 u_ambient 使物體有一個最低亮度,最後讓相機操作更加完整:透過拖曳平移視角,使用滑鼠右鍵、滾輪或是多指手勢可對視角進行縮放、轉動。這個章節便從這個進度作為起始點

05-framebuffer-shadow.html / 05-framebuffer-shadow.js

05-framebuffer-shadow-start

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

可以發現點光源改回平行光,而且會隨著時間改變方向,然後地板變成全黑的了,因為這是本章接下來要實做的

Framebuffer 是什麼

簡單來說,這是一個 WebGL 渲染的目標,本系列文到這邊渲染的目標皆為 <canvas /> 元件,畫給使用者看的,而 framebuffer 可以改變這件事,其中一個選項是使之渲染到 texture 上

為什麼要渲染到 texture 上呢?假設今天有一面鏡子,鏡子上所看到的圖像,等同於從鏡子中的相機看回原本世界,因此可以先從鏡子內繪製一次場景到一個 texture 上,接著繪製鏡子時就可以拿此 texture 來繪製;甚至感覺比較沒有關聯的陰影效果也需要透過 framebuffer 的功能,事先請 GPU 做一些運算,在正式『畫』的時候使用

初嘗 Framebuffer

在實做鏡面或是陰影之前,先來專注在 framebuffer 這個功能上,畢竟想想也知道鏡子、陰影需要的不會只是 framebuffer,還需要一些能夠讓物件位置成像能對得起來的方法,因此本篇的目標是:渲染到 texture 上,接著渲染地板時使用該 texture,效果上來說像是把畫面上的球體變到黑色地板中

首先在 setup() 中建立 framebuffer,並且把目標對準(bind)新建立的 framebuffer:

const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

同時也建立好 texture 作為 framebuffer 渲染的目標,筆者先命名為 fb,framebuffer 的縮寫:

textures.fb = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, textures.fb);

const width = 2048;
const height = 2048;

gl.texImage2D(
  gl.TEXTURE_2D,
  0, // level
  gl.RGBA, // internalFormat
  width,
  height,
  0, // border
  gl.RGBA, // format
  gl.UNSIGNED_BYTE, // type
  null, // data
);

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

可以看到建立了一個 2048x2048 大小的 texture,並且傳 null 讓資料留白,同時也得關閉 mipmap 功能,畢竟渲染到 texture 上之後,如果還要呼叫 gl.generateMipmap() 計算縮圖就太浪費資源了,有需要的話可以回去參考 Day 7 的講解

然後是建立『framebuffer 與 texture 的關聯』:

gl.framebufferTexture2D(
  gl.FRAMEBUFFER,
  gl.COLOR_ATTACHMENT0, // attachment
  gl.TEXTURE_2D,
  textures.fb,
  0, // level
);

呼叫 gl.framebufferTexture2D() 使得當下對準的 framebuffer 的一個 attachment 對準指定的 texture,因為我們現在關心的是顏色,gl.COLOR_ATTACHMENT0 使得渲染到 framebuffer 時,『顏色(gl_FragColor)』部份會寫入,最後 level 表示要寫入 mipmap 的哪一層

建立完成後,在 app 下加入 framebuffers 物件來存放建立好的 framebuffer:

 async function setup() {
 // ...

+  const framebuffers = {}

   {
     const framebuffer = gl.createFramebuffer();
     gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

     // ...
     
+    framebuffers.fb = {
+      framebuffer, width, height,
+    };
   }

   return {
     gl,
     programInfo,
-    textures, objects,
+    textures, framebuffers, objects,
     state: {
       fieldOfView: degToRad(45),
       cameraRotationXY: [degToRad(-45), 0],
       cameraDistance: 15,
       cameraViewing: [0, 0, 0],
       cameraViewingVelocity: [0, 0, 0],
       lightRotationXY: [0, 0],
     },
     time: 0,
   };
 }

渲染到 Framebuffer

如果接下來會需要先渲染到 framebuffer,再渲染到畫面,那麼可以想見某些物體會需要繪製兩次,為了避免重複程式碼,筆者把標注 ball, ground 的花括弧 {} 區域獨立成兩個 function:

  • function renderBall(app, viewMatrix)
  • function renderGround(app, viewMatrix)

準備完成後,在 render() 設定好全域 uniform 之後,呼叫 gl.bindFramebuffer() 切換到 framebuffer, 像這樣渲染到 framebuffer 並寫入 textures.fb:

gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers.fb.framebuffer);
gl.viewport(0, 0, framebuffers.fb.width, framebuffers.fb.height);

renderBall(app, viewMatrix);

要記得使用 gl.viewport() 設定渲染長寬跟 texture 一樣大,接著就跟原本渲染到畫面上一樣,因此直接呼叫 renderBall() 渲染球體

那麼要怎麼讓渲染目標切換回 <canvas /> 呢?呼叫 gl.bindFramebuffer() 並傳入 null 即可,不過一樣要記得把渲染長寬設定好:

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);

renderGround(app, viewMatrix);

呼叫 renderGround() 渲染地板的同時,設定其 uniform 時在 u_diffuseMap 填上 textures.fb 使地板顯示在 framebuffer 時渲染的樣子:

 function renderGround(app, viewMatrix) {
   // ...

   twgl.setUniforms(programInfo, {
     u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
     u_worldMatrix: worldMatrix,
     u_normalMatrix: matrix4.transpose(matrix4.inverse(worldMatrix)),
     u_diffuse: [0, 0, 0],
-    u_diffuseMap: textures.nil,
+    u_diffuseMap: textures.fb,
     u_normalMap: textures.nilNormal,
     u_specular: [1, 1, 1],
     u_specularExponent: 200,
     u_emissive: [0, 0, 0],
   });
   
   twgl.drawBufferInfo(gl, objects.ground.bufferInfo);
 }

存檔重整,轉一下的確看得出來球體渲染在一幅畫上的感覺,但是平移會看到殘影:

framebuffer-texture-rendered-but-with-afterimage

有殘影是因為上一次渲染到 texture 的東西不會被自動清除,因此透過 Day 1 的油漆工具清除 framebuffer-texture:

 async function setup() {
   // ...

   gl.enable(gl.CULL_FACE);
   gl.enable(gl.DEPTH_TEST);
+  gl.clearColor(1, 1, 1, 1);
   return {
     // ...
   };
 }

 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);

   renderBall(app, viewMatrix);
 }

afterimage-gone

雖然清除的顏色是白色,但是因為光線方向會移動導致散射(textures.fb 設定在 u_diffuseMap 上)亮度下降,除此之外球體成功透過 framebuffer 渲染到 texture,並繪製在平面上了,完整程式碼可以在這邊找到:


上一篇
Normal Map
下一篇
鏡面效果
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言