多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Unity动态全局光照实战:PRTGI原理与移动端优化方案

Unity动态全局光照实战:PRTGI原理与移动端优化方案 1. 项目概述为什么我们需要动态全局光照在游戏开发中光照是塑造世界氛围、提升沉浸感的核心要素。传统的静态光照烘焙Lightmap技术虽然能提供高质量的全局光照GI效果但它有一个致命的弱点无法应对动态变化。一旦场景中的光源移动或者物体本身发生位移静态光照图就“失效”了画面会显得虚假、割裂。而像VXGI、DDGI这类完全动态的全局光照方案虽然效果惊艳但对性能的消耗巨大在移动端或开放世界等复杂场景中往往难以承受。因此一种折中但高效的方案——预计算辐射度传输Precomputed Radiance Transfer, PRT技术成为了许多3A大作的“秘密武器”。它通过在场景中预计算并存储光照传输信息在运行时进行快速插值从而以较低的开销实现高质量的间接光照。然而传统的PRT技术通常只支持静态光源或者对动态物体的支持非常有限。我们今天要探讨的正是这个问题的进阶解法如何让PRT技术“动起来”支持动态光源下的全局光照。这不仅仅是让一盏灯动起来那么简单它意味着整个场景的间接光照能实时响应光源颜色、强度、位置的变化让动态光影与静态环境无缝融合。我将结合一个具体的Unity Demo带你从原理到实现一步步拆解这个技术并分享我在实现过程中踩过的坑和优化心得。2. 核心原理PRTGI如何“记住”并“重算”光照要理解动态PRT首先要明白传统PRT做了什么以及它的瓶颈在哪里。2.1 传统PRT的静态本质传统的PRT技术其核心思想是“预计算”和“传输”。在离线阶段烘焙时它会在场景中布置大量的采样点通常称为探针或Probe。每个Probe会捕获其所在位置来自各个方向的环境光照信息通常编码为球谐函数SH系数并预计算场景几何体表面对这些光照的响应即传输函数Transfer Function。这个响应包含了可见性Visibility和材质属性如法线、漫反射率的影响。在运行时对于场景中的任意一点比如一个动态物体的表面我们找到其周围的几个Probe取出它们预计算好的光照和传输信息进行三线性插值就能快速得到该点在当前光照环境下的间接光照结果。因为大部分复杂的可见性计算和光线反弹都在预计算阶段完成了所以运行时开销很低。那么为什么它不支持动态光源问题的关键在于“预计算”。传输函数中包含了光源方向到该点的可见性信息。如果光源动了光源方向变了之前预计算的、针对特定光源方向的传输数据就全都不对了。这就好比你在晚上用一张白天拍好的照片来模拟夜景肯定是驴唇不对马嘴。2.2 实时PRTGI的核心突破分离光照与传输要让PRT支持动态光源核心思路就是将“光照”和“传输”彻底解耦。这也是《全境封锁》等游戏采用的方案常被称为实时PRTGIPrecomputed Radiance Transfer Global Illumination。传输Transfer保持预计算且与光源无关我们预计算的不再是“在某个具体光源下的光照结果”而是“场景中从A点到B点的光线传输能力”。这通常体现为一种“辐照度传输”或“可见性”信息。这部分信息只依赖于场景的静态几何和材质一旦场景确定就可以烘焙好不再改变。光照Radiance实时计算光源无论是方向光、点光源还是聚光灯的辐照度信息在运行时实时计算。对于每个Probe我们根据当前动态光源的位置、颜色、强度实时计算出该Probe位置接收到的直接光照Direct Lighting。运行时合成在着色时对于物体表面的一个点我们结合其周围的Probe信息从Probe中获取预计算的传输信息描述周围环境如何将光传递到这个点。从同一个Probe中获取实时计算的直接光照信息。将二者结合通常是一次点积或积分运算即可得到该点在当前动态光源影响下的间接光照结果。简单来说传统PRT存储的是“标准答案”静态光照结果而实时PRTGI存储的是“解题公式”传输函数运行时再把“新的题目”动态光源代入公式实时算出“新答案”。2.3 数据结构Surfel、Brick与Probe的三层架构为了实现上述过程我们需要一套高效的数据结构来组织场景的光照信息。参考主流方案通常采用三层结构层级名称精度/密度存储内容作用第一层Surfel(表面元素)高 (如1米/个)位置(Position)、法线(Normal)、反照率(Albedo)最基础的几何与材质信息存储单元。可以理解为场景表面的一个微小采样点。第二层Brick(体素块)中 (如4米/个)其所包围空间内所有Surfel的索引列表作为Surfel的聚合与管理单元。一个Brick管理一定空间范围内如4x4x4米的所有Surfel减少查询开销。第三层Probe(探针)低 (可配置如8米/个)自身位置、对其有贡献的Brick的索引及权重、天空可见度(Sky Visibility)光照信息的最终存储和查询节点。它聚合了下属Brick的光照信息是运行时物体进行光照插值的直接数据源。工作流程简述烘焙阶段在场景中按规则摆放Probe。每个Probe向其周围发射射线或渲染CubeMap采集Surfel信息位置、法线、颜色并向上聚合生成Brick和Probe数据同时计算天空可见度。运行时Relight重光照阶段当动态光源发生变化时系统会遍历受影响的Probe。对每个Probe根据其位置和当前光源信息实时计算其接收到的直接光照辐照度。这个实时计算的直接光照会与Probe中预存的传输信息由下属的Surfel/Brick的几何材质信息构成结合计算出该Probe代表的区域在当前光源下的间接光照结果。渲染阶段物体着色时根据其世界坐标找到最近的几个Probe取出它们更新后的间接光照结果进行插值作为该物体的间接光照输入。关键点天空可见度Sky Visibility是一个非常重要的优化。它表示该Probe位置能看到多少天空。在计算环境光天空盒贡献时可以快速衰减被遮挡区域的环境光强度从而产生更真实的闭塞Ambient Occlusion效果且性能开销极低。3. Unity Demo实战一步步构建动态PRTGI系统理论讲完了我们动手实现一个简化版的实时PRTGI系统。这个Demo将包含烘焙工具和运行时重光照逻辑。3.1 环境准备与项目设置首先创建一个新的Unity项目建议使用2021 LTS或更新版本以确保Compute Shader和图形API的良好支持。渲染管线选择本项目不依赖SRP核心功能但在URP或HDRP下更容易管理后期效果。为求通用我们选择URP。在Package Manager中安装Universal RP并创建一个URP Asset和Renderer Asset。关闭内置的实时GI在Window - Rendering - Lighting中确保Realtime Global Illumination选项是关闭的。我们的系统将完全接管动态GI。创建目录结构保持项目整洁。Assets/ ├── Scripts/ │ ├── Runtime/ // 运行时脚本 │ └── Editor/ // 烘焙工具脚本 ├── Shaders/ │ ├── Compute/ // Compute Shader │ └── Surface/ // 渲染Shader ├── Data/ // 存放烘焙好的数据 └── Prefabs/ // 预置体3.2 核心数据结构定义C#部分我们需要在C#中定义与Shader通信的数据结构。// Surfel数据需要与Compute Shader中的结构体严格对齐 public struct SurfelData { public Vector3 position; public uint normal; // 编码为2个球面坐标或octahedron encoding public uint albedo; // 编码为RGBM或类似的LDR格式 // 可以添加其他属性如辐射度缓存等 } // Brick数据主要存储Surfel索引范围 public struct BrickData { public int surfelStartIndex; public int surfelCount; public Vector3Int gridCoord; // 在Brick网格中的坐标 } // Probe数据核心存储单元 public struct ProbeData { public Vector3 position; public int brickStartIndex; public int brickCount; public float skyVisibility; // 实时计算的直接辐照度SH系数将存储在这里 public Vector4[] shCoefficients; // 例如3阶SH需要9个Vector4 } // 管理整个PRT系统的单例类 public class PRTManager : MonoBehaviour { public static PRTManager Instance { get; private set; } // 烘焙参数 public float probeSpacing 4.0f; public float surfelSpacing 1.0f; public Vector3Int volumeResolution; // 根据场景边界计算 // 数据容器 private ComputeBuffer _surfelBuffer; private ComputeBuffer _brickBuffer; private ComputeBuffer _probeBuffer; private Texture3D _volumeLightmap; // 用于快速查询的3D纹理 // Compute Shader引用 public ComputeShader prtComputeShader; private void Awake() { if (Instance ! null Instance ! this) Destroy(this); else Instance this; } // ... 其他方法烘焙、重光照、查询等 }注意结构体对齐Compute Shader中的struct必须与C#中的内存布局完全一致。一个常见的技巧是使用[System.Runtime.InteropServices.StructLayout(LayoutKind.Sequential)]并确保所有字段都是4字节的倍数如Vector3后补一个float占位。3.3 烘焙工具实现Editor脚本烘焙是离线预处理的核心耗时较长需要在Editor下进行。using UnityEditor; using UnityEngine; public class PRTBaker : EditorWindow { [MenuItem(Window/PRT Baker)] public static void ShowWindow() GetWindowPRTBaker(PRT Baker); private PRTManager _targetManager; private bool _isBaking false; void OnGUI() { _targetManager (PRTManager)EditorGUILayout.ObjectField(PRT Manager, _targetManager, typeof(PRTManager), true); if (_targetManager null) { EditorGUILayout.HelpBox(请指定一个PRT Manager对象。, MessageType.Warning); return; } GUI.enabled !_isBaking; if (GUILayout.Button(开始烘焙)) { _isBaking true; BakePRTData(_targetManager); _isBaking false; } GUI.enabled true; } async void BakePRTData(PRTManager manager) { // 1. 清理场景中旧的Probe和Surfel标记物如果存在 // 2. 根据场景包围盒和probeSpacing生成Probe网格位置 Bounds sceneBounds CalculateSceneBounds(); Vector3Int probeGridSize new Vector3Int( Mathf.CeilToInt(sceneBounds.size.x / manager.probeSpacing), Mathf.CeilToInt(sceneBounds.size.y / manager.probeSpacing), Mathf.CeilToInt(sceneBounds.size.z / manager.probeSpacing) ); ListVector3 probePositions new ListVector3(); for (int z 0; z probeGridSize.z; z) for (int y 0; y probeGridSize.y; y) for (int x 0; x probeGridSize.x; x) { Vector3 pos sceneBounds.min new Vector3(x, y, z) * manager.probeSpacing; // 可选进行碰撞检测将Probe位置吸附到最近的导航网格或地表 probePositions.Add(pos); } // 3. 对每个Probe位置进行信息采集这是最耗时的部分 // 我们需要渲染6张CubeMap或使用Ray Tracing来获取Surfel信息 // 这里简化描述使用Physics.Raycast或Graphics.RenderMesh来采集 ListSurfelData allSurfels new ListSurfelData(); ListBrickData allBricks new ListBrickData(); ListProbeData allProbes new ListProbeData(); int totalProbes probePositions.Count; for (int i 0; i totalProbes; i) { // 更新进度条 EditorUtility.DisplayProgressBar(烘焙PRT数据, $正在处理探针 ({i1}/{totalProbes}), (float)i / totalProbes); Vector3 probePos probePositions[i]; // a. 采集Surfel从probePos向周围均匀或随机发射射线命中点即为Surfel候选 // b. 对采集到的Surfel进行聚类和去重生成Brick // c. 计算该Probe的天空可见度向天空方向发射射线统计未命中比例 // d. 构建ProbeData关联其下属的Brick // 注意实际实现中这部分逻辑应放在Compute Shader中并行执行效率极高。 // Editor下可以通过CommandBuffer和AsyncGPUReadback来调度。 } EditorUtility.ClearProgressBar(); // 4. 将数据序列化到磁盘或填充到PRTManager的ComputeBuffer中 Debug.Log($烘焙完成。生成 {allSurfels.Count} 个Surfel, {allBricks.Count} 个Brick, {allProbes.Count} 个Probe。); } Bounds CalculateSceneBounds() { // 遍历所有静态渲染器计算合并包围盒 var renderers FindObjectsOfTypeRenderer(); Bounds bounds new Bounds(); bool first true; foreach (var r in renderers) { if (r.gameObject.isStatic) { if (first) { bounds r.bounds; first false; } else bounds.Encapsulate(r.bounds); } } return bounds; } }实操心得烘焙过程非常耗时尤其是射线检测部分。务必使用异步操作和进度条防止编辑器卡死。对于大型场景考虑将场景分块烘焙并支持增量烘焙只更新发生变化的区域。3.4 运行时重光照与渲染Compute Shader Shader这是系统的核心在游戏运行时每帧或当光源变化时执行。1. 重光照Compute Shader (Relight.compute): 这个Shader负责在光源变化时更新所有Probe的实时直接光照信息。// Relight.compute #pragma kernel CSMain RWStructuredBufferProbeData ProbeBuffer; // 可读写的Probe数据 StructuredBufferBrickData BrickBuffer; StructuredBufferSurfelData SurfelBuffer; float4 _MainLightDirection; // 主光源方向 float4 _MainLightColor; // 主光源颜色和强度 [numthreads(64, 1, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { uint probeIndex id.x; if (probeIndex ProbeBuffer.Length()) return; ProbeData probe ProbeBuffer[probeIndex]; float3 totalIrradiance float3(0, 0, 0); // 遍历该Probe关联的所有Brick for (int b 0; b probe.brickCount; b) { BrickData brick BrickBuffer[probe.brickStartIndex b]; // 遍历Brick下的所有Surfel for (int s 0; s brick.surfelCount; s) { SurfelData surfel SurfelBuffer[brick.surfelStartIndex s]; // 计算该Surfel位置接收到的直接光照 // 1. 判断光源可见性简单起见这里假设无遮挡 float NoL max(0, dot(surfel.normal, _MainLightDirection.xyz)); // 2. 计算辐照度贡献简化模型 float3 directLight _MainLightColor.rgb * _MainLightColor.a * NoL; // 3. 根据Surfel的反照率和到Probe的距离进行加权累积 // (实际应使用更精确的辐射度传输模型这里仅为示意) totalIrradiance directLight * surfel.albedo * WeightFunction(distance(probe.position, surfel.position)); } } // 将计算出的总辐照度转换为SH系数存储回Probe // (此处省略SH投影的详细代码) ProbeBuffer[probeIndex].shCoefficients ProjectToSH(totalIrradiance); } float WeightFunction(float dist) { // 一个简单的距离衰减函数例如反平方衰减 return 1.0 / (dist * dist 1.0); }在C#端我们这样调度它void UpdateDynamicGI() { if (prtComputeShader null || _probeBuffer null) return; int kernel prtComputeShader.FindKernel(CSMain); // 绑定所有Buffer prtComputeShader.SetBuffer(kernel, ProbeBuffer, _probeBuffer); prtComputeShader.SetBuffer(kernel, BrickBuffer, _brickBuffer); prtComputeShader.SetBuffer(kernel, SurfelBuffer, _surfelBuffer); // 传递当前光源参数这里以主方向光为例 Light mainLight RenderSettings.sun; prtComputeShader.SetVector(_MainLightDirection, mainLight.transform.forward); prtComputeShader.SetVector(_MainLightColor, mainLight.color * mainLight.intensity); // 调度计算每个Probe一个线程 prtComputeShader.Dispatch(kernel, Mathf.CeilToInt(_probeBuffer.count / 64.0f), 1, 1); }2. 表面着色器集成 最后我们需要在物体的Shader中读取更新后的Probe信息来计算最终间接光照。// 在Surface Shader的片段着色器中 void surf (Input IN, inout SurfaceOutputStandard o) { // 1. 标准纹理采样和直接光照计算 fixed4 c tex2D(_MainTex, IN.uv_MainTex); o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; // 2. **关键查询PRT间接光照** float3 worldPos IN.worldPos; float3 worldNormal o.Normal; // 找到最近的N个Probe (例如4个) int probeIndices[4]; float probeWeights[4]; FindNearestProbes(worldPos, probeIndices, probeWeights); float3 indirectDiffuse float3(0,0,0); for (int i 0; i 4; i) { // 从Probe数据中获取SH系数这里假设通过一个3D纹理或Buffer查询 SHCoeffs sh GetProbeSH(probeIndices[i]); // 使用法线和SH系数重建该方向的辐照度 indirectDiffuse probeWeights[i] * SHEval(sh, worldNormal); } // 3. 将间接光照结果叠加到最终输出 // 注意Unity的Standard Shader已将间接光加到gi.indirect中我们需要重写或修改光照函数。 // 这里展示一个简单加法 o.Emission indirectDiffuse * o.Albedo; // 漫反射间接光 }注意事项直接修改Unity内置着色器比较复杂。更实用的方法是在URP/HDRP中编写自定义的Lit Shader Graph或者通过脚本将计算好的间接光照数据写入一个全局的SphericalHarmonicsL2变量供Unity的Lighting系统读取。4. 性能优化与常见问题排查实现基础功能后性能和质量优化才是真正的挑战。4.1 性能优化策略数据压缩Surfel数据法线可以用八面体映射Octahedral Encoding压缩到2个float16或一个uint32中。反照率Albedo可以用RGBM或HDR编码压缩。SH系数对于间接漫反射3阶SH9个系数通常足够。可以存储为float3数组并考虑使用half精度在移动端。稀疏存储不是每个网格位置都需要Probe。使用自适应采样在几何复杂或光照变化剧烈的区域如墙角、物体边缘增加Probe密度在空旷区域减少密度。计算优化分帧更新除非光源剧烈变化否则不需要每帧更新所有Probe。可以将Probe分成若干组每帧只更新一部分例如1/4实现渐进式更新。影响范围裁剪每个动态光源都有一个有效半径。只更新该光源影响范围内的Probe可以大幅减少计算量。为每个光源维护一个Probe索引列表。利用层级结构Brick在重光照时不是遍历所有Surfel。如果某个Brick整体处于阴影中或光照贡献极小可以直接跳过其下所有Surfel的计算。渲染优化Volume Lightmap体积光照贴图这是《全境封锁》使用的技巧。将Probe计算出的最终光照SH系数烘焙到一张3D纹理中。在着色时物体直接根据其世界坐标三线性采样这张3D纹理比查找多个Probe再插值更快缓存更友好。Clipmap对于开放大世界可以使用Clipmap技术。围绕摄像机布置多层不同精度的Volume Lightmap近处精度高远处精度低在保证视觉效果的同时控制数据量。4.2 常见问题与解决方案实录问题一动态物体边缘光照闪烁或接缝现象动态物体在移动时其表面的间接光照在穿过不同Probe区域边界时发生跳变。原因这是最经典的“三明治”问题。当物体表面一点正好位于两个或更多Probe影响权重相等的位置时微小的数值误差或插值方式可能导致结果不稳定。解决方案增加Probe密度这是最直接但最耗费内存的方法。使用更平滑的插值函数不要用简单的线性插值。尝试使用基于距离平方反比的权重或者使用三次样条插值让权重在边界处变化更平滑。引入“体积索引Volume Index”参考《全境封锁》的方案在Probe之间生成一个规则的3D网格索引。每个网格点存储离它最近的Probe的索引。在查询时对网格点进行三线性采样得到一组混合的Probe索引和权重这比直接对离散的Probe位置做插值更稳定。问题二烘焙时间过长现象点击烘焙按钮后编辑器卡住十几分钟甚至更久。原因每帧在Editor中进行大量的Raycast或Graphics.DrawMesh调用是同步且缓慢的。解决方案使用Job System Burst Compiler将射线检测和Surfel采集任务并行化。IJobParallelFor可以极大地利用多核CPU。使用Compute Shader进行GPU烘焙这是工业级方案。将场景几何信息传入GPU让Compute Shader并行地为每个Probe渲染微型CubeMap或进行光线追踪。通过AsyncGPUReadback将结果读回CPU。速度比CPU方案快一个数量级。增量烘焙记录场景中静态物体的哈希值。只有当场景中的静态物体发生改变时才重新烘焙受影响的局部区域。问题三动态物体无法对周围环境产生GI仅接收现象一个红色的动态方块移动到白色墙边墙上没有出现红色的反光颜色渗透。原因我们的方案中Surfel信息是在烘焙阶段从静态场景中采集的。动态物体没有被采集因此它们不存在于PRT系统中自然无法贡献间接光。解决方案权衡接受这个限制对于大多数游戏动态物体较小且移动快其产生的间接光贡献可以忽略或通过屏幕空间全局光照SSGI等后处理技术近似补充。这是性能和效果的最佳平衡。动态Surfel注入为动态物体实时生成Surfel并注入到PRT系统中。这非常复杂需要动态更新Brick和Probe的关联关系对性能挑战极大通常只用于少数关键的大型动态物体如可破坏的建筑。问题四内存占用过大现象烘焙后的数据文件有几个GB游戏加载缓慢。原因Surfel和Probe数量过多或者存储的数据格式未经压缩。解决方案调整密度根据目标平台调整probeSpacing和surfelSpacing。对于手机probeSpacing可能要从4米增加到8米或更大。使用更激进的压缩如前所述对所有数据进行编码压缩。甚至可以探索使用神经网络压缩光照数据在运行时解压这是前沿研究方向。流式加载将PRT数据按场景分块结合场景加载流式载入和卸载。5. 进阶方向与Demo扩展建议完成了基础系统后你可以尝试以下扩展让Demo更完善、更接近生产环境支持多光源目前的Demo只处理了单一主方向光。修改系统以支持多个点光源、聚光灯。关键在于为每个光源维护其影响范围内的Probe列表并分别调度重光照计算。集成天空盒与环境光在Probe的间接光照计算中加入天空盒的贡献。利用烘焙时计算的SkyVisibility因子对天空盒颜色进行遮挡衰减。实现Volume Lightmap将Probe的最终SH系数烘焙到一张Texture3D中。编写一个Custom Renderer Feature在物体渲染前通过材质属性块将Volume Lightmap纹理和采样参数传递给所有Shader。添加编辑器可视化在Scene视图中绘制Probe的位置用Gizmos并用不同颜色显示其光照强度或天空可见度这对于调试和摆放Probe至关重要。性能分析工具在游戏中添加一个简单的UI显示当前激活的Probe数量、重光照耗时、PRT系统总内存占用等方便进行性能剖析。实现动态光源下的PRT全局光照是一个系统工程它平衡了质量、性能和动态性。虽然Unity和Unreal Engine等商业引擎已经提供了类似的解决方案如Unity的Light Probe Proxy Volumes的增强版或Unreal的Dynamic GI实验功能但亲手实现一遍会让你对全局光照、实时渲染数据结构和性能优化有脱胎换骨的理解。这个Demo的代码框架已经为你搭好了骨架剩下的血肉——那些精妙的优化和边界情况的处理——正是打磨一个优秀渲染系统的乐趣所在。
返回列表