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 兼容性较差
- 正向渲染(Forward Rendering)
-
大多数情况下,一个项目只使用一种渲染路径(即全局配置)
-
BRP 中的设置方式:Edit - Project Settings - Graphics - Tier Settings 下
- 确认需要修改的渲染等级(Low/Midium/High)
- 反勾 Use Defaults
- 修改 Rendering Path
-
-
-
有时我们希望同时使用多个渲染路径,如摄像机 A 使用前向,而摄像机 B 使用延迟,我们可以给每个摄像机单独设置渲染路径,以覆盖全局配置
-
BRP 中的设置方式:摄像机 Inspector 面板中
-
URP 中的设置方法
-
新增一个渲染路径为“Deferred”的
渲染器配置 -
将上面新增的
渲染器配置添加到当前使用的渲染管线资产下的渲染列表中 -
修改摄像机的渲染器
-
-
-
完成上面的设置后,我们就可以在每个 Pass 中使用标签来指定该 Pass 使用的渲染路径
Pass { Tags { "LightMode"="ForwardBase" }-
上面代码告诉 Unity,该 Pass 使用前向渲染路径中的 ForwardBase 路径。其他渲染路径:
-
-
使用 LightMode 标签指定渲染路径是我们和 Unity 渲染引擎的一次重要沟通
- Unity 会为我们准备好对应的光照属性
9.1.1 前向渲染路径
传统且最常用的一种渲染路径
1. 前向渲染路径的原理
- 每进行一次完整的前向渲染,需要渲染该对象的
渲染图元,并计算两个缓冲区的信息颜色缓冲区深度缓冲区。决定一个片元是否可见,若可见则更新颜色缓冲区中的值
- 对于每个
逐像素光源,都需要进行上面一次完整的渲染流程- 若一个物体在多个
逐像素光源的影响区域内,那么该物体就需要执行多个 Pass。每个 Pass 计算一个光照结果,然后在帧缓存中将这些结果混合起来得到最终的颜色值 - 假设场景有 N 个物体,每个物体受 M 个光源影响,那么要渲染场景就一共需要执行 N*M 个 Pass
- 渲染引擎通常会限制每个物体的逐像素光源数量
- 若一个物体在多个
2. Unity 中的前向渲染
-
一个 Pass 不仅可用来计算逐像素光照,也可用来计算逐顶点等其他光照(逐顶点和逐像素)。取决于:
- 光照计算所处流水线阶段
- 计算时使用的数学模型
-
当我们渲染一个物体时
- Unity 会计算哪些光源照亮了它,以及这些光源照亮物体的方式。前向渲染路径有 3 种光照计算方式:逐顶点处理(在顶点着色器中计算光照)、逐像素处理(在片元着色器中计算光照)、球谐函数(Spherical Harmonics,SH)处理 (渲染路径与光照计算方式的关系)
-
决定一个光源使用哪种计算方式,取决于它的
光源类型(平行光、点光)和渲染模式(是否是重要的,若是则以逐像素处理)。设置方法
-
- Unity 会根据场景中各光源的设置以及它们对物体的影响程度,对这些光源进行一个重要度排序
- 一定数目的光源会按
逐像素方式处理 - 最后 4 个光源按
逐顶点处理 - 剩下的光源可按
SH (球谐函数)方式处理
- 一定数目的光源会按
- Unity 会计算哪些光源照亮了它,以及这些光源照亮物体的方式。前向渲染路径有 3 种光照计算方式:逐顶点处理(在顶点着色器中计算光照)、逐像素处理(在片元着色器中计算光照)、球谐函数(Spherical Harmonics,SH)处理 (渲染路径与光照计算方式的关系)
-
光照计算在 Pass 中进行。前向渲染有两种 Pass:Base Pass 和 Additional Pass,它们进行的
标签、渲染设置以及常规光照计算- 除了设置 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 的调用次数跟影响物体的
逐像素光源有关(一个光源一次)
- 除了设置 Pass 标签以外,还使用了
-
渲染路径的设置用于告诉 Unity 该 Pass 在渲染路径中的位置,然后渲染引擎会进行相关计算并填充一些内置变量(如 _LightColor0 )
3. 内置的光照变量和函数
9.1.2 顶点照明渲染路径
- 性能高,质量低
- 是前向渲染路径的一个子集
- 只使用了逐顶点的方式来计算光照,所以不可使用逐像素光照相关的变量
1. Unity 中的顶点照明渲染
- 通常在一个 Pass 中就只可以完成对物体的渲染
2. 可访问的内置变量和函数
-
可在一个顶点照明的 Pass 中最多访问到 8 个逐顶点光源
-
若只需要渲染其中两个光源对物体的照明,可仅使用下表中内置光照数据的前两个
-
若影响该物体的光源数小于 8,那么数组中剩下的光源颜色会设置为黑色
9.1.3 延迟渲染路径
- 前向渲染路径的问题:当场景中包含大量
实时光源时,渲染性能会急剧下降- 每个物体需要执行多个 Pass 来计算(很多计算是重复的)不同光源对物体的光照结果
- 最后在颜色缓存中把结果混合起来得到最终结果
- 因前向渲染上述的问题,
延迟渲染这项古老的方法近几年又流行起来 - 除了颜色缓冲和深度缓冲,
延迟渲染还会利用额外的缓冲区——G 缓冲(G-buffer),这个缓冲区存储了我们所关心的表面的其他信息,如表面法线、位置、材质属性等
1. 延迟渲染的原理
- 主要包含两个 Pass
- 第一个 Pass 中,不进行任何光照计算,仅计算哪些片元是可见的(主要通过深度缓冲技术),并将可见的片元相关信息存储到 G 缓冲区中
- 第二个 Pass 中,利用 G 缓冲区的各个片元信息,进行真正的光照计算
- 无论场景中的光源有多少个,此渲染方法使用的 Pass 只有两个,渲染性能与屏幕空间大小有关
2. Unity 中的延迟渲染
- 若游戏中使用了大量实时光照,那么就建议使用
延迟渲染路径(需一定硬件支持) - 每个光源都可按逐像素的方式来处理。但也有缺点
- 不支持真正的抗锯齿功能
- 不能处理半透明物体
- 对显卡有一定要求,须支持
MRT(Multiple Render Targets)、Shader Mode3.0 及以上、深度渲染纹理以及双面模板缓冲
- 当使用这种渲染方法时,Unity 要求我们提供两个 Pass
- 第一个 Pass 用于渲染 G 缓冲,将物体的相关信息渲染到屏幕空间的 G 缓冲区中。每个物体只执行一次
- 第二个 Pass 用于计算真正的光照模型,使用上一个 Pass 的渲染数据(在 G 缓冲中)来计算,再存储到帧缓冲中
- 默认的 G 缓冲区包含以下几个渲染纹理(Render Texture,RT)
- RT0:ARGB32,RGB 存储
漫反射颜色,A 没被使用 - RT1:ARGB32,RGB 存储
高光反射颜色,A 存储反射的指数 - RT2:ARGB2101010,RGB 通道用于存储
法线,A 没被使用 - RT3:ARGB32,存储
自发光+lightmap+反射探针 深度缓冲和模板缓冲
- RT0:ARGB32,RGB 存储
- 默认使用 Unity 内置的 Standard 光照模型,想更换的话看看文档说明(链接)
3. 可访问的内置变量和函数
9.1.4 选择哪种渲染路径
9.2 Unity 的光源类型
Unity 一共支持 4 种光源:平行光、点光源、聚光灯和面光源
9.2.1 光源类型有什么影响(对 Shader)
1. 平行光
- 可以照亮的范围没限制(如太阳之于地球)
- 放哪个位置都没关系,重要的几何属性只有方向
- 光线到场景中所有点的方向都一样,没有衰减的概念
2. 点光源
- 照亮空间有限
- 可调整位置和光照半径,还有颜色和强度
- 有衰减
3. 聚光灯
- 照亮空间为锥形的有限区域
- 除可调整点光源那些属性外,还可调整锥形区域的半径,和锥体的张开角度
9.2.2 在前向渲染中处理不同的光源类型
如何在 Unity Shader 中访问光源的 5 个属性:位置、方向、颜色、强度和衰减
1. 实践
步骤:
-
老 5 步
-
Shader 代码:源码
- 第一个 Pass——Base Pass
-
配置
- 除设置渲染路径外,还使用了 #pragma multi_compile_fwdbase 编译指令,来保证 Shader 中使用的光照衰减等变量被正确赋值
-
计算环境光
- 环境光计算一次即可,在后面的 Additional Pass 中就不会再计算(物体的自发光也是这样处理)
-
处理平行光
- 最亮的平行光传递给 Base Pass 进行逐像素处理
- 其他平行光在 Additional Pass 中进行逐顶点处理
- 使用 _WorldSpaceLightPos0 得到光的方向
- 使用 _LightColor0 得到光的颜色和强度
- 平行光没有衰减,直接令衰减值为 1.0
-
- 第二个 Pass——Additional Pass,处理场景中其他逐像素光源
-
配置
- 除了设置渲染路径标签外,还使用了 #pragama multi_compile_fwdadd 指令,来保证可访问到正确的光照变量
- 使用 Blend 命令来开启和设置混合模式,以让光照计算结果可在帧缓冲中与之前的进行叠加(否则会直接覆盖掉之前的)
-
顶点着色器与 Base Pass 的一致
-
片元着色器
-
没有再计算环境光
-
光源的颜色和强度还是使用 _LightColor0 来得到
-
而光源的位置、方向和衰减,则需要根据光源类型来分别计算
- 光源方向
- 光源衰减:Unity 使用一张纹理作为查找表,以在片元着色器中得到光源的衰减。首先得到
光源空间下的坐标,然后使用来对衰减纹理进行采样得到衰减值
-
-
- 第一个 Pass——Base Pass
2. 实验:Base Pass 和 Additional Pass 的调用
验证内容:
-
在 Additional Pass 中按逐像素处理的光源(光源 Render Mode 为 Auto)数量,等于 Project Settings - Quality 中 Pixel Light Count 的数量
-
方法:,将点光源数量增加至Pixel Light Count 的数量之上,使用 Frame Debugger 来查看绘制过程,观察变化
-
渲染事件数量没变化
-
-
点光源的处理顺序是按它们的重要度来排序的(并非按添加到场景中的顺序)。重要度取决于:颜色、强度和与物体间的距离
- 方法:调整光源的上述参数,在 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 就会为它计算
阴影映射纹理。此纹理本质上也是一张深度图,记录了从该光源出发,能看到的场景中距离它最近的表面位置(深度信息)- 如何判定距离光源最近的表面位置
- 先把摄像机放到光源位置上,按正常的渲染流程(调用 Base Pass 和 Additional Pass )更新深度信息,得到
阴影映射纹理。缺点是浪费性能 - Unity 使用一个额外的 Pass 来专门更新光源的
阴影映射纹理,这个 Pass 就是 LightMode 标签被设为ShadowCaster的 Pass- 渲染目标不是帧缓存,而是阴影映射纹理
- 若没设置这个 Pass,Unity 会在 Fallback 中继续找,若仍然没找到,该物体就无法向其他物体投射阴影(但它仍可接收来自其他物体的阴影)
- 当找到一个 LightMode 为 ShadowCaster 的 Pass 后,Unity 会使用该 Pass 来更新光源的
阴影映射纹理
- 先把摄像机放到光源位置上,按正常的渲染流程(调用 Base Pass 和 Additional Pass )更新深度信息,得到
- 如何判定距离光源最近的表面位置
- Unity 使用了不同于传统阴影采样技术——屏幕空间的阴影映射技术(Screenspace Shadow Map)
-
需要显卡支持(MRT)
-
步骤:
- Unity 首先通过调用 LightMode 为 ShadowCaster 的 Pass 来得到可投射阴影的光源的
阴影映射纹理以及摄像机的深度纹理 - 根据上面两个信息来得到屏幕空间的
阴影图
- Unity 首先通过调用 LightMode 为 ShadowCaster 的 Pass 来得到可投射阴影的光源的
-
阴影图包含了屏幕空间中所有有阴影的区域
-
若摄像机的深度纹理中记录的
表面深度大于转换到阴影映射纹理中的深度值,就说明该表面虽然可见,但却处于该光源的阴影中(上图红点 P 就是那个“虽然可见,但却处于该光源阴影中”的点)
-
一个物体接收来自其他物体的阴影,和它向其他物体投射阴影是两个过程
- 若想要一个物体接收其他物体的投影,就必须在 Shader 中对阴影映射纹理(包括屏幕空间的阴影图)进行采样,把采样结果和最后的光照结果相乘来产生阴影效果
- 若想让一个物体向其他物体投影,就必须把物体加入到光源的阴影映射纹理的计算中(为该物体执行 LightMode 为 ShadowCaster),从而让其他物体在对阴影映射纹理采样时可得到该物体的相关信息
-
9.4.2 不透明物体的阴影
1. 让物体投射阴影
- LightMode 为 ShadowCaster 的 Pass
2. 让物体接收阴影
- Base Pass 中的修改(三剑客)
-
引入新的内置文件
#include "AutoLight.cginc" -
在顶点着色器输出结构体中添加内置宏 SHADOW_COORDS,作用是声明一个用于对阴影纹理采样的坐标,参数是下一个可用的插值寄存器索引值(这里用 2)
-
在顶点着色器返回前添加另一个内置宏 TRANSFER_SHADOW,用于在顶点着色器计算上一步中声明的阴影纹理坐标
-
在片元着色器中使用内置宏 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 声明阴影坐标
-
在顶点着色器中使用内置宏 TRANSFER_SHADOW 计算阴影坐标
-
在片元着色器中使用内置宏 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