DirectX11进阶5_硬件实例化与视锥体裁剪及鼠标拾取交互

2022-07-28,,,

一、硬件实例化(Hardware Instancing)

硬件实例化指的是在场景中绘制同一个物体多次,但是是以不同的位置、旋转、缩放、材质以及纹理来绘制(比如一棵树可能会被多次使用以构建出一片森林)。在以前,每次实例绘制(Draw方法)都会引发一次顶点缓冲区和索引缓冲区经过输入装配阶段传递进渲染管线中,大量重复的绘制则意味着多次反复的输入装配操作,会引发十分庞大的性能开销。事实上在绘制同样物体的时候顶点缓冲区和索引缓冲区应当只需要传递一次,然后真正需要多次传递的也应该是像世界矩阵、材质、纹理等这些可能会经常变化的数据。

要能够实现上面的这种操作,还需要图形库底层API本身能够支持按对象绘制。对于每个对象,我们必须设置它们各自的材质、世界矩阵等,然后才是调用绘制命令。尽管在Direct3D 10和后续的版本已经将原本Direct3D 9的一些API重新设计以尽可能最小化性能上的开销,部分多余的开销仍然存在。因此,Direct3D提供了一种机制,不需要通过API上的额外性能开销来实现实例化,我们称之为硬件实例化。

为什么要担忧API性能开销呢?Direct3D 9应用程序通常因为API导致在CPU上遇到瓶颈,而不是在GPU。以前关卡设计师喜欢使用单一材质和纹理来绘制许多对象,因为对于它们来说需要经常去单独改变它的状态并且去调用绘制。场景将会被限制在几千次的调用绘制以维持实时渲染的速度,主要在于这里的每次API调用都会引起高级别的CPU性能开销。现在图形引擎可以使用批处理技术以最小化绘制调用的次数。硬件实例化是API帮助执行批处理的一个方面。

1.1 多顶点缓冲区输入

之前我们提到,在输入装配阶段中提供了16个输入槽,这意味着可以同时绑定16个顶点缓冲区作为输入。那这时候如果我们使用多个顶点缓冲区作为输入会产生什么样的结果呢?
下面演示下:顶点按数据类型拆分成多个顶点缓冲区
这里做一个铺垫,以前我们在输入装配阶段只使用了1个输入槽。现在假定我们有如下顶点缓冲区结构:

这里我们也可以使用2个输入槽,第一个顶点缓冲区存放顶点位置,第二个顶点缓冲区存放顶点法向量和顶点颜色:

先回顾一下顶点输入布局描述的结构:

 typedef struct D3D11_INPUT_ELEMENT_DESC
 {
    LPCSTR SemanticName;    // 语义名
    UINT SemanticIndex;     // 语义名对应的索引值
    DXGI_FORMAT Format;     // DXGI数据格式
    UINT InputSlot;         // 输入槽
    UINT AlignedByteOffset; // 对齐的字节偏移量
    D3D11_INPUT_CLASSIFICATION InputSlotClass;  // 输入槽类别(此时为顶点)
    UINT InstanceDataStepRate;  // 忽略(0)
 } 	D3D11_INPUT_ELEMENT_DESC;

然后我们就可以在输入布局中这样指定(这里先简单提及一下):

这样,下面在HLSL的结构体数据实际上来源于两个输入槽:

struct VertexPosNormalColor
{
	float3 pos : POSITION;		// 来自输入槽0
	float3 normal : NORMAL;		// 来自输入槽1
	float4 color : COLOR;		// 来自输入槽1
};

然后,输入装配器就会根据输入布局,以及索引值来抽取对应数据,最终构造出来的顶点数据流和一开始给出的表格数据是一致的。即便你把第二个顶点缓冲区再按顶点法向量和顶点颜色拆分成两个新的顶点缓冲区,使用三输入槽产生的结果也是一致的。如果你只能拿到连续的顶点位置数据、连续的法向量数据、连续的纹理坐标数据话,可以考虑使用上述方案。

1.2 顶点与实例数据的组合 与 流式实例化数据

现在,我们需要着重观察D3D11_INPUT_ELEMENT_DESC的最后两个成员:

 typedef struct D3D11_INPUT_ELEMENT_DESC
 {
    LPCSTR SemanticName;    // 语义名
    UINT SemanticIndex;     // 语义名对应的索引值
    DXGI_FORMAT Format;     // DXGI数据格式
    UINT InputSlot;         // 输入槽
    UINT AlignedByteOffset; // 对齐的字节偏移量
    D3D11_INPUT_CLASSIFICATION InputSlotClass;  // 输入槽类别(顶点/实例)
    UINT InstanceDataStepRate;  // 实例数据步进值
 } 	D3D11_INPUT_ELEMENT_DESC;
  • InputSlotClass:指定输入的元素是作为顶点元素还是实例元素。枚举值含义如下:
  • [ ]InstanceDataStepRate:指定每份实例数据绘制出多少个实例。例如,假如你想绘制6个实例,但提供了只够绘制3个实例的数据,1份实例数据绘制出1种颜色,分别为红、绿、蓝。那么我们可以设置该成员的值为2,使得前两个实例绘制成红色,中间两个实例绘制成绿色,后两个实例绘制成蓝色。通常在绘制实例的时候我们会将该成员的值设为1,保证1份数据绘制出1个实例。对于顶点成员来说,设置该成员的值为0.

在前面的例子,我们知道一个结构体的数据可以来自多个输入槽,现在要使用硬件实例化,我们需要使用至少两个输入槽(其中至少一个顶点缓冲区,至少一个实例缓冲区)

现在我们需要使用的顶点与实例数据组合的结构体如下:

struct InstancePosNormalTex
{
    float3 PosL : POSITION;		// 来自输入槽0
    float3 NormalL : NORMAL;	// 来自输入槽0
    float2 Tex : TEXCOORD;		// 来自输入槽0
    matrix World : World;		// 来自输入槽1
    matrix WorldInvTranspose : WorldInvTranspose;	// 来自输入槽1
};

输出的结构体和以前一样:

struct VertexPosHWNormalTex
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;  // 在世界中的位置
    float3 NormalW : NORMAL; // 法向量在世界中的方向
    float2 Tex : TEXCOORD;
};

现在顶点着色器代码变化如下:

VertexPosHWNormalTex VS(InstancePosNormalTex vIn)
{
    VertexPosHWNormalTex vOut;
    
    matrix viewProj = mul(g_View, g_Proj);
    vector posW = mul(float4(vIn.PosL, 1.0f), vIn.World);

    vOut.PosW = posW.xyz;
    vOut.PosH = mul(posW, viewProj);
    vOut.NormalW = mul(vIn.NormalL, (float3x3) vIn.WorldInvTranspose);
    vOut.Tex = vIn.Tex;
    return vOut;
}

至于像素着色器,和上一章为模型所使用的着色器的保持一致。

对于前面的结构体InstancePosNormalTex,与之对应的输入成员描述数组如下:

D3D11_INPUT_ELEMENT_DESC basicInstLayout[] = {
	{ "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},
	{ "WorldInvTranspose", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 64, D3D11_INPUT_PER_INSTANCE_DATA, 1},
	{ "WorldInvTranspose", 1, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 80, D3D11_INPUT_PER_INSTANCE_DATA, 1},
	{ "WorldInvTranspose", 2, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 96, D3D11_INPUT_PER_INSTANCE_DATA, 1},
	{ "WorldInvTranspose", 3, DXGI_FORMAT_R32G32B32A32_FLOAT, 1, 112, D3D11_INPUT_PER_INSTANCE_DATA, 1}
};

因为DXGI_FORMAT一次最多仅能够表达128位(16字节)数据,在对应矩阵的语义时,需要重复描述4次,区别在于语义索引为0-3.

顶点的数据占用输入槽0,而实例数据占用的则是输入槽1。这样就需要我们使用两个缓冲区以提供给输入装配阶段。其中第一个作为顶点缓冲区,而第二个作为实例缓冲区以存放有关实例的数据,绑定到输入装配阶段的方法如下:

struct VertexPosNormalTex
{
	DirectX::XMFLOAT3 pos;
	DirectX::XMFLOAT3 normal;
	DirectX::XMFLOAT2 tex;
	static const D3D11_INPUT_ELEMENT_DESC inputLayout[3];
};

struct InstancedData
{
	XMMATRIX world;
	XMMATRIX worldInvTranspose;
};

// ...
UINT strides[2] = { sizeof(VertexPosNormalTex), sizeof(InstancedData) };
UINT offsets[2] = { 0, 0 };
ID3D11Buffer * buffers[2] = { vertexBuffer.Get(), instancedBuffer.Get() };

// 设置顶点/索引缓冲区
deviceContext->IASetVertexBuffers(0, 2, buffers, strides, offsets);
deviceContext->IASetInputLayout(instancePosNormalTexLayout.Get());

1.3 实例ID

系统值SV_InstanceID可以告诉我们当前进行绘制的顶点来自哪个实例。通常在绘制N个实例的情况下,第一个实例的索引值为0,一直到最后一个实例索引值为N - 1.它可以应用在需要个性化的地方,比如使用一个纹理数组,然后不同的索引去映射到对应的纹理,以绘制出网格模型相同,但纹理不一致的物体。

1.4 按实例进行绘制

  1. ID3D11DeviceContext::DrawIndexedInstanced方法–带索引数组的实例绘制
    通常我们使用ID3D11DeviceContext::DrawIndexedInstanced方法来绘制实例数据:
void ID3D11DeviceContext::DrawIndexedInstanced(
    UINT IndexCountPerInstance,     // [In]每个实例绘制要用到的索引数目
    UINT InstanceCount,             // [In]绘制的实例数目
    UINT StartIndexLocation,        // [In]起始索引偏移值
    INT BaseVertexLocation,         // [In]起始顶点偏移值
    UINT StartInstanceLocation      // [In]起始实例偏移值
);

下面是一个调用示例:

deviceContext->DrawIndexedInstanced(part.indexCount, numInsts, 0, 0, 0);
  1. ID3D11DeviceContext::DrawInstanced方法–实例绘制

若没有索引数组,也可以用ID3D11DeviceContext::DrawInstanced方法来进行绘制

void ID3D11DeviceContext::DrawInstanced(
    UINT VertexCountPerInstance,    // [In]每个实例绘制要用到的顶点数目
    UINT InstanceCount,             // [In]绘制的实例数目
    UINT StartVertexLocation,       // [In]起始顶点偏移值
    UINT StartInstanceLocation      // [In]起始实例偏移值
);

在调用实例化绘制后,输入装配器会根据所有顶点输入槽与实例输入槽进行笛卡尔积的排列组合,这里举个复杂的例子,有5个输入槽,其中顶点相关的输入槽含3个元素,实例相关的输入槽含2个元素:

最终产生的实例数据流如下表,含3x2=6组结构体数据:

1.5 实例缓冲区的创建

和之前创建顶点/索引缓冲区的方式一样,我们需要创建一个ID3D11Buffer,只不过在缓冲区描述中,我们需要将其指定为动态缓冲区(即D3D11_BIND_VERTEX_BUFFER),并且要指定D3D11_CPU_ACCESS_WRITE。

// 设置实例缓冲区描述
D3D11_BUFFER_DESC vbd;
ZeroMemory(&vbd, sizeof(vbd));
vbd.Usage = D3D11_USAGE_DYNAMIC;
vbd.ByteWidth = count * (UINT)sizeof(InstancedData);
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
// 新建实例缓冲区
HR(device->CreateBuffer(&vbd, nullptr, m_pInstancedBuffer.ReleaseAndGetAddressOf()));

因为我们不需要访问里面的数据,因此不用添加D3D11_CPU_ACCESS_READ标记。

1.6 实例缓冲区数据的修改

若需要修改实例缓冲区的内容,则需要使用ID3D11DeviceContext::Map方法将其映射到CPU内存当中。对于使用了D3D11_USAGE_DYNAMIC标签的动态缓冲区来说,在更新的时候只能使用D3D11_MAP_WRITE_DISCARD标签,而不能使用D3D11_MAP_WRITE或者D3D11_MAP_READ_WRITE标签。

将需要提交上去的实例数据存放到映射好的CPU内存区间后,使用ID3D11DeviceContext::Unmap方法将实例数据更新到显存中以应用。

D3D11_MAPPED_SUBRESOURCE mappedData;
HR(deviceContext->Map(m_pInstancedBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));
InstancedData * iter = reinterpret_cast<InstancedData *>(mappedData.pData);
// 省略写入细节...

deviceContext->Unmap(m_pInstancedBuffer.Get(), 0);

二、视锥体裁剪

在前面的所有章节中,顶点的抛弃通常发生在光栅化阶段。这意味着如果一份模型数据的所有顶点在经过矩阵变换后都不会落在屏幕区域内的话,这些顶点数据将会经历顶点着色阶段,可能会经过曲面细分阶段和几何着色阶段,然后在光栅化阶段的时候才抛弃。让这些不会被绘制的顶点还要走过这么漫长的阶段才被抛弃,可以说是一种非常低效的行为。

视锥体裁剪,就是在将这些模型的相关数据提交给渲染管线之前,生成一个包围盒,与摄像机观察空间的视锥体进行碰撞检测。若为相交或者包含,则说明该模型对象是可见的,需要被绘制出来,反之则应当拒绝对该对象的绘制调用,或者不传入该实例对象相关的数据。这样做可以节省GPU资源以避免大量对不可见对象的绘制,对CPU的性能开销也不大。

可以说,若一个场景中的模型数目越多,或者视锥体的可视范围越小,那么视锥体裁剪的效益越大。

查看上图,可以知道的是物体A和D没有与视锥体发生碰撞,因此需要排除掉物体A的实例数据。而物体B和E与视锥体有相交,物体C则被视锥体所包含,这三个物体的实例数据都应当传递给实例缓冲区。

视锥体裁剪有三种等价的代码表现形式。需要已知当前物体的包围盒、世界变换矩阵、观察矩阵和投影矩阵。其中投影矩阵本身可以构造出视锥体包围盒。

下面有关视锥体裁剪的方法都放进了Collision.h中。

2.1 方法1

现在已知物体的包围盒位于自身的局部坐标系,我们可以使用世界变换矩阵将其变换到世界空间中。同样,由投影矩阵构造出来的视锥体包围盒也位于自身局部坐标系中,而观察矩阵实质上是从世界矩阵变换到视锥体所处的局部坐标系中。因此,我们可以使用观察矩阵的逆矩阵,将视锥体包围盒也变换到世界空间中。这样就好似物体与视锥体都位于世界空间中,可以进行碰撞检测了:

std::vector<XMMATRIX> XM_CALLCONV Collision::FrustumCulling(
	const std::vector<XMMATRIX>& Matrices,const BoundingBox& localBox, FXMMATRIX View, CXMMATRIX Proj)
{
	std::vector<DirectX::XMMATRIX> acceptedData;

	BoundingFrustum frustum;
	BoundingFrustum::CreateFromMatrix(frustum, Proj);
	XMMATRIX InvView = XMMatrixInverse(nullptr, View);
	// 将视锥体从局部坐标系变换到世界坐标系中
	frustum.Transform(frustum, InvView);

	BoundingOrientedBox localOrientedBox, orientedBox;
	BoundingOrientedBox::CreateFromBoundingBox(localOrientedBox, localBox);
	for (auto& mat : Matrices)
	{
		// 将有向包围盒从局部坐标系变换到世界坐标系中
		localOrientedBox.Transform(orientedBox, mat);
		// 相交检测
		if (frustum.Intersects(orientedBox))
			acceptedData.push_back(mat);
	}
	return acceptedData;
}

2.2 方法2

该方法对应的正是龙书中所使用的裁剪方法,基本思路为:分别对观察矩阵和世界变换矩阵求逆,然后使用观察逆矩阵将视锥体从自身坐标系搬移到世界坐标系,再使用世界变换的逆矩阵将其从世界坐标系搬移到物体自身坐标系来与物体进行碰撞检测。改良龙书的碰撞检测代码如下:

std::vector<DirectX::XMMATRIX> XM_CALLCONV Collision::FrustumCulling2(
	const std::vector<DirectX::XMMATRIX>& Matrices,const DirectX::BoundingBox& localBox, DirectX::FXMMATRIX View, DirectX::CXMMATRIX Proj)
{
	std::vector<DirectX::XMMATRIX> acceptedData;

	BoundingFrustum frustum, localFrustum;
	BoundingFrustum::CreateFromMatrix(frustum, Proj);
	XMMATRIX InvView = XMMatrixInverse(nullptr, View);
	for (auto& mat : Matrices)
	{
		XMMATRIX InvWorld = XMMatrixInverse(nullptr, mat);

		// 将视锥体从观察坐标系(或局部坐标系)变换到物体所在的局部坐标系中
		frustum.Transform(localFrustum, InvView * InvWorld);
		// 相交检测
		if (localFrustum.Intersects(localBox))
			acceptedData.push_back(mat);
	}
	return acceptedData;
}

3.3 方法3

这个方法理解起来也比较简单,直接将物体先用世界变换矩阵从物体自身坐标系搬移到世界坐标系,然后用观察矩阵将其搬移到视锥体自身的局部坐标系来与视锥体进行碰撞检测。代码如下:

std::vector<DirectX::XMMATRIX> XM_CALLCONV Collision::FrustumCulling3(
	const std::vector<DirectX::XMMATRIX>& Matrices,const DirectX::BoundingBox& localBox, DirectX::FXMMATRIX View, DirectX::CXMMATRIX Proj)
{
	std::vector<DirectX::XMMATRIX> acceptedData;

	BoundingFrustum frustum;
	BoundingFrustum::CreateFromMatrix(frustum, Proj);

	BoundingOrientedBox localOrientedBox, orientedBox;
	
	BoundingOrientedBox::CreateFromBoundingBox(localOrientedBox, localBox);
	for (auto& mat : Matrices)
	{
		// 将有向包围盒从局部坐标系变换到视锥体所在的局部坐标系(观察坐标系)中
		localOrientedBox.Transform(orientedBox, mat * View);
		// 相交检测
		if (frustum.Intersects(orientedBox))
			acceptedData.push_back(mat);
	}
	return acceptedData;
}

这三种方法的裁剪表现效果是一致的。

三、硬件实例化与视锥体裁剪代码入口

3.1 实例矩阵生成

创建一个CreateRandomHouses方法–用来生成大量随机位置和方向的房子
该方法创建了房子的模型,并以随机的方式在一个大范围的圆形区域中生成了256个实例的数据(世界矩阵)。其中该圆形区域被划分成16个扇形区域,每个扇形划分成4个面,距离中心越远的扇面生成的树越多。

void CreateRandomHouses()
{
	srand((unsigned)time(nullptr));
	// 初始化模型
	m_ObjReader.Read(L"Model\\house.mbo", L"Model\\house.obj");
	m_Trees.SetModel(Model(m_pd3dDevice.Get(), m_ObjReader));
	XMMATRIX S = XMMatrixScaling(0.015f, 0.015f, 0.015f);
	
	BoundingBox treeBox = m_Trees.GetLocalBoundingBox();
	// 获取模型包围盒顶点
	m_TreeBoxData = Collision::CreateBoundingBox(treeBox, XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f));
	// 让模型底部紧贴地面位于y = -2的平面
	treeBox.Transform(treeBox, S);
	XMMATRIX T0 = XMMatrixTranslation(0.0f, -(treeBox.Center.y - treeBox.Extents.y + 2.0f), 0.0f);
	// 随机生成256个随机朝向的模型
	float theta = 0.0f;
	for (int i = 0; i < 16; ++i)
	{
		// 取5-125的半径放置随机的模型
		for (int j = 0; j < 4; ++j)
		{
			// 距离越远,模型越多
			for (int k = 0; k < 2 * j + 1; ++k)
			{
				float radius = (float)(rand() % 30 + 30 * j + 5);
				float randomRad = rand() % 256 / 256.0f * XM_2PI / 16;
				XMMATRIX T1 = XMMatrixTranslation(radius * cosf(theta + randomRad), 0.0f, radius * sinf(theta + randomRad));
				XMMATRIX R = XMMatrixRotationY(rand() % 256 / 256.0f * XM_2PI);
				XMMATRIX World = S * R * T0 * T1;
				m_InstancedData.push_back(World);
			}
		}
		theta += XM_2PI / 16;
	}

	m_Trees.ResizeBuffer(m_pd3dDevice.Get(), 256);
}

3.2 实例矩阵生成

ResizeBuffer方法–重新调整实例缓冲区的大小
若实例缓冲区的大小容不下当前增长的实例数据,则需要销毁原来的实例缓冲区,并重新创建一个更大的,以确保刚好能容得下之前的大量实例数据。

void ResizeBuffer(ComPtr<ID3D11Device> device, size_t count)
{
	// 设置实例缓冲区描述
	D3D11_BUFFER_DESC vbd;
	ZeroMemory(&vbd, sizeof(vbd));
	vbd.Usage = D3D11_USAGE_DYNAMIC;
	vbd.ByteWidth = (UINT)count * sizeof(InstancedData);
	vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
	vbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
	// 创建实例缓冲区
	HR(device->CreateBuffer(&vbd, nullptr, m_pInstancedBuffer.ReleaseAndGetAddressOf()));

	// 重新调整m_Capacity
	m_Capacity = count;
}

3.3 实例绘制

DrawInstanced方法–绘制游戏对象的多个实例
该方法接受一个装满世界矩阵的数组,把数据装填进实例缓冲区(若容量不够则重新扩容),然后交给设备上下文进行实例的绘制。

但是要注意需要将世界矩阵和其逆的转置矩阵都进行一次转置。

void DrawInstanced(ID3D11DeviceContext * deviceContext, BasicEffect & effect, const std::vector<DirectX::XMMATRIX>& data)
{
	D3D11_MAPPED_SUBRESOURCE mappedData;
	UINT numInsts = (UINT)data.size();
	// 若传入的数据比实例缓冲区还大,需要重新分配
	if (numInsts > m_Capacity)
	{
		ComPtr<ID3D11Device> device;
		deviceContext->GetDevice(device.GetAddressOf());
		ResizeBuffer(device.Get(), numInsts);
	}

	HR(deviceContext->Map(m_pInstancedBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedData));

	InstancedData * iter = reinterpret_cast<InstancedData *>(mappedData.pData);
	for (auto& mat : data)
	{
		iter->world = XMMatrixTranspose(mat);
		iter->worldInvTranspose = XMMatrixInverse(nullptr, mat);	// 两次转置抵消
		iter++;
	}

	deviceContext->Unmap(m_pInstancedBuffer.Get(), 0);

	UINT strides[2] = { m_Model.vertexStride, sizeof(InstancedData) };
	UINT offsets[2] = { 0, 0 };
	ID3D11Buffer * buffers[2] = { nullptr, m_pInstancedBuffer.Get() };
	for (auto& part : m_Model.modelParts)
	{
		buffers[0] = part.vertexBuffer.Get();

		// 设置顶点/索引缓冲区
		deviceContext->IASetVertexBuffers(0, 2, buffers, strides, offsets);
		deviceContext->IASetIndexBuffer(part.indexBuffer.Get(), part.indexFormat, 0);

		// 更新数据并应用
		effect.SetTextureDiffuse(part.texDiffuse.Get());
		effect.SetMaterial(part.material);
		effect.Apply(deviceContext);

		deviceContext->DrawIndexedInstanced(part.indexCount, numInsts, 0, 0, 0);
	}
}

四、鼠标拾取交互

4.1 原理

因为我们所能观察到的3D对象都处于视锥体的区域,而且又已经知道摄像机所在的位置。因此在屏幕上选取一点可以理解为从摄像机发出一条射线,然后判断该射线是否与场景中视锥体内的物体相交。若相交,则说明选中了该对象。

当然,有时候射线会经过多个对象,这个时候我们就应该选取距离最近的物体。

一个3D对象的顶点原本是位于局部坐标系的,然后经历了世界变换、观察变换、投影变换后,会来到NDC空间中,可视物体的深度值(z值)通常会处于0.0到1.0之间。而在NDC空间的坐标点还需要经过视口变换,才会来到最终的屏幕坐标系。在该坐标系中,坐标原点位于屏幕左上角,x轴向右,y轴向下,其中x和y的值指定了绘制在屏幕的位置,z的值则用作深度测试。而且从NDC空间到屏幕坐标系的变换只影响x和y的值,对z值不会影响。

而现在我们要做的,就是将选中的2D屏幕点按顺序进行视口逆变换、投影逆变换和观察逆变换,让其变换到世界坐标系并以摄像机位置为射线原点,构造出一条3D射线,最终才来进行射线与物体的相交。在构造屏幕一点的时候,将z值设为0.0即可。z值的变动,不会影响构造出来的射线,相当于在射线中前后移动而已。

现在回顾一下视口类D3D11_VIEWPORT的定义:

typedef struct D3D11_VIEWPORT {
    FLOAT TopLeftX;
    FLOAT TopLeftY;
    FLOAT Width;
    FLOAT Height;
    FLOAT MinDepth;
    FLOAT MaxDepth;
} D3D11_VIEWPORT;

从NDC坐标系到屏幕坐标系的变换矩阵如下:

现在,给定一个已知的屏幕坐标点(x, y, 0),要实现鼠标拾取的第一步就是将其变换回NDC坐标系。对上面的变换矩阵进行求逆,可以得到:

尽管DirectXMath没有构造视口矩阵的函数,我们也没必要去直接构造一个这样的矩阵,因为上面的矩阵实际上可以看作是进行了一次缩放和平移,即对向量进行了一次乘法和加法:

由于可以从之前的Camera类获取当前的投影变换矩阵和观察变换矩阵,这里可以直接获取它们并进行求逆,得到在世界坐标系的位置:

4.2 射线类Ray

Ray类的定义如下:

struct Ray
{
	Ray();
	Ray(const DirectX::XMFLOAT3& origin, const DirectX::XMFLOAT3& direction);

	static Ray ScreenToRay(const Camera& camera, float screenX, float screenY);

	bool Hit(const DirectX::BoundingBox& box, float* pOutDist = nullptr, float maxDist = FLT_MAX);
	bool Hit(const DirectX::BoundingOrientedBox& box, float* pOutDist = nullptr, float maxDist = FLT_MAX);
	bool Hit(const DirectX::BoundingSphere& sphere, float* pOutDist = nullptr, float maxDist = FLT_MAX);
	bool XM_CALLCONV Hit(DirectX::FXMVECTOR V0, DirectX::FXMVECTOR V1, DirectX::FXMVECTOR V2, float* pOutDist = nullptr, float maxDist = FLT_MAX);

	DirectX::XMFLOAT3 origin;		// 射线原点
	DirectX::XMFLOAT3 direction;	// 单位方向向量
};

其中静态方法Ray::ScreenToRay执行的正是鼠标拾取中射线构建的部分,其实现灵感来自于DirectX::XMVector3Unproject函数,它通过给定在屏幕坐标系上的一点、视口属性、投影矩阵、观察矩阵和世界矩阵,来进行逆变换,得到在物体坐标系的位置:

inline XMVECTOR XM_CALLCONV XMVector3Unproject
(
    FXMVECTOR V, 
    float     ViewportX, 
    float     ViewportY, 
    float     ViewportWidth, 
    float     ViewportHeight, 
    float     ViewportMinZ, 
    float     ViewportMaxZ, 
    FXMMATRIX Projection, 
    CXMMATRIX View, 
    CXMMATRIX World
)
{
    static const XMVECTORF32 D = { { { -1.0f, 1.0f, 0.0f, 0.0f } } };

    XMVECTOR Scale = XMVectorSet(ViewportWidth * 0.5f, -ViewportHeight * 0.5f, ViewportMaxZ - ViewportMinZ, 1.0f);
    Scale = XMVectorReciprocal(Scale);

    XMVECTOR Offset = XMVectorSet(-ViewportX, -ViewportY, -ViewportMinZ, 0.0f);
    Offset = XMVectorMultiplyAdd(Scale, Offset, D.v);

    XMMATRIX Transform = XMMatrixMultiply(World, View);
    Transform = XMMatrixMultiply(Transform, Projection);
    Transform = XMMatrixInverse(nullptr, Transform);

    XMVECTOR Result = XMVectorMultiplyAdd(V, Scale, Offset);

    return XMVector3TransformCoord(Result, Transform);
}

将其进行提取修改,用于我们的Ray对象的构造:

Ray Ray::ScreenToRay(const Camera & camera, float screenX, float screenY)
{
	//
	// 节选自DirectX::XMVector3Unproject函数,并省略了从世界坐标系到局部坐标系的变换
	//
	
	// 将屏幕坐标点从视口变换回NDC坐标系
	static const XMVECTORF32 D = { { { -1.0f, 1.0f, 0.0f, 0.0f } } };
	XMVECTOR V = XMVectorSet(screenX, screenY, 0.0f, 1.0f);
	D3D11_VIEWPORT viewPort = camera.GetViewPort();

	XMVECTOR Scale = XMVectorSet(viewPort.Width * 0.5f, -viewPort.Height * 0.5f, viewPort.MaxDepth - viewPort.MinDepth, 1.0f);
	Scale = XMVectorReciprocal(Scale);

	XMVECTOR Offset = XMVectorSet(-viewPort.TopLeftX, -viewPort.TopLeftY, -viewPort.MinDepth, 0.0f);
	Offset = XMVectorMultiplyAdd(Scale, Offset, D.v);

	// 从NDC坐标系变换回世界坐标系
	XMMATRIX Transform = XMMatrixMultiply(camera.GetViewXM(), camera.GetProjXM());
	Transform = XMMatrixInverse(nullptr, Transform);

	XMVECTOR Target = XMVectorMultiplyAdd(V, Scale, Offset);
	Target = XMVector3TransformCoord(Target, Transform);

	// 求出射线
	XMFLOAT3 direction;
	XMStoreFloat3(&direction, XMVector3Normalize(Target - camera.GetPositionXM()));
	return Ray(camera.GetPosition(), direction);
}

此外,在构造Ray对象的时候,还需要预先检测direction是否为单位向量:

Ray::Ray(const DirectX::XMFLOAT3 & origin, const DirectX::XMFLOAT3 & direction)
	: origin(origin)
{
	// 射线的direction长度必须为1.0f,误差在1e-5f内
	XMVECTOR dirLength = XMVector3Length(XMLoadFloat3(&direction));
	XMVECTOR error = XMVectorAbs(dirLength - XMVectorSplatOne());
	assert(XMVector3Less(error, XMVectorReplicate(1e-5f)));

	XMStoreFloat3(&this->direction, XMVector3Normalize(XMLoadFloat3(&direction)));
}
bool Ray::Hit(const DirectX::BoundingBox & box, float * pOutDist, float maxDist)
{
	
	float dist;
	bool res = box.Intersects(XMLoadFloat3(&origin), XMLoadFloat3(&direction), dist);
	if (pOutDist)
		*pOutDist = dist;
	return dist > maxDist ? false : res;
}

bool Ray::Hit(const DirectX::BoundingOrientedBox & box, float * pOutDist, float maxDist)
{
	float dist;
	bool res = box.Intersects(XMLoadFloat3(&origin), XMLoadFloat3(&direction), dist);
	if (pOutDist)
		*pOutDist = dist;
	return dist > maxDist ? false : res;
}
构造好射线后,就可以跟各种碰撞盒(或三角形)进行相交检测了:
bool Ray::Hit(const DirectX::BoundingSphere & sphere, float * pOutDist, float maxDist)
{
	float dist;
	bool res = sphere.Intersects(XMLoadFloat3(&origin), XMLoadFloat3(&direction), dist);
	if (pOutDist)
		*pOutDist = dist;
	return dist > maxDist ? false : res;
}

bool XM_CALLCONV Ray::Hit(FXMVECTOR V0, FXMVECTOR V1, FXMVECTOR V2, float * pOutDist, float maxDist)
{
	float dist;
	bool res = TriangleTests::Intersects(XMLoadFloat3(&origin), XMLoadFloat3(&direction), V0, V1, V2, dist);
	if (pOutDist)
		*pOutDist = dist;
	return dist > maxDist ? false : res;
}

至于射线与网格模型的拾取,有三种实现方式,对精度要求越高的话效率越低:

  • 将网格模型单个OBB盒(或AABB盒)与射线进行相交检测,精度最低,但效率最高;
  • 将网格模型划分成多个OBB盒,分别于射线进行相交检测,精度较高,效率也比较高;
  • 将网格模型的所有三角形与射线进行相交检测,精度最高,但效率最低。而且模型面数越大,效率越低。这里可以先用模型的OBB(或AABB)盒与射线进行大致的相交检测,若在包围盒内再跟所有的三角形进行相交检测,以提升效率。

本次尝试中只考虑第1种方法,剩余的方法根据需求可以自行实现。

本文地址:https://blog.csdn.net/qq_35312463/article/details/109239812

《DirectX11进阶5_硬件实例化与视锥体裁剪及鼠标拾取交互.doc》

下载本文的Word格式文档,以方便收藏与打印。