剧集推荐📺:
- 《我的恐怖妻子》,目前我心中悬疑剧top1
- 《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; }
|
- partitioning modes(细分模式):
integer
模式下会取比当前因子大的第一个整数作为细分,如果想让细分有光滑的过渡,那么请使用fractional_odd
或 fractional_even
模式;(pow2
模式实测下来感觉和integer
模式效果差不多,希望大家自行测试一下)
常量外壳着色器
常量外壳着色器每处理一个面片就被调用一次,它以面片的所有控制点作为输入,主要任务是输出网格的曲面细分因子【曲面细分因子(被 SV_TessFactor
和 SV_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; };
PatchTess ConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID) { PatchTess pt; pt.EdgeTess[0] = 3; pt.EdgeTess[1] = 3; pt.EdgeTess[2] = 3; pt.EdgeTess[3] = 3; pt.InsideTess[0] = 3; pt.InsideTess[1] = 3; return pt; }
|
- 对于三角形片面来说,边的序号和与它具有相同序号的顶点是相对的,也就是说顶点0的对面是边0(边0由顶点1和顶点2组成);【这件事情很重要,否则在计算三角形面片边的细分因子时,可能就搞不清楚该传入边的哪两个端点】
- 两个相邻三角形之间的边要有相同的曲面细分因子,否则会由于顶点不匹配而产生小洞,要保证这点,那么需要让边的细分因子只和连接这条边的顶点有关;
- 我们可以对曲面细分进行一些优化:
- 提前进行视锥体和背面剔除,在常量外壳着色器中我们先判断一个patch是否应该被剔除,如果是,那么把细分因子设置为0就可以丢弃掉该patch,否则进行正常的常量外壳着色器操作;
- 使用动态的细分因子,使用一些启发式的方法来调整细分因子,例如根据世界空间下边的长度,裁剪空间下边的长度,边到摄像机的距离等因素动态调整细分因子。
- 使用一些DCC软件如blender给网格绘制顶点色,把不需要细分的区域标注出来(例如直接涂黑,而需要的细分的区域不为黑色),这部分区域patch的细分因子为1。【顶点色相当于一个判定该区域是否细分的bool值,如果为true则细分,否则细分因子设置1不细分。该bool值也可以通过其它的方式得到而不仅仅直接粗暴的拿顶点色,通过这些自定义的方法可以实现很多很酷的效果,例如通过某种算法判定某个区域是否要发生形变,如果要发生形变那么为true则进行细分否则不细分。】
- 细分因子如何细分patch的?(其它类型图元的细分情况比较直观,这里说明的是三角形为图元的情况)以integer的细分模式为例,其它的以此类推。对于边因子,细分因子是几那么就分几段。对于inside细分因子,是在三角形内部生成一些嵌套三角形来实现的,每生成一个内部三角形就将细分因子-2,直到减到0或者1为止,需要注意的是当细分因子是奇数的时候最里面生成的是一个空心三角形,当为偶数的时候最里面是一个点。例如当细分因子为3时会生成1个三角形,为4时会生成1个三角形嵌套1个顶点,为5时会生成1个三角形嵌套另一个三角形。
Tessellation(镶嵌器阶段)
该阶段会基于常量外壳着色器程序所输出的曲面细分因子,对面片进行镶嵌化处理。镶嵌器会为新面片中的每一个点生成重心坐标。
Domain Shader(域着色器阶段)
域着色器为镶嵌化后的面片中的每个点都运行一次,它的工作是输出顶点的最终数据,以每个顶点的重心坐标 和 原始的patch(常量外壳着色器和控制点外壳着色器产生的所有数据)作为输入。
Silhouette Smoothing
有时尽管做了平滑着色,但是在某些区域特别是物体的轮廓处有较强的棱角感,下面介绍一些通过曲面细分来平滑网格的方法。
Phong tessellation
算法
如上图所示,首先通过重心坐标插值得到p的位置,接着分别把p点投影到三角形三个顶点的切平面上(该切平面由三角形的顶点和法线定义);
对投影得到的三个点再做一次重心坐标插值,得到p*;
使用光滑因子α在p和p*之间插值,相当于在控制面片的光滑程度。
- 为何phong tessellation有时会失效?如果面片的顶点法线向量十分的接近几乎平行了,那么phong tessellation后的顶点将退化为普通的在面片上插值的顶点,即p*退化成p。【也就是说得让模型“光滑”一些phong tessellation才会有效果(例如blender里面把模型shade smooth一下),否则如果每个顶点的法线都垂直于当前的面片,同一个面片各个顶点的法线相互平行,那么phong tessellation会失效。】
- 如果模型有很锋利的边,那么尝试给该边添加环形边,且环形边尽可能的离这条边近,从而产生一些很细长的面片。目的就是借助这些细长面片的顶点法线近乎是平行的,从而类似于取消掉phong tessellation的效果,保持边缘的锋利。
- phong tessellation是通过仅仅修改插值顶点的位置来实现面片的光滑(弯曲)的,因此该算法是在domain shader中实现的(在这个shader中我们实现对细分顶点的插值操作)。
- 可以把网格的光滑因子烘培到顶点色中(类似于上面的细分因子的操作),从而得到哪些区域想要被光滑而哪些区域不想被光滑。
PN 三角形方法
PN 三角形方法比phong tessellation效果好,但是效率会差一些。
算法
PN三角形将它的几何信息和法线信息分开定义。其中每个点的几何信息由10个控制点进行cubic Bezier triangle插值得到,法线信息由6个控制点进行由一个二次函数定义的插值得到,两者的计算公式分别为下面的左图和右图所示:
PN三角形几何系数的计算
控制点的拓扑结构如上图所示。这些控制点的位置是由三角形三个顶点(即上图的b300,b003,b030,在下面的计算中保持它们的位置不变)的法线和位置计算得到的。
对上图的每个b进行三角形重心坐标的插值,其中b的下标表示了重心坐标;
如上图,对于三角形边上的每一个点,选择一个最靠近它的顶点,把自己投影到该顶点位置和法线定义的平面上;
偏移最中间的点b111,具体如何偏移见下方左图公式。
PN三角形法线系数的计算
计算法线时的拓扑结构如上所示。法线向量的插入可以选择线性的(即普通的重心坐标插值),也可以选择二次的,鉴于 PN 三角形点的插入是三次的(即曲面的),而法线向量又是对曲面方程求偏导数的结果,因而选法线的二次插入。
对于三个顶点上的法线我们不去改变它。在计算边上控制点的法线时,并不是直接使用三角形的两个端点进行线性插值后的结果,如上图所示,线性插值后还需要把该法线镜像一下。这么做是因为,如果是简单的线性插入,所插入的法线信息可能不能反映法线反射的情况。比如,起始点和终止点的法线向量的方向相同,则在这之间插入的所有法线向量的方向与起始点和终止点的方向相同,但实际情况可能是,起始点和终止点之间的曲线可能是类似于正弦曲线的形状,而在这种情况下,所插入的法线向量的方向显然不是全都相同的。具体系数计算公式如下右图所示。
切线矫正
经过上面的一番操作之后原始的切线可能已经不再垂直于新的法线了,于是我们做如下操作:
- 将原始的法线和切线做叉乘得到原始的副切线;
- 将新的法线和原始的副切线做叉乘得到新的切线,这样新生成的切线便垂直于新的法线 且 垂直于原来的副切线。
- 我们在常量外壳着色器中计算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 >folded1 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 #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; };
struct VertexOutput{ float4 position : INTERNALTESSPOS; float3 normal : NORMAL; float2 uv : TEXCOORD0; };
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; }; struct HS_CONTROL_POINT { float3 pos[3] : BEZIERPOS; float3 nor[2] : BEZIERNORMAL; float2 uv : TEXCOORD3; };
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]); 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; float3 p1 = inputPatch[tid].position; float3 p2 = inputPatch[next].position; float3 n1 = inputPatch[tid].normal; float3 n2 = inputPatch[next].normal; HS_CONTROL_POINT output; output.pos[0] = p1; output.pos[1] = 2 * p1 + p2 - dot(p2-p1, n1) * n1; output.pos[2] = 2 * p2 + p1 - dot(p1-p2, n2) * n2; 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); output.uv = inputPatch[tid].uv; return output; }
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; 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; 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; 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; }
float4 frag (VertexOutput i) : SV_Target { return float4(1.0,1.0,1.0,1.0); } ENDHLSL } } }
|
上图红色线段标注的就是每个HS_CONTROL_POINT
结构体中存储的控制点的几何和法线信息。
关于常量外壳着色器使用OutputPatch
作为输入我有点困惑,文档中说常量外壳着色器和控制点着色器是并行执行的,常量外壳着色器可以访问输入控制点和输出控制点的信息(仅只读权限)。两者同时执行的话,那这样常量外壳着色器读到的输出控制点信息不会有错吗?
估计原因可能来自文档中的这句话:HLSL 编译器提取外壳着色器中的并行度,并将其编码为驱动硬件的字节码。是不是假如常量外壳着色器要访问控制点的输出数据,那么就会让常量外壳着色器在所有控制点数据计算完毕之后再运行,如果不需要访问控制点的输出数据,两者就同时并行运行?
算法实现前(左)后(右)的效果。