Hubs-Foundation / hubs

Duck-themed multi-user virtual spaces in WebVR. Built with A-Frame.
https://hubsfoundation.org
Mozilla Public License 2.0
2.13k stars 1.41k forks source link

Hubs Client add-on API #6099

Open takahirox opened 1 year ago

takahirox commented 1 year ago

An add-on in Hubs Client is small programs to add one or set of features in Hubs Client. An add-on consists of Components, Systems, Inflators, Prefabs, Network schemas.

Refer to https://hubs.mozilla.com/docs/dev-client-gameplay.html and https://hubs.mozilla.com/docs/dev-client-networking.html for the details.

Ideally Hubs core (built-in) and user custom features should be implemented as add-ons.

Unfortunately right now we don't have clear APIs to register add-ons and we require hand-edit some Hubs core files.

This issue is to propose new APIs to register add-ons.

Purpose of the new APIs

Allow custom client developers to add their own custom add-ons without editing the Hubs Client core files.

Process of add-on creation

If you want to add a new add-on, you need to do

If you want networked (synced with remote clients) components, you need additional two tasks

Then, the APIs we need are to register Systems, Inflators, Prefabs, and Network schemas.

APIs proposal

Add register APIs to App.

Register System

type System = (world: HubsWorld) => void;

export const SystemOrder = Object.freeze({
  Setup: 0,
  BeforeMatricesUpdate: 100,
  MatricesUpdate: 200,
  BeforeRender: 300,
  Render: 400,
  AfterRender: 500,
  PostProcessing: 600,
  TearDown: 700
});

App.registerSystem = (system: System, order: number = SystemOrder.BeforeMatricesUpdate) => void

Example:

app.registerSystem(fooSystem, SystemOrder.BeforeMatricesUpdate);
app.registerSystem(barSystem, SystemOrder.BeforeRender - 1);

One of the good things of systems is the order of systems execution is controllable and managable. For example, we can ensure that a system that requires updated world matrices can run after the world matrices are updated (by the Hubs Client Core). The second unsigned integer argument is to specify the order, the systems with small numbers run earlier. (Systems with the same order number may be out of order, or first-registered first-run in them.)

Potential advanced APIs

We may add am API to allow to know the order number of a registered system for, for example, to ensure a system runs before or after another certain system, like

App.getSystemOrder = (system: System) => number;

Example: Ensure fooSystem runs before the built-in textSystem

app.registerSystem(fooSystem, app.getSystemOrder(textSystem) - 1);

And we may add an API to deregister system for, for example, replacing a built-in system with a custom system.

App.deregisterSystem = (system: System): void;

Example: Replace a built-in foo system with a custom bar system

app.deregisterSystem(fooSystem);
app.registerSystem(barSystem);

Register Inflator

type Inflator = (world: HubsWorld, eig: number, params?: object) => void;

App.registerInflator = (keys: {jsx?: string, gltf?: string}, inflator: Inflator) => void;

Example:

app.registerInflator({jsx: 'foo', gltf: 'foo'}, fooInflator);

jsx and gltf in the first argument object are keys for JSX and glTF inflators map respectively.

Register Prefab

type Prefab = (params?: object) => EntityDef;

App.registerPrefab = (key: string, prefab: Prefab);

Example:

app.registerPrefab('foo', FooPrefab);

Register Network schema

App.registerNetworkSchema = (key: string, schema: NetworkSchema);

Example:

app.registerNetworkSchema('foo', NetworkedFooSchema);

Custom add-on Example

// Define Component
// src/components/foo.ts
import { defineComponent, Types } from "bitecs";

export const Foo = defineComponent({
  val: Types.f32
});

// Write Inflator
// src/inflators/foo.ts

export type FooParams = {
  val?: number
};

const DEFAULTS: Required<FooParams> = {
  val: 0
};

export const fooInflator = (world: HubsWorld, eid: number, params?: FooParams): void => {
  params = Object.assign({}, params, DEFAULTS);
  addComponent(world, Foo, eid);
  Foo.val[eid] = params.val;
};

// Write System
// src/bit-systems/foo.ts
import { defineQuery } from "bitecs";
import { HubsWorld } from "../app";
import { Foo } from "../components/foo";

const fooQuery = defineQuery([Foo]);

export const fooSystem = (world: Hubsworld): void => {
  fooQuery(world).forEach(eid => {
    const val = Foo.val[eid];
    ...
  });
};

// Write Prefab
// src/prefabs/foo.ts
/** @jsx createElementEntity */
import { createElementEntity, EntityDef } from "../utils/jsx-entity";

export type FooPrefabParams = {
  val: number;
};

export const FooPrefab = (FooPrefabParams: params): EntityDef => {
  return (
    <entity
      networked
      foo={{val: params.val}}
    />
  );
};

// Write Network schema
// src/network-schemas/foo.ts
import { NetworkSchema } from "../utils/network-schemas";
import { defineNetworkSchema } from "../utils/define-network-schema";
import { Foo } from "../components/foo";

const runtimeSerde = defineNetworkSchema(Foo);
export const NetworkedFooSchema: NetworkSchema = {
  componentName: "foo",
  serialize: runtimeSerde.serialize,
  deserialize: runtimeSerde.deserialize
};

// register add-on
// src/hub.ts
...

import { SystemOrder } from "./src/system-order";
import { fooSystem } from "./src/bit-systems/foo";
import { fooInflator } from "./src/inflators/foo";
import { FooPrefab } from "./src/prefabs/foo";
import { NetworkedFooSchema } from "./src/network-schemas/foo";

...

// Built-in add-ons are set up in the constructor
const app = new App();

// TODO: Where should these custom add-on registration code be written?
// Still will we require hand-edit some core files like src/hubs.ts?

// register systems
app.registerSystem(fooSystem, SystemOrder.BeforeMatricesUpdate);

// register inflators
app.registerInflator({jsx: "foo", gltf: "foo"}, fooInflator);

// register prefabs
app.registerPrefab("foo", FooPrefab);

// register network schemas
app.registerNetworkSchema("foo", NetworkedFooSchema);

// kick-off
app.start();

...

Expected Hubs Client Core (built-in add-ons) setup implementation

type SystemEntry = {
  system: System;
  orderPriority: number;
};

export class App {
  private systems: SystemEntry[];
  private renderer: WebGLRenderer;
  private world: HubsWorld;
  // These maps are used by other functions (eg: renderAsEntity())
  // TODO: Think how to expose to them
  private jsxInflators: Map<string, Inflator>;
  private gltfInflators: Map<string, Inflator>;
  private networkSchemas: Map<string, NetworkSchema>;
  private prefabs: Map<string, Prefab>;

  ...

  constructor() {
    ...
    this.world = new HubsWorld();
    ...
    this.init();
    ...
  }

  private init() {
    // register the built-in systems
    this.registerSystem(..., ...);
    this.registerSystem(..., ...);
    this.registerSystem(..., ...);
    this.registerSystem(..., ...);

    // register the built-in inflators
    this.registerInflator(..., ...);
    this.registerInflator(..., ...);
    this.registerInflator(..., ...);
    this.registerInflator(..., ...);

    // register the built-in prefabs
    this.registerPrefab(..., ...);
    this.registerPrefab(..., ...);
    this.registerPrefab(..., ...);
    this.registerPrefab(..., ...);

    // register the built-in network schemas
    this.registerNetworkSchema(..., ...);
    this.registerNetworkSchema(..., ...);
    this.registerNetworkSchema(..., ...);
    this.registerNetworkSchema(..., ...);
  }

  registerSystem(system: System, order: number = SystemOrder.BeforeRender): void {
    this.systems.push({system, order); 
    this.systems.sort((a: SystemEntry, b: SystemEntry) => {
      return a.order - b.order;
    });
  }

  registerInflator(keys: { jsx?: string, gltf?: string }, inflator: Inflator ): void {
    if (keys.jsx !== undefined) {
      this.jsxInflators(keys.jsx, inflator);
    }
    if (keys.gltf !== undefined) {
      this.gltfInflators(keys.gltf, inflator);
    }
  }

  registerPrefab(key: string, prefab: Prefab): void {
    this.prefabs.set(key, prefab);
  }

  registerNetworkSchema(key: string, schema: NetworkSchema): void {
    this.schemas.set(key, schema);
  }

  private mainTick(): void {
    updateElapsedTime();
    for (const entry of this.systems) {
      entry.system(this.world);
    }
  }

  start(): void {
    this.renderer.setAnimationLoop(() => {
      this.mainTick();
    });
  }

  ...
}
keianhzo commented 1 year ago

I really like this proposal, is simple and really clear. Great job.

jywarren commented 1 year ago

This is so exciting! Might this be a place for custom shaders like depth of field, or #5575, perhaps!