DirectX11--HLSL中矩陣的內存佈局和mul函數探討

前言

說實話,我感覺這是一個大坑,不知道爲什麼要設計成這樣混亂的形式。

在我用的時候,以row_major矩陣,並且mul函數以向量左乘矩陣的形式來繪製時的確能夠正常顯示,並不會有什麼感覺。但是也有人會遇到明明傳的矩陣沒有問題,卻怎麼樣都繪製不出的情況;或者使用列矩陣,在mul函數用向量左乘的形式卻又可以繪製出來的疑問。因此本文目的就是要掃清這些障礙。

ps. 本問題由淡一抹夕霞提供。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

一些線性代數基礎

行主矩陣與列主矩陣

首先要了解的是,行主矩陣是這樣的:

\[ \mathbf{M}=\begin{bmatrix} m_{11} & m_{12} & m_{13} & m_{14} \\ m_{21} & m_{22} & m_{23} & m_{24} \\ m_{31} & m_{32} & m_{33} & m_{34}\\ m_{41} & m_{42} & m_{43} & m_{44} \end{bmatrix}\]

列主矩陣是這樣的:

\[ \mathbf{M}=\begin{bmatrix} m_{11} & m_{21} & m_{31} & m_{41} \\ m_{12} & m_{22} & m_{32} & m_{42} \\ m_{13} & m_{23} & m_{33} & m_{43}\\ m_{14} & m_{24} & m_{34} & m_{44} \end{bmatrix}\]

行主矩陣經過一次轉置後就會變成列主矩陣

矩陣左乘與右乘

由於矩陣乘法不滿足交換律,則需要區分當前矩陣位於乘號的左邊還是右邊。有時候經常都會聽到左乘右乘這兩個概念,下面是有關它們的含義:

左乘指的是該矩陣位於乘號的左邊,例如:行向量 左乘 矩陣,即行向量在乘號的左邊

右乘指的是該矩陣位於乘號的右邊,例如:列向量 右乘 矩陣,即列向量在乘號的右邊

ps. 向量也是矩陣

行向量v和矩陣M滿足下面的關係:

\[ \mathbf{(vM)}^{T} = \mathbf{M}^{T} \mathbf{v}^{T} \]

C++和HLSL中矩陣的內存佈局

在C++的DirectXMath中,無論是XMFLOAT4X4,還是使用函數生成的XMMATRIX,都是採用行主矩陣的存儲方式。在連續內存中的佈局是這樣的:
\[ m_{11} \; m_{12} \; m_{13} \; m_{14} \; m_{21} \; m_{22} \; m_{23} \; m_{24} \; m_{31} \; m_{32} \; m_{33} \; m_{34} \; m_{41} \; m_{42} \; m_{43} \; m_{44}\]

在C++傳遞給HLSL的字節流數據是不會發生變化的,這一點可以通過VS自帶的圖形調試器可以察看。

但是數據傳遞給HLSL後,matrix(float4x4)的屬性決定如何去接受這些數據。

默認情況下,matrix(float4x4)是列矩陣,這意味着它會按列主矩陣的形式進行選取,相當於進行了一次轉置。

如果想讓它按行主矩陣的形式進行選取,則應當在前面加上row_major修飾符以避免"轉置"。

HLSL中的mul函數

微軟的官方文檔是這麼描述mul函數的(微軟官方文檔鏈接),這裏進行個人翻譯:

使用矩陣數學來進行矩陣x左乘矩陣y的運算,要求矩陣x的列數與矩陣y的行數相等。

如果x是一個向量,那麼它將被解釋爲行向量。

如果y是一個向量,那麼它將被解釋爲列向量。

表面上看起來很美滿,很智能,但稍有不慎就要在這裏踩大坑了。

dp4指令

dp4是一個彙編指令(微軟官方文檔鏈接),使用方法如下:

dp4 dst, src0, src1

其中 src0和src1是一個向量,計算它們的點乘並將結果傳給dst。

當然這裏並不是要教大家怎麼寫彙編,而是怎麼看。

爲了瞭解mul函數是如何進行向量與矩陣的乘法運算,我們需要探討一下它的彙編實現。這裏我所使用的是row_major矩陣。首先是向量作爲第一個參數的情況:

可以看到這種運算方式實際上卻是按照向量右乘矩陣的形式進行的運算。

然後是將向量作爲第二個參數的情況(僅單純的參數交換):

無論是行向量左乘矩陣,還是列向量右乘矩陣,在彙編層面上都是用dp4的形式進行計算,這是因爲對矩陣來說在內存上是以4個行向量的形式存儲的,傳遞一行比傳遞一列更簡單,適合進行與列向量的運算,並且效率會更高。

但是交換兩個參數卻會導致運算結果/顯示結果的不同,這時候就要看看矩陣所存的值了。

先看一段HLSL代碼:

struct VertexPosNormalTex
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 Tex : TEXCOORD;
};

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

// 頂點着色器
VertexPosHWNormalTex VS(VertexPosNormalTex pIn)
{
    VertexPosHWNormalTex pOut;
    
    row_major matrix viewProj = mul(gView, gProj);

    pOut.PosW = mul(float4(pIn.PosL, 1.0f), gWorld).xyz;
    pOut.PosH = mul(float4(pOut.PosW, 1.0f), viewProj);
    pOut.NormalW = mul(pIn.NormalL, (float3x3) gWorldInvTranspose);
    pOut.Tex = pIn.Tex;
    return pOut;
}

我們只考慮viewProj的初始化和pOut.PosH的賦值操作。

首先是viewProj原本的值:

這是向量左乘矩陣時四個向量寄存器的值(默認HLSL):

這是向量右乘矩陣時四個向量寄存器的值(將float4(pOut.PosW, 1.0f)viewProj交換):

可以發現這裏面隱含了一次轉置操作。這裏的轉置不是憑空出現的,而是源自於這句話前面的代碼所產生的彙編(默認HLSL):

而將float4(pOut.PosW, 1.0f)viewProj交換後,則彙編代碼沒有了轉置操作:

因此,我們可以知道一個行向量左乘行主矩陣時,爲了滿足mul函數使用dp4指令優化運算,很可能會預先對原來的矩陣進行轉置。其中r4 r5 r6 r3爲viewProj轉置後的矩陣,即將會左乘向量float4(pOut.PosW, 1.0f)

總結

綜上所述,有三處地方可能會發生轉置:

  1. C++代碼端的轉置
  2. HLSL中matrix(float4x4)是列主矩陣時會發生轉置
  3. mul乘法內部是以列向量右乘矩陣的形式實現的,對於行向量左乘矩陣的情況會發生轉置

經過組合,就一共有四種能夠正常繪製的情況:

  1. C++代碼端不進行轉置,HLSL中使用row_major matrix,mul函數使用行向量左乘矩陣。這種方法易於理解,但是在HLSL中很可能會產生用於轉置矩陣的大量指令,性能上略有損失。
  2. C++代碼端進行轉置,HLSL中使用matrix,mul函數使用行向量左乘矩陣。這是官方例程所使用的方式,可以避免HLSL的轉置,但是在理解上可能會有所困惑,容易陷入爲什麼不是使用列向量右乘矩陣的迷思中。後續我會將教程的項目也使用這種方式,但是爲了在使用上能表現得更像是向量(矩陣)左乘矩陣的形式,在C++端矩陣仍然按行主矩陣傳入,但通過一些手段在內部隱藏轉置操作,然後在HLSL端也統一用向量(矩陣)左乘矩陣的思想來編寫着色器代碼。
  3. C++代碼端不進行轉置,HLSL中使用matrix,mul函數使用列向量右乘矩陣。這種方法的確可行,效率上和2等同,就是HLSL那邊的矩陣乘法都要反過來寫,對於適應了左乘寫法的人來說不建議使用這種方式。
  4. C++代碼端進行轉置,HLSL中使用row_major matrix,mul函數使用列向量右乘矩陣。就算這種方法也可以繪製出來,但還是很讓人難受,比第2點還難受。

也就是說,以組合1爲基準,任意改變其中兩個狀態(即轉置兩次)都不會影響最終結果。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章