In diesem Artikel

In diesem Artikel werde ich zeigen wie Angular und BabylonJS erfolgreich und zukunftssicher integriert werden. Beide Frameworks – Angular als Business-Application-Framework und BabylonJS als Graphics-Engine – sind auf dem jeweiligen Gebiet gestandene Beispiele und damit perfekt für langfristige Projekte geeignet. Folgend werde ich meine Beispielarchitektur zur Integration vorstellen. Als Bonus gibt es noch eine Demoanwendung oben drauf. Alle gezeigten und diskutierten Inhalte sind nicht nur auf Angular und BabylonJS anwendbar, sondern gelten für alle Frameworks, die in einer ähnlichen Weise agieren.

Article Series

  1. 🇬🇧Integration Basics: Integrating BabylonJS 3D Engine Into an Angular Business Application - Part 1
  2. 🇬🇧Performance optimization: Integrating BabylonJS 3D engine into an Angular business application - Part 2
  3. Architekturlösung für die Integration von Angular und BayblonJS - Demo und Integrationsbeispiele ⬅

Was Sie erwartet

  • Tipps zu Angular und BabylonJS Framework
  • Architekturkonzepte und Implementierung
  • „Composition over inheritance“
  • Demoanwendung mit den vorgestellten Konzepten und Herangehensweisen

Was Sie nicht erwartet

Ich werde an dieser Stelle keine Einführung in BabylonJS oder Angular geben. Wenn Sie mehr über die Grundlagen wissen möchten empfehle ich Ihnen meine beiden vorhergegangen (englischsprachigen) Artikel zum Thema "Integration Basics" und "Performance Optimization". In diesen Artikeln findet ihr noch viel mehr Beispiele und eine weitere Demo zum Thema.

Alle meine Artikel finden Sie natürlich auch auf meiner Profilseite.

Das Fundament

Wie in meinen vorherigen Artikeln zu diesem Thema sind die wichtigsten Gesichtspunkte bei der Integration eine klare Verantwortung und das Schaffen von Schnittstellen. Nur auf diese Weise können wir die positiven Aspekte der einzelnen Frameworks nutzen ohne gleichzeitig Probleme zu verursachen.

  1. Nutze Dependency Injection, auch in BabylonJS
  2. Schaffe klare Verantwortlichkeiten
  3. Versuche nicht die Spezialität des anderen Frameworks nachzubauen
  4. Verlasse dich auf die jeweiligen Framework Spezialisierungen
  5. Erstelle Objekte mit dem Composition-Prinzip

Übrigens: Um Verwirrung zwischen Components, Objects, etc. zu vermeiden verwende ich immer wieder den Begriff GameObject. Vereinfacht ausgedrückt soll dies ein Oberbegriff für die logischen Objekte, der 3D-Engine sein, die ich mit Hilfe von Angular erzeugen möchte. Die GameObjects werden zu einem späteren Zeitpunkt ausführlicher erklärt.

Die Architekturgrundlagen

Bei der Integration zweier so unterschiedlicher Systeme, ist es wichtig auf das richtige Maß der Integration zu achten. Es darf nur genau so viel integriert werden, dass eine reibungslose Kommunikation stattfinden kann. Sollten zu viele Aspekte vermischen, werden sich Angular und die Grafik sich Engine gegenseitig im Wege stehen.

Jedes Framework besitzt sein eigenes Spezialgebiet, welches von einem anderen nicht ersetzt werden darf. Als Beispiel: Angular sollte nicht die BabaylonJS-Objekte verwalten oder deren Berechnung und Manipulation durchführen. Genau so sollte BabylonJS nicht versuchen den State von Angular direkt zu beeinflussen oder gar die UI selbst darstellen. Ein Wege zur jeweiligen Kommunikationsrichtung und Weise wird in dieser Architektur dargelegt.

In der vorgestellten Architektur werden Angular‘s Services benutzt um Kontext zu schaffen und die Zugriffe auf die Engine und die Scene zu strukturieren. Jedoch werden, bis auf nötige Referenzen, keinerlei Inhalte von BabylonJS innerhalb von Angular verwaltet. So wird auch ein bestimmter Service als Factory ein zentraler Bestandteil dieser Architektur sein.

Durch die Factory Nutzung zur Erstellung einzelner Objektinstanzen, ist der Engine Lifecycle auch von Angular klar getrennt. Wir müssen keine Components erstellen und verwalten um GameObjects zu erhalten. Gleichzeitig ermöglicht die Factory durch Angular's Dependency Injection den reibungslosen Zugriff auf Angular aus GameObjects heraus.

In der folgenden Grafik ist vereinfacht aufgeführt wie die Frameworks in der Applikation eingebunden sind und über welche Wege diese mit der View kommunizieren.

Vereinfachte Darstellung der angestrebten Integrations-Architektur An diesere stelle ist nur der "Application" teil Interessant. HTML APIs und WebGL werdene von den Frameworks selbst gehandhabt.

Der Zugriff von BabaylonJS nach Angular erfolgt mittels Funktionsaufrufen und Serivces. Dieses Vorgehen ist bereits aus Angular weit bekannt. Es unterscheidet sich kaum merklich zu der bekannten Kommunikation innerhalb von Angular. Das auslösende Element, wie zum Beispiel ein geklicktes oder verschobenes GameObject, übermittelt liebige Informationen an Angular.

Angulars Zugriff auf BabylonJS erfolgt durch das initiale Anlegen von einzelnen Objekten oder auch durch die Scene Erstellung an sich. Danach folgen nur noch einzelne Modelupdates. BabylonJS selbst bietet stark optimierte Möglichkeiten zur direkten Nutzerinteraktion. Durch die strikte Bindung an den Render Loop (Eine kurze Erklärung aus dem Artikel zur den 🇬🇧 "Integration Basics") und damit verbundenen Lifecycle muss darauf geachtet die Selbstverwaltung BabylonJSs nicht zu stören.

Die Fallstricke der Integration

Sieht man sich den Funktionsumfang von Angular und BabylonJS an, so erkennt man schnell, dass es Überschneidungen gibt. Diese Überscheidungen führen in der Regel zu Problemen, denen wir aus dem Weg gehen möchten.

Architektuelle Problemfelder

Wie in der Grafik zu erkennen ist, müssen wir besonders auf die einzelnen Lifecycles achten sowie das Event Handling trennen. So kompliziert sich das erst einmal anhört ist es eigentlich nicht - vielmehr ist es recht einfach, wenn wir die mitgelieferten Werkzeuge nutzen.

Das Event-Handling der Frameworks

Der Zustand einer 3D-Engine verändert sich ständig zur Laufzeit. Dazu kommt, dass sehr viele dieser Engines eine eventbasierte Kommunikation verwenden.

Mittels der bereitgestellten Funktion runOutsideAngular wird Angular sichergestellt, dass Angular die Events der Engine ignoriert.

this.ngZone.runOutsideAngular(() => this._engine.runRenderLoop(() => scene.render()));

Da eine die ngZone von Zone.js abgeleitet ist, müssen wir noch sicherstellen, dass Zone.js selbst nicht auf Engine Events reagiert. Zone.js stellt genau dazu Paramater bereit die in der polyfills.ts gesetzt werden können.

(window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
import 'zone.js/dist/zone';  // Included with Angular CLI.

Diese zwei Zeilen Code sind alles was nötig ist, um das Problem des Event Handlings zu lösen. Mehr zu diesem Thema, die Hintergründe und tief greifendere Informationen finden Sie in meinem Integration Basics Artikel (EN)🇬🇧.

Nachdem das erste Problem schnell aus dem Weg geschafft werden kann, benötigt die Trennung der Lifecycle etwas mehr Aufwand. Berücksichtigt man folgende Hinweise zur Trennung der einzelnen Lifecycle von Anfang an in seiner Architektur ist aber auch das kein großes Hindernis.

Der Application-Lifecycle

Vorab: Es ist essenziell, die Erzeugung und Verwaltung der Engine-Objekte (Game Objects) von der Erzeugung und Verwaltung der Angular-Components zu trennen.

Die Trennung der Lifecycles ist etwas komplizierter als das Lösen des Event-Problems. Wie erschaffen wir aus einer Anwendung heraus zwei parallell laufende Systeme, die möglichst wenig direkte Berührungspunkte haben? Erschwert wird das Ganze durch den Umstand, dass das Application-Framework (hier Angular) die Instanz des 3D-Grafik-Frameworks (BabylonJS in unserem Fall) erzeugt.

Um die Tatsache, dass wir Angular benötigen, um alles andere zu erzeugen, kommen wir nicht herum. Schließlich muss es einen Entrypoint geben. Ich werde zeigen, dass dies kein Problem darstellt, wenn man den hier vorgestellten Architekturansatz nutzt. So ermgölicht z.B. eine Factory die Nutzung von Angular's Dependency-Injection für und in BabylonJS Objekten.

Sorry to interrupt your reading of this article, but it seems that you're really interested in BabylonJS. Did you know that we're also publishing a free monthly newsletter about this topic and more?

Please enter a valid email address.

Angular’s Dependency-Injection für BabylonJS: Eine Factory muss her

Für den Entwickler ist Dependency-Injection (DI) ein Segen: Wir müssen uns nicht mehr um Abhängigkeitsketten kümmern. Das Problem ist jedoch: BabylonJS hat keine DI und benötigt diese auch nicht. Die Strukturen einer Szene sind für gewöhnlich flach oder haben ähnliche Abhängigkeiten. Eine Business-Application hat meist andere Anforderungen als ein Spiel, so ist auch die Anforderung an die Engine eine andere. Jedoch können wir die Engine mit wenigen Schritten um eine DI erweitern.

Diese Erweiterung gelingt mit einer Factory: Angular’s Dependency-Injection wird benutzt, um einzelne Objektinstanzen zu erzeugen.

Diese Factory ist übersichtlich und folgt immer einem ähnlichen Muster:

GameObject Factory, Angular's Dependency Injection instatiert einzelne Objekte

Mit Hilfe einer provider function wird eine (Sub-)Factory und ein Token erzeugt. Dieses Token wird dann über das Module registriert und kann so in der eigentlichen Factory genutzt werden. Die Sub-Factory hat den Vorteil, dass Angular die Dependency-Injection übernimmt und wir daraus neue einzelne Instanzen erzeugen können.

const FACTORY_PROVIDER_TOKEN = new InjectionToken<FactoryProvider>('factory-provider');

export function provideGameObject<T extends GameObjectTransformNode>(GameObjectNode: Type<T>, deps: any[] = []): ValueProvider {
    return {
        provide: FACTORY_PROVIDER_TOKEN,
        multi: true,
        useValue: {
            provide: GameObjectNode,
            useFactory: (...dependencies: any[]): ResolvedFactory<T> => {
                return {
                    ctor: GameObjectNode,
                    resolvedDependencies: dependencies,
                };
            },
            deps: [
                SceneContext,
                GameObjectFactory,
                ...deps,
            ],
        } as FactoryProvider,
    };
}

Im inneren return Statement ist alles Notwendige : es ist der constructor bekannt und alle nötigen Abhängigkeiten sind bereits aufgelöst und instanziiert. Neben den Standardabhängigkeiten, die meine GameObjects immer benötigen, können mit dem Parameter deps weitere Abhängigkeiten angegeben werden. So wird im Module eine solche Sub-Factory auf diese Weise registriert:

    …
    providers: [
        provideGameObject(BoxGameObject, [LightService, MaterialService, SearchContext]),]

Die Factory ist dann wie folgt aufgebaut:

@Injectable({ providedIn: 'root' })
export class GameObjectFactory {
    readonly injector: Injector;

    constructor(
        injector: Injector,
        @Inject(FACTORY_PROVIDER_TOKEN) factories: FactoryProvider[],
    ) {
        this.injector = Injector.create({
            parent: injector,
            name: 'GameObjectInjector',
            providers: [
                ...factories,
            ],
        });
    }

    create<TGameObject extends GameObjectTransformNode>(
        GameObject: Type<TGameObject>,
        dimensions: Dimensions,
        name: string,
        gameObjectType?: GameObjectType,
        parent?: TransformNode,
    ) {
        return this.resolveAndInitialize(GameObject, dimensions, name, gameObjectType, parent);
    }

    private resolveAndInitialize<T extends GameObjectTransformNode>(type: Type<T>, dim: Dimensions, name: string, gameObjectType?: GameObjectType, parent?: TransformNode): T {

        const resolvedFactory: ResolvedFactory<T> = this.injector.get(type as any);

        const gameObject = new resolvedFactory.ctor(...resolvedFactory.resolvedDependencies, parent);
 // call init helper method 
        gameObject.init(dim, name, gameObjectType);
        return gameObject;

    }

Da FACTORY_PROVIDER_TOKEN ein multi Provider Token ist, können wir diesen im constructor auflösen und einen Child-Injector erzeugen. In der Methode resolveAndInitialize wird dann mit dem Injector direkt auf den constructor des GameObjects und dessen Abhängigkeiten zugegriffen um eine Instanz zu erzeugen. Diese Factory kann jetzt überall verwendet werden, auch in anderen erzeugten Game Objects.

Was genau ist ein „Game Object“? Erstellung wiederbenutzbarer Objekte mit Composition

Betrachten wir eine reine Mesh fällt schnell auf, dass viel vereinfachende Funktionalität fehlt. In anderen Engines gibt es das Konzept des GameObjects. Dies ist ein möglichst einfaches Object welches uns bei der Verwaltung von Objekten jeglicher Art und Verwendung der Engine hilft. In BabylonJS kommt die TransformNode dem am nächsten, d.h. diese dient uns als Basis für unsere GamemObjects.

export abstract class GameObjectTransformNode extends TransformNode {
    dimensions: Dimensions;
    protected gameObjectType: GameObjectType;

    constructor(readonly sceneContext: SceneContext,
                readonly gameObjectFactory: GameObjectFactory,
                parent?: TransformNode) {
        super('GameObjectTransformNode-' + Math.random(), sceneContext.scene);
        this.parent = parent;
    }

    abstract init(dimensions: Dimensions, name: string, type: GameObjectType);
}

Darauf aufbauend werden GameObjects gestaltet und per Composition mit Funktionalität versehen. Composition bezeichnet ein Konzept, welches der Vererbung gegenüber steht. Die Funktionalität und Gruppenzugehörigkeit wird dabei nicht von der Hierarchie bestimmt, sondern viel mehr durch implementierte Interfaces, Member-Klassen und Methoden gegeben.

//...
export class BoxGameObject extends GameObjectTransformNode implements Decalable, Lightable, Container, Pickable {

    decal: Mesh;
    readonly meshes: Mesh[] = [];

    // Behaviors sind wiederbenutzbar und außerhalb der Klasse definiert
    addDecal = () => decalBehavior(this);
    removeDecal = () => removeDecalBehavior(this);

Anhand der Interfaces und sogenannten behaviors wird das Können und Verhalten eines GameObject bestimmt. Dieses Verhalten oder behavior kann dann extern, unabhängig vom GameObject, gelöst und wiederverwendet werden.

Zuerst wird das Interface deklariert, so lässt sich auch direkt zur Kontrolle, ein TypeGaurd (Doku) definieren:

export interface Decalable {
    decal: Mesh;
    meshes?: Mesh[];
    removeDecal: () => void;
    addDecal(parent: GameObjectTransformNode);
}

export function isDecalGameObject(toCheck: any): toCheck is Decalable {
    return toCheck.removeDecal && toCheck.addDecal;
}

Das hier definierte behvaior zum Hinzufügen und Entfernen von Stickern (eng. Decal) wird außerhalb jeder Klasse definiert und kann so unabhängig vom Object wieder verwendet werden. Die einzige Vorraussetzung ist, dass diese Methoden für GameObjectTransformNode ausgeführt werden.

export function decalBehavior(parent: GameObjectTransformNode) {
    if (isDecalGameObject(parent) && !parent.decal) {
        parent.decal = Mesh.CreateDecal(
            parent.name + 'decal',
            parent.meshes[0],
            parent.meshes[0].getAbsolutePosition().add(new Vector3(parent.dimensions.width / 2 + 0.01, 0, parent.dimensions.depth / 4)),
            new Vector3(1, 0, 0),
            new Vector3(3, 3, 3),
            0);
        parent.decal.material = this.materialService.getDecalMaterial();
    }
}

export function removeDecalBehavior(parent: GameObjectTransformNode) {
    if (isDecalGameObject(parent)) {
        parent.decal.dispose();
        parent.decal = undefined;
    }
}

Mit diesem Vorgehen lassen sich alle hierarchischen Probleme lösen, ohne auf Funktionalität zu verzichten. Wie man im decalGameObjectBehavior sieht, wird ein Angular Service genutzt um das Decal Material zu verwalten. Ein großer Vorteil den uns Angular mit der DI gegenüber reinem BabylonJS bietet. Damit erhält man klare Verantwortungen und eine robuste Struktur. Der umgekehrte Zugriff, von Angular in die Engine, kann ebenfalls mittels Services realisiert werden.

Noch mehr Angular: Services wieder benutzen, das Beste aus beiden Welten

Für mich sind Services einer der wichtigsten und besten Aspekte von Angular. Ob man diese als Pure-Service oder als Context verwendet spielt dabei keine Rolle. Es ist eine zentrale Stelle und bietet immer Vereinfachungen und strukturelle Vorteile gegenüber stark verteilten Systemen. In dieser Architektur werden themenbezogene Services und Contexts verwendet. Anhand des SearchContext werden die Vorteile klar. Dieser dient uns als ein bindeglied zwischen Angular und BabylonJS. Einfach in Angular genutzt können wir auf alle Aspekte der Engine und Scene zugreifen.

Durch die zentrale Anlaufstelle und Angular Components und Game Objects ist die Verantwortung und Rolle jederzeit klar definiert.

Betrachtet man die findGameObject Methode genauer, wird klar wie der architekturelle Ansatz die Integration vereinfacht. Dieser ermöglicht die direkte und einfache Inpsektion von BabylonJS-Attributen sowie die gleichzeitige Nutzung der bewährten Vorgehen der Programmierung mit Angular und Typescript.

@Injectable({providedIn: 'root'})
export class SearchContext {

    activeGameObject: GameObjectTransformNode;

    constructor(private readonly camera: CameraService,
                private readonly sceneCtx: SceneContext, 
                private readonly light: LightService) {}

    //...

   findGameObject<T extends TransformNodeGameObject>(term: string, searchedType: Type<T>): T {
        this.clear(false);
        /*
        * Find the object that represents our desired object.
        * Get all Nodes / Objects from the scene and filter them by some properties.
        * 1. Type of Node we are looking for
        * 2. Is it activatable at all ?
        * 3. Does it store information ?
        * Inspect each filtered node for the information, and store the found game object for later.
        */
        this.activeGameObject = this.scene.scene.transformNodes
            .filter(node => node instanceof searchedType)
            .find((node: T) => node.information === term) as T;

        if (isActivatable(this.activeGameObject)) {
            this.activeGameObject.activate(true);
        }

        /*
        * check if the Node has custom functionality that could be enabled
        * is it possible to add a decal to the Node ?
        */
        if (isDecalGameObject(this.activeGameObject)) {
            this.activeGameObject.addDecal(this.activeGameObject);
        }
        if (isPickable(this.activeGameObject)) {
            this.activeGameObject.enablePick(true);
        }

        /*
        * "disable" all other Nodes by modifying the underlying material
        * and set the material of the active node to "active"
        */
        this.materialService.deactivateBoxMaterials();
        this.activeGameObject.getChildMeshes(true).forEach(mesh => {
            this.inactiveMaterial = mesh.material;
            mesh.material = this.materialService.getBoxActiveMaterial(mesh.material);
        });

        return this.activeGameObject as T;
    }

    //...
}

Es ist möglich in einem ganz normalen Angular-Service auf die Engine bzw. hier die Scene zuzugreifen und direkt mit dieser zu interagieren. Es müssen keinerlei Referenzen zu den GameObjects mitgegeben werden. Die Scene verwaltet alle von ihr abhängigen Objekte selbst.

this.activeGameObject = this.scene.scene.transformNodes
            .filter(node => node instanceof searchedType)
            .find((node: T) => node.information === term) as T;

Ein paar Zeilen weiter ist ein weiterer Vorteil der Composition zu erkennen. Obwohl nach einem generischen Objekt gesucht wird, kann dieses trotzdem spezielle Funktionalität bieten. Diese feingliedrige Aufspaltung von Objekten und Möglichkeiten, bzw. Fähigkeiten der Objekte, wäre ohne Composition nur schwer zu realisieren.

if (isDecalGameObject(this.activeGameObject)) {
    this.activeGameObject.addDecal(this.activeGameObject);
}
if (isPickable(this.activeGameObject)) {
    this.activeGameObject.enablePick(true);
}

Wie oben dargelegt, ist einer der wichtigsten Aspekte die klare Trennung der Frameworks, und doch werden diese in vielen Services und GameObjects gemischt. Wenngleich es den Anschein macht als würden die Trennlinien verwischen, sind die Frameworks zu diesem Zeitpunkt sauber integriert.

Eine Faustregel: Eine Trennung, oder Austausch, der "verschiedenen" Apps sollte mit wenig Aufwand verbunden sein. Man stelle sich vor der Grafik-Teil liefe in einem IFrame und die Kommunikation erfolgt über postMessage. Genau dieses Problem vereinfachen vereinfachen die Services, Contexts und Interfaces auch!

Zusammengefasst

Nicht zu guter letzt, hier gehts zum Github Repo der Demo Anwendung und zur Demo selbst.

Schaubild zur Demo

Weitere Information zum Thema finden Sie auch in meiner 🇬🇧Präsentation

Die Schritte zu einer guten und integrativen Architektur:

  1. Factory-Pattern zur Instanziierung und Dependency Injection für BabylonJS
  2. Angular Services nutzen
  3. Game Objects mittels composition erstellen
  4. Sich auf die Framework Spezialitäten verlassen und richtig nutzen

Wenn Sie mehr zu diesem oder ähnlichen Themen erfahren wollen, empfehle ich Ihnen unseren Newsletter. So bleiben Sie auch über Folgeartikel und weiteren Publikationen auf dem Laufenden.

Bis zum nächsten Mal.

Related Articles

babylonjs
Performance optimization: Integrating BabylonJS 3D engine into an Angular business application - Part 2
Welcome to the second part of this blog post series. If you want to learn how to basically integrate BabylonJS into Angular (or vice versa) please take a look at part 1. In this post, our goal is to make your application and the interactions fast! Article Series Integration…
Max Schulte
babylonjs
Integration Basics: Integrating BabylonJS 3D Engine Into an Angular Business Application - Part 1
In this two parts article, I will demonstrate how easy it can be to integrate a 3D engine like BabylonJS into a business application built with a SPA framework like Angular. You will see how those two frameworks can interact with each other and where possible performance pitfalls…
Max Schulte
web components
The Flaws of Web Components (and possible solutions): Perks & Flaws Series - Part 3
The first article of this series introduced into the motivation for using Web Components. After looking at the perks in the second part, we are going to learn about the flaws of Web Components in this article. Please note that with the on-going development of the standards, some…
Manuel Rauber
angular
Creating Expressive Toast Components with Angular Animations
In some of my recent projects, I have experienced the need and requirements for better user experience by incorporating animations into Angular-based user interfaces. In this article, I am showing you how you can create an expressive toast component with Angular animations…
Konstantin Denerz