A implementação do efeito aquarela nos jogos

imagem

Introdução


Quando, em janeiro de 2019, começamos a discutir nosso novo jogo de tonalidades. , decidimos imediatamente que o efeito aquarela seria o elemento mais importante. Inspirados por este anúncio da Bulgari , percebemos que a implementação da pintura em aquarela deveria ser consistente com a alta qualidade dos recursos restantes que planejávamos criar. Encontramos um artigo interessante de pesquisadores da Adobe (1) . A técnica de aquarela descrita nela parecia maravilhosa e, devido à sua natureza vetorial (em vez de pixel), poderia funcionar mesmo em dispositivos móveis fracos. Nossa implementação é baseada neste estudo, nós alteramos e / ou simplificamos partes dela porque nossos requisitos de desempenho eram diferentes. tonalidade .- este é um jogo, portanto, além do próprio desenho, precisávamos renderizar todo o ambiente 3D e executar a lógica do jogo em um quadro. Também procuramos garantir que a simulação fosse realizada em tempo real e o jogador imediatamente viu o que foi desenhado.


Simulação de cor de água em tonalidade.

Neste artigo, compartilharemos os detalhes individuais da implementação dessa técnica no mecanismo de jogo Unity e falaremos sobre como a adaptamos para funcionar perfeitamente em dispositivos móveis de última geração. Falaremos mais sobre os principais estágios desse algoritmo, mas sem demonstrar o código. Esta implementação foi criada no Unity 2018.4.2 e atualizada posteriormente para a versão 2018.4.7.

O que é tonalidade?


Matiz . - Este é um jogo de quebra-cabeça que permite ao jogador completar os níveis, misturando as cores das aquarelas para combinar com as cores do origami. O jogo foi lançado no outono de 2019 na Apple Arcade para iOS, macOS e tvOS.


Matiz da captura de tela.

Exigências


A técnica descrita no meu artigo pode ser dividida em três estágios principais executados em cada quadro:

  1. Gere novos spots com base na entrada do jogador e adicione-os à lista de spots
  2. Simulação de tinta para todos os pontos da lista
  3. Renderização pontual

Abaixo, falaremos detalhadamente sobre como implementamos cada uma das etapas.

Nosso objetivo era atingir 60 FPS, ou seja, esses estágios e toda a lógica descrita abaixo são executados 60 vezes por segundo.

Obtendo entrada


Em cada quadro, transformamos a entrada do jogador (dependendo da plataforma, pode ser um toque, a posição do mouse ou o cursor virtual) em uma estrutura splatData que contém a posição, vetor de movimento, cor e pressão (2). Primeiro, verificamos o comprimento do furto do jogador na tela e o comparamos com um determinado valor limite. Com furtos curtos, geramos um ponto por quadro na posição de entrada. No caso oposto, preenchemos a distância entre os pontos inicial e final do furto do jogador com novos pontos criados com uma densidade predeterminada (isso garante uma densidade de tinta constante, independentemente da velocidade do furto). A cor indica a tinta atual usada e a inclinação do movimento indica a direção do furto. Novos pontos criados são adicionados a uma coleção chamada splatList, que também contém todos os pontos criados anteriormente. É usado para simular e renderizar tinta nas etapas a seguir. Cada ponto individual indica uma "gota" de tinta que precisa ser renderizada - o principal componente da pintura em aquarela. O desenho em aquarela finalizado será o resultado da renderização de dezenas / centenas de pontos que se cruzam. Além disso, o valor da vida útil (em quadros) é atribuído ao ponto recém-criado, que determina por quanto tempo o ponto pode ser simulado.


Um exemplo de interpolação de pontos de furto longos. Círculos ocos indicam pontos criados em intervalos regulares.

Tela de pintura


Como pintura real, precisamos de uma tela. Para implementá-lo, criamos uma área limitada no espaço 3D que parece uma folha de papel. As coordenadas de entrada do jogador e todas as outras operações, como renderizar uma malha, são registradas no espaço da tela. Da mesma forma, o tamanho em pixels de qualquer buffer usado para simular desenho depende do tamanho da tela. O termo "tela", conforme usado neste artigo, não está de forma alguma associado à classe Canvas da interface do usuário do Unity.


O retângulo verde mostra a área da tela no jogo

Local


Visualmente, o ponto é representado por uma malha redonda, cuja borda consiste em 25 vértices. Você pode percebê-lo como uma “gota” que um pincel molhado deixa em um pedaço de papel se você tocá-lo por um momento muito curto. Adicionamos um pequeno deslocamento aleatório à posição de cada vértice, o que garante a irregularidade das bordas das manchas de tinta.


Exemplos de malhas de malha.

Para cada vértice, também armazenamos o vetor de velocidade externa, que é então usado na fase de simulação. Geramos várias malhas com pequenas variações entre elas e armazenamos seus dados no objeto skriptuemy ( um objeto com script ). Cada vez que um jogador desenha um ponto em tempo real, atribuímos a ele uma malha selecionada aleatoriamente deste conjunto. Vale ressaltar que em diferentes resoluções de tela a tela tem um tamanho diferente em pixels. Para que em todos os dispositivos o coeficiente do tamanho dos pontos seja o mesmo, quando o jogo começa, alteramos a escala de acordo com o tamanho da tela.


Um exemplo de vetores spot armazenados com novos dados spot.

Quando uma malha pontual é gerada, também salvamos sua “área molhada”, que define um conjunto de pixels que estão dentro das bordas pontuais originais. A área de umedecimento é usada para simular a advecção . Durante a execução do aplicativo no momento da criação de cada novo ponto, marcamos a tela como molhada. Ao simular o movimento da tinta, permitimos que ela se "espalhe" sobre as áreas da tela que já se molharam. Armazenamos o conteúdo de umidade da tela no buffer global do wetmap , que é atualizado à medida que cada novo ponto é adicionado. Além de participar da mistura de duas cores, a advecção desempenha um papel importante na aparência final da pincelada.


Preenchimento de mapas úmidos , pixels dentro da forma pontual (círculo verde) marcam o buffer do mapa úmido (grade) como úmido (verde). O buffer do wetmap em si tem uma resolução muito mais alta.

Além disso, cada ponto também contém um valor de opacidade , que é uma função de sua área; representa o efeito de armazenar pigmento (uma quantidade constante de pigmento no local). Quando o tamanho de um ponto aumenta durante a simulação, sua opacidade diminui e vice-versa.


Um exemplo de tinta sem advecção (esquerda) e com ela (direita).


Exemplos de advecção de tinta.

Ciclo de simulação


Depois que a entrada do jogador no quadro atual é recebida e convertida em novos pontos, o próximo passo é simular os pontos para simular a propagação de aquarelas. No início desta simulação, temos uma lista de pontos que precisam ser atualizados e um mapa úmido atualizado .

Em cada quadro, contornamos a lista de pontos e alteramos as posições de todos os vértices dos pontos usando a seguinte equação:


onde: m é o novo vetor de movimento, a é o parâmetro de correção constante (0,33), b é o vetor de inclinação do movimento = direção normalizada do furto do jogador, multiplicado por 0,3, cr é o valor escalar da rugosidade da tela = Random.Range (1,1 + r), r é o parâmetro de rugosidade global; para pintura padrão, definimos como 0,4; v é o vetor de velocidade criado previamente com a malha pontual; vm é o fator de velocidade; o valor escalar que usamos localmente em algumas situações para acelerar a advecção; x (t + 1) - potencial nova posição do vértice, x (t) - posição atual do vértice, brÉ o vetor de rugosidade da ramificação = (Random.Range (-r, r), Random.Range (-r, r)), w (x) é o valor de umedecimento no buffer do wetmap.

O resultado de tais equações é chamado de caminhada aleatória tendenciosa , imita o comportamento de partículas na pintura real de aquarela. Estamos tentando mover cada vértice do ponto para fora do centro ( v ), adicionando aleatoriedade. Então a direção do movimento muda levemente com a direção do curso ( b ) e é novamente randomizada por outro componente de rugosidade ( br ). Então, essa nova posição de vértice é comparada com um wetmap . Se a tela na nova posição já estiver molhada (valor no buffer do wetmapmaior que 0), então damos ao vértice uma nova posição x (t + 1) , caso contrário, não alteramos sua posição. Como resultado, a tinta se espalhará apenas nas áreas da tela que já estavam molhadas. No último estágio, recalculamos a área pontual, que é usada no ciclo de renderização para alterar sua opacidade.


Exemplo em escala micro de simulação de advecção entre dois pontos ativos de tinta.

Ciclo de renderização - Buffer úmido


Após recontar os pontos, você pode começar a renderizá-los. Na saída após o estágio de emulação, a malha de pontos geralmente se deforma (por exemplo, ocorrem interseções); portanto, para sua correta renderização sem custos adicionais para triangulações repetidas, usamos uma solução com buffer de estêncil de duas passagens. A interface de desenho do Unity Graphics é usada para renderizar pontos e o ciclo de renderização é executado dentro do método Unity OnPostRender . As malhas pontuais são renderizadas para renderizar textura ( wetBuffer ) usando uma câmera separada. No início do ciclo, o wetBuffer é limpo e definido como um destino de renderização usando Graphics.SetRenderTarget (wetBuffer) . Avançar para cada ponto ativo do splatList executamos a sequência mostrada no diagrama a seguir:


Diagrama do ciclo de renderização.

Começamos limpando o buffer de estêncil antes de cada ponto, para que o estado do buffer de estêncil do ponto anterior não afete o novo ponto. Depois, selecionamos o material usado para desenhar o ponto. Esse material é responsável pela cor do ponto e o selecionamos com base no índice de cores armazenado em splatData quando o jogador desenhou o ponto. Em seguida, alteramos a opacidade da cor (canal alfa) com base na área da malha pontual calculada na etapa anterior. A renderização em si é realizada usando um sombreador de buffer de estêncil de duas passagens. Na primeira passagem (Material.SetPass (0)), passamos a malha pontual original para registrar as coordenadas nas quais a malha é preenchida. Com este passe ColorMaskatribuiu um valor 0, para que a malha em si não seja renderizada. Na segunda passagem (Material.SetPass (1)), usamos o quadrilátero descrito ao redor da malha pontual. Verificamos o valor no buffer de estêncil para cada pixel do quadrilátero; se o valor for um, o pixel será renderizado, caso contrário, será ignorado. Como resultado dessa operação, renderizamos a mesma forma que a malha pontual, mas certamente não conterá artefatos indesejados, por exemplo, interseções automáticas.


O procedimento para executar a técnica de buffer de estêncil duplo (da esquerda para a direita). Observe que esse buffer de estêncil tem uma resolução muito mais alta do que a mostrada, para que possa manter sua forma original com grande precisão.


Um exemplo de três pontos de interseção renderizados da maneira tradicional, que levaram ao aparecimento de artefatos (à esquerda) e usando a técnica de buffer de estêncil de duas passagens com a eliminação de todos os artefatos (à direita).

Após renderizar todos os pontos no wetBuffer, ele é exibido na cena do jogo. Nossa tela usa um shader improvisado que combina um wetBuffer , um mapa em papel difuso e um mapa normal em papel.


Sombreador de lona: apenas wetBuffer (esquerda), textura de papel adicionada (centro), mapa normal adicionado (direita).

O jogo suporta um modo para pessoas com daltonismo, no qual padrões separados são sobrepostos sobre a pintura. Para conseguir isso, alteramos o material das manchas adicionando a textura do padrão com ladrilhos. Os padrões seguem as regras de mistura das cores do jogo, por exemplo, azul (barras) + amarelo (círculos) dão verde (círculos nas barras) no cruzamento. Para mesclar perfeitamente os padrões, eles devem ser renderizados no mesmo espaço UV. Ajustamos as coordenadas UV do quadrilátero usadas na segunda passagem do buffer de estêncil, dividindo as posições x e y (especificadas no espaço da tela) pela largura e altura da tela. Como resultado, obtemos os valores corretos de u, v no espaço de 0 a 1.


Um exemplo de padrões de daltonismo.

Otimização - tampão de manchas secas


Como mencionado acima, uma de nossas tarefas era oferecer suporte a dispositivos móveis de baixa potência. A renderização spot acabou sendo o gargalo do nosso jogo. Cada ponto exige três chamadas de desenho (faça duas passagens + limpe o buffer de estêncil) e, como a linha de pintura contém dezenas ou centenas de pontos, o número de chamadas de desenho aumenta rapidamente e leva a uma queda na taxa de quadros. Para lidar com isso, aplicamos duas técnicas de otimização: primeiro, o desenho simultâneo de todas as manchas "secas" no dryBuffer e, em segundo lugar, a aceleração local da secagem das manchas após atingir um certo número de manchas ativas.

dryBufferÉ uma textura de renderização adicional adicionada ao ciclo de renderização. Como mencionado anteriormente, cada ponto tem uma vida útil (em quadros), que diminui a cada quadro. Depois que o tempo de vida atinge 0, a mancha é considerada "seca". Pontos secos não são mais simulados, sua forma não muda e, portanto, eles não precisam ser renderizados novamente em cada quadro.


DryBuffer em ação; os pontos cinzas mostram os pontos copiados para o dryBuffer.

Cada ponto cuja vida útil atinge 0 é removido da splatList e "copiado" para dryBuffer . Durante o processo de cópia, o ciclo de renderização é reutilizado e, desta vez, dryBuffer é definido como a textura de renderização de destino .

A mistura adequada entre wetBuffer e dryBuffer não pode ser alcançada simplesmente sobrepondo os buffers no shader de lona, ​​porque a textura de renderização do buffer wetBuffercontém pontos já renderizados com valor alfa (que é equivalente ao alfa pré-multiplicado). Contornamos esse problema adicionando uma etapa ao início do ciclo de renderização antes de percorrer os pontos iterativamente. Nesse ponto, renderizamos um quadrilátero do tamanho de uma pirâmide de corte de câmera que exibe dryBuffer . Graças a isso, qualquer mancha renderizada no wetBuffer já será misturada com manchas secas previamente pintadas.


Uma mistura de manchas molhadas e secas.

O tampão dryBuffer acumula todos os pontos "secos" e não é limpo entre os quadros. Portanto, toda a memória associada às manchas expiradas pode ser limpa após serem "copiadas" para o buffer.


Graças à otimização com dryBuffer , não temos mais limites na quantidade de tinta que um jogador pode aplicar na tela.

O uso da técnica dryBuffer separadamente permite que o jogador desenhe com uma quantidade quase infinita de tinta, mas não garante um desempenho consistente. Como mencionado acima, o traço da tinta tem uma espessura constante , o que é obtido através do desenho usando a interpolação de muitos pontos entre os pontos inicial e final do furto. No caso de muitos golpes rápidos e longos, o jogador pode gerar um grande número de pontos ativos. Esses pontos serão simulados e renderizados sobre o número de quadros especificado por sua vida útil, o que leva a taxas de quadros mais baixas.

Para garantir uma taxa de quadros estável, alteramos o algoritmo para que o número de pontos ativos fosse limitado por um valor constante de maxActiveSplats . Todos os pontos que excedem esse valor "secam" instantaneamente. Isso é realizado reduzindo o tempo de vida útil dos pontos ativos mais antigos para 0, e é por isso que eles são copiados para o buffer de pontos secos anteriormente. Como quando reduzimos a vida, obtemos um ponto no estado incompleto da simulação (que parecerá bastante interessante), ao mesmo tempo aumentamos a velocidade de espalhamento da tinta. Devido ao aumento da velocidade, o ponto atinge quase o mesmo tamanho da velocidade normal com uma vida útil padrão.


Demonstração de no máximo 40 pontos ativos (superior) e 80 (inferior). Os pontos secos copiados no dryBuffer são mostrados em cinza. O valor indica a "quantidade" de tinta que pode ser simulada ao mesmo tempo.

O valor de maxActiveSplats é o parâmetro de desempenho mais importante, pois permite controlar com precisão o número de chamadas de desenho que podemos alocar para a renderização em aquarela. Definimos isso na inicialização, com base na energia da plataforma e do dispositivo. Você também pode alterar esse valor durante a execução do aplicativo se for detectada uma diminuição na taxa de quadros.

Conclusão


A implementação desse algoritmo tornou-se uma tarefa interessante e desafiadora. Esperamos que os leitores tenham gostado do artigo. Você pode fazer perguntas nos comentários ao original . Se você quiser apreciar a nossa aquarela em ação, tente tocar matiz. no Apple Arcade .


Captura de tela de um jogo em execução na Apple TV

(1) S. DiVerdi, A. Krishnaswamy, R. MÄch e D. Ito, "Pintura com polígonos: um mecanismo de aquarela processual", em IEEE Transactions on Visualization and Computer Graphics, vol. 19, n. 5, pp. 723–735, maio de 2013. doi: 10.1109 / TVCG.2012.295

(2) A pressão é levada em consideração apenas ao desenhar o Apple Pencil em um iPad.

All Articles