-
Notifications
You must be signed in to change notification settings - Fork 6
Custom System Parameters
Thyseus' system parameters are designed so that it is simple to write your own. Parameters are responsible for declaring how they interact with other parameters and what data they need from the world - as a result, just about anything Thyseus can do internally, you can do, too! System parameters start with system parameter descriptors.
As far as Thyseus is concerned, a system parameter descriptor - the items in the
yourSystem.parameters array - is simply an object that contains the following
four methods:
isLocalToThread(): booleanintersectsWith(other: object): booleanonAddSystem(builder: WorldBuilder): voidintoArgument(world: World): any
We'll dive into each of these methods and what they do before getting into how to write our own descriptors.
This determines whether this parameter may only be accessed from the main
thread. Descriptors should return true if the parameter is only available from
the main thread, and false if the parameter is available on any thread.
Usually descriptors will either always return true or always return false, but
sometimes this is dependent on the provided data!
This method is called with other parameter descriptors, and is used to determine
if other parameters intersect with or are disjoint with this parameter. If
parameters intersect (true is returned), the systems they belong to will be
marked as intersecting, and cannot run in parallel. If they are disjoint
(false is returned), the systems may run in parallel.
intersectsWith does not need to be symmetric; both descriptors are called
with each other, and if either returns true they are marked as intersecting.
This method is called when a system is added by a WorldBuilder instance. It is
called with the WorldBuilder that added it, and allows the descriptor to do
whatever it may need with the builder - anything from adding plugins to
registering types. For some descriptors (e.g., WorldDescriptor), this function
is a no-op.
This method is passed the world building the system, and whatever is returned from this method will be passed to systems when they are called. This method may return anything.
Note
Promises returned by intoArgument(world: World): any will be awaited
before systems are called!
Let's dive into the internal implementation of ResourceDescriptor to get a
feel for how these methods might work.
First, we'll need a class that creates our descriptor objects. For parameter
descriptors to work with the transformer, parameter descriptors must be
implemented as classes, as the transformer will try to new them.
To get started with resources, we really just need to know the type (or class) of the resource:
class ResourceDescriptor {
resource: Class;
constructor(resource: Class) {
this.resource = resource;
}
}That's a nice start! Let's try adding some descriptor methods.
First, we need to know what the builder should do when it comes across this descriptor. Resources in Thyseus are a first-class concept, so all we'll need to do is register that we have a (potentially) new resource!
class ResourceDescriptor {
onAddSystem(builder: WorldBuilder) {
builder.registerResource(this.resource);
}
}The builder handles deduping types, so that's it for our responsibilities. Other descriptors may need to add systems to work correctly, or to register components, or more.
Resources in Thyseus are allowed to be either mainthread-only or shareable
across threads. Thyseus allows shared data with Structs; if a class is
correctly implemented as a Struct, then we should be able to share it across
threads. If not, then we'll only be able to access the data from the main
thread.
So how do we know a class is a struct? Well, there's an internal utility for this:
function isStruct(val: unknown): val is Struct {
return (
typeof val === 'function' &&
typeof val.size === 'number' &&
typeof val.alignment === 'number'
);
}Structs are very simple, from Thyseus' perspective - classes (functions) with
static size and alignment properties.
Okay! Now to use this in our descriptor class:
class ResourceDescriptor {
// ...
isLocalToThread() {
// Remember - this method asks if the parameter _IS_ local to the thread!
// If our resource IS a struct, then it is NOT local to the thread
// If it is NOT a struct, then it IS local to the thread.
return !isStruct(this.resource);
}
}Now we've made it to a trickier concept - parameter intersection. We need to know if parameters intersect to determine whether systems may run in parallel. In general, it is safe for two (or more) systems to read the same data at the same time. However, if a system writes data, it is no longer safe for a different system to read or write that data.
But wait - we don't even know if we're reading or writing data! We'll need to revise our constructor a little bit to make sure we know what we're doing with the data we access.
There's a number of approaches we could take here. One would be to accept a
second argument, perhaps a boolean indicating whether our data should be
readonly or not. Another would be to split our descriptor into two, perhaps a
ResourceDescriptor and MutableResourceDescriptor. Either of these would be a
perfectly valid approach - however, Thyseus already includes a Mut<T> utility
for queries to denote whether they access a component mutably or not. For the
sake of a consistent API, we'll mirror that for resources. Our new constructor
then might look like this:
class ResourceDescriptor {
resource: Class;
canWrite: boolean;
constructor(resource: Class | Mut<any>) {
// Mut is a class, so we can use instanceof
const isMut = resource instanceof Mut;
// Mut instances just have a value property, containing the wrapped class.
this.resource = isMut ? resource.value : resource;
this.canWrite = isMut;
}
}Great! Now we know if we're reading or writing data.
For intersectsWith, we really don't know anything about the values we'll
receive. It could be a first-party parameter descriptor, or a different third
party descriptor. Internally, we generally choose to treat these as unknown,
but since they'll always be descriptors, you could type them as object (or
even a slightly more narrow Descriptor type).
So, first we need to know if the other descriptor is a resource descriptor. If it isn't, there's no risk of intersection - or strictly speaking, we will place the responsibility of determining this intersection on the other descriptor. Next, we only run the risk of intersection if we're accessing the same data. And last, we only intersect if either this descriptor or the other is accessing data mutably. In code:
class ResourceDescriptor {
intersectsWith(other: unknown) {
if (!(other instanceof ResourceDescriptor)) {
// No intersection if it's not a resource.
return false;
}
if (this.resource !== other.resource) {
// Also no intersection if we're not accessing the same resource.
return false;
}
if (!this.canWrite && !other.canWrite) {
// No intersection if neither needs to write data!
return false;
}
// One of us writes data the other needs to read/write.
// That's an intersection!
return true;
}
}Remember that intersectsWith does not need to be symmetric. Both descriptors
will call this method with the other, and if either returns true, then the
parameters intersect. For example, the WorldDescriptor implements this method
like so:
class WorldDescriptor {
intersectsWith(other: unknown) {
return true;
}
}Because we trust that WorldDescriptor correctly implements its own
intersectsWith method (it always intersects), ResourceDescriptor doesn't
need to concern itself with how it interacts with WorldDescriptor or any other
descriptor.
The last step is turning your parameter descriptor into an argument. For this, we receive the world as an argument, and will usually return some piece of its internal state. In the case of resources, it's pretty easy to get that data!
class ResourceDescriptor {
intoArgument(world: World) {
return world.getResource(this.resource);
}
}And perfect - you now have a fully functioning custom system parameter! Here's the full code:
class ResourceDescriptor<T extends Class | Mut<Class>> {
resource: Class;
canWrite: boolean;
constructor(resource: T) {
const isMut = resource instanceof Mut;
this.resource = isMut ? resource.value : resource;
this.canWrite = isMut;
}
onAddSystem(builder: WorldBuilder) {
builder.registerResource(this.resource);
}
isLocalToThread() {
return !isStruct(this.resource);
}
intersectsWith(other: unknown) {
if (!(other instanceof ResourceDescriptor)) {
return false;
}
if (this.resource !== other.resource) {
return false;
}
if (!this.canWrite && !other.canWrite) {
return false;
}
return true;
}
intoArgument(
world: World,
): T extends Mut<infer X>
? X
: Readonly<InstanceType<T extends Class ? T : never>> {
return world.resources.get(this.resource);
}
}Next, we'll go into how to let the transformer recognize your parameter.
The transformer plugin accepts config that allows you to register additional system parameter types for it to recognize. Using the config, we can tell the transformer how to handle our custom system parameters.
type ThyseusPluginConfig = {
systemParameters: SystemParameterMap;
};
type SystemParameterMap = Record<string, SystemParameterDescriptor>;
type SystemParameterDescriptor = { descriptorName: string; importPath: string };The key in the SystemParameterMap refers to the name of the type we'd like
the transformer to recognize. The descriptorName is the named import it
will be imported as. importPath is the location to import it from. For system
parameters to work correctly, they must be able to be imported from a
non-relative path; how you handle this is dependent on your bundler and how
you'd like to handle import aliasing.
For the case of Res, this looks something like:
import { defineConfig } from 'vite';
import { thyseusPlugin } from '@thyseus/rollup-plugin-thyseus';
export default defineConfig({
plugins: [
thyseusPlugin({
systemParameters: {
Res: {
descriptorName: 'ResourceDescriptor',
importPath: 'thyseus/descriptors',
},
},
}),
],
});We tell the plugin that we'd like it to recognize types named Res, and that it
will need to import { ResourceDescriptor } from 'thyseus/descriptors'; in
order to use that system parameter. When the transformer sees a system with that
type, it'll generate the necessary code to make your systems work.
// This input...
function mySystem(res: Res) {}
// ...will be transformed into
import { ResourceDescriptor } from 'thyseus/descriptors';
function mySystem(res: Res) {}
mySystem.parameters = [new ResourceDescriptor()];You may have noticed that Res as provided by Thyseus is generic - in fact, it
has to be in order to work correctly! When system parameters accept generics,
those are passed as arguments to the parameter descriptor function.
// === Untransformed code
class MyResource {}
function mySystem(res: Res<MyResource>) {}
// === Transformed!
import { ResourceDescriptor } from 'thyseus/descriptors';
class MyResource {}
function mySystem(res: Res<MyResource>) {}
mySystem.parameters = [ResourceDescriptor(MyResource)];It is up to you to validate that the types provided to your system parameter and the arguments provided to your descriptor are correct, both at the type level and at runtime.
Getting Started
Introduction
Installation & Setup
Core Concepts
Entities
Components
Systems
Worlds
System Parameters
Queries
Resources
Events
World
Threads
Advanced Patterns
Custom System Parameters