阴影(下)

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 25 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。本章节讲述的是如何透过 framebuffer 使 WebGL 预先计算资料到 texture,并透过这些预计算的资料制作镜面、阴影效果,如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容

Day 24 渲染好深度并绘制到画面上,可以看到中间一颗球的轮廓,并且在其顶部的地方颜色深度更深,表示更接近深度投影的投影面,接下来让这个拍摄深度的目标移动到 framebuffer/texture 去,并且在渲染给使用者时使用

移动拍摄深度资讯的目标至 framebuffer

现在开始除了镜面的 framebuffer 渲染之外又要多了光源投影,为了让渲染到不同 framebuffer 之程序能够在程序码中比较好分辨,笔者建立一个 {} 区域来表示这个区域在做光源投影:

function render(app) {
  // ...

  { // lightProjection
    gl.useProgram(depthProgramInfo.program);

    twgl.bindFramebufferInfo(gl, framebuffers.lightProjection);
    gl.clear(gl.DEPTH_BUFFER_BIT);

    renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
    renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
  }
  
  // ...
}

把这个区域放置在镜面 framebuffer 渲染前,毕竟在镜面世界可以看到阴影。因为渲染到镜面世界时与正式渲染都使用主要的 programInfo,把 gl.useProgram() 移动下来与设定全域 uniform 到此 programInfotwgl.setUniforms() 放在一起,同时也把镜面世界的渲染用 {} 包起来:

 function render(app) {
   const {
     gl,
-    framebuffers,
+    framebuffers, textures,
     programInfo, depthProgramInfo,
     state,
   } = app;
  
-  gl.useProgram(programInfo.program);
-
   const lightProjectionViewMatrix = matrix4.multiply( /* ... */)
   // ...
   
   { // lightProjection
     // ...
   }
   
+  gl.useProgram(programInfo.program);
   twgl.setUniforms(programInfo, {
     u_worldViewerPosition: cameraMatrix.slice(12, 15),
     u_lightDirection: lightDirection,
     u_ambient: [0.4, 0.4, 0.4],
   });
  
-  twgl.bindFramebufferInfo(gl, framebuffers.mirror);
-  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+  { // mirror
+    twgl.bindFramebufferInfo(gl, framebuffers.mirror);
+    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  
-  renderBall(app, mirrorViewMatrix, programInfo);
+    renderBall(app, mirrorViewMatrix, programInfo);
+  }
   // ...
 }

最後是让正式『画』的程序回复使用 viewMatrix, programInfo:

 function render(app) {
   // ...
   { // mirror
     // ...
   }
   
   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);

-  gl.useProgram(depthProgramInfo.program);
-
-  renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
-  renderGround(app, lightProjectionViewMatrix, mirrorViewMatrix, depthProgramInfo);
+  renderBall(app, viewMatrix, programInfo);
+  renderGround(app, viewMatrix, mirrorViewMatrix, programInfo);
 }

计算是否在阴影下

这麽一来深度资讯就会存在 textures.lightProjection 中,接下来请参考这张图:

using-light-projection

经过光源投影之後,B 点上的深度来自 A 点,如果从 C 进行光源投影同样会到达 B 点的位置,但是深度将会比较深,我们可以利用这一点来检查是否在阴影下,把 C 点投影到 B 点的原理其实跟 Day 23 镜面计算 texture 位置一样,将在 fragment shader 中得到的表面位置进行 framebuffer 的 view matrix 转换,也就是 lightProjectionViewMatrix

把光源投影的 view 矩阵用名为 u_lightProjectionMatrix 的 uniform 传入,并且在 vertex shader 中 transform 成 v_lightProjection 投影後的位置:

 uniform mat4 u_mirrorMatrix;
+uniform mat4 u_lightProjectionMatrix;

 // ...

 varying float v_depth;
+varying vec4 v_lightProjection;

 void main() {
   v_depth = gl_Position.z / gl_Position.w * 0.5 + 0.5;
+  v_lightProjection = u_lightProjectionMatrix * worldPosition;
 }

在 fragment shader 方面,接收 u_lightProjectionMatrix 以及 v_lightProjection,并且跟 v_mirrorTexcoord 一样要除以 .w 使之与 clip space 中的位置相同,接着需要两个深度:

  • v_lightProjection.z / v_lightProjection.w 计算而来的 lightToSurfaceDepth: 表示该点(可能为 A 或是 C 点)投影下去的深度
  • u_lightProjectionMap 查询到的值:光源投影时该点的深度,也就是 B 点上的值
// ...
uniform sampler2D u_lightProjectionMap;
varying vec4 v_lightProjection;

void main() {
  // ...

  vec2 lightProjectionCoord =
    v_lightProjection.xy / v_lightProjection.w * 0.5 + 0.5;
  float lightToSurfaceDepth =
    v_lightProjection.z / v_lightProjection.w * 0.5 + 0.5;
  float lightProjectedDepth = texture2D(
    u_lightProjectionMap,
    lightProjectionCoord
  ).r;
}

除了 lightProjectionCoord* 0.5 + 0.5 以符合 texture 上的座标范围外,v_lightProjection.z / v_lightProjection.w 在 clip space 为 -1 ~ +1,也要传换成 0 ~ +1,以符合深度 texture 『颜色』的 channel 值域。资料准备就绪,进行深度比较:

float occulusion = lightToSurfaceDepth > lightProjectedDepth ? 0.5 : 0.0;

diffuseBrightness *= 1.0 - occulusion;
specularBrightness *= 1.0 - occulusion * 2.0;

笔者使用 occulusion 表示『有多少成的光源被遮住』,并设定成在阴影下时减少 50% 的散射光亮度以及全部反射光,结果长这样:

shadow-too-sensitive

真的该有阴影的地方是有阴影了:

correct-shadow-regions

不过显然阴影区域太大,而且球体上光照的区域也有一点一点的阴影,为什麽会这样呢?尽管像是上方示意图中的 A 点,光源投影下来的深度与後来重算的深度可能因为 GPU 计算过程中浮点数的微小差异而导致 lightToSurfaceDepth > lightProjectedDepth 成立,为了避免这个问体我们让 lightToSurfaceDepth 必须比 lightProjectedDepth 还要大出一定的数值才判定为有阴影,笔者让这个值为 0.01:

 void main() {
   // ..
-  float occulusion = lightToSurfaceDepth > lightProjectedDepth ? 0.5 : 0.0;
+  float occulusion = lightToSurfaceDepth > 0.01 + lightProjectedDepth ? 0.5 : 0.0;
 }

阴影功能就完成罗:

finished-shadow

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

好了,花了这麽多篇介绍光线相关的效果,从散射光、反射光到镜面与阴影,这些效果加在一起可以制作出颇生动的画面,不觉得上面的画面蛮漂亮的吗?在此同时本系列技术文章也将进入尾声,下个章节将制作一个完整的场景作为完结作品:帆船与海


<<:  Day 11 号志如何使用-等待与号志同步

>>:  网路架构检视 - 网路分段/分区与 IP 发放

[Day15] CSV Injection(Formula Injection)

前言 写完一半了,洒花 你的使用的Sheet Application安全吗? 正文 概念 CSV是一...

IOS 菜菜菜鸟30天挑战 Day-29 本地推播

今天来讲讲本地推播 1.先导入推播 import UserNotifications 2.先获取权限...

Day 4.环境预备备(二)- Node.js

NPM (全称 Node Package Manager,即「node包管理器」),它是一个线上套件...

Day 25 - redux-saga 文件范例

Q_Q 没学完啦 Redux-saga 范例 import { createStore, appl...

Day19 Project2 - 留言板

今天就用昨天讲到的CRUD搭配RESTful API做出一个留言板,首先先来定义一下留言板会需要有的...