UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技
UWA博客，UWA旗下官方博客。针对游戏及VR应用性能优化等内容记录、交流，以及案例分享。博客主要分为：厚积薄发、万象更新、博观约取、充电一刻、博物纳新等几个模块。
核心玩家是游戏长线运营的核心底盘 —— 他们活跃社区、带动口碑、稳定贡献营收，同时对卡顿、掉帧、发热、闪退等性能异常高度敏感。一次不起眼的体验波动，都可能持续消耗好感，最终导致流失。 在与大量游戏厂商的合作中我们发现：性能优化不只是解决技术问题，更是守住核心玩家的真实游玩体感。为此，UWA GPM 2.0全新上线「核心玩家守护」专属能力，跳出笼统的全局数据大盘，聚焦高价值用户体验痛点，以轻量化、全链路、精细化监控，全程护航核心玩家全周期游戏体验。 精准分层识人 告别笼统评估，聚焦核心玩家体验 常规全局性能监控存在明显盲区：整体数据看似平稳，核心高价值玩家却可能早已遭遇持续卡顿、帧率不稳。宽泛的统计标准无法匹配分层玩家体感，优化方向难以精准聚焦。 分桶体系所呈现的分组概览 GPM 2.0搭建核心玩家专属分桶体系，支持按高价值、高潜力、高影响力等维度维度灵活划分，为每类核心玩家建立独立体验监控。 基于线上真实运行数据，自动输出各组别健康状态，风险等级一目了然 按问题权重自动划分P0/P1/P2三级告警，清晰标注影响人群与故障规模 支持自由管理成员、自定义备注与标签，玩家画像清晰直观 可一键开启全量采集，配合客服精准服务核心用户 结合AI智能体自动分析，动态调整名单、支持临时专项分组，适配版本更新、大型活动等场景 自动输出优先级优化建议，让研发资源聚焦核心体验攻坚 成员管理页面 分组概览中快速进行AI分析 全维数据佐证 真实体验可量化，优化迭代可追溯 核心玩家精细化守护，拒绝经验主义预判，所有优化必须以客观数据为依据。GPM 2.0配套「性能分布看板」，实现核心玩家体验全维度可视。 性能分布看板 按游戏会话/活跃玩家双视角，精准观测核心群体性能表现 支持按天/小时粒度切换，锁定晚间高峰、团本、大战场等高负载场景的隐性波动 优化后可与历史基线一键对比，直观呈现优化前后差异 支持一键回归分析，结合AI可自动验证优化效果，让迭代有据可依 性能分布看板中的对比分析 告警推送处置 直达异常现场，响应快人一步 线上隐患最忌“发现晚、响应慢”：隐性性能问题被数据淹没，等到玩家投诉、舆情发酵，口碑与留存已遭受损失。 GPM 2.0提供核心玩家专属告警处置工作台： 可根据玩家分桶触发告警，直接展示问题归因、受影响核心玩家名单与设备信息 支持自定义告警阈值与力度，可对高价值用户收紧监测标准，单人异常即可预警 灵活配置通知渠道，告警实时触达负责人 完整记录MTTA（平均首次确认时长）、MTTR（平均恢复/关闭时长），实现处置流程可量化、可闭环 提前阻断体验劣化，远超传统“客诉→反馈→修复”的被动流程 告警处置工作台 告警处置详情 定义告警处置规则 流失风险预判 读懂沉默信号，提前拦截流失 核心玩家流失并非突发行为，而是长期体验不佳累积的结果。帧率下跌、发热严重、内存过载、战斗卡顿…… 每一次细微体感下降，都在埋下流失隐患。事后挽回成本极高，前置预判、主动干预才是最优方案。 GPM 2.0核心玩家流失预判能力： 自动记录核心玩家活跃变化、性能问题、告警记录等关键信息，并创建标签 量化各异常标签的流失权重，自动标记高风险流失人群 溯源标注流失根因：低帧率、高卡顿、内存超标、温控异常等一目了然 提供流失对比分析，横向对比留存与流失玩家性能数据，精准定位留存关键短板 生成全周期流失台账，展示流失占比、高危时段、最后活跃轨迹，输出可落地的关怀与优化方向 流失分析-确定性流失 流失分析-风险预警 流失分析-流失对比 从核心玩家分层看护、全维度性能数据追踪，到实时告警闭环、流失风险前置预判，GPM 2.0始终以真实体验为出发点，把精细化守护落到每一处细节。未来我们会持续贴近项目需求，不断迭代产品能力，并持续深化AI在数据分析、报告解读、问题归因上的应用，让效率更高、判断更准、落地更轻松，与研发和运营一路同行，共同守护核心玩家，助力游戏长线稳健生长。 立即体验！ 专属通道限时开放 ➡ 预约技术专家1V1部署指导！我们将全程支持服务搭建、数据分析与反馈，确保您能够充分体验GPM 2.0带来的价值。 邮件：sales@uwa4d.com 微信号：17502188376 了解GPM 2.0全功能，可以点击《GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》查看详情。 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《3 月刊｜GPM 2.0性能数据探索 & 单玩家追踪升级，精细化性能分析再突破》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 GPM 2.0新功能 集锦 《数字助理已到岗！GPM AI 智能体发布，游戏性能分析进入全自动时代》 《GPM 2.0全新功能发布｜GPU精准监测 + 精细化运营，重构游戏性能管控新范式》 《GPM 2.0 SDK 热更新｜不打扰玩家的“隐形升级术”！》 《GPM 2.0功能尝新｜AI识图精准捕获材质丢失，全链路护航游戏品质！》 《UWA GPM 2.0 崩溃上报功能全面升级｜全场景覆盖+免费开放，让崩溃排查不再卡壳》 《大R流失、问题难复现？GPM 2.0「指标 - Session直连」功能，让异常定位效率翻倍！》 《从 “被动救火” 到 “主动掌控”：GPM 2.0 如何重塑游戏性能竞争力？》 《GPM 2.0 告警功能：游戏性能的智能守护者》 《无限接近真相｜GPM 2.0 功能上新》 《崩溃现场全回溯｜GPM 2.0「崩溃上报分析」上线，终结“无法复现”的噩梦》 《GPM 2.0 全新发布「卡顿帧堆栈」功能｜精准高效定位游戏玩家端性能》 《深入解析「卡顿帧堆栈」》 《解锁GPM 2.0「卡顿帧堆栈」｜代码示例与实战分析》 《如何精准打点解决卡牌、SLG、开放大世界、放置类游戏卡顿难题》 《不妥协每一毫秒的性能 不浪费每一字节的流量》 《GPM 2.0 重新定义性能监测与优化｜从未知到已知，从被动到主动》 《UWA GPM 2.0 重新定义性能监测与优化｜如何高效预警和定位玩家端的性能问题》 GPM 2.0案例精讲 集锦 《携手护航亿级体验｜《迷你世界》× UWA GPM 2.0 性能管控最佳实践》 《我们的目标是不耗电、不发烫、不闪退》 《冒险闯关类玩家关注的四大体验》 《玩家端性能数据 一手掌握》 《造成设备功耗高的罪魁祸首》 《海外市场设备适配性优化实战》 《5000人测试背后的秘密》 GPM 2.0实操篇 集锦 《GPM 2.0 按游戏类型定制性能监控策略｜「告警」功能实战指南》 《GPM 2.0 让每一帧的流畅兑换真金白银》 《GPM 2.0 实操之Session篇之如果闪退是因为内存崩了》 《GPM 2.0 应用场景实例之解决让人头疼的问题》 《GPM 2.0 助力设备监测、行为分析与性能优化｜玩家共测新体验》 《GPM 2.0 API 功能助力定制你的性能优化》 《GPM 2.0 驱动优化与投放决策｜硬件性能一目了然》 《GPM 2.0 多维数据与实时截帧完美结合》
Shader内存为何居高不下？变体管理是关键
1）Shader内存为何居高不下？变体管理是关键 2）字体资源冗余的常见原因与定位方法 这是第473篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们项目在使用UWA GOT Online测试后，发现报告中显示Shader峰值在200MB，远超UWA推荐值（<60MB），且在不同场景下波动较大，这个现象合理吗，后续应该如何优化Shader？ A：从GOT Online报告可以看到，项目Shader内存峰值约200MB，且常驻Shader内存在不同场景下存在明显波动。单从Shader资源内存的走势曲线即可判断，当前Shader内存管理存在一定问题。 我们可以通过指定帧来具体定位一下Shader内存偏高的原因。选中指定帧按内存占用倒序排序，会发现大量单体Shader存在占用过高的情况。建议在移动端游戏项目中重点关注所有单体占用超过1MB的Shader，这类Shader基本都存在优化空间，例如Built-in管线的Standard、UPR管线中的UberPost、Lit等Shader。 另外由于Shader内存占用与变体数量存在极强的关联性，Shader内存本质上反映的是编译后文件的复杂程度，核心取决于两个关键因素：单个变体的计算量，以及变体总数，两者可近似视为乘积关系。 通常我们不会为了降低Shader内存而牺牲单个变体的计算复杂度 —— 这会直接影响画面表现，因此优化的核心切入点，应聚焦于控制变体数量。举例来说，一般URP项目中的UberPost Shader内存占用约21MB，此时它的对应变体数量在2000个左右。因此除非是项目中自己写的Shader比这种还要复杂的多，否则变体的内存和数量的比例大致就会维持在这样的数量级。 结合该规律可进一步推断：项目中各类高占用单体Shader，其内存偏高的核心原因，大多是大量冗余变体长期驻留。反观常规移动端项目，即便复用范围较广的Shader，实际运行中真正参与渲染的变体往往仅有数百个甚至更少，大量未启用的无效Shader变体长期驻留，持续造成内存冗余与资源浪费。当前报告中排名靠前的这些Shader推测都会有数千个变体进入内存，可以用UWA的在线AssetBundle检测工具进一步排查变体的数量和关键字组合情况。 对此，可借助OnProcessShader等回调节点定制逻辑，精准拦截并屏蔽渲染流程内无用的宏关键字组合及多余变体。由于变体规模会随关键字叠加呈指数级扩张，因此清理冗余关键字、精简无效组合后，Shader变体总量与内存开销均可大幅缩减，整体优化收益十分显著。 综上，再结合我们以往的项目优化经验，更推荐的做法是：在Shader效果稳定后进行统一管理，完成变体收集，将ShaderVariantCollection文件与Shader资源统一打包到同一AssetBundle，在游戏启动阶段集中加载、缓存与预热。该优化方案可有效控制Shader内存开销，使其维持在合理区间，最终在内存走势图上呈现平稳无波动的线性状态。 实战案例 Q：我们在UWA GOT Online报告中发现了字体资源存在冗余，数量峰值显示有多个相同名称的资源同时驻留在内存中，这是什么原因导致的？ A：资源重复冗余最常见的原因之一，是未按依赖关系正确打包。若同一字体资源被打入多个不同AssetBundle包，且多个AssetBundle包在运行期同时加载，就会造成该资源在内存中重复实例化，最终出现同名资源多份驻留的现象。该问题具备普遍性，并非仅存在于字体资源，贴图、模型、Shader等各类资源均会出现同类情况。后续若发现项目资源数量异常、内存峰值偏高，可优先排查是否存在因AssetBundle打包导致的冗余问题，这也是游戏开发中最典型、最常见的资源冗余类型之一。 此类问题可借助UWA的在线AssetBundle检测工具快速完成问题定位：通过解析完整资源依赖链路，精准筛选出被重复打包的资源，高效排查冗余根源。除此以外，不合理的资源加载与释放策略，也会引发资源冗余情况，需要结合项目资源生命周期管理逻辑，进行针对性排查与优化。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
携手护航亿级体验｜《迷你世界》× UWA GPM 2.0 性能管控最佳实践
作为全球月活突破1.1亿的国民级3D沙盒游戏，《迷你世界》凭借超高自由度与庞大UGC生态，持续为亿万玩家带来丰富的创作与冒险体验。在长线运营中，项目团队始终以极致性能与稳定流畅为核心目标，面对自研引擎、海量UGC内容、亿级用户高并发带来的复杂性能挑战，选择与UWA GPM 2.0深度协作，以更高效、更精准的技术能力，实现性能管控全面升级，保障全平台、全机型用户的体验稳定。 本文将完整复盘《迷你世界》与GPM 2.0的合作实践，展示头部沙盒产品如何借助专业工具，在自研引擎适配、多端体验统一、崩溃高效回溯等环节提质增效，为亿级用户量项目的长线运营提供可复用的行业方案。 ▍ 沙盒无界，性能求精 ——《迷你世界》的长期运营挑战 《迷你世界》全球月活突破1.1亿、海外月活用户超千万，以高自由度创作与多元玩法构建起庞大生态。项目采用自研沙盒引擎，实现全平台互通与无限创作，《迷你世界》基于定制化技术架构持续演进，在渲染管线、物理逻辑与UGC资源加载等核心能力上形成了鲜明特色。与此同时，这也对性能工具的深度适配提出了更高要求，目前在数据采集完整性、指标覆盖广度以及崩溃溯源效率方面仍有进一步提升空间。为持续保持行业顶级体验，团队需要一套更贴合业务、更灵活高效的性能监控体系，快速响应玩家反馈，协助项目团队支撑亿级用户长期稳定运行。 ▍ GPM 2.0深度协同，助力自研引擎性能监控更高效 针对《迷你世界》自研引擎的定制化特点，GPM 2.0以灵活适配能力，在团队提出的精准监控架构下，提供了高效支撑，让性能监控与问题定位更顺畅。 1. 核心函数精准打点 通过跨语言API（C/C++/Lua），在地形生成、物理结算、UGC加载等关键节点灵活埋点，帮助团队实时捕获耗时信息，更高效定位大规模建造、批量方块生成等场景的卡顿根源。 2. Native崩溃堆栈完整解析 原生支持C/C++层崩溃捕获与解析，可直接对接物理引擎、渲染模块等自研逻辑，帮助团队更快定位偶现崩溃问题，缩短排查周期。 3. 自定义事件闭环追踪 围绕玩家创作、联机、发布、地图加载等核心行为上报事件，将性能数据与用户操作、设备状态强关联，助力团队更高效开展性能体验与商业化联合分析。 4. AI智能体赋能高效分析 支持自然语言性能查询，自动理解场景语义、完成数据筛选与分析推理，无需人工复杂配置，即可直接输出设备性能、场景卡顿等结论，进一步降低团队分析门槛、提升线上问题定位效率。 GPM Insights x AI 解读 ▍ 全设备性能基线管理，助力多平台体验统一更顺畅 《迷你世界》已覆盖移动端、PC 及海外多个地区，触达广泛用户与多层级设备生态，也因此对跨平台体验一致性的精细化管控提出了更高标准。GPM 2.0以全平台监控与设备分层分析能力，成为团队高效管控体验的得力助手。 1. 多端数据统一汇聚 支持PC、移动端数据集中管理，实时监控FPS、CPU、内存、GPU等核心指标，帮助团队快速定位跨平台性能差异。 2. 核心机型画像清晰 梳理出Top核心机型，针对不同档位机型标注“安全性能阈值”，例如对RAM≤4GB的低端机，将PSS内存峰值控制在2GB以内，避免闪退；对旗舰机则使用更精细的LOD层级，开放更多渲染特性，保障视觉体验。 3. 自定义数据采集逻辑 支持场景化数据上传控制，例如高敏感场景暂停、网络恢复后续传；不同玩家/设备对象配置不同上传触发阈值，减少团队额外运维成本。 ▍ 动态监控+崩溃溯源，高效应对沙盒高自由度挑战 沙盒玩法所带来的高自由度与丰富场景，充分体现了产品内容生态的活力；同时，由于场景组合更加多样，卡顿与低频崩溃问题的复现和定位也更依赖体系化能力建设。GPM 2.0全链路追踪能力，助力团队更快锁定问题、高效解决。 1. 场景化性能直观监控 按游戏模式标记场景，结合性能无损截帧，还原复杂地图与高负载建筑下的卡顿现场，大幅提升团队问题复现与定位效率。 2. 崩溃信息全维度回溯 崩溃发生时同步上报操作日志、场景配置、设备参数与完整堆栈，快速还原触发链路，帮助团队高效定位逻辑漏洞。 3. 高性价比私有化部署 支持本地化部署，计费模式贴合大用户量项目需求，成本只与项目真实崩溃数有关，不受MAU波动影响，助力团队长期稳定使用、控制运营成本。 崩溃/ANR/错误分析 ▍ 全引擎兼容覆盖，为各类项目提供高效支撑 《迷你世界》的深度落地实践，充分验证GPM 2.0具备强大的引擎适配能力，可高效支撑各类技术路线项目。 从《迷你世界》的成功协同，到全引擎生态兼容，UWA GPM 2.0始终以专业、高效、灵活的性能管控能力，成为上线项目的可靠助手，助力研发团队更高效地完成性能监控、卡顿定位、崩溃回溯、设备分层、自定义行为分析等工作，让优化更聚焦、迭代更顺畅，为长线运营与体验升级持续护航。 如需试用GPM 2.0或获取《迷你世界》同款高效性能监控方案，可联系UWA官方微信：17502188376，获取1对1技术支持。 了解GPM 2.0全功能，可以点击《GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》查看详情。 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《4 月刊｜GPM 2.0 核心玩家守护功能上线》 《3 月刊｜GPM 2.0 性能数据探索 & 单玩家追踪升级，精细化性能分析再突破》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 GPM 2.0新功能 集锦 《数字助理已到岗！GPM AI 智能体发布，游戏性能分析进入全自动时代》 《GPM 2.0全新功能发布｜GPU精准监测 + 精细化运营，重构游戏性能管控新范式》 《GPM 2.0 SDK 热更新｜不打扰玩家的“隐形升级术”！》 《GPM 2.0功能尝新｜AI识图精准捕获材质丢失，全链路护航游戏品质！》 《UWA GPM 2.0 崩溃上报功能全面升级｜全场景覆盖+免费开放，让崩溃排查不再卡壳》 《大R流失、问题难复现？GPM 2.0「指标 - Session直连」功能，让异常定位效率翻倍！》 《从 “被动救火” 到 “主动掌控”：GPM 2.0 如何重塑游戏性能竞争力？》 《GPM 2.0 告警功能：游戏性能的智能守护者》 《无限接近真相｜GPM 2.0 功能上新》 《崩溃现场全回溯｜GPM 2.0「崩溃上报分析」上线，终结“无法复现”的噩梦》 《GPM 2.0 全新发布「卡顿帧堆栈」功能｜精准高效定位游戏玩家端性能》 《深入解析「卡顿帧堆栈」》 《解锁GPM 2.0「卡顿帧堆栈」｜代码示例与实战分析》 《如何精准打点解决卡牌、SLG、开放大世界、放置类游戏卡顿难题》 《不妥协每一毫秒的性能 不浪费每一字节的流量》 《GPM 2.0 重新定义性能监测与优化｜从未知到已知，从被动到主动》 《UWA GPM 2.0 重新定义性能监测与优化｜如何高效预警和定位玩家端的性能问题》 GPM 2.0案例精讲 集锦 《我们的目标是不耗电、不发烫、不闪退》 《冒险闯关类玩家关注的四大体验》 《玩家端性能数据 一手掌握》 《造成设备功耗高的罪魁祸首》 《海外市场设备适配性优化实战》 《5000人测试背后的秘密》 GPM 2.0实操篇 集锦 《GPM 2.0 按游戏类型定制性能监控策略｜「告警」功能实战指南》 《GPM 2.0 让每一帧的流畅兑换真金白银》 《GPM 2.0 实操之Session篇之如果闪退是因为内存崩了》 《GPM 2.0 应用场景实例之解决让人头疼的问题》 《GPM 2.0 助力设备监测、行为分析与性能优化｜玩家共测新体验》 《GPM 2.0 API 功能助力定制你的性能优化》 《GPM 2.0 驱动优化与投放决策｜硬件性能一目了然》 《GPM 2.0 多维数据与实时截帧完美结合》
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ HPWater BSDF - 稍微基于物理的水体散射模型 实时水体渲染BSDF模型，支持体积散射、薄层SSS和背光透射。 本模型的散射模拟 直射光5°恒定厚度 直射光15°恒定厚度 直射光30°恒定厚度 第一张（厚度近似）后三张（不同直射光角度） 海浪厚度近似（法线）这个近似并不准确，但将就着用 可以看到不同厚度下，薄层SSS和透射经过不同厚度介质时，前向散射的分散性导致的散射形状 不同光向下的厚度近似模拟 与GGX BRDF的类比 本模型的结构设计参考了经典的GGX微表面BRDF，两者都将光照分解为几个物理项的乘积： GGX镜面公式： 水体散射公式： 两者的共同点： 都用几何项G表示入射光的有效投影面积 都用菲涅尔项F/T处理界面的能量分配 都将复杂光照分解为可独立计算的物理项相乘 主要区别： GGX处理的是表面微观几何的反射 水体BSDF处理的是体积介质的散射和透射 水体需要区分反射方向（diffR）和透射方向（diffT） 输入参数 相比GGX BRDF（只需要roughness和fresnel0），水体BSDF需要额外的体积散射参数。 材质参数（BSDFData）： 体积散射参数（WaterLightLoopData）： 派生量： 消光系数：μt = μa + μs（absorptionColor + scatterColor） 散射反照率：ω = μs / μt（决定吸收/散射比例） 光学深度：γ = μt · d（决定介质的“不透明度”） 整体概述 水体的光照与普通材质不同。光进入水中后会被吸收和散射，部分光在水体内部多次弹射后从表面出射回到摄像机。这个过程涉及：入射折射、体积散射、出射透射三个阶段。 本模型将水体散射分解为两个输出通道（遵循HDRP的CBSDF结构）。 关于diffR/diffT的命名： 在HDRP的CBSDF中，R = Reflection（反射方向），T = Transmission（透射方向）： diffR：光从表面的“同一侧”出射（入射和出射在法线同侧） diffT：光从表面的“另一侧”穿过来（入射和出射在法线两侧） 对于水体： diffR（宏观体积散射）：光从正面入射 → 水下散射 → 正面出射。光路在法线同侧，属于“反射方向”。适用于深水区域，通过Ray Marching累加散射。 diffT（薄层散射）：光从背面/侧面穿过薄层出射。光路穿过表面，属于“透射方向”。包含： thinLayerSSS：近似深水散射在薄层区域的延续，侧面和背面可见 backlitTransmission：光从背面直接穿透薄水层，看向光源时产生辉光 三个组件通过入射几何项自然分离，避免能量重复计算。 // ============================================================================ // 水体BSDF模型概述 // ============================================================================ // // 【物理场景：圆锥形薄层 + 深水区域】 // // ☀ 光源 // | // 波峰（薄层） thickness ≈ 0 // /\ // / \ ← 薄层：透射率高，fallback到深水 // / \ // / \ thickness渐变 // /________\ ← 圆锥底部，thickness = 1（薄层与深水边界） // | | // | 深水区 | ← ray marching 区域（macroScattering） // |__________| // ↓ // 水底 // // 【散射模型分解】 // // 1. 宏观体积散射 (diffR)： // - 正面入射 → 水下 ray marching → 正面出射 // - 处理深水区域的体积散射 // // 2. 薄层散射 (diffT)： // - 薄层 SSS：近似深水散射在薄层区域的延续 // - 背光透射：光直接穿透薄层，强前向散射 // - 处理波峰、浪花等薄水区域 // // 【薄层与深水的过渡】 // // 薄层（thickness 小）→ 透射率高 → 直接使用深水散射颜色（S_volume） // 厚层（thickness 大）→ 透射率低 → 使用薄层 SSS（近似深水累积） // // 通过 sss_transmittance 自动混合两者 // // 【出射菲涅尔】 // // T_exit = 1 - F(NdotV) 在 PostEvaluateBSDF 末尾统一应用 // // ============================================================================ 渲染流程总结 Step 1：入射计算 计算三个入射几何项（G_entry、G_sss、G_backlit）和入射菲涅尔透射（T_entry），决定多少光进入水体、进入哪个散射路径。 Step 2：体积散射（diffR） 对深水区域进行少量Ray Marching，累加多次散射的光量，输出S_volume。 Step 3：薄层SSS（diffT的一部分） 对薄层区域计算散射光量S_sss，使用非线性光程修正处理不同厚度，并与深水散射混合。 Step 4：背光透射（diffT的一部分） 计算光从背面穿透薄层的透射光，使用极强前向相位函数。 Step 5：出射透射 所有散射光经过出射菲涅尔透射T_exit后到达摄像机。 最终输出： diffR = G_entry × T_entry × S_volume diffT = (G_sss × S_sss) + (G_backlit × T_backlit × P_backlit) output = (diffR + diffT) × T_exit Step 1：入射计算 1.1 菲涅尔透射（Fresnel Transmission） 光在界面上一部分被反射，剩余部分透射进入水中。透射比例由菲涅尔方程决定。 数学公式： 水的f₀ ≈ 0.02，即垂直入射时只有2%的光被反射。 代码实现： // HPWaterBSDFLibary.hlsl: Line 200 float3 T_entry = 1.0 - F_Schlick(bsdfData.fresnel0, clampedNdotL); 1.2 入射几何项（Incident Geometry Terms） 三个组件各自有独立的入射几何项，根据NdotL的正负自然分配能量，避免重复计算。 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl float G_entry = clampedNdotL; // Line 204 float G_sss = 1.0 - G_entry; // Line 309 float G_backlit = saturate(-NdotL); // Line 375 分工表： 解释： 正面（NdotL>0）：大部分光穿过薄层进入深水，由diffR处理 侧面（NdotL≈0）：diffR无贡献，薄层散射thinLayerSSS主导 背面（NdotL<0）：光从背后入射，thinLayerSSS和backlitTransmission共同作用 Step 2：体积散射（Volume Scattering） 深水区域使用Ray Marching沿视线采样，累加每一步的散射光量。循环次数默认6次。 物理过程： 光进入水后沿视线传播 每走一小步，部分光被吸收、部分被散射 散射的光中，朝向摄像机的部分被累加 重复直到到达水底或视线结束 2.1 Beer-Lambert透射 光在介质中传播时按指数衰减。 公式： 代码实现： // HPWaterVolumetrics.hlsl: Line 202-205 float3 extinctionCoeff = absorptionCoeff + scatteringCoeff; transmittance = exp(-extinctionCoeff * crossDistance); 2.2 散射光计算 被消光的光中，一部分被吸收（变成热量），另一部分被散射（改变方向）。 公式： L：入射光 (1−T)： 被消光的光的比例（积分结果） μs / μt：散射反照率（被消光的光中有多少是散射而非吸收） P(θ)：相位函数（散射光中朝向摄像机的比例） 代码实现： // HPWaterVolumetrics.hlsl: Line 199-218 float3 CaculateScatteredLight( float3 originLight, // L: 入射光 float3 absorptionCoeff, // μa: 吸收系数 float3 scatteringCoeff, // μs: 散射系数 float crossDistance, // d: 光程 float3 phase, // P(θ): 相位函数 out float3 transmittance) { // μt = μa + μs float3 extinctionCoeff = absorptionCoeff + scatteringCoeff; // T = exp(-μt × d) transmittance = exp(-extinctionCoeff * crossDistance); // (1 - T): 被消光的光量 float3 extinguishedLight = originLight * (1.0 - transmittance); // μs / μt: 散射反照率 float3 scatteringAlbedo = scatteringCoeff * rcp(extinctionCoeff); // S = L × (1-T) × (μs/μt) × P(θ) float3 scatteredLight = extinguishedLight * scatteringAlbedo * phase; return scatteredLight; } 2.3 Henyey-Greenstein相位函数 相位函数描述光散射后的方向分布。水体主要是前向散射。 数学公式： g = 0.8：强前向散射（水体典型值） g = 0：各向同性 g < 0：后向散射 代码实现： // HPWaterVolumetrics.hlsl: Line 171-177 float HenyeyPhase(float cosTheta, float phaseG) { float g2 = phaseG * phaseG; float denom = 1.0 + g2 - 2.0 * phaseG * cosTheta; float mieScatter = (1.0 - g2) * rcp(pow(abs(denom), 1.5)); return mieScatter; } 2.4 混合相位函数（瑞利 + 米氏） 真实水体同时存在瑞利散射（分子级，波长相关，产生蓝色）和米氏散射（颗粒级，前向散射）。 数学公式： 代码实现： // HPWaterVolumetrics.hlsl: Line 187-196 float3 CaculateScatterPhase(float cosTheta, float phaseG) { // 瑞利散射（5%）：βR ∝ 1/λ⁴，短波长（蓝光）散射更强 static const float3 betaRayleigh = float3(5.8e-6, 13.5e-6, 33.1e-6); float rayleighPhase = (1.0 + cosTheta * cosTheta) * (3 / (16 * PI)); float3 rayleighScatter = betaRayleigh * rayleighPhase * 1e6; // 米氏散射（95%） float mieScatter = HenyeyPhase(cosTheta, phaseG); // 混合 float3 scatterPhase = rayleighScatter * 0.05 + mieScatter * 0.95; return scatterPhase; } 2.5 Ray Marching主循环 辐射传输方程的数值积分： 理论上，沿视线累加的散射光是一个积分： 其中S(x)是位置x处的散射光源项，T(x → eye)是从x到眼睛的透射率。 这个积分没有解析解，所以用Ray Marching做数值近似（黎曼求和）： 指数步进的数学推导： 近处对视觉贡献大（细节可见），远处贡献小（被衰减）。用指数步进可以在采样数相同时获得更好的精度。 设t∈[0,1] 是归一化采样索引（t=i/N），k=ln(EXP_FACTOR)： 当t=0时d=0，当t=1时d=0。步长为导数： 代码实现优化： 令currentExp=e^(k·t)=EXP_FACTOR^t，则： d=(currentExp-1)*kDenom dd=currentExp*kDD 每次迭代currentExp*=expStep（其中expStep=EXP_FACTOR^(1/N)） // HPWaterVolumetrics.hlsl: Line 294-345 // 指数步进参数预计算 float rcpCount = rcp(float(WATER_SAMPLE_COUNT)); // 1/N float kDenom = rcp(EXP_FACTOR - 1.0); // 1/(e^k - 1) float kDD = log(EXP_FACTOR) * rcpCount * kDenom; // k/(N·(e^k-1)) float expStep = pow(EXP_FACTOR, rcpCount); // e^(k/N) float currentExp = pow(EXP_FACTOR, Dither * rcpCount); // 抖动起始点 //WATER_SAMPLE_COUNT = 6 for (int i = 0; i < WATER_SAMPLE_COUNT; i++) { // d: 归一化采样位置 [0,1]，dd: 当前步的归一化步长 float d = (currentExp - 1.0) * kDenom; float dd = currentExp * kDD; float3 samplePos = RayStart + NoLinearRayDirection * d; float3 samplePosDynamic = RayStart + DynamicRayDirection * d; // 计算阴影 shadowValue = ComputeShadowValue(samplePosDynamic, featureFlags, lightLoopContext, posInput, bsdfData); if(i == 0) lastShadowValue = shadowValue; // ===== 一次散射：直接光照 ===== float cosTheta = dot(safeNormalize(samplePos), safeNormalize(LightDir)); float3 scatterPhase = CaculateScatterPhase(cosTheta, _PhaseG); float3 directLighting = LightColor * shadowValue; float directCrossDistance = dd * NoLinearRayLength + SunDepth * dd; // 先计算不带相位的基础散射（phase = 1） float3 baseScatter = CaculateScatteredLight( directLighting, AbsorptionCoefficient, ScatterCoefficient, directCrossDistance, 1, transmittance, scatteringAlbedo).rgb; // 多次散射效果：根据散射反照率决定相位的各向同性程度 // scatterAlbedo 高 -> 多次散射充分 -> 相位趋于各向同性 // scatterAlbedo 低 -> 单次散射主导 -> 保留方向性相位 float scatterAlbedo = Luminance(scatteringAlbedo) * GetInverseCurrentExposureMultiplier(); float3 effectivePhase = lerp(scatterPhase, 1.0, saturate(smoothstep(0, 0.5, scatterAlbedo))); float3 directScatteredLight = baseScatter * effectivePhase; // 累加 accumTransmittance *= transmittance; scatteredLight += directScatteredLight; currentExp *= expStep; } // ===== 水下场景散射（Scene In-Scattering）===== // 水下物体反射的光，穿过水体时也会被散射，产生「雾化」效果 float3 sceneLight = SceneColor * lerp(shadowValue, 1, 0.3); float lightIntensity = Luminance(LightColor); float3 sceneScatteredLight = sceneLight * accumTransmittance * ScatterCoefficient * NoLinearRayLength * lightIntensity; scatteredLight += sceneScatteredLight; 解释： EXP_FACTOR控制指数步进的疏密程度，近处采样密集以捕捉细节 Dither用于抖动起始点，减少带状Artifact effectivePhase在厚介质中趋于各向同性，模拟多次散射后方向性丢失 水下场景散射公式： 这是一个简化近似（避免对场景光再做完整Ray Marching）： Lscene：水下场景光（SceneColor，考虑30%阴影穿透） Taccum：累积透射率（场景光穿过水体的衰减） μs：散射系数（决定多少光被散射出来） d：光程长度（路径越长散射越多） Isun：光源亮度（调制系数，使场景散射与直接光散射亮度一致） 物理效果：让远处的水下物体看起来更模糊、更偏向水的散射色（雾化）。 Step 3：薄层SSS（Thin Layer Subsurface Scattering） 薄层（如波峰、浪花）厚度无法直接得知，只能用高度或法线近似。关键是正确估算光程，并处理与深水散射的过渡。 输入参数thickness： thickness是归一化的厚度值，范围 [0, 1]： 0=极薄（波峰顶部、浪花边缘） 1=最厚（薄层与深水区域的边界） 通常由高度场、法线或其他几何信息近似得到。代码中乘以HPWATER_SSS_PATH_SCALE（默认20米）转换为等效光程。 薄层与深水的几何关系： 波峰（薄层顶部）thickness ≈ 0 /\ / \ / \ ← 薄层区域 / \ /────────\ ← thickness = 1（薄层底部 = 深水顶部） │ │ │ 深水区 │ ← S_volume（ray marching 累积） │__________│ 薄层的底部与深水区域连接。当thickness接近1时，薄层实际上“看到”的是深水区域累积的散射光。因此： 薄层SSS不是独立计算，而是近似深水散射在薄层区域的延续 光程缩放（20米）较大，是因为要匹配深水Ray Marching的累积效果 Fallback机制：当thickness很小（透射率高）时，直接使用S_volume作为薄层的颜色 为什么相机与光源角度相近时，水面背面会稍亮？ 典型场景：夕阳下，人以较低视角看水面。此时相机方向V和光源方向L角度相近（都是低角度），水面波峰的背面会呈现微微透亮的效果。 这是相位函数、入射几何项和出射菲涅尔透射三者共同作用的结果： 1. 相位函数P(cosθ)的前向散射 cosθ=dot(-V, L)，V和L角度越近，cosθ越大 HG相位函数随cosθ增大而增大（前向散射特性） 散射光更多地朝向摄像机方向 2. 入射几何项G_sss在背面有值 背面入射时NdotL<0，导致G_entry=0 因此G_sss=1-G_entry=1，薄层SSS获得入射能量 正面入射时G_sss减小，薄层贡献减少 3. 出射菲涅尔透射fresnelTransmissionExit T_exit=1-F_Schlick(f0, NdotV) 法线朝向相机（NdotV大）→ T_exit大 → 压制小，光容易出射 法线不朝向相机（NdotV小）→ T_exit小 → 压制大，光难以出射 波峰背面的法线相对更朝向相机，所以T_exit较大，散射光能出射 三者的平衡： 夕阳低角度 + 人高视角看水面： 入射：G_sss ≈ 0.6~1（背面/侧面入射） ↓ 散射：P_sss 中等偏高（V、L 角度相近但非正对） ↓ 出射：T_exit ≈ 0.7~0.9（低视角有所衰减） 三者相乘 → 背面稍亮（自然的透光效果） 这就是为什么波峰薄处在这种视角下会呈现柔和的透光效果。 3.1 非线性光程修正（Nonlinear Path Length） 薄层SSS用单次计算近似深水散射的延续。光程需要随thickness增加而增长，以匹配深水Ray Marching的累积效果。 物理直觉： 薄层（d→0）：透射率高，fallback到S_volume，光程影响小 厚层（d→1）：需要更长光程来匹配深水累积散射的视觉效果 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 264-280 // 线性部分：薄层区域 float L_linear = thickness * HPWATER_SSS_PATH_SCALE; // 非线性部分：厚层区域，d² 让光程在 thickness→1 时增长更快 float scatterStrength = Luminance(bsdfData.scatterColor); float L_nonlinear = thickness * thickness * HPWATER_SSS_PATH_SCALE * (1.0 + scatterStrength); // 光学深度决定混合比例，τ 大时使用非线性 float opticalDepth = sss_extinctionScalar * thickness * HPWATER_SSS_PATH_SCALE; float nonlinearWeight = saturate(opticalDepth * HPWATER_SSS_NONLINEAR_STRENGTH); // 有效光程 float sssPathLength = lerp(L_linear, L_nonlinear, nonlinearWeight); 3.2 薄层散射计算与深水混合 薄层SSS是深水散射在薄层区域的延续。根据透射率与深水散射混合：透射率高说明水很薄，应该直接看到深水的散射颜色。 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 282-296 // P_sss：相位函数 float sss_cosTheta = dot(-V, L); float3 P_sss = CaculateScatterPhase(sss_cosTheta, _PhaseG); // S_sss：散射光计算 float3 sss_transmittance; float3 S_sss = WaterVolumeLightLoop::CaculateScatteredLight( LightColor, bsdfData.absorptionColor, bsdfData.scatterColor, sssPathLength, P_sss, sss_transmittance); // HPWaterBSDFLibary.hlsl: Line 309-329 // G_sss：薄层入射项 = 1 - 正面入射项 float G_sss = 1.0 - G_entry; // 薄层 SSS 输出 = 入射项 × 散射 × 阴影 × 补偿 float3 thinLayerSSS = S_sss * G_sss * lastShadowValue * HPWATER_SSS_SCATTER_BOOST; // -------------------------------- // 薄层与深水的混合（Fallback 机制） // -------------------------------- // // 薄层区域（thickness 小）： // └─ transmittance 高 → sssWeight 低 // └─ 直接使用深水散射颜色（S_volume） // // 厚层区域（thickness 大）： // └─ transmittance 低 → sssWeight 高 // └─ 使用薄层 SSS（近似深水累积散射的延续） // // 注意：两个分支都乘以 G_sss，保持入射分工一致 // float sssWeight = saturate(1.0 - Luminance(sss_transmittance)); thinLayerSSS = lerp(S_volume * G_sss, thinLayerSSS, sssWeight); 解释： G_sss=1-G_entry确保正面入射时薄层SSS为 0，能量由diffR处理 HPWATER_SSS_SCATTER_BOOST补偿单次计算与深水Ray Marching的亮度差异 Fallback机制让波峰顶部（极薄）直接看到深水散射颜色 两个分支都乘以G_sss，保证入射分工一致 Step 4：背光透射（Backlit Transmission） 当对准光源观察时，光从背后穿透薄水层产生辉光。这是对准太阳时水面发亮的原因。 与薄层SSS的区别： 薄层SSS：光散射后出射，方向较分散（g≈0.8） 背光透射：光几乎直穿，极强方向性（g≈0.998） 数学公式： 其中： Gbacklit=clamp(−N⋅L,0,1)：背面入射投影 Tbacklit=e^(−μt⋅d)：Beer-Lambert透射 Pbacklit=PHG(cosθ,0.998)：极强前向相位 代码实现： // HPWaterBSDFLibary.hlsl: Line 370-392 // G_backlit：背面入射投影 float G_backlit = saturate(-NdotL); // 背光专用光程（比 SSS 短，纯穿透路径） float backlitPathLength = thickness * HPWATER_BACKLIT_PATH_SCALE; // T_backlit：Beer-Lambert 透射 float3 T_backlit = exp(-sss_extinctionCoeff * backlitPathLength); // P_backlit：极强前向相位（g = 0.998） float backlit_cosTheta = dot(V, -L); float P_backlit = HenyeyPhase(backlit_cosTheta, 0.998); // 输出 float3 backlitTransmission = LightColor * G_backlit * T_backlit * P_backlit * lastShadowValue; 解释： G_backlit只在背面（NdotL<0）有值 backlit_cosTheta=dot(V, -L)是摄像机看向光源穿透方向的夹角 g=0.998意味着只有几乎正对光源时才能看到背光透射 HPWATER_BACKLIT_PATH_SCALE比SSS的scale小，因为背光是纯穿透 Step 5：出射透射（Exit Transmission） 散射光从水内出射时，再次经过菲涅尔透射。 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 98 (PostEvaluateBSDF) float3 fresnelTransmissionExit = 1.0 - F_Schlick(bsdfData.fresnel0, clampedNdotV); lightLoopOutput.diffuseLighting *= fresnelTransmissionExit; 最终输出组合 数学公式： 代码实现： // HPWaterBSDFLibary.hlsl: Line 207, 220 float3 underwaterLight = G_entry * T_entry; float3 macroScattering = S_volume * underwaterLight; // HPWaterBSDFLibary.hlsl: Line 312 float3 thinLayerSSS = S_sss * G_sss * lastShadowValue * HPWATER_SSS_SCATTER_BOOST; // HPWaterBSDFLibary.hlsl: Line 392 float3 backlitTransmission = LightColor * G_backlit * T_backlit * P_backlit * lastShadowValue; // HPWaterBSDFLibary.hlsl: Line 440-441 cbsdf.diffR = macroScattering; cbsdf.diffT = thinLayerSSS + backlitTransmission; 参数说明 薄层SSS参数 背光透射参数 体积散射参数 视觉效果分区 物理参考 Beer-Lambert定律：光在介质中的指数衰减 Henyey-Greenstein相位函数：描述散射的角度分布 菲涅尔方程：界面反射/透射比例 辐射传输方程（RTE）：体积散射的理论基础 这是侑虎科技第1971篇文章，感谢作者hulalalala供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/ou-yang-xuan-58-47 再次感谢hulalalala的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
1）GPU Clocks正常但带宽高怎么办 2）FixedUpdate一帧调两次，这个"追帧"真的有必要吗 这是第472篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：在我们的项目中，用UWA GOT Online测了一个新坐骑场景用例的报告，单看GPU Clocks数据信息，表现还可以，基本没碰红线，按理说不太像是典型的GPU算力瓶颈。但问题是，带宽这边还是有点高。像这种情况，后面到底该先排查什么？是继续盯Fragment、三角形这些指标，还是该往别的方向看？ A：这个问题其实挺典型的，一般都会下意识觉得：GPU指标有波动，那是不是面数高了、Shader重了、像素太多了。就比如说Clocks，它是像素数量乘以Shader复杂度，只看像素数量，其实不一定能反映Clocks是特别高还是特别低，如果Clocks本身已经没超，那说明至少从“GPU算力被打满”这个角度看，当前这个场景在这档设备上问题不算大，Fragment Shader影响就比较小了，可以先不用关注。真往下查，还是要回到带宽到底是被谁拉高了的角度去查。 带宽压力一般会去从下面这两个角度进行考虑，一个是纹理，另一个是RT（顶点也可能有一定影响，不过当前报告中顶点的影响比较小）。 1. 纹理方面： 第一个纹理，一般是考虑纹理的压缩，比如说是ASTC 4×4、6×6或者8×8，原理就是你压得越厉害，带宽就肯定更小。 再一个要考虑3D纹理是不是开了Mipmap？假设用来做3D渲染的，如果不开，影响会比较大。而开启Mipmap的影响会比压缩大很多，因为不开Mipmap，它很容易Cache Miss，而打开，它就有缓存，可以从很大程度上减少带宽的开销。 还有一个就是各向异性。各向异性只会在那种视角很小的情况下，比如一个很长的地形，镜头拉得很远，看到比较远的地方会有一些毛刺，开各向异性才比较有收益。大部分情况下开各向异性其实没有什么好处，它只会增加带宽。 2. RT方面： 对于RT来说，影响带宽的主要因素是分辨率。RT分辨率越大，Fragment Shader数量就越多，每多画一个像素就要做纹理读取、Shader计算、寄存器存取等步骤，带宽就会越多。 再有，RT分辨率还会影响Load/Store次数。分辨率越大，要Load和Store的像素内容就会越多。如果能把分辨率降一下，带宽收益会非常高，顺带着Clocks也会更低。 再回到我们GOT Online的报告上来，从报告来看，分辨率影响最大。因为看起来RT的格式是比较正常的，穿插的也不是特别多，看上去只有一个Bloom的后期处理。所以还是分辨率的影响最大，如果用这个原始分辨率去做渲染，确实渲染的像素数量会非常多，切RT的时候，或者说Load/Store的时候也会有比较大的影响。 UWA以往优化的常规做法是把RT分辨率控制在1920×1080这个面积，约200万像素，而格式选32位而非64位。 实战案例 Q1：项目是帧同步，主要逻辑放在FixedUpdate里。UWA GOT Online报告显示，有些帧会出现FixedUpdate调用两次的情况，而且一旦跑两次，耗时明显就上去了。这个“两次调用”到底有没有必要？ A：一般追帧是为了保证物理模拟的准确性，比如子弹速度很快，如果不追帧，可能中间穿过去的墙就挡不住它了。但像这个项目的情况，应该不太会需要非常高速的移动或高速判定，所以理论上来说，触发两次是没有太多意义的，就需要做一下限制，可以在参数上做调整，就比如说Time里面的两个参数，一个是每一帧的上限，还有一个是每间隔多少时间触发一次，我们可以把每一帧的上限，调得更小一点，或者说把间隔调得更大一点。 Q2：但项目中是逻辑帧，间隔调得更大点可能就不合适了。 A：如果参数不好调，可以在代码里加一个硬核的限制。比如给它一个变量，判断这一帧有没有更新过，然后把更新过的变量变成True，第二次调用的时候因为变量是True就Return。这样不论上限设多少次，对这个游戏来说，FixedUpdate最多只调一次。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
数字助理已到岗！GPM AI 智能体发布，游戏性能分析进入全自动时代
研发天天翻报表？运营夜夜盯数据？问题总是用户反馈了才发现？ UWA GPM 2.0全新推出GPM AI智能体 —— 一位7×24小时不休息、不请假、不遗漏的数字助理，正式加入你的团队。 它不只是工具，更是自动读报告、自动查异常、自动给结论、自动盯风险的全能分析助手。从此，团队从海量数据中解放，把时间还给创作，把效率交给AI。 全链路智能洞察 不用翻报告，直接要答案 过去做性能分析，要开N个页面、对比N份曲线、查N天日志，半天才能摸到问题轮廓。数据越多，人越慢。 现在，GPM AI智能体直接给结论，从“人找问题”，真正变成“问题找人”。 它具备自然语言交互能力，你用日常说话的方式提问，它就能自动检索、自动统计、自动分析，分钟级输出结构化项目体检报告。不论你是研发还是运营角色，都能一眼看懂项目健康度，不用筛选、不用比对、不用人工汇总。 示例： 对数据总览（大盘数据）的分析 综合分析月度性能表现 多模态深度分析 多维度全方位关联，快速溯源 游戏卡顿、崩溃问题涉及多类信息，传统模式难以高效关联溯源。GPM AI智能体突破单一数据局限，打造多模态融合分析能力，将玩家会话、游戏截帧、性能趋势、卡顿/崩溃堆栈、错误信息等全方位整合关联，实现故障快速溯源。 AI 深度分析报告 AI 哨兵全自动预警 你不用盯，它替你盯 最可怕的问题，不是难解决，而是发现晚了。等玩家大量投诉，流失已经发生。 GPM AI智能体带来AI哨兵 —— 全程自动站岗，异常实时告警。无论你在GPM 2.0的任何页面，它都在默默巡检。帧率下跌、崩溃突增、卡顿上涨、稳定性异常…只要出现，立刻主动提示，不依赖经验、不需要培训、不用人工盯盘。 未来更将升级为7×24小时全天候巡检：深夜、节假日、无人值守时，一旦风险出现，AI哨兵第一时间预警，并同步分析与建议，从被动救火，真正变成主动防控。 精细化玩家行为分析 把海量数据，变成运营弹药 传统工具只能给宏观报表，看不清真实用户体验。GPM AI智能体不一样 —— 它能穿透到每一位玩家的行为数据。 自动完成多维度交叉分析，秒级定位卡顿、闪退、加载慢等体验痛点，自动输出证据链+影响范围+优化方案，把过去几天的工作量，压缩到几分钟。 更重要的是：它打通研发↔运营的数据壁垒，让技术问题可量化、可同步、可决策，为精细化运营、版本迭代、用户增长提供真正可靠的依据。 从人工排查→AI自动分析，从被动响应→主动预警，从模糊统计→精准洞察，GPM AI智能体不仅是GPM 2.0的重大升级，更是游戏行业智能数据分析的一次范式突破。这位7×24小时在岗的数字助理，以多模态AI分析、自动化AI哨兵、精细化行为洞察三大核心能力，为游戏研发与运营全流程减负、增效、提质。 未来，GPM AI智能体将持续深耕游戏场景、快速迭代能力，不断拓展AI在性能治理、崩溃分析、体验优化中的边界，助力更多游戏团队摆脱数据困境，聚焦核心创作与品质提升。 立即体验！ 专属通道限时开放 ➡ 预约技术专家1V1部署指导！我们将全程支持服务搭建、数据分析与反馈，确保您能够充分体验GPM 2.0带来的价值。 邮件：sales@uwa4d.com 微信号：17502188376 了解GPM 2.0全功能，可以点击《GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》查看详情。 关于GPM 2.0 UWA GPM 2.0是专为上线及测试阶段游戏项目打造的高性能监测平台。它不仅能深度捕捉宏观性能数据，更创新采用性能无损截帧技术，在不影响玩家体验的前提下，助力开发者全面掌握玩家端运行关键细节，从多维度优化游戏性能，实现从“玩家投诉后救火” 到 “问题发生前预警” 的核心转变。 GPM 2.0全景指南 《UWA GPM 2.0 核心功能全景指南｜重塑游戏性能主动管控能力》 《4 月刊｜GPM 2.0 核心玩家守护功能上线》 《3 月刊｜GPM 2.0 性能数据探索 & 单玩家追踪升级，精细化性能分析再突破》 《2 月刊｜GPM 2.0错误日志分析上线，PC端监测能力全维度突破》 《1 月刊｜日报周报一键获取+AI问答，玩家行为精准分析》 《12月刊｜上新！全局筛选+设备标记+序列帧回溯，GPM 2.0性能分析效率拉满》 《GPM 2.0 功能更新月报：新增导出表格、崩溃反混淆等实用功能，体验再升级！》 GPM 2.0新功能 集锦 《GPM 2.0全新功能发布｜GPU精准监测 + 精细化运营，重构游戏性能管控新范式》 《GPM 2.0 SDK 热更新｜不打扰玩家的“隐形升级术”！》 《GPM 2.0功能尝新｜AI识图精准捕获材质丢失，全链路护航游戏品质！》 《UWA GPM 2.0 崩溃上报功能全面升级｜全场景覆盖+免费开放，让崩溃排查不再卡壳》 《大R流失、问题难复现？GPM 2.0「指标 - Session直连」功能，让异常定位效率翻倍！》 《从 “被动救火” 到 “主动掌控”：GPM 2.0 如何重塑游戏性能竞争力？》 《GPM 2.0 告警功能：游戏性能的智能守护者》 《无限接近真相｜GPM 2.0 功能上新》 《崩溃现场全回溯｜GPM 2.0「崩溃上报分析」上线，终结“无法复现”的噩梦》 《GPM 2.0 全新发布「卡顿帧堆栈」功能｜精准高效定位游戏玩家端性能》 《深入解析「卡顿帧堆栈」》 《解锁GPM 2.0「卡顿帧堆栈」｜代码示例与实战分析》 《如何精准打点解决卡牌、SLG、开放大世界、放置类游戏卡顿难题》 《不妥协每一毫秒的性能 不浪费每一字节的流量》 《GPM 2.0 重新定义性能监测与优化｜从未知到已知，从被动到主动》 《UWA GPM 2.0 重新定义性能监测与优化｜如何高效预警和定位玩家端的性能问题》 GPM 2.0案例精讲 集锦 《携手护航亿级体验｜《迷你世界》× UWA GPM 2.0 性能管控最佳实践》 《我们的目标是不耗电、不发烫、不闪退》 《冒险闯关类玩家关注的四大体验》 《玩家端性能数据 一手掌握》 《造成设备功耗高的罪魁祸首》 《海外市场设备适配性优化实战》 《5000人测试背后的秘密》 GPM 2.0实操篇 集锦 《GPM 2.0 按游戏类型定制性能监控策略｜「告警」功能实战指南》 《GPM 2.0 让每一帧的流畅兑换真金白银》 《GPM 2.0 实操之Session篇之如果闪退是因为内存崩了》 《GPM 2.0 应用场景实例之解决让人头疼的问题》 《GPM 2.0 助力设备监测、行为分析与性能优化｜玩家共测新体验》 《GPM 2.0 API 功能助力定制你的性能优化》 《GPM 2.0 驱动优化与投放决策｜硬件性能一目了然》 《GPM 2.0 多维数据与实时截帧完美结合》
1）Spine动画更新耗时问题治理 2）项目卡顿与GC问题优化方案 这是第471篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q1：我们是一款三消游戏，进到游戏场景后SkeletonGraphic.Update和SkeletonGraphic.LateUpdate的耗时比较高，该怎么优化？ A：从GOT Online的报告中可以看到，进入游戏场景后Update和LateUpdate的调用次数达到50个以上，它其实反映了场景中激活状态的Spine组件数量，首先我们要定位下具体的来源，检查有没有预期之外的。 经讨论后确认，这些格子里的小图标特效是Spine做的，像这种变化幅度不是特别大的、没有像骨骼比较精细的动画，从我们经验上看确实没有太大必要用Spine来做，这种小的简单动画可以尝试改成其它动画方案实现。 另外了解到画面上方的一些角色也都是Spine做的，但是它们在屏幕外时没有做相关处理，仍然在更新。这种情况可以利用Graphic组件上的Freeze功能，也可以通过代码进行控制，当这个对象在视域体外的时候就把它冻结，这样它就会停止Spine相关逻辑的更新，等到快要回到视域体中时再把Freeze状态解除。 Q2：那如果我在这些角色滑出屏幕后直接disable隐藏掉，和这个Freeze效果是一样的吗？ A：理论上是一样的，但还是更推荐Freeze，因为SkeletonGraphic也是属于UI组件之一，然后看画面本身也是比较复杂的UI元素，如果直接调用激活隐藏，可能对UI的性能会有一些负面的影响，触发网格重建导致额外的开销。 实战案例 Q：我们目前比较关心项目的卡顿和GC相关的情况，想问下该怎么优化？ A：从GOT Online的性能测评报告来看，主要有两方面的问题。 首先是GarbageCollectAssetsProfile这个节点，也就是在调用Resource.UnloadUnusedAssets时触发垃圾回收的开销，在这个类似主城的场景里它的调用频率非常高，每隔几百帧就会触发一次，这是不太合理的。Unity中本身有一个机制，在LoadScene的时候会自动调用Asset垃圾回收，所以一般不需要手动去触发。不过也有一些像这种没有去做LoadScene切场景，不会有自动的调用，有的项目会选择用定时器的方式去手动调用，一般控制在5~10分钟调一次，还有的项目会放在触发内存报警Low Memory的时候主动调用，或者还有在场景切换前后这种明确的节点，主动调用。其他时候尽量还是要避免频繁调用，不然会造成非常多的卡顿。 另外还看到在三消的场景中，GC.Collect的调用频率比较高，从Mono报告能看到有个函数会以非常高的频率进行分配，且每次分配1MB以上的堆内存，主要是Json相关的数据写入序列化等。 总之从这个表现来看还是分配的频率太高，而导致GC的频率也比较高，建议排查一下这种操作的单次分配量和频率能否降低一些。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
系统层显存 vs 引擎层显存的差值从哪来
1）系统层显存 vs 引擎层显存的差值从哪来 2）如何定位String的异常增长 这是第470篇UWA技术知识分享的推送，精选了UWA社区的热门话题，涵盖了UWA问答、社区帖子等技术知识点，助力大家更全面地掌握和学习。 UWA社区主页：community.uwa4d.com UWA QQ群：793972859 本次推送的实战案例来自于使用UWA服务的项目的真实且典型的问题。UWA将关键线索、定位路径与处理建议整理成了可复用的案例笔记，便于大家快速对照、排查自身项目中的同类问题。 实战案例 Q：我们项目中通过系统层统计到的显存有1.5GB，但是通过引擎层采集到的纹理、网格、RT这些加起来也只有400+MB，为什么会这样，其它的部分是什么呢？ A：这个其实是一种比较少见的情况，根据以往的经验来说可能有以下几种可能性： Shader占用本身是属于引擎层Native部分的，在运行中会执行Shader.CreateGPUProgram把它编译成GPU可以理解使用的一个Program，生成的这个GPU Program在一些项目中可能造成相当可观的高占用，我们在过往部分极端测试中也发现，它会同时导致系统层Native内存与GFX显存出现显著上升。 测试发现一些RT除了在Memory Profiler中统计到的部分外，也会对显存造成一些额外的开销。 在其它项目中还见过使用Compute Buffer，或者在引擎设置里面开辟一些额外的Buffer，通过C++进行加载的，也会对显存造成额外开销。 这些内存的具体来源是什么无法直接通过Unity引擎的分析工具进行分类和定位，建议可以做一些对比测试，比如去拆分一些渲染策略或场景，如果观察到开启某个渲染策略、进入某个渲染场景时显存出现明显的飙升，那么基本就能做更精准的定位了。 参考文章：《Unity移动游戏工业级性能优化指南 3.0》 实战案例 Q：我用Memory Profiler看到托管堆内存部分中String驻留了50+MB、45万多条，这个现象是正常的吗？ A：这一部分占用还是比较高的，从下图右边的Details信息看很多来自TMP图集用的文本，这种大量字符串理论上不应该驻留在运行时了，我们生成了TMP的图集纹理后像这个文本应该就不需要了，可以看看是不是可以及时卸载。 无论是社区里开发者们的互助讨论，还是AI基于知识沉淀的快速反馈，核心都是为了让每一个技术难题都有解、每一次踩坑都有回响。本期分享分别来自UWA AI问答和UWA问答社区，希望这些从真实开发场景中提炼的经验，能直接帮你解决当下的技术卡点，也让你在遇到同类问题时，能更高效地找到破局方向。 封面图来源于网络 今天的分享就到这里。生有涯而知无涯，在漫漫的开发周期中，我们遇到的问题只是冰山一角，UWA社区愿伴你同行，一起探索分享。欢迎更多的开发者加入UWA社区。 UWA官网：www.uwa4d.com UWA社区：community.uwa4d.com UWA学堂：edu.uwa4d.com 官方技术QQ群：793972859
【USparkle专栏】如果你深怀绝技，爱“搞点研究”，乐于分享也博采众长，我们期待你的加入，让智慧的火花碰撞交织，让知识的传递生生不息！ 物理系统与物理世界 物理系统是每个游戏引擎必备的功能，可以让游戏模拟真实世界的物理规则。而现代游戏引擎中的“物理”，应该称为刚体动力学。刚体（Rigidbody）是理想化、无限坚硬、不变形的固体物理。动力学（Dynamics）是一个过程，计算刚体怎样在力（Force）的影响下随时间移动及相互作用。 在游戏世界背后，有一个镜像的物理世界，其中不关心Actor的视觉表现，只关注Actor的形状、物理材质等信息。简单使用场景下，只要把所有Actor当成刚体处理即可。 以常见的StaticMeshActor为例。下图左侧是Editor窗口，右侧是Chaos Visual Debugger看到的物理世界。 UE5的物理引擎已经从PhysX改成了Chaos，可能物理查询速度会慢些，但更适合做物理破坏效果，而且数据结构和游戏引擎统一，不需要再做转换。 物理世界类 UWorld是代表由Actor构成的整个游戏世界，它持有一个FPhysScene来代表物理世界，可以类比为渲染中的FScene，物理世界的步进（Advance）初始也由UWorld::Tick()发起。UWorld与物理相关的功能一般在PhysLevel.cpp中实现。FPhysScene等价于FPhysScene_Chaos，继承自FChaosScene，是Chaos的首要入口。碰撞事件的注册分发，物理网络同步相关的内容也由其处理，物理模拟的步进也在该类开始（StartFrame）。 示例：创建一个StaticMeshComponent StaticMesh的物理配置 编辑器中，可以给StaticMesh设置碰撞，这里直接使用和模型大小一样的Box碰撞即可。还有其他很多选项，如Sphere、Capsule、凸包等，面对复杂模型时会用多种基础形状拼出一个碰撞。 UBodySetup 这些碰撞配置，最终会存储到UBodySetup类里，作为资源的一部分，它会是StaticMesh的一个配置项。对于例子中添加的简单几何体碰撞，存储在其AggGeom变量中。 class UBodySetup : public UBodySetupCore { //... /** Simplified collision representation of this */ UPROPERTY(EditAnywhere, Category = BodySetup, meta=(DisplayName = "Primitives", NoResetToDefault)) struct FKAggregateGeom AggGeom; //... }; AggGeom里是一大串几何体的数据，Box就位于BoxElems数组里。 struct FKAggregateGeom { GENERATED_USTRUCT_BODY() UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Spheres", TitleProperty = "Name")) TArray<FKSphereElem> SphereElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Boxes", TitleProperty = "Name")) TArray<FKBoxElem> BoxElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Capsules", TitleProperty = "Name")) TArray<FKSphylElem> SphylElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Convex Elements", TitleProperty = "Name")) TArray<FKConvexElem> ConvexElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Tapered Capsules", TitleProperty = "Name")) TArray<FKTaperedCapsuleElem> TaperedCapsuleElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "Level Sets", TitleProperty = "Name")) TArray<FKLevelSetElem> LevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) Skinned Level Sets", TitleProperty = "Name"), Experimental) TArray<FKSkinnedLevelSetElem> SkinnedLevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) ML Level Sets", TitleProperty = "Name"), Experimental) TArray<FKMLLevelSetElem> MLLevelSetElems; UPROPERTY(EditAnywhere, editfixedsize, Category = "Aggregate Geometry", meta = (DisplayName = "(Experimental) Skinned Triangle Meshes", TitleProperty = "Name"), Experimental) TArray<FKSkinnedTriangleMeshElem> SkinnedTriangleMeshElems; }; UPrimitiveComponent UPrimitiveComponent为所有持有几何物体的基类，StaticMeshComponent也是它的子类。会创建一个FBodyInstance作为其在物理世界中的代表。无论是UStaticMeshComponent还是USkeletalMeshComponent又或者UCapsuleComponent，实际最终都会通过FBodyInstance与物理世界进行交互。此外还有UGeometryCollectionComponent等用于特定物理功能相关的Component，它们会持有额外的物理代理。 FBodyInstance 一个物理对象的实例，在Gameplay部分的表示，用于设置物理相关的各项属性。引擎代码注释中对FBodyInstance的定义如下： Container for a physics representation of an object 一些属性举例： TWeakObjectPtr BodySetup：关联的BodySetup，BodyInstance本身并不包含具体的几何形状。 FBodyInstance* WeldParent：一个BodyInstance所连接的父Body。 FPhysicsActorHandle ActorHandle：实际是FSingleParticlePhysicsProxy*，为物理引擎内部BodyInstance的呈现与代理。 FName CollisionProfileName：碰撞的ProfileName。 至此，StaticMeshComponent创建完毕，其包含了BodyInstance，并关联了BodySetup。 简单理解的话，StaticMeshComponent包含了Render和物理信息，而BodySetup+BodyInstance就表示物理信息。 物理对象与Shape 直观理解，物理世界就是大量的形状构成的。 物理世界如何表示"对象" Game世界使用Actor、Component等类型来表示一个对象，但物理世界只关注一个物体的几何形状、位置、以及质量等属性，它们构成了另一套PhysScene里的数据结构，以及PhysScene里的“对象”。这个对象称为GeometryParticle，Game世界通过FPhysicsActorHandle类型来索引它。 FPhysicsActorHandle using FPhysicsActorHandle = Chaos::FSingleParticlePhysicsProxy*; 看声明代码，等价于FSingleParticlePhysicsProxy*，既然叫XXProxy，那就是一个代理的作用。一个需要进行物理解算的对象在物理引擎内被称为一个Particle，是一个约定俗成的名字，该类作为其代理充当与物理交互的接口。物理引擎只关注一个物体的几何形状、位置、以及质量等属性，它们由TGeometryParticle持有。PhysicsActorHandle的TGeometryParticleHandle属性指向了GeometryParticle，实现关联。不仅如此，TGeometryParticle内部还有一个指向更底层物理SOA存储的FGeometryParticleHandle，暂时用不到，先不管。概念比较多，之后会做总结。 注意，目前都只涉及物理世界，但不涉及物理线程。游戏线程和物理线程都可以读写物理世界，但物理线程主要负责物理模拟。 除此之外还有描述Chaos破坏集合的FGeometryCollectionPhysicsProxy、描述关节约束的FJointConstraintPhysicsProxy等。 FSingleParticlePhysicsProxy的属性： TUniquePtr<PARTICLE_TYPE> Particle; FParticleHandle* Handle; FPhysicsObjectUniquePtr Reference; int32 GravityGroupIndex; TUniquePtr<FProxyInterpolationBase> InterpolationData; GetGameThreadAPI()是GameThread获取操作类的方法，直接cast即可： FORCEINLINE FRigidBodyHandle_External& GetGameThreadAPI() { return (FRigidBodyHandle_External&)*this; } UPrimitiveComponent::OnCreatePhysicsState 用于创建物理数据。Component在创建时会执行RegisterComponent函数，其中执行到OnCreatePhysicsState创建物理数据，OnCreatePhysicsState本身是虚函数，可以被PrimitiveComponent的不同子类重载。 重载了OnCreatePhysicsState的Component： 而其中主要逻辑都在FBodyInstance::InitBody函数里，最终执行到FInitBodiesHelperBase::InitBodies。 UPrimitiveComponent和FPhysicsActorHandle的映射 给UPrimitiveComponent创建完物理对象后，和PhysicsActorHandle的映射关系会存储在PhysScene里，用于后续查询。 值得注意的是，Component和PhysicsActorHandle是个一对多的关系，比如一个Component里面可以创建多个BodyInstance，自然就有了多个PhysicsActorHandle。 一个例子是SkeletalMeshComponent，会有多个刚体组成。 /** Array of FBodyInstance objects, storing per-instance state about about each body. */ TArray<struct FBodyInstance*> Bodies; 创建Actor（Particle） 这里Actor是PhysX遗留的术语，可以视为物理场景中的一个对象，基本等价于Particle，也即前文的TGeometryParticle。这里的Particle和下文的Shapes都在CreateShapesAndActors函数中创建。 最终的创建代码如下，StaticMesh情况比较简单，直接创建TGeometryParticle即可： void FChaosEngineInterface::CreateActor(const FActorCreationParams& InParams,FPhysicsActorHandle& Handle, UObject* InOwner) { TUniquePtr<FGeometryParticle> Particle; // Set object state based on the requested particle type if(InParams.bStatic) { Particle = FGeometryParticle::CreateParticle(); Particle->SetResimType(EResimType::ResimAsFollower); } //... } TGeometryParticle的属性如下： TChaosProperty<FParticlePositionRotation, EChaosProperty::XR> MXR; TChaosProperty<FParticleNonFrequentData,EChaosProperty::NonFrequentData> MNonFrequentData; void* MUserData; FShapeInstanceProxyArray MShapesArray; EParticleType Type; FDirtyChaosPropertyFlags MDirtyFlags; MXR：表示位置和旋转；FVec3 MX；FRotation3f MR；MShapesArray是Particle所包含的Shape，一个GeometryParticle完全可以包含多个形状，如球、长方体等，只是用的不多。 MNonFrequentData指向了低频访问的数据。 有了Particle，随之就能创建PhysicsActorHandle了，把刚创建的FGeometryParticle指针存入即可。 创建不同形状的Shape Shape为一个几何体，它包含碰撞、几何与物理材质等数据，一个Particle可以有多个Shape。 Shape的形状描述都在AggGeom里，总共有以下几种基础形状： Sphere：对应几何类为TSPhere，记录了圆心坐标和半径。 Box：对应几何类为TBox，记录了Box的HalfExtent和中心Transform。 Capsule：对应类型为FCapsule，照理说应该记录HalfHeight、Radius和中心Transform，但小小优化了一下，改为记录圆柱体上下两个点+Radius。 Convex：除了这些基础形状，还有能处理任意形状的凸包，对应类型为FConvex，里面存储了所有顶点数据，是三角形的集合。下图是为一个锥体生成的凸包。 这里例子的StaticMesh只配置了Box类型的碰撞，会创建TBox类型的Geometry，包含了Min和Max两个坐标，其实就是HalfExtend的作用。 template<class T, int d> class TBox final : public FImplicitObject { TAABB<T, d> AABB; }; class TAABB { TVector<T, d> MMin, MMax; }; 然后对TBox再包一下，加上Box的Transform数据，生成ImplicitObjectTransformed对象。 ChaosInterface::CreateGeometry函数负责根据配置的AggGeom数据，创建Geometry实例，然后存入Particle中。 至此，示例中StaticMesh对应的物理Geometry已创建完毕。 Simple Collision & Complex Collision 通常一个对象会有简单&复杂两套碰撞，就需要分别创建两个Shape和Geometry。勾选UseSimpleAsComplex则只用一套，但不建议这么做。 把上面几个类都联系起来，总结关系如下。 写入物理世界 目前为止，只是创建了Particle、Shape等数据，还需要把它们注册到物理世界中，就像把Actor注册到UWorld。 FChaosScene::AddActorsToScene_AssumesLocked 物理世界写锁 首先物理世界会被多线程同时读取与写入，因此是个读写锁的场景，写入时需要加写锁，通过FPhysicsCommand::ExecuteWrite函数实现，执行到这时可能会阻塞在等待锁上。 写入Solver Solver即为FPBDRigidsSolver，管理了物理世界所有Particle，如此理解即可，写入代码如下： FPBDRigidsSolver::RegisterObject RigidBody_External.SetUniqueIdx(GetEvolution()->GenerateUniqueIdx()); TrackGTParticle_External(*Proxy->GetParticle_LowLevel()); //todo: remove this Proxy->SetSolver(this); Proxy->GetParticle_LowLevel()->SetProxy(Proxy); AddDirtyProxy(Proxy); UpdateParticleInAccelerationStructure_External(Proxy->GetParticle_LowLevel(), EPendingSpatialDataOperation::Add); 然后Particle会被赋予一个UniqueIdx作为标识。 RigidBody_External.SetUniqueIdx(GetEvolution()->GenerateUniqueIdx()); 写入空间加速结构 这是一个重点。物理世界中有大量的几何体，为了支持射线检测、碰撞查询、物理模拟等功能，必然要用到空间加速结构。这就是FChaosScene类中的SolverAccelerationStructure属性。 Chaos::ISpatialAccelerationCollection<Chaos::FAccelerationStructureHandle, Chaos::FReal, 3>* SolverAccelerationStructure; 虽然是一个Interface，但实际都用的是： 它只是一个Collection，里面划分了多个底层的加速结构。Chaos的默认底层加速结构为AABBTree，它是一个类似BVH的数据结构，但实现更简单。 为什么要划分多个AABBTree？ 整个物理场景使用一个超大的AABBTree来管理并不合适，元素多了效率会很低。所以分多个AABBTree子树是合理的，划分依据并不是空间远近，而是Particle的Static/Movable属性、QueryOnly/QueryAndPhysics属性等，大方向是划分了Static Tree和Dynamic Tree。因为Static Tree是很少做更新的，Particle移动时，更新单独的Dynamic Tree效率更高。Particle的SpatialAccelerationIdx属性就表示它属于哪一个AABBTree，有16位，能表示8个Bucket，每个Bucket又有最多8192个AABBTree，但目前远没有用这么多，实际默认用了四个，最多能支持到8个。 struct FSpatialAccelerationIdx { uint16 Bucket : 3; uint16 InnerIdx : 13; } Bucket的划分 对于Bucket的划分，只有两种，一个是0，即默认的，另一个是1，当AABB的包围盒过大时会放到Bucket 1里。这个包围盒阈值默认为100米。 在FPBDRigidsSolver::RegisterObject函数中，会根据这个条件设置Bucket。 为什么超大AABB要单独划分？如果超大Particle和小Particle存储在一棵树中，会导致查询效率变低。想象一个叶节点包含了一个超大Particle和若干小Particle，那么做射线检测时，很容易与这个叶节点相交，但做逐Particle判断时，大概率只和这个超大Particle相交，小Particle的射线检测就都浪费了。 InnerIndex的设置 查看FChaosEngineInterface::CreateActor代码，会发现根据Particle的Static/Dynamic，以及是否QueryOnly，分成了四类： Defautl是Static Tree，Dynamic是Dynamic Tree。 Static Tree会存储静态对象，如场景中的树木、石头、房屋等。 例子中，Box的属性是Static+QueryOnly，因此InnerIndex被设置成了ESpatialAccelerationCollectionBucketInnerIdx::DefaultQueryOnly。 Dynamic Tree存储会移动的对象，如角色、载具、电梯。 DefaultQueryOnly是对Static Tree再细分出Query Only的Particle，DynamicQueryOnly类似，但目前没启用，通过p.Chaos.AccelerationStructureIsolateQueryOnlyObjects参数开启。 通过Bucket和InnerIndex划分AABBTree的示意图如下： 例子中，Box的属性是Static+QueryOnly，因此InnerIndex被设置成了DefaultQueryOnly。 具体写入了什么数据 这里立即更新物理世界的加速结构，UpdateElement就是把Geometry插入到AABB中。 写入数据有Payload和PayloadInfo两部分。 Payload更像Key，类型是FAccelerationStructureHandle，包含Particle指针、UniqueIdx、FilterData等数据。 PayloadInfo是AABBTree叶节点真正存储的数据，能反向关联到Payload和Particle，类型为FAABBTreePayloadInfo。属性如下： struct FAABBTreePayloadInfo { int32 GlobalPayloadIdx; // GlobalPayloads里单独存的NodeIndex，没有BoundingBox int32 DirtyPayloadIdx; // 单独的DirtyElementTree里的NodeIdx int32 LeafIdx; // 常见情况，Leaf Node的Index int32 DirtyGridOverflowIdx; // 用Dirty Grid而不是DirtyElementTree时，overflow的Index，少见情况 int32 NodeIdx; // 常见情况，Tree Node的Index } 最终Payload和PayloadInfo会存在PayloadToInfo这个Map里，其实是个数组，通过UniqueIndex索引，模拟成了Map。 typename StorageTraits::PayloadToInfoType PayloadToInfo; AABBTree 既然AABBTree加速结构很重要，不妨展开分析下。 核心思想 其实和BVH类似，都是把一群AABB包围盒，通过启发式的规则，不断划分成左右两个AABB子树，直到每个叶节点包含的AABB包围盒少于一个阈值，本质是一棵二叉树。 以2D情况为例，AABB树的非叶节点，会把叶节点的AABB给取并集，作为自己的AABB，叶子节点则包含了一定数量的AABB对象。下面Root的Child1节点就是叶子节点。 其对应的二叉树结构如下： 至于AABBTree的具体代码实现，为了效率，没有使用动态new节点的方法，而是用数组来表示了二叉树的拓扑。 TArray<FNode> Nodes; TLeafContainer<TLeafType> Leaves; Nodes存储了所有节点，每个节点如下： struct TAABBTreeNode { TAABB<T, 3> ChildrenBounds[2]; int32 ChildrenNodes[2]; int32 ParentNode; bool bLeaf : 1; bool bDirtyNode : 1; }; 当Node是叶节点时，ChildrenNodes[ ChildrenBounds是左右子节点的Bounds。 TAABBTreeLeafArray TArray<TPayloadBoundsElement<TPayloadType, T>> Elems; 叶子节点数组只存储AABB包围盒，会略微扩大一点，DynamicTreeLeafEnlargePercent=0.1，为了给Update做一点开销缓冲，避免Update太频繁。 叶节点包含最多8个AABB包围盒，叶节点的Bounds是它们的并集。 8是默认值，也可以通过CVar参数改变： int32 FAABBTreeCVars::DynamicTreeLeafCapacity = 8; FAutoConsoleVariableRef FAABBTreeCVars::CVarDynamicTreeLeafCapacity(TEXT("p.aabbtree.DynamicTreeLeafCapacity"), FAABBTreeCVars::DynamicTreeLeafCapacity, TEXT("Dynamic Tree Leaf Capacity")); 直接向叶节点插入元素 InsertLeaf 把Payload和Bounds插入到AABBTree中。简单起见，假设已经找到了最适配的那个叶节点，直接插入其中。 叶节点有个Elements数组，打包存储了Payload和Bounds，元素类型如下： struct TPayloadBoundsElement { TPayloadType Payload; TAABB<T, 3> Bounds; }; 具体代码层面的实现上，一个Leaf节点还需要一个额外的Node节点来辅助。 下图为AABBTree拓扑到实际存储结构的示例，可以看到树的叶节点，需要一个Node节点与一个Leaf节点来表示。 插入完成后，会返回Particle在AABBTree中的索引，索引由Node数组下标和Leaf数组下标两部分组成，类型是NodeAndLeafIndices。 struct NodeAndLeafIndices { int32 NodeIdx; int32 LeafIdx; }; 然后这个数据就存储在之前的FAABBTreePayloadInfo中。 如何寻找最合适的叶节点 叶节点满了，新建一个Node，作为Parent。 FindBestSibling 如果一颗AABBTree层数较多，那么插入一个AABB包围盒时，需要寻找“最合适”的叶节点插入。 何谓“最合适”，加入新AABB，通常会使一些ChildNode的BoundingBox变大，需要使BoundingBox增加的体积尽量小，以此来判定“最合适”。这是一种启发式的方法，认为BoundingBox大小直接影响到AABBTree查询的效率，类似构建BVH使用的SAH表面积启发算法。这个寻找过程称为“FindBestSibling”。 下面看几个例子。 例子1，新加入AABB在原先AABBTree的BoundingBox之内。此时经过对比BoundingBox增加的体积，应该加入ChildTree[1]。 例子2，新加入AABB在原先AABBTree的BoundingBox之外。此时不仅要考虑分别加入ChildTree[ 这里Root节点带来的增量相同，显然加入ChildTree[1]更合适。 例子3，多层子节点情况。看个更复杂的多层子节点例子，ChildTree[ 对于ChildTree[ 对于ChildTree[1]的两个子树，先要计算与ChildTree1产生的额外空间，再计算与两个子树的额外空间，最后都加起来。 因此这里显然应该把新节点加入ChildTree[ Leaf满了怎么办？ 当BestSibling找到的叶节点已经满了，就无法直接添加，需要再新增两个Node，一个作为中间节点，另一个作为新的Leaf。之后同样要更新Leaf到根节点路径上所有Parent的BoundingBox。 例子2的变体： 删除元素 与插入元素对应的，就是删除元素了，删除逻辑会简单很多。 virtual bool RemoveElement(const TPayloadType& Payload) 首先从PayloadToInfo中找到Payload所属的节点下标，然后从节点的Element数组里把Payload移除即可，并且把到根节点链路上的所有Bounds再更新一遍。 但是特别的，当叶节点删除Payload后整个都空了，就要精简一下AABB树了，把Parent节点和自己都删掉，然后Parent节点的位置放Sibling节点即可。 Static AABBTree AABBTree大体上分了Dynamic Tree和Static Tree两类，其中Dynamic Tree最贴近原生的AABBTree实现，增、删、改都直接操作AABBTree即可，而Static Tree则做了不少优化，来提升效率。因为通常Static Tree里的元素远超Dynamic Tree。 “Static”并不意味着不更新，比如可以把石头先改成Movable移动，再改回Static等等。Static Tree的构建总是先提供所有AABB对象，一次性建好，之后也能继续添加/删除/修改Payload，但树节点结构不实时改变，只会重建。 Static AABBTree的构建 构造函数中提供一个能表示AABB的数组即可，像这里的数组元素有3202个。 接着执行GenerateTree函数来构造AABBTree。 GenerateTree(Particles); 一次性构造一整棵树有个好处，就是能让左右子树尽量空间上均衡，提升之后的查找效率。如果是Dynamic那样一个一个节点的插入，树的质量和节点插入顺序是有关系的。那么重点就是对于一群AABB节点，如何合理地划分左右子树。回想在构造BVH时，有两种常用划分方法，一种是在XYZ三个方向里选取Max-Min最大的一个作为划分轴，然后取中点划分；另一种是SAH表面积启发算法，同样是考虑XYZ三个轴，但在每个轴上要计算多种表面积划分组合，再选取某个能使Cost最小的位置做划分，计算量更大，但效果也更好。UE的AABBTree使用了更接近前者的简单做法，但不会取Max-Min最大的作为划分轴，而是计算每个轴上AABB 中心点形成的方差，取方差最大的轴作为划分轴。计算中心点和方差的代码如下，使用了流式数据常用的Welford算法。 举个二维平面的例子。 按照Max-Min的方式，应该用X轴划分，按照方差方式，应该按照Y轴划分，结果上看方差方式更优。 然后GenerateTree的具体实现上，也有两个特点，一是支持分帧构建，避免突然卡一下，另一个就是全程避免递归和动态内存分配了，是比较值得学习的工程实践。 Static AABBTree删除Payload 如果Static AABBTree要删除Payload，过程反而更简单，直接从Leaves数组中移除Payload即可，不用考虑树的坍缩，也不用更新整个树节点链路上的AABB包围盒。 然后标记ShouldRebuild为true，等待后面一起更新树，见PBDRigidsEvolution.cpp文件。 Static AABBTree插入Payload 插入Payload比较有意思，既要不改变AABBTree的结构，又要插入元素。UE做法是另外创建了一个容器，来存储插入的元素，而且这个容器同样支持空间加速查询。一种实现是2D Grid，另一种实现是再加一个Dynamic AABBTree。还是比较复杂的。 Grid实现 默认用Grid实现，首先Payload加入DirtyElements数组，然后把整个场景划分为2D正方形Grid，再把Payload加入到Grid数据结构中。 Grid的每个Cell大小为CVarDirtyElementGridCellSize，默认1000，如果一个Payload和某个Cell重叠，该Cell就会把Payload的DirtyPayloadIndx记下来。 但Grid也有大小限制和容量限制。首先，Payload所覆盖的Cell不能过多，即Payload的AABB不能过大，然后每个Cell重叠的Payload数量不能过多，不然都会影响效率。目前前者配置是16，后者配置是32。如果超过了限制，Payload就要被加入到单独的DirtyElementsGridOverflow数组，并把下标记录在PayloadInfo中，后续也会单独查询。 Grid示意图如下： DirtyElementTree实现 新加的AABBTree称为DirtyElementTree，Payload插入完成后，PayloadInfo会存储其在DirtyTree中的Node下标。 如此一来，Grid/DirtyElementTree就是Static AABBTree的一部分了，往后的增/删/改Payload，以及AABBTree做碰撞查询，都要额外考虑它们。后面AABBTree重建了，再清空Grid/DirtyElementTree。 AABBTree可视化 最后，可以读取AABBTree的所有元素，并画出来，看下AABBTree都是什么样。 遍历PhysicsScene的SolverAccelerationStructure属性即可，可以看到整个AABBTree还是比较复杂的。 尤其是中间角色，有多个BodyInstance。 碰撞通道设置 一个PrimitiveComponent，不仅包含几何信息，还可以配置它的碰撞通道、ObjectType、CollisionType等信息，这些设置都会影响到PrimitiveComponent的物理表现。比如例子中的StaticMeshComponent，默认的ObjectType为WorldStatic，然后对所有CollisionResponses的响应都是Block。 首先需要根据ColllisionProfileName加载对应的各个碰撞响应，每个BodyInstance都自己存了一份CollisionProfileName和CollisionResponses，加载函数为UCollisionProfile::ReadConfig。至于为什么每个BodyInstance都要存一份，而不是用BodySetup里的，是因为运行时可以动态改变单个BodyInstance的碰撞设置。 此时这些碰撞设置还是一堆Bool和Enum，对于底层物理引擎存储不太方便，因此要把它们进行编码，最终会存入四个int32，用FCollisionFilterData表示。 struct FCollisionFilterData { uint32 Word0; uint32 Word1; uint32 Word2; uint32 Word3; }; 在CreateShapes函数中，会对碰撞数据进行编码，并且生成三个FCollisionFilterData实例，存储不同用途的物理碰撞信息。 /** Helper struct holding physics body filter data during initialisation */ struct FBodyCollisionFilterData { FCollisionFilterData SimFilter; FCollisionFilterData QuerySimpleFilter; FCollisionFilterData QueryComplexFilter; }; 编码过程主要由CreateShapeFilterData函数实现。 SourceObjectID：UPrimitiveComponent的UniqueID。 InstanceBodyIndex：这是UPrimitiveComponent里的第几个BodyInstance，比如常见的SkeletalMeshComponent有多个BodyInstance。 SimFilter：物理模拟相关信息。 inline void GetSimData(uint32 BodyIndex, uint32 ComponentID, uint32& OutWord0, uint32& OutWord1, uint32& OutWord2, uint32& OutWord3) const { OutWord0 = BodyIndex; OutWord1 = BlockingBits; OutWord2 = ComponentID; OutWord3 = Word3; } QuerySimpleFilter：SimpleCollision的碰撞响应。 inline void GetQueryData(uint32 SourceObjectID, uint32& OutWord0, uint32& OutWord1, uint32& OutWord2, uint32& OutWord3) const { OutWord0 = SourceObjectID; OutWord1 = BlockingBits; OutWord2 = TouchingBits; OutWord3 = Word3; } QueryComplexFilter：ComplexCollision的碰撞响应，响应部分和Simple是一样的。 // Build filterdata variations for complex and simple SimpleQueryData.Word3 |= EPDF_SimpleCollision; if (bUseSimpleAsComplex) { SimpleQueryData.Word3 |= EPDF_ComplexCollision; } ComplexQueryData.Word3 |= EPDF_ComplexCollision; if (bUseComplexAsSimple) { ComplexQueryData.Word3 |= EPDF_SimpleCollision; } OutFilterData.QuerySimpleFilter = SimpleQueryData; OutFilterData.QueryComplexFilter = ComplexQueryData; Word3的设置： inline uint32 CreateChannelAndFilter(ECollisionChannel CollisionChannel, FMaskFilter MaskFilter) { uint32 ResultMask = (uint32(MaskFilter) << NumCollisionChannelBits) | (uint32)CollisionChannel; return ResultMask << NumFilterDataFlagBits; } PrimitiveComponent的销毁 Component销毁时，需要同步的清除掉物理世界数据，主要通过FBodyInstance::TermBody函数实现。 1.从PhysScene的几个容器里移除 PhysicsProxyToComponentMap和ComponentToPhysicsProxyMap。 2.从PhysScene的加速结构中移除 FChaosScene::RemoveActorFromAccelerationStructure 就是从AABBTree移除，先从Buckets找到对应的AABBTree，然后调用RemoveElement移除。 最终进入AABBTree的RemoveElement函数，根据DynamicTree属性、使用Grid还是DirtyElementTree等情况，做移除。 3.从Solver中移除 FPBDRigidsSolver::UnregisterObject，物理模拟信息的删除。 这是侑虎科技第1966篇文章，感谢作者南京周润发供稿。欢迎转发分享，未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859） 作者主页：https://www.zhihu.com/people/xu-chen-71-65 再次感谢南京周润发的分享，如果您有任何独到的见解或者发现也欢迎联系我们，一起探讨。（QQ群：793972859）
做UE项目的你，一定经历过这些场景：QA测出卡顿 → 录屏反馈 → 程序复现半天，对不上。打开 Unreal Insights，却发现真机数据和Editor分析结果不一致： 跨线程（GameThread / RenderThread / RHIThread）互相影响，瓶颈像“黑盒”一样难以拆解，长周期卡顿、内存泄露问题，测得到却抓不住。最后问题不是解决不了，而是——定位成本太高，效率太低。 UE性能分析长期存在一个核心断层：开发侧分析工具（Insights）与日常QA测试数据之间，无法稳定对齐，这意味着：QA的数据，程序不能直接拿来主义、分析必须反复复现、性能优化高度依赖经验与人力，仍然是一件“专家工具 + 人肉分析”的事情。 随着 Unreal Engine 5.7的发布，UWA GOT Online UE版迎来全量升级。但这次，我们做的不是简单适配，我们想做的是：把UE性能优化，从“少数人能做”，变成“团队都能做”的标准能力。 原生级引擎适配 让数据第一次真正“对齐” 从“看数据”到“直接定位问题” 我们完成了UE 5.x全系列（含5.7）的原生适配，并打通了引擎底层数据链路。具体包括： 1. 函数级Timeline与Insights的100%对齐 函数名、调用层级、时间统计完全一致，这意味着：真机数据，可以直接用于定位问题，而不是“参考数据”，程序无需再在多个工具之间反复核对。 2. 引擎原生指标全量接入 新增5大类、数十条UE原生STAT指标，譬如覆盖了渲染流程中，从FDrawSceneCommand（RT）到RHIThreadExecute（RHI），每一层都有指标 + 函数堆栈，可以精确定位：到底卡在“哪一段渲染流程”。 另有，加载/GC/动画/物理/AI/UI，所有数据均来自UE原生采集通道，开发者看到的，就是引擎真实发生的。 物理模块 UI 模块 多线程核心指标（Game/Render/RHI）全面覆盖：支持多视角切换（总览/单帧/场景） 且支持独立线程分析、冰柱图 + 堆栈联动、万级节点自动过滤。可以直接定位到某一帧、某一线程、某一个函数。 性能优化第一次变成“团队能力” 这个版本中我们也支持了一系列对于日常调试需求来说非常友好的功能，譬如： 新增Direct Mode模式，支持游戏启动后，立即开始抓取性能数据。能够针对游戏启动后加载慢、内存增长快等特殊场景进行针对排查。 编译级开关可控，支持宏定义一键屏蔽 SDK，可在 Debug、Release 等不同编译环境中灵活控制 SDK 的启用与关闭，彻底避免了 SDK 对项目正常开发、编译与发布的影响。 集成流程简化，移除 Binaries 目录，降低了项目版本管理与团队协作的成本。 这次升级里，最具行业意义的一点，QA可以参与性能定位了： 过去：QA → 发现问题 → 程序分析 → 来回拉扯 现在：QA → 直接提供可定位数据 → 程序直接修 带来的本质变化是：性能优化，从“程序能力”，变成“团队协作能力”，并且覆盖研发全周期的性能管控，从SDK接入、功能测试、性能回归、长周期监控、一套工具全部覆盖，支持 Android / iOS；不只是“跑得快”，也关注“玩家体验”。 从2015年至今，UWA始终专注于性能优化领域。但这一次，我们更在意的是另一件事：能不能把“复杂能力”，变成“标准流程”，如果你正在做UE项目，并且遇到这些问题： 性能问题定位效率低 QA与程序反复沟通 长周期问题难复现 那这次Unreal版的GOT Online，就是为你准备的。我们做的，不只是工具升级，而是让性能优化这件事，第一次可以被“团队稳定复用”。 关于UWA UWA是一家创业十年的高新技术企业，作为游戏行业的深耕者，UWA始终专注于为使用Unity、Unreal引擎的开发者提供丰富的优化产品，帮助开发者高效解决开发问题、定位性能瓶颈、提供解决方案，已支持超过一万款游戏项目。