SharpDX 的 Matrix.PerspectiveFov* 问题

我之前曾经暗笑过,毕设我的题目是一个 Direct3D 的东西,我觉得一个多月就可以搞定。然而,实际上手之后,世界又欺骗了我。今天我讲一下我是如何被几个连锁的问题坑,最终发现并解决了的。

首先,按照项目要求、我的喜好和能力范围,我选择的是 C#/SharpDX 的组合,没有选择 C++ 也没有选择 XNA。不过原理是相通的,所以 DirectX 的教程在大致了解了 SharpDX 的封装风格后还是能用的。我采用的是这个教程的逐步深入方式(学习嘛),而不是 MiniCube 示例的直接上顶点数组+shader的方式。

我跳过了 Lesson 4 ,虽然知道这方式很蛋疼(用多了 WebGL 习惯了 OpenGL 的思路),特别是顶点格式(SetFVF())。不过看起来后期会用到 VertexDeclaration,好理解多了,一步一步来吧。然而转到 Lesson 5 的时候就出问题了,我的屏幕上什么都没出现。

我是进行了初步的代码理解后就开始设计以后的渲染流程了(分别用 C++ 和 C# 写了简单的架构),所以逻辑并没有教程里的渲染单一三角形那么直观。我想,设备创建之后,也就是初始化顶点数据-写入-绘制而已啊,不到20行的代码怎么就出问题了呢?此时我是比较整体逻辑的,而不是一句句照搬;逻辑上等价就是正确。折腾了一晚上没发现是什么导致的。于是决定返回尝试和教程亲和度最高的 C++。可是,C++ 这边也是什么都出不来。是我的 DirectX 问题吗?新建一个代码文件,粘贴教程内容,编译(CLion 使用 CMake,对构建选项的控制比 Visual Studio 方便得多,增加一个 target,只编译这个就行),运行,嘿,不是 DX 的问题。那么是什么呢?抓狂后迫不得已,一行行对比和教程中的区别。明明消隐(culling)都是关了的。最后发现,我启用了模板缓冲:

1
presentationParameters.EnableAutoDepthStencil = TRUE;

注释掉(默认 ZeroMemory() 了所以是 FALSE)就好了。然而并不知道为什么会出错……或者我是没有去看模板函数和模板判定……


C++ 那边搞定,终于回到 C# 这边了。按照 C++ 那边调好,还是没有绘制。

偶然之间,发现如果注释掉设置变换矩阵(视图、投影、世界)的语句,依稀能看到点三角形的样子——绘制的范围在 XOY 平面 [-3, 3] 范围,所以能看部分并分辨出来。

最终,将问题发生点锁定在数行代码之内:

1
2
3
4
5
6
7
8
Matrix matrix;
matrix = Matrix.LookAtLH(new Vector3(0, 0, 10), new Vector3(0, 0, 0), new Vector3(0, 1, 0));
target.Device.SetTransform(TransformState.View, matrix);
var size = _manager.Control.Size;
// matrix = Matrix.OrthoLH(size.Width, size.Height, -100, 100);
matrix = Matrix.PerspectiveFovLH(MathUtil.DegreesToRadians(45), (float)size.Width / size.Height, 0f, 100f);
target.Device.SetTransform(TransformState.Projection, matrix);
target.Device.SetTransform(TransformState.World, Matrix.Identity);

看到这里,由于 Control.Size 是不会出错的,所以不得不怀疑是 Matrix 的问题,怀疑一个流行库的底层函数的实现有问题!

然后发现,视图和投影共同起作用,无法分离。如果使用正交投影(如注释了的代码)而不是透视投影,能正常显示。

设断点单步调试,发现投影矩阵居然是这样的(宽度 800px,高度 600px):

1
2
3
4
[NaN 0   0 0
0 NaN 0 0
NaN NaN 1 1
0 0 0 0]

这个矩阵在同样的条件下,C++ 那边的值是正常的:

1
2
3
4
[1.72751749 0          0 0
0 2.41421342 0 0
0 0 1 1
0 0 -0 1]

其他两个矩阵第一眼看上去正常。嗯,如果给投影设置一个无效的矩阵,自然就画不出东西了。那么为什么会出错呢?

首先来看看透视投影矩阵。以下的矩阵以 Direct3D、左手系的为准,OpenGL 的和/或右手系会有所不同

第一种形式是这样的:

关键参数是视场(field of view,FOV,FOV=2θ)、宽高比(aspect ratio)、近剪裁面和远剪裁面距离4个参数。

第二种形式是这样的:

关键参数是矩形的左右上下(LRTB)和远近剪裁面距离6个参数。不过一般投影面是居中的(原点在正中),所以一般会取 L=-R=0.5WT=-B=0.5H 的值。

然后我们看看 SharpDX 对 Matrix.PerspectiveFovLH()实现。我将关键代码列出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void PerspectiveFovLH(float fov, float aspect, float znear, float zfar, out Matrix result)
{
float yScale = (float)(1.0 / Math.Tan(fov * 0.5f));
float xScale = yScale / aspect;

float halfWidth = znear / xScale;
float halfHeight = znear / yScale;

PerspectiveOffCenterLH(-halfWidth, halfWidth, -halfHeight, halfHeight, znear, zfar, out result);
}

public static void PerspectiveOffCenterLH(float left, float right, float bottom, float top, float znear, float zfar, out Matrix result)
{
float zRange = zfar / (zfar - znear);

result = new Matrix();
result.M11 = 2.0f * znear / (right - left);
result.M22 = 2.0f * znear / (top - bottom);
result.M31 = (left + right) / (left - right);
result.M32 = (top + bottom) / (bottom - top);
result.M33 = zRange;
result.M34 = 1.0f;
result.M43 = -znear * zRange;
}

可以看到,Matrix.PerspectiveFovLH() 用 FOV、宽高比和近剪裁面距离确定了一个四棱锥的底面(矩形),以此作为目标投影矩形的大小,然后传给 Matrix.PerspectiveOffCenterLH() 计算矩阵。然后注意到我传的参数,近剪裁面距离为零——意味着直接计算的话,目标投影矩形是一个零宽度零高度的矩形!然后接下来就是被零除的问题,返回的自然是 NaN

看来我怀疑对了。

那么近剪裁面距离为0是否是一个可取的值呢?有意义,可以取到。考虑到我们绘制的实际上是一个平截头体(frustum)所交的内容,其极限是一个四棱锥,意义就是从观察点到远剪裁面中由 FOV 和宽高比确定的矩形所形成的四棱锥中的所有内容都会被考虑。谁说“眼前”一定要大于某个值呢?更重要的是,Direct3D 对应的函数(D3DXMatrixPerspectiveFovLH())很好地处理了0值——同样的参数,在 C++/DirectX 中能正确渲染。寻找一下理论基础,那就是——在矩阵计算形式一中,根本不需要计算宽高。形式一在给定了 FOV 和宽高比时,比形式二更通用;形式二向形式一的转换必须在近剪裁面距离不为零时才能以成立。也就是说,SharpDX 的开发者可能并没有测试这种特殊情况,其他用 SharpDX 写各种 DirectX 程序的人也至少是规避了这个问题(可能是效率影响?),而不巧我撞上了。

所以最后,提出了一个 issue,紧接着提了一个 pull request。PR 的提交自动构建失败是强签名的问题——不过代码本身没错,更改也不影响其他文件——人工查看日志的话应该会明白的。


有一个很有意思的现象,C++ 的 IDE 我选择的是 CLion,编译基于 Cygwin。Direct3D 应用程序的绘制逻辑一般是空闲时渲染的:

1
2
3
4
5
6
7
8
9
10
MSG msg;
while (TRUE) {
if (PeekMessage(&msg, hWnd, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
} else {
// Idle
Render();
}
}

在这种逻辑下,测试空白的渲染(只有 Clear()),在我的显卡上如果不进行垂直同步(也就是立即渲染),那么原生 D3D 的帧率在2800左右,而 SharpDX 的“改良版”循环(RenderLoop)帧率在6200左右。这刷新了我的认知——而且是在图形渲染上。

我以为是 Cygwin 的 API 包装层引起的效率损失,然后费了好大劲换到 MinGW 上(主要是 CLion 对 MinGW 版本和环境要求很严格),还是差不多的数值。

难道是 SharpDX 的黑科技?

分享到 评论