Skip to content

Emulate C-Sharp features using struct

What

Many C-Sharp features could be emulated using just struct

Why

  • DOTS offer high performance
  • But Burst-compatible code is limited in functionality
  • OOP has lots of tools to reuse code:
  • Inheritance, abstract and virtual method
  • Enumerator
  • Delegate / function pointer
  • Emulating these features allow us to avoiding code repetition

How

Generic and interface as our tools

Struct

  • Struct does not support inheritance
  • Struct could implement interface
  • Using struct as interface will cause boxing, i.e. an object is created to store the struct value
    interface IVehicle{
        float GetSpeed();
    }
    struct MyData: IVehicle {
        float MySpeed;
        float GetSpeed() => MySpeed;
    }
    
    void MyMethod() {
        // boxing will happen here
        IVehicle vehicle = new MyData{MySpeed = 2f};
        DoSomething(vehicle.GetSpeed());
    }
    
  • Boxing will cause a compilation error for Burst

Generic

  • To avoid boxing, we use generic
    void MyMethod<T>(T vehicle) where T: unmanaged, IVehicle {
        DoSomething(vehicle.GetSpeed());
    }
    

Emulate base class methods

class Base {
    int BaseField1;
    float BaseField2;
    float Compute(float input) => input + BaseField1 + BaseField2;
}
class A: Base {
    float2 FieldA;
}
class B: Base {
    float3 FieldB;
}
- Use IComponentData in composition
struct Base: IComponentData {
    int BaseField1;
    float BaseField2;
    float Compute(float input) => input + BaseField1 + BaseField2;
}
struct A: IComponentData {
    float2 FieldA;
}
struct B: IComponentData {
    float3 FieldB;
}
// entity1: component Base + A
// entity2: component Base + B
EntityManager.GetComponent<Base>(entity1).Compute(1);
- Emulate using composition
struct Base {
    int BaseField1;
    float BaseField2;
    float Compute(float input) => input + BaseField1 + BaseField2;
}
interface IBase {
    Base BaseFields {get;} 
}
struct A: IBase {
    Base BaseFields {get; set;}
    float2 FieldA;
}
struct B: IBase {
    Base BaseFields {get; set;}
    float3 FieldB;
}
void SomeMethod<T>(T value) where T:unmanaged, IBase {
    value.BaseFields.Compute(1);
}
- Use extension method
interface IBase {
    int BaseField1 {get;}
    float BaseField2 {get;}
}
struct A: IBase {
    int BaseField1 {get; set;}
    float BaseField2 {get; set;}
    float2 FieldA;
}
struct B: IBase {
    int BaseField1 {get; set;}
    float BaseField2 {get; set;}
    float3 FieldB;
}
static class BaseExtensions{
    static float Compute<T>(this T value, int input)
    where T:unmanaged, IBase    
    {
        return input + value.BaseField1 + value.BaseField2;
    }
}

A a;
a.Sum(1);

Emulate Method Override (abstract)

class Base {
    int BaseField1;
    float BaseField2;
    abstract float Compute(int input);
}
class A: Base {
    float2 FieldA;
    override float Compute(int input){
        return FieldA + input + BaseField1 + BaseField2;
    }
}
class B: Base {
    float3 FieldB;
    override float Compute(int input){
        return FieldB * input * BaseField1 * BaseField2;
    }
}

Base obj;
obj.Compute(1);
- Use interface and generic
interface IBase {
    int BaseField1 {get;}
    float BaseField2 {get;}
    float Compute(int input);
}
struct A: IBase {
    int BaseField1 {get; set;}
    float BaseField2 {get; set;}
    float2 FieldA;
    float Compute(int input){
        return FieldA + input + BaseField1 + BaseField2;
    }
}
class B: IBase {
    int BaseField1 {get; set;}
    float BaseField2 {get; set;}
    float3 FieldB;
    override float Compute(int input){
        return FieldB * input * BaseField1 * BaseField2;
    }
}

void SomeMethod<T>(T value) where T:unmanaged, IBase {
    value.Compute(1);
}

Emulate Method Override (virtual)

The previous method is not ideal if some struct want to use the base method

class Base {
    int BaseField1;
    float BaseField2;
    virtual float Compute(int input) {
        return input + BaseField1 + BaseField2;
    }
}
class A: Base {
    float2 FieldA;
    override float Compute(int input){
        return FieldA + input + BaseField1 + BaseField2;
    }
}
class B: Base {
    float3 FieldB;
}

Base obj = new B();
obj.Compute(1); // same implementation as Base.Compute
- This scenario could be overcome by using default implementation of interfaces
interface IBase {
    int BaseField1 {get;}
    float BaseField2 {get;}
    float Compute(int input) {
        return input + BaseField1 + BaseField2;
    }
}
struct A: IBase {
    int BaseField1 {get; set;}
    float BaseField2 {get; set;}
    float2 FieldA;
    float Compute(int input){
        return FieldA + input + BaseField1 + BaseField2;
    }
}
class B: IBase {
    int BaseField1 {get; set;}
    float BaseField2 {get; set;}
    float3 FieldB;
}

void SomeMethod<T>(T value) where T:unmanaged, IBase {
    value.Compute(1);
}
- Use a combination of extension method, interface implementation and default implementation, we could emulate any inheritance design using struct
interface IBase {
    float Compute() => 123; // default implementation
}
interface IA: IBase {
    float ComputeA() => 111; // default implementation
}
struct A1: IA {
    float Compute() => 456; // interface implementation
    // inherit ComputeA()
}
struct A2: IA {
    // inherit Compute()
    float ComputeA() => 222; // interface implementation
}
static class AExtensions{
    static float Calculate<T>(this T value) where T:unmanaged, IA {
        return 789;
    }
}

A1 a1;
a1.Compute(); // 456
a1.ComputeA(); // 111
a1.Calculate(); // 789

A2 a2;
a2.Compute(); // 123
a2.ComputeA(); // 222
a2.Calculate(); // 789

Emulate enumerator

IEnumerable<int> Compute(int input){
    int val1 = f1(input);
    yield return val1;
    int val2 = f2(input, val1);
    yield return val2;
}
float state;
foreach (var value in Compute(1)){
    state = f3(state, value);
    Show(state);
}
- Challenges - Burst doesn't support yield statement - foreach for IEnumerable will create an object iterator - We could use the Producer Consumer pattern to solve this scenario

interface IConsumer{
    void Consume(int value);
}
void Compute<T>(T consumer, int input) where T:unmanaged, IConsumer {
    int val1 = f1(input);
    consumer.Consume(val1);
    int val2 = f2(input, val1);
    consumer.Consume(val2);
}
struct ShowConsumer: IConsumer{
    float State;
    void Consume(int value){
        State = f3(State, value);
        Show(State);
    }
}
Compute(new ShowConsumer(), 1);

Emulate Function Pointer

interface IFunc{
    float Compute(float a, float b);
}
struct FuncJob<T>: IJobParallelFor
where T:unmanaged, IFunc    
{
    NativeArray<float> ArrayA;
    NativeArray<float> ArrayB;
    T Func;
    NativeArray<float> Results;
    void Execute(int i) {
        Results[i] = Func.Compute(ArrayA[i], ArrayB[i]);
    }
}

struct Add: IFunc {
    float Compute(float a, float b) => a + b;
}
struct Mul: IFunc {
    float Offset;
    float Compute(float a, float b) => a * b + Offset;
}

new FuncJob<Add> {
    ...
    Func = new Add(),
}.ScheduleParallel();

new FuncJob<Mul> {
    ...
    Func = new Mul{Offset = 1},
}.ScheduleParallel();

Unity has a way to use function pointer directly in Burst-code: https://docs.unity3d.com/Packages/com.unity.burst@1.8/manual/csharp-function-pointers.html

Using System and Job with generic

  • Generic is not an equivalence to polymorphism
  • Generic types need to be known at Compile time
  • With Polymorphism, types are determined at Runtime
  • To compensate for this
  • All possible types should be declared in the source code
  • All possible combination of types should be captured by queries
    // We want to perform Add or Mul on entities with this archetype
    // - InputA
    // - InputB
    // - Func (either Add or Mul component)
    // - Result
    
    new FuncJob<Add> {
        ...
    }.ScheduleParallel(SystemAPI.QueryBuilder()
        .WithAll<InputA, InputB, Result>()
        .WithAll<Add>
        .Build());
    
    new FuncJob<Mul> {
        ...
    }.ScheduleParallel(SystemAPI.QueryBuilder()
        .WithAll<InputA, InputB, Result>()
        .WithAll<Mul>
        .Build());
    
    We could apply Generic further to reduce code repetition
    struct FuncJobScheduler<T> where T:unmanaged, IFunc {
        EntityQuery Query;
        T Func;
        FuncJobScheduler(T func, EntityManager em)
            Func = func;
            Query = em.CreateEntityQuery(
                typeof(InputA), typeof(InputB), typeof(Result), 
                typeof(T));
        }
        void Schedule(inputs){
            new FuncJob<T>{
                inputs,
                Func = Func,
            }.ScheduleParallel(Query);
        }
    }
    
    struct MySystem: ISystemBase {
        FuncJobScheduler<Add> _add;
        FuncJobScheduler<Mul> _mul;
        void OnCreate(ref SystemState state){
            _add = new(new Add(), state.EntityManager);
            _mul = new(new Mul{Offset = 1}, state.EntityManager);
        }
        void OnUpdate(ref SystemState state){
            var inputs = ...;
            _add.Schedule(inputs);
            _mul.Schedule(inputs);
        }
    }
    

Retrieving value using ref

interface IConsumer{
    void Consume(int value);
    int Result {get;}
}

struct SumConsumer: IConsumer{
    int Result {get; set;}
    void Consume(int value){
        Result += value;
    }
}

struct MinConsumer: IConsumer{
    int Result {get; set;}
    void Consume(int value){
        Result = math.min(Result, value);
    }
}

void Aggregate<T>(ref T consumer, NativeArray<int> array) 
    where T: unmanaged, IConsumer {
    foreach (var value in array){
        consumer.Consume(value);
    }
}

var sum = new SumConsumer();
Aggregate(ref sum, array);
print(sum.Result);

var min = new MinConsumer();
Aggregate(ref min, array);
print(min.Result);

Summary

  • Using interface
  • To have the same method signature with different implementations
  • To have the same data inputs or outputs using properties
  • Using generic
  • To have the same Bursted code using said interfaces
  • Extension method
  • To have the same implementation for all structs
  • Similar to base class method (not virtual, not abstract)
  • Interface default implementation
  • Similar to base class virtual method
  • Reduce code repetition
  • Put the similar part into a method
  • Extract an interface for the different part
  • Create different structs to implement the difference