Acessando dados de textura de forma eficiente

NICO LEYMAN Software Development Consultant
May 25, 2023|15 Min
Acessando dados de textura de forma eficiente
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

Aprenda sobre os benefícios e as compensações de diferentes maneiras de acessar os dados de pixels de textura subjacentes no seu projeto Unity.

Trabalhando com dados de pixel no Unity

Dados de pixel descrevem a cor de pixels individuais em uma textura. O Unity fornece métodos que permitem ler ou gravar dados de pixel com scripts C#.

Você pode usar esses métodos para duplicar ou atualizar uma textura (por exemplo, adicionando um detalhe à foto do perfil de um jogador) ou usar os dados da textura de uma maneira específica, como ler uma textura que representa um mapa-múndi para determinar onde colocar um objeto.

Existem várias maneiras de escrever código que lê ou grava em dados de pixel. A escolha depende do que você planeja fazer com os dados e das necessidades de desempenho do seu projeto.

Este blog e o projeto de exemplo que o acompanha têm como objetivo ajudar você a navegar pela API disponível e pelas armadilhas comuns de desempenho. Entender ambos ajudará você a escrever uma solução de alto desempenho ou a lidar com gargalos de desempenho à medida que eles aparecem.

Cópias de dados de pixel da CPU e GPU

Para a maioria dos tipos de texturas, o Unity armazena duas cópias dos dados de pixel: uma na memória da GPU, necessária para renderização, e a outra na memória da CPU. Esta cópia é opcional e permite que você leia, grave e manipule dados de pixels na CPU. Uma textura com uma cópia de seus dados de pixel armazenados na memória da CPU é chamada de textura legível . Um detalhe a ser observado é que o RenderTexture existe apenas na memória da GPU.

As diferenças entre CPU e GPU
Memória

A memória disponível para a CPU difere daquela da GPU na maioria dos hardwares. Alguns dispositivos têm uma forma de memória parcialmente compartilhada, mas neste blog vamos assumir a configuração clássica de PC, onde a CPU só tem acesso direto à RAM conectada à placa-mãe e a GPU depende de sua própria RAM de vídeo (VRAM). Todos os dados transferidos entre esses diferentes ambientes precisam passar pelo barramento PCI, o que é mais lento do que transferir dados dentro do mesmo tipo de memória. Devido a esses custos, você deve tentar limitar a quantidade de dados transferidos a cada quadro.

Fluxograma visualizando a relação entre a memória da CPU e da GPU e uma seção transversal da API
Processamento

A amostragem de texturas em shaders é a operação de dados de pixel de GPU mais comum. Para alterar esses dados, você pode copiar entre texturas ou renderizar em uma textura usando um shader. Todas essas operações podem ser executadas rapidamente pela GPU.

Em alguns casos, pode ser preferível manipular seus dados de textura na CPU, o que oferece mais flexibilidade em como os dados são acessados. As operações de dados de pixel da CPU atuam apenas na cópia dos dados da CPU, portanto, exigem texturas legíveis. Se você quiser amostrar os dados de pixel atualizados em um shader, primeiro você deve copiá-los da CPU para a GPU chamando Apply. Dependendo da textura envolvida e da complexidade das operações, pode ser mais rápido e fácil limitar-se às operações da CPU (por exemplo, ao copiar várias texturas 2D em um ativo Texture2DArray).

A API do Unity fornece vários métodos para acessar ou processar dados de textura. Algumas operações atuam tanto na cópia da GPU quanto da CPU, se ambas estiverem presentes. Como resultado, o desempenho desses métodos varia dependendo se as texturas são legíveis. Métodos diferentes podem ser usados para alcançar os mesmos resultados, mas cada método tem suas próprias características de desempenho e facilidade de uso.

Responda às seguintes perguntas para determinar a solução ideal:

  • A GPU pode executar seus cálculos mais rápido que a CPU?
  • Qual nível de pressão o processo está colocando nos caches de textura? (Por exemplo, amostrar muitas texturas de alta resolução sem usar mipmaps provavelmente deixará a GPU mais lenta.)
  • O processo requer uma textura de gravação aleatória ou pode gerar um anexo de cor ou profundidade? (Escrever em pixels aleatórios em uma textura requer frequentes liberações de cache que tornam o processo lento.)
  • Meu projeto já tem gargalo na GPU? Mesmo que a GPU seja capaz de executar um processo mais rápido que a CPU, ela pode se dar ao luxo de assumir mais trabalho sem exceder seu orçamento de tempo de quadro?
  • Se tanto a GPU quanto o thread principal da CPU estiverem próximos do limite de tempo do quadro, talvez a parte lenta de um processo possa ser executada pelos threads de trabalho da CPU.
  • Quantos dados precisam ser carregados ou baixados da GPU para calcular ou processar os resultados?
  • Um shader ou tarefa em C# poderia compactar os dados em um formato menor para reduzir a largura de banda necessária?
  • Um RenderTexture pode ser reduzido para uma versão de resolução menor que seja baixada?
  • O processo pode ser executado em blocos? (Se muitos dados precisarem ser processados de uma só vez, há o risco de a GPU não ter memória suficiente para eles.)
  • Com que rapidez os resultados são necessários? Cálculos ou transferências de dados podem ser realizados de forma assíncrona e manipulados posteriormente? (Se muito trabalho for feito em um único quadro, há o risco de a GPU não ter tempo suficiente para renderizar os gráficos reais de cada quadro.)
Tornando uma textura legível ou não legível

Por padrão, os ativos de textura que você importa para seu projeto não são legíveis, enquanto as texturas criadas a partir de um script são legíveis.

Texturas legíveis usam o dobro de memória que texturas não legíveis porque precisam ter uma cópia de seus dados de pixel na RAM da CPU. Você só deve tornar uma textura legível quando necessário e torná-la ilegível quando terminar de trabalhar com os dados na CPU.

Para verificar se um ativo de textura no seu projeto é legível e fazer edições, use a opção Leitura/Gravação Habilitada nas Configurações de Importação de Texturaou a API TextureImporter.isReadable .

Para tornar uma textura ilegível, chame seu método Apply com o parâmetro makeNoLongerReadable definido como “true” (por exemplo, Texture2D.Apply ou Cubemap.Apply). Uma textura não legível não pode se tornar legível novamente.

Todas as texturas são legíveis pelo Editor nos modos Editar e Reproduzir. Chamar Apply para tornar a textura ilegível atualizará o valor de isReadable, impedindo que você acesse os dados da CPU. Entretanto, alguns processos do Unity funcionarão como se a textura fosse legível porque eles veem que os dados internos da CPU são válidos.

Exemplos de API de acesso a texturas no GitHub
Exemplo de uma textura gerada na CPU a cada quadro

O desempenho difere muito entre as várias formas de acessar dados de textura, especialmente na CPU (embora menos em resoluções mais baixas). O repositório de exemplos da API Unity Texture Access no GitHub contém vários exemplos que mostram diferenças de desempenho entre várias APIs que permitem acesso ou manipulação de dados de textura. A interface do usuário mostra apenas os tempos de CPU do thread principal. Em alguns casos, recursos DOTS como Burst e o sistema de tarefas são usados para maximizar o desempenho.

Aqui estão os exemplos incluídos no repositório GitHub:

  • SimpleCopy: Copiando todos os pixels de uma textura para outra
  • PlasmaTexture: Uma textura de plasma atualizada na CPU por quadro
  • TransferGPUTextura: Transferir (copiar para um tamanho ou formato diferente) todos os pixels na GPU de uma textura para uma RenderTexture

Abaixo estão listadas as medições de desempenho retiradas dos exemplos no GitHub. Esses números são usados para dar suporte às recomendações a seguir. As medições são de um player construído em um sistema com uma CPU Xeon® W-2145 de 8 núcleos e 3,7 GHz e uma RTX 2080.

Exemplo de SimpleCopy

Esses são os tempos médios de CPU para SimpleCopy.UpdateTestCase com um tamanho de textura de 2.048.

Observe que os métodos Graphics são concluídos quase instantaneamente no thread principal porque eles simplesmente enviam o trabalho para o RenderThread, que posteriormente é executado pela GPU. Os resultados estarão prontos quando o próximo quadro estiver sendo renderizado.

Resultados

  • 1.326 ms – foreach(mip) for(x em largura) for(y em altura) SetPixel(x, y, GetPixel(x, y, mip), mip)
  • 32,14 ms – foreach(mip) SetPixels(fonte.GetPixels(mip), mip)
  • 6,96 ms – foreach(mip) SetPixels32(fonte.GetPixels32(mip), mip)
  • 6.74 ms – LoadRawTextureData(source.GetRawTextureData())
  • 3,54 ms – Graphics.CopyTexture(fonte legível, destino legível)
  • 2,87 ms – foreach(mip) SetPixelData<byte>(mip, GetPixelData<byte>(mip))
  • 2.87 ms – LoadRawTextureData(source.GetRawTextureData<byte>())
  • 0,00 ms – Graphics.ConvertTexture(origem, destino)
  • 0,00 ms – Graphics.CopyTexture(nonReadableSource, alvo)
Exemplo de PlasmaTexture

Esses são os tempos médios de CPU para PlasmaTexture.UpdateTestCase com um tamanho de textura de 512.

Você verá que SetPixels32 é inesperadamente mais lento que SetPixels. Isso ocorre porque é preciso pegar o resultado da cor baseado em float do cálculo do pixel de plasma e convertê-lo na estrutura Color32 baseada em byte. SetPixels32NoConversion ignora essa conversão e apenas atribui um valor padrão à matriz de saída Color32, resultando em melhor desempenho que SetPixels. Para superar o desempenho do SetPixels e a conversão de cores subjacente realizada pelo Unity, é necessário retrabalhar o próprio método de cálculo de pixels para gerar diretamente um valor Color32. Uma implementação simples usando SetPixelData quase certamente dará melhores resultados do que abordagens cuidadosas como SetPixels e SetPixels32.

Resultados

  • 126,95 ms – DefinirPixel
  • 113.16 ms – SetPixels32
  • 88,96 ms – DefinirPixels
  • 86,30 ms – DefinirPixels32SemConversão
  • 16,91 ms – DefinirPixelDataBurst
  • 4,27 ms – DefinirPixelDataBurstParallel
Exemplo de TransferGPUTexture

Estes são os tempos de GPU do Editor para TransferGPUTexture.UpdateTestCase com um tamanho de textura de 8.196:

  • Blit – 1,584 ms
  • CopyTexture – 0,882 ms
Recomendações da API de dados de pixel

Você pode acessar dados de pixels de várias maneiras. No entanto, nem todos os métodos suportam todos os formatos, tipos de textura ou casos de uso, e alguns demoram mais para serem executados do que outros. Esta seção aborda os métodos recomendados, e a seção seguinte aborda aqueles que devem ser usados com cautela.

CopyTexture

CopyTexture é a maneira mais rápida de transferir dados de GPU de uma textura para outra. Ele não realiza nenhuma conversão de formato. Você pode copiar dados parcialmente especificando uma posição de origem e destino, além da largura e altura da região. Se ambas as texturas forem legíveis, a operação de cópia também será realizada nos dados da CPU, aproximando o custo total desse método do de uma cópia somente da CPU usando SetPixelData com o resultado de GetPixelData de uma textura de origem.

Blit

Blit é um método rápido e poderoso de transferir dados de GPU para um RenderTexture usando um shader. Na prática, isso precisa configurar o estado da API do pipeline gráfico para renderizar na RenderTexture de destino. Ele vem com um pequeno custo de instalação independente de resolução em comparação ao CopyTexture. O shader Blit padrão usado pelo método pega uma textura de entrada e a renderiza na RenderTexture de destino. Ao fornecer um material ou shader personalizado, você pode definir processos complexos de renderização de textura para textura.

Obter dados de pixel e definir dados de pixel

GetPixelData e SetPixelData (junto com GetRawTextureData) são os métodos mais rápidos para usar apenas quando se trata de dados da CPU. Ambos os métodos exigem que você forneça um tipo de estrutura como um parâmetro de modelo usado para reinterpretar os dados. Os métodos em si só precisam dessa estrutura para derivar o tamanho correto, então você pode usar apenas byte se não quiser definir uma estrutura personalizada para representar o formato da textura.

Ao acessar pixels individuais, é uma boa ideia definir uma estrutura personalizada com alguns métodos utilitários para facilitar o uso. Por exemplo, uma estrutura no formato R5G5B5A1 poderia ser composta de um membro de dados ushort e alguns métodos get/set para acessar os canais individuais como bytes.

Tipo de bloco desconhecido "codeBlock", especifique um serializador para ele na propriedade `serializers.types`

O código acima é um exemplo de uma implementação de um objeto que representa um pixel no formato R5G5B5A5A1; os definidores de propriedades correspondentes são omitidos por questões de brevidade.

SetPixelData pode ser usado para copiar um nível mip completo de dados para a textura alvo. GetPixelData retornará um NativeArray que aponta para um nível mip dos dados de textura da CPU interna do Unity. Isso permite que você leia/grave esses dados diretamente sem a necessidade de nenhuma operação de cópia. O problema é que o NativeArray retornado por GetPixelData só tem garantia de ser válido até que o código do usuário que chama GetPixelData retorne o controle ao Unity, como quando MonoBehaviour.Update retorna. Em vez de armazenar o resultado de GetPixelData entre quadros, você precisa obter o NativeArray correto de GetPixelData para cada quadro do qual deseja acessar esses dados.

Aplicar

O método Apply retorna depois que os dados da CPU são carregados para a GPU. O parâmetro makeNoLongerReadable deve ser definido como “true” sempre que possível para liberar a memória dos dados da CPU após o upload.

RequestIntoNativeArray e RequestIntoNativeSlice

Os métodos RequestIntoNativeArray e RequestIntoNativeSlice baixam de forma assíncrona dados de GPU da Textura especificada para (uma fatia de) um NativeArray fornecido pelo usuário.

Chamar os métodos retornará um identificador de solicitação que pode indicar se o download dos dados solicitados foi concluído. O suporte é limitado a apenas alguns formatos, então use SystemInfo.IsFormatSupported com FormatUsage.ReadPixels para verificar o suporte ao formato. A classe AsyncGPUReadback também tem um método Request , que aloca um NativeArray para você. Se precisar repetir esta operação, você obterá melhor desempenho se alocar um NativeArray que poderá ser reutilizado.

Métodos para usar com cautela

Há uma série de métodos que devem ser usados com cautela devido a impactos potencialmente significativos no desempenho. Vamos analisá-los com mais detalhes.

Acessadores de pixel com conversões subjacentes

Esses métodos realizam conversões de formato de pixel de complexidade variável. As variantes Pixels32 são as de melhor desempenho do grupo, mas mesmo elas ainda podem executar conversões de formato se o formato subjacente da textura não corresponder perfeitamente à estrutura Color32. Ao usar os métodos a seguir, é melhor ter em mente que o impacto no desempenho aumenta significativamente em graus variados à medida que o número de pixels aumenta:

Acessadores rápidos de dados com uma pegadinha

GetRawTextureData e LoadRawTextureData são métodos exclusivos do Texture2D que funcionam com matrizes contendo os dados de pixels brutos de todos os níveis de mip, um após o outro. O layout vai do maior para o menor mip, com cada mip sendo a quantidade de “altura” dos valores de pixel de “largura”. Essas funções são rápidas para dar acesso aos dados da CPU. GetRawTextureData tem um “gotcha” em que a variante não modelada retorna uma cópia dos dados. Isso é um pouco mais lento e não permite a manipulação direta do buffer subjacente gerenciado pelo Unity. GetPixelData não tem essa peculiaridade e só pode retornar um NativeArray apontando para o buffer subjacente que permanece válido até que o código do usuário retorne o controle ao Unity.

ConvertTexture

ConvertTexture é uma maneira de transferir dados da GPU de uma textura para outra, onde as texturas de origem e destino não têm o mesmo tamanho ou formato. Esse processo de conversão é o mais eficiente possível, dadas as circunstâncias, mas não é barato. Este é o processo interno:

Aloque uma RenderTexture temporária correspondente à textura de destino.

Execute um Blit da textura de origem para a RenderTexture temporária.

Copie o resultado do Blit do RenderTexture temporário para a textura de destino.

Responda às seguintes perguntas para ajudar a determinar se esse método é adequado ao seu caso de uso:

  • Preciso realizar essa conversão?
  • Posso ter certeza de que a textura de origem será criada no tamanho/formato desejado para a plataforma de destino no momento da importação?
  • Posso alterar meus processos para usar os mesmos formatos, permitindo que o resultado de um processo seja usado diretamente como entrada para outro processo?
  • Posso criar e usar uma RenderTexture como destino? Fazer isso reduziria o processo de conversão para um único Blit para o RenderTexture de destino.
ReadPixels

O método ReadPixels baixa sincronicamente os dados da GPU do RenderTexture ativo (RenderTexture.active) para os dados da CPU do Texture2D. Isso permite que você armazene ou processe a saída de uma operação de renderização. O suporte é limitado a apenas alguns formatos, então use SystemInfo.IsFormatSupported com FormatUsage.ReadPixels para verificar o suporte ao formato.

Baixar dados da GPU é um processo lento. Antes de começar, o ReadPixels precisa esperar que a GPU conclua todo o trabalho anterior. É melhor evitar esse método, pois ele não retornará até que os dados solicitados estejam disponíveis, o que diminuirá o desempenho. A usabilidade também é uma preocupação porque você precisa que os dados da GPU estejam em um RenderTexture, que precisa ser configurado como o ativo no momento. Tanto a usabilidade quanto o desempenho são melhores quando usamos os métodos AsyncGPUReadback discutidos anteriormente.

Métodos para converter entre formatos de arquivo de imagem

A classe ImageConversion tem métodos para converter entre Texture2D e vários formatos de arquivo de imagem. O LoadImage é capaz de carregar dados JPG, PNG ou EXR (desde 2023.1) em um Texture2D e enviá-los para a GPU para você. Os dados de pixels carregados podem ser compactados instantaneamente, dependendo do formato original do Texture2D. Outros métodos podem converter uma matriz de dados Texture2D ou pixel em uma matriz de dados JPG, PNG, TGA ou EXR.

Esses métodos não são particularmente rápidos, mas podem ser úteis se seu projeto precisar passar dados de pixel por meio de formatos comuns de arquivo de imagem. Casos de uso típicos incluem carregar o avatar de um usuário do disco e compartilhá-lo com outros jogadores em uma rede.

Principais conclusões e recursos mais avançados

Há muitos recursos disponíveis para aprender mais sobre otimização gráfica, tópicos relacionados e práticas recomendadas no Unity. A seção de desempenho gráfico e criação de perfil da documentação é um bom ponto de partida.

Você também pode conferir vários e-books técnicos para usuários avançados, incluindo Guia definitivo para criação de perfil de jogos Unity,Otimize o desempenho de seus jogos para dispositivos móveise Otimize o desempenho de seus jogos de console e PC.

Você encontrará muitas outras práticas recomendadas avançadas no hub de instruções do Unity.

Aqui está um resumo dos pontos principais a serem lembrados:

  • Ao manipular texturas, o primeiro passo é avaliar quais operações podem ser executadas na GPU para um desempenho ideal. A carga de trabalho da CPU/GPU existente e o tamanho dos dados de entrada/saída são fatores importantes a serem considerados.
  • Usar funções de baixo nível como GetRawTextureData para implementar um caminho de conversão específico quando necessário pode oferecer melhor desempenho em relação aos métodos mais convenientes que realizam cópias e conversões (geralmente redundantes).
  • Operações mais complexas, como grandes leituras e cálculos de pixels, só são viáveis na CPU quando executadas de forma assíncrona ou em paralelo. A combinação do Burst e do sistema de tarefas permite que o C# execute certas operações que, de outra forma, só teriam desempenho em uma GPU.
  • Perfil frequentemente: Há muitas armadilhas que você pode encontrar durante o desenvolvimento, desde conversões inesperadas e desnecessárias até paralisações por esperar por outro processo. Alguns problemas de desempenho só começarão a surgir à medida que o jogo for escalonado e certas partes do seu código forem mais utilizadas. O projeto de exemplo demonstra como aumentos aparentemente pequenos na resolução de textura podem fazer com que certas APIs se tornem um problema de desempenho.

Compartilhe seu feedback sobre dados de textura conosco nos fóruns de Script ou Gráficos Gerais . Não deixe de ficar atento aos novos blogs técnicos de outros desenvolvedores do Unity como parte do processo contínuo SérieTech from the Trenches.