Article Series
- 🇬🇧Integration Basics: Integrating BabylonJS 3D Engine Into an Angular Business Application – Part 1
- 🇬🇧Performance optimization: Integrating BabylonJS 3D engine into an Angular business application – Part 2
- 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.
- Nutze Dependency Injection, auch in BabylonJS
- Schaffe klare Verantwortlichkeiten
- Versuche nicht die Spezialität des anderen Frameworks nachzubauen
- Verlasse dich auf die jeweiligen Framework Spezialisierungen
- 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.
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('factory-provider');
export function provideGameObject(GameObjectNode: Type, deps: any[] = []): ValueProvider {
return {
provide: FACTORY_PROVIDER_TOKEN,
multi: true,
useValue: {
provide: GameObjectNode,
useFactory: (...dependencies: any[]): ResolvedFactory => {
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(
GameObject: Type,
dimensions: Dimensions,
name: string,
gameObjectType?: GameObjectType,
parent?: TransformNode,
) {
return this.resolveAndInitialize(GameObject, dimensions, name, gameObjectType, parent);
}
private resolveAndInitialize(type: Type, dim: Dimensions, name: string, gameObjectType?: GameObjectType, parent?: TransformNode): T {
const resolvedFactory: ResolvedFactory = 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(term: string, searchedType: Type): 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.
Weitere Information zum Thema finden Sie auch in meiner Präsentation
Die Schritte zu einer guten und integrativen Architektur:
- Factory-Pattern zur Instanziierung und Dependency Injection für BabylonJS
- Angular Services nutzen
- Game Objects mittels composition erstellen
- Sich auf die Framework Spezialitäten verlassen und richtig nutzen