Физический движок, лежащий в основе Ammo.js, выполняет расчёты с фиксированным шагом времени (fixed timestep). Обычно это 1/60 секунды или 1/120 секунды. Рендеринг сцены при этом может происходить с другой частотой — 60, 144 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 = 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() сохраняется текущее
состояние.
Позиция интерполируется по формуле:
[ 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), чтобы избежать:
В 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:
btRigidBody) отвечает только за
расчёт;THREE.Mesh отвечает за отображение;mesh, а не к
физическому телу.Это принципиально важно: физика всегда остаётся дискретной и детерминированной, визуализация — плавной.
В нативном Bullet Physics существует механизм
interpolationWorldTransform. Ammo.js частично его
поддерживает, но полагаться на него не рекомендуется по причинам:
Ручная интерполяция даёт:
Если камера привязана к физическому объекту (например, персонажу), её также следует интерполировать. Иначе возникает эффект «подрагивания», несмотря на сглаженные модели.
Принцип тот же:
Иногда применяется экстраполяция:
[ P_{next} = P_{current} + V dt]
Однако при столкновениях она приводит к:
Интерполяция всегда безопаснее, поскольку работает только с подтверждёнными физическими состояниями.
1. Интерполяция самого физического тела Изменение
transform btRigidBody нарушает детерминированность.
2. Сохранение состояния после шага, а не до него Предыдущее состояние должно отражать точку до расчёта.
3. Использование LERP для кватернионов Это приводит к неравномерной скорости вращения.
4. Зависимость fixedTimeStep от FPS Физический шаг должен быть константой.
Интерполяция:
stepSimulation().Оптимизация:
Такой подход обеспечивает:
Интерполяция состояний является обязательным элементом архитектуры любого проекта, использующего Ammo.js в реальном времени.