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
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:
- Creating curve data: bpy.data.curves.new() creates the curve data structure
- Setting dimensions: '3D' allows our curve to exist in three-dimensional space
- Adding a spline: A curve can contain multiple splines (sub-curves)
- NURBS vs Bezier: NURBS curves provide smooth interpolation, perfect for race tracks
- Point coordinates: Each point has (x, y, z, weight) where weight affects influence
- 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)
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}")
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
}
// ...
]
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-
xandyrepresent horizontal position -
zrepresents 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)
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 []
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)
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)
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)
Nice post! Is there a way to simplify/automate the satellite imaging method?
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.