Writing a 3D space rail shooter in Three.js, Part 2

Martin Naumann

October 01st, 2015

In the course of this 3 part article series, you will learn how to write a simple 3D space shooter game with Three.js.

The game will look like this:

It will introduce the basic concepts of a Three.js application, how to write modular code and the core principles of a game, such as camera, player motion and collision detection.

In Part 1 we set up our package and created the world of our game. In this Part 2, we will add the spaceship and the asteroids for our game.

Adding the spaceship

Now we'll need two additional modules for loading our spaceship 3D model file: objmtlloader.js and mtlloader.js - both put into the js folder.

We can then load the spaceship by requiring the ObjMtlLoader with

var ObjMtlLoader = require('./objmtlloader')
and loading the model with
var loader = new ObjMtlLoader(),
    player = null
   
loader.load('models/spaceship.obj', 'models/spaceship.mtl', function(mesh) {
  mesh.scale.set(0.2, 0.2, 0.2)
  mesh.rotation.set(0, Math.PI, 0)
  mesh.position.set(0, -25, 0)
  player = mesh
  World.add(player)
})

Looks about right!

Writing a 3D space rail shooter in Three.js, Part 2

So let's see what's going on here.

First of all we're calling ObjMtlLoader.load with the name of the model file (spaceship.obj) where the polygons are defined and the material file (spaceship.mtl) where colors, textures, transparency and so on are defined. The most important thing is the callback function that returns the loaded mesh.

In the callback we're scaling the mesh to 20% of its original size, rotate it by 180° (Three.js uses euler angles instead of degrees) for it to face the vortex instead of our camera and then we finally positioned it a bit downwards, so we get a nice angle.

Now with our spaceship in space, it's time to take off!

Launch it!

Now let's make things move!

First things first, let's get a reference to the camera:

var loader = new ObjMtlLoader(),
    player = null,
    cam    = World.getCamera()

and instead of adding the spaceship directly to the world, we will make it a child of the camera and add the camera to the world instead. We should also adjust the position of the spaceship a bit:

player.position.set(0, -25, -100)
  cam.add(player)
  World.add(cam)

Now in our render function we move the camera a bit more into the vortex on each frame, like this:

function render() {
  cam.position.z -= 1;
}

This makes us fly into the vortex...

Writing a 3D space rail shooter in Three.js, Part 2

Intermission - modularize your code

Now that our code gains size and function is a good time to come up with a strategy to keep it understandable and clean. A little housekeeping, if you will.

The strategy we're going for in this article is splitting the code up in modules. Every bit of the code that is related to a small, clearly limited piece of functionality will be put into a separate module and interacts with other modules by using the functions those other modules expose.

Our first example of such a module is the player module: Everything that is related to our player, the spaceship and its motion should be captured into a player module.

So we'll create the js/player.js file.

var ObjMtlLoader = require('./objmtlloader')

var spaceship = null

var Player = function(parent) {
  var loader = new ObjMtlLoader(), self = this
  this.loaded = false

  loader.load('models/spaceship.obj', 'models/spaceship.mtl', function(mesh) {
    mesh.scale.set(0.2, 0.2, 0.2)
    mesh.rotation.set(0, Math.PI, 0)
    mesh.position.set(0, -25, 0)
    spaceship = mesh
    self.player = spaceship
    parent.add(self.player)
    self.loaded = true
  })
}

module.exports = Player

And we can also move out the tunnel code into a module called tunnel.js:

var THREE = require('three')

var Tunnel = function() {
  var mesh = new THREE.Mesh(
    new THREE.CylinderGeometry(100, 100, 5000, 24, 24, true),
    new THREE.MeshBasicMaterial({
      map: THREE.ImageUtils.loadTexture('images/space.jpg', null, function(tex) {
        tex.wrapS = tex.wrapT = THREE.RepeatWrapping
        tex.repeat.set(5, 10)
        tex.needsUpdate = true
      }),
      side: THREE.BackSide
    })
  )
  mesh.rotation.x = -Math.PI/2

  this.getMesh = function() {
    return mesh
  }

  return this;
}

module.exports = Tunnel

Using these modules, our main.js now looks more tidy:

var World  = require('three-world'),
    THREE  = require('three'),
    Tunnel = require('./tunnel'),
    Player = require('./player')

function render() {
  cam.position.z -= 1;
}

World.init({ renderCallback: render, clearColor: 0x000022})
var cam = World.getCamera()

var tunnel = new Tunnel()
World.add(tunnel.getMesh())

var player = new Player(cam)
World.add(cam)

World.getScene().fog = new THREE.FogExp2(0x0000022, 0.00125)

World.start()

The advantage is that we can reuse these modules in other projects and the code in main.js is pretty straight forward.

Now when you keep the browser tab with our spaceship in the vortex open long enough, you'll see that we're flying out of our vortex quite quickly.

To infinity and beyond!

Let's make our vortex infinite. To do so, we'll do a little trick: We'll use two tunnels, positioned after each other. Once one of them is behind the camera (i.e. no longer visible), we will move it to the end of the currently visible tunnel.

It's gonna be a bit like laying the tracks while we're riding on them.

Writing a 3D space rail shooter in Three.js, Part 2

Our code will need a little adjustment for this trick. We'll add a new update method to our Tunnel class and use a THREE.Object3D to hold both our tunnel parts. Our render loop will then call the new update method with the current camera z-coordinate to check, if a tunnel segment is invisible and can be moved to the end of the tunnel.

In tunnel.js it will look like this:

And the update method of the tunnel:

this.update = function(z) {
    for(var i=0; i<2; i++) {
      if(z < meshes[i].position.z - 2500) {
        meshes[i].position.z -= 10000
        break
      }
    }
  }

This method may look a bit odd at first. It takes the z position from the camera and then checks both tunnel segments (meshes) if they are invisible.

But what's that -2500 doing in there? Well, that's because Three.js uses coordinates in a particular way.

Writing a 3D space rail shooter in Three.js, Part 2

The coordinates are in the center of the mesh, which means the tunnel reaches from meshes[i].position.z + 2500 to meshes[i].position.z - 2500. The code is accounting for that by making sure that the camera has gone past the farthest point of the tunnel segment, before moving it to a new position.

It's being moved 10000 units into the screen, as its current position + 2500 is the beginning of the next tunnel segment. The next tunnel segment ends at the current position + 7500. Then we already know that our tunnel will start at its new position - 2500. So all in all, we'll move the segment by 10000 to make it seamlessly continue the tunnel.

Space is full of rocks

Now that's all a bit boring - so we'll spice it up with Asteroids!

Let's write our asteroids.js module:

var THREE = require('three'),
    ObjLoader = require('./objloader')

var loader = new ObjLoader()
var rockMtl = new THREE.MeshLambertMaterial({
  map: THREE.ImageUtils.loadTexture('models/lunarrock_s.png')
})

var Asteroid = function(rockType) {
  var mesh = new THREE.Object3D(), self = this
  this.loaded = false

  // Speed of motion and rotation
  mesh.velocity = Math.random() * 2 + 2
  mesh.vRotation = new THREE.Vector3(Math.random(), Math.random(), Math.random())

  loader.load('models/rock' + rockType + '.obj', function(obj) {
    obj.traverse(function(child) {
      if(child instanceof THREE.Mesh) {
        child.material = rockMtl
      }
    })

    obj.scale.set(10,10,10)

    mesh.add(obj)
    mesh.position.set(-50 + Math.random() * 100, -50 + Math.random() * 100, -1500 - Math.random() * 1500)
    self.loaded = true
  })

  this.update = function(z) {
    mesh.position.z += mesh.velocity
    mesh.rotation.x += mesh.vRotation.x * 0.02;
    mesh.rotation.y += mesh.vRotation.y * 0.02;
    mesh.rotation.z += mesh.vRotation.z * 0.02;

    if(mesh.position.z > z) {
      mesh.velocity = Math.random() * 2 + 2
      mesh.position.set(
        -50 + Math.random() * 100,
        -50 + Math.random() * 100, 
        z - 1500 - Math.random() * 1500
      )
    }
  }

  this.getMesh = function() {
    return mesh
  }

  return this
}

module.exports = Asteroid

This module is pretty similar to the player module but still a lot of things are going on, so let's go through it:

var loader = new ObjLoader()
var rockMtl = new THREE.MeshLambertMaterial({
  map: THREE.ImageUtils.loadTexture('models/lunarrock_s.png')
})

We're creating a material with a rocky texture, so our rocks look nice. This material will be shared by all the asteroid models later.

var mesh = new THREE.Object3D()

  // Speed of motion and rotation
  mesh.velocity = Math.random() * 2 + 2
  mesh.vRotation = new THREE.Vector3(Math.random(), Math.random(), Math.random())

In this part of the code we're creating a THREE.Object3D to later contain the 3D model from the OBJ file and give it two custom properties:

  • velocity - how fast the asteroid should move towards the camera
  • vRotation - how fast the asteroid rotates around each of its axes

This gives our asteroids a bit more variety as some are moving faster than others, just as they would in space.

obj.traverse(function(child) {
      if(child instanceof THREE.Mesh) {
        child.material = rockMtl
      }
    })

    obj.scale.set(10,10,10)

We're iterating through all the children of the loaded OBJ to make sure they're using the material we've defined at the beginnin of our module, then we scale the object (and all its children) to be nicely sized in relation to our spaceship.

On to the update method:

this.update = function(z) {
    mesh.position.z += mesh.velocity
    mesh.rotation.x += mesh.vRotation.x * 0.02;
    mesh.rotation.y += mesh.vRotation.y * 0.02;
    mesh.rotation.z += mesh.vRotation.z * 0.02;

    if(mesh.position.z > z) {
      mesh.velocity = Math.random() * 2 + 2
      mesh.position.set(-50 + Math.random() * 100, -50 + Math.random() * 100, z - 1500 - Math.random() * 1500)
    }
  }

This method, just like the tunnel update method is called in our render loop and given the position.z coordinate of the camera.

Its responsibility is to move and rotate the asteroid and reposition it whenever it flew past the camera.

With this module, we can extend the code in main.js:

var World = require('three-world'),
    THREE = require('three'),
    Tunnel = require('./tunnel'),
    Player = require('./player'),
    Asteroid = require('./asteroid')

var NUM_ASTEROIDS = 10

function render() {
  cam.position.z -= 1
  tunnel.update(cam.position.z)
  for(var i=0;i<NUM_ASTEROIDS;i++) asteroids[i].update(cam.position.z)
}
and a bit further down in the code:
var asteroids = []

for(var i=0;i<NUM_ASTEROIDS; i++) {
  asteroids.push(new Asteroid(Math.floor(Math.random() * 6) + 1))
  World.add(asteroids[i].getMesh())
}

So we're creating 10 asteroids, randomly picking from the 6 available types (Math.random() returns something that is smaller than 1, so flooring will result in a maximum of 5).

Writing a 3D space rail shooter in Three.js, Part 2

Now we've got the asteroids coming at our ship - but they go straight through... we need a way to fight them and we need them to be an actual danger to us!

In the final Part 3, we will set the collision detection, add weapons to our craft and add a way to score and health management as well.

About the author

Martin Naumann is an open source contributor and web evangelist by heart from Zurich with a decade of experience from the trenches of software engineering in multiple fields. He works as a software engineer at Archilogic in front and backend. He devotes his time to moving the web forward, fixing problems, building applications and systems and breaking things for fun & profit. Martin believes in the web platform and is working with bleeding edge technologies that will allow the web to prosper.

Get your free eBook today

Continue on your journey as a game developer by deciding which game engine and language suits you best with the help of our free eBook.
comments powered by Disqus