Woher die Löcher in generierten Meshes kommen
Von Oleg Sidorkin, CTO und Mitgründer von Cinevva

Ich generierte mit TRELLIS.2 einen Vogel, riggte ihn, und in dem Moment, als eine Animation abgespielt wurde, erschienen unter beiden Flügeln Löcher. Leuchtende Schlitze, durch die man direkt durch den Körper hindurchsehen konnte. Zuerst bekam der Rigger die Schuld, und er hatte tatsächlich ein eigenes Problem, doch die Löcher waren bereits im Mesh, bevor auch nur ein Bone es berührte. Dieser Beitrag beschreibt die anschließende Untersuchung: was die Löcher tatsächlich sind, welche neun Maßnahmen sie nicht behoben haben, warum sie scheiterten und was schließlich funktionierte. Die Sackgassen nehme ich bewusst mit auf. Die meisten davon sind die naheliegenden Dinge, die man ausprobieren würde, und jede einzelne brachte uns eine Erkenntnis, von der die letztendliche Korrektur abhing.
Schritt eins: Einigen wir uns darauf, was ein Loch ist
Unser Mesh-Prüfer meldete null Randkanten, und ein Strahlenparitätstest besagte, die Oberfläche sei geschlossen. Also erklärte ich dem Team, das Mesh sei sauber. Das war es nicht, und die Person, die die Schlitze rot markiert hatte, widersprach zu Recht. Ein Mesh kann geschlossen sein und trotzdem Tunnel haben. Die einzige Kennzahl, die sie erfasst, ist die Euler-Charakteristik V minus E plus F, aus der sich die Anzahl der Henkel pro Hülle ergibt. Der Vogel hatte 69 davon. Jede Wand im Export war verdoppelt. Daher ist ein Schlitz in der Außenwand, der mit dem Hohlraum dahinter verbunden ist, ein Henkel und keine Öffnung. Ein Verfahren, das nach offenen Kanten sucht, wird ihn niemals finden.
Die zweite, noch peinlichere Lektion: Ich verbrachte eine Weile damit, dunkle Pixel auf Löcher zu untersuchen, weil Löcher dunkel sind, bis man mir sagte, ich solle mir zuerst das Bild ansehen. Die Löcher waren hell. Es waren schmale Streifen der grünen Textur der gegenüberliegenden Wand, die durch eine graue Falte hindurchschienen. Ein Algorithmus, den man nicht auf die richtige Stelle angesetzt hat, sagt nichts aus, und der günstigste Weg, ihn richtig auszurichten, besteht darin, hinzusehen.
Schritt zwei: Die Phase finden, in der sie entstehen
TRELLIS.2 erstellt ein Mesh in drei Schritten. Ein Decoder gibt eine rohe Oberfläche mit einer Auflösung von 1024 hoch drei aus. Ein Narrow-Band-Dual-Contouring-Durchlauf erzeugt daraus ein neues Mesh. Ein Decimator reduziert es auf hunderttausend Flächen, und die Textur wird gebacken. Wir speicherten die Geometrie nach jedem Schritt und schickten jedes Mal dasselbe Strahlenbündel durch den Flügelansatz.
Das Problem beginnt bei der rohen Oberfläche. Sie ist eine Ansammlung offener Flächen: 266.242 Randschleifen, die meisten davon einzelne fehlende Quads, sowie 65.371 nichtmannigfaltige Kanten. Am Flügelansatz gingen ein Drittel der Teststrahlen direkt hindurch. Die anschließende Lochfüllung schließt fast nichts: 144 Flächen von fünf Millionen. Der Remesher arbeitet mit einem vorzeichenlosen Abstandsfeld, weil ihm nichts anderes übrig bleibt. Er hat weder ein Innen noch ein Außen, mit dem er arbeiten könnte, und legt daher eine dünne, versetzte Hülle um jede Fläche. Deshalb ist jede Wand verdoppelt, mit einem Abstand von etwa vier Voxeln, und so wird aus jedem porösen Streifen eine Falte mit einem Schlitz. Die Dezimierung verschlimmert das Problem anschließend, und zwar zufällig: Dasselbe rohe Mesh lieferte bei zwei Dezimierungen 69 beziehungsweise 107 Tunnel.

Diese Karte war der erste echte Hinweis. Die Porosität ist nicht zufällig. Sie befindet sich an jedem Übergang zwischen Teilen und nirgendwo sonst.
Was nicht funktioniert hat
Wir probierten die Ansätze ungefähr in der Reihenfolge aus, in der sie uns naheliegend erschienen.
| Versuch | Ergebnis |
|---|---|
| Auflösung 1536 statt 1024 | bei drei von fünf Testmodellen schlechter. Eine höhere Auflösung erfasst mehr dünne Kontakte, von denen jeder einen neuen Übergang bildet |
| die integrierte Bereinigungskaskade | mehr nichtmannigfaltige Kanten statt weniger |
| jedes kleine Loch mit MeshLib füllen | 266.000 Schleifen gefüllt, Flügelansatz unverändert. Die Oberfläche wurde dort nie ausgegeben, daher gibt es keine Schleife, die man füllen könnte |
| vorzeichenbehaftetes Remeshing anhand einer Windungszahl | zertrümmert das Mesh in Tausende Hüllen. Die rohe Ausgabe hat kein Inneres, dem man ein Vorzeichen zuweisen könnte |
| ein breiteres Contouring-Band mit 2 und 3 Voxeln | insgesamt weniger Tunnel, aber der Schlitz wird breiter statt schmaler. Kleine Tunnel verschmelzen zu großen |
| morphologisches Schließen der Voxelstruktur | verfestigt die Hülle; der Decoder gibt anschließend in jedem inneren Voxel eine Oberfläche aus: viermal so viele Flächen, 969 Tunnel |
| globales Voxel-Remeshing des finalen Meshes | Genus 0 und der Schlitz ist versiegelt. Aber auch jede Federkante ist weicher. Beim ersten Ansehen zu Recht abgelehnt |
| Verschieben des Crossing-Schwellenwerts des Decoders | offene Kanten von 65.000 auf 58.000. Rauschen |
| Ergänzen fehlender Nachbarvoxel | 560 Fälle im gesamten Vogel. Rauschen |

Beim globalen Schließen lohnt es sich, etwas länger zu verweilen, denn genau dazu würde ein 3D-Artist zuerst greifen: Voxel-Remesh mit einer Größe knapp oberhalb der Lücke ausführen. Topologisch funktioniert es. Es verfälscht aber auch das Modell. Der Generator hatte scharfe Federkanten erzeugt, und wir würden sie gegen einen geschlossenen Flügelansatz eintauschen. Zwei der neun Versuche reduzierten die Tunnelzahl, und beide wurden in dem Moment verworfen, als sich jemand den Vogel statt der Zahl ansah.
Den Code lesen
An diesem Punkt blieb als einzig ehrliche Maßnahme, den Decoder nicht länger als Blackbox zu behandeln und nachzulesen, wie aus seiner Ausgabe ein Mesh wird. TRELLIS.2 verwendet eine Repräsentation namens O-Voxel, und die Extraktion ist kurz. Jedes aktive Voxel enthält einen dualen Vertex und drei Logits, einen für jede Gitterkante, die von seiner Ecke ausgeht. Jeder davon bedeutet: „Die Oberfläche kreuzt diese Kante.“ Ein Logit über null erzeugt ein Quad, das die vier Voxel um diese Kante verbindet. Fehlt eines der vier, wird das Quad stillschweigend verworfen. Das ist alles. Es gibt kein Vorzeichenfeld, keine Belegung und im gesamten Modell weder innen noch außen.
Wir patchten den Decoder so, dass er diese Logits vor der Anwendung des Schwellenwerts ausgab, und zählten sie beim selben Vogel.
Fehlende Nachbarn: 560 von 2,7 Millionen Voxeln. Logits nahe null: 8.600 von 8 Millionen. Keines von beidem ist das Problem. Das Problem wird sichtbar, wenn man die vier Kanten um jede Gitterfläche betrachtet. Eine geschlossene Oberfläche kreuzt den Rand einer Fläche geradzahlig oft. Bei 126.542 Flächen, also 2,3 Prozent, widersprechen sich die vier Flags, und jede Fläche mit ungerader Anzahl ist per Definition eine offene oder nichtmannigfaltige Kante. Und dieser Widerspruch ist eindeutig. Das einzelne gesetzte Flag auf einer Fläche mit genau einer Kreuzung hat einen Logit von +10, während die Nachbarn bei -4 liegen. Die medianen Kosten, um ein Flag umzudrehen und die Parität wiederherzustellen, betragen 2,3 Logits. Das ist kein geringfügig falsch gesetzter Schwellenwert. Das Modell ist sich einer Oberfläche sicher, die sich nicht schließen kann.

Dann maßen wir den Streifen selbst, und zwar an exakt dem Oberflächenpunkt, auf den unsere fest positionierte Kamera die ganze Woche geblickt hatte. Im Umkreis von zwölf Voxeln befinden sich dort 2,1-mal so viele aktive Voxel, wie eine einzelne flache Fläche benötigen würde. Auf der Brust sind es zum Vergleich 1,2-mal so viele. Die Unterseite des Flügels und der Körper sind zwei Oberflächen, die innerhalb derselben Voxelschicht verlaufen. Die Repräsentation hat nur einen Vertex pro Voxel und kann daher nicht beide enthalten. Während des Trainings ist die Ground Truth für ein solches Voxel ein Least-Squares-Mittelwert beider Oberflächen mit „beliebige Kreuzung“-Flags, was bereits in sich widersprüchlich ist. Das Modell hat also zuverlässig gelernt, dort einen Kamm auszugeben. Der Remesher hüllt den Kamm in eine Platte, und diese Platte hat Tunnel.
Die Breite des Streifens ergibt sich aus der Geometrie: ungefähr ein Voxel geteilt durch den Tangens des Kontaktwinkels. Zwei Oberflächen, die sich im rechten Winkel treffen, ergeben eine ein Voxel breite Falte und bereiten keine Probleme. Ein Flügel, der flach am Körper anliegt, Feder auf Feder, Zehen auf dem Boden – all das erzeugt einen zehn oder fünfzehn Voxel breiten Streifen. Damit ist die Porositätskarte erklärt.
Ich versuchte auch die prinzipiell saubere Reparatur: Für jede Ecke einen Innen-/Außen-Zustand abzuleiten, der die Flags bestmöglich erklärt, und diese dann daraus neu zu bestimmen. Das kann nicht funktionieren. Das Vorzeichenfeld existiert nur auf der einen Voxel breiten Hülle, die der Decoder ausgegeben hat, und jede Erweiterung abseits dieser Hülle gerät außer Kontrolle. Zwölf Durchläufe verdoppelten die Voxelzahl mit Ausschuss.
Was ein 3D-Artist tun würde
Ein 3D-Artist würde niemals zwei Oberflächen mit einem haarbreiten Abstand belassen. Er würde den Flügel mit dem Körper vereinigen und eine Falte zulassen. Das globale Schließen tut dies überall, und genau das ist das Problem. Also tun wir es nur dort, wo sich die beiden Oberflächen befinden.
Der Detektor folgt einem einfachen Gedanken. Zähle für jedes Voxel die aktiven Nachbarn innerhalb von vier Voxeln. Auf einer einzelnen Fläche ist diese Zahl überall gleich und entspricht dem Median. Wo sich zwei Flächen eine Schicht teilen, ist sie ungefähr doppelt so hoch. Markiere alles oberhalb des 1,5-Fachen des Medians, erweitere die markierte Menge um ein Voxel und setze an jede Position einen Einheitswürfel. Der Raum zwischen den Oberflächen wird massiv, der Contouring-Durchlauf sieht nur noch eine Wand, und nichts anderes am Modell wird verändert.

Wir testeten es zunächst an einem Ausschnitt, wobei MeshLib das GPU-Contouring ersetzte. Aus dreiundfünfzig Hüllen und neunzehn Henkeln wurden zwei Hüllen und fünf Henkel. Dann integrierten wir es zwischen Decoder und Export in die echte Pipeline und ließen denselben Vogel durch den echten Remesher und den echten Textur-Bake laufen.

| Tunnel | nichtmannigfaltige Kanten | Lasche am Flügelansatz | Loch am Rücken | |
|---|---|---|---|---|
| unverändert | 79,5 | 318 | vorhanden | vorhanden |
| Füllung, Radius 3 | 84,5 | 97 | verschwunden | verschwunden |
| Füllung, Radius 4 | 72 | 38 | verschwunden | verschwunden |
Die Lasche ist weg. Auch ein zweites Loch am Rücken des Vogels, derselbe Übergang von hinten betrachtet, dem ich nicht einmal nachgegangen war, ist verschwunden. Die Federn bleiben getrennt, die Zehen bleiben getrennt, und die Textur wird normal gebacken, weil die Würfel innerhalb der Hülle liegen, wo das Attributvolumen bereits Daten enthält. Bei einem vierminütigen Job verlängert sich die Laufzeit um sechs Sekunden. Die Funktion ist jetzt standardmäßig aktiviert, mit Radius vier.

Was dadurch nicht behoben wird und was es uns verrät
Die Tunnelzahl hat sich kaum verändert, und ich möchte ehrlich erklären, warum. Die verbleibenden Tunnel befinden sich an den Spitzen der Federn und Zehen, wo ein dünnes Teil endet und die doppelte Wand zusammenläuft. Sie sind klein, nicht sichtbar und entstehen durch einen anderen Mechanismus. Die Füllung zielt auf Kontakte zwischen Teilen, denn dort befanden sich die sichtbaren Löcher. Die eigentliche Lösung muss vorgelagert ansetzen. Ein Decoder, der Vorzeichen pro Ecke statt Flags pro Kante vorhersagt, wäre konstruktionsbedingt paritätskonsistent. So funktionierte auch die vorherige Generation dieses Modells, weshalb ihre Meshes wasserdicht waren. Das erfordert eine Änderung am Training und lässt sich nicht bei der Inferenz nachträglich beheben. Bis dahin ergeben sich aus der Streifenformel zwei praktische Konsequenzen. Wenn du eine Figur generierst, lässt sich eine Pose, bei der die Gliedmaßen vom Körper abstehen, sauberer vermeshen als eine, bei der sie flach am Körper anliegen. Und wenn du auf einem solchen Generator aufbaust, solltest du deine Meshes mithilfe der Euler-Charakteristik überprüfen und das Ergebnis mit eigenen Augen betrachten, bevor du einer Zahl vertraust. Die Zahl sagte, das Mesh sei sauber. Sie lag gleich zweimal falsch.
Der instrumentierte Decoder, die Zwischenstände der einzelnen Phasen, die Paritätsanalyse und das Füllverfahren sind jetzt alle Teil unserer Pipeline. Wenn das nächste Modell also Meshes mit Löchern erzeugt, können wir es innerhalb weniger Minuten statt erst nach einer Woche auf dieselbe Weise untersuchen.