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 画当前正在接触的配对线,一眼就能看出"这两个东西根本没碰上"还是"碰到了但没触发回调"。后者是分组配错时的典型症状。
