《Cocos 游戏开发高级编程》5.2 物理调试与性能

讲清 Cocos 2D 物理的可观测性:用 PhysicsSystem2D 的 debugDrawFlags 与 EPhysics2DDrawFlags 位掩码把物理世界画出来,理解 fixedTimeStep、maxSubSteps、autoSimulation 与求解器迭代参数的语义,并用 profiler.stats.physics 定位物理帧开销,附可单测的固定步长累加器实现。

5.2 物理调试与性能

物理是最容易「看不见」的子系统:碰撞体是逻辑形状,不渲染;刚体在背后积分,不体现在节点树上。当主角穿墙、金币不触发、帧率在关卡加载后掉下去,你没法像查渲染那样看一眼 Draw Call 就定位。这一节讲两件事——怎么把物理世界画出来,以及怎么把物理开销从帧预算里单独拎出来量。

5.2.1 把物理世界画出来:debugDrawFlags

PhysicsSystem2D.instance 上有一个 debugDrawFlags 属性,类型是 number,取值来自 EPhysics2DDrawFlags:

import { _decorator, Component, PhysicsSystem2D, EPhysics2DDrawFlags } from 'cc';
const { ccclass } = _decorator;

@ccclass('PhysicsDebugToggle')
export class PhysicsDebugToggle extends Component {
    start() {
        // 打开:形状 + 包围盒 + 碰撞对
        PhysicsSystem2D.instance.debugDrawFlags =
            EPhysics2DDrawFlags.Shape |
            EPhysics2DDrawFlags.Aabb |
            EPhysics2DDrawFlags.Pair;
    }

    onDestroy() {
        PhysicsSystem2D.instance.debugDrawFlags = EPhysics2DDrawFlags.None;
    }
}

打开后,编辑器/预览窗口里会在物理形状的位置叠一层线框:蓝色通常是静态刚体、绿色是动态、黄色是运动学(具体配色随版本可能微调,以你屏幕上的图例为准)。这是排查「碰撞体和精灵对不上」最快的手段——offset 没设、size 和贴图尺寸不一致、缩放没同步,一眼就能看出来。

5.2.2 EPhysics2DDrawFlags 是一个位掩码

这个枚举不是「单选」,而是按位组合。engine/cc.d.ts 第 47332 行给出的完整定义:

// EPhysics2DDrawFlags(摘自 @cocos/creator-types@3.8.8)
None = 0,           // 关闭
Shape = 1,          // 碰撞形状轮廓
Joint = 2,          // 关节
Aabb = 4,           // 轴对齐包围盒(Broadphase 用的那个)
Pair = 8,           // 正在检测的碰撞对
CenterOfMass = 16,  // 质心
Particle = 32,      // 粒子
Controller = 64,    // 控制器
All = 63            // = 1+2+4+8+16+32,注意不含 Controller

有两个反直觉点,值得单独记住:

  1. All 是 63,不是 64 也不是 127。 它等于前六个标志位之和,恰好不含 Controller = 64。想要「全开」必须写 EPhysics2DDrawFlags.All | EPhysics2DDrawFlags.Controller。
  2. Aabb 与 Pair 是性能调试的利器,不是视觉调试的。 Pair 会画出当前正在做 Narrowphase 的碰撞对;如果屏幕上密密麻麻全是 Pair 线,说明 Broadphase 没能有效剔除,碰撞体数量或分组出了问题。

组合的写法就是按位或,关掉某个维度用按位与取反:

// 先全开,再关掉 Aabb
let flags = EPhysics2DDrawFlags.All | EPhysics2DDrawFlags.Controller;
flags &= ~EPhysics2DDrawFlags.Aabb;
PhysicsSystem2D.instance.debugDrawFlags = flags;

5.2.3 步进参数:fixedTimeStep / maxSubSteps / autoSimulation

物理求解和渲染帧率解耦,这是固定步长(fixed timestep)的基本设计。PhysicsSystem2D.instance 暴露了三个关键参数(engine/cc.d.ts 第 47344 行起,源码路径 cocos/physics-2d/framework/physics-system.ts,tag 3.8.8):

import { PhysicsSystem2D } from 'cc';

const ps = PhysicsSystem2D.instance;

// 每个物理步的固定时长(秒),默认约 1/60
console.log(ps.fixedTimeStep);

// 单帧最多补几个子步,默认 4——防止「一次卡顿后物理追帧追到崩」
console.log(ps.maxSubSteps);

// 是否自动模拟。关掉后需要自己调 step()
console.log(ps.autoSimulation);
console.log(ps.stepping); // 当前是否正在步进中

它们的分工是这样的:

  • fixedTimeStep 决定物理世界的「时间分辨率」。调小更精确但更贵,调大更省但会「跳」。
  • maxSubSteps 是安全阀。一帧如果花了 100 ms,理论上要补 6 个步,但 maxSubSteps = 4 会把它截断在 4 步——代价是物理时间慢于真实时间,换来的是不会雪崩。
  • autoSimulation = false 后,引擎不再自动步进,你在合适的时机自己调:
const ps = PhysicsSystem2D.instance;
ps.autoSimulation = false;

// 重置时间累加器,避免暂停后一次性补一大串子步
ps.resetAccumulator();

// 手动推进一步(例如联机的「锁步」帧)
ps.step(1 / 60);

resetAccumulator() 在从暂停恢复、切场景、联机回放这三种场景下必须调用,否则累加器里攒下的时间会在恢复那一帧被一次性补完,表现为「一恢复就瞬移/穿墙」。

5.2.4 求解器迭代:velocityIterations / positionIterations

Box2D 的约束求解是迭代式的,迭代次数直接决定「约束有多硬」。两个参数在 PhysicsSystem2D.instance 上:

const ps = PhysicsSystem2D.instance;
ps.velocityIterations = 8;   // 速度约束迭代
ps.positionIterations = 3;   // 位置约束迭代

经验规律:

  • 堆叠不稳、抖动、缓慢下沉 → 加 velocityIterations。
  • 穿透、重叠没被推开 → 加 positionIterations。
  • 两者都是线性放大开销:8 → 16 基本就是物理步耗时翻倍。

builtin 后端不实现约束求解,这两个参数对它没有意义(见 5.1 那张能力对照表)。

5.2.5 用 profiler 定位物理开销

Cocos 内置的 profiler 把帧时间拆成了几段,其中就有一段专给物理。profiler.stats 返回一个 IProfilerState,字段包含 frame / fps / logic / physics / render / present 等:

import { profiler } from 'cc';

const state = profiler.stats;
if (state) {
    // 每段是一个 ICounterOption,其 counter.value 即耗时(ms)
    const physicsMs = state.physics.counter.value;
    const logicMs = state.logic.counter.value;
    console.log(`physics ${physicsMs.toFixed(2)} ms / logic ${logicMs.toFixed(2)} ms`);
}

Profiler 类上还有 beforePhysics() / afterPhysics() 两个钩子(engine/cc.d.ts 第 51675 行起),这正是「物理耗时能被单独统计」的原因——引擎在物理步前后各打一次时间戳。

对照卷二 13.3 定的帧预算:60 FPS → 16.6 ms/帧。物理的合理占比通常是 10%–20%,即 1.5–3 ms。 如果你的 physics 稳定超过 5 ms,就该按下面的顺序排查:

现象优先怀疑处理
physics 随时间缓慢上升刚体没休眠、堆积在场景里检查 allowSleep,清理离场物体
某帧 physics 尖刺一帧补了多个子步看 maxSubSteps,检查是否有长帧
physics 一直偏高迭代次数过大 / 碰撞对过多降 velocityIterations,用分组裁剪
physics 与 logic 同涨大量回调里做了重活回调里只打标记,逻辑延后处理

其中「回调里做重活」是最常见的隐性成本:BEGIN_CONTACT 回调里如果做对象查找、字符串拼接、甚至发网络包,这些耗时会被算进物理步,让 physics 段虚高。回调里只做 O(1) 的标记,重活放到 update 里批量处理——这条规则在 5.3 讲自定义碰撞响应时还会用到。

5.2.6 L1:复刻固定步长累加器

固定步长 + 最大子步的逻辑是纯算法,与引擎无关,可以直接单测。下面这段能在 Node/vitest 里跑,用来验证你对 fixedTimeStep / maxSubSteps 的理解:

// mini2d/physics/stepper.ts —— 纯 TS,无 cc 依赖
export interface StepperOptions {
    fixedTimeStep: number; // 每个物理步的固定时长(秒)
    maxSubSteps: number;   // 单帧最多补几个子步
}

export class FixedStepper {
    private accumulator = 0;
    constructor(private readonly opts: StepperOptions) {}

    /** 推入一帧的实际时长,返回本帧应执行的物理步数 */
    advance(frameDelta: number): number {
        this.accumulator += frameDelta;
        let steps = 0;
        while (this.accumulator >= this.opts.fixedTimeStep
            && steps < this.opts.maxSubSteps) {
            this.accumulator -= this.opts.fixedTimeStep;
            steps += 1;
        }
        // 被 maxSubSteps 截断时,丢弃多余累积,避免追帧雪崩
        if (steps === this.opts.maxSubSteps) {
            this.accumulator = 0;
        }
        return steps;
    }

    reset(): void {
        this.accumulator = 0;
    }
}

测试覆盖两个关键语义——正常帧只走一步、长帧被 maxSubSteps 截断:

import { describe, it, expect } from 'vitest';
import { FixedStepper } from '../mini2d/physics/stepper';

describe('FixedStepper', () => {
    it('正常帧走一步', () => {
        const s = new FixedStepper({ fixedTimeStep: 1 / 60, maxSubSteps: 4 });
        expect(s.advance(1 / 60)).toBe(1);
    });

    it('长帧被 maxSubSteps 截断', () => {
        const s = new FixedStepper({ fixedTimeStep: 1 / 60, maxSubSteps: 4 });
        // 100ms 理论要 6 步,被截断到 4 步
        expect(s.advance(0.1)).toBe(4);
    });
});

跑通这两个用例,你就理解了 maxSubSteps 到底在保护什么:它用「物理时间落后于真实时间」换取「不雪崩」。这也解释了为什么卡顿之后,物体的物理位置会「落后」一小段——那是引擎主动做的取舍,不是 bug。

5.2.7 工程决策

落到《方块冲刺》:

  1. 调试绘制只在开发构建开。 用 DEBUG 宏或构建变量包住 debugDrawFlags 的赋值,正式包必须为 None——它在原生端有实打实的绘制开销。
  2. fixedTimeStep 不要轻易改。 保持默认 1/60,改它等于改所有物理参数的隐含前提。
  3. maxSubSteps 调大要谨慎。 它是「不雪崩」的护栏;调大到 8,长帧时会真的补 8 步,可能把一帧推到 100 ms 以上。
  4. 把 profiler.stats.physics 纳入性能看板。 和渲染的 Draw Call 一样,物理耗时应该是可观测的常态指标,而不是出问题才临时去测。
  5. 给物理单独留一个调试开关。 在 assets/scripts/core 下放一个全局的 physicsDebug 配置,运行时可以一键开关 debugDrawFlags,避免每次改代码重新构建。

一个实践细节:debugDrawFlags 的赋值最好只在开发构建里生效。可以在工程里定义一个构建常量,让正式包把这段代码整段摇树掉:

import { PhysicsSystem2D, EPhysics2DDrawFlags } from 'cc';

// 假设 DEBUG 是构建期常量(正式构建为 false)
declare const DEBUG: boolean;
if (DEBUG) {
    PhysicsSystem2D.instance.debugDrawFlags = EPhysics2DDrawFlags.All;
}

DEBUG 为 false 时,整个 if 块会被压缩器判定为死代码移除,正式包里连这段调用都不会留下。

下一节进入这一章的落点:当内置的碰撞回调不够用时,怎么用射线、点查、区域查询,以及怎么在 Contact2DType 回调里写自定义响应。

阅读导航:上一节:5.1 内置物理与 Box2D 后端 · 下一节:5.3 自定义碰撞与查询 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画