🎮 3D OBJ Wireframe Viewer in Go

A Complete Tutorial for Building a Rotating 3D Model Viewer

📋 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.

Prerequisites:
  • 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
}
💡 Understanding the structures:
  • 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()
}
🔍 OBJ File Format:
  • 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:

Rotation Matrix (Y-axis):
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,
    }
}
✨ Why this works: The rotation matrix preserves the Y-coordinate while rotating X and Z coordinates in a circle. The angle parameter controls how far to rotate.

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
}
📐 Understanding Projection:
  • 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")
}
🎨 Drawing Process:
  1. Rotate all vertices by the current angle
  2. For each face, connect its vertices with lines
  3. Use modulo (%) to wrap around and close the polygon
  4. Project each 3D point to 2D screen coordinates
  5. 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

2 Run the Program

go run main.go cube.obj

You can also use our cute dino if you prefer: dino.obj

🔧 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

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.

⚠️ Common Pitfalls:
  • 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