这是光线追踪三篇的第一篇,讲算法本身和 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 函数。
下一篇讲怎么用这些射线算阴影和光照。