Estudo de caso – Dimensionar aplicativos entre dispositivos com diferentes funcionalidades

Este estudo de caso descreve como um aplicativo Windows Mixed Reality pode ter como destino várias plataformas com diferentes funcionalidades de hardware. O Datascape é um aplicativo Windows Mixed Reality que exibe dados meteorológicos sobre dados de terreno. O aplicativo envolve usuários com visualizações de dados holográficos. Os usuários podem explorar insights exclusivos que obtêm ao descobrir dados em realidade misturada.

O aplicativo Datascape tem como destino Microsoft HoloLens, Windows Mixed Reality headsets imersivos, computadores com menor potência e computadores de alto desempenho. O principal desafio era renderizar uma cena visualmente atraente, enquanto executava a uma taxa de quadros alta, em dispositivos com habilidades de desempenho extremamente diferentes.

Este estudo de caso percorre o processo e as técnicas que usamos para criar alguns dos sistemas mais intensivos em desempenho, especialmente renderizando o clima como nuvens. Descrevemos os problemas que encontramos e como os superamos.

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

Visão geral do estudo de caso

Veja algumas informações sobre o aplicativo e os desafios do Datascape.

Transparência e superação

Nossas principais lutas de renderização lidavam com transparência, já que a transparência pode ser cara.

Você pode renderizar geometria sólida frente a trás durante a gravação no buffer de profundidade, o que impede a renderização de quaisquer pixels futuros localizados atrás desse pixel. Essa operação impede que pixels ocultos executem o sombreador de pixel e acelera significativamente a renderização. Se você classificar a geometria de forma ideal, cada pixel na tela desenhará apenas uma vez.

A geometria transparente deve ser classificada de volta para a frente e depende da combinação da saída do sombreador de pixel com o pixel atual na tela. Esse processo pode fazer com que cada pixel na tela seja desenhado várias vezes por quadro, chamado de overdraw.

Para HoloLens e computadores tradicionais, você só pode preencher a tela algumas vezes, tornando a renderização transparente problemática.

Componentes de cena do Datascape

A cena do Datascape tem três componentes principais: a interface do usuário, o mapa e o clima. Sabíamos que os efeitos climáticos precisariam de todo o desempenho possível, então projetamos a interface do usuário e o mapa para reduzir a sobrecarga.

Reformulamos a interface do usuário várias vezes para minimizar a quantidade de sobrecarga. Para componentes como botões brilhantes e visões gerais do mapa, optamos por usar geometria mais complexa em vez de substituir a arte transparente.

Para o mapa, usamos um sombreador personalizado que retirou recursos padrão do Unity, como sombras e iluminação complexa. O sombreador personalizado substituiu esses recursos por um modelo simples de iluminação solar simples e um cálculo de neblina personalizado. Esse sombreador de pixel simples melhorou o desempenho.

Obtivemos a interface do usuário e o mapa a serem renderizados no orçamento, portanto, eles não precisavam de nenhuma alteração dependente de hardware. A visualização meteorológica, especialmente a renderização de nuvem, foi mais desafiadora.

Dados de nuvem

Dados de nuvem baixados de servidores NOAA em três camadas 2D distintas. Cada camada tinha a altura superior e inferior da nuvem, e a densidade da nuvem, para cada célula da grade. Processamos os dados em uma textura de informações de nuvem que armazenava cada componente no componente vermelho, verde e azul da textura.

Criar nuvens de geometria

Para garantir que computadores com menos potência possam renderizar as nuvens, nossa abordagem de backup usou geometria sólida para minimizar a sobrecarga.

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

O seguinte código de sombreador 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 bordas de nuvem redondas, descartamos valores próximos a zero cortando os pixels no sombreador de pixels quando o valor de raio interpolado atingiu um limite.

Como as nuvens são geometria sólida, elas podem renderizar antes que o terreno seja renderizado. Ocultar os pixels de mapa caros sob as nuvens melhora ainda mais a taxa de quadros. Devido à abordagem de renderização de geometria sólida, essa solução foi bem executada em todas as placas gráficas, desde a especificação mínima até as placas gráficas de alto nível e em HoloLens.

Image that shows geometry clouds.

Usar nuvens de partículas sólidas

Nossa solução produziu uma representação decente dos dados de nuvem, mas foi um pouco sem brilho. A renderização de nuvem não transmitia a sensação volumétrica que queríamos para nossos computadores high-end. Nossa próxima etapa foi produzir uma aparência mais orgânica e volumétrica, representando as nuvens com aproximadamente 100.000 partículas.

Se as partículas permanecerem sólidas e classificarem frente a trás, você ainda se beneficiará do abate de buffer de profundidade atrás de partículas renderizadas anteriormente, reduzindo a sobrecarga. Além disso, uma solução baseada em partículas pode alterar o número de partículas para atingir hardwares diferentes. No entanto, todos os pixels ainda precisam ser testados em profundidade, o que causa mais sobrecarga.

Primeiro, criamos posições de partícula ao redor do ponto central da experiência na inicialização. Distribuímos as partículas mais densamente ao redor do centro e menos à distância. Pré-classificamos todas as partículas do centro para a parte de trás, então as partículas mais próximas renderizaram primeiro.

Um sombreador de computação exemplou a textura de informações de nuvem para posicionar cada partícula em uma altura correta e colori-la com base na densidade. Cada partícula continha uma altura e um raio. A altura foi baseada nos dados de nuvem amostrados da textura de informações de nuvem. O raio foi baseado na distribuição inicial, que calculou e armazenou a distância horizontal para seu vizinho mais próximo.

Usamos DrawProcedural para renderizar um quad por partícula. Os quadriciclos usaram esses dados para se orientarem, angulado pela altura. Quando os usuários olham para uma partícula horizontalmente, ela mostra a altura. Quando os usuários olham para a partícula de cima para baixo, a área entre ela e seus vizinhos é coberta.

Diagram that shows particle shape and coverage.

O seguinte código de sombreador 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;
}

Classificamos as partículas frente a trás e ainda usamos um sombreador de estilo sólido para cortar pixels transparentes, não mesclar. Essa técnica manipula um grande número de partículas mesmo em computadores com menos potência, evitando uma sobrecarga dispendiosa.

Experimentar nuvens de partículas transparentes

As partículas sólidas forneceram uma sensação orgânica para as formas de nuvem, mas ainda precisavam de algo para capturar a fofura das nuvens. Decidimos experimentar uma solução personalizada para placas gráficas de alto nível que introduza transparência. Nós simplesmente trocamos a ordem de classificação inicial das partículas e alteramos o sombreador para usar as texturas alfa.

Image that shows fluffy clouds.

Esta solução parecia ótima, mas mostrou-se muito pesada até mesmo para as máquinas mais difíceis. Cada pixel teve que ser renderizado na tela centenas de vezes.

Renderizar fora da tela com resolução inferior

Para reduzir o número de pixels para renderizar as nuvens, as renderizamos em um buffer que era um quarto de resolução de tela. Estendemos o resultado final de volta para a tela depois de desenhar todas as partículas.

O código a seguir mostra a renderização fora da tela:

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

Essa solução acelerou o processamento quatro vezes, mas teve algumas ressalvas. Primeiro, ao renderizar em um buffer fora da tela, perdemos todas as informações de profundidade da nossa cena principal. Partículas atrás de montanhas renderizadas no topo da montanha.

Em segundo lugar, o alongamento do buffer introduziu artefatos nas bordas das nuvens, onde a alteração de resolução era perceptível. As próximas duas seções descrevem como resolvemos esses problemas.

Usar um buffer de profundidade de partícula

Precisávamos fazer com que as partículas coexistissem com a geometria mundial, onde uma montanha ou objeto cobria partículas atrás dela. Portanto, populamos o buffer offscreen com um buffer de profundidade que continha a geometria da cena principal. Para produzir o buffer de profundidade, criamos uma segunda câmera que renderizava apenas a geometria sólida e a profundidade da cena.

Usamos a nova textura no sombreador de pixel de nuvem para occluir pixels. Usamos a mesma textura para calcular a distância até a geometria atrás de um pixel de nuvem. Usando essa distância e aplicando-a ao alfa do pixel, conseguimos o efeito de nuvens desaparecendo à medida que se aproximam do terreno. Esse efeito remove os cortes rígidos em que partículas e terrenos se encontram.

Image that shows clouds blended into terrain.

Afiar as bordas

As nuvens estendidas pareciam quase idênticas às nuvens de tamanho normal nos centros de partículas, ou onde se sobrepõem, mas mostraram alguns artefatos nas bordas da nuvem. Bordas afiadas pareciam desfocadas, e o movimento da câmera introduziu efeitos de alias.

Para resolver esse problema, nós:

  1. Executou um sombreador simples no buffer offscreen para determinar onde ocorreram grandes alterações no contraste.
  2. Coloque os pixels com grandes alterações em um novo buffer de estêncil.
  3. Usou o buffer de estêncil para mascarar essas áreas de alto contraste ao aplicar o buffer offscreen de volta à tela, resultando em buracos dentro e ao redor das nuvens.
  4. Renderizaram todas as partículas novamente no modo de tela inteira, usando o buffer de estêncil para mascarar tudo, menos as bordas, resultando em um conjunto mínimo de pixels tocados. Como já criamos o buffer de comando para renderizar as partículas, simplesmente renderizamos novamente para a nova câmera.

Image showing the progression of rendering cloud edges.

O resultado final foram bordas acentuadas com seções de centro baratas das nuvens. Embora essa solução seja muito mais rápida do que renderizar todas as partículas em tela inteira, ainda há um custo para testar pixels no buffer de estêncil. Uma enorme quantidade de saques ainda é cara.

Partículas de cull

Para o efeito do vento, geramos longas faixas de triângulo em um sombreador de computação, criando muitas nuvens de vento no mundo. O efeito do vento não foi pesado na taxa de preenchimento, devido às faixas estreitas. No entanto, as centenas de milhares de vértices causaram uma carga pesada para o sombreador de vértice.

Para reduzir a carga, introduzimos buffers de acréscimo no sombreador de computação para alimentar um subconjunto das faixas de vento a serem desenhadas. Usamos a lógica de abate de frusto de exibição simples no sombreador de computação para determinar se uma faixa estava fora do modo de exibição da câmera e impedimos que essas faixas fossem adicionadas ao buffer de push. Esse processo reduziu significativamente o número de faixas, melhorando o desempenho.

O código a seguir demonstra um buffer 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);
}

Tentamos essa técnica nas partículas de nuvem, abatendo-as no sombreador de computação e apenas empurrando as partículas visíveis para serem renderizadas. Mas não economizamos muito processamento, porque o maior gargalo foi o número de pixels de nuvem a serem renderizados na tela, não o custo de calcular vértices.

Outro problema foi que o buffer de acréscimo foi preenchido em ordem aleatória, devido à computação paralelizada das partículas. As partículas classificadas tornaram-se não variadas, resultando em partículas de nuvem piscando. Há técnicas para classificar o buffer de push, mas a quantidade limitada de ganho de desempenho com o abate de partículas provavelmente seria compensada por outra classificação. Decidimos não prosseguir com essa otimização para as partículas de nuvem.

Usar renderização adaptável

Para garantir uma taxa de quadros constante no aplicativo com condições de renderização variadas, como uma exibição nublada versus clara, introduzimos a renderização adaptável.

A primeira etapa da renderização adaptável é medir o desempenho. Inserimos o código personalizado no buffer de comando no início e no final de um quadro renderizado, para capturar o tempo de tela do olho esquerdo e direito.

Compare o tempo de renderização com a taxa de atualização desejada para mostrar o quão perto você chega de descartar quadros. Quando você chega perto de soltar quadros, você pode adaptar a renderização para ser mais rápido.

Uma maneira simples de adaptar a renderização é alterar o tamanho do visor de tela para que ele exija menos pixels para renderizar. O sistema usa UnityEngine.XR.XRSettings.renderViewportScale para reduzir o visor de destino e estica automaticamente o resultado de volta para se ajustar à tela. Uma pequena alteração na escala mal é perceptível na geometria mundial, e um fator de escala de 0,7 requer metade do número de pixels a serem renderizados.

Image showing 70% scale, with half the pixels.

Quando detectamos que estamos prestes a descartar quadros, reduzimos a escala por uma taxa fixa e restauramos quando estamos executando rápido o suficiente novamente.

Neste estudo de caso, decidimos qual técnica de nuvem usar com base nos recursos gráficos do hardware na inicialização. Você também pode basear essa decisão em dados de medidas de desempenho, para ajudar a impedir que o sistema fique em baixa resolução por um longo tempo.

Recomendações

Direcionar diferentes funcionalidades de hardware é um desafio e requer planejamento. Veja algumas recomendações:

  • Comece a direcionar computadores com menos potência para se familiarizar com o espaço problemático.
  • Desenvolva uma solução de backup executada em todos os seus computadores. Em seguida, você pode colocar em camadas mais complexidade para computadores high-end ou aprimorar a resolução da solução de backup.
  • Crie sua solução com a taxa de preenchimento em mente, já que pixels são seu recurso mais precioso.
  • Geometria sólida de destino sobre transparência.
  • Projete para cenários piores e considere usar a renderização adaptável para situações pesadas.

Sobre os autores

Picture of Robert Ferrese Robert Ferrese
Engenheiro de software @Microsoft
Picture of Dan Andersson Dan Andersson
Engenheiro de software @Microsoft

Confira também