ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Cocos Creator物理引擎实战:从选型到优化,打造真实游戏交互

2026/8/8 1:53:50 拓冰建站 浏览量
Cocos Creator物理引擎实战:从选型到优化,打造真实游戏交互

1. 项目概述:为什么物理引擎是游戏开发的“定海神针”?

在Cocos Creator里折腾过一阵子游戏开发的朋友,大概都经历过这样的场景:你精心设计了一个角色跳跃动作,结果它像火箭一样飞出了屏幕外;或者想让几个箱子堆叠起来,它们却像喝醉了酒一样互相穿透、疯狂抖动。这些让人头疼的问题,根源往往不在你的代码逻辑,而在于缺少一套稳定、可靠的物理模拟系统。物理引擎,就是解决这些问题的核心工具,它负责计算游戏中物体的运动、碰撞、重力、摩擦力等一切符合物理规律的行为,是让虚拟世界变得真实可信的“物理法则制定者”。

这次我们不谈枯燥的理论,直接切入实战。我将结合几个具体的开发案例,带你深入Cocos Creator的物理世界,从引擎选型、组件配置到脚本编写和性能优化,手把手拆解物理引擎的应用。无论你是想做一个简单的“愤怒的小鸟”式弹射游戏,还是一个需要复杂关节和约束的机械模拟,理解物理引擎的“脾气”和“用法”,都能让你的开发效率大幅提升。这篇文章适合已经熟悉Cocos Creator基础操作,希望在游戏真实感和交互性上更进一步的开发者。我们会聚焦于3D物理,因为3D场景下的物理交互更为复杂,也更能体现引擎的能力边界。

2. 物理引擎选型深度解析:Bullet、Cannon.js、PhysX与Builtin的抉择

在Cocos Creator中开启物理功能,第一步就是在项目设置 -> 功能裁剪 -> 3D物理系统里做出选择。面对Bullet、Cannon.js、PhysX和Builtin这四个选项,很多开发者会感到困惑。这不仅仅是选择一个功能开关,而是为你的项目选定整个物理模拟的“底层架构”,它直接影响最终包体大小、运行性能、功能支持以及平台兼容性。

2.1 各引擎核心特性与适用场景对比

为了让你一目了然,我将这四大引擎的关键信息整理成了下面的表格:

特性维度Builtin (内置)Cannon.jsBullet (ammo.js)PhysX
核心定位纯碰撞检测轻量级全功能功能完整的工业级高性能商业级
包体影响几乎无增加约141KB约1.5MB (Wasm)约5MB+ (原生)
功能完整性仅触发检测基础刚体、碰撞、力完整刚体、碰撞、关节、车辆、布料等最完整,支持高级特性(如GPU加速)
性能表现极高(仅检测)中等,纯JS计算高(Wasm加速)极高(原生/GPU)
平台兼容全平台全平台(JS)全平台(Wasm)受限(主要支持原生平台与部分小游戏平台)
推荐场景仅需碰撞触发(如机关、区域)的轻量游戏2D/简单3D游戏,对包体敏感的小游戏需要复杂物理的中重度3D游戏(跨平台)追求极致性能的原生平台3D大作

2.2 选型决策树与实战考量

面对表格,如何选择?你可以遵循这个简单的决策流程:

  1. 是否需要真实的物理运动反馈?

    • -> 选择Builtin。比如,你只需要检测玩家是否进入一个奖励区域,或者两个触发器是否重叠,Builtin完全够用,且零开销。
    • -> 进入下一步。
  2. 你的目标发布平台是什么?

    • 微信小游戏、抖音小游戏等Web平台-> 优先考虑Bullet。PhysX在这些平台通常不被支持或包体过大。Bullet的Wasm版本在功能和性能上取得了很好的平衡。
    • iOS、Android、Windows、macOS原生应用-> 优先考虑PhysX。它能充分利用硬件性能,模拟更稳定、更复杂,尤其是在iOS上,PhysX通常是唯一的高性能选择。
  3. 你的项目复杂度和包体预算如何?

    • 超轻度,极度追求小包体-> 可以尝试Cannon.js。它是一个纯JavaScript实现的引擎,虽然功能相对基础,但对于简单的抛物、滚动、堆叠等需求完全足够,且集成简单。
    • 中重度,需要关节、滑轮、车辆、布料等复杂模拟-> 无脑选择BulletPhysX。根据平台决定选哪个。

我的实战心得:在早期项目中,我曾为了追求小包体在微信小游戏上使用了Cannon.js。但在实现一个“拆房子”玩法时,大量刚体堆叠后出现了明显的性能下降和偶尔的穿透现象。后来切换到Bullet,同样的场景稳定流畅。教训是:不要过早优化。在原型阶段,完全可以直接使用Bullet进行开发,它的功能最全面,能避免后期因物理功能不足而大规模重构。等到性能真的成为瓶颈时,再根据目标平台评估是否值得为了一点包体空间去迁移到Cannon.js或做深度裁剪。

3. 核心组件详解:RigidBody与Collider的默契配合

选定引擎后,我们需要在场景中创建物理对象。这离不开两个核心组件:RigidBody(刚体)Collider(碰撞体)。很多新手会混淆二者的职责,导致物理效果异常。

3.1 RigidBody(刚体):物理世界的“演员”

你可以把RigidBody理解为物体在物理世界中的“身份证”和“动力源”。它定义了物体的物理属性,并负责其运动。

  • 质量、阻尼与速度mass(质量)决定物体惯性,linearDamping(线性阻尼)和angularDamping(旋转阻尼)模拟空气阻力等效果,让运动自然停止。
  • 重力与睡眠useGravity控制是否受重力影响。allowSleep允许物体在静止时进入“睡眠”状态,停止物理计算以提升性能。
  • 类型RigidBodyType是关键属性。
    • Dynamic(动态):完全受物理引擎控制的物体,如掉落的箱子、发射的炮弹。这是最常用的类型。
    • Static(静态):质量无限大、永不移动的物体,如地面、墙壁。用于环境碰撞。
    • Kinematic(运动学):不受物理力直接影响,但可通过代码设置速度或位置来移动,并能推动其他动态刚体。常用于玩家角色、移动平台。

重要提示:在Cannon.js引擎下,必须为节点添加RigidBody组件,物理模拟才会生效。而在Bullet和PhysX中,即使没有RigidBody,仅有Collider的物体也会被视为静态碰撞体(Static)。

3.2 Collider(碰撞体):物理世界的“形状”

Collider定义了物体的“物理轮廓”。引擎根据这个形状进行碰撞检测。Cocos Creator提供了多种基础形状:

  • BoxCollider(盒碰撞体):方形物体,性能最好。
  • SphereCollider(球碰撞体):球形物体。
  • CapsuleCollider(胶囊碰撞体):常用于角色控制器,能避免卡住和抖动。
  • MeshCollider(网格碰撞体):使用模型的三角网格进行精确碰撞,性能开销大,仅用于需要极高精度的静态复杂物体(如岩石)。

组件配合的黄金法则

  1. 一个节点想被物理推动或影响其他物体,必须同时拥有RigidBody和Collider。
  2. Collider的isTrigger属性:勾选后,该碰撞体变为“触发器”。物体可以穿透它,但会触发碰撞事件(onTriggerEnter等),用于检测区域进入、拾取物品等,而不产生物理阻挡。
  3. Attached属性:在Bullet/PhysX中,Collider可以关联到一个RigidBody上。通常保持默认(关联到当前节点的RigidBody)即可。这允许一个刚体拥有多个不同形状的碰撞体,组合成复杂形状。

避坑指南:我曾遇到一个Bug:一个球体碰撞体设置正确,但就是无法掉落到一个盒形碰撞体上。排查半天发现,盒子的节点缩放是(2, 0.5, 2),但BoxCollider的size属性却还是默认的(1,1,1)记住:Collider的尺寸(Size/Radius)是本地尺寸,不受节点缩放影响!你需要手动将Collider的尺寸与模型的视觉尺寸匹配,或者确保节点的缩放为(1,1,1),通过修改模型资产本身的缩放来改变大小。

4. 案例实践一:构建一个简单的弹射游戏

理论说得再多,不如动手做一个。我们来实现一个经典案例:用鼠标拖拽弹弓上的小鸟,松开后将其射出,并撞击前方的积木堆。

4.1 场景搭建与物理环境配置

  1. 初始化项目:创建一个新的3D项目,在项目设置中启用物理系统,选择Bullet (ammo.js)
  2. 创建地面和围墙:创建一个Cube节点,命名为Ground,缩放至(20, 1, 20)。为其添加BoxCollider组件,并勾选isStatic(这会自动添加一个Static类型的RigidBody)。复制这个节点,调整缩放和位置,创建四周的围墙。
  3. 创建积木堆:创建多个Cube节点作为积木,缩放为(1, 1, 4)(4, 1, 1)。为每个积木添加RigidBody(类型Dynamic)和BoxCollider。将它们错落有致地堆叠起来。
  4. 创建弹弓和小鸟
    • 弹弓:可以用两个细长的Cube表示。
    • 小鸟:创建一个Sphere节点,命名为Bird。添加RigidBody(类型Dynamic,质量设为2)和SphereCollider(半径0.5)。在弹弓皮筋处创建一个空节点作为发射点LaunchPoint

4.2 实现拖拽与发射逻辑

Bird节点创建一个脚本BirdController.ts

import { _decorator, Component, RigidBody, Vec3, input, Input, EventTouch, Camera, geometry, PhysicsSystem } from 'cc'; const { ccclass, property } = _decorator; @ccclass('BirdController') export class BirdController extends Component { @property(Camera) mainCamera: Camera = null!; // 主摄像机,用于屏幕坐标转世界坐标 @property(Node) launchPoint: Node = null!; // 发射点节点 private _rigidBody: RigidBody = null!; private _isDragging: boolean = false; private _startPos: Vec3 = new Vec3(); // 小鸟初始位置(发射点) private _maxDragDistance: number = 3; // 最大拖拽距离 start() { this._rigidBody = this.getComponent(RigidBody)!; this._startPos = this.launchPoint.worldPosition.clone(); this.resetBird(); // 监听触摸事件 input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); } onTouchStart(event: EventTouch) { // 简单判断:点击位置是否在小鸟附近(实际项目应用射线检测) this._isDragging = true; // 拖拽开始时,让小鸟不受物理影响,便于拖拽 this._rigidBody.setType(RigidBodyType.KINEMATIC); } onTouchMove(event: EventTouch) { if (!this._isDragging) return; const touchPos = event.getUILocation(); // 将屏幕触摸点转换为一条从摄像机出发的射线 const ray = new geometry.Ray(); this.mainCamera.screenPointToRay(touchPos.x, touchPos.y, ray); // 假设我们在一个平面上拖拽(Y轴固定),计算射线与平面Y=_startPos.y的交点 const planeY = this._startPos.y; const t = (planeY - ray.o.y) / ray.d.y; const worldPos = new Vec3(ray.o.x + ray.d.x * t, planeY, ray.o.z + ray.d.z * t); // 计算相对于发射点的偏移向量,并限制最大距离 const offset = new Vec3(); Vec3.subtract(offset, worldPos, this._startPos); const distance = offset.length(); if (distance > this._maxDragDistance) { offset.normalize().multiplyScalar(this._maxDragDistance); } // 设置小鸟位置(发射点 + 受限偏移) const newPos = new Vec3(); Vec3.add(newPos, this._startPos, offset); this.node.setWorldPosition(newPos); } onTouchEnd() { if (!this._isDragging) return; this._isDragging = false; // 计算发射向量:从当前位置指向发射点,并乘以一个力系数 const forceVector = new Vec3(); Vec3.subtract(forceVector, this._startPos, this.node.worldPosition); forceVector.multiplyScalar(20); // 力系数,控制发射力度 // 将小鸟类型改回Dynamic,并施加冲量力 this._rigidBody.setType(RigidBodyType.DYNAMIC); this._rigidBody.wakeUp(); // 确保刚体被激活 this._rigidBody.applyImpulse(forceVector); } // 重置小鸟到发射点 resetBird() { this._rigidBody.setType(RigidBodyType.KINEMATIC); this.node.setWorldPosition(this._startPos); this._rigidBody.setLinearVelocity(Vec3.ZERO); this._rigidBody.setAngularVelocity(Vec3.ZERO); } }

代码关键点解析

  • 坐标转换:拖拽的核心是将2D屏幕坐标转换为3D世界坐标。我们通过摄像机射线与一个固定高度平面的交点来模拟。
  • 刚体类型切换:拖拽时设为KINEMATIC,可以无视物理由代码直接设置位置;发射时切回DYNAMIC,使其受物理力影响。
  • 力的应用:使用applyImpulse施加冲量。冲量会立即改变物体的速度,非常适合表现瞬间的弹射力。你也可以用applyForce来施加持续的力。

4.3 添加碰撞反馈与游戏逻辑

为了让游戏更完整,我们需要检测小鸟是否击中目标,并计分。

  1. 为目标积木添加标签:选中所有作为目标的积木,在属性检查器中添加一个用户自定义标签,如Target
  2. 在小鸟脚本中监听碰撞:在BirdController.ts中增加碰撞回调。
import { Collider, ITriggerEvent } from 'cc'; // ... 其他导入 export class BirdController extends Component { // ... 已有属性 private _hitTargets: Set<Collider> = new Set(); // 记录已击中的目标,避免重复计分 start() { // ... 原有代码 // 获取碰撞体组件并监听触发事件 const collider = this.getComponent(Collider); if (collider) { collider.on('onTriggerEnter', this.onTriggerEnter, this); } } onTriggerEnter(event: ITriggerEvent) { const otherCollider = event.otherCollider; // 判断碰撞的另一方是否是目标积木 if (otherCollider.node.group === 'Target' && !this._hitTargets.has(otherCollider)) { console.log('击中目标!'); this._hitTargets.add(otherCollider); // 这里可以触发得分效果、播放音效等 // 例如:otherCollider.node.getComponent(BlockController)?.onHit(); } } // 在resetBird中清空记录 resetBird() { // ... 原有代码 this._hitTargets.clear(); } }

5. 案例实践二:实现角色控制器与复杂交互

第二个案例,我们实现一个第三人称角色控制器,并让角色与一个需要推动的箱子和一个旋转的门进行交互。

5.1 角色移动:胶囊碰撞体与力控制

  1. 创建角色:导入或创建一个角色模型。为其添加一个CapsuleCollider(高度约2,半径约0.3)和一个RigidBody。将RigidBody的mass设为10,linearDamping设为0.5以减少滑动感。
  2. 编写角色移动脚本:创建PlayerController.ts
import { _decorator, Component, RigidBody, Vec3, input, Input, KeyCode } from 'cc'; const { ccclass, property } = _decorator; @ccclass('PlayerController') export class PlayerController extends Component { @property moveSpeed: number = 5; @property jumpForce: number = 8; private _rigidBody: RigidBody = null!; private _velocity: Vec3 = new Vec3(); private _isOnGround: boolean = false; start() { this._rigidBody = this.getComponent(RigidBody)!; input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); } update(deltaTime: number) { const horizontal = input.isKeyPressed(KeyCode.KEY_A) ? -1 : input.isKeyPressed(KeyCode.KEY_D) ? 1 : 0; const vertical = input.isKeyPressed(KeyCode.KEY_W) ? 1 : input.isKeyPressed(KeyCode.KEY_S) ? -1 : 0; // 计算基于摄像机朝向的移动方向(此处简化,假设沿XZ平面移动) const moveDir = new Vec3(horizontal, 0, vertical); moveDir.normalize(); // 计算目标速度 const targetVelocity = new Vec3(); Vec3.multiplyScalar(targetVelocity, moveDir, this.moveSpeed); // 获取当前水平速度 const currentVel = this._rigidBody.getLinearVelocity(); const currentHorizontalVel = new Vec3(currentVel.x, 0, currentVel.z); // 计算速度差,并施加力来改变速度(模拟角色驱动) const force = new Vec3(); Vec3.subtract(force, targetVelocity, currentHorizontalVel); force.multiplyScalar(this._rigidBody.mass * 10); // 乘以一个增益系数 force.y = 0; // 不改变垂直方向的速度 this._rigidBody.applyForce(force); } onKeyDown(event: any) { if (event.keyCode === KeyCode.SPACE && this._isOnGround) { const jumpImpulse = new Vec3(0, this.jumpForce * this._rigidBody.mass, 0); this._rigidBody.applyImpulse(jumpImpulse); this._isOnGround = false; } } // 通过碰撞检测判断是否在地面 onCollisionEnter(event: any) { // 简单判断:如果碰撞点法线朝上,则认为在地面 const worldNormal = event.worldNormal; if (worldNormal.y > 0.7) { // 阈值判断 this._isOnGround = true; } } }

移动方案选择:这里使用了applyForce来驱动角色。另一种更常见的方案是直接设置刚体的速度setLinearVelocity,这样控制更直接,但可能显得“滑”。applyForce模拟了角色发力推动自己的过程,感觉更物理化。选择哪种取决于你想要的手感。

5.2 推动箱子:质量比与摩擦力

创建一个箱子,添加RigidBody(质量设为50)和BoxCollider。角色(质量10)去推箱子(质量50),由于质量差异,推动会显得费力,这很物理。你可以调整箱子的质量或角色施加的力来改变手感。

关键点:确保箱子和地面的PhysicsMaterial(物理材质)设置了合适的friction(摩擦力)。摩擦力太小,箱子一推就滑走;太大,可能推不动。通常需要反复调试找到一个感觉自然的值。

5.3 创建旋转门:铰链关节(HingeConstraint)的应用

物理关节(Constraint)用于连接两个刚体,限制它们的相对运动。铰链关节就像门上的合页。

  1. 创建门和门框:两个Cube,门框设为Static刚体,门设为Dynamic刚体。
  2. 添加铰链关节:选中门节点,添加组件HingeConstraint
  3. 配置关节
    • connectedBody:拖入门框节点对应的RigidBody。如果为空,则相当于连接到世界。
    • axis:旋转轴,比如(0, 1, 0)表示绕Y轴旋转。
    • pivotconnectedPivot:分别是关节在本刚体和连接刚体上的锚点位置。对于门,通常设置在门的一侧边缘。
    • limit:启用角度限制,lowerupper设为-9090,这样门就只能旋转90度。
    • motor:可以启用马达,并设置目标速度和最大力,让门自动开关。

现在,你的角色就可以走过去推动这扇门了。关节的配置需要一些空间想象力,多调试几次就能掌握。

6. 性能优化与常见问题排查

物理计算是性能消耗大户,不当的使用会导致帧率骤降。以下是一些核心优化策略和常见问题解决方法。

6.1 性能优化黄金法则

  1. 精简碰撞体形状:永远优先使用BoxSphereCapsule等基础碰撞体。一个MeshCollider的性能开销可能是BoxCollider的数十倍。对于复杂物体,可以用多个简单碰撞体组合(Compound Shapes)来近似。
  2. 善用静态刚体(Static):对于永远不会移动的地形、建筑,务必将其RigidBody类型设为Static。物理引擎会对静态物体做大量优化。
  3. 启用睡眠(Allow Sleep):对于动态刚体,确保勾选allowSleep。静止的物体会进入睡眠,停止物理计算。
  4. 控制刚体数量:屏幕上同时活跃的刚体数量最好控制在100个以下。对于大量小物体(如碎片),可以考虑使用粒子系统或在一段时间后将其设为静态或销毁。
  5. 调整物理更新频率:在PhysicsSystem实例中,可以调整fixedTimeStep(固定时间步长)。默认是1/60秒。提高它(如改为1/30)会降低物理更新频率,提升性能,但会降低模拟精度。在移动端可以酌情调整。
  6. 使用碰撞分组(Group)和掩码(Mask):这是最重要的优化手段之一。通过PhysicsSystem.instance.setCollisionMatrix,你可以精确控制哪些物体之间需要进行碰撞检测。例如,子弹不需要和子弹碰撞,大量装饰物之间也不需要互相碰撞。合理设置可以极大减少检测对数量。

6.2 常见问题排查速查表

问题现象可能原因解决方案
物体抖动或穿透1. 物理步长(fixedTimeStep)过大。
2. 物体移动速度过快(每帧位移超过自身尺寸)。
3. 质量差异极端(如质量1的物体撞质量10000的物体)。
1. 减小fixedTimeStep(如保持1/60)。
2. 使用RigidBody.setLinearVelocity而非直接setPosition移动高速物体。
3. 避免极端质量比,或使用Kinematic类型。
刚体“飘”或“滑”线性阻尼(linearDamping)或角阻尼(angularDamping)过小。适当增加阻尼值,模拟空气阻力。
碰撞检测不触发1. 至少一方未添加Collider组件。
2. 双方碰撞体都是Trigger
3. 节点或碰撞体被禁用。
4. 碰撞分组被过滤。
1. 检查组件。
2.Trigger只触发事件,不产生物理反应,确保至少一方不是Trigger。
3. 检查节点状态。
4. 检查碰撞矩阵设置。
物理效果在不同引擎下不一致不同引擎算法和参数体系有差异。参考官方文档的“物理引擎的效果差异”部分。重点检查阻尼(damping)和因子(factor)参数的转换。在项目早期确定引擎并坚持使用。
包体过大使用了PhysX或Bullet,但实际功能需求简单。评估是否可降级为Cannon.js或Builtin。对于Bullet,可以尝试引擎定制的裁剪功能(如果提供)。

6.3 调试技巧:可视化物理世界

在开发过程中,打开物理调试绘制功能至关重要。在脚本中(例如在start函数里)添加:

import { PhysicsSystem } from 'cc'; PhysicsSystem.instance.debugDrawFlags = // 0x1 绘制碰撞体线框 // 0x2 绘制碰撞体形状 // 0x4 绘制接触点 // 0x8 绘制关节 // 0x10 绘制AABB包围盒 // 0x20 绘制性能统计 0x1 | 0x2; // 例如,同时绘制线框和形状

这样在游戏运行时,你就能在场景中清晰地看到所有碰撞体的轮廓,对于确认碰撞体形状、位置和大小是否正确有极大帮助。

物理引擎的学习是一个“踩坑”和“填坑”的过程。从简单的碰撞开始,逐步尝试力、关节、射线检测、物理材质等更高级的特性,并时刻将性能优化放在心上。记住,最逼真的物理不一定是“最好”的物理,适合游戏玩法、运行流畅的物理才是好物理。多动手实验,观察不同参数下的行为变化,你就能逐渐驾驭Cocos Creator中的物理之力,为你创造的游戏世界注入真实的灵魂。