9. Mai 2026
Z-Fighting und Picking: Orthogonale Tiefensortierung durch ADTs
Eine Spatial UI, die im echten 3D-Raum gerendert wird, unterliegt den mathematischen Limitierungen der Grafik-Pipeline. Ein klassisches Problem bei der Konstruktion von verschachtelten 2D-Interfaces in einer 3D-Welt ist koplanare Geometrie: Ein Textfeld-Hintergrund, der darauf liegende Text und eine danebenliegende Scrollbar besitzen fast identische Z-Koordinaten im Weltraum.
Aufgrund der begrenzten Präzision des Tiefenpuffers kann die Grafikkarte nicht verlässlich entscheiden, welches Fragment vorne liegt. Das Resultat ist Z-Fighting (visuelles Flackern).
Exkurs: Warum der Tiefenpuffer ungenau wird
Der Z-Buffer (Tiefenpuffer) der Grafikkarte speichert die Entfernung jedes Pixels zur Kamera. Bei perspektivischen Projektionen verläuft diese Auflösung jedoch nicht linear. Nah an der Kamera ist die Präzision extrem hoch. Je weiter ein Objekt entfernt ist, desto weniger Bits stehen für die Tiefenunterscheidung zur Verfügung. Zwei Flächen mit winzigem Abstand (z. B. 0.001 Einheiten) werden in der Ferne für die GPU mathematisch auf denselben Wert gerundet – das Flackern beginnt.
Das eigentliche Problem: Falsche Picking-Ergebnisse
Das visuelle Flackern ist jedoch nur ein Symptom. Das gravierendere Problem betrifft die Interaktion.
Wie in einem früheren Beitrag beschrieben, nutzt die Engine einen separaten Offscreen-Pass für das "Picking". Wenn nun die Z-Sortierung instabil ist, passiert folgendes: Die unsichtbare, großflächige Hintergrund-Geometrie einer Textzeile verdeckt mathematisch den schmalen Anfasser der Scrollbar. Der Nutzer klickt optisch auf die Scrollbar, der Auslesevorgang aus dem Framebuffer liefert jedoch die ID des Textfeldes zurück. Das UI-Element wird unbedienbar.
Exkurs: Offscreen Picking
Um zu wissen, worauf der Nutzer klickt, verwendet die Engine kein mathematisches Raycasting (Schnittpunktberechnung von Linien mit 3D-Boxen). Stattdessen wird die Szene unsichtbar im Hintergrund ein zweites Mal gezeichnet. Jedes anklickbare Element (Button, Scrollbar, Textfeld) erhält dabei keine echte Textur, sondern eine einzigartige, flache Farbe (z. B. Rotwert 12, Grünwert 104). Klickt der Nutzer, liest die CPU die Farbe des Pixels unter dem Mauszeiger aus und rechnet diese Farbe wieder in die exakte ID des UI-Elements.
Warum überhaupt ein Tiefenpuffer? (Maleralgorithmus vs. Multithreading)
An dieser Stelle drängt sich eine berechtigte Architektur-Frage auf: Klassische 2D-Benutzeroberflächen (wie der Browser oder Standard-Desktop-Apps) benötigen für ihre Fensterinhalte meist gar keinen Tiefenpuffer. Warum verzichten wir nicht einfach darauf und zeichnen die Elemente strikt sequenziell von hinten nach vorne?
Dieses Konzept nennt sich Maleralgorithmus (Painter's Algorithm): Zuerst wird der Hintergrund gezeichnet, dann das Formular, dann die Scrollbar, zuletzt der Text. Was später an die Grafikkarte geschickt wird, übermalt schlicht die vorherigen Pixel. Z-Fighting existiert hier nicht, da die Zeichenreihenfolge (Draw Order) die Sichtbarkeit diktiert.
Dass wir diesen Weg in der Engine verlassen müssen, hat zwei zwingende Gründe:
- Echtes 3D (Spatial UI): Da unsere Fenster frei im 3D-Raum schweben und die Kamera sich kippen lässt, können sich Fenster gegenseitig durchdringen. Die CPU müsste in jedem Frame alle tausenden UI-Dreiecke mathematisch nach ihrer Distanz zur Kamera sortieren. Das ist ein massiver CPU-Flaschenhals.
- Lock-free Multithreading: Dies ist der entscheidende Punkt. Wie in einem früheren Devlog beschrieben, generieren wir die Geometrie für hunderte Objekte echtzeitfähig durch asynchrone Worker-Threads. Alle Prozessorkerne schreiben ihre Daten gleichzeitig in einen großen, gemeinsamen Vertex-Puffer. Dadurch ist die finale Reihenfolge der Dreiecke im Speicher nicht deterministisch. Ein Thread ist vielleicht mit den Textbuchstaben schneller fertig als ein anderer Thread mit dem zugehörigen Hintergrund.
Wir können uns also schlicht nicht auf eine vorhersehbare Zeichenreihenfolge verlassen. Wir delegieren das Problem der Verdeckung komplett an die Hardware der Grafikkarte – den Z-Buffer. Dieser per-Pixel Tiefentest ermöglicht erst unsere massive Parallelisierung auf der CPU, erfordert als Vertragspreis dafür aber zwingend exakte, mathematisch eindeutige Z-Koordinaten für jedes einzelne UI-Fragment.
"Primitive Obsession" und Magic Numbers
Die pragmatische, aber architektonisch unsaubere Lösung, um diese eindeutigen Z-Koordinaten herzustellen, sind fest kodierte Offsets (Magic Numbers). Bei der Generierung der Render-Befehle werden manuelle Fließkommawerte addiert: z + 0.001 für den Hintergrund, z + 0.002 für die Scrollbar.
Das führt zu einem klassischen Architekturfehler: der Primitive Obsession.
Exkurs: Was ist Primitive Obsession?
Der Begriff stammt aus der Refactoring-Literatur. Er beschreibt den Code-Smell (Gestank), wenn komplexe Domänenkonzepte durch fundamentale Basis-Datentypen (Primitives wieint,floatoderstring) abgebildet werden. Aber warum "Obsession" (Besessenheit)? Weil Programmierer dazu neigen, aus reiner Bequemlichkeit geradezu zwanghaft an diesen eingebauten Standard-Typen festzuhalten. Es fühlt sich im ersten Moment nach unnötigem Overhead an, für eine simple Tiefenkoordinate extra einen neuen Datentyp zu deklarieren. Man denkt sich: "Einfloatreicht doch völlig aus!" und nutzt ihn einfach überall. Man ist "besessen" davon, alles mit primitiven Basiswerkzeugen zu erschlagen, und weigert sich, der Domäne ein eigenes Vokabular zu geben. Das rächt sich, weil der Compiler dann nicht mehr bei Logikfehlern helfen kann: Ein Betrag von "50 Euro" ist fachlich keinfloat(50.0), sondern sollte ein TypMoneysein. Und die topologische Reihenfolge von UI-Elementen ist keinfloat, sondern ein TypLayer.
Die Reihenfolge von UI-Elementen ist eine strukturelle Information, keine Fließkommazahl. Kapselt man dies in primitiven float-Werten, wird diese Regel über alle Abstraktionsschichten hinweg ungeschützt durchgereicht. Sobald neue GUI-Komponenten hinzukommen, kollidieren die Offsets unweigerlich. Eine nachträgliche Korrektur erfordert das Anpassen global verteilter Konstanten.
Die Lösung: Topologie in das Typsystem heben
Um das Problem strukturell zu lösen, wurde die Z-Tiefensortierung innerhalb von UI-Frames aus der Geometrie-Ebene entfernt und in das Typsystem der Engine verlagert.
Anstatt rohe Z-Offsets zu verwenden, ordnet das Layout-System Render-Befehle nun einem abstrakten Datentyp (ADT). Dieser definiert ein geschlossenes Vokabular der topologischen Schichten:
Exkurs: Algebraic Data Types (ADTs)
Ein ADT erlaubt es, eine exakt begrenzte, geschlossene Menge an gültigen Zuständen zu definieren. Im Gegensatz zu einemfloat, der unendlich viele (und oft ungültige) Werte annehmen kann, lässt ein Aufzählungstyp (Enum/Sum Type) nur die exakt definierten Ausprägungen zu. Der Compiler garantiert, dass keine anderen Werte in das System eingeschleust werden.
Das System kennt nun folgende strikte Hierarchie:
- Fenster-Hintergrund
- Formular-Hintergrund (z. B. Input-Boxen)
- Aktive Zustände (z. B. aktivierte Toggles)
- Texthintergründe (nur für den Picking-Pass)
- Selektions-Markierungen
- Text
- Scroll-Spuren
- Scroll-Anfasser
- Overlay-Outlines
Die Zuordnung erfolgt deklarativ im Theme-Setup. Die tatsächliche Transformation in mathematische Z-Werte passiert erst ganz am Ende der Rendering-Pipeline in einer isolierten Funktion. Dort wird die Enum-Reihenfolge mit einem ausreichend großen Epsilon-Wert multipliziert, der garantierte Tiefenabstände erzeugt.
Lokale vs. Globale Topologie: Das Problem der durchstechenden Buttons
Die Einführung des ADTs für die Z-Ebenen löst das Flackern innerhalb eines Fensters. Damit entsteht jedoch sofort ein Folgeproblem: Die Z-Achse ist nun doppelt belastet. Sie definiert die absolute Position des Fensters im 3D-Raum (Global Z), aber auch die gestapelte Höhe der UI-Elemente auf diesem Fenster (Local Z).
Wenn Fenster A bei Z=0.100 liegt und Fenster B kurz dahinter bei Z=0.090, überdeckt Fenster A das Fenster B korrekt. Wenn nun aber Fenster B einen Button besitzt, der durch unsere ADT-Logik einen lokalen Z-Offset von + 0.015 erhält, liegt dieser Button absolut bei Z=0.105. Die fatale Folge: Der Button des hinteren Fensters durchsticht plötzlich die Hintergrundfläche des vorderen Fensters.
Um dieses "Punch-Through"-Problem zu verhindern, nutzt die Engine einen sogenannten FrameStackCompositor. Er entkoppelt die logische Fensterreihenfolge von der physischen Raumtiefe.
Die lokalen Z-Werte aus dem ADT dürfen nicht beliebig auf die Raumkoordinate addiert werden. Sie werden stattdessen in ein streng limitiertes "Z-Band" komprimiert. Der Compositor garantiert mathematisch, dass die gesamte Dicke dieses lokalen Epsilon-Bandes (vom Fenster-Hintergrund bis zur höchsten Scrollbar) stets kleiner ist als der minimal mögliche Abstand zweier Fenster im Haupt-Szenengraph. Jedes Fenster wird beim Rendern somit als in sich geschlossene, atomare Z-Scheibe (Slice) behandelt, deren interne Schichten niemals in den Raum anderer Objekte ausbrechen können.
Fazit
Durch das Heben der Tiefensortierung in einen dedizierten Typen übernimmt der Compiler die Kontrolle über die topologische Korrektheit der Benutzeroberfläche.
Visuelles Z-Fighting bei gekippter Kamera ist physikalisch ausgeschlossen. Noch wichtiger ist jedoch die Stabilisierung des Eingabe-Routings: Verdeckungen im Picking-Buffer durch fehlerhafte Fließkomma-Offsets sind strukturell unmöglich geworden. Die Interaktionsschicht kann sich darauf verlassen, dass das, was logisch vorne liegen soll, vom Rendering-System auch als vorderstes Fragment in den Puffer geschrieben wird.
🖼️ Galerie
Screenshots und visuelle Einblicke in die aktuelle Entwicklung der Engine und UI.
🚀 Freelancer & Mitgründer gesucht
Pragmatisch und produktnah: Softwareprodukte entwickeln, die reale Probleme lösen.
📰 Aktuelle Beiträge
Zurück zur Startseite mit den neuesten Projekten und Gedanken.
🗄️ Artikel-Archiv
Ältere Beiträge und Notizen, die zur Dokumentation erhalten bleiben.