大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 28 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,在本系列文的最後章節將製作一個完整的場景作為完結作品:帆船與海,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
於 Day 27 建立 cube texture 並把圖片資料下載好,本篇的主要目標就是把 skybox 繪製上去,同時我們也可以讓海面反射天空,使得場景更栩栩如生
因為 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 資料就好因此設定成 vec2
,gl_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,
};
}
// ...
}
法向量將從 clip space 頂點位置出發,從上方 vertex shader 實做可以看到直接從 gl_Position
出發,也就是這四個位置(實際上為兩個三角形六個頂點):
[-1, -1, 1]
[ 1, -1, 1]
[-1, 1, 1]
[ 1, 1, 1]
理論上我們是可以透過 app.state.cameraRotationXY
(cameraViewing
, cameraDistance
是對平移的控制,可以無視)算出對應的 transform,但是另一個方式是透過現成的 viewMatrix
,這樣的話視角不使用 app.state.cameraRotationXY
時也可以通用
下圖為場景位置到 clip space 轉換從正上方俯瞰的示意圖,原本使用 viewMatrix
是要把場景中透過 worldMatrix
轉換的物件投影到 clip space,也就是下圖中橘色箭頭方向,但是看著上方四個點的座標,現在的出發點是 clip space 中的位置(下圖黑點),如果轉換成觀察者所能看到最遠平面的四個角落(下圖深藍綠色點),這樣一來再單位矩陣化便成為 cube texture 取樣時所需的 normal 法向量,而這樣的轉換在下圖中為同黑色箭頭,稍微想一下,這個動作其實是把 clip space 轉換回場景位置,那麼說也就是 viewMatrix
的『反向』 -- 它的反矩陣
不過 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);
與其他物件一樣,建立一個 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,在鏡像世界有個自己的 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],
// ...
});
// ...
}
海面變成天空的鏡子,晴朗天氣的部份也就完成了:
本篇完整的程式碼可以在這邊找到: