Florian am 26.9.2014

WebGL Game mit Three.js


Zusammen mit dem Relaunch der neuen Demodern Website haben wir ein Mini WebGL Game umgesetzt. Zum einen als Prof-of-Concept, dass aufführt wie WebGL die Web Experience steigert und zum anderen da WebGL einfach unser digitales Herz schneller schlagen lässt.

Für die Umsetzung  benutzten wir  Three.js R68, eine JavaScript library/API zum Erstellen von WebGL-Animationen/Grafiken im Browser.

In diesem Artikel geben wir einen kurzen Überblick für ein simples "Hello Three.js" sowie Einblicke in die ein oder andere Herausforderung in der Umsetzung unseres WebGL-Games.


„Hello Three.js“

DER RENDERER

// Überprüft Browser auf WebGL support.
if(window.WebGLRenderingContext){
// Initialisiert Renderer mit aktiviertem Antialias.
renderer = new THREE.WebGLRenderer({antialias:true});}
// Wenn kein WebGL vorhanden ist, fallback zu CSS3.
else{ renderer = new THREE.CanvasRenderer();} 

// Referenzierung zum gewünschten Output-Element.
container = document.getElementById('WebGLContainer'); 

// Ausgabe wird im kompletten Canvas gerendert.
renderer.setSize(container.offsetWidth, container.offsetHeight); 

// Renderer wird dem DOM-Element angehängt.
container.appendChild(renderer.domElement);

DIE SCENE

// Erstellt eine neue Instanz vom Typ Scene. Dient als Wrapper für nachfolgend erstellte Three.js Objekte. 
var scene = new THREE.Scene();

DIE KAMERA

// Blickwinkel – Vertical field of view in degrees.
var fov = 45; 
// Seitenverhältnis – Frustum aspect ratio. 
var aspect = container.offsetWidth / container.offsetHeight; 

// Frustum near & far plane. Nur Objekte zw. diesen beiden Werten werden gerendert.
var near = 1; 
var far = 5000; 
camera = new THREE.PerspectiveCamera(fov, aspect, near, far); 

// Setzt die Kamera ein wenig zurück, um nicht mit default-positionierten Objekten zu überlappen.
camera.position.set(0, 20, 50); 

// Fügt Kamera der Scene hinzu.
scene.add(camera);

DAS LICHT

// Erzeugt eine Lichtquelle, ähnlich einer Glühbirne, um die Objekte sichtbar zu machen.
var light = new THREE.PointLight(0xffffff, 1); 
// Lichtpositionierung an Hand x, y, z.
light.position.set(0, 50, 50); 
// Fügt Licht der Scene hinzu.
scene.add(light);

DAS OBJEKT: BOX

// Erzeugt eine Boxgeometrie für unser Mesh/Objekt mit den Seitenmaßen von 5 auf jeder Achse (x, y, z).
var boxGeometry = new THREE.BoxGeometry(5, 5, 5); 

// Erzeugt ein Material für unser Mesh/Objekt.
var boxMaterial = new THREE.MeshPhongMaterial({color:0x0ace86})
;
// Erstellt die Box anhand der zuvor definierten Parameter Geometrie und Material.
box = new THREE.Mesh(boxGeometry, boxMaterial); 

// Wir rotieren die Box ein wenig um die Y-Achse, damit wir nicht nur die Vorderseite sehen.
box.rotation.y = 1;

// Fügt Box zur Scene hinzu.
scene.add(box);

Das Zwischenergebnis sollte nun so aussehen.

three.js box

Falls nicht, keine Sorge. Den Code findet Ihr auch auf GitHub.

Nichtsdestotrotz sieht das ganze ein wenig langweilig aus, also lasst uns die Box animieren.

DAS OBJEKT: PLANE

// Parameter für einen Boden.
var planeWidth = 100; 
var planeHeight = 100; 
var widthSegments = 10; 
var heightSegments = 10; 

// Erzeugt die benötigte Geometrie für unseren Boden.
var groundgeometry = new THREE.PlaneGeometry(planeWidth, planeHeight, widthSegments, heightSegments);

// Erstellt ein Material.
var groundMaterial = new THREE.MeshPhongMaterial({color:0xcdcfcf, side:THREE.DoubleSide}); 

// Erzeugt ein neues Objekt vom Typ .MESH und übergibt unsere vordefinierten Parameter Geometrie und Material.
var ground = new THREE.Mesh(groundgeometry, groundMaterial);
 
// Rotiert den Boden um 180 Grad von seiner Ursprungsposition aus.
ground.rotation.x = Math.PI / 2; 

// Wir verschieben den Boden ein wenig nach unten auf der Y-Achse, damit er unterhalb der Box platziert wird. 
ground.position.y = -5
;
// Fügt den Boden der Scene hinzu.
scene.add(ground);

DIE ANIMATION

// Rotiert die Box jedes gerenderte Frame über Ihre drei Achsen. Dieser Code muss in der animate() ausgeführt werden.
box.rotation.x = box.rotation.y = box.rotation.z = Date.now() * 0.0005;

Jetzt müsstet Ihr eine grüne und rotierende Box sehen, sowie einen grauen Untergrund.

three.js hello world result

Den Code findet Ihr ebenfalls auf  GitHub


„Three.js Game Parts“

Ok... alles schön und gut, aber wie kommen wir nun von einer grünen Box zu einem WebGL-Mini Game? Also lasst uns einmal die "Knackpunkte" auseinander nehmen.

DIE HEIGHTMAP

Wir nehmen uns eine noisemap zur Hand, eine Art grauskaliertes PNG und lassen es in einen 2D canvas rendern. Daraufhin gehen wir jeden einzelnen Pixel dieses canvas durch und konvertieren die Pixeldaten in für uns brauchbare Werte. Zum Beispiel bekommt ein grauer Pixel den Wert 15 und ein schwarzer den Wert 25.

Danach bestimmen wir die Höhe der Eckpunkte unserer verwendeten Three.js Geometrie anhand der konvertierten Pixelwerte. Das bedeutet, dass z. B. ein weißer Pixel den tiefsten Punkt abbildet und ein schwarzer den höchsten.

three.js heightmap

BILDDATEN AUSLESEN

// Erstellt neue Instanz und übergibt unsere gewünschte noise map.
var img = new Image(); 
img.src = 'images/noisemap.png'; 

// Übergibt img der getHeightData zum Auslesen der Pixeldaten.
function getHeightData (img,scale) {
if (scale == undefined) scale = 1; 

// Erstellt canvas und rendert das img dort hinein.
var canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
var context = canvas.getContext('2d');
var size = img.width * img.height;
var data = new Float32Array(size); context.drawImage(img, 0, 0); 

// Holt sich jedes Pixel und gibt diesem einen Wert.
for (var i = 0; i < size; i ++) { 
data[i] = 0 
} 
var imgd = context.getImageData(0, 0, img.width, img.height);
var pix = imgd.data;
var j=0;

for (var i = 0; i<pix.length; i +=4) {
 var all = pix[i]+pix[i+1]+pix[i+2]; data[j++] = all*2.5 ; 
}
 return data; };

TERRAIN ERSTELLEN

img.onload = function () {
var data = getHeightData(img); 

// Erstellt ein Bodenobjekt mit gewünschten Parametern.
var terrain = new THREE.Mesh(new THREE.PlaneGeometry( stageSize, stageSize, segmentsWidth, segmentsHeight), new THREE.MeshBasicMaterial({color: 0xffffff})); 

// Geht durch alle Eckpunkte der Bodengeometrie und vergibt die konvertierten Pixeldaten aus der noisemap.
for (var i = 0; i<terrain.geometry.vertices.length; i++) {
 terrain.geometry.vertices[i].z = data[i];
 } 
// Die Bodengeometrie muss mit true "geflagged" werden.
terrain.geometry.dynamic = true; scene.add(terrain);
 };

DIE KOLLISIONSABFRAGE

Aus Performancegründen wollten wir auf eine Physik-Engine verzichten. Somit behalfen wir uns der THREE.Raycaster Klasse, welche zusammen mit Three.js gebündelt ist.

function collision() { 
/* Wir stellen uns am besten Strahlen vor, welche vom inneren Ursprungspunkt des Spielerobjekts an alle dessen umliegenden Polygone/Faces reichen. Mithilfe eines Vektors erhalten wir deren Längen, welche wir in unserem Renderer alle FPS auf eine Differenz zu Ihrem Urspungswert hin prüfen. Eine Differenz entsteht, sobald andere Polygone/Faces eindringen und sich somit die Länge der Strahlen verkürzt. Eine Kollision findet statt. Damit sich die Objekte untereinander kennen, legen wir diese bei ihrer Erzeugung in jeweils unterschiedliche Arrays. Je nachdem aus welchem Array das kollidierte Objekt stammt wird eine andere Funktion aufgerufen – crash() oder collect(). */

var originPoint = playerObject.position.clone();
for (var vertexIndex = 0; vertexIndex < playerObject.geometry.vertices.length; vertexIndex++) {
      var localVertex = playerObject.geometry.vertices[vertexIndex].clone();
      var globalVertex = localVertex.applyMatrix4(playerObject.matrix);
      var directionVector = globalVertex.sub(playerObject.position);
      var ray = new THREE.Raycaster(originPoint, directionVector.clone().normalize());
      var collisionResults = ray.intersectObjects(collideMeshList); 

// Iterieren des Arrays um zu prüfen ob kollidiert wurde.
   if (collisionResults.length > 0 && collisionResults[0].distance < directionVector.length()) { 
      crash();
     }
   }
 
// Das gleiche wie oben, jedoch diesmal für das Einsammeln unserer Items.
   if(collecter=!collecter) {
     for (var vertexIndex = 0; vertexIndex < playerObject.geometry.vertices.length; vertexIndex++) {
           var localVertex = playerObject.geometry.vertices[vertexIndex].clone();
           var globalVertex = localVertex.applyMatrix4(playerObject.matrix); 
           var directionVector = globalVertex.sub(playerObject.position);
           var ray = new THREE.Raycaster(originPoint, directionVector.clone().normalize() );
           var collisionResults = ray.intersectObjects(collectItemsList);
 
   if ( collisionResults.length > 0 && collisionResults[0].distance < directionVector.length() ) { 
      itemCollected(); 
   }}} 
}};

FAZIT

Für unser WebGL-Game sind wir mit Three.js ganz gut gefahren um innerhalb kurzer Zeit ein Ergebnis zu erreichen. Three.js ist noch relativ neu, und es gibt noch viel Raum nach oben. Aber three.js entwickelt sich weiter, und andere WebGl Plattformen wie away3D und auch der UnityV5 WebGl export zeigen das wir erst am Anfang sind. Heißhunger haben wir auf jeden Fall.

Links:

ThreeJs

Away3D

Unity5 WebGL

Sourcecode auf Github