前言
拾取是一項非常重要的技術,不論是電腦上用鼠標操作,還是手機的觸屏操作,只要涉及到UI控件的選取則必然要用到該項技術。除此之外,一些類似魔獸爭霸3、星際爭霸2這樣的3D即時戰略游戲也需要通過拾取技術來選中角色。
給定在2D屏幕坐標系中由鼠標選中的一點,並且該點對應的正是3D場景中某一個對象表面的一點。 現在我們要做的,就是怎么判斷我們選中了這個3D對象。
在閱讀本章之前,先要了解下面的內容:
章節 |
---|
05 鍵盤和鼠標輸入 |
06 DirectXMath數學庫 |
10 攝像機類 |
18 使用DirectXCollision庫進行碰撞檢測 |
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。
核心思想
龍書11上關於鼠標拾取的數學原理講的過於詳細,這里盡可能以簡單的方式來描述。
因為我們所能觀察到的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
類獲取當前的投影變換矩陣和觀察變換矩陣,這里可以直接獲取它們並進行求逆,得到在世界坐標系的位置:
射線類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種方法,剩余的方法根據需求可以自行實現。
最后是一個項目演示動圖,該項目沒有做點擊物體后的反應。鼠標放到這些物體上會當即顯示出當前所拾取的物體,點擊物體就會彈出窗口。其中立方體和房屋使用的是OBB盒。
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。