DirectX11 With Windows SDK–33 曲面细分阶段(Tessellation)

前言

曲面细分是Direct3D 11带来的其中一项重要的新功能。它引入了两个可编程着色器阶段以及一个固定的镶嵌处理过程。简单来说,曲面细分技术可以将几何体细分为更小的三角形,并以某种方式把这些新生成的顶点偏移到合适的位置,从而以增加三角形数量的方式丰富网格细节。但为什么不在创建网格之初就直接赋予它高模(high-poly,高面数多边形)的细节呢?以下是使用曲面细分的3个理由:

  1. 基于GPU实现动态LOD(Level of Detail,细节级别)。可以根据网格与摄像机的距离或依据其他因素来调整其细节。比如说,若网格离摄像机过远,则按高模的规格对它进行渲染将是一种浪费,因为在那个距离我们根本看不清网格的所有细节。随着物体与摄像机之间距离的拉紧,我们就能连续地对它镶嵌细分,以增加物体的细节。
  2. 物理模拟与动画特效。我们可以在低模(low-poly,低面数多边形)网格上执行物理模拟与动画特效的相关计算,再以镶嵌画处理手段来获取细节上更加丰富的网格。这种降低物理模拟与动画特效计算量的做法能够节省不少的计算资源。
  3. 节约内存。我们可以在各种存储器(磁盘、RAM、VRAM)中保存低模网格,再根据需求用GPU动态地对网格进行镶嵌细分。

曲面细分技术涉及到的三个阶段都是可选的,但如果要使用曲面细分,这三个阶段都是必须要经历的。

学习目标:

  1. 了解曲面细分所用的面片图元类型。
  2. 理解曲面细分阶段中的每个步骤都做了什么,它们所需的输入及输出又分别是哪种数据
  3. 通过编写外壳着色器与域着色器程序来对几何图形进行镶嵌化细分
  4. 熟悉不同的细分策略,以便于在镶嵌化处理的时候选择出最适当的方案。除此之外,还需要了解硬件曲面细分的性能
  5. 学习贝塞尔曲线与贝塞尔曲面的数学描述,并在曲面细分阶段将它们予以实现

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

曲面细分的图元类型

在进行曲面细分时,我们并不向IA(输入装配)阶段提交三角形,而是提交具有若干控制点面片。Direct3D支持具有1~32个控制点的面片,并以下列图元类型进行描述:

D3D_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST	= 33,
D3D_PRIMITIVE_TOPOLOGY_2_CONTROL_POINT_PATCHLIST	= 34,
D3D_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST	= 35,
...
D3D_PRIMITIVE_TOPOLOGY_31_CONTROL_POINT_PATCHLIST	= 63,
D3D_PRIMITIVE_TOPOLOGY_32_CONTROL_POINT_PATCHLIST	= 64,

由于可以将三角形看作是拥有3个控制点的三角形面片(D3D_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST),所以我们依然可以提交需要镶嵌化处理的普通三角形网格。对于简单的四边形面片而言,则只需要提交4个控制点的面片(D3D_PRIMITIVE_4_CONTROL_POINT_PATCH)即可。这些面片最终也会在曲面细分阶段经过镶嵌化处理而分解为多个三角形。

注意:D3D_PRIMITIVE_TOPOLOGY枚举项描述输入装配阶段中的顶点类型,而D3D_PRIMITIVE枚举项则描述的是外壳着色器的输入图元类型。

那么,具有更多控制点的面片又有什么用处呢?控制点的概念来自于特定种类数学角度上特定曲线或曲面的构造过程。如果在类似于Adobe Illustrator这样的绘图程序中使用过贝塞尔曲线工具,那读者一定会知道要通过控制点才能描绘出曲线形状。在数学上,可以利用贝塞尔曲线来生成贝塞尔曲面。举个例子,我们可以用9个控制点或16个控制点来创建一个贝塞尔四边形面片,所用的控制点越多,我们对面片形状的控制也就越随心所欲。因此,这一切图元控制类型都是为了给这些不同种类的曲线、曲面的绘制提供支持。

曲面细分与顶点着色器

在我们向渲染管线提交了面片的控制点后,它们就会被推送至顶点着色器。这样一来,在开始曲面细分的时候,顶点着色器就彻底沦为“处理控制点的着色器”。正因为如此,我们还能在曲面细分开始之前,对控制点进行一些调整。一般来说,动画与物理模拟的计算工作都会在对几何体进行镶嵌化处理之前的顶点着色器中以较低的频次进行(镶嵌化处理之后,顶点增多,处理的频次也将随之增加)。

外壳着色器

外壳着色器是由两种着色器共同组成的:常量外壳着色器(Constant Hull Shader)控制点外壳着色器(Control Point Hull Shader)

常量外壳着色器

常量外壳着色器会针对每个面片统一进行处理(即每处理一个面片就被调用一次)。它的任务是输出当前网格曲面细分因子,而且必须要输出。曲面细分因子指示了在曲面细分阶段中将面片镶嵌处理后的份数,以及怎么进行细分。它由两个输出系统值所表示:SV_TessFactorSV_InsideTessFactor,这两个系统值属于float或float数组的类型,具体取决于输入装配阶段定义的图元类型。常量外壳着色器的输出被限制在128个标量(如32个4D单精度浮点向量),这意味着除了系统值,你还可以额外添加输出信息供每个面片所使用。下面是一个具有3个控制点的四边形面片示例,我们通过常量缓冲区来为其设置各个方面的细分程度:

struct QuadPatchTess
{
    float EdgeTess[4] : SV_TessFactor;
    float InsideTess[2] : SV_InsideTessFactor;
    
    // 可以在下面为每个面片附加所需的额外信息
};

QuadPatchTess QuadConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
    QuadPatchTess pt;
    
    pt.EdgeTess[0] = g_QuadEdgeTess[0];			// 四边形面片的左侧边缘
    pt.EdgeTess[1] = g_QuadEdgeTess[1];			// 四边形面片的上侧边缘
    pt.EdgeTess[2] = g_QuadEdgeTess[2];			// 四边形面片的右侧边缘
    pt.EdgeTess[3] = g_QuadEdgeTess[3];			// 四边形面片的下册边缘
    pt.InsideTess[0] = g_QuadInsideTess[0];		// u轴(四边形内部细分的列数)
    pt.InsideTess[1] = g_QuadInsideTess[1];		// v轴(四边形内部细分的行数)
    
    return pt;
}

其中InputPatch<VertexOut, 4>定义了控制点的数目和信息。前面提到,控制点首先会传至顶点着色器,因此它们的类型由顶点着色器的输出类型VertexOut来确定。在此例中,我们的面片拥有4个控制点,所以就将InputPatch模板第二个参数指定为4。系统还通过SV_PrimitiveID语义提供了面片的ID值,此ID唯一地标识了绘制调用过程中的各个面片,我们可以根据具体的需求来运用它。

但按左上右下的顺序来控制边缘细分是建立在使用下面的顶点摆放顺序而言的:

XMFLOAT3 quadVertices[4] = {
    XMFLOAT3(-0.54f, 0.72f, 0.0f),	// 左上角
    XMFLOAT3(0.54f, 0.72f, 0.0f),	// 右上角
    XMFLOAT3(-0.54f, -0.72f, 0.0f),	// 左下角
    XMFLOAT3(0.54f, -0.72f, 0.0f)	// 右下角
};

四边形面片(quad)进行镶嵌化处理的过程由两个构成:

  1. 4个边缘曲面细分因子控制着对应边缘镶嵌后的份数
  2. 两个内部曲面细分因子指示了如何来对该四边形面片的内部进行镶嵌化处理(其中一个针对四边形的横向维度,另一个则作用于四边形的纵向维度)

三角形面片(tri)进行镶嵌化处理的过程同样分为两部分:

  1. 3个边缘曲面细分因子控制着对应边缘镶嵌后的份数
  2. 一个内部曲面细分因子指示着三角形面片内部的镶嵌份数。

等值线(isoline)进行镶嵌化处理的过程如下:

  1. 2个边缘细分因子控制着等值线如何进行镶嵌。第一个值暂时不知道作用(忽略),第二个用于控制两个相邻控制点之间分成多少段。

Direct3D 11硬件所支持的最大曲面细分因子为64(D3D11_TESSELLATOR_MAX_TESSELLATION_FACTOR).如果把所有的曲面细分因子都设置为0,则该面片会被后续的处理阶段所丢弃。这就使得我们能够以每个面片为基准来实现如视锥体剔除与背面剔除这类优化。

  1. 如果面片根本没有出现在视锥体范围内,那么就能将它从后续的处理中丢弃(倘若已经对该面片进行了镶嵌化处理,那么其细分后的各三角形将在三角形裁剪期间被抛弃)
  2. 如果面片是背面朝向的,那么就能将其从后面的处理过程中丢弃(如果该面片已经过了镶嵌化处理,则其细分后的所有三角形会在光栅化阶段的背面剔除过程中被抛弃)

一个问题自然而然地复现出来:到底应该执行几次镶嵌化处理才合适?前面提到,曲面细分的基本想法就是为了丰富网格的细节。但是,如果用户对此无感,我们就不需要对它增添细节了。以下是一些确定镶嵌次数的常用衡量标准。

  1. 根据与摄像机之间的距离:物体与摄像机的距离越远,能分辨的细节就越少。因此,我们在两者距离较远的时候渲染物体的低模版本,并随着两者逐渐接近而逐步对物体进行更加细致的镶嵌化细分。
  2. 根据占用屏幕的范围:可以先估算出物体覆盖屏幕的像素个数。如果数量比较少,则渲染物体的低模版本。随着物体占用屏幕范围的增加,我们便可以逐渐增大镶嵌化细分因子。
  3. 根据三角形的朝向:三角形相对于观察者的朝向也被纳入考虑的范畴之中。位于物体轮廓边线上的三角形势必比其他位置的三角形拥有更多的细节。
  4. 根据粗糙程度:粗糙不平的表面较光滑的表面需要进行更为细致的曲面细分处理。通过对表面纹理进行检测可以预算出相应的粗糙度数据,继而来决定镶嵌化处理的次数。

[Story10(可点击)]给出了以下几点关于性能的建议。

  1. 如果曲面细分因子为1(这个数字意味着该面片不必细分),那么就考虑在渲染此面片时不对它进行细分处理;否则,便会在曲面细分阶段白白浪费GPU资源,因为在此阶段并不对其执行任何操作。
  2. 考虑到性能又涉及GPU对曲面细分的具体实现,所以不要对小于8个像素这种过小的三角形进行镶嵌化处理。
  3. 使用曲面细分技术时要采用批绘制调用(batch draw call,即尽量将曲面细分任务集中执行)(在绘制调用之间往复开启、关闭曲面细分功能的代价极其高昂)。

控制点外壳着色器

控制点外壳着色器以大量的控制点作为输入与输出,顶点着色器每输出一个控制点,此着色器都会被调用一次。控制点外壳着色器的应用之一是改变曲面的表示方式,比如把一个普通的三角形(向渲染管线提交的3个控制点)转换为3次贝塞尔三角形面片。例如,假设我们像平常那样利用三角形对网格进行建模,就可以通过控制点外壳着色器,把这些三角形转换为具有10个控制点的高阶三次贝塞尔三角形面片。新增的控制点不仅会带来更加丰富的细节,而且能将三角形面片镶嵌细分为用户所期望的份数。这一策略被称之为N-patches方法(法线—面片方法,normal-patches scheme)或PN三角形方法(即(曲面)点—法线三角形方法,point-normal triangles,简写为PN triangles scheme)[Vlachos]。由于这种方案只需用曲面细分技术来改进存在的三角形网格,且无需改动美术制作流程,所以实现起来比较方便。对于本章前面两个演示案例来说,控制点外壳着色器仅充当一个简单的传递着色器,它不会对控制点进行任何的修改。

注意:驱动程序可能会对传递着色器进行检测与优化。

struct VertexOut
{
    float3 PosL : POSITION;
};

typedef VertexOut HullOut;

// Tessellation_Quad_Integer_HS.hlsl

[domain("quad")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(4)]
[patchconstantfunc("QuadConstantHS")]
[maxtessfactor(64.0f)]
float3 HS(InputPatch<VertexOut, 4> patch, uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID) : POSITION
{
    return patch[i].PosL;
}

通过InputPatch参数可以将面片的所有控制点都传至外壳着色器中。系统值SV_OutputControlPointID索引的是正在被外壳着色器处理的输出控制点。值得注意的是,输入的控制点数量与输出的控制点数量未必相同。例如,输入的面片可能仅含有4个控制点,而输出的面片却能够拥有16个控制点;这些多出来的控制点可以由输入的4个控制点所衍生。

上面的控制点外壳着色器还用到了以下几种属性。

  1. domain:面片的类型。可选用的参数有tri(三角形面片)、quad(四边形面片)或isoline(等值线)

  2. partioning:指定了曲面细分的细分模式。

    • integer:新顶点的添加或移除依据的是上取整的函数。例如我们将细分值设为3.25f时,实际上它将会细分为4份。这样一来,在网格随着曲面细分级别而改变时,会容易发生明显的跃变。
    • 非整型曲面细分(fractional_even/fractional_odd):新顶点的增加或移除取决于曲面细分因子的整数部分,但是细微的渐变“过渡”调整就要根据细分因子的小数部分。当我们希望将粗糙的网格经曲面细分而平滑地过渡到具有更加细节的网格时,该参数就派上用场了。
    • pow2:目前测试的时候行为和integer一致,不知道什么原因。这里暂时不讲述。
  3. outputtopology:通过细分所创的三角形的绕序

    • triangle_cw:顺时针方向的绕序
    • triangle_ccw:逆时针方向的绕序
    • line:针对线段的曲面细分
  4. outputcontrolpoints:外壳着色器执行的次数,每次执行都输出1个控制点。系统值SV_OutputControlPointID给出的索引标明了当前正在工作的外壳着色器所输出的控制点。

  5. patchconstantfunc:指定常量外壳着色器函数名称的字符串

  6. maxtessfactor:告知驱动程序,用户在着色器中所用的曲面细分因子的最大值。如果硬件知道了此上限,就可以了解曲面细分所需的资源,继而在后台对此进行优化。Direct3D 11硬件支持的曲面细分因子最大值为64

镶嵌器阶段

程序员无法对镶嵌器这一阶段进行任何控制,因为这一步的操作全权交给硬件处理。此环节会基于常量外壳着色器程序所输出的曲面细分因子,对面片进行镶嵌化处理。

四边形面片的曲面细分示例

  1. integer模式

  1. fractional_odd模式:

  1. fractional_even模式:

三角形面片的曲面细分示例

域着色器

镶嵌器阶段会输出新建的所有顶点与三角形,在此阶段所创建的顶点,都会逐一调用域着色器进行后续处理。随着曲面细分功能的开启,顶点着色器便化身为“处理每个控制点的顶点着色器”,而外壳着色器的本质则为“针对已经镶嵌化的面片进行处理的顶点着色器”。特别是,我们可以在此将经过镶嵌化处理的面片顶点投射到齐次裁剪空间。

首先是三角形面片,域着色器以曲面细分因子(还有一些来自常量外壳着色器所输出的每个面片的附加信息)、控制点外壳着色器所输出的所有面片控制点、镶嵌化处理后的顶点位置参数(以重心坐标系(alpha, beta, gamma)的形式表示)作为输入。注意,域着色器给出的并不是镶嵌化处理后的实际顶点位置,而是这些点位于面片域空间内的参数坐标。是否利用这些参数坐标及控制点来求取真正的3D顶点位置,完全取决于用户自己。下面展示了前面的例子显示的三角形所用到的域着色器代码:

struct VertexOut
{
    float3 PosL : POSITION;
};

typedef VertexOut HullOut;

// Tessellation_Triangle_DS.hlsl

[domain("tri")]
float4 DS(TriPatchTess patchTess,
    float3 weights : SV_DomainLocation,
    const OutputPatch<HullOut, 3> tri) : SV_POSITION
{
    // 重心坐标系插值
    float3 pos = tri[0].PosL * weights[0] +
        tri[1].PosL * weights[1] +
        tri[2].PosL * weights[2];
    
    return float4(pos, 1.0f);
}

将三角形面片以重心坐标系作为输出的原因,很可能是因为贝塞尔三角形面片都是用重心坐标来定义所导致的。

而四边形面片的顶点位置参数以(u, v)的形式表示,前面例子的四边形所用的域着色器代码如下:

struct VertexOut
{
    float3 PosL : POSITION;
};

typedef VertexOut HullOut;

// Tessellation_Quad_DS.hlsl

[domain("quad")]
float4 DS(QuadPatchTess patchTess,
    float2 uv : SV_DomainLocation,
    const OutputPatch<HullOut, 4> quad) : SV_POSITION
{
    // 双线性插值
    float3 v1 = lerp(quad[0].PosL, quad[1].PosL, uv.x);
    float3 v2 = lerp(quad[2].PosL, quad[3].PosL, uv.x);
    float3 p = lerp(v1, v2, uv.y);
    
    return float4(p, 1.0f);
}

贝塞尔曲线(Bézier Curves)

这部分借用GAMES101来说明。并且这里讲的贝塞尔曲线所用的算法是由Pierre Bézier和Paul de Casteljau所提出的。

现在我们先从二阶贝塞尔曲线开始,下面有3个非共线的控制点b0b1b2

接下来我们用线性插值的方式,从b0b1方向的线段使用参数t来确定其中一点,记为\(\mathbf{b_{0}^{1}}\)

然后从b1b2方向的线段使用同样的参数t来确定另一点,记为\(\mathbf{b_{1}^{1}}\)

\(b_{0}^{1}\)\(b_{1}^{1}\)连接起来,问题降级为一阶贝塞尔曲线(直线)。我们对其再使用一次参数t的线性插值即可得到在参数t下该贝塞尔曲线的对应一点\(\mathbf{b_{2}^{0}}\)

我们将t[0, 1]的所有情况都求出来,就可以得到一条光滑的贝塞尔曲线。

三阶的贝塞尔曲线需要用到4个控制点,但做法也是类似的。首先求出b0b1b1b2b2到b3的线段在t时刻下的插值点\(\mathbf{b_{0}^{1}}, \mathbf{b_{1}^{1}}, \mathbf{b_{2}^{1}}\),此时问题就被转化成了这三个控制点下的二阶贝塞尔曲线。不断降阶最终算出目标点即可。

可以看到,三阶贝塞尔曲线需要进行6次插值运算,二阶贝塞尔曲线则需要进行3次插值运算。以此类推,我们可以知道n阶贝塞尔曲线需要n(n+1)/2次运算

计算过程

下图阐述了二阶贝塞尔曲线的计算过程

观察b0b1b2的各项系数,可以发现它们满足二项式定理。我们也可以用下面的一个金字塔来描述

从底端的这些控制点选取其中一个然后不断往上走,最终走到顶端点的过程中,如果走过一段朝着右上方向的路径,则给该控制点乘上因子(1-t),而朝着左上方向的路径则给该控制点乘上因子t。比如从b0走到顶端的项为\(t^3 b_{0}\)。我们将这所有8条路径都加起来就可以得到最后的结果:

\[\mathbf{b_0^3} = (1-t)^3\mathbf{b_0} + 3t(1-t)^2\mathbf{b_1} + 3t^2 (1-t)\mathbf{b_2} + t^3\mathbf{b_3}
\]

更抽象的,我们可以用伯恩斯坦函数的形式来表示:

\[\mathbf{b^n}(t)=\sum_{i=0}^{n} C_{n}^{i}t^{i}(1-t)^{n-i}\cdot\mathbf{b_i}
\]

对于三阶贝塞尔曲线而言,曲线端点为:

\[\mathbf{b^3}(0)=\mathbf{b_0};\;\;\mathbf{b^3}(1)=\mathbf{b_3}
\]

而三次伯恩斯坦函数的导数为:

\[\begin{align}
{B_{0}^{3}}'(t)&=-3(1-t)^2\\
{B_{1}^{3}}'(t)&=3(1-t)^2-6t(1-t)\\
{B_{2}^{3}}'(t)&=6t(1-t)-3t^2\\
{B_{3}^{3}}'(t)&=3t^2\\
\end{align}
\]

因此,对3次贝塞尔曲线求导的结果为:

\[\mathbf{{b^3}'(t)}=-3(1-t)^2\mathbf{b_0}+[3(1-t)^2-6t(1-t)]\mathbf{b_1}+[6t(1-t)-3t^2]\mathbf{b_2}+3t^2\mathbf{b_3}
\]

通过这些导数就可以很方便地计算出曲线上某点处的切向量。

连续性

对于复杂曲线,如果我们使用高阶曲线的话,观察伯恩斯坦函数形式的顶点式会发现计算量呈平方级别增长。为此我们可以考虑将该曲线分段,然后这些曲线都使用较低阶的贝塞尔曲线去拟合,以此来减少计算量。

假设我们现在有两条三阶贝塞尔曲线ab,那么当曲线a的最后一个控制点与曲线b的第一个控制点位置相同时,我们称这两条曲线满足C0连续。下图的红点为控制点,观察中间的控制点处显然满足这一性质,但是可以看到左右两端曲线的过渡并不是平缓的。

而如果在满足C0连续的基础上,曲线a在最后一个控制点处的导数还与曲线b在第一个控制点处的导数相等,则此时我们说这两条曲线满足C1连续。

而满足C1连续的控制点必然满足:

\[\mathbf{a_n}=\mathbf{b_0}=\frac{1}{2}(\mathbf{a_{n-1}+b_{1}})
\]

即曲线ab的连接点为曲线a倒数第二个控制点与曲线b第二个控制点的中点。

当然还有更高级别的C2连续,即满足二阶导数相等,这里不再深入讨论。

HLSL代码实现

绘制贝塞尔曲线的外壳着色器和域着色器代码如下:

// Tessellation.hlsli

float4 BernsteinBasis(float t)
{
    float invT = 1.0f - t;
    
    return float4(
        invT * invT * invT,         // B_{0}^{3}(t)= (1-t)^3
        3.0f * t * invT * invT,     // B_{1}^{3}(t)= 3t(1-t)^2
        3.0f * t * t * invT,        // B_{2}^{3}(t)= 3t^2(1-t)
        t * t * t);                 // B_{3}^{3}(t)= t^3
}

float4 dBernsteinBasis(float t)
{
    float invT = 1.0f - t;
    
    return float4(
        -3 * invT * invT,                   // B_{0}^{3}'(t)= -3(1-t)^2
        3.0f * invT * invT - 6 * t * invT,  // B_{1}^{3}'(t)= 3(1-t)^2 - 6t(1-t)
        6 * t * invT - 3 * t * t,           // B_{2}^{3}'(t)= 6t(1-t) - 3t^2
        3 * t * t);                         // B_{3}^{3}'(t)= 3t^2
}
// Tessellation_Isoline_HS.hlsl
IsolinePatchTess IsolineConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
    IsolinePatchTess pt;
    
    pt.EdgeTess[0] = g_IsolineEdgeTess[0];  // 未知
    pt.EdgeTess[1] = g_IsolineEdgeTess[1];  // 段数
    
    return pt;
}

[domain("isoline")]
[partitioning("integer")]
[outputtopology("line")]
[outputcontrolpoints(4)]
[patchconstantfunc("IsolineConstantHS")]
[maxtessfactor(64.0f)]
float3 HS(InputPatch<VertexOut, 4> patch, uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID) : POSITION
{
    return patch[i].PosL;
}

// Tessellation_BezierCurve_DS.hlsl
#include "Tessellation.hlsli"

[domain("isoline")]
float4 DS(IsolinePatchTess patchTess,
    float t : SV_DomainLocation,
    const OutputPatch<HullOut, 4> bezPatch) : SV_POSITION
{
    float4 basisU = BernsteinBasis(t);
    
    // 贝塞尔曲线插值
    float3 sum = basisU.x * bezPatch[0].PosL +
        basisU.y * bezPatch[1].PosL +
        basisU.z * bezPatch[2].PosL +
        basisU.w * bezPatch[3].PosL;
    
    float4 posH = mul(float4(sum, 1.0f), g_WorldViewProj);
    
    return posH;
}

贝塞尔曲线示例

下面的动图展示了贝塞尔曲线的控制和细分

贝塞尔曲面

三阶贝塞尔曲线有4个控制点,而三阶贝塞尔曲面自然就有4×4控制点了。我们可以将其看做4条三次贝塞尔曲线。

这里就不再列出复杂的公式来晕人了。在一维情况下,贝塞尔曲线使用范围在[0, 1]的参数t来表示曲线上一点。那么在二维情况下,我们可以使用范围在[0, 1]的参数(u, v)来表示曲面上一点。

首先对于这4条横向的贝塞尔曲线,我们分别使用参数u代入来求得四个点,这四个顶点按列顺序构成新的控制点,然后问题就转化成了在这四个控制点构成的贝塞尔曲线中求其中一点。然后我们再用参数v代入就可以求得最终在曲面上的一点。

HLSL代码实现

贝塞尔曲面的着色器代码实现如下:

// Tessellation.hlsli

// 计算以4x4控制点为基础的三阶贝塞尔曲面在(u, v)下的一点
float3 CubicBezierSum(const OutputPatch<HullOut, 16> bezPatch,
    float4 basisU, float4 basisV)
{
    float3 sum = float3(0.0f, 0.0f, 0.0f);
    sum = basisV.x * (basisU.x * bezPatch[0].PosL +
        basisU.y * bezPatch[1].PosL +
        basisU.z * bezPatch[2].PosL +
        basisU.w * bezPatch[3].PosL);
    
    sum += basisV.y * (basisU.x * bezPatch[4].PosL +
        basisU.y * bezPatch[5].PosL +
        basisU.z * bezPatch[6].PosL +
        basisU.w * bezPatch[7].PosL);
    
    sum += basisV.z * (basisU.x * bezPatch[8].PosL +
        basisU.y * bezPatch[9].PosL +
        basisU.z * bezPatch[10].PosL +
        basisU.w * bezPatch[11].PosL);
    
    sum += basisV.w * (basisU.x * bezPatch[12].PosL +
        basisU.y * bezPatch[13].PosL +
        basisU.z * bezPatch[14].PosL +
        basisU.w * bezPatch[15].PosL);
    
    return sum;
}


上面的函数不仅能用来计算\(\mathbf{p}(u, v)\),还能够求它的偏导数:

float4 basisU = BernsteinBasis(uv.x);
float4 basisV = BernsteinBasis(uv.y);
    
// p(u, v)
float3 p = CubicBezierSum(bezPatch, basisU, basisV);


float4 dBasisU = dBernsteinBasis(uv.x);
float4 dBasisV = dBernsteinBasis(uv.y);
// p(u, v)对u的偏导
float3 dpdu = CubicBezierSum(bezPatch, dbasisU, basisV);
// p(u, v)对v的偏导
float3 dpdv = CubicBezierSum(bezPatch, basisU, dbasisV);

注意:可以发现,我们把基函数的计算结果传入了CubicBezierSum函数。由于p(u, v)与其偏导数的求和形式相同,仅基函数不同,因此CubicBezierSum函数不仅能用来计算p(u, v),还能用于求其偏导数。

// Tessellation_BezierSurface_HS.hlsl
#include "Tessellation.hlsli"

[domain("quad")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(16)]
[patchconstantfunc("QuadPatchConstantHS")]
[maxtessfactor(64.0f)]
float3 HS(InputPatch<VertexOut, 16> patch, uint i : SV_OutputControlPointID, uint patchId : SV_PrimitiveID) : POSITION
{
    return patch[i].PosL;
}

// Tessellation_BezierSurface_DS.hlsl
#include "Tessellation.hlsli"

[domain("quad")]
float4 DS(QuadPatchTess patchTess,
    float2 uv : SV_DomainLocation,
    const OutputPatch<HullOut, 16> bezPatch) : SV_POSITION
{
    float4 basisU = BernsteinBasis(uv.x);
    float4 basisV = BernsteinBasis(uv.y);
    
    // 贝塞尔曲面插值
    float3 p = CubicBezierSum(bezPatch, basisU, basisV);
    
    float4 posH = mul(float4(p, 1.0f), g_WorldViewProj);
    
    return posH;
}

几何体定义

下面的代码定义了16个控制点:

XMFLOAT3 surfaceVertices[16] = {
// 行 0
XMFLOAT3(-10.0f, -10.0f, +15.0f),
XMFLOAT3(-5.0f,  0.0f, +15.0f),
XMFLOAT3(+5.0f,  0.0f, +15.0f),
XMFLOAT3(+10.0f, 0.0f, +15.0f),

// 行 1
XMFLOAT3(-15.0f, 0.0f, +5.0f),
XMFLOAT3(-5.0f,  0.0f, +5.0f),
XMFLOAT3(+5.0f,  20.0f, +5.0f),
XMFLOAT3(+15.0f, 0.0f, +5.0f),

// 行 2
XMFLOAT3(-15.0f, 0.0f, -5.0f),
XMFLOAT3(-5.0f,  0.0f, -5.0f),
XMFLOAT3(+5.0f,  0.0f, -5.0f),
XMFLOAT3(+15.0f, 0.0f, -5.0f),

// 行 3
XMFLOAT3(-10.0f, 10.0f, -15.0f),
XMFLOAT3(-5.0f,  0.0f, -15.0f),
XMFLOAT3(+5.0f,  0.0f, -15.0f),
XMFLOAT3(+25.0f, 10.0f, -15.0f)
};

注意:这里并没有严格地限定控制点一定要按等距排列为均匀的网格。

贝塞尔曲面示例

下图展示了贝塞尔曲面

练习题

  1. 参考16章,只用6个顶点构成的八面体,并根据其与观察点的距离关系将其镶嵌细分为一个球体(距离越近,镶嵌程度越大)
  2. 尝试修改本演示程序中的控制点来改变其中的贝塞尔曲面
  3. 修改本演示程序,利用光照使得其中的贝塞尔曲面表现出明暗变化。为此,我们需要在域着色器中计算顶点法线。而位于顶点处的法线可以用此曲面点处坐标的偏导数的叉积求得。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。