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,并绘制在平面上了,完整程序码可以在这边找到:


<<:  Day-08 Android专案架构

>>:  Proxmox VE 安装虚拟机:Windows 10 (二)

D20/ 怎麽在 compose 与 non-compoe 间传资料 - Compose Side-Effect part 2

今天大概会聊到的范围 rememberUpdateState 上一篇聊到,SideEffect 周...

追求JS小姊姊系列 Day25 -- 工具人、姐妹的存活原理:宣告变数的有效区域

前情提要: 看完记忆体储存差异,现在要来谈谈全域污染这件事。 基本scope概念 所谓的范畴Scop...

Day 24 - 云端服务评估业务篇

从入职到现在也帮助部门开发了许多的平台和应用服务,而原本虚拟主机的签约也快到期了,然而前阵子除了接触...

[Day3]C# 鸡础观念- 核心的数据成员~变数(一)

变数的世界 在C#世界中,基本上大家都希望有自己的归属感,所以他们都会被赋予一个的种族, 这也是所谓...

day11: CSS style 规划 - utility CSS(Tailwind)-2

这个章节我们来介绍怎麽安装 Tailwind,和使用 Tailwind 我们一样使用 create-...