Building Procedural Tracks: Splines, Extrusions, and Loops

Building Procedural Tracks: Splines, Extrusions, and Loops

I’ve been working on an arcade racing game that contains its own level editor to allow for sharable user-generated tracks!

I am still working on some personal projects at the time, mostly to learn new subjects and techniques, but I wanted to update this site with something that’s been occupying a lot of my time in the meantime.

It ain’t pretty, but this is what too many hours of work looks like

A wide shot of a completed track showing a curve leading into a vertical loop

Now I realize this doesn’t look like much with very little context, but this is what hours of work looks like.

Originally I wanted to create an arcade racing game that uses blendshapes tied to physics (lean left to deform a blendshape, squash, stretch, etc). More on that implementation some other time, but for now I just wanted to yap a bit about how I handled the creation of tracks and loops programmatically in Godot 4.6.

I’ve decided to bench this project in the meantime, though I might revisit it later if I’m feeling inspired to. I’ve learned quite a bit and it was an excellent test project to get the hang of working with 3D-space and using Godot.

At first I was going to model individual track pieces but I decided to go with another solution after playing the F-Zero X Expansion for the N64DD (the track creator specifically!). I really liked how quickly you were able to lay down a track using points on a grid so I (mistakenly) thought this would be a fun way to approach custom track creation.

This post goes over a quick rundown on how I’m building out race track meshes dynamically using splines and extrusion.

Drawing the Path: Splines

A top-down view of a line passing exactly through several points

A spline is a mathematical curve drawn through a series of points. These points are referred to as control points. You place the points in the 3D world, and the computer connects them smoothly to form a path.

Game engines usually default to Bezier curves. Bezier curves use extra handles attached to the points to bend the line. For creating racetracks I’ve found that a Catmull-Rom spline is usually better.

A Catmull-Rom spline guarantees that the curve passes exactly through every single control point you place. To find the direction the track is pointing (the tangent) at any spot along the curve, the math looks at the point behind it and the point ahead of it. This allows us to predict exactly where the road will go without adjusting extra handles.

If we have points P0, P1, P2, and P3, the position between P1 and P2 is found by stepping a value (t) from 0 to 1 using this equation:

P(t) = 0.5 * (
    (2 * P1) +
    (-P0 + P2) * t +
    (2 * P0 - 5 * P1 + 4 * P2 - P3) * t^2 +
    (-P0 + 3 * P1 - 3 * P2 + P3) * t^3
)

Building the Road: CSG Extrusion

Once you have a path, you need to build the actual road model over it. You can do this using CSG, which stands for Constructive Solid Geometry. It is a tool that builds complex 3D shapes from simpler rules.

The specific method used here is called extrusion or sweeping. You draw a flat 2D shape of your road. This is the profile or cross-section, showing the width of the road and the height of the curbs. Then, you tell the engine to drag that flat shape along the entire length of your spline. The engine fills in the geometry to create a solid 3D road.

In Godot, you handle this with a CSGPolygon3D node set to follow a path.

# Required by CSGPolygon3D to determine the shape of the 3D extrusion
func _build_profile() -> PackedVector2Array:
    return PackedVector2Array([
        Vector2(-HALF_WIDTH, 0.0),
        Vector2(-HALF_WIDTH, LIP_HEIGHT),
        Vector2(-HALF_WIDTH - LIP_WIDTH, LIP_HEIGHT),
        Vector2(-HALF_WIDTH - LIP_WIDTH, -LIP_UNDERHANG),
        Vector2(HALF_WIDTH + LIP_WIDTH, -LIP_UNDERHANG),
        Vector2(HALF_WIDTH + LIP_WIDTH, LIP_HEIGHT),
        Vector2(HALF_WIDTH, LIP_HEIGHT),
        Vector2(HALF_WIDTH, 0.0)
    ])

The Problem with Vertical Loops

A broken 3D loop that twists violently and flattens at the very top

Extrusion works great for flat roads or slight hills. It breaks completely when you try to make a full 360-degree vertical loop.

To build the road along the path, the engine needs to know which way is “up” so it keeps the road facing the sky. Usually, it uses a global up direction. When the track goes completely vertical in a loop, the path’s forward direction aligns exactly with the global up direction. The math fails to calculate the angle, causing a problem called gimbal lock. The resulting 3D mesh will twist and attempt to become a 4th dimensional sentient being with its own will or pinch completely flat at the top of the loop.

Fixing Loops with Math

A smooth loop mesh perfectly connected to a standard road segment

To fix this, you stop using the standard CSG path-following for the loop sections. You generate the loop geometry manually using mathematical equations.

Instead of a spline, a loop is built mathematically as a cylinder. To stop the track from crashing into itself when the loop finishes, you add a lateral offset. As the loop goes around, the track shifts slightly to the side so the exit clears the entrance.

To transition this lateral offset smoothly, we use a smootherstep function. This takes a value t (from 0 to 1) and curves it so the movement starts slow, speeds up in the middle, and ends slow, preventing jerky track shifts.

smootherstep(t) = 6 * t^5 - 15 * t^4 + 10 * t^3

To calculate the positions, you use a parametric equation. This is an equation where the 3D coordinates (X, Y, and Z) are calculated based on a single changing value. Here, that value is a step going from 0 at the start of the loop to 1 at the end:

X(t) = HalfOffset - (TotalOffset * smootherstep(t))
Y(t) = Radius * (1 - cos(2 * PI * t))
Z(t) = Radius * sin(2 * PI * t)

You also fix the “up” direction problem. Instead of relying on a global up vector, you tell the math that “up” for the loop is always pointing toward the exact center of the circle. This is called a centripetal orientation. We calculate the exact forward direction (tangent) using the derivative of our positions, and the up direction by pointing back at the circle’s center.

To get the accurate forward direction, we need the derivative of the smootherstep function. This tells us exactly how fast the lateral offset is changing at any given point:

smootherstep_deriv(t) = 30 * t^4 - 60 * t^3 + 30 * t^2
# Avoids spline tangent errors by determining direction analytically
var dpx := -LATERAL_OFFSET * _smootherstep_deriv(t)
var dpy := radius * TAU * sin(angle)
var dpz := radius * TAU * cos(angle)
var tangent := Vector3(dpx, dpy, dpz).normalized()

# Prevents gimbal lock at the loop apex by using a centripetal reference
var up := Vector3(0.0, radius - py, -pz).normalized()

var right := tangent.cross(up).normalized()
up = right.cross(tangent).normalized()

Constructing the Mesh

In Godot, the SurfaceTool allows you to define points in 3D space and link them together into solid triangles.

# build raw triangle geometry
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)

for i in LOOP_SEGMENTS:
    for j in pcount:
        var jn := (j + 1) % pcount
        var a := sections[i][j]
        var b := sections[i][jn]
        var c := sections[i + 1][j]
        var d := sections[i + 1][jn]
        
        st.add_vertex(a)
        st.add_vertex(b)
        st.add_vertex(d)
        st.add_vertex(a)
        st.add_vertex(d)
        st.add_vertex(c)

st.generate_normals()
var mesh := st.commit()

Gameplay Considerations: Curb Height

If you’re using curbs on the sides of the tracks to create walls you might run into an issue with generating loops.

When driving through a loop, gravity is no longer holding the vehicle to the floor. The vehicle’s speed and the loop’s curve generate centripetal force to keep the car glued to the track.

Because of this physical change, the tall curbs used on the rest of the track become a hazard. If a car’s wheels catch the inside curb while driving upside down, the bump can easily eject the car from the track. We resolve this by shrinking the curb height specifically in the loop generation profile.

# Reduces lip height to prevent vehicles from catching their wheels on the inside of the loop
var lh := SplineRoad.LIP_HEIGHT * 0.3
var lu := SplineRoad.LIP_UNDERHANG * 0.3

You generate this mathematical loop as a static mesh and then connect your standard CSG tracks to its entrance and exit ramps.

This is a gross oversimplification of how to achieve this, but I hope this helps someone in the future in some way shape or form.

If you made it this far, you are worthy enough to see my ugly work-in-progress race-track editor for this experiment.


Written for Godot 4.6