「Babylon.js 一帧之旅」番外篇之二。第(一)篇确立了"Engine 是节拍器、Scene 是指挥家"的架构认知,但正篇里我们始终只用一个 Engine 配一个 Scene。真实的应用往往不止一个场景:主菜单与游戏关卡、3D 世界与 HUD、主视图与预览窗……本篇系统讲清多场景(乃至多画布)的四种组织形态,以及每种形态的正确姿势。
引言:先记住一个资源事实
做选型之前,必须知道一个硬件层约束:浏览器对同时存活的 WebGL 上下文有数量限制(Chrome 约 16 个),超出后最旧的上下文会被强制回收——表现就是"某个画布突然黑了"。每个 Engine 占一个上下文,这决定了多 Engine 永远是最后的选择。
一个实用的类比:
- Scene 是"同一舞台上的不同剧目"——换剧目、叠布景都很便宜;
- Engine 是"新建一座剧院"——昂贵且数量有限;
- Engine Views 是"舞台上的分屏直播"——一座剧院,多块屏幕。
一、四种形态总览
| 形态 | 结构 | 上下文数 | 典型用途 |
|---|---|---|---|
| 场景切换 | 1 Engine + N Scene(分时渲染) | 1 | 主菜单/关卡/结算 |
| 场景叠加 | 1 Engine + N Scene(同帧渲染) | 1 | 3D 世界 + HUD、叠层特效 |
| 引擎视图 | 1 Engine + N canvas(registerView) | 1 | 主视图 + 多角度预览窗 |
| 多引擎 | N Engine + N canvas | N | 完全独立的少量 3D 区域 |
二、场景切换:分时复用同一舞台
核心手法:渲染指针切换——循环只渲染"当前场景",切场景只是换指针:
letcurrentScene=menuScene;engine.runRenderLoop(()=>currentScene.render());asyncfunctionswitchLevel(){// 1. 后台场景预加载(就绪机制照常工作,见正篇第(二)篇)constnextScene=newBABYLON.Scene(engine);awaitBABYLON.SceneLoader.AppendAsync("./assets/","level2.glb",nextScene);awaitnextScene.whenReadyAsync();// 含着色器编译,切换零等待// 2. 切换渲染指针constoldScene=currentScene;currentScene=nextScene;// 3. 释放旧场景(见正篇第(九)篇的销毁纪律)oldScene.dispose();}要点:
- 不渲染的场景零开销——不调
render()就没有任何帧成本; - 预加载模式让切换"无缝":新场景完全就绪后才换指针,用户看不到加载过程;
- 输入控制记得随迁:旧场景相机
detachControl、新场景相机attachControl(正篇第(四)篇)。
三、场景叠加:一帧画两条管线
核心手法:同帧依次渲染多个场景,上层场景关闭清屏:
worldScene.autoClear=true;// 底层:正常清屏(颜色+深度)hudScene.autoClear=false;// 上层:保留下层画面// 关键细节:深度缓冲也要保住,否则上层场景会被"清空后的深度"误导hudScene.autoClearDepthAndStencil=false;engine.runRenderLoop(()=>{worldScene.render();hudScene.render();});3.1 深度缓冲的两种处理哲学
叠加时深度缓冲怎么处理,决定上层场景和下层场景是什么关系:
| 上层场景设置 | 效果 | 适用 |
|---|---|---|
autoClearDepthAndStencil = false | 上层共享下层的深度——上层物体仍能被"下层世界的墙"遮挡 | 上下层是同一个世界的两部分(如分层加载的场景) |
autoClearDepthAndStencil = true | 上层开始前清深度——上层完全无视下层遮挡,永远置顶 | HUD、告警标记、独立于世界的内容 |
3.2 叠加方案的官方封装:UtilityLayerRenderer
手写双场景叠加时,你要自己同步相机、处理输入路由、管理生命周期。对于"辅助工具/交互控件永远置顶"这个最常见诉求,Babylon 直接提供了封装:UtilityLayerRenderer——官方 Gizmo 系统(GizmoManager)就是基于它实现的。
constutilLayer=newBABYLON.UtilityLayerRenderer(scene);// utilityLayerScene 就是内部维护的叠层场景,渲染在主场景之上consthandle=BABYLON.MeshBuilder.CreateTorus("handle",{diameter:1},utilLayer.utilityLayerScene);// 叠层场景的相机默认跟随主场景相机,无需手动同步什么时候手写双场景,什么时候用 UtilityLayer?做自定义 Gizmo、选中框、操作手柄等交互辅助层——用 UtilityLayer(输入、相机同步、清理都帮你处理了);做内容级的叠层(一套独立于主世界的 HUD 场景,有自己的相机构图)——手写双场景,自由度更高。
3.3 同场景内其实也能"置顶"——别急着开第二个场景
在决定开叠层场景之前,先确认你要的效果是不是同一场景内就能解决。 Babylon 提供三个层级的同场景手段:
// 层级一:渲染组置顶(最省事)// 原理:Babylon 默认在每个渲染组开始时清空深度缓冲,// 高组号内容天然无视低组号的遮挡marker.renderingGroupId=3;// 想让组间保留深度关系时:scene.setRenderingAutoClearDepthStencil(3,false);// 层级二:深度函数穿透(透视/X光)pipe.onBeforeRenderObservable.add(()=>{engine.setDepthFunction(BABYLON.Constants.ALWAYS);// 深度测试永远通过});pipe.onAfterRenderObservable.add(()=>{engine.setDepthFunction(BABYLON.Constants.LEQUAL);// 画完恢复});// 层级三:双趟渲染实现"墙内幽灵"效果(楼宇管道透视的标准做法)// 第一趟正常渲染(被墙挡);第二趟半透明 + ALWAYS,墙内部分以幽灵轮廓透出选择原则:只是"显示在最上"→ 渲染组;要"穿透遮挡的透视感"→ 深度函数/双趟;内容是交互工具且要独立输入管理 → UtilityLayer;内容是完全独立的一套世界(独立相机、独立光照)→ 才轮到双场景叠加。
四、多画布:Engine Views 与多 Engine
4.1 Engine Views:一个引擎,多块画布(推荐)
需求是"主视图 + 几个不同角度的预览窗"时,registerView是正解——一座剧院,多块屏幕:
// 把小画布注册为主场景某相机的输出视口constpreviewCam=newBABYLON.ArcRotateCamera("preview",0,Math.PI/2.5,15,BABYLON.Vector3.Zero(),scene);engine.registerView(previewCanvas,previewCam);// 渲染循环照常只有一句 scene.render()// 引擎会自动把主画布渲染给主相机、把 previewCanvas 渲染给 previewCamengine.runRenderLoop(()=>scene.render());// 移除视图engine.unRegisterView(previewCanvas);每个 view 可以有自己的相机,共享同一个场景、同一份资源、同一个 WebGL 上下文。注意:view 的渲染是增量开销(每个 view 多走一遍该相机的渲染流程),但与多 Engine 相比,省下了重复的资源加载和着色器编译。
4.2 多 Engine:最后的选择
仅当"多个完全独立、互不相干的 3D 画布"且数量很少时才用。代价清单:
- 上下文数量天花板(约 16 个),超限回收旧上下文;
- 同一模型在每个 Engine 里重复解析、重复编译着色器、重复占显存;
- 多个 rAF 循环各自为政,低端设备集体掉帧。
特殊例外——Web Worker + OffscreenCanvas:渲染重到阻塞主线程时,把 Engine 放进 Worker(BABYLON.Engine支持 OffscreenCanvas)。此时必然是新 Engine,但这是有意的架构选择,不是资源浪费。
4.3 决策速查
| 需求 | 方案 |
|---|---|
| 同画布,不同时刻不同内容 | 场景切换(渲染指针) |
| 同画布,内容叠加 | 场景叠加(autoClear 组合)/ UtilityLayer |
| 多画布,内容相关(同场景多视角) | engine.registerView |
| 多画布,完全独立,数量 ≤ 个位数 | 多 Engine |
| 渲染阻塞主线程 | OffscreenCanvas + Worker |
五、实战:产品展示页——主视图 + 缩略预览
组合本篇手法:一个主画布交互查看产品,右下角一个小画布固定俯视角度:
constengine=newBABYLON.Engine(mainCanvas,true);constscene=newBABYLON.Scene(engine);// 主相机:用户交互constmainCam=newBABYLON.ArcRotateCamera("main",Math.PI/4,Math.PI/3,8,BABYLON.Vector3.Zero(),scene);mainCam.attachControl(mainCanvas,true);scene.activeCamera=mainCam;// 俯视预览相机:挂在 view 上,不占额外汇编/资源consttopCam=newBABYLON.ArcRotateCamera("top",0,0.01,10,BABYLON.Vector3.Zero(),scene);engine.registerView(thumbCanvas,topCam);// 选中部件高亮:同场景渲染组置顶即可,无需叠层functionhighlightPart(mesh:BABYLON.AbstractMesh){mesh.renderingGroupId=3;// 组间默认清深度,永远可见consthl=newBABYLON.HighlightLayer("hl",scene);hl.addMesh(mesh,BABYLON.Color3.Green());}engine.runRenderLoop(()=>scene.render());// 页面卸载:一个引擎,一套清理(正篇第(九)篇)window.addEventListener("beforeunload",()=>{engine.stopRenderLoop();engine.unRegisterView(thumbCanvas);scene.dispose();engine.dispose();});整个页面只用了1 个上下文、1 条渲染循环,实现了双画布 + 置顶高亮——这就是本篇选型的价值。
六、常见误区
误区一:叠加场景只设autoClear = false,忘了autoClearDepthAndStencil。
颜色缓冲保住了,深度缓冲却被清掉,上层物体的遮挡关系全乱。两个开关要成对考虑。
误区二:为"置顶显示"就开一个新场景。
同场景的渲染组(默认组间清深度)或 UtilityLayer 就能解决的事,开新场景意味着独立的相机同步、输入管理和资源开销。粒度够用就别升级方案。
误区三:registerView之后以为每个 view 是免费的。
每个 view 都是一遍额外的相机渲染流程(剔除 + 绘制)。view 多了同样掉帧——只是比多 Engine 便宜得多。
误区四:多 Engine 做无限滚动的 3D 列表。
WebGL 上下文约 16 个就到天花板,之后旧画布被系统回收变黑。列表类需求应该用"少量 view 复用"(滚出视口的 item 回收其 view 给新 item)或干脆截图静态化。
误区五:切换场景后忘了旧场景的输入监听。detachControl不调用,旧相机还在响应拖拽(正篇第(四)篇的坑在多场景下会放大)。
七、小结
- 多内容组织的四种形态按成本递增:场景切换 → 场景叠加 → Engine Views → 多 Engine,能用便宜方案就别升级;
- 场景叠加的核心开关是
autoClear/autoClearDepthAndStencil的组合,深度缓冲是否共享决定上下层"是不是同一个世界"; - "永远在最上"有三个同场景层级:渲染组(默认组间清深度)、深度函数穿透、UtilityLayer 叠层——开新场景是最后才需要的手段;
- 多画布首选
registerView(一个上下文多块屏幕),多 Engine 只留给真正独立的少量场景或 Worker 隔离。
本篇为「Babylon.js 一帧之旅」番外篇之二,与正篇第(一)(四)(七)(九)篇互为补充。