绘制 Skybox

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 28 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,在本系列文的最後章节将制作一个完整的场景作为完结作品:帆船与海,如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容

Day 27 建立 cube texture 并把图片资料下载好,本篇的主要目标就是把 skybox 绘制上去,同时我们也可以让海面反射天空,使得场景更栩栩如生

Skybox Shader

因为 skybox 是『背景』,因此 skybox 这个『物件』最终到达 clip space 时应该落在距离观察位置最远但是还看得到的地方,回想 Day 12 这边提到成像时投影到 z = -1 平面、看着 +z 方向,那麽离观察最远的平面为 z = 1,而且为了填满整个画面,skybox 物件在 clip space 中即为 x, y 范围於 -1 ~ +1 的 z = 1 平面,刚好 twgl.primitives.createXYQuadVertices() 几乎可以直接做出我们需要的这个平面,就差在他没有 z 的值

这样听起来物件的顶点应该是不需要 transform,就只是输出到 gl_Position 的 z 需要设定成 1,比较需要操心的是对 cube texture 取样时的 normal 法向量,我们将从 clip space 顶点位置出发,透过『某种 transform』指向使用者观看区域的边界顶点,接下来就跟一般 texture 一样利用 varying 补间得到每个 pixel 取用 cube texture 的 normal 法向量

综合以上,skybox 之 vertex shader 实做:

attribute vec2 a_position;
uniform mat4 u_matrix;

varying vec3 v_normal;

void main() {
  gl_Position = vec4(a_position, 1, 1);
  v_normal = (u_matrix * gl_Position).xyz;
}

twgl.primitives.createXYQuadVertices() 顶点将输入到 a_position,我们只需要其 x, y 资料就好因此设定成 vec2gl_Position 照着上面所说直接输出并设定 z 为 1;u_matrix 变成转换成 normal 的矩阵,转换好透过 v_normal 给 fragment shader 使用。fragment shader 的部份就变得很简单,纯粹透过 v_normal 把颜色从 cube texture 中取出即可:

precision highp float;

varying vec3 v_normal;

uniform samplerCube u_skyboxMap;

void main() {
  gl_FragColor = textureCube(u_skyboxMap, normalize(v_normal));
}

分别把这两个 shader 原始码写在 literals string 并称为 skyboxVS, skyboxFS,接着建立对应的 programInfo 并放在 app.skyboxProgramInfo:

 async function setup() {
   // ...
   const oceanProgramInfo = twgl.createProgramInfo(gl, [vertexShaderSource, oceanFragmentShaderSource]);
+  const skyboxProgramInfo = twgl.createProgramInfo(gl, [skyboxVS, skyboxFS]);

   // ...

   return {
     gl,
-    programInfo, depthProgramInfo, oceanProgramInfo,
+    programInfo, depthProgramInfo, oceanProgramInfo, skyboxProgramInfo,
     textures, framebuffers, objects,
     // ...
   }
 }

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

   // ...

 }

最後把 skybox 『物件』建立好,别忘了其 VAO 要使 buffer 与新的 skyboxProgramInfo 绑定:

async function setup() {
  // ...

  { // skybox
    const attribs = twgl.primitives.createXYQuadVertices()
    const bufferInfo = twgl.createBufferInfoFromArrays(gl, attribs);
    const vao = twgl.createVAOFromBufferInfo(gl, skyboxProgramInfo, bufferInfo);

    objects.skybox = {
      attribs,
      bufferInfo,
      vao,
    };
  }

  // ...
}

取得 Cube texture normal 之『某种 transform』

法向量将从 clip space 顶点位置出发,从上方 vertex shader 实做可以看到直接从 gl_Position 出发,也就是这四个位置(实际上为两个三角形六个顶点):

[-1, -1,  1]
[ 1, -1,  1]
[-1,  1,  1]
[ 1,  1,  1]

理论上我们是可以透过 app.state.cameraRotationXYcameraViewing, cameraDistance 是对平移的控制,可以无视)算出对应的 transform,但是另一个方式是透过现成的 viewMatrix,这样的话视角不使用 app.state.cameraRotationXY 时也可以通用

下图为场景位置到 clip space 转换从正上方俯瞰的示意图,原本使用 viewMatrix 是要把场景中透过 worldMatrix 转换的物件投影到 clip space,也就是下图中橘色箭头方向,但是看着上方四个点的座标,现在的出发点是 clip space 中的位置(下图黑点),如果转换成观察者所能看到最远平面的四个角落(下图深蓝绿色点),这样一来再单位矩阵化便成为 cube texture 取样时所需的 normal 法向量,而这样的转换在下图中为同黑色箭头,稍微想一下,这个动作其实是把 clip space 转换回场景位置,那麽说也就是 viewMatrix 的『反向』 -- 它的反矩阵

clip-space-to-world-normal

不过 viewMatrix 会包含平移,需要将平移效果移除,我们把 viewMatrix 拆开来看:

viewMatrix = 
  matrix4.perspective(...) * matrix4.inverse(cameraMatrix)

平移会来自於 matrix4.inverse(cameraMatrix),而平移为 4x4 矩阵中最後一行的前三个元素,只要将之设定为 0 即可。综合以上,转换成 normal 所需要的矩阵 u_matrix 计算方式为:

inversedCameraMatrix = matrix4.inverse(cameraMatrix)
u_matrix = inverse(
  matrix4.perspective(...) *
    [
      ...inversedCameraMatrix[0..3],
      ...inversedCameraMatrix[4..7],
      ...inversedCameraMatrix[8..11],
      0, 0, 0, inversedCameraMatrix[15]
    ]
)

这边 inversedCameraMatrix[0..3] 表示取得 inversedCameraMatrix 的 0, 1, 2, 3 的元素

继续实做之前,在程序码中将 viewMatrix 的两个矩阵独立成 projectionMatrix 以及 inversedCameraMatrix:

 function render(app) {
   // ...
+  const projectionMatrix = matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000);
+
   // ...
   const cameraMatrix = matrix4.multiply(
     // ...
   );
-
-  const viewMatrix = matrix4.multiply(
-    matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
-    matrix4.inverse(cameraMatrix),
-  );
+  const inversedCameraMatrix = matrix4.inverse(cameraMatrix);
+  const viewMatrix = matrix4.multiply(projectionMatrix, inversedCameraMatrix);

实做 skybox 的绘制

与其他物件一样,建立一个 function 来实做 skybox 的绘制,并接收拆分出来的 projectionMatrix 以及 inversedCameraMatrix

function renderSkybox(app, projectionMatrix, inversedCameraMatrix) {
  const { gl, skyboxProgramInfo, objects, textures } = app;
  gl.bindVertexArray(objects.skybox.vao);
}

照着上方所描述的 u_matrix 计算公式实做,并且把 skybox cube texture 传入 uniform:

function renderSkybox(app, projectionMatrix, inversedCameraMatrix) {
  // ...
  twgl.setUniforms(skyboxProgramInfo, {
    u_skyboxMap: textures.skybox,
    u_matrix: matrix4.inverse(
      matrix4.multiply(
        projectionMatrix,
        [
          ...inversedCameraMatrix.slice(0, 12),
          0, 0, 0, inversedCameraMatrix[15], // remove translation
        ],
      ),
    ),
  });
}

uniform 输入完成,进行『画』这个动作之前还有一件事,在 clip space z = 1 因为没有『小於』最远深度 z = 1 而不会被判定在 clip space,所以需要设定成『小於等於』,并在『画』完之後设定回来避免影响到其他物件的绘制:

function renderSkybox(app, projectionMatrix, inversedCameraMatrix) {
  // ...

  gl.depthFunc(gl.LEQUAL);
  twgl.drawBufferInfo(gl, objects.skybox.bufferInfo);
  gl.depthFunc(gl.LESS); // reset to default
}

最後在 render() 中切换好 shader 并呼叫 renderSkybox():

 function render(app) {
   // ...
+  gl.useProgram(skyboxProgramInfo.program);
+  renderSkybox(app, projectionMatrix, inversedCameraMatrix);
 }

拉低视角,就可以看到天空罗:

skybox-rendered

使海面反射 skybox

说到底就是要在绘制镜像世界时绘制 skybox,在镜像世界有个自己的 viewMatrix 叫做 reflectionMatrix,我们也必须把他拆开来:

 function render(app) {
   // ...
   const reflectionCameraMatrix = matrix4.multiply(
     // ...
   );
-
-  const reflectionMatrix = matrix4.multiply(
-    matrix4.perspective(state.fieldOfView, gl.canvas.width / gl.canvas.height, 0.1, 2000),
-    matrix4.inverse(reflectionCameraMatrix),
-  );
+  const inversedReflectionCameraMatrix = matrix4.inverse(reflectionCameraMatrix);
+  const reflectionMatrix = matrix4.multiply(projectionMatrix, inversedReflectionCameraMatrix);

projectionMatrix 跟第一次拆出来的相同,直接共用即可

接下来就是切换 shader 并且在绘制镜像世界时呼叫 renderSkybox(),同时也因为海面会反射整个天空,就不需要自带颜色了:

 function render(app) {
   // ...

   { // reflection
     // ...
     renderBoat(app, reflectionMatrix, programInfo);
+
+    gl.useProgram(skyboxProgramInfo.program);
+    renderSkybox(app, projectionMatrix, inversedReflectionCameraMatrix);
   }

   gl.bindFramebuffer(gl.FRAMEBUFFER, null);

   twgl.resizeCanvasToDisplaySize(gl.canvas, state.resolutionRatio);
   gl.viewport(0, 0, canvas.width, canvas.height);

+  gl.useProgram(programInfo.program);
+
   renderBoat(app, viewMatrix, programInfo);
   // ...
 }

 // ...
 
 function renderOcean(app, viewMatrix, reflectionMatrix, programInfo) {
   // ...

   twgl.setUniforms(programInfo, {
     // ...
-    u_diffuse: [45/255, 141/255, 169/255],
+    u_diffuse: [0, 0, 0],
     // ...
   });

   // ...
 }

海面变成天空的镜子,晴朗天气的部份也就完成了:

ocean-reflecting-sunny-sky

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


<<:  Day 13 - UML x Interface — Transition

>>:  【Day13】Git 版本控制 - 什麽是 branch?

[Day. 27] Codeigniter Session

现在我们的网页已经具有相当基本的功能了 但是,有一个很容易发现的问题, 就是浏览器并没有记住使用者的...

以Postgresql为主,再聊聊资料库 PostgreSQL 多笔 update 方式探讨

PostgreSQL 多笔 update 方式探讨 前言 看到FB上 Backend 台湾 (Bac...

{DAY 25} Matplotlib 基础操作

前言 今天这篇要进入到资料视觉化 在前几篇的文章里,资料的形式多半是呈现在表格化的资料表上 为了让数...

Day 21 Compose UI Animation III

今年的疫情蛮严重的,希望大家都过得安好,希望疫情快点过去,能回到一些线下技术聚会的时光~ 今天目标:...

URLSession 介绍 Day 15

今天来介绍使用API会用到NSURLSession 其实NSURLSession 与 URLSess...