Will [RegisterGenericJobType] be required for all generic jobs going forward?

In case it will help, here’s what we’ve been doing. This is what broke when using Entities 0.14 and Unity 2020.2. This used to compile fine - Burst had no problem detecting these generic job for JIT and AOT:

We started with a problem: Most of our jobs shared 90% of the same logic & component types. But 10% was always unique to each job. We knew it would be crucial to our workflow to figure out a way not to repeat a bunch of the same logic in each of our dozens or hundreds of such jobs.

(Our particular need in these jobs was to iterate over DynamicBuffers, and perform some logic on each of their elements).

The solution was to have our devs write unique structs inside of their systems, which contained the custom logic they wanted to perform on each of the DB elements. Then, to schedule that logic as a job, we would encapsulate that unique struct inside of a generic wrapper job. That wrapper job would house all of the shared job code, which otherwise would need to be repeated in each of the structs our devs write:

So given these component types:

public struct Foo
{
    public int num;
}

public struct Bar : IBufferElementData
{
    public int num;
}

public struct Baz : IComponentData
{
    public int num;
}

We wrote a generic static class which held our wrapper job type, along with a special Schedule() method:

public static class IJobCustomUtility<TJob, T0> where TJob : struct, IJobCustom<T0> where T0 : struct, IBufferElementData
{
    [BurstCompile]
    public struct WrapperJob : IJobChunk
    {
        // wrapped job ends up here
        public TJob wrappedJob;

        [ReadOnly]
        [DeallocateOnJobCompletion]
        public NativeArray<Foo> foos;

        [ReadOnly]
        public EntityTypeHandle entityType;
        public ComponentTypeHandle<Baz> bazType;
        public BufferTypeHandle<T0> t0Type;
        public bool isReadOnly_T0;

        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var entities = chunk.GetNativeArray(entityType);
            var bazArray = chunk.GetNativeArray(bazType);
            var t0Buffers = chunk.GetBufferAccessor(t0Type);

            for (int i = 0; i < chunk.Count; i++)
            {
                var entity = entities[i];
                var baz = bazArray[i];
                var t0Buffer = t0Buffers[i];

                for (int iT0 = 0; iT0 < t0Buffer.Length; iT0++)
                {
                    var t0 = t0Buffer[iT0];

                    // wrapped job Execute method is called
                    wrappedJob.Execute(entity, ref t0, ref baz, foos);

                    bazArray[i] = baz;

                    if (!isReadOnly_T0)
                    {
                        t0Buffer[iT0] = t0;
                    }
                }
            }
        }
    }

    public static JobHandle Schedule(TJob jobData, CustomSystemBase system, JobHandle dependentOn)
    {
        EntityQuery query = GetExistingEntityQuery(system);

        if (query == default)
        {
            ComponentType[] componentTypes;
            if (!componentTypesByJobType.TryGetValue(typeof(TJob), out componentTypes))
            {
                componentTypes = GetIJobCustomComponentTypes();
            }

            query = system.GetEntityQueryPublic(componentTypes);

            system.RequireForUpdate(query);

            if (query.CalculateChunkCount() == 0)
            {
                return dependentOn;
            }
        }

        WrapperJob wrapperJob = new WrapperJob
        {
            wrappedJob = jobData,
            foos = new NativeArray<Foo>(10, Allocator.TempJob),
            entityType = system.EntityManager.GetEntityTypeHandle(),
            bazType = system.EntityManager.GetComponentTypeHandle<Baz>(false),
            t0Type = system.EntityManager.GetBufferTypeHandle<T0>(false),
            isReadOnly_T0 = false
        };

        return wrapperJob.ScheduleParallel(query, dependentOn);
    }

    private static Dictionary<Type, ComponentType[]> componentTypesByJobType = new Dictionary<Type, ComponentType[]>();

    private static EntityQuery GetExistingEntityQuery(ComponentSystemBase system)
    {
        ComponentType[] componentTypes;
        if (!componentTypesByJobType.TryGetValue(typeof(TJob), out componentTypes))
        {
            return default;
        }

        for (var i = 0; i != system.EntityQueries.Length; i++)
        {
            if (system.EntityQueries[i].CompareComponents(componentTypes))
                return system.EntityQueries[i];
        }

        return default;
    }

    private static ComponentType[] GetIJobCustomComponentTypes()
    {
        // Temporary. A final version might use reflection to find the read/write attributes from the wrapper job's Execute method.
        return new ComponentType[]
            {
            ComponentType.ReadWrite<T0>(),
            ComponentType.ReadWrite<Baz>()
            };
    }

}

We then created a custom interface - this is the ‘job’ type our devs would actually write in their systems. This is what the wrapper job actually “wraps”:

public interface IJobCustom<T> where T : struct, IBufferElementData
{
    void Execute(Entity entity, ref T t0, ref Baz baz, NativeArray<Foo> foos);
}

We created a custom system type, just so we could expose the GetEntityQuery() function:

public abstract class CustomSystemBase : SystemBase
{
    public EntityQuery GetEntityQueryPublic(ComponentType[] componentTypes)
    {
        return GetEntityQuery(componentTypes);
    }
}

…Then, this is what one of our dev’s actual systems might look like:

public class MySystem : CustomSystemBase
{
    protected override void OnUpdate()
    {
        Dependency = IJobCustomUtility<WrappedCustomJob, Bar>.Schedule(new WrappedCustomJob
        {
            toAdd = 10
        }, this, Dependency);
    }

    public struct WrappedCustomJob : IJobCustom<Bar>
    {
        public int toAdd;

        public void Execute(Entity entity, ref Bar t0, ref Baz baz, NativeArray<Foo> foos)
        {
            // do custom logic here
            t0.num += toAdd;
        }
    }
}

You can see in this last code snippet that all this dev had to do was define a struct that implements the IJobCustom interface.

Then in OnUpdate(), they call the Schedule method and pass in a new instance of this IJobCustom. The Schedule method takes it from there, and wraps that IJobCustom in the wrapper job, populates the wrapper job with a bunch of data, and then schedules that wrapper job.

This had so many advantages for us:

  • No code was repeated between the jobs they wrote day to day. So there was no boilerplate for our devs to worry about. They didn’t have to think about what was going on behind the scenes - only what logic to do on each element in those DynamicBuffers.
  • No repeated code means no human error when repeating that code.
  • A single source for shared code means that code was easy to change (which has happened dozens of times since we implemented this pattern).
  • The jobs they defined in their systems could still include their own local variables - for example, the ‘toAdd’ integer in the example above. ← we had previously looked into using FunctionPointers, but they wouldn’t have allowed for this.

We fretted for weeks over wether this was a “bad” way to solve this problem. I mean, we’re devs - we fret that way about every solution.

But after a lot of testing, it worked, and worked quickly. Everything abided by the guidelines in your Generic Jobs Burst documentation, and so everything Burst-compiled fine in our AOT builds. It was so fast and convenient, that it made huge DOTS believers out of us. It’s been a critical part of our code since then, and a big part of why we’re DOTS cheerleaders over here.

But now, that solution just won’t work. If we were to try to use the [RegisterGenericJobType] attribute, the [assembly: ... ] line we’d have to write for it would be giant and convoluted. We’d have to mark almost everything involved public, just so we could access it from that line. And it would break our initial goal of not forcing our devs to think about the boiletplate behind the scenes in their day-to-day work.

If you, or anything else, can think of an elegant way to adapt our pattern, it would help us tremendously. You would have solved a problem that I’ve been failing at for over a week.

But so far, it looks like there’s no current workaround that will give us the features we had before. It’s just not a viable option for us to repeat all of that shared code in all of our jobs, every day. We need some way to share common code like this between them, or we’re going to have to reconsider our ability to use DOTS in our products.