ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity VR优化:用屏幕覆盖率解决透明玻璃Overdraw问题

Unity VR优化:用屏幕覆盖率解决透明玻璃Overdraw问题 做Unity VR优化做到第五篇终于撞上了最难缠的一环透明玻璃、屏幕覆盖率与 Overdraw。如果你用 VR 一体机做过项目肯定遇到过这种场景美术同学放了五片漂亮的落地玻璃窗测试机帧率立刻掉5帧把玻璃一删又丝滑如初。问题出在哪不是模型面数不是Draw Call而是那些透明像素在GPU里被一遍遍地重复着色——也就是Overdraw。而这个坑在VR里比在PC上严重得多因为双眼渲染意味着同样是50%覆盖率的玻璃你要为两只眼睛各付一笔账。这篇文章我结合自己的实际优化经验从Overdraw的原理、透明玻璃的几种替代方案到用“屏幕覆盖率”量化性能再给一个完整案例的优化过程最后列几个容易踩的坑。不管你是刚接触VR优化还是已经跟掉帧战斗了一阵子都应该能从这里拿回去一些直接用得上东西。1. 为什么VR里的Overdraw会被成倍放大1.1 双眼渲染、高刷新率和高分辨率三座大山在普通PC游戏里我们常说Overdraw不是大问题因为桌面GPU的填充率高得离谱死磕它往往投入产出比很低。但VR不同。以Quest 2这类一体机为例单眼渲染分辨率大约是1832×1920还需要以72Hz甚至90Hz刷新率连续渲染。算一笔账每秒要处理的像素量大约是1920×1832×2×72约等于5亿像素。这还只是“每个像素画一次”的理想情况。一旦出现Overdraw这个数字就要乘上Overdraw倍率。一个画面里如果20%面积被半透明玻璃覆盖每个像素额外写一次就意味着每秒要多处理1亿次像素着色。移动端GPU的着色器规模本来就不大这些多余工作直接变成帧时间。移动端GPU和桌面端GPU还有一个本质差异移动端大多采用tile-based架构依靠隐藏表面消除HSR之类的优化来尽量做到“只画一次”。但这些优化有个前提——像素最终是不透明的并且能被深度剔除。而透明物体必须执行混合你得拿到背景颜色才能混合所以早期深度剔除对透明物体基本失效。换句话说透明物体的Overdraw是硬开销省不掉除非不让它透明。这个原理解释了为什么VR项目里一块玻璃就能把帧率拖垮它让你在双眼、高分辨率、高刷新率的三重压力下额外承担成倍的像素着色工作。1.2 “屏幕覆盖率”才是透明问题的正解我们平时盯得很紧的Draw Call在透明性能问题上经常误导人。两个透明物体Draw Call相同一个覆盖屏幕5%一个覆盖屏幕50%GPU开销能差出10倍。所以我要引入一个词屏幕覆盖率Screen Coverage。它指的是一个物体在屏幕上占据的像素比例这个指标直接决定了透明效果要付出多少填充率成本。举个例子假设一个场景本来平均Overdraw是1.05已经很理想了。你加了一面玻璃墙占屏幕30%的像素玻璃材质还需要额外采样反射Cubemap并混合背景色那么它的像素着色成本大约是背景的1.8倍。新的平均Overdraw大约是 0.7×1.05 0.3×(1.050.8) ≈ 1.29增加了约23%。听起来不多在700万像素、72Hz的VR渲染里23%的填充增量足以让GPU从稳定72Hz变成间歇掉帧。所以优化透明效果时第一件事应该是估算“这块透明物体在屏幕上占了多大面积”而不是先翻Shader代码。很多时候把一块覆盖40%屏幕的大型玻璃换成不透明方案成就感比优化几百行Shader大得多。1.3 帧时间、GPU频率和Fillrate的跷跷板关系这里有个常见误区GPU时间不是线性的。一体机的GPU会动态调频当场景复杂度高时GPU可能自动降频温度升高后帧时间会跳变。你看到3.2ms的基线可能是在频率波动时取的平均值。真正可靠的做法是固定频率、固定温度、连续跑2分钟取中位数。如果发现帧时间曲线毛刺特别多先检查是不是玻璃这类高Overdraw物体导致GPU频繁降频。还有一个和VR强相关的因素注视点渲染Foveated Rendering。很多一体机在边缘区域会降低分辨率渲染。如果透明玻璃恰好只在屏幕边缘它的实际成本会低一些但如果玻璃在玩家视线中心比如展柜正前方成本一点都躲不掉。优化前要判断高覆盖率的透明物体是否位于视线停留区域这会影响“先优化谁”的优先级。2. 透明玻璃为什么它是VR场景的“隐形炸弹”2.1 标准Shader透明模式到底有多贵很多美术同事习惯用Standard Shader的Transparent模式做玻璃。在PC上看起来确实还行但在VR一体机里这个选择会立刻带来两个问题第一Unity的透明渲染走的是Transparent队列3000所有透明物体按物体中心排序沿观察方向由远到近绘制。VR里双眼视角有差异左眼排序正确右眼可能就错了玻璃边缘会出现各种混合闪烁。第二Transparent模式下很多移动端GPU的深度优化都用不上本来能跳过的片段现在必须完整执行。如果你真的要在VR里做真透明玻璃这几个点必须先处理好双面渲染Cull Off如果玻璃很薄且从两面看确实要开但像素着色量会翻倍慎用。关闭硬阴影对玻璃的影响避免每个片段都做光照计算。反射探针千万不要设成Realtime。一个实时探针等于额外相机把场景渲染6次两个就是12次。我见过一个极端的项目玻璃展馆内摆了6个实时反射探针其中4个是Realtime结果GPU时间有一半花在探针渲染上而且面板上看不到任何一个透明物体。透明玻璃的隐藏成本往往不在玻璃本身而在它周围的“探针群”。2.2 从“真透明”到“伪透明”三种替代方案既然真透明这么贵在VR里能不做就不做。下面是我在多个项目里验证过、可以替代真透明玻璃的三种方案按开销从低到高排序。方案一纯假玻璃Unlit伪造把玻璃材质换成完全不透明用浅蓝灰色加一个Cubemap贴图模拟环境反射。这种玻璃不真实但很适合大面积窗户、幕墙、中远景玻璃。优点是零Overdraw不用排序不会闪烁。很多VR全景视频里的玻璃其实就这么做玩家根本不会去细看。这里给一个示意Shader关键是RenderType是OpaqueQueue是Geometry完全不走透明PassShader VR/FakeGlass { Properties { _Tint (Tint, Color) (0.7, 0.9, 1, 1) _Cubemap (Cubemap, Cube) {} } SubShader { Tags { RenderTypeOpaque QueueGeometry } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; float3 worldRefl : TEXCOORD0; }; v2f vert (appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 viewDir WorldSpaceViewDir(v.vertex); o.worldRefl reflect(-viewDir, worldNormal); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col texCUBE(_Cubemap, i.worldRefl); return col * _Tint; } ENDCG } } }这个Shader没有Alpha没有Blend没有额外Pass。看起来有玻璃的反射质感但本质是普通不透明物体。缺点是不能透过它看到后面的东西——适合不需要真实透视的玻璃幕墙。方案二背景拉伸玻璃做一个相机把背景渲染到一张低分辨率RT再把RT的U,V映射到玻璃表面看起来就像“透过玻璃看到了背后的景象”。这个方案比真透明便宜比假玻璃真实。可以用反射矩阵把世界平面反射到玻璃上再采样Mipmap模糊。注意不要用GrabPassGrabPass在VR里会复制整帧缓冲等于一次全屏读取费用很高。建议自己创建一个低分辨率相机只渲染需要反射的对象配合LOD控制效果和性能能平衡得很好。方案三真透明但限面积保留Alpha Blend但只用于小面积玻璃比如展柜的一小块门、器械视窗。同时用不透明材质做玻璃框让玻璃框遮挡住透明部分不参与排序。这样Overdraw只在局部发生可控。必须用透明时我给自己立了一条铁律玻璃的屏幕覆盖率不能超过10%。超过就换方案二。2.3 反射探针的正确打开方式真实玻璃总归需要反射。在VR里我推荐用StaticBaked Cubemap并且在Shader里用Box Projection做区域校正。这样在房间内走动时玻璃反射会随视角偏移观感接近实时反射但成本只有一次采样。具体到Unity为每个空间设置一个Baked ReflectionProbe类型选BakedBox size要和空间匹配。打开Box ProjectionImportance设为1。如果场景里有动态物体需要被玻璃反射那就放弃“实时真反射”用方案二做个假的动态纹理。移动VR里动态玻璃反射用真实时渲染的代价实在太高不划算。还有个冷知识Unity默认Standard玻璃Shader里如果材质勾选了Specular High Definition会额外做更宽的反射卷积像素着色成本会上升。移动端VR建议关闭高光细节用普通反射模式。3. 把Overdraw摆到桌面上覆盖率统计的实战方法3.1 先看Frame Debugger而不是只盯Statistics很多开发者的习惯是打开Game统计面板看到SetPass Calls少一点就认为性能好了。但SetPass Calls只反映Draw Call和状态切换跟GPU像素着色时间的相关性经常很差。我看性能的第一个动作是在PC编辑器里跑一遍场景打开Frame Debugger逐个看透明Draw Call的Rect。Frame Debugger里每个Draw Call会显示它提交到屏幕上的包围盒范围这个矩形的大小就是该物体覆盖率的粗略上界。如果一个玻璃窗的Rect占了半个屏那它一定是重点审查对象。如果项目接了RenderDoc那就更清楚。RenderDoc里有Overdraw可视化模式能直接生成一幅热力图把每个像素的着色次数用颜色显示出来。你一眼就能看出玻璃窗的位置是不是一片红。不过对一体机项目来说抓取工具链复杂一些我的办法是在PC上以同样场景用Oculus PC模式跑抓一帧分析再等比估算到移动平台。3.2 一个Editor脚本算出每一块透明物体的覆盖率我平时会写一个小工具专门统计场景里所有透明Renderer在指定相机下的屏幕面积。核心思路很简单对每个Renderer计算包围盒8个顶点在相机下的屏幕坐标形成一个矩形统计矩形面积占比。这里给一个可直接用的Editor脚本// CoverageTool.cs using UnityEngine; using UnityEditor; using System.Collections.Generic; public static class CoverageTool { [MenuItem(Tools/透明覆盖率估算)] static void Estimate() { Camera cam Camera.main; if (cam null) { Debug.LogError(没有找到主相机); return; } Renderer[] rends Object.FindObjectsOfTypeRenderer(); float totalPixels Screen.width * Screen.height; float sumArea 0f; int transparentCount 0; Liststring details new Liststring(); foreach (Renderer rd in rends) { if (rd null || !rd.enabled) continue; if (!IsTransparent(rd)) continue; transparentCount; float area GetScreenRectArea(cam, rd); sumArea Mathf.Clamp(area, 0f, totalPixels); if (area 1f) details.Add(rd.name : (area / totalPixels * 100f).ToString(0.00) %); } Debug.Log(透明物体数量: transparentCount); Debug.Log(总屏幕覆盖率(粗): (sumArea / totalPixels * 100f).ToString(0.00) %); Debug.Log(覆盖率明细:\n string.Join(\n, details.ToArray())); } static bool IsTransparent(Renderer rd) { if (rd.sharedMaterials null || rd.sharedMaterials.Length 0) return false; Material m rd.sharedMaterials[0]; if (m null) return false; string renderType m.GetTag(RenderType, false, Opaque); if (renderType Transparent || renderType TransparentCutout) return true; // 额外判断Standard Shader的_RenderMode if (m.HasProperty(_Mode)) { float mode m.GetFloat(_Mode); if (mode 3f || mode 4f) return true; // 3Transparent, 4TransparentCutout } return false; } static float GetScreenRectArea(Camera cam, Renderer rd) { Vector3 c rd.bounds.center; Vector3 e rd.bounds.extents; Vector3[] corners new Vector3[8]; for (int i 0; i 8; i) { Vector3 dir new Vector3( (i 1) 0 ? -1f : 1f, (i 2) 0 ? -1f : 1f, (i 4) 0 ? -1f : 1f ); corners[i] c new Vector3(e.x * dir.x, e.y * dir.y, e.z * dir.z); } float minX float.MaxValue, maxX float.MinValue; float minY float.MaxValue, maxY float.MinValue; int valid 0; foreach (Vector3 p in corners) { Vector3 sp cam.WorldToScreenPoint(p); if (sp.z 0f) continue; // 在相机背后的点忽略 minX Mathf.Min(minX, sp.x); minY Mathf.Min(minY, sp.y); maxX Mathf.Max(maxX, sp.x); maxY Mathf.Max(maxY, sp.y); valid; } if (valid 0) return 0f; float rectX Mathf.Clamp(maxX, 0f, Screen.width) - Mathf.Clamp(minX, 0f, Screen.width); float rectY Mathf.Clamp(maxY, 0f, Screen.height) - Mathf.Clamp(minY, 0f, Screen.height); return Mathf.Max(rectX, 0f) * Mathf.Max(rectY, 0f); } }这个脚本有一定粗糙度包围盒矩形比真实形状大也没做透视裁剪。它适合做快速排序找出“嫌疑犯”不适合作为精确像素计数。如果想要精确结果可以用运行时方法把所有透明物体塞进一个Layer用一个低分辨率相机只渲染该层到黑底RT然后读取纹理统计非零像素比例。这个更准确但只能在构建验证时跑不能实时开。3.3 用覆盖率数字换算GPU时间预算拿到覆盖率后怎么换算成GPU时间我的做法是对照实验在场景里关掉所有透明物体记录固定视角的GPU时间A。打开一个目标玻璃物体记录GPU时间B。差值B-A就是这块玻璃在当前分辨率下的成本。然后在不同距离、不同角度多测几组得到“覆盖率-成本”曲线。以Quest 2为例如果单眼分辨率1832×1920某个玻璃窗覆盖屏幕20%那它大约占据70万像素。每个像素如果做背景混合加反射采样按移动端GPU能承受的像素着色带宽70万像素的额外开销约在0.20.4ms之间。如果同时出现5块这样的大玻璃就是12ms足以打穿性能预算。所以我在项目里设了一条红线所有透明物体的总屏幕覆盖率不得超过10%~15%在这个预算内做效果。4. 一次完整优化透明展馆从3.2ms压到1.1ms4.1 基线数据GPU时间的诊断顺序处理过一个真实的VR珠宝展馆项目目标平台Quest 272Hz。整个场景以黑色背景为主看起来不重但经常掉到55fps。使用Meta Quest性能工具抓帧GPU帧时间均值3.2ms峰值能到6ms。一开始我怀疑Shader太复杂后来转向Draw Call发现386个其实也不算离谱。真正定位问题靠的是分层测试先禁用所有ReflectionProbeGPU时间立刻从3.2ms降到2.4ms再把所有Transparent队列的Renderer隐藏GPU又从2.4ms降到1.3ms。这说明透明物体和反射探针合计占了接近2ms才是真正的元凶。Draw Call反而影响不大CPU消耗只有0.8ms。这提醒大家拿到性能数据先切开几个大块透明、光照、后处理、阴影再分析具体分支。4.2 四个动作把透明物体从48个减到8个第一解决反射探针实时更新。场景里有6个探针其中4个是Realtime。我全部改成Baked并调整盒体范围让它们覆盖每个展区。这一步立竿见影GPU节省约0.8ms。第二大玻璃换“伪透明”。展馆四角的落地玻璃原来都是Standard Transparent加反射探针屏幕覆盖率加起来差不多35%。我换上了前面说的FakeGlass Shader改成不透明渲染保留浅蓝色tint和Fresnel边缘。透明覆盖率从35%降到0.5%只剩玻璃边缘有一点高光错觉。这一步又降了约0.9ms。第三展柜玻璃限面积加遮挡剔除。展柜内的小玻璃门不可能全都换成假玻璃因为玩家会凑近看展品。我的策略是保留Alpha Blend但这些玻璃门总覆盖率控制在8%左右。然后开启Occlusion Culling被墙面和展柜底座遮挡的玻璃直接不画。再用一个运行时脚本根据玩家位置动态调整玻璃透明度玩家近处时透明度低一些少透一点远处时透明度高一些观感够即可。优化后整体Draw Call从386降到240透明Draw Call从48降到8。第四关掉玻璃的影子投射。投影阴影会让玻璃产生额外Pass。我把所有玻璃的Cast Shadows都关掉只保留Receive Shadows。玻璃在VR里能贡献的阴影非常少这个操作画面几乎无感但省下的渲染量很可观。优化后固定频率下GPU时间中位数稳定在1.1ms帧率恢复到72Hz满帧。峰值下降到1.6ms以下后面增加粒子特效也不会打穿预算。指标优化前优化后GPU帧时间3.2ms1.1msDraw Call386240透明Draw Call488Realtime反射探针40透明总覆盖率35%8%4.3 顺手处理的两个“隐形Overdraw”优化过程中还发现两个和玻璃本身无关、但会叠加在透明物体上的问题很多玻璃Shader都开了雾效Fog透明区域要多算一次雾的混合。VR场景里雾效用得少建议全局禁用或者只用于天空盒。这个改动让透明部分的GPU时间又降了一截。场景里有一些全屏后处理比如Bloom和Vignette。Bloom的Blur Pass会遍历全屏很多次虽然不是Overdraw但同样吃填充率。我把Bloom强度降低、分辨率降到1/4效果提升明显。这本质上也是“屏幕覆盖率”的优化——让全屏效果不要全开。4.4 验证方法确保数据可信优化后我做了一轮严格测试使用设备在固定场景位置用Quest后台抓取Perf数据连续跑3分钟记录中位数和P95。每改一个参数就抓一轮对比GPU时间变化。开发者模式会允许固定GPU频率尽量固定后测避免动态调频干扰。关闭UWA等性能插件或者至少保持前后一致。测试发现透明物体从48个降到8个玩家在体验中完全没有感知——注意力都在展品上但掉帧消失、发热减少、电池续航也变好。这就是我说的VR里夸张的透明效果往往是最大的浪费。5. 透明优化原则我踩过这些坑5.1 坑一为了省Draw Call用了大范围透明粒子有朋友问粒子光效不用透明感觉太弱结果整片粒子覆盖了屏幕30%以上。这是典型的“低Draw Call但高Overdraw”。再少的Draw Call也救不了全屏粒子混合的像素成本。我在项目里给粒子系统设总覆盖率红线超过就减少发射量或改用不透明材质。如果必须用光效推荐使用Additive混合模式而不是Alpha混合同时把覆盖面积控制住。5.2 坑二只优化玻璃Shader忘了反射探针透明玻璃和反射探针往往是连在一起的。把玻璃优化成假玻璃后如果还开着实时探针照样白费。每次做透明优化查一圈探针、灯光、后处理是基本操作。还有一个容易被忽略的是高光很多玻璃材质高光开得很高VR里高光闪烁还会带来额外的像素运算建议关掉高光抗锯齿或用低分辨率法线。5.3 坑三在Shadows上过度纠结很多人看到掉帧第一反应关阴影但当你把透明覆盖率从35%压到8%后阴影可能根本不是瓶颈。阴影的消耗是固定的不透明物体的阴影在移动端VR里很多时候可以用假阴影贴图解决。不要一上来就关阴影导致画面质量大幅下降。先处理高覆盖率的透明物体阴影放到最后再试。5.4 一个值得长期投资的工具运行时覆盖率统计脚本运行时透明覆盖率统计脚本我每个VR项目都会留一份。它不只是检查当前场景还能打点运行时每隔一帧统计透明覆盖率并写入日志方便QA在测试多个关卡后直接给一份“哪一帧覆盖率超标”的报告。后续美术做大世界时能自动发现哪里又把玻璃当成普通透明物体了。脚本成本不高收益却很稳定。我的最终体会VR优化做到最后你会发现像素次数永远比顶点次数更重要。屏幕覆盖率就是最直观的“像素次数”把它当成和Draw Call一样的预算项每天跑一遍批处理第二天看报告谁超标谁改项目就能一直稳定在满帧状态。透明玻璃很美但在VR里我们要学会用“假玻璃”保住真帧数——这才是优化该有的样子。
返回列表