本节摘要:界面与声音不参与三维渲染,却都要在帧流水线里记账——GUI 的动态纹理每帧重绘要计入 CPU 预算,音频则应与帧率彻底解耦、由事件驱动。本节讲三维空间 UI 与屏幕 UI 的两套方案、动态纹理的成本纪律与声音的空间化处理,为第6章的游戏装配备齐最后两个零件。
3D 应用的界面分两个世界。屏幕界面:血条、计分、菜单,贴在画布上不随镜头动,实现载体是动态纹理——一块画在纹理上的画布,控件画上去,纹理贴到一块始终面向相机的平面上。空间界面:展厅铭牌、头顶名牌,存在于三维世界里、会被物体遮挡、有透视大小,直接用网格加材质实现。两套方案的取舍全在"要不要参与三维":
// 屏幕界面:动态纹理是核心——一块可以反复重绘的画布 const advTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("ui", true, scene); const scoreText = new BABYLON.GUI.TextBlock(); scoreText.text = "分数 0"; // 改 text 属性即触发重绘 scoreText.color = "white"; scoreText.fontSize = 24; scoreText.horizontalAlignment = BABYLON.Control.HORIZONTAL_ALIGNMENT_RIGHT; scoreText.verticalAlignment = BABYLON.Control.VERTICAL_ALIGNMENT_TOP; advTexture.addControl(scoreText); // 空间界面:铭牌是一个网格,随世界透视变化 const plate = BABYLON.MeshBuilder.CreatePlane("plate", { width: 1.2, height: 0.4 }, scene); const plateTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateForMesh(plate, 512, 170); const nameText = new BABYLON.GUI.TextBlock(); nameText.text = "展品一号"; plateTexture.addControl(nameText); plate.billboardMode = BABYLON.Mesh.BILLBOARDMODE_Y; // 只绕竖轴面向相机:像立牌不像贴纸
控件的常用三件是文本、按钮、容器。按钮的点击事件走 4.2 的事件系统;容器负责对齐与布局,嵌套容器实现"血条带边框"这类组合。
动态纹理的本质是"一块每帧可能重绘的画布",成本纪律围绕"少重绘"三个字。FullscreenUI 默认只在控件变化时重绘,这是良性的;失控的写法是在帧回调里无脑改文本——分数没变也每帧赋值,每帧都付一次全屏纹理重绘与上传。纪律两条:改属性前比值,值没变不赋值;纯静态的界面(开局菜单)标记为不自动重绘,要动时手动触发。
// 血条的正确更新:值没变就不碰纹理 let lastHp = -1; scene.onBeforeRenderObservable.add(() => { if (hp === lastHp) return; // 帧率 60 血条变化 2 次:省下 58 次重绘 lastHp = hp; bar.width = `${hp}%`; barText.text = `${hp} / 100`; });
头顶名牌这类"跟随物体"的空间 UI 还有一层选择:名牌用 HTML 覆盖层做(把世界坐标投到屏幕坐标),还是用三维网格做。HTML 层不耗 GPU、文字清晰,代价是每帧做一次坐标投影与可能的 DOM 更新;三维网格参与遮挡与透视、风格统一,代价是占绘制调用与纹理。展示类项目我倾向 HTML 层——文字永远清晰是硬需求;游戏内 UI 倾向三维网格——被前景物体遮挡是表达的一部分。
声音最容易踩的坑是把它当帧内系统——在渲染回调里同步播放,帧率抖动声音就断续。正确的心智模型是:音频走浏览器的独立音频线程,由事件触发,一帧都不该等它。
// 事件驱动:点击命中在 4.2 的回调里触发,而不是在帧循环里轮询 const coinSound = new BABYLON.Sound( "coin", "coin.wav", scene, () => console.log("解码完成"), // 就绪回调:预热就靠它 { loop: false, volume: 0.6 } ); scene.onPointerObservable.add((pi) => { if (pi.type === BABYLON.PointerEventTypes.POINTERPICK) { const pick = scene.pick(scene.pointerX, scene.pointerY); if (pick.hit && pick.pickedMesh.metadata?.isCoin) { coinSound.play(); // 事件到了就响,不看任何帧 } } });
空间声音给音源一个位置,音量与左右声道随听者距离方位变化——引擎把这件事接到相机上,配置一个开关:
// 空间音:瀑布声挂在瀑布网格的位置,走近变响、绕行变向 const fall = new BABYLON.Sound("fall", "water.wav", scene, null, { loop: true, spatialSound: true, distanceModel: "inverse", // 距离衰减模型 maxDistance: 30, // 30 个单位外听不见:顺带省解码后的混音 rolloffFactor: 1.2, // 衰减速度:大于 1 衰得更快 }); fall.attachToMesh(waterfallMesh); fall.play();
音频的预算纪律只有一条但极重要:解码即成本。一个十兆的音频文件首次播放前要完整解码,正好落在播放那一刻的帧上——巨大的尖刺。解法是加载期"预解码":场景初始化时把关键音效依次播一遍零音量的实例,或用浏览器的音频上下文提前解码,正式开场就再无尖刺。
⚠️ 常见坑:浏览器自动播放策略——用户没有交互前音频会被拦。背景音乐等首次点击后再启动,加载页的"点击开始"正是天然的解锁点。
💡 关键直觉:界面的钱花在重绘,声音的钱花在解码——都不在渲染本身。把这两笔账从帧预算里单列,性能排查时才不会冤枉渲染管线。
第4章收束:动、点、撞、面、声五个系统全部挂上流水线。下一章转向钱——每一帧的花销怎么量、怎么省。