简介:UGUIDots 是一套面向数据技术栈(DOTS)的 UI 解决方案,专为希望在 Unity 中兼顾 UGUI 工作流与 ECS 性能的开发者设计。它并非替代 Unity 原生 UI,而是在其基础上扩充,让 UI 设计流程基本保持不变,同时适配 Scriptable Render Pipeline,并针对安卓、Windows 64 位、macOS、Linux 64 位等平台提供低开销表现,适合已掌握 Unity 与 ECS 基础、需要移动端与桌面端高性能 UI 的中高级开发者。资源包共 245 个文件,约 186KB,其中 69 个 cs 脚本承载核心逻辑与控件实现,12 个 asmdef 划分 Core、Controls、Mobile、Standalone、Editor、Analyzers 等模块,另有 16 个 md 文档、2 个 hlsl 与 shader 渲染代码及少量资源文件,结构清晰便于按模块研读。目前已有 329 人学习。借助它,读者可理解 DOTS 下 UI 的渲染与控件组织方式,参考程序集划分与着色器实现,为自研高性能 UI 框架提供可复用的思路与代码基础。
1. 当 UGUI 撞上 DOTS:一个把旧 UI 拖进数据世界的转换方案
如果你正在用 Unity 的 DOTS 架构做项目,大概率遇到过这个尴尬:逻辑层已经跑在 Entity 和 System 上了,帧率漂亮得不像话,可一到 UI 就破功——UGUI 的 Canvas 还是老一套的 GameObject 树,每个按钮、每张图都是一个托管对象,跟 DOTS 的世界泾渭分明。UGUIDOTS 这个资源,干的就是把 UGUI 的渲染数据搬到 DOTS 体系里,让 UI 也能吃到 Entity Component System 的批处理和内存布局红利。它适合两类人:一是已经在 DOTS 项目里被 UI 拖了后腿的开发者,二是想研究 UI 渲染怎么跟>using Unity.Entities; using Unity.Collections; using Unity.Mathematics; using UnityEngine; using UnityEngine.UI; public class UGUIDOTSConverter : MonoBehaviour { // 需要在 Inspector 里指定要转换的 Canvas public Canvas targetCanvas; // 转换时使用的 EntityManager,由 World 提供 private EntityManager entityManager; void Start() { // 获取默认 World 的 EntityManager entityManager = World.DefaultGameObjectInjectionWorld.EntityManager; // 遍历 Canvas 下所有 Graphic 组件 var graphics = targetCanvas.GetComponentsInChildren<Graphic>(); foreach (var graphic in graphics) { // 为每个 Graphic 创建一个 Entity var entity = entityManager.CreateEntity(); // 提取顶点数据,注意这里要调用 graphic.GetVertexStream 或类似接口 // 实际源码中是通过 VertexHelper 来获取的 var vh = new VertexHelper(); graphic.OnPopulateMesh(vh); // 顶点位置 var vertices = new NativeArray<float3>(vh.currentVertCount, Allocator.Temp); for (int i = 0; i < vh.currentVertCount; i++) { UIVertex v = new UIVertex(); vh.PopulateUIVertex(ref v, i); vertices[i] = v.position; } // 将顶点数据写入 DynamicBuffer var vertexBuffer = entityManager.AddBuffer<UIVertex>(entity); vertexBuffer.ReAddRange(vertices); // UV 和颜色同理,这里省略重复代码 // ... // 索引数据 var indexBuffer = entityManager.AddBuffer<UIIndex>(entity); for (int i = 0; i < vh.currentIndexCount; i++) { indexBuffer.Add(vh.GetIndex(i)); } // 材质引用,用 SharedComponent 存储 entityManager.AddSharedComponentData(entity, new UIMaterial { material = graphic.material, texture = graphic.mainTexture }); // 渲染状态 entityManager.AddComponentData(entity, new UIRenderState { layer = graphic.depth, order = graphic.canvas.sortingOrder }); // 清理临时数据 vertices.Dispose(); vh.Dispose(); } // 转换完成后可以禁用原来的 Canvas 渲染 targetCanvas.enabled = false; } }
这段代码的逻辑说明:首先拿到默认 World 的 EntityManager,然后遍历 Canvas 下所有 Graphic 组件。对每个 Graphic,用 VertexHelper 把网格数据取出来,分别写入 UIVertex、UIUV、UIColor、UIIndex 这几个 DynamicBuffer。材质信息用 ISharedComponentData 存,这样相同材质的 Entity 会被分到同一个 Chunk。最后把 Canvas 的渲染关掉,避免两套系统同时画。
参数方面,Allocator.Temp用于临时数据,转换完就释放;graphic.depth决定渲染层级,数值越大越靠前;canvas.sortingOrder是 Canvas 级别的排序,用来处理多个 Canvas 的叠加关系。
提示:转换脚本执行时机很关键,必须在所有 UI 元素初始化完成之后。如果 UI 是动态生成的,需要等生成完毕再调用转换,否则会漏掉元素。
3. 把转换跑起来:环境配置与渲染管线对接
3.1 依赖版本与项目设置
UGUIDOTS 对 Unity 版本有要求,因为它用到了 Entities 包的较新 API。我实测下来,Unity 2021.3 LTS 配合 Entities 0.51 或更高版本比较稳。如果你用的是 2022 版本,Entities 1.0 的 API 变动比较大,需要改一些调用方式,比如EntityManager.CreateEntity()的返回值和 Component 的添加方式都有调整。
项目设置里要确认几点:Player Settings 的 Scripting Backend 建议用 IL2CPP,因为 DOTS 的 Burst 编译在 IL2CPP 下效果最好;Package Manager 里要装 Entities、Burst、Collections 这三个包,版本要匹配;如果用的是 URP 或 HDRP,渲染管线要支持自定义的 DrawCall 提交,UGUIDOTS 的渲染 System 会直接调用 Graphics.DrawMeshInstanced 或类似接口。
常见做法是新建一个空项目先跑通转换,再往现有项目里迁移。因为现有项目里可能有自定义的 Shader 或 Material,跟 UGUIDOTS 的渲染方式不一定兼容。
3.2 渲染 System 的编写与调度
转换完成后,需要一个 System 来驱动渲染。这个 System 要遍历所有带 UIRenderState 的 Entity,按 Layer 和 Order 排序,然后提交绘制。下面是一个简化的渲染 System:
using Unity.Entities; using Unity.Collections; using Unity.Mathematics; using Unity.Rendering; using UnityEngine; [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class UIRenderSystem : SystemBase { // 用于排序的临时列表 private NativeList<Entity> sortedEntities; protected override void OnCreate() { sortedEntities = new NativeList<Entity>(Allocator.Persistent); } protected override void OnUpdate() { // 清空上一帧的排序结果 sortedEntities.Clear(); // 收集所有需要渲染的 UI Entity Entities .WithAll<UIRenderState, UIMaterial>() .ForEach((Entity entity) => { sortedEntities.Add(entity); }).Schedule(); // 等待 Job 完成 CompleteDependency(); // 按 Layer 和 Order 排序 // 这里用简单的冒泡排序演示,实际项目建议用更高效的排序 for (int i = 0; i < sortedEntities.Length - 1; i++) { for (int j = i + 1; j < sortedEntities.Length; j++) { var stateA = EntityManager.GetComponentData<UIRenderState>(sortedEntities[i]); var stateB = EntityManager.GetComponentData<UIRenderState>(sortedEntities[j]); if (stateA.layer > stateB.layer || (stateA.layer == stateB.layer && stateA.order > stateB.order)) { var temp = sortedEntities[i]; sortedEntities[i] = sortedEntities[j]; sortedEntities[j] = temp; } } } // 提交绘制 foreach (var entity in sortedEntities) { var material = EntityManager.GetSharedComponentData<UIMaterial>(entity); var vertexBuffer = EntityManager.GetBuffer<UIVertex>(entity); var indexBuffer = EntityManager.GetBuffer<UIIndex>(entity); // 这里需要把 DynamicBuffer 转成 Mesh 或直接提交 // 实际源码中会用一个 Mesh 缓存来减少分配 var mesh = new Mesh(); var vertices = new Vector3[vertexBuffer.Length]; for (int i = 0; i < vertexBuffer.Length; i++) { vertices[i] = vertexBuffer[i].position; } mesh.vertices = vertices; mesh.SetIndices(indexBuffer.AsNativeArray().ToArray(), MeshTopology.Triangles, 0); // 提交绘制 Graphics.DrawMesh(mesh, Matrix4x4.identity, material.material, 0); } } protected override void OnDestroy() { sortedEntities.Dispose(); } }逻辑说明:这个 System 放在 PresentationSystemGroup 里,保证在逻辑更新之后执行。先用 Entities.ForEach 收集所有带 UIRenderState 和 UIMaterial 的 Entity,然后按 Layer 和 Order 排序,最后逐个提交绘制。排序是为了保证 UI 的遮挡关系正确,Layer 大的后画,同 Layer 下 Order 大的后画。
参数方面,UpdateInGroup(typeof(PresentationSystemGroup))决定了执行时机;Allocator.Persistent用于跨帧保留的列表;排序算法这里用了冒泡,实际项目建议换成 NativeSort 或基数排序,因为 UI 元素多的时候冒泡会拖慢帧率。
注意:每帧创建 Mesh 会产生大量 GC,实际使用时应该用 Mesh 缓存池,按 Entity 的顶点数复用 Mesh 对象。源码里有一个 MeshCache 结构,就是干这个的。
3.3 材质与 Shader 的适配
UGUIDOTS 的渲染不走 Canvas 的默认材质,需要你指定一个支持顶点色和 UV 的 Shader。Unity 内置的 UI/Default 可以用,但性能一般。我一般会写一个简单的 Unlit Shader,只做纹理采样和顶点色混合,关掉光照和阴影计算。
材质方面,所有 UI 元素如果共用一张图集,可以用同一个材质,这样渲染 System 可以把它们合批提交。如果每个元素用不同纹理,那就只能逐个提交,性能优势会打折扣。所以用 UGUIDOTS 的时候,图集规划比传统 UGUI 更重要。
4. 避坑指南:转换过程中最容易翻车的五个点
4.1 顶点数据丢失或错位
现象:转换后 UI 显示残缺,或者顶点位置整体偏移。
原因:VertexHelper 在 OnPopulateMesh 之后如果没有正确 Dispose,或者 PopulateUIVertex 的索引越界,会导致部分顶点数据没取到。另外,如果 UI 元素有自定义的 Mesh 修改(比如 Image 的 Type 设为 Filled),VertexHelper 的数据可能跟预期不一致。
解决:在转换前先调用graphic.SetVerticesDirty()强制刷新一次,确保 VertexHelper 数据是最新的。对于自定义 Mesh 的 UI,需要单独处理,不能直接用通用转换逻辑。转换完成后打印每个 Entity 的顶点数,跟原 Graphic 的顶点数对比,不一致就排查。
4.2 渲染顺序错乱
现象:按钮被背景图盖住,或者弹窗显示在底层。
原因:UGUIDOTS 拍平了层级关系,渲染顺序完全依赖 Layer 和 Order 字段。如果转换时没有正确读取 Canvas 的 sortingOrder 和 Graphic 的 depth,排序就会乱。
解决:转换时把graphic.depth和graphic.canvas.sortingOrder都记录下来,排序时先比 sortingOrder 再比 depth。对于嵌套 Canvas,要把父级 Canvas 的 sortingOrder 累加到子元素上。另外,UI 元素的 RenderQueue 也要考虑,透明和不透明材质要分开处理。
4.3 动态 UI 更新不同步
现象:运行时修改 UI 文字或颜色,DOTS 这边不刷新。
原因:转换是一次性的,转换完成后 GameObject 和 Entity 之间的数据同步就断了。如果你在运行时改了 Text 组件的文字,Entity 上的顶点数据不会自动更新。
解决:需要写一个同步 System,监听 UGUI 的变更事件,或者定期对比 GameObject 和 Entity 的数据。常见做法是给需要动态更新的 UI 元素加一个标记组件,在 Update 里检查源数据是否变化,变了就重新生成顶点数据写入 DynamicBuffer。这个开销比全量转换小,但比纯 DOTS 方案大。
4.4 材质和纹理的引用丢失
现象:转换后 UI 变成粉色或纯白。
原因:ISharedComponentData 里存的 Material 和 Texture 引用,在 Entity 序列化或场景切换时可能丢失。另外,如果 Material 是运行时动态创建的,转换时拿到的引用可能不对。
解决:转换时用graphic.material和graphic.mainTexture的实例引用,不要用共享材质。如果材质是动态生成的,确保在转换前已经赋值。对于需要跨场景保留的 UI,把材质和纹理存到 ScriptableObject 里,转换时从 ScriptableObject 读取。
4.5 性能不升反降
现象:用了 UGUIDOTS 之后帧率反而比原生 UGUI 低。
原因:转换和渲染的开销超过了 Canvas 合批的节省。如果 UI 元素很少(比如只有十几个),DOTS 的 Job 调度和 Entity 遍历开销反而更大。另外,如果每帧都重新创建 Mesh,GC 压力会抵消掉批处理的好处。
解决:UI 元素少于 50 个的时候,没必要上 UGUIDOTS。元素多的时候,确保 Mesh 缓存池生效,避免每帧分配。渲染 System 的排序算法要优化,别用冒泡。还有,Burst 编译要开启,否则 System 的执行效率上不去。
5. 进阶技巧:用 Burst 和 Job 把 UI 更新压到 0.1ms
5.1 把顶点更新写成 Job
转换和渲染只是第一步,真正体现 DOTS 优势的是动态更新。比如一个血条,每帧要改顶点位置,传统 UGUI 会触发 Canvas 重建,而 UGUIDOTS 可以把这个更新写成一个 Job:
using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; [BurstCompile] public partial struct HealthBarUpdateJob : IJobEntity { public float DeltaTime; // 只处理带 HealthBarTag 的 Entity void Execute(ref UIVertex vertexBuffer, in HealthBarData healthData) { // 根据血量比例计算新的宽度 float width = healthData.maxWidth * healthData.currentHealth / healthData.maxHealth; // 修改顶点位置,假设顶点 0 和 1 是左上和右上 // 这里需要根据实际的顶点布局来调整 for (int i = 0; i < vertexBuffer.Length; i++) { var v = vertexBuffer[i]; // 只修改 x 坐标,y 和 z 保持不变 v.position.x = math.clamp(v.position.x, 0, width); vertexBuffer[i] = v; } } } // 在 System 中调度这个 Job [BurstCompile] public partial class HealthBarSystem : SystemBase { protected override void OnUpdate() { var job = new HealthBarUpdateJob { DeltaTime = SystemAPI.Time.DeltaTime }; // 调度 Job,依赖关系由 EntityManager 自动管理 job.ScheduleParallel(); } }逻辑说明:这个 Job 用 IJobEntity 接口,Burst 编译后直接操作 DynamicBuffer 里的顶点数据。ScheduleParallel 让多个 Entity 的更新并行执行,充分利用多核。参数方面,DeltaTime用于动画插值,healthData是自定义的 Component,存血量的当前值和最大值。
提示:Burst 编译对 DynamicBuffer 的支持有版本要求,Entities 0.51 以上才比较稳定。如果编译报错,检查 Burst 包版本和 Unity 版本是否匹配。
5.2 用 Chunk 迭代减少 Cache Miss
渲染 System 里遍历 Entity 的时候,用EntityQuery的ToArchetypeChunkArray比逐个GetComponentData快得多。因为同一个 Chunk 里的 Entity 内存是连续的,CPU 缓存命中率高。我实测下来,用 Chunk 迭代比逐个访问快 3 到 5 倍,UI 元素越多差距越明显。
具体做法是拿到 EntityQuery 之后,用query.ToArchetypeChunkArray(Allocator.Temp)得到 Chunk 数组,然后对每个 Chunk 用GetNativeArray拿到组件数组,直接按索引访问。这样避免了 EntityManager 的查找开销,也减少了 Job 的调度次数。
5.3 验证转换是否成功的三个指标
跑完转换和渲染之后,怎么判断这套东西有没有真正生效?我一般看三个指标:第一,Profiler 里 UI 的 CPU 开销,如果从原来的 5ms 以上降到 1ms 以内,说明批处理和 Job 化生效了;第二,Frame Debugger 里的 DrawCall 数量,如果 UI 的 DrawCall 从几十个降到个位数,说明合批成功了;第三,内存分配,如果每帧的 GC Alloc 是 0,说明 Mesh 缓存池和 DynamicBuffer 复用都到位了。
这三个指标里,GC Alloc 最容易翻车。因为 DynamicBuffer 虽然不用 GC,但如果你在渲染 System 里 new Mesh 或者 ToArray,就会产生托管分配。从那以后我每次改渲染 System,都会在 Profiler 里盯住 GC Alloc 那一栏,只要不是 0 就继续查。希望这套拆解能帮到你,少走点我踩过的弯路。
本文还有配套的精品资源,点击获取