Intro Newton Protocol: o que pode caber em 4 kilobytes

imagem

Recentemente, competi na cena demo de Revisão 2019 na categoria de introdução para PC 4k, e minha introdução ganhou o primeiro lugar. Eu fiz codificação e gráficos, e dixan compôs músicas. A regra básica da competição é criar um arquivo ou site executável com apenas 4096 bytes de tamanho. Isso significa que tudo precisa ser gerado usando matemática e algoritmos; de nenhuma outra maneira posso espremer imagens, vídeo e áudio em uma quantidade tão pequena de memória. Neste artigo, falarei sobre o pipeline de renderização da minha introdução de Newton. Abaixo você pode ver o resultado final, ou clique aqui para ver como ficou ao vivo na Revisão, ou acesse o pouet para comentar e baixar a introdução que participou da competição. Você pode ler sobre o trabalho e as correções dos concorrentes aqui .


A técnica dos campos de distância da marcha Ray é muito popular na disciplina de introdução em 4k, pois permite especificar formas complexas em apenas algumas linhas de código. No entanto, a desvantagem dessa abordagem é a velocidade de execução. Para renderizar a cena, você precisa encontrar o ponto de interseção dos raios com a cena, primeiro determinar o que vê, por exemplo, um raio da câmera e, em seguida, os raios subsequentes do objeto para as fontes de luz para calcular a iluminação. Ao trabalhar com marchas com raios, essas interseções não podem ser encontradas em uma única etapa, você precisa dar várias pequenas etapas ao longo da viga e avaliar todos os objetos em cada ponto. Por outro lado, ao usar o traçado de raios, você pode encontrar a interseção exata marcando cada objeto apenas uma vez, mas o conjunto de formas que podem ser usadas é muito limitado: você precisa ter uma fórmula para cada tipo para calcular a interseção com o raio.

Nesta introdução, eu queria simular uma iluminação muito precisa. Como era necessário refletir milhões de raios na cena, o traçado de raios parecia uma escolha lógica para alcançar esse efeito. Limitei-me a uma única figura - uma esfera, porque a interseção de um raio e uma esfera é calculada de maneira bastante simples. Até as paredes da introdução são na verdade esferas muito grandes. Além disso, simplificou a simulação da física; bastava levar em conta apenas conflitos entre as esferas.

Para ilustrar a quantidade de código que cabe em 4096 bytes, abaixo apresentei o código fonte completo da introdução concluída. Todas as partes, exceto o HTML no final, são codificadas como uma imagem PNG para compactá-las para um tamanho menor. Sem essa compactação, o código levaria quase 8900 bytes. A parte chamada Synth é uma versão simplificada do SoundBox . Para empacotar o código nesse formato minimizado, usei o Google Closure Compiler e o Shader Minifier . No final, quase tudo é compactado em PNG usando JsExe . O pipeline de compilação completo pode ser visto no código-fonte da minha introdução prévia de 4k Core Critical , porque corresponde completamente ao apresentado aqui.


Música e sintetizador são totalmente implementados em Javascript. A parte no WebGL é dividida em duas partes (destacada em verde no código); ela configura o pipeline de renderização. Os elementos de física e traçador de raios são sombreadores GLSL. O restante do código é codificado em uma imagem PNG e o HTML é adicionado ao final da imagem resultante inalterada. O navegador ignora os dados da imagem e executa apenas o código HTML, que por sua vez decodifica PNG em javascript e o executa.

Pipeline de renderização


A imagem abaixo mostra o pipeline de renderização. Consiste em duas partes. A primeira parte do pipeline é um simulador de física. A cena de introdução contém 50 esferas colidindo umas com as outras dentro da sala. A sala em si é composta por seis esferas, algumas das quais menores que outras para criar paredes mais curvas. Duas fontes verticais de iluminação nos cantos também são esferas, ou seja, um total de 58 esferas na cena. A segunda parte do pipeline é o traçador de raios, que renderiza a cena. O diagrama abaixo mostra a renderização de um quadro no tempo t. A simulação física pega o quadro anterior (t-1) e simula o estado atual. O traçador de raios toma as posições atuais e as do quadro anterior (para o canal de velocidade) e renderiza a cena. O pós-processamento combina os 5 quadros anteriores e o quadro atual para reduzir a distorção e o ruído e, em seguida, cria um resultado final.


Renderizando um quadro no momento t.

A parte física é bastante simples, na Internet você encontra muitos tutoriais sobre como criar simulações primitivas para esferas. Posição, raio, velocidade e massa são armazenados em duas texturas com resolução de 1 x 58. Usei a funcionalidade Webgl 2, que permite renderizar vários destinos de renderização, para que os dados de duas texturas sejam gravados simultaneamente. A mesma funcionalidade é usada pelo ray tracer para criar três texturas. O Webgl não fornece acesso às APIs de rastreamento de raios NVidia RTX ou DirectX Raytracing (DXR), portanto, tudo é feito do zero.

Traçador de raios


O rastreamento de raios em si é uma técnica bastante primitiva. Nós liberamos um raio na cena, ele é refletido 4 vezes e, se entrar na fonte de luz, a cor das reflexões se acumula; caso contrário, ficamos pretos. Em 4096 bytes (que inclui música, sintetizador, física e renderização), não há espaço para a criação de estruturas complexas de rastreamento de raios em aceleração. Portanto, usamos o método de pesquisa aproximada, ou seja, verificamos todas as 57 esferas (a parede frontal é excluída) para cada raio, sem fazer nenhuma otimização para excluir parte das esferas. Isso significa que, para fornecer 60 quadros por segundo em resolução 1080p, você pode emitir apenas 2-6 raios ou amostras por pixel. Está perto o suficiente para criar uma iluminação suave.


1 amostra por pixel.


6 amostras por pixel.

Como lidar com isso? Inicialmente, investiguei o algoritmo de rastreamento de raios, mas ele já estava simplificado ao ponto. Consegui aumentar levemente o desempenho eliminando os casos em que o raio começa dentro da esfera, porque esses casos são aplicáveis ​​apenas na presença de efeitos de transparência e apenas objetos opacos estavam presentes em nossa cena. Depois disso, combinei cada condição if em uma instrução separada para evitar ramificações desnecessárias: apesar dos cálculos "redundantes", essa abordagem ainda é mais rápida do que várias instruções condicionais. Você também pode melhorar o padrão de amostragem: em vez de emitir raios aleatoriamente, podemos distribuí-los pela cena em um padrão mais uniforme. Infelizmente, isso não ajudou e levou a artefatos ondulados em todos os algoritmos que tentei. No entanto, essa abordagem criou bons resultados para imagens estáticas. Como resultado, voltei a usar uma distribuição completamente aleatória.

Os pixels vizinhos devem ter iluminação muito semelhante. Por que não usá-los no cálculo da iluminação de um único pixel? Não queremos desfocar texturas, apenas iluminação, por isso precisamos renderizá-las em canais separados. Além disso, não queremos desfocar objetos, portanto, precisamos considerar os identificadores dos objetos para saber quais pixels podem ser desfocados facilmente. Como temos objetos refletores de luz e precisamos de reflexos claros, não basta descobrir o ID do primeiro objeto com o qual o feixe colide. Usei um caso especial para materiais refletivos puros para incluir também os IDs do primeiro e do segundo objetos visíveis nas reflexões no canal identificador do objeto. Nesse caso, o desfoque pode suavizar a iluminação dos objetos nas reflexões, mantendo ao mesmo tempo os limites dos objetos.


Canal de textura, não precisamos desfocá-lo.


Aqui no canal vermelho contém o ID do primeiro objeto, em verde - o segundo e em azul - o terceiro. Na prática, todos eles são codificados em um único valor do formato float, no qual a parte inteira armazena os identificadores de objetos, e a parte fracionária indica rugosidade: 332211.RR.

Como existem objetos com rugosidade diferente na cena (algumas áreas são rugosas, a luz é espalhada em outras, na terceira há um reflexo no espelho), guardo a rugosidade para controlar o raio do borrão. Como não há pequenos detalhes na cena, usei um núcleo grande de 50 x 50 com os pesos na forma de quadrados inversos para desfocar. Ele não leva em consideração o espaço do mundo (isso pode ser realizado para obter resultados mais precisos), porque em superfícies localizadas em ângulo em algumas direções, corroe uma área maior. Esse embaçamento cria uma imagem bastante suave, mas os artefatos são claramente visíveis, especialmente em movimento.


Canal de iluminação com desfoque e artefatos ainda visíveis. Nesta imagem, pontos embaçados na parede traseira são visíveis, causados ​​por um pequeno erro com os identificadores do segundo objeto refletido (os raios deixam a cena). Na imagem final, isso não é muito perceptível, porque reflexões claras são obtidas do canal de textura. As fontes de iluminação também ficam embaçadas, mas eu gostei desse efeito e o deixei. Se desejado, isso pode ser evitado alterando os identificadores de objetos, dependendo do material.

Quando objetos estão em cena e a câmera que filma a cena se move lentamente, a iluminação em cada quadro deve permanecer constante. Portanto, podemos executar o desfoque não apenas nas coordenadas XY da tela; nós podemos desfocar no tempo. Se assumirmos que a iluminação não muda muito em 100 ms, podemos calculá-la em média para 6 quadros. Mas durante essa janela de tempo, os objetos e a câmera ainda irão se distanciar; portanto, um simples cálculo da média de 6 quadros criará uma imagem muito embaçada. No entanto, sabemos onde estavam todos os objetos e a câmera no mapa anterior, para que possamos calcular os vetores de velocidade no espaço da tela. Isso é chamado de reprojeção temporária. Se eu tiver um pixel no tempo t, posso calcular a velocidade desse pixel e calcular onde estava no tempo t-1, e depois calcular onde o pixel no tempo t-1 está no tempo t-2 e assim por diante. 5 quadros de volta. Ao contrário do desfoque no espaço da tela, usei o mesmo peso para cada quadro, ou seja, apenas calculou a média da cor entre todos os quadros para um "desfoque" temporário.


Um canal de velocidade de pixel que informa onde o pixel estava no último quadro com base no movimento do objeto e da câmera.


Para evitar o desfoque conjunto de objetos, usaremos novamente o canal de identificadores de objetos. Nesse caso, consideramos apenas o primeiro objeto com o qual o feixe colidiu. Isso fornece anti-aliasing dentro do objeto, ou seja, em reflexões.

Obviamente, o pixel pode não ter sido visível no quadro anterior; pode estar oculto por outro objeto ou estar fora do campo de visão da câmera. Nesses casos, não podemos usar as informações anteriores. Essa verificação é realizada separadamente para cada quadro, portanto, obtemos de 1 a 6 amostras ou quadros por pixel e usamos os possíveis. A figura abaixo mostra que para objetos lentos isso não é um problema muito sério.


Quando os objetos se movem e abrem novas partes da cena, não temos 6 quadros de informações para calculá-lo como média para essas partes. Esta imagem mostra áreas que possuem 6 quadros (branco), bem como aquelas que não os possuem (tons gradualmente escurecendo). A aparência dos contornos é causada pela randomização dos locais de amostragem do pixel em cada quadro e pelo fato de pegarmos o identificador do objeto da primeira amostra.


A iluminação borrada tem uma média de seis quadros. Os artefatos são quase invisíveis e o resultado é estável ao longo do tempo, porque em cada quadro apenas um quadro em cada seis mudanças em que a iluminação é levada em consideração.

Combinando tudo isso, obtemos uma imagem finalizada. A iluminação é desfocada para os pixels vizinhos, enquanto as texturas e os reflexos permanecem claros. Em média, tudo isso é calculado entre seis quadros para criar uma imagem ainda mais suave e mais estável ao longo do tempo.


A imagem finalizada.

Os artefatos de amortecimento ainda são perceptíveis, porque calculei a média de várias amostras por pixel, embora eu tenha escolhido o canal do identificador de objeto e a velocidade para a primeira interseção. Você pode tentar corrigir isso e suavizar as reflexões descartando as amostras se elas não coincidirem com a primeira, ou pelo menos se a primeira colisão não coincidir em ordem. Na prática, os traços são quase invisíveis, então não me preocupei em eliminá-los. Os limites dos objetos também são distorcidos, porque os canais de velocidade e identificadores de objetos não podem ser suavizados. Eu estava considerando a possibilidade de renderizar a imagem inteira em 2160p, com uma redução adicional na escala para 1080p, mas minha NVidia GTX 980ti não é capaz de processar essas resoluções a 60fps, então decidi abandonar essa idéia.

Em geral, estou muito satisfeito com o resultado da introdução. Consegui juntar tudo o que tinha em mente e, apesar de pequenos bugs, o resultado final foi de alta qualidade. No futuro, você pode tentar corrigir bugs e melhorar o anti-aliasing. Também vale a pena experimentar recursos como transparência, desfoque de movimento, várias formas e transformações de objetos.

Source: https://habr.com/ru/post/pt450612/


All Articles