Skip to content

Jobs & scheduler API

GodotECS.Jobs — parallel chunk iteration with deterministic results. Source: addons/godotecs/src/core/Systems.cs (JobHandle, IJobEntityChunk, IJobRunner), addons/godotecs/src/jobs/Scheduler.cs, addons/godotecs/src/jobs/Runners.cs.

SystemState.ScheduleParallel (see Core) delegates to Scheduler. Typical usage:

csharp
public partial struct MoveJob : GodotECS.Jobs.IJobEntityChunk<LocalTransform, Velocity>
{
    public float Dt;
    public void Execute(ref LocalTransform t, ref Velocity v, int entityIndex, int sortKey)
    {
        t.Pos += v.Value * Dt;
    }
}

// In OnUpdate:
var job = new MoveJob { Dt = state.FixedDelta };
state.ScheduleParallel<MoveJob, LocalTransform, Velocity>(ref job, _q).Complete();

JobHandle

csharp
public struct JobHandle
{
    public static readonly JobHandle Completed;
    public bool IsCompleted { get; }   // true when State == null (already done)
    public void Complete();            // blocks until done; rethrows worker exceptions
    public static JobHandle Combine(params JobHandle[] handles);  // empty/null => Completed
}

A handle wraps internal job state; the default value counts as completed. Complete() blocks and rethrows the first worker exception with its original stack. Combine waits for all handles in order. Never call Complete() from inside a chunk body — it deadlocks like in Unity.

Job interfaces

csharp
public interface IJobEntityChunk<T0> where T0 : struct
{
    void Execute(ref T0 c0, int entityIndex, int sortKey);
}
public interface IJobEntityChunk<T0, T1> where T0 : struct where T1 : struct
{
    void Execute(ref T0 c0, ref T1 c1, int entityIndex, int sortKey);
}
public interface IJobEntityChunk<T0, T1, T2>
    where T0 : struct where T1 : struct where T2 : struct
{
    void Execute(ref T0 c0, ref T1 c1, ref T2 c2, int entityIndex, int sortKey);
}
public interface IJobEntityChunk<T0, T1, T2, T3>
    where T0 : struct where T1 : struct where T2 : struct where T3 : struct
{
    void Execute(ref T0 c0, ref T1 c1, ref T2 c2, ref T3 c3, int entityIndex, int sortKey);
}
public interface IJobEntityChunk<T0, T1, T2, T3, T4>
    where T0 : struct where T1 : struct where T2 : struct where T3 : struct where T4 : struct
{
    void Execute(ref T0 c0, ref T1 c1, ref T2 c2, ref T3 c3, ref T4 c4,
        int entityIndex, int sortKey);
}

public delegate void ChunkRangeBody(int from, int to);

public interface IJobRunner
{
    JobHandle ScheduleParallel(ChunkRangeBody chunkRangeBody, int chunkCount,
        int batchSize, JobHandle dependsOn);
}

entityIndex is the row inside the chunk; sortKey is the chunk ordinal in creation order — pass it to ParallelWriter methods for deterministic ECB playback. All generic parameters are currently ref (read-write): the spec's in (read-only, change-version-preserving) variants are a Source Generator task. The scheduler stamps every scheduled column, so scheduled jobs are always observed by DidChangeSince.

WARNING

IJobRunner.ScheduleParallel takes a ChunkRangeBody delegate (void (int from, int to)), not Func<int, int, void> as sketched in docs/API.md §5. dependsOn is completed (blocking) before scheduling — there is no cross-system overlap in v1.

Rules inside Execute: pure math on spans only — no Godot API calls, no structural EntityManager calls (use an ECB ParallelWriter), no nested Complete(). Treat job struct fields as read-only; accumulating into them races. The job is copied once per schedule, so mutations are lost.

Scheduler

csharp
public static class Scheduler
{
    // Default: DedicatedPoolRunner. Swappable (e.g. WorkerPoolRunner, test doubles).
    public static IJobRunner Default { get; set; }
    // True => inline sequential execution with identical semantics and chunk order.
    public static bool ForceSequential { get; set; }
    public static int ComputeBatchSize(int chunkCount, int batchHint);
    public static JobHandle Schedule<TJob, T0>(ref TJob job, EntityManager em,
        EntityQuery query, int batchSize = 128)
        where TJob : struct, IJobEntityChunk<T0>
        where T0 : struct;
    public static JobHandle Schedule<TJob, T0, T1>(ref TJob job, EntityManager em,
        EntityQuery query, int batchSize = 128)
        where TJob : struct, IJobEntityChunk<T0, T1>
        where T0 : struct where T1 : struct;
    public static JobHandle Schedule<TJob, T0, T1, T2>(ref TJob job, EntityManager em,
        EntityQuery query, int batchSize = 128)
        where TJob : struct, IJobEntityChunk<T0, T1, T2>
        where T0 : struct where T1 : struct where T2 : struct;
    public static JobHandle Schedule<TJob, T0, T1, T2, T3>(ref TJob job,
        EntityManager em, EntityQuery query, int batchSize = 128)
        where TJob : struct, IJobEntityChunk<T0, T1, T2, T3>
        where T0 : struct where T1 : struct where T2 : struct where T3 : struct;
    public static JobHandle Schedule<TJob, T0, T1, T2, T3, T4>(ref TJob job,
        EntityManager em, EntityQuery query, int batchSize = 128)
        where TJob : struct, IJobEntityChunk<T0, T1, T2, T3, T4>
        where T0 : struct where T1 : struct where T2 : struct where T3 : struct
        where T4 : struct;
}

Behaviour per schedule: snapshot matching non-empty chunks in creation order (the snapshot index is the job sortKey); bump-then-stamp the involved change versions up front (jobs are change-observable even before Complete()); empty matches return JobHandle.Completed; a single chunk (or ForceSequential) runs inline and also returns Completed. ComputeBatchSize returns batchHint when positive, else max(1, chunkCount / (workers * 4)) where workers = max(1, ProcessorCount - 1).

Runners

csharp
// Above Godot's shared WorkerThreadPool. Real async: the handle completes later.
public sealed class WorkerPoolRunner : IJobRunner
{
    public int WorkerCount { get; }
    public WorkerPoolRunner(int workers = -1);  // default: max(1, ProcessorCount - 1)
    public JobHandle ScheduleParallel(ChunkRangeBody chunkRangeBody, int chunkCount,
        int batchSize, JobHandle dependsOn);
}

// Dedicated persistent threads. Synchronous with caller participation:
// ScheduleParallel returns with the work already done (completed handle).
public sealed class DedicatedPoolRunner : IJobRunner, System.IDisposable
{
    public int WorkerCount { get; }
    public DedicatedPoolRunner(int workers = -1);
    public JobHandle ScheduleParallel(ChunkRangeBody chunkRangeBody, int chunkCount,
        int batchSize, JobHandle dependsOn);
    public void Dispose();
}

Both partition chunk ranges statically and contiguously in creation order, so results are bit-identical regardless of worker count. WorkerPoolRunner enqueues one task per batch (at most workers * 2) and falls back to synchronous execution of not-yet-started ranges if the pool is unreachable; worker exceptions are captured and rethrown on Complete(). DedicatedPoolRunner uses persistent background threads (EcsWorker{i}) with short spin then 1 ms park; the caller executes the last range, so no thread idles — and since it finishes synchronously, there is no system overlap in v1. Use WorkerPoolRunner for genuinely async scheduling. Scheduler.Default is a DedicatedPoolRunner.

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