4、动画循环设计:游戏循环模式、状态管理、时间增量(delta time)计算

动画循环,说白了就是让画面动起来的那个「心脏」。你想想看,不管是游戏还是网页动画,本质上都是一帧一帧地刷新画面。但怎么刷、什么时候刷、刷多快,这里面的门道可不少。

我刚开始做动画的时候,觉得用 setInterval 就完事了。后来发现,这玩意儿坑太多。用户切换标签页、手机降频、系统卡顿……各种情况都会让动画变得一塌糊涂。今天我们就来聊聊,怎么设计一个靠谱的动画循环。

4.1 游戏循环模式:三种主流方案

动画循环的核心,就是「更新状态 → 渲染画面」这个死循环。但具体怎么实现,业内主要有三种模式。

4.1.1 固定时间步长(Fixed Timestep)

这种模式,我习惯叫它「铁饭碗」模式。不管实际帧率是多少,逻辑更新都按固定的时间间隔走。

const FIXED_DT = 1000 / 60; // 16.67ms
let accumulator = 0;
let lastTime = 0;

function gameLoop(currentTime) {
    let deltaTime = currentTime - lastTime;
    lastTime = currentTime;

    // 防止时间增量过大(比如切标签页回来)
    if (deltaTime > 100) deltaTime = 100;

    accumulator += deltaTime;

    // 按固定步长更新逻辑
    while (accumulator >= FIXED_DT) {
        update(FIXED_DT); // 物理、碰撞检测等
        accumulator -= FIXED_DT;
    }

    // 渲染可以插值,也可以直接渲染
    render(accumulator / FIXED_DT);

    requestAnimationFrame(gameLoop);
}

我在项目中遇到过一个问题:用固定步长做物理模拟,结果低端手机上卡成幻灯片。后来发现是 while 循环里积压了太多帧。嗯,这里要注意,一定要加一个最大帧数限制,否则就是灾难。

⚠️ 我曾经踩过的坑: 固定步长模式下,如果设备性能跟不上,accumulator 会无限累加,导致 while 循环卡死。一定要设置最大帧数上限,比如最多处理 5 帧。

4.1.2 可变时间步长(Variable Timestep)

这种模式更「随性」。每帧的实际时间差是多少,就用多少来更新逻辑。代码简单,但问题也明显。

let lastTime = 0;

function gameLoop(currentTime) {
    let deltaTime = (currentTime - lastTime) / 1000; // 转成秒
    lastTime = currentTime;

    // 直接用实际时间差更新
    update(deltaTime);
    render();

    requestAnimationFrame(gameLoop);
}

说白了,这种模式就是「有啥吃啥」。帧率高,更新就快;帧率低,更新就慢。好处是代码简单,坏处是物理模拟会变得不稳定。比如一个跳跳球,60帧时跳得正常,30帧时可能就穿模了。

4.1.3 半固定时间步长(Semi-Fixed Timestep)

这是我个人最推荐的模式。它结合了前两者的优点:逻辑更新用固定步长,渲染用可变步长。

const TICK_RATE = 1000 / 60;
let lastTime = 0;
let accumulator = 0;
let alpha = 0;

function gameLoop(currentTime) {
    let deltaTime = currentTime - lastTime;
    lastTime = currentTime;

    if (deltaTime > 100) deltaTime = 100;

    accumulator += deltaTime;

    while (accumulator >= TICK_RATE) {
        fixedUpdate(TICK_RATE);
        accumulator -= TICK_RATE;
    }

    alpha = accumulator / TICK_RATE;
    render(alpha); // 渲染时做插值

    requestAnimationFrame(gameLoop);
}

为什么推荐这个?因为物理模拟需要稳定的时间步长,而渲染需要平滑的视觉效果。半固定模式两者兼顾,我在做 Canvas 游戏引擎时一直用这个方案。

4.2 状态管理:动画的「大脑」

动画循环跑起来了,但状态怎么管?我见过不少新手把状态变量散落在全局,最后自己都搞不清哪个变量是干嘛的。

4.2.1 状态机的设计

一个典型的动画状态机,至少包含这几个状态:

状态 说明 触发条件
IDLE 空闲状态 动画未开始或已结束
RUNNING 运行中 调用 start()
PAUSED 暂停 调用 pause()
STOPPED 停止 调用 stop() 或自然结束
class AnimationStateMachine {
    constructor() {
        this.state = 'IDLE';
        this.transitions = {
            'IDLE': ['RUNNING'],
            'RUNNING': ['PAUSED', 'STOPPED'],
            'PAUSED': ['RUNNING', 'STOPPED'],
            'STOPPED': ['IDLE']
        };
    }

    transition(newState) {
        if (this.transitions[this.state].includes(newState)) {
            this.state = newState;
            console.log(`状态切换: ${this.state} → ${newState}`);
        } else {
            console.warn(`非法状态转换: ${this.state} → ${newState}`);
        }
    }
}
💡 我的习惯: 状态机一定要做非法转换检查。我曾经因为没做检查,导致动画在暂停状态下被重复 start,结果画面直接崩了。加一行检查,省心很多。

4.2.2 状态数据的组织

我建议把动画状态数据封装成一个对象,不要散落在各处。

class AnimationState {
    constructor() {
        this.position = { x: 0, y: 0 };
        this.rotation = 0;
        this.scale = { x: 1, y: 1 };
        this.opacity = 1;
        this.velocity = { x: 0, y: 0 };
        this.acceleration = { x: 0, y: 0 };
        this.elapsed = 0;
        this.duration = 1000;
        this.easing = 'easeInOut';
    }

    reset() {
        this.position = { x: 0, y: 0 };
        this.rotation = 0;
        this.scale = { x: 1, y: 1 };
        this.opacity = 1;
        this.velocity = { x: 0, y: 0 };
        this.acceleration = { x: 0, y: 0 };
        this.elapsed = 0;
    }
}

4.3 时间增量(delta time)计算

delta time 是动画循环里最重要的概念之一。它决定了动画的「速度」是否与帧率无关。

4.3.1 为什么需要 delta time?

你想想看,60帧的显示器,每帧间隔约16.67ms。但如果用户用的是144Hz的显示器,每帧间隔只有6.94ms。如果不做 delta time 处理,同样的动画在144Hz上会快一倍多。

说白了,delta time 就是让动画速度与帧率解耦。不管你是60帧还是144帧,动画播放的「实际时间」是一样的。

4.3.2 正确的 delta time 计算方式

let lastTimestamp = 0;
let deltaTime = 0;

function animationLoop(timestamp) {
    // 第一帧时,lastTimestamp 为0,需要特殊处理
    if (lastTimestamp === 0) {
        lastTimestamp = timestamp;
        deltaTime = 16.67; // 默认值
    } else {
        deltaTime = timestamp - lastTimestamp;
        lastTimestamp = timestamp;
    }

    // 限制 deltaTime 的最大值,防止切标签页后跳帧
    if (deltaTime > 100) {
        deltaTime = 100;
    }

    // 使用 deltaTime 更新动画
    update(deltaTime);
    render();

    requestAnimationFrame(animationLoop);
}
🔑 关键点: delta time 的单位是毫秒。如果你要做物理计算,建议转成秒(除以1000),这样加速度、速度的单位更直观。

4.3.3 常见坑点与避坑指南

  • 第一帧问题: requestAnimationFrame 的第一帧 timestamp 是随机的,不能直接用来计算 delta time。我习惯在第一帧时跳过计算,或者给一个默认值。
  • 标签页切换: 用户切到其他标签页再回来,delta time 可能非常大(几秒甚至几分钟)。一定要做上限限制,我一般设 100ms。
  • 精度问题: performance.now() 的精度是微秒级的,但 requestAnimationFrame 的 timestamp 是毫秒级的。如果需要高精度,建议用 performance.now() 自己算。
⚠️ 我曾经踩过的坑: 有一次做 H5 游戏,用户手机切到后台再切回来,delta time 直接飙到 3000ms。结果角色直接瞬移到了地图外面。从那以后,我所有动画循环都加了 delta time 上限。

4.4 实战:一个完整的动画循环框架

最后,我把上面讲的东西整合成一个完整的框架。这个框架我用了好几年,改了好几版,现在算是比较成熟了。

class AnimationEngine {
    constructor() {
        this.isRunning = false;
        this.lastTime = 0;
        this.accumulator = 0;
        this.fixedDt = 1000 / 60;
        this.maxFrameSkip = 5;
        this.state = new AnimationStateMachine();
        this.listeners = [];
    }

    start() {
        if (this.isRunning) return;
        this.isRunning = true;
        this.state.transition('RUNNING');
        this.lastTime = performance.now();
        this.loop(this.lastTime);
    }

    loop(currentTime) {
        if (!this.isRunning) return;

        let deltaTime = currentTime - this.lastTime;
        this.lastTime = currentTime;

        // 防止切标签页后跳帧
        if (deltaTime > 100) deltaTime = 100;

        this.accumulator += deltaTime;

        // 固定步长更新,最多处理 maxFrameSkip 帧
        let frameCount = 0;
        while (this.accumulator >= this.fixedDt && frameCount < this.maxFrameSkip) {
            this.fixedUpdate(this.fixedDt);
            this.accumulator -= this.fixedDt;
            frameCount++;
        }

        // 渲染插值
        let alpha = this.accumulator / this.fixedDt;
        this.render(alpha);

        requestAnimationFrame((time) => this.loop(time));
    }

    fixedUpdate(dt) {
        // 物理、碰撞检测等逻辑
        this.listeners.forEach(fn => fn(dt));
    }

    render(alpha) {
        // 渲染逻辑,alpha 用于插值
    }

    pause() {
        this.isRunning = false;
        this.state.transition('PAUSED');
    }

    resume() {
        this.start();
        this.state.transition('RUNNING');
    }

    stop() {
        this.isRunning = false;
        this.accumulator = 0;
        this.state.transition('STOPPED');
    }

    addUpdateListener(fn) {
        this.listeners.push(fn);
    }
}
💡 使用建议: 这个框架的核心是 fixedUpdaterender 分离。逻辑更新用固定步长,渲染用插值。这样既保证了物理模拟的稳定性,又保证了画面的平滑度。

好了,关于动画循环设计,今天就聊这么多。记住一句话:好的动画循环,是让用户感觉「流畅」,而不是让开发者感觉「简单」。下一章我们聊聊缓动函数,那才是让动画「活起来」的关键。