首先用一张图来回顾一下渲染管线的各个阶段,目前为止我们接触的着色器有顶点着色器和像素着色器,而接触到的渲染管线阶段有:输入装配阶段、顶点着色阶段、光栅化阶段、像素着色阶段、输出合并阶段。 可以看到,几何着色器是我们在将顶点送入光栅化阶段之前,可以操作顶点的最后一个阶段。它同样也允许我们编写自己的着色器代码。几何着色器可以做如下事情:
让程序自动决定如何在渲染管线中插入/移除几何体;通过流输出阶段将顶点信息再次传递到顶点缓冲区;改变图元类型(如输入点图元,输出三角形图元);但它也有缺点,几何着色器输出的顶点数据很可能是有较多重复的,从流输出拿回到顶点缓冲区的话会占用较多的内存空间。它本身无法输出索引数组。
几何着色阶段会收到一系列代表输入几何体类型的顶点,然后我们可以自由选择其中的这些顶点信息,然后交给流输出对象重新解释成新的图元类型(或者不变),传递给流输出阶段或者是光栅化阶段。而几何着色器仅能够接受来自输入装配阶段提供的顶点信息,对每个顶点进行处理,无法自行决定增减顶点。
注意:离开几何着色器的顶点如果要传递给光栅化阶段,需要包含有转换到齐次裁剪坐标系的坐标信息(语义为SV_POSITION的float4向量)。
若我们直接从VS项目新建一个几何着色器文件,则可以看到下面的代码:
struct GSOutput { float4 pos : SV_POSITION; }; [maxvertexcount(3)] void main(triangle float4 input[3] : SV_POSITION, inout TriangleStream<GSOutput> output) { for (uint i = 0; i < 3; i++) { GSOutput element; element.pos = input[i]; output.Append(element); } }若在输入装配阶段指定使用TriangleList图元的话,初步观察该代码,实际上你可以发现其实该着色器只是把输入的顶点按原样输出给流输出对象,即跟什么都没做(咸鱼)有什么区别。。不过从这份代码里面就已经包含了几何着色器所特有的绝大部分语法了。
首先,几何着色器是根据图元类型来进行调用的,若使用的是TriangleList,则每一个三角形的三个顶点都会作为输入,触发几何着色器的调用。这样一个TriangleList解释的30个顶点会触发10次调用。
对于几何着色器,我们必须要指定它每次调用所允许输出的最大顶点数目。我们可以使用属性语法来强行修改着色器行为:
[maxvertexcount(N)]
这里N就是每次调用允许产出的最大顶点数目,然后最终输出的顶点数目不会超过N的值。maxvertexcount的值应当尽可能的小。
注意: 1. maxvertexcount的值应当设置到尽可能小的值,因为它将直接决定几何着色器的运行效率。 2. 几何着色器的每次调用最多只能处理1024个标量,对于只包含4D位置向量的顶点来说也只能处理256个顶点。 3. 几何着色器输入的结构体类型不允许超过128个标量,对于只包含4D位置向量的顶点来说也只能包含32个顶点。然后代码中第一个参数的修饰符triangle是用于指定输入的图元类型,具体支持的关键字如下: 而参数类型可以是用户自定义的结构体类型,或者是向量(float4)类型。从顶点着色器传过来的顶点至少会包含一个表示齐次裁剪坐标的向量。
参数名inupt实际上用户是可以任意指定的。
对于该输入参数的元素数目,取决于前面声明的图元类型: 而第二个参数必须是一个流输出对象,而且需要被指定为inout可读写类型。可以看到,它是一个类模板,模板的形参指定要输出的类型。流输出对象有如下三种: 流输出对象都具有下面两种方法:
注意: 1. 所谓的删除顶点,实际上就是不将该顶点传递给流输出对象 2. 若传入的顶点中多余的部分无法构成对应的图元,则抛弃掉这些多余的顶点首先贴出Basic.hlsli文件的内容(以下案例皆使用此结构):
#include "LightHelper.hlsli" cbuffer CBChangesEveryFrame : register(b0) { matrix g_World; matrix g_WorldInvTranspose; } cbuffer CBChangesOnResize : register(b1) { matrix g_Proj; } cbuffer CBChangesRarely : register(b2) { DirectionalLight g_DirLight[5]; PointLight g_PointLight[5]; SpotLight g_SpotLight[5]; Material g_Material; matrix g_View; float3 g_EyePosW; float g_CylinderHeight; } //2.1用 struct VertexPosColor { float3 PosL : POSITION; float4 Color : COLOR; }; //2.2用 struct VertexPosHColor { float4 PosH : SV_POSITION; float4 Color : COLOR; }; //2.3用 struct VertexPosNormalColor { float3 PosL : POSITION; float3 NormalL : NORMAL; float4 Color : COLOR; }; struct VertexPosHWNormalColor { float4 PosH : SV_POSITION; float3 PosW : POSITION; float3 NormalW : NORMAL; float4 Color : COLOR; };现在我们的目标是把一个三角形分裂成三个三角形:
已知图元类型为LineStrip,现在有一系列连续的顶点构成圆线(近似圆弧的连续折线),构造出圆柱体的侧面。即输入图元类型为线段,输出一个矩形(两个三角形)。 思路: 光有顶点位置还不足以构造出圆柱体侧面,因为无法确定圆柱往哪个方向延伸。所以我们还需要对每个顶点引入所在圆柱侧面的法向量,通过叉乘就可以确定上方向/下方向并进行延伸了。
画出顶点的法向量可以帮助你进行调试,排查法向量是否出现了问题。这时候图元的类型为PointList,需要通过几何着色器输出一个线段(两个顶点)。由于顶点中包含法向量,剩下的就是要自行决定法向量的长度。
下图的法向量长度为0.5:
HLSL代码
// Normal_VS.hlsl #include "Basic.hlsli" VertexPosHWNormalColor VS(VertexPosNormalColor vIn) { VertexPosHWNormalColor vOut; matrix viewProj = mul(g_View, g_Proj); float4 posW = mul(float4(vIn.PosL, 1.0f), g_World); vOut.PosH = mul(posW, viewProj); vOut.PosW = posW.xyz; vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose); vOut.Color = vIn.Color; return vOut; } // Normal_GS.hlsl #include "Basic.hlsli" [maxvertexcount(2)] void GS(point VertexPosHWNormalColor input[1], inout LineStream<VertexPosHWNormalColor> output) { matrix viewProj = mul(g_View, g_Proj); VertexPosHWNormalColor v; // 防止资源争夺 v.PosW = input[0].PosW + input[0].NormalW * 0.01f; v.NormalW = input[0].NormalW; v.PosH = mul(float4(v.PosW, 1.0f), viewProj); v.Color = float4(1.0, 0.0, 0.0, 0.0); output.Append(v); v.PosW = v.PosW + input[0].NormalW * 0.5f; v.PosH = mul(float4(v.PosW, 1.0f), viewProj); output.Append(v); } // Normal_PS.hlsl #include "Basic.hlsli" float4 PS(VertexPosHWNormalColor pIn) : SV_TARGET { return pIn.Color; }在上一部分中,我们知道了如何使用几何着色器来重新组装图元,比如从一个三角形分裂成三个三角形。但是为了实现更高阶的分形,我们必须要从几何着色器拿到输出的顶点。这里我们可以使用可选的流输出阶段来拿到顶点集合。
现在我们知道GPU可以写入纹理(textures),例如深度/模板缓冲区以及后备缓冲区。当然,我们也可以通过渲染管线的流输出阶段让GPU将几何着色器输出的顶点集合写入到指定的顶点缓冲区(vertex buffer)。除此之外,我们还能够指定不进行光栅化以及后续的所有阶段,仅让顶点数据经过流输出阶段。 在几何着色器中,最多四个流输出对象可以被设置,即几何着色器的入口函数中只允许设置四个流输出对象的参数。当多个流输出对象存在时,它们必须都要为PointStream类模板,但允许模板参数不同。输出的顶点回流到顶点缓冲区后可以再次进行一遍新的渲染管线流程。
上一部分也提到,几何着色器的单次调用不能产出超过1024个标量。因此分配给所有流输出对象的标量总和不能超过1024。比如现在我有2个流输出对象,它们的结构体相同,容纳512个标量,那最多仅允许输出2个这样的顶点来分配给这2个流输出对象。
ID3D11DeviceContext::SOSetTargets方法–绑定流输出对应用于接收数据的顶点缓冲区
void ID3D11DeviceContext::SOSetTargets( UINT NumBuffers, // [In]顶点缓冲区数目 ID3D11Buffer * const *ppSOTargets, // [In]顶点缓冲区数组 const UINT *pOffsets // [In]一个数组包含对每个顶点缓冲区的字节偏移量 );该方法多允许设置4个顶点缓冲区。
每个要绑定到流输出阶段的缓冲区资源必须要在创建的时候额外设置D3D11_BIND_STREAM_OUTPUT绑定标签。
若偏移值设为-1,则会引起流输出缓冲区被追加到最后一个缓冲区的后面
顶点缓冲区绑定到流输出阶段的输出槽0操作如下:
UINT offset = 0; m_pd3dImmediateContext->SOSetTargets(1, vertexBufferOut.GetAddressOf(), &offset);如果我们需要恢复默认的状态,则可以这样调用:
ID3D11Buffer* nullBuffer = nullptr; UINT offset = 0; m_pd3dImmediateContext->SOSetTargets(1, &nullBuffer, &offset); 注意: 如果使用的是当前绑定到输入装配阶段的顶点缓冲区,则绑定会失效。因为顶点缓冲区不可以同时被绑定到输入装配阶段和流输出阶段。因为后续我们是将每一阶输出的顶点都保存下来,即便不需要交换顶点缓冲区,但也有可能出现同时绑定输入/输出的情况。一种合理的绑定顺序如下:
// 先恢复流输出默认设置,防止顶点缓冲区同时绑定在输入和输出阶段 UINT stride = sizeof(VertexPosColor); UINT offset = 0; ID3D11Buffer * nullBuffer = nullptr; m_pd3dImmediateContext->SOSetTargets(1, &nullBuffer, &offset); // ... m_pd3dImmediateContext->IASetInputLayout(mVertexPosColorLayout.Get()); // ... m_pd3dImmediateContext->SOSetTargets(1, vertexBufferOut.GetAddressOf(), &offset);当渲染管线完成一次流输出后,我们就可以用下面的方法来获取绑定在流输出阶段上的顶点缓冲区(当然你本身持有该缓冲区的指针的话就不需要了)
ID3D11DeviceContext::SOGetTargets方法–获取绑定在流输出阶段的顶点缓冲区
void ID3D11DeviceContext::SOGetTargets( UINT NumBuffers, // [In]缓冲区数目 ID3D11Buffer **ppSOTargets // [Out]获取绑定流输出阶段的顶点缓冲区 );输出的顶点缓冲区引用数会加1,最好是能够使用ComPtr来承接顶点缓冲区,否则就要在结束的时候手工调用Release方法,若忘记调用则会引发内存泄漏。
ID3D11Device::CreateGeometryShaderWithStreamOutput方法–创建带流输出阶段的几何着色器 接下来我们需要指定数据会流向哪个输出槽,首先我们需要填充结构体D3D11_SO_DECLARATION_ENTRY,结构体声明如下:
typedef struct D3D11_SO_DECLARATION_ENTRY { UINT Stream; // 输出流索引,从0开始 LPCSTR SemanticName; // 语义名 UINT SemanticIndex; // 语义索引 BYTE StartComponent; // 从第几个分量(xyzw)开始,只能取0-3 BYTE ComponentCount; // 分量的输出数目,只能取1-4 BYTE OutputSlot; // 输出槽索引,只能取0-3 };其中,语义名SemanticName用于指定在几何着色器的流输出对象对应的结构体中该语义描述的成员,然后用语义索引SemanticIndex指定存在同名语义下用索引值标记的唯一成员。
然后StartComponent和ComponentCount用于控制该向量需要输出哪些分量。若StartComponent为1,ComponentCount为2,则输出的分量为(y, z),而要输出全部分量,则指定StartCompnent为0, ComponentCount为4.
输出槽索引OutputSlot用于指定选择绑定流输出的缓冲区数组中的某一元素。
由于这里一个结构体只能指定某个输出流中的某一向量,所以通常我们需要像顶点输入布局那样传递一个数组来取出组合成特定顶点。
比如说现在顶点着色器输入的顶点和流输出的顶点是一致的:
struct VertexPosColor { DirectX::XMFLOAT3 pos; DirectX::XMFLOAT4 color; static const D3D11_INPUT_ELEMENT_DESC inputLayout[2]; };输入布局描述如下:
const D3D11_INPUT_ELEMENT_DESC VertexPosColor::inputLayout[2] = { { "POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0 }, { "COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0 } };HLSL中的结构体如下:
struct VertexPosColor { float3 PosL : POSITION; float4 Color : COLOR; };流输出的入口描述如下:
const D3D11_SO_DECLARATION_ENTRY posColorLayout[2] = { { 0, "POSITION", 0, 0, 3, 0 }, { 0, "COLOR", 0, 0, 4, 0 } };这里对应的是索引为0的流输出对象,输出给绑定在索引为0的输出槽的顶点缓冲区,先输出语义为POSITION的向量中的xyz分量,然后输出COLOR整个向量。这样一个输出的顶点就和原来的顶点一致了。
接下来给出ID3D11Device::CreateGeometryShaderWithStreamOutput方法的原型:
HRESULT ID3D11Device::CreateGeometryShaderWithStreamOutput( const void *pShaderBytecode, // [In]编译好的着色器字节码 SIZE_T BytecodeLength, // [In]字节码长度 const D3D11_SO_DECLARATION_ENTRY *pSODeclaration, // [In]D3D11_SO_DECLARATION_ENTRY的数组 UINT NumEntries, // [In]入口总数 const UINT *pBufferStrides, // [In]一个数组包含了每个绑定到流输出的缓冲区中顶点字节大小 UINT NumStrides, // [In]上面数组的元素数目 UINT RasterizedStream, // [In]按索引指定哪个流输出对象用于传递到光栅化阶段 ID3D11ClassLinkage *pClassLinkage, // [In]忽略 ID3D11GeometryShader **ppGeometryShader // [Out]创建好的几何着色器 );如果不需要有流输出对象提供数据给光栅化阶段,则RasterizedStream应当指定为D3D11_SO_NO_RASTERIZED_STREAM。即便某一流输出对象传递了数据给光栅化阶段,它仍可以提供数据给某一绑定的缓冲区。
下面是一个调用的例子:
const D3D11_SO_DECLARATION_ENTRY posColorLayout[2] = { { 0, "POSITION", 0, 0, 3, 0 }, { 0, "COLOR", 0, 0, 4, 0 } }; HR(device->CreateGeometryShaderWithStreamOutput(blob->GetBufferPointer(), blob->GetBufferSize(), posColorLayout, ARRAYSIZE(posColorLayout), &stridePosColor, 1, D3D11_SO_NO_RASTERIZED_STREAM, nullptr, m_pTriangleSOGS.GetAddressOf()));当该着色器被绑定到渲染管线上,流输出阶段就会被激活,我们可以使用ID3D11DeviceContext::Draw方法来进行绘制。然后当渲染管线开始执行的时候,任何传递给几何着色器中的流输出对象的数据,都会基于语义名和语义索引尝试匹配输出布局。一旦发现有匹配的语义,该数据就会流向对应的缓冲区来创建完整的输出顶点集。
预备知识:
ID3D11DeviceContext::DrawAuto方法–绘制未知数目的图元 这是一个唯一不需要形参就能绘制的API,它可以根据输入装配阶段绑定的缓冲区(内部存有图元数目的记录)自动进行绘制,它会经过顶点着色阶段并一直到流输出阶段。它可能会继续经过光栅化阶段到输出合并阶段,也可以不经过。但是它的调用要求如下:
在输入装配阶段下,输入槽0需要绑定顶点缓冲区绑定到输入槽0的顶点缓冲区需要设置绑定标签D3D11_BIND_VERTEX_BUFFER和D3D11_BIND_STREAM_OUTPUT作为流输出的缓冲区也需要设置上述同样的两个绑定标签绑定到输入槽0的顶点缓冲区,需要存在图元数目的内部记录关于最后一点,一般的顶点缓冲区是不会存在内部记录的。通常要求第一次流输出绘制时使用Draw、DrawIndexed系列的方法,而不是DrawAuto来绘制,这样流输出缓冲区在产生运行结果的同时,其内部还会产生图元数目的记录。这样在后续的调用中,你就可以将该流输出缓冲区作为输入,然后提供另一个流输出缓冲区作为输出,最后调用DrawAuto来正确绘制了。
通过流输出阶段,一个三角形就分裂出了三个三角形,顶点的数目翻了3倍。若规定1阶分形三角形的顶点数为3,则N阶分形三角形的顶点数为3^N。
HLSL代码首先是TriangleSO_VS.hlsl,它负责将顶点直接传递给几何着色器
// TriangleSO_VS.hlsl #include "Basic.hlsli" VertexPosColor VS(VertexPosColor vIn) { return vIn; }然后和上一章一样,TriangleSO_GS.hlsl中的几何着色器将一个三角形分裂成三个三角形,并且输出的顶点类型和输入的顶点是一致的。
// TriangleSO_GS.hlsl #include "Basic.hlsli" [maxvertexcount(9)] void GS(triangle VertexPosColor input[3], inout TriangleStream<VertexPosColor> output) { // // 将一个三角形分裂成三个三角形,即没有v3v4v5的三角形 // v1 // /\ // / \ // v3/____\v4 // /\xxxx/\ // / \xx/ \ // /____\/____\ // v0 v5 v2 VertexPosColor vertexes[6]; int i; [unroll] for (i = 0; i < 3; ++i) { vertexes[i] = input[i]; vertexes[i + 3].Color = (input[i].Color + input[(i + 1) % 3].Color) / 2.0f; vertexes[i + 3].PosL = (input[i].PosL + input[(i + 1) % 3].PosL) / 2.0f; } [unroll] for (i = 0; i < 3; ++i) { output.Append(vertexes[i]); output.Append(vertexes[3 + i]); output.Append(vertexes[(i + 2) % 3 + 3]); output.RestartStrip(); } }接下来的Triangle_VS.hlsl和Triangle_PS.hlsl则是常规的三角形绘制:
// Triangle_VS.hlsl #include "Basic.hlsli" VertexPosHColor VS(VertexPosColor vIn) { matrix worldViewProj = mul(mul(g_World, g_View), g_Proj); VertexPosHColor vOut; vOut.Color = vIn.Color; vOut.PosH = mul(float4(vIn.PosL, 1.0f), worldViewProj); return vOut; } // Triangle_PS.hlsl #include "Basic.hlsli" float4 PS(VertexPosHColor pIn) : SV_Target { return pIn.Color; } CPU端处理首先是缓存各级的顶点缓冲区:
void ResetSplitedTriangle() { // 设置三角形顶点 VertexPosColor vertices[] = { { XMFLOAT3(-1.0f * 3, -0.866f * 3, 0.0f), XMFLOAT4(1.0f, 0.0f, 0.0f, 1.0f) }, { XMFLOAT3(0.0f * 3, 0.866f * 3, 0.0f), XMFLOAT4(0.0f, 1.0f, 0.0f, 1.0f) }, { XMFLOAT3(1.0f * 3, -0.866f * 3, 0.0f), XMFLOAT4(0.0f, 0.0f, 1.0f, 1.0f) } }; // 设置顶点缓冲区描述 D3D11_BUFFER_DESC vbd; ZeroMemory(&vbd, sizeof(vbd)); vbd.Usage = D3D11_USAGE_DEFAULT; // 这里需要允许流输出阶段通过GPU写入 vbd.ByteWidth = sizeof vertices; vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_STREAM_OUTPUT; // 需要额外添加流输出标签 vbd.CPUAccessFlags = 0; // 新建顶点缓冲区 D3D11_SUBRESOURCE_DATA InitData; ZeroMemory(&InitData, sizeof(InitData)); InitData.pSysMem = vertices; HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffers[0].ReleaseAndGetAddressOf())); // 三角形顶点数 m_InitVertexCounts = 3; // 初始化所有顶点缓冲区 for (int i = 1; i < 7; ++i) { vbd.ByteWidth *= 3; HR(m_pd3dDevice->CreateBuffer(&vbd, nullptr, m_pVertexBuffers[i].ReleaseAndGetAddressOf())); m_BasicEffect.SetStreamOutputSplitedTriangle(m_pd3dImmediateContext.Get(), m_pVertexBuffers[i - 1].Get(), m_pVertexBuffers[i].Get()); // 第一次绘制需要调用一般绘制指令,之后就可以使用DrawAuto了 if (i == 1) { m_pd3dImmediateContext->Draw(m_InitVertexCounts, 0); } else { m_pd3dImmediateContext->DrawAuto(); } } }其中SetStreamOutputSplitedTriangle使用流输出部分来缓存顶点缓冲
void BasicEffect::SetStreamOutputSplitedTriangle(ID3D11DeviceContext * deviceContext, ID3D11Buffer * vertexBufferIn, ID3D11Buffer * vertexBufferOut) { // 先恢复流输出默认设置,防止顶点缓冲区同时绑定在输入和输出阶段 UINT stride = sizeof(VertexPosColor); UINT offset = 0; ID3D11Buffer * nullBuffer = nullptr; deviceContext->SOSetTargets(1, &nullBuffer, &offset); deviceContext->IASetInputLayout(nullptr); deviceContext->SOSetTargets(0, nullptr, &offset); deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); deviceContext->IASetInputLayout(pImpl->m_pVertexPosColorLayout.Get()); deviceContext->IASetVertexBuffers(0, 1, &vertexBufferIn, &stride, &offset); deviceContext->VSSetShader(pImpl->m_pTriangleSOVS.Get(), nullptr, 0); deviceContext->GSSetShader(pImpl->m_pTriangleSOGS.Get(), nullptr, 0); deviceContext->SOSetTargets(1, &vertexBufferOut, &offset); deviceContext->RSSetState(nullptr); deviceContext->PSSetShader(nullptr, nullptr, 0); }和之前一样,SnowSO_VS.hlsl中的顶点着色器阶段只用于顶点直传:
// SnowSO_VS.hlsl #include "Basic.hlsli" VertexPosNormalColor VS(VertexPosNormalColor vIn) { return vIn; }然后重点就在于SnowSO_GS.hlsl的几何着色器了。这里先放出代码:
// SnowSO_GS.hlsl #include "Basic.hlsli" [maxvertexcount(5)] void GS(line VertexPosColor input[2], inout LineStream<VertexPosColor> output) { // 要求分形线段按顺时针排布 // z分量必须相等,因为顶点没有提供法向量无法判断垂直上方向 // v1 // /\ // ____________ => ____/ \____ // i0 i1 i0 v0 v2 i1 VertexPosColor v0, v1, v2; v0.Color = lerp(input[0].Color, input[1].Color, 0.25f); v1.Color = lerp(input[0].Color, input[1].Color, 0.5f); v2.Color = lerp(input[0].Color, input[1].Color, 0.75f); v0.PosL = lerp(input[0].PosL, input[1].PosL, 1.0f / 3.0f); v2.PosL = lerp(input[0].PosL, input[1].PosL, 2.0f / 3.0f); // xy平面求出它的垂直单位向量 // // | // ____|_____ float2 upDir = normalize(input[1].PosL - input[0].PosL).yx; float len = length(input[1].PosL.xy - input[0].PosL.xy); upDir.x = -upDir.x; v1.PosL = lerp(input[0].PosL, input[1].PosL, 0.5f); v1.PosL.xy += sqrt(3) / 6.0f * len * upDir; output.Append(input[0]); output.Append(v0); output.Append(v1); output.Append(v2); output.Append(input[1]); }可以发现分形雪花每升一阶,需要绘制的顶点数就变成了上一阶的4倍。
这里要求了z分量必须相等,因为使用的着色器仍把一切的顶点仍当做3D顶点来对待出来(当然你也可以写成2D的着色器)。
然后开始具体分析从直线变折线的过程,可以看到因为顶点v1所在角的度数在60度,且v0, v1, v2构成等边三角形,故v0v2,v0v1和v1v2的边长是一致的。而且4条折线要求边长相等,故这里的i0v0和v2i1应当各占线段i0i1的1/3.
其中lerp函数是线性插值函数,数学公式如下: p = p0 + t(p1 − p0) 其中t的取值范围在[0.0f, 1.0f],并且操作对象p0和p1可以是标量,也可以是矢量,对矢量来说则是对每个分量都进行线性插值。
当t = 0.5f时,描述的就是p0和p1的中值或中点。
该函数很容易描述两点之间某一相对位置。
由于我们规定了连续线段必须按顺时针排布,我们就可以利用向量i0i1逆时针旋转90度得到对应的突出方向向量,然后标准化,乘上相应的高度值即可得到顶点v1的位置。 最后就是用于绘制的着色器代码:
// Snow_VS.hlsl #include "Basic.hlsli" VertexPosHColor VS(VertexPosColor vIn) { matrix worldViewProj = mul(mul(g_World, g_View), g_Proj); VertexPosHColor vOut; vOut.Color = vIn.Color; vOut.PosH = mul(float4(vIn.PosL, 1.0f), worldViewProj); return vOut; } // Snow_PS.hlsl #include "Basic.hlsli" float4 PS(VertexPosHColor pIn) : SV_Target { return pIn.Color; } CPU端处理首先是缓存各级的顶点缓冲区: 由于绘制方式统一用LineList,初始阶段应当提供3条线段的6个顶点,虽然说每个顶点都被重复使用了2次。
void ResetSplitedSnow() { // ****************** // 雪花分形从初始化三角形开始,需要6个顶点 // // 设置三角形顶点 float sqrt3 = sqrt(3.0f); VertexPosColor vertices[] = { { XMFLOAT3(-3.0f / 4, -sqrt3 / 4, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(0.0f, sqrt3 / 2, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(0.0f, sqrt3 / 2, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(3.0f / 4, -sqrt3 / 4, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(3.0f / 4, -sqrt3 / 4, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(-3.0f / 4, -sqrt3 / 4, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) } }; // 将三角形宽度和高度都放大3倍 for (VertexPosColor& v : vertices) { v.pos.x *= 3; v.pos.y *= 3; } // 设置顶点缓冲区描述 D3D11_BUFFER_DESC vbd; ZeroMemory(&vbd, sizeof(vbd)); vbd.Usage = D3D11_USAGE_DEFAULT; // 这里需要允许流输出阶段通过GPU写入 vbd.ByteWidth = sizeof vertices; vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_STREAM_OUTPUT; // 需要额外添加流输出标签 vbd.CPUAccessFlags = 0; // 新建顶点缓冲区 D3D11_SUBRESOURCE_DATA InitData; ZeroMemory(&InitData, sizeof(InitData)); InitData.pSysMem = vertices; HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffers[0].ReleaseAndGetAddressOf())); // 顶点数 m_InitVertexCounts = 6; // 初始化所有顶点缓冲区 for (int i = 1; i < 7; ++i) { vbd.ByteWidth *= 4; HR(m_pd3dDevice->CreateBuffer(&vbd, nullptr, m_pVertexBuffers[i].ReleaseAndGetAddressOf())); m_BasicEffect.SetStreamOutputSplitedSnow(m_pd3dImmediateContext.Get(), m_pVertexBuffers[i - 1].Get(), m_pVertexBuffers[i].Get()); // 第一次绘制需要调用一般绘制指令,之后就可以使用DrawAuto了 if (i == 1) { m_pd3dImmediateContext->Draw(m_InitVertexCounts, 0); } else { m_pd3dImmediateContext->DrawAuto(); } } }其中SetStreamOutputSplitedSnow使用流输出部分来缓存顶点缓冲,注意这里是用LineList而不是LineStrip方式。
void BasicEffect::SetStreamOutputSplitedSnow(ID3D11DeviceContext * deviceContext, ID3D11Buffer * vertexBufferIn, ID3D11Buffer * vertexBufferOut) { // 先恢复流输出默认设置,防止顶点缓冲区同时绑定在输入和输出阶段 UINT stride = sizeof(VertexPosColor); UINT offset = 0; ID3D11Buffer * nullBuffer = nullptr; deviceContext->SOSetTargets(1, &nullBuffer, &offset); deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_LINELIST); deviceContext->IASetInputLayout(pImpl->m_pVertexPosColorLayout.Get()); deviceContext->IASetVertexBuffers(0, 1, &vertexBufferIn, &stride, &offset); deviceContext->VSSetShader(pImpl->m_pSnowSOVS.Get(), nullptr, 0); deviceContext->GSSetShader(pImpl->m_pSnowSOGS.Get(), nullptr, 0); deviceContext->SOSetTargets(1, &vertexBufferOut, &offset); deviceContext->RSSetState(nullptr); deviceContext->PSSetShader(nullptr, nullptr, 0); }仔细观察可以看到,原先的一个三角形分裂出了四个三角形,即每升一阶,需要绘制的顶点数就变成了上一阶的4倍。
HLSL代码SphereSO_VS.hlsl代码和SphereSO_GS.hlsl代码如下:
// SphereSO_VS.hlsl #include "Basic.hlsli" VertexPosNormalColor VS(VertexPosNormalColor vIn) { return vIn; } // SphereSO_GS.hlsl #include "Basic.hlsli" [maxvertexcount(12)] void GS(triangle VertexPosNormalColor input[3], inout TriangleStream<VertexPosNormalColor> output) { // // 将一个三角形分裂成四个三角形,但同时顶点v3, v4, v5也需要在球面上 // v1 // /\ // / \ // v3/____\v4 // /\xxxx/\ // / \xx/ \ // /____\/____\ // v0 v5 v2 VertexPosNormalColor vertexes[6]; matrix viewProj = mul(g_View, g_Proj); [unroll] for (int i = 0; i < 3; ++i) { vertexes[i] = input[i]; vertexes[i + 3].Color = lerp(input[i].Color, input[(i + 1) % 3].Color, 0.5f); vertexes[i + 3].NormalL = normalize(input[i].NormalL + input[(i + 1) % 3].NormalL); vertexes[i + 3].PosL = g_SphereCenter + g_SphereRadius * vertexes[i + 3].NormalL; } output.Append(vertexes[0]); output.Append(vertexes[3]); output.Append(vertexes[5]); output.RestartStrip(); output.Append(vertexes[3]); output.Append(vertexes[4]); output.Append(vertexes[5]); output.RestartStrip(); output.Append(vertexes[5]); output.Append(vertexes[4]); output.Append(vertexes[2]); output.RestartStrip(); output.Append(vertexes[3]); output.Append(vertexes[1]); output.Append(vertexes[4]); }由于v3, v4, v5也需要在球面上,我们还需要额外知道球的半径和球心位置。虽然说通过三角形三个顶点位置和法向量可以算出圆心和半径,但直接从常量缓冲区提供这两个信息会更方便一些。
要计算诸如v3顶点所在位置,我们可以先求出它的法向量,将v0和v1的法向量相加取其单位向量即为v3的法向量,然后从圆心开始加上半径长度的法向量即可得到顶点v3的位置。
剩下绘制圆的着色器代码如下:
// Sphere_VS.hlsl #include "Basic.hlsli" VertexPosHWNormalColor VS(VertexPosNormalColor vIn) { VertexPosHWNormalColor vOut; matrix viewProj = mul(g_View, g_Proj); float4 posW = mul(float4(vIn.PosL, 1.0f), g_World); vOut.PosH = mul(posW, viewProj); vOut.PosW = posW.xyz; vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose); vOut.Color = vIn.Color; return vOut; } // Sphere_PS.hlsl #include "Basic.hlsli" float4 PS(VertexPosHWNormalColor pIn) : SV_Target { // 标准化法向量 pIn.NormalW = normalize(pIn.NormalW); // 顶点指向眼睛的向量 float3 toEyeW = normalize(g_EyePosW - pIn.PosW); // 初始化为0 float4 ambient = float4(0.0f, 0.0f, 0.0f, 0.0f); float4 diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f); float4 spec = float4(0.0f, 0.0f, 0.0f, 0.0f); // 只计算方向光 ComputeDirectionalLight(g_Material, g_DirLight[0], pIn.NormalW, toEyeW, ambient, diffuse, spec); return pIn.Color * (ambient + diffuse) + spec; }至于法线生成,其着色器与上一部分相同,便不做阐述。
CPU端处理使用ResetSplitedSphere方法–重建各阶的分形圆球顶点的缓冲区 这里不使用Geometry类来构造一阶圆球,而是仅提供与外接正方体相交的六个顶点,包含八个三角形对应的24个顶点。
void ResetSplitedSphere() { // ********分形球体********** VertexPosNormalColor basePoint[] = { { XMFLOAT3(0.0f, 2.0f, 0.0f), XMFLOAT3(0.0f, 1.0f, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(2.0f, 0.0f, 0.0f), XMFLOAT3(1.0f, 0.0f, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(0.0f, 0.0f, 2.0f), XMFLOAT3(0.0f, 0.0f, 1.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(-2.0f, 0.0f, 0.0f), XMFLOAT3(-1.0f, 0.0f, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(0.0f, 0.0f, -2.0f), XMFLOAT3(0.0f, 0.0f, -1.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, { XMFLOAT3(0.0f, -2.0f, 0.0f), XMFLOAT3(0.0f, -1.0f, 0.0f), XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f) }, }; int indices[] = { 0, 2, 1, 0, 3, 2, 0, 4, 3, 0, 1, 4, 1, 2, 5, 2, 3, 5, 3, 4, 5, 4, 1, 5 }; std::vector<VertexPosNormalColor> vertices; for (int pos : indices) { vertices.push_back(basePoint[pos]); } // 设置顶点缓冲区描述 D3D11_BUFFER_DESC vbd; ZeroMemory(&vbd, sizeof(vbd)); vbd.Usage = D3D11_USAGE_DEFAULT; // 这里需要允许流输出阶段通过GPU写入 vbd.ByteWidth = (UINT)(vertices.size() * sizeof(VertexPosNormalColor)); vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_STREAM_OUTPUT; // 需要额外添加流输出标签 vbd.CPUAccessFlags = 0; // 新建顶点缓冲区 D3D11_SUBRESOURCE_DATA InitData; ZeroMemory(&InitData, sizeof(InitData)); InitData.pSysMem = vertices.data(); HR(m_pd3dDevice->CreateBuffer(&vbd, &InitData, m_pVertexBuffers[0].ReleaseAndGetAddressOf())); // 顶点数 m_InitVertexCounts = 24; // 初始化所有顶点缓冲区 for (int i = 1; i < 7; ++i) { vbd.ByteWidth *= 4; HR(m_pd3dDevice->CreateBuffer(&vbd, nullptr, m_pVertexBuffers[i].ReleaseAndGetAddressOf())); m_BasicEffect.SetStreamOutputSplitedSphere(m_pd3dImmediateContext.Get(), m_pVertexBuffers[i - 1].Get(), m_pVertexBuffers[i].Get()); // 第一次绘制需要调用一般绘制指令,之后就可以使用DrawAuto了 if (i == 1) { m_pd3dImmediateContext->Draw(m_InitVertexCounts, 0); } else { m_pd3dImmediateContext->DrawAuto(); } } }其中SetRenderSplitedSphere使用流输出部分来缓存顶点缓冲:
void BasicEffect::SetRenderSplitedSphere(ID3D11DeviceContext * deviceContext) { // 先恢复流输出默认设置,防止顶点缓冲区同时绑定在输入和输出阶段 UINT stride = sizeof(VertexPosColor); UINT offset = 0; ID3D11Buffer* nullBuffer = nullptr; deviceContext->SOSetTargets(1, &nullBuffer, &offset); deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); deviceContext->IASetInputLayout(pImpl->m_pVertexPosNormalColorLayout.Get()); deviceContext->VSSetShader(pImpl->m_pSphereVS.Get(), nullptr, 0); deviceContext->GSSetShader(nullptr, nullptr, 0); deviceContext->RSSetState(nullptr); deviceContext->PSSetShader(pImpl->m_pSpherePS.Get(), nullptr, 0); }