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()); + } }