全网最详Babylon.js入门教材(10)-动画系统

avatar
SugarTurbos Club 成员

Q:Babylon.js是什么?🤔️

Babylon.js 是一个强大的、开源的、基于 WebGLWebGPU3D引擎,用于在网页上创建和渲染 3D图形。它提供了一套丰富的 API和功能,包括物理引擎、粒子系统、骨骼动画、碰撞检测、光照和阴影等,可以帮助开发者快速创建复杂的 3D场景和交互。

Q:我为什么要写该系列的教材? 🤔️

因为公司业务的需要因而要在项目中使用到 Babylon.js,虽然官方的文档看起来覆盖面都挺全,且 playgroud 上的案例也都比较多,但一些具体的 API 或者功能属性也都没有特别多详细的介绍,包括很多使用方式的很多坑都得自己去源码中或者论坛上找。在将其琢磨完之后, 决定写一系列关于它的教材来帮助更多 babylon.js的使用者或者是期于学习 Web 3D的开发者。同时也是自己对其的一种巩固。

Babylon.js中的动画系统

动画是 3D 应用和游戏开发中的重要组成部分,通过动画可以使场景更加生动和互动。本篇文章我将详细讲解 Babylon.js 中的关键帧动画,包括其基本概念、创建步骤、常见用法以及实际案例。

关键帧动画及插值的概念

在正式开讲之前,还是有一些概念要对齐一下的。首先就是关键帧动画。对音视频稍微熟悉一些的朋友应该知道,视频实际上就是有一张张的图片(帧)快速播放,3D 物体的动画也类似,我们想让 3D 物体动起来,起码要定义一些时间点,然后在这个时间点上,物体应该处于哪个状态,如下,展示了物体第0秒的位置在 (0,0,0),第一秒的位置在 (2, 0, 0)

如果这个动画只有两种状态(两帧)的话,第一秒的时候物体直接瞬移到 (2, 0, 0)的位置上,那肯定会非常生硬。所以比较好的办法就是在 0~1秒这个时间内,再加入一些其它的帧,例如 x坐标为 0.5 ... 、1 ...、1.5 ...,这样我们就可以看到一秒内,物体是慢慢慢慢地到 x = 2的这个位置的,让整个过程变得非常丝滑。

像这种在动画序列中,表示对象在特定时间点上的状态或属性值的帧,例如第 0 秒和 第 1 秒,就是关键帧。

而在两个关键帧之间计算中间状态,以生成平滑的过渡效果,就被称为插值

什么是帧率?

好,现在我们了解了什么是关键帧动画,也了解了插值,那这些关键帧之间的中间状态总不可能我们自己去定义吧。那得定义多少...

别慌,使用 Babylon.js 的话开发者是不用担心这个问题的,你只需要定义好你的关键帧,剩下的交给 Babylon.js 就行。例如我下面就定义了这两个关键帧,我希望第 0 帧状态为 0, 第30帧的时候,状态为 2

const keyFrames = []; // 这个数组可以简单理解为放关键帧的

keyFrames.push({
  frame: 0, // frame 表示第几帧
  value: 0, // value 表示物体的 x 的值
});

keyFrames.push({
  frame: 30,
  value: 2,
});

诶~但实际开发的时候,我们关注的肯定不是第几帧第几帧,而是以时间(秒或者毫秒)来指定这个动画执行多久。这时候,我们就需要把时间与帧数关联起来,例如如果能规定一秒能执行多少帧,那是不是就能用时间来换算出帧数了呢?

比如我们指定一秒必须执行 10帧,那添加到 keyFrames数组里的 frame30就表示是第三秒了。

这种表示每秒钟播放的帧数,我们称之为 frameRate(帧率),也就是常听到的 FPS

(一)帧率小练习

来看个小练习,加深一下理解。如下所示,定义帧率(30)和动画执行时间(3秒),我希望物体在:

  • 第1秒的时候,x1
  • 第2秒的时候,x1.5
  • 第3秒的时候,x2
const frameRate = 10; // 帧率, 每秒执行多少帧
const duration = 3; // 动画执行的时间, 单位秒

keyFrames.push({
  frame: 0,
  value: 0,
});

keyFrames.push(...);

keyFrames.push(...);

keyFrames.push(...);

思考一下,keyFrames.push(...)应该怎么添加?

---------------------------- 手动分割线 ----------------------------

揭晓答案:

const frameRate = 10; // 帧率, 每秒执行多少帧
const duration = 3; // 动画执行的时间, 单位秒

keyFrames.push({
  frame: 0,
  value: 0,
});

keyFrames.push({
  frame: 1 * frameRate, // 10
  value: 1,
});

keyFrames.push({
  frame: 2 * frameRate, // 20
  value: 1.5,
});

keyFrames.push({
  frame: 3 * frameRate, // 30
  value: 2,
});

相信你们,这就不需要我多做解释了吧 [微笑~]

(二)帧率设置多少比较合适?

通过上面的介绍,我们知道了,帧率实际上表示的就是每秒播放的帧数,那播放的帧数越多,动画的流畅度肯定就越好,随之而来的对CPUGPU还与内存的要求肯定就越大。所以在理想情况下我们肯定是希望帧率越高越好,越高你的流畅度越高,体验肯定越好,但你的硬件也得能支持上才行呀~

一般来说,有以下几种常见的帧率值:

  • 24 FPS:电影和传统动画。这是电影工业标准,适用于大多数电影和传统动画制作。
  • 30 FPS:常用于电视节目和网络视频,提供较为流畅的观看体验。
  • 60 FPS:提供非常流畅的视觉效果,特别适合需要快速响应和高互动性的应用,如游戏。
  • 120 FPS:极其流畅,但对硬件要求较高,通常用于高端显示设备或VR头显。

对于大多数 Web 3D应用和动画 ,30FPS 是一个合理的起点 ,既能提供足够流畅的视觉效果,又不会给 硬件带来太大压力。如果你的应用是高度交互性的游戏,比如高端游戏、VR应用,60FPS可能会更合适一些。

(三)帧率与硬件及屏幕刷新率的关系

我们知道,屏幕刷新率(Refresh Rate)表示显示器每秒钟刷新的次数,以赫兹(Hz)为单位。例如,60Hz表示每秒钟刷新60次。理想情况下,帧率应与屏幕刷新率匹配,以避免图像撕裂(Tearing)现象。如果帧率超过屏幕刷新率,多余的帧将无法显示;如果低于屏幕刷新率,则可能导致卡顿或不流畅。

除此之外,就还需要考虑硬件的性能了,特别是在复杂场景或高分辨率下,高帧率会显著地增加计算负担,反而可能会带来更差的体验。

在 Babylon.js 中使用关键帧动画

建立好了关键帧动画的思维体系,下面的学习就事倍功半了,无非就是看看 Babylon.js 要怎么利用它提供的 API来定义、执行动画而已。

使用 Animation 类来创建动画

第一个要介绍的是最简单的一种方式,利用BABYLON.Animation 类来实现。

例如一个 box(通过BABYLON.MeshBuilder.CreateBox("box", {});来创建),去实现上面我们说的动画:

  • 第1秒的时候,x1
  • 第2秒的时候,x1.5
  • 第3秒的时候,x2

要经过这么几个步骤:

(一)创建一个 BABYLON.Animation 对象,并指定要改变的属性及其变化范围和时间节点:

const frameRate = 30;

const animation = new BABYLON.Animation(
    "animation", // 动画名称
    "position.x", // 要改变的属性
    frameRate, // 帧率
    BABYLON.Animation.ANIMATIONTYPE_FLOAT, // 属性类型
    BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE // 循环模式
);

(二) Animation 对象添加一组关键帧,指定在特定时间点上的属性值:

const keyFrames = []; 

keyFrames.push({
  frame: 0,
  value: 0,
});

keyFrames.push({
  frame: 1 * frameRate, // 30
  value: 1,
});

keyFrames.push({
  frame: 2 * frameRate, // 60
  value: 1.5,
});

keyFrames.push({
  frame: 3 * frameRate, // 90
  value: 2,
});

// 这步很重要!!!
animation.setKeys(keyFrames);

(三)应用到目标对象:

请注意,在经过了咱上面的一系列操作之后,box和这个 animation都是没有关系的,我们要主动将 animation添加到 boxanimations中,:

box.animations.push(animation);

(四)使用 scene.beginAnimation来启动动画:

scene.beginAnimation(box, 0, 3 * frameRate, true);

完成上面的步骤之后,最终的效果如下:

完整代码查看:playground.babylonjs.com/#7V0Y1I#427…

Animation 类

虽然通过上面的四个步骤我们成功让 box动起来了,但 Animation这个类还是得详细说一下。它的主要作用就是帮开发者创建各种复杂的动画效果,如平移、旋转、缩放等。

参数及定义如下:

const animation = new BABYLON.Animation(
    name, // 动画名称
    targetProperty, // 要改变的属性
    frameRate, // 帧率 (frames per second)
    dataType, // 属性类型 (如 ANIMATIONTYPE_FLOAT)
    loopMode // 循环模式 (如 ANIMATIONLOOPMODE_CYCLE)
);

参数的说明:

  • name:字符串,动画名称。
  • targetProperty:字符串,要改变的属性(如 "position", "rotation.y")。
  • frameRate:数字,每秒钟播放的帧数。
  • dataType:数字,属性类型,可以是以下之一:
    • 浮点数:BABYLON.Animation.ANIMATIONTYPE_FLOAT
    • 二维向量:BABYLON.Animation.ANIMATIONTYPE_VECTOR2
    • 三维向量:BABYLON.Animation.ANIMATIONTYPE_VECTOR3
    • 四元数:BABYLON.Animation.ANIMATIONTYPE_QUATERNION
    • 颜色:BABYLON.Animation.ANIMATIONTYPE_COLOR3
  • loopMode:数字,循环模式,可以是以下之一:
    • BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE(默认为循环模式)
    • BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
    • BABYLON.Animation.ANIMATIONLOOPMODE_RELATIVE
    • BABYLON.Animation.ANIMATIONLOOPMODE_YOYO
    • BABYLON.Animation.ANIMATIONLOOPMODE_RELATIVE_FROM_CURRENT

targetProperty和dataType

其中的一些参数需要额外说明一下。

例如 targetProperty属性,它实际上就是定义了你的对象的哪个属性发生动画效果,同时这个属性是个什么类型的,也需要在第四个参数 dataType中告诉 Babylon.js。

就以平移为例,如果你只想要某个物体做 x方向的平移动画的话,那么 targetProperty就定义 position.x,同时 dataType定义 BABYLON.Animation.ANIMATIONTYPE_FLOAT,表明这个属性是一个浮点数。

而如果你是想要某个物体在各个方向上都发生平移动画,targetProperty就可以定义为 position,然后dataType定义 BABYLON.Animation.ANIMATIONTYPE_VECTOR3,表明它是一个 Vector3类型的属性。

const animation = new BABYLON.Animation(
    "animation",
    "position.x", // 要改变的属性为 position.x
    frameRate,
    BABYLON.Animation.ANIMATIONTYPE_FLOAT, // 为浮点数
    BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE
);

const animation = new BABYLON.Animation(
    "animation",
    "position", // 要改变的属性为 position
    frameRate,
    BABYLON.Animation.ANIMATIONTYPE_VECTOR3, // 为 Vector3
    BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE
);

在线查看地址:playground.babylonjs.com/#7V0Y1I#427…

还有其它的一些常用的属性动画如下:

// y 方向旋转:
"rotation.y"
BABYLON.Animation.ANIMATIONTYPE_FLOAT

// x 方向缩放:
"scaling.x"
BABYLON.Animation.ANIMATIONTYPE_FLOAT

// 各个方向都缩放:
"scaling"
BABYLON.Animation.ANIMATIONTYPE_VECTOR3

在线查看地址:playground.babylonjs.com/#7V0Y1I#427…

loopMode

另外就是动画的循环类型这个参数了。默认情况下,是循环模式的(BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE)。动画播放完后,会从头开始继续播放。

如果只想动画执行一次的话,可以设置为 BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT

除了上面两个常用的,还有两个模式也可以介绍一下。

一个是:BABYLON.Animation.ANIMATIONLOOPMODE_YOYO。看着名字很可爱的样子,"YOYO",它会使动画在每次循环时反向播放,从而产生往返运动的效果:

这种模式非常适合模拟诸如钟摆、弹簧等需要往返运动的场景。

另一个:BABYLON.Animation.ANIMATIONLOOPMODE_RELATIVE。它的效果是:当动画到达终点时,下一次循环将从当前结束状态开始,并且属性值会继续增加或减少。

有点像是这个动画不会停了,会继续在前一次的结束状态进行增量变化:

这种模式特别适合用于需要连续运动或增长的场景,如物体沿某个方向无限移动、旋转角度不断增加等,最典型的就是一个人物模型的腿,不停的在走。

以上案例在线查看地址:playground.babylonjs.com/#7V0Y1I#427…

定义多个动画

在上面我们学习了,要把某个动画和目标对象联系在一起,是通过给目标对象的 animations里添加动画的方式。这个目标对象的定义实际上非常广泛,不仅仅是 mesh这种网格对象,还有灯光、材质、相机等对象都具有默认定义的动画数组属性。

既然动画属性是一个数组,那么也就意味着我们可以向同一个目标对象上定义多个动画。

例如我想让 box 一边旋转,一边位移:

var frameRate = 10;

var xSlide = new BABYLON.Animation("xSlide", "position.x", frameRate, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
// ...省略定义 keyFrames 的代码

var yRot = new BABYLON.Animation("yRot", "rotation.y", frameRate, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
// ...省略定义 keyFrames 的代码

// 向 box 的 animations 中添加俩个动画
box.animations.push(...[xSlide, yRot]);

// 开始播放动画
scene.beginAnimation(box, 0, 2 * frameRate, true);

效果如下:

在线查看地址:playground.babylonjs.com/#9WUJN#2621

开始动画的几种方式

scene.beginAnimation()

开始播放动画一种方式是上面介绍的 scene.beginAnimation(),接收一个目标对象,定义动画从第几帧开始执行,到第几帧结束。

scene.beginAnimation的参数说明:

scene.beginAnimation(target, from, to, loop);

前三个参数都没啥问题,第四个参数控制是否循环,那有人可能就会好奇了,如果前面定义 animation的时候,指定的 loopType不是循环属性(BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE),这个 beginAnimation的第四个参数能控制循环吗?

我试过了,需要 animationloopType为循环模式,同时 beginAimation的第四个参数设置为 true,循环才能生效。

这个方法会将目标对象上所有的动画都播放,如果你只想控制某些动画的执行,可能得用 scene.beginDirectAnimation()

scene.beginDirectAnimation()

参数说明:

scene.beginDirectAnimation(target, animations, from, to, loop);

beginAnimation的区别就是,多了一个 animations参数,传入指定的动画数组来进行播放。

并且,这些 animations可以不用被添加到 targetanimations属性上。

如果用它来改造上面的案例的话,可以这么写:

var frameRate = 10;

var xSlide = new BABYLON.Animation("xSlide", "position.x", frameRate, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
// ...省略定义 keyFrames 的代码

var yRot = new BABYLON.Animation("yRot", "rotation.y", frameRate, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
// ...省略定义 keyFrames 的代码

// 开始播放动画
scene.beginDirectAnimation(box, [xSlide, yRot], 0, 2 * frameRate, true);

在线查看地址:playground.babylonjs.com/#9WUJN#2621

动画的暂停、停止、重置

上面介绍的都是如何开始动画,那么如何暂停、停止动画呢?

这里有一个很重要的类是 Animatable。诶,它和 Animation是有一些区别的哈。

Animation 类是用来创建和配置具体的动画,包括设置目标属性、关键帧以及循环模式。而 Animatable 则用于实际运行这些已配置好的动画,并提供对其进行控制和管理的方法,比如播放、暂停、停止这些。

前面介绍的 scene.beginAnimation()scene.beginDirectAnimation()其实都是有返回值的,返回的就是一个 Animatable实例对象。我们可以拿到这个实例对象之后,自行进行存储,然后在有需要的时候调用它的相关方法控制动画。

它具有的方法也非常朴实:

  • pause():暂停
  • play():开始播放
  • restart():重新启动
  • stop():停止
  • reset():重置

这里提一下 restartreset的区别:

方法名restartreset
作用从头开始重新播放动画重置动画到初始状态但不自动播放
效果动画从起始帧重新开始,无论当前状态如何动画停止,属性值恢复到起始帧,需要手动启动
使用场景希望立即重新开始播放整个动画时使用希望将动画重置为初始状态但暂时不播放时使用

动画组 AnimationGroup

其实理解了 Animation之后,动画组也就不难理解了,可以将多个动画放到一个组里统一来控制处理。主要用于:

  • 同时控制多个动画
  • 统一管理动画的播放状态
  • 同步多个物体的动画
  • 提供统一的动画事件系统

使用 AnimationGroup 类来创建动画组

创建:

动画组的创建很简单:

const animationGroup1 = new BABYLON.AnimationGroup("Group1");

参数上面,除了第一个动画名称的参数以外,还有另外三个可选参数,分别是:

  • 场景 scene,不传的话默认为最新创建的那个场景
  • 权重 weight,默认为 -1,暂时没用到
  • 顺序 playOrder,默认为 0,暂时没用到

添加动画:

// 创建一个立方体
const box = BABYLON.MeshBuilder.CreateBox("box", {}, scene);
// 创建位置动画
const positionAnimation = new BABYLON.Animation(
    "positionAnimation",
    "position",
    30,
    BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
    BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE
);
// 省略设置关键帧的代码
// ...

// 将动画添加到动画组
animationGroup.addTargetedAnimation(positionAnimation, box);

// 播放动画组
animationGroup.play(true); // true表示循环播放

可以看到, positionAnimation是通过 addTargetedAnimation方法关联起来的,不需要额外指定把 positionAnimation放到 box.animations中。

多物体同步动画:

组的特性就是同一管理和控制,这也就可以让我们同时对多物体的动画做同步的控制。

例如我们把两个不同物体上的动画添加到一个组里,可以用 animationGroup来同时控制它们的播放暂停。

AnimationGroup 的基本操作

对于动画组的控制也比较简单,这里列出 API 即可:

const animationGroup = new BABYLON.AnimationGroup("controlGroup");
// ... 添加动画 ...

// 播放控制
animationGroup.play(); // 开始播放
animationGroup.pause(); // 暂停
animationGroup.reset(); // 重置到起始状态
animationGroup.stop(); // 停止并重置
animationGroup.restart(); // 重新开始播放

// 速度控制
animationGroup.speedRatio = 0.5; // 减慢播放速度
animationGroup.speedRatio = 2; // 加快播放速度

// 设置播放范围
animationGroup.start = 10; // 设置开始帧
animationGroup.end = 50; // 设置结束帧

AnimationGroup 的事件处理

另外,AnimationGroup也会有一些事件抛出,例如:

const animationGroup = new BABYLON.AnimationGroup("eventGroup");
// ... 添加动画 ...

// 动画开始事件
animationGroup.onAnimationGroupPlayObservable.add(() => {
    console.log("动画开始播放");
});

// 动画循环事件
animationGroup.onAnimationGroupLoopObservable.add(() => {
    console.log("动画完成一次循环");
});

// 动画结束事件
animationGroup.onAnimationGroupEndObservable.add(() => {
    console.log("动画播放结束");
});

// 动画暂停事件
animationGroup.onAnimationGroupPauseObservable.add(() => {
    console.log("动画暂停");
});

后语

知识无价,支持原创!学习了解了动画系统,我们的场景才有了动效和活力,也为后来学习一些交互打好基础。

喜欢霖呆呆的小伙伴还希望可以关注霖呆呆的公众号 LinDaiDai

我会不定时的更新一些前端方面的知识内容以及自己的原创文章🎉。

你的鼓励就是我持续创作的主要动力 😊。

其它相关文章推荐: