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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user