Add position smoothing and movement snapping

Introduce simple position smoothing for normal proxy movement and snap special movements. Adds kProxyPositionLerpAlpha, Lerp/LerpPosition helpers, and ShouldSnapRemoteMovement to lerp proxy position on normal updates while snapping on cell_change, worldspace_change, and teleport. Update MoveProxyToRemotePlayer to use lerp or snap, keep heading snapping (TODO: wrapped angle smoothing), and add throttled/readable logs for smoothing start, first smoothed movement, snaps, and idle-on-cell-mismatch. Minor messaging change on disconnect to leave proxy idle at last position. Update docs (architecture, setup, protocol) and dev log to reflect the new behavior and testing notes; dynamic spawning/multiple proxies remain out of scope.
This commit is contained in:
2026-06-02 12:47:12 +12:00
parent 46b2fa8b9b
commit 8dbe3117b0
5 changed files with 112 additions and 7 deletions
+3 -2
View File
@@ -23,7 +23,9 @@ both send and receive paths:
9. The plugin and fake client remove disconnected players from their remote 9. The plugin and fake client remove disconnected players from their remote
player tables. player tables.
10. On the game-thread update path, the plugin reads a copied remote-player 10. On the game-thread update path, the plugin reads a copied remote-player
snapshot and moves the single placed proxy actor in `F4TTestCell01`. snapshot and moves the single placed proxy actor in `F4TTestCell01`,
smoothing normal movement and snapping cell changes, worldspace changes, and
teleports.
The Fallout 4 plugin still does not dynamically spawn remote actors. The Fallout 4 plugin still does not dynamically spawn remote actors.
@@ -102,7 +104,6 @@ update path.
- Remote actor spawning - Remote actor spawning
- Multiple proxy actors - Multiple proxy actors
- Interpolation or smoothing
- Combat sync - Combat sync
- Quest sync - Quest sync
- Inventory sync - Inventory sync
+38
View File
@@ -644,3 +644,41 @@ Use this format for future updates:
- Build the plugin with `xmake build`. - Build the plugin with `xmake build`.
- Run the single-client, two-client, and disconnect tests in `F4TTestCell01`. - Run the single-client, two-client, and disconnect tests in `F4TTestCell01`.
## 2026-06-02
### What Changed
- Added simple position smoothing for normal remote proxy movement.
- Kept proxy actor movement on the `F4TProxyActorController` game-thread update
path using copied remote-player snapshots.
- Kept the single-proxy, lowest-`playerId` selection behavior.
- Added snap movement for `cell_change`, `worldspace_change`, and `teleport`
remote transforms.
- Left rotation snapping in place with a TODO for wrapped angle smoothing.
- Left the proxy idle at its last position when the represented remote player
disconnects, disappears, or leaves the same cell.
- Added readable one-shot and throttled logs for smoothing, first smoothed
movement, special movement snaps, disappearance, and same-cell mismatch idle
behavior.
### What Worked
- Dynamic spawning and multiple proxy actors remain out of scope.
- Networking still only updates plain remote-player state and does not touch
Fallout 4 actors or references.
### What Broke
- Not tested in-game yet.
### Notes
- Hiding or disabling the proxy actor is still deferred. The current safe
behavior is to leave the placed proxy idle at its last valid position.
### Next Steps
- Build the plugin with `xmake build`.
- Run the single-client, two-client, special-movement, disconnect, and cell-leave
tests in `F4TTestCell01`.
+2 -1
View File
@@ -38,6 +38,8 @@ This means the plugin should be able to:
* Store remote player state internally * Store remote player state internally
* Move the single placed test proxy actor from remote player state on the game * Move the single placed test proxy actor from remote player state on the game
thread without dynamically spawning actors thread without dynamically spawning actors
* Smooth normal proxy movement and snap cell changes, worldspace changes, and
teleports safely
## Not Included Yet ## Not Included Yet
@@ -45,7 +47,6 @@ The plugin does not currently include:
* Dynamic actor spawning * Dynamic actor spawning
* Multiple proxy actors * Multiple proxy actors
* Interpolation or smoothing
* Remote player spawning * Remote player spawning
* Combat sync * Combat sync
* Quest sync * Quest sync
+66 -3
View File
@@ -23,6 +23,7 @@ namespace
constexpr auto kTestPluginName = "Fallout4Together_Test.esp"; constexpr auto kTestPluginName = "Fallout4Together_Test.esp";
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2; constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F; constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.25F;
constexpr auto kMovementInterval = 200ms; constexpr auto kMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s; constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s; constexpr auto kNotInTestCellLogInterval = 30s;
@@ -47,6 +48,8 @@ namespace
bool g_safetyOffsetMovedLogged = false; bool g_safetyOffsetMovedLogged = false;
bool g_remoteStateMovementStartedLogged = false; bool g_remoteStateMovementStartedLogged = false;
bool g_remoteStateMovedLogged = false; bool g_remoteStateMovedLogged = false;
bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false;
auto g_lastMovementTime = std::chrono::steady_clock::time_point{}; auto g_lastMovementTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_representedPlayerId; std::optional<std::uint32_t> g_representedPlayerId;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes; std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
@@ -102,6 +105,25 @@ namespace
} }
} }
float Lerp(float a_current, float a_target, float a_alpha)
{
return a_current + ((a_target - a_current) * a_alpha);
}
RE::NiPoint3 LerpPosition(const RE::NiPoint3& a_current, const RE::NiPoint3& a_target, float a_alpha)
{
return {
Lerp(a_current.x, a_target.x, a_alpha),
Lerp(a_current.y, a_target.y, a_alpha),
Lerp(a_current.z, a_target.z, a_alpha)
};
}
bool ShouldSnapRemoteMovement(std::string_view a_movementType)
{
return a_movementType == "cell_change" || a_movementType == "worldspace_change" || a_movementType == "teleport";
}
bool HasEditorId(const RE::TESForm& a_form, std::string_view a_editorId) bool HasEditorId(const RE::TESForm& a_form, std::string_view a_editorId)
{ {
const auto* editorId = a_form.GetFormEditorID(); const auto* editorId = a_form.GetFormEditorID();
@@ -392,10 +414,11 @@ namespace
}); });
if (!representedPlayerStillExists) { if (!representedPlayerStillExists) {
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Represented remote player {} disconnected or disappeared from state; stopping proxy movement. TODO: hide or disable the proxy actor after disconnect.", "Represented remote player {} disconnected or disappeared from state; leaving proxy actor idle at its last position. TODO: hide or disable the proxy actor after disconnect.",
*g_representedPlayerId)); *g_representedPlayerId));
g_representedPlayerId.reset(); g_representedPlayerId.reset();
g_remoteStateMovedLogged = false; g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
} }
} }
@@ -426,10 +449,17 @@ namespace
if (g_representedPlayerId != selectedPlayer->playerId) { if (g_representedPlayerId != selectedPlayer->playerId) {
g_representedPlayerId = selectedPlayer->playerId; g_representedPlayerId = selectedPlayer->playerId;
g_remoteStateMovedLogged = false; g_remoteStateMovedLogged = false;
g_firstSmoothedMovementLogged = false;
if (!g_remoteStateMovementStartedLogged) { if (!g_remoteStateMovementStartedLogged) {
LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started."); LogInfoWithLocalPlayerPrefix("Remote-state-driven proxy movement started.");
g_remoteStateMovementStartedLogged = true; g_remoteStateMovementStartedLogged = true;
} }
if (!g_proxySmoothingEnabledLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor smoothing enabled for normal movement with position lerp alpha {:.2f}. Rotation still snaps; TODO: add wrapped angle smoothing later.",
kProxyPositionLerpAlpha));
g_proxySmoothingEnabledLogged = true;
}
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Proxy actor {} is representing remote player {}.", "Proxy actor {} is representing remote player {}.",
kProxyEditorId, kProxyEditorId,
@@ -458,14 +488,41 @@ namespace
void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer) void MoveProxyToRemotePlayer(RE::Actor& a_proxy, const RemotePlayer& a_remotePlayer)
{ {
auto targetPosition = a_proxy.GetPosition(); RE::NiPoint3 targetPosition = a_proxy.GetPosition();
targetPosition.x = a_remotePlayer.x; targetPosition.x = a_remotePlayer.x;
targetPosition.y = a_remotePlayer.y; targetPosition.y = a_remotePlayer.y;
targetPosition.z = a_remotePlayer.z; targetPosition.z = a_remotePlayer.z;
a_proxy.SetPosition(targetPosition, true); const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
const auto nextPosition = shouldSnap ? targetPosition : LerpPosition(a_proxy.GetPosition(), targetPosition, kProxyPositionLerpAlpha);
a_proxy.SetPosition(nextPosition, true);
// TODO: Smooth rotation with wrapped angle interpolation. Heading snaps for now
// to avoid wraparound bugs near the angle boundary.
a_proxy.SetHeading(a_remotePlayer.angleZ); a_proxy.SetHeading(a_remotePlayer.angleZ);
if (shouldSnap) {
LogThrottledInfo(
"proxy_snap_" + std::to_string(a_remotePlayer.playerId) + "_" + a_remotePlayer.movementType,
std::format(
"Snapped proxy actor {} to remote player {} because movementType={} at X={:.2f}, Y={:.2f}, Z={:.2f}.",
kProxyEditorId,
a_remotePlayer.playerId,
a_remotePlayer.movementType,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z));
} else if (!g_firstSmoothedMovementLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"First smoothed proxy movement for remote player {}: current moves toward target X={:.2f}, Y={:.2f}, Z={:.2f} with alpha {:.2f}.",
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z,
kProxyPositionLerpAlpha));
g_firstSmoothedMovementLogged = true;
}
if (!g_remoteStateMovedLogged) { if (!g_remoteStateMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format( LogInfoWithLocalPlayerPrefix(std::format(
"Moved proxy actor {} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}.", "Moved proxy actor {} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}.",
@@ -525,6 +582,12 @@ namespace F4T::ProxyActorController
} }
if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) { if (!IsRemotePlayerInSameLocation(*selectedRemotePlayer, *player, *parentCell)) {
LogThrottledInfo(
"proxy_idle_cell_mismatch_" + std::to_string(selectedRemotePlayer->playerId),
std::format(
"Proxy actor {} is idle because represented remote player {} is not in the same cell. TODO: hide or disable the proxy actor after cell leave.",
kProxyEditorId,
selectedRemotePlayer->playerId));
return; return;
} }
+3 -1
View File
@@ -72,12 +72,14 @@ player exists.
- The fake client removes disconnected remote players from its state table. - The fake client removes disconnected remote players from its state table.
- The plugin moves the single placed test proxy actor from a thread-safe - The plugin moves the single placed test proxy actor from a thread-safe
remote-player snapshot on the game-thread update path. remote-player snapshot on the game-thread update path.
- Normal proxy movement is smoothed with a simple position lerp, while
`cell_change`, `worldspace_change`, and `teleport` transforms snap directly to
avoid slow movement across large discontinuities.
No Fallout 4 remote actor is dynamically spawned yet. No Fallout 4 remote actor is dynamically spawned yet.
## Planned Later ## Planned Later
- Add interpolation or smoothing after snap movement is proven stable.
- Add one proxy actor per remote player after the single-proxy test is stable. - Add one proxy actor per remote player after the single-proxy test is stable.
- Dynamically spawn remote actors only after placed-proxy movement is stable. - Dynamically spawn remote actors only after placed-proxy movement is stable.