Working on interpolation

This commit is contained in:
Vladislav Khorev committed 2026-11-06 15:14:48 +06:00
1 parent 23fef6f349
commit 28846b9f25
8 files changed
+229 -49

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
# Character Presentation Interpolation
The authoritative game simulation still advances in exact 100 ms ticks. Character
interpolation is a rendering-only feature and does not modify `CharacterState`,
network commands, tick IDs, state hashes, or deterministic lockstep behavior.
## How it works
Each runtime `Character` keeps two presentation samples:
- `previous`: the visual values received at the preceding completed simulation tick;
- `current`: the visual values received at the latest completed simulation tick.
Rendering calculates an alpha from the real elapsed time since the latest completed
tick, clamped to the 100 ms simulation interval. It renders the delayed
`previous -> current` transition. This introduces at most one simulation tick of
presentation latency, but it avoids predicting a state that has not arrived from
the authoritative simulation.
The following values are interpolated:
- Position uses linear interpolation.
- Facing angle uses the shortest angular path, including wraparound at ±π.
- Animation frame remains fractional and is interpolated when both samples use
the same animation state. Looping clips treat the last frame to first frame
transition as a cyclic interval instead of interpolating backward through the
entire clip.
The interpolated values are used by mesh, weapon, shadow, night-lighting, name,
and health-bar rendering. The local camera and shadow-map center follow the same
interpolated player position; otherwise the camera would jump to the newest
authoritative position while the player model was still in its presentation
transition. Gameplay queries, commands, callbacks, collision, and presentation
events continue to use the authoritative snapshot.
## Other presentation animations
Teleport-zone star particles are advanced by `Location::updatePresentation()`
once per render frame using the real frame delta. They are no longer advanced
only from the 100 ms simulation-tick callback, so their visual motion remains
continuous without affecting teleport activation or other authoritative state.
Dialogue typewriter text and cutscene presentation timers use the same
per-render-frame path. This keeps character reveals, subtitle timing, fades, and
cutscene image poses smooth instead of stepping in 100 ms increments.
## Animation skinning
GPU and CPU bone interpolation receive the fractional render frame instead of an
integer-truncated frame. Bone positions are linearly interpolated and bone
rotations use spherical interpolation as before. Frames at the end of a clip are
clamped to the final keyframe.
## Discontinuities
The presentation buffer is reset instead of blended when a character is first
observed, changes animation state or enabled state, or has not received a tick
for a sufficiently long gap. This prevents stale positions, animation clips, or
disabled characters from producing visible interpolation artifacts.
## Local and networked games
Local games continue accumulating wall-clock remainder time and executing exact
100 ms simulation updates. Networked games continue consuming only contiguous
server tick messages. Both paths publish completed snapshots to the same runtime
presentation objects; interpolation only changes how those snapshots are drawn
between updates.
+29 -12
View File
@@ -722,9 +722,10 @@ namespace FRG
return -1;
}
void BoneSystemNew::Interpolate(int frame)
void BoneSystemNew::Interpolate(float frame)
{
int startingKeyFrame = -1;
int nextKeyFrame = -1;
for (int i = 0; i < static_cast<int>(animations[0].keyFrames.size()) - 1; i++)
{
int oldFrame = animations[0].keyFrames[i].frame;
@@ -732,22 +733,29 @@ namespace FRG
if (frame >= oldFrame && frame < nextFrame)
{
startingKeyFrame = i;
nextKeyFrame = i + 1;
break;
}
}
if (startingKeyFrame == -1)
{
logger() << "Exception here: frame number is out of range of keyframes. Frame: " << frame << std::endl;
throw std::runtime_error("Exception here");
if (animations[0].keyFrames.size() < 2)
return;
startingKeyFrame = static_cast<int>(animations[0].keyFrames.size()) - 1;
nextKeyFrame = 0;
}
int modifiedFrameNumber = frame - animations[0].keyFrames[startingKeyFrame].frame;
int diffFrames = animations[0].keyFrames[startingKeyFrame + 1].frame - animations[0].keyFrames[startingKeyFrame].frame;
float t = (modifiedFrameNumber + 0.f) / diffFrames;
float modifiedFrameNumber = frame - animations[0].keyFrames[startingKeyFrame].frame;
int diffFrames = nextKeyFrame == 0
? animations[0].keyFrames.back().frame -
animations[0].keyFrames.front().frame + 1
: animations[0].keyFrames[nextKeyFrame].frame -
animations[0].keyFrames[startingKeyFrame].frame;
float t = diffFrames > 0 ? modifiedFrameNumber / diffFrames : 0.0f;
std::vector<Bone>& oneFrameBones = animations[0].keyFrames[startingKeyFrame].bones;
std::vector<Bone>& nextFrameBones = animations[0].keyFrames[startingKeyFrame + 1].bones;
std::vector<Bone>& nextFrameBones = animations[0].keyFrames[nextKeyFrame].bones;
std::vector<Matrix4f> skinningMatrixForEachBone(currentBones.size());
@@ -906,9 +914,10 @@ namespace FRG
}
}
void GpuSkinningShaderDataNew::ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, int frame)
void GpuSkinningShaderDataNew::ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, float frame)
{
int startingKeyFrame = -1;
int nextKeyFrame = -1;
for (size_t i = 0; i < keyFrames.size() - 1; i++)
{
int oldFrame = keyFrames[i].frame;
@@ -916,6 +925,7 @@ namespace FRG
if (frame >= oldFrame && frame < nextFrame)
{
startingKeyFrame = static_cast<int>(i);
nextKeyFrame = static_cast<int>(i + 1);
break;
}
}
@@ -923,17 +933,24 @@ namespace FRG
skinningMatrices.resize(startBones.size());
if (startingKeyFrame == -1)
{
if (keyFrames.size() < 2)
{
for (auto& m : skinningMatrices) m = Matrix4f::Identity();
return;
}
startingKeyFrame = static_cast<int>(keyFrames.size()) - 1;
nextKeyFrame = 0;
}
int modifiedFrameNumber = frame - keyFrames[startingKeyFrame].frame;
int diffFrames = keyFrames[startingKeyFrame + 1].frame - keyFrames[startingKeyFrame].frame;
float t = (modifiedFrameNumber + 0.f) / diffFrames;
float modifiedFrameNumber = frame - keyFrames[startingKeyFrame].frame;
int diffFrames = nextKeyFrame == 0
? keyFrames.back().frame - keyFrames.front().frame + 1
: keyFrames[nextKeyFrame].frame - keyFrames[startingKeyFrame].frame;
float t = diffFrames > 0 ? modifiedFrameNumber / diffFrames : 0.0f;
const std::vector<Bone>& oneFrameBones = keyFrames[startingKeyFrame].bones;
const std::vector<Bone>& nextFrameBones = keyFrames[startingKeyFrame + 1].bones;
const std::vector<Bone>& nextFrameBones = keyFrames[nextKeyFrame].bones;
for (size_t i = 0; i < startBones.size(); i++)
{
+3 -3
View File
@@ -67,7 +67,7 @@ namespace FRG
void LoadFromFile(const std::string& fileName, const std::string& ZIPFileName = "");
void LoadFromBinaryFile(const std::string& fileName, const std::string& ZIPFileName = "");
void Interpolate(int frame);
void Interpolate(float frame);
int findBoneIndex(const std::string& name) const;
};
@@ -94,14 +94,14 @@ namespace FRG
void prepareGpuSkinningVBOs(BoneSystemNew& model);
void RenderVBO(Renderer& renderer, const std::vector<std::string>& meshNamesOrdered);
void ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, int frame);
void ComputeSkinningMatrices(const std::vector<Bone>& startBones, const std::vector<AnimationKeyFrame>& keyFrames, float frame);
};
struct BoneAnimationDataNew
{
BoneSystemNew model;
float currentFrame = 0.f;
int lastFrame = -1;
float lastFrame = -1.0f;
int totalFrames = 1;
GpuSkinningShaderDataNew gpuSkinningShaderData;
+98 -26
View File
@@ -52,6 +52,11 @@ Eigen::Vector3f Character::getCurrentNavigationTarget() const {
return state ? state->getCurrentNavigationTarget() : Eigen::Vector3f::Zero();
}
Eigen::Vector3f Character::renderPosition() const {
auto state = stateSnapshot();
return state ? interpolatedState(*state).position : Eigen::Vector3f::Zero();
}
float Character::getHp() const {
auto state = stateSnapshot();
return state ? state->getHp() : 0.f;
@@ -163,6 +168,62 @@ AnimationState Character::resolveActiveState(const CharacterState& state) const
return AnimationState::STAND;
}
CharacterState Character::interpolatedState(const CharacterState& state) const {
if (!hasPresentationSample_ || !currentPresentationSample_.enabled || !state.enabled) {
return state;
}
constexpr float kSimulationTickMs = 100.0f;
const auto now = std::chrono::steady_clock::now();
const float elapsedMs = static_cast<float>(
std::chrono::duration_cast<std::chrono::milliseconds>(
now - lastPresentationUpdate_).count());
const float alpha = (std::max)(0.0f, (std::min)(1.0f, elapsedMs / kSimulationTickMs));
CharacterState result = state;
result.position = previousPresentationSample_.position +
alpha * (currentPresentationSample_.position - previousPresentationSample_.position);
constexpr float kPi = 3.14159265358979323846f;
constexpr float kTwoPi = 2.0f * kPi;
float angleDelta = currentPresentationSample_.facingAngle -
previousPresentationSample_.facingAngle;
while (angleDelta > kPi) angleDelta -= kTwoPi;
while (angleDelta < -kPi) angleDelta += kTwoPi;
result.facingAngle = previousPresentationSample_.facingAngle + alpha * angleDelta;
if (previousPresentationSample_.currentState == state.currentState &&
currentPresentationSample_.currentState == state.currentState) {
float previousFrame = previousPresentationSample_.animationFrame;
float currentFrame = currentPresentationSample_.animationFrame;
const auto animationIndex = static_cast<std::size_t>(state.currentState);
const int frameCount = animationIndex < state.animationFrameCounts.size()
? state.animationFrameCounts[animationIndex] : 1;
if (frameCount > 1 && currentFrame < previousFrame)
currentFrame += static_cast<float>(frameCount);
result.animationFrame = previousFrame + alpha * (currentFrame - previousFrame);
}
return result;
}
void Character::updateAnimationForRender(const CharacterState& state) {
auto it = animations.find(state.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
anim.currentFrame = (std::min)(
state.animationFrame, static_cast<float>((std::max)(0, anim.totalFrames)));
if (std::abs(anim.currentFrame - anim.lastFrame) <= 0.0001f) return;
if (!anim.model.animations.empty() && !anim.model.animations[0].keyFrames.empty()) {
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
anim.model.startBones, anim.model.animations[0].keyFrames,
anim.currentFrame);
if (!state.useGpuSkinning)
anim.model.Interpolate(anim.currentFrame);
}
anim.lastFrame = anim.currentFrame;
}
void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
if (hitSparkEmitter.isConfigured()) {
hitSparkEmitter.update(static_cast<float>(deltaMs));
@@ -172,6 +233,26 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
const auto now = std::chrono::steady_clock::now();
PresentationSample sample;
sample.position = state.position;
sample.facingAngle = state.facingAngle;
sample.animationFrame = state.animationFrame;
sample.currentState = state.currentState;
sample.enabled = state.enabled;
const bool largeGap = hasPresentationSample_ &&
std::chrono::duration_cast<std::chrono::milliseconds>(
now - lastPresentationUpdate_).count() > 250;
if (!hasPresentationSample_ || largeGap ||
currentPresentationSample_.enabled != sample.enabled) {
previousPresentationSample_ = sample;
} else {
previousPresentationSample_ = currentPresentationSample_;
}
currentPresentationSample_ = sample;
lastPresentationUpdate_ = now;
hasPresentationSample_ = true;
if (events) {
if (events->deathAnimationComplete && onDeathAnimComplete)
onDeathAnimComplete();
@@ -183,22 +264,7 @@ void Character::update(int64_t deltaMs, const CharacterUpdateEvents* events) {
}
if (!state.enabled) return;
auto it = animations.find(state.currentState);
if (it == animations.end()) return;
auto& anim = it->second;
anim.currentFrame = (std::min)(
state.animationFrame, static_cast<float>((std::max)(0, anim.totalFrames - 1)));
if (static_cast<int>(anim.currentFrame) != anim.lastFrame) {
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
anim.model.startBones, anim.model.animations[0].keyFrames,
static_cast<int>(anim.currentFrame));
if (!state.useGpuSkinning) {
anim.model.Interpolate(static_cast<int>(anim.currentFrame));
}
anim.lastFrame = static_cast<int>(anim.currentFrame);
}
updateAnimationForRender(state);
}
void Character::draw(Renderer& renderer) {
@@ -211,8 +277,9 @@ void Character::draw(Renderer& renderer) {
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return;
updateAnimationForRender(state);
if (state.useGpuSkinning) {
drawGpuSkinning(renderer, state);
@@ -270,7 +337,7 @@ void Character::drawGpuSkinning(Renderer& renderer, const CharacterState& state)
if (anim.model.animations.empty() || anim.model.animations[0].keyFrames.empty()) return;
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
anim.model.startBones, anim.model.animations[0].keyFrames,
static_cast<int>(anim.currentFrame));
anim.currentFrame);
if (anim.gpuSkinningShaderData.skinningMatrices.empty()) return;
}
@@ -328,7 +395,7 @@ bool Character::prepareGpuSkinning(const CharacterState& state) {
if (anim.model.animations.empty() || anim.model.animations[0].keyFrames.empty()) return false;
anim.gpuSkinningShaderData.ComputeSkinningMatrices(
anim.model.startBones, anim.model.animations[0].keyFrames,
static_cast<int>(anim.currentFrame));
anim.currentFrame);
if (anim.gpuSkinningShaderData.skinningMatrices.empty()) return false;
}
return true;
@@ -370,8 +437,9 @@ void Character::drawAttachedWeapon(Renderer& renderer, const CharacterState& sta
void Character::drawShadowDepth(Renderer& renderer) {
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return;
updateAnimationForRender(state);
if (state.useGpuSkinning) {
drawShadowDepthGpuSkinning(renderer, state);
} else {
@@ -453,8 +521,9 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer, const CharacterSt
void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) {
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return;
updateAnimationForRender(state);
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -603,8 +672,9 @@ void Character::drawNight(Renderer& renderer,
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return;
updateAnimationForRender(state);
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -766,8 +836,9 @@ void Character::drawNightWithShadow(Renderer& renderer,
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (!state.enabled) return;
updateAnimationForRender(state);
if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) {
hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false);
}
@@ -1014,7 +1085,7 @@ void Character::drawName(TextRenderer& textRenderer,
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (state.isPlayer) return;
if (state.npcName.empty()) return;
@@ -1037,10 +1108,11 @@ void Character::drawPlayerName(
{
auto snapshot = stateSnapshot();
if (!snapshot) return;
CharacterState state = interpolatedState(*snapshot);
static const std::array<float, 4> color = {0.f, 1.f, 0.f, 1.f};
drawCharacterNameLabel(
textRenderer, cameraViewMatrix, projectionMatrix,
*snapshot, displayName, color);
state, displayName, color);
}
void Character::drawHealthBar(Renderer& renderer,
@@ -1049,7 +1121,7 @@ void Character::drawHealthBar(Renderer& renderer,
{
auto stateSnapshot = this->stateSnapshot();
if (!stateSnapshot) return;
const CharacterState& state = *stateSnapshot;
CharacterState state = interpolatedState(*stateSnapshot);
if (state.isPlayer || !state.canAttack) return;
if (!state.enabled) return;
if (state.hp <= 0.f) return;
+17
View File
@@ -12,6 +12,7 @@
#include <vector>
#include <unordered_map>
#include <utility>
#include <chrono>
#include "dialogue/TranslationDatabase.h"
#include "AudioPlayerAsync.h"
@@ -94,6 +95,7 @@ public:
bool isMoving() const;
Eigen::Vector3f getRequestedWalkTarget() const;
Eigen::Vector3f getCurrentNavigationTarget() const;
Eigen::Vector3f renderPosition() const;
float getHp() const;
// ---- Weapon rendering data (initialized from state.creationInfo by createFromState) ----
@@ -124,7 +126,22 @@ private:
VertexRenderStruct healthBarMesh;
std::unordered_map<std::string, std::shared_ptr<Texture>> meshTextures;
struct PresentationSample {
Eigen::Vector3f position = Eigen::Vector3f::Zero();
float facingAngle = 0.0f;
float animationFrame = 0.0f;
AnimationState currentState = AnimationState::STAND;
bool enabled = true;
};
PresentationSample previousPresentationSample_;
PresentationSample currentPresentationSample_;
std::chrono::steady_clock::time_point lastPresentationUpdate_;
bool hasPresentationSample_ = false;
AnimationState resolveActiveState(const CharacterState& state) const;
CharacterState interpolatedState(const CharacterState& state) const;
void updateAnimationForRender(const CharacterState& state);
bool prepareGpuSkinning(const CharacterState& state);
void drawAttachedWeapon(Renderer& renderer, const CharacterState& state);
+2
View File
@@ -1194,6 +1194,8 @@ namespace FRG
simulationAccumulatorMs -= kSimulationTickMs;
}
}
if (Location* location = currentLocation())
location->updatePresentation(delta);
#if defined(NETWORK)
if (remoteCampaignActive && websocketGameClient &&
websocketGameClient->connectionFailed()) {
+12 -8
View File
@@ -1011,7 +1011,7 @@ namespace FRG
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
const Eigen::Vector3f camTarget =
playerState ? playerState->position : Eigen::Vector3f::Zero();
player ? player->renderPosition() : Eigen::Vector3f::Zero();
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
renderer.TranslateMatrix({ 0, -1.f, 0 });
@@ -1088,7 +1088,7 @@ namespace FRG
auto playerState = player ? player->stateSnapshot() : nullptr;
const Eigen::Vector3f sceneCenter =
playerState ? playerState->position : Eigen::Vector3f::Zero();
player ? player->renderPosition() : Eigen::Vector3f::Zero();
shadowMap->updateLightSpaceMatrix(sceneCenter);
shadowMap->bind();
@@ -1206,7 +1206,7 @@ namespace FRG
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
auto playerState = player ? player->stateSnapshot() : nullptr;
const Eigen::Vector3f camTarget =
playerState ? playerState->position : Eigen::Vector3f::Zero();
player ? player->renderPosition() : Eigen::Vector3f::Zero();
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
renderer.TranslateMatrix({ 0, -1.f, 0 });
@@ -1345,7 +1345,7 @@ namespace FRG
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
const Eigen::Vector3f camTarget =
playerState ? playerState->position : Eigen::Vector3f::Zero();
player ? player->renderPosition() : Eigen::Vector3f::Zero();
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
renderer.TranslateMatrix({ 0, -1.f, 0 });
@@ -1591,7 +1591,7 @@ namespace FRG
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraInclination, Eigen::Vector3f::UnitX())).toRotationMatrix());
renderer.RotateMatrix(Eigen::Quaternionf(Eigen::AngleAxisf(localState_->cameraAzimuth, Eigen::Vector3f::UnitY())).toRotationMatrix());
const Eigen::Vector3f camTarget =
playerState ? playerState->position : Eigen::Vector3f::Zero();
player ? player->renderPosition() : Eigen::Vector3f::Zero();
renderer.TranslateMatrix({ -camTarget.x(), -camTarget.y(), -camTarget.z() });
renderer.TranslateMatrix({ 0, -1.f, 0 });
@@ -1860,7 +1860,6 @@ namespace FRG
if (player) {
player->update(delta, eventFor(gameServer_.localPlayerId()));
dialogueSystem.update(static_cast<int>(delta));
auto playerState = player->stateSnapshot();
if (playerState) updateTriggerZones(playerState->position);
}
@@ -1896,8 +1895,6 @@ namespace FRG
}
}
for (auto& tz : teleportZones) tz.update(static_cast<float>(delta));
auto playerState = player ? player->stateSnapshot() : nullptr;
if (auto* tz = getTargetTeleportZone(*locationState); tz && playerState) {
float dist = (playerState->position - tz->position).norm();
@@ -2179,6 +2176,13 @@ namespace FRG
}
}
void Location::updatePresentation(int64_t delta)
{
dialogueSystem.update(static_cast<int>(delta));
for (auto& tz : teleportZones)
tz.update(static_cast<float>(delta));
}
bool Location::requestDialogueStart(const std::string& dialogueId)
{
return dialogueSystem.startDialogue(dialogueId);
+1
View File
@@ -210,6 +210,7 @@ namespace FRG
bool setNavigationAreaAvailable(const std::string& areaName, bool available);
void update(int64_t deltaMs, const LocationSimulationEvents& events);
void updatePresentation(int64_t deltaMs);
// Purely visual feedback for click-triggered player routes. Disabling it
// also cancels an animation that is currently playing.
void setPathVisualizationEnabled(bool enabled);