可视化与渲染调试:工具链与实战技巧
对应月影课程:第 23~28 章(调试工具、性能分析、可视化原理)
目标:掌握完整的渲染调试工具链,能独立定位 DrawCall、Overdraw、Shader、GPU 瓶颈。
写在前面:为什么调试是程序员的必修课?
如果不懂调试会怎样?
想象你是一位厨师,菜做咸了却不知道哪里出了问题——是盐放多了?还是酱油放多了?还是高汤本身就很咸?没有调试能力,你只能靠猜,一道菜可能要重做十几次才能成功。
写代码也一样。渲染性能出问题时,如果你不会调试,就只能盲目地"优化":
- 听说合批能降低 DrawCall,就把所有代码改一遍,结果反而更卡了
- 听说 Shader 要简化,就删掉一半代码,结果画面全黑了
- 听说纹理要压缩,就全改成压缩格式,结果某些设备上显示花屏
调试的本质是"用数据说话"。就像医生用体温计、血压计、X光来诊断病情,程序员用调试工具来诊断程序的问题。学会调试,你的优化效率会提升 10 倍以上。
调试的思维方式
调试不是"修 bug",而是科学实验:
- 观察现象:游戏卡顿了,帧率从 60 掉到 20
- 提出假设:可能是 DrawCall 太高?可能是 Shader 太复杂?可能是内存泄漏?
- 设计实验:用工具抓帧、测内存、看耗时
- 验证假设:数据告诉你真正的问题在哪
- 解决问题:针对根因优化,而不是盲目修改
一、调试方法论:从现象到根因
1.1 渲染性能问题的典型症状
为什么需要这个表格?
当你去医院看病,医生会先问你"哪里不舒服"。同样,调试的第一步是识别"症状"。这个表格就像一个"症状自查表",帮你快速缩小问题范围。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 帧率低,CPU 高 | JS 逻辑瓶颈、节点遍历过多 | Chrome Performance、Cocos Profiler |
| 帧率低,GPU 高 | Overdraw、Shader 复杂、带宽瓶颈 | Spector.js、Overdraw 可视化 |
| 帧率低,DrawCall 高 | 合批失败、材质切换频繁 | Cocos Stats、Spector.js |
| 画面闪烁/撕裂 | VSync 关闭、多缓冲失效 | 浏览器/设备设置 |
| 远处纹理闪烁 | Mipmap 缺失、各向异性过滤 | 纹理导入设置 |
| 透明物体排序错误 | 深度写入未关闭、排序算法 | 材质设置、RenderQueue |
| 部分物体不显示 | Culling、Layer、Stencil | 场景面板、Spector.js |
生活类比:这就像是汽车故障诊断。发动机异响(症状)→ 可能是皮带松了、可能是轴承坏了(可能原因)→ 打开引擎盖检查皮带张力、听轴承声音(排查方向)。
1.2 调试流程图
为什么需要流程图?
新手调试时最容易犯的错误是"东一榔头西一棒槌"——今天试试这个工具,明天试试那个工具,没有系统性。流程图给你一个清晰的"路线图",按图索骥,不会迷路。
发现性能问题
↓
开启 Cocos Stats → 观察 FPS / DrawCall / Frame Time
↓
├─ DrawCall 高? → Spector.js 抓帧 → 分析合批 / 材质切换
├─ Frame Time 高? → Chrome Performance → 分析 JS 耗时
├─ GPU Time 高? → Spector.js / RenderDoc → 分析 Shader / Overdraw
└─ 内存高? → Chrome Memory → 分析纹理 / 节点泄漏
生活类比:这就像侦探破案。先发现尸体(性能问题),然后检查现场(Stats 面板),根据线索选择调查方向(DrawCall/CPU/GPU/内存),最后用专业工具深入取证(Spector.js/RenderDoc)。
自问自答
Q:为什么一定要先开 Stats,而不是直接用 Spector.js? A:Stats 是"体检报告",告诉你哪里有问题;Spector.js 是"CT扫描",深入看具体病灶。如果跳过体检直接做 CT,你可能在健康的地方浪费时间。
Q:FPS 低一定是渲染问题吗? A:不一定!FPS 低可能是 JS 逻辑卡了(比如每帧遍历 10000 个数组),也可能是渲染卡了。Stats 面板会显示 Frame Time 的分布,帮你区分是 CPU 还是 GPU 的问题。
二、Cocos Creator 内置调试工具
2.1 Stats 面板
为什么需要 Stats?
Stats 面板就像汽车的仪表盘。开车时你不需要拆开引擎看内部,只需要看速度表、转速表、油量表就能知道车是否正常。Stats 面板就是游戏的"仪表盘",一眼看出性能状况。
// 在代码中开启 Stats 显示
// director 是 Cocos 的全局导演类,控制游戏主循环
director.enableRetina(true);
// 或者在编辑器中更简单地开启:
// Game 面板 → 右上角 Stats 按钮(点击即可切换显示/隐藏)
术语解释:
- Retina:苹果提出的高分辨率屏幕技术。开启后 Stats 文字在高分辨率屏幕上更清晰。
- Director:Cocos 的"导演",管理游戏的主循环、场景切换等全局事务。
Stats 显示指标:
| 指标 | 含义 | 健康范围 |
|---|---|---|
| FPS | 每秒帧数(Frames Per Second) | ≥ 55(60 目标) |
| Frame Time | 单帧耗时(毫秒) | ≤ 16.67ms(60FPS) |
| DrawCall | CPU 提交的绘制调用数 | 2D: <50, 3D: <200 |
| Instance Count | GPU Instancing 批次 | 越高越好 |
| Triangle | 每帧渲染的三角形数 | 视场景而定 |
| GFX Texture Mem | GPU 纹理内存 | < 100MB(移动端) |
| GFX Buffer Mem | GPU 顶点/索引/Uniform 缓冲 | < 20MB |
| JSB Mem | JS 引擎内存 | 关注增长趋势 |
生活类比:FPS 就像电影的流畅度,24 FPS 是电影标准,60 FPS 是游戏标准。Frame Time 就像做一道菜的耗时,如果每道菜要做 20 秒,那一分钟只能上 3 道菜(30 FPS);如果 16 毫秒做完,一分钟能上 60 道菜(60 FPS)。
初学者常见错误
只看 FPS,不看 Frame Time
- 错误:FPS 显示 60 就以为没问题
- 问题:FPS 是平均值,可能大部分时间 16ms,偶尔一帧 100ms,玩家会感受到卡顿
- 正确:同时关注 Frame Time 的稳定性,波动大说明有卡顿
DrawCall 高就盲目合批
- 错误:看到 DrawCall 100+,就把所有 Sprite 放同一个节点下
- 问题:如果纹理不同,合批会失败,反而增加层级管理复杂度
- 正确:先用 Spector.js 分析为什么合批失败
忽视内存增长趋势
- 错误:GFX Texture Mem 显示 80MB,觉得没超 100MB 就安全
- 问题:如果内存每秒增加 1MB,说明有泄漏,迟早会崩溃
- 正确:观察内存是否稳定,而不是只看瞬时值
2.2 自定义性能面板
为什么需要自己写性能面板?
Stats 面板是引擎内置的,显示的是全局数据。但在实际项目中,你可能想知道:
- 某个具体场景的 FPS 是多少?
- 某个 UI 界面的 DrawCall 是多少?
- GPU 到底花了多少时间?
自定义性能面板就像给病人戴上一个"24 小时心电监测仪",持续监控关键指标,还能根据数值变色报警。
// 导入 Cocos 引擎模块
// _decorator 是装饰器命名空间,用于定义组件
// Component 是所有自定义组件的基类
// Label 是文本显示组件
// director 是全局导演实例
// game 是游戏实例
// gfx 是图形抽象层
import { _decorator, Component, Label, director, game, gfx } from 'cc';
// 解构出常用装饰器
// ccclass 用于标记这是一个 Cocos 组件类
// property 用于在编辑器中暴露属性
const { ccclass, property } = _decorator;
// @ccclass 装饰器:告诉 Cocos 这是一个组件类,可以在编辑器中挂载到节点上
@ccclass('PerformanceMonitor')
export class PerformanceMonitor extends Component {
// @property(Label) 装饰器:在编辑器中暴露这个属性,类型为 Label
// 这样你可以在编辑器里把对应的 Label 节点拖进来
@property(Label)
fpsLabel: Label = null; // 显示 FPS 的文本标签
@property(Label)
drawCallLabel: Label = null; // 显示 DrawCall 的文本标签
@property(Label)
memoryLabel: Label = null; // 显示内存的文本标签
@property(Label)
gpuTimeLabel: Label = null; // 显示 GPU 耗时的文本标签
// 帧计数器:记录过去 1 秒内渲染了多少帧
private _frameCount: number = 0;
// 上次计算 FPS 的时间戳
private _lastTime: number = 0;
// 当前计算出的 FPS 值
private _fps: number = 60;
// 平均每帧耗时(毫秒)
private _frameTime: number = 0;
// GPU 时间查询相关变量(需要 WebGL 2.0 支持)
// WebGL2RenderingContext 是浏览器提供的 WebGL 2.0 渲染上下文
private _gl: WebGL2RenderingContext = null;
// EXT_disjoint_timer_query_webgl2 扩展:用于测量 GPU 执行时间
private _queryExt: any = null;
// 查询对象队列:存储待查询的 GPU 时间戳
private _queries: WebGLQuery[] = [];
// onLoad 是组件生命周期函数,在节点首次激活时调用
onLoad() {
// 获取 Cocos 底层的 WebGL 设备对象
// director.root 是渲染系统的根节点
// .device 是图形设备抽象
const device = director.root.device;
// 通过类型断言 (as any) 访问内部属性 _gl,获取原始 WebGL 上下文
this._gl = (device as any)._gl;
// 检查当前设备是否支持 GPU 时间查询扩展
// 如果不支持,gpuTimeLabel 将不会更新
this._queryExt = this._gl.getExtension('EXT_disjoint_timer_query_webgl2');
}
// update 是组件生命周期函数,每帧调用一次
// dt 是上一帧到当前帧的时间间隔(秒)
update(dt: number) {
// 帧计数加 1
this._frameCount++;
// 获取当前时间(毫秒,从页面加载开始计算)
const now = performance.now();
// 如果距离上次计算已经过了 1 秒(1000 毫秒)
if (now - this._lastTime >= 1000) {
// 计算 FPS:过去 1 秒内渲染的帧数
this._fps = this._frameCount;
// 计算平均每帧耗时:总时间 / 帧数
this._frameTime = (now - this._lastTime) / this._frameCount;
// 重置计数器
this._frameCount = 0;
this._lastTime = now;
// 更新 UI 显示
this._updateDisplay();
}
// 如果支持 GPU 时间查询且需要显示,则测量 GPU 时间
if (this._queryExt && this.gpuTimeLabel) {
this._measureGPUTime();
}
}
// 私有方法:更新所有 Label 的显示内容
private _updateDisplay() {
// 更新 FPS 显示
if (this.fpsLabel) {
// 根据 FPS 值设置颜色:低于 30 红色,30-50 黄色,高于 50 绿色
const color = this._fps < 30 ? '#FF4444' : this._fps < 50 ? '#FFAA00' : '#44FF44';
// 设置文本内容
this.fpsLabel.string = `FPS: ${this._fps}`;
// 设置文本颜色(需要创建 Color 对象)
this.fpsLabel.color = new Color().fromHEX(color);
}
// 更新 DrawCall 显示
if (this.drawCallLabel) {
// 从 Cocos 内部获取 DrawCall 统计
const device = director.root.device;
// _stats 是内部统计对象,包含 drawArrays 和 drawElements 次数
const stats = (device as any)._stats;
// 计算总 DrawCall:drawArrays 调用次数 + drawElements 调用次数
const drawCalls = stats?.drawArrays + stats?.drawElements || 0;
this.drawCallLabel.string = `DrawCall: ${drawCalls}`;
}
// 更新内存显示(仅 Chrome 浏览器支持)
if (this.memoryLabel) {
// performance.memory 是 Chrome 提供的内存信息 API
const memory = (performance as any).memory;
if (memory) {
// usedJSHeapSize:当前 JS 使用的堆内存(字节)
// 除以 1048576(= 1024 * 1024)转换为 MB
const usedMB = (memory.usedJSHeapSize / 1048576).toFixed(1);
const totalMB = (memory.totalJSHeapSize / 1048576).toFixed(1);
this.memoryLabel.string = `JS Mem: ${usedMB}/${totalMB} MB`;
}
}
}
/** GPU 时间测量 */
private _measureGPUTime() {
// 获取 WebGL 上下文和扩展
const gl = this._gl;
const ext = this._queryExt;
// 检查上一帧的查询结果是否可用
if (this._queries.length > 0) {
// 取出队列中最早的查询
const query = this._queries[0];
// 检查查询结果是否可用
const available = gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE);
// 检查 GPU 时间是否连续(disjoint 表示时间戳不连续,结果不可靠)
const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
// 如果结果可用且时间连续
if (available && !disjoint) {
// 获取查询结果(纳秒)
const time = gl.getQueryParameter(query, gl.QUERY_RESULT);
// 转换为毫秒(1 毫秒 = 1,000,000 纳秒)
const ms = time / 1000000;
// 更新显示
this.gpuTimeLabel.string = `GPU: ${ms.toFixed(2)}ms`;
// 删除已完成的查询对象,释放资源
gl.deleteQuery(query);
// 从队列中移除
this._queries.shift();
}
}
// 开始新的 GPU 时间查询
// createQuery 创建一个查询对象
const query = gl.createQuery();
// queryCounterEXT 记录当前 GPU 时间戳
ext.queryCounterEXT(query, ext.TIMESTAMP_EXT);
// 加入队列,下一帧再读取结果(GPU 执行是异步的)
this._queries.push(query);
}
}
初学者常见错误
忘记在编辑器中绑定 Label
- 错误:代码写好了,但运行后 Label 不显示
- 原因:
@property(Label)只是声明,需要在编辑器里把对应的 Label 节点拖进去 - 解决:选中挂载 PerformanceMonitor 的节点,在属性检查器中把 Label 拖进去
在非 WebGL 2.0 环境下使用 GPU 查询
- 错误:在微信小游戏或 WebGL 1.0 设备上 GPU 时间显示为 0
- 原因:
EXT_disjoint_timer_query_webgl2是 WebGL 2.0 扩展,不支持时_queryExt为 null - 解决:代码中已经做了判断
if (this._queryExt),但初学者可能没注意到这个限制
频繁创建查询对象导致内存泄漏
- 错误:如果不在
_measureGPUTime中deleteQuery,查询对象会无限累积 - 原因:每帧创建一个查询,但只在结果可用时删除
- 解决:确保代码中有
gl.deleteQuery(query),且_queries.shift()正确执行
- 错误:如果不在
自问自答
Q:为什么 FPS 计算要等 1 秒?不能直接 1 / dt 吗?
A:可以,但 1 / dt 波动太大。一帧如果因为系统调度慢了 1ms,FPS 就会从 60 跳到 55。累积 1 秒后取平均,数据更稳定。
Q:为什么 GPU 时间查询结果要延迟一帧读取?
A:GPU 执行是异步的。CPU 提交绘制命令后,GPU 可能还没执行完。如果立刻读取,结果可能还没出来(available = false)。等一帧再读,GPU 大概率已经执行完了。
三、Chrome DevTools 性能分析
3.1 Performance 面板
为什么需要 Performance 面板?
Stats 面板告诉你"游戏卡了",但不知道"为什么卡"。Performance 面板就像一台"高速摄像机",把每一帧的 CPU 工作都录下来,让你看到是哪段代码、哪个函数在拖慢帧率。
术语解释:
- 火焰图(Flame Graph):一种可视化方式,把函数调用栈画成一层层的火焰形状。火焰越高,调用栈越深;火焰越宽,函数耗时越长。
- Scripting:浏览器执行 JavaScript 的时间。
- Rendering:浏览器计算样式和布局的时间(对 Web 游戏影响较小)。
- Painting:浏览器把像素画到屏幕上的时间。
操作步骤(一步一步来,不要跳过):
步骤 1:在 Chrome 浏览器中打开你的游戏
- 可以是 Cocos 编辑器的预览模式
- 也可以是构建后的 Web 版本
步骤 2:按 F12 打开开发者工具
- 或者右键页面 → 检查
步骤 3:切换到 Performance 面板
- 顶部标签栏找到 "Performance"
- 如果找不到,点击 ">>" 展开更多面板
步骤 4:点击左上角的 Record 按钮(圆形按钮)
- 按钮变成红色,表示正在录制
步骤 5:在游戏中执行你要分析的操作
- 比如:打开卡牌列表、滚动背包、释放技能
- 操作不要太快,保持 3~5 秒
步骤 6:点击 Stop 按钮(方形按钮)停止录制
- 等待 Chrome 分析数据(可能需要几秒到几十秒)
步骤 7:分析生成的火焰图
- 黄色区域 = JS 执行
- 紫色区域 = 样式计算
- 绿色区域 = 绘制
- 点击宽条可以查看具体函数名
关键区域:
| 区域 | 说明 | 优化方向 |
|---|---|---|
| Scripting(黄色) | JavaScript 执行 | 减少每帧计算、使用对象池 |
| Rendering(紫色) | 样式计算、布局 | 减少 DOM 操作(Web 平台) |
| Painting(绿色) | 绘制操作 | 减少 Overdraw |
| GPU | GPU 执行 | 优化 Shader、减少带宽 |
| Idle(白色) | 空闲时间 | 理想状态,越大越好 |
生活类比:Performance 面板就像工厂的流水线监控。黄色是工人在干活,紫色是工人在看图纸,绿色是机器在喷漆,白色是流水线在等零件。你要找的是"哪个工位最忙",然后优化那个工位。
3.2 Memory 面板
为什么需要 Memory 面板?
内存泄漏是游戏开发中最隐蔽的 bug。它不会立刻崩溃,而是像温水煮青蛙一样,让玩家玩 10 分钟后闪退。Memory 面板帮你找到"谁在偷偷占用内存不释放"。
操作步骤(一步一步来):
步骤 1:切换到 Memory 面板
步骤 2:选择 "Heap Snapshot"(堆快照)
- 这是拍摄当前内存中的对象照片
步骤 3:点击 "Take Snapshot" 按钮
- 等待拍摄完成(游戏可能会卡顿几秒)
- 这是"操作前"的快照
步骤 4:在游戏中执行可疑操作
- 比如:打开关闭某个界面 10 次
- 如果界面有内存泄漏,每次打开都会残留一些对象
步骤 5:再次点击 "Take Snapshot"
- 这是"操作后"的快照
步骤 6:切换到 "Comparison" 视图
- 在第二个快照的下拉菜单中选择 "Comparison"
- 第一个快照选择你刚才拍的第一个
步骤 7:查看增长的对象
- 正数表示增加的对象数量
- 重点关注 Texture2D、Node、Array 等游戏常用类型
关键检查项:
- Texture2D 对象数量是否持续增长?(纹理泄漏)
- Node 对象是否被正确释放?(节点泄漏)
- Array / Object 是否有累积?(数据缓存未清理)
生活类比:Memory 面板就像检查房间里的东西。第一次拍照时房间有 10 个箱子,反复进出房间 10 次后再拍照,发现房间有 20 个箱子——说明每次你都在房间里留下了东西,这就是"泄漏"。
3.3 使用 console.time 快速测量
为什么需要 console.time?
Performance 面板很强大,但操作步骤多。有时候你只想快速知道"这段代码花了多久"。console.time 就像厨房里的计时器,按一下开始,再按一下结束,立刻知道耗时。
// 快速测量代码块耗时
// console.time('标签名') 开始计时
console.time('updateList');
// 执行要测量的代码
this.virtualList.updateVisibleItems();
// console.timeEnd('标签名') 结束计时,自动输出结果
console.timeEnd('updateList');
// 控制台输出示例: updateList: 2.345ms
// 测量函数调用频率(高级技巧)
// 定义计数器
let callCount = 0;
// 保存原始函数的引用
const original = this.update.bind(this);
// 替换为包装函数
this.update = function(...args) {
// 每次调用计数加 1
callCount++;
// 调用原始函数,保持原有功能
return original(...args);
};
// 每 1 秒输出一次调用次数
setInterval(() => {
console.log(`update called ${callCount} times/sec`);
// 重置计数器
callCount = 0;
}, 1000);
初学者常见错误
console.time 标签名不匹配
- 错误:
console.time('A')但console.timeEnd('B') - 结果:不会输出任何结果,也不会报错
- 正确:确保开始和结束的标签名完全一致
- 错误:
在 Performance 录制时同时用 console.time
- 错误:Performance 已经在录制,console.time 的输出被淹没在大量日志中
- 正确:先用 console.time 快速定位问题范围,再用 Performance 深入分析
Memory 快照时游戏还在运行
- 错误:拍快照时角色还在移动、特效还在播放
- 结果:快照中包含大量临时对象,干扰分析
- 正确:快照前让游戏静止,或者拍多次取平均
自问自答
Q:Performance 面板和 Stats 面板有什么区别? A:Stats 是"实时心率监测仪",显示当前数值;Performance 是"心电图记录",显示一段时间内的详细变化。Stats 适合实时监控,Performance 适合事后分析。
Q:为什么 Memory 面板拍快照时游戏会卡? A:拍快照需要遍历内存中所有对象,这个操作本身很耗时。就像给一屋子的人拍照,人越多,拍照时间越长。
四、Spector.js:WebGL 抓帧神器
4.1 安装与使用
为什么需要 Spector.js?
想象你在看一场魔术表演,想知道魔术师是怎么变出鸽子的。Spector.js 就像一台"慢动作摄像机",把 GPU 渲染的每一帧都拆解成一个个步骤,让你看到:
- 这一帧调用了哪些 WebGL 命令?
- 每个命令前后 GPU 状态是什么?
- 纹理、Shader、缓冲区的内容是什么?
术语解释:
- 抓帧(Capture Frame):记录某一帧的所有 GPU 命令和数据,用于事后分析。
- WebGL 命令:如
gl.drawArrays、gl.bindTexture等,是 CPU 告诉 GPU "画什么、怎么画" 的指令。
方式 1:Chrome 扩展(推荐初学者)
步骤 1:打开 Chrome Web Store
- 地址栏输入 chrome://extensions
- 或者搜索 "Chrome Web Store"
步骤 2:搜索 "Spector.js"
- 在搜索框输入 "Spector.js"
- 找到由 "Babylon.js" 团队维护的版本
步骤 3:点击 "添加至 Chrome"
- 等待安装完成
步骤 4:使用
- 打开你的游戏页面
- 点击浏览器右上角的 Spector.js 图标
- 点击红色圆点 "Capture" 按钮
方式 2:npm 集成(更强大,适合高级用户)
步骤 1:安装 npm 包
npm install spectorjs
步骤 2:代码中引入
// 导入 Spector.js 库
import * as SPECTOR from 'spectorjs';
// 创建 Spector 实例
const spector = new SPECTOR.Spector();
// 显示 UI 面板
spector.displayUI();
步骤 3:在浏览器中访问游戏
- Spector.js 的 UI 会嵌入到页面中
4.2 抓帧操作
抓帧步骤(一步一步来):
步骤 1:打开 Spector.js 面板
- Chrome 扩展:点击浏览器右上角的 Spector.js 图标
- npm 集成:页面上会显示 Spector.js 的浮动面板
步骤 2:点击红色圆点(Capture 按钮)
- 按钮变成"等待中"状态
步骤 3:在游戏里执行一帧渲染
- 通常只需要等待 1 秒,Spector.js 会自动捕获下一帧
- 或者点击游戏画面触发一帧更新
步骤 4:自动分析并展示结果
- 捕获完成后,Spector.js 会打开一个新标签页
- 显示该帧的所有 WebGL 命令列表
4.3 Spector.js 核心功能
| 功能 | 说明 | 使用场景 |
|---|---|---|
| Commands 列表 | 该帧所有 GL 调用按顺序排列 | 查看 DrawCall 顺序 |
| Texture 查看 | 所有绑定的纹理及其内容 | 检查纹理是否正确加载 |
| Buffer 查看 | 顶点缓冲、索引缓冲数据 | 检查顶点数据 |
| Shader 查看 | 当前使用的 VS/FS 代码 | 检查 Shader 是否正确 |
| State 查看 | 深度测试、混合、裁剪等状态 | 检查渲染状态 |
| Visualize | 可视化每个 DrawCall 的结果 | 定位渲染错误 |
| MRT 查看 | 多渲染目标输出 | Deferred Rendering 调试 |
生活类比:Spector.js 就像餐厅的"后厨监控"。Commands 列表是"做菜步骤清单",Texture 查看是"检查食材质量",Shader 查看是"检查菜谱",Visualize 是"每道菜出锅时的照片"。
4.4 实战:用 Spector.js 定位合批失败
为什么需要这个实战?
合批(Batching)是降低 DrawCall 的核心技术,但初学者经常遇到"明明应该合批,为什么 DrawCall 还是很高"的困惑。Spector.js 能告诉你"为什么合批失败了"。
问题描述:DrawCall 异常高,怀疑合批失败
排查步骤(一步一步来):
步骤 1:Spector.js 抓帧
- 按照 4.2 节的步骤捕获一帧
步骤 2:查看 Commands 列表
- 找到所有 drawArrays 或 drawElements 调用
- 这些就是实际的 DrawCall
步骤 3:检查相邻的 DrawCall 之间发生了什么
- 合批的条件是:相邻的绘制使用相同的纹理、Shader、混合模式等
- 如果中间有状态切换,合批就会中断
步骤 4:识别中断合批的原因
- 如果看到 gl.bindTexture → 纹理不同,合批中断
- 如果看到 gl.useProgram → Shader 不同,合批中断
- 如果看到 gl.enable/disable → 状态变化,合批中断
- 如果看到 gl.uniform* → Uniform 变化,可能合批中断
优化方案:
- 纹理不同 → 使用图集(Atlas)/ 动态图集
* 把多个小图合并到一张大图里
* 这样多个 Sprite 可以共用同一张纹理,就能合批
- Shader 不同 → 减少 Shader 变体 / 合并 Pass
* 如果两个 Sprite 一个用普通 Shader,一个用发光 Shader,就无法合批
* 考虑把发光效果做成可选的 uniform,而不是独立的 Shader
- Uniform 变化 → 使用 Instancing / UBO
* Instancing:一次性画 100 个相同的物体,只发 1 个 DrawCall
* UBO(Uniform Buffer Object):批量传递 Uniform 数据
生活类比:合批就像食堂打饭。如果 10 个人都要番茄炒蛋,食堂可以炒一大锅,每人盛一碗(合批成功)。但如果第 3 个人突然要红烧肉,厨师就得换锅(合批中断)。Spector.js 就是帮你找出"谁突然要红烧肉"。
4.5 实战:用 Spector.js 分析 Overdraw
Spector.js 本身不支持直接的 Overdraw 可视化,但可以通过以下方法间接分析:
方法 1:修改 Shader 临时输出深度(适合快速检查)
// 在片元着色器(Fragment Shader)中添加以下代码
// gl_FragCoord.z 是当前像素的深度值(0~1)
gl_FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
// 效果:越白表示越早被绘制(深度小)
// 如果某个区域颜色异常(比如应该是白的却是黑的),
// 说明那里被多次绘制了(Overdraw)
方法 2:使用 Cocos 内置 Overdraw 模式(如果版本支持)
// 开启调试绘制模式
director.showDebugDraw(true);
// 某些 Cocos 版本会显示 Overdraw 热力图
方法 3:使用 RenderDoc(Windows 平台,功能最完整)
- RenderDoc 支持真正的 Overdraw 热力图
- 红色表示 Overdraw 严重,蓝色表示正常
- 详见第六节
初学者常见错误
抓帧时选错了 Canvas
- 错误:页面有多个 Canvas,Spector.js 抓到了错误的那个
- 结果:Commands 列表为空或显示的不是你的游戏
- 解决:确保 Spector.js 面板中显示的是你的游戏 Canvas
看不懂 Commands 列表
- 错误:看到几百条命令就慌了,不知道从哪看起
- 解决:先只看
drawArrays和drawElements,这些是实际的绘制命令。其他的bindTexture、useProgram等是状态设置。
修改 Shader 调试后忘记改回来
- 错误:为了看深度,把 Shader 改成输出白色,然后提交到版本库了
- 结果:游戏画面全白了
- 解决:调试代码用注释标记
// DEBUG: remove before commit
自问自答
Q:Spector.js 和 Chrome Performance 有什么区别? A:Chrome Performance 看的是"CPU 在干什么",Spector.js 看的是"GPU 收到了什么命令"。就像 Performance 是监控厨师的动作,Spector.js 是监控传给后厨的点菜单。
Q:为什么抓帧后 Commands 列表里有几百条命令? A:一帧画面可能包含上百个物体的绘制,每个物体可能需要绑定纹理、设置 Shader、设置 Uniform、然后绘制。这些步骤加起来就有很多命令了。
五、WebGL-Inspector:轻量替代方案
5.1 安装
为什么需要 WebGL-Inspector?
Spector.js 功能强大,但有时候你可能需要一个更轻量的工具,或者需要实时编辑 Shader 的功能(Spector.js 不支持)。WebGL-Inspector 就是替代方案。
方式 1:Chrome 扩展
- Chrome Web Store 搜索 "WebGL-Inspector"
- 安装后点击扩展图标
方式 2:书签方式(不需要安装扩展)
步骤 1:创建一个新书签
步骤 2:书签的 URL 设置为以下代码:
javascript:(function(){var%20script=document.createElement('script');script.src='https://raw.githubusercontent.com/benvanik/WebGL-Inspector/master/core/embed.js';document.head.appendChild(script);})();
步骤 3:打开你的游戏页面
步骤 4:点击这个书签
步骤 5:页面右下角会出现 WebGL-Inspector 的面板
5.2 与 Spector.js 对比
| 特性 | Spector.js | WebGL-Inspector |
|---|---|---|
| 活跃度 | 高(微软维护) | 低(已停更) |
| 功能丰富度 | 高 | 中 |
| 使用难度 | 低 | 中 |
| 纹理查看 | 支持 | 支持 |
| Shader 编辑 | 不支持 | 支持(实时编辑) |
| 跨平台 | 所有 WebGL 平台 | 所有 WebGL 平台 |
推荐:优先使用 Spector.js,需要实时 Shader 编辑时用 WebGL-Inspector。
自问自答
Q:既然 Spector.js 更好,为什么还要学 WebGL-Inspector? A:两个原因:(1) 有些老项目还在用 WebGL-Inspector;(2) WebGL-Inspector 的实时 Shader 编辑功能在调试 Shader bug 时非常有用,可以边改边看效果。
六、RenderDoc:跨平台 GPU 调试(Windows/Android)
6.1 安装与配置
为什么需要 RenderDoc?
Spector.js 适合 Web 平台,但如果你在开发原生游戏(Windows、Android),就需要更底层的工具。RenderDoc 就像"GPU 的 X 光机",能看到:
- 每个像素是怎么被画出来的
- 纹理的每个 Mipmap 层级
- Shader 的逐行执行过程
- Overdraw 的热力图
术语解释:
- 注入(Inject):把 RenderDoc 的监控代码插入到正在运行的程序中。
- 抓帧(Capture):记录某一帧的所有 GPU 操作。
安装步骤(一步一步来):
步骤 1:下载 RenderDoc
- 官网:https://renderdoc.org/
- 下载对应平台的安装包(Windows 推荐 .msi 安装包)
步骤 2:安装
- Windows:双击 .msi,按向导安装
- 安装完成后桌面会出现 RenderDoc 图标
步骤 3:打开 RenderDoc
- 双击桌面图标启动
步骤 4:选择调试方式
- 方式 A:Launch Application(启动新程序)
* File → Launch Application
* Executable Path 选择你的游戏 .exe 文件
* 点击 Launch
- 方式 B:Inject into Process(注入运行中的程序)
* File → Inject into Process
* 找到你的游戏进程
* 点击 Inject
步骤 5:抓帧
- 游戏运行后,按 F12 抓帧
- 或者点击 RenderDoc 窗口中的 "Capture Frame(s) Immediately" 按钮
Cocos 原生平台调试特别说明:
- 启动你的原生游戏(Windows 或 Android)
- RenderDoc 选择该进程注入
- 按 F12 抓帧
- 抓到的帧会在 RenderDoc 中打开分析
6.2 RenderDoc 核心功能
| 功能 | 说明 |
|---|---|
| Texture Viewer | 查看所有纹理,支持通道分离、Mipmap 层级 |
| Mesh Viewer | 查看顶点数据、索引、法线可视化 |
| Shader Debugger | 单步调试 Shader,查看变量值 |
| Pixel History | 追踪某个像素的完整绘制历史 |
| Overdraw 渲染 | 热力图显示 Overdraw 程度 |
| Performance Counter | GPU 硬件计数器( cycle、带宽等) |
生活类比:RenderDoc 就像法医实验室。Texture Viewer 是"显微镜看纤维",Mesh Viewer 是"3D 重建尸体模型",Pixel History 是"追踪子弹轨迹",Overdraw 是"血迹分布图"。
6.3 Pixel History:定位像素渲染问题
为什么需要 Pixel History?
有时候你会发现屏幕上某个像素颜色不对:
- "这个像素应该是红色的,为什么是黑色的?"
- "这个按钮的文字为什么没显示?"
- "透明效果为什么没生效?"
Pixel History 能告诉你这个像素被哪些 DrawCall 修改过,每次修改时深度测试、模板测试是否通过。
操作步骤(一步一步来):
步骤 1:抓帧后选择 Texture Viewer
- 在 RenderDoc 中打开抓到的帧
- 左侧选择 "Texture Viewer"
步骤 2:找到有问题的像素
- 在右侧的纹理预览中放大画面
- 找到颜色不对的像素
步骤 3:右键点击该像素
- 弹出菜单中选择 "Pixel History"
步骤 4:查看绘制历史
- 弹出的窗口显示该像素被哪些 DrawCall 修改过
- 每个 DrawCall 显示:
* 修改前的颜色
* 修改后的颜色
* 深度测试是否通过
* 模板测试是否通过
* 混合模式是什么
常见用途:
- 为什么某个像素是黑色?
* 查看是否有 DrawCall 写入了黑色
* 或者深度测试失败,导致后面的绘制被丢弃
- 透明物体为什么没有显示?
* 查看透明物体的 DrawCall 是否被深度测试丢弃
* 或者混合模式设置错误
- 深度测试是否通过了?
* 查看 Pixel History 中的 "Depth Test" 列
初学者常见错误
RenderDoc 无法注入某些程序
- 错误:尝试注入 Steam 游戏或受保护的应用程序
- 结果:注入失败或游戏崩溃
- 解决:RenderDoc 对某些 DRM 保护的游戏不支持,先用简单程序测试
抓帧后找不到 Shader 代码
- 错误:在 Shader Debugger 中看不到源码
- 原因:某些平台(如 Metal)Shader 是编译后的二进制,没有源码
- 解决:尝试在 Windows/OpenGL 平台下调试,源码支持最好
Overdraw 热力图看不懂
- 错误:看到全屏红色就慌了
- 解读:红色表示 Overdraw 严重(同一个像素画了 5 次以上),蓝色/绿色表示正常
- 正确做法:找到红色区域,回到场景里优化那些地方的 UI 或特效
自问自答
Q:RenderDoc 和 Spector.js 有什么区别? A:Spector.js 是"WebGL 专用调试器",适合 Web 平台;RenderDoc 是"通用 GPU 调试器",支持 Windows、Android、Linux 等原生平台。RenderDoc 功能更强(如 Pixel History、Shader 单步调试),但只支持原生应用。
Q:为什么 Pixel History 显示深度测试失败了? A:深度测试失败意味着"这个像素被前面的物体挡住了"。就像你站在墙后面,相机拍不到你。解决方法是调整物体的 RenderQueue,或者检查深度值是否正确。
七、Intel GPA:免费 GPU 分析工具
7.1 适用场景
为什么需要 Intel GPA?
Intel GPA(Graphics Performance Analyzers)是 Intel 提供的免费 GPU 分析工具。虽然名字里有"Intel",但它不仅用于 Intel 显卡,还可以分析其他 GPU。
适用场景:
- 你使用的是 Intel 集成显卡(如轻薄本)
- 需要分析移动端 GPU 行为(通过 Stream 功能)
- 需要查看帧率、GPU 占用率、带宽等系统级指标
7.2 核心功能
| 功能 | 说明 |
|---|---|
| System Analyzer | 实时查看 CPU/GPU/内存占用 |
| Graphics Frame Analyzer | 逐帧分析 GPU 瓶颈 |
| Platform Analyzer | 查看多线程渲染时间线 |
生活类比:Intel GPA 就像汽车的 OBD 诊断仪。System Analyzer 是"仪表盘",Frame Analyzer 是"发动机工况分析",Platform Analyzer 是"各气缸工作状态"。
自问自答
Q:我有 RenderDoc 了,还需要 Intel GPA 吗? A:如果你只用独立显卡(NVIDIA/AMD)开发,可能不需要。但如果你在轻薄本上开发(Intel 核显),Intel GPA 的系统级监控功能很有用,可以看到 CPU 和 GPU 的协同工作情况。
八、Overdraw 分析与优化
8.1 Overdraw 的定义
为什么需要关注 Overdraw?
Overdraw 是移动端游戏性能的头号杀手。它就像反复在同一张纸上涂色——涂第一层是必需的,涂第二层、第三层就是浪费。GPU 每多画一层,就多消耗一次计算和带宽。
术语解释:
- Overdraw:同一个像素被多次绘制的次数。理想情况下每个像素只画一次(Overdraw = 1.0)。
Overdraw 计算示例:
假设屏幕上有以下 UI 元素(从底到顶):
背景(全屏,不透明) → 每个像素画 1 次
UI 面板(半透明,覆盖 80% 屏幕) → 80% 的像素再画 1 次(累计 2 次)
按钮(覆盖 20% 屏幕) → 20% 的像素再画 1 次(累计 3 次)
文字(覆盖 5% 屏幕) → 5% 的像素再画 1 次(累计 4 次)
平均 Overdraw 计算:
= (100% × 1 + 80% × 2 + 20% × 3 + 5% × 4) / 100%
= (1 + 1.6 + 0.6 + 0.2)
= 3.4
等等,这个计算不对!正确的计算应该是:
= (1×1 + 0.8×2 + 0.2×3 + 0.05×4) / 1
= (1 + 1.6 + 0.6 + 0.2)
= 3.4
实际上更简单的理解:
理想值:1.0(每个像素只画一次)
可接受:2.0~3.0(UI 游戏通常在这个范围)
危险值:>5.0(需要紧急优化)
生活类比:Overdraw 就像刷墙。第一遍刷底漆是必要的(1 次绘制),但如果你在第一遍没干的时候又刷了第二遍、第三遍,就是浪费油漆和力气。GPU 的"力气"就是电量和性能。
8.2 Cocos 中检测 Overdraw
// 方法 1:临时修改 Shader 可视化 Overdraw
// 创建一个全屏后处理 Effect
// 后处理(Post-processing)是画面渲染完成后,对整屏图像再处理一次
CCProgram overdraw-fs %{
precision mediump float; // 中等精度浮点数,平衡性能和精度
in vec2 v_uv; // 从顶点着色器传入的 UV 坐标
uniform sampler2D mainTexture; // 主纹理(上一帧渲染结果)
vec4 frag() {
// 采样上一帧的颜色
vec4 color = texture(mainTexture, v_uv);
// 计算 Overdraw:alpha 越低说明被覆盖的次数越多
// 这个简化算法假设 alpha 累积表示覆盖次数
float overdraw = 1.0 - color.a;
// 输出红色,越红表示 Overdraw 越严重
return vec4(overdraw, 0.0, 0.0, 1.0);
}
}%
// 方法 2:使用 RenderDoc 的 Overdraw 模式(推荐)
// RenderDoc 直接提供 Overdraw 热力图
// 红色 = Overdraw 严重,蓝色 = 正常
// 方法 3:Cocos 内置调试(某些版本支持)
// director.showDebugDraw(true);
8.3 Overdraw 优化策略
| 策略 | 说明 | 效果 |
|---|---|---|
| 减少透明区域 | 使用九宫格(Sliced)代替全屏透明图 | 显著 |
| 不透明背景 | 使用不透明色块代替半透明遮罩 | 显著 |
| 从前往后绘制 | 不透明物体先画,利用 Early-Z | 中等 |
| 减少 UI 层级 | 合并相邻的同级 UI | 中等 |
| 使用 RectMask | 代替 Stencil Mask | 轻微 |
| 剔除不可见元素 | 超出屏幕的节点设置 active=false | 显著 |
生活类比:
- 减少透明区域:不要用半透明的玻璃纸做背景,用实色的纸板
- 不透明背景:先画墙(不透明),再画窗户,这样墙后面的东西就不用画了
- 从前往后绘制:先画前面的物体,后面的物体被深度测试挡住,就不用画了
初学者常见错误
全屏半透明遮罩滥用
- 错误:每个弹窗都加一个全屏黑色半透明遮罩
- 结果:整个屏幕每个像素都多画了一次
- 解决:用不透明的深色底 + 边缘渐变,只在边缘有透明度
UI 层级嵌套过深
- 错误:一个按钮有 5 层父节点,每层都有背景图
- 结果:即使按钮只占屏幕 5%,也要画 5 层
- 解决:合并相邻层级的背景,减少不必要的层级
忽视 Early-Z 优化
- 错误:把所有物体都标记为透明
- 结果:Early-Z 失效,所有像素都要完整计算
- 解决:确实不透明的物体就不要用透明材质
自问自答
Q:Overdraw 高一定会卡吗? A:不一定。高端 GPU 处理能力很强,Overdraw 5.0 也能流畅运行。但低端手机(如千元机)GPU 性能弱,Overdraw 3.0 就可能卡。而且 Overdraw 高会导致发热和耗电。
Q:为什么从前往后绘制能减少 Overdraw? A:因为 GPU 有 Early-Z 优化。先画前面的不透明物体,后面的物体在深度测试时会被丢弃("被挡住了,不用画"),这样就减少了实际的像素计算。
九、Shader 调试技巧
9.1 颜色可视化法
为什么需要颜色可视化?
Shader 运行在 GPU 上,不能像 JS 代码那样 console.log 打印变量。当 Shader 输出不对时,你就像个盲人摸象,不知道哪里出了问题。
颜色可视化法就是把 Shader 内部变量的值"画"成颜色,用眼睛看。就像把温度计的水银柱染成红色,让你一眼看出温度高低。
// 技巧 1:可视化 UV 坐标
// UV 坐标是纹理坐标,范围通常是 0~1
// 如果 UV 超出这个范围,纹理采样就会出错(出现重复或拉伸)
void main() {
// 把 UV 的 x 分量作为红色,y 分量作为绿色
// 左下角 (0,0) = 黑色
// 右上角 (1,1) = 黄色(红+绿)
gl_FragColor = vec4(v_uv, 0.0, 1.0);
}
// 技巧 2:可视化法线
// 法线(Normal)是垂直于表面的向量,范围 -1~1
// 法线方向决定了光照计算的结果
void main() {
// 法线从 -1~1 映射到 0~1,方便显示为颜色
vec3 normalColor = v_normal * 0.5 + 0.5;
gl_FragColor = vec4(normalColor, 1.0);
// 效果:面向右的面 = 红色,面向上的面 = 绿色,面向前的面 = 蓝色
}
// 技巧 3:可视化深度
// 深度值表示像素距离相机的远近,范围 0~1(非线性分布)
void main() {
float depth = gl_FragCoord.z;
// 把深度值作为灰度输出
gl_FragColor = vec4(vec3(depth), 1.0);
// 效果:越近越黑,越远越白
}
// 技巧 4:可视化 Mipmap 层级
// Mipmap 是纹理的多级缩小版本,GPU 根据距离自动选择
// textureQueryLod 返回 GPU 选择的 Mipmap 层级
void main() {
// textureQueryLod 需要 GLSL 4.00+(WebGL 2.0 不支持)
vec2 lod = textureQueryLod(sampler, uv);
float level = lod.x;
// 把层级映射到红色通道
gl_FragColor = vec4(level / 10.0, 0.0, 0.0, 1.0);
// 效果:越红表示使用的 Mipmap 层级越高(纹理越小)
}
// 技巧 5:检查 Alpha 值
// Alpha 是透明度,0 = 完全透明,1 = 完全不透明
void main() {
vec4 color = texture(mainTexture, v_uv);
// 把 Alpha 值作为灰度输出
gl_FragColor = vec4(vec3(color.a), 1.0);
// 效果:纯白 = 完全不透明,纯黑 = 完全透明
}
9.2 逐步简化法
为什么需要逐步简化?
Shader 出问题时,最笨也最有效的方法是"二分法"——先确认一半代码是对的,再确认另一半。逐步简化法就是系统性地缩小问题范围。
Shader 输出异常时的排查步骤(一步一步来):
步骤 1:只输出固定颜色(红色)
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 纯红色
}
→ 如果能看到红色,说明 Shader 编译和执行正常
→ 如果看不到,说明 Shader 根本没运行(检查材质绑定)
步骤 2:输出 UV 坐标
void main() {
gl_FragColor = vec4(v_uv, 0.0, 1.0);
}
→ 检查 UV 是否正确传入
→ 如果全屏一种颜色,说明 UV 没有正确传入
步骤 3:输出纹理采样结果
void main() {
gl_FragColor = texture(mainTexture, v_uv);
}
→ 检查纹理是否正确绑定
→ 如果全黑,可能是纹理路径错误或采样坐标不对
步骤 4:输出法线
void main() {
gl_FragColor = vec4(v_normal * 0.5 + 0.5, 1.0);
}
→ 检查法线矩阵是否正确
→ 如果颜色异常,可能是法线没有被正确变换
步骤 5:输出光照计算中间结果
void main() {
// 只计算漫反射,不计算高光
float diff = max(dot(v_normal, lightDir), 0.0);
gl_FragColor = vec4(vec3(diff), 1.0);
}
→ 逐步定位计算错误
步骤 6:恢复完整 Shader
→ 此时应该能定位问题所在
生活类比:逐步简化法就像修电路。先检查电源通不通(固定颜色),再检查开关好不好(UV),再检查灯泡亮不亮(纹理),最后检查调光器(光照计算)。一步一步来,不跳过。
9.3 Cocos 中动态替换 Shader
为什么需要动态替换?
你不需要为了调试 Shader 而修改原始材质文件。Cocos 允许运行时临时替换 Shader,调试完再恢复。
// 获取 Sprite 组件
// Sprite 是 Cocos 中显示图片的组件
const sprite = node.getComponent(Sprite);
// 保存原始材质,调试完后恢复用
const originalMaterial = sprite.customMaterial;
// 异步加载调试用的 Effect 资源
// EffectAsset 是 Cocos 的着色器效果资源
const debugEffect = await resources.load('effects/debug', EffectAsset);
// 创建新的材质实例
const debugMaterial = new Material();
// 初始化材质,使用调试用的 Effect
debugMaterial.initialize({ effectAsset: debugEffect });
// 临时替换为调试材质
sprite.customMaterial = debugMaterial;
// 测试完成后恢复原始材质
// 这样可以确保调试不影响正式功能
sprite.customMaterial = originalMaterial;
初学者常见错误
替换 Shader 后忘记恢复
- 错误:调试完直接保存场景,原始材质被覆盖了
- 解决:始终在代码中保存
originalMaterial,调试完恢复
在顶点着色器里用
gl_FragColor- 错误:混淆顶点着色器和片元着色器
- 顶点着色器输出的是
gl_Position(顶点位置) - 片元着色器输出的是
gl_FragColor(像素颜色)
颜色值超出 0~1 范围
- 错误:
gl_FragColor = vec4(2.0, 0.0, 0.0, 1.0) - 结果:颜色被截断或显示异常(取决于 GPU)
- 解决:确保颜色值在 0~1 范围内,或者用
clamp()函数限制
- 错误:
自问自答
Q:为什么 Shader 不能 console.log?
A:Shader 运行在 GPU 上,GPU 没有控制台。而且 GPU 是并行执行的,一个画面有几十万个像素同时计算,每个像素都打印日志是不可能的。
Q:颜色可视化法只能看 0~1 的值吗?
A:是的,颜色通道范围是 01。如果变量值超出这个范围,可以用数学方法映射,比如 10 映射到 0~1。value / 10.0 把 0
十、移动端专项调试
10.1 真机调试方法
为什么需要真机调试?
电脑上的 Chrome 调试很方便,但真机和电脑有很大差异:
- 真机 GPU 性能弱很多
- 真机内存限制严格
- 真机有热节流(发热降频)
- 真机浏览器内核可能不同
术语解释:
- 热节流(Thermal Throttling):手机过热时,CPU/GPU 自动降频保护硬件,导致性能下降。
- ADB(Android Debug Bridge):Android 调试桥,是连接电脑和 Android 设备的命令行工具。
| 平台 | 工具 | 步骤 |
|---|---|---|
| iOS | Safari 远程调试 | iPhone 连 Mac → Safari → 开发 → 设备 |
| Android | Chrome 远程调试 | 手机开 USB 调试 → Chrome → chrome://inspect |
| 微信小游戏 | 开发者工具 | 真机调试模式 |
| 原生 | ADB + logcat | adb logcat -s Cocos |
10.2 移动端特有性能问题
| 问题 | 原因 | 检测方法 |
|---|---|---|
| 发热严重 | GPU 持续高负载 | 手感 + 系统监控 |
| 耗电快 | CPU/GPU 频率高 | 系统电池统计 |
| 低端机闪退 | 内存不足 / Shader 编译失败 | 日志分析 |
| 纹理显示异常 | 不支持 NPOT / 格式不支持 | Spector.js |
| 帧率波动 | 热节流(Thermal Throttling) | 长时间压力测试 |
术语解释:
- NPOT(Non-Power-Of-Two):非 2 的幂次方尺寸(如 100×100)。部分老旧 GPU 不支持 NPOT 纹理的 Mipmap 和重复寻址。
生活类比:移动端调试就像在不同海拔测试运动员。在平原(电脑)跑得很快,不代表在高原(真机)也能跑得快。真机就是"高原",氧气(性能)稀薄,你必须针对性地调整策略。
10.3 使用 Chrome DevTools 远程调试
Android 远程调试步骤(一步一步来):
步骤 1:手机开启开发者选项
- 设置 → 关于手机 → 连续点击"版本号"7 次
- 返回设置 → 系统 → 开发者选项
步骤 2:开启 USB 调试
- 在开发者选项中找到 "USB 调试"
- 开启开关
步骤 3:连接电脑
- 用 USB 线连接手机和电脑
- 手机上弹出 "允许 USB 调试吗?",点击"确定"
步骤 4:Chrome 中打开调试页面
- 电脑 Chrome 地址栏输入 chrome://inspect
- 等待几秒,应该能看到你的手机和设备上的页面列表
步骤 5:找到目标页面
- 在列表中找到你的 Cocos Web 构建页面
- 点击页面下方的 "Inspect" 链接
步骤 6:使用完整的 DevTools 功能
- 弹出的窗口和电脑调试完全一样
- 可以使用 Performance、Memory、Console 等所有面板
iOS 远程调试步骤(一步一步来):
步骤 1:iPhone 开启 Web 检查器
- 设置 → Safari → 高级 → 开启 "Web 检查器"
步骤 2:连接 Mac
- 用数据线连接 iPhone 和 Mac
步骤 3:Mac Safari 开启开发菜单
- Safari → 偏好设置 → 高级 → 勾选 "在菜单栏中显示开发菜单"
步骤 4:开始调试
- Mac Safari → 开发 → [你的设备名] → [你的页面]
- 弹出 Web Inspector 窗口
初学者常见错误
手机连接后 Chrome 看不到设备
- 错误:USB 线只充电不传输数据
- 解决:换一根支持数据传输的 USB 线(有些线只能充电)
iOS 调试时 Safari 看不到页面
- 错误:iPhone 锁屏了或 Safari 被杀了
- 解决:保持 iPhone 解锁,Safari 保持打开状态
真机调试时性能数据和电脑差异大
- 错误:以为电脑上的 FPS 就是真机 FPS
- 解决:始终以真机数据为准,电脑数据仅供参考
自问自答
Q:为什么真机发热后帧率会下降? A:这是手机的自我保护机制。当 CPU/GPU 温度过高时,系统会自动降低频率(降频),防止硬件烧毁。降频后性能自然下降,这就是"热节流"。
Q:微信小游戏怎么调试? A:微信小游戏有自己的开发者工具。在开发者工具中点击"真机调试",会生成一个二维码,用微信扫描后可以在电脑上查看真机的控制台日志和性能数据。
十一、自动化性能测试
11.1 基准测试框架
为什么需要自动化测试?
手动测试有两个问题:
- 不精确:人眼判断"卡不卡"很不准确
- 不可重复:今天测的结果和明天测的可能不一样
自动化测试就像工厂的"质检流水线",每次测试条件相同,结果可对比。
// PerformanceBenchmark.ts
// 性能基准测试类:用于对比不同实现方案的性能
export class PerformanceBenchmark {
// 存储每次测试的原始数据
// Map 的 key 是测试名称,value 是每次执行的时间数组
private _results: Map<string, number[]> = new Map();
/**
* 运行基准测试
* @param name 测试名称(用于标识和输出)
* @param fn 要测试的函数
* @param iterations 执行次数(默认 100 次)
* @returns 中位数耗时(毫秒)
*/
async run(name: string, fn: () => void, iterations: number = 100): Promise<number> {
// 存储每次执行的时间
const times: number[] = [];
// 预热阶段:先执行 10 次,排除 JIT 编译等初始开销
// JIT(Just-In-Time)是 JS 引擎的即时编译,第一次执行较慢
for (let i = 0; i < 10; i++) fn();
// 正式测试阶段
for (let i = 0; i < iterations; i++) {
// performance.now() 返回高精度时间戳(毫秒)
const start = performance.now();
// 执行被测试的函数
fn();
const end = performance.now();
// 记录本次耗时
times.push(end - start);
}
// 统计分析
// 先排序,方便计算中位数和百分位数
times.sort((a, b) => a - b);
// 中位数:排序后中间位置的值,不受极端值影响
const median = times[Math.floor(iterations / 2)];
// P95:95% 的请求都比这个值快,反映最差情况
const p95 = times[Math.floor(iterations * 0.95)];
// 平均值:总时间 / 次数
const avg = times.reduce((a, b) => a + b, 0) / times.length;
// 输出结果到控制台
console.log(`[Benchmark] ${name}:`);
console.log(` Median: ${median.toFixed(3)}ms`); // 中位数
console.log(` P95: ${p95.toFixed(3)}ms`); // 95 百分位
console.log(` Avg: ${avg.toFixed(3)}ms`); // 平均值
// 保存结果
this._results.set(name, times);
return median;
}
/**
* 对比两个实现方案
* @param nameA 方案 A 的名称
* @param fnA 方案 A 的函数
* @param nameB 方案 B 的名称
* @param fnB 方案 B 的函数
*/
async compare(nameA: string, fnA: () => void, nameB: string, fnB: () => void): Promise<void> {
// 分别测试两个方案
const timeA = await this.run(nameA, fnA);
const timeB = await this.run(nameB, fnB);
// 计算性能倍数
const ratio = timeA / timeB;
// 耗时短的获胜
const winner = ratio > 1 ? nameB : nameA;
console.log(`Winner: ${winner} (${ratio.toFixed(2)}x faster)`);
}
}
// 使用示例
const benchmark = new PerformanceBenchmark();
// 测试两种列表更新方式
benchmark.compare(
'Direct Update', // 方案 A:直接更新所有节点
() => { /* 直接更新所有节点 */ },
'Virtual List', // 方案 B:虚拟列表更新
() => { /* 虚拟列表更新 */ }
);
初学者常见错误
忘记预热
- 错误:直接开始测试,第一次执行特别慢
- 原因:JS 引擎的 JIT 编译在第一次执行时发生
- 解决:代码中已经包含预热循环
for (let i = 0; i < 10; i++)
测试函数里有副作用
- 错误:测试函数修改了全局状态,第二次执行结果不同
- 解决:确保每次测试的初始状态相同
只看平均值
- 错误:平均值 10ms 就以为没问题
- 问题:可能 99% 是 5ms,但 1% 是 500ms(严重卡顿)
- 解决:同时关注 P95 和最小值
自问自答
Q:为什么要用中位数而不是平均值? A:平均值容易被极端值拉偏。比如 99 次 10ms,1 次 1000ms,平均值是 19.9ms,但中位数是 10ms。中位数更能反映"典型情况"。
Q:P95 是什么意思? A:P95 表示"95% 的情况下都比这个值好"。如果 P95 是 20ms,意味着 95% 的帧都能在 20ms 内完成,只有 5% 的帧更慢。这是衡量"卡顿严重程度"的重要指标。
十二、知识图谱
渲染调试
├── 内置工具
│ ├── Cocos Stats(FPS/DrawCall/内存)
│ └── 自定义性能面板
│
├── 浏览器工具
│ ├── Chrome Performance(火焰图)
│ ├── Chrome Memory(堆快照)
│ └── Remote Debugging(真机)
│
├── WebGL 抓帧
│ ├── Spector.js(推荐)
│ │ ├── Commands 列表
│ │ ├── Texture/Buffer/Shader 查看
│ │ └── 逐 DrawCall 可视化
│ └── WebGL-Inspector(Shader 实时编辑)
│
├── 原生 GPU 调试
│ ├── RenderDoc(Windows/Android)
│ │ ├── Pixel History
│ │ ├── Overdraw 热力图
│ │ └── Shader Debugger
│ └── Intel GPA(集成显卡)
│
├── 专项分析
│ ├── Overdraw 检测与优化
│ ├── Shader 颜色可视化
│ └── 移动端发热/耗电
│
└── 自动化
├── console.time 快速测量
└── 基准测试框架
十三、自检清单
| 检查项 | 说明 |
|---|---|
| □ 会使用 Cocos Stats | 识别 FPS/DrawCall/内存瓶颈 |
| □ 会用 Chrome Performance | 分析 JS 耗时火焰图 |
| □ 会用 Spector.js 抓帧 | 定位合批失败、材质切换 |
| □ 会用 RenderDoc | Pixel History、Overdraw、Shader 调试 |
| □ 掌握 Shader 颜色可视化 | UV/法线/深度/Alpha 可视化 |
| □ 会检测 Overdraw | 热力图分析、优化策略 |
| □ 会真机远程调试 | Chrome Inspect / Safari 远程 |
| □ 能写性能监控组件 | 实时 FPS/DrawCall/GPU 时间 |
| □ 会写基准测试 | 对比不同实现的性能 |
| □ 理解移动端特有问题 | 热节流、内存限制、Shader 兼容性 |
附录:调试工具速查表
| 问题现象 | 首选工具 | 操作步骤 | 预期结果 |
|---|---|---|---|
| 游戏卡顿,不知道原因 | Cocos Stats | 开启 Stats 面板 | 看 FPS/DrawCall/Frame Time |
| JS 代码耗时长 | Chrome Performance | F12 → Performance → Record | 找到火焰图中最宽的黄色条 |
| DrawCall 太高 | Spector.js | 抓帧 → 看 Commands 列表 | 找到 bindTexture/useProgram 中断合批的位置 |
| 某个像素颜色不对 | RenderDoc Pixel History | 抓帧 → Texture Viewer → 右键像素 | 查看该像素的完整绘制历史 |
| 发热严重 | 手感 + Stats | 长时间运行观察 | Frame Time 逐渐增长说明热节流 |
| 内存持续增长 | Chrome Memory | Heap Snapshot → Comparison | 找到增长的对象类型 |
| Shader 输出异常 | 颜色可视化法 | 修改 Shader 输出中间变量 | 用眼睛看变量值是否正确 |
| 真机表现和电脑不同 | Chrome Remote Debug | chrome://inspect → Inspect | 在真机上用 Performance 面板分析 |
结语:渲染优化是一个持续迭代的过程。掌握原理、善用工具、数据驱动,才能在各种平台上交付流畅的体验。祝你的游戏 60 FPS 常驻!