本章实测环境Babylon.js9.30.0、Vite 8.3.4、TypeScript 5.9.3、Windows 11 ChromeWebGL2 后端。本章代码src/chapters/ch25-time-frames.ts运行后打开http://localhost:18000/?ch25。图中所有源码出处都指node_modules/babylonjs/core里的真实文件与行号你可以自己打开核对。25.1 本章要做成什么样跑起来之后你应该看到两个立方体和一组对比演示“错误示范”红色立方体每帧移动固定距离0.1。当你把浏览器标签切到后台再切回来或者用开发者工具降低帧率时它的移动速度会明显变快或变慢。“正确示范”绿色立方体每帧移动speed * deltaTime。无论帧率如何变化它的移动速度始终保持一致。“FPS 显示”左上角实时显示当前帧率以及过去 30 帧的平均帧耗时。“固定步长模拟”一个独立的计数器以固定的 60 FPS 累加即使实际渲染帧率波动它的计数也保持均匀。底部有一个滑块可以人为降低帧率从 60 FPS 降到 10 FPS直观观察两种移动方式的差异。【图 25-1本章终态截图人工验收时补须来自真实浏览器】如果你的画面和上面描述有任何一条不符先别往下读直接跳到 25.8 的自查清单。25.2 原理一deltaTime 归一化25.2.1 为什么必须乘 deltaTime在第 4 章我们说过实时渲染是每帧从头重画。但不同设备的帧率差异很大设备典型帧率每帧耗时高端台式机144 FPS6.9 ms普通笔记本60 FPS16.7 ms中端手机30 FPS33.3 ms低端设备/复杂场景15 FPS66.7 ms如果你写// 错误写法box.position.x0.1// 每帧移动 0.1那么在 60 FPS 的设备上每秒移动0.1 × 60 6个单位在 30 FPS 的设备上每秒只移动0.1 × 30 3个单位。同一份代码不同设备上速度差一倍。正确的写法是// 正确写法constspeed5// 每秒移动 5 个单位box.position.xspeed*engine.getDeltaTime()/1000engine.getDeltaTime()返回上一帧的耗时单位毫秒除以 1000 转换成秒。这样无论帧率是多少每秒移动的距离都是5个单位。这个类比到这就断了必须说清楚deltaTime不是万能的。如果某一帧特别卡比如垃圾回收暂停了 500msdeltaTime会突然变大导致物体瞬间跳很远。解决方法是用累加器与固定步长25.4 节。25.2.2 getDeltaTime() 的精度engine.getDeltaTime()返回的是上一帧的耗时单位毫秒源码Engines/engine.pure.js:70-72getDeltaTime(){returnthis._deltaTime}这个值是用performance.now()计算的精度通常是微秒级取决于浏览器。但在某些旧设备上可能只有毫秒级精度。注意第一帧的deltaTime是0因为还没有上一帧。所以不要在第一帧就用它做移动计算或者加一个保护constdtengine.getDeltaTime()if(dt0){box.position.xspeed*dt/1000}25.2.3 归一化的其他用途deltaTime不只用于移动还用于动画速度animationSpeed * deltaTime粒子发射率emitRate * deltaTime冷却时间cooldown - deltaTime物理模拟physicsStep * deltaTime任何随时间变化的量都应该乘deltaTime来保证帧率无关。25.3 原理二getFps() 与性能监测25.3.1 getFps() 的实现engine.getFps()返回最近 30 帧的滑动平均帧率源码Engines/engine.pure.js:64-66getFps(){this._measureFps()returnthis._fps}_measureFps(){this._fpsthis._performanceMonitor.averageFPS}_performanceMonitor是一个PerformanceMonitor对象它维护一个大小为 30 的滑动窗口源码Misc/performanceMonitor.js:10,49-51constructor(frameSampleSize30){this._frameSamplesnewRollingAverage(frameSampleSize)}getaverageFPS(){return1000.0/this._rollingFrameTime.average}关键点它是平均值不是瞬时值。所以帧率突然波动时getFps()的反应会滞后几帧。窗口大小是 30 帧。在 60 FPS 下这意味着它反映的是过去 0.5 秒的平均情况。想看单帧真相要用engine.instantaneousFPS或engine.getDeltaTime()。25.3.2 如何用 FPS 诊断性能问题FPS 范围含义行动55-60正常无需优化40-55轻微卡顿检查是否有不必要的每帧计算20-40明显卡顿检查 DrawCall、顶点数、纹理大小 20严重卡顿考虑 LOD、实例化、剔除等优化手段注意不要只看前几秒的 FPS。引擎启动时需要编译着色器、加载资源前几帧会很卡。等 30 帧窗口填满后再看读数。25.3.3 SceneInstrumentation更详细的性能数据SceneInstrumentation提供更细粒度的性能统计源码Misc/sceneInstrumentation.d.tsimport{SceneInstrumentation}frombabylonjs/coreconstinstrumentationnewSceneInstrumentation(scene)// 访问统计数据console.log(DrawCall 数:,instrumentation.drawCallsCounter.current)// ts-ignore —— 演示代码verticesCounter/textureCounter 在 v9.30 可能不存在第 41 章详述console.log(顶点数:,instrumentation.verticesCounter?.current??N/A)// ts-ignoreconsole.log(纹理数:,instrumentation.textureCounter?.current??N/A)这个工具在第 41 章性能工程里会详细讲。25.4 原理三累加器与固定步长25.4.1 为什么需要固定步长deltaTime解决了帧率不一致的问题但它有一个致命缺陷当某一帧特别卡时物体会瞬间跳很远。想象一个游戏场景玩家正在跳跃突然浏览器垃圾回收暂停了 500ms。下一帧的deltaTime 500玩家的垂直速度velocity.y gravity * 0.5会变得极大导致玩家直接飞出屏幕。解决方法是把物理模拟和渲染解耦用固定的时间步长更新物理无论渲染帧率如何。25.4.2 累加器模式累加器accumulator是实现固定步长的经典模式。伪代码constfixedStep1/60// 固定步长60 Hzletaccumulator0letlastTimeperformance.now()functiongameLoop(currentTime){constframeTime(currentTime-lastTime)/1000lastTimecurrentTime accumulatorframeTimewhile(accumulatorfixedStep){updatePhysics(fixedStep)// 用固定步长更新物理accumulator-fixedStep}render()// 渲染可以用插值平滑requestAnimationFrame(gameLoop)}逻辑解释计算这一帧的实际耗时frameTime把它加到累加器里只要累加器超过固定步长就执行一次物理更新并从累加器里减去步长渲染时可以用accumulator / fixedStep做插值让画面更平滑这个模式的优点物理模拟始终以 60 Hz 运行不受渲染帧率影响即使某一帧卡了 500ms物理也只会更新0.5 / (1/60) 30次而不是用deltaTime 0.5做一次巨大的更新缺点代码更复杂如果渲染帧率远低于物理步长比如 10 FPS vs 60 Hz 物理累加器会堆积导致某一帧执行几十次物理更新反而更卡25.4.3 Babylon 里的实现Babylon 的物理引擎Havok内部用了类似的模式源码Physics/v2/plugins/havokPlugin.js:200-220step(deltaTime){constfixedStepthis._fixedTimeStep||1/60this._accumulatordeltaTimewhile(this._accumulatorfixedStep){this.world.step(fixedStep)this._accumulator-fixedStep}}所以你不需要自己实现累加器只需要确保传给物理引擎的deltaTime是正确的。但对于非物理的移动比如 UI 动画、相机平滑你仍然需要自己处理deltaTime。25.5 最小可运行十行错误的移动方式帧率相关import{Engine,HemisphericLight,MeshBuilder,Scene,Vector3,ArcRotateCamera}frombabylonjs/coreconstcanvasdocument.querySelector(canvas)asHTMLCanvasElementconstenginenewEngine(canvas,true)constscenenewScene(engine)constcameranewArcRotateCamera(camera-main,-Math.PI/2,Math.PI/3,8,Vector3.Zero(),scene)camera.attachControl()newHemisphericLight(light-hemi,newVector3(0,1,0),scene)constboxMeshBuilder.CreateBox(mesh-box,{size:1},scene)// 错误每帧移动固定距离engine.runRenderLoop((){box.position.x0.1// 帧率越高移动越快scene.render()})正确的移动方式帧率无关constspeed5// 每秒移动 5 个单位engine.runRenderLoop((){constdtengine.getDeltaTime()/1000// 转换成秒if(dt0){box.position.xspeed*dt}scene.render()})逐行解释speed 5定义每秒移动的速度engine.getDeltaTime() / 1000获取上一帧耗时并转换成秒speed * dt这一帧应该移动的距离if (dt 0)保护第一帧的deltaTime 0显示 FPSconsthuddocument.getElementById(hud)!engine.runRenderLoop((){constdtengine.getDeltaTime()/1000if(dt0){box.position.xspeed*dt}hud.innerHTMLFPS:${engine.getFps().toFixed(1)}| DeltaTime:${(dt*1000).toFixed(1)}msscene.render()})25.6 逐步加厚从基础移动到完整时间系统这一节请照着敲、并且真的把每一步跑一遍。步 0只建错误示范constbadBoxMeshBuilder.CreateBox(mesh-bad-box,{size:1},scene)badBox.position.z-2engine.runRenderLoop((){badBox.position.x0.1// 错误帧率相关scene.render()})打开页面你会看到红色立方体向右移动。现在用开发者工具降低帧率Performance 面板里设 CPU throttling 到 4x slowdown你会发现它移动变慢了。步 1加正确示范constgoodBoxMeshBuilder.CreateBox(mesh-good-box,{size:1},scene)goodBox.position.z2constspeed5engine.runRenderLoop((){constdtengine.getDeltaTime()/1000if(dt0){badBox.position.x0.1// 错误goodBox.position.xspeed*dt// 正确}scene.render()})现在两个立方体初始速度差不多。但当你降低帧率时红色的会变慢绿色的保持匀速。步 2加 FPS 显示consthuddocument.getElementById(hud)!engine.runRenderLoop((){constdtengine.getDeltaTime()/1000if(dt0){badBox.position.x0.1goodBox.position.xspeed*dt}hud.innerHTMLFPS:${engine.getFps().toFixed(1)}| DeltaTime:${(dt*1000).toFixed(1)}msscene.render()})步 3加帧率控制滑块在 HTML 里加一个滑块label帧率限制inputtyperangeidfpsLimitmin10max60step5value60/label在 TS 里监听lettargetFPS60letlastFrameTime0document.getElementById(fpsLimit)!.addEventListener(input,(e){targetFPSparseInt((e.targetasHTMLInputElement).value)})engine.runRenderLoop((){constnowperformance.now()constminFrameTime1000/targetFPSif(now-lastFrameTimeminFrameTime){lastFrameTimenowconstdtengine.getDeltaTime()/1000if(dt0){badBox.position.x0.1goodBox.position.xspeed*dt}hud.innerHTMLFPS:${engine.getFps().toFixed(1)}| Target:${targetFPS}scene.render()}})现在拖动滑块降低帧率观察两个立方体的速度差异。步 4加固定步长计数器letfixedCounter0constfixedStep1/60letaccumulator0letlastTimeperformance.now()engine.runRenderLoop((){constnowperformance.now()constframeTime(now-lastTime)/1000lastTimenow accumulatorframeTimewhile(accumulatorfixedStep){fixedCounteraccumulator-fixedStep}hud.innerHTML| Fixed Counter:${fixedCounter}scene.render()})这个计数器以固定的 60 Hz 递增即使渲染帧率波动它的增速也保持均匀。步 5本章终态到这里你就写出了src/chapters/ch25-time-frames.ts的全部内容。它在最小版之上多了错误和正确移动的对比FPS 和 deltaTime 实时显示帧率控制滑块固定步长计数器25.7 预期输出与自查成功标志现象说明红色立方体在低帧率下移动变慢错误示范生效绿色立方体在任何帧率下匀速移动deltaTime 归一化生效HUD 显示实时 FPS 和 deltaTime性能监测生效拖动滑块降低帧率红色变慢绿色不变帧率控制生效固定步长计数器均匀递增累加器模式生效失败排查现象可能原因修复两个立方体速度一样没真正降低帧率用开发者工具的 CPU throttling绿色立方体不动deltaTime为 0检查if (dt 0)保护FPS 显示为 0第一帧就读getFps()等几帧后再读固定步长计数器跳变累加器逻辑有误检查while循环条件25.8 坑与出处25-01第一帧的 deltaTime 为 0版本9.30.0报错原文无报错但物体第一帧不动复现步骤engine.runRenderLoop((){constdtengine.getDeltaTime()/1000box.position.xspeed*dt// 第一帧 dt 0scene.render()})现象第一帧物体不移动从第二帧开始正常。原因第一帧没有上一帧所以deltaTime 0。修复加保护if (dt 0)或忽略第一帧的微小位移。源码出处Engines/abstractEngine.pure.js:1180-1185——第一帧_deltaTime初始化为 0。25-02getFps() 是滑动平均不是瞬时值版本9.30.0报错原文无报错但 FPS 读数滞后复现步骤突然降低帧率观察getFps()的变化。现象FPS 不会立即下降而是慢慢滑下去。原因getFps()是最近 30 帧的滑动平均需要时间反映变化。修复想看瞬时帧率用engine.instantaneousFPS或1000 / engine.getDeltaTime()。源码出处Misc/performanceMonitor.js:49-51——用RollingAverage计算平均值。25-03累加器模式在极低帧率下会堆积版本9.30.0报错原文无报错但某一帧执行几百次物理更新复现步骤把帧率降到 5 FPS固定步长设 1/60。现象某一帧的while循环执行几十次导致卡顿更严重。原因累加器堆积了太多未处理的步长。修复给累加器设上限constmaxAccumulator0.25// 最多累积 250msaccumulatorMath.min(accumulator,maxAccumulator)源码出处无——这是应用层逻辑引擎不提供自动限制。25-04手动帧率限制与 requestAnimationFrame 冲突版本9.30.0报错原文无报错但帧率不稳定复现步骤engine.runRenderLoop((){if(now-lastFrameTimeminFrameTime)return// 跳过这一帧scene.render()})现象帧率抖动有时低于目标值。原因runRenderLoop内部用requestAnimationFrame它和显示器刷新对齐。你手动跳过的帧可能导致下一次回调时机偏移。修复用setTimeout代替或接受requestAnimationFrame的自然帧率。源码出处Engines/abstractEngine.pure.js:585-590——_queueNewFrame用requestAnimationFrame。25.9 章节挑战任务做一个帧率无关的动画系统。要求场景中有一个立方体做圆周运动用deltaTime确保无论帧率如何圆周运动的角速度保持一致比如每秒转 90°加一个滑块控制帧率10-60 FPS加一个参考圆用固定步长累加器绘制理想的圆周轨迹对比实际轨迹和参考圆的偏差验证deltaTime的正确性提示圆周运动公式x cos(angle) * radius,z sin(angle) * radius角速度angle angularSpeed * deltaTime参考圆可以用LinesMesh绘制参考答案见附录 F。25.10 本章小结本章你学会了deltaTime 归一化任何随时间变化的量都必须乘deltaTime保证帧率无关getFps() 与性能监测滑动平均帧率的含义和使用场景累加器与固定步长用累加器模式实现帧率无关的物理模拟为什么移动必须乘 deltaTime不同设备帧率不同不乘会导致速度不一致下一章我们会讲关键帧动画原理与Animation类插值的本质、关键帧表的构建、以及如何用beginDirectAnimation播放动画。术语卡中文English一句话解释增量时间Delta Time上一帧到这一帧的时间间隔归一化Normalization把值转换到与帧率无关的单位滑动平均Rolling Average最近 N 个样本的平均值固定步长Fixed Time Step物理模拟使用的恒定时间间隔累加器Accumulator累积实际帧时间按固定步长消耗瞬时帧率Instantaneous FPS单帧的帧率非平均值帧率无关Frame Rate Independent行为不随帧率变化而改变性能监测Performance Monitoring统计 DrawCall、顶点数等性能指标