Drive proxy cadence from measured motion
Tie proxy locomotion/cadence to actual per-frame movement and motion-feedback instead of only replaying transmitted Speed. Changes: - docs/dev-log.md: add detailed dev log describing measured-cadence fix and cadence/animationSpeed work. - plugin/include/F4TProxyPuppet.h: added NiPoint3 include and new API: SetProxyLocomotionState, SetProxyDesiredSpeed (legacy alias), and ApplyProxyLocomotionFrame. - plugin/lib/commonlibf4/include/RE/A/ActorMotionFeedbackOutput.h: new tentative struct for motion-feedback output. - plugin/lib/commonlibf4/include/RE/Fallout.h: include new ActorMotionFeedbackOutput header. - plugin/src/F4TProxyActorController.cpp: track per-frame timing/puppet state, compute frame delta, use proxy->Move() with measured frameDelta and call ApplyProxyLocomotionFrame (instead of SetPosition/Lerp-only), and pass measured graph speed/direction to proxy. - plugin/src/F4TProxyAnimationSync.cpp: use SetProxyLocomotionState to include direction when writing proxy locomotion state. - plugin/src/F4TProxyPuppet.cpp: introduce ProxyLocomotionState map, sync controller motion fields, compute/push engine motion-feedback (via Compute/Update feedback vtable calls), use UpdateNoAI + manual motion feedback to drive cadence, implement ApplyProxyLocomotionFrame which derives velocity from world delta, clamps/chooses feedback speed (uses measured horizontal speed unless provided graph speed is valid), applies an idle threshold (<8 u/s -> idle), and writes graph speed back to the animation graph. Why: This makes foot cadence reflect on-screen translation (eliminating mild foot slide/stop-overstay) by measuring applied displacement each frame, updating controller velocity/motion-feedback, and re-asserting animation graph inputs after UpdateNoAI.
This commit is contained in:
@@ -251,6 +251,14 @@ namespace
|
||||
std::chrono::steady_clock::time_point settleStartTime{};
|
||||
std::chrono::steady_clock::time_point heldSinceTime{};
|
||||
std::chrono::steady_clock::time_point lastPositionSyncTime{};
|
||||
// Per-frame translation cadence tracking. The proxy is moved by SetPosition with
|
||||
// exponential Lerp easing, so its instantaneous ground speed varies even when the
|
||||
// remote moves at a constant pace. We measure the actual applied displacement each
|
||||
// frame and feed an EMA of it to the animation puppet so the walk/run foot cadence
|
||||
// tracks real on-screen motion (eliminates foot slide / glide and stop-overstay).
|
||||
std::chrono::steady_clock::time_point lastSmoothMoveTime{};
|
||||
float puppetGraphSpeed{ -1.0F };
|
||||
float puppetDirection{ 0.0F };
|
||||
RE::NiPoint3 targetPosition{}; // Current target position for smooth movement
|
||||
float targetHeading{ 0.0F }; // Current target heading for smooth movement
|
||||
float initialRemoteSpawnHeading{ 0.0F };
|
||||
@@ -4960,6 +4968,25 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
// Store latest target for smooth frame movement
|
||||
a_slot.targetPosition = targetPosition;
|
||||
a_slot.targetHeading = a_remotePlayer.angleZ;
|
||||
a_slot.puppetDirection = a_remotePlayer.angleZ;
|
||||
|
||||
if (a_remotePlayer.isMoving && a_remotePlayer.movementSpeed >= 1.0F) {
|
||||
if (a_remotePlayer.animationGraphSpeed >= 0.0F &&
|
||||
std::isfinite(a_remotePlayer.animationGraphSpeed)) {
|
||||
a_slot.puppetGraphSpeed = a_remotePlayer.animationGraphSpeed;
|
||||
} else {
|
||||
a_slot.puppetGraphSpeed = F4T::ProxyAnimationSync::MapMovementSpeedToGraphSpeed(
|
||||
a_remotePlayer.movementSpeed,
|
||||
a_remotePlayer.isSprinting);
|
||||
}
|
||||
} else {
|
||||
a_slot.puppetGraphSpeed = -1.0F;
|
||||
}
|
||||
|
||||
F4T::ProxyPuppet::SetProxyLocomotionState(
|
||||
a_proxy.GetFormID(),
|
||||
a_slot.puppetGraphSpeed,
|
||||
a_slot.puppetDirection);
|
||||
|
||||
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
|
||||
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
|
||||
@@ -5313,32 +5340,54 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
|
||||
|
||||
proxy->SetHeading(slot.targetHeading);
|
||||
|
||||
const auto frameNow = std::chrono::steady_clock::now();
|
||||
float frameDeltaSeconds = 1.0F / 60.0F;
|
||||
if (slot.lastSmoothMoveTime != std::chrono::steady_clock::time_point{}) {
|
||||
frameDeltaSeconds =
|
||||
std::chrono::duration<float>(frameNow - slot.lastSmoothMoveTime).count();
|
||||
}
|
||||
slot.lastSmoothMoveTime = frameNow;
|
||||
frameDeltaSeconds = std::clamp(frameDeltaSeconds, 1.0F / 120.0F, 0.1F);
|
||||
|
||||
// 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);
|
||||
F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
|
||||
*proxy,
|
||||
RE::NiPoint3{},
|
||||
frameDeltaSeconds,
|
||||
-1.0F,
|
||||
slot.puppetDirection);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kinematic smoothing toward the network target.
|
||||
// We intentionally avoid proxy->Move() + character-controller velocity
|
||||
// injection: SetLinearVelocityImpl physically drives the Havok controller,
|
||||
// which fights this SetPosition pin and the proxy's active AIProcess, causing
|
||||
// forward/back oscillation. SetPosition pins the actor deterministically; the
|
||||
// locomotion animation is driven by the graph "Speed" variable instead.
|
||||
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);
|
||||
}
|
||||
|
||||
proxy->SetPosition(nextPosition, true);
|
||||
const RE::NiPoint3 frameDelta{
|
||||
nextPosition.x - currentPosition.x,
|
||||
nextPosition.y - currentPosition.y,
|
||||
nextPosition.z - currentPosition.z
|
||||
};
|
||||
|
||||
// Move through the character controller so FO4's motion-feedback path sees
|
||||
// real velocity (within-state walk cadence). SetPosition alone leaves ~0
|
||||
// controller velocity, so every sub-run pace used the same authored walk.
|
||||
proxy->Move(frameDeltaSeconds, frameDelta, true);
|
||||
F4T::ProxyPuppet::ApplyProxyLocomotionFrame(
|
||||
*proxy,
|
||||
frameDelta,
|
||||
frameDeltaSeconds,
|
||||
slot.puppetGraphSpeed,
|
||||
slot.puppetDirection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,9 +497,10 @@ namespace F4T::ProxyAnimationSync
|
||||
|
||||
// Hand the puppet Update hook the speed to re-assert after the engine resets
|
||||
// it each frame. Negative when idle so the engine's idle Speed (0) stands.
|
||||
F4T::ProxyPuppet::SetProxyDesiredSpeed(
|
||||
F4T::ProxyPuppet::SetProxyLocomotionState(
|
||||
actorId,
|
||||
desired.isMoving ? desired.graphSpeed : -1.0F);
|
||||
desired.isMoving ? desired.graphSpeed : -1.0F,
|
||||
a_remotePlayer.angleZ);
|
||||
|
||||
// Throttled per-update diagnostic: shows what speed the proxy actually receives
|
||||
// vs. computes, and reads back the live graph variables to detect engine clobber.
|
||||
|
||||
+163
-34
@@ -4,6 +4,15 @@
|
||||
|
||||
#include "F4TNetworking.h"
|
||||
|
||||
#include "RE/A/AIProcess.h"
|
||||
#include "RE/A/ActorMotionFeedbackData.h"
|
||||
#include "RE/A/ActorMotionFeedbackOutput.h"
|
||||
#include "RE/B/bhkCharacterController.h"
|
||||
#include "RE/H/HighProcessData.h"
|
||||
#include "RE/I/IAnimationGraphManagerHolder.h"
|
||||
#include "RE/M/MiddleHighProcessData.h"
|
||||
#include "RE/N/NiPoint3.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <iterator>
|
||||
@@ -17,40 +26,41 @@ namespace F4T::ProxyPuppet
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct ProxyLocomotionState
|
||||
{
|
||||
float speed{ -1.0F };
|
||||
float direction{ 0.0F };
|
||||
};
|
||||
|
||||
std::shared_mutex g_proxyFormIdMutex;
|
||||
std::unordered_set<std::uint32_t> g_proxyFormIds;
|
||||
std::unordered_map<std::uint32_t, float> g_proxyDesiredSpeed;
|
||||
std::unordered_map<std::uint32_t, ProxyLocomotionState> g_proxyLocomotion;
|
||||
|
||||
bool g_hookInstalled = false;
|
||||
|
||||
// Actor primary-vtable index of Update(float). CommonLibF4 documents this
|
||||
// virtual at offset 0xCF (see RE/A/Actor.h). This is the per-frame actor
|
||||
// update that is genuinely dispatched through the vtable (unlike the
|
||||
// internal feedback routine at 0x129, which the engine calls by direct
|
||||
// address, so hooking its vtable slot did nothing).
|
||||
//
|
||||
// On a SetPosition-driven proxy this update resets the locomotion graph
|
||||
// "Speed" to 0 (controller velocity is ~0), which is why every proxy played
|
||||
// the slowest in-state walk. We let Update run, then re-assert our Speed.
|
||||
constexpr std::size_t kUpdateVtableIndex = 0xCF;
|
||||
constexpr std::size_t kComputeFeedbackVtableIndex = 0x128;
|
||||
constexpr std::size_t kUpdateFeedbackVtableIndex = 0x129;
|
||||
|
||||
// x64 has a single calling convention, so the virtual member
|
||||
// `void Update(float)` is binary-compatible with this free function
|
||||
// (this -> rcx, delta -> xmm1).
|
||||
using UpdateFn = void (*)(RE::Actor*, float);
|
||||
using ComputeFeedbackFn =
|
||||
bool (*)(RE::Actor*, const RE::ActorMotionFeedbackData&, float, RE::ActorMotionFeedbackOutput&);
|
||||
using UpdateFeedbackFn = bool (*)(RE::Actor*, const RE::ActorMotionFeedbackOutput&);
|
||||
|
||||
UpdateFn g_originalUpdate = nullptr;
|
||||
|
||||
std::atomic<std::uint64_t> g_hookCallCount{ 0 };
|
||||
std::atomic<std::uint64_t> g_proxyReassertCount{ 0 };
|
||||
std::atomic<std::uint64_t> g_motionFeedbackPushCount{ 0 };
|
||||
|
||||
float GetProxyDesiredSpeed(std::uint32_t a_formID)
|
||||
ProxyLocomotionState GetProxyLocomotionState(std::uint32_t a_formID)
|
||||
{
|
||||
const std::shared_lock lock{ g_proxyFormIdMutex };
|
||||
const auto it = g_proxyDesiredSpeed.find(a_formID);
|
||||
return it != g_proxyDesiredSpeed.end() ? it->second : -1.0F;
|
||||
const auto it = g_proxyLocomotion.find(a_formID);
|
||||
return it != g_proxyLocomotion.end() ? it->second : ProxyLocomotionState{};
|
||||
}
|
||||
|
||||
void WriteProxySpeed(RE::Actor* a_this, float a_speed)
|
||||
void WriteProxyGraphSpeed(RE::Actor* a_this, float a_speed)
|
||||
{
|
||||
if (a_speed < 0.0F || !std::isfinite(a_speed)) {
|
||||
return;
|
||||
@@ -60,29 +70,103 @@ namespace F4T::ProxyPuppet
|
||||
graphHolder->SetGraphVariableFloat(RE::BSFixedString{ "SpeedSampled" }, a_speed);
|
||||
}
|
||||
|
||||
void SyncControllerMotionFields(RE::Actor& a_actor, const RE::NiPoint3& a_velocity, float a_speed)
|
||||
{
|
||||
auto* process = a_actor.currentProcess;
|
||||
if (!process) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auto* middleHigh = process->middleHigh) {
|
||||
middleHigh->desiredSpeed = a_speed;
|
||||
middleHigh->animationSpeed = a_speed;
|
||||
|
||||
if (auto* controller = middleHigh->charController.get()) {
|
||||
controller->cachedLinearVelocity = a_velocity;
|
||||
controller->outVelocity = RE::hkVector4f{ a_velocity.x, a_velocity.y, a_velocity.z, 0.0F };
|
||||
}
|
||||
}
|
||||
|
||||
if (auto* high = process->high) {
|
||||
high->movementOutput.handlerData.movementSpeed = a_speed;
|
||||
high->movementOutput.tweenerData.velocity = a_velocity;
|
||||
high->pathingCurrentMovementSpeed = a_velocity;
|
||||
high->pathingDesiredMovementSpeed = a_velocity;
|
||||
}
|
||||
}
|
||||
|
||||
bool TryPushMotionFeedbackToGraph(
|
||||
RE::Actor& a_actor,
|
||||
const RE::NiPoint3& a_worldDelta,
|
||||
float a_deltaSeconds,
|
||||
float a_graphSpeed,
|
||||
float a_direction)
|
||||
{
|
||||
if (a_graphSpeed < 0.0F || !std::isfinite(a_graphSpeed) || !std::isfinite(a_direction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
REL::Relocation<std::uintptr_t> actorVtable{ RE::Actor::VTABLE[0] };
|
||||
const auto computeFeedback = reinterpret_cast<ComputeFeedbackFn>(
|
||||
actorVtable.get()[kComputeFeedbackVtableIndex]);
|
||||
const auto updateFeedback = reinterpret_cast<UpdateFeedbackFn>(
|
||||
actorVtable.get()[kUpdateFeedbackVtableIndex]);
|
||||
|
||||
if (!updateFeedback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RE::ActorMotionFeedbackOutput feedback{};
|
||||
feedback.speed = a_graphSpeed;
|
||||
feedback.direction = a_direction;
|
||||
|
||||
if (computeFeedback) {
|
||||
RE::ActorMotionFeedbackData motionData{};
|
||||
motionData.desiredWorldDelta = a_worldDelta;
|
||||
motionData.actualWorldDelta = a_worldDelta;
|
||||
motionData.currentHeading = a_direction;
|
||||
motionData.previousSpeed = a_graphSpeed;
|
||||
motionData.previousDirection = a_direction;
|
||||
|
||||
const auto safeDelta = std::clamp(a_deltaSeconds, 1.0F / 120.0F, 0.1F);
|
||||
if (computeFeedback(&a_actor, motionData, safeDelta, feedback)) {
|
||||
feedback.speed = a_graphSpeed;
|
||||
}
|
||||
}
|
||||
|
||||
const auto pushed = updateFeedback(&a_actor, feedback);
|
||||
if (pushed) {
|
||||
g_motionFeedbackPushCount.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
void UpdateHook(RE::Actor* a_this, float a_delta)
|
||||
{
|
||||
g_hookCallCount.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
const auto isProxy = a_this && IsProxyFormID(a_this->GetFormID());
|
||||
if (!isProxy) {
|
||||
// Real actors (and the player, on a separate vtable) update normally.
|
||||
g_originalUpdate(a_this, a_delta);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto speed = GetProxyDesiredSpeed(a_this->GetFormID());
|
||||
const auto locomotion = GetProxyLocomotionState(a_this->GetFormID());
|
||||
|
||||
// Puppet model: set the networked locomotion speed, then run the actor
|
||||
// update WITHOUT AI. The full Update's AI step recomputes/zeroes the
|
||||
// locomotion Speed from the (near-zero, SetPosition-driven) controller
|
||||
// velocity BEFORE the pose is generated, which is why writing Speed after
|
||||
// Update held the value but never changed the visible cadence. UpdateNoAI
|
||||
// is the engine's own "update minus AI" path, so the pose is generated
|
||||
// against the Speed we set here.
|
||||
WriteProxySpeed(a_this, speed);
|
||||
WriteProxyGraphSpeed(a_this, locomotion.speed);
|
||||
a_this->UpdateNoAI(a_delta);
|
||||
WriteProxySpeed(a_this, speed);
|
||||
|
||||
if (locomotion.speed >= 0.0F) {
|
||||
const RE::NiPoint3 zeroVelocity{};
|
||||
SyncControllerMotionFields(*a_this, zeroVelocity, locomotion.speed);
|
||||
TryPushMotionFeedbackToGraph(
|
||||
*a_this,
|
||||
RE::NiPoint3{},
|
||||
a_delta,
|
||||
locomotion.speed,
|
||||
locomotion.direction);
|
||||
WriteProxyGraphSpeed(a_this, locomotion.speed);
|
||||
}
|
||||
|
||||
g_proxyReassertCount.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
@@ -109,7 +193,7 @@ namespace F4T::ProxyPuppet
|
||||
const auto message =
|
||||
F4T::Networking::GetLocalPlayerLogPrefix() +
|
||||
(g_hookInstalled
|
||||
? " Proxy puppet Update hook installed: networked proxy locomotion Speed is re-asserted after each engine actor update."
|
||||
? " Proxy puppet Update hook installed: proxies use UpdateNoAI + manual motion feedback for cadence."
|
||||
: " Proxy puppet Update hook FAILED to install: original Actor::Update was null.");
|
||||
REX::INFO(std::string_view{ message });
|
||||
|
||||
@@ -129,20 +213,65 @@ namespace F4T::ProxyPuppet
|
||||
const std::unique_lock lock{ g_proxyFormIdMutex };
|
||||
g_proxyFormIds = std::move(updated);
|
||||
|
||||
// Drop desired-speed entries for proxies that are no longer active.
|
||||
for (auto it = g_proxyDesiredSpeed.begin(); it != g_proxyDesiredSpeed.end();) {
|
||||
it = g_proxyFormIds.contains(it->first) ? std::next(it) : g_proxyDesiredSpeed.erase(it);
|
||||
for (auto it = g_proxyLocomotion.begin(); it != g_proxyLocomotion.end();) {
|
||||
it = g_proxyFormIds.contains(it->first) ? std::next(it) : g_proxyLocomotion.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed)
|
||||
void SetProxyLocomotionState(std::uint32_t a_formID, float a_speed, float a_direction)
|
||||
{
|
||||
if (a_formID == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::unique_lock lock{ g_proxyFormIdMutex };
|
||||
g_proxyDesiredSpeed[a_formID] = a_speed;
|
||||
g_proxyLocomotion[a_formID] = ProxyLocomotionState{ a_speed, a_direction };
|
||||
}
|
||||
|
||||
void SetProxyDesiredSpeed(std::uint32_t a_formID, float a_speed)
|
||||
{
|
||||
const auto existing = GetProxyLocomotionState(a_formID);
|
||||
SetProxyLocomotionState(a_formID, a_speed, existing.direction);
|
||||
}
|
||||
|
||||
void ApplyProxyLocomotionFrame(
|
||||
RE::Actor& a_actor,
|
||||
const RE::NiPoint3& a_worldDelta,
|
||||
float a_deltaSeconds,
|
||||
float a_graphSpeed,
|
||||
float a_direction)
|
||||
{
|
||||
if (!IsProxyFormID(a_actor.GetFormID())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const float safeDelta = std::clamp(a_deltaSeconds, 1.0F / 120.0F, 0.1F);
|
||||
RE::NiPoint3 velocity{};
|
||||
if (safeDelta > 0.0F) {
|
||||
velocity.x = a_worldDelta.x / safeDelta;
|
||||
velocity.y = a_worldDelta.y / safeDelta;
|
||||
velocity.z = a_worldDelta.z / safeDelta;
|
||||
}
|
||||
|
||||
const float horizontalSpeed = std::sqrt((velocity.x * velocity.x) + (velocity.y * velocity.y));
|
||||
const float feedbackSpeed =
|
||||
(a_graphSpeed >= 0.0F && std::isfinite(a_graphSpeed)) ? a_graphSpeed : horizontalSpeed;
|
||||
|
||||
if (feedbackSpeed < 8.0F) {
|
||||
SetProxyLocomotionState(a_actor.GetFormID(), -1.0F, a_direction);
|
||||
SyncControllerMotionFields(a_actor, {}, 0.0F);
|
||||
return;
|
||||
}
|
||||
|
||||
SetProxyLocomotionState(a_actor.GetFormID(), feedbackSpeed, a_direction);
|
||||
SyncControllerMotionFields(a_actor, velocity, feedbackSpeed);
|
||||
TryPushMotionFeedbackToGraph(
|
||||
a_actor,
|
||||
a_worldDelta,
|
||||
a_deltaSeconds,
|
||||
feedbackSpeed,
|
||||
a_direction);
|
||||
WriteProxyGraphSpeed(&a_actor, feedbackSpeed);
|
||||
}
|
||||
|
||||
bool IsProxyFormID(std::uint32_t a_formID)
|
||||
|
||||
Reference in New Issue
Block a user