做实时渲染的人应该都有一个共识:想要画面光感真实、暗部有层次,光靠直接光照加一两个屏幕空间效果远远不够,全局光照才是决定画面“高级感”的核心。RTXGI里的DDGI(Dynamic Diffuse Global Illumination,动态漫反射全局光照)是当前实时光线追踪GI方案里少有的、能真正跑进实际项目并且效果还很稳的一套技术。这篇分享我打算从DDGI的原理、数据布局、GPU实现细节,到我在接入过程中踩过的坑和调参经验,完整梳理一遍。适合正在做实时GI的引擎程序员、图形渲染方向的学生,以及准备在自研引擎里接入动态全局光照的团队。
1. 全局光照问题的本质与DDGI的定位取舍
1.1 为什么实时漫反射GI这么难搞
漫反射GI的难点不在“算不算得出来”,而在“能不能实时算完还不崩”。直接光照是一次着色,光线路径可见即可;但漫反射GI要求每个像素收集来自四面八方、经过无数次弹射的光能量。
传统做法是烘焙光照贴图,把静态物体的间接光提前算好。这招在静态场景里效果很好,可一旦场景里有动态物体、动态光源、可破坏场景,烘焙就露馅了:动态物体会把静态漏光问题放大,光源变了光照贴图还不更新,玩家一眼就能看出“这地方光没变”。
实时方案里,屏幕空间GI只能利用屏幕内已经亮起来的像素做反射采集,信息不全、容易漏光;LPV把radiance注入体素再做传播,内存和漏光控制都比较头疼;光子映射类方案会有噪声和带宽问题。DDGI选择了一条很务实的技术路线:用少量携带方向和深度信息的探针,配合实时光线追踪,迭代出场景的漫反射辐射场。
1.2 DDGI在GI技术谱系里的位置
如果按“从便宜到昂贵”给实时GI方案排个序,大概是:烘焙GI最简单也最弱,然后是SSGI、LPV这类中档方案,再往上是RSM、VXGI,最后才是DDGI和路径追踪降噪。
DDGI属于“半动态”方案:它不要求每帧重算整个场景的光照,而是维护一个可更新的探针网格,让间接光通过多帧迭代慢慢收敛到正确结果。这样做有几个直接好处:
- 探针数量远少于像素数量,完整重建成本可控;
- 光线追踪只发生在探针处,而不是每个像素,适合当前硬件性能;
- 场景变化后探针会逐步响应,天然支持Relighting(重新打光)和动态物体。
它最适合的场景是“漫反射为主、中低频光照变化、需要动态响应”的3D场景。至于镜面高光、焦散、极高频的接触阴影,它不是为这个设计的。
2. DDGI核心原理:探针、双球谐与深度信息
2.1 探针布局:从规则网格到自适应层级
DDGI的基础是“辐射度探针”。探针本质上是一个记录周围辐照度分布的采样点,最简单的布局就是像体素一样在场景里铺满规则网格。
但规则网格有个致命问题:场景空旷处不需要那么密,复杂几何区域又不够密。实践里DDGI采用了层级式探针布局,类似视锥体或包围盒层级,离相机近的区域探针密度更高。层级之间通过距离判断选取采样级数,插值时跨级之间也要做过渡,否则会出现明显的“级间跳变”。
我实际用过的基础配置是:第一级探针网格覆盖相机周围约8x8x8的范围,间距约1.5米,第二级间距4到6米,覆盖场景主要区域。这个密度下,单帧光追开销稳定在毫秒级以内,漫反射信息也基本够用。探针数量是DDGI的性能命脉:探针太多,每帧更新成本指数上升;探针太少,插值结果会“糊成一片”。
2.2 双球谐:用9个系数装下整个天空的漫反射光
探针需要记录“我这位置朝任意方向看,收到的漫反射光是多少”。如果直接存方向-颜色映射,内存和更新开销都吃不消。DDGI选择了球谐(SH)编码。
球谐是定义在球面上的一组正交基函数。漫反射的辐照度变化非常平滑,用二阶球谐(9个系数)已经能还原约95%以上的漫反射信息。这也是为什么DDGI可以把每个探针的核心数据压缩成9个RGB系数,也就是一张RGBA16F纹理里的一行正好存下。
双球谐这个说法的困惑点在于:DDGI里SH系数实际有两份,一份是常规的辐照度SH,另一份是深度信息对应的球面距离。每次光追采样产生一个新方向上的辐射亮度,就把它投影到SH系数上,累加进探针。更新时不是覆盖而是混合,让辐射场随时间收敛。
这里有个很重要的工程细节:SH编码在低频漫反射上效果好,但遇到极亮的窄光源或高对比边缘,9个系数的重建会产生“振铃”伪影,也就是光斑周围出现暗色环。实际项目中遇到这种情况,我一般会把光源数量控制住,或者配合TAA让它不那么显眼。
2.3 一次光追更新过程:从发射到写入
DDGI每帧只重新计算一部分探针,其他探针沿用上一帧累积结果。每帧做的更新大致分三步。
一是光线发射。每个探针按预定义的分布(通常是均匀球面或余弦加权半球)发射若干条光线,例如每探针64条或128条。光线数量直接决定收敛速度和噪声水平,64条是性能与质量之间的常用平衡点。
二是结果累积。每条光线命中场景表面后,取得该点的直接光照颜色和发射颜色,组合成radiance,再作为这一方向上的入射辐射亮度投影到探针的SH系数上。注意这里取的是命中点的“出射辐射”,而不是继续弹射,因为多弹射是靠多帧迭代完成的。
三是hysteresis混合。新采样结果不会直接覆盖旧值,而是做指数移动平均:
newCoeff = lerp(oldCoeff, sampledCoeff, alpha)alpha一般取0.05到0.2之间。这个参数是整个DDGI里最微妙的东西:alpha太小,探针响应变化太慢,动态物体后面会拖“鬼影”;alpha太大,噪声收敛不下来,画面一直在闪。
2.4 深度信息:修正偏移与漏光的关键
探针只记录SH颜色还不够,DDGI在存储里还包含一个“平均深度”或球面距离信息。这个深度的作用很关键:插值采样时,如果目标点与探针之间的实际距离明显大于探针记录的深度,说明两者之间可能存在遮挡,就应该降低这个探针的权重,抑制漏光。
这就是DDGI在漏光问题上比普通辐照度体素强的原因。每帧更新后探针记录的是“从探针向外看,各方向上的遮挡距离”,采样时再结合世界坐标算一个可见性权重。这样墙后面、地板下面、阴影角落在插值时不容易被错误照亮。
深度信息的舍入误差是很多人忽略的坑。如果你用RGBA8来存,远处探针的深度误差很大,漏光会变得很不可控。我建议至少用RGB16F存SH,用R16F存深度,否则r11g11b10看起来省内存,但深度擦边情况多到你想骂人。
3. 实现一个可落地的DDGI模块
3.1 数据布局与GPU缓冲设计
DDGI在GPU上的核心数据是三类纹理:
| 数据 | 格式 | 说明 |
|---|---|---|
| SH辐照度 | RGBA16F,宽=探针数,高=9(或12) | 每行一个探针,每像素存一个SH系数带的RGB |
| 深度 | R16F,宽=探针数,高=最大光追方向数 | 每个探针每个方向一个平均距离 |
| 回退数据 | 低分辨率SH副本 | 用于光线无效或探针未更新时回退到上一级 |
写数据时不能多线程同时写同一个探针,因此比较稳的做法是每个探针一条dispatch,在CS里循环处理该探针的128条采样方向,结果先累积在LDS(group shared memory),最后再写回全局纹理。用LDS同时做读写合并,能省掉大量原子操作。
因为SH系数是浮点且混合是线性的,写回时无需额外原子,但必须保证“读旧值、混合新值、写回”这个过程对同一帧内不同探针是互不干扰的,所以每帧每个探针只允许一个线程组更新。时间切片时要格外注意边界情况:两个线程组同时处理同一个探针的任何可能都要从数据结构层面排除。
3.2 探针坐标哈希与更新调度
如果探针是动态的,比如跟随相机移动或检测到场景变化而新增,就需要一个从世界坐标到探针索引的映射。规则网格可以简单地用偏移量除间距求整得到格子坐标,再线性化。
非规则或层级探针就麻烦点了,我通常会维护一张哈希表:世界坐标对探针间距取模后哈希,槽位里存探针索引。每帧的更新队列从哈希表里取出n个探针做光追,保证整体更新负载平稳,不会出现某一帧突然要算几百个探针、下一帧光追空闲的情况。
这里分享一个实战小技巧:探针更新不要做“全量刷新”,而是按“从旧到新”的LRU策略。因为相机附近探针每帧都在变,远处的探针可以降低更新频率。LRU队列用GPU侧atomic计数器维护即可,CPU无需介入。这样可以省掉大量重采样开销,画质几乎无损。
3.3 采样与三线插值:从一个点到周围探针
渲染最终像素时,场景中每个着色点需要从周围探针插值出间接漫反射。规则的探针网格在没有跨级时,使用三线插值:找到包围该点的8个探针,分别计算权重,再按xyz三个维度线性混合。
这个插值最大的敌人是“探针穿墙”。一个点被插值到墙壁另一侧的探针,就会收到不该收到的光照。所以插值前会做权重修正:
- 计算采样点到探针的方向向量;
- 读取探针在该方向上的平均深度;
- 如果采样点与探针的距离接近或超过记录深度,降低该探针权重;
- 对权重做幂运算,通常是平方或立方,保留近距离强权重的探针;
- 最后归一化到总权重为1。
实际调试中最常见的漏光现象,就是某个探针记录的深度太小(比如飘在地板上方),把墙另一边的光带过来了。遇到这种问题,第一反应应该是检查深度纹素的单位统一,很多漏光其实是深度算成了毫米,插值权重直接崩了。
3.4 背光面探针更新策略
动态物体背面、屋顶上方、地板下方这些位置的探针,光线方向朝场景内部发射才能采样到有效光照。但有的探针位置天生受限,比如嵌在天花板里的探针,它将来的采样结果完全不可靠。
DDGI的做法是:更新探针时,以探针所在位置为起点发射光线,光线命中的是“探针可见的表面”,也就是该探针能“看到”的环境。如果探针在墙体内,实际它看不见任何表面,更新结果全是自遮挡,这时SH系数会越迭代越暗。
工程上我一般会在探针放置阶段加一个“有效性检测”:发射少量测试光线,统计命中率,命中率过低就直接标记为invalid,采样时跳过。这个检测可以每帧只做一次,成本比更新整套探针低得多。如果场景是动态挖洞的,这个检测就必须跟随探针更新周期执行,否则挖洞后残留的invalid探针会变成一个个暗点。
4. 性能分析与参数调优实录
4.1 性能预算:光追数量、探针数量、回退策略
我们以一个20x20x10的探针网格为例,每个探针每帧发射64条光线。排除重复命中后的有效采样约120万到200万条。这些光线如果都走完整的TraceRay,在桌面级GPU上约消耗0.5到1.5毫秒,移动端就别想了,这个量级短期跑不动。
性能优化的方向不是单纯堆硬件,而是做预算分配:
- 探针每帧全量更新改成时间切片,分4帧轮转,开销立减75%;
- 光线行程长度限制,漫反射光在真实世界弹射衰减很快,设个最大距离10米足够;
- 第二级探针的光线数量缩半,因为低频光照不需要太高的角分辨率;
- 回退探针用较低分辨率SH,远处不需要细节。
我调优过的项目里,DDGI的GPU开销最好控制在2毫秒以内,超过就得砍光线数量或者探针密度。除非你的场景是展示型Demo,否则2毫秒预算内的DDGI完全够用。
4.2 三大质量参数调节经验
hysteresis alpha。这是质量最敏感的参数。我建议先固定0.1,观察场景光照突变时的响应速度,再逐渐调低到0.04,同时观察噪声是否明显。调到0.04后如果发现暗处红色噪点,说明采集噪声压不下去,再加回0.08。这个参数没有绝对正确答案,完全看项目接受度。
偏移修正强度。漏光收敛困难时,不要直接拉权重修正,先检查深度精度。如果深度目标是接近“表面距离均值”,权重修正公式可以用:
weight = pow(saturate(recordedDepth / actualDistance), power)power取2到4。取高了容易造成过渡生硬,黑暗墙角出现“墨迹”般的黑斑。这个“墨迹”是最难排查的伪影,因为它看起来像贴图问题,实际上是权重运算的数值精度问题。
探针密度。规则网格下的探针间距不能让场景里的高频几何信息“钻空子”。间距2米以上时,小于半米的物体无法被正确捕捉,它们要么被忽略,要么产生光照抖动。要么换自适应布局,要么在光照敏感区加密,二选一。
4.3 Relighting与动态光源切换的应对
如果项目里有“白天/黑夜”切换,或动态调节探照灯颜色,DDGI需要额外设计。
光照变化时,SH系数会经历一个消散过程,旧值逐渐被新值替代。这个过程在hysteresis为0.1时约需30帧才完全切换,玩家会看到场景由旧光照“漂移”到新光照。为了体验更好,我一般会引入一个“全局光照强度”参数,在Relighting前清零所有探针的SH缓存,然后以相对low的hysteresis快速重建,重建完成后再切回正常值。
极端情况下还可以做“双缓冲探针”:一套旧数据,一套新数据,Relighting瞬间直接切换指针。切换会产生一次轻微光照跳变,但比30帧漂移干净得多。代价是显存翻倍,看具体需求取舍。
5. 常见问题与排查技巧实录
5.1 漏光、黑斑、闪烁:三类现象的对症排查
漏光是最常见的。排查顺序是:先看探针网格在问题位置是否有穿墙,再看深度纹素的精度和单位,最后调偏移修正power。如果是穿墙导致的,调整探针布局或标记invalid比调权重更有效。
黑斑也叫密闭黑暗伪影,通常是某个方向上的SH系数因为光线少而欠收敛,或者权重修正把正常探针的贡献压没了。先用大密度探针区分是收敛问题还是权重问题:大密度下黑斑消失,说明原密度不够;不消失,就是权重或深度有问题。
闪烁基本都出现在动态几何边缘。原因一般是探针更新与新物体移动不同步:物体刚移开,探针还是旧阴影;物体刚移入,探针还没来得及产生新阴影。这时需要降低hysteresis让探针响应更快,或者把动态物体单独处理,不参与探针采样。
我把它们整理成一张速查表:
| 现象 | 首要嫌疑 | 排查手段 | 快速缓解 |
|---|---|---|---|
| 墙体漏光 | 探针穿墙/深度不准 | 可视化探针网格 | 提高偏移修正power |
| 墙角黑斑 | 权重过度修正 | 关闭深度修正测试 | 降power或加探针密度 |
| 动态物体闪烁 | hysteresis过大 | 单帧步进观察 | 降alpha至0.04~0.06 |
| 整体噪点 | 光线数不足 | 提高光线数到128 | 先降分辨率,再提数量 |
| 光照响应迟滞 | 时间切片更新慢 | 检查LRU队列 | 提高近品探针更新频率 |
5.2 可视化与调试:Nsight Graphics的独门用法
调试DDGI时,最有效的不是看最终画面,而是把中间数据直接输出。我会在Nsight Graphics里添加着色器dump,把探针SH纹理、深度纹理、插值权重分别导出成图像。
你能直接看到的东西包括:探针SH的RGB通道颜色块、深度的伪彩色、最终插值时哪些探针权重偏高。有一次排查漏光,我就是导出深度纹理后发现某个探针的深度值明显小于应有距离,顺着查找到该探针正好悬在墙体中央,深度只记录了墙体厚度,而不是到外面。这种问题如果不看中间数据,在最终画面里猜几个月也猜不出来。
如果项目不依赖Nsight Graphics,也可以在代码里临时加一个“可视化模式”,按探针索引对应到屏幕网格区域,直接渲染出探针SH重建后的图像。这个模式建议从第一天接DDGI就带上,调试效率提升一倍。
5.3 哪些场景不适合DDGI
最后说点劝退的。以下情况我建议慎重选DDGI:
- 高频镜面反射需求大的场景,DDGI只做漫反射,镜面必须另配反射方案;
- 大尺度极端焦散场景,光追的采样数量根本不足以稳定焦散;
- 纯2D或贴图平面为主的视觉风格,DDGI是三维空间技术,收益很小;
- 极低配置的移动平台,硬件光追都悬,只能走软件光追,这时候DDGI的收益会被性能代价完全吃掉。
但话又说回来,在一套支持光追的桌面级渲染器里,把DDGI作为间接漫反射的底层方案,再叠加一套屏幕空间反射和高光方案,整体能很轻松地达到“照片级”的错觉。这种组合拳比单纯上一套路径追踪降噪在硬件成本和稳定性上都可靠得多。
我个人折腾DDGI这么久,最大的体会是:这套技术真正的难点不在数学,而在工程整合。探针布局、更新调度、深度精度、参数平衡,每一步都有坑。但只要你把中间数据可视化做好,把这些坑一个个趟平,实时全局光照其实没有想象中那么玄乎。它不过是把“方向+距离”这两个信息用聪明的方式存起来,再用时间和硬件把真实性换出来。后续如果你想把DDGI扩展到动态Open World、多级探针联动,或者整合进体积云光照,这些经验完全能继续复用。