Case study- Scaling Datascape across devices with different performance (Gegevensseenschaling schalen op apparaten met verschillende prestaties)

Datascape is een Windows Mixed Reality die intern is ontwikkeld bij Microsoft, waar we ons hebben gericht op het weergeven van weersgegevens boven op degegevens. De toepassing verkent de unieke inzichten die gebruikers verkrijgen door gegevens te detecteren in mixed reality door de gebruiker om te helpen met holografische gegevensvisualisatie.

Voor Datascape wilden we ons richten op verschillende platforms met verschillende hardwaremogelijkheden, variërend van Microsoft HoloLens tot Windows Mixed Reality immersive headsets en van pc's met lagere stroom tot de nieuwste pc's met geavanceerde GPU. De belangrijkste uitdaging was het weergeven van onze scène in een visueel aantrekkelijke kwestie op apparaten met totaal verschillende grafische mogelijkheden tijdens het uitvoeren met een hoge framesnelheid.

In deze casestudie worden de processen en technieken doorlopen die worden gebruikt om een aantal van onze meer GPU-intensieve systemen te maken, met een beschrijving van de problemen die we hebben ondervonden en hoe we deze hebben overschreven.

Transparantie en overdraw

Onze belangrijkste rendering-problemen hebben te maken met transparantie, omdat transparantie kostbaar kan zijn voor een GPU.

Effen geometrie kan van voor naar achteren worden weergegeven tijdens het schrijven naar de dieptebuffer, waardoor toekomstige pixels die zich achter die pixel bevinden, niet meer kunnen worden verwijderd. Dit voorkomt dat verborgen pixels de pixel-shader uitvoeren, waardoor het proces aanzienlijk wordt versnellen. Als geometrie optimaal is gesorteerd, wordt elke pixel op het scherm slechts één keer getekend.

Transparante geometrie moet weer naar voren worden gesorteerd en is afhankelijk van het combineren van de uitvoer van de pixel-shader naar de huidige pixel op het scherm. Dit kan ertoe leiden dat elke pixel op het scherm meerdere keren per frame wordt getekend, aangeduid als overdraw.

Voor HoloLens en algemene pc's kan het scherm slechts een handjevol keer worden gevuld, waardoor transparante rendering problematisch wordt.

Inleiding tot de onderdelen van de Datascape-scène

We hadden drie belangrijke onderdelen voor onze scène: de gebruikersinterface, de kaart en het weer. We wisten al vroeg dat onze weerseffecten alle GPU-tijd zouden vereisen die het zou kunnen krijgen. Daarom hebben we met opzet de gebruikersinterface ontworpen en gedreneerd op een manier die eventuele overdraw zou verminderen.

We hebben de gebruikersinterface meerdere keren opnieuw gewerkt om de hoeveelheid overdraw te minimaliseren die deze zou produceren. We hebben een fout gemaakt aan de zijde van complexere geometrie in plaats van transparante kunst over elkaar heen te leggen voor onderdelen zoals het oplichten van knoppen en kaartoverzichten.

Voor de kaart hebben we een aangepaste shader gebruikt waarmee standaard Unity-functies, zoals schaduwen en complexe belichting, worden gestriped en vervangen door een eenvoudig model voor enkelvoudige zonlicht en een aangepaste berekening van de schaduw. Dit heeft een eenvoudige pixel-shader geproduceerd en GPU-cycli vrijgemaakt.

Het is ons gelukt om zowel de gebruikersinterface als de kaart op budget weer te geven, waarbij er geen wijzigingen nodig waren, afhankelijk van de hardware; De weervisualisatie, met name de cloudrendering, bleek echter meer een uitdaging!

Achtergrondinformatie over cloudgegevens

Onze cloudgegevens zijn gedownload van NOAA-servers ( en zijn naar ons gekomen in drie afzonderlijke 2D-lagen, elk met de bovenste en onderste hoogte van de cloud, evenals de dichtheid van de cloud voor elke cel van het https://nomads.ncep.noaa.gov/) raster. De gegevens zijn verwerkt in een patroon met cloudgegevens waarin elk onderdeel is opgeslagen in het rode, groene en blauwe onderdeel van het patroon voor eenvoudige toegang tot de GPU.

Geometriewolken

Om ervoor te zorgen dat onze machines met lagere stroom onze clouds kunnen renderen, hebben we besloten om te beginnen met een benadering die gebruik zou maken van een solide geometrie om overdraw te minimaliseren.

We hebben eerst geprobeerd clouds te produceren door voor elke laag een effen heightmap-mesh te genereren met behulp van de radius van het patroon van de cloudgegevens per hoekpunt om de vorm te genereren. We hebben een geometrie-shader gebruikt om de hoek punten te produceren, zowel boven als onder aan de cloud, waardoor solide cloudvormen worden gegenereerd. We hebben de dichtheidswaarde van het patroon gebruikt om de cloud te kleuren met donkere kleuren voor meer compacte clouds.

Shader voor het maken van de punten:

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

We hebben een patroon voor kleine ruis geïntroduceerd om meer details over de echte gegevens te krijgen. Voor het produceren van ronde cloudranden hebben we de pixels in de pixel-shader afgekapt wanneer de geïnterpoleerde radiuswaarde een drempelwaarde bereikt om waarden van bijna nul te verwijderen.

Geometriewolken

Omdat de clouds een solide geometrie zijn, kunnen ze vóór de route worden weergegeven om dure kaartpixels eronder te verbergen om de framesnelheid verder te verbeteren. Deze oplossing werd goed gebruikt op alle grafische kaarten, van min-spec tot geavanceerde grafische kaarten, evenals op HoloLens, vanwege de weergavebenadering van de effen geometrie.

Effen deeltjeswolken

We hadden nu een back-upoplossing die een goede weergave van onze cloudgegevens produceert, maar een beetje ondeskundiger was in de 'wow'-factor en niet het volumetrische gevoel we wilden overbrengen voor onze high-end machines.

De volgende stap was het maken van de clouds door ze weer te geven met ongeveer 100.000 deeltjes om een meer organische en volumetrische look te produceren.

Als deeltjes solide blijven en van voor naar achteren sorteren, kunnen we nog steeds profiteren van het bufferen van de pixels achter eerder gerenderde deeltjes, waardoor de overdraw wordt verkleind. Met een oplossing op basis van deeltjes kunnen we ook de hoeveelheid deeltjes wijzigen die worden gebruikt om zich op verschillende hardware te richten. Alle pixels moeten echter nog steeds verder worden getest, wat leidt tot extra overhead.

Eerst hebben we bij het opstarten deeltjesposities gemaakt rond het middelpunt van de ervaring. We hebben de deeltjes dichter rond het midden gedistribueerd en minder in de afstand. We hebben alle deeltjes vooraf gesorteerd van het midden naar achteren, zodat de dichtstbijzijnde deeltjes als eerste zouden worden weergegeven.

Een compute-shader zou een steekproef nemen van het patroon van de cloudgegevens om elk deeltje op de juiste hoogte te plaatsen en te kleuren op basis van de dichtheid.

We hebben DrawProcedcore gebruikt om een quad per deeltje weer te geven, zodat de gegevens van het deeltje te allen tijde in de GPU kunnen blijven.

Elk deeltje bevatte zowel een hoogte als een straal. De hoogte is gebaseerd op de cloudgegevens die zijn verzameld op basis van het patroon met cloudgegevens en de radius is gebaseerd op de initiële distributie waar deze zou worden berekend om de horizontale afstand naar de dichtstbijzijnde buur op te slaan. De quads zouden deze gegevens gebruiken om zich te oriënteren op de hoogte, zodat wanneer gebruikers deze horizontaal bekijken, de hoogte wordt weergegeven en wanneer gebruikers deze van boven naar beneden bekijken, wordt het gebied tussen de aangrenzende omgevingen bedekt.

Vorm van deeltjes

Shadercode met de distributie:

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

Omdat we de deeltjes front-to-back sorteren en we nog steeds een solide tinter hebben gebruikt om transparante pixels te knippen (niet gemengd), verwerkt deze techniek een verrassend aantal deeltjes, waardoor kostbare overtrekkingen zelfs op de machines met lagere stroom worden voorkomen.

Transparante deeltjeswolken

De effen deeltjes boden een goede organische vorm aan de vorm van de clouds, maar hadden nog steeds iets nodig om de griep van de clouds te verkopen. We hebben besloten om een aangepaste oplossing te proberen voor de geavanceerde grafische kaarten, waar we transparantie kunnen introduceren.

Hiervoor hebben we eenvoudigweg de initiële sorteerorde van de deeltjes gewijzigd en de shader gewijzigd om de texturen alpha te gebruiken.

Clouds van het jaar tot nu toe

Het zag er goed uit, maar het bleek te zwaar te zijn voor zelfs de verantwoordelijke machines, omdat hierdoor elke pixel honderden keren op het scherm zou worden weergegeven.

Off-screen renderen met een lagere resolutie

Om het aantal pixels dat door de clouds wordt weergegeven te verminderen, zijn we begonnen met het weergeven van deze pixels in een buffer met een kwartaalresolutie (vergeleken met het scherm) en begonnen we het eindresultaat weer op het scherm uit te strekken nadat alle deeltjes waren getekend. Dit heeft ons grofweg een versnelling van 4x gegeven, maar er zijn enkele waarschuwingen.

Code voor weergave buiten het scherm:

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

Ten eerste zijn we bij het weergeven in een buffer buiten het scherm alle dieptegegevens van onze hoofdscène kwijtgeraakt, wat resulteert in deeltjes achter de rendering van de sneeuw op de berg.

Ten tweede, bij het uitstrekken van de buffer werden ook artefacten geïntroduceerd op de randen van onze clouds, waarbij de oplossingswijziging zichtbaar was. In de volgende twee secties wordt bevraagd hoe we deze problemen hebben opgelost.

Buffer voor de diepte van deeltjes

Om de deeltjes naast elkaar te laten bestaan met de wereldgeometrie waar een berg of object deeltjes achter kan behandelen, vulden we de buffer buiten het scherm met een dieptebuffer met de geometrie van de hoofdscène. Om een dergelijke dieptebuffer te produceren, hebben we een tweede camera gemaakt, die alleen de solide geometrie en diepte van de scène we weergeven.

Vervolgens hebben we het nieuwe patroon in de pixel-shader van de clouds gebruikt om pixels af te sluiten. We hebben hetzelfde patroon gebruikt om de afstand tot de geometrie achter een cloudpixel te berekenen. Door die afstand te gebruiken en deze toe te passen op de alfa van de pixel, hadden we nu het effect dat clouds uit elkaar rijdt wanneer ze dicht bij elkaar komen, waardoor eventuele harde delen waar deeltjes en deeltjes elkaar vinden, worden verwijderd.

Clouds die zijn gecombineerd tot een mix van elkaar

De randen slepen

De uitgerekte clouds zagen er bijna identiek uit aan de normale groottewolken in het midden van de deeltjes of waar ze elkaar overlappen, maar er werden enkele artefacten aan de randen van de cloud getoond. Anders zouden de randen wazig lijken en werden aliaseffecten geïntroduceerd toen de camera werd verplaatst.

We hebben dit opgelost door een eenvoudige shader uit te voeren op de buffer buiten het scherm om te bepalen waar grote contrastwijzigingen zijn opgetreden (1). We zetten de pixels met grote wijzigingen in een nieuwe stencilbuffer (2). Vervolgens hebben we de stencilbuffer gebruikt om deze gebieden met hoog contrast te maskeren bij het toepassen van de buffer buiten het scherm op het scherm, wat resulteert in gaten in en rond de clouds (3).

Vervolgens hebben we alle deeltjes weer weergegeven in de modus Volledig scherm, maar deze keer hebben we de stencilbuffer gebruikt om alles behalve de randen te maskeren, wat resulteert in een minimale set pixels die is betast (4). Omdat de opdrachtbuffer al is gemaakt voor de deeltjes, moesten we deze gewoon opnieuw renderen voor de nieuwe camera.

Voortgang van het weergeven van cloudranden

Het eindresultaat was sterke randen met goedkope middelste secties van de clouds.

Hoewel dit veel sneller was dan het weergeven van alle deeltjes in een volledig scherm, zijn er nog steeds kosten verbonden aan het testen van een pixel op basis van de stencilbuffer, waardoor een enorme hoeveelheid overdraw nog steeds kosten met zich mee brengt.

Deeltjes geruimd

Voor ons windeffect hebben we lange driehoeken strips gegenereerd in een berekenings-shader, waardoor er veel windsopen in de wereld ontstaan. Hoewel het windeffect niet zwaar was op de opvulsnelheid als gevolg van de gegenereerde strips, genereerde het vele honderdduizenden hoek punten, wat leidde tot een zware belasting voor de hoekpunt-shader.

We hebben buffers voor de berekeningssubset geïntroduceerd om een subset van de getrokken windzones op te geven. Met een eenvoudige frustum-ontdubbelingslogica in de berekenings-shader kunnen we bepalen of een strip buiten de cameraweergave was en voorkomen dat deze aan de pushbuffer wordt toegevoegd. Hierdoor is de hoeveelheid strips aanzienlijk verminderd, wat een aantal benodigde cycli op de GPU vrij heeft gemaakt.

Code die een buffer voor append demonstreert:

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

We hebben geprobeerd dezelfde techniek te gebruiken op de clouddeeltjes, waarbij we ze in de berekenings-shader zouden plaatsen en alleen de zichtbare deeltjes zouden pushen om te worden gerenderd. Deze techniek heeft ons niet veel op de GPU bespaart, omdat het grootste knelpunt de hoeveelheid pixels is die op het scherm wordt weergegeven en niet de kosten voor het berekenen van de punten.

Het andere probleem met deze techniek was dat de buffer voor de append in willekeurige volgorde werd gevuld vanwege de ge parallelliseerde aard van het berekenen van de deeltjes, waardoor de gesorteerde deeltjes niet-gesorteerd werden, wat leidde tot het afkeuren van clouddeeltjes.

Er zijn technieken om de pushbuffer te sorteren, maar de beperkte prestatieverbetering die we hebben door het afdoen van deeltjes zou waarschijnlijk worden verschoven met een extra sortering, dus hebben we besloten om deze optimalisatie niet te volgen.

Adaptieve rendering

Om een stabiele framesnelheid te garanderen voor een app met verschillende renderingsvoorwaarden, zoals een cloud of een duidelijke weergave, hebben we adaptieve rendering in onze app geïntroduceerd.

De eerste stap van adaptieve rendering is het meten van GPU. We hebben dit gedaan door aangepaste code in te voegen in de GPU-opdrachtbuffer aan het begin en het einde van een gerenderd frame, waardoor zowel de schermtijd van het linker- als het rechteroogscherm wordt vastleggen.

Door de weergavetijd te meten en deze te vergelijken met de gewenste vernieuwingsfrequentie, hebben we een idee gehad van hoe dicht we bij het verwijderen van frames waren.

Wanneer we frames bijna verwijderen, passen we onze rendering aan om deze sneller te maken. Een eenvoudige manier om aan te passen is het wijzigen van de viewportgrootte van het scherm, waardoor er minder pixels nodig zijn om te worden weergegeven.

Door UnityEngine.XR.XRSettings.renderViewportScale te gebruiken, verkleint het systeem de doel-viewport en wordt het resultaat automatisch weer passend gemaakt op het scherm. Een kleine wijziging in de schaal is merkbaar in de wereldgeometrie en een schaalfactor van 0,7 vereist de helft van de hoeveelheid pixels die moet worden weergegeven.

70% schalen, de helft van de pixels

Wanneer we detecteren dat we frames gaan verwijderen, verlagen we de schaal met een vast aantal en verhogen we deze weer wanneer we weer snel genoeg worden uitgevoerd.

Hoewel we hebben besloten welke cloudtechniek moet worden gebruikt op basis van grafische mogelijkheden van de hardware bij het opstarten, is het mogelijk om deze te baseren op gegevens uit de GPU-meting om te voorkomen dat het systeem lange tijd in een lage resolutie blijft, maar dit is iets wat we niet hebben gehad om te verkennen in Datascape.

Laatste ideeën

Het is lastig om een verscheidenheid aan hardware te richten en hiervoor is enige planning vereist.

We raden u aan om te beginnen met het richten op machines met een lager vermogen om vertrouwd te raken met de probleemruimte en een back-upoplossing te ontwikkelen die op al uw computers wordt uitgevoerd. Ontwerp uw oplossing met een opvulpercentage in gedachten, omdat pixels uw meest kostbare resource zijn. Richt de effen geometrie boven transparantie.

Met een back-upoplossing kunt u vervolgens beginnen met gelaagdheid voor high-end machines of misschien alleen de resolutie van uw back-upoplossing verbeteren.

Ontwerp voor slechtste scenario's en overweeg mogelijk adaptieve rendering te gebruiken voor zware situaties.

Over de auteurs

Picture of Robert Ferrese Robert Hadees
Softwaretechnicus @Microsoft
Picture of Dan Andersson Dan Andersson
Softwaretechnicus @Microsoft

Zie ook