代码工程地址:
https://github.com/jiabaodan/Direct12BookReadingNotes
曲面细分阶段包含渲染管线中的三个阶段,用以细分几何物体,它在顶点着色器和几何着色器之间。使用曲面细分的主要原因:
- 基于GPU的LOD;
- 物理和动画的优化,可以在低面模型上计算物理效果和动画,然后细分为高面模型用以渲染;
- 节省内存(硬盘,RAM,VRAM)。
学习目标
- 学习曲面细分使用的patch基元类型;
- 学习曲面细分每个阶段的作用,以及他们的输入输出;
- 学习通过编写hull和domain着色器来细分几何体;
- 学习曲面细分的不同策略,以及曲面细分的优化;
- 学习贝塞尔曲线和平面的数学公式,以及如何用曲面细分来实现它。
1 曲面细分基元类型
当我们使用曲面细分渲染,我们不想IA阶段提交三角形列表,我们提交具有许多控制点的patches。D3D支持patches拥有1~32个控制点,并且由下面的基元类型定义:
D3D_PRIMITIVE_TOPOLOGY_1_CONTROL_POINT_PATCHLIST = 33,
D3D_PRIMITIVE_TOPOLOGY_2_CONTROL_POINT_PATCHLIST = 34,
D3D_PRIMITIVE_TOPOLOGY_3_CONTROL_POINT_PATCHLIST = 35,
D3D_PRIMITIVE_TOPOLOGY_4_CONTROL_POINT_PATCHLIST = 36,
.
.
.
D3D_PRIMITIVE_TOPOLOGY_31_CONTROL_POINT_PATCHLIST = 63,
D3D_PRIMITIVE_TOPOLOGY_32_CONTROL_POINT_PATCHLIST = 64,
一个三角形可以被理解为一个具有3个控制点的三角patch((D3D_PRIMITIVE_3_CONTROL_POINT_PATCH),所以你依然可以提交你的三角形网格。四边形可以被提交为(D3D_PRIMITIVE_4_CONTROL_POINT_PATCH)。这些patch最终会被曲面细分阶段细分为三角形。
当传递控制点基元类型到ID3D12GraphicsCommandList::IASetPrimitiveTopology时,设置D3D12_GRAPHICS_PIPELINE_STATE_DESC::PrimitiveTopologyType为D3D12_PRIMITIVE_TOPOLOGY_TYPE_PATCH:
opaquePsoDesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_PATCH;
1.1 曲面细分和顶点着色器
因为我们提交的是patch的控制点,所以顶点着色器可以正常处理这些控制点(和顶点一样)。
2 HULL着色器
HULL着色器实际上包含2个着色器:常量Hull着色器(Constant Hull Shader)和控制点Hull着色器(Control Point Hull Shader)。
2.1 常量Hull着色器
常量Hull着色器针对每个patch执行,并且负责输出网格的曲面细分因子(tessellation factors);曲面细分因子命令曲面细分阶段对patch细分多少。下面是一个将拥有4个控制点的方块patch均匀细分3次的例子:
struct PatchTess
{
float EdgeTess[4] : SV_TessFactor;
float InsideTess[2] : SV_InsideTessFactor;
// Additional info you want associated per patch.
};
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;
}
常量Hull着色器必须输出细分因子,细分因子取决于patch的拓扑结构。
除了细分因子(SV_TessFactor和SV_InsideTessFactor),你还可以输出其他patch的信息,让domain着色器接收并使用。
细分一个方块patch包含两部分:
- 四条边的细分因子角色四条边怎么细分;
- 2个内部细分因子决定内部如何细分。
细分一个三角形patch同样包含两部分:
- 3条边的细分因子;
- 1个内部细分因子;
D3D11硬件支持的最大细分因子是64。如果所有细分因子都是0,那么当前patch就拒绝进入后面的阶段。它可以帮助我们基于patch在背面消除和视锥体裁切上实现优化。
- 如果这个patch不在视锥体内,可以让他拒绝进入后面的阶段;
- 如果这个patch是背面,可以让它拒绝进入后面的阶段;
具体裁切多少主要基于需求,不要做不需要的裁切来浪费性能。下面是一些常用的度量单位来决定裁切多少:
- 于相机的距离;
- 屏幕的覆盖率;
- 三角形的方向和定位;
- 粗糙度。
[Story10]给出了下面的优化建议:
- 如果细分因子是1(也就是不细分),走一遍细分阶段流程是浪费GPU开销;
- 因为是基于GPU实现的,不要细分一个覆盖小于8个像素的这种太小的三角形;
- 批量调用具有细分的绘制调用(频繁打开和关闭曲面细分非常浪费性能)。
2.1 控制点Hull着色器
控制点Hull着色器输入一系列控制点,输出一系列控制点。它每次控制点输出的时候调用一次。一个Hull着色器是改变平面的表现,比如一个将普通的三角形(拥有3个控制点)修改为立方贝塞尔三角形patch(拥有10个控制点)。这种策略称之为N-patches方案或者PN三角形方案([Vlachos01])。对于我们的第一个Demo,我们只是简单的pass-through着色器,只传递控制点,不修改(驱动可以检测和优化pass-through着色器([Bilodeau10b])):
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;
}
Hull着色器通过InputPatch输入参数传进所有控制点。系统值SV_OutputControlPointID给出控制点的索引。输入控制点的数量不需要匹配输出控制点的数量。
控制点Hull着色器介绍了一些属性:
- domain:patch类型:tri,quad或者isoline;
- partitioning:指定细分的模式:
a、integer:新顶点添加/删除值根据整形细分因子,小数部分会无视;
b、Fractional((fractional_even/fractional_odd)):新顶点添加/删除值根据整形细分因子,但是通过小数部分滑动。 - outputtopology:细分后的三角形的缠绕顺序,triangle_cw(顺时针)、triangle_ccw(逆时针)、line(针对线段的细分);
- outputcontrolpoints:Hull着色器执行的次数,每次输出一个控制点。SV_OutputControlPointID给出输出点在Hull着色器中的索引。
- patchconstantfunc:常量Hull着色器函数的名称;
- maxtessfactor:提示驱动指定你的着色器使用的最大细分因子。这个可以让硬件有一个潜在的优化(让硬件知道最大细分因子)。D3D11硬件支持的最大细分因子是64.
3 曲面细分阶段
作为程序员,我们无法控制曲面细分阶段,它是由硬件完成的,基于常量Hull着色器程序输出的细分因子对Patch进行细分,下面是一些基于不同因子细分的例子:
3.1 方块patch的细分例子:
3.2 三角形patch的细分例子:
4 DOMAIN着色器
曲面细分阶段输出了所有新的顶点和三角形。DOMAIN着色器对每个新创建的顶点进行调用。当曲面细分开启的时候,顶点着色器运行与每个控制点,Hull着色器是每个细分patch的顶点着色器。Domain着色器中对每个细分完成的patch变换到其次裁切空间。
对于方块patch,Domain着色器输入细分因子(常量Hull着色器的输出),细分顶点位置(u, v)的坐标参数,所有从控制点hull着色器输出的控制点。Domain并不给你每个顶点的实际位置,而是patch空间的(u, v),顶点的位置通过双线性差值得到:
struct DomainOut
{
float4 PosH : SV_POSITION;
};
// The domain shader is called for every vertex created by the tessellator.
// It is like the vertex shader after tessellation.
[domain("quad")]
DomainOut DS(PatchTess patchTess,
float2 uv : SV_DomainLocation,
const OutputPatch<HullOut, 4> quad)
{
DomainOut dout;
// Bilinear interpolation.
float3 v1 = lerp(quad[0].PosL, quad[1].PosL, uv.x);
float3 v2 = lerp(quad[2].PosL, quad[3].PosL, uv.x);
float3 p = lerp(v1, v2, uv.y);
float4 posW = mul(float4(p, 1.0f), gWorld);
dout.PosH = mul(posW, gViewProj);
return dout;
}
三角形patch类似,只是坐标从(u, v)变为三维重心(u, v, w)坐标。修改为重心坐标系原因是贝塞尔三角形patches通过重心坐标系定义的。
5 细分一个平面方块
作为本章中的一个Demo,我们提交一个方块patch,然后根据和摄像机的距离进行细分,然后根据数学公式对顶点进行偏移(类似之前“hills”Demo)。
顶点缓冲保存4个控制点,创建如下:
void BasicTessellationApp::BuildQuadPatchGeometry()
{
std::array<XMFLOAT3,4> vertices =
{
XMFLOAT3(-10.0f, 0.0f, +10.0f),
XMFLOAT3(+10.0f, 0.0f, +10.0f),
XMFLOAT3(-10.0f, 0.0f, -10.0f),
XMFLOAT3(+10.0f, 0.0f, -10.0f)
};
std::array<std::int16_t, 4> indices = { 0, 1, 2, 3 };
const UINT vbByteSize = (UINT)vertices.size() * sizeof(Vertex);
const UINT ibByteSize = (UINT)indices.size() * sizeof(std::uint16_t);
auto geo = std::make_unique<MeshGeometry>();
geo->Name = "quadpatchGeo";
ThrowIfFailed(D3DCreateBlob(vbByteSize, &geo->VertexBufferCPU));
CopyMemory(geo->VertexBufferCPU->GetBufferPointer(), vertices.data(), vbByteSize);
ThrowIfFailed(D3DCreateBlob(ibByteSize, &geo->IndexBufferCPU));
CopyMemory(geo->IndexBufferCPU->GetBufferPointer(), indices.data(), ibByteSize);
geo->VertexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
mCommandList.Get(), vertices.data(),
vbByteSize, geo->VertexBufferUploader);
geo->IndexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
mCommandList.Get(), indices.data(),
ibByteSize, geo->IndexBufferUploader);
geo->VertexByteStride = sizeof(XMFLOAT3);
geo->VertexBufferByteSize = vbByteSize;
geo->IndexFormat = DXGI_FORMAT_R16_UINT;
geo->IndexBufferByteSize = ibByteSize;
SubmeshGeometry quadSubmesh;
quadSubmesh.IndexCount = 4;
quadSubmesh.StartIndexLocation = 0;
quadSubmesh.BaseVertexLocation = 0;
geo->DrawArgs["quadpatch"] = quadSubmesh;
mGeometries[geo->Name] = std::move(geo);
}
渲染物体创建如下:
void BasicTessellationApp::BuildRenderItems()
{
auto quadPatchRitem = std::make_unique<RenderItem>();
quadPatchRitem->World = MathHelper::Identity4x4();
quadPatchRitem->TexTransform = MathHelper::Identity4x4();
quadPatchRitem->ObjCBIndex = 0;
quadPatchRitem->Mat = mMaterials["whiteMat"].get();
quadPatchRitem->Geo = mGeometries["quadpatchGeo"].get();
quadPatchRitem->PrimitiveType = D3D_PRIMITIVE_TOPOLOGY_4_CONTROL_POINT_PATCHLIST;
quadPatchRitem->IndexCount = quadPatchRitem->Geo->DrawArgs["quadpatch"].IndexCount;
quadPatchRitem->StartIndexLocation = quadPatchRitem->Geo->DrawArgs["quadpatch"].StartIndexLocation;
quadPatchRitem->BaseVertexLocation = quadPatchRitem->Geo->DrawArgs["quadpatch"].BaseVertexLocation;
mRitemLayer[(int)RenderLayer::Opaque].push_back(quadPatchRitem.mAllRitems.push_back(std::move(quadPatchRitem));
}
Hull着色器和前面介绍的基本一致,不同的地方在于,根据和摄像机的距离决定细分多少;并且它是一个pass-through着色器:
struct VertexIn
{
float3 PosL : POSITION;
};
struct VertexOut
{
float3 PosL : POSITION;
};
VertexOut VS(VertexIn vin)
{
VertexOut vout;
vout.PosL = vin.PosL;
return vout;
}
struct PatchTess
{
float EdgeTess[4] : SV_TessFactor;
float InsideTess[2] : SV_InsideTessFactor;
};
PatchTess ConstantHS(InputPatch<VertexOut, 4> patch, uint patchID : SV_PrimitiveID)
{
PatchTess pt;
float3 centerL = 0.25f*(patch[0].PosL +
patch[1].PosL +
patch[2].PosL +
patch[3].PosL);
float3 centerW = mul(float4(centerL, 1.0f), gWorld).xyz;
float d = distance(centerW, gEyePosW);
// Tessellate the patch based on distance from the eye such that
// the tessellation is 0 if d >= d1 and 64 if d <= d0. The interval
// [d0, d1] defines the range we tessellate in.
const float d0 = 20.0f;
const float d1 = 100.0f;
float tess = 64.0f*saturate( (d1-d)/(d1-d0) );
// Uniformly tessellate the patch.
pt.EdgeTess[0] = tess;
pt.EdgeTess[1] = tess;
pt.EdgeTess[2] = tess;
pt.EdgeTess[3] = tess;
pt.InsideTess[0] = tess;
pt.InsideTess[1] = tess;
return pt;
}
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;
}
最后在domain着色器中对顶点的y坐标进行偏移:
struct DomainOut
{
float4 PosH : SV_POSITION;
};
// The domain shader is called for every vertex created by the tessellator.
// It is like the vertex shader after tessellation.
[domain("quad")]
DomainOut DS(PatchTess patchTess,
float2 uv : SV_DomainLocation,
const OutputPatch<HullOut, 4> quad)
{
DomainOut dout;
// Bilinear interpolation.
float3 v1 = lerp(quad[0].PosL, quad[1].PosL, uv.x);
float3 v2 = lerp(quad[2].PosL, quad[3].PosL, uv.x);
float3 p = lerp(v1, v2, uv.y);
// Displacement mapping
p.y = 0.3f*( p.z*sin(p.x) + p.x*cos(p.z) );
float4 posW = mul(float4(p, 1.0f), gWorld);
dout.PosH = mul(posW, gViewProj);
return dout;
}
float4 PS(DomainOut pin) : SV_Target
{
return float4(1.0f, 1.0f, 1.0f, 1.0f);
}
6 立方贝塞尔方块PATCHES
本节我们通过描述立方贝塞尔方块Patches来展示如何通过大量控制点构成一个表面。
6.1 贝塞尔曲线
有三个不共线的控制点p0, p1, 和p2定义一个贝塞尔曲线,那么如果要求曲线上的点p(t)的位置,首先对p0、p1和p1、p2根据t进行线性插值:
然后p(t)点就可以通过基于t的线性插值得到:
将上面两组方程结合起来,就得到贝塞尔曲线方程:
类似的方式,如果是4个控制点(p0, p1, p2,和p3):
第一次插值:
第二次插值:
第三次插值:
将上面方程结合起来,最终公式为:
通常情况下都只用到3个点,因为已经足够光滑,和控制表面。
针对N维的贝塞尔曲线方程是Bernstein basis functions,可以定义为:
对于三维曲线Bernstein basis functions是:
相比于之前4个控制点的最终方程,我们可以将贝塞尔曲线方程写为:
然后求出三次Bernstein basis functions的偏导数:
三次贝塞尔曲线的偏导数就是:
偏导数方程对求表面的切线方向很有用。
6.2 三次贝塞尔平面
对于一个具有4x4个控制点的patch,我可以将每一行定义为一个具有4个控制点的三次贝塞尔曲线;那么第i行的贝塞尔曲线为:
如果我们在u0的位置求这些贝塞尔曲线的值,那么我们会得到从列方向上的4个点。我们可以使用这4个点定义另一条贝塞尔曲线:
如果我们让U正常变化,我们就会扫出一组类似的贝塞尔曲线,组成一个贝塞尔平面。
它的偏导数用以求切线和法线向量:
6.3 三次贝塞尔平面求解代码
本节给出三次贝塞尔平面求解代码,为了方便,先给出完整公式:
代码直接映射到上面给出的公式:
float4 BernsteinBasis(float t)
{
float invT = 1.0f - t;
return float4( invT * invT * invT,
3.0f * t * invT * invT,
3.0f * t * t * invT,
t * t * t );
}
float3 CubicBezierSum(const OutputPatch<HullOut, 16> bezpatch, float4 basisU, float4 basisV)
{
float3 sum = float3(0.0f, 0.0f, 0.0f);
sum = basisV.x * (basisU.x*bezpatch[0].PosL + basisU.y*bezpatch[1].PosL + basisU.z*bezpatch[2].PosL + basisU.w*bezpatch[3].PosL );
sum += basisV.y * (basisU.x*bezpatch[4].PosL + basisU.y*bezpatch[5].PosL + basisU.z*bezpatch[6].PosL + basisU.w*bezpatch[7].PosL );
sum += basisV.z * (basisU.x*bezpatch[8].PosL + basisU.y*bezpatch[9].PosL + basisU.z*bezpatch[10].PosL + basisU.w*bezpatch[11].PosL);
sum += basisV.w * (basisU.x*bezpatch[12].PosL + basisU.y*bezpatch[13].PosL + basisU.z*bezpatch[14].PosL + basisU.w*bezpatch[15].PosL);
return sum;
}
float4 dBernsteinBasis(float t)
{
float invT = 1.0f - t;
return float4( -3 * invT * invT,
3 * invT * invT - 6 * t * invT,
6 * t * invT - 3 * t * t,
3 * t * t );
}
6.4 定义Patch几何
我们的顶点缓冲保存16个控制点:
void BezierPatchApp::BuildQuadPatchGeometry()
{
std::array<XMFLOAT3,16> vertices =
{
// Row 0
XMFLOAT3(-10.0f, -10.0f, +15.0f),
XMFLOAT3(-5.0f, 0.0f, +15.0f),
XMFLOAT3(+5.0f, 0.0f, +15.0f),
XMFLOAT3(+10.0f, 0.0f, +15.0f),
// Row 1
XMFLOAT3(-15.0f, 0.0f, +5.0f),
XMFLOAT3(-5.0f, 0.0f, +5.0f),
748
XMFLOAT3(+5.0f, 20.0f, +5.0f),
XMFLOAT3(+15.0f, 0.0f, +5.0f),
// Row 2
XMFLOAT3(-15.0f, 0.0f, -5.0f),
XMFLOAT3(-5.0f, 0.0f, -5.0f),
XMFLOAT3(+5.0f, 0.0f, -5.0f),
XMFLOAT3(+15.0f, 0.0f, -5.0f),
// Row 3
XMFLOAT3(-10.0f, 10.0f, -15.0f),
XMFLOAT3(-5.0f, 0.0f, -15.0f),
XMFLOAT3(+5.0f, 0.0f, -15.0f),
XMFLOAT3(+25.0f, 10.0f, -15.0f)
};
std::array<std::int16_t, 16> indices =
{
0, 1, 2, 3,
4, 5, 6, 7,
8, 9, 10, 11,
12, 13, 14, 15
};
const UINT vbByteSize = (UINT)vertices.size() * sizeof(Vertex);
const UINT ibByteSize = (UINT)indices.size() * sizeof(std::uint16_t);
auto geo = std::make_unique<MeshGeometry>();
geo->Name = "quadpatchGeo";
ThrowIfFailed(D3DCreateBlob(vbByteSize, &geo->VertexBufferCPU));
CopyMemory(geo->VertexBufferCPU->GetBufferPointer(), vertices.data(), vbByteSize);
ThrowIfFailed(D3DCreateBlob(ibByteSize, &geo->IndexBufferCPU));
CopyMemory(geo->IndexBufferCPU->GetBufferPointer(), indices.data(), ibByteSize);
geo->VertexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
mCommandList.Get(), vertices.data(),
vbByteSize, geo->VertexBufferUploader);
geo->IndexBufferGPU = d3dUtil::CreateDefaultBuffer(md3dDevice.Get(),
mCommandList.Get(), indices.data(),
ibByteSize, geo->IndexBufferUploader);
geo->VertexByteStride = sizeof(XMFLOAT3);
geo->VertexBufferByteSize = vbByteSize;
geo->IndexFormat = DXGI_FORMAT_R16_UINT;
geo->IndexBufferByteSize = ibByteSize;
SubmeshGeometry quadSubmesh;
quadSubmesh.IndexCount = (UINT)indices.size();
quadSubmesh.StartIndexLocation = 0;
quadSubmesh.BaseVertexLocation = 0;
geo->DrawArgs["quadpatch"] = quadSubmesh;
mGeometries[geo->Name] = std::move(geo);
}
我们的渲染物体创建和定义如下:
void BezierPatchApp::BuildRenderItems()
{
auto quadPatchRitem = std::make_unique<RenderItem>();
quadPatchRitem->World = MathHelper::Identity4x4();
quadPatchRitem->TexTransform = MathHelper::Identity4x4();
quadPatchRitem->ObjCBIndex = 0;
quadPatchRitem->Mat = mMaterials["whiteMat"].get();
quadPatchRitem->Geo = mGeometries["quadpatchGeo"].get();
quadPatchRitem->PrimitiveType = D3D11_PRIMITIVE_TOPOLOGY_16_CONTROL_POINT_PATCHLIST;
quadPatchRitem->IndexCount = quadPatchRitem->Geo->DrawArgs["quadpatch"].IndexCount;
quadPatchRitem->StartIndexLocation = quadPatchRitem->Geo->DrawArgs["quadpatch"].StartIndexLocation;
quadPatchRitem->BaseVertexLocation = quadPatchRitem->Geo->DrawArgs["quadpatch"].BaseVertexLocation;
mRitemLayer[(int)RenderLayer::Opaque].push_back(quadPatchRitem.mAllRitems.push_back(std::move(quadPatchRitem));
}
7 总结
- 曲面细分阶段是渲染流水线中的一个可选的阶段,它包含Hull着色器,曲面细分,Domain着色器;曲面细分完全由硬件完成,其他两个阶段是可编程的;
- 曲面细分可以优化内存,也可以减少物理和动画运算(在低模上计算),可以实现LOD(以前只能放到CPU);
- 提交曲面细分控制点要使用新的基元类型;单个基元D3D12支持1到32个控制点,由枚举D3D_PRIMITIVE_1_CONTROL_POINT_PATCH到D3D_PRIMITIVE_32_CONTROL_POINT_PATCH定义;
- 启用曲面细分后,顶点着色器输入控制点,对每个控制点进行传统的动画和物理计算;Hull着色器包含常量Hull着色器(Constant Hull Shader)和控制点Hull着色器(Control Point Hull Shader)。常量Hull着色器针对每个Patch执行,输出度每个Patch细分多少的细分因子(tessellation factors)(也可以添加其他可选数据)。控制点Hull着色器在每次控制点输出的时候调用一次,它修改了表面的表达方式。比如一个有3个控制点的三角形,可以输出为有10个控制点的贝塞尔三角面;
- Domain着色器对每个细分生成的顶点调用一次,在这里对每个顶点投射到其次裁切空间;
- 如果不需要细分物体,就不要开启细分阶段,因为会有性能开销。避免细分太多覆盖小于8像素的三角形。将需要细分的绘制放到一起,不要在同一帧中频繁开启和关闭细分。Hull着色器中使用背面消除和视锥体消除屏蔽看不到的Patch;
- 用参数方程定义的贝塞尔曲线和平面,可以用来表示平滑的曲线或表面。它们通过控制点在确定形状。为了让我们可以直接绘制平滑的曲线和表面,贝塞尔表面被很多流行的硬件细分算法使用,比如PN Triangles 和 Catmull-Clark approximations。
8 练习
本章内容我目前用不到,练习暂时不做。