James Stanley


Progress on my Godot racing game for Oculus Quest

Wed 19 August 2020
Tagged: software, games

I've made some decent progress on the Oculus Quest racing game. I think it is already the most realistic driving simulator for the Quest, but that's not a very high bar as there are not any real competitors.

You can play the game now, if you want. It's available on SideQuest. I'd warn you that it's not much of a game: all you can do is drive the car around the track over and over again, or drive through the car park when you get bored of that. It does time your laps and show a ghost car of your best time so far, but it doesn't save your best lap for future.

The game is currently called "Ghost Racing VR", but I think this is a bad name, not least because I discovered there is already a game called Ghost Racing. So I'm on the lookout for a better name. I want the word "racing" in it so that it definitely shows up in SideQuest searches for "racing", but I also don't want the title to imply that you are racing against anything other than a ghost of yourself. Tricky one.

Facebook

Oculus have announced that as of October this year, everyone using an Oculus device for the first time will need to link it to a Facebook account, and all existing Oculus device users will have to do so in 2 years' time. On a device they already bought and paid for and own! This seems unreasonable, and I would now advise anyone who is thinking of buying an Oculus product not to buy one. The Facebook side of things was always concerning (what with Facebook basically having cameras and microphones inside my house), but it was previously quite easy to ignore. Not so much now :(.

Track

The track I was working with last time was created with the "track_base" scene from Bastiaan Olij's Vehicle Demo project. The problem with this is that it's hard to export as a triangle mesh to help with adding surrounding scenery.

After that, I tried making some tracks in Blender. You can see a track I made in Blender in this clip, albeit with no texturing. I was starting to get the hang of the process of drawing a track segment, drawing a Bezier curve, extruding the track segment through the curve using the Array and Fit Curve modifiers, then applying the modifiers to edit it as a triangle mesh, and then exporting with glTF to import into Godot. I was still struggling with what to do in the event that I applied the modifiers too early and wanted to go back to editing the curve. It seems there is just no option here: once you apply the modifier, you're stuck with whatever you've got. I much prefer FreeCAD's method of allowing you to go back to earlier steps and change the parameters.

I didn't need to learn any more Blender, however, as a chap named Quy Nguyen emailed me instructions for importing Assetto Corsa race tracks into Godot. This is an approach I hadn't even considered, and it worked really well. It has saved an enormous amount of effort, and the upshot is that I now have a perfectly passable copy of Cadwell Park, created by Terra21 and Jim Lloyd, which I can drive around in virtual reality. This is already significantly better than I ever thought the game would be. Releasing early and often is definitely the way to go, because other people see what you're doing, get in touch, and help you. So: if you read something in this post and think it's wrong, or think you can help, please let me know!


Physics

Starting from Godot's VehicleBody means a lot of the physics is already taken care of, which saves a lot of work.

Limited-slip diff

I did a basic simulation of a limited-slip diff as follows:

# limited-slip diff: apply more power through the wheel with the least grip, with at least min_diff_bias of total going to each wheel
var total_force = throttle_val * current_engine_force
var diff_bias = 0.5
if ($left_rear.get_skidinfo() > 0 || $right_rear.get_skidinfo() > 0):
    diff_bias = $left_rear.get_skidinfo() / ($left_rear.get_skidinfo() + $right_rear.get_skidinfo())
if (diff_bias < min_diff_bias):
    diff_bias = min_diff_bias
if (diff_bias > (1 - min_diff_bias)):
    diff_bias = (1 - min_diff_bias)
$left_rear.engine_force = lerp(0, total_force, 1 - diff_bias)
$right_rear.engine_force = lerp(0, total_force, diff_bias)

throttle_val ranges from 0 to 1, to indicate throttle position. current_engine_force gives us the engine force available at current RPM and maximum throttle. min_diff_bias is a configuration value for the minimum proportion of engine force that must go to each wheel (set to 8%). diff_bias is computed by weighting the engine force to each wheel according to the proportion that the wheels are skidding. lerp(a, b, t) is equivalent to a + (b - a) * t, i.e. linear interpolate from a to b, at time t (t ranging from 0 to 1).

This is not a great simulation, but it's better than both the "locked diff" that we got by default, and the "open diff" that we got without the min_diff_bias. Problems are that it basically assumes that there is 0 inertia in both the wheels and the diff, so it reacts immediately, instead of smoothly. It also acts exactly like an open diff up until the point where the min_diff_bias is reached, whereas in reality I think it should reach the limit smoothly instead of a hard stop. It drives well enough though, so I don't really plan to change it. Using "force" instead of "torque" is a VehicleBody quirk. The conversion from torque to force is performed earlier on, in the computation of current_engine_force.

Brake bias

Brake bias is easy:

var front_brake = delta * (brake_val * MAX_BRAKE_FORCE * front_brake_bias)
var rear_brake = delta * (brake_val * MAX_BRAKE_FORCE * (1 - front_brake_bias))
$left_front.brake = front_brake
$right_front.brake = front_brake
$left_rear.brake = rear_brake
$right_rear.brake = rear_brake

brake_val is the brake "pedal" position.

The only surprising part here is that we need to multiply by delta to get the braking values. delta tells us how much time has passed since the last physics timestep (which would be 1/72 of a second in the normal case). For some reason the VehicleBody wants forces for engine force, but impulses for braking force.

Clutch

Currently the clutch "simulation" lets the game down. Basically the clutch slips perfectly to hold you at precisely 1000 rpm (but magically still with full engine braking???) until you are moving fast enough for the engine to be running faster than 1000 rpm, and then the clutch is fully disengaged. This also means there's hardly any torque when pulling away from a standstill, because you're not allowed anything more than 1000 rpm until the clutch is fully engaged. The only time this isn't true is when you're in neutral, when a completely different model is applied, which allows you to rev up the engine semi-convincingly even though you're not moving. I need to work on the clutch model.

Grip

You can read about how grip is supposed to work in the "Magic Formula" parts of Brian Beckman's Physics of Racing Series. Basically optimal grip is achieved at some small level of slip, and then grip drops off as the tyre starts to slip further. This should be calculated independently for lateral and longitudinal slip.

VehicleBody doesn't allow us to set separate friction for lateral and longitudinal grip, so I just compute a single friction value for each tyre, which applies to both. I based it on this chart from the Wikipedia page on Hans Pacejka, who invented the Magic Formula:


(Image from Wikipedia.)

In reality the curve should be very much dependent on the tyre temperature, tyre pressure, tyre wear, road surface type, weather conditions, etc., but I just have a single hard-coded approximation made of linear sections that looks like this:


(The reason the friction force starts out at the basic level instead of at 0 is so that the tyre doesn't feel oddly slippery at really low speeds. In practice Pacejka's formula is more correct because it is impossible for the tyre to provide more force than is being pushed against it, but in the game we're basically setting the tyre friction 1 frame behind where we want to be, which makes it feel slippery if we set it too low. At low levels of slip we could just give the tyre infinite grip and it wouldn't make any difference, so it's fine as long as we don't give it too little).

It works quite well at giving the feeling of being able to drive up to the limit of the tyre, and then "falling off" as you exceed the grip limit. It does not work well at giving much feedback as to how close you are to the limit. You can see that on the Wikipedia chart the curve starts to round off before peak friction is achieved, which would let you know you're approaching it. Maybe I need to do something like that in the game.

Another hack I have in the game is to increase the grip of the rear tyres by 9% compared to the front. I found that the car is quite prone to lift-off oversteer. I tried to fix this with suspension settings but couldn't sort it out. Increasing the grip at the rear is not really realistic, but helps stabilise the car on corner entry. 9% is a level where the car doesn't feel like it either understeers or oversteers too much, it seems like a good balance to me.

VehicleBody limitations

VehicleBody has several annoying limitations. Some of them would be easy to fix, if only VehicleBody were implemented in GDScript instead of C++. As it stands, I think making changes to VehicleBody implies recompiling all of Godot, which I looked at but seemed too much hassle. It might be worth porting VehicleBody to GDScript so that it can be changed more easily, but I think I'd rather just work around its limitations for the time being.

VehicleBody calculates the weight on each corner of the car, because it needs to know this for the physics, but it does not expose this information in any way. The best I have come up with is working backwards from the suspension position to try to approximate the weight. This would be easy to fix, it just needs to expose the value that it has already calculated.

VehicleBody doesn't have a way to set separate friction values for lateral and longitudinal grip. It kind of bodges this internally by reducing all longitudinal impulses by half, but I'd prefer to have more control over it. I think just exposing a way to change the reduction of longitudinal forces at runtime (from "half" to a value of my choice) would go a long way towards helping with this.

VehicleBody does not correctly calculate wheel RPM in the event that the wheel is slipping. It calculates the wheel RPM by measuring how far the wheel has moved and dividing by the wheel circumference, which is not correct when the wheel is not purely rolling. This makes working backwards from wheel RPM to inform engine RPM problematic, because the engine RPM suddenly drops, instead of rising, as soon as the wheels start slipping. I have some bodges around this but they don't work well. This definitely needs work, and unfortunately I don't know what the solution would look like.

VehicleBody seems to completely ignore the friction setting of the surface that the wheel is touching. This is a problem because it means the grass is just as grippy as the tarmac and there is seemingly nothing I can do about it. The VehicleWheel documentation says that the friction of the tyre is "combined with the friction setting of the surface the wheel is in contact with", but I have not observed this behaviour. This definitely needs looking at.

VehicleBody does not have any obvious way to adjust the moment of inertia of the car. I suspect this is why the car is so prone to lift-off oversteer: it basically acts like a point mass that only has tyres preventing it from spinning, whereas in real life cars are hard to spin because their weight is not all concentrated in the centre. This could do with looking at, but just increasing the grip of the rear tyres is probably good enough for now.

A RigidBody vehicle

I said last time that I didn't see why I couldn't just create a car by adding a RigidBody for each wheel, and a damped spring joint connecting each wheel to the body. I did try this, but it did not work very well. Whenever a wheel rolled over a join between 2 triangles on the surface of the road (and therefore touched 2 triangles simultaneously, briefly) it acted weirdly and made the car jump around. Also, the suspension kind of behaved like it was acting on the centre of mass of the car rather than on the corner it was connected to (?). A force upwards on any corner would raise the entire car, parallel, rather than tilting it. I'm not really sure why, but I learnt enough to decide that I haven't learnt enough to make it work this way, so I'm going to be sticking with VehicleBody for now.

Sound

I've added some basic sounds to the game, and they add a lot compared to driving around in total silence. So far I've got tyre squeal sounds, a clunk when you change gear, and 2 types of engine sound. The engine sound is possibly too quiet, especially at low throttle positions, but it'll do for now.

Tyre squeal

There's an AudioStreamPlayer3D node positioned (roughly) at the contact patch of each wheel. When Godot's VehicleWheel.get_skidinfo() tells us that the wheel has broken grip, the audio starts playing a looped sample of tyre squeal, with volume increasing as the amount of skid increases, and frequency decreasing as the amount of skid increases. The "amount of skid" is calculated based on get_skidinfo(), the speed of the car, and the approximate amount of weight on that corner.

I was going to include a code sample but it's really long and confusingly-written. The text description is easier, I promise.

Engine noises

There's an AudioStreamPlayer3D node positioned at the end of the exhaust, and one positioned under the bonnet. I have 2 samples of Caterham engine noise, one with the throttle open and one with it closed, and they are played as follows:

func update_audio_db(audio_node, new_db, rate):
    audio_node.unit_db = audio_node.unit_db * (1-rate) + new_db * rate

func update_engine_sound(engine_rpm, throttle_val): if (throttle_val < 0.01): throttle_val = 0.01 update_audio_db($EngineAudio, log(throttle_val)-1.0, 0.5) $EngineAudio.pitch_scale = engine_rpm / 4100.0 update_audio_db($EngineThrottleAudio, 2.0*log(throttle_val)-1.0, 0.5) $EngineThrottleAudio.pitch_scale = engine_rpm / 5000.0

Both the "ordinary" and "on-throttle" samples increase in amplitude as the throttle is opened, but the "on-throttle" sample more so.

4100 RPM and 5000 RPM are the approximate engine speeds in the corresponding samples.

update_audio_db()'s smoothing of audio volume is not correct given that the unit is decibels, but it's adequate. All it is really doing is making sure the audio doesn't instantly change volume when you squeeze the throttle trigger, because that sounds weird.

The engine sounds in the game are not that great. The engine always sounds very smooth, across the entire rev range and with every throttle position. I think I want to add some crackle sound effects when RPM are dropping rapidly, get a more throaty-sounding "on-throttle" sample, and get several more interesting exhaust samples to fade between at different RPM ranges.

Graphics

Performance

The Cadwell Park track had about 1.5M vertices at first. I reduced this down to more like 400k by getting rid of the 3-dimensional grass, spectators, vehicles parked alongside the circuit, most of the buildings, and half the trees. More than 50% of the remaining vertices now are in the tyre walls, because every single tyre in every single wall is modelled as a separate 10-sided cylinder, which adds up fast considering they line almost the entire circuit on both sides and are stacked 4-high. I probably want to either delete the less-important tyre walls, or replace them with something simpler. Not quite sure on the easiest way to do that, but something to keep an eye out for.


I've also turned off almost all of the graphical enhancements in Godot, and the result is that the game stays above 50 fps for the entire lap, even with foveated rendering turned off. Really we'd be aiming for a stable 72 fps at all times, which I think is probably achievable if I can sort out the tyre walls.

Things to improve

It would probably be worth spending a bit more of the polygon budget on the cockpit of the car, given that it takes up about a quarter of the field-of-view and is visible the entire time. Also a more appropriate (and higher-res) skybox would help, because Cadwell Park is actually not nestled in the foothills of the Rocky Mountains.

Weird bugs

The foveated rendering appears to cause some issues with the depth buffer.


You can see that in the areas where the resolution is reduced (i.e. at the edge), you can see "through" the road to the skybox behind. I do not know why this happens. I don't see why reducing the resolution would have this effect. It only seems to happen on things that are far away. I tried reducing the distance to the far plane, and didn't observe the effect at all. Unfortunately the far plane needs to be quite far away else you see trees in the distance being clipped in and out of view, which looks bad.

I have also observed a "flickery grey rectangle" that sometimes appears in the middle of the view and makes it impossible to continue. There is no way to get rid of it, you just need to close and restart the game.


It is possible that this is another consequence of the depth problem, and that this time you're actually seeing beyond the skybox, to the default grey colour behind. I tried disabling foveated rendering and it seems to have solved both the weird "see-through road" problem and the flickery rectangle problem. I'd like to be able to use foveated rendering for the improved performance, but for now I'm going to be leaving it disabled.

Timing

I've added basic lap timing. I just defined an Area at the position of the start/finish line. Whenever the car enters the area, the previous lap time is recorded and the current lap time is reset to 0.


This does mean that you can take whatever route you want and it will still count as a valid lap, but that's fine for now. I'd probably want to add 2 or 3 split timing checkpoints around the track, which need to be crossed in the correct order, and also an Area to define the track limits. If the car ever leaves the track limits Area then that lap time would be considered invalid for the leaderboard, or something.

The Cadwell Park track model has several different layout options, named the "full", "MotoGP", and "Woodlands" layouts. We could quite easily put down a few cones, and adjust the split checkpoints and track limits Area, to have several different "tracks" without needing any more 3D models, which might be handy.

Menus

I still need to work out how to do menus in VR. I assumed this would be easy and I would stumble across it by accident, but I haven't yet found anything obvious. The gui_in_3d demo project might be a good starting point.


Ghost car


The ghost car is textured in plain white, mainly because I found it too annoying to apply the proper textures, but also because ghosts are white. The ghost car is faded out when the player is near it, by adjusting the alpha setting in the "albedo" of the material. I considered trying to fade out the car using the "proximity fade" and "distance fade" settings on the SpatialMaterial, but found they did not really provide enough control.

I had quite a bit of trouble positioning the wheels. At first I put the wheel meshes as child nodes of the ghost car body, and then recorded the relative transform of the wheels on the real car (relative to its body), and tried to replay these relative transforms onto the ghost car wheels, but it didn't work. They rotated around the wrong centre point, and around the wrong axis. I'm not sure why. Eventually I settled on recording the global transforms of the wheels, and making the ghost wheels not child nodes of the ghost car. Then I just replay the global transforms and it all works correctly.

The ghost car sometimes looks quite bad because some of the back-faces are seen through the transparency. I'm not sure what is best to do about this. I tried enabling back-face culling, but it still looked weird.

I think I would want the ghost car to be drawn completely opaque on to a separate buffer, and then composited into the scene with some transparency. Not really sure how to do this in Godot, but it would solve the problem of seeing back-faces on the ghost car, without stopping you from seeing through it.

Godot

I'm really impressed with Godot. It saves so much time compared to writing everything from scratch, and the visual editor makes it easy to lay everything out in 3D space.

If I had my way, it would be easier to set object properties in GDScript. The way you set properties in Godot is with the properties editor, but this doesn't provide you with any way to write comments next to the values, or compute the values from more human-friendly numbers, or from other constants. You can set the properties at startup time, but then the values won't be reflected in the editor.

There are only about 500 lines of code in the entire game. It's a very productive way to make games.

Steering wheel

I have made a first attempt at a steering wheel for Oculus Quest controllers. Although this one doesn't work very well, I don't think it has completely disproven the concept. The controllers are held onto the rotating part with magnets, so that the controllers can be easily removed from the steering wheel to operate the Oculus Quest menus.


Problems include:

Gameplay

I think it would be cool to add an online leaderboard. Enforcing track limits is obviously a prerequisite to this. Once we have a leaderboard, it might be interesting to add separate game modes. For example, there could be a 30-lap challenge, where instead of just trying to set a fast lap, you have to do 30 consecutive laps, which we could expect to take about 30 * 105 seconds = 52.5 minutes. That would enforce a more conservative driving style because a crash would be undoing quite a lot of hard work. I'm not sure how track limits would work on the 30-lap challenge. Invalidating the entire session seems unfairly harsh. Invalidating even an entire lap seems unfairly harsh. Maybe just a time penalty? But we'd have to make sure the time penalty is severe enough that you are always penalised for taking shortcuts. Maybe we could say that if you leave the course during a lap, then the best time we'll allow you to score for that lap is 20 seconds worse than your worst legitimate lap? Not sure.

If we said that you could have N people start the 30-lap challenge simultaneously, and we'd render ghost cars of all your opponents onto your screen, then we're actually not a million miles away from creating a collision body for each of the opponent cars and turning it into a full-contact online race! I'm probably not going to implement this, but interesting to consider.



If you like my blog, please consider subscribing to the RSS feed or the mailing list: