1 准备工作
1.1 模块的迁移:Include Files
多光源的光照使用了多个 passes,这些 passes 往往具有几乎相同的代码。为避免冗长的 code duplication, 首先将通用代码段移到一个被包含的文件中。
以下基于上一章最后一节的 PBS 文件:
在代码编辑器中新建文本文件,命名为 SecondLight_Light.cginc (因为 Unity 编辑器里我没找到哪里可以新建文本文件...);
把 #pragma
以下,ENDCG
以上的代码,移到新建 cginc 文件里;
在原文件(这里是 SecondLight.shader )中,pragma
语句下,加入 #include "SecondLight_Light.cginc"
;
Main Light
Copy Shader "Code/PA4_MultipleLights/SecondLight"
{
Properties { ... }
SubShader {
Pass {
Tags { ... }
CGPROGRAM
#pragma target 3.0
#pragma vertex Vert
#pragma fragment Frag
#include "SecondLight_Light.cginc"
ENDCG
}
}
}
Copy #if !defined(MULTIPLELIGHTS_SECONDLIGHT_LIGHT)
#define MULTIPLELIGHTS_SECONDLIGHT_LIGHT
#define UNITY_PBS_USE_BRDF3
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc" // 包含头文件
sampler2D _MainTex;
float4 _Tint, _MainTex_ST;
float _Smoothness, _Metallic;
struct VertexData { ... };
struct Interpolators { ... };
Interpolators Vert(VertexData v) { ... }
float4 Frag(Interpolators i): SV_Target { ... }
#endif
1.2 正确的配置:The Second Light
将 LightMode 设置为 ForwardAdd,使得其不是覆写主光源,而是加入其中;
Copy Shader"Code/PA4_MultipleLights/SecondLight"
{
Properties { ... }
SubShader {
Pass {
Tags {
"LightMode" = "ForwardBase"
}
CGPROGRAM
#pragma target 3.0
#pragma vertex Vert
#pragma fragment Frag
#include "SecondLight_Light.cginc"
ENDCG
}
// new Pass
Pass {
Tags {
"LightMode" = "ForwardAdd"
}
Blend One One
ZWrite Off
CGPROGRAM
#pragma target 3.0
#pragma vertex Vert
#pragma fragment Frag
#include "SecondLight_Light.cginc"
ENDCG
}
}
}
如何理解 Blend One One
?Blend One One 是定义如何将当前光源的渲染结果与已有的帧缓冲区(frame buffer)中的内容进行混合:
Blend 模式的定义是 Blend SrcFactor DstFactor
,其中 SrcFactor
是当前源片段(当前光源)的混合因子,DstFactor
是目标片段(已经存在于帧缓冲区中的颜色)的混合因子。
Blend One One
表示当前光源的颜色和已经在帧缓冲区中的颜色都直接相加。具体来说,第一个 One
表示使用全强度 (1) 的源颜色,第二个 One
表示使用全强度 (1) 的目标颜色。
为什么需要同时设置 Light Mode 和 Blend Mode?前向渲染模式下,除了主光源外,其他附加光源都会使用 ForwardAdd Pass。
根据 Blend One One 的定义,其与光源的叠加有关,即讨论当前光源颜色如何与已在帧缓冲区的颜色混合;
因此,虽然这两者看似相关,但它们解决的是渲染过程中的不同问题,缺一不可。LightMode = ForwardAdd
控制流程,而 Blend One One
确保效果正确。
为什么需要关闭第二光源的深度检测?在前向渲染(Forward Rendering)的 Base Pass
阶段,Unity 会对场景中的主光源进行渲染。这个过程中,Unity 不仅会计算并将渲染结果写入到帧缓冲区(frame buffer)中,还会更新深度缓冲区(depth buffer),以记录场景中各个片段的深度信息。
而由于附加光源(ForwardAdd
Pass)在渲染时作用于同一对象,并且这些光源只是增加额外的光照效果,因此无需再次对深度缓冲区进行更新。
对于第二光源,为什么需要另外增加一个 Pass?对于第二个灯光,首先考虑同为 directional light。首先复制 main light 并且设置其颜色和旋转,使得主光源与第二光源有所区分,然后减少其强度滑块。
此时可以注意到,Games 里什么都没发生。
因为 shader 只会计算第一个光源,forward base pass 是给主光源的。想渲染一个额外的光源,就需要一个额外的 pass。
1.3 观察的方式:Draw Call Batches
什么是 Draw Call?Draw Call 是指在渲染管线中,CPU 向 GPU 发出的一次渲染命令。每次 Draw Call 通常会让 GPU 绘制一批三角形(或者其他基本几何图形),并根据指定的材质、着色器等渲染设置来进行绘制。
一次 Draw Call 流程通常包括以下几个步骤:
设置渲染状态:CPU 设定 GPU 要使用的着色器、材质、纹理、光源、变换矩阵等信息。
发送顶点数据:CPU 将需要渲染的顶点、索引数据发送给 GPU。
执行渲染命令:CPU 发出实际的绘制命令,GPU 开始渲染。
在 Game 视图的左上角有 Stats 按钮,打开可以看到 Statistics,Graphics 窗口下可以查看 Batches 数量 和 Saved by batching 数量。
先将附加光源关闭,只开启主光源观察,可以看到图中有五个 objects 仅使用了 4 个 batches。节省的一个 draw call 是由于开启了 Dynamic batch,会将两个 Cube 进行合并(当然也因为是 Dynamic batch 所以只是其中一帧,其他帧可能并不能合并)。
此外,一个额外的 Batch 是来自 dynamic shadows。可以在 "Edit - Project Settings - Quality - Shadows - Disable Shadows" 路径中关闭它。
好吧,我设置了,但是 Batches 总数还是 Objects + 1,不知道为啥。但是不重要我们继续。
打开附加光源,会发现 Batch 的数量应该是 Objects × 2 (因为我上面 disable shadow 没成功所以是 Objects × 2 + 1)。因为 Unity 的 Dynamic Batching 仅对单 directional light 生效。对于附加光源,该优化就不再 work 了。
1.4 优化的手段:Frame Debugger
在 Game 界面点击上面的小虫虫,打开 Frame Debugger 观察渲染步骤和渲染顺序。
Frame Debugger 可以 step by step 地展示每一条 draw call 渲染结果。渲染顺序一般是 front-to-back,但也会依照 mesh group,或者同材质组团渲染等各种情况而定。
2 入手新光源:以 Point Light 为例
如果直接禁用两个 directional lights 而添加 point light,会导致什么问题?禁用两个 directional lights,添加 point light 作为光源。会发现光线表现的很奇怪。如果打开 frame debugger,可以观察到对象先是被渲染为全黑的色块,最后几步的时候又在上面平添了奇怪的光。
Shader 中的 base pass 无论如何都会被渲染,此时主光源被禁用而导致对应光源信息缺失,因此在前几个 draw call 中,物体被渲染为黑色轮廓。
而 second pass 处理场景中的额外光源,此时光源为 Point Light,然而代码依然假设该光源为 Directional Light,从而导致渲染出现异常的光照行为。接下来就需要对此进行修正。
2.1 缜密的想法 Meticulous Idea
理解 Point Light 的特点,其核心在于修正逻辑,实现光线衰减计算;
将与 Point Light 相关的光源信息抽象出一个函数 CreateLight(要不然看着太乱了);
重新计算光线方向(light direction);计算衰减,应用到光线颜色部分(light color)。
Unity 中光线衰减计算的 idea光线衰减的核心思想是:随着距离的增加,光照强度逐渐减弱。
为了实现这种效果,Unity在处理点光源时,通过将物体的世界坐标转换到光源的局部空间(Light Space)来计算光衰减。即光源位置为 (0, 0, 0),受光物体坐标基于光源位置重新计算。
衰减计算的具体步骤:
转换到光源的局部空间:Unity 会将物体表面的每个像素的位置从世界坐标系转换到光源的局部坐标系。这种转换通常使用一个变换矩阵来完成,类似于其他几何变换(如视图变换或投影变换)。
计算光衰减:在光源的局部空间中,Unity 会使用点光源的衰减公式(例如 1 1 + d 2 \frac{1}{1 + d^2} 1 + d 2 1 )来计算光的强度。在这个公式中, d d d 为物体表面上某点到光源的距离,又因为局部空间中点光源位于原点,因此计算这个距离非常方便。
使用纹理采样:Unity 还使用了一种衰减纹理(falloff texture)。这是一种预计算的纹理,表示光线强度如何随距离变化。通过将局部空间中的距离值转换为纹理坐标(UV坐标),Unity可以从这个纹理中采样出准确的衰减值,确保光照的平滑过渡。这个纹理实际上更像位图而非矢量图。里面的每个像素记录的是光照衰减的强度数据(而不是直接用于显示的图像信息)。这个办法确保 “光衰减至 0” 发生的更早些,更进一步处理了边缘处的光照变化。
2.2 CODING
Main Light
Copy Shader"Code/PA4_MultipleLights/AddPointLight"
{
Properties {
_Tint("Tint", Color) = (1, 1, 1, 1)
_MainTex("Albedo", 2D) = "white" {}
[Gamma] _Metallic("Metallic", Range(0, 1)) = 0
_Smoothness("Smoothness", Range(0, 1)) = 0.5
}
SubShader {
Pass {
Tags {
"LightMode" = "ForwardBase"
}
CGPROGRAM
#pragma target 3.0
#pragma vertex Vert
#pragma fragment Frag
#include "AddPointLight_Light.cginc"
ENDCG
}
// new Pass
Pass {
Tags {
"LightMode" = "ForwardAdd"
}
Blend One One // new
ZWrite Off // new
CGPROGRAM
#pragma target 3.0
#pragma vertex Vert
#pragma fragment Frag
#include "AddPointLight_Light.cginc"
#define POINT
ENDCG
}
}
}
Copy #if !defined(MULTIPLELIGHTS_ADDPOINTLIGHT_LIGHT)
#define MULTIPLELIGHTS_ADDPOINTLIGHT_LIGHT
#define UNITY_PBS_USE_BRDF3
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc" // 使用 UNITY_LIGHT_ATTENUATION 宏需包含头文件
sampler2D _MainTex;
float4 _Tint, _MainTex_ST;
float _Smoothness, _Metallic;
struct VertexData {
float4 position: POSITION;
float2 uv: TEXCOORD0;
float3 normal: NORMAL;
};
struct Interpolators {
float4 position: SV_POSITION;
float2 uv: TEXCOORD0;
float3 normal: TEXCOORD1;
float3 worldPos: TEXCOORD2;
};
Interpolators Vert(VertexData v) {
Interpolators i;
i.position = mul(unity_MatrixMVP, v.position);
i.worldPos = mul(unity_ObjectToWorld, v.position);
i.normal = UnityObjectToWorldNormal(v.normal);
i.uv = TRANSFORM_TEX(v.uv, _MainTex);
return i;
}
// 将 Fragment shader 中 BRDF 函数需要的 direct light 的光线信息相关计算抽象出来
// 要不然代码就会越来越长了...
UnityLight CreateLight(Interpolators i) {
UnityLight light;
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal, light.dir);
return light;
}
float4 Frag(Interpolators i): SV_Target {
i.normal = normalize(i.normal);
// float3 lightDir = _WorldSpaceLightPos0.xyz;
float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
// float3 lightColor = _LightColor0.rgb;
float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
float3 SpecularTint;
float oneMinusReflectivity;
albedo = DiffuseAndSpecularFromMetallic(albedo, _Metallic, SpecularTint, oneMinusReflectivity);
// UnityLight light;
// light.color = lightColor;
// light.dir = lightDir;
// light.ndotl = DotClamped(i.normal, lightDir);
UnityIndirect indirectLight;
indirectLight.diffuse = 0;
indirectLight.specular = 0;
return UNITY_BRDF_PBS(
albedo, SpecularTint,
oneMinusReflectivity, _Smoothness,
i.normal, viewDir,
CreateLight(i), indirectLight
);
}
#endif
如何理解 CreateLight
函数中 light.dir
的逻辑改动?对于 Directional Light 来说,光线是一种假想的全局平行光线,因此对于场景中的任何物体,光源方向都是一致的。而 Light Position 的光源包含了具体的位置,因此对于每一个 fragment,都需要单独计算其所对应的光源方向。
光源方向由包含该光源位置的 _WorldSpaceLightPos0 变量减去 fragment 位置得到(点 - 点 = 向量),并且进行归一化处理,即在这里只取其 “方向”,以保证在后续计算中光源不会有奇怪的强度变化。
Copy // light.dir = _WorldSpaceLightPos0.xyz;
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
如何实现 Point Light 的光线衰减?对于点光源,光线衰减为球体表面积(想象光是一个个粒子,从中心散开球体表面扩大,而强度总和不变),即公式为 1 4 π r 2 \frac{1}{4\pi r^2} 4 π r 2 1 。考虑到简化计算,只关注主要变化的部分,所以忽略常数项,成为 1 r 2 \frac{1}{r^2} r 2 1 。不过图形学中经常以字母 d d d 指代距离(不是直径的意思),因此为 1 d 2 \frac{1}{d^2} d 2 1 。
最后,考虑到当 0 < d < 1 0 < d < 1 0 < d < 1 时,该数值会剧烈增大,因此为分数增加一个常数项。最终光线衰减公式为 1 1 + d 2 \frac{1}{1+d^2} 1 + d 2 1 。
Copy // 对 Point Light 应用光线衰减
UnityLight CreateLight(Interpolators i) {
UnityLight light;
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos; // 光的向量
float attenuation = 1 / (dot(lightVec, lightVec)); // 公式
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal, light.dir);
return light;
}
为什么 CreateLight
函数使用 UNITY_LIGHT_ATTENUATION
来处理光线衰减?为了避免在 Light Range 范围处突然出现的剧烈的光照变化(阴阳脸 Object),在 Light Range 中需设置其与光线衰减同步。
为处理与 Range 同步的光线衰减,Unity 提供了一个包含光线衰减计算的头文件 AutoLight.cginc
。
这个文件中定义了一些宏和函数,用来简化光线衰减的计算过程。在处理点光源时,就可以使用 UNITY_LIGHT_ATTENUATION
宏来计算衰减因子,该宏会自动考虑光源的范围和衰减效果。
UNITY_LIGHT_ATTENUATION
宏包含三个参数:
输出的衰减值,表示经过计算后,光源的光强应该如何影响当前像素,这里定义为 attenuation
;
“have something to do with shadows”,因为我们现在不用这个,所以 just 设置为 0;
当前像素的世界坐标,宏会将其转换到光源的局部空间并进行后续计算(好乖)。
Copy #include "AutoLight.cginc" // 包含头文件
...
// 使用头文件中定义的 UNITY_LIGHT_ATTENUATION 宏
UnityLight CreateLight(Interpolators i) {
UnityLight light;
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
// float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
// float attenuation = 1 / (dot(lightVec, lightVec));
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal, light.dir);
return light;
}
如何理解 “Light Range”?现实世界中光源的光线会一直传播,直到被物体阻挡或逐渐减弱到不可见。然而在计算机图形学中,模拟光线的无限传播,渲染负担过重。因此为了优化渲染性能,点光源和聚光灯设定了一个范围(Range),Range 之外的物体不会受到光源的影响,也就不会需要额外的 draw call。
如何理解 #define POINT
语句UNITY_LIGHT_ATTENUATION
宏设置完成后看起来好像没有立即生效,因为其有多个版本,每一个对应了一个光源模型,默认为 Directional Light,而它刚好没有光衰减。
正确使用宏还需要在 include AuoLight 文件前,显式指出当前的光源条件。因为我们现在对附加 pass 仅需处理 Point Light,因此只需简单声明在该 include 语句前(注意需在其前)即可。
Copy #define POINT
#include "AutoLight.cginc"
3 多光源控制:Shader 变体
尝试关闭 Point Light 并打开两个Directional Lights,会发现渲染结果依然显示为 Point Light 的效果。因为当前的 Shader 错误地将所有光源都当作了 Point Light 来处理。
为解决这个问题,将引入了 “Shader 变体” 的概念,并解释如何使用这些变体来控制 Shader 的行为。
3.1 前置的知识 Pre-Knowledge
什么是 shader 变体?shader 变体是指一个 shader 在编译过程中,根据不同的条件(如不同的光源类型)生成的多个版本。
变体有点类似于 C++ 中的函数模板。虽然从代码结构上看,似乎只定义了一个通用的 Shader,但实际编译时,编译器会为每个光源类型生成一个独立的 Shader。每个 Shader 变体都会针对特定的光源类型进行优化。
打开当前 shader 的 inspector 面板,在 Compile and show code 下拉按钮中可以看到下图所包含的两个变体。
文件里面包含两个代码片段,指出 shader 现在包含的两个变体,base pass 和 additive pass。
但是我们希望 additive pass 本身即能够为不同的光照条件(Directional 和 Point)包含两个变体。变体的生成通过多编译指令(multi_compile)来完成。
什么是多编译指令 multi_compile?对于一个 pass,处理不同的光源类型而使用的指令。
这个指令告诉编译器为每个指定的光源类型生成一个独立的 Shader 变体(variant)。每个变体对应一种光源类型,比如 DIRECTIONAL
(方向光)、POINT
(点光源)等。
3.2 粗糙的想法 Rough Idea
通过多编译指令生成 shader 变体,使得 additive pass 能够为不同类型的附加光源各自编译,并通过变量检测完成逻辑切换;
分析不同光照的逻辑差别。在 directional light 和其他类型光源中,主要是光源方向 (light direction) 计算逻辑不同;
3.3 CODING
Main Light
Copy // 这行代码会生成两个 Shader 变体,一个用于处理方向光,另一个用于处理点光源。
// 注意这里是 ForwardAdd 的
#pragma multi_compile DIRECTIONAL POINT
#pragma vertex Vert
#pragma fragment Frag
// #define POINT
Copy // 如果定义了 POINT,则手动计算光线距离;
// 否则就意味着 Directional Light,就直接使用 _WorldSpaceLightPos0 变量
UnityLight CreateLight(Interpolators i) {
UnityLight light;
// 使用 #if defined 语句来检测这些 Shader 变量是否被定义
// 根据变量选择不同的光照处理方式,从而针对光源类型实现对应变体的调用
#if defined(POINT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
light.dir = _WorldSpaceLightPos0.xyz;
#endif
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal, light.dir);
return light;
}.
如何控制一个 Spotlights 类型的光源?Spotlights 和 Point Light 没什么不一样的,除了 Spotlights 光线是聚焦在一个圆锥形区域内(而不是向所有方向发散)。因此处理 Spotlights 时,只需要增加一条新的变体关键字即可:
MultipleLights_Second-Light_Main
Copy #pragma multi_compile DIRECTIONAL POINT SPOT
以及在条件编译阶段,仅需增加 “或” 运算条件:
MultipleLights_Second-Light_Included - CreateLight
Copy UnityLight CreateLight(Interpolators i) {
...
#if defined(POINT) || defined(SPOT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
light.dir = _WorldSpaceLightPos0.xyz;
#endif
...
}
4 小饼干:Cookies
用来做 range & 衰减同步的 texture 可以是自定义的任何纹理,这些纹理被叫做 “cookies”。cookies 纹理通过 alpha 通道控制光照区域的透明度和强度,实现细腻的光照效果。
光源类型
multi_compile 语句关键字
cookies 格式
Main Light
Copy // 多编译指令包含上述所有 cookies 模式太冗长,Unity 提供了一种更为简便的方式
#pragma multi_compile_fwdadd
// #pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT POINT_COOKIE SPOT
Copy // don't forget 增加编译条件
UnityLight CreateLight(Interpolators i) {
...
#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
light.dir = _WorldSpaceLightPos0.xyz;
#endif
}
4.1 Spotlights Cookies
对于 Spotlights 来说,其本身具有默认的 cookies (其他两个光源则需要手动添加了)。是一个糊的圆圈,中心部分 alpha 值为 1(完全不透明),边缘渐隐至 0。
4.2 Directional Cookies
Tiled Cookies
Directional Light(平行光)的 cookies 是平铺(tiled)的。因为平行光覆盖整个场景,它的光照效果需要均匀分布,因此这些 cookies 纹理必须能够在场景中无缝地重复,而不出现明显的接缝或不连续(而不是边缘渐隐)。
调整 Cookies 大小
默认情况下,Directional Light 的 cookies 的大小设置为 10,但在较小的场景中,可能需要增加或减少这个数值,以调整纹理的平铺密度。
本是同根生
带有 cookies 的平行光需要转换到光源的坐标空间(和 Spotlights、Point Lights)一样。这意味着在光照计算中,Directional Light 的 cookies 与其他光源类型一样,也需要进行光线衰减计算。
为此,它不仅有自己的光线衰减宏,也由于带有 cookies 的 Directional Light 在处理逻辑上与不带 cookies 的 Directional Light 不同,Unity 实际上将它们视为两种不同的光源类型。带有 cookies 的 Directional Light 会使用附加的 pass,并使用 DIRECTIONAL_COOKIE
关键字来标识。
Copy #pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT SPOT
4.3 Points Lights Cookies
Point Lights中应用的 cookies 是指将光照效果包裹在一个球体上,这样可以模拟 Point Light 的光线向所有方向发散的效果。这是通过 cube map 来实现的。
什么是 Cube Map?Cube map 是一种特殊的纹理格式,它使用六个方形的纹理面来表示一个立方体的六个方向(上、下、左、右、前、后),就是经常看到的那种十字架一样的方盒展开图。
当将 Point Light 的光照效果投影到一个球体上时,Unity 会将这六个面贴图应用到球体的内表面,模拟光线从光源发散到各个方向的效果。
可以把它想象成地球仪和地图的关系。Cube map 类似于将地球表面的地图 “拆解” 成六个方形部分,然后将这些部分重新贴在一个立方体的内表面上,从而包裹整个球体。
如何创建 Cube Map?Unity 允许使用多种格式来创建点光源的 cookies。可以直接创建一个完整的 cube map,或者提供六张单独的纹理来组成 cube map。如果你提供了一张普通的 2D 纹理,Unity 可以通过自动映射模式(automatic mapping mode)将其转换为 cube map。当然最好的方法是直接提供一个完整的 cube map。
一旦创建了cube map 并将其应用到 Point Light 的 cookies 中,通常不需要额外的设置,因为 cube map 本身已经包含了所有需要的信息来处理光照的方向和衰减。
5 便宜光源:打包为 Vertex Lights
多光源场景中,每个光源需要额外的 pass 来处理,从而导致更多 draw call。计算公式:objects × passes
。即物体数量乘渲染的 pass 数。
Unity 提供了一种 Vertex Lighting(顶点光照)的方式,在这种方式下,光照计算在 vertex shader(而不是 fragment shader)阶段进行,结果通过插值传递给 fragment shader 进行渲染。
vertex lighting 的光照计算将移到 base pass 中处理,而不再为每个光源单独添加一个 pass。这样,多个光源的光照计算可以批量处理,而不是为每个光源逐个叠加。从而减少 draw call,大幅降低渲染的计算量。
Unity 使用 VERTEXLIGHT_ON
关键字识别处理顶点光照的 Shader 变体,从而启动 vertex lighting 流程。
Unity 如何决定光源为顶点光照还是片元级别?Unity 会根据光源的相对强度和距离进行排序,决定哪些光源会被用于片元级别的计算(成为 pixel lights),剩下的会被直接抛弃(自动丢弃机制)。因此不同物体上的光源计算顺序可能不同。麻烦在于,物体移动时,这种排序也会动态变化,被渲染的光线大开大合。
Bad News!vertex lighting 仅支持 Point Light。因为 vertex lighting 的插值方式更适合点光源的光照分布。
而 spotlight 的光照分布同时取决于距离衰减和光锥角度。由于光强分布的不均匀性(例如,锥形光边缘的快速过渡),顶点插值往往会造成明显的误差。比如当顶点在光锥边缘时,插值结果可能产生过暗或过亮的伪影。
5.1 暴力的想法 Violent Thoughs
将顶点光照(vertex lighting)视为 “indirect light” 项,在 main 文件的 base pass 中添加多编译指令关键字 VERTEXLIGHT_ON
vertex shader:使用条件编译对顶点光照进行计算(仅使用 diffuse 漫反射模型);
fragment shader:将 vertex shader 计算并插值后的 “indirect light” 项填入 BRDF 宏,分配给其中的间接光组件。可将原有的相关计算抽象为函数 CreateIndirectLight
。
5.2 CODING
Main Light
Copy Shader"Code/PA4_MultipleLights/VertexLight"
{
Properties {
_Tint("Tint", Color) = (1, 1, 1, 1)
_MainTex("Albedo", 2D) = "white" {}
[Gamma] _Metallic("Metallic", Range(0, 1)) = 0
_Smoothness("Smoothness", Range(0, 1)) = 0.5
}
SubShader {
Pass {
Tags {
"LightMode" = "ForwardBase"
}
CGPROGRAM
#pragma target 3.0
// 使用 _ 来表示所有其他光源类型,让编译器自动处理
// 仅对重要关键字(VERTEXLIGHT_ON)进行显式定义
#pragma multi_compile _ VERTEXLIGHT_ON
#pragma vertex Vert
#pragma fragment Frag
#include "VertexLight_Light.cginc"
ENDCG
}
Pass {
Tags {
"LightMode" = "ForwardAdd"
}
Blend One One
ZWrite Off
CGPROGRAM
#pragma target 3.0
#pragma multi_compile_fwdadd
#pragma vertex Vert
#pragma fragment Frag
#include "VertexLight_Light.cginc"
ENDCG
}
}
}
Copy #if !defined(MULTIPLELIGHTS_VERTEXLIGHT_LIGHT)
#define MULTIPLELIGHTS_VERTEXLIGHT_LIGHT
#define UNITY_PBS_USE_BRDF3
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
float4 _Tint, _MainTex_ST;
float _Smoothness, _Metallic;
struct VertexData {
float4 position: POSITION;
float2 uv: TEXCOORD0;
float3 normal: NORMAL;
};
struct Interpolators {
float4 position: SV_POSITION;
float2 uv: TEXCOORD0;
float3 normal: TEXCOORD1;
float3 worldPos: TEXCOORD2;
// 增加条件判断,决策是否启动额外的数据插值与传递
#if defined(VERTEXLIGHT_ON)
float3 vertexLightColor: TEXCOORD3;
#endif
};
// 将顶点光照的计算逻辑抽象出来,作为 inout 参数传递给函数
// 其将在 vertex shader 中调用
// 这里是单个 Vertex Light 的计算逻辑,多个光源打包使用工具函数 Shade4PointLights 即可
void ComputeVertexLightColor(inout Interpolators i) {
#if defined(VERTEXLIGHT_ON)
float3 lightPos = float3(unity_4LightPosX0.x, unity_4LightPosY0.x, unity_4LightPosZ0.x);
float3 lightVec = lightPos - i.worldPos;
float3 lightDir = normalize(lightVec);
float ndotl = DotClamped(i.normal, lightDir);
float attenuation = 1 / (1 + dot(lightVec, lightVec) * unity_4LightAtten0.x);
i.vertexLightColor = unity_LightColor[0].rgb * ndotl * attenuation;
// i.vertexLightColor = unity_LightColor[0].rgb; // 测试语句
#endif
}
Interpolators Vert(VertexData v) {
Interpolators i;
i.position = mul(unity_MatrixMVP, v.position);
i.worldPos = mul(unity_ObjectToWorld, v.position);
i.normal = UnityObjectToWorldNormal(v.normal);
i.uv = TRANSFORM_TEX(v.uv, _MainTex);
ComputeVertexLightColor(i);
return i;
}
UnityLight CreateLight(Interpolators i) {
UnityLight light;
#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
light.dir = _WorldSpaceLightPos0.xyz;
#endif
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal, light.dir);
return light;
}
// 抽象出 fragment shader 阶段的 indirect light 计算逻辑
UnityIndirect CreateIndirectLight(Interpolators i) {
UnityIndirect indirectLight;
indirectLight.diffuse = 0;
indirectLight.specular = 0;
#if defined(VERTEXLIGHT_ON)
indirectLight.diffuse = i.vertexLightColor;
#endif
return indirectLight;
}
float4 Frag(Interpolators i): SV_Target {
i.normal = normalize(i.normal);
float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
float3 SpecularTint;
float oneMinusReflectivity;
albedo = DiffuseAndSpecularFromMetallic(albedo, _Metallic, SpecularTint, oneMinusReflectivity);
// UnityIndirect indirectLight;
// indirectLight.diffuse = 0;
// indirectLight.specular = 0;
return UNITY_BRDF_PBS(
albedo, SpecularTint,
oneMinusReflectivity, _Smoothness,
i.normal, viewDir,
CreateLight(i), CreateIndirectLight(i)
);
}
#endif
对于 vertex light,为何仅计算其 diffuse (漫反射)模型?顶点光照的效果主要影响物体的基本光照强度,因此仅需漫反射模型。漫反射光照描述了光线以较大角度打到物体上时的亮度变化。
虽然理论上也可以计算 Specular (高光项),但在顶点光照中,由于其需经过插值操作,高光效果可能会显得非常不准确(特别是在较大的三角形中)。
如何理解 ComputeVertexLightColor
函数中 lightPos
变量的计算?Unity 使用特殊的全局变量来存储场景中顶点光源的位置和方向信息。对于顶点光,Unity 最多支持 4 个光源,每个光源的位置信息被拆分存储在不同的变量中(这个想法好变态啊!)。
举个例子:unity_4LightPosX0
是一个 float4
,它的四个分量分别代表场景中四个光源在 X 轴上的位置,然后 unity_4LightPosY0
的四个分量是四个光源在 Y 轴上的位置。
好变态啊。
如何理解 ComputeVertexLightColor
函数中光线衰减(attenuation)的计算?其他光照计算照常进行,只是 UNITY_LIGHT_ATTENUATION 宏在这里不能使用,所以还是用公式 1 1 + d 2 \frac{1}{1+d^2} 1 + d 2 1 。
另外对于衰减效果,实际上 UnityShaderVariables
也提供了一个变量,unity_4LightAtten0
。它包含了光衰减的近似因子。使用这个办法,光线衰减计算将会变为 1 1 + d 2 a \frac{1}{1+d^2a} 1 + d 2 a 1 。
如何将单一 Vertex Light 包裹变成多个 Vertex Light 的打包光源?使用新的工具函数 Shade4PointLights。
从单一光源扩展到多个(最多四个)顶点光,只需要向顶点光照计算函数(Shade4PointLights
)中传递多个光源的参数即可。
函数通过对每个顶点光源的光照计算进行循环处理,将多个光源的光照结果累加,最终形成完整的顶点光照效果。
MultipleLights_Vertex-Light_Included - ComputeVertexLightColor
Copy void ComputeVertexLightColor(inout Interpolators i) {
#if defined(VERTEXLIGHT_ON)
i.vertexLightColor = Shade4PointLights(
unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
unity_LightColor[0].rgb, unity_LightColor[1].rgb,
unity_LightColor[2].rgb, unity_LightColor[3].rgb,
unity_4LightAtten0, i.worldPos, i.normal
);
#endif
}
使用 Shade4PointLights
工具函数,但是场景中超出 / 少于四个顶点光时,会怎么样?当场景中的光源数量小于四个时,没有什么特别的处理,Unity 会直接将剩余的光源设为黑色或忽略。
当场景中有超过四个光源时,Unity 会自动根据光源的重要性(如距离、强度等)决定哪些光源作为像素光,哪些光源作为顶点光,以减少性能开销。
转换机制:
Unity 通过同时将光源作为顶点光和像素光的方式,隐藏了两者之间的切换,以保证光照效果在多个 pass 中保持平滑过渡,并自动根据情况决定以何种方式进行处理。具体来说,重要的光源会被处理为像素光(光照计算精确),否则会被处理为顶点光。
这种处理是自动的,但也可以在光源设置的 Render Mode 里覆写。
6 最后的光:Spherical Harmonics
球谐函数(Spherical Harmonics,SH)可以理解为一个工具,它将空间中的光线信息表示成低频数据。这种低频数据适合处理环境光(ambient light)或间接光(indirect light),因为这类光的变化较缓慢,无需高精度的逐像素或逐顶点计算。
6.1 前前置的知识 Pre-Pre-Knowledge
球谐函数(Spherical Harmonics,SH)可以理解为一个工具,它将空间中的光线信息表示成低频数据。其核心思想是:通过一个单独的函数来描述一个点周围所有方向上的入射光 。
这个函数在一个虚拟的球面上定义,描述不同方向的光线强度。虽然函数并不是无限铺展的,但它能够在三维空间中每个方向上定义光照的分布。这种球面模型可以捕捉到任意角度的光线变化。
因为精确的采样计算(在每个点进行采样转化为一个连续的方程)是不可能的,因此球谐函数是一种近似处理。
什么情况下会使用球谐函数处理光源?在渲染系统中,当所有的像素光和顶点光都已经用完时,球谐函数(Spherical Harmonics)就成为处理光照的另一种方法。它支持三种光源(方向光、点光源和聚光灯),并通过数学方法近似地计算光照,而不是在每个像素或顶点上进行精确计算。
因为球谐函数用低频的方式描述光照分布,因此这种描述方式适合较大区域、低频变化的光源环境,因为它不会精准地记录每个微小变化。
如何使用球谐函数描述光线?球谐函数函数通常使用球坐标系来定义(也可以使用三维直角坐标系),然后利用物体的法线向量来对函数进行采样。
关键采样点 :因为球谐函数只是低频近似,通常不会对每一个像素都进行细粒度计算,而是会在物体表面的一些关键采样点(如特征表面或区域中心)计算,这种分辨率比逐像素光照要低,但已经足够描绘环境光的效果。
采样法线向量 :法线向量用来找到表面每个点接收光线的方向。球谐函数通过这些法线采样点获取入射光方向,并结合光强来构造环境光效果。
定义光照方程 :在物体的本地坐标系上,光照方程定义了物体表面上的光分布情况。对于距离远、变化慢的光源(如天空光),可以用一个全局的球谐函数来定义环境光的分布。
频带分解 :光照方程的关键是将环境光拆分为多个频率的函数(频带)。球谐函数的每个频带描述光照中的不同频率部分,而不是追求完美表达所有细节。通常使用一到三个频带已足够逼真,能有效近似大范围、柔和的光照变化。
举个例子。假设小写字母 a,b,c,d,e,… 表示从环境光采样得到的球谐系数。现有三种光源:天空光 α、霓虹光 β、彩虹光 γ。光照方程由球谐基函数 A,B,C,D,E... 表示(下文有述)。
每个光源在不同频带上都会有对应的强度系数。例如,天空光 α 在频带 A 上的强度为 a 1 α a1_{\alpha} a 1 α ,在频带 B 上的强度为 b 1 α b1_{\alpha} b 1 α ;同理,霓虹光 β 和彩虹光 γ 在这些频带上也有自己的系数 a 2 β a2_{\beta} a 2 β 、b 2 β b2_{\beta} b 2 β 、a 3 γ a3_{\gamma} a 3 γ 、b 3 γ b3_{\gamma} b 3 γ ,表示它们对这些频带的光照贡献。
因此,最终的光照方程是不同光源对每个频带的加权和,可以表示为:
光照方程 = ( a 1 α + a 2 β + a 3 γ ) A + ( b 1 α + b 2 β + b 3 γ ) B + … {光照方程} = (a1_{\alpha} + a2_{\beta} + a3_{\gamma}) A + (b1_{\alpha} + b2_{\beta} + b3_{\gamma}) B + \ldots 光照方程 = ( a 1 α + a 2 β + a 3 γ ) A + ( b 1 α + b 2 β + b 3 γ ) B + …
如何理解频带的分解?一个简单的例子是将正弦波(sine wave)分解成不同的频带。我们可以从一个简单的正弦函数开始,并逐步引入更多的频带来增加复杂性。每个频带的频率会加倍,而幅度会减半。通过调整每个频带的频率、幅度和偏移量,可以对复杂的光照方程进行逼近。当使用较少的频带时,虽然精确度降低,但仍然可以捕捉到主要的光照特征。类似的技术被应用于声音和图像的处理,在这里,则是用来近似 3D 环境中的光照。
球谐函数会如何处理采样结果中,不同频率的频带?最低频率的频带(横坐标上波浪更长)代表了光照方程的大致特征,我们希望保留这些特征,舍弃高频的频带。这意味着会失去一些光照的细节,但对于变化不大的漫反射光(diffuse light)场景来说,这样的近似是足够的。因此,球谐函数更适合用来处理漫反射的光线。
6.2 前置的知识 Pre-Knowledge
最简易的对于光线的近似是使用一个均匀的颜色。这个光线在所有方向上都相同。这是第一个频带(也叫零阶频带),用符号 Y 0 0 Y_0^0 Y 0 0 来表示。它被定义在一个 sub-function(基函数)里,是一个简单的常数。
第二个频带代表了线性光照,即光线的方向性变化。每个方向上的光照强度不同,可以沿着三个坐标轴(x, y, z)来描述。分解成的三个方程 Y 1 − 1 Y_1^{-1} Y 1 − 1 、 Y 1 0 Y_1^{0} Y 1 0 、 Y 1 1 Y_1^{1} Y 1 1 对应于 x、y 和 z 轴,它们分别表示光线在每个方向上的强度变化。
第三个频带更加复杂一些,它包含了五个函数 Y 2 − 2 Y_2^{-2} Y 2 − 2 ... Y 2 2 Y_2^{2} Y 2 2 。这些方程是二次方程(quadratic),它们包含法线坐标的二次组合。这些项表示更复杂的光照分布,尤其是阴影、反射等光的变化
我们可以继续,但 Unity 只使用了前三个频带,因为更高的频带会增加计算复杂度,且对于大多数场景,前三个频带已经足够描述光照的主要特征。如下图。
所有这些项都被乘以 1 2 π \frac{1}{2\sqrt\pi} 2 π 1 。
虽然这些球谐函数看起来是分开的基函数,但它们其实构成了一个整体的方程。通过将九个不同的球谐函数项相加,来表示复杂的光照环境。
我们还可以将函数中的常数部分合并到系数中去,形成最终的方程:
a + b y + c z + d x + e x y + f y z + g z 2 + h x z + i ( x 2 − y 2 ) a + by+cz+dx+exy+fyz+gz^2+hxz+i(x^2-y^2) a + b y + cz + d x + e x y + f yz + g z 2 + h x z + i ( x 2 − y 2 ) 其中 a ~ i 都是因子。
6.2 粗糙的想法 Rough Idea
直接使用球谐函数看起来是什么样子?UnityCG 的 ShadeSH9
使用球谐函数数据和法线参数计算光照,传入的 float4 参数的第四个分量通常设为 1,表示法线是标准化的,且没有进行任何缩放。
为看到最终近似的效果,先在 fragment shader 直接返回 ShadeSH9
的结果。
MultipleLights_Vertex-Light_Included - Frag
Copy // 在 fragment shader 的 return 语句前加入这两句
float3 shColor = ShadeSH9(float4(i.normal, 1));
return float4(shColor, 1);
Surprise!物体没有完全变黑!球谐函数被用于模拟环境光(Ambient Light),即物体周围所有方向上的漫射光线。所以即使在没有直接光照的情况下,物体仍然会从环境光中拾取颜色。
现在打开一堆灯光,确保它们足够多,让像素光和顶点光完全用完。剩余的光照会被分配给球谐函数来处理。
就像处理顶点光一样,我们将球谐函数的光照数据添加到漫反射的间接光中。此外记得确保球谐函数生成的光照值不会是负值。
因为球谐函数在 Base Pass 中使用,首先定义 FORWARD_BASE_PASS
,此时 Unity 的 shader 会知道它需要同时处理顶点光和球谐函数光照,从而混合使用这两种光照技术生成最终结果。
在 indirect light 的自定义计算函数 CreateIndirectLight
中加入球谐函数计算,使其计算结果加入 indirect light 的 diffuse 部分(需条件检测);
6.3 CODING
Main Light
Copy Pass {
Tags {
"LightMode" = "ForwardBase"
}
CGPROGRAM
#pragma target 3.0
#pragma multi_compile _ VERTEXLIGHT_ON
#pragma vertex Vert
#pragma fragment Frag
#define FORWARD_BASE_PASS // 需在 cginc 声明之上定义
#include "SH_Light.cginc"
ENDCG
}
Copy UnityIndirect CreateIndirectLight(Interpolators i) {
UnityIndirect indirectLight;
indirectLight.diffuse = 0;
indirectLight.specular = 0;
#if defined(VERTEXLIGHT_ON)
indirectLight.diffuse = i.vertexLightColor;
#endif
#if defined(FORWARD_BASE_PASS)
indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
#endif
return indirectLight;
}
Vertex Light 和球谐函数都使用 Base Pass,不会发生冲突吗?球谐函数与顶点光是相互独立的。Base Pass 是处理主要光照(包括像素光和顶点光)的地方。球谐函数可以在 Base Pass 中处理环境光,和顶点光并不冲突。
而且在 indirect light 的计算函数中可知,是在已有计算的 diffuse 上再增加来自球谐函数的 diffuse(如果定义球谐函数的话),因此它们的关系是相加,并非相斥。
球谐函数能用于 Skybox(天空盒)吗?如果球谐函数能够包含均匀的环境光颜色,它能用于天空盒吗?当然可以!Unity 通过球谐函数来近似天空盒中的光照效果。
关闭所有灯光,选择默认灯光的默认天空盒。新的场景是默认使用这个天空盒的,只是我们在此前的教程里删去它了。
Unity 现在在背景中渲染了这个天空盒,它是基于主光源生成的程序化天空盒。当没有任何活动光源时,天空盒看起来像是太阳位于地平线附近。可以看到物体表面上拾取了一些天空盒的颜色,形成了微妙的光影效果。这些效果都是通过球谐函数实现的。
打开主光源后,天空盒的外观会有明显变化。可能注意到球谐函数的光照更新会比天空盒稍微滞后,因为 Unity 在拟合天空盒光照时需要一些时间。当环境光突然变化时,这种延迟会更明显。
会发现物体突然变得非常明亮!这是因为环境光的贡献非常强,天空盒模拟了晴朗的天气。在这种情况下,纯白色的表面会显得极为明亮,特别是在 Gamma 颜色空间下更为明显。现实生活中,物体通常不会像这样亮得如此夸张,表面通常会暗一些。
APPENDIX
Anki
Reference
Catlike Coding - Rendering 5: Multiple Lights
Last updated 4 months ago