2D 物理与手感调参

2.1 无人机到底要不要挂刚体

第一章立项的时候我写的是"用 RigidBody2D 表达风力推力"。做起来才发现,这个选择要么全用,要么全不用,最难受的是半用半不用。

先说结论:Skyline Courier 里的无人机我没有挂刚体。玩家直接操控的主角用位置插值更听话,这个我在上一章讲过了。真正用上物理的是那些玩家不直接操作的物件,比如被撞飞的包裹、飘落的纸屑、关卡里的障碍墙体。

那么障碍物为什么要挂刚体?因为它们需要"撞到无人机之后给出反馈"。哪怕反馈只是一个减速数字,物理系统能免费给你碰撞点、法向量和相对速度,比自己写一套 AABB 检测靠谱得多。

这一章讲的是那些真正挂上刚体的东西怎么调。

2.2 刚体类型怎么分配

3.x 的 2D 刚体类型是枚举 ERigidBody2DType,四个值:Static、Kinematic、Dynamic、Animated。这个枚举跟 3D 物理的 ERigidBodyType 名字很像但不是同一个东西,我第一次导入的时候写错了类型名,编辑器直接报类型不匹配。

四个类型的区别,按官方文档的说法:

类型 质量 受重力 速度怎么来 我用它做什么
Static 零 否 手动设置位置 关卡里的楼宇墙体、地面
Kinematic 零 否 可以设置速度 匀速移动的巡逻障碍、传送带
Dynamic 有 是 可以设置速度,也吃力和冲量 被撞飞的包裹、可推动的杂物
Animated 零 否 从当前位移和目标位移反算速度 用动画驱动的移动平台

Animated 是 Cocos 自己加的。Box2D 原生只有三种,Animated 是从 Kinematic 派生的:它会根据你当前的位置和目标位置算出需要的速度再赋值,而不是让你直接设速度。官方文档说加这个类型是为了防止给刚体做动画时出现穿透。我用得不多,但关卡里的"电梯平台"就是 Animated 类型,动画改它的位置,速度由引擎算,比手动同步位置和速度省事。

有一点我得强调:Animated 在内部是把刚体设成 Kinematic 的。改类型的时候你会看到引擎内部转了一道,这不奇怪。

2.3 linearVelocity 和冲量,差别在哪

这两个是新手最容易混的一对。官方文档里有一句话我认为是整篇物理文档最该加粗的:力会随时间慢慢修改物体的速度,冲量会立即修改物体的速度。

linearVelocity 是直接赋值速度。赋值的那一刻速度就变了,之后靠阻尼和重力慢慢衰减。适合"我明确知道它现在该有多快"的场景,比如关卡开场给一个初速度。

冲量是 applyLinearImpulse 和 applyLinearImpulseToCenter,前者带作用点,后者直接作用在质心上。冲量改变的是速度增量,瞬时生效。

import { _decorator, Component, RigidBody2D, Vec2 } from 'cc';
const { ccclass } = _decorator;

@ccclass('WindZone')
export class WindZone extends Component {
    private _body!: RigidBody2D;
    private _force = new Vec2(60, 0); // 风区朝向决定正负

    onLoad () {
        this._body = this.getComponent(RigidBody2D)!;
    }

    update () {
        // 风是持续的力,每帧施加,引擎按固定步长积分
        this._body.applyForceToCenter(this._force, true);
    }

    onBump () {
        // 撞击那一瞬间给一个冲量,让包裹明显弹开
        this._body.applyLinearImpulseToCenter(new Vec2(0, 120), true);
    }
}

这两个方法在 3.x 里都有第三个参数 wake,意思是"要不要顺便把刚体唤醒"。刚体如果正在休眠,你给它施加力但忘了唤醒,它会纹丝不动。这个坑我踩过:包裹落在地上不动了,代码检查了半天发现力确实在施加。

还有个细节要说:applyForce 传进去的是"持续施加的力",力的作用是随时间慢慢改变速度,不像冲量那样一步到位。所以我在 update 里每帧施加同一个力向量,对象就会一直加速;只想推一下的话,应该用冲量。

3.x 的方法名比 2.x 更啰嗦,但也更明确:线冲量分成 applyLinearImpulse(作用到指定点)和 applyLinearImpulseToCenter(作用到质心)两个版本,力也对应分成 applyForce 和 applyForceToCenter。我从旧项目搬代码时,方法名对不上,TypeScript 直接报方法不存在。

2.4 gravityScale:怎么调出"飘但不飘"

无人机挂 Dynamic 刚体的时候,如果不干预,它会往下掉。我想要的效果是"有一点点失重感,但不明显"。

关键参数是 gravityScale。它是重力缩放比例,只对 Dynamic 类型生效。默认值是 1,也就是完全接受全局重力。设成 0 就是完全不受重力,设成 0.3 就是只受三成重力。

全局重力是 PhysicsSystem2D.instance.gravity,默认值 (0, -10)。这个值是米每秒方,乘以 PHYSICS_2D_PTM_RATIO(默认 32)换算成世界单位,所以实际是 (0, -320) 世界单位每秒方。

我在无人机的刚体上设 gravityScale = 0.25。手感上的差别是:无人机在屏幕上飘落的时候速度增加得很慢,看起来像有空气阻力托着,但又不至于完全失重让人失去方向感。0.3 以上就明显飘了,0.2 以下就基本感受不到重力了。

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

@ccclass('DronePhysics')
export class DronePhysics extends Component {
    onLoad () {
        const body = this.getComponent(RigidBody2D)!;
        body.type = ERigidBody2DType.Dynamic;
        body.gravityScale = 0.25;   // 轻微失重
        body.linearDamping = 2.0;   // 模拟空气阻力
        body.fixedRotation = true;   // 无人机不因为被撞而自转
        body.allowSleep = true;
        body.enabledContactListener = true; // 监听碰撞回调

        // 全局步长保持 1/60,这是默认值
        PhysicsSystem2D.instance.fixedTimeStep = 1 / 60;
    }
}

fixedRotation 这个参数我用得最多。默认是 false,意思是刚体可以自由旋转。我的包裹在地面上翻滚是想要这个效果,无人机撞墙后歪过来转到天朝下,那就太难看了,勾上 fixedRotation 就锁住旋转。

不过要注意,fixedRotation 锁的是旋转,不是旋转速度。它不会让无人机在碰撞时保持原来的朝向角度,只是不让它转。多数时候这够用了。

2.5 手感参数对照表,和休眠这件小事

这张表是我调参时真正在用的。左边是参数名,右边是我实际观察到的变化。

参数 挂在哪 调大的效果 调小的效果
gravityScale RigidBody2D 下落更快,更像石头 更飘,失重感更强,0 附近就飘着不动了
linearDamping RigidBody2D 速度衰减更快,滑行距离短 衰减慢,容易一直飘,冲量效果更持久
angularDamping RigidBody2D 自转更快停住 自转持续更久,看着像陀螺
fixedRotation RigidBody2D 无 设为 true 完全不旋转,撞墙歪不了
friction Collider2D 接触时更"涩",撞上去不容易滑开 更滑,容易被撞飞后滑很远
restitution Collider2D 弹得更高,撞墙像乒乓球 弹得低,接近完全不弹
density Collider2D 质量更大,推不动,撞别人自己不动 质量小,一推就飞
fixedTimeStep PhysicsSystem2D 模拟更精细、更贵 更省,但穿透概率上升
velocityIterations PhysicsSystem2D 碰撞解算更准,堆叠更稳 更抖,物体容易陷进地里
positionIterations PhysicsSystem2D 位置修正更准 更抖
allowSleep PhysicsSystem2D 无 设为 false,物体永不休眠,CPU 一直跑

friction 和 restitution 挂在 Collider2D 上,不是刚体上。这点我也搞错过。官方文档写得很清楚:刚体的质量是通过碰撞组件的密度和大小自动计算出来的。所以想改质量,去改碰撞器的 density,别去刚体上找质量字段。3.x 的 2D 刚体压根没有质量属性,只有 getMass() 这个读取方法。

fixedTimeStep 我一开始想调成 1/30 省性能,结果包裹开始穿墙。官方文档在设置步长那节明确写了:降低物理步长和迭代次数都会降低物理的检测频率,更有可能发生刚体穿透。这个项目的包裹飞行速度不慢,我老老实实留在 1/60。

关于休眠:关卡里那些停在原地的障碍物、已经送达的包裹、飘到角落的碎片,如果它们一直在参与物理模拟,CPU 就一直在算。3.x 的物理系统默认允许休眠,刚体速度低于阈值(项目设置里的 sleepThreshold,默认 0.1)并持续一段时间后进入休眠,不再参与碰撞计算。RigidBody2D 上有 isAwake() 查状态,也有 sleep() 和 wakeUp() 手动控制。

我实测过的收益:一关里同时存在 60 个刚体,开启休眠之后静止部分的物理耗时降到三分之一左右。听起来不多,但在低端机上这就是 60 帧和 40 帧的差别。allowSleep = false 的刚体永远不会休眠,官方文档注释里写得很直接"这将使 CPU 占用率提高",别图省事给所有东西都关掉休眠。

想看物理系统到底在干什么,可以打开调试绘制。PhysicsSystem2D.instance.debugDrawFlags 是位标志,能组合出包围盒、配对线、质心、关节、形状几种视图:

import { PhysicsSystem2D, EPhysics2DDrawFlags } from 'cc';

PhysicsSystem2D.instance.debugDrawFlags =
    EPhysics2DDrawFlags.Shape | EPhysics2DDrawFlags.Pair;

调参阶段我几乎一直开着 Shape 和 Pair。Shape 画碰撞体轮廓,Pair 画当前正在接触的配对线,一眼就能看出"这两个东西根本没碰上"还是"碰到了但没触发回调"。后者是分组配错时的典型症状。

2.6 相关阅读