因毛发渲染需要用几何着色器生成外壳和鳍状体,但鳍状体的个数本质上还是取决于原始网格的数量,如果使用细分曲面的话可以很有效地控制毛发的密度。本节学习在Unity中实现细分曲面。
外壳着色器和域着色器
细分曲面可以将物体细分为很小的部分,可以为几何体增添很多细节。
创建细分曲面着色器
在Unity中,我们先创建一个自己的头文件:
#if !defined(TESSELLATION_INCLUDED)
#define TESSELLATION_INCLUDED
...
#endif
头文件的写法和普通的C++头文件写法类似,只不过所指定的类别是TESSELLATION_INCLUDED
。
然后我们随意创建一个shader,将头文件包含在内:
#pragma target 4.6
...
#include "MyTessellation.cginc"
target即编译器所支持的着色器模型版本,如果要使用细分曲面着色器的话,需要高于4.6,另外,几何着色器需要高于4.0。
外壳着色器
与几何着色器类似,细分曲面阶段灵活性很高,可以对三角形、四边形或等值线进行操作。我们需要告知其要在哪一个表面上操作,并且将必要的数据给予它,这是外壳着色器的任务,我们向头文件中添加函数:
void MyHullProgram(){}
因为该外壳着色器在一个表面片上执行操作,我们添加一个InputPatch
操作:
void MyHullProgram(InputPatch patch){}
一个片式网格点的结合,类似于几何着色器的流参数,我们需要确定顶点数据的格式,这里使用VertexData
结构体(这是自定义的顶点着色器输出数据结构体):
void MyHullProgram(InputPatch<VertexData> patch){}
又因为这里的每个片是一个三角形,所以包含3个顶点,所以需要标识出来:
void MyHullProgram(InputPatch<VertexData, 3> patch){}
外壳着色器的任务是将所需要的数据传入细分曲面阶段,尽管接受三个顶点,即一个完整的片,但还是应该每次只输出一个顶点。片的每个顶点会调用一次该函数,所以需要标识目前在控制哪一个顶点。这里使用无符号整型ID,且送往语义SV_OutputControlPointID
:
void MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID){}
函数最简单的功能就是返回片的ID(这是默认什么都不做):
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
return patch[id];
}
注意,在shader文件中,我们也需要用预处理命令对外壳着色其进行标识:
#pragma hull MyHullProgram
不过就这么写的话会带来编译错误,因为和几何着色器一样,我们需要对函数配置属性。首先,我们要明确告诉函数,要操作三角形。这里使用UNITY_domain
属性,tri
作为参数:
[UNITY_domain("tri")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
return patch[id];
}
我们还需要明确每个片输出三个控制点,每个三角形的点输出一个:
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
return patch[id];
}
当GPU创建新三角形时,它需要知道要三角形的绘制顺序。像Unity中的其它三角形,它需要是顺时针的。我们使用UNITY_outputtopology()
标识,参数需要为triangle_cw
:
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
return patch[id];
}
GPU也需要知道如何切分片,使用UNITY_partitioning
属性,这里我们使用integer
模式:
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
return patch[id];
}
除了分割方法,GPU还需要知道有多少片需要切割。这不是一个常量,可以每个片变化。我们可以使用UNITY_patchconstantfunc
标识一个自定义函数。
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
return patch[id];
}
片常量函数
一个片如何被细分的,这是一个片的属性。这意味着片常量函数每个片调用一次,不是每个控制点。该函数与MyHullProgram
并行运行。
为了判断如何细分一个三角形,GPU使用细分系数。每个三角形片的边得到一个因数。三角形内部也有一个系数,三个边矢量和内部因数需要传递至相应的语义。结构体:
struct TessellationFactors
{
float edge[3] : SV_TessFactor;
float inside : SV_InsideTessFactor;
};
片常量函数将片作为一个输入参数,输出细分因数。我们这里将所有因数都设为1,相当于不细分:
TessellationFactors MyPatchConstantFunction(InputPatch<VertexData, 3> patch)
{
TessellationFactors f;
f.edge[0] = 1;
f.edge[1] = 1;
f.edge[2] = 1;
f.inside = 1;
return f;
}
域着色器
此时,着色器编译器会报错,说不能没有一个细分求值着色器来使用一个细分控制着色器。外壳着色器只是我们需要进行的细分工作的一部分。一旦细分曲面阶段决定了片如何细分,这就取决于几何着色器来求值结果,并生成最后三角形的顶点。因此我们创建一个域着色器的方法:
void MyDomainProgram(){}
外壳着色器和域着色器在相同的域中工作,即三角形。我们还是要通过UNITY_domain
来标识:
[UNITY_domain("tri")]
void MyDomainProgram(){}
函数的参数,使用细分系数结构体,输出的片和之前类似:
[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch){}
细分曲面阶段决定片如何被细分,但并不生成新的顶点。取而代之,它为这些顶点生成一个重心坐标,域着色器根据这些数据生成最终的顶点。为了让这点变为可能,域着色器函数逐顶点调用一次,并为其提供重心坐标。使用SV_DomainLocation
语义标识:
[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation){}
函数中,我们生成最终的顶点数据:
[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
VertexData data;
}
为了找到顶点的位置,我们沿原始三角形的域插值,使用重心坐标。由于除了位置我们还要对其它的属性进行插值,这里我们定义一个宏定义:
[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
VertexData data;
#define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) data.fieldName = \
patch[0].fieldName * barycentricCoordinate.x + \
patch[1].fieldName * barycentricCoordinate.y + \
patch[2].fieldName * barycentricCoordinate.z;
MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)
MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
MY_DOMAIN_PROGRAM_INTERPOLATE(uv1)
MY_DOMAIN_PROGRAM_INTERPOLATE(uv2)
}
我们并没有对实例ID进行插值,因为Unity不支持同时进行实例化和细分曲面。为了避免编译错误,我们要移除实例化的预处理命令。
现在我们有了新顶点,它将被送往几何着色器或者下一阶段的插值。我们修改函数的返回结构体,并使用一个自定义的函数处理数据:
[UNITY_domain("tri")]
InterpolatorsVertex MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
VertexData data;
#define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) data.fieldName = \
patch[0].fieldName * barycentricCoordinate.x + \
patch[1].fieldName * barycentricCoordinate.y + \
patch[2].fieldName * barycentricCoordinate.z;
MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)
MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
MY_DOMAIN_PROGRAM_INTERPOLATE(uv1)
MY_DOMAIN_PROGRAM_INTERPOLATE(uv2)
return MyVertexProgram(data);
}
注意在shader文件中用于处理命令标识:
#pragma domain MyDomainProgram
控制点
MyVertexProgram
只需要调用一次。不过我们仍需要定义在顶点着色器期间调用的顶点程序,位于外壳着色器前。这里我们可以在点上不做任何操作,所以我们可以只使用一个最简单的处理顶点数据的函数:
VertexData MyTessellationVertexProgram(VertexData v)
{
return v;
}
在我们的三个pass的shader中标识该方法:
#pragma vertex MyTessellationVertexProgram
这会带来另一个编译错误,即位置语义的重复使用。为了解决这一问题,我们使用一个替代的输出结构体,对于顶点位置使用INTERNALTESSPOS
语义。结构体剩下的部分和VertexData
一样,只是没有实例ID。:
struct TessellationControlPoint
{
float4 vertex : INTERNALTESSPOS;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
float2 uv1 : TEXCOORD1;
float2 uv2 : TEXCOORD2;
};
然后改变函数的返回值:
TessellationControlPoint MyTessellationVertexProgram(VertexData v)
{
TessellationControlPoint p;
p.vertex = v.vertex;
p.normal = v.normal;
p.tangent = v.tangent;
p.uv = v.uv;
p.uv1 = v.uv1;
p.uv2 = v.uv2;
return p;
}
然后,MyHullProgram
也需要改变参数中输入片的点的类型:
TessellationControlPoint MyHullProgram (
InputPatch<TessellationControlPoint, 3> patch,
uint id : SV_OutputControlPointID
)
{
return patch[id];
}
对于片常量函数也一样:
TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
TessellationFactors f;
f.edge[0] = 1;
f.edge[1] = 1;
f.edge[2] = 1;
f.inside = 1;
return f;
}
域着色器的参数类型也要改变:
InterpolatorsVertex MyDomainProgram(TessellationFactors factors, OutputPatch<TessellationControlPoint, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
至此,我们相当于编写了一个默认的细分曲面着色器。
细分三角形
在细分准备阶段我们可以细分片,我们可以使用一些更小的三角形集合来替代单个三角形。
细分系数
三角形片的细分由它的细分系数控制,我们在MPatchConstantFunction
中决定这些系数。目前,我们将它们设为1,这没有任何效果。我们尝试修改系数:
TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
TessellationFactors f;
f.edge[0] = 2;
f.edge[1] = 2;
f.edge[2] = 2;
f.inside = 2;
return f;
}
然后就会发现三角形被细分了:
原始四边形:
系数设为2,三角形每个边被分为两个子边,每个三角形生成三个新的顶点。同样,每个三角形的中心也添加了一个顶点,这允许每个原始边上生成两个三角形,所以原始的三角形被6个更小的三角形替代。
如果系数全设为3:
每个边被分为3个子边,在这种情况下,没有一个中心顶点,三个顶点会添加到原始三角形中,组成一个更小的内部三角形,外部三角形会和内部三角形相连,组成连续三角形。
如果细分系数为偶数,那么就会有单个中心顶点,奇数的话,就会有一个中心三角形。边与内部系数不同
边系数可以可以根据对应的边重写细分数量,只影响原始片边,不生成内部三角形。我们将内部系数设为7,边系数保持1,效果为:
可以发现,外围的三角形被剔除,感觉像是外部和内部的三角形被针线缝合在一起。
如果将边的系数设为7,内部系数设为1的话,结果为:
这种情况下,内部的因数的效果看起来就和2一样,否则新的三角形无法生成。
变化的因数
直接赋予细分因数不大常用,所以我们让其可配置,设定一个统一因数:
float _TessellationUniform;
...
TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
TessellationFactors f;
f.edge[0] = _TessellationUniform;
f.edge[1] = _TessellationUniform;
f.edge[2] = _TessellationUniform;
f.inside = _TessellationUniform;
return f;
}
然后在shader中声明这个属性
_TessellationUniform("Tessellation Uniform", Range(1,64))=1
小数化因数
即使我们使用float作为细分因数的类型,我们还是只能得到整数型的细分,这是因为我们使用的时整型的切分模式。我们可以改为fractional_odd
:
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("fractional_odd")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
TessellationControlPoint MyHullProgram (
InputPatch<TessellationControlPoint, 3> patch,
uint id : SV_OutputControlPointID
)
{
return patch[id];
}
```.
也可以使用`fractional_even`模式,只不过基准是偶数。比如说`fractional_odd`模式就经常使用,因为它可以处理1这一因数,但`fractional_even`会强制设为2。
# 细分的启发
怎样才是最佳的细分因数呢?这没有绝对的答案,完全取决于实际。这里给出几个相关思路
#### 边因数
尽管细分因数需要逐边的提供,但不需要直接基数赋值。例如,我们可以逐顶点的决定因数,然后逐边的平均。也许因数存储在纹理中。在任何情况下,用一个独立的函数取决定因数是很方便的,通过给定一条边的两个控制点。
```c
float TessellationEdgeFactor(TessellationControlPoint cp0, TessellationControlPoint cp1)
{
return _TessellationUniform;
}
该方法可以得到边的因数控制:
f.edge[0] = TessellationEdgeFactor(patch[1], patch[2]);
f.edge[1] = TessellationEdgeFactor(patch[2], patch[0]);
f.edge[2] = TessellationEdgeFactor(patch[0], patch[1]);
对于内部因数,我们直接使用边的平均:
f.inside = (f.edge[0]+f.edge[1]+f.edge[2])*(1/3.0);
边长度
边细分因数控制我们细分边的程度,那么因数如果基于这些边的长度那也是合情合理。例如,我们可以设定一个预期生成三角形边的长度,如果三角形边的长度大于该值,那么就应该通过预期的长度细分该三角形。添加一个变量:
float _TessellationEdgeLength;
然后在shader中添加一个属性:
_TessellationEdgeLength("Tessellation Edge Length", Range(0.1, 1)) = 0.5
我们需要一个着色器特性来在同一和基于边的细分中切换,使用:
#pragma shader_feature _TESSELLATION_EDGE
然后,在函数中进行相应的切换:
float TessellationEdgeFactor(TessellationControlPoint cp0, TessellationControlPoint cp1)
{
#if defined(_TESSELLATION_EDGE)
float3 p0 = mul(unity_ObjectToWorld, float4(cp0.vertex.xyz,1)).xyz;
float3 p1 = mul(unity_ObjectToWorld, float4(cp1.vertex.xyz,1)).xyz;
float edgeLength = distance(p0, p1);
return edgeLength / _TessellationEdgeLength;
#else
return _TessellationUniform;
#endif
}
屏幕空间的边长
我们可以根据物体在屏幕空间中的深度来调整细分的程度,因为远离摄像机的话,高精度细分是一种浪费。我们可以检测屏幕空间的边长来进行替代。首先调整属性的范围:
_TessellationEdgeLength("Tessellation Edge Length", Range(5, 100)) = 50
因为是像素的大小,所以设置大一些。
然后我们在方法中获得点在裁剪空间的位置,并计算距离(注意进行透视除法):
float TessellationEdgeFactor(TessellationControlPoint cp0, TessellationControlPoint cp1)
{
#if defined(_TESSELLATION_EDGE)
float4 p0 = UnityObjectToClipPos(cp0.vertex);
float4 p1 = UnityObjectToClipPos(cp1.vertex);
float edgeLength = distance(p0.xy/p0.w, p1.xy/p1.w);
return edgeLength * _ScreenParam.y / _TessellationEdgeLength;
#else
return _TessellationUniform;
#endif
}
使用观察距离
如果基于屏幕长度的话,那些在世界空间很长的边在屏幕空间就很很小,这样的话可能完全不会细分。我们可以回到使用世界空间边长度的方法,基于观察距离调整因数。
float3 p0 = mul(unity_ObjectToWorld, float4(cp0.vertex.xyz,1)).xyz;
float3 p1 = mul(unity_ObjectToWorld, float4(cp1.vertex.xyz,1)).xyz;
float edgeLength = distance(p0, p1);
float3 edgeCenter = (p0+p1)*0.5;
float viewDistance = distance(edgeCenter, _WorldSpaceCameraPos);
return edgeLength * _ScreenParams.y / (_TessellationEdgeLength * viewDistance);
使用正确的内部因数
尽管此时细分的结果看起来不错,但可能会因为内部细分的因数而变得很奇怪。比如下面这个例子,
在这个立方体例子中,两个相邻的三角形面的细分结果完全不同,这其实取决于三角形点的定义顺序。Unity默认的立方体不使用对称布局,而四边形使用,这也就意味着边的顺序会影响内部细分的因数。然而,我们只是获得了边因数的平均,所以不会影响顺序。
如果我们不调用函数,而是手动平均起来的话:
f.inside =
(TessellationEdgeFactor(patch[1], patch[2]) +
TessellationEdgeFactor(patch[2], patch[0]) +
TessellationEdgeFactor(patch[0], patch[1])) * (1 / 3.0);
结果就是这样:
可以发现完全不同了。问题出在哪呢?
注意,片常量函数和外壳着色器的剩余部分并行调用,但实际上更复杂。着色器编译器也可以并行计算边因数。在MyPatchConstantFunction
中代码会被拆散并部分复制,使用一个并行计算三个边因数的进程替代,一旦三个进程完成,他们的结果会用来计算内部的因数。
无论编译器是否决定使用一个进程转换,它实际上都应该不影响最后的结果,而只影响性能才对。不幸的是,对于OpenGL核心生成代码时会有BUG,在计算内部因数时,不会使用三个边因数,而是只使用第三个边因数。
在片常量函数中,着色器编译器优先并行,它尽可能分割进程,在之后就不能优化复制的TessellationEdgeFactor
的调用。我们以三个进程结束,每个进程计算两个点的世界位置、距离和最后的因数。然后,有一个进行计算内部的因数,也需要计算三个点的坐标,加上所有包含的距离和因数。当我们对内部因数做了所有的工作后,对每个边因数单独进行部分相同的工作就显得没那么有意义。
如果我们先计算点的世界坐标,接着对边和内部因数进行TessellationEdgeFactor
函数的调用,那么着色器编译器就不会对每个边因数进行进程切换。我们用一个进程进行所有的计算。
float TessellationEdgeFactor(float3 p0, float3 p1)
{
#if defined(_TESSELLATION_EDGE)
float edgeLength = distance(p0, p1);
float3 edgeCenter = (p0+p1)*0.5;
float viewDistance = distance(edgeCenter, _WorldSpaceCameraPos);
return edgeLength * _ScreenParams.y / (_TessellationEdgeLength * viewDistance);
#else
return _TessellationUniform;
#endif
}
TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
float3 p0 = mul(unity_ObjectToWorld, float4(patch[0].vertex.xyz,1)).xyz;
float3 p1 = mul(unity_ObjectToWorld, float4(patch[1].vertex.xyz,1)).xyz;
float3 p2 = mul(unity_ObjectToWorld, float4(patch[2].vertex.xyz,1)).xyz;
TessellationFactors f;
f.edge[0] = TessellationEdgeFactor(p1, p2);
f.edge[1] = TessellationEdgeFactor(p2, p0);
f.edge[2] = TessellationEdgeFactor(p0, p1);
f.inside = (TessellationEdgeFactor(p1, p2)+TessellationEdgeFactor(p2, p0)+TessellationEdgeFactor(p0, p1))*(1/3.0);
return f;
}
感谢Jasper Flick。