Boilerplate for Babylonjs-mini-apps with web-components powered by Lit

The project is a boilerplate/template for babylon web-modules and a collection of snippets in form of web-components.
The web-modules mean to be some consumer-level functional blocks (like babylon-viewer ± features), integrated to already existing babylon-unaware web pages and apps (like, product gallery or order picking), implemented with arbitrary popupar or unpopular frameworks or being some legacy jquery mess.

The idea is to construct an app from components like a lego and integrate it with page using standard HMTL/DOM API.

Page setup

So, a page supposed to look like this:

<!-- the app is a placeholder representing babylon-unaware hosting app 
     in the demo it is an empty wrapper and does nothing 
     except debugging and providing global reference to the main component -->
<the-app>
    <!-- the main component contains shadow dom with canvas, loading screen, etc
         it provides engine, scene, and stuff -->
    <b3d-main rightHanded>
        <!-- other components contrbute something to the scene 
             they are proxy-elements to control underlying scene entities via DOM API -->

        <b3d-camera-basic id="cam0" position="[5, 1.75, 5]"></b3d-camera-basic>
        <b3d-camera-orbit id="cam1" orbit="[45, 60, 10]"></b3d-camera-orbit>
        <b3d-camera-look id="cam2" selected autoZoom defaults="[45, 45, 10]"></b3d-camera-look>

        <b3d-sky-env src="assets/studio.env">
            <b3d-sky-box blurring="0.75" intensity="0.25"></b3d-sky-box>
        </b3d-sky-env>

        <b3d-stuff id="ball" shape="sphere"></b3d-stuff>
        <b3d-stuff id="box" class="foo" shape="cube" size="2" position="[-3, 1, -5]" texture="assets/checker.png"></b3d-stuff>
        <b3d-stuff id="cone" class="foo bar" shape="cone" positionRnd="5" texture="assets/checker.png"></b3d-stuff>

        <b3d-ground-flat size="auto" src="assets/ground.png" color="#102040"></b3d-ground-flat>
        
        <!-- the utility layer provides additional scene for utility stuff -->
        <b3d-utility-layer events>
            <b3d-ground-grid size="auto" src="assets/ground.png" color="#80FFFF"></b3d-ground-grid>
            <b3d-axesview></b3d-axesview>
        </b3d-utility-layer>

        <b3d-highlighter color="#F0F000"></b3d-highlighter>
        
        <div slot="overlay" style="position: absolute; left: 0; top: 0">
          <!-- some html on top of the scene -->
        </div>
    </b3d-main>

    <!-- a sample integration component outside babylon context, 
         it's linked to the `b3d-main` via `the-app` -->
    <our-stuff-tools>
        <!-- contains some plain kinda third-party html -->
    </our-stuff-tools>
</the-app>

Features

General features of the components:

  • they can be created and configured manually as static html
  • or by a html template system on the site backend
  • they can be created/removed at runtime using standard DOM API
  • also, they can be included in the shadow dom of a customized b3d-main element
  • HTML attributes provide initialization parameters
  • (some) DOM properties affect internal state of underlying scene entities
  • (some) DOM properties reflect actual state of the entities

In particular, for standard/base components with underlying scene nodes:

  • id and name of elements translated to corresponding babylon id and name
  • tokens from class translated to babylon tags, just like the developers told us
  • disabled attribute and enabled property are synchronized with internal isEnabled
  • hidden attribute and visible property are synchornized with internal isVisible
  • selected attribute/property of a camera is synchornized with scene active camera

Repository

All the code is in the github repository. Note, that its head is stub to not interfere with main of an actual project.

And here is live demo page where you can manipulate the scene using built-in browser element inspector and devtools.

Brief Coding Guide

Here is how a typical component looks like:

@customElement("b3d-stuff")
class MyStuffElem extends NodeElemBase<Mesh> {
    /** a init/only property */
    @property()
    shape?: string;

    /** a write/only property */
    @property({ useDefault: true, converter: coordsConverter })
    position: Coords = { x: 0, y: 0.5, z: 0 };

    /** create and initialize actual babylon entity */
    override init() {
        assertNonNull(this.shape, `${this.tagName}.shape is required`);
        this._node = this._createMesh(this.shape);
        this._node.material = this._getMatrial();
        this._node.position = coordsConverter.toVector3(this.position)!
        super.init();
    }

    _createMesh(shape: string): Mesh { /* ... */ }
    _getMatrial() { /* ... */ }

    /** affect the entity when attribute or property set */
    override update(changes: PropertyValues) {
        if (changes.has("position")) this._syncPosition(this.position);
        super.update(changes);
    }

    /** perform conversion */
    _syncPosition(position: Coords) {
        assertNonNull(this._node);
        assertNonNull(position);
        this._node.position = coordsConverter.toVector3(position);
    }

The base class takes care of the core attributes and properties.

The particular component creates a babylon entity in init and changes it in update.

Unlike native html-based Lit elements, the update is only called when properties change after initialization.

Properties

Components can have init/only, write/only and read/write properties.

The init/only properties are initialized from html attributes or default values and they are only used in the init.

The write/only properties schedule component updates so it can change underlying babylon entity.
That cannot be done via setter, because setters are called before the entity is initialized.
On the other hand, the update is guaranted (by me) to be called only after initialization.

The read/write properties are little more tricky:

class OrbitCameraElem extends CameraElemBase<ArcRotateCamera> {
    @property({ useDefault: true, converter: polarConverter })
    set orbit(value: Polar) {
        this.__orbit = value;
    }
    get orbit(): Polar {
        return this._camera ? polarConverter.fromCam(this._camera) : this.__orbit;
    }
    @state()
    __orbit: Polar = { a: 0, b: 0, r: 1 };

    override update(changes: PropertyValues) {
        if (changes.has("__orbit")) this._syncOrbit(this.__orbit);
        super.update(changes);
    }

    _syncOrbit(polar: Polar) {
        assertNonNull(this._camera);
        assertNonNull(polar);
        Object.assign(this._camera, polarConverter.toCam(polar)!);
    }

Here, setter caches the value, and getter retrieves actual values from babylon entity. Scheduled update actually affects entity.

All the attributes are deletable, so the properties are implicitely Nullable.
Also, the components are exposed to the hostile world (still inhabited by jQuery) and external scripts can set properties to some random shit, which turns to null by value conveters.

That’s why default values should always be provided, and double check for null performed at runtime.

Even more tricky example:

class MainElem extends MainElemBase implements IMyMain {
    model!: IModelContainer;
    _init() {
        /* ... */
 
        this.model = this.scene as IModelContainer;

        const affect = (mesh: AbstractMesh) => {
          if (MainElem._isImportant(mesh)) this.requestUpdate("model");
        };
        this.scene.onNewMeshAddedObservable.add(affect);
        this.scene.onMeshRemovedObservable.add(affect);
   }

   override update(changes: PropertyValues): void {
        if (changes.has("model")) {
            /* ... */        
        }
        super.update(changes);
   }
}

The model is just a reference to the scene with another interface and it’s value doesn’t actually change. But when an important mesh is added/removed, it implies that its content changed, and the requestUpdate enforces an update as if it’s changed.

Converters

Lit already has converters for very basic types.
For babylon-related types there are some custom converters with extended interfaces.

In the example above there used coordsConverter that converts values between 3 layers:

  • property: {x: ..., y: ..., z: ...} for extenal scripts (babylon-unaware)
  • attribute: “[x, y, z]” for bare html
  • internal: Vector3 for babylon

Similar converters created for colors and polar camera orientation.

Reflecting the values back to attributes doesn’t make much sense for complex types, but it’s implemented anyway just for completeness.

Context

The context is kinda future web standard, already implemented in Lit.
It works like observables imbedded into properties’ getters and setters.
Setting a context property in a provider notifies all descendant subscribers (probably, asynchronously). They then can read updated value simply via their properties getters.

Example:

class MainElem extends MainElemBase implements IMyMain {
    @provide({ context: boundsCtx })
    bounds: BoundsInfo = /* initial dumb bounds */

    @provide({ context: pickCtx })
    picked: Nullable<PickingInfo> = null;

    somewhere() {
      this.bounds = this._getBounds();
    }
    somewherelse() {
      this.picked = ...
    }
}

class LookCameraElem extends CameraElemBase<ArcRotateCamera> {
    @consume({ context: boundsCtx, subscribe: true })
    @state() // makes it call update() with changes.has(_bounds)
    _bounds: Nullable<BoundsInfo> = null;

    @consume({ context: pickCtx, subscribe: true })
    @state()  // makes it call update() with changes.has(_picked)
    _picked: Nullable<PickingInfo> = null;

    override update(changes: PropertyValues) {
        if (changes.has("_picked") || changes.has("_bounds")) { /* ... */ }
    }
}

When main component sets its bounds or picked, camera component recieve new values and schedules an update, it then can access the values via this._bounds and this._picked.

That’s much more convenient than using Babylon Observables.
Especially because all updates are aggregated and end up in a single call to update(changes) and it can use arbitrary logic of handling them.

More tricky example:

class MainElem extends MainElemBase implements IMyMain {
    model!: IModelContainer;
    #modelCtx = new ContextProvider(this, { context: modelCtx });

    override update(changes: PropertyValues): void {
        if (changes.has("model")) {
            /* ... */ 
            this.#modelCtx.setValue(this.model, true); // forced notification
        }
        super.update(changes);
   }
}

class MySomethingElem extends NodeElemBase<Mesh> {
    @consume({ context: modelCtx, subscribe: true })
    @state({ hasChanged: () => true }) // do not compare
    model!: IModelContainer;
}

The update of context is enforced so that all subscribers get notified, even though value itself is the same. Now, the state() decorator inserts comparision of actual new and old values, so it should also be tricked.

Controllers

Controllers is a feature of Lit. They are functional add-ons to components and they helps to decompose functionality.

Originally they are very dumb.
The BabylonControllerBase class is a bit extended version that suport init/remove/dispose.

Example:

class MainElem extends MainElemBase implements IMyMain {
    @state()  Nullable<PickingInfo> = null;
    #pickingCtrl = new PickingCtrl(this); // sets this.picked when pointer taps a mesh
    #draggingCtrl = new DraggingCtrl(this); // uses this.picked to attach DraggingBehavior
}

The controllers have access to host component and can do whatever they need.
Although they cannot see what exactly changed in an reactive update cycle, so some additional check is needed like this:

    override hostUpdated(): void {
        if (this.host.picked?.pickedMesh !== this.dragBhv.attachedNode) {
            if (this.host.picked?.pickedMesh) this.#pick(this.host.picked);
            else this.#unpick();
        }
    }

But that quite makes sense to check anyway.

Lit controllers are designed to be created in constructor, so their presence is decided at coding time.

The BabylonControllerBase kinda supports adding/removing them at run time like this:

    override update(changes: PropertyValues): void {
        if (changes.has('somethingEnabled') {
            if (this.somethingEnabled) {
                // calls host.addControler(), and then ctrl.init()
                #somethingCtrl = new SomethingCtrl(this); 
            else {
               // calls ctrl.dispose() and host.removeController()
               this.#somethingCtrl?.remove();
            }
        }
    } 

That use case is not actually tested because it doesn’t make much sense. And implementing ctrl.isEnabled seems more reasonable.

Disclamer

The project used to be my personal production-like playground for experiments over the last year.
It’s been refactored (several times) to make the snippets more reusable.
Now the core functionality is somewhat polished, documented and even unit-tested.
The rest is still in kinda draft state, maybe permanently. Some essential parts still missing (model loading, drag’n’drop, other gui snippets)
But overall the current state resembles kinda nano-framework and i think it’s worth sharing.

Hopefully, I’ll continue to work with BabylonJS in my upcoming freelance services, and the project will grow and evolve. But that’s not for sure.

Anyway, feel free to try this out in your work and provide some feedback here or on github.

Happy coding to everyone in the new year!

3 Likes

Also,

In the gui branch, there are some snippets for gui2d i’ve been working recently.
With experimental support of styling controls with CSS.

<b2g-gui-layer foreground>
    <b2g-hotspot anchor="#box" style="color: #ff00ff; opacity: 0.5; r: 16px"></b2g-hotspot>
    <b2g-hotspot anchor="baz" style="color: #ffff00; opacity: 0.5; r: 24px" blinking></b2g-hotspot>
    <b2g-label anchor="#center" style="border-color: #9eeaf9; background-color: #cff4fc; color: #055160; border-radius: 3px; padding: 3px; font-size: 10px;">
        bazycenter
    </b2g-label>
    <b2g-callout anchor="#ball" style="border-color: #9eeaf9; background-color: #cff4fc; color: #055160; stroke: #0dcaf0; border-radius: 6px; padding: 6px; stroke-width: 2px; stroke-dasharray: 4 4; position: relative; right: 100px; bottom: 100px;">
        a ball
    </b2g-callout>
    <b2g-callout edge anchor="#box" style="border-color: #9eeaf9; background-color: #cff4fc; color: #055160; stroke: #0dcaf0; border-radius: 6px; padding: 6px; stroke-width: 2px; stroke-dasharray: 4 4;">
        a box
    </b2g-callout>
    <b2g-dimension anchor="qux" style="position: relative; bottom: 150px; stroke-dasharray: 4 4; stroke-width: 2px; stroke: #ffffff; fill: #ffffff; r: 4px; background-color: #808080; color: #000000; border-radius: 6px; padding: 6px; border-width: 0;"></b2g-dimension>
</b2g-gui-layer>

Upd.

Separate live demo page available.

Add a few icospheres to reveal touch-blinking hotspots and their bazycenter with the distance.

2 Likes

Thanks for the writeup !!!

This sounds way better than the actual word and I support it to be the official name :slight_smile:

1 Like