Vulkan 只是屏幕坐标系和别人不一样。世界空间,view 空间的手性是用户定义的,想怎么做都行,只要最终保证传入 Vulkan 的 NDC 坐标的手性是对的。也就是说,你可以在计算投影矩阵的时候,注意要转换手性。
如果没有推导过投影矩阵,就可能不会理解到世界空间,view 空间的手性和 NDC 的手性可以毫无关系——毕竟你不知道有矩阵可以转换手性。
推导透视矩阵时可能遇到的困难
物理过程
首先要知道变换过程是怎么样的
物理量的定义
不同的文章对某一个物理量的定义可能不同,但是他们又使用了相同的符号,结果就导致最终的表达式可能差了一个负号
比如 games101 的推导中,视图空间是右手系,近平面的坐标为 n,远平面的坐标为 f。这说明 n,f 都是负数
但是在 LearnOpenGL 推荐的贴子 https://www.songho.ca/opengl/gl_projectionmatrix.html 中,他视图空间是右手系,但定义 n,f 为正,那么近平面的坐标为 -n,远平面的坐标为 -f
深度的约定
创建 vk::PipelineDepthStencilStateCreateInfo
时,我们一般会传入 vk::CompareOp::eLessOrEqual
到 depthCompareOp
,表示传入的深度比存储的深度更小时,就通过了测试,写入新的深度。这就表明近处的物体会遮挡远处的物体,符合了 z 的正值越大表示深度越深的习惯。
用户可以定义用 z 的正值越大表示深度越深,或者是负值越大表示深度越深,也就是说,用户可以定义用 z 轴垂于屏幕朝内或者朝外来表示深度的正半轴
理论上来说,你不知道 NDC 的 z 轴朝向,你单单讨论屏幕空间的 x 和 y 之间的关系,你是没办法说 NDC 空间的手性的手性如何
于是当我们看到某些帖子在说 Vulkan 与 OpenGL 的区别在于 y 轴反了,那是默认 z 轴垂于屏幕朝内表示深度的正半轴
NDC 深度范围与 z 轴反转
某些细节会影响结果,但是一般的教程不会强调这些细节,因为他们默认你都知道。比如假设近平面的坐标为 n,远平面的坐标为 f 都是负数,那么他要转换到 [-1, 1],是 n 对应 -1 还是 f 对应 -1?或者是转换到 [0, 1]?都有可能。
变换到 clip 空间中的坐标的齐次坐标 w 的符号
在推导挤压平截头体的矩阵的时候,齐次坐标的位置可以用 z 或者 -z,都不影响结果,但是结果矩阵会所有元素差一个负号。最终因为要做透视除法,所以每个元素多出来的一个负号和 w 的负号相抵,所以不会导致变换到裁剪空间的结果不同。
但是这会导致公式中的符号不同,所以可能令人困惑
为什么 OpenGL 中相机在 eye 空间中看向 -z 轴
OpenGL 推导透视矩阵时,相机在 eye 空间中是看向 -z 轴,所以视锥体的近平面坐标和远平面坐标都是负数,这是推导透视矩阵公式的基础。那么为什么是看向 -z 轴而不是 +z 轴呢?
glm 默认的 lookAt
调用的是 glm::lookAtRH
OpenGL 中的构建 view 矩阵的堆栈(来自 https://learnopengl.com/)
1 | glm::mat4 view = camera.GetViewMatrix(); |
1 | glm::mat4 GetViewMatrix() |
1 | void updateCameraVectors() |
于是我们知道了,传入 lookAtRH
的期望是摄像机看向 Front
的指向
但是为什么反而在 eye 空间中却看向 -z 轴呢?按道理来说,乘以 view 矩阵之后,整个世界都被转到相机面向物体的坐标系中了呀?现在你推导透视矩阵时反而认为相机背向物体?
所以这个事情还是要看 lookAtRH
是怎么做的
1 | template<typename T, qualifier Q> |
可以看出,其实返回的 view 空间的 z 轴是 -front
但是明明 front 是指向物体的,所以 view 变换之后,摄像机就背向了物体,摄像机的 -z 指向物体
所以这就说明了 lookAt 矩阵表示的 z 轴他不一定是传入的 front
lookAt 怎么与透视矩阵对应
我不止一次看到别人推荐这个网站 https://www.songho.ca/opengl/gl_projectionmatrix.html,然后引用这句话
Note that the eye coordinates are defined in the right-handed coordinate system, but NDC uses the left-handed coordinate system. That is, the camera at the origin is looking along -Z axis in eye space, but it is looking along +Z axis in NDC.
他的意思似乎是,因为 NDC 是左手系,view 空间(eye 空间)是右手系,所以 x 和 y 轴不变的话,就可以认为两者的 z 轴是反的。那么假设视锥体都在一个固定的 NDC 正 z 的地方,那么我在 NDC 中需要看向这个视锥体,所以我在 view 空间中才需要让我的摄像机看向 -z 方向而不是 +z
当然这是一个倒因为果……并不是因为透视矩阵是这样,视图矩阵才是这样
而是因为首先你知道了,经过 lookAt 之后,摄像机看向了 -z,所以透视矩阵中注意对 z 反转,使得 NDC 中摄像机看向 +z
网上大部分教程以及评论都默认你右手系并且用的是 glm::lookAt
,这确保了摄像机在 eye 空间中却看向 -z 轴
原则上你用什么 lookAt 都可以,但是你对应的投影矩阵的公式也要跟着变
现在大家用的都是 glm 的 lookAt,所以我觉得还是入乡随俗
从 Games101 的公式开始
先假设世界空间,视图空间是右手系
视图空间中有一个正交长方体的左平面的坐标为 l,右平面的坐标为 r,上平面的坐标为 t,下平面的坐标为 b,近平面的坐标为 n,远平面的坐标为 f
现在希望转成一个 NDC 标准坐标
那么 games101 是
注意,这个公式把 [f, n] 转成 [-1, 1] 的,那么原来是 f 比 n 小,现在也是 -1 比 1 小,所以没有改变手性
frustum 的关系式是
因此设计一个 frustum 挤压成正交长方体的矩阵,使得变换出来的 的部分与这个关系式对应。对应的方法就是使得齐次坐标 的位置放关系式的分母,也就是
为了求解未知数,有两个关系,一个是近平面上的点在压缩时不变,另一个是远平面的中心点在压缩时不变
单独看“近平面上的点在压缩时不变”,这使得
对任意 x,y 成立
那么可以证出 都是 0,因为 与 无关
那么取近平面的中心点不变和远平面的中心点不变,得到两个式子
即
解得
最终结果
两者相乘可以得到
进行这个投影变换之后,原来是右手坐标的视图空间变为右手坐标的裁剪空间
考虑 OpenGL
OpenGL 的公式中要求 n,f 都是距离,所以都是正值
那么近平面是 -n,远平面是 -f
又要求变换之后是左手系,也就是 [-n, -f] 变换到 [-1, 1]
那么正交投影矩阵
frustum 的关系式是
因为这里的 z 是负值,所以要加负号表示距离
设计压缩矩阵
那么取近平面的中心点不变和远平面的中心点不变,得到两个式子
即
解得
最终结果
两者相乘可以得到
但是还是和 OpenGL 的公式搭不上
观察发现我自己推出来的矩阵乘以 -1 就是 OpenGL 的公式
于是说……这两个矩阵的结果会是一样的吗
之后看了 https://www.zhyingkun.com/perspective/perspective/
才确认了别人也遇到了这个问题,并且他们会是一样的
重新推一下,frustum 的关系式是
设计压缩矩阵
那么取近平面的中心点不变和远平面的中心点不变,得到两个式子
即
解得
最终结果
That’s all. 可以看到投影矩阵和之前的差在乘以一个负号,最终算出来的就是 OpenGL 的公式了。
glm 的函数
1 | template<typename T> |
Matlab 代入我推的公式
1 | syms zNear zFar width height fovy aspect; |
得到的结果一样
这就是所谓的齐次坐标里面放 z 还是 -z 会导致的公式的不同
这一点不会导致结果不同
用于 Vulkan 的透视矩阵
视图矩阵是右手系,正交长方体的左平面的坐标为 l,右平面的坐标为 r,上平面的坐标为 t,下平面的坐标为 b,近平面的坐标为 -n,远平面的坐标为 -f。转换之后 NDC 坐标还是右手系。
n, f 为正
[b, t] 映射到 [1, -1], [-n, -f] 映射到 [0, 1]
沿用之前推 OpenGL 推出来的压缩矩阵
Matlab 代入
1 | syms zNear zFar width height fovy aspect; |
Matlab 结果
得到的结果与 perspectiveRH_ZO
确实仅仅在 [1][1] 差一个负号
1 | template<typename T> |
但是如果仅仅就这么用了
1 | glm::vec3 forward = transfrom_comp_ptr->rotation * glm::vec3(0.0f, 0.0f, 1.0f); |
还会有 x 轴反转的问题
这个确实……有点难以思考原因。我觉得可能还是因为反转了 z 轴的问题。
于是最终还是自己抄了一个透视矩阵,其中与 glm::perspectiveRH_ZO
的区别就是反转了 x 轴,然后用 viewport 负高度,front 设置为 vk::FrontFace::eClockwise
1 | static glm::mat4 perspective_vk(float fovy, float aspect, float zNear, float zFar) |
这样是可以 work,也可以保证用的是 glm 的 view 空间,也是基于 glm 的透视矩阵改的,我觉得还 ok
别人也会有类似的 x 轴翻转的问题
https://stackoverflow.com/questions/65049297/perspective-projection-inverting-x-axis-glmperspective
https://stackoverflow.com/questions/78557339/glmlookat-image-is-visually-flipped-both-x-and-y-axis
但是我脑子有限不知道怎么办