[!error] 渲染管线的差异
由于 URP 管线不适用多 pass,一些在 Built-in 中的双 pass 的实现都可以用单 pass 实现。具体原因,还不清楚。有待学习

1 Unity 渲染顺序

在 Unity 中,渲染顺序是根据以下参数依次按条件先后顺序进行排序渲染处理。先按上层条件排序,如上层条件相同,则进入下层条件牌序,最终分出先后

**Camera Depth > Render Queue 大于还是小于等于 2500 > Sorting Layer > Order in Layer > Render Queue > Camera order algorithm

  1. Camera Depth:值越小越优先渲染,优先渲染可能会被覆盖
  2. Render Queue <= 2500:视为不透明物体
    1. 按照 Sorting Layer / Order in Layer 设置的值,越小越优先。若无此属性,等同于 Sorting Layer = default,Order in Layer = 0 参与排序
    2. Render Queue 越小越优先
    3. Render Queue 相等,由近到远排序
  3. Render Queue >= 2500:视为透明物体
    1. 按照 Sorting Layer / Order in Layer 设置的值,越小越优先。无此属性,等同于 Sorting Layer = default,Order in Layer = 0 参与排序
    2. Render Queue 越小越优先
    3. Render Queue 相等,由远到近排序

注意:

  • Sprite 组件和 Canvas 组件默认使用的 Shader 没有深度写入,但是进行深度测试。默认 RenderQueue 均为 Transparent
  • SkyBox 渲染发生在渲染不透明物体(Render Queue <= 2500)之后,透明物体(Render Queue >= 2500)之前。

Camera Depth

c41cf813488837339d527b343fd9840f_MD5

Sorting Layer

Layer 层级
29f5c88b31d2d14474e6a32292a876a7_MD5

8e7a22d963d86de96cb7d3b3f06399b9_MD5

Order in Layer

同一 Layer 中的排序
ca37a2ad867ee77bcfa77d3da7caae06_MD5

Render Queue

渲染队列
9fa4b63e50b7a4612d5d0a08591529f7_MD5

PropertiesValue渲染队列描述说明
Background1000This Render Queue is rendered before any others这个队列通常被最先渲染(比如天空盒)。
Geometry2000Opaque geometry uses this queue这个默认的渲染队列。它被用于绝大数对象。不透明几何体使用该队列。
AlphaTest2450Alpha tested geometry uses this queue需要开启透明度测试的物体。Unity5 以后从 Geometry 队列中拆出来,因为在所有不透明物体渲染完之后再渲染会比较高效。
GeometryLast2500Last Render Queue that is considered "opaque"所有 Geometry 和 AlphaTest 队列的物体渲染完后。
Transparent3000This Render Queue is rendered after Geometry and AlphaTest, in back-to-front order所有 Geometry 和 AlphaTest 队列渲染完后,再按照从后往前的顺序进行渲染,任何使用了透明度混合的物体都应该使用该队列(例如玻璃和粒子效果)。
Overlay4000This Render Queue is meat for overlay effects该队列用于实现一些叠加效果,适合最后渲染的物体(如镜头光晕)。

Camera render algorithm

不透明物体排序算法

camera. opaqueSortMode

  • Default :在 Unity 2018.1 预设值 FrontToBack
  • FrontToBack :从近到远排序绘制,由于 z-buffering 机制,能使得 GPU rendering 时有更好的性能
  • NoDistanceSort:关闭排序绘制,能减低 CPU 的使用量

透明物体排序算法

camera. transparencySortMode

  • Default:根据 camera projection mode 调整
  • Perspective:根据 camera 位置到物体中心(object center)的距离排序
  • Orthographic:根据 view plane 到物体中心(object center)的距离排序
  • CustomAxis:指定 axis 排序,专门用于 2D 游戏制作

UGUI’s rendering order

CanvasRenderer 可视为画在画布 Canvas 的元件,之后该画布再画在最终的画面上(eg:render target)

Canvas

Screen Space - Overlay

55a299e7de41c82f4c3575de0fdde378_MD5

  • 该 Canvas 由隐藏的 camera 处理,其 depth = 101(最后才处理,用户自建的 Camera Depth 最多 100)
  • 多个相同的 canvas 使用 Sort order 来决定绘制顺序

Screen Space - Camera & World Space

bcef7b0b7bb75a0d0bd756cd891f833f_MD5

  • 存在世界场景的平面
  • 多个相同的 canvas 使用 Sorting Layer 以及 Order in Layer 来决定绘制顺序

CanvasRenderer

关于同一个 canvas 下,其 CanvasRenderer 之间的 rendering order:Render Queue > Transform order

13689268295ba6d6c9a4925ed171ae8a_MD5

  • Render Queue:材质球的 Render Queue
  • Transform order:依照 Transform Hierarchy 关系,采用 Pre-order 方式排序
当所属 Canvas 的 render mode 为 Screen Space - Overlay,则无视 Render Queue。

最佳实践

  • 3D

    • 不透明物体 & 半透明物体(例如草、铁丝网等)依照场景摆放
      • 不需要特别设定 rendering order
      • 一切交给 z-buffering 机制
    • 透明物体或者粒子特效可透过 Sorting Layer & Order in Layer 机制调整 rendering order
      • 透明物体 shader 通常不会写 z-buffer(ZWrite Off)
      • 可 hack inspector 来设定 renderer. sortingLayerID 以及 renderer. sortingOrder
  • 2D

    • Sprite renderer 使用 Sorting Layer & Order in Layer 机制来调整 rendering order,以控制 depth
  • UGUI

    • 利用 transform hierarchy 来建立 rendering order,根据性能优化可以拆成多个 canvas
    • 若采用 Canvas render mode:World Space,想让 UI 与 3D 场景物体的结合,可将 canvas 视为 3D 物体去设计场景架构

2 Unity 渲染半透明物体的解决方案

[!NOTE] Unity 中的渲染顺序
按 shader 指定的渲染队列顺序
Background—>Geometry (默认队列)—>AlphaTest—>Transparent—>Overlay
这里总结不全,看一下上面的文章

一般采用的方案
(1) 先渲染所有不透明物体,并开启它们的深度测试和深度写入
(2) 把半透明物体按它们距离摄像机的远近进行排序,然后按照从后往前的顺序渲染这些半透明物体,并开启它们的深度测试,但关闭深度写入

即便按照上述这种正确的渲染顺序,仍会存在问题: [[08 透明#^t3bkkp]],半透明排序的问题只能尽量减少,无法避免。

Unity 为了解决渲染顺序的问题提供了 渲染队列 (render queue) 这一解决方案。我们可以使用 SubShader Queue 标签来决定我们的模型将归于哪个渲染队列。 Unity 在内部使用一系列整数索引来表示每个渲染队列,且索引号越小表示越早被渲染Pasted image 20221003232825

2 透明度测试和透明度混合

Unity 中,我们通常使用两种方法来实现透明效果:

  1. **透明度测试(Alpha Test)**:无法得到半透明,要么完全透明,要么完全不透明
    • 只要一个片元的透明度不满足条件(通常是小于某个阙值),那么它对应的片元就会被舍弃。 被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生任何影响;
    • 否则,就会按照普通的不透明物体的处理方式来处理它,即进行深度测试、深度写入等。
    • 也就是说,透明度测试是不需要关闭深度写入的,它和其他不透明物体最大的不同就是它会根据透明度来舍弃些片元。虽然简单,但是它产生的效果也很极端,要么完全透明,即看不到,要么完全不透明,就像不透明物体那样。
  2. **透明度混合 (Alpha Blending)**: 这种方法可以得到真正的半透明效果
    • 它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。
    • 透明度混合需要关闭深度写入,破坏了深度缓冲机制,这使得我们要非常小心物体的渲染顺序。需要注意的是,透明度混合只关闭了深度写入,但没有关闭深度测试
    • 当使用透明度混合渲染一个片元时,会进行深度测试,测试通过才会进行混合操作。这一点决定了当一个不透明物体出现在一个透明物体前,我们先渲染不透明物体并进行了深度写入,后面的透明物体无法通过深度测试所以被遮挡。
    • 深度缓冲中的值其实是像素级别的,即每个像素有一个深度值。但是由于我们关闭了深度写入,无法对模型进行像素级别的排序。当模型网格之间有互相交叉的结构时,往往会得到错误的半透明效果(解决办法 [[#2. 开启深度写入的半透明效果]])
  • ? 为什么要关闭深度写入?
    正常来说,我们可以透过半透明物体的前表面看到后表面,假设不关闭深度写入,按照半透明从后往前的渲染顺序。先渲染后表面写入深度缓冲和颜色缓冲,然后渲染前表面。由于前表面更靠近摄像机,深度测试通过,并写入深度和颜色缓冲,覆盖掉了后表面,我们就无法看到后表面了。所以为了半透明的正常显示,不得不关闭深度写入。

  • ? 为什么关闭了深度写入,渲染顺序很重要?
    Pasted image 20230620142344
    第一种情况,我们先渲染 B, 再渲染 A(先渲染不透明物体,再渲染半透明物体,结果正确)。那么由于不透明物体开启了深度测试和深度写入,而此时深度缓冲中没有任何有效数据,因此 B 首先会写入颜色缓冲和深度缓冲。随后我们渲染 A,透明物体仍然会进行深度测试,因此我们发现和 B 相比 A 距离摄像机更近,因此,我们会使用 A 的透明度来和颜色缓冲中的 B 的颜色进行混合,得到正确的半透明效果。
    第二种情况,我们先渲染 A,再渲染 B(先渲染半透明物体,再渲染不透明物体,结果错误)。渲染 A 时,深度缓冲区中没有任何有效数据,因此 A 直接写入颜色缓冲,但由于对半透明物体关闭了深度写入,因此 A 不会修改深度缓冲。等到渲染 B 时,B 会进行深度测试,它发现,“咦,深度缓存中还没有人来过,那我就放心地写入颜色缓冲了!”,结果就是 B 会直接覆盖 A 的颜色。从视觉上来看,B 就出现在了 A 的前面,而这是错误的。 ^t3bkkp

Pasted image 20230620142352
第一种情况,我们先渲染 B,再渲染 A (从后往前渲染半透明物体,结果正确)。那么 B 会正常写入颜色缓冲,然后 A 会和颜色缓冲中的 B 颜色进行混合,得到正确的半透明效果。
第二种情况,我们先渲染 A,再渲染 B (从前往后渲染半透明物体,结果错误)。那么 A 会先写入颜色缓冲,随后 B 会和颜色缓冲中的 A 进行混合,这样混合结果会完全反过来,看起来就好像 B 在 A 的前面,得到的就是错误的半透明结构。

按照正确的渲染顺序仍存在问题,在一些情况下,半透明物体还是会出现“穿帮镜头”。

深度缓冲中的值其实是像素级别的,即每个像素有一个深度值。但是由于我们关闭了深度写入,无法对模型进行像素级别的排序
我们只是按照距离摄像机的远近进行排序,这是对单个物体级别进行排序,这意味着排序结果是,要么物体 A 全部在 B 前面渲染,要么 A 全部在 B 后面渲染。如果存在循环重叠的情况,那么使用这种方法就永远无法得到正确的结果。

Pasted image 20230620143149
在图 8.3 中,由于 3 个物体互相重叠,我们不可能得到一个正确的排序顺序。这种时候,我们可以选择把物体拆分成两个部分,然后再进行正确的排序。但即便我们通过分割的方法解决了循环覆盖的问题,还是会有其他的情况来”捣乱”。考虑图 8.4 给出的情况。

这里的问题是: 如何排序? 我们知道,一个物体的网格结构往往占据了空间中的某一块区域, 也就是说,这个网格上每一个点的深度值可能都是不一样的,我们选择哪个深度值来作为整个物体的深度值和其他物体进行排序呢? 是网格中点吗? 还是最远的点? 还是最近的点? 不幸的是, 对于图 8.4 中的情况,选择哪个深度值都会得到错误的结果, 我们的排序结果总是 A 在 B 的前面,但实际上 A 有一部分被 B 遮挡了。这也意味着,一旦选定了一种判断方式后,在某些情况下半透明物体之间一定会出现错误的遮挡问题。

减少错误排序的方法

  1. 分割网格,尽可能让模型是凸面体, 并且考虑将复杂的模型拆分成可以独立排序的多个子模型
  2. 如果不想分割网格,可以试着让透明通道更加柔和,使穿插看起来并不是那么明显
  3. 使用开启了深度写入的半透明效果来近似模拟物体的半透明 (详见 8.5 节)
  4. Unity 为了解决渲染顺序的问题,提供了渲染队列(render queue)解决方案。

透明度测试 Alpha Test(Alpha Cutout)

透明度测试:只要一个片元的透明度不满足条件 (通常是小于某个阙值),那么它对应的片元就会被舍弃。被舍弃的片元将不会再进行任何处理,也不会对颜色缓冲产生任何影响; 否则,就会按照普通的不透明物体的处理方式来处理它。

[!NOTE] Clip 函数
void clip(float4 x);void clip(float3 x);void clip(float2 x);void clip(float1 x);void clip(float x);
给定参数任何一个分量是负数,就舍弃当前像素的输出颜色

void clip (float4)
{
if ((any<0))
discard;
}

透明度测试的显示效果比较极端:要么完全透明(看不到),要么完全不透明。而且,透明效果的边缘有锯齿,这是因为在边界处纹理的透明度变化精度问题,为了得到更加柔滑的透明效果,就可以使用透明度混合。

image-20220704153317101 image-20220704153515750

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
Shader "Unlit/MyAlphaTest"
{
Properties
{
_Color ("Main Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_Cutoff ("Alpha Cutoff", Range(0, 1))= 0.5
//为了控制透明度测试时使用的阈值。_Cutoff参数用于决定我们调用clip进行透明度测试时使用的判断条件,范围是[0,1],这是因为纹理像素的透明度就在此范围内

}
SubShader
{
Tags
{
"Queue"="AlphaTest" //透明度测试使用AlphaTest渲染队列
"IgnoreProjector"="True" //该Shader不会受到投影器的影响
"RenderType"="TransparentCutout" //把该Shader归入到提前定义的组中(TransparentCutout组),以指明该Shader时一个使用了透明度测试的Shader,该标签通常被用于着色器替换功能
}

Pass
{
Tags{"LightMode"="ForwardBase"}

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _Cutoff;

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

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

v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

return o;
}

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

fixed4 texColor = tex2D(_MainTex, i.uv);

//透明度测试
clip(texColor.a - _Cutoff);

fixed3 albedo = texColor.rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(worldNormal,worldLightDir));

return fixed4(ambient + diffuse, 1.0);
}

ENDCG
}
}
Fallback "Transparent/Cutout/VertexLit"
}

透明度混合 Alpha Blend

1. 一般方法

透明度混合:这种方法可以得到真正的半透明效果。它会使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。但是,透明度混合需要关闭深度写入,这使得我们要非常小心物体的渲染顺序。

image-20220704153735600

本节使用的 Blend 命令为: Blend SrcAlpha OneMinusSrcAlpha
$DstColor_{new}=SrcAlpha×SrcColor+(1-SrcAlpha)× DstColorold$
比如源颜色的不透明度 $\alpha$ 值为 0.25,那么可以理解为用最终颜色由 25% 的源颜色,和 75%的目标颜色组成。

透明度混合可以得到更加柔和的透明效果,但是这种方法仍有弊端:关闭深度写入后,造成错误排序,无法对模型进行像素级别的深度排序。

image-20220704154122183

实现效果:

image-20220704153821708

image-20220704154027806

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
Shader "Unlit/MyAlphaBlendMat"
{
Properties
{
_Color ("Main Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_AlphaScale ("Alpha Scale", Range(0, 1))= 0.5
}
SubShader
{
Tags
{
"Queue"="Transparent" //透明度混合使用Transparent渲染队列
"IgnoreProjector"="True" //该Shader不会受到投影器的影响
"RenderType"="Transparent" //把该Shader归入到提前定义的组中(Transparent组),以指明该Shader时一个使用了透明度混合的Shader,该标签通常被用于着色器替换功能
}

Pass
{
Tags{"LightMode"="ForwardBase"}
ZWrite Off //关闭深度写入
Blend SrcAlpha OneMinusSrcAlpha //Blend设置混合模式

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale; //用于在透明纹理的基础上控制整体的透明度

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

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

v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

return o;
}

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


fixed4 texColor = tex2D(_MainTex, i.uv);

fixed3 albedo = texColor.rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(worldNormal,worldLightDir));

return fixed4(ambient + diffuse, texColor.a * _AlphaScale); //设置了该片元着色器返回值中的透明通道,它是纹理像素的透明通道和材质参数_AlphaScale的乘积
}

ENDCG
}
}
Fallback "Transparent/VertexLit"
}

2. 开启深度写入的半透明效果

Built-in 实现

由于透明度混合关闭深度写入,我们就无法对模型进行像素级别的深度排序。当模型本身有复杂的遮挡关系或是包含了复杂的非凸网格的时候, 就会有各种各样因为排序错误而产生的错误的透明效果。

我们可以想办法重新利用深度写入, 一种解决办法是是使用两个 Pass 来渲染模型:

  1. 第一个 Pass: 开启深度写入,但不输出颜色,它的目的仅仅是为了把该模型的深度值写入深度缓冲中;
  2. 第二个 Pass :进行正常的透明度混合,由于上一个 Pass 已经得到了逐像素的正确的深度信息,该 Pass 就可以按照像素级别的深度排序结果进行透明渲染。

使用这种方法我们仍可以实现模型与他后面的背景混合的效果,同时模型内部之间不会有任何的半透明效果。但这种方法的缺点在于,多使用一个 Pass 会对性能造成一定的影响。

这个新添加的 Pass 的目的仅仅是为了把模型的深度信息写入深度缓冲中,从而剔除模型中被自身遮挡的片元。 因此,Pass 的第一行开启了深度写入。
在第二行,我们使用了一个新的渲染命令—— ColorMask
在 ShaderLab 中,ColorMask 用于设置颜色通道的写掩码 (write mask)。它的语义如下:
ColorMask RGB/A/0/其他任何 R、G、B、A 的组合

当 ColorMask 设为 0 时,即使用 ColorMask 0 指令,意味着该 Pass 不写入任何颜色通道,即不会输出任何颜色。 这正是我们需要的,该 Pass 只需写入深度缓存即可。

image-20220704154337978

h:20,28
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
Shader "Unlit/MyAlphaBlendZwriteMat"
{
Properties
{
_Color ("Main Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_AlphaScale ("Alpha Scale", Range (0, 1))= 0.5
}
SubShader
{
Tags
{
"Queue"="Transparent" //透明度混合使用Transparent渲染队列
"IgnoreProjector"="True" //该Shader不会受到投影器的影响
"RenderType"="Transparent" //把该Shader归入到提前定义的组中(Transparent组),以指明该Shader时一个使用了透明度混合的Shader,该标签通常被用于着色器替换功能
}

//使用两个Pass来渲染模型

//第一个Pass开启深度写入,但不输出颜色,目的仅仅是为了把该模型的深度值写入深度缓冲中
pass
{
Zwrite On
ColorMask 0
//ColorMask用于设置颜色通道的写掩码(write mask),设为0,意味着该Pass不写入任何颜色通道
}

//第二个Pass进行正常的透明度混合,由于上一个Pass已经得到了逐像素的正确深度信息,该Pass就可以按照像素级别的深度排序结果进行透明渲染
Pass
{
Tags{"LightMode"="ForwardBase"}
ZWrite Off //关闭深度写入
Blend SrcAlpha OneMinusSrcAlpha //Blend设置混合模式

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed _AlphaScale; //用于在透明纹理的基础上控制整体的透明度

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

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

v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

return o;
}

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


fixed4 texColor = tex2D (_MainTex, i.uv);

fixed3 albedo = texColor.rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * saturate(dot(worldNormal,worldLightDir));

return fixed4(ambient + diffuse, texColor.a * _AlphaScale); //设置了该片元着色器返回值中的透明通道,它是纹理像素的透明通道和材质参数_AlphaScale的乘积
}

ENDCG
}
}
Fallback "Transparent/VertexLit"
}
URP 实现

与 Built-in 实现下不同的是,在 URP 下无需额外增加一个 Pass 去单独渲染深度缓冲区,可以直接开启深度写入

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
Shader "URP/AlphaBlendingWithZWrite"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" { }
_AlphaScale ("Alpha Scale", Range(0, 1)) = 1
}
SubShader
{
// 指定渲染通道使用URP渲染管线 渲染队列 = 透明度混合 忽略投影 = Ture 渲染类型 = 透明物体
Tags { "RenderPipeline" = "UniversalRenderPipeline" "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }

/*
// 添加额外的Pass,仅用于渲染到深度缓冲区
Pass
{
// 开启深度写入
ZWrite On
// 用于控制Pass不写入任何颜色通道
ColorMask 0
}
*/

Pass
{
Tags { "LightMode" = "UniversalForward" }
// 关闭渲染剔除
Cull Off
ZWrite On
Blend SrcAlpha OneMinusSrcAlpha

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

// 引用URP函数库
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

// 声明纹理
TEXTURE2D(_MainTex);
// 声明采样器
SAMPLER(sampler_MainTex);

CBUFFER_START(UnityPerMaterial)
half4 _Color;
float4 _MainTex_ST;
half _AlphaScale;
CBUFFER_END

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

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

v2f vert(a2v v)
{
v2f o;
// 初始化变量
ZERO_INITIALIZE(v2f, o);

o.pos = TransformObjectToHClip(v.vertex.xyz);

o.worldNormal = TransformObjectToWorldNormal(v.normal);

o.worldPos = TransformObjectToWorld(v.vertex.xyz);

o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

return o;
}

half4 frag(v2f i): SV_Target
{
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(TransformObjectToWorldDir(_MainLightPosition.xyz));

half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

half3 albedo = texColor.rgb * _Color.rgb;

half3 ambient = _GlossyEnvironmentColor.xyz * albedo;

half3 diffuse = _MainLightColor.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

return half4(ambient + diffuse, texColor.a * _AlphaScale);
}

ENDHLSL

}
}
FallBack "Packages/com.unity.render-pipelines.universal/SimpleLit"
}

双面渲染

image-20220704155211450

1. 透明度测试的双面渲染

代码和透明度测试一样,只需要添加 Cull Off 指令
image-20220704155008714

2. 透明度混合的双面渲染

58898c932eae8d9bc2a4e81f2ee3a426_MD5|350

Built-in 实现

再透明度混合代码的基础上直接添加 Cull Off 指令,无法保证同一个物体的正面和背面图元的渲染顺序。

为此,我们选择把双面渲染的工作分成两个 Pass:

  • 第一个 Pass 只渲染背面
  • 第二个 Pass 只渲染正面

由于 Unity 会顺序执行 SubShader 中的各个 Pass,因此我们可以保证背面总是在正面被渲染之前渲染,从而可以保证正确的深度渲染关系。

image-20220704155047959

URP 实现

与 Built-in 实现不同,在 URP 下无需用双 Pass 进行正反面渲染,直接关闭渲染剔除即可实现对透明物体的双面渲染

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
Shader "URP/AlphaBlendWithBothSide"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" { }
_AlphaScale ("Alpha Scale", Range(0, 1)) = 1
}
SubShader
{
Tags { "RenderPipeline" = "UniversalRenderPipeline" "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }

Pass
{
Tags { "LightMode" = "UniversalForward" }
// 关闭渲染剔除
Cull Off
// 关闭深度写入
ZWrite Off
// 混合因子设置
Blend SrcAlpha OneMinusSrcAlpha

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag

// 引用URP函数库
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

// 声明纹理
TEXTURE2D(_MainTex);
// 声明采样器
SAMPLER(sampler_MainTex);

CBUFFER_START(UnityPerMaterial)
half4 _Color;
float4 _MainTex_ST;
half _AlphaScale;
CBUFFER_END

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

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

v2f vert(a2v v)
{
v2f o;
// 初始化变量
ZERO_INITIALIZE(v2f, o);

o.pos = TransformObjectToHClip(v.vertex.xyz);

o.worldNormal = TransformObjectToWorldNormal(v.normal);

o.worldPos = TransformObjectToWorld(v.vertex.xyz);

o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);

return o;
}

half4 frag(v2f i): SV_Target
{
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(TransformObjectToWorldDir(_MainLightPosition.xyz));

half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);

half3 albedo = texColor.rgb * _Color.rgb;

half3 ambient =_GlossyEnvironmentColor.xyz * albedo;

half3 diffuse = _MainLightColor.rgb * albedo * max(0, dot(worldNormal, worldLightDir));

return half4(ambient + diffuse, texColor.a * _AlphaScale);
}
ENDHLSL
}
}
FallBack "Packages/com.unity.render-pipelines.universal/SimpleLit"
}

Additive

3 Alpha 预乘

AP01_L15_5

![[03 定向光#3.4.1 Premultiplied(预乘) Alpha]]

Tip1: 理解 Alpha 混合

最常见的混合是 “over” 混合,假设已经有一张 RenderTexture,RT 上像素的 RGB 我们称其为 $RGB_{dst}$ ,alpha 为 $A_{dst}$ 。现在有一个像素 $(RGB_{src}, A_{src})$ 要和 RT 上的像素混合,那正确的混合会这样进行:

$RGB’{result} = A{src} *RGB_{src}+ (A_{dst}*RGB_{dst} ) *(1-A_{src})$

$A_{result} = A_{src} + A_{dst} * (1-A_{src})$

最终混合出来的颜色由两部组成:

  • $A_{src} *RGB_{src}$ 代表 $RGB{src}$ 对最终颜色的贡献,它受 alpha 影响,如果 alpha 为 0 则对最终像素没有影响,如果 alpha 为 1 则贡献 100% 的 $RGB{src}$
  • $A_{dst} * RGB_{dst}$ 是 RT 中像素原本没有其它像素覆盖时的贡献值,但是现在被一个新来的像素遮挡了,被遮挡了 $(1-A_{src})$ ,因此 RT 中像素最终贡献了 $(A_{dst}*RGB_{dst}) * (1-A_{src})$

由此可见,无论对于 src 还是 dst, $A * RGB$ 才是实际的有效颜色,称其为 premultiplied alpha。

我们令 $RGB’ = A * RGB$ ,则颜色混合可以改写为

$RGB’{result} = RGB’{src}+ RGB’{dst} * (1-A{src})$

这么看就更清楚了:

最终的输出 = 新叠加像素的有效颜色 + RT 中原有像素的有效颜色 * 新像素的遮挡

Tip2: SrcAlpha, OneMinusSrcAlpha 颜色混合正确是有前提的

例如 Unity 中的透明混合,默认采用 SrcAlpha, OneMinusSrcAlpha 这种方式,如果用符号表示出来是这样的:

$RGB_{result} = A_{src} * RGB_{src} + RGB_{dst} * (1-A_{src})$

对比之前给出的计算

$RGB’{result} = A{src} *RGB_{src}+ (A_{dst}*RGB_{dst} ) *(1-A_{src})$

加号右侧少乘了 $A_{dst}$ ,这是为什么呢?

SrcAlpha, OneMinusSrcAlpha 正确的前提是混合目标是不透明的,即 $A_{dst}$ 为 1。

平时渲染时,常见的情况是先渲染不透明物体,再渲染不透明的天空盒,最后再渲染半透明物体做 alpha 混合,在这种情况下渲染目标是不透明的,不透明物体的有效颜色即其颜色本身

在满足这个前提下

$RGB_{result} = A_{src} * RGB_{src} + RGB_{dst} * (1-A_{src})$

才会成立,其本质为

$RGB’_{result} = A_{src} * RGB_{src} + (1.0 * RGB_{dst}) * (1-A_{src})$

依然是符合 tip1 中给出的结论

$RGB’{result} = RGB’{src}+ RGB’{dst} * (1-A{src})$

只不过此时的 $RGB’{dst}$ 等于 $RGB{dst}$ 。

可能有人会产生疑问,不透明背景上混合半透明后,怎么看待混合后的透明度?

我们回过头去看 tip1 中 alpha 的计算

$A_{result} = A_{src} + A_{dst} * (1-A_{src})$ ,

$= 1.0 * A_{src} + A_{dst} * (1-A_{src})$

$= lerp(A_{dst}, 1.0, A_{src})$

发现没有,其本质是以 $A_{src}$ 作为参数的 $A_{dst}$ 到 1.0 线性插值。当 $A_{dst}$ 为 1.0 时,无论 $A_{src}$ 是何值,最终输出都是 1.0。回到现实中,这很好理解,砖墙前放一块玻璃,当我们将玻璃和墙看作一个整体时,它们是不透明的。

Tip3: SrcAlpha, OneMinusSrcAlpha 混合出来的 Alpha 值是无意义的

这种常见混合方式根据 tip2,其已默认渲染目标的 alpha 为 1,因此它不关心 alpha 结果的正确性。

根据其表达式

$RGB_{result} = A_{src} * RGB_{src} + RGB_{dst} * (1-A_{src})$

我们可以清晰的看到,这里没有出现 $A_{dst}$ ,得出正确的 RGB 与 RT 中的 alpha 存什么没有任何关联。

通过 SrcAlpha, OneMinusSrcAlpha 方式我们会计算得到

$A_{result} = A_{src} * A_{src} + A_{dst} * (1-A_{src})$

这个结果没有意义。有些情况下,可以利用这种性质,将 RT 中没有被用到的 alpha 通道利用起来,例如存储 bloom 系数。

正因如此,想要单独渲染含半透的角色、特效到 RT,再混合到界面必须修改 Alpha 的计算方式,请看 tip4。

Tip4: 如何正渲染半透 RenderTexture

可以使用 《GPU Gems 3》 Chapter 23. High-Speed, Off-Screen Particles 中提到的反转 alpha 的方法,和本文的主题无关不展开讲了。另外可以从前往后排序用 “under” 混合也是可以的。

最直接了当的方法就是采用 premultiplied alpha 的混合方式。

Tip5: 理解预乘 Alpha 混合公式的颜色部分

Premultiplied alpha 混合采用 One, OneMinusSrcAlpha,其实我们在 tip1 中就已经看到了,

$RGB’{result} = RGB’{src}+ RGB’{dst} * (1-A{src})$

$RGB’{result} = RGB’{src} * 1.0 + RGB’{dst} * (1-A{src})$

One 就是这里的 1.0 而 OneMinusSrcAlpha 就是 $(1-A_{src})$ 。

$RGB’{dst}$ 来自于混合的结果,真正留给我们的问题是 $RGB’{src}$ 如何获得。

最简单方式就是纹理中的 RGB 预乘好 alpha,那我们采样得到的颜色直接就是有效 RGB。

另外需要注意的是这里输出的 RGB 是预乘 alpha 的 RGB,详见 tip8。

Tip6: 纹理预乘 Alpha 实践上可能有潜在问题

在实践中,我们的纹理的数据源大多是 RGBA32,即单通道 8 比特,只能表示 0-255 的整数,同时游戏资产还会根据目标平台做纹理压缩。

由于精度问题,原本相近的颜色在预乘后会存储为更相近,甚至相同的颜色,经压缩后很容易产生大量 artifacts。要使用预乘 alpha 的纹理,一般会建议采用单通道 16 位的存储。

由于这种情况,即使预乘有很好的纹理过滤特性,也没有被广泛采用,我所了解 WebGL 由于网页对于 alpha composition 的天然需求,做了这方面的支持。

Tip7: 即使不纹理预乘,采用预乘 Alpha 的混合公式也有好处

采用 One, OneMinusSrcAlpha 混合有个很好的特性,可以统一 Blend 和 Additive,减少 BlendState 切换,还能增加效果。

推荐阅读 https://amindforeverprogramming.blogspot.com/2013/07/why-alpha-premultiplied-colour-blending.html ,这里简单介绍下思路:

  • 把非预乘纹理的采样到的 RGBA,我们在 shader 中输出 (RGB*A, A) 就是 Blend 模式。
  • 把非预乘纹理的采样到的 RGBA,我们在 shader 中输出 (RGB*A, 0) 就是 Additive 模式。

输出的 Alpha 可以定义一个 uniform t 控制,输出 (RGBA, At),这样通过 t 就是控制 Blend 和 Additive 模式之间的过渡。

如果再定义一个 uniform s,输出 (RGBAs, Ats),我们还可以通过 s 控制其整体透明度,用于淡入淡出!简直就是特效的救星。

众所周知,采用 Additive 模式的特效,在亮的场景中几乎看不到效果,而 Blend 模式的特效在暗的场景中提不亮。采用 One OneMinusSrcAlpha 就可以使用中间态来做出适配比较好的特效,而且不需要 framebuffer fetch。

Tip8: Premultiplied Alpha 运算是封闭的

换人话来讲,就是预乘 alpha 混合得到的颜色也是预乘 alpha 的

细心的同学会注意到,在 tip1 中

$RGB’{result} = RGB’{src}+ RGB’{dst} * (1-A{src})$

作为运算结果的 $RGB’_{result}$ 是有 prime 符号的,正是想提示这一点。

最终输出的有效颜色来自两部分,

  • 叠加上去的 src 像素贡献的有效颜色
  • 背景 dst 像素贡献的有效颜色,它被 src 遮挡掉一部分,遮挡的量是 $(1-A_{arc})$

我们观察 tip1 中给出的两式

$$RGB’{result} = A{src} *RGB_{src}+ (A_{dst}*RGB_{dst} ) *(1-A_{src}) \tag{1}$$

$$RGB’{result} = RGB’{src}+ RGB’{dst} * (1-A{src}) \tag{2}$$

(1)(2) 的计算过程是一样的,这就不禁会产生疑问:(1) 式混合两个未预乘 alpha 的 RGB,结果是预乘 alpha 的 RGB?

这没错,未预乘 alpha 的颜色经混合得到的是预乘 alpha 的颜色。

那平时用 SrcAlpha, OneMinusSrcAlpha 为什么能得到未预乘的结果呢?

原因正是 tip2。

由于 SrcAlpha, OneMinusSrcAlpha 混合隐含了一个假设,渲染目标是不透明的。在这个前提下,用正确的混合公式计算,我们可以得到:

  • 预乘了 alpha 的 $RGB’_{result}$
  • $A_{result} =1.0$

我们在 tip2 中已经讲过,与不透明目标混合得到的 alpha 恒为 1。

显而易见,当 alpha 为 1 时, $RGB’_{result}$ 等于 $RGB_{result}$ 。

因此 (1) 式在当渲染目标是不透明时,改成下式是成立的

$RGB_{result} = A_{src} *RGB_{src}+ (A_{dst}*RGB_{dst} ) *(1-A_{src})$

小结一下

  • 预乘 alpha 的颜色经透明混合得到的颜色也是预乘 alpha 的
  • 未预乘 alpha 的颜色经透明混合得到的是预乘 alpha 的颜色
  • SrcAlpha, OneMinusSrcAlpha 假设了渲染目标不透明,因此结果你可以说是预乘的,也可以说是没预乘的,因为两个值此时是一样的
  • 如果渲染目标是半透明的,未预乘混合后需要 ”unmultiply” 才能恢复未预乘的颜色,详见 tip10

Tip9: 理解预乘 Alpha 混合公式的 Alpha 部分

预乘 Alpha 混合时,颜色分量和 Alpha 分量的运算是一致的。对比一下

$RGB’{result} = RGB’{src}+ RGB’{dst} * (1-A{src})$

$A_{result} = A_{src} + A_{dst} * (1-A_{src})$

都是 $Result = Src + Dst * (1-A_{src})$ 。

因此,不需要额外指定 alpha 分量的混合公式,就能得到有意义的 alpha 值,而且无论渲染目标是透明还是不透明,结果都是正确的。(Tip4)

Tip2 和 tip3 中讲到过 SrcAlpha, OneMinusSrcAlpha 混合正确是有条件的,必须满足渲染目标是不透明的,而且得到的 alpha 也是无意义的。相比之下用 premultiplied alpha 做混合优势就太明显了。

如果提前知道会往不透明渲染目标混合,那预乘 alpha 混合也可以借鉴 tip3 中的做法把 RT 的 alpha 通道利用起来。

Tip10: 仅当必要时 Unmultiply

凡是讲 premultiplied alpha 都会告诉你,可以通过

$RGB = \frac{RGB’}{A}$ 还原未预乘的颜色值,常见的、未预乘的颜色值也叫 straight alpha 或 unassociated alpha,而预乘好的叫 premultiplied alpha 或 associated alpha。这种还原操作在渲染自己可控的环境下几乎用不到。

根据 tip8,预乘 alpha 混合时运算封闭,可以多次混合不需要还原 straight alpha。

但如果用未预乘 alpha 混合时,如果渲染目标是半透明的,每次混合完成都要 unmultiply 回 straight alpha 才能继续混合,而且当一个网格有多层透明叠加时结果是错误的。

从实践上讲,预乘 alpha 混合的结果需要 unmultiply 主要就这种情况:三方组件只接受 straight alpha 表示的纹理。

Framebuffer 显示到屏幕上输出时 RT 最终总是不透明的,不透明的 alpha 为 1,预乘和未预乘没有区别,也不用特殊处理。

Tip11:Bleed Alpha 与预乘 Alpha 原理不同,目的相同,结果略有不同

Alpha bleeding 的原理以前已经写过了

undefined

预乘 alpha 和 bleed alpha 目的都是减少半透明纹理过滤产生的瑕疵,但它们有一些比较显著的区别:

  • Bleed alpha 不需要修改混合公式
  • Bleed alpha 只能优化完全透明和非完全透明像素边缘的过滤瑕疵
  • 预乘 alpha 不仅可以达到 bleed alpha 的结果,半透像素之间的过滤效果也能得到优化
  • 预乘 alpha 需要修改混合公式,可能产生 tip6 中提到的情况

当不使用 premultiplied alpha 时,预处理贴图 bleed alpha 是一个 “免费” 替代品。虽然效果上会有折扣,但性价比极高。

Tip12: 纹理预乘 Alpha 可以减少纹理过滤带来的 artifacts

介绍 premultiplied alpha 的文章大多都会从纹理过滤的角度讲,个人认为没有充分理解 alpha 混合的情况下,如果还结合着 SrcAlpha, OneMinusSrcAlpha 混合的简单认知去看 premultiplied alpha 很容易一头雾水。

本文已经够长了,应该没也几个人会耐心看完,

本文不再探讨纹理过滤的内容了,这里推荐两篇不错的文章

Alpha Blending: To Pre or Not To PreBeware of Transparent Pixels - Adrian Courrèges

Nvidia 的这篇从 mipmap 的角度而 Adrian 的文章从双线性过滤的角度介绍了 premultiplied alpha 是如何减少半透纹理过滤带来的 artifacts。

小结一下,纹理预乘 alpha 可以减少 downsampling、upsampling、非 pixel perfect 各种情况下半透纹理过滤产生的 artifacts。

喜欢我的文章请点赞、收藏加关注,转发给有需要的朋友。