曲面细分表面置换(翻译二十三)
- 在 GPU 上调整顶点位置。
- 细分阴影几何体。
- 跳过细分不可见的三角形。
1 重定位顶点
网格通常由三角形组成,而三角形总是平坦的。曲率的错觉是通过顶点法线添加的。法线贴图可用于添加更多表面不规则的错觉,这些不规则比单个网格三角形还要小。除此之外,视差贴图(Parallax Mapping)使得伪造表面置换成为可能。但所有这些方法都是错觉。使表面更复杂的所谓“最稳健”的方法就是简单地使用更多更小的三角形。更小的三角形意味着我们有更多的顶点,足以描述我们想要的所有表面细节。不幸的是,这将导致更大的网格,需要更多的存储空间、CPU 和 GPU 内存以及内存带宽。曲面细分是解决这个问题的一种方法,因为它允许我们在需要时在 GPU 上生成更多三角形。这意味着 GPU 必须做更多的工作,但我们可以将其限制在真正需要的时候。
仅仅切割现有的三角形并插值顶点数据不足以添加更多细节。这只是给了我们更多描述相同平坦表面的三角形。我们必须引入新数据,以某种方式调整三角形的顶点。
添加更多细节的一个直接方法是通过置换贴图(Displacement Map)调整网格的顶点。该贴图用于向上或向下移动顶点,就像高度场(Height Field)可用于将平坦的地形网格变成实际的景观一样。本教程将介绍如何做到这一点。
1.1 劫持视差贴图
要置换顶点,我们需要一个置换贴图。虽然我们的 Tessellation Shader 没有这样一个贴图的属性,但它有一个我们在视差贴图教程中使用的视差贴图。视差贴图实际上就是一个置换贴图,只是我们用它来伪造置换。我们也可以将同一个贴图用于实际的置换。
假设一个 Shader 可以决定使用真正的顶点置换而不是视差贴图,只需定义 VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX 即可。如果定义了该宏并且我们有视差贴图,那么我们必须确保不包含视差代码,而是将其替换为适当的顶点置换代码。为此,当我们有视差贴图且应该使用顶点置换时,在 My Lighting Input 中取消定义 _PARALLAX_MAP 并定义一个方便的 VERTEX_DISPLACEMENT 宏。
1
2
3
4
5
6
7
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"
#if defined(_PARALLAX_MAP) && defined(VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX)
#undef _PARALLAX_MAP
#define VERTEX_DISPLACEMENT 1
#endif
让我们也为 _ParallaxMap 和 _ParallaxStrength 变量创建宏别名,这样我们可以使用 _DisplacementMap 和 _DisplacementStrength 代替。这使得以后如果你想摆脱视差代码并切换到适当的置换属性时更容易。
1
2
3
4
5
6
#if defined(_PARALLAX_MAP) && defined(VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX)
#undef _PARALLAX_MAP
#define VERTEX_DISPLACEMENT 1
#define _DisplacementMap _ParallaxMap
#define _DisplacementStrength _ParallaxStrength
#endif
由于我们不会同时使用视差贴图和曲面细分,我们可以在 Tessellation Shader 的 CGINCLUDE 块中删除所有与视差相关的定义。相反,我们只需要定义 VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CGINCLUDE
#define BINORMAL_PER_FRAGMENT
#define FOG_DISTANCE
// #define PARALLAX_BIAS 0
// #define PARALLAX_OFFSET_LIMITING
// #define PARALLAX_RAYMARCHING_STEPS 10
// #define PARALLAX_RAYMARCHING_INTERPOLATE
// #define PARALLAX_RAYMARCHING_SEARCH_STEPS 3
// #define PARALLAX_FUNCTION ParallaxRaymarching
// #define PARALLAX_SUPPORT_SCALED_DYNAMIC_BATCHING
#define VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX
ENDCG
我们将在对象空间(Object Space)中执行顶点置换。为了允许相当数量的置换,将最大强度从 0.1 增加到 1。
1
_ParallaxStrength ("Parallax Strength", Range(0, 1)) = 0
1.2 改变顶点位置
置换顶点必须在 My Lighting 的顶点程序中完成,在我们使用顶点位置做任何其他事情之前。这意味着如果我们想支持像所有其他贴图一样缩放和偏移置换贴图,我们必须在此点之前转换纹理坐标。所以让我们将 TRANSFORM_TEX 行移动到第一次使用顶点位置之前。
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
InterpolatorsVertex MyVertexProgram (VertexData v) {
InterpolatorsVertex i;
UNITY_INITIALIZE_OUTPUT(InterpolatorsVertex, i);
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, i);
i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
i.pos = UnityObjectToClipPos(v.vertex);
i.worldPos.xyz = mul(unity_ObjectToWorld, v.vertex);
#if FOG_DEPTH
i.worldPos.w = i.pos.z;
#endif
i.normal = UnityObjectToWorldNormal(v.normal);
#if defined(BINORMAL_PER_FRAGMENT)
i.tangent = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
#else
i.tangent = UnityObjectToWorldDir(v.tangent.xyz);
i.binormal = CreateBinormal(i.normal, i.tangent, v.tangent.w);
#endif
// i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
// i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
...
}
一旦我们有了最终的纹理坐标,我们就可以采样置换贴图。这与采样视差贴图相同,所以我们将使用它的绿色纹理通道。但是,因为我们不是在片元程序中这样做,所以没有屏幕空间导数(Screen-space Derivatives)可用,因此 GPU 无法确定使用哪个 Mipmap 级别。我们不能使用 tex2D,而必须使用 tex2Dlod 来指定显式 Mip 级别。这是通过提供两个额外的纹理坐标来完成的,第三个是未使用的 3D 坐标,第四个指定 Mip 级别。我们将对两者都使用 0,实际上不使用 Mipmaps。
1
2
3
4
5
6
i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
#if VERTEX_DISPLACEMENT
float displacement = tex2Dlod(_DisplacementMap, float4(i.uv.xy, 0, 0)).g;
#endif
就像我们对视差贴图所做的那样,让我们解释贴图,使 0.5 的值意味着没有变化,从而可以向上和向下移动顶点。之后,计入置换强度,以便我们可以控制顶点在对象空间中移动的程度。
1
2
3
4
#if VERTEX_DISPLACEMENT
float displacement = tex2Dlod(_DisplacementMap, float4(i.uv.xy, 0, 0)).g;
displacement = (displacement - 0.5) * _DisplacementStrength;
#endif
如果我们使用默认的高度场,那么我们只需在此点将置换添加到顶点 Y 位置。
1
2
3
float displacement = tex2Dlod(_DisplacementMap, float4(i.uv.xy, 0, 0)).g;
displacement = (displacement - 0.5) * _DisplacementStrength;
v.vertex.y += displacement;
当将此方法应用于 Quad 时,结果看起来像一团乱七八糟的三角形,但仍然是平坦的。这是因为 Quad 在对象空间中与 XY 平面对齐。如果我们想扰动其原本平坦的表面,我们必须调整其 Z 坐标。一般来说,从网格的角度来看,正置换应该向上移动顶点。但并非所有网格都是平面。在球体的情况下,置换向外移动顶点是有意义的。所以通常最合理的做法是沿顶点法线进行置换。
1
2
// v.vertex.y += displacement;
v.vertex.xyz += v.normal * displacement;
因为我们使用的是曲面细分,新顶点的法线向量是通过插值创建的。所以只有当所有顶点法线具有相同的方向时,它们才保证是单位长度的。为了保证我们通常获得单位长度的法线向量,我们在使用它们进行置换之前应该将它们归一化。
1
2
v.normal = normalize(v.normal);
v.vertex.xyz += v.normal * displacement;
1.3 使用足够的三角形
支持所需的细节级别需要多少三角形?这取决于情况。我们在全强度下的置换贴图产生了相当大的变化,所以我们需要相当多的三角形才能使其看起来不错。但我们不想使用比我们需要的更多的三角形,所以我们应该使用 Edge 曲面细分模式而不是 Uniform 模式。
处于 Edge 模式时,曲面细分由 Edge Length 属性和视图距离控制。所以使用的三角形数量可能会有很大差异。一个 Quad 本身只包含两个三角形。我们需要大量的曲面细分才能获得比低多边形锯齿平面更好的东西。我们可以通过使用具有更多三角形的基础网格来大大帮助曲面细分。例如,Unity 的默认平面网格由 10×10 个 Quad 组成。使用它而不是 Quad 可以防止完全退化,如果需要,也可以产生比 Quad 高得多的顶点分辨率。
使用平面代替 Quad 允许更微调的曲面细分,这使得更容易从所有视角实现视觉上均匀的三角形密度。
然而,这仍然不能保证所有三角形最终具有相同的视觉大小。细分的三角形仅在其顶点被置换之前大约是相同的大小。如果三角形的顶点最终被不同程度地置换,它将沿法线向量被拉伸。通常,顶点置换并不像我们在本教程中使用的示例那样极端。如果你将其用于地形网格,常规网格应该有足够的分辨率来表示地形的大特征。如果你使用平坦网格作为地形的基础,请考虑在确定曲面细分因子之前置换原始顶点,以便在细分时将粗略的高程考虑在内。
1.4 法线着色
到目前为止,我们一直在使用平坦线框着色效果,以使其在视觉上明显地显示三角形是如何被细分的。但大多数时候,目标是在没有明显曲面细分的情况下增强网格。所以让我们恢复到默认的着色方法。我们通过从 Tessellation Shader 的 forward base、additive 和 deferred 通道中删除几何着色器指令来做到这一点。我们还必须在相同的通道中用 My Lighting 替换 MyFlatWireframe 包含文件的使用。
1
2
3
4
5
// #pragma geometry MyGeometryProgram
...
// #include "MyFlatWireframe.cginc"
#include "My Lighting.cginc"
#include "MyTessellation.cginc"
随着平坦线框效果的移除,我们也不再需要线框属性了。
1
2
3
// _WireframeColor ("Wireframe Color", Color) = (0, 0, 0)
// _WireframeSmoothing ("Wireframe Smoothing", Range(0, 10)) = 1
// _WireframeThickness ("Wireframe Thickness", Range(0, 10)) = 1
我们现在回到了正常的着色,结果看起来很平坦。这是因为我们置换了顶点位置,但没有调整顶点法线以匹配。确切的最终结果取决于你是使用 forward 还是 deferred 渲染路径。
渲染路径之间的视觉差异是由于阴影造成的。我们还没有为阴影做任何特殊处理,所以我们得到了平面的默认阴影。在 forward 渲染的情况下,用于屏幕空间阴影的深度通道和阴影投射都是用这个平面完成的。所以我们的平面最终没有投射阴影给自己。在 deferred 渲染的情况下,细分的几何体用于填充 G-buffer,包括深度缓冲。所以向下置换的细分几何体最终被平坦平面的阴影所遮挡。我们将在下一节处理阴影,所以现在我将使用 forward 渲染路径。
为了让我们的置换表面获得正确的着色,我们必须使用正确的法线向量。幸运的是,我们有一个与视差贴图匹配的法线贴图,所以我们可以直接使用它。
这种方法适用于任何网格,但重要的是没有纹理接缝。任何接缝都会导致不连续性。如果我们只使用法线贴图,这将导致着色中的伪影,暗示在不应该有硬边的地方出现了硬边。在置换的情况下,它会导致网格中的间隙,这要糟糕得多。要看到这一点的一个好例子是,将我们的曲面细分材质应用于默认球体并检查其极点。
我们怎样才能让它适用于球体? 你必须使用一种没有不连续性的置换贴图方法。例如,你可以使用立方体贴图(Cubemap)而不是经纬度贴图。
此时,我们已经通过曲面细分完成了一个置换效果,取代了之前教程中的视差效果。曲面细分相对于视差贴图的一大优势是它与其他所有东西都能很好地配合,因为它只是三角形。所有适用于常规三角形的技术都适用,并且它与其他几何体正确相交。
2 阴影
目前,阴影的表现就像我们的平面仍然是平坦的一样。只有在使用 deferred 渲染时,置换的几何体才被用于接收阴影,但投射的阴影仍然是平坦的。我们现在要确保障阴影与置换表面匹配。
2.1 带曲面细分的 Shadow Caster Pass
要使阴影工作,我们要做的第一件事是为 Shadow Caster Pass 启用曲面细分。这意味着此通道的 Shader Target 级别必须增加到 4.6。
1
#pragma target 4.6
由于我们的置换方法使用视差贴图,我们必须为其添加适当的 Shader Feature,还要为 Edge 曲面细分模式添加一个 Feature。
1
2
3
#pragma shader_feature _SMOOTHNESS_ALBEDO
#pragma shader_feature _PARALLAX_MAP
#pragma shader_feature _TESSELLATION_EDGE
然后我们必须将阴影的顶点程序替换为曲面细分顶点程序,并添加所需的 Hull 和 Domain 程序。
1
2
3
4
5
// #pragma vertex MyShadowVertexProgram
#pragma vertex MyTessellationVertexProgram
#pragma fragment MyShadowFragmentProgram
#pragma hull MyHullProgram
#pragma domain MyDomainProgram
这些程序定义在 MyTessellation 中,所以在 My Shadows 之后包含它。
1
2
#include "My Shadows.cginc"
#include "MyTessellation.cginc"
2.2 使细分阴影工作
此时我们的 Shadow Caster Pass 无法在没有错误的情况下编译。这是因为 My Shadows 没有遵循与 My Lighting 完全相同的方法。第一个问题是 MyTessellation 期望 VertexData 的顶点位置字段名为 vertex,而在 My Shadows 中它被称为 position。让我们通过在 My Shadows 中将其重命名为 vertex 来解决这个问题。
1
2
3
4
5
6
7
struct VertexData {
UNITY_VERTEX_INPUT_INSTANCE_ID
// float4 position : POSITION;
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
顶点位置在 MyShadowVertexProgram 中使用了两次,所以也要更改这些引用。
1
2
3
4
5
6
7
8
9
10
11
12
InterpolatorsVertex MyShadowVertexProgram (VertexData v) {
...
#if defined(SHADOWS_CUBE)
i.position = UnityObjectToClipPos(v.vertex);
i.lightVec =
mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz;
#else
i.position = UnityClipSpaceShadowCasterPos(v.vertex.xyz, v.normal);
i.position = UnityApplyLinearShadowBias(i.position);
#endif
...
}
下一个问题是阴影使用的顶点数据比其他三个通道少。具体来说,它们不需要 tangent、uv1 和 uv2 数据。我们可以无论如何都添加这些数据,但那会不必要地使阴影变慢。相反,让我们调整 MyTessellation 以便它可以支持更少的顶点数据。我们可以通过仅在定义了适当的宏时才在 TessellationControlPoint 结构中包含 tangent、uv1 和 uv2 来做到这一点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct TessellationControlPoint {
float4 vertex : INTERNALTESSPOS;
float3 normal : NORMAL;
#if TESSELLATION_TANGENT
float4 tangent : TANGENT;
#endif
float2 uv : TEXCOORD0;
#if TESSELLATION_UV1
float2 uv1 : TEXCOORD1;
#endif
#if TESSELLATION_UV2
float2 uv2 : TEXCOORD2;
#endif
};
使用相同的技巧,我们可以控制 MyTessellationVertexProgram 是否将相关字段从顶点数据复制到控制点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TessellationControlPoint MyTessellationVertexProgram (VertexData v) {
TessellationControlPoint p;
p.vertex = v.vertex;
p.normal = v.normal;
#if TESSELLATION_TANGENT
p.tangent = v.tangent;
#endif
p.uv = v.uv;
#if TESSELLATION_UV1
p.uv1 = v.uv1;
#endif
#if TESSELLATION_UV2
p.uv2 = v.uv2;
#endif
return p;
}
以及 MyDomainProgram 是否插值数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[UNITY_domain("tri")]
InterpolatorsVertex MyDomainProgram (
...
) {
...
MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
#if TESSELLATION_TANGENT
MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)
#endif
MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
#if TESSELLATION_UV1
MY_DOMAIN_PROGRAM_INTERPOLATE(uv1)
#endif
#if TESSELLATION_UV2
MY_DOMAIN_PROGRAM_INTERPOLATE(uv2)
#endif
return MyVertexProgram(data);
}
这种方法允许我们微调在细分时包含哪些网格数据。我们不需要阴影的 tangent、uv1 和 uv2,但其他三个通道可能都需要它们。所以让我们在 My Lighting Input 的顶部定义相关的宏。
1
2
3
4
5
6
7
#define TESSELLATION_TANGENT 1
#define TESSELLATION_UV1 1
#define TESSELLATION_UV2 1
#if defined(_PARALLAX_MAP) && defined(VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX)
...
#endif
我们在其他通道中总是需要所有这些数据吗? 不,但我们只是假设我们需要。你可以进一步微调,仅在真正需要时才包含 UV1 和 UV2。
最后一个问题是 My Lighting 依赖于 MyVertexProgram 的存在,但我们已将 Shadow Caster Pass 的顶点程序命名为 MyShadowVertexProgram。快速解决方案是在 My Shadows 中定义一个宏别名。这样 MyVertexProgram 也适用于阴影,而不会破坏现有的 Shader。
1
2
3
4
5
#define MyVertexProgram MyShadowVertexProgram
InterpolatorsVertex MyShadowVertexProgram (VertexData v) {
...
}
2.3 置换阴影几何体
阴影现在被细分了。下一步是置换它们的顶点,为此我们可以使用应用于 My Lighting 的相同方法。首先,将适当的宏定义复制到 My Shadows 的顶部。唯一的区别是我们还必须定义 SHADOWS_NEED_UV,如果它尚未定义的话。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#if SHADOWS_SEMITRANSPARENT || defined(_RENDERING_CUTOUT)
#if !defined(_SMOOTHNESS_ALBEDO)
#define SHADOWS_NEED_UV 1
#endif
#endif
#if defined(_PARALLAX_MAP) && defined(VERTEX_DISPLACEMENT_INSTEAD_OF_PARALLAX)
#undef _PARALLAX_MAP
#define VERTEX_DISPLACEMENT 1
#define _DisplacementMap _ParallaxMap
#define _DisplacementStrength _ParallaxStrength
#if !defined(SHADOWS_NEED_UV)
#define SHADOWS_NEED_UV 1
#endif
#endif
阴影没有使用视差贴图,所以我们现在必须添加所需的变量。
1
2
sampler2D _ParallaxMap;
float _ParallaxStrength;
在阴影顶点程序中,将纹理坐标的变换移动到顶点位置的使用之上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
InterpolatorsVertex MyShadowVertexProgram (VertexData v) {
InterpolatorsVertex i;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_TRANSFER_INSTANCE_ID(v, i);
#if SHADOWS_NEED_UV
i.uv = TRANSFORM_TEX(v.uv, _MainTex);
#endif
#if defined(SHADOWS_CUBE)
i.position = UnityObjectToClipPos(v.vertex);
i.lightVec =
mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz;
#else
i.position = UnityClipSpaceShadowCasterPos(v.vertex.xyz, v.normal);
i.position = UnityApplyLinearShadowBias(i.position);
#endif
// #if SHADOWS_NEED_UV
// i.uv = TRANSFORM_TEX(v.uv, _MainTex);
// #endif
return i;
}
然后置换顶点位置。
1
2
3
4
5
6
7
8
9
10
#if SHADOWS_NEED_UV
i.uv = TRANSFORM_TEX(v.uv, _MainTex);
#endif
#if VERTEX_DISPLACEMENT
float displacement = tex2Dlod(_DisplacementMap, float4(i.uv.xy, 0, 0)).g;
displacement = (displacement - 0.5) * _DisplacementStrength;
v.normal = normalize(v.normal);
v.vertex.xyz += v.normal * displacement;
#endif
我们现在得到了正确置换的阴影。接收和投射阴影现在都是正确的,对于两个渲染路径都是如此。
阴影工作正常,曲面细分相对于视差贴图的另一个优势是我们自动获得了自阴影(Self-shadowing)。它不需要任何额外的工作。
当然,你必须牢记阴影贴图的局限性。此外,当使用 Edge 曲面细分模式时,阴影贴图的视图距离与常规摄像机不同。这意味着细分的阴影几何体并不完全匹配常规细分几何体,这可能会产生阴影伪影。曲面细分越精细,这个问题就越小。
3 剔除三角形
虽然曲面细分很好,但它并不便宜,特别是当需要高水平的曲面细分时。需要意识到的一件重要事情是,网格的每个三角形都会被细分,无论它最终是否可见。然而,可以对此做些什么。
场景中并非所有东西都会被渲染。只有位于摄像机视锥体(View Frustum)内的对象才能被看到。这些对象由 Unity 发送到 GPU,其他所有东西都被剔除。但是,如果对象的包围盒哪怕只有一小部分位于视锥体内,它的整个网格都将由 GPU 处理,从而被细分。幸运的是,有一种方法可以在细分时跳过三角形,有效地在曲面细分阶段之前剔除它们。
3.1 跳过一些三角形
曲面细分的数量由 Edge 和 Inside 曲面细分因子控制。因子 1 对应于不添加三角形。更高的因子导致更多的三角形。但也可以使用因子 0。当曲面细分因子之一为零时,原始三角形被丢弃,根本不会被渲染。
如果我们能弄清楚三角形是否位于视锥体之外,我们就可以将其曲面细分因子设置为 0,有效地在 GPU 上逐三角形执行视锥体剔除。让我们在 MyTessellation 中添加一个函数来解决这个问题。将其放在 MyPatchConstantFunction 之上,因为该函数将调用它。我们将从一个非常简单的测试开始。如果三角形的所有三个顶点都有负的 X 坐标,我们将认为它被剔除了。我们可以使用布尔值来传达这一点。
1
2
3
bool TriangleIsCulled (float3 p0, float3 p1, float3 p2) {
return p0.x < 0 && p1.x < 0 && p2.x < 0;
}
在 MyPatchConstantFunction 中使用此函数来检查我们是否可以跳过三角形。如果是,将所有边因子设置为零。否则,像往常一样确定因子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
TessellationFactors MyPatchConstantFunction (
InputPatch<TessellationControlPoint, 3> patch
) {
float3 p0 = mul(unity_ObjectToWorld, patch[0].vertex).xyz;
float3 p1 = mul(unity_ObjectToWorld, patch[1].vertex).xyz;
float3 p2 = mul(unity_ObjectToWorld, patch[2].vertex).xyz;
TessellationFactors f;
if (TriangleIsCulled(p0, p1, p2)) {
f.edge[0] = f.edge[1] = f.edge[2] = f.inside = 0;
}
else {
f.edge[0] = TessellationEdgeFactor(p1, p2);
f.edge[1] = TessellationEdgeFactor(p2, p0);
f.edge[2] = TessellationEdgeFactor(p0, p1);
f.inside =
(TessellationEdgeFactor(p1, p2) +
TessellationEdgeFactor(p2, p0) +
TessellationEdgeFactor(p0, p1)) * (1 / 3.0);
}
return f;
}
3.2 视锥体剔除
要执行实际的视锥体剔除,我们必须验证三角形是否位于摄像机的视锥体内。视锥体是一个金字塔,其顶部被平行于其底部的平面切断。金字塔的底部和侧面也可以由平面定义。这些平面形成一个系统,其中视锥体内的空间被认为位于所有六个裁剪平面之上。所以我们必须检查每个平面,点是否位于其上方或下方。让我们创建一个布尔函数来检查单个平面,默认返回 true。在 TriangleIsCulled 内部调用该函数,替换我们的测试代码。
1
2
3
4
5
6
7
8
bool TriangleIsBelowClipPlane (float3 p0, float3 p1, float3 p2) {
return true;
}
bool TriangleIsCulled (float3 p0, float3 p1, float3 p2) {
// return p0.x < 0 && p1.x < 0 && p2.x < 0;
return TriangleIsBelowClipPlane(p0, p1, p2);
}
因为摄像机可能有任何位置和方向,我们无法提前对其裁剪平面做出任何假设。所以我们必须能够处理任意方向和位置的平面。一般来说,平面可以由定义其局部向上方向的法线向量以及相对于世界原点的偏移来定义。此数据可以存储在一个四分量向量中,其中 W 分量包含偏移。对应于我们之前丢弃具有负 X 坐标的三角形的测试用例的平面向量将是 (1, 0, 0, 0)。如果我们改为丢弃 X 坐标高达 2 的三角形,则向量将是 (1, 0, 0, 2)。
要弄清楚点是否位于平面上方或下方,我们可以通过点积将该点的向量投影到平面的法线向量上。如果结果为负,则它们的角度大于 90°,因此点位于平面下方。平面的偏移也必须考虑在内,方法是将其添加到计算中,例如将其设为 (px, py, pz, 1) 和平面向量之间的点积,其中 px, py 和 pz 是点的坐标。相应地调整 TriangleIsBelowClipPlane。
1
2
3
4
5
6
7
bool TriangleIsBelowClipPlane (float3 p0, float3 p1, float3 p2) {
float4 plane = float4(1, 0, 0, 0);
return
dot(float4(p0, 1), plane) < 0 &&
dot(float4(p1, 1), plane) < 0 &&
dot(float4(p2, 1), plane) < 0;
}
摄像机的实际裁剪平面通过 UnityShaderVariables 中定义的 unity_CameraWorldClipPlanes 数组提供。它包含六个平面定义,分别用于左、右、底、顶、近和远平面。所以要使用摄像机的左平面,我们必须使用 unity_CameraWorldClipPlanes[0]。
1
2
// float4 plane = float4(1, 0, 0, 0);
float4 plane = unity_CameraWorldClipPlanes[0];
为了使 TriangleIsBelowClipPlane 适用于任何摄像机裁剪平面,添加平面索引作为附加参数,并使用它来选择适当的摄像机平面。
1
2
3
4
5
6
7
8
9
bool TriangleIsBelowClipPlane (
float3 p0, float3 p1, float3 p2, int planeIndex
) {
float4 plane = unity_CameraWorldClipPlanes[planeIndex];
return
dot(float4(p0, 1), plane) < 0 &&
dot(float4(p1, 1), plane) < 0 &&
dot(float4(p2, 1), plane) < 0;
}
现在我们可以在 TriangleIsCulled 内部检查所有裁剪平面。如果三角形最终位于其中任何一个之下,那么它就不可能是可见的,应该被裁剪。我们必须检查左、右、底和顶平面。近平面实际上并不需要,因为视锥体通常在摄像机后方很短的距离处汇聚成一点。所以检查近平面不值得额外努力。远平面也不必要,因为在这个距离上,曲面细分通常无论如何都不会发生。
1
2
3
4
5
6
7
bool TriangleIsCulled (float3 p0, float3 p1, float3 p2) {
return
TriangleIsBelowClipPlane(p0, p1, p2, 0) ||
TriangleIsBelowClipPlane(p0, p1, p2, 1) ||
TriangleIsBelowClipPlane(p0, p1, p2, 2) ||
TriangleIsBelowClipPlane(p0, p1, p2, 3);
}
3.3 偏移剔除
因为我们只裁剪那些我们看不到的三角形,所以除了帧率可能存在差异外,我们不应该能够区分裁剪和不裁剪。然而,这只有在我们不置换任何顶点时才是真的。当顶点确实被置换时,即使原始三角形位于视锥体之外,置换的顶点也有可能最终位于视锥体内。在我们置换平面的情况下,你可以通过以浅角度观察平面来验证这一点,这样它的一些网格三角形最终刚好位于视图底部下方。你会很快遇到孔洞,因为三角形突然消失,而它们不应该消失。
这个问题的解决方案是在确定三角形是否位于裁剪平面下方时,将最大置换量考虑在内。这可以通过向 TriangleIsBelowClipPlane 添加偏移(Bias)来完成。与其检查点积是否小于零,不如检查它是否小于此偏移。
1
2
3
4
5
6
7
8
9
bool TriangleIsBelowClipPlane (
float3 p0, float3 p1, float3 p2, int planeIndex, float bias
) {
float4 plane = unity_CameraWorldClipPlanes[planeIndex];
return
dot(float4(p0, 1), plane) < bias &&
dot(float4(p1, 1), plane) < bias &&
dot(float4(p2, 1), plane) < bias;
}
我们应该对所有平面检查使用相同的偏移,所以将其作为参数添加到 TriangleIsCulled 中。
1
2
3
4
5
6
7
bool TriangleIsCulled (float3 p0, float3 p1, float3 p2, float bias) {
return
TriangleIsBelowClipPlane(p0, p1, p2, 0, bias) ||
TriangleIsBelowClipPlane(p0, p1, p2, 1, bias) ||
TriangleIsBelowClipPlane(p0, p1, p2, 2, bias) ||
TriangleIsBelowClipPlane(p0, p1, p2, 3, bias);
}
让我们在 MyPatchConstantFunction 中使用偏移 1,看看会发生什么。
1
2
3
4
float bias = 1;
if (TriangleIsCulled(p0, p1, p2, bias)) {
f.edge[0] = f.edge[1] = f.edge[2] = f.inside = 0;
}
正偏移有效地将裁剪平面向上推,减小了视锥体的大小。结果,当三角形靠近视图边缘时,它们会被过快地裁剪。负偏移具有相反的效果,因此位于视锥体之外但仍在其附近的三角形不会被裁剪。所以当使用顶点置换时,我们必须使用负偏移。由于我们在任何维度上的最大置换等于置换强度的一半,这就是我们需要的负偏移。
1
2
3
4
5
6
7
float bias = 0;
#if VERTEX_DISPLACEMENT
bias = -0.5 * _DisplacementStrength;
#endif
if (TriangleIsCulled(p0, p1, p2, bias)) {
f.edge[0] = f.edge[1] = f.edge[2] = f.inside = 0;
}
我们现在正在尽可能多地裁剪三角形,同时保证永远不会出现孔洞。当然,确定我们是否应该裁剪三角形也需要工作,所以它不会提高完全在视图中的网格的性能,实际上会让它变得更糟。但是当你渲染具有大量曲面细分的大型网格,并且它们通常只是部分可见时,你最终可以显着提高帧率。
曲面细分的介绍到此结束。现在你知道如何细分三角形以及如何通过置换贴图添加几何细节。这并不是你可以用曲面细分做的唯一事情。例如,还有 PN 三角形、Phong 曲面细分、程序化置换等等。

















