可视化与渲染调试:工具链与实战技巧

对应月影课程:第 23~28 章(调试工具、性能分析、可视化原理)

目标:掌握完整的渲染调试工具链,能独立定位 DrawCall、Overdraw、Shader、GPU 瓶颈。


写在前面:为什么调试是程序员的必修课?

如果不懂调试会怎样?

想象你是一位厨师,菜做咸了却不知道哪里出了问题——是盐放多了?还是酱油放多了?还是高汤本身就很咸?没有调试能力,你只能靠猜,一道菜可能要重做十几次才能成功。

写代码也一样。渲染性能出问题时,如果你不会调试,就只能盲目地"优化":

  • 听说合批能降低 DrawCall,就把所有代码改一遍,结果反而更卡了
  • 听说 Shader 要简化,就删掉一半代码,结果画面全黑了
  • 听说纹理要压缩,就全改成压缩格式,结果某些设备上显示花屏

调试的本质是"用数据说话"。就像医生用体温计、血压计、X光来诊断病情,程序员用调试工具来诊断程序的问题。学会调试,你的优化效率会提升 10 倍以上。

调试的思维方式

调试不是"修 bug",而是科学实验

  1. 观察现象:游戏卡顿了,帧率从 60 掉到 20
  2. 提出假设:可能是 DrawCall 太高?可能是 Shader 太复杂?可能是内存泄漏?
  3. 设计实验:用工具抓帧、测内存、看耗时
  4. 验证假设:数据告诉你真正的问题在哪
  5. 解决问题:针对根因优化,而不是盲目修改

一、调试方法论:从现象到根因

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)。

初学者常见错误

  1. 只看 FPS,不看 Frame Time

    • 错误:FPS 显示 60 就以为没问题
    • 问题:FPS 是平均值,可能大部分时间 16ms,偶尔一帧 100ms,玩家会感受到卡顿
    • 正确:同时关注 Frame Time 的稳定性,波动大说明有卡顿
  2. DrawCall 高就盲目合批

    • 错误:看到 DrawCall 100+,就把所有 Sprite 放同一个节点下
    • 问题:如果纹理不同,合批会失败,反而增加层级管理复杂度
    • 正确:先用 Spector.js 分析为什么合批失败
  3. 忽视内存增长趋势

    • 错误: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);
    }
}

初学者常见错误

  1. 忘记在编辑器中绑定 Label

    • 错误:代码写好了,但运行后 Label 不显示
    • 原因:@property(Label) 只是声明,需要在编辑器里把对应的 Label 节点拖进去
    • 解决:选中挂载 PerformanceMonitor 的节点,在属性检查器中把 Label 拖进去
  2. 在非 WebGL 2.0 环境下使用 GPU 查询

    • 错误:在微信小游戏或 WebGL 1.0 设备上 GPU 时间显示为 0
    • 原因:EXT_disjoint_timer_query_webgl2 是 WebGL 2.0 扩展,不支持时 _queryExt 为 null
    • 解决:代码中已经做了判断 if (this._queryExt),但初学者可能没注意到这个限制
  3. 频繁创建查询对象导致内存泄漏

    • 错误:如果不在 _measureGPUTimedeleteQuery,查询对象会无限累积
    • 原因:每帧创建一个查询,但只在结果可用时删除
    • 解决:确保代码中有 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);

初学者常见错误

  1. console.time 标签名不匹配

    • 错误:console.time('A')console.timeEnd('B')
    • 结果:不会输出任何结果,也不会报错
    • 正确:确保开始和结束的标签名完全一致
  2. 在 Performance 录制时同时用 console.time

    • 错误:Performance 已经在录制,console.time 的输出被淹没在大量日志中
    • 正确:先用 console.time 快速定位问题范围,再用 Performance 深入分析
  3. 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.drawArraysgl.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 严重,蓝色表示正常
  - 详见第六节

初学者常见错误

  1. 抓帧时选错了 Canvas

    • 错误:页面有多个 Canvas,Spector.js 抓到了错误的那个
    • 结果:Commands 列表为空或显示的不是你的游戏
    • 解决:确保 Spector.js 面板中显示的是你的游戏 Canvas
  2. 看不懂 Commands 列表

    • 错误:看到几百条命令就慌了,不知道从哪看起
    • 解决:先只看 drawArraysdrawElements,这些是实际的绘制命令。其他的 bindTextureuseProgram 等是状态设置。
  3. 修改 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" 列

初学者常见错误

  1. RenderDoc 无法注入某些程序

    • 错误:尝试注入 Steam 游戏或受保护的应用程序
    • 结果:注入失败或游戏崩溃
    • 解决:RenderDoc 对某些 DRM 保护的游戏不支持,先用简单程序测试
  2. 抓帧后找不到 Shader 代码

    • 错误:在 Shader Debugger 中看不到源码
    • 原因:某些平台(如 Metal)Shader 是编译后的二进制,没有源码
    • 解决:尝试在 Windows/OpenGL 平台下调试,源码支持最好
  3. 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 显著

生活类比

  • 减少透明区域:不要用半透明的玻璃纸做背景,用实色的纸板
  • 不透明背景:先画墙(不透明),再画窗户,这样墙后面的东西就不用画了
  • 从前往后绘制:先画前面的物体,后面的物体被深度测试挡住,就不用画了

初学者常见错误

  1. 全屏半透明遮罩滥用

    • 错误:每个弹窗都加一个全屏黑色半透明遮罩
    • 结果:整个屏幕每个像素都多画了一次
    • 解决:用不透明的深色底 + 边缘渐变,只在边缘有透明度
  2. UI 层级嵌套过深

    • 错误:一个按钮有 5 层父节点,每层都有背景图
    • 结果:即使按钮只占屏幕 5%,也要画 5 层
    • 解决:合并相邻层级的背景,减少不必要的层级
  3. 忽视 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;

初学者常见错误

  1. 替换 Shader 后忘记恢复

    • 错误:调试完直接保存场景,原始材质被覆盖了
    • 解决:始终在代码中保存 originalMaterial,调试完恢复
  2. 在顶点着色器里用 gl_FragColor

    • 错误:混淆顶点着色器和片元着色器
    • 顶点着色器输出的是 gl_Position(顶点位置)
    • 片元着色器输出的是 gl_FragColor(像素颜色)
  3. 颜色值超出 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。如果变量值超出这个范围,可以用数学方法映射,比如 value / 10.0 把 010 映射到 0~1。


十、移动端专项调试

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 窗口

初学者常见错误

  1. 手机连接后 Chrome 看不到设备

    • 错误:USB 线只充电不传输数据
    • 解决:换一根支持数据传输的 USB 线(有些线只能充电)
  2. iOS 调试时 Safari 看不到页面

    • 错误:iPhone 锁屏了或 Safari 被杀了
    • 解决:保持 iPhone 解锁,Safari 保持打开状态
  3. 真机调试时性能数据和电脑差异大

    • 错误:以为电脑上的 FPS 就是真机 FPS
    • 解决:始终以真机数据为准,电脑数据仅供参考

自问自答

Q:为什么真机发热后帧率会下降? A:这是手机的自我保护机制。当 CPU/GPU 温度过高时,系统会自动降低频率(降频),防止硬件烧毁。降频后性能自然下降,这就是"热节流"。

Q:微信小游戏怎么调试? A:微信小游戏有自己的开发者工具。在开发者工具中点击"真机调试",会生成一个二维码,用微信扫描后可以在电脑上查看真机的控制台日志和性能数据。


十一、自动化性能测试

11.1 基准测试框架

为什么需要自动化测试?

手动测试有两个问题:

  1. 不精确:人眼判断"卡不卡"很不准确
  2. 不可重复:今天测的结果和明天测的可能不一样

自动化测试就像工厂的"质检流水线",每次测试条件相同,结果可对比。

// 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:虚拟列表更新
    () => { /* 虚拟列表更新 */ }
);

初学者常见错误

  1. 忘记预热

    • 错误:直接开始测试,第一次执行特别慢
    • 原因:JS 引擎的 JIT 编译在第一次执行时发生
    • 解决:代码中已经包含预热循环 for (let i = 0; i < 10; i++)
  2. 测试函数里有副作用

    • 错误:测试函数修改了全局状态,第二次执行结果不同
    • 解决:确保每次测试的初始状态相同
  3. 只看平均值

    • 错误:平均值 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 常驻!