点云光栅化
在这篇文章中,我们探讨一个在游戏和 3D 引擎中使用了几十年的核心技术:光栅化(Rasterization)。
在 3D 游戏中,我们看到的那些逼真、流畅的实时画面,绝大多数都不是像传统照片那样“拍”出来的,而是通过 3D 引擎“算”出来的。
简单来说,光栅化就是将三维世界中的几何体(比如网格或点云),投影到二维的屏幕图像上,并为像素上色的过程。
想象一下,我们的 3D 场景里有一把椅子。传统的做法是用数百万个细小的三角形(Mesh/网格)来拼凑出这把椅子。但在我们今天的探讨中,我们将使用另一种形式——点云(Point Cloud)。顾名思义,它是由空间中无数个带有颜色的“点”聚集而成的。
无论是三角形还是点云,光栅化的核心思想非常纯粹:
1. 拿来 3D 物体。
2. 把它投影到 2D 的画布(图像)上。
3. 处理遮挡关系(谁在前面谁在后面)。
场景与相机
在写代码之前,我们需要搞清楚参与这场“光栅化游戏”的两位核心玩家。
1. 3D点云(场景)
点云包含两个核心属性:
- 空间坐标 (pc):每个点在 3D 空间中的位置,即
X, Y, Z 坐标。如果一共有n 个点,它的形状就是 (n, 3)。 - 颜色 (pc_color):每个点对应的RGB颜色值。形状同样是 (n, 3)。
2. 相机(观测者)
同样的 3D 场景,用不同的相机、从不同的角度去拍,得到的画面完全不同。相机需要提供以下参数:
- 图像尺寸:高度
H (Height)和宽度W (Width)。 - 焦距(Focal Length,
f ):简单理解为相机的“缩放级别(Zoom)”。分为 X 方向的f_x 和 Y 方向的f_y 。 - 视椎体裁剪面:近平面(Near Plane)和远平面(Far Plane)。
在计算机图形学中,我们只会渲染距离相机在近平面和远平面之间的物体。
如果物体太远(远于 Far Plane),它在画面上可能只占不到1个像素,强行计算会浪费大量算力;如果物体太近(近于 Near Plane),它可能会完全遮挡镜头,甚至在后续涉及到矩阵求逆时,过近的距离会导致奇异值,引发严重的数值不稳定问题。
代码实现
现在,让我们把理论转化为代码。我们将使用 PyTorch 和 NumPy 来实现一个名为 pc_rasterization 的函数。我们会利用向量化操作(Vectorization),同时处理这几百万个点,而不是用缓慢的 for 循环。
- def PCRasterization(PC, PCColor, height, width, fx, fy, cx, cy, near=2e-3, far=100):
- # 1. 形状校验
- assert PC.shape == PCColor.shape, "PC and PCColor must have the same shape"
- # 2. 调用投影函数并解包
- uv, x_cam, y_cam, z_cam = project_points(PC, height, width, fx, fy, cx, cy)
- u = uv[:, 0]
- v = uv[:, 1]
- # 3. 边界与相机系远近深度判定
- valid_mask = (u >= 0) & (u < width) & (v >= 0) & (v < height) & (z_cam >= near) & (z_cam <= far)
- # 4. 过滤出在视口与远近深度平面内的有效点云坐标及颜色
- u_valid = u[valid_mask]
- v_valid = v[valid_mask]
- colors_valid = PCColor[valid_mask]
- # 5. 将浮点像素坐标转换为整数像素索引
- u_rounded = torch.round(u_valid).long()
- v_rounded = torch.round(v_valid).long()
- # 6. 使用 clamp 确保整数索引严格在合法画布范围内
- u_int = torch.clamp(u_rounded, 0, width - 1)
- v_int = torch.clamp(v_rounded, 0, height - 1)
- # 7. 创建画布并进行向量化渲染赋值
- image = torch.zeros((height, width, 3), dtype=torch.float32, device=PC.device)
- image[v_int, u_int] = colors_valid
- return image
掩码过滤 (mask) 是一个非常关键的性能和安全保障步骤。如果物体没有进入镜头,我们就没必要浪费时间去计算它的颜色。
图像是由一个个离散的像素格组成的。如果一个点投影出来的位置是 (0.12, 0.3),我们需要使用 torch.round 将它四舍五入,让它落入 (0, 0) 这个像素格中。
为什么这里不处理深度排序?
我们可能会有疑问,在把颜色赋给像素时,如果有两个点落在了同一个像素上,代码里似乎直接覆盖了,并没有判断谁前谁后(Z-Buffering)?
这是因为我们当前针对的是基础点云。我们假设每个点的大小只有1个像素,并且点云通常有很多空洞。在这种极其微小的粒度下,即使不按深度(Depth)对点进行排序,产生的近似效果也是足够好的。
后续针对的高斯点不再是单像素的点,而是有范围、有重叠的“椭球”。一个高斯可能会覆盖多个像素,这时候按深度排序就变得至关重要。如果排序不对,就会出现后面的物体强行遮挡前面物体的穿帮画面。
我们在后续迭代引入 Gaussian Splatting 代码时,将会把深度排序作为一个极其重要的核心步骤加回来。