🎮 Przeglądarka szkieletowa 3D OBJ w Go

Kompletny tutorial programowania przeglądarki modeli 3D

📋 W skrócie

W tym tutorialu zbudujemy przeglądarkę szkieletową 3D w Go, która może wczytywać i animować pliki .obj. Nauczysz się podstaw grafiki 3D, parsowania plików, transformacji macierzowych i renderowania w czasie rzeczywistym.

Wymagania wstępne:
  • Podstawowa znajomość programowania w Go
  • Zrozumienie podstaw matematyki (trygonometria)
  • Zainstalowane Go 1.16 lub nowsze

🚀 Rozpoczęcie pracy

1 Konfiguracja projektu

Utwórz nowy katalog i zainicjuj moduł Go:

mkdir 3d-viewer
cd 3d-viewer
go mod init 3d-viewer

2 Instalacja zależności

Użyjemy Ebiten, prostego silnika gier 2D dla Go:

go get github.com/hajimehoshi/ebiten/v2

🏗️ Budowa aplikacji

Pobierz kompletny kod tutaj: main.go lub wykonaj poniższe kroki.

Krok 1: Definiowanie struktur danych

Najpierw zdefiniujmy podstawowe struktury do reprezentacji naszego modelu 3D:

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 reprezentuje punkt 3D lub wektor
type Vec3 struct {
    X, Y, Z float64
}

// Face reprezentuje ścianę wielokąta (lista indeksów wierzchołków)
type Face struct {
    Indices []int
}

// Model przechowuje wszystkie dane geometryczne
type Model struct {
    Vertices []Vec3
    Faces    []Face
}
💡 Zrozumienie struktur:
  • Vec3 - przechowuje współrzędne 3D (X, Y, Z)
  • Face - lista indeksów wierzchołków tworzących wielokąt
  • Model - kontener dla wszystkich wierzchołków i ścian

Krok 2: Wczytywanie plików OBJ

Format .obj to prosty tekstowy format modelu 3D. Oto jak go parsować:

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())
        
        // Pomijaj puste linie i komentarze
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }

        parts := strings.Fields(line)
        if len(parts) == 0 {
            continue
        }

        switch parts[0] {
        case "v": // Definicja wierzchołka
            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": // Definicja ściany
            face := Face{Indices: []int{}}
            for i := 1; i < len(parts); i++ {
                // Obsługa formatu: wierzchołek/tekstura/normalna
                indexStr := strings.Split(parts[i], "/")[0]
                index, _ := strconv.Atoi(indexStr)
                // OBJ używa indeksowania od 1, konwertuj na od 0
                face.Indices = append(face.Indices, index-1)
            }
            model.Faces = append(model.Faces, face)
        }
    }

    return model, scanner.Err()
}
🔍 Format pliku OBJ:
  • v x y z - definiuje wierzchołek na pozycji (x, y, z)
  • f v1 v2 v3 - definiuje trójkątną ścianę używając wierzchołków v1, v2, v3
  • Linie zaczynające się od # to komentarze
  • OBJ używa indeksowania od 1 (konwertujemy na od 0)

Krok 3: Implementacja obrotu wokół osi Y

Aby obrócić punkt wokół osi Y, używamy macierzy obrotu:

Macierz obrotu (oś Y):
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,
    }
}
✨ Dlaczego to działa: Macierz obrotu zachowuje współrzędną Y, jednocześnie obracając współrzędne X i Z po okręgu. Parametr kąta kontroluje jak daleko obrócić.

Krok 4: Projekcja 3D na 2D

Aby wyświetlić punkty 3D na ekranie 2D, potrzebujemy projekcji perspektywicznej:

func project(v Vec3) (float32, float32) {
    // Parametry projekcji perspektywicznej
    scale := 200.0  // Kontroluje rozmiar modelu
    fov := 500.0    // Pole widzenia (odległość do kamery)
    
    // Oblicz współczynnik perspektywy
    factor := fov / (fov + v.Z)
    
    // Projektuj i wycentruj na ekranie
    x := float32(v.X*scale*factor + screenWidth/2)
    y := float32(-v.Y*scale*factor + screenHeight/2)
    
    return x, y
}
📐 Zrozumienie projekcji:
  • scale - powiększa/pomniejsza model
  • fov - wyższe wartości = mniejsze zniekształcenie perspektywy
  • factor - obiekty dalej wyglądają mniejsze
  • Odwracamy Y (ujemne), ponieważ współrzędne ekranu idą w dół, nie w górę

Krok 5: Utworzenie struktury gry

Ebiten używa wzorca pętli gry. Implementujemy interfejs Game:

type Game struct {
    model    *Model
    rotation float64
}

// Update jest wywoływane co klatkę (60 FPS)
func (g *Game) Update() error {
    g.rotation += 0.02  // Prędkość obrotu
    if g.rotation > 2*math.Pi {
        g.rotation -= 2 * math.Pi
    }
    return nil
}

// Layout ustawia rozmiar ekranu gry
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
    return screenWidth, screenHeight
}

Krok 6: Implementacja funkcji rysowania

Tu renderujemy szkielet w każdej klatce:

func (g *Game) Draw(screen *ebiten.Image) {
    // Krok 1: Obróć wszystkie wierzchołki
    rotated := make([]Vec3, len(g.model.Vertices))
    for i, v := range g.model.Vertices {
        rotated[i] = rotateY(v, g.rotation)
    }

    // Krok 2: Narysuj każdą ścianę jako szkielet
    for _, face := range g.model.Faces {
        // Połącz każdy wierzchołek z następnym
        for i := 0; i < len(face.Indices); i++ {
            idx1 := face.Indices[i]
            idx2 := face.Indices[(i+1)%len(face.Indices)]

            // Sprawdzanie granic
            if idx1 >= 0 && idx1 < len(rotated) && 
               idx2 >= 0 && idx2 < len(rotated) {
                
                // Projektuj punkty 3D na 2D
                x1, y1 := project(rotated[idx1])
                x2, y2 := project(rotated[idx2])

                // Narysuj linię między punktami
                vector.StrokeLine(screen, x1, y1, x2, y2, 
                                1, color.White, false)
            }
        }
    }

    // Wyświetl informacje
    ebitenutil.DebugPrint(screen, 
        "Przeglądarka szkieletowa 3D OBJ\nObrót wokół osi Y")
}
🎨 Proces rysowania:
  1. Obróć wszystkie wierzchołki o aktualny kąt
  2. Dla każdej ściany połącz jej wierzchołki liniami
  3. Użyj modulo (%) aby zawinąć i zamknąć wielokąt
  4. Projektuj każdy punkt 3D na współrzędne 2D ekranu
  5. Rysuj linie między kolejnymi wierzchołkami

Krok 7: Funkcja główna

Na koniec połącz wszystko razem:

func main() {
    // Sprawdź argumenty linii poleceń
    if len(os.Args) < 2 {
        log.Fatal("Użycie: go run main.go ")
    }

    // Wczytaj plik OBJ
    model, err := loadOBJ(os.Args[1])
    if err != nil {
        log.Fatalf("Nie udało się wczytać pliku OBJ: %v", err)
    }

    fmt.Printf("Wczytano model: %d wierzchołków, %d ścian\n", 
               len(model.Vertices), len(model.Faces))

    // Konfiguruj okno
    ebiten.SetWindowSize(screenWidth, screenHeight)
    ebiten.SetWindowTitle("Przeglądarka szkieletowa 3D OBJ")

    // Utwórz instancję gry
    game := &Game{
        model:    model,
        rotation: 0,
    }

    // Uruchom pętlę gry
    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

🎯 Uruchamianie aplikacji

1 Utwórz testowy plik OBJ

Utwórz prosty sześcian w cube.obj:

# Wierzchołki sześcianu
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

# Ściany sześcianu
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 Uruchom program

go run main.go cube.obj

Jeżeli wolisz, użyj naszego ślicznego dinozaura: dino.obj

🔧 Pomysły na dostosowanie

Dostosuj prędkość obrotu

W funkcji Update(), zmień przyrost:

g.rotation += 0.05  // Szybszy obrót

Dodaj wiele osi obrotu

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,
    }
}

// W Draw(), zastosuj oba obroty:
rotated[i] = rotateY(rotateX(v, g.rotation*0.5), g.rotation)

Zmień kolor szkieletu

// Użyj różnych kolorów w zależności od głębokości
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)

📚 Wyjaśnienie kluczowych koncepcji

System współrzędnych 3D

Macierze obrotu

Macierze obrotu pozwalają nam obracać punkty w przestrzeni 3D, zachowując odległości. Każda oś ma własny wzór macierzy obrotu wyprowadzony z trygonometrii.

Projekcja perspektywiczna

Konwertuje współrzędne 3D na 2D przez dzielenie przez głębokość (Z). Obiekty dalej wydają się mniejsze, tworząc realistyczne wrażenie głębi.

⚠️ Częste pułapki:
  • Indeksy OBJ są od 1, tablice od 0
  • Współrzędne Y ekranu rosną w dół
  • Dzielenie przez zero gdy Z = -fov
  • Indeksy ścian mogą wykraczać poza granice

🎓 Następne kroki