阴影(上)

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

有了 framebuffer 的帮助,我们可以动用 GPU 的力量事先运算,在正式绘制画面时使用。继镜面完成之後,根据 Day 22 所说,另一个 framebuffer 的应用是阴影,接下来就来介绍如何制作出阴影效果

如何『拍摄』深度照片

阴影的产生是因为物体表面到光源之间有其他物体而被遮住,为了得知有没有被遮住,我们可以从光源出发『拍摄』一次场景,从上篇改用 twgl 时有提到 framebuffer 也可以包含深度资讯,实际绘制画面时就可以利用深度资讯来得知是否在阴影下

shadow

但是目前的光源是平行光,这样要怎麽拍摄?首先,利用 Day 11 的 Orthogonal 3D 投影,如果光线是直直往 +y 的方向与地面垂直倒是蛮容易想像的,不过如果不是的时候,那麽感觉拍摄的范围就没办法很大(淡蓝色区域为投影区域,蓝色面为成像面):

orthogonal-light-projection

笔者想到在矩阵运算中还有一个叫做 shear,可以把一个空间中的矩形转换成平行四边形,透过这个工具,可以使得投影区域为平行四边形:

shear-orthogonal-light-projection

建立存放深度资讯的 texture

如果去看 twgl.createFramebufferInfo() 预设建立的 framebuffer 与 textures 组合,可以看到一个存放颜色的 texture,但是另一个存放深度资讯却不是 texture,是一个叫做 WebGLRenderbuffer 的东西:

WebGLTexture-and-WebGLRenderbuffer

经过测试,WebGLRenderbuffer 无法当成 texture 使用,为了建立能放深度资讯的 texture,需要 WebGL extension WEBGL_depth_texture,跟 Day 16 的 VAO 功能一样,不是 WebGL spec 的一部分,幸好 WEBGL_depth_texture 在主流浏览器中都有支援,只是需要写一点程序来启用:

async function setup() {
  const gl = canvas.getContext('webgl');
  // ...

  const webglDepthTexExt = gl.getExtension('WEBGL_depth_texture');
  if (!webglDepthTexExt) {
    throw new Error('Your browser does not support WebGL ext: WEBGL_depth_texture')
  }

  // ...
}

启用後,建立 framebuffer-texture 时便可指定 texture 的格式为 gl.DEPTH_COMPONENT 存放深度资讯,笔者将此 framebuffer-texture 命名为 lightProjection:

async function setup() {
  // ...

  framebuffers.lightProjection = twgl.createFramebufferInfo(gl, [{
    attachmentPoint: gl.DEPTH_ATTACHMENT,
    format: gl.DEPTH_COMPONENT,
  }], 2048, 2048);
  textures.lightProjection = framebuffers.lightProjection.attachments[0];

  // ...
}

建立一组拍摄深度用的 shader

在拍摄深度时,颜色计算就变成多余的,同时为了预览深度照片的成像,因此建立了一个简单的 fragment shader,待会会与现有的 vertexShaderSource 连结:

precision highp float;

varying float v_depth;

void main() {
  gl_FragColor = vec4(v_depth, v_depth, v_depth, 1);
}

可以看到这个 fragment shader 需要 varying v_depth,因此在 vertex shader 中输出:

+varying float v_depth;

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

因为 gl_Position.z / gl_Position.w clip space 中的范围是 -1 ~ +1,因此 * 0.5 + 0.5 使之介於 0 ~ +1 用於颜色输出,并且使用 twgl.createProgramInfo() 建立 depthProgramInfo:

 async function setup() {
   // ...
+  const depthProgramInfo = twgl.createProgramInfo(gl, 
+    [vertexShaderSource, depthFragmentShaderSource]
+  );

   return {
     gl,
-    programInfo,
+    programInfo, depthProgramInfo,
     // ...
   }
 }

产生 light projection 用的 transform matrix

现有的光线方向向量是由 state.lightRotationXY 所控制,根据产生程序:

const lightDirection = matrix4.transformVector(
  matrix4.multiply(
    matrix4.yRotate(state.lightRotationXY[1]),
    matrix4.xRotate(state.lightRotationXY[0]),
  ),
  [0, -1, 0, 1],
).slice(0, 3);

光线一开始向着 -y 方向,接着旋转 x 轴 state.lightRotationXY[0] 以及 y 轴 state.lightRotationXY[1],场景物件放置在 xz 平面上,因此 shear 时使用的角度为旋转 x 轴的 state.lightRotationXY[0],整个 transform 经过以下步骤:

  1. 移动视角,因 matrix4.projection() 捕捉的正面看着 +z,需要先旋转使之看着 -y,接着旋转 y 轴 state.lightRotationXY[1],这两个转换就是 Day 13 的视角 transform,需要做反矩阵
  2. shearing,同样因为 matrix4.projection() 捕捉的正面看着 +z,依据角度偏移 y 值:y' = y + z * tan(state.lightRotationXY[0])
  3. 使用 matrix4.projection() 进行投影,捕捉场景中 xz 介於 0 ~ 20,y (深度)介於 0 ~ 10 的物件
  4. matrix4.projection() 会把原点偏移到左上,透过 matrix4.translate(1, -1, 0) 转换回来,最後捕捉场景中 xz 介於 -10 ~ +10,y 介於 -5 ~ +5 的物件

把这些 transform 通通融合进 lightProjectionViewMatrix:

function render(app) {
  // ...
  const lightProjectionViewMatrix = matrix4.multiply(
    matrix4.translate(1, -1, 0),
    matrix4.projection(20, 20, 10),
    [ // shearing
      1, 0, 0, 0,
      0, 1, 0, 0,
      0, Math.tan(state.lightRotationXY[0]), 1, 0,
      0, 0, 0, 1,
    ],
    matrix4.inverse(
      matrix4.multiply(
        matrix4.yRotate(state.lightRotationXY[1]),
        matrix4.xRotate(degToRad(90)),
      )
    ),
  );

  // ...
}

视觉化深度到画面上看看

因为现在有多个 program,得在 renderBall() 以及 renderGround() 时指定使用的 program,因此加入 programInfo 参数到这两个 function

-function renderBall(app, viewMatrix) {
-  const { gl, programInfo, textures, objects } = app;
+function renderBall(app, viewMatrix, programInfo) {
+  const { gl, textures, objects } = app;
   // ...
 }

-function renderGround(app, viewMatrix, mirrorViewMatrix) {
-  const { gl, programInfo, textures, objects } = app;
+function renderGround(app, viewMatrix, mirrorViewMatrix, programInfo) {
+  const { gl, textures, objects } = app;
   // ...
 }

并且修改现有渲染到画面上的流程使用 depthProgramInfo 以及 lightProjectionViewMatrix:

 function render(app) {
   const {
     gl,
     framebuffers,
-    programInfo,
+    programInfo, depthProgramInfo,
     state,
   } = app;

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

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

   gl.bindFramebuffer(gl.FRAMEBUFFER, null);
   // ...

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

我们就获得了灰阶的深度视觉化:

visualized-depth

至於回到正式『画』时使用这些资讯绘制阴影的部份,将在下篇继续实做,本篇的完整程序码可以在这边找到:


<<:  未来世界的树 - DOM Tree

>>:  [Day24] iT邦帮忙502 Bad Gateway怎麽办? 教你自动侦测网页修复了没!

D7(9/7)-91App(6741) 帮商家做电商的电商专家

注:发文日和截图的日期不一定是同一天,所以价格计算上和当日不同,是很正常的。 声明:这一系列文章并无...

Day 26: Insertion sort & Selection sort

我们先来用insertion sort algorithm来解题。 虽然他的效率也不高,但这是很好理...

Day33 ( 电子元件 ) 长条图显示土壤湿度

长条图显示土壤湿度 教学原文参考:长条图显示土壤湿度 这篇文章会介绍如何使用土让湿度感测器,搭配「点...

Day 26 : 插件篇 05 — 做好笔记备份,使用 Obsidian Git自动备份笔记到 Github

前言 我在《操作基础篇 04 — 做好笔记备份 ,使用 iCloud 和 Google Drive ...

#10 Pandas教学2

新增删除操作 # 载入pandas import pandas as pd if __name__ ...