恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
UGUIDOTS实战:将UGUI渲染数据迁移到DOTS架构的完整方案
首页
资讯中心
/
UGUIDOTS实战:将UGUI渲染数据迁移到DOTS架构的完整方案
UGUIDOTS实战:将UGUI渲染数据迁移到DOTS架构的完整方案
发布时间:2026/10/18 12:41:59
简介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.GetComponentsInChildrenGraphic(); foreach (var graphic in graphics) { // 为每个 Graphic 创建一个 Entity var entity entityManager.CreateEntity(); // 提取顶点数据注意这里要调用 graphic.GetVertexStream 或类似接口 // 实际源码中是通过 VertexHelper 来获取的 var vh new VertexHelper(); graphic.OnPopulateMesh(vh); // 顶点位置 var vertices new NativeArrayfloat3(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.AddBufferUIVertex(entity); vertexBuffer.ReAddRange(vertices); // UV 和颜色同理这里省略重复代码 // ... // 索引数据 var indexBuffer entityManager.AddBufferUIIndex(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 排序然后提交绘制。下面是一个简化的渲染 Systemusing Unity.Entities; using Unity.Collections; using Unity.Mathematics; using Unity.Rendering; using UnityEngine; [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class UIRenderSystem : SystemBase { // 用于排序的临时列表 private NativeListEntity sortedEntities; protected override void OnCreate() { sortedEntities new NativeListEntity(Allocator.Persistent); } protected override void OnUpdate() { // 清空上一帧的排序结果 sortedEntities.Clear(); // 收集所有需要渲染的 UI Entity Entities .WithAllUIRenderState, 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.GetComponentDataUIRenderState(sortedEntities[i]); var stateB EntityManager.GetComponentDataUIRenderState(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.GetSharedComponentDataUIMaterial(entity); var vertexBuffer EntityManager.GetBufferUIVertex(entity); var indexBuffer EntityManager.GetBufferUIIndex(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 设为 FilledVertexHelper 的数据可能跟预期不一致。解决在转换前先调用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 遍历开销反而更大。另外如果每帧都重新创建 MeshGC 压力会抵消掉批处理的好处。解决UI 元素少于 50 个的时候没必要上 UGUIDOTS。元素多的时候确保 Mesh 缓存池生效避免每帧分配。渲染 System 的排序算法要优化别用冒泡。还有Burst 编译要开启否则 System 的执行效率上不去。5. 进阶技巧用 Burst 和 Job 把 UI 更新压到 0.1ms5.1 把顶点更新写成 Job转换和渲染只是第一步真正体现 DOTS 优势的是动态更新。比如一个血条每帧要改顶点位置传统 UGUI 会触发 Canvas 重建而 UGUIDOTS 可以把这个更新写成一个 Jobusing 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 就继续查。希望这套拆解能帮到你少走点我踩过的弯路。本文还有配套的精品资源点击获取