大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 27 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。在本系列文的最後章節將製作一個完整的場景作為完結作品:帆船與海,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
.obj
取代球體Day 26 經過套件幫忙讀取並準備好 app.objects.boat
,為繪製到畫面上的部份建立一個 renderBoat
function:
function renderBoat(app, viewMatrix, programInfo) {
const { gl, textures, objects } = app;
const worldMatrix = matrix4.multiply(
matrix4.yRotate(degToRad(45)),
matrix4.translate(0, 0, 0),
matrix4.scale(1, 1, 1),
);
twgl.setUniforms(programInfo, {
u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
u_worldMatrix: worldMatrix,
u_normalMatrix: matrix4.transpose(matrix4.inverse(worldMatrix)),
u_normalMap: textures.nilNormal,
});
objects.boat.forEach(({ bufferInfo, vao, uniforms }) => {
gl.bindVertexArray(vao);
twgl.setUniforms(programInfo, uniforms);
twgl.drawBufferInfo(gl, bufferInfo);
});
}
worldMatrix
與之前的物件差不多,用來決定整體物件位置的 transform,其中 matrix4.yRotate(degToRad(45))
讓帆船可以稍微轉一下不要屁股面對使用者twgl.setUniforms
設定帆船所有子物件共用的 uniformsobjects.boat.forEach()
把所有子物件繪製出來,在 loadBoatModel()
就為每個子物件整理好 bufferInfo
, vao
, uniforms
,只要把 VAO 工作區域切換好、設定個別子物件的 uniform(材質設定)便可以進行『畫』的動作最後把 renderBall()
替換成 renderBoat()
:
function render(app) {
// ...
{ // lightProjection
gl.useProgram(depthProgramInfo.program);
twgl.bindFramebufferInfo(gl, framebuffers.lightProjection);
gl.clear(gl.DEPTH_BUFFER_BIT);
- renderBall(app, lightProjectionViewMatrix, depthProgramInfo);
+ renderBoat(app, lightProjectionViewMatrix, depthProgramInfo);
renderOcean(app, lightProjectionViewMatrix, reflectionMatrix, depthProgramInfo);
}
{ // reflection
twgl.bindFramebufferInfo(gl, framebuffers.reflection);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
- renderBall(app, reflectionMatrix, programInfo);
+ renderBoat(app, reflectionMatrix, programInfo);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// ...
- renderBall(app, viewMatrix, programInfo);
+ renderBoat(app, viewMatrix, programInfo);
gl.useProgram(oceanProgramInfo.program);
twgl.setUniforms(oceanProgramInfo, globalUniforms);
renderOcean(app, viewMatrix, reflectionMatrix, oceanProgramInfo);
}
新的主角 -- 帆船就出現在畫面中囉:
筆者也把原本球體相關的程式移除,避免讀取不必要的檔案,程式碼在此:
在上個章節中實做好的陰影、反射效果,配合海面的 normal map 以及 distortion,只要在 lightProjection
以及 reflection
執行船體的渲染,帆船的水面倒影、陰影就完成了,這麼一來整個場景已經可以看得出來是一艘帆船在海上囉;但是如果把視角調低,就可以立刻看到我們缺少的東西:天空
3D 場景中的天空事實上只是一個背景,但是要符合視角方向,因此這個背景就成了一張 360 度的照片,類似於 Google 街景那樣,在 WebGL 中要做出這樣效果可以透過 gl.TEXTURE_CUBE_MAP
的 texture 來做到;我們常用的 texture 形式是 gl.TEXTURE_2D
,在 shader 中以 texture2D()
傳入 2D 平面上的位置來取樣,使用 gl.TEXTURE_CUBE_MAP
的 texture 時,要給他 6 張圖,分別為 +x, -x, +y, -y, +z, -z,貼在下圖立方體的 6 個面,在 shader 中使用 textureCube()
並傳入三維向量(理論上也應該是單位向量),這個向量稱為 normal 法向量,類似於一顆球體表面上某個位置的法向量,取樣的結果將是從立方體正中間往該向量方向出發,其延伸的線與面相交的點的顏色
若將 Va
傳入 textureCube()
,會取樣到 +x 圖的正中央,Vb
的話會取樣到 -y 圖的正中央,Vc
的話會取樣到 +y 圖的中間偏左。這樣一來這個天空就像是一個盒子一樣,因此這樣的效果叫做 skybox
筆者在 opengameart.org 找到 Sky Box - Sunny Day 作為接下來實做 skybox 的素材,把圖貼在文章實在太佔空間,讀者可以點擊這個連結來看:https://imgur.com/a/8EE6sl2
使用 WebGL API 建立、載入 texture 圖片的方法與 Day 6 的 2D texture 差在兩個地方:
gl.bindTexture()
時目標為 gl.TEXTURE_CUBE_MAP
而非 gl.TEXTURE_2D
gl.texImage2D()
需要呼叫 6 次,把圖片分別輸入到 +x, -x, +y, -y, +z, -z,直接寫下去程式碼會很長:async function setup() {
// const textures = ...
{
const images = await Promise.all([
'https://i.imgur.com/vYEUTTe.png',
'https://i.imgur.com/CQYYFPo.png',
'https://i.imgur.com/Ol4h1f1.png',
'https://i.imgur.com/qYV0zv9.png',
'https://i.imgur.com/uapdS7d.png',
'https://i.imgur.com/MPL3hRV.png',
].map(loadImage));
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_CUBE_MAP, texture);
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_POSITIVE_X,
/* level: */ 0, /* internalFormat: */ gl.RGBA, /* format: */ gl.RGBA, /* type: */ gl.UNSIGNED_BYTE,
images[0],
);
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
/* level: */ 0, /* internalFormat: */ gl.RGBA, /* format: */ gl.RGBA, /* type: */ gl.UNSIGNED_BYTE,
images[1],
);
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_POSITIVE_Y,
/* level: */ 0, /* internalFormat: */ gl.RGBA, /* format: */ gl.RGBA, /* type: */ gl.UNSIGNED_BYTE,
images[2],
);
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
/* level: */ 0, /* internalFormat: */ gl.RGBA, /* format: */ gl.RGBA, /* type: */ gl.UNSIGNED_BYTE,
images[3],
);
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_POSITIVE_Z,
/* level: */ 0, /* internalFormat: */ gl.RGBA, /* format: */ gl.RGBA, /* type: */ gl.UNSIGNED_BYTE,
images[4],
);
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_NEGATIVE_Z,
/* level: */ 0, /* internalFormat: */ gl.RGBA, /* format: */ gl.RGBA, /* type: */ gl.UNSIGNED_BYTE,
images[5],
);
gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
textures.skybox = texture;
}
// ...
}
這邊的
loadImage
為lib/utils.js
的圖片讀取 async function
看一下 twgl.createTextures()
的文件,可以看到它也可以幫忙載入 texture cube map,既然現有實做中已經使用這個 function 載入 app.textures
,那麼只要加上這幾行就相當於上面這 48 行程式碼了:
async function setup() {
const textures = twgl.createTextures(gl, {
// ...
nil: { src: [0, 0, 0, 255] },
nilNormal: { src: [127, 127, 255, 255] },
+ skybox: {
+ target: gl.TEXTURE_CUBE_MAP,
+ src: [
+ 'https://i.imgur.com/vYEUTTe.png',
+ 'https://i.imgur.com/CQYYFPo.png',
+ 'https://i.imgur.com/Ol4h1f1.png',
+ 'https://i.imgur.com/qYV0zv9.png',
+ 'https://i.imgur.com/uapdS7d.png',
+ 'https://i.imgur.com/MPL3hRV.png',
+ ],
+ crossOrigin: true,
+ },
});
// ...
}
讀取 6 張圖並建立好 cube texture 後,可以在開發者工具的 Network tab 中看到六張圖
Cube texture 是準備好了,但是如果要製作出 skybox 效果,還得要為 skybox 建立屬於他的 shader, bufferInfo, VAO,同時還得依據視角產生正確的方向向量以進行 textureCube()
從立方體的面上進行取樣,這些工作就留到下一篇繼續討論,到這邊的進度也就只是上面幾行: