📋 Overview
In this tutorial, we'll build a 3D wireframe viewer in Go that can load and animate .obj files. You'll learn about 3D graphics fundamentals, file parsing, matrix transformations, and real-time rendering.
- Basic Go programming knowledge
- Understanding of basic mathematics (trigonometry)
- Go 1.16 or later installed
🚀 Getting Started
1 Project Setup
Create a new directory and initialize a Go module:
mkdir 3d-viewer
cd 3d-viewer
go mod init 3d-viewer
2 Install Dependencies
We'll use Ebiten, a simple 2D game engine for Go:
go get github.com/hajimehoshi/ebiten/v2
🏗️ Building the Application
Download the complete code here: main.go or follow the steps below.
Step 1: Define Data Structures
First, let's define the basic structures to represent our 3D model:
package main
import (
"bufio"
"fmt"
"image/color"
"log"
"math"
"os"
"strconv"
"strings"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
)
const (
screenWidth = 800
screenHeight = 600
)
// Vec3 represents a 3D point or vector
type Vec3 struct {
X, Y, Z float64
}
// Face represents a polygon face (list of vertex indices)
type Face struct {
Indices []int
}
// Model holds all the geometry data
type Model struct {
Vertices []Vec3
Faces []Face
}
- Vec3 - Stores 3D coordinates (X, Y, Z)
- Face - Lists vertex indices that form a polygon
- Model - Container for all vertices and faces
Step 2: Load OBJ Files
The .obj format is a simple text-based 3D model format. Here's how to parse it:
func loadOBJ(filename string) (*Model, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
model := &Model{
Vertices: []Vec3{},
Faces: []Face{},
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Fields(line)
if len(parts) == 0 {
continue
}
switch parts[0] {
case "v": // Vertex definition
if len(parts) >= 4 {
x, _ := strconv.ParseFloat(parts[1], 64)
y, _ := strconv.ParseFloat(parts[2], 64)
z, _ := strconv.ParseFloat(parts[3], 64)
model.Vertices = append(model.Vertices, Vec3{x, y, z})
}
case "f": // Face definition
face := Face{Indices: []int{}}
for i := 1; i < len(parts); i++ {
// Handle format: vertex/texture/normal
indexStr := strings.Split(parts[i], "/")[0]
index, _ := strconv.Atoi(indexStr)
// OBJ uses 1-based indexing, convert to 0-based
face.Indices = append(face.Indices, index-1)
}
model.Faces = append(model.Faces, face)
}
}
return model, scanner.Err()
}
- v x y z - Defines a vertex at position (x, y, z)
- f v1 v2 v3 - Defines a triangular face using vertices v1, v2, v3
- Lines starting with # are comments
- OBJ uses 1-based indexing (we convert to 0-based)
Step 3: Implement Y-Axis Rotation
To rotate a point around the Y-axis, we use a rotation matrix:
x' = x·cos(θ) + z·sin(θ)
y' = y
z' = -x·sin(θ) + z·cos(θ)
func rotateY(v Vec3, angle float64) Vec3 {
cos := math.Cos(angle)
sin := math.Sin(angle)
return Vec3{
X: v.X*cos + v.Z*sin,
Y: v.Y,
Z: -v.X*sin + v.Z*cos,
}
}
Step 4: Project 3D to 2D
To display 3D points on a 2D screen, we need perspective projection:
func project(v Vec3) (float32, float32) {
// Perspective projection parameters
scale := 200.0 // Controls model size
fov := 500.0 // Field of view (distance to camera)
// Calculate perspective factor
factor := fov / (fov + v.Z)
// Project and center on screen
x := float32(v.X*scale*factor + screenWidth/2)
y := float32(-v.Y*scale*factor + screenHeight/2)
return x, y
}
- scale - Makes the model larger/smaller
- fov - Higher values = less perspective distortion
- factor - Objects farther away appear smaller
- We flip Y (negative) because screen coordinates go down, not up
Step 5: Create the Game Structure
Ebiten uses a game loop pattern. We implement the Game interface:
type Game struct {
model *Model
rotation float64
}
// Update is called every frame (60 FPS)
func (g *Game) Update() error {
g.rotation += 0.02 // Rotation speed
if g.rotation > 2*math.Pi {
g.rotation -= 2 * math.Pi
}
return nil
}
// Layout sets the game screen size
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenWidth, screenHeight
}
Step 6: Implement the Draw Function
This is where we render the wireframe each frame:
func (g *Game) Draw(screen *ebiten.Image) {
// Step 1: Rotate all vertices
rotated := make([]Vec3, len(g.model.Vertices))
for i, v := range g.model.Vertices {
rotated[i] = rotateY(v, g.rotation)
}
// Step 2: Draw each face as a wireframe
for _, face := range g.model.Faces {
// Connect each vertex to the next
for i := 0; i < len(face.Indices); i++ {
idx1 := face.Indices[i]
idx2 := face.Indices[(i+1)%len(face.Indices)]
// Bounds checking
if idx1 >= 0 && idx1 < len(rotated) &&
idx2 >= 0 && idx2 < len(rotated) {
// Project 3D points to 2D
x1, y1 := project(rotated[idx1])
x2, y2 := project(rotated[idx2])
// Draw line between points
vector.StrokeLine(screen, x1, y1, x2, y2,
1, color.White, false)
}
}
}
// Display info
ebitenutil.DebugPrint(screen,
"3D OBJ Wireframe Viewer\nRotating around Y-axis")
}
- Rotate all vertices by the current angle
- For each face, connect its vertices with lines
- Use modulo (%) to wrap around and close the polygon
- Project each 3D point to 2D screen coordinates
- Draw lines between consecutive vertices
Step 7: Main Function
Finally, tie everything together:
func main() {
// Check command line arguments
if len(os.Args) < 2 {
log.Fatal("Usage: go run main.go ")
}
// Load the OBJ file
model, err := loadOBJ(os.Args[1])
if err != nil {
log.Fatalf("Failed to load OBJ file: %v", err)
}
fmt.Printf("Loaded model: %d vertices, %d faces\n",
len(model.Vertices), len(model.Faces))
// Configure window
ebiten.SetWindowSize(screenWidth, screenHeight)
ebiten.SetWindowTitle("3D OBJ Wireframe Viewer")
// Create game instance
game := &Game{
model: model,
rotation: 0,
}
// Start game loop
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
🎯 Running the Application
1 Create a Test OBJ File
Create a simple cube in cube.obj:
# 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
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
# Cube faces
f 1 2 3 4
f 5 6 7 8
f 1 2 6 5
f 2 3 7 6
f 3 4 8 7
f 4 1 5 8
🔧 Customization Ideas
Adjust Rotation Speed
In the Update() function, change the increment:
g.rotation += 0.05 // Faster rotation
Add Multiple Rotation Axes
func rotateX(v Vec3, angle float64) Vec3 {
cos := math.Cos(angle)
sin := math.Sin(angle)
return Vec3{
X: v.X,
Y: v.Y*cos - v.Z*sin,
Z: v.Y*sin + v.Z*cos,
}
}
// In Draw(), apply both rotations:
rotated[i] = rotateY(rotateX(v, g.rotation*0.5), g.rotation)
Change Wireframe Color
// Use different colors based on depth
c := color.RGBA{
R: uint8(128 + rotated[idx1].Z*50),
G: uint8(128 + rotated[idx1].Z*50),
B: 255,
A: 255,
}
vector.StrokeLine(screen, x1, y1, x2, y2, 1, c, false)
📚 Key Concepts Explained
3D Coordinate System
- X-axis: Left (-) to Right (+)
- Y-axis: Down (-) to Up (+)
- Z-axis: Away (-) to Toward (+) camera
Rotation Matrices
Rotation matrices allow us to rotate points in 3D space while preserving distances. Each axis has its own rotation matrix formula derived from trigonometry.
Perspective Projection
Converts 3D coordinates to 2D by dividing by depth (Z). Objects further away appear smaller, creating realistic depth perception.
- OBJ indices are 1-based, arrays are 0-based
- Screen Y-coordinates increase downward
- Division by zero when Z = -fov
- Face indices may be out of bounds
🎓 Next Steps
- Add mouse controls for rotation
- Implement backface culling for performance
- Add lighting and shading
- Support textured models
- Implement zoom and pan controls
- Add multiple models to the scene
