Volumetric Light A Simple Example of Volumetric Light Implement

這篇是我在 PTT 發表的文章的備份。https://www.ptt.cc/bbs/GameDesign/M.1655700635.A.0DF.html

綱要

今天我為這篇文章寫的 shader 在這:

沒錯,有顏色了,這次的主題就是光,
與如何讓物件疊加的方法。
(畫面裏面有『四個』球)

其實要我寫『光』的教學文,我感覺超心虛的。

我微積分學的不好,很多東西我只會抄,
一堆相關公式文章wiki根本看不懂在說三隻小貓的。
別說我自己寫了,
我還希望有哪個大師寫給我看勒。Orz

所以… 這篇真的是新手文囉。

基本知識

這篇『不會』說到像是用 Game Engine 或是 Blender,
先做幾個多邊形,然後擺個光源調個強度,
再改一改材質就做會出來的效果。

這篇是新手文,不是大學電腦繪圖課。:>

這篇會講的是體積光 (Volumetric Light)。

體積光是甚麼?體積光也有人叫他 God Light,長這樣:

https://i.ebayimg.com/images/g/mggAAOSwShpZc31P/s-l500.jpg

(Link to ebay)

早期的電腦繪圖,光影效果只出現在實際存在的鋼體上。
像是實心的球表面有光暗反射散射等等的效果,
影子也只會出現在實體的地面上,
那些也就是我上面說的大學電腦繪圖課會教的東西。

但體積光,就像上面那個天使要降臨前的光束,
是在空無一物的空氣中出現的。
這種東西就像讓光有體積,或是把光當作物體一樣看待,
所以就叫體積光。

我們之前二個 shader,
不都有假定世界任何一個點都有密度嗎?
只要有密度,就能被光照到,
而且密度不是 1,光就能透過去,
並照到下一個點 (ray marching)。
這概念其實就是體積光。

再換個說法,現實中看的到體積光,是因為空氣中有粉塵,
粉塵多寡就代表了該點為中心的某個範圍的密度,
所以是很符合物理現實的。

除了體積光,還需要知道一些光的基本常識:

1. 反射 (Reflection)

就國中物理的入射光反射光那些東西,
這篇 shader 沒用到,再用 GPU 都要燒起來了。

2 吸收 (Absortion)

光射到一個物體,某些波長會被吸收變成熱量之類的,
沒被吸收然後反射的部分就是顏色。

3. 透射 (Transmittance)

光可以穿過透明物體,不過會有耗損 (被反射或被吸收了)

所以,Reflection + Absortion + Transmittance = 1.0

透射就是等等程式裡的密度,
程式中的光的顏色則是代表了吸收的概念。

大家國中物理一定都學得很好,
所以我們開始看程式吧!(笑)

程式

程式在這:
https://www.shadertoy.com/view/fdycD3

基本邏輯都是繼承前二篇的,所以看不懂可以先翻前面的看。

世界構成

func 跟之前長的一樣

void world( vec3 pos, out distance, out density )

這次世界有四個球,ball_1 ~ ball_4。

雖然前一個範例也有 sky/river 二個物體,
但二者沒有任何交集,顏色只是加起來而已。

這是世界的四個球是有重疊的,
這種情況,密度和最短距離要用正確的方法算。

最短距離:

先分別算 pos 對四顆球的最短距離,
然後在四個數字中取最小的。
min( min( min( dis1, dis2 ), dis3 ), dis4 );

密度:

二種算法,
一種是四個球的密度取最大值。
一種是把四個值加起來。
沒有好壞,是疊加處的感覺不同。

我的感覺是透明度越低的,越適合最大值,反之是疊加值。
二種在程式中都有寫,可以試試。

用 ray marching 算顏色

raymarch() 現在不是回傳該方向的總密度,
而是直接回傳該方向的總顏色了。

vec4 raymarch( in vec3 ro, in vec3 rd )

要怎麼算某個方向的總顏色呢?

總顏色跟總密度一樣,
是 raymarch 的路徑上所有的採樣點的顏色的總和,
密度高的點顏色佔的比例高,密度低的比例低,
比例的高底是放在 color.a 的透明度裡面。
計算方法跟總密度的算法幾乎一樣。

color.a *= 0.4; // 跟範例 (六) 把密度調低在加的意思一樣
color.rgb *= color.a; // 照透明度調淡顏色
color_sum += color * (1.0 – color_sum.a);

那要怎麼算出各別的採樣點的顏色呢?

假設採樣點位置是 pos,
首先,我們有個光源位置:

LIGHT_POS = vec3( -2.1, -2.6, 1.2 ),

接著我們可以算出採樣點到光源的方向向量。

然後從採樣點出發,
往光源方向 ray marching 前進某距離,
再算出 pos 到光源的該距離的總密度 t。

t 代表 Transmittance (透射率),
密度高的路線,光比較不容易射到 pos,
密度低的路線,光比較容易射到 pos,
若 t = 0,光線可以沒有任何耗損直接射到 pos。

也就是說,
t 越高,光的影響越低,
t 越低,光的影響越高。

假設光的顏色是 light_color,
我們就依照透射率比例,加光的顏色加在原本該點的顏色上,
如此就是下面 code 的意思:

t = lightmarch( pos, LIGHT_POS );
vec3 light_color = vec3(1.0,0.6,0.3);
vec3 ambient_color = vec3(0.91,0.98,1.05);
color.rgb = color.rgb * (light_color*(1.0-t) + ambient_color);

那… ambient color 是甚麼?
如果有用 Blender 或任何的遊戲引擎做過 3D 遊戲都應該用過,
這是 “如果沒有任何光源時,物體的基本自體發光”。

你可以把 ambient_color = vec3(0.0),
然後就會發現光照不到的地方都變全黑了。

在做 3D 遊戲時,
有 ambient color 其實不太好,
因為不好控制光源效果。
比較好的做法是多放幾個光源,
像 direction light 之類的,
但這只是 shader 範例,所以別在意這個了。

最佳化

我們一直都沒討論最佳化的問題,
但當你在做光影時,最佳化的問題是避免不了的。
像是我這個範例,在瀏覽器的 WebGL 跑,
若你拿幾千塊的手機或是平板,
fps 我猜就 10 上下吧。XD

那要怎麼辦呢?

首先,我們真的需要對畫面上所有的方向做 ray marching 嗎?
像前一個例子,做天空時,畫面下半部的點可以通通不做 ray march,
做河流時,畫面上半部也是一樣,馬上就可以減少一半的計算。

而以這個四球例子,一般要先算 ro/rd 是否有和任何物體相交,
有相交再做 ray marching。

甚麼?
怎麼知道一條 ro/rd 的光線有沒有和四顆球相交?
wiki 公式和證明:
https://bit.ly/39AgCMz
看不懂在寫三隻小貓?顆顆,寫成 code 是這樣:

// i remember its from Duke in shadertoy,
// but i can’t find the origin post, sorry
bool inter(v3 ro,v3 rd,v3 pos,f radius,v3 pt)
{
v3 pos2ro = (ro – pos);
float b = dot(pos2ro, rd);
float c = dot(pos2ro, pos2ro) – radius*radius;
float d = b*b – c;
pt = ro + rd*(-b – sqrt(d));//第一個交點
return d >= 0.0;
}
不是我寫的,給你參考。

還有,光源的 ray marching 採樣
可是直接讓計算次數以倍數成長的,
到底採樣幾次,才會達到你預期的效果?
還是只採樣最遠的那個採樣點,
然後照比例算透射率就好?

然後,我們真的需要這麼逼真的表面嗎?
不用的話,減少 ray marching 採樣次數,
然後調整 noise 參數找到可接受的就好,
像前一個大河的例子,
從 40 次降到 20 次其實也還行。

最後,如果這不是遊戲的主要重點,
我們是不是該手動降低 fps,
把資源留給遊戲前景呢?
作法只是不用每次都在 main 裏叫 ray marching,
加個 timer 減少畫面的更新。