T.TAO
返回博客
/7 min read/Graphics Engine

Metal #17 光线追踪(一)渲染算法

#ComputerGraphics#GraphicsEngine#Metal

这是光线追踪三篇的第一篇,讲算法本身和 Metal 的 API 结构。

渲染方程

所有基于物理的渲染都在近似同一个方程(Kajiya, 1986):

Plain TextL_o(p, ω_o) = L_e(p, ω_o) + ∫_Ω f_r(p, ω_i, ω_o) · L_i(p, ω_i) · (n · ω_i) dω_i

读作:从点 p 沿方向 ω_o 离开的辐射亮度,等于 p 自身发出的光,加上从半球 Ω 各个方向入射的光经过 BRDF 加权后的积分。

这个方程是递归的:L_i 本身是另一个点的 L_o。这就是全局光照的数学本质——一次反弹的光还会再反弹。光栅化无法直接处理这种递归,只能用各种预计算和屏幕空间技巧近似;光线追踪则可以直接对它求值。

蒙特卡洛估计

这个积分没有解析解,只能数值求解。蒙特卡洛方法用随机采样估计积分:

Plain Text∫ f(x) dx ≈ (1/N) · Σ f(x_i) / p(x_i)

其中 p 是采样的概率密度。这个估计是无偏的:期望值等于真实积分。它的误差表现为噪声,标准差按 1/√N 下降。

这条 1/√N 是光线追踪的根本困难:噪声减半需要四倍的采样。从 100 spp 到 1 spp 的差距不是一百倍算力,而是整个实时光追领域存在的理由。

重要性采样

既然可以自由选择采样分布 p,那就应该让它尽量接近被积函数的形状。在 p 正比于 f 的地方多采样,方差就会下降。

对于 GGX 镜面 BRDF,我们按它的法线分布采样半程向量:

MSLfloat3 importanceSampleGGX(float2 xi, float3 n, float roughness) {
    float a = roughness * roughness;
    float phi = 2.0 * M_PI_F * xi.x;
    float cosTheta = sqrt((1.0 - xi.y) / (1.0 + (a * a - 1.0) * xi.y));
    float sinTheta = sqrt(1.0 - cosTheta * cosTheta);

    float3 h = float3(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);
    return tangentToWorld(h, n);
}

对于漫反射,按余弦加权采样半球——因为渲染方程里有一个 (n · ω_i) 因子:

MSLfloat3 cosineSampleHemisphere(float2 xi, float3 n) {
    float r = sqrt(xi.x);
    float phi = 2.0 * M_PI_F * xi.y;
    float3 d = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - xi.x)));
    return tangentToWorld(d, n);
}

余弦采样和 (n · ω_i) 因子正好抵消,估计式简化成对 BRDF 值的简单平均——这也是它成为标准做法的原因。

多重重要性采样(MIS)进一步结合多种采样策略。对光源采样在小光源上方差小,对 BRDF 采样在光滑表面上方差小,MIS 用平衡启发式把两者加权组合,取各自的长处。这是路径追踪里效果最显著的单项优化。

Metal 的加速结构

光追的核心操作是"这条射线撞到了什么"。暴力遍历所有三角形是 O(n),不可行。加速结构(BVH)把它降到 O(log n)。

Metal 的加速结构是两层的:

BLAS(底层)包含实际的几何:

Swiftlet geometry = MTLAccelerationStructureTriangleGeometryDescriptor()
geometry.vertexBuffer = vertexBuffer
geometry.vertexStride = MemoryLayout<Vertex>.stride
geometry.indexBuffer  = indexBuffer
geometry.indexType    = .uint32
geometry.triangleCount = triangleCount

let primitiveDesc = MTLPrimitiveAccelerationStructureDescriptor()
primitiveDesc.geometryDescriptors = [geometry]

TLAS(顶层)包含 BLAS 的实例,每个带一个变换矩阵:

Swiftlet instanceDesc = MTLInstanceAccelerationStructureDescriptor()
instanceDesc.instancedAccelerationStructures = blasArray
instanceDesc.instanceCount = instanceCount
instanceDesc.instanceDescriptorBuffer = instanceBuffer

这个两层结构的价值在于复用和增量更新:一万棵相同的树只需要一个 BLAS 和一万个实例变换;一个物体移动时只需要重建 TLAS(很快),BLAS 完全不动。

只有形变的几何(蒙皮角色、布料)需要更新自己的 BLAS,而且可以用 refit 而不是完全重建——refit 保持 BVH 的树结构不变,只更新包围盒,快得多,代价是几何变化剧烈后树的质量会下降。

构建加速结构需要一个 scratch buffer 和一次 GPU 命令:

Swiftlet sizes = device.accelerationStructureSizes(descriptor: primitiveDesc)
let accel = device.makeAccelerationStructure(size: sizes.accelerationStructureSize)!
let scratch = device.makeBuffer(length: sizes.buildScratchBufferSize, options: .storageModePrivate)!

let encoder = commandBuffer.makeAccelerationStructureCommandEncoder()!
encoder.build(accelerationStructure: accel, descriptor: primitiveDesc,
              scratchBuffer: scratch, scratchBufferOffset: 0)
encoder.endEncoding()

求交:intersector

Metal 提供 intersector 模板来发射射线。它可以在 compute kernel 里用,也可以在片元或顶点函数里用(inline ray tracing):

MSL#include <metal_raytracing>
using namespace metal::raytracing;

kernel void raytrace(instance_acceleration_structure accel [[buffer(0)]],
                     texture2d<float, access::write> output [[texture(0)]],
                     constant Uniforms &u [[buffer(1)]],
                     uint2 gid [[thread_position_in_grid]])
{
    ray r;
    r.origin       = u.cameraPosition;
    r.direction    = computeRayDirection(gid, u);
    r.min_distance = 0.001;                  // 避免自相交
    r.max_distance = INFINITY;

    intersector<instancing, triangle_data> i;
    i.assume_geometry_type(geometry_type::triangle);
    i.force_opacity(forced_opacity::opaque); // 没有 alpha test 时可以加速

    intersection_result<instancing, triangle_data> hit = i.intersect(r, accel);

    if (hit.type == intersection_type::none) {
        output.write(sampleSky(r.direction), gid);
        return;
    }

    float2 bary = hit.triangle_barycentric_coord;
    // 用 bary 插值顶点属性,然后着色 ...
}

min_distance = 0.001 那一行不是可选的。射线从表面出发,浮点误差会让它有一定概率立刻和自己出发的那个三角形相交,表现为画面上遍布的黑色噪点——这就是光追版本的 shadow acne

更稳健的做法是沿法线方向偏移射线原点,偏移量随到相机的距离缩放,因为浮点精度本身就是相对的。

force_opacity(opaque) 告诉硬件不需要调用任何 any-hit 回调,遍历可以在第一次命中时立刻停止。有 alpha test 的几何(树叶)不能这么做,需要用 intersection function table 提供一个 any-hit 函数。

下一篇讲怎么用这些射线算阴影和光照。

本系列文章

Metal
  1. 01Metal #0 Swift 回顾
  2. 02Metal #1 初始化
  3. 03Metal #2 渲染管线
  4. 04Metal #3 顶点函数
  5. 05Metal #4 片元函数
  6. 06Metal #5 纹理
  7. 07Metal #6 摄像机与交互
  8. 08Metal #7 光照
  9. 09Metal #8 材质
  10. 10Metal #9 渲染通道
  11. 11Metal #10 阴影
  12. 12Metal #11 延迟渲染
  13. 13Metal #12 粒子系统
  14. 14Metal #13 曲面细分
  15. 15Metal #14 后处理
  16. 16Metal #15 反射与折射
  17. 17Metal #16 动画
  18. 18Metal #17 光线追踪(一)渲染算法
  19. 19Metal #18 光线追踪(二)阴影与光照
  20. 20Metal #19 光线追踪(三)性能优化
  21. 21Metal #21 [附录] 计算着色器
  22. 22Metal #22 [附录] SwiftUI 中的 Metal