UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 - 优化
UWA DAY 2025 正式启动｜十年筑基，驱动游戏未来引擎
由侑虎科技UWA主办的年度游戏开发者大会 - UWA DAY 2025，正式拉开帷幕！作为第八届盛会，也是侑虎科技成立十周年的里程碑之作，本届大会将以“十年筑基 · 驱动游戏未来引擎”为主题，于2025年8月在上海盛大举办。 从开发优化到质量保障，从前沿技术到实践落地，UWA DAY始终紧贴行业脉动，持续为开发者提供深度技术交流的平台。今年，我们将以更加开放的姿态，与广大开发者共同探讨下一阶段游戏工业的进化之路。 十年耕耘，与开发者共筑技术星河 自2015年创立以来，UWA始终坚持“快｜精准｜有温度｜值得信赖”的初心，持续深耕游戏研发工具和服务。十年来，从性能评测工具到全生命周期解决方案，从小型沙龙到全国技术峰会，我们始终与开发者并肩前行，见证并参与了中国游戏产业的不断跃迁。 UWA DAY，作为技术交流的风向标，也在这三千多个日夜中与千万开发者一起成长。站在十周年的新起点，我们愿以更加坚实的技术底座，为下一代引擎注入持续动力。 技术破界，重构未来研发生态 本届UWA DAY将围绕「引擎革新」、「工业化工具链」与「研发生态建设」三大核心主题，来自UWA及行业顶尖团队的资深技术专家，预计带来30+场高质量分享内容——从理论探索到实战落地，绘制一幅面向未来的技术图谱。 议题议程 2025年8月1日 周五 2025年8月2日 周六 议题总览 ➨十年筑基，驱动游戏未来引擎 主题演讲 张鑫 侑虎科技 CEO 随着移动游戏研发成本持续攀升与轻量化游戏生态的成熟，中国游戏行业正呈现 "品质深耕" 与 "敏捷开发" 并行的双轨发展格局。一端是3A化手游对工业化流程的极致打磨，另一端是轻量化团队对快速迭代效率的不懈追求。这种行业分化既考验着UWA技术服务的专业性与适配性，也为工具矩阵的场景化创新开辟了全新赛道。 过去一年，UWA围绕研发效率与玩家体验两大核心场景展开深度探索，在技术落地与产品迭代中实现关键突破。本次报告将重点分享三个方向的成果： GPM 2.0：玩家端体验的精准洞察。通过精准捕捉玩家的性能异常波动，为高品质游戏的长线运营提供质量保障。 GOT Online & Pipeline：工业化流程的 AI 赋能。通过与AI大模型的结合，进一步降低游戏团队对于研发工业化流程的准入门槛。 Gears：降低研发端性能检测的使用门槛。通过这一年来不断完善的检测功能，满足广大游戏团队的日常常规检测需求。 行业变革之下，UWA 始终相信，唯有扎根研发痛点的持续创新，才能在品质与效率的双螺旋中找到平衡点。本次报告将深度拆解技术落地的实战经验，期待与行业同仁共探 "工业化流程+智能化工具" 的破局路径，以十年积淀为基，为游戏未来研发注入新的动能。 ➨ UWA Gears深度体验 - 运行时性能测试与GPU深度分析新突破 张强 侑虎科技 CTO UWA Gears作为一款集成运行时性能分析与截帧渲染分析的专业测评工具，是游戏GPU性能优化的重要支撑，能够为开发者提供精准的性能数据与问题定位依据。 当前，随着游戏引擎对Vulkan API的支持日益成熟，以及移动设备的快速更新换代，Vulkan的兼容性持续改善，其在渲染效率提升、图形资源内存优化等方面的优势逐渐凸显，越来越多的研发团队开始使用Vulkan。在此背景下，UWA Gears近期实现了对Vulkan API截帧分析的支持，为开发者在Vulkan环境下的GPU性能优化提供了更强助力。 本次报告将围绕UWA Gears展开分享：一方面，详细演示如何借助该工具进行GPU性能分析；另一方面，重点展示在Vulkan环境下的重要参数解读与分析方法。通过这些内容，助力开发者更透彻地理解Vulkan特性，精准发现并高效解决GPU性能问题。 ➨ 虚幻引擎5构建开放世界下的优化、挑战以及开发方向 陈宝康 Epic Games 开发者关系工程师 现担任Epic Games开发者关系工程师，协助解决游戏公司在使用虚幻引擎中遇到的相关问题，曾先后就职于字节、腾讯，从事游戏开发多年。 随着引擎技术的不断发展，游戏引擎未来会更加关注开放世界的构建能力来带给玩家以沉浸式的体验，而Epic作为以赋能创作者和构建数字生态为核心的商业引擎，长远目标同样也是为了创建出一个元宇宙的虚拟世界，并带给玩家以身临其境的体验。每个人都可以自由的进行创作和表达，构建虚拟世界的未来，同时并将其技术赋能于游戏、影视、工业等不同领域。而在这长远目标下如何去构建开放世界成为了其中的重要一环，因为构建开放世界下的场景对游戏引擎提出了更高的要求，更大的开放世界意味着制作过程中需要面临一系列问题，本次报告与大家分享的就是虚幻引擎5针对开放世界在不同版本中主要做了哪些工作、目前的进度、面临了哪些挑战，以及未来的开发方向。主要包括：UE5构建开放世界的技术点、不同版本新增了哪些功能、开放世界下pcg工作流展示、UE5.6的主要内容介绍以及未来的开发方向。 ➨ AI在游戏开发领域的颠覆式革新 麒麟子 Cocos引擎 技术总监 游戏引擎领域深耕15年，目前负责Cocos引擎的产品研发与开发者社区。社区 KOL、技术布道师、科技自媒体博主，接触了4000+研发公司，全网程序员粉丝15万+。当前阶段的核心任务，是研究AI技术如何转化为工作流和生产力，为产品研发带来极致的效率提升。 人工智能正在以前所未有的速度重塑游戏开发的每一个环节。从传统的手工编程到智能化工具辅助，从昂贵的资产制作到AI一键生成，从复杂的开发流程到30分钟构建游戏原型，游戏开发正经历着一场深刻的技术革命。本次分享将深入探讨AI技术在游戏开发领域的五大革新方向：开发工具、资产生成、技术栈、端侧AI、实时内容驱动。这不仅仅是一次技术分享，更是对游戏开发未来的深度思考。无论你是资深开发者、创业团队负责人，还是对游戏技术充满好奇的从业者，这次演讲都将为你提供一些思考和帮助。让我们一起探索AI如何重新定义游戏开发的边界，见证一个全新时代的到来。 ➨ UE-DS的长跑挑战 - 长生命周期大世界的性能治理探索之路 赵珂 腾讯天美J1工作室 高级工程师 十年游戏开发经验，2019年入职腾讯，先后在《ApexMobile》、《高能英雄》项目中长期专注UE4引擎网络同步和DS性能优化工作。目前在《元梦之星-山海寻灵》玩法中负责DS基建和战斗相关工作。 本次报告将介绍在使用UE4引擎开发一款长生命周期大世界游戏过程中面临的技术挑战和实践经验。在短单局射击游戏中，DS优化聚焦逻辑精简和单线程压榨，通过限制同步、关闭动画等常规手段提升承载；而《元梦之星-山海寻灵》的长生命周期大世界需直面复杂业务叠加与持久战斗体验的冲突，迫使优化思路发生本质转型。报告主要内容包括：常规DS的优化、长生命周期大世界的新挑战、长局优化的混合方案和实验探索。 ➨ 《归环》项目研发效率提升之路 贾伟昊 萨罗斯网络科技（深圳）有限公司 项目主程 毕业于清华大学软件学院，先后任职于网易、心光流美、字节等公司，积累了十余年游戏开发经验，喜欢思考和分享，目前担任《归环》项目主程序。 为了满足玩家对于内容和品质的需求，游戏项目的体量越来越大，动辄几百人的团队规模对于开发和协作效率的挑战也越来越大，如何让团队高效地生产出高品质的游戏内容，成为了项目成败的关键。本次报告将从研效基建、技术框架、工作流建设等方面和大家交流《归环》这款刚刚首曝的二次元产品在效能提升方面的一些思考和实践，也会和大家分享一些我们走过的弯路、踩过的坑，希望能够带给一同在游戏研发之路上探索的朋友们一些新的思路。 ➨ 大世界场景工业化：程序化生成x模块化资产x渲染优化工作流 何文雅 珠海西山居 资深技术美术 拥有16年游戏开发经验，现任职西山居技术美术经理。专注美术资产管线规范化，主导制定跨项目的美术标准与工具链体系，推动场景自动化生成、虚拟几何体、全局光照、画质分级等核心技术落地，致力于用引擎技术解放美术生产力，实现画质与性能的动态平衡。 在游戏工业化进程中，流程效率瓶颈与技术迭代需求已成为核心挑战。本次报告将分享《解限机》项目基于Unity引擎定制的大世界场景自动构建解决方案，揭秘超大规模场景高效开发的破局之道。主要内容包括：通过Houdini程序化生成实现自然生态系统自动化构建；采用模块化数据架构革新人文环境生产管线；并融合虚拟几何体等次世代渲染技术。在零成本改造美术流程的前提下，完成从场景生成到电影级表现的技术闭环。 ➨ 技术美术：AI在游戏研发中的应用探索- 搭建游戏开发AI智能体与AI生成美术资产工业化管线 丁彦清 资深技术美术 北京电影学院数字媒体艺术专业硕士，现就职于某游戏大厂，深耕游戏开发领域。曾参与《生死狙击2》、《矩阵·零日危机》等项目的研发工作，积累了丰富的实战经验。核心专长聚焦于：游戏渲染技术 - 致力于高品质视觉效果的实现与渲染管线优化；性能优化 - 对游戏运行效率有深入理解与实践，擅长解决性能瓶颈；AI技术应用探索 - 积极研究并实践生成式AI等前沿技术在游戏美术管线、内容创作及玩法创新中的应用潜力。 本次报告系统性拆解AI技术在游戏美术管线中的工业化实践方案，涵盖三大核心模块： AI智能体游戏开发实践：基于Dify构建的TA-Agent实现材质规范审查、性能风险预警、资产版本自动化管理；基于Dify构建的智能助手如何解决材质开发、性能优化等跨领域难题； 生成式工作流革新：通过ComfyUI实现3D模型→UI资产的一键转化（含IP Lora训练/ControlNet姿势控制）、PBR贴图智能生成（SDXL+Substance 3D Sampler联动）、立方体贴图批量化生产； 工业化管线集成：贴图生成工作流与Substance 3D Sampler的PBR管线对接方案，立方体贴图批量生成标准。 ➨ UE美术表现的非技术力思考 - 效果的合理偷懒 李季（老李头儿） 数字艺术家，TA 上海市美术家协会会员，上海市青年美术摄影家协会副秘书长，视觉暂留TA合伙人。第四届瑞云渲染大赛季军，第六届GGAC裸眼3D赛道季军，B站UP渲染封神榜TOP50，数字艺术作品参与2025大阪世博会Shining Hat，WESTBUND西岸艺术博览会，全国青年艺术邀请大展等国内外诸多艺术展览。服务对象包括米哈游、腾讯、网易、开天工作室等。 在虚幻引擎的美术创作中，“合理偷懒”并非降低品质，而是通过策略性取舍与美术思维重构技术实现路径，以最低技术成本撬动高效视觉表现。其核心逻辑在通过资源复用、流程优化与注意力引导，在保障表现力的前提下规避冗余劳动。本次报告将介绍资源复用策略以及视觉转移设计的两个方向，运用美学决策优先的策略以减少手动调优的负担。让技术成为创意的赋能者。 ➨ 稳定、高效、安全的出海之路 - 阿里云游戏出海解决方案 云骧 阿里云智能集团 高级解决方案架构师 10年云计算领域经验，15年数据库、大数据经验。现任阿里云解决方案架构师，熟悉医疗、教育和游戏行业，帮助多家大中型企业完成系统上云、出海架构规划。 在全球化浪潮不断加速的背景下，越来越多的游戏企业开始走向海外市场，但随之而来的技术挑战也愈加复杂。本次报告将从出海游戏的实际需求出发，结合案例，分享阿里云全球基建、系统架构等方面的优势和实践结果。帮助客户快速高效的开展海外业务，探讨阿里云提供的云产品和解决方案如何助力游戏企业实现稳定、高效、安全的出海之路，希望能为正在或计划出海的游戏客户提供有价值的参考与启发。 ➨ 移动端Unity游戏功耗调优的破局实践 周旋 侑虎科技 引擎开发工程师 随着移动端Unity游戏在画质精度与玩法复杂度上的持续突破，功耗问题已成为制约用户体验的核心瓶颈 - 过度发热不仅导致设备降频、性能波动，更会直接影响玩家留存与口碑传播。然而，当前行业对功耗的优化仍停留在经验驱动阶段：CPU主线程压力、子线程负载、GPU算力消耗、带宽占用等关键因素与发热之间的定量关联缺乏直观呈现，开发者往往陷入“盲目排查 - 试错优化 - 效果模糊” 的低效循环。 正是基于这一痛点，本次分享旨在为开发者提供一套可量化的功耗调优方法论。过去一年，UWA团队深耕移动端功耗优化领域，依托Perfetto性能分析工具与ODPM（On-Device Power Metering）功耗数据采集方案，构建了从底层机制到项目落地的全链路分析体系。 通过本次分享，我们期望帮助开发者建立“精准定位 - 科学优化 - 效果可测”的功耗调优闭环，切实解决项目发热难题，助力打造低耗高效、持久稳定的移动端游戏体验。 ➨ 从App到多平台小游戏 - 高效迁移的全链路优化策略 周旋 侑虎科技 引擎开发工程师 随着小游戏平台的流量红利与用户触达优势日益凸显，将成熟Unity App游戏迁移至多平台小游戏已成为行业重要的拓展方向。然而，这种跨平台转化往往面临引擎适配壁垒、性能瓶颈突出、资源约束严苛等系列挑战，导致多数团队在迁移过程中效率低下、效果不达预期。 过去一年，UWA团队深度参与数十个Unity游戏向微信、抖音等主流小游戏平台的迁移项目，在实战中系统性沉淀了覆盖迁移全流程的优化方法论。本次分享将聚焦迁移关键内容，包括：同步加载转异步加载的方案；URP性能优化；内存优化和iOS平台的渲染异常表现修正等。 我们期望通过这套经过验证的全链路策略，助力开发者规避迁移陷阱，实现从App到多平台小游戏的高效转化与体验升级。 ➨ 基于自定义格式对Unity场景的深度性能优化 李俊 欢乐互娱科技有限公司 引擎组组长 欢乐互娱引擎组长，曾任青果灵动黑光工作室技术负责人，字节跳动客户端主程序，技术专家。曾参与《黑暗之光》、《魔法王座》、《放置少女》、《RO仙境》等多款MMORPG和卡牌手游的研发。 重度MMO游戏中，大世界的场景加载效率、内存、渲染效率都是游戏性能的瓶颈。从提升流式块的加载速度出发，提出了无解码的自定义场景格式，大幅提升了场景流式加载速度，但是因为场景已经没有组件了，Unity原先支持的机制不再支持。在重新造轮子过程中，我们深度优化了场景内存、渲染提交等方面的性能，同时解决了被引入的新问题。本次报告将重点介绍的内容包括：无解码的自定义场景格式实现；Lightmap流式实现；自定义格式下地形渲染；基于Batchrendergroup的渲染优化等。 ➨ “AI拼UI”的探索历程 罗培羽 广州四三九九 主程序 就职于4399技术研发中心，负责前沿探索和技术支撑工作。著有《Unity3D网络游戏实战》、《百万在线：大型游戏服务端开发》等书籍。 “拼UI”一直是游戏前端开发的一个痛点，工作量大且繁琐，尽管已有诸如PSD2UGUI、FairyGUI等方案，但却也未能完全解决问题，很多时候是把程序的工作量转移到美术而已。4399公司的游戏迭代速度很快，“拼UI”是一个突出痛点，因此开展了使用图像识别方法辅助拼UI的相关研究。本次报告将叙述这一研究历程，包括最初使用传统的图像识别方法、后续分析PSD结构的迭代算法，以及使用大语言模型来辅助识别UI结构的探索。尽管“AI自动拼UI”离落地尚有距离，但这一课题是行业共同面对的痛点，通过分享其中的探索历程，以作启发。 ➨ Vibe Playing & Agentic & 共创游戏：新游戏体验与商业模型 蔡俊鸿（卡卡）潜在领域 CEO、CTO 游戏全栈开发者，游戏热爱者，极客 人工智能不仅正在改变游戏的开发方式，更正在重塑玩家体验与商业逻辑。从“千人一面”的任务到因人而异的剧情，从静态付费点到动态行为预测与实时个性化触达，AI正在成为驱动游戏留存与变现的新引擎，未来的游戏，不再只是研发人员为玩家开发，而是玩家与AI共创。本次分享将围绕从Generative AI驱动的内容生成，到Agentic设计理念下的动态剧情与交互体验；从AI Agents对玩家行为的实时响应，到Vibe Playing带来的沉浸式参与与内容共创，探讨如何用AI提升玩家留存与付费。如果你是研发、策划、或者是对AI充满热情的探索者，希望能为你打开一个Agentic Game Design的全新视角。 ➨ 探索基于模拟现实的应用开发 - 以Godot引擎为基础的辅助驾驶可视化案例 张骋 莲花跑车 高级技术专家，UWA学堂作者 10年游戏行业和8年汽车行业辅助驾驶开发经验，现就职于莲花跑车智驾部门，负责辅助驾驶人机交互开发工作，将设计优秀的技术解决方案视为长期事业。 模拟现实应用开发已经在生活各处逐渐出现，如智能家具、辅助驾驶等。目前辅助驾驶系统的高级可视化已成为了中国新能源汽车的标配。高级可视化功能需要精美的视觉效果和自由交互的能力，同时还需满足较为复杂的开发环境要求。本次报告将通过我们的开发实践，分享基于Godot引擎实现高级可视化的经验，相信程序化内容和受限环境的开发经验对Unity引擎用户及其它机器人交互系统的开发者也有很高的参考性。 ➨ 性能优化新基建：GPM 2.0，如何从 “被动修复” 到“主动赋能”，重塑游戏竞争力底层逻辑 张鑫 侑虎科技 CEO 在传统游戏性能管理模式中，卡顿、发热、降频等影响体验的问题，往往需依赖玩家反馈或舆情发酵后才被察觉。研发团队不仅要消耗巨大人力成本复现问题场景，更易因响应滞后错失最佳修复窗口，最终导致用户流失与口碑损耗。 UWA GPM 2.0 的推出，标志着游戏性能管理进入“新基建”时代 - 通过构建“主动赋能”的全新范式，实现从被动响应到主动预判的关键跨越。 本次分享将聚焦GPM 2.0的核心能力：解析其如何基于玩家真实行为数据构建分析体系，实现性能风险的及时识别；详解运行时卡顿帧分析功能，如何精准定位卡顿帧逻辑、代码耗时点与资源加载瓶颈；阐述崩溃检测分析模块如何实时捕获崩溃前的场景状态、堆栈信息，为根因定位提供完整依据。 通过GPM 2.0，研发团队可在用户感知抱怨前锁定问题核心，将性能管理转化为前置化的体验优化能力，从而显著降低用户流失率，以技术基建的升级重塑游戏竞争力的底层逻辑。 ➨ 搞性能，不一定是和美术做对抗 - 美术向TA在性能优化中的思路分享 Wek（韦克） 技术美术专家，《矩阵零日危机》TA负责人 北京电影学院毕业，10年游戏工作经验，曾参与项目《一梦江湖》（原名楚留香）、《燕云十六声》、《矩阵零日危机》。 不少项目在性能优化的时候都会遇到推进有阻力的问题，甚至有的项目会出现临近上线加班优化性能，但是最后还是不得已砍掉一些效果的情况。《矩阵零日危机》从立项到上线一直没有遇到类似问题，可以实现较好的性能和美术效果的平衡。本次报告将分享美术向TA在性能优化中的思路和方法。 ➨ Unity引擎资源管理经验 - 如何使用YooAsset进行高效的资源管理 何冠峰 途游游戏 技术专家 YooAsset资源系统作者，长期专注于游戏开发领域，热爱技术研究和开源社区建设。技术特长包括游戏资源管理，游戏框架设计与性能优化。 在使用Unity引擎开发移动端游戏时，资源管理至关重要。合理的资源管理可以优化游戏的运行效率，减少加载时间，提高用户留存率。本次报告将深入讲解Unity资源管理，从资产管理到性能优化，全面讲解YooAsset的高效能应用。重点内容涵盖：资源管理与打包策略、自动化脚本配置、加载卡顿优化与调试工具使用，以及常见问题的解决方案。 ➨ 《仙遇》MMO手游工具链设计的思考与实践 李博皇 海南元游信息技术有限公司 客户端主程 拥有多年游戏研发与项目上线经验，专注技术创新与突破，持续推动协作效率与产品品质的提升。 制作一款大型MMO手游，工具链不仅是技术实现的手段，更是保障研发效率与产品品质的核心支撑。本次报告将围绕《仙遇》项目从立项、研发、上线到运营全过程，探讨所采用的工具链设计理念及其背后的原因，聚焦于为什么要有这些工具及其设计初衷（Why），以及它们如何提升团队协作效率和优化产品性能。通过回顾工具链的设计与迭代，分享实战经验，期待为同行提供参考。 ➨ 如何用物理驱动游戏角色 许晨光 （Shadow） Nubrain Game Studio 工程师 独立游戏《无极道人》开发者。 在各种游戏和3A大作中，为玩家提供更丰富可信的角色动画一直是业界追求的方向之一。基于物理的角色运动系统是一种利用物理引擎实时模拟角色运动和动画的游戏开发技术。与传统的预设动画驱动的角色控制不同，它通过力学计算各种运动中的力和碰撞反馈等效果来提供更丰富的视觉表现，以及实时与周围环境物体交互的能力。本次报告将介绍如何实现一个基础的由物理驱动的角色运动系统，以及对于传统物理引擎的算法在驱动物理角色时遇到的一些问题的改进思路和尝试。 ➨ Unity移动游戏工业级性能优化指南 3.0 余含章 侑虎科技 技术支持工程师 在过去一年中，UWA团队持续深耕于各项目性能优化一线，聚焦游戏研发过程中的各类疑难问题，不断沉淀实战经验并致力于向行业开发者分享。本次分享的《Unity移动游戏工业级性能优化指南 3.0》，在内容上实现了多维度的升级与深化： 其一，针对数年前完成的内存与加载测试进行重置与细化，在Unity 2022版本普及率持续提升的当下，重新验证既有结论的适配性与可靠性； 其二，系统梳理了新版Unity的高频问题，涵盖新接口评测、内存“黑洞” 定位等核心痛点，提供针对性分析； 其三，专项剖析iOS包相较于Android包更易出现的内存紧张、闪退等差异化问题，并配套输出经过实践验证的优化策略与落地经验。 我们期望通过本次分享，助力开发者更精准地解读数据背后的逻辑，共同探索性能优化的深层价值。 ➨ Tripo 3D大模型如何赋能全球游戏行业 宋亚宸 VAST 创始人、CEO VAST创始⼈兼⾸席执⾏官，本科毕业于约翰霍普⾦斯⼤学，曾在商汤科技落地了多个从零到⼀的⼈⼯智能项⽬，实现AIGC技术在动画及游戏行业的商业化落地，也曾参与创⽴通用大模型独⻆兽MiniMax。 为了适应全球用户对于3D用户体验升级、内容表达创新和新质生产力提升的持续追求，Tripo Studio重新定义了AI驱动的3D内容创作流程。本次报告将围绕VAST正式发布全球首个AI驱动的一站式3D工作台Tripo Studio展开探讨，例如：如何让每个创意都能从模糊构想走向完整成果，让3D创作不再止步于模型生成，而是获得完整、可用、有价值的可交互3D内容。 我们期待能为相关领域从业者提供“基础模型 + 生态插件 + 原生工作台”的端到端产品体系，从而构建覆盖从专业级（PGC 生产者）、达人级（PUGC 创作者）到大众级（UGC 用户）的创作者，进而推动全球3D内容创作领域新发展。 ➨ 3D MMORPG手游征服2GB设备之历程 张益宁 欢乐互娱科技有限公司 技术专家 《仙境传说：爱如初见》性能优化负责人，擅长技术架构，性能优化。喜欢为玩家带来极致游戏体验的同时，探索技术的极限。曾就职于腾讯，字节，吉比特，趣加。 3DMMO游戏在全球受众广泛，但因其复杂的业务需求，在2GB/3GB手机上内存面临极大的挑战，而这一部分用户，对于发行买量，游戏生态的构建，又是不可忽略的群体，基本每一个项目都会花费大量的精力在内存优化上。本次报告主要介绍一种系统的解决3DMMO游戏内存问题的思路，让3DMMO游戏畅通无阻的覆盖全球手机设备。 ➨ 《心动小镇》内存优化经验谈 刘贞明 心动网络 小镇技术组负责人 2009年进入游戏行业，先后供职于2K Games、CCP Games、Red Accent以及心动网络，有Console与Mobile平台的开发经验，并参与了心动多款自研项目的性能优化工作。 本次报告主要介绍《心动小镇》项目在内存优化过程中遇到的挑战和经验。具体包括： 项目的设计特点和在内存方面的挑战，例如：大世界、无缝集成家园、家园承载上限、潮流季等动态活动内容。 项目内存问题的分析与定位的流程、工具、方法和经验。 项目线上数据收集方法：自研工具及线上数据分析的方法及帮助。 我们为《心动小镇》实施的内存方面的优化方案，例如：针对资产系统（AssetBundle）的分析与方案、ECS化（去GameObject化：地形、草、树、岩石、房屋、HLOD、BRG等、Streaming系统、物理方案、Significance Manager、Jobs Animator、Shader的优化、配置数据库化、资产方面的优化（Mesh精度压缩、骨骼层级优化、贴图通道合并等），以及引擎相关的优化方案。 ➨ 动态大规模场景破碎解决方案，及有重量感的角色物理场景交互 唐翎（flashyiyi） 幽灵猫科技 独立游戏开发者 6年游戏行业经验，5年Unreal图形引擎/技术美术，知乎作者，知乎5W关注。曾在《影之刃零》和网易《归唐》项目任职；目前在制作个人独立游戏《猫咪漂移》。 本次报告将会介绍一种少量修改引擎代码，就可以让Unreal的Chaos物理引擎支持大规模的破碎且确保卡顿现象不出现的方案，以及一种可以避免角色和模拟物体碰撞偶然产生的“超高速弹飞”，“挤压抽搐”现象的方法。这两个方案在技术层面上可以基本解决开发者在游戏中加入实时物理模拟的顾虑。 十周年特别福利｜致敬每一位支持者 为感谢广大开发者一路以来的信任与陪伴，UWA在十周年之际特别推出【UWA用户专属免费票】！数量有限，先到先得。点击即可报名！ 活动信息 主办方：侑虎科技 时 间：2025年8月1日~8月2日（周五、周六） 地 点：上海市静安区广中西路333号 上海宝华万豪酒店 扫描上方图片二维码报名 感谢每一位提前锁票的你，是你们的热情与期待，让这个八月注定不凡。 让我们在代码的碰撞与灵感的激荡中，一起书写游戏产业的下一个黄金十年！ 了解更多大会详情，敬请关注官方平台后续发布。UWA DAY 2025，期待与你现场相见！ 往届精彩盘点 UWA DAY 2024｜创新潜藏无限可能 UWA DAY 2022｜躬身入局 践行游戏研发工业化 UWA DAY 2021｜助力游戏研发迈入工业化时代 UWA DAY 2020｜品往鉴来 质存高远 UWA DAY 2019｜以数之名，赋能游戏精品化 UWA DAY 2018｜快、精准、有温度 UWA DAY 2017｜没有横空出世，只有厚积薄发 关于UWA UWA是一家创业十年的高新技术企业，作为游戏行业的深耕者，UWA始终专注于为使用Unity、Unreal引擎的开发者提供丰富的优化产品，帮助开发者高效解决开发问题、定位性能瓶颈、提供解决方案，已支持超过一万款游戏项目。还打造了技术博客、问答、开源库、学堂等社区产品，为开发者提供便利和高效的支持。线上培训和线下教育的新业务， 满足行业对人才培育的需求。
一．优化最终效果及消耗 8人同屏，最高画质，耗时1.1ms（高通 骁龙710）。因为项目未上线原因，只能用测试图进行相关说明。 二、原反射脚本源码 1. 源码 using UnityEngine; using System.Collections; [ExecuteInEditMode] public class MirrorReflection : MonoBehaviour { public Material m_matCopyDepth; public bool m_DisablePixelLights = true; public int m_TextureSize = 256; public float m_ClipPlaneOffset = 0.07f; public LayerMask m_ReflectLayers = -1; private Hashtable m_ReflectionCameras = new Hashtable(); // Camera -> Camera table public RenderTexture m_ReflectionTexture = null; public RenderTexture m_ReflectionDepthTexture = null; private int m_OldReflectionTextureSize = 0; private static bool s_InsideRendering = false; public void OnWillRenderObject() { if (!enabled || !GetComponent<Renderer>() || !GetComponent<Renderer>().sharedMaterial || !GetComponent<Renderer>().enabled) return; Camera cam = Camera.current; if (!cam) return; // Safeguard from recursive reflections. if (s_InsideRendering) return; s_InsideRendering = true; Camera reflectionCamera; CreateMirrorObjects(cam, out reflectionCamera); // find out the reflection plane: position and normal in world space Vector3 pos = transform.position; Vector3 normal = transform.up; // Optionally disable pixel lights for reflection int oldPixelLightCount = QualitySettings.pixelLightCount; if (m_DisablePixelLights) QualitySettings.pixelLightCount = 0; UpdateCameraModes(cam, reflectionCamera); // Render reflection // Reflect camera around reflection plane float d = -Vector3.Dot(normal, pos) - m_ClipPlaneOffset; Vector4 reflectionPlane = new Vector4(normal.x, normal.y, normal.z, d); Matrix4x4 reflection = Matrix4x4.zero; CalculateReflectionMatrix(ref reflection, reflectionPlane); Vector3 oldpos = cam.transform.position; Vector3 newpos = reflection.MultiplyPoint(oldpos); reflectionCamera.worldToCameraMatrix = cam.worldToCameraMatrix * reflection; // Setup oblique projection matrix so that near plane is our reflection // plane. This way we clip everything below/above it for free. Vector4 clipPlane = CameraSpacePlane(reflectionCamera, pos, normal, 1.0f); Matrix4x4 projection = cam.projectionMatrix; CalculateObliqueMatrix(ref projection, clipPlane); reflectionCamera.projectionMatrix = projection; reflectionCamera.cullingMask = ~(1 << 4) & m_ReflectLayers.value; // never render water layer reflectionCamera.targetTexture = m_ReflectionTexture; //GL.SetRevertBackfacing(true); GL.invertCulling = true; reflectionCamera.transform.position = newpos; Vector3 euler = cam.transform.eulerAngles; reflectionCamera.transform.eulerAngles = new Vector3(0, euler.y, euler.z); reflectionCamera.depthTextureMode = DepthTextureMode.Depth; reflectionCamera.Render(); // copy depth Graphics.SetRenderTarget(m_ReflectionDepthTexture); m_matCopyDepth.SetPass(0); DrawFullscreenQuad(); Graphics.SetRenderTarget(null); // Graphics.Blit(m_ReflectionTexture, m_ReflectionDepthTexture, m_matCopyDepth); reflectionCamera.transform.position = oldpos; //GL.SetRevertBackfacing(false); GL.invertCulling = false; Material[] materials = GetComponent<Renderer>().sharedMaterials; foreach (Material mat in materials) { mat.SetTexture("_ReflectionTex", m_ReflectionTexture); mat.SetTexture("_ReflectionDepthTex", m_ReflectionDepthTexture); } // // Set matrix on the shader that transforms UVs from object space into screen // // space. We want to just project reflection texture on screen. // Matrix4x4 scaleOffset = Matrix4x4.TRS( // new Vector3(0.5f, 0.5f, 0.5f), Quaternion.identity, new Vector3(0.5f, 0.5f, 0.5f)); // Vector3 scale = transform.lossyScale; // Matrix4x4 mtx = transform.localToWorldMatrix * Matrix4x4.Scale(new Vector3(1.0f / scale.x, 1.0f / scale.y, 1.0f / scale.z)); // mtx = scaleOffset * cam.projectionMatrix * cam.worldToCameraMatrix * mtx; // foreach (Material mat in materials) // { // mat.SetMatrix("_ProjMatrix", mtx); // } // Restore pixel light count if (m_DisablePixelLights) QualitySettings.pixelLightCount = oldPixelLightCount; s_InsideRendering = false; } // Cleanup all the objects we possibly have created void OnDisable() { if (m_ReflectionTexture) { DestroyImmediate(m_ReflectionTexture); m_ReflectionTexture = null; } if (m_ReflectionDepthTexture) { DestroyImmediate(m_ReflectionDepthTexture); m_ReflectionDepthTexture = null; } foreach (DictionaryEntry kvp in m_ReflectionCameras) DestroyImmediate(((Camera)kvp.Value).gameObject); m_ReflectionCameras.Clear(); } private void UpdateCameraModes(Camera src, Camera dest) { if (dest == null) return; // set camera to clear the same way as current camera dest.clearFlags = src.clearFlags; dest.backgroundColor = src.backgroundColor; if (src.clearFlags == CameraClearFlags.Skybox) { Skybox sky = src.GetComponent(typeof(Skybox)) as Skybox; Skybox mysky = dest.GetComponent(typeof(Skybox)) as Skybox; if (!sky || !sky.material) { mysky.enabled = false; } else { mysky.enabled = true; mysky.material = sky.material; } } // update other values to match current camera. // even if we are supplying custom camera&projection matrices, // some of values are used elsewhere (e.g. skybox uses far plane) dest.farClipPlane = src.farClipPlane; dest.nearClipPlane = src.nearClipPlane; dest.orthographic = src.orthographic; dest.fieldOfView = src.fieldOfView; dest.aspect = src.aspect; dest.orthographicSize = src.orthographicSize; } // On-demand create any objects we need private void CreateMirrorObjects(Camera currentCamera, out Camera reflectionCamera) { reflectionCamera = null; // Reflection render texture if (!m_ReflectionTexture || m_OldReflectionTextureSize != m_TextureSize) { if (m_ReflectionTexture) DestroyImmediate(m_ReflectionTexture); m_ReflectionTexture = new RenderTexture(m_TextureSize, m_TextureSize, 16); m_ReflectionTexture.name = "__MirrorReflection" + GetInstanceID(); m_ReflectionTexture.isPowerOfTwo = true; m_ReflectionTexture.hideFlags = HideFlags.DontSave; m_ReflectionTexture.filterMode = FilterMode.Bilinear; if (m_ReflectionDepthTexture) DestroyImmediate(m_ReflectionDepthTexture); m_ReflectionDepthTexture = new RenderTexture(m_TextureSize, m_TextureSize, 0, RenderTextureFormat.RHalf); // m_ReflectionDepthTexture = new RenderTexture(m_TextureSize, m_TextureSize, 0, RenderTextureFormat.R8); m_ReflectionDepthTexture.name = "__MirrorReflectionDepth" + GetInstanceID(); m_ReflectionDepthTexture.isPowerOfTwo = true; m_ReflectionDepthTexture.hideFlags = HideFlags.DontSave; m_ReflectionDepthTexture.filterMode = FilterMode.Bilinear; m_OldReflectionTextureSize = m_TextureSize; } // Camera for reflection reflectionCamera = m_ReflectionCameras[currentCamera] as Camera; if (!reflectionCamera) // catch both not-in-dictionary and in-dictionary-but-deleted-GO { GameObject go = new GameObject("Mirror Refl Camera id" + GetInstanceID() + " for " + currentCamera.GetInstanceID(), typeof(Camera), typeof(Skybox)); reflectionCamera = go.GetComponent<Camera>(); reflectionCamera.enabled = false; reflectionCamera.transform.position = transform.position; reflectionCamera.transform.rotation = transform.rotation; reflectionCamera.gameObject.AddComponent<FlareLayer>(); go.hideFlags = HideFlags.HideAndDontSave; m_ReflectionCameras[currentCamera] = reflectionCamera; } } // Extended sign: returns -1, 0 or 1 based on sign of a private static float sgn(float a) { if (a > 0.0f) return 1.0f; if (a < 0.0f) return -1.0f; return 0.0f; } // Given position/normal of the plane, calculates plane in camera space. private Vector4 CameraSpacePlane(Camera cam, Vector3 pos, Vector3 normal, float sideSign) { Vector3 offsetPos = pos + normal * m_ClipPlaneOffset; Matrix4x4 m = cam.worldToCameraMatrix; Vector3 cpos = m.MultiplyPoint(offsetPos); Vector3 cnormal = m.MultiplyVector(normal).normalized * sideSign; return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal)); } // Adjusts the given projection matrix so that near plane is the given clipPlane // clipPlane is given in camera space. See article in Game Programming Gems 5 and // http://aras-p.info/texts/obliqueortho.html private static void CalculateObliqueMatrix(ref Matrix4x4 projection, Vector4 clipPlane) { Vector4 q = projection.inverse * new Vector4( sgn(clipPlane.x), sgn(clipPlane.y), 1.0f, 1.0f ); Vector4 c = clipPlane * (2.0F / (Vector4.Dot(clipPlane, q))); // third row = clip plane - fourth row projection[2] = c.x - projection[3]; projection[6] = c.y - projection[7]; projection[10] = c.z - projection[11]; projection[14] = c.w - projection[15]; } // Calculates reflection matrix around the given plane private static void CalculateReflectionMatrix(ref Matrix4x4 reflectionMat, Vector4 plane) { reflectionMat.m00 = (1F - 2F * plane[0] * plane[0]); reflectionMat.m01 = (-2F * plane[0] * plane[1]); reflectionMat.m02 = (-2F * plane[0] * plane[2]); reflectionMat.m03 = (-2F * plane[3] * plane[0]); reflectionMat.m10 = (-2F * plane[1] * plane[0]); reflectionMat.m11 = (1F - 2F * plane[1] * plane[1]); reflectionMat.m12 = (-2F * plane[1] * plane[2]); reflectionMat.m13 = (-2F * plane[3] * plane[1]); reflectionMat.m20 = (-2F * plane[2] * plane[0]); reflectionMat.m21 = (-2F * plane[2] * plane[1]); reflectionMat.m22 = (1F - 2F * plane[2] * plane[2]); reflectionMat.m23 = (-2F * plane[3] * plane[2]); reflectionMat.m30 = 0F; reflectionMat.m31 = 0F; reflectionMat.m32 = 0F; reflectionMat.m33 = 1F; } static public void DrawFullscreenQuad(float z = 1.0f) { GL.Begin(GL.QUADS); GL.Vertex3(-1.0f, -1.0f, z); GL.Vertex3(1.0f, -1.0f, z); GL.Vertex3(1.0f, 1.0f, z); GL.Vertex3(-1.0f, 1.0f, z); GL.Vertex3(-1.0f, 1.0f, z); GL.Vertex3(1.0f, 1.0f, z); GL.Vertex3(1.0f, -1.0f, z); GL.Vertex3(-1.0f, -1.0f, z); GL.End(); } } 相关链接： https://github.com/unity3d-jp/unitychan-crs/blob/master/Assets/UnityChanStage/Visualizer/MirrorReflection.cs 2. 该脚本主要功能 在场景中获取当前生效的相机，根据反射平面进行镜像，并对设定的渲染层进行逐帧拍照，并将其传给反射平面进行显示。配合自带的半透明反射材质效果，叠加到底层材质上使用。 3. 主要问题 优化之前，该反射效果大概耗时10ms以上。主要问题如下： 反射效果均为实时反射，使用Layer控制。要看到反射效果，只能将该层物体纳入反射层。 反射表面为独立于真实反射表面的半透明材质，既耗性能效果也无法保证。 项目角色的面数极高（单角色万面左右），极限同屏9个角色外加宠物。除了本身的角色渲染外，同时还存在描边、压片阴影等效果。如果反射直接采用相机拍照的方式生成，额外的性能消耗极大。 项目角色本身渲染支持5盏像素光，带法线贴图，卡通光照图等各种复杂渲染效果，如果反射直接采用相机拍照的方式生成，额外的性能消耗极大。 该脚本本身也存在一些问题： 1）会根据场景中正在生效的相机，逐个生成反射相机。 2）逐帧获取反射材质，进行参数传递。 3）反射层的设置需要美术每次使用时都设置一次，容易设置过多或漏设置。 三、优化方向 针对以上问题，实时反射主要有两大优化方向，美术效果优化及性能优化。 一、美术效果优化 1.将反射效果与反射表面物理属性关联。 引入反射表面法线贴图的支持，对反射效果增加真实的法线扭曲效果。 方法：弃置默认的半透明反射表面。将反射图直接指定给反射表面的渲染材质，增加法线效果，使用法线贴图的xy通道对反射图的UV进行偏移（这里还增加了法线扭曲强度及遮罩等的调节）。 2.引入反射表面粗糙度设置。 可以调节反射效果的清晰度（模糊策略，后面性能优化详述）。 方法：不考虑性能，这里可以向脚本中传参，使用高斯模糊来进行粗糙表面的反射模糊效果。 3.引入反射深度，实现反射跟随距离从有到无的柔和过渡。 方法：在渲染反射图时，传入衰减系数，根据顶点到地面高度的距离进行衰减，衰减强度写入反射图的A通道。 二、性能优化 概述： 性能优化方面主要集中在如下几个方面： 减少数据量，降低CPU负担。 降低DrawCall，增加C/GPU传输率。 降低渲染复杂度，加速GPU渲染。 优化脚本，减少GC及重复操作。 基于以上考虑，性能优化方向归为两类：美术端优化和程序端优化。 美术端优化 1.动静分离 将反射效果拆分为静态物体的与烘焙反射和动态物体的实时反射两部分组成。这样可以大大降低实时运算量。 2.预烘焙部分 根据场景需要的反射清晰度，预先烘焙尺寸尽可能小的反射图。对于非常粗糙且并不清晰的反射场景，可以共用一张场景反射图。 或者昼夜、雨晴等同场景不同状态下的反射图共用。通过调节材质参数（例如反射颜色、粗糙度及反射强度）来实现不同状态效果。 通过BoxProjection方法，修正与烘焙CubeMap的场景对齐问题。 3.实时部分 同静态与烘焙相似，可以根据场景的反射清晰度，实时生成尽可能小的反射图来使用。 （1）程序开放尺寸调节： （2）代码写入支持尺寸配置： 降低实时反射的DrawCall，例如：尽可能地合并角色部件，忽略较小部件不进行反射等。 降低实时反射的数据量和计算量，例如：使用低面数的Lod来渲染反射，降低反射效果要求减少灯光数量，效果数量等。 反射表面优化策略 同一个场景，只能存在一个反射表面。 将反射表面的材质作为高配材质由美术特别对待。 将尽可能多的参数暴露给美术，尽可能地适配高性能参数。 程序端优化 同样从概述中的几个方面进行考虑来进行优化。 1.去除不必要的渲染批次及效果 反射效果中因为清晰度有限，描边效果可以剔除，阴影效果也可以不进行渲染。 2.降低反射渲染的计算复杂度 反射效果中，只需要表现出基本的明暗关系即可，故只保留1盏平行光效果。光照计算也从像素光照更改为顶点光照。 一个角色渲染需要基本色纹理，法线纹理，粗糙金属性纹理，身体材质区分纹理，光照纹理和buff效果纹理。在反射中，精简效果到只需要基本色纹理和buff效果纹理即可。 要实现以上优化方案，就需要在进行反射渲染时，将动态物体的材质进行替换渲染。可用策略如下： （1）更换材质 需要每帧用反射材质进行替换渲染。如果是后处理描边、风格化单色等效果，这类方法比较适合。但角色材质多样，参数各不相同，用这种方法需要自己维护动态物件的反射列表，比较繁琐。 （2）Lod Int控制 就是使用SubShader的Lod参数进行控制。这样，把各个材质的反射Shader集中到一个Lod下进行计算即可。最简单的一种方式，但后来经过测试，实时切换Lod的消耗过大，不太实际。 （3）宏开关 比较简单，集成性、通用性好，且切换消耗小。最初我们使用的优化方案。但因为存在角色的多材质及多Pass渲染，后期对优化效率仍然不足够（无法优化掉描边和阴影）。 核心代码： （4）使用CommandBuff 可控性和自由度最高。但问题同（1），需要自己维护角色列表，对于频繁发生角色更换的场合，维护成本会较高，但仍可实现。可以由逻辑层在发生角色更换时，更新角色列表到反射程序中进行处理即可。 部分核心代码： （5）使用CameraReplaceShader 我们项目最终优化采用的方法。优点是可以实现替换渲染，且不需要自己维护角色列表。缺点就是重写反射渲染的Shader。 核心脚本代码： 这里简单解释一下SetReplacementShader命令。 他是将该相机中进行渲染的所有Shader，根据后面设定的replacementTag进行替换。 例如上面代码中，我们使用了“MirrorReflect”的Tag，所以，在Shader中，凡是含有“MirrorReflect”Tag的SubShader都会被替换为reflectShader中，相同“MirrorReflect”Tag的SubShader。 如上举例，即可在相机进行反射渲染时，将含有相同“MirrorReflect”Tag的SubShader 进行对应替换渲染了。 程序策略性优化 1.合并静态与实时反射 只需要根据反射图的A通道中，存储的反射衰减值进行动静反射混合即可。 2.开放美术调节参数 如前所述的一些性能相关设置，可以开放给美术进行择优。 3.反射清晰度 引入粗糙度的概念，使用模糊来遮盖反射图分辨率不足的问题。可以进一步降低反射分辨率。 这里的模糊策略采用了MipMap的方式来避免高斯模糊带来的消耗。 程序逻辑层性能优化 1.减反射相机数量 将反射相机个数从逐相机生成的多反射相机优化为单反射相机，逐帧对位的方式。 2.减GC 将申请RT，生成相机，获取反射材质，传递材质参数等可一次性进行的操作，移出OnWillRenderObject()。 3.优化刷新率 增加刷新率设置，将逐帧渲染改为可配置刷新率渲染。目前默认为1/2刷新率。 4.反射层程序端写死 避免美术出错及加速配置。 5.开关策略 当反射平面不可见时，关闭反射功能，将所有资源进行释放（逻辑层控制）。 6.平台匹配 适配不同性能的平台。目前我们仅高配平台开启实时反射效果。中配置平台仅开启静态反射效果。 最后：优化总结 这是侑虎科技第1072篇文章，感谢作者罗汉铭供稿。罗汉铭：朝夕光年无双工作室 技术美术专家，目前就职于字节跳动101技术中心，08年加入上海昱泉国际，从事TA相关工作，参与了《流星蝴蝶剑》、《射雕三部曲》项目的制作。之后加入完美世界引擎组，从事ARK1.0-ARK2.0的相关开发工作，并支持《最终兵器》、《射雕英雄传OL》项目的开发；后转向Unity，参与了《射雕英雄传3D》手游的开发工作。 作者主页：https://www.zhihu.com/people/lucifer-3-91 作者在 UWA DAY 2021 大会中的《航母战斗群中的巡洋舰 - 为项目保驾护航的技术美术们》议题演讲已经上线UWA学堂，欢迎前往观看。 再次感谢罗汉铭的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
在上一期《特效优化：发现绚丽背后的质朴》中，我们主要针对UWA本地资源检测中和特效相关的知识点做了讲解。这些看似细小的知识点，很容易在大家的开发和学习过程中被疏忽，而长期的问题积累最终都会反映到项目的性能表现上。为此，我们将这些规则列出，并且以一个个知识点的形式向大家逐一解读。 本期，我们将主要面向粒子特效相关的检测规则：“特效运行时包含的ParticleSystem组件数量过多”、“特效播放时最大粒子数量过大”，“特效总贴图数量过大”，以及新增加的规则“开启各向异性过滤的纹理”。我们将力图以浅显易懂的表达，让职场萌新或优化萌新能够深入理解。 1、特效运行时包含的ParticleSystem组件数量过多 本条规则面向的核心其实是项目开发中最常见的一对矛盾：效果与性能。为了实现更为复杂、更为绚丽的展示效果，研发团队会添加各种粒子系统。在带来效果提升的同时，也会产生非常明显的性能消耗和渲染压力。所以我们需要科学地做减法，减少ParticleSystem组件数量，以降低部分特效效果的代价来达到和项目性能之间的平衡。 这里需要说明的是，规则给出的默认阈值是一个比较严苛的标准，研发团队可以根据自己项目的实际需求去设定符合现阶段性能承受范围的阈值。 2、播放特效时最大粒子数量过多 本条规则和我们之前在文章《【性能黑榜】掌握了这些规则，你已经战胜了80%的对手！》中提到的“粒子数上限超过30的粒子系统”的这条规则很相近。 合理的粒子数会使得特效的表现效果恰到好处，但过多的粒子数会给CPU和GPU的计算带来更大压力。在复杂的粒子特效下，由于大量各种粒子的存在，处理特效所带来的性能开销也会大幅度地增加。 天平的两端是特效总体效果和性能，所以我们的视角要提高到总粒子数上。在实际操作中，开发团队要尽可能精确地基于特效总体展示效果，对每一个粒子系统的最大粒子数都进行合理的规划与调整，这样才有可能在保证降低粒子特效更新开销的同时，依然能够接近或维持原有的特效效果。 3、特效总贴图数量过大 本条规则从另一个维度为开发团队提供特效相关的检测规范。贴图涉及的方面很多，在之前的文章《材质优化：如何正确处理纹理和材质的关系》和《粒子系统优化：Mesh模式下的优化策略》内我们曾简单介绍过，Shader对纹理的采样、材质对纹理的引用以及粒子系统对纹理的应用等，如果在使用时不细致，就会导致特效内的贴图资源上涨，从而带来内存和计算上的额外消耗。 所以本条规则从“贴图总数”的角度，为大家统计出这些包含贴图数过多的特效资源，以供开发团队对其进行进一步的优化，来为特效做瘦身。 4、开启各向异性过滤的纹理 在之前的文章《纹理优化：不仅仅是一张图片那么简单》中，我们对纹理的过滤模式有过介绍，简单来讲是为了降低纹理到模型表面的这个过程中发生的“失真”现象。 相比于之前提到的各种过滤模式，纹理“贴”到物体表面时，各向异性过滤多考虑了纹理贴合与视野观察角度的关系。在这个前提下，如果在U、V方向上，纹理像素对应到物体表面的关系不是1:1时，它会按比例在各方向上进行采样不同数量的点，并进行计算以得到最终的对应关系。 在纹理的过滤模式中选择Bilinear（双线性过滤）或者Trilinear（三线性过滤）时，我们就可以对Aniso Level进行调整，范围是从0到16。 当我们把摄像机调成与纹理表面成一定角度时，Aniso Level的作用就会显示出来。如下图是nisoLevel设置为0或1时的纹理的表现（即不进行各向异性过滤）： 下图是AnisoLevel设置为9时（这时候进行了各向异性过滤）的贴图情况。对比两张图就能很明显地发现视野远端的清晰程度是完全不一样的。 开启各向异性过滤，在这种要考虑视野角度的情况下，可以很显著地提升纹理的表现效果，但代价是更高的GPU性能开销。而且各向异性过滤主要适用于大面积的地面与地形设置，对场景内的物体来讲，开启相关纹理的各向异性过滤就有“杀鸡用牛刀”的浪费之嫌了，表现效果上的提升并不足以抵消或平衡由此带来的性能消耗和渲染压力。 所以本条规则筛选出这些纹理后，开发团队要根据实际情况，在充分考量使用场景、表现效果和性能消耗的多方平衡后，再去决定是否需要保留开启各向异性过滤。 希望以上这些知识点能在实际的开发过程中为大家带来帮助。需要说明的是，每一项检测规则的阈值都可以由开发团队依据自身项目的实际需求去设置合适的阈值范围，这也是本地资源检测的一大特点。同时，也欢迎大家来使用UWA推出的本地资源检测服务，可帮助大家尽早对项目建立科学的美术规范。 万行代码屹立不倒，全靠基础掌握得好！ 相关推荐 《UWA学堂训练营｜游戏自动化测试》 性能黑榜相关阅读 《那些年给性能埋过的坑，你跳了吗？》 《那些年给性能埋过的坑，你跳了吗？（第二弹）》 《掌握了这些规则，你已经战胜了80%的对手！》
在上一期《动画优化：关于AnimationClip的三两事》中，我们针对动画优化中的“AnimationClip的压缩方式”以及“AnimatorController挂载的AnimationState”做了解释。这些看似细小的知识点，很容易在大家的开发和学习过程中被疏忽，而长期的问题积累最终都会反映到项目的性能表现上。为此，我们将这些规则列出，并且以一个个知识点的形式向大家逐一解读。 本期，我们将面向粒子系统，讲解UWA本地资源检测中的规则：“网格发射数超过5的粒子系统”、“检测粒子系统使用的网格Read/Write选项”、“引用网格面片数超过500的粒子系统”和“引用纹理数超过5的粒子系统”。我们将力图以浅显易懂的表达，让职场萌新或优化萌新深入理解。 1、网格发射数超过5的粒子系统 关于粒子系统的定义，我们在此不再赘述。我们重点来看一下粒子系统的渲染模式。 粒子的渲染模式可分为两大类：2D的Billboard（公告牌）图形模式和Mesh模式。Billboard是3D游戏中用的非常多的一种技术，使用该模式，粒子会以一个平面的形式存在，并始终以一定的角度对着我们的镜头。举个例子来讲，场景中有一棵树，是实体存在的；但在各种Billboard模式下，你只需要看到这棵树的一个“面”。在展示效果上表现其实一样，但渲染一个“面”和渲染一整个实体，前者在消耗的内存和运算量上会更有优势。 Billboard模式，粒子会始终面向摄像机； Stretched Billboard模式下，粒子在面向摄像机的同时，会在速度的方向上进行拉伸，可以设置粒子的各种缩放和拉伸效果； Horizontal Billboard模式下，粒子平面会和XZ面平行； Vertical Billboard模式下，粒子平面在面向摄像机的同时，会在Y轴方向上直立（垂直于XZ平面）； 而“Mesh”模式就是我们今天的主题。这个模式允许粒子系统发射3D网格而非2D的Billboard，以此来实现更为复杂和贴合需求的粒子效果。 3D网格性能开销显然是高于2D的Billboard的。如果粒子系统的发射的网格数量过大，会带来较高的CPU计算开销、GPU渲染压力甚至较高的堆内存分配。所以我们对使用Mesh进行渲染的粒子系统的“最大粒子数”要求更为严苛。一般的粒子系统中，粒子发射数上限建议不超过30（我们在之前的文章中有进行讨论，详见《【性能黑榜】掌握了这些规则，你已经战胜了80%的对手！》）。经由UWA的测试结果表明：渲染模式为Mesh、网格发射数在不超过5的情况下，能够维持粒子效果和性能消耗上的一个相对平衡。 所以开发团队在本条规则执行后，需要对筛选出来的粒子系统进行显示效果和性能消耗上的平衡考量，并对相应的粒子系统进行修改。 2、所使用的网格未开启Read/Write选项的粒子系统 由于粒子系统的渲染对象从传统的粒子变成了3D网格，所以引用的网格对象本身的属性也会对整个粒子系统产生影响。在这种情况下，很多针对网格的优化建议也同样适用于Mesh模式下的粒子系统，只不过针对特定应用环境，部分优化建议需要“反着看”。 在之前的文章《网格优化：溃堤之穴，一个也不能放过》中，我们对于网格的Read/Write选项进行过一定的说明。仅仅针对网格而言，我们是不建议开启相关的读写权限。 但是在粒子系统应用中，往往需要去实现各种各样的动态效果。所以当渲染模式为Mesh时，网格就需要开启Read/Write选项以实现对3D网格的动态修改，来更好地实现相应的渲染展示效果。 Unity官方文档告诉我们，网格必须设置为Read/Write Enabled才能在粒子系统上工作。若不开启，在某些设备上运行时，可能会出现模型丢失的情况。可见问答：https://answer.uwa4d.com/question/5cf732fed27511377098284e。因此我们将该类异常的资源检测出来，希望研发团队进行规范，防止出现意外的显示结果。 3、引用网格面片数超过500的粒子系统 和上一条规则不同，这条便是网格优化在粒子系统中的正向应用。在以前文章《那些年给性能埋过的坑，你跳了吗？》中，我们对于网格的面片数有过相关的简单讲解。对3D网格而言，过多的面片数会带来内存，加载和渲染上的额外消耗。在粒子系统中也是同样如此。 在Mesh模式下，发射的3D网格对面片数的要求也是同样严苛的。所以开发团队需要在本条规则下，去检查相关粒子系统引用的网格是否存在面片数过多的情况，并视实际的表现效果和性能消耗去决定是否需要做出相关的修改。 4、引用纹理数超过5的粒子系统 在之前的文章《材质优化：如何正确处理纹理和材质的关系》中，我们简单讲了下材质的Shader中引用了过多纹理会在内存和计算上带来的影响。 同样的，在粒子系统中，如果在材质内包含了过多的引用纹理，也同样会导致存储和运算上的额外消耗，从而对整个的粒子系统带来不必要的负担。 所以开发团队在为纹理系统引入材质时，需要关注相关材质内引用的纹理数量。在满足使用的前提下，从材质做瘦身，从而为整个粒子系统调优。 以上，大家其实可以发现，看似各个独立的部分：网格、粒子系统、材质和纹理等，其实在优化上都有着互相的关联。所以面对项目优化，开发团队要以一个整体的高度去看待项目的各个部分。 希望以上这些知识点能在实际的开发过程中为大家带来帮助。需要说明的是，每一项检测规则的阈值都可以由开发团队依据自身项目的实际需求去设置合适的阈值范围，这也是本地资源检测的一大特点。同时，也欢迎大家来使用UWA推出的本地资源检测服务，可帮助大家尽早对项目建立科学的美术规范。 我们已支持Luacheck功能啦~ 我们已支持粒子特效检测~ 下周会有重磅新功能推出喔 这么多实用贴心的功能 还不走你~ 万行代码屹立不倒，全靠基础掌握得好！ 性能黑榜相关阅读 《那些年给性能埋过的坑，你跳了吗？》 《那些年给性能埋过的坑，你跳了吗？（第二弹）》 《掌握了这些规则，你已经战胜了80%的对手！》
3分钟就能掌握的视频/音频优化技巧！
节前，我们已经将【性能黑榜】上的Top10规则均做了详细的解读（可戳文末相关链接回顾）。无论是大家在开发时的疏忽，还是相关知识点的缺失，这些问题的积累最终都会反映到项目的性能表现上。为此，我们将这些规则曝光出来，并且以一个个知识点的形式逐一解读。 今天，我们来继续剖析【UWA本地资源检测】中和音频视频相关的规则。我们将力图以浅显易懂的表达，让职场萌新或优化萌新深入理解。 1、未使用Streaming加载的长音频 这里我们先了解一下Unity里音频加载的三种方式： Decompress On Load 音频文件以压缩形式存储在磁盘上，加载时直接解压放到内存中。这种方式在内存占用上没有优势，但在后续播放时对CPU的计算需求是最小的。一般来讲这种方式更适用于短小的音频。 Compressed In Memory 音频文件以压缩形式存放在内存里，使用时再解压。这意味着会有更多的CPU开销，但是在加载速度和内存占用上具有优势。这种情况适用于大型音频文件。 Streaming 音频文件存放在磁盘中，加载时循环以下操作：“从磁盘读取一部分，解压到内存中，播放，卸载”。这种方式在内存占用上相较而言是最小的，但在CPU的消耗上是最不具优势的。 而在音频的实际运用中，背景音乐的使用是普遍存在的。几乎所有游戏都会使用BGM以增强对游戏氛围和环境的渲染。结合以上关于音频加载方式的描述可以看出，采用Streaming方式去加载背景音乐，可以有效减少内存占用和加载时间，从而降低Audio资源对项目总体内存和加载时间的影响。 因此，UWA使用这条规则，找出非Streaming加载的音频，再由开发团队依据这些音频的具体使用场合，去决定最适合的加载方式。 2、未使用PCM格式的音频 这条规则面向的是音频压缩时的编码方式。Unity支持的音频压缩方式有如下三种： PCM 全称是Pulse-Code Modulation。属于脉冲调制编码，它将模拟信号转换为数字信号，实质上没有经过编码，没有进行压缩，所以在音质上是属于完全无损的原始音频。而且相较于原生的模拟信号，它的抗干扰能力更强，保真效果更好。 Vorbis 应该叫做OGG Vorbis。类似mp3格式，但这是一种免费开发的非商业压缩格式。属于有损压缩。 ADPCM Adaptive Differential Pulse Code Modulation，自适应差分脉冲编码调制。是一种基于PCM的优化压缩方式，但也属于有损压缩。 未使用 PCM 格式的音频可能存在音质问题，所以我们将这些音频筛选出来，以供开发团队进行进一步的检查，去考虑音频格式和原音频的质量是否符合预期的使用需求。 3、双声道的音频 在导入音频文件时，我们可以发现，有些音频文件是多声道的（我们常见的是左右双声道），如下图： 在Android设备上，双声道音频对声音的效果提升不大，但是会占用双倍内存。所以建议使用单声道的音频。 而对于这些原本就是双声道的音频，可以考虑开启Force To Mono： 勾选Force To Mono的情况下，Unity会将音频合并成单声道，从而节约内存。 所以在本条规则筛选出这些双声道音频后，开发团队可以结合音频的实际表现效果去决定是否要对音频进行设置上的修改。 4、尺寸过大的视频资源 大尺寸的视频资源在表现效果上会更好，但是如果超过了目标设备群体的性能范围，那不仅提升效果有限，而且还会导致内存上的额外占用。 根据UWA对大数据的分析，在当前设备的性能范围内，512的视频尺寸能够保证在大部分的设备上达到所需的表现效果，同时不会对设备性能造成额外的压力。 需要说明的是，每一项检测规则的阈值都可以由开发团队依据自身项目的实际需求去设置合适的阈值范围，这也是本地资源检测的一大特点。同时，也欢迎大家来使用UWA推出的本地资源检测服务，可帮助大家尽早对项目建立科学的美术规范。 万行代码屹立不倒，全靠基础掌握得好！ 性能黑榜相关阅读 《那些年给性能埋过的坑，你跳了吗？》 《那些年给性能埋过的坑，你跳了吗？（第二弹）》 《掌握了这些规则，你已经战胜了80%的对手！》
本文整理的内容会以UGUI为主，当然也会涉及到NGUI和FairyGUI的内容，并将UI框架和优化内容单列出来帮助大家分类学习，最后是UWA的工具篇，开发者们可以根据报告查看问题，优化项目。模块知识Tree内容会持续更新推送，本期推送在“优化”分类中新增了两篇课程《让UI性能飞起来-基于GPU实现的UI动画系统》和《UITookit下一代UI系统》。 在UWA学堂界面中的“分类“选择UI即可查看相关课程。 无论对于从业者还是正在读大学的技术萌新，学习的有效时间和个人时间都是有限的。高效学习者最在意的是时间的价值，一个需要体系化的知识地图就显得尤为重要。 UGUI 《UGUI开发基础与实践》 该课程通过对Unity引擎UGUI的基础介绍结合具体的实例制作，系统、详细地讲解了UGUI开发的各项实用知识点，主要包括：UI界面布局、自定义Shader效果、UI组件扩展、UI事件系统搭建，以及常用的性能优化方法等。 讲师会手把手带领学员掌握如何搭建高效和酷炫的3D UI系统，并在实践中理解其中涉及到的基础知识、运行机制，以及进阶技巧。 《UGUI深度研究之源码鉴赏》 从UI的代码底层讲起，通过理解底层代码的原理来认知上层的有效优化方法。这里会从源码中深入分析UI网格重建的实现原理，并完成了提取触发UI重建的UI元素的代码实现，可以更方便、更准确地定位引起UI性能问题的UI元素，从而有针对性地做优化。 而在分析Image、Text、Layout等部分源码的同时，也提出使用的注意事项。使开发者在UGUI制作及性能优化方面，更好地达到“知其然，知其所以然”的效果。 “UI开销如何优化？”、“如何定位引起重建的UI元素？”、“UGUI Text如何进行优化？”、“如何合理使用Layout？”、“在设置UI点击事件时有哪些注意事项？”等等这些常见的UI开发问题，都可以在这个专栏中得到解答。不管是行业新人，还是Unity从业者，都不妨来一起领略一番UGUI源码，相信都会有所收获。 《UGUI深度研究之优化技巧》 UI界面常常面临的性能问题是界面打开慢，在一些低端机上打开复杂UI时，可能会需要5秒以上。本篇专栏通过讲解纹理格式、纹理通道分离、纹理尺寸设置等方面，找到移动端UI纹理资源兼具效果和性能的合理格式，以此减少纹理资源加载耗时，从而减少UI的打开耗时。此外，还包括了UI制作上的常见UI制作优化思路，帮助开发者找到UI的“正确打开方式”。为了更好地在研发后期找到瓶颈UI，这里还提供了一个UI界面的静态分析工具，可以帮助开发者更直观地分析定位“问题UI”。 随着UI迭代和研发流程的变化，在UI资源上也会遇到一些不可避免的麻烦问题，本篇专栏也对这些问题进行了举例并给出了可参考的解决方案。 通过本篇专栏的学习，开发者可以更好地对移动端UI制作进行有条理的性能把控，提升UI界面的运行效率。 《Unity UGUI 渲染3D对象详解》 该课程是一个针对项目研发过程中一个场景问题的解决方案。在UI界面中渲染3D物体是一个非常普遍的需求，该课程讲解的并不是如何去渲染，而是讲解在UI界面中渲染3D物体通常会遇到哪些棘手的问题，它的原理是什么以及如何解决。通过该课程，你可以对UI界面中渲染3D物体的原理进行掌握，同时对于大多数研发团队中会碰到的问题和解决方案都会有一个更为全面的了解。 《Unity运行时动态图集的实现》 本课程讲解了Unity运行时通过动态图集来降低Draw Call的方法，从基本思路和实现原理切入，随后对动态图集的功能做了优化和扩展，附带Demo源码。不仅包含可复用的图集，还有空间利用效率更高的不可复用图集的实现。 《UGUI输入组件支持Emoji》 基本上每个游戏都会有聊天功能，策划通常也会要求我们支持Emoji的输入和展示。然而UGUI的InputField组件是不支持Emoji输入的，旧版本甚至输入Emoji还会触发exception。 本文会先说明为什么InputField不支持Emoji，这部分主要先解释一下Unicode的编码规则、计算机中Unicode的实现方式，再介绍Emoji的编码规则，以此推出InputField不支持Emoji输入的原因。 之后详细介绍一套低成本的解决方案，会逐个的说明InputField需要改写的关键点和原因，最后介绍Text组件对Emoji的渲染支持。 《详解UGUI Draw Call计算和Rebuild操作优化》 这是目前对UGUI Draw Call分析得最为透彻、全面的一篇文章。通过大量举例让大家直接明白为何UI界面中的Draw Call总是居高不下，同时让大家掌握对Draw Call、UI重建、Overdraw等高频实际问题的高效优化知识。 《Unity UI模块优化案例精讲》 对UI模块常见的性能问题进行全方位的解析，具体分析UGUI和NGUI各自的优势和性能瓶颈所在，以及提升性能的主要方式，主要包括： 如何降低界面的渲染开销 如何降低界面的更新开销，以及避免峰值 如何高效地处理大量HUD元素等 《基于DOTS的UI解决方案》 本文介绍了DOTS UI解决方案，是比较早的对DOTS UI的介绍，基于0.3.0版本，主要是一些简单的功能实现，包括Conversion、UI Mesh Batching、 RenderSystem，并做了UGUI和DOTS UI的性能对比。 《UGUI文本多语言方案》 Unity的两套UI框架中，NGUI包含UI文本的多语言功能，而UGUI原生并不支持多语言。本文采用一套Key对应多套文本的方式，实现了游戏内动态的语言切换，简单、方便，又避免了插件的使用。原理讲解清晰，并附上了关键的代码讲解，值得一读。 《UGUI表情系统解决方案》 图文混编是表情系统的常见问题，Unity推荐的TextMesh方式可以混编，但是与UGUI难以整合、使用多个材质时还会带来动态合批的问题，作者通过研究UGUI源码和其Shader，重写了UGUI的Text生成Mesh的方法，很好地解决了图文混编的问题，本文详细讲解了这套表情系统解决方案，并附赠源码。 《Unity游戏界面解决方案: PSD For UGUI》 在游戏开发过程中界面系统需求变化频繁，牵涉到人员最多，从项目长期各系统开发、维护所占时间统计来看，界面系统所占时间是最多的。笔者通过学习、总结，开发出这个从PSD文件直接生成Unity中Prefab资源的流程，该流程可以高保真地在Unity中复现美术设计，减少沟通，提高游戏整体研发效率。 《属性雷达图UGUI渲染器》 使用雷达图展示角色的各个属性值，是常见的UI界面，文中对方案的实现逻辑和思路做了详细的讲解并实际测评了效果。 《如何实现次时代美术效果—UGUI美术制作篇》 本视频展示了使用UGUI，现场制作自定义的材质，实现酷炫的UI表现效果的过程，并讲解了如何通过编写自定义的UI组件实现复杂的UI状态，结合动画系统来实现动感的UI效果。 FairyGUI+NGUI 《FairyGUI插件解析及Lua导出》 本专栏作者详细介绍了UI框架设计理念和核心内容，并通过FairyGUI的自动化逻辑，重构了UI管线，同时在插件创建和Lua脚本导出两个环节，详细地介绍了自己的工程操作、并对关键代码进行了解析，很好地解决了重度游戏开发中常见的反复率高、耗时等痛点，文末还提出了编辑器化的未来扩展思路。提高游戏工业生产力、解决开发痛点，选它！ 《Unity UI模块优化案例精讲》 对UI模块常见的性能问题进行全方位的解析，具体分析UGUI和NGUI各自的优势和性能瓶颈所在，以及提升性能的主要方式，主要包括： 如何降低界面的渲染开销 如何降低界面的更新开销，以及避免峰值 如何高效地处理大量HUD元素等 《FairyGUI的使用技巧和优化建议》 当时作者的团队正面临Cocos2d向Unity的转型，选择了FairyGUI作为大部分图形界面设计和测试的工具，经过一年半的时间，很好地完成了过渡和不同部门的配合。文中分享了实战中总结的7个技巧，都很实用。包括： 1）像素点击测试功能的注意点 2）动效播放TimeScale的问题 3）列表使用的优化 4）合批 5）循环虚拟列表 6）Text影响Draw Call的例子 7）滤镜影响Draw Call 《Unity一键图集生成工具，附源码（基于NGUI和TexturePacker）》 TexturePacker GUI是NGUI中打包管理图集的常用工具，但是如果图集较多，就会带来很多的重复，笔者写了这套工具来实现批量图集的生成，从而提高效率。文中详细讲解了工具的制作过程，并附上了工程源码。 UI框架 《UI框架搭建：控件定制篇》 《UI框架搭建：消息重构篇》 《UI框架搭建：应用技巧篇》 整套框架分为三节课程来讲解：第一节课程主要讲解控件侧的框架搭建，第二节课程进一步讲解消息流程框架搭建，最后一节则罗列我们在研发过程中特别推荐的高级技巧操作。该系列课程带你从一个不同的视角审视UI框架，并在关键的地方给予踩坑预警；底层认识UI控件背后的逻辑；在搭建框架时，教你面对不同技术选型做出合理的取舍判断。 《Unity手游UI框架一站式解决方案》 该课程内容非常详实、性价比极高。初级开发者可以拥有一套完整的实战UI框架源码，快速进入实战；中级及以上者可以深入理解框架的形成过程并在此基础上进行修改，迭代出属于自己的框架；语言中并没有华丽的辞藻，技术则完全以实用、稳健为基础。 《如何绕过Unity打造自己的HUD UI系统》 详细讲解如何自己实现一套UI系统渲染器，并实现HUD的高效渲染，从而解决战斗过程中大量头顶血条和伤害数字效果造成的卡顿问题，特别适合于大量HUD高频出现的游戏项目，内附工程源码方便移植。 《Unity中基于前缀树的高性能红点系统实现》 文章主要介绍了红点系统的特点，如何基于前缀树这一数据结构实现红点系统，提出了相关实现中存在的两个性能问题，以及如何去解决这两个问题，并在最后基于UnityEditor的TreeView开发了树视图窗口，方便使用者在开发阶段的Debug需求。 优化 《UIToolkit下一代UI系统》 UIToolkit是Unity打造的全新UI系统，它同时支持运行时与编辑时的UI界面开发，新版本引擎自带布局将全面使用UIToolkit替换，官方已经明确表示后续禁止在提交IMGUI的代码，运行时界面将取代原有的UGUI系统。自Unity 2020.2起UIToolkit已内置在Unity引擎中并非package包的形式，地位已然超过UGUI。 UGUI已经好几年没有在更新了，性能问题一直饱受开发者诟病，还有图文混排、循环滑动列表一类的基础组件缺失，导致每个项目都要单独实现一遍。在性能上UIToolkit更是超越UGUI，它可以1次DrawCall完成所有界面的绘制，UIBuilder编辑器对界面布局上也对策划美术更加友好。 《Unity引擎渲染、UI、逻辑代码模块的量化分析和优化方法》 Unity引擎的渲染、UI和逻辑脚本耗时是大多数项目的Top 3耗时模块。对此，我们对这三个模块进行量化地统计和分析，介绍准确定位性能瓶颈的方法。同时，从常见的性能问题入手，分析和对比各种可行的性能优化方案，以求显著地提升这些模块的效率。内容包括： 渲染模块的重要参数分析以及优化方法 UI模块瓶颈的精确定位和优化 C#/Lua瓶颈函数的优化方法 《Unity引擎移动游戏性能优化全解析》 目前，UWA已经为超过70款移动游戏项目进行了深度优化。在针对这些项目的性能进行深度剖析时，我们遇到了大量的“疑难杂症”。在此，我们将这些问题进行总结和归纳，通过大量实际案例，让大家能够了解和掌握如何对Unity引擎各个模块、资源和内存管理方面的性能问题进行快速的定位和精准的分析，内容主要包括： 1、渲染、UI、加载、物理等Unity引擎各个主流模块的“疑难杂症”分析 2、纹理、网格等主流资源的资源加载和内存管理 《Unity UI模块优化案例精讲》 对UI模块常见的性能问题进行全方位的解析，具体分析UGUI和NGUI各自的优势和性能瓶颈所在，以及提升性能的主要方式，主要包括： 如何降低界面的渲染开销 如何降低界面的更新开销，以及避免峰值 如何高效地处理大量HUD元素等 《UGUI深度研究之优化技巧》 本课程通过讲解纹理格式、纹理通道分离、纹理尺寸设置等方面，找到移动端UI纹理资源兼具效果和性能的合理格式，以此减少纹理资源加载耗时，从而减少UI的打开耗时。此外，还包括了UI制作上的常见UI制作优化思路，帮助开发者找到UI的“正确打开方式”。为了更好地在研发后期找到瓶颈UI，这里还提供了一个UI界面的静态分析工具，可以帮助开发者更直观地分析定位“问题UI”。 《如何绕过Unity打造自己的HUD UI系统》 详细讲解如何自己实现一套UI系统渲染器，并实现HUD的高效渲染，从而解决战斗过程中大量头顶血条和伤害数字效果造成的卡顿问题，特别适合于大量HUD高频出现的游戏项目，内附工程源码方便移植。 《详解UGUI Draw Call计算和Rebuild操作优化》 这是目前对UGUI Draw Call分析得最为透彻、全面的一篇文章。通过大量举例让大家直接明白为何UI界面中的Draw Call总是居高不下，同时让大家掌握对Draw Call、UI重建、Overdraw等高频实际问题的高效优化知识。 《如何实现高效UGUI性能—UGUI性能优化篇》 本视频主要讲解了以下四方面的内容： UGUI 中Draw Call的形成规则 Overdraw的若干优化方式 通过对源码的简单修改，优化事件检测的开销 UI 网格重建的机制和优化方法 《如何优化UGUI的ScrollRect》 UGUI的ScrollRect在加载太多物体的时候，第一次弹出界面会非常卡顿，而且不在界面里的内容依然会参与绘制，导致毫无意义的浪费。笔者对此进行了一番研究，并将心得与大家分享。 《UGUI 降低填充率技巧两则》 Fill Rate（填充率）是指显卡每帧每秒能够渲染的像素数。在每帧绘制中，如果一个像素被反复绘制的次数越多，那么它占用的资源也必然更多。目前在移动设备上，FillRate的压力主要来自半透明物体。因为多数情况下，半透明物体需要开启 Alpha Blend 且关闭 ZTest和 ZWrite，同时如果我们绘制像 alpha=0 这种实际上不会产生效果的颜色上去，也同样有 Blend 操作，这是一种极大的浪费。因此，本文为大家推荐了两则UGUI 降低填充率的技巧，希望对大家有所帮助。 《Unity手游开发札记——使用Shader进行UGUI的优化》 UI优化是个极其繁琐且考验功底的部分。笔者整理了之前工作中基于Shader来进行优化的思路和方法，以及自己构建的、代替UGUI提供的通用控件的组件，包括小地图、Mask的使用、进度条、基于DoTween的动画效果优化等，具有极高的参考意义。 《NGUI性能优化技巧》 包含了上、下两节内容，上主要介绍了使 Widgets 对不同的屏幕尺寸进行自适应；自定义UI材质的制作；降低渲染时的Draw Call数量；解决拾取UI与场景物体的鼠标事件冲突等，下主要介绍了UI Panel 开销原理和实例演示降低UI Panel的开销。 《如何大幅优化NGUI的堆内存分配》 本文旨在修改NGUI的几处底层实现，从而规避堆内存的临时分配。文末，通过复杂的实例来这种方法进行了测试，通过比较可以看出，该方法可以大幅优化UI界面在运行时的堆内存分配。 对于NGUI解决方法的优化主要包括以下几个方面： 对UIGeometry的顶点属性做了缓存池，并保证OnFill时进行按需选择 通过unsafe的方法来修改IndexBuffer的长度 填充数组前充分设置List.capacity 《高效的iPhone X适配技术方案（UGUI和NGUI）》 本文作者旨在通过改锚点的方式，分别实现在NGUI和UGUI上的iPhone X适配技术方案，并结合自身项目经验，阐述了主要的实现细节，希望能对广大游戏开发团队有借鉴意义。 工具 《一个高效研发的制胜之道 | UWA GOT Online功能说明》 不管项目本身是否重度，性能调优永远是上线前的必经之路。优化性能，不仅需要足够深度的分析来定位问题瓶颈，也需前瞻性的保障措施来缓解后续的优化压力和性能风险。因此，我们推出了UWA GOT Online，方便研发团队在本地真机测试，并能在编辑器中搭建本地服务器查看性能数据。 《如何读懂UWA性能报告？—UGUI篇》 《如何读懂UWA性能报告？—NGUI篇》 UI模块是很多开发团队头疼的疑难杂症之一，它的性能和UI设计师的制作及工程师的实现息息相关，两套看上去完全一样的UI很有可能在底层设计和实现上截然不同，因此性能也大相径庭。所以我们在UWA测评报告中也提供了UI的性能分析。 《Unity性能优化 — UI模块》 在Unity引擎中，主流的UI框架有UGUI，NGUI以及使用越来越多的FairyGUI。本篇文章主要从使用最多的UGUI来进行说明。本文从CPU耗时部分和对内存分配的影响，以及对GPU的影响来讨论UI的优化。 无论对于从业者还是正在读大学的技术萌新，学习的有效时间和个人时间都是有限的。高效学习者最在意的是时间的价值，一个需要体系化的知识地图就显得尤为重要。以上就是本期为大家梳理的Unity引擎UI模块知识Tree，作为体系化的知识，我们希望大家能掌握背后的逻辑与原理，结合学习方法论并反复实践，能够在自己的项目中更高效地解决UI问题。同时也欢迎大家向我们投稿，交流行业内的技术知识。 往期回顾 《Unity引擎渲染模块知识Tree汇总》 《UWA学堂｜逻辑代码模块》 《UWA学堂｜开发流程模块》 《UWA学堂｜解决方案模块》 《UWA学堂｜优化模块》
帧同步这部分比较复杂，细枝末节有很多优化点，也有一些不同的优化方向，根据不同项目类型、对操作手感的要求、联机玩家的个数等，会有不同的难点和痛点。不同的优化方向，优化手法的差异，可能导致一些争论。并且，帧同步，本身也有很多变种，以应对不同的需求。所以，本文一切都是基于作者的项目类型（ACT）来做的方案和优化，并不一定适合其它也需要帧同步的游戏，故在此提前说一下，以免引起一些不必要的误解。 帧同步的几个难点 帧同步的基础原理，以及和状态同步的区别，已经有很多文章介绍，我就不再赘述，大家可以自行google。以下只说几个难点。 保证客户端独自计算的正确，即一致性 帧同步的基础，是不同的客户端，基于相同的操作指令顺序，各自执行逻辑，能得到相同的效果。就如大家所知道的，在Unity引擎中，不同的调用顺序，时序，浮点数计算的偏差，容器的排序不确定性，Coroutine内写逻辑带来的不确定性，物理浮点数，随机数值带来的不确定性等等。 有些比较好解决，比如随机数值，只需要做随机种子即可。 有些需要注意代码规范，比如在帧同步的战斗中，逻辑部分不使用Coroutine，不依赖类似Dictionary等不确定顺序的容器的循环等。 还有最基础的，要通过一个统一的逻辑Tick入口，来更新整个战斗逻辑，而不是每个逻辑自己去Update。保证每次Tick都从上到下，每次执行的顺序一致。 物理方面，因为我们战斗逻辑不需要物理，碰撞都是自己做的碰撞逻辑，所以，跳过不说，这块可以参考别的文章。 最后，浮点数计算无法保证一致性，我们需要转换为定点数。关于定点数的实现，比较简单的方式是，在原来浮点数的基础上乘1000或10000，对应地方除以1000或10000，这种做法最为简单，再辅以三角函数查表，能解决一些问题，减少计算不一致的概率。但是，这种做法是治标不治本的方式，存在一些隐患（举个例子，例如一个int和一个float做乘法，如果原数值就要*1000，那最后算出来的数值，可能会非常大，有越界的风险。） 最佳的解决办法是：使用实现更加精确和严谨，并经过验证的定点数数学库，在C#上，有一个定点数的实现，Photon网络的早期版本，Truesync有一个很不错的定点数实现。 定点数的实现 其中FP，就可以完全代替Float，我们只需要将我们自己的逻辑部分，Float等改造为FP，就可以轻松解决。并且，能够很好的和我们Protobuf的序列化方式集成（注意代码中的Attribute，如下图），保证我们的配置文件，也是定点数的。 TSVector对应Vector3，只要基于FP，可以自己扩展自己的数据结构。（当然，如果用到了复杂的插件，并且不开源，那么对于定点数的改造，就会困难很多） 三角函数通过查表方式实现，保证了定点数的准确 我个人认为，这一套的实现，是优于简单的乘10000，除10000的方式。带来的坏处，可能就是计算性能略差一点点，但是我们大量测试下来，对计算性能的影响很小，应该是能胜任绝大部分项目的需求。 对于计算的不确定性，我们也有一些小的隐患，就是我们用到了Physics.Raycast来检测地面和围墙，让人物可以上下坡，走楼梯等高低不平的路，也可以有形状不规则的墙。这里会获得一个浮点数的位置，可能会导致不确定性，这里，我们用了数值截断等方式，尽量规避，经过反复测试，没有出现过不一致。但是这种方式，毕竟在逻辑上，存在隐患，更好的方式，是实现一套基于定点数的raycast机制，我们人力有限，就没时间精力去做了。这块有篇文章讲得更细致一些，大家可以参看： 帧同步：浮点精度测试 https://zhuanlan.zhihu.com/p/30422277 帧同步网络协议的实现 在处理好了基础的计算一致性问题后，我们就要考虑网络如何通信。这里，我不谈p2p方式了，我们以下谈的，都是多client，一个server的模式，server负责统一tick，并转发client的指令，通知其他client。 首先，是网络协议的选择。TCP和UDP的选择，我就不多说了，帧同步肯定要基于UDP才能保证更低的延迟。在UDP的选择上，我看网上有些文章，容易导入一个误区，即，我们是要用可靠传输的UDP，还是冗余信息的UDP。 基于可靠传输的UDP，是指在UDP上加一层封装，自己去实现丢包处理，消息序列，重传等类似TCP的消息处理方式，保证上层逻辑在处理数据包的时候，不需要考虑包的顺序，丢包等。类似的实现有Enet，KCP等。 冗余信息的UDP，是指需要上层逻辑自己处理丢包，乱序，重传等问题，底层直接用原始的UDP，或者用类似Enet的Unsequenced模式。常见的处理方式，就是两端的消息里面，带有确认帧信息，比如客户端(C)通知服务器(S)第100帧的数据，S收到后通知C，已收到C的第100帧，如果C一直没收到S的通知（丢包，乱序等原因），就会继续发送第100帧的数据给S，直到收到S的确认信息。 有些文章介绍的时候，没有明确这两者的区别，但是这两种方式，区别是巨大的。可靠传输的UDP，在帧同步中，个人认为是不合适的，因为他为了保证包的顺序和处理丢包重传等，在网络不佳的情况下，Delay很大，将导致收发包处理都会变成类似TCP的效果，只是比TCP会好一些。必须要用冗余信息的UDP的方式，才能获得好的效果。并且实现并不复杂，只要和服务器商议好确认帧和如何重传即可，自己实现，有很大的优化空间。例如，我们的协议定义类似如下： 双方都要通知对方，已经接受哪一帧的通知了，并通过cmd list重发没有收到的指令 这里简单说一下，对于这种收发频繁的消息，如果使用Protobuf，会造成每个逻辑帧的GC，这是非常不好的，解决方案，要么对Protobuf做无GC改造，要么就自己实现一个简单的byte[]读写。无GC改造工程太大，感觉无必要，我们只是在战斗的几个频繁发送的消息，需要自己处理一下byte[]读写即可。 在这部分需要补充一下，KCP作者韦易笑提到KCP+FEC的模式，可以比冗余方式，有更好的效果，我之前并没有仔细研究过这个模式，不过可以推荐大家看一下。 因为我们项目早期，服务器定下了使用Enet，我评估了一下，反正使用冗余包的方式，所以没有纠结Enet或KCP，后续其实想改成KCP，服务器不想再动，也就放下了。 Enet麻烦的地方是，Enet的ipv6版本，是一个不成熟的Pull Request，Enet作者没有Merge（并且存在好几个ipv6的Pull Request），我不确定稳定性，还好看了下Commit，加上测试下来，没有太大问题。KCP我没有评估过ipv6的问题，不过Github上有C#版本，改一下ipv6支持应该很简单。 逻辑和显示的分离 这部分在很多讲帧同步的文章中都提过了。配置的数据和显示要分离，在战斗中，战斗的逻辑，也要和显示做到分离。 例如，我们动作切换的逻辑，是基于自己抽象的逻辑帧，而不是基于Animator中一个Clip的播放。比如一个攻击动作，当第10帧的时候，开始出现攻击框，并开始检测和敌人受击框的碰撞，这个时候的第10帧，必须是独立的逻辑，不能依赖于Animator播放的时间，或者AnimatorStateInfo的NormalizedTime等。甚至，当我们不加载角色的模型，一样可以跑战斗的逻辑。如果抽离得好，还可以放到服务器跑，做为战斗的验证程序，王者荣耀就是这样做的。 联机如何做到流畅战斗 前面所有的准备，最终的目的，都是为了战斗的流畅。特别是我们这种ACT游戏，或者格斗类游戏，对按键以后操作反馈的即时性，要求非常高，一点点延迟，都会影响玩家的手感，导致玩家的连招操作打断，非常影响体验。我们对延迟的敏感性，甚至比MOBA类游戏还要高，我们要做到好的操作手感，还要联机战斗（PVP，组队PVE），都需要把帧同步做到极致，不能因为延迟卡住或者操作反馈出现变化。 因为这个原因，我们不能用Lockstep的方式，Lockstep更适合网络环境良好的内网，或者对操作延迟不敏感的类型（例如我听过还有项目用来做卡牌类的帧同步）。 我们也不能用缓存服务器确认操作的方式，也就是一些游戏做的指令Buffer。具体描述，王者荣耀的分析文章，讲得很具体了。这也是他们说的模式，这个模式能解决一些小的网络波动，对一些操作反馈不需要太高的游戏，例如有些游戏攻击前会有一个比较长的前摇动作，这类游戏，用这种方式，应该就能解决大部分问题。但是这种方式还是存在隐患，即使通过策略能很好地动态调整Buffer，也还是难以解决高延迟下的卡顿和不流畅。王者荣耀优化得很好，他们说能让Buffer长度为0，文章只提到通过平滑插值和逻辑表现分离来优化，更细节的没有提到，我不确定他们是否只是基于这个方式来优化的。目前也没有看到更具体的分析。 指令Buffer的方式，也不能满足我们的需求，或者说，我没有找到基于此方式，能优化到王者荣耀的效果的办法。我也测试过其他MOBA和ACT，ARPG类游戏的联机，在高延迟，网络波动情况下，没有比王者表现更好的了。 最后，在仔细研究了我们的需求后，找到一篇指导性的文章，非常适合我们： Understanding Fighting Game Networking http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/ 这篇文章非常详细地介绍了各种方式，最终回滚逻辑(Rollback)是终极的解决方案，国内也有文章提到过，即： Skywind Inside 再谈网游同步技术 http://www.skywind.me/blog/archives/1343#more-1343 文章里面提到的Time Warp方式，我理解回滚逻辑，和Time Warp是一个概念。 游戏逻辑的回滚 回滚逻辑，就是我们解决问题的方案。可以这样理解，客户端的时间，领先服务器，客户端不需要服务器确认帧返回才执行指令，而是玩家输入，立刻执行（其他玩家的输入，按照其最近一个输入做预测，或者其他更优化的预测方案），然后将指令发送给服务器，服务器收到后给客户端确认，客户端收到确认后，如果服务确认的操作，和之前执行的一样（自己和其他玩家预测的操作），将不做任何改变，如果不一样（预测错误），就会将游戏整体逻辑回滚到最后一次服务器确认的正确帧，然后再追上当前客户端的帧。 此处逻辑较为复杂，我尝试举个例子说明下。 当前客户端(A,B)执行到100帧，服务器执行到97帧。在100帧的时候，A执行了移动，B执行了攻击，A和B都通知服务器：我已经执行到100帧，我的操作是移动(A)，攻击(B)。服务器在自己的98帧或99帧收到了A,B的消息，存在对应帧的操作数据中，等服务器执行到100帧的时候（或提前），将这个数据广播给AB。 然后A和B立刻开始执行100帧，A执行移动，预测B不执行操作。而B执行攻击，预测A执行攻击（可能A的99帧也是攻击），A和B各自预测对方的操作。 在A和B执行完100帧后，他们会各自保存100帧的状态快照，以及100帧各自的操作（包括预测的操作），以备万一预测错误，做逻辑回滚。 执行几帧后，A和B来到了103帧，服务器到了100帧，他开始广播数据给AB，在一定延迟后，AB收到了服务器确认的100帧的数据，这时候，AB可能已经执行到104了。A和B各自去核对服务器的数据和自己预测的数据是否相同。例如A核对后，100帧的操作，和自己预测的一样，A不做任何处理，继续往前。而B核对后，发现在100帧，B对A的预测，和服务器确认的A的操作，是不一样的（B预测的是攻击，而实际A的操作是移动），B就回滚到上一个确认一样的帧，即99帧，然后根据确认的100帧操作去执行100帧，然后快速执行101~103的帧逻辑，之后继续执行104帧，其中（101~104）还是预测的逻辑帧。 因为客户端对当前操作的立刻执行，这个操作手感，是完全和PVE（不联网状态）是一样的，不存在任何Delay。所以，能做到绝佳的操作手感。当预测不一样的时候，做逻辑回滚，快速追回当前操作。 这样，对于网络好的玩家，和网络不好的玩家，都不会互相影响，不会像Lockstep一样，网络好的玩家，会被网络不好的玩家Lock住。也不会被网络延迟Lock住，客户端可以一直往前预测。 对于网络好的玩家(A)，可以动态调整（根据动态的Latency），让客户端领先服务器少一些，尽量减少预测量，就会尽量减少回滚，例如网络好的，可能客户端只领先2~3帧。 对于网络不好的玩家(B)，动态调整，领先服务器多一些，根据Latency调整，例如领先5帧。 那么，A可能预测错的情况，只有2~3帧，而网络不好的B，可能预测错误的帧有5帧。通过优化的预测技术，和消息通知的优化，可以进一步减少A和B的预测错误率。对于A而言，战斗是顺畅的，手感很好，少数情况的回滚，优化好了，并不会带来卡顿和延迟感。 重点优化的是B，即网络不好的玩家，他的操作体验。因为客户端不等待服务器确认，就执行操作，所以B的操作手感，和A是一致的，区别只在于，B因为延迟，预测了比较多的帧，可能导致预测错，回滚会多一些。比如按照B的预测，应该在100帧击中A，但是因为预测错误A的操作，回滚重新执行后，B可能在100帧不会击中A。这对于B来说，通过插值和一些平滑方式，B的感受是不会有太大区别的，因为B看自己，操作自己都是及时反馈的，他感觉自己是平滑的。 这种方式，保证了网络不好的B的操作手感，和A一致。回滚导致的一些轻微的抖动，都是B看A的抖动，通过优化（插值，平滑等），进一步减少这些后，B的感受是很好的。我们测试在200~300毫秒随机延迟的情况下，B的操作手感良好。 这里，客户端提前服务器的方式，并且在延迟增大的情况下，客户端将加速，和 守望先锋的处理方式是一样的。当然，他们肯定比我做得好很多： https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg 希望我已经大致讲清楚了这个逻辑，大家参看几篇链接的文章，能体会更深。 这里，我要强调的一点是，我们这里的预测执行，是真实逻辑的预测，和很多介绍帧同步文章提到的预测是不同的。有些文章介绍的预测执行，只是View层面的预测，例如前摇动作和位移，但是逻辑是不会提前执行的，还是要等服务器的返回。这两种预测执行（View的预测执行，和真实逻辑的预测执行）是完全不是一个概念的，这里需要仔细地区分。 这里有很多的可以优化的点，我就不一一介绍了，以后可能零散地再谈。 游戏逻辑的快照（snapshot） 我们的逻辑之所以能回滚，都是基于对每一帧状态可以处理快照，存储下每一帧的状态，并可以回滚到任何一帧的状态。 Understanding Fighting Game Networking文章： http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/ 守望先锋网络文章： https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg 以上两篇文章都一笔带过了快照的说明。他们说的快照，可能略有不同，但是思路，都是能保存下每一帧的状态。如果去处理快照（Understanding那篇文章做的是模拟器游戏，可以方便地以内存快照的方式来做），是一个难点。 这也是我前面文章，提到ECS在这个方式下的应用： https://zhuanlan.zhihu.com/p/38280972 云风的解释： 云风博客截图，地址https://blog.codingnow.com/2017/06/overwatch_ecs.html ECS是一个好的处理方式，并且我找到一篇文章，也这样做了（我看过他开源的demo，做得还不够好，应该还是demo阶段，不太像是一个成型的项目）。 https://www.kisence.com/2017/11/12/guan-yu-zheng-tong-bu-de-xie-xin-de/ 这篇文章的思路是很清晰的，并且也点到了一些实实在在的痛点，解决思路也基本是正确的，可以参看。 这块我做得比较早了，当时守望先锋的文章还没出，我的战斗也没有基于ECS，所以，在处理快照上，只有自己理顺逻辑来做了。 我的思路是，通过一个回滚接口，需要数据回滚的部分，实现接口，各自处理自己的保存快照和回滚。就像我们序列化一个复杂的配置，每个配置各自序列化自己的部分，最终合并成一个序列化好的文件。 首先，定义接口，和快照数据的Reader和Writer 然后，就是每个模块，自己去处理自己的TakeSnapshot和Rollback，例如： 简单的数值回滚 复制的列表回滚和调用子模块回滚 思路理顺以后，就可以很方便地处理了，注意Write和Read的顺序，注意处理好List，就解决了大部分问题。当然，在实现逻辑的过程中，时刻要注意，一个模块如何回滚（例如获取随机数也需要回滚）。 有一个更简单的方式，就是给属性打Attribute，然后写通用的方法。例如，我早期的实现方案： 给属性打标签 根据标签，通用的读写方法，通过反射来读写，就不需要每个模块自己去实现自己的方法了： 部分代码 这种方法，能很好地解决大部分问题，甚至前面提到的Truesync，也是用的这种方式来做。 但是这种方法有个难以回避的问题，就是GC，因为基于反射，当我们调用field的GetValue和SetValue的时候，GC难以避免。并且，因为全自动，不方便处理一些特殊逻辑，调试优化也不方便，最后改成了现有的方式，虽然看起来笨重一些，但是可控性更强，我后续做的很多优化，都方便很多。 关于快照，也有很多可以优化的点，无论是GC内存上的，还是运行效率上的，都需要优化好，否则，可能带来性能问题。这块优化，有空另辟文章再细谈吧。 当我们有了快照，就可以支持回滚，甚至跳转。例如我们要看战斗录像，如果没有快照，我们要跳到1000帧，就需要从第一帧，根据保存的操作指令，一直快速执行到1000帧，而有了快照，可以直接跳到1000帧，不需要执行中间的过程，如果需要在不同的帧之间切换，只需要跳转即可，这将带来巨大的帮助。 自动测试 由于帧同步需要测试一致性的问题，对我们来说，回滚也是需要大量测试的问题。自动测试是必须要做的一步，这块没有什么特别的点，主要就是保存好操作，快照，log，然后对不同客户端的数据做比对，找到不同的地方，查错改正。 我们现在做到，一步操作，自动循环战斗，将每一盘战斗数据上传内网log服务器。 当有很多盘战斗的数据后，通过工具自动解析比对数据，找到不同步的点。也是还可以优化得更好，只是现在感觉已经够用了。经过大量的内部自动测试，目前战斗的一致性，是很好的。 总结 我们现在的帧同步方案，总结下来，就是预测，快照，回滚。当把这些有机地结合起来，优化好，就有了非常不错的帧同步联网效果，无论网络速度如何，只要不是延迟大到变态，都保证了非常好的操作手感。 快照回滚的方式，也不是所有游戏都适用，例如： Skywind Inside 再谈网游同步技术文章中对此模式（Time warp或Rollback）的缺点，也说明了。 http://www.skywind.me/blog/archives/1343#more-1343 如图所述，这种模式不适合太多人的联网玩法，例如MOBA，可能就不太适用。我们最多三人联机，目前优化测试下来，效果也没有太大问题。但是联机人数越多，预测操作的错误可能性越大，导致的回滚也会越多。 一篇文章，难以讲得面面俱到，很多地方可能描述也不一定明确，并且，个人能力有限，团队人员有限（3个客户端）的情况下，必定有很多设计实现不够好的地方，大家见谅。 一些有帮助的文章再列一下： 1、Understanding Fighting Game Networking http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/ 2、Skywind Inside " 再谈网游同步技术 http://www.skywind.me/blog/archives/1343#more-1343 3、《守望先锋》回放技术：阵亡镜头、全场最佳和亮眼表现 https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg 4、《王者荣耀》技术总监复盘回炉历程 http://youxiputao.com/articles/11842 5、帧同步：浮点精度测试 https://zhuanlan.zhihu.com/p/30422277 6、A guide to understanding netcode https://www.gamereplays.org/overwatch/portals.php?show=page&name=overwatch-a-guide-to-understanding-netcode 7、网游流畅基础：帧同步游戏开发 http://www.10tiao.com/html/255/201609/2650586281/4.html 最后啰嗦一句，如最开始所述，帧同步有很多变种、实现方式和优化方向。有时候，可能不同文章提到帧同步这个术语的时候，里面的意思，可能都有区别，大家需要仔细理清和区分。 这是侑虎科技第418篇文章，感谢作者Gordon供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。QQ群：793972859（原群已满员） 作者主页：https://zhuanlan.zhihu.com/p/38468615，作者也是U Sparkle活动参与者，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
2018的号角已经吹响，UWA携新功能，为优化再添助力！作为UWA性能测评报告中的精髓，此次“分析与建议“模块加强了问题的分析力度，它能将数据所反映的表象一层层抽丝剥茧，逐步定位到更里层阶段。 目前我们已将其部署到所有测评过的项目中，用户登录官网，在项目测评报告的“分析与建议”中即可查看。 我们以渲染模块为例，上图为最新版本报告中的渲染模块界面。通过半透明渲染的耗时，UWA定位到问题瓶颈在于UI、粒子或者其他半透明物体的耗时，并分别对于这些瓶颈提供了不同的优化建议，大大缩减了研发团队进一步排查和试错的时间。 上图为某个项目的性能评测报告的内存模块页面，在“分析与建议”一栏中，我们详细解释了Unity项目的运行内存的构成，如资源内存、Mono以及其他内存等，并就当前测试项目的以上各内存分别给出优化建议，让大家在得到解决方案的同时，对引擎本身有个更系统深入的了解，让每一次优化，都能成为技术知识温故更新的过程。 不同的项目所展现的性能问题各不相同，需要的优化方案也大相径庭。UWA凭借多年丰富的实战经验，将沉淀过后的技术积累通过不断的测试验证，转换成UWA性能测评中可量化的上千种参考标准，为研发团队的项目提供定制化的解决方案，并力求检测速度更快捷、分析内容更全面、 解决问题更精准。 目前，UWA支持过的项目类型已经覆盖到市面上大部分的主流类型如MOBA、FPS、MMO、沙盒、ACT、ARPG、吃鸡、音乐、电竞、TPS、SLG等，更有不少精品游戏在近两年的游戏市场中取得了傲人的成绩，如当下备受追捧的《恋与制作人》，日益渐兴的沙盒类手游《创造与魔法》，挑战极致性能的重度MOBA手游《小米超神》等。UWA非常荣幸能见证它们荣耀背后，极具匠心地将产品反复打磨的历程。在我们看来，这些成就更多是必然。 UWA的合作案例 种一棵树最好的时间是十年前，其次是现在。优化亦是如此，因为时间不等你。 不是所有的分析都能一针见血， 不是所有的优化都能对症下药。 UWA，优化有“道”。 UWA会员功能一览
UI优化是个极其繁琐且考验功底的部分。笔者整理了之前工作中基于Shader来进行优化的思路和方法，以及自己构建的、代替UGUI提供的通用控件的组件，包括小地图、Mask的使用、进度条、基于DoTween的动画效果优化等，具有极高的参考意义。 一、前言 近期断断续续地做了一些优化的工作，包括资源加载、UI优化、效果分级等各个方面。优化本身是一件琐碎且耗神的事情，需要经历问题定位、原因探查、优化方案设计和实现、效果验证、资源修改多个步骤，也会涉及到各个职位之间的配合和协调。在这其中，可能带来较大工作量的是对于之前普遍使用的一些方法/控件的优化，如果无法兼容之前的使用接口，可能会给美术和程序带来较大的迭代工作量。 UI是这其中可能越早发现问题收益越高的一块内容，所以我整理了这段时间做的一些基于Shader来进行优化的思路和方法，以及会分享自己构建的、代替UGUI提供的通用控件的那些Component，希望在项目中前期的同学可以提前发现类似的问题，并尽早进行改善。 二、优化目标 UGUI已经提供了非常丰富的控件为我们使用，但是出于通用性的考虑，其中很多控件的性能和效果都可能存在一些问题，又或者在频繁更改UI数值的需求下会引发持续的Mesh重建导致CPU的消耗。我们期望通过一些简单的Shader逻辑来提升效果或者提高效率，主要会集中在如下几个方面： 降低Draw Call 减少Overdraw 减少UI Mesh重建次数和范围 接下来的内容，我们就来分享如何使用简单的Shader进行UGUI优化的过程。 三、小地图 在我们游戏中，玩家移动的时候右上角会始终有小地图的显示，这个功能在最初的实现方案中是使用UGUI的Mask组件来做的，给了一个方形的Mask组件，然后根据玩家位置计算出地图左下角的位置进行移动。这种实现方式虽然简单，但是会有两个问题： Overdraw特别大，几乎很多时候会有整个屏幕的Overdraw； 玩家在移动过程中，因为一直在持续移动图片的位置（做了适当的降频处理），所以会一直有UI的Mesh重建过程。 当时的Prefab已经被修改了，我简单模拟了使用Mask的方法带来的Overdraw的效果，如下图所示： 使用Mask组件带来的Overdraw的问题 在上图中可以看到，左侧是小地图在屏幕中的效果，右侧是选择Overdraw视图之后的效果，整张图片都会有一个绘制的过程，占据几乎整个屏幕（白框），而且Mask也是需要一次绘制过程，这样就是两个Draw Call。其实这里UI同学为了表现品质感，在小地图上又蒙了一层半透的外框效果，消耗更大一些。 针对这一问题，首先对于矩形的地图，可以使用运行效率更高一些的RectMask2D组件，但这并不能有本质的提升，解决OverDraw最根本的方法还是不要绘制那么大的贴图，然后通过蒙版或者Clip的方式去掉，这是很浪费的方法。有基本Shader概念的朋友应该可以想到修改UV的方法，这也是我们采用的方法：思路很简单，就做一个和需要显示大小一样的RawImage控件，然后赋给它一个特殊的材质，在vs里面修改要显示的区域的UV，就可以做到想要的效果。 直接贴出来Shader代码如下： sampler2D _MainTex; fixed4 _UVScaleOffset; sampler2D _BlendTexture; v2f vert(appdata_t IN) { v2f OUT; OUT.vertex = mul(UNITY_MATRIX_MVP, IN.vertex); OUT.texcoord = IN.texcoord; //计算uv偏移 OUT.offsetcoord.xy = OUT.texcoord.xy * _UVScaleOffset.zw + _UVScaleOffset.xy; #ifdef UNITY_HALF_TEXEL_OFFSET OUT.vertex.xy -= (_ScreenParams.zw-1.0); #endif return OUT; } fixed4 frag(v2f IN) : SV_Target { half4 color = tex2D(_MainTex, IN.offsetcoord); half4 blendColor = tex2D(_BlendTexture, IN.texcoord); color.rgb = blendColor.rgb + color.rgb * (1 - blendColor.a); return color; 核心的代码就只有加粗的那一句，给UV一个整体的缩放之后再加上左下角的偏移。之后C#逻辑就只需要根据地图的大小和玩家所在的位置，计算出想要显示的UV缩放和偏移值就可以了。玩家移动的时候只需要修改材质的参数，这也不会导致UI的Mesh重建，一箭双雕，解决两个问题。 小地图的外框也在材质中一并做了，减少一个Draw Call，最终的效果如下图所示： 优化后的Overdraw对比图 这里需要注意的是，对于Image控件的Material进行赋值时，如果它在一个Mask控件之下，可能会遇到赋值失效的问题，采用materialForRendering或者强制更新材质的方式可能会有新的Material的创建过程导致的内存分配，这些在优化之后可能带来问题的点也是需要优化后进行验证的。 四、Mask的使用 除了小地图部分，游戏中比如头像、技能界面等处都大量地使用了Mask。当然通常情况下Mask不会带来像小地图那么高Overdraw，但是因为UGUI中的Mask需要一遍绘制过程，因此对于Draw Call的增加还是会有不少。而且Mask也存在边缘锯齿的问题，效果上UI同学也不够满意，因此我们针对像头像这样单张的Mask也进行了一下优化，具体的过程可以参考之前的Unity填坑笔记—《Unity填坑笔记（三）—ugui中针对单独图片的Mask优化》，比较详细地记录了整个优化过程。 这里补充两点： 在那篇文章的最后提到，我们自己拷贝了一个ThorImage类，开放部分接口然后继承。我们后来改成了从Image直接继承的方式，否则之前编写的游戏逻辑要在代码上兼容两种Image，会比较烦，这些向前兼容的需求也是在优化过程中需要额外考虑和处理的点。 针对滚动列表这样需要Mask的地方，一方面建议UI同学使用Rect Mask 2D组件，另外一方面为了边缘的渐变效果，为UI引入了Soft Mask 插件来提供边缘的渐变处理。放一张Soft Mask自己的效果对比截图，需要类似效果的朋友可以自己购买。 Soft Mask 插件效果对比 UWA针对我们项目的深度优化报告里提到：Soft Mask插件的Component在Disable逻辑里有明显的性能消耗，目前我们未开始针对这块进行优化，不过只在UI关闭的时候才有，所以优先级也比较低，想尝试的朋友可以提前评估下性能。 五、基于DoTween的动画效果优化 在游戏中，UI会大量使用了DoTween插件制作动画效果，以此来强调一些需要重点提醒玩家的醒目信息。DoTween是一个非常好用的插件，无论是对于程序还是对于UI来说，都可以经过简单的操作来实现较为好的动画效果。 然而，对于UGUI来说，DoTween往往意味着持续的Canvas的重建，因为动画通常是位置、旋转和缩放的变化，这些都会导致其所在的Canvas在动画过程中一直有重建操作，比如我们游戏中如下图所示的旋转提醒的效果： 不断旋转的提示信息 这一效果在DoTween中通过不断改变图片的旋转来实现的，在我们Profile过程中发现了可疑的持续Canvas重建，最后通常会定位到类似这样界面动画的地方。使用Shader进行优化，只需要把旋转的过程拿到Shader的vs阶段来做，同样是修改UV信息，材质代码的vert函数如下： v2f vert(appdata_t IN) { v2f OUT; OUT.vertex = mul(UNITY_MATRIX_MVP, IN.vertex); OUT.texcoord = IN.texcoord; OUT.texcoord.xy -= 0.5; //避免时间过长的时候影响精度 half t = fmod(_Time.y, 2 * UNITY_PI); t *= _RotationSpeed; half s, c; sincos(t, s, c); half2x2 rotationMatrix = half2x2(c, -s, s, c); OUT.texcoord.xy = mul(OUT.texcoord.xy, rotationMatrix); OUT.texcoord.xy += 0.5; #ifdef UNITY_HALF_TEXEL_OFFSET OUT.vertex.xy -= (_ScreenParams.zw-1.0); #endif OUT.color = IN.color; return OUT; } 注意，这里因为要求UI控件使用的是一张RawImage，因此UV的中心点就认为了是（**0.5， 0.5**）位置。通过参数可以做到从C#中传递UV的偏移和缩放信息从而兼容Image，但是因为材质不同导致本来就无法合批，所以使用Image和Atlas带来的优势就没有了，所以这里只简单地支持RawImage。 Tips：注意所使用贴图的边缘处理，因为UV的旋转可能会导致超过之前0和1的范围。首先贴图的采样方式要使用Clamp的方式，其次贴图的边缘要留出几个像素的透明区域，保证即使在设备上贴图压缩之后依然可以让边缘的效果正确。 另外使用材质进行优化的地方是自动寻路的提示效果： 常见的动态字体提示动画 最初UI想使用DoTween来制作，但是觉得工作量有点大所以想找程序写DoTween的代码进行开发，为了减少UI的Canvas重建，使用材质来控制每一个字的缩放过程。同样是在vert函数中针对UV进行修改即可： v2f vert(appdata_t IN) { v2f OUT; UNITY_SETUP_INSTANCE_ID(IN); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT); //根据时间和配置参数对顶点进行缩放操作 OUT.worldPosition = IN.vertex; half t = fmod((_Time.y - _TimeOffset) * _TimeScale, 1); t = 4 * (t - t * t); OUT.worldPosition.xy -= _VertexParmas.xy; OUT.worldPosition.xy *= (1 + t*_ScaleRatio); OUT.worldPosition.xy += _VertexParmas.xy; OUT.vertex = UnityObjectToClipPos(OUT.worldPosition); OUT.texcoord = IN.texcoord; OUT.color = IN.color * _Color; return OUT; } Tips：这里使用UWA群里一位朋友之前提供的三角函数近似的方法来略微减少一下指令消耗： sin函数的近似模拟 这一效果的实现不像之前的旋转效果那样只需Shader就可以了。这里需要额外处理的部分有如下几点： 1）逐个缩放的效果需要控制“自动寻路中...”这句话中的每一个字，因此在这个效果中，每一个字都是一个Text控件。正在缩放的字使用特殊的缩放材质来绘制，其他的字依然使用默认的UI材质绘制，这意味着需要2个Draw Call来实现整体效果。 2）vert函数中需要获取字体的中心点和长宽大小，然后进行缩放计算，也就是参数_VertexParmas的内容。经过测试，在UGUI中，顶点的位置信息worldPosition是其相对于Canvas的位置信息，因此这里需要在C#中进行计算，计算过程借助RectTransformUtility的ScreenPointToLocalPointInRectangle函数： Vector2 tempPos; RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentCanvas.transform as RectTransform, ParentCanvas.worldCamera.WorldToScreenPoint(img.transform.position), ParentCanvas.worldCamera, out tempPos); 这里的ParentCanvas是当前控件向上遍历找到的第一个Canvas对象。 Tips：这里，搞清楚了vs中顶点的worldPosition对应的属性之后，可以做很多有趣的事情，包括之前的旋转效果也可以不再旋转UV，而是对顶点位置进行旋转。 3） 关于_Time，它的y值与C#中对应的是Time.timeSinceLevelLoad。这里为了实现界面一开始的时候第一个字是从0开始放大的效果，需要从C#传递给Shader一个起始时间，通过_Time.y - _TimeOffset 来确保起始效果。 最后直接贴一下C#部分的代码好了，也很简单，提供给UI配置缩放尺寸、缩放持续时间和间隔等参数，然后通过协程来控制字体的材质参数： using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using DG.Tweening; using ThorUtils; using System.Collections; namespace KEngine.UI { public class UIAutoExpand : MonoBehaviour { public float ScaleDuration = 1; public float CycleInterval = 5; public float ScaleRatio = 0.5f; private Canvas ParentCanvas; private Image[] images; private Vector4[] meshSizes; private int currentScaleIdx = -1; private Coroutine playingCoroutine; private static Material UIExpandMat = null; private static int VertexParamsID = -1; private static int TimeOffsetID = -1; private static int TimeScaleID = -1; private static int ScaleRatioID = -1; void Awake() { //初始化静态变量 if (UIExpandMat == null) { UIExpandMat = new Material(Shader.Find("ThorShader/UI/UIExpand")); VertexParamsID = Shader.PropertyToID("_VertexParmas"); TimeOffsetID = Shader.PropertyToID("_TimeOffset"); TimeScaleID = Shader.PropertyToID("_TimeScale"); ScaleRatioID = Shader.PropertyToID("_ScaleRatio"); } UIExpandMat.SetFloat(TimeScaleID, 1 / ScaleDuration); UIExpandMat.SetFloat(ScaleRatioID, ScaleRatio); if (ParentCanvas == null) { Transform trans = transform; while(trans != null) { ParentCanvas = trans.GetComponent (); if (ParentCanvas != null) { break; } trans = trans.parent; } } if (ParentCanvas == null || ParentCanvas.worldCamera == null) { Debug.LogError("**The parent canvas of UIAutoExpand could not be empty!**"); return; } images = GetComponentsInChildren<Image>(); if (images.Length > 0) { meshSizes = new Vector4[images.Length]; } Vector2 tempPos; for (int i = 0; i < images.Length; ++i) { Image img = images[i]; tempPos.x = 0; tempPos.y = 0; RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentCanvas.transform as RectTransform, ParentCanvas.worldCamera.WorldToScreenPoint(img.transform.position), ParentCanvas.worldCamera, out tempPos); meshSizes[i].x = tempPos.x; meshSizes[i].y = tempPos.y; meshSizes[i].z = 0; meshSizes[i].w = 0; } } private void OnEnable() { playingCoroutine = StartCoroutine(PlayExpandAni()); } private void OnDisable() { if (playingCoroutine != null) { StopCoroutine(playingCoroutine); } } private void OnDestroy() { StartScaleImage(-1); if (playingCoroutine != null) { StopCoroutine(playingCoroutine); } } private void StartScaleImage(int idx) { if (images == null) { return; } if (currentScaleIdx > -1 && currentScaleIdx < images.Length) { Image preImg = images[currentScaleIdx]; preImg.material = null; } if (idx < 0 || idx >= images.Length) { return; } currentScaleIdx = idx; Image curImg = images[idx]; UIExpandMat.SetVector(VertexParamsID, meshSizes[idx]); curImg.material = UIExpandMat; } private IEnumerator PlayExpandAni() { while (true) { for (int i = 0; i < images.Length; ++i) { UIExpandMat.SetFloat(TimeOffsetID, Time.timeSinceLevelLoad); StartScaleImage(i); yield return Yielders.GetWaitForSeconds(ScaleDuration); } StartScaleImage(-1); yield return Yielders.GetWaitForSeconds(CycleInterval); } } } } Tips：这里使用了 Shader.PropertyToID 方法来减少给material赋值过程中的消耗，对于协程使用了一个Yielders类减少频繁的内存分配。 总之，基于Shader来对持续的DoTween动画进行优化，可以大大减少Canvas重建的几率。而Shader中基于顶点和_Time属性进行动画计算的消耗非常少，比如通常的Image只有四个顶点而已，再配合部分C#代码提供给材质必须的参数，就可以实现更加复杂的UI动画。尤其对于会长时间存在的动画效果，善用Shader可以兼顾效果和效率。 六、进度条 在进行战斗中的Profile的时候也是发现了每帧都有一个Canvas重建的过程，排查后发现是用于显示倒计时效果的进度条在持续地被更新导致的。 UGUI的进度条控件功能非常通用，但是层次很复杂，包括Background、Fill Area和Handle Slide Area三个部分。它是实现原理是基于Mesh的修改： Slider控件的更新过程 从上面的gif可以看出，当Slider的value更改的时候，Mesh会跟着调整。这可以做到一些UI想要的效果，比如让Fill中的图是一张九宫格的形式，就可以做出比较好看的进度条效果，保证拉伸之后的效果是正确的。 当你需要一条可能持续变化的进度条一直在显示的时候，比如倒计时进度，持续的Canvas重建就不可避免。 针对具体的需求，通过Shader来进行一个简单的ProgressBar也非常容易，通过对于Alpha的控制就可以做到截取的效果： fixed4 frag(v2f IN) : SV_Target { half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color; #if _ISVERTICAL_ON float uvValue = IN.texcoord.y - _UVRect.y; float totalValue = _UVRect.w; #else float uvValue = IN.texcoord.x - _UVRect.x; float totalValue = _UVRect.z; #endif #if _ISREVERSE_ON uvValue = totalValue - uvValue; #endif color.a *= uvValue / totalValue < _Progress; color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #ifdef UNITY_UI_ALPHACLIP clip (color.a - 0.001); #endif return color; } 这次是在PS阶段进行处理，当然也可以在vs中模拟顶点的缩放效果或者处理UV的偏移。为了支持垂直和反向，这里通过两个宏来进行控制。在实现了条状的进度条，然后准备根据UI的具体需求进行效果上优化的时候，UI同学表示设计方案修改了变成了圆形的进度条，而且是两个方向同时展示进度，最终效果如下图所示的效果： 红色部分的倒计时进度条效果 不要被酷炫的特效晃瞎眼，重点是红色的倒计时进度条部分。在PS中进行位置的计算，然后通过Alpha的值同样可以达到控制进度的效果： fixed4 frag(v2f IN) : SV_Target { half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color; float theta = atan2((IN.texcoord.y - _UVRect.y)/_UVRect.w - 0.5, (IN.texcoord.x + 1E-18 - _UVRect.x)/_UVRect.z-0.5); #ifdef IS_SYMMETRY color.a *= ((1 - _Progress) * UNITY_PI < abs(theta)); #else color.a *= ((1 - _Progress) * UNITY_PI * 2 < theta + UNITY_PI); #endif color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #ifdef UNITY_UI_ALPHACLIP clip(color.a - 0.001); #endif return color; } 这里用了一个消耗比较大的atan2指令来进行弧度值的计算，支持对称和非对称的两种方式，对称的方式用于上面的特殊进度条，非对称的方式用于下面这种环形的进度条。 UGUI针对Image提供了Filled的Image Type来做环形进度条的效果，其原理是根据角度来更改Mesh实现。 为了兼容Image中使用的Atlas，这里需要将uv信息设置给材质： /// /// 更新贴图的uv值到材质中 /// 注意：需要在Image更新的时候调用本逻辑 /// public void UpdateImageUV() { if (relativeImage != null) { Vector4 uv = (relativeImage.overrideSprite != null) ? DataUtility.GetOuterUV(relativeImage.overrideSprite) : defaultUV; uv.z = uv.z - uv.x; uv.w = uv.w - uv.y; relativeImage.material.SetVector(UVRectId, uv); } } 对于进度条的修改，尤其是环形进度条，是在Shader的PS阶段做的，因此消耗可能还比较大，相当于拿GPU换取CPU的消耗，有可能在某些设备上还不够划算。这个具体使用哪种方法更好，或者是否需要继续优化就看读者自己具体的项目需求了。 七、总结 针对UGUI的优化零零散散也做了不少，上面讨论到的是其中影响相对比较大的部分。 可以看到，虽然从结果上看，这些优化后使用的Shader技术都非常非常简单，大都是一些UV计算或者顶点位置的计算，相对于需要进行光照阴影等计算的3D Shader，UI中使用的Shader简直连入门都算不上，但是通过合理地使用它，配合部分C#代码逻辑，可以实现兼顾效果和效率的UI控件功能。 另外，虽然从结果看很简单，仿佛每一个Shader都只需要1-2个小时就可以完成，但是在排查问题和思考优化方法的过程中其实也花费了很多精力，有很多的纠结和思考。这些方案的对比和思考的过程由于时间关系没有全部反映在这篇博客里，但你从Tips和一些只言片语中也可以窥见到一些当时的心路历程……除此之外，由于项目已经到了中后期，还有不少时间花费在新控件的易用性和向前兼容的方面，以让UI和程序同学可以用尽量少的时间来完成对于之前资源的优化工作。 最后，我想坦诚地说，对于UGUI和基于Shader的方案，我没有进行定量的性能对比测试，所以也不能保证基于Shader的方法都一定效率更高，比如最后圆形的Mask就可能会是一个反例。我能做到的是尽量公正地从原理角度分析两者之间在Overdraw、DrawCall和Canvas重建方面的性能差异，也可能有考虑不全的地方，欢迎大家一起讨论~ 最后的Tips：除了一些“傻X”Bug引发的“神级”优化之外，大部分的优化都是琐碎而且成效不会那么直接、显著的工作。比如上面这些内容可能花费了我大约2个周左右的时间，还需要推进UI和程序对于已有的资源进行修改，而且面临着需求变更的问题……然而，面对优化工作还是那句话：“勿以恶小而为之，勿以善小而不为”。 另外，让团队中的每一个人都了解更多更深入的技术原理，拥有对于性能消耗的警觉，不至于让问题在最后Profile的时刻集中爆发出来，而是被消化在日常开发的点点滴滴之中，这也是我对于理想团队的期待。 这是侑虎科技第361原创文章，感谢作者贾伟昊供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者知乎：https://zhuanlan.zhihu.com/narrater，同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！
笔者利用Fast Shadow Receiver插件，通过CPU的实时计算来换取GPU的渲染性能的方案，解决了场景静态合批被动态阴影打断的问题，大大提升了游戏在中配下的帧率。本文详细记录了笔者从熟悉、应用测试到反复修改的过程，极具参考意义。 一、起因 关于Unity中的动态阴影，已经有挺多帖子聊过这个话题了，比如这篇《Unity移动端动态阴影总结》，还有钱康来博客里的这篇《利用Projector实现动态阴影》和《Planar Shadow》等等。 无论是最简单的基于Planar投影的方案还是稍微“老式”一些的Projector的方案，乃至目前比较主流的ShadowMap的方案其实都各有优劣和对应的应用场景，它们之间的原理和差异不是本文的重点，有兴趣的同学也可以很容易地找到相关的论文或者博客来看。 我们项目本着不要重复造轮子的想法，一直坚持使用Unity原生的ShadowMap的方案来做动态阴影。而且UWA也做过一些动态阴影方案效率的对比，自己的轮子能做得比有源码的官方好的并不多，更何况我们这种地表有起伏，高配需要支持多角色动态阴影的“大型”MMORPG游戏，ShadowMap已经是最适合的方案了。 然而！人生总会有然而！大约1个多月前，我发现了这个问题—《Unity中静态合批与ShadowMap的宏设置冲突问题》，简单来说，静态合批首先对场景物体进行了排序，保证结果正确，但是当引入了动态阴影之后，会去修改物体接受阴影的宏（这也是一种优化，因为有采样和阴影计算的消耗，所以关闭掉宏着色器的效率更高），导致原本排序好的物体无法正常进行合批，因为着色器的宏不一样了，从而导致之前静态合批之后理论上可以做到很低的Batch数值增加了很多，使得场景渲染的效率大幅下降。 在想清楚这个问题的原因之后，在依然想要使用Unity的ShadowMap的前提下感觉是没有什么特别简单的优化方案的，于是就暂时搁置下来，直到上周的时候对游戏各个效果对于帧率的影响在真机上做了一个定量的测试之后，才发现问题远比想象中的严重…… 各个效果对于帧率影响的定量测试结果 上面的测试是在中配机型小米Max2上进行的，可以看出阴影的开关与否导致一帧的时间消耗有9.5ms左右的差异，是所有效果中影响最大的！而ShadowMap自身渲染消耗不应该有这么大的差异才对，观察了下Batch数量的差异，单纯场景的Batch数量大约会从25增加到150左右，这有点超出我们之前制定的美术规范了。 在中配效果下，我们只有主角自己开启了动态阴影，因此最初的一个想法就是引入另外一套阴影绘制方案，比如Dynamic Shadow Projector，来专门针对主角进行阴影的绘制。虽然我个人很不喜欢同时使用两套技术方案，但目前看起来这似乎是在不降低效果的前提下唯一的选择了。 二、Dynamic Shadow Projector插件 This simple Unity asset provides a few components to render a shadow onto a render texture so that the render texture can be used with Blob Shadow Projector. Blob Shadow Projector is usually used for dropping a round blurry shadow which is not suitable for a skinned mesh object. This asset enables a projector to drop a dynamic shadow which is perfect for skinned mesh objects. Dynamic Shadow Projector插件的原理比较简单，将角色的阴影绘制到一张rt上，然后使用Unity的Projector组件将这张rt作为绘制输入，再绘制一遍接受阴影的物体。阴影的rt是每帧更新的，也就做到了可以让带有动画的角色阴影是实时变化的。 试用了一下，还是比较简单易上手的，几个组件正确设置之后就可以看到效果了，由于是针对单个角色的，因此使用比较小的rt就可以做到比ShadowMap更加精细的效果，但是如果想让一个Projector处理多个角色，一旦扩大Projector的范围，阴影效果质量的下降就比ShadowMap的方法还要厉害。 128×128的rt只投影一个Cube的情况下rt的使用率和阴影质量 512×512的rt投影三个Cube的情况下rt的使用率和阴影质量 上面两张图分别给出了模拟使用一个Projector针对单个角色进行投影和多个角色进行投影的效果对比图，在下面的那张图中，三个Cube的距离相隔并不远，但是即使使用了512*512的rt，明显可以看到其阴影已经有了锯齿感，距离更大的时候锯齿更加严重。 那我为什么纠结于一定想要使用一个Projector来进行多个角色的动态阴影绘制呢？因为对于每一个Projector来说，绘制阴影的时候都需要把接受阴影的模型完整重回一遍，从下面抓帧的截图可以看出，三个Cube分别使用三个不同的Projector，地表平面需要绘制三遍。这其实就是Projector的方法不太适合移动设备上多个物体都需要进行动态阴影绘制的原因。 多个Projector的时候接收阴影的地表绘制抓帧截图 我们的地表使用了Terrain制作，转为Mesh之后的三角形数量一般在大几千的水平，多遍绘制对于整体面数的增加还是很可观的，虽然在我们的中配下只有主角接受动态阴影，只需要多一遍地表模型的绘制，拿一次Draw Call和几千面的消耗换取100+次Batch的减少，理论上已经够划算了，但是我还有些不太甘心，于是想尝试下Dynamic Shadow Projector推荐配合“服用”的Fast Shadow Receiver插件。 三、Fast Shadow Receiver的试用 Fast Shadow Receiver插件是很久前我就关注过的一个插件，钱康来在他的博客里也有提到。我一直保持一个敬而远之的心态，一是因为从经验上来说ShadowMap没有接受阴影方需要重绘的问题，只是宏的改变，效率应该挺高的（没想到影响了Static Batching）；二是对于运行时对mesh进行暴力重建一直心存怀疑，担心其对于CPU和内存的额外压力。 购买了插件，将其引入我自己本地的项目工程，玩了玩Demo之后，尝试将其和Dynamic Shadow Projector结合一起使用。和AssetStore上对于这个插件的评论一样，这个插件的文档的确有些晦涩，大约玩了三四个小时的时间才正式在游戏中跑通整个流程，过程不详述了，几个小坑记录一下： 1、可能是官方被吐槽文档太难读，所以做了一套Wizard，一步步走教你怎么配置，然而我按照步骤做完之后并没有得到正确的结果，反而因为Wizard隐藏了背后的部分设置步骤导致我无法正确理解过程，从而难以排查原因。而且Wizard是针对特定的需求，未必是我自己想要的效果。最终我还是按照Demo工程里的组件逐个对照配置实现的效果。 2、LayerMask设定需要注意，为了优化效率，Projector组件上有Igore Layers的设定，在Draw Target Object上，也有Layer Mask的设定用于标识要绘制的节点下哪些Layer会被绘制，最终的ShadowReceiver组件也会属于某一个Layer，比如默认的Default。这几个Layer如果设定有问题，会导致最终没有影子被绘制出来。我因为这里的失误多花了1个小时的时间调试各种参数，如果你在使用中遇到了奇怪的问题，可以把自己设置的各种Layer梳理一遍，保证逻辑上的正确性。我当时的问题之一是把ShadowReceiver所在的GameObject归入到了Default Layer，而Projector又Igore掉了Default Layer，导致结果不正确。 3、Fast Shadow Receiver的插件制作者估计没有经受过中国美术的洗礼，除了文档晦涩之外，代码中对于容错的兼容考虑得也不周全……我们场景中有几千个物体，在最初测试的时候没有花心思标记所有的地表接受阴影的物体，索性将所有物体都进行标注，结果MeshTree的生成一直存在问题，查了下是因为我们场景中存在一个Mesh对象为miss状态的GameObject导致的，做一下兼容就好了，当然根本上也要美术去修复掉mesh miss的问题。 总之，经过一系列的尝试，最终在我们自己的工程内使用正式的美术资源跑通了整个流程，也对于Fast Shadow Receiver的原理有了更深的理解：它使用Mesh Tree这样一个继承自Scriptable的类在离线阶段来预计算并存储需要接受阴影的地表网格信息，并且提供BinaryMeshTree、OctMeshTree和TerrainMeshTree三种类型来应对不同的场景。运行时，它提供MeshShadowReceiver这样的组件，根据Projector的设定实时计算出来接受阴影的地方需要覆盖的那些面片，生成一个新的网格作为阴影接收者的网格对象进行渲染，从而做到可以将原本几千面的模型只需要几十个面就可以绘制出来，因为毕竟需要绘制动态阴影的只有镜头前的部分区域。 Fast Shadow Receiver的Demo中的示例截图 四、和ShadowMap的结合以及集成 在最初的设想中是针对单独的主角使用Projector方式的动态阴影，然后用Fast Shadow Receiver进行优化，在Demo中看到Fast Shadow Receiver支持ShadowMap的方案时也没有多想。后来在和同事讨论这个问题的时候聊到Projector的动态阴影方案和ShadowMap的动态阴影方案的优劣，被问到两种方案是不是有可能做一个结合，然后想起了在Demo中看到了使用Fast Shadow Receiver来优化ShadowMap的例子。正好也在纠结我们抽离式的战斗中在中等配置下的效果，如果使用Projector，需要多几张rt的绘制是否合算，那如果可以用Fast Shadow Receiver结合之前的Shadow Map方案，对于目前结构的改动是最小的，也不必引入第二套动态阴影的产生方案，只相当于用新的插件在中配下解决场景静态合批的问题，这似乎是非常理想的一个方案。 沿着这个思路，学习了一下Fast Shadow Receiver中关于ShadowMap的例子，看上去也非常简单。在理解了原理的情况下，只是让场景内的其他Render组件的Receive Shadow属性都更改为false，然后只让Fast Shadow Receiver生成的那样一个面片读取生成的ShadowMap进行阴影的绘制即可，这样额外增加1个Draw Call和几十个面的渲染消耗，就可以做到和之前相似的效果，中高配置的切换逻辑也更加简洁。 我们先来看一下最后经过修改敲定下来的制作步骤，然后再聊一些其中的设计细节。 1、统一将场景中的Mesh相关的组件放置到同一个GameObject下。 这一条原本没有一条硬性的规定，完全看场编同学自觉，其实整理之后Unity中的Hierarchy面板也会更加干净整洁； 场景Mesh统一放入ArtRoot根节点下 2、标记接受阴影的物体。 这一步是个有点琐碎的工作，需要美术标记出来哪些物体是接收阴影的，BinaryMeshTree是根据这些标记出来的物体来进行网格的预处理的。标记的物体过少会出现应当接受阴影的物体没有阴影效果，而过多会导致BinaryMeshTree的数据内容过多，加载变慢、检索速度降低，内存占用也会很多。由于我们目前只在中配下使用，所以对于这部分只要求地表和表现明显的物体加入到标记中。Fast Shadow Receiver只支持Layer和RenderType的过滤方式，在我们场景中有些物体已经被标记过了其他有逻辑意义的Layer，因此我针对这点进行了改造，增加了Tag的过滤，和Mask Layer取或的方式来进行处理，并且为美术提供了方便的快捷键进行快速标注。我自己测试，我们游戏内的场景，标注加上验证需要的耗时大约也就半个小时到2个小时不等。 提供FastReceiver的Tag进行标注 3、创建BinaryMeshTree。 我们最终选择使用BinaryMeshTree这种结构，它和OctMeshTree的区别见下图。其实这个步骤还需要更多的测试来做对比，因为官方也明说small和large的界限具体是什么。 两种不同的MeshTree对比 创建BinaryMeshTree的过程也很简单，插件提供了右键Create菜单的支持： 创建BinaryMeshTree 4、生成Mesh Tree。 在标注完接收阴影的物体之后，就可以选中创建好的BinaryMeshTree，填写其Root Object为场景的根节点，设置好Layer进行build。我们建议美术检查最后创建完毕之后给出的build信息中对于内存的占用要小于2MB，这是一个编辑几个场景之后的经验值而已，还需要更多验证。 Mesh Tree生成时Layer的配置 Build之后的Mesh Tree信息统计 5、配置Projector和Mesh Tree信息。 这部分为了简化美术的配置工作，大部分的配置逻辑都写在了代码中，只需要美术复制一份Prefab出来，将新创建的Mesh Tree信息设置正确即可。需要注意这份Prefab是不保留在场景内的，编辑完毕Apply后会从场景中删除掉。 创建BinaryMeshTree 这里一共只使用了两个组件，一个是图中LightProjector对象上的LightProjector组件，用于设置阴影使用的方向光对象以及一些Projector的参数，比如跟随的Target对象，扩展的Bound范围等；另外一个是MeshShadowReceiver组件，关联Mesh Tree数据，场景渲染物体的根节点和Projecter对象，一些Fast Shadow Receiver的裁剪、更新方式等属性也可以在这里进行设置。 6、在资源根节点上添加Shadow Receiver Controller组件，并进行配置。 这一组件是我们自己实现的，用于控制Fast Shadow Receiver的开关，它会根据游戏配置在场景加载、游戏配置切换等逻辑中对Fast Shadow Receiver进行设置。并且基于这一组件实现对于Mesh Tree的懒加载功能。 Shadow Receiver Controller组件配置 7、在游戏运行状态下进行测试。上述配置完毕之后，就可以在游戏逻辑的中等配置下看到优化后的阴影效果了，可以跑跑游戏进行测试。 大部分细节已经在上述步骤中描述了，这里再说明以下几个地方： Projector和MeshShadowReceiver组件是不默认放在场景里的。 这是由于当地表物体较多的时候，Mesh Tree的加载是有时间消耗的（遇到过一个测试例子，Mesh Tree的大小有18M左右，在PC上需要5s以上的情况，具体原因没有细查），也会有额外的内存消耗，因此这里一方面建议美术确保这个文件不会特别大，另一方面通过Lazy Load的方式，在需要的时候才加载，来保证在高配和低配的情况下，不需要任何额外的CPU和内存开销。 为美术提供更多便利的工具来标记信息。 由于标记地表是一个相对琐碎的工作，验证标记是否合理也是一个件需要花费很多时间和精力的事情，除了前面提到的快捷键可以一键标注，还推荐通过Layer的显隐功能，以及我们自己开发的Tag显隐功能进行快速检查和问题定位。 Unity原生的Layer过滤功能 五、优化结果和代价 使用同样的测试方式，对比优化前后的游戏运行帧率和时间消耗： 优化前后的性能消耗对比 可以看到，使用Fast Shadow Receiver在小米 Max2上有大约7.2ms的性能提升，帧率从26上升到33，这其中有Batch数量降低的功劳，应该也有场景物体不需要采样ShadowMap贴图带来的渲染性能提升，更加具体的数据就没有去测试了。剩余的1.5ms的时间消耗包括了ShadowMap的绘制以及Fast Shadow Receiver的更新消耗，这是后续的优化对象，但这次优化已经有很大的提升了，中配下整体效率提升了20%，已经是难得的“神级优化”了。当然，这建立在场景通过关闭Shadow接收的宏能够降低较大Batch数量的前提下。 这次优化的收益是很大的，但它也不全是一种无损优化，需要付出的代价有这么几点： 1、美术工作量 需要美术同学针对场景进行地表接收阴影物体的标注，虽然提供了快捷的工具，但是依然需要花费一些时间成本。 2、部分物体不再会受到动态阴影的影响 在之前基于ShadowMap的方案中，几乎所有的物体都可以标记为接收阴影，而且可以保证效果的正确性，但是目前这种方案如果要做到这点会导致Mesh Tree对于内存的占用较多，对于外部的大世界场景也不适应，因此会有出现一些小石头等物体不会接收角色阴影的问题，这是一些效果的降低，但目前看是可以接受的范围内。 3、和静态阴影的融合与ShadowMap的方案不同 ShadowMap的方案是在场景绘制的时候进行处理的，一次像素着色的过程中会采样Lightmap和ShadowMap两张贴图，这就可以判断出该像素点是否在静态阴影之中，这样可以做到比如在屋檐下或者树荫下这样的静态阴影中，角色的实时阴影可以和静态阴影做一个较好的融合，如下图所示。 基于ShadowMap的方案动态阴影和静态阴影的融合效果 而使用Fast Shadow Receiver方案之后，就比较难做融合的效果，除非在新生成的mesh中保存之前mesh的uv2信息以及使用的Lightmap贴图信息，再做一次Lightmap的采样。但这比较麻烦，性价比也不高，于是在静态隐形中的角色动态阴影的效果就变成了如下图所示的样子。 使用Fast Shadow Receiver方案的效果 除了这些之外的代价就是程序这边花费了大约半个多星期的时间来学习和集成这套方案，但是从优化结果上看，还是收获很大，非常值得的~ 六、一个Projector同时处理多个角色的动态阴影 由于我们是类似回合制的抽离式战斗方式，即玩家进入战斗后整场战斗都会发生在一小块固定区域内，这里其实对于ShadowMap结合Fast Shadow Receiver的方案是一个非常合适的应用场景——只需要在进入战斗前生成一次阴影接收的面片，整场战斗中都不需要对其进行修改和变动！ 我们将LightProjector的Target锁定为战斗的中心区域点，然后通过修改Bound的方式扩大其投射范围到整个战场。前面已经讨论过基于Projector的动态阴影方案的一个问题是当Projector较大的时候rt的使用率较低，导致阴影质量骤降的问题，但因为我们使用的是ShadowMap的阴影方案，因此扩大Projector的范围并不会影响阴影精度，也不需要处理多个Projector带来rt数量、DrawCall增加等问题。 战场中多个角色共用一个Light Projector的方案 七、总结和展望 Fast Shadow Receiver这种通过CPU的实时计算来换取GPU的渲染性能的方案，正好解决了我们场景静态合批被动态阴影打断的问题，大大提升了我们游戏在中配下的帧率，是近期所做的优化中效果最为显著的一个了，因此也记录一下详细的过程在这里分享出来。 对于这个插件的感觉，在这一周的逐渐熟悉、应用、修改的过程中，也从心存怀疑到由衷赞叹。目前针对这个插件的魔改还不多，除了前面提到的增加Tag的支持、建立Mesh Tree的时候缺少一些对于资源的错误兼容之外，只修改了部分Component的默认参数，更加适合我们项目的设定，让美术和程序可以更加方便地使用。它在运行时对于内存的分配和CPU的性能消耗也让我们满意，因此在这里也帮这个插件做一下广告——别被它的文档和使用过程吓到，用好之后，你的游戏效率可以获得很大的提升~ 至于未来，当中配下的效果和效率都被验证可以接受之后，可能考虑优化一些它的效果，将它也应用到高配下，当然，对于贴花等需要处理高低不平地面效果的地方，也可以考虑使用这个插件进行效率的优化。 PS：从Fast Shadow Receiver的启发来思考场景静态合批被打断的问题，其实另外一个思路是自己来做哪些物体需要被接受阴影的判断。Unity内部肯定也是有这样的判定逻辑来设置各个场景Render的宏，由于Shadow的距离设定较大，Unity的判定范围也过广，导致了虽然我们在中配下只有角色渲染阴影，但是接收阴影的物体数量过多，从而导致Batch被频繁打断的问题。仿照Fast Shadow Receiver，使用一个跟随角色的投影，和场景物体相交来判断有哪些物体需要被设置为接收阴影，由于角色脚下的物体可能只会有几个，因此Batch的数量也只会增加几个。目前没有沿着这个思路来做的原因之一也是地表物体的面数实在是有点多，Fast Shadow Receiver对于面数的降低也是我们想要的优化之一。 这是侑虎科技第342原创文章，感谢作者贾伟昊供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者知乎：https://zhuanlan.zhihu.com/narrater，同时，作者也是U Sparkle活动参与者哦，UWA欢迎更多开发朋友加入U Sparkle开发者计划，这个舞台有你更精彩！