From 2814a2211ca54cea90cc0f27ec1eeaa48f866ce0 Mon Sep 17 00:00:00 2001 From: Canberk Pitirli Date: Sun, 4 Oct 2026 20:10:31 +0300 Subject: [PATCH 1/2] fix: step physics on a clamped fixed timestep Physics stepped with the raw frame delta inside SceneBase::Update while the fixed-step loop in Application had an unclamped accumulator and no step cap. The scene now owns the accumulator, runs FixedUpdate (objects, then PhysicsWorld::Step) at the point physics used to run, caps the steps per frame and exposes the interpolation alpha. Frame delta is clamped and measured in double precision. --- include/public/Core/Scene.hpp | 1 + include/public/Core/SceneBase.hpp | 29 +++++++++++++++++++++++-- include/public/Core/Timer.hpp | 8 +++++-- include/public/Physics/PhysicsWorld.hpp | 20 +++++++++-------- src/Core/Application.cpp | 24 +++++++------------- src/Core/Timer.cpp | 13 ++++++----- src/Physics/PhysicsWorld.cpp | 3 +++ src/Scene/SceneBase.cpp | 29 +++++++++++++++++++++++-- 8 files changed, 91 insertions(+), 36 deletions(-) diff --git a/include/public/Core/Scene.hpp b/include/public/Core/Scene.hpp index f355a5a0..c0f13bcc 100644 --- a/include/public/Core/Scene.hpp +++ b/include/public/Core/Scene.hpp @@ -22,6 +22,7 @@ namespace Sleak { /// /// Call SetFixedUpdateEnabled(false) on scenes that do not simulate /// anything, such as menus, to skip the fixed-timestep pass entirely. + /// That pass is also where physics steps, so it stops physics too. /// /// @code{.cpp} /// class WorldScene : public Sleak::Scene { diff --git a/include/public/Core/SceneBase.hpp b/include/public/Core/SceneBase.hpp index f8cc161f..0842f366 100644 --- a/include/public/Core/SceneBase.hpp +++ b/include/public/Core/SceneBase.hpp @@ -94,9 +94,11 @@ namespace Sleak { virtual void Begin() = 0; // Update loops - /// Advances all active, root-level objects by deltaTime, then steps lighting and physics. + /// Advances all active, root-level objects by deltaTime, then runs + /// as many FixedUpdate() steps as the accumulated time allows. virtual void Update(float deltaTime) = 0; - /// Advances all active, root-level objects on the fixed timestep. + /// Advances all active, root-level objects on the fixed timestep, then + /// steps physics. virtual void FixedUpdate(float fixedDeltaTime); /// Advances all active, root-level objects after the main Update pass. virtual void LateUpdate(float deltaTime); @@ -140,6 +142,21 @@ namespace Sleak { List FindObjectsByTag(const std::string& tag); size_t GetObjectCount() const { return Objects.GetSize(); } + /// Length of one FixedUpdate() step in seconds; defaults to 1/60. + float GetFixedTimestep() const { return m_fixedTimestep; } + void SetFixedTimestep(float seconds); + /// Most FixedUpdate() steps one frame may run; leftover time is + /// dropped. + int GetMaxFixedSteps() const { return m_maxFixedSteps; } + void SetMaxFixedSteps(int steps) { + m_maxFixedSteps = steps > 0 ? steps : 1; + } + /// How far the current frame sits between the last fixed step and + /// the next one, in [0, 1). Use it to interpolate rendered poses. + float GetFixedStepAlpha() const { + return static_cast(m_fixedAccumulator / m_fixedTimestep); + } + Camera* GetActiveCamera() const { return m_activeCamera; } void SetActiveCamera(Camera* cam) { m_activeCamera = cam; } @@ -170,6 +187,14 @@ namespace Sleak { Physics::PhysicsWorld* m_physicsWorld = nullptr; Skybox* m_skybox = nullptr; + double m_fixedAccumulator = 0.0; + float m_fixedTimestep = 1.0f / 60.0f; + int m_maxFixedSteps = 5; + + /// Adds deltaTime to the accumulator and runs the FixedUpdate() steps + /// it covers. + void RunFixedSteps(float deltaTime); + /// Actually deletes objects queued by DestroyObject(). void ProcessPendingDestroy(); /// Destroys every object still owned by the scene, e.g. during Unload(). diff --git a/include/public/Core/Timer.hpp b/include/public/Core/Timer.hpp index 83a3ecdb..f09b25c6 100644 --- a/include/public/Core/Timer.hpp +++ b/include/public/Core/Timer.hpp @@ -17,9 +17,13 @@ namespace Sleak { /// Seconds since construction or the last Reset(). float Elapsed() const; + /// Same as Elapsed() in double precision, for subtracting two + /// readings that are hours into a session. + double ElapsedSeconds() const; + private: - std::chrono::time_point startPoint; + std::chrono::time_point startPoint; }; }; -#endif \ No newline at end of file +#endif diff --git a/include/public/Physics/PhysicsWorld.hpp b/include/public/Physics/PhysicsWorld.hpp index 4a1e3d3d..fe4a15c8 100644 --- a/include/public/Physics/PhysicsWorld.hpp +++ b/include/public/Physics/PhysicsWorld.hpp @@ -41,14 +41,15 @@ namespace Sleak { float distance = 0.0f; }; - /// Owns the broadphase tree and every registered collider; drives collision detection and resolution each step. + /// Owns the broadphase tree and every registered collider; drives + /// collision detection and resolution each step. /// /// Each Scene creates one PhysicsWorld and steps it from its - /// update, so you normally reach it with - /// SceneBase::GetPhysicsWorld() rather than constructing one. You - /// also rarely call RegisterCollider() by hand: adding a - /// GameObject that carries a ColliderComponent registers it (and - /// its children) automatically. + /// FixedUpdate() on the scene's fixed timestep, so you normally reach + /// it with SceneBase::GetPhysicsWorld() rather than constructing one. + /// You also rarely call RegisterCollider() by hand: adding a GameObject + /// that carries a ColliderComponent registers it (and its children) + /// automatically. /// /// Step() integrates every dynamic RigidbodyComponent using that /// body's own gravity setting, refreshes the DynamicAABBTree @@ -88,9 +89,10 @@ namespace Sleak { PhysicsWorld() = default; ~PhysicsWorld() = default; - /// Advances the simulation by dt: integrates each dynamic rigidbody's - /// velocity, including its own gravity setting, into position, then - /// updates the broadphase and resolves collisions. + /// Advances the simulation by dt: integrates each dynamic + /// rigidbody's velocity, including its own gravity setting, into + /// position, then updates the broadphase and resolves collisions. + /// Ignores dt <= 0. void Step(float dt); void RegisterCollider(ColliderComponent* collider); diff --git a/src/Core/Application.cpp b/src/Core/Application.cpp index 83c0eae5..3927697d 100644 --- a/src/Core/Application.cpp +++ b/src/Core/Application.cpp @@ -126,8 +126,6 @@ namespace Sleak { int Application::Run(GameBase* game) { Game = game; - float lastTime = FrameTimer.Elapsed(); - if(CoreWindow && CoreWindow->InitializeWindow()) { if(renderer && renderer->Initialize()) { @@ -150,9 +148,7 @@ namespace Sleak { auto context = renderer->GetContext(); auto queue = RenderEngine::RenderCommandQueue::GetInstance(); - float lastTime = FrameTimer.Elapsed(); - float accumulator = 0.0f; - const float fixedTimestep = 1.0f / 60.0f; + constexpr double kMaxDeltaTime = 0.25; // Initialize and begin the game (and scene) if (Game) { @@ -184,10 +180,13 @@ namespace Sleak { m_benchmark->ToggleRecording(); } + // Start after init so load time is not the first frame delta + double lastTime = FrameTimer.ElapsedSeconds(); + while(!CoreWindow->ShouldClose()) { - - float currentTime = FrameTimer.Elapsed(); - DeltaTime = currentTime - lastTime; + double currentTime = FrameTimer.ElapsedSeconds(); + DeltaTime = static_cast( + std::clamp(currentTime - lastTime, 0.0, kMaxDeltaTime)); lastTime = currentTime; #if defined(_DEBUG) && defined(COUNT_FRAME) @@ -218,14 +217,7 @@ namespace Sleak { if (Game && Game->GetActiveScene()) { auto* activeScene = Game->GetActiveScene(); - // Fixed timestep updates (physics, etc.) - accumulator += DeltaTime; - while (accumulator >= fixedTimestep) { - activeScene->FixedUpdate(fixedTimestep); - accumulator -= fixedTimestep; - } - - // Per-frame update + // Per-frame update; the scene runs its fixed steps inside activeScene->Update(DeltaTime); // Late update (after all updates, e.g. camera follow) diff --git a/src/Core/Timer.cpp b/src/Core/Timer.cpp index f20aa707..0c259cf0 100644 --- a/src/Core/Timer.cpp +++ b/src/Core/Timer.cpp @@ -6,13 +6,16 @@ namespace Sleak { } void Timer::Reset() { - startPoint = std::chrono::high_resolution_clock::now(); + startPoint = std::chrono::steady_clock::now(); } - // Outputs elaosed time as seconds float Timer::Elapsed() const { - auto now = std::chrono::high_resolution_clock::now(); - std::chrono::duration elapsed = now - startPoint; + return static_cast(ElapsedSeconds()); + } + + double Timer::ElapsedSeconds() const { + std::chrono::duration elapsed = + std::chrono::steady_clock::now() - startPoint; return elapsed.count(); } -}; \ No newline at end of file +}; diff --git a/src/Physics/PhysicsWorld.cpp b/src/Physics/PhysicsWorld.cpp index d4d6783e..0ce5ad31 100644 --- a/src/Physics/PhysicsWorld.cpp +++ b/src/Physics/PhysicsWorld.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace Sleak { namespace Physics { @@ -39,6 +40,8 @@ void PhysicsWorld::UnregisterCollider(ColliderComponent* collider) { } void PhysicsWorld::Step(float dt) { + if (!(dt > 0.0f) || !std::isfinite(dt)) return; + // We need wasGrounded BEFORE clearing, so gravity doesn't apply while standing for (auto* collider : m_colliders) { if (auto* owner = collider->GetOwner()) { diff --git a/src/Scene/SceneBase.cpp b/src/Scene/SceneBase.cpp index 8ab146a4..42cc9957 100644 --- a/src/Scene/SceneBase.cpp +++ b/src/Scene/SceneBase.cpp @@ -10,6 +10,7 @@ #include #include #include "../../include/private/Graphics/Common/RenderCommandQueue.hpp" +#include namespace Sleak { @@ -170,8 +171,7 @@ void SceneBase::Update(float deltaTime) { if (m_skybox) m_skybox->Render(); - if (m_physicsWorld) - m_physicsWorld->Step(deltaTime); + RunFixedSteps(deltaTime); if (DebugLineRenderer::IsEnabled()) { auto drawColliderShape = [](ColliderComponent* collider, const Math::Vector3D& worldPos, const Math::Vector3D& worldScale) { @@ -227,6 +227,31 @@ void SceneBase::FixedUpdate(float fixedDeltaTime) { Objects[i]->FixedUpdate(fixedDeltaTime); } } + + if (m_physicsWorld) m_physicsWorld->Step(fixedDeltaTime); +} + +void SceneBase::SetFixedTimestep(float seconds) { + if (!(seconds > 0.0f) || !std::isfinite(seconds)) return; + m_fixedTimestep = seconds; + m_fixedAccumulator = std::fmod(m_fixedAccumulator, m_fixedTimestep); +} + +void SceneBase::RunFixedSteps(float deltaTime) { + if (!(deltaTime > 0.0f) || !std::isfinite(deltaTime)) return; + + m_fixedAccumulator += deltaTime; + + int steps = 0; + while (m_fixedAccumulator >= m_fixedTimestep && steps < m_maxFixedSteps) { + FixedUpdate(m_fixedTimestep); + m_fixedAccumulator -= m_fixedTimestep; + ++steps; + } + + // Behind by more than the cap: drop the backlog instead of spiraling + if (m_fixedAccumulator >= m_fixedTimestep) + m_fixedAccumulator = std::fmod(m_fixedAccumulator, m_fixedTimestep); } void SceneBase::LateUpdate(float deltaTime) { From cb382ad001a24966299b4068d508e9fab7fc0e82 Mon Sep 17 00:00:00 2001 From: Canberk Pitirli Date: Sun, 4 Oct 2026 20:52:15 +0300 Subject: [PATCH 2/2] style: apply clang-format to changed lines --- include/public/Core/Scene.hpp | 146 +++++++++++++++++----------------- include/public/Core/Timer.hpp | 2 +- src/Core/Timer.cpp | 6 +- src/Physics/PhysicsWorld.cpp | 10 +-- src/Scene/SceneBase.cpp | 12 +-- 5 files changed, 88 insertions(+), 88 deletions(-) diff --git a/include/public/Core/Scene.hpp b/include/public/Core/Scene.hpp index c0f13bcc..4ed4df5f 100644 --- a/include/public/Core/Scene.hpp +++ b/include/public/Core/Scene.hpp @@ -4,85 +4,85 @@ #include "SceneBase.hpp" namespace Sleak { - /// Concrete scene type games subclass. Adds an opt-out switch for the - /// fixed-timestep update on top of SceneBase's lifecycle. - /// - /// This is the class your levels, menus, and screens derive from. - /// Override the hooks you need and call the base implementation from - /// each one: OnLoad() and OnUnload() for assets, OnActivate() and - /// OnDeactivate() for entering and leaving, Begin() to build the object - /// graph, and Update() / FixedUpdate() / LateUpdate() for per-frame - /// work. - /// - /// The scene owns every object handed to AddObject(), including lights - /// and cameras, and destroys all of them on unload. It also owns a - /// LightManager and a Physics::PhysicsWorld, reachable through - /// GetLightManager() and GetPhysicsWorld(), and registers added objects - /// with both automatically. - /// - /// Call SetFixedUpdateEnabled(false) on scenes that do not simulate - /// anything, such as menus, to skip the fixed-timestep pass entirely. - /// That pass is also where physics steps, so it stops physics too. - /// - /// @code{.cpp} - /// class WorldScene : public Sleak::Scene { - /// public: - /// WorldScene() : Sleak::Scene("WorldScene") {} - /// - /// void Begin() override { - /// auto* cube = Sleak::GameObject::CreateCube( - /// Sleak::Math::Vector3D(0.0f, 0.0f, 0.0f)); - /// AddObject(cube); - /// - /// auto* sun = new Sleak::DirectionalLight("Sun"); - /// sun->SetDirection(Sleak::Math::Vector3D(-0.5f, -0.8f, -0.3f)); - /// sun->SetIntensity(4.0f); - /// AddObject(sun); - /// - /// auto* cam = new Sleak::Camera("MainCamera"); - /// AddObject(cam); - /// SetActiveCamera(cam); - /// - /// Sleak::Scene::Begin(); // activates everything added above - /// } - /// - /// void Update(float deltaTime) override { - /// Sleak::Scene::Update(deltaTime); - /// } - /// }; - /// @endcode - /// - /// @see SceneBase, GameBase, GameObject, LightManager - /// @ingroup core - class ENGINE_API Scene : public SceneBase { - public: - explicit Scene(const std::string& name) - : SceneBase(name), bEnableFixedUpdate(true) {} - ~Scene() override = default; +/// Concrete scene type games subclass. Adds an opt-out switch for the +/// fixed-timestep update on top of SceneBase's lifecycle. +/// +/// This is the class your levels, menus, and screens derive from. +/// Override the hooks you need and call the base implementation from +/// each one: OnLoad() and OnUnload() for assets, OnActivate() and +/// OnDeactivate() for entering and leaving, Begin() to build the object +/// graph, and Update() / FixedUpdate() / LateUpdate() for per-frame +/// work. +/// +/// The scene owns every object handed to AddObject(), including lights +/// and cameras, and destroys all of them on unload. It also owns a +/// LightManager and a Physics::PhysicsWorld, reachable through +/// GetLightManager() and GetPhysicsWorld(), and registers added objects +/// with both automatically. +/// +/// Call SetFixedUpdateEnabled(false) on scenes that do not simulate +/// anything, such as menus, to skip the fixed-timestep pass entirely. +/// That pass is also where physics steps, so it stops physics too. +/// +/// @code{.cpp} +/// class WorldScene : public Sleak::Scene { +/// public: +/// WorldScene() : Sleak::Scene("WorldScene") {} +/// +/// void Begin() override { +/// auto* cube = Sleak::GameObject::CreateCube( +/// Sleak::Math::Vector3D(0.0f, 0.0f, 0.0f)); +/// AddObject(cube); +/// +/// auto* sun = new Sleak::DirectionalLight("Sun"); +/// sun->SetDirection(Sleak::Math::Vector3D(-0.5f, -0.8f, -0.3f)); +/// sun->SetIntensity(4.0f); +/// AddObject(sun); +/// +/// auto* cam = new Sleak::Camera("MainCamera"); +/// AddObject(cam); +/// SetActiveCamera(cam); +/// +/// Sleak::Scene::Begin(); // activates everything added above +/// } +/// +/// void Update(float deltaTime) override { +/// Sleak::Scene::Update(deltaTime); +/// } +/// }; +/// @endcode +/// +/// @see SceneBase, GameBase, GameObject, LightManager +/// @ingroup core +class ENGINE_API Scene : public SceneBase { + public: + explicit Scene(const std::string& name) + : SceneBase(name), bEnableFixedUpdate(true) {} + ~Scene() override = default; - // Resource lifecycle - void OnLoad() override; - void OnUnload() override; + // Resource lifecycle + void OnLoad() override; + void OnUnload() override; - // Activation lifecycle - void OnActivate() override; - void OnDeactivate() override; + // Activation lifecycle + void OnActivate() override; + void OnDeactivate() override; - // Initialization - bool Initialize() override; - void Begin() override; + // Initialization + bool Initialize() override; + void Begin() override; - // Update loops - void Update(float deltaTime) override; - void FixedUpdate(float fixedDeltaTime) override; - void LateUpdate(float deltaTime) override; + // Update loops + void Update(float deltaTime) override; + void FixedUpdate(float fixedDeltaTime) override; + void LateUpdate(float deltaTime) override; - void SetFixedUpdateEnabled(bool enabled) { bEnableFixedUpdate = enabled; } - bool IsFixedUpdateEnabled() const { return bEnableFixedUpdate; } + void SetFixedUpdateEnabled(bool enabled) { bEnableFixedUpdate = enabled; } + bool IsFixedUpdateEnabled() const { return bEnableFixedUpdate; } - private: - bool bEnableFixedUpdate; - }; + private: + bool bEnableFixedUpdate; +}; } #endif // _SCENE_HPP_ diff --git a/include/public/Core/Timer.hpp b/include/public/Core/Timer.hpp index f09b25c6..3ea571c6 100644 --- a/include/public/Core/Timer.hpp +++ b/include/public/Core/Timer.hpp @@ -21,7 +21,7 @@ namespace Sleak { /// readings that are hours into a session. double ElapsedSeconds() const; - private: + private: std::chrono::time_point startPoint; }; }; diff --git a/src/Core/Timer.cpp b/src/Core/Timer.cpp index 0c259cf0..e3fef64c 100644 --- a/src/Core/Timer.cpp +++ b/src/Core/Timer.cpp @@ -5,9 +5,7 @@ namespace Sleak { Reset(); } - void Timer::Reset() { - startPoint = std::chrono::steady_clock::now(); - } + void Timer::Reset() { startPoint = std::chrono::steady_clock::now(); } float Timer::Elapsed() const { return static_cast(ElapsedSeconds()); @@ -18,4 +16,4 @@ namespace Sleak { std::chrono::steady_clock::now() - startPoint; return elapsed.count(); } -}; + }; // namespace Sleak diff --git a/src/Physics/PhysicsWorld.cpp b/src/Physics/PhysicsWorld.cpp index 0ce5ad31..f8ea165f 100644 --- a/src/Physics/PhysicsWorld.cpp +++ b/src/Physics/PhysicsWorld.cpp @@ -1,10 +1,10 @@ -#include -#include -#include -#include #include -#include +#include #include +#include +#include +#include +#include #include #include diff --git a/src/Scene/SceneBase.cpp b/src/Scene/SceneBase.cpp index 42cc9957..80b9a279 100644 --- a/src/Scene/SceneBase.cpp +++ b/src/Scene/SceneBase.cpp @@ -1,17 +1,19 @@ #include "../../include/public/Core/SceneBase.hpp" + +#include #include #include -#include +#include #include #include #include -#include -#include #include -#include -#include "../../include/private/Graphics/Common/RenderCommandQueue.hpp" +#include +#include #include +#include "../../include/private/Graphics/Common/RenderCommandQueue.hpp" + namespace Sleak { /// Walks obj and its children, registering any ColliderComponent found with world.