从视锥裁剪到屏幕尺寸估算
最近在阅读一段点云 LOD 相关代码时,遇到了下面这个方法:
CameraEvaluationContext makeCameraEvaluationContext(const Eigen::Matrix4f& viewMatrix,const Eigen::Matrix4f& projectionMatrix,int screenHeight,bool parallelProjection,float parallelScale)
{CameraEvaluationContext context;context.viewMatrix = viewMatrix;context.projectionMatrix = projectionMatrix;context.viewProjectionMatrix = projectionMatrix * viewMatrix;context.screenHeight = screenHeight;context.parallelProjection = parallelProjection;context.parallelScale = parallelScale;context.perspectiveScale =static_cast<float>(screenHeight) *projectionMatrix(1, 1) *0.5f;const Eigen::Vector4f row0 =context.viewProjectionMatrix.row(0);const Eigen::Vector4f row1 =context.viewProjectionMatrix.row(1);const Eigen::Vector4f row3 =context.viewProjectionMatrix.row(3);const auto makePlane =[](const Eigen::Vector4f& value){return FrustumPlane{value.x(),value.y(),value.z(),value.w()};};context.sidePlanes[0] = makePlane(row3 + row0);context.sidePlanes[1] = makePlane(row3 - row0);context.sidePlanes[2] = makePlane(row3 + row1);context.sidePlanes[3] = makePlane(row3 - row1);for (FrustumPlane& plane : context.sidePlanes) {plane.normalize();}Eigen::Vector4f cameraPosition =viewMatrix.inverse() *Eigen::Vector4f(0, 0, 0, 1);if (std::abs(cameraPosition.w()) > 1.0e-6f) {cameraPosition /= cameraPosition.w();}context.cameraPosition = CCVector3(cameraPosition.x(),cameraPosition.y(),cameraPosition.z());return context;
}
一开始看上去有很多矩阵、平面和投影计算,但这个方法实际只是在提前准备后续 LOD 判断需要的数据。
它主要完成四件事:
- 保存相机矩阵;
- 计算世界尺寸到屏幕像素尺寸的换算比例;
- 提取视锥的左、右、上、下四个平面;
- 计算相机在世界坐标系中的位置。
一、从世界坐标到屏幕
一个世界坐标点通常会经历下面的变换:
世界坐标↓ viewMatrix
相机坐标↓ projectionMatrix
裁剪空间↓ 除以 w
NDC 坐标↓ viewport 映射
屏幕像素坐标
对应代码:
Eigen::Vector4f clipPoint =projectionMatrix *viewMatrix *worldPoint;
也可以提前组合成一个矩阵:
viewProjectionMatrix =projectionMatrix * viewMatrix;
然后:
clipPoint =viewProjectionMatrix * worldPoint;
这里得到的 clipPoint 还不是最终屏幕坐标。
它包含四个分量:
clip.x
clip.y
clip.z
clip.w
需要进行透视除法:
ndc.x = clip.x / clip.w;
ndc.y = clip.y / clip.w;
ndc.z = clip.z / clip.w;
得到 NDC 坐标。
NDC 的 X、Y 范围通常是:
-1 到 +1
最后再映射到屏幕像素坐标。
screenX =(ndc.x * 0.5f + 0.5f) * viewportWidth;screenY =(ndc.y * 0.5f + 0.5f) * viewportHeight;
其中 0.5f 的来源很简单:
NDC 范围长度为 2:[-1, 1]
屏幕范围长度为 viewportSize
因此 NDC 中长度 1 对应:
viewportSize / 2
也就是:
viewportSize * 0.5f

二、perspectiveScale 是什么
代码中有这样一行:
context.perspectiveScale =static_cast<float>(screenHeight) *projectionMatrix(1, 1) *0.5f;
它可以理解成:
相机和 Viewport 共同决定的垂直像素缩放基准。
透视投影下,一个世界空间尺寸 worldSize 在屏幕上大约占多少像素,可以用下面的公式估算:
pixelSize =worldSize *perspectiveScale /depth;
其中:
worldSize 物体在世界坐标中的大小
perspectiveScale 相机和屏幕决定的缩放系数
depth 物体在相机前方的深度
pixelSize 最终占用的像素大小
这个公式表达的是最基本的“近大远小”:
物体越大,屏幕尺寸越大
物体越近,屏幕尺寸越大
物体越远,屏幕尺寸越小
为什么使用 projectionMatrix(1, 1)
标准透视投影矩阵中:
projectionMatrix(1, 1)
对应垂直方向的投影缩放值,通常近似为:
1 / tan(fovY / 2)
垂直视场角越小,物体看起来越大。
垂直视场角越大,物体看起来越小。
为什么乘 screenHeight
投影矩阵得到的是 NDC 范围,最终还需要换算成实际屏幕像素。
因为这里使用的是垂直方向投影,所以搭配:
screenHeight
如果计算水平方向,则对应使用:
screenWidth * projectionMatrix(0, 0) * 0.5f
为什么乘 0.5f
因为 NDC 的垂直范围是:
-1 到 +1
总长度为 2。
屏幕高度为:
screenHeight
所以 NDC 中长度 1 对应:
screenHeight / 2
即:
screenHeight * 0.5f
一个简单例子
假设:
perspectiveScale = 1000
worldSize = 0.1
depth = 10
那么:
pixelSize = 0.1 × 1000 / 10= 10 像素
如果深度变成 20:
pixelSize = 0.1 × 1000 / 20= 5 像素
距离加倍,屏幕尺寸减半。

三、为什么使用相机空间深度
公式中最好使用相机空间中的 Z 深度:
Eigen::Vector4f positionView =viewMatrix *Eigen::Vector4f(position.x,position.y,position.z,1.0f);float depth = -positionView.z();
OpenGL 相机一般朝向 -Z,所以相机前方的点通常满足:
positionView.z < 0
为了得到正数深度,使用:
depth = -positionView.z();
实际代码还应避免深度接近零:
float depth =std::max(-positionView.z(), 1.0e-4f);
不建议直接使用点到相机的欧氏距离:
distance(position, cameraPosition)
因为透视投影真正使用的是相机前向方向上的深度,而不是空间直线距离。
四、从矩阵中提取视锥平面
代码中:
const Eigen::Vector4f row0 =viewProjectionMatrix.row(0);const Eigen::Vector4f row1 =viewProjectionMatrix.row(1);const Eigen::Vector4f row3 =viewProjectionMatrix.row(3);
然后:
row3 + row0
row3 - row0
row3 + row1
row3 - row1
分别得到:
左平面
右平面
下平面
上平面
为什么 row0 表示左右
矩阵与列向量相乘时:
clip = matrix * point;
结果为:
clip.x = row0 · point
clip.y = row1 · point
clip.z = row2 · point
clip.w = row3 · point
因此:
row0 负责计算最终的 X
row1 负责计算最终的 Y
row3 负责计算最终的 W
屏幕中的 X 表示左右,Y 表示上下,所以:
row0 对应左右
row1 对应上下
这里要注意:
OpenGL 和 Eigen 默认可以使用列主序存储,但“矩阵按列存储”和“输出分量由矩阵的行参与计算”并不冲突。
存储方式和数学运算方式是两件事。
五、为什么是 row3 ± row0
裁剪空间中,一个点在水平方向可见,需要满足:
-clip.w <= clip.x <= clip.w
左边界条件:
clip.x >= -clip.w
移项:
clip.x + clip.w >= 0
又因为:
clip.x = row0 · point
clip.w = row3 · point
所以:
(row0 + row3) · point >= 0
因此左平面是:
row3 + row0
右边界条件:
clip.x <= clip.w
移项:
clip.w - clip.x >= 0
所以右平面是:
row3 - row0
上下方向同理:
row3 + row1 // 下平面
row3 - row1 // 上平面
最终:
context.sidePlanes[0] = makePlane(row3 + row0);
context.sidePlanes[1] = makePlane(row3 - row0);
context.sidePlanes[2] = makePlane(row3 + row1);
context.sidePlanes[3] = makePlane(row3 - row1);
六、平面是什么
一个三维平面可以写成:
a*x + b*y + c*z + d = 0
所以一个平面可以用四个参数表示:
(a, b, c, d)
其中:
(a, b, c)
是平面的法向量。
对于一个点:
P = (x, y, z)
计算:
value =a * x +b * y +c * z +d;
如果平面已经归一化,那么这个值可以理解成点到平面的有符号距离:
value > 0 位于平面内侧
value = 0 位于平面上
value < 0 位于平面外侧
因此代码会对平面进行归一化:
for (FrustumPlane& plane : context.sidePlanes) {plane.normalize();
}
归一化指的是让法向量长度变成 1。
七、点积为什么能得到投影距离
两个向量做点积:
dot(v, n)
如果 n 是单位向量,那么结果就是:
向量
v在方向n上的有符号投影长度。
例如:
v = (3, 4)
n = (1, 0)
n 是 X 方向的单位向量:
dot(v, n)
= 3 × 1 + 4 × 0
= 3
所以 v 在 X 方向上的投影长度是 3。
在平面计算中:
dot(normal, point) + d
当 normal 已归一化时,结果就是点到平面的有符号距离。
八、AABB 可见性判断
后续代码使用视锥平面判断节点包围盒是否可见:
bool isNodeVisibleWithContext(const CCVector3& minPoint,const CCVector3& maxPoint,const CameraEvaluationContext& context)
{const CCVector3 center =(minPoint + maxPoint) * 0.5f;const CCVector3 extent =(maxPoint - minPoint) * 0.5f;for (const FrustumPlane& plane : context.sidePlanes) {const float projectedRadius =std::abs(plane.a * extent.x) +std::abs(plane.b * extent.y) +std::abs(plane.c * extent.z);if (plane.distance(center) < -projectedRadius) {return false;}}return true;
}
这里不能只判断包围盒中心是否在平面外,因为包围盒有体积。
假设:
d = 包围盒中心到平面的有符号距离
r = 包围盒沿平面法线方向的投影半径
包围盒在法线方向上的范围是:
[d - r, d + r]
要确定整个包围盒都在平面外侧,需要满足:
d + r < 0
移项后:
d < -r
所以代码是:
if (plane.distance(center) < -projectedRadius) {return false;
}
三种情况可以概括为:
d < -r
包围盒完全在外侧,可以裁剪-r <= d <= r
包围盒与平面相交,需要保留d > r
包围盒完全在内侧,需要保留
如果包围盒缩小成一个点:
r = 0
判断就退化成:
d < 0
也就是普通的点和平面判断。
九、相机世界坐标是怎么得到的
在相机坐标系中,相机自身位于:
(0, 0, 0, 1)
viewMatrix 的作用是:
世界坐标 → 相机坐标
因此使用其逆矩阵:
viewMatrix.inverse()
就可以将相机坐标原点变回世界坐标:
Eigen::Vector4f cameraPosition =viewMatrix.inverse() *Eigen::Vector4f(0, 0, 0, 1);
如果齐次坐标 w 不为 1,还需要除以 w:
cameraPosition /= cameraPosition.w();
最终得到相机在世界坐标系中的位置。
十、这个 Context 最终用在哪里
makeCameraEvaluationContext() 本身不负责加载节点,也不负责绘制点云。
它只是提前准备这些数据:
viewMatrix
projectionMatrix
viewProjectionMatrix
perspectiveScale
视锥四个侧平面
相机世界坐标
投影类型和屏幕高度
后续可以用于:
判断节点是否在相机视野中
isNodeVisibleWithContext(...)
估算节点屏幕尺寸
pixelSize =worldSize *context.perspectiveScale /depth;
决定是否细化 LOD
例如:
if (pixelSize > lodThreshold) {// 尝试显示更深层子节点
}
设置点云点大小
pointSize =spacing *context.perspectiveScale /depth;
总结
这个方法可以简单理解为:
把当前相机状态整理成一份
“节点可见性和 LOD 评估工具包”
其中最重要的两个部分是:
sidePlanes
判断节点是否位于相机视野中perspectiveScale
估算世界尺寸在屏幕上对应多少像素
最终屏幕尺寸估算公式是:
pixelSize =worldSize *perspectiveScale /depth;
其中:
perspectiveScale =screenHeight *projectionMatrix(1, 1) *0.5f;
可以记成:
屏幕高度
× 相机垂直缩放
÷ NDC 总高度 2
这套计算的最终目的,是让点云系统能够根据相机距离和屏幕占用大小,决定一个节点是否需要绘制,以及是否需要继续加载更深层的 LOD。