Post

曲面细分(翻译二十二)

曲面细分(翻译二十二)
  • 创建 Hull Shader 和 Domain Shader。
  • 细分三角形。
  • 控制细分的方式。

1 Hull 和 Domain

曲面细分是将物体切割成更小部分的艺术。在我们的案例中,我们将细分三角形,从而得到覆盖相同空间的小三角形。这使得为几何体添加更多细节成为可能,尽管在本教程中我们将重点关注曲面细分过程本身。

GPU 能够细分喂给它进行渲染的三角形。它这样做有多种原因,例如当三角形的一部分被剪裁时。我们无法控制这一点,但也有一个我们可以配置的曲面细分阶段。该阶段位于顶点(Vertex)和片元(Fragment)着色器阶段之间。但它不仅仅是向我们的 Shader 添加另一个程序那么简单。我们需要一个 Hull 程序和一个 Domain 程序

Inside a hull shader
Inside a hull shader

1.1 创建曲面细分 Shader

第一步是创建一个启用了曲面细分的 Shader。让我们将需要的代码放在它自己的文件 MyTessellation.cginc 中,并带上包含保护。

1
2
3
#if !defined(TESSELLATION_INCLUDED)
#define TESSELLATION_INCLUDED
#endif

为了清楚地看到三角形被细分,我们将使用 Flat Wireframe Shader。复制该 Shader,重命名为 Tessellation Shader 并调整其菜单名称。

1
Shader "Custom/Tessellation" { ... }

使用曲面细分时的最低 Shader Target 级别是 4.6。如果我们不手动设置,Unity 将发出警告并自动使用该级别。我们将为 forward base、additive 通道以及 deferred 通道添加曲面细分阶段。还要在这些通道中包含 MyTessellation,放在 MyFlatWireframe 之后。

1
2
3
4
#pragma target 4.6
...
#include "MyFlatWireframe.cginc"
#include "MyTessellation.cginc"

那 Shadow 通道呢? 在渲染阴影时也可以使用曲面细分,但在本教程中我们不会这样做。

创建一个依赖于此 Shader 的材质,并在场景中添加一个使用该材质的 Quad。我将材质设为灰色,这样它就不会太亮,就像 Flat Wireframe 材质一样。

A quad
A quad

请注意,它由两个等腰直角三角形组成。短边长度为 1,而长对角线长度为 $\sqrt{2}$。

1.2 Hull Shader

与几何着色器(Geometry Shader)类似,曲面细分阶段非常灵活,可以处理三角形、四边形或等值线。我们必须告诉它它要处理什么表面,并提供必要的数据。这是 Hull 程序的工作。在 MyTessellation 中添加一个 Hull 程序,从一个不执行任何操作的 void 函数开始。

1
void MyHullProgram () {}

Hull 程序操作一个 Surface Patch,它作为参数传递给程序。我们必须添加一个 InputPatch 参数来实现这一点。

void MyHullProgram (InputPatch patch) {}

Patch 是网格顶点的集合。就像我们为几何函数的流参数所做的那样,我们必须指定顶点的数据格式。我们现在将使用 VertexData 结构。

1
void MyHullProgram (InputPatch<VertexData> patch) {}

**不应该是 InputPatch 吗?** 由于 Hull 阶段在 Vertex 阶段之后,逻辑上 Hull 函数的输入类型必须与 Vertex 函数的输出类型匹配。这是事实,但我们现在将忽略这一点。

由于我们处理的是三角形,每个 Patch 将包含三个顶点。这个数量必须指定为 InputPatch 的第二个模板参数。

1
void MyHullProgram (InputPatch<VertexData, 3> patch) {}

Hull 程序的工作是将所需的顶点数据传递给曲面细分阶段。虽然它被喂给了一个完整的 Patch,但函数一次只能输出一个顶点。它将针对 Patch 中的每个顶点调用一次,并带有一个额外的参数,指定它应该处理哪个控制点(顶点)。该参数是一个带有 SV_OutputControlPointID 语义的无符号整数。

1
2
3
4
void MyHullProgram (
    InputPatch<VertexData, 3> patch,
    uint id : SV_OutputControlPointID
) {}

只需将 Patch 像数组一样建立索引并返回所需的元素。

1
2
3
4
5
6
VertexData MyHullProgram (
    InputPatch<VertexData, 3> patch,
    uint id : SV_OutputControlPointID
) {
    return patch[id];
}

这看起来像是一个功能性的程序,所以让我们添加一个编译器指令来将其用作 Hull Shader。为涉及的三个 Shader 通道都这样做。

1
2
3
4
#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
#pragma hull MyHullProgram
#pragma geometry MyGeometryProgram

这将产生一些编译器错误,抱怨我们没有正确配置 Hull Shader。与几何函数一样,它需要属性来配置。首先,我们必须明确告诉它它正在处理三角形。这是通过 UNITY_domain 属性完成的,使用 tri 作为参数。

1
2
[UNITY_domain("tri")]
VertexData MyHullProgram ...

这还不够。我们还必须明确指定我们每个 Patch 输出三个控制点,三角形的每个角对应一个。

1
2
3
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
VertexData MyHullProgram ...

当 GPU 创建新的三角形时,它需要知道我们要将它们定义为顺时针还是逆时针。与 Unity 中的所有其他三角形一样,它们应该是顺时针的。这通过 UNITY_outputtopology 属性控制。其参数应为 triangle_cw

1
2
3
4
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
VertexData MyHullProgram ...

还需要告诉 GPU 它应该如何切割 Patch,这通过 UNITY_partitioning 属性完成。有几种不同的划分方法,我们稍后将研究它们。目前,只需使用 integer 模式。

1
2
3
4
5
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
VertexData MyHullProgram ...

除了划分方法之外,GPU 还必须知道 Patch 应该被切割成多少部分。这不是一个常数值,它可以针对每个 Patch 而变化。我们必须提供一个函数来评估这一点,称为 Patch Constant 函数。让我们假设我们有这样一个函数,名为 MyPatchConstantFunction

1
2
3
4
5
6
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
VertexData MyHullProgram ...

1.3 Patch Constant 函数

Patch 如何被细分是 Patch 的属性。这意味着 Patch Constant 函数对每个 Patch 仅调用一次,而不是对每个控制点调用一次。这就是为什么它被称为 Constant 函数,在整个 Patch 上保持不变。实际上,这个函数是与 MyHullProgram 并行运行的一个子阶段。

Inside a hull shader
Inside a hull shader

为了确定如何细分三角形,GPU 使用四个曲面细分因子(Tessellation Factors)。三角形 Patch 的每条边都有一个因子。三角形内部也有一个因子。三个边向量必须作为具有 SV_TessFactor 语义的 float 数组传递。内部因子使用 SV_InsideTessFactor 语义。让我们为此创建一个结构。

1
2
3
4
struct TessellationFactors {
    float edge[3] : SV_TessFactor;
    float inside : SV_InsideTessFactor;
};

Patch Constant 函数将 Patch 作为输入参数并输出曲面细分因子。现在让我们创建这个缺失的函数。只需将其所有因子设置为 1。这将指示曲面细分阶段不细分 Patch。

1
2
3
4
5
6
7
8
TessellationFactors MyPatchConstantFunction (InputPatch<VertexData, 3> patch) {
    TessellationFactors f;
    f.edge[0] = 1;
    f.edge[1] = 1;
    f.edge[2] = 1;
    f.inside = 1;
    return f;
}

1.4 Domain Shader

此时,Shader 编译器会抱怨 Shader 不能在没有曲面细分评估着色器(Tessellation Evaluation Shader)的情况下拥有曲面细分控制着色器(Tessellation Control Shader)。Hull Shader 只是我们需要让曲面细分工作的一部分。一旦曲面细分阶段确定了 Patch 应该如何细分,就轮到 Domain Shader(在 HLSL 中也称为几何评估着色器)来评估结果并生成最终三角形的顶点。所以让我们为我们的 Domain Shader 创建一个函数,再次从一个存根开始。

1
void MyDomainProgram () {}

Hull 和 Domain Shader 都作用于同一个域(Domain),即三角形。我们再次通过 UNITY_domain 属性发出信号。

1
2
[UNITY_domain("tri")]
void MyDomainProgram () {}

Domain 程序被喂给了所使用的曲面细分因子,以及原始 Patch,在这种情况下其类型为 OutputPatch

1
2
3
4
5
[UNITY_domain("tri")]
void MyDomainProgram (
    TessellationFactors factors,
    OutputPatch<VertexData, 3> patch
) {}

虽然曲面细分阶段决定了 Patch 应该如何细分,但它并不生成任何新的顶点。相反,它为这些顶点提供了重心坐标(Barycentric Coordinates)。由 Domain Shader 使用这些坐标来导出最终顶点。为了使这成为可能,Domain 函数对每个顶点调用一次,并为其提供重心坐标。它们具有 SV_DomainLocation 语义。

1
2
3
4
5
6
[UNITY_domain("tri")]
void MyDomainProgram (
    TessellationFactors factors,
    OutputPatch<VertexData, 3> patch,
    float3 barycentricCoordinates : SV_DomainLocation
) {}

在函数内部,我们必须生成最终的顶点数据。

1
2
3
4
5
6
7
8
[UNITY_domain("tri")]
void MyDomainProgram (
    TessellationFactors factors,
    OutputPatch<VertexData, 3> patch,
    float3 barycentricCoordinates : SV_DomainLocation
) {
    VertexData data;
}

要找到此顶点的中心位置,我们必须使用重心坐标在原始三角形域上进行插值。X、Y 和 Z 坐标决定了第一、第二和第三个控制点的权重。

1
2
3
4
5
    VertexData data;
    data.vertex =
        patch[0].vertex * barycentricCoordinates.x +
        patch[1].vertex * barycentricCoordinates.y +
        patch[2].vertex * barycentricCoordinates.z;

我们必须以同样的方式插值所有顶点数据。让我们为此定义一个方便的宏,它可以用于所有向量大小。

1
2
3
4
5
6
#define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) data.fieldName = 
    patch[0].fieldName * barycentricCoordinates.x + 
    patch[1].fieldName * barycentricCoordinates.y + 
    patch[2].fieldName * barycentricCoordinates.z;

    MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)

除了位置,还要插值法线、切线和所有 UV 坐标。

1
2
3
4
5
6
    MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
    MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
    MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv1)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv2)

唯一不插值的是实例 ID。由于 Unity 不支持同时使用 GPU Instancing 和曲面细分,因此没有必要复制此 ID。要防止编译器错误,请从三个 Shader 通道中删除 multi-compile 指令。这也将从 Shader 的 GUI 中删除 Instancing 选项。

1
2
// #pragma multi_compile_instancing
// #pragma instancing_options lodfade force_same_maxcount_for_gl

是否可以同时使用 Instancing 和曲面细分? 目前不行。请记住,GPU Instancing 在多次渲染相同对象时非常有用。由于曲面细分成本很高且是关于添加细节的,它们通常不是一个好的组合。如果你想让某些物体在近处使用曲面细分,你可以使用 LOD Group。让 LOD 0 使用非实例化的曲面细分材质,而所有其他 LOD 级别使用实例化的非曲面细分材质。

我们现在有了一个新顶点,它将在这一阶段之后被发送到几何程序或插值器。但这些程序期望的是 InterpolatorsVertex 数据,而不是 VertexData。为了解决这个问题,我们让 Domain Shader 接管原始顶点程序的职责。这是通过在其中调用 MyVertexProgram——就像任何其他函数一样——并返回其结果来完成的。

1
2
3
4
5
6
7
8
9
[UNITY_domain("tri")]
InterpolatorsVertex MyDomainProgram (
    TessellationFactors factors,
    OutputPatch<VertexData, 3> patch,
    float3 barycentricCoordinates : SV_DomainLocation
) {
    ...
    return MyVertexProgram(data);
}

现在我们可以将 Domain Shader 添加到我们的三个 Shader 通道中,但我们仍然会得到错误。

1
2
#pragma hull MyHullProgram
#pragma domain MyDomainProgram

1.5 控制点(Control Points)

MyVertexProgram 只需调用一次,只是我们改变了发生调用的位置。但我们仍然必须指定一个顶点程序在顶点着色器阶段调用,该阶段位于 Hull Shader 之前。此时我们不需要做任何事情,所以我们可以使用一个简单地原样传递顶点数据的函数。

1
2
3
VertexData MyTessellationVertexProgram (VertexData v) {
    return v;
}

让我们的三个 Shader 通道从现在起使用此函数作为其顶点程序。

1
#pragma vertex MyTessellationVertexProgram

这将产生另一个编译器错误,抱怨位置语义的重复使用。为了使其工作,我们必须为顶点程序使用一个替代的输出结构,该结构使用 INTERNALTESSPOS 语义作为顶点位置。结构的其余部分与 VertexData 相同,只是它永远没有实例 ID。由于此顶点数据用作曲面细分过程的控制点,让我们将其命名为 TessellationControlPoint

1
2
3
4
5
6
7
8
struct TessellationControlPoint {
    float4 vertex : INTERNALTESSPOS;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
    float2 uv : TEXCOORD0;
    float2 uv1 : TEXCOORD1;
    float2 uv2 : TEXCOORD2;
};

更改 MyTessellationVertexProgram,使其将顶点数据放入控制点结构并返回它。

1
2
3
4
5
6
7
8
9
10
TessellationControlPoint MyTessellationVertexProgram (VertexData v) {
    TessellationControlPoint p;
    p.vertex = v.vertex;
    p.normal = v.normal;
    p.tangent = v.tangent;
    p.uv = v.uv;
    p.uv1 = v.uv1;
    p.uv2 = v.uv2;
    return p;
}

接下来,MyHullProgram 也必须更改,以便它使用 TessellationControlPoint 而不是 VertexData。只有其参数类型需要更改。

1
2
3
4
5
6
TessellationControlPoint MyHullProgram (
    InputPatch<TessellationControlPoint, 3> patch,
    uint id : SV_OutputControlPointID
) {
    return patch[id];
}

Patch Constant 函数也是如此。

1
2
3
4
5
TessellationFactors MyPatchConstantFunction (
    InputPatch<TessellationControlPoint, 3> patch
) {
    ...
}

Domain 程序的参数类型也必须更改。

1
2
3
4
5
6
7
InterpolatorsVertex MyDomainProgram (
    TessellationFactors factors,
    OutputPatch<TessellationControlPoint, 3> patch,
    float3 barycentricCoordinates : SV_DomainLocation
) {
    ...
}

此时我们终于有了一个正确的曲面细分 Shader。它应该可以编译并像以前一样渲染 Quad。它还没有被细分,因为曲面细分因子始终为 1。

2 细分三角形

曲面细分设置的重点是我们可以细分 Patch。这允许我们将单个三角形替换为小三角形的集合。我们现在就来做这件事。

2.1 曲面细分因子

三角形 Patch 如何被细分由其曲面细分因子控制。我们在 MyPatchConstantFunction 中确定这些因子。目前,我们将它们都设置为 1,这不会产生视觉变化。Hull、曲面细分和 Domain Shader 阶段正在工作,但它们正在传递原始顶点数据并且不生成任何新内容。要更改此设置,请将所有因子设置为 2。

1
2
3
4
5
6
7
8
9
10
TessellationFactors MyPatchConstantFunction (
    InputPatch<TessellationControlPoint, 3> patch
) {
    TessellationFactors f;
    f.edge[0] = 2;
    f.edge[1] = 2;
    f.edge[2] = 2;
    f.inside = 2;
    return f;
}
Tessellation factors 2
Tessellation factors 2

三角形现在确实被细分了。它们的所有边都被分成了两个子边,导致每个三角形增加了三个新顶点。此外,在每个三角形的中心还添加了另一个顶点。这使得为每条原始边生成两个三角形成为可能,因此原始三角形已被六个较小的三角形替换。由于 Quad 由两个三角形组成,我们现在总共得到 12 个三角形。

如果你改为将所有因子设置为 3,每条边将被分成三个子边。在这种情况下,不会有中心顶点。相反,在原始三角形内部添加了三个顶点,形成一个较小的内部三角形。外边缘将通过三角形带(Triangle Strips)连接到这个内部三角形。

Tessellation factors 3
Tessellation factors 3

当曲面细分因子为偶数时,将有一个单一的中心顶点。当它们为奇数时,将有一个中心三角形。如果我们使用更大的曲面细分因子,我们最终会得到多个嵌套的三角形。向中心每迈进一阶,三角形被细分的数量就会减少二,直到我们最终得到一或零个子边。

Tessellation factors 4–7
Tessellation factors 4–7

2.2 不同的边和内部因子

三角形如何被细分由内部曲面细分因子控制。边因子可用于覆盖其各自边被细分的数量。这仅影响原始 Patch 边缘,不影响生成的内部三角形。为了清楚地看到这一点,将内部因子设置为 7,同时保持边因子为 1。

1
2
3
4
    f.edge[0] = 1;
    f.edge[1] = 1;
    f.edge[2] = 1;
    f.inside = 7;
Factor 7 inside, but 1 outside
Factor 7 inside, but 1 outside

实际上,三角形使用因子 7 进行细分,之后最外圈的三角形被丢弃。然后每条边使用其自己的因子进行细分,之后生成一个三角形带以将边和内部三角形缝合在一起。

边因子也有可能大于内部因子。例如,将边因子设置为 7,同时将内部因子保持为 1。

1
2
3
4
    f.edge[0] = 7;
    f.edge[1] = 7;
    f.edge[2] = 7;
    f.inside = 1;
Factor 1 inside, but 7 outside
Factor 1 inside, but 7 outside

在这种情况下,内部因子被迫表现得像 2,因为否则无法生成新的三角形。

2.3 可变因子

硬编码的曲面细分因子不是很有用。所以让我们把它变成可配置的,从一个单一的统一(Uniform)因子开始。

1
2
3
4
5
6
7
8
9
10
11
12
float _TessellationUniform;
...
TessellationFactors MyPatchConstantFunction (
    InputPatch<TessellationControlPoint, 3> patch
) {
    TessellationFactors f;
    f.edge[0] = _TessellationUniform;
    f.edge[1] = _TessellationUniform;
    f.edge[2] = _TessellationUniform;
    f.inside = _TessellationUniform;
    return f;
}

为我们的 Shader 添加一个属性。将其范围设置为 1–64。无论我们想使用多高的因子,硬件在每个 Patch 上的细分限制都是 64。

1
_TessellationUniform ("Tessellation Uniform", Range(1, 64)) = 1

为了能够编辑此因子,请在 MyLightingShaderGUI 中添加一个 DoTessellation 方法,将其显示在自己的部分中。

1
2
3
4
5
6
7
8
9
void DoTessellation () {
    GUILayout.Label("Tessellation", EditorStyles.boldLabel);
    EditorGUI.indentLevel += 2;
    editor.ShaderProperty(
        FindProperty("_TessellationUniform"),
        MakeLabel("Uniform")
    );
    EditorGUI.indentLevel -= 2;
}

OnGUI 内部,在渲染模式和线框(Wireframe)部分之间调用此方法。仅在所需属性存在时才执行此操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
public override void OnGUI (
    MaterialEditor editor, MaterialProperty[] properties
) {
    ...
    DoRenderingMode();
    if (target.HasProperty("_TessellationUniform")) {
        DoTessellation();
    }
    if (target.HasProperty("_WireframeColor")) {
        DoWireframe();
    }
    ...
}
Configurable uniform tessellation
Configurable uniform tessellation

2.4 分数因子(Fractional Factors)

即使我们使用 float 来设置曲面细分因子,我们最终总是在每条边上得到整数等效的细分。这是因为我们正在使用 integer 划分模式。虽然它是查看曲面细分如何工作的好模式,但它阻止了我们在细分级别之间平滑过渡。幸运的是,还有分数划分模式。让我们将模式更改为 fractional_odd

1
2
3
4
5
6
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("fractional_odd")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
TessellationControlPoint MyHullProgram ...
Fractional odd partitioning
Fractional odd partitioning

当使用完整的奇数因子时,fractional_odd 划分模式产生与 integer 模式相同的结果。但在奇数因子之间转换时,额外的边细分将分离开来并增长,或者缩小并合并。这意味着边不再总是被分成等长的段。这种方法的优点是细分级别之间的过渡现在是平滑的。

也可以使用 fractional_even 模式。它的工作方式相同,只是它基于偶数因子。

Fractional even partitioning
Fractional even partitioning

通常使用 fractional_odd 模式,因为它处理因子 1,而 fractional_even 模式被迫使用最低级别 2。

3 曲面细分启发式(Tessellation Heuristics)

什么是最好的曲面细分因子?这是在使用曲面细分时你必须问自己的主要问题。这个问题没有唯一的客观答案。通常,你能做的最好的事情就是提出一些度量标准,作为一个能够产生良好结果的启发式方法。在本教程中,我们将支持两种简单的方法。

3.1 边因子

虽然必须按边提供曲面细分因子,但你不必直接根据边来确定因子。例如,你可以针对每个顶点确定因子,然后对每条边求平均值。也许因子存储在纹理中。无论如何,有一个单独的函数来确定给定一条边的两个控制点的因子是很方便的。创建这样一个函数,目前只是返回统一值。

1
2
3
4
5
float TessellationEdgeFactor (
    TessellationControlPoint cp0, TessellationControlPoint cp1
) {
    return _TessellationUniform;
}

MyPatchConstantFunction 内部使用此函数处理边因子。

1
2
3
4
5
6
7
8
9
10
TessellationFactors MyPatchConstantFunction (
    InputPatch<TessellationControlPoint, 3> patch
) {
    TessellationFactors f;
    f.edge[0] = TessellationEdgeFactor(patch[1], patch[2]);
    f.edge[1] = TessellationEdgeFactor(patch[2], patch[0]);
    f.edge[2] = TessellationEdgeFactor(patch[0], patch[1]);
    f.inside = _TessellationUniform;
    return f;
}

对于内部因子,我们将简单地使用边因子的平均值。

1
    f.inside = (f.edge[0] + f.edge[1] + f.edge[2]) * (1 / 3.0);

3.2 边长(Edge Length)

由于边曲面细分因子控制我们对原始三角形边的细分程度,因此根据这些边的长度来确定此因子是有意义的。例如,我们可以指定一个理想的三角形边长。如果我们最终得到的三角形边长超过该长度,我们就应该按理想长度对其进行细分。为此添加一个变量。

1
2
float _TessellationUniform;
float _TessellationEdgeLength;

还要添加一个属性。让我们使用 0.1 到 1 的范围,默认值为 0.5。这是世界空间单位。

1
2
_TessellationUniform ("Tessellation Uniform", Range(1, 64)) = 1
_TessellationEdgeLength ("Tessellation Edge Length", Range(0.1, 1)) = 0.5

我们需要一个 Shader Feature 来实现在统一和基于边的曲面细分之间切换。在我们的三个通道中都添加所需的指令,使用 _TESSELLATION_EDGE 关键字。

1
#pragma shader_feature _TESSELLATION_EDGE

接下来,在 MyLightingShaderGUI 中添加一个枚举类型来表示曲面细分模式。

1
2
3
enum TessellationMode {
    Uniform, Edge
}

然后调整 DoTessellation,使其可以使用枚举弹出框在两种模式之间切换。它的工作方式类似于 DoSmoothness 控制光滑度模式的方式。在这种情况下,统一是默认模式,不需要关键字。

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
void DoTessellation () {
    GUILayout.Label("Tessellation", EditorStyles.boldLabel);
    EditorGUI.indentLevel += 2;
    TessellationMode mode = TessellationMode.Uniform;
    if (IsKeywordEnabled("_TESSELLATION_EDGE")) {
        mode = TessellationMode.Edge;
    }
    EditorGUI.BeginChangeCheck();
    mode = (TessellationMode)EditorGUILayout.EnumPopup(
        MakeLabel("Mode"), mode
    );
    if (EditorGUI.EndChangeCheck()) {
        RecordAction("Tessellation Mode");
        SetKeyword("_TESSELLATION_EDGE", mode == TessellationMode.Edge);
    }
    if (mode == TessellationMode.Uniform) {
        editor.ShaderProperty(
            FindProperty("_TessellationUniform"),
            MakeLabel("Uniform")
        );
    }
    else {
        editor.ShaderProperty(
            FindProperty("_TessellationEdgeLength"),
            MakeLabel("Edge Length")
        );
    }
    EditorGUI.indentLevel -= 2;
}
Using edge mode
Using edge mode

现在我们必须调整 TessellationEdgeFactor。当定义了 _TESSELLATION_EDGE 时,确定两个点的世界位置,然后计算它们之间的距离。这是世界空间中的边长。边因子等于此长度除以理想长度。

1
2
3
4
5
6
7
8
9
10
11
12
float TessellationEdgeFactor (
    TessellationControlPoint cp0, TessellationControlPoint cp1
) {
    #if defined(_TESSELLATION_EDGE)
        float3 p0 = mul(unity_ObjectToWorld, float4(cp0.vertex.xyz, 1)).xyz;
        float3 p1 = mul(unity_ObjectToWorld, float4(cp1.vertex.xyz, 1)).xyz;
        float edgeLength = distance(p0, p1);
        return edgeLength / _TessellationEdgeLength;
    #else
        return _TessellationUniform;
    #endif
}
Different quad scales, same desired edge length
Different quad scales, same desired edge length

因为我们现在使用边长来确定边的曲面细分因子,我们最终可能会为每条边得到不同的因子。你可以看到 Quad 发生了这种情况,因为对角线边缘比其他边缘长。当使用 Quad 的非均匀缩放,将其在一个维度上拉伸时,这一点也变得显而易见。

Stretched quad
Stretched quad

为了使其工作,必不可少的是共享一条边的 Patch 最终都为该边使用相同的曲面细分因子。否则,生成的顶点将无法沿该边匹配,这可能会在网格中产生可见的间隙。在我们的案例中,我们对所有边都使用相同的逻辑。唯一的区别可能是控制点参数的顺序。由于浮点限制,这在技术上可能会产生不同的因子,但差异微小到无法察觉。

3.3 屏幕空间中的边长

虽然我们现在可以在世界空间中控制三角形边长,但这并不对应于它们在屏幕空间中的表现。曲面细分的重点是在需要时添加更多三角形。因此,我们不想细分那些看起来已经很小的三角形。所以让我们改用屏幕空间边长。

首先,更改我们的边长属性的范围。我们将使用像素而不是世界单位,因此 5–100 这样的范围更有意义。

1
_TessellationEdgeLength ("Tessellation Edge Length", Range(5, 100)) = 50

将世界空间计算替换为它们的屏幕空间等效项。为此,点必须转换为裁剪空间(Clip Space)而不是世界空间。然后在 2D 中确定它们的距离,使用它们的 X 和 Y 坐标,除以它们的 W 坐标以将它们投影到屏幕上。

1
2
3
4
5
    float4 p0 = UnityObjectToClipPos(cp0.vertex);
    float4 p1 = UnityObjectToClipPos(cp1.vertex);
    float edgeLength = distance(p0.xy / p0.w, p1.xy / p1.w);

    return edgeLength / _TessellationEdgeLength;

现在我们在裁剪空间中得到了一个结果,裁剪空间是一个尺寸为 2 的统一立方体,正好适合显示。要转换为像素,我们必须按以像素为单位的显示尺寸进行缩放。实际上,由于显示器很少是正方形的,为了获得最准确的结果,我们应该在确定距离之前分别缩放 X 和 Y 坐标。但让我们仅通过缩放屏幕高度来满足要求,看看它看起来如何。

1
    return edgeLength * _ScreenParams.y / _TessellationEdgeLength;
Same world size, different screen size
Same world size, different screen size

我们的三角形边现在根据它们渲染的大小进行细分。位置、旋转和缩放都会相对于摄像机影响这一点。因此,当物体处于运动状态时,曲面细分的数量会发生变化。

我们不应该使用屏幕高度的一半吗? 由于裁剪空间立方体的范围是 -1 到 1,两个单位对应于显示的完整高度(和宽度)。这意味着我们最终得到了实际尺寸的两倍,高估了我们的边缘有多大。结果是我们实际上瞄准了预期的边长的一半。至少,对于完全垂直的边缘是这种情况,因为我们无论如何都没有使用确切的屏幕维度。使用屏幕高度的主要目的是使曲面细分依赖于显示分辨率。边长是否与我们的滑块的确切值匹配其实并不重要。

3.4 使用视图距离

纯粹依赖边缘的视觉长度的一个缺点是,世界空间中较长的边缘在屏幕空间中最终可能会变得非常小。这可能会导致这些边缘根本不被细分,而其他边缘被大量细分。当曲面细分用于近距离添加细节或生成复杂的轮廓时,这是不可取的。

另一种方法是回到使用世界空间边长,但根据视图距离调整因子。物体距离越远,它在视觉上看起来就越小,因此需要的曲面细分就越少。因此,将边长除以边缘与摄像机之间的距离。我们可以使用边缘的中点来确定这个距离。

1
2
3
4
5
6
7
8
    float3 p0 = mul(unity_ObjectToWorld, float4(cp0.vertex.xyz, 1)).xyz;
    float3 p1 = mul(unity_ObjectToWorld, float4(cp1.vertex.xyz, 1)).xyz;
    float edgeLength = distance(p0, p1);

    float3 edgeCenter = (p0 + p1) * 0.5;
    float viewDistance = distance(edgeCenter, _WorldSpaceCameraPos);

    return edgeLength / (_TessellationEdgeLength * viewDistance);

我们仍然可以通过简单地将屏幕高度计入其中并保持我们的 5–100 滑块范围,使曲面细分依赖于显示尺寸。请注意,这些值不再直接对应于显示像素。当你改变摄像机的视野(Field of View)时,这一点非常明显,这根本不会影响曲面细分。因此,这种简单的方法不适用于使用可变视野的游戏,例如放大和缩小。

1
2
    return edgeLength * _ScreenParams.y /
        (_TessellationEdgeLength * viewDistance);
Based on edge length and view distance
Based on edge length and view distance

3.5 使用正确的内部因子

虽然此时曲面细分看起来运行良好,但内部曲面细分因子似乎有些奇怪。至少,在使用 OpenGL Core 时是这种情况。在使用统一的 Quad 时并不那么明显,但在使用变形的立方体时,它变得显而易见。

Cube with incorrect inner factors
Cube with incorrect inner factors

在立方体的情况下,组成面的两个三角形各自得到了截然不同的内部曲面细分因子。Quad 和立方体面之间的唯一区别是三角形顶点的定义顺序。Unity 的默认立方体不使用对称的三角形布局,而 Quad 则是。这表明边的顺序显然影响了内部曲面细分因子。然而,我们只是取边因子的平均值,所以它们的顺序不应该有影响。一定是其他地方出了问题。

让我们做一些看起来毫无意义的事情,在计算内部因子时再次明确调用 TessellationEdgeFactors 函数。从逻辑上讲,这不应该产生差异,因为我们最终只是执行了两次完全相同的计算。Shader 编译器肯定会优化掉它。

1
2
3
4
5
// f.inside = (f.edge[0] + f.edge[1] + f.edge[2]) * (1 / 3.0);
    f.inside =
        (TessellationEdgeFactor(patch[1], patch[2]) +
        TessellationEdgeFactor(patch[2], patch[0]) +
        TessellationEdgeFactor(patch[0], patch[1])) * (1 / 3.0);
Cube with correct inner factors
Cube with correct inner factors

显然,它确实产生了差异,因为两个面三角形现在最终使用了几乎相同的内部因子。这是怎么回事?

Patch Constant 函数与 Hull Shader 的其余部分并行调用。但实际上它可能比这更复杂。Shader 编译器也能够并行化边因子的计算。MyPatchConstantFunction 内部的代码被拆开并部分重复,取而代之的是一个并行计算三个边因子的分叉进程。一旦所有三个进程完成,它们的结果就会合并并用于计算内部因子。

编译器是否决定分叉进程不应影响我们的 Shader 的结果,只影响其性能。不幸的是,在为 OpenGL Core 生成的代码中存在一个 Bug。在计算内部因子时,只使用了第三个边因子,而不是使用三个边因子。数据就在那里,只是它访问了三次索引 2,而不是索引 0、1 和 2。所以我们总是最终得到一个等于第三个边因子的内部因子。

在 Patch Constant 函数的情况下,Shader 编译器优先考虑并行化。它尽可能早地拆分进程,之后它无法再优化掉对 TessellationEdgeFactor 的重复调用。我们最终得到三个进程,每个进程都计算两个点的世界位置、距离和最终因子。然后还有一个计算内部因子的进程,它现在也必须计算三个顶点的世界位置,以及所涉及的所有距离和因子。由于我们现在为内部因子做了所有这些工作,那么为边因子分别执行部分相同的工作就没有意义了。

事实证明,如果我们先计算顶点的世界位置,然后分别为边因子和内部因子调用 TessellationEdgeFactor,Shader 编译器会决定不为每个边因子分叉单独的进程。我们最终得到一个计算所有内容的单一进程。在这种情况下,Shader 编译器确实优化掉了对 TessellationEdgeFactor 的重复调用。

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
float TessellationEdgeFactor (float3 p0, float3 p1) {
    #if defined(_TESSELLATION_EDGE)
        ...
    #else
        return _TessellationUniform;
    #endif
}

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;
    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;
}

此时我们可以细分三角形,但我们还没有利用这种能力做任何事情。曲面细分 演示了如何使用曲面细分来使表面变形。

This post is licensed under CC BY 4.0 by the author.