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:
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
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
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
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
// 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.