Estudo de caso – Dimensionar aplicativos entre dispositivos com diferentes funcionalidades

Este estudo de caso descreve como um aplicativo de Windows Mixed Reality pode ter como alvo 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 os usuários com visualizações de dados holográficas. Os usuários podem explorar insights exclusivos que obtêm descobrindo dados em realidade misturada.

O aplicativo Datascape tem como alvo Microsoft HoloLens, Windows Mixed Reality headsets imersivos, computadores de menor potência e computadores de alto desempenho. O desafio main era renderizar uma cena visualmente atraente, enquanto executava a uma alta taxa de quadros, 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 de 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 realidade misturada e para aplicativos do Unity, consulte:

Visão geral do estudo de caso

Aqui estão algumas informações sobre o aplicativo Datascape e os desafios.

Transparência e excesso de saque

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

Você pode renderizar geometria sólida frente para trás durante a gravação no buffer de profundidade, o que impede que os pixels futuros localizados atrás desse pixel sejam renderizados. Essa operação impede que pixels ocultos executem o sombreador de pixel e acelera significativamente a renderização. Se você classificar a geometria de maneira 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 o HoloLens e os computadores base, você só pode preencher a tela algumas vezes, tornando a renderização transparente problemática.

Componentes da 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, portanto, 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 do que sobrepor 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 e um cálculo de neblina personalizado. Esse sombreador de pixel simples melhorou o desempenho.

Obtivemos a interface do usuário e o mapa para renderizar no orçamento, portanto, eles não precisavam de nenhuma alteração dependente de hardware. A visualização do clima, especialmente a renderização da 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 os computadores de menor potência possam renderizar as nuvens, nossa abordagem de backup usou geometria sólida para minimizar o excesso de energia.

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 nas parte superiores e inferiores 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 de zero cortando os pixels no sombreador de pixel 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, de especificação mínima a placas gráficas high-end e no HoloLens.

Imagem que mostra nuvens de geometria.

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. Nosso próximo passo 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 buffers de profundidade atrás de partículas renderizadas anteriormente, reduzindo o excesso dedraw. 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ículas 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, angulados pela altura. Quando os usuários olham 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.

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

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 recortar pixels transparentes, não mesclá-las. Essa técnica lida com 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 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 high-end 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.

Imagem que mostra nuvens fofas.

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

Renderizar fora da tela com resolução mais baixa

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 de nossa cena de main. 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, preenchemos o buffer fora da tela com um buffer de profundidade que continha a geometria da cena main. 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 ocluir 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, alcançamos o efeito de nuvens desaparecendo à medida que se aproximam do terreno. Esse efeito remove quaisquer cortes rígidos onde partículas e terrenos se encontram.

Imagem que mostra nuvens misturadas em terreno.

Nitidez das 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. Execute um sombreador simples no buffer fora da tela para determinar onde ocorreram grandes alterações em 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 fora da tela de volta à tela, resultando em orifícios 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.

Imagem mostrando a progressão de bordas de nuvem de renderização.

O resultado final foram bordas afiadas 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 sacada ainda é cara.

Partículas de abate

Para o efeito do vento, geramos longas tiras de triângulo em um sombreador de computação, criando muitos wisps 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 uma lógica de eliminação de frutos de exibição simples no sombreador de computação para determinar se uma faixa estava fora da exibição da câmera e impedimos que essas tiras 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 era que o buffer de acréscimo era preenchido em ordem aleatória, devido à computação paralelizada das partículas. As partículas classificadas tornaram-se não classificadas, resultando em partículas de nuvem cintilando. 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 outro tipo. Decidimos não buscar 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 código personalizado no buffer de comandos 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 soltar 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 alonga automaticamente o resultado de volta para caber na tela. Uma pequena alteração na escala mal é perceptível na geometria do mundo e um fator de escala de 0,7 requer metade do número de pixels a serem renderizados.

Imagem mostrando escala de 70%, com metade dos pixels.

Quando detectamos que estamos prestes a remover quadros, reduzimos a escala em uma taxa fixa e a 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 de menor potência para se familiarizar com o espaço do problema.
  • 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, pois os 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

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

Confira também