Let’s keep working on our web runner game and add an infinite plane with a little trick that relies on shaders!
This article is also available on Medium.
In the previous episode, we improved the architecture of our project and we had the game instance fully handle the initialisation of the 3D scene. Also, we created our little spaceship using Three.js buffer geometries.
Today, we’re going to add a grid in our scene. This grid will allow us to “feel” the movement – because for now, this big black screen doesn’t give us any indication on the current speed of our ship… and we want this grid to be infinite, because we’re making an infinite runner! 😉
This tutorial is available either in video format or in text format — see below 🙂
An “infinite” 3D object?
So – we want our grid to be infinite… but the problem is that, of course, we can’t make “infinite” objects in 3D! 3D meshes are defined by a given set of vertices and faces and you can’t just tell the computer to “keep adding more”.
This means that making an object infinite is in truth about using various tricks to make the viewer believe the object is larger than it really is.
In our case, the trick is to simulate movement without actually moving anything. As I said last time, what we’re gonna do is use shaders to add a little animation on a basic grid and make it look like it’s moving towards us – so like it’s moving backwards.
This way, it will be visually equivalent to our ship moving forward on this plane but:
- it will be much more efficient, because basic shaders are quick to compute and Three.js won’t have to recompute lots of position updates all the time
- and we won’t risk running out of grid
Because basically, everything will be standing still, and it’s just the animation that will create the illusion of movement!
A quick code clean up
Let’s go back to our
game.js script and, before we do anything, let’s split the
_initializeScene() method into sub-functions, because it’s getting a bit long.
I’ll just make a
_createShip() and a
_createGrid() method that both take in the scene as parameter, move the code from the
_initializeScene() function into
_createShip() and call the two functions, passing in the scene:
This will make it easier to jump around in our code and is more descriptive of our scene initialisation logic 🙂
Adding our grid with a shader
Ok, now! How are we going to create this infinite grid with a shader?
To do this, I adapted a shader that I found in this StackOverflow answer. This gives me the following code for the
_createShip() function – be careful, there are some modifications to the original code so that it matches our current setup:
Pfiou! A bit long, I know, but don’t worry – we’re going to dive into this piece of code and see how it works bit by bit.
Creating the grid
The idea of this script is that, at first, we create a simple grid with Three.js
GridHelper object. This constructor has a few parameters, like the size of the grid plane, the number of subdivisions and the colour of the minor and major divisions. And so then we apply a specific material on it, that uses a very specific set of properties written in a custom shader – we’ll talk about this in a sec:
Adding user-defined data with buffer attributes
Just before we dive into shaders, though, I want to detail a little bit this part, where we create a custom buffer attribute on our mesh:
Here, we’re using a little utility that Three.js has, the
setAttribute() method, that allows you to define user data on the vertices of your mesh, to have additional information. It’s a way of easily storing any data you want to access on your vertex afterwards – because by default Three.js creates some basic data for each vertex but of course it may not contain all the info that you need for your specific algorithm. So, this way you can prepare more input info for the code to come:
In our case, we’re using this
setAttribute() function to tell Three.js that some specific vertices in our mesh have a little “moveable” flag. This will distinguish between the horizontal and the vertical lines, so that our animation only impacts the horizontal line of the grid and we fill like we’re moving in this one direction.
And so we’ll be able to access this in our shader code later on to only move the right vertices, because we store it on the vertices with this
Making a material from a custom shader
Now that we’ve discussed the easy parts, let’s dive into the shader!
What are shaders?
Shaders are a really complex topic. I myself dived into this world after discovering Freya Holmér’s Youtube channel where she has a 3-parts live course on shaders for game dev. If you’re completely new to shaders and you want to learn, I definitely recommend you take a look.
If you want a quicker intro to shaders, though, I’ve talked about them extensively in the intro article of my latest CG series: “Shader Journey”! So make sure to go and read that if you’re not familiar with common shader concepts like: properties, vertex shader, fragment shader, interpolators… 😉
Here, I’ll just copy the diagram from that article that sums up the whole process of going from the 3D world to the 2D screen space when rendering a 3D object with a shader:
You start off with per-vertex data (both the auto-generated and the user-defined data) in the 3D world. For each of these vertices, you compute a new set of data in your vertex shader. This computed data is then passed to the interpolators; this intermediary data structure is used by the GPU to compute all the “missing” values for all the pixels in-between your vertices during the interpolation phase. It’s simply about linearly blending the values of the two closest vertices in each direction. This gives you some per-fragment (~ per-pixel) data to use in your fragment shader. This function will take this info and compute what colour to output on your screen for each pixel in your image, giving you the final render.
Defining our properties
The properties are also sometimes called “uniforms”; they are user-specific input parameters that are used within your shader during the computation (but that’s not per-vertex, that is global to the entire shader). In our case, we have 3 user-defined input parameters:
speedZ: the speed of our ship along the Z axis, so the automatic fly speed
gridLimits: the size of our basic grid mesh
time: the current time of the scene
Note that you have to define them as an object with a
value key, so that the Three.js shader code can retrieve the info in a variable with a matching name.
Defining our vertex shader
Here is the part of the code that defines our vertex shader:
It first gets our various inputs (the ones we defined in the uniforms) plus some info specific to the vertex it’s working on, such as our custom “moveable” flag or the vertex colour. Remember that some of this per-vertex data is passed in implicitly by Three.js (for example the vertex colour). We can then use it directly or transfer it to the fragment shader by storing it into variables, like
vColor here. This variable is retrieved later on in the fragment shader.
Then, the vertex shader’s main routine applies an offset to the vertices in the mesh depending on the current time and the speed, which will create the animation. We use the grid limits to wrap the offset when it’s reached the border, and so this way we get the feeling we’re on an infinite moving grid!
This entire block is wrapped inside an if-condition, where we check to see if the vertex as the “moveable” attribute. This insures that we only move the vertices that create the horizontal lines, and not the ones that create the vertical lines.
At the end of the vertex shader, we simply return the updated vertex position with some matrix multiplication to convert it to the right space for GPU computation.
Defining our fragment shader
Finally, our fragment shader just takes in the interpolated vertex info and writes the corresponding pixel colour on the screen. Basically, it uses the interpolated
vColor variable we passed from the vertex shader.
We use vectors with 4 components because we have the 3 usual red, green and blue colour channels, plus the alpha (for transparency) that is set to 1.
Note that we also pass in a
vertexColors parameter so that the shader gives us access to the
Including this grid in our scene
Around all of this new stuff, we find more common things like a new instance variable, the
this.speedZ, and the
gridLimit that are passed to our shader; or a
scene.add() in the end to actually instantiate the grid in the Three.js scene:
If you save this, you’ll see that there is now an infinite grid in your scene… but that it’s not moving!
Animating the grid!
That’s because, for now, we’re not actually updating the current time in our scene, so the shader animation is stuck at the initial frame, it can’t move.
To fix this, let’s create a little
this.time variable in our
Game class with an initial value of zero – I’ll do it in our
_createGrid() function because it is mostly linked to this object, but you can also put it in the constructor if you prefer:
And then, to update this time variable, we’ll have to use a
THREE.Clock() object. This tool allows us to keep track of the time and it will give us the time delta between two calls to our
update() method. So this way, we’ll be able to update our
this.time variable accordingly:
Let’s actually do this update right now: we’ll head to the
update() function and add a new line to modify the
this.time current value:
This simply increments our time variable by a little amount of time, a delta that is computed between the last time this line was executed and the current time.
Now, all we have to do is update the parameter inside of our shader using this new value. We’ll do this in our
If you save the script now, you’ll see that the grid is animated! Once again, nothing is actually moving but thanks to the vertex displacement that’s computed by our vertex shader, we feel like the horizontal lines are coming towards us, and so feel like we’re moving forward.
Because the grid itself is not moving, it will continue forever, just cycling through the animation.
That’s really cool! We’ve successfully created an infinite grid for our runner game, and even if we’re not actually changing the position of the ship, the camera or the grid, we’ve used a little trick based on shader to simulate movement.
This allows us to save a lot on computation power, because shaders are super efficient, and it avoids asking Three.js to recompute lots of positions all the time 🙂
Next time, however, we will be moving some objects: we’ll work on spawning the obstacles and the bonuses on the grid, and we’ll see how to make them move consistently with the grid animation to match the fake movement illusion…