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

В реальном времени физический движок и система рендеринга работают с разной частотой обновления. В библиотеке Oimo.js шаг симуляции обычно фиксированный (например, 60 Гц), тогда как частота отрисовки зависит от requestAnimationFrame и может быть нестабильной. Это приводит к визуальным артефактам:

  • рывкам при низком FPS;
  • неравномерному движению объектов;
  • «дребезгу» камер, следящих за телами;
  • заметной ступенчатости перемещения.

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


Принцип фиксированного шага симуляции в Oimo.js

В Oimo.js мир создаётся через объект World:

const world = new OIMO.World({
    timestep: 1 / 60,
    iterations: 8,
    broadphase: 2,
    worldscale: 1,
    random: false,
    info: false
});

Параметр timestep задаёт фиксированный шаг интегрирования. При корректной архитектуре физика обновляется строго с этим шагом, независимо от реального FPS:

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

function loop(deltaTime) {
    accumulator += deltaTime;

    while (accumulator >= fixedTimeStep) {
        world.step();
        accumulator -= fixedTimeStep;
    }
}

Без дополнительной обработки визуальные объекты будут получать данные только после каждого world.step(), что создаёт ступенчатое движение.


Концепция интерполяции

Интерполяция — это вычисление промежуточного состояния между двумя дискретными состояниями физического тела:

  • state_previous — трансформация на предыдущем шаге;
  • state_current — трансформация на текущем шаге;
  • alpha — коэффициент интерполяции в диапазоне [0, 1].

Формула линейной интерполяции позиции:

[ P_{render} = P_{previous} (1 - ) + P_{current} ]

Где:

[ = ]

Таким образом, визуализация отображает состояние, соответствующее реальному времени между шагами физики.


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

Oimo.js не хранит автоматически историю трансформаций для рендеринга. Поэтому необходимо сохранять предыдущее положение и ориентацию вручную.

Пример структуры для хранения:

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

        this.previousPosition = new THREE.Vector3();
        this.currentPosition = new THREE.Vector3();

        this.previousQuaternion = new THREE.Quaternion();
        this.currentQuaternion = new THREE.Quaternion();
    }

    updateFromPhysics() {
        // Сохранение текущего состояния как предыдущего
        this.previousPosition.copy(this.currentPosition);
        this.previousQuaternion.copy(this.currentQuaternion);

        // Получение нового состояния из Oimo
        const pos = this.body.getPosition();
        const quat = this.body.getQuaternion();

        this.currentPosition.set(pos.x, pos.y, pos.z);
        this.currentQuaternion.set(quat.x, quat.y, quat.z, quat.w);
    }
}

Метод updateFromPhysics() вызывается сразу после world.step().


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

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

function interpolatePosition(obj, alpha, target) {
    target.lerpVectors(
        obj.previousPosition,
        obj.currentPosition,
        alpha
    );
}

Где target — позиция визуального объекта (например, mesh.position).

Преимущества линейной интерполяции:

  • простота вычисления;
  • высокая производительность;
  • отсутствие накопления ошибок.

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

Для вращений линейная интерполяция неприменима из-за особенностей кватернионов. Используется сферическая интерполяция — SLERP:

function interpolateRotation(obj, alpha, targetQuaternion) {
    THREE.Quaternion.slerp(
        obj.previousQuaternion,
        obj.currentQuaternion,
        targetQuaternion,
        alpha
    );
}

SLERP обеспечивает:

  • равномерную угловую скорость;
  • корректное движение по кратчайшей дуге;
  • отсутствие искажений ориентации.

Интеграция в основной цикл

Полный цикл с интерполяцией:

let lastTime = performance.now();

function animate() {
    requestAnimationFrame(animate);

    const now = performance.now();
    const deltaTime = (now - lastTime) / 1000;
    lastTime = now;

    accumulator += deltaTime;

    while (accumulator >= fixedTimeStep) {
        world.step();

        physicsObjects.forEach(obj => {
            obj.updateFromPhysics();
        });

        accumulator -= fixedTimeStep;
    }

    const alpha = accumulator / fixedTimeStep;

    physicsObjects.forEach(obj => {
        interpolatePosition(obj, alpha, obj.mesh.position);
        interpolateRotation(obj, alpha, obj.mesh.quaternion);
    });

    renderer.render(scene, camera);
}

Такой подход обеспечивает визуально плавное движение даже при нестабильном FPS.


Почему интерполяция предпочтительнее экстраполяции

Существует альтернативный метод — экстраполяция, при которой вычисляется предполагаемое будущее положение на основе текущей скорости:

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

Однако в физическом движке с коллизиями экстраполяция может вызывать:

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

Интерполяция использует уже подтверждённые состояния и не нарушает целостность симуляции.


Интерполяция и спящие тела

В Oimo.js тела могут переходить в режим сна (sleep), если их скорость ниже порога. При пробуждении возможен резкий скачок трансформации.

Корректная стратегия:

  • при пробуждении сбрасывать previousState = currentState;
  • не выполнять интерполяцию, если тело только что активировано;
  • контролировать флаг body.sleeping.

Это устраняет резкие визуальные скачки.


Интерполяция для камер и следящих объектов

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

Порядок действий:

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

Числовая стабильность

При длительной работе возможно накопление ошибок в accumulator. Рекомендуется:

accumulator = Math.min(accumulator, fixedTimeStep);

Это предотвращает чрезмерную интерполяцию при просадке FPS.

Также следует избегать использования переменного timestep в world.step(), так как это:

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

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

Интерполяция добавляет вычислительную нагрузку:

  • один LERP для позиции;
  • один SLERP для вращения.

Даже при сотнях объектов нагрузка остаётся минимальной по сравнению с расчётом коллизий.

Оптимизация возможна:

  • пропуск интерполяции для спящих тел;
  • объединение обновлений в векторных структурах;
  • хранение чисел в Float32Array.

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

1. Интерполяция после рендера Нарушает временную последовательность и вызывает один кадр задержки.

2. Использование текущей позиции вместо предыдущей Приводит к отсутствию сглаживания.

3. Смешивание интерполяции и переменного шага Создаёт двойную компенсацию времени.

4. Неверная работа с кватернионами Попытка линейной интерполяции вращений вызывает артефакты.


Связь с архитектурой ECS

В архитектурах на основе Entity Component System интерполяция обычно реализуется как отдельная система:

  • PhysicsSystem — обновляет состояния.
  • InterpolationSystem — вычисляет визуальные трансформации.
  • RenderSystem — выполняет отрисовку.

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


Результат применения интерполяции

Корректная интерполяция трансформаций в Oimo.js:

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

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