在 2025 年 10 月 24 日的游戲案例專場中,《戀與深空》制作組的引擎負責人阮天龍和技術美術負責人秦平帶來了演講《打造影視級渲染管線》,詳細闡述了如何在移動端實現影視級渲染效果。
阮天龍:大家好,我是疊紙游戲《戀與深空》的制作人阮天龍,很榮幸能夠站在 Unite 的講臺上。今天我們要給大家介紹的是“我們是如何在戀與深空這款游戲中打造影視級渲染管線”的。我叫阮天龍、是 2010 年進入的游戲行業,之前先后在盛大、完美和網易,從事過引擎開發相關工作,2018 年入職的疊紙,目前擔任《戀與深空》制作組的引擎負責人。
![]()
《戀與深空》作為一款超現實 3D 沉浸戀愛互動手游,自 2024 年 1 月 18 日上線以來,目前全球玩家的數量已經突破了 7000 萬、并榮獲了科隆游戲展 2025 最佳移動游戲。我們是基于 Unity 2019 開發的,并且對引擎源碼進行了深度修改,開發了一套自定義的 SRP 管線。Android線 上版本是基于 GLES3.1,未來也即將上線 Vulkan 版本。持續提升性能,滿足玩家對高品質游戲的需求。
接下來,看一段實機畫面的混剪視頻。
這次分享分為兩個部分:第一部分由我為大家介紹《戀與深空》的渲染管線設計與優化。主要涵蓋場景渲染的優化、光照的方案與管線設計、陰影優化這三個部分內容。第二部分將由我們的 TA 負責人秦平為大家介紹如何聚焦高質量角色表演核心渲染技術,來打造影視品質的效果。
渲染管線設計與優化
場景渲染優化
前面我們對《戀與深空》這個項目有了整體了解。現在,我們將聚焦到游戲開發中至關重要的一環——場景渲染優化。我們開發了一套場景渲染系統,稱之為 RenderGroupRenderer。我們將每個渲染批次稱之為一個 RenderGroup。RenderGroupRenderer 主要做了三件事情。首先是自定義靜態場景描述,去除 GameObject,避免了更新大量的 GameObject 時帶來的性能損耗。其次我們對 CPU 上 GPU 的 Upload 的頻率做了優化,主要包括了 Instance Data 和 Constant Buffer 的 Upload。Constant Buffer 的 Upload 優化方面,我會在后面介紹單 draw call 性能優化時詳細解釋。關于 Instance Data 的 Upload 優化,我們在項目初期主要針對室內小規模的場景,采用的是靜態生成 InstanceDataBuffer,配合 BVH 分割裁剪的形式。隨著項目的推進,我們的場景精度要求不斷地提高,后期便轉向在 GPU 端完成裁剪與 Instance 的填充。最后我們對靜態物件的 CPU 側裁剪也做了對應的優化。通過 Burst + Job System 實現了一套高度并發的裁剪系統,同時只在 CPU 側進行粗略的裁剪,將細粒度的裁剪任務交由 GPU 完成。
![]()
接下來想介紹一下我們的 InstanceData 的數據形式。大家常用的應該都是 Constant Buffer 形式的 InstanceData。但是它也有一些缺點。首先它有一個尺寸限制,通常是 64kb;其次常量緩存通常都比較小。當我們聲明的 buffer 比較大,并且通過 Instant ID 對它做動態索引時,即使沒有超過最大尺寸,也容易發生緩存擊穿,導致性能顯著下降。另一種常用的形式是用 SSBO 來傳遞 InstanceData,這種方法也存在一些缺點。首先它的讀取性能通常是不如緩存未擊穿情況下的 ConstantBuffer,甚至通常不如 Texture。很多移動設備芯片會為 Texture 設計更高效的緩存。其次,一些安卓設備在 GLES 下不支持在 Vertex Shader 中讀取 SSBO,這也限制了它的兼容性。最后這兩個方案都有一個共同的問題,就是都需要使用動態索引。在低端手機上,這是一種對緩存非常不友好的操作。針對以上問題我們提出了一種“新瓶裝舊酒”的方案。使用 PerInstance Step 的 Vertex Stream 作為 Instance Buffer。這是一種在 GPU Instancing 誕生之初就被支持的 Instance 方法,既可避免動態索引帶來的性能問題,也可避免 SSBO 的兼容性問題;還可以通過 Compute Shader 向 Instance Vertex Buffer 輸出,來實現 GLES 下兼容性較高的 GPU 積累。最后因為 Unity 引擎的底層沒有支持 PerInstance Step 的 Vertex Stream,所以我們對引擎做了相應的定制。最終暴露給上層的是 CommanBuffer 中添加的一個 DrawMeshInstancedTraditional 接口。它需要將另一個 mesh 作為 instance data 傳進來。我們也加了相應的接口來配置 instance mesh 中各個數據段對應的頂點 semantic。
![]()
接下來介紹一下我們的 GPU Driven 系統。首先我們會依據 Group 的數量和 Instance 的數量,提前分配 IndirectParameter Buffer 與 Instant Data Buffer,注意這里 Instance Data Buffer 只是提前分配了空間,實際的數據是在 GPU Cull 的時候填入的。同時我們會預計算每個 Group 的 instance offset,并將其存儲到 Parameter 的 InstanceStart 項。這樣我們就可以全程只綁定一份 instance buffer。此外我們還需要生成逐物件的信息 buffer,其中包含了 Group ID、LOD Distance Range、Bounds、Transform 等信息,用于在 GPU 裁剪時獲取每個物件的屬性。
![]()
在 GPU 裁剪之前,我們會先執行一次 CPU 粗裁剪,CPU 裁剪僅判斷 Group 整體是否可見,從一個根包圍盒開始,比較物件包圍盒體積總和與合并后包圍盒的體積比值,低于閾值就遞歸分裂包圍盒。這個主要是為了避免兩個物件距離很遠,拉出一個超大的總包圍盒這種情況。同時我們還會配合 PVS 進一步判斷 Group 的可見性,因為我們沒有類似 DX12 的 IndirectExecute,我們的 GPU 裁剪只能減少 instance 數,并不能消除 Group 整體的 drawcall,因此需要 CPU 裁剪盡可能準確地剔除掉完全不可見的 Group。GPU 裁剪則通過一次 dispatch 對所有 Group 進行逐物件裁剪,包含視錐裁剪、LOD 裁剪、Hiz 遮擋剔除這 3 段裁剪,通過裁剪就將 Parameter 的 Instance Count 加 1,并輸出 InstanceData。對于陰影剔除,我們參考了龍之教條分享的方法,將畫面深度重投影到陰影空間作為 Shadow Receiver Mask,若 shadow caster 投出的 volume 與 Mask 不相交,就可剔除,避免多余陰影渲染。另外想解釋一下我們為什么沒有實現 Cluster/Meshlet。首先它不是免費的,在移動端存在比較大的基礎開銷。其次在 GLES 下實現 cluster 也存在一些兼容性問題,同樣是之前提過的 Vertex Shader 訪問 SSBO 的問題。綜合考慮之下,我們認為優先優化單 draw call 的性能更能為我們帶來免費且直接的性能提升。
![]()
接下來介紹一下我們對 draw call 調用本身做的性能優化。為什么我們今天要著重介紹這塊內容呢?因為我發現我們周圍有很多同事或同行對于渲染的 CPU 耗時優化這塊,往往過于關注 draw call 的數量,而忽視了每個 draw call 本身的耗時。降低 Draw Call 數量只是一種優化方法,最終的 CPU 耗時才是唯一的衡量指標。而且現代移動設備與圖形標準,其實早就可以勝任大量的 drawcall,這塊 HypeHype 引擎的團隊在 Siggraph 2023 有過一個分享,他們在 iPhone 6s 上測試了一萬個不同 mesh 與材質的 drawcall,結果耗時僅有 11.27ms。其他同等的安卓設備也都基本能維持在 60 幀以上。另外在 2014 年,蘋果的 Metal 剛剛誕生時,也早就提出過比 GLES 多畫 10 倍 draw call 的口號。如果當年大家已經入行了的話,一定聽不少人說過,等新的圖形標準普及了以后就不需要合批了之類的話。那么 11 年后的今天,我們為什么仍在為 draw call 過多而苦惱?原因在于多方面的開銷。我們總結了一下,主要包括 PSO 切換過多、Buffer 提交與拷貝、引擎渲染邏輯以及過多 RHI 調用的開銷,都會增加 CPU 負擔。所以性能優化不能只盯著 draw call 數,而要綜合考量這些因素。
![]()
首先關于 PSO 切換的開銷,其實主要取決于每個項目對 shader 變體數量和 shader 復雜度的權衡。我們的 RenderGroupRenderer 也只是做了一些基本的渲染隊列排序。不過我們對于陰影做了一個特殊處理,沒有 AlphaTest 的材質統一用相同 shader 渲染 Shadow Depth,可以減少陰影渲染時的 PSO 切換頻率。相對而言我們在 Buffer 提交方面做了更多優化。在 GLES 下,Map/Unmap buffer 會帶來顯著開銷,現代 RHI 支持的 persistent map 雖能顯著減少 upload 耗時,但仍無法避免數據從主線程到渲染線程,再到 buffer 內存的多次拷貝以及 memcmp。我們采用了三種針對性的策略:PerRendererBuffer 將逐 Renderer 的參數,比如物體所受的環境光 SH,存放在由 Renderer 對象維護的 Uniform Buffer 中,渲染時直接綁定;PerShaderBuffer 針對不需要逐材質變化的 Uniform Buffer,只在 shader 切換時提交一次,相比 PerRendererBuffer 來說,PerShaderBuffer 更加靈活,可以支持不同的 shader 變體;最后針對 PerMaterialBuffer,我們借用了 SRP Batcher 代碼預生成逐材質 buffer 并直接綁定,通過這些方法顯著減少了 buffer upload。
![]()
接下來是渲染邏輯的優化。商業游戲引擎為保證靈活性與穩定性,渲染時會進行復雜的邏輯判斷。在 Unity 引擎內部,每次調用 draw 的時候會先調用一個 ApplyMaterial 函數,它負責在渲染之前更新所有的渲染狀態與參數。我們發現當 draw call 數量較多時,它存在一些較為可觀的耗時。因此我們嘗試對 ApplyMaterial 接口進行了單獨拆分拆,僅在材質或參數需要切換時才由上層主動調用,另外如果我們只需改變 PerMaterialBuffer,就改用簡化后的專用接口。做完上述優化之后,相同 draw call 數下,CPU 耗時可以減少 1/3。RHI 調用優化主要的目標是減少除了 draw primitive 以外的其他圖形 API 調用。我們將相同 stride 的 Vertex&Index Buffer 合并到一個 buffer 里,通過 offset 渲染,可以避免逐 draw call bind VB/IB,耗時可以減少 15%。另一項優化是當 Resource 未發生變化時,我們跳過了 DescriptorSet 設置。SetDescriptors 本身是個耗時很高的接口,而且切換 Descriptor 還會增加下一次 draw 的耗時,這個在 Arm 的 Best Practice Guide 里有過介紹。做完這個可以使耗時進一步減少 30%。
![]()
我們在低端安卓設備上測試了 5000 個 drawcall 的耗時。使用引擎原生的渲染時,渲染線程的耗時是 34.79ms。當我們對 Buffer 提交與渲染邏輯進行優化后,耗時降低到 22.97ms。在進一步優化 RHI 調用次數后,耗時進一步大幅降至了 11.8ms。最終我們在 draw call 數量不變的前提下,讓 CPU 耗時減少到了原來的 1/3 以下。
![]()
我們還嘗試了一些新的 RHI 特性。首先是 MDI,它在支持的設備上能帶來明顯的優化效果,還能在一定程度上改善 GPU 遮擋剔除可能提交空 draw call 的問題,因為不同的 mesh 被合成了一個 draw call,總的 CPU 端提交變少了。然而, Bindless 的表現卻不盡如人意,即便在最新的安卓設備上也出現了神秘的負優化。結合 MDI 與 Bindless,我們可以實現幾乎用一個 draw call 渲染所有物件,但是 CPU 耗時卻比不合批時還更高。這也是一個過度關注 draw call 數量的反面案例。當然,我們期待以后的移動芯片對 bindless 能有更好的支持。現階段的話,我們嘗試基于 Unity Texture Streaming 擴展出了一套無 Feedback SVT 系統作為替代方案,不過這個方案也還在驗證階段。從 Benchmark 場景測試結果來看,RenderGroupRenderer 對比原始無 instancing 渲染,draw call 數減少了 1/3,渲染線程耗時大幅減少 3/4,主線程耗時也減少了 2/3。雖然C,因為渲染與裁剪邏輯都移到 SRP 了,但是引擎原生裁剪與 GameObject 更新耗時減少,整體仍然帶來了大幅的優化。
光照方案
前面我們探討了渲染性能的多方面優化。現在讓我們將目光轉向光照的實現方案。我們在項目中選擇使用前向渲染管線,這有多方面的理由。首先,前向管線在應對美術復雜且多變的需求方面有其優勢,我們不需要擔心一些材質屬性的添加是否會導致 GBuffer 膨脹。其次,傳統的延遲管線對于移動平臺而言帶寬不太友好。 OnePassDeferred 則在靈活性方面存在一些局限,比如無法在 RenderPass 中間改變 RT 的尺寸,也不能 fetch 當前位置以外的像素內容。另外在 GLES 下, FrameBufferFetch 的兼容性也存在問題,不同芯片支持的 fetch RT 數量不同,有的只支持 1 張 RT,需要改成通過 PLS 實現,但是我們測試 PLS 的性能并不是很理想。引擎自帶的逐物件 4 盞光源對于較大的物件來說不太夠用,因此我們嘗試了 Forward+。但是 Forward+ 在早期設備上耗時太高,若限制逐 tile 最大光源數,鏡頭變化時,tile 內光源數量不可控,超上限會帶來表現 bug。為解決這些問題,我們采用了水平世界空間 Tile 劃分,默認 2 米一格,分布于相機前方,逐 Tile 最多 4 盞光源,用一張 128*128 Index Map。這種劃分方式使 Tile 光源重疊狀態穩定,便于在制作時及時發現超限問題。
![]()
我們在未來的 Vulkan 版本的管線中增加了基于 Subpass 的 Light Pre-pass。在 Pre-Z Pass 中,我們會輸出一張簡易的 GBuffer RT 并且 store 下來。由于我們的 local light 光照使用了沒有 fresnel 的簡化 PBR 模型,所以我們不需要在 GBuffer 中輸出 specular 或者 Albedo。我們只將 normal,roughness 和一些特殊的材質 id 或屬性信息 pack 到了一張 RGBA8 的 Gbuffer 上。然后我們就可以跑一遍類似 deferred shading 的光源 volume 渲染流程,將幾何光照結果保存到 Tile Memory 上。之后在 shading pass 中,我們會把物件再畫一遍,然后 fetch 這些光照信息,再結合渲染時獲得的 albedo 等材質屬性,就可以得到最終的光照結果。我們將 TAA 所需的 MotionVector Encode 為 RGBA8,R + G == 0 代表無有效速度。這樣某些不輸出速度的材質可在 BA 通道存其他信息,比如我們針對一些簡易且大量的植被,會在 MotionVector 的 BA 通道上保存他們的 UV 信息,這樣在 shading pass 時我們就不需要再畫一遍植被,只需要一個后處理獲取 gbuffer 中的幾何信息與 MotionVector 中的 UV 信息即可還原出植被的材質表現。
![]()
Vulkan 版本的管線大致是這樣的流程,首先由 PreZ Pass 輸出 Depth,GBuffe與 MotionVector。然后計算陰影的遮擋剔除,接著執行陰影的深度渲染,再然后是一些 AO 和屏幕空間 SSS 之類的計算。然后我們就進入了 NativeRenderPass,在 SubPass 中計算 ShadowMask,Light Pre-Pass,以及執行正常的 Shading Pass。最后退出 RenderPass,再執行其他后處理 Pass。Vulkan 版本管線改進存在一定局限。Light Pre-Pass 只能替換默認 Lighting Model,對于需要更多 Gbuffer 通道的 Lighting Model,還是需要采用 Forward+。不過我們提供了一個逐光源可選參數,可以針對某個光源強行使用 Standard Lit Model,對所有材質統一處理,這樣可以在犧牲 lighting model 準確性的條件下實現讓同 Tile 內的像素受 4 盞以上燈的影響。
![]()
然后是 GI 部分。我們線上的 Diffuse GI 用的還是比較傳統的 Lightmap+light probe 的方式,我們的 Lightmap 只保存了間接光信息。另外我們正在開發一套實時 GI 系統,相信不久就能和大家見面。我們的 Light Probe 除了正常的逐物件單個采樣點的模式以外,還提供了一種多采樣點模式。能為每個物體設置多個采樣點,依據線段、三角形或四面體的重心坐標進行插值。下面是兩張對比圖,左邊這張圖是單采樣點的效果,可以看到這個 box 的底部受到的是統一的環境光照。右邊的是用了兩個采樣點的結果,可以發現左右兩邊受到了不同的間接光照。Specular GI 方面,我們主要是基于使用了 AABB 校正的 Reflection Probe。另外對于一些特定的地板或水面,我們還會使用平面反射代理。大致可以看成一種專門用來畫反射的 HLOD。此外我們還參考了戰神的做法,對 Reflection Probe 的CubeMap 做了歸一化。具體來說就是我們會根據 CubeMap 的像素生成一份環境光照的 SH 系數,然后將 Cubemap 中的像素顏色與該方向的環境光照相除,就得到了歸一化的 Cubemap。實際渲染時,我們再用每個像素在反射方向上所受的實際環境光照與 Cubemap 像素相乘,還原出反射顏色。這么做的好處是,即使大量的物件采樣的都是同一個 Reflection Probe,不同區域的反射也能產生不同的明暗差別。
![]()
陰影優化
前面我們探討了光照和渲染管線相關的內容。接下來,我們來進入“陰影優化”這一環節。
我們陰影系統的基本設計是,最多 3 級 cascade 的 CSM 加上一級角色專屬的特寫陰影,或者在某些多角色場景時會使用 POSM,也就是 Per-Object Shadow Map。我們目前支持兩盞錐燈投影,上面所有陰影的結果都輸出到了一張 RGBA8的 ScreenSpaceShadowMask上,R 通道保存主光陰影,G 和 B保存了錐燈陰影,A 通道保存了 AO 信息。我們首先做了一個簡單的距離剔除,根據陰影距離修改 ScreenSpaceShadow 后處理三角形頂點的深度值。再用 ZTest Greater 渲染,剔除陰影距離外的 Shadow 計算。因為我們在計算陰影時需要采樣 depth,所以我們需要兩份 depth 分別用于 Test 與 Sample。我們在NativeRenderPass 中拷貝了一份 memoryless 的 depth buffer 用于 Test,盡量避免了額外的讀寫帶寬。
![]()
另外我們還做了一個半影區域檢測功能。我們先在 1/4 分辨率下計算一次 PCF,隨后在全分辨率 shadow pass 里采樣 1/4 mask,僅對 shadow 值處于中間區域的像素執行全分辨率 PCF,這樣能在保證效果的同時,降低計算量。不過,這么做之后某些細節像素會存在檢測不準確的問題。為此,我們分別依據 1/4 buffer 中 position 的偏導與全分辨率 gather 的 4 個深度值計算兩組法線。如果法線夾角大于閾值,就判定低分辨率像素不可靠,并強行執行全分辨率 PCF。下面是一個祈煜畫室場景的 debug 視圖,紅色區域是被我們判定為半影區間的區域,只有這些像素才會執行全分辨率的 PCF。
![]()
我們利用 Receiver Plane Depth Bias 算法實現了逐像素的 Shadow Bias。它的原理也比較簡單,首先我們求出屏幕空間 Shadow Coordinates 的偏導,然后我們可以發現,我們如果使用屏幕空間的 UV 偏導對陰影空間的深度偏導做個二維鏈式轉換,就能得到屏幕空間的深度偏導。將鏈式轉換描述為雅可比矩陣之后,輕易就能得出陰影空間的深度偏導等于雅可比矩陣的逆乘以屏幕空間深度偏導。這個偏導值可以近似描述像素在陰影空間深度的坡度,進而就可以用它與 PCF 的采樣偏移量相乘,得到近似的 bias 值。對于中心點來說,我們增加了 1 個像素偏移的 bias 結果作為起始 bias。
![]()
下面是逐像素 bias 與固定 bias 的對比結果。左邊用的是固定的 bias 值,可以看到 box 的底部有一段漏光區域,并且與光照方向接近垂直的表面存在一些自陰影的走樣。用了逐像素 bias 之后,我們就只會在偏導較大的區域增加 bias,可以在保持細節投影的同時解決自陰影的走樣問題。不過,當屏幕深度不連續時,逐像素 bias 可能算出錯誤結果,導致一些漏光現象。為了解決這一問題,需要美術手動指定 bias 的最大最小范圍。
![]()
另外我們針對 draw call 較多的場景,還嘗試了 Scrolling Cached Shadow Map。我們通過緩存 CSM 的深度,對于前后兩幀都被陰影視錐完全包含的對象,將上一幀的 CSM 滾動到當前幀投影位置直接得到陰影深度,避免直接渲染對象。我們只對最后一級 cascade 應用了 scrolling,當 cascade 范圍比較小時,大量物體與會與視錐相交,優化效果就會受限。另外針對移動平臺的帶寬壓力,我們選擇間隔多幀來更新 CSM 緩存。最后在未來,我們還準備支持 Local ShadowMap Atlas 以及緩存機制。我們將會支持兩盞以上的局部燈投影,并且根據光源的屏占比動態調整 ShadowDepth 精度。對于遠距離的局部光源,也會引入靜態緩存支持。
![]()
以上是我本次分享引用的一些參考資料。下面將由我們的 TA 負責人秦平為大家介紹如何打造影視品質的效果,謝謝大家!
打造影視品質效果
![]()
秦平:各位朋友,大家好!前面我的搭檔分享了《戀與深空》項目渲染底層框架的一些內容,接下來我來分享一下我們在這樣的框架下面是如何打造一個高品質效果的。主要是一些方案相關的東西。我叫秦平,十七年游戲行業從業經驗,曾經有幸參與制作《神秘海域4》、《怪物獵人世界》等游戲項目的制作;2017 年加入疊紙成為疊紙的第一個 TA,參與開發了《閃耀暖暖》項目,現任《戀與深空》項目 TA 負責人;因為之前做過一些主機游戲,所以我對高品質的游戲效果一直都有一些追求,今天借這個機會跟大家聊一聊《戀與深空》項目追求角色效果表現的路上遇到的一些問題和解決思路。
這里我大致總結了一下對我們角色表現影響比較大的幾個方面:首先就是分鏡,我們每一個劇情表演和每一個約會,都是采用了影視級的導演分鏡思維來指導創作的。一個好的分鏡思維對一個好的作品是必不可少的內容。然后我們大量采用了真人動捕的技術來提升動作表現。有了這個增進和動作之后,我們還需要一個專用的劇情編輯工具。這里我們工具組的同學花了大概大半年的時間,精心打造了一套專用的劇情表演工具,配合上 AI 組同學支持的一個口型和表情系統。最后是定制化的光照和渲染方案,這個也是我這次主要要跟大家分享的東西。
角色光照方案
我這邊先分享的是“角色光照方案”,這里講的“光照方案”不是大家熟知的前向渲染、延遲渲染這些管線相關的內容。我這個方案指的是一個解決思路,比如我角色應該受哪些光,怎么管理這些光。經常有美術的同學報怨,我的場景和角色割裂感很重;我一個近幀打光打好了之后效果還可以,但是鏡頭和角色一動起來效果就崩了,這些問題經常會困擾我們。其中最突出的幾個點:
第一是場景氛圍和角色燈光要求沖突。比如夜晚場景需要冷色調營造氛圍,但是角色面部一般都需要暖色調保持自然膚色,如果想要用一個冷色調去著重表現當前是一個夜晚的氛圍,就很容易讓角色顯得不自然或者失去立體感;第二個跟前面這個是息息相關的,就是場景和角色的美術同學需要反復溝通。比如一個室內場景做好了,但是角色入場之后發現這個表演的位置透不進光,想在這個角色附近開個窗,透一個光進來;或者補一個光源,比如臺燈。場景補好之后劇情編輯的時候可能又發現這新增的物體會在表演過程當中遮擋鏡頭,或者是角色動作會穿到這個物體里面,反反復復增加了很多溝通成本;三就是如果每一張卡,每一個劇情、每一個表演都要從零開始的話,會帶來海量的工作內容;最后就是我們希望我們提供的作品每一幀都經得起反復觀看,這就需要燈光和效果逐幀精修。
![]()
有了這些問題,我們就可以把具體需求拆解出來了,我們需要角色和場景可以分開調整,盡量互不影響;要支持逐幀調整燈光參數;還需要支持把調整好的效果保存成模板,支持編輯和切換功能。
![]()
有了明確的需求就可以開始干活了:我們知道光照是由直接光和間接光組成的,直接光正常就是平行光、射燈和點光。一般情況下我們只會有一個平行光,我們習慣稱之為主光。對這個主光,我們讓他正常照亮場景,但在照亮角色的時候我們保留了它的方向,然后用角色 PPV(Post Process Volume)去復寫它的顏色和強度。具體實現方式就是給 shader 多傳了一份角色主光顏色,角色的 shader 在獲取主光的時候獲取到的顏色就是這個角色主光顏色;另外我們給角色提供了一盞額外的不投影的平行光用來做輪廓光;此外我們預留了兩個額外光給角色,他可以是任意的點光和射燈組合,這兩個額外光就是正常的光源,他們可以正常照亮范圍內的角色和場景物件。因為我們一個 2 米的格子最多可以有四盞額外光,所以在這里劃分了一下,角色兩盞,場景兩盞。間接光我們使用 Unity 的 LightProbe 系統來創建探針,自己實現了保存間接光信息到探針里的部分。我們把場景的環境光和角色的環境光分開存儲,分成了兩套;至于環境光高光還是使用的同一個反射球,但是我們在特殊的材質上支持了一些 cubemap 的覆蓋收入。
![]()
然后我們把這些影響角色的光照信息存到一個 scriptableobject 里,由燈光師調整好之后保存成一個模板;大家可以看最右邊這張圖就是我們保存下來的 scriptableobject。它包含了我上面提到的兩盞平行光,兩個額外光,還有探針保存下來的 sh 信息,以及一些后處理盒子上可以額外調整的信息。比如主光顏色、額外光顏色;最后用一個 manager 去用一種類似棧的方式去管理起來,這里選用棧的管理方式跟它的使用關系很大,通常情況下除了加載新的燈光方案之外,“棧”就很好的滿足了這個特性、我們就用這個方式把它管理了起來。
![]()
到這里角色燈光方案基本上就完成了,它實現了我們拆分出來的需求。比如:角色和場景可以分開調整,還可以實時的切換。這個動圖就是切換不同的光照方案時這個角色的表現。
我們把切換燈光方案這個事情定義為一個劇情編輯器上的一個事件,這樣它就天然支持后續可以銜接光照動畫。因為只有在切換這個事件發生的那一幀,然后修改了當前的這些參數,后續就可以對這些參數額外進行 K 幀。下面是一段劇情編輯器上動態 K 幀的一個視頻,給大家展示一下我們可以逐幀調整光照動畫的內容。可以看到,這個視頻上面很多的參數都是被 K 了偵的、包括一些后處理、燈光、陰影,那些藍掉的地方全部都是 K 了幀的。但是不僅是光會用,劇情編譯器是我們所有表演的入口,比如:物理之類的東西,也是在這個地方 K 進去的。
特寫陰影
光和影一直都是密不可分的,前面介紹了光,接下來介紹一下陰影。前面我的搭檔也有提到,我們的陰影方案是三級 CSM 加特寫陰影,這里我著重介紹一下特寫陰影。我們特寫陰影就是在角色身上選一根骨骼,然后作為一個球心,然后用這個球心來指定一個指定半徑的球。可以看到這里有一個子系統,子系統上面有這個包圍球半徑,還有這個負節點,這個節點就是指定的這根骨骼,然后用它作為球心,然后用這個 0.85 作為半徑來構建一個球。如果有兩個角色的話,我們會對這兩個球進行一個包裹,用算法求一個包裹球,然后用這個球來計算最終的 ShadowMap。
這個視頻里面我們可以看到,就是原本因為在室內整個都在陰影里面的這個角色,因為我打開了特寫陰影,然后他的面部被照亮了。我這里展示的主要就是想要說明我們這個特寫陰影的很多參數都可以單獨的調整。比如說這個近裁切平面,近裁切平面往前推室內的墻就被不投影了,然后這個角色就被照亮了。這個特寫陰影上面這些參數都是可以改動的,我希望調整到一個角色最好的效果,然后呈現給玩家。
![]()
皮膚細節
說完了光影,接下來聊一聊角色的皮膚、臉上細節相關的內容。皮膚分了兩檔:高配就是屏幕空間的 3S,SSS 圖降了分辨率,就相當于做了一次模糊。低配的其實是用了一個工具,去實時地擬合和保存這預積分圖,這里不做過多展開。接下來,我們聚焦一些細節的表現。例如:臉紅、流汗的效果,當然我們也做了一些張嘴、閉嘴的 AO,以及可以 k 幀的眼睛高光,睜眼閉眼的雙眼皮的一些效果。由于時間有限,這里先聊一下臉紅和流汗的效果。這里我錄了一張卡,可以展示角色的臉紅的表現。當女主和男主進行一些互動的時候,如果我改變臉上皮膚的顏色、可以很明顯地讓畫面看起來更加的生動和更真實。
通常來講,“臉紅”的過程是一個逐漸變化、并且不同區域變化程度不一樣的過程。大部分的人在臉紅的時候會從耳朵先開始紅,然后臉頰,偶爾會有整張臉變得通紅的情況。為了模擬這個過程,我們把“臉紅”做成了一個可以區分通道的遮罩圖。每一個通道對應一個區域。這里也分別提供了對應的色塊,去改變臉紅的顏色。如果逐幀去 K 的話,這個工作量也會非常大。這里我們也是采用了模板的思路,美術同學可以根據不同的男主,定制不同的臉紅的曲線做成模板。我們只要在 K 臉紅效果的時候,調用對應的模板、就可以做到對應的臉紅效果。
![]()
臉紅到一定程度,比如:我現在是在運動,運動的時候臉非常的紅,可能我接下來就會流汗。如果只有臉紅沒有流汗的效果還是不夠真實,接下來我們做了流汗的效果。我們的流汗有兩種:一種是粒子,主要用的是 vfx,用 skinmeshrender 發射粒子出來,用來描述一些“甩汗”的效果。材質上的實現,也分為兩部分:一是修改粗糙度,讓皮膚看起來比較濕潤。第二個就是凝聚成的汗珠,這個凝聚汗珠的實現思路與 Unite2019 閃耀暖暖里分享的實現思路是一樣的,它詳細介紹了一個閃點,有興趣的同學可以翻一翻當年的這篇分享。這就是大概的一個實現思路。
![]()
下圖最上面這個部分就是計算汗滴生成位置,并根據汗滴的位置修改粗糙度。我們先把 uv 劃分成格子,然后把格子的 ID 做成材質的輸入。這樣每個格子里所有的 uv 就會返回同一個隨機數,我們把這個隨機數處理當成汗滴生成的位置,詳細代碼如下圖。下方的視頻就是最終實現出來的流汗效果。流汗時汗滴流下來就是 uv 動畫。但為了讓汗滴流下來的過程更真實,我們做了一個模擬它軌跡的很小的算法實現(下圖右下角)。
![]()
謝謝大家,我的分享結束了。
Unity 官方微信
第一時間了解Unity引擎動向,學習進階開發技能
每一個“點贊”、“在看”,都是我們前進的動力

特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.