Unity Shader学习笔记(三) - 光照、高级纹理与时间动画

本文最后更新于 2025年2月2日 晚上

不语,只是一味的学。

光照

渲染路径

渲染路径(Rendering Path)决定了光照如何应用到Shader。Unity支持前向渲染路径(Forward Rendering Path)和延迟渲染路径(Deferred Rendering Path)。通过Edit-Project Settings-Player-Other Settings-Rendering Path选择全局渲染路径,也可以在Camera组件的Inspector中修改。

编写Unity Shader时,需要使用“LightMode”标签指定该Pass使用的渲染路径。具体包含下列选项:

标签名 描述
Always 无论使用何种渲染路径,始终渲染该Pass
ForwardBase 前向渲染,计算环境光、主平行光、逐顶点/球谐光源和光照贴图
ForwardAdd 前向渲染,计算额外的逐像素光源,每个Pass对应一个光源
Deferred 延迟渲染
ShadowCaster 将物体深度信息渲染到Shadow Map或一张深度纹理中

前向渲染

前向渲染的步骤如下:

  • 对于每个对象的片段:
    • 若未通过深度测试,则剔除
    • 若可见,则基于材质属性、位置、法线、光照方向、视线方向等信息计算光照
    • 更新帧缓冲

对于每个逐像素光源,我们都需要进行一次完整的光照计算流程。也就是说,在前向渲染中,光源数越多,执行的Pass数量越多,性能消耗越大。

在Unity中,前向渲染路径有三种光照处理方式:逐顶点、逐像素、球谐函数。使用哪种方式处理光源取决于光源Inspector面板中的Render Mode属性。它包含Auto、Important、Not Important。设置为Important的光源将会被作为逐像素光源。

Unity的判断规则如下:

  • 场景中Intensity最大的平行光始终按逐像素处理
  • Not Important的光源不会按逐像素处理,而是使用逐顶点或球谐
  • Important的光源始终逐像素
  • 若按上述规则判定完毕的逐像素光源小于Quality Settings中的逐像素光源数量,则更多光源按逐像素渲染,直到达到上限。

光照计算在Pass中进行。前面提到,前向渲染的Pass有Base和Additional两种。

前者通过ForwardBaseLightMode Tag指定,且需要加上#pragma multi_compile_fwdbase指令

后者通过ForwardAddLightMode Tag指定,且需要加上#pragma multi_compile_fwdadd_fullshadows指令,同时需要使用Blend One One指令,使得计算结果与颜色缓冲中的值相加

带有_fullshadows的指令用于给Additional Pass中渲染的光源附加阴影。如果不需要阴影,删除该后缀即可。

带有后缀的Additional Pass会导致更多的Shader变体。

对千前向渲染来说, 一个Unity Shader通常会定义一个Base Pass (Base Pass也可以定义多次, 例如需要双面渲染等情况)以及一个Additional Pass。 一个Base Pass仅会执行一 次(定义了多个BasePass的情况除外), 而一个Additional Pass会根据影响该物体的其他 逐像素光源的数目被多次调用, 即每个逐像素光源会执行一次AdditionalPass。

延迟渲染

延迟渲染中,第一个Pass仅用于深度测试,通过测试的片段将会将其携带的材质信息、位置、法线等数据写入G-Buffer。第二个Pass采样G-Buffer进行光照计算。

延迟渲染的缺点有:

  • 对MSAA支持不佳
  • 无法处理半透明物体
  • 显卡必须支持MRT等较新的特性

延迟渲染的优点有:

  • 支持的光源数量大大提升

Unity中,默认G-Buffer包含下列RT:

  • RT0:ARGB32,RGB存储漫反射颜色,A未使用
  • RT1:ARGB32,RGB存储镜面反射颜色,A存储镜面反射指数
  • RT2:ARGB2101010,RGB存储法线,A未使用
  • RT3:ARGB32(非HDR)或ARGBHalf(HDR),存储自发光、光照贴图、反射探针
  • 深度、模板缓冲

在第二个Pass计算光照时,仅能使用Unity内置标准光照模型。若要替换,请见延迟着色渲染路径 - Unity 手册

光源类型

Unity中,包含平行光、点光源、聚光灯以及面光源(仅在烘焙时起效)。

光源的常用属性有位置、方向、颜色、强度、衰减。

  • 对于平行光,它的位置属性没有意义。
  • 对于点光源,其照明范围为一个球体,衰减值由一个函数定义。
  • 对于聚光灯,其照明范围为一个四棱锥。锥体的半径由Range属性决定,锥体张开角度由Spot Angle定义。

对于我们自己编写的Shader,需要考虑场景中存在的任何种类的光源,并分别对它们加以处理。主光源在ForwardBase Tag Pass下计算光照值与衰减(没错,衰减值要手动计算);其他光源在ForwardAdd Tag Pass下计算。

可以使用USING_DIRECTIONAL_LIGHT宏进行条件编译,来判断不同的光源类型。

平行光的_WorldSpaceLightPos0的w分量没有意义,xyz分量作为LightDir

光照衰减

计算光照衰减是个性能消耗很大的过程,因为其中包含开根号。为了避免这种情况,Unity使用内置变量_LightTexture0作为衰减贴图,将片段在光源空间下的坐标的平方作为采样坐标,对其进行采样,作为衰减值。

1
2
float3 lightCoord = mul(_LightMatrix0, float4(i.worldPosition, 1)).xyz;
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL; //UNITY_ATTEN_CHANNEL用于得到衰减值中衰减纹理所在的分量

阴影

传统Shadow Map

Shadow Map是最常见的阴影技术,它将光源作为相机,对周围进行一次仅深度写入的渲染,得到一张深度图,从而得到哪些片段应当被照亮。

用于生成Shadow Map的仅深度写入的Pass的LightMode为ShadowCaster。

标记LightMode为ShadowCaster后,此Pass的RT便自动更改到深度纹理。

一般我们无需手动编写ShadowCaster Pass,而是在Fallback语义块中指定Unity的默认Shadow Caster Pass。

Mesh Renderer的Cast Shadow属性本质上是该物体是否被Shadow Caster Pass渲染。

屏幕空间Shadow Map

屏幕空间Shadow Map通过光源的深度纹理和摄像机的深度纹理计算得到屏幕空间的阴影图。通过将片段坐标变换到屏幕空间,就能知道该片段是否位于阴影中。

接受阴影

步骤如下。

  1. #include “AutoLight.cnginc”
  2. v2f结构体中添加内置宏SHADOW_COORDS(2),声明用于对阴影纹理采样的坐标。

注意,括号中的数字为插值寄存器序号,代表TEXCOORDN

  1. 在VS中,返回颜色值前添加宏TRANSFER_SHADOW(o)
  2. 在FS中使用宏SHADOW_ATTENUATION(i)计算阴影值
  3. 添加到最终的颜色值上

衰减与阴影的统一管理

光源衰减和阴影本质上都是最终颜色值的系数。使用UNITY_LIGHT_ATTENUATION宏可以同时处理这两个信息。步骤如下:

  1. #include “Lighting.cginc”#include “AutoLight.cginc”
  2. v2f添加SHADOW_COORDS(2)
  3. VS中调用TRANSFER_SHADOW
  4. FS中调用UNITY_LIGHT_ATTENUATION(atten, v2f, worldPos)得到out atten值。

在使用UNITY_LIGHT_ATTENUATION时,我们不再需要在Base Pass中单独处理阴影,也无需在Add Pass中判断光源类型来处理衰减。

透明物体阴影

步骤如下:

  1. 像之前一样,使用UNITY_LIGHT_ATTENUATION计算阴影与衰减
  2. 声明_Cutoff属性
  3. 使用`“Transparent/Cutout/VertexLit”作为Fallback
  4. 设置此半透明物体的Mesh RendererCast Shadow属性为Two Sided

注意:此设置仅是用与Alpha Test处理的透明物体。使用Alpha Blend的物体始终无法实现现实中的半透光阴影,只能把Fallback设置为Vertex Lit,投射完整阴影。

高级纹理

CubeMap

CubeMap包含六张图像,对应立方体的六个面。CubeMap使用三维向量而非二维纹理坐标进行采样。

CubeMap最常应用于Skybox。创建CubeMap天空盒材质的步骤如下:

  1. 新建材质,将其Shader选择为Skybox/6_Sided
  2. 将六张纹理分别赋值给六个面(纹理的环绕模式需要设置为Clamp)
  3. 在Window-Lighting菜单中把材质赋值给Skybox属性。
  4. 设置Camera组件的Clear Flag为Skybox

除了创建材质并赋值外,也可以直接导入HDRI,将其Import Settings中的Texture Type设置为Cubemap即可。

也可以通过脚本创建CubeMap。Camera组件的RenderToCubeMap方法可以将相机所看到的图像渲染到指定的CubeMap资产中。用此方法时,需要注意创建的CubeMap资产的导入设置需要设置为Readable,且尺寸要足够大,否则分辨率会比较低。

除此之外,CubeMap也可用于镜面反射的环境映射。以反射材质为例:

  1. 首先,Properties语义块中需要定义类型为Cube的CubeMap属性。
  2. 在VS中计算反射方向
1
2
o.worldViewDir = UnityWorldSpaceViewDir(o.worldPos);
o.WorldRefl = reflect(-o.worldViewDir, o.WorldNormal);
  1. 在FS中使用texCUBE函数对CubeMap采样
1
fixed3 reflection = texCUBE(_Cubemap, i.worldRefl).rgb * _ReflectColor.rgb;

通过计算菲涅尔项并将其作为漫反射光和镜面反射的lerp系数,可以模拟出较好的环境反射效果。

RenderTexture

由于Built-in管线和URP在RenderTexture和GrabPass方面的应用差距过大,故此处略。

程序纹理

Texture2DSetPixel方法可以精准地设置纹理中某个像素的颜色。由此,我们可以在Unity内部生成程序化纹理。

然而,在实际项目中,更多会使用Substance Designed进行程序化纹理设计。SD生成的材质以.sbsar为后缀,可以直接拖入Unity。

时间与动画

时间内置变量

Unity Shader包含下列时间相关内置变量:

名称 类型 描述
_Time float4 t为自场景开始所经过的时间,分量分别为(t/20, t, 2t, 3t)
_SinTime float4 t为时间正弦值,分量分别为(t/8, t/4, t/2, t)
_CosTime float4 t为时间余弦值,其余同上
unity_DeltaTime float4 dt为时间增量,份量分别为(dt, 1/dt, smoothDt, 1/smoothDt)

纹理动画

序列帧

一张序列帧如图:

image-20250202210001476

使用下列代码实现序列帧播放。

  • 首先,Properties块:
1
2
3
4
5
_Color ("Color Tint", Color) = (1,1,1,1) // 主颜色
_MainTex ("Image Sequence", 2D) = "white"{} // 序列帧纹理
_HorizontalAmount ("Horizontal Amount", float) = 4 // 列数
_VerticalAmount ("Vertical Amount", float) = 4 // 行数
_Speed ("Speed", float) = 30 // 播放速度
  • 标准的半透明Shader起手,即Tag中Queue、RenderType设置为Transparent, IgnoreProjector设置为True,启用Blend,禁用深度写入
  • VS转换顶点坐标即可
  • FS如下:
1
2
3
4
5
6
7
8
9
10
11
fixed4 frag (v2f i) : SV_Target{
float time = floor(_Time.y * _Speed); //得到取整的模拟时间,表示目前是序列帧中的第time个Sprite
float row = floor(time / _HorizontalAmount); //得到行索引。一行有_HorizontalAmount个Sprite,time除以该值的商就是行索引
float column = time - row * _HorizontalAmount; //得到列索引,这里很容易理解
half2 uv = i.uv + half2(column, -row); // 由于Unity中UV在垂直坐标上由下往上递增,故这里row取反
uv.x /= _HorizontalAmount;
uv.y /= _VerticalAmount; // 将UV限制在选中的Sprite范围内
fixed4 c = tex2D(_MainTex, uv);
c.rgb *= _Color;
return c;
}

尽管这种方法是脱裤子放屁,但它的思想仍然值得学习。

滚动动画

由于我已经实现过了,所以略。

顶点动画

河流

思路很简单,用时间正弦值对Sprite的顶点进行偏移。

需要注意的是,**使用顶点动画时,需要标记Tag “DisableBatching”为”True”。**因为Unity在进行Batching时,会将若干模型进行合并,导致原本的模型空间坐标发生差错。为了避免这种情况,需要关闭此材质的批处理。

VS如下:

1
2
3
4
5
6
7
8
9
10
11
v2f vert(a2v v){
v2f o;
float4 offset;
offset.yzw = float3(0.0, 0.0, 0.0);
// 这里之所以是x是因为书中给出的纹理是一个旋转了90度的河流贴图
offset.x = sin(_Frequency * _Time.y + v.vertex.x * _InvWaveLength + v.vertex.y * _InvWaveLength + v.vertex.z * _InvWaveLength) * _Magnitude;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex + offset);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.uv += float2(0.0, _Time.y * _Speed);
return o;
}

Billboard

让渲染的物体始终指向摄像机的技术。

同样地,设置为半透明起手式,并禁用批处理。

VS如下:

1
2
3
4
5
6
7
8
9
10
11
12
float3 center = float3(0,0,0);
float3 viewer = mul(_World2Object, float4(_WorldSpaceCameraPos,1)); //将相机位置变换到模型空间,因为后续需要将法线替换为ViewDir
float3 normalDir = viewer-center; // 模型空间下的viewDir,后续将作为normal
// 计算正交向量以计算旋转矩阵
normalDir.y = normalDir.y * _VerticalBillboarding; //此处_VerticalBillboarding为int类型的bool值,值垂直方向是否需要朝向相机。在类似于草面片的Billboarding中,垂直方向无需Billboarding
normalDir = normalize(normalDir);
float3 upDir = abs(normalDir.y) > 0.999 ? float3(0,0,1) : float3(0,1,0); // 如果normalDir与UpDir平行,那么叉乘得不到有意义的结果,所以要判断一下。此外,这里得到的upDir不是真正的正交upDir,只是用于计算rightDir的过渡变量
float3 rightDir = normalize(croos(upDir, normalDir));
upDir = normalize(cross(normalDir, rightDir)); //现在的upDir是正交的了
float3 centerOffs = v.vertex.xyz - center; // 中心到各顶点的方向向量,本质上就是各顶点的模型空间坐标,这个声明本质上没意义
float3 localPos = center + rightDir * centerOffs.x + upDir * centerOffs.y + normalDir * centerOffs.z; // 进行旋转变换
o.pos = mul(UNITY_MATRIX_MVP, float4(localPos, 1));

注意事项

使用了顶点动画的物体,若使用默认的VertexLit作为Fallback,则无法正确投射进行顶点动画后的阴影。我们需要自己写一个ShadowCaster Pass,其中v2f仅包含V2F_SHADOW_CASTER宏,并在VS中对顶点做一次相同的变换,然后使用TRANSFER_SHADOW_CASTER_NORMALOFFSET传入一个v2f结构体。在FS中,则直接调用SHADOW_CASTER_FRAGMENT宏即可。


Unity Shader学习笔记(三) - 光照、高级纹理与时间动画
http://example.com/2025/02/02/Unity-Shader学习笔记(三)-光照、高级纹理与时间动画/
作者
Yoi
发布于
2025年2月2日
许可协议