点云光栅化

在这篇文章中,我们探讨一个在游戏和 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 循环。

  1. def PCRasterization(PC, PCColor, height, width, fx, fy, cx, cy, near=2e-3, far=100):
  2.     # 1. 形状校验
  3.     assert PC.shape == PCColor.shape, "PC and PCColor must have the same shape"
  4.  
  5.     # 2. 调用投影函数并解包
  6.     uv, x_cam, y_cam, z_cam = project_points(PC, height, width, fx, fy, cx, cy)
  7.     u = uv[:, 0]
  8.     v = uv[:, 1]
  9.  
  10.     # 3. 边界与相机系远近深度判定
  11.     valid_mask = (u >= 0) & (u < width) & (v >= 0) & (v < height) & (z_cam >= near) & (z_cam <= far)
  12.  
  13.     # 4. 过滤出在视口与远近深度平面内的有效点云坐标及颜色
  14.     u_valid = u[valid_mask]
  15.     v_valid = v[valid_mask]
  16.     colors_valid = PCColor[valid_mask]
  17.  
  18.     # 5. 将浮点像素坐标转换为整数像素索引
  19.     u_rounded = torch.round(u_valid).long()
  20.     v_rounded = torch.round(v_valid).long()
  21.  
  22.     # 6. 使用 clamp 确保整数索引严格在合法画布范围内
  23.     u_int = torch.clamp(u_rounded, 0, width - 1)
  24.     v_int = torch.clamp(v_rounded, 0, height - 1)
  25.  
  26.     # 7. 创建画布并进行向量化渲染赋值
  27.     image = torch.zeros((height, width, 3), dtype=torch.float32, device=PC.device)
  28.     image[v_int, u_int] = colors_valid
  29.  
  30.     return image

掩码过滤 (mask) 是一个非常关键的性能和安全保障步骤。如果物体没有进入镜头,我们就没必要浪费时间去计算它的颜色。

图像是由一个个离散的像素格组成的。如果一个点投影出来的位置是 (0.12, 0.3),我们需要使用 torch.round 将它四舍五入,让它落入 (0, 0) 这个像素格中。


为什么这里不处理深度排序?

我们可能会有疑问,在把颜色赋给像素时,如果有两个点落在了同一个像素上,代码里似乎直接覆盖了,并没有判断谁前谁后(Z-Buffering)?

这是因为我们当前针对的是基础点云。我们假设每个点的大小只有1个像素,并且点云通常有很多空洞。在这种极其微小的粒度下,即使不按深度(Depth)对点进行排序,产生的近似效果也是足够好的。

后续针对的高斯点不再是单像素的点,而是有范围、有重叠的“椭球”。一个高斯可能会覆盖多个像素,这时候按深度排序就变得至关重要。如果排序不对,就会出现后面的物体强行遮挡前面物体的穿帮画面。

我们在后续迭代引入 Gaussian Splatting 代码时,将会把深度排序作为一个极其重要的核心步骤加回来。