更复杂的光照(二)

本文最后更新于:2025年4月17日 下午

Unity 的光源类型

Unity 一共支持 4 种光源类型:平行光(directional light)点光源(point light)聚光灯(spot light)面光源(area light)。面光源仅在烘焙时才可发挥作用,因此不在本节讨论范围内。

光源类型有什么影响

Shader 中使用的光源属性最常用的有光源的位置、方向(更具体的说就是到某点的方向)、颜色、强度以及衰减(更具体的说就是,到某点的衰减,与该点到光源的距离有关)。这些属性和光源的几何定义息息相关。

1. 平行光

平行光可以照亮的范围没有限制,它通常作为太阳这样的角色在场景中出现。平行光没有唯一的位置,它可以放在场景中的任意位置。它的几何属性只有方向,而且平行光到场景中的所有点的方向都是一样的。除此之外,由于平行光没有一个具体的位置,因此也没有衰减的概念,也就是说,光照强度不会随着距离而发生改变。

平行光

2. 点光源

点光源的照亮空间时有限的,它由空间中的一个球体定义。点光源可以表示由一个点发出的、向所有方向延伸的光。球体的半径可以由面板中的 Range 属性来调整,也可以在 Scene 视图中直接拖拉点光源的线框来修改。点光源有位置属性,由点光源的 Transform 组件中的 Position 属性定义。对于方向属性,我们需要用点光源的位置减去某点的位置来得到它到该点的方向。而点光源的颜色和方向可以在 Light 组件中调整。同时,点光源也是会衰减的,随着物体逐渐远离点光源,它接收到的光照强度也会逐渐减小。点光源球心处的光照强度最强,球体边界处的最弱,值为 0。其中间的衰减值可以由一个函数定义。

点光源

3. 聚光灯

聚光灯的照亮空间同样是有限的,是由空间中的一块锥形区域定义的。聚光灯可以用于表示由一个特定位置出发、向特定方向延伸的光。这块锥形区域的半径由面板中的 Range 属性决定,而锥体的张开角度由 Spot Angle 属性决定。我们同样也可以在 Scene 视图中直接拖拉聚光灯的线框来修改它的属性。聚光灯的位置同样是由 Transform 组件中的 Position 属性定义的。对于方向属性,我们需要用聚光灯的位置减去某点的位置来得到它到该点的方向。聚光灯的衰减也是随着物体逐渐远离点光源而逐渐减小,在锥形的顶点处光照强度最强,在锥形的边界处强度为 0。其中间的衰减值可以由一个函数定义,这个函数相对于点光源衰减计算公式要更加复杂,因为我们需要判断一个点是否在锥体的范围内。

聚光灯

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

在了解了 3 种光源的几何定义后,我们来看一下如何在 Unity Shader 中访问它们的 5 个属性:位置、方向、颜色、强度以及衰减。需要注意的是,本节均建立在使用前向渲染路径的基础上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// Upgrade NOTE: replaced '_LightMatrix0' with 'unity_WorldToLight'

Shader "Custom/Chapter9/Chapter9-ForwardRendering"
{
Properties
{
_Diffuse("Diffuse", Color) = (1, 1, 1, 1)
_Specular("Specular", Color) = (1, 1, 1, 1)
_Gloss("Gloss", Range(8.0, 256)) = 20
}
SubShader
{
Tags { "RenderType"="Opaque" }

Pass {
// Pass for ambient light & first pixel light(directional light)
Tags {"LightMode" = "ForwardBase"}

CGPROGRAM

//Apparently need to add this declaration
#pragma multi_compile_fwdbase

#pragma vertex vert
#pragma fragment frag

#include "Lighting.cginc"

fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//可以使用顶点变换矩阵的逆转置矩阵对法线进行变换,首先得到模型空间到世界空间的变换矩阵的
//逆矩阵_World2Object,然后通过调换它在mul函数中的位置,得到和转置矩阵相同的矩阵乘法
//o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}

fixed4 frag(v2f i) : SV_TARGET {
//Get ambient term
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
//Compute diffuse term
fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(0, dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
//Compute Specular term
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal,halfDir)), _Gloss);
//The attenuation of directional light is awaly 1
fixed atten = 1.0;
return fixed4(ambient + (diffuse +specular) * atten, 1.0);
}

ENDCG
}

Pass{
//Pass for other pixel lights
Tags {"LightMode" = "ForwardAdd"}

Blend One One

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

//Apparently need to add this declaration
#pragma multi_compile_fwdadd

#include "AutoLight.cginc"
#include "Lighting.cginc"


fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f {
float4 pos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
};

v2f vert(a2v v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
//可以使用顶点变换矩阵的逆转置矩阵对法线进行变换,首先得到模型空间到世界空间的变换矩阵的
//逆矩阵_World2Object,然后通过调换它在mul函数中的位置,得到和转置矩阵相同的矩阵乘法
//o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
return o;
}

fixed4 frag(v2f i) : SV_TARGET {
fixed3 worldNormal = normalize(i.worldNormal);

#ifdef USING_DERECTIONAL_LIGHT
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
#else
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos.xyz);
#endif

fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
fixed3 halfDir = normalize(worldLightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal,halfDir)), _Gloss);

#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
return fixed4((diffuse +specular) * atten, 1.0);
}

ENDCG
}

}
FallBack "Specular"
}

我们在 Base Pass 中处理了场景中的最重要的平行光。在这个例子中,场景中只有一个平行光。如果场景中包含了多个平行光,Unity 会选择最亮的平行光传递给 Base Pass 进行逐像素处理,其他平行光会按照逐顶点或在 Additional Pass 中按逐像素的方式处理。如果场景中没有任何平行光,那么 Base Pass 会当成全黑的光源处理。我们提到过,每一个光源有 5 个属性:位置、方向、颜色、强度以及衰减。对于 Base Pass 来说,它处理的逐像素光源类型一定是平行光。我们可以使用 _WorldSpaceLightPos0 来得到这个平行光的方向(位置对于平行光来说没有意义),使用 _LightColor0 来得到它的颜色和强度(_LightColor0 已经是颜色和强度相乘后的结果),由于平行光可以认为是没有衰减的,因此这里我们直接令衰减值为 1.0。

我们在 Additional Pass 中使用 Blend 命令开启和设置了混合模式。这是因为开启混合模式可以让 Additional Pass 计算得到的光照结果可以在帧缓存中与之前的光照结果进行叠加。如果没有使用 Blend 命令的话,Additional Pass 会直接覆盖掉之前的光照结果。我们通过判断是否定义了 USING_DIRECTIONAL_LIGHT 来决定当前处理的光源类型。如果是平行光的话,衰减值是 1.0。如果是其他光源类型,那么处理更复杂一些。尽管我们可以使用数学表达式来计算给定点相对于点光源和聚光灯的衰减,但这些计算往往涉及开根号、除法等计算量相对较大的操作,因此 Unity 选择了使用一张纹理作为查找表(Lookup Table, LUT),以在片元着色器中得到光源的衰减。我们首先得到光源空间下的坐标,然后使用该坐标对衰减纹理进行采样得到衰减值。

使用一个平行光和一个点光源共同照亮物体

使用一个平行光和四个点光源照亮一个物体

打开帧调试器查看场景的绘制事件

本例中的6个渲染事件

如果物体不在一个光源的光照范围内,Unity不会调用Additional Pass来为该物体处理该光源

把光源的RenderMode设为Not Important时,这些光源就不会按逐像素光来处理

Unity 的光照衰减

Unity 使用一张纹理作为查找表来在片元着色器中计算逐像素光照的衰减。这样的好处在于计算衰减不依赖于数学公式的复杂性,我们只要使用一个参数值去纹理中采样即可。但使用纹理查找来计算衰减也有一些弊端。

  • 需要预处理得到采样纹理,而且纹理的大小也会影响衰减的精度。
  • 不直观,同时也不方便,因此一旦把数据存储到查找表中,我们就无法使用其他数学公式来计算衰减。

但由于这种方法可以在一定程度上提升性能,而且得到的效果在大部分情况下都是良好的,因此 Unity 默认就是使用这种纹理查找的方式来计算逐像素的点光源和聚光灯的衰减的。

用于光照衰减的纹理

Unity 在内部使用一张名为 _LightTexture0 的纹理来计算光源衰减。如果我们使用了 cookie,那么衰减查找纹理是 _LightTextureB0。我们通常只关心 _LightTexture0 对角线上的纹理颜色值,这些值表明了在光源空间中不同位置的点的衰减值。例如,(0, 0)点表明了与光源位置重合的点的衰减值,而(1, 1)点表明了在光源空间中所关心的距离最远的点的衰减。

为了对 _LightTexture0 纹理采样得到给定点到该光源的衰减值,我们首先需要得到该点在光源空间中的位置,这是通过 _LightMatrix0 变换矩阵得到的。_LightMatrix0 可以把顶点从世界空间变换到光源空间。因此只需要把 _LightMatrix0 和世界空间中的顶点坐标相乘即可得到光源空间中的相应位置。

1
float3 lightCoord = mul(_LightMatrix0, float4(i.worldPosition, 1)).xyz;

然后我们可以使用这个坐标的模的平方对衰减纹理进行采样,得到衰减值:

1
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;

我们使用了光源空间中顶点距离的平方(通过 dot 函数来得到)来对纹理采样,之所以没有使用距离值来采样是因为这种方法可以避免开方操作。然后,我们使用宏 UNITY_ATTEN_CHANNEL 来得到衰减值所在的分量,以得到最终的衰减值。

使用数学公式计算衰减

尽管纹理采样的方法可以减少计算衰减时的复杂度,但有时我们希望可以在代码中利用公式来计算光源的衰减。

1
2
float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
atten = 1.0 / distance; //linear attenuation

可惜的是,Unity 没有在文档中给出内置衰减计算的相关说明。尽管我们仍然可以在片元着色器中利用一些数学公式来计算衰减,但由于我们无法在 Shader 中通过内置变量得到光源的范围、聚光灯的朝向、张开角度等信息,因此得到的效果往往在有些时候不尽如人意,尤其在物体离开光源的照明范围时会发生突变(这是因为,如果物体不在该光源的照明范围内, Unity 就不会为物体执行一个 Additional Pass)。当然,我们可以利用脚本将光源的相关信息传递给 Shader,但这样的灵活性很低。


更复杂的光照(二)
http://example.com/posts/更复杂的光照(二)/
作者
祭零小白
发布于
2022年2月18日
许可协议