「Babylon.js 一帧之旅」系列第四篇。上一篇讲了帧管线的第一站"数据结算"(动画与物理)。本篇进入第二站:相机更新。相机在管线中扮演着双重角色------它既在阶段②被"更新"(处理输入、计算矩阵),又是阶段⑥整个绘制流程的"组织单位"(逐相机渲染)。理解这两个角色的分界,是理解多相机、VR 渲染的前提。
引言:相机为什么被处理两次
很多开发者注意到一个现象:和相机相关的事件似乎"分散"在帧管线的两个位置------有的在 onBeforeRenderObservable 之前,有的在它之后。这不是设计混乱,而是因为相机有两副面孔:
阶段②(更新):activeCamera.update()
→ 处理用户输入,刷新相机自身状态 ← 每帧一次,与绘制无关
阶段⑥(渲染):逐相机执行 _renderForCamera()
→ 以相机为视角组织整个绘制流程 ← 每个相机一次
更新是"准备数据",渲染是"使用数据"。单相机场景里这两步紧贴发生,容易混为一谈;多相机场景里它们会明显分离------所有相机先统一更新,再轮流渲染。
一、camera.update() 每帧到底做了什么
在动画物理结算完毕之后,scene.render() 对所有活动相机调用 update():
typescript
// 源码中的简化逻辑
if (scene.activeCameras && scene.activeCameras.length > 0) {
for (const camera of scene.activeCameras) {
camera.update();
// rig 模式下,每个子相机(如 VR 左右眼)也各自 update
}
} else if (scene.activeCamera) {
scene.activeCamera.update();
}
update() 内部的核心动作是处理输入:比如 ArcRotateCamera 的指针拖拽惯性、键盘平移,FreeCamera 的按键移动,都在这一步根据 deltaTime 结算,更新相机的位置/旋转参数。
视图矩阵和投影矩阵的计算 则是惰性的------绘制阶段真正需要时才生成(getViewMatrix() / getProjectionMatrix())。理解这一点很重要:update() 更新的是"参数",矩阵是"参数的推导结果"。
二、相机事件总表
| 事件 | 触发时机 | 频率 |
|---|---|---|
camera.onAfterCheckInputsObservable |
相机处理完本帧输入后 | 每相机每帧一次 |
camera.onViewMatrixChangedObservable |
视图矩阵发生变化时 | 变化才触发 |
camera.onProjectionMatrixChangedObservable |
投影矩阵变化(如 FOV、近远裁剪面修改) | 变化才触发 |
camera.onRestoreStateObservable |
相机状态被恢复(storeState/restoreState) | 按需 |
scene.onActiveCameraChangedObservable |
场景的活动相机被切换 | 按需 |
scene.onBeforeCameraRenderObservable |
某相机即将开始渲染(阶段⑥) | 见下文"多相机" |
scene.onAfterCameraRenderObservable |
某相机渲染完成 | 每相机每帧一次 |
scene.onAfterRenderCameraObservable |
与 rig 子相机相关的完成通知 | 见下文 |
几个典型用法:
typescript
// 相机一停下就保存视角(比每帧保存省性能)
camera.onViewMatrixChangedObservable.add(() => {
localStorage.setItem("cameraState", JSON.stringify(camera.position));
});
// 投影矩阵冻结:FOV/裁剪面确定不变时,每帧省一次矩阵计算
camera.freezeProjectionMatrix();
// 相机切换时更新 UI 提示
scene.onActiveCameraChangedObservable.add((scene) => {
console.log("当前相机切换为:", scene.activeCamera?.name);
});
三、输入系统:attachControl 背后是什么
camera.attachControl(canvas, true) 做了两件事:把相机的输入管理器挂到 canvas 的 DOM 事件上(pointer、wheel、keyboard),并登记到每帧的输入处理流程中。
每种相机内置若干"输入源",可以单独增删:
typescript
// 查看 ArcRotateCamera 自带的输入源
// pointers(指针拖拽)、mousewheel(滚轮缩放)、keyboard(键盘)等
// 禁用键盘输入,只保留鼠标
camera.inputs.removeByType("ArcRotateCameraKeyboardMoveInput");
// 禁用滚轮缩放
camera.inputs.removeByType("ArcRotateCameraMouseWheelInput");
这也解释了 detachControl 为什么必须成对调用:不 detach,DOM 事件监听和输入处理会一直挂着------相机隐藏了还会响应拖拽,组件销毁后还会泄漏。多相机切换的标准写法就是"旧的 detach,新的 attach":
typescript
function switchCamera(scene, canvas, from, to) {
from.detachControl();
scene.activeCamera = to; // 触发 onActiveCameraChangedObservable
to.attachControl(canvas, true);
}
四、多相机:activeCamera 与 activeCameras
这是本篇最关键的一对概念:
scene.activeCamera:单一活动相机。视锥剔除、音频监听等"需要唯一视角"的逻辑都用它;scene.activeCameras:活动相机数组 。一旦赋值非空数组,渲染阶段⑥会对数组里每个相机各执行一遍完整的渲染流程(剔除 → 渲染目标 → 绘制),实现分屏。
经典分屏写法:
typescript
const cam1 = new BABYLON.FreeCamera("cam1", new BABYLON.Vector3(0, 5, -10), scene);
const cam2 = new BABYLON.FreeCamera("cam2", new BABYLON.Vector3(10, 5, 0), scene);
// 各占左右半屏(viewport 参数是归一化的 x, y, width, height)
cam1.viewport = new BABYLON.Viewport(0, 0, 0.5, 1.0);
cam2.viewport = new BABYLON.Viewport(0.5, 0, 0.5, 1.0);
scene.activeCameras = [cam1, cam2];
scene.activeCamera = cam1; // 仍需指定一个"主"相机用于剔除基准等
此时事件频率发生变化:
scene.onBeforeCameraRenderObservable/onAfterCameraRenderObservable:每帧触发 2 次(每相机一次);scene.onBeforeRenderObservable/onAfterRenderObservable:仍然每帧 1 次------它们是帧级事件,与相机数量无关;- 视锥剔除、渲染目标(阴影贴图等)、渲染组遍历:每个相机各做一遍。所以分屏的代价大致是"渲染两遍",阴影贴图等也可能被重复生成。
五、rig 相机:VR/立体渲染的特殊形态
camera.cameraRigMode 非 RIG_MODE_NONE 时,相机会派生出若干 rig 子相机 (如左右眼),挂在 camera._rigCameras 上。渲染时的行为与多相机不同:
- 父相机先触发一次
scene.onBeforeCameraRenderObservable; - 然后每个 rig 子相机 各走一遍
_renderForCamera------每次又各自触发一次onBeforeCameraRenderObservable和onAfterCameraRenderObservable; - 全部子相机完成后,触发
scene.onAfterRenderCameraObservable(rig 模式下它按子相机粒度通知)。
所以经典的双眼立体渲染里,onBeforeCameraRenderObservable 一帧会触发 3 次(父相机 1 次 + 左右眼各 1 次)。如果你在它里面做"每帧一次"的逻辑(比如计数、累加),在 VR 场景就会出错------这就是第一篇留的变体实验的答案。
实用结论:要区分"帧级""相机级""子相机级"三种粒度:
| 你要做的事 | 该挂的事件 |
|---|---|
| 每帧一次,与相机无关 | scene.onBeforeRenderObservable |
| 每个视口/每只眼各一次 | scene.onBeforeCameraRenderObservable |
| 相机参数变化时 | camera.onViewMatrixChangedObservable 等 |
六、实战:验证多相机下的事件次数
把第一篇的日志实验改造一下,加上分屏双相机:
typescript
let camRenderCount = 0;
let frameRenderCount = 0;
scene.onBeforeCameraRenderObservable.add(() => camRenderCount++);
scene.onBeforeRenderObservable.add(() => frameRenderCount++);
scene.onAfterRenderObservable.addOnce(() => {
console.log(`本帧 onBeforeRender 触发 ${frameRenderCount} 次`);
console.log(`本帧 onBeforeCameraRender 触发 ${camRenderCount} 次`);
// 分屏双相机输出:1 和 2
// 单相机输出:1 和 1
});
这个三行实验是面试和排查问题时非常好用的"探针":任何"这个事件一帧触发几次"的疑问,都可以这样快速验证。
七、常见误区
误区一:在 onBeforeRenderObservable 里改相机位置,却期望"本帧生效"------其实没问题,但理由要想清。
阶段③在阶段⑥之前,改位置本帧生效。但如果你改完后又依赖 onViewMatrixChangedObservable 的回调做后续逻辑,注意矩阵是惰性计算的,变化通知在绘制阶段才真正到来。
误区二:切换相机只改 activeCamera,忘了 detachControl。
旧相机的输入监听还在,两个相机同时响应拖拽,视角"打架"。
误区三:viewport 传像素值。
Viewport 的参数是 0~1 的归一化坐标,不是像素。传了像素值会得到一块怪异的小画面。
误区四:以为 activeCameras 非空时 activeCamera 就无所谓了。
视锥剔除的基准、LOD 距离计算等仍参考 activeCamera(或数组第一个)。分屏时建议显式指定主相机。
误区五:在 onBeforeCameraRenderObservable 里做每帧一次的全局逻辑。
多相机/VR 下它会多次触发。全局逻辑请回到 onBeforeRenderObservable。
八、小结
- 相机在管线中有两副面孔:阶段②更新 (处理输入),阶段⑥作为渲染单位组织绘制;
- 矩阵是惰性计算的,
update()更新的是参数;确定不变的投影矩阵可用freezeProjectionMatrix()省掉重复计算; activeCamera是唯一视角,activeCameras数组驱动分屏渲染,每个相机各走一遍完整渲染流程;- rig 模式下
onBeforeCameraRenderObservable一帧触发"1 + 子相机数"次------挂载逻辑前先想清楚自己要哪种粒度。
下篇预告 :《Babylon.js 一帧之旅(五):活动网格评估------视锥剔除与可见性判定》。相机就绪后,管线要回答一个每帧必答的问题:这么多网格,画谁?我们将拆解 _evaluateActiveMeshes() 的完整过滤链,讲清 isVisible、setEnabled、visibility 三者的区别,以及拿到"本帧可见网格列表"的唯一正确时机。