SDFGI 技术解析:从距离场到实时全局光照

前言:实时全局光照的困境与希望

在实时渲染领域,全局光照(Global Illumination, GI) 始终是衡量画面真实感的关键。经典的解决方案各有掣肘:

  • 烘焙光照贴图:需要漫长的预处理,无法响应动态场景或动态光源。
  • 屏幕空间方案(SSAO/SSGI):仅利用当前屏幕可见信息,边缘拉丝、漏光严重。
  • 硬件光线追踪:对硬件要求苛刻,集成显卡和移动端难以实时运行,VR更成问题。
  • 基于探针的方案(如DDGI):动态刷新困难,探针摆放需要大量手动工作。

Godot引擎在4.x版本中引入了一项全新的GI方案——SDFGI(Signed Distance Field Global Illumination)。它的使命是:一键开启,实时更新,全Vulkan设备支持,同时提供漫反射和镜面反射,甚至能驱动体积雾。不依赖光线追踪硬件,甚至连VR都不强制使用TAA。

其核心技术建立在Morgan McGuire的DDGI(Dynamic Diffuse Global Illumination)思想之上,但将探针更新机制改为有符号距离场球体追踪,从而摆脱了对硬件RT的依赖。这份解析基于Godot联合创始人Juan Linietsky的公开技术讲义,我将从零开始,一步步剥离SDFGI的每一个细节,介绍背后的图形学原理。


1. 设计哲学:目标与妥协

一个好的实时算法必先明确它的战场边界。SDFGI在设计之初就定下了严格的硬性目标自觉牺牲

1.1 硬性目标

  • 一键启用:无需布置探针、SDF几何体、光照图UV等任何预处理。在Godot编辑器中,只需在Environment节点勾选SDFGI即可。
  • 实时更新:光照变化(太阳移动、灯光开关)和几何变化(静态网格移动/旋转)能快速反映在间接光照中。近似实时,一般几帧内收敛。
  • 良好质量:最大限度地消除漏光,提供可信的漫反射和镜面反射。目标是“够好”而非物理精确。
  • 全Vulkan兼容:能在集成显卡(IGP)上运行,不依赖硬件光线追踪。Vulkan 1.0即可,无需扩展。
  • VR友好:不强制要求TAA(时间抗锯齿),避免VR中的重影不适感。SDFGI的时间累积是可选的,且独立于TAA。
  • 支持透明物体和体积雾:间接光也要成为体积光的来源。这意味着体积雾可以散射SDFGI计算的间接光。

1.2 妥协

为了达到上述目标,它明确放弃了一些特性:

  1. 高频GI细节缺失:小尺寸阴影和颜色变化无法被探针捕捉,必须由屏幕空间GI(SSIL)补偿。探针分辨率决定了间接光的频率上限。
  2. 动态物体不贡献GI:动态对象只能接收间接光,不会将自己表面的颜色反弹到环境中。未来可能为其增加简单的遮挡体支持。
  3. 必须使用级联(Cascades):随着距离增加,探针分辨率下降,细节丢失。这种层级结构是内存与精度的折衷。
  4. 小自发光物体产生噪点:因为探针数量有限,小的发射面很难被稳定采样,表现为时间上的闪烁。

了解了这些先决条件,我们就能明白后续每一步技术选择的原因:一切都为了在苛刻的限制下找到最优的性价比


2. 全局光照的“记忆单元”——辐照度与遮蔽场

SDFGI的核心思想,是在空间中布置一张3D的“灯光记忆网”。这张网由成千上万个虚拟探针组成,每个探针记录着它所在位置从各个方向接收到的光照。当渲染某个表面时,就去周围找几个探针,插值得到间接光

2.1 双场系统:Irradiance Field 与 Occlusion Field

为了防止插值造成“穿墙漏光”,SDFGI维护了两套场:

  • 辐照度场(Irradiance Field):一个3D网格,每个节点是一个探针,存储该点来自球面各个方向的入射辐照度。注意,它存储的是辐照度(Irradiance),即单位面积接收的辐射通量,采样时可直接作为漫反射间接光。
  • 遮蔽场(Occlusion Field):记录每个探针在各个方向上能看到的最近几何体的径向距离。作用相当于一个距离图,用来计算当前着色点与探针之间的可见性。它实际上存储的是该方向的深度均值 和平方均值 ,用于切比雪夫测试。

当我们在着色点 采样时,会考虑围绕 8个最近探针(三线性插值所需)。对于每个探针,先根据其与 的连线方向,从遮蔽场中取出该方向的平均遮挡距离 ,然后结合 到探针的实际距离 ,利用切比雪夫不等式计算一个可见性权重。这样,若 在墙壁一侧,它的探针权重会因为遮挡距离远小于实际距离而被迅速压到接近0,从而消灭漏光。更多数学细节在4.2节。

2.2 多级联(Cascades)覆盖全距离

为了在一个广袤世界中同时保证近处的精度和远处的覆盖范围,SDFGI借鉴了阴影贴图的级联思想,采用了最多8个级联。每个级联是一个立方体区域,边长以2的幂次扩大,例如第一个级联边长可能是16米,第二个是32米,以此类推,直到覆盖整个开放世界。这种指数增长保证了对数级的深度覆盖,非常适合大世界。

每个级联的内部结构是固定的:

  • 距离场体素128³ (存储16位浮点距离)
  • 辐照度与遮蔽探针17³(每个维度上17个探针,均匀分布在128³空间中,即每隔8个体素一个探针:,每个探针覆盖的单元格区域)

图1 左侧展示了单一级联的解剖图:外框是128³体素距离场,内部17³个探针,以及级联之间重叠混合区域。当物体逐渐远离时,它会在两个相邻级联的过渡区域内同时读取两套探针并混合结果,从而平滑切换精度。混合因子通常由着色点距两个级联边界的距离决定。

alt text
图.1

图1 右侧直观展示了三个级联的嵌套关系和过渡混合区域,由于级联跟随相机移动(类似于CSM阴影),相机总是位于中心级联的内部,保证了近处最高精度。

2.3 探针的存储格式:5×5八面体映射

每个探针需要记录球面上各个方向的入射光,常见的做法是小立方体贴图或球谐函数。SDFGI选择的是5×5像素的八面体映射。八面体映射能将球面均匀地参数化到一个正方形,具体方法:将单位球面上的方向映射到一个八面体,然后展开到 [-1,1]² 的正方形,再映射到 [0,1]² 的纹理坐标。

八面体映射推导
给定球面方向 ,通过绝对值投影到八面体:

  1. 计算 范数分母:
  2. 映射到二维:
  3. 处理下半球:如果 ,则额外将 变换到 以外的区域,具体表现为 ,最终得到一个在 内的表示。
  4. 缩放至 texcoord = (u*0.5+0.5, v*0.5+0.5)

下面用示意图展示从三维方向到八面体,再到2D纹理的过程:

方向 d 1. 单位球面 2. 八面体投影 3. 5×5 2D纹理 1像素 = 1个方向样本 25线程并行 Compute Shader

这种映射的优点是球面面积均匀性较好,且5×5的网格可以很好地覆盖所有方向,每个像素对应一个立体角大致相等的方向。25个方向采样可以满足漫反射探针的精度需求。

为什么用八面体而非立方体贴图?因为它在存储上更紧凑且无接缝问题,并且易于在Compute Shader中遍历。5×5像素正好构成25个方向采样,等价于一个极低分辨率的立方体贴图(2×2 face),但更适合直接作为Compute Shader的25线程工作组处理。25个线程可以完美覆盖整个探针的更新计算。

这25个方向的光照打包成一个大纹理数组,一个级联的所有探针(17³ = 4913个)平铺在数组的一层中。每个5×5小块周围留1像素边框,用于双线性插值采样。注意,边框像素通常与相邻探针的边框共享,实现无缝滤波。如此紧凑的安排为后续的高效更新埋下伏笔。

在内存中,一个辐照度探针占用 5*5*4字节(RGBA8) ≈ 100字节。一个级联的辐照度探针数组约 4913 * 100 ≈ 480 KB。遮蔽探针需要存储两个通道(均值和平方均值),每个像素8字节,一个级联占用约 4913 * 5*5*8 ≈ 960 KB。加上距离场等,单个级联内存占用约几MB,8级联控制在几十MB内,非常适合低端硬件。


3. 数据生成管道:从几何到光照

SDFGI的数据生成分为五个阶段,均在GPU上执行,利用Compute Shader和光栅化管线:

  1. 高精度实体位场生成
  2. 距离场生成(Jump Flood)
  3. 遮蔽探针生成
  4. 光照缓冲与光照纹理建立
  5. 球体追踪填充辐照度探针

3.1 超采样实体位场(Oversampled Solid Bitfield)

如果直接在128³体素上渲染几何体,薄墙会因分辨率不足而消失,导致漏光或者需要巨大的偏差。SDFGI的解决方案是4倍超采样,即用512³的子体素精度表达几何体。

  • 渲染时,不写入颜色,只利用光栅化覆盖信息。使用空帧缓冲,通过片段着色器或直接利用保守光栅化,调用 imageStoreAtomicOr 将覆盖的位填充到 512³ 的“实体位”网格中。这个网格用 uint32 类型纹理,每个 uint 包含一个 子立方体的64个体素状态(因为 ),因此总内存为 (512/4)^3 * 4字节 = 128^3 * 4 ≈ 8 MB,实际上因为有些压缩,最终约16MB。这种位打包极大节省了带宽。
  • 同时,在更粗的 64³ 网格中存储该区域的平均反照率和自发光颜色。理由:光照查询时不需要512³的颜色细节,64³足以提供平滑的表面颜色。它存储为RGBA8纹理,每个体素代表 (512/64)^3 = 8^3 个子体素的平均色。

位打包细节
每个 uint32 包含64位(4³),每个位代表一个子体素是否被几何体覆盖。位顺序一般按Z/Y/X递进。例如,对于一个体素坐标 ,其内部子体素 对应的位索引为 $x + y4 + z16$。这样可以通过原子OR操作安全地并行写入。

于是,我们得到了一个在亚体素精度上表达几何体的二进制体素场,为生成精确距离场打下基础。

下图是位打包的示意图:一个 uint32 存储一个 4×4×4 子立方体的64个位置占用信息。

128³ 体素单元 4×4×4 子立方体 共 64 个子体素 32 2 x uint32_t (64 bits) ... 0 1 1 0 1 0 0 1 每个 bit 代表一个子体素是否为实体

渲染技巧

为了高效填充位场,Godot使用了几何着色器或多视图渲染:对每个三角形,根据其朝向选择投影到哪个2D平面(即选择对应的轴对齐投影),然后光栅化到 512² 的2D切片,再用原子操作写入3D位。这样可以一次性覆盖一个方向上的所有切片,效率很高。

3.2 Jump Flood 距离场

拥有一个高精度实体位场后,需要将其转换为有符号距离场(存储每个空体素到最近实体表面的距离)。这里采用Jump Flood 算法,它是一种基于传播的并行算法,特别适合GPU。

算法核心思想
每个体素存储一个指向“最近实体子体素”的坐标(称为种子)。初始只有实体子体素养心提供有效种子。然后通过多次迭代,步长从网格边长的一半开始,每次减半,每个体素检查步长距离处的邻居的种子,如果更近就采纳。经过对数次迭代后,每个体素都收敛到全局最近实体的种子。

Jump Flood 迭代 Step = 64 Step = 32 Step = 16 步长递减采样邻居的种子 最终收敛并计算精确 SDF

具体步骤128³网格):

  1. 初始化种子

    1
    2
    3
    4
    5
    6
    7
    ivec3 seed;
    if (hasSolidBit) {
    // 实体子体素养心坐标,子体素大小为 1/4,所以实体坐标为 (x*4 + 1.5, y*4 + 1.5, z*4 + 1.5)
    seed = ivec3(x*4 + 1.5, y*4 + 1.5, z*4 + 1.5);
    } else {
    seed = ivec3(INT_MAX, INT_MAX, INT_MAX); // 无穷远
    }
  2. Jump Flood 迭代
    步长 step64 开始,每次除以2,直到 1。对于每个体素 ,查询周围偏移量为 的邻居。通常取采样模式为6轴(±x, ±y, ±z)或更复杂的星型模式。这里给出一个简化但有效的伪代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    int step = 64;
    while (step > 0) {
    ivec3 best_seed = mySeed;
    float best_dist = length(vec3(mySeed - currentCoord));
    // 采样所有可能方向
    for (int dx = -1; dx <= 1; dx++) {
    for (int dy = -1; dy <= 1; dy++) {
    for (int dz = -1; dz <= 1; dz++) {
    ivec3 nCoord = currentCoord + ivec3(dx, dy, dz) * step;
    ivec3 nSeed = loadSeed(nCoord);
    float dist = length(vec3(nSeed - currentCoord));
    if (dist < best_dist) {
    best_dist = dist;
    best_seed = nSeed;
    }
    }
    }
    }
    mySeed = best_seed;
    step >>= 1;
    }

    实际实现会优化采样模式,只检查那些可能提供更近种子的方向,但概念上如此。

  3. 转换为距离
    最终,每个体素的种子是一个亚体素坐标,计算欧几里得距离:

    1
    float distance = length(vec3(mySeed - currentCoord)) * voxelSize;

    距离值存储为16位浮点数。

由于种子来自512³精度位场,得到的距离场能够表达薄至1/4体素的几何体,极大提高了遮蔽探针和球体追踪的精度。需要注意的是,这里生成的是无符号距离场(只存在于空体素),但通过一定的偏置和遮蔽探针,仍然能有效工作。

3.3 遮蔽探针:亚体素精确追踪

遮蔽探针需要知道“从该探针向某个方向看,多远能撞到几何体”。最大距离设为28个体素(约为相邻探针间距的 的两倍,留有安全边界)。对于每个探针的每个方向(5×5像素),执行:

  1. SDF初筛:查SDF距离场(128³),如果当前位置的距离值 > 28,则直接取28为遮蔽距离,因为已超出有效检测范围。
  2. DDA精追踪:否则,在 512³ 实体位场中进行光栅化式的追踪。从探针中心出发,沿方向 前进,步长自适应:
    • 先用SDF给出的距离大步前进,因为SDF保证不会穿墙。
    • 当SDF值小于一个阈值(比如0.5体素)时,切换为精细的DDA步进,每次步进一个子体素(1/4体素),检查所在体素的64位掩码中对应位是否为1。

DDA位检查具体实现
对于当前子体素坐标 subPos,计算包含它的父体素索引 gridCoord 和子体素在64位内的索引 bitIndex。然后读取 solidBits[gridCoord],检查 (solidBits >> bitIndex) & 1。一旦发现被设置,记录精确深度 ,将 写入遮蔽探针纹理。

为了减少噪声,完成所有方向追踪后,对该探针的5×5遮蔽纹理进行3×3的高斯模糊(或简单均值滤波),平滑相邻方向的深度,抑制切比雪夫测试中的突变。

3.4 光照缓冲与光照纹理

光照数据并非直接存在探针里。取而代之,我们构建一个光缓冲区:一个1D数组(StructuredBuffer),包含所有与实体相邻的空体素坐标(即表面下一层的体素)。这些空体素恰好是“可以看到表面”的位置,它们将会被着色以形成出射光。对应的3D光照纹理(RG32U,实际存储为RGBE编码的颜色+各向异性权重)也一并生成,覆盖整个级联区域,但只有缓冲区列表中的那些体素会被计算。

这样将光照计算的范围缩小到仅包括表面附近真正重要的区域,避免了在整个128³空间内计算光照。对于空旷场景,这能节省95%以上的计算量。

RGBE9995与各向异性编码
颜色以共享指数形式存储,9位红色、9位绿色、9位蓝色、5位指数,共32位。各向异性权重占用另一个32位纹理的5位,编码了8个方向的大致概率分布(可能用一组离散方向索引),用于后续探针更精确的方向性采样。

3.5 球体追踪填充辐照度探针

这是整个流程的核心计算,也是SDFGI区别于传统DDGI的关键。我们用Compute Shader为每一个辐照度探针计算其5×5的球面入射光。

工作组分配:每个探针对应一个工作组(25个线程),每个线程处理八面体的一个像素方向。线程组共享内存用于最后的后处理。

每个线程的处理流程(伪代码详细描述):

GLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 线程ID对应5x5像素中的位置
ivec2 pixel = ivec2(gl_LocalInvocationID.xy);
vec2 octCoord = (vec2(pixel) + jitter) / 5.0; // 抖动用于多帧平滑
vec3 rayDir = octahedral_to_direction(octCoord); // 逆八面体映射

float rayDist = 0.0;
int cascade = start_cascade;
bool hit = false;

// 从遮蔽探针获得初始安全步长
float safeDist = occlusionProbe.sample(dirToProbe, probeIdx).mean;
rayDist = safeDist * 0.5; // 半推进
vec3 pos = probeWorldPos + rayDir * rayDist;

// 跨级联球体追踪
while (!hit && cascade < max_cascade) {
float sdf = sampleSDF(cascade, pos);
if (sdf < THRESHOLD) {
hit = true;
break;
}
rayDist += max(sdf, MIN_STEP);
pos = probeWorldPos + rayDir * rayDist;
// 检查是否超出当前级联
if (pos out of cascade bounds) {
// 切换到上一个更粗的级联
pos = convert_to_next_cascade(pos);
cascade++;
}
}

if (hit) {
// 命中几何
vec3 albedo = sampleAlbedo(cascade, pos);
vec3 emissive = sampleEmissive(cascade, pos);
vec3 normal = normalize(gradientSDF(cascade, pos));

// 回退寻找有效光照体素
vec3 lightPos = pos - normal * OFFSET;
int steps = 0;
while (!hasLightData(lightPos) && steps < MAX_STEPS) {
lightPos -= normal * STEP_SIZE;
steps++;
}
vec3 outgoingRadiance = emissive + albedo * loadLightTexture(lightPos);

// 各向异性权重
float anisotropy = loadAnisotropyWeight(lightPos, rayDir);
// 写入共享内存
sharedRadiance[pixel] = outgoingRadiance * anisotropy;
} else {
// 未命中:天空
sharedRadiance[pixel] = skyRadiance(rayDir);
}

groupMemoryBarrier();
barrier();

// 后处理:半球漫反射积分
// 对每个像素方向,预计算的权重与邻居加权平均
float3 irradiance = vec3(0.0);
for (int ny = -1; ny <= 1; ny++) {
for (int nx = -1; nx <= 1; nx++) {
vec2 nOct = (vec2(pixel) + vec2(nx, ny)) / 5.0;
vec3 nDir = octahedral_to_direction(nOct);
float weight = max(0.0, dot(nDir, normal_estimate));
irradiance += sharedRadiance[clamp(pixel+ivec2(nx,ny), 0,4)] * weight;
}
}
irradiance /= totalWeight;

// 时间混合
vec3 oldIrradiance = loadLastFrame(probeIdx, pixel);
irradiance = mix(oldIrradiance, irradiance, alpha);
storeIrradiance(probeIdx, pixel, irradiance);

法线计算:利用SDF梯度,中心差分近似:

1
2
3
4
5
float d = SDF(pos);
float dx = SDF(pos + vec3(epsilon, 0, 0)) - d;
float dy = SDF(pos + vec3(0, epsilon, 0)) - d;
float dz = SDF(pos + vec3(0, 0, epsilon)) - d;
vec3 normal = normalize(vec3(dx, dy, dz));

各向异性:光照纹理中的5位各向异性索引一个预定义的方向集合,权重用于在半球模糊之前调制入射光,使得在粗糙度极低时仍能保留一定的方向性。

半球模糊:近似漫反射积分 ,因为5×5网格较粗糙,通过邻域加权模拟。其中 normal_estimate 可以使用探针对应方向,或者更简单的取八面体中心方向。

时间混合因子 通常为0.15~0.3,取决于探针更新频率和场景动态。

跨级联追踪的过程对于清晰反射尤为关键,下图为射线跨级联追踪示意图:

Cascade N+1 (粗级联) Cascade N (细级联) 探针 细级联内追踪 穿出边界 切换至粗级联继续

4. 渲染时的间接光采样

当实际渲染场景几何时,每个像素需要计算出间接漫反射光照。我们使用三线性探针混合 + 切比雪夫遮蔽测试

4.1 寻找周围8个探针

对于世界坐标点 ,首先找到它在当前级联中的基网格坐标。周围8个探针的偏移由循环生成,计算每个探针的中心位置、到 的向量及距离。探针的三线性权重基于 相对于基网格的局部坐标 ,其中

GLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
for (int i = 0; i < 8; ++i) {
int3 offset = ivec3(i, i >> 1, i >> 2) & ivec3(1, 1, 1); // 0~7的位展开
int3 probeCoord = clamp(baseCoord + offset, 0, probeCounts - 1);
int p = gridCoordToProbeIndex(probeCoord);

float3 probePos = gridCoordToPosition(probeCoord);
float3 probeToPoint = wsPosition - probePos;
float distToProbe = length(probeToPoint);
float3 dirToProbe = -normalize(probeToPoint);

// 三线性权重,考虑表面背向探测方向
float3 trilinear = lerp(1.0 - alpha, alpha, offset);
float weight = trilinear.x * trilinear.y * trilinear.z *
max(0.005, dot(dirToProbe, wsNormal));

// 读取遮蔽探针
float2 occTex = occlusionProbeGrid.Sample(sampler, float4(-dirToProbe, p)).rg;
float mean = occTex.x + bias;
float variance = abs(mean * mean - occTex.y) + varianceBias;

// 切比雪夫可见性权重
float diff = distToProbe - mean;
float chebyshevWeight = variance / (variance + diff * diff);
chebyshevWeight = max(chebyshevWeight * chebyshevWeight - chebyBias, 0.0)
/ (1.0 - chebyBias);

weight *= (distToProbe <= mean) ? 1.0 : chebyshevWeight;
weight = max(0.00001, weight);

sumWeight += weight;
// 实际采样方向是表面法线,因为辐照度场存储的是漫反射光照(已积分)
sumIrradiance += weight * irradianceProbeGrid.Sample(sampler,
float4(wsNormal, p)).rgb;
}

最终漫反射间接光为 的原因是来自DDGI的约定以及辐照度存储格式的归一化。

背向权重 max(0.005, dot(dirToProbe, wsNormal)) 使得如果表面背对探针方向,该探针贡献极小,防止背面不合理的照明。

着色点 P 权重 w_i 通过 8 个角落探针进行三线性插值

4.2 切比雪夫不等式防漏光原理

遮蔽探针存储了每个方向深度的均值 和平方均值 ,由此得方差 。切比雪夫不等式(单边)给出:对于随机变量 (实际深度,非负),当 时,有

这里我们将可见性权重近似为这个概率上界。当着色点 在墙后时,其到探针的距离 远大于探针记录的平均深度 ,因此分子不变而分母增大,权重急剧衰减。例如,若 ,则上界为 ,该探针贡献几乎为零。

为了增加过渡对比度,对计算的权重 做进一步处理:

平方操作使衰减更快,减去偏置可进一步收紧半影,防止过度柔化。默认bias取值约0.1~0.2。

另外,加入 varianceBiasdistanceBias 防止除零和数值不稳定:varianceBias 保证方差不为零,distanceBias 轻微偏移均值以补偿SDF精度误差。

探针 遮挡墙体 着色点 P M = 5 d = 30 w ≈ σ² / (σ² + (d - M)²) w ≈ 4 / (4 + 625) w ≈ 0.006 → 几乎阻断

4.3 级联间混合

如果 处于两个级联的过渡区域(例如第一个级联边界向内一定范围,如0.9~1.0倍级联半径),需要同时计算两个级联的间接光,然后根据混合因子 blend = smoothstep(0.9, 1.0, dist_norm) 线性插值。有时还需要考虑更高一级联。混合因子由着色点到两个级联有效边界的相对距离确定。为了保证一致性,还要注意权重归一化。

多级联采样会使着色器开销略有增加,但确保了远近光照的平滑过渡,不会出现明显的环状边界。


5. 性能优化

如果每一帧都对所有级联的所有切片重新生成距离场、遮蔽探针和辐照度探针,性能将无法接受。SDFGI的精髓在于一套精密的增量更新机制

5.1 级联滚动(Cascade Scrolling)

级联体跟随相机移动。当相机移动导致级联体边界在某个方向上移动超过一个体素时,一条新的切片(3D slab)进入,对应旧区域的另一条切片滚出。由于相机通常不会沿对角线疾速移动,每个时刻往往只有一个轴向的切片更新,因此我们只需重新生成那些新进入的切片区域,而非整个128³域。这被称为“滚动”更新。

例如,相机沿X轴正方向移动,导致最左侧的一片体素被丢弃,最右侧出现一片新体素需要计算。那么我们只需重新光栅化右侧新切片,并合并旧数据。

下图展示了交界处指针合并的逻辑:

相机滚动方向 保留区域 (旧) 新增切片 (新) 交界体素比较区 旧种子 (距离更近,保留) 新种子 (远) dist(新) > dist(旧)

5.1.1 距离场合并

对于新旧区域交界的切片,我们要将新生成的区域与存量数据进行无缝合并:

  • 旧区域保留着Jump Flood最终迭代的种子指针(指向最近实体子体素),新切片也产生了自己的指针。
  • 对于交界附近的每个体素,检查对面区域对应位置的种子指针距离,如果更近,则替换自己的种子。具体做法:在切片边界,新旧区域各保存一份完整的种子纹理。对每个体素,如果 dist(newSeed) < dist(oldSeed),则用新种子替换。
  • 之后,只需在这些交界区域重新运行少数几次Jump Flood迭代(因为跳跃传播会快速扩散新种子),通常只需额外几次步长较小的迭代,就能让交界体素收敛到正确的最近实体。正是因为跳洪水算法局部性弱,这种局部修补十分高效。

伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 交界区域
for each voxel in border_region {
seed_new = loadNewSeed(voxel);
seed_old = loadOldSeed(voxel);
float dist_new = length(vec3(seed_new - voxel));
float dist_old = length(vec3(seed_old - voxel));
if (dist_new < dist_old) {
storeSeed(voxel, seed_new);
}
}
// 额外几轮小步长Jump Flood来传播改变
for (int s = 32; s >= 1; s >>= 1) {
// 仅处理标记为dirty的区域
}

5.1.2 遮蔽探针合并

  • 完全落入新区域的探针,全部重新计算(8个方向像素全追踪)。
  • 边界上的探针,仅重新追踪朝向新切片方向的那部分八面体像素(例如向右偏移的方向),朝向旧区域的方向保持原值不变。具体实现:对每个八面体方向像素,计算其方向向量,如果与新区域有正点积,则重新追踪;否则保留原值。
  • 然后,对这些边界探针执行一次轻量的模糊,融合新旧数据。

这种方向性裁剪极大减少了遮蔽探针的更新工作。

5.1.3 光缓冲区与光照纹理更新

光缓冲区(需要着色的空体素列表)在距离场合并时同步重构:删除出界体素,加入新入界体素。这个操作仅是一个数组重排,开销很低。光照纹理对应的区域被标记为脏,后续球体追踪光照计算时,将只处理标记为脏的光照体素和依赖它们的探针。

5.2 射线缓存

由于多数场景几何是静态的,球体追踪的命中信息(命中级联、命中光照纹理坐标、命中法线)可以跨帧缓存。每个辐照度探针的每个方向像素存储一个缓存结构。每帧只对失效缓存进行重追踪。缓存失效条件:

  • 命中点在当前级联内,且该级联发生了滚动影响到该区域(新切片影响了该体素)。
  • 命中点在上层级联,且射线穿出当前级联的边界侧面因滚动而改变了(即射线方向对应的出口侧面进入新数据)。

这样,静态区域内的大多数射线直接复用上一帧的命中,大幅降低了追踪负载。动态灯光或天空变化时,虽然缓存仍有效,但需要通过脏标记重新计算光照颜色(见下节),不影响命中位置。

5.3 光照脏区域缓存

除了射线命中缓存,还有一套粗粒度(比如 块)的3D脏位纹理。当某个光缓冲区体素因为灯光变化、新几何或天空变化需要重新计算时,它被标记为脏。在辐照度探针更新步骤中,对于每个方向像素,如果其命中点所在光照体素的脏位被设置,则强制重算该方向光照。未脏的命中点直接复用缓存光照。

此外,静态光源(如烘焙灯)的光照可以完全分离到另一张静态光照纹理中,它们只在几何体变化时重新计算脏区域,其他时间完全跳过。这几乎将静态光照的维护成本降为零。

5.4 视锥外降频更新

远离相机视锥体的探针可大幅降低更新频率。例如,根据探针与视锥体的距离,设置跳过因子:视锥内每帧更新,近距离外区域每2帧更新,更远区域每4帧或8帧更新一次。由于这些探针主要影响远处间接光,而远处间接光本身时间累积就慢,降频根本看不出差异。由于相机居中,视锥外探针数量很大,此优化可节省大量GPU时间。

5.5 小结

通过这些策略,SDFGI的重负载计算量被压缩到近似于“仅仅处理相机周围一个薄壳的新内容”。当相机以正常速度移动时,每帧额外开销极低;突然跳跃时会有短暂重计算高峰,但很快恢复。这使得实时GI在低端硬件(如Steam Deck、AMD APU)上也能够流畅运行。


6. 用SDF做反射:从镜面到漫反射

通常GI只解决漫反射,但SDFGI直接将反射也纳入了同样的框架,覆盖全粗糙度范围。这是SDFGI的一大亮点——在探针数据基础上,几乎无额外成本地提供三种反射层级。

alt text

6.1 清晰反射(Sharp Reflections)

对屏幕上的每个像素(通常在延迟渲染的反射pass中):

  1. 从深度缓存重建世界坐标。
  2. 计算反射向量
  3. 找到包含该点的最小级联(即最精细级联),开始追踪。
  4. 利用该级联的距离场进行球体追踪,沿 方向步进,直到命中表面。
  5. 在命中点采样光照纹理,获取出射光,即为清晰反射颜色。还可结合材质粗糙度做微量模糊。

级联跳变问题解决:单个级联追踪时,当像素恰好在级联边界,反射内容可能因分辨率突变而pop。SDFGI在追踪清晰反射时,会在当前级联和上一级联(更粗一级)同时执行球体追踪,取两者中较小的命中距离,并用两个距离的差作为混合权重,融合两者颜色。具体混合:

1
2
3
4
float d0 = traceCascade(cascade0, ray);
float d1 = traceCascade(cascade1, ray);
float blend = smoothstep(0.0, 1.0, (d1 - d0) / someThreshold);
vec3 reflectionColor = mix(color0, color1, blend);

若粗级联命中更近,则更多采用粗级联;否则采用细级联。这极大地平滑了级联过渡。

6.2 中等粗糙反射(Medium Roughness)

回顾3.5节,辐照度探针在工作组屏障之前,有一个“未滤波”的5×5辐射率数据(即方向辐射率,未经半球积分)。系统会将其存入另一个纹理数组——反射探针场,并进行时间累积平均(不加半球模糊)。它存留了方向性,因此能产生比辐照度更清晰的反射。

对于中等粗糙度材质,采样该反射探针场:取反射向量方向,在该方向对应的探针中按八探针方法插值获取辐射率。由于反射场的时间累积,它相当于在多帧内对出射方向分布进行了平均,自然实现了可变粗糙度的效果。粗糙度约0.2~0.5时效果最佳。

6.3 完全粗糙反射(Rough Reflections)

也就是纯粹的漫反射镜面:直接用反射向量去采样辐照度场(而不是反射场)。因为辐照度场已经进行了漫反射半球积分,其存储的辐照度值已经相当于在所有入射方向上按余弦加权平均,因此无论取哪个方向采样(只要用反射向量索引探针的八面体),返回的值都是该位置的近似漫反射光。这提供了几乎免费的完全粗糙反射。传统光线追踪需要成百上千条光线逼近粗糙反射,而SDFGI利用探针将这一积分提前完成了。

三个粗糙度级别通过线性插值融合:粗糙度0使用清晰追踪,粗糙度0.5混合清晰追踪和反射场,粗糙度1使用辐照度场。覆盖了从镜面到Lambert的全频谱。配合屏幕空间反射(SSR)填补小细节,能产生非常令人信服的反射效果。


7. 局限与未来展望

7.1 当前的主要局限

  • 动态物体不参与光能传递:只能接收光,不能在环境中产生二次照明。房间里移动一个亮红色箱子并不会把墙壁染红。这限制了动态光照的真实感。
  • 小发射体噪声:由于探针的5×5分辨率有限,手电筒、火苗等小光源可能在GI中出现闪烁或颗粒感,因为探针可能未能稳定采样到该小发射体。
  • 网格分辨率限制:体素化会丢失细小几何体(如铁索、植被叶片),这些丢失的几何体无法遮挡或反弹光线,导致间接光不准确。
  • 陡峭角度的背面漏光:虽然切比雪夫权重能极大抑制漏光,但在某些极端观察角度或探针恰好位于薄墙附近时,可能出现轻微的渗色。这需要通过bias调整和屏幕空间遮蔽来掩盖。

7.2 未来的改进方向

Juan Linietsky 在讲义中提到了若干改进计划:

  • 个体SDF与卡片:类似Lumen的方式,为动态物体生成局部距离场,注入到探针更新射线中,让动态物体也能参与GI(至少作为遮挡或光源)。
  • 动态遮挡盒子:支持门、移动墙体等作为简单的光线遮挡物,允许动态场景对GI做出遮挡反应。这可以用轴对齐包围盒近似,不需要完整SDF。
  • 探针自适应偏移:当动态物体靠近探针时,探针位置可临时偏移,避免突然变暗;或采用淡入淡出遮挡强度,防止光照突变。
  • 级联剔除动态体:为了性能,在上层级联中完全关闭动态物体的影响。

这些改进将使SDFGI更接近一个完备的实时GI解决方案,同时保持对低端硬件的友好性。Godot社区也在积极贡献相关PR。


8. 实践建议:调参与诊断

如果你在Godot项目中使用SDFGI(在Environment节点中勾选SDFGI),下面几个参数值得深入理解:

  • Cascade Count / Cell Size:Cell Size 决定最精细级联的体素大小(米)。较小的值意味着近处精度更高,但覆盖范围变小,需要更多级联维持远距离覆盖。建议根据项目尺度调整:室内场景可设0.51m,室外可设24m。级联总数越大,远处间接光越好,但内存和更新时间增加。
  • Bias / Chebyshev Bias:漏光时增加,但过大会导致应该照亮的区域变暗(探针被错误地判定为遮挡)。Min Occupied Voxel 参数也很关键,可避免细小物体产生漏光。
  • Energy / Normal Bias:控制间接光强度和方向性。Normal Bias 影响探针插值时表面法线的影响程度。
  • Temporal Blending 系数:控制探针收敛速度,增大可更快响应灯光变化,但可能引入瞬态噪点。
  • Reflection Max Distance / Resolution:影响清晰反射追踪距离和精度。降低可提升性能。
  • Use Lightmap with SDFGI:可与LightmapGI混合,Lightmap提供静态高精度GI,SDFGI处理动态物体和光源,互不干扰。

诊断技巧

  • 打开 Debug Draw -> SDFGI Probes 可视化探针颜色和遮蔽。若墙壁后方探针亮色,说明漏光。
  • 查看 SDFGI Cascade 范围,了解当前级联覆盖区。
  • 如果出现块状闪烁,可能是更新频率不足,可尝试减少 Min Occupied Voxel 或增加更新预算。
  • 在低端硬件上,可适当减少级联数,关闭清晰反射,或降低探针更新速率。

通过这些调参,你可以在不同硬件和场景规模下获得最佳的SDFGI表现。


9. 结语

SDFGI是面向真实世界实时渲染约束的一次精彩设计。它勇敢地放弃了硬件光线追踪的“标准答案”,转而用体素化+距离场+球体追踪重新实现了DDGI的核心流程。25线程的八面体探针、亚体素位场、切比雪夫遮挡、增量级联合并……这些细节组成了一套高效、易用、跨硬件的全局光照系统。

理解SDFGI不仅能让我们用好Godot,更能深刻体会到现代实时渲染中数据结构与增量更新的重要性。这套方案的很多思想,如超采样位场、Jump Flood传播、射线缓存,都可以迁移到其他GI或物理模拟系统中。