Add interpolation system and integrate into proxy

Introduce a waypoint-based interpolation system and integrate it into the proxy actor controller. Added F4T::InterpolationSystem (header + implementation) with TimePoint, InterpolationComponent, Lerp helpers and Update() logic. Integrates per-slot InterpolationComponent into F4TProxyActorController, switches to kinematic SetPosition-based smoothing (per-frame Lerp with snap thresholds) and removes character-controller velocity injection to prevent drift; also clears waypoints on hard snaps and uses steady_clock for timing. Added a descriptor-based bulk animation write (ApplyDesiredStateToGraphDescriptorBased) in ProxyAnimationSync for efficient graph variable updates with failure logging. Also included testing and debugging docs (phase2-interpolation-testing.md and phase2-interpolation-debugging.md) and flipped some debug flags to false.
This commit is contained in:
2026-06-03 22:46:57 +12:00
parent b128729ebf
commit a11c7d4475
6 changed files with 918 additions and 35 deletions
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <chrono>
#include <deque>
#include <cstdint>
#include "RE/N/NiPoint3.h"
namespace F4T::InterpolationSystem
{
// TimePoint: Represents a position/rotation at a specific game tick
struct TimePoint
{
RE::NiPoint3 Position{};
float Rotation{}; // Z-axis rotation (angleZ)
std::chrono::steady_clock::time_point Timestamp{};
uint64_t Tick{ 0 };
TimePoint() = default;
TimePoint(const RE::NiPoint3& a_pos, float a_rot, uint64_t a_tick)
: Position(a_pos), Rotation(a_rot), Tick(a_tick), Timestamp(std::chrono::steady_clock::now())
{
}
};
// InterpolationComponent: Manages smooth movement between waypoints
struct InterpolationComponent
{
// Deque maintains 2 waypoints for lerp: current and next
std::deque<TimePoint> TimePoints;
// Last interpolated position (for delta checks)
RE::NiPoint3 LastInterpolatedPosition{};
// Add a new waypoint to the queue
void AddWaypoint(const TimePoint& a_point) noexcept
{
TimePoints.push_back(a_point);
}
// Get the current number of waypoints
std::size_t GetWaypointCount() const noexcept
{
return TimePoints.size();
}
// Clear all waypoints
void Clear() noexcept
{
TimePoints.clear();
}
// Check if we have enough waypoints for interpolation (need at least 2)
bool HasEnoughWaypoints() const noexcept
{
return TimePoints.size() >= 2;
}
};
// Update interpolation for an actor
// Returns the interpolated position if interpolation occurred, nullopt otherwise
struct InterpolationResult
{
RE::NiPoint3 Position{};
float Rotation{};
bool IsInterpolating{ false };
};
// Update the interpolation for a remote player
// a_currentTime: Current game time (in milliseconds or ticks)
// Returns interpolated position/rotation if movement is happening
InterpolationResult Update(
InterpolationComponent& a_component,
uint64_t a_currentTick) noexcept;
// Helper: Linear interpolation between two 3D points
RE::NiPoint3 Lerp(const RE::NiPoint3& a_from, const RE::NiPoint3& a_to, float a_alpha) noexcept;
// Helper: Circular interpolation for rotation (handles wrap-around)
float LerpRotation(float a_from, float a_to, float a_alpha) noexcept;
}
+100
View File
@@ -0,0 +1,100 @@
#include "pch.h"
#include "F4TInterpolationSystem.h"
#include <cmath>
#include <algorithm>
namespace F4T::InterpolationSystem
{
// Helper: Linear interpolation for 3D points
RE::NiPoint3 Lerp(const RE::NiPoint3& a_from, const RE::NiPoint3& a_to, float a_alpha) noexcept
{
a_alpha = std::clamp(a_alpha, 0.0F, 1.0F);
return RE::NiPoint3{
a_from.x + (a_to.x - a_from.x) * a_alpha,
a_from.y + (a_to.y - a_from.y) * a_alpha,
a_from.z + (a_to.z - a_from.z) * a_alpha
};
}
// Helper: Circular interpolation for angles (handles 0-2π wrap-around)
float LerpRotation(float a_from, float a_to, float a_alpha) noexcept
{
a_alpha = std::clamp(a_alpha, 0.0F, 1.0F);
// Normalize both angles to [0, 2π]
const auto normalize = [](float a_angle) {
while (a_angle < 0.0F) a_angle += 2.0F * 3.14159265F;
while (a_angle >= 2.0F * 3.14159265F) a_angle -= 2.0F * 3.14159265F;
return a_angle;
};
float from = normalize(a_from);
float to = normalize(a_to);
// Find the shortest path
float delta = to - from;
if (delta > 3.14159265F) {
delta -= 2.0F * 3.14159265F;
} else if (delta < -3.14159265F) {
delta += 2.0F * 3.14159265F;
}
return normalize(from + delta * a_alpha);
}
InterpolationResult Update(
InterpolationComponent& a_component,
uint64_t a_currentTick) noexcept
{
InterpolationResult result{};
// Need at least 2 waypoints to interpolate
if (a_component.TimePoints.size() < 2) {
return result;
}
auto& first = a_component.TimePoints[0];
auto& second = a_component.TimePoints[1];
// If current tick is before the first waypoint, return the first position
if (a_currentTick <= first.Tick) {
result.Position = first.Position;
result.Rotation = first.Rotation;
result.IsInterpolating = false;
return result;
}
// If current tick is past the second waypoint, remove the first and try again
if (a_currentTick >= second.Tick) {
a_component.TimePoints.pop_front();
// Recursively check if we can interpolate with the next pair
if (a_component.TimePoints.size() >= 2) {
return Update(a_component, a_currentTick);
} else {
// Only one waypoint left; use it
result.Position = a_component.TimePoints[0].Position;
result.Rotation = a_component.TimePoints[0].Rotation;
result.IsInterpolating = false;
return result;
}
}
// Interpolate between first and second waypoint
const uint64_t tickDelta = second.Tick > first.Tick ? second.Tick - first.Tick : 1;
const uint64_t tickElapsed = a_currentTick - first.Tick;
float alpha = static_cast<float>(tickElapsed) / static_cast<float>(tickDelta);
alpha = std::clamp(alpha, 0.0F, 1.0F);
result.Position = Lerp(first.Position, second.Position, alpha);
result.Rotation = LerpRotation(first.Rotation, second.Rotation, alpha);
result.IsInterpolating = true;
a_component.LastInterpolatedPosition = result.Position;
return result;
}
}
+70 -34
View File
@@ -6,6 +6,7 @@
#include "F4TProxyAnimationSync.h"
#include "F4TRemotePlayerState.h"
#include "F4RemoteActionComponent.h"
#include "F4TInterpolationSystem.h"
#include "RE/A/ACTOR_STANCE.h"
#include "RE/A/ActorValue.h"
@@ -76,8 +77,8 @@ namespace
constexpr auto kRuntimeProxyDebugForwardDistance = 250.0F;
constexpr auto kRuntimeProxyDebugFallbackOffsetX = 200.0F;
constexpr auto kDebugPlaceAtMeSpawnDetectionRadius = 700.0F;
constexpr bool kDebugForceRuntimeProxyNearLocalPlayer = true;
constexpr bool kDebugHoldRuntimeProxyNearLocalPlayer = true;
constexpr bool kDebugForceRuntimeProxyNearLocalPlayer = false;
constexpr bool kDebugHoldRuntimeProxyNearLocalPlayer = false;
constexpr float kDebugRuntimeProxyHoldSeconds = 15.0F;
constexpr auto kDebugRuntimeProxyOffsetZ = 0.0F;
constexpr auto kDebugRuntimeProxyScale = 1.50F;
@@ -276,6 +277,11 @@ namespace
// Phase 5.1: Action queue for discrete action replay
F4T::RemoteActionComponent::RemoteActionQueue actionQueue;
// Phase 2: Interpolation system for smooth movement between waypoints
F4T::InterpolationSystem::InterpolationComponent interpolationComponent{};
uint64_t lastInterpolationTick{ 0 };
bool interpolationEnabledLogged{ false };
};
constexpr auto kProxyMovementMode = ProxyMovementMode::kRemotePlayerStateTest;
@@ -579,28 +585,53 @@ namespace
a_proxy.GetFormID(), hasAI));
}
// Always update heading
a_proxy.SetHeading(a_heading);
RE::NiPoint3 currentPosition = a_proxy.GetPosition();
RE::NiPoint3 deltaPos = a_nextPosition - currentPosition;
float distanceDrift = deltaPos.Length();
// SetPosition moves the proxy to the correct network position.
a_proxy.SetPosition(a_nextPosition, true);
if (a_optSlot) {
a_optSlot->lastPositionSyncTime = std::chrono::steady_clock::now();
// Stable smoothing strategy:
// - snap for explicit snap movements or huge drift
// - otherwise, smoothly lerp from current -> latest network target
constexpr float kLargeDriftSnapDistance = 1800.0F;
constexpr float kInterpolationRatePerSecond = 8.0F;
constexpr float kMinInterpolationAlpha = 0.08F;
constexpr float kMaxInterpolationAlpha = 0.35F;
const auto now = std::chrono::steady_clock::now();
float deltaSeconds = 1.0F / 60.0F;
if (a_optSlot && a_optSlot->lastPositionSyncTime != std::chrono::steady_clock::time_point{}) {
deltaSeconds = std::chrono::duration<float>(now - a_optSlot->lastPositionSyncTime).count();
}
deltaSeconds = std::clamp(deltaSeconds, 1.0F / 120.0F, 0.1F);
RE::NiPoint3 positionToApply = a_nextPosition;
if (!a_shouldSnap && distanceDrift < kLargeDriftSnapDistance) {
const float alpha = std::clamp(
deltaSeconds * kInterpolationRatePerSecond,
kMinInterpolationAlpha,
kMaxInterpolationAlpha);
positionToApply = F4T::InterpolationSystem::Lerp(currentPosition, a_nextPosition, alpha);
} else if (a_optSlot) {
// Clear queued waypoints when hard-snapping to avoid stale interpolation state.
a_optSlot->interpolationComponent.Clear();
}
// Inject velocity into the character controller so the movement controller
// computes a non-zero Speed, which the animation graph reads to blend walk/run.
// Without this, SetPosition produces zero velocity and the NPC stands still.
if (distanceDrift > 0.001F) {
auto* charController = a_proxy.Move(0.016F, deltaPos, false);
if (charController) {
InjectProxyMovementVelocity(*charController, deltaPos, 0.016F);
}
// Always apply authoritative heading directly.
a_proxy.SetHeading(a_heading);
a_proxy.SetPosition(positionToApply, true);
if (a_optSlot) {
a_optSlot->lastPositionSyncTime = now;
}
// NOTE: Movement is kinematic (SetPosition-based). We deliberately do NOT
// inject character-controller velocity here. Persistent havok velocity plus
// the proxy's active AIProcess caused the actor to drift/wander around the
// network target (visible as teleporting), especially while the remote was
// idle. Locomotion animation is driven separately by writing the "Speed"
// graph variable in ProxyAnimationSync (Phase 1), so velocity injection is
// unnecessary for animation blending. Per-frame SetPosition in
// ApplySmoothFrameMovement pins the actor to the network target.
}
// NEW: Enable AI-driven animation mode
@@ -5304,28 +5335,33 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
RE::NiPoint3 deltaToTarget = slot.targetPosition - currentPosition;
float distanceToTarget = deltaToTarget.Length();
// Skip if too close to target
proxy->SetHeading(slot.targetHeading);
// Already at target: pin exactly and stop. This prevents the actor from
// drifting/wandering around a fixed target while the remote is idle.
if (distanceToTarget < 0.1F) {
proxy->SetPosition(slot.targetPosition, true);
continue;
}
proxy->SetHeading(slot.targetHeading);
// Use Move() to smoothly approach target via character controller
// This lets the AI/character controller drive animations naturally
constexpr float kFrameDeltaSeconds = 0.016F;
constexpr float MAX_FRAME_DELTA = 12.0F; // Max movement per frame
RE::NiPoint3 frameDelta = deltaToTarget;
if (distanceToTarget > MAX_FRAME_DELTA) {
frameDelta = (deltaToTarget / distanceToTarget) * MAX_FRAME_DELTA;
// Kinematic smoothing toward the network target.
// We intentionally avoid proxy->Move() + velocity injection: physics-driven
// movement combined with the proxy's active AIProcess caused unstable
// wandering/overshoot. SetPosition pins the actor deterministically while
// the locomotion animation is driven by the "Speed" graph variable (Phase 1).
constexpr float kLargeDriftSnapDistance = 1500.0F;
RE::NiPoint3 nextPosition;
if (distanceToTarget > kLargeDriftSnapDistance) {
// Recover instantly from very large gaps (e.g. cell change / teleport).
nextPosition = slot.targetPosition;
} else {
// Smoothly converge a fraction of the remaining distance each frame.
constexpr float kPerFrameApproachAlpha = 0.25F;
nextPosition = F4T::InterpolationSystem::Lerp(
currentPosition, slot.targetPosition, kPerFrameApproachAlpha);
}
// Call Move() through the character controller and publish a velocity
// sample so the graph can blend into locomotion.
// This should update velocity in the character controller for animations
if (auto* charController = proxy->Move(kFrameDeltaSeconds, frameDelta, false)) {
InjectProxyMovementVelocity(*charController, frameDelta, kFrameDeltaSeconds);
}
proxy->SetPosition(nextPosition, true);
}
}
}
+60 -1
View File
@@ -239,6 +239,65 @@ namespace
return success;
}
bool ApplyDesiredStateToGraphDescriptorBased(
RE::Actor& a_proxy,
const DesiredProxyAnimationState& a_desired,
std::uint32_t a_remotePlayerId)
{
auto* graphHolder = static_cast<RE::IAnimationGraphManagerHolder*>(&a_proxy);
const auto actorId = a_proxy.GetFormID();
auto& descriptor = F4T::AnimationDescriptor::F4AnimationDescriptor::GetHumanoidDescriptor();
F4T::AnimationDescriptor::AnimationVariableSnapshot snapshot;
// Resize vectors to match descriptor counts
snapshot.floats.resize(descriptor.GetFloatVariableCount(), 0.0F);
snapshot.bools.resize(descriptor.GetBoolVariableCount(), false);
snapshot.ints.resize(descriptor.GetIntVariableCount(), 0);
// Populate float variables
const auto speedIdx = descriptor.GetFloatVariableIndex("Speed");
if (speedIdx != static_cast<std::size_t>(-1)) {
snapshot.floats[speedIdx] = a_desired.graphSpeed;
}
const auto directionIdx = descriptor.GetFloatVariableIndex("direction");
if (directionIdx != static_cast<std::size_t>(-1)) {
snapshot.floats[directionIdx] = a_desired.direction;
}
// Populate bool variables
const auto isSprintingIdx = descriptor.GetBoolVariableIndex("isSprinting");
if (isSprintingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSprintingIdx] = a_desired.isSprinting;
}
const auto isSneakingIdx = descriptor.GetBoolVariableIndex("isSneaking");
if (isSneakingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isSneakingIdx] = a_desired.isSneaking;
}
const auto isMovingIdx = descriptor.GetBoolVariableIndex("isMoving");
if (isMovingIdx != static_cast<std::size_t>(-1)) {
snapshot.bools[isMovingIdx] = a_desired.isMoving;
}
// Single bulk write operation (major performance win)
const auto success = descriptor.LoadAnimationVariablesToCache(*graphHolder, snapshot);
if (!success) {
static std::unordered_set<std::uint32_t> loggedWriteFailures;
if (loggedWriteFailures.insert(a_remotePlayerId).second) {
LogWarningWithLocalPlayerPrefix(std::format(
"Runtime proxy animation sync (descriptor-based) failed for remote player {}: actor={:08X}.",
a_remotePlayerId,
actorId));
}
}
return success;
}
void LogAppliedTransition(
std::uint32_t a_remotePlayerId,
std::uint32_t a_actorId,
@@ -293,7 +352,7 @@ namespace F4T::ProxyAnimationSync
const auto initial = !a_state.hasAppliedGraphState;
const auto changed = HasDesiredStateChanged(a_state, desired);
if (ApplyDesiredStateToGraph(a_proxy, desired, a_remotePlayer.playerId)) {
if (ApplyDesiredStateToGraphDescriptorBased(a_proxy, desired, a_remotePlayer.playerId)) {
if (initial || changed) {
LogAppliedTransition(a_remotePlayer.playerId, actorId, desired, initial);
}