进阶实战与知识图谱:从理论到上线的完整闭环
对应月影课程:第 29~41 章(综合实战、性能调优、跨平台、前沿技术)
目标:整合前面所有知识,完成一个可上线的渲染优化实战案例,并建立完整的知识索引体系。
写在前面:为什么需要进阶实战?
如果不懂实战整合会怎样?
想象你学了很多烹饪技巧:会切菜、会炒菜、会调味、会摆盘。但当你真正要办一场 100 人的宴席时,却发现:
- 不知道先做哪道菜(优化顺序混乱)
- 切菜太慢导致后面的菜赶不上(瓶颈分析错误)
- 所有菜一起下锅,厨房乱成一团(没有优先级)
- 客人吃到一半菜凉了(没有持续监控)
学习渲染优化也是一样。你可能已经学会了:
- 什么是 DrawCall
- 什么是合批
- 什么是 Shader
- 什么是纹理压缩
但知道每个知识点 ≠ 能做出流畅的游戏。进阶实战就是教你如何把零散的知识点组合成一套完整的"优化 workflow",让你面对真实项目时知道:
- 先测什么:拿到项目第一步该做什么?
- 问题在哪:数据告诉你瓶颈是 CPU、GPU 还是内存?
- 怎么优化:针对具体问题的具体解法是什么?
- 验证效果:优化后怎么确认真的变好了?
- 持续维护:上线后怎么防止性能退化?
实战的思维方式
不要"我觉得这里需要优化",要"数据告诉我这里需要优化"。
就像医生不会凭感觉开药,而是先看化验单。优化前必须先有性能基线数据,优化后必须对比数据验证效果。
一、综合实战项目: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 | 6× |
自问自答: 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');
// 触发全局资源释放(由资源管理器处理)
}
}
初学者常见错误
不做平台适配,一套配置跑所有平台
- 错误:在 iPhone 14 上开发,配置拉满,然后直接在低端 Android 上运行
- 结果:低端机直接闪退或卡成幻灯片
- 解决:必须做平台适配,根据设备能力动态调整
忽略内存警告
- 错误:收到
EVENT_LOW_MEMORY后什么都不做 - 结果:iOS 系统直接杀掉应用
- 解决:必须响应内存警告,释放缓存、降低质量
- 错误:收到
性能检测代码放在游戏循环里
- 错误:每帧都执行
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 对象池完整实现
为什么需要对象池?
游戏中频繁创建和销毁节点(如子弹、伤害数字、卡牌)会导致:
- 内存碎片:反复分配释放导致内存不连续
- GC 卡顿:JS 垃圾回收时会暂停程序
- 初始化开销:每个新节点都要执行构造函数和 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 节点还在内存中,只是从场景移除了
初学者常见错误
回收后还继续使用对象
- 错误:
cardPool.put(card)后还访问card - 结果:对象可能已经被复用,状态不确定
- 解决:
put后立即把引用设为 null
- 错误:
忘记预热
- 错误:游戏过程中第一次
get()时创建节点,导致卡顿 - 解决:在场景加载时调用
prewarm()
- 错误:游戏过程中第一次
对象池容量设置太小
- 错误:同时需要 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 自动化性能测试脚本
为什么需要自动化测试?
手动测试的问题是:
- 不精确:人眼判断"卡不卡"误差很大
- 不可重复:今天测和明天测条件不同
- 耗时:需要人工盯着屏幕
自动化测试就像工厂的"质检机器人",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;
}
}
初学者常见错误
采样间隔太短
- 错误:每帧都采样,导致测试本身影响性能
- 解决:每 100ms 采样一次足够
只看平均 FPS
- 错误:平均 60 FPS 就以为没问题
- 问题:可能大部分时间 60 FPS,偶尔掉到 10 FPS
- 解决:同时关注 minFPS 和 p1FPS
测试时间太短
- 错误:只测 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 常驻!