ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从视锥裁剪到屏幕尺寸估算

从视锥裁剪到屏幕尺寸估算

从视锥裁剪到屏幕尺寸估算

最近在阅读一段点云 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 判断需要的数据。

它主要完成四件事:

  1. 保存相机矩阵;
  2. 计算世界尺寸到屏幕像素尺寸的换算比例;
  3. 提取视锥的左、右、上、下四个平面;
  4. 计算相机在世界坐标系中的位置。

一、从世界坐标到屏幕

一个世界坐标点通常会经历下面的变换:

世界坐标↓ 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

image


二、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 像素

距离加倍,屏幕尺寸减半。

image


三、为什么使用相机空间深度

公式中最好使用相机空间中的 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。

返回列表