用户登录
用户注册

分享至

DirectX11--HLSL中矩阵的内存布局和mul函数探讨

  • 作者: 嗫?暁雲?
  • 来源: 51数据库
  • 2021-11-21

前言

说实话,我感觉这是一个大坑,不知道为什么要设计成这样混乱的形式。

在我用的时候,以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,以及有什么问题也可以在这里汇报。

软件
前端设计
程序设计
Java相关