Case study - Dimensionar aplicações entre dispositivos com diferentes capacidades

Este caso de estudo descreve como uma aplicação Windows Mixed Reality pode visar várias plataformas com diferentes capacidades de hardware. O Datascape é uma aplicação Windows Mixed Reality que apresenta dados meteorológicos sobre os dados do terreno. A aplicação envolve utilizadores com visualizações de dados holográficos. Os utilizadores podem explorar informações exclusivas que ganham ao detetar dados na realidade mista.

A aplicação Datascape destina-se a Microsoft HoloLens, Windows Mixed Reality headsets envolventes, PCs de menor potência e PCs de alto desempenho. O principal desafio foi compor uma cena visualmente apelativa, ao mesmo tempo que executava a uma taxa de fotogramas elevada, em dispositivos com capacidades de desempenho extremamente diferentes.

Este estudo de caso explica o processo e as técnicas que utilizámos para criar alguns dos sistemas mais intensivos de desempenho, especialmente compor o clima como nuvens. Descrevemos os problemas que encontrámos e como os ultrapassámos.

Para obter mais informações sobre considerações de desempenho para a realidade mista e para aplicações do Unity, consulte:

Descrição geral do caso de estudo

Eis algumas informações sobre a aplicação Datascape e os desafios.

Transparência e controlo excessivo

As nossas principais lutas de composição lidaram com a transparência, uma vez que a transparência pode ser dispendiosa.

Pode compor geometria sólida de frente para trás enquanto escreve na memória intermédia de profundidade, o que impede que quaisquer píxeis futuros localizados atrás desse pixel sejam compostos. Esta operação impede que os píxeis ocultos executem o sombreado de píxeis e acelere significativamente a composição. Se ordenar a geometria de forma ideal, cada pixel no ecrã desenha apenas uma vez.

A geometria transparente tem de ser ordenada de trás para a frente e depende da mistura da saída do sombreador de pixels com o pixel atual no ecrã. Este processo pode fazer com que cada píxel no ecrã seja desenhado várias vezes por fotograma, denominado overdraw.

Para HoloLens e PCs principais, só pode preencher o ecrã algumas vezes, o que torna a composição transparente problemática.

Componentes de cena do Datascape

A cena Do Datascape tem três componentes principais: a IU, o mapa e as condições meteorológicas. Sabíamos que os efeitos meteorológicos precisariam de todo o desempenho que poderiam obter, por isso concebemos a IU e o mapa para reduzir a eliminação excedam.

Retrabalhámos a IU várias vezes para minimizar a quantidade de substituição. Para componentes como botões brilhantes e descrições geral do mapa, optámos por utilizar geometria mais complexa em vez de sobrepor arte transparente.

Para o mapa, utilizámos um sombreado personalizado que despojava funcionalidades padrão do Unity, como sombras e iluminação complexa. O sombreador personalizado substituiu estas funcionalidades por um modelo simples e único de iluminação solar e um cálculo de nevoeiro personalizado. Este simples sombreado de píxeis melhorou o desempenho.

Conseguimos que a IU e o mapa fossem compostos no orçamento, para que não precisassem de alterações dependentes do hardware. A visualização meteorológica, especialmente a composição da cloud, foi mais desafiante.

Dados da cloud

Dados na cloud transferidos a partir de servidores NOAA em três camadas 2D distintas. Cada camada tinha a altura superior e inferior da cloud e a densidade da cloud, para cada célula da grelha. Processámos os dados numa textura de informações da cloud que armazenava cada componente no componente vermelho, verde e azul da textura.

Criar clouds de geometria

Para garantir que as máquinas com tecnologia inferior podem compor as clouds, a nossa abordagem de cópia de segurança utilizou geometria sólida para minimizar a sobrecarga.

Produzimos clouds ao gerar uma malha de mapa de altura sólida para cada camada. Utilizámos o raio da textura das informações da cloud por vértice para gerar a forma. Utilizámos um sombreado de geometria para produzir os vértices na parte superior e inferior das nuvens, gerando formas de nuvem sólidas. Utilizámos o valor de densidade da textura para colorir a cloud com cores mais escuras para nuvens mais densas.

O seguinte código de sombreado cria os vértices:

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

Introduzimos um pequeno padrão de ruído para obter mais detalhes sobre os dados reais. Para produzir arestas de nuvem redondas, eliminámos valores quase nulos ao cortar os píxeis no sombreador de pixels quando o valor de raio interpolado atingiu um limiar.

Uma vez que as nuvens são geometria sólida, podem compor antes da composição do terreno. Ocultar os pixéis de mapa caros por baixo das nuvens melhora ainda mais a taxa de fotogramas. Devido à abordagem de composição de geometria sólida, esta solução correu bem em todas as placas gráficas, desde especificações mínimas a placas gráficas de alta qualidade e no HoloLens.

Imagem que mostra clouds de geometria.

Utilizar nuvens de partículas sólidas

A nossa solução produziu uma representação decente dos dados da cloud, mas foi um pouco insuficiente. A composição da cloud não transmitiu a sensação volumétrica que queríamos para as nossas máquinas de alta gama. O próximo passo foi produzir um aspeto mais orgânico e volumetrico ao representar as nuvens com aproximadamente 100.000 partículas.

Se as partículas se mantiverem sólidas e ordenarem frente a frente, ainda beneficiará do abate da memória intermédia de profundidade por trás de partículas compostas anteriormente, reduzindo a sobrecarga. Além disso, uma solução baseada em partículas pode alterar o número de partículas para visar hardware diferente. No entanto, todos os píxeis ainda precisam de ser testados em profundidade, o que causa mais sobrecarga.

Primeiro, criámos posições de partículas em torno do ponto central da experiência no arranque. Distribuímos as partículas mais densamente ao redor do centro e menos à distância. Pré-ordenamos todas as partículas do centro para trás, por isso as partículas mais próximas são compostas primeiro.

Um tom de computação deu uma amostra da textura das informações da cloud para posicionar cada partícula a uma altura correta e colori-la com base na densidade. Cada partícula continha uma altura e um raio. A altura baseou-se nos dados da cloud que foram amostrados a partir da textura das informações da cloud. O raio foi baseado na distribuição inicial, que calculou e armazenou a distância horizontal para o vizinho mais próximo.

Utilizámos DrawProcedural para compor um quad por partícula. Os quads utilizaram estes dados para se orientarem, em ângulo pela altura. Quando os utilizadores olham para uma partícula horizontalmente, mostra a altura. Quando os utilizadores olham para a partícula de cima para baixo, a área entre ela e os seus vizinhos é coberta.

Diagrama que mostra a forma e a cobertura das partículas.

O seguinte código de sombreado mostra a distribuição:

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

Ordenamos as partículas frente a frente e ainda usamos um tom de estilo sólido para cortar píxeis transparentes, não para as misturar. Esta técnica processa um grande número de partículas mesmo em máquinas de menor potência, evitando uma sobrecarga dispendiosa.

Experimentar nuvens de partículas transparentes

As partículas sólidas proporcionavam uma sensação orgânica às formas da nuvem, mas ainda precisavam de algo para capturar a fluffiness das nuvens. Decidimos experimentar uma solução personalizada para cartões gráficos de alta qualidade que introduza transparência. Alterámos simplesmente a ordem de ordenação inicial das partículas e alterámos o sombreado para utilizar as texturas alfa.

Imagem que mostra nuvens fofinhas.

Esta solução parecia excelente, mas revelou-se demasiado pesada até para as máquinas mais difíceis. Cada pixel teve de ser composto no ecrã centenas de vezes.

Compor offscreen com resolução inferior

Para reduzir o número de píxeis para compor as clouds, compõemo-las numa memória intermédia que era um quarto da resolução do ecrã. Esticámos o resultado final novamente para o ecrã depois de desenharmos todas as partículas.

O código seguinte mostra a composição offscreen:

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

Esta solução acelerou o processamento quatro vezes, mas teve algumas ressalvas. Primeiro, ao compor numa memória intermédia offscreen, perdemos todas as informações de profundidade da nossa cena principal. Partículas atrás de montanhas compostas no topo da montanha.

Em segundo lugar, o alongamento da memória intermédia introduziu artefactos nas margens das nuvens, onde a alteração da resolução era percetível. As duas secções seguintes descrevem como resolvemos estes problemas.

Utilizar uma memória intermédia de profundidade de partículas

Precisávamos que as partículas coexistissem com a geometria mundial, onde uma montanha ou objeto cobria partículas atrás dela. Assim, preenchemos a memória intermédia offscreen com uma memória intermédia de profundidade que continha a geometria da cena principal. Para produzir a memória intermédia de profundidade, criámos uma segunda câmara que compõe apenas a geometria e a profundidade sólidas da cena.

Utilizámos a nova textura no sombreado de píxeis na nuvem para ocluir pixéis. Utilizámos a mesma textura para calcular a distância para a geometria por trás de um pixel de nuvem. Ao usar essa distância e aplicá-la ao alfa do pixel, conseguimos o efeito de nuvens desaparecendo à medida que se aproximam do terreno. Este efeito remove quaisquer cortes duros onde partículas e terreno se encontram.

Imagem que mostra nuvens misturadas em terreno.

Afiar as arestas

As nuvens esticadas pareciam quase idênticas às nuvens de tamanho normal nos centros de partículas, ou onde se sobrepunham, mas mostravam alguns artefactos nas margens da cloud. As arestas afiadas pareciam desfocadas, e o movimento da câmara introduziu efeitos de alias.

Para resolver este problema, vamos:

  1. Executou um sombreado simples na memória intermédia offscreen, para determinar onde ocorreram grandes alterações em contraste.
  2. Coloque os píxeis com grandes alterações numa nova memória intermédia de stencil.
  3. Utilizou a memória intermédia do stencil para mascarar estas áreas de alto contraste ao aplicar a memória intermédia offscreen de volta ao ecrã, resultando em orifícios dentro e à volta das clouds.
  4. Compor todas as partículas novamente no modo de ecrã inteiro, utilizando a memória intermédia do stencil para mascarar tudo menos as arestas, resultando num conjunto mínimo de píxeis tocados. Uma vez que já criámos a memória intermédia de comandos para compor as partículas, simplesmente tornámo-la novamente na nova câmara.

Imagem que mostra a progressão da composição das margens da cloud.

O resultado final foi arestas afiadas com secções de centro baratas das nuvens. Embora esta solução seja muito mais rápida do que compor todas as partículas em ecrã inteiro, ainda há um custo para testar pixéis na memória intermédia do stencil. Uma quantidade enorme de sobrecarga ainda é cara.

Partículas de abate

Para o efeito de vento, gerámos faixas de triângulo compridas num sombreado de computação, criando muitos fios de vento no mundo. O efeito do vento não era pesado na taxa de preenchimento, devido às faixas estreitas. No entanto, as muitas centenas de milhares de vértices causaram uma carga pesada para o tom de vértice.

Para reduzir a carga, introduzimos memórias intermédias de acréscimo no sombreado de computação, para alimentar um subconjunto das faixas de vento a desenhar. Utilizámos uma lógica de abate de frustum de vista simples no sombreado de computação para determinar se uma tira estava fora da vista da câmara e impedimos que essas tiras fossem adicionadas à memória intermédia push. Este processo reduziu significativamente o número de tiras, melhorando o desempenho.

O código seguinte demonstra uma memória intermédia de acréscimo.

Sombreador de computação:

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

Código 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);
}

Tentámos esta técnica nas partículas da nuvem, abatindo-as no tom de computação e empurrando apenas as partículas visíveis para serem compostas. Mas não poupámos muito processamento, porque o maior estrangulamento foi o número de píxeis da cloud a compor no ecrã, não o custo do cálculo de vértices.

Outro problema era que a memória intermédia de acréscimo era preenchida por ordem aleatória, devido à computação paralela das partículas. As partículas ordenadas tornaram-se não ordenadas, resultando em partículas de nuvem intermitentes. Existem técnicas para ordenar a memória intermédia push, mas a quantidade limitada de ganho de desempenho das partículas de abate seria provavelmente compensada por outra ordenação. Decidimos não prosseguir com esta otimização para as partículas da cloud.

Utilizar composição adaptável

Para garantir uma taxa de fotogramas constante na aplicação com diferentes condições de composição, como uma vista cloud vs. clear, introduzimos a composição adaptável.

O primeiro passo da composição adaptável é medir o desempenho. Inserimos código personalizado na memória intermédia de comandos no início e no fim de uma moldura composta, para capturar o tempo de ecrã do olho esquerdo e direito.

Compare o tempo de composição com a taxa de atualização pretendida para mostrar o quão perto está de remover fotogramas. Quando estiver perto de largar fotogramas, pode adaptar a composição para ser mais rápida.

Uma forma simples de adaptar a composição é alterar o tamanho do viewport do ecrã para que seja necessário menos pixéis para compor. O sistema utiliza UnityEngine.XR.XRSettings.renderViewportScale para reduzir o viewport direcionado e estica automaticamente a cópia de segurança do resultado para se ajustar ao ecrã. Uma pequena alteração na escala mal se nota na geometria mundial e um fator de escala de 0,7 requer metade do número de píxeis a ser composto.

Imagem a mostrar uma escala de 70%, com metade dos píxeis.

Quando detetamos que estamos prestes a largar fotogramas, reduzimos a escala por uma proporção fixa e restauramo-la quando estivermos a ser executados com rapidez suficiente novamente.

Neste caso, decidimos que técnica de cloud utilizar com base nas capacidades gráficas do hardware no arranque. Também pode basear esta decisão em dados de medições de desempenho, para ajudar a impedir que o sistema se mantenha em baixa resolução durante muito tempo.

Recomendações

A segmentação de diferentes capacidades de hardware é desafiante e requer planeamento. Veja a seguir algumas recomendações:

  • Comece a direcionar máquinas com tecnologia inferior para se familiarizar com o espaço problemático.
  • Desenvolva uma solução de cópia de segurança que seja executada em todos os seus computadores. Em seguida, pode colocar uma camada mais complexa para máquinas de gama alta ou melhorar a resolução da solução de cópia de segurança.
  • Crie a sua solução com a taxa de preenchimento em mente, uma vez que os píxeis são o recurso mais precioso.
  • Geometria sólida de destino sobre transparência.
  • Crie cenários na pior das hipóteses e considere utilizar a composição adaptável para situações pesadas.

Sobre os autores

Imagem de Robert Ferrese Robert Ferrese
Engenheiro de software @Microsoft
Imagem de Dan Andersson Dan Andersson
Engenheiro de software @Microsoft

Ver também