在Unity中實現2D光照系統

遊資網發表於2019-06-21
在Unity中實現2D光照系統

在一些2D遊戲中引入實時光影效果能給遊戲帶來非常大的視覺效果提升,亦或是利用2D光影實現視線遮擋機制。例如Terraria,Starbound。

在Unity中實現2D光照系統

在Unity中實現2D光照系統

2D光影效果需要一個動態光照系統實現,而通常遊戲引擎所提供的實時光照系統僅限於3D場景,要實現圖中效果的2D光影需要額外設計適用於2D場景的光照系統。雖然在Unity Assets Store上有不少2D光照系統外掛,實際上實現一個2D光照系統並不複雜,並且可以藉此機會熟悉Unity渲染管線開發。

本文將介紹通過Command Buffer擴充套件Unity Built-in Render Pipeline實現一個簡單的2D光照系統。所涉及到的前置技術棧包括Unity,C#,render pipeline,shader programming等。本文僅包含核心部分的部分程式碼,完整程式碼可以在我的GitHub上找到:

SardineFish/Unity2DLightinggithub.com

2D Lighting Model

首先我們嘗試仿照3D場景中的光照模型,對2D光照進行理論建模。

在現實世界中,我們通過肉眼所觀測到的視覺影象,來自於光源產生的光,經過物體表面反射,通過晶狀體、瞳孔等眼球光學結構,投射在視網膜上導致視覺細胞產生神經衝動,傳遞到大腦中形成。而在照片攝影中,則是經過鏡頭後投射在感光元件上成像並轉換為數字影象資料。而在圖形渲染中,通常通過模擬該過程,計算攝像機所接收到的來自物體反射的光,從而渲染出影象。

1986年,James T.Kajiya在論文THE RENDERING EQUATION[1]中提出了一個著名的渲染方程:

在Unity中實現2D光照系統

3D場景中物體表面任意一面元所受光照,等於來自所有方向的光線輻射度的總和。這些光經過反射和散射後,其中一部分射向攝像機(觀察方向)。(通常為了簡化這一過程,我們可以假定這些光線全部射向攝像機)

而在2D平面場景中,我們可以認為,該平面上任意一點所受的光照,等於來自所有方向的光線輻射度的總和,其中的一部分射向攝像機,為了簡化,我們認為這些光線全部進入攝像機。這一光照模型可以用以下方程描述:

在Unity中實現2D光照系統

即,平面上任意一點,或者說一個畫素(x,y)的顏色,等於在該點處來自[0,2π]所有方向的光的總和。其中Light(x,y,θ)表示在點(x,y)處來自θ方向的光量。

該方程來自 Milo Yip的一篇文章:

Milo Yip:用C語言畫光(一):基礎zhuanlan.zhihu.com

基於這一光照模型,我們可以實現一個2D空間內的光線追蹤渲染器。去年我在這系列文章的啟發下,基於js實現了一個簡單的2D光線追蹤渲染器demo

Raytrace 2Dray-trace-2d.sardinefish.com

關於該渲染器,我寫過一篇Blog:2D光線追蹤渲染,借用該渲染器渲染出來的2D光線追蹤影象,我們可以對2D光照效果做出一定的分析和比較。

在Unity中實現2D光照系統

2D Lighting System

Light Source

相較於3D實時渲染中的點光源、平行光源和聚光燈等多種精確光源,在2D光照中,通常我們只需要點光源就足以滿足對2D光照的需求。

由於精確光源的引入,我們不再需要對光線進行積分計算,因此上文中的2D光照方程就可以簡化為:

在Unity中實現2D光照系統

即空間每點的光照等於場景中所有點光源在(x,y)處光量的總和。為了使光照更加真實,我們可以對點光源引入光照衰減機制:

在Unity中實現2D光照系統

其中d為平面上一點到光源的距離,t為可調節引數,取值範圍[0,1]

所得到的光照效果如圖(t=0.3):

在Unity中實現2D光照系統

光照衰減模型還有很多種,可以根據需求進行更改。

Light Rendering

在有了光源模型之後,我們需要將光照繪製到螢幕上,也就是光照的渲染實現。計算光照顏色與物體固有顏色的結合通常採用直接相乘的形式,即color=lightColor.rgb*albedo.rgb,與Photoshop等軟體中的“正片疊底”是同樣的。

在Unity中實現2D光照系統

在3D光照中,通常有兩種光照渲染實現:Forward Rendering和Deferred Shading。在2D光照中,我們也可以參考這兩種光照實現:

Forward:對場景中的每個Sprite設定自定義Shader材質,渲染每一個2D光源的光照,然而由於Unity渲染管線的限制,這一過程的實現相當複雜,並且對於具有N個Sprite,M個光源的場景,光照渲染的時間複雜度為O(MN)。

Deferred:這一實現類似於螢幕後處理,在Unity完成場景渲染後,對場景中的每個光源,繪製到一張螢幕光照貼圖上,將該光照貼圖與螢幕影象相乘得到最終光照效果,過程類似於上圖。

顯然在實現難度和執行效率上來說,選擇Deferred的渲染方式更方便

Render Pipeline

在Unity中實現這樣的一個光照渲染系統,一些開發者選擇生成一張覆蓋螢幕的Mesh,用該Mesh渲染光照,最終利用Unity渲染管線中的透明度混合實現光照效果。這樣的實現具有很好的平臺相容性,但也存在可擴充套件性較差,難以進行更復雜的光照和軟陰影生成等問題。

因此我在這裡選擇使用CommandBuffer對Unity渲染管線進行擴充套件,設計一條2D光照渲染管線,並新增到Unity Built-in Render Pipeline中。對於使用Unity Scriptable Render Pipeline的開發者,本文提到的渲染管線亦有一定參考用途,SRP也提供了相應擴充套件其渲染管線的相關API。

總結一下上文關於2D光照系統的建模,以及光照渲染的實現,我們的2D光照渲染管線需要實現以下過程:

1.針對場景中每個需要渲染2D光照的攝像機,設定我們的渲染管線

2.準備一張空白的Light Map

3.遍歷場景中的所有2D光源,將光照渲染到Light Map

4.抓取當前攝像機目標Buffer中的影象,將其與Light Map相乘混合後輸出到攝像機渲染目標

Camera Script

要使用CommandBuffer擴充套件渲染管線,一個CommandBuffer例項只需要例項化一次,並通過Camera.AddCommandBuffer方法新增到攝像機的某個渲染管線階段。此後需要在每次攝像機渲染影象前,即呼叫OnPreRender方法時,清空該CommandBuffer並重新設定相關引數。

這裡還設定ExecuteInEditMode和ImageEffectAllowedInSceneView屬性以確保能在編輯器的Scene檢視中實時渲染2D光照效果。

這裡選擇CameraEvent.BeforeImageEffects作為插入點,即在Unity完成了場景渲染後,準備渲染螢幕後處理前的階段。

  1. using System.Collections;
  2. using System.Linq;
  3. using UnityEngine;
  4. using UnityEngine.Rendering;

  5. [ExecuteInEditMode]
  6. [ImageEffectAllowedInSceneView]
  7. [RequireComponent(typeof(Camera))]
  8. public class Light2DRenderer : MonoBehaviour
  9. {
  10.     CommandBuffer cmd;
  11.     // Init CommandBuffer & add to camera.
  12.     void OnEnable()
  13.     {
  14.         cmd = new CommandBuffer();
  15.         GetComponent<Camera>().AddCommandBuffer(CameraEvent.BeforeImageEffects, cmd);
  16.     }
  17.     void OnDisable()
  18.     {
  19.         GetComponent<Camera>().RemoveCommandBuffer(CameraEvent.BeforeImageEffects, cmd);
  20.     }
  21.     void OnPreRender()
  22.     {
  23.         // Setup CommandBuffer every frame before rendering.
  24.         RenderDeffer(cmd);
  25.     }
  26. }
複製程式碼

Setup CommandBuffer

由於我們要繪製一張光照貼圖,並將其與螢幕影象混合,我們需要一個臨時的RenderTexture(RT),這裡設定Light Map的貼圖格式為ARGBFloat,原因是我們希望光照貼圖中每個畫素的RGB光照分量是可以大於1的,這樣可以提供更精確的光照效果和更好的擴充套件性,而預設的RT會在混合前將緩衝區中每個畫素的值裁剪到[0,1]。

在臨時RT使用完畢後,請務必Release!請務必Release!請務必Release!(別問,問就是顯示卡崩潰)

  1. public void RenderDeffer(CommandBuffer cmd)
  2. {
  3.     cmd.Clear();

  4.     // Render light map
  5.     var lightMap = Shader.PropertyToID("_LightMap");
  6.     cmd.GetTemporaryRT(lightMap, -1, -1, 0, FilterMode.Bilinear, RenderTextureFormat.ARGBFloat);
  7.     cmd.SetRenderTarget(lightMap);
  8.     cmd.ClearRenderTarget(true, true, Color.black);
  9.     var lights = GameObject.FindObjectsOfType<Light2D>();
  10.     foreach (var light in lights)
  11.     {
  12.         light.RenderLight(cmd);
  13.     }

  14.     var screen = Shader.PropertyToID("_ScreenImage");
  15.     cmd.GetTemporaryRT(screen, -1, -1);
  16.     // Grab screen
  17.     cmd.Blit(BuiltinRenderTextureType.CameraTarget, screen);
  18.     // Blend light map & screen image with custom shader
  19.     cmd.Blit(screen, BuiltinRenderTextureType.CameraTarget, LightingMaterial, 0);

  20.     // DONT FORGET to release the temp RT!!!
  21.     // OR your graphic card may crash after a while due to the memory overflow (may be) :)
  22.     cmd.ReleaseTemporaryRT(lightMap);
  23.     cmd.ReleaseTemporaryRT(screen);
  24.     cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget);
  25. }
複製程式碼

最終用於光照混合的Shader程式碼非常簡單,這裡使用了UNITY_LIGHTMODEL_AMBIENT引入一個場景全域性光照,全域性光照可以在Lighting>Scene皮膚裡設定:

  1. fixed4 frag(v2f i) : SV_Target
  2. {
  3.     float3 ambient = UNITY_LIGHTMODEL_AMBIENT;
  4.     float3 light = ambient + tex2D(_LightMap, i.texcoord).rgb;
  5.     float3 color = light * tex2D(_MainTex, i.texcoord).rgb;
  6.     return fixed4(color, 1.0);
  7. }
複製程式碼


Render Lighting

渲染光源光照貼圖的過程,對於不同的光源型別有不同的實現方式,例如直接使用Shader程式式生成,亦或是使用一張光斑貼圖。其核心部分就是:

1.生成一張用於渲染的Mesh(通常就是一個簡單的Quad)

2.設定CommandBuffer將該Mesh繪製到Light Map

Quad就是一個正方形,可以用以下程式碼生成:

  1. Mesh = new Mesh();
  2. Mesh.vertices = new Vector3[]
  3. {
  4.     new Vector3(-.5, -.5, 0),
  5.     new Vector3(.5, -.5, 0),
  6.     new Vector3(-.5, .5, 0),
  7.     new Vector3(.5, .5, 0),
  8. };
  9. Mesh.triangles = new int[]
  10. {
  11.     0, 2, 1,
  12.     2, 3, 1,
  13. };
  14. Mesh.RecalculateNormals();
  15. Mesh.uv = new Vector2[]
  16. {
  17.     new Vector2 (0, 0),
  18.     new Vector2 (1, 0),
  19.     new Vector2 (0, 1),
  20.     new Vector2 (1, 1),
  21. };
複製程式碼

需要注意的是,Mesh資源不參與GC,也就是每次new出來的Mesh會永久駐留記憶體直到退出(導致Unity記憶體洩漏的一個主要因素)。因此不應該在每次渲染的時候new一個新的Mesh,而是在每次渲染時,呼叫Mesh.Clear()方法將Mesh清空後重新設定。

這裡生成的Mesh基於該GameObject的本地座標系,在呼叫CommandBuffer.DrawMesh以渲染該Mesh,我們還需要設定相應的TRS變換矩陣,以確保渲染在螢幕上的正確位置。

  1. public void RenderLight(CommandBuffer cmd)
  2. {
  3.     if (!LightMaterial)
  4.         LightMaterial = new Material(Shader.Find("Lighting2D/2DLight"));
  5.    
  6.     // You may want to set some properties for your lighting shader
  7.     LightMaterial.SetTexture("_MainTex", LightTexture);
  8.     LightMaterial.SetColor("_Color", LightColor);
  9.     LightMaterial.SetFloat("_Attenuation", Attenuation);
  10.     LightMaterial.SetFloat("_Intensity", Intensity);
  11.     cmd.SetGlobalVector("_2DLightPos", transform.position);
  12.    
  13.     var trs = Matrix4x4.TRS(transform.position, transform.rotation, transform.localScale);
  14.     cmd.DrawMesh(Mesh, trs, LightMaterial);
  15. }
複製程式碼

由於我們需要同時將多個光照繪製到同一張光照貼圖上,根據光照物理模型,光照強度的疊加應當使用直接相加的方式,因此用於渲染光照貼圖的Shader應該設定Blend屬性為One One:

  1. Tags {
  2.     "Queue"="Transparent"
  3.     "RenderType"="Transparent"
  4.     "PreviewType"="Plane"
  5.     "CanUseSpriteAtlas"="True"
  6. }

  7. Lighting Off
  8. ZWrite Off
  9. Blend One One
複製程式碼


2D Shadow

要在該光照系統中引入2D陰影,只需要在每次繪製光照貼圖時,額外對每個陰影投射光源繪製一個陰影貼圖(Shadow Map),並應用在渲染光照貼圖的Shader中取樣即可。

  1. var lights = GameObject.FindObjectsOfType<Light2D>();
  2. foreach (var light in lights)
  3. {
  4.     cmd.SetRenderTarget(shadowMap);
  5.     cmd.ClearRenderTarget(true, true, Color.black);
  6.     if (light.LightShadows != LightShadows.None)
  7.     {
  8.         light.RenderShadow(cmd, shadowMap);
  9.     }
  10.     cmd.SetRenderTarget(lightMap);
  11.     light.RenderLight(cmd);
  12. }
複製程式碼

關於2D陰影貼圖的生成,可以參考偽人的這篇文章:

偽人:如何在unity實現足夠快的2d動態光照zhuanlan.zhihu.com

或者我有時間繼續填坑再寫一個。(FLAG)

Source Code

完整的project放在了GitHub上:https://github.com/SardineFish/Unity2DLighting

截止本文,已實現的功能包括:

•2D光照系統框架

o渲染管線擴充套件

o全域性光照設定

•2D光源

o程式式光源,光照衰減

o貼圖光源

•2D陰影

o硬陰影

o軟陰影(高斯模糊實現、體積光實現)

陰影投射物體目前僅支援多邊形,未來將加入對Box和Circle等2D碰撞體的陰影實現。

Git Tag:https://github.com/SardineFish/Unity2DLighting/tree/v0.1.0

References

[1]Kajiya,James T."The rendering equation."ACM SIGGRAPH computer graphics.Vol.20.No.4.ACM,1986.
https://currypseudo.github.io/2018-12-14-2d-dynamic-light/-CurryPseudo-在unity實現足夠快的2d動態光照(一)
https://docs.unity3d.com/Manual/GraphicsCommandBuffers.html-Unity-Graphics Command Buffers
https://zhuanlan.zhihu.com/p/30745861-Milo Yip-用C語言畫光(一):基礎

作者:SardineFish
專欄地址:https://zhuanlan.zhihu.com/p/67923713

相關文章