曲面细分着色器

曲面细分着色器

剧集推荐📺:

  1. 《我的恐怖妻子》,目前我心中悬疑剧top1
  2. 《3年A班:从现在起,大家都是人质》

本文涉及到的一些术语在不同文献中描述的时候略有不同,大概意会其意思即可。


如上图所示,展示了曲面细分着色器和几何着色器在渲染管线中的位置。

曲面细分技术就是将几何体细分为更小的三角形,并以某种方式把这些新生成的顶点偏移到合适的位置(可以在domain shader完成),从而以增加三角形数量的方式丰富网格的细节。


Hull Shader(外壳着色器阶段)

hull shader由 常量外壳着色器(constant hull shader) 和 控制点外壳着色器(control point hull shader) 组成,它们都接受patch(组成图元的一堆点的集合)作为输入,控制点外壳着色器每输出一个控制点就运行一次,而常量外壳着色器每个patch运行一次且必须输出细分因子。


控制点外壳着色器

控制点外壳着色器以大量的控制点作为输入和输出(输入的控制点与输出的控制点数量未必相同),每输出一个控制点,此着色器都会被调用一次(尽管向其提供了整个补丁,但该着色器一次仅应输出一个顶点)。

控制点外壳着色器,四边形面片示例(超简易版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*以下各关键字的详细含义见龙书,在此不赘述*/

struct HullOut
{
float3 PosL : POSITION;
};

[domain(“quad”)] // 输入的图元类型
[partitioning(“integer”)] // 选择一种细分模式
[outputtopology(“triangle_cw”)] // 通过细分所创造的三角形的绕序
[outputcontrolpoints(4)] // 构成图元的点的数量
[patchconstantfunc(“ConstantHS”)] // 注册常量外壳着色器
[maxtessfactor(64.0f)] // 着色器中所用的曲面细分因子的最大值
HullOut HS(InputPatch<VertexOut, 4> p,
uint i : SV_OutputControlPointID,
uint patchId : SV_PrimitiveID)
{
HullOut hout;
hout.PosL = p[i].PosL;
return hout;
}
值得注意的事情
  1. partitioning modes(细分模式):integer模式下会取比当前因子大的第一个整数作为细分,如果想让细分有光滑的过渡,那么请使用fractional_oddfractional_even模式;(pow2模式实测下来感觉和integer模式效果差不多,希望大家自行测试一下)

常量外壳着色器

常量外壳着色器每处理一个面片就被调用一次,它以面片的所有控制点作为输入,主要任务是输出网格的曲面细分因子【曲面细分因子(被 SV_TessFactorSV_InsideTessFactor语义标识的数据)是必须输出的,因子的形式取决于面片的拓扑结构(quad/tri/isoline)。常量外壳着色器输出的数据会作为域着色器的输入,因此在常量外壳着色器的输出数据中除了包含曲面细分因子外,还可以输出一些额外的数据供域着色器使用(特别是那些一个patch中所有点共享的数据 或 只需要为整个patch计算一次的数据)。】

常量外壳着色器,四边形面片示例(超简易版)
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
// 常量外壳着色器的输出数据
struct PatchTess
{
float EdgeTess[4] : SV_TessFactor;
float InsideTess[2] : SV_InsideTessFactor;
// Additional info you want associated per patch.
};

/*面片控制点经由顶点着色器处理后输入,因此控制点的类型由顶点着色器的输出数据类型决定(例如下面的VertexOut)*/
/*hull shader对曲面patch(patch是网格顶点的集合)进行操作,该patch作为参数传递给hull shader。我们必须添加一个InputPatch参数才能实现这一点。*/
// 1. InputPatch<VertexOut, 4>定义了控制点的数目和信息;
// VertexOut则是指定数据格式,它是Vertex shader的输出参数;
// "4":处理三角形时每个patch包含3个顶点,四边形则是4个顶点,4是InputPatch的第二个参数代表patch的控制点的个数。
// 2. SV_Primitive:该语义提供了面片的ID值,此ID唯一地标识了绘制调用过程中的各个面片,我们可以根据具体的需求来运用它。
PatchTess ConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
PatchTess pt;
// Uniformly tessellate the patch 3 times.
pt.EdgeTess[0] = 3; // Left edge
pt.EdgeTess[1] = 3; // Top edge
pt.EdgeTess[2] = 3; // Right edge
pt.EdgeTess[3] = 3; // Bottom edge
pt.InsideTess[0] = 3; // u-axis (columns)
pt.InsideTess[1] = 3; // v-axis (rows)
return pt;
}
值得注意的事情
  1. 对于三角形片面来说,边的序号和与它具有相同序号的顶点是相对的,也就是说顶点0的对面是边0(边0由顶点1和顶点2组成);【这件事情很重要,否则在计算三角形面片边的细分因子时,可能就搞不清楚该传入边的哪两个端点】
  2. 两个相邻三角形之间的边要有相同的曲面细分因子,否则会由于顶点不匹配而产生小洞,要保证这点,那么需要让边的细分因子和连接这条边的顶点有关;
  3. 我们可以对曲面细分进行一些优化:
    • 提前进行视锥体和背面剔除,在常量外壳着色器中我们先判断一个patch是否应该被剔除,如果是,那么把细分因子设置为0就可以丢弃掉该patch,否则进行正常的常量外壳着色器操作;
    • 使用动态的细分因子,使用一些启发式的方法来调整细分因子,例如根据世界空间下边的长度,裁剪空间下边的长度,边到摄像机的距离等因素动态调整细分因子。
    • 使用一些DCC软件如blender给网格绘制顶点色,把不需要细分的区域标注出来(例如直接涂黑,而需要的细分的区域不为黑色),这部分区域patch的细分因子为1。【顶点色相当于一个判定该区域是否细分的bool值,如果为true则细分,否则细分因子设置1不细分。该bool值也可以通过其它的方式得到而不仅仅直接粗暴的拿顶点色,通过这些自定义的方法可以实现很多很酷的效果,例如通过某种算法判定某个区域是否要发生形变,如果要发生形变那么为true则进行细分否则不细分。】
  4. 细分因子如何细分patch的?(其它类型图元的细分情况比较直观,这里说明的是三角形为图元的情况)以integer的细分模式为例,其它的以此类推。对于边因子,细分因子是几那么就分几段。对于inside细分因子,是在三角形内部生成一些嵌套三角形来实现的,每生成一个内部三角形就将细分因子-2,直到减到0或者1为止,需要注意的是当细分因子是奇数的时候最里面生成的是一个空心三角形,当为偶数的时候最里面是一个点。例如当细分因子为3时会生成1个三角形,为4时会生成1个三角形嵌套1个顶点,为5时会生成1个三角形嵌套另一个三角形。

Tessellation(镶嵌器阶段)

该阶段会基于常量外壳着色器程序所输出的曲面细分因子,对面片进行镶嵌化处理。镶嵌器会为新面片中的每一个点生成重心坐标。


Domain Shader(域着色器阶段)

域着色器为镶嵌化后的面片中的每个点都运行一次,它的工作是输出顶点的最终数据,以每个顶点的重心坐标 和 原始的patch(常量外壳着色器和控制点外壳着色器产生的所有数据)作为输入。


Silhouette Smoothing

有时尽管做了平滑着色,但是在某些区域特别是物体的轮廓处有较强的棱角感,下面介绍一些通过曲面细分来平滑网格的方法。


Phong tessellation

算法

  1. 如上图所示,首先通过重心坐标插值得到p的位置,接着分别把p点投影到三角形三个顶点的切平面上(该切平面由三角形的顶点和法线定义);
    πi(q)=q((qpi)ni)ni

  2. 对投影得到的三个点再做一次重心坐标插值,得到p*;
    p(u,v)=(u,v,w)(πi(p(u,v))πj(p(u,v))πk(p(u,v)))

  3. 使用光滑因子α在p和p*之间插值,相当于在控制面片的光滑程度。
    pα(u,v)=(1α)p(u,v)+α(u,v,w)(πi(p(u,v))πj(p(u,v))πk(p(u,v)))

值得注意的事情
  1. 为何phong tessellation有时会失效?如果面片的顶点法线向量十分的接近几乎平行了,那么phong tessellation后的顶点将退化为普通的在面片上插值的顶点,即p*退化成p。【也就是说得让模型“光滑”一些phong tessellation才会有效果(例如blender里面把模型shade smooth一下),否则如果每个顶点的法线都垂直于当前的面片,同一个面片各个顶点的法线相互平行,那么phong tessellation会失效。】
  2. 如果模型有很锋利的边,那么尝试给该边添加环形边,且环形边尽可能的离这条边近,从而产生一些很细长的面片。目的就是借助这些细长面片的顶点法线近乎是平行的,从而类似于取消掉phong tessellation的效果,保持边缘的锋利。
  3. phong tessellation是通过仅仅修改插值顶点的位置来实现面片的光滑(弯曲)的,因此该算法是在domain shader中实现的(在这个shader中我们实现对细分顶点的插值操作)。
  4. 可以把网格的光滑因子烘培到顶点色中(类似于上面的细分因子的操作),从而得到哪些区域想要被光滑而哪些区域不想被光滑。

PN 三角形方法

PN 三角形方法比phong tessellation效果好,但是效率会差一些。


算法

PN三角形将它的几何信息和法线信息分开定义。其中每个点的几何信息由10个控制点进行cubic Bezier triangle插值得到,法线信息由6个控制点进行由一个二次函数定义的插值得到,两者的计算公式分别为下面的左图和右图所示:

PN三角形几何系数的计算

控制点的拓扑结构如上图所示。这些控制点的位置是由三角形三个顶点(即上图的b300,b003,b030,在下面的计算中保持它们的位置不变)的法线和位置计算得到的。

  1. 对上图的每个b进行三角形重心坐标的插值,其中b的下标表示了重心坐标;

  2. 如上图,对于三角形边上的每一个点,选择一个最靠近它的顶点,把自己投影到该顶点位置和法线定义的平面上;

  3. 偏移最中间的点b111,具体如何偏移见下方左图公式。

PN三角形法线系数的计算

计算法线时的拓扑结构如上所示。法线向量的插入可以选择线性的(即普通的重心坐标插值),也可以选择二次的,鉴于 PN 三角形点的插入是三次的(即曲面的),而法线向量又是对曲面方程求偏导数的结果,因而选法线的二次插入。

对于三个顶点上的法线我们不去改变它。在计算边上控制点的法线时,并不是直接使用三角形的两个端点进行线性插值后的结果,如上图所示,线性插值后还需要把该法线镜像一下。这么做是因为,如果是简单的线性插入,所插入的法线信息可能不能反映法线反射的情况。比如,起始点和终止点的法线向量的方向相同,则在这之间插入的所有法线向量的方向与起始点和终止点的方向相同,但实际情况可能是,起始点和终止点之间的曲线可能是类似于正弦曲线的形状,而在这种情况下,所插入的法线向量的方向显然不是全都相同的。具体系数计算公式如下右图所示。

切线矫正
经过上面的一番操作之后原始的切线可能已经不再垂直于新的法线了,于是我们做如下操作:

  1. 将原始的法线和切线做叉乘得到原始的副切线;
  2. 将新的法线和原始的副切线做叉乘得到新的切线,这样新生成的切线便垂直于新的法线 且 垂直于原来的副切线。
值得注意的事情
  1. 我们在常量外壳着色器中计算PN三角形控制点的系数【我之前看到了英伟达的一个GDC分享,它把计算控制点系数的代码分别放在了控制点外壳着色器和常量外壳着色器中,在常量外壳着色器中计算最中间的那个控制点,而在控制点外壳着色器中计算剩下的9个控制点,估计这是为了通过并行计算从而更快的得到结果,一下在一个常量外壳着色器中至少算7个点可能会不那么高效。在下面的代码示例中我们的实现就是基于英伟达的方法】,在域着色器中根据曲面细分得到的重心坐标和这些控制点系数计算每个点的位置坐标和法线信息。

*Bezier Triangles

贝塞尔三角形是一种特殊的贝塞尔曲面,它通过控制点和质心坐标信息来确定三次曲面上的点的位置, 上面的 PN 三角形是贝塞尔三角形的一种特殊的实现,PN 三角形的控制点信息是依据输入三角形的顶点位置信息和法线信息计算求得的。


计算重心坐标
在一个N维的simplex中放置一个点A【simplex是在给定维度上,可以使用的最少的点构建出的形状。例如0维simplex是一个点,1维simplex是一条线,2维simplex是一个三角形,3维simplex是一个四面体】,将该simplex分割成很多份小的同维度的simplex,分别计算这些小的simplex和大的simplex之间面积的比值,则得到了点A的重心坐标。
Bezier triangles

  • linear Bezier triangles:linear Bezier curve在两个端点之间线性插值,而linear Bezier triangles在3个顶点之间线性插值(在三角形的每条边上的点相当于是linear Bezier curve的)。【这里的linear Bezier curve的插值相当于就是普通的线性插值,linear Bezier triangles的插值相当于就是普通的三角形重心坐标的插值。】
  • quadratic Bezier triangle:quadratic Bezier curve在两条linear Bezier curve之间线性插值,而quadratic Bezier triangle在三个linear Bezier triangles之间线性插值【先在三个linear Bezier triangles中分别用待计算点的重心坐标线性插值,之后再把三个线性插值得到的点用同样的重心坐标再线性插值一遍】。类似的对于cubic Bezier triangle,就是在三个quadratic Bezier triangle之间线性插值,更多阶的Bezier triangle以此类推。(计算某一阶的Bezier triangle就是拿它前一阶的Bezier triangle做线性插值——依次递归,非常类似于贝塞尔曲线的递归算法)

贝塞尔三角形的公式
以cubic Bezier triangle为例:

上述橙色的符号表示的是控制点信息,其下标描述了每个重心坐标的次幂,上述绿色的符号表示的就是重心坐标,上述蓝色的符号表示的是三角域伯恩斯坦多项式的系数,每一阶贝塞尔三角形的系数对应了帕斯卡金字塔的一层【对于贝塞尔曲线则对应的则是帕斯卡三角形】:


代码示例

这里示范的是PN三角形的算法,这里仅仅只实现了算法的核心,因此为了直观的感受效果,需要在unity中开启Shaded Wireframe。

PN triangle >folded
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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
Shader "Unlit/PNTriangle" {
Properties
{
_TessellationUniform("TessellationUniform",Range(1,64)) = 1
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
HLSLPROGRAM
// 定义曲面细分函数
#pragma hull HS
// 定义Domain函数
#pragma domain DS
// 定义顶点着色器
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

#pragma target 5.0

CBUFFER_START(UnityPerMaterial)
uniform float _TessellationUniform;
CBUFFER_END

// 定义顶点着色器的输入结构体
struct VertexInput
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};

// 判断是否能曲面细分
//#ifdef UNITY_CAN_COMPILE_TESSELLATION(这个宏在URP里好像不支持)

// 曲面着色器输入结构体
struct VertexOutput{
float4 position : INTERNALTESSPOS;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};

// 顶点着色器
// 这里可以做一些顶点动画之类的,但是演示Demo里没必要
VertexOutput vert (VertexInput v){
VertexOutput o;
o.position = v.vertex;
o.normal = v.normal;
o.uv = v.uv;
return o;
}

struct HS_PATCH_DATA
{
float edges[3] : SV_TessFactor;
float inside : SV_InsideTessFactor;
float3 center : CENTER; // 总之HLSL里面就有很多这种语义,我也没在文档中查到,但是有的代码中这么用了,于是就这么用吧
};
struct HS_CONTROL_POINT
{
float3 pos[3] : BEZIERPOS;
float3 nor[2] : BEZIERNORMAL;
float2 uv : TEXCOORD3;
};

//patch constant data
HS_PATCH_DATA HullShaderPatchConstant( OutputPatch<HS_CONTROL_POINT, 3> controlPoints // 注意这里!OutputPatch既可以用于域着色器又可以用于常量外壳着色器
, uint pid : SV_PrimitiveID ) {
HS_PATCH_DATA patch = (HS_PATCH_DATA)0;
patch.edges[0] = _TessellationUniform;
patch.edges[1] = _TessellationUniform;
patch.edges[2] = _TessellationUniform;
patch.inside = max(max(patch.edges[0], patch.edges[1]), patch.edges[2]);
//calculate center
float3 center = (controlPoints[0].pos[1] + controlPoints[0].pos[2]) * 0.5 -
controlPoints[0].pos[0] +
(controlPoints[1].pos[1] + controlPoints[1].pos[2]) * 0.5 -
controlPoints[1].pos[0] +
(controlPoints[2].pos[1] + controlPoints[2].pos[2]) * 0.5 -
controlPoints[2].pos[0];

patch.center = center;
return patch;
}

[domain("tri")]
[outputtopology("triangle_cw")]
[outputcontrolpoints(3)]
[partitioning("integer")]
[patchconstantfunc("HullShaderPatchConstant")]
HS_CONTROL_POINT HS( InputPatch<VertexOutput, 3> inputPatch,
uint tid : SV_OutputControlPointID, uint pid : SV_PrimitiveID) {
int next = (1 << tid) & 3; // (tid + 1) % 3
float3 p1 = inputPatch[tid].position;
float3 p2 = inputPatch[next].position;
float3 n1 = inputPatch[tid].normal;
float3 n2 = inputPatch[next].normal;
HS_CONTROL_POINT output;
//control points positions
output.pos[0] = p1;
output.pos[1] = 2 * p1 + p2 - dot(p2-p1, n1) * n1; // 这里缺少1/3的系数,是因为在后面插值的时候会被约掉
output.pos[2] = 2 * p2 + p1 - dot(p1-p2, n2) * n2;
//control points normals
float3 v12 = 4 * dot(p2-p1, n1+n2) / dot(p2-p1, p2-p1);
output.nor[0] = n1;
output.nor[1] = n1 + n2 - v12 * (p2 - p1); // 这里缺少1/2的系数,是因为后面会对法线做归一化处理
output.uv = inputPatch[tid].uv;
return output;
}

// Domain函数的输出结构体,即片元着色器的输入结构体
struct DSOutput
{
float2 uv : TEXCOORD0;
float4 position : SV_POSITION;
float3 normal : NORMAL;
};

[domain("tri")]
DSOutput DS(HS_PATCH_DATA patchData,
const OutputPatch<HS_CONTROL_POINT, 3> input, float3 uvw : SV_DomainLocation) {
DSOutput output;
float u = uvw.x;
float v = uvw.y;
float w = uvw.z;
//output position is weighted combination of all 10 position control points
float3 pos =
(float3)input[0].pos[0] * w*w*w +(float3)input[1].pos[0] * u*u*u +(float3)input[2].pos[0] * v*v*v +
(float3)input[0].pos[1] * w*w*u +(float3)input[0].pos[2] * w*u*u +(float3)input[1].pos[1] * u*u*v +
(float3)input[1].pos[2] * u*v*v +(float3)input[2].pos[1] * v*v*w + (float3)input[2].pos[2] * v*w*w +
(float3)patchData.center * u*v*w;
//output normal is weighted combination of all 10 position control points
float3 nor =
input[0].nor[0] * w*w + input[1].nor[0] * u*u + input[2].nor[0] * v*v +
input[0].nor[1] * w*u + input[1].nor[1] * u*v + input[2].nor[1] * v*w;
//transform and output data
output.position = TransformObjectToHClip(float4(pos,1));
output.normal = TransformObjectToWorldNormal(float4(normalize(nor),1)).xyz;
output.uv = input[0].uv * w + input[1].uv * u + input[2].uv * v;
return output;
}

//#endif

float4 frag (VertexOutput i) : SV_Target
{
return float4(1.0,1.0,1.0,1.0);
}
ENDHLSL
}
}
}

上图红色线段标注的就是每个HS_CONTROL_POINT结构体中存储的控制点的几何和法线信息。

猜测

关于常量外壳着色器使用OutputPatch作为输入我有点困惑,文档中说常量外壳着色器和控制点着色器是并行执行的,常量外壳着色器可以访问输入控制点和输出控制点的信息(仅只读权限)。两者同时执行的话,那这样常量外壳着色器读到的输出控制点信息不会有错吗?
估计原因可能来自文档中的这句话:HLSL 编译器提取外壳着色器中的并行度,并将其编码为驱动硬件的字节码。是不是假如常量外壳着色器要访问控制点的输出数据,那么就会让常量外壳着色器在所有控制点数据计算完毕之后再运行,如果不需要访问控制点的输出数据,两者就同时并行运行?

算法实现前(左)后(右)的效果。

评论