Unity URP 曲面细分学习笔记

学百人时遇到了曲面着色器的内容,有点糊里糊涂,于是上知乎找到了两篇大佬的文章 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;
}
相关推荐
LK_0742 分钟前
【Open3D】Ch.3:顶点法向量估计 | Python
开发语言·笔记·python
饮浊酒1 小时前
Python学习-----小游戏之人生重开模拟器(普通版)
python·学习·游戏程序
li星野1 小时前
打工人日报#20251011
笔记·程序人生·fpga开发·学习方法
摇滚侠1 小时前
Spring Boot 3零基础教程,yml配置文件,笔记13
spring boot·redis·笔记
QT 小鲜肉1 小时前
【个人成长笔记】在Ubuntu中的Linux系统安装 anaconda 及其相关终端命令行
linux·笔记·深度学习·学习·ubuntu·学习方法
QT 小鲜肉1 小时前
【个人成长笔记】在Ubuntu中的Linux系统安装实验室WIFI驱动安装(Driver for Linux RTL8188GU)
linux·笔记·学习·ubuntu·学习方法
急急黄豆1 小时前
MADDPG学习笔记
笔记·学习
BullSmall1 小时前
《道德经》第十七章
学习
Chloeis Syntax2 小时前
栈和队列笔记2025-10-12
java·数据结构·笔记·
知识分享小能手2 小时前
微信小程序入门学习教程,从入门到精通,项目实战:美妆商城小程序 —— 知识点详解与案例代码 (18)
前端·学习·react.js·微信小程序·小程序·vue·前端技术