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.
