Übung: 3D-Grafik im Browser – Vom Punkt zum rotierenden Würfel

Inspiriert von: One Formula That Demystifies 3D Graphics – Tsoding (YouTube)

Ziel: Wir bauen Schritt für Schritt eine Animation, die einen rotierenden 3D-Würfel im Browser rendert – ohne externe Bibliotheken, nur mit HTML, JavaScript und etwas Mathematik.

Was du brauchst:

  • Texteditor (z.B. VS Code)
  • Webbrowser (Chrome, Firefox oder Safari)
  • Grundkenntnisse: Variablen, Funktionen, Schleifen in JavaScript

Das große Bild

Bevor wir mit dem Code beginnen, kurz die Idee dahinter:

3D-Punkt  →  Projektion  →  Normalisierte 2D-Koordinaten  →  Canvas-Pixel
(x, y, z)     (÷ durch z)        (−1 bis +1)                  (0 bis 800)

Jeden Frame:

  1. Den Würfel ein bisschen weiter drehen
  2. Jeden 3D-Eckpunkt auf 2D projizieren
  3. Die projizierten Punkte als Linien auf dem Canvas zeichnen

Schritt 1 – Die HTML-Grundlage

Erstelle eine neue Datei index.html:

<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      margin: 0;
      background: #999;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
    }
  </style>
</head>
<body>
  <canvas id="game"></canvas>
  <script src="index.js"></script>
</body>
</html>

Erstelle daneben eine leere Datei index.js – hier kommt der gesamte Code rein.

Öffne index.html im Browser und lass ihn während der ganzen Übung offen.
Nach jedem Schritt einfach die Seite neu laden (F5 / Cmd+R).


Schritt 2 – Wie funktionieren Canvas-Koordinaten?

Der Canvas hat sein eigenes Koordinatensystem. Der Nullpunkt (0, 0) liegt oben links. Die x-Achse zeigt nach rechts, die y-Achse zeigt nach unten – das ist anders als in der Mathematik!

PunktPosition
(0, 0)Oben links
(800, 0)Oben rechts
(0, 800)Unten links
(400, 400)Genau in der Mitte

Das werden wir später berücksichtigen müssen, wenn wir von mathematischen Koordinaten in Canvas-Koordinaten umrechnen.


Schritt 3 – Canvas einrichten und Hintergrund zeichnen

Füge am Anfang von index.js hinzu:

const BACKGROUND = "#101010";  // Fast-Schwarz
const FOREGROUND = "#50FF50";  // Grün (wie ein alter Terminal-Bildschirm)
 
// Canvas und Zeichenkontext einrichten
const canvas = document.getElementById("game");
canvas.width = 800;
canvas.height = 800;
const ctx = canvas.getContext("2d");
 
// Füllt den gesamten Canvas mit der Hintergrundfarbe
function clearCanvas() {
  ctx.fillStyle = BACKGROUND;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
}
 
// Test
clearCanvas();

Ergebnis: Ein fast-schwarzes Quadrat im Browser.


Schritt 4 – Einen Punkt zeichnen

Wir zeichnen Punkte als kleine Quadrate:

// Zeichnet ein kleines Quadrat an Position p (Canvas-Koordinaten)
function drawPoint(p) {
  const size = 20;
  ctx.fillStyle = FOREGROUND;
  ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size);
}
 
// Test: Punkt genau in der Mitte des Canvas
clearCanvas();
drawPoint({ x: 400, y: 400 });

Ergebnis: Ein grüner Punkt in der Mitte.


Schritt 5 – Normalisierte Koordinaten

Direkt mit Pixelwerten (0 bis 800) zu rechnen ist umständlich. Viel einfacher ist es, in einem normalisierten Koordinatenraum zu arbeiten: Werte von −1 bis +1, wobei (0, 0) die Mitte ist.

In normalisierten Koordinaten zeigt die y-Achse nach oben (wie in der Mathematik). Das müssen wir beim Umrechnen umkehren.

Die Umrechnungsformel:

screen_x = (x + 1) / 2  ×  canvas.width
screen_y = (1 − (y + 1) / 2)  ×  canvas.height
              ↑ Das Minus kehrt die y-Achse um

Schritt für Schritt für x = 0 (Mitte):

(0 + 1) / 2 × 800  =  0.5 × 800  =  400  ✓

Für x = −1 (linker Rand):

(−1 + 1) / 2 × 800  =  0 × 800  =  0  ✓

Für x = 1 (rechter Rand):

(1 + 1) / 2 × 800  =  1 × 800  =  800  ✓
// Wandelt normalisierte Koordinaten (−1 bis +1) in Canvas-Pixel um
function toScreenCoords(p) {
  return {
    x: (p.x + 1) / 2 * canvas.width,
    y: (1 - (p.y + 1) / 2) * canvas.height,
  }
}
 
// Test: Punkt bei (0, 0) in normalisiertem Raum = Mitte des Canvas
clearCanvas();
let pointCoords = {x: 0, y: 0}; // punkt in der mitte
let screenPoint = toScreenCoords(pointCoords);
drawPoint(screenPoint);

Ergebnis: Grüner Punkt in der Mitte. Jetzt mit mathematischen Koordinaten statt Pixelwerten.


Schritt 6 – Linien zeichnen

// Zeichnet eine Linie zwischen zwei Punkten (Canvas-Koordinaten)
function drawLine(p1, p2) {
  ctx.lineWidth = 3;
  ctx.strokeStyle = FOREGROUND;
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);
  ctx.lineTo(p2.x, p2.y);
  ctx.stroke();
}
 
// Test: Linie von oben-links nach unten-rechts
clearCanvas();
drawLine(
  toScreenCoords({ x: -0.8, y: 0.8 }),
  toScreenCoords({ x: 0.8, y: -0.8 })
);

Ergebnis: Eine diagonale grüne Linie.


Schritt 7 – 3D-Projektion verstehen

Jetzt kommt das mathematische Herzstück der Übung.

Die Idee: Stell dir vor, dein Auge ist bei (0, 0, 0) und du schaust in Richtung der positiven z-Achse. Ein Punkt, der weiter weg ist (größeres z), erscheint kleiner und näher an der Mitte.

Die Formel (Zentralprojektion):

x_2D = x_3D / z
y_2D = y_3D / z

Das war’s! Durch Division durch z entsteht automatisch die Perspektive.

Vergleich zweier Punkte:

PunktProjektionWirkt…
(0.5, 0.5, 1)(0.5, 0.5)Nah, groß
(0.5, 0.5, 2)(0.25, 0.25)Weiter weg, kleiner
(0.5, 0.5, 5)(0.1, 0.1)Sehr weit weg, winzig

Je größer z, desto kleiner und mittig-er erscheint der Punkt – genau wie in der Realität!

Achtung: z darf nie 0 sein (Division durch 0)!

// Projiziert einen 3D-Punkt auf 2D (Perspektive durch Division durch z)
function projectTo2D(p) {
  return {
    x: p.x / p.z,
    y: p.y / p.z,
  }
}

Schritt 8 – Die Transformationskette

Jetzt kombinieren wir alles zu einer Kette:

3D-Punkt  →  projectTo2D()  →  toScreenCoords()  →  drawPoint()
// Hilfsfunktion: Verschiebt einen Punkt entlang der z-Achse
function moveAlongZ(p, distance) {
  return { x: p.x, y: p.y, z: p.z + distance }
}
 
// Test: Einen 3D-Punkt bei (0, 0, 1.5) zeichnen
clearCanvas();
const point3D = { x: 0, y: 0, z: 1.5 };
const point2D = projectTo2D(point3D);
const screenPos = toScreenCoords(point2D);
drawPoint(screenPos);

Oder in einer Zeile:

drawPoint(toScreenCoords(projectTo2D({ x: 0, y: 0, z: 1.5 })));

Schritt 9 – Den Würfel definieren

Ein Würfel hat 8 Ecken (Vertices) und 6 Flächen (Faces). Wir speichern die Flächen als Listen von Eckpunkt-Indizes.

// 8 Ecken des Würfels (±0.25 in jede Richtung)
const vertices = [
  { x:  0.25, y:  0.25, z:  0.25 },  // 0: vorne  oben  rechts
  { x: -0.25, y:  0.25, z:  0.25 },  // 1: vorne  oben  links
  { x: -0.25, y: -0.25, z:  0.25 },  // 2: vorne  unten links
  { x:  0.25, y: -0.25, z:  0.25 },  // 3: vorne  unten rechts
 
  { x:  0.25, y:  0.25, z: -0.25 },  // 4: hinten oben  rechts
  { x: -0.25, y:  0.25, z: -0.25 },  // 5: hinten oben  links
  { x: -0.25, y: -0.25, z: -0.25 },  // 6: hinten unten links
  { x:  0.25, y: -0.25, z: -0.25 },  // 7: hinten unten rechts
];
 
// Flächen als Eckpunkt-Indizes (Reihenfolge = Verbindungsreihenfolge)
const faces = [
  [0, 1, 2, 3],  // Vorderseite (Viereck)
  [4, 5, 6, 7],  // Rückseite   (Viereck)
  [0, 4],        // Kante: oben rechts
  [1, 5],        // Kante: oben links
  [2, 6],        // Kante: unten links
  [3, 7],        // Kante: unten rechts
];

Der Trick mit % face.length:

Beim Zeichnen einer Fläche verbinden wir Eckpunkt i mit i+1. Beim letzten Punkt muss die Linie aber zurück zum ersten – das erledigt der Modulo-Operator:

Fläche [0, 1, 2, 3]:
  i=0: Verbinde Ecke 0 → Ecke 1
  i=1: Verbinde Ecke 1 → Ecke 2
  i=2: Verbinde Ecke 2 → Ecke 3
  i=3: Verbinde Ecke 3 → Ecke (3+1) % 4 = Ecke 0  ← schließt das Viereck!

Schritt 10 – Den Würfel zeichnen

function drawCube() {
  clearCanvas();
 
  for (const face of faces) {
    for (let i = 0; i < face.length; i++) {
      const a = vertices[face[i]];
      const b = vertices[face[(i + 1) % face.length]];
 
      // Würfel um 1 nach vorne schieben (z darf nicht 0 sein!)
      const screenA = toScreenCoords(projectTo2D(moveAlongZ(a, 1)));
      const screenB = toScreenCoords(projectTo2D(moveAlongZ(b, 1)));
 
      drawLine(screenA, screenB);
    }
  }
}
 
drawCube();

Ergebnis: Ein statischer Drahtgitter-Würfel!

Warum moveAlongZ(a, 1)? Weil der Würfel bei z = 0 liegt. Division durch 0 würde alles kaputtmachen. Wir schieben ihn um 1 in die z-Richtung (von uns weg).


Schritt 11 – Rotation um die Y-Achse

Jetzt bringen wir den Würfel zum Drehen. Eine Rotation im XZ-Raum entspricht mathematisch einer Rotation um die Y-Achse. Die Formel kommt aus der Rotationsmatrix:

x′ = x · cos(θ) − z · sin(θ)
z′ = x · sin(θ) + z · cos(θ)
y bleibt unverändert
// Dreht einen Punkt um die Y-Achse um den angegebenen Winkel (in Radiant)
function rotateY(p, angle) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  return {
    x: p.x * cos - p.z * sin,
    y: p.y,
    z: p.x * sin + p.z * cos,
  }
}

Radiant vs. Grad: JavaScript verwendet Radiant. Ein voller Kreis = 2π ≈ 6.28. Eine halbe Drehung = π ≈ 3.14.


Schritt 12 – Der Animationsloop

Wir nutzen setTimeout, um regelmäßig einen neuen Frame zu zeichnen. In jedem Frame drehen wir den Würfel ein kleines bisschen weiter.

const FPS = 60;
let angle = 0;
 
function renderFrame() {
  const deltaTime = 1 / FPS;      // Wie viele Sekunden ein Frame dauert
  angle += Math.PI * deltaTime;   // Eine halbe Umdrehung pro Sekunde
 
  clearCanvas();
 
  for (const face of faces) {
    for (let i = 0; i < face.length; i++) {
      // Erst drehen, dann nach vorne schieben, dann projizieren
      const a = rotateY(vertices[face[i]], angle);
      const b = rotateY(vertices[face[(i + 1) % face.length]], angle);
 
      drawLine(
        toScreenCoords(projectTo2D(moveAlongZ(a, 1))),
        toScreenCoords(projectTo2D(moveAlongZ(b, 1)))
      )
    }
  }
 
  setTimeout(renderFrame, 1000 / FPS);
}
 
setTimeout(renderFrame, 1000 / FPS);

Ergebnis: Ein rotierender 3D-Würfel!

Die Transformationskette pro Eckpunkt lautet:

rotateY()  →  moveAlongZ()  →  projectTo2D()  →  toScreenCoords()  →  drawLine()
(drehen)      (nach vorne)     (3D → 2D)         (normalisiert       (zeichnen)
                                                   → Pixel)

Vollständiger Code (index.js)

// ─── Farben ──────────────────────────────────────────────────────────────────
const BACKGROUND = "#101010";
const FOREGROUND = "#50FF50";
 
// ─── Canvas einrichten ───────────────────────────────────────────────────────
const canvas = document.getElementById("game");
canvas.width = 800;
canvas.height = 800;
const ctx = canvas.getContext("2d");
 
// ─── Zeichenfunktionen ────────────────────────────────────────────────────────
 
function clearCanvas() {
  ctx.fillStyle = BACKGROUND;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
}
 
function drawPoint(p) {
  const size = 20;
  ctx.fillStyle = FOREGROUND;
  ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size);
}
 
function drawLine(p1, p2) {
  ctx.lineWidth = 3;
  ctx.strokeStyle = FOREGROUND;
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);
  ctx.lineTo(p2.x, p2.y);
  ctx.stroke();
}
 
// ─── Koordinatentransformationen ──────────────────────────────────────────────
 
// Normalisierte Koordinaten (−1..+1) → Canvas-Pixel
function toScreenCoords(p) {
  return {
    x: (p.x + 1) / 2 * canvas.width,
    y: (1 - (p.y + 1) / 2) * canvas.height,
  }
}
 
// Perspektivprojektion: 3D → 2D (Kernformel: teile durch z)
function projectTo2D(p) {
  return {
    x: p.x / p.z,
    y: p.y / p.z,
  }
}
 
// Verschiebt einen Punkt entlang der z-Achse
function moveAlongZ(p, distance) {
  return { x: p.x, y: p.y, z: p.z + distance }
}
 
// Rotiert einen Punkt um die Y-Achse
function rotateY(p, angle) {
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  return {
    x: p.x * cos - p.z * sin,
    y: p.y,
    z: p.x * sin + p.z * cos,
  }
}
 
// ─── Würfel-Definition ────────────────────────────────────────────────────────
 
const vertices = [
  { x:  0.25, y:  0.25, z:  0.25 },  // 0
  { x: -0.25, y:  0.25, z:  0.25 },  // 1
  { x: -0.25, y: -0.25, z:  0.25 },  // 2
  { x:  0.25, y: -0.25, z:  0.25 },  // 3
  { x:  0.25, y:  0.25, z: -0.25 },  // 4
  { x: -0.25, y:  0.25, z: -0.25 },  // 5
  { x: -0.25, y: -0.25, z: -0.25 },  // 6
  { x:  0.25, y: -0.25, z: -0.25 },  // 7
]
 
const faces = [
  [0, 1, 2, 3],
  [4, 5, 6, 7],
  [0, 4],
  [1, 5],
  [2, 6],
  [3, 7],
]
 
// ─── Animation ────────────────────────────────────────────────────────────────
 
const FPS = 60;
let angle = 0;
 
function renderFrame() {
  angle += Math.PI * (1 / FPS);
 
  clearCanvas();
 
  for (const face of faces) {
    for (let i = 0; i < face.length; i++) {
      const a = rotateY(vertices[face[i]], angle);
      const b = rotateY(vertices[face[(i + 1) % face.length]], angle);
 
      drawLine(
        toScreenCoords(projectTo2D(moveAlongZ(a, 1))),
        toScreenCoords(projectTo2D(moveAlongZ(b, 1)))
      )
    }
  }
 
  setTimeout(renderFrame, 1000 / FPS);
}
 
setTimeout(renderFrame, 1000 / FPS);

Bonusaufgaben

Versuche, den Code selbst zu erweitern:

  1. Langsamer/Schneller: Ändere Math.PI * (1 / FPS) – was passiert bei Math.PI * 0.1?
  2. Farbe: Ändere FOREGROUND auf "#FF5050" oder "#5080FF".
  3. Größerer Würfel: Ändere alle 0.25 auf 0.4 in den Vertices.
  4. Weiter weg: Ändere moveAlongZ(a, 1) auf moveAlongZ(a, 3) – was ändert sich?
  5. Y-Rotation dazumischen: Füge eine zweite Rotation hinzu:
    function rotateX(p, angle) {
      const cos = Math.cos(angle);
      const sin = Math.sin(angle);
      return {
        x: p.x,
        y: p.y * cos - p.z * sin,
        z: p.y * sin + p.z * cos,
      }
    }
    Und wende sie an: rotateX(rotateY(vertex, angle), angle * 0.7)