Analiza przypadku — skalowanie krajobrazu danych między urządzeniami o różnej wydajności

Datascape to aplikacja Windows Mixed Reality opracowana wewnętrznie w firmie Microsoft, w której skupiliśmy się na wyświetlaniu danych pogodowych na podstawie danych terenu. Aplikacja eksploruje unikatowe szczegółowe informacje, które użytkownicy uzyskują, odkrywając dane w rzeczywistości mieszanej, otaczając użytkownika wizualizacją danych holograficznych.

W przypadku rozwiązania Datascape chcieliśmy kierować się różnymi platformami o różnych możliwościach sprzętowych, od immersywnych zestawów Microsoft HoloLens Windows Mixed Reality po immersywne zestawy nagłowne, od komputerów z niższymi wersjami po najnowsze komputery z wysokiej klasy procesorem GPU. Głównym wyzwaniem było renderowanie naszej sceny w atrakcyjny wizualnie sposób na urządzeniach z bardzo różnymi możliwościami graficznymi przy dużej prędkości klatek.

W tej analizie przypadku opisano proces i techniki używane do tworzenia niektórych z naszych bardziej intensywnie obciążających procesory GPU systemów, opisując napotkane problemy i sposób ich zawycięć.

Przezroczystość i narysowanie

Nasze główne problemy z renderowaniem są rozwiązywane z przejrzystością, ponieważ przezroczystość może być kosztowna w przypadku procesora GPU.

Solidną geometrię można renderować z przodu do tyłu podczas zapisywania w buforze głębokości, zatrzymując odrzucanie wszystkich przyszłych pikseli znajdujących się za tym pikselem. Zapobiega to wykonywaniem cieniowania pikseli w ukrytych pikselach, co znacznie przyspiesza proces. Jeśli geometria jest sortowana optymalnie, każdy piksel na ekranie będzie rysowany tylko raz.

Przezroczysta geometria musi być posortowana z powrotem do przodu i opiera się na mieszaniu danych wyjściowych cieniowania pikseli z bieżącym pikselem na ekranie. Może to spowodować wielokrotne narysowanie każdego piksela na ekranie, nazywanego narysem.

W HoloLens i komputerów głównych ekran może być wypełniony tylko w kilku momentach, co sprawia, że niewidoczne renderowanie jest problematyczne.

Wprowadzenie do składników sceny datascape

Mieliśmy trzy główne składniki do naszej sceny: interfejs użytkownika, mapa i pogoda. Wcześnie wiedzieliśmy, że efekty pogodowe będą wymagały całego czasu procesora GPU, jaki może uzyskać, dlatego celowo zaprojektowaliśmy interfejs użytkownika i terenu w taki sposób, aby zmniejszyć ilość narysów.

Kilka razy przerabialiśmy interfejs użytkownika, aby zminimalizować ilość narysowanych elementów. Myręcąc się po stronie bardziej złożonej geometrii, nie nakładaliśmy przezroczystej grafiki na siebie na składniki, takie jak cykanie przycisków i przeglądy map.

W przypadku mapy ubraliśmy niestandardowy moduł cieniowania, który odsunie standardowe funkcje aparatu Unity, takie jak cienie i złożone oświetlenie, zastępując je prostym pojedynczym modelem oświetlenia słonecznego i niestandardowym obliczeniem oświetlenia. Wytłoniło to prosty cieniowanie pikseli i wolne cykle procesora GPU.

Udało nam się uzyskać zarówno interfejs użytkownika, jak i mapę do renderowania w budżecie, w którym nie potrzebowaliśmy żadnych zmian w zależności od sprzętu. Jednak wizualizacja pogody, w szczególności renderowanie w chmurze, okazała się bardziej wyzwaniem.

Podstawowe informacje na temat danych w chmurze

Nasze dane w chmurze zostały pobrane z serwerów NOAA ( i przyszły do nas w trzech odrębnych warstwach 2D, z których każda ma górną i dolną wysokość chmury, a także gęstość chmury dla każdej komórki https://nomads.ncep.noaa.gov/) siatki. Dane są przetwarzane do tekstury informacji w chmurze, gdzie każdy składnik był przechowywany w czerwonym, zielonym i niebieskim składniku tekstury w celu uzyskania łatwego dostępu do procesora GPU.

Chmury geometrii

Aby upewnić się, że nasze maszyny o niższej mocy mogą renderować chmury, postanowiliśmy zacząć od podejścia, które będzie korzystać z solidnej geometrii w celu zminimalizowania natłoku.

Najpierw próbowaliśmy tworzyć chmury, generując solidną siatkę mapy wysokości dla każdej warstwy przy użyciu promienia tekstury informacji o chmurze na wierzchołek w celu wygenerowania kształtu. Umyliśmy geometryczne cieniowanie do tworzenia wierzchołków zarówno w górnej, jak i dolnej części chmury generującej stałe kształty chmury. Umyliśmy wartość gęstości z tekstury, aby pokolorować chmurę ciemniejszymi kolorami dla bardziej gęstych chmur.

Cieniowanie do tworzenia wierzchołków:

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

Wprowadziliśmy wzorzec małego szumu, aby uzyskać więcej szczegółów na podstawie rzeczywistych danych. Aby utworzyć zaokrąglone krawędzie chmury, przycięliśmy piksele w programie do cieniowania pikseli, gdy wartość promienia interpolowanych przekroczy próg, aby odrzucić wartości bliskie zera.

Chmury geometrii

Ponieważ chmury są solidną geometrią, można je renderować przed terenu, aby ukryć wszystkie kosztowne piksele mapy poniżej, aby jeszcze bardziej zwiększyć szybkość klatek. To rozwiązanie dobrze się uruchomiło na wszystkich kartach graficznych od kart graficznych o minimalnej specyfikacji do wysokiej klasy, a także na HoloLens ze względu na podejście do renderowania z geometrią ciągłą.

Chmury cząstek stałych

Mamy teraz rozwiązanie do tworzenia kopii zapasowych, które wyniosło dobrą reprezentację danych w chmurze, ale było nieco niedosyt w współczynniku "wow" i nie przekazało wolumetrycznego odczuć, które chcieliśmy zrobić dla naszych maszyn wysokiej klasy.

Następnym krokiem było utworzenie chmur przez reprezentowanie ich za pomocą około 100 000 cząstek w celu uzyskania bardziej organiczny i wolumetryzny wygląd.

Jeśli cząstki pozostają stałe i sortuje się od przodu do tyłu, nadal możemy skorzystać z buforu głębokości, wytłaniając piksele za poprzednio wyrenderowane cząstki, zmniejszając przepełnienie. Ponadto, w przypadku rozwiązania opartego na cząstkach, możemy zmienić ilość cząstek używanych do kierowania różnych urządzeń. Jednak nadal należy przetestować głębokość wszystkich pikseli, co powoduje pewne dodatkowe obciążenie.

Najpierw utworzono pozycje cząstek wokół punktu środku doświadczenia podczas uruchamiania. Cząstki rozkładamy bardziej gęsto wokół środka i mniej w odległości. Wstępnie posortowaliśmy wszystkie cząstki od środka do tyłu, tak aby najbliższe cząstki renderowała się jako pierwsze.

Cieniowanie obliczeniowe próbkuje teksturę informacji o chmurze, aby umieścić każdą cząstkę na prawidłowej wysokości i pokolorować ją na podstawie gęstości.

Umyliśmy drawProcedural do renderowania czworokątu na cząstkę, dzięki czemu dane cząstek pozostają na procesorze GPU przez cały czas.

Każda cząstka zawierała wysokość i promień. Wysokość została obliczona na podstawie danych w chmurze próbkowanej z tekstury informacji o chmurze, a promień był oparty na początkowym rozmieszczeniach, gdzie zostałby obliczony do przechowywania poziomej odległości do najbliższego sąsiada. Czworokąty będą używać tych danych, aby zorientować się pod kątem wysokości, tak aby gdy użytkownicy spojrzyli na nie w poziomie, była wyświetlana wysokość, a gdy użytkownicy patrzyli na nie od góry do dołu, obejmowałoby to obszar między sąsiadami.

Kształt cząstki

Kod cieniowania przedstawiający rozkład:

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

Ponieważ cząstki sortujemy od przodu do tyłu i nadal używamy cieniowania w stylu stałym do przycinania (nie mieszania) przezroczystych pikseli, ta technika obsługuje zaskakującą ilość cząstek, co pozwala uniknąć kosztownego nadmiernego losowania nawet na maszynach o niższej mocy.

Przezroczyste chmury cząstek

Cząstki stałe zapewniały dobry organiczny wygląd kształtu chmur, ale nadal potrzebowały czegoś, aby sprzedać fluffiness chmur. Postanowiliśmy wypróbować niestandardowe rozwiązanie dla kart graficznych wysokiej klasy, w którym możemy wprowadzić przejrzystość.

W tym celu zmieniliśmy po prostu początkową kolejność sortowania cząstek i zmieniliśmy moduł cieniowania, aby używać tekstur alfa.

Fluffy clouds (Fluffy clouds)

Wyglądało to świetnie, ale okazało się, że jest zbyt duże dla nawet najtrudniejszych maszyn, ponieważ spowoduje to renderowanie każdego piksela na ekranie setki razy!

Renderowanie poza ekranem z niższą rozdzielczością

Aby zmniejszyć liczbę pikseli renderowanych przez chmury, zaczęliśmy renderować je w buforze rozdzielczości kwartału (w porównaniu z ekranem) i rozciągać wynik końcowy z powrotem na ekran po narysowanych wszystkich cząstkach. To dało nam około 4-krotne przyspieszenie, ale przyszło z kilku zastrzeżeniami.

Kod renderowania poza ekranem:

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

Najpierw podczas renderowania w buforze poza ekranem utraciliśmy wszystkie informacje o głębokości z naszej głównej sceny, co spowoduje renderowanie cząstek za górami na górze.

Po drugie, rozciąganie buforu wprowadziło również artefakty na krawędziach naszych chmur, gdzie zmiana rozwiązania była zauważalna. W następnych dwóch sekcjach opisano sposób rozwiązania tych problemów.

Bufor głębokości cząstek

Aby cząstki istniały razem z geometrią świata, w której góry lub obiekt mogą pokrywać cząstki, wypełniliśmy bufor poza ekranem buforem głębokości zawierającym geometrię sceny głównej. Aby utworzyć taki bufor głębokości, utworzono drugą kamerę, która renderuje tylko solidną geometrię i głębokość sceny.

Następnie umyliśmy nową teksturę w programie do cieniowania pikseli chmur, aby okludnić piksele. Umyliśmy tę samą teksturę do obliczenia odległości do geometrii za pikselem chmury. Korzystając z tej odległości i stosując ją do alfa piksela, dostaliśmy efekt zanikania chmur, gdy zbliżają się one do terenu, usuwając wszelkie twarde przecięcia tam, gdzie spełniają się cząstki i terenu.

Chmury wtopiene w terenu

Udoskonalanie krawędzi

Chmury rozciągnięte wyglądały niemal tak samo jak chmury o normalnym rozmiarze w środku cząstek lub tam, gdzie się nakładały, ale pokazywały artefakty na krawędziach chmury. W przeciwnym razie ostre krawędzie będą wyglądać na rozmyte, a efekt aliasu zostałby wprowadzony podczas przesuwania aparatu.

Rozwiązaliśmy ten problem, uruchamiając prosty program cieniujący w buforze poza ekranem, aby określić, gdzie wystąpiły duże zmiany kontrastu (1). Umieszczamy piksele z dużymi zmianami w nowym buforze wzornika (2). Następnie umyliśmy bufor wzornika do zamaskowania tych obszarów o dużym kontraście podczas stosowania buforu poza ekranem z powrotem do ekranu, co spowoduje otwory w chmurach i wokół nich (3).

Następnie ponownie renderowaliśmy wszystkie cząstki w trybie pełnoekranowym, ale tym razem umyliśmy bufor wzornika do zamaskowania wszystkiego oprócz krawędzi, co powoduje dotknięcie minimalnego zestawu pikseli (4). Ponieważ bufor poleceń został już utworzony dla cząstek, musieliśmy po prostu ponownie renderować go w nowej aparacie.

Postęp renderowania krawędzi w chmurze

Wynik końcowy to ostre krawędzie z tańszymi środkowymi sekcjami chmur.

Chociaż było to znacznie szybsze niż renderowanie wszystkich cząstek na pełnym ekranie, nadal istnieje koszt związany z testowaniem piksela względem bufora wzornika, więc ogromna ilość narysowania nadal wiąże się z kosztami.

Culling particles (Cząstki cullinga)

W przypadku efektu wiatru wygenerowali paski z długim trójkątem w cieniowania obliczeniowego, tworząc wiele listw wiatru na świecie. Chociaż efekt wiatru nie był duży na współczynniku wypełnienia ze względu na wygenerowane paski o chudanych paskach, wygenerował on wiele setek tysięcy wierzchołków, co przyniosło duże obciążenie modułu cieniującego wierzchołki.

Wprowadziliśmy bufory dołączania w cieniowania obliczeń, aby nasyłać podzestaw pasków wiatru do narysowania. Przy użyciu prostej logiki obliczania frustum w cieniowania obliczeń możemy określić, czy pasek był poza widokiem aparatu i uniemożliwić dodanie go do buforu wypychania. Znacznie zmniejszało to liczbę pasków, co pozwalało na zmniejszenie niektórych potrzebnych cykli procesora GPU.

Kod demonstrujący bufor dołączania:

Cieniowanie obliczeń:

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

Kod C#:

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

Próbowaliśmy użyć tej samej techniki na cząstkach chmury, gdzie ujmowalibyśmy je w obliczeniowym cieniowania i wypchalibyśmy tylko widoczne cząstki do renderowania. Ta technika w rzeczywistości nie zaoszczędziła nam dużo na procesorze GPU, ponieważ największym wąskim gardłem była ilość pikseli renderowanych na ekranie, a nie koszt obliczania wierzchołków.

Innym problemem z tą techniką było to, że bufor dołączania został wypełniony w kolejności losowej ze względu na równoległy charakter przetwarzania cząstek, co powoduje niesortowanie posortowanych cząstek, co powoduje migające cząstki chmury.

Istnieją techniki sortowania bufora wypychania, ale ograniczona ilość zysków wydajnościowych, które uzyskaliśmy z cząstki uśmiercania, prawdopodobnie zostanie przesunięta przy użyciu dodatkowego sortowania, więc zdecydowaliśmy, że nie będziemy kontynuować tej optymalizacji.

Renderowanie adaptacyjne

Aby zapewnić stałą szybkość klatek w aplikacji z różnymi warunkami renderowania, na przykład w przypadku chmury i jasnego widoku, wprowadziliśmy do naszej aplikacji renderowanie adaptacyjne.

Pierwszym krokiem renderowania adaptacyjnego jest pomiar procesora GPU. Zrobiliśmy to przez wstawienie kodu niestandardowego do bufora poleceń procesora GPU na początku i na końcu renderowanych ramek, przechwytując czas na ekranie o lewą i prawą oku.

Mierząc czas renderowania i porównując go z żądaną częstotliwością odświeżania, mieliśmy świeższe informacje o tym, jak blisko upuszczaliśmy ramki.

W przypadku blisko porzucania ramek dostosowujemy renderowanie, aby przyspieszyć jego pracę. Jednym z prostych sposobów dostosowania jest zmiana rozmiaru okna ekranu, co wymaga renderowania mniejszej liczby pikseli.

Za pomocą aparatu UnityEngine.XR.XRSettings.renderViewportScale system zmniejsza docelowy ekran i automatycznie rozciąga wynik w górę w celu dopasowania do ekranu. Niewielka zmiana skali jest ledwo zauważalna w geometrii świata, a współczynnik skali 0,7 wymaga wyrenderowania połowy ilości pikseli.

70% skalowania, połowa pikseli

Gdy wykryjemy, że chcemy upuszczać ramki, obniżamy skalę o stałą liczbę i zwiększamy ją z powrotem, gdy ponownie będziemy działać wystarczająco szybko.

Mimo że zdecydowaliśmy, jakiej techniki chmury użyć na podstawie możliwości graficznych sprzętu podczas uruchamiania, można ją wykorzystać na podstawie danych z pomiaru procesora GPU, aby zapobiec długotrwałemu pozostawaniu systemu w niskiej rozdzielczości, ale nie mieliśmy czasu na eksplorowanie danych.

Końcowe przemyślenia

Ukierunkowanie na różne urządzenia jest trudne i wymaga pewnego planowania.

Zalecamy rozpoczęcie określania docelowych maszyn o niższych zasilaniach, aby zapoznać się z obszarem problemów i opracować rozwiązanie do tworzenia kopii zapasowych, które będzie działać na wszystkich maszynach. Zaprojektuj rozwiązanie z myślą o szybkości wypełniania, ponieważ piksele będą najbardziej cennym zasobem. Przekieruj solidną geometrię nad przezroczystością.

Dzięki rozwiązaniu do tworzenia kopii zapasowych można następnie rozpocząć tworzenie warstw o większej złożoności dla maszyn wysokiej klasy lub po prostu zwiększyć rozdzielczość rozwiązania do tworzenia kopii zapasowych.

Projektuj dla scenariuszy najgorszego przypadku i rozważ użycie renderowania adaptacyjnego w dużych sytuacjach.

Informacje o autorach

Picture of Robert Ferrese Robert Ferrese
Inżynier oprogramowania @Microsoft
Picture of Dan Andersson Dan Andersson
Inżynier oprogramowania @Microsoft

Zobacz też