大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 11 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
"Orthogonal" 查詢字典的話得到的意思是:直角的,正交的,垂直的,而 orthogonal 3D projection 筆者的理解是:從 3D 場景中選取一個長方體區域作為 clip space 投影到畫布上,事實上與先前 2D 投影非常類似,只是多一個維度 z
,在 orthogonal 3D 投影時叫做深度,本篇的目標將以 orthogonal 3D 投影的方式渲染一個 P 文字形狀的 3D 物件
03-3d-objects.html
/ 03-3d-objects.html
這個章節使用新的一組 .html
/ .js
作為開始,完整程式碼可以在這邊找到:github.com/pastleo/webgl-ironman/commit/bc7c806,絕大部分的程式碼在先前的章節都有相關的說明了,以下幾點比較值得注意的:
a_position
, a_color
,a_color
作為 fragment shader 輸出顏色使用,顯示的結果將會是一個一個由 a_color
指定的色塊(或是漸層,如果一個三角形內的顏色不同的話)lib/matrix.js
內實做了三維運算需要的 matrix4
系列 function,同樣因為平移需要再加上一個運算時多餘的維度而成為四維矩陣
xRotate
, yRotate
, zRotate
a_position
, a_color
在起始點的 buffer 傳入空陣列、viewMatrix
, worldMatrix
起始點為 matrix4.identity()
,什麼都不做、gl.drawArrays(gl.TRIANGLES, 0, 0);
繪製 0 個頂點,這些都將在本篇補上startLoop
被註解不會用到仔細看的話,會發現在 vertex shader 中的 a_position
宣告型別為 vec4
,可以直接與 4x4 矩陣 u_matrix
相乘,但是在設定傳入的資料時候 gl.vertexAttribPointer()
指定的長度只有 3,那麼剩下的向量第四個元素(接下來稱為 w)的值怎麼辦?有意思的是,在提供的資料長度不足或是沒有提供時 x, y, z 預設值為 0
,而 w 很有意思地預設值是 1
,對於所有 vec4
的 vertex attribute 都是如此,這樣一來就可以符合平移時多餘維度為 1
的需求,很巧的是,如果今天這個 attribute 是顏色,那意義上就變成 alpha 預設值是 1
以下是筆者在設計時畫的圖:
a
, b
, c
, d
表示特定邊長的長度,以便為各個頂點定位座標這邊的任務是要為此 3D 物件產生對應的 a_position
, a_color
陣列,可以想像要建立的資料不少,直接寫在 setup()
內很快就會讓 setup()
失去焦點,因此建立 function createModelBufferArrays()
,選定 a
, b
, c
, d
的數值後第一步驟就是產生各個頂點的座標:
function createModelBufferArrays() {
// positions
const a = 40, b = 200, c = 60, d = 45;
const points = [0, d].flatMap(z => ([
[0, 0, z], // 0, 13
[0, b, z],
[a, b, z],
[a, 0, z],
[2*a+c, 0, z], // 4, 17
[a, a, z],
[2*a+c, a, z],
[a, 2*a, z],
[2*a+c, 2*a, z], // 8, 21
[a, 3*a, z],
[2*a+c, 3*a, z],
[a+c, a, z],
[a+c, 2*a, z], // 12, 25
]));
}
在上圖中頂點的編號對應 points
陣列中的 index 值,因為正面、底面的座標位置只有 z
軸前後的差別,因此用 flatMap
讓程式碼更少一點,筆者在程式碼中某些座標的右方有註解表示其對應的頂點編號
不過 points
不能當成 a_position
陣列,a_position
陣列必須是一個個三角形的頂點,以P 文字形狀的 3D 物件來說,所以的面都可以由長方形組成,兩個三角形可以形成一個長方形,因此寫一個 function 接受四個頂點座標,產生兩個三角形的 a_position
陣列:
function rectVertices(a, b, c, d) {
return [
...a, ...b, ...c,
...a, ...c, ...d,
];
}
有了這個工具之後,a_position
就可以以長方形為單位寫成:
const a_position = [
...rectVertices(points[0], points[1], points[2], points[3]), // 0
...rectVertices(points[3], points[5], points[6], points[4]),
...rectVertices(points[7], points[9], points[10], points[8]),
...rectVertices(points[11], points[12], points[8], points[6]),
...rectVertices(points[13], points[16], points[15], points[14]), // 4
...rectVertices(points[16], points[17], points[19], points[18]),
...rectVertices(points[20], points[21], points[23], points[22]),
...rectVertices(points[24], points[19], points[21], points[25]),
...rectVertices(points[0], points[13], points[14], points[1]), // 8
...rectVertices(points[0], points[4], points[17], points[13]),
...rectVertices(points[4], points[10], points[23], points[17]),
...rectVertices(points[9], points[22], points[23], points[10]),
...rectVertices(points[9], points[2], points[15], points[22]), // 12
...rectVertices(points[2], points[1], points[14], points[15]),
...rectVertices(points[5], points[7], points[20], points[18]),
...rectVertices(points[5], points[18], points[24], points[11]),
...rectVertices(points[11], points[24], points[25], points[12]), // 16
...rectVertices(points[7], points[12], points[25], points[20]),
];
同樣地,在 a_position
陣列中某些 rectVertices()
呼叫右方有註解表示其對應在上圖中的長方形
完成 a_position
之後,a_color
也要為三角形每個頂點產生對應資料,筆者的設計是一個平面使用一個顏色,也就是一個長方形(兩個三角形,共 6 個頂點)的顏色至少都會一樣,同時希望面的顏色是隨機,因此寫了以下 function:
function rectColor(color) {
return Array(6).fill(color).flat();
}
function randomColor() {
return [Math.random(), Math.random(), Math.random()];
}
筆者私心想要正面顏色使用筆者的主題顏色,除此之外隨機產生,因此 a_color
的產生如下:
// a_color
const frontColor = [108/255, 225/255, 153/255];
const backColor = randomColor();
const a_color = [
...rectColor(frontColor), // 0
...rectColor(frontColor),
...rectColor(frontColor),
...rectColor(frontColor),
...rectColor(backColor), // 4
...rectColor(backColor),
...rectColor(backColor),
...rectColor(backColor),
...rectColor(randomColor()), // 8
...rectColor(randomColor()),
...rectColor(randomColor()),
...rectColor(randomColor()),
...rectColor(randomColor()), // 12
...rectColor(randomColor()),
...rectColor(randomColor()),
...rectColor(randomColor()),
...rectColor(randomColor()), // 16
...rectColor(randomColor()),
];
最後回傳整個『P 文字形狀的 3D 物件』相關的全部資料,除了 vertex attributes 之外,待會 gl.drawArrays()
需要知道要繪製的頂點數量,在這邊也以 numElements
回傳:
return {
numElements: a_position.length / 3,
attribs: {
a_position, a_color,
},
}
辛苦寫好了 createModelBufferArrays
,當然得在 setup()
呼叫,把 attribute 資料傳送到對應的 buffer 內:
// async function setup() {
// ...
const modelBufferArrays = createModelBufferArrays();
// ...
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(modelBufferArrays.attribs.a_position),
gl.STATIC_DRAW,
);
// ...
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(modelBufferArrays.attribs.a_color),
gl.STATIC_DRAW,
);
// ...
return {
gl,
program, attributes, uniforms,
buffers, modelBufferArrays,
state: {
},
time: 0,
};
同時也把 modelBufferArrays
也回傳,在 render()
使用,並讓 viewMatrix
使用 matrix4.projection()
產生,orthogonal projection 就是在這行發生:
function render(app) {
const {
gl,
program, uniforms,
+ modelBufferArrays,
state,
} = app;
// ...
- const viewMatrix = matrix4.identity();
+ const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, 400);
const worldMatrix = matrix4.identity();
// ...
- gl.drawArrays(gl.TRIANGLES, 0, 0);
+ gl.drawArrays(gl.TRIANGLES, 0, modelBufferArrays.numElements);
}
存檔,來看看結果:
因為是其他顏色是隨機產生,讀者跟著跑到這邊的話看到的不一定是這個顏色
有東西是有東西,但是正面顏色怎麼不是主題色?像是這張圖的底色才是筆者的主題色:
事實上,在 WebGL 繪製時,假設先繪製了一個三角形,然後再繪製了一個三角形在同一個位置,那麼後面的三角形會覆蓋掉之前的顏色,也就是說當前看到的顏色是底面的顏色(上方程式碼中的 backColor
),解決這個問題的其中一個方法是啟用『只繪製正面面向觀看者的三角形』功能 gl.CULL_FACE
,當三角形的頂點順序符合右手開掌的食指方向時,大拇指的方向即為三角形正面,如下圖所示的長方形(或是說兩個三角形)的正面朝觀看者:
筆者已經在上方建立 a_position
時使得組成底面的三角形面向下,因此只要加上這行:
gl.enable(gl.CULL_FACE);
底面就會因為面向下,其正面沒有對著觀看者,不會被繪製,可以看到正面了:
因為使用 matrix4.projection()
做 orthogonal 投影,其實就是投影到 xy 平面上,這個 3D 模組投影下去看不出來是 3D 的,因此串上各個 transform,尤其是旋轉,使之看起來真的是 3D,首先在 setup()
回傳初始 tranform 值:
// async function setup() {
// ...
return {
gl,
program, attributes, uniforms,
buffers, modelBufferArrays,
state: {
projectionZ: 400,
translate: [150, 100, 0],
rotate: [degToRad(30), degToRad(30), degToRad(0)],
scale: [1, 1, 1],
},
time: 0,
};
可以注意到除了 translate
, rotate
, scale
之外,筆者也加上 projectionZ
,之後讓使用者可以控制 z 軸 clip space,接著在 render()
串上 worldMatrix
矩陣的產生:
const viewMatrix = matrix4.projection(gl.canvas.width, gl.canvas.height, state.projectionZ);
const worldMatrix = matrix4.multiply(
matrix4.translate(...state.translate),
matrix4.xRotate(state.rotate[0]),
matrix4.yRotate(state.rotate[1]),
matrix4.zRotate(state.rotate[2]),
matrix4.scale(...state.scale),
);
結果看起來像是這樣,是 3D 了,但是顯然怪怪的:
在上面已經啟用 gl.CULL_FACE
,不面向觀看者的面確實不會被繪製,但是不夠,下圖箭頭指著的面是面向觀看者的,因為在 a_position
上排列在正面之後,導致正面繪製之後被面覆蓋過去:
調換 a_position
或許可以解決,不過如果讓使用者可以旋轉,旋轉到背面時那麼又會露出破綻,因此需要另一個功能:gl.DEPTH_TEST
,也就是深度測試,在 vertex shader 輸出的 gl_Position.z
除了給 clip space 之外,也可以作為深度資訊,如果準備要畫上的 pixel 比原本畫布上的來的更接近觀看者,顏色才會覆蓋上去,因此加入這行啟用這個功能:
gl.enable(gl.DEPTH_TEST);
耶,一切就正確囉:
最後筆者也加入使用者控制 transform 功能,本篇完整程式碼可以在這邊找到:
不過使用 orthogonal 投影方法畫出來的畫面與我們在生活中從眼睛、相機看到的其實不同,待下篇來介紹更接近現實生活眼睛看到的成像方式:Perspective projection