18 控制物理对象¶
动机¶
上一章你生成了一个物理球,它落下来、弹起、停住。但这只是被动的,球在重力和碰撞作用下自然运动。
现在我们要主动控制它。想象这样的场景:按一个键,球被"打"向右边;按住另一个键,有一股风持续吹着球跑。这就是 Impulse(冲量)和 Force(力)的区别。
物理学里: - 冲量像用锤子敲一下,瞬间给一个速度变化,之后不再管了 - 力像风扇一直吹,每一帧都在施加影响,停止施加后效果才消失
本章用键盘操作这两种物理控制,让你直观感受它们的区别。
四个物理操作 API¶
| API | 物理类比 | 效果 | 是否需要配套清理 |
|---|---|---|---|
BML::Physics::WakeUp(target) |
推一下正在睡觉的人 | 让休眠对象重新参与仿真 | 不需要 |
BML::Physics::Impulse(...) |
用锤子敲一下 | 瞬间改变速度 | 不需要 |
BML::Physics::SetForce(...) |
开风扇 | 每帧持续施加力 | 必须 ClearForce |
BML::Physics::ClearForce(target) |
关风扇 | 停止持续力 | 不需要 |
WakeUp:唤醒休眠¶
物理引擎有一个优化策略:如果一个对象已经静止了一段时间(速度为零且没有力作用),引擎会把它标记为"休眠",不再计算它的物理。这节省了大量 CPU 时间。
但是,如果你想推一个已经休眠的球,必须先唤醒它。否则 Impulse 和 SetForce 可能没有效果(引擎认为它在睡觉,不参与本帧计算)。
返回 true 表示成功唤醒。false 表示对象可能未物理化或已无效。
唤醒后如果没有外力,球不会动,它只是从"不计算"变成"要计算"。但因为没有力,计算结果就是保持静止。
Impulse:瞬间推力¶
bool ok = BML::Physics::Impulse(
spawnedBall, // 目标对象
VxVector(0.0f, 0.0f, 0.0f), null, // 作用位置, 位置参考系
VxVector(3.0f, 0.0f, 0.0f), null, // 方向, 方向参考系
1.5f); // 冲量大小
六个参数的含义:
| 参数 | 含义 | 说明 |
|---|---|---|
target |
推哪个对象 | 必须已物理化 |
position |
力作用在对象的哪个点 | (0,0,0) 表示对象中心 |
positionReference |
位置是相对于谁的坐标系 | null 表示世界坐标系 |
direction |
推力方向 | (3,0,0) 表示正 X 方向 |
directionReference |
方向是相对于谁的坐标系 | null 表示世界坐标系 |
impulse |
冲量大小 | 越大推力越猛 |
关于 null 参考系:当 positionReference 或 directionReference 为 null 时,坐标按世界坐标系解释。世界坐标系是固定的,X 轴始终指向同一方向,不管对象怎么旋转。如果传入一个对象作为参考系,坐标就是相对于那个对象的局部坐标。
作用点的影响:如果作用点不在对象重心,推力会同时产生旋转。比如推球的顶部,球会向下滚动同时平移。推重心则只平移不转。本教程用 (0,0,0) 推重心。
冲量的直觉:1.5f 配合方向向量 (3,0,0),最终的推力效果是 1.5 * 3 = 4.5 的 X 方向冲量。球会瞬间获得一个向右的速度,然后在摩擦和重力作用下逐渐停下来。
SetForce:持续力¶
bool ok = BML::Physics::SetForce(
spawnedBall,
VxVector(0.0f, 0.0f, 0.0f), null, // 作用位置, 位置参考系
VxVector(1.0f, 0.0f, 0.0f), null, // 方向, 方向参考系
0.6f); // 力大小
参数结构和 Impulse 完全一样(6 个参数),区别在于语义:
- Impulse 执行一次就完事,不需要后续操作
- SetForce 设置后每帧都生效,直到你调用 ClearForce
这意味着如果你 SetForce 后忘记 ClearForce,球会一直被推,越来越快,最终飞出场景。
ClearForce:停止持续力¶
调用后,之前 SetForce 施加的力立刻消失。球不会瞬间停下,它保留已有的速度(惯性),但不再加速。之后会在摩擦和阻尼作用下逐渐减速停住。
TickForce 模式:帧计数自动清理¶
持续力通常有时限。实现方式是用帧计数器:SetForce 成功后设 forceActive = true 和 forceFramesLeft = 90(约 1.5 秒),在 OnProcess 里每帧递减,到 0 时调用 ClearForce。完整逻辑见下方脚本中的 TickForce 方法。
这个模式的优点:不会忘记清理,可以精确控制持续时间。
键位设计¶
J=生成球,K=清理,U=唤醒,I=向右推(Impulse),H=向左推(Impulse),F=持续力(90帧),C=清除力
完整脚本¶
新建 ModLoader/Mods/ControlBall.mod.as:
[bml.mod id="control.ball" name="Control Ball" version="1.0.0" author="Tutorial" bml="0.3.13" description="Control a physicalized ball with keyboard"]
class ControlBall {
private CK3dEntity@ spawnedBall = null;
private bool forceActive = false;
private int forceFramesLeft = 0;
void OnLoad(const BML::ModContext &in ctx) {
ctx.LogInfo("ControlBall loaded. J=spawn K=cleanup U=wake I/H=impulse F=force C=clear");
}
void OnUnload(const BML::ModContext &in ctx) {
CleanupBall(ctx);
}
void OnGameEvent(const BML::ModContext &in ctx, BML::GameEvent event) {
if (event == BML::GAME_EVENT_PRE_EXIT_LEVEL ||
event == BML::GAME_EVENT_PRE_LOAD_LEVEL ||
event == BML::GAME_EVENT_EXIT_GAME) {
CleanupBall(ctx);
}
}
void OnProcess(const BML::ModContext &in ctx) {
HandleInput(ctx);
TickForce(ctx);
}
private void HandleInput(const BML::ModContext &in ctx) {
BML::InputHook@ input = ctx.BorrowInputManager();
if (input is null) return;
if (input.IsKeyPressed(CKKEY_J)) {
SpawnBall(ctx);
}
if (input.IsKeyPressed(CKKEY_K)) {
CleanupBall(ctx);
BML::UI::AddMessage("Ball cleaned.");
}
if (input.IsKeyPressed(CKKEY_U)) {
WakeBall(ctx);
}
if (input.IsKeyPressed(CKKEY_I)) {
ImpulseBall(ctx, VxVector(3.0f, 0, 0));
}
if (input.IsKeyPressed(CKKEY_H)) {
ImpulseBall(ctx, VxVector(-3.0f, 0, 0));
}
if (input.IsKeyPressed(CKKEY_F)) {
SetBallForce(ctx);
}
if (input.IsKeyPressed(CKKEY_C)) {
ClearBallForce(ctx, "key");
}
}
private void TickForce(const BML::ModContext &in ctx) {
if (!forceActive) return;
forceFramesLeft--;
if (forceFramesLeft <= 0) {
ClearBallForce(ctx, "timer");
}
}
// ---- 物理操作 ----
private void WakeBall(const BML::ModContext &in ctx) {
if (spawnedBall is null) {
BML::UI::AddMessage("No ball. Press J first.");
return;
}
bool ok = BML::Physics::WakeUp(spawnedBall);
ctx.LogInfo("WakeUp ok=" + BoolText(ok));
}
private void ImpulseBall(const BML::ModContext &in ctx, VxVector direction) {
if (spawnedBall is null) {
BML::UI::AddMessage("No ball. Press J first.");
return;
}
BML::Physics::WakeUp(spawnedBall);
bool ok = BML::Physics::Impulse(
spawnedBall,
VxVector(0.0f, 0.0f, 0.0f), null,
direction, null,
1.5f);
ctx.LogInfo("Impulse direction=(" + direction.x + "," + direction.y + "," + direction.z + ") ok=" + BoolText(ok));
BML::UI::AddMessage("Impulse sent.");
}
private void SetBallForce(const BML::ModContext &in ctx) {
if (spawnedBall is null) {
BML::UI::AddMessage("No ball. Press J first.");
return;
}
ClearBallForce(ctx, "replace");
bool ok = BML::Physics::SetForce(
spawnedBall,
VxVector(0.0f, 0.0f, 0.0f), null,
VxVector(1.0f, 0.0f, 0.0f), null,
0.6f);
if (ok) {
forceActive = true;
forceFramesLeft = 90;
}
ctx.LogInfo("SetForce ok=" + BoolText(ok) + " frames=90");
BML::UI::AddMessage("Force applied for ~90 frames.");
}
private void ClearBallForce(const BML::ModContext &in ctx, const string &in reason) {
if (!forceActive) return;
if (spawnedBall !is null && BML::CK::IsValid(spawnedBall)) {
BML::Physics::ClearForce(spawnedBall);
}
forceActive = false;
forceFramesLeft = 0;
ctx.LogInfo("ClearForce reason=" + reason);
}
// ---- 生成和清理(与上一章相同,区别是 CleanupBall 先调 ClearBallForce)----
private void SpawnBall(const BML::ModContext &in ctx) {
if (spawnedBall !is null) {
CleanupBall(ctx);
}
string relativePath = "3D Entities\\PH\\P_Ball_Wood.nmo";
string resourcePath = BML::Path::Combine(ctx.GetDirectoryUtf8(BML::DIR_GAME), relativePath);
if (!BML::Path::IsFile(resourcePath)) { ctx.LogWarn("Resource missing"); return; }
BML::ObjectLoadOptions options;
options.File = resourcePath; options.Rename = true;
options.MasterName = "Tutorial_ControlBall"; options.AddToScene = true;
options.ReuseMeshes = true; options.ReuseMaterials = true; options.Dynamic = true;
BML::ObjectLoadResult@ result = BML::CK::LoadObject(options);
if (result is null || !result.Success || result.Count <= 0) { ctx.LogWarn("Load failed"); return; }
@spawnedBall = FindFirstEntity(result);
if (spawnedBall is null) return;
CK3dEntity@ playerBall = BorrowActiveBall(ctx);
if (playerBall !is null && BML::CK::IsValid(playerBall)) {
VxVector pos = BML::CK::GetPosition(playerBall);
pos.y += 5.0f; BML::CK::SetPosition(spawnedBall, pos);
} else { BML::CK::SetPosition(spawnedBall, VxVector(0.0f, 10.0f, 0.0f)); }
BML::PhysicalizeDefinition physics;
physics.Fixed = false; physics.Friction = 0.6f; physics.Elasticity = 0.2f;
physics.Mass = 2.0f; physics.EnableCollision = true;
physics.LinearDamp = 0.6f; physics.RotDamp = 0.1f;
physics.CollisionSurface = "P_Ball_Wood_Mesh";
BML::Physics::PhysicalizeBall(spawnedBall, physics, VxVector(0.0f, 0.0f, 0.0f), 2.0f);
BML::CK::Show(spawnedBall, CKSHOW, true);
BML::UI::AddMessage("Ball spawned.");
}
private void CleanupBall(const BML::ModContext &in ctx) {
ClearBallForce(ctx, "cleanup");
if (spawnedBall is null) return;
if (BML::CK::IsValid(spawnedBall)) {
BML::Physics::Unphysicalize(spawnedBall);
BML::CK::Show(spawnedBall, CKHIDE, true);
}
@spawnedBall = null;
}
private CK3dEntity@ BorrowActiveBall(const BML::ModContext &in ctx) {
CKDataArray@ currentLevel = ctx.BorrowDataArrayByName("CurrentLevel");
if (currentLevel is null) return null;
int col = BML::CK::FindColumn(currentLevel, "ActiveBall");
if (col < 0) return null;
CKObject@ object = currentLevel.GetElementObject(0, col);
return cast<CK3dEntity>(object);
}
private CK3dEntity@ FindFirstEntity(BML::ObjectLoadResult@ result) {
for (int i = 0; i < result.Count; i++) {
CKObject@ obj = result.BorrowObject(i);
CK3dEntity@ e = cast<CK3dEntity>(obj);
if (e !is null) return e;
}
return null;
}
private string BoolText(bool v) { return v ? "true" : "false"; }
}
测试步骤¶
不要一上来就乱按。逐个验证:
- 生成:按 J。球出现在上方,落下来,静止
- 唤醒:等球静止后按 U。日志
WakeUp ok=true,球不动(唤醒不产生运动) - Impulse:按 I。球瞬间向右弹出,像弹珠台弹簧弹走,然后靠摩擦减速停住。按 H 向左
- SetForce:按 F。球逐渐加速向右(不是瞬间弹走),约 1.5 秒后日志
ClearForce reason=timer,球不再加速但靠惯性继续滑行 - 手动清除:按 F 后立刻按 C。球还没加速多少就停止。日志
ClearForce reason=key - 清理:按 K。球消失
Impulse 和 SetForce 对比¶
| Impulse | SetForce | |
|---|---|---|
| 类比 | 锤子敲 | 风扇吹 |
| 作用时间 | 一帧 | 每帧,直到清除 |
| 速度变化 | 立刻跳变 | 逐帧累加 |
| 清理 | 不需要 | 必须 ClearForce |
| 适用场景 | 弹射、爆炸、跳跃 | 推进器、风场、传送带 |
"爆炸推开附近物体"用 Impulse。"风区让球偏移"用 SetForce。
防御性设计要点¶
脚本中有三个防御性写法值得注意:
- Impulse 前先 WakeUp:休眠对象可能忽略冲量,先唤醒确保物理引擎处理它
- SetForce 前先 ClearForce:避免新旧力叠加导致意外行为
- CleanupBall 前先 ClearForce:保持
forceActive状态标志和实际物理状态一致
失败诊断¶
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| Impulse 返回 true 但球不动 | 球可能卡在地形里面 | 按 K 清理后在空旷平面上按 J 重新生成 |
| Impulse 返回 true 但方向不对 | X/Y/Z 轴和你预想的不同 | Ballance 的 Y 轴是上方向,X 和 Z 是水平面。尝试改方向向量 |
| WakeUp 返回 false | 对象未物理化或已无效 | 先按 K 再按 J 重新生成 |
| SetForce 后球一直加速不停 | TickForce 没在 OnProcess 里调用 | 确认 OnProcess 里有 TickForce(ctx) |
| 按 C 但球还在动 | ClearForce 只停止加力,不停止已有速度 | 这是正确行为,球在惯性作用下继续滑行直到摩擦让它停下 |
| 关卡切换后崩溃 | 清理逻辑有遗漏 | 确认 OnGameEvent 处理了三种退出事件 |
| 按 F 后日志显示 ok=false | 对象可能已在物理化过程中出问题 | 检查生成时 physical=true |
本章要点¶
- 物理控制只能作用于已物理化的对象,对未物理化的对象调用这些 API 无效
- WakeUp 让休眠对象重新参与仿真,是其他操作的前提
- Impulse 是一次性的瞬间推力,适合"弹射"类效果
- SetForce 是持续力,必须配套 ClearForce,否则球永远加速
- TickForce 模式(帧计数器 + 自动清除)是管理持续力生命周期的常用方法
- 清理对象前先清力,先取消物理化再隐藏再释放句柄
- 先在自己创建的对象上练习,确认理解后再尝试影响游戏中的活动球
完成状态¶
按键能控制自己创建的球。能观察到 Impulse 的瞬间推动效果和 SetForce 的持续推动效果。能区分两者的行为差异。理解了帧计数自动清除的模式。
下一步:19 修改前检查清单