学百人时遇到了曲面着色器的内容,有点糊里糊涂,于是上知乎找到了两篇大佬的文章 Unity URP 曲面细分 和 Unity曲面细分笔记,本文只是自己做学习记录使用
1.曲面细分与镶嵌
曲面细分或细分曲面(Subdivision surface)是指一种通过递归算法将一个粗糙的几何网格细化的技术。镶嵌(Tessellation)则是实现曲面细分的具体手段,它能将场景中的几何物体顶点集划分为合适的渲染结构。
曲面细分分为三个阶段:外壳着色器(Hull Shader)、镶嵌器(Tessellator)、域着色器(Domain Shader)。
1.1 外壳着色器 Hull Shader
Hull shader实际上是两个阶段(Phase)组成:常量外壳着色器(Constant Hull Shader)和 控制点外壳着色器(Control point hull shader),两个阶段并行运行。
- Constant Hull Shader 会对每一个面片进行处理,主要任务是输出网格的曲面细分因子(Tessellation Factor) ,曲面细分因子用于指导面片细分数。
- 假如要传入的面片是三角形,那么对于三角形就会有三个边 曲面细分因子,所以
edgeFactor[3]
,对于内部 曲面细分因子,因为三角形是最小的图元所以内部是一个因子(个人理解)insideFactor
,如果是矩形的话就是四个边因子和两个内部因子 - 常量外壳着色器会以面片的所有顶点(或控制点)为输入 ,所以有三个顶点
InputPatch<VertexOut, 3> patch
,数字为3 - SV_PrimitiveID 提供传入面片的ID值,可以用来区分不同的Patch,这样你就可以根据Patch的ID来为每个Patch设置不同的细分因子,或者执行其他依赖于Patch ID的操作。它对于每个图元都是不同的
- 假如要传入的面片是三角形,那么对于三角形就会有三个边 曲面细分因子,所以
cpp
struct PatchTess{
float edgeFactor[3];
float insideFactor;
};
PatchTess PatchConstant(InputPatch<VertexOut, 3> patch, uint patchID : SV_PrimitiveID)
{
PatchTess o;
o.edgeFactor[0] = 4;
o.edgeFactor[1] = 4;
o.edgeFactor[2] = 4;
o.insideFactor = 4;
return o;
}
- Control Point Hull Shader 用来改变每个输出顶点的位置信息 ,例如将一个三角形变为三次贝塞尔三角面片
- domain:面片类型,参数有三角面tri、四角面片quad、等值线isoline
- partitioning:曲面细分方式 ,参数有integer、fractional_even、fractional_odd
- integer,指新顶点的增加指取决于细分的整数部分(等分),图形可能会出现图片
- fractional_even,向上取最近的偶数n,将整段切割为n-2个相等长度的部分,和两端较短的部分
- fractional_odd,向上取最近的奇数n,将整段切割为n-2个相等长度的部分,和两端较短的部分
- outputtopology:细分创建的三角形面片的绕序,参数有顺时针triangle_cw、逆时针triangle_ccw
- patchconstantfunc:常量外壳着色器的函数名
- outputcontrolpoints:外壳着色器的执行次数,即生成的控制点个数
- maxtessfactor:程序会使用到最大的细分因子
- SV_OutputControlPointID:当前正在操作的控制点索引ID
cpp
[domain("tri")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[patchconstantfunc("PatchConstant")]
[outputcontrolpoints(3)]
[maxtessfactor(64.0f)]
HullOut ControlPoint (InputPatch<VertexOut,3> patch,uint id : SV_OutputControlPointID){
HullOut o;
o.positionOS = patch[id].positionOS;
o.texcoord = patch[id].texcoord;
return o;
}
常量外壳着色器对每个片元执行一次,输出细分因子等信息;控制点外壳着色器对每个控制点执行一次,并输出对应或衍生的控制点。两个阶段并行运行。
1.2 镶嵌器阶段 Tessellator
这一阶段我们无法对其做出任何控制,全程由硬件控制。在这一阶段,硬件根据之前曲面细分因子对面片做出细分操作。
1.3 域着色器阶段 Domain Shader
就和普通的顶点着色器要做的差不多,我们需要计算每一个控制点的顶点位置等信息。
- 功能
- 生成细分顶点,这些顶点是由外壳着色器确定的细分因子和细分模式所生成的
- 插值属性,根据控制点的属性进行插值,得到细分顶点的属性
- 输出顶点 ,域着色器生成最终的顶点数据,并传递给后面的着色器
cpp
struct HullOut{
float3 positionOS : INTERNALTESSPOS;
float2 texcoord : TEXCOORD0;
};
struct DomainOut
{
float4 positionCS : SV_POSITION;
float2 texcoord : TEXCOORD0;
};
[domain("tri")]
DomainOut FlatTessDomain (PatchTess tessFactors, const OutputPatch<HullOut,3> patch, float3 bary : SV_DOMAINLOCATION)
{
float3 positionOS = patch[0].positionOS * bary.x +
patch[1].positionOS * bary.y +
patch[2].positionOS * bary.z;
float2 texcoord = patch[0].texcoord * bary.x +
patch[1].texcoord * bary.y +
patch[2].texcoord * bary.z;
DomainOut output;
output.positionCS = TransformObjectToHClip(positionOS);
output.texcoord = texcoord;
return output;
}
- 域着色器输入:接受一个域(Domain)输入,代表了细分后顶点在原始补丁内的位置,这个位置通常用参数空间坐标表示,例如重心坐标(Barycentric Coordinates)(这里的重心坐标,是由硬件在细分过程的一个中间阶段 Tessellator 自动计算得出的)
- 属性插值:域着色器使用这些参数空间坐标来插值原始控制点的属性。
- 顶点输出:域着色器输出一个顶点,这个顶点包含计算后的位置和其他属性(如颜色、纹理坐标、法线等)
2.具体实现
2.1 不同的细分策略
2.1.1 平面镶嵌 Flat Tessellation
平面镶嵌只是线性插值位置信息,细分后的图案只比之前多了一些三角面片,单独使用并不能平滑模型。通常和置换贴图配合使用,创建凹凸不平的平面。
cpp
Shader "Tessellation/Flat Tessellation"
{
Properties
{
[NoScaleOffset]_BaseMap ("Base Map", 2D) = "white" {}
[Header(Tess)][Space]
[KeywordEnum(integer, fractional_even, fractional_odd)]_Partitioning ("Partitioning Mode", Float) = 0
[KeywordEnum(triangle_cw, triangle_ccw)]_Outputtopology ("Outputtopology Mode", Float) = 0
_EdgeFactor ("EdgeFactor", Range(1,8)) = 4
_InsideFactor ("InsideFactor", Range(1,8)) = 4
}
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
HLSLPROGRAM
#pragma target 4.6
#pragma vertex FlatTessVert
#pragma fragment FlatTessFrag
#pragma hull FlatTessControlPoint
#pragma domain FlatTessDomain
#pragma multi_compile _PARTITIONING_INTEGER _PARTITIONING_FRACTIONAL_EVEN _PARTITIONING_FRACTIONAL_ODD
#pragma multi_compile _OUTPUTTOPOLOGY_TRIANGLE_CW _OUTPUTTOPOLOGY_TRIANGLE_CCW
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
CBUFFER_START(UnityPerMaterial)
float _EdgeFactor;
float _InsideFactor;
CBUFFER_END
TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap);
struct Attributes
{
float3 positionOS : POSITION;
float2 texcoord : TEXCOORD0;
};
struct VertexOut{
float3 positionOS : INTERNALTESSPOS;
float2 texcoord : TEXCOORD0;
};
struct PatchTess {
float edgeFactor[3] : SV_TESSFACTOR;
float insideFactor : SV_INSIDETESSFACTOR;
};
struct HullOut{
float3 positionOS : INTERNALTESSPOS;
float2 texcoord : TEXCOORD0;
};
struct DomainOut
{
float4 positionCS : SV_POSITION;
float2 texcoord : TEXCOORD0;
};
VertexOut FlatTessVert(Attributes input){
VertexOut o;
o.positionOS = input.positionOS;
o.texcoord = input.texcoord;
return o;
}
PatchTess PatchConstant (InputPatch<VertexOut,3> patch, uint patchID : SV_PrimitiveID){
PatchTess o;
o.edgeFactor[0] = _EdgeFactor;
o.edgeFactor[1] = _EdgeFactor;
o.edgeFactor[2] = _EdgeFactor;
o.insideFactor = _InsideFactor;
return o;
}
[domain("tri")]
#if _PARTITIONING_INTEGER
[partitioning("integer")]
#elif _PARTITIONING_FRACTIONAL_EVEN
[partitioning("fractional_even")]
#elif _PARTITIONING_FRACTIONAL_ODD
[partitioning("fractional_odd")]
#endif
#if _OUTPUTTOPOLOGY_TRIANGLE_CW
[outputtopology("triangle_cw")]
#elif _OUTPUTTOPOLOGY_TRIANGLE_CCW
[outputtopology("triangle_ccw")]
#endif
[patchconstantfunc("PatchConstant")]
[outputcontrolpoints(3)]
[maxtessfactor(64.0f)]
HullOut FlatTessControlPoint (InputPatch<VertexOut,3> patch,uint id : SV_OutputControlPointID){
HullOut o;
o.positionOS = patch[id].positionOS;
o.texcoord = patch[id].texcoord;
return o;
}
[domain("tri")]
DomainOut FlatTessDomain (PatchTess tessFactors, const OutputPatch<HullOut,3> patch, float3 bary : SV_DOMAINLOCATION)
{
float3 positionOS = patch[0].positionOS * bary.x + patch[1].positionOS * bary.y + patch[2].positionOS * bary.z;
float2 texcoord = patch[0].texcoord * bary.x + patch[1].texcoord * bary.y + patch[2].texcoord * bary.z;
DomainOut output;
output.positionCS = TransformObjectToHClip(positionOS);
output.texcoord = texcoord;
return output;
}
half4 FlatTessFrag(DomainOut input) : SV_Target{
half3 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.texcoord).rgb;
return half4(color, 1.0);
}
ENDHLSL
}
}
}
2.1.2 PN Tessellation(Per-Node Tessellation)
论文学习 Curved PN Triangles(Paper)
网站学习 OpenGL tutorial
在外壳着色器阶段,把一个三角面片(3个控制点)转换为一个3次贝塞尔三角面片(Cubic Bezier Triangle Patch,一种具有10个控制点的面片),这种策略称为 Curved Point-Normal Triangles(PN triangles),不同于Flat Tessellation,即使没有置换贴图,也能实现改变模型形状,平滑轮廓的作用。满足了资源有限以及环境限制的需求。
我们将使用一个Bezier曲面,Bezier三角形,形式如下
uvw代表的是质心坐标(u + v + w = 1),10个 b u v w b_{uvw} buvw是CPs,CPs形似如下图,类似于三角形顶部有一个点膨胀的表面
在镶嵌流水线中,
- 外壳着色器:我们将生成10个控制点 并确定细分因子
- 如何生成控制点?
- 1.三角形原始顶点B003、B030和B300保持不变
- 2.两个中点B012和B021在1/3和2/3的位置
- 3.将中点投影在初始顶点的切平面上
- 4.对于B111点,我们从原始的三角形中心(三个初始顶点取平均值)到6个中点的平均值(投影后的)取一个矢量
- 如何生成控制点?
- Tesellation Primitive Generator:PG中再根据细分因子对三角形域进行细分,并对每个新点执行域着色器;
- 域着色器:把来自PG的质心坐标 和来自外壳着色器的10个控制点 插入到贝塞尔三角形多项式中,得到的结果是膨胀表面上的坐标。
每个控制点的生成:
cpp
float3 ComputeCP(float3 pA, float3 pB, float3 nA){
return (2 * pA + pB - dot((pB - pA), nA) * nA) / 3.0f;
}
由于控制点的增多,在Hull Shader输出时每个顶点需要多携带两个顶点信息(中心控制点b111可以直接推算出来),例如:b030 可能需要携带b021和b012的顶点信息。修改一下控制点外壳着色器。
- 用TEXCOORD1和TEXCOORD2来存储额外的两个顶点信息
- 在PNTessControlPoint控制点着色器中,使用三元运算符来对nextID进行赋值,实现对每个新生成的控制点进行计算。
cpp
struct HullOut{
float3 positionOS : INTERNALTESSPOS;
float3 normalOS : NORMAL;
float2 texcoord : TEXCOORD0;
float3 positionOS1 : TEXCOORD1; // 三角片元每个顶点多携带两个顶点信息
float3 positionOS2 : TEXCOORD2;
};
float3 ComputeCP(float3 pA, float3 pB, float3 nA){
return (2 * pA + pB - dot((pB - pA), nA) * nA) / 3.0f;
}
[domain("tri")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[patchconstantfunc("PatchConstant")]
[outputcontrolpoints(3)]
[maxtessfactor(64.0f)]
HullOut PNTessControlPoint(InputPatch<VertexOut,3> patch,uint id : SV_OutputControlPointID){
HullOut output;
const uint nextCPID = id < 2 ? id + 1 : 0;
output.positionOS = patch[id].positionOS;
output.normalOS = patch[id].normalOS;
output.texcoord = patch[id].texcoord;
output.positionOS1 = ComputeCP(patch[id].positionOS, patch[nextCPID].positionOS, patch[id].normalOS);
output.positionOS2 = ComputeCP(patch[nextCPID].positionOS, patch[id].positionOS, patch[nextCPID].normalOS);
return output;
}
域着色器负责"Bezier三角形的实现",根据上面Bezier三角形的表达式
- 首先计算系数u,v,w(即质心坐标,上面已经介绍了,是PG阶段自动生成的)
- 把外壳着色器中得到的控制点赋值给变量,并计算E和V,得到b111点
- 此处对于法线的计算就简化为"最简单的三个控制点插值获取的方式"
cpp
[domain("tri")]
DomainOut PNTessDomain (PatchTess tessFactors, const OutputPatch<HullOut,3> patch, float3 bary : SV_DOMAINLOCATION)
{
float u = bary.x;
float v = bary.y;
float w = bary.z;
float uu = u * u;
float vv = v * v;
float ww = w * w;
float uu3 = 3 * uu;
float vv3 = 3 * vv;
float ww3 = 3 * ww;
float3 b300 = patch[0].positionOS;
float3 b210 = patch[0].positionOS1;
float3 b120 = patch[0].positionOS2;
float3 b030 = patch[1].positionOS;
float3 b021 = patch[1].positionOS1;
float3 b012 = patch[1].positionOS2;
float3 b003 = patch[2].positionOS;
float3 b102 = patch[2].positionOS1;
float3 b201 = patch[2].positionOS2;
float3 E = (b210 + b120 + b021 + b012 + b102 + b201) / 6.0;
float3 V = (b003 + b030 + b300) / 3.0;
float3 b111 = E + (E - V) / 2.0f;
// 插值获得细分后的顶点位置
float3 positionOS = b300 * ww * w + b030 * uu * u + b003 * vv * v
+ b210 * ww3 * u
+ b120 * uu3 * w
+ b021 * uu3 * v
+ b012 * vv3 * u
+ b102 * vv3 * w
+ b201 * ww3 * v
+ b111 * 6.0 * w * u * v;
// 此处简化了法线的计算
float3 normalOS = patch[0].normalOS * u
+ patch[1].normalOS * v
+ patch[2].normalOS * w;
normalOS = normalize(normalOS);
float2 texcoord = patch[0].texcoord * u
+ patch[1].texcoord * v
+ patch[2].texcoord * w;
DomainOut output;
output.positionCS = TransformObjectToHClip(positionOS);
output.normalWS = TransformObjectToWorldNormal(normalOS);
output.uv = texcoord;
return output;
}
然而,目前的做法是有缺陷的,在面对一些相同位置有不同法线的模型时,细分后会造成模型边缘的不连续,形成裂缝(Crack)。
2.1.3 Phong Tessellation
Phong着色应该很熟悉,是一种利用法向量线性差值得到平滑的着色的技术。Phong细分的灵感来自Phong着色,将Phong着色这一概念扩展到空间域。
核心思想:是利用三角形每个角的顶点法线 来影响细分过程中新顶点的位置,从而创造出曲面而非平面。
三角形原始顶点的切平面上, P ′ = P − ( ( P − V ) ⋅ N ) N P' = P - ((P-V)\cdot N)N P′=P−((P−V)⋅N)N
- P P P 是最初插值的平面位置
- V V V 是平面上的一个顶点位置
- N N N 是顶点 V V V 处的法线
- P ′ P' P′是 P P P 在平面上的投影。
cpp
float3 ProjectPointOnPlane(float3 flatPositionWS, float3 cornerPositionWS, float3 normalWS)
{
return flatPositionWS - dot(flatPositionWS - cornerPositionWS, normalWS) * normalWS;
}
投影在三个切平面的三个点重新组成一个新的三角形,再用当前顶点的重心坐标插值计算出新的点。
cpp
real3 PhongTessellation(real3 positionWS, real3 p0, real3 p1, real3 p2, real3 n0, real3 n1, real3 n2, real3 baryCoords, real shape)
{
// 分别计算三个切平面的投影点
real3 c0 = ProjectPointOnPlane(positionWS, p0, n0);
real3 c1 = ProjectPointOnPlane(positionWS, p1, n1);
real3 c2 = ProjectPointOnPlane(positionWS, p2, n2);
// 利用质心坐标插值得到最终顶点位置
real3 phongPositionWS = baryCoords.x * c0 + baryCoords.y * c1 + baryCoords.z * c2;
// 通过shape 控制平滑程度
return lerp(positionWS, phongPositionWS, shape);
}
域着色器修改为
cpp
DomainOut PhongTriTessDomain (PatchTess tessFactors, const OutputPatch<HullOut,3> patch, float3 bary : SV_DOMAINLOCATION)
{
float3 positionWS = patch[0].positionWS * bary.x + patch[1].positionWS * bary.y + patch[2].positionWS * bary.z;
positionWS =PhongTessellation(positionWS, patch[0].positionWS, patch[1].positionWS, patch[2].positionWS, patch[0].normalWS, patch[1].normalWS, patch[2].normalWS, bary, _PhongShape);
float2 texcoord = patch[0].texcoord * bary.x + patch[1].texcoord * bary.y + patch[2].texcoord * bary.z;
DomainOut output;
output.positionCS = TransformObjectToHClip(positionWS);
output.texcoord = texcoord;
return output;
}
2.2 不同的细分因子
2.2.1 基于相机距离
为了让距离相机近的位置细分程度高一点,所以我们要先获取相机的位置,并且得到片元距相机的距离(三角形边缘中点到相机的距离),以此距离来调整细分因子
cpp
real3 GetDistanceBasedTessFactor(real3 p0, real3 p1, real3 p2, real3 cameraPosWS, real tessMinDist, real tessMaxDist)
{
real3 edgePosition0 = 0.5 * (p1 + p2);
real3 edgePosition1 = 0.5 * (p0 + p2);
real3 edgePosition2 = 0.5 * (p0 + p1);
// In case camera-relative rendering is enabled, 'cameraPosWS' is statically known to be 0,
// so the compiler will be able to optimize distance() to length().
real dist0 = distance(edgePosition0, cameraPosWS);
real dist1 = distance(edgePosition1, cameraPosWS);
real dist2 = distance(edgePosition2, cameraPosWS);
// The saturate will handle the produced NaN in case min == max
real fadeDist = tessMaxDist - tessMinDist;
real3 tessFactor;
tessFactor.x = saturate(1.0 - (dist0 - tessMinDist) / fadeDist);
tessFactor.y = saturate(1.0 - (dist1 - tessMinDist) / fadeDist);
tessFactor.z = saturate(1.0 - (dist2 - tessMinDist) / fadeDist);
return tessFactor;
}
- real可以根据编译时的设置或宏定义来决定它具体代表哪种浮点数类型
- cameraPosWS变量不需要在Shader中声明,因为它是由Unity渲染管线自动提供的
- fadeDist是最大距离tessMaxDist和最小距离tessMinDist之间的差值,这个值定义了距离影响细分因子的范围
- 对于每个边缘,细分因子是通过将距离与tessMinDist相减,然后除以fadeDist(这个比例表示了当前距离相对于细分开始和结束的距离范围的位置 ),再用1减去该值(将比例翻转,即摄像机靠近相机时,细分因子接近1 ),最后使用saturate函数来限制结果在0到1之间(细分因子通常是介于0-1之间的值,0表示没有细分,1表示最大细分级别)。这样,当边缘中点距离相机较近时,细分因子接近1,表示细分程度较高;当边缘中点距离相机较远时,细分因子接近0,表示细分程度较低。
通过上述返回的细分因子,给三角形的边缘细分因子和内部细分因子赋值(内部细分因子设为三个边缘的平均)
cpp
real4 CalcTriTessFactorsFromEdgeTessFactors(real3 triVertexFactors)
{
real4 tess;
tess.x = triVertexFactors.x;
tess.y = triVertexFactors.y;
tess.z = triVertexFactors.z;
tess.w = (triVertexFactors.x + triVertexFactors.y + triVertexFactors.z) / 3.0;
return tess;
}
现将常量外壳着色器的代码调整如下
cpp
PatchTess PatchConstant (InputPatch<VertexOut,3> patch, uint patchID : SV_PrimitiveID){
PatchTess o;
float3 cameraPosWS = GetCameraPositionWS();
real3 triVectexFactors = GetDistanceBasedTessFactor(patch[0].positionWS, patch[1].positionWS, patch[2].positionWS, cameraPosWS, _TessMinDist, _TessMinDist + _FadeDist);
float4 tessFactors = _EdgeFactor * CalcTriTessFactorsFromEdgeTessFactors(triVectexFactors);
o.edgeFactor[0] = max(1.0, tessFactors.x);
o.edgeFactor[1] = max(1.0, tessFactors.y);
o.edgeFactor[2] = max(1.0, tessFactors.z);
o.insideFactor = max(1.0, tessFactors.w);
return o;
}