加速光线追踪算法优化

在上一篇文章中,我们学习了光线追踪的基本原理:向场景发射光线,并计算它与物体是否相交。听起来很简单,对吧?

但这里隐藏着一个巨大的性能陷阱:假设你的场景中有 1000 万个三角形,需要渲染 1920×1080 分辨率的图像。

  • 朴素做法:每条光线都与这 1000 万个三角形逐一进行求交测试
  • 时间复杂度:O(像素数 × 三角形数) ≈ 2M × 10M = 20 万亿次计算
  • 实际后果:渲染一帧可能需要几天甚至几周

本文基于 GAMES101 Lecture 14 的内容,介绍三种主流的光线追踪加速结构,帮助你理解现代渲染器如何将渲染时间从几天缩短到几秒。

核心思想:如果光线连物体所在的区域都没碰到,那就不需要测试区域内的物体。

一、均匀网格(Uniform Grids)

1.1 基本思想

将整个场景像切蛋糕一样,划分成大小均匀的网格(Voxels),每个格子记录其中包含的物体。

1.2 工作流程

预处理阶段

  1. 计算包围整个场景的轴对齐包围盒(AABB)
  2. 将包围盒均匀划分为 nx × ny × nz 个网格单元
  3. 遍历所有物体,将其注册到与之相交的所有网格中

光线遍历阶段

  1. 光线从起点开始,按照 3D-DDA 算法逐格前进
  2. 每进入一个网格,检查该格是否包含物体
  3. 如果包含,则与格内所有物体进行精确求交测试
  4. 找到最近交点后终止遍历
def traverse_grid(ray, grid):
    """3D-DDA 光线网格遍历"""
    current_cell = get_entry_cell(ray)

    while current_cell in grid:
        if grid[current_cell].has_objects():
            hit = intersect_objects(ray, grid[current_cell].objects)
            if hit:
                return hit

        current_cell = next_cell(ray, current_cell)  # 移动到下一个格子

    return None

1.3 优缺点分析

优点

  • 实现简单,易于理解
  • 当物体均匀分布时(如粒子系统、体积数据),性能优秀
  • 网格访问为 O(1),空间划分规则

缺点

  • “体育场里的茶壶”(Teapot in a Stadium)问题
    • 场景:巨大的空旷体育场中心放一个小茶壶
    • 困境:为了精细表示茶壶需要细分网格 → 光线要遍历成千上万个空格子 → 性能崩溃
    • 根本原因:无法适应物体分布不均匀的场景

均匀网格的局限性

二、空间划分:KD-Tree

2.1 设计动机

为了解决均匀网格的"体育场茶壶"问题,自适应空间划分方法应运而生:

  • 核心思想:根据物体密度动态调整空间划分粒度
  • 策略:物体密集区域细分,空旷区域保持大块

2.2 KD-Tree 原理

KD-Tree(K-Dimensional Tree)是一种二叉空间划分树:

构建过程

  1. 选择一个坐标轴(x/y/z 循环选择)和分割位置
  2. 沿该轴将空间一分为二
  3. 对左右子空间递归执行 1-2 步
  4. 当节点内物体数量 ≤ 阈值时停止分割

节点结构

class KDNode:
    # 内部节点
    axis: int          # 分割轴 (0=x, 1=y, 2=z)
    split_pos: float   # 分割位置
    left: KDNode       # 左子树
    right: KDNode      # 右子树

    # 叶子节点
    objects: List      # 物体列表

KD-Tree 空间划分示意图

2.3 光线遍历算法

def traverse_kdtree(ray, node):
    """递归遍历 KD-Tree"""
    if node.is_leaf():
        return intersect_objects(ray, node.objects)

    # 判断光线在分割平面的哪一侧
    if ray.origin[node.axis] < node.split_pos:
        near, far = node.left, node.right
    else:
        near, far = node.right, node.left

    # 先查近端
    hit = traverse_kdtree(ray, near)
    if hit:
        return hit

    # 如果光线穿过分割平面,查远端
    if ray_crosses_split_plane(ray, node):
        return traverse_kdtree(ray, far)

    return None

2.4 致命缺陷

物体重复存储问题: 虽然 KD-Tree 很好地解决了空间分布不均的问题,但它有两个明显的缺点,导致现代光线追踪(尤其是动态场景)较少使用它。 构建困难:很难确定一个完美的划分位置,且一旦物体移动(动态场景),整棵树很难高效更新。

后果

  • 内存占用增加(最坏情况指数级膨胀)
  • 同一物体可能被多次求交
  • 维护复杂度高

现状:由于这个缺陷,KD-Tree 在光线追踪领域已被 BVH 取代。

KD-Tree 物体重复问题

三、物体划分:BVH(工业界首选)

3.1 设计思想的根本转变

为了解决 KD-Tree 的物体重复存储问题,BVH(Bounding Volume Hierarchy,层次包围盒)采用了完全不同的策略:

维度KD-TreeBVH
划分对象空间物体
策略沿坐标轴切割空间将物体分组打包
节点内容空间区域物体集合的包围盒
物体归属可能属于多个节点仅属于一个节点
包围盒关系严格不相交可能重叠

核心优势:每个物体只出现在一个叶子节点中,彻底避免了重复存储。

3.2 BVH 构建算法

递归构建流程

def build_bvh(objects, max_leaf_size=5):
    """
    递归构建 BVH

    Args:
        objects: 物体列表
        max_leaf_size: 叶子节点最大物体数量

    Returns:
        BVHNode: BVH 树的根节点
    """
    node = BVHNode()

    # 1. 计算包围所有物体的 AABB
    node.bbox = compute_bounding_box(objects)

    # 2. 停止条件:物体数量足够少
    if len(objects) <= max_leaf_size:
        node.objects = objects
        return node

    # 3. 选择分割轴和分割点
    axis, split_pos = choose_split(objects)

    # 4. 将物体分成两组
    left_objects = [obj for obj in objects if obj.centroid[axis] < split_pos]
    right_objects = [obj for obj in objects if obj.centroid[axis] >= split_pos]

    # 5. 递归构建左右子树
    node.left = build_bvh(left_objects, max_leaf_size)
    node.right = build_bvh(right_objects, max_leaf_size)

    return node

分割策略

1. SAH (Surface Area Heuristic) - 表面积启发式

SAH 是最常用的分割策略,其核心思想是:

$$ \text{Cost} = C_{\text{trav}} + \frac{S_L}{S_P} \cdot N_L \cdot C_{\text{isect}} + \frac{S_R}{S_P} \cdot N_R \cdot C_{\text{isect}} $$

其中:

  • $C_{\text{trav}}$:遍历节点的代价
  • $S_P, S_L, S_R$:父节点、左子节点、右子节点的表面积
  • $N_L, N_R$:左右子节点包含的物体数量
  • $C_{\text{isect}}$:求交测试的代价

物理意义:包围盒表面积越大,光线击中的概率越高。

2. 中位数分割

更简单但次优的方法:

  • 选择物体重心跨度最大的轴
  • 按该轴坐标排序,从中位数处分割
def choose_split_median(objects):
    """中位数分割策略"""
    # 计算每个轴的跨度
    centroids = [obj.centroid for obj in objects]
    extents = [max(c[i] for c in centroids) - min(c[i] for c in centroids)
               for i in range(3)]

    # 选择跨度最大的轴
    axis = extents.index(max(extents))

    # 按该轴排序,取中位数
    sorted_objects = sorted(objects, key=lambda obj: obj.centroid[axis])
    split_pos = sorted_objects[len(objects) // 2].centroid[axis]

    return axis, split_pos

BVH 构建过程示意图

3.3 光线遍历算法

BVH 的遍历是一个递归过程,采用深度优先搜索

def intersect_bvh(ray, node, t_min=0.0, t_max=float('inf')):
    """
    光线与 BVH 求交

    Returns:
        hit_record: 最近的交点信息,若无交点则返回 None
    """
    # 1. 包围盒测试:光线是否击中该节点的包围盒
    if not ray_box_intersect(ray, node.bbox, t_min, t_max):
        return None

    # 2. 叶子节点:与其中所有物体求交
    if node.is_leaf():
        closest_hit = None
        closest_t = t_max

        for obj in node.objects:
            hit = ray_object_intersect(ray, obj)
            if hit and t_min < hit.t < closest_t:
                closest_hit = hit
                closest_t = hit.t

        return closest_hit

    # 3. 内部节点:递归遍历左右子树
    hit_left = intersect_bvh(ray, node.left, t_min, t_max)
    hit_right = intersect_bvh(ray, node.right, t_min, t_max)

    # 4. 返回最近的交点
    if hit_left and hit_right:
        return hit_left if hit_left.t < hit_right.t else hit_right
    elif hit_left:
        return hit_left
    else:
        return hit_right

优化技巧

  • 提前终止:找到左子树交点后,可以用该距离更新 t_max,提前剪枝右子树
  • 排序遍历:优先访问距离光线起点更近的子节点

3.4 光线-AABB 求交算法

BVH 的高效依赖于快速的包围盒求交测试:

def ray_box_intersect(ray, bbox, t_min, t_max):
    """
    光线与 AABB 求交(Slab 方法)

    Args:
        ray: 光线 (origin, direction)
        bbox: 包围盒 (min_point, max_point)
        t_min, t_max: 有效距离范围

    Returns:
        bool: 是否相交
    """
    for axis in range(3):
        # 计算光线进入/离开该轴的 slab 的时间
        inv_d = 1.0 / ray.direction[axis]
        t0 = (bbox.min[axis] - ray.origin[axis]) * inv_d
        t1 = (bbox.max[axis] - ray.origin[axis]) * inv_d

        # 处理负方向
        if inv_d < 0:
            t0, t1 = t1, t0

        # 更新有效区间
        t_min = max(t_min, t0)
        t_max = min(t_max, t1)

        # 无交集
        if t_max < t_min:
            return False

    return True

Slab 方法原理

AABB 可以看作三对垂直于坐标轴的平面(slabs)的交集。光线与盒子相交,当且仅当它与所有三对平面的交集非空。

AABB 求交示意图

3.5 BVH 的优势与代价

优势

  1. 无重复存储:每个物体仅存储一次,内存效率高
  2. 构建速度快:O(N log N),远快于 KD-Tree 的 O(N log² N)
  3. 动态场景友好:物体移动时只需更新包围盒,无需重建整棵树
  4. 硬件加速支持:现代 GPU(RTX 系列)直接在硬件层面支持 BVH

代价

  1. 包围盒重叠:兄弟节点的包围盒可能重叠,导致光线需要同时遍历两个子树
  2. 包围盒冗余:物体分布稀疏时,包围盒可能包含大量空白空间

实际表现

  • 对于典型场景,BVH 比朴素求交快 100-1000 倍
  • 渲染 1000 万三角形的场景,从数天降低到数秒

四、三种加速结构对比总结

4.1 综合对比表

维度均匀网格KD-TreeBVH
划分方式固定尺寸网格自适应空间二分物体层次分组
时间复杂度(构建)O(N)O(N log² N)O(N log N)
时间复杂度(查询)O(N) 最坏O(log N) 平均O(log N) 平均
空间复杂度O(N + 网格数)O(N × 重复因子)O(N)
物体重复存储是(严重)
包围盒重叠是(可接受)
适用场景均匀分布场景空间静态、物体简单通用场景
动态场景支持差(需重建)好(局部更新)
硬件加速RTX GPU 原生支持
工业应用体素渲染、粒子系统早期学术研究主流渲染器首选

4.2 选择建议

使用均匀网格

  • 物体均匀分布(如体素化医学数据、流体模拟)
  • 场景尺寸已知且物体密度恒定
  • 实现简单优先

使用 KD-Tree

  • 目前几乎不推荐用于光线追踪
  • 仅在某些点云搜索、最近邻查询场景下使用

使用 BVH(推荐):

  • 三角网格模型渲染
  • 复杂场景(物体分布不均)
  • 动态场景(变形、动画)
  • 需要硬件加速(GPU)
  • 生产环境渲染器

4.3 现代渲染器的实际选择

电影级渲染器

  • Pixar RenderMan: BVH
  • Arnold Renderer: BVH
  • V-Ray: BVH
  • Cycles (Blender): BVH

实时光线追踪

  • NVIDIA OptiX: BVH(硬件加速)
  • DirectX Raytracing (DXR): BVH
  • Vulkan Ray Tracing: BVH

结论:对于初学者和实际应用,深入理解 BVH 的构建和遍历是最重要的,因为它是现代光线追踪的绝对基石。

五、实践:简易 BVH 实现

下面是一个简化的 BVH 实现示例,帮助理解核心概念:

from dataclasses import dataclass
from typing import List, Optional
import math

@dataclass
class AABB:
    """轴对齐包围盒"""
    min: Vec3
    max: Vec3

    def union(self, other: 'AABB') -> 'AABB':
        """合并两个包围盒"""
        return AABB(
            min=Vec3(
                min(self.min.x, other.min.x),
                min(self.min.y, other.min.y),
                min(self.min.z, other.min.z)
            ),
            max=Vec3(
                max(self.max.x, other.max.x),
                max(self.max.y, other.max.y),
                max(self.max.z, other.max.z)
            )
        )

    def surface_area(self) -> float:
        """计算表面积"""
        d = self.max - self.min
        return 2.0 * (d.x * d.y + d.y * d.z + d.z * d.x)

class BVHNode:
    """BVH 节点"""
    def __init__(self):
        self.bbox: Optional[AABB] = None
        self.left: Optional[BVHNode] = None
        self.right: Optional[BVHNode] = None
        self.objects: List = []

    def is_leaf(self) -> bool:
        return len(self.objects) > 0

def build_bvh(objects: List, max_leaf_size=4) -> BVHNode:
    """构建 BVH"""
    node = BVHNode()

    # 计算包围盒
    node.bbox = objects[0].bbox
    for obj in objects[1:]:
        node.bbox = node.bbox.union(obj.bbox)

    # 叶子节点
    if len(objects) <= max_leaf_size:
        node.objects = objects
        return node

    # 选择分割轴(最长轴)
    extents = node.bbox.max - node.bbox.min
    axis = 0 if extents.x > extents.y and extents.x > extents.z else \
           1 if extents.y > extents.z else 2

    # 按重心排序
    objects.sort(key=lambda obj: obj.centroid()[axis])
    mid = len(objects) // 2

    # 递归构建
    node.left = build_bvh(objects[:mid], max_leaf_size)
    node.right = build_bvh(objects[mid:], max_leaf_size)

    return node

def intersect_bvh(ray, node, t_min=0.0, t_max=float('inf')):
    """光线与 BVH 求交"""
    # 包围盒测试
    if not ray_box_intersect(ray, node.bbox, t_min, t_max):
        return None

    # 叶子节点
    if node.is_leaf():
        closest_hit = None
        for obj in node.objects:
            hit = obj.intersect(ray, t_min, t_max)
            if hit:
                closest_hit = hit
                t_max = hit.t  # 提前剪枝

        return closest_hit

    # 递归遍历
    hit_left = intersect_bvh(ray, node.left, t_min, t_max)
    if hit_left:
        t_max = hit_left.t

    hit_right = intersect_bvh(ray, node.right, t_min, t_max)

    return hit_right if hit_right else hit_left

参考资料

  1. GAMES101: 现代计算机图形学入门 - 闫令琪 Lecture 14
  2. TinyRaytracer - Dmitry V. Sokolov
  3. Physically Based Rendering - Matt Pharr et al.
  4. NVIDIA OptiX Programming Guide - BVH 硬件加速
  5. Wald, I. (2007). “On fast Construction of SAH-based Bounding Volume Hierarchies”