基于 WebGPU 的体素锥追踪全局光照(Voxel Cone Tracing GI)
基于 WebGPU 的体素锥追踪全局光照(Voxel Cone Tracing GI)基于案例AI编写
什么是 Voxel Cone Tracing?
Voxel Cone Tracing (VCT) 是一种用于实时全局光照的技术,最早由 Crassin 等人在 2011 年的论文 "Interactive Indirect Illumination Using Voxel Cone Tracing" 中提出。它的核心思想是:
- 将场景体素化:把 3D 场景离散化为一个 3D 体素网格
- 注入光照信息:在体素中存储直接光照(radiance)
- 生成 Mipmap:通过逐级下采样构建稀疏体素八叉树(或 3D Mipmap)
- 锥追踪采样:沿着法线方向发射多个锥体,在不同 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) │ │
└─────────────────────────────┴───────────────────────────────┘为什么体素化时必须存储法线?
⚠️ 这是一个关键的架构决策
传统方法是在光照注入阶段从邻域体素估算法线,但这有几个问题:
- 一致性:直接光照和间接光照应使用相同的法线
- LOD 支持:法线需要在 Mipmap 链中正确平均
- 精度:从邻域估算法线会引入误差,尤其在体素边缘
// 体素化时存储法线
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世界坐标到体素坐标的转换:
三角形-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 层级与锥体直径的对应关系?
| Mip Level | 分辨率 | 覆盖范围(体素数) | 实际直径(world) |
|---|---|---|---|
| 0 | 64³ | 1 | 0.0375 |
| 1 | 32³ | 2 | 0.075 |
| 2 | 16³ | 4 | 0.15 |
| 3 | 8³ | 8 | 0.3 |
| 4 | 4³ | 16 | 0.6 |
| 5 | 2³ | 32 | 1.2 |
| 6 | 1³ | 64 | 2.4 |
Q2: 为什么 Show Only GI 模式全黑?
这是 Mipmap 生成失败的典型症状。最常见的原因是 WebGPU Bind Group Layout 配置问题:
rgba16float需要sampleType: 'unfilterable-float'layout: 'auto'不会自动推断这个配置- 必须显式创建
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 层级的采样能正确反映该区域的"密度",而不是简单的"有/无"。
性能优化建议
- 降低分辨率:64³ → 32³ 可大幅提升性能,但会损失细节
- 限制追踪距离:根据场景大小设置合理的
maxDist - 时域累积:多帧混合可以用更少的锥获得更好的效果
- 级联体素:近处高分辨率,远处低分辨率(类似 CSM)
- 稀疏体素八叉树:只存储非空体素,节省内存和带宽
总结
Voxel Cone Tracing 是一种优雅的实时全局光照解决方案,它巧妙地利用了:
- 体素化:将连续场景离散化
- Mipmap LOD:用预计算换取运行时效率
- 锥体近似:用一个锥体近似无数光线
虽然相比于 Path Tracing 有诸多近似,但 VCT 在实时渲染中仍然是性价比极高的选择。现代游戏引擎(如 UE4 的 SVOGI、Unity 的 VXGI)都采用了类似的技术。