Интерполяция состояний для плавной анимации

Физический движок, лежащий в основе Ammo.js, выполняет расчёты с фиксированным шагом времени (fixed timestep). Обычно это 1/60 секунды или 1/120 секунды. Рендеринг сцены при этом может происходить с другой частотой — 60, 144 FPS или нестабильно меняющимся значением.

Если визуализация берёт состояние объектов напрямую из физического мира на каждом кадре, возникают:

  • микроподёргивания при нестабильном FPS;
  • «телепортация» при низкой частоте симуляции;
  • рассинхронизация между визуальным и физическим состоянием.

Интерполяция решает эту проблему, создавая промежуточное состояние между двумя шагами симуляции, не вмешиваясь в физический расчёт.


Фиксированный шаг симуляции

Типичный цикл обновления физики:

const fixedTimeStep = 1 / 60;
let accumulator = 0;

function upd ate(deltaTime) {
    accumulator += deltaTime;

    while (accumulator >= fixedTimeStep) {
        physicsWorld.stepSimulation(fixedTimeStep, 10);
        accumulator -= fixedTimeStep;
    }
}

Физический мир в Bullet Physics (портирован в Ammo.js через Emscripten) предполагает именно такой режим работы. Однако между последним выполненным шагом и текущим временем рендера всегда существует остаток accumulator.

Этот остаток используется как коэффициент интерполяции.


Интерполяционный коэффициент (alpha)

После выполнения всех шагов симуляции остаётся:

alpha = accumulator / fixedTimeStep;

alpha принимает значение в диапазоне [0, 1) и показывает, насколько далеко текущее время продвинулось от последнего шага физики к следующему.

Интерполяция выполняется между:

  • предыдущим физическим состоянием;
  • текущим физическим состоянием.

Хранение предыдущего состояния

Ammo.js не хранит автоматически историю состояний, поэтому требуется собственная структура:

class PhysicsObject {
    constructor(body, mesh) {
        this.body = body;
        this.mesh = mesh;

        this.previousPosition = new Ammo.btVector3();
        this.previousRotation = new Ammo.btQuaternion();
    }

    savePreviousState() {
        const transform = this.body.getWorldTransform();
        this.previousPosition.setValue(
            transform.getOrigin().x(),
            transform.getOrigin().y(),
            transform.getOrigin().z()
        );

        this.previousRotation.setValue(
            transform.getRotation().x(),
            transform.getRotation().y(),
            transform.getRotation().z(),
            transform.getRotation().w()
        );
    }
}

Перед каждым stepSimulation() сохраняется текущее состояние.


Линейная интерполяция позиции (LERP)

Позиция интерполируется по формуле:

[ P = P_{prev} + (P_{current} - P_{prev})]

Реализация:

function lerp(a, b, alpha) {
    return a + (b - a) * alpha;
}

function interpolatePosition(prev, current, alpha) {
    return {
        x: lerp(prev.x, current.x, alpha),
        y: lerp(prev.y, current.y, alpha),
        z: lerp(prev.z, current.z, alpha)
    };
}

Линейная интерполяция подходит для координат, но не для вращения.


Интерполяция вращения (SLERP)

Кватернионы требуют сферической линейной интерполяции (SLERP), чтобы избежать:

  • искажения скорости вращения;
  • потери нормализации;
  • «переворотов».

В Ammo.js используется btQuaternion, но удобнее копировать значения в собственную структуру.

Пример SLERP (упрощённый):

function slerp(q1, q2, alpha) {
    let cosHalfTheta = 
        q1.w * q2.w + 
        q1.x * q2.x + 
        q1.y * q2.y + 
        q1.z * q2.z;

    if (Math.abs(cosHalfTheta) >= 1.0) {
        return q1;
    }

    const halfTheta = Math.acos(cosHalfTheta);
    const sinHalfTheta = Math.sqrt(1.0 - cosHalfTheta * cosHalfTheta);

    if (Math.abs(sinHalfTheta) < 0.001) {
        return {
            w: 0.5 * (q1.w + q2.w),
            x: 0.5 * (q1.x + q2.x),
            y: 0.5 * (q1.y + q2.y),
            z: 0.5 * (q1.z + q2.z)
        };
    }

    const ratioA = Math.sin((1 - alpha) * halfTheta) / sinHalfTheta;
    const ratioB = Math.sin(alpha * halfTheta) / sinHalfTheta;

    return {
        w: q1.w * ratioA + q2.w * ratioB,
        x: q1.x * ratioA + q2.x * ratioB,
        y: q1.y * ratioA + q2.y * ratioB,
        z: q1.z * ratioA + q2.z * ratioB
    };
}

Полный цикл: физика + интерполяция + рендер

Структура обновления:

function gameLoop(deltaTime) {
    accumulator += deltaTime;

    while (accumulator >= fixedTimeStep) {
        for (const obj of objects) {
            obj.savePreviousState();
        }

        physicsWorld.stepSimulation(fixedTimeStep, 10);
        accumulator -= fixedTimeStep;
    }

    const alpha = accumulator / fixedTimeStep;

    for (const obj of objects) {
        renderInterpolated(obj, alpha);
    }
}

Где:

function renderInterpolated(obj, alpha) {
    const transform = obj.body.getWorldTransform();
    const currentPos = transform.getOrigin();
    const currentRot = transform.getRotation();

    const interpolatedPos = interpolatePosition(
        obj.previousPosition,
        currentPos,
        alpha
    );

    const interpolatedRot = slerp(
        obj.previousRotation,
        currentRot,
        alpha
    );

    obj.mesh.position.se t(
        interpolatedPos.x,
        interpolatedPos.y,
        interpolatedPos.z
    );

    obj.mesh.quaternion.set(
        interpolatedRot.x,
        interpolatedRot.y,
        interpolatedRot.z,
        interpolatedRot.w
    );
}

Интерполяция и интеграция с Three.js

При работе с Three.js:

  • физический объект (btRigidBody) отвечает только за расчёт;
  • THREE.Mesh отвечает за отображение;
  • интерполяция применяется только к mesh, а не к физическому телу.

Это принципиально важно: физика всегда остаётся дискретной и детерминированной, визуализация — плавной.


Встроенная интерполяция Bullet

В нативном Bullet Physics существует механизм interpolationWorldTransform. Ammo.js частично его поддерживает, но полагаться на него не рекомендуется по причинам:

  • ограниченная документация;
  • различия в порте;
  • отсутствие гибкого контроля.

Ручная интерполяция даёт:

  • полное понимание состояния;
  • возможность фильтрации;
  • контроль над камерой и дочерними объектами.

Интерполяция камеры

Если камера привязана к физическому объекту (например, персонажу), её также следует интерполировать. Иначе возникает эффект «подрагивания», несмотря на сглаженные модели.

Принцип тот же:

  1. хранение предыдущего состояния цели;
  2. вычисление alpha;
  3. LERP позиции;
  4. SLERP вращения.

Экстраполяция как альтернатива

Иногда применяется экстраполяция:

[ P_{next} = P_{current} + V dt]

Однако при столкновениях она приводит к:

  • проникновению объектов;
  • резким корректировкам;
  • визуальным скачкам.

Интерполяция всегда безопаснее, поскольку работает только с подтверждёнными физическими состояниями.


Типичные ошибки

1. Интерполяция самого физического тела Изменение transform btRigidBody нарушает детерминированность.

2. Сохранение состояния после шага, а не до него Предыдущее состояние должно отражать точку до расчёта.

3. Использование LERP для кватернионов Это приводит к неравномерной скорости вращения.

4. Зависимость fixedTimeStep от FPS Физический шаг должен быть константой.


Производительность

Интерполяция:

  • добавляет O(N) операций на кадр;
  • не влияет на стабильность физики;
  • почти не нагружает CPU по сравнению с stepSimulation().

Оптимизация:

  • использовать временные объекты вместо создания новых;
  • избегать частого выделения памяти;
  • кэшировать btTransform.

Схема взаимодействия систем

  1. Накопление времени.
  2. Несколько фиксированных шагов физики.
  3. Вычисление alpha.
  4. Интерполяция визуальных компонентов.
  5. Рендер.

Такой подход обеспечивает:

  • стабильную физику;
  • плавную анимацию;
  • независимость частоты симуляции от частоты рендера;
  • предсказуемость поведения в сетевых и многопользовательских сценариях.

Интерполяция состояний является обязательным элементом архитектуры любого проекта, использующего Ammo.js в реальном времени.