Skip to main content

Writing custom plugins

All Viewer Plugins must implement a IViewerPlugin to be attached to the viewer:

interface IViewerPlugin extends IEventDispatcher<string>, IUiConfigContainer, Partial<IJSONSerializable> {
// all classes must have this static property with a unique identifier value for this plugin
readonly PluginType: string;

// these plugins will be added automatically(with default settings), if they are not added yet.
dependencies?: Class<IViewerPlugin<any>>[];

// the viewer will render the next frame if this is set to true
dirty?: boolean;

// Called when this plug-in is added to the viewer
onAdded(viewer: TViewer): Promise<void>;

// Called when this plug-in is removed from the viewer
onRemove(viewer: TViewer): Promise<void>;

// Called when the viewer is disposed
onDispose(viewer: TViewer): Promise<void>;
}

To make it easier and remove boilerplate, the abstract class AViewerPlugin, GenericFilterPlugin, MultiFilterPlugin can be used.

Sample plugin - simple

Here is a sample plugin that extends from AViewerPlugin and adds some basic functionalities to the viewer. Check the inline comments for various explanation.

import {
AViewerPlugin,
IEvent,
reflHelpers,
serialize,
ViewerApp,
uiFolder,
uiToggle,
uiSlider,
uiInput,
uiButton,
onChange,
} from "webgi";

@uiFolder("Sample Plugin") // This creates a folder in the Ui. (Supported by TweakpaneUiPlugin)
export class SamplePlugin extends AViewerPlugin<"sample-1" | "sample-2"> {
// These are the list of events that this plugin can dispatch.
static readonly PluginType = "SamplePlugin"; // This is required for serialization and handling plugins. Also used in viewer.getPluginByType()

@uiToggle() // This creates a checkbox in the Ui. (Supported by TweakpaneUiPlugin)
@serialize() // Adds this property to the list of serializable. This is also used when serializing to glb in AssetExporter.
enabled = true;

// A plugin can have custom properties.

@uiSlider("Some Number", [0, 100], 1) // Adds a slider to the Ui, with custom bounds and step size (Supported by TweakpaneUiPlugin)
@serialize("someNumber")
@onChange(SamplePlugin.prototype._updateParams) // this function will be called whenevr this value changes.
val1 = 0;

// A plugin can have custom properties.
@uiInput("Some Text") // Adds a slider to the Ui, with custom bounds and step size (Supported by TweakpaneUiPlugin)
@onChange(SamplePlugin.prototype._updateParams) // this function will be called whenevr this value changes.
@serialize()
val2 = "Hello";

@uiButton("Print Counters") // Adds a button to the Ui. (Supported by TweakpaneUiPlugin)
public printValues = () => {
console.log(this.val1, this.val2);
this.dispatchEvent({ type: "sample-1", detail: { sample: this.val1 } }); // This will dispatch an event.
}

constructor() {
super();
this._updateParams = this._updateParams.bind(this);
}

private _updateParams() {
console.log("Parameters updated.");
this.dispatchEvent({ type: "sample-2" }); // This will dispatch an event.
}

async onAdded(v: ViewerApp): Promise<void> {
await super.onAdded(v);

// Do some initialization here.
this.val1 = 0;
this.val2 = "Hello";

v.addEventListener("preRender", this._preRender);
v.addEventListener("postRender", this._postRender);
v.addEventListener("preFrame", this._preFrame);
v.addEventListener("postFrame", this._postFrame);

this._viewer!.scene.addEventListener("addSceneObject", this._objectAdded); // this._viewer can also be used while this plugin is attached.
}

async onRemove(v: ViewerApp): Promise<void> {
// remove dispose objects

v.removeEventListener("preRender", this._preRender);
v.removeEventListener("postRender", this._postRender);
v.removeEventListener("preFrame", this._preFrame);
v.removeEventListener("postFrame", this._postFrame);

this._viewer!.scene.removeEventListener("addSceneObject", this._objectAdded); // this._viewer can also be used while this plugin is attached.

return super.onRemove(v);
}

// async onDispose(viewer: ViewerApp): Promise<void> {
// // this is optional
// return super.onDispose(viewer);
// }

private _objectAdded = (ev: IEvent<any>) => {
console.log("A new object, texture or material is added to the scene.", ev.object);
};
private _preFrame = (ev: IEvent<any>) => {
// THis function will be called before each frame. This is called even if the viewer is not dirty, so it's a good place to do viewer.setDirty()
};
private _preRender = (ev: IEvent<any>) => {
// This is called before each frame is rendered, only when the viewer is dirty.
};
// postFrame and postRender work the same way as preFrame and preRender.
}

Notes:

  • All plugins that are present in the dependencies array when the plugin is added to the viewer, are created and attached to the viewer in super.onAdded
  • Custom events can be dispatched with this.dispatchEvents, and subscribed to with addEventListener. The event type must be described in the class signature for typescript autocomplete to work.
  • Event listeners can be added and removed in onAdded and onRemove functions for the viewer and other plugins, check the sample above for an example.
  • To the viewer render the next frame, viewer.setDirty() can be called, or set this.dirty = true in preFrame and reset in postFrame to stop the rendering. (Note that rendering may continue if some other plugin sets the viewer dirty)
  • All Plugins which inherit from AViewerPlugin support serialisation.
    • plugin.toJSON() and plugin.fromJSON() can be used to get custom properties.
    • @serialize('label') decorator can be used to mark any public/private variable as serialisable. label (optional) corresponds to the key in JSON.
    • @serialize supports instances of ITexture, IMaterial, all primitive types, simple JS objects, three.js math classes(Vector2, Vector3, Matrix3...), and some more.
    • Check the serialisation docs(coming soon) for more
  • uiDecorators can be used to mark properties and functions that will be shown in the Ui. The Ui shows up automatically when TweakpaneUiPlugin is added to the viewer.
  • Delete/Dispose off any stuff created in the constructor or not related to the viewer in onDispose.

Events

The AViewerPlugin class provides a mechanism to define and dispatch events using the dispatchEvent method inherited from EventDispatcher. To define an event, you can specify a type for the event using a string literal type like this:

class MyPlugin extends AViewerPlugin<'myEvent'> {
// ...
}

Here, MyPlugin defines an event with the type myEvent. To dispatch an event pass an object with its type property to the event type you want to dispatch. You can also include additional data by setting the detail property to an object containing any data you want to send with the event.

this.dispatchEvent({
type: "myEvent",
detail: {
someData: "hello world",
},
});

To listen for events in your plugin, you can use the addEventListener method inherited from EventDispatcher. The method takes two arguments: the type of the event you want to listen for and a callback function that will be called when the event is dispatched

class MyPlugin extends AViewerPlugin<'myEvent'> {
constructor() {
super()
this.addEventListener('myEvent', this.handleMyEvent)
}

private handleMyEvent = (event: any) => {
console.log("Received myEvent:", event.detail.someData)
};
}

Here, MyPlugin registers a listener for the myEvent event in the constructor by calling addEventListener and passing in the event type and a callback function (handleMyEvent) that will be called when the event is dispatched. The handleMyEvent function takes an IEvent object as its argument, which includes a detail property containing the data sent with the event.

By defining and dispatching events in your plugin, you can allow other parts of your application to listen for and react to changes in your plugin's state.

Viewer event handlers

The preFrame, preRender, postFrame, and postRender methods are event handlers that are called by the viewer at specific stages of rendering. They are provided as methods of the AViewerPlugin class and can be overridden in your plugin class.

  • The preFrame method is called once per frame, before the frame is rendered. This means that it will be called even if the viewer is not dirty, i.e. if no changes have been made to the scene since the last frame was rendered. This makes it a good place to perform any tasks that need to be done every frame, regardless of whether the scene has changed or not.

  • The preRender method is called once per frame, but only if the viewer is dirty, i.e. if changes have been made to the scene since the last frame was rendered. This method is called just before the rendering of the frame starts, so it is a good place to perform any tasks that need to be done before the actual rendering starts.

  • The postFrame and postRender methods work in the same way as preFrame and preRender, respectively, but are called after the frame or render is complete. They are called once per frame, regardless of whether the viewer is dirty or not. They can be used to perform any tasks that need to be done after the rendering is complete.

To use these methods in your plugin, simply override them in your plugin class, like this:

export class MyPlugin extends AViewerPlugin {
// ... plugin properties and methods ...

preFrame(ev: IEvent<any>) {
// do something before each frame is rendered
}

preRender(ev: IEvent<any>) {
// do something before each frame is rendered, but only if the viewer is dirty
}

postFrame(ev: IEvent<any>) {
// do something after each frame is rendered
}

postRender(ev: IEvent<any>) {
// do something after each frame is rendered, but only if the viewer is dirty
}
}

@decorators

Enable decorators

Decorators were introduced in ES6 and were added to TypeScript. However, in order to use experimental features such as decorators in TypeScript, you need to enable them in your tsconfig.json file.

To enable decorators in TypeScript, you need to add "experimentalDecorators": true to the compilerOptions section of your tsconfig.json file. Here's an example:

{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}
  • @serialize(): This decorator is used to add the decorated property to the list of serializable properties. It is also used when serializing to glb in AssetExporter and in the plugin's toJSON and fromJSON methods.

  • @uiFolder('Sample Plugin'): This decorator is used to create a folder in the UI for the plugin's properties. It is supported by the TweakpaneUiPlugin.

  • @uiToggle(): This decorator is used to create a checkbox in the UI for the plugin's boolean property. It is also supported by the TweakpaneUiPlugin.

  • @uiSlider('Some Number', [0, 100], 1): This decorator is used to create a slider in the UI for the plugin's numerical property. It takes in a label, a range, and a step size as arguments. It is supported by the TweakpaneUiPlugin.

  • @uiInput('Some Text'): This decorator is used to create an input field in the UI for the plugin's string property. It takes in a label as an argument. It is also supported by the TweakpaneUiPlugin.

  • @uiButton('Print Counters'): This decorator is used to create a button in the UI for the plugin's method. It takes in a label as an argument. It is also supported by the TweakpaneUiPlugin.

@uiButton('Click me!')
private onClickButton() {
console.log('Button clicked!');
}
  • @onChange is a decorator that allows you to register a function to be called when the decorated property is changed. It takes a function as a parameter and sets it as a listener for the change event of the decorated property.
@onChange(MyPlugin.prototype.onMyPropertyChange)
myProperty = 0;

onMyPropertyChange() {
console.log('myProperty changed to', this.myProperty);
}
note

The @onChange decorator is often used in conjunction with other decorators such as @uiSlider, @uiToggle, and @uiInput to update the UI in response to changes to the underlying property.

Plugin with a ShaderPass

Below is a sample plugin that shows implementation for a simple post processing pass responsible for Tonemapping. This inherits the abstract class GenericFilterPlugin which adds support for creating a single shader pass and adding it to the viewer pipeline. Incase multiple passes are required to be added in the sample plugin, see MultiFilterPlugin. Note that we already provide a TonemapPlugin that should be used, this is just a sample to show the GenericFilterPlugin API

@uiFolder("Sample Plugin 2") // This creates a folder in the Ui. (Supported by TweakpaneUiPlugin)
export class SampleTonemapPlugin
extends GenericFilterPlugin<TonemapPass, "tonemap", "", ViewerApp>
implements IViewerPlugin
{
static readonly PluginType = "SampleTonemap";
passId: "tonemap" = "tonemap"; // ID for this pass, to represent in the pipeline.

dependencies = [GBufferPlugin];

protected _beforeFilters = ["screen"]; // this pass is added before the screen pass..
protected _afterFilters = ["render"]; // this pass is added after the render pass.
protected _requiredFilters = ["render"]; // render pass(RenderPass) is required in the pipeline to render this pass.

constructor(readonly renderToScreen = true) {
super();
this._setDirty = this._setDirty.bind(this);
}

async onAdded(viewer: ViewerApp): Promise<void> {
if (this.renderToScreen)
safeSetProperty(
viewer.renderer.passes.find((value) => value.passId === "screen"),
"enabled",
false,
true,
true
); // disable the screen pass from the pipeline so that this can be used instead
return super.onAdded(viewer);
}

passCtor(v: ViewerApp): TonemapPass {
return new TonemapPass();
}

protected _update(v: ViewerApp): boolean {
if (!super._update(v)) return false;
this._pass!.passObject.updateShaderProperties(this._viewer?.getPlugin(GBufferPlugin)); // Add uniforms from GBufferPlugin to this shader, like the depthNormal texture.
return true;
}

@serialize()
@uiSlider("Exposure", [0, 10], 0.1)
get exposure(): number {
return this.pass?.passObject.exposure ?? 1;
}

set exposure(value: number) {
const t = this.pass?.passObject;
if (t) {
t.exposure = value;
this._setDirty();
}
}

@serialize()
@uiDropdown(
"Mode",
(
[
["Linear", LinearToneMapping],
["Reinhard", ReinhardToneMapping],
["Cineon", CineonToneMapping],
["ACESFilmic", ACESFilmicToneMapping],
["Uncharted2", Uncharted2Tonemapping],
] as [string, ToneMapping][]
).map((value) => ({
label: value[0],
value: value[1],
}))
)
get toneMapping(): ToneMapping {
return this.pass?.passObject.toneMapping ?? LinearToneMapping;
}

set toneMapping(value: ToneMapping) {
const t = this.pass?.passObject;
if (t) {
t.toneMapping = value;
this._setDirty();
}
}

private _setDirty() {
if (this.pass) this.pass.dirty = true;
}
}

In-Depth tutorials and guides for Post/pre-processing Passes and Filters pipeline is coming soon