unity曲面细分着色器详解

 前言:本文翻译自catlikecoding上一篇十分详细的英文blog并修改了几处错误,逐行解释了如何在自己的shader中添加曲面细分支持,并给出了多种计算细分因子的方案以及它们的优缺点。

原文链接:https://catlikecoding.com/unity/tutorials/advanced-rendering/tessellation/


曲面细分-用于再分三角形

        创建hull和domain shaders

        再分三角形

        控制如何进行细分

此教程涵盖了如何为自定义的着色器添加曲面细分支持。使用了Flat and Wireframe Shading作为基础(用于画出三角形以显示细分结果)。

教程使用Unity 2017.1.0 版本制作。

1.Hulls and Domains

细分是一个将事物切割成更小部分的操作。在我们的例子中,我们将分割三角形。因此我们的最终目标是将一个大的三角形细分成占据同样空间的多个更小的三角形。这样做就可以为几何体添加更多细节,但此教程我们将只专注于细分的过程。

GPU可以将输入的三角形分割后再进行渲染,这样做的理由很多,例如当一个三角形一部分位于裁剪区域外面时,我们就可以细分它,然后舍弃其部分而非全部。unity的曲面细分阶段无法完全控制,但可以配置。曲面细分位于顶点着色器和片元着色器之间,但它并是仅仅添加一个函数就完事了,它需要hull program和domain program(见下图)。

1.1创建一个曲面细分着色器

第一步是创建一个启用了曲面细分的shader。我们把需要的代码放在MyTessellation.cginc中,并添加相应的inlude检查。

#if !defined(TESSELLATION_INCLUDED)
#define TESSELLATION_INCLUDED#endif

为了清晰的看到细分的结果,我们将使用Flat Wireframe Shader(这个shader见于文章开头的链接中),复制这个shader,将它重命名为Tessellation并改写它的菜单名。

Shader "Custom/Tessellation" { … }

要使用曲面细分,最小的shader target必须是4.6,如果我们没有手动地设置target,Unity将发出警告并自动使用这个等级。我们将为forward base和additive passes, 以及deferred pass添加曲面细分,保证inlude MyTessellation(放在include MyFlatWireframe之后)在这些pass中。

#pragma target 4.6…#include "MyFlatWireframe.cginc"
#include "MyTessellation.cginc"

 shadow pass能否使用曲面细分? 答案是在渲染阴影时也能够使用曲面细分,但这不在此教程的讨论范围内。

创建一个材质并将前面创建的shader挂上去,再在场景中创建一个quad,使用这个材质。我把这个quad调成了灰色,这样就不至于太亮,就像Flat Wireframe中的材质一样。

                                                                          A quad

我们将使用这个quad去测试我们的曲面细分着色器。注意它包含了两个等腰直角三角形,短边的长度是1,斜边是√2。

1.2Hull shaders

就像几何着色器一样,曲面细分着色器也是很灵活的,可以细分的图元包括三角形、四边形或等值线,我们必须告诉它需要细分的面是哪一种,并为它输入相应的数据。这就是Hull函数的工作,在MyTessellation中添加这样的函数,让我们从一个什么也不做的空函数开始。

void MyHullProgram () {}

Hull函数的操作对象是一个面片,因此面片(Patch)数据将作为参数传递给它,我们为它添加一个InputPatch来实现 。

void MyHullProgram (InputPatch patch) {}

面片是一个网格顶点数据的集合,和我们在几何着色器中为流参数做的一样,我们也得为输入面片的顶点指定数据类型,这里使用VertexData结构体。

void MyHullProgram (InputPatch<VertexData> patch) {}

不应该是InputPatch<InterpolatorsVertex>吗?  答:作为顶点着色器的下一个阶段,逻辑上来说,hull函数的输入应该与顶点着色器的输出相匹配(即插值寄存器中的顶点数据),确实应该如此,但此刻我们先忽略这个事实。

但处理三角形时,每个面片都包含三个顶点,这个数量应当被指定为InputPatch的第二个模板参数。

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

Hull函数的作用就是向曲面细分阶段传递所需要的顶点数据,尽管它的输入是整个面片,它也应当一次只输出一个顶点。因此对于面片中的每个顶点,hull函数都将被调用一次,此时就需要一个额外的参数来指定哪个顶点正在被调用,这个参数就是一个使用SV_OutputControlPointID语义的无符号整数。

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

接下来,只需简单的将patch看作一个数组,利用索引返回所需要的元素即可。

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

这看起来就像一个函数了,为包含此函数的其他每个pass都添加一个编译指令使它作为hull shader(#pragma hull MyHullProgram)。

#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
#pragma hull MyHullProgram
#pragma geometry MyGeometryProgram

完成上面的工作后,会产生几个编译错误,抱怨着我们没有正确地指明hull shader。因此,跟几何着色器一样,它需要一个属性来指明。首先,我们通过UNITY_domain的tri参数来显示地声明它处理的是三角形面片。

[UNITY_domain("tri")]
VertexData MyHullProgram …

这还不够,我们还需要显示地指明函数将要为每个面片输出三个控制点,即三角形的三个角对应的点。使用[UNITY_outputcontrolpoints(3)]来指明。

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
VertexData MyHullProgram …

但GPU创建新的三角形时,它需要知道我们想要的是顺时针还是逆时针定义三角形。如同unity中的其他任何三角形一样,我们要的是顺时针。通过将UNITY_outputtopology属性设置为triangle_cw来指明。

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
VertexData MyHullProgram …

GPU还需要知道如何来切割面片,通过 UNITY_partitioning属性来指明。这个属性定义了几种不同的切割方式,我们稍后再来研究它。在这里,我们先将它设置为整数模式:integer mode

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
VertexData MyHullProgram …

除了切割方式外,GPU还需要具体如何来切割面片,这个属性将不再是一个常量,它会根据不同的面片产生不同的结果。因此我们将提供一个函数来进行评估,即我们要说的patch constant function,让我们先假设已经有了这样一个函数,名为MyPatchConstantFunction。通过[UNITY_patchconstantfunc("MyPatchConstantFunction")]来指定。

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
VertexData MyHullProgram …

1.3Patch Constant Function

“面片如何被划分”是面片本身的一个属性,这就意味着patch constant function将会每个面片调用一次,而非和上面的hull函数一样,每个顶点调用一次。这就是它名为“常量”函数的原因,它将在整个面片中作为“常量”存在。实际上,这个函数是与hull函数(MyHullProgram)平行执行的子阶段操作。

 为确定如何去划分一个三角形,GPU将使用四个曲面细分因子(tessellation factors),三角形每个边各有一个因子,三角形内部也有一个因子。三个边因子将作为一个用SV_TessFactor语义修饰的float数组来进行传递,内部因子单独用SV_InsideTessFactor语义来指明。我们将创建一个结构体来存储这四个因子。

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

Patch constant function函数将接收一个面片作为输入参数,并计算并输出属于这个面片的四个细分因子。现在,我们就可以创建这个缺失的函数了。这里先简单的将每个因子都设为1,这将告诉曲面细分着色器不要去分割这个面片。

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.4Domain Shaders

完成上面工作后,shader编译器将提示我们不能只有曲面细分控制部分(tessellation control shader)而没有曲面细分评估部分(tessellation evaluation shader)。我们在上面所定义的hull shader只是让曲面细分着色器工作起来的一个部分而已。一旦曲面细分着色器决定了如何去分割面片,它将根据几何着色器去评估计算结果并最终生成细分后的三角形的顶点数据。因此,让我们先来创建一个domain shader,仍然从一个函数根来作为起点。

void MyDomainProgram () {}

Hull和domain都作用于同一个范围,即一个三角形。再次通过UNITY_domain 属性来声明之。

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

给domain函数提供细分因子和原始的面片,在这里我们使用OutputPatch。

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

当hull阶段指明了面片如何被细分之后,实际上还未生成任何新的顶点,而是给出了顶点的重心坐标。domain阶段将利用这些重心坐标来计算出最终的顶点。为达到这个目的,domain函数需要每个顶点执行一次,还需要为它提供所需要的重心坐标,重心坐标使用SV_DomainLocation语义来指明。

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

在函数内,将生成并最终的顶点数据。

[UNITY_domain("tri")]
void MyDomainProgram (TessellationFactors factors,OutputPatch<VertexData, 3> patch,float3 barycentricCoordinates : SV_DomainLocation
) {VertexData data;
}

为找到这个顶点的位置,我们必须在原本的三角形区域上使用重心坐标进行插值。重心坐标的X、Y、Z分量分别指明了第一、二、三个控制点的权重。

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

我们需要为所有顶点数据进行相同方式的插值,为此可以定义一个适用于任何向量大小的宏来更方便的完成这样的工作。

//	data.vertex =
//		patch[0].vertex * barycentricCoordinates.x +
//		patch[1].vertex * barycentricCoordinates.y +
//		patch[2].vertex * barycentricCoordinates.z;#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坐标。

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实例化(一种在GPU层面上减少Draw Call的优化手段)和曲面细分,没有必要去复制这些ID。因此为了避免编译错误,从所有pass中移除实例化相关的多重编译指令(multi-compile directives),这也会将实例化选项从shader的GUI中移除。

//			#pragma multi_compile_instancing
//			#pragma instancing_options lodfade force_same_maxcount_for_gl

有没有可能同时使用实例化和曲面细分?  目前来看还没有这样的情况。我们需要知道GPU实例化是一种对于渲染同一个物体很多次很有用的优化方法,而曲面细分则是一个很昂贵的用于增加表面细节的操作,两者进行组合不是很切合实际。如果你想为使用曲面细分的物体使用实例化,可以使用LOD group来实现,如将使用了曲面细分但未实例化的材质放在LOD 0中,将其他使用了实例化但未使用曲面细分的材质放在LOD的其他level中。

不管怎么说,我们现在有了一个新的顶点,它将在domain阶段之后被送往几何着色器阶段或插值器中(如果没有几何着色器,就直接进行光栅化插值),但是这些阶段都希望以InterpolatorsVertex数据作为它们的输入,而非VertexData。为解决这个问题,我们将使用domain shader来接管原本顶点着色器的职责,通过在其中调用MyVertexProgram来实现,并和使用其他任何函数一样,返回其结果。

[UNITY_domain("tri")]
InterpolatorsVertex MyDomainProgram (TessellationFactors factors,OutputPatch<VertexData, 3> patch,float3 barycentricCoordinates : SV_DomainLocation
) {…return MyVertexProgram(data);
}

现在,我们就可以在所有pass中加上domain shader。

#pragma hull MyHullProgram
#pragma domain MyDomainProgram

完成这些后,依然会报错,这意味着我们的工作还未结束。

1.5Control Points

MyVertexProgram只需要被调用一次,而我们所做的只是改变了调用的位置。但是我们仍然必须指明vertex program是在hull shader之前的顶点着色器阶段执行的。我们可以在顶点着色器中什么也不做,仅仅定义一个原封不动地传递顶点数据的函数。

VertexData MyTessellationVertexProgram (VertexData v) {return v;
}

此刻起,所有pass都将使用这个函数作为其vertex program阶段。

#pragma vertex MyTessellationVertexProgram

这将产生另一个编译错误,指示我们重复使用了POSITION语义。为解决这个问题,我们将为vertex program使用一个替代的输出结构体,使用INTERNALTESSPOS作为其顶点坐标的语义。

结构体的其他部分与VertexData一样(除了不使用实例化ID)。这些顶点数据将被用作曲面细分过程的控制点,因此我们将其命名为TessellationControlPoint。

struct TessellationControlPoint {float4 vertex : INTERNALTESSPOS;float3 normal : NORMAL;float4 tangent : TANGENT;float2 uv : TEXCOORD0;float2 uv1 : TEXCOORD1;float2 uv2 : TEXCOORD2;
};

修改MyTessellationVertexProgram,使之将顶点数据放入控制点结构体(TessellationControlPoint)中,并返回这个结构体。

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。

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

Domain函数的参数类型也需要进行替换。

InterpolatorsVertex MyDomainProgram (TessellationFactors factors,OutputPatch<TessellationControlPoint, 3> patch,float3 barycentricCoordinates : SV_DomainLocation
) {…
}

到这里,我们终于有了一个正确的曲面细分shader,它应该能够通过编译并和之前一样能够渲染quad了。但它还没有进行面片的划分,因为我们之前把细分的因子都设为了1。

                                                                                                package下载链接:      unitypackage

2细分三角形(Subdividing Triangles)

整个曲面细分阶段设置的关键就是我们可以分割面片,这允许我们使用更小的三角形集合来替换原来的大的三角形。接下来要做的,就是控制曲面细分着色器去分割三角形。

2.1细分因子(Tessellation Factors)

一个三角形如何被分割取决于它的细分因子,我们已经在MyPatchConstantFunction中定义了这些因子。当时我们把所有因子都设置为1,这在视觉上没有产生任何变化。Hull、tessellation和domain阶段都正常工作,但他们只是传递了原本的顶点数据而已,并没有生成任何新的顶点。现在,我们把所有因子都设置为2。

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

现在可以看到三角形被分割了,它的每个边都被分成了两个子边,导致每个三角形边都产生了3个新的顶点,并且在三角形的中心也产生了一个顶点。每条原始边都产生了两个新的三角形,即原三角形被分割成了6个更小的子三角形。现在,我们的quad拥有了12个三角形了。

如果把细分因子设置为3,每条边将被分割成3条子边,但这样就不会在三角形中心生成控制点了,取而代之的是在原三角形内部产生了3个新的点——生成了一个小的内三角形。接下来,外边缘将用三角形带来连接到这个内三角形(如图)。

                                                             Tessellation factors 3.

当细分因子是偶数时,就会产生一个中心点;当细分因子是奇数时,就会产生一个内三角形。当细分因子变大时,就会产生多个内嵌的内三角形。每向中心前进一步,三角形边被划分的数量就减少2,直到划分数量为1或者0停止。

 

 

Tessellation factors 4–7. 

 2.2不同的边缘和内部因子

三角形如何被划分取决于其内部细分因子,边因子可以控制外边被分割的数量,但无法影响到内三角形的划分。为了验证这一点,我们把内因子设置为7,把边缘因子设置为1。

f.edge[0] = 1;
f.edge[1] = 1;
f.edge[2] = 1;
f.inside = 7;

 

                                                      Factor 7 inside, but 1 outside.

效果上来看,就像以全部因子为7进行划分后,再丢掉其外环,再用边缘因子对外边进行单独划分、产生三角形带,再把这个三角形带与内三角形的划分结果合在一起一样。

边缘因子比内因子大也是可以的,例如把边因子设为7,内因子设为1。

                                                       Factor 1 inside, but 7 outside.

在这个例子中,内因子被迫被当成了2,否则就不会生成任何三角形了。

使用不同的边因子会怎样? 这是被允许的,但是shader编译器不太喜欢我们使用硬编码这样做。使用某些值时可能会出现编译错误。稍后我们将看到不同的边缘因子有什么用。

2.3可变因子(Variable Factors)

硬编码的细分因子并不是非常有用,因此让我们从单一的统一因子开始,使之变得可配置。

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。无论我们想用多高的细分因子,硬件对于每个图元都有64的分割上限。

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

为了能够编辑这个因子,向 MyLightingShaderGUI 添加一个 DoTesselling 方法,以便在它自己的部分中显示它。

	void DoTessellation () {GUILayout.Label("Tessellation", EditorStyles.boldLabel);EditorGUI.indentLevel += 2;editor.ShaderProperty(FindProperty("_TessellationUniform"),MakeLabel("Uniform"));EditorGUI.indentLevel -= 2;}

在OnGUI中的rendering mode和wireframe section之间调用此函数,并使之在所需属性存在时调用。

	public override void OnGUI (MaterialEditor editor, MaterialProperty[] properties) {…DoRenderingMode();if (target.HasProperty("_TessellationUniform")) {DoTessellation();}if (target.HasProperty("_WireframeColor")) {DoWireframe();}…}

 2.4分数因子(Fractional Factors)

在上面的例子中可以看到,即使我们使用了浮点数作为细分因子,我们仍会得到一个等效的整数细分结果。这是因为我们使用的是整数划分模式(integer partitioning mode)。整数模式能够使我们很清晰地看到细分是如何进行的,但它不能够在细分等级之间进行平滑地过渡。幸运的是,还有分数划分模式可以用。让我们把Unity_partitioning属性改成fractional_odd试试看。

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("fractional_odd")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
TessellationControlPoint MyHullProgram …

Fractional odd partitioning.

当使用整数的奇数因子时,odd的划分模式与integer划分模式没什么不同。但当因子在奇数之间过渡时,随着小数部分的增大或减小,会产生额外的边划分,并随之扩大到下一个整数等级,或收缩至合并。这就意味着边不再被分割为等长的段,这样做的好处在于可以在划分等级之间平滑的过渡。

也可以把划分模式设置为fractional_even,它的工作方式与odd类似,只不过它是基于偶数因子的。

Fractional even partitioning.

odd mode更加常用,因为可以把因子最小设为1,而even模式下因子最小为2。

                                             package下载链接:unitypackage

3Tessellation Heuristics

在我们使用曲面细分时,不得不问自己这样一个问题:最好的划分因子是什么? 事实上,这个问题并没有一个客观的答案。总的来说,我们最好应该设置一些度量基准,以便在拓展时有更好的结果。在接下来的教程中,我们会提出两种简单的方案以供参考。

3.1Edge Factors

尽管我们必须为每条边都提供细分因子,但不必直接根据边来确定因子。例如,可以根据顶点来指定因子大小,然后在作用于边时求它们的平均即可,这样做是因为因子可能是存储于一张纹理中的。不管怎样,为因子的计算提供一个单独的函数是很方便的,给函数传递边的两个端点,然后返回对应的因子值。创建这样一个函数,此处我们先简单地返回一个统一值作为示例。

float TessellationEdgeFactor (TessellationControlPoint cp0, TessellationControlPoint cp1
) {return _TessellationUniform;
}

然后在 MyPatchConstantFunction中调用这个函数,内部因子直接求边因子的平均即可。

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 = (f.edge[0] + f.edge[1] + f.edge[2]) * (1 / 3.0);return f;
}

 3.2Edge Length

边缘细分因子决定着边如何被划分,因此根据边的长度来进行划分也是可以的。例如,我们可以指定一个边缘长度,当一条边比这个值大时,我们就根据这个长度来划分它。为此,先声明一个值(_TessellationEdgeLength)。

float _TessellationUniform;
float _TessellationEdgeLength;

添加属性,使用0.1-1的范围,设置默认值为0.5(这些值都是在世界空间中的单位长度)。

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

接下来,我们还需要一个shader feature编译指令来生成shader变体。这样我们就可以在统一的因子和基于边长的因子之间切换了。为所有pass添加所需要的编译指令,使用_TESSELLATION_EDGE作为关键字。

#pragma shader_feature _TESSELLATION_EDGE

接下来对shaderGUI进行修改,先添加一个enum。

	enum TessellationMode {Uniform, Edge}

接下来,调整DoTessellation方法,使用一个enum变量, 使它可以在两种模式之间切换。

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

 接下来,就是我们的重点:修改TessellationEdgeFactor函数——当 _TESSELLATION_UNIFORM被defined时,根据输入的两点的世界坐标计算两点之间的边长,然后除以我们定义的边长,就得到了这个边的划分因子。

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;#elsereturn _TessellationUniform;#endif
}

这样,因子大小就能够随着quad的缩放(scale)变化了(如图)。

                                            Different quad scales, same desired edge length.

因为我们现在使用的因子大小是基于边长的,因此对角线的因子更大,因为它比直角边更长。当我们对quad使用不统一的缩放时,不同边有不同因子的效果将更加明显(如图)。

不论用哪种因子计算逻辑,都需要保证的一点是:不同面片之间的共用边一定要产生相同的细分因子,否则,生成的顶点就可能不沿着边,从而导致网格出现缝隙。在上面的例子中,我们对所有边都使用了相同的计算逻辑,唯一的不同就是控制点参数的顺序。由于浮点数的限制,可能在技术上产生不同的因子,但问题不大,产生的误差是在可忽略范围内的。

3.3Edge Length in Screen Space

虽然我们现在可以控制三角形在世界坐标中的边长了,但有时我们更关注它在屏幕空间中的表现。我们进行曲面细分的根本原因是我们现在需要的时候为网格增加更多三角形,但如果网格在屏幕空间中已经非常小了(即离摄像机很远),就不需要再为它进行细分。因此接下来让我们用屏幕空间边长来替代世界空间的边长。

首先,需要修改我们的之前定义的边长属性,要和屏幕空间边长进行计算,它的单位就不能再是世界空间单位长度,而应该是像素。所以把它的范围设置到5-100。

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

 要用屏幕空间边长来替换世界空间边长,首先需要把点从世界空间转换到裁剪空间(clip space)下,然后除以W分量使它们转换到NDC,接着用X、Y分量来计算两点之间的距离。

//		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);float4 p0 = UnityObjectToClipPos(cp0.vertex);float4 p1 = UnityObjectToClipPos(cp1.vertex);float edgeLength = distance(p0.xy / p0.w, p1.xy / p1.w);return edgeLength / _TessellationEdgeLength;

 现在,我们得到了NDC空间下的计算结果,但我们都知道NDC是一个边长为2的标准立方体,为了把计算结果转换成像素大小,我们还需要使之根据屏幕分辨率进行一次缩放。这是一次不统一的缩放,需要X、Y分开来进行,但通常我们只需要让它们都乘上屏幕高度就够了。

// return edgeLength * _ScreenParams.xy / _TessellationEdgeLength;
return edgeLength * _ScreenParams.y / _TessellationEdgeLength;

                                 Same world size, different screen size

现在,位置、旋转、缩放、与摄像机的距离都能够影响到三角形边的划分了。物体在移动时,它的细分结果也会随时改变。

不应该乘上屏幕高度的一半吗? 我们知道NDC空间是-1到1的,因此NDC中求得的边长乘上高度后会变成实际屏幕空间长度的两倍。但这其实无伤大雅,使用屏幕高度与之相乘的原因是让结果能够依赖于屏幕大小,边长能否与屏幕精确匹配实际上并不重要。

3.4Using the View Distance

仅仅将细分因素依赖于边的视觉长度会有明显的缺陷:实际上很长的边(在模型空间中),只要它透视过后在屏幕上看起来很短,那它就不会被细分,与之相反的边则会被细分会多次。这样的结果对于增加模型细节或优化轮廓来说是不够合理的(例如上图中,第一个quad之外的quad的横边都没有被细分,而竖着的边被细分很多次)。

一个改进的方案是,仍然倒回去使用世界空间的边长,但是这次要根据边与相机之间的距离来调整因子大小。利用边的中点来计算边与相机之间的距离。

//		float4 p0 = UnityObjectToClipPos(cp0.vertex);
//		float4 p1 = UnityObjectToClipPos(cp1.vertex);
//		float edgeLength = distance(p0.xy / p0.w, p1.xy / p1.w);
//		return edgeLength * _ScreenParams.y / _TessellationEdgeLength;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);

这样我们就仍可以保证细分程度与其显示的大小相关了。但要注意的是,这个方案的计算逻辑将不再与屏幕像素相关,因此改变相机的fov也不会对细分结果有任何影响。因此对于那些使用了zoom in和zoom out的游戏(如狙击枪瞄准镜缩放),这个简单的方案并不适用。

 Based on edge length and view distance.

3.5Using the Correct Inside Factor

说完了边缘因子的计算方式,现在我们来关注一下内部因子。上面看来这些计算方式都工作的挺好,但某些情况下,这种简单的求平均得到的内部因子会使结果看起来很奇怪。在OpenGl核心下,统一缩放的quad没什么问题,但不统一缩放的quad问题就比较明显了。

Cube with incorrect inner factors. 

在这个立方体的例子中,组成一个面的两个三角形得到了非常不一样的内部因子。立方体与quad是唯一不同是其三角形的顶点顺序——Unity默认的cube并没有使用对称的三角形布局,但quad却用了。这就表明边的顺序对内因子的计算结果有很大的影响。但我们用的是三个外边因子的平均值作为内因子,似乎并不与顺序有关。那么问题出在了哪里呢?

让我们做一些看起来没什么意义的事情试试——在计算内部因子时再次调用 TessellationEdgeFactors 函数(而非直接使用上面得到的结果)。逻辑上来说,我们只是做了两次相同的计算而已,并没有什么区别,而且编译器肯定会对其进行优化。

//    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.

但从结果上来看,它似乎产生了不同。这是怎么回事?

上面我们提到过,patch constant function是与hull shader平行调用的。实际情况比这更复杂一些——shader编译器还能够平行的计算那些边缘因子。即MyPatchConstantFunction函数里面的函数调用也会并行执行,三个函数调用过程都结束后,才会进行内因子的计算。

但不管它是否并行计算,按理说都不应该对我们的结果产生影响。但不幸的是,OpenGL Core生成的代码中有一个bug,导致在计算内因子时只使用了第三个边因子,即它只是访问了2这个索引三次,而非0,1,2索引各一次。所以得到的结果就是内因子与第三条边的因子相同。

但在上面我们修改过的例子中,shader编译器始终会遵循并行优先的原则,这就导致重复计算不会被优化。即我们传递顶点坐标、计算距离、除以系数(_TessellationEdgeLength * viewDistance)的过程执行了两次。

对此我们可以进行一些修改:把计算世界空间坐标的过程从TessellationEdgeFactor函数中分离出来。这样,编译器就不会再对MyPatchConstantFunction启动线程进行并行运算了,因此就可以正常的进行Tessellation优化。(注意TessellationEdgeFactor函数的参数也由TessellationControlPoint修改为了float3)

float TessellationEdgeFactor (float3 p0, float3 p1) {#if defined(_TESSELLATION_EDGE)
//		float3 p0 = mul(unity_ObjectToWorld, cp0.vertex).xyz;
//		float3 p1 = mul(unity_ObjectToWorld, cp1.vertex).xyz;…#elsereturn _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;
}

到这里,我们就可以正确的分割三角形了。关于曲面细分实际的应用例子,可以查阅下一篇blog:Surface Displacement 

                                package下载地址:unitypackage

pdf下载地址:PDF


Published by

风君子

独自遨游何稽首 揭天掀地慰生平