
本章实测环境Babylon.js9.30.0、Vite 8.3.4、TypeScript 5.9.3、Windows 11 ChromeWebGL2 后端。本章代码src/chapters/ch26-keyframe-animation.ts运行后打开http://localhost:18000/?ch26。图中所有源码出处都指node_modules/babylonjs/core里的真实文件与行号你可以自己打开核对。26.1 本章要做成什么样跑起来之后你应该看到三个立方体和一个控制面板“基础位移动画”红色立方体从 x-5 移动到 x5再返回循环播放。HUD 显示当前帧号和属性值。“旋转缩放组合”绿色立方体同时做旋转和缩放动画两个属性用不同的关键帧表。“四元数旋转”蓝色立方体用四元数做平滑旋转避免万向锁问题。控制面板可以调整动画速度0.5x、1x、2x、暂停/继续、跳转到指定帧。左上角实时显示动画状态playing播放中、paused暂停、stopped停止。【图 26-1本章终态截图人工验收时补须来自真实浏览器】如果你的画面和上面描述有任何一条不符先别往下读直接跳到 26.8 的自查清单。26.2 原理一插值的本质26.2.1 什么是关键帧动画关键帧动画的核心思想是你只定义几个关键时刻的状态关键帧引擎自动计算中间时刻的状态插值。比如你想让一个立方体从 A 点移动到 B 点帧 0: position (-5, 0, 0) ← 关键帧 帧 15: position (0, 0, 0) ← 关键帧 帧 30: position (5, 0, 0) ← 关键帧引擎会在帧 0~15 之间、15~30 之间自动计算中间值。第 7 帧的位置是多少引擎会用线性插值算出来t (7 - 0) / (15 - 0) 0.467 // 归一化时间 position (-5, 0, 0) ((0, 0, 0) - (-5, 0, 0)) * 0.467 (-5, 0, 0) (5, 0, 0) * 0.467 (-2.67, 0, 0)这就是插值的本质在两个已知值之间按比例混合。26.2.2 插值的类型Babylon 支持多种插值方式源码Animations/animation.pure.d.ts:90-100类型含义适用场景线性插值匀速变化位移动画步进插值瞬间跳变不插值开关状态样条插值平滑曲线带切线复杂路径最常用的是线性插值它的公式是result start (end - start) * t其中t是归一化时间范围 0~1。26.2.3 为什么不能直接每帧手动改属性初学者常问“我可以在onBeforeRenderObservable里每帧改box.position.x为什么要用Animation类”原因是Animation 类自动处理插值你只需要定义关键帧不用自己算中间值支持循环、倒放、暂停内置这些功能不用手写逻辑可以混合多个动画比如同时做位移和旋转可以与缓动函数结合让动画更自然第 27 章可以被序列化方便编辑器工具保存和加载所以简单动画可以用手动改属性复杂动画应该用Animation类。26.3 原理二Animation 类的结构26.3.1 构造函数参数Animation的构造函数有五个参数源码Animations/animation.pure.d.ts:105-125newAnimation(name:string,// 动画名称targetProperty:string,// 要动画的属性名如 position.xframePerSecond:number,// 帧率dataType:number,// 数据类型loopMode?:number// 循环模式可选)逐行解释name只是标识符不影响功能targetProperty要动画的属性路径可以是嵌套属性如position.x、rotationQuaternion.yframePerSecond动画的帧率决定关键帧之间的时间间隔dataType属性的数据类型必须是以下之一Animation.ANIMATIONTYPE_FLOAT单个数字Animation.ANIMATIONTYPE_VECTOR2二维向量Animation.ANIMATIONTYPE_VECTOR3三维向量Animation.ANIMATIONTYPE_QUATERNION四元数Animation.ANIMATIONTYPE_MATRIX矩阵Animation.ANIMATIONTYPE_COLOR3颜色loopMode循环模式见下节26.3.2 循环模式loopMode决定动画结束后的行为源码Animations/animation.pure.d.ts:40-50模式常量含义相对循环ANIMATIONLOOPMODE_RELATIVE每次循环在上一次结束值基础上继续周期循环ANIMATIONLOOPMODE_CYCLE每次循环回到起始值重新开始常数保持ANIMATIONLOOPMODE_CONSTANT动画结束后保持最后一帧的值直观理解RELATIVE像跑步机一直往前跑CYCLE像钟摆来回摆动CONSTANT像电梯到顶楼就停默认值是CONSTANT。26.3.3 关键帧表关键帧表是一个数组每个元素包含frame和value源码Animations/animation.pure.d.ts:130-135interfaceIAnimationKey{frame:number// 帧号value:any// 该帧的属性值}设置关键帧animation.setKeys([{frame:0,value:-5},{frame:15,value:0},{frame:30,value:5}])关键点帧号必须是整数value的类型必须和构造函数的dataType匹配关键帧不需要等间距引擎会自动插值26.4 原理三beginDirectAnimation 与播放控制26.4.1 beginDirectAnimation 的作用scene.beginDirectAnimation()是播放动画的主入口源码Animations/scene.pure.d.ts:180-195scene.beginDirectAnimation(target:any,// 动画目标对象animations:Animation[],// 动画数组from:number,// 起始帧to:number,// 结束帧loop?:boolean,// 是否循环speedRatio?:number,// 速度倍率onAnimationEnd?:()void// 结束回调):Animatable逐行解释target要动画的对象通常是网格或相机animations一个或多个Animation对象的数组from/to播放的帧范围loop是否循环播放speedRatio速度倍率1.0 正常速度2.0 两倍速onAnimationEnd动画结束时调用的回调返回值是Animatable对象可以用来控制动画暂停、停止等。26.4.2 Animatable 的控制方法Animatable对象提供以下方法源码Animations/animatable.pure.d.ts:25-50方法作用pause()暂停动画restart()重新开始stop()停止并清理goToFrame(frame)跳转到指定帧isPaused是否暂停属性isRunning是否正在运行属性26.4.3 同时动画多个属性如果你想同时动画位置和旋转需要创建两个Animation对象constpositionAnimnewAnimation(posAnim,position,30,Animation.ANIMATIONTYPE_VECTOR3,Animation.ANIMATIONLOOPMODE_CYCLE)positionAnim.setKeys([{frame:0,value:newVector3(-5,0,0)},{frame:30,value:newVector3(5,0,0)}])constrotationAnimnewAnimation(rotAnim,rotation.y,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)rotationAnim.setKeys([{frame:0,value:0},{frame:30,value:Math.PI*2}])// 同时播放scene.beginDirectAnimation(box,[positionAnim,rotationAnim],0,30,true)关键点两个动画的framePerSecond应该相同否则它们的同步会乱掉。26.5 最小可运行十行基础位移动画import{Animation,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)// 创建动画constanimnewAnimation(moveAnim,position.x,30,// 帧率 30 FPSAnimation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)// 设置关键帧anim.setKeys([{frame:0,value:-5},{frame:30,value:5}])// 添加到网格box.animations.push(anim)// 播放动画scene.beginAnimation(box,0,30,true)engine.runRenderLoop(()scene.render())逐行解释new Animation(...)创建动画对象动画position.x属性anim.setKeys([...])设置关键帧从 -5 到 5box.animations.push(anim)把动画加到网格的动画列表scene.beginAnimation(box, 0, 30, true)播放动画从帧 0 到 30循环注意这里用的是scene.beginAnimation()而不是beginDirectAnimation()。前者要求动画已经在目标的animations数组里后者可以直接传动画数组。两者效果一样只是 API 风格不同。旋转动画constrotAnimnewAnimation(rotAnim,rotation.y,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)rotAnim.setKeys([{frame:0,value:0},{frame:30,value:Math.PI*2}])box.animations.push(rotAnim)scene.beginAnimation(box,0,30,true)暂停和继续constanimatablescene.beginAnimation(box,0,30,true)// 暂停animatable.pause()// 继续animatable.restart()// 或用 animatable.isPaused false26.6 逐步加厚从单一动画到完整控制系统这一节请照着敲、并且真的把每一步跑一遍。步 0只建基础位移动画constboxMeshBuilder.CreateBox(mesh-box,{size:1},scene)constanimnewAnimation(moveAnim,position.x,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)anim.setKeys([{frame:0,value:-5},{frame:30,value:5}])box.animations.push(anim)scene.beginAnimation(box,0,30,true)打开页面你会看到红色立方体从左向右移动然后循环。步 1加旋转动画constrotAnimnewAnimation(rotAnim,rotation.y,30,Animation.ANIMATIONTYPE_FLOAT,Animation.ANIMATIONLOOPMODE_CYCLE)rotAnim.setKeys([{frame:0,value:0},{frame:30,value:Math.PI*2}])box.animations.push(rotAnim)现在立方体同时做位移和旋转。步 2加四元数旋转避免万向锁constquatAnimnewAnimation(quatAnim,rotationQuaternion,30,Animation.ANIMATIONTYPE_QUATERNION,Animation.ANIMATIONLOOPMODE_CYCLE)quatAnim.setKeys([{frame:0,value:Quaternion.FromEulerAngles(0,0,0)},{frame:30,value:Quaternion.FromEulerAngles(0,Math.PI*2,0)}])box.rotationQuaternionnewQuaternion()// 初始化box.animations.push(quatAnim)注意如果要用四元数动画必须先初始化mesh.rotationQuaternion否则引擎会用欧拉角导致冲突。步 3加控制面板在 HTML 里加按钮buttonidbtn-pause暂停/buttonbuttonidbtn-resume继续/buttonbuttonidbtn-stop停止/buttonlabel速度inputtyperangeidspeedmin0.5max2step0.5value1/label在 TS 里监听letanimatable:Animatable document.getElementById(btn-pause)!.addEventListener(click,(){animatable.pause()})document.getElementById(btn-resume)!.addEventListener(click,(){animatable.restart()})document.getElementById(btn-stop)!.addEventListener(click,(){animatable.stop()})document.getElementById(speed)!.addEventListener(input,(e){constspeedparseFloat((e.targetasHTMLInputElement).value)animatable.speedRatiospeed})步 4显示当前帧号consthuddocument.getElementById(hud)!scene.onBeforeRenderObservable.add((){if(animatableanimatable.isRunning){constcurrentFrameanimatable.getAnimations()[0]?.currentFrame??0hud.innerHTMLFrame:${currentFrame.toFixed(1)}| Status:${animatable.isPaused?paused:playing}}})步 5本章终态到这里你就写出了src/chapters/ch26-keyframe-animation.ts的全部内容。它在最小版之上多了三种动画类型的演示位移、旋转、四元数完整的播放控制暂停、继续、停止、调速实时帧号和状态显示26.7 预期输出与自查成功标志现象说明立方体从左向右移动并循环位移动画生效立方体同时旋转旋转动画生效点击暂停按钮动画停止暂停功能生效调整速度滑块动画变速速度控制生效HUD 显示当前帧号帧号读取正确失败排查现象可能原因修复动画不播放没调用beginAnimation()检查播放调用四元数动画无效没初始化rotationQuaternion加box.rotationQuaternion new Quaternion()暂停后无法继续用了pause()但继续时用错方法用restart()或设isPaused false速度调整无效没设animatable.speedRatio检查赋值语句26.8 坑与出处26-01欧拉角和四元数不能混用版本9.30.0报错原文无报错但旋转异常复现步骤box.rotation.y0// 用欧拉角constanimnewAnimation(anim,rotationQuaternion,...)// 动画四元数现象旋转混乱物体抖动或翻转错误。原因Babylon 内部用rotationQuaternion或rotation其中之一不能同时设。如果设了rotationQuaternionrotation会被忽略。修复统一用一种表示方式。如果要动画旋转推荐用四元数。源码出处Meshes/abstractMesh.pure.js:1890-1900——rotation和rotationQuaternion的 setter 会互相覆盖。26-02beginAnimation 要求动画在 animations 数组里版本9.30.0报错原文无报错但动画不播放复现步骤constanimnewAnimation(...)// 没加到 box.animationsscene.beginAnimation(box,0,30,true)现象动画不播放。原因beginAnimation()只播放目标对象animations数组里的动画。修复box.animations.push(anim)或用beginDirectAnimation(box, [anim], ...)。源码出处Animations/scene.pure.js:210-215——beginAnimation()遍历target.animations。26-03关键帧的 value 类型必须匹配 dataType版本9.30.0报错原文运行时错误或静默失败复现步骤constanimnewAnimation(anim,position.x,30,Animation.ANIMATIONTYPE_FLOAT)anim.setKeys([{frame:0,value:newVector3(0,0,0)}// 错误应该是数字])现象动画不工作或报错。原因dataType是FLOAT但value是Vector3类型不匹配。修复确保value的类型和dataType一致。源码出处Animations/animation.pure.js:150-160——插值函数根据dataType选择算法。26-04pause() 后不能用 restart() 恢复原位版本9.30.0报错原文无报错但行为不符合预期复现步骤animatable.pause()// 暂停animatable.restart()// 想继续但回到了起始帧现象动画从第 0 帧重新开始不是从暂停处继续。原因restart()的设计就是重置到起始帧。要继续播放应该设animatable.isPaused false。修复animatable.pause()// ...animatable.isPausedfalse// 继续播放源码出处Animations/animatable.pure.js:80-85——restart()调用reset()。26.9 章节挑战任务做一个弹跳球动画。要求场景中有一个球体用关键帧动画让球做弹跳运动从 y0 上升到 y5再落回 y0循环下落时加速上升时减速用非均匀的关键帧分布模拟重力同时让球在 x 方向匀速移动加一个慢动作按钮把动画速度降到 0.25x提示弹跳的关键帧可以这样分布{ frame: 0, value: 0 }, { frame: 15, value: 5 }, { frame: 30, value: 0 }为了让下落加速可以在中间加更多关键帧或用缓动函数第 27 章x 方向的位移动画用线性关键帧参考答案见附录 F。26.10 本章小结本章你学会了插值的本质在两个关键帧之间按比例混合生成中间值Animation 类的结构构造函数参数、数据类型、循环模式关键帧表的构建用setKeys()定义关键时刻的状态beginDirectAnimation 与播放控制播放、暂停、停止、调速下一章我们会讲缓动、混合与状态机如何用 Easing 函数让动画更自然以及如何用AnimationStateMachine管理复杂的动画切换。术语卡中文English一句话解释关键帧Keyframe定义动画关键时刻状态的帧插值Interpolation在两个值之间计算中间值线性插值Linear Interpolation匀速变化的插值方式帧率Frame Per Second (FPS)每秒的帧数决定动画速度循环模式Loop Mode动画结束后的行为相对/周期/常数四元数Quaternion避免万向锁的旋转表示方式可动画对象Animatable正在播放动画的对象速度倍率Speed Ratio动画播放速度的倍数