15-实例化与视锥裁剪

本章中,我们研究两个主题:实例化和截锥体剔除。实例化是指在场景中多次绘制同一个对象。我们描述了Direct3D的特性,使我们能够以最小的API开销实现实例化。剔除是指通过简单的测试避免绘制全部三角形的过程。
学习目标:
1.学习如何实现硬件实例化。
2.熟悉边界卷,为什么它们有用,如何创建它们以及如何使用它们。
3.发现如何实施视锥体筛选。

15.1 HARDWARE INSTANCING

实例化是指在场景中不止一次绘制同一对象,但是在不同的位置,方向,比例,材质和纹理中(例如,树对象可能会重复使用多次来构建森林)。为每个实例复制顶点和索引数据很浪费。相反,我们存储相对于对象局部空间的几何图形(即顶点和索引列表)。然后我们多次绘制对象,如果需要变化,每次都会使用不同的世界矩阵和不同的材质。

虽然这种策略可以节省内存,但仍需要每个对象的API开销。也就是说,对于每个对象,我们必须设置其独特的材质,其世界矩阵,并调用绘图命令。尽管Direct3D 10及更高版本进行了重新设计,以尽量减少Direct3D 9中存在的大量API开销,但仍然存在一些开销。因此,Direct3D提供了一种实现实例化的机制,而无需额外的API开销,我们称之为hardware instancing。

Note:为什么关注API开销?由于API开销Direct3D 9应用程序通常会受CPU限制(这意味着CPU是瓶颈,而不是GPU)。原因在于关卡设计人员喜欢使用独特的材质和纹理绘制多个对象,这导致每个对象状态改变和绘制时都会产生调用。当每个API调用的CPU开销很高时,场景将被限制为数千次绘制调用,以便仍然保持实时渲染速度。然后图形引擎将采用批处理技术(请参阅[Wloka03])以最大限度地减少绘制调用次数。硬件实例化通常与纹理数组结合,是API帮助执行批处理的一种。

15.1.1顶点着色器

除了顶点和索引数据之外,硬件实例化通过将实例化的数据流式传输到输入汇编程序。然后我们告诉硬件绘制网格的N个实例。在绘制每个实例时,顶点着色器将有权访问正在绘制的当前实例的输入顶点和实例化数据:

struct VertexIn
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 Tex : TEXCOORD;
    row_major float4x4 World : WORLD;
    float4 Color : COLOR;
    uint InstanceId : SV_InstanceID;
};

我们对实例数据使用粗体:每个实例都有一个世界矩阵,以便每个实例在场景中唯一定位,每个实例都有一个颜色,以便每个实例都有唯一的颜色。该系统还提供了SV_InstanceID标识符。例如,第一个实例的顶点将具有id 0,第二个实例的顶点将具有id 1,依此类推。实例ID的一个应用是将其用作纹理数组的索引,以便每个实例都可以具有唯一的纹理。顶点着色器使用实例化的世界矩阵来进行世界变换,而不是常量缓冲区中的世界矩阵; 它还会将实例颜色传递给像素着色器,以便为每个实例提供唯一的颜色:

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;
    float3 NormalW : NORMAL;
    float2 Tex : TEXCOORD;
    float4 Color : COLOR;
};
VertexOut VS(VertexIn vin)
{
    VertexOut vout;

    // Transform to world space space.
    vout.PosW = mul(float4(vin.PosL, 1.0f), vin.World).xyz;
    vout.NormalW = mul(vin.NormalL, (float3x3)vin.World);

    // Transform to homogeneous clip space.
    vout.PosH = mul(float4(vout.PosW, 1.0f), gViewProj);

    // Output vertex attributes for interpolation across triangle.
    vout.Tex = mul(float4(vin.Tex, 0.0f, 1.0f), gTexTransform).xy;
    vout.Color = vin.Color;

    return vout;
}

像素着色器与我们在上一章中使用的像素着色器相似,不同之处在于我们使用实例颜色来调制环境和漫反射项:

ambient += A*pin.Color;
diffuse += D*pin.Color;

15.1.2流式实例化数据

那么我们如何将实例化的数据传输到输入汇编程序阶段? 我们通过输入布局输入顶点数据,因此Direct3D使用相同的机制来实例化数据。回想一下我们在第6章中首次描述的D3D11_INPUT_ELEMENT_DESC结构:

typedef struct D3D11_INPUT_ELEMENT_DESC {
    LPCSTR SemanticName;
    UINT SemanticIndex;
    DXGI_FORMAT Format;
    UINT InputSlot;
    UINT AlignedByteOffset;
    D3D11_INPUT_CLASSIFICATION InputSlotClass;
    UINT InstanceDataStepRate;
} D3D11_INPUT_ELEMENT_DESC;

最后两名成员与实例有关:
1.InputSlotClass:指定输入元素是作为顶点元素还是实例化元素进行流式传输。指定以下两个值之一:
D3D11_INPUT_PER_VERTEX_DATA:输入元素按每个顶点进行流式传输。
D3D11_INPUT_PER_INSTANCE_DATA:输入元素是每个实例的流。
2.InstanceDataStepRate:指定有多少实例绘制每个实例的数据元素。例如,假设您要绘制6个实例,但只提供3个实例化颜色的数组:红色,绿色和蓝色;然后我们将步进速率设置为2,前两个实例将用红色绘制,后两个实例将用绿色绘制,最后2个实例将用蓝色绘制。如果每个实例数据元素与每个实例之间存在一对一的对应关系,则步进速率为1。对于顶点数据,为步进速率指定0。

在我们的演示中,我们为每个实例分配一个世界矩阵和颜色;这就是我们的输入布局的定义:

const D3D11_INPUT_ELEMENT_DESC InputLayoutDesc::InstancedBasic32[8] =
{
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24, D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"WORLD", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 0, D3D11_INPUT_PER_INSTANCE_DATA, 1},
    {"WORLD", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 16, D3D11_INPUT_PER_INSTANCE_DATA, 1},
    {"WORLD", 2, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 32, D3D11_INPUT_PER_INSTANCE_DATA, 1},
    {"WORLD", 3, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 48, D3D11_INPUT_PER_INSTANCE_DATA, 1},
    {"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 64, D3D11_INPUT_PER_INSTANCE_DATA, 1}
};

观察顶点数据来自输入槽0,实例数据来自输入槽1.我们使用两个缓冲区:第一个是通常的包含顶点数据的顶点缓冲区; 第二个是包含实例化数据的实例化缓冲区。 然后我们将两个缓冲区绑定到IA阶段:

struct Basic32
{
    XMFLOAT3 Pos;
    XMFLOAT3 Normal;
    XMFLOAT2 Tex;
};
struct InstancedData
{
    XMFLOAT4X4 World;
    XMFLOAT4 Color;
};
UINT stride[2] = {sizeof(Vertex::Basic32), sizeof(InstancedData)};
UINT offset[2] = {0,0};

ID3D11Buffer* vbs[2] = {mSkullVB, mInstancedBuffer};
md3dImmediateContext->IASetVertexBuffers(0, 2, vbs, stride, offset);
md3dImmediateContext->IASetInputLayout(InputLayouts::InstancedBasic32);

15.1.3绘制实例化数据

为了绘制实例化数据,我们使用DrawIndexedInstanced绘图调用:

void ID3D11DeviceContext::DrawIndexedInstanced(
    UINT IndexCountPerInstance,
    UINT InstanceCount,
    UINT StartIndexLocation,
    INT BaseVertexLocation,
    UINT StartInstanceLocation
);

1.IndexCountPerInstance:将在此绘图调用中用于定义一个实例的索引数。这不需要索引缓冲区中的每个索引; 也就是说,您可以绘制一个连续的索引子集。
2.InstanceCount:要绘制的实例的数量。
3.StartIndexLocation:指向索引缓冲区中的一个元素的索引,该元素标志着开始读取索引的起始点。
4.BaseVertexLocation:在获取顶点之前添加到此绘图调用中使用的索引的整数值。
5.StartInstanceLocation:实例缓冲区中一个元素的索引,标记了开始读取的起始点实例化数据。
以下是一个示例调用:

md3dImmediateContext->DrawIndexedInstanced(
    mSkullIndexCount, // number of indices in the skull mesh
    mVisibleObjectCount, // number of instances to draw
    0, 0, 0);

我们可以使用以下变体绘制不使用索引缓冲区的实例化数据:

void ID3D11DeviceContext::DrawInstanced(
    UINT VertexCountPerInstance,
    UINT InstanceCount,
    UINT StartVertexLocation,
    UINT StartInstanceLocation
);

15.1.4创建实例化缓冲区

包含实例化数据的缓冲区就像任何其他ID3D11Buffer一样被创建。在我们的演示中,我们存储所有实例化数据的系统内存副本,并使实例化缓冲区变为动态。然后,在每一帧中,我们都将可见实例的实例化数据复制到缓冲区中(这与截锥体剔除有关,请参阅第15.3节)。实例化数据通常放在动态缓冲区中,以便可以更改。例如,如果您想要移动对象,则需要更新世界矩阵,您可以使用动态缓冲区轻松完成这些操作。

void InstancingAndCullingApp::BuildInstancedBuffer()
{
    const int n = 5;
    mInstancedData.resize(n*n*n);
    float width = 200.0f;
    float height = 200.0f;
    float depth = 200.0f;
    float x = -0.5f*width;
    float y = -0.5f*height;
    float z = -0.5f*depth;
    float dx = width / (n-1);
    float dy = height / (n-1);
    float dz = depth / (n-1);
    for(int k = 0; k < n; ++k)
    {
        for(int i = 0; i < n; ++i)
        {
            for(int j = 0; j < n; ++j)
            {
                // Position instanced along a 3D grid.
                mInstancedData[k*n*n + i*n + j].World = XMFLOAT4X4(
                    1.0f, 0.0f, 0.0f, 0.0f,
                    0.0f, 1.0f, 0.0f, 0.0f,
                    0.0f, 0.0f, 1.0f, 0.0f,
                    x+j*dx, y+i*dy, z+k*dz, 1.0f);
                // Random color.
                mInstancedData[k*n*n + i*n + j].Color.x = MathHelper::RandF(0.0f, 1.0f);
                mInstancedData[k*n*n + i*n + j].Color.y = MathHelper::RandF(0.0f, 1.0f);
                mInstancedData[k*n*n + i*n + j].Color.z = MathHelper::RandF(0.0f, 1.0f);
                mInstancedData[k*n*n + i*n + j].Color.w = 1.0f;
            }
        }
    }
    D3D11_BUFFER_DESC vbd;
    vbd.Usage = D3D11_USAGE_DYNAMIC;
    vbd.ByteWidth = sizeof(InstancedData) * mInstancedData.size();
    vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
    vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
    vbd.MiscFlags = 0;
    vbd.StructureByteStride = 0;
    HR(md3dDevice->CreateBuffer(&vbd, 0, &mInstancedBuffer));
}

15.2 边界与视锥

为了实现平截头体剔除,我们需要熟悉平截头体和各种边界体积的数学表示。边界体积是原始几何对象,与物体的体积近似-见图15.1。权衡利弊,尽管边界体积应该于物体,但它的形式更需要是一个简单的数学表达形式,因为这样更容易处理。

15-实例化与视锥裁剪_第1张图片
图15.1 用AABB和边界球体渲染的网格

15.2.1 XNA碰撞

我们使用xnacollision.h/.cpp库。这些文件位于Microsoft DirectX SDK(2010年6月)\Samples\C ++\Misc\Collision中。它们提供了快速的XNA数学实现,用于常见的几何图元相交测试,例如光线/三角形交叉,光线/箱子交叉,箱子/箱子交叉,箱子/平面交叉,箱子/平截头体,球体/平截头体等等。练习3要求您浏览这些文件以熟悉它们提供的内容。

15.2.2 盒子

网格的轴对齐包围盒(AABB)是一个紧紧包围网格的框,其面与主轴平行。AABB可以用最小点 vmin v m i n 和最大点 vmax v m a x 来描述(见图15.2)。通过搜索网格的所有顶点并找到最小的(x,y,z)坐标来找到最小点 vmin v m i n ,并且通过搜索网格的所有顶点的x,y和z坐标并找到最大值来找到最大点 vmax v m a x

15-实例化与视锥裁剪_第2张图片
图15.2 使用最小和最大点表示的一组点的AABB

15-实例化与视锥裁剪_第3张图片
图15.3 使用中心和范围表示的一组点的AABB

或者,AABB可用盒中心点c和盘区矢量e(其存储从坐标轴中心点到盒子边的距离)来表示(见图15.3)。
XNA碰撞库使用中心/范围表示法:

_DECLSPEC_ALIGN_16_ struct AxisAlignedBox
{
    XMFLOAT3 Center; // Center of the box.
    XMFLOAT3 Extents; // Distance from the center to each side.
};

从一种表示转换为另一种表示很容易。例如,给定由 vmin v m i n vmax v m a x 定义的包围盒,中心/范围表示由下式给出:

c=0.5(vmin+vmax)e=0.5(vmin+vmax) c = 0.5 ( v m i n + v m a x ) e = 0.5 ( v m i n + v m a x )

以下代码显示了我们如何在本章的演示中计算头骨网格的包围盒:

XMFLOAT3 vMinf3(+MathHelper::Infinity, +MathHelper::Infinity, +MathHelper::Infinity);
XMFLOAT3 vMaxf3(-MathHelper::Infinity, -MathHelper::Infinity, -MathHelper::Infinity);

XMVECTOR vMin = XMLoadFloat3(&vMinf3);
XMVECTOR vMax = XMLoadFloat3(&vMaxf3);
std::vector vertices(vcount);
for(UINT i = 0; i < vcount; ++i)
{
    fin >> vertices[i].Pos.x >> vertices[i].Pos.y >> vertices[i].Pos.z;
    fin >> vertices[i].Normal.x >> vertices[i].Normal.y >> vertices[i].Normal.z;

    XMVECTOR P = XMLoadFloat3(&vertices[i].Pos);
    vMin = XMVectorMin(vMin, P);
    vMax = XMVectorMax(vMax, P);
}

// Convert min/max representation to center and extents representation.
XMStoreFloat3(&mSkullBox.Center, 0.5f*(vMin+vMax));
XMStoreFloat3(&mSkullBox.Extents, 0.5f*(vMax-vMin));

XMVectorMin和XMVectorMax函数返回矢量:

min(u,v)=(min(ux,vx),min(uy,vy),min(uz,vz),min(uw,vw))max(u,v)=(max(ux,vx),max(uy,vy),max(uz,vz),max(uw,vw)) m i n ( u , v ) = ( m i n ( u x , v x ) , m i n ( u y , v y ) , m i n ( u z , v z ) , m i n ( u w , v w ) ) m a x ( u , v ) = ( m a x ( u x , v x ) , m a x ( u y , v y ) , m a x ( u z , v z ) , m a x ( u w , v w ) )

15.2.2.1旋转和轴对齐包围盒

图15.4显示在一个坐标系中轴对齐的方框可能不与另一个坐标系轴对齐。特别是,如果我们计算局部空间中的网格的AABB,它将被转换为世界空间中的定向包围盒(OBB)。但是,我们总是可以转换成网格轴对齐的局部空间。
或者,我们可以在世界空间重新计算AABB,但这可能会导致一个“较胖”的盒子,与实际音量差不多(见图15.5)。

15-实例化与视锥裁剪_第4张图片
图15.4 包围盒与xy坐标轴对齐,但不与XY坐标系对齐

15-实例化与视锥裁剪_第5张图片
图15.5 包围盒与XY框架对齐

另一种选择是放弃轴对齐的包围盒,并且仅与定向包围盒一起工作,在那里我们保持框相对于世界空间的方向。XNA碰撞库提供了用于表示定向包围盒的以下结构。

_DECLSPEC_ALIGN_16_ struct OrientedBox
{
    XMFLOAT3 Center; // Center of the box.
    XMFLOAT3 Extents; // Distance from the center to each side.
    XMFLOAT4 Orientation; // Unit quaternion representing rotation
    //(box -> world).
};

Note:在这一章中,你会看到提到用于表示旋转/方向的四元数。简而言之,单位四元数可以像旋转矩阵一样表示旋转。我们在第24章中介绍了四元数。现在,把它看作代表像旋转矩阵那样的旋转。

AABB和OBB也可以使用XNA碰撞库函数构造:

VOID ComputeBoundingAxisAlignedBoxFromPoints(AxisAlignedBox* pOut,
    UINT Count, const XMFLOAT3* pPoints, UINT Stride);
VOID ComputeBoundingOrientedBoxFromPoints(OrientedBox* pOut,
    UINT Count, const XMFLOAT3* pPoints, UINT Stride);

如果您的顶点结构如下所示:

struct Basic32
{
    XMFLOAT3 Pos;
    XMFLOAT3 Normal;
    XMFLOAT2 Tex;
};

你有一个顶点阵列形成你的网格:

std::vector vertices;

然后你可以这样调用这个函数:

AxisAlignedBox box;
ComputeBoundingAxisAlignedBoxFromPoints(&box,vertices.size(), &vertices[0].Pos, sizeof(Vertex::Basic32));

步幅表示跳过多少个字节才能到达下一个位置元素。

Note:为了计算网格的包围盒,您需要有一个系统内存中可用的顶点列表的副本,例如存储在std :: vector中的副本。这是因为CPU无法从为渲染而创建的顶点缓冲区读取 - CPU只能读取D3D11_USAGE_STAGING资源。因此,应用程序通常会保留系统内存副本,以及拾取(第16章)和碰撞检测。

15.2.3球体

球形包围盒是紧紧包围物体的球形网格。球形包围盒可以用中心点和半径来描述。计算球形包围盒的一种方法是首先计算其AABB。然后我们以AABB的中心为球形包围盒的中心:

c=0.5(vmin+vmax) c = 0.5 ( v m i n + v m a x )

然后将任意顶点p与中心c之间的最大距离作为半径:
r=max{||cp||:pmesh} r = m a x { | | c – p | | : p ∈ m e s h }

假设我们计算局部空间中球形包围盒。在世界变换之后,由于缩放,球形包围盒可能不会紧紧包围物体。因此半径需要相应地重新调整。为了补偿非均匀缩放,我们必须通过最大缩放分量来缩放半径,以便球形包围盒将物体全部包裹。另一种可能的策略是通过将所有物体采取与游戏世界相同的比例建模来避免缩放。这样,一旦加载到应用程序中,模型就不需要重新缩放。

也可以使用XNA碰撞库函数计算球包围盒:

VOID ComputeBoundingSphereFromPoints(Sphere* pOut,UINT Count, const XMFLOAT3* pPoints, UINT Stride);

15.2.4 Frustums

我们熟悉第5章的平截头体。数学上指定平截头体的一种方式是六个平面的组合:左/右平面,上/下平面和近/远平面。我们假设六个平截面的面是“向内”的 - 见图15.6。

这种六个平面表示使得可以很容易地进行平截头体和边界体积相交测试。

15.2.4.1构造Frustum平面

构造平截头体平面的一种简单方法是在视图空间中,截头体呈现以正视z轴的原点为中心的规范形式。在这里,近平面和远平面由它们沿着z轴的距离来平均指定,左右平面是对称的并且穿过原点(再次参见图15.6),并且顶部和底部平面是对称的并且穿过 起源。 因此,我们甚至不需要存储完整的平面方程来表示视图空间中的平截头体,我们只需要平面顶部/底部/左/右平面的斜率以及近平面和远平面的z距离。 XNA碰撞库提供了用于表示平截头体的以下结构:

15-实例化与视锥裁剪_第6张图片
图15.6 平截头体平面的正半空间的交点限定平截头体的体积。

_DECLSPEC_ALIGN_16_ struct Frustum
{
    XMFLOAT3 Origin; // Origin of the frustum (and projection).
    XMFLOAT4 Orientation; // Unit quaternion representing rotation.

    FLOAT RightSlope; // Positive X slope (X/Z).
    FLOAT LeftSlope; // Negative X slope.
    FLOAT TopSlope; // Positive Y slope (Y/Z).
    FLOAT BottomSlope; // Negative Y slope.
    FLOAT Near, Far; // Z of the near plane and far plane.
};

在平截头体的局部空间(例如,相机的视角空间)中,原点将为零,并且方向将表示为特定变换(不旋转)。通过指定原点位置和方向四元数,我们可以定向和定位世界某个地方的平截头体。

如果我们缓存了相机的平截面垂直视场,宽高比,近平面和远平面,那么我们可以通过一些数学计算来确定视空间中的平截面体的平面方程。然而,还可以从投影矩阵导出视空间中的平截面平面方程(对于两种不同的方式参见[Lengyel02]或[Möller08])。XNA碰撞库采取以下策略。在NDC空间中,视锥体已被卷曲成框[-1,1]×[-1,1]×[0,1]。所以视角的8个角落就是:

// Corners of the projection frustum in homogenous space.
static XMVECTOR HomogenousPoints[6] =
{
    { 1.0f, 0.0f, 1.0f, 1.0f }, // right (at far plane)
    { -1.0f, 0.0f, 1.0f, 1.0f }, // left
    { 0.0f, 1.0f, 1.0f, 1.0f }, // top
    { 0.0f, -1.0f, 1.0f, 1.0f }, // bottom

    { 0.0f, 0.0f, 0.0f, 1.0f }, // near
    { 0.0f, 0.0f, 1.0f, 1.0f } // far
};

我们可以计算投影矩阵的逆(以及齐次转置)将NDC空间中的8个角转换回视图空间。一旦获得视角空间的平截头体的8个角,一些简单的数学用于计算平面方程(这很简单,因为在视图空间中,平截头体位于原点,并且轴对齐)。以下XNA碰撞代码根据投影矩阵计算视图空间中的平截头体:

//--------------------------------------------------------------------–
// Build a frustum from a persepective projection matrix. The matrix
// may only contain a projection; any rotation, translation or scale
// will cause the constructed frustum to be incorrect.
//--------------------------------------------------------------------–
VOID ComputeFrustumFromProjection(Frustum* pOut, XMMATRIX* pProjection)
{
    XMASSERT(pOut);
    XMASSERT(pProjection);

    // Corners of the projection frustum in homogenous space.
    static XMVECTOR HomogenousPoints[6] =
    {
        { 1.0f, 0.0f, 1.0f, 1.0f }, // right (at far plane)
        { -1.0f, 0.0f, 1.0f, 1.0f }, // left
        { 0.0f, 1.0f, 1.0f, 1.0f }, // top
        { 0.0f, -1.0f, 1.0f, 1.0f }, // bottom
        { 0.0f, 0.0f, 0.0f, 1.0f }, // near
        { 0.0f, 0.0f, 1.0f, 1.0f } // far
    };

    XMVECTOR Determinant;
    XMMATRIX matInverse = XMMatrixInverse(&Determinant, *pProjection);

    // Compute the frustum corners in world space.
    XMVECTOR Points[6];
    for(INT i = 0; i < 6; i++)
    {
        // Transform point.
        Points[i] = XMVector4Transform(HomogenousPoints[i], matInverse);
    }

    pOut->Origin = XMFLOAT3(0.0f, 0.0f, 0.0f);
    pOut->Orientation = XMFLOAT4(0.0f, 0.0f, 0.0f, 1.0f);

    // Compute the slopes.
    Points[0] = Points[0] * XMVectorReciprocal(XMVectorSplatZ(Points[0]));
    Points[1] = Points[1] * XMVectorReciprocal(XMVectorSplatZ(Points[1]));
    Points[2] = Points[2] * XMVectorReciprocal(XMVectorSplatZ(Points[2]));
    Points[3] = Points[3] * XMVectorReciprocal(XMVectorSplatZ(Points[3]));

    pOut->RightSlope = XMVectorGetX(Points[0]);
    pOut->LeftSlope = XMVectorGetX(Points[1]);
    pOut->TopSlope = XMVectorGetY(Points[2]);
    pOut->BottomSlope = XMVectorGetY(Points[3]);

    // Compute near and far.
    Points[4] = Points[4] * XMVectorReciprocal(XMVectorSplatW(Points[4]));
    Points[5] = Points[5] * XMVectorReciprocal(XMVectorSplatW(Points[5]));

    pOut->Near = XMVectorGetZ(Points[4]);
    pOut->Far = XMVectorGetZ(Points[5]);

    return;
}

15.2.4.2截面/球面相交

对于截锥体剔除,我们将要执行的一个测试是平截体/球体相交测试。这告诉我们球体是否与平截头体相交。请注意,完全位于平截头体内部的球体算作交叉点,因为我们将平截头体视为体积,而不仅仅是边界。因为我们将一个平截头体建模为六个面向内的平面,所以可以如下陈述平截体/球体测试:如果存在平截面平面L,使得球体处于L的负半空间,那么我们可以得出结论:球体完全在平截头体之外。如果这样一个平面不存在,那么我们得出这样的结论:球体与平截体相交。

因此,平截头体/球体相交测试减少到六个球体/平面测试。图15.7显示了球面相交测试的设置。让球体具有中心点c和半径r。那么从球体中心到平面的有符号距离是 k=nc+d k = n · c + d (附录C)。如果 |k|≤r 则球体与平面相交。如果 k<-r,那么球体在平面外。如果k> r,那么球体位于平面内,球体与平面的正半空间相交。对于平截头体/球体相交测试的目的,如果球体在平面的前方,那么我们将其视为交叉点,因为它与平面定义的正半空间相交。

XNA碰撞库提供以下功能来测试球体是否与平截头体相交。请注意,球体和平截头体必须位于相同的坐标系中才能使测试合理。

// Return values: 0 = no intersection,
//                1 = intersection,
//                2 = A is completely inside B
INT IntersectSphereFrustum(
            const Sphere* pVolumeA,
            const Frustum* pVolumeB);

15-实例化与视锥裁剪_第7张图片
图15.7 球体/平面相交。 (a)k> r并且球体与平面的正半空间相交。 (b)k <-r,并且球体完全在负半空间的平面后面。 (c)| k | ≤r并且球体与平面相交。

15.2.4.3 Frustum / AABB相交

平截头体/ AABB相交测试遵循与平截头体/球体测试相同的策略。因为我们将一个平截头体作为六个面向内的平面进行建模,所以平截体/ AABB测试可以表述如下:如果存在平截头体平面L使得该盒位于L的负半空间中,那么我们可以得出结论:该盒完全在平截头体之外。如果这样的平面不存在,那么我们可以得出结论,这个箱子与平截头体相交。

所以平截头体/ AABB相交测试减少到六个AABB/平面测试。AABB/平面测试的算法如下。找到盒子对角线矢量 v=PQ v = P Q → ,通过盒子的中心,与平面法线n最对齐。从图15.8可以看出,(a)如果P在平面的前面,那么Q必也在平面的前面; (b)如果Q在平面后面,那么P也必在飞机后面;(c)如果P在平面后面,并且Q在平面的前面,那么该框与平面相交。

找到与平面法线向量n最一致的PQ可以用以下代码完成:

// For each coordinate axis x, y, z...
for(int j = 0; j < 3; ++j)
{
    // Make PQ point in the same direction as
    // the plane normal on this axis.
    if(planeNormal[j] >= 0.0f)
    {
        P[j] = box.minPt[j];
        Q[j] = box.maxPt[j];
    }
    else
    {
        P[j] = box.maxPt[j];
        Q[j] = box.minPt[j];
    }
}

15-实例化与视锥裁剪_第8张图片
图15.8 AABB /平面相交测试。对角线 PQ P Q → 总是与平面法线最直接对角的

15-实例化与视锥裁剪_第9张图片
图15.9 (上)沿着第i轴的法向分量是正的,所以我们选择 Pi=vMin[i]Qi=vMax[i] P i = v M i n [ i ] 和 Q i = v M a x [ i ] ,使得 QiPi Q i − P i 与平面法线坐标ni具有相同的符号。 (下图)沿着第i轴的法向分量是负的,所以我们选择 Pi=vMin[i]Qi=vMax[i] P i = v M i n [ i ] 和 Q i = v M a x [ i ] ,使得 QiPi Q i − P i 具有与平面法线坐标ni相同的符号。

该代码一次仅查看一个维度,并选择 PiQi P i 和 Q i ,使 PiQi P i 和 Q i 具有与平面法线坐标 ni n i 相同的符号(图15.9)。

XNA碰撞库提供以下功能来测试AABB是否与平截头体相交。请注意,AABB和平截头体必须位于相同的坐标系中才能使测试更有意义。

    // Return values: 0 = no intersection,
    //                1 = intersection,
    //                2 = A is completely inside B
    INT IntersectAxisAlignedBoxFrustum(
            const AxisAlignedBox* pVolumeA,
            const Frustum* pVolumeB);

15.3 剔除(FRUSTUM CULLING)

回想一下第5章,硬件会自动丢弃剪切阶段以外的视锥体外的三角形。但是,如果我们有数百万个三角形,所有三角形仍然通过绘制调用(它具有API开销)提交给渲染管线,并且所有三角形都可能通过镶嵌阶段(可能通过几何着色器)通过顶点着色器,仅在裁剪阶段被丢弃。显然,这是浪费且低效率的。

截锥体剔除的想法是应用程序代码在比每个三角形更高的层次上剔除三角形组。图15.10显示了一个简单的例子。我们在场景中的每个对象周围构建一个包围盒,例如球体或框。如果包围盒不与平截头体相交,那么我们不需要将对象(可以包含数千个三角形)提交给Direct3D进行绘制。这样可以避免GPU不得不对几何图形进行浪费计算,而这是以廉价的CPU测试为代价的。假设摄像机具有90°的视野并且远离平面无限远,相机平截头体仅占世界体积的1/6,所以世界对象的五分之六可以是截锥体剔除,假设物体均匀地分布在整个场景。在实践中,摄像机使用的视场角小于90°,有限的远平面,这意味着我们可以剔除超过5/6的场景物体。

15-实例化与视锥裁剪_第10张图片
图15.10 由体积A和D限定的对象完全在截锥体外,因此不需要绘制。 对应于卷C的对象完全位于平截头体内部,需要绘制。 由体积B和E定界的物体部分位于平截头体外,部分位于平截头体内部; 我们必须绘制这些对象并让硬件剪辑和三角形在截锥外。

在我们的演示中,我们使用实例化来渲染5×5×5的颅骨网格(见图15.11)。我们计算局部空间中头骨网格的AABB。在UpdateScene方法中,我们对所有实例执行截锥体剔除。如果实例与平截头体相交,那么我们将它添加到动态实例化缓冲区中的下一个可用插槽,并增加mVisibleObjectCount计数器。这样,动态实例化缓冲区的前端包含所有可见实例的数据。(当然,在所有实例都可见的情况下,实例缓冲区的大小与实例的数量相匹配。)因为头骨网格的AABB位于局部空间中,所以为了执行相交测试我们必须将视锥体转换为每个实例的局部空间;可以使用空间转换,将AABB转换为世界空间,或将视锥转换为世界空间。截锥体剔除更新代码如下:


15-实例化与视锥裁剪_第11张图片
图15.11 “Instancing and Culling”演示的屏幕截图。

mCam.UpdateViewMatrix();
mVisibleObjectCount = 0;

XMVECTOR detView = XMMatrixDeterminant(mCam.View());
XMMATRIX invView = XMMatrixInverse(&detView, mCam.View());

// Map the instanced buffer to write to it.
D3D11_MAPPED_SUBRESOURCE mappedData;
md3dImmediateContext->Map(mInstancedBuffer, 0,D3D11_MAP_WRITE_DISCARD, 0, &mappedData);

InstancedData* dataView = reinterpret_cast(mappedData.pData);

for(UINT i = 0; i < mInstancedData.size(); ++i)
{
    XMMATRIX W = XMLoadFloat4x4(&mInstancedData[i].World);
    XMMATRIX invWorld = XMMatrixInverse(&XMMatrixDeterminant(W), W);

    // View space to the object's local space.
    XMMATRIX toLocal = XMMatrixMultiply(invView, invWorld);

    // Decompose the matrix into its individual parts.
    XMVECTOR scale;
    XMVECTOR rotQuat;
    XMVECTOR translation;
    XMMatrixDecompose(&scale, &rotQuat, &translation, toLocal);

    // Transform the camera frustum from view space to the object's
    // local space.
    XNA::Frustum localspaceFrustum;
    XNA::TransformFrustum(&localspaceFrustum, &mCamFrustum,

    XMVectorGetX(scale), rotQuat, translation);

    // Perform the box/frustum intersection test in local space.
    if(XNA::IntersectAxisAlignedBoxFrustum(&mSkullBox, &localspaceFrustum) != 0)
    {
        // Write the instance data to dynamic VB of the visible objects.
        dataView[mVisibleObjectCount++] = mInstancedData[i];
    }
}

md3dImmediateContext->Unmap(mInstancedBuffer, 0);

尽管我们的实例化缓冲区可以装下每个实例,但我们只绘制与从0到mVisibleObjectCount-1的可见实例:

md3dImmediateContext->DrawIndexedInstanced(
                        mSkullIndexCount, // number of indices in the skull mesh
                        mVisibleObjectCount, // number of instances to draw
                        0, 0, 0);

图15.12显示了启用了平截头体剔除功能之后的性能差异。对于平截头体剔除,我们仅向渲染管线提交11个实例进行处理。如果没有截锥体剔除,我们将所有125个实例提交到渲染管道进行处理。尽管可见场景是相同的,但截锥体剔除被禁用,我们浪费计算能力绘制超过100个头骨网格,其几何图形在裁剪阶段最终被丢弃。每个颅骨有大约60K个三角形,所以这是很多顶点处理和大量的三角形剪辑每个头骨。通过进行一个平截头体/ AABB测试,这就避免了60K的三角形被使发送到图形管道,这就是截锥体剔除的优势,我们可以看到每秒帧数的差异。

15-实例化与视锥裁剪_第12张图片
图15.12 (左)启用剔除,我们可以看到125个实例中有11个可见,并且渲染帧需要大约1.75 ms。(右)关闭剔除,我们渲染所有125个实例,渲染一个帧需要大约16.94 ms - 比启用截锥剔除时长9.68倍。

15.4总结

1.实例化是指在场景中不止一次绘制同一个对象,而且是在不同的位置,方向,比例,材质和纹理中绘制同一个对象。为了节省内存,我们只创建一个网格,并使用不同的世界矩阵,材质和纹理向Direct3D提交多个绘图调用。为了避免发布资源更改的API开销,我们可以将每个实例的数据传输到输入汇编器,并让Direct3D使用ID3D11DeviceContext :: DrawIndexedInstanced方法绘制多个实例。每个实例将绑定到输入汇编程序阶段的实例化缓冲区中获取关联的实例化数据。
2.边界体积是逼近对象体积的原始几何对象。尽管包围盒只是体积近似于物体,但它的形式有一个简单的数学表示形式,因此很容易处理。包围盒的示例是球体,轴对齐包围盒(AABB)和定向包围盒(OBB)。xnacollision.h/.cpp库具有表示边界体积的结构,以及用于转换它们并计算各种相交测试的函数。
3.GPU会自动丢弃裁剪阶段中位于视锥之外的三角形。然而,剪切后的三角形仍然通过绘制调用(其具有API开销)被提交给渲染管线,并且所有三角形可能通过镶嵌阶段,并且可能通过几何着色器穿过顶点着色器,仅在裁剪阶段。为了解决这种低效率问题,我们可以实施平截头剔除。这个想法是在场景中的每个对象周围建立一个包围盒,例如球体或盒子。如果边界体积不与平截头体相交,那么我们不需要将对象(可以包含数千个三角形)提交给Direct3D进行绘制。这样可以避免GPU不得不在不可见的几何体上进行浪费计算,代价是简单的CPU计算。

15.5 练习

你可能感兴趣的:(D3D)