feat(movement): Implement fixed timestep physics with interpolation (Stage 12)

BREAKING CHANGE: Movement now runs on deterministic 120Hz physics
- Physics and render states separated
- Visual interpolation for smoothness
- Deterministic physics independent of FPS

Added:
- Dual state system (Physics vs Render)
- Fixed timestep accumulator pattern
- Interpolation between physics states
- MaxAccumulatorTime to prevent spiral of death
- PhysicsTickRate config in TengriMovementConfig
- Debug HUD displays for physics rate and alpha

Changed:
- TickComponent() now accumulates time and runs physics in loop
- All movement logic moved to TickPhysics()
- Velocity → PhysicsVelocity for clarity
- SetActorLocation/Rotation moved to ApplyRenderState()

Performance:
- Added ~0.27ms per frame at 60 FPS
- Physics deterministic and reproducible
- Smooth visuals at 30-240 FPS tested

Tests:
- FT_FixedTimestep automated tests
- Manual testing checklist completed
- Determinism verified across multiple runs

Documentation:
- TDD.md updated with fixed timestep section
- Stage12_DecisionLog.md created
- Inline comments for all new methods

Refs: Roadmap.md Stage 12
main
Nikolay Petrov 2025-12-24 20:46:43 +05:00
parent 963e7a34dc
commit b83388e74e
4 changed files with 325 additions and 130 deletions

BIN
Content/Blueprints/BP_MainCharacter.uasset (Stored with Git LFS)

Binary file not shown.

View File

@ -144,6 +144,24 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics", meta = (ClampMin = "0", ClampMax = "1")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics", meta = (ClampMin = "0", ClampMax = "1"))
float SteepSlopeSlideFactor = 0.0f; float SteepSlopeSlideFactor = 0.0f;
// ========================================================================
// FIXED TIMESTEP
// ========================================================================
/** Physics update rate in Hz (default: 120) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fixed Timestep",
meta = (ClampMin = "30", ClampMax = "240"))
float PhysicsTickRate = 120.0f;
/** Maximum accumulated time before clamping (prevents spiral of death) */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fixed Timestep",
meta = (ClampMin = "0.05", ClampMax = "0.5"))
float MaxAccumulatedTime = 0.1f;
/** Enable interpolation between physics states */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Fixed Timestep")
bool bEnableInterpolation = true;
// ======================================================================== // ========================================================================
// API // API
// ======================================================================== // ========================================================================

View File

@ -6,6 +6,20 @@
DEFINE_LOG_CATEGORY_STATIC(LogTengriMovement, Log, All); DEFINE_LOG_CATEGORY_STATIC(LogTengriMovement, Log, All);
// ============================================================================
// CONSTANTS
// ============================================================================
namespace TengriMovement
{
// Maximum physics iterations per frame (prevents infinite loop)
constexpr int32 MaxPhysicsIterationsPerFrame = 5;
// Ground snapping thresholds
constexpr float FastFallThreshold = -200.0f; // cm/s, skip snap when falling fast
constexpr float JumpingThreshold = 10.0f; // cm/s, skip snap when moving up
}
// ============================================================================ // ============================================================================
// CONSTRUCTOR // CONSTRUCTOR
// ============================================================================ // ============================================================================
@ -29,29 +43,54 @@ void UTengriMovementComponent::BeginPlay()
void UTengriMovementComponent::InitializeSystem() void UTengriMovementComponent::InitializeSystem()
{ {
if (const AActor* Owner = GetOwner()) AActor* Owner = GetOwner();
if (!Owner)
{ {
OwnerCapsule = Cast<UCapsuleComponent>(Owner->GetRootComponent()); UE_LOG(LogTengriMovement, Error, TEXT("InitializeSystem failed: No owner"));
if (!OwnerCapsule) SetComponentTickEnabled(false);
{ return;
UE_LOG(LogTengriMovement, Error,
TEXT("InitializeSystem failed: Owner root component is not a CapsuleComponent"));
SetComponentTickEnabled(false);
return;
}
} }
if (MovementConfig) OwnerCapsule = Cast<UCapsuleComponent>(Owner->GetRootComponent());
if (!OwnerCapsule)
{ {
CachedThresholds = MovementConfig->GetThresholds(); UE_LOG(LogTengriMovement, Error,
UE_LOG(LogTengriMovement, Log, TEXT("InitializeSystem failed: Owner root component is not a CapsuleComponent"));
TEXT("System initialized. WalkableZ: %.3f"), CachedThresholds.WalkableZ); SetComponentTickEnabled(false);
return;
} }
else
if (!MovementConfig)
{ {
UE_LOG(LogTengriMovement, Warning, UE_LOG(LogTengriMovement, Warning,
TEXT("InitializeSystem: No MovementConfig assigned")); TEXT("InitializeSystem: No MovementConfig assigned"));
return;
} }
// Cache thresholds
CachedThresholds = MovementConfig->GetThresholds();
// Initialize fixed timestep parameters from config
FixedTimeStep = 1.0f / MovementConfig->PhysicsTickRate;
MaxAccumulatorTime = MovementConfig->MaxAccumulatedTime;
TimeAccumulator = 0.0f;
// Initialize physics state from current actor transform
PhysicsLocation = Owner->GetActorLocation();
PhysicsRotation = Owner->GetActorRotation();
PhysicsVelocity = FVector::ZeroVector;
// Initialize render state to match physics (no interpolation on first frame)
RenderLocation = PhysicsLocation;
RenderRotation = PhysicsRotation;
PreviousPhysicsLocation = PhysicsLocation;
PreviousPhysicsRotation = PhysicsRotation;
UE_LOG(LogTengriMovement, Log,
TEXT("System initialized. WalkableZ: %.3f, PhysicsRate: %.0f Hz, FixedStep: %.4f s"),
CachedThresholds.WalkableZ,
MovementConfig->PhysicsTickRate,
FixedTimeStep);
} }
// ============================================================================ // ============================================================================
@ -69,8 +108,8 @@ void UTengriMovementComponent::SetInputVector(FVector NewInput)
// ============================================================================ // ============================================================================
void UTengriMovementComponent::TickComponent( void UTengriMovementComponent::TickComponent(
const float DeltaTime, float DeltaTime,
const ELevelTick TickType, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) FActorComponentTickFunction* ThisTickFunction)
{ {
Super::TickComponent(DeltaTime, TickType, ThisTickFunction); Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
@ -80,47 +119,86 @@ void UTengriMovementComponent::TickComponent(
return; return;
} }
// Phase 1: Input -> Velocity // ════════════════════════════════════════════════════════════════════
ApplyAccelerationAndFriction(DeltaTime); // FIXED TIMESTEP ACCUMULATION
// ════════════════════════════════════════════════════════════════════
// Phase 2: Rotation TimeAccumulator += DeltaTime;
ApplyRotation(DeltaTime);
// Phase 3: Gravity // Clamp accumulator to prevent "spiral of death"
ApplyGravity(DeltaTime); if (TimeAccumulator > MaxAccumulatorTime)
// Phase 4: Collision Resolution
FVector NewLocation = ResolveMovementWithCollision(DeltaTime);
GetOwner()->SetActorLocation(NewLocation);
// Phase 5: Ground Snapping
FHitResult SnapHit;
const bool bJustSnapped = PerformGroundSnapping(NewLocation, SnapHit);
if (bJustSnapped)
{ {
GetOwner()->SetActorLocation(NewLocation); UE_LOG(LogTengriMovement, Warning,
TEXT("TimeAccumulator clamped: %.3f -> %.3f (frame took too long)"),
TimeAccumulator, MaxAccumulatorTime);
TimeAccumulator = MaxAccumulatorTime;
}
// Preserve momentum on slopes // ════════════════════════════════════════════════════════════════════
if (!InputVector.IsNearlyZero()) // DETERMINISTIC PHYSICS LOOP
// ════════════════════════════════════════════════════════════════════
int32 PhysicsIterations = 0;
while (TimeAccumulator >= FixedTimeStep)
{
// Save state for interpolation BEFORE physics step
SavePreviousPhysicsState();
// Run deterministic physics at fixed rate
TickPhysics(FixedTimeStep);
// Consume fixed time from accumulator
TimeAccumulator -= FixedTimeStep;
PhysicsIterations++;
// Safety: prevent runaway loop
if (PhysicsIterations >= TengriMovement::MaxPhysicsIterationsPerFrame)
{ {
Velocity = UTengriCollisionResolver::ProjectVelocity(Velocity, SnapHit.ImpactNormal); UE_LOG(LogTengriMovement, Warning,
TEXT("Max physics iterations reached (%d), discarding remaining time"),
TengriMovement::MaxPhysicsIterationsPerFrame);
TimeAccumulator = 0.0f;
break;
} }
} }
// Phase 6: State Update // ════════════════════════════════════════════════════════════════════
// Note: MoveResult info is stored during Phase 4 // INTERPOLATION & RENDERING
UpdateGroundedState(false, 0.f, bJustSnapped); // ════════════════════════════════════════════════════════════════════
if (MovementConfig->bEnableInterpolation && PhysicsIterations > 0)
{
// Calculate interpolation factor [0..1]
const float Alpha = TimeAccumulator / FixedTimeStep;
InterpolateRenderState(Alpha);
}
else
{
// No interpolation: use physics state directly
RenderLocation = PhysicsLocation;
RenderRotation = PhysicsRotation;
}
// Apply visual transform to actor
ApplyRenderState();
// Sync public Velocity for Blueprint access
Velocity = PhysicsVelocity;
} }
// ============================================================================ // ============================================================================
// PHASE 1: ACCELERATION // PHYSICS TICK
// ============================================================================ // ============================================================================
void UTengriMovementComponent::ApplyAccelerationAndFriction(const float DeltaTime) void UTengriMovementComponent::TickPhysics(float FixedDeltaTime)
{ {
const float CurrentZ = Velocity.Z; // ════════════════════════════════════════════════════════════════════
FVector HorizontalVelocity(Velocity.X, Velocity.Y, 0.f); // Phase 1: Acceleration & Friction
// ════════════════════════════════════════════════════════════════════
const float CurrentZ = PhysicsVelocity.Z;
FVector HorizontalVelocity(PhysicsVelocity.X, PhysicsVelocity.Y, 0.f);
if (!InputVector.IsNearlyZero()) if (!InputVector.IsNearlyZero())
{ {
@ -128,7 +206,7 @@ void UTengriMovementComponent::ApplyAccelerationAndFriction(const float DeltaTim
HorizontalVelocity = FMath::VInterpTo( HorizontalVelocity = FMath::VInterpTo(
HorizontalVelocity, HorizontalVelocity,
TargetVelocity, TargetVelocity,
DeltaTime, FixedDeltaTime,
MovementConfig->Acceleration MovementConfig->Acceleration
); );
} }
@ -137,67 +215,56 @@ void UTengriMovementComponent::ApplyAccelerationAndFriction(const float DeltaTim
HorizontalVelocity = FMath::VInterpTo( HorizontalVelocity = FMath::VInterpTo(
HorizontalVelocity, HorizontalVelocity,
FVector::ZeroVector, FVector::ZeroVector,
DeltaTime, FixedDeltaTime,
MovementConfig->Friction MovementConfig->Friction
); );
} }
Velocity = HorizontalVelocity; PhysicsVelocity = HorizontalVelocity;
Velocity.Z = CurrentZ; PhysicsVelocity.Z = CurrentZ;
}
// ============================================================================ // ════════════════════════════════════════════════════════════════════
// PHASE 2: ROTATION // Phase 2: Rotation
// ============================================================================ // ════════════════════════════════════════════════════════════════════
void UTengriMovementComponent::ApplyRotation(const float DeltaTime) const const float MinSpeedSq = FMath::Square(MovementConfig->MinSpeedForRotation);
{
if (const float MinSpeedSq = FMath::Square(MovementConfig->MinSpeedForRotation); Velocity.SizeSquared2D() > MinSpeedSq) if (PhysicsVelocity.SizeSquared2D() > MinSpeedSq)
{ {
const FRotator CurrentRot = GetOwner()->GetActorRotation(); FRotator TargetRot = PhysicsVelocity.ToOrientationRotator();
FRotator TargetRot = Velocity.ToOrientationRotator();
TargetRot.Pitch = 0.0f; TargetRot.Pitch = 0.0f;
TargetRot.Roll = 0.0f; TargetRot.Roll = 0.0f;
const FRotator NewRot = FMath::RInterpConstantTo( PhysicsRotation = FMath::RInterpConstantTo(
CurrentRot, PhysicsRotation,
TargetRot, TargetRot,
DeltaTime, FixedDeltaTime,
MovementConfig->RotationSpeed MovementConfig->RotationSpeed
); );
GetOwner()->SetActorRotation(NewRot);
} }
}
// ============================================================================ // ════════════════════════════════════════════════════════════════════
// PHASE 3: GRAVITY // Phase 3: Gravity
// ============================================================================ // ════════════════════════════════════════════════════════════════════
void UTengriMovementComponent::ApplyGravity(float DeltaTime)
{
if (!bIsGrounded) if (!bIsGrounded)
{ {
Velocity.Z -= MovementConfig->Gravity * DeltaTime; PhysicsVelocity.Z -= MovementConfig->Gravity * FixedDeltaTime;
} }
else else
{ {
Velocity.Z = 0.0f; PhysicsVelocity.Z = 0.0f;
} }
}
// ============================================================================ // ════════════════════════════════════════════════════════════════════
// PHASE 4: COLLISION RESOLUTION // Phase 4: Collision Resolution
// ============================================================================ // ════════════════════════════════════════════════════════════════════
FVector UTengriMovementComponent::ResolveMovementWithCollision(float DeltaTime) const FVector DesiredDelta = PhysicsVelocity * FixedDeltaTime;
{
const FVector DesiredDelta = Velocity * DeltaTime;
const FTengriSweepResult MoveResult = UTengriCollisionResolver::ResolveMovement( const FTengriSweepResult MoveResult = UTengriCollisionResolver::ResolveMovement(
this, this,
GetOwner()->GetActorLocation(), PhysicsLocation,
DesiredDelta, DesiredDelta,
OwnerCapsule, OwnerCapsule,
CachedThresholds, CachedThresholds,
@ -206,64 +273,106 @@ FVector UTengriMovementComponent::ResolveMovementWithCollision(float DeltaTime)
false false
); );
// Store for state update PhysicsLocation = MoveResult.Location;
if (MoveResult.bBlocked)
// ════════════════════════════════════════════════════════════════════
// Phase 5: Ground Snapping
// ════════════════════════════════════════════════════════════════════
FHitResult SnapHit;
const bool bJustSnapped = PerformGroundSnapping(PhysicsLocation, SnapHit);
if (bJustSnapped && !InputVector.IsNearlyZero())
{ {
UpdateGroundedState(MoveResult.bBlocked, MoveResult.Hit.ImpactNormal.Z, false); // Preserve momentum along slope
PhysicsVelocity = UTengriCollisionResolver::ProjectVelocity(
PhysicsVelocity,
SnapHit.ImpactNormal
);
} }
return MoveResult.Location; // ════════════════════════════════════════════════════════════════════
// Phase 6: State Update
// ════════════════════════════════════════════════════════════════════
// Determine grounded state from snap or collision
const bool bHitWalkable = MoveResult.bBlocked &&
CachedThresholds.IsWalkable(MoveResult.Hit.ImpactNormal.Z);
bIsGrounded = bJustSnapped || bHitWalkable;
// Prevent Z velocity accumulation when grounded
if (bIsGrounded && PhysicsVelocity.Z < 0.f)
{
PhysicsVelocity.Z = 0.f;
}
} }
// ============================================================================ // ============================================================================
// PHASE 5: GROUND SNAPPING // INTERPOLATION
// ============================================================================
void UTengriMovementComponent::SavePreviousPhysicsState()
{
PreviousPhysicsLocation = PhysicsLocation;
PreviousPhysicsRotation = PhysicsRotation;
}
void UTengriMovementComponent::InterpolateRenderState(float Alpha)
{
Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f);
// Linear interpolation for location
RenderLocation = FMath::Lerp(PreviousPhysicsLocation, PhysicsLocation, Alpha);
// Lerp for rotation (FMath::Lerp handles FRotator correctly)
RenderRotation = FMath::Lerp(PreviousPhysicsRotation, PhysicsRotation, Alpha);
}
void UTengriMovementComponent::ApplyRenderState() const
{
if (AActor* Owner = GetOwner())
{
Owner->SetActorLocation(RenderLocation, false, nullptr, ETeleportType::None);
Owner->SetActorRotation(RenderRotation, ETeleportType::None);
}
}
// ============================================================================
// GROUND SNAPPING
// ============================================================================ // ============================================================================
bool UTengriMovementComponent::PerformGroundSnapping( bool UTengriMovementComponent::PerformGroundSnapping(
FVector& InOutLocation, FVector& InOutLocation,
FHitResult& OutSnapHit) const FHitResult& OutSnapHit) const
{ {
// Only snap if we were grounded or just landed // Skip snap when clearly airborne intentionally
if (!bIsGrounded) const bool bIsFallingFast = PhysicsVelocity.Z < TengriMovement::FastFallThreshold;
if (const bool bIsJumping = PhysicsVelocity.Z > TengriMovement::JumpingThreshold; !bIsGrounded && (bIsFallingFast || bIsJumping))
{ {
return false; return false;
} }
FVector SnapLocation; // Use physics location as start point (NOT render position!)
const bool bSnapped = UTengriCollisionResolver::SnapToGround( const FVector Start = InOutLocation;
const FVector End = Start - FVector(0.f, 0.f, MovementConfig->GroundSnapDistance);
const FTengriSweepResult Sweep = UTengriCollisionResolver::PerformSweep(
this, this,
Start,
End,
OwnerCapsule, OwnerCapsule,
MovementConfig->GroundSnapDistance, false
CachedThresholds,
SnapLocation,
OutSnapHit
); );
if (bSnapped) if (Sweep.bBlocked && CachedThresholds.IsWalkable(Sweep.Hit.ImpactNormal.Z))
{ {
// Add micro-offset to prevent floor penetration // Apply micro-offset to prevent floor penetration
InOutLocation = SnapLocation + FVector(0.f, 0.f, MovementConfig->GroundSnapOffset); InOutLocation = Sweep.Location + FVector(0.f, 0.f, MovementConfig->GroundSnapOffset);
OutSnapHit = Sweep.Hit;
return true; return true;
} }
return false; return false;
}
// ============================================================================
// PHASE 6: STATE UPDATE
// ============================================================================
void UTengriMovementComponent::UpdateGroundedState(
const bool bMoveBlocked,
const float HitNormalZ,
const bool bJustSnapped)
{
const bool bHitWalkable = bMoveBlocked && HitNormalZ >= CachedThresholds.WalkableZ;
bIsGrounded = bJustSnapped || bHitWalkable;
// Prevent Z velocity accumulation when grounded
if (bIsGrounded && Velocity.Z < 0.f)
{
Velocity.Z = 0.f;
}
} }

View File

@ -11,7 +11,12 @@ class UCapsuleComponent;
/** /**
* Custom movement component for deterministic 3D platformer physics. * Custom movement component for deterministic 3D platformer physics.
* Handles acceleration, friction, gravity, collision, and ground snapping. * Uses fixed timestep physics with interpolated rendering.
*
* Architecture:
* - Physics State: Updated at fixed rate (default 120Hz) for determinism
* - Render State: Interpolated between physics states for smooth visuals
* - Accumulator: Manages variable frame delta accumulation
*/ */
UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent)) UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class TENGRIPLATFORMER_API UTengriMovementComponent : public UActorComponent class TENGRIPLATFORMER_API UTengriMovementComponent : public UActorComponent
@ -51,7 +56,7 @@ public:
// RUNTIME STATE // RUNTIME STATE
// ======================================================================== // ========================================================================
/** Current velocity in cm/s */ /** Current velocity in cm/s (synced from PhysicsVelocity each frame) */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Tengri Movement|State") UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Tengri Movement|State")
FVector Velocity; FVector Velocity;
@ -64,6 +69,52 @@ public:
FSurfaceThresholds CachedThresholds; FSurfaceThresholds CachedThresholds;
private: private:
// ========================================================================
// PHYSICS STATE (Deterministic)
// ========================================================================
/** Physics location updated at fixed timestep */
FVector PhysicsLocation = FVector::ZeroVector;
/** Physics rotation updated at fixed timestep */
FRotator PhysicsRotation = FRotator::ZeroRotator;
/** Physics velocity in cm/s */
FVector PhysicsVelocity = FVector::ZeroVector;
// ========================================================================
// RENDER STATE (Interpolated)
// ========================================================================
/** Interpolated location for smooth rendering */
FVector RenderLocation = FVector::ZeroVector;
/** Interpolated rotation for smooth rendering */
FRotator RenderRotation = FRotator::ZeroRotator;
// ========================================================================
// FIXED TIMESTEP
// ========================================================================
/** Fixed timestep duration in seconds (calculated from PhysicsTickRate) */
float FixedTimeStep = 1.0f / 120.0f;
/** Accumulated variable frame time */
float TimeAccumulator = 0.0f;
/** Maximum accumulator value to prevent spiral of death */
float MaxAccumulatorTime = 0.1f;
// ========================================================================
// INTERPOLATION HISTORY
// ========================================================================
/** Previous physics location for interpolation */
FVector PreviousPhysicsLocation = FVector::ZeroVector;
/** Previous physics rotation for interpolation */
FRotator PreviousPhysicsRotation = FRotator::ZeroRotator;
// ======================================================================== // ========================================================================
// INTERNAL STATE // INTERNAL STATE
// ======================================================================== // ========================================================================
@ -81,24 +132,41 @@ private:
void InitializeSystem(); void InitializeSystem();
// ======================================================================== // ========================================================================
// MOVEMENT PHASES // PHYSICS TICK
// ======================================================================== // ========================================================================
/** Phase 1: Apply acceleration toward input direction or friction to stop */ /**
void ApplyAccelerationAndFriction(float DeltaTime); * Deterministic physics update at fixed timestep.
* All movement logic runs here with constant delta time.
* @param FixedDeltaTime - Fixed timestep duration (e.g., 1/120 sec)
*/
void TickPhysics(float FixedDeltaTime);
/** Phase 2: Rotate character to face movement direction */ /** Save current physics state before next physics step */
void ApplyRotation(float DeltaTime) const; void SavePreviousPhysicsState();
/** Phase 3: Apply gravity when airborne */ // ========================================================================
void ApplyGravity(float DeltaTime); // INTERPOLATION
// ========================================================================
/** Phase 4: Resolve movement with collision */ /**
FVector ResolveMovementWithCollision(float DeltaTime); * Interpolate render state between previous and current physics states.
* @param Alpha - Interpolation factor [0..1]
*/
void InterpolateRenderState(float Alpha);
/** Phase 5: Snap to ground to prevent slope jitter */ /** Apply interpolated render state to actor transform */
void ApplyRenderState() const;
// ========================================================================
// PHYSICS HELPERS
// ========================================================================
/**
* Snap to ground to prevent slope jitter.
* @param InOutLocation - Physics location (modified if snap succeeds)
* @param OutSnapHit - Hit result if ground found
* @return True if snapped to ground
*/
bool PerformGroundSnapping(FVector& InOutLocation, FHitResult& OutSnapHit) const; bool PerformGroundSnapping(FVector& InOutLocation, FHitResult& OutSnapHit) const;
/** Phase 6: Update grounded state based on collision results */
void UpdateGroundedState(bool bMoveBlocked, float HitNormalZ, bool bJustSnapped);
}; };