Architekturlösung für die Integration von Angular und BayblonJS – Demo und Integrationsbeispiele

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.

In diesem Artikel:

mm_300x300
Max Schulte ist Consultant bei Thinktecture mit dem Schwerpunkt auf Angular und 2D/3D Visualisierung.

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.

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 ComponentsObjects, 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.

 

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.

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.

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.

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:

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 ServicesContexts und Interfaces auch!

Zusammengefasst

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

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
Bis zum nächsten Mal.
Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Angular
sl_300x300

View Transition API Integration in Angular—a brave new world (Part 1)

If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
Low-angle photography of metal structure
AI
cl-neu

AI-Funktionen zu Angular-Apps hinzufügen: lokal und offlinefähig

Künstliche Intelligenz (KI) ist spätestens seit der Veröffentlichung von ChatGPT in aller Munde. Wit WebLLM können Sie einen KI-Chatbot in Ihre eigenen Angular-Anwendungen integrieren. Wie das funktioniert und welche Vor- und Nachteile WebLLM hat, lesen Sie hier.
26.02.2024
Angular
sl_300x300

Konfiguration von Lazy Loaded Angular Modulen

Die Konfigurierbarkeit unserer Angular-Module ist für den Aufbau einer wiederverwendbaren Architektur unerlässlich. Aber in der jüngsten Vergangenheit hat uns Angular seine neue modullose Zukunft präsentiert. Wie sieht das Ganze jetzt aus? Wie konfigurieren wir jetzt unsere Lazy-Komponenten? Lasst uns gemeinsam einen Blick darauf werfen.
03.08.2023
Angular
YB_300x300

Using EntityAdapter with ComponentStore: @ngrx/entity Series – Part 3

As someone who enjoys the ComponentStore on an average level, I have written simple reactive CRUD logic several times. While storing a vast number of entities in the component state might not be a frequent use case, I will briefly illustrate the usage of the EntityAdapter with the @ngrx/component-store.
14.02.2023
Angular
YB_300x300

Multiple Entity Collections in the Same Feature State: @ngrx/entity-Series – Part 2

After introducing the @ngrx/entity package, I am often asked how to manage multiple entity types in the same feature state. While I hope that the previous part of this article series has made this more apparent, I will further focus on this question in the following.
07.02.2023
Angular
YB_300x300

Managing Your Collections With the EntityAdapter: @ngrx/entity-Series – Part 1

This three-part series of blogposts is targeted at developers who have already gained experience with NgRx but still manage their collections themselves. In the first part I introduce the Entity Adapter, in the second part I show you how to connect it to NgRx and in the third part how to do it with the Component Store as well.
31.01.2023