Letzte Woche habe ich einen Blick unter die Haube des Forks versprochen. Statt einer Rundreise gibt es ein kleines Stück, das mich eine Woche gekostet und mehr gelehrt hat als jede Rundreise: die Dachkarte (roof map).
Das Problem
Im Two-Worlds-Port war der erste Licht-Schalter grob. Man betrat ein Haus, und das ganze Bild wechselte auf Innenlicht. Für Höhlen okay. Für eine Hütte mit offener Tür falsch: Das Land draußen wurde auch dunkel, und das Bild insgesamt dunkler. Was ich wollte, ist schnell gesagt: unter einem Dach dunkel, überall sonst hell, im selben Bild, pro Pixel.
Dafür muss jeder Pixel eins wissen: Ist über mir ein Dach?
Version 1: Strahlen (0.19.0)
Die erste Antwort war eine Karte von 128 x 128 m um den Betrachter. Für jede Zelle schicke ich einen Strahl nach oben, ab etwa einem Meter über der Höhe des Betrachters. Trifft er eine nach unten zeigende Fläche (Decke, Dachunterseite), liegt die Zelle unter einem Dach. Trifft er eine nach oben zeigende Fläche, begann der Strahl in einem Festkörper, etwa einem Felsen oder einer dicken Mauer, die unter dem Gelände weiterläuft. Solche Zellen lasse ich aus. Baumkronen zählen als Himmel.
Das Ergebnis geht als Textur an die Shader. Unter einem Dach wird das Umgebungslicht zu dem des Innenraums, und die Sonne ist aus. Ein Blur über 3 x 3 Zellen hält den Rand weich. Damit sah eine Hütte richtig aus; ein breiterer Blur ließ sie halb beleuchtet.
Es funktionierte. Es hatte aber einen Preis.
Der Preis
Ich habe gemessen, bevor ich etwas anfasste: je acht Sekunden Laufen an der Starthalle, am Portikus, hinten in der Halle und an einer Hütte.
- die Strahlenkarte kostete bis zu 6 ms pro Bild auf der CPU, mit deutlich höheren Spitzen, meist am Portikus
- eigene Dreiecksbäume für die Haushälften senkten den schlimmsten Fall der Strahlenkarte von 54 ms auf 15 ms
- und in den Screenshots zeigte sich ein zweites Problem: Die offenen Stufen des Portikus galten als Dach, weil ein Strahl aus der Zellenmitte problemlos eine Decke über den Säulen trifft
Das Letzte wiegt schwerer als die Millisekunden. Die Säulen sind seitlich offen. Wer dazwischen steht, ist nicht drinnen.
Version 2: zeichnen statt strahlen
Die zweite Antwort ist, wie ein Mensch es machen würde: nach oben schauen. Ein orthografischer Tiefen-Pass von einem Meter über dem Betrachter, 60 m nach oben, über 128 m bei 0,25 m pro Texel (512 x 512). Die erste nach unten zeigende Fläche über einem Texel ist die Unterseite seines Dachs. Ein kleiner Compute-Shader macht daraus dieselbe Textur, die die Shader schon kannten, mit einem Dachgewicht aus dem sichtbaren Himmelsanteil.
Auf der CPU kostete das 0,002 ms pro Bild, praktisch nichts. In den Bildern sahen Halle und Hütte aus wie vorher, und die Portikus-Stufen waren kein Dach mehr. Die Säulen waren raus.
Der neue Preis
Die Kosten sind nur umgezogen. Jedes Zeichnen brauchte 1,8 bis 3,5 ms auf der GPU, einmal 12 ms direkt nach einem Teleport. Ich zeichne etwa alle zwei Sekunden beim Laufen neu. Ein 3-ms-Ausschlag alle zwei Sekunden spürt man als Ruckler, auch wenn der Durchschnitt gut aussieht.
Version 3: backen (0.26.0)
Ein Dach bewegt sich nicht. Wer zweimal über dasselbe Gelände läuft, bekommt dieselbe Antwort. Also habe ich aufgehört, neu zu zeichnen, und angefangen, mir Dinge zu merken.
- die Welt ist in Chunks von 32 m geschnitten, das Kartenfenster ist 5 x 5 Chunks groß, 160 m, mit der Ecke auf dem Chunk-Raster, damit die Chunks von Bild zu Bild zusammenpassen
- jedes Zeichnen wird über einen kleinen Readback-Ring zur CPU gelesen und pro Chunk und pro Kamerahöhe (in 2-m-Stufen) behalten
- jeder behaltene Chunk speichert einen Fingerabdruck der Boxen der Objekte, die darüber gezeichnet wurden. Streamt ein Haus ein oder ändert sich, passt der Fingerabdruck nicht mehr, und der Chunk wird neu gezeichnet
- sind alle 25 Chunks des Fensters behalten und passen noch, setze ich die Karte auf der CPU so zusammen, wie der Shader es täte, und lade sie als neue Textur hoch. Gar kein Zeichnen.
- die Chunks gehen auf die Platte, höchstens einmal pro Minute, damit der zweite Besuch eines Ortes warm startet
Der erste Gang durch ein Gebiet zahlt die GPU-Kosten weiter. Jeder spätere nicht. Die CPU-seitige Karte wird auch aus dem Readback gefüllt, so funktioniert der Dachtest des Helden mit der Kamerakarte genauso.
Was ich anders machen würde
Ich habe der ersten Version vertraut, weil sie in einer Hütte richtig aussah. Der Portikus zeigte mir, dass "sieht im Testfall richtig aus" und "ist richtig" zwei Dinge sind. Das Messskript für die Starthalle fand Kosten und Portikus-Fehler in einem Lauf. Zuerst am hässlichen Ort messen.
Außerdem: Die Strahlenversion steckt noch im Fork, umschaltbar über die Konsole (roofmap source cpu). Sie ist die Referenz. Sieht das Bild falsch aus, schalte ich um und vergleiche.
Common pitfalls
- Ein Strahl aus der Zellenmitte ist eine Punktprobe. Offene Bauten wie Portiken und Brücken brauchen eine Flächenantwort, keine Punktantwort.
- Cachen nur nach Position reicht nicht. Ohne Fingerabdruck dessen, was dort steht, hinterlassen eingestreamte Häuser veraltete Dächer.
- GPU-Ergebnisse zurücklesen blockiert, wenn man darauf wartet. Einen Ring benutzen und das Ergebnis ein paar Bilder später abholen.
- Der Durchschnitt versteckt Ruckler. Aufs schlechteste Bild schauen, nicht aufs Mittel.
30-second recap
- Ein Dachtest pro Pixel braucht eine Karte "Dach oder Himmel" um den Betrachter.
- CPU-Strahlen funktionierten, kosteten aber bis zu 6 ms pro Bild und deckten einen offenen Portikus fälschlich ab.
- Ein orthografischer Tiefen-Pass von der Kamera behob den Portikus und nahm die CPU-Kosten weg, kostete aber 1,8-3,5 ms GPU pro Zeichnen.
- Das Ergebnis pro 32-m-Chunk zu backen, mit Fingerabdruck der Objekte darüber, spart die meisten dieser Zeichnungen.
- Die einfache Version als Referenz behalten.
Nächstes Mal: was passiert, wenn die GPU mitten im Bild verschwindet.
— Marco
Comments
Leave a comment