Create a Simple Racing Game With Threejs

Alexander Parks
16 min readJan 15, 2024

--

One day I saw cars speeding by on the road, and I thought about making a racing game.

Instead of using native, I used threejs. After all, for larger 3D projects, if I continue to use native, I will cause trouble for myself…

This article explains the development process of this game from 0 to 1. There is no special introduction to webgl and threejs. Students who have no foundation can read it together with the threejs document, or learn the basic knowledge of webgl first~

Here’s how to do it:
w, forward
a, d turn left and right
space, slows down and can drift

At present, the collision detection of the game has not been completed (it will be updated and improved in the future), and only the left side of the car and the two sides of the track will be tested for collision. The details will be mentioned below~ You can also find out which two sides are by trying it yourself.

Next we will implement this racing game from 0 to 1~

1. Game preparation

First of all, we have to choose what game to make. If it is a company-level game project, then there is basically no choice in development. If you practice by yourself, you can do it according to your own preferences. The reason why I chose racing is as an example:

First of all, it is because racing games are relatively simple and do not require too many materials. After all, it is a personal development, and there is no specialized design to provide a model. You have to find the model yourself.

Secondly, the cost of racing games is simple and closed-loop. With a car and a track, it is actually the simplest game to run.

So we finally decided to make a simple racing game. Next we have to look for materials.

2. Material preparation

I searched online for a long time and found a good car obj file. It has textures and so on, but some colors haven’t been added yet. I used blender to complete it.

Now that we have the car material, the next step is the track. The earliest idea for the track was to generate it dynamically, similar to the previous maze game.

Formal racing games definitely cannot be dynamically generated, because the tracks need to be customized and have many details, such as textured scenery and so on.

Our practice project cannot be so cool, so we can consider dynamic generation.

The advantage of dynamic generation is that you will play a new map every time you refresh it, which may be more fresh.

There are also two methods of dynamic generation. One is to use a board to tile it continuously, and the vertex information of the board is
[-1,0,1, 1,0,1, 1,0,-1, -1,0,-1].

From a top view, it looks like this:

But this one has a very bad thing, that is, the curves are too rough, and each curve is at a right angle, which is not very good-looking. Just change a plan
Obj builds two models, namely straight road and turn, as shown in the figure.

Then these two models are constantly being tiled.
In 2D it looks like this.

It looks like this is possible, but! After the actual implementation, I found that it was still not good!

First of all, there is no turning back on the track because our y-axis is fixed and there is no concept of uphill or downhill. Once the track turns around and the new road meets the existing road, it will become chaotic and become a fork in the road.

Secondly, a lot of control must be done on randomness, otherwise there may be too frequent corners, as shown in the figure.

After being compatible for a while, I found that it was really messed up, so I decided to build a track model myself and have enough food and clothing by myself, as shown in the picture.

Once again, blender is very useful~

When designing the track here, there is a corner that is too difficult to design. It is impossible to negotiate the corner without slowing down… I believe you can definitely find out which corner it is by trying it for a lap~

3. Threejs

The preparation work is done, the next step is to write the code

I don’t know if you still remember the previous native webgl development. It was very cumbersome, right? This time we used threejs, which is much more convenient. However, I still have to say that it is recommended to familiarize yourself with native webgl before contacting threejs, otherwise there may be a lot of dependence, and some foundations in graphics will not be solid.

Our first step is to create the entire scene world

var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(90, window.innerWidth/window.innerHeight, 0.1, 1000);
camera.position.z = 0;
camera.position.x = 0;
var webGLRenderer = new THREE.WebGLRenderer();
webGLRenderer.setPixelRatio(window.devicePixelRatio);
webGLRenderer.setSize(window.innerWidth, window.innerHeight);
webGLRenderer.setClearColor(0x0077ec, 1);

These are necessary to use threejs. It is much more convenient than creating programs, shaders, and various compilation and binding by ourselves.

Next we need to import the model. Last time I wrote a simple objLoader, this time we use the one that comes with threejs.

var mtlLoader = new THREE.MTLLoader();
mtlLoader.setPath('./assets/');
mtlLoader.load('car4.mtl', function(materials) {
materials.preload();
var objLoader = new THREE.OBJLoader();
objLoader.setMaterials(materials);
objLoader.setPath('./assets/');
objLoader.load('car4.obj', function(object) {
car = object;
car.children.forEach(function(item) {
item.castShadow = true;
});
car.position.z = -20;
car.position.y = -5;
params.scene.add(car);
self.car = car;
params.cb();
}, function() {
console.log('progress');
}, function() {
console.log('error');
});
});

First load the mtl file, generate the material and then load the obj file, which is very convenient. Note here that we need to adjust position.zy after adding the car to the scene. The y-axis coordinate of the ground in our world is -5.

As can be seen from the previous code, the starting z-coordinate of the camera is 0, and we initially set the z-coordinate of the car to -20.

In the same way, import the track file again. If we access it at this time, we will find it is completely dark, as shown in the figure.

Why is this?

God said let there be light!

The tracks and cars themselves have no color, so materials and lights are needed to create colors. Creating lights in native webgl is also troublesome and requires writing shaders. Threejs is very convenient.
All we need is the following code:

var dirLight = new THREE.DirectionalLight(0xccbbaa, 0.5, 100);
dirLight.position.set(-120, 500, -0);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1000; // default
dirLight.shadow.mapSize.height = 1000; // default
dirLight.shadow.camera.near = 2;
dirLight.shadow.camera.far = 1000;
dirLight.shadow.camera.left = -50;
dirLight.shadow.camera.right = 50;
dirLight.shadow.camera.top = 50;
dirLight.shadow.camera.bottom = -50;
scene.add(dirLight);
var light = new THREE.AmbientLight( 0xccbbaa, 0.1 );
scene.add( light );

Refresh it and our whole world will become brighter! (Note that here we use ambient light + parallel light. We will change it to other lights later, and the reasons will also be given), but is there something missing? right! Still missing the shadow.

But let’s talk about shadows in the next section, because the processing of shadows here is not as simple as light.

Putting aside the shadows, we can understand that a static world has been completed, with cars and tracks.

document.body.addEventListener('keydown', function(e) {
switch(e.keyCode) {
case 87: // w
car.run = true;
break;
case 65: // a
car.rSpeed = 0.02;
break;
case 68: // d
car.rSpeed = -0.02;
break;
case 32: // space
car.brake();
break;
}
});
document.body.addEventListener('keyup', function(e) {
switch(e.keyCode) {
case 87: // w
car.run = false;
break;
case 65: // a
car.rSpeed = 0;
break;
case 68: // d
car.rSpeed = 0;
break;
case 32: // space
car.cancelBrake();
break;
}
});

We don’t use any keyboard event-related libraries, we just write a few keys ourselves. The code should still be easy to understand.

Pressing w means stepping on the accelerator, the run attribute of the car is set to true, and acceleration will occur in the tick; similarly, pressing a modifies rSpeed, and the rotation of the car will change in the tick.

if(this.run) {
this.speed += this.acceleration;
if(this.speed > this.maxSpeed) {
this.speed = this.maxSpeed;
}
} else {
this.speed -= this.deceleration;
if(this.speed < 0) {
this.speed = 0;
}
}
var speed = -this.speed;
if(speed === 0) {
return ;
}
var rotation = this.dirRotation += this.rSpeed;
var speedX = Math.sin(rotation) * speed;
var speedZ = Math.cos(rotation) * speed;
this.car.rotation.y = rotation;
this.car.position.z += speedZ;
this.car.position.x += speedX;

It is very convenient. It is ok to modify the rotation and position of the car with some mathematical calculations. It is much more convenient than implementing various transformation matrices in native webgl itself. However, you must know that the bottom layer of threejs is still changed through matrix.

To briefly summarize this section, we used threejs to complete the layout of the entire world, and then used keyboard events to make the car move, but we are still missing a lot of things.

4. Features and functions

This section mainly talks about functions that threejs cannot implement or cannot be easily implemented by threejs. Let’s first summarize the capabilities we still lack after the third quarter.
a. Camera following
b. Tire details
c. Shadow
d. Collision detection
e. Drift

Let’s go one by one.

Camera following

Just now we successfully made the car move, but our perspective did not move, and the car seemed to be gradually moving away from us. The perspective is controlled by the camera. Previously we created a camera and now we want it to follow the movement of the car. The relationship between the camera and the car is as shown in the two pictures below.

The rotation of the camera corresponds to the rotation of the car, but whether the car turns (rotation) or moves (position), it also has to change the position of the camera! This correspondence needs to be clarified.

camera.rotation.y = rotation;
camera.position.x = this.car.position.x + Math.sin(rotation) * 20;
camera.position.z = this.car.position.z + Math.cos(rotation) * 20;

In the tick method of car, the position of the camera is calculated based on the position and rotation of the car itself. 20 is the distance between the camera and the car when the car is not rotating (as mentioned at the beginning of Section 3). It’s better to understand the code together with the picture above. This enables the camera to follow.

Tire details

The tire details need to be in order to experience the authenticity of the yaw angle. It doesn’t matter if you don’t know the yaw angle, just understand it as the authenticity of the drift, as shown below.

In fact, when turning normally, the tires should go first and the body moves second, but we omit it here due to the perspective problem.

The core here is the inconsistency between the direction of the body and the direction of the tires. But here comes the trick. The rotation of threejs is relatively rigid. It cannot specify any rotation axis. Either use rotation.xyz to rotate the coordinate axis, or rotateOnAxis to select an axis passing through the origin for rotation. Therefore, we can only rotate the tires with the vehicle, but cannot rotate on their own. As shown in the picture.

Then if we want to rotate, we first need to extract the tire model separately, and it will look like this, as shown in the figure.

Then we found that the rotation is OK, but the car rotation is gone… Then we have to establish a parent relationship. The rotation of the car is done by the parent, and the rotation is done by the tire itself.

mtlLoader.setPath('./assets/');
mtlLoader.load(params.mtl, function(materials) {
materials.preload();
var objLoader = new THREE.OBJLoader();
objLoader.setMaterials(materials);
objLoader.setPath('./assets/');
objLoader.load(params.obj, function(object) {
object.children.forEach(function(item) {
item.castShadow = true;
});
var wrapper = new THREE.Object3D();
wrapper.position.set(0,-5,-20);
wrapper.add(object);
object.position.set(params.offsetX, 0, params.offsetZ);
scene.add(wrapper);
self.wheel = object;
self.wrapper = wrapper;
}, function() {
console.log('progress');
}, function() {
console.log('error');
});
});
……
this.frontLeftWheel.wrapper.rotation.y = this.realRotation;
this.frontRightWheel.wrapper.rotation.y = this.realRotation;
this.frontLeftWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2;
this.frontRightWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2;

The demonstration of the picture is like this:

Shadow

We skipped shadows before, saying that they are not as simple as light. In fact, shadow is implemented in threejs, which is several levels simpler than the native implementation of webgl.
Let’s take a look at the implementation of shadows in threejs. It requires three steps.
1. Light source calculation shadow
2. Object calculation shadow
3. Objects bear shadows
These three steps can make shadows appear in your scene.

dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1000;
dirLight.shadow.mapSize.height = 1000;
dirLight.shadow.camera.near = 2;
dirLight.shadow.camera.far = 1000;
dirLight.shadow.camera.left = -50;
dirLight.shadow.camera.right = 50;
dirLight.shadow.camera.top = 50;
dirLight.shadow.camera.bottom = -50;
……
objLoader.load('car4.obj', function(object) {
car = object;
car.children.forEach(function(item) {
item.castShadow = true;
}
);
……
objLoader.load('ground.obj', function(object) {
object.children.forEach(function(item) {
item.receiveShadow = true;
});

but! What we have here is dynamic shadow, which can be understood as the entire scene is constantly changing. In this way, shadows in threejs are more troublesome and require some additional processing.

First of all, we know that our light is parallel light. Parallel light can be regarded as sunlight, covering the entire scene. But shadows don’t work. Shadows need to be calculated through the orthomatrix! Then here comes the problem. Our entire scene is very large. If you want to cover the entire scene with the orthomatrix, your frame buffer image will also be very large, otherwise the shadow will be very unrealistic. In fact, there is no need to consider this step, because the frame buffer image cannot be that large at all, and it will definitely become stuck.
What to do? We have to dynamically change the orthomatrix!

var tempX = this.car.position.x + speedX;
var tempZ = this.car.position.z + speedZ;
this.light.shadow.camera.left = (tempZ-50+20) >> 0;
this.light.shadow.camera.right = (tempZ+50+20) >> 0;
this.light.shadow.camera.top = (tempX+50) >> 0;
this.light.shadow.camera.bottom = (tempX-50) >> 0;
this.light.position.set(-120+tempX, 500, tempZ);
this.light.shadow.camera.updateProjectionMatrix();

We only considered the shadow of the car on the ground, so the orthomatrix only ensures that it can completely contain the car. The walls were not considered. In fact, according to perfection, the walls should also have shadows. The orthomatrix needs to be enlarged.

There is no specular reflection effect of parallel light in threejs, and the whole car is not vivid enough, so I tried to change the parallel light into a point light source (feeling like a street light?), and then let the point light source follow the car all the time.

var pointLight = new THREE.PointLight(0xccbbaa, 1, 0, 0);
pointLight.position.set(-10, 20, -20);
pointLight.castShadow = true;
scene.add(pointLight);
……
this.light.position.set(-10+tempX, 20, tempZ);
this.light.shadow.camera.updateProjectionMatrix();

This looks a lot better overall. This is the reason for changing the light type mentioned before~

Impact checking

I don’t know if you have found which edges have collision detection, but it’s actually these edges~

There is collision detection between the red edges and the right side of the car, but the collision detection is very random. Once it hits, it is regarded as a crash… The speed is directly set to 0 and it reappears.

It is indeed lazy, because collision detection is easy to do, but this kind of racing collision feedback is really difficult to do without being connected to the physics engine. There is a lot to consider. It would be much more convenient if it is simply viewed as a circle.
So I will tell you about collision detection first. If you want to get good feedback… it is better to connect to a mature physics engine.

For collision detection between the car and the track, we first have to convert 3D to 2D to see it, because we don’t have any obstacles going up or downhill here, it’s simple.

2D collision, we can detect the left and right sides of the car and the sides of obstacles.

First we have the 2D data of the track, and then we dynamically obtain the left and right sides of the car for inspection.

var tempA = -(this.car.rotation.y + 0.523);
this.leftFront.x = Math.sin(tempA) * 8 + tempX;
this.leftFront.y = Math.cos(tempA) * 8 + tempZ;
tempA = -(this.car.rotation.y + 2.616);
this.leftBack.x = Math.sin(tempA) * 8 + tempX;
this.leftBack.y = Math.cos(tempA) * 8 + tempZ;
……
Car.prototype.physical = function() {
var i = 0;
for(; i < outside.length; i += 4) {
if(isLineSegmentIntr(this.leftFront, this.leftBack, {
x: outside[i],
y: outside[i+1]
}, {
x: outside[i+2],
y: outside[i+3]
})) {
return i;
}
}
return -1;
};

This is somewhat similar to the concept of a camera, but the math is a little more troublesome.

For line-to-line collision detection, we use the triangle area method, which is the fastest line-to-line collision detection.

function isLineSegmentIntr(a, b, c, d) {
// console.log(a, b);
var area_abc = (a.x - c.x) * (b.y - c.y) - (a.y - c.y) * (b.x - c.x);
var area_abd = (a.x - d.x) * (b.y - d.y) - (a.y - d.y) * (b.x - d.x);
if(area_abc * area_abd > 0) {
return false;
}
var area_cda = (c.x - a.x) * (d.y - a.y) - (c.y - a.y) * (d.x - a.x);
var area_cdb = area_cda + area_abc - area_abd ;
if(area_cda * area_cdb > 0) {
return false;
}
return true;
}
}

What happens after they meet? Although we don’t have perfect feedback, we should have basic feedback. When we set the speed to 0 and reappear, we have to reset the direction of the car correctly, right? Otherwise, the player will keep crashing… To reset the direction, use the original direction vector of the car and project it to the collision edge. The resulting vector is the reset direction.

function getBounceVector(obj, w) {
var len = Math.sqrt(w.vx * w.vx + w.vy * w.vy);
w.dx = w.vx / len;
w.dy = w.vy / len;
w.rx = -w.dy;
w.ry = w.dx;
w.lx = w.dy;
w.ly = -w.dx;
var projw = getProjectVector(obj, w.dx, w.dy);
var projn;
var left = isLeft(w.p0, w.p1, obj.p0);
if(left) {
projn = getProjectVector(obj, w.rx, w.ry);
} else {
projn = getProjectVector(obj, w.lx, w.ly);
}
projn.vx *= -0.5;
projn.vy *= -0.5;
return {
vx: projw.vx + projn.vx,
vy: projw.vy + projn.vy,
};
}
function getProjectVector(u, dx, dy) {
var dp = u.vx * dx + u.vy * dy;
return {
vx: (dp * dx),
vy: (dp * dy)
};
}

Drift

The car doesn’t drift, it’s like opening an online game and finding the network cable is broken.

We don’t consider which one is faster, drifting or normal cornering. Interested students can check it out. It’s quite interesting.
Let me first explain three conclusions:
1. One of the core parts of the drift racing game (handsome), it’s impossible not to do it.

2. The core of drifting is that it has a better exit direction without twisting the front of the car (other advantages and disadvantages are omitted, because this is the most intuitive visually).

3. There is no readily available drift algorithm on the Internet (regardless of unity), so we need to simulate drift.

For simulation, we first need to know the principle of drift. Do you remember the yaw angle we talked about before? The yaw angle is the visual experience of drifting.

To be more specific, the yaw angle means that when the direction of movement of the car is inconsistent with the direction of the front of the car, the difference is called the yaw angle.
So our simulated drift needs to be done in two steps:
1. Generate a yaw angle to visually allow players to feel drift.

2. The direction of exiting the corner is correct, allowing players to feel the authenticity. The player will not find it more uncomfortable to corner after drifting…

Below we will simulate these two points. In fact, once you know the purpose, it is still easy to simulate.

When the yaw angle is generated, we need to maintain two directions, one is the real rotation direction of the car body, realRotation, and the other is the real movement direction of the car, dirRotation (this is what the camera follows!).

These two values ​​​​are usually the same, but once the user presses space, they will start to change.

var time = Date.now();
this.dirRotation += this.rSpeed;
this.realRotation += this.rSpeed;
var rotation = this.dirRotation;
if(this.isBrake) {
this.realRotation += this.rSpeed * (this.speed / 2);
}
this.car.rotation.y = this.realRotation;
this.frontLeftWheel.wrapper.rotation.y = this.realRotation;
this.frontRightWheel.wrapper.rotation.y = this.realRotation;
this.frontLeftWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2;
this.frontRightWheel.wheel.rotation.y = (this.dirRotation - this.realRotation) / 2;
camera.rotation.y = this.dirRotation;

At this time, the yaw angle has been generated.

When the user releases space, the two directions must begin to unify. At this time, remember that dirRotation must be unified toward realRotation, otherwise the meaning of drifting out of the corner will be lost.

var time = Date.now();
if(this.isBrake) {
this.realRotation += this.rSpeed * (this.speed / 2);
} else {
if(this.realRotation !== this.dirRotation) {
this.dirRotation += (this.realRotation - this.dirRotation) / 20000 * (this.speed) * (time - this.cancelBrakeTime);
}
}

Summary

Due to time constraints, the details are not so detailed, but the core points are basically covered. If you have any questions, you can leave a message for discussion~

This game still has many shortcomings and flaws. I will continue to optimize and improve it in the future. Interested students can continue to pay attention~

Thank you for reading~

--

--

Responses (1)