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
有两个反直觉点,值得单独记住:
All是 63,不是 64 也不是 127。 它等于前六个标志位之和,恰好不含Controller = 64。想要「全开」必须写EPhysics2DDrawFlags.All | EPhysics2DDrawFlags.Controller。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 工程决策
落到《方块冲刺》:
- 调试绘制只在开发构建开。 用
DEBUG宏或构建变量包住debugDrawFlags的赋值,正式包必须为None——它在原生端有实打实的绘制开销。 fixedTimeStep不要轻易改。 保持默认 1/60,改它等于改所有物理参数的隐含前提。maxSubSteps调大要谨慎。 它是「不雪崩」的护栏;调大到 8,长帧时会真的补 8 步,可能把一帧推到 100 ms 以上。- 把
profiler.stats.physics纳入性能看板。 和渲染的 Draw Call 一样,物理耗时应该是可观测的常态指标,而不是出问题才临时去测。 - 给物理单独留一个调试开关。 在
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 自定义碰撞与查询 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。