DEV Community

Cover image for Building 3D Race Tracks in Blender from Real F1 Telemetry Data
Mathis Le Bonniec
Mathis Le Bonniec

Posted on • Edited on

Building 3D Race Tracks in Blender from Real F1 Telemetry Data

Race tracks are some of the most challenging and rewarding subjects to model in 3D. Unlike flat circuits, real-world tracks feature complex elevation changes, banking, and precise geometries that define the character of each corner and straight. Whether you're creating visualizations for motorsport analysis, building assets for simulation games, or simply exploring the intersection of data and 3D art, accurately modeling a race track with elevation presents a unique technical challenge.

In this tutorial, we'll explore a data-driven approach to creating a 3D race track in Blender using Python scripting and real-world telemetry data. We'll start by understanding the basics of Blender's Python API and how to create curves programmatically. Then we'll leverage real telemetry data from the OpenF1 API to generate an accurate, elevation-mapped representation of an actual Formula 1 circuit. Finally, we'll refine our model using satellite imagery to ensure it matches reality.

What makes this workflow particularly powerful is the combination of automation and precision. Real-world data provides the foundation of accuracy, scripting enables rapid generation and iteration, and satellite imagery validation ensures the final result is both mathematically correct and visually authentic. By the end of this tutorial, you'll be able to transform raw GPS coordinates and elevation data into a fully realized 3D track model.

Prerequisites:

  • Basic familiarity with Blender's interface and navigation
  • Understanding of curves and mesh objects in Blender
  • Basic Python knowledge
  • Blender 3.0 or higher installed

Let's get started by exploring Blender's scripting capabilities—understanding these fundamentals will be essential for working with real track data.

Scripting Basics: Modeling a Line with Python in Blender

Before we dive into real-world track data, let's familiarize ourselves with Blender's Python API by creating a simple curved line programmatically. Understanding these fundamentals will make working with complex telemetry data much more manageable.

Accessing Blender's Python Console

Blender comes with a built-in Python console and scripting workspace. To access it:

  • Switch to the Scripting workspace (top menu bar)
  • You'll see a Text Editor panel where you can write scripts
  • The Python Console at the bottom allows for interactive testing

Alternatively, you can run scripts directly from the your system's terminal using Blender's command-line interface.

It's better to run scripts from the Terminal if you want to see print outputs and debug information.

  • macOS /Applications/Blender.app/Contents/MacOS/Blender --background --python /path/to/your/script.py
  • Windows blender.exe --background --python C:\path\to\your\script.py
  • Linux blender --background --python /path/to/your/script.py

Understanding Blender's Data Structure

Blender organizes everything through bpy (Blender Python). The key modules we'll use are:

  • bpy.data - Access to Blender's internal data (objects, meshes, curves, materials)
  • bpy.ops - Operators that perform actions (like add, delete, transform)
  • bpy.context - Current state and active objects

Creating a Simple Curve

Let's start with a basic example, creating a curve with a few control points:

import bpy
import math

# Clear existing curves (optional, for clean testing)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()

# Create a new curve object
curve_data = bpy.data.curves.new(name="SimpleCurve", type='CURVE')
curve_data.dimensions = '3D'

# Create a spline (the actual curve path)
spline = curve_data.splines.new(type='NURBS')

# Define some points for our curve
points = [
    (0, 0, 0),
    (2, 1, 0.5),
    (4, 0, 1),
    (6, -1, 0.5),
    (8, 0, 0)
]

# Add points to the spline (NURBS need n-1 additional points)
spline.points.add(len(points) - 1)

# Set the coordinates for each point
for i, point in enumerate(points):
    x, y, z = point
    spline.points[i].co = (x, y, z, 1)  # The 4th value is the weight

# Create an object from the curve data and link it to the scene
curve_object = bpy.data.objects.new("SimpleCurve", curve_data)
bpy.context.collection.objects.link(curve_object)

# Make the curve smooth
spline.use_smooth = True
Enter fullscreen mode Exit fullscreen mode

Copy this into Blender's Text Editor and press Run Script (or Alt+P). You should see a smooth curved line appear in your viewport.

Understanding the Code

Let's break down what's happening:

  1. Creating curve data: bpy.data.curves.new() creates the curve data structure
  2. Setting dimensions: '3D' allows our curve to exist in three-dimensional space
  3. Adding a spline: A curve can contain multiple splines (sub-curves)
  4. NURBS vs Bezier: NURBS curves provide smooth interpolation, perfect for race tracks
  5. Point coordinates: Each point has (x, y, z, weight) where weight affects influence
  6. Linking to scene: The curve must be added to the collection to be visible

Creating a Closed Loop

For a race track, we need a closed circuit. Let's modify our script:

import bpy
import math

# Clear the scene
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()

# Create curve
curve_data = bpy.data.curves.new(name="ClosedLoop", type='CURVE')
curve_data.dimensions = '3D'

spline = curve_data.splines.new(type='NURBS')

# Create a circular pattern of points
num_points = 12
radius = 5

points = []
for i in range(num_points):
    angle = (i / num_points) * 2 * math.pi
    x = radius * math.cos(angle)
    y = radius * math.sin(angle)
    z = math.sin(angle * 3) * 0.5  # Add some elevation variation
    points.append((x, y, z))

# Add points to spline
spline.points.add(len(points) - 1)

for i, point in enumerate(points):
    x, y, z = point
    spline.points[i].co = (x, y, z, 1)

# Make it a closed loop
spline.use_cyclic_u = True

# Create object and link to scene
curve_object = bpy.data.objects.new("ClosedLoop", curve_data)
bpy.context.collection.objects.link(curve_object)
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

You now understand how to:

  • Create curves programmatically in Blender
  • Define 3D points in space
  • Close a curve to create a circuit
  • Add visual properties to curves

These concepts form the foundation for working with real track data. In the next section, we'll obtain actual GPS coordinates and elevation data from Formula 1 races, which we'll transform into a realistic circuit using these same techniques.

Acquiring Real-World Track Data

Now that we understand Blender's scripting fundamentals, it's time to work with real data. We'll use the OpenF1 API to retrieve actual telemetry from Formula 1 races, including GPS coordinates and elevation data that will form the foundation of our 3D track model.

The OpenF1 API (https://openf1.org) provides free access to real-time and historical Formula 1 data, including:

  • Driver positions (latitude, longitude)
  • Car telemetry (speed, RPM, gear, throttle)
  • Session information (practice, qualifying, race)
  • Timing data

For our purposes, we need the position data, which includes GPS coordinates that we can use to recreate the track layout.

The OpenF1 API uses RESTful endpoints. The key endpoint for position data is https://api.openf1.org/v1/location. As it returns a large dataset, we need to filter it based on specific sessions and drivers.

  • session_key - Identifies a specific session (e.g., "9158" for a particular race)
  • driver_number - The driver's number (e.g., "16" for Charles Leclerc)
  • date - Timestamp filters

Fetching Data: Method 1 - API Call

You can retrieve data directly using Python's requests library. Here's a script to fetch track data:

import requests
import json

# Define the API endpoint
URL = "https://api.openf1.org/v1/location"

# Parameters for our request
PARAMS = {
    "session_key": 9094,  # Example: 2023 Monaco GP
    "driver_number": 1,    # Example: Driver #1
    "date>": "2023-05-28T13:05:00.000Z",  # Lap start time
    "date<": "2023-05-28T13:06:20.000Z"   # Lap end time (roughly 1:20 lap)
}

# Make the API request
response = requests.get(URL, params=PARAMS)

# Check if successful
if response.status_code == 200:
    data = response.json()
    print(data)
    print(f"Retrieved {len(data)} data points")
else:
    print(f"Error: {response.status_code}")
Enter fullscreen mode Exit fullscreen mode

Fetching Data: Method 2 - Pre-Downloaded JSON

For simplicity and to avoid API rate limits, you can download the data once and save it as a JSON file. Here's what the data structure looks like:

[
  {
    "date": "2023-05-28T13:00:01.234Z",
    "driver_number": 1,
    "meeting_key": 1234,
    "session_key": 9158,
    "x": 245,
    "y": -50,
    "z": 78
  },
  {
    "date": "2023-05-28T13:00:01.334Z",
    "driver_number": 1,
    "meeting_key": 1234,
    "session_key": 9158,
    "x": 248,
    "y": -48,
    "z": 78
  }
  // ...
]
Enter fullscreen mode Exit fullscreen mode

Each data point contains:

  • date: Timestamp of the measurement
  • driver_number: Which driver this data belongs to
  • session_key: Identifies the specific session
  • x, y, z: Coordinates in the track's local coordinate system
    • x and y represent horizontal position
    • z represents elevation/altitude

Note: The coordinates are relative to the track's origin point, not global GPS coordinates like latitude/longitude or even distance in meters. We'll need to interpret these correctly when building our 3D model.

Alternative Data Sources

If you don't want to use the OpenF1 API, other options include:

  • Manual GPS logging: Drive/walk the track with a GPS device
  • Racing games: Some games allow telemetry export (e.g., Assetto Corsa, iRacing)
  • Google Earth: Extract coordinates from the path tool
  • OpenStreetMap: Some tracks are mapped with elevation data

The key is having a JSON file with coordinate points that we can load into Blender.

Building the Track: Python Script for Closed Circuit Generation

Now comes the exciting part—transforming our real-world telemetry data into a 3D track model in Blender. We'll create a Python script that reads our JSON data, processes the coordinates, and generates a smooth, closed circuit with accurate elevation.

Setting Up the Script in Blender

Open Blender and switch to the Scripting workspace. Create a new text file (Text → New) and let's start building our script step by step.

Loading the JSON Data

First, we need to read our processed lap data. Here's the foundation of our script:

The OpenF1 data uses a local track coordinate system. F1 tracks are typically 3-6 km long, which would be huge in Blender's default scale. We'll add a scale factor. In this example, we'll use a scale of 1:150 (i.e., divide coordinates by 150) to keep the model manageable.

Creating the Track Curve

Here's the complete function to create a curve from our points:

import bpy

def create_curve(points: list[tuple[float, float, float]], name: str = "F1Track"):
    curve_data = bpy.data.curves.new(name + "Path", type="CURVE")
    curve_data.dimensions = "3D"

    curve_data.bevel_depth = 0.01
    curve_data.bevel_resolution = 4

    spline = curve_data.splines.new('BEZIER')
    spline.bezier_points.add(len(points) - 1)

    for i, coordinates in enumerate(points):
        point = spline.bezier_points[i]
        point.co = coordinates
        point.handle_left_type = 'AUTO'
        point.handle_right_type = 'AUTO'

    curve_obj = bpy.data.objects.new(name + "Object", curve_data)
    bpy.context.collection.objects.link(curve_obj)
Enter fullscreen mode Exit fullscreen mode

This function will read a list of (x, y, z) tuples, create a NURBS curve, and add each point to the spline.

We'll start by modifying our OpenF1 API call to load the data in a function and pass it to create_curve.

import requests
import json

# Define the API endpoint
URL = "https://api.openf1.org/v1/location"
PARAMS = {
    "session_key": 9094,  # Example: 2023 Monaco GP
    "driver_number": 1,    # Example: Driver #1
    "date>": "2023-05-28T13:05:00.000Z",  # Lap start time
    "date<": "2023-05-28T13:06:20.000Z"   # Lap end time (roughly 1:20 lap)
}

def get_locations(url: str, params: dict[str, any]) -> list[tuple[int, int, int]]:
    # Make the API request
    response = requests.get(url, params=params)

    # Check if successful
    if response.status_code == 200:
        return response.json()
    else:
        return []
Enter fullscreen mode Exit fullscreen mode

Now it's time to integrate everything and generate our track.
We'll call get_locations to fetch the data, extract the coordinates in the points array and pass them to create_curve function.

TRACK_SCALE: int = 150

locations = get_locations(URL, PARAMS)
points = [(
    value["x"] / TRACK_SCALE,
    value["y"] / TRACK_SCALE,
    value["z"] / TRACK_SCALE
) for value in locations]

create_curve(points)
Enter fullscreen mode Exit fullscreen mode

Creating the Track Mesh

Now, what if we used a mesh instead of a curve? A mesh allows for more complex geometry, such as banking and varying widths. Here's a simplified version of how to create a mesh track:

import bpy, bmesh
from mathutils import Vector

TRACK_WIDTH: int = 0.65
TRACK_HEIGHT: int = 0.05

def create_mesh(points: list[tuple[float, float, float]], width, name="F1TrackMesh"):
    # Create a new mesh and object, then link it to the current collection
    mesh = bpy.data.meshes.new(name)
    obj = bpy.data.objects.new(name, mesh)
    bpy.context.collection.objects.link(obj)

    # Create a BMesh for procedural geometry construction
    bm = bmesh.new()

    n = len(points)
    verts_left = []
    verts_right = []

    for i in range(n):
        # Current, previous, and next points (looping for a closed track)
        p_curr = Vector(points[i])
        p_prev = Vector(points[i - 1]) if i > 0 else Vector(points[-1])
        p_next = Vector(points[(i + 1) % n])

        # Compute the averaged tangent direction using previous and next segments
        # This smooths the direction changes along the track
        tangent = (p_next - p_curr).normalized() + (p_curr - p_prev).normalized()
        tangent.normalize()

        # Up vector (assuming the track lies mostly in the XY plane)
        up = Vector((0, 0, 1))

        # Compute the lateral normal of the ribbon:
        # perpendicular to the tangent, lying in the XY plane
        normal = tangent.cross(up).normalized()

        # Offset points to the left and right to give the track its width
        left_point = p_curr + normal * (width / 2)
        right_point = p_curr - normal * (width / 2)

        verts_left.append(bm.verts.new(left_point))
        verts_right.append(bm.verts.new(right_point))

    bm.verts.ensure_lookup_table()

    # Create the top faces of the ribbon (one quad per segment)
    faces_top = []
    for i in range(n):
        v1 = verts_left[i]
        v2 = verts_right[i]
        v3 = verts_right[(i + 1) % n]
        v4 = verts_left[(i + 1) % n]
        faces_top.append(bm.faces.new([v1, v2, v3, v4]))

    # Extrude the top faces downward to give the track thickness
    ret = bmesh.ops.extrude_face_region(bm, geom=faces_top)
    geom_extrude = ret['geom']

    # Extract only the vertices created by the extrusion
    verts_extrude = [ele for ele in geom_extrude if isinstance(ele, bmesh.types.BMVert)]

    # Move the extruded vertices downward along Z to form the track body
    for v in verts_extrude:
        v.co.z -= TRACK_HEIGHT

    # Recalculate normals to ensure correct shading
    bmesh.ops.recalc_face_normals(bm, faces=faces_top)

    # (Optional / questionable) Creates a face from all left-side vertices
    # This may produce invalid geometry if the vertices are not coplanar
    bm.faces.new(verts_left)

    # Write the BMesh into the actual mesh datablock
    bm.to_mesh(mesh)
    bm.free()

    return obj

create_mesh(points, width=TRACK_WIDTH)
Enter fullscreen mode Exit fullscreen mode

You can see the result of the mesh track, and look at the elevation!

Top View

Side View

Fixing Track Twists

If you notice any twists or irregularities in the track mesh, it may be due to abrupt changes in direction or elevation in the source data. It can be helpful to smooth the input points before generating the mesh. You can implement a simple moving average filter or use more advanced smoothing algorithms to refine the point data.

We'll for now just fix the mesh by removing duplicated vertices that could cause issues.

Broken Mesh

Fixing Mesh

Fixed Mesh

Adding Visual Properties

Now that we have our curve, we can enhance its appearance by adding a bevel to give it thickness, simulating the track surface.

Refinement: Enhancing Accuracy with Satellite Imagery

Now that we have a basic 3D model of the race track, it's up to you to refine and enhance its accuracy. One effective method is to use satellite imagery as a reference to ensure that the track layout, elevation changes, and surrounding environment closely match reality.

You can also add materials, textures, models of curbs, barriers, and other trackside details to bring your model to life.

Conclusion

In this tutorial, we've explored how to create a 3D race track in Blender using real-world telemetry data from the OpenF1 API. By leveraging Python scripting, we automated the process of generating a smooth, closed circuit with accurate elevation changes. This data-driven approach not only ensures precision but also allows for rapid iteration and customization. With the foundation laid out here, you can now experiment with different tracks, refine your models using satellite imagery, and add intricate details to create stunning visualizations or assets for simulation games. Happy modeling!

Top comments (2)

Collapse
 
truprequau profile image
Joube

Nice post! Is there a way to simplify/automate the satellite imaging method?

Collapse
 
mlbonniec profile image
Mathis Le Bonniec

Hello, thank you! You could use a plugin such as Blender GIS, that'll allow you to get very precise satellite images. If you position the view from the top, you should be able to model the track width based on the roads on the images.