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
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; // simulated seconds (ticks run × FixedDelta), not wall time
}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
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 Elapsed { get; private set; } // simulated seconds (run × FixedDelta)
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. Elapsed advances only by the steps actually run (run × FixedDelta): dropped backlog never counts, so it tracks simulated seconds, not wall time. 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 (whose Elapsed follows the same rule).
Time systems and RenderTransform
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.