「Babylon.js 一帧之旅」系列第十篇。前九篇把一帧的旅程完整走了一遍。本篇做两件事:把所有知识压缩成一张"我要做 X,该挂哪个事件"的决策表 ,再补上事件系统的性能纪律------Observable 用错位置,本身就是性能杀手。
一、挂载点决策表(建议收藏)
1.1 按"我要做什么"查表
| 需求 | 挂载点 | 理由 |
|---|---|---|
| 每帧执行的游戏逻辑(输入响应、状态机) | scene.onBeforeRenderObservable |
每帧一次,动画/物理/相机已结算,数据最新 |
| 在动画推进前修改动画参数/权重 | scene.onBeforeAnimationsObservable |
改完本帧动画立即生效 |
| 动画结算后做自定义约束(如 IK 修正) | scene.onAfterAnimationsObservable |
骨骼已更新,修正不会被覆盖 |
| 施加力/冲量,驱动物理 | scene.onBeforePhysicsObservable |
本帧模拟"吃到"输入 |
| 读取物理结果(跟随刚体、碰撞响应) | scene.onAfterPhysicsObservable |
模拟刚结算,位姿最新 |
| 相机参数变化时做事(保存视角) | camera.onViewMatrixChangedObservable |
变化才触发,不浪费帧 |
| 针对每个相机/视口分别处理 | scene.onBeforeCameraRenderObservable |
相机级粒度,VR 下按眼触发 |
| 拿到本帧可见网格列表 | scene.onAfterActiveMeshesEvaluationObservable |
剔除刚完成,列表最新 |
| 每帧统计(FPS、draw call、可见数) | scene.onAfterRenderObservable |
帧尾数据最全 |
| 截图、推送视频流 | scene.onAfterRenderObservable |
帧缓冲已完整 |
| 注入自定义 WebGL 绘制 | onBefore/AfterDrawPhaseObservable 或渲染组事件 |
标准注入点 |
| 动态改后处理参数(呼吸泛光等) | postProcess.onApplyObservable |
每次滤镜执行时可拿到 effect |
| 给 draw call 设置每网格 shader 参数 | node.onBeforeDrawObservable |
能拿到当前 effect |
| 加载完成后初始化 | scene.onReadyObservable.addOnce / whenReadyAsync |
含着色器编译的真正就绪 |
| 组件/关卡销毁清理 | 各对象的 onDisposeObservable + 手动 remove |
销毁过程中唯一通知点 |
1.2 按"触发粒度"反查(防踩坑)
| 事件 | 粒度 | 每帧触发次数(参考) |
|---|---|---|
engine.onBeginFrame/EndFrameObservable |
帧级 | 1 |
scene.onBeforeRender/onAfterRenderObservable |
帧级 | 1 |
scene.onBefore/AfterAnimationsObservable |
帧级 | 1(锁步模式按子步多次) |
onBefore/AfterRenderTargetsRenderObservable |
批次级 | ≥2(场景级+相机级) |
onBefore/AfterCameraRenderObservable |
相机级 | 相机数(rig 模式更多) |
onBefore/AfterActiveMeshesEvaluationObservable |
相机级(含 RTT 的迷你评估) | ≥ 相机数 |
onBefore/AfterRenderingGroupObservable |
组级 | 相机数 × 非空渲染组数 |
mesh.onBefore/AfterRenderObservable |
绘制级 | 网格数 × pass 数(阴影/反射各算一遍) |
node.onBeforeDrawObservable |
draw call 级 | 同上,最热点 |
一行口诀:选事件先看粒度,帧级需求用帧级事件,绝不"下沉"到网格级凑活。
二、三条铁律(全系列的浓缩)
铁律一:读结果,挂在结算之后;给输入,挂在结算之前。
读物理位置 → onAfterPhysicsObservable;施加力 → onBeforePhysicsObservable。读可见列表 → onAfterActiveMeshesEvaluationObservable。这条原则能回答 80% 的"该挂哪"。
铁律二:回调里只做赋值和判断,不做计算。
热点回调(网格级、draw call 级)每秒执行数万次。需要计算的,在帧级事件里算好缓存,热点回调只读取:
typescript
// ❌ 错误:在绘制级回调里做计算
hero.onBeforeRenderObservable.add(() => {
const dist = BABYLON.Vector3.Distance(hero.position, camera.position); // 每次绘制都算
shader.setFloat("fade", 1 / dist);
});
// ✅ 正确:帧级算一次,绘制级只赋值
let cachedFade = 0;
scene.onBeforeRenderObservable.add(() => {
cachedFade = 1 / BABYLON.Vector3.Distance(hero.position, camera.position);
});
hero.onBeforeRenderObservable.add(() => {
shader.setFloat("fade", cachedFade);
});
铁律三:注册即负债,用完即还。
每个 add 返回的 observer 都是一笔"内存债":要么 addOnce 自动偿还,要么保存引用手动 remove,要么挂在宿主对象上随宿主 dispose 清偿。绝不允许"匿名注册、永不移除"------这是 3D 应用内存泄漏的第一大来源。
三、Observable 的性能开销真相
空观察者零成本 :框架内部大量事件触发前都有 hasObservers() 检查,没人监听时直接跳过整块逻辑。所以"监听事件本身"几乎免费,贵的是回调里的代码。
通知成本 = 观察者数 × 单次回调耗时 。notifyObservers 是同步遍历数组调用回调(下一篇详解源码),没有任何隐藏开销,但也没有任何保护------一个 1ms 的回调挂在 60FPS 的帧事件上,就吃掉了帧预算的 6%(16.6ms 帧时间)。
性能纪律清单:
- 帧级回调控制在微秒级;超过 100μs 的逻辑考虑移到 Worker 或降频(每 N 帧执行一次);
- 网格级/draw call 级回调只做查表和赋值;
- 回调内禁止
new对象(预分配复用); - 多个帧级回调可以合并为一个总回调内部分发,减少遍历次数(微优化,观察者数量大时才值得);
- 调试用的日志观察者,上线前务必移除------
console.log每帧执行足以让帧率腰斩。
四、综合案例:一个迷你游戏循环
把全系列的事件串成一个完整但极简的游戏循环------玩家控制小球吃金币:
typescript
function setupGameLoop(scene: BABYLON.Scene, player: BABYLON.Mesh, coins: BABYLON.Mesh[]) {
let score = 0;
const moveDir = BABYLON.Vector3.Zero();
const collectedThisFrame: BABYLON.Mesh[] = [];
// ① 帧级:读取输入,计算本帧移动方向(数据最新处)
scene.onBeforeRenderObservable.add(() => {
moveDir.set(0, 0, 0);
// ...读取键盘,填充 moveDir...
});
// ② 物理前:把输入转化为力
scene.onBeforePhysicsObservable.add(() => {
player.physicsBody?.applyForce(moveDir.scale(10), player.getAbsolutePosition());
});
// ③ 物理后:相机跟随(读结果挂结算后)
scene.onAfterPhysicsObservable.add(() => {
camera.target = BABYLON.Vector3.Lerp(camera.target, player.position, 0.08);
});
// ④ 剔除后:只检测"可见金币"的碰撞(省性能)
scene.onAfterActiveMeshesEvaluationObservable.add(() => {
collectedThisFrame.length = 0;
for (const coin of coins) {
if (!coin.isEnabled()) continue;
if (BABYLON.Vector3.Distance(coin.position, player.position) < 1) {
collectedThisFrame.push(coin);
}
}
});
// ⑤ 帧尾:统一结算(帧内修改下一帧生效,避免边遍历边删)
scene.onAfterRenderObservable.add(() => {
for (const coin of collectedThisFrame) {
coin.setEnabled(false); // 下一帧起被剔除链跳过
score++;
}
hud.text = `得分:${score}`;
});
// ⑥ 离场清理(铁律三)
scene.onDisposeObservable.addOnce(() => {
// 业务侧清理:存档、断开网络等
saveScore(score);
});
}
五个挂载点各司其职:①帧级读输入 → ②物理前给力 → ③物理后跟随 → ④剔除后检测 → ⑤帧尾结算。这就是本系列全部知识的肌肉记忆版。
五、常见误区(实战篇特供)
误区一:一个功能挂多个事件"保险"。
比如在 onBeforeRender 和 onAfterRender 里都更新 HUD------双倍开销。每个需求只挂一个最贴切的点。
误区二:用 onBeforeRenderObservable 当"万能每帧函数",里面 if-else 塞满所有逻辑。
功能一多就变成几千行的巨石回调。按职责拆成多个观察者(Observable 本来就该这么用),或按子系统组织。
误区三:在回调里直接操作 DOM 更新 HUD,每帧 60 次。
DOM 写入极慢。HUD 数值变化才更新(对比缓存值),或降频到每 5~10 帧一次。
误区四:多人协作时各自 registerBeforeRender,没人 remove。
团队项目约定:所有观察者注册处必须配对的移除代码出现在同一模块的 cleanup 函数里,code review 专项检查。
六、小结
- 决策表的核心逻辑只有两条:粒度匹配 (帧级需求用帧级事件)和时序匹配(读结果挂结算后);
- 三条铁律:结果在结算后读、热点回调只赋值、注册即负债;
- Observable 本身近乎零成本,性能问题永远出在回调内容和挂载粒度上;
- 综合案例的五个挂载点(输入 → 施力 → 跟随 → 检测 → 结算)覆盖了大多数游戏/交互应用的需求模式。
下篇预告:《Babylon.js 一帧之旅(十一):原理篇------Observable 机制源码解析》。系列收官之作:不到 200 行的 Observable 类如何支撑起整个框架的事件体系?为什么"遍历时删除观察者"要用 setTimeout 延迟?EventState 为什么全框架复用同一个对象?以及------手写一个迷你 Observable。