diff --git a/Content/Blueprints/BP_MainCharacter.uasset b/Content/Blueprints/BP_MainCharacter.uasset index 93a5531..a2d4b4f 100644 --- a/Content/Blueprints/BP_MainCharacter.uasset +++ b/Content/Blueprints/BP_MainCharacter.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:468a4d9767ccc437aa25469a4273d39cbafceb6ee7218624123098e7ce83c2f2 -size 314135 +oid sha256:566676c4c7ff13fecce9a8fcaf504d59752422e1e69fa477e8a7f035b67edc62 +size 314181 diff --git a/Source/TengriPlatformer/Movement/Core/TengriMovementConfig.h b/Source/TengriPlatformer/Movement/Core/TengriMovementConfig.h index 6080fa3..340eb52 100644 --- a/Source/TengriPlatformer/Movement/Core/TengriMovementConfig.h +++ b/Source/TengriPlatformer/Movement/Core/TengriMovementConfig.h @@ -144,6 +144,24 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Physics", meta = (ClampMin = "0", ClampMax = "1")) 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 // ======================================================================== diff --git a/Source/TengriPlatformer/Movement/TengriMovementComponent.cpp b/Source/TengriPlatformer/Movement/TengriMovementComponent.cpp index 50b1d4f..0ae61c8 100644 --- a/Source/TengriPlatformer/Movement/TengriMovementComponent.cpp +++ b/Source/TengriPlatformer/Movement/TengriMovementComponent.cpp @@ -6,6 +6,20 @@ 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 // ============================================================================ @@ -29,29 +43,54 @@ void UTengriMovementComponent::BeginPlay() void UTengriMovementComponent::InitializeSystem() { - if (const AActor* Owner = GetOwner()) + AActor* Owner = GetOwner(); + if (!Owner) { - OwnerCapsule = Cast(Owner->GetRootComponent()); - if (!OwnerCapsule) - { - UE_LOG(LogTengriMovement, Error, - TEXT("InitializeSystem failed: Owner root component is not a CapsuleComponent")); - SetComponentTickEnabled(false); - return; - } + UE_LOG(LogTengriMovement, Error, TEXT("InitializeSystem failed: No owner")); + SetComponentTickEnabled(false); + return; } - if (MovementConfig) + OwnerCapsule = Cast(Owner->GetRootComponent()); + if (!OwnerCapsule) { - CachedThresholds = MovementConfig->GetThresholds(); - UE_LOG(LogTengriMovement, Log, - TEXT("System initialized. WalkableZ: %.3f"), CachedThresholds.WalkableZ); + UE_LOG(LogTengriMovement, Error, + TEXT("InitializeSystem failed: Owner root component is not a CapsuleComponent")); + SetComponentTickEnabled(false); + return; } - else + + if (!MovementConfig) { UE_LOG(LogTengriMovement, Warning, 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( - const float DeltaTime, - const ELevelTick TickType, + float DeltaTime, + ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); @@ -80,47 +119,86 @@ void UTengriMovementComponent::TickComponent( return; } - // Phase 1: Input -> Velocity - ApplyAccelerationAndFriction(DeltaTime); + // ════════════════════════════════════════════════════════════════════ + // FIXED TIMESTEP ACCUMULATION + // ════════════════════════════════════════════════════════════════════ - // Phase 2: Rotation - ApplyRotation(DeltaTime); + TimeAccumulator += DeltaTime; - // Phase 3: Gravity - ApplyGravity(DeltaTime); - - // 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) + // Clamp accumulator to prevent "spiral of death" + if (TimeAccumulator > MaxAccumulatorTime) { - 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 - UpdateGroundedState(false, 0.f, bJustSnapped); + // ════════════════════════════════════════════════════════════════════ + // INTERPOLATION & RENDERING + // ════════════════════════════════════════════════════════════════════ + + 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()) { @@ -128,7 +206,7 @@ void UTengriMovementComponent::ApplyAccelerationAndFriction(const float DeltaTim HorizontalVelocity = FMath::VInterpTo( HorizontalVelocity, TargetVelocity, - DeltaTime, + FixedDeltaTime, MovementConfig->Acceleration ); } @@ -137,67 +215,56 @@ void UTengriMovementComponent::ApplyAccelerationAndFriction(const float DeltaTim HorizontalVelocity = FMath::VInterpTo( HorizontalVelocity, FVector::ZeroVector, - DeltaTime, + FixedDeltaTime, MovementConfig->Friction ); } - Velocity = HorizontalVelocity; - Velocity.Z = CurrentZ; -} + PhysicsVelocity = HorizontalVelocity; + PhysicsVelocity.Z = CurrentZ; -// ============================================================================ -// PHASE 2: ROTATION -// ============================================================================ + // ════════════════════════════════════════════════════════════════════ + // Phase 2: Rotation + // ════════════════════════════════════════════════════════════════════ -void UTengriMovementComponent::ApplyRotation(const float DeltaTime) const -{ - if (const float MinSpeedSq = FMath::Square(MovementConfig->MinSpeedForRotation); Velocity.SizeSquared2D() > MinSpeedSq) + const float MinSpeedSq = FMath::Square(MovementConfig->MinSpeedForRotation); + + if (PhysicsVelocity.SizeSquared2D() > MinSpeedSq) { - const FRotator CurrentRot = GetOwner()->GetActorRotation(); - - FRotator TargetRot = Velocity.ToOrientationRotator(); + FRotator TargetRot = PhysicsVelocity.ToOrientationRotator(); TargetRot.Pitch = 0.0f; TargetRot.Roll = 0.0f; - const FRotator NewRot = FMath::RInterpConstantTo( - CurrentRot, + PhysicsRotation = FMath::RInterpConstantTo( + PhysicsRotation, TargetRot, - DeltaTime, + FixedDeltaTime, MovementConfig->RotationSpeed ); - - GetOwner()->SetActorRotation(NewRot); } -} -// ============================================================================ -// PHASE 3: GRAVITY -// ============================================================================ + // ════════════════════════════════════════════════════════════════════ + // Phase 3: Gravity + // ════════════════════════════════════════════════════════════════════ -void UTengriMovementComponent::ApplyGravity(float DeltaTime) -{ if (!bIsGrounded) { - Velocity.Z -= MovementConfig->Gravity * DeltaTime; + PhysicsVelocity.Z -= MovementConfig->Gravity * FixedDeltaTime; } 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 = Velocity * DeltaTime; + const FVector DesiredDelta = PhysicsVelocity * FixedDeltaTime; const FTengriSweepResult MoveResult = UTengriCollisionResolver::ResolveMovement( this, - GetOwner()->GetActorLocation(), + PhysicsLocation, DesiredDelta, OwnerCapsule, CachedThresholds, @@ -206,64 +273,106 @@ FVector UTengriMovementComponent::ResolveMovementWithCollision(float DeltaTime) false ); - // Store for state update - if (MoveResult.bBlocked) + PhysicsLocation = MoveResult.Location; + + // ════════════════════════════════════════════════════════════════════ + // 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( FVector& InOutLocation, FHitResult& OutSnapHit) const { - // Only snap if we were grounded or just landed - if (!bIsGrounded) + // Skip snap when clearly airborne intentionally + const bool bIsFallingFast = PhysicsVelocity.Z < TengriMovement::FastFallThreshold; + + if (const bool bIsJumping = PhysicsVelocity.Z > TengriMovement::JumpingThreshold; !bIsGrounded && (bIsFallingFast || bIsJumping)) { return false; } - FVector SnapLocation; - const bool bSnapped = UTengriCollisionResolver::SnapToGround( + // Use physics location as start point (NOT render position!) + const FVector Start = InOutLocation; + const FVector End = Start - FVector(0.f, 0.f, MovementConfig->GroundSnapDistance); + + const FTengriSweepResult Sweep = UTengriCollisionResolver::PerformSweep( this, + Start, + End, OwnerCapsule, - MovementConfig->GroundSnapDistance, - CachedThresholds, - SnapLocation, - OutSnapHit + false ); - if (bSnapped) + if (Sweep.bBlocked && CachedThresholds.IsWalkable(Sweep.Hit.ImpactNormal.Z)) { - // Add micro-offset to prevent floor penetration - InOutLocation = SnapLocation + FVector(0.f, 0.f, MovementConfig->GroundSnapOffset); + // Apply micro-offset to prevent floor penetration + InOutLocation = Sweep.Location + FVector(0.f, 0.f, MovementConfig->GroundSnapOffset); + OutSnapHit = Sweep.Hit; return true; } 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; - } } \ No newline at end of file diff --git a/Source/TengriPlatformer/Movement/TengriMovementComponent.h b/Source/TengriPlatformer/Movement/TengriMovementComponent.h index cd1f72d..9fcf7cf 100644 --- a/Source/TengriPlatformer/Movement/TengriMovementComponent.h +++ b/Source/TengriPlatformer/Movement/TengriMovementComponent.h @@ -11,7 +11,12 @@ class UCapsuleComponent; /** * 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)) class TENGRIPLATFORMER_API UTengriMovementComponent : public UActorComponent @@ -51,7 +56,7 @@ public: // RUNTIME STATE // ======================================================================== - /** Current velocity in cm/s */ + /** Current velocity in cm/s (synced from PhysicsVelocity each frame) */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Tengri Movement|State") FVector Velocity; @@ -64,6 +69,52 @@ public: FSurfaceThresholds CachedThresholds; 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 // ======================================================================== @@ -81,24 +132,41 @@ private: 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 */ - void ApplyRotation(float DeltaTime) const; + /** Save current physics state before next physics step */ + 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; - - /** Phase 6: Update grounded state based on collision results */ - void UpdateGroundedState(bool bMoveBlocked, float HitNormalZ, bool bJustSnapped); }; \ No newline at end of file