RE Engine实时路径追踪实践:G-Buffer、降噪与闭源引擎改造
说真的第一次接到“在RE Engine上为《识质存在》和《生化危机:复仇》做实时路径追踪”这个需求时我第一反应是疯狂摇头。RE Engine虽然是CAPCOM的看家引擎但从本质上说它是一款为硬件光栅化做了大量优化的封闭商业引擎内部着色器经过多层混合编译资源绑定方式极度自成一派。想要把整套渲染路径替换成做路径追踪光是把G-Buffer和场景几何信息从引擎手里完整掏出来就是一场硬仗。可项目偏偏要的是电影级资产实时预览镜面反射要带清晰结构漫反射要能看到空间遮罩阴影要软层次要够。这让我最终选择了一条揉合了帧分析、API拦截、自定义光追着色器和一套成熟降噪链路的实现方案。如果你也想在某个商业引擎里塞进实时路径追踪或者单纯想知道这条路线有多少坑这篇东西应该能帮你省下不少时间。1. 需求拆解往RE Engine里塞路径追踪到底在解决什么问题1.1 两款作品两种完全不同的渲染诉求《识质存在》是一个合作中的交互式写实项目目标是给导演和美术提供一个“接近最终成片”的实时预览环境。导演希望在镜头调度的时候画面里的光照逻辑是可信的窗户外面打进来的光能在地板上拉出长影子人物站到暗处时衣服和皮肤的暗部不会死黑一片金属道具上的反射能随着镜头移动自然流淌。这些需求靠传统光栅化的静态光照方案很难满足因为场景里的灯光和物体是动态的美术没有时间逐帧去手工摆放反射探针和补光。《生化危机:复仇》这边不太一样我们拿到的是CG电影资产原本是在离线渲染器里一帧一帧算出来的。把这类资产搬进RE Engine做实时预演最大的难点在于电影资产的材质信息极度丰富很多表面是次表面散射和复杂多层高光而RE Engine默认的实时材质管线根本表达不出那种质感。如果只是用普通PBR采样再开个屏幕空间反射画面会显得“很游戏”不够“电影”。所以这个项目需要路径追踪级别的一手光照信息而不是靠大量近似堆出来的结果。两个项目碰在一起反而把需求边界拉开了一个要动态实时交互一个要静态画面精度。最终方案的每项参数都是在这两个目标的拉扯里定下来的。1.2 光栅化管线做不到的事为什么路径追踪能轻易做到传统延迟渲染管线里间接光通常靠SSAO、SSR、反射探针、体积光照这些零散手段去模拟。SSR只能在屏幕里看见的东西上找反射镜头一转地板上的金属反射就断了SSAO只能给一个接触阴影的近似没法表达“头顶那片天花板把环境光挡住了”这种大尺度的遮蔽关系反射探针更不用说数量一多就爆内存放得稀疏又会在物体运动时产生明显的“探针切换”感。路径追踪不一样。它的核心思路是蒙特卡洛积分对每个像素往半球方向发射若干条射线让这些射线在场景里与几何体求交、被材质吸收、继续反弹最后把所有射线的贡献加权平均。好处是直接光、间接光、AO、反射、焦散这些效果在同一个数学框架下被统一解决不需要像光栅化那样为每种效果单独造一个近似算法。只要场景加速结构建得好材质参数送得对结果天然就带有全局光照的一致性。用最通俗的比喻光栅化有点像拿着投影仪往墙上打影像墙后面是什么你完全不知道路径追踪则像是真的往屋子里扔了几万颗弹珠让弹珠去撞墙、撞家具、反弹回来告诉你每个角落里到底能接收到多少光。这个物理上的真实性正是电影资产预览最看重的东西。1.3 三条技术路线最后为什么选了“混合式路径追踪”想在一个闭源商业引擎里加入实时光线追踪摆在桌面上的主要路数有三条。第一条在引擎内部直接改渲染器。这是最“正统”的做法但RE Engine的C源码和着色器源码都拿不到自己和引擎做深度绑定改动的周期是以季度计的基本不现实。第二条完全走API层接管把引擎当成一个纯粹的“场景数据发射器”拦截它所有的draw call在帧里重建出完整的场景几何然后用自研的路径追踪器代替它完成全部绘制。NVIDIA开源的RTX Remix大体就是这条路。问题是RE Engine在PC上主要走DX12资源绑定、Barrier管理、PSO编译都非常复杂想稳定解析所有draw call并还原场景工作量巨大等做完项目早就黄了。第三条混合式路径追踪让引擎继续用光栅化跑主场景保留它的G-Buffer和相机输出我们在帧尾注入一个自定义Pass读取引擎渲染出来的深度、法线、基础色、粗糙度、金属度再结合一个我们自己构建的简化场景加速结构用Compute Shader补上路径追踪级别的反射、间接光照和软阴影最后合成回帧缓冲。这条方案改动小对引擎版本变化也相对耐受还能保证实时性能。三条路线的对比我整理成了表。技术路线实现成本画面质量实时性能对闭源引擎的适应性引擎内部改造极高最高受引擎优化限制几乎不可能API层完全接管极高高中低困难工作量大G-Buffer混合路径追踪中高良好推荐我们最终选了第三条。这个决定不是因为它最好而是因为它是在现有条件下唯一能落地的“最优解”。2. 环境准备逆向引擎前先把这些工具凑齐2.1 RenderDoc负责找资源NSight负责看性能面对一个不开放的引擎第一件事永远是搞清楚“它每一帧到底画了什么”。我的做法是先用RenderDoc抓一帧把整个帧的GPU资源列表过一遍。RenderDoc最适合做资源语义反推。RE Engine的G-Buffer通常由几张RenderTarget组成深度、基础色、法线、材质属性。它们在不同版本里的名字和排序并不固定有时候叫GBufferA、GBufferB有时候直接用一些不明所以的名字。RenderDoc的好处是可以把任意RT内容导出成EXR或者PNG然后放到Blender里对照着像素看一点点逆推每一层存的是什么。NSight Graphics的活儿是在性能层面。当我加了自定义路径追踪Pass之后整个帧的GPU时间分布会被彻底打乱。NSight能告诉我哪些Pass是真正的瓶颈SM占用率是多少波前是否打满了L2命中也还正常。我印象很深的一次是路径追踪Pass开销没想象中大反而是我为了传输G-Buffer加的几个CopyResource占了一大截带宽这在NSight里一眼就能看出来。另外PIX也要装。RE Engine走DX12的时候PIX对着色器调试非常友好。它能把DXIL反汇编成可读性比较高的代码配合RenderDoc能定位很多飘在空中的Shader错误。2.2 注入方案怎么选DLL代理、Present Hook还是完全接管闭源引擎加Pass绕不开的课题是“怎么把我的代码塞进它的帧时序里”。我试过几种注入方式各有利弊。DLL代理Proxy DLL是最稳的起步方案。引擎要加载dxgi.dll我们就放一个同名DLL在可执行文件目录下把原DLL改名成dxgi_org.dll然后由我们的代理DLL转发所有函数调用。这样引擎完全不知道发生了什么我们只需要在CreateSwapChain返回后拿到IDXGISwapChain指针然后在Present被调用前插入自己的渲染逻辑。Present Hook是更轻量的一种形态。在DX11或者DX12里Present是CPU侧必然调用的函数我们只需要在SwapChain的虚函数表里替换掉Present指针就能在每次帧提交前拿到控制权。不过DirectX12的Present虚表结构不公开直接用偏移量去找Present指针有点脆弱一旦驱动更新或者应用层封装变化可能就挂掉。所以我在工程里做了两层保险优先用代理DLL主动记录接口指针拿不到的时候再退回到修改虚函数表。至于“完全接管”我前面说过它对RE Engine这种高复杂度引擎太重前期验证可以做个小Demo生产项目里不建议。2.3 两套资产的管线摸底结果在正式写路径追踪Pass之前我花了不少时间把两个项目的资产状态摸清楚。这一步直接决定了后续所有参数设置。《识质存在》这边的问题集中在动态元素上。场景里有大量半透明的特效粒子、刀光、闪烁的灯牌。半透明物体通常不写入延迟渲染的G-Buffer所以路径追踪Pass读不到这些物体这会导致“反射里缺了半透明物体”的奇怪现象。我们最后的处理是专门为半透明物体开了个额外的颜色缓冲在合成阶段用深度排序再叠上去。《生化危机:复仇》的资产风格完全不同。它的问题不是动态而是精度太高。电影资产动辄就是几百万面的高模直接拿来做实时路径追踪的加速结构每帧构建BLAS的代价能直接把GPU压垮。我们做了两步第一步把大面积静态背景减面到30万面以内第二步把角色和主要道具保留到10万面左右。砍完以后画面细节依然足够但构建加速结构的耗时大幅下降。RE Engine本身还有一个特点要注意它不同项目之间G-Buffer的布局是有差异的。不要指望在《识质存在》里确认了通道语义换到《生化危机:复仇》就一定能直接读取。每一次都要老老实实抓帧、导出、对照。3. 核心实现从G-Buffer拿到最终画面的一整条链路3.1 第一步把RE Engine的G-Buffer完整解析出来这是整条链路里最枯燥也最关键的环节。解析G-Buffer的流程基本是这样的用RenderDoc抓取一帧按资源名称定位到G-Buffer相关RT。RE Engine常见的布局里深度往往是R32_FLOAT或者D32_FLOAT_S8法线大概率是R16G16B16A16_FLOAT的视图空间法线基础色和粗糙度金属度通常分摊在若干张R8G8B8A8_UNORM里。但不要相信这些“通常”我的做法是用RenderDoc把候选RT全部导出成EXR在Blender的Shader编辑器里用MixRGB逐层去看哪个通道和漫反射颜色对得上哪个通道看起来像粗糙度哪个通道是金属度的二值mask。把每个通道的“身份”确认完才能拿来当输入。确认之后我会在注入的Compute Shader里用SRV把这些RT绑定进来。读取的时候要注意坐标系转换RE Engine的法线可能是视图空间的而路径追踪需要在世界空间里计算入射和反射方向所以读取之后要乘上相机视图矩阵的逆矩阵才能得到世界空间法线。深度也要做一步转换把非线性的深度值还原成到相机的实际距离再用视锥信息重建出世界坐标。这一步最容易出问题的是引擎自己会在后处理阶段把颜色从线性空间转到sRGB。如果你在所有后处理之后再去读颜色缓冲拿到的颜色已经被tone map过了用它当路径追踪的输入最后算出来必定是灰蒙蒙的。所以我强烈建议路径追踪Pass的位置尽量安排在引擎的tone map之前直接去读它的中间RT。如果实在做不到就必须在Shader里加一个Decode步骤把sRGB转回线性。3.2 第二步构建场景加速结构让射线有东西可打路径追踪的射线不能只对着天空打它必须和场景里的几何体求交。可问题是我拿不到RE Engine内部的顶点缓冲也没法实时把引擎正在draw的那些网格全部偷出来。所以我的做法是给路径追踪器单独准备一份“光追场景库”。这个场景库的构建分成离线阶段和运行时阶段。离线阶段我从Maya或者引擎里把项目最主要的静态物体导出来以glTF或者自定义二进制格式存储。导出的时候做一次减面保留UV和材质分组即可因为路径追踪主要需要的是几何遮挡和材质ID不需要超高精度的细分面。运行时阶段我把这些网格上传到GPU用DXR接口为每个网格构建BLAS再汇总到TLAS里。动态角色因为没法预知位置我用简化的胶囊体和盒体替代每一帧更新它们的Transform矩阵这样至少可以提供正确的AO和反射遮挡。《生化危机:复仇》那个场景我们离线花了大概半天时间处理资产减面和导出最后整个场景库控制在200万面左右。这个量级在RTX 40系显卡上构建BLAS的开销已经可以忽略不计了但求交质量比用屏幕空间反射要高出好几个档次。有一点要特别提醒RE Engine的世界坐标可能会非常大或者启用类似大世界浮点原点的机制。如果你直接把世界空间坐标塞进加速结构而不做相对化处理很容易因为浮点精度问题让射线在远处发生抖动或者偏移。我最后是把整个路径追踪过程放在以相机为中心的局部坐标系里算的射线起点和场景网格都减去相机位置这样精度问题基本消失。3.3 第三步路径追踪主循环怎么写整个自定义Pass的核心是一个Compute Shader我把线程组大小设成8x8每个线程处理一个像素。主循环的逻辑大致是这样的从G-Buffer读取该像素的世界坐标、法线、基础色、粗糙度、金属度。根据材质参数做分类金属度低的走漫反射采样金属度高的走镜面反射采样。生成入射光线的方向。漫反射用余弦加权半球采样镜面反射根据粗糙度在反射锥内做重要性采样。发射这条射线到场景加速结构里求交。如果命中光源累加直接贡献如果命中普通几何体视作一次反弹继续递归追踪最多反弹2到3次。额外发射一条Shadow Ray从当前命中点打向主光源判断是否被遮挡以产生软阴影效果。这里有个细节是为了实时性每个像素每帧只发射1条主射线靠多帧时间累积来等效更高的采样数。我额外使用了InterleavedGradientNoise作为每帧的随机序列来源这种噪声的好处是相邻像素的噪声分布非常均匀降噪器会很喜欢。如果你用Halton序列或者随机白噪声降噪效果会差不少。追踪结束之后每个线程把累积到的Radiance写进一张R16G16B16A16_FLOAT的RenderTarget里。不要用R8G8B8A8HDR范围会溢出暗部细节也会被量化噪声吃光。3.4 第四步降噪和时域累积实时性的命根子每像素1条射线的原始输出直接看的话就是满屏噪点所以降噪是整个方案里灵魂一般的一步。我用的是经典的两段式降噪时域累积加空间滤波。时域累积的做法是维护一张历史Radiance缓冲。对当前帧的每个像素用运动向量把上一帧的结果重投影到当前坐标然后和当前帧的Radiance做指数移动平均。如果深度、法线、亮度变化很大说明这个像素可能发生了遮挡变化或者物体移动这时要把混合权重降下来防止鬼影。最保守的写法是给历史值做一个三通道Clamp限制它和当前帧邻居像素的差异范围这样就算画面变化很剧烈也不会拖出明显的残影。空间滤波我用了A-Trous小波简单说就是多轮Bilateral Filter每一次的半径会翻倍。Edge-Stopping的条件用的是深度差、法线夹角差和亮度差。这套方案在GI和AO这类低频信号上效果极好但对高频镜面反射会有点吃亏所以对《生化危机:复仇》那种金属高光多、反射细节密的画面我又加了一步额外的反射降噪把镜面通道单独拿出来用更大的滤波半径处理。做完降噪以后最后一步是根据项目需要做上采样。我一般是先把路径追踪结果以1080p的分辨率算出来再用DLSS或者FSR上到2K或者4K。注意顺序先降噪再上采样最后再ToneMap。如果先ToneMap再上采样暗部的噪声会被放大反射边缘也会糊成一团。4. 性能调优与画面打磨4.1 从PPT到可玩的三个关键参数刚把整套链路跑通的时候帧率大概是个位数。我记得第一次输出正常画面时整个屏幕的Radiance都是噪点卡得我以为死机了。后来一步步把性能调上去主要靠三个杠杆。第一个是SPP也就是每像素的射线数。这玩意儿是画面质量和性能最直接的放大器。虽然我说每像素1条主射线但实际上阴影射线、反射射线、间接光射线都会叠加计算量。我最初的实现里每个像素总共要打十几条射线后来通过复用和裁剪不必要的分支把它压到平均3到4条性能一下子翻了好几十个百分点。第二个是渲染分辨率。路径追踪是像素级的计算量分辨率砍半计算量就变成原来的四分之一。我最后在《识质存在》里把路径追踪Pass放在1/2分辨率下执行再用TAAU上采样回原分辨率。牺牲掉的高频细节由降噪和时域累积补回来人眼在动态预览下根本分不出来。第三个是反弹次数。1次反弹和3次反弹在间接光的完整度上差别非常明显但性能开销也几乎线性增加。我在两个项目上最终都选了2次反弹对大部分室内场景和角色特写来说2次反弹已经足够产生自然的色彩溢出和环境光层次。只有遇到特别暗的场景我才会临时把它提成3次。实际跑下来在RTX 4080上1080p分辨率、1spp主射线加2次反弹加全套降噪基本能稳在55到75帧4K输出则需要先用1/2分辨率计算再配合DLSS保底大约能维持在45到60帧。对于预览用途这个表现已经非常能打了。4.2 两套项目各自怎么取舍因为《识质存在》和《生化危机:复仇》的需求差异我最后在调参上做了明显区分。《识质存在》是动态交互项目灯光经常移动角色频繁进出场景所以阴影射线和GI射线的计算压力很大。我选择把阴影单独降到一个更低的分辨率去做比如干脆用半分辨率深度做一次近似遮挡GI射线数量控制在每像素2条反射则保留半分辨率。这样动态变化时降噪器也能及时收敛不会出现人群走动后面拖一长串鬼影。《生化危机:复仇》是静态镜头为主角色在固定位置做表演所以反射可以开全分辨率GI保持半分辨率但反弹次数可以稳定在3次。高模资产比较多BLAS构建开销反而成了重点优化对象我采取的策略是静态场景只构建一次TLAS每帧只更新动态角色的简单盒体这样构建成本大幅下降。项目SPP反射分辨率GI分辨率反弹次数重点优化对象识质存在11/21/42动态灯光、阴影、半透明生化危机:复仇1-2全1/23高模资产、反射细节、SSS4.3 画面打磨的几个重点区域做完性能释放画面质量也要能过美术那一关。我这里整理了三个最容易被人抓着问的点。第一个是反射边缘的锐度。路径追踪在全屏反射上的结果通常比SSR好因为SSR是沿着屏幕空间步进的遇到屏幕边缘就断掉路径追踪不会。但如果你把反射通道和漫反射通道做成简单的加法混合高光边缘可能会显得过于“硬”像贴了一层镜面贴图。我的做法是根据粗糙度做一个反射强度的渐变粗糙度越低反射越强然后在镜面边缘加一个基于法线变化的小范围模糊让过渡更自然。第二个是暗部噪点。降噪器对很暗的区域往往不敏感容易把它们压成死黑或者留下色斑。我的解决方案是给输出亮度加一个极小的下限同时限制降噪器在高方差暗区的过滤强度防止它把有价值的光照细节整个抹掉。第三个是透明物体的焦散。实时路径追踪做真正的焦散仍然极其昂贵我们没有硬上。对《生化危机:复仇》里角色的皮肤透射和玻璃酒杯这类物体用了近似方案把透射材质当成半透明遮罩在路径追踪结果上叠一层预积分SSS让视觉上过得去就行。美术看预览的时候不会拿着放大镜去追每一颗焦散光子但会一眼看出玻璃杯是不是黑乎乎的一坨。5. 常见问题与排查技巧实录5.1 画面闪烁得像圣诞树这是所有做路径追踪的人都会遇到的第一道坎。表现是画面高频闪烁尤其在物体边缘和暗部区域噪点像雪花一样跳。我排查下来最常见的原因是每像素射线数太少加上时域累积的权重没有根据运动向量正确调整导致上一帧和当前帧无法稳定对齐。解决思路分两步第一步先关掉时域累积只显示当前帧的原始Radiance如果单帧画面噪声是均匀的颗粒感说明追踪和采样本身是健康的如果单帧就已经有结构性跳变那问题多半出在法线重建或者世界坐标重建上。第二步再打开时域累积检查运动向量是否正确写入。另外一个很有效的习惯是使用InterleavedGradientNoise替代白噪声它能显著降低邻域像素之间的噪声相关性让降噪器的输入更友好。5.2 反射边缘漏光漏光的直观表现是模型轮廓周围出现一圈泛白的光晕或者金属物体边缘反射出不该出现的东西。这通常和射线发射时的精度有关。路径追踪射线必须设置一个tMin值避免射线刚从表面发出就和自己的几何体相交。这个值如果设得太小就会出现自相交闪烁设得太大又会把贴近表面的遮挡错误地忽略掉。我最终的做法是让tMin随距离缩放近处用极小值远处逐步放大。同时把法线方向的偏移也做了一个偏置减少因为法线贴图高频变化导致的错误求交。另外深度重建的精度也要检查尤其在看远景的时候深度缓冲的浮点误差会被放大导致世界坐标重建出现漂移进而让反射线偏移。5.3 崩溃和显存溢出这套方案最让我头疼的不是画面问题而是稳定性。RE Engine本身就是显存大户再叠加上路径追踪用的场景库和Radiance缓冲稍微不注意就爆显存。而且DX12的崩溃常常让你看不到堆栈只有一句Device Removed。排查崩溃的经验是先把所有我创建的资源都挂上命名并开启GPU验证层这样一旦出现非法资源访问能直接在Output窗口定位到具体Resource。另外TLAS和BLAS的构建与光追执行之间有严格的同步要求如果上一帧的加速结构还没构建完下一帧就开始发射射线大概率秒崩。我在工程里做了资源延迟回收队列把要销毁的资源统一推迟3帧再释放绕开了大部分同步问题。Descriptor Heap也要提前规划好路径追踪Pass需要的SRV和UAV数量很大如果不做分段管理堆超限是必然的。5.4 画面发灰、颜色不对颜色问题基本上是线性空间转换造成的。如果路径追踪全程在线性空间计算最后输出到一个已经被引擎ToneMap过的LDR缓冲里结果必然发灰、发闷。正确的做法是确保路径追踪Pass输入的是线性HDR数据输出也是HDR然后在引擎原始的ToneMap算子之后再进行最终的sRGB编码。如果引擎自己会做ToneMap那就要保证你的Radiance缓冲是在ToneMap之前否则要丢掉一次色彩变换。调试颜色还有一个非常实用的办法在场景里放几个已知颜色值的标准物体比如纯白色的球、中灰色的板子然后去读路径追踪输出的像素值。如果白色球读出来是0.5而不是1.0那基本就是线性空间链路上某个环节出了问题。这个方法成本极低但能救命的次数真的很多。最后再分享一个我自己踩出来的经验在闭源引擎上做路径追踪真正的技术难点其实不在“追踪”本身而在“对齐”——数据对齐、空间对齐、时间对齐。G-Buffer通道认对了坐标系转对了历史缓冲重投影做对了渲染管线自然就稳定了。如果哪天你被噪点搞到怀疑人生不妨找个午后的时间回到第一步重新抓一次帧看看是不是某个通道认错了。很多时候答案就在RenderDoc那张不起眼的纹理里。