WebGL Anwendungen mit Angular
Der Browser entwickelt sich mehr und mehr zu einer vielseitigen Plattform, die als Ziel für immer komplexere Anwendungen dient. Um mit dieser neuen Plattform alle Anwendungsfälle abbilden zu können, müssen auch aufwändige grafische Darstellungen wie etwa 3D-Modelle oder ganze Spiele auf dieser Plattform möglich sein. Für 3D-Modelle und Videospiele wurden bisher zum Beispiel OpenGl oder Direct3D verwendet. Im Browserumfeld steht mit WebGL jetzt eine vergleichbare API für 3D-Programmierung zur Verfügung. Damit wird ermöglicht, aus dem Browser heraus per JavaScript Grafiken zu erzeugen, die hardwarebeschleunigt direkt durch die Grafikkarte gerendert werden.
Unterstützt wird WebGL heute von nahezu allen Browsern.
Als Abstraktionsschicht für den für den grafischen Teil einer komplexen Anwendung eignet sich somit WebGL, zum Beispiel in Verbindung mit einem Framework wie three.js oder babylon.js.
Als Framework für den fachlichen Teil der Anwendung eignet sich zum Beispiel Angular (Angular 2, "Angular next"), da sich mit Angular gut testbare und wartbare Anwendungen bauen lassen.
Außerdem werden komplexe Anwendungen leichter handhabbar, da sie sich durch den Komponenten-Ansatz von Angular in kleinere Teilprobleme zerlegen lassen.
Dieser Artikel zeigt daher, wie man eine WebGL-basierte Anwendung durch Einsatz von Angular und three.js bauen kann.
Exemplarisch wird auch aufgezeigt, was notwendig ist, um alle Features der Anwendung - unter Berücksichtigung von WebGl - zu testen.
three.js mit Angular
Die three.js-Library bietet dem Entwickler eine Abstraktionsschicht über WebGL, um mit JavaScript-Mitteln GPU-unterstützte Szenen bauen zu können.
Für das grundsätzliche Setup der Anwendung wird das Kommandozeilenwerkzeug Angular-CLI verwendet. Zunächst wird die App angelegt:
ng new angular-webgl
Anschließend wird die Abhängigkeit zu three.js hinzugefügt :
cd angular-webgl
npm i --save three
Dann kann die Entwicklung von three.js mit Angular beginnen.
Im Beispiel entwickeln wir einen Angular-Rendering-Service.
Dieser Service rendert die 3D-Szene mittels three.js, wobei er Objekte zu der Szene hinzufügen oder die Szene animieren kann.
Um jedoch überhaupt eine Szene irgendwo hin rendern zu können, benötigt man eine Komponente als Rendering-Kontext.
Ein Ausschnitt aus dieser Komponente ist in folgendem Code-Beispiel zu sehen.
@Component({
selector: 'app-simple-webgl',
template: '<div #container>\</div>'
})
export class SimpleWebglComponent implements AfterViewInit {
@ViewChild('container') canvasContainer: ElementRef;
constructor(private webgl: WebglService,
private renderer2: Renderer2) {
}
ngAfterViewInit(): void {
this.webgl.init(this.canvasContainer, this.renderer2);
}
// ...
}
Dieser Ausschnitt zeigt, das eine WebGl-Komponente im einfachsten Falle eine leeres Template haben kann, da in dieses Template später (durch den WebGlService) das WebGl-Canvas hinein gerendert wird.
Dafür besorgt man sich per Dependency Injection eine Referenz auf den WebglService
, auf die ElementRef
der Komponente - also eine Refenrenz auf das DOM-Element der Komponente - und eine Referenz auf den Angular-Renderer, der für das Rendern der Komponente im DOM zuständig ist.
Nachdem die initiale Komponente (noch ohne WebGl-Canvas) sich im DOM befindet kann der WebGl-Context initialisiert werden;
dafür wird im ngAfterViewInit
-Lifecycle-Hook gesorgt.
Um das WebGl-Canvas in den DOM einhängen zu können, und um das Canvas an der richtigen Position anzuzeigen, wird dem WebGl Service beim Initialisieren eine Referenz auf das Komponenten-Element und den Renderer der Komponente übergeben.
Um zu verstehen, wie der Initialisierungsprozess abläuft, ist es wichtig, die Prinzipien von 3D-Programmierung mit three.js zu verstehen.
Vereinfacht kann man die Funtionsweise von three.js mit einer Filmaufnahme vergleichen:
Es gibt eine Kamera, die eine Szene aufnimmt.
Damit man etwas in der Szene erkennen kann, muss die Szene mit Lichtquellen aufhellen.
Die Szene an sich wird per WegGL in ein HTML5 Canvas-Element gerendert, dieses Canvas wiederum wird von Angular in den DOM eingefügt.
Im folgenden Code-Beispiel ist der Ausschnitt aus dem WebGlService dargestellt, der für den Initialisierungsprozess verantwortlich ist.
// webgl.service.ts
@Injectable()
export class WebglService {
private camera: THREE.Camera;
private scene = new THREE.Scene();
private webGlRenderer = new THREE.WebGLRenderer();
init(hostElementRef: ElementRef, ngRenderer: Renderer2) {
const hostElement = hostElementRef.nativeElement;
this.webGlRenderer.setSize(
hostElement.clientWidth,
hostElement.clientWidth / 1.8
);
ngRenderer.appendChild(
hostElement,
this.webGlRenderer.domElement
);
this.camera = new THREE.PerspectiveCamera(23, 1.77, 10, 3000);
this.camera.position.set(700, 50, 1900);
this.camera.lookAt(new THREE.Vector3(0, 0, 0));
const ambient = new THREE.AmbientLight(0x444444);
this.scene.add(ambient);
const light = new THREE.SpotLight(0xffffff);
light.position.set(0, 1500, 1000);
this.scene.add(light);
this.webGlRenderer.render(this.scene, this.camera);
}
// ...
}
Die drei Properties camera
, scene
und webGlRederer
werden als private Instanzvariablen vorgehalten, da diese später für jeden Rendering-Schritt wieder benötigt werden.
In der Methode init()
wird zunächst die Größe des WebGL-Rendering Kontextes (also letzten Endes des HTML-Canvas-Elementes) gesetzt.
In diesem Beispiel wird dafür die Breite des übergebenen HTML-Elementes (element.nativeElement
) genutzt.
Das vom WebGL Renderer bereitgestellte Canvas-Element wird dann durch den Angular-Renderer unterhalb des übergebenen Elementes in den DOM eingehangen.
Nun folgt die Einrichtung der three.js-Szene an sich.
Dazu wird zunächst eine Kamera erzeugt, die die Szene quasi "aufnimmt".
Der Konstruktor der Kamera bekommt das vertikale Field-of-View (FoV) der Kamera übergeben, ausserdem das Seitenverhältnis und die erste und letzte sichtbare Ebene.
Im Anschluss wird noch die Position der Kamera innerhalb der Szene und die Blickrichtung der Kamera eingestellt (beide Werte werden durch ihre entsprechenden x
,y
,z
-Koordinaten spezifiziert).
Zum gleichmäßigen Aufhellen der Szene wird ein AmbientLight
eingesetzt.
Die Angabe 0x444444
steht dabei für ein dunkles Umgebungslicht mit einer Lichtfarbe, die z.B. in CSS dem Hex-Wert #444444
entsprechen würde.
Zu bemerken ist hier, dass das AmbientLight im Gegensatz zur Kamera wird das zur Szene hinzugefügt wird (this.scene.add(ambient);
).
Dies liegt daran, dass das Licht ein fester Bestandteil der Szene ist, quasi "in die Szene gehört", während die Kamera die Szene nur "von aussen" aufnehmen soll.
Für die Highlights und Schatten im Bild sorgt eine zweite Lichtquelle, das SpotLight
.
Diese Punktlichtquelle ist durch den Farbwert 0xffffff
auf maximale Helligkeit eingestellt und strahlt (standardmäßig voreingestellt) in Richtung des Koordinaten-Zentrums (0,0,0)
mit einen Abstrahlwinkel von 90°.
Das SpotLight wird so in die Szene eingefügt, dass es das Zentrum der Szene von der Position (0, 1500, 1000)
aus anstrahlt.
Um die Szene einmal initial zu rendern, wird der render
-Methode des WebGLRenderer
die zu rendernde Szene und die zu verwendende Kamera übergeben.
Objekte zur Szene hinzufügen
Nach dem initialen Rendern bleibt die Szene - trotz der hinzugefügten Lichter - schwarz, wie in folgendem Screenshot zu sehen.
Dies liegt daran, dass es in der Szene noch keine Objekte gibt, die beleuchtet werden könnten.
Daher ist nun unsere nächste Aufgabe, tatsächliche Objekte zur Szene hinzuzufügen.
Zunächst wollen wir eine einfache Linie in das Canvas zeichnen.
In three.js besteht jedes 3D-Objekt aus einem unterliegenden Geometrie-Objekt und einem Material-Objekt.
Das Geomentrie-Objekt kann als ein dreidimensionales Gitternetz verstanden werden, das dazu dient eine räumliche Struktur vorzugeben.
Das Material-Objekt hingegen dient als Oberfläche des 3D-Objektes.
Die Geometrie des Objektes wird sozusagen mit dem Material eingewickelt.
In folgendem Code-Beispiel ist die Methode gezeigt, mit der wir unsere Linie in das Canvas zeichnen.
// webgl.service.ts
drawLine(): THREE.Line {
const geometry = new THREE.Geometry();
geometry.vertices.push(new THREE.Vector3(-200, 0, 200));
geometry.vertices.push(new THREE.Vector3(0, 200, 200));
geometry.vertices.push(new THREE.Vector3(200, 0, 200));
const material = new THREE.LineBasicMaterial({
color: 0x00ff00,
linewidth: 10
});
const line = new THREE.Line(geometry, material);
this.scene.add(line);
this.webGlRenderer.render(this.scene, this.camera);
return line;
}
Zunächst legen wir neues ein Geometrie-Objekt an. Um nun ein tatsächliches 3D-Gitternetzes aufzubauen, müssen wir Stützpunkte - oder Vertices - zur Geometrie hinzufügen.
In unserem Fall soll sich die Linie über 3 Punkte erstrecken, daher fügen wir 3 Vertices zum Gitternetz hinzu.
Jeder Vertex ist dabei in Form eines mathematischen Dreiervektors zu übergeben.
Anschließend wird noch ein Material als "Oberfläche" benötigt.
Da wir eine Linie zeichnen wollen, nehmen wir ein einfaches Lininen-Material (LineBasicMaterial
) und setzen eine grüne Linienfarbe (als Hex-Wert) und eine Linienbreite von 10 Einheiten.
Die Linie wird dann erzeugt, indem ihr das Geometrie-Objekt und das Material-Objekt übergeben werden.
Dann wird die Linie zur Szene hinzugefügt und die Szene durch den WebGl-Renderer gerendert.
Schlussendlich wird die Linie zurückgegeben, um sie später zum Beispiel für Animationen wiederverwenden zu können.
Eine durch diese Methode gerenderte Linie ist in folgendem Screenshot zu sehen.
Animationen
Neben statisch gerenderten Objekten sollen auch dynamische Animationen in einer Anwendung möglich sein.
Dafür wird eine Animationsschleife benötigt, welche den Bildschirminhalt dauerhaft aktualisiert.
Der Browser bietet hierfür die Funktion requestAnimationFrame
an.
Diese Funktion informiert den Browser darüber, dass eine Animation beabsichtigt wird.
Wie in folgendem Code-Beispiel zu sehen ist, bekommt requestAnimationFrame
auch eine Funktion als Parameter übergeben (hier die Funktion loop
) die vor dem Repaint der Szene ausgeführt werden soll.
// webgl.service.ts
private animationId: number;
private startRenderingLoop(animation?: () => void) {
this.cancelCurrentAnimation();
const loop = () => {
this.zone.runOutsideAngular(() => {
if (typeof animation === 'function') {
animation();
}
this.webGlRenderer.render(this.scene, this.camera);
this.animationId = requestAnimationFrame(loop);
});
};
loop();
}
cancelCurrentAnimation() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
}
Um nun eine Animationsschleife auszulösen, muss die übergebene Funktion loop
wiederum die Funktion requestAnimationFrame
aufrufen.
Es wird somit eine indirekte Rekursionsschleife erzeugt, welche nur einmal initial angestoßen werden muss durch einen manuellen Aufruf der Funktion loop
.
Dies geschieht alles in der Methode startRenderingLoop
des WebglService.
Diese Methode hat einen optionalen Parameter animation
, mit der der Methode die Logik übergeben wird die den konkreten Animationsablauf beschreibt.
Bevor eine neue Animation gestartet wird, wird in diesem Beispiel jedesmal Methode cancelCurrentAnimation
des WebglService aufgerufen, um die bisher laufende Animationsschleife abzubrechen.
Dies geschieht durch die durch den Browser zur Verfügung gestellten Funktion cancelAnimationFrame
, welche die ID der momentan laufenden Animation übergeben bekommt.
In der Methode rotate
wird wird die Methode startRenderingLoop
beispielhaft dazu genutzt, das übegebene three.js-Objekt3D
um die y-Achse zu rotieren. Dies ist in folgenem Code-Beispiel dargestellt.
// webgl.service.ts
rotate(obj: THREE.Object3D) {
const rotation = () => {
obj.rotation.y += 0.01;
}
this.startRenderingLoop(rotation);
}
Im Wesentlichen wird innerhalb von rotate
nur die Funktion rotation
definiert, welche den Rotationswinkel des Objektes bei jedem Aufruf um 0.01 Radian um die y-Achse gedreht.
Damit diese Funktion vor jedem Rerendering der Szene ausgeführt wird, muss dann einfach an die oben gezeigte Methode startRenderingLoop
des WebglService übergeben.
Die so generierte Angular-WebGL-Anwendung stellt sich dann folgendermassen dar:
Das vollständige Beispiel ist auf Github verfügbar: https://github.com/codecoster/ng-wegbl-demo