前言

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

(2020年4月12日更新)过了几年回头看这篇博客,连我自己都看的头发都乱了,又重新理了一遍才把自己说通。。于是这篇博客又被我大改了一遍。

在我用的时候,以row_major矩阵,并且mul函数以向量左乘矩阵的形式来绘制时的确能够正常显示,并不会有什么感觉。但是也有人会遇到明明传的矩阵没有问题,却怎么样都绘制不出的情况;或者使用一遍矩阵,在mul函数用向量左乘的形式却又可以绘制出来的疑问。因此本文目的就是要扫清这些障碍。

ps. 本问题由淡一抹夕霞提供。

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

一些线性代数基础

行主序矩阵与列主序矩阵

首先要了解的是,对于连续内存数据:

\[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}
\]

行主序矩阵是这样解释数据的:

\[\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}
\]

上述数据流传递到HLSL后,若是传递给cb0的寄存器的前4个向量,那么它内存布局一定如下:

cb0[0].xyzw = (m11, m12, m13, m14);
cb0[1].xyzw = (m21, m22, m23, m24);
cb0[2].xyzw = (m31, m32, m33, m34);
cb0[3].xyzw = (m41, m42, m43, m44);

而在HLSL中,默认的matrixfloat4x4采用的是列主序矩阵的解释形式。

假设在HLSL的cbuffer为:

cbuffer cb : register(b0)
{
(row_major) matrix g_World;
}

如果g_Worldmatrixfloat4x4类型,由于是列主序矩阵,上面的4个寄存器存储的数据会被看作:

\[\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}
\]

而如果g_Worldrow_major matrixrow_major float4x4类型,则为行主序矩阵,上面的4个寄存器存储的数据则依然被视作:

\[\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}
\]

HLSL中的mul函数

微软的官方文档是这么描述mul函数的(微软官方文档链接),这里进行个人翻译:

使用矩阵数学来进行矩阵x左乘矩阵y的运算,要求矩阵x的列数与矩阵y的行数相等。

如果x是一个向量,那么它将被解释为行向量。

如果y是一个向量,那么它将被解释为列向量。

表面上看起来很美满,很智能,但稍有不慎就要在这里踩大坑了。

dp4指令

dp4是一个汇编指令(微软官方文档链接),使用方法如下:

dp4 dst, src0, src1

其中 src0和src1是一个向量,计算它们的点乘并将结果传给dst。

当然这里并不是要教大家怎么写汇编,而是怎么看。

为了了解mul函数是如何进行向量与矩阵的乘法运算,我们需要探讨一下它的汇编实现。这里我所使用的是row_major矩阵。首先是向量作为第一个参数的情况:

可以看到这种运算方式实际上却是按照向量右乘矩阵的形式进行的运算。

然后是将向量作为第二个参数的情况(仅单纯的参数交换):

无论是行向量左乘矩阵,还是列向量右乘矩阵,在汇编层面上都是用dp4的形式进行计算,这是因为对矩阵来说在内存上是以4个行向量的形式存储的,传递一行的寄存器向量比传递一列更简单,适合进行与列向量的运算,并且效率会更高。

然后眼尖的同学会发现,同一条指令,只是改变了顺序,指令执行的起始地址就产生了差别(16条指令数目差)。

但是交换两个参数又会导致运算结果/显示结果的不同,这时候就要看看矩阵所存的值了。

先看一段HLSL代码:

cbuffer cb : register(b0)
{
row_major matrix gWorld;
row_major matrix gView;
row_major matrix gProj;
} 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交换后,则汇编代码没有了转置操作:

严格意义上说,00000000到0000001B的指令才是上图语句的实际执行内容,而0000001C到0000002B的代码则应是在计算pOut.PosH = mul(float4(pOut.PosW, 1.0f), viewProj);之前所进行的一系列额外操作。

因此无论是行向量还是列向量,在执行完0000001B指令后,行主序矩阵viewProj的内存布局一定为:

如果用行向量左乘该行主序矩阵,由于dp4运算需要按列取出这些寄存器的值,为此需要额外16条指令进行转置(0000001C到0000002B)。

而如果用列向量右乘该行主序矩阵,则不需要进行转置,直接取寄存器行向量就可以直接进行dp4运算。

因此,我们可以知道一个行向量左乘行主序矩阵时,为了满足mul函数使用dp4指令优化运算,需要会预先对原来的矩阵进行转置。其中r4 r5 r6 r3为viewProj转置后的矩阵,即将会左乘向量float4(pOut.PosW, 1.0f)。而列向量右乘行主序矩阵则可以避免转置操作。

同理,如果采用列主序矩阵行向量左乘列主序矩阵可以避免转置操作;而列向量右乘行主序矩阵又会产生转置操作。

故对于dp4来说,最好是能够对一个行向量列主序矩阵(取列主序矩阵的行,也就是取一行寄存器向量与行向量做点乘)操作,又或者是对一个行主序矩阵(取行主序矩阵的行与列向量做点乘)和列矩阵操作,这样都能有效避免转置。

总结

综上所述,有三处地方可能会发生转置:

  1. C++代码端的转置
  2. HLSL中matrix(float4x4)是列主矩阵时会发生转置
  3. mul乘法内部是以列向量右乘矩阵的形式实现的,对于行向量左乘矩阵的情况会发生转置

经过组合,就一共有四种能够正常绘制的情况:

  1. C++代码端不进行转置,HLSL中使用row_major matrix(行主序矩阵),mul函数让向量放在左边(行向量),这样实际运算就是(行向量 X 行主序矩阵) 。这种方法易于理解,但是这样做dp4运算取矩阵的列很不方便,在HLSL中会产生用于转置矩阵的大量指令,性能上有损失。
  2. C++代码端进行转置,HLSL中使用matrix(列主序矩阵) ,mul函数让向量放在左边(行向量),这样就是(行向量 X 列主序矩阵),但C++这边需要进行一次矩阵转置,HLSL内部不产生转置 。这是官方例程所使用的方式,这样可以使得dp4运算可以直接取列主序矩阵的行,从而避免内部产生大量的转置指令。后续我会将教程的项目也使用这种方式。
  3. C++代码端不进行转置,HLSL中使用matrix(列主序矩阵),mul函数让向量放在右边(列向量),实际运算是(列主序矩阵 X 列向量)。这种方法的确可行,取列矩阵的行也比较方便,效率上又和2等同,就是HLSL那边的矩阵乘法都要反过来写,然而DX本身就是崇尚行主矩阵的,把OpenGL的习惯带来这边有点。。。
  4. C++代码端进行转置,HLSL中使用row_major matrix(行主序矩阵),mul函数让向量放在右边(列向量),实际运算是(行主序矩阵 X 列向量)。 就算这种方法也可以绘制出来,但还是很让人难受,比第2点还难受,我甚至不想去说它。

也就是说,以组合1为基准,任意改变其中两个状态都不会影响最终结果。

DirectX11 With Windows SDK完整目录

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

DirectX11--HLSL中矩阵的内存布局和mul函数探讨的更多相关文章

  1. JVM中对象的内存布局与访问定位

      一.对象的内存布局 已主流的HotSpot虚拟机来说,   在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header).实例数据(Instance Data)和对齐填 ...

  2. STL库中string类内存布局的探究

    在STL中有着一个类就是string类,他的内存布局和存储机制究竟是怎么样的呢? 这就是建立好的string 可以看出,图中用黄色框框标注的部分就是主要区域 我们用来给string对象进行初始化的字符 ...

  3. 99.9%的Java程序员都说不清的问题:JVM中的对象内存布局?

    本文转载自公众号:石彬的架构笔记,阅读大约需要8分钟. 作者:李瑞杰 目前就职于阿里巴巴,资深 JVM 研究人员 在 Java 程序中,我们拥有多种新建对象的方式.除了最为常见的 new 语句之外,我 ...

  4. 理解numpy中ndarray的内存布局和设计哲学

    目录 ndarray是什么 ndarray的设计哲学 ndarray的内存布局 为什么可以这样设计 小结 参考 博客:博客园 | CSDN | blog 本文的主要目的在于理解numpy.ndarra ...

  5. C# Struct的内存布局

    转载:http://www.csharpwin.com/csharpspace/10454r4891.shtml 问题:请说出以下struct的实例大小以及内存布局 struct Struct1 { ...

  6. vs查看虚函数表和类内存布局

    虚继承和虚基类 虚继承:在继承定义中包含了virtual关键字的继承关系:     虚基类:在虚继承体系中的通过virtual继承而来的基类,需要注意的是:class CSubClass : publ ...

  7. C++对象的内存布局以及虚函数表和虚基表

    C++对象的内存布局以及虚函数表和虚基表 本文为整理文章, 参考: http://blog.csdn.net/haoel/article/details/3081328 http://blog.csd ...

  8. VS2013命令行界面查看虚函数的内存布局

    内存布局可能使用vs的界面调试看到的旺旺是一串数字,很不方便,但是vs的命令行界面可以很直观的显示出一个类中具体的内存布局. 打开命令行.界面如下所示: 测试代码如下所示: class Base1 { ...

  9. 【JVM之内存与垃圾回收篇】对象实例化内存布局与访问定位

    对象实例化内存布局与访问定位 从各自具体的内存分配上来讲 new 的对象放在堆中 对象所属的类型信息是放在方法区的 方法当中的局部变量放在栈空间 这 new 的对象怎么把三块粘合到一起 就是这章的内容 ...

随机推荐

  1. zabbix忘记admin登录密码重置密码

    问题描述: 有时候忘记admin的密码了,因为账号太多 解决方案: 1.zabbix连接的是mysql数据库 [root@localhost /]# mysql -uroot -pAbc123 #-u ...

  2. Filebeat使用内置的mysql模块收集日志存储到ES集群并使用kibana存储

    Filebeat内置了不少的模块,可以直接使用他们对日志进行收集,支持的模块如下: [root@ELK-chaofeng07 logstash]# filebeat modules list Enab ...

  3. selenium Python 总结一些工作中可能会经常使用到的API。

    selenium Python 总结一些工作中可能会经常使用到的API. 1.获取当前页面的Url 方法:current_url 实例:driver.current_url 2.获取元素坐标 方法:l ...

  4. 腾讯大数据平台Oceanus: A one-stop platform for real time stream processing powered by Apache Flink

    January 25, 2019Use Cases, Apache Flink The Big Data Team at Tencent     In recent years, the increa ...

  5. 好程序员技术教程分享JavaScript运动框架

    好程序员技术教程分享JavaScript运动框架,有需要的朋友可以参考下. JavaScript的运动,即让某元素的某些属性由一个值变到另一个值的过程.如让div的width属性由200px变到400 ...

  6. appium设置会话时间,可以超长时。Open Application

  7. 移动端无限滚动 TScroll.vue组件

    // 先看使用TScroll.vue的几个demo 1.https://sorrowx.github.io/TScroll/#/ 2. https://sorrowx.github.io/TScrol ...

  8. Linux中断管理

    CPU和外设之间的交互,或CPU通过轮询机制查询,或外设通过中断机制主动上报. 对大部分外设中断比轮询效率高,但比如网卡驱动采取轮询比中断效率高. 这里重点关注ARM+Linux组合下中断管理,从底层 ...

  9. C#中的Finalize,Dispose,SuppressFinalize(转载)

    MSDN建议按照下面的模式实现IDisposable接口: public class Foo : IDisposable { public void Dispose() { Dispose(true) ...

  10. 动态链接库-Win32 DLL的说明

    在实际编程时,我们可以把完成某种功能的函数放在一个动态链接库中,然后给其他程序调用. WinAPI中所有的函数都包含在3个最重要的DLL中. Kernel32.dll 它包含那些用于管理内存.进程和线 ...