Im Entwicklerblog dieser Woche schauen wir uns unsere tapferen Grafik-Architekten an, wie sie die Herausforderung gemeistert haben, 15 Jahre Inhalte von RuneScape auf einer noch nie dagewesenen Bandbreite an Geräten gut aussehen und laufen zu lassen, indem sie eine einzigartige Bandbreite an sowohl alten als auch neuen Rendering-Techniken einsetzen.
Wenn ihr angehende Grafikentwickler oder Technikfans seid, dann dürfte euch dies ganz besonders interessieren!
Bisher haben unsere Entwicklerblogs kaum an der Oberfläche der aufregenden Features gekratzt, die den neuen RuneScape-Client so außergewöhnlich machen. In diesem Blog sehen wir uns diese Features mal genauer an, wie sie umgesetzt werden und warum wir uns für sie entschieden haben.
Eine der größten Herausforderungen bei der Entwicklung von NXT war es, die grafische Wiedergabe und Liestungsstärke zu verbessern, während das Spiel immer noch so aussieht wie das RuneScape, das ihr alle so gerne spielt. Und so haben wir das geschafft.
Globale Beleuchtung
Mithilfe der globalen Beleuchtung geben Spiele und Filme indirektes Licht in einer Szene wieder (z.B. reflektiertes Licht). Ohne eine globale Beleuchtung wäre jedes Pixel im Schatten schwarz.
Keine globale Beleuchtung
Dies ist bei den interaktiven Raten extrem schwierig zu lösen, sodass selbst heute noch die meisten Spiele offline einen Vorbearbeitungsalgorithmus benutzen, um globale Lichtergebnisse in Lightmaps zu integrieren, damit diese beim Laufen schnell nachgeguckt werden können.
Die klassische Technik bei älteren Spielen war beispielsweise die sogenannte Pre-Baked Radiosity (z.B. bei Quake und Half-Life), aber in letzter Zeit wurden mehr Details der globalen Beleuchtung in die Lightmaps mit eingebaut, wie Kugelfunktionen (Spherical Harmonics) und Oberflächensichtbarkeit (Surface-To-Surface Visibility), was den zusätzlichen Bonus hat, dass die globale Beleuchtung mit Normal Maps von sich bewegenden Lichtern funktioniert.
Als allerneueste Technik sind nun reine Echtzeit-Lösungen aufgetaucht (Light Propagation Volumes und Voxel Cone Tracing), aber diese Techniken sind noch nicht voll ausgereift und benötigen sehr gute Grafikprozessor-Technologien, um effektiv zu laufen.
Das ist etwas, was wir bei unserem neuen Client wirklich verbessern wollten, aber aufgrund der Einschränkungen unserer Tools und der schieren Größe von RuneScape war eine Offline-Lösung der globalen Beleuchtung einfach keine brauchbare Option. Zusätzlich dazu wollten wir natürlich auch etwas entwickeln, das auf so vielen Computern wie möglich laufen wird.
Also haben wir tief in unsere Grafiktoolbox gegriffen und uns für eine modernere Version von Hemisphre Lighting entschieden, bei der Irradiance Environment Maps durch Spherical Harmonics fusioniert werden.
Hemisphere Lighting verwendet eine manuell platzierte Sphäre in allen Umgebungen, um einen Farbverlauf von Himmels- bis zu den Bodenfarben zu definieren. Der Normalenvektor der Szenengeometrie wird dann verwendet, um eine Farbe von dieser Sphäre zu bestimmen. Wenn eine Oberfläche also beispielsweise in den Himmel gerichtet ist, wird die Farbe oben auf der Sphäre ausgewählt und so weiter.
Wenn unsere Grafiker all diese Lichtsphären selber hätten herstellen müssen, wäre das sehr zeitaufwändig gewesen und hätte größere Instandhaltungskosten bedeutet. Daher haben wir uns für eine Echtzeit-Programmierungslösung entschieden.
Hierbei wird, nachdem die Kartenbereiche geladen wurden, hoch in der Luft über mehrere Frames hinweg eine Lichtsonde gerendert (Global Environment Map), wodurch die Spherical Harmonics davon in Echtzeit integriert werden und wir eine höchst komprimierte Irradiance Environment Map in Form von Koeffizienten aus Spherical Harmonics erhalten.
Diese Koeffizienten werden dann im Pixel Shader mit intelligenter Mathematik benutzt, um den Normalenvektor als Dateneingabe zu verwenden, damit wir die Beleuchtungsdichte bei diesem Pixel erfahren. Ultimativ erhalten wir dadurch eine einzelne indirekte Lichtreflexion vom Sonnenlicht (Irradiance Lighting) - oder, wie wir es gerne nennen, Hemisphere Lighting nach ner Steroidenüberdosis!
Zusätzlich zum Irradiance Lighting haben wir unserem Lichtblender eine Umgebungsverdeckung (Ambient Occlusion) hinzugefügt. Ambient Occlusion simuliert sanfte, kleinräumige Schatten aus der Umgebungsbelichtung je nachdem, wie sichtbar eine Oberfläche ist. Hierfür haben wir uns für eine Screen-Space Ambient Occlusion (SSAO) namens Horizon-Based Ambient Occlusion entschieden, die gerade so ziemlich das Beste auf dem Markt ist.
Anders als bei den meisten Spielen, die die SSAO als Nachbearbeitung benutzen - was zu ziemlich schlechten und nicht überzeugenden Resultaten führen kann - wird unsere Ambient Occlusion vorher beim Forward Lighting Pass nur für indirekte Umgebungsbeleuchtung verwendet. Dadurch weisen Pixel, die direkt beleuchtet werden, nicht zu viel Ambient Occlusion auf, was physisch korrekter ist. Unsere SSAO wird auch mit voller Auflösung durchgeführt, wodurch stabilere Resultate erzielt werden.
Die folgenden Screenshots zeigen euch unsere kombinierten Lösungen für die Irradiance und Ambient Occlusion. Hoffentlich stimmt ihr uns zu, dass die Resultate eine bedeutende Verbesserung im Vergleich zu den Ergebnissen des alten Java-Clients sind.
Globale Beleuchtung bei Java Globale Beleuchtung bei NXT Globale Beleuchtung bei Java Globale Beleuchtung bei NXTHDR, Gamma Correct Rendering und Tone Mapping
Ein weiterer Grund, warum der Java-Client so flach, matt und übersättigt aussieht, ist, dass er die ganze Bandbreite an Farben und Lichtintensitäten in der Szene nicht wiedergeben kann. Um diese Situation zu verbessern, mussten wir erst sicherstellen, dass die Grafikprozessoren alle Lichtkalkulationen in einem linearen Raum berechnen.
Alle Grafikprozessoren führen ihre Rechnungen mit Hochpräzisions-Fließkommamathematik aus, doch um gänzlich hiervon profitieren zu können, müssen die Daten für die Lichtberechnungen während der Shaderausführung ebenfalls in einem linearen Raum sein. Daher mussten wir sicherstellen, dass die Texturen aus dem Spiel - die aus Photoshop in einem sRGB-Raum gespeichert werden - in einen linearen Raum konvertiert werden, bevor sie von den Shadern genutzt werden.
Diesen Prozess wenden wir auch bei anderen durch unsere Künstler definierte Daten an, wie Licht- und Nebelfarben. Die meisten Grafikprozessoren können diese Umwandlung vom sRGB- zum linearen Raum durchführen. Ist dies nicht der Fall, haben wir jedoch auch die Möglichkeit, dies manuell zu tun. Hierdurch stellen wir sicher, dass die hochdynamische Bandbreite des Lichts nicht durch nichtlineare Daten verdorben wird und dass die Belichtung in der Szene konsistent bleibt. Außerdem verhindern wir so, dass Gegenden, die sehr gut beleuchtet sind, zu schnell 'verglühen', wodurch die Lichtdetails zerstört werden können.
Der nächste Bestandteil, um eine volle HDR (hochdynamische Reichweite) beim Rendering zu erreichen, liegt darin, dass wir die Resultate der Lichtberechnungen des linearen Raums in Texturen außerhalb der Bildfläche speichern, die selber ihre Linearität beibehalten können, indem sie das Fließkomma-Format verwenden. Fließkomma-Texturen können jedoch teuer werden, daher versuchen wir immer ein gepacktes Fließkommatexturformat zu verwenden, wenn es zur Verfügung steht.
Das Sahnehäubchen auf dem HDR-Kuchen ist das Tone Mapping, ein Prozess, der Farbbereiche miteinander kartografiert. In unserem Fall bedeutet das, dass lineare Lichtergebnisse der HDR in eine Bandbreite umgewandelt werden, mit der der Bildschirm klarkommt, da Bildschirme nur Werte im niedrigen Bereich (Low Dynamic Range) anzeigen können.
Ohne Tone Mapping würde eine direkte Umwandlung von einer hohen in eine niedrige dynamische Reichweite zu einem Verlust von Lichtinformationen und einem unschönen Aussehen führen. Daher mussten wir unermüdlich mit den Künstlern zusammenarbeiten, um viele verschiedene Tone-Mapping-Formeln auszurechen, um zu einem Ergebnis zu kommen, das dem bestehenden Aussehen von Runescape am meisten ähnelt und gleichzeitig eine gute dynamische Bandbreite an Farben und Lichtintensität bietet. Das haben wir in der Form vom sogenannten Filmic Tone Mapping erreicht.
Echtzeit-Schatten
Bei den meisten Spielen, die ihre globale Beleuchtung offline berechnen können, sind Schatten, die bei statischer Szenengeometrie durch dominante Lichtquellen ausgelöst werden (z.B. Sonnenlicht), meistens als Teil dieses Lichtintegrierungsprozesses mit inbegriffen (Schatten sind eigentlich Teil der globalen Beleuchtung, aber darauf werden wir in diesem Blog nicht näher eingehen). Dies war erneut keine Option für uns, also haben wir uns für eine voll dynamische Lösung entschieden. Die Herausforderung bei einem voll dynamischen Echtzeit-Schattensystem ist es, sowohl eine gute Qualität als auch eine gute Leistungsfähigkeit zu erreichen. Für Qualität und Leistungsfähigkeit liegt die beste Technik, um Echtzeit-Schatten mit modernen Grafikprozessoren zu rendern, im Shadow Mapping. Bei jedem Algorithmus für das Shadow Mapping gibt es jedoch zwei Hauptprobleme: Projective und Perspective Aliasing, hauptsächlich aufgrund fehlender Auflösung in der Shadow Map. Ohne eine ausreichende Auflösung können mehrere Shadow-Map-Texel zu einem einzigen Screen-Pixel kartiert werden, wodurch es zu schweren Aliasing-Bildfehlern kommen kann.
Wir haben uns für das Format der Parallel Split Cascaded Shadow Map entschieden, wo die sichtbare Szene in zwei Segmente unterteilt wird, wobei jedes Segment einer einzigen Shadow Cascade entspricht, wodurch ein besseres Verhältnis von Shadow Texel Map zu Screen Space Pixeln erreicht wird. Dadurch wird das Perspective Aliasing bedeutend verbessert, was ansonsten sichtbar wäre, wenn nur eine einzige Shadow Map für die ganze Szene verwendet werden würde. Der Nachteil dabei ist jedoch, dass die Szene für jede Shadow Map Cascade noch mal gerendert werden muss, wodurch die Anzahl an gezeichneten Objekten für jede Frame massiv erhöht wird und die Leistungsstärke des Clients extrem beeinträchtigt werden kann.
Eine mehrgleisige Herangehensweise war also nötig, um diese Explosion von Draw Calls abzuschwächen. Es gibt verschiedene Stufen von Culling, die mit jedem Shadow Cascade Render Pass angewendet werden, inklusive dem View Frustum Culling, Distance-Based Culling, Shadow Map Area Culling und Shadow Caster Volume Culling. Zusätzlich dazu können wir mehr Objekte bearbeiten, indem wir verschiedene Vermutungen über die Szene anstellen. Zum Beispiel rendern wir keine flachen Gebiete in der Shadow Map, da sie wahrscheinlich keine Schatten auf die Szene werfen und aktualisieren entfernte Cascades nur in abwechselnden Frames, um die Anzahl der Draw Calls zu verringern.
Um das Problem mit dem Shadow Map Aliasing weiter zu reduzieren, verwenden wir für die entfernten Cascades, die mehr von der sichtbaren Szene abdecken, einen Algorithmus, der allgemein als Unit Cube Clipping bekannt ist und die orthografische Projektion der Shadow Map den sichtbaren Werfern/Empfängern entsprechend anpasst. Dadurch kann die Verwendung der Shadow Map in vielen Szenen bedeutend verbessert werden.
Wir müssen dafür sogen, dass jeder Draw Call für eine Shadow Map so günstig wie möglich ist. Dazu verwenden wir eine Reihe an Tricks, wie etwa die Deaktivierung von Farbenaufzeichnungen, das angepasste Schneiden von Vertex Shadern, Null Pixel Shadern und Minimal-Vertexformaten. Dadurch wird die Nutzung des Grafikprozessor während des Generierens von Shadow Maps auf ein Minimum reduziert.
Das letzte Stück der Qualität und Leistungsstärke bei allen Techniken des Shadow Mappings liegt in der Filterung, um sanfte Schattenkanten zu erreichen. Wir nutzen die Vorteile des GPU PCF (Percentage Closer Filtering) Hardware Shadow Map Filtering gänzlich aus, wodurch wir geschmeidig eingefügte Muster der Shadow Map erhalten. Kombiniert mit Multifilter-Systemkernen und besonderen Textur-Nachschlagefunktionen, um die Nutzung des allgemeinen Registers zu reduzieren, erlaubt es uns, weiche Schatten mit einer hohen Leistungsrate zu erzielen.
Wenn ihr es bis hierher geschafft habt, dann ist euch mittlerweile bestimmt bewusst, wie sehr das Echtzeit-Rendering der Shadow Map einem wie Jedikünste vorkommen können, aber die Resultate sind die Arbeit wirklich wert!
Light Indexed Deferred Lighting
Die Umgebungen in RuneScape verfügen wirklich über sehr viel Licht. Der neue Client führt alle Berechnungen für die Beleuchtung pro Pixel durch, ohne jegliche Offline-Lichtintegrierung, wodurch wir jegliches Licht als dynamisch ansehen müssen. Ein moderner Ansatz einer derart dynamisch beleuchteten Szene ist das Fully Deferred Shading. Diese Art der verzögerten Schattierung hat jedoch ihre eigenen Nachteile und wäre keine brauchbare Lösung für uns, während wir immer noch die Geräte mit niedriger Leistungsfähigkeit unterstützen wollen.
Daher wollten wir also bei unserer Vorwärtsrendering-Technik beim Licht bleiben. Die Standardherangehensweise bei Licht mit 8-16 Lichtelementen pro Objekt war jedoch aufgrund der Größe von den Geometriepaketen einfach nicht ausreichend. Wir mussten also mal wieder etwas unkonventionell denken!
Wir haben uns für eine Lösung entschieden, die als Light-Indexed Deferred Lighting bekannt ist, was ungefähr in der Mitte zwischen Fully Deferred Shading und Forward Lighting liegt. Dadurch können wir bis zu vier Lichter pro Pixel unterstützen und es passt perfekt in unseren Forward-Lighting-Renderingprozess. Das hat so ziemlich all unsere N-Licht-Probleme für große statische Geometriepakete gelöst, während es immer noch die MSAA-Unterstützung und verschiedene Lichtformeln für zukünftige Materialvariationen zulässt.
Viele Lichtpunkte Atmosphärische LichtstreuungBei der größeren Sichtweite wussten wir, dass der Distanzen-Nebel von Java einfach nicht gut genug wäre. Wir haben damit angefangen, den distanzbasierten Nebel komplett zu entfernen und ihn mit einer physischbasierten, atmosphärischen Lichtstreuungstechnik zu ersetzen, was gut aussah, uns jedoch nicht komplett erlaubte, die Grenzen der Welt zu verdecken. Also haben wir uns dafür entschieden, den alten Distanzen-Nebel mit unserer neuen atmosphärischen Lichtstreuung zu verbinden, um eine Hybridlösung zu bekommen, die den alten Nebel verbessern kann. Das Endresultat hat uns einen viel natürlicher aussehenden Nebel für die Szene eingebracht - vor allem bei größerer Sichtweite - und uns auch geholfen, einen besseren Eindruck von Tiefe hervorzurufen.
Wasser-Rendering
Ein Schlüsselfeature des neuen Effektsystems ist definitiv der neue Wasser-Shader. Wie bei quasi allem anderen auch haben wir den Shader völlig neu entwickelt, haben allerdings außerdem noch die kühne Entscheidung getroffen, auf das alte Java-Datenset zurückzugreifen, um verschiedene Probleme zu verbessern, die wir von HTML5 geerbt haben. Wir nutzen die Wasserdaten immer noch für Echtzeit-Ebenenreflexionen, aber die Wassergeometrie an sich wird nun wieder von dem Java-Datenset hergestellt, wodurch die Künstler nicht mehr zurückgehen mussten, um alle Patches noch mal zu machen, was uns richtig viel Entwicklungszeit gespart hat.
Der Shader selbst basiert auf vielen Elementen, um den endgültigen Look zu erzielen. Die zwei Hauptkomponenten von allen Wasser-Renderingsystemen ist die Unterstützung von Echtzeit-Reflexionen und Lichtbrechungen, die waren also ein Muss. Zusätzlich dazu stellen wir noch sicher, dass das Licht auf dem Wasser richtig mit unserem Schattensystem funktioniert, sodass direkte Spiegelungen nun korrekt abgedeckt werden, wo Schatten hinfallen. Der Welleneffekt wurde ebenfalls verbessert, indem geändert wurde, wie die Water Normal Maps abgefragt werden, wo es keine oder kaum Verzerrungen gibt, was Bildfehler bedeutend reduziert, die man sonst oft bei Wassereffekten sieht. Und letztlich haben wir mit dem alten Datenset Zugang zu verbesserten Tiefeninformationen für Unterwassergebiete erhalten, wodurch wir verschiedene Komponenten wie Nebel und Wellenverzerrungen ausblenden konnten, wenn das Wasser auf Landmasse trifft.
Hoffentlich stimmt ihr uns zu, dass einem bei den Ergebnissen wirklich das Waser im Mund zusammenläuft!
Zusammenwirkung von Schatten, Licht und Wasser
Reduzierungstechniken für Draw Calls
Obwohl es an sich kein Feature ist, wäre es nachlässig von mir, wenn ich einen der Hauptgründe nicht nennen würde, warum wir eine bessere Leistung als Java erreichen konnten, während wir dennoch so viel mehr rendern. Ich bin bereits kurz darauf eingegangen, wie wir die Draw Calls für unser Shadow Map Rendering bedeutend verringern konnten, aber noch wichtiger war es, die Draw Calls für das Forward Lighting und die Szenentiefe zu reduzieren.
Das wahrscheinlich teuerste Element einer Spielengine in Bezug auf Prozessor/Grafikprozessorzeiten ist es, eine Anfrage zu senden, Objekte direkt auf dem Bildschirm zu zeichnen. Die Kosten sind dabei zweifältig: Erstmal muss man die Kosten des Overhead-Grafiktreibers für Draw Calls beim Nutzen von Grafikprozessor-Befehlbuffern tragen und zweitens kommen noch die eigentlichen Grafikprozessor-Kosten bei der Verarbeitung von Eckpunkten und Pixeln auf einen zu.
Ich hatte bereits das standardmäßige View Frustum und Distance-Based Object Culling erwähnt, aber durch die bedeutend höheren Kosten bei den Forward Lighting Pass Draw Calls im Vergleich zu einfachen Shadow Pass Draw Calls mussten wir noch einen draufsetzen.
Die größte Kosteneinsparung für Draw Calls kommt von unserem dynamischen Geometrie-Batching-System. Wenn Objekte geladen werden, werden sie auch in Gruppen eingeteilt, die dasselbe Material teilen, und dann physisch zusammengefügt, damit sie in einem einzigen Draw Call gerendert werden können. Dies hat jedoch auch einen Nachteil, da ein komplexes Texture-Atlas-System benötigt wird, damit auf alle Texturen von diesen gruppierten Objekten über eine einzelne Texturseite zugegriffen werden kann. Dies hat den Dominoeffekt, dass die Größe der Modeleckpunkte aufgrund der zusäztlich benötigten Information, um die Texturen eines Objektes in diesen Atlas-Systemen abzurufen, aufgebläht wird, zusammen mit einer erhöhten Anzahl an Anweisungen für den Pixel Shader. Diese zusätzlichen Kosten sind jedoch immer noch gering im Vergleich zum Nettogewinn für die Leistung aufgrund der massiven Reduzierung der Draw Calls durch die Pakete. Dasselbe Paketsystem wird auch bei allen anderen Renderinganfragen genutzt, um die Draw Calls noch mehr zu reduzieren.
Jede Farbe bedeutet einen einzelnen Draw Call Batch
Der letzte Bestandteil unserer Kostenreduzierung für Draw Calls liegt in unserem einzigartigen und innovativen Culling-System. Anders als bei den meisten Lösungen für Occlusion Culling, für die eine Offline-Szenenverarbeitung benötigt wird, um potenziell sichtbare Sätze an Orten in der Welt zu generieren, sowie handgemachte Occluder-Geometrie für Echtzeit-Lösungen benötigt unser Ansatz keines von beidem. Dies entstand hauptsächlich, da es nicht möglich war, die großen Mengen an Umgebungen von RuneScape offline zu verarbeiten oder - aufgrund von geringen Kunstressourcen - Proxy Occluder Geometry für eine Onlinelösung zu generieren. Daher haben wir eine Hybrid-Technik entwickelt, die einen Software Rasteriser zum Ausführen von Occlusion-Anfragen kombiniert. Anstatt jedoch die Tiefendaten der Szene auf Seiten des Prozessors zu generieren, transferieren wir die Buffer-Daten der Szenentiefe, die durch vorherige Frames generiert wurden, um unsere Anfragen zur Software Occlusion vom Prozessor zu versorgen. Der Tiefenbuffer-Read-Back vom Prozessor und die Anfragen zur Software Occlusion haben hohe Fixkosten, aber in Szenen mit hoher Tiefenkomplexität sind die Reduzierungen der Draw Calls hoch genug, dass wir beträchtliche Nettogewinne bei der Leistung sehen, vor allem bei Geräten mit schlechteren Grafiktreibern.
Viel Spaß!
Ich hoffe, dieser Entwicklerblog war aufschlussreich und hat euch gezeigt, was für eine Herausforderung es war, euch einen neuen Client zu bieten, der mit 15 Jahren Inhalten besser läuft und auch besser aussieht als der alte Java-Client.
Wir glauben, dass die Entscheidungen, die wir während dieses Projekts getroffen haben, ein starkes Fundament sind, auf dem RuneScape nun aufbauen kann, um euch in den kommenden Jahren eine noch bessere Grafik und Leistungsstärke bieten zu können.
Mod Lordgit
Leitender Grafik-Programmierer