GPU Lightmapper: Um mergulho técnico profundo

FLORENT GUINIER / UNITY TECHNOLOGIESContributor
May 20, 2019|15 Min
GPU Lightmapper: Um mergulho técnico profundo
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.

A equipe de iluminação está apostando tudo na velocidade de iteração. Projetamos o Lightmapper Progressivo com esse objetivo em mente. Nosso objetivo é fornecer feedback rápido sobre qualquer alteração que você fizer na iluminação do seu projeto. Em 2018.3, apresentamos uma prévia da versão de GPU do Progressive Lightmapper. Agora estamos nos aproximando da paridade de recursos e qualidade visual com seu irmão de CPU. Nosso objetivo é tornar a versão da GPU uma ordem de magnitude mais rápida do que a versão da CPU. Isso traz o lightmapping interativo para os fluxos de trabalho artísticos, com grandes melhorias na produtividade da equipe.

Com isso em mente, optamos por usar o RadeonRays: uma biblioteca de rastreamento de raios de código aberto da AMD. A Unity e a AMD colaboraram no GPU Lightmapper para implementar vários recursos e otimizações importantes. A saber: amostragem de potência, compactação de raios e passagem de BVH personalizada.

O objetivo do projeto do GPU Lightmapper era oferecer os mesmos recursos do CPU Lightmapper e, ao mesmo tempo, obter um desempenho superior:

  • Mapeamento de luz interativo imparcial
  • Paridade de recursos entre back-ends de CPU e GPU
  • Solução baseada em computação
  • Rastreamento de caminho de frente de onda para desempenho máximo

Sabemos que o tempo de iteração é a chave para capacitar os artistas a melhorar a qualidade visual e liberar a criatividade. O objetivo aqui é o mapeamento de luz interativo. Além de tempos de cozimento gerais impressionantes, também queremos que a experiência do usuário ofereça feedback imediato.

Precisávamos resolver vários problemas interessantes para conseguir isso. Nesta postagem, exploraremos algumas das decisões que tomamos.

Feedback progressivo

Para que o Lightmapper ofereça atualizações progressivas ao usuário, precisamos tomar algumas decisões de design.

Nenhum dado pré-computado ou armazenado em cache

Não armazenamos em cache a irradiância ou a visibilidade ao fazer a iluminação direta (a iluminação direta pode ser armazenada em cache e reutilizada para a iluminação indireta). Em geral, não armazenamos nenhum dado em cache e preferimos etapas de computação que sejam pequenas o suficiente para não criar atrasos e oferecer uma exibição progressiva e interativa durante o cozimento.

Imagem

As cenas podem ser muito grandes e conter muitos mapas de luz. Para garantir que o trabalho seja gasto onde ele oferece mais benefícios ao usuário, é importante concentrar-se na área visível no momento. Para isso, primeiro detectamos quais dos mapas de luz contêm a maioria dos texels visíveis não convergidos em uma tela e, em seguida, renderizamos esses mapas de luz e priorizamos os texels visíveis (os texels fora da tela serão processados quando todos os visíveis tiverem convergido).

Um texel é definido como visível se estiver no frustum da câmera atual e se não estiver ocluído por nenhuma geometria estática da cena.

Fazemos essa seleção na GPU (para aproveitar o traçado rápido de raios). Este é o fluxo de um trabalho de abate.

Imagem

Os trabalhos de seleção têm duas saídas:

  • Um buffer de mapa de seleção, que armazena se cada texel do mapa de luz está visível. Esse buffer de mapa de seleção é então usado pelos trabalhos de renderização.
  • Um número inteiro que representa o número de texels visíveis para o lightmap atual. Esse número inteiro será lido de forma assíncrona pela CPU para ajustar o agendamento do mapa de luz no futuro.

No vídeo abaixo, podemos ver o efeito da seleção. O cozimento é interrompido no meio do caminho para fins de demonstração. Portanto, quando a visualização da cena se move, podemos ver os texels ainda não criados (ou seja, pretos) que não são visíveis na posição e direção iniciais da câmera.

Por motivos de desempenho, as informações de visibilidade são atualizadas somente toda vez que o estado da câmera se "estabiliza". Além disso, a superamostragem não é levada em conta.

Desempenho e eficiência

As GPUs são otimizadas para pegar grandes lotes de dados e executar a mesma operação em todos eles; elas são otimizadas para o rendimento. Além disso, a GPU consegue essa aceleração e, ao mesmo tempo, é mais eficiente em termos de energia e custo do que uma CPU de vários núcleos. Entretanto, as GPUs não são tão boas quanto as CPUs em termos de latência (intencionalmente, pelo design do hardware). É por isso que usamos um pipeline orientado por dados sem pontos de sincronização CPU-GPU para aproveitar ao máximo a natureza de computação inerentemente paralela da GPU.

No entanto, o desempenho bruto não é suficiente. A experiência do usuário é o que importa, e nós a medimos pelo impacto visual ao longo do tempo, também conhecido como taxa de convergência. Portanto, também precisamos de algoritmos eficientes.

Pipeline orientado por dados

As GPUs foram projetadas para serem usadas em grandes conjuntos de dados e são capazes de obter alta taxa de transferência ao custo da latência. Além disso, eles geralmente são acionados por uma fila de comandos preenchidos antecipadamente pela CPU. O objetivo desse fluxo contínuo de comandos grandes é garantir que possamos saturar a GPU com trabalho. Vamos dar uma olhada nas principais receitas que estamos usando para maximizar a taxa de transferência e, portanto, o desempenho bruto.

Nosso pipeline

A maneira como abordamos o pipeline de dados de mapeamento de luz da GPU se baseia nos seguintes princípios:

1. Preparamos os dados uma vez.

Nesse momento, a CPU e a GPU podem estar sincronizadas para reduzir a alocação de memória.

2. Após o início do cozimento, não são permitidos pontos de sincronização CPU-GPU.

A CPU está enviando uma carga de trabalho predefinida para a GPU. Essa carga de trabalho será excessivamente conservadora em alguns casos (por exemplo, usando 4 saltos, mas todos os raios indiretos terminaram após o segundo salto, então ainda temos kernels enfileirados que serão executados, mas serão antecipados).

3. A GPU não pode gerar raios nem kernels.

Em vez disso, ele pode ser solicitado a processar trabalhos vazios (ou muito pequenos). Para lidar com esses casos de forma eficiente, os kernels são escritos de forma a maximizar a coerência dos dados e das instruções. Lidamos com isso por meio da `compactação` de dados, que será abordada mais adiante.

4. Não queremos nenhum ponto de sincronização entre a CPU e a GPU, nem nenhum tipo de bolhas na GPU após o início do cozimento.

Por exemplo, alguns comandos do OpenCL podem criar pequenas bolhas na GPU (ou seja, momentos em que a GPU não tem nada para processar), como clEnqueueFillBuffer ou clEnqueueReadBuffer (mesmo nas versões assíncronas), por isso os evitamos o máximo possível. Além disso, o processamento de dados precisa permanecer na GPU pelo maior tempo possível (ou seja, renderização e composição até a conclusão). Quando precisarmos trazer os dados de volta para a CPU para processamento adicional, faremos isso de forma assíncrona e não os enviaremos novamente para a GPU. Por exemplo, a costura é um pós-processo da CPU no momento.

5. A CPU adaptará a carga da GPU de forma assíncrona.

Alterar o mapa de luz que está sendo renderizado quando a visualização da câmera muda ou quando um mapa de luz está totalmente convergido incorrerá em alguma latência. Os threads da CPU geram e manipulam esses eventos de readback usando uma fila sem bloqueio para evitar a contenção de mutex.

Imagem
Tamanho do trabalho compatível com a GPU

Um dos principais recursos da arquitetura da GPU é o amplo suporte a instruções SIMD. SIMD significa Single Instruction Multiple Data (Instrução única e dados múltiplos). Um conjunto de instruções será executado sequencialmente em uma determinada quantidade de dados dentro do que é chamado de warp/wavefront. O tamanho dessas frentes de onda/guerra é de 64, 32 ou 16 valores (dependendo da arquitetura da GPU). Portanto, uma única instrução aplicará a mesma transformação a vários dados - instrução única, vários dados. No entanto, para maior flexibilidade, a GPU também é capaz de suportar caminhos de código divergentes em sua implementação SIMD. Para isso, ele pode desativar alguns threads enquanto trabalha em um subconjunto antes de voltar a trabalhar. Isso é chamado de SIMT: Instrução única, vários threads. No entanto, isso tem um custo, pois os caminhos de código divergentes em uma frente de onda/warp só se beneficiarão de uma fração da unidade SIMD. Leia esta excelente postagem no blog para obter mais informações.

Por fim, uma extensão interessante da ideia do SIMT é a capacidade da GPU de manter muitos warps/frentes de onda por núcleo SIMD. Se uma frente de onda/guia estiver esperando por um acesso lento à memória, o agendador poderá alternar para outra frente de onda/guia e continuar trabalhando nela enquanto isso (desde que haja trabalho pendente suficiente). No entanto, para que isso realmente funcione, a quantidade de recursos necessários por contexto precisa ser baixa, de modo que a ocupação (a quantidade de trabalho pendente) possa ser alta.

Resumindo, devemos ter como meta:

  • Muitos fios em movimento
  • Evitar ramificações divergentes
  • Boa ocupação

Ter uma boa ocupação tem tudo a ver com o código do kernel e é um assunto muito amplo para fazer parte desta postagem do blog. Aqui estão alguns recursos excelentes:

Em geral, o objetivo é usar os recursos locais de forma esparsa, especialmente os registros vetoriais e a memória compartilhada local.

Vamos dar uma olhada no que poderia ser o fluxo para a iluminação direta da GPU. Esta seção aborda principalmente os mapas de luz; no entanto, as sondas de luz funcionam de maneira muito semelhante, exceto pelo fato de não terem dados de visibilidade ou ocupação.

Imagem

Há alguns problemas aqui:

  • A ocupação do mapa de luz nesse exemplo é de 44% (4 texels ocupados em 9), portanto, apenas 44% dos threads da GPU produzirão trabalho utilizável! Além disso, os dados úteis são esparsos na memória, portanto, pagaremos pela largura de banda até mesmo para os texels desocupados. Na prática, a ocupação do mapa de luz geralmente fica entre 50% e 70%, o que representa um enorme ganho potencial.
  • O conjunto de dados é muito pequeno. O exemplo está mostrando um mapa de luz 3x3 para simplificar, mas mesmo o caso comum de um mapa de luz de 512x512 será um conjunto de dados muito pequeno para que as GPUs recentes atinjam a eficiência máxima.
  • Em uma seção anterior, falamos sobre a priorização de visualizações e o trabalho de seleção. Os dois pontos acima são ainda mais verdadeiros, pois alguns texels ocupados não serão processados porque não estão visíveis no momento na visualização Scene, reduzindo ainda mais a ocupação e o conjunto geral de dados.

Como podemos resolver isso? Como parte de uma colaboração com a AMD, foi adicionada a compactação de raios. A ideia melhora muito o desempenho do traçado de raios e do sombreamento. Em resumo, a ideia é criar todas as definições de raio em uma memória contígua, permitindo que todos os threads em um warp/wavefront trabalhem com dados quentes.

Na prática, você também precisa que cada raio saiba o índice do texel ao qual está relacionado; armazenamos isso na carga útil do raio. Além disso, armazenamos a contagem global de raios compactados.

Aqui está o fluxo com compactação:

Imagem

Os dois kernels que estão sombreando e rastreando os raios agora podem ser executados somente na memória ativa e com divergência mínima nos caminhos de código.

O que vem a seguir? Bem, ainda não resolvemos o fato de que o conjunto de dados pode ser muito pequeno para a GPU, especialmente se a priorização de exibição estiver ativada. A próxima ideia é descorrelacionar a geração de raios da representação do gbuffer. Com a abordagem ingênua, geramos apenas um raio por texel. Como, em algum momento, vamos querer gerar mais raios de qualquer forma, é melhor gerarmos vários raios por texels desde o início. Dessa forma, podemos criar um trabalho mais significativo para a GPU. Aqui está o fluxo:

Imagem

Antes da compactação, geramos muitos raios por texel e chamamos isso de expansão. Também geramos metainformações que são usadas na etapa de coleta para acumular no texto de destino correto.

Os kernels de expansão e coleta não são executados com muita frequência. Na prática, expandimos e, em seguida, sombreamos cada luz (para direta) ou processamos todos os rebatimentos (para indireta), para finalmente coletar apenas uma vez.

Com essas técnicas, atingimos nosso objetivo: geramos trabalho suficiente para saturar a GPU e gastamos largura de banda somente nos texels que importam.

Esses são os benefícios de disparar vários raios por texel:

  • O conjunto de raios ativos sempre será um grande conjunto de dados, mesmo no modo de priorização de visualização.
  • A preparação, o rastreamento e o sombreamento estão todos trabalhando com dados muito coerentes, pois o kernel de expansão criará raios direcionados ao mesmo texel na memória contínua.
  • O núcleo de expansão lida com a ocupação e a visibilidade, tornando o núcleo de preparação muito mais simples e, portanto, mais rápido.
  • O tamanho dos buffers do conjunto de dados expandido/em funcionamento é desacoplado do tamanho do mapa de luz.
  • O número de raios que disparamos por texel pode ser determinado por qualquer algoritmo; uma expansão natural será a amostragem adaptativa.

A iluminação indireta usa ideias muito semelhantes, embora mais complexas:

Imagem

Com a luz indireta, temos que realizar vários rebatimentos, e cada um deles pode descartar raios aleatórios. Assim, fazemos a compactação iterativamente para continuar trabalhando com dados quentes.

A heurística que usamos atualmente favorece uma quantidade igual de raios por texel. O objetivo é obter uma saída muito progressiva. No entanto, uma extensão natural disso seria aprimorar essas heurísticas usando amostragem adaptativa, de modo a obter mais raios onde os resultados atuais são ruidosos. Além disso, a heurística poderia visar a uma maior coerência, tanto na memória quanto na execução do grupo de threads, estando ciente do tamanho da frente de onda/guarda do hardware.

Transparência/Translucidez

Ativos do ArchVizPRO criados com o GPU Lightmapper.

Há muitos casos de uso para transparência/translucidez. Uma maneira comum de lidar com a transparência e a translucidez é lançar um raio, detectar a interseção, buscar o material e programar um novo raio se o material encontrado for translúcido ou transparente. No entanto, no nosso caso, a GPU não pode gerar raios por motivos de desempenho (consulte a seção "Pipeline orientado por dados" acima). Além disso, não podemos pedir à CPU que agende raios suficientes com antecedência para termos certeza de que estamos lidando com o pior caso possível, pois isso seria um grande impacto no desempenho.

Por isso, optamos por uma solução híbrida. Tratamos a translucidez e a transparência de forma diferente, o que permite resolver os problemas acima:

Transparência (quando um material não é opaco devido a buracos nele). Nesse caso, o raio pode atravessar ou ricochetear no material com base em uma distribuição de probabilidade. Assim, a carga de trabalho preparada antecipadamente pela CPU não precisa ser alterada, pois ainda somos independentes da cena.

Translucidez (quando um material está filtrando a luz que passa por ele). Nesse caso, fazemos uma aproximação e não consideramos a refração. Em outras palavras, deixamos o material colorir a luz, mas não mudamos sua direção. Isso nos permite lidar com a translucidez enquanto percorremos o BVH, o que significa que podemos lidar facilmente com um grande número de materiais de recorte e escalar muito bem com a complexidade da translucidez na cena.

Imagem

No entanto, há uma peculiaridade: a travessia do BVH está fora de ordem:

No caso de raios de oclusão, isso é realmente bom, pois estamos interessados apenas na atenuação da translucidez de cada triângulo cruzado ao longo do raio. Como a multiplicação é comutativa, a passagem de BVH fora de ordem não é um problema.

No entanto, para os raios de interseção, o que queremos é poder parar em um triângulo (de forma probabilística quando o triângulo for transparente) e coletar a atenuação da translucidez para cada triângulo desde a origem do raio até o ponto de impacto. Como a travessia do BVH está fora de ordem, a solução que escolhemos é primeiro executar apenas a interseção para encontrar o ponto de acerto e marcar o raio se alguma translucidez for atingida. Para cada raio marcado, geramos um raio de oclusão extra a partir da origem do raio de interseção até o acerto do raio de interseção. Para fazer isso de forma eficiente, usamos a compactação ao gerar os raios de oclusão, o que significa que só se pagará o custo extra se o raio de interseção tiver sido marcado como necessitando de tratamento de translucidez.

Tudo isso foi possível graças à natureza de código aberto do RadeonRays, que foi bifurcado e personalizado de acordo com nossas necessidades como parte da colaboração com a AMD.

Algoritmos eficientes

Já vimos o que fazemos em relação ao desempenho bruto, ótimo! No entanto, essa é apenas a primeira parte do quebra-cabeça. Amostras altas por segundo são ótimas, mas o que realmente importa, no final, é o tempo de cozimento. Em outras palavras, queremos obter o máximo de cada raio que lançamos. Essa última afirmação é, na verdade, a raiz de décadas de pesquisa contínua. Aqui estão alguns recursos excelentes:

Ray Tracing em um fim de semana

Ray Tracing: A próxima semana

Ray Tracing: O resto de sua vida

O Unity GPU Lightmapper é um lightmapper de difusão pura. Isso simplifica muito a interação da luz com os materiais e também ajuda a diminuir os vaga-lumes e o ruído. No entanto, ainda há muito que podemos fazer para melhorar a taxa de convergência. Aqui estão algumas das técnicas que usamos:

Roleta russa

Em cada salto, eliminamos o caminho de forma probabilística com base no albedo acumulado. É possível encontrar uma ótima explicação na tese de Eric Veach (página 67).

Ambiente Amostragem de importância múltipla (MIS)

Os ambientes HDR que exibem alta variação podem causar uma quantidade considerável de ruído na saída, exigindo grandes contagens de amostras para produzir resultados agradáveis. Portanto, aplicamos uma combinação de estratégias de amostragem especificamente adaptadas para avaliar o ambiente, analisando-o primeiro, identificando áreas importantes e fazendo a amostragem de acordo. Essa abordagem, que não é exclusiva da amostragem ambiental, é geralmente conhecida como amostragem de importância múltipla e foi inicialmente proposta na tese de Eric Veach (página 252). Isso foi feito em colaboração com o Unity Labs Grenoble.

Muitas luzes

Em cada ressalto, selecionamos probabilisticamente uma luz direta e limitamos o número de luzes que afetam as superfícies com uma estrutura de grade espacial. Isso foi feito em colaboração com a AMD. No momento, estamos investigando mais profundamente o problema de muitas luzes, pois a amostragem da seleção de luzes é fundamental para a qualidade.

Imagem

Redução de ruído

O ruído é removido com o uso de um atenuador de IA treinado nas saídas de um rastreador de caminho. Veja a apresentação de Jesper Mortensen na Unity GDC 2019.

Concluindo

Vimos como um pipeline orientado por dados, a atenção ao desempenho bruto e os algoritmos eficientes são combinados para oferecer uma experiência interativa de mapeamento de luz com o GPU Lightmapper. Observe que o GPU Lightmapper está em desenvolvimento ativo e é constantemente aprimorado.

Diga-nos sua opinião!

A equipe de iluminação

PS: Se você acha que esta leitura foi divertida e está interessado em aceitar um novo desafio, estamos procurando um desenvolvedor de iluminação em Copenhague, entre em contato conosco!

---

Deseja aprender como otimizar gráficos no Unity? Dê uma olhada neste tutorial.