《Unity Shader入门精要》九、更复杂的光照

130 阅读20分钟

Unity 到底是如何处理各种各样的光源的,当我们在场景里旋转了各种光源后,Unity 的底层渲染引擎是如何让我们在 Shader 中访问到它们。

9.1 Unity 的渲染路径

渲染路径是渲染引擎处理光照和场景渲染的整体流程架构,决定了光照计算的 “时机”“资源分配” 和 “管线阶段”(如光源如何被处理、光照数据如何传递)。

(怎么莫名奇妙又多个渲染路径?脑袋慢慢起糊了,先理清它们的关系)

渲染引擎下光照相关模块的层级结构

  • 在 Unity 里,渲染路径(Rendering Path)决定了光照是如何应用到 Unity Shader 中的。因此,若要和光源打交道,需为每个 Pass 指定它使用的渲染路径

  • Unity 支持多种类型的渲染路径

    • 正向渲染(Forward Rendering)
      • 原理:逐对象、逐光源计算光照,每个对象根据影响它的光源依次计算光照贡献。
      • 特点:
        • 适用于移动设备、低配 PC 等性能受限平台。
        • 支持所有光源类型,但光源数量较多时性能可能下降(需合理设置光源优先级)。
        • 可通过 Per Object Light Culling 优化光源影响范围
    • 延迟渲染(Deferred Rendering)
      • 原理:先渲染场景的几何信息(位置、法线、颜色等)到 G 缓冲区,再统一计算所有光源对像素的影响。
      • 特点:
        • 适用于中高配平台(PC、主机),光源数量对性能影响较小(适合多光源场景)。
        • 不支持抗锯齿(需后期处理),且对移动端 GPU 兼容性较差
  • 大多数情况下,一个项目只使用一种渲染路径(即全局配置)

    • BRP 中的设置方式:Edit - Project Settings - Graphics - Tier Settings 下

      image.png

      • 确认需要修改的渲染等级(Low/Midium/High)
      • 反勾 Use Defaults
      • 修改 Rendering Path
    • URP 中的设置方式

      image.png

      image.png

      image.png

      image.png

  • 有时我们希望同时使用多个渲染路径,如摄像机 A 使用前向,而摄像机 B 使用延迟,我们可以给每个摄像机单独设置渲染路径,以覆盖全局配置

    • BRP 中的设置方式:摄像机 Inspector 面板中

      image.png

    • URP 中的设置方法

      1. 新增一个渲染路径为“Deferred”的渲染器配置

        image.png

        image.png

      2. 将上面新增的渲染器配置添加到当前使用的渲染管线资产下的渲染列表

        image.png

      3. 修改摄像机的渲染器

        image.png

  • 完成上面的设置后,我们就可以在每个 Pass 中使用标签来指定该 Pass 使用的渲染路径

    Pass {
        Tags { "LightMode"="ForwardBase" }
    
    • 上面代码告诉 Unity,该 Pass 使用前向渲染路径中的 ForwardBase 路径。其他渲染路径:

      image.png

  • 使用 LightMode 标签指定渲染路径是我们和 Unity 渲染引擎的一次重要沟通

    • Unity 会为我们准备好对应的光照属性

9.1.1 前向渲染路径

传统且最常用的一种渲染路径

1. 前向渲染路径的原理

  • 每进行一次完整的前向渲染,需要渲染该对象的渲染图元,并计算两个缓冲区的信息
    • 颜色缓冲区
    • 深度缓冲区。决定一个片元是否可见,若可见则更新颜色缓冲区中的值
  • 对于每个逐像素光源,都需要进行上面一次完整的渲染流程
    • 若一个物体在多个逐像素光源的影响区域内,那么该物体就需要执行多个 Pass。每个 Pass 计算一个光照结果,然后在帧缓存中将这些结果混合起来得到最终的颜色值
    • 假设场景有 N 个物体,每个物体受 M 个光源影响,那么要渲染场景就一共需要执行 N*M 个 Pass
    • 渲染引擎通常会限制每个物体的逐像素光源数量

2. Unity 中的前向渲染

  • 一个 Pass 不仅可用来计算逐像素光照,也可用来计算逐顶点等其他光照(逐顶点和逐像素)。取决于:

    • 光照计算所处流水线阶段
    • 计算时使用的数学模型
  • 当我们渲染一个物体

    • Unity 会计算哪些光源照亮了它,以及这些光源照亮物体的方式。前向渲染路径有 3 种光照计算方式:逐顶点处理(在顶点着色器中计算光照)、逐像素处理(在片元着色器中计算光照)、球谐函数(Spherical Harmonics,SH)处理 (渲染路径与光照计算方式的关系
      • 决定一个光源使用哪种计算方式,取决于它的光源类型(平行光、点光)和渲染模式(是否是重要的,若是则以逐像素处理)。设置方法

        image.png

    • Unity 会根据场景中各光源的设置以及它们对物体的影响程度,对这些光源进行一个重要度排序
      • 一定数目的光源会按逐像素方式处理
      • 最后 4 个光源按逐顶点处理
      • 剩下的光源可按 SH (球谐函数)方式处理
  • 光照计算在 Pass 中进行。前向渲染有两种 Pass:Base PassAdditional Pass,它们进行的标签渲染设置以及常规光照计算

    image.png

    • 除了设置 Pass 标签以外,还使用了 #pragma multi_compile_fwdbase 这样的编译命令(官方说明)。只有分别为 Base Pass和 Additional Pass 使用这两个编译命令,我们才可以得到一些正确的光照变量,如光照衰减值等
    • Base Pass 中渲染的平行光默认是支持阴影的,而 Additional Pass 中默认情况下是没阴影效果的(即便 Light 组件中设置了Shadow Type)
    • 不需要在 Additional Pass 中计算环境光自发光,否则会造成叠加光照效果
    • 在 Additional Pass 中,我们还开启和设置了混合模式。因为我们希望每个 Additional Pass 可与上次的光照结果在帧缓存中进行叠加,从而得到有多个光照渲染的效果,否则只会有最后一次 Pass 的渲染效果。通常选择的混合模式是 Blend One One
    • 对于前向渲染来说,一个 Unity Shader 通常会定义至少一个 Base Pass 以及一个 Additional Pass。Base Pass 仅会执行一次,而 Additional Pass 的调用次数跟影响物体的逐像素光源有关(一个光源一次)
  • 渲染路径的设置用于告诉 Unity 该 Pass 在渲染路径中的位置,然后渲染引擎会进行相关计算并填充一些内置变量(如 _LightColor0 )

3. 内置的光照变量和函数

image.png

image.png

9.1.2 顶点照明渲染路径

  • 性能高,质量低
  • 是前向渲染路径的一个子集
  • 只使用了逐顶点的方式来计算光照,所以不可使用逐像素光照相关的变量

1. Unity 中的顶点照明渲染

  • 通常在一个 Pass 中就只可以完成对物体的渲染

2. 可访问的内置变量和函数

  • 可在一个顶点照明的 Pass 中最多访问到 8 个逐顶点光源

  • 若只需要渲染其中两个光源对物体的照明,可仅使用下表中内置光照数据的前两个

  • 若影响该物体的光源数小于 8,那么数组中剩下的光源颜色会设置为黑色

    image.png

    image.png

9.1.3 延迟渲染路径

  • 前向渲染路径的问题:当场景中包含大量实时光源时,渲染性能会急剧下降
    • 每个物体需要执行多个 Pass 来计算(很多计算是重复的)不同光源对物体的光照结果
    • 最后在颜色缓存中把结果混合起来得到最终结果
  • 因前向渲染上述的问题,延迟渲染这项古老的方法近几年又流行起来
  • 除了颜色缓冲和深度缓冲,延迟渲染还会利用额外的缓冲区——G 缓冲(G-buffer),这个缓冲区存储了我们所关心的表面的其他信息,如表面法线、位置、材质属性等

1. 延迟渲染的原理

  • 主要包含两个 Pass
    1. 第一个 Pass 中,不进行任何光照计算,仅计算哪些片元是可见的(主要通过深度缓冲技术),并将可见的片元相关信息存储到 G 缓冲区中
    2. 第二个 Pass 中,利用 G 缓冲区的各个片元信息,进行真正的光照计算
  • 无论场景中的光源有多少个,此渲染方法使用的 Pass 只有两个,渲染性能与屏幕空间大小有关

2. Unity 中的延迟渲染

  • 若游戏中使用了大量实时光照,那么就建议使用延迟渲染路径(需一定硬件支持)
  • 每个光源都可按逐像素的方式来处理。但也有缺点
    • 不支持真正的抗锯齿功能
    • 不能处理半透明物体
    • 对显卡有一定要求,须支持 MRT(Multiple Render Targets)Shader Mode3.0 及以上深度渲染纹理以及双面模板缓冲
  • 当使用这种渲染方法时,Unity 要求我们提供两个 Pass
    1. 第一个 Pass 用于渲染 G 缓冲,将物体的相关信息渲染到屏幕空间的 G 缓冲区中。每个物体只执行一次
    2. 第二个 Pass 用于计算真正的光照模型,使用上一个 Pass 的渲染数据(在 G 缓冲中)来计算,再存储到帧缓冲中
  • 默认的 G 缓冲区包含以下几个渲染纹理(Render Texture,RT)
    • RT0:ARGB32,RGB 存储漫反射颜色,A 没被使用
    • RT1:ARGB32,RGB 存储高光反射颜色,A 存储反射的指数
    • RT2:ARGB2101010,RGB 通道用于存储法线,A 没被使用
    • RT3:ARGB32,存储自发光+lightmap+反射探针
    • 深度缓冲模板缓冲
  • 默认使用 Unity 内置的 Standard 光照模型,想更换的话看看文档说明(链接

3. 可访问的内置变量和函数

image.png

9.1.4 选择哪种渲染路径

说明文档

9.2 Unity 的光源类型

Unity 一共支持 4 种光源:平行光、点光源、聚光灯和面光源

9.2.1 光源类型有什么影响(对 Shader)

1. 平行光

  • 可以照亮的范围没限制(如太阳之于地球)
  • 放哪个位置都没关系,重要的几何属性只有方向
  • 光线到场景中所有点的方向都一样,没有衰减的概念

2. 点光源

  • 照亮空间有限
  • 可调整位置和光照半径,还有颜色和强度
  • 有衰减

3. 聚光灯

  • 照亮空间为锥形的有限区域
  • 除可调整点光源那些属性外,还可调整锥形区域的半径,和锥体的张开角度

9.2.2 在前向渲染中处理不同的光源类型

如何在 Unity Shader 中访问光源的 5 个属性:位置方向颜色强度衰减

1. 实践

步骤:

  1. 老 5 步

  2. Shader 代码:源码

    • 第一个 Pass——Base Pass
      • 配置

        image.png

        • 除设置渲染路径外,还使用了 #pragma multi_compile_fwdbase 编译指令,来保证 Shader 中使用的光照衰减等变量被正确赋值
      • 计算环境光

        image.png

        • 环境光计算一次即可,在后面的 Additional Pass 中就不会再计算(物体的自发光也是这样处理)
      • 处理平行光

        image.png

        • 最亮的平行光传递给 Base Pass 进行逐像素处理
        • 其他平行光在 Additional Pass 中进行逐顶点处理
        • 使用 _WorldSpaceLightPos0 得到光的方向
        • 使用 _LightColor0 得到光的颜色和强度
        • 平行光没有衰减,直接令衰减值为 1.0
    • 第二个 Pass——Additional Pass,处理场景中其他逐像素光源
      • 配置

        image.png

        • 除了设置渲染路径标签外,还使用了 #pragama multi_compile_fwdadd 指令,来保证可访问到正确的光照变量
        • 使用 Blend 命令来开启和设置混合模式,以让光照计算结果可在帧缓冲中与之前的进行叠加(否则会直接覆盖掉之前的)
      • 顶点着色器与 Base Pass 的一致

      • 片元着色器

        • 没有再计算环境光

        • 光源的颜色和强度还是使用 _LightColor0 来得到

        • 而光源的位置、方向和衰减,则需要根据光源类型来分别计算

          • 光源方向

          image.png

          • 光源衰减:Unity 使用一张纹理作为查找表,以在片元着色器中得到光源的衰减。首先得到光源空间下的坐标,然后使用来对衰减纹理进行采样得到衰减值

          image.png

2. 实验:Base Pass 和 Additional Pass 的调用

验证内容:

  • 在 Additional Pass 中按逐像素处理的光源(光源 Render Mode 为 Auto)数量,等于 Project Settings - Quality 中 Pixel Light Count 的数量

    • 方法:,将点光源数量增加至Pixel Light Count 的数量之上,使用 Frame Debugger 来查看绘制过程,观察变化

    • 渲染事件数量没变化

      image.png

  • 点光源的处理顺序是按它们的重要度来排序的(并非按添加到场景中的顺序)。重要度取决于:颜色、强度和与物体间的距离

    • 方法:调整光源的上述参数,在 Frame Debugger 中观察变化
    • 期望:光源的渲染顺序会变化
  • 只有光照范围覆盖了物体的光源,Unity 才会处理这个光源

    • 方法:调小光源范围让物体在范围之外,在 Frame Debugger 中观察变化
    • 期望:渲染事件及光照效果会减少
  • Render Mode 为 Not Important 的光源,Unity 不会以逐像素光照来处理

    • 方法:调整光源的 Render Mode 为 Not Important,在 Frame Debugger 中观察变化
    • 期望:渲染事件及光照效果减少(调整一个光源就减少一个)

9.3 Unity 的光照衰减

使用纹理查找来计算衰减的弊端

  • 需要预处理得到采样纹理,且纹理大小也会影响衰减的精度
  • 不直观且不方便,无法使用其他数学公式来计算衰减

但这种方法能提升性能,且效果在大部分情况下都良好,因此 Unity 默认使用这种方法来计算逐像素的点光源和聚光灯的衰减

9.3.1 用于光照衰减的纹理

  • Unity 在内部用一张名为 _LightTexture0 的纹理来计算光源衰减

  • 通常只关心 _LightTexture0 对角线上的纹理颜色值,这些值表明了在光源空间不同位置的点的衰减值

    • (0,0) 是与光源位置重合点的衰减值
    • (1,1) 是在光源空间中离光源距离最远点的衰减值
  • 为了得到指定(世界空间)点到光源的衰减值,首先需通过 _LightMatrix0 变换矩阵来将该点变换到光源空间中

    float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
    
  • 再计算衰减值

    • 点光源

      fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
      
      • 最终衰减值 = 距离衰减
      • tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL:计算距离衰减
        • _LightTexture0:灯光的距离衰减纹理(预计算的衰减曲线)
        • dot(lightCoord, lightCoord):计算灯光空间中的位置长度平方(避免开方运算优化性能)
        • .rr:将float值转换为float2,满足纹理采样的UV坐标要求
        • UNITY_ATTEN_CHANNEL:Unity 宏定义,指向衰减纹理中存储衰减值的通道(通常是 r 通道)
    • 聚光灯

      fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
      
      • 最终衰减值 = 可见性(0或1) * 形状衰减 * 距离衰减
      • lightCoord.z > 0:判断点相对于光源的可见性。确保只有位于灯光前方的点才会被照亮(剔除灯光后方的物体)
      • tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w:计算聚光灯形状衰减
        • _LightTexture0:聚光灯的形状纹理
        • lightCoord.xy / lightCoord.w:执行透视除法,将齐次坐标转换为NDC坐标(范围[-1,1])
        • + 0.5:将坐标范围从[-1,1]映射到[0,1],符合纹理采样要求
        • .w分量:采样结果的w通道存储聚光灯的径向衰减因子(中心亮 边缘暗)
      • tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL:计算距离衰减
        • _LightTextureB0:灯光的距离衰减纹理(预计算的衰减曲线)
        • dot(lightCoord, lightCoord):计算灯光空间中的位置长度平方(避免开方运算优化性能)
        • .rr:将float值转换为float2,满足纹理采样的UV坐标要求
        • UNITY_ATTEN_CHANNEL:Unity 宏定义,指向衰减纹理中存储衰减值的通道(通常是 r 通道)

9.3.2 使用数学公式计算衰减

(书中表示 Unity 对这种方法的支持较弱,不知新版本的 Unity 有没改进)

9.4 Unity 的阴影

9.4.1 阴影是如何实现的 (豆包讲解 | 视频讲解 1:35 开始

  • 阴影区域的产生是因为光线无法到达这些区域
  • 实时渲染中,常用的技术是 阴影映射纹理(Shadow Map)——首先将光源当作摄像机,那么场景中该光源的阴影区域就是摄像机看不到的地方(深度比较判断能不能看到)
  • 在前向渲染路径中,若场景中最重要的平行光开启了阴影,Unity 就会为它计算阴影映射纹理。此纹理本质上也是一张深度图,记录了从该光源出发,能看到的场景中距离它最近的表面位置(深度信息)
    • 如何判定距离光源最近的表面位置
      1. 先把摄像机放到光源位置上,按正常的渲染流程(调用 Base Pass 和 Additional Pass )更新深度信息,得到阴影映射纹理。缺点是浪费性能
      2. Unity 使用一个额外的 Pass 来专门更新光源的阴影映射纹理,这个 Pass 就是 LightMode 标签被设为 ShadowCaster 的 Pass
        • 渲染目标不是帧缓存,而是阴影映射纹理
        • 若没设置这个 Pass,Unity 会在 Fallback 中继续找,若仍然没找到,该物体就无法向其他物体投射阴影(但它仍可接收来自其他物体的阴影)
        • 当找到一个 LightMode 为 ShadowCaster 的 Pass 后,Unity 会使用该 Pass 来更新光源的阴影映射纹理
  • Unity 使用了不同于传统阴影采样技术——屏幕空间的阴影映射技术(Screenspace Shadow Map)
    • 需要显卡支持(MRT)

    • 步骤:

      1. Unity 首先通过调用 LightMode 为 ShadowCaster 的 Pass 来得到可投射阴影的光源的阴影映射纹理以及摄像机的深度纹理
      2. 根据上面两个信息来得到屏幕空间的阴影图
    • 阴影图包含了屏幕空间中所有有阴影的区域

    • 摄像机的深度纹理中记录的表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然可见,但却处于该光源的阴影中

      image.png

      (上图红点 P 就是那个“虽然可见,但却处于该光源阴影中”的点)

    • 一个物体接收来自其他物体的阴影,和它向其他物体投射阴影两个过程

      • 若想要一个物体接收其他物体的投影,就必须在 Shader 中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果
      • 若想让一个物体向其他物体投影,就必须把物体加入到光源的阴影映射纹理的计算中(为该物体执行 LightMode 为 ShadowCaster),从而让其他物体在对阴影映射纹理采样时可得到该物体的相关信息

9.4.2 不透明物体的阴影

1. 让物体投射阴影

  • LightMode 为 ShadowCaster 的 Pass

2. 让物体接收阴影

  • Base Pass 中的修改(三剑客)
    • 引入新的内置文件

      #include "AutoLight.cginc"
      
    • 在顶点着色器输出结构体中添加内置宏 SHADOW_COORDS,作用是声明一个用于对阴影纹理采样的坐标,参数是下一个可用的插值寄存器索引值(这里用 2)

      image.png

    • 在顶点着色器返回前添加另一个内置宏 TRANSFER_SHADOW,用于在顶点着色器计算上一步中声明的阴影纹理坐标

      image.png

    • 在片元着色器中使用内置宏 SHADOW_ATTENUATION 计算阴影值,最后与原结果相乘

      fixed4 frag (v2f i) : SV_Target
      {
          ...
      
          fixed shadow = SHADOW_ATTENUATION(i);
      
          return fixed4(ambient + (diffuse + specular) * atten * shadow, 1.0);
      }
      
    • 总结:

      • 引入内置文件(以调用新的内置宏):AutoLight.cginc
      • 声明阴影纹理采样坐标:SHADOW_COORDS
      • 顶点着色器中计算阴影纹理坐标:TRANSFER_SHADOW
      • 片元着色器中计算阴影值:SHADOW_ATTENUATION
      • 将阴影值放入最终颜色计算中

9.4.3 使用帧调试器查看阴影绘制过程

渲染事件分为 4 个部分:

  • 更新摄像机的深度纹理:UpdateDepthTexture
  • 渲染平行光阴影映射纹理:RenderShadowmap
  • 根据深度纹理和阴影映射纹理得到屏幕空间的阴影图:CollectShadows
  • 绘制结果

9.4.4 统一管理光照衰减和阴影

  • 光照衰减和阴影对物体最终的渲染结果的影响本质上是相同的——将它们相乘得到最终结果
  • Unity 中可通过 UNITY_LIGHT_ATTENUATION 内置宏来同时计算光照衰减和阴影值
  • 两个 Pass 中 都这样处理
    • 包含 AutoLight.cginc

    • 在 v2f 中使用内置宏 SHADOW_COORDS 声明阴影坐标

      image.png

    • 在顶点着色器中使用内置宏 TRANSFER_SHADOW 计算阴影坐标

      image.png

    • 在片元着色器中使用内置宏 UNITY_LIGHT_ATTENUATION 来计算光照衰减和阴影

      fixed4 frag(v2f i) : SV_Target
      {
          ...
          // -------- Base Pass 中注释掉的部分 -------- 
          // fixed atten = 1.0;
          // fixed shadow = SHADOW_ATTENUATION(i);
          // -------- Additional Pass 中注释掉的部分 -------- 
          // #ifdef USING_DIRECTIONAL_LIGHT
          //     fixed atten = 1.0;
          // #else
          //     #if defined (POINT)
          //         float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
          //         fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
          //     #elif defined (SPOT)
          //         float4 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1));
          //         fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
          //     #else
          //         fixed atten = 1.0;
          //     #endif
          // #endif
      
          // 统一使用内置宏实现
          UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
      
          return fixed4((diffuse + specular) * atten, 1.0);
      
      }
      
      • UNITY_LIGHT_ATTENUATION 会将光照衰减和阴影值相乘后存储到首个参数中(不用另外声明)
      • 第二个参数用来计算阴影值
      • 第三个参数用于计算光源空间下的坐标,再对光照衰减纹理采样得到光照衰减值

9.4.5 透明度物体的阴影

透明度测试实现

  • 若继续使用 VertexLit、Diffuse、Specular 等作为 Fallback,往往无法得到正确的阴影
    • 这些 Pass 中并没有进行任何透明度测试计算,因此会把整个物体的深度信息渲染到深度图和阴影映射纹理中
  • 想得到经过透明度测试后的阴影效果,就需要提供一个有透明度测试功能的 ShadowCaster Pass,Unity 内置中就有——Transparent/Cutout/VertexLit
  • Shader 中必须提供名为 _Cutoff 的属性

透明度混合实现

  • 所有内置的透明度混合的 Unity Shader,都没有包含阴影投射的 Pass
    • 这些半透明物体不会参与深度图和阴影映射纹理的计算
    • 不会向其他物体投射阴影,同样也不会接收其他物体的投影
    • 原因:由于透明度混合需要关闭深度写入,想要产生正确的阴影
      • 需要在每个光源空间下仍然严格按从后往前的顺序进行渲染,复杂且影响性能
  • dirty trick 解决:
    • 将 Fallback 设置为 VertexLit、Diffuse 这些不透明物体使用的 Unity Shader,这样就会在它的 Fallback 打到一个阴影投射的 Pass