Fallstudie – Skalieren von Apps auf Geräten mit unterschiedlichen Funktionen

In dieser Fallstudie wird beschrieben, wie eine Windows Mixed Reality-Anwendung auf verschiedene Plattformen mit unterschiedlichen Hardwarefunktionen abzielen kann. Datascape ist eine Windows Mixed Reality Anwendung, die Wetterdaten über Geländedaten anzeigt. Die Anwendung umgeben Benutzer mit holografischen Datenvisualisierungen. Benutzer können einzigartige Einblicke erkunden, die sie gewinnen, indem Sie Daten in mixed Reality entdecken.

Die Datascape-App zielt auf Microsoft HoloLens, Windows Mixed Reality immersive Headsets, niedrigeren PCs und high-performance PCs ab. Die Wichtigste Herausforderung war das Rendern einer visuell ansprechenden Szene, während sie auf Geräten mit wild unterschiedlichen Leistungsfähigkeiten ausgeführt wird.

In dieser Fallstudie werden die Prozesse und Techniken durchlaufen, die wir verwendet haben, um einige der leistungsintensiveren Systeme zu erstellen, insbesondere das Rendern von Wetter wie Clouds. Wir beschreiben die Probleme, die wir gefunden haben und wie wir sie überschrieben haben.

Weitere Informationen zu Leistungsaspekten für Mixed Reality und für Unity-Apps finden Sie unter:

Übersicht über die Fallstudie

Hier finden Sie einen Hintergrund über die Datascape-Anwendung und Herausforderungen.

Transparenz und Überziehung

Unsere Hauptrenderingkämpfe behandelten Transparenz, da Transparenz teuer sein kann.

Sie können eine solide Geometrie vor der Rückseite rendern, während Sie den Tiefenpuffer schreiben, wodurch alle zukünftigen Pixel, die sich hinter diesem Pixel befinden, vom Rendern beendet werden. Dieser Vorgang verhindert, dass ausgeblendete Pixel den Pixel-Shader ausführen und das Rendering erheblich beschleunigen. Wenn Sie geometrie optimal sortieren, zeichnet jedes Pixel auf dem Bildschirm nur einmal.

Transparente Geometrie muss zurück nach vorne sortiert werden und basiert darauf, dass die Ausgabe des Pixel-Shaders auf dem Bildschirm mit dem aktuellen Pixel kombiniert wird. Dieser Prozess kann dazu führen, dass jedes Pixel auf dem Bildschirm mehrmals pro Frame gezeichnet wird, der überdraw aufgerufen wird.

Für HoloLens und Mainstream-PCs können Sie den Bildschirm nur ein paar Mal füllen, wodurch transparentes Rendering problematisch ist.

Datascape-Szenenkomponenten

Die Datascape-Szene verfügt über drei Hauptkomponenten: die Benutzeroberfläche, die Karte und das Wetter. Wir wussten, dass die Wettereffekte alle Leistung benötigen würden, die sie erhalten könnten, sodass wir die Benutzeroberfläche und die Karte entwickelt haben, um überdraw zu reduzieren.

Wir haben die Benutzeroberfläche mehrmals überarbeitet, um die Anzahl der Überziehung zu minimieren. Für Komponenten wie leuchtende Schaltflächen und Kartenübersichten haben wir beschlossen, komplexere Geometrie zu verwenden, anstatt transparente Kunst zu überlagern.

Für die Karte haben wir einen benutzerdefinierten Shader verwendet, der Standard-Unity-Features wie Schatten und komplexe Beleuchtung entfernt hat. Der benutzerdefinierte Shader ersetzt diese Features durch ein einfaches, einzelnes Sonnenlichtmodell und eine benutzerdefinierte Nebelberechnung. Dieser einfache Pixel-Shader verbessert die Leistung.

Wir haben sowohl die Benutzeroberfläche als auch die Karte zum Rendern im Budget erhalten, sodass sie keine hardwareabhängigen Änderungen benötigen. Die Wettervisualisierung, insbesondere das Cloudrendering, war schwieriger.

Clouddaten

Clouddaten, die von NOAA-Servern in drei verschiedenen 2D-Ebenen heruntergeladen wurden. Jede Ebene hatte die obere und untere Höhe der Cloud und die Dichte der Cloud, für jede Zelle des Rasters. Wir verarbeiteten die Daten in eine Cloud-Infotextur, die jede Komponente in der roten, grünen und blauen Komponente der Textur gespeichert hat.

Erstellen von Geometrie-Clouds

Um sicherzustellen, dass untere Computer die Clouds rendern könnten, verwendet unser Sicherungsansatz solide Geometrie, um überdraw zu minimieren.

Wir produzierten Clouds, indem wir ein solides Heightmap-Gitter für jede Ebene generieren. Wir haben den Radius der Cloud-Info-Textur pro Vertex verwendet, um die Form zu generieren. Wir haben einen Geometrie-Shader verwendet, um die Scheitelpunkte am oberen und unteren Rand der Clouds zu erzeugen, wodurch solide Cloud-Formen generiert werden. Wir haben den Dichtewert von der Textur verwendet, um die Cloud mit dunkleren Farben für Denser-Clouds zu farben.

Der folgende Shadercode erstellt die Vertices:

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 mehr Details zu den realen Daten zu erhalten. Um runde Cloudränder zu erzeugen, verworfen wir nahezu null Werte, indem wir die Pixel im Pixel-Shader abschneiden, wenn der interpolierte Radiuswert einen Schwellenwert erreicht.

Da die Clouds solide Geometrie sind, können sie vor dem Rendern des Geländes rendern. Das Ausblenden der teuren Kartenpixel unter den Clouds verbessert die Framerate weiter. Aufgrund des soliden Geometrie-Rendering-Ansatzes hat diese Lösung gut auf allen Grafikkarten, von Mindestspezifikationen bis zu High-End-Grafikkarten und auf HoloLens ausgeführt.

Image that shows geometry clouds.

Verwenden von soliden Partikelwolken

Unsere Lösung produzierte eine anständige Darstellung der Clouddaten, war aber etwas unlustr. Das Cloud-Rendering hat das volumetrische Gefühl nicht vermittelt, das wir für unsere High-End-Maschinen wollten. Unser nächster Schritt war die Herstellung eines organischen und volumerischen Aussehens, indem die Clouds mit etwa 100.000 Partikeln dargestellt werden.

Wenn Partikel solide bleiben und front-to-back sortieren, profitieren Sie weiterhin von tiefen Pufferpuffern hinter zuvor gerenderten Partikeln, wodurch die Überziehung reduziert wird. Außerdem kann eine partikelbasierte Lösung die Anzahl der Partikel ändern, um auf unterschiedliche Hardware zu abzielen. Alle Pixel müssen jedoch immer noch tiefen getestet werden, wodurch mehr Aufwand verursacht wird.

Zunächst haben wir Partikelpositionen um den Mittelpunkt der Erfahrung beim Start erstellt. Wir verteilten die Partikel genauer um das Zentrum und weniger so in der Entfernung. Wir sortieren alle Partikel aus dem Zentrum nach hinten vor, sodass die nächsten Partikel zuerst gerendert werden.

Ein Compute-Shader hat die Cloud-Infotextur gebeispielt, um jedes Partikel auf einer richtigen Höhe zu positionieren, und farbt sie basierend auf der Dichte. Jedes Partikel enthält sowohl eine Höhe als auch einen Radius. Die Höhe basiert auf den Clouddaten, die aus der Cloud-Infotextur gebeispielt wurden. Der Radius basiert auf der anfänglichen Verteilung, die den horizontalen Abstand zu seinem nächstgelegenen Nachbarn berechnet und gespeichert hat.

Wir haben DrawProcedural verwendet, um ein Quad pro Partikel zu rendern. Die Quads verwendeten diese Daten, um sich selbst zu orientieren, gewinkelt von der Höhe. Wenn Benutzer horizontal ein Partikel betrachten, wird die Höhe angezeigt. Wenn Benutzer die Partikel oben unten betrachten, wird der Bereich zwischen ihm und seinen Nachbarn abgedeckt.

Diagram that shows particle shape and coverage.

Der folgende Shadercode zeigt die 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;
}

Wir sortierten die Partikel front-nach-zurück und verwendeten weiterhin einen soliden Style-Shader, um transparente Pixel zu clipmen, nicht zu blenden. Diese Technik behandelt eine große Anzahl von Partikeln selbst auf niedrigeren Maschinen, um kostspielige Überziehungen zu vermeiden.

Versuchen Sie transparente Partikelwolken

Die soliden Partikel lieferten ein organisches Gefühl für die Cloud-Formen, benötigten aber noch etwas, um die Fluffiität von Clouds zu erfassen. Wir haben beschlossen, eine benutzerdefinierte Lösung für High-End-Grafikkarten zu versuchen, die Transparenz eingeführt. Wir haben einfach die anfängliche Sortierreihenfolge der Partikel gewechselt und den Shader geändert, um die Textur alpha zu verwenden.

Image that shows fluffy clouds.

Diese Lösung war hervorragend, aber für sogar die härtesten Maschinen zu schwer. Jedes Pixel musste auf dem Bildschirm hunderte mal gerendert werden.

Rendern des Offscreens mit niedrigerer Auflösung

Um die Anzahl der Pixel für das Rendern der Clouds zu verringern, haben wir sie in einem Puffer gerendert, der ein Viertel der Bildschirmauflösung war. Wir haben das Endergebnis wieder auf den Bildschirm gezogen, nachdem wir alle Partikel zeichnen.

Der folgende Code zeigt das Offscreen-Rendering:

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);

Diese Lösung hat die Verarbeitung vierfach gedreht, hatte jedoch einige Vorbehalte. Zunächst verloren wir beim Rendern in einen Offscreenpuffer alle Tiefeninformationen aus unserer Hauptszene. Partikel hinter Bergen, die auf dem Berg gerendert wurden.

Zweitens durch das Dehnen des Puffers wurden Artefakte an den Kanten von Clouds eingeführt, wo die Auflösungsänderung spürbar war. In den nächsten beiden Abschnitten wird beschrieben, wie diese Probleme behoben wurden.

Verwenden eines Partikeltiefepuffers

Wir mussten die Partikel mit der Weltgeometrie koexistieren, wo ein Berg oder Objekt partikelbedeckt ist. Daher haben wir den Offscreenpuffer mit einem Tiefenpuffer gefüllt, der die Hauptszenegeometrie enthält. Um den Tiefenpuffer zu erzeugen, haben wir eine zweite Kamera erstellt, die nur die solide Geometrie und tiefe der Szene gerendert hat.

Wir haben die neue Textur im Cloud-Pixel-Shader verwendet, um Pixel zu okclude pixeln. Wir haben die gleiche Textur verwendet, um den Abstand zu der Geometrie hinter einem Cloud pixel zu berechnen. Durch die Verwendung dieser Entfernung und Anwendung auf die Alpha des Pixels haben wir die Wirkung von Clouds erreicht, die ausblenden, da sie sich nahe an das Gelände halten. Dieser Effekt entfernt alle harten Schnitte, wo Partikel und Gelände treffen.

Image that shows clouds blended into terrain.

Schärfen der Kanten

Die gestreckten Clouds sahen fast identisch mit normal großen Clouds am Zentrum von Partikeln oder wo sie überlappen, aber einige Artefakte an den Cloudranden gezeigt haben. Scharfe Kanten sind verschwommen, und Kamerabewegung führte Aliaseffekte ein.

Um dieses Problem zu lösen, wir:

  1. Führte einen einfachen Shader auf dem Offscreenpuffer aus, um zu ermitteln, wo große Änderungen im Kontrast aufgetreten sind.
  2. Fügen Sie die Pixel mit großen Änderungen in einen neuen Schablonenpuffer ein.
  3. Verwendet den Schablonenpuffer, um diese Bereiche mit hohem Kontrast zu maskieren, wenn der Offscreenpuffer wieder auf den Bildschirm angewendet wird, was zu Löchern in und um die Wolken führt.
  4. Alle Partikel wieder im Vollbildmodus gerendert, indem sie den Schablonenpuffer verwenden, um alles, aber die Kanten zu maskieren, was zu einer minimalen Gruppe von Pixeln führt, die berührt wurden. Da wir bereits den Befehlspuffer zum Rendern der Partikel erstellt haben, haben wir sie einfach wieder in die neue Kamera gerendert.

Image showing the progression of rendering cloud edges.

Das Endergebnis war scharfe Kanten mit günstigen Mittleren Abschnitten der Wolken. Obwohl diese Lösung viel schneller ist als das Rendern aller Partikel im Vollbildmodus, gibt es weiterhin kosten, Pixel mit dem Schablonenpuffer zu testen. Eine massive Überzeichnung ist immer noch teuer.

Kullpartikel

Für den Windeffekt haben wir lange Dreiecksstreifen in einem Compute-Shader generiert, wodurch viele Windwisser in der Welt entstehen. Der Windeffekt war aufgrund der schmalen Streifen nicht stark auf Füllrate. Die vielen hunderttausend Scheitelpunkte verursachten jedoch eine schwere Belastung für den Vertex-Shader.

Um die Last zu verringern, haben wir Anfügepuffer für den Compute-Shader eingeführt, um eine Teilmenge der zu zeichnenden Windstreifen zu feeden. Wir haben eine einfache Frustum-Kullinglogik im Compute-Shader verwendet, um festzustellen, ob sich ein Streifen außerhalb der Kameraansicht befand und verhinderte, dass diese Streifen dem Pushpuffer hinzugefügt werden. Dieser Prozess hat die Anzahl der Streifen erheblich reduziert und die Leistung verbessert.

Der folgende Code veranschaulicht einen Anfügepuffer.

Computehader:

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 diese Technik auf den Wolkenpartikeln ausprobiert, sie auf dem Compute-Shader kultieren und nur die sichtbaren Partikel zu rendern. Aber wir haben nicht viel Verarbeitung gespeichert, da der größte Engpass die Anzahl der Cloudpixel war, die auf dem Bildschirm gerendert werden sollen, nicht die Kosten für die Berechnung von Scheitelpunkten.

Ein weiteres Problem war, dass der Anfügepuffer zufällig aufgefüllt wurde, aufgrund der parallelisierten Berechnung der Partikel. Die sortierten Partikel wurden unsortiert, was zu flimmernden Wolkenpartikeln führt. Es gibt Techniken zum Sortieren des Pushpuffers, aber der begrenzte Leistungsgewinn von Culling-Partikeln würde wahrscheinlich durch eine andere Sorte versetzt werden. Wir haben uns entschieden, diese Optimierung für die Wolkenpartikel nicht zu verfolgen.

Verwenden des adaptiven Renderings

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

Der erste Schritt des adaptiven Renderings besteht darin, die Leistung zu messen. Wir haben benutzerdefinierten Code am Anfang und Ende eines gerenderten Frames in den Befehlspuffer eingefügt, um sowohl die linke als auch die rechte Bildschirmzeit zu erfassen.

Vergleichen Sie die Renderingzeit mit der gewünschten Aktualisierungsrate, um zu zeigen, wie nah Sie zum Ablegen von Frames kommen. Wenn Sie nahe an das Ablegen von Frames kommen, können Sie das Rendern schneller anpassen.

Eine einfache Möglichkeit zum Anpassen des Renderings besteht darin, die Bildschirmansichtsgröße zu ändern, sodass weniger Pixel gerendert werden müssen. Das System verwendet UnityEngine.XR.XRSettings.renderViewportScale , um den gezielten Viewport zu verkleinern, und das Ergebnis wird automatisch an den Bildschirm angepasst. Eine kleine Skalierungsänderung ist in der Weltgeometrie kaum spürbar, und ein Skalierungsfaktor von 0,7 erfordert eine halbe Anzahl von Pixeln, die gerendert werden sollen.

Image showing 70% scale, with half the pixels.

Wenn wir feststellen, dass wir Frames ablegen möchten, senken wir die Skalierung um ein festes Verhältnis, und stellen sie wieder her, wenn wir schnell genug ausgeführt werden.

In dieser Fallstudie haben wir entschieden, welche Cloudtechnik basierend auf den Grafikfunktionen der Hardware beim Start verwendet werden soll. Sie können diese Entscheidung auch auf Daten aus Leistungsmessungen stützen, um zu verhindern, dass das System lange zeit mit niedriger Auflösung bleibt.

Empfehlungen

Die Ausrichtung verschiedener Hardwarefunktionen ist schwierig und erfordert Planung. Hier sind einige Empfehlungen dafür:

  • Beginnen Sie mit der Ausrichtung auf weniger angetriebene Computer, um sich mit dem Problembereich vertraut zu machen.
  • Entwickeln Sie eine Sicherungslösung, die auf allen Ihren Computern ausgeführt wird. Sie können dann die Komplexität für High-End-Computer erhöhen oder die Auflösung der Sicherungslösung verbessern.
  • Entwerfen Sie Ihre Lösung mit Füllrate, da Pixel Ihre wertvollste Ressource sind.
  • Zielgeometrie über Transparenz.
  • Entwerfen sie für Szenarios im schlimmsten Fall, und berücksichtigen Sie die Verwendung des adaptiven Renderings für schwere Situationen.

Über die Autoren

Picture of Robert Ferrese Robert Ferrese
Softwaretechniker @Microsoft
Picture of Dan Andersson Dan Andersson
Softwaretechniker @Microsoft

Weitere Informationen