文章目錄

前言


這篇文章記錄我透過Final Fantasy 9(簡稱FF9)的模組 - Memoria,修改遊戲中執行的shader,讓遊戲中的人物與場景可以有完全不一樣的體驗。文中包括分析FF9的shader與在模型面數、realtime光照方法受限的場景中導入per-pixel光照的過程與想法。

8歲時在家裡第一次玩了FF9,成為了讓我最終踏入遊戲業的契機,目前我正開心的透過自己修改後的模組重新破FF9中。希望這一次除了石中劍II之外的武器與成就都能全入手。

比較模改前後的畫面


先來比較一下2016年移植到PC的版本與套用Memoria模組+新的shader的畫面-

非戰鬥時,人物使用了卡通渲染+描邊效果,背景則使用Moguri模組的AI Upscale圖片。

Copy_of_Lighting_Model_%286%29.png

而在全3D的戰鬥場景時,敵人與玩家隊伍的人物則會套用blinn-phong光照模型,並且根據不同環境產生對應的skybox來計算出對應的環境光,順便新增了有更生動的雲效果的背景shader。

Battle_Compare.png

PS1時期還沒有pixel shader這種東西,所以大部分的遊戲都是使用純貼圖(或使用vertex lighting),而FF9也是一樣,人物身上的的明暗變化都是直接畫在貼圖上的,視覺上會比較扁平,導入per-pixel lighting會凸顯出3D的立體感,至於使用Toon Shading或是Blinn-Phong就看個人偏好了,我的模組中不論是戰鬥或是非戰鬥的shader都可以從這兩種風格中選擇。

關於FF9 PC版、模組與現況


Battle_Compare_%283%29.png

FF9代最早於1999年在PS1發售(好懷念阿),PC版於2016年在Steam上釋出,這個PC移植版是透過Unity引擎製作的,不過並不是全部遊戲中全部的邏輯都在Unity重寫,內部有很大一部分仍然是依賴舊引擎的程式碼去跑。像是魔法粒子特效的演出、相機的運鏡、渲染時的投影矩陣…etc,不過因為移植時使用的是Unity,也開啟了社群去模改它的大門。

Memoria專案是一個可以完全取代FF9 PC版C#邏輯的模組,基於Memoria,玩家社群導入了不同的功能,比如說將遊戲使用的背景圖片全都利用AI-Upscale+手繪修改的Moguri Mod是目前最多人用的,還有近期Memoria也加入了將遊戲中的人物動畫從PS1/PC版的15 FPS提升到可最高120FPS的功能也有人製作了提升遊戲難度、新的可遊玩角色的模組…等等。

這些模組都大幅度地提高了遊玩體驗,但畫面的部分則仍與大多的老遊戲模組一樣都侷限在替換貼圖的部分。

而我則是在前陣子重玩FF7時,安裝了修改光照的模組(cosmos lighting)後,有了修改FF9中使用的shader的想法。

Untitled.png

竟然能夠修改遊戲中的Shader !?


結論是 - 可以,但比起一般寫shader的方式更硬核一些。

首先,可以看這份文件先複習一下Unity是如何編譯與使用shader的 :

用白話文來講Unity的shader的生命從製作到真正在遊戲中使用大約是這樣:

  1. 在Unity編輯器中透過node editor或者使用HLSL語法寫Shader
  2. build版本→Shader compiler將Shader檔編譯為不同目標平台的shader asset。
  3. 在遊戲進行時讀取shader asset,將asset中的shader assembly傳給GPU dirver,gpu driver會在將shader assembly進行一次編譯,並將編譯好的program cached住,下一次使用時就不會重複編譯。

在玩遊戲時,第3步如果過於費時,就會導致著名的shader stutter,近期常有一些大作在第一次遊玩時總是卡卡的,而在重新玩一次的時候就變順了,原因是第一次遊玩時gpu driver需要編譯的shader數量太龐大+第二次遊玩時gpu driver不用再重複編譯。

而透過Memoria模組執行FF9的時候,會從指定Path將需要的shader asset(就是…一大坨string)讀取至遊戲中,接著就執行上方第3步驟。也就是說,雖然透過模組沒辦法重新build整個專案,但可以透過修改原本要讀取的shader asset的字串,來加入需要的功能。只要沒有編譯錯誤,那GPU driver就能順利的將shader編譯並且在遊戲中執行。

Untitled.png

遊戲內的Shader Asset提供了哪些資訊?


FF9移植版雖然不是整個遊戲都在C#端重寫,但像是渲染的部分就是完全透過Unity,因此雖然畫面看起來還是ps1的那個老遊戲,背後使用的所有shader都是重新在Unity中寫的。

而這也代表了幾件事 -

  1. Unity的shader都包含Pixel Shader Stage,也就是說可以對3D物件做per-pixel的上色,
  2. 使用者能夠透過第三方工具將遊戲的asset輸出,包含以文字檔類型儲存的Shader

接著,以Unity自帶的UI用shader來舉例,讓我們來看看遊戲資料夾中包含的shader檔案會長什麼樣子(部分) -

Shader "UI/Default" {
Properties {
[PerRendererData]  _MainTex ("Sprite Texture", 2D) = "white" { }
 _Color ("Tint", Color) = (1,1,1,1)
 _StencilComp ("Stencil Comparison", Float) = 8
 _Stencil ("Stencil ID", Float) = 0
 _StencilOp ("Stencil Operation", Float) = 0
 _StencilWriteMask ("Stencil Write Mask", Float) = 255
 _StencilReadMask ("Stencil Read Mask", Float) = 255
 _ColorMask ("Color Mask", Float) = 15
}
SubShader { 
 Tags { "QUEUE"="Transparent" "IGNOREPROJECTOR"="true" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="true" }
 Pass {
  Tags { "QUEUE"="Transparent" "IGNOREPROJECTOR"="true" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="true" }
  ZTest [unity_GUIZTestMode]
  ZWrite Off
  Cull Off
  Stencil {
   Ref [_Stencil]
   ReadMask [_StencilReadMask]
   WriteMask [_StencilWriteMask]
   Comp [_StencilComp]
   Pass [_StencilOp]
  }
  Blend SrcAlpha OneMinusSrcAlpha
  ColorMask [_ColorMask]
Program "vp" {
SubProgram "d3d9 " {
Bind "vertex" Vertex
Bind "color" Color
Bind "texcoord" TexCoord0
Matrix 0 [glstate_matrix_mvp]
Vector 5 [_Color]
Vector 4 [_ScreenParams]
"vs_2_0
		def c6, -1, 1, 0, 0
		dcl_position v0
		dcl_color v1
		dcl_texcoord v2
		dp4 oPos.z, c2, v0
		dp4 oPos.w, c3, v0
		dp4 r0.x, c0, v0
		dp4 r0.y, c1, v0

可以看的出來,這份shader asset與我們平常在Unity寫的Shader沒有太大的差異,檔案中包刮了Tags、Pass、Properties…等等Unity的shader會需要的資料,以及Program “vp”和”fp”,也就是vertex shader program與fragment shader program(或稱pixel shader)。

透過以上資訊我們能很快地看出使用這個shader的材質的RenderType,是否是半透明,是否可以接收到陰影、採樣環境的SH..等等,以及使用這個Shader的材質時會傳入的參數類型。

program “vp” 下的第一行的d3d9,代表目標平台是DX9(很老),也就是說FF9的遊戲環境不可能支援更花俏的shader像是compute或者其他DX11後才有的功能。vs_2_0則代表了這份檔案編譯時是使用Shader Model 2.0,相較於之後的版本,vs_2_0能夠支援的instrcution count更少,也就是說能塞進shader的功能會更受限。但越低的版本能支援的硬體越多。

而與一般Unity shader最明顯的差異就是shader program本身的code了,在這裡shader code已經被編譯為assembly的形式,不是我們平常寫的HLSL/CG語法。為了加入自訂義的shader邏輯,我要修改的就是這一塊(以及新增修改對應的變數、變數的index)。

修改FF9的一些限制


在正式修改Shader之前必須先列出在FF9透過模組改遊戲的一些限制 :

  • 無法更換遊戲中的3D模型與動畫,因為遊戲中使用的模型是在Unity中serialized過後的。
  • 可以替換現有或載入新的貼圖給shader
  • 可以更改既有的shader或者新增shader,但是必須保留目前遊戲中使用到的邏輯、變數,不然顯示時多半會出錯。
  • 遊戲中的模型與貼圖大多是來自PS1時期,低面數與尺寸很小(256x256)。
  • 可以新增修改C#程式碼,但Unity的API只能使用Unity 5.2提供的(我最不習慣的是這點)。

從Assembly到實做Unity Shader


為了修改遊戲中實際使用的Shader,流程大致如下

Untitled_drawing_%283%29.png

至於該如何透過觀察assembly code反推出原本的unity shader,從這裡開始大概就是這篇部落格最無趣的部分了,

首先,以vertex shader中的一段來舉例

Bind "vertex" Vertex
Bind "texcoord" TexCoord0
Matrix 0 [glstate_matrix_modelview0]
"vs_2_0
		dcl_position v0
		dcl_texcoord v1
		dp4 r0.x, c0, v0
		dp4 r0.y, c1, v0
		dp4 r0.w, c3, v0
		dp4 r0.z, c2, v0
...
...
...
"

dcl_position v0 的 v0 是 vertex position。 Matrix 0 [glstate_matrix_modelview0] 的 c0變數是一個4x4矩陣,c0同時也代表該矩陣的第1列, c1即是矩陣第2列, c2第3列, c3是第4列

因為4x4矩陣占掉4個變數的位置,
所以下一個變數的宣告會是由c4開始。

上方的assembly邏輯,根據dp4的說明,反推回Unity shader的寫法就是 :

//dp4 r0.x, c0, v0
//dp4 r0.y, c1, v0
//dp4 r0.z, c2, v0
//dp4 r0.w, c3, v0
//其實就是一個將vertex position - v0利用矩陣做空間變換的操作。
//c0~c3是UNITY_MATRIX_MV矩陣。

//用Unity的寫法其實就是這樣短短一行 :
mul(UNITY_MATRIX_MV, v.vertex);

透過上方這樣的反推方式,理論上我們可以把一個可讀性非常低的shader assembly在Unity中重建出來。

接著用pixel shader中常見的一段assembly來舉例 :

Float 0 [_Cutoff]
SetTexture 0 [_MainTex] 2D 0
"ps_2_0
		dcl_pp t0.xy
		dcl_2d s0
		texld_pp r0, t0, s0
		add_pp r1, r0.w, -c0.x
		texkill r1
...
...
...
"

這一段的code如果用HLSL的語法就會等同於 :

//i.uv       -> dcl_pp t0.xy
//dcl_2d s0  -> sampler of _MainTex
fixed4 frag (v2f i) : SV_Target {
    
//texld_pp r0, t0, s0
half4 color = tex2D(_MainTex, i.uv);
		
//c0         -> _Cutoff
//add_pp r1, r0.w, -c0.x
//texkill r1
float alphaDifference = color.a - _Cutoff;
if (color.a - _Cutoff< 0)
    discard;
...
...
}

可以看出,這一段assembly執行的就是一個採樣貼圖並用cutoff變數與貼圖的a通道做alpha clip的動作。

但實際上,對我這種shader assembly菜鳥來說要完全靠上面這種方式推測出完整的shader仍然是滿困難的,原因是shader compiler在編譯的時候會針對code做優化。我們最終看到的assembly往往跟原本寫的HLSL shader有很大的差距。code寫的順序、變數的數值,都可能會在compile後長的完全不一樣,唯一會相同的只有最終計算出的output。

因此,根據不同的情況,我的作法有三種 :

  1. 如果該vertex shader的邏輯難以反推,那就保留原本的assembly code,直接手動寫需要的assembly進去原本的檔案內(需要注意不影響到原本的assembly邏輯)。
  2. 簡單一點的shader,可以直接靠肉眼反推回unity shader,並將整份新的unity shader在編譯成dx9的assembly,並取代掉原本的assembly。
  3. 更加複雜的shader,可以把assembly的code,1比1的用hlsl的語法照抄到unity shader中,並在code的最後加入自己的想新增的功能,之後一樣在將這份新的shader編譯一次。

💡 (本段一開始的截圖就是使用第三種作法),

Blinn-Phong光照、Toon Shading、描邊效果


在上一步驟將Assembly還原為Unity Shader後,我就在pixel shader中實作了光照模型,總共製作了兩種類型的光照,一種是Toon shading,另一種是稍微修改過的Blinn-Phong光照,同時用了一些根據顏色的對比度/明度的mask來避免Blinn-Phong的specular light出現在人物的皮膚上,效果….就還行,理想上應該要針對所有人物/怪物都出一張額外的貼圖來控制高光的強度/範圍,但這工太大了,我自己是沒計畫做這塊。

Lighting_Model_%281%29.png

Lighting_Model_%282%29.png

上方為不使用per-pixel光照(遊戲預設),與使用per-pixel的Blinn-Phong還有Toon Shading三者之間的比較。

描邊效果的部分就是在Shader Assembly中加一個描邊用的pass,單純地用法線外推法,剛好法線都是平滑過後的(見下一段)所以效果也算是堪用。

Untitled.png

💡 FF9因為全都是使用貼圖與vertex color來表現整個遊戲的顏色,因此場景中沒有設置任何光源,我加入光照後的光源方向也只是單純寫死的一個變數。

在runtime平滑模型的法線


1999年推出的FF9,人物模型的面數用現在的標準看都是蠻低的,而因為人物沒有打光(以前是直接使用vertex color來控制整個人物的明暗),僅使用貼圖,所以低面數的人物不會有問題。

但是在shader中加入光照之後就不一樣了,光照往往依賴模型的法線資訊,而某些低面數模型在加上光照後會出現這種狀況 -

Copy_of_Lighting_Model.png

上圖中那樣的法線,套用光照模型後會形成flat shading,而套用toon shading之後會出現慘不忍睹的效果,為了避免出現難看的光照結果,需要將模型頂點中儲存的法線向量是平滑化,運氣不錯的是,即使是 Unity5,仍然是有辦法在runtime修改模型的法線。

只要在runtime時將模型的法線抽出並計算平滑的法線後在儲存回記憶體中ㄉㄜmesh即可。在Mainthread上重新計算模型上所有頂點的法線滿耗效能的,但畢竟遊戲中只會在載入人物時計算一次+人物模型的面數都不高,目前的實作就都還是在mainthread。 (在較新版本的Unity中,同樣的操作可以用Job System/Burst來加速+避免卡mainthread)

Copy_of_Lighting_Model_%282%29.png

過於激進的平滑模型上的法線,其實不適合用在追求擬真的3D模型上,因為這會讓模型的細節消失,但單純透過模組改FF9這也是唯一能把原本模型上的flat shading法線解決的方法,而且剛好FF9的人物風格偏卡通,所以很適合。

環境光


有了平滑的法線、導入per-pixel光照之後,人物身上光照的明暗變化就好看了許多,但背光的部分如果沒有特別處理的話會是全黑,這是因為目前的shader缺乏Global Illumination中,環境光的漫反射(Diffuse)的關係

在下圖中,可以看到沒有環境光的shader,暗處過暗導致細節消失,而有環境光的shader不會有過暗的問題,同時其他部位的顏色與環境光混和後在場景之中也更加自然。

Copy_of_Lighting_Model_%284%29.png

我可以直接使用一個常數作為環境光,但是這樣在不同的場景的,環境光都會是一樣的,缺乏變化,而且只有單一顏色,會過於扁平,不適用於Blinn-Phong,我希望能有品質比寫死的常數還高一些的環境光。

以Unity為例,一般來說環境的漫反射主要的來源根據不同的workflow有以下幾種:

  1. 離線烘培時直接算到光照貼圖

  2. 離線烘培時,以二階球諧函數的型式儲存到場景的light probe

  3. 當沒有光照貼圖或者light probe可以使用時最終使用透過當前Skybox所算出的Diffuse Irradiance,一樣是以二階球諧的形式儲存/採樣。

  4. Enlighten GI

  5. SSGI (HDRP)

    💡 在沒有烘培、沒有特別設定的Unity專案中,3D物件的陰影偏藍的原因就是因為最終的fallback是預設skybox的diffuse irradiance的關係。

當然,FF9的這些選項預設都是關閉的,我能修改的範圍都在runtime,因此離線烘培一定是不可行的。我想到的方法是利用skybox算出的diffuse irradiance來當成環境光,運氣不錯的是 - Unity支援runtime更換skybox,而且透過呼叫DynamicGI.UpdateEnvironemt**,**Unity會自動把從skybox算出的diffuse irradiance以二階球諧的方式儲存起來。

那要怎麼在遊戲進行時動態產生skybox呢?

我用了非常偷懶的作法 -> 遊戲進行時在場景中生成Reflection Probe Component將環繞的景色作為CubeMap,將這個CubeMap塞進skybox material後,就可以傳給Unity當作場景的skybox了。

Copy_of_Lighting_Model_%285%29.png

比較麻煩的是reflection probe的位置與大小,FF9不論是戰鬥時還是非戰鬥,相機使用的矩陣以及使用方式與一般的Unity專案都有很大的不同,因此戰鬥中的3D場景跟人物的座標都是非常神奇的數字,我最終是直接在某個玩家隊伍人物頭上N公尺的地方生成reflection probe才正確捕捉到整個戰鬥場景。

成功在runtime產出對應的skybox後,實際在遊戲中測試,下圖的人物只顯示在不同的戰鬥場景中,動態產出的skybox所生成的環境漫反射。

Ambient_Lighting_Model.png

一樣是綠色系的環境漫反射,但可以看到在封閉的森林較暗,開放的山地則較亮,同時透過二階球諧儲存的漫反射可以根據人物表面的世界空間法向量得到不同的顏色,比單純寫死單一顏色好看很多。雖然只依賴skybox作為環境光源的結果比不上離線烘培,但對於FF9的戰鬥場景來說,已經非常夠用了。

💡 在Unity的Built-In RP中。只要是帶有”Forward Base” Tag的shader都能夠採樣到球諧的資訊。

Tone Mapping


採樣球諧中的環境光來補足人物身上的照明後,會出現另一個問題 - 人物過亮+偏白。FF9專案本身使用的buffer是非HDR的,也就是說對每個像素來說,shader output到畫面上的數值無法超過1,超過1都會被當成1。

Ambient_Lighting_Model_%282%29.png

可以在上圖中看到,加上環境光之後,雖然人物的暗處不會過暗,但就有可能在原本顏色就較亮的地方出現偏白的顏色,因為顏色的RGB值超過1,導致細節消失。

為了修正這個問題,需要將shader輸出的顏色從當前的範圍重新映射到0到1的區間,也就是Tone Mapping。我執行tone mapping的時間點方式不是在一般常用的全螢幕後處理階段,因為人物以外的像素並不需要做處理,我直接在人物shader中,在pixel shader的output輸出前,做一次tone mapping將人物的顏色重新映射回LDR。

Ambient_Lighting_Model_%283%29.png 上圖中原本頭頂上偏白、細節消失的部分,在tone mapping後原本的細節被成功保留下來。

💡 關於HDR與LDR可以參考LearnOpenGL的這篇說明

優化戰鬥場景的天空背景


FF9的戰鬥場景是全3D的,人物與敵人會站在場景的中心,其他3D的模型則是大多以圓形排列的方式環繞著中心。像是天空的效果大多都是用圓形或者圓柱狀模型+貼圖,透過旋轉這個模型來給讓背景的天空或雲會移動。

Ambient_Lighting_Model_%281%29.png

這一種天空背景的表現方式到現在都還是滿常見的(英文關鍵字是skydome),對美術或關卡的設計師來說設定很直覺,缺點就是效果滿依賴貼圖,而且當要新增加入的物件時 - 比如漂浮的雲,就要放很多billboard,並且讓這些billboard以玩家為中心繞圈。

對模改FF9來說,在場景中放入額外的mesh不太可行,最直覺的做法可能是使用一個更高解析度的天空貼圖,但因為場景的天空只有一個圓柱體,我仍然覺得這樣太單調太假。於是,我開始思考有沒有pixel shader的效果是可以在目前的圓柱狀模型上做出更生動的雲的效果,突然想到以前在摸Unity HDRP的時候他們有個Cloud Layer,並沒有使用更進階的技術,單純地透過FlowMap貼圖與調整採樣貼圖的向量就做出一個滿複雜的雲效果。稍微研究一下HDRP的shader code之後我覺得應該是可行的,於是我便試著把cloud layer中的shader code移植到Unity 5中。

原本的天空背景

使用重新寫的,包含cloud layer的shader所渲染出的天空背景

結果…出乎意料的好,稍微調整一下與原本貼圖顏色的混和比例以及tone mapping的程度後,我的天空有比以前生動N倍的雲特效了!

結果與後續


比較最終效果

到目前為止,人物/怪物的Shading都完成了。從下面的影片中我們可以看到2016年的PC版與2024年模改後的FF9在體驗上有著巨大的差異。 戰鬥的部分可以從25秒開始看 video

  • 左側為2016年釋出的FF9 - 僅遊戲原生解析度與UI解析度提高
  • 右側為2024模改後的FF9 - 高畫質背景、支援寬螢幕、戰鬥人物動畫的FPS從15提升到120FPS,還有寫這篇部落格時加入的per-pixel lighting以及更加生動的背景。

總結一下目前我修改FF9的shader使用到的技術:

  • blinn-phong/toon shading
  • 生成cubemap並計算出環境光,儲存於球諧中
  • 平滑法線
  • 利用每個像素到相機世界空間的view direction當成UV採樣貼圖(cloud layer)
  • Tone mapping

從2024的標準來看,沒光追,都是基礎到不行的東西,上面大多數的東西你都可以在LearnOpenGL中學到。 即使應用到的技術沒有很花俏,畫面給人的感覺就像是完全不同的遊戲,有點PS2遊戲HD Remaster後的感覺(自認)。

將cubemap算出的irradiance儲存到球諧的過程我這邊用了偷吃步,依賴Unity的API來自動幫我計算。如果要自己實作的話在上方所有功能中我覺得這是最複雜的一項…..可以參考BakingLab

為何不使用PBR?

其實要加入PBR是可行的,效能也不是太大的問題,最大的問題是 - 貼圖,PBR要好看,就需要定義人物身上每個部分的金屬/粗糙度的貼圖,遊戲中的人物與敵人種類實在太多,

後續

對於3D的戰鬥場景還有些screen space的shader是值得嘗試的,比如SSAO以及Bloom/Color Grading…等等。我有成功實作HBAO到戰鬥場景中,不過不論是品質或效能都還有很大的進步空間,修改FF9 shader的side project到這邊會先告一段落,未來如果真的哪天很閒,就會再screen space shader的部分做一些實驗吧。

參考資料