Unity中实现2D光照系统

Unity中实现2D光照系统

在一些 2D 游戏中引入实时光影效果能给游戏带来非常大的视觉效果提升,亦或是利用 2D 光影实现视线遮挡机制。例如 Terraria, Starbound。

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] 中提出了一个著名的渲染方程:

3D 场景中物体表面任意一面元所受光照,等于来自所有方向的光线辐射度的总和。这些光经过反射和散射后,其中一部分射向摄像机(观察方向)。(通常为了简化这一过程,我们可以假定这些光线全部射向摄像机)

而在 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光照效果做出一定的分析和比较。


2D Lighting System

Light Source

相较于 3D 实时渲染中的点光源、平行光源和聚光灯等多种精确光源,在 2D 光照中,通常我们只需要点光源就足以满足对 2D 光照的需求。

由于精确光源的引入,我们不再需要对光线进行积分计算,因此上文中的 2D 光照方程就可以简化为:

即空间每点的光照等于场景中所有点光源在 (x, y) 处光量的总和。为了使光照更加真实,我们可以对点光源引入光照衰减机制:

其中 d 为平面上一点到光源的距离,t 为可调节参数,取值范围 [0, 1]

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



光照衰减模型还有很多种,可以根据需求进行更改。

Light Rendering

在有了光源模型之后,我们需要将光照绘制到屏幕上,也就是光照的渲染实现。计算光照颜色与物体固有颜色的结合通常采用直接相乘的形式,即 color = lightColor.rgb * albedo.rgb,与 Photoshop 等软件中的“正片叠底”是同样的。



在 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 并重新设置相关参数。

这里还设置ExecuteInEditModeImageEffectAllowedInSceneView属性以确保能在编辑器的 Scene 视图中实时渲染 2D 光照效果。

这里选择CameraEvent.BeforeImageEffects作为插入点,即在 Unity 完成了场景渲染后,准备渲染屏幕后处理前的阶段。

using System.Collections;
using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;

[ExecuteInEditMode]
[ImageEffectAllowedInSceneView]
[RequireComponent(typeof(Camera))]
public class Light2DRenderer : MonoBehaviour
{
    CommandBuffer cmd;
    // Init CommandBuffer & add to camera.
    void OnEnable()
    {
        cmd = new CommandBuffer();
        GetComponent<Camera>().AddCommandBuffer(CameraEvent.BeforeImageEffects, cmd);
    }
    void OnDisable()
    {
        GetComponent<Camera>().RemoveCommandBuffer(CameraEvent.BeforeImageEffects, cmd);
    }
    void OnPreRender()
    {
        // Setup CommandBuffer every frame before rendering.
        RenderDeffer(cmd);
    }
}


Setup CommandBuffer

由于我们要绘制一张光照贴图,并将其与屏幕图像混合,我们需要一个临时的 RenderTexture (RT),这里设置 Light Map 的贴图格式为ARGBFloat,原因是我们希望光照贴图中每个像素的 RGB 光照分量是可以大于1的,这样可以提供更精确的光照效果和更好的扩展性,而默认的 RT 会在混合前将缓冲区中每个像素的值裁剪到[0,1]

在临时 RT 使用完毕后,请务必 Release!请务必 Release!请务必 Release!(别问,问就是显卡崩溃)

public void RenderDeffer(CommandBuffer cmd)
{
    cmd.Clear();

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

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

    // DONT FORGET to release the temp RT!!!
    // OR your graphic card may crash after a while due to the memory overflow (may be) :)
    cmd.ReleaseTemporaryRT(lightMap);
    cmd.ReleaseTemporaryRT(screen);
    cmd.SetRenderTarget(BuiltinRenderTextureType.CameraTarget);
}

最终用于光照混合的 Shader 代码非常简单,这里使用了UNITY_LIGHTMODEL_AMBIENT引入一个场景全局光照,全局光照可以在Lighting > Scene面板里设置:

fixed4 frag(v2f i) : SV_Target
{
    float3 ambient = UNITY_LIGHTMODEL_AMBIENT;
    float3 light = ambient + tex2D(_LightMap, i.texcoord).rgb;
    float3 color = light * tex2D(_MainTex, i.texcoord).rgb;
    return fixed4(color, 1.0);
}


Render Lighting

渲染光源光照贴图的过程,对于不同的光源类型有不同的实现方式,例如直接使用 Shader 程序式生成,亦或是使用一张光斑贴图。其核心部分就是:

  1. 生成一张用于渲染的 Mesh(通常就是一个简单的 Quad)
  2. 设置 CommandBuffer 将该 Mesh 绘制到 Light Map

Quad 就是一个正方形,可以用以下代码生成:

Mesh = new Mesh();
Mesh.vertices = new Vector3[]
{
    new Vector3(-.5, -.5, 0),
    new Vector3(.5, -.5, 0),
    new Vector3(-.5, .5, 0),
    new Vector3(.5, .5, 0),
};
Mesh.triangles = new int[]
{
    0, 2, 1,
    2, 3, 1,
};
Mesh.RecalculateNormals();
Mesh.uv = new Vector2[]
{
    new Vector2 (0, 0),
    new Vector2 (1, 0),
    new Vector2 (0, 1),
    new Vector2 (1, 1),
};

需要注意的是,Mesh 资源不参与 GC,也就是每次new出来的 Mesh 会永久驻留内存直到退出(导致 Unity 内存泄漏的一个主要因素)。因此不应该在每次渲染的时候new一个新的 Mesh,而是在每次渲染时,调用Mesh.Clear()方法将 Mesh 清空后重新设置。

这里生成的 Mesh 基于该 GameObject 的本地坐标系,在调用 CommandBuffer.DrawMesh 以渲染该 Mesh,我们还需要设置相应的 TRS 变换矩阵,以确保渲染在屏幕上的正确位置。

public void RenderLight(CommandBuffer cmd)
{
    if (!LightMaterial)
        LightMaterial = new Material(Shader.Find("Lighting2D/2DLight"));
    
    // You may want to set some properties for your lighting shader
    LightMaterial.SetTexture("_MainTex", LightTexture);
    LightMaterial.SetColor("_Color", LightColor);
    LightMaterial.SetFloat("_Attenuation", Attenuation);
    LightMaterial.SetFloat("_Intensity", Intensity);
    cmd.SetGlobalVector("_2DLightPos", transform.position);
    
    var trs = Matrix4x4.TRS(transform.position, transform.rotation, transform.localScale);
    cmd.DrawMesh(Mesh, trs, LightMaterial);
}


由于我们需要同时将多个光照绘制到同一张光照贴图上,根据光照物理模型,光照强度的叠加应当使用直接相加的方式,因此用于渲染光照贴图的 Shader 应该设置Blend属性为One One

Tags { 
    "Queue"="Transparent" 
    "RenderType"="Transparent" 
    "PreviewType"="Plane"
    "CanUseSpriteAtlas"="True"
}

Lighting Off
ZWrite Off
Blend One One


2D Shadow

要在该光照系统中引入 2D 阴影,只需要在每次绘制光照贴图时,额外对每个阴影投射光源绘制一个阴影贴图 (Shadow Map),并应用在渲染光照贴图的 Shader 中采样即可。

var lights = GameObject.FindObjectsOfType<Light2D>();
foreach (var light in lights)
{
    cmd.SetRenderTarget(shadowMap);
    cmd.ClearRenderTarget(true, true, Color.black);
    if (light.LightShadows != LightShadows.None)
    {
        light.RenderShadow(cmd, shadowMap);
    }
    cmd.SetRenderTarget(lightMap);
    light.RenderLight(cmd);
}


关于 2D 阴影贴图的生成,可以参考 @伪人 的这篇文章:

伪人:如何在unity实现足够快的2d动态光照zhuanlan.zhihu.com图标


或者我有时间继续填坑再写一个。(FLAG)

Source Code

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

截止本文,已实现的功能包括:

  • 2D 光照系统框架
    • 渲染管线扩展
    • 全局光照设置
  • 2D 光源
    • 程序式光源,光照衰减
    • 贴图光源
  • 2D阴影
    • 硬阴影
    • 软阴影(高斯模糊实现、体积光实现)

阴影投射物体目前仅支持多边形,未来将加入对 Box 和 Circle 等 2D 碰撞体的阴影实现。

Git Tag:github.com/SardineFish/

References

[1] Kajiya, James T. "The rendering equation."ACM SIGGRAPH computer graphics. Vol. 20. No. 4. ACM, 1986.

currypseudo.github.io/2 - CurryPseudo - 在unity实现足够快的2d动态光照(一)

docs.unity3d.com/Manual - Unity - Graphics Command Buffers

zhuanlan.zhihu.com/p/30 - Milo Yip - 用 C 语言画光(一):基础

发布于 2019-06-03