ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity复刻斗鱼直播App:UGUI渲染优化与弹幕合批实战

Unity复刻斗鱼直播App:UGUI渲染优化与弹幕合批实战 我最近做了个挺有意思的项目在Unity里复刻斗鱼直播App的界面渲染效果。这事儿听起来简单真正动起手来才发现一个直播类App的UI渲染里藏着不少足能让人揪头发的技术细节——从UGUI的Canvas重建机制到弹幕合批优化从深浅色模式切换的图集策略到WebGL发布时IDBFS写入失败这个老朋友每一步都是在跟渲染基础原理打交道。这个项目最适合两类人看一是正打算用Unity做直播、社交类产品UI的开发者二是想深入理解Unity UI渲染机制、从“能用”进阶到“用得顺手”的Unity客户端。文章里不会堆太多学院派理论更多是实际操作中摸出来的经验毕竟这些东西光看文档是真的踩不出深浅。1. 直播类App的界面渲染需求拆解为什么非要用Unity先聊清楚一个事儿为什么放着Flutter、React Native不用偏要选Unity来做直播App界面这个决策直接决定了后面所有渲染方案的方向。1.1 这类App界面在渲染层面到底要什么拿斗鱼为代表看直播间的界面渲染大致分四层底层是视频画面渲染第二层是弹幕流第三层是送礼动画、直播间活动这类动态特效最上层是固定交互控件关注按钮、输入框、设置面板等。这四层里只有第一层是视频解码其他层都是典型的实时渲染任务。关键区别在于普通App的UI是“静态为主、动态点缀”而直播App的UI是“动态为核心”。弹幕每秒钟要产生大量文字礼物特效动不动就是几十个粒子加动画叠加深浅色皮肤切换要求所有UI元素在几帧内完成换装。这种强度下UI框架的渲染性能就不是“能不能画出来”的问题而是“每帧重置多少顶点、触发多少次Canvas重建”的量级问题。1.2 Unity和传统UI框架的渲染思路分岔这里不拉踩只讲原理差异。Flutter走的是自绘引擎所有控件都是自己拿Skia往纹理上画好处是跨平台一致性强。Unity里的UGUI则是基于GameObject 组件树最终通过Canvas Renderer把UI网格提交给合批系统再走一遍完整的可编程渲染管线。换句话说Unity的UI天然就在一个3D渲染管线里这意味着你可以给UI挂材质、写Shader、插进后处理也能把UI和3D虚拟主播、特效模型混在同一个场景里渲染。斗鱼这类产品的直播互动越来越依赖3D元素比如虚拟礼物在房间里的全屏3D演出、主播的3D形象和挂件。传统方案里UI和3D是两套体系接合处总得处理“哪个在上面、事件怎么穿透”这类问题。Unity里这些都是同一个场景里的不同层渲染顺序用Camera或RenderQueue控制就行干净利落。1.3 开发环境与版本选型Unity版本我用的2021.3 LTSUI框架和WebGL构建都够稳。UI框架UGUI为主个别复杂列表场景用ObjectPool配合ScrollRect手写。Text组件全部使用TextMeshPro这个后面会讲它和动态字体的合批差异非常大。渲染管线内置渲染管线BiRP。这个项目没到需要URP的程度内置管线调UI阴影和透明排序更顺手。版本这块想多说一句Unity 6现在把UI Toolkit推得挺猛我试过做原型但它的合批机制和动态内容高频更新的性能优化空间不如UGUI可控在做直播弹幕这种每秒要变更几MB顶点数据的场景时还是UGUI更踏实。2. UGUI底层渲染机制Canvas重建、合批规则和OverdrawUGUI的渲染机制是理解一切问题的前提。很多做Unity UI的朋友都在抱怨“UI一多就卡”但真问出“卡在哪一步”时能具体说上来的人不多都停在“发虚”的层面。2.1 一个Canvas到底是怎么把UI画出来的UGUI里任何一个可交互控件最后都会变为CanvasRenderer上的一个网格Mesh。网格里有顶点、UV、索引和颜色数据这些Mesh从UI网格生成到真正显示要走三个关键步骤布局与网格构建布局组件LayoutGroup计算出每个元素的矩形然后每个Graphic子类Image、Text等根据矩形生成自己的Mesh。合并成批处理单元同一个Canvas下的Mesh会按材质、纹理、Shader实例分组合并这就是合批。同一批内的所有UI元素只需要一次Draw Call就能画完。上交GPUCanvasRenderer把合并后的网格数据传到GPU。这里是第一个性能点第2步的合批不是每帧都重算它有个依赖脏标记的缓存机制。这个机制在Unity里叫Canvas.BuildBatch只有当某个UI元素的状态变化颜色、尺寸、顶点位置、材质属性等被标记为“脏”时这个Canvas才会重新构建批处理数据。2.2 哪些操作会触发Canvas重建哪些不会就以我实际调得最多的弹幕和礼物列表为例操作类型是否触发重建原因改变Text的字符串内容会文本网格需要重新生成改变RectTransform的尺寸会顶点位置变化重新布局改变UI元素的anchoredPosition会同一批内的元素位置变了合并结果需要重算改变Canvas的scale或alpha不会重建子元素只影响最终合成GPU端处理移动相机位置不会重建UI网格UI是屏幕空间相机位置不影响网格数据切换材质Shader参数会重建批次材质属性变化导致批次切换这套表格做出来后弹幕和动画类UI的优化思路就清晰了把不会触发重建的操作尽量留在GPU端处理把会触发重建的操作压缩到单个Canvas里并用对象池复用。2.3 合批的打断情况和Overdraw的控制合批最怕三类情况贴图不同、材质不同、被嵌入不同层级。前两种好理解第三种经常被忽略——Canvas下的渲染顺序是按Sibling Index兄弟节点顺序和z轴位置决定的如果一个高z值的不透明UI插在两个应该合批的UI之间Unity不会跳过它去合并批次生生拆开。Overdraw的问题在直播App里有另一重含义。礼物的环形辉光、弹幕的半透明背景、主播头像的圆形遮罩这些UI绘制时如果没有正确修剪填充率会翻着倍往上涨。我用Frame Debugger抓过一个普通直播间界面不做任何裁剪的面元填充率能到2.5x以上这还是在移动端硬要弄到1.2x以下的话就得在Image的alphaHitTestMinimumThreshold和RectMask2D上下一番功夫。2.4 层级拆分思路不要把所有UI丢进同一个Canvas在斗鱼渲染这个项目里我把直播间界面拆成了4个CanvasCanvas_Background视频渲染层上面的半透明白色遮罩、背景装饰光。这是最底层的Canvas不参与高频更新。Canvas_Danmaku弹幕专用Canvas。原因是弹幕每秒要产生大量新元素独立成一个Canvas后重建只影响弹幕层不会连累其他UI的合批缓存。Canvas_Interactive礼物特效、点赞动画、红点提示等高频动态内容层。Canvas_FixedUI底部导航栏、设置按钮、关注按钮等静态交互层。拆分完之后最直接的收益是用户疯狂刷礼物、弹幕飞速滚动时底下静态UI的批次缓存完全不受影响整个UI的Draw Call能稳定在40以内。没拆之前弹幕每次重建都会让全场UI跟着重算性能直接掉一截。3. 弹幕渲染的合批与字体策略动态内容优化的硬仗弹幕是直播间里渲染压力最大的部分没有之一。它的特点太不友好了元素数量巨大、每个元素都在匀速移动、文字内容不断变化、还得保证视觉上不遮挡关键画面。把这些需求翻译成UGUI的术语就是高频顶点重建 大批量移动 动态文本光栅化。3.1 为什么弹幕是UGUI性能的天敌UGUI的Text组件是靠字体纹理来绘制文字的。传统Text用动态字体Dynamic Font时每次出现一个新的字符系统都要把这个字形光栅化到字体图集Font Atlas里这个操作是在CPU端完成后再上传给GPU代价非常高。弹幕内容的随机性意味着每个新弹幕里大概率都有未预热的字符于是每来一条新弹幕图集可能就得重新填充一次。这还没完。弹幕的位置每帧都在变意味着每个弹幕文字的Mesh顶点每帧都要重新计算。滚动方向固定还好说如果带一点贝塞尔曲线入场或退场每帧重算的顶点数量会爆炸式增长。3.2 我的方案TextMeshPro 独立Canvas 对象池TextMeshProTMP解决的是字形动态光栅化的问题。TMP默认用静态字体资产SDF字体纹理所有字符预先烘焙好运行时只需要查找字形信息生成Mesh。这样弹幕内容再随机也不需要往纹理里增加字形代价是它的图集纹理比较大但这对合批影响很小。对象池解决的是Mesh重建频次问题。我把弹幕按模板字号、颜色、描边粗细预先实例化好一批TMP文本对象弹幕出现时从池子里取滑出屏幕时放回。滑动过程中的位置更新不是改RectTransform的anchoredPosition而是直接改成员的缓存RectTransform——这是个小细节但能省掉一部分布局重算的开销。具体实现时几个关键参数同屏弹幕上限我控制在200条以内超过的排进等待队列。这个数字是基于中端移动设备的顶点处理能力估算出来的。弹幕更新频率不是每帧都改所有弹幕的位置而是固定步长比如0.016s一步统一更新这样重建更可控。弹幕Canvas的排序放在背景Canvas之上、交互层之下并且这个Canvas的sortingOrder固定为中间值避免频繁和其他Canvas重排。3.3 弹幕合批策略和逐条测试数据所有同字号同颜色的弹幕放在同一个TMP字体材质下这样它们能在同一批内移动。TMP的材质如果设置了Face Color属性那么每改变一次颜色就会产生一个新的材质实例合批立刻被打断。所以弹幕颜色多样化时我宁可预生成几种固定颜色材质也不要运行时时时改色。上线前我实际测了一组数据中端安卓机高通骁龙778G级别场景弹幕量Draw Call帧耗时UI部分未优化传统Text 单Canvas100条8712.4ms优化后TMP 独立Canvas 对象池100条324.1ms优化后同屏200条200条355.8ms弹幕量翻倍的时候Draw Call只涨了3因为都在同一个批次里。这时候真正的压力转移到GPU端填充率上CPU端反倒轻松。这里有个容易被忽视的点弹幕的RectMask2D。因为弹幕会产生在屏幕外但仍在Canvas区域内的Mesh如果不裁剪那些已经滑出屏幕的弹幕其实还在被GPU处理。我在弹幕Canvas上挂了RectMask2D裁剪范围设成屏幕安全区。这个操作能立省5%-10%的UI填充率开销。4. 深浅色主题切换的渲染方案图集策略与材质实例化斗鱼这类App都有深色模式在Unity里做UI主题切换初看以为是API的问题做下去才发现这其实是个纯渲染架构问题。4.1 三种切换方案的性能对比主题切换通常有三种做法Color Tint着色器乘法运行时修改Image的color属性。实现最简单但它会触发Graphic顶点数据重建而且着色器乘法意味着所有控件颜色都是原图乘一个系数没办法做渐变、描边、多色分区这类细节。多套图集切换亮色一套、暗色一套切换时直接把Image的Sprite引用换掉。这套做法的困境是资源量翻倍而且在直播App里大部分按钮、图标都是程序化生成的角标、红点、进度条没法全用图集覆盖。自定义材质 材质属性控制UI材质里暴露一个Color属性切换时只改GlobalTexture或材质参数重点是这个操作不会触发Canvas重建。我把三种都实测了一遍最后采用的是第3种为主、第1种补位。原因很简单第3种切换时不动任何一次UI几何数据GPU端通过常量缓冲切换颜色开销几乎为零。4.2 用材质属性做主题切换的具体做法给UI材质加一个_ThemeColor属性然后为亮色和暗色各维护一套值。切换时遍历所有挂了这个材质的UI调用MaterialPropertyBlock设置新值。重点来了MaterialPropertyBlock不会生成新的材质实例所以不会导致批次断层如果用material.SetColor()那这个UI背后会悄悄new出一个材质实例合批立刻失效很多Unity新手在这里翻车。实现时要注意保持半透明物体的渲染顺序。UI材质默认在Transparent队列Queue3000切换主题后如果某张UI贴图改用透明裁剪Alpha Cutout风格它会变成不透明物体但优先级还是Transparent队列这会导致它被其他半透明UI错误遮挡。因此我在主题脚本里维护了一套RenderQueue映射表确保每个UI切换主题后Queue值不变。4.3 切换时的帧率卡顿根因是图集加载深色模式第一次打开时皮肤相关的大图集比如礼物墙背景、直播页底部背景需要从磁盘加载到内存。如果是AssetBundle打包这个加载就是同步IO几十毫秒的卡顿就来了。我的处理方式是做一个预加载队列App启动后空闲时就把两种主题的图集都加载到内存切换时只用加载材质参数对应的纹理。直播列表页、直播间的背景大图各占用几MB内存换来的是切换零卡顿值得。4.4 深色模式下的阴影问题热词里有个“unity阴影问题”在这个项目里具体体现为UI上的阴影用Shadow或Outline组件加的特效在深色背景下会变成一团脏黑色因为Shadow组件默认就是个纯色半透明四边形它不具备基于底图做模糊的能力。方案是全面换成CustomShadow底层画一张模糊纹理5x5高斯模糊采样的预制纹理颜色随主题变量走亮色下阴影透明度低暗色下阴影透明度再调低。实际效果在深色模式下几乎看不出硬边。5. 直播间3D内容与UI叠加World UI无遮挡方案直播App现在都带着3D玩法比如3D礼物模型在屏幕上旋转、虚拟主播坐在屏幕边角。这部分在渲染上最大的难点是3D世界坐标里的模型怎么保证它跟UI的遮挡关系是符合直觉的。5.1 两层Camera方案而不是RenderTexture最粗暴的方案是把3D模型渲染到RenderTexture再贴到UI上。但这方案有几个硬伤RenderTexture分辨率固定在不同屏幕比例下会糊不能实时和UI做深度穿插每多一个3D元素就要多一张RT显存扛不住。我在斗鱼渲染项目里用的是双层Camera方案UICamera渲染所有UI CanvasClear Flags设为Solid ColorAlpha0不参与物理相机层。OverlayCamera专门渲染3D直播互动元素的相机放在UICamera之前Clear Flags设为Depth Only。两个Camera的CullingMask严格分离UICamera只看UI层OverlayCamera只看3D互动层。这样3D模型天然在UI之上UI不会盖住它但它也不会被UI挡住。5.2 “world ui 无遮挡”的坑RenderQueue与深度写入这里有个巨坑。如果你的3D模型材质是半透明的比如礼物是个半透玻璃球Unity的渲染管线默认会按物体距离相机远近做排序半透明物体之间不写深度这就导致它跟UI叠加时经常出现“UI穿帮”——3D礼物在UI底下但又被UI的透明区域透过去了或者反过来。解决方法是给半透3D互动元素单独设一个渲染深度和RenderQueue。把它们的RenderQueue提到Transparent3000之内但深度写入保持On并且给它们一个大于UI层但小于不透明层之间的固定Queue值。这样Unity在透明队列里会优先画后排的半透物体再画前排的半透明UI视觉上就不会穿帮。更深一层如果要做“3D模型被弹幕挡住”的效果就不能靠双Camera解决了需要把3D模型放到UICamera的渲染管线里用同一个Canvas做深度测试。我测试后觉得这个方案不太划算——它会极大增加UGUI的重建压力最终选择放弃弹幕永远在3D模型上面产品认为可以接受。5.3 摄像机跟随和FOV动态变化直播场景里如果有3D主播那摄像机就得跟着主播做一些位移动画。这里有个容易忽略的细节如果OverlayCamera和UICamera没有同步主播移动时UI锚点跟着动UI会抖动。我的做法是写一个CameraSync脚本LateUpdate里把OverlayCamera的Transform同步成UICamera的Transform但CullingMask保持不同。这样只改相机位置不触发任何UI重建不会产生性能问题。5.4 扩大按钮点击范围的另一种思路热词里有“unity 如何扩大按钮的点击范围”传统做法是调Image的alphaHitTestMinimumThreshold为0或者在按钮外面套一个透明的Image。在3D叠加的场景里我用了另一种方式把Button的Graphic拖到一个自定的RaycastArea组件上这个组件在OnPointerDown时做一次矩形判断如果位置在按钮的扩展区域内就执行点击逻辑。好处是可以做异形点击热区比如一个圆环按钮只有环内的深度才响应。6. 发布WebGL时IDBFS写入失败的排查记录这个项目还有个特殊分支要发布WebGL版本然后毫无意外地撞上了“unity 发布 webgl 使用 idbfs 写入失败”这个经典问题。这不是新问题但每次都会有人问我把这次排查链路记录下来。6.1 IDBFS是什么为什么会写失败Unity WebGL的文件系统默认是内存文件系统Mono File System所有文件读写都在内存里完成关闭页面就丢。要在浏览器里持久化数据得靠IndexedDBUnity通过IDBFSIndexedDB File System在Unity的文件系统和浏览器的IndexedDB之间搭一座桥。写失败的原因主要五类IndexedDB不可用浏览器的隐私模式、旧版本浏览器、存储配额满都会导致IDBFS写入抛错。权限问题页面未获得存储权限或用户关闭了站点数据存储。并发事务冲突多个Unity实例或同一实例内多个协程同时写同一个IDBFS目录导致事务性写入失败。路径问题Unity在WebGL里的IDBFS挂载路径必须用Application.persistentDataPath如果用Application.dataPath写文件直接进内存关页面就没了。文件大小超限IndexedDB单条记录通常限制在几十MB到几百MB不等具体看浏览器。6.2 完整排查链路先确认错误日志里Error的关键词。Unity WebGL端的IDBFS报错经常是Failed to write file或IndexedDB operation failed。我加的排查顺序是先看浏览器DevTools的Application面板确认站点有IndexedDB空间、可用配额。在Unity里打印Application.persistentDataPath确认路径指向idbfs挂载点。用一个最小测试脚本初始化后写入一个10字节文件再读回来确认IDBFS本身通不通。如果通了再逐步增加并发写入量看是不是并发问题。如果还是失败换一个无痕窗口和普通窗口分别测排除隐私模式。最后我发现这个项目里最大的问题其实是Unity在初始化IDBFS之前我就尝试了File.WriteAllBytes。由于异步初始化没有完成写入直接落到内存文件系统然后被当成成功返回但刷新后文件没了。这个坑最阴的地方在于它不报错。6.3 最终的稳定写入方案给IDBFS一个明确的就绪回调然后把所有文件系统操作包在这个回调之后执行。具体用到了Unity的UnityEngine.Application.ExternalEval去触发Web端的IndexedDB检查等回调确认挂载完成后才允许业务代码读写。文件操作统一走一个IdbfsFileManager内部维护了一个写入队列任何并发写请求都会排队串行执行避免事务冲突。缓冲掉到内存等队列空闲后再统一落盘。这个方案跑下来连续写几百个小文件都没有再丢数据。6.4 WebGL渲染内存和压缩层级的取舍WebGL的另一个常见问题是内存膨胀。Unity WebGL默认用wasm堆作为unity堆的一部分压缩层级太高会导致加载慢太低则传输体积大。实测下来Brotli压缩配合Low的初始堆设置比较平衡中端手机上加载耗时大概慢10%但运行内存峰值降了20%。如果这个WebGL版本还需要跑3D礼物动画建议把Enable Exceptions关掉这个选项在开发模式下开着没关系正式构建开了它内存直接多出一两百MB。7. 针对这套渲染方案的个人体会和几个实用建议项目做到后期我发现真正决定UI渲染质量的不在单点技术选型而在于对UGUI渲染全链路的敏感度。什么时候能放心地让一个UI随着动画每帧重建、什么时候必须把它隔离到独立Canvas、什么时候该用MaterialPropertyBlock而不是材质实例这些判断力比背任何API都重要。有几个经验我觉得值得单独拿出来说在PC编辑器里测UI性能会骗人。UGUI的Canvas重建和批次合并在编辑器里用的是CPU模拟跟真机GPU行为差异非常大。我习惯直接在真机上用Frame Debugger抓数据。TextMeshPro的字体烘焙参数里Padding和Atlas Size直接影响渲染质量。Padding太小字重渲染时容易有毛边Atlas Size太大切换时加载卡顿。一般正文用Padding5Atlas Size 2048起步。如果团队里有人问“为什么我UI改了颜色Draw Call突然翻倍”多半是Image.color改到了材质实例上。先查脚本里有没有GetComponentImage().material.SetColor()有就换成MaterialPropertyBlock。弹幕合批时同一个批次里尽量不要混入不同字号的文本。TMP的字号如果变了字体资产里对应的字形UV和缩放矩阵都会变Unity会为这个变化重算Mesh批次照样打断。提前按字号分好模板池比运行时动态改字号省太多事。WebGL发布时Application.persistentDataPath在首帧可能不可用这个我在前面说过了。更稳妥的做法是写文件之前检查一个静态标志位这个标志位由IDBFS初始化回调置位不到绝不执行持久化写操作。最后分享一个小技巧。在直播间这种高频UI场景里我自己写了一个UIPerfProbe调试脚本把UGUI的CanvasRenderer列表取出来统计每帧提交的顶点数总和。用这个数字除以理论最大顶点预算可以得到一个“UI健康度”。当这个健康度超过70%的时候我就会警惕是不是又有哪个UI在做无谓的重建了。这个脚本在编辑器里和真机上都能跑相当于给UI渲染装了个心率监测仪玩起来比看那些抽象的性能报告实用多了。
返回列表