noahlange/gecs

Readable/writable components in queries for scheduling

Opened this issue · 0 comments

Is this a solution in search of a problem? Probably.

The Problem

Can't have parallelized systems reading/writing to the same components. This is a well understood issue, and some implementations exist to address it.

In specs, this is covered by the dispatcher and "system data" constructs.

Proposal

Introduce new SystemData class that:

  1. exports readonly/read-write versions of components to be passed to the query builder
  2. makes the list of readable/writable components visible externally so a scheduler can use it to properly sort the systems so they can be run safely in parallel.
class A extends Component { static readonly type = 'a'; } 
class B extends Component { static readonly type = 'b'; } 

class CannotWriteToA extends System {
  // has two type parameters — read-only (`R`) and read-write (`W`)
  public data = new SystemData([A], [B]);
  // normal system definition
  public tick(): void {
    //  component constructors accessible by their `type` property
    const { a, b } = this.data.$;
    // pass directly to query builder
    for (const entity of this.ctx.query.all.components(a, b)) {
      entity.$.a.value = ''; // no - this is read-only
      entity.$.b.value = 2;
    }
  }
}

const myOtherCannotWriteToA = withData(
  new SystemData([A],[B]),
  (ctx, dt, ts, data) =>  {
    const { a, b } = data.$;
    for (const entity of ctx.query.all.components(a, b)) {
      // whatever
    }
  }
);
function ObviouslyEasierButUnschedulable(ctx)  {
  for (const entity of ctx.query.all.components(A, B)) {
    entity.$.a.value = ''; // sure, whatever
    entity.$.b.value = 2; // can't stop ya
  }
}

And then the scheduler can go through all the systems registered with data properties, find which components things will be accessing and sort stuff accordingly.

Implementation (SystemData)

Will need to do additional research to figure out how to implement the scheduler.

export interface ReadonlyComponentClass<T extends WithStaticType> {
  readonly type: T['type'];
  new (data?: $AnyEvil): Readonly<InstanceType<T>>;
}

export type Writable<A> = Merge<
  A extends (infer B)[] ? (B extends WithStaticType ? { [key in B['type']]: B } : never) : never
>;

export type Readable<A> = Merge<
  A extends (infer B)[] ? (B extends WithStaticType ? { [key in B['type']]: ReadonlyComponentClass<B> } : never) : never
>;

export class SystemData<R extends WithStaticType[], W extends WithStaticType[]> {
  public readonly $: Merge<Readable<R> & Writable<W>>;
  constructor(public readonly readable: R, public readonly writable: W) {
    this.$ = Object.assign(
      readable.reduce((a, b) => ({ ...a, [b.type]: b }), {}),
      writable.reduce((a, b) => ({ ...a, [b.type]: b }), {})
    );
  }
}

Caveats

The readable/writable stuff is necessarily 100% imaginary compile time schnonsense. There's nothing stopping you from doing something like this:

class CannotWriteToA extends System {
  public data = new SystemData([A], [B]);
  public tick(): void {
    for (const entity of this.ctx.query.all.components(A, this.data.$.b)) {
      entity.$.a.value = ''; // lol, write away
    }
  }
}

I could update the query builder to flags components as in-use / not in-use while the scheduler is running, then throw warnings if they're being passed without being pulled from the data property.

This proposal solves the ergonomics issues of ensuring readable/writable components are being treated sanely and making that information available internally.

This does not solve the issue of "how the hell do you parallelize synchronous code in JS?" and the how-what-where of actually accessing shared data.