What Is an OBJ File?
OBJ — Wavefront Object — is one of the oldest and most universally supported 3D file formats. Developed by Wavefront Technologies in the late 1980s for their Advanced Visualizer software, OBJ stores 3D mesh geometry as plain ASCII text. Despite being over 30 years old, OBJ remains a lingua franca of 3D graphics: virtually every 3D modeling, animation, game engine, and rendering application supports it.
Unlike STL (which stores only triangles), OBJ supports:
- Polygons of any number of vertices (triangles, quads, n-gons)
- UV texture coordinates for mapping 2D images onto 3D surfaces
- Vertex normals for smooth shading
- Named groups and objects for organizing mesh components
- Material references via companion
.mtlfiles (colors, textures, reflectance)
OBJ File Structure
An OBJ file is a sequence of keyword-prefixed lines. Lines starting with # are comments. The most important keywords:
| Keyword | Data | Example |
|---|---|---|
v |
Vertex position (x y z, optional w) | v 1.0 2.0 0.5 |
vt |
Texture coordinate (u v, optional w) | vt 0.500 0.750 |
vn |
Vertex normal (x y z) | vn 0.0 1.0 0.0 |
f |
Face — vertex indices (1-based) | f 1 2 3 or f 1/1/1 2/2/2 3/3/3 |
o |
Object name | o Body |
g |
Group name | g left_arm |
usemtl |
Use material from .mtl file | usemtl MetalBlue |
mtllib |
Reference to .mtl file | mtllib scene.mtl |
s |
Smooth shading group (0 = off) | s 1 |
Minimal OBJ: A Simple Triangle
# Simple triangle
o Triangle
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.5 1.0 0.0
f 1 2 3
OBJ with Texture Coordinates and Normals
The face definition f v/vt/vn v/vt/vn v/vt/vn references vertex, texture, and normal indices simultaneously:
# Textured quad (two triangles)
mtllib cube.mtl
o Cube
# Vertices
v -1.0 -1.0 1.0
v 1.0 -1.0 1.0
v 1.0 1.0 1.0
v -1.0 1.0 1.0
# Texture coordinates
vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0
# Vertex normals
vn 0.0 0.0 1.0
usemtl WoodTexture
# Faces: v/vt/vn
f 1/1/1 2/2/1 3/3/1
f 1/1/1 3/3/1 4/4/1
The MTL Material Library
Every OBJ reference to mtllib points to a .mtl file that defines named materials. MTL uses the Phong shading model:
# Material library
newmtl WoodTexture
Ka 0.1 0.1 0.1 # Ambient color (RGB)
Kd 0.8 0.6 0.3 # Diffuse color (RGB) — main visible color
Ks 0.3 0.3 0.3 # Specular color (RGB) — highlights
Ns 32.0 # Specular exponent (shininess: 0–1000)
d 1.0 # Opacity (1.0 = opaque, 0.0 = transparent)
illum 2 # Illumination model (2 = diffuse + specular)
map_Kd wood_grain.png # Diffuse texture map
newmtl ChromeMetal
Ka 0.25 0.25 0.25
Kd 0.4 0.4 0.4
Ks 0.9 0.9 0.9
Ns 200.0
illum 3 # Reflective material
map_Ks chrome_env.png # Specular/environment map
Working with OBJ in Python
Reading OBJ Files with trimesh
pip install trimesh
import trimesh
import numpy as np
def inspect_obj(filepath: str) -> dict:
"""Load and analyze an OBJ file."""
# Load as scene (may contain multiple meshes)
scene = trimesh.load(filepath)
if isinstance(scene, trimesh.Scene):
meshes = list(scene.geometry.values())
print(f"OBJ contains {len(meshes)} mesh(es)")
else:
meshes = [scene]
total_verts = sum(len(m.vertices) for m in meshes)
total_faces = sum(len(m.faces) for m in meshes)
for i, mesh in enumerate(meshes):
print(f"\nMesh {i+1}:")
print(f" Vertices: {len(mesh.vertices)}")
print(f" Faces: {len(mesh.faces)}")
print(f" Watertight: {mesh.is_watertight}")
print(f" Has UV: {mesh.visual.kind == 'texture' if hasattr(mesh, 'visual') else False}")
print(f" Bounds: {mesh.bounds}")
return {'vertices': total_verts, 'faces': total_faces, 'meshes': len(meshes)}
inspect_obj('character.obj')
Creating an OBJ File Programmatically
def write_obj(
filepath: str,
vertices: list,
faces: list,
uvs: list = None,
normals: list = None,
object_name: str = 'MyObject',
mtl_file: str = None,
) -> None:
"""
Write a minimal OBJ file.
Args:
vertices: List of (x, y, z) tuples
faces: List of face tuples — each is a tuple of 1-based vertex indices
uvs: Optional list of (u, v) texture coordinate tuples
normals: Optional list of (nx, ny, nz) vertex normal tuples
"""
with open(filepath, 'w') as f:
f.write(f"# Generated by Python\n")
if mtl_file:
f.write(f"mtllib {mtl_file}\n")
f.write(f"o {object_name}\n\n")
# Write vertices
for vx, vy, vz in vertices:
f.write(f"v {vx:.6f} {vy:.6f} {vz:.6f}\n")
# Write texture coordinates
if uvs:
f.write("\n")
for u, v in uvs:
f.write(f"vt {u:.6f} {v:.6f}\n")
# Write normals
if normals:
f.write("\n")
for nx, ny, nz in normals:
f.write(f"vn {nx:.6f} {ny:.6f} {nz:.6f}\n")
f.write("\n")
# Write faces (1-based indices)
for face in faces:
indices = ' '.join(str(i) for i in face)
f.write(f"f {indices}\n")
# Example: write a simple pyramid
vertices = [
(0.0, 0.0, 0.0), # 1 base
(1.0, 0.0, 0.0), # 2
(1.0, 1.0, 0.0), # 3
(0.0, 1.0, 0.0), # 4
(0.5, 0.5, 1.0), # 5 apex
]
faces = [
(1, 2, 3, 4), # base (quad)
(1, 2, 5), # side triangles
(2, 3, 5),
(3, 4, 5),
(4, 1, 5),
]
write_obj('pyramid.obj', vertices, faces, object_name='Pyramid')
Converting OBJ to Other Formats
import trimesh
def convert_obj(input_path: str, output_path: str) -> None:
"""Convert OBJ to another 3D format."""
scene = trimesh.load(input_path)
# trimesh determines output format from extension:
# .stl, .ply, .glb, .gltf, .dae, .off, .3mf
scene.export(output_path)
import os
size_kb = os.path.getsize(output_path) / 1024
print(f"Converted: {output_path} ({size_kb:.1f} KB)")
# Conversions
convert_obj('model.obj', 'model.stl') # → STL for 3D printing
convert_obj('model.obj', 'model.glb') # → GLB for web/AR/VR
convert_obj('model.obj', 'model.ply') # → PLY for point clouds
OBJ Face Formats: The Three Variants
The face keyword f supports three syntactic variants:
# 1. Vertex only (no UV, no normals)
f 1 2 3
# 2. Vertex + texture coordinate
f 1/1 2/2 3/3
# 3. Vertex + texture coordinate + normal
f 1/1/1 2/2/2 3/3/3
# 4. Vertex + normal (no UV) — double slash
f 1//1 2//2 3//3
Indices are 1-based (not 0-based like most programming languages). Negative indices count from the end of the current vertex list: f -3 -2 -1 references the last three vertices defined.
OBJ Comparison: STL, FBX, glTF
| Format | OBJ | STL | FBX | glTF/GLB |
|---|---|---|---|---|
| Year | 1989 | 1987 | 1996 | 2015 |
| Text-based | Yes | Yes (ASCII) / No (Binary) | No | JSON + binary |
| Materials | .mtl companion file | No | Yes (embedded) | Yes (embedded) |
| UV/textures | Yes | No | Yes | Yes |
| Animations | No | No | Yes (skeletal) | Yes (skeletal + morph) |
| Scene hierarchy | Partial (groups) | No | Yes | Yes |
| File size | Medium | Small | Medium | Small (GLB) |
| 3D printing | Limited (no watertight check) | Primary format | No | No |
| Web/AR/VR | Possible but not ideal | No | No | Optimal |
| Game engines | Universal | Limited | Universal | Universal |
When to use OBJ:
- Maximum compatibility between different 3D software
- Exchanging static mesh data with materials/textures
- Simple assets without animation requirements
- Legacy workflows
When to choose something else:
- Game/app with animations → FBX or glTF
- 3D printing → STL or 3MF
- Web/AR/VR → glTF/GLB (smaller, web-optimized)
- CAD engineering → STEP or IGES
Performance Tips for Large OBJ Files
OBJ files for complex scenes (film VFX, architectural visualization) can reach hundreds of MB. Optimization strategies:
Triangulate faces — Convert quads and n-gons to triangles before export. Triangular meshes load faster because GPUs natively process triangles.
Merge vertices — Many exporters produce duplicate vertices. Merge vertices within a small threshold (e.g., 0.001 mm) to reduce vertex count by 30–50%.
Split large files — OBJ supports multiple objects in one file, but very large single files are slower to parse than multiple smaller files loaded in parallel.
import trimesh
def optimize_obj(input_path: str, output_path: str) -> None:
"""Merge duplicate vertices and triangulate an OBJ mesh."""
mesh = trimesh.load(input_path, force='mesh')
# Merge vertices within 1e-6 distance
mesh.merge_vertices()
# Ensure triangulation
mesh = mesh.triangulate() if hasattr(mesh, 'triangulate') else mesh
# Remove degenerate faces
mesh.remove_degenerate_faces()
mesh.remove_duplicate_faces()
print(f"Optimized: {len(mesh.vertices)} vertices, {len(mesh.faces)} faces")
mesh.export(output_path)
optimize_obj('scene_large.obj', 'scene_optimized.obj')
Related conversions
Frequent conversions across the catalogue: