基于 WebGPU 的体素锥追踪全局光照(Voxel Cone Tracing GI)基于案例AI编写

什么是 Voxel Cone Tracing?

Voxel Cone Tracing (VCT) 是一种用于实时全局光照的技术,最早由 Crassin 等人在 2011 年的论文 "Interactive Indirect Illumination Using Voxel Cone Tracing" 中提出。它的核心思想是:

  1. 将场景体素化:把 3D 场景离散化为一个 3D 体素网格
  2. 注入光照信息:在体素中存储直接光照(radiance)
  3. 生成 Mipmap:通过逐级下采样构建稀疏体素八叉树(或 3D Mipmap)
  4. 锥追踪采样:沿着法线方向发射多个锥体,在不同 Mip 层级采样间接光照

相比于 Path Tracing 需要大量光线采样,VCT 利用预计算的体素 Mipmap 来近似锥体内的光照积分,实现了性能与质量的平衡。

完整实现可以在本站的 Voxel Cone Tracing Demo 中体验交互式效果。


渲染 Pipeline 概览

我们的 VCT 实现分为以下几个阶段:

┌─────────────┐    ┌─────────────────┐    ┌───────────────┐    ┌─────────────────┐
│  Voxelize   │ -> │ Light Injection │ -> │ Mipmap Gen    │ -> │ Cone Tracing    │
│  体素化      │    │ 光照注入        │    │ Mipmap 生成   │    │ 锥追踪渲染      │
└─────────────┘    └─────────────────┘    └───────────────┘    └─────────────────┘
     ↓                    ↓                      ↓                     ↓
  3D 体素网格       存储直接光照           构建多级 LOD          采样间接光照
  (64³ rgba16float)  + 阴影计算            (2x2x2 下采样)        (Diffuse + Specular)

1. 体素化(Voxelization)

体素化的目标是将场景中的三角形光栅化到 3D 网格中。我们使用保守光栅化的思想:如果三角形与体素有任何交集,该体素就被标记为占用。

双纹理架构

我们的实现采用双纹理架构,分离存储几何信息和光照信息:

┌─────────────────────────────────────────────────────────────┐
│                    双纹理架构                                │
├─────────────────────────────┬───────────────────────────────┤
│  纹理 A (albedoGrid)        │  纹理 B (normalGrid)          │
│  RGB = 反照率               │  RGB = 法线 (编码到 0-1)      │
│  A = 存在标记               │  A = 不透明度                 │
├─────────────────────────────┼───────────────────────────────┤
│  纹理 C (radianceGrid)      │                               │
│  RGB = 出射辐射度 (HDR)     │  ← 光照注入阶段生成           │
│  A = 占用率 (用于 Mipmap)   │                               │
└─────────────────────────────┴───────────────────────────────┘

为什么体素化时必须存储法线?

⚠️ 这是一个关键的架构决策

传统方法是在光照注入阶段从邻域体素估算法线,但这有几个问题:

  1. 一致性:直接光照和间接光照应使用相同的法线
  2. LOD 支持:法线需要在 Mipmap 链中正确平均
  3. 精度:从邻域估算法线会引入误差,尤其在体素边缘
// 体素化时存储法线
let avgNormal = normalize((v0.normal + v1.normal + v2.normal) / 3.0);
// 编码 [-1,1] -> [0,1] 范围
let encodedNormal = avgNormal * 0.5 + 0.5;
textureStore(normalGrid, vec3i(x, y, z), vec4f(encodedNormal, 1.0));

体素坐标转换

体素网格覆盖世界空间 [-1.2, 1.2] 的范围,网格分辨率为 64³:

const GRID_SIZE = 64;
const BOUNDS_MIN = -1.2;
const BOUNDS_MAX = 1.2;
const BOUNDS_SIZE = BOUNDS_MAX - BOUNDS_MIN; // 2.4
const VOXEL_SIZE = BOUNDS_SIZE / GRID_SIZE;  // ≈ 0.0375

世界坐标到体素坐标的转换:

voxelCoord=worldPosboundsMinboundsSize×gridSize\text{voxelCoord} = \frac{\text{worldPos} - \text{boundsMin}}{\text{boundsSize}} \times \text{gridSize}

三角形-AABB 相交检测

对于每个体素,我们检测三角形是否与其 AABB 相交:

fn triangleAABBIntersect(v0: vec3f, v1: vec3f, v2: vec3f, 
                          boxCenter: vec3f, boxHalfSize: vec3f) -> bool {
    // 1. 将三角形平移到以 AABB 中心为原点
    let tv0 = v0 - boxCenter;
    let tv1 = v1 - boxCenter;
    let tv2 = v2 - boxCenter;
    
    // 2. 检查三角形边的分离轴(9 个轴)
    let e0 = tv1 - tv0;
    let e1 = tv2 - tv1;
    let e2 = tv0 - tv2;
    
    // 3. 检查 AABB 的 3 个面法线作为分离轴
    // 4. 检查三角形法线作为分离轴
    
    return true; // 所有轴都没有分离 -> 相交
}

体素化 Compute Shader 遍历所有三角形,对每个三角形:

  • 计算其 AABB 在体素空间中的范围
  • 对范围内的每个体素进行相交测试
  • 相交则使用 textureStore 写入颜色(alpha = 1 表示占用)

2. 光照注入(Light Injection)

光照注入阶段为每个被占用的体素计算直接光照。这一步是 VCT 的核心——我们在体素中存储的是出射辐射度(outgoing radiance)

阴影射线追踪

为了支持阴影,我们在体素空间中追踪阴影射线:

fn traceShadowRay(origin: vec3f, lightPos: vec3f) -> f32 {
    let direction = normalize(lightPos - origin);
    let maxDist = length(lightPos - origin);
    
    var t = uniforms.voxelSize * 2.0;  // 起始偏移,避免自相交
    let stepSize = uniforms.voxelSize;
    
    while (t < maxDist) {
        let samplePos = origin + direction * t;
        let uvw = worldToUVW(samplePos);
        
        if (any(uvw < vec3f(0.0)) || any(uvw > vec3f(1.0))) {
            break;  // 超出体素网格范围
        }
        
        let voxel = textureSampleLevel(voxelTexture, voxelSampler, uvw, 0.0);
        if (voxel.a > 0.5) {
            return 0.0;  // 被遮挡
        }
        t += stepSize;
    }
    return 1.0;  // 可见
}

Diffuse 光照计算

// 从法线纹理读取法线(解码 [0,1] -> [-1,1])
// 这是在体素化阶段存储的平均法线
var normal = normalVoxel.rgb * 2.0 - 1.0;
if (length(normal) < 0.001) {
    normal = vec3f(0.0, 1.0, 0.0);  // fallback
} else {
    normal = normalize(normal);
}

let L = normalize(uniforms.lightPos - worldPos);
let NdotL = max(dot(normal, L), 0.0);

// 双面光照:如果法线背对光源,也给一些光(模拟薄表面)
let backLighting = max(dot(-normal, L), 0.0) * 0.3;
let effectiveNdotL = NdotL + backLighting;

let shadow = traceShadowRay(worldPos, uniforms.lightPos);
let lighting = uniforms.lightColor * uniforms.lightIntensity * effectiveNdotL * shadow;

// 存储出射辐射度到体素
textureStore(radianceGrid, voxelCoord, vec4f(baseColor * lighting, 1.0));

3. Mipmap 生成

Mipmap 是 VCT 的关键优化。通过预计算不同分辨率的体素网格,锥追踪时可以根据锥体直径选择合适的 LOD 层级,一次采样就能获得较大区域的平均光照。

辐射度 Mipmap(2×2×2 下采样)

每个上层 Mip 的体素对应下层 8 个体素的平均。关键改进是几何体占据判断同时检查 alpha 和法线有效性:

// 判断子体素是否有几何体占据
// 条件:alpha > 0 且 法线有效(解码后长度 > 阈值)
fn hasGeometry(radianceSample: vec4f, normalSample: vec4f) -> bool {
    if (radianceSample.a <= 0.0) {
        return false;
    }
    // 解码法线: [0,1] -> [-1,1]
    let normal = normalSample.rgb * 2.0 - 1.0;
    // 法线长度必须足够大才认为有效
    return length(normal) > 0.1;
}

@compute @workgroup_size(4, 4, 4)
fn mipmapMain(@builtin(global_invocation_id) id: vec3u) {
    let srcCoord = id * 2u;
    var colorSum = vec3f(0.0);
    var alphaSum = 0.0;
    var validCount = 0.0;
    
    // 采样 2×2×2 = 8 个源体素
    for (var dz = 0; dz < 2; dz++) {
        for (var dy = 0; dy < 2; dy++) {
            for (var dx = 0; dx < 2; dx++) {
                let srcPos = srcCoord + vec3i(dx, dy, dz);
                let radianceSample = textureLoad(srcRadiance, srcPos, 0);
                let normalSample = textureLoad(srcNormal, srcPos, 0);
                
                // 累加所有体素的 alpha(计算覆盖率)
                alphaSum += radianceSample.a;
                
                // 只累加有几何体的体素(alpha > 0 且 法线有效)
                if (hasGeometry(radianceSample, normalSample)) {
                    colorSum += radianceSample.rgb;
                    validCount += 1.0;
                }
            }
        }
    }
    
    var result = vec4f(0.0);
    if (validCount > 0.0) {
        // 颜色:有效体素的平均值
        // alpha:8个子体素的平均占用率(0-1范围)
        result = vec4f(colorSum / validCount, alphaSum / 8.0);
    }
    
    textureStore(dstTexture, id, result);
}

法线 Mipmap(特殊处理)

法线不能简单平均!必须:解码 → 平均 → 重新归一化 → 编码

这确保了法线在任何 LOD 级别都是单位向量:

@compute @workgroup_size(4, 4, 4)
fn mipmapNormalMain(@builtin(global_invocation_id) id: vec3u) {
    let srcBase = vec3i(id) * 2;
    
    var normalSum = vec3f(0.0);
    var alphaSum = 0.0;
    var validCount = 0.0;
    
    for (var dz = 0; dz < 2; dz++) {
        for (var dy = 0; dy < 2; dy++) {
            for (var dx = 0; dx < 2; dx++) {
                let srcPos = srcBase + vec3i(dx, dy, dz);
                let sample = textureLoad(srcTexture, srcPos, 0);
                
                alphaSum += sample.a;
                
                // 解码法线: [0,1] -> [-1,1]
                let normal = sample.rgb * 2.0 - 1.0;
                let normalLen = length(normal);
                
                // 只有 alpha > 0 且 法线有效(长度足够)才参与平均
                if (sample.a > 0.0 && normalLen > 0.1) {
                    normalSum += normal;
                    validCount += 1.0;
                }
            }
        }
    }
    
    var result = vec4f(0.0);
    if (validCount > 0.0) {
        // 平均法线并重新归一化
        var avgNormal = normalSum / validCount;
        let len = length(avgNormal);
        if (len > 0.001) {
            avgNormal = avgNormal / len;
        } else {
            avgNormal = vec3f(0.0, 1.0, 0.0);  // fallback
        }
        
        // 编码法线: [-1,1] -> [0,1]
        let encodedNormal = avgNormal * 0.5 + 0.5;
        result = vec4f(encodedNormal, alphaSum / 8.0);
    }
    
    textureStore(dstTexture, id, result);
}

关键点:Alpha 通道表示该体素区域的占用率(0~1),而不是简单的二值占用。这对于锥追踪的遮挡累积至关重要。


4. 锥追踪渲染(Cone Tracing)

锥追踪是 VCT 的核心渲染算法。我们沿着某个方向发射一个锥体,锥体会随着距离逐渐变宽。在每个采样点,根据锥体直径选择合适的 Mip 层级进行采样。

步长与 Mip 层级计算

fn traceCone(origin: vec3f, direction: vec3f, coneAngle: f32, maxDist: f32) -> vec4f {
    var color = vec3f(0.0);
    var alpha = 0.0;
    
    let tanHalfAngle = tan(coneAngle * 0.5);
    var t = uniforms.voxelSize * 2.0;  // 起始偏移
    
    while (t < maxDist && alpha < 0.99) {
        let samplePos = origin + direction * t;
        
        // 锥体直径 = 2 * t * tan(θ/2)
        let coneDiameter = 2.0 * t * tanHalfAngle;
        
        // Mip 层级 = log2(直径 / 体素大小)
        let mipLevel = clamp(log2(coneDiameter / uniforms.voxelSize), 0.0, 6.0);
        
        // 采样体素(带三线性 + Mip 插值)
        let uvw = worldToUVW(samplePos);
        let voxel = textureSampleLevel(voxelTexture, voxelSampler, uvw, mipLevel);
        
        // 前后混合(front-to-back compositing)
        let sampleAlpha = voxel.a;
        color += (1.0 - alpha) * sampleAlpha * voxel.rgb;
        alpha += (1.0 - alpha) * sampleAlpha;
        
        // 步长 = max(直径 * 0.5, 体素大小)
        // 0.5 倍直径确保采样有重叠,避免漏采
        let stepSize = max(coneDiameter * 0.5, uniforms.voxelSize);
        t += stepSize;
    }
    
    return vec4f(color, alpha);
}

为什么步长是 coneDiameter * 0.5

系数 效果
1.0× 相邻采样刚好相切,可能漏采
0.5× 50% 重叠,确保连续覆盖
0.25× 更密集,质量更高但更慢

同时我们设置 max(..., voxelSize) 作为最小步长,防止在起点附近步长过小导致穿墙。

Diffuse 与 Specular GI

Diffuse GI:沿法线方向发射一个宽锥(60°),近似半球积分:

let diffuseCone = traceCone(origin, N, PI / 3.0, maxDist);  // 60° 锥
let diffuseGI = diffuseCone.rgb * uniforms.giIntensity;

Specular GI:沿反射方向发射一个窄锥,锥角取决于表面粗糙度:

let R = reflect(-V, N);
let specularConeAngle = 0.1;  // 约 6°,光滑表面
let specularCone = traceCone(origin, R, specularConeAngle, maxDist);
let specularGI = specularCone.rgb * uniforms.giIntensity * 0.5;

常见问题 Q&A

Q1: Mip 层级与锥体直径的对应关系?

mipLevel=log2(coneDiametervoxelSize)\text{mipLevel} = \log_2 \left( \frac{\text{coneDiameter}}{\text{voxelSize}} \right)
Mip Level 分辨率 覆盖范围(体素数) 实际直径(world)
0 64³ 1 0.0375
1 32³ 2 0.075
2 16³ 4 0.15
3 8 0.3
4 16 0.6
5 32 1.2
6 64 2.4

Q2: 为什么 Show Only GI 模式全黑?

这是 Mipmap 生成失败的典型症状。最常见的原因是 WebGPU Bind Group Layout 配置问题:

  1. rgba16float 需要 sampleType: 'unfilterable-float'
  2. layout: 'auto' 不会自动推断这个配置
  3. 必须显式创建 BindGroupLayout 并用它创建 Pipeline

Q3: 单锥 vs 多锥的权衡?

方案 优点 缺点
单锥 60° 简单高效,一次采样 方向性差,可能漏掉侧向光
5-9 锥 更准确的半球积分 5-9 倍采样成本

对于实时应用,单锥 + 时域累积是不错的选择。

Q4: Alpha 在 Mipmap 中的含义?

Alpha 通道存储的是占用率而非二值占用:

  • Mip 0: alpha = 1.0(原始体素完全占用)或 0.0
  • Mip 1: alpha = validCount / 8.0(8 个子体素中有几个被占用)
  • 更高 Mip: 递归计算

这样在锥追踪时,高 Mip 层级的采样能正确反映该区域的"密度",而不是简单的"有/无"。


性能优化建议

  1. 降低分辨率:64³ → 32³ 可大幅提升性能,但会损失细节
  2. 限制追踪距离:根据场景大小设置合理的 maxDist
  3. 时域累积:多帧混合可以用更少的锥获得更好的效果
  4. 级联体素:近处高分辨率,远处低分辨率(类似 CSM)
  5. 稀疏体素八叉树:只存储非空体素,节省内存和带宽

总结

Voxel Cone Tracing 是一种优雅的实时全局光照解决方案,它巧妙地利用了:

  • 体素化:将连续场景离散化
  • Mipmap LOD:用预计算换取运行时效率
  • 锥体近似:用一个锥体近似无数光线

虽然相比于 Path Tracing 有诸多近似,但 VCT 在实时渲染中仍然是性价比极高的选择。现代游戏引擎(如 UE4 的 SVOGI、Unity 的 VXGI)都采用了类似的技术。