大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 30 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,本文將加入一些海面效果作為本系列文的完結作品:帆船與海,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
有了帆船、天空、反射天空的海面以及簡易的操作說明,要說這個是成品應該是沒什麼問題,不過看著那固定的海面以及帆船,如果能讓他們動起來是不是更好?
這其實是一個網站:https://www.shadertoy.com/
像是 codepen, jsfiddle, jsbin 那樣,在網頁中寫程式,然後在旁邊跑起來呈現結果,同時網站也是分享的平台,只是 shadertoy 是專門寫 WebGL shader 的,而且能寫的是 fragment shader,主要的資料來源只有當下 fragment shader 繪製的 pixel 位置以及時間,剩下的就是發揮使用者的想像力(以及數學)來畫出絢麗的畫面,可以在這邊看到許多別人寫好的 shader,像是:
可以看到這些 shader 的實做蠻複雜的,究竟是什麼演算法使得只透過簡單的資料來源就能得到這麼漂亮的效果?筆者看了 Youtube 頻道 -- The Art of Code 的一些影片之後,了解到這些 shader 多少運用到了 hash & noise 的技巧,這些偽隨機函數接收二或三維度的座標,接著回傳看似隨機的數值,通常介於 0~1,那麼我們就可以利用這個數字當成一個地方的雲層密度、火焰強度、海浪高度
回到想要讓海面動起來的問題,除了上述的技巧之外,筆者找到一個水面效果的 shadertoy,效能蠻好的,有許多地方可以拿來參考,接下來就看要怎麼實做在 oceanFragmentShaderSource
吧
oceanNormal
海面反光計算、倒影陰影的計算都會利用到海面的法向量,在 Day 21 使用一個 texture 作為 normal map 使得表面可以利用法向量的變化產生凹凸細節,只可惜這一張圖不會動
vec3 normal = texture2D(u_normalMap, v_texcoord * 256.0).xyz * 2.0 - 1.0;
要讓海面動起來,也就是這個 normal
要可以根據時間改變,已經有 uniform float u_time;
,早在這一章節起始點就已經準備好了,其值為 app.time / 1000
,大約每一秒加一;接著設計一個類似於 shadertoy 那樣的 function,透過海面的 xz 座標來產生偽隨機的法向量:
+vec3 oceanNormal(vec2 pos);
+
void main() {
vec2 reflectionTexcoord = (v_reflectionTexcoord.xy / v_reflectionTexcoord.w) * 0.5 + 0.5;
- vec3 normal = texture2D(u_normalMap, v_texcoord * 256.0).xyz * 2.0 - 1.0;
+ vec3 normal = oceanNormal(v_worldSurface.xz);
reflectionTexcoord += normal.xy * 0.1;
// ...
}
+vec3 oceanNormal(vec2 position) {
+}
不知不覺這篇可能會變成以 C 語言為主,在
main()
之前要先宣告vec3 oceanNormal(vec2 pos)
的存在,要不然編譯會失敗
接下來假設會實做一個 function,帶入一組 xz 座標會得到 xyz 填上高度,把這個 function 叫做 vec3 oceanSurfacePosition(vec2 position)
,那麼 oceanNormal()
就可以呼叫 oceanSurfacePosition()
三次,第一次帶入原始的 p1: [x, z]
、第二次 p2: [x + 0.01, z]
、第三次 p3: [x, z + 0.01]
,拿 p1 -> p2
以及 p1 -> p3
兩個向量做外積就可以得到一定準度的法向量,oceanNormal()
實做如下:
#define OCEAN_SAMPLE_DISTANCE 0.01
vec3 oceanNormal(vec2 position) {
vec3 p1 = oceanSurfacePosition(position);
vec3 p2 = oceanSurfacePosition(position + vec2(OCEAN_SAMPLE_DISTANCE, 0));
vec3 p3 = oceanSurfacePosition(position + vec2(0, OCEAN_SAMPLE_DISTANCE));
return normalize(cross(
normalize(p2 - p1), normalize(p3 - p1)
));
}
讓假設會有的 oceanSurfacePosition()
function 成為事實:
vec3 oceanSurfacePosition(vec2 position) {
float height = 0.0;
return vec3(position, height);
}
為了符合 normal 以
[0, 0, 1]
為上面,height
輸出到z
的位置,也就跟 normal map 取得的vec3
排列一致
現在要求的是運算 position
來取得 height
,筆者在觀察 水面效果的 shadertoy 時發現其第 15 行:
float wave = exp(sin(x) - 1.0);
輸入到 desmos 可以看到漂亮的波浪形狀:
sin()
的波形本身就是於 +1 ~ -1 之間不停來回,減 1 套 exp()
指數函數 得到最大值為 1 的波浪函數,實做給 height
試試看
vec3 oceanSurfacePosition(vec2 position) {
float height = exp(sin(position.x) - 1.0);
return vec3(position, height);
}
縮小預覽,可以看到規律的波紋與 z 軸平行:
如果想要讓帆船的方向與海浪垂直、並且使之跟著時間而波動,定義一個角度以及向量 direction
與位置進行內積,加上時間成為波浪函數的 x 軸輸入值 waveX
:
vec3 oceanSurfacePosition(vec2 position) {
float directionRad = -2.355;
vec2 direction = vec2(cos(directionRad), sin(directionRad));
float waveX = dot(position, direction) * 2.5 + u_time * 5.0;
float height = exp(sin(waveX) - 1.0);
return vec3(position, height);
}
-2.355
為3.14
的-0.75
倍,也就是反向旋轉 135 度;將dot(position, direction)
乘上 2.5 可以縮小波長、時間乘上 5.0 加快波浪速度
波浪就動起來囉:
但是這樣的波浪未免也太規律,這時來使用上方說到的 hash / noise 的偽隨機技巧,先實做這個 Youtube 影片 -- Shader Coding: Making a starfield - Part 1 by The Art of Code 中的 hash()
function:
float hash(vec2 p) {
p = fract(p * vec2(234.83, 194.51));
p += dot(p, p + 24.9);
return fract(p.x * p.y);
}
fract()
用來取小數,這整個 function 其實沒有什麼道理,其中的 234.83
, 194.51
等數字也可以隨便改,某種程度算是 seed 吧,總之輸入一個二維向量,得到介於 0~1 的數字
算式雖然是說沒什麼道理,但是筆者自己隨便寫一個,要達到一個隨機的感覺其實不容易,可能到數字很大的時候還是會出現特定的規律
接著使用類似此 Youtube 影片 -- Value Noise Explained! by The Art of Code 的技巧,利用 floor()
把海面以每個整數分成一格一格,例如 1~1.99999... 為一格,用 id
表示當前的格子,每個格子使用同一個 hash 值,拿 hash 出來的偽隨機值當成 directionRad
角度偏移量:
vec3 oceanSurfacePosition(vec2 position) {
- float directionRad = -2.355;
+ vec2 id = floor(position);
+
+ float directionRad = (hash(id) - 0.5) * 0.785 - 2.355;
vec2 direction = vec2(cos(directionRad), sin(directionRad));
float waveX = dot(position, direction) * 2.5 + u_time * 5.0;
float height = exp(sin(waveX) - 1.0);
return vec3(position, height);
}
因為 hash(id)
得到 0~1 之間的數值,減掉 0.5 成為 -0.5~+0.5,最後乘以 0.785,為 45 度 / 180 * 3.14 而來,這麼一來角度將為 -135 + (-22.5 ~ 22.5),採用 id
分格子之後就變成這樣了:
每格是有不同的方向,但是格子之間都有明顯的一條線,解決這個問題的方法是每個格子去計算鄰近 1 格的海浪,並且海浪的強度會隨著距離來源格子越遠而越弱,為此再建立一個 function 叫做 localWaveHeight()
計算一個位置(position
)能從一個格子(id
)得到多少的海浪高度:
float localWaveHeight(vec2 id, vec2 position) {
float directionRad = (hash(id) - 0.5) * 0.785 - 2.355;
vec2 direction = vec2(cos(directionRad), sin(directionRad));
float distance = length(id + 0.5 - position);
float strength = smoothstep(1.5, 0.0, distance);
float waveX = dot(position, direction) * 2.5 + u_time * 5.0;
return exp(sin(waveX) - 1.0) * strength;
}
directionRad
, direction
, waveX
以及 exp(sin(waveX) - 1.0)
是從 oceanSurfacePosition()
搬過來的distance
透過 length()
計算 position
與 id
格子中央的距離strength
表示海浪的強度,如果距離為 0 則強度最強為 1,而且我們只打算取到鄰近 1 格,距離到達 1.5 時表示已經到達影響力的邊緣,這時強度為 0
smoothstep(edge0, edge1, x)
可以把輸入值 x
介於 edge0 < x < edge1 轉換成 0 ~ 1 之間的值,x
超出 edge0 時回傳 0、超出 edge1 時回傳 1,而且此函數是一個曲線,會平滑地到達邊緣,更詳細的資料可以參考其維基百科
回到 oceanSurfacePosition()
,這時它的任務便是蒐集鄰近 id.xy
相差 -1~+1、共 9 個格子對於當下位置的波浪高度,並且加在一起:
vec3 oceanSurfacePosition(vec2 position) {
vec2 id = floor(position);
float height = 0.0;
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
height += localWaveHeight(id + vec2(i, j), position);
}
}
return vec3(position, height);
}
分成格子產生偽隨機的波浪角度、對鄰近格子進行採樣後,看起來真的像是海浪了:
不過海浪似乎有點太高了,而且希望可以更細緻一點,因此在 oceanSurfacePosition()
加入這兩行:
vec3 oceanSurfacePosition(vec2 position) {
+ position *= 6.2;
vec2 id = floor(position);
float height = 0.0;
for (int i = -1; i <= 1; i++) {
for (int j = -1; j <= 1; j++) {
height += localWaveHeight(id + vec2(i, j), position);
}
}
+ height *= 0.15;
return vec3(position, height);
}
把 position
乘以 6.2 可以使格子更小,height
則是很直覺地乘以 0.15 降低高度,海面就平靜許多了,筆者也轉一下視角觀察反射光的反應:
好的,海面的部份就到這邊,希望讀者覺得這樣的海面有足夠的說服力。既然海面的 normal map texture oceanNormal
已經沒有用到,筆者就順手移除來避免下載不必要的檔案
事實上,shadertoy 這樣的做法對於實際應用程式上的效能是不利的,理論上應該可以利用原本的 normal map 以更省力的方式達到類似的效果,即時做 hash()
等運算在一些裝置上可能會有點跑不動,這麼做其實某種程度上只是筆者覺得這樣很有趣,不必依靠別人的 normal map;關於效能問題,這也是為什麼筆者在此章節的右上角加上一個解析度的調整,使用者可以自己選擇解析度,如果螢幕以及性能都允許再調到 Retina,例如 iPad pro 或 M1 等級的 apple 裝置,反之如果是舊手機可能普通解析度都有點吃力,這時可以選擇『低』解析度來順跑
既然有了波浪,那帆船是不是也該隨著時間前後、上下擺動?類似剛剛使用的海浪技巧,只要把時間套 Math.sin()
函數,剩下的就是 3D 物件的 transform:
function renderBoat(app, viewMatrix, programInfo) {
- const { gl, textures, objects } = app;
+ const { gl, textures, objects, time } = app;
const worldMatrix = matrix4.multiply(
matrix4.yRotate(degToRad(45)),
- matrix4.translate(0, 0, 0),
- matrix4.scale(1, 1, 1),
+ matrix4.xRotate(Math.sin(time * 0.0011) * 0.03 + 0.03),
+ matrix4.translate(0, Math.sin(time * 0.0017) * 0.05, 0),
);
// ...
}
本系列文的最終成品也就完成囉!完整程式碼在此:
對於看到這邊的讀者,希望這系列文章有讓各位學習到東西,筆者其實在踏入業界很早期就知道有 WebGL 這個東西的存在,但是因為要做到有些成果需要非常多基石而一直沒有深入下去研究,就如同各位看到的,本系列文於 Day 11 才在畫面上出現比較實際的 3D 畫面,不過也因此學到非常多東西,已經很久沒有這種跳脫舒適圈的感覺了呢
文章中製作的範例主要是示範該文主旨的概念,筆者為了讓這些成品比較有成品的感覺,所以有時候會直接在程式碼中出現一些調校好的數字,筆者在實做這些範例時當然不會是第一次就完美的,都是一改再改,改完覺得滿意了才用一個理想的順序去描述實做的過程作為文章內容;在本系列文撰寫前,筆者學習 WebGL 的練習有放到 github.com/pastleo/webgl-practice,有興趣的讀者可以玩玩後面幾個比較完整的練習的 live 版:
WebGL 作為底層的技術,懂得活用其功能,尤其是 shader (以及數學)的話,能製作出的效果肯定是不勝枚舉的,本系列文中有許多概念是沒有提到,像是 raycast 得到滑鼠、觸控位置的 3D 物件、迷霧效果等,甚至透過 WebGL 把 GPU 當成無情的運算機器,舉 Conway's Game of Life 為例,WebGL 的實做性能顯然遠超過先前筆者用 rust + webassembly 的版本 (部落格文章),現在也可以想像的到使用 WebGL 實做的方向:使用 framebuffer,在 fragment shader 讀取上回合地圖 texture 相關的 cell 來繪製該回合的地圖
在最後筆者要感謝 @greggman 撰寫的 WebGL2 Fundamentals, WebGL Fundamentals 甚至 Three.js Fundamentals,有完整、深入的教學讓筆者可以有系統地學習,補足電腦繪圖的知識,在進行 3D 遊戲程式設計的時候也更加順利,因此寫下這系列文章分享給各位讀者