目錄
- 寫在前面
- ray marching 演算法
- 通過噪聲圖生成云朵
- 云朵光照效果的繪制
- 優化與改進
- 著色器代碼
- 總結
寫在前面
今天來搞了賽艇的特效 ---- 體積云,第一次看見體積云還是在 Minecraft 的光影包里面,好像也是 SE 大大寫的,,,當時因為硬體條件(買不起顯卡)而沒能享受到,今天重新在 OpenGL 中再自己做一次!先上效果圖:
注:本篇博客的代碼幾乎都在 GLSL 中完成,與前面的博客的 c++ 代碼無關,可以放心食用!
上一篇博客回顧:OpenGL學習(十一):延遲渲染管線 本來想寫 OpenGL學習(十二)的,可是一想體積云都是在 shader 里面寫的,和 OpenGL 這套 API 沒啥關系了,就改了標題,
ray marching 演算法
與一般的物體繪制不同,體積云是一種無中生有的特效,因為體積云不是 cpu 傳遞三角面片資訊給 GPU 而繪制的,相反,體積云是在 shader 中由演算法生成的,
注意:體積云不是物體,也沒有頂點資訊,我們在片元著色器中進行渲染,此外,我們渲染體積云,其實是對云后面的像素顏色做計算!
渲染體積云的思路十分簡單:在片元著色器中,我們負責對場景的每一個像素進行上色,如果一個像素被云遮擋,那么我們應該把它涂上云的顏色,如圖描述了體積云的渲染流程:

于是問題變為求解視線方向和云朵有無相交,如果有,那么我們繪制上對應的顏色:

如果云朵是一個三角形,或者是其他規則的幾何圖形,比如球形,那么我們通過數學幾何的方法,就能很好進行求交,可是偏偏云朵是不規則的,無法確定形狀的 “體”,我們無法通過幾何方法進行求交,ray marching 演算法幫助我們解決了不規則體的求交問題,
ray marching 演算法又名光線行進,在 之前的博客 中我簡單講過這種演算法,并且用它來生成了一個體積光的特效作為大作業,今天來詳細講解,
ray marching 演算法從攝像機開始,向世界空間投射光線,并且逐步行進,記錄沿途的資訊,比如我們沿途不斷判斷當前點是否在云層中,如果沿途至少有一點在云層中,我們認為視線和云層相交,下圖描述了 ray marching 演算法的步程序序:

以上的思路是針對具有明確邊界的【固體】進行的,但是云朵通常是用一種沒有具體邊界的【密度函式】來描述的,密度函式的輸入是三維的坐標,輸出是當前坐標的云朵的密度,于是每次采樣我們累積云朵的密度,就可以知道當前光線穿越了多厚的云,二維下的演算法圖示如下:

兩條光線穿越厚度不同的云層,于是累積了不同的云密度,我們根據積累的密度,將云層的顏色和背景的顏色進行混合(時刻記得在任何 “體積” 特效中,我們都是針對背景的像素進行著色!),這里需要用到透明混合的技巧,
在 RGBA 色彩空間中,RGB 通道存盤了顏色,而 A 通道則是透明度,已知背景的顏色為 bgColor,透明覆寫物的顏色為 cvColor,最終的顏色為 c,那么可以用如下的公式進行透明物體的顏色混合:
c = b g C o l o r ? ( 1.0 ? c v C o l o r . a ) + c v C o l o r c =bgColor * (1.0 - cvColor.a) + cvColor c=bgColor?(1.0?cvColor.a)+cvColor
此處 1.0 - color.a 為透明物體的 “不透明度”,比如透明度是 0.4,不透明度就是 0.6 ,我們將不透明度乘以背景色,然后疊加透明物體的顏色即可!
至此,我們知曉了 ray marching 的整個流程,下面我們來實作一個簡單的 ray marching 以繪制帶體積的物體,我們在指定范圍內生成一個立方體,因為最基礎的 ray marching 需要兩個變數:
- 當前片元的世界坐標:worldPos
- 攝像機在世界空間下的位置:cameraPos
這里 cameraPos 不是眼坐標,不要搞混了,然后我們撰寫一個函式,執行 ray marching 演算法:
#define bottom 13 // 云層底部
#define top 20 // 云層頂部
#define width 5 // 云層 xz 坐標范圍 [-width, width]
// 獲取體積云顏色
vec4 getCloud(vec3 worldPos, vec3 cameraPos) {
vec3 direction = normalize(worldPos - cameraPos); // 視線射線方向
vec3 step = direction * 0.25; // 步長
vec4 colorSum = vec4(0); // 積累的顏色
vec3 point = cameraPos; // 從相機出發開始測驗
// ray marching
for(int i=0; i<100; i++) {
point += step;
if(bottom>point.y || point.y>top || -width>point.x || point.x>width || -width>point.z || point.z>width) {
continue;
}
float density = 0.1;
vec4 color = vec4(0.9, 0.8, 0.7, 1.0) * density; // 當前點的顏色
colorSum = colorSum + color * (1.0 - colorSum.a); // 與累積的顏色混合
}
return colorSum;
}
首先朝視線方向 direction 投射光線,然后沿途記錄光線是否在指定的盒子中,如果在,那么我們積累顏色,并且進行顏色混合,注意這里我們的 ray marching 是從攝像機出發,在代公式的時候我們要注意:
- 當前點的顏色 color,是背景色 bgColor
- 累積的顏色 colorSum,是覆寫物的顏色 cvColor
于是有:
colorSum = colorSum + color * (1.0 - colorSum.a); // 與累積的顏色混合
這樣的混合公式,不要搞錯了,,,
最后我們在片元著色器的 main 中添加如下的呼叫,其中 fColor 是片元著色器輸出的顏色,同樣,云的顏色是公式中的 cvColor,而背景色是公式中的 bgColor,于是有:
vec4 cloud = getCloud(worldPos, cameraPos); // 云顏色
fColor.rgb = fColor.rgb*(1.0 - cloud.a) + cloud.rgb; // 混色
我們可以看到,一個立方體被繪制了出來:

注意到每次采樣,我們都認為當前點的密度為 0.1,然后均勻地疊加顏色,所以我們渲染出來的 “體” 是一個規則的立方體,如果我們隨即地改變每次采樣的密度,就可以得到形狀不規則的云了!
這里還要引入兩個小優化,因為我們的云層是在有效范圍 [bottom, top] 內才會生成,而測驗卻從相機原點開始投射光線,假設攝像機在云層下方,那么從相機開始到云層底部這一段路絕對不會有云,我們可以直接 pass,我們將采樣原點移動至云層底部即可:

根據相似三角形法則,我們可以這么挪:

于是在計算完起始點 point 之后,我們馬上執行如下代碼:
// 如果相機在云層下,將測驗起始點移動到云層底部 bottom
if(point.y<bottom) {
point += direction * (abs(bottom - cameraPos.y) / abs(direction.y));
}
// 如果相機在云層上,將測驗起始點移動到云層頂部 top
if(top<point.y) {
point += direction * (abs(cameraPos.y - top) / abs(direction.y));
}
此外,還有一個問題,就是目前的云層會不正確地遮擋本應該遮擋云層的物體,比如樹明明在云層之下,卻被云層遮擋了,尤其是我們增大云層的范圍 width 的時候:

出現這個問題的原因是我們沒有判斷當前片元和云層之間的關系,解決方案也很簡單,通過距離來判斷:

知道原理就可以進行操作了,在平移采樣點之后,我們加入:
// 如果目標像素遮擋了云層則放棄測驗
float len1 = length(point - cameraPos); // 云層到眼距離
float len2 = length(worldPos - cameraPos); // 目標像素到眼距離
if(len2<len1) {
return vec4(0);
}
可以看到現在云層與物體的遮蔽關系能夠被正確地描繪:

最終的 ray marching 代碼如下:
#define bottom 13 // 云層底部
#define top 20 // 云層頂部
#define width 40 // 云層 xz 坐標范圍 [-width, width]
// 獲取體積云顏色
vec4 getCloud(vec3 worldPos, vec3 cameraPos) {
vec3 direction = normalize(worldPos - cameraPos); // 視線射線方向
vec3 step = direction * 0.25; // 步長
vec4 colorSum = vec4(0); // 積累的顏色
vec3 point = cameraPos; // 從相機出發開始測驗
// 如果相機在云層下,將測驗起始點移動到云層底部 bottom
if(point.y<bottom) {
point += direction * (abs(bottom - cameraPos.y) / abs(direction.y));
}
// 如果相機在云層上,將測驗起始點移動到云層頂部 top
if(top<point.y) {
point += direction * (abs(cameraPos.y - top) / abs(direction.y));
}
// 如果目標像素遮擋了云層則放棄測驗
float len1 = length(point - cameraPos); // 云層到眼距離
float len2 = length(worldPos - cameraPos); // 目標像素到眼距離
if(len2<len1) {
return vec4(0);
}
// ray marching
for(int i=0; i<100; i++) {
point += step;
if(bottom>point.y || point.y>top || -width>point.x || point.x>width || -width>point.z || point.z>width) {
break;
}
float density = 0.1;
vec4 color = vec4(0.9, 0.8, 0.7, 1.0) * density; // 當前點的顏色
colorSum = colorSum + color * (1.0 - colorSum.a); // 與累積的顏色混合
}
return colorSum;
}
這里還額外添加了一個福利:因為我們手動將采樣點移動到貼近云層底層或者頂層的地方,于是我們一旦發現采樣超出云層范圍,就直接 break 掉,能夠節省更多的計算量:

通過噪聲圖生成云朵
在上文,我們討論到只要在每次采樣的時候,將每一點的密度隨機化(即我們從云朵的密度函式中取值)就可以生成隨機的云朵形狀,那么現在的問題就是云朵密度函式的獲取了!
亂數在編程語言里面非常常見,不管是 python 的 random 還是 c++ 的 uniform_distribution,可是 GLSL 中我們沒這好待遇,于是我們需要從 cpu 中生成,我們以噪聲圖(紋理)的形式將一張圖上面擺滿亂數,并且傳遞給 GPU,這樣我們在 shaders 中就可以使用亂數了,,,
云朵是在一定范圍內比較連續的圖形,一般的離散噪聲還不足以生成云朵,理論上我們要生成一張 連續 的噪聲圖,比如柏林噪聲或者別的,但是這里我 沒有學過信號與系統 ,我也不會寫生成噪聲圖的代碼(再度擺爛),于是我直接使用了現成的圖片:

注:
這圖是從 minecraft 中生成的,在 final 著色器中直接輸出光影 mod 生成噪聲圖然后截圖保存的
又是 mc
這張圖片在邊緣也是連續的,這意味著在 c++ 讀取紋理時,設定了紋理環繞方式為 REPEAT 之后,超出紋理的采樣可以被連續地映射回紋理,這是好的!
我們通過簡單的 c++ 代碼就可以加載噪聲圖,前提是安裝了 SOIL2 庫:
// 加載噪聲圖
glGenTextures(1, &noisetex);
glBindTexture(GL_TEXTURE_2D, noisetex);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
int textureWidth, textureHeight;
unsigned char* image = SOIL_load_image("textures/noisetex2.png", &textureWidth, &textureHeight, 0, SOIL_LOAD_RGB);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, textureWidth, textureHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, image); // 生成紋理
delete[] image;
然后將噪聲圖傳遞到著色器:
// 傳陰噪聲紋理
glActiveTexture(GL_TEXTURE6);
glBindTexture(GL_TEXTURE_2D, noisetex);
glUniform1i(glGetUniformLocation(composite0, "noisetex"), 6);
注:
這里傳遞到任意的紋理單元都可,只是我這里按照順序排下來是 6 號
接下來在著色器中添加:
uniform sampler2D noisetex;
即可訪問噪聲紋理!
在使用了噪聲圖之后,我們就可以根據采樣點的世界坐標,手動采樣噪聲圖,同時回傳該點的云密度,我們撰寫一個函式 getDensity 來獲取某一點的云密度:
// 計算 pos 點的云密度
float getDensity(sampler2D noisetex, vec3 pos) {
vec2 coord = pos.xz * 0.0025;
float noise = texture2D(noisetex, coord).x;
return noise;
}
隨后我們將 getCloud 中的密度計算,由:
float density = 0.1;
改為:
float density = getDensity(noisetex, point) * 0.1;
其中 0.1 是為了防止積累的顏色過亮導致看不出差別,最后別忘了在 getCloud 的形參中,加入一個 sampler2D 以傳遞噪聲圖給函式,再次運行程式,我們看到:

唔,,,與其說是云,不如說是霧! 云朵的明顯特征是云朵之間有間隔,而不是像上圖那樣相當連續且飽滿, 下面我們改變密度的生成方式,來逼近云朵,
注意到我們使用的柏林噪聲是連續的,但是如果我們以一定的閾值去截取,即認為一旦噪聲圖資料小于某個值,就認為它是0.這意味著有如下的云密度函式,我們通過按照閾值截取,可以生成不連續的密度函式:

我們在計算云密度之前,對云的密度進行截斷:

再次加載程式,可以看到云層像一塊奶酪一樣,被挖了各種洞洞,其中空缺的地方對應噪聲圖中數值小于 0.4 的部分:

云朵水平方向的形狀是有了,可是豎直方向上,像被直直地切了一刀,并不是云朵的橢圓型,我們希望將云朵調整成橢圓型:

我們根據高度,對密度進行調整即可,位于云層中部的密度最大,而位于 bottom 和 top 附近的采樣應當獲取更小的密度,我們將密度計算函式 getDensity 改為:
#define bottom 13 // 云層底部
#define top 20 // 云層頂部
#define width 40 // 云層 xz 坐標范圍 [-width, width]
// 計算 pos 點的云密度
float getDensity(sampler2D noisetex, vec3 pos) {
// 高度衰減
float mid = (bottom + top) / 2.0;
float h = top - bottom;
float weight = 1.0 - 2.0 * abs(mid - pos.y) / h;
weight = pow(weight, 0.5);
// 采樣噪聲圖
vec2 coord = pos.xz * 0.0025;
float noise = texture2D(noisetex, coord).x;
noise *= weight;
// 截斷
if(noise<0.4) {
noise = 0;
}
return noise;
}
這里先把高度決定的權重映射到 [0, 1] 范圍,然后開根號,最終進行權重的計算,不要在意那個開根號(二分之一冪)因為這是調引數調的,我認為比較好看,
重新加載程式,我們得到了一個人模狗樣的結果!現在云朵終于圓潤了:

顯然云朵的細節不夠豐富,這是因為我們直接采樣噪聲圖,頻率太低,對應云朵非常平緩的邊緣,我們嘗試利用多個采樣的噪聲來進行多次采樣并且合并出最終的結果,
關于多次采樣噪聲,,,我沒學過信號與系統,無法解釋這個現象,但是就是有效(逃
開始抄代碼(擺爛)我們對噪聲進行多次采樣,就可以獲得一些有豐富細節的高頻噪聲,我們將 getDensity 中對噪聲圖的采樣增加幾次:

注:
上述采樣引數來自 Minecraft 的光影包 Chocapic13_V5_Extreme
c13 大大早期作品
OTL
你可能會說怎么又偷 mc 的?答案是塞拉斯大招轉好了 然后重新加載程式,現在的云擁有更多的細節了:

距離炒雞逼真(并不)的體積云只差一步,下面我們將進行光照的繪制!
云朵光照效果的繪制
體積云的光照分為兩個部分:
- 基礎顏色
- 光照明暗顏色
其中基礎顏色由累積的密度決定,而光照敏感顏色則取決于云與光源的相對位置,基礎顏色所決定的是云的散射光,即云層越厚的地方,要越昏暗,而云層薄的地方則稍亮,在開始之前,我們預設一組顏色變數如下:
#define baseBright vec3(1.26,1.25,1.29) // 基礎顏色 -- 亮部
#define baseDark vec3(0.31,0.31,0.32) // 基礎顏色 -- 暗部
#define lightBright vec3(1.29, 1.17, 1.05) // 光照顏色 -- 亮部
#define lightDark vec3(0.7,0.75,0.8) // 光照顏色 -- 暗部
他們分別是基礎顏色和光照顏色的亮暗部分,待會我們會根據密度對亮暗顏色進行混合,馬上開始編程!
先從基礎顏色開始繪制,基礎顏色根據云的密度決定當前云的顏色,那么我們根據當前采樣點的密度,對基礎顏色進行線性混合即可,此外每一次采樣積累的顏色的亮度,還要取決于該點的密度,于是將 ray marching 中的累積顏色的代碼改為:
float density = getDensity(noisetex, point); // 當前點云密度
vec3 base = mix(baseBright, baseDark, density) * density; // 基礎顏色
vec4 color = vec4(base, density); // 當前點的最終顏色
colorSum = color * (1.0 - colorSum.a) + colorSum; // 與累積的顏色混合
即:

重新加載程式,可以看到基礎顏色的繪制,在云層比較厚的地方呈現暗色,而云層薄的地方則亮一些:

好!接下來我們進行光照云顏色的繪制,
想要知道一點的亮度,我們就得知道這一點到光源之間隔了多厚的云層,我們從該點出發,朝著光源方向投射射線,再積累一次密度,就能最準確的知道當前點距離光源還隔了多少云,如圖:

但是這個方法的復雜度是 n 方,因為每一次采樣我們都要重新執行一次 ray marching,總所周知在計算機中 n 方的演算法是爬著走的,于是我們引入優化,
我們沒有必要精確地計算每一點到光源到底間隔了多少云,因為現實中在強陽光的照射下,云的渲染總是一半黑一半白,換句話說,云的光照具有二值性,經典非黑即白:

那么我們通過一次采樣就夠了,我們朝著光源的方向,僅做一次采樣,然后計算兩點的密度差,以此粗略地估計云的顏色,原理如下:

于是我們將剛才的顏色積累的代碼改為:
// 采樣
float density = getDensity(noisetex, point); // 當前點云密度
vec3 L = normalize(lightPos - point); // 光源方向
float lightDensity = getDensity(noisetex, point + L); // 向光源方向采樣一次 獲取密度
float delta = clamp(density - lightDensity, 0.0, 1.0); // 兩次采樣密度差
// 控制透明度
density *= 0.5;
// 顏色計算
vec3 base = mix(baseBright, baseDark, density) * density; // 基礎顏色
vec3 light = mix(lightDark, lightBright, delta); // 光照對顏色影響
// 混合
vec4 color = vec4(base*light, density); // 當前點的最終顏色
colorSum = color * (1.0 - colorSum.a) + colorSum; // 與累積的顏色混合
即:

別忘了在 getCloud 函式的形參串列中加入一個 lightPos 表示光源在世界空間下的坐標,此外,這里偷偷加了一個小 trick,就是在計算光照顏色之前,控制一下透明度以達到更好的效果(否則透過云層難以看到背景)
重新加載程式,我們看到云對于光照有了回應,那只巨大的鴨子模型表示了光源的位置:

注:這種判斷方法其實是有誤差的,但是實際效果還不錯,而且可以模擬云層背光時候,邊緣透光的真實效果,故采用之,此外,向著光源進行采樣,我們偏移的步長越大,云朵能夠透光的 “邊緣” 也就越大,這個引數要根據你生成云朵的空間范圍大小自己調整,
優化與改進
在這一步,我們的體積云已經勉強能看了(雖然寫的真的非常 shit

雖然還有很多優化與改進的空間,比如性能的優化,品質的優化等等,畢竟 ray march 非常依賴 GPU 的計算能力,但是由于篇幅有限,以下內容不一定會完全實作這些改進,而是探討問題所在與大致的解決方案,因為不同的方案可以適用于不同的運用場合,,,
注:
翻譯過來就是:擺爛了,后面的我不會了,不寫了,就嗯編
編輯下,又肝了一天,下文的優化基本都實作了
云層移動
我們希望云層飄動,于是我們在 c++ 中傳遞 uniform 給著色器,每一幀累加一個變數,以此間接地作為 “時間”,然后我們根據當前時間,在采樣噪聲圖的時候添加一個偏移,因為噪聲圖是連續的,我們可以模擬云層飄動的效果,
在 c++ 端我們這么做:
int frameCounter = 0;
...
// 傳遞幀計數器
frameCounter++;
if (frameCounter == INT_MAX)
{
frameCounter = 0;
}
glUniform1i(glGetUniformLocation(composite0, "frameCounter"), frameCounter);
然后在采樣噪聲的時候我們以 frameCounter 作為偏移量進行采樣即可,此處我們只對 x 做偏移,所以云會朝著 x 方向移動:

最終效果如下:

通過改變常數(就是那個 0.0001)可以調整云的移動速度,此外這里我偷懶了,這里使用的是每一幀都++ 的計數器,那么在不同的設備上會有不同的速率,最優的方法應該是計算每一幀的時間,然后提供一個固定 trick 的計數器,,,
云層內正確的遮擋關系
還記得上文我們用距離來計算云層與物體的遮擋關系嗎?在大多數情況下都是行得通的,但是如果你把云層調整到和物體一個水平,那么就會有 bug,我們可以穿透物體看到其后面的云,這種遮擋一半的關系我們沒有正確地處理,如圖:

于是會有如下的鬼畜的效果:

解決方案也很簡單,我們在 ray march 的時候,每一步都判斷光線是否和物體相交,判斷的方式是通過深度紋理,將世界坐標轉換到螢屏坐標,然后根據當前螢屏坐標采樣深度紋理,如果采樣深度小于測驗深度,說明命中物體,此時應該停止 ray march,
所以需要如下的 uniform 變數:
// 透視投影近截面 / 遠截面
uniform float near;
uniform float far;
// 視圖,投影矩陣
uniform mat4 view;
uniform mat4 projection;
// 螢屏深度紋理
uniform sampler2D gdepth;
此外,需要額外的一個負責函式幫助我們判斷,因為透視投影的深度是非線性的:
// 螢屏深度轉線性深度
float linearizeDepth(float depth) {
return (2.0 * near) / (far + near - depth * (far - near));
}
最后在 ray march 的 for 回圈中加入如下的判斷條件:
// 轉螢屏坐標
vec4 screenPos = projection * view * vec4(point, 1.0);
screenPos /= screenPos.w;
screenPos.xyz = screenPos.xyz * 0.5 + 0.5;
// 深度采樣
float sampleDepth = texture2D(gdepth, screenPos.xy).r; // 采樣深度
float testDepth = screenPos.z; // 測驗深度
// 深度線性化
sampleDepth = linearizeDepth(sampleDepth);
testDepth = linearizeDepth(testDepth);
// hit 則停止
if(sampleDepth<testDepth) {
break;
}
現在正常了:

可以看到物體和云層的部分遮擋關系,當物體插入云層的時候,只會顯示前方的云,
可變步長的采樣
在上文中每次采樣我們選擇固定 0.25 作為步長,迭代 100 次,那么最大范圍就是 25 的距離,想要增大云層的范圍我們就必須增大【步長與迭代次數的乘積】,我們也可以增大步長,從而減小迭代次數,但代價是云的品質會降低,下圖展示了 4 中步長與迭代次數的組合帶來的效果變化:

因為遠處的云朵對細節的要求沒有那么高,我們可以在近處使用低步長,在遠處的采樣使用高步長,以彌補采樣次數過少的我、不足,下面提供一種簡單的可變步長的寫法:
// ray marching
for(int i=0; i<50; i++) {
point += step * (1.0 + i*0.05);
....
}
通過線性增大每一次采樣的步長,我們可以使用更少的采樣次數,就可以達到湊合的效果,下面是使用了 50 次迭代的結果,與 100 次迭代相比,品質差距不是很大,但是性能卻提升了:

注:這里可變步長僅是一個經驗公式,需要根據場景的大小尺寸自行定義,此外,僅通過步長變化減少迭代次數并不是最優的優化方式,如果你的顯卡足夠強勁那么可以無視這個優化,
分層步進的采樣
注意到一個問題,當視線非常平行于云層的時候,遠處的云會有缺失:

原理也很簡單,因為我們的 ray marching 是固定步長,這意味著會有一個固定的生成距離,以攝像機為圓心,半徑為 R 的球內的云層才會被生成,而當我們平視云層的時候,ray marching 不足以走完整個云層,于是必定會發生缺失:

解決方案也很簡單,我們在 y 軸方向上,對采樣的步長做一個投影,換句話說,就是使得不管視線方向如何變化,每一次采樣我們都在 y 方向上行走固定的距離,如圖:

我們將每次步進的代碼改為:
point += step / abs(direction.y);
即可,這樣每次步進都會在 y 方向上前進 1 單位的距離,即使我們把云的生成范圍增大十倍,最遠處也不會發生云的缺失,因為所有光線都能走完云層,但是代價是因為巨大的步長造成的采樣品質的下降,

這種方法適合于攝像機不會超出云層的場景,如果一定要渲染在云中穿行的效果,那么還是老老實實一步一步執行 ray marching 吧,,,
3D噪聲
我沒有學過信號與系統,我只會抄代碼,我爬 ,關于 3D 噪聲的生成,我直接照抄了 szszss大佬的博客,而據他(她?)所言該方法又來自于 iq 大佬在 shadertoy 發表的某篇博客,總之我是看不懂了,抄就完事了(經典擺爛
將噪聲生成的代碼 copy 上:
float noise(vec3 x)
{
vec3 p = floor(x);
vec3 f = fract(x);
f = smoothstep(0.0, 1.0, f);
vec2 uv = (p.xy+vec2(37.0, 17.0)*p.z) + f.xy;
float v1 = texture2D( noisetex, (uv)/256.0, -100.0 ).x;
float v2 = texture2D( noisetex, (uv + vec2(37.0, 17.0))/256.0, -100.0 ).x;
return mix(v1, v2, f.z);
}
float getCloudNoise(vec3 worldPos) {
vec3 coord = worldPos;
coord *= 0.2;
float n = noise(coord) * 0.5; coord *= 3.0;
n += noise(coord) * 0.25; coord *= 3.01;
n += noise(coord) * 0.125; coord *= 3.02;
n += noise(coord) * 0.0625;
return max(n - 0.5, 0.0) * (1.0 / (1.0 - 0.5));
}
然后重新啟動程式,可以看到 3D 噪聲的效果還是非常驚艷的,能夠更進一步逼近現實中的云:

能用 3D 噪聲還是用上,,,畢竟比較真實,我嘗試著 copy SE 大大體積云光影里面的 3D 噪聲,可惜無奈他代碼寫的太亂,再加上引數不對口,我嗯是沒調出來好的效果,,,這里就不放了
此外,我在 GitHub 上面找到了另一種 3D 噪聲的實作,因為沒有效果圖,我就沒 copy,這里放上 鏈接 來,有興趣可以康康
摩爾紋的解決
摩爾紋是因為浮點精度不足造成的,如果你是使用累積顏色的方式輸出最后的顏色值,而不是混合顏色的話,就會出現摩爾紋,摩爾紋將最終的圖形以圓圈的形式分隔開來,因為浮點精度不足,相似方向的 vec3 會被認為是同一個方向,于是就會出現圓形的分隔帶,
雖然在渲染體積云的時候沒有出現,但是我上學期做大作業的時候確實遇到過這種現象,如圖:
解決方案也很簡單,使用隨機步長進行采樣即可,亂數從噪聲圖中獲取即可,值得注意的是,隨機步長會帶來噪點,可以通過后處理階段對體積云進行一次高斯模糊進行解決,
低解析度采樣與LOD
因為云通常是非常模糊的東西,我們沒有必要對每個像素都進行 ray march,相反,我們可以只對螢屏上的某些像素進行 ray march,比如左下角 1/4 的像素,得到一張低解析度的體積云影像,然后在最終合成的時候,再將低解析度的影像采樣到螢屏上,
相比于使用 1920×1080 的完全解析度進行采樣,下 1/4 的采樣能夠節省很多的計算量,1920×1080 總共需要呼叫 2073600次 ray marching,而下 1/4 采樣的解析度是 480×270,需要 129600 次 ray march,整整少了一個數量級!我們可以把寶貴的計算能力節省下來,
Minecraft 早期的體積云光影就是這么做的,如果你貼近觀察,就會發現其物體積云是非常模糊的:

此外,也可以疊加多個不同解析度下計算的體積云影像,作為最終的輸出,也能夠達到很好的效果,這個思想叫做 LOD,Level Of Detail,在 shadertoy 上有一篇 iq 大佬的代碼 就是通過這個思想,取了 4 個不同的解析度,最后進行疊加,你敢信這是他 2013 年寫的代碼?你說是照片我都信:

注意到云仍然模糊并且品質非常高,而且使用 LOD 帶來的性能提升是明顯的,我的電腦能跑到 60 fps 了
云陰影
在原神中就有實作,因為我 是狗罕見 電腦帶不動,我沒下游戲,于是偷了一張截圖:

有三種實作方法,首先是根據世界坐標的 xz 軸,直接去云的噪聲圖中采樣,并且判斷當前位置是否有云,有則涂黑,我估計原神就使用了這種方法,優點是簡單且快速,
其次可以從光源方向直接做一次光線求交,注意是求交不是 ray march,這樣可以實時地讓云陰影回應光源位置的變化,
第三種方法就是最逼真的,我們從光源方向進行 ray march 以獲取實際云層的厚度,同時可以根據厚度涂黑地面,優點是逼真,缺點是計算開銷,當然也可以通過降解析度進行優化,這里就不詳細討論了,,,
什么?你想看代碼?我懶得寫了,再度擺爛
著色器代碼
注:
這里我只給出最基本的 ray march + 光照的代碼,在、方便移植
不包含 上文【改進與優化】部分的代碼
著色器輸出:
out vec4 fColor;
uniform 變數宣告:
uniform sampler2D noisetex; // 噪聲紋理
uniform vec3 lightPos; // 光源位置
uniform vec3 cameraPos; // 相機位置
函式定義:
#define bottom 13 // 云層底部
#define top 20 // 云層頂部
#define width 40 // 云層 xz 坐標范圍 [-width, width]
#define baseBright vec3(1.26,1.25,1.29) // 基礎顏色 -- 亮部
#define baseDark vec3(0.31,0.31,0.32) // 基礎顏色 -- 暗部
#define lightBright vec3(1.29, 1.17, 1.05) // 光照顏色 -- 亮部
#define lightDark vec3(0.7,0.75,0.8) // 光照顏色 -- 暗部
// 計算 pos 點的云密度
float getDensity(sampler2D noisetex, vec3 pos) {
// 高度衰減
float mid = (bottom + top) / 2.0;
float h = top - bottom;
float weight = 1.0 - 2.0 * abs(mid - pos.y) / h;
weight = pow(weight, 0.5);
// 采樣噪聲圖
vec2 coord = pos.xz * 0.0025;
float noise = texture2D(noisetex, coord).x;
noise += texture2D(noisetex, coord*3.5).x / 3.5;
noise += texture2D(noisetex, coord*12.25).x / 12.25;
noise += texture2D(noisetex, coord*42.87).x / 42.87;
noise /= 1.4472;
noise *= weight;
// 截斷
if(noise<0.4) {
noise = 0;
}
return noise;
}
// 獲取體積云顏色
vec4 getCloud(sampler2D noisetex, vec3 worldPos, vec3 cameraPos, vec3 lightPos) {
vec3 direction = normalize(worldPos - cameraPos); // 視線射線方向
vec3 step = direction * 0.25; // 步長
vec4 colorSum = vec4(0); // 積累的顏色
vec3 point = cameraPos; // 從相機出發開始測驗
// 如果相機在云層下,將測驗起始點移動到云層底部 bottom
if(point.y<bottom) {
point += direction * (abs(bottom - cameraPos.y) / abs(direction.y));
}
// 如果相機在云層上,將測驗起始點移動到云層頂部 top
if(top<point.y) {
point += direction * (abs(cameraPos.y - top) / abs(direction.y));
}
// 如果目標像素遮擋了云層則放棄測驗
float len1 = length(point - cameraPos); // 云層到眼距離
float len2 = length(worldPos - cameraPos); // 目標像素到眼距離
if(len2<len1) {
return vec4(0);
}
// ray marching
for(int i=0; i<100; i++) {
point += step;
if(bottom>point.y || point.y>top || -width>point.x || point.x>width || -width>point.z || point.z>width) {
break;
}
// 采樣
float density = getDensity(noisetex, point); // 當前點云密度
vec3 L = normalize(lightPos - point); // 光源方向
float lightDensity = getDensity(noisetex, point + L); // 向光源方向采樣一次 獲取密度
float delta = clamp(density - lightDensity, 0.0, 1.0); // 兩次采樣密度差
// 控制透明度
density *= 0.5;
// 顏色計算
vec3 base = mix(baseBright, baseDark, density) * density; // 基礎顏色
vec3 light = mix(lightDark, lightBright, delta); // 光照對顏色影響
// 混合
vec4 color = vec4(base*light, density); // 當前點的最終顏色
colorSum = color * (1.0 - colorSum.a) + colorSum; // 與累積的顏色混合
}
return colorSum;
}
main 函式:
vec3 worldPos = ...; // 想辦法弄到當前片元的世界坐標,可以是深度重建或者讀坐標紋理
...
vec4 cloud = getCloud(noisetex, worldPos, cameraPos, lightPos); // 云顏色
fColor.rgb = fColor.rgb*(1.0 - cloud.a) + cloud.rgb; // 混色
總結
經過不懈的努力,我們終于得到了一個還湊合的體積云渲染,也了解了體積渲染的一些作業流程和常識,下次有時間我把它移植到 mc 里面,下次再說吧,咕!
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/253016.html
標籤:其他
上一篇:三子棋(井字棋)的實作
