Throttle transform sends and smooth proxy movement

Separate normal transform send cadence from movement logging and improve proxy smoothing. Key changes:

- Polling/send logic (plugin/src/main.cpp): introduced distinct send vs log intervals (100ms send, 1s log), lowered send thresholds (≈3.0 units position, 0.02 rad rotation), renamed/clarified functions, and added special movement types (worldspace_change, cell_change, teleport) that bypass the send gate and are logged immediately. Normal movement targets roughly 10 Hz while moving to avoid packet-per-frame traffic.
- Proxy controller (plugin/src/F4TProxyActorController.cpp): tuned proxy position lerp alpha to 0.15 for smoother motion, renamed movement-interval variables for clarity, and removed the generic per-update movement gate so normal proxy visual updates are applied each game update while only the safety-offset path is throttled.
- Documentation updates (docs/architecture.md, docs/dev-log.md, protocol/player-sync.md): describe the new send/log separation, cadence and thresholds, smoothing behavior, and recorded dev-log about smoothing tests and results.

These changes reduce network noise, produce smoother remote visuals, and keep readable local movement logs while preserving immediate sends for large/categorical movement changes.
This commit is contained in:
2026-06-02 13:01:02 +12:00
parent 8dbe3117b0
commit 4ebc4a2814
5 changed files with 197 additions and 80 deletions
+9 -8
View File
@@ -23,8 +23,8 @@ namespace
constexpr auto kTestPluginName = "Fallout4Together_Test.esp";
constexpr RE::TESFormID kProxyFallbackLocalFormId = 0x0020A2;
constexpr auto kProxyOffsetX = 150.0F;
constexpr float kProxyPositionLerpAlpha = 0.25F;
constexpr auto kMovementInterval = 200ms;
constexpr float kProxyPositionLerpAlpha = 0.15F;
constexpr auto kSafetyOffsetMovementInterval = 200ms;
constexpr auto kWarningLogInterval = 5s;
constexpr auto kNotInTestCellLogInterval = 30s;
@@ -50,7 +50,7 @@ namespace
bool g_remoteStateMovedLogged = false;
bool g_proxySmoothingEnabledLogged = false;
bool g_firstSmoothedMovementLogged = false;
auto g_lastMovementTime = std::chrono::steady_clock::time_point{};
auto g_lastSafetyOffsetMovementTime = std::chrono::steady_clock::time_point{};
std::optional<std::uint32_t> g_representedPlayerId;
std::unordered_map<std::string, std::chrono::steady_clock::time_point> g_lastWarningLogTimes;
@@ -393,14 +393,15 @@ namespace
return proxy;
}
bool ShouldMoveProxy()
bool ShouldMoveSafetyOffsetProxy()
{
const auto now = std::chrono::steady_clock::now();
if (g_lastMovementTime.time_since_epoch().count() != 0 && now - g_lastMovementTime < kMovementInterval) {
if (g_lastSafetyOffsetMovementTime.time_since_epoch().count() != 0 &&
now - g_lastSafetyOffsetMovementTime < kSafetyOffsetMovementInterval) {
return false;
}
g_lastMovementTime = now;
g_lastSafetyOffsetMovementTime = now;
return true;
}
@@ -563,7 +564,7 @@ namespace F4T::ProxyActorController
if constexpr (kProxyMovementMode == ProxyMovementMode::kSafetyOffsetTest) {
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
if (!proxy || !ShouldMoveSafetyOffsetProxy()) {
return;
}
@@ -577,7 +578,7 @@ namespace F4T::ProxyActorController
}
auto* proxy = ResolveProxy(*parentCell, *player);
if (!proxy || !ShouldMoveProxy()) {
if (!proxy) {
return;
}
+62 -34
View File
@@ -24,10 +24,10 @@ namespace
std::uint32_t worldspaceId;
};
constexpr auto kPositionLogThreshold = 10.0F;
constexpr auto kRotationLogThresholdDegrees = 1.0F;
constexpr auto kRotationLogThresholdRadians = kRotationLogThresholdDegrees * 3.14159265358979323846F / 180.0F;
constexpr auto kMinimumLogInterval = 1s;
constexpr auto kTransformSendInterval = 100ms;
constexpr auto kTransformLogInterval = 1s;
constexpr auto kTransformSendPositionThreshold = 3.0F;
constexpr auto kTransformSendRotationThreshold = 0.02F;
constexpr auto kTeleportDistanceThreshold = 5000.0F;
std::string GetLocalPlayerLogMessage(std::string_view a_message)
@@ -99,7 +99,7 @@ namespace
return (deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ);
}
bool HasTransformChanged(const PlayerTransform& a_previous, const PlayerTransform& a_current)
bool HasMeaningfulTransformDelta(const PlayerTransform& a_previous, const PlayerTransform& a_current)
{
auto deltaAngleZ = a_current.angleZ - a_previous.angleZ;
@@ -113,10 +113,10 @@ namespace
}
const auto squaredPositionDelta = GetSquaredPositionDistance(a_previous, a_current);
constexpr auto squaredPositionThreshold = kPositionLogThreshold * kPositionLogThreshold;
constexpr auto squaredPositionThreshold = kTransformSendPositionThreshold * kTransformSendPositionThreshold;
return squaredPositionDelta > squaredPositionThreshold ||
deltaAngleZ > kRotationLogThresholdRadians || deltaAngleZ < -kRotationLogThresholdRadians;
deltaAngleZ > kTransformSendRotationThreshold || deltaAngleZ < -kTransformSendRotationThreshold;
}
bool HasCellChanged(const PlayerLocation& a_previous, const PlayerLocation& a_current)
@@ -146,7 +146,37 @@ namespace
a_transform.angleZ));
}
void CheckAndLogPlayerPositionChange()
const char* GetSpecialMovementType(
const PlayerLocation& a_previousLocation,
const PlayerLocation& a_currentLocation,
const PlayerTransform& a_previousTransform,
const PlayerTransform& a_currentTransform)
{
if (HasWorldspaceChanged(a_previousLocation, a_currentLocation)) {
return "worldspace_change";
}
if (HasCellChanged(a_previousLocation, a_currentLocation)) {
return "cell_change";
}
if (HasTeleportDistance(a_previousTransform, a_currentTransform)) {
return "teleport";
}
return "normal";
}
void LogSpecialMovement(std::string_view a_movementType)
{
if (a_movementType == "worldspace_change") {
LogInfoWithLocalPlayerPrefix("Detected worldspace change. Sending immediate transform update.");
} else if (a_movementType == "cell_change") {
LogInfoWithLocalPlayerPrefix("Detected cell change. Sending immediate transform update.");
} else if (a_movementType == "teleport") {
LogInfoWithLocalPlayerPrefix("Detected teleport/large position jump. Sending immediate transform update.");
}
}
void PollLocalPlayerTransform()
{
const auto* player = TryGetLocalPlayer();
if (!player) {
@@ -161,42 +191,33 @@ namespace
static bool hasLastTransform = false;
static PlayerTransform lastSentTransform{};
static PlayerLocation lastKnownLocation{};
static auto lastLogTime = std::chrono::steady_clock::now();
static auto lastSendTime = std::chrono::steady_clock::time_point{};
static auto lastLogTime = std::chrono::steady_clock::time_point{};
const auto currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player);
const auto currentTime = std::chrono::steady_clock::now();
if (!hasLastTransform) {
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSendTime = currentTime;
lastLogTime = currentTime;
hasLastTransform = true;
return;
}
const char* movementType = "normal";
if (HasWorldspaceChanged(lastKnownLocation, currentLocation)) {
LogInfoWithLocalPlayerPrefix("Detected worldspace change. Sending immediate transform update.");
movementType = "worldspace_change";
} else if (HasCellChanged(lastKnownLocation, currentLocation)) {
LogInfoWithLocalPlayerPrefix("Detected cell change. Sending immediate transform update.");
movementType = "cell_change";
} else if (HasTeleportDistance(lastSentTransform, currentTransform)) {
LogInfoWithLocalPlayerPrefix("Detected teleport/large position jump. Sending immediate transform update.");
movementType = "teleport";
}
const auto* movementType =
GetSpecialMovementType(lastKnownLocation, currentLocation, lastSentTransform, currentTransform);
const auto isSpecialMovement = movementType != std::string_view{ "normal" };
if (!isSpecialMovement && !HasTransformChanged(lastSentTransform, currentTransform)) {
if (!isSpecialMovement && !HasMeaningfulTransformDelta(lastSentTransform, currentTransform)) {
return;
}
const auto currentTime = std::chrono::steady_clock::now();
if (!isSpecialMovement && currentTime - lastLogTime < kMinimumLogInterval) {
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
return;
}
// Player transforms can change by tiny amounts every frame. Throttling keeps the
// readout useful for debugging movement without flooding Fallout4Together.log or
// turning this first local networking test into a packet every frame.
// Normal movement sends more often than logs, but still avoids packet-per-frame noise.
const auto sentTransform = F4T::Networking::SendTransformPacket(
currentTransform.x,
currentTransform.y,
@@ -208,11 +229,18 @@ namespace
currentLocation.hasWorldspaceId,
currentLocation.worldspaceId);
if (sentTransform) {
LogPlayerTransform(currentTransform);
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSendTime = currentTime;
if (isSpecialMovement) {
LogSpecialMovement(movementType);
lastLogTime = currentTime;
} else if (currentTime - lastLogTime >= kTransformLogInterval) {
LogPlayerTransform(currentTransform);
lastLogTime = currentTime;
}
}
lastSentTransform = currentTransform;
lastKnownLocation = currentLocation;
lastLogTime = currentTime;
}
void StartPlayerPositionPolling()
@@ -224,14 +252,14 @@ namespace
const auto* taskInterface = F4SE::GetTaskInterface();
if (!taskInterface) {
LogWarningWithLocalPlayerPrefix("Failed to get F4SE task interface; player position changes will not be logged.");
LogWarningWithLocalPlayerPrefix("Failed to get F4SE task interface; player position changes will not be sent or logged.");
return;
}
// The player reference may not exist at plugin load time. A permanent task lets us
// check on the game thread after load, while the logging gate keeps updates readable.
// check on the game thread after load, while separate send and log gates keep updates useful.
taskInterface->AddTaskPermanent([]() {
CheckAndLogPlayerPositionChange();
PollLocalPlayerTransform();
F4T::ProxyActorController::Update();
});