如何編寫Shadersmod光影包(下)

鏈接:上篇 附錄

下篇 - Shadersmod光影包高級應用

實戰光線行進 - 體積雲

這一次我們通過Ray Marching來實現一個體積雲效果.

==================================================
知識點:Ray Marching

Ray Marching(光線行進)是一種渲染方法,正常的渲染方法是將頂點數據進行變換後組裝成圖元,然後經由光柵器進行光柵化後成為一個個像素.而另一種方法則正好相反 - 從視點中心向觀察方向逐像素地投射射線,基於光路可逆的原理,反向查找落在這個像素的光會是來自哪些物體,Ray Marching就是採用這種方法,而它之所以被稱為"Marching"則是因為它查找光源的獨特方式,有時很難一步查找到射線命中的物體,所以Ray Marching是通過步進的方式,沿着射線一點點前進,並沿途判斷是否命中了發光物體.
Ray Marching的一個好處是可以用於渲染氣體以及非均勻質地的透明固、液體的,只要讓它在命中物體後不停下,而是繼續前進,並累積"吸收"到的亮度,當它前行到最大距離,或已吸收到最大亮度時,得到的結果就是最終顏色.另一個好處則是能方便地實現反射和折射等效果,只要根據光線入射角度和入射點的法線按照反射和折射定律,偏轉光線方向,或者乾脆再發射一條光線,就可以輕鬆實現在正常渲染中需要各種手段才能實現的效果(想想我們當初為了實現陰影...)
而Ray Marching的缺點則是對數據結構的要求太苛刻,同時邊緣鋸齒也比較嚴重.此外,它的性能消耗也是極大的,為了降低消耗,通常會引入估距函數,估距函數用於在當前測試點未能命中物體時,估算還要一次向前前進多少距離才能命中物體,估計函數過於保守會導致測試點遲遲未能命中,浪費大量計算時間,降低效率;而過於激進的話會導致首次命中發生在物體表層之下,甚至是穿透或錯過小的物體,因而產生錯誤的結果.
==================================================

首先還是先思考一下怎麼實現體積雲效果,我們設定雲層是在一個固定的高度,比如150~160的高度之間.當玩家站在地面上時,着色器向觀察方向投射射線,當射線到達雲層的位置時,會繼續沿着射線方向繼續前進,並判斷是否命中雲朵.

在動工之前我們先得把Minecraft的原版雲幹掉,幹掉原版雲其實並不難 - 只要你知道怎麼弄的話...我很驚訝為什麼那麼多光影包在有自己的體積雲的情況下還保留了原版雲,難道就是因為Karyonix沒有把判斷雲朵的關鍵部分寫在文檔里嗎...

原版雲的渲染其實分為兩部分,一部分是視線在雲層之下時,這時雲是不透明的,而當視線到達雲層或雲層之上時雲就會按透明的方式渲染.不過這對我們來說不重要,無論是哪種雲都是在gbuffers_textured着色器中渲染的,然而除了雲以外還有其他東西也使用gbuffers_textured,因此判斷雲的關鍵辦法是通過頂點屬性mc_Entity,之前我們在製作草木擺動效果時就用到了mc_Entity,文檔只說了在渲染地形時mc_Entity.x用於表示磚塊類型,實際上在gbuffers_textured中當mc_Entity.x等於-3.0時代表正在渲染雲,只要在頂點着色器中將mc_Entity.x提取出來並傳入片元着色器,然後在片元着色器中判斷它是否<-2.5(如果是!=-3.0可能會有問題) 如果是的話就discard掉就行了.

首先在gbuffers_textured.vsh中加入:

attribute vec4 mc_Entity;
varying float entityType;
//...main函數 {
entityType = mc_Entity.x;
//...}

然後在gbuffers_textured.fsh中加入:

varying float entityType;
//...main函數 {
if(entityType < -2.5)
	discard;
//...}

現在再測試一下,遊戲中的雲已經消失了,然後我們就可以着手製作體積雲,但在此之前先來演示一個概念上的Ray Marching原型,我們來製作一個在世界坐標的(-10, 150, -10)到(10, 160, 10)這一盒型區域內繪製雲朵的函數,在composite.fsh中添加:

uniform vec3 cameraPosition;

#define CLOUD_MIN 150.0
#define CLOUD_MAX 160.0
vec3 cloudRayMarching(vec3 startPoint, vec3 direction, vec3 bgColor, float maxDis) {
	vec3 testPoint = startPoint;
	float sum = 0.0;
	direction *= 0.1;
	for(int i = 0; i < 256; i++)
	{
		testPoint += direction;
		if(testPoint.x < 10.0 && testPoint.x > -10.0 && 
		testPoint.z < 10.0 && testPoint.z > -10.0 &&
		testPoint.y > CLOUD_MIN && testPoint.y < CLOUD_MAX)
			sum += 0.01;
	}
	return bgColor + vec3(sum);
}

這個函數有4個參數,世界坐標系下的視點中心,觀察方向,原像素顏色和未經規整化的最大距離(1單位長度等於遊戲中的1格).
cameraPosition變量則是玩家在世界坐標系中的位置,可以用作表示視點中心.
然後我們還要解決怎麼計算觀察方向,對於延遲渲染來說這倒非常好解決,由於已經知道了像素所對應的坐標(我們在進行Shadow Mapping時已經計算過了),我們只要將它的坐標減去視點中心,再進行規整化後就能得到方向向量,如果這個坐標是在光照坐標系下(光照坐標系的原點就是視點中心),那就連減去視點中心的步驟都省了,在brightness變量的計算之前添加(也就是在計算泛光亮度的代碼之前添加):

vec3 rayDir = normalize(gbufferModelViewInverse * viewPosition).xyz;
if(dist > 0.9999)
	dist = 100.0;
color.rgb = cloudRayMarching(cameraPosition, rayDir, color.rgb, dist * far);

dist是經過規整化後的距離,因此要再乘以最大視距來恢復到正常單位長度.遊戲中的天空看起來離玩家很遠,其實也還是受最大視距的限制,因此如果保留默認天空的距離的話,今後在實際製作體積雲時會發現只有正上方能看見雲,稍微遠一點的就被裁減掉了...因此這裡要手動將天空的距離調到儘可能大,比如100.現在在遊戲里飛到(0,150,0)附近,然後重載光影包,就能看到效果了.

2015-12-19_16.56.22

我們確實在世界中心的上空渲染出了一個巨大的立方體,然而你會發現只有飛到距離它極近時才能看到它,這是因為現在的測試是直接從視角原點開始,即使我們的採樣次數多達256次,但由於步長極短,大部分採樣在測試點尚未到達雲的位置時便停止了,顯然單方面增加採樣次數或加大步長都不能解決問題,因此這裡要做一些優化和取捨.
由於雲層的高度是已知的,我們可以先讓測試點沿着射線方向移動到雲層的高度再進行測試,對於不與雲層相交的射線,則可以直接放棄測試.此外,對於超過最大測試距離(換句話說,對於那些看不見雲層的點)的也是可以直接跳過的.
不過採用這種策略的話就無法正確處理當視角位於雲層高度時的情況了,對此只能忍痛放棄雲層以及雲層以上的渲染,稍後我們會修改程序,讓雲層無論什麼時候始終處於視角之上的高度.

現在將Ray Marhcing的代碼改為:

vec3 cloudRayMarching(vec3 startPoint, vec3 direction, vec3 bgColor, float maxDis) {
	if(direction.y <= 0.1)
		return bgColor;
	vec3 testPoint = startPoint;
	float cloudMin = startPoint.y + CLOUD_MIN * (exp(-startPoint.y / CLOUD_MIN) + 0.001);
	float distanceFromCloudLayer = cloudMin - startPoint.y;
	float d = distanceFromCloudLayer / direction.y;
	testPoint += direction * d;
	if(distance(testPoint, startPoint) > maxDis)
		return bgColor;
	float sum = 0.0;
	float cloudMax = cloudMin + (CLOUD_MAX - CLOUD_MIN);
	direction *= 0.1;
	for(int i = 0; i < 256; i++)
	{
		testPoint += direction;
		if(testPoint.x < 10.0 && testPoint.x > -10.0 && 
		testPoint.z < 10.0 && testPoint.z > -10.0 &&
		testPoint.y > cloudMin && testPoint.y < cloudMax)
			sum += 0.01;
	}
	return bgColor + vec3(sum);
}

我們先來細數一下這段代碼新增了什麼和修改了什麼,首先我們在開頭加入了射線方向的判斷,對於不是指向天空的射線直接放棄測試,為什麼這裡是<=0.1而不是<=0.0?一個原因是遊戲中很少有雲能延展到地平線的高度;另一個原因則是這是段dirty hack...這個問題大概是由太陽和月亮在渲染時的深度造成的,想解釋詳細原因和效果有些困難,直接上圖吧: 2015-12-31_02.39.59(2)
還記得之前我們為什麼將天空的距離改為100嗎?這張圖是在"dist=15,direction.y <= 0.0"時的截圖,增大dist是為了增加雲的可見範圍;增大射線方向的剔除範圍是為了避免在低處渲染雲.兩者結合便解決了上圖的問題. 言歸正題,之後我們重新計算了雲的最低高度,這個公式的核心是exp(-startPoint.y / CLOUD_MIN),參考以e為底的指數函數的曲線,你會發現當x位於(-2, 0]時,函數是從1緩慢接近0,然而當x小於-2時,函數會無限接近但不等於0,這恰好滿足我們的需要:在一定的高度內玩家能明顯感覺到高度越高越接近雲,但一旦超過一定範圍後,玩家會發現自己只能無限接近雲的底層,卻永遠夠不到雲.而那"+0.001",則是為了防止真有哪個蛋疼的傢伙飛到幾千格的高度,讓exp(x)過於小從而導致CLOUD_MIN * exp(x) ≈ 0. 在確定了雲的最低位置,並保證玩家的視角永遠不會超過雲層高度後,就可以計算怎樣將測試點前移到雲層的高度了.最後根據最大距離再做一次剔除,之前你可能已經發現在第三人稱下無論在什麼位置雲都會遮掩住你的人物,這是因為之前我們沒有根據最大距離做剔除,現在雲在渲染時已經會正確處理深度關係了. (其實這裡還有個小優化沒有做,distance函數需要一次開平方操作,而它遠比一次乘法操作要慢的多,如果將"distance(testPoint, startPoint) > maxDis"替換成"distance2(testPoint, startPoint) > maxDis * maxDis"(distance2是個虛構的函數,代表distance的平方,或者說沒有經過開平方的distance)會更一步優化算法.)

2016-01-27_14.26.29

現在我們已經掌握了在一個確定的範圍內渲染雲的方式了,接下來要解決的是如何確定雲的位置,這就涉及到了噪聲問題.

==================================================
知識點:再論噪聲

"對物理學家來說,凡是數值V隨時間T的變動而產生不可預測的變化者,皆為噪聲."
-Richard F. Voss, 《The Science of Fractal Images》

之前我們已經提到了噪聲圖的使用方法,但噪聲圖只能作為噪聲源,很多情況下我們需要的是經過處理的噪聲.這裡我將噪聲根據處理過程分為三個階段/種類:噪聲源、噪聲函數、複合噪聲函數.

噪聲源:
噪聲源負責產生一個"隨時間T的變動而發生不可預測的變化"的數值,通常的手段是一個Hash函數;或者是實現通過(偽)隨機數生成器事先製作一個噪聲圖,在使用時以查表的方式來獲取噪聲.

噪聲函數:
一個無規律變化的噪聲並不適合直接拿來使用,一個最致命的缺陷是:它是離散的,特別是通過隨機數生成器產生的噪聲圖,因此便有了各種連續噪聲函數.此外我們還希望噪聲函數具有確定性,即如果向噪聲函數中代入兩個相同的參數值的話,得到的結果也是相同的.
連續噪聲函數根據其生成手段分為兩大類:基於點(Point based)和基於分段(Lattice based).這兩種手段完全不同,相互之間沒有可對比性.
基於點的噪聲函數最常見的是沃利噪聲,其原理是在空間中隨機放置一定數量的固定點,每次計算噪聲時都把參數值轉換成空間中對應的一個採樣點,然後計算採樣點到最近的N個(N>=1)固定點的距離,根據距離信息來生成噪聲,熟悉幾何學的人可能會想到沃羅諾伊圖,事實上如果將一個簡單的沃利噪聲的結果轉換成顏色並繪製出來的話,基本上就是一副沃羅諾伊圖...具體效果可以見這個:https://www.shadertoy.com/view/4tsXRH
然後便是最常用的基於分段的噪聲了,基於分段的噪聲的原理是對空間進行虛擬的分割,比如對一個2D噪聲函數來說,它可以將空間分割成虛擬的網格,每當計算某個採樣點的噪聲值時,就取其所在網格的4個頂點所對應的噪聲源的數值,然後根據採樣點在網格中的位置,進行雙線性插值,由此獲得一個經過平滑的連續噪聲值.
基於分段的噪聲根據其使用的噪聲源還分成兩個小類:值噪聲(Value nose)和梯度噪聲(Gradient noise).值噪聲是最簡單的,在每個網格的頂點上它直接使用隨機數,假如它的噪聲源是一個噪聲圖,而每個頂點都是落在一個像素上的話...靠,這在OpenGL中不就等同於一個手工實現的雙線性插值採樣器嗎...其實也並不完全是,它通常在計算採樣點在網格中的位置時會進行一次埃爾米特插值,也就是所謂的平滑插值,這對噪聲函數的輸出影響不是很大,但對待會的複合噪聲函數會有微妙的影響.效果可以見這裡:https://www.shadertoy.com/view/lsf3WH
相比之下,梯度噪聲就稍微複雜一點,它在每個點上使用的是一個隨機的梯度向量,當計算噪聲時,會在每個頂點計算頂點到採樣點的距離向量與頂點的梯度向量的點乘,然後對這些結果進行雙線性插值.最經典的柏林噪聲就是梯度噪聲,實例可以見這個:https://www.shadertoy.com/view/XdXGW8 (雖然作者強調梯度噪聲不等於柏林噪聲,柏林噪聲是梯度噪聲的一種形式,但這個着色器確實使用的是柏林噪聲)
這兩種噪聲的差異還是蠻大的,但現在我們還無需關心,因為在待會我們所使用的複合噪聲函數中,它們看起來都差不多...

複合噪聲函數:
我們已經知道單一的一個噪聲函數是什麼樣了,然而顯然這東西怎麼看也不像是自然界中雲的樣子,那麼該怎麼用它來表示一個雲呢?在數學中一個正弦波怎麼看也不像是鋸齒波,但通過傅立葉變換,許多個不同頻率不同振幅的正弦波疊加在一起便可以近似表示一個鋸齒波.對於噪聲信號而言這個方法也適用,我們可以通過對同一個噪聲函數的不同頻率不同振幅下的結果疊加來產生一個變化更複雜的噪聲信號,那麼這個方法的理論支持又是什麼?
首先我們要先聊一個不相關的話題:概率論.說到這個蛋疼的學科,我至今還沒忘記當初概率論考試擦線飄過的驚險...好吧歪樓了,概率論中有個被稱為分數布朗運動(Fractional Brownian motion,亦被譯為分形布朗運動,簡稱FBM)的東西,要想詳細討論這個東西的性質的話那實在是超過本文的範圍和作者的能力了...要論用途的話,FBM可以很好地描述一些自然現象,比如地理與氣象;還有一些其他的東西,比如金融,這也是為什麼你在搜索引擎中搜索"分數布朗運動"會搜出一堆和股票相關的研究...但至少我們現在知道雲的分布比較符合FBM了.
然而如果你搜一下FBM的公式的話,估計你的第一反應多半是"卧槽",這種公式顯然要讓我們實現一遍有些不太現實,不過好在FBM也有一種"近似",就是分數噪聲(Fractal Noise),關於分數噪聲和FBM的關係,互聯網和書本上對它們的解釋眾說紛紜.Wiki的分數噪聲頁上只是含糊地提到"與FBM有密切的關係";在《圖形着色器 - 理論與實踐》一書中,作者直言不諱地表示FBM就是分數噪聲;在Fractal Terrain Generation這個庫的Wiki中,對FBM的定義和公式完全照搬分數噪聲;而在Scratchapixel的"Noise Part 1"一課中,則稱FBM只是計算機圖形學家從數學中借來的術語,僅僅只是因為它和分數噪聲相似.那麼到底誰是正確的呢...我也不知道,我的看法是FBM和分數噪聲確實是兩個不同的東西,但由於兩者的相似性,分數噪聲被用來近似表示FBM,天長日久兩者在圖形學界也被混為一談了.引用某篇Presentation的吐槽:"分數布朗運動與什麼都有關,就是與運動無關;分數噪聲與什麼都有關,就是與噪聲無關!"
在解決完"歷史問題"後,我們就可以研究分數噪聲該如何得到,獲取一個分數噪聲十分容易,對一個指定的噪聲函數取樣,每次取樣時都讓頻率加倍,振幅減半,最後將取樣結果相加,得到的就是一個分數噪聲,在實際操作中,頻率不一定是整加倍,可以是別的數;而為了讓結果處於0~1的範圍,振幅的減半衰減通常不會有改動.

最後五毛特效糊弄了一張複合噪聲函數生成噪音的過程:
SistersNoise
==================================================

在磕磕絆絆地看完噪聲的原理後,我們就可以着手開始製作一個雲朵噪聲發生器了,不過它需要能夠支持3D噪聲,之前我們看的都是2D噪聲,這裡我們可以使用一個現成的方案,iq(Shadertoy的站長,人類強者(半霧,有興趣的話可以看一下他的blog的文章區:http://www.iquilezles.org/www/index.htm 這真是老司機啊...))製作了一個以噪聲圖為噪聲源的3D值噪聲函數(https://www.shadertoy.com/view/4sfGzS),之前我們看的那兩個都是以Hash函數為噪聲源,速度沒有噪聲圖那麼快.不過需要注意的是,這個着色器中他使用的是特製的噪聲圖,噪聲圖的ga分量等同於偏移(-37, -17)個像素的位置的rb分量,而rb分量則是隨機數,據他說這樣是為了降低紋理採樣頻率,一次取樣當兩次使,但由於我們在Shadersmod中沒這好待遇,所以只有在代碼中將紋理採樣拆開了.

又到了喜聞樂見的寫(copy)代碼的時候了!在composite.fsh中添加(記得添加到cloudRayMarching的前面,並且強烈建議在CLOUD_MAX的後面,至少是這兩個函數,noisetex和frameTimeCounter在哪無所謂):

uniform float frameTimeCounter;
uniform sampler2D noisetex;

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.x += frameTimeCounter * 5.0;
	coord *= 0.002;
	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));
}

這段代碼由兩部分組成,noise函數為噪音函數,其中floor是向下取整,fract為取小數部分,smoothstep是GLSL內置的埃爾米特插值,如果手動寫出來的話就是"f = clamp((f - 0.0) / (1.0 - 0.0), 0.0, 1.0); f = f*f*(3.0-2.0*f);",之後的計算就是將整數部的z映射到二維的UV坐標上,然後取當前整數部對應的噪聲,和當前整數部z+1時所對應的噪聲,再根據小數部的z對兩值進行線性插值,以得到連續的三維噪聲.
而getCloudNoise有一半是對採樣坐標的頻率進行調整,修改0.002可以改變雲的平均體積,值越小單朵雲體積越大但視野內雲的數量越少;return中的是渲染雲的最小閾值,為了之後計算方便,這裡對它們進行了規整化,即輸出範圍重新映射到了[0.0~1.0) 要修改的話別忘了同時修改兩個0.5.暫且我們先不管它倆,畢竟現在我們連雲是什麼樣都還沒見到呢...這個函數的其餘部分便是一個複合噪聲函數,採用的是簡單的4次分數噪聲,值得注意的是這裡沒有使用整數次倍頻程(n次倍頻程=頻率x2n) 而是使用的頻率x3,這樣可以豐富雲的細節,在完成本章後,你可以嘗試一下頻率x2的效果,雲底看上去會過於平滑.此外你還可以嘗試5次分數噪音,看看細節過多時的效果.至於為什麼頻率加倍時沒有採用整倍數...因為據說在小數位添加少許抖動有助於清除鋸齒和瑕疵...反正我是沒觀察出有什麼明顯改進,但還是保留下來了.

現在離生成雲只差一步在,將cloudRayMarching中的:

if(testPoint.x < 10.0 && testPoint.x > -10.0 && 
	testPoint.z < 10.0 && testPoint.z > -10.0 &&
	testPoint.y > cloudMin && testPoint.y < cloudMax)
		sum += 0.01;

修改為:

if(testPoint.y > cloudMin && testPoint.y < cloudMax)
	sum += getCloudNoise(vec3(testPoint.x, testPoint.y - cloudMin, testPoint.z)) * 0.01;

由於我們的雲的高度不是固定的,因此這裡要重新計算取樣點的高度.現在可以進遊戲測試一下了.

2016-01-29_01.37.49

然而我們看到的卻是如精斑一樣散亂分布的,亮瞎人眼的東西,什麼鬼啊!不過不用急着關遊戲(但我強烈建議你趕快把光影包換回(none),因此RayMarching是很吃GPU的) 畢竟當初ShadowMapping那麼不堪入目我們不也挺過來了...

首先我們先來修正兩個小問題:

  • 有些卡/本子有些燙/顯卡風扇在讀條了: 256次採樣其實是沒必要的;對於雲這樣距離遠且外形模糊的東西來說,每次0.1個單位長度的步長也是過於奢侈了.由於我們的體積雲只支持從雲層下方觀察,因此可以採用"偽步進"方案,將雲視為一層層離散的雲片,而不是一個連續的雲團,每次步進時不是前進固定的距離,而是直接進入下一層,雖然聽上去可能會損失不少細節,但實際測試時效果還是可接受的,而且作為補償還科技增加雲層的厚度來讓雲顯得更有立體感.將"direction *= 0.1;"改成"direction *= 1.0 / direction.y;",將"for(int i = 0; i < 256; i++)"改成"for(int i = 0; i < 32; i++)"
  • 雲的高度過低,雲層過薄: 將CLOUD_MIN和CLOUD_MAX改成400.0和430.0

然後我們可以開始着手處理雲的渲染質量問題了,現在的問題在於我們沒有對雲做光照處理.所以接下來要製作雲的光照函數.
對於寫實渲染(Photorealistic rendering)的手段有兩類,一類是按照經驗公式,另一類是基於物理.前者就是將日常生活中所見的場景擬合出一條經驗公式,然後選擇合適的參數進行渲染,現實中的積雲底層比頂層要暗,我們就在渲染底層時將它塗暗一些,黃昏時雲朵朝向落日的一面會被染紅,我們就在渲染黃昏的景色時手工調整雲的顏色,只要讓它們"看起來像真的一樣"就好.而後者的渲染則是從原理出發,根據實際的物理公式來計算渲染結果,在日光穿透雲朵到達人眼之前,大部分光會被氣溶膠吸收或者由於散射而損失掉,但亦會有一部分來自其他光束的散射光"加入"這條光束,採用基於物理的渲染就要計算這些因素而對出射光線造成的影響.如果你對基於物理的方法感興趣的話,可以參考一下這篇文章http://wiki.nuaj.net/index.php?title=Clouds,Shadertoy上也有現成的實現(第二版https://www.shadertoy.com/view/ldlXzM 第三版https://www.shadertoy.com/view/lssXzM 兩版本方法均相同,僅僅只是參數有變化,說實話我覺得他的第二版比第三版要好...) 這裡我使用的是iq寫的基於經驗的雲光照,原始版本為https://www.shadertoy.com/view/XslGRr

在開始寫(chao)代碼之前,我們先要討論一下光照函數的組成,從直覺來講似乎只要1個控制參數就夠了:該位置相對於光源的厚度,假如知道這個參數的話,只需要增大參數小(向光面)的地方的亮度,減小參數大(背光面)的地方的亮度就行了,但實際上卻不可行,不只是因為想獲取這個參數有些難,還有個問題是這個算法將雲看成了一個有明確邊界的固體,而事實上雲是由許多不均勻分布的點組成,在邊緣很難找到明確的分界.
所以這裡我們會採用兩個參數,密度和光照,光照函數會在光照函數的所有採樣點中逐點統計該點的雲密度以及光照強度,最後將它們對應的顏色混合到一起,沒錯,就是照搬iq的方法... ?
然而還有兩個問題,一個是如何判斷光照,另一個是如何混合.判斷光照最好的辦法是在採樣點的位置立刻向光源方向再做一組Ray Marching,判斷自己到光源之間還隔了多少雲,但這種方法有些麻煩,而且是O(NM)的複雜度,因此這裡採用的方法是從採樣點向光源方向偏移一定距離,然後再次根據複合噪音函數計算一次雲密度,根據兩點的密度之差來估算收到的光照影響(其實這個方法不是很準確,儘管一點密度高另一點密度低的話,可能是前一點在雲內後一點在雲外,但也有可能前一點在厚的地方,後一點在薄的地方.正確的估算方法應該採用累計雲厚度,不過現在這個效果看起來也不是太差,而且也很簡單?) 這個方法的複雜度只有O(N).第二個問題則是如何混合,正確的混合姿勢應該是從後往前混合,即先繪製不透明的背景,然後從遠往近依次混合透明物體,然而由於我們採用的是由近到遠進行Ray Marching,固只能湊合使用從前往後的混合.(相比之下,那片基於物理的渲染的文章中作者就說"從後♂面的方法更符合直覺" 雖然他指的是Ray Marching從遠往近)

首先先來加入光照函數,在composite.fsh中添加:

vec4 cloudLighting(vec4 sum, float density, float diff) {  
	vec4 color = vec4(mix(vec3(1.0,0.95,0.9), vec3(0.3,0.315,0.325), density ), density );
	vec3 lighting = mix(vec3(0.7,0.75,0.8), vec3(1.8, 1.6, 1.35), diff);
	color.xyz *= lighting;
	color.a *= 0.4;
	color.rgb *= color.a;
	return sum + color*(1.0-sum.a);
}

3個參數分別是累積顏色、密度和兩點間密度的差值.變量color就是由密度控制的基色,而lighting則是由密度間接推出的光照,不用太在意那4個vec3,這些都是經驗公式中的魔數...函數的後半部分都是在將現顏色混合入積累的顏色.

之後要小改一下getCloudNoise,我們要讓它在超出雲層的範圍過渡到0,否則通過判斷兩點間密度來確定光照的方法將會失效,在"vec3 coord = worldPos;"的下方加入:

float v = 1.0;
if(coord.y < CLOUD_MIN)
{
	v = 1.0 - smoothstep(0.0, 1.0, min(CLOUD_MIN - coord.y, 1.0));
}
else if(coord.y > CLOUD_MAX)
{
	v = 1.0 - smoothstep(0.0, 1.0, min(coord.y - CLOUD_MAX, 1.0));
}

然後在函數返回值之前加入:

n *= v;

然後還要添加光源方向的計算,現在的lightPosition是在眼坐標系下,我們需要一個在世界坐標系中的方向,而且由於lightPosition會在日出和黃昏時在太陽和月亮之間直接切換,雲層光照又不像陰影那樣可以通過一個淡入淡出來掩飾住這個變化,因此待會我們將只使用太陽的位置來計算光源方向.

在composite.vsh("我們也很久沒有看見新鮮的頂點着色器了!" 提問,這是哪個紅字本里的誰的台詞?)中加入:

uniform mat4 gbufferModelViewInverse;
varying vec3 worldSunPosition;

//...main函數 {
worldSunPosition = normalize((gbufferModelViewInverse * vec4(sunPosition, 0.0)).xyz);
//... }

gbufferModelViewInverse是將點變換到世界坐標系中然後平移-cameraPosition個距離,然而由於"vec4(sunPosition, 0.0)"不是點而是個向量,因此不受平移影響,這樣我們就得到了一個新鮮的世界坐標系下的光原向量了.

最後就該魔改RayMarching了,將RayMarching替換為 (還有順手添加上worldSunPosition):

varying vec3 worldSunPosition;

vec3 cloudRayMarching(vec3 startPoint, vec3 direction, vec3 bgColor, float maxDis) {
	if(direction.y <= 0.1)
		return bgColor;
	vec3 testPoint = startPoint;
	float cloudMin = startPoint.y + CLOUD_MIN * (exp(-startPoint.y / CLOUD_MIN) + 0.001);
	float d = (cloudMin - startPoint.y) / direction.y;
	testPoint += direction * d;
	if(distance(testPoint, startPoint) > maxDis)
		return bgColor;
	float cloudMax = cloudMin + (CLOUD_MAX - CLOUD_MIN);
	direction *= 1.0 / direction.y;
	vec4 final = vec4(0.0);
	float fadeout = (1.0 - clamp(length(testPoint) / (far * 100.0) * 6.0, 0.0, 1.0));
	for(int i = 0; i < 32; i++)
	{
		if(final.a > 0.99 || testPoint.y > cloudMax)
			continue;
		testPoint += direction;
		vec3 samplePoint = vec3(testPoint.x, testPoint.y - cloudMin + CLOUD_MIN, testPoint.z);
		float density = getCloudNoise(samplePoint) * fadeout;
		if(density > 0.0)
		{
			float diff = clamp((density - getCloudNoise(samplePoint + worldSunPosition * 10.0)) * 10.0, 0.0, 1.0 );
			final = cloudLighting(final, density, diff);
		}
	}
	final = clamp(final, 0.0, 1.0);
	return bgColor * (1.0 - final.a) + final.rgb;
}

先來欽點...不,清點一下新增和刪去了哪些內容,首先我們刪掉了sum,取而代之的是final,它代表最終累積到的雲顏色.然後我還悄悄地加了一個福利 - 用於淡出遠處雲朵的變量fadeout,它的計算公式中有2個關鍵參數,"far*100"是最遠視距,出自之前的"dist = 100; dist * far";而那個6.0則是控制淡出範圍的魔數,這個數值剛好是天空和霧的邊緣.之後在計算採樣點samplePoint的時候我改了一下y坐標的公式,否則之前在getCloudNoise的修改就會有問題.之後就是根據採樣點獲取雲密度,如果該位置有雲的話,就根據採樣點和世界坐標系下的光源方向計算出偏移點,再計算偏移點的雲密度與採樣點的雲密度之差.獲得所有信息後,就可以計算此點的光照了.

現在可以着手測試了,把遊戲時間調到白天,然後重載光影包.

2016-01-30_04.18.47

現在的效果看起來還不錯,你可以嘗試"/time set day" "/time set 6000" "/time set 11000",來觀察光照對雲的影響.覺得雲不夠厚,缺乏立體感?嘗試修改getCloudNoise的返回值,將它的返回值改成:

return smoothstep(0.0, 1.0, pow(max(n - 0.5, 0.0) * (1.0 / (1.0 - 0.5)), 0.5));

這個的原理利用0<a<1的冪函數在x位於0~1時會增大中間值的特點,增加雲的平均厚度,最後還順手來了一個平滑插值,如果嫌礙事的話可以去掉.通過控制最後一個0.5可以改變厚度,不過如果過厚的話會讓雲顯得跟一張餅似的...

2016-01-30_04.39.34

現在只剩一個問題需要解決了:不同時間下的光照顏色,現在整個光照都是由4個顏色控制:低密度的基色、高密度的基色、無光照的顏色係數和有光照的顏色係數,只要在頂點着色器中根據時間計算這4種顏色並實現平滑過渡就可以了.
理論上講只需要將時間分為4片:白天、黃昏、夜間和黎明,但在我的實現中我將時間分為2x5個點,一套時間點包括開始、中點1、中點2、中點3、結束,黃昏和黎明時各有一套,對於黃昏,當遊戲時間在"開始"和"中點1"之間時,遊戲從晝間顏色過渡到黃昏的顏色,在"中點1"到"中點2"之間,顏色保持在黃昏時,在"中點2"到"中點3"時,顏色會過渡到無月光時的夜間顏色,在"中點3"到"結束"時過渡到正常的夜間顏色,之所以要加一個無月光的夜間顏色是和我挑選的顏色參數有關,如果你設計出更好的顏色的話,可能完全不需要中點3的無月光顏色過渡.黎明和黃昏差不多,只不過順序顛倒了一下,先是過渡到無月光,然後過渡到黎明,停留一段時間後最終過渡到晝間顏色.

varying vec3 cloudBase1;
varying vec3 cloudBase2;
varying vec3 cloudLight1;
varying vec3 cloudLight2;

#define SUNSET_START 11500.0
#define SUNSET_MID1 12300.0
#define SUNSET_MID2 13600.0
#define SUNSET_MID3 14200.0
#define SUNSET_END 14500.0
#define SUNRISE_START 21000.0
#define SUNRISE_MID1 22000.0
#define SUNRISE_MID2 22500.0
#define SUNRISE_MID3 23500.0
#define SUNRISE_END 24000.0

const vec3 BASE1_DAY = vec3(1.0,0.95,0.9), BASE2_DAY = vec3(0.3,0.315,0.325);
const vec3 LIGHTING1_DAY = vec3(0.7,0.75,0.8), LIGHTING2_DAY = vec3(1.8, 1.6, 1.35);

const vec3 BASE1_SUNSET = vec3(0.6,0.6,0.72), BASE2_SUNSET = vec3(0.1,0.1,0.1);
const vec3 LIGHTING1_SUNSET = vec3(0.63,0.686,0.735), LIGHTING2_SUNSET = vec3(1.2, 0.84, 0.72);

const vec3 BASE1_NIGHT_NOMOON = vec3(0.27,0.27,0.324), BASE2_NIGHT_NOMOON = vec3(0.05,0.05,0.1);
const vec3 LIGHTING1_NIGHT_NOMOON = vec3(1.5,1.5,1.5), LIGHTING2_NIGHT_NOMOON = vec3(0.8,0.8,0.9);

const vec3 BASE1_NIGHT = vec3(0.075,0.075,0.09), BASE2_NIGHT = vec3(0.05,0.05,0.1);
const vec3 LIGHTING1_NIGHT = vec3(6.0,6.0,6.3), LIGHTING2_NIGHT = vec3(1.0,1.0,1.0);

//...main函數 {
float fTime = float(worldTime);
if(fTime > SUNSET_START && fTime <= SUNSET_MID1)
{
	float n = smoothstep(SUNSET_START, SUNSET_MID1, fTime);
	cloudBase1 = mix(BASE1_DAY, BASE1_SUNSET, n);
	cloudBase2 = mix(BASE2_DAY, BASE2_SUNSET, n);
	cloudLight1 = mix(LIGHTING1_DAY, LIGHTING1_SUNSET, n);
	cloudLight2 = mix(LIGHTING2_DAY, LIGHTING2_SUNSET, n);
}
else if(fTime > SUNSET_MID1 && fTime <= SUNSET_MID2)
{
	cloudBase1 = BASE1_SUNSET;
	cloudBase2 = BASE2_SUNSET;
	cloudLight1 = LIGHTING1_SUNSET;
	cloudLight2 = LIGHTING2_SUNSET;
}
else if(fTime > SUNSET_MID2 && fTime <= SUNSET_MID3)
{
	float n = smoothstep(SUNSET_MID2, SUNSET_MID3, fTime);
	cloudBase1 = mix(BASE1_SUNSET, BASE1_NIGHT_NOMOON, n);
	cloudBase2 = mix(BASE2_SUNSET, BASE2_NIGHT_NOMOON, n);
	cloudLight1 = mix(LIGHTING1_SUNSET, LIGHTING1_NIGHT_NOMOON, n);
	cloudLight2 = mix(LIGHTING2_SUNSET, LIGHTING2_NIGHT_NOMOON, n);
}
else if(fTime > SUNSET_MID3 && fTime <= SUNSET_END)
{
	float n = smoothstep(SUNSET_MID3, SUNSET_END, fTime);
	cloudBase1 = mix(BASE1_NIGHT_NOMOON, BASE1_NIGHT, n);
	cloudBase2 = mix(BASE2_NIGHT_NOMOON, BASE2_NIGHT, n);
	cloudLight1 = mix(LIGHTING1_NIGHT_NOMOON, LIGHTING1_NIGHT, n);
	cloudLight2 = mix(LIGHTING2_NIGHT_NOMOON, LIGHTING2_NIGHT, n);
}
else if(fTime > SUNSET_END && fTime <= SUNRISE_START)
{
	cloudBase1 = BASE1_NIGHT;
	cloudBase2 = BASE2_NIGHT;
	cloudLight1 = LIGHTING1_NIGHT;
	cloudLight2 = LIGHTING2_NIGHT;
}
else if(fTime > SUNRISE_START && fTime <= SUNRISE_MID1)
{
	float n = smoothstep(SUNRISE_START, SUNRISE_MID1, fTime);
	cloudBase1 = mix(BASE1_NIGHT, BASE1_NIGHT_NOMOON, n);
	cloudBase2 = mix(BASE2_NIGHT, BASE2_NIGHT_NOMOON, n);
	cloudLight1 = mix(LIGHTING1_NIGHT, LIGHTING1_NIGHT_NOMOON, n);
	cloudLight2 = mix(LIGHTING2_NIGHT, LIGHTING2_NIGHT_NOMOON, n);
}
else if(fTime > SUNRISE_MID1 && fTime <= SUNRISE_MID2)
{
	float n = smoothstep(SUNRISE_MID1, SUNRISE_MID2, fTime);
	cloudBase1 = mix(BASE1_NIGHT_NOMOON, BASE1_SUNSET, n);
	cloudBase2 = mix(BASE2_NIGHT_NOMOON, BASE2_SUNSET, n);
	cloudLight1 = mix(LIGHTING1_NIGHT_NOMOON, LIGHTING1_SUNSET, n);
	cloudLight2 = mix(LIGHTING2_NIGHT_NOMOON, LIGHTING2_SUNSET, n);
}
else if(fTime > SUNRISE_MID2 && fTime <= SUNRISE_MID3)
{
	cloudBase1 = BASE1_SUNSET;
	cloudBase2 = BASE2_SUNSET;
	cloudLight1 = LIGHTING1_SUNSET;
	cloudLight2 = LIGHTING2_SUNSET;
}
else if(fTime > SUNRISE_MID3 && fTime <= SUNRISE_END)
{
	float n = smoothstep(SUNRISE_MID3, SUNRISE_END, fTime);
	cloudBase1 = mix(BASE1_SUNSET, BASE1_DAY, n);
	cloudBase2 = mix(BASE2_SUNSET, BASE2_DAY, n);
	cloudLight1 = mix(LIGHTING1_SUNSET, LIGHTING1_DAY, n);
	cloudLight2 = mix(LIGHTING2_SUNSET, LIGHTING2_DAY, n);
}
else
{
	cloudBase1 = BASE1_DAY;
	cloudBase2 = BASE2_DAY;
	cloudLight1 = LIGHTING1_DAY;
	cloudLight2 = LIGHTING2_DAY;
}
//... }

這段代碼雖然看着很多,但沒有什麼可說明的...唯一需要提醒的兩點是黃昏和黎明用的都是一套顏色(XXX_SUNSET),以及針對夜間的光照係數,由於我們總是使用太陽的位置作為光照方向,而MC中月亮和太陽的位置總是相對的,所以在夜間光照時cloudLight1是作為有光照的係數(本來應該是無光照),而cloudLight2作為無光照的係數,我知道這個設計確實有缺陷,因為在通過密度來判斷光照時不能簡單地把有光區和無光區做顛倒,因為有些區域本該是無論光源在哪個方向都是照不到光的(比如雲的中心),但現在的方法看上去並不是那麼糟糕 ☺ 所以我就繼續這麼做了,如果你實在不喜歡這樣的話,可以試試修改無月光時的顏色和光照,將它們改成和光照強度無關,然後在利用無月光時的過渡時改變worldSunPosition的光源.

最後別忘了把cloudLighting中的顏色計算中的4個魔數改成:

//別忘了varying vec3 cloudBase1什麼的...
vec4 color = vec4(mix(cloudBase1, cloudBase2, density ), density );
vec3 lighting = mix(cloudLight1, cloudLight2, diff);

現在我們的雲已經基本能看了,誠然它還有很多不完善之處並且有可改進空間(毫無疑問這是句客套話,意思是"我知道我做出來的是坨shit但我TM就不幹了"),但是針對雲的教程就到這裡了...我承認這章並不是很完善,但至少我們有了一個能看的雲(我認為如果優化一下參數的話可以超越Chocapic13系的雲),以及簡單學習了一下RayMarching的方法,不過不要高興的太早,待會我們還要繼續跟光線打交道 ?

2016-01-30_15.09.56

對了,如果你的雲過於薄的話,你會發現當你站在一個雲的下方並向正上方觀察時,會出現明顯的莫列紋(也就是一圈一圈的花紋),並且會隨着你的上下移動而閃爍,正所謂十個莫列紋九個跟浮點精度有關,現在遇到的情況也不例外,它的原因是因為我們的採樣點是緊壓着CLOUD_MIN出發,而且由於每次步進是步進一整層,最後一次測試又會緊壓着CLOUD_MAX,簡直就是在刁難FPU啊...解決它的辦法不難,給"testPoint += direction * d;"稍微加個小小的偏移就行,比如:

testPoint += direction * (d + 0.01);

最後再討論一下我們的體積雲的改進空間:

  • 天氣:我們還沒有實現雨天時陰天的效果,要實現這個效果至少需要修改兩處,一個是根據天氣修改光照係數,一個是根據天氣修改getCloudNoise
  • 雲陰影:忘了Shadersmod中的那個逗比的雲陰影吧,那個只支持原版雲,而且沒有幾個光影包認真實現了它,而且Shadersmod本來對雲的處理就有問題...如果要實現雲陰影的話,就在陰影測試點對着光源的方向做一次RayMarching看看有沒有遇到雲,有的話就根據厚度來判斷陰影的程度.
  • 真.體積雲:我承認我們現在的體積雲應該算偽體積雲,它幾乎就是幾十層雲紋理疊在一起組成的,然而好像還沒有幾個光影包實現了真體積雲,據我所知SEUS10.2實現了,而且真的很不錯(雖然被奇怪的偽影肛的生不如死..."這種打黑槍的快感,又是什麼感情呢?" 提問,這是出自哪個紅字本的誰的話?) 但是為了效率他採用的1/4分辨率計算然後上採樣到屏幕尺寸...而且你猜猜他的RayMarching需要多少步? A.800略小 B.正好800 C.800略多 真·是·悲·傷·呢
  • 光線直穿過雲時的亮光:就是當太陽位於雲後方時雲被照亮的效果,特別是黃昏時尤其明顯,這個我還真沒仔細想過怎麼弄,也許可以根據投影出的射線與光源方向的夾角來增加此處雲的亮度?
  • 光環:如果你的體積雲不打算支持視角位於雲上方的話就可以跳過這個了...根據相函數,光在射入氣溶膠時會有一部分光以接近180°的角度"反射"回光源,結果就是當光源直視雲朵時,會看到一個彩色的光環,由於並非每個人都像JC那樣擁有"My vision is augmented"的發光狗眼,所以平時難以見到這種奇景,不過在乘飛機時,在飛過雲層上空時如果觀察飛機在雲上的影子可以看見圍繞影子的光環.
  • 粒子效果遮掩:根據深度緩衝來判斷是否渲染雲存在一個問題,就是遊戲中的粒子效果(包括雨和雪)都沒有深度緩衝信息(儘管在渲染時有深度測試,我估計它們是關閉了深度緩衝寫入),這導致在渲染時它們會被雲遮住...想糾正這個問題我估計得修改gbuffers_skybasic,讓它在渲染天空時輸出一個特殊值,在渲染其它物體時還要輸出個正常值將它抵消掉,只有擁有特殊值的像素才會進行雲渲染.

該部分的最終成品可以從這裡下載: [SkyDrive] [百度網盤]

實現光線追蹤 - 屏幕空間反射

歡迎離開與再一次來到波與粒的境界!也許你還在為RayMarching而吐血(只要你剛才不是一路複製粘貼,而是完整讀下來的話),但遺憾的是,我們現在又要和光打交道了!這一次我們來製作一個這個星球和它的衛星上最糟糕的水面反射,沒有之一.

鏡面反射在圖形學上已經有一段歷史,說到反射,即使是外行也能想到的最簡單的方式是以鏡面中心為原點,朝向反射光方向渲染一幅圖像,然後再貼回鏡面上,事實上過去就是這麼干,現在也是這麼干,變化的只是手段,早期的方法是利用模板緩衝,在正常繪製時如果遇到鏡面就在模板緩衝中加入標記,然後在渲染鏡像畫面時打開模板測試,這樣鏡像畫面只會被繪製在鏡面上,它的缺點是每繪製一個鏡面就要進行一遍渲染,而且只能用於渲染平面.

whatashame
圖:2000年的DeusEx,圖片源自Steam社區. 說起來一代廚嘲笑三代的一個把柄就是2011年推出的三代完全沒有任何反射,而且為了掩蓋這個問題,遊戲中所有的鏡子不是被砸了就是被貼上了廣告...

後來又發展出了環境立方圖(Environment Cube-map),最初的環境立方圖只能用於反射靜態物體,原理是在遊戲開發時製圖師在遊戲地圖中挑選合適的位置放入光探針(Light Probe,亦被稱為Environment Probe,可能是為了區別功能,防止與用於生成球諧函數的Light Probe混淆),在地圖烘焙階段每個光探針都會生成一幅靜態場景的全景照,並存入環境立方圖,在遊戲中當需要繪製反射時就挑選距離鏡面最近的環境立方圖並根據反射光線從中取樣,這種方法速度快並且可以處理任意曲面任意角度的反射,缺點是只能反射靜態物體,而且如果鏡面是可移動的話就會面臨在多個環境立方圖之間切換的問題.

csscubemap
圖:2004年的CS:Source,圖片源自網絡. 注意AWP瞄準鏡上的反射,只有地圖上具有環境立方圖的地圖才會有反射(比如沙1,弔橋),如果沒有的話(比如沙2)就只會是霧蒙蒙的一片,之前我還在想V社是採用了什麼黑科技能在靜態的立方圖之間平滑過渡,後來才發現是不過渡,超過了範圍直接瞬間切換到更近的立方圖...看來他們認為專心玩遊戲的人沒心思觀察瞄準鏡,專心看瞄準鏡的傻子不會滿處跑,事實看來確實如此.

後來隨着顯卡機能的發展以及幀緩衝和RenderToTexture的出現,環境立方圖也可以實時渲染了,實時渲染的環境立方圖可以包含動態實體,以此可以實現真正意義上的真實反射,然而這東西渲染起來略慢,畢竟是張全景圖,因此人們還在尋找有沒有無需多遍渲染,能在一次pass中直接完成的反射.2011年Crytek的員工在關於CryENGINE3的演講中介紹了它的實時局部反射(Real Time Local Reflections)功能,這個功能後來被稱為屏幕空間反射(Screen Space Reflection,簡稱SSR).

SSR的原理簡單可分為如下4步.

  • 根據視點到鏡面像素的射線,和鏡面的法線,計算出反射光線.
  • 以像素對應的位置為起點,以反射光線為方向,進行Ray Tracing.
  • 在Ray Tracing中找到第一個命中的像素.
  • 取此像素對應的顏色作為反射顏色,如沒有命中則返回一個預先指定的顏色.

那麼,Ray Tracing又是什麼鬼呢...?

==================================================
知識點:Ray Tracing

Ray Tracing(光線追蹤)幾乎就是Ray Marching的邪惡的孿生兄弟,很多時候它們它們採用的手段是相同的,有時也會被混淆在一起,真要嚴格區分的話,Ray Tracing的目的是沿射線去追蹤查找第一個命中的物體,而Ray Marching是沿射線一路向前沿途收集信息,很多時候它們採用的都是逐步前進採樣的方法.Ray Tracing可以被用於計算反射和陰影,繪製固體,以及計算全局光照實現照片級的渲染.
==================================================

SSR的原理基本就是這樣,關鍵在於如何判斷命中像素,這裡使用的方法是根據深度值來判斷,在一次判斷中,先計算測試點的深度,這個深度稱之為測試深度,然後將測試點換算到屏幕坐標系中得到一個採樣點,在深度緩衝中根據採樣點獲得一個深度值,這個深度值稱為採樣深度,通過對比測試深度和採樣深度,可以判斷出測試點是在它在屏幕中對應的像素的前面還是後面,如果它是在前面,說明尚未命中像素,如果在後面,則代表它命中了這個像素.(當然實際操作並沒有這麼簡單,待會會詳細說明.)

ssrp1
圖:基於深度值的射線追蹤概念演示.紅色為測試點,藍色為採樣點.

接下里我們就來實現一個在眼坐標系中進行Ray Tracing的"簡單"的SSR,首先要解決的問題是如何判定水面,半透明物體繪製有一個專門的G-Buffer着色器gbuffers_water,現在先創建gbuffers_water.vsh和gbuffers_water.fsh,然後為它們添加內容.

在gbuffers_water.vsh中添加:

#version 120

attribute vec4 mc_Entity;

varying vec4 color;
varying vec4 texcoord;
varying vec4 lmcoord;
varying vec2 normal;
varying float attr;

vec2 normalEncode(vec3 n) {
    vec2 enc = normalize(n.xy) * (sqrt(-n.z*0.5+0.5));
    enc = enc*0.5+0.5;
    return enc;
}

void main()
{
	vec4 position = gl_ModelViewMatrix * gl_Vertex;
	float blockId = mc_Entity.x;
	if(mc_Entity.x == 8 || mc_Entity.x == 9)
		attr = 1.0 / 255.0;
	else
		attr = 0.0;
	gl_Position = gl_ProjectionMatrix * position;
	gl_FogFragCoord = length(position.xyz);
	color = gl_Color;
	texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0;
	lmcoord = gl_TextureMatrix[1] * gl_MultiTexCoord1;
	normal = normalEncode(gl_NormalMatrix * gl_Normal);
}

在gbuffers_water.fsh中添加:

#version 120

uniform int fogMode;
uniform sampler2D texture;
uniform sampler2D lightmap;

varying vec4 color;
varying vec4 texcoord;
varying vec4 lmcoord;
varying vec2 normal;
varying float attr;
/* DRAWBUFFERS:024 */

void main() {
	gl_FragData[0] = texture2D(texture, texcoord.st) * texture2D(lightmap, lmcoord.st) * color;
	if(fogMode == 9729)
		gl_FragData[0].rgb = mix(gl_Fog.color.rgb, gl_FragData[0].rgb, clamp((gl_Fog.end - gl_FogFragCoord) / (gl_Fog.end - gl_Fog.start), 0.0, 1.0));
	else if(fogMode == 2048)
		gl_FragData[0].rgb = mix(gl_Fog.color.rgb, gl_FragData[0].rgb, clamp(exp(-gl_FogFragCoord * gl_Fog.density), 0.0, 1.0));
	gl_FragData[1] = vec4(normal, 0.0, 1.0);
	gl_FragData[2] = vec4(attr, 0.0, 0.0, 1.0);
}

由於gbuffers_water除了渲染水之外還要渲染其它半透明的物體,比如彩色玻璃和冰,理論上說那些東西也是能有鏡面反射的,但這裡我們只是先製作一個水面反射效果,因此要做一次篩選,流動水和靜態水(這裡我記不清了...貌似有一個still water是不會流動的水)的id是8和9,我們還需要一個用於標記水面的屬性,這裡我啟用了第五個顏色緩衝,也就是colortex4,接下來要在final.fsh中添加它的聲明.

const int R8 = 0;
const int colortex4Format = R8;

由於只用到了紅色分量,故此使用R8格式即可,一個byte可以存儲256個數值,由於每次渲染前Shadersmod會將colortex4清空為全黑(0.0),故此我們使用0標記普通磚塊,使用1/255來標記水.

然後在編寫SSR的代碼之前,先優化一下final.fsh的結構,在舊的代碼中高光和基色的疊加與Tonemap都被寫在了一塊,現在將它們分離開能使代碼更加美觀:

vec3 uncharted2Tonemap(vec3 x) {
	return ((x*(A*x+C*B)+D*E)/(x*(A*x+B)+D*F))-E/F;
}

vec3 bloom(vec3 color, vec2 uv) {
	return color + texture2D(colortex1, uv).rgb;
}

vec3 tonemapping(vec3 color) {
	color = pow(color, vec3(1.4));
	color *= 6.0;
	vec3 curr = uncharted2Tonemap(color);
	vec3 whiteScale = 1.0f/uncharted2Tonemap(vec3(W));
	color = curr*whiteScale;
	return color;
}

void main() {
	vec3 color =  texture2D(gcolor, texcoord.st).rgb;
	color = bloom(color, texcoord.st);
	color = tonemapping(color);
	gl_FragColor = vec4(color, 1.0);
}

在正式動工前,先來驗證一下之前做的水面屬性標記有沒有效果:

uniform mat4 gbufferProjectionInverse;
uniform sampler2D colortex4;
uniform sampler2D depthtex0;

vec3 waterEffect(vec3 color, vec2 uv, vec3 viewPos, float attr) {
	if(attr == 1.0)
	{
		return vec3(0.0);
	}
	return color;
}

//...main函數
vec3 attrs =  texture2D(colortex4, texcoord.st).rgb;
float depth = texture2D(depthtex0, texcoord.st).r;
vec4 viewPosition = gbufferProjectionInverse * vec4(texcoord.s * 2.0 - 1.0, texcoord.t * 2.0 - 1.0, 2.0 * depth - 1.0, 1.0f);
viewPosition /= viewPosition.w;
float attr = attrs.r * 255.0;
//bloom的後面
color = waterEffect(color, texcoord.st, viewPosition.xyz, attr);
//...}

waterEffect函數之後將用於繪製水面效果,現在重載光影包,如果沒問題的話,所有水面將變成純黑色,而其他景物,包括半透明的磚塊,都不會受影響.
(關於為什麼把眼坐標系的坐標計算放在main函數中,是因為在之後章節的效果中需要復用這一變量)

2016-02-09_17.08.32

然後要在waterEffect中計算眼坐標系中的反射光線,現在已經有了眼坐標系位置viewPos,由於眼坐標系中的原點就在鏡頭位置,所以到某點的視線就等於該點坐標,故此只要再從gnormal中獲取眼坐標系中的法線,然後根據反射公式就能計算出反射光線了,GLSL已經將反射公式計算封裝成了reflect函數.

uniform sampler2D gnormal;

vec3 normalDecode(vec2 enc) {
    vec4 nn = vec4(2.0 * enc - 1.0, 1.0, -1.0);
    float l = dot(nn.xyz,-nn.xyw);
    nn.z = l;
    nn.xy *= sqrt(l);
    return nn.xyz * 2.0 + vec3(0.0, 0.0, -1.0);
}

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color) {
	return direction;
}

vec3 waterEffect(vec3 color, vec2 uv, vec3 viewPos, float attr) {
	if(attr == 1.0)
	{
		vec3 normal = normalDecode(texture2D(gnormal, texcoord.st).rg);
		vec3 viewRefRay = reflect(normalize(viewPos), normal);
		color = waterRayTarcing(viewPos, viewRefRay, color);
	}
	return color;
}

這一次我們創建了waterRayTarcing函數,在這裡面執行真正的Ray Tracing,當然現在裡面還沒有內容,它所做的僅僅只是將射線方向作為顏色返回.如果沒問題的話,重載光影包後你將看到的是一個"五顏六色"的海.接下來就該製作一個"真正意義"上的SSR:

uniform float near;
uniform float far;
uniform mat4 gbufferProjection;

vec2 getScreenCoordByViewCoord(vec3 viewCoord) {
	vec4 p = vec4(viewCoord, 1.0);
	p = gbufferProjection * p;
	p /= p.w;
	if(p.z < -1 || p.z > 1)
		return vec2(-1.0);
	p = p * 0.5f + 0.5f;
	return p.st;
}

float linearizeDepth(float depth) {
    return (2.0 * near) / (far + near - depth * (far - near));
}

float getLinearDepthOfViewCoord(vec3 viewCoord) {
	vec4 p = vec4(viewCoord, 1.0);
	p = gbufferProjection * p;
	p /= p.w;
	return linearizeDepth(p.z * 0.5 + 0.5);
}

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color) {
	const float stepBase = 0.05;
	vec3 testPoint = startPoint;
	direction *= stepBase;
	bool hit = false;
	vec4 hitColor = vec4(0.0);
	for(int i = 0; i < 40; i++)
	{
		testPoint += direction;
		vec2 uv = getScreenCoordByViewCoord(testPoint);
		float sampleDepth = texture2DLod(depthtex0, uv, 0.0).x;
		sampleDepth = linearizeDepth(sampleDepth);
		float testDepth = getLinearDepthOfViewCoord(testPoint);
		if(sampleDepth < testDepth)
		{
			hitColor = vec4(texture2DLod(gcolor, uv, 0.0).rgb, 1.0);
			break;
		}
	}
	return hitColor.rgb;
}

這一部分首先添加了3個輔助函數,分別用於"眼坐標轉屏幕坐標"、"屏幕深度轉線性深度"和"獲取眼坐標對應屏幕上一點的線性深度".
然後是waterRayTarcing中新增的代碼,在一個40次的循環中,測試點會從測試出發點沿射線方向行進,每次固定前進0.05個單位,每次測試時,會先計算當前測試點所對應的屏幕坐標uv,然後獲取屏幕坐標對應的深度sampleDepth和測試點在屏幕中的深度testDepth,如果測試點的深度大於sampleDepth,即測試點位於這個像素的"後面"的話,即代表命中,記錄該像素的顏色並中斷循環.為了測試方便,未命中的情況下返回黑色.

這一次再測試,看到的卻是...正常的水面,和少數的黑色和反射?

2016-02-09_23.06.04

按理說,現在是不應該有水面的,出現水面說明有些測試是直接命中了出發點的像素...解決方法就是將出發點沿法線方向偏移一點,將waterEffect中的"color = waterRayTarcing(viewPos, viewRefRay, color);"改成:

color = waterRayTarcing(viewPos + normal * (-viewPos.z / far * 0.2 + 0.05), viewRefRay, color);

這一次測試已經沒有水面了,然而可見的反射卻少得可憐,這是由於步長過短導致,0.05的步長走完40步後只能移動2.0個長度,而在眼坐標系下僅z軸的分布就可以在-[0.05, 256.0].但是簡單地增大步長會導致近處的反射變得非常難看,因此這裡需要一個變長的步長,這裡使用一個湊出來的經驗公式,將stepBase和"testPoint += direction;"改成:

const float stepBase = 0.025;
//...
	testPoint += direction * pow(float(i + 1), 1.46);

2016-02-09_23.42.53

現在水面效果好了很多,然而你會注意到有些反射有些奇怪,特別是當你在第三人稱下看自己的反射時...

2016-02-10_03.26.39
Koishi all the way down!

這是因為對反射的命中判定不能簡單地以sampleDepth < testDepth作為條件,試想一下,假如在一個遠景中,在近處放置一個浮在空中的盒子,正常情況下,反射線應當從盒子後方穿過,然而在現在的測試條件中,卻將盒子判定為命中對象. ssrp2
圖:由於近處物體導致的命中判斷錯誤,正確結果是命中遠景,然而由於在此之前先命中了盒子,故錯誤地使用盒子作為測試結果.紅色為測試點,藍色為採樣點.

修正的方法是在判斷中加一個額外的限定條件,要求測試點與採樣點之差不能大於一個值,將"if(sampleDepth < testDepth)"改成: [code] if(sampleDepth < testDepth && testDepth - sampleDepth < (1.0 / 2048.0) * (1.0 + testDepth * 200.0 + float(i))) [/code] 這個也是一個拼湊出來的公式,不管怎麼說,效果還是可以的. 2016-02-10_03.27.30

Yet another problem,你會注意到有些反射糊成了一團,這是由於步長過大導致,比如說正確的命中像素是A,但是測試點最終停在了A旁邊的像素,那麼就會使用旁邊的像素,而不是A,解決方法是採用二分搜索,每次步進時記錄上一個測試點,一旦命中後,就在上一點和這一點之間進行二分搜索,查找正確的命中像素.

ssrp3
圖:測試點停在了旁邊的像素,而不是正確的命中像素,而二分搜索可以解決這一問題,以一個4次二分搜索為例,第一次搜索從上一點出發,像當前點前進了0.5個長度,此時由於測試深度大於採樣深度,故此調轉方向,在第二次搜索時向上一點的方向前進0.25個長度,這一次測試深度小於採樣深度,故再次調轉方向,第三次測試像當前點前進0.125個長度,測試深度依然小於採樣深度,所以方向不變,第四次測試保持方向不變前進0.0625個長度,這一次的測試結果是什麼已經不重要了,因為最終結果就會使用當前所指的像素.紅色為測試點,藍色為採樣點,紫色為像素.

首先先要添加二分搜索的代碼,這裡我用宏寫了一個無分支的二分搜索:

#define BISEARCH(SEARCHPOINT, DIRVEC, SIGN) DIRVEC *= 0.5; \
					SEARCHPOINT+= DIRVEC * SIGN; \
					uv = getScreenCoordByViewCoord(SEARCHPOINT); \
					sampleDepth = linearizeDepth(texture2DLod(depthtex0, uv, 0.0).x); \
					testDepth = getLinearDepthOfViewCoord(SEARCHPOINT); \
					SIGN = sign(sampleDepth - testDepth);

其中,SEARCHPOINT是當前搜索的位置的vec3變量;DIRVEC是從上一點指向當前點的vec3變量;SIGN是一個用於表示符號的float變量,初值為1.0.每次搜索時,DIRVEC折半,然後SEARCHPOINT根據SIGN,向當前點前進/後退|DIRVEC|個長度,然後根據當前的深度值判斷下一步是前進(那麼SIGN將為1.0)還是後退(那麼SIGN將為-1.0).

然後就該添加waterRayTarcing中的內容了,這一次添加的東西不多,但是很亂,首先在函數的變量初始化那一堆添加:

vec3 lastPoint = testPoint;

然後去魔改找到命中點之後的處理:

if(sampleDepth < testDepth && testDepth - sampleDepth < (1.0 / 2048.0) * (1.0 + testDepth * 200.0 + float(i)))
{
	vec3 finalPoint = lastPoint; //finalPoint為二分搜索後的最終位置
	float _sign = 1.0;
	direction = testPoint - lastPoint;
	BISEARCH(finalPoint, direction, _sign);
	BISEARCH(finalPoint, direction, _sign);
	BISEARCH(finalPoint, direction, _sign);
	BISEARCH(finalPoint, direction, _sign);
	uv = getScreenCoordByViewCoord(finalPoint);
	hitColor = vec4(texture2DLod(gcolor, uv, 0.0).rgb, 1.0);
	hit = true;
	break;
}
lastPoint = testPoint;

現在再測試一下,效果已經好多了,對於二分搜索的次數,我認為4次的效果就可以了,此外關於有分支和無分支的二分搜索誰快誰慢我還真沒試過...有閑心的可以測試一下,說不定我這裡的無分支版本由於爆了L1指令緩存反而更慢呢 (笑)

繼續解決問題,你肯定注意到在某些位置會有"花屏"的現象,將如此嚴重的問題放在這麼靠後的位置來討論,你是不是認為那一定是一個難題?其實也不是,它只是測試點超出了屏幕範圍,畢竟SSR只能反射屏幕之內的景物嘛,解決方法很簡單,在"vec2 uv = getScreenCoordByViewCoord(testPoint);"後面加一段:

if(uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)
{
	hit = true;
	break;
}

(為什麼超出範圍未命中還要hit = true?這是為之後準備的,待會你就明白了.)

效果很明顯,這裡就不上圖了.

然而現在還有一個嚴重的問題,就是當你靠近和遠離某處,或者在創造模式下上升或下降時,會看到反射的像在瘋狂地扭曲,如果你眼睛夠尖的話,可以發現這些扭曲是分層的,這是由於在循環中,每個循環的步長差異過大所導致,更準確地說,是因為它們是離散的,比如區域A能在第一次循環中命中反射對象,旁邊的區域B能在第二次循環中命中反射對象,如果第一次循環與第二次循環的步長不是連續的,勢必會導致兩個區域命中的位置不連續,理論上講這會導致斷層,但由於各種原因,這裡導致的是扭曲.這一問題的解決方法相當獵奇,是在計算步長時引入抖動(Jitter),強行讓原本不連續的離散步長表現的近似連續.之前我以為抖動是引入噪聲,後來看了別人的代碼才知道是引入一個由屏幕位置決定的,範圍在[0,1)的連續值,將這個值摻入步長計算,就可以顯著清除扭曲效果.

2016-02-10_05.32.02

首先要加入抖動的計算:

uniform float viewWidth;
uniform float viewHeight;

vec3 waterEffect(vec3 color, vec2 uv, vec3 viewPos, float attr) {
	if(attr == 1.0)
	{
		vec3 normal = normalDecode(texture2D(gnormal, texcoord.st).rg);
		vec3 viewRefRay = reflect(normalize(viewPos), normal);
		vec2 uv2 = texcoord.st * vec2(viewWidth, viewHeight);
		float c = (uv2.x + uv2.y) * 0.25;
		float jitter = mod(c, 1.0);
		color = waterRayTarcing(viewPos + normal * (-viewPos.z / far * 0.2 + 0.05), viewRefRay, color, jitter);
	}
	return color;
}

這個抖動計算公式我是取自kode80的SSR着色器中的,接下來要改waterRayTarcing,為它加上抖動值參數,並應用這個參數到步長計算中:

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color, float jitter) {
//...
		testPoint += direction * pow(float(i + 1 + jitter), 1.46);

現在再測試一下,扭曲確實沒了,但卻變成了鋸齒...這是由抖動的計算公式決定的,不過並不是很礙眼,如果你實在看不順眼的話,可以考慮換個公式.

2016-02-10_05.32.19

(注:其實對一個完善的光影包來說,扭曲效果的消除反而是可選的,因為好的光影包通常都會有水面的波動效果,玩家很難分辨出反射的扭曲是由於水的波動造成,還是因光影包固有的缺陷而導致.相反,鋸齒效果是無論如何也掩蓋不住的,因此如果你打算製作一個功能完整的光影包的話,對扭曲效果的消除要斟酌考慮)

目前,我們對超過範圍的反射是一刀切似地直接拋棄,這在遊戲中當然顯得很不自然,最好的辦法是對它們自然過渡,比如在"hitColor = vec4(bloom(texture2DLod(gcolor, uv, 0.0).rgb, uv) * 0.5, 1.0);"的下面加入:

hitColor.a = clamp(1.0 - pow(distance(uv, vec2(0.5))*2.0, 2.0), 0.0, 1.0);

為了使它表現出效果,將後面的"return hitColor.rgb;"改成:

return hitColor.rgb * hitColor.a;

現在再測試一下,已經沒有前列腺剎車式的瞬間消隱了.

2016-02-10_05.56.04

現在的反射看起來已經挺不錯了,然而現在的水面卻無法反射過遠的地方的鏡像,甚至當鏡頭貼近水面時可反射的範圍都會大大減少,如果要解釋原因的話,是因為我們這裡採用的是3D反射.
SSR其實根據方式可以分為兩種,3D和2D,3D反射就是在某一坐標系內,從起點向射線方向逐步前進,然後計算測試點在屏幕上的位置,這種做法會帶來一個顯而易見的問題,就是如果入射角和法線夾角接近90度,比如,緊貼水面並平視時,反射線將幾乎與視線成一條直線,反應到屏幕上便是即使40遍循環過後,測試點也依然沒在屏幕上走幾個像素.
而這個問題對2D反射來說就不存在,2D反射的原理是先根據起點、射線方向和最大反射距離,計算出可能的反射終點,然後將起點和終點變換到屏幕坐標系中,此時變換後的起點到終點的方向可以被視為一個2D矢量(在屏幕坐標系中z分量的意義不大),之後利用這個2D的方向,結合一些方法,比如經過透視修正的線性插值,或者光柵化算法之類的,從起點向終點的方向逐像素地測試,這種方法的好處是特別擅長處理3D反射所面臨的反射線與視線夾角過小的問題,缺點也顯而易見,在近距離反射時鏡像點到反射點的距離可以跨幅半個屏幕,逐像素地一點點爬過去,聽着就累得慌(開玩笑,其實還是可以採用跨步的方法,即每隔幾個像素採樣一次,然後用二分搜索來確定實際命中像素).不管怎麼說,2DSSR反射作為近兩年剛剛出現的技術,還是比較厲害的,一些遊戲(比如殺戮地帶4,防反信條4)已經採用了2DSSR,可以說這將是今後SSR的趨勢,我也確實做了一個2DSSR的版本,看着效果還行,但存在幾個嚴重的問題,故最終採用的還是3DSSR.

2016-02-09_02.42.05
圖:2DSSR,有些嚴重問題在圖中未表現出來.

如果你對2DSSR感興趣的話,可以看這幾篇文章:
http://jcgt.org/published/0003/04/04/paper.pdf 2DSSR的原始論文
http://casual-effects.blogspot.hk/2014/08/screen-space-ray-tracing.html 2DSSR的作者在blog上以比較通俗化的語言對2DSSR的講解
http://www.kode80.com/blog/2015/03/11/screen-space-reflections-in-unity-5/ kode80對2DSSR實際實現的講解
https://github.com/kode80/kode80SSR/blob/master/Assets/Resources/Shaders/SSR.shader kode80用CG語言寫的2DSSR

那麼,我們現在的3DSSR就只能坐以待斃了嗎,當然不是了,我們還有一個2DSSR做不到的殺必死("蓮子可是做不到這樣的哦" 提問,這是哪部紅字本里的台詞?),就是當40遍循環結束後,如果仍未命中,但測試點尚在屏幕範圍內的話,就不顧一切直接返回當前位置所對應的顏色,這個聽起來比較蠢,但事實上效果相當好,因為此時即使測試點尚未命中反射對象,與反射對象的距離也差不多了.

實現這個很簡單,在循環體的後面加上:

if(!hit)
	{
	vec2 uv = getScreenCoordByViewCoord(lastPoint);
	float testDepth = getLinearDepthOfViewCoord(lastPoint);
	float sampleDepth = texture2DLod(depthtex0, uv, 0.0).x;
	sampleDepth = linearizeDepth(sampleDepth);
	if(testDepth - sampleDepth < 0.5)
	{
		hitColor = vec4(texture2DLod(gcolor, uv, 0.0).rgb, 1.0);
		hitColor.a = clamp(1.0 - pow(distance(uv, vec2(0.5))*2.0, 2.0), 0.0, 1.0);
	}
}

這裡還要有一步額外的深度判斷,因為在第三人稱時有可能這個點正好在玩家身上,如果遇到這種情況時,就應該不繪製反射,雖然看着有些奇怪,但遠比返回一個錯的離譜的結果好.

2016-02-10_06.00.40

2016-02-10_06.01.47

現在的工作只剩如何將反射顏色hitColor和基色color混合了,這裡我使用的是"color * (1.0 - hitColor.a) + hitColor.rgb * hitColor.a",它可以被簡寫為"mix(color, hitColor.rgb, hitColor.a)".將"return hitColor.rgb * hitColor.a;"改成:

return mix(color, hitColor.rgb, hitColor.a);

2016-02-10_19.02.57

水面反射已經基本完善了,但仍有兩個小的改進,一個是在水下時水面依然有反射效果,然而這種反射效果往往得不到正確的結果,因此最好將它們取消掉,我的解決方法是在頂點着色器里禁止將世界坐標系法線方向指向下方的頂點標記為水面,把gbuffers_water.vsh中的"if(mc_Entity.x == 8 || mc_Entity.x == 9)"改成:

if(gl_Normal.y > -0.9 && (mc_Entity.x == 8 || mc_Entity.x == 9))

第二個改進則是無論在什麼角度,水面都有爆肝式的鏡面反射問題,在現實中很少有水面會有如鏡子般的反射,通常來說只有當平視遠方的水面時才會以反射為主,當俯視近處的水面時,是以來自水底的折射為主,菲涅耳方程描述了某點反射光和折射光比例的關係,想解菲涅耳方程確實有點難,就像當初要我們解分數布朗運動一樣,幸運的是,這裡同樣也有一個菲涅耳方程的近似解,就是"Schlick's approximation",它的公式是:

ebe3046c32ee37cb0bed621127114784

其中,θ是入射光線和法線的夾角,n1是反射光所在介質的折射率,n2是折射光所在介質的折射率,最終結果R(θ)是透射係數,代表反射光所佔的比例,當視線與法線接近90°時,R(θ)≈1,也就是反射光幾乎佔全部,這時反射面就會像鏡子一樣,而當視線與法線平行時,R(θ)=R0,此時的反射光比例就要由兩種介質的折射率來決定了.對於空氣-水的介質組合來說,R0=0.02.也就是說這裡的透射係數計算公式是:
R(θ) = 0.02 + 0.98 * (1.0 - cos(θ))^5

實現起來很簡單,在waterEffect中調用waterRayTarcing之前,加上一段透射係數計算,然後改掉waterRayTarcing的調用:

float fresnel = 0.02 + 0.98 * pow(1.0 - dot(viewRefRay, normal), 5.0);
color = waterRayTarcing(viewPos + normal * (-viewPos.z / far * 0.2 + 0.05), viewRefRay, color, jitter, fresnel);

點乘兩個單位長度的向量就可以獲得它們的夾角的餘弦值,為什麼這裡計算的是反射光線和法線的夾角,而不是入射光線和法線的夾角?因為兩者的數值都一樣 ?

然後為waterRayTarcing添加一個透射係數參數和相應的計算:

vec3 waterRayTarcing(vec3 startPoint, vec3 direction, vec3 color, float jitter, float fresnel) {
//...
	return mix(color, hitColor.rgb, hitColor.a * fresnel);

2016-02-11_01.13.58

現在爆肝式鏡面反射已經沒了.覺得反射太少了?那就把計算反射係數時的^5改成^3,就可以取得Chocapic13的反射效果;去掉反射係數,就可以取得SEUS10.0的反射效果. ?

關於SSR和Ray Tracing的教程就到這裡了,雖然我們自嘲這是這個星球和它的衛星上所能找到的最糟的反射,然而實際上效果並不是太糟,與其他光影包相比,這裡差的是:1.水面顏色 2.水浪效果 3.水的高光反射 而這些都不是SSR的鍋.
說到改進空間的話,我想大概是繼續調整各種參數吧...說實話,我在寫這個SSR時只有20%的時間是在寫代碼,80%的時間是在調整參數和觀察對比效果.

該部分的最終成品可以從這裡下載: [SkyDrive] [百度網盤]

畫龍點睛 - 光照着色、顏色調整與伽馬校正

本來這章作為倒數第二章應該是壓軸的,然而由於超時太久急於截稿,因此暫時這章只能以扯蛋為主了...下一次更新(如果有的話)會加入一些實質性的內容.

儘管我們已經做了這麼多工作,但光影包看上去依然和其他光影包有些不同(事實上,它看起來像GMOD中的MC地圖),一方面是我們本來做的就很爛(霧),另一方面在於我們現在的着色方式.

自始至終我們都在使用MC默認的光照着色,也就是通過一張預生成的光度圖(Lightmap),然後按照查表的方式獲取像素的光照顏色,而市面上大多數光影包多採用自行編寫的光照代碼,要實現自定義的光照,需要在G-Buffer繪製時向一個緩衝區輸出光照坐標(lmcoord)數據,它的數據可以作為光照強度的參考.然後在後處理着色器中進行着色.你也可以直接在G-Buffer中計算光照,只不過運算量比較大,一是你需要在頂點着色器中計算一些控制光照的數據,比如不同時間時的環境光顏色,在後處理渲染器中你只需要計算4次,而在G-Buffer着色器中你需要計算數千次;二是G-Buffer繪製時存在很多無效渲染,Minecraft確實有通過OpenGL遮掩查詢機制實現剔除的,不過它只能以Chunk為單位來剔除,另外它只有在設置中打開"高級OpenGL"時才會啟用這個功能,而那個默認是關閉的(而且它也確實不可靠,我打開它後沒少看見黑色區域和區塊突然閃爍消失)...這次倒是真能發揮延遲渲染在處理光照時的優勢了,當然,為了讓後處理着色器區分哪些需要着色(磚塊),哪些不需要着色(天空),你可以添加一個特殊的標記量,比如如果你將光照坐標存在一個RGB8的顏色緩衝中,頭兩位可以用於存放自然光強度和人造光強度,第三位可以存一個非零數,着色器只有在該位不為零時才進行光照着色,由於大部分顏色緩衝區在每幀渲染前會清空成純黑(0.0),你就不用在不需要光照的G-Buffer着色器中添加輸出了,但是有兩個例外是gbuffers_weather和gbuffers_hand,它們無論你想不想進行手動光照,都需要添加一個輸出,這是因為手和雨的渲染是在最後渲染的,如果它們遮住了有光照的像素,那就必須輸出一個值去覆蓋掉標記量.此外,如果你想使用霧效果的話,你就需要將霧計算放在光照中進行.

除了自定義光照着色以外,還有一些手段來改變光影包給人帶來的感覺,比如顏色調整.

顏色調整(Color Grading)並不是什麼新技術,就像Tone Mapping很早以前便被攝影界使用一樣,顏色調整很早以前便被電影業使用,你可能好奇為什麼好萊塢的電影拍出來即使再爛,場面也都一個個如同大片一樣,而你的秒拍視頻卻總是顯得土裡土氣,其中一個因素是氣氛,平面藝術家都知道作品中不同的顏色搭配可以給觀看者帶來不同的心理感受,在攝影中雖然很難控制場景中的顏色,但通過後期處理依然可以實現對色調的控制.

colorgrading
圖:顏色調整例子,圖片摘自www.philwesson.com

而在遊戲業,顏色調整卻是近些年才出現並普及,粗略的估計至少是在可編程着色器之後才有,因為顯然沒有哪個圖形API提供了顏色調整的功能,最常見的顏色調整方法是LUT(Look-up table,也就是速查表),原理上是製作一個256x256x256的三維紋理,每一個維度代表RGB中的一個分量的刻度,之後美工在PS中打開一張有代表性的遊戲截圖,再打開那個三維紋理,同時調整兩者的顏色,當調整到一個理想的顏色配置後,將那個三維紋理導出,在遊戲中引擎加載這個紋理作為速查表,在後處理的最後階段,將當前像素顏色當做紋理坐標,在速查表中查找調整後的顏色並輸出.實際中也差不多,只不過考慮到256x256x256的RGB紋理那驚人的尺寸,通常來說會使用16x16x16的紋理來代替,由於紋理取樣時的線性插值,效果依然近似不變.

然而這個好方法在Shadersmod中卻用不到,原因很簡單,我們沒法加載LUT紋理!這意味着如果我們想實現顏色調整的話,就得手工在着色器中把PS里的那些顏色調整過程自己實現一遍,光麻煩不說,PS中有些顏色調整算法你知道怎麼實現嗎 ?

不管怎麼說,這裡還是列出了一些顏色調整算法的實現,需要注意的是,它們取得的效果與PS中的同類功能不一定相同,比如亮度調整在0.0時就是全黑了,而PS中則永遠不會被調到全黑.首先需要說明的是顏色模型,在OpenGL中我們使用的是RGB顏色模型,然而有一些顏色調整算法是針對HSL顏色模型而設計的,這就需要在兩種顏色模型間相互轉換.

RGB轉HSL:

vec3 rgbToHsl(vec3 rgbColor) {
	rgbColor = clamp(rgbColor, vec3(0.0), vec3(1.0));
	float h, s, l;
	float r = rgbColor.r, g = rgbColor.g, b = rgbColor.b;
	float minval = min(r, min(g, b));
	float maxval = max(r, max(g, b));
	float delta = maxval - minval;
	l = ( maxval + minval ) / 2.0;	
	if (delta == 0.0) 
	{
		h = 0.0;
		s = 0.0;
	}
	else
	{
		if ( l < 0.5 )
			s = delta / ( maxval + minval );
		else 
			s = delta / ( 2.0 - maxval - minval );
			
		float deltaR = (((maxval - r) / 6.0) + (delta / 2.0)) / delta;
		float deltaG = (((maxval - g) / 6.0) + (delta / 2.0)) / delta;
		float deltaB = (((maxval - b) / 6.0) + (delta / 2.0)) / delta;
		
		if(r == maxval)
			h = deltaB - deltaG;
		else if(g == maxval)
			h = ( 1.0 / 3.0 ) + deltaR - deltaB;
		else if(b == maxval)
			h = ( 2.0 / 3.0 ) + deltaG - deltaR;
			
		if ( h < 0.0 )
			h += 1.0;
		if ( h > 1.0 )
			h -= 1.0;
	}
	return vec3(h, s, l);
}

HSL轉RGB:

float hueToRgb(float v1, float v2, float vH) {
	if (vH < 0.0)
		vH += 1.0;
	if (vH > 1.0)
		vH -= 1.0;
	if ((6.0 * vH) < 1.0)
		return (v1 + (v2 - v1) * 6.0 * vH);
	if ((2.0 * vH) < 1.0)
		return v2;
	if ((3.0 * vH) < 2.0)
		return (v1 + ( v2 - v1 ) * ( ( 2.0 / 3.0 ) - vH ) * 6.0);
	return v1;
}

vec3 hslToRgb(vec3 hslColor) {
	hslColor = clamp(hslColor, vec3(0.0), vec3(1.0));
	float r, g, b;
	float h = hslColor.r, s = hslColor.g, l = hslColor.b;
	if (s == 0.0)
	{
		r = l;
		g = l;
		b = l;
	}
	else
	{
		float v1, v2;
		if (l < 0.5)
			v2 = l * (1.0 + s);
		else
			v2 = (l + s) - (s * l);
	
		v1 = 2.0 * l - v2;
	
		r = hueToRgb(v1, v2, h + (1.0 / 3.0));
		g = hueToRgb(v1, v2, h);
		b = hueToRgb(v1, v2, h - (1.0 / 3.0));
	}
	return vec3(r, g, b);
}

以下是一些常見的顏色調整:

亮度:
針對模型:RGB
輸入參數:b - 亮度,0.0為全黑;0.0~1.0為變暗,1.0以為上變亮.

vec3 brightness(vec3 rgbColor, float b) {
	return mix(vec3(0.0), rgbColor, b);
}

對比度:
針對模型:RGB
輸入參數:c - 對比度,0.0為全灰,0.0~1.0為降低對比度,1.0以為上提高對比度

vec3 contrast(vec3 rgbColor, float c) {
	return mix(vec3(0.5), rgbColor, c);
}

色階:
色階其實就是對RGB中某一個顏色分量進行截斷並重新映射到[0.0, 1.0]的範圍.

曲線:
和色階相似,通過一個函數對RGB中某一個顏色分量進行重新映射,考驗你擬合函數能力的時刻到了!

飽和度:
針對模型:RGB
輸入參數:s - 飽和度,0.0為灰度圖像,0.0~1.0為降低飽和度,1.0以上為提高飽和度

vec3 saturation(vec3 rgbColor, float s) {
	float lum = dot(rgbColor, vec3(0.2125, 0.7154, 0.0721));
	return mix(vec3(lum), rgbColor, s);
}

增加自然飽和度:
自然飽和度與飽和度的區別在於,後者會無差別地增加任何顏色的飽和度,而前者對柔和的顏色增加更多的飽和度,對已經很鮮艷的顏色少增加飽和度.下面這個是利用冪函數圖像的特點來在HSL模型中實現增加自然飽和度,是的,只能增加...不能減少 ?
針對模型:HSL
輸入參數:v - 自然飽和度,0.0完全飽和,0.0~1.0為增加自然飽和度,1.0以上為不自然地降低飽和度

vec3 vibrance(vec3 hslColor, float v) {
	hslColor.g = pow(hslColor.g, v);
	return hslColor;
}

色相/飽和度:
其實這就是HSL模型中的三個顏色分量...

色彩平衡:
色彩平衡是個很奇怪的東西,老實說,我連它的原理和作用都不知道...不過似乎有不少人喜歡用它.然而它的算法也略複雜,這裡使用的是GIMP的色彩平衡算法,其中,smh三個參數對應色彩平衡中的三個色彩區域,每個分量代表色彩平衡中的三個值,0.0代表不調整,1.0理論上相當於PS中某個條向右拉到頭(+100),-1.0理論上相當於PS中某個條向左拉到頭(-100),之所以說是理論,是因為它的實際效果比PS的強度要大一些,想取得和PS的色彩平衡相同的效果,考慮將值縮小為三分之二左右.
針對模型:RGB和HSL
輸入參數:s - 陰影 m - 中間調 h - 高光 p - 保持亮度,布爾值

vec3 colorBalance(vec3 rgbColor, vec3 hslColor, vec3 s, vec3 m, vec3 h, bool p) {
	s *= clamp((hslColor.bbb - 0.333) / -0.25 + 0.5, 0.0, 1.0) * 0.7;
	m *= clamp((hslColor.bbb - 0.333) /  0.25 + 0.5, 0.0, 1.0) *
		 clamp((hslColor.bbb + 0.333 - 1.0) / -0.25 + 0.5, 0.0, 1.0) * 0.7;
	h *= clamp((hslColor.bbb + 0.333 - 1.0) /  0.25 + 0.5, 0.0, 1.0) * 0.7;
	vec3 newColor = rgbColor;
	newColor += s;
	newColor += m;
	newColor += h;
	newColor = clamp(newColor, vec3(0.0), vec3(1.0));
	if(p)
	{
		vec3 newHslColor = rgbToHsl(newColor);
		newHslColor.b = hslColor.b;
		newColor = hslToRgb(newHslColor);
	}
	return newColor;
}

不通過LUT進行顏色調整相當昂貴而且效果不佳,因此其他光影包中的顏色調整多局限於飽和度和對比度之類的,主力還是光照着色.

這裡給出一個簡單的晝間屎綠色色調光照調整,作用在final.fsh的tonemap之後:

vec3 hslColor = rgbToHsl(color);
hslColor = vibrance(hslColor, 0.75);
color = hslToRgb(hslColor);
color = colorBalance(color, hslColor, vec3(0.0), vec3(0.0, 0.12, 0.2), vec3(-0.12, 0.0, 0.2), true);

2016-02-12_04.22.30
(我終於知道為什麼ici2cc一定要給CustomSteve加上跳舞功能了...)

此外,還有一個很重要的東西(有多重要呢?理論上應該在製作陰影前就說的...(逃?)),就是伽馬校正.

伽馬校正(Gamma Correction)解釋起來有點麻煩,簡單地說,它是用來解決亮度非線性變化的手段,由於顯示器的特性,你輸出的顏色的亮度在顯示器上的變化不是線性的,舉例來說,按照常識,RGB顏色(0.5, 0.5, 0.5)的亮度應該是(1.0, 1.0, 1.0)的一半,然而實際在顯示器中前者看上去明顯要比後者暗很多,遠不到一半亮度,事實上,(0.73, 0.73, 0.73)在顯示器上才像是(1.0, 1.0, 1.0)的一半亮度.

2016-02-12_05.37.57
左上為(0.5),左下為(0.73),右邊為(1.0)

通常來講,顯示器實際輸出的亮度,是輸入顏色(規整化到[0.0, 1.0]之間)的2.2次冪,這個值在不同的顯示器間可能有差異,但基本不會偏離這個值太多,這就導致了一個線性變化的顏色實際顯示在屏幕中會是非線性地變化,這種特性會導致很多涉及到漸變的顏色變得很奇怪,也會導致開發者在給經驗公式調整參數時難以摸清規律.因此就有了伽馬校正,它的工作方式是在最終向屏幕輸出顏色前(也就是fina.fsh在輸出gl_FragColor前)將這個顏色調整為它的(1.0/2.2)次冪,這是因為顯示器的輸出顏色可以理解為是X^2.2,為了讓顯示器輸出原本想要的顏色,可以直接向它輸入X^(1.0/2.2),這樣最終顯示出來的結果就是X^(2.2/2.2)=X.

如果你現在就急匆匆地想去把gl_FragColor的輸出改成"gl_FragColor = vec4(pow(color, vec3(1.0/2.2)), 1.0)"的話,現實可能會潑你一盆冷水,你會發現屏幕除了變得十分亮以外什麼效果也沒有,這是因為之前所有的參數都是在不考慮伽馬校正的情況下敲定的,就連MC的光照系統也沒有考慮伽馬修正,整個光影包要經過大修才能使用.你可以嘗試先在compsite.fsh中開頭加個"color.rgb = pow(color.rgb, vec3(2.2));",來簡單預覽一下效果,你會注意到最明顯的變化是陰影變淡了,因為之前在未經伽馬修正時,亮度x0.5得到的結果遠比一半亮度要暗,當初設計時我們想着陰影區的效果是亮度減半,實際上幾乎是減到了20%.至於顏色變鮮艷了...我覺得這是臨時措施的副作用,伽馬校正可沒這功能.

如果你對伽馬校正有興趣的話,可以看一看這篇文章:http://learnopengl-cn.readthedocs.org/zh/latest/05%20Advanced%20Lighting/02%20Gamma%20Correction/

本章沒有可供下載內容,因為沒有任何乾貨 ?

錦上添花 - 常見特效

暈影

Vignette,也就是暈影,或者叫暗角,效果就是屏幕邊緣變暗,恐怕是最好實現的後處理特效了.暈影最初是出現在攝影上,有時照片拍出來會因為各種原因在邊緣留下一個暗圈(還記得我之前在故宮拍的那張照片不?那個是因為劣質ipad皮套鬆了導致皮套遮住了鏡頭...),聽起來很糟糕但看起來還不錯,我猜是因為暈影酷似人眼的邊緣視覺吧.印象中第一個見過的帶暈影的遊戲是戰地3,而到了現在隨便什麼遊戲都整個暈影,不信你看連AVG遊戲都有:

thisgamenamedtuozuo
圖:AVG遊戲都有暈影了,你的光影包還能沒有嗎?說起來"妹子"你的駱駝趾可夠大的啊...等等為什麼你挺着桿大♂炮!?

暈影實現起來非常簡單,對於不使用着色器的固定管線來說,可以直接事先製作好一張暈影貼圖,然後在渲染的最後階段繪製到屏幕上.使用着色器來實現也很簡單,一次全屏後處理,根據像素到屏幕中央的距離減弱亮度,具體的算法決定了最終的暈影效果,比如在final.fsh中加入:

vec3 vignette(vec3 color) {
	float dist = distance(texcoord.st, vec2(0.5f));
	dist = clamp(dist * 1.7 - 0.65, 0.0, 1.0); //各種湊魔數
	dist = smoothstep(0.0, 1.0, dist);
	return color.rgb * (1.0 - dist);
}

//...main函數 {
color = vignette(color);
//...}

2016-02-13_21.54.37

炫光

炫光(Lens flare,也就是鏡頭光暈)是指照片中與陽光成一條直線排布的那些光環,它的物理原理是光在多個鏡片間反射而成,因此實際上誰也沒有親眼看見過這種效果,然而藝術家和導演們還是很熱衷於在作品中加入這種特效,玩家老爺們也挺喜歡,因此就加上吧.

製作炫光基本只需要解決3個問題. 1.如何確定光源位置和可見性 2.如何確定光環的位置 3.如何在屏幕上繪製光環

Shadersmod提供了sunPosition這個一致變量作為太陽在眼坐標系中的位置,正好可以用於計算光源在屏幕上的位置,判斷光源可見性可以根據像素深度值來判斷.
一旦知道光源在屏幕上的位置,光環的位置也很容易計算了,炫光中的光環總是分布在光源與視點中心相連的直線上,並且光環到視點中心的距離,與光源到視點中心的距離成正比.
至於如何在屏幕上繪製光環,最簡單的辦法是計算像素到圓心的距離,小於一定閾值就繪製上圓的顏色,如果要實現漸變效果,可以對距離施以冪函數或平滑過渡,也可以像分數噪音那樣,將多個函數的結果疊加來形成更複雜的效果.

首先要在final.vsh中計算光源和光環在屏幕上的位置:

uniform float viewWidth;
uniform float viewHeight;
uniform vec3 sunPosition;
uniform mat4 gbufferProjection;
uniform sampler2D depthtex0;

varying float sunVisibility;
varying vec2 lf1Pos;
varying vec2 lf2Pos;
varying vec2 lf3Pos;
varying vec2 lf4Pos;

#define LF1POS -0.3
#define LF2POS 0.2
#define LF3POS 0.7
#define LF4POS 0.75

//...main函數 {
vec4 ndcSunPosition = gbufferProjection * vec4(normalize(sunPosition), 1.0);
ndcSunPosition /= ndcSunPosition.w;
vec2 pixelSize = vec2(1.0 / viewWidth, 1.0 / viewHeight);	
sunVisibility = 0.0f;
vec2 screenSunPosition = vec2(-10.0);
lf1Pos = lf2Pos = lf3Pos = lf4Pos = vec2(-10.0);
if(ndcSunPosition.x >= -1.0 && ndcSunPosition.x <= 1.0 &&
	ndcSunPosition.y >= -1.0 && ndcSunPosition.y <= 1.0 &&
	ndcSunPosition.z >= -1.0 && ndcSunPosition.z <= 1.0)
{
	screenSunPosition = ndcSunPosition.xy * 0.5 + 0.5;
	for(int x = -4; x <= 4; x++)
	{
		for(int y = -4; y <= 4; y++)
		{
			float depth = texture2DLod(depthtex0, screenSunPosition.st + vec2(float(x), float(y)) * pixelSize, 0.0).r;
			if(depth > 0.9999)
				sunVisibility += 1.0 / 81.0;
		}
	}
	float shortestDis = min( min(screenSunPosition.s, 1.0 - screenSunPosition.s),
							 min(screenSunPosition.t, 1.0 - screenSunPosition.t));
	sunVisibility *= smoothstep(0.0, 0.2, clamp(shortestDis, 0.0, 0.2));
	
	vec2 dir = vec2(0.5) - screenSunPosition;
	lf1Pos = vec2(0.5) + dir * LF1POS;
	lf2Pos = vec2(0.5) + dir * LF2POS;
	lf3Pos = vec2(0.5) + dir * LF3POS;
	lf4Pos = vec2(0.5) + dir * LF4POS;
}
//...}

首先,這段代碼包含了5個傳遞變量,sunVisibility代表太陽的可見性,它的計算方法是根據太陽中心及周圍9x9範圍內共計81個像素的深度來判斷能否看見陽光,此外它還會根據太陽在屏幕上與屏幕邊界的距離進行淡出;lfXPos代表4個光環在屏幕上的位置,如果不在屏幕上的話會被設為(-10.0, -10.0).
在代碼中,我們先把太陽在眼坐標系中的位置進行了規整化,然後做了次投影變換和透視除法,然後在NDC中進行了一次裁剪,為什麼之前的所有投影變換後都沒有裁剪?這是因為之前我們所有進行投影變換的頂點都是已經位於屏幕中的,毫無疑問它們肯定之前在G-Buffer繪製時已經通過裁剪了,因此沒有必要再進行一次,而這次太陽的眼坐標系位置很可能是在屏幕之外,故此要進行一次裁剪.後面的計算就沒有什麼需要特別說明的了.

然後是final.fsh:

uniform float aspectRatio;

varying float sunVisibility;
varying vec2 lf1Pos;
varying vec2 lf2Pos;
varying vec2 lf3Pos;
varying vec2 lf4Pos;

#define MANHATTAN_DISTANCE(DELTA) abs(DELTA.x)+abs(DELTA.y)

#define LENS_FLARE(COLOR, UV, LFPOS, LFSIZE, LFCOLOR) { \
				vec2 delta = UV - LFPOS; delta.x *= aspectRatio; \
				if(MANHATTAN_DISTANCE(delta) < LFSIZE * 2.0) { \
					float d = max(LFSIZE - sqrt(dot(delta, delta)), 0.0); \
					COLOR += LFCOLOR.rgb * LFCOLOR.a * smoothstep(0.0, LFSIZE, d) * sunVisibility;\
				} }

#define LF1SIZE 0.1
#define LF2SIZE 0.15
#define LF3SIZE 0.25
#define LF4SIZE 0.25

const vec4 LF1COLOR = vec4(1.0, 1.0, 1.0, 0.1);
const vec4 LF2COLOR = vec4(0.42, 0.0, 1.0, 0.1);
const vec4 LF3COLOR = vec4(0.0, 1.0, 0.0, 0.1);
const vec4 LF4COLOR = vec4(1.0, 0.0, 0.0, 0.1);

vec3 lensFlare(vec3 color, vec2 uv) {
	if(sunVisibility <= 0.0)
		return color;
	LENS_FLARE(color, uv, lf1Pos, LF1SIZE, LF1COLOR);
	LENS_FLARE(color, uv, lf2Pos, LF2SIZE, LF2COLOR);
	LENS_FLARE(color, uv, lf3Pos, LF3SIZE, LF3COLOR);
	LENS_FLARE(color, uv, lf4Pos, LF4SIZE, LF4COLOR);
	return color;
}

//...main函數 {
color = lensFlare(color, texcoord.st);
//...}

這裡是逐一計算各個光環對當前像素點的顏色影響,為了增加效率,每次預先根據曼哈頓距離進行一次剔除(其實這個真的能增加效率嗎...唯一慢的操作只有那個開平方,不過如果你的光環繪製涉及到更複雜的計算,那或許這個判斷會有意義),有個值得注意的是"delta.x *= aspectRatio",它是對X分量做一次屏幕橫縱比的修正,如果沒有這一步的話,你的炫光繪製出來會是扁的橢圓,這是因為大多數屏幕是寬大於高,所以同樣的數值在Y軸方向上的變化實際是要大於X軸分量上的變化,因此要做一次橫縱比的修正.這裡的炫光繪製是最簡單的一坨圓形的光團,如果想要更複雜的效果,比如真正的空心光環,或者是月牙形的弧光,就需要改良計算公式.最終繪製出來的效果是三團光,其中第三個是由距離極近的紅光和綠光疊加成的.

2016-02-13_21.53.25

景深

DOF(Depth of Field)用於模擬人眼或相機的對焦效果,實現起來倒並不難,對原圖像做一次模糊,然後對每一個像素,根據它的深度與屏幕中心像素的深度的差值,在原像素和模糊後的像素間進行插值.

然而在此之前,我們先需要進行一次大改,你需要把final.fsh中的bloom、waterEffect和tonemapping這三部分轉移到compsite2.fsh中進行,這樣在final.fsh中我們才會有一個可供進行模糊操作的"圖像",這是一個比較繁瑣的操作,這裡有一些提示:

需要將uncharted2Tonemap(和它的那些常量)、bloom、getScreenCoordByViewCoord、linearizeDepth、getLinearDepthOfViewCoord、normalDecode、waterRayTarcing(與那個BISEARCH宏)、waterEffect和tonemapping移入compsite2.fsh,其中除了linearizeDepth以外,其他的在final.fsh中都可以刪掉了.
需要移入uniform float near; uniform float far; uniform mat4 gbufferModelView; uniform mat4 gbufferProjection; uniform mat4 gbufferProjectionInverse; uniform sampler2D gcolor; uniform sampler2D gnormal; uniform sampler2D colortex4; uniform sampler2D depthtex0;
需要修改bloom,將它的返回值改成"return color + blur(colortex3, uv, vec2(0.0, 1.0) / vec2(viewWidth, viewHeight));"
需要修改compsite2.fsh的main函數:

void main() {
	vec3 color =  texture2D(gcolor, texcoord.st).rgb;
	vec3 attrs =  texture2D(colortex4, texcoord.st).rgb;
	float depth = texture2D(depthtex0, texcoord.st).r;
	vec4 viewPosition = gbufferProjectionInverse * vec4(texcoord.s * 2.0 - 1.0, texcoord.t * 2.0 - 1.0, 2.0 * depth - 1.0, 1.0f);
	viewPosition /= viewPosition.w;
	float attr = attrs.r * 255.0;
	color = bloom(color, texcoord.st);
	color = waterEffect(color, texcoord.st, viewPosition.xyz, attr);
	color = tonemapping(color);
/* DRAWBUFFERS:1 */
	gl_FragData[0] = vec4(color, 1.0);
}

在final.fsh中去掉:

color = bloom(color, texcoord.st);
color = waterEffect(color, texcoord.st, viewPosition.xyz, attr);
color = tonemapping(color);

在final.fsh中將所有針對"gcolor"的採樣換成針對"colortex1"的,此外它的"uniform sampler2D gcolor和gnormal"可以去掉了.

這樣就完成轉移了,從colortex1中我們可以獲取一個已經完成了高光、水面效果和ToneMapping的像素,然後可以添加DOF特效了:

const float centerDepthHalflife = 0.5;
uniform float centerDepthSmooth;

#define DOF_FADE_RANGE 0.15
#define DOF_CLEAR_RADIUS 0.2
#define DOF_NEARVIEWBLUR

vec3 dof(vec3 color, vec2 uv, float depth) {
	float linearFragDepth = linearizeDepth(depth);
	float linearCenterDepth = linearizeDepth(centerDepthSmooth);
	float delta = linearFragDepth - linearCenterDepth;
	#ifdef DOF_NEARVIEWBLUR
	float fade = smoothstep(0.0, DOF_FADE_RANGE, clamp(abs(delta) - DOF_CLEAR_RADIUS, 0.0, DOF_FADE_RANGE));
	#else
	float fade = smoothstep(0.0, DOF_FADE_RANGE, clamp(delta - DOF_CLEAR_RADIUS, 0.0, DOF_FADE_RANGE));
	#endif
	if(fade < 0.001)
		return color;
	vec2 offset = vec2(1.33333 * aspectRatio / viewWidth, 1.33333 / viewHeight);
	vec3 blurColor = vec3(0.0);
	//0.12456 0.10381 0.12456
	//0.10380 0.08651 0.10380
	//0.12456 0.10381 0.12456
	blurColor += texture2D(colortex1, uv + offset * vec2(-1.0, -1.0)).rgb * 0.12456;
	blurColor += texture2D(colortex1, uv + offset * vec2(0.0, -1.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv + offset * vec2(1.0, -1.0)).rgb * 0.12456;
	blurColor += texture2D(colortex1, uv + offset * vec2(-1.0, 0.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv).rgb * 0.08651;
	blurColor += texture2D(colortex1, uv + offset * vec2(1.0, 0.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv + offset * vec2(-1.0, 1.0)).rgb * 0.12456;
	blurColor += texture2D(colortex1, uv + offset * vec2(0.0, 1.0)).rgb * 0.10381;
	blurColor += texture2D(colortex1, uv + offset * vec2(1.0, 1.0)).rgb * 0.12456;
	return mix(color, blurColor, fade);
}

//...main函數 {
color = dof(color, texcoord.st, depth);
//...}

這段代碼原地進行了一次單遍5x5的高斯模糊,由於採用了線性過濾,只需要9次採樣即可,同時根據像素的線性深度和centerDepthSmooth的線性深度的差值,在原顏色和模糊後的顏色進行漸變,centerDepthSmooth代表屏幕中心像素的深度,centerDepthHalflife用於控制它的變化速率.這裡還有3個控制量,DOF_FADE_RANGE用於控制從模糊開始到完全模糊之間的深度距離,DOF_CLEAR_RADIUS是控制在中心深度的前後多少範圍內的像素保持清晰,DOF_NEARVIEWBLUR用於定義是否啟用近景模糊,雖然景深在字面意義上只管遠景模糊,但由於對焦不只包括遠處模糊,還包括近景模糊,因此DOF還應負責近處景物的模糊,然而這個效果並非很好,畢竟我可不想因為望了一眼天,近處的場景就變得賢者狀態了...因此近景模糊成了一個可選選項,去掉或注釋掉DOF_NEARVIEWBLUR將關閉近景模糊,保留它則啟用.

2016-02-14_18.12.37
無近景模糊

2016-02-14_18.12.04
有近景模糊

2016-02-14_18.13.46
無近景模糊

2016-02-14_18.14.24
有近景模糊

運動模糊

Motion Blur用於模擬快速運動的物體在人眼前的模糊效果,運動模糊可以分為兩種,一種是別人動你不動,一種是你自己在動,由於條件限制,在Shadersmod中只能實現後者了.
運動模糊的本質是徑向模糊(Radial Blur),與高斯模糊的對四周進行採樣不同,徑向模糊是為每個像素確定一個徑向矢量,在模糊時從源像素出發沿着這個矢量進行步進採樣,步進的次數和步長決定了模糊的質量.
運動模糊面臨的問題就是如何確定這個矢量,如果我們知道這個像素對應的點在上一幀時所對應的屏幕位置,那可以用這兩個位置之差作為矢量.正好Shadersmod提供了previousCameraPosition、gbufferPreviousModelView和gbufferPreviousProjection,分別代表上一幀的cameraPosition、gbufferModelView和gbufferProjection,可以用於計算其在屏幕坐標系中的位置.

不用多說了,上車吧:

uniform vec3 cameraPosition;
uniform vec3 previousCameraPosition;
uniform mat4 gbufferModelViewInverse;
uniform mat4 gbufferPreviousModelView;
uniform mat4 gbufferPreviousProjection;

#define MOTIONBLUR_THRESHOLD 0.01
#define MOTIONBLUR_MAX 0.21
#define MOTIONBLUR_STRENGTH 0.5
#define MOTIONBLUR_SAMPLE 5

vec3 motionBlur(vec3 color, vec2 uv, vec4 viewPosition) {
	vec4 worldPosition = gbufferModelViewInverse * viewPosition + vec4(cameraPosition, 0.0);
	vec4 prevClipPosition = gbufferPreviousProjection * gbufferPreviousModelView * (worldPosition - vec4(previousCameraPosition, 0.0));
	vec4 prevNdcPosition = prevClipPosition / prevClipPosition.w;
	vec2 prevUv = (prevNdcPosition * 0.5 + 0.5).st;
	vec2 delta = uv - prevUv;
	float dist = length(delta);
	if(dist > MOTIONBLUR_THRESHOLD)
	{
		delta = normalize(delta);
		dist = min(dist, MOTIONBLUR_MAX) - MOTIONBLUR_THRESHOLD;
		dist *= MOTIONBLUR_STRENGTH;
		delta *= dist / float(MOTIONBLUR_SAMPLE);
		int sampleNum = 1;
		for(int i = 0; i < MOTIONBLUR_SAMPLE; i++)
		{
			uv += delta;
			if(uv.s <= 0.0 || uv.s >= 1.0 || uv.t <= 0.0 || uv.t >= 1.0)
				break;
			color += texture2D(colortex1, uv).rgb;
			sampleNum++;
		}
		color /= float(sampleNum);
	}
	return color;
}

//...main函數 {
color = motionBlur(color, texcoord.st, viewPosition);
//...}

運動模糊的代碼包含了4個控制量,MOTIONBLUR_THRESHOLD表示要引發動態模糊時,所需在屏幕空間上最小的移動距離;MOTIONBLUR_MAX表示最大效果時的移動距離,必須要大於MOTIONBLUR_THRESHOLD,如果這個值過大的話,在迅速切換鏡頭(比如F5,或者/tp)時會引發一瞬間的畫面撕裂感;MOTIONBLUR_STRENGTH是一個強度係數,決定模糊強度;MOTIONBLUR_SAMPLE是採樣次數,其實這個不是很重要,5次甚至都有點偏多了,你可以考慮降到4次.
在代碼中,我們先將像素對應的眼坐標系位置變換到真正的世界坐標系(在光照坐標系中加上cameraPosition),然後一路變換到上一幀對應的NDC坐標,然後算出在屏幕坐標系中的位置.將兩坐標之差作為徑向矢量,最後是一段玄學的徑向模糊.循環中的坐標位置判斷是有必要的,如果去掉它,會導致屏幕邊緣有嚴重的黑邊.

2016-02-14_20.21.54

至此,本文最後一個特效也製作完了(散花?),現在我們有了一個基本能看的光影包,誠然,它還有很多缺陷,但至少我們已經邁出了第一步,市面上最重要的兩個光影包:Chocapic13的光影包和Sonic Ether的SEUS都是經過無數個版本的千錘百鍊才達到了今天的效果,遠不是一小時的複製粘貼能實現的,事實上,我相信除了ZiWei,沒有什麼是不學就會的 ? 圖形學是個計算機中的小數學,數學中的小計算機,與其他計算機衍生學科相比,它更需要紮實的基礎,和無數個晝夜的奮鬥與經驗積累.最後,你可以在這下載到完整的光影包,但是不包括之前那個屎綠色顏色調整,DOF的近景模糊也被注釋掉了. [SkyDrive] [百度網盤]

2016-02-14_21.13.02
提問時間:考驗MMD廚的時刻到了,請問這是哪個舞蹈呢? 提示:開始時是背向觀眾

後記

"一生傾注於文字 - 但它顯然徒勞
在保存逝去的事物.
因為在我死後不能想象的未來
誰還會去讀?"
-John Updike

我認為,撰寫序言和後記大概是寫作時最大的樂趣所在,有人用微笑曲線來比喻完成一件工作時最大的樂趣是在工作開始和結束時,畢竟從開頭到結束的路途往往異常坎坷,在陶瓷國古人云"行百里者半九十",在西方有人提出過"圓周理論",指出一項工程的實際耗時往往是理想預期中的3倍 - 剛好是圓的直徑與周長的比例,顯然無論古今中外,填坑比挖坑要難是公認的.老實說,這篇文章在最後階段的衝刺經歷並不是很愉快,一邊忍受着Winter Blue時的尋死覓活,一邊面對着一次次被打破的自設Deadline,看着屏幕上支離破碎的圖形無論如何調整也總是無法繪製正確,這種絕望感幾次讓我險些放棄,對程序員來說,"It just doesn't work"是最讓人崩潰的情景,幸好我挺過來了! ☺

在當初撰寫教程時的上百個日夜中,我曾想"等寫到後記時我一定要好好吐槽一番..." 然而當我真寫到這部分時卻是無語凝噎,或許當時我真該把想說的話記下來 ? 為什麼要寫這篇教程啊,Shadersmod的光影包本身就是一個很小眾的"市場"呢,或許是因為當初我單純想把自己研究Shadersmod的經驗記下來,又或是純粹為了憋一通字裝逼?大概是因為我喜歡追求沒有人做過的事吧.四年前的初春時我也在撰寫一篇教程,也同樣是作為一個剛剛入門的半瓶子晃蕩的新人在作死地撰寫一篇專業文章,當時我在那篇教程中寫出過一些啼笑皆非的結論(還好都被及時改掉了w),我相信這篇教程里或多或少也會有些不足,事實上,本文的上半部分在截稿時和撰稿之初已有一些差異,這篇文章的寫作過程對我而言也是一次學習的過程,正如那句玩笑"學習一個語言的最好辦法是去寫一個它的解析器",學習一個事物的最好方式或許是寫一部它的教程,因為這會強迫你事無巨細地了解它的每一方面,如果不求甚解的話,我大可大段地從別處複製粘貼代碼到我的光影包,幸好我並沒有那樣做,而是選擇儘可能將那些晦澀難懂的原理用樸實的語言轉述出來,有人將之稱為"二手知識",比喻為"吃別人嚼爛了吐出來的蘋果",而我更樂意將其比喻做"吃別人烹調出的熟食".

誠然,如果我們每個人都有一個極漫長的壽命,那麼學習很可能會被作為一門藝術,或許我們可以回到像古希臘的學院一樣,讓學生們與名師面對面以討論的方式學習,我們將有無窮無盡的時間,去鑽研我們喜愛的學科,然而現實是社會為教育分配的時間已經快到了人的"性價比"的底線了,在這個知識和信息都在爆炸的年代,學習已經成了我們日常生活中,像呼吸、吃飯、擼-唔...我是說"鍛煉",像鍛煉一樣重要的事情.儘管學習沒有捷徑,但我們都知道在兩點間位移不變的情況下,是可以有無數種路程的,找出一條路程最短的軌跡,是教育者們永恆的追求,我寫出這套"二手知識",也是為了降低入門難度,讓更多人能更快地跳入圖形學這個大坑 (笑)

說起來,當時我又是怎麼跳進這個大坑的啊,我印象中是13年10月,當時ici2cc給我發了個截圖,是在Minecraft中渲染一個超低清的⑨,沒錯,這個就是後來的CustomSteve的原型,當時問我搞不搞,我當然是滋詞,於是就跳入大坑了,當時是學着用傳統的立即模式在Minecraft中繪製模型,具體的過程記不清了,總之當我在屏幕上終於繪製出一個不畸形的沒貼圖的小⑤時,我整個人都感覺生無可戀了(笑^2) 或許這種"視覺刺激"遠超過之前寫過的任何程序吧,至此我就決定去跳這個大坑了,然而那時我還僅停留在OpenGL的API使用上,對底層的原理和更複雜的着色算法還一無所知,就這樣混混沌沌地到了2015年的8月,我決定寫這篇教程為止,那麼現在呢?唔...我想是只學到了一個,就是從當初的什麼都不知道,到現在的"知道自己什麼都不知道" ? 在這段學習過程中,恐怕是為數不多的在考前複習之外的情境中,體會到"當我學得越多,覺得自己懂得越少"的感覺了.

考慮到這段後記正有向學期總結髮展的趨勢,我們還是來聊一些"嚴肅"的話題吧,或許你讀完這篇教程後依然不能寫出一個基本的着色器(當然我希望我的讀者們並不是隨便沿着超鏈接爬行的網絡醉漢),也許你依然無法理解那些理論,但至少,我希望你能做到不惑,曾幾何時計算機系統(但不要提計算機系統結構!那科我掛了!5天後的2月19日我還要去補考!)在外行眼中甚至如同本文開篇提到的"黑魔法"一樣,就像祖父輩的人曾認為電燈要靠人點火,收音機裡面藏了個人似的,而現如今雖然計算機與它的知識已經普及,但在這個巨大的學科當中不同分支間的隔閡也日益加劇,單是圖形系統這一部分,對於日常工作不涉及到的人來說,也幾乎與"蠱術"無疑,或許你讀完這篇文章中不會去真的動手寫一個光影包(如果你真的要這樣做的話,小心那很累哦),但至少你不會再困惑於那些神奇的光影魔法是怎樣做到的,不會再在UnityAssetStore上花幾十刀買了一個五毛特效還感激涕零地給個好評. ?

話題就這樣莫名轉到了U3D的黑槍上,我有個習慣是差不多每隔幾個月就去跑一趟北京圖書大廈,從新書的主題上就可以大致看出時下流行的技術,不過由於寫作周期的制約,往往是在某項技術大火後的幾個月才會有成書,比如Gradle是在去年9月才有了第一本中文書,然而這對U3D卻不是問題,自從在14年初佔領了大半個遊戲類書架(我在此文中還吐槽過)後,你幾乎可以僅憑U3D圖書的標題就能知道U3D最新的版本號,足以精確到minor.相比之下,UE我在兩年前看到過一兩本針對UE3的,針對UE4的我好像沒有見到,就連兩隻腳已經全踏進棺材的XNA都尚有一本書在架(雖然沒有看它的封面,不知道是不是那本魚書),而當初在2D領域與U3D平起平坐的Cocos2d-x現在也只剩寥寥幾本了,看起來當年在罈子上一個萌二說的"今後遊戲界將只剩下U3D一個引擎"預言快要成真了?好吧,我不知道這會不會成為現實,但我可以肯定André LaMothe在《Windows遊戲編程大師技巧》中的結論"一個初學者能在3~6個月中開發出一個Asteroids(爆破彗星)的復刻版就已經很不容易了"已經失效了,甚至於這句話的上一句,"想要開發一個和DOOM或Quake水平相當的遊戲作為處女作,顯然這是不可能的",都已經快要失效,軟件工程的荒蠻時代已經過去,我們可以盡情地站在前人的成果上攀登高塔,能有一個降低門檻、節省時間的東西,何樂而不為呢?既然如此,那為何現在還會有人在學習底層的技術?顯然我們的目的不僅僅是省一筆錢,"在一個免費的3A級遊戲引擎橫行的時代,我們的工作還有意義嗎",這是JME引擎 - 一個老牌Java遊戲引擎 - 的開發者之一,在去年UE4和U3D宣布免費後,於官博上發的一篇文章的標題,在描述二線引擎存在的意義時,作者寫道"如果Unity和UE是遊戲開發中的樂高玩具,那麼JME就是一個怪蜀黍,給了你一把能打開他的塞滿各種電動機床和裸露接線的工作間的鑰匙,並且囑咐你'拜託別把自己命玩進去'(當然,這只是一種逗悶子的誇張比喻,事實上我們是很重視用戶體驗的).大多數孩子在面對這種程度的自由時都會感到無所適從,但總會有人能做出在安全範圍內幾乎不可想象的東西."U3D、UE4作為一線的免費引擎,總能滿足大部分人的需求,但是總會有人在尋求另一種可能,在兩極爭霸(甚至更糟的,單極稱霸)的背景下開闢一個新的理想國;總會有人願意去研究終不見天日的底層,為最重要卻又最不引人矚目的基礎件添磚加瓦;也總會有人願意犧牲時間將自己的成果或經驗總結成文字、圖片、視頻或音頻,以各種方式傳授給後來者.有一段時間我也在給一個Java遊戲引擎(但不是JME,猜猜是哪個?☺)寫基礎件,但工作還是差最後一點,但願儘快能完成吧.最近一次去書店看到那個引擎居然已經有兩本中文書了呢,可喜可賀啊!

剛才說到"另一種可能",其實我也經常在想我的人生的其他可能,如果我從未學過編程,現在我會在幹什麼?如果當年填志願時沒有在最後時刻清醒過來把專業改回計算機,而是繼續腦抽報材料的話,我現在會不會就該王八看綠豆似的盯着爐子玩真人MC呢?如果當初中考沒爆豆,而是像我那些初中同學那樣上職高的話,我現在是不是就該工作了?如果當初我沒接觸過東方的話...淦,這個不會發生的 ? 當初曾看到個問自己最想做的職業,我說的是"大學老師,畫家,作家",有人吐槽說你就不想當程序員嗎?我就地裝了個逼:"編程對我來說就像呼吸一樣正常,你會把呼吸當做職業嗎?" 咳咳...裝逼歸裝逼,了解自己想做什麼和能做什麼的差別是痛苦但卻必要的,而且我從未相信自己能成為一線的碼農,說不定哪天當我發現自己數學功底真的不夠時,就得回去擺弄看不見的東西(其實也並不糟,前一段時間在寫一個解釋器,感覺也蠻有意思的),甚至更糟的,改去做企業應用了,可憐我這數字傳媒方向啊...

不管怎樣,碎碎念就到此為止了,但是本文仍未就此結束,還有許多重要的附錄在下一頁,我相信對於想開發光影包的人來說是必不可少的.祝各位c♂ding happily!

哎呀,差一點忘了Credits~

感謝這些人:
daxnitro與Karyonix - 光影Mod的原作者和現在的維護者,雖然你倆的光影Mod有那麼多缺陷不足和Bug,但依然不妨礙它成為一個好Mod. ?
Chocapic13 - "模範光影包"的作者,拜你寬鬆的協議所賜,你的光影包成了80%+的光影包的模板,雖然我沒有用. ?
Sonic Ether - SEUS的作者,你的光影包幾乎成了標杆,代表着MC光影界的最高水準. ?
Inigo Quilez - Shadertoy的站長,感謝你的體積雲光照和3D噪音算法,以及你和Pol Jeremias搭建的那麼好的一個在線着色器分享網站,雖然從1月份的那次更新起有像小遊戲平台發展的趨勢. ?
ici2cc - CustomSteve的大♂老♂板,作為一個嚴厲的監工(霧),感謝你批准我放下CustomSteve(還有你那個最新的槍戰Mod,叫什麼來的?)的工作專註於撰寫這篇文章,以及在撰文過程中的精神支持.順便拜考前你給我立的那個大Flag所賜,我掛科了! ?
エボシ - 戀戀的模型作者,沒有你的模型我怎麼能在工作時保持興♂奮呢? ?
許許多多的CGer - 沒有你們的資料和對自己的經驗與成果的無私分享,僅憑我的能力是寫不出這篇文章的. ?
and you - 能完整的讀完全文而沒有Alt+F4,我滿足了! ?
另外,本文中隱藏了數不勝數的彩蛋!你能發現它們嗎?

szszss
2016-2-14