Unity Shader学习笔记(五) - 噪声、优化与PBS工作流

本文最后更新于 2025年2月10日 下午

噪声

消融

水波

水面的流动一般使用时间变量+噪声采样+改变法线方向的方式实现。除此之外,要想模拟较好的水波效果,还需要进行反射、折射的计算。其中,反射还涉及到菲涅尔反射(fresnel=pow(1-max(0, v·n),4)),因为视线与水面越平行,反射率就越高。此处,反射、折射使用CubeMap作为采样源。

Shader如下:

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
struct v2f{
float4 pos : SV_POSITION;
float4 scrPos : TEXCOORD0;
float4 uv : TEXCOORD1; // 此处UV需要存储两个float2,所以时float4
float4 TtoW0 : TEXCOORD2;
float4 TtoW1 : TEXCOORD3;
float4 TtoW2 : TEXCOORD4; // TBN矩阵,用于将法线从切线空间变换到世界空间
};
v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.scrPos = ComputeGrabScreenPos(o.pos); // 计算顶点在被Grab的图像中的“屏幕位置”。
o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord, _WaveMap); // _WaveMap是由噪声生成的法线纹理
float3 worldPos = mul(_Object2World, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 法线不能直接通过M矩阵变换,而是用M矩阵的逆转置矩阵
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
return o;
}
fixed4 frag(v2f i) : SV_Target{
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w); // 节省一个寄存器
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float2 speed = _Time.y * float2(_WaveXSpeed, _WaveYSpeed); // _Time.y为t本身
fixed3 bump1 = UnpackNormal(_tex2D(_WaveMap, i.uv.zw + speed)).rgb; // UnpackNormal用于解码采样法线值
fixed3 bump2 = UnpackNormal(_tex2D(_WaveMap, i.uv.zw - speed)).rgb;
fixed3 bump = normalize(bump1+bump2); // 模拟两层交叉水面的波动效果。否则水波只向一个方向流动,不太真实
float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy; // _RefractionTex是Grab得到的屏幕图像。struct v2f{
float4 pos : SV_POSITION;
float4 scrPos : TEXCOORD0;
float4 uv : TEXCOORD1; // 此处UV需要存储两个float2,所以时float4
float4 TtoW0 : TEXCOORD2;
float4 TtoW1 : TEXCOORD3;
float4 TtoW2 : TEXCOORD4; // TBN矩阵,用于将法线从切线空间变换到世界空间
};
v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.scrPos = ComputeGrabScreenPos(o.pos); // 计算顶点在被Grab的图像中的“屏幕位置”。
o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord, _WaveMap); // _WaveMap是由噪声生成的法线纹理
float3 worldPos = mul(_Object2World, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 法线不能直接通过M矩阵变换,而是用M矩阵的逆转置矩阵
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
return o;
}
fixed4 frag(v2f i) : SV_Target{
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w); // 节省一个寄存器
fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
float2 speed = _Time.y * float2(_WaveXSpeed, _WaveYSpeed); // _Time.y为t本身
fixed3 bump1 = UnpackNormal(_tex2D(_WaveMap, i.uv.zw + speed)).rgb; // UnpackNormal用于解码采样法线值
fixed3 bump2 = UnpackNormal(_tex2D(_WaveMap, i.uv.zw - speed)).rgb;
fixed3 bump = normalize(bump1+bump2); // 模拟两层交叉水面的波动效果。否则水波只向一个方向流动,不太真实
float2 offset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy; // _RefractionTex是Grab得到的屏幕图像,水波bump越强烈,折射看到的画面就越扭曲。
i.scrPos.xy = offset * i.scrPos.z + i.scrPos.xy; // 将深度纳入考虑,近处水面折射越强,远处较小
// 此时,i.scrPos.xy为投影空间坐标,而非Clip空间坐标,所以要进行透视除法(采样的UV是Clip空间下的)
fixed3 refrCol = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb; // 对i.scrPos进行透视除法,以得到折射向量
bump = normalize(half3(dot(i.TtoW0.xyz, bump),dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump))); // 将采样法线值通过TBN矩阵变换到世界空间
fixed4 texColor = tex2D(_MainTex, i.uv.xy + speed); // 主材质颜色,即水波本身的颜色
fixed3 reflDir = reflect(-viewDir, bump); // 反射方向,用于CubeMap采样
fixed3 reflCol = texCUBE(_Cubemap, reflDir).rgb * texColor.rgb * _Color.rgb;
fixed fresnel = pow(1-saturate(dot(viewDir, bump)),4); // 计算菲涅尔项
fixed3 finalColor = reflCol * fresnel + refrCol * (1-fresnel);
return fixed4(finalColor, 1);
}

非均匀雾效

VS与先前的全局雾效一致。

FS:

1
2
3
4
5
6
7
8
9
10
11
fixed4 frag(v2f i) : SV_Target{
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
float2 speed = _Time.y * float2(_FogXSpeed, _FogYSpeed);
float noise = tex2D(_NoiseTex, i.uv + speed).r - 0.5) * _NoiseAmount; // 此全局雾方案本质上是屏幕后处理,所以实际上只渲染了一个面片,所以噪声纹理二维的足矣
float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart); // 基于高度的线性雾
fogDensity = saturate(fogDensity * _FogDensity * (1 + noise));
fixed4 finalColor = tex2D(_MainTex, i.uv);
finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);
return finalColor;
}

优化

影响性能的优化如下:

  • CPU:DrawCall数量、脚本逻辑复杂程度、物理模拟次数
  • GPU:顶点数量、逐顶点计算复杂度、片段数量、逐片段计算复杂度
  • 带宽:纹理尺寸/压缩格式、帧缓冲分辨率

减少Drawcall

Drawcall是CPU对GPU发出的一次绘制请求。

渲染一千个三角形网格比渲染包含了一千个三角形的网格耗时更久,因为前者的drawcall数量是后者的一千倍。CPU会花费大把时间在提交drawcall上,而在提交的过程中,GPU将会等待CPU完成提交后才会渲染。

批处理(Batching)是一种常见的优化drawcall数量的技术。使用同一个材质的物体可以进行批处理。

Unity支持动态批处理和静态批处理。前者的一切操作是Unity自动完成的,但其存在诸多限制,以至于一不小心就会破坏动态批处理。静态批处理自由度较高,但会占用更多内存,并且经过静态批处理的物体将不可以再移动。

动态批处理

其原理为:在每一帧把符合条件的模型网格进行合并,然后把合并后的模型数据传递给GPU,再用同一材质对其进行渲染.。

主要的条件限制如下:

  • 网格顶点属性规模要小于某个定值。顶点属性规模的意思是,有N个顶点属性,M个顶点,则N*M应当小于定值。
  • 若使用了Lightmap,则材质使用的有关Lightmap的属性必须相同。
  • 单Pass

静态批处理

其原理为:在开始运行时,一次性将需要静态批处理的模型合并到同一网格。它与动态批处理的本质区别在于,静态批处理只需要运行一次

启用静态批处理的方式是,勾选GO Inspector名称右侧的Static复选框(或勾选下拉栏中的Batching Static)。

其具体实现原理为:

  1. 首先,将静态物体的顶点坐标变换到世界空间下。
  2. 为需要Batching的物体构建一个更大的顶点和索引缓存。
  3. 对于使用了同一材质的物体,调用一个Drawcall将其全部提交
  4. 对于使用不同材质的物体,减少它们之间的状态切换

共享材质

当一个材质从同一个着色器创建,赋值给了两个模型。但两个模型的材质属性有所不同,如纹理、颜色等,也会导致两个Drawcall。我们可以通过Atlas、顶点数据等策略规避这个问题。

图集(Atlas)是把若干纹理合并到一张大的纹理的优化策略。使用了同一张纹理,就能使用同一个材质,减少Drawcall。

纹理以外的材质属性,有微小变化的,可以使用网格顶点数据存储这些属性。无论如何,只要使用同一个材质实例的网格,都会共享所有属性的数值。使用同一个材质实例的网格,我们称这些网格使用了共享材质,表现为Renderer.sharedMaterial。使用setFloat等方法改变sharedMaterial属性时,所有使用该材质的网格均会变化。

若使用Renderer.material修改材质,本质上是创建了sharedMaterial的一个复制体,会破坏批处理。

注意事项

使用批处理需要谨记下列建议:

  1. 在保证内存占用可以接受的情况下,尽量选择静态批处理,且注意静态批处理的物体不可移动
  2. 若使用动态批处理,则需要尽可能避免破坏限制。

同时也需要注意,若Shader中存在模型空间运算(如顶点动画),则必须使用DisableBatching标签取消批处理,否则会导致错误。此外,对于半透明材质物体,一定注意其在Hierachy下的排列顺序满足从后往前,否则会破坏批处理。

减少顶点数量

几何体

在3D建模时,尽可能减少三角形数量,同时移除不必要的硬边(Hard Edge)和纹理衔接,以避免边界平滑和纹理分离。

这里解释一下上面几个专有名词的概念。

在Unity中显示的顶点数往往多于建模软件中的顶点数,这是因为GPU有时需要把一个顶点拆分为更多的顶点,一是为了分离纹理坐标(UV Split),另一个是为了产生平滑边界。对于前者,举个例子,一个立方体的三个面可能共用一个顶点,但在这三个面上该顶点对应的纹理坐标并不相同,所以就需要拆分出三个顶点。对于后者,一个顶点可能会对应多个法线或切线信息,而GPU对于一个顶点只能处理一个属性变量,所以就需要拆分出多个。

LOD

使用LOD Group组件为物体构建LOD(Level of Details),为一个物体准备多个不同细节层次的模型,并给组件赋值。

遮挡剔除

遮挡剔除(Occlusion Culling)用于消除在其他物体之后,无法看到的物体。

Occlusion Culling与视锥体剔除(Frustum Culling)不同。前者主要做深度判断,后者主要做NDC范围判断。

遮挡剔除作用于Renderer组件。被更近的Render遮挡的Renderer将会被删除。

遮挡剔除本身需要CPU算力。适合开启遮挡剔除的情景如下:

  • 只有当Overdraw到一定程度时,以至于GPU遇到瓶颈时,才需要开启遮挡剔除。
  • 用户设备的内存足够大,因为需要存放遮挡剔除数据
  • 彼此连接的狭长室内场景
  • 运行时不会生成场景几何体(即所有需要渲染的物体在场景加载时就已经确定),如地形破坏、大量即时生成的GO等。

通过在Occlusion Culling窗口更改参数,并在场景中使用遮挡区域,就可以开启遮挡剔除。具体步骤如下:

  1. 为场景中所有运行时始终不移动具有Renderer组件被遮挡物(即会被其他物体遮挡的物体)设置为Occludee Static
  2. 为场景中所有运行时始终不移动不透明具有Mesh Renderer或Terrain组件的遮挡物(会遮挡其他物体的物体)设置为Occluder Static
  3. 启用摄像机的Occlusion Culling属性
  4. 在Window-Rendering-Occlusion Culling窗口的Bake选项卡中进行烘焙。
  5. 若要在Scene中查看Occulusion Culling效果,则在Occlusion Culling窗口活跃时,选中一个相机GO,观察Scene视图。此时,无法被相机看到的物体应当消失。

动态被遮挡物无需烘焙,只需要在Renderer组件中开启Dynamic Occulusion组件。此时,当它被静态遮挡物遮挡时,将会被剔除。但存在类似于“鹰眼视觉”、“墙后显示”的需求时,应当关闭该属性。若确定该动态物体不可能被遮挡剔除,则关闭该属性。

使用Occlusion Area组件定义需要进行遮挡剔除的场景区域。Unity在烘焙遮挡数据时,将对组件包围盒内的区域进行精度更高的烘焙。如果场景内没有该组件,则会烘焙所有静态遮挡、被遮挡物体,导致漫长的烘焙时间和过大的遮挡数据。

使用Occlusion Portal组件定义遮挡入口。遮挡入口关闭时将作为静态遮挡物,否则就不会遮挡游戏对象。改变该组件的open属性以切换打开/关闭状态。

减少片段数量

减少片段数量的关键在于减少Overdraw。可通过Scene试图左上角的下拉菜单中选择Overdraw以查看Overdraw情况。

控制绘制顺序

Unity中,Render Queue小于2500的物体是从前往后绘制的,因为深度测试的存在,使得后面的物体被遮挡的片段会被直接剔除。大于2500的物体则是从后往前绘制,因为要考虑到半透明物体的渲染次序。

我们要尽可能根据实际情况排列物体顺序。例如,在FPS中,先玩家,再绘制掩体,再绘制敌人。

小心透明物体

半透明物体必定会造成Overdraw。大部分UI对象为半透明,所以将UI相机和主相机分离可以减少大量的Overdraw。

在移动设备上,我们要尽量避免使用Alpha Test,因为discard/clip操作会导致一些硬件优化策略(与TBDR有关)失效。此时,Blend的性能反而比Test更优。

减少实时光照和阴影

多用Light Map烘焙,少用RealTime光源;LUT也是一种避免光照计算的好方法。

节省带宽

前面提到,纹理大小和分辨率是影响带宽的重要因素。因此,我们可以:

  1. 对于纹理,其长宽比最好为正方形,长宽最好为2的整数幂,多使用MipMap(在导入设置的高级选项中)
  2. 对于分辨率,略。

减少计算复杂度

Shader LOD

ShaderLab中,LOD语义可以指定Shader的LOD值。当LOD值小于特定值时才使用该Shader,而使用了超过设定值的Shader的物体将不会被渲染。

优化Shader代码

  1. 注意float、half类型的使用。通常,float适用于顶点坐标等变量,应当尽量在VS中使用;half适用于标量、纹理坐标等变量;fixed适用于颜色和单位向量。对于half、fixed变量,应当尽量避免Swizzle操作。
  2. 定义appdata、v2f结构体时,应尽量减少插值寄存器(TEXCOORD语义标记)的数量。例如,对于两个float2,把他们放在一个float4里存储。
  3. 避免全屏后处理。如果不可避免使用全屏后处理,使用fixed或half进行计算。如果实在要涉及到高精度计算,则使用LUT或转移到VS中处理。
  4. 把多个后处理特效合并到一个Shader中以减少Pass数量。
  5. 避免使用控制语句。
  6. 避免使用复杂数学计算函数
  7. 避免使用discard操作

PBR

本节主要介绍Unity Standard Shader使用方法。

Standard Shader支持Metallic Workflow和Specular Workflow,前者较常用且为默认工作流。

使用Standard Shader时,需要在Edit-Project Settings-Player-Other Settings-Color Space中设置颜色空间为Linear。因为PBS需要在线性空间中计算。

下面介绍部分主要材质属性。

  • Albedo:物体整体颜色
  • Metallic:物体金属度。设置为纹理时,A通道为Smoothness
    • Smoothness:Metallic的附属属性,用于定义材质表面光滑程度。

在Specular工作流中,Specular将用于替换Metallic,用于定义镜面反射强度。

  • Normal Map:法线贴图
  • Height Map:高度图

工作流描述

设置光照环境

打开Window-Lighting窗口,将HDRI拖入Scene选项卡下的Skybox属性中。

设置环境光照来源(Ambient Source),可以是Skybox,也可以是渐变/固定颜色。

设置环境光强度(Ambient Intensity)。

环境光来自反射源的反射。默认反射源(Reflection Resource)为天空盒。若不想让物体接受环境光,则设置反射源为Custom并留空。

实时全局光照(Global Illumination,GI)使得场景物体不仅可以收到直接光影响,也可以收到间接光影响。

对于光源组件的Bake属性,我们可以根据设备性能选择不同的选项。Realtime模式将让场景内所有受到此光源影响的物体都会进行实时光照计算;Baked模式将会在Bake时生成Lightmap用于采样获取光照结果,但物体移动后再采样Lightmap会出现差错;Mix模式会将标记为Static的物体进行Bake,其他物体采用实时模式。

光源组件的Bounce Intensity属性反映了GI中受此光源影响的物体的间接光(由此光源发出的光反射得到)强度。调整Light窗口Scene选项卡下General GI参数块中的Bounce Boost和Indirect Intensity参数能全局控制间接光照强度。

反射探针

对于金属度高、平滑度高的物体,将能精确映射反射源的内容。然而,在变化较大的场景中,如果物体四周的环境总是在发生变化,就会发生穿帮。为此,我们可以使用反射探针(Reflection Probe)。它允许我们在场景的特定位置对整个场景的环境反射进行采样。

反射探针有Baked、Realtime和Custom类型。对于第一种,运行时探针中存储的Cubemap不会发生变化,适合“环境不变物体变”的情况。对于第二种,会实时更新Cubemap,但性能开销较大(可以通过脚本精确触发探针更新);对于第三种,可以使用自定义Cubemap完成环境反射,也可以烘焙。

反射探针应当放置在明显具有反射现象的物体旁边,或容易发生遮挡的物体周围。放置完毕后,还要设置探针管辖的区域,区域内的反射物体将采用探针的Cubemap作为反射源。存在多个管辖区域重叠的探针时,Unity会像处理Light Probe那样,进行探针的平滑混合。

反射探针不仅能提高反射真实度,也能模拟互相反射的效果(即两个反射物体互相靠近)。


Unity Shader学习笔记(五) - 噪声、优化与PBS工作流
http://example.com/2025/02/10/Unity-Shader学习笔记(五)-噪声、优化与PBS工作流/
作者
Yoi
发布于
2025年2月10日
许可协议