UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 厚积薄发
UWA旨在诊断与优化游戏和VR项目开发过程中潜在的运行性能问题，帮助项研发团队快速检测项目运行时的性能瓶颈，高效定位问题根源，及时反馈针对瓶颈问题的深度分析和解决方法。
1）明明没有主动调用，为什么GC依然频发 2）特效精准降温：别急着对游戏画质“动刀” 这是第484篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们发现部分设备运行一段时间后，GC次数异常高。但项目里没有主动调用GC，也没有明显的大量内存申请。这种情况一般是什么原因导致的？应该如何定位和优化？ A：遇到GC次数异常增加的情况，首先不要只关注“有没有主动调用GC”。在Unity中，GC不一定需要业务代码显式调用GC.Collect才会发生，更多时候是因为运行过程中持续产生大量临时对象，导致系统被迫进行垃圾回收。 另外，也需要注意增量GC（Incremental GC）的影响。开启增量GC后，垃圾回收会被拆分到多个帧中执行，因此可能观察到GC次数增加，但单次GC耗时降低的情况，需要结合GC耗时和整体性能表现综合判断。 这类问题建议先结合UWA GOT Online报告查看GC发生趋势，确认GC集中的时间段，再结合对应业务场景分析，例如是否发生在战斗Tick、UI刷新等高频逻辑中。 如果GC峰值与某段逻辑高度重合，就需要进一步检查该路径上的内存分配情况。实际项目中，比较常见的问题是高频代码持续产生临时对象，例如Tick中频繁进行字符串拼接、格式化等操作。 例如：string msg = "hp:" + hp + " mp:" + mp；这类代码单次分配并不明显，但如果处于高频执行路径中，会不断产生新的字符串对象，长期运行后就会增加Managed分配压力，最终导致GC频繁触发。 因此，排查GC问题时，不建议只看GC次数本身，而应该结合：GC触发时间点、Managed内存变化趋势、对应业务逻辑执行情况定位真正产生分配的代码位置。优化方向主要是减少高频路径中的临时分配，例如避免在Update、Tick等逻辑中频繁创建对象，对固定文本进行缓存，减少不必要的字符串处理。 最终来看，GC频繁通常不是GC本身的问题，而是业务代码持续制造垃圾对象，GC只是被迫进行清理。排查这类问题时，比起关注“GC调用了多少次”，更重要的是找到：哪些代码在持续制造垃圾对象。 实战案例 Q：我们发现部分特效开启1/4 RT后，渲染性能提升比较明显，GPU压力和设备发热也有改善。目前考虑根据特效Overdraw评分，超过一定阈值后自动开启1/4 RT。另外，有些项目会通过降低战斗场景相机分辨率来降低GPU压力，这种方式是否有必要？ A：1/4 RT本质上是通过降低特效渲染分辨率，减少特效绘制带来的GPU填充压力，因此对于Overdraw较高、覆盖范围较大的特效，通常能够带来比较明显的性能收益。 在实际项目中，如果某些特效长期存在较高Overdraw，或者在战斗场景中频繁出现，可以考虑根据特效复杂度、Overdraw情况等制定自动开启策略。例如，当某类特效达到一定开销阈值时，自动使用1/4 RT方案，在保证视觉效果的同时降低GPU消耗。不过，需要注意的是，1/4 RT主要解决的是特效自身带来的GPU压力，而降低场景相机分辨率影响的是整个画面的渲染成本，两者优化范围不同。 因此，在决定是否降低场景分辨率前，建议先确认当前GPU瓶颈来源。可以结合UWA GOT Online报告重点关注： GPU Clocks是否长期处于较高水平，判断设备GPU负载情况； Overdraw是否随着特效数量增加明显升高，确认是否存在大量透明特效叠加导致的填充压力； 开启1/4 RT前后，GPU Clocks、帧率以及设备发热情况是否有实际改善； GPU负载变化是否与特效集中出现的战斗场景保持一致。 如果开启1/4 RT后，GPU压力和发热已经得到明显缓解，通常没有必要继续降低场景分辨率。因为降低分辨率属于更偏整体性的优化手段，会直接影响画面清晰度，需要谨慎使用。只有当特效优化完成后，GPU负载依然较高，例如场景渲染、后处理、UI以及其他绘制开销仍然占据较大比例时，再考虑通过降低分辨率、调整画质档位等方式进一步降低GPU压力。最终目标是在保证视觉表现的前提下，找到GPU消耗、画面质量和设备发热之间的平衡，而不是单纯降低某一项指标。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
解决URP角色渲染短板，自研IllusionRP管线落地移动端
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 随着移动端芯片（如高通骁龙Elite）性能的飞跃，移动端芯片性能大幅提升，光线追踪等高规格渲染技术已能够落地移动端。然而，作为Unity移动端开发首选的URP（Universal Render Pipeline），其原生Feature在应对高质量角色渲染需求时显得捉襟见肘。 为了在移动端实现高品质的角色渲染，笔者历时一年开发了IllusionRP。插件除了提升角色渲染质量，还吸收了大量来自HDRP和开源社区的渲染功能。 IllusionRP完全基于URP的RendererFeature开发，无缝衔接现有URP项目，目前适用于Unity 2022.3.62和URP 14.0.12，理论上也支持基于22版本的团结引擎（没测试过），仓库链接放在了文章末尾，欢迎在GitHub收藏关注。 本篇文章就开发IllusionRP的设计思路以及工程实现方面进行经验分享。 现有方案 作为Unity开发者，想要提升画面表现力，通常有以下几种路径： 直接用HDRP。Feature很多，但由于大部分使用ComputeShader，只适用于主机和高端PC。由于HDRP主要面向高端PC和主机平台，在移动端商业项目中的应用相对较少。但现在移动端性能逐渐变强的情况下，也存在有优化能力的团队将HDRP适配到移动端，例如搜狐畅游引擎部的《图形引擎实战：HDRP移动版高清渲染管线改造》[1]。 URP加插件。URP可以直接通过RendererFeature注入ScriptableRenderPass方便拓展功能，AssetStore和Github都能找到非常多的插件。 自己写SRP，例如蛋白胨大佬的《DanbaidongRP》[2]，前提是有比较强的图形学知识和工程能力。 第一种我觉得对于普通开发者来说不太可能，等搞完都出Unity 7了（Unity之前说会在Unity 7整合URP和HDRP，但在发此文的时候Unity把合并计划挪到了6.3，现在可以坐等了）。 第二种我认为有好有坏，将渲染功能拆分成RenderFeature让社区开发者可以协作是一件好事，但也会导致冗余浪费和隐藏成本的提高。 譬如A插件实现了一个SSR，在Forward渲染路径下增加一个ThinGBufferPass，而B插件实现了一个SSGI，也在Forward渲染路径下增加了类似的Pass，那么很自然会看到这里要渲染两次实则功能一样的ThinGBuffer。 再譬如对于需要修改Shader的效果，插件一般都会单独提供一个Shader，而要使用多个插件的效果就需要开发者手动整合。 因此在接入各类插件后，整体优化和整合的问题还是落在了开发者头上，改完了还不方便更新。 第三种对我这样的民科菜鸟来说属实太难了…客观来讲，自定义的SRP难以随引擎升级以适配新功能（例如Unity 6的GPU Driven），维护成本比较高。 最终选型 由于笔者认为自己的工程能力尚可，不想直接摆烂，结合自己对SRP的理解攒出了第四个方案 —— 在URP基础上再搭一层管线，将HDRP迁移过来的渲染功能合并在一个RendererFeature中以便管线调度RendererPass。 因为没有重写渲染管线，基本都是加法，这让笔者能在一边迁移功能的同时学习相关概念和算法。 然后渲染路径的选择上，使用Deferred的话需要修改LightLoop源码比较麻烦，最后选择只兼容Forward和Forward+渲染路径，添加Shader和Pass比较灵活。 主要拓展 大概可以分为Shading-着色器、Rendering-渲染管线以及Workflow-工作流三大部分。 Shading 着色器的拓展主要依赖于添加新的ShadingModel，URP只有一个Lit，对于复杂的材质是完全不够的。 IllusionRP添加了以下ShadingModel： Skin 皮肤 Hair 头发 Fabric 布料 Rendering 光添加ShadingModel对于提高渲染质量是不够的，例如URP的SSAO对于角色渲染就是非常糟糕，至少也得调整为HBAO才能看。 IllusionRP集成了以下渲染功能： Per-Object Shadow逐物体阴影 Percentage Closer Soft Shadows（PCSS）高质量的软阴影 Contact Shadows接触阴影 Ground Truth Ambient Occlusion（GTAO）高质量的屏幕空间环境光遮蔽 Screen-Space Reflection（SSR）屏幕空间反射 Screen Space Subsurface Scattering（SSSS）屏幕空间次表面散射 Screen Space Global Illumination（SSGI）屏幕空间全局光照 Precomputed Radiance Transfer Global Illumination（PRTGI）预计算辐照度全局光照 Reflection Normalization反射标准化 Order-Independent transparency（OIT）顺序无关半透明 Volumetric Fog体积雾 Convolution Bloom卷积泛光（替换原生Bloom） Auto Exposure自适应曝光 Gran Turismo风格Tonemapping（替换原生Tonemapping-Neutral） Unreal的Filmic ACES Tonemapping（替换原生Tonemapping-ACES） 其中PerObjectShadow、GTAO和SSSS对于提升角色渲染质量影响最大，如果读者也想要提高角色渲染质量，可以尝试只添加这3个功能。 给这些功能细分一下的话： 1~3是阴影渲染提升； 4~7是屏幕空间光照提升； 7~9是全局光照提升； 10是半透明渲染提升； 11~15是后处理提升。 我会在后面的实现部分会再介绍其中的实现细节。 Workflow 工作流这部分可以分为着色器开发流程、效果管理流程、渲染调试流程与构建流程。 着色器开发：提供ASE（Amplify Shader Editor）模板，分离设计层与模型层。 效果管理：通过RendererFeature全局开关与VolumeComponent进行分级控制。 渲染调试：集成CVar。 构建：定制Shader Stripper，剔除无用变体。 着色器实现细节 IllusionRP在着色器实现上的过程与细节。 皮肤材质 皮肤核心是次表面散射，笔者起初尝试了Matt大神提供的球面高斯次表面散射（SGSSS），用公式拟合替代了过去移动端经常使用的预计算次表面散射技术中的LUT，在Forward渲染路径下为着色器节省了宝贵的一个Sampler。但因为没有屏幕空间的模糊，效果我感觉还是不够好，一种方式是再使用一个模糊过的法线，但这样就又把省下的Sampler还了回去。 之后还是完全使用了屏幕空间次表面散射（SSSS），并且还原了HDRP的多Profile方式以支持多种次表面散射材质。 剩下的就是皮肤比较特殊的双镜叶高光来模拟皮肤的油脂层。 // Primary Lobe half3 SpecularLobe1 = SkinGGXSpecularLobe(brdfData, normalWS, lightDirectionWS, viewDirectionWS); // Secondary Lobe BRDFData brdfData2 = brdfData; brdfData2.roughness = max(PerceptualRoughnessToRoughness(SkinData.PerceptualRoughness), HALF_MIN_SQRT); half3 SpecularLobe2 = SkinGGXSpecularLobe(brdfData2, normalWS, lightDirectionWS, viewDirectionWS); // Dual Lobe Mix half3 SpecularLobeTerm = lerp(SpecularLobe1, SpecularLobe2, SkinData.LobeWeight); 头发材质 Kajiya-Kay模型目前都是使用双层高光的变体版本，可以参考HDRP接入。 Marschner模型则直接使用了UE5中的版本。 额外参考神秘海域4中分享的Volumetric-based方式，增加了一个背面体积散射，搭配体积光有不错的视觉效果。 // Reference: [The Process of Creating Volumetric-based Materials in Uncharted 4] half3 HairVolumetricBacklitScatter(half3 albedo, half3 L, half3 V, half3 N, HairData hairData) { const half ScatterPower = 9; const half lightScale = 1; half3 NoisedL = normalize(L + hairData.Noise * 0.25); half NdotL = dot(N, NoisedL); half NdotV = dot(N, V); half VdotL = dot(V, NoisedL); half CosThetaL = saturate(abs(NdotL)); half CosThetaV = saturate(abs(NdotV)); half3 scatterFresnel = pow(1.0 - CosThetaV, ScatterPower); half3 scatterLight = pow(saturate(-VdotL), ScatterPower) * (1.0 - CosThetaV) * saturate(1.0 - CosThetaL); half3 transAmount = scatterFresnel + lightScale * scatterLight; return lerp(1, albedo, 0.8) * hairData.Tint * transAmount * hairData.Backlit; } 剩下就是为了让头发边缘没有锯齿or噪点，使用顺序无关的透明渲染方式。详细可见《如何在Unity URP中让头发渲染更丝滑》[3]。 布料材质 布料主要就两部分： Anisotropic Specular（各向异性高光）。 Ashikhmin和Charlie两种Sheen Scattering模型。 需要注意在URP下因为设计原因，Lambert漫反射都没有除PI，为了保证能量守恒，对于自定义的ShadingModel，需要使用NoPI版本即未除PI的版本。 void SheenScattering(BRDFData brdfData, half NoH, half NoV, half NoL, out half SheenTerm) { // @IllusionRP: // Since URP does not multiply INV_PI in Lambert Diffuse for artistic design considerations. // Specular term need multiply PI to maintain energy conservation. #ifdef _SHEEN_VELET half D_Sheen = D_AshikhminNoPI(NoH, brdfData.roughness2); #else half D_Sheen = D_CharlieNoPI(NoH, brdfData.roughness); #endif half V_Sheen = V_Neubelt(NoL, NoV); SheenTerm = D_Sheen * V_Sheen; #if REAL_IS_HALF SheenTerm = SheenTerm - HALF_MIN; SheenTerm = clamp(SheenTerm , 0.0, 100.0); // Prevent FP16 overflow on mobiles #endif } 然后因为前面适配了多Profile的次表面散射，理论上可以根据需要加入布料的次表面散射。 阴影质量提升细节 由于CSM的阴影精度并不高，对于需要高精度阴影的角色，除了增加CSM等级的方式，更普遍的做法是使用逐物体阴影PerObjectShadow。 这里使用了Stalo大佬星穹铁道复刻项目stalomeow/StarRailNPRShader[4]中的PerObjectShadow实现。 其次参考HDRP和recaeee大佬的渲染项目recaeee/RecaNoMaho_P[5]，为主光源阴影和逐物体阴影适配了PCSS软阴影，越远越模糊的表现能提高阴影的质感，从实践上看在开启了PenumbraMask标记半影区域以及使用ScreenSpaceShadow后，PCSS的开销比较可控，移动端可以选择开启。 另一方面，URP由于使用Shadow Caster Vertex Based Bias会在特定角度有很多瑕疵（具体可参考《自适应Shadow Bias算法》[6]的分享），因此IllusionRP使用了Shadow Receiver Fragment Based Bias。 但这也有一定弊端，即IllusionRP的Shader无法和其他URP Shader一起使用，例如要添加AssetStore的Shader，就需要逐个手动适配。 // Ref: ApplyShadowBias // Should be used in fragment shader instead of vertex shader float3 IllusionApplyShadowBias(float3 positionWS, float3 normalWS, float3 lightDirection) { #ifdef _MAIN_LIGHT_SHADOWS_CASCADE half cascadeIndex = ComputeCascadeIndex(positionWS); #else half cascadeIndex = half(0.0); #endif float4 shadowBias = _MainLightShadowCascadeBiases[cascadeIndex]; float invNdotL = 1.0 - saturate(dot(lightDirection, normalWS)); float scale = -invNdotL * shadowBias.y; // In fragment shadow bias, bias direction is reversed positionWS = -lightDirection * shadowBias.xxx + positionWS; positionWS = normalWS * scale.xxx + positionWS; return positionWS; } float3 biasPositionWS = positionWS; #if APPLY_SHADOW_BIAS_FRAGMENT // Recalculate shadowCoord when using shadow bias in fragment shader biasPositionWS = IllusionApplyShadowBias(positionWS, normalWS, lightDir); #if !(defined(_MAIN_LIGHT_SHADOWS_SCREEN) && (SURFACE_TYPE_RECEIVE_SCREEN_SPACE_SHADOWS)) shadowCoord = TransformWorldToShadowCoord(biasPositionWS); #endif #endif half realtimeShadow = IllusionMainLightRealtimeShadow(shadowCoord); 屏幕空间光照实现细节 屏幕空间光照接入了SSAO（使用了GTAO算法）、SSGI、SSR。 SSR在我之前的文章《UPR Screen Space Reflection实践》[7]中提过，和HDRP不同的是IllusionRP额外包含了几个更轻量的不使用Hiz的方法。 对于前向渲染管线中实现屏幕空间光照的工程实践中，我认为有以下几个重点： GBuffer获取 因为IllusionRP没有侵入式修改URP，所以是单独增加了一个ForwardGBufferPass来收集GBuffer数据，更好的方式是放在DepthNormalPass中MRT一起收集。 历史帧管理 SSR和SSGI都有时域混合降噪的需求，因此管线需要支持历史帧管理。 SRP提供了BufferedRTHandleSystem专门用于管理历史帧RT资源的Swap。 需要注意的是BufferedRTHandleSystem只会在RT尺寸超过缓冲区大小时重新创建RenderTexture，而如果是RT尺寸变小，则直接通过修改全局的HistoryRTHandleSize来调整采样和写入时的UV，减少运行时DynamicResolution伸缩RTSize带来的Allocation。 URP下因为不开启DynamicResolution可以关掉这个功能，防止手机屏幕转动导致RTSize不匹配。 var descriptor = renderingData.cameraData.cameraTargetDescriptor; var viewportSize = new Vector2Int(descriptor.width, descriptor.height); _historyRTSystem.SwapAndSetReferenceSize(descriptor.width, descriptor.height); // Since we do not use RTHandleScale, ensure render texture size correct if (_historyRTSystem.rtHandleProperties.currentRenderTargetSize.x > descriptor.width || _historyRTSystem.rtHandleProperties.currentRenderTargetSize.y > descriptor.height) { _historyRTSystem.ResetReferenceSize(descriptor.width, descriptor.height); } AsyncCompute优化 屏幕空间光照的计算量比较大，因此可以使用AsyncCompute来提高效率。 根据英伟达Advanced API Performance: Async Compute and Overlap[8]的建议，我们可以在Compute Queue中计算SSR、GTAO，在Graphics Queue中做一些计算较轻的任务，例如阴影图的渲染。 所以我们可以在启用AsyncCompute时修改一下URP中MainLightShadowCasterPass和AdditionalLightsShadowCasterPass的renderPassEvent。注意这里在实践中发现URP 14.0.12因为会在场景中没有相应光源阴影时Clear了错误的RenderTarget，会导致Pre-Z深度被清掉，所以要视情况调整顺序。 // Re-order light shadow caster pass renderPassEvent better for async compute. // Ref: https://developer.nvidia.com/blog/advanced-api-performance-async-compute-and-overlap/ bool enableAsyncCompute = _rendererData.PreferComputeShader && IllusionRuntimeRenderingConfig.Get().EnableAsyncCompute; // Shadow Caster has bug in URP14.0.12 when there is no main/additional light in scene which will clear pre-z. // So skip re-order when shadow is not rendered. bool reorderMainLightShadowPass = enableAsyncCompute && CanRenderMainLightShadow(renderingData); var mainLightShadowCasterPass = UniversalRenderingUtility.GetMainLightShadowCasterPass(renderer); if (mainLightShadowCasterPass != null) { mainLightShadowCasterPass.renderPassEvent = reorderMainLightShadowPass ? IllusionRenderPassEvent.LightsShadowCasterPass : RenderPassEvent.BeforeRenderingShadows; } bool reorderAdditionalLightShadowPass = enableAsyncCompute && CanRenderAdditionalLightShadow(renderingData); var additionalLightsShadowCasterPass = UniversalRenderingUtility.GetAdditionalLightsShadowCasterPass(renderer); if (additionalLightsShadowCasterPass != null) { additionalLightsShadowCasterPass.renderPassEvent = reorderAdditionalLightShadowPass ? IllusionRenderPassEvent.LightsShadowCasterPass : RenderPassEvent.BeforeRenderingShadows; } 半分辨率优化 屏幕空间光照如果启用全分辨率，可以在Shader中直接LoadTexture减少一个Sampler，但带宽会吃的比较紧。如果使用半分辨率，就需要在ForwardPass中额外使用一个Sampler，这对于前向渲染来说比较够呛（启用SSR+SSGI+SSAO三个效果的话则可能需要3个Sampler）。 因此HDRP对ScreenSpaceLighting功能普遍使用Compute In Half Resolution+Upsampling to RTSize的做法，Lighting就可以直接LoadTexture，这比URP原生SSAO只做半分辨率更为合理。 全局光照方案细节 在Unity 2022中，URP还未支持APV，只有LightMap+LightProbes一套原始人方案。前者纯静态、需要UV2，后者面向动态物体，但由于URP没有LightProbeProxy构建3D纹理，只能逐物体对附近的LightProbe做插值，这使得角色模型等复杂模型的间接光光照效果很差（与环境不一致）。 所以IllusionRP亟需接入一套更方便的全局光照方案。 接入SSGI是比较轻松的，但SSGI由于无法获取屏幕外的信息，我们还需要有一套Fallback的方案，因此IllusionRP采取了PRTGI+SSGI的全局光照组合。 预计算辐照度全局光照 预计算辐照度全局光照（PRTGI）的实践在我之前的文章《Unity预计算辐照度全局光照PRTGI实践与拓展（上）》[9]和《Unity预计算辐照度全局光照PRTGI实践与拓展（下）》[10]已介绍过，其缺点就是很容易丢失高频的漫反射信息，所以对于室内细节表现不佳。 编辑器工具上，我提供了和APV一样的Adjustment Volume来调整Probe位置。 屏幕空间全局光照 迁移HDRP中的SSGI方案后，将fallback到APV的部分改成采样PRT的IrradianceVolume即可。 参考了HDRP和jiaozi158/UnitySSGIURP[11]中的做法，对未命中的像素fallback到AmbientProbe和ReflectionProbes上。 HDRP中的AmbientProbe来自物理天空盒，URP中我们暂未实现类似功能，因此直接使用RenderSettings中的ambientProbe（即LightSettings中设置的环境颜色）。 private void BindAmbientProbe(CommandBuffer cmd) { SphericalHarmonicsL2 ambientProbe = RenderSettings.ambientProbe; _ambientProbeBuffer ??= new ComputeBuffer(7, 16); var array = new NativeArray<Vector4>(7, Allocator.Temp); array[0] = new Vector4(ambientProbe[0, 3], ambientProbe[0, 1], ambientProbe[0, 2], ambientProbe[0, 0] - ambientProbe[0, 6]); array[1] = new Vector4(ambientProbe[1, 3], ambientProbe[1, 1], ambientProbe[1, 2], ambientProbe[1, 0] - ambientProbe[1, 6]); array[2] = new Vector4(ambientProbe[2, 3], ambientProbe[2, 1], ambientProbe[2, 2], ambientProbe[2, 0] - ambientProbe[2, 6]); array[3] = new Vector4(ambientProbe[0, 4], ambientProbe[0, 5], ambientProbe[0, 6] * 3, ambientProbe[0, 7]); array[4] = new Vector4(ambientProbe[1, 4], ambientProbe[1, 5], ambientProbe[1, 6] * 3, ambientProbe[1, 7]); array[5] = new Vector4(ambientProbe[2, 4], ambientProbe[2, 5], ambientProbe[2, 6] * 3, ambientProbe[2, 7]); array[6] = new Vector4(ambientProbe[0, 8], ambientProbe[1, 8], ambientProbe[2, 8], 1); _ambientProbeBuffer.SetData(array); array.Dispose(); cmd.SetGlobalBuffer(IllusionShaderProperties._AmbientProbeData, _ambientProbeBuffer); } 后处理提升细节 这块因为笔者用的模型不是那么写实（日式写实），使用ACES时感觉效果不好，就找了两个方案： Filmic ACES Tonemapping：参照UE将ACES参数暴露出来, 根据场景调整。 Gran Turismo Tonemapping：使用更简单的曲线在不降低饱和度的情况下拉低过曝区域。 到应用方面，URP实际也给我们预留了扩展接口, 我们可以创建一个新的PostProcessData，在里面替换后处理Shader，然后在原有Shader基础上增加关键词控制Tonemapping算法即可。 从接入结果上看，对于我这里的角色，个人认为GT效果要更好些。 工作流细节 着色器开发流程 IllusionRP将Shader开发分为了设计层和模型层，插件为每个新增的ShadingModel都提供了Amplify Shader Editor的Template，设计师可以像在HDRP或UE中一样直接选择ShadingModel练练看创建Shader， 并且使用了HDRP的Surface Input提供了几个示例（HD Skin、HD Hair、HD Fabric等）。 IllusionRP使用ASE作为设计层工具的一大原因是ASE的模板开发比较方便，可以通过注释来创建静态编译选项，例如Hair Template就可以直接在ASE编辑器中切换KajiyaKay和Marschner模型。 效果管理流程 IllusionRP将为每个效果在RendererFeature上提供了全局开关，方便在不同平台下切换可使用的效果，以及之后方便剔除Shader变体。其次提供了VolumeComponent方便配置效果参数以及Gameplay侧的效果开关。 渲染调试流程 RendererDebugger是Unity原生的运行时调试工具，但可惜不支持非侵入式的拓展，所以我基于之前写的开发框架AkiKurisu - Chris[12]走了自己的一套调试方式。 Chris提供了类似Unreal的CVar功能，我们可以在控制台中输入变量来调整运行时启用的渲染效果以及一些Debug效果。 构建流程 对于渲染管线，构建部分要考虑的实际就是Shader变体控制，IllusionRP完全参考URP的Shader变体管理，使用Shader Prefiltering和Variant Stripper两个工具来分别进行剔除。 Shader Prefiltering是Unity提供的粗筛漏斗，但这里存在设计问题导致有一些坑点。 例如[RemoveIf]和[SelectIf]这几个Attribute只能作用在Const或序列化字段中，静态字段是不生效的。其次因为URP会在自己的Prefiltering中剔除掉例如ScreenSpaceShadow和ScreenSpaceOcclusion，我们需要标记overridePriority为true来覆盖URP的设置。 public partialclassIllusionRendererFeature { // Notice that prefilter attribute only works for serialized fields and constants internalenum PrefilterMode { Remove, // Removes the keyword Select, // Keeps the keyword SelectOnly // Selects the keyword and removes others } // Override priority in UniversalRenderPipelinePrefiltering // Prefer Depth Normal, see GroundTruthAmbientOcclusion.cs [SelectIf(true, overridePriority: true, keywordNames: ScreenSpaceAmbientOcclusion.k_SourceDepthNormalsKeyword)] privateconstbool RequiresScreenSpaceOcclusionDepthNormals = true; [RemoveIf(PrefilterMode.Remove, keywordNames: IllusionShaderKeywords._SCREEN_SPACE_SSS)] [SelectIf(PrefilterMode.Select, keywordNames: new [] {"", IllusionShaderKeywords._SCREEN_SPACE_SSS})] [SelectIf(PrefilterMode.SelectOnly, keywordNames: IllusionShaderKeywords._SCREEN_SPACE_SSS)] [SerializeField] internal PrefilterMode ScreenSpaceSubsurfaceScatteringPrefilterMode = PrefilterMode.Select; } Variant Stripper是更加精细化针对特定变体的剔除工具。IllusionRP直接参考URP，对于每一个Shader变体判断关键词组合是否有效，例如开启SURFACE_TYPE_TRANSPARENT后，因为没有写入深度，就不应该使用屏幕空间的效果例如SSAO、SSR、SSAO，我们就可以在Variant Stripper中剔除掉这些变体。 private bool StripUnusedFeatures_ScreenSpaceOcclusion(ref ShaderStrippingData strippingData, ref ShaderStripTool<ShaderFeatures> stripTool) { if (strippingData.IsShaderFeatureEnabled(ShaderFeatures.ScreenSpaceOcclusion)) { // Transparent strip ssao if (strippingData.IsKeywordEnabled(_surfaceTypeTransparent) && strippingData.IsKeywordEnabled(_screenSpaceOcclusion)) returntrue; if (stripTool.StripMultiCompileKeepOffVariant(_screenSpaceOcclusion, ShaderFeatures.ScreenSpaceOcclusion)) returntrue; } else { if (stripTool.StripMultiCompile(_screenSpaceOcclusion, ShaderFeatures.ScreenSpaceOcclusion)) returntrue; } returnfalse; } 参考 [1] 图形引擎实战：HDRP移动版高清渲染管线改造 [2] DanbaidongRP [3] 如何在Unity URP中让头发渲染更丝滑 [4] stalomeow/StarRailNPRShader [5] recaeee/RecaNoMaho_P [6] 自适应Shadow Bias算法 [7] UPR Screen Space Reflection实践 [8] Advanced API Performance: Async Compute and Overlap [9] Unity预计算辐照度全局光照PRTGI实践与拓展（上） [10] Unity预计算辐照度全局光照PRTGI实践与拓展（下） [11] jiaozi158/UnitySSGIURP [12] AkiKurisu - Chris 参考资料 Matt - Approximating Subsurface Scattering With Spherical Gaussians Unity实现SSS皮肤次表面散射 jplee - Extra technique for Skin shader by URP jplee - UE4 ACES Tone mapping port URP Naughty Dog - The Process of Creating Volumetric-based Materials in Uncharted 4 NVIDIA - Weighted Blended Order-Independent Transparency Casual Effects - Weighted, Blended Order-Independent Transparency ZZNEWCLEAR13 - 在URP的片元着色器中应用阴影偏移 其他参考项目可以在项目文档中查看。 仓库链接 AkiKurisu - IllusionRP 这是侑虎科技第2002篇文章，感谢作者AkiKurisu供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/akikurisu 再次感谢AkiKurisu的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
资源回收“两头堵”：RUUA调了会卡，不调内存又涨
1）资源回收未生效的典型排查路径 2）Mono与其它同时偏高时，应该优先优化谁 这是第483篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：为了控制内存增长，我们把Resources.UnloadUnusedAssets（RUUA）挂在打开背包、地图这类全屏UI上执行。结果调用了UI会卡，不调用内存又一直涨。我们项目是无缝地图，没有切场景的天然时机，感觉怎么选都不对。UnloadUnusedAssets到底该什么时候调用？开放世界项目如何解决这种“两头堵”的问题？ A：很多团队遇到这个问题，第一反应都是继续调整UnloadUnusedAssets的调用时机，但大多数情况下，真正的问题并不在“什么时候调用”，而在于“这次调用有没有真正回收到资源”。 建议先打开UWA GOT Online分析报告，查看Asset数量曲线，重点关注每次RUUA调用点前后的变化。正常情况下，如果RUUA真正生效，Asset数量通常会出现明显下降；如果曲线几乎没有变化，就说明这次调用很可能没有释放任何资源，属于一次“空跑”。 这种情况，问题往往不是RUUA没调用，而是对应的AssetBundle并没有真正卸载。如果AssetBundle仍保持加载状态，其中包含的资源通常无法进入可释放状态，RUUA即使执行，也可能找不到可回收对象。结果就是白白做了一次资源扫描，不仅没有释放资源，还额外带来一次主线程卡顿。因此，排查顺序建议反过来：先确认RUUA是否真的释放了资源，再检查调用时机，而不是一开始就不停调整调用位置。如果Asset曲线没有明显变化，就优先检查AssetBundle的卸载策略。 当然，也有一种情况属于设计使然。项目为了避免资源反复加载带来的卡顿，通常会主动缓存一部分AssetBundle。如果这些Bundle本身就是计划常驻内存，那么RUUA自然也无法回收其中的资源。这种情况下，更合理的做法不是频繁调用RUUA，而是建立AssetBundle分级缓存机制，例如根据最近访问时间（LRU）、玩家当前位置或距离管理Bundle生命周期，让高优先级资源保持常驻，低优先级Bundle在Application.lowMemory等系统内存紧张事件触发时统一卸载，再执行资源回收。 至于调用时机，目前业内比较常见的做法主要有两种：固定时间间隔轮询，例如每5～10分钟执行一次；或者监听Application.lowMemory回调，在系统内存压力较大时再主动回收。将RUUA挂在打开背包、地图等全屏UI上也并非不可行，因为玩家通常能够接受这类界面存在一定的等待时间，但前提是这次调用必须真正有收益，而不是一次没有任何效果的“空跑”。因此，与其不断调整RUUA的调用时机，不如先确认它是否真正回收了资源，再决定后续的优化方向。 实战案例 Q：我们项目Mono峰值已经到了400MB，Reserved Total里还有接近1GB的其它，Memory Profiler又定位不到来源。这种情况到底该先压Mono，还是先查其它？ A：很多团队看到Mono超过400MB，第一反应就是优化GC、对象池，甚至重构代码。但很多时候，真正占用内存最多、也最容易被忽略的，并不是Mono，而是那块来源复杂、定位困难的其它。 Mono是否合理，并没有一个固定的“标准值”，真正应该先确定的是整机的PSS总预算。对于3GB RAM设备，通常建议将PSS控制在约1.3～1.5GB；4GB RAM设备建议控制在约1.8～2.2GB。更高内存设备虽然预算更宽松，但也并不是可以无限增长，即使是8GB RAM设备，当进程PSS持续接近3GB，在系统可用内存紧张或存在持续泄漏的情况下，仍然有被系统回收的风险。相比Android，iOS的预算通常还需要更加保守。 PSS总预算确定之后，Mono、纹理、Mesh、音频、Native等模块，其实都是在同一份预算里分配空间。也就是说，Mono如果希望多占用100MB，就意味着其他模块需要相应减少100MB。因此，400MB的Mono是否合理，并不能脱离整个项目的内存预算单独判断，而应该结合各模块的内存占用综合评估。 如果其它已经接近1GB，那么相比继续压缩Mono，更建议优先弄清楚这部分内存到底来自哪里。排查时可以先使用Memory Profiler分析内存快照，如果能够直接定位到来源，就优先解决对应问题；如果Memory Profiler无法解释这部分内存，再进一步结合UWA Gears或Android Studio排查NativeHeap，重点关注Unity无法直接归类的Native分配。如果怀疑来自Lua或其他脚本引擎产生的Native内存，还可以进一步使用Perfetto做符号化分析，定位具体的分配来源。 另外需要注意的是，Android系统以及Unity无法进一步细分归类的一部分Native内存，也可能表现为其它，因此分析其它时建议结合Memory Profiler、Native Heap分析工具以及运行时数据进行交叉验证，而不要仅凭单一工具下结论。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
小游戏发热的系统排查与性能调优实战
1）小游戏发热问题的系统排查法 2）iOS高性能模式下的内存与合批博弈 这是第482篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们项目最近发热问题比较严重，玩家反馈集中在“玩一段时间后手机发烫”。我们用UWA GOT Online跑了一份小游戏性能报告，针对里面CPU占比偏高的几个模块做了一轮优化，但实际跑下来降温效果并不明显，想问一下，除了CPU模块本身，是不是还有别的维度漏看了？这种发热问题该怎么系统排查？ A：移动端发热可以从CPU、GPU、屏幕、网络四个维度切入。屏幕和网络我们也做过测试，对发热确实有贡献，但很难单独拆出来，真正能动手优化的还是CPU和GPU。 图源：微信官方文档 受小游戏接口限制，UWA GOT Online小游戏报告暂时拿不到CPU/GPU温度数据，我们改用UWA Gears工具补测后发现，CPU温度持续高于GPU，所以这个项目的发热主导确认是CPU。接下来拆解GOT Online的CPU数据，可以看到问题主要集中在三类结构性负载。 第一类是战斗逻辑模块。高负载场景下出现明显的帧级Spike，单帧耗时甚至超过100ms，本质是战斗Tick在复杂场景中的集中执行，造成阶段性压力堆积。 第二类是渲染模块。整体平稳，但大厅与武器、复杂UI界面耗时升到20ms左右，主要来自半透明对象渲染造成的Overdraw，以及UI嵌套过深导致的Batch Break，DrawCall超过250的UWA推荐阈值。 第三类是UI模块。均值耗时8-9ms（高于UWA推荐值），CanvasRenderer.SyncTransform调用频次异常高（峰值达940次），根因是UI状态切换过于频繁（SetActive/Instantiate/销毁），重建链路反复触发，形成尖刺型耗时。 除上面三类主要负载，CPU里还有一个容易忽略的次级开销“物理空转”，报告里Physics 3D的Overlaps调用次数全程为0，说明项目没触发过3D物理碰撞（实际可能只用到Physics 2D的射线检测或重力），但物理模块依然按默认Fixed Update模式持续Step。如果业务上不依赖自动模拟，可以把Update Mode改成Script，如果还需要射线检测，再勾上Auto SyncTransform保证Transform实时同步。 回到这个项目，发热的本质不是单点CPU过高，而是CPU内部三个模块的负载叠加，逻辑代码的实例化Spike、渲染的Overdraw与Batch Break、UI的重建过频。物理空转是顺手处理的小尾巴，GPU整体只是次级贡献。 实战案例 Q：我们iOS端开了高性能加模式，图标比较多，如果都打到一张图集里，会导致内存占用偏高，你们之前有没有遇到过这种情况？有什么优化建议吗？ A：在这种配置下，图集带来的“内存增长”，需要先区分是工具统计层面的变化，还是实际会影响闪退风险的系统级压力变化，否则容易出现判断偏差。 在iOS高性能模式下，纹理相关内存的分布机制与Unity Heap并不完全一致，部分纹理资源会进入系统图形层（GPU/iOS图形内存池）进行管理，因此在小游戏侧看到的内存增长，并不一定完整等价于最终的进程级内存压力。 小游戏的闪退判定也不是只看Unity Heap总量，而是取决于整体内存压力来源，包括WebContent进程RSS、Native内存占用、GPU纹理压力以及系统Jetsam机制的综合结果。因此，图集带来的内存变化本身，并不必然直接转化为闪退风险。 反过来看散图，省的是内存，但代价在CPU和渲染链路上。散图是按需加载的（比如100张图只用20张，其余不会进入内存），从“单次内存占用”来看确实更轻。但碎片化资源会导致UI合批被打断，纹理切换增加，同时UI提交阶段CPU开销上升，这部分开销在小游戏环境下往往比单纯的纹理内存增长更容易影响帧率与发热。 图集方案则是另一种权衡：它会带来一次性的加载成本，包括贴图解码与GPU上传，同时内存占用更集中。但换来的收益是更稳定的合批效果、更少的纹理切换，以及更低的UI渲染提交CPU开销。 所以在图标较多的UI场景下，在iOS高性能加模式+无Read/Write纹理的前提下，我们一般仍然建议优先使用图集。首次加载虽然有额外开销，但在稳定运行阶段，整体CPU成本和渲染稳定性会更优。从工程角度看，这里的关键不是“图集是否更省内存”，而是它在CPU渲染链路上的收益通常会覆盖内存集中带来的成本。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
为什么驻留对象没增涨，而Used Mono却一直在增涨
1）为什么驻留对象没增涨，而Used Mono却一直在增涨 2）为什么粒子内存一直在增涨，场景里却没新增特效 这是第481篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：项目近期在排查Mono内存问题，从已跑测的UWA GOT Online Mono模式报告可以观测到：托管堆内存碎片持续上涨，但Managed Object总量几乎无增长，想咨询一下这类现象一般是什么原因造成的？ A：我们可以去GOT Online Mono模式报告的「堆内存对象快照」看一眼碎片占比的情况。需要注意的是，Mono内存上涨并不一定意味着驻留对象增长，也可能是堆内存碎片增长。在Mono模式报告里，Used Mono=Managed Object（实际对象）+堆内存碎片。这两块在「堆内存对象快照」里会分开显示，如下图所示，粉色是Managed Object，蓝色是内存碎片。 从这份报告可以看到，在15000～20000帧之间的Used Mono呈持续上涨趋势。按照常规思路，第一步通常会查看驻留对象，排查是否存在内存泄漏。但进一步分析发现，驻留对象并没有出现与上涨趋势相匹配的增长，继续拆解Used Mono的组成可以看到，真正上涨的并不是Managed Object，而是堆内存碎片。 判断依据很简单： 如果Managed Object曲线基本稳定，而Used Mono持续上涨，同时碎片占比不断扩大，那么问题大概率不是内存泄漏，而是频繁分配导致的堆碎片增长。这是Mono堆的典型特征之一：Mono堆通常按需扩容，而已经申请的堆空间一般不会随着对象释放而立即归还给操作系统。 当项目中频繁发生小对象的“分配 — 释放”行为时，即使Managed Object总量变化不大，也会在堆中留下大量无法连续复用的空闲区域。随着碎片不断累积，Mono堆会持续扩容，最终推高Used Mono。 在部分项目中，内存碎片甚至可能接近Managed Object的1:1水平，例如实际托管对象占用约200MB，而Used Mono最终可能被推高到接近400MB。 这种情况下，需要重点排查的不是“谁没有释放”，而是“谁在频繁分配”。LINQ、字符串拼接、装箱拆箱、协程闭包、临时List、频繁创建委托等，都是最常见的碎片来源。因此，当Mono内存持续上涨时，不要急着下结论认为发生了泄漏。先拆解Used Mono的组成，区分清楚究竟是对象增长，还是碎片增长，再决定下一步的优化方向。 实战案例 Q：从UWA GOT Online报告中可以看到，粒子系统内存持续上涨，总占用已超过100MB，但对应场景中并没有新增大量特效，运行过程中也未观察到明显的粒子播放行为。这种情况下，应该如何进一步排查？ A：很多项目会采用粒子系统预热策略：进入战斗时批量Instantiate特效实例，按粒子类型预生成缓存池，等到需要时再直接激活。这种方案的优势是能够规避运行时实例化带来的卡顿。但代价同样存在：如果缓存池缺少合理的回收机制，这些粒子系统及其关联资源就会长期驻留内存，即使运行期间从未真正发射过粒子。 GOT Online报告提供了一个非常实用的过滤条件：「Playing组件数峰值为0的粒子系统资源」。勾选后，可以快速筛选出当前仍占用内存、但Playing组件数峰值为0的粒子系统。换句话说，就是那些预热完成后，到当前采样时刻为止从未真正触发过的特效。 从报告中可以看到，粒子系统总占用超过100MB。开启过滤后发现，其中相当一部分都属于Playing组件数峰值为0的粒子系统 —— 它们被提前预热进缓存池，却始终没有被实际使用。 排查思路建议分三步： 第一步：看粒子系统内存走势。 如果内存呈现明显的阶梯式上涨，而非随场景变化平滑波动，大概率说明粒子系统存在分批预热行为。 第二步：统计Playing组件数峰值为0的粒子系统的占比。 如果占比过高，说明当前预热策略可能过于激进。 第三步：结合资源路径进行归类分析。 GOT Online报告支持向上追溯5个层级的资源路径。可以按技能特效、场景特效、UI特效等维度进行分类，找出那些从未实际播放过的粒子系统，再决定是改为按需加载，还是直接从预热列表中移除。 需要注意的是，Playing组件数峰值为0并不意味着这些特效永远不会被使用，而是代表截至当前采样时刻尚未触发过。因此，在调整预热策略前，还需要结合实际触发频率和业务场景综合评估，避免误伤低频但关键的特效资源。预热本身没有问题，但“预热后长期驻留却从未使用”，就是典型的资源浪费。粒子系统的预热策略，应与实际触发频率相匹配。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
1）WaitForTargetFPS单帧60ms，根本不是在“等帧” 2）UGUI常驻7ms比战斗场景还高，问题出在哪 这是第480篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们在UWA GOT Online的小游戏报告里发现，卡顿点分析中WaitForTargetFPS这一项耗时很高，单帧能到60ms。但项目本身帧率已经很低了，理论上不应该还有时间在“等帧”，这个WaitForTargetFPS到底是什么？ A：在移动端APP报告中，WaitForTargetFPS通常表示主线程提前完成一帧、为保持目标帧率而空转等待的时间。但在小游戏报告里，由于运行机制不同，WaitForTargetFPS的含义完全变了。 小游戏环境下，由于运行时机制与原生APP不同，GC调用栈无法像APP侧那样被完整采集和展示，因此GC耗时不会以GC.Collect等函数形式直接出现在报告中。实际采集过程中，这部分耗时通常会被归入同步等待相关模块，在GOT Online报告中常表现为WaitForTargetFPS的异常升高。所以在小游戏报告里如果看到WaitForTargetFPS出现周期性尖峰，并且与Mono Used的下降拐点高度对应，那么大概率是GC导致的停顿，而不是空闲等待。 如何反推GC的真实调用时机？ 可以结合Mono Used走势定位：Mono Used每出现一次明显下降的拐点，理论上都对应一次GC调用。把这些下降点和WaitForTargetFPS的高峰对齐看，就能判断哪些卡顿是GC导致的。 为什么小游戏的GC比APP上更需要重点关注？ 小游戏的Mono内存属于Unity Heap中的一部分，受运行机制影响是“只增不降”的。Mono Reserved通常会停在历史峰值附近，不会随着Mono Used的下降而立即回收，因此一次大额分配往往会长期抬高Mono内存占用。项目实测中，单局Mono已经在220MB，部分场景能到260MB左右；连续关卡游玩时，Mono Reserved往往会不断抬升，持续挤压项目内存上限，加剧内存压力。 GC在小游戏上既会直接造成卡顿，也往往暴露出托管内存增长过快的问题，所以排查优先级要比APP上更高。 优化建议 定位GC高频分配源头 如果项目同时有APP版本，可以用APP跑一份GOT Online Mono模式报告，先从正序调用中筛选单帧分配峰值较高的函数，再通过倒序调用排查持续产生内存分配的子节点。 控制Mono峰值 由于Mono Reserved很难主动回落，单次大额分配往往会长期抬高内存占用，重点排查战斗开局、UI切换等场景中是否有非必要的临时对象分配，能用对象池替代的优先复用。 关注连续游玩的累积曲线 不要只看单局Mono占用，而是要观察连续2-3局后的Mono Reserved是否还持续抬升，这是判断是否有托管对象在持续驻留增长（例如对象被意外引用、缓存未释放、对象池管理异常等）的关键信号。 实战案例 Q：我们在UWA GOT Online的主场景报告里，看到UGUI的耗时持续有7ms左右，比战斗场景还高。从堆栈定位到Rendering UpdateBatches自身耗时占比很大，是不是项目里SetActive调得太频繁导致的？ A：方向是对的。大概率就是SetActive频繁触发了SyncTransform，这是UGUI优化里非常典型的踩坑。不过SetActive是最常见原因之一，除此之外，RectTransform尺寸变化、LayoutGroup重排、ContentSizeFitter更新和SetParent等操作，同样可能持续触发SyncTransform，仍需结合调用堆栈进一步确认。 定位过程很清晰：UGUI的主耗时入口是Rendering.UpdateBatches，但这一项的内部走向决定了真正的问题在哪。常见情况下，耗时会集中在Canvas.SendWillRenderCanvases节点；但项目报告里看到的是UpdateBatches的自身耗时占比异常大，这就指向一个特定函数 —— CanvasRenderer.SyncTransform。 在4000帧的采样窗口内，SyncTransform的调用次数持续维持在300次左右。这个调用次数和UpdateBatches自身耗时占比是高度相关的 —— 次数越高，自身耗时越高，UGUI整体开销也跟着上升。 需要注意的是，SyncTransform调用次数升高本身并不是问题的根源，而是UI Transform脏标记（TransformDirty）持续产生后的表现。当节点的激活状态、层级关系、位置尺寸等属性频繁变化时，Unity会不断同步Transform数据；这些变化最终又会传递到Canvas系统，触发Batch重建和Canvas更新。因此，排查SyncTransform时，重点不应只关注耗时本身，更要关注究竟是谁在持续制造TransformDirty。 为什么SetActive会触发SyncTransform？ 当代码调用GameObject.SetActive(true)时，被激活节点所在的UI层级会触发Transform同步与Canvas脏标记更新。在复杂Canvas结构下，这种影响可能进一步扩散到大量关联UI元素。如果项目里某些UI元素被高频显隐，例如战斗HUD的伤害飘字、状态图标、计时器等，每显示一次就让整个Canvas重新同步一次Transform，SyncTransform调用次数就会持续飙升。 更隐蔽的情况是，频繁显隐的UI和不需要变化的静态UI共用同一个Canvas，那么每一次SetActive都会牵连静态部分一起重算，额外产生大量无效重建开销。 优化建议 首选用localScale 0/1替代SetActive做显隐 对于显隐频率高、生命周期短的UI元素（如：伤害飘字、状态图标、计时器等），把localScale在0和1之间切换的这种方式不会触发SyncTransform的持续调用，对Update Batches的开销几乎可以忽略。对中长生命周期、复用率高的元素，优先走对象池；需要保留位置和状态、只是临时隐藏的场景，再考虑Alpha渐隐方案。 把频繁显隐的UI做动静分离 把高频显隐的UI节点从主Canvas中拆出来，单独放在一个独立Canvas下。这样每次显隐只影响这个小Canvas，主Canvas下的静态UI不会被连带触发重绘计算。 排查嵌套Canvas的覆盖范围 如果某个父级Canvas下挂了大量子元素，子元素一旦频繁变化，整个父级也会受影响。因此可以结合SyncTransform调用次数曲线，查看哪些场景峰值最高，从而反推是哪个Canvas在被反复牵动。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
UE Toon渲染半影着色实现指南：纯色法与Ramp法详解
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 一、前言 在动画或者卡通渲染存在一个很常见的效果，光源的边界染上一个饱和度较高的颜色，这个效果业内一般称作半影着色： 《吹响吧！上低音号》 《别当欧尼酱了》 《绝区零》 在HDRP中也有类似的效果： Unity里的实现非常简单，就是拿一个纯色来给半影染色： 除了Unity的这种实现方式，还有一种常见的实现方法是用阴影来采样Ramp： https://zhuanlan.zhihu.com/p/588991753 https://zhuanlan.zhihu.com/p/688482736 二、实现思路 2.1 纯色法 首先在光源组件上添加新的参数： LightComponent.h 接下来正常把这些参数传给Shader就行，半影颜色的计算直接抄HDRP： ToonLightCommon.ush 在光照计算的时候调用ComputeShadowColor： DeferredLightingCommon.ush 接下来就能看到效果了： 半影着色_1 效果还挺不错的，多光影也可以完美兼容： 不过，有一个尴尬的问题，从上面的视频效果图可以发现，主光的半影着色并没有作用在Toon角色上，这也正常，毕竟Toon角色的主光是在ToonLightPass里单独计算的。 按照正常的逻辑，我们应该把染过色的Shadow传给ToonLightPass，但是，这样需要再用3个通道来存储阴影，之前的ToonShadow已经满了，这样得新创建一张，有些浪费了。 所以我干脆还是把半影的参数传到ToonLightPass，然后在ToonLightPass自己去计算。 这样做还有个好处，如果觉得半影颜色在角色上有点太强了，很容易可以调节强度： 半影着色_2 2.2 Ramp法 同样的，在光源上添加新的参数： 传参方式和以往有所区别，如果把Ramp贴图放到FLightShaderParameters里的话会报错： SceneManagement.h 所以我把贴图放到FDeferredLightUniformStruct里，FToonLightUniformParameters也一样： LightRendering.h ToonLightPassRendering.h 采样贴图的时候由于不同Pass里Buffer的名字不一样，所以在Shader上添加宏来区分： LightRendering.cpp ToonLightPassRendering.h ToonLightCommon.ush 在材质编辑器里也使用相同的函数来计算半影： 看看效果： 三、参考和链接 3.1 链接 本文的修改都上传GitHub了，对应下图的提交记录： https://github.com/Yu-ki016/UnrealEngine/tree/YK_Engine 示例工程： https://github.com/Yu-ki016/YKEngineExampleProject 3.2 参考 [1] Unity URP 8Cascade+PCF/PCSS 阴影+Compute Shader屏幕空间shadowmap+阴影渐变 [2] 图形引擎实战：8级风格化级联阴影 这是侑虎科技第1990篇文章，感谢作者Yu-ki016供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/yu-ki-016 再次感谢Yu-ki016的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）AssetBundle粒度过粗导致Remapper占用上升 2）CPU发热问题优化 这是第479篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们在UWA GOT Online报告里发现，PSS内存会在某些场景出现明显暴涨，通过Memory Profiler定位到Remapper占用很高。想咨询一下，是不是AssetBundle包分得太细导致的？ A：方向正好相反 —— 问题不是包分得太细，而是打得太粗了。 通过PSS走势图结合资源列表的生命周期，很快就能定位到导致内存暴涨的源头：资源列表里一个关键AssetBundle —— defaultpackage_share_assets_art_prefab_scenes.bundle。只要它被加载，内存就会出现明显暴涨。 进一步查看AssetBundle检测报告，就会发现异常信号：Bundle本体大小达到232MB，但包内并不包含大量纹理、网格等典型大体积资源；同时在Resource模式下，AssetBundle资源类的内存占用就有22MB，一个没有主流资源的包，本体却超过200MB，本身就说明包内资源组织有问题，也侧面印证了Memory Profiler里看到的Remapper高占用。 基于以上信息，建议重点排查该Bundle的打包结构，主要确认三件事：是不是把多个场景打进了同一个Bundle、是不是包含了大量当前场景实际用不到的资源、是不是公共资源归属不合理导致依赖聚合。从以往项目经验看，如果只保留当前实际需要的那一个场景，内存占用通常不会出现这种量级的上涨。 为什么“包打得太粗”会同时拉高Remapper和PSS？ 当多个场景被打进同一个AssetBundle时，即使当前只使用其中一个场景，Bundle中大量其他资源及其依赖关系仍会被加载到内存。与此同时，Bundle内对象数量和依赖关系增加，Unity维护对象映射和资源引用关系所需的数据结构也会随之膨胀，Remapper占用因此显著推高。部分资源在实际被访问后，还会继续贡献PSS。 所以经常会看到Remapper和PSS同时上涨，但两者并不完全等价。Remapper反映的是Unity内部对象映射和引用关系的开销，PSS反映的则是进程实际占用的物理内存，是同一问题在不同维度上的表现。 优化建议 控制AssetBundle粒度 场景类资源尽量保证一个场景对应一个Bundle，避免一次加载就引入大量无关资源和依赖关系。 用UWA AssetBundle检测工具排查结构 重点关注三类高风险Bundle：本体异常大的、依赖节点过于集中的和引用关系特别复杂的场景资源包。通过资源依赖路径通常可以快速定位问题。实际项目中，这类异常大的Bundle往往对应几种典型情况：如多个场景混打、公共资源归属不合理、场景资源与共享资源耦合过深、资源依赖链过长等，都是上线前性能验收阶段值得关注的检查项。 实战案例 Q：项目针对功耗问题已经做了多轮优化，目前GPU压力和带宽问题得到了明显控制，GPU温度也相应下降。但CPU侧温度问题仍然突出，请问后续应如何优化CPU发热问题？ A：从GOT Online报告中的温度曲线来看，当前设备发热主要由CPU侧贡献，其中部分区段温度有所回落。降频虽然降低了功耗，但同时也会降低处理器执行效率，导致相同工作量需要更长时间完成，因此需要重点关注。CPU发热优化的核心目标仍然是降低CPU压力，可以从以下三个方向入手。 一、逻辑代码层 优先排查LuaBehaviourUpdater.Update、LuaBehaviourUpdater.LateUpdate以及协程中的尖刺耗时，这类节点经常出现数十甚至数百毫秒的单帧开销，多数与资源加载和实例化等操作相关。另外在报告中发现，战斗场景下，LuaBehaviourUpdater.Update往往还会存在持续性的逻辑开销。从堆栈上观察到大量耗时集中在C#层，建议在关键业务节点增加PushSample/PopSample打点，以进一步定位真实热点函数。 二、物理模块 如果项目当前并未使用刚体模拟，仅依赖Raycast等物理查询功能，则可以考虑进一步优化Physics模块。 关闭自动物理模拟 将Physics的SimulationMode设置为Script，仅在需要时主动执行物理模拟，从而避免每帧固定产生的物理步进开销。 谨慎使用Auto Sync Transforms 对于大多数项目而言，更推荐关闭Auto Sync Transforms。因为该选项会在Transform发生变化时自动同步到物理系统，频繁移动对象时可能产生额外CPU开销。如果关闭后出现物理查询结果异常，可以在必要位置手动调用Physics.SyncTransforms()，从而兼顾性能与正确性。 三、动画模块：将部分计算转移至GPU 如果逻辑层和物理层的优化空间已经较为有限，可以进一步关注动画系统。在战斗场景中，Animator、SkinnedMeshRenderer以及骨骼蒙皮计算往往会占据较高CPU开销。将部分动画计算转移至GPU，通常能够有效降低CPU负载，从而降低发热。 需要注意以下两点： 1. GPU Skinning方案需要实测评估 Unity内置的GPU Skinning、Compute Shader GPU Skinning以及第三方GPU Skinning插件都各有适用场景。最终收益与角色数量、骨骼规模、平台特性以及Unity版本密切相关，因此建议结合项目实际情况进行测试验证，而不是直接采用固定方案。 2. 关注整体功耗而非单项耗时 CPU功耗与频率通常并非线性关系。很多情况下，CPU频率每下降一个档位，功耗下降幅度会明显大于性能下降幅度。因此，即使GPU负载略有增加，只要能够降低CPU大核占用率，整体发热与续航表现往往仍会得到改善。这也是许多大型手游后期采用GPU Skinning、Animation Baking等方案的重要原因。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
16G高端机反而比6G低端机更吃内存，差值究竟从哪来？
1）同一个包，高端机为什么反而更吃内存？ 2）iOS内存预算与优化策略 这是第478篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们用UWA GOT Online跑兼容性测试时发现一个反直觉的现象 —— 同一个包在16G机型上PSS Total比6G机型还高几十兆，Reserved Total两边一致，差异全在“其它”里。这“其它”是什么？ A：这不是测试误差，原因在于PSS Total的统计方式。PSS（Proportional Set Size）会将共享库按进程数进行均摊，不同设备由于系统服务、驱动模块以及ROM组件差异，导致共享库占用规模不同。因此，即使运行同一个游戏包，不同机型的PSS Total也可能出现数十MB甚至更大的差异。通常高端机系统层更复杂（高版本Framework、厂商定制更多、图形栈更重、预装服务多），加载的共享库种类和体积往往更大，分摊到游戏进程的份额自然也更多 —— 这就是为什么同一个包跑在高端机上PSS Total反而更高。这部分开销引擎层看不到，体现在PSS Total比Reserved Total多出来的差值里。 那么报告里引擎层的“其它”是什么？ 它与PSS多出来的部分不同 —— “其它”是Reserved内部未单独归类的资源，主要包括： 内存碎片预留 Scene Object（GameObject/Transform/ MonoBehaviour） 序列化文件 Cubemap等未统计资源 因此，同一个包在16G机型上比6G机型高出几十兆是正常现象，与包体无关。 内存分析建议： PSS Total反映系统整体压力，但其绝对值可能因共享库均摊而被“垫高”，跨机型横向对比容易误判； Reserved Total衡量引擎可控资源； 如果PSS Total与Reserved Total差值持续扩大，说明引擎层未统计的内存（例如Native Heap、图形驱动缓存、第三方SDK占用或文件映射内存）在增长，需要结合系统工具进一步排查。 实战案例 Q：我们目前使用GOT Online测了Android版本，Android系统上3G左右的内存峰值并没有触发大量闪退。后续要上iOS系统，有什么有优化策略吗？ A：优化思路基本通用，但iOS的内存压力通常更大。 为什么差异明显？ 当前主流Android版本的中高端机型普遍在8GB以上内存，3GB左右的内存峰值在多数设备上通常仍有一定安全余量，不一定会直接引发闪退； iOS虽然近几代iPhone也普遍在8GB内存，但由于系统采用严格内存管理机制，单个应用可使用的内存预算受系统控制。超过预算可能触发Jetsam机制被系统终止，闪退阈值通常低于3GB，而且触发时机精准。 很多项目反馈一致：Android闪退率低，但切换到iOS平台后，内存闪退问题集中暴露。因此iOS最好提前规划，避免上线再补救。 iOS优化策略： 缓存放得更保守 iOS CPU性能较强，可以接受更频繁的加载与释放。与其长期驻留资源，不如多做几次加载，把峰值压下去。 打包时开启Increase Memory Limit 对于iOS 15/16及以上系统设备，可评估开启Xcode的Increase Memory Limit，允许应用申请更高内存预算，在部分设备上可降低Jetsam闪退。但实际提升因设备型号和系统版本而异，需要真机验证。 复用Android优化成果 针对Android做的系统层优化（Shader、AB缓存、Native占用等）在iOS同样有效，无需重复实现。仅少数使用不同插件的情况需要单独处理。 建立低内存机型测试标准 开发阶段以3GB~4GB预算作为压测目标，而不是依赖高端测试机数据。可更早发现资源常驻、加载峰值和缓存策略问题，避免上线后集中爆发。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
Unity预计算辐照度全局光照PRTGI实践与拓展（上）
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 在知乎平台上，最早由网易根据育碧在GDC上分享的《Global Illumination in Tom Clancy's The Division》提出了实时PRTGI（预计算辐射度全局光照）技术的实现思路。随后，AKG4e3提供了示例工程，其内容涵盖从理论到实践的PRTGI实现过程（参见《预计算辐照度全局光照（PRTGI）从理论到实战》）。方木君则在《Unity 移动端可用实时GI方案细节补充》中，对局部光源（Local Light）的重光照（Relight）及优化方案进行了扩展。 本文内容基于上述项目的Fork版本，在学习过程中进行了进一步完善与扩展。部分修改已记录于本人的Fork仓库（AkiKurisu/UnityPRTGI）中。但由于后续开发需要在Forward渲染路径下进行，相关修改已集成至其他项目，该Fork版本已停止维护。 后续还将结合其他在URP下实现的相关Feature进行整理与开源，敬请期待。 流程概述 先总结一下《预计算辐照度全局光照（PRTGI）从理论到实战》的项目中的流程，方便后续对比： 离线烘焙生成Surfel（总计512*Probe） 按Probe顺序存储Surfel 运行时Probe拿到对应的512个Surfel Relight所有Probe 烘焙提速 原作者使用Camera.RenderToCubemap来抓取Cubemap，这个函数在GPU上的开销实际不大，手动渲染每一个面的成本并没有减少，但可以考虑改成使用另一个拓展方法，来在构建RenderList的时候忽略非静态物体： public static bool RenderToCubemap( this Camera camera, Texture target, int faceMask, StaticEditorFlags culledFlags); 通过不同场景的性能测试，烘焙这里更拖延速度的是CPU侧设置Material Shader的开销，由于需要分别设置Shader来采样Position、Albedo、Normal数据，实际开销约等于 次Shader设置，时间复杂度为 所以优化方式就是使用一个Shader，烘焙时只需要设置一次，每个Probe烘焙时通过切换Keyword来抓取所需数据。 #pragma multi_compile _ _GBUFFER_WORLDPOS _GBUFFER_NORMAL float4 frag (v2f i) : SV_Target { #if defined(_GBUFFER_WORLDPOS) // Output world position return float4(i.worldPos, 1.0); #elif defined(_GBUFFER_NORMAL) // Output world space normal return float4(i.normal, 1.0); #else // Default output albedo half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv) * _Color; return albedo; #endif } private static void SetGlobalGBufferCaptureMode(GBufferCaptureMode captureMode) { // Enable the specific keyword based on capture mode switch (captureMode) { case GBufferCaptureMode.WorldPosition: Shader.EnableKeyword("_GBUFFER_WORLDPOS"); Shader.DisableKeyword("_GBUFFER_NORMAL"); break; case GBufferCaptureMode.Normal: Shader.DisableKeyword("_GBUFFER_WORLDPOS"); Shader.EnableKeyword("_GBUFFER_NORMAL"); break; case GBufferCaptureMode.Albedo: Shader.DisableKeyword("_GBUFFER_WORLDPOS"); Shader.DisableKeyword("_GBUFFER_NORMAL"); break; } } // 对于每个Probe执行下面的代码 SetGlobalGBufferCaptureMode(GBufferCaptureMode.WorldPosition); camera.RenderToCubemap(_worldPosRT, -1, StaticEditorFlags.ContributeGI); SetGlobalGBufferCaptureMode(GBufferCaptureMode.Normal); camera.RenderToCubemap(_normalRT, -1, StaticEditorFlags.ContributeGI); SetGlobalGBufferCaptureMode(GBufferCaptureMode.Albedo); camera.RenderToCubemap(_albedoRT, -1, StaticEditorFlags.ContributeGI); 如此一来时间复杂度为O(1)，大大提升了复杂场景下的烘焙速度。 球谐优化 由于Unity 2023、Unity 6相关版本未在国内普及，其新增的APV（Adaptive Probe Volume）系统，国内相关技术讨论较少。然而，作为Unity官方新推出的GI方案，APV有大量值得学习的优化技巧。 PRT（Precomputed Radiance Transfer）和APV实际上师出同门，共通点是都不需要UV2、比LightMap方便，需要烘焙，仅支持静态场景，两者的核心差异在于： APV：每个Probe离线存储球谐系数，不支持动态光照（如TOD系统），但因为存储了SkyVisibility，可以实现动态的天光遮蔽。 PRT：存储Radiance数据和SkyVisibility，支持动态光照。 PRT缺点也很明显，因为要存储的数据更多了，使得实践上Probe放置的密度小于APV的密度，这使得高频的Diffuse信息会被忽略，所以更适合（半）室外。 尽管应用场景不同，APV作为Unity官方实现，其优化策略具有重要的参考价值。 原项目SH.hlsl中的球谐函数实现使用大量条件判断： // 老版本 - 大量分支判断 float SH(in int l, in int m, in float3 s) { if (l == 0) return kSHBasis0; if (l == 1 && m == -1) return kSHBasis1 * y; if (l == 1 && m == 0) return kSHBasis1 * z; // ... 更多条件判断 return0.0; } // 在循环中重复调用 for (int shIndex = 0; shIndex < 9; shIndex++) { contribution = SHProject(shIndex, dir) * totalRadiance * 4.0 * PI / SampleCount; // 每次调用都要执行完整的条件判断逻辑 } 这种GPU上的条件分支会导致Warp Divergence，且影响性能。 下面是参考Unity的SphericalHarmonics.hlsl的实现方式，使用向量化方式来优化。 对照用的公式，数学不好不会推也没事： 向量化版本： // 新版本 - 向量化计算 void EvaluateSH9(in float3 dir, out float sh[9]) { float x = dir.x; float y = dir.y; float z = dir.z; // L0 (constant) sh[0] = 1.0; // Constant term (will be multiplied by kSHBasisCoef[0]) // L1 (linear) sh[1] = y; // Y_1_-1 sh[2] = z; // Y_1_0 sh[3] = x; // Y_1_1 // L2 (quadratic) sh[4] = x * y; // Y_2_-2 sh[5] = y * z; // Y_2_-1 sh[6] = 3.0 * z * z - 1.0; // Y_2_0 (Equals 2.0 * z * z - x * x - y * y) sh[7] = x * z; // Y_2_1 sh[8] = x * x - y * y; // Y_2_2 // Apply kSHBasisCoef to get the final SH basis values [unroll] for (int i = 0; i < 9; ++i) { sh[i] = sh[i] * kSHBasisCoef[i]; } } // 优化后的使用方式 float shCoeffs[9]; EvaluateSH9(dir, shCoeffs); // 一次计算所有系数 [unroll] for (int shIndex = 0; shIndex < 9; shIndex++) { contribution = shCoeffs[shIndex] * totalRadiance * 4.0 * PI / SampleCount; // 直接数组访问，无分支判断 } 这种方式可以连续访问，性能更好。 除了向量化外，APV系统中一个巧妙的优化是在球谐系数上预除PI，减少Radiance转为Irradiance时的ALU开销： // Clamped cosine convolution coefs (pre-divided by PI) // See https://seblagarde.wordpress.com/2012/01/08/pi-or-not-to-pi-in-game-lighting-equation/ #define kClampedCosine0 1.0f #define kClampedCosine1 2.0f / 3.0f #define kClampedCosine2 1.0f / 4.0f static const float kClampedCosineCoefs[] = { kClampedCosine0, kClampedCosine1, kClampedCosine1, kClampedCosine1, kClampedCosine2, kClampedCosine2, kClampedCosine2, kClampedCosine2, kClampedCosine2 }; 这个优化基于Sébastien Lagarde的经典文章《Pi or not to Pi in game lighting equation》。什么时候改乘PI也是实时渲染中一个比较经典的问题，例如URP中的Lambert BRDF就没有除PI，目的是简化灯光流程，让灯光颜色调整时所见即所得。 3D纹理 原作者存储球谐是将27位float存在一个巨大的ComputeBuffer中，这导致需要使用定点数Encode并且使用较多的原子操作。 这个方式弊病很多，一方面没有利用GPU的优势（对3D纹理的硬件优化），另一方面原子操作导致写入前需要Clear，需要使用双缓冲来维护，内存翻倍。 // 使用定点数存储小数, 因为 compute shader 的 InterlockedAdd 不支持 float // array size: 3x9=27 RWStructuredBuffer<int> _coefficientSH9; // storage to volume if(_indexInProbeVolume >= 0) { constint coefficientByteSize = 27; int offset = _indexInProbeVolume * coefficientByteSize; for(int i = 0; i < 9; i++) { InterlockedAdd(_coefficientVoxel[offset + i * 3 + 0], EncodeFloatToInt(c[i].x)); InterlockedAdd(_coefficientVoxel[offset + i * 3 + 1], EncodeFloatToInt(c[i].y)); InterlockedAdd(_coefficientVoxel[offset + i * 3 + 2], EncodeFloatToInt(c[i].z)); } } 我将其修改为probeSizeX，probeSizeZ，probeSizeY*9大小，格式为RGB111110Float的3D纹理。虽然这样还是会有一定的CacheMiss，但相比使用ComputeBuffer来存储球谐系数性能更好，并且可以方便在FrameDebugger中查看。 // Layout: [probeSizeX, probeSizeZ, probeSizeY * 9] RWTexture3D<float3> _coefficientVoxel3D; if (_indexInProbeVolume >= 0) { // Write to 3D texture int3 texCoord = ProbeIndexToTexture3DCoord(_indexInProbeVolume, index, _coefficientVoxelSize); _coefficientVoxel3D[texCoord] = groupCoefficients[0]; } 需要注意Relight时为了计算MultiBounce我们依然需要访问上一帧的球谐系数，这使得在一个线程中可能存在同时访问和写入的可能，所以只是将ComputeBuffer修改为3D纹理后，还不能去除双缓冲，还需要之后的几步优化。 并行规约 由于改成3D纹理，我们需要解决原来作者没处理的球谐系数求和问题，这本质是GPU中的多线程求和问题即并行规约问题。 无需掌握底层原理，英伟达直接提供了最佳实践： 《Optimizing Parallel Reduction in CUDA》 在CS中实现起来非常简单，我们有512个Thread，刚好是2次幂，因此可以直接使用PPT中的方法3。 // Parallel reduction for (uint stride = 256; stride > 0; stride >>= 1) { if (groupIndex < stride) { groupCoefficients[groupIndex] += groupCoefficients[groupIndex + stride]; } GroupMemoryBarrierWithGroupSync(); } 由于利用了多线程能力，带宽换时间，性能大概提升2倍，还有两个进阶版本可以更有效利用带宽，但代码实在有些繁琐，用第三种基本足够了。 但需要注意这里如果直接存所有二阶球谐系数（27个float）到LDS后再并行规约，可能会导致超出阈值或使用了过多寄存器造成性能下降，为了消除该问题，我将二阶球谐的9个维度放在循环里分别进行规约。 UNITY_UNROLL for (int shIndex = 0; shIndex < 9; shIndex++) { float3 contribution = ...; groupCoefficients[groupIndex] = contribution; GroupMemoryBarrierWithGroupSync(); // Parallel reduction for non-power-of-2 size for (uint stride = ThreadCount / 2; stride > 0; stride >>= 1) { if (groupIndex < stride) { groupCoefficients[groupIndex] += groupCoefficients[groupIndex + stride]; } GroupMemoryBarrierWithGroupSync(); } // Write results if (groupIndex == 0 && _indexInProbeVolume >= 0) { uint3 texCoord = ProbeIndexToTexture3DCoord(_indexInProbeVolume, shIndex, _coefficientVoxelSize); _coefficientVoxel3D[texCoord] = groupCoefficients[0]; } GroupMemoryBarrierWithGroupSync(); } 但这样明显会增多同步次数，后续我们会再次来优化这部分。 分帧Relight 由于现有方法是需要每帧遍历所有Probe进行Relight，这导致场景越大或Probe密度越大，Relight成本越高，时间复杂度为$O(N_\text{probes})$。为了性能可控，我们可以利用Diffuse GI低频的特点，将Relight的步骤分摊到多帧。 void DoRelight(CommandBuffer cmd, PRTProbeVolume volume) { volume.SwapCoefficientVoxels(); // 如果是多帧Relight，则不需要清空体素 if (!multiFrameRelight) volume.ClearCoefficientVoxel(cmd); // May only update a subset of probes each frame using (ListPool<PRTProbe>.Get(outvar probesToUpdate)) { volume.GetProbesToUpdate(probesToUpdate); foreach (var probe in probesToUpdate) { probe.ReLight(cmd, _relightCS, _relightKernel); } } // Advance volume render frame volume.AdvanceRenderFrame(); } // 滚动获取当前帧要更新的Probe public void GetProbesToUpdate(List<PRTProbe> probes) { for (int i = _currentProbeUpdateIndex; i < _currentProbeUpdateIndex + probesToUpdateCount; i++) { probes.Add(Probes[i]); } } public void AdvanceRenderFrame() { // Advance the update index for next frame _currentProbeUpdateIndex = (_currentProbeUpdateIndex + probesToUpdateCount) % Probes.Length; } 回到文章《Global Illumination in Tom Clancy's The Division》的方案，这并不是使用简单的轮询法（Round Robin），而是将一组组Probe划分为一个个Sector，每帧Relight两组，并且对于相机周围的Probe再额外Relight一组。 这里是否要划分Sector笔者觉得不太重要，但Relight玩家相机附近的Probe确实是有必要的，我们可以修改为每帧计算相机附近的Probe，添加到上面的GetProbesToUpdate中。 /// /// Update local probe indices based on camera position /// private void UpdateLocalProbeIndices() { if (!_mainCamera || Probes == null || Probes.Length == 0) return; Vector3 cameraPos = _mainCamera.transform.position; // Only recalculate if camera has moved significantly if (Vector3.Distance(cameraPos, _lastCameraPosition) < CameraMovementThreshold) return; _lastCameraPosition = cameraPos; _localProbeIndices.Clear(); // Convert camera position to probe grid coordinates for more efficient distance calculation Vector3 gridPos = (cameraPos - transform.position) / probeGridSize; // Calculate distances from camera to all probes using grid coordinates using (ListPool<(int index, float distance)>.Get(outvar probeDistances)) { for (int i = 0; i < Probes.Length; i++) { if (Probes[i]) { // Calculate probe position in grid coordinates Vector3 probeGridPos = (Probes[i].transform.position - transform.position) / probeGridSize; // Use squared distance for efficiency (avoiding sqrt) float sqrDistance = (gridPos - probeGridPos).sqrMagnitude; probeDistances.Add((i, sqrDistance)); } } // Sort by distance and take the closest ones probeDistances.Sort(static (a, b) => a.distance.CompareTo(b.distance)); int count = Mathf.Min(localProbeCount, probeDistances.Count); for (int i = 0; i < count; i++) { _localProbeIndices.Add(probeDistances[i].index); } } } Forward+多光源适配 理论上只要添加_FOWARD_PLUS宏后就可以使用了，但从URP 14后会遇到一个离谱的编译问题。 Can't find included file `Packages/com.unity.render-pipelines.ps5/ShaderLibrary/API/FoveatedRendering_PSSL.hlsl` CS的编译似乎无视了SHADER_API_PS5宏，导致找不到平台文件报错，问题是散修开发者也没PS5平台的引擎拓展。 国内有开发者咨询了Unity官方，得到的AI答复是：URP14.0.7及之后的版本下计算着色器库文件引用问题。 因此在不修改源码的情况下，最佳的解决方案就是本地创建一个空的com.unity.render-pipelines.ps5库，里面写一个空的FoveatedRendering_PSSL.hlsl。 然后我们在LightLoop前加上下面的代码，初始化Cluster需要拿到Surfel的屏幕坐标和世界坐标： #if _FORWARD_PLUS float2 uv = ComputeNormalizedDeviceCoordinates(surfel.position, UNITY_MATRIX_VP); InputData inputData = (InputData)0; inputData.normalizedScreenSpaceUV = uv; inputData.positionWS = surfel.position; #endif uint pixelLightCount = GetAdditionalLightsCount(); LIGHT_LOOP_BEGIN(pixelLightCount) // 这里会创建Cluster // Light Loop... LIGHT_LOOP_END 体积雾适配 《Global Illumination in Tom Clancy's The Division》文章分享中也谈到了体积雾可以在Raymarch时同时采样PRT Volume，笔者这里使用了一个开源的体积光方案： CristianQiu/Unity-URP-Volumetric-Light 把其中采样APV的贡献改成采样PRT Volume后，就实现了类似的效果： 阴影缓存 《Global Illumination in Tom Clancy's The Division》和《实时PRTGI技术与实现》文章中都提到不在视锥内的物体会被CSM剔除，因此对于离屏物体，我们需要添加一个Shadow Cache来保留最近一次有效的主光源阴影信息。 // mainlight shadow float4 shadowCoord = TransformWorldToShadowCoord(surfel.position); if (!BEYOND_SHADOW_FAR(shadowCoord)) { // Shadow is valid, sample and update cache atten = SampleShadowmap( TEXTURE2D_ARGS(_MainLightShadowmapTexture, sampler_MainLightShadowmapTexture), shadowCoord, GetMainLightShadowSamplingData(), GetMainLightShadowParams(), false ); // Update shadow cache with new valid result _shadowCache[surfelGlobalIndex] = atten; } else { // Shadow is invalid, use cached result if available atten = _shadowCache[surfelGlobalIndex]; } Surfel合并Brick 我们回过头看下现在的数据存储，对于每个Probe我们都存放了其512个Surfel数据，如果两个Probe挨着很近，那很大概率Surfel的数据是比较重复的，对于离得很近、方向基本一致的Surfel，我们实际可以清理一部分冗余数据。 《Global Illumination in Tom Clancy's The Division》文章中的全境封锁给予了一个方案，即根据Grid大小（4×4×4）和Surfel的法线的主方向来聚集为Brick。同一个Brick中的Surfel数据就可以提取一下特征（比如对于坐标相同、法线方向相近的Surfel进行合并）。 下面是数据结构： /// /// Represents the indices of a Surfel /// [Serializable] publicstruct SurfelIndices { publicint start; publicint end; } /// /// Represents a 4x4x4 brick containing merged Surfels /// publicclassSurfelBrick { publicreadonly List<int> SurfelIndices = new(); publicreadonly HashSet<PRTProbe> ReferencedProbes = new(); } SurfelBrick即为烘焙时的Brick存储结构，由于Surfel不再唯一对应一个Probe，我们还需要在烘焙期间存储Probe的引用关系，直到存储数据时再扁平化为索引。 从实现细节上来讲，对于每个Probe完成Sample后，需要将Surfel注册到一个具有HashGrid结构的BrickManager中（根据Surfel世界坐标和主方向计算Hash），BrickPool找到对应位置的SurfelBrick将其添加或合并，并记录引用的Probe。 其次因为Surfel被合并为Brick，Probe不再直接引用其烘焙阶段命中的512个Surfel，在序列化前我们需要额外的数据来存储Relight时Probe所需的数据。 参考文章《Global Illumination in Tom Clancy's The Division》，下面是一个示例： /// /// Factor structure: contains Brick index and the contribution weight of that Brick to the Probe /// [Serializable] publicstruct BrickFactor { publicint brickIndex; publicfloat weight; } /// /// Factor range: each Probe stores the range of Factors it uses /// [Serializable] publicstruct FactorIndices { publicint start; publicint count; } 这里BrickFactor对应了一个Brick对于一个Probe的贡献权重，可以离线通过Brick中所有Surfel的平均法线计算，空间换时间。FactorIndices即是一个Probe所对应的Factor范围。 最后我们根据Probe顺序将FactorIndices、BrickFactor、SurfelIndices和Surfel进行排序，尽可能保证数据访问时的连续性。 结合上面的数据结构，下面是从烘焙到使用的新流程： Probe发射512个射线采样生成Surfel Surfel合并聚集到Brick Brick平均法线计算Probe贡献系数，存到Factor中 存储Factor、Brick、以及合并后的全部Surfel 运行时Volume拿到全部Surfel、Brick、Factor数据，提交GPU Relight所有Brick Relight所有Factor 为了验证数据正确，这里优先编写一下Brick的Gizmos视图，方便在编辑器看到各个Brick对选中Probe的贡献值以及Brick中各个Surfel方向是否朝向一致。 我们对比下性能，因为Surfel数据大量进行了合并，Relight Brick开销非常小，而Probe在Relight时采样的Brick数量也远远小于原先的512个，因此开销也有所下降。但需要注意这个合并实际会让GI精度下降，所以对于室内部分，我认为肯定是需要结合SSGI使用的。 注意这里开启了Multi Frame Relight来控制每帧更新的Probe数量（这里为1帧15个Probe）。 在我们完成Surfel和Probe的Relight分离后，还可以额外获得两个免费的优化效果： 由于不再存在3D纹理的写入和读取冲突，Relight不再需要历史帧缓冲，可以减少一张3D纹理使用。 由于ProbeRelight所需的Brick变少了（从512减到小于256），足够我们将二阶球谐27个float直接写入LDS中。现在的并行规约代码如下： #define ThreadCount 256 // Max Brick Num groupshared float3 groupCoefficients[9][ThreadCount]; // Compute all SH coefficients at once using vectorized approach float shCoeffs[9]; EvaluateSH9(dir, shCoeffs); float weight = 4.0 * PI / sampleCount; // Process each SH coefficient and store to LDS UNITY_UNROLL for (int shIndex = 0; shIndex < 9; shIndex++) { float3 contribution = shCoeffs[shIndex] * totalRadiance * weight; groupCoefficients[shIndex][groupIndex] = contribution; } GroupMemoryBarrierWithGroupSync(); // Parallel reduction for non-power-of-2 size for (uint stride = ThreadCount / 2; stride > 0; stride >>= 1) { if (groupIndex < stride) { UNITY_UNROLL for (int shIndex = 0; shIndex < 9; shIndex++) { groupCoefficients[shIndex][groupIndex] += groupCoefficients[shIndex][groupIndex + stride]; } } GroupMemoryBarrierWithGroupSync(); } 那么上半部分就是这些了，下半部分我们继续优化和拓展PRTGI。 参考文章 [1] 实时PRTGI技术与实现 [2] 预计算辐照度全局光照（PRTGI）从理论到实战 [3] Unity移动端可用实时GI方案细节补充 [4] Global Illumination in Tom Clancy's The Division [5] Pi or not to Pi in game lighting equation 这是侑虎科技第1983篇文章，感谢作者AkiKurisu供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/akikurisu 再次感谢AkiKurisu的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）