镜面效果

大家好,我是西瓜,你现在看到的是 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

本篇的完整程序码可以在这边找到:


<<:  [Day22] Tableau 轻松学 - TabPy 介绍

>>:  初学者跪着学JavaScript Day8 : 资料型别:BigInt

Day23 切版笔记- 人员介绍卡片

运用到的观念 border搭配伪元素制作出三角形区块 绝对定位&相对定位 用:hover ...

[Day18] Webpack - 预处理器

前几天学到了 PostCSS、Babel 这些後处理器,来协助在打包时改写原始码来支援各种浏览器,今...

第46天-fd-find 代替 find

使用 fd-find 代替 find,效率更好 下载方式 : sudo apt-get instal...

Day 13 - PHP SDK: 查询订单状态

昨天 Day 12 介绍了 Sinopac PHP SDK 关於建立订单的部分,接着今天要介绍的是查...

[神经机器翻译理论与实作] 这个翻译不大正经

前言 也许你会觉得,这个标题下得很神经。没错!因为今天要正式进入新的主题-神经机器翻译。我们今天将会...