Babylon.js一帧之旅(十):实战——事件挂载点选择指南与性能优化

「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 帧时间)。

性能纪律清单:

  1. 帧级回调控制在微秒级;超过 100μs 的逻辑考虑移到 Worker 或降频(每 N 帧执行一次);
  2. 网格级/draw call 级回调只做查表和赋值;
  3. 回调内禁止 new 对象(预分配复用);
  4. 多个帧级回调可以合并为一个总回调内部分发,减少遍历次数(微优化,观察者数量大时才值得);
  5. 调试用的日志观察者,上线前务必移除------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);
    });
}

五个挂载点各司其职:①帧级读输入 → ②物理前给力 → ③物理后跟随 → ④剔除后检测 → ⑤帧尾结算。这就是本系列全部知识的肌肉记忆版。

五、常见误区(实战篇特供)

误区一:一个功能挂多个事件"保险"。

比如在 onBeforeRenderonAfterRender 里都更新 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。