📋 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.
- 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
}
- 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()
}
- 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:
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,
}
}
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
}
- 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")
}
- Obróć wszystkie wierzchołki o aktualny kąt
- Dla każdej ściany połącz jej wierzchołki liniami
- Użyj modulo (%) aby zawinąć i zamknąć wielokąt
- Projektuj każdy punkt 3D na współrzędne 2D ekranu
- 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
🔧 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
- Oś X: Lewa (-) do Prawej (+)
- Oś Y: Dół (-) do Góry (+)
- Oś Z: Od (-) do Ku (+) kamerze
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.
- 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
- Dodaj sterowanie myszką dla obrotu
- Zaimplementuj usuwanie tylnych ścian dla wydajności
- Dodaj oświetlenie i cieniowanie
- Obsługa modeli z teksturami
- Zaimplementuj sterowanie przybliżaniem i przesuwaniem
- Dodaj wiele modeli do sceny
