Fallstudie: Geräteübergreifendes Skalieren von Datascape mit unterschiedlicher Leistung

Datascape ist eine intern bei Microsoft entwickelte Windows Mixed Reality-Anwendung, bei der wir uns auf die Anzeige von Wetterdaten auf der Grundlage von Geländedaten konzentriert haben. Die Anwendung untersucht die einzigartigen Erkenntnisse, die Benutzer durch die Ermittlung von Daten in Mixed Reality gewinnen, indem sie den Benutzer mit holografischer Datenvisualisierung umgibt.

Für Datascape wollten wir eine Vielzahl von Plattformen mit unterschiedlichen Hardwarefunktionen anzielen, die von Microsoft HoloLens bis hin zu Windows Mixed Reality immersiven Headsets reichen und von PCs mit niedrigeren Leistung bis hin zu den neuesten PCs mit High-End-GPU reichen. Die größte Herausforderung bestand darin, unsere Szene auf Geräten mit sehr unterschiedlichen Grafikfunktionen visuell ansprechender zu gestalten, während sie mit hoher Framerate ausgeführt wurde.

Diese Fallstudie führt Sie durch den Prozess und die Techniken, die zum Erstellen einiger unserer GPU-intensiven Systeme verwendet werden, und beschreibt die aufgetretenen Probleme und wie wir sie übersprungen haben.

Transparenz und Überzeichnung

Bei unserem Hauptrendering geht es um Transparenz, da Transparenz auf einer GPU teuer sein kann.

Die Solid-Geometrie kann beim Schreiben in den Tiefenpuffer von vorn nach zurück gerendert werden, sodass zukünftige Pixel hinter diesem Pixel nicht verworfen werden. Dadurch wird verhindert, dass ausgeblendete Pixel den Pixelshader ausführen, wodurch der Prozess erheblich beschleunigt wird. Wenn die Geometrie optimal sortiert ist, wird jedes Pixel auf dem Bildschirm nur einmal gezeichnet.

Transparente Geometrie muss wieder nach vorne sortiert werden und basiert darauf, die Ausgabe des Pixel-Shaders mit dem aktuellen Pixel auf dem Bildschirm zu mischen. Dies kann dazu führen, dass jedes Pixel auf dem Bildschirm mehrmals pro Frame gezeichnet wird, was als Überzeichnung bezeichnet wird.

Bei HoloLens und gängigen PCs kann der Bildschirm nur wenige Male gefüllt werden, sodass das transparente Rendering problematisch wird.

Einführung in Datascape-Szenenkomponenten

Wir hatten drei Hauptkomponenten für unsere Szene: die Benutzeroberfläche, die Karte und das Wetter. Wir wussten früh, dass unsere Wettereffekte die gesamte GPU-Zeit erfordern würden, die sie erhalten könnte. Daher haben wir die Benutzeroberfläche und das Gelände absichtlich so entworfen, dass jede Überzeichnung reduziert wird.

Wir haben die Benutzeroberfläche mehrmals überarbeitet, um die Überzeichnung zu minimieren, die sie erzeugen würde. Wir haben bei Komponenten wie leuchtenden Schaltflächen und Kartenübersichten eine komplexere Geometrie verwendet, anstatt transparente Bilder übereinander zu überlagern.

Für die Karte haben wir einen benutzerdefinierten Shader verwendet, mit dem Standardmäßige Unity-Features wie Schatten und komplexe Beleuchtung entfernt und durch ein einfaches Modell für ein einzelnes Licht und eine benutzerdefinierte Berechnung von Farben ersetzt werden. Dadurch wurde ein einfacher Pixelshader erzeugt und GPU-Zyklen freigegeben.

Wir haben es verstanden, sowohl die Benutzeroberfläche als auch die Karte für das Rendern nach Budget zu erhalten, wobei wir je nach Hardware keine Änderungen an ihnen benötigten. die Wettervisualisierung, insbesondere das Cloudrendering, hat sich jedoch als eine größere Herausforderung erwiesen!

Hintergrundinformationen zu Clouddaten

Unsere Clouddaten wurden von NOAA-Servern heruntergeladen ( https://nomads.ncep.noaa.gov/) und sind in drei unterschiedlichen 2D-Ebenen mit jeweils der oberen und unteren Höhe der Cloud sowie der Dichte der Cloud für jede Zelle des Rasters zu uns gekommen. Die Daten wurden in einer Cloudinformationstextur verarbeitet, in der jede Komponente in der roten, grünen und blauen Komponente der Textur gespeichert wurde, um den einfachen Zugriff auf die GPU zu erleichtern.

Geometriewolken

Um sicherzustellen, dass unsere Maschinen mit geringerer Leistung unsere Clouds rendern können, haben wir uns entschieden, mit einem Ansatz zu beginnen, bei dem eine solide Geometrie verwendet wird, um die Überzeichnung zu minimieren.

Wir haben zuerst versucht, Clouds zu erzeugen, indem wir ein festes HeightMap-Gitter für jede Ebene mithilfe des Radius der Cloudinformationstextur pro Scheitelpunkt generieren, um die Form zu generieren. Wir haben einen Geometrie-Shader verwendet, um die Scheitelpunkte sowohl am oberen als auch am unteren Rand der Cloud zu erzeugen, die feste Cloudformen generieren. Wir haben den Dichtewert aus der Textur verwendet, um die Cloud mit dunkleren Farben zu bemalen, um dichte Clouds zu erzielen.

Shader zum Erstellen der Scheitelpunkte:

v2g vert (appdata v)
{
    v2g o;
    o.height = tex2Dlod(_MainTex, float4(v.uv, 0, 0)).x;
    o.vertex = v.vertex;
    return o;
}
 
g2f GetOutput(v2g input, float heightDirection)
{
    g2f ret;
    float4 newBaseVert = input.vertex;
    newBaseVert.y += input.height * heightDirection * _HeigthScale;
    ret.vertex = UnityObjectToClipPos(newBaseVert);
    ret.height = input.height;
    return ret;
}
 
[maxvertexcount(6)]
void geo(triangle v2g p[3], inout TriangleStream<g2f> triStream)
{
    float heightTotal = p[0].height + p[1].height + p[2].height;
    if (heightTotal > 0)
    {
        triStream.Append(GetOutput(p[0], 1));
        triStream.Append(GetOutput(p[1], 1));
        triStream.Append(GetOutput(p[2], 1));
 
        triStream.RestartStrip();
 
        triStream.Append(GetOutput(p[2], -1));
        triStream.Append(GetOutput(p[1], -1));
        triStream.Append(GetOutput(p[0], -1));
    }
}
fixed4 frag (g2f i) : SV_Target
{
    clip(i.height - 0.1f);
 
    float3 finalColor = lerp(_LowColor, _HighColor, i.height);
    return float4(finalColor, 1);
}

Wir haben ein kleines Rauschmuster eingeführt, um weitere Details zu den echten Daten zu erhalten. Um runde Cloudränder zu erzeugen, haben wir die Pixel im Pixelshader abgeschnitten, wenn der Interpolationsradiuswert einen Schwellenwert erreicht hat, um Werte nahezu null zu verwerfen.

Geometriewolken

Da es sich bei den Clouds um eine solide Geometrie handelt, können sie vor dem Gelände gerendert werden, um alle teuren Kartenpixel darunter auszublenden, um die Framerate weiter zu verbessern. Diese Lösung funktionierte aufgrund des Renderingansatzes mit solider Geometrie gut auf allen Grafikkarten von min-spec bis high-end-Grafikkarten sowie auf HoloLens.

Festkörperteilchenwolken

Wir hatten nun eine Sicherungslösung, die eine angemessene Darstellung unserer Clouddaten erzeugte, aber im "Wow"-Faktor etwas unübersättlich war und nicht das volumetrische Gefühl vermittelte, das wir für unsere High-End-Computer wollten.

Der nächste Schritt bestand darin, die Clouds zu erstellen, indem wir sie mit ungefähr 100.000 Partikeln darstellen, um ein organisch und volumetrisches Aussehen zu erzeugen.

Wenn Partikel stabil bleiben und von vorn nach oben sortieren, können wir weiterhin von der Tiefenpuffer-Culling der Pixel hinter zuvor gerenderten Partikeln profitieren, wodurch die Überzeichnung reduziert wird. Außerdem können wir mit einer partikelbasierten Lösung die Menge der Partikel ändern, die für unterschiedliche Hardware verwendet werden. Alle Pixel müssen jedoch noch ausführlich getestet werden, was zu zusätzlichem Mehraufwand führt.

Zuerst haben wir beim Start Partikelpositionen um den Mittelpunkt der Umgebung erstellt. Wir verteilten die Partikel stärker um den Mittelpunkt und weniger in der Entfernung. Wir haben alle Partikel von der Mitte nach der Rückseite vorsortiert, sodass die nächsten Partikel zuerst gerendert werden.

Ein Compute-Shader würde die Textur der Cloudinformationen abtasten, um jeden Partikel in einer richtigen Höhe zu positionieren und basierend auf der Dichte zu farbig zu machen.

Wir haben DrawProcedural verwendet, um ein Quader pro Partikel zu rendern, sodass die Partikeldaten jederzeit auf der GPU bleiben können.

Jeder Partikel enthielt sowohl eine Höhe als auch einen Radius. Die Höhe basierte auf den Clouddaten, die aus der Cloudinformationstextur entnommen wurden, und der Radius basierte auf der anfänglichen Verteilung, in der er berechnet wurde, um den horizontalen Abstand zum nächsten Nachbarn zu speichern. Die Quader verwenden diese Daten, um sich nach der Höhe abgewinkelt zu orientieren, sodass beim horizontalen Betrachten die Höhe angezeigt wird und wenn Benutzer sie von oben nach unten betrachten, wird der Bereich zwischen den Nachbarn abgedeckt.

Partikelform

Shadercode mit der Verteilung:

ComputeBuffer cloudPointBuffer = new ComputeBuffer(6, quadPointsStride);
cloudPointBuffer.SetData(new[]
{
    new Vector2(-.5f, .5f),
    new Vector2(.5f, .5f),
    new Vector2(.5f, -.5f),
    new Vector2(.5f, -.5f),
    new Vector2(-.5f, -.5f),
    new Vector2(-.5f, .5f)
});
 
StructuredBuffer<float2> quadPoints;
StructuredBuffer<float3> particlePositions;
v2f vert(uint id : SV_VertexID, uint inst : SV_InstanceID)
{
    // Find the center of the quad, from local to world space
    float4 centerPoint = mul(unity_ObjectToWorld, float4(particlePositions[inst], 1));
 
    // Calculate y offset for each quad point
    float3 cameraForward = normalize(centerPoint - _WorldSpaceCameraPos);
    float y = dot(quadPoints[id].xy, cameraForward.xz);
 
    // Read out the particle data
    float radius = ...;
    float height = ...;
 
    // Set the position of the vert
    float4 finalPos = centerPoint + float4(quadPoints[id].x, y * height, quadPoints[id].y, 0) * radius;
    o.pos = mul(UNITY_MATRIX_VP, float4(finalPos.xyz, 1));
    o.uv = quadPoints[id].xy + 0.5;
 
    return o;
}

Da wir die Partikel front-to-back sortieren und weiterhin einen soliden Shader verwendet haben, um transparente Pixel zu beschneiden (nicht zu mischen), verarbeitet diese Technik eine überraschende Menge an Partikeln, wodurch auch auf den Computern mit geringerer Leistung teures Über zeichnen vermieden wird.

Transparente Partikelwolken

Die festen Partikel boten ein gutes natürliches Gefühl für die Form der Clouds, benötigten aber dennoch etwas, um die Flaffiness der Clouds zu verkaufen. Wir haben uns entschieden, eine benutzerdefinierte Lösung für die High-End-Grafikkarten auszuprobieren, mit der wir Transparenz einführen können.

Dazu haben wir einfach die anfängliche Sortierreihenfolge der Partikel geändert und den Shader so geändert, dass die Texturen alpha verwendet werden.

Flaffy-Clouds

Es hat gut ausgesehen, aber es hat sich als zu schwer erwiesen, selbst für die schwierigsten Computer zu sein, da es dazu führen würde, dass jedes Pixel hunderte Male auf dem Bildschirm gerendert wird!

Rendern außerhalb des Bildschirms mit niedrigerer Auflösung

Um die Anzahl der von den Clouds gerenderten Pixel zu reduzieren, haben wir damit begonnen, sie in einem Puffer mit Quartalsauflösung (im Vergleich zum Bildschirm) zu rendern und das Endergebnis wieder auf den Bildschirm zu strecken, nachdem alle Partikel gezeichnet wurden. Dies führte zu einer ungefähr vierfachen Beschleunigung, aber es gab einige Einschränkungen.

Code zum Rendern außerhalb des Bildschirms:

cloudBlendingCommand = new CommandBuffer();
Camera.main.AddCommandBuffer(whenToComposite, cloudBlendingCommand);
 
cloudCamera.CopyFrom(Camera.main);
cloudCamera.rect = new Rect(0, 0, 1, 1);    //Adaptive rendering can set the main camera to a smaller rect
cloudCamera.clearFlags = CameraClearFlags.Color;
cloudCamera.backgroundColor = new Color(0, 0, 0, 1);
 
currentCloudTexture = RenderTexture.GetTemporary(Camera.main.pixelWidth / 2, Camera.main.pixelHeight / 2, 0);
cloudCamera.targetTexture = currentCloudTexture;
 
// Render clouds to the offscreen buffer
cloudCamera.Render();
cloudCamera.targetTexture = null;
 
// Blend low-res clouds to the main target
cloudBlendingCommand.Blit(currentCloudTexture, new RenderTargetIdentifier(BuiltinRenderTextureType.CurrentActive), blitMaterial);

Zunächst haben wir beim Rendern in einen Off-Screen-Puffer alle Tiefeninformationen aus unserer Hauptszene verloren, was zu Partikeln hinter dem Rendering auf dem Mountain führt.

Zweitens wurden beim Strecken des Puffers auch Artefakte an den Rändern unserer Clouds eingeführt, bei denen die Auflösungsänderung erkennbar war. In den nächsten beiden Abschnitten wird erläutert, wie wir diese Probleme gelöst haben.

Partikeltiefepuffer

Damit die Partikel mit der Weltgeometrie koexistieren, in der ein Mountain oder Objekt Partikel dahinter abdecken könnte, haben wir den Off-Screen-Puffer mit einem Tiefenpuffer gefüllt, der die Geometrie der Hauptszene enthält. Um einen solchen Tiefenpuffer zu erzeugen, haben wir eine zweite Kamera erstellt, die nur die Vollbildgeometrie und die Tiefe der Szene rendert.

Anschließend haben wir die neue Textur im Pixelshader der Clouds verwendet, um Pixel einzuschließen. Wir haben dieselbe Textur verwendet, um den Abstand zur Geometrie hinter einem Cloudpixel zu berechnen. Indem wir diesen Abstand verwenden und auf das Alpha des Pixels anwenden, haben wir nun den Effekt, dass Clouds ausblenden, wenn sie sich dem Gelände nähern, und alle harte Schnitte entfernen, an denen Partikel und Gelände sich treffen.

In Gelände vermischende Clouds

Schärfen der Kanten

Die gestreckten Clouds wirkten fast identisch mit den Clouds mit normaler Größe in der Mitte der Partikel oder an der Stelle, an der sie sich überlappen, zeigten jedoch einige Artefakte an den Cloudrändern. Andernfalls erscheinen spitze Kanten unscharf, und Aliaseffekte wurden eingeführt, wenn die Kamera bewegt wurde.

Wir haben dies behoben, indem wir einen einfachen Shader auf dem Off-Screen-Puffer ausgeführt haben, um zu bestimmen, wo große Kontraständerungen aufgetreten sind (1). Wir legen die Pixel mit großen Änderungen in einen neuen Schablonenpuffer (2) ein. Anschließend haben wir den Schablonenpuffer verwendet, um diese Bereiche mit hohem Kontrast zu maskieren, wenn der Off-Screen-Puffer wieder auf den Bildschirm angewendet wird, was zu Löchern in und um die Cloud führt (3).

Anschließend haben wir alle Partikel erneut im Vollbildmodus gerendert. Dieses Mal wurde jedoch der Schablonenpuffer verwendet, um alles außer den Rändern zu maskieren, was zu einer minimalen Anzahl von berührten Pixeln führt (4). Da der Befehlspuffer bereits für die Partikel erstellt wurde, mussten wir ihn einfach erneut für die neue Kamera rendern.

Fortschritt beim Rendern von Cloudrändern

Das Endergebnis waren spitze Kanten mit kostengünstigen mittleren Abschnitten der Clouds.

Dies war zwar viel schneller als das Rendern aller Partikel im Vollbildmodus, aber es gibt immer noch Kosten für das Testen eines Pixels mit dem Schablonenpuffer, sodass eine große Menge an Überzeichnungen immer noch mit Kosten verbunden war.

Culling-Partikel

Für unseren Windeffekt haben wir lange Dreiecksstreifen in einem Compute-Shader generiert, wodurch viele Windwips auf der Welt erzeugt wurden. Obwohl der Windeffekt aufgrund der generierten Skiosestreifen nicht stark auf die Füllrate zurückzuführen war, erzeugte er viele Hunderttausend Scheitelpunkte, was zu einer hohen Last für den Scheitelpunkt-Shader führte.

Wir haben Anfügepuffer für den Compute-Shader eingeführt, um eine Teilmenge der zu zeichnenden Windstreifen zu feeden. Mit einer einfachen Ansichts-Frustum-Culling-Logik im Compute-Shader konnten wir ermitteln, ob sich ein Strip außerhalb der Kameraansicht befindet, und verhindern, dass er dem Pushpuffer hinzugefügt wird. Dadurch wurde die Menge der Strips erheblich reduziert, wodurch einige erforderliche Zyklen auf der GPU freigegeben wurden.

Code, der einen Anfügepuffer veranschaulicht:

Compute-Shader:

AppendStructuredBuffer<int> culledParticleIdx;
 
if (show)
    culledParticleIdx.Append(id.x);

C#-Code:

protected void Awake() 
{
    // Create an append buffer, setting the maximum size and the contents stride length
    culledParticlesIdxBuffer = new ComputeBuffer(ParticleCount, sizeof(int), ComputeBufferType.Append);
 
    // Set up Args Buffer for Draw Procedural Indirect
    argsBuffer = new ComputeBuffer(4, sizeof(int), ComputeBufferType.IndirectArguments);
    argsBuffer.SetData(new int[] { DataVertCount, 0, 0, 0 });
}
 
protected void Update()
{
    // Reset the append buffer, and dispatch the compute shader normally
    culledParticlesIdxBuffer.SetCounterValue(0);
 
    computer.Dispatch(...)
 
    // Copy the append buffer count into the args buffer used by the Draw Procedural Indirect call
    ComputeBuffer.CopyCount(culledParticlesIdxBuffer, argsBuffer, dstOffset: 1);
    ribbonRenderCommand.DrawProceduralIndirect(Matrix4x4.identity, renderMaterial, 0, MeshTopology.Triangles, dataBuffer);
}

Wir haben versucht, das gleiche Verfahren für die Cloudteilchen zu verwenden, bei dem wir sie auf dem Compute-Shader mit der Wolke vertauschten und nur die sichtbaren Partikel pushten, um gerendert zu werden. Diese Technik hat uns nicht viel auf der GPU gespart, da der größte Engpass die Anzahl der auf dem Bildschirm gerenderten Pixel und nicht die Kosten für die Berechnung der Scheitelpunkte waren.

Das andere Problem bei dieser Technik war, dass der Anfügepuffer in zufälliger Reihenfolge aufgefüllt wurde, da die Partikel parallelisiert werden, wodurch die sortierten Partikel unsortiert werden, was zu flackernden Cloudteilchen führt.

Es gibt Techniken zum Sortieren des Pushpuffers, aber der begrenzte Leistungsgewinn, den wir aus den Culling-Partikeln gewonnen haben, wird wahrscheinlich mit einer zusätzlichen Sortierung versetzt, daher haben wir uns entschieden, diese Optimierung nicht fortzusetzen.

Adaptives Rendering

Um eine stabile Framerate für eine App mit unterschiedlichen Renderingbedingungen wie einer cloudigen und einer klaren Ansicht sicherzustellen, haben wir adaptives Rendering für unsere App eingeführt.

Der erste Schritt des adaptiven Renderings ist das Messen der GPU. Dazu haben wir benutzerdefinierten Code am Anfang und Am Ende eines gerenderten Frames in den GPU-Befehlspuffer eingefügt und dabei die Bildschirmzeit für die linke und rechte Augen erfasst.

Indem wir die Zeit für das Rendering gemessen und mit der gewünschten Aktualisierungsrate verglichen haben, haben wir ein Gefühl dafür bekommen, wie nah wir am Löschen von Frames waren.

Wenn wir uns dem Löschen von Frames nähern, passen wir unser Rendering an, um es schneller zu machen. Eine einfache Möglichkeit zur Anpassung ist das Ändern der Viewportgröße des Bildschirms, sodass weniger Pixel gerendert werden müssen.

Mit UnityEngine.XR.XRSettings.renderViewportScale verkleinert das System den Zielviewport und streckt das Ergebnis automatisch an den Bildschirm. Eine kleine Änderung der Skala ist in der Weltgeometrie spürbar, und ein Skalierungsfaktor von 0,7 erfordert, dass die Hälfte der Pixel gerendert wird.

70 % Skalierung, hälfte der Pixel

Wenn wir feststellen, dass wir Frames löschen, verringern wir die Skala um eine feste Zahl und erhöhen sie wieder, wenn wir schnell genug ausgeführt werden.

Während wir entschieden haben, welche Cloudtechnik basierend auf den Grafikfunktionen der Hardware beim Start verwendet werden soll, ist es möglich, sie auf Daten aus der GPU-Messung zu basieren, um zu verhindern, dass das System lange zeitaufwendig eine niedrige Auflösung aufbehält. Dies war jedoch in Datascape nicht zeitaufwendig.

Schlussbemerkungen

Die Ausrichtung auf eine Vielzahl von Hardware ist eine Herausforderung und erfordert eine gewisse Planung.

Es wird empfohlen, mit der Ausrichtung auf computer mit geringeren Leistung zu beginnen, um sich mit dem Problembereich vertraut zu machen und eine Sicherungslösung zu entwickeln, die auf allen Ihren Computern ausgeführt wird. Entwerfen Sie Ihre Lösung unter Berücksichtigung der Füllrate, da Pixel ihre wertvolle Ressource sind. Zielgeometrie über Transparenz.

Mit einer Sicherungslösung können Sie dann mit einer höheren Komplexität für High-End-Computer beginnen oder die Auflösung Ihrer Sicherungslösung verbessern.

Entwerfen Sie den Entwurf für worst case-Szenarien, und erwägen Sie möglicherweise die Verwendung von adaptivem Rendering für große Situationen.

Über die Autoren

Picture of Robert Ferrese Robert Mooese
Anwendungsentwickler @Microsoft
Picture of Dan Andersson Dan Andersson
Anwendungsentwickler @Microsoft

Siehe auch