本文章演示Demo
已上传Github
:CameraProjectionMatix
3D渲染流水线中,物体某一个点从三维空间中映射到二维的屏幕上,通常使用MVP
变换矩阵,而这三个字母分别代指不同坐标空间转换的三个矩阵,即:
Model
):从本地空间转换到世界空间View
):世界空间转换到相机空间Projection
):相机空间转换到规则观察体在之前的文章中,有对相机不同坐标空间的转换矩阵做过具体的描述,其中关于物体本地坐标转换到世界坐标的介绍,同样适用于世界空间转换为相机空间,如果感兴趣,可以查看该链接:
在前篇文章的基础上,本文章会介绍到渲染过程中最重要的P变换,即相机空间转换到规则观察体的过程(也可以理解为将相机的透视空间转换到正交空间),过程如图(图片来源于网络)所示:
关于投影矩阵推导有很多大佬在理论上做过的详细描述,不过通常是面向图形学的公式解析,比较难懂的同时对于工程项目上的应用提及较少。为了可以简单的理解投影的变换过程并可以实际应用,本文章会基于Unity
引擎做一个详细的变换可视化,尽可能简单的拆解整个过程
1、相机透视投影概念:
在绘画理论中,用透视来表示平面或曲面上描绘物体的空间关系的方法或技术,通常来讲,在平面上在线空间感、立体感会通过三个属性来表示:
以上信息来自百度百科对于透视的解释。简单的理解就是,在物体的透视形上,由于眼睛张角的存在,使得我们观察物体是总会有近大远小的感受,长此以往,由于经验的逐渐积累,这种现象间接的帮助人类完成的立体空间的构造
回到游戏引擎,没有什么是比模拟人眼成像更好的方式的,为了可以让计算机正确模拟人类感官的渲染出透视画面,相机在透视模式下,通过具有张角的锥形标识其取景范围,而且通常该锥形的顶部的尖会被消除,被称为视锥
虽然使用视锥的概念可以很好的解决透视的问题,但是对其后续计算带来了一定的问题。简单的来说,一个矩形可以通过中心点与其各边的长度非常方便的划分其空间范围,同时压缩某一轴达到三维到二维投影的目的。但是视锥是一个不同轴方向的长度不等的锥体,很难对范围做出界定
既然难以直接对视锥做空间判定,优秀的程序员或数学家就想到了将视锥规则化的数学变换公式,并以矩阵的形式参与运算,这就是相机的透视投影矩阵
2、通过Gizmos可视化相机视锥空间:
与相机的正交投影模式不同,透视投影为了得到近大远小的画面,会根据深度信息做平切面的画面缩放,而这些平切面连续组合起来就组成了相机的视锥空间,可以通过Unity
提供的绘制工具Gizmos
来表示出相机的渲染空间范围,绘制代码为:
public void OnDrawGizmos()
{
//相机投影矩阵绘制
Matrix4x4 start = Gizmos.matrix;
Gizmos.matrix = Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one);
Gizmos.color = Color.yellow;
Gizmos.DrawFrustum(Vector3.zero, cam.fieldOfView, cam.farClipPlane, 0, cam.aspect);
Gizmos.color = Color.red;
Gizmos.DrawFrustum(Vector3.zero, cam.fieldOfView, cam.farClipPlane, cam.nearClipPlane, cam.aspect);
Gizmos.matrix = start;
//坐标辅助线绘制
Gizmos.color = Color.red;
Gizmos.DrawLine(cam.transform.position, cam.transform.position + cam.transform.right * 10);
Gizmos.color = Color.green;
Gizmos.DrawLine(cam.transform.position, cam.transform.position + cam.transform.up * 10);
Gizmos.color = Color.blue;
Gizmos.DrawLine(cam.transform.position, cam.transform.position + cam.transform.forward * 10);
}
在编辑完成上面的代码后,可以在Unity
引擎的Scene
窗口可以看到下图中的辅助线,其中红框划定的立体图形即为相机投影视锥:
通过视锥可以划定出相机的渲染空间,但是如何通过数学方式表示以便于机器理解与运算呢,要想得到结论,需要先明确条件。在开始推到前需要得到所拥有的信息条件,查阅Unity
官方文档,可以得到与相机视角有关的参数信息:
Near Clip Planes
:近裁剪平面,代表物体将要渲染的最近位置Far Clip Planes
:远裁剪平面,代表物体将要渲染的最远位置FOV
:相机张角代表视野的宽高比例FOV
1、标识投影视锥八个顶点:
要标识一个空间的范围,通常会利用线段组合成对应形状的线框,例用模型的网格。而要决定线段的长度位置,就需要先得到顶点。所以我们第一步会基于上面的相机的一些基本参数来计算处相机视锥对应的八个顶点,来为后续的CVV
空间划定获取做基础
为了简化推导过程,将三维空间问题映射到二维来考虑。以Y轴为法线,相机视锥的形状如下图所示,以X轴与Z轴构成的二维视角下的相机视锥空间范围标识线,并标识一些关键点的代名词,根据前面的相机关键参数,可以得到以下的已知信息:
nearClipPlane
farClipPlane
FOV
(horiziontal
或vertical
方向)的一半对应弧度以D点的坐标获取为例,通过上面的图中所构造的三角形,已知AB的长度为近裁剪平面距离相机的长度,角BAD的度数为相机FOV
的一半,利用三角函数可以计算出BD的长度,这样就得到了D点在Z轴与X轴的坐标长度AB与BD
至于Z轴的长度,对应相机在同样可以通过近裁剪平面的长度与相机在另外一个轴方向的FOV
(可以使用Camera
的静态方法VerticalToHorizontalFieldOfView
求得)的数值
循环上面的求值过程,得到相机视锥八个顶点的坐标,并在对应位置分别放置一个物体来实例化这些坐标点,代码如下:
List<Vector3> GetPosLocation()
{
List<Vector3> backList=new List<Vector3>();
for (int z = 0; z < 2; z++)
{
for (int i = -1; i < 2; i += 2)
{
for (int j = -1; j < 2; j += 2)
{
Vector3 pos = GetPos(z, new Vector2Int(i, j) , cam);
backList.Add(pos);
}
}
}
return backList;
}
Vector3 GetPos(int lenType,Vector2Int dirType,Camera cam)
{
Vector3 cPos = cam.transform.position;
//根据FOV得到水平与垂直角度一般对应的弧度
float vecAngle = (cam.fieldOfView*Mathf.PI)/360;
float horAngle = Camera.VerticalToHorizontalFieldOfView(cam.fieldOfView, cam.aspect) * Mathf.PI/360;
float zoffset = lenType == 0 ? cam.nearClipPlane : cam.farClipPlane;
float vecOffset = zoffset * Mathf.Tan(vecAngle);
float horOffset = zoffset * Mathf.Tan(horAngle);
Debug.Log(Mathf.Tan(horAngle));
Vector3 offsetV3 = new Vector3(horOffset * dirType.x, vecOffset * dirType.y, zoffset);
return cPos + offsetV3;
}
通过三角函数得到八个顶点的坐标位置后,并实例化Sphere
来标定这些点,具体效果如图所示:
2、通过投影矩阵转换相机空间至CVV:
为了标定经过投影矩阵变换后的视锥范围,利用上面得到相机视锥的八个顶点做参照,对其做MVP
计算,由于本文章直接基于世界坐标的点做空间转换,并没有本地坐标的概念,所以可以忽略物体从本地空间转换到世界空间的过程
跳过M
计算,直接将上面通过三角函数的八个点的坐标与相机的空间转换坐标计算得到其在相机空间下的坐标,如图所示:
通过上图可以看出,当相机处于原点时,得到的计算后的八个顶点与计算之前的八个顶点坐标X
轴与Y
轴坐标相同,但是Z
轴是反的。这是因为Unity
的世界空间与本地空间坐标都是采用左手坐标系,而相机空间是使用相反的右手坐标系
在完成顶点从世界空间到相机空间的转换后,就可以通过相机的投影矩阵视线从相机空间到CVV
的转换,但是注意与投影矩阵计算时实在齐次坐标下做计算,所以在得到CVV
空间的坐标时需要除以Vector4返回值中的W
分量,计算完成后在显示为:
如图所示,经过投影矩阵的乘法计算后,相机视锥内的空间坐标点会被映射到一个空间范围为1的规则观察体中,当然在三维空间内的长度表示不明显,将其映射到二维空间内,其长度如图:
通过投影变换得到CVV
内对应坐标后,只需要抛弃某一个轴就可以将三维空间降维到二维平面内,就可以得到了相机的取景画面。同时为了获取正确遮挡关系的画面,通常以Z轴为基准做画面的绘制处理,即深度缓冲的概念
相机投影矩阵:
通过前面的可视化过程可以看到,将相机空间的视锥转换到CVV
就是相机投影矩阵的意义。反过来说,支撑这一过程的投影矩阵就是这一变换过程中数学公式的集合
根据Unity文档,可以得到相机的投影矩阵,不过要注意矩阵中舍弃了相机FOV
参数,转换为相机近裁剪平面的中心点到四边的距离,这样矩阵的表达稍微清晰明确一些,矩阵表示为:
{ 2 ∗ n e a r / ( r i g h t − l e f t ) 0 ( r i g h t + l e f t ) / ( r i g h t − l e f t ) 0 0 2 ∗ n e a r / ( t o p − b o t t o m ) ( t o p + b o t t o m ) / ( t o p − b o t t o m ) 0 0 0 − ( f a r + n e a r ) / ( f a r − n e a r ) − ( 2 ∗ f a r ∗ n e a r ) / ( f a r − n e a r ) 0 0 − 1 0 } \left\{ \begin{matrix} 2*near/(right-left) & 0 & (right+left)/(right-left) & 0\\ 0 & 2*near/(top-bottom) & (top + bottom) / (top - bottom) & 0\\ 0 & 0 & -(far + near) / (far - near) & -(2 * far * near) / (far - near) \\ 0 &0 &-1 &0 \end{matrix} \right\} ⎩ ⎨ ⎧2∗near/(right−left)00002∗near/(top−bottom)00(right+left)/(right−left)(top+bottom)/(top−bottom)−(far+near)/(far−near)−100−(2∗far∗near)/(far−near)0⎭ ⎬ ⎫
其中各参数意义:
near
:相机近裁剪平面到相机的距离far
:相机远裁剪平面到相机的距离right
: 相机近裁剪平面右边框到中点的距离left
:相机近裁剪平面左边框到中点的距离top
:相机近裁剪平面上边框到中点的距离bottom
:相机近裁剪平面底边框到中点的距离虽然原理至于其具体的推导过程比较容易理解,但是其中的数学公式的表达体现比较复杂,如果非常有兴趣可以阅读该文章:
深入探索透视投影变换
1、利用相机投影矩阵判断某点是否在相机视野内
由于相机视锥的范围不规则性,如果直接使用世界坐标来判断某点是否在相机视野内是比较复杂的。而在前面的投影矩阵理解中,当相机的视锥从世界坐标转换到CVV
后,其边界范围一个已知大小的规则立方体,非常容易的就可以判断出某点是否存在于该空间范围内:
public static bool CheckPointIsInCamera(Vector3 worldPoint, Camera camera)
{
Vector4 projectionPos = camera.projectionMatrix * camera.worldToCameraMatrix * new Vector4(worldPoint.x, worldPoint.y, worldPoint.z, 1);
if (projectionPos.x < -projectionPos.w) return false;
if (projectionPos.x > projectionPos.w) return false;
if (projectionPos.y < -projectionPos.w) return false;
if (projectionPos.y > projectionPos.w) return false;
if (projectionPos.z < -projectionPos.w) return false;
if (projectionPos.z > projectionPos.w) return false;
return true;
}
不过通常对于一个点的判断场景还是比较少的,更多的是对于场景中某个物体做处理。同时为了尽量的减少计算量,会物体的边界范围Bound执行判断,这里就粘贴出一个大佬写的判断物体的Bound
是否与相机视锥有交点的方法:
public static bool CheckBoundIsInCamera(this Bounds bound, Camera camera)
{
System.Func<Vector4, int> ComputeOutCode = (projectionPos) =>
{
int _code = 0;
if (projectionPos.x < -projectionPos.w) _code |= 1;
if (projectionPos.x > projectionPos.w) _code |= 2;
if (projectionPos.y < -projectionPos.w) _code |= 4;
if (projectionPos.y > projectionPos.w) _code |= 8;
if (projectionPos.z < -projectionPos.w) _code |= 16;
if (projectionPos.z > projectionPos.w) _code |= 32;
return _code;
};
Vector4 worldPos = Vector4.one;
int code = 63;
for (int i = -1; i <= 1; i += 2)
{
for (int j = -1; j <= 1; j += 2)
{
for (int k = -1; k <= 1; k += 2)
{
worldPos.x = bound.center.x + i * bound.extents.x;
worldPos.y = bound.center.y + j * bound.extents.y;
worldPos.z = bound.center.z + k * bound.extents.z;
code &= ComputeOutCode(camera.projectionMatrix * camera.worldToCameraMatrix * worldPos);
}
}
}
return code == 0 ? true : false;
}
2、深度缓冲小知识
深度缓冲用于记录每个像素的深度值,,通过深度缓冲区,可以进行深度测试,从而确定像素的遮挡关系,保证渲染正确。当空间中的物体经过MVP计算到CVV空间时,会以Z轴为方向记录距离相机的距离,即为某以像素点的深度
由于空间投影的关系,深度缓冲的精度是非线性的,通常距离相机越近时,深度缓冲的精度越高,可以从下图的示例中看到,世界空间中的某点(以右边的黄球标识)匀速远离相机时,其对应的CVV空间的Z轴变化会越来越来越小: