Making a 3D web runner game #5: Spawning obstacles and bonuses

Today, let’s continue our web 3D runner game and create some objects to populate our level!

This article is also available on Medium.

In the previous episode, we saw how to make the infinite animated grid. We used a little trick, relying on shaders, to make it much more efficient to compute, thanks to the power of parallel computation of GPUs; and so overall, for now, not a single object in our scene is actually moving: we are only simulating movement via animation and relative viewpoints.

Today, we are going to see how to create and actually move objects on this grid to populate our level, and keep a consistent feel of this infinite movement.


This tutorial is available either in video format or in text format — see below 🙂

A quick overview

In Hyperspeed, we need to spawn two types of objects: the obstacles and the bonuses.

The obstacles are white cubes that can be a bit stretched on all 3 axis, and that are placed at random on the grid in front of us. The bonuses are also placed randomly, but these are displayed as spheres with various colours and sizes depending on how much score points they give if grabbed (their price).

So, to begin with, let’s see how to instantiate those objects in our level. To make it easier to move those objects later on, we will re-use the notion of 3D groups that we saw a couple of tutorials ago when we worked on our ship: instead of adding the obstacles and the bonuses directly into the scene (at the root of the hierarchy), we’re going to create a specific parent for all the objects (both obstacles and bonuses) to define a sub-hierarchy.

Let’s start by doing that in our _initializeScene() method, before we finish our setup. I’ll just create a new this.objectsParent variable that is a THREE.Group:

Once again, I’ll make it an instance variable so that it’s easily accessible from any other function from the Game class.

Ok – now, we need to create two util methods in our Game class: the _spawnObstacle() and _spawnBonus() functions:

Let’s first look at the obstacles, because they’re a little bit simpler.

Creating our obstacles

Just spawning some cubes (but with shared data)

The idea here is that we are going to create a basic cube geometry, and then we will get some random floats for the scale along the 3 axis.

However, because all of the obstacles share the same base geometry and the same material, we can improve memory management by storing and re-using the same geometry and material variables.

Those variables don’t depend on an instance of our Game class, but they depend on the game itself. So let’s make them class variables instead of instance variables. To do this, we simply have to declare the variables in the body of the class, so outside of any function. I usually prefer to put all my class variables at the top of the class, before the constructor – so, here, I’ll have:

Now, I can use these variables in my _spawnObstacle() method using the this keyword as usual:

This code snippets creates a new THREE.Mesh that directly uses the OBSTACLE_PREFAB and OBSTACLE_MATERIAL variables we defined in our class.

And we can actually see how it looks right now! We’ve added the newly created object to our brand new parent group (the this.obstaclesParent) so, in our _initializeScene() method, let’s write a little “for-loop” that creates 10 obstacles in the level:

Now if I save this, it will look like I have one cube in the middle of the screen, at the same position as my ship. That’s because, for now, we aren’t scaling or positioning the cubes at random, so the 10 obstacles are overlapping each other at the origin point:

Let’s go back to our spawn obstacle logic and add some randomness.

Making the obstacles’ scale random!

To anticipate some future code, we’ll separate the part of the logic that sets the random attributes on the object in a _setupObstacle() function and call it in our _spawnObstacle() method right now, passing in our new object:

So – we want to get random floats for both the scale along the 3 axis, and part of the position vector. To begin with, we can actually write up a little tool function to get a random float within a range, like this:

If you’re curious, you can find more info on the net – but basically this maps the range of Math.random(), which is a float between 0 and 1, to the range that you pass in through the min and max parameters.

Now, let’s actually use it to create our scale and position vectors. We’ll use Three.js .set() syntax to update the size and the location of our newly created object.

For the scale, it’s pretty easy:

Here, I’m filling in with some constants that I found beforehand and that I think look nice for my game. Of course, depending on your game and your design, you may have to tweak those! 😉

For the position, it’s a bit more complex because we don’t want it to be completely random. What we want is the following:

  • For the X position, it should be related to our current X position so that the objects spawn somewhat in front of us (even if we pressed the arrow keys and we derived away from the initial X = 0 position) – so we will consider a reference X position and then add a random offset to it.
  • For the Y position, it depends on the size of the cube because we don’t want it to be centred on the grid, but to be placed on it. So the bottom of the mesh should be at Y = 0, meaning that we have to translate the mesh itself by half its height along the Y axis.
  • Finally, for the Z position, it’s a bit more problematic, because we have to create the objects in front of us, at the horizon, so it’s also related to the current position of the ship, but this time along the Z axis… except that, remember, we’re not moving! So our Z position isn’t actually changing.

This means that we need to find a way of keeping track of our current travelled distance, our current “offset”, even though we haven’t actually moved!

We’ll see in just a minute how to get that – for now, let’s just assume we know this reference Z position (which is 0 by default). What we want to do then is take the half-size of our grid – in our case that is 200/2 = 100 units – and move further than this distance to create new objects far enough from our current view point.

So all of the following area (highlighted in blue) is allowed for spawning our objects:

We’ll just say that we start from this reference Z position, add the half-grid size offset and add a random offset that is somewhere between 0 and 100 units to populate this soon-to-come piece of the level.

The reference positions will be passed in as refXPos and refZPos input parameters, with default values of 0:

If you save your file again, you’ll see that there are now 10 cubes in the distance! 🙂

Adding bonuses on the terrain

Before we make those objects move and come toward us, let’s actually take care of the bonuses first. The overall idea is pretty much the same, except that some of the values for the bonus size and colour depend on the “price” of the bonus, so how much score points it will give if it is grabbed.

Creating the objects

We will re-use the same technique and have a BONUS_PREFAB class variable to share the same geometry; but this time we can’t share the material because all bonuses will have different colours.

So we have the following for our _spawnBonus() function:

Here, we don’t care about the colour because we’ll be re-assigning it very soon depending on the price of the bonus.

Setting them up randomly

Just like before, we’ll separate the part of the logic that assigns the random attributes in a _setupBonus() method and call it inside of our _spawnBonus() function:

The price of a bonus is picked at random between 5 and 20 score points, with an additional method called _randomInt() – quite similar to the _randomFloat() we wrote before, except we clamp the values to only get integers:

So with this function, we can now go to our _setupBonus() function and get a random price. We’ll also compute the ratio of this value compared to the maximum possible price (20), because we need this value to deduce the size and colour of the bonus mesh:

The size is simply half the ratio, and it will be the same for the 3 axis because we want a sphere:

For the colour, we want to have something that goes from blue for low prices to red for high prices. We could write these blue and red limits in the hexadecimal colour format, which is quite usual for web development, but this would mean we have to add some tiresome math computation in the middle to interpolate for the intermediary price values.

Instead, to get this linear gradient easily, we will use another well-known colour format: the HSL format.

The idea of this format is to represent each colour by 3 numbers: the hue, the saturation and the lightness.

With the HSL format, colours are organised in a disk. We usually say that the 0 degree angle is on the red, on the right, and we can then increase this angle to change the tint (= hue) of the colour. We get blues for angles around 180 to 240 degrees, and we’re back to reds around 360 degrees because we’ve come full circle:

On the other hand, the distance to the center gives you the saturation of your colour. If you stay at the same angle and move along this line, the tint doesn’t change but, the closer you are to the center, the whiter the colour is: it has low saturation. The maximum saturation is on the edge of the disk where you have your full tint:

Finally, lightness is often represented on a third axis, turning the disk into a cylinder:

This is the HSL cylinder. The height of your point simply tells you how “light” or “dark” the colour is. If you stay at the same point on the disk and only move vertically across the cylinder depth, you’ll get variations of your colour with lighter or darker tones.

The central axis of the cylinder is where you have the neutral, or achromatic colours: it’s only grays ranging from full black at the bottom to full white at the top.

In our case, we actually don’t need to move much on the HSL cylinder. We only care about the tint of our colour, so the angle on the disk. The HSL makes it really easy to get “equivalent” colours where only the tint changes: just keep the same saturation and lightness colours, and then change the hue to get various tints!

Here, we want to get blues for low prices and red for high prices. So we will simply remap the 180° to 360° angle range to our 5 to 20 price range. Since Three.js renormalises the hue value, it’s actually a remapping to the 0.5-1 range.

Then, we simply use Three.js setHSL() function to update the colour of the material on our new object:

I just put a full saturation and a medium lightness to get well-contrasted colours.

The final step is to just place our bonus object – it’s the exact same thing as for the obstacles, so let’s just copy back those few lines, and re-add our refXPos and refZPos input parameters:

And we can now go back to our _initializeScene() method and do another for-loop for the bonuses that also spawns 10 objects, using the _spawnBonus() function:

If you save your file, you’ll see that there are now also some coloured bubbles in the distance! 🙂

Moving the objects along with the grid

Ok, so we’ve successfully created our objects. Now, we want them to move and come towards us so that it matches the grid animation and we still feel like we’re moving forward.

To do this, we’ll take advantage of the fact that all of our objects are stored in the this.objectsParent group: this means moving the group will automatically move all of the objects inside it!

So basically, we can just go to the _updateGrid() method and add a little line, below the one that updates the shader of our grid, to update the position of this object.

I will also move the this.speedZ variable to be defined in the constructor rather than the _createGrid() method, because I think it’s better to centralise all of our variable initialisation logic in one place:

Now, if I save this, you see that all of the objects are slowly coming to me! I can increase the speed value a bit to make everything move faster – both the grid and the objects. Here is an example with a speed of 20:

So far, so good: we have spawned and moved our objects 😉

Using object pooling for endless re-instantiation

But of course, now, we don’t have an “infinite” level anymore, because as soon as the objects have been dragged too far and that they are behind us, we don’t see them anymore… and we aren’t creating any new ones!

So: should we just keep on creating more and more objects to fill this empty space in front of us?

In fact, the better solution is to use another common game dev pattern: the object pooling pattern.

What is object pooling?

Something that is very important whenever you do some game dev – or even programming in general – is that, overall, creating and destroying objects is costly. Continuously re-instantiating and deleting stuff means that you’re doing a lot of memory allocations, deallocations and re-allocations which is always inefficient. This is mostly due to caching and the way that computers sort objects in memory: they always prefer to have objects in neatly consecutive chunks of memory, but destroying an object means deleting a chunk, which leaves a hole – this is the problem of memory fragmentation:

And so then, you either have to account for this empty space afterwards, or move everything that is further down this memory line to remove the hole… neither of which is quick or free to do.

It’s usually way more optimised to keep the same objects as much as possible, and simply move them around I your scene to fake the apparition of new ones. This way you don’t create holes in your memory lines: once an object has reserved a spot, it keeps it until the end of the process and doesn’t collide with the rest of the objects in memory. So the object pool pattern relies on defining some objects as “obsolete” and “resetting” them from time to time, but never re-creating new ones or destroying them completely. We just simulate their appearance or disappearance via various tricks, but the memory manager is happy because we ask for one big chunk of continuous memory at the very beginning and then that’s it: you keep working with the same objects in memory over and over again.

Note that you can have an object pool that is larger than the number of objects shown initially: your routine may only reveal some after a while. But the whole point of that pattern is that the pool should have as many spots as the maximal number of objects needed in your scene at the same time.

Applying this pattern to our scene

In our case, we’ll just stick with the 10 obstacles and 10 bonuses, so that’s 20 objects in total. We create those at the very beginning in our _initializeScene() method, and then we’ll have to find a little bit of logic that “resets” them so they look like brand new object when they are “obsolete”.

Ok, now: what does it mean for us that an object is obsolete?

It’s simply that it has gotten out of sight, that it is somewhere behind us. Since the ship is still not really moving and stuck at Z = 0, an object is obsolete if its Z position in the world is more than 0.

In that case, we will simply need to recompute its scale and position – that’s for an obstacle – or its price and position – that’s for a bonus – and it will feel like we just created a brand new object on the terrain, somewhere in front of us!

We’ll handle this in our _updateGrid() method.

Basically, we have to first go through all the objects in our scene. Because they are all children of our this.objectsParent group, we can use the Three.js traverse() method that allows you to iterate through a sub-hierarchy in your scene and run some logic for an object and all of its children.

Here, we’ll traverse the this.objectsParent object, and the first thing we’ll want to do is check that we’re on a child and not the group itself; so let’s just add a little if check on the type of the child variable:

Now, we know that we’re on an obstacle or a bonus object and we can just check if it’s Z position is more than 0.

We just need to be careful to take its world position, or in other words, its position plus the position of its parent (the this.objectsParent group), because the overall offset is given by the parent object:

In that conditional block, we’ll want to reset the object using either the obstacle or the bonus initialisation routine. The problem is that, for now, we can’t distinguish between the two, so we don’t know which part of the logic to call!

To fix this, we can add some custom user data on our objects to make it easier to differentiate between the two types of objects. This is a bit like the buffer attributes we saw in the previous episode when we created our grid except that it’s not at the geometry level but at the mesh level.

Three.js has this specific property on meshes, the .userData property, where you can store whatever you like and it will just be passed around with the object all throughout its lifetime. So let’s set this property to a little object with just one key: the type, that is “obstacle” for an obstacle and “bonus” for a bonus:

Now, back in our _updateGrid() function, we can access this info to know if we’re working on an obstacle or a bonus, and so we’ll be able to call either the _setupObstacle() or the _setupBonus() function:

The first parameter we pass in is the object to apply the modifications on, so in our case that’s the child variable. But then, remember we have to get the reference positions along the X and Z axes to properly shift the spawning points of our objects. This will make sure that the obstacles and the bonuses always appear somewhat in front of us.

The reference X position is easy to get: it’s simply the ship position on this axis – because we’ll see next time that we will get the user inputs with the arrow keys and actually convert them to translations along the X axis – so the ship position will indeed change on this axis.

On the Z axis, however, we’re not moving, so the ship position is always 0. What is moving is the objects parent group – we’re continuously sliding it backwards. So to get the current position along the Z axis, we can simply take the opposite of the position of this object:

You might notice that we have exactly the same input parameters for the two calls. Something you can do to showcase your Javascript skills is define those parameters beforehand in an array (I’ll call it params) and then pass it to your functions as input parameters using the spread operator:

Now, if I save this, you see that the objects are indeed moving towards the ship and that there are “infinitely many”. Once again, we’re feeling like we’re gliding along this grid and, basically, every time an object gets behind us, another “pops” far at the horizon to replace it – except that it’s the exact same object, we just moved it 🙂

Conclusion

Today, we’ve seen how to spawn both obstacles and bonuses in our level and how to move them consistently with the grid by re-using 3D groups. Then, we talked about object pooling to optimise our code by pre-instantiating all the objects that are needed at the beginning of the game rather than creating and destroying new ones over and over again – which avoids running into memory fragmentation issues.

Next time, we will see how to get the arrow key inputs and handle them in our _keydown() and _keyup() functions to make the ship move along the X axis and slalom between the newly created objects…

Leave a Reply

Your email address will not be published.