进阶实战与知识图谱:从理论到上线的完整闭环

对应月影课程:第 29~41 章(综合实战、性能调优、跨平台、前沿技术)

目标:整合前面所有知识,完成一个可上线的渲染优化实战案例,并建立完整的知识索引体系。


写在前面:为什么需要进阶实战?

如果不懂实战整合会怎样?

想象你学了很多烹饪技巧:会切菜、会炒菜、会调味、会摆盘。但当你真正要办一场 100 人的宴席时,却发现:

  • 不知道先做哪道菜(优化顺序混乱)
  • 切菜太慢导致后面的菜赶不上(瓶颈分析错误)
  • 所有菜一起下锅,厨房乱成一团(没有优先级)
  • 客人吃到一半菜凉了(没有持续监控)

学习渲染优化也是一样。你可能已经学会了:

  • 什么是 DrawCall
  • 什么是合批
  • 什么是 Shader
  • 什么是纹理压缩

知道每个知识点 ≠ 能做出流畅的游戏。进阶实战就是教你如何把零散的知识点组合成一套完整的"优化 workflow",让你面对真实项目时知道:

  1. 先测什么:拿到项目第一步该做什么?
  2. 问题在哪:数据告诉你瓶颈是 CPU、GPU 还是内存?
  3. 怎么优化:针对具体问题的具体解法是什么?
  4. 验证效果:优化后怎么确认真的变好了?
  5. 持续维护:上线后怎么防止性能退化?

实战的思维方式

不要"我觉得这里需要优化",要"数据告诉我这里需要优化"。

就像医生不会凭感觉开药,而是先看化验单。优化前必须先有性能基线数据,优化后必须对比数据验证效果。


一、综合实战项目:2D 卡牌游戏渲染优化

1.1 项目背景

为什么选卡牌游戏作为案例?

卡牌游戏是 2D 游戏中最典型的"性能重灾区":

  • 大量 UI 元素(卡牌、按钮、文字)
  • 列表需要显示成百上千项
  • 特效和动画叠加
  • 需要同时适配高中低端设备

如果你能把卡牌游戏优化好,其他 2D 游戏(RPG、SLG、休闲)基本都能应对。

游戏类型:2D 卡牌对战
目标平台:Web、微信小游戏、Android、iOS
技术栈:Cocos Creator 3.8.2

原始性能问题(这是真实项目中常见的问题):
  - 战斗场景 200+ DrawCall
    * 为什么这是问题?DrawCall 是 CPU 告诉 GPU "画这个" 的次数。
      每次 DrawCall 都有固定开销(准备数据、状态切换、提交命令)。
      200+ DrawCall 意味着 CPU 花了大量时间在"发号施令",而不是"真正工作"。
  
  - 卡牌列表 1000+ 项,初始化卡顿 2s+
    * 为什么这是问题?一次性创建 1000 个节点,JS 引擎要分配大量内存,
      还要遍历设置每个节点的属性。2 秒卡顿玩家会直接关掉游戏。
  
  - 低端机(骁龙 660)帧率 15~20 FPS
    * 为什么这是问题?人眼对帧率很敏感。60 FPS 是丝滑,30 FPS 是可接受,
      20 FPS 以下会有明显卡顿感,像在看幻灯片。
  
  - 发热严重,30 分钟后帧率降至 10 FPS
    * 为什么这是问题?发热导致"热节流"(Thermal Throttling),
      GPU 自动降频保护硬件。玩家玩半小时就卡得没法玩,会差评。

生活类比:这就像一家餐厅。DrawCall 高 = 服务员跑断腿(每次只端一盘菜);初始化卡顿 = 客人进门后 2 分钟没人招呼;低端机帧率低 = 餐厅座位太少,客人排队等位;发热降频 = 厨房太热,厨师越干越慢。

1.2 优化前性能基线

为什么需要性能基线?

没有基线,你就不知道优化有没有效果。就像减肥前要称体重,优化前要记录数据。

场景 DrawCall Frame Time 低端机 FPS 内存
主界面 45 12ms 45 45MB
卡牌列表 320 35ms 18 120MB
战斗场景 210 28ms 22 80MB
背包界面 280 30ms 20 95MB

数据分析

  • 主界面相对健康,Frame Time 12ms(目标 16.67ms),但 FPS 只有 45,说明还有优化空间
  • 卡牌列表是重灾区:DrawCall 320(超标 6 倍),Frame Time 35ms(超标 2 倍)
  • 战斗场景 DrawCall 210,主要是特效和 UI 叠加
  • 背包界面和卡牌列表类似,都是大量物品格子导致

1.3 分阶段优化方案

为什么分阶段?

一次性改太多东西,出了问题不知道是哪一步导致的。分阶段就像科学实验:每次只改一个变量,观察效果,确认有效后再进行下一步。

阶段 1:DrawCall 优化(目标 <50)

为什么先优化 DrawCall?

因为 DrawCall 是最容易"立竿见影"的优化。它就像交通堵塞——先把路拓宽(减少 DrawCall),再考虑其他问题。

问题分析(用 Spector.js 抓帧验证):
  - 320 DrawCall 中,200+ 来自卡牌列表
  - 每个卡牌的构成:
    * 背景图(1 个 Sprite)
    * 角色图(1 个 Sprite)
    * 品质框(1 个 Sprite)
    * 星级 × 5(5 个 Sprite)
    * 文字(1 个 Label)
    * 总计:约 9 个渲染组件 × 显示约 20 张卡牌 = 180+ DrawCall
  
  - 根本原因:纹理切换频繁,几乎无合批
    * 每张卡牌的角色图是不同的纹理
    * 文字使用系统字体,每个 Label 单独一个 DrawCall
    * 星级是 5 个独立的 Sprite

优化措施(每个措施都解释"为什么有效"):

  1. 卡牌列表改为虚拟列表
     → 为什么有效?虚拟列表只渲染屏幕上可见的卡牌(比如 8 张),
        而不是全部 1000 张。不可见的卡牌不创建、不渲染。
     → DrawCall 从 200+ 降到 8(只画可见的 8 张卡牌)
     → 生活类比:就像餐厅只准备当前客人的菜,不用把菜单上所有菜都做出来。

  2. 卡牌图标使用动态图集
     → 为什么有效?动态图集(Dynamic Atlas)把多个小纹理合并到一张大纹理。
        原本 20 张卡牌需要 20 次纹理切换(20 DrawCall),
        合并后只需要 1 张纹理(1 DrawCall)。
     → 所有卡牌图标合并到 1~2 张纹理
     → 单个卡牌的多个 Sprite 可合批(因为纹理相同了)
     → 生活类比:就像把 20 个快递包裹合并成 1 个大包裹寄出,快递费大幅降低。

  3. 星级使用九宫格 + 平铺
     → 为什么有效?原本 5 个星级 = 5 个 Sprite = 5 DrawCall。
        九宫格(Sliced)+ 平铺(Tiled)可以用 1 个 Sprite 画出 5 个星星。
     → 5 个星级 Sprite → 1 个 Sliced Sprite
     → DrawCall 再减 4
     → 生活类比:原本需要 5 张纸贴 5 颗星,现在用 1 张长纸条连续印 5 颗星。

  4. 文字使用 Bitmap Font
     → 为什么有效?系统字体 Label 每个都是独立纹理和 DrawCall。
        Bitmap Font(位图字体)把文字预渲染到一张纹理上,
        多个文字可以合批到同一个 DrawCall。
     → Label 不触发 batch break(合批中断)
     → 所有文字合并为 1~2 DrawCall
     → 生活类比:系统字体就像现场手写菜单,每人一份;Bitmap Font 就像预印的菜单,一次印 100 份。

阶段 1 结果

场景 优化前 优化后 提升
卡牌列表 320 12 26×
背包界面 280 8 35×
战斗场景 210 35

自问自答Q:为什么战斗场景只优化了 6 倍,不如卡牌列表 26 倍? A:战斗场景的 DrawCall 来源更复杂:角色动画、技能特效、UI 血条、伤害数字等。这些元素纹理不同、层级交错,不像卡牌列表那样规则。后续阶段会继续优化战斗场景。

阶段 2:内存优化(目标 <60MB)

为什么第二阶段优化内存?

DrawCall 优化后游戏变流畅了,但如果内存占用高,低端机会闪退。内存优化是"稳定性"问题,比帧率更重要(闪退比卡顿更致命)。

问题分析(用 Chrome Memory 面板验证):
  - 纹理内存 85MB(大量 1024×1024 全尺寸加载)
    * 为什么这么大?每张卡牌角色图都是 1024×1024,
      但卡牌在屏幕上只显示 200×300 大小,大部分像素被浪费了。
  
  - 节点泄漏:切换场景后旧节点未销毁
    * 为什么?Cocos 的节点不会自动释放,需要手动销毁或放入对象池。
      如果忘记销毁,节点和关联的纹理会一直占用内存。
  
  - 图集未压缩:RGBA8888 格式,无压缩
    * 为什么?RGBA8888 每个像素 4 字节(红、绿、蓝、透明度各 1 字节)。
      1024×1024 的纹理 = 4MB。压缩后可以减少 50%~75%。

优化措施(每个措施都解释"为什么有效"):

  1. 纹理按需加载 + 引用计数
     → 为什么有效?不是一次性加载所有资源,而是"用的时候再加载,不用就释放"。
        引用计数跟踪每个纹理被多少个对象使用,
        当引用数降为 0 时自动释放。
     → 进入战斗才加载角色大图
     → 离开场景释放纹理
     → 生活类比:就像图书馆借书,需要的时候才去借,看完就还,而不是把整间图书馆搬回家。

  2. 纹理压缩格式
     → 为什么有效?不同 GPU 支持不同的压缩格式,压缩后纹理在 GPU 中占用更少显存。
     → Android:ETC2(减少 75% 显存)
        * ETC2 是 Android 设备广泛支持的压缩格式
        * 压缩后 1024×1024 纹理从 4MB 降到 1MB
     → iOS:ASTC 4×4(减少 75% 显存)
        * ASTC 是 iOS 设备支持的高质量压缩格式
        * 4×4 表示压缩率,数字越小质量越高、压缩率越低
     → Web:PVRTC / 保持 PNG(平台限制)
        * Web 平台受浏览器限制,通常只能用 PNG/JPG
        * 某些情况下可以用 Basis Universal 压缩
     → 生活类比:就像把文件从 BMP 格式转成 JPG 格式,画质差不多但文件小很多。

  3. 节点生命周期管理
     → 为什么有效?对象池复用节点,避免反复创建和销毁。
        创建节点是昂贵的操作(内存分配、组件初始化)。
     → 使用对象池复用卡牌节点
     → 场景切换时强制清理(destroyAllChildren)
     → 生活类比:就像餐厅的餐具,洗完后放回消毒柜复用,而不是每桌客人都买一套新餐具。

  4. Mipmap 策略
     → 为什么有效?Mipmap 是纹理的多级缩小版本(1/2、1/4、1/8...)。
        开启 Mipmap 后 GPU 根据距离自动选择合适的大小,减少锯齿。
        但 Mipmap 会增加 33% 的内存(原图 + 1/2 + 1/4 + ... ≈ 1.33 倍)。
     → 3D 纹理开启 Mipmap(远处物体需要小纹理)
     → UI 纹理关闭 Mipmap(UI 始终是 1:1 显示,不需要缩小)
     → 生活类比:Mipmap 就像准备同一张照片的多种尺寸:
        相册封面用小图(节省空间),放大看时用大图(清晰)。
        UI 始终按原尺寸看,不需要准备小图。

阶段 2 结果

指标 优化前 优化后
纹理内存 85MB 28MB
JS 堆内存 65MB 42MB
总内存峰值 150MB 70MB

阶段 3:Shader 与 Overdraw 优化

为什么第三阶段优化 Shader 和 Overdraw?

前两阶段优化了 CPU 端(DrawCall)和内存,现在该优化 GPU 端了。Overdraw 和 Shader 复杂度直接影响 GPU 负载,而 GPU 负载高是发热和耗电的主因。

问题分析(用 RenderDoc Overdraw 模式验证):
  - 战斗场景平均 Overdraw 4.2
    * 为什么高?UI 遮罩、技能特效、角色叠加,同一个像素被画了 4 次以上。
  
  - 技能特效大量使用加法混合,全屏叠加
    * 加法混合(Additive Blending)是特效常用的混合模式,
      但它会让像素更亮,多个特效叠加时 Overdraw 严重。
  
  - UI 遮罩使用半透明黑底,全覆盖
    * 半透明遮罩覆盖整个屏幕,每个像素都多画了一层。

优化措施(每个措施都解释"为什么有效"):

  1. 技能特效优化
     → 缩小特效区域(从全屏到 300×300)
        * 为什么有效?特效只影响 300×300 的区域,
          而不是整个屏幕。Overdraw 从覆盖 100% 降到覆盖 10%。
     → 限制同屏特效数量(最多 3 个)
        * 为什么有效?每个特效都是一层 Overdraw,
          限制数量直接限制最大 Overdraw。
     → 使用粒子系统代替全屏 Shader
        * 为什么有效?粒子系统只在粒子所在位置绘制,
          全屏 Shader 是整个屏幕每个像素都计算。
     → 生活类比:就像放烟花,原本整个天空都是烟花(全屏特效),
        现在只在广场中央放(缩小区域),而且同时最多放 3 个(限制数量)。

  2. UI 背景优化
     → 半透明遮罩 → 不透明深色底 + 边缘渐变
        * 为什么有效?半透明遮罩每个像素都计算混合(2 次绘制)。
          不透明底只需要画 1 次,边缘渐变只影响少量像素。
     → 减少 50% Overdraw
     → 生活类比:原本窗户上贴了一层磨砂膜(半透明,整个窗户都处理),
        现在换成实色窗帘(不透明,只挂一次),边缘加蕾丝花边(渐变)。

  3. Shader 精度优化
     → 片元着色器 mediump 替代 highp
        * 为什么有效?highp(高精度)每个计算用 32 位浮点,
          mediump(中精度)用 16 位浮点。
          GPU 处理 16 位比 32 位快,功耗也更低。
        * 注意:mediump 精度有限,某些需要精确计算的效果(如深度)不能降精度。
     → 减少 GPU cycle 15%
     → 生活类比:就像用计算器算账,highp 是精确到小数点后 10 位,
        mediump 是精确到 3 位。对于大部分日常计算,3 位就够了,还算得更快。

  4. Early-Z 友好化
     → 移除不必要的 discard
        * 为什么有效?discard 是 Shader 中的"丢弃"指令,
          告诉 GPU "这个像素不要画了"。但 discard 会禁用 Early-Z 优化,
          导致 GPU 无法提前剔除被遮挡的像素。
     → 不透明物体从前往后排序
        * 为什么有效?先画前面的不透明物体,后面的物体在深度测试时失败,
          GPU 就可以直接跳过,不用执行片元着色器。
     → 生活类比:Early-Z 就像排队买票。如果前面的人已经买了(画了),
        后面的人就不用再问了(深度测试失败,直接跳过)。
        discard 就像队伍里有人说"我不买了",导致后面的人都要重新排队。

阶段 3 结果

指标 优化前 优化后
平均 Overdraw 4.2 2.1
GPU Frame Time 18ms 10ms
设备发热 严重 温和

1.4 最终性能对比

场景 优化前 FPS 优化后 FPS 提升
主界面 45 60 33%
卡牌列表 18 60 233%
战斗场景 22 55 150%
背包界面 20 60 200%
30 分钟后 10 50 400%

自问自答Q:为什么战斗场景优化后只有 55 FPS,不能到 60? A:战斗场景有角色动画、技能特效、物理碰撞等复杂逻辑,即使渲染优化到极致,CPU 端的逻辑计算也可能成为瓶颈。55 FPS 已经是很好的结果,玩家几乎感受不到卡顿。


二、跨平台渲染差异与适配

2.1 各平台渲染后端

为什么需要了解平台差异?

不同平台就像不同国家的交通规则。你在一个国家开车靠右,另一个国家靠左。如果不了解差异,就会出事故。

术语解释

  • 渲染后端(Backend):引擎底层用来和 GPU 通信的 API。就像你和外国人交流,后端就是"翻译"。
  • WebGL:浏览器提供的 3D 渲染 API,基于 OpenGL ES。
  • GLES:OpenGL ES,移动设备常用的图形 API。
  • Metal:Apple 开发的图形 API,iOS/macOS 专用。
  • Vulkan:新一代跨平台图形 API,性能更好但开发更复杂。
平台 Cocos 后端 底层 API 特性
Web WebGL WebGL 1.0/2.0 受浏览器限制
微信小游戏 WebGL WebGL 1.0 无 WebGL 2.0
Android GLES OpenGL ES 3.0 完整特性
iOS Metal / GLES Metal / GLES 3.0 Metal 性能更好
原生桌面 Vulkan / GL Vulkan / OpenGL 最高性能

生活类比:不同平台的渲染后端就像不同品牌的手机充电器。WebGL 是通用 USB 充电(哪里都能用但慢),Metal 是苹果快充(iPhone 专用但快),Vulkan 是 120W 超级快充(最快但需要专门设备)。

2.2 平台特有坑点

Web 平台

// 坑 1:WebGL Context 丢失
// 为什么有这个问题?浏览器为了节省资源,
// 当标签页切换到后台或系统内存不足时,会回收 WebGL 上下文。
// 如果不处理,游戏画面会变黑或崩溃。

const canvas = document.getElementById('GameCanvas');
// 监听 WebGL 上下文丢失事件
canvas.addEventListener('webglcontextlost', (e) => {
    // 阻止浏览器的默认行为(默认是彻底销毁上下文)
    e.preventDefault();
    console.warn('WebGL context lost!');
    // 需要重新加载资源(纹理、Shader 等)
    // 实际项目中应该在这里触发资源重载逻辑
});

// 坑 2:最大纹理尺寸限制
// 为什么有这个问题?不同设备的 GPU 能力不同,
// 老旧设备无法处理太大的纹理。
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// PC 通常支持 16384×16384
// 手机通常支持 4096~8192
// 老旧手机可能只支持 2048
// 图集不能超过此限制,否则会导致渲染错误

// 坑 3:Mipmap 限制
// 为什么有这个问题?WebGL 1.0 的规范要求:
// 只有纹理尺寸是 2 的幂次方(如 128、256、512)才能使用 Mipmap。
// Cocos 会自动处理,但可能回退到无 Mipmap(画质下降)。
// 例如:100×100 的纹理在 WebGL 1.0 下无法生成 Mipmap。

微信小游戏

// 坑 1:无 WebGL 2.0
// 为什么?微信小游戏的运行环境基于浏览器内核,
// 但目前只支持 WebGL 1.0。
// 这意味着以下功能不可用:
// - UBO(Uniform Buffer Object):批量传递 Uniform 数据
// - 3D 纹理:体积纹理、纹理数组
// - 多重采样(MSAA):抗锯齿
// Cocos 会自动降级到 WebGL 1.0 路径,但某些效果可能无法实现。

// 坑 2:内存限制
// 为什么?小游戏运行在微信进程中,微信限制了每个小游戏的内存使用。
// 总内存约 200~400MB,包括代码、资源、运行时数据。
// 需要更激进的资源释放策略。

// 坑 3:iOS 高性能模式
// 为什么?iOS 小游戏默认使用 JavaScriptCore 引擎,性能较弱。
// wx.triggerGC() 可以主动触发垃圾回收,释放内存。
// 但频繁调用 GC 也会导致卡顿,需要平衡。
if (sys.platform === sys.Platform.WECHAT_GAME && sys.os === sys.OS.IOS) {
    // 每 30 秒触发一次垃圾回收
    setInterval(() => {
        wx.triggerGC();
    }, 30000);
}

Android

// 坑 1:GPU 驱动差异大
// 为什么?Android 设备品牌众多,GPU 厂商不同,驱动实现有差异。
// 同样的代码在不同手机上表现可能不同。

// Mali GPU(常见于华为、三星):Early-Z 较弱
// Early-Z 是 GPU 的优化技术,提前剔除被遮挡的像素。
// Mali 的 Early-Z 不够激进,discard 指令会严重影响性能。
// 注意:避免在 Shader 中使用 discard。

// Adreno GPU(常见于高通骁龙):Shader 编译慢
// 为什么?Adreno 的 Shader 编译器优化策略不同,
// 首次使用某个 Shader 时可能卡顿几百毫秒。
// 解决:游戏启动时预热(Pre-warm)所有 Shader。

// PowerVR(常见于联发科):TBDR 架构
// TBDR(Tile-Based Deferred Rendering)是移动端常用架构。
// 特点是按"瓦片"(Tile)渲染,透明物体排序很重要。
// 注意:透明物体要从远到近排序,减少 Overdraw。

// 坑 2:纹理压缩格式检测
// 为什么?不同 GPU 支持的压缩格式不同,
// 必须在运行时检测,否则纹理无法加载。
// ETC2:大部分 Android 设备支持
// ASTC:较新的设备支持,压缩质量更好
const supportETC2 = device.getFormatFeatures(Format.ETC2_RGB8);
const supportASTC = device.getFormatFeatures(Format.ASTC_RGBA_4X4);

// 坑 3:多分辨率适配
// 为什么?Android 设备分辨率差异极大,
// 从 720p 到 2K、4K 都有。
// 如果统一用最高分辨率纹理,低端机内存会爆。
// 解决:根据设备性能动态选择纹理质量。

iOS

// 坑 1:Metal 与 GLES 差异
// 为什么?Cocos 在 iOS 上可以选择 Metal 或 GLES 后端。
// Metal 是 Apple 原生 API,性能更好、DrawCall 开销更低。
// GLES 是通用 API,兼容性更好但性能稍差。
// 建议:优先使用 Metal 后端。

// 坑 2:内存警告
// 为什么?iOS 系统对内存管理非常严格,
// 当内存不足时会发送内存警告,如果应用不释放内存,会被系统强制杀死。
import { game } from 'cc';
// 监听内存警告事件
game.on(Game.EVENT_LOW_MEMORY, () => {
    console.warn('Low memory warning!');
    // 必须响应:释放缓存、降低纹理质量、清理不用的资源
    // 如果不处理,游戏会被系统杀掉(闪退)
});

// 坑 3:热节流
// 为什么?iPhone 长时间高负载会发热,
// 系统为了保护硬件会自动降低 CPU/GPU 频率。
// 降频后性能下降,帧率降低。
// 解决:控制帧率或降低画质,避免持续高负载。

2.3 平台适配代码框架

为什么需要适配框架?

如果没有统一的适配框架,平台相关的代码会散落在项目各处,维护困难。适配框架就像"电源适配器",把不同国家的电压转换成统一的标准。

// PlatformAdapter.ts
// 平台适配器:根据运行平台自动调整配置

import { sys, director, gfx } from 'cc';

export class PlatformAdapter {
    // 默认配置(高性能设备的默认值)
    static readonly config = {
        maxTextureSize: 2048,        // 最大纹理尺寸
        useDynamicAtlas: true,       // 是否使用动态图集
        useMipmap: true,             // 是否使用 Mipmap
        maxParticles: 100,           // 最大粒子数量
        targetFrameRate: 60,         // 目标帧率
        textureQuality: 'high' as 'high' | 'medium' | 'low'  // 纹理质量
    };
    
    // 初始化:根据平台自动调整配置
    static init() {
        // 获取当前平台信息
        const platform = sys.platform;
        const os = sys.os;
        
        // 根据平台类型选择初始化逻辑
        if (platform === sys.Platform.WECHAT_GAME) {
            this._initWechat();
        } else if (platform === sys.Platform.ANDROID) {
            this._initAndroid();
        } else if (platform === sys.Platform.IOS) {
            this._initIOS();
        } else if (sys.isBrowser) {
            this._initWeb();
        }
        
        // 设备性能分级:通过简单测试判断设备性能
        this._detectPerformanceLevel();
        
        // 输出最终配置,方便调试
        console.log('[PlatformAdapter] Config:', this.config);
    }
    
    // 微信小游戏初始化
    private static _initWechat() {
        // 小游戏内存限制更严格,降低配置
        this.config.maxTextureSize = 2048;    // 限制纹理尺寸
        this.config.useDynamicAtlas = true;   // 开启动态图集减少 DrawCall
        this.config.maxParticles = 50;        // 减少粒子数量
        
        // iOS 小游戏性能更弱,进一步降低
        if (sys.os === sys.OS.IOS) {
            this.config.targetFrameRate = 30;     // 目标帧率降到 30
            this.config.textureQuality = 'medium'; // 纹理质量降到中
        }
    }
    
    // Android 初始化
    private static _initAndroid() {
        // 获取 GPU 型号字符串
        const gpu = sys.gpuRenderer || '';
        
        // 极低端 Mali GPU(如 Mali-400)
        if (gpu.includes('Mali') && gpu.includes('400')) {
            this.config.maxTextureSize = 1024;   // 大幅降低纹理尺寸
            this.config.useMipmap = false;        // 关闭 Mipmap 节省内存
            this.config.textureQuality = 'low';   // 低质量纹理
            this.config.targetFrameRate = 30;     // 30 FPS 目标
        } 
        // 低端 Adreno GPU(如 Adreno 3xx 系列)
        else if (gpu.includes('Adreno') && gpu.includes('3')) {
            this.config.textureQuality = 'low';   // 低质量纹理
            this.config.maxParticles = 50;        // 减少粒子
        }
        // 其他设备保持默认配置
    }
    
    // iOS 初始化
    private static _initIOS() {
        // iOS 通常性能较好,保持较高配置
        this.config.maxTextureSize = 4096;    // 支持大纹理
        this.config.useMipmap = true;         // 开启 Mipmap
        
        // 监听内存警告(iOS 特有)
        game.on(Game.EVENT_LOW_MEMORY, () => {
            this._onLowMemory();
        });
    }
    
    // Web 平台初始化
    private static _initWeb() {
        // 检测 WebGL 2.0 支持
        const canvas = document.createElement('canvas');
        const gl = canvas.getContext('webgl2');
        
        if (!gl) {
            // 无 WebGL 2.0,降低配置
            this.config.useDynamicAtlas = false;  // 动态图集可能依赖 WebGL 2.0 特性
            this.config.maxParticles = 30;        // 大幅减少粒子
        }
    }
    
    // 设备性能分级检测
    private static _detectPerformanceLevel() {
        // 通过简单 CPU 测试判断性能等级
        const start = performance.now();
        let sum = 0;
        // 执行 100 万次平方根计算
        for (let i = 0; i < 1000000; i++) {
            sum += Math.sqrt(i);
        }
        const duration = performance.now() - start;
        
        // 根据耗时分级
        if (duration > 50) {
            // 超过 50ms 认为是低端设备
            this.config.textureQuality = 'low';
            this.config.targetFrameRate = 30;
        } else if (duration > 20) {
            // 20~50ms 是中端设备
            this.config.textureQuality = 'medium';
        }
        // 小于 20ms 保持默认 high 配置
    }
    
    // 内存警告处理
    private static _onLowMemory() {
        // 降低纹理质量
        this.config.textureQuality = 'low';
        // 通知场景释放资源
        director.getScene()?.emit('low-memory');
        // 触发全局资源释放(由资源管理器处理)
    }
}

初学者常见错误

  1. 不做平台适配,一套配置跑所有平台

    • 错误:在 iPhone 14 上开发,配置拉满,然后直接在低端 Android 上运行
    • 结果:低端机直接闪退或卡成幻灯片
    • 解决:必须做平台适配,根据设备能力动态调整
  2. 忽略内存警告

    • 错误:收到 EVENT_LOW_MEMORY 后什么都不做
    • 结果:iOS 系统直接杀掉应用
    • 解决:必须响应内存警告,释放缓存、降低质量
  3. 性能检测代码放在游戏循环里

    • 错误:每帧都执行 Math.sqrt 测试
    • 结果:测试本身消耗大量 CPU
    • 解决:性能检测只在初始化时执行一次

自问自答

Q:为什么微信小游戏没有 WebGL 2.0? A:微信小游戏的运行环境是微信内置的浏览器内核,目前基于 WebGL 1.0。微信团队可能出于兼容性和稳定性考虑,没有升级到 WebGL 2.0。

Q:性能分级检测的 Math.sqrt 测试准吗? A:不太准,但够用。它只能粗略区分"很卡"和"不卡"的 device。更精确的方法是检测 GPU 型号(如 sys.gpuRenderer),但型号检测需要维护一个庞大的设备数据库。


三、常见坑点与解决方案

3.1 渲染层级

坑点 现象 原因 解决
合批突然失效 某帧 DrawCall 暴增 动态加载的纹理未进入图集 预加载 + 强制图集
Mask 嵌套过多 帧率骤降 每层 Mask = 1 额外 DrawCall 限制嵌套 ≤3,用 RectMask
Label 打断合批 文字区域单独 DrawCall Label 使用独立纹理 使用 Bitmap Font
Spine 动画卡顿 每帧重建网格 Spine 的 mesh 模式 使用骨骼动画或预烘焙
粒子系统过载 GPU 时间飙升 粒子数过多 / 纹理过大 限制同屏粒子数
动态创建节点 内存泄漏 未正确销毁 对象池 + 弱引用
场景切换闪白 旧场景已清,新场景未加载 异步加载无占位 添加 Loading 遮罩
远处闪烁 z-fighting 深度精度不足 调整 near/far 比
半透明排序错误 后面的物体显示在前面 透明物体未从远到近排序 设置 RenderQueue
Shader 编译卡顿 首次显示某材质时卡顿 Shader 实时编译 预编译 / 变体预热

术语解释

  • z-fighting:两个物体重叠时,由于深度精度不足,GPU 无法判断哪个在前面,导致像素闪烁。
  • RenderQueue:渲染队列,数值小的先画,数值大的后画。透明物体通常设置较大的 RenderQueue。

3.2 对象池完整实现

为什么需要对象池?

游戏中频繁创建和销毁节点(如子弹、伤害数字、卡牌)会导致:

  1. 内存碎片:反复分配释放导致内存不连续
  2. GC 卡顿:JS 垃圾回收时会暂停程序
  3. 初始化开销:每个新节点都要执行构造函数和 onLoad

对象池就像餐厅的餐具消毒柜:用过的餐具不扔掉,洗干净放回柜子里,下一位客人直接拿。

// ObjectPool.ts
// 通用对象池实现

// 泛型约束:T 必须具有 active、setParent、destroy 方法
// 这些方法是 Cocos Node 的基础方法
export class ObjectPool<T extends { active: boolean; setParent(p: Node|null): void; destroy(): void }> {
    // 对象池:存储空闲的对象
    private _pool: T[] = [];
    // 对象创建函数:当池中没有空闲对象时调用
    private _creator: () => T;
    // 对象重置函数:取出对象时调用,恢复初始状态
    private _resetter: (obj: T) => void;
    // 对象池最大容量,防止无限增长
    private _maxSize: number;
    // 当前活跃(使用中)的对象数量
    private _activeCount: number = 0;
    
    /**
     * 构造函数
     * @param creator 创建对象的工厂函数
     * @param resetter 重置对象的函数(取出时调用)
     * @param maxSize 对象池最大容量(默认 50)
     */
    constructor(
        creator: () => T,
        resetter: (obj: T) => void,
        maxSize: number = 50
    ) {
        this._creator = creator;
        this._resetter = resetter;
        this._maxSize = maxSize;
    }
    
    /**
     * 从对象池获取一个对象
     * @returns 可用的对象
     */
    get(): T {
        let obj: T;
        
        // 如果池中有空闲对象,直接取出
        if (this._pool.length > 0) {
            // pop() 从数组末尾取出,时间复杂度 O(1)
            obj = this._pool.pop();
            // 调用重置函数,恢复对象初始状态
            this._resetter(obj);
        } else {
            // 池中没有空闲对象,创建新的
            obj = this._creator();
        }
        
        // 标记为活跃状态
        obj.active = true;
        // 活跃计数加 1
        this._activeCount++;
        return obj;
    }
    
    /**
     * 回收对象到池中
     * @param obj 要回收的对象
     */
    put(obj: T) {
        // 标记为非活跃
        obj.active = false;
        // 从父节点移除(但保留在内存中)
        obj.setParent(null);
        // 活跃计数减 1
        this._activeCount--;
        
        // 如果池未满,放入池中复用
        if (this._pool.length < this._maxSize) {
            this._pool.push(obj);
        } else {
            // 池已满,销毁对象释放内存
            obj.destroy();
        }
    }
    
    /**
     * 预热:预先创建一批对象
     * @param count 预热数量
     */
    prewarm(count: number) {
        for (let i = 0; i < count; i++) {
            const obj = this._creator();
            obj.active = false;
            this._pool.push(obj);
        }
    }
    
    /**
     * 清空对象池
     */
    clear() {
        // 销毁池中所有对象
        for (const obj of this._pool) {
            obj.destroy();
        }
        // 清空数组
        this._pool.length = 0;
        this._activeCount = 0;
    }
    
    // 获取池中空闲对象数量
    get size(): number { return this._pool.length; }
    // 获取活跃对象数量
    get activeCount(): number { return this._activeCount; }
}

// 使用示例:卡牌对象池
const cardPool = new ObjectPool<Node>(
    // creator:创建新卡牌节点
    () => instantiate(cardPrefab),
    // resetter:重置卡牌状态(取出时调用)
    (node) => {
        // 重置位置到原点
        node.setPosition(Vec3.ZERO);
        // 重置缩放为 1
        node.setScale(Vec3.ONE);
        // 可以在这里重置其他状态(如旋转、透明度等)
    },
    30  // 最大容量 30 个
);

// 预热:游戏启动时预先创建 20 个卡牌
// 避免游戏过程中创建节点导致的卡顿
cardPool.prewarm(20);

// 使用:获取一个卡牌节点
const card = cardPool.get();
// 添加到场景中
parent.addChild(card);

// 回收:卡牌不需要时放回池中
cardPool.put(card);
// 注意:put 后 card 节点还在内存中,只是从场景移除了

初学者常见错误

  1. 回收后还继续使用对象

    • 错误:cardPool.put(card) 后还访问 card
    • 结果:对象可能已经被复用,状态不确定
    • 解决:put 后立即把引用设为 null
  2. 忘记预热

    • 错误:游戏过程中第一次 get() 时创建节点,导致卡顿
    • 解决:在场景加载时调用 prewarm()
  3. 对象池容量设置太小

    • 错误:同时需要 50 个对象,但池容量只有 10
    • 结果:频繁的创建和销毁,失去对象池的意义
    • 解决:根据峰值使用量设置容量

自问自答

Q:对象池和直接创建销毁相比,能提升多少性能? A:取决于创建对象的复杂度。简单对象可能提升 2~3 倍,复杂对象(如带 Spine 动画的节点)可能提升 10 倍以上。关键是避免了 GC 卡顿。

Q:对象池里的对象会一直占用内存吗? A:是的,这是对象池的代价——用内存换 CPU。如果内存紧张,可以减小 _maxSize,或者在收到内存警告时调用 clear()


四、性能测试方法论

4.1 测试矩阵

为什么需要测试矩阵?

你不能只在高端机上测试。真实用户有各种设备,必须在"最差情况"下也能流畅运行。测试矩阵确保你覆盖了主要用户群体。

测试项 低端机 中端机 高端机
设备示例 红米 9A 小米 10 iPhone 14
GPU PowerVR GE8320 Adreno 650 A16
目标帧率 30 FPS 60 FPS 60 FPS
DrawCall 上限 30 80 200
纹理内存上限 50MB 100MB 200MB
测试场景 全部 全部 全部

生活类比:测试矩阵就像汽车的碰撞测试。不仅要在理想路况测试,还要在冰雪路面、山路、拥堵路段都测试。只有通过了所有测试,才能上市销售。

4.2 自动化性能测试脚本

为什么需要自动化测试?

手动测试的问题是:

  1. 不精确:人眼判断"卡不卡"误差很大
  2. 不可重复:今天测和明天测条件不同
  3. 耗时:需要人工盯着屏幕

自动化测试就像工厂的"质检机器人",24 小时不间断,结果精确可对比。

// PerformanceTest.ts
// 自动化性能测试类

export class PerformanceTest {
    // 存储所有测试结果
    private _results: any[] = [];
    
    /**
     * 运行场景性能测试
     * @param sceneName 场景名称
     * @param duration 测试持续时间(秒,默认 30 秒)
     * @returns 测试结果对象
     */
    async runSceneTest(sceneName: string, duration: number = 30): Promise<any> {
        console.log(`[PerfTest] Starting: ${sceneName}`);
        
        // 采样数据结构
        const samples = {
            fps: [],        // 帧率样本
            drawCalls: [],  // DrawCall 样本
            frameTime: [],  // 帧时间样本
            memory: []      // 内存样本
        };
        
        // 记录开始时间
        const startTime = performance.now();
        
        // 在指定时间内持续采样
        while (performance.now() - startTime < duration * 1000) {
            // 收集当前帧的数据
            samples.fps.push(this._getFPS());
            samples.drawCalls.push(this._getDrawCall());
            samples.frameTime.push(this._getFrameTime());
            samples.memory.push(this._getMemory());
            
            // 等待 100ms 后再次采样
            await this._wait(100);
        }
        
        // 分析采样数据
        const result = this._analyze(samples);
        result.scene = sceneName;
        result.duration = duration;
        
        // 保存结果
        this._results.push(result);
        console.log(`[PerfTest] Result:`, result);
        
        return result;
    }
    
    /**
     * 分析采样数据
     * @param samples 原始采样数据
     * @returns 统计分析结果
     */
    private _analyze(samples: any): any {
        // 计算平均值
        const avg = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;
        // 计算最小值
        const min = (arr: number[]) => Math.min(...arr);
        // 计算 1% 最低值(P1)
        // P1 表示最差的 1% 帧的性能,反映卡顿的严重程度
        const p1 = (arr: number[]) => arr.sort((a, b) => a - b)[Math.floor(arr.length * 0.01)];
        
        return {
            avgFPS: avg(samples.fps),           // 平均帧率
            minFPS: min(samples.fps),           // 最低帧率
            p1FPS: p1(samples.fps),             // 1% Low FPS
            avgDrawCall: avg(samples.drawCalls), // 平均 DrawCall
            avgFrameTime: avg(samples.frameTime), // 平均帧时间
            avgMemory: avg(samples.memory),      // 平均内存
            // 通过条件:平均帧率 > 30 且平均 DrawCall < 100
            pass: avg(samples.fps) > 30 && avg(samples.drawCalls) < 100
        };
    }
    
    /**
     * 获取当前 FPS
     */
    private _getFPS(): number {
        // director.getDeltaTime() 返回上一帧的耗时(秒)
        // FPS = 1 / 帧耗时
        return Math.round(1000 / director.getDeltaTime());
    }
    
    /**
     * 获取当前 DrawCall
     */
    private _getDrawCall(): number {
        const device = director.root.device;
        const stats = (device as any)._stats;
        // drawArrays + drawElements = 总 DrawCall
        return (stats?.drawArrays || 0) + (stats?.drawElements || 0);
    }
    
    /**
     * 获取当前帧时间(毫秒)
     */
    private _getFrameTime(): number {
        return director.getDeltaTime() * 1000;
    }
    
    /**
     * 获取当前内存使用(MB)
     */
    private _getMemory(): number {
        const mem = (performance as any).memory;
        // usedJSHeapSize 是已使用的 JS 堆内存(字节)
        return mem ? Math.round(mem.usedJSHeapSize / 1048576) : 0;
    }
    
    /**
     * 等待指定毫秒
     */
    private _wait(ms: number): Promise<void> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    /**
     * 生成 Markdown 格式的测试报告
     * @returns 报告字符串
     */
    generateReport(): string {
        let report = '# 性能测试报告\n\n';
        
        for (const r of this._results) {
            report += `## ${r.scene}\n`;
            report += `- 平均 FPS: ${r.avgFPS.toFixed(1)}\n`;
            report += `- 最低 FPS: ${r.minFPS}\n`;
            report += `- 1% Low FPS: ${r.p1FPS.toFixed(1)}\n`;
            report += `- 平均 DrawCall: ${r.avgDrawCall.toFixed(0)}\n`;
            report += `- 平均帧时间: ${r.avgFrameTime.toFixed(2)}ms\n`;
            report += `- 平均内存: ${r.avgMemory.toFixed(1)}MB\n`;
            report += `- 结果: ${r.pass ? '通过' : '不通过'}\n\n`;
        }
        
        return report;
    }
}

初学者常见错误

  1. 采样间隔太短

    • 错误:每帧都采样,导致测试本身影响性能
    • 解决:每 100ms 采样一次足够
  2. 只看平均 FPS

    • 错误:平均 60 FPS 就以为没问题
    • 问题:可能大部分时间 60 FPS,偶尔掉到 10 FPS
    • 解决:同时关注 minFPS 和 p1FPS
  3. 测试时间太短

    • 错误:只测 5 秒
    • 问题:热节流通常在 10 分钟后才出现
    • 解决:至少测试 30 秒,发热测试要 10 分钟以上

自问自答

Q:1% Low FPS 是什么意思? A:把所有帧的 FPS 排序,取最差的 1% 的平均值。如果 1% Low FPS 是 20,意味着有 1% 的帧低于 20 FPS,玩家会感受到明显卡顿。

Q:自动化测试能完全替代人工测试吗? A:不能。自动化测试测的是"数据",人工测试测的是"感受"。有些问题(如动画不流畅、特效刺眼)数据上看正常,但玩家感受不好。两者结合最好。


五、完整知识图谱

5.1 渲染知识总图

Cocos Creator 3.8.x 渲染知识体系
│
├── 01 基础概念篇
│   ├── GPU 渲染管线(8 阶段)
│   │   ├── Input Assembler(输入装配:把顶点数据传给 GPU)
│   │   ├── Vertex Shader(顶点着色器:处理顶点位置)
│   │   ├── Tessellation / Geometry(细分/几何:增加细节)
│   │   ├── Clipping(裁剪:去掉看不到的部分)
│   │   ├── Rasterization(光栅化:把三角形变成像素)
│   │   ├── Fragment Shader(片元着色器:计算像素颜色)
│   │   ├── Per-Fragment Operations(逐像素操作:深度测试、混合等)
│   │   │   ├── Scissor Test(裁剪测试)
│   │   │   ├── Stencil Test(模板测试)
│   │   │   ├── Depth Test(深度测试)
│   │   │   ├── Blending(颜色混合)
│   │   │   └── Dithering(抖动)
│   │   └── Frame Buffer(帧缓冲:最终输出到屏幕)
│   │
│   └── 核心名词(58 项)
│       ├── 渲染管线术语
│       ├── GPU 架构术语
│       ├── WebGL/API 术语
│       ├── 性能指标
│       ├── Cocos 引擎术语
│       └── 高级渲染术语
│
├── 02 Cocos 渲染架构篇
│   ├── Batcher2D(2D 合批器)
│   │   ├── walk() DFS 遍历(深度优先遍历场景树)
│   │   ├── commitComp() 合批判断(4 条件:纹理/材质/模板/混合)
│   │   └── flush() GPU 提交(把合批后的数据发给 GPU)
│   ├── RenderPipeline(渲染管线)
│   │   ├── 相机优先级排序(决定哪个相机先渲染)
│   │   └── RenderPass 执行(执行每个渲染通道)
│   ├── GFX 抽象层(跨平台图形 API 抽象)
│   │   ├── WebGL / WebGL2
│   │   ├── Metal
│   │   └── Vulkan
│   ├── 材质系统
│   │   ├── Effect → Technique → Pass(效果 → 技术 → 通道)
│   │   └── Material → 运行时实例
│   └── 线程模型
│       ├── H5:单线程(逻辑和渲染在同一线程)
│       └── Native:双线程(逻辑 + 渲染分离)
│
├── 03 DrawCall 优化篇
│   ├── 静态合批(编译时合并)
│   ├── 动态合批(运行时合并)
│   ├── GPU Instancing(一次画多个相同物体)
│   ├── 图集策略
│   │   ├── 按模块打包(UI、角色、特效分开)
│   │   ├── 按渲染顺序打包(相邻绘制的放一起)
│   │   ├── 平台尺寸控制(不同平台不同大小)
│   │   └── 动态图集(运行时自动合并)
│   ├── 层级优化(减少节点层级)
│   └── Profiler 分析(用工具定位问题)
│
├── 04 Shader 篇
│   ├── GLSL 语法
│   ├── Uniform 硬件原理
│   ├── 深度测试(数学推导)
│   ├── 模板测试(Mask 实现)
│   ├── 混合公式推导
│   ├── Shader 变体(不同平台/质量的版本)
│   ├── 多 Pass 渲染(一个物体画多次)
│   └── Cocos Effect 实践
│
├── 05 渲染优化篇
│   ├── 虚拟列表(只渲染可见项)
│   │   ├── 固定高度
│   │   ├── 不等高(二分查找)
│   │   └── 网格列表
│   ├── 节点池(复用节点)
│   ├── 与 Batcher2D 协同
│   └── 内存管理(加载/释放/压缩)
│
├── 06 调试与工具篇
│   ├── Cocos Stats(内置性能面板)
│   ├── Chrome DevTools(浏览器调试)
│   ├── Spector.js(WebGL 抓帧)
│   ├── RenderDoc(原生 GPU 调试)
│   ├── Overdraw 分析
│   ├── Shader 调试
│   └── 移动端调试
│
└── 07 进阶实战篇
    ├── 综合优化案例(卡牌游戏)
    ├── 跨平台适配
    ├── 常见坑点
    ├── 对象池
    └── 性能测试

5.2 性能优化速查表

问题 快速诊断 解决方案
DrawCall 高 Spector.js 看 batch break 图集、排序、虚拟列表
Frame Time 高 Chrome Performance 减少 JS 计算、对象池
GPU Time 高 RenderDoc / Spector.js 简化 Shader、减少 Overdraw
内存高 Chrome Memory 纹理压缩、按需加载、释放
发热 手感 + 长时间测试 降帧率、降画质、限制特效
卡顿 观察首次/偶发 Shader 预热、资源预加载
闪退 日志 + 内存监控 减少内存峰值、响应警告

5.3 月影课程章节映射

本教程章节 月影课程章节 核心内容
01 GPU 渲染管线 第 01~05 章 渲染管线、坐标变换、GPU 架构
02 核心名词 第 06~08 章 术语体系、概念辨析
03 Cocos 架构 第 09~11 章 引擎渲染流程、材质系统
04 DrawCall 优化 第 12~14 章 合批、图集、层级优化
05 Shader 入门 第 15~18 章 着色器、Uniform、多 Pass
06 虚拟列表 第 19~22 章 大规模数据、列表优化
07 调试工具 第 23~28 章 性能分析、调试技巧
08 进阶实战 第 29~41 章 综合实战、跨平台、前沿

六、自检清单(全课程)

6.1 基础概念

检查项 说明
□ 能画出完整渲染管线图 8 个阶段,数据流向
□ 理解 NDC 到屏幕的映射 视口变换
□ 掌握深度值的非线性分布 公式推导
□ 知道 Early-Z 的触发条件 无 discard、无 gl_FragDepth
□ 理解 GPU 内存层级 VRAM → L2 → Shared → Register

6.2 Cocos 架构

检查项 说明
□ 能描述 Batcher2D 的 3 个核心函数 walk / commitComp / flush
□ 知道合批的 4 个条件 texture / material / stencil / blend
□ 理解 RenderPipeline 的执行流程 相机排序 → RenderPass
□ 知道 GFX 的作用 跨平台图形 API 抽象
□ 能解释 Effect/Technique/Pass 关系 YAML 结构

6.3 优化实践

检查项 说明
□ 能独立优化 DrawCall 从 200+ 降到 <50
□ 会实现虚拟列表 固定高 + 不等高
□ 会写自定义 Shader Effect 文件 + Material
□ 会使用 Spector.js 抓帧、分析合批
□ 会检测 Overdraw 热力图、优化策略
□ 能处理跨平台差异 Web/小游戏/Android/iOS
□ 会写性能测试 自动化基准测试

七、扩展阅读与资源

7.1 推荐书籍

书名 作者 重点章节
《Real-Time Rendering, 4th》 Tomas Akenine-Möller 全书
《GPU Gems》系列 NVIDIA Shader 优化
《WebGL Programming Guide》 Kouichi Matsuda 基础 API
《OpenGL ES 3.0 Programming Guide》 Dan Ginsburg 移动端

7.2 在线资源

资源 链接 用途
WebGL Fundamentals webglfundamentals.org WebGL 基础教程
The Book of Shaders thebookofshaders.com Shader 入门
Cocos 官方文档 docs.cocos.com 引擎 API
GPUOpen gpuopen.com AMD GPU 优化
ARM Mali 优化指南 developer.arm.com 移动端优化

7.3 工具下载

工具 平台 下载
Spector.js Chrome 扩展 Chrome Web Store
RenderDoc Windows/Linux/Android renderdoc.org
Intel GPA Windows intel.com/content/www/us/en/developer/tools/graphics-performance-analyzers.html
Snapdragon Profiler Android developer.qualcomm.com

附录:实战优化 Checklist

当你拿到一个新项目需要优化时,按以下顺序执行:

第一步:收集基线数据(1~2 天)

  • 在所有目标设备上运行游戏
  • 记录每个场景的 FPS、DrawCall、Frame Time、内存
  • 用 Spector.js 抓帧,记录典型帧的 DrawCall 分布
  • 用 Chrome Performance 分析 JS 耗时

第二步:定位瓶颈(1~2 天)

  • 如果 DrawCall > 100,优先优化合批
  • 如果 Frame Time > 16ms 但 DrawCall 低,检查 JS 逻辑
  • 如果 GPU Time 高,检查 Overdraw 和 Shader
  • 如果内存持续增长,检查泄漏

第三步:分阶段优化(1~2 周)

  • 阶段 1:DrawCall 优化(虚拟列表、图集、合批)
  • 阶段 2:内存优化(按需加载、压缩、对象池)
  • 阶段 3:GPU 优化(Overdraw、Shader 精度、Early-Z)

第四步:验证效果(2~3 天)

  • 对比优化前后的性能数据
  • 在低端机上长时间运行(30 分钟以上)
  • 检查发热和耗电情况
  • 确保没有引入新的 bug

第五步:上线监控(持续)

  • 接入性能监控 SDK
  • 收集真实用户的 FPS、内存、崩溃率
  • 根据数据持续优化

结语:渲染优化是一个持续迭代的过程。掌握原理、善用工具、数据驱动,才能在各种平台上交付流畅的体验。祝你的游戏 60 FPS 常驻!