HUD 优化实战教程从 UGUI 到 GPU Instancing 动态合批本教程是讲解如何把性能堪忧的 UGUI 血条和伤害飘字改造成高性能的 GPU 渲染方案。一、概述为什么要动 HUD在 DOTS面向数据的技术栈项目中可能会有成百上千个敌人同时出现在屏幕上。如果你的每个敌人头顶都挂一个 UGUI 血条、每次命中都 Instantiate 一个伤害数字 GameObject那么瓶颈原因CPU 开销每个 UGUI 血条都是一个独立的 GameObject有自己的 Transform、Canvas 组件每帧都要做位置同步WorldToScreenPoint 坐标赋值Draw Call 爆炸每个 UGUI 血条至少 1~2 个 Draw Call100 个敌人就是 200 DCGC 压力命中敌人时反复 Instantiate/Destroy 伤害飘字触发 GC垃圾回收导致卡顿本次优化分两步走提交内容优化目标50a4eb9血条改为 GPU Instancing血条 Draw Call 降至1~N/10236c90f35修复血条 Bug修复位置错误、比例失调7d89a1a文档总结—9c71f95伤害飘字改为动态合批 Mesh飘字 Draw Call 降至1二、核心概念先修班在讲具体代码前先理解几个关键概念。如果你已经熟悉 GPU Instancing 和 Billboard可以跳过本节。2.1 什么是 Draw Call一句话CPU 每次告诉 GPU 给我画这个就是一次 Draw Call。传统方式100 个血条 → 告诉 GPU 100 次 →100 DCGPU Instancing100 个血条 → 告诉 GPU 1 次 “用同一个网格画 100 遍每遍的位置和颜色不同” →1 DC为什么 Draw Call 多了不好因为每次 Draw Call CPU 要做很多准备工作设置渲染状态、绑定 Buffer 等数量多了 CPU 就忙不过来了。GPU 其实很快但 CPU 来不及喂数据——这叫CPU 瓶颈。2.2 什么是 GPU InstancingGPU Instancing 是 Unity 提供的一种渲染技术。你提供一个网格比如一个 Quad再提供一个数组每项是位置、颜色等数据然后让 GPU 一次性画出很多个该网格的副本。可以用一个公式记忆GPU Instancing 1个网格 × N组数据 → N个独立物体前提是 Shader 要声明#pragma multi_compile_instancing并且在顶点/片元中通过UNITY_GET_INSTANCE_ID知道当前在画第几个实例。2.3 什么是 BillboardBillboard 是一种让平面始终面向相机的技术。常用于树叶、粒子、头顶血条。原理很简单顶点位置 中心点 相机的右方向(right) × x 相机的上方向(up) × y这样不管你怎么转视角这个平面都正对着你——就像超市的广告牌永远对着你走过来的方向。2.4 什么是 ComputeBufferComputeBuffer 是 GPU 上的一块内存。你可以把数据从 CPU 上传到 GPU 的这快内存里然后在 Shader 中用StructuredBufferfloat4来读取。本项目中用它传递每个血条的血量比例和颜色到 GPU。2.5 什么是合批Batching合批就是把多个小的绘制请求合并成一个大的。有两个方向方法适用场景原理GPU Instancing网格相同、数据不同血条复用同一个网格动态 Mesh 合批网格不固定文字飘字每帧手动把所有几何拼成一个新 Mesh三、优化一血条系统——从 UGUI 到 GPU Instancing3.1 旧方案长什么样架构图EnemyHealthSystem (ECS) └─ HealthUIManager.Instance.CreateAction(HpType.Enemy, entity.Index) → Instantiate 一个 HealthUI(GameObject) └─ 每帧 UpdatePosAction(entity.Index, worldPos) → WorldToScreenPoint → 赋值 transform.position └─ 受伤时 UpdateValueAction(entity.Index, value) → 调用 HealthUI.OnHealthChange(value) 由继承自 MonoBehaviour 的单例 HealthUIManager 管理所有血条 GameObject工作流敌人生成时EnemyHealthSystem调用HealthUIManager.Instance.CreateAction()→Instantiate(m_HealthUI)创建一个 UGUI 血条每帧调用HealthUIManager.Instance.UpdatePosAction(entity.Index, pos)→ 把世界坐标转屏幕坐标 → 赋值给血条的transform.position受伤时调用UpdateValueAction(entity.Index, value)→ 更新血条 Slider 的 value死亡时调用RecycleAction(HpType.Enemy, entity.Index)→ 回收血条到对象池存在的问题问题具体表现每帧位置同步每个血条每帧都要做Camera.main.WorldToScreenPoint()这是 CPU 操作GameObject 数量爆炸100 个敌人 100 个 HealthUI GameObject每个都挂 Canvas、Slider、Image 组件Draw Call 多每个 UGUI 血条至少 1~2 个 Draw Call背景填充100 个 200 DC与 ECS 割裂血条数据在 ECS 里但渲染在 Managed 单例里需要频繁的跨系统通信3.2 新方案长什么样架构图HealthBarRenderSystem (LateSimulationSystemGroup) ├─ 收集所有带 HealthBarTag 的实体 ├─ 逐实体计算世界位置 Position (0, HealthUIPointHeight, 0) ├─ 逐实体计算血量比例 Value / MaxValue, 颜色 玩家绿/敌人红 ├─ 写入 ComputeBuffer └─ Graphics.DrawMeshInstanced(quad, count, material, matrices, mpb) HealthBar.shader (GPU Instancing Billboard) ├─ 顶点读取实例矩阵 → 取位置 → Billboard → 输出 Clip Space └─ 片元UNITY_GET_INSTANCE_ID → 读 ComputeBuffer → 画边框/背景/填充色工作流生成角色时AddComponentHealthBarTag()——只是一个标记不创建任何 GameObject每帧HealthBarRenderSystem.OnUpdate()执行查询所有带HealthBarTag HealthData LocalTransform的实体遍历实体把血量比例和颜色写入float4[] instanceData构建议个 变换矩阵数组Matrix4x4[] matrices把instanceData上传到 ComputeBuffer调用Graphics.DrawMeshInstanced()一次性绘制所有血条Shader 中通过实例 ID 读取自己的数据用 UV 决定像素是背景、边框还是血量填充核心代码解读HealthBarRenderSystem.cs// 每帧在 LateSimulationSystemGroup 中运行protectedoverridevoidOnUpdate(){// 1. 查询所有需要血条的实体intinstanceCount_query.CalculateEntityCount();// 2. 收集每个实体的数据for(inti0;ientities.Length;i){// 血量比例和颜色floathealthRatiohd.MaxValue0?(float)hd.Value/hd.MaxValue:0f;// 玩家绿、敌人红instanceData[i]newfloat4(healthRatio,0.19f,1.0f,0.36f);// 世界位置 角色位置 头顶偏移float3worldPoslt.Positionnewfloat3(0,hd.HealthUIPointHeight,0);matrices[i]Matrix4x4.TRS(worldPos,Quaternion.identity,Vector3.one);}// 3. 上传到 GPU ComputeBuffer_buffer.SetData(instanceData);_mpb.SetBuffer(_HealthBarData,_buffer);// 4. 批量绘制每批最多 1023 个for(intb0;bbatchCount;b){_mpb.SetFloat(_StartIndex,start);Graphics.DrawMeshInstanced(_quadMesh,0,_material,matrices_batch,count,_mpb);}}3.3 Shader 中的 Billboard 实现// 顶点阶段 float3 centerPos float3(UNITY_MATRIX_M._m03, UNITY_MATRIX_M._m13, UNITY_MATRIX_M._m23); // 取相机的右和上方向逆视图矩阵的前两列 float3 right UNITY_MATRIX_I_V._m00_m10_m20; float3 up UNITY_MATRIX_I_V._m01_m11_m21; // 离相机越远血条越大保持屏幕大小恒定 float dist length(centerPos - _WorldSpaceCameraPos); float widthScale max(dist * _ScreenScale, 0.01); float heightScale widthScale * _BarAspect; // 扁条 // Billboard顶点位置 中心 右×x×宽 上×y×高 float3 worldPos centerPos right * v.positionOS.x * widthScale up * v.positionOS.y * heightScale;为什么要_m03_m13_m23因为 Unity 的矩阵是列主序。第 4 列才是平移量而[3]取的是第 4行恒等于(0,0,0,1)。这是本次修复的第一个 Bug下面会讲。3.4 片元中如何根据血量比例染色原理UV.x 范围是 0~1血量比例也是 0~1。简单地比较两者大小// 边框判断UV 的边缘区域 bool isBorder uv.x b || uv.x 1.0 - b || uv.y b || uv.y 1.0 - b; // 血量填充判断 float fillX (uv.x - b) / max(1.0 - 2.0 * b, 0.001); // 去掉边框后的 UV.x bool isFill fillX health; // 小于血量比例就是填充区域 // 三选一边框色 / 血量色 / 背景色可以想象成血条是一把尺子血量比例是 0.6那么 UV.x 0.6 的部分涂绿色 0.6 的部分涂灰色。边框就是四个边边。3.5 踩坑记录Bug 修复Bug 1血条全部堆在原点是现象只看到屏幕中央一大个血条根因Shader 中用UNITY_MATRIX_M[3].xyz取实例位置UNITY_MATRIX_M是float4x4矩阵[3]取第 4行即(0, 0, 0, 1)正确做法是取第 4列_m03_m13_m23修复一行代码// 错误 float3 centerPos UNITY_MATRIX_M[3].xyz; // 永远 (0,0,0) // 正确 float3 centerPos float3(UNITY_MATRIX_M._m03, UNITY_MATRIX_M._m13, UNITY_MATRIX_M._m23);深入理解HLSL 中matrix[行][列]而_m03_m13_m23表示第 0~2 行的第 3 列即第 4 列。对于变换矩阵第 4 列的前 3 个分量就是平移量位置。Bug 2NativeArray 双重释放现象运行时抛ObjectDisposedException: The NativeArray has been disposed根因using var entities _query.ToEntityArray(Allocator.TempJob)声明了using var函数结束时自动释放。但末尾又手动entities.Dispose()—— 释放了两次。修复删除多余的entities.Dispose()调用经验using var会在作用域结束时自动调用Dispose()不要再手动释放。同理NativeArray配合using var是最安全的写法。Bug 3血条又大又方现象血条是正方形大块遮住角色根因宽高用同一个scale缩放且_ScreenScale 0.1偏大修复// 新增 _BarAspect 0.18高只有宽的 18% float heightScale widthScale * _BarAspect; // 扁条 float _ScreenScale 0.04; // 默认缩小知识点血条是一个扁条宽高比大约是 5:1 到 6:1。用_BarAspect控制这个比例用_ScreenScale控制整体大小。3.6 性能提升量化指标旧方案UGUI新方案GPU Instancing每个血条的 Draw Call1~20合并到批次中100 个血条的 Draw Call~2001没超过 1023 上限每帧位置计算WorldToScreenPointCPU直接使用LocalTransform.PositionWorld SpaceGameObject 数量100 0Shader 中的位置数据通过 Transform 传递GPU Instancing 内置支持核心结论对于大量同类物体血条都是扁矩形GPU Instancing 是最优方案——网格不变、数据变化。它不仅消灭了 GameObject 开销还把 Draw Call 从成百上千降低到个位数。四、优化二伤害飘字——从对象池到动态合批 Mesh4.1 为什么不能直接用 GPU Instancing伤害飘字是文字每个飘字有不同的字符组合“15”、“238”、0等每个字符的几何形状不同。GPU Instancing 要求所有实例用同一个网格所以不适合。解决方案每帧把所有当前存活的飘字拼成一个大网格一次性绘制。4.2 旧方案长什么样// 每次命中voidCreateItem(Vector3pos,intdemageNum){// 从对象池取或者 Instantiate 一个 BloodUITextMeshProUGUIvargetItemBloodUIPool.Count!0?BloodUIPool.Dequeue():Instantiate(BloodUI);// WorldToScreenPoint 转屏幕坐标varposTmpCamera.main.WorldToScreenPoint(pos);// 设置文本、播放动画...getItem.ShowAt(posTmp);getItem.BloodShowDetail(demageNum);getItem.Anim();}问题分析问题原因Instantiate 开销对象池用完了就要 Instantiate分配新 GameObject每帧 Update每个 BloodUI 需要自己的 Update 驱动动画上升、淡出100 个飘字 100 个 UpdateTMP 重建TextMeshProUGUI 文本变化后会触发 Canvas 重建Layout 重新计算WorldToScreenPoint每帧把世界坐标转屏幕坐标需要 Camera.mainDraw Call每个 TMP 文本至少 1 Draw Call4.3 新方案动态 Mesh 合批一句话原理每帧把所有飘字的每个字符的四边形拼成大 Mesh 的顶点一次性用Graphics.DrawMesh绘制。架构图EnemyHealthSystem 检测受伤 └─ BloodUIManager.Instance.CreateAction(HitPoint, demage) └─ 存入 _active ListFloatingText BloodUIManager.LateUpdate() 每帧执行 ├─ 遍历 _active 列表 │ ├─ 剔除过期age Lifetime │ ├─ 计算上升位移t * RiseHeight │ ├─ 计算弹出缩放PopInRatio → PopStartScale → 1 │ ├─ 计算淡出FadeStartRatio → alpha 递减 │ └─ 逐字符 GetCharacterInfo → 拼四边形顶点 UV 颜色 ├─ Mesh.SetVertices / SetUVs / SetColors / SetTriangles └─ Graphics.DrawMesh(mesh, identity, material, 0, camera)核心数据结构structFloatingText{publicVector3WorldPos;// 命中时的世界坐标publicstringText;// 数字字符串publicfloatStartTime;// 生成时间用于计算动画进度}readonlyListFloatingText_activenewListFloatingText(256);// 预分配几何数据列表避免每帧分配readonlyListVector3_vertsnewListVector3(1024);readonlyListVector2_uvsnewListVector2(1024);readonlyListColor32_colorsnewListColor32(1024);readonlyListint_trisnewListint(1536);每帧构建网格LateUpdate 中的核心逻辑voidLateUpdate(){floatnowTime.time;intwriteIndex0;for(inti0;i_active.Count;i){varft_active[i];floatagenow-ft.StartTime;if(ageLifetime)continue;// 过期丢弃_active[writeIndex]ft;// 保留存活就地压缩// 计算上升、缩放、淡出floattage/Lifetime;Vector3centerft.WorldPosVector3.up*(RiseHeight*t);// 逐字符拼四边形AppendString(ft.Text,center,camRight,camUp,scale,color);}// 移除过期项_active.RemoveRange(writeIndex,_active.Count-writeIndex);// 更新 Mesh_mesh.Clear();_mesh.SetVertices(_verts);_mesh.SetUVs(0,_uvs);_mesh.SetColors(_colors);_mesh.SetTriangles(_tris,0);// 一次绘制Graphics.DrawMesh(_mesh,Matrix4x4.identity,_material,0,_camera);}4.4 逐字符 Billboard 的原理伤害飘字用动态字体Dynamic Font每个字符存储在图集Font Atlas的某个位置。通过Font.GetCharacterInfo可以得到每个字符的 UV 坐标和尺寸。voidAppendString(stringtext,Vector3center,Vector3camRight,Vector3camUp,floatscale,Color32color){// 先量总宽度用于居中floattotalWidth0f;for(intc0;ctext.Length;c){_font.GetCharacterInfo(text[c],outvarci,FontSize);totalWidthci.advance;}floatpenX-totalWidth*0.5f;// 从中间开始画for(intc0;ctext.Length;c){_font.GetCharacterInfo(text[c],outvarci,FontSize);// 字符的四个角都是相对于笔位置的偏移floatx0penXci.minX;floatx1penXci.maxX;floaty0ci.minY-vCenter;floaty1ci.maxY-vCenter;// Billboard把字符偏移映射到世界空间Vector3blcentercamRight*(x0*scale)camUp*(y0*scale);Vector3tlcentercamRight*(x0*scale)camUp*(y1*scale);Vector3trcentercamRight*(x1*scale)camUp*(y1*scale);Vector3brcentercamRight*(x1*scale)camUp*(y0*scale);// 添加 4 个顶点、4 个 UV、4 个颜色、6 个索引两个三角形_verts.Add(bl);_verts.Add(tl);_verts.Add(tr);_verts.Add(br);_uvs.Add(ci.uvBottomLeft);...// 图集 UV_tris.Add(baseIdx0);...// 三角面penXci.advance;// 移动笔到下一个字符}}核心思想把每个字符看作一个小 Quad四边形4 个顶点。几个字符拼在一起就是一个词。所有飘字的所有字符的所有四边形拼在一起就是一个大 Mesh。4.5 FloatingText.shader 的作用着色器做的事情很简单half4 frag(Varyings i) : SV_Target { // 动态字体图集的 alpha 通道存储字形覆盖信息 half a SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv).a; // 用 alpha 做遮罩乘以顶点色包含淡出透明度 return half4(i.color.rgb, i.color.a * a); }_MainTex动态字体图集Font Atlasi.uv每个字符在图集中的 UV 坐标i.color顶点色已包含淡出后的 alpha 值最终 alpha 图集 alpha × 顶点色 alpha字形遮罩 × 淡出4.6 性能提升量化指标旧方案对象池新方案动态 MeshDraw CallN 个飘字 N DC1整个网格一次绘制GameObject每个飘字 1 个0Update 调用每个飘字独立 Update一个 LateUpdate 管理全部内存分配Instantiate 分配预分配 List无分配动画驱动每个对象自己算循环中统一计算核心结论对于文字这种网格不固定的渲染需求每帧拼一个大网格比每个字一个 GameObject高效得多。缺点是需要自己管理顶点列表代码量略大但换来的是确定性的单次 Draw Call。五、两处优化的共同思路虽然血条和飘字用了不同的技术Instancing vs 合批 Mesh但思路是相通的5.1 消灭 GameObject优化前优化后每个血条 1 个 GameObjectHealthUI数据在 ECS 中渲染在 GPU 上每个飘字 1 个 GameObjectBloodUI顶点暂存在 List 中每帧重建 MeshGameObject 在 Unity 中不是轻量的东西——它有 Transform、有组件、有生命周期。当数量达到几百上千时光是遍历它们就能占满一帧的 CPU 时间。5.2 合并 Draw Call优化前优化后100 个血条 ~200 DC1 DC50 个飘字 50 DC1 DC经验法则能用 Instancing 就用 Instancing相同网格不同数据不能用 Instancing 就手动合批动态拼网格或 Mesh.CombineMeshes实在不行才用 GameObject。5.3 把逻辑集中驱动优化前优化后每个 GameObject 有自己的 UpdateHealthBarRenderSystem集中管理所有血条每个 BloodUI 有自己的动画BloodUIManager.LateUpdate统一推进所有飘字动画集中驱动意味着可以自由选择执行时机LateUpdate、LateSimulationSystemGroup容易做性能调优一个 profiler sample 覆盖全部数据局部性好所有血量数据在一块连续内存中六、陷阱与经验总结6.1 这段旅程中踩过的坑Bug原因教训血条在原点[3]取的是行不是列HLSL 矩阵索引[row][col]_m03是第 0 行第 3 列NativeArray 双重释放using var 手动Dispose用了using var就别再手动释放血条正方形宽高用同一 scale扁矩形要分开控制宽和高的缩放烘焙缓存问题新增组件后 Library 缓存陈旧修改 Authoring 后要删除 Library 重新烘焙6.2 性能调试建议用 Frame Debugger每帧有多少 Draw Call在哪里是不是合批了用 ProfilerCPU 时间花在哪里GC Allocation 大不大关注 GameObject 数量Hierarchy 窗口右上角显示的 “DontDestroyOnLoad” 数量 场景中的对象数测试极端情况100 个敌人同时在场、每秒触发 50 次伤害、多个玩家同时存活七、进一步优化方向当前方案已经高效很多但还有可以继续优化的点HealthBarRenderSystem 的 Job/Burst 化当前在主线程用EntityManager.GetComponentData逐实体遍历。可以用IJobEntityBurstCompile并行收集数据把 CPU 耗时进一步降低。动态 Mesh 的 GPU Buffer当前每帧Mesh.SetVertices会把数据从 CPU 传到 GPU。可以用MeshUpdateFlags.DontRecalculateBounds减少开销或用 GraphicsBuffer 做 GPU 端的数据更新。LOD细节层次离相机太远的血条可以降低更新频率或缩小尺寸甚至不显示。ComputeBuffer 的扩容策略当前实例数变化时会Release旧 Buffer 再new新的可以按 2 的幂预分配以减少重分配。八、总结优化前优化后收益血条UGUI GameObject × NGPU Instanced Quad × NDraw Call 从 N×2 降到 1飘字GameObject 对象池动态合批 MeshDraw Call 从 N 降到 1零 GC数据流Managed 单例 ↔ ECS全 ECS / 集中 List数据流清晰无跨系统耦合优化的本质减少 CPU 和 GPU 之间的通信次数把很多个小东西变成一个大东西一次性处理。无论是 Instancing 还是合批 Mesh都是在做这件事。https://mp.weixin.qq.com/s/oBbGtM_4tsbHJ-ebR-qqZg