August 2016

WebGL und VR mit den Flammengeistern

"Virtual Reality" Applikationen stehen kurz davor, massentauglich das Internet zu erobern. Mit einem halbwegs aktuellen Smartphone und einem Google Cardboard sind VR-Brillen für jeden erschwinglich.

Nachdem wir bereits einige Web-3D-Anwendungen umgesetzt hatten, wollten wir uns jetzt der Herausforderung der Entwicklung einer VR-Umgebung im Web stellen.

Um gleichzeitig verschiedene Anwendungsszenarien zu testen haben wir ein Projekt umgesetzt, das 3 verschiedene VR-Welten miteinander verknüpft:

  • Eine explorative VR im Look&Feel "ancient Egypt"

  • Eine spielerische VR mit Suchfunktionen

  • Eine VR zur Darstellung von 360°-Bildern

Zum fertigen VR-Projekt "Flammengeister"

Als Grundlage der VR-Entwicklung haben wir uns hierbei für WebGL mit

three.js entschieden.

Wie meistens bei neuen Technologien gab es auch bei diesem Projekt nicht für alle Probleme bereits "Out-of-the-box"-Lösungen. Einige der Schwierigkeiten und unsere Lösungsansätze stellen wir hier vor:

Import von Szenen aus 3ds Max

Die statische Umgebung für Flammengeister sollte möglichst in einem Importdurchgang von three.js übernommen werden können, so dass man keine einzelnen 3D-Objekte anlegen muss. 3ds Max unterstützt diverse Exportierungsmöglichkeiten. Doch welche ist die Beste für unseren Use-Case?

Wir entschieden uns dazu, verschiedene Importer von three.js zu testen und bemerkten gleich, dass einige Importer mit unseren Szenen an ihre Grenzen kommen. Getestet wurden OBJ-, JSON- und DAE-Formate, sowie eine Kombination von OBJ- und MTL-Dateien. Das beste Resultat in Bezug auf die Darstellung lieferte in unserem Fall die Kombination aus .obj- und .mtl-Dateien.

                                    var mtlLoader = new THREE.MTLLoader(manager);
    mtlLoader.setPath( 'assets/images/world/' );
    mtlLoader.load( 'scene.mtl', function( materials ) {

        materials.preload();

        var objLoader = new THREE.OBJLoader(manager);
        objLoader.setMaterials( materials );
        objLoader.setPath( 'assets/images/world/' );
        objLoader.load( 'scene.obj', function ( object ) {

            // INSERT CODE HERE

            scene.add( object );

        }, function(){}, function(){} );

    });
                                

Positionierung / Animation / Kollisionserkennung von importierten Elementen

Im weiteren Projektverlauf bemerkten wir allerdings, dass die von 3ds Max exportierte Gesamtszene uns keine Möglichkeiten zur Interaktion gab.

Es war nicht möglich, Kollisionen mit Einzel-Objekten zu erkennen oder Texturen und Animationen hinzuzufügen.

Es war also unabdingbar, einige Objekte nachzubauen und sie zuvor aus dem Export zu entfernen. Dies bezog sich hauptsächlich auf die Geister und die Elemente zum Wechseln der aktuellen Welt. Ein Einbau erfolgte über Sphären (Kugeln, auf die wir eine Textur gelegt haben) und Cubes (Würfeln mit sechs Seiten, von denen 5 transparent dargestellt wurden). Diese können wir direkt formen und nach Belieben während der Laufzeit manipulieren.

                                    var materials = [
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0} ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0} ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0} ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0} ),
    new THREE.MeshBasicMaterial( { map: texture, side: THREE.FrontSide, transparent: true} ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0} )
];

// Create internal mesh
var internalMesh = new THREE.Mesh(new THREE.CubeGeometry(32, 64, 32, 1, 1), new THREE.MeshFaceMaterial(materials));

internalMesh.position.set(posX, posY, posZ);
                                

Pfadanimationen von Objekten

2 der Geister sollten auf einer Kreisbahn animiert werden.
Eine Kreisbewegung wird von three.js zum Glück direkt mitgeliefert: „CatmullRomCurve3“. Diese beschreibt eine Bézierbahn für alle Punkte im 3-dimensionalen Raum, die dafür angegeben werden.

Ein Geist bewegt sich dabei immer wieder hinter einer Kiste vor und versteckt sich danach wieder. Der andere Geist bewegt sich stetig um den Punkt der Kamera umher.

CatmullRomCurve3 liefert dabei beim Aufruf der Funktion "getPointAt" und der Angabe eines Parameters zwischen 0 und 1 den jeweiligen Punkt auf der gezeichneten Bézierkurve. Durch das Anpassen der Position der beiden Objekte auf den zurückgelieferten Punkt war die Animation perfekt.

                                    var curve1 = new THREE.CatmullRomCurve3([
    new THREE.Vector3(-520, 0, 0),
    new THREE.Vector3(-520, 90, 0),
    new THREE.Vector3(-520, 0, 0)
]);

// circleanimation
var factor = 300;
var curve2 = new THREE.CatmullRomCurve3([
    new THREE.Vector3(1*factor, 0, -1*factor),
    new THREE.Vector3(1*factor, 0, 1*factor),
    new THREE.Vector3(-1*factor, 0, 1*factor),
    new THREE.Vector3(-1*factor, 0, -1*factor),
    new THREE.Vector3(1*factor, 0, -1*factor)
]);
                                

Darstellung von Desktop / VR mit der selben Szene

Um dieselbe Seite für entweder Desktop- oder VR-Ansicht benutzen zu können wurde eine Switch auf der Startseite implementiert. Beim Klick auf „Desktop“ wurde die Seite normal aufgerufen, beim Klick auf „VR“ fügten wir einen GET-Parameter hinzu.

Dieser Parameter setzt eine JS-Variable, die hinter allen Verlinkungen innerhalb der 3D-Welt denselben Parameter ergänzt. Eine Benutzung der Seite ohne erneute Entscheidung von Seite des Users war somit gewährleistet. Dies war wichtig, um nicht jedes Mal beim Wechseln der Welten die VR-Brille absetzen und erneut auf dem Smartphone eine haptische Eingabe tätigen zu müssen.

                                    if(window['mode'] == 'vr')
{
    // VR-Code
} else {
    // Non-VR-Code
}
                                

Abstürze des Browsers auf mobilen Endgeräten

Bei ersten Tests auf mobilen Geräten produzierten wir regelmäßig Browser-Abstürze.
Smartphones und Tablets haben eine maximale Speichergröße für WebGL.

Tablets wie iPads unterstützen maximal 6.5MB an Daten in der Szene, Smartphones wie beispielsweise iPhones allerdings sogar bis zu 10MB an Daten.

Wir reduzierten die Texturen unserer Szenen bis die Grenze von 6.5MB nicht mehr überschritten wurde und die Welten auf allen Test-Devices problemlos liefen.

Performance-Probleme bei der Benutzung diverser Lichtquellen

Um bestimmte Bereiche der Szenen in den Vordergrund zu rücken wollten wir diverse punktuelle Lichtquellen einsetzen.

Die Performance-Einbußen durch die Lichtberechnung waren leider frappierend. Die FPS (Frames per Second) reduzierten sich beim Einsatz von 4 Lichtquellen mit Google Chrome von 24 Frames auf 4 Frames. Dynamisch gerenderte Schatten waren damit nicht umsetzbar.

Wir entschieden uns dazu, die Texturen der Szene anzupassen und Highlights und Schatten als Prerender bereits in die Textur-Grafiken zu integrieren. Es blieb ein allgemeines Licht in jeder Szene.

Teiltransparente PNGs

Um die Flammengeister in der Szene darzustellen war es notwendig, zum Teil transparente PNGs zu importieren und diese als Texturen zu verwenden.

Teiltransparenzen in WebGL-Texturen darzustellen ist problematisch und wir entschieden uns, auf die bereits bestehende Funktion von three.js zurückzugreifen: „alphaTest“. Dieser Wert bestimmt eine Zahl zwischen 0 und 1 und steht für einen prozentualen Wert. Wenn beispielsweise ein Wert von 0.5 angegeben ist und die Transparenz eines Pixels in der angegebenen Grafik mehr als 50% Transparenz aufweist, so wird der Pixel nicht dargestellt. Bei einem Wert unter 50% wird der Pixel vollständig ohne Transparenz angezeigt.

Eine Darstellung von Halb- bzw. Teiltransparenzen war also nicht möglich. Zum Ausgleich haben wir die Größe der Texturen erhöht, um eine verpixelte Darstellung aufgrund der fehlenden Halbtransparenz zu verhindern.

                                    materials = [
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0, alphaTest: .99 } ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0, alphaTest: .99 } ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0, alphaTest: .99 } ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0, alphaTest: .99 } ),
    new THREE.MeshBasicMaterial( { map: texture, side: THREE.FrontSide, transparent: true, alphaTest: .7 } ),
    new THREE.MeshBasicMaterial( { transparent: true, opacity: 0, alphaTest: .99 } )
];

// Create internal mesh
var internalMesh = new THREE.Mesh(new THREE.CubeGeometry(32, 64, 32, 1, 1), new THREE.MeshFaceMaterial(materials));
                                

Animierte Texturen

Für die Flammengeister wollten wir die Texturen animieren, um sie lebendiger zu gestalten. Also testeten wir den Einbau von animierten GIF-Grafiken als Texturen der Objekte. Da uns hier aber nur jeweils der erste Frame des GIFs angezeigt wurde, mussten wir uns eine andere Möglichkeit einfallen lassen.

Wir entwickelten einen Sprite-Animator, der eine Grafik als Textur auf die Objekte legte und in regelmäßigen Abständen die Textur verschob, so dass die Animation sichtbar wurde.

Usersteuerung mit der VR-Brille

Eine Steuerung der Welten am Desktop-PC war ohne weiteres mit der Maus möglich. Doch wir mussten uns in unserer Hybrid-Darstellung eine Möglichkeit einfallen lassen, wie der User die Welt im VR-Modus bedienen konnte, ohne seine Finger zu benutzen.

Wir nutzten die Device-Orientation um die Welt so zu bewegen, wie der User das Handy bewegt. Ein einfaches Kopfdrehen mit der VR-Brille reicht also aus, um sich in der virtuellen Welt zumindest umzusehen.

Jedoch bleibt noch die Möglichkeit aus, Objekte zu fokussieren und Interaktionen zu starten. Dazu fügten wir der Kamera einen Fokuskreis hinzu. Dieser bewegt sich selbst beim Drehen des Kopfes nicht von der zentralen Position weg und mit ihm hat der User immer einen Punkt auf den er sich konzentrieren kann, denn leider ist es noch nicht möglich, die Bewegung der Augen direkt auszuwerten. Ein innerhalb einer Sekunde größer werdender Kreis zeigt dem User eine Interaktion an und erst nach dieser eben genannten Sekunde wird jeweils eine Interaktion durchgeführt.

                                    // ADD CROSSHAIR TO CAMERA
var crosshair = new THREE.Mesh(
    new THREE.RingGeometry( 0.25, 0.375, 50 ),
    new THREE.MeshBasicMaterial( {
        color: 0xffffff,
        opacity: 0.5,
        transparent: true
    } )
);
crosshair.position.z = -50;
camera.add(crosshair);
                                

Kollisionserkennung mit Planes je nach FPS-Rate

Nicht jedes Gerät und nicht jeder PC verfügt über dieselbe Performance. Auf manchen Geräten war deshalb die FPS-Zahl um einiges kleiner als auf anderen Geräten. Da wir als Interaktion allerdings eine Kugel animiert auf die jeweiligen Ziele hinbewegen lassen, kommt es bei niedrigen FPS-Zahlen gelegentlich dazu, dass beim jeweiligen Frame die Kugel sich bereits durch das Plane mit der Textur durchbewegt hatte und somit keine Kollision erkannt wurde.

Wir modifizierten die Trigger-Planes und ersetzten sie durch Cubes – also Würfel. Diese haben dieselbe Höhe und Breite wie die Planes, jedoch verfügen diese zusätzlich über eine bestimmte Tiefe, was für eine bessere Kollisionserkennung sorgt. 

Fazit:

VR-Technologie fürs Web ist marktreif, die nötige Hardware extrem erschwinglich. Wie bei allen Web-Applikationen sind Kompromisse nötig, um Kompatibilität mit möglichst vielen Client-Devices zu erreichen. Aber das sind wir seit Netscape 3 ja gewohnt ;-)