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
static 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)
@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.
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() {
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.



  • 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.

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) {
this._setDirty = this._setDirty.bind(this)

async onAdded(viewer: ViewerApp): Promise<void> {
if (this.renderToScreen)
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

@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

@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

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