Die Bausteine hinter jedem 3D-Bild

💡Licht, Texturen, Z-Buffer & Co.

Sechs Konzepte, jeweils mit einer kleinen Demo, die im Browser nachrechnet – ganz ohne GPU. So sieht man, was die Grafikkarte später millionenfach parallel erledigt.

💡Licht und Materialien: Lambert, Phong, PBR

Wie hell ein Pixel ist, hängt von drei Richtungen ab: Flächennormale N, Richtung zum Licht L und Richtung zur Kamera V. Die Kugel links wird Pixel für Pixel in JavaScript schattiert – mit denselben Formeln, die als GLSL im WebGL-Labor laufen. Die Ferienwohnung nutzt PBR: alle 27 Materialien sind glTF-pbrMetallicRoughness.
32400 Pixel, je Pixel die Formel rechts
I = k_d·max(0, N·L) + k_s·max(0, N·H)ˢ, H = norm(L+V)

Schnellere und bei flachen Winkeln realistischere Variante mit dem Halbvektor H. Jahrzehntelang der Standard der Fixed-Function-Pipeline (OpenGL 1.x).

Licht −90°0° (senkrecht)+90°Helligkeit (Blick aus 40°)

🏁Texturen und UV-Koordinaten

Eine Textur ist ein Bild, das auf Dreiecke „geklebt“ wird. Jeder Vertex bekommt dafür eine UV-Koordinate (u, v ∈ [0; 1]) – wo er im Bild liegt. Der Fragment-Shader liest dann mit texture2D(uTex, vUv) die Farbe. Übrigens: Das Apartment-Modell hat 0 Texturen und keine UVs – alle Oberflächen sind einfarbige PBR-Materialien, das hält die Datei klein.

Die Textur ist ein 64 × 64-Schachbrett, die rote Ecke markiert UV (0; 0). Zoom stark hinein und vergleiche NEAREST (Klötzchen) mit LINEAR (bilineare Mischung der vier Nachbartexel).

UV-Raum: wo greift das Quad in die Textur?
A (0,00; 0,00)B (1,00; 0,00)C (1,00; 1,00)D (0,00; 1,00)(0; 0)(1; 1)
Ergebnis auf dem Bildschirm (200 × 200, per Software gesampelt)

🌳Szenengraph: Eltern und Kinder

Ein Szenengraph ist ein Baum aus Knoten. Jeder Knoten hat eine lokale Transformation relativ zu seinem Elternteil; die Weltmatrix ergibt sich als Produkt entlang des Pfades: Welt(Kind) = Welt(Eltern) · Lokal(Kind). three.js berechnet das in updateMatrixWorld().

Dreh die Schulter: Unterarm und Hand folgen, obwohl sich ihre lokalen Werte nicht ändern. Genau so hängen in der Ferienwohnung 712 Meshes an 9 Gruppenknoten – verschiebt man „OG_Moebel“, wandern alle Möbel des Obergeschosses mit.

Szene (Wurzel)
Oberarm: Weltmatrix
0,87-0,500,000,000,500,870,000,000,000,001,000,000,000,000,001,00
Unterarm: Weltmatrix
0,970,260,001,91-0,260,970,001,100,000,001,000,000,000,000,001,00
Hand: Weltmatrix
1,00-0,090,003,450,091,000,000,690,000,001,000,000,000,000,001,00
EG_Boeden
Erdgeschoss · Böden und Decken
5 Objekte
EG_Waende
Erdgeschoss · Wände
36 Objekte
EG_Oeffnungen
Erdgeschoss · Fenster und Türen
6 Objekte
EG_Moebel
Erdgeschoss · Möblierung
123 Objekte
OG_Boeden
Obergeschoss · Böden und Terrassendielen
65 Objekte
OG_Waende
Obergeschoss · Wände und Geländer
326 Objekte
OG_Oeffnungen
Obergeschoss · Fenster und Türen
5 Objekte
OG_Moebel
Obergeschoss · Möblierung
93 Objekte
Aussenanlagen
Umgebung · Bäume, Wege, Möblierung
53 Objekte

🧱Z-Buffer: Wer ist vorne?

Die GPU zeichnet Dreiecke in beliebiger Reihenfolge. Damit trotzdem das Nähere gewinnt, speichert der Z-Buffer (Tiefenpuffer) je Pixel die bisher kleinste Tiefe. Ein neues Fragment wird nur geschrieben, wenn es näher ist. Das funktioniert sogar bei sich durchdringenden Flächen – wo Sortieren nach Objekt scheitern würde.
Geschrieben: 381 Fragmente · verworfen: 0
Türkis wird nach rechts unten tiefer (z 0,15 → 0,85), Violett liegt überall bei z = 0,5 – die beiden durchdringen sich. Ohne Tiefentest gewinnt, wer zuletzt zeichnet; mit Tiefentest gewinnt pro Pixel das nähere Fragment.
Z-Fighting
Die Tiefe wird nicht linear gespeichert, sondern ~1/z: nahe der Kamera sehr fein, in der Ferne grob. Liegen zwei Flächen fast gleich weit weg (z. B. Boden und Teppich), flackern sie. Abhilfe: near nicht zu klein wählen, Flächen leicht versetzen oder polygonOffset.

🪜Anti-Aliasing: Treppenstufen glätten

Pixel sind ein Raster, Kanten sind es nicht. Aliasing entsteht, wenn pro Pixel nur ein Punkt geprüft wird. MSAA (Multisample) prüft die Abdeckung an mehreren Punkten, rechnet den Fragment-Shader aber nur einmal pro Pixel – ein guter Kompromiss. Supersampling rendert alles in höherer Auflösung (teuer).

Mit nur einem Abtastpunkt pro Pixel ist ein Pixel entweder ganz drin oder ganz draußen – schräge Kanten werden zur Treppe. Mehr Abtastpunkte ergeben Zwischenwerte: 25 Pixel sind hier nur teilweise bedeckt und bekommen eine Mischfarbe.

In WebGL: getContext("webgl2", { antialias: true }) fordert MSAA für den Standard-Framebuffer an; three.js: new WebGLRenderer({ antialias: true }). Alternativen als Nachbearbeitung: FXAA, SMAA, TAA.

🚀Draw Calls und Performance

Ein Draw Call (gl.drawElements) ist ein Auftrag der CPU an die GPU: „Zeichne diesen Puffer mit diesem Shader und diesen Uniforms“. Jeder kostet CPU-Zeit für Zustandswechsel – oft ist nicht die Zahl der Dreiecke das Problem, sondern die Zahl der Draw Calls.
Ferienwohnung: 16.076 Dreiecke in 712 Meshes
wie exportiert: 1 Draw Call je Mesh712
nach Material zusammengeführt27
alles in einem Puffer (nur mit Texturatlas/Vertexfarben)1

Im Schnitt nur 23 Dreiecke pro Draw Call – für eine GPU winzig. Moderne Grafikkarten schaffen Millionen Dreiecke pro Bild; 712 Draw Calls sind im Browser noch gut machbar (Richtwert: einige Hundert bis wenige Tausend pro Bild).

🧩 Instancing
Gleiche Geometrie vielfach (Stühle, Bäume) mit einem Draw Call: InstancedMesh bzw. drawElementsInstanced.
🧵 Batching/Merging
Statische Meshes mit gleichem Material zu einem Puffer zusammenfügen (BufferGeometryUtils.mergeGeometries).
🗜️ Kompression
Draco/Meshopt für Geometrie, KTX2/Basis für Texturen – kleinere Downloads, weniger GPU-Speicher.
🔭 Culling & LOD
Unsichtbares gar nicht erst zeichnen (Frustum-Culling ist in three.js aktiv), ferne Objekte vereinfachen.
🌗 Schatten sparsam
Jede schattenwerfende Lichtquelle rendert die Szene ein weiteres Mal in eine Shadow-Map.
📱 Pixelmenge
devicePixelRatio begrenzen (z. B. max. 2) – auf Retina-Displays sonst 4× so viele Fragmente.