從零開始手敲次世代遊戲引擎(六十六)

從零開始手敲次世代遊戲引擎(六十六)

來自專欄高品質遊戲開發51 人贊了文章

在從零開始手敲次世代遊戲引擎(六十五)當中我們使用了很多手段來讓模型融入到環境當中。但是這種畫質的調整方法因為缺少理論基礎,很難適用於所有的場景。它看起來更像是P照片,雖然有個大致的流程,但是需要為每幅照片單獨調整參數。

因此,如果場景多變,那麼這樣的製作流程就顯得非常地不方便了。作為動畫行業的先驅,迪士尼首先遇到了這樣的問題。經典材質模型(環境光+漫反射貼圖+高光貼圖)是一種經驗模型,在不同的光照條件和環境下材質所呈現出的效果與實際偏離很大,需要為不同的場景使用不同的材質,這很麻煩。

為了使得材質在不同環境下有著一致的表現,迪士尼的研究者開發出了「基於物理的渲染方法」,即PBR。下圖展示了使用該技術的一款動畫作品。

圖片來源互聯網,未能確認版權狀況,為迪士尼作品截圖。原作品版權為迪士尼所有。本文作為技術演示目的進行引用。

該技術的詳細由迪士尼在2010年的SIGGRAPH上進行發布,發布的原文可以在參考引用1當中找到。

同時,互聯網上有很多關於PBR的文章分享。與我的前幾篇文章相同,我這裡主要參考的是參考引用2。此外,互聯網社群對參考引用2進行了中文翻譯,我列在了參考引用3當中。

由於這些文章對於PBR技術的理論基礎已經進行了十分詳細的描述,我這裡就不重複同樣的方式,而是換一個角度,談談該項技術的「心流」,以及具體到我們這個系列當中的實現。

首先讓我們來重新審視一下我們迄今為止使用的Phong光照模型:

這是一個經驗模型。所謂經驗模型,就是根據人們的觀察總結出的模型,而不是經過數學推導出來的。這個模型至少有下面這麼幾個問題:

  1. 將光源和材質的光學特性混為一談。比如Ambient,這個翻譯成中文叫環境光。但是這個環境光是從哪裡來的呢?Phong模型假定環境光是一種各向同性、無處不在的勻質的光,但是並沒說明這種光的來源,從而對其強度的估計只能靠拍腦袋。此外,既然是光,那麼就會在材質表面產生高光和漫反射,但是可以看到phong模型是認為環境光不會產生高光,而是直接產生漫反射。從而,環境光成為一種不同於其他光源的特殊光源,似乎擁有著完全不同的光學性質;
  2. 割裂了漫反射和高光(鏡面反射)之間的聯繫。在Phong模型當中,漫反射(Diffuse)和高光(Specular)是完全分開的兩個部分,相互之間沒有任何關係。然而事實上,無論是漫反射還是高光(鏡面反射),都是對材質對入射光線作出的反應,兩者是有此消彼長的能量關係的。在Phong模型當中,根據不同的參數選擇,很可能會出現材質的漫反射能量+高光能量>入射光能量的情況,這顯然是違反物理規律的;
  3. 完全忽略了材質表面微小結構的特性。比如一塊玻璃和塊毛玻璃,它們都是玻璃,反射折射係數相同。然後它們看起來肯定是不一樣的。這是由於它們的表面平坦層度很不同,而Phong模型只關心表面的法線。當然,如果我們將法線進行擾動,讓它在各處都輕微地不太一樣,是可以在一定的程度上模擬出不平坦表面的。然而,對於法線的擾動的最小單位就是逐像素(或者更為嚴謹地,逐採樣),對於遠小於像素尺寸的(很多時候時候分子或者原子尺寸)的材質粗糙度對於光的影響,是很難模擬的。

除了上面這些問題,基於經驗的模型也對材質的製作提出了艱巨的挑戰。因為缺少各項係數的物理意義,調參就如同作畫一樣,必須一邊看著實際渲染的效果,一邊調整。這對於製作人員的經驗和藝術感覺有著很高的要求,而且好不容易調好了,光源環境一變,又得重來,十分低效。

PBR實際上就是針對上面這些問題進行的演算法改良。需要注意的是,它仍然是一種近似模型,並非物理正確。因此它的名稱是基於物理的渲染(PBR)而並非是物理渲染(PR)。

在我看來,PBR主要進行了下面這幾個方面的改良:

  1. 分離了光源和材質的光學性質。將光源分為間接光源和直接光源這兩部分,其中間接光源指來自於物體反射或者大氣散射等的光能,而直接光指來自於場景光源直接照射的部分;材質的光學性質則分為漫反射部分和高光反射部分,這是和phong模型類似的。所以作為結果,PBR模型當中的反射方程可以分為間接光產生的漫反射、間接光產生的高光反射、直接光產生的漫反射、直接光產生的高光反射著四個部分;
  2. 根據材質的光學特性,將材質分為金屬和非金屬這兩個大類分別進行處理。在phong模型當中,金屬是很難模擬的。因為金屬幾乎不產生任何漫反射,其顏色主要來自於對於間接光的高光反射。而phong模型實際上是一個局部光照模型,它的高光部分只含有直接光照部分,缺少間接光的高光部分。所以PBR模型在渲染金屬材質方面與phong模型相比特別突出,某種意義上這也是為什麼在2010年之後有那麼多科幻或者機甲類遊戲問世,而之前很少的一個重要原因;
  3. 導入粗糙度(roughness)和環境光遮蔽(ambient occlution,簡稱ao)來描述材質表面的微小結構對其光學特性的影響。在phone模型當中,我們採用了一個被稱為是高光係數的參數控制高光光斑的面積,來模擬不同粗糙度材質的高光反射效果。但是我們沒有任何手段來控制對間接光的高光反射。而且這個高光因子沒有什麼物理意義,所以全憑手動試驗。同時,凹凸不平的表面還會對入射光和反射光造成一定的遮蔽效果,也就是形成很多微小的陰影,這些陰影甚至存在在物體面向光源的那個部分,這也是phong模型無法模擬的。PBR模型使用粗糙度代替高光因子,並使用環境光遮蔽模擬微小表面產生的細小陰影;
  4. 漫反射和高光反射的能量守恆。在phong模型當中,環境光、漫反射、高光三個部分各自的係數之間沒有任何聯繫,也就是說3個部分的能量加起來很可能與場景當中的總光能並不守恆。但是在PBR模型當中只有漫反射和高光反射這兩種反射模型,並且要求兩者係數相加為1,這就保證了能量的守恆。(嚴格來說,是保證了能量不憑空產生,但是光能的部分可能減少:吸收轉變為熱能)

所以實現PBR光照模型,其實主要包括4個部分:

  1. 實現直接光的高光反射計算
  2. 實現直接光的漫反射計算
  3. 實現間接光的高光反射計算
  4. 實現間接光的漫反射計算

接下來我們會逐一解釋上述每一個步驟。

(大寶要看小豬佩奇,所以。。。未完待續)

------ 2018年9月8日晚20點續 -------

冷眼看著正在各種調皮不肯睡覺的大寶,繼續寫。

在進入第一部分:直接光的高光反射之前,首先補充一下為什麼要將光源分為直接光和間接光。因為既然是基於物理的渲染,這兩種光在物理性質上應該並沒有什麼差別,都遵循同樣的光學原理,為什麼要將它們分開考慮呢?

其實這主要是為了利用它們在空間分布上的特徵來簡化計算。一般來說,場景當中的直接光源(也就是場景當中的各種類型的光源物體)的個數總是有限的,特別是在遊戲當中,考慮到實時繪製的需要,對光源的數目及面積限制會比較嚴格。這就是說,對於場景當中的直接光照,我們是可以精確地知道並一一列舉的;

而對於間接光照,比如大氣散射,又比如場景物體所反射的光,這些都是大面積(大體積)的光。我們沒辦法(或者很難)找到它們的準確來源,也沒法對其進行理論上準確的遍歷和採樣。

如果不很嚴謹地比喻的話,場景當中的直接光就有如同經典力學,是可以精確地唯一性地確定表達的。然而場景當中的間接光則有如同量子力學,我們很難甚至不可能對其進行精確唯一性描述,然而其位於統計意義上的特性是可以確定的。

正是因為這種存在形式上的差異,如果我們將它們分開進行考慮,會大大簡便我們的計算。但是同時請記住它們所遵循的物理規則其實是相同的。

一、直接光照的高光反射

PBR當中的單光源高光反射

這個場景當中展示了三種材質的對於單一點光源的PBR高光反射,從左到右依次是:竹製地板、金、銹鐵。我們可以看到相比較於Phong光照模型的高光光斑,PBR的高光不僅僅有光斑的大小和亮度的區別,而且呈現出了分布上的變化。

這是因為在PBR當中,我們考慮了材質表面的微小結構對於反射光線的擾動。為了量化這種擾動,在PBR材質模型當中,我們導入了粗糙度(roughness)這個參數,來描述材質表面的微小平面的平整程度。粗糙度 = 0表示材質表面是光學光滑的,就是表面沒有任何凹凸不平;而粗糙度 = 1則表示材質表面凹凸不平的程度相當大,基本上難以形成可見的鏡面反射(不會形成光斑)。

比如上圖當中的第三個球的鐵鏽部分,它的粗糙度很高,所以我們就可以看到光斑在這裡基本上是看不到的。但是同時,由於此處十分粗糙,所以可以認為在此處總有一些微小結構,其朝向正好能將入射光的一小部分反射到我們眼睛裡面,所以我們可以看到粗糙度高的地方反而不是黑的,而粗糙度低的地方卻恰恰是黑的。這個黑,就好比我們在夜晚看一面鏡子,那麼鏡子里沒有光源的地方,都是黑的。

這個根據粗糙度改變高光分布的效果,是通過一個被稱為是法線分布函數的函數實現的。這個函數的形式如下:

NDF_{GGXTR}(n,h,α)=frac{α^2}{π((n?h)^2(α^2?1)+1)^2}

其中 alpha 是表面某處的粗糙度, n 是該處的法線,而 h 是入射光與視線所形成的夾角的對稱軸。這個函數並不是嚴格的物理數學推導的結果,而是一個經驗公式。因為是經驗公式,它不是唯一的。如參考引用2所介紹的,我們這裡採用的是Epic(就是Unreal Engine的製作公司)在進行大量比較之後所推薦的一種。下面是GLSL的實現:

float DistributionGGX(vec3 N, vec3 H, float roughness){ float a = roughness*roughness; float a2 = a*a; float NdotH = max(dot(N, H), 0.0); float NdotH2 = NdotH*NdotH; float num = a2; float denom = (NdotH2 * (a2 - 1.0) + 1.0); denom = PI * denom * denom; return num / denom;}

NDF

上圖是將NDF函數單獨提取出來進行渲染的結果,可以清晰地看到各種材質表面根據粗糙度對鏡面反射所形成的擾動。可以看到,僅僅是NDF已經能夠給我們比較強烈的質感,缺陷在於立體感。

事實上,對於粗糙的表面來說,除了對於反射光線的擾動之外,還有一種現象,那就是表面的凹凸對光線所形成的遮擋。很容易想像,當我們幾乎垂直觀察物體的表面時,這種遮擋應該最小,而當我們以幾乎平行與物體表面的視角觀察的時候,這種遮擋應該最大。就好像地面上的山脈一樣,我們在地上時看不到山背後的光,但是如果我們飛在天上,就可以很容易地看到山後面的光了。上面的NDF正是因為沒有考慮這種變化所以看上去比較平面化。

粗糙表面的光線遮擋模擬 https://www.zhihu.com/video/1021881760635871232

雖然由於視頻尺寸以及壓縮的關係,上面的視頻可能不能很明顯地看出,在改變視角的時候鐵鏽的部分是會發生一些微妙的明暗變化的。

在PBR模型當中,採用另外一個經驗公式來模擬這種現象。這個公式被稱為是材質表面的幾何形狀因子公式:

G_{SchlickGGX}(n,v,k)=frac{n?v}{(n?v)(1?k)+k}\

其中 n 同上面一樣,為該處的法線, v 是視線,而 k 是一個係數。對於直接光照,其推薦的取值為:

k_{direct}=frac{(α+1)^2}{8}\

需要注意的是我們需要分別對入射光線和反射光線進行遮擋模擬,所以實際上我們要套用上面的公式兩次。

下面是GLSL的實現:

float GeometrySchlickGGX(float NdotV, float roughness){ float r = (roughness + 1.0); float k = (r*r) / 8.0; float num = NdotV; float denom = NdotV * (1.0 - k) + k; return num / denom;}float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness){ float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx2 = GeometrySchlickGGX(NdotV, roughness); float ggx1 = GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2;}

形狀因子

上圖給出了單獨將形狀因子函數進行圖像化的結果。在右側的球可以非常明顯地看到明面的內部比較均勻而周圍一圈呈現出與粗糙度分布一致的暗影。其中,2點鐘方向的暗影來自於凹凸表面對於反射光線的遮擋,而6、7點鐘方向的暗影來自於表面的凹凸對於入射光的遮擋。

(困了,暫時告一段落吧,未完待續。。。)

------- 2018年9月9日 中午更新 -------

在PBR的高光當中最後考慮的一個是菲涅爾現象,這個也是Phong模型當中沒有考慮的。所謂菲涅爾現象,就是材質表面對於光線的反射,當光線垂直入射的時候(沿著法線入射的時候)是最弱的,而當光線與法線的夾角逐漸增大,則反射也會增強。

在現實生活當中我覺得這個現象最為明顯的就是水面。如果我們垂直向下看,那麼水面看起來相當透明。但是如果我們看比較遠處的水面,那麼我們基本上就只能看到天空的倒影,水面看起來就不是那麼透明了。

特別是,作為一個特例,當光從密度較大的介質進入密度較小的介質的時候,如果與法線的夾角達到一定的閾值,會發生全反射現象。比如在水裡看天空,頭頂的天空是看得到的但是比較遠處的地方水面看起來就像鏡子一樣閃閃發光了。家裡若是有魚缸的同學也可以很容易看到這種現象。

這個現象其實在物理碰撞當中也有。當碰撞並不是理想的完全彈性碰撞的時候(現實當中的碰撞基本上都不是完全彈性碰撞),從正面剛往往是反彈最弱的(大部分能量被形變而吸收),但是僅僅是擦過的話,能量的損失很小。比如一顆子彈擊中鋼盔,如果是正面擊中,即是不打穿衝擊力也是很大的,子彈基本上會停止下來;然而如果是與法線成很大角度「擦」過,那麼基本上就是子彈稍微改變飛行軌跡繼續飛行,能量損失不大而鋼盔上也就是一個擦痕而已。

PBR當中同樣使用了一個經驗公式來模擬這種現象:

F_{Schlick}(h,v,F_0)=F_0+(1?F_0)(1?(h?v))^5\

其中 F_0 是當光線垂直入射(沿著法線入射)時的反射率, v 是視線, h 是視線與法線夾角的對稱軸。顯然,當視線平行於法線的時候, hcdot v 為最大值1,第二項值消失;當光線幾乎平行於表面(垂直於法線)入射時, hcdot v 接近0,整個表達式的值接近1,即全反射。

顯然對於上面我們所說的從水當中看天空的情況,這個公式是過於保守的。因此這個公式並不是一個正確的公式,只是一種簡便的模擬。畢竟在遊戲當中,大部分情況下我們面臨的都是光線從低密度(比如大氣)到高密度(物體)的情況。

菲涅爾現象

上圖是將菲涅爾現象模擬函數的值單獨進行渲染的結果。可以看到從中心到邊緣的反射率變化。注意對於純金屬,其顏色其實就是由這個反射率形成的。中間第二個球的材質是金,其呈現金色是因為它對不同的波長的光的反射率不同所造成的。一般說來,金屬的顏色是因為反射率不同造成,而非金屬的顏色是因為對不同波長的光的吸收率不同所造成。所以在只有高光反射的圖當中,非金屬(比如第一個球)是沒有特定的顏色偏向而只有明暗的。

最後將這三個函數通過乘法混合在一起,就形成了PBR的高光反射部分。注意在合成之後的邊緣部分的菲涅爾現象並不是十分明顯,這是因為其於形狀因子的效果形成了部分的抵銷。

---- 2018年9月9日下午3點更新 ----

二、直接光照的漫反射

接下來讓我們來看PBR當中直接光照所產生的漫反射部分。

這部分的基本模型其實和Phong模型是差不多的,唯一的不同在於能量的守恆方面。如前面所說,Phong模型的漫反射係數和高光係數之間沒有定義任何關係,從而導致了能量不守恆的問題:即漫反射的能量+高光的能量可能會大於入射光的能量。在PBR當中對這個問題進行了修正,規定 漫反射係數+高光係數leq1 ,這就保證了能量的守恆。

還有另外一個地方需要調整。任何一處的漫反射是向以該處法線方向為z軸正方向的整個半球進行均勻發射的。因此,對於這其中的任意一個方向而言其所具有的能量只能是其中的一個微分。因為整個半球的圓心角為 pi ,所以我們要將漫反射係數除以 pi

vec3 kS = F; vec3 kD = vec3(1.0f) - kS; kD *= 1.0f - meta; Lo += (kD * albedo / PI) * radiance * NdotL * visibility;

此外,實驗表明,金屬材質基本不會產生漫反射。也就是說金屬材質會立即吸收掉折射入材質當中的光線,並將其轉化成為別的形式的能量,而不再次將該部分的能量以光能的形式釋放出來。所以,在渲染金屬材質的時候,我們直接定義漫反射係數為 0,而不論其高光係數是多少。

也正因為如此,對於金屬材質來說,原本Phong模型當中的漫反射貼圖變得沒有意義,因為漫反射係數為0。但是,不同的金屬的反射係數有很大的差別,且對於不同的波長的可見光其反射係數可能不同,這就是各種金屬在光照下呈現出不同顏色的原因。既然金屬不需要漫反射貼圖,我們就可以使用漫反射貼圖來傳遞這個反射係數,RGB三個通道正好可以用來傳遞對於RGB三種波長的反射係數。需要注意的是,各種金屬材質對於不同的波長的反射係數並非線性關係,所以用RGB三種波長的反射係數是無法準確模擬出金屬對於整個可見光波段的反射特性的。但是這種偏差對於大多數遊戲來說可以忽略不計,唯有諸如賽車遊戲等要求高保真還原原廠賽車的遊戲,才會在這個地方使用一些更為高級的技巧。(比如使用更多的波段的反射係數)

正是因為在PBR渲染當中漫反射貼圖的這種不同使用目的,使得行業內一般稱PBR當中的漫反射貼圖為反射貼圖(albedo貼圖)。對於非金屬材質,它其實就是漫反射貼圖;而對於金屬材質,它是金屬表面的反射率貼圖。

PBR當中單一點光源產生的漫反射

上圖就是將PBR渲染當中只考慮直接光漫反射部分所得到的結果。可以看到,對於第一個材質球(竹材),因為其是非金屬,所以結果和Phong模型的漫反射部分是完全一致的;但是對於第二個材質球,因其材質為金(金屬),沒有漫反射成份,所以可以看到它是一個完美的「黑洞」;第三個材質球,雖然其材質為鐵(金屬),但是其表面有金屬氧化物(鐵鏽),所以又展現出不同的特點。

現在我們將(一)(二)合併到一起,就得到了PBR當中完整的直接光照射部分,也稱為局部光照模型部分。

PBR當中的局部光照模型

---- 2018年9月9日晚7點更新 ----

三、間接光的漫反射

相較於直接光,間接光的主要特點是其來自於場景當中的各個方向。所以雖然從原理上來說對於直接光的模型同樣適用於間接光,但是那樣的話我們需要在各個方向進行足夠多的採樣才可以。因為理論上這樣的樣本有無窮多個,所以顯然這樣的計算量是很大的,不適合遊戲這樣實時渲染的情況。

因此這裡的基本思路就是先通過離線預計算或者是一些近似演算法,在所有可能的法線方向上,將來自各個方向的間接光等效於法線方向的單一入射光,然後在實時渲染的時候根據材質表面的法線方向對預計算結果進行採樣,得到等效的單一入射光信息之後採用類似直接光的方法進行計算。

話說得可能有些繞,更為簡單地來說的話,就是在場景里放一個感光球,那麼這個球的表面上的任何一點就代表了該方向上所有光照的貢獻。把這個球表面的光照信息記錄下來,就是一張360度無死角的間接光強度查找表。

很容易想到,對於任何一個給定法線方向的表面,其會受到與其法線成-90度到90度這個範圍的間接光的影響。這很像中國古代的天圓地方說,地面會受到蓋在其上的整個半球形天空的影響。

圖片來自互聯網,版權信息不明

學過球面積分的話,那麼可以知道這就是個球面積分求解的問題。

L_o(p,ω_o)=k_dfrac{c}{π}oint_Omega L_i(p,ω_i)n?ω_idω_i

沒學過或者忘記的話,也完全不要緊。因為我們並不會真正的去解這個積分。做軟體有一個好處,那就是積分到了我們這裡,就變成了 sum_{a}^{b}{x} 。是不是一下子簡單了無數。。。

這是因為計算機裡面所有的東西都是離散的。我們的天空盒,無論我們使用多大的cubemap,它都是有限解析度的。所以,我們只需要找到位於我們所關心的半球內的所有像素,然後將它們累加取平均(當然,需要根據與法線的夾角加權,所以其實是加權平均),就可以得到相當於上面的積分的數值解。(由於是在一個相同尺寸的圖當中計算每個像素對當前像素的影響,這個過程其實也被稱為「卷積」,就是現在在機器學習當中大紅大紫的那個)

下圖就展現了我們使用的天空盒,和對其進行上述積分之後的cubemap(稱為照射圖、irradiance map)

天空盒

由上面的天空盒求出的照射圖

有了這幅圖之後,我們的間接光漫反射計算就變得十分簡單了:對於場景物體表面的任何一點,我們取此處表面的法線,用其方向對上面的照射圖進行採樣,就得到了以法線所指方向為穹頂的整個半個天空(半個包圍盒)所有間接光對該處的影響(以一條沿著法線負方向入射光的等效形式體現)。只不過需要注意的是,這種等效只是強度上的等效,在方向上其實間接光來自於各個方向,所以在直接光計算當中的入射角(入射光方向)在這裡已經無法適用,需要進行一定程度的修改。在漫反射當中,唯一用到的就是菲涅爾係數,也就是反射率。在前面的直接光部分我們已知反射率與入射光的入射角有關,但是對於間接光沒有單一的入射角可用,所以我們採用材質的粗糙度來代替。這同樣並非物理數學正確的方法,只是一種經驗公式。

PBR當中的間接光漫反射效果

上圖展示了只考慮間接光漫反射的渲染結果。可以看到因為漫反射的各向同性性質,渲染的結果看起來很平,沒有什麼立體感。另外,金屬材質由於其沒有漫反射,所以同直接光一樣體現為黑色。

下面是GLSL當中這一部分的實現:

vec3 ambient = ambientColor.rgb; { float ambientOcc = ao; if (usingAoMap) { ambientOcc = texture(aoMap, uv).r; } vec3 kS = fresnelSchlickRoughness(max(dot(N, V), 0.0f), F0, roughness); vec3 kD = 1.0f - kS; kD *= 1.0f - meta; vec3 irradiance = textureLod(skybox, N, 1.0f).rgb; vec3 diffuse = irradiance * albedo; ambient = (kD * diffuse) * ambientOcc; }

而上面對於天空盒貼圖進行卷積計算的部分,我是採用了一個稱為cmft的軟體。下面是鏈接:

dariomanesku/cmft?

github.com圖標

我在我們的代碼樹的External/src目錄裡面也以git submodule的方式添加了這個工具。

不過這個項目自動生成的在各個平台的項目文件有些老。比如在windows平台是visual studio 2015的,而在OSX上是xcode4的。編譯的時候需要作一些升級。(當然也可以直接下載編譯好的文件)

這個工具有命令行版本和GUI版本,我使用的是命令行版本。生成照射圖的時候大致是下面這個樣子。

ExternalWindowsincmft.exe --useOpenCL true --inputFacePosX AssetTexturessor_seasea_posx.tga --inputFaceNegX AssetTexturessor_seasea_negx.tga --inputFacePosY AssetTexturessor_seasea_posy.tga --inputFaceNegY AssetTexturessor_seasea_negy.tga --inputFacePosZ AssetTexturessor_seasea_posz.tga --inputFaceNegZ AssetTexturessor_seasea_negz.tga --filter irradiance --outputNum 1 --output0 AssetTexturessor_seasea_irradiance --output0params tga,bgr8,facelist --dstFaceSize 256

細心的讀者可能會想到一個問題。對於天空盒這樣不變的環境,我們可以採用離線計算來事先計算好卷積。但是場景當中有很多可動的物體,或者即使是場景當中靜止的物體,也會隨著遊戲角色的移動出現在視野的不同位置。對於這種情況該如何處理呢?

使用過商業遊戲引擎的讀者應該見到過放在場景當中的「環境探針」,或者說反射球陣列。是的,由於我們是在場景當中的某處對四周的間接光進行採樣,所以這個採樣其實只是反映了該處的情況。如果我們需要在場景當中移動,那麼我們就需要在場景當中的各個地方進行採樣。同樣的,理論上這種樣本可以有無窮多個,顯然遊戲不能這麼做,所以我們就按照一定的空間間隔進行採樣,而對於任意位置,我們使用一定的演算法進行內插/外插計算來混合附近的幾個樣本來模擬。

並且,對於這些動態採樣的「環境貼圖」我們需要進行實時的「卷積」計算。為了將性能影響儘可能降低,我們會通過一些辦法盡量減少在間接光方向上採樣的個數,同時盡量維持結果的魯棒性。遊戲的好處在於,我們並不需要一個正確的結果,而只是需要一個「看起來合理」的結果就好了。

好了,讓我們把到此為止的三個部分結合在一起:

PBR直接光照+間接光漫反射渲染結果

對於非金屬材質來說,這個結果已經很接近最終結果了。但是對於金屬材質(比如第二個材質球)來說,還缺少了最重要的特徵:間接光高光反射。

四、間接光高光反射

(額,程序還沒寫好,先去補程序。。。)

---- 2018年9月12日早8點更新 ----

好了,間接光高光反射部分也寫好了,來補上。

間接光高光反射部分基本思路與間接光漫反射類似,將其分為

  1. 間接光貢獻值的預計算
  2. PBR高光反射部分的適用

首先來看間接光貢獻值的預計算部分。

與漫反射不同,由於高光反射屬於鏡面反射,對材質表面某處有貢獻的間接光並不是以該處法線為穹頂的半個天空球,而是經過該處表面反射之後,能夠進入攝像機鏡頭的那部分間接光。

如果表面是理想鏡面(粗糙度為0),那麼根據我們初中所學知識就可以知道,對此處有貢獻的間接光只有一條,就是關於該處法線與到該處視線形成對稱的那條。

根據視點和物體表面位置可以求得反射光線,再根據法線就可以求得入射光線。(圖片來自互聯網)

得到入射光線之後,求其與天空盒的交點,那麼天空盒上該點的顏色就是我們在物體上看到的高光反射的顏色。

很容易,是吧。

然而,這是理想狀態。現實世界當中的材質,基本上沒有那麼平坦的。表面上布滿了各種微小結構,形成很多凹凹凸凸。所以,現實當中的鏡面反射往往是(b)這樣的:

(圖片來自互聯網)

當然,理論上如果光線的直徑為零,那麼即使是不光滑表面,其高光反射之後的反射光線也只有一條。但是我們這裡的像素是有面積的,所以我們計算的對象實際上是有一定半徑的一條光束。這種情況下,一條光線會被反射到理論方向附近的一個角度範圍之內,如下圖右側所示:

(圖片來自互聯網)

根據光路可逆原理,我們可以將上面的圖當中的箭頭反過來,那麼我們就知道對於到表面的某條視線來說,它的顏色會是多條入射光的合成。也就是說,我們需要對天空盒進行一個範圍的採樣,才能確定該處的高光反射的顏色。

要精確求得這個範圍是比較難的。因為它和材質表面的微小結構的細節有關。但是我們可以從宏觀上去模擬它。

顯然,如果表面越粗糙,那麼這個範圍就越大。最極端的情況,就是漫反射——向整個180度半球面。

所以間接光高光反射當中的間接光卷積計算,與漫反射的間接光卷積計算的差別,實際上就是多了一個對錶面粗糙度的考量。之前漫反射的間接光卷積計算我們得到的是一張cubemap,但是間接光高光反射的間接光卷積計算我們理論上0到1之間的每個粗糙度都會對應一張cubemap。也就是說,理論上有無窮多張。

當然,我們不可能生成和保存無窮多張cubemap,所以我們只是將粗糙度按照一定間隔預先計算好幾張,然後用線性插值的方法去插出中間的結果。同時,由於粗糙度越高畫面約模糊,為了節省內存,我們採用MipMaps的方法來保存不同粗糙度的圖。

我們依然可以使用cmft工具實際生成這個預計算圖:

ExternalWindowsincmft.exe --useOpenCL true --clVendor anyGpuVendor --deviceType gpu --deviceIndex 0 --inputFacePosX AssetTexturessor_seasea_posx.tga --inputFaceNegX AssetTexturessor_seasea_negx.tga --inputFacePosY AssetTexturessor_seasea_posy.tga --inputFaceNegY AssetTexturessor_seasea_negy.tga --inputFacePosZ AssetTexturessor_seasea_posz.tga --inputFaceNegZ AssetTexturessor_seasea_negz.tga --filter radiance --excludeBase false --mipCount 9 --glossScale 10 --glossBias 1 --lightingModel phongbrdf --outputNum 2 --output0 AssetTexturessor_seasea_radiance --output0params dds,rgba16f,vstrip --output1 AssetTexturessor_seasea_radiance_preview --output1params tga,bgr8,facelist

預計算的高光環境貼圖

然後就是反射方程,也就是通常被稱為BRDF的部分(我們在直接光高光反射那部分展示的NDF,形狀因子,菲涅爾係數統合起來就是BRDF)

但是BRDF不僅與視線方向有關,還與入射角有關。視線方向我們在渲染的時候可以知道,但是入射角對於間接光來說不是一個單值。所以再次地,我們需要對這部分進行卷積計算,並且形成一張以表面粗糙度(代表了可能的入射角範圍)和視線方向這兩個因子作為索引的查找表。

這個表因為與具體的場景結構無關,所以我們可以事先計算。需要注意的是這次我們的對象是間接光,對於間接光,幾何因子公式當中的係數 k 與直接光相比略有不同。至於為什麼,這個本來就是經驗公式,看上去不太對所以就給調整了唄。(當然本質上還是因為間接光來自各個方向,所以被表面凹凸的遮擋實際上也是一個積分)

k_{indirect}=frac{alpha^2}{2}

計算這個表的代碼如下,是用GLSL的Computer Shader實現的。Computer Shader是OpenGL 4.3之後才有的功能,實現的就是GPGPU的非繪圖計算(當然我們這裡最終還是把計算結果保存在一張貼圖裡)

integrateBRDF.comp

#version 430 layout(local_size_x = 1, local_size_y = 1) in;layout(rg16f, binding = 0) uniform image2D img_output;#define PI 3.14159265359float RadicalInverse_VdC(uint bits) { bits = (bits << 16u) | (bits >> 16u); bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); return float(bits) * 2.3283064365386963e-10; // / 0x100000000}vec2 Hammersley(uint i, uint N){ return vec2(float(i)/float(N), RadicalInverse_VdC(i));} vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness){ float a = roughness*roughness; float phi = 2.0 * PI * Xi.x; float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y)); float sinTheta = sqrt(1.0 - cosTheta*cosTheta); // from spherical coordinates to cartesian coordinates vec3 H; H.x = cos(phi) * sinTheta; H.y = sin(phi) * sinTheta; H.z = cosTheta; // from tangent-space vector to world-space sample vector vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0); vec3 tangent = normalize(cross(up, N)); vec3 bitangent = cross(N, tangent); vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z; return normalize(sampleVec);}float GeometrySchlickGGX(float NdotV, float roughness){ float a = roughness; float k = (a * a) / 2.0; float nom = NdotV; float denom = NdotV * (1.0 - k) + k; return nom / denom;}float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness){ float NdotV = max(dot(N, V), 0.0); float NdotL = max(dot(N, L), 0.0); float ggx2 = GeometrySchlickGGX(NdotV, roughness); float ggx1 = GeometrySchlickGGX(NdotL, roughness); return ggx1 * ggx2;}vec2 IntegrateBRDF(float NdotV, float roughness){ vec3 V; V.x = sqrt(1.0 - NdotV*NdotV); V.y = 0.0; V.z = NdotV; float A = 0.0; float B = 0.0; vec3 N = vec3(0.0, 0.0, 1.0); const uint SAMPLE_COUNT = 1024u; for(uint i = 0u; i < SAMPLE_COUNT; ++i) { vec2 Xi = Hammersley(i, SAMPLE_COUNT); vec3 H = ImportanceSampleGGX(Xi, N, roughness); vec3 L = normalize(2.0 * dot(V, H) * H - V); float NdotL = max(L.z, 0.0); float NdotH = max(H.z, 0.0); float VdotH = max(dot(V, H), 0.0); if(NdotL > 0.0) { float G = GeometrySmith(N, V, L, roughness); float G_Vis = (G * VdotH) / (NdotH * NdotV); float Fc = pow(1.0 - VdotH, 5.0); A += (1.0 - Fc) * G_Vis; B += Fc * G_Vis; } } A /= float(SAMPLE_COUNT); B /= float(SAMPLE_COUNT); return vec2(A, B);}void main() { vec4 pixel; ivec2 pixel_coords = ivec2(gl_GlobalInvocationID.xy); pixel.rg = IntegrateBRDF(float(pixel_coords.x) / gl_NumWorkGroups.x, float(pixel_coords.y) / gl_NumWorkGroups.y); imageStore(img_output, pixel_coords, pixel);}

因為這個Compute Shader只需要在實際畫面繪製之前執行一次,所以我們在GraphicsManager當中新建一個隊列,專門用來存放這種只需要初始化執行的Pass。

std::vector<std::shared_ptr<IDispatchPass>> m_InitPasses;

同時,由於這個Computer Shader需要將計算結果輸出到一張貼圖當中,所以我們新建兩個介面,一個負責將貼圖傳遞給Computer Shader,一個負責取回貼圖:

virtual intptr_t GenerateAndBindTexture(const char* id, const uint32_t width, const uint32_t height); virtual intptr_t GetTexture(const char* id);

我們還需要追加一個啟動Computer Shader的介面:

virtual void Dispatch(const uint32_t width, const uint32_t height, const uint32_t depth);

最後我們就是在合適的地方,執行這些初始化Pass

void GraphicsManager::InitializeBuffers(const Scene& scene){ for (auto pPass : m_InitPasses) { pPass->Dispatch(); }}

最終得到的結果如下:

用於間接光高光反射的BRDF查找表。實際上這是一張浮點格式的表,所以這個圖只是一個示意。

好了,現在我們有了經過預處理的cubemap,也有了BRDF的查找表,接下來就可以實現漫反射高光部分的計算了。我們在第三節的基礎上修改間接光部分如下:

vec3 ambient = ambientColor.rgb; { // ambient diffuse float ambientOcc = ao; if (usingAoMap) { ambientOcc = texture(aoMap, uv).r; } vec3 F = fresnelSchlickRoughness(max(dot(N, V), 0.0f), F0, rough); vec3 kS = F; vec3 kD = 1.0f - kS; kD *= 1.0f - meta; vec3 irradiance = textureLod(skybox, vec4(N, 0.0f), 1.0f).rgb; vec3 diffuse = irradiance * albedo; // ambient reflect const float MAX_REFLECTION_LOD = 8.0f; vec3 prefilteredColor = textureLod(skybox, vec4(R, 1.0f), rough * MAX_REFLECTION_LOD).rgb; vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N, V), 0.0f), rough)).rg; vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y); ambient = (kD * diffuse + specular) * ambientOcc; } vec3 linearColor = ambient + Lo;

修改天空盒載入部分的程序,使其載入預先計算好的高光反射環境貼圖。

按下F5,就可以得到如下的渲染結果:

不太真實的PBR渲染效果

嗯?中間的球看上去不像金,有點像珍珠啊。其實這是因為我們使用的環境貼圖(天空盒)是一張照片,自帶Gamma矯正引起的。對於PBR來說,計算過程所使用的所有貼圖都必須不帶Gamma,否則就不是線性合成了。

解決方法很簡單,cmft工具自帶Gamma矯正功能,我們重新生成我們的漫反射cubemap和高光反射cubemap

// irradianceExternalWindowsbincmft.exe --useOpenCL true --inputFacePosX AssetTexturessor_seasea_posx.tga --inputFaceNegX AssetTexturessor_seasea_negx.tga --inputFacePosY AssetTexturessor_seasea_posy.tga --inputFaceNegY AssetTexturessor_seasea_negy.tga --inputFacePosZ AssetTexturessor_seasea_posz.tga --inputFaceNegZ AssetTexturessor_seasea_negz.tga --filter irradiance --outputNum 1 --output0 AssetTexturessor_seasea_irradiance --output0params tga,bgr8,facelist --dstFaceSize 256 --inputGammaNumerator 2.2 --inputGammaDenominator 1.0// radianceExternalWindowsbincmft.exe --useOpenCL true --clVendor anyGpuVendor --deviceType gpu --deviceIndex 0 --inputFacePosX AssetTexturessor_seasea_posx.tga --inputFaceNegX AssetTexturessor_seasea_negx.tga --inputFacePosY AssetTexturessor_seasea_posy.tga --inputFaceNegY AssetTexturessor_seasea_negy.tga --inputFacePosZ AssetTexturessor_seasea_posz.tga --inputFaceNegZ AssetTexturessor_seasea_negz.tga --filter radiance --excludeBase false --mipCount 9 --glossScale 10 --glossBias 1 --lightingModel phongbrdf --outputNum 2 --output0 AssetTexturessor_seasea_radiance --output0params dds,rgba16f,vstrip --output1 AssetTexturessor_seasea_radiance_preview --output1params tga,bgr8,facelist --inputGammaNumerator 2.2 --inputGammaDenominator 1.0

注意命令行最後兩個新加的參數。

然後我們就可以得到如下的圖。注意右側貼圖的顯示,有兩張變暗了。

PBR材質最終渲染效果

最終渲染結果看上去雖然好了很多,但是感覺上依然有些不自然。具體來說,金屬部分的高光反射看起來對比度太低了。這是因為我們使用的天空盒是來源自一張普通的圖片,而普通的圖片所能保存的色階只有256個,但是顯然,陽光的強度與海水反射的強度的差別遠遠不止256倍。所以,對於PBR當中的環境貼圖(天空盒)來說,我們需要使用寬動態範圍(高對比度,HDR)的圖片格式,才能獲得最好的效果。在下一篇我們就來進行這方面的工作。

(呼~終於結束掉本篇了)

參考引用

SIGGRAPH 2010 Course: Physically-Based Shading Models in Film and Game Production?

renderwonk.com

https://learnopengl.com/PBR/Theory?

learnopengl.com

理論 - LearnOpenGL CN?

learnopengl-cn.github.io圖標
推薦閱讀:

PBRT-E2.7-變換(Transformations)
港科大教授權龍:計算機視覺下一步將走向三維重建
UUG北京站2018年7月活動
多邊形的布爾運算-或 與 異或 減法
截取遊戲3D模型並導入Mathematica

TAG:遊戲引擎 | 實時渲染 | 計算機圖形學 |