A lightweight Unity Job System manager that removes the boilerplate from writing and scheduling high-performance multithreaded jobs.
- Highlights
- What JobIt is Not
- Overview
- Requirements
- Installation
- Usage
- Code Generation (optional)
- Lifecycle at a Glance
- Execution Order
- Samples
- Optional: UniTask support
- AI Disclosure Statement
- License
Using an AI coding assistant? AGENTS.md carries the agent-facing API contract, job templates, and a diagnostics action table.
- Less boilerplate — stop manually managing
JobHandles,NativeContainerlifecycles, and completion calls; JobIt handles all of it - Automatic lifecycle integration — jobs schedule on
Updateand complete onLateUpdate(or both onLateUpdate) with zero setup - Burst-Focused — designed to manage with Burst-compiled jobs
- Multi-component data management — register many components against a single job and let JobIt keep their data in sync
- Execution order control — assign priority to control which jobs execute when across the same frame
- Safe memory management — native containers are built, tracked, and disposed automatically; even handles destroyed objects mid-frame
- Disposable registration handles —
JobRegistration<TJob, TData>wraps a component's register/update/withdraw lifecycle in oneIDisposable, with completion callbacks and readback; or call the static scheduler facade directly from anywhere in your code - Optional code generation — mark a job
[GenerateJobData]and a Roslyn generator writes the native-container plumbing, Burst-struct factories, and scratch buffers for you, with 20+ compile-time diagnostics guarding misuse - Multi-phase jobs — one job class can schedule several Burst passes that share columns across the phase boundary
- Optional UniTask integration — schedule and complete jobs on any UniTask
PlayerLoopTiminginstead ofUpdate/LateUpdate
While powerful, JobIt is not a replacement for Unity's ECS system. Instead, it's designed as a halfway conversion, for projects that started in the classic GameObject flow, but now need the vast performance improvements that a DOTs approach gives. JobIt was originally built before the ECS system entered a production ready state, and now exists as a job wrapper for developers who cannot migrate their projects.
Unity's Job System is powerful but verbose, with the ECS pattern being even more so. A typical workflow requires allocating NativeArrays, scheduling jobs with the right JobHandle dependencies, completing them at the right point in the frame, and cleaning everything up — for every job type you write.
JobIt wraps that workflow into a simple class hierarchy:
- You create a job class by inheriting from a base like
UpdateToUpdateJob<T>and implementing your actualIJobParallelForlogic inside it. - You create a data struct
Tthat describes what each registered component needs. - Any
MonoBehaviourthat wants to participate callsUpdateJobScheduler.Register<MyJob, MyData>(this, data)onOnEnableandUpdateJobScheduler.Withdraw<MyJob, MyData>(this)onOnDisable. Or it holds aJobRegistration<MyJob, MyData>, which registers on construction and withdraws onDispose.
JobIt then:
- Keeps a
NativeContainerbuffer sized to all registered components - Schedules your job every frame at the right point in the lifecycle
- Completes it before any code needs to read the results
- Disposes everything cleanly when the job is destroyed or Play Mode ends
| Dependency | Version |
|---|---|
| Unity | 6000.0.0f1 or newer |
| com.unity.burst | 1.8.9+ |
| com.unity.collections | 1.2.4+ |
Open the Unity Package Manager (Window → Package Manager), click +, and choose Add package from git URL:
https://github.com/Reag/JobIt.git
Or add it directly to your project's Packages/manifest.json:
{
"dependencies": {
"com.dms.jobit": "https://github.com/Reag/JobIt.git"
}
}This is the per-component data your job operates on.
public struct MyJobData
{
public float Speed;
public float3 Direction;
}Inherit from UpdateToUpdateJob<T> (schedules on Update, completes on LateUpdate) or LateUpdateToLateUpdateJob<T> (schedules before LateUpdate and completes near the end of LateUpdate).
Implement the abstract members to wire up your NativeContainers and your actual IJob / IJobParallelFor struct.
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using JobIt.Runtime.Abstract;
public class MoveJob : UpdateToUpdateJob<MyJobData>
{
private NativeList<float> _speeds;
private NativeList<float3> _directions;
protected override void BuildNativeContainers()
{
_speeds = new NativeList<float>(Allocator.Persistent);
_directions = new NativeList<float3>(Allocator.Persistent);
}
protected override void DisposeNativeContainers()
{
_speeds.Dispose();
_directions.Dispose();
}
protected override void AddJobData(MyJobData data)
{
_speeds.Add(data.Speed);
_directions.Add(data.Direction);
}
protected override void RemoveJobDataAndSwapBack(int index)
{
_speeds.RemoveAtSwapBack(index);
_directions.RemoveAtSwapBack(index);
}
protected override MyJobData ReadJobDataAtIndex(int index) =>
new MyJobData { Speed = _speeds[index], Direction = _directions[index] };
// --- Job scheduling (completion is handled by the invoker) ---
protected override JobHandle ScheduleJob(JobHandle dependsOn = default)
{
return new MoveParallelJob
{
Speeds = _speeds.AsArray(),
Directions = _directions.AsArray(),
DeltaTime = Time.deltaTime
}.Schedule(JobSize, 64, dependsOn);
}
[BurstCompile]
struct MoveParallelJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> Speeds;
[ReadOnly] public NativeArray<float3> Directions;
public float DeltaTime;
public void Execute(int index)
{
// Logic goes here
}
}
}See the Inherit Transform sample (
Window → Package Manager → JobIt → Samples) for a complete, working example usingTransformAccessArray.
Any MonoBehaviour that wants to participate adds itself to the job.
using JobIt.Runtime.Impl.JobScheduler;
using UnityEngine;
public class Mover : MonoBehaviour
{
[SerializeField] private float speed = 5f;
[SerializeField] private Vector3 direction = Vector3.forward;
private void OnEnable()
{
UpdateJobScheduler.Register<MoveJob, MyJobData>(
this,
new MyJobData { Speed = speed, Direction = direction }
);
}
private void OnDisable()
{
UpdateJobScheduler.Withdraw<MoveJob, MyJobData>(this);
}
}That's it. The singleton job instance is created automatically the first time a component registers.
Calling the scheduler directly is fine, but JobRegistration<TJob, TData> bundles the whole
lifecycle into one disposable object — construct it to register, Dispose it to withdraw. Like the
static facade, registration is queued and takes effect at the next schedule, not immediately, and
does nothing outside play mode:
using JobIt.Runtime.Utils;
using UnityEngine;
public class Mover : MonoBehaviour
{
[SerializeField] private float speed = 5f;
[SerializeField] private Vector3 direction = Vector3.forward;
private JobRegistration<MoveJob, MyJobData> _registration;
private void OnEnable() =>
_registration = new JobRegistration<MoveJob, MyJobData>(
this, new MyJobData { Speed = speed, Direction = direction });
public void SetSpeed(float newSpeed)
{
speed = newSpeed;
_registration?.UpdateJobData(new MyJobData { Speed = speed, Direction = direction });
}
private void OnDisable()
{
_registration?.Dispose(); // withdraws
_registration = null;
}
}It needs no subclass. Subclass JobRegistrationBase<TJob, TData> only when you want to override how
registration reaches the scheduler.
Call UpdateJobData whenever the underlying values change.
public void SetSpeed(float newSpeed)
{
speed = newSpeed;
UpdateJobScheduler.UpdateJobData<MoveJob, MyJobData>(
this,
new MyJobData { Speed = speed, Direction = direction }
);
}Results written by the job into your NativeContainers are available after the job completes. Use TryReadItem or subscribe to OnJobComplete.
// Subscribe once (e.g. in OnEnable)
UpdateJobScheduler.GetJobObject<MoveJob, MyJobData>().OnJobComplete += OnMoveComplete;
private void OnMoveComplete()
{
if (UpdateJobScheduler.TryReadJobData<MoveJob, MyJobData>(this, out var data))
{
// data is safe to read here
}
}If you're holding a JobRegistration<TJob, TData> handle instead (see 3b), subscribe to its JobCompleted event and read with TryReadData, which wraps the same TryReadJobData call:
private void OnEnable()
{
_registration = new JobRegistration<MoveJob, MyJobData>(
this, new MyJobData { Speed = speed, Direction = direction });
_registration.JobCompleted += OnMoveComplete;
}
private void OnMoveComplete()
{
if (_registration.TryReadData(out var data))
{
// data is safe to read here
}
}Either way, mind the difference between the two data accessors. CurrentData (on the registration
handle) is the last value you pushed — it may not be applied yet, since register/update/withdraw
are all queued to take effect at the next schedule. TryReadData / TryReadJobData is what the
job's containers actually hold right now, and only succeeds once the job has completed — calling
it while the job is running fails and logs a warning.
Writing a job class means hand-authoring the same container bookkeeping — BuildNativeContainers, AddJobData, RemoveJobDataAndSwapBack, UpdateJobData, DisposeNativeContainers, and ReadJobDataAtIndex — with one parallel container per data field. JobIt can generate all of it.
Mark your job partial with [GenerateJobData], and tag the members of your data struct with [JobData]:
using JobIt.Runtime.CodeGen;
public struct MyJobData
{
[JobData] public Transform mover; // -> TransformAccessArray
[JobData] public float speed; // -> NativeList<float>
[JobData] public float3 direction; // -> NativeList<float3>
}
[GenerateJobData]
public partial class MoveJob : UpdateToUpdateJob<MyJobData>
{
protected override JobHandle ScheduleJob(JobHandle dependsOn = default) { /* your logic */ }
// BuildNativeContainers / AddJobData / RemoveJobDataAndSwapBack /
// UpdateJobData / DisposeNativeContainers / ReadJobDataAtIndex are generated.
}The generator creates the backing containers (Transform → TransformAccessArray, any unmanaged type → NativeList<T>) and fills in the six plumbing methods. ScheduleJob and your Burst struct stay hand-written — that's your actual logic.
Build{Name}() factories. Tag a Burst job struct with [JobTarget] and the generator emits a Build{StructName}() that populates it from the generated containers. Mark every field the factory should fill with [JobField] — the marker is required; unmarked fields are treated as manual and left for you to set (a [JobData] column that no [JobField] consumes triggers the JOBIT008 warning). A bare [JobField] binds to the column whose name matches the field's own name; [JobField(nameof(MyJobData.member))] binds to a named column when the names differ. Name matching is canonical: a leading m_ or _ is stripped and the first character lowercased, so a field speed also binds a column _speed or m_Speed. Transform columns are never bound into factories (schedule with the generated accessor instead — see below):
// Both of these live INSIDE the partial [GenerateJobData] class —
// the generator only discovers [JobTarget] structs nested in the job.
[BurstCompile]
[JobTarget]
struct Move : IJobParallelForTransform
{
[JobField]
[ReadOnly] public NativeArray<float> speed; // binds [JobData] 'speed' by name
[JobField(nameof(MyJobData.direction))]
[ReadOnly] public NativeArray<float3> dir; // mapped explicitly
public float deltaTime; // unmarked -> you set it
public void Execute(int i, TransformAccess t) { }
}
protected override JobHandle ScheduleJob(JobHandle dependsOn = default)
{
var job = BuildJob(); // Move is the only [JobTarget], so BuildMove() is aliased as BuildJob()
job.deltaTime = Time.deltaTime; // wire the rest yourself
return job.Schedule(MoverTransforms, dependsOn);
}Here Move is the job's only [JobTarget], so the generator emits BuildMove() and, because it's the sole target, a BuildJob() alias for it — either name can be called. A multi-phase job with several [JobTarget] structs would instead call each Build{StructName}() by its full name (no alias, since there's more than one), threading the JobHandles from phase to phase in ScheduleJob. A column may be bound by fields in different targets to carry data across the phase boundary — see the Spring To Target sample for a complete two-phase job.
The generator also emits a {Member}Transforms accessor property per [JobData] Transform column (here, MoverTransforms), so ScheduleJob references documented API instead of the internal backing field.
All-or-nothing plumbing. The four data-plumbing methods — BuildNativeContainers, AddJobData, RemoveJobDataAndSwapBack, DisposeNativeContainers — are all-or-nothing: hand-write one of them yourself (for example, to manage a container the generator doesn't cover) and the compiler requires you to implement all four, so you can't silently forget a lifecycle step. The generator also warns if you never wire up generated API: JOBIT006 if a generated factory is never called, JOBIT007 if a generated transform accessor is never used.
Mixing hand-written code. Generation is opt-in per job and fully additive — a job without [GenerateJobData] is unchanged. When you hand-write the four data-plumbing methods (all-or-nothing, as above), the generated managed phase still runs first at each lifecycle point and your code runs after it, so generated columns and your hand-managed state coexist.
Jobs often need per-object working state that isn't part of the registration data — accumulated velocity, a smoothed value, last frame's result. Mark a NativeList<T> field (unmanaged T) on a [GenerateJobData] job with [JobBuffer] and the generator owns its entire lifecycle: allocation, one seeded slot per registered object, swap-back removal, and disposal — always index-aligned with the [JobData] columns. Bind it into a [JobTarget] struct with [JobField] exactly like a column:
[GenerateJobData]
public partial class SmoothedSpinJob : UpdateToUpdateJob<SmoothedSpinElement>
{
[JobBuffer(CustomSeed = true)] private NativeList<Quaternion> _smoothed;
// Called by generated code to seed each new '_smoothed' slot.
private Quaternion SeedSmoothed(SmoothedSpinElement data) => Quaternion.identity;
protected override JobHandle ScheduleJob(JobHandle dependsOn = default)
{
var integrate = BuildIntegrate(); // phase 1 writes the buffer
integrate.deltaTime = Time.deltaTime;
var handle = integrate.Schedule(SpinnerTransforms, dependsOn);
var apply = BuildApply(); // phase 2 reads it
return apply.Schedule(SpinnerTransforms, handle);
}
// ... [JobTarget] structs Integrate and Apply bind the buffer with [JobField]
}- New slots seed to
default(T), or to yourSeed{Name}(TData)method whenCustomSeed = true(forget the method and JOBIT017 tells you — with a one-click code fix in the IDE). ReseedOnUpdate = truere-seeds a slot whenever its owner's job data is updated; by default updates leave the buffer slot untouched.- The buffer is internal working memory: it is not part of the registration struct
Tand is not surfaced byTryReadItem.
See the Smoothed Spin sample for the complete job, where phase 1 integrates rotation state in the buffer and phase 2 applies it to Transforms.
Misuse is caught at compile time and surfaces in your IDE's error list and the Unity console. Errors block generation for the offending job; warnings flag work that would be silently wasted. (JOBIT004 is retired and its ID reserved.)
| ID | Severity | Meaning |
|---|---|---|
| JOBIT001 | Error | [GenerateJobData] class is not partial |
| JOBIT002 | Warning | No [JobData] members and no [JobBuffer]s — only empty lifecycle plumbing is generated |
| JOBIT003 | Error | Managed non-Transform type marked [JobData] |
| JOBIT005 | Error | [JobField] resolves to no column or buffer |
| JOBIT006 | Warning | A generated Build{Name}() factory is never called |
| JOBIT007 | Warning | A generated {Member}Transforms accessor is never used |
| JOBIT008 | Warning | A [JobData] column no [JobField] consumes |
| JOBIT009 | Error | [JobField] type is not NativeArray<column element> |
| JOBIT010 | Warning | [JobField] outside a [JobTarget] struct |
| JOBIT011 | Error | [GenerateJobData] on a type without an UpdateJob<T> base |
| JOBIT012 | Warning | Two [JobField]s bind the same column within one [JobTarget] |
| JOBIT013 | Warning | [JobData] on a static/const field |
| JOBIT014 | Error | [JobTarget] struct implements no Unity job interface |
| JOBIT015 | Error | [JobBuffer] on a type that isn't NativeList<unmanaged T> |
| JOBIT016 | Warning | A [JobBuffer] no [JobField] consumes |
| JOBIT017 | Error | CustomSeed = true but no Seed{Name}(TData) method (code fix available) |
| JOBIT018 | Warning | A seed method exists but its buffer doesn't set CustomSeed = true |
| JOBIT019 | Error | [JobBuffer] field is readonly or inline-initialized |
| JOBIT020 | Error | A [JobBuffer] and a column share a canonical name |
| JOBIT021 | Warning | [JobBuffer] on a static/const field |
| JOBIT022 | Error | Two [JobData] columns share a canonical name |
| JOBIT023 | Warning | [JobTarget] struct is not nested inside a [GenerateJobData] class |
| JOBIT024 | Error | [GenerateJobData] on a nested or generic class (unsupported) |
| JOBIT025 | Warning | [JobBuffer] on a class without [GenerateJobData] |
The Orbit Around sample ends with a "diagnostics playground" comment block — one-line edits that make each diagnostic fire, so you can see where every squiggle lands.
Visual Studio + ReSharper users: ReSharper's "Hide only those VS squiggles that duplicate ReSharper code analysis highlightings" setting (Environment → Editor → Visual Studio Features) also hides custom analyzer squiggles like JOBIT's. The diagnostics still appear in the Error List,
dotnet build, the Unity console, and Rider — disable that option to see the squiggles inline.
Update ──► [UpdateJobInvoker] Schedule all registered jobs
(PreStartJob → ScheduleJob for each)
LateUpdate ──► [LateUpdateJobCompleter] Complete all job handles
→ OnJobComplete fired
Swap UpdateToUpdateJob with LateUpdateToLateUpdateJob if your results are only needed within the same LateUpdate.
When multiple jobs are registered with the same invoker, JobIt chains their JobHandles in priority order. Assign a priority by overriding JobPriority:
protected override int JobPriority => 10; // higher runs laterJobs with the same priority receive a combined dependency handle (all of them must finish before the next priority tier begins).
Import any of these from the Package Manager (Window → Package Manager → JobIt → Samples). Together they walk the whole API surface, from fully hand-written to fully generated:
| Sample | Demonstrates |
|---|---|
| Inherit Transform | A hand-written job: a virtual "owner" in the transform hierarchy via TransformAccessArray, with manually managed containers |
| Job Registration | JobRegistration — holding a component's register/update/withdraw lifecycle in a disposable handle instead of calling the scheduler directly |
| Orbit Around | A fully code-generated single-phase job ([GenerateJobData] + [JobTarget]); doubles as a diagnostics playground |
| Spring To Target | A multi-phase job: two [JobTarget] structs sharing a velocity column across the phase boundary |
| Smoothed Spin | [JobBuffer] scratch state: a generator-managed rotation buffer with a custom seed, written in phase 1 and read in phase 2 |
| Match Rotation (UniTask) | A UniTask-timed job (UniTaskInvokedUpdateJob); requires the UniTask package |
If the UniTask package (com.cysharp.unitask) is installed,
JobIt exposes UniTaskInvokedUpdateJob<T>, whose scheduling and completion run on UniTask
PlayerLoopTiming injection points instead of Update/LateUpdate. When UniTask is not
installed, none of this code is compiled and the core package is unaffected.
Override the two timing properties to place your job in the frame:
using Cysharp.Threading.Tasks;
using JobIt.Runtime.UniJobs;
public class MyJob : UniTaskInvokedUpdateJob<MyData>
{
protected override PlayerLoopTiming StartTiming => PlayerLoopTiming.Update;
protected override PlayerLoopTiming CompleteTiming => PlayerLoopTiming.PostLateUpdate;
// ... implement the UpdateJob<T> members as usual
}CompleteTiming must be later in the frame than StartTiming (validated at registration; an
invalid window logs an error and the job is not scheduled). Jobs that share a CompleteTiming
are completed together in a single sync point, even if their StartTimings differ. See the
"Match Rotation (UniTask)" sample.
The original core of this library — all code, tests, and structure through version 1.0.4 — was created entirely by humans with no AI assistance. Beginning with version 1.1.0, the optional UniTask integration (the DMS.JobIt.UniJobs assembly, its tests, and the "Match Rotation" sample) was written with AI assistance under human direction and review. Version 1.2.0's job-data code generation — the Generator~ Roslyn project, the JobIt.Runtime.CodeGen attributes, the additive UpdateJob<T> two-phase changes, and their tests — was likewise written with AI assistance under human direction and review. This Readme, AGENTS.md, and CLAUDE.md also involved the use of AI. Version 1.3.0's JobRegistration API, runtime resilience fixes and their tests were written with AI assistance under human direction and review.
MIT — see LICENSE.md.