Skip to content

Fixed timestep API

GodotECS.Fixed — configurable fixed-step simulation, time singletons, and the interpolation pipeline. Source: addons/godotecs/src/fixed/. Concept overview: Core concepts.

FixedConfig and EcsTime

csharp
public struct FixedConfig : GodotECS.Core.ISingleton
{
    public const float DefaultHz = 60f;
    public const float MinHz = 10f;
    public const float MaxHz = 240f;
    public const int DefaultMaxSubsteps = 3;
    public const int MaxClampedSubsteps = 64;
    public float TargetHz;
    public int MaxSubsteps;
    public double Accumulator;   // internal: unsimulated residue, seconds
    public static FixedConfig Default { get; }   // 60 Hz, 3 substeps, empty accumulator
    public static float ClampHz(float hz);       // NaN/Inf -> 60; clamps to [10, 240]
    public static int ClampSubsteps(int n);      // clamps to [1, 64]
    public float FixedDelta { get; }             // 1 / ClampHz(TargetHz); never <= 0, NaN, or Inf
    public FixedConfig Sanitized();              // validated copy: Hz, substeps, finite Accumulator >= 0
}

public struct EcsTime : GodotECS.Core.ISingleton
{
    public uint Tick;
    public float FixedDelta;   // from the active FixedConfig, e.g. 1/60
    public float Delta;        // variable render delta of the last TickFixed
    public double Alpha;       // 0..1 Prev -> Curr interpolation factor
    public double Elapsed;     // total seconds fed through TickFixed
}

Both are World singletons (GetSingleton / SetSingleton). Tune per game by setting the world's FixedConfig; systems read timing once per OnUpdate via SystemState or the EcsTime singleton — never per entity in hot paths.

FixedStepper

csharp
public sealed class FixedStepper
{
    public float FixedDelta { get; set; } = 1f / 60f;
    public int MaxSubsteps { get; set; } = 3;
    public uint Tick { get; private set; }
    public double Alpha { get; private set; }
    public ulong Spills { get; private set; }
    public double Accumulator { get; }
    // Adopt a validated config, importing its residue (smooth hot Hz changes).
    public void Configure(in FixedConfig cfg);
    // Calls step(tick, fixedDelta) 0..N times, then present(alpha). Null-tolerant.
    public void Update(double frameDelta, System.Action<uint, float> step, System.Action<double> present);
    public void Reset();   // Tick = 0, Alpha = 0, Spills = 0, accumulator = 0
}

Update accumulates the variable frameDelta (negative/NaN/Infinity → 0), runs up to MaxSubsteps steps, then drops any remaining backlog and counts the event in Spills — the sim slows down under extreme lag instead of spiraling. Alpha (residue / FixedDelta, always in [0,1]) drives present interpolation. World.TickFixed implements the same algorithm against the world's accumulator, running the FixedStep group and refreshing EcsTime.

Time systems and RenderTransform

csharp
public struct RenderTransform : GodotECS.Core.IComponentData
{
    public Godot.Vector3 Pos;
    public Godot.Quaternion Rot;
    public float Scale;
    public static RenderTransform From(in LocalTransform t);
}

// First in FixedStep: snapshots Curr into Prev before integration.
public struct CopyPrevSystem : GodotECS.Core.ISystem
{
    public void OnCreate(ref GodotECS.Core.SystemState state);
    public void OnUpdate(ref GodotECS.Core.SystemState state);
    public void OnDestroy(ref GodotECS.Core.SystemState state);
}

// First in Presentation: RenderTransform = Lerp/Slerp(Prev, Curr, Alpha).
public struct InterpolateSystem : GodotECS.Core.ISystem
{
    public void OnCreate(ref GodotECS.Core.SystemState state);
    public void OnUpdate(ref GodotECS.Core.SystemState state);
    public void OnDestroy(ref GodotECS.Core.SystemState state);
}

// Presentation, [UpdateBefore(InterpolateSystem)]: equips LocalTransform-only
// entities with PrevTransform + RenderTransform (Prev = Render = Curr).
[UpdateBefore(typeof(InterpolateSystem))]
public struct EnsurePrevRenderSystem : GodotECS.Core.ISystem
{
    public void OnCreate(ref GodotECS.Core.SystemState state);
    public void OnUpdate(ref GodotECS.Core.SystemState state);
    public void OnDestroy(ref GodotECS.Core.SystemState state);
    public void Update(EntityManager em);   // logic entry, usable without a SystemGroup
}

Contract: simulation reads/writes LocalTransform; only Presentation reads PrevTransform + LocalTransform and writes the display-only RenderTransform. InterpolateSystem touches only entities having all three (position lerped, scale lerped, rotation slerped with clamped alpha) via a pure chunk job completed inside OnUpdate. CopyPrevSystem / InterpolateSystem hold their "first" slots by insertion convention — add them before other systems in their groups. EnsurePrevRenderSystem instead declares [UpdateBefore(typeof(InterpolateSystem))], collects entities missing PrevTransform / RenderTransform in two passes (collect, then add, so iteration is never invalidated by moves), and initializes them from current values — no one-frame pop for mid-tick spawns, ~zero steady-state cost. Its Update(EntityManager) overload throws ArgumentNullException on null and lazily creates its queries, so tests can drive it directly.

GodotECS docs — version 0.1.0. All rights reserved until a license is added.