如何編寫Shadersmod光影包(上)

"我們活着只是為了發現美,其餘的皆是某種形式的等待."
-Kahlil Gibran

時間回到2015年的那個多雨的盛夏,我在頹廢了小半個月後終於掙扎地上線幹活,在給CustomSteve做Shadersmod(即喜聞樂見的光影Mod)的兼容時我了解到了一些Shadersmod的特性,在暑假的最後日子裡我決定把我之前學到的東西和積累到的經驗總結一番,寫成一篇關於如何製作MC光影包的教程,結果這一干就是半年,8月我曾樂觀地認為能在開學前完成教程,而到了9月末我認為在國慶期間可以完成,當時間到了12月時我肯定這篇教程不會拖到明年,但事實上如今當我寫下這段文字時幾年來最冷的寒冬都已經快過去了,幸運的是,如今它終於完成了.

鏈接:下篇 附錄

上篇 - Shadersmod光影包基礎

基礎預備

這個教程需要讀者具有最基本的線性代數知識,至少了解矩陣的特性.這個教程的上篇對讀者能力的最低要求是略微了解OpenGL,至少要知道什麼是紋理,以及了解OpenGL的各個坐標系和頂點變換流程.對GLSL的掌握要求並不高,因為在教程中會循序漸進地介紹各種功能和特性.而對於圖形學知識,上篇的要求並不高,只要了解一些常識性的東西,比如知道屏幕上的畫面是通過光柵化以及像素着色得來的,而不是通過古中國的邪惡蠱術製造的就行了 ☺

為了編寫着色器腳本,讀者還需要一個除Windows記事本以外的任意現代文本編輯器,比如Notepad++.專業的着色器IDE是可選的,由於Shadersmod光影包開發的特殊性質,在IDE中測試着色器不太可行,但其在編譯時的語法檢查可以提高不少開發效率,RenderMonkey是一個不錯的選擇,而ShaderAnalyzer雖然難以勝任編輯器的功能,但它的嚴格語法檢查功能可以用於代碼除錯,這裡強烈推薦安裝一個ShaderAnalyzer,雖然它是為ATI顯卡設計的,但由於它內置了符合ATI規範的GLSL編譯器,因此可以在任何機器上運行.不過它的除錯能力偶爾也有失敗的時候,比如它無法檢測到向vec3變量賦一個vec4值的情況.當你遇到Shadersmod報告着色器錯誤,但卻始終無法找到錯誤原因時,可以按正常順序退出遊戲,然後在遊戲目錄中(如果你是使用第三方啟動器,有可能是在啟動器目錄)的logs文件夾里找到shadersmod.txt,在它的結尾處附近往往記錄了遇到的着色器編譯錯誤(真是納悶為什麼不設計為直接打印在屏幕上).

最後,本教程是在Windows10和Nvidia GTX970的軟硬件環境下撰寫和測試的,在截稿前所有章節的最終代碼已在Win8.1和Win7與Intel HD4400, Nvidia GTX650M和GTX750M中進行了實機測試並在ShaderAnalyzer中通過了針對ATI驅動規範的靜態檢查.理論上講你在Windows環境和A、N、I三家顯卡上都不會遇到問題,然而如果你在OSX或Linux甚至是A卡I卡上遇到了問題的話,可能只有靠自行搜集網上的資料來解決了.

Shadersmod、固定管線與着色器

回想一下當你第一次見到Minecraft光影包時的感覺,是不是就像黑魔法一樣?為何MC原版那麼簡陋的畫面只要安裝上一個Mod就能擁有一個主流遊戲的畫質?它是怎麼繪製陰影和反射的?
一切都要歸功於着色器(Shader),着色器就是決定內存或顯存中的頂點數據和紋理是如何在GPU上正確(或錯誤)地變形、組裝、光柵化成像素並進行着色然後繪製到屏幕上的黑魔法,遙想在過去消費級顯卡尚未出現的年代,編寫軟件渲染器是每一個3D遊戲程序員都必須掌握的事情,在主頻只有數百MHz的CPU上以定點數學編寫渲染器雖然不是愉快的事情,但至少那時的程序員對每一個像素都有絕對的控制權.而當顯卡普及時硬件幾乎完全接管了渲染的處理,只留給了程序員一套API,在一些老的圖形學或遊戲製作相關的書籍中你經常能看到"硬件T&L"這個名詞,它指的就是由硬件實現的頂點變換和光照,也就是所謂的固定管線渲染.硬件加速的渲染固然高效,但卻讓程序員失去了對像素的控制權,開發者只能通過圖形API提供的最簡單的操作,通過搭積木似的拼湊出想要的效果,你還記得在OpenGL1.0規範中甚至不包括紋理嗎?隨着時間的推移,可編程電路逐漸取代了單一功能的電路,在原本由CPU一家獨大的主板上,另一股不容忽視的計算力已經悄然崛起,顯卡已經從只能執行簡單任務的勞工變成了可以處理靈活任務的工程師了,但畢竟各家顯卡的編程規範不同,程序員如果想編寫一個能在大多數主流顯卡(要知道那時候的顯卡商是百花齊放)上運行的着色程序得學習數款顯卡驅動和它們的彙編語言(高級語言?抱歉,沒有),畢竟不是每一個圖形學程序員都能像約翰卡馬克那樣一天適配一款顯卡,業界需要一個統一的標準,這時作為後起之秀的DirectX卻拋下了自詡為業界標準的OpenGL,獨自扛起了可編程着色器的大旗,在2000年的DirectX8.0中提供了統一的彙編語言用於編寫着色器,而在這一關鍵時期OpenGL規範的眾謀特性卻讓它沒能趕上時代步伐,OpenGL規範是由各家(不管是不是做顯卡的)統一協定,這種制度免不了會有各種扯皮,結果是直到2004年9月它才提供了一個可用的着色器語言 - GLSL(OpenGL着色語言),不過相比它的老冤家DirectX來說倒也不算太糟,畢竟DirectX當初提供的是彙編語言,而OpenGL提供的是一個C-like的高級語言,相比之下DirectX到了2004年11月才做出了同為高級語言的HLSL(高級着色語言).着色器語言用於編寫一個着色器程序,經由驅動編譯成顯卡能執行的彙編語言後傳入顯卡,替代原本的固定管線,以此讓開發者得以控制渲染的方式.
Minecraft原版就是採用固定管線進行渲染(據說在1.9要換成等效的着色器形式實現,其實現在顯卡中已經不存在固定管線了,所謂的固定管線實際上是一組能實現等效功能的預置着色器),而Shadersmod就是替換了渲染程序,將它們替換成了可編程的GLSL腳本,並交給光影包開發者來開發,當在Shadersmod中載入一個光影包時,內部流程其實就是載入GLSL腳本並交給OpenGL編譯,然後替換掉原來的固定管線,將所有的渲染都交給開發者編寫的着色器程序來處理.
然而,畢竟Minecraft本身是為實現簡單效果的固定管線而設計,有些效果即使是在可編程的着色器中也難以直接實現,為了實現這些複雜的效果,Shadersmod曲線救國,提供了延遲渲染的支持.

何為延遲渲染

Shadersmod為MC引入了延遲渲染(Deferred Rendering)的支持,什麼是延遲渲染?這要從最早的光照技術說起,在早期的固定渲染管線時代,光照多採用逐頂點光照,即光照的計算僅在頂點變換時進行,像素的光照效果通過頂點間插值來實現.到了可編程着色器時代,光照開始逐漸採用逐像素光照,光照計算會在每一個像素上進行.到目前為止,這些技術都還屬於前向渲染,所有的光照和着色計算都在正常渲染中完成.
然而頂點光照和像素光照都面臨一個問題,就是光照的性能消耗跟繪製物體的數量與光源數量之積成正比,每一個物體在渲染時都要計算與每一個光源的光照效果,更遺憾的是其大多都是無用功,因為深度測試是在像素着色之後進行,很多不可見的像素佔用了大多數的計算力,一些技術比如Early Z-Test和Z-PrePass便是為了預剔除不可見像素而努力.
這時一個奇特的技術:延遲着色出現了,延遲着色是在首遍渲染時不進行着色,僅將物體的幾何信息(比如位置與法線)和材質信息全部輸出到被稱為G-Buffer的緩衝區,然後在第二遍渲染時按照後處理的方式根據G-Buffer渲染出最終圖像,由於G-Buffer中的各像素一定是可見的,因此它保證了我們能將寶貴的計算力用在刀刃上,渲染的複雜度也由之前的O(M*N)變成了O(M+N);此外還有一些技術比如延遲光照(通過三遍渲染來降低G-Buffer的帶寬壓力)甚至是實驗性的延遲貼圖(Deferred Texturing...有興趣的可以谷歌一下),不過這些都與我們無緣了,因為Shadersmod只支持延遲着色...無論如何,這些渲染方法都被統稱為延遲渲染,因為它們將部分或全部着色推遲到第二遍或第三遍渲染時進行.
此外還要糾正個誤區,就是Shadersmod強迫所有光影包使用延遲渲染,其實你完全可以使用前向渲染,只要將所有着色過程放在繪製G-Buffer的片元着色器即可.
(最後還有個小吐槽:延遲渲染對Minecraft來說真的有必要嗎?我覺得用途不大,延遲渲染的意義在於處理巨大數量的光源,如果它Shadersmod真的支持動態光源,徹底一腳踢開遊戲的光照系統,完全實現動態光源的話,那延遲渲染是完全有必要的,但問題是它現在並不支持,因此它的意義只局限在能在G-Buffer中收集足夠的幾何信息,在着色階段渲染出原本缺乏足夠信息繪製不出來的效果)

Karyonix says hello to you - 初識光影包

==================================================
知識點:光影包的組成

一個光影包由4部分組成:G-Buffer繪製,陰影貼圖繪製,後處理以及最終合成.

  • G-Buffer繪製:此部分的着色器用於將遊戲中的物體繪製在G-Buffer當中,該部分所有的着色器的名稱均以"gbuffers_"為前綴.具體為:
    gbuffers_basic:繪製無紋理的純顏色,目前只有在繪製天空時會少量用到它...
    gbuffers_skybasic:繪製無紋理的天空物體,它與basic唯一的差別是basic不受霧影響,skybasic受霧影響.
    gbuffers_skytextured:繪製有紋理的天空物體,比如太陽和月亮
    gbuffers_textured:繪製不受光照影響的紋理
    gbuffers_textured_lit:繪製受光照影響的紋理
    gbuffers_entities:繪製實體
    gbuffers_hand:第一人稱下繪製手
    gbuffers_terrain:繪製磚塊
    gbuffers_water:繪製半透明物體(不包括純透明和不透明相間的物體,比如樹葉),比如水,染色玻璃(不包括無色的玻璃),染色玻璃板(不包括無色的玻璃板)和冰(不包括浮冰).
    gbuffers_weather:繪製天氣效果,如下雨和下雪
    gbuffers_spidereyes:唔...似乎是觀眾模式下附體到蜘蛛身上時的蜘蛛視覺效果?
    G-Buffer繪製採用了後備鏈制度,每種着色器均會有自己的後備着色器,如果這種着色器不存在的話,Shadersmod會沿着後備鏈向後搜索可用的着色器作為替補,如果實在找不到的話,則會使用OpenGL默認的固定管線進行渲染,後備鏈如下圖所示:
    ShaderModBackup
    圖左側的None即代表OpenGL默認的固定管線渲染,除此之外的任何着色器在不存在時,Shadersmod會從它左方第一個着色器開始,依次向左查找,直到找到一個可用的着色器,或者發現沒有着色器可用為止.
    舉例,如果gbuffers_water不存在,而gbuffers_terrain存在的話,Shadersmod在繪製水體時會用gbuffers_terrain着色器來渲染.如果gbuffers_hand不存在的話,Shadersmod會去查找gbuffers_textured_lit,如果還找不到的話就去查找gbuffers_textured,然後是gbuffers_basic.
  • Shadow Map繪製:Shadow Map繪製其實是發生在G-Buffer繪製之前,不過在這裡我顛倒了寫作順序.Shadow Map即陰影貼圖,用於Shadow Mapping - 一種繪製陰影的方法,具體內容會在後文介紹.這一項是可選的,如果沒有的話,系統會自動為你生成一個.
  • 後處理:後處理包括composite和composite1~9這10個着色器,它們都不是必須存在,當它們存在時,Shadersmod會按照數字順序(composite視為0)依次調用.在後處理階段,所有必須的幾何信息都已在G-Buffer繪製階段整理完畢,着色器可以在此階段修改G-Buffer(比如調整現有的顏色),甚至是寫入新內容(比如渲染Bloom特效時需要一塊額外的緩衝).
  • 最終合成:此階段的着色器名稱為final,這個着色器和後處理着色器沒有太多區別,唯一差別是它必須根據G-Buffer信息繪製出最終圖像並輸出.這個着色器也是可選的,如果它不存在的話,Shadersmod會將幀緩衝上的0號顏色緩衝的內容直接轉繪到最終圖像上.

每一個着色器均由頂點着色器(後綴名.vsh)和片元着色器(後綴名.fsh,其實就是像素着色器啦...)組成.所有的着色器文件均放在"[minecraft目錄]/shaderpacks/[光影包名稱]/shaders/"目錄下.
===================================================

Make a Shader Pack from scratch - 從零實現固定管線

實現一個固定管線相當無聊,如果你已經對GLSL有一定了解的話,可以跳過這部分,此部分的最終結果可以從這裡下載: [SkyDrive] [百度網盤]

==================================================
知識點:GLSL簡單介紹

這裡會簡單介紹一下GLSL,已經熟悉它的人就可以跳過這塊了.

GLSL是OpenGL使用的着色器語言,它是一個C-like語言,語法和C極為相似,區別在於:

  • 渲染用的着色器是由兩部分組成,用於控制頂點變換的頂點着色器和用於控制像素着色的片元着色器.
  • 使用#version XXX來聲明版本,比如#version 120為使用1.2版GLSL規範.
  • 數據類型有限,沒有指針,在早期版本(比如1.1,1.2)中標量只有float,int和bool,後期版本加入了uint,double.
  • 由於沒有內存分配和指針,所有數組必須是定長數組.
  • 有矢量數據類型,比如vec2、vec3和vec4分別是二三四維float向量,mat2、mat3和mat4分別是2x2,3x3,4x4 float矩陣,matNxM為N*M矩陣,最大為4x4.對於向量類型,可以通過.xyzw、.rgba或.stpq來訪問分量,這三種寫法沒有實質差別,比如對一個vec4變量somevec,somevec.xyz和somevec.rgb都是抽取前三個分量作為一個vec3,somevec.w和somevec.a都是取最後一個分量作為float變量,這三種寫法純粹是供人類閱讀方便的,但是不可以混合使用,比如somevec.xgpw就是找打的寫法.
  • 有採樣源類型,比如sampler2D就是一個2D紋理.
  • 有特殊的變量修飾符,比如attribute頂點變量,在頂點着色器中可用,每個頂點均有它獨特的值;uniform一致變量,任何着色器均可用,用於存儲常量,在一次渲染(即一次DrawCall)中uniform可以視為常量;varying可插值傳遞變量,由頂點着色器傳遞給片元着色器的變量,當一個三角形上不同頂點間的輸出值不同時,會被插值然後傳遞給其上的像素.
  • 特殊的形參修飾符,in輸入參數,也是什麼都不寫時的默認選項,只可讀不可寫(wiki上說的是可寫但結果不會影響實參,就像C那樣,但在我這裡有時可以這樣做,有時卻不行...鬧不明白);out輸出參數,函數可以向這個參數輸出值,結果會被反饋給實參,以此可以實現多輸出函數;inout引用參數,即可寫又可讀,結果會被反饋給實參.
  • 對int和相關的整數操作與位運算的支持到1.3才出現.
  • 沒有字符串

共同點也是有不少的:

  • 幾乎一摸一樣的語法,除了沒有字符串和指針以外.
  • 同樣要求函數聲明必須在調用之前.
  • 都支持預處理器,格式相同,包括讓編譯器程序員吐血的多行預處理.
  • 支持結構體.

想更多了解GLSL的話,可以看一下官方wiki https://www.opengl.org/wiki/Core_Language_(GLSL) 以及上網搜一些教程啥的.
==================================================

首先我們在shaderpacks目錄下新建一個文件夾,名字就叫MyFirstShader吧,然後在裡面創建一個叫shaders的文件夾.

shadersmod1

然後打開遊戲,此時你就已經可以在菜單中選擇你的光影包了!

shadersmod3

但是打開之後你會發現並沒有什麼卵用!遊戲依然還是原來的樣子,這是因為正如上文所言,G-Buffer繪製由於有後備鏈的存在,在這裡全都已經被默認管線取代其功能了,由於ShadowMap沒有使用,因此ShadowMap繪製被跳過了,後處理由於沒有相關的着色器被直接跳過,最終合成由於沒有着色器也被自動處理了.因此自然遊戲效果和原來沒有什麼區別.

現在我們先來實現第一個G-Buffer繪製着色器:gbuffers_basic

在shaders目錄下創建2個文件:gbuffers_basic.vsh和gbuffers_basic.fsh,在裡面輸入:

gbuffers_basic.vsh:

#version 120

varying vec4 color;

void main() {
	gl_Position = ftransform();
	color = gl_Color;
}

gbuffers_basic.fsh:

#version 120

varying vec4 color;

void main() {
	gl_FragData[0] = color;
}

開頭的#version 120是用來聲明GLSL版本,如果你熟悉OpenGL的話,你應該會知道它出了名的平台差異性,不同的顯卡甚至同一種顯卡的不同版本驅動對GLSL着色器的處理方式都不盡相同,如果不聲明版本的話,有些OpenGL實現會將着色器按照當前平台所支持的最高版本來對待(具體還要分為是按核心版本對待還是兼容版本),有些則會按照最低版本對待,這意味着你的光影包可能在某些顯卡上跑的很溜,某些顯卡上就跪成椛,因此為謹慎起見這裡我們主動聲明了GLSL版本為1.2,即#version 120. GLSL1.2為OpenGL2.1所使用的版本,能運行Shadersmod的機器肯定是支持OpenGL2.1的.其實這裡使用GLSL1.2完全是為了照顧OSX,Minecraft在OSX上只能使用OpenGL2.1...
然後是varying vec4 color,熟悉低版本GLSL的人都會知道只是聲明一個能從頂點着色器傳遞到片元着色器的可插值變量,在高版本中我們使用in和out來表示傳入和傳出...不多說了,那些皋大上的東西不是我們這些用1.2的土鱉需要考慮的.
之後是頂點着色器中的gl_Position = ftransform(),這也是低版本GLSL特有的一個福利 - 全自動的MVP(Model, View, Projection)變換,由於在這裡我們還不需要計算其它特殊的信息,因此就先使用系統自帶的變換了.
最後是片元着色器中的gl_FragData[0] = color,對沒使用過MRT的人來說,gl_FragData是個奇怪的東西,MRT即Multi Render Target,指同時向多個緩衝區輸出數據,在不啟用MRT的時候,我們都是使用gl_FragColor向單個緩衝輸出像素顏色.在啟用MRT後,我們通過將gl_FragData按照數組的形式來操作,來向幀緩衝中的多個顏色附件輸出像素顏色.

==================================================
知識點:幀緩衝

這一部分是面向不了解幀緩衝的人,如果你已經了解相關的內容的話可以跳過.

幀緩衝有很多意思,在Linux上它是一個簡單的繪圖接口,在硬件上它是輸出到顯示設備前的一個圖像緩衝區,而在圖形API中,它指的是一個渲染對象,在沒有幀緩衝之前,OpenGL不支持離屏渲染,所有的繪圖操作的最終輸出對象都是屏幕或屏幕的緩衝區,而在有了幀緩衝之後,OpenGL允許開發者在渲染前綁定上一個幀緩衝,在完成綁定之後,所有的繪圖操作的結果都會輸出到該幀緩衝上.

幀緩衝還有一個巨大的優勢是允許掛載多個附件,眾所周知,OpenGL的屏幕緩衝所使用的像素格式在啟動時就已經決定了,無法輕易改變,而幀緩衝通過附件的形式,允許開發者隨意掛載複數個多種格式的緩衝附件,附件包括顏色緩衝、深度緩衝、模板緩衝和深度模板緩衝這四種,顏色緩衝又根據需求分為RenderBuffer和Texture兩種,這裡Shadersmod使用的是Texture,也就是紋理,它的好處是既能當做渲染對象,又能作為紋理被採樣.
==================================================

之後保存遊戲,在遊戲里將光影包切換回(none)或(internal),然後再切換回你的光影包. (能夠隨時重新加載着色器是一件很方便的事,這也是通過在代碼中硬編碼來控制的固定管線所無法企及的)

2015-08-24_01.59.11

這次真成純色的大色塊了...如果你沒裝其它的什麼人物模型Mod的話,此時你的人物應該是幾個白色幾何體...除了沒有紋理外,嘗試往深處挖一挖,或者乾脆將時間跳到晚上,你會發現周圍依然清晰如白晝,顯然現在我們還沒有光照效果.

那麼接下來我們開始製作gbuffers_textured,實現了這個後,我們就至少有了紋理效果了.

複製一份gbuffers_basic(包括它的2個着色器文件),然後改名為gbuffers_textured.vsh/fsh,之後修改它們的內容.

gbuffers_textured.vsh:

#version 120

varying vec4 color;
varying vec4 texcoord;

void main() {
	gl_Position = ftransform();
	color = gl_Color;
	texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0;
}

gbuffers_textured.fsh:

#version 120

uniform sampler2D texture;

varying vec4 color;
varying vec4 texcoord;

void main() {
	gl_FragData[0] = texture2D(texture, texcoord.st) * color;
}

與basic相比,textured的頂點着色器變量多了個texcoord,用於表示輸出到片元着色器的紋理坐標;主函數中多了個texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0, gl_MultiTexCoord0表示在啟用多重紋理時的0號TextureUnit的坐標,gl_TextureMatrix則是OpenGL中最容易被忽視的矩陣:紋理坐標矩陣,在固定管線時代,它可以實現一些特殊效果,比如被雷劈後的充能苦力帕身上的能量罩的流動效果就是通過紋理坐標矩陣來實現的.
片元階段則是多了主紋理的採樣器texture,在主函數中,我們根據紋理坐標,從採樣器中獲得紋理的顏色,然後再乘以自帶的顏色來獲得最終顏色.

再進入遊戲重新加載一遍試試,現在已經有紋理效果了,但依然沒有光照.

shadersmod5

那麼接下來我們來實現光照效果,繼續複製一份gbuffers_textured,改名為gbuffers_textured_lit.vsh/fsh.內容為:

gbuffers_textured_lit.vsh:

#version 120

varying vec4 color;
varying vec4 texcoord;
varying vec4 lmcoord;

void main()
{
	gl_Position = ftransform();
	color = gl_Color;
	texcoord = gl_TextureMatrix[0] * gl_MultiTexCoord0;
	lmcoord = gl_TextureMatrix[1] * gl_MultiTexCoord1;
}

gbuffers_textured_lit.fsh:

#version 120

uniform sampler2D texture;
uniform sampler2D lightmap;

varying vec4 color;
varying vec4 texcoord;
varying vec4 lmcoord;

void main() {
	gl_FragData[0] = texture2D(texture, texcoord.st) * texture2D(lightmap, lmcoord.st) * color;
}

這一次我們又引入了新東西:頂點着色階段的lmcoord,代表在光度圖(Lightmap,Minecraft每隔一段時間生成一個光度圖,用來表示一個物體在指定光照環境下的亮度)中的坐標,s(x)軸對應人造光源強度,t(y)軸對應環境光源強度.片元階段則是多了個乘光照顏色.

再次進入遊戲測試,此時我們有了光照效果了,但不覺得有什麼不對勁? Yep,沒有霧效果.

shadersmod6

關於是否實現霧效果我挺糾結的,因為那麼多光影包沒有一個實現霧效果的...而且Shadersmod對霧效果還有Bug,具體可以見附錄,但畢竟我們這裡是要用着色器實現硬件管線,因此還是加上霧的渲染吧.

處理霧渲染的一個棘手之處在於並非每一種物體都受霧影響,因此需要區分對待,這裡總結出了一個規律:

ShaderModFog
右邊的我就懶得寫了...按後備鏈規則,顯然它們都是受霧影響的.這張圖有一個問題,就是最初我只考慮了線性狀霧(Minecraft大部分時間你所看到的霧),而沒有考慮指數狀霧(在水下和下界看到的),因此稱gbuffers_skybasic只有在fogMode不為0時才會有霧,事實上如果嚴格按照fogMode的定義來的話,根本無需區分哪個有霧哪個沒霧...

於是我們就開始按照這個規律開始修改了,首先是處理gbuffers_skytextured,它與gbuffers_textured的唯一區別是前者不受霧影響而後者受,因此我們首先直接複製一份gbuffers_textured,然後將它改名為gbuffers_skytextured.vsh/fsh.

shadersmod7

接下來我們修改gbuffers_textured,OpenGL霧的計算規則分為線性,指數狀和指數平方狀這三種,不過Minecraft只使用了線性霧,因此這裡我們就只實現線性霧就行了.噫,其實還是用到指數狀了,因此我們要實現線性和指數狀兩種.

修改gbuffers_textured.vsh,將

gl_Position = ftransform();

修改為

vec4 position = gl_ModelViewMatrix * gl_Vertex;
gl_Position = gl_ProjectionMatrix * position;
gl_FogFragCoord = length(position.xyz);

然後在gbuffers_textured.fsh中添加:

uniform int fogMode;

然後在"gl_FragData[0] = texture2D(texture, texcoord.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));

回顧一下我們幹了什麼,首先我們拆開了ftransform,將原來的MVP變換一條龍拆成了模型視圖變換(MV)和投影(P)變換,這是因為霧坐標計算(其實不止霧計算,很多和光照有關的計算也是如此)是發生在MV變換後,P變換之前.在線性規則中霧坐標的計算很簡單,就是計算該點到視覺原點的距離,(這裡再補充一遍MVP變換的知識,我們在OpenGL中繪製的模型,頂點是被放置到模型坐標系中,在經過模型變換後到了世界坐標系;然後經過視圖變換後到了眼坐標系;最後經過投影變換到了剪裁坐標系,此時頂點便脫離了可編程着色管線,要經由固定管線來處理了.)由於我們是在眼坐標系下,視覺原點就在坐標原點,因此直接計算該點到原點的距離就行了.
然後我們在片元着色器中根據霧類型(9729為線性霧,2048為指數霧,具體定義見附錄)和其它參數,計算出了霧的濃度,最後對原顏色和霧一起進行混合.

於是按照這個規則如法炮製,複製一份gbuffers_basic,將其改名為gbuffers_skybasic.vsh/fsh, gbuffers_skybasic.vsh的修改同上文的gbuffers_textured.vsh.

最後將gbuffers_textured_lit也按照gbuffers_textured的方法進行修改,這樣我們就獲得了一個和固定着色管線功能一模一樣的光影包,Mojang要到MC1.9才能實現的功能我們現在不到一小時就實現了! (迫真裝逼)

shadersmod8

頂點着色器實戰 - 草的搖擺效果

這一次我們來嘗試寫第一個光影包新功能:草的搖擺效果.

顯然草的搖擺效果是通過頂點着色器來實現,草的渲染是通過讓2個正方形以十字交叉的樣子渲染在遊戲中來實現,我們只要讓那2個正方形上方的頂點向一個方向移動就能讓草看上去在擺動.

shadersmod9
shadersmod10
圖:通過移動頂點讓矩形進行切變. 紋理源自Nickm77的EPR材質包.

首先我們複製一份gbuffers_textured_lit,將其改名為gbuffers_terrain.vsh/fsh,之後準備修改gbuffers_terrain.vsh.

首先我們要解決的是如何判斷當前磚塊是草,Shadersmod提供了一個頂點屬性mc_Entity,它是一個vec4變量,在用來繪製磚塊時,X值代表當前磚塊的磚塊ID,Y值代表其渲染類型(RenderType)(注:我不確定這個值在1.8還是否存在,因為1.8大改了渲染系統,似乎去掉了RenderType了),Z值代表Metadata,不過雖然這些值按說都是整數值,但Shadersmod卻是轉換成浮點值傳過來的.

然後要解決的是如何判斷頂點是上面的還是下面的,畢竟草的根部是不動的.由於Minecraft在渲染地形時是按16x16x16為一組的方式來渲染,因此在模型坐標系中判斷頂點的高度(Y坐標)是不管用的;因此這裡我們通過判斷頂點的紋理坐標的方式來判斷頂點位置.
然而這裡還有2個問題需要解決,首先,Minecraft為了提高渲染效率,在內部會將磚塊紋理拼接成一張大的紋理(其實老開發者都知道早期的MC(大概是什麼時候來的?1.5以前?還是1.6以前?)就是一張大紋理包含全部的磚塊和物品貼圖,現在的MC只不過是為了方便開發者,將拼接紋理的工作放在遊戲里進行了),這意味着它的紋理坐標不再是(0, 0) (0, 1) (1, 0) 或(1, 1)的形式,而可能是位於0~1之間的任何值,這導致我們沒法判斷紋理坐標的位置.不過好在Shadersmod提供了一個vec4定點變量mc_midTexCoord(這裡不得不吐槽Shadersmod的Wiki文檔的糟糕,mc_midTexCoord的作用,以及mc_Entity的Y值和Z值的作用,都完全沒有提及,是我在論壇上的帖子里從作者的留言中發現的...看來想完全搞懂Shadersmod得先像讀臘肉語錄那樣把作者的發言全看一遍)來輔助我們判斷坐標,mc_midTexCoord的st值代表紋理中當前待繪製區域的中點位置,這樣通過對比紋理坐標的T值和mc_midTexCoord的T值就能判斷出頂點是頂部的還是底部的.
另一個問題則是MC中的紋理(僅限MC的紋理,不包括幀緩衝的各個附件)是上下顛倒的,或許這是為了在操作上迎合DirectX風格的紋理坐標系,在大部分GUI程序中,坐標系都是規定窗口左上角為原點,T軸(也就是Y軸,在紋理操作上我們習慣用ST指代XY,當然這只是一種約定俗成的習慣,對GLSL來說st和xy沒什麼區別)向下為正,DirectX也是這樣規定的紋理坐標系,以紋理左上角為原點.然而OpenGL規定紋理坐標系和其它坐標系一樣,採用T軸朝上為正的設定,這意味着原點位於左下角,這種設計雖然做到了坐標系的操作統一,但確實有違於很多人的習慣,因此這裡有一種曲線救國的方式,就是在傳入紋理時直接把紋理上下顛倒一下,此時原點依然在紋理左下,T軸也依然朝上為正,但是在操作時卻可以認為是原點在左上角而T軸朝下為正.
(注:這裡最初有個烏龍,寫的是OpenGL本來就以T軸朝下為正,事實上這是錯誤的,發布前想着把這段改過來,結果居然忘了!)
stc1
圖:MC的紋理的樣子.
stc2
圖:如果想象成紋理未顛倒但T軸向下的話,就變成正常的紋理了.
在操作的時候,你可以想象成原點在左上角,T軸朝下為正,右下角為(1, 1),因此在判斷坐標時紋理坐標的T值小於中心點的T值(即靠近上方)代表此頂點對應着草的高處.這個問題其實也是有一定的歷史背景,具體可以見"附錄 - ST坐標系,朝上還是朝下?"

最後要解決的是如何讓草擺動 - 噫,前文不是已經說過是通過修改頂點位置了嗎?但那隻能讓草看起來像彎了一樣,但依然是靜止不動的,如果要讓它看上去是在擺動,那就需要讓它的位置不斷變化.Shadersmod為我們提供了兩個代表時間的一致變量(uniform),一個是int格式的worldTime,代表當前世界的時間,範圍在[0, 24000);另一個是float格式的frameTimeCounter,它是以秒為單位計算遊戲正式啟動(即載入了一個存檔/加入了一個服務器)後的時間,這個變量的範圍是[0.0, 100000.0),即每100000秒它便會歸零一次,用來避免浮點誤差,這個時間約合於27.78小時,顯然大於大部分人的Minecraft在不崩潰前所能運行的時間,因此這並不會成為導致運維人員失眠的原因,況且即使它真的歸零了...也不會有什麼大的影響,不過是那些像sin(frameTimeCounter)這樣依賴它的算法產生一個一瞬間的不連續而已.不過需要注意的是,這個值不會因為遊戲暫停而停止改變...畢竟即使當遊戲暫停時,畫面也是在渲染的.(順便再婊一下Shadersmod的文檔,根本沒有提frameTimeCounter這個東西)

探究完可行性後我們就可以開始實際編碼了,首先在gbuffers_terrain.vsh中添加這幾個變量:

uniform float frameTimeCounter;
attribute vec4 mc_Entity;
attribute vec4 mc_midTexCoord;

然後將

vec4 position = gl_ModelViewMatrix * gl_Vertex;

改成

vec4 position = gl_Vertex;
float blockId = mc_Entity.x;
if((blockId == 31.0 || blockId == 37.0 || blockId == 38.0) && gl_MultiTexCoord0.t < mc_midTexCoord.t)
{
	float time = frameTimeCounter * 3.0;
	position.x += sin(time) * 0.2;
	position.z += sin(time) * 0.2;
}
position = gl_ModelViewMatrix * position;

首先,我們拆分了position的計算,將獲取頂點在模型坐標系中的位置(即獲取gl_Vertex變量)和MV變換拆成了兩步,因為我們接下來的操作要在模型坐標系中進行.
然後我們獲取了磚塊ID並判斷它的具體類型,這裡31是草的ID,37和38是蒲公英和花的ID.此外還根據紋理坐標來判斷是否為草的頂部.

現在再來測試一下,現在遊戲里的草都像磕了葯一樣,齊刷刷地朝同一方向來回擺動...

shadersmod11

那麼接下來我們給它們整點多樣性,讓不同位置的草朝不同方向擺動如何?簡單的解決方案是把XYZ坐標什麼的代入公式,讓影響最終結果的變量有不止一個,不過在這裡我們會嘗試使用一個更皋大上的東西:噪聲圖
噪聲圖是一組預先生成的隨機數,這些隨機數的範圍在0.0~1.0之間,每個數的最小間隔是1/255(好吧,按人話說就是每個隨機數剛開始被生成在0~255之間,然後被除以255.0,歸一到0.0~1.0之間),這些數3個一組,被打包在一個紋理中,在需要隨機數的時候,系統就不必再去生成,而是直接從噪聲圖中抽一組隨機數來使用.
要啟用噪聲圖,首先要在一個片元着色器中(隨便哪個片元着色器都行,但最好只有一個,因此建議在final或composite中的一個)中加入const int noiseTextureResolution = X,其中X為噪聲圖分辨率,分辨率越高則包含的隨機數越多,重複越少,但我認為256就足夠了.

接下來,先在gbuffers_terrain.fsh中加入:

const int noiseTextureResolution = 256;

然後在gbuffers_terrain.vsh中添加:

uniform sampler2D noisetex;

之後修改我們原來的代碼:

vec4 position = gl_Vertex;
float blockId = mc_Entity.x;
if((blockId == 31.0 || blockId == 37.0 || blockId == 38.0) && gl_MultiTexCoord0.t < mc_midTexCoord.t)
{
	vec3 noise = texture2D(noisetex, position.xz / 256.0).rgb;
	float time = frameTimeCounter * 3.0;
	position.x += sin(noise.x * 10.0 + time) * 0.2;
	position.z += sin(noise.y * 10.0 + time) * 0.2;
}
position = gl_ModelViewMatrix * position;

(注:就實用性而言,使用XYZ坐標作為擾動因素其實是性價比最高的,它既能保證質量又足夠快速,噪聲圖會涉及到紋理採樣,速度要慢一些,只不過數據量太小體現不出來而已. 這裡用到噪聲圖僅僅只是為了演示它的使用方法.)

這一次改進後,遊戲中的花和草已經會向不同的方向擺動了,但如果總是擺動的話會顯得有些不自然.而且我們希望它們在雨中擺動的更猛烈一些,我們可以使用float類型的一致變量rainStrength來實現這個,rainStrength代表雨或雪的強度,默認情況下它是0,隨着雨或雪的增大會逐漸增加,直到1.0為止,雨和雷暴都是這個值.但似乎在停止時有些問題,它不會逐漸減小,而是繼續逐漸增大,然後在雨停的一瞬間驟然變為0,不過在大部分情況下,它工作的還是很好的.

這一次我們在gbuffers_terrain.vsh中加入:

uniform float rainStrength;

然後繼續修改之前的代碼:

vec4 position = gl_Vertex;
float blockId = mc_Entity.x;
if((blockId == 31.0 || blockId == 37.0 || blockId == 38.0) && gl_MultiTexCoord0.t < mc_midTexCoord.t)
{
	float blockId = mc_Entity.x;
	vec3 noise = texture2D(noisetex, position.xz / 256.0).rgb;
	float maxStrength = 1.0 + rainStrength * 0.5;
	float time = frameTimeCounter * 3.0;
	float reset = cos(noise.z * 10.0 + time * 0.1);
	reset = max( reset * reset, max(rainStrength, 0.1));
	position.x += sin(noise.x * 10.0 + time) * 0.2 * reset * maxStrength;
	position.z += sin(noise.y * 10.0 + time) * 0.2 * reset * maxStrength;
}
position = gl_ModelViewMatrix * position;

這一次我們引入了最大強度(maxStrength),在默認強度下,它是1.0,當下起雨時,它會逐漸加大,最終導致花草搖擺的幅度更大;reset也被用來限定擺動幅度,用來模擬草搖擺的幅度忽大忽小,畢竟無論什麼時候都在死了命地擺總會顯得有些奇怪,不過當下雨時是不會停止擺動的.

2015-08-25_02.23.16

2015-08-25_02.23.34

花草改進的餘地還有很多,比如採用更好的算法,這裡就不一一贅述了,最後我們來探究一下樹葉和兩格高度的草的擺動.

樹葉的渲染和正常磚塊一樣,而且樹葉不區分上下,任何一個頂點都可以擺動,但難點在於如何讓多個鄰近的樹葉整體擺動,而不讓它們撕裂開,如果直接套用草擺動的算法,則每個樹葉都會各自為戰地按自己的方向擺動,最終結果是整個樹看上去被撕裂開一樣.下面給出了樹葉和高草搖擺的代碼.值得一提的是其中的"(position.xz + 0.5) / 16.0",除以16是因為MC以16x16x16為一組來渲染,這意味着在世界坐標系中位置相同的兩個頂點在模型坐標系中位置差了整整16個單位,不過好在噪聲圖的邊緣處理模式是Repeat,這意味着當取樣點超過一個邊時,會重新落回到對面邊上,即一個位於(1.1, 0.4)的取樣點實際取到的是(0.1, 0.4)上的顏色,因此我們通過除以16而不是15或256,從而讓最邊沿的點重新落回對邊;而加0.5則是為蔓藤準備的,這個值一定意義上是湊出來的...目的是讓蔓藤的擺動更貼近樹葉的擺動.

if(mc_Entity.x == 18.0 || mc_Entity.x == 106.0 || mc_Entity.x == 161.0 || mc_Entity.x == 175.0)  //如果緊接上面的if塊的話,這裡可以改成else if
{
	vec3 noise = texture2D(noisetex, (position.xz + 0.5) / 16.0).rgb;
	float maxStrength = 1.0 + rainStrength * 0.5;
	float time = frameTimeCounter * 3.0;
	float reset = cos(noise.z * 10.0 + time * 0.1);
	reset = max( reset * reset, max(rainStrength, 0.1));
	position.x += sin(noise.x * 10.0 + time) * 0.07 * reset * maxStrength;
	position.z += sin(noise.y * 10.0 + time) * 0.07 * reset * maxStrength;
}

此部分的最終結果可以從這下載: [SkyDrive] [百度網盤]

片元着色器實戰 - 後處理階段的陰影效果

接下來我們寫一個後處理階段的片元着色器,這個片元着色器通過Shadow Mapping來實現陰影效果.

==================================================
知識點:Shadow Mapping

Shadow Mapping(陰影映射)是一種實時陰影技術,它的思路就是:"判斷空間中一點是否處於陰影之中,就是判斷從光源位置能否看見該點",說起來很簡單,但該如何實現判斷點對光源的可見性?
Shadow Mapping的策略是先從光源位置進行一次渲染,渲染後得到的深度緩衝被稱為Shadow Map,之後從觀察者位置進行正常渲染,正常渲染時根據像素所對應的點在空間中的位置,計算點到光源的距離,之後判斷這個距離是否超過離光源最近的點的距離,超過即這個點位於固體之後,無法被光源所照到,是處於陰影之中.
大體的思路已經有了,接下來要解決的是技術細節,首先是在光源位置進行繪製Shadow Map時,鏡頭朝向何處,對於向太陽光這樣的平行光源來說,通常是朝向觀察者(玩家)的位置,這樣能保證觀察者周圍的景物都被繪製進Shadow Map.
然後是在正常渲染時如何確定像素所對應的點,對於前向著色,我們可以根據gl_FragCoord和MVP矩陣的逆矩陣來計算出其在模型坐標系中的位置,或者乾脆直接將頂點位置通過varying變量從頂點着色器傳遞給片元着色器,讓OpenGL自己去插值去.對於延遲着色,我們可以根據深度緩衝和MVP矩陣的逆矩陣來計算出它的位置,或者直接將它的位置在G-Buffer繪製階段保存在G-Buffer中,這裡我們使用的是前者,通過深度緩衝來重建其位置信息.
最後是如何計算點到光源的距離,以及判斷它是否距離光源足夠近,可以被照亮.在已有片元位置的情況下,我們可以直接把它的坐標乘以渲染Shadow Map時的MVP矩陣,相當於將這個點放到光源鏡頭中重新做一次頂點變換,然後判斷變換後的坐標落在哪裡,計算它的深度值,之後根據坐標取出Shadow Map上相同位置的深度值,通過對比兩者的深度,來判斷這個點對光源是否可見.
==================================================

首先我們先創建composite.vsh和composite.fsh兩個文件,然後為其添加內容,在composite.vsh中添加:

#version 120

varying vec4 texcoord;

void main() {
	gl_Position = ftransform();
	texcoord = gl_MultiTexCoord0;
}

在composite.fsh中添加:

#version 120

uniform sampler2D gcolor;

varying vec4 texcoord;

void main() {
/* DRAWBUFFERS:0 */
	gl_FragData[0] = texture2D(gcolor, texcoord.st);
}

這些代碼沒有什麼新意,只是多了個DRAWBUFFERS,它的含義我們要之後再解釋,目前先照葫蘆畫瓢抄上即可,不過需要注意的是"/* DRAWBUFFERS:0 */"的前面不能有縮進!後面也不能有多餘的東西!"DRAWBUFFERS:0"前後的倆個空格不要刪掉!
之後進遊戲重新加載一遍光影包測試一下,如果沒問題的話,現在的效果和之前不會有任何變化,因為這個着色器所做的處理僅僅是把G-Buffer中的幀緩衝中的0號紋理的內容重新轉繪一遍,沒有做任何其他的事情,接下來我們要實現一個最簡單的Shadow Mapping,首先在composite.fsh中加入這些變量:

uniform mat4 gbufferProjectionInverse;
uniform mat4 gbufferModelViewInverse;
uniform mat4 shadowModelView;
uniform mat4 shadowProjection;
uniform sampler2D shadow;
uniform sampler2D depthtex0;

shadow是Shadow Map;depthtex0是繪製G-Buffer時的深度緩衝;gbufferProjectionInverse和gbufferModelViewInverse可以將位於裁剪坐標系中的坐標變換到"光照坐標系",這個坐標系不是OpenGL坐標系的一部分,它類似世界坐標系,但是它的原點位於玩家的位置.一個光照坐標系中的坐標乘以shadowModelView和shadowProjection就能獲得該點在Shadow Map中的位置.

在composite.fsh的main函數開頭添加:

float depth = texture2D(depthtex0, texcoord.st).x;
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;
vec4 worldPosition = gbufferModelViewInverse * viewPosition;

首先我們取出該像素的深度,然後用該像素在屏幕空間的位置以及深度信息重建出了它的NDC坐標,之後用gbufferProjectionInverse和反向的透視除法(也就是後面的除以w分量,正常的透視除法是發生在投影變換之後,因此反向的透視除法是發生在反投影變換之後),將NDC坐標變換到眼坐標系中,我們將這個坐標記為viewPosition,而worldPosition則是繼續變換到光照坐標系時的位置.
之後我們要計算它在Shadow Map中對應的點,這一次我們添加一個函數:

float shadowMapping(vec4 worldPosition) {
	vec4 shadowposition = shadowModelView * worldPosition;
	shadowposition = shadowProjection * shadowposition;
	shadowposition /= shadowposition.w;
	shadowposition = shadowposition * 0.5 + 0.5;
	float shadowDepth = texture2D(shadow, shadowposition.st).z;
	float shade = 0.0;
	if(shadowDepth < shadowposition.z)
		shade = 1.0;
	return shade;
}

這一步基本上就是重新實現了一遍頂點的MVP變換,只不過在完成投影矩陣變換後,我們手動完成了一遍從裁剪坐標繫到NDC坐標系然後再到屏幕空間的變換,最後我們從中取出深度信息,與預期深度進行對比,並通過返回值來表示是否在陰影中.

然後要在main函數中調用shadowMapping來獲取在光照坐標系下的一個點的陰影覆蓋程度,在"vec4 worldPosition = gbufferModelViewInverse * viewPosition;"下方加入

float shade = shadowMapping(worldPosition);

在"gl_FragData[0] = texture2D(gcolor, texcoord.st);"下方加入

gl_FragData[0].rgb *= 1.0 - shade * 0.5;

接下來就可以進行測試了,但我勸你先做好心理準備,因為接下來的內容會很慘不忍睹...

2015-10-10_01.10.35

進入遊戲後你會發現地面布滿了條紋式的黑影,這是因為浮點精度不足導致,解決的方法是加入一個偏移值,將"if(shadowDepth < shadowposition.z)"改為:

if(shadowDepth + 0.0001 < shadowposition.z)

2015-10-10_17.53.31

現在再次重載光影包,這次就沒有黑影了,但是影子的質量依然堪憂,這是由於Shadow Map分辨率不足,無法記錄足夠的細節造成的,你可以在Shadersmod的控制台中增大Shadow Map分辨率倍數,但這依然不是個好的解決方法.一個流行的解決方案是採用穹式投影,穹式投影有個更廣為人知的名字是魚眼鏡頭(Fisheye lens),它有個特點就是越靠近投影中心點的場景在畫面上占的比重越大,邊緣的場景會被擠壓,會損失一些細節,但不至於完全消失,這正好符合我們的要求:在近處保留儘可能多的細節.

2015-09-13_23.33.31
圖:默認的Shadow Map

2015-09-13_23.33.48
圖:採用穹式投影的Shadow Map,注意邊緣的樹和山體雖然被擠壓變形,但仍未完全消失.

首先創建shadow.vsh和shadow.fsh兩個文件.然後在shadow.vsh中添加:

#version 120

#define SHADOW_MAP_BIAS 0.85

varying vec4 texcoord;

void main() {
	gl_Position = ftransform();
	float dist = length(gl_Position.xy);
	float distortFactor = (1.0 - SHADOW_MAP_BIAS ) + dist * SHADOW_MAP_BIAS ;
	gl_Position.xy /= distortFactor;
	texcoord = gl_MultiTexCoord0;
}

在shadow.fsh中添加:

#version 120

uniform sampler2D texture;

varying vec4 texcoord;

void main() {
	gl_FragData[0] = texture2D(texture, texcoord.st);
}

顯而易見,實現穹式投影的所有操作都在頂點着色器中完成,其中SHADOW_MAP_BIAS為投影係數,關於其不同取值時的效果如下:

SM-Default
圖:默認的Shadow Map,用於對比

SM-0.6
圖:係數為0.6的穹式投影

SM-0.8
圖:係數為0.8的穹式投影

SM-0.9
圖:係數為0.9的穹式投影

SM-0.99
圖:係數為0.99的穹式投影

很抱歉我忘了截係數為0.85時的截圖...顯而易見,係數越接近1,則靠近中央的景物占的比重越多,邊緣景物被壓縮的越厲害.
如果你好奇那些光影包使用的投影係數的話...MrMeepz、Robobo和Trilitons使用的0.6,大多數版本的SEUS和AirLoocke使用的0.8,某一個實驗版的SEUS使用的0.9,其餘的光影包基本都使用0.85.

完成了Shadow Map的着色器後,我們還得修改原來的陰影算法以適應採用穹式投影的Shadow Map,在composite.fsh中的"shadowposition = shadowProjection * shadowposition;"的下面添加:

float distb = sqrt(shadowposition.x * shadowposition.x + shadowposition.y * shadowposition.y);
float distortFactor = (1.0 - SHADOW_MAP_BIAS) + distb * SHADOW_MAP_BIAS;
shadowposition.xy /= distortFactor;

然後別忘了加上"#define SHADOW_MAP_BIAS 0.85",現在再測試一下新的陰影效果,這時效果已經可以接受了.

2015-10-10_18.04.53

接下來我們要解決的是讓遠處的陰影能平滑過渡,以及防止天空被渲染上陰影,一個最簡單的解決方法是判斷其在深度緩衝中的距離,然而這個方法實現起來卻不像它的原理那麼簡單,首先深度緩衝不是線性的,它的前99%幾乎都集中在視角最前方,使用默認的非線性深度緩衝難以區分開遠處的地面和天空,另一個問題則是深度緩衝沒有經過透視修正,即站在某部不動,僅旋轉鏡頭的情況下,某一物體在屏幕中間和在屏幕邊緣的深度是不一樣的,這導致通過它來實現的陰影漸變會讓遠方的陰影隨着玩家鏡頭的轉動而變化,這顯然有違於人類的直覺.因此我們這裡使用一個折衷的方案,使用規整化了的光照坐標系坐標來判斷,由於這個坐標系的特點是坐標原點在玩家的位置,因此直接求某點到原點的距離便是該點到視角的距離,而規整化的方式就是將這個距離除以Shadersmod提供的一致變量:far,它代表投影矩陣的遠裁面,基本可以作為一個最遠視距的參考.

首先,在composite.fsh中添加這個變量:

uniform float far;

然後將"float shade = shadowMapping(worldPosition, dist);"改成:

float dist = length(worldPosition.xyz) / far;
float shade = shadowMapping(worldPosition, dist);

最後我們改掉整個Shadow Mapping函數,將它換成:

float shadowMapping(vec4 worldPosition, float dist) {
	if(dist > 0.9) //距離過遠(比如遠景和天空)的地方就不渲染了
		return 0.0;
	vec4 shadowposition = shadowModelView * worldPosition;
	shadowposition = shadowProjection * shadowposition;float distb = sqrt(shadowposition.x * shadowposition.x + shadowposition.y * shadowposition.y);
	float distortFactor = (1.0 - SHADOW_MAP_BIAS) + distb * SHADOW_MAP_BIAS;
	shadowposition.xy /= distortFactor;
	shadowposition /= shadowposition.w;
	shadowposition = shadowposition * 0.5 + 0.5;
	float shadowDepth = texture2D(shadow, shadowposition.st).z;
	float shade = 0.0;
	if(shadowDepth + 0.0001 < shadowposition.z)
		shade = 1.0;
	shade -= clamp((dist - 0.7) * 5.0, 0.0, 1.0);//在l處於0.7~0.9的地方進行漸變過渡
	shade = clamp(shade, 0.0, 1.0); //避免出現過大或過小
	return shade;
}

代碼看上去有一大段,其實只有4行是新增的,其它的都是之前已有的代碼.

現在我們的陰影效果已經好多了,但是問題依然存在,首先就是平行於光線方向的表面會產生大量斷裂的黑影,這是由浮點誤差造成的,當表面垂直於光線時,有些點被變換到Shadow Map上時落在了陰影區,而有些則落在了無陰影的區域.

2015-10-12_01.25.42
圖:平行於光線的表面的裂痕狀陰影

有一個解決方案是調整光源的位置,比如通過添加"const float sunPathRotation = [偏移角度];"來偏移光線的角度,但這也無法從根本解決問題,因為遊戲中一天總會有那麼幾個時候光線平行於某個物體的表面,比如正午和日出日落時.

2015-10-12_01.26.35
圖:偏轉陽光的角度並不能解決問題

一個永久性的解決方案是判斷法線(即表面的垂線)和光線的夾角,對於夾角接近90度的點漸變到完全處於陰影的狀態.但現在的問題是,我們還沒有法線啊...目前為止,我們的着色器雖然確實是在向G-Buffer中寫入數據,但實際上仍和傳統的前向著色沒有任何區別,我們僅僅使用了一個顏色緩衝,從G-Buffer的最終合成也是由系統來完成,這一次我們來實現一個真正的延遲渲染系統,首先我們要了解的是如何向G-Buffer中寫入多個數據.

==================================================
知識點:Shadersmod可用的緩衝區

如果你熟悉OpenGL規範的話,你會知道它要求具備MRT功能的顯卡至少支持4個RenderTarget,即至少能同時向4個顏色緩衝輸出數據,目前大部分主流顯卡都支持8個RenderTarget,因此綜合考慮,Shadersmod在G-Buffer中為開發者準備了8個正常渲染時的顏色緩衝和3個深度緩衝,以及Shadow Map渲染時的2個顏色緩衝和2個深度緩衝,它們分別是:

ID 名稱 別名 默認格式 默認數據
0 gcolor colortex0 RGBA8 霧的顏色
1 gdepth colortex1 RGBA32F (注) (1.0, 1.0, 1.0, 1.0)
2 gnormal colortex2 RGBA8 (0, 0, 0, 0)
3 composite colortex3 RGBA8 (0, 0, 0, 0)
4 gaux1 colortex4 RGBA8 (0, 0, 0, 0)
5 gaux2 colortex5 RGBA8 (0, 0, 0, 0)
6 gaux3 colortex6 RGBA8 (0, 0, 0, 0)
7 gaux4 colortex7 RGBA8 (0, 0, 0, 0)

(注:"請不要誤會,我不是針對你,我是說在座的各位,都...唔...都知道RGBA32F指的是4個32位浮點數,但這裡有個Bug,如果在下文的'聲明緩衝區'中使用別名colortex1而不是gdepth,並且沒有手動聲明格式,會引發一個Bug(至少是在1.7.10的Shadersmod中),導致gdepth的格式變成RGBA8,即4個8位字節,這一毀滅性的Bug足以導致你的gdepth廢掉")

名稱和別名是在聲明緩衝時用的,聲明緩衝類似我們之前使用噪聲圖時聲明噪聲圖尺寸,你需要在一個片元着色器(隨便哪個片元着色器)中起一行加入:

uniform sampler2D [名稱或別名];

對GLSL稍有熟悉的人看到這個估計都會噴我:"這不就是使用紋理嗎"...對,這其實就是使用紋理...

在聲明了緩衝後,系統會為它按默認格式創建一個顏色/深度緩衝並掛載到幀緩衝上,如果希望自定格式的話,可以按如下的代碼:

const int [格式] = [隨便一個整數,隨便,真的];
const int [名稱或別名]Format = [格式];

沒錯...Shadersmod就是這麼獵奇,它是純粹基於字符串判斷來判讀參數,然而顯然GLSL不允許你輸入個字符串,因此這裡只能變通一下,先聲明個和格式名相同的變量或定義(GLSL也支持#define),然後再聲明緩衝的格式.
可用的格式有:

格式 描述 體積 規整化
R8 1個8位定點數,只有R可用 8
RG8 2個8位定點數,只有R,G可用 16
RGB8 3個8位定點數,只有R,G,B可用 24
RGBA8 4個8位定點數 32
R16 1個16位定點數,只有R可用 16
RG16 2個16位定點數,只有R,G可用 32
RGB16 3個16位定點數,只有R,G,B可用 48
RGBA16 4個16位定點數 64
R32F 1個32位浮點數,只有R可用 32
RG32F 2個32位浮點數,只有R,G可用 64
RGB32F 3個32位浮點數,只有R,G,B可用 96
RGBA32F 4個32位浮點數 128

此表描述了所有可用的格式;對於非RGBA的格式來說,在寫入gl_FragData時只有格式所具有的那幾位有效,雖然你依然必須輸出整個vec4...規整化表示輸出結果是否必須在0.0~1.0之間,超出的會被clamp,如你所見,所有的定點數都是規整化的,只有浮點數是存進什麼值就是什麼值.
此外,gaux4還支持一種特殊的格式聲明方式,但過於撒比,這裡就不介紹了...

一個緩衝在每一幀渲染前都會被刷新成默認數據,這個是無法更改的...

最後還有一個問題是如何控制數據輸出,我們可能在整個渲染過程中用到多個緩衝,但在其中一個階段可能只需要向其中的某幾個輸出數據,這就用到了Shadersmod的輸出控制,設置輸出目標的方式是在該階段的片元着色器中新起一行,加入:

/* DRAWBUFFERS:[各個輸出目標的ID] */

輸出目標的ID即之前那個表中的緩衝的ID,如果有多個目標,就將它們按你希望的順序依次排列,比如想向0,1,3這3個顏色緩衝輸出的話:

/* DRAWBUFFERS:013 */

注意Shadersmod是嚴格按照逐行字符串匹配來掃描這些參數,因此不要忘記這裡面的兩個空格,也不要手賤往前面或後面加東西,比如不能添加縮進,這可能會逼死強迫症,因此將輸出對象聲明寫在文件開頭,放在#version的下一行也不失為一種兩全其美的選擇.

在設置了輸出目標後,就可以在gl_FragData中輸出數據了,gl_FragData的操作方式類似數組,下表對應之前聲明的輸出,比如:

/* DRAWBUFFERS:013 */
gl_FragData[0] = vec4(1.0);
gl_FragData[1] = vec4(1.0, 2.0, 3.0, 4.0);
gl_FragData[2] = vec4(0.0);

是向gcolor中輸出一個純白(@純白之面),向gdepth輸出4個浮點數,向composite中輸出一個完全透明的純黑(噫...玄黑之面?)

如果沒有聲明輸出對象的話,Shadersmod就會默認是向所有可用的顏色緩衝按自然順序輸出,至少理論上是如此,但實際上這裡存在一個Bug,在某些顯卡下(比如N卡的Maxwell架構系列)不聲明輸出對象會導致渲染異常,具體內容見附錄"DRAWBUFFERS導致的水面異常".

至於緩衝的使用,在後處理和最終合成階段,每一個緩衝都可以按讀取紋理的方式來獲取數據,比如:

uniform sampler2D gnormal;

//...

vec3 data = texture2D(gnormal, texcoord.st).rgb;

就是從gnormal中讀取數據.此外除了正常渲染時的顏色緩衝以外,Shadersmod還支持從如下緩衝中讀取數據:

名稱 別名 默認格式 數據內容
shadowtex0 shadow/watershadow (注) Depth24 Shadow Map的深度緩衝,水面被視為一個不透光的固體
shadowtex1 無/shadow (注) Depth24 Shadow Map的深度緩衝,水面被視為一個全透明的物體,即不包含水面
shadowcolor0 shadowcolor RGBA8? Shadow Map的顏色,除非你要用到什麼奇技淫巧,否則是用不到它的.準確說它也是個RenderTarget,想使用它也需要聲明,聲明方法和之前的那些一樣,但似乎無法更改格式.順便一提,它的Alpha位你是別想拿來存儲數據,因為Shadow Map在繪製時會開啟Alpha測試,所以這個Alpha位必須為1.0,否則數據不會被輸出...
shadowcolor1 RGBA8? 同上,不同的是它的每一位都能隨意使用.
depthtex0 gdepthtex Depth24 G-Buffer繪製後的深度緩衝,包含水面和雲
depthtex1 Depth24 G-Buffer繪製後的深度緩衝,它不包含水面,但卻包含雲...而且是只有當玩家位於雲層之下時才包含雲,一旦玩家位於雲層或更高時,雲會驟然消失,不要忘了玩家是很容易到達雲層的高度的.
depthtex2 Depth24 G-Buffer繪製後的深度緩衝,和上面的相同,但不包含手和手上的物品

(注:shadowtex0與shadowtex1的別名規則十分獵奇,如果你聲明了shadow而沒有聲明watershadow的話,shadow將指向shadowtex0;如果你聲明了watershadow的話,shadow將指向shadowtex1;watershadow無論如何只要被聲明了的話一定指向shadowtex0.)

Depth24即24位規整化定點數,假如OpenGL有R24的話,那麼它就相當於R24.這幾項數據主要在渲染陰影時使用.
==================================================

首先我們需要設計延遲渲染所需的G-Buffer,每個遊戲的G-Buffer的設計都不盡相同,比較公認的必不可少的內容有:漫反射顏色、法線和位置.
前者就是我們之前已經完成的在G-Buffer繪製階段輸出的顏色,接下來我們需要解決的是法線,為什麼不需要輸出位置?因為位置可以通過深度信息來重建嘛,我們剛才不就已經實現了嗎?
法線即垂直於一個平面的方向,幾乎所有光照計算都需要計算光線與法線的夾角,在GLSL中,我們通過gl_Normal來獲取模型坐標系下的法線,然後通過gl_NormalMatrix將它變換到眼坐標系,gl_NormalMatrix相當於一個專門供gl_Normal使用的模型視圖矩陣,解釋它的原理需要一點點線性代數知識,欲知更多真相,見附錄"⑨評gl_NormalMatrix".

然後我們要研究的是如何存儲法線,最簡單的方式是直接存為3個浮點數,但不覺得這有點太...浪費了嗎...有一種辦法是通過2個16位半精度浮點數來儲存法線,然而Shadersmod卻不允許我們使用半精度浮點數作為RenderTarget的格式,這確實是個糟糕的設計,我們不得不像一些使用OpenGLES的移動端那樣將法線編碼到16位定點數中儲存,關於編碼方法,這個網站給出了很多種方法,這裡採用的是Spheremap Transform.

我決定使用gnormal這個顏色緩衝來存儲法線數據,首先要將它的格式從RGBA8改為RG16,在composite.fsh中添加:

const int RG16 = 0;
const int gnormalFormat = RG16;

這樣便完成gnormal格式的聲明了,然後我們要修改所有G-Buffer的着色器,在其中向gnormal寫入法線數據,以gbuffers_basic為例,在gbuffers_basic.vsh中添加:

varying vec2 normal;

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

然後在它的main函數中添加:

normal = normalEncode(gl_NormalMatrix * gl_Normal);

在gbuffers_basic.fsh中添加:

varying vec2 normal;

/* DRAWBUFFERS:02 */

在它的main函數中添加:

gl_FragData[1] = vec4(normal, 0.0, 1.0);

輸出一個RG16格式數據的方法和輸出RGBA8的方法相同,只不過只有R和G中的數據會被實際輸出,不過A的數據雖然沒有被輸出,但依然會被用於Alpha混合,在MRT中進行Alpha混合和Alpha測試是一件很頭疼的事,更詳細的內容我們會在附錄中討論.現在我們已經為其中一個着色器完成法線數據輸出了,以此類推,為所有的G-Buffer繪製着色器添加法線輸出.

然後我們要為composite.fsh添加法線解碼,在composite.fsh中添加:

uniform sampler2D gnormal;
uniform vec3 sunPosition;

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);
}

sunPosition是太陽光的光線向量,這個我們待會會用到.現在向main函數開頭添加:

vec3 normal = normalDecode(texture2D(gnormal, texcoord.st).rg);

於是我們便完成了全部的法線數據存儲與讀取工作!如果你想親眼體驗一下自己的成果的話,可以在最後添加"gl_FragData[0] = vec4(normal, 1.0);",以在屏幕上繪製各點的法線信息,不過請相信我,它們並不好看...

然後我們要大改一下陰影判斷的代碼:

float shadowMapping(vec4 worldPosition, float dist, vec3 normal) {
	if(dist > 0.9)
		return 0.0;
	float shade = 0.0;
	//計算法線和光線夾角,最終算出來的結果是位於-1.0~1.0之間的數,1代表光線垂直照射平面,0代表光線與平面平行,小於0代表光線來自平面後方.
	float angle = dot(normalize(sunPosition), normal);
	if(angle <= 0.1) //如果角度過小,就直接塗黑
	{
		shade = 1.0;
	}
	else
	{
		vec4 shadowposition = shadowModelView * worldPosition;
		shadowposition = shadowProjection * shadowposition;
		float distb = sqrt(shadowposition.x * shadowposition.x + shadowposition.y * shadowposition.y);
		float distortFactor = (1.0 - SHADOW_MAP_BIAS) + distb * SHADOW_MAP_BIAS;
		shadowposition.xy /= distortFactor;
		shadowposition /= shadowposition.w;
		shadowposition = shadowposition * 0.5 + 0.5;
		float shadowDepth = texture2D(shadow, shadowposition.st).z;
		if(shadowDepth + 0.0001 < shadowposition.z)
			shade = 1.0;
		if(angle < 0.2) //如果角度略小的話,就將它過渡到全黑.
			shade = max(shade, 1.0 - (angle - 0.1) * 10.0);
	}
	shade -= clamp((dist - 0.7) * 5.0, 0.0, 1.0);//在l處於0.7~0.9的地方進行漸變過渡
	shade = clamp(shade, 0.0, 1.0); //避免出現過大或過小
	return shade;
}

看上去代碼有一大堆,其實真正新增的地方並不多...首先我們把夾角過小,或者乾脆背朝光線的面直接塗黑,然後我們將夾角略小的面過渡到全黑.

最後別忘了改一下shadowMapping調用的代碼:

float shade = shadowMapping(worldPosition, dist, normal);

現在側面的效果已經很好了,但是又引發了兩個新問題,一個是雲朵也被我們染成黑色了...另一個是水面下那些背光的磚塊也被染成了黑色.

2015-10-19_14.52.55

2015-10-19_14.53.09

之前為了解決這兩個問題我想了不少拆東牆補西牆的辦法...雲被染黑是因為它的面正好背朝光源,當時我想出的解決方案有修改着色器讓雲的法線方向始終和光源方向相反;在G-Buffer中打上特殊的標誌位,禁止雲接受光照;或者乾脆不渲染雲,反正到最後我們得替換成體積雲.水下陰影的處理最開始是對比depthtex0和depthtex1的深度.後來我才想起一個最簡單的辦法,直接判斷顏色的alpha值...雲和水的alpha都是小於1的,我們直接禁掉alpha小於1的像素的背光塗黑就行了.

首先我們要先將gcolor的採樣提到最前面,在main函數的開頭加入:

vec4 color = texture2D(gcolor, texcoord.st);

然後將原本的"gl_FragData[0] = ..."兩行改成:

color.rgb *= 1.0 - shade * 0.5;
gl_FragData[0] = color;

給shadowMapping函數加一個alpha參數:

float shadowMapping(vec4 worldPosition, float dist, vec3 normal, float alpha) //在copy的時候別手滑把括號給覆蓋掉 233

將陰影測試中的那兩個"if(angle < xx)"條件改成:

if(angle <= 0.1 && alpha > 0.99)
//...
if(angle < 0.2 && alpha > 0.99)

最後修改shadowMapping的調用:

float shade = shadowMapping(worldPosition, dist, normal, color.a);

現在水面和雲的陰影已經正常了,現在還有一些小的細節值得改良,首先是遠處陰影的過渡,如果你使用默認的shadowDistance設置(它用來配置陰影的範圍,具體作用見附錄"所有可用的控制參數"),那麼這一步基本可以跳過,如果你縮小了默認的shadowDistance範圍來換取更高的細節精度的話,那麼你就得考慮對遠處的陰影進行手工消隱,否則會出現奇怪的"怪圈".

2015-10-20_02.07.09
圖:黑色怪圈,這是因為某些點在變換到Shadow Map後沒有落在有效範圍內造成的.

olddrivervenomsnake
圖:合金裝備5中的邊緣陰影淡化,這個場景是在一個峽谷中,光源位於鏡頭的左後方,正確的陰影應當如圖片右側中的那樣,而圖片中上方那些淡化的陰影則是超出Shadow Map範圍而被淡化掉的,在實際遊戲中可以明顯地看到遠處原本沒有陰影的區域隨着自己靠近而被塗上了陰影. 事實上這個問題要比我們現在討論的要複雜,你會注意到在極遠處,比如遠處的岩石和土坡上依然有陰影,這裡可能有兩種原因,一種是遊戲採用了CSM(Cascaded Shadow Maps)技術,即將Shadow Map按照精細程度分為多個,在距離玩家最近的區域使用最高精度但覆蓋範圍最小的Shadow Map,在距離玩家較遠的區域使用覆蓋範圍最廣但精度最差的Shadow Map,對於兩者之間銜接的區域則採用插值過度,上圖所示的情況可能是近處的高精度Shadow Map包含了峽谷的谷頂,而遠處的低精度卻由於種種原因(LOD?)未能包含峽谷,這種情況我們在Minecraft光影中也可能遇到,如果你嘗試迎着光靠近一個非常高(y>120)的山的話你會注意到山腳下的陰影會隨着你的靠近而不斷發生劇烈變化.而第二種原因就不那麼浪漫了,那些陰影說不定是遊戲開發時事先烘焙好的...關於烘焙陰影(Baked Shadows)的詳情可以自行百度/谷歌,反正我是不感興趣. (霧)

手工消隱的辦法是判斷像素變換到Shadow Map上的坐標,如果接近邊緣則平滑過渡到沒有陰影,不過由於我們使用的是穹面投影,因此在屏幕坐標系判斷有些不便,這裡我們退一步在剪裁坐標系(也就是經過透視變換後的坐標系)進行判斷. 在"shadowposition = shadowProjection * shadowposition;"的下面添加:

float edgeX = abs(shadowposition.x) - 0.9;
float edgeY = abs(shadowposition.y) - 0.9;

然後在這個代碼塊的結尾添加:

shade -= max(0.0, edgeX * 10.0);
shade -= max(0.0, edgeY * 10.0);

現在我們現在只考慮了日間的陰影,為了讓晚上也有陰影,以及讓晝夜交替時陰影能平滑過渡,我們可以在後處理階段的頂點着色器對時間進行判斷(在頂點着色器計算的原因是降低計算量),將composite.vsh修改為:

#version 120

uniform vec3 sunPosition;
uniform vec3 moonPosition;
uniform int worldTime;

varying vec4 texcoord;
varying vec3 lightPosition;
varying float extShadow;

#define SUNRISE 23200
#define SUNSET 12800
#define FADE_START 500
#define FADE_END 250

void main() {
	gl_Position = ftransform();
	texcoord = gl_MultiTexCoord0;
	if(worldTime >= SUNRISE - FADE_START && worldTime <= SUNRISE + FADE_START)
	{
		extShadow = 1.0;
		if(worldTime < SUNRISE - FADE_END) extShadow -= float(SUNRISE - FADE_END - worldTime) / float(FADE_END); else if(worldTime > SUNRISE + FADE_END)
			extShadow -= float(worldTime - SUNRISE - FADE_END) / float(FADE_END);
	}
	else if(worldTime >= SUNSET - FADE_START && worldTime <= SUNSET + FADE_START)
	{
		extShadow = 1.0;
		if(worldTime < SUNSET - FADE_END) extShadow -= float(SUNSET - FADE_END - worldTime) / float(FADE_END); else if(worldTime > SUNSET + FADE_END)
			extShadow -= float(worldTime - SUNSET - FADE_END) / float(FADE_END);
	}
	else
		extShadow = 0.0;
	
	if(worldTime < SUNSET || worldTime > SUNRISE)
		lightPosition = normalize(sunPosition);
	else
		lightPosition = normalize(moonPosition);
}

其中日落SUNSET和日出SUNRISE的時間是取自Shadow Map光源變化時間.在這兩個時間光源會從太陽變到月亮,或月亮變到太陽,因此我們的陰影過渡全是針對這兩個時間點來進行,變量extShadow代表全局陰影,在時間點前500tick全局陰影會逐漸加重,直到前250tick時場景中所有地方都被塗成有陰影的部分,在後250tick時全局陰影逐漸淡出,直到後500tick時恢復正常.這裡的過渡方式之所以採用讓全局都塗上陰影而不是讓已有的陰影淡出,是為了兼顧室內環境,雖然在日出日落時遊戲環境會變暗一些,但總比室內突然變亮好.

然後我們就可以修改composite.fsh了,為它添加變量:

varying float extShadow;
varying vec3 lightPosition;

然後將夾角計算改成:

float angle = dot(lightPosition, normal);

然後將shadowMapping中的兩個return改成:

return extShadow; //原來的return 0.0;
//...
return max(shade, extShadow); //原來的return shade;

2015-10-20_12.56.36

現在我們的陰影已經基本可以看了,美中不足的是陰影的邊緣總是顯得有些稜角分明,顯然這與現實中的陰影不符,這是因為遊戲中的光源在計算光照時都是當做點光源來處理,所有的光線都是發射自一點,而現實中的光源基本都是面光源,由此便形成了半影(一部分光能照到)和本影(所有光都照不到),再考慮到漫反射和空氣散射,有明顯邊緣的純陰影在現實中幾乎很難見到,因此現在Shadow Mapping的發展重點之一便是實現邊緣淡化的軟陰影,最簡單的軟陰影技術是PCF(Percentage Closer Filtering),它的思路就是對像素周圍幾個點的位置均進行採樣,然後計算位於陰影區中的採樣點的所佔比例,然後對此點在無陰影和全陰影之間過渡.看完這個原理之後,你都可以嘗試自己手寫一個多點採樣來實現PCF,不過這裡我們會使用硬件提供的PCF來快速實現這個效果,GLSL函數shadow2D是擁有硬件支持的PCF採樣的深度比較,在大部分設備中它採用4點的雙線性過濾進行採樣,最後輸出一個介於0.0~1.0之間的浮點數,0.0表示完全處於陰影當中,1.0代表不處於陰影中.

首先我要啟用硬件支持的深度比較,在composite.fsh中添加:

const bool shadowHardwareFiltering = true;

啟動硬件深度比較後我們就沒法手動從Shadow Map中讀取值了,如果你現在重載光影包的話會發現陰影消失了.接下來我們將變量shadow的類型從sampler2D改為sampler2DShadow:

uniform sampler2DShadow shadow;

然後將

if(shadowDepth + 0.0001 < shadowposition.z)
{
	shade = 1.0;
}

改為:

shade = 1.0 - shadow2D(shadow, vec3(shadowposition.st, shadowposition.z - 0.0001)).z;

最後再刪掉這行:

float shadowDepth = texture2D(shadow, shadowposition.st).z;

現在如果你測試的話,會發現地面上再次出現了明暗交替的條紋,這是因為shadow2D對Shadow Map的精度要求太高了,我們的Shadow Map精度遠遠達不到它的要求,因此只能死命地加大偏移值...但簡單地加大偏移值並不完全管用,首先你會注意到遠處依然會有這種條紋,因為我們使用的穹面投影的精度是越遠越差的,即使你把偏移值改成跟距離成正相關也沒法解決所有問題,你會注意到野草等薄面的陰影和本體之間會有一個空隙,而且這個空隙會隨着偏移值的增加而越來越大,它是由於過度的偏移造成的,該問題有個學名叫Peter Panning,這個梗出自小飛俠,小飛俠Peter Pan的能力之一就是隱匿自己的影子.對於這個問題業界給出的解決方案是:"少偏移,合理偏移,少繪製薄面"... Shadow Mapping的偏移技術其實有3種,分別是基於光源深度的偏移,基於觀察者深度的偏移和基於法線的偏移,我們之前實現的都是基於光源深度的偏移,即在Shadow Map取樣時從光源的視角將深度值(即shadowposition.z)微微向近處調整一些;而基於觀察者深度的偏移就是在觀察者的視角讓點拉近一些再變換到Shadow Map上;基於法線的偏移則是在眼坐標系中將像素對應的點沿法線方向稍微偏移一點.根據我的測試基於法線的偏移效果是最好的.

實現基於法線的偏移很簡單,將"vec4 worldPosition = gbufferModelViewInverse * viewPosition;"改成:

vec4 worldPosition = gbufferModelViewInverse * (viewPosition + vec4(normal * 0.05 * sqrt(abs(viewPosition.z)), 0.0));

這個偏移方程是根據眼坐標系下點到視覺平面的距離(-position.z,但為了防止不小心真冒出一個正數導致取負後sqrt崩壞,這裡用的是取絕對值)的平方根來計算,這個公式很大程度上是我湊出來的...如果把sqrt(abs(position.z))改成pow(abs(position.z), 0.6)對遠處的暗紋消除效果更好,其次遠景的陰影會因為過度偏移而在邊緣出現尖刺,諷刺的是我們恰恰是為了實現軟陰影才使用PCF的.我懷疑這可能是默認的1024x1024尺寸的Shadow Map所能取得的最好效果了,如果你覺得這實在難以忍受的話,可以加大Shadow Map的尺寸,除了讓玩家在設置中調大ShadowResMul以外,你還可以在光影包中修改基數(默認為1024,最終尺寸為基數乘以ShadowResMul),方法是在任意一個片元着色器中加入:

const int shadowMapResolution = [尺寸基數];

只要改成2048,就能取得還能讓人接受的遠景效果.

2015-10-20_15.40.01
圖:有PCF的軟陰影

2015-10-20_15.40.24
圖:沒有PCF的默認陰影

2015-10-22_22.46.13
圖:使用最終尺寸為1024*1024的Shadow Map的PCF軟陰影 (點圖放大)

2015-10-22_22.46.34
圖:使用最終尺寸為2048*2048的Shadow Map的PCF軟陰影 (點圖放大)

雖然還是有很多很多可改進的空間,但這些內容已經遠超我們當初"實戰片元着色器"的範圍,實時陰影技術如果單拿出來說的話完全可以出一本書(並不是開玩笑,清華大學出版社的"圖形學全家桶"就包括《實時陰影技術》一書),因此我只能就此淺嘗輒止介紹這些最基本的技術,如果你對陰影映射感興趣的話,可以看看GPU Gem系列對實時陰影的討論以及Wiki上Shadow Mapping英文版的附錄.這裡給出一些尚未解決的問題:

  • 在室內環境和下界時場景過暗的問題,可以考慮結合eyeBrightnessSmooth來減淡陰影.
  • 夜間環境下過暗的問題,可以考慮結合worldTime和eyeBrightnessSmooth來減淡陰影.
  • 陰影在末地無法正常工作,不過別擔心,沒幾個光影包在末地能正常工作...
  • 有人造光源時依然有陰影的問題,可以考慮在G-Buffer繪製時向一個顏色緩衝中輸出該像素所受人造光源影響的強度,然後禁止向有人造光源的地方繪製陰影.人造光源強度來自lmcoord的s值,獲取方式見上文的lmcoord取值方式...
  • 你應該注意到了草和樹葉的陰影總會在自身染上奇怪的陰影,這是因為繪製Shadow Map時它們是不動的,而在繪製G-Buffer時我們對它們進行了變形...這一次我不得不承認Sonic Ether更厲害一些,他的SEUS無需修改Shadow Map繪製便能正確處理草和樹葉的陰影,而我所能想到的解決方案還是像Chocapic13那樣在Shadow Map中也對草和樹葉做一遍變形...難道SEUS是在對草和樹葉做深度測試時沿着法線方向進行了偏移?

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

實戰多遍後處理 - 泛光

我們已經有了一個基於片元着色器的後處理特效:陰影.現在我們來嘗試實現一個需要多遍渲染的後處理效果:泛光(Bloom)

泛光就是指當極亮和極暗的區域相鄰時,亮處的光就會溢出到暗處,它的物理學原理好像是透鏡聚焦不準造成的...不管了,當初我的大物2還是靠旁邊貴人相助才過的...

2015090846
圖:泛光效果實例,注意圖中間的兩個窗戶. 照片是和姬友去故宮玩時攝於某殿(...沒留意拍照時是在哪),另外請留意這張照片,今後我們還要把它拿出來鞭屍,因為它除了泛光,還涉及到了暈影、鏡頭光暈、炫光、體積光甚至是飛棍(...)等要素 ☺

首先我們先來思考一下泛光實現的方法,既然是亮處的光照溢出到暗處,那我們是不是可以把亮的部分提取出來,然後讓它們向外擴散,再覆蓋回原圖像上呢.這個想法是正確的,但關鍵是怎麼用算法實現"向外擴散"? 一個最簡單的辦法是模糊,高斯模糊可以實現被模糊圖像的顏色相互滲透,對於有明顯明暗區分的圖像來說,效果就是亮處的顏色向暗處擴散,正好符合我們的要求.高斯模糊的原理簡單地說就是在計算模糊後圖像的每一個像素點時,從原圖像該位置的像素周圍取一定數量的採樣點,然後按一定的權重將它們混合,這裡有個問題就是"一定數量"究竟是多少,簡單的高斯混合可以只取9x9區域內的像素,而一個高度模糊的可能需要73x73的區域,前者一次運算"只需"取樣81次,而後者一次運算需要取樣5329次.不過高斯模糊有個神奇的特性就是可以被分解成橫縱兩次,先對圖像進行一次橫向模糊,然後對第一次的結果進行一次縱向模糊,這樣最終結果的一個像素點總共只需A+A次,而不是A*A次,上面的73x73區域的5329次採樣會驟減到146次,已經到了可接受的範圍了.

然後我們就可以着手實現了,首先我們先來實現高光提取,想從圖片中找出亮處的最簡單辦法就是判斷像素的亮度,亮度計算可不是三種顏色取最高或者計算三種顏色平均值什麼的,而是將三種顏色乘以三個係數然後相加,其中綠色對亮度貢獻最大,佔71.52%;其次是紅色,佔21.26%;最後是藍色,僅僅只佔7.22%.因此計算亮度的公式是R*0.2126+G*0.7152+B*0.0722.

將composite.fsh中原來的:

/* DRAWBUFFERS:0 */
color.rgb *= 1.0 - shade * 0.5;
gl_FragData[0] = color;

改為:

color.rgb *= 1.0 - shade * 0.5;
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
vec3 highlight = color.rgb * max(brightness - 0.25, 0.0);
/* DRAWBUFFERS:01 */
gl_FragData[0] = color;
gl_FragData[1] = vec4(highlight, 1.0);

(不要忘了"/* DRAWBUFFERS:01 */"前面不能有縮進)

這一次我們使用colortex1這個顏色緩衝來存儲高光數據,colortex1的正式名字是gdepth,默認格式是RGBA32F,也是被建議用來存儲深度的,但由於我們是通過深度緩衝來計算深度,因此用不到它,為了不浪費,這裡就拿來用於存儲高光數據,同樣為了防止歧義和混淆,這裡使用的是它的別名colortex1,而不是gdepth.首先我們計算了在添加完陰影效果後的亮度,然後在對它進行適當衰減,剔除掉過暗的顏色後,寫入了colortex1中.另外別忘了聲明它:

const int RGB8 = 0;
const int colortex1Format = RGB8;

接下來我們要創建着色器composite1和composite2,然後在它們中進行高斯模糊.

首先先創建composite1.vsh和composite2.vsh,它們的內容均為:

#version 120

varying vec4 texcoord;

void main() {
	gl_Position = ftransform();
	texcoord = gl_MultiTexCoord0;
}

然後創建composite1.fsh,它的內容是:

#version 120

const int RGB8 = 0;
const int colortex3Format = RGB8;

uniform sampler2D colortex1;
uniform float viewWidth;
uniform float viewHeight;

varying vec4 texcoord;

const float offset[9] = float[] (0.0, 1.4896, 3.4757, 5.4619, 7.4482, 9.4345, 11.421, 13.4075, 15.3941);
const float weight[9] = float[] (0.066812, 0.129101, 0.112504, 0.08782, 0.061406, 0.03846, 0.021577, 0.010843, 0.004881);

vec3 blur(sampler2D image, vec2 uv, vec2 direction) {
	vec3 color = texture2D(image, uv).rgb * weight[0];
	for(int i = 1; i < 9; i++)
	{
		color += texture2D(image, uv + direction * offset[i]).rgb * weight[i];
		color += texture2D(image, uv - direction * offset[i]).rgb * weight[i];
	}
	return color;
}

void main() {
/* DRAWBUFFERS:3 */
	gl_FragData[0] = vec4(blur(colortex1, texcoord.st, vec2(1.0, 0.0) / vec2(viewWidth, viewHeight)), 1.0);
}

這一部分主要是做一次橫向的標準差(Sigma)為3,半徑((KernelSize-1)/2)為16的高斯模糊,blur為我們定義的高斯模糊函數,其中第三個參數用於決定模糊的方向,由於這裡面所有的數值都被歸一到0.0~1.0之間,因此為了計算從一個像素到另一個像素所需偏移的距離,需要用1.0除以屏幕寬/高(單位為像素)來獲取步長.此外你會注意到這裡只進行了8次循環,理論上講一個半徑為16的高斯模糊起碼需要16次循環,然而這裡利用了OpenGL的紋理線性過濾,一次採樣處理兩個像素,具體原理可以看這個http://rastergrid.com/blog/2010/09/efficient-gaussian-blur-with-linear-sampling/
weight是事先計算好的權重表,offset是事先計算好的當採用線性過濾時的採樣坐標偏移量.用於計算高斯函數的權重分布的在線計算器可以使用http://dev.theomader.com/gaussian-kernel-calculator/
(注:這段代碼可能在OSX Snow Leopard(10.6)或更老版本的OSX上無法運行,因為OSX的OpenGL驅動的一個知名bug就是不支持GLSL數組(牛逼啊),如果你希望你的着色器能在7年前的Macbook上運行的話,你就得手動把它展開,不過這天下怎麼會有不緊跟腎果步伐的果廚呢?)
而這部分可能最讓人困惑的,大概是我們這裡沒有繼續向colortex1輸出數據,而是轉而向colortex3輸出數據.這是因為OpenGL不建議向正在使用的紋理寫入數據,在我的機器上這樣做會導致紋理花屏,你可能會好奇那為什麼在composite着色器中向gcolor寫入數據就沒有出錯,這個我真回答不了...通常來說大部分實現多遍渲染的程序都會在底層準備兩套緩衝區,一套用於讀,一套用於寫,完成一遍渲染後兩個緩衝區的職責交換.然而我們的Shadersmod卻神奇地獨闢蹊徑,強行只留了一套緩衝區,這迫使着色器程序員必須手動把8個緩衝區輪番交換使用,將本應由系統完成的兩套緩衝相互交替寫入手動模擬一遍...
更新:現在有一個較為(偽)科學的解釋了,可以見附錄"紋理讀寫的競態條件",如果暫時不想看的話,可以將規則簡單記為:如果一個緩衝區既涉及到讀又涉及到寫的話,一個像素只能被負責向它寫入的那個片元着色器讀.例如在composite.fsh中片元着色器只讀取gcolor中它對應的那個像素,因此暫時沒有問題.composite1.fsh中片元着色器需要從colortex1中讀取33個像素的數據,於是就有問題了.
明白composite1.fsh的原理後就可以編寫composite2.fsh了,它的內容和1十分相似:

#version 120

uniform sampler2D colortex3;
uniform float viewWidth;
uniform float viewHeight;

varying vec4 texcoord;

const float offset[9] = float[] (0.0, 1.4896, 3.4757, 5.4619, 7.4482, 9.4345, 11.421, 13.4075, 15.3941);
const float weight[9] = float[] (0.066812, 0.129101, 0.112504, 0.08782, 0.061406, 0.03846, 0.021577, 0.010843, 0.004881);

vec3 blur(sampler2D image, vec2 uv, vec2 direction) {
	vec3 color = texture2D(image, uv).rgb * weight[0];
	for(int i = 1; i < 9; i++)
	{
		color += texture2D(image, uv + direction * offset[i]).rgb * weight[i];
		color += texture2D(image, uv - direction * offset[i]).rgb * weight[i];
	}
	return color;
}

void main() {
/* DRAWBUFFERS:1 */
	gl_FragData[0] = vec4(blur(colortex3, texcoord.st, vec2(0.0, 1.0) / vec2(viewWidth, viewHeight)), 1.0);
}

它和composite1極為相似,只不過是執行的縱向模糊,然後把結果輸出到colortex1.

最後我們要來編寫final.vsh/fsh,來完成兩個顏色緩衝的合成,首先創建final.vsh並為它添加:

#version 120

varying vec4 texcoord;

void main() {
	gl_Position = ftransform();
	texcoord = gl_MultiTexCoord0;
}

然後編寫final.fsh,添加:

#version 120

uniform sampler2D gcolor;
uniform sampler2D colortex1;

varying vec4 texcoord;

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

現在可以測試了!但是請做好瞎狗眼的準備,而且這次是真瞎...

2015-12-18_23.49.09

泛光效果確實有了,但整個屏幕卻亮的能晃瞎狗眼...有一個簡單的解決方法是直接在brightness後面乘個小於1.0的係數,降低它的亮度,但效果並不是太好,因此我們來整個皋大上的東西 - ToneMapping.

ToneMapping最初是出自攝影學,早在圖形程序員王八瞪綠豆似地盯着CRT顯示器之前,攝影師們就開始苦惱於照片的曝光問題,最典型的例子是站在室內向室外拍攝,由於室內亮度遠小於室外亮度,如果曝光過小,室內將變成一團黑;而如果曝光過大,室外將變成一團光.如果想同時清晰地拍下室內和室外的場景,或者說同時記錄暗處與亮處的細節,就需要一種方式,將它們按照一定形式映射在印刷品或底片所能表示的顏色範圍內,於是便有了ToneMapping,ToneMapping便是將對比度差異極大的顏色重新映射到可接受的範圍,雖然可能略微失真,但起碼能同時呈現暗處和亮處的細節.

用於圖形學的ToneMapping算法有不少,這裡使用的是http://filmicgames.com/archives/75的修改,而那個算法又是改良自自神秘海域2的算法(http://frictionalgames.blogspot.jp/2012/09/tech-feature-hdr-lightning.html)

首先在final.fsh中添加:

float A = 0.15;
float B = 0.50;
float C = 0.10;
float D = 0.20;
float E = 0.02;
float F = 0.30;
float W = 13.134;

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

這一個是神秘海域2的ToneMapping算法,然後在main函數的"color = color + highlight;"的下面添加:

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

這段程序是從John Hable的原算法改來的,基本就是手動增加曝光和對比度,W的值代表最大可能的顏色,由於基色最大為1.0,泛光顏色最大為0.75,所以W=1.75^1.4*6.0=13.134. 整個程序看着有些奇怪,但實際上效果相當好,沒有霧蒙蒙或白晃晃的感覺.

(注:如果還想更亮的話,可以試試這個:

float W = 16.2;
//...
color = pow(color, vec3(2.5));
color *= 4.0;
vec3 curr = uncharted2Tonemap(color);
vec3 whiteScale = 1.0f/uncharted2Tonemap(vec3(W));
color = pow(curr*whiteScale, vec3(1.0 / 2.2));

嚴格意義上講這段程序是錯的...然而它的效果卻比上文的要好,出於嚴謹性我沒有將它正式加入教程,有興趣的話可以試試)

2015-12-27_21.30.38
圖:有ToneMapping

2015-12-27_21.31.05
圖:無ToneMapping

泛光的內容就到這裡,今後的更新中會加入一些對泛光的改進,比如如何實現SEUS的爆肝式泛光,早先為了實現SEUS式的泛光做了不少努力,但後來覺得這樣做需要修改的略多,超過這一章節的範圍了.畢竟泛光這個效果不像陰影那樣是做不好就不行的...

2015-12-12_22.56.12

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

關於Shadersmod的功能性介紹基本就到這裡了,如果你已經是個着色器專家的話,基本就可以跳過後面的章節,直接看附錄作為技術手冊然後寫自己的光影包了.這後面的內容是供想要實現更多着色器特效但不知從何入手的人參考的.

鏈接:下篇