From c2bcb9b9febfe2491aef3beb476ef2ae723abe77 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 16:54:08 +0300 Subject: [PATCH] M4: Stage.FixedUpdate + Time's fixed-step accumulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were explicitly deferred to M4 by ITime and Stage's own doc comments back when they were written: a FixedUpdate stage with no real accumulator behind it would be actively misleading, and building the accumulator with no physics system to test it against would be untested speculative machinery. engine.physics (next) is the real consumer. Time.ConsumeFixedSteps accumulates DeltaTime and hands back how many FixedDeltaTime-sized (1/50s) steps it can pay for, capped at 5 per frame so a real stall becomes visible lag instead of a catch-up burst of physics steps. Engine.Host calls it once per frame in both the headless and windowed loops, running Stage.FixedUpdate that many times before Stage.Update — gated by IPlayModeController.IsPlaying the same way Update already is, and only accumulating time while actually playing, so entering Play doesn't open with a burst of steps for however long Edit mode had been sitting idle. 3 new tests on the accumulator itself (below-one-step, whole-steps-plus- remainder, the 5-step cap under a simulated stall). Full suite: 76 tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- src/Engine.Host/Program.cs | 17 +++++++++++- src/Engine.Kernel/Diagnostics/ITime.cs | 21 +++++++++----- src/Engine.Kernel/Diagnostics/Time.cs | 38 ++++++++++++++++++++++++-- src/Engine.Kernel/Scheduling/Stage.cs | 20 +++++++++----- tests/Engine.Kernel.Tests/TimeTests.cs | 33 ++++++++++++++++++++++ 5 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index 7210cc2..47bdf06 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -270,9 +270,19 @@ if (windowed) // before this plugin existed. With one loaded, Update only runs // while actually Playing: Edit mode still renders the scene every // frame (so the editor UI stays responsive and the view isn't - // frozen mid-edit), it just never ticks it. + // frozen mid-edit), it just never ticks it. FixedUpdate is gated + // the same way and for the same reason — physics shouldn't step + // while Edit mode has the world frozen — and only accumulates time + // while playing, so Play doesn't open with a catch-up burst of + // steps for however long Edit mode had been sitting idle. if (playMode is null || playMode.IsPlaying) + { + var fixedSteps = time.ConsumeFixedSteps(); + for (var step = 0; step < fixedSteps; step++) + schedule.RunStage(Stage.FixedUpdate, world); + schedule.RunStage(Stage.Update, world); + } schedule.RunStage(Stage.Render, world); schedule.RunStage(Stage.Present, world); @@ -313,6 +323,11 @@ else for (var frame = 0; frame < frames; frame++) { time.Tick(headlessDeltaTime); + + var fixedSteps = time.ConsumeFixedSteps(); + for (var step = 0; step < fixedSteps; step++) + schedule.RunStage(Stage.FixedUpdate, world); + schedule.RunStage(Stage.Update, world); } diff --git a/src/Engine.Kernel/Diagnostics/ITime.cs b/src/Engine.Kernel/Diagnostics/ITime.cs index 58a2e4c..a44cce7 100644 --- a/src/Engine.Kernel/Diagnostics/ITime.cs +++ b/src/Engine.Kernel/Diagnostics/ITime.cs @@ -3,13 +3,12 @@ namespace Engine.Kernel.Diagnostics; /// /// Frame clock. See docs/kernel-contract.md §2. /// -/// No fixed-step accumulator yet — that was in the original kernel scope -/// but nothing consumes it: building it now, with no physics system to -/// test it against, would be exactly the kind of untested speculative -/// machinery this project has avoided everywhere else. It arrives with -/// M4, alongside the Stage.FixedUpdate it would drive — a "FixedUpdate" -/// stage without a real fixed-timestep accumulator behind it would be -/// actively misleading, not just incomplete. +/// M4 adds the fixed-step accumulator this doc comment used to say was +/// missing: engine.physics needs to step Box3D at a constant rate +/// regardless of how the real frame rate wobbles, and a "FixedUpdate" +/// stage without a real accumulator behind it would be actively +/// misleading, not just incomplete. See Stage.FixedUpdate and +/// Time.ConsumeFixedSteps for the actual mechanism. /// public interface ITime { @@ -20,4 +19,12 @@ public interface ITime double ElapsedTime { get; } int FrameCount { get; } + + /// + /// The constant step size Stage.FixedUpdate always runs at, regardless + /// of the real frame rate — 1/50s. Not per-project configurable yet: + /// nothing has needed a different rate, and a setting nobody's asked + /// for is speculative the same way an unconsumed accumulator was. + /// + float FixedDeltaTime { get; } } diff --git a/src/Engine.Kernel/Diagnostics/Time.cs b/src/Engine.Kernel/Diagnostics/Time.cs index 5dac6f4..5193971 100644 --- a/src/Engine.Kernel/Diagnostics/Time.cs +++ b/src/Engine.Kernel/Diagnostics/Time.cs @@ -2,15 +2,26 @@ namespace Engine.Kernel.Diagnostics; /// /// Read-only to plugins (ITime); advanced by whoever owns the frame loop — -/// Engine.Host today, headless or windowed — via , which -/// isn't on the interface. Same split as Schedule/ISchedule: the host gets -/// the extra, plugin-facing code doesn't. +/// Engine.Host today, headless or windowed — via and +/// , neither on the interface. Same split as +/// Schedule/ISchedule: the host gets the extra, plugin-facing code doesn't. /// public sealed class Time : ITime { + // Capped so a real stall — a debugger pause, a slow disk load — can't + // turn into a catch-up burst of hundreds of physics steps executing + // back to back, each one still assuming a normal frame budget around + // it. Five steps behind (0.1s at the default rate) already means + // something else is wrong; better to visibly fall behind real time + // than to freeze the process trying to simulate through it. + private const int MaxStepsPerFrame = 5; + + private float _accumulator; + public float DeltaTime { get; private set; } public double ElapsedTime { get; private set; } public int FrameCount { get; private set; } + public float FixedDeltaTime { get; } = 1f / 50f; public void Tick(float deltaTime) { @@ -18,4 +29,25 @@ public sealed class Time : ITime ElapsedTime += deltaTime; FrameCount++; } + + /// + /// Adds this frame's DeltaTime to the accumulator and returns how many + /// FixedDeltaTime-sized steps it can pay for, having already deducted + /// that much. Call once per frame, then run Stage.FixedUpdate that many + /// times (zero is normal — most frames are shorter than FixedDeltaTime + /// at 50Hz against a 60+fps display). + /// + public int ConsumeFixedSteps() + { + _accumulator += DeltaTime; + + var steps = 0; + while (_accumulator >= FixedDeltaTime && steps < MaxStepsPerFrame) + { + _accumulator -= FixedDeltaTime; + steps++; + } + + return steps; + } } diff --git a/src/Engine.Kernel/Scheduling/Stage.cs b/src/Engine.Kernel/Scheduling/Stage.cs index bbe54b2..b1ffe20 100644 --- a/src/Engine.Kernel/Scheduling/Stage.cs +++ b/src/Engine.Kernel/Scheduling/Stage.cs @@ -3,16 +3,22 @@ namespace Engine.Kernel.Scheduling; /// /// Fixed, kernel-defined, not plugin-extensible — see "Frame stages" in /// docs/kernel-contract.md §8. is the second addition -/// to the original {Update, Render} set (after FixedUpdate was scoped for -/// M4), added for M3's editor: engine.render splits its old single -/// Clear+Draw+SwapBuffers system into a Render-stage draw and a -/// Present-stage swap specifically so engine.editor's ImGui overlay — which -/// has to run after the scene is drawn but before the buffers swap — has -/// somewhere to register that isn't a race against load order. Not a stage -/// added speculatively: SwapBuffers already needed to move somewhere real. +/// to the original {Update, Render} set, added for M3's editor: engine. +/// render splits its old single Clear+Draw+SwapBuffers system into a +/// Render-stage draw and a Present-stage swap specifically so engine. +/// editor's ImGui overlay — which has to run after the scene is drawn but +/// before the buffers swap — has somewhere to register that isn't a race +/// against load order. is the third, arriving +/// with M4 exactly as ITime's own doc comment said it would: engine.physics +/// steps Box3D here, at ITime.FixedDeltaTime's fixed rate, run zero-or-more +/// times per frame by the accumulator Engine.Host drives — see +/// Time.ConsumeFixedSteps. Runs before Update each frame it fires, so +/// gameplay code reading a Rigidbody's position in Update sees this frame's +/// physics result, not last frame's. /// public enum Stage { + FixedUpdate, Update, Render, Present, diff --git a/tests/Engine.Kernel.Tests/TimeTests.cs b/tests/Engine.Kernel.Tests/TimeTests.cs index 5f696da..b0300cf 100644 --- a/tests/Engine.Kernel.Tests/TimeTests.cs +++ b/tests/Engine.Kernel.Tests/TimeTests.cs @@ -38,4 +38,37 @@ public class TimeTests Assert.Equal(0.75, time.ElapsedTime, 3); Assert.Equal(2, time.FrameCount); } + + [Fact] + public void ConsumeFixedSteps_BelowOneStep_ReturnsZero() + { + var time = new Time(); + + // FixedDeltaTime is 1/50 = 0.02s; a 60fps-ish frame is shorter. + time.Tick(0.01f); + + Assert.Equal(0, time.ConsumeFixedSteps()); + } + + [Fact] + public void ConsumeFixedSteps_ConsumesWholeStepsAndKeepsRemainder() + { + var time = new Time(); + + time.Tick(0.05f); // 2.5 steps' worth + Assert.Equal(2, time.ConsumeFixedSteps()); + + time.Tick(0.03f); // remainder 0.01 + 0.03 = 0.04 -> 2 more steps + Assert.Equal(2, time.ConsumeFixedSteps()); + } + + [Fact] + public void ConsumeFixedSteps_CapsAtFiveEvenAfterAHugeStall() + { + var time = new Time(); + + time.Tick(10f); // a debugger pause, not a real frame + + Assert.Equal(5, time.ConsumeFixedSteps()); + } }