DirectXMath 中的向量

1,802 阅读9分钟

DirectXMath 是微软(Microsoft)开发的一个数学库,它的 API 提供了一些支持 SIMD(single instruction multiple data)的 C++ 类型和函数,以使得能在 DirectX 应用中执行一些常用的线性代数和图形学相关的运算操作。对于Windows 8 以上版本的 Windows 操作系统,DirectXMath 是 Windows SDK 的一部分,支持 SSE2(Streaming SIMD Extensions 2)指令集。

本文简要介绍 DirectXMath 中向量相关的一些内容,包括向量类型、编译优化以及常用的函数运算等。

SIMD 和 SSE2

SIMD(single instruction multiple data)是一种采用一个控制器来控制多个处理单元同时对一组数据(又称“数据向量”)中的每一个数据点分别执行相同的操作,从而实现空间上的并行性的技术,在微处理器中的例子有 Intel 的 MMX、SSE 以及 AMD 的 3D Now! 指令集等。

SSE2(Streaming SIMD Extensions 2)是一种 IA-32 架构的 SIMD 指令集。SSE2 是在 2001 年随着 Intel 发布第一代 Pentium 4 处理器而一并推出的指令集,它延伸了较早的 SSE 指令集,并意在完全取代 MMX) 指令集。

利用 128-bit 的 SIMD 寄存器,SSE2 可以通过一个指令同时操作 4 个 32-bit 的 floatint 类型的数据。图形学应用中的向量一般不会超过四维,因此 SIMD 指令能很好地于提升图形学应用的性能。

类型及编译优化

为了能够利用 SIMD 带来的性能提升,使用 DirectXMath 库时有一些与编译、操作系统和硬件相关的问题需要注意。

Visual Studio 编译器配置

既然我们要用微软搞出来的 DirectXMath,在 Windows 上开发当然是首选宇宙第一 IDE —— Visual Studio 了(我使用的版本是 Visual Studio 2017 Community)。

对于 x86 平台,我们需要启用 SSE2 指令集:

Project Properties > Configuration Properties > C/C++ > Code Generation > Enable Enhanced Instruction Set) > Streaming SIMD Extensions 2 (/arch:SSE2)

而在 x64 平台上,不需要我们启用 SSE2 指令集,因为所有的 x64 CPU 都支持 SSE2 指令集。

另外,对于所有的平台,我们都应该启用快速浮点模型(fast floating point model) /fp:fast

Project Properties > Configuration Properties > C/C++ > Code Generation > Floating Point Model > Fast (/fp:fast)

向量类型及内存对齐

在 DirectXMath 中,核心的向量类型是 XMVECTOR ,它对应了一个 SIMD 寄存器,是一个 128-bit 的类型,能够同时处理 4 个 32-bit 的 float 类型的数据,它的定义如下:

typedef __m128 XMVECTOR

其中 __m128 是一个 SIMD 类型,我们必须要使用这个类型来进行向量的计算,才能利用到 SIMD 的优势。

需要注意的是, XMVECTOR 类型在内存中是需要 16-byte 对齐的(如果不清楚什么是内存对齐以及为什么要进行内存对齐,请自行谷歌一下)。对于全局变量和局部变量,编译器会自动实现内存对齐,但是对于类的数据成员来说,直接使用 XMVECTOR 类型就有可能会造成内存对齐的问题,因此一般会使用 XMFLOAT2XMFLOAT3XMFLOAT4 类型代替。XMFLOAT3 类型的定义如下(XMFLOAT2XMFLOAT4 也是类似的,只是类的浮点数成员数量不同):

struct XMFLOAT3 {
    float x;
    float y;
    float z;
    
    XMFLOAT3() {}
    XMFLOAT3(float _x, float _y, float _z) : 
        x(_x), y(_y), z(_z) {}
    explicit XMFLOAT3(_In_reads_(3) const float *pArray) :
        x(pArray[0]), y(pArray[1]), z(pArray[2]) {}
    XMFLOAT3& operator= (const XMFLOAT3& Float3) { 
        x = Float3.x; y = Float3.y; z = Float3.z; 
        return *this; 
    }
};

然而,直接使用 XMFLOATn 类型是无法利用 SIMD 的,所以我们需要将其转化为 XMVECTOR 类型再执行运算,运算完成后再将结果转回 XMFLOATn 类型。DirectXMath 提供了相应的 Loading 和 Storage 方法来进行这些转换:

// Loading methods: load data from XMFLOATn into XMVECTOR
XMVECTOR XM_CALLCONV XMLoadFloat2(const XMFLOAT2 *pSource);
XMVECTOR XM_CALLCONV XMLoadFloat3(const XMFLOAT3 *pSource);
XMVECTOR XM_CALLCONV XMLoadFloat4(const XMFLOAT4 *pSource);

// Stroing methods: store XMVECTOR into XMFLOATn
void XM_CALLCONV XMStoreFloat2(XMFLOAT2 *pDestination, FXMVECTOR V);
void XM_CALLCONV XMStoreFloat3(XMFLOAT3 *pDestination, FXMVECTOR V);
void XM_CALLCONV XMStoreFloat4(XMFLOAT4 *pDestination, FXMVECTOR V);

有时我们只想要操作 XMVECTOR 中的一个分量,可以使用下列的 getter 和 setter 函数:

// Getter functions
float XM_CALLCONV XMVectorGetX(FXMVECTOR V);
float XM_CALLCONV XMVectorGetY(FXMVECTOR V);
float XM_CALLCONV XMVectorGetZ(FXMVECTOR V);
float XM_CALLCONV XMVectorGetW(FXMVECTOR V);

// Setter functions
XMVECTOR XM_CALLCONV XMVectorSetX(FXMVECTOR V, float x);
XMVECTOR XM_CALLCONV XMVectorSetY(FXMVECTOR V, float y);
XMVECTOR XM_CALLCONV XMVectorSetZ(FXMVECTOR V, float z);
XMVECTOR XM_CALLCONV XMVectorSetW(FXMVECTOR V, float w);

函数参数传递及调用约定

可能你在看到上面的函数原型时会感到疑惑:为什么函数名前面会有一个 XM_CALLCONV 修饰符,为什么函数的参数类型不是 XMVECTOR 而是 FXMVECTOR。这是因为 DirectXMath 利用这些修饰符和类型别名来指导编译器生成利用 SIMD 的高效目标代码,从而提升程序的性能。

参数传递

为了提高效率,在函数调用中 XMVECTOR 类型的值作为参数被传递到函数中时,可以直接传递到 SSE/SSE2 寄存器中,而不用存到栈内存中(如果不清楚寄存器、栈内存、函数调用的编译相关的知识,推荐可以阅读经典著作 《Computer Systems: A Programmer’s Perspective (3rd Edition)》,对于理解计算机系统很有帮助)。

但是, SSE/SSE2 寄存器的数量、函数调用中可直接传递到这些寄存器中的参数的最大数量是和平台和编译器相关的,在不同的平台和编译器下有不同的值。为了提高程序的可移植性,我们可以使用 DirectXMath 提供的类型别名,XMVECTOR 参数的传递规则如下:

  1. 前三个 XMVECTOR 参数使用 FXMVECTOR 类型;
  2. 第四个 XMVECTOR 参数使用 GXMVECTOR 类型;
  3. 第五、六个 XMVECTOR 参数使用 HXMVECTOR 类型;
  4. 其余额外的 XMVECTOR 参数使用 CXMVECTOR 类型。

需要注意的是,上述的计数是针对类型为 XMVECTOR 的输入参数而言的,XMVECTOR 类型的输出参数(引用、指针)以及其他类型的参数不算入到参数个数的计数中,计数时可以将他们在函数原型中忽略。下面是一个函数原型的例子:

inline XMMATRIX XM_CALLCONV XMMatrixTransformation2D(
    FXMVECTOR ScalingOrigin,
    float ScalingOrientation,
    FXMVECTOR Scaling,
    FXMVECTOR RotationOrigin,
    float Rotation,
    GXMVECTOR Translation
);

调用约定(Calling Conventions)

上面我们讨论了在函数调用时不同的参数位置上应当使用的 XMVECTOR 相应类型别名,以及我们之前也提到了 XMVECTOR 是一个在内存中 16-byte 对齐的类型。为了满足在不同平台和编译器上这些函数调用的需求,我们需要使用合适的 DirectXMath 提供的调用约定(Calling Conventions),也就是在函数名称前使用修饰符 XM_CALLCONV

XM_CALLCONV 在不同的平台和编译器下有不同的两种定义:__fastcall__vectorcall。它们都是 Windows 下特有的修饰符,用于实现 __m128 类型值的高效参数传递,它们会决定 FXMVECTORGXMVECTOR 等类型别名的定义。

__vectorcall 是更新版本编译器支持的调用约定,相比 __fastcall,它可以将更多数量的参数直接传递到 SSE/SSE2 寄存器中。至于它们的一些详情细节及其他问题,我也不太清楚,感兴趣的可以去查阅微软提供的文档。

总之,为了提高代码的可移植性和平台/编译器无关性,我们在函数调用时应当使用 XM_CALLCONV 修饰符以及 按规则 使用 FXMVECTORGXMVECTOR 等类型别名。

向量常量

对于 XMVECTOR 的常量实例,我们应当使用 XMVECTORF32 类型。实际上,当我们要使用 C++ 的初始化语法时,我们都应该使用 XMVECTORF32 类型。下面是一些例子:

static const XMVECTORF32 g_vHalfVector = { 0.5f, 0.5f, 0.5f, 0.5f };

XMVECTORF32 vRightTop = {
    vViewFrust.RightSlope,
    vViewFrust.TopSlope,
    1.0f, 1.0f
};

XMVECOTRF32也是一个 16-byte 对齐的结构,它也可以转化为 XMVECTOR 类型,其定义如下:

__declspec(align(16)) struct XMVECTORF32 {
    union {
        float f[4];
        XMVECTOR v;
    };
    inline operator XMVECTOR() const { return v; }
    inline operator const float*() const { return f; }
    #if !defined(_XM_NO_INTRINSICS_) && defined(_XM_SSE_INTRINSICS_)
    inline operator __m128i() const { 
        return _mm_castps_si128(v); 
    }
    inline operator __m128d() const { 
        return _mm_castps_pd(v); 
    }
    #endif
};

常用的重载运算符

XMVECTOR 重载了一些运算符,以进行相关的向量计算:

XMVECTOR XM_CALLCONV operator+ (FXMVECTOR V);
XMVECTOR XM_CALLCONV operator- (FXMVECTOR V);

XMVECTOR& XM_CALLCONV operator+= (XMVECTOR& V1, FXMVECTOR V2);
XMVECTOR& XM_CALLCONV operator-= (XMVECTOR& V1, FXMVECTOR V2);
XMVECTOR& XM_CALLCONV operator*= (XMVECTOR& V1, FXMVECTOR V2);
XMVECTOR& XM_CALLCONV operator/= (XMVECTOR& V1, FXMVECTOR V2);

XMVECTOR& operator*= (XMVECTOR& V, float S);
XMVECTOR& operator/= (XMVECTOR& V, float S);

XMVECTOR XM_CALLCONV operator+ (FXMVECTOR V1, FXMVECTOR V2);
XMVECTOR XM_CALLCONV operator- (FXMVECTOR V1, FXMVECTOR V2);
XMVECTOR XM_CALLCONV operator* (FXMVECTOR V1, FXMVECTOR V2);
XMVECTOR XM_CALLCONV operator/ (FXMVECTOR V1, FXMVECTOR V2);
XMVECTOR XM_CALLCONV operator* (FXMVECTOR V, float S);
XMVECTOR XM_CALLCONV operator* (float S, FXMVECTOR V);
XMVECTOR XM_CALLCONV operator/ (FXMVECTOR V, float S);

常用的常量和内联函数

DirectXMath 定义了一些常用的常量和内联函数。

一些与 π" role="presentation" style="position: relative;">\pi 有关的常量:

const float XM_PI = 3.141592654f;
const float XM_2PI = 6.283185307f;
const float XM_1DIVPI = 0.318309886f;
const float XM_1DIV2PI = 0.159154943f;
const float XM_PIDIV2 = 1.570796327f;
const float XM_PIDIV4 = 0.785398163f;

弧度/角度转化、取最大/最小值的内联函数:

inline float XMConvertToRadians(float fDegrees) { 
    return fDegrees * (XM_PI / 180.0f);
}
inline float XMConvertToDegrees(float fRadians) { 
    return fRadians * (180.0f / XM_PI); 
}

template<class T> inline T XMMin(T a, T b) { 
    return (a < b) ? a : b; 
}
template<class T> inline T XMMax(T a, T b) {
    return (a > b) ? a : b; 
}

常用的向量函数

DirectXMath 提供了一系列常用的函数来对向量进行操作以及进行相关的向量运算。

Setter 函数

可以使用下列函数来设置 XMVECTOR 值的内容:

// Returns the zero vector 0
XMVECTOR XM_CALLCONV XMVectorZero();
// Returns the vector (1, 1, 1, 1)
XMVECTOR XM_CALLCONV XMVectorSplatOne();
// Returns the vector (x, y, z, w)
XMVECTOR XM_CALLCONV XMVectorSet(float x, float y, float z, float w);
// Returns the vector (s, s, s, s)
XMVECTOR XM_CALLCONV XMVectorReplicate(float Value);
// Returns the vector (vx, vx, vx, vx)
XMVECTOR XM_CALLCONV XMVectorSplatX(FXMVECTOR V);
// Returns the vector (vy, vy, vy, vy)
XMVECTOR XM_CALLCONV XMVectorSplatY(FXMVECTOR V);
// Returns the vector (vz, vz, vz, vz)
XMVECTOR XM_CALLCONV XMVectorSplatZ(FXMVECTOR V);

向量函数

DirectXMath 提供了许多函数以执行向量运算,下面列出了一些常用的 3D 版本的向量函数(它们也有相应的 2D 和 4D 版本,只需要把函数名中的 3 替换为 24 即可):

XMVECTOR XM_CALLCONV XMVector3Length( // Returns |v||
    FXMVECTOR V  // Input v
);
XMVECTOR XM_CALLCONV XMVector3LengthSq( // Returns ||v||^2
    FXMVECTOR V  // Input v
);
XMVECTOR XM_CALLCONV XMVector3Dot( // Returns v1 · v2
    FXMVECTOR V1, // Input v1
    FXMVECTOR V2  // Input v2
); 
XMVECTOR XM_CALLCONV XMVector3Cross( // Returns v1 × v2
    FXMVECTOR V1, // Input v1
    FXMVECTOR V2  // Input v2
);
XMVECTOR XM_CALLCONV XMVector3Normalize( // Returns v / ||v||
    FXMVECTOR V  // Input v
);
XMVECTOR XM_CALLCONV XMVector3Orthogonal( // Returns a vector orthogonal to v
    FXMVECTOR V  // Input v
);
XMVECTOR XM_CALLCONV XMVector3AngleBetweenVectors( // Returns the angle between v1 and v2
    FXMVECTOR V1, // Input v1
    FXMVECTOR V2  // Input v2
);
void XM_CALLCONV XMVector3ComponentsFromNormal(
    XMVECTOR* pParallel, // Returns proj_n(v)
    XMVECTOR* pPerpendicular, // Returns perp_n(v)
    FXMVECTOR V, // Input v
    FXMVECTOR Normal  // Input n
);
bool XM_CALLCONV XMVector3Equal( // Returns v1 == v2
    FXMVECTOR V1, // Input v1
    FXMVECTOR V2  // Input v2
);
bool XM_CALLCONV XMVector3NotEqual( // Returns v1 != v2
    FXMVECTOR V1, // Input v1
    FXMVECTOR V2 // Input v2
);

总结

本文一开始先简单介绍了 SIMD 和 SSE2 ;然后介绍 DirectXMath 中类型和编译优化相关的一些话题,包括 Visual Studio 编译器的配置、核心向量类型 XMVECTOR 及其内存对齐的问题(引入XMFLOAT2XMFLOAT3XMFLOAT4 类型及相关的 Loading 和 Store 方法)、函数参数传递及调用约定(XM_CALLCONV修饰符和FXMVECTOR等类型别名)以及向量常量(XMVECTORF32类型);最后介绍了 DirectXMath 中一些常用的重载运算符、常量值、内联函数、Setter 函数以及向量函数。

参考文献