Em setembro deste ano, o jogo para celular Titan World da Unstoppable, o escritório da Glu mobile em Minsk, deveria ser lançado. O projeto foi cancelado pouco antes do lançamento mundial. Mas as conquistas permaneceram, e a mais interessante delas, com a gentil permissão dos diretores do estúdio Dennis Zdonov e Alex Paley, eu gostaria de compartilhar com o público.Em março de 2018, o líder da equipe e eu realizamos uma reunião na qual discutimos o que fazer a seguir: o código de renderização foi concluído e não havia novos recursos e efeitos especiais nos planos. Parecia uma escolha lógica para reescrever o sistema de partículas do zero - de acordo com todos os testes, causou os maiores inconvenientes em produtividade, além de enlouquecer os designers com sua interface (arquivo de configuração de texto) e recursos extremamente escassos.
Deve-se notar que na maioria das vezes a equipe trabalhava no jogo no modo "lançamento amanhã", então escrevi todos os subsistemas, primeiro, tentando não quebrar o que já está funcionando, e segundo, com um curto ciclo de desenvolvimento. Em particular, a maioria dos efeitos que o sistema regular não era capaz foram realizados no sombreador de fragmentos sem afetar o código principal.
A restrição no número de partículas (matrizes de transformação para cada partícula foram formadas na CPU, a conclusão foi através do instalador do ios extensível à gl), por exemplo, foi necessário escrever um sombreador que "emulasse" uma grande variedade de partículas com base na representação analítica da forma dos objetos e composta com espaço reter dados falsos no buffer de profundidade.
A coordenada z do fragmento foi calculada para uma partícula plana, como se estivéssemos desenhando uma esfera, e o raio dessa esfera foi modulado pelo seno do ruído de Perlin, levando em consideração o tempo:
r=.5+.5*sin(perlin(specialUV)+time)
Uma descrição completa da reconstrução da profundidade da esfera pode ser encontrada em
Íñigo Quílez , mas usei um código mais rápido e simplificado. É claro que ele era uma aproximação aproximada, mas em formas geométricas complexas (fumaça, explosões) ele deu uma imagem bastante decente.
Captura de tela da jogabilidade. A "saia" de fumaça foi feita em uma pequena parte; várias outras foram deixadas no corpo principal da explosão. Obviamente, parecia mais espetacularmente “do chão”, quando a fumaça envolveu suavemente prédios e unidades, no entanto, propostas para mudar a posição da câmera durante a explosão não entraram em produção.Declaração do problema
O que você gostaria de sair? Preferimos as limitações com as quais fomos atormentados no sistema anterior de partículas. A situação foi agravada pelo fato de o orçamento do quadro estar quase esgotado, e em dispositivos fracos (como o ipad air), os transportadores de pixel e vértice estavam totalmente carregados. Portanto, eu queria obter o sistema mais produtivo como resultado, mesmo que limitasse um pouco a funcionalidade.
Os designers compilaram uma lista de recursos e desenharam um esboço da interface do usuário com base em suas próprias experiências e práticas com efeitos unitários, irreais e posteriores.
Tecnologia disponível
Devido ao legado e às restrições impostas pela matriz, estávamos limitados ao opengl es 2. Portanto, tecnologias como feedback de transformação usadas em sistemas modernos de partículas não estavam disponíveis.
O que restou? Usar busca de textura de vértice e armazenar posições / acelerações em texturas? Uma opção de trabalho, mas a memória também está quase acabando, o desempenho dessa solução não é o mais ideal e o resultado não é diferente na beleza arquitetônica.
Nessa época, eu já havia lido muitos artigos sobre a implementação de sistemas de partículas na gpu. A grande maioria continha um título brilhante (“milhões de partículas em gpu móvel, com preferência e poetas”), no entanto, a implementação se resumiu a exemplos de emissores / atrativos simples, embora divertidos, e em geral era quase inútil para uso real no jogo.
Este artigo trouxe o máximo benefício: o autor resolveu o problema real e não fez “partículas esféricas no vácuo”. Os números de referência deste artigo e os resultados de criação de perfil pouparam muito tempo na fase de design.
Procurar abordagens
Comecei classificando os problemas resolvidos pelo sistema de partículas e procurando casos particulares. Aconteceu aproximadamente o seguinte (uma parte das docas reais do conceito da correspondência com o líder da equipe):
“- Matrizes de partículas / malha com movimento cíclico. Nenhuma posição de processamento, através da equação do movimento. Aplicações - fumaça de canos, vapor sobre a água, neve / chuva, neblina volumétrica, árvores balançando, é possível o uso parcial de efeitos não cíclicos de explosões aka.
- fitas. Formação de vb por evento, processando apenas na GPU (disparos por raios, voos ao longo de uma trajetória fixa (?) Com um traço). Talvez a variante com a transferência das coordenadas de início e término para os uniformes e a construção da fita pelo vertexID decolem. com t.z. faça cruz com fresnel como em luzes diretas + uvscroll.
- Geração de partículas e processamento de velocidade. A opção mais versátil e mais difícil / mais lenta, consulte processamento de movimento técnico. ”
Em resumo: existem diferentes efeitos de partículas, e alguns deles podem ser implementados mais facilmente do que outros.
Decidimos dividir a tarefa em várias iterações - do simples ao complexo. A prototipagem foi feita no meu mecanismo / editor no windows / directx11 devido ao fato de a velocidade desse desenvolvimento ser várias ordens de magnitude mais altas. O projeto foi compilado em alguns segundos e os shaders foram completamente editados “on the fly” e compilados em segundo plano, exibindo o resultado em tempo real e sem a necessidade de gestos adicionais, como pressionar os botões. Qualquer um que tenha construído grandes projetos com um monte de macbook / xcode, eu acho, entenderá os motivos dessa decisão.
Todos os exemplos de código serão retirados do protótipo do Windows.
Ambiente de desenvolvimento para Windows.Implementação
O primeiro estágio é a saída estática de uma matriz de partículas. Nada complicado: inicie o buffer de vértice, preencha-o com quads (escreva o uv correto para cada quad) e costure a identificação do vértice no uv "adicional". Depois disso, no sombreador, pelo id do vértice com base nas configurações do emissor, formamos as posições das partículas e, por uv, restauramos as coordenadas da tela.
Se vertex_id estiver disponível nativamente, você poderá fazer completamente sem um buffer e sem uv para restaurar as coordenadas da tela (como resultado, isso foi feito na versão do Windows).
Shader:
struct VS_INPUT { … uint v_id:SV_VertexID; … } //float index = input.uv2.x/6.0;// vertex_id index = floor(input.v_id/6.0);// vertex_id float2 map[6]={0,0,1,0,1,1,0,0,1,1,0,1}; float2 quaduv=map[frac(input.v_id/6.0)*6];
Depois disso, você pode implementar cenários simples com uma quantidade muito pequena de código, por exemplo, movimentos cíclicos com pequenos desvios são adequados para o efeito de neve. No entanto, nosso objetivo era dar o controle do comportamento das partículas ao lado dos artistas, e eles, como você sabe, raramente sabem como shaders. A opção com predefinições de comportamento e parâmetros de edição através dos controles deslizantes também não atraiu - alternar shaders ou ramificações internas, multiplicar opções predefinidas, falta de controle total.
A próxima tarefa foi implementar o desvanecimento / desvanecimento para esse sistema. As partículas não devem aparecer do nada e desaparecer no nada. Na implementação clássica de um sistema de partículas, processamos o buffer programaticamente usando a CPU, criando novas partículas e removendo as antigas. De fato, para obter um bom desempenho, você precisa escrever um gerenciador de memória inteligente. Mas o que acontece se você simplesmente não extrair as partículas "mortas"?
Suponha (para iniciantes) que o intervalo de tempo da emissão de partículas e a vida útil de uma partícula seja uma constante em um único emissor.

Em seguida, podemos apresentar especulativamente nosso buffer (que contém apenas o ID do vértice) como circular e determinar seu tamanho máximo da seguinte maneira:
pCount = round (prtPerSec * LifeTime / 60.0); pCountT = floor (prtPerSec * EmissionEndTime / 60.0); pCount=min (pCount, pCountT);
e no sombreador, calcule o tempo com base no índice e no tempo (tempo decorrido desde o início do efeito)
pTime=time-index/prtPerSec;
Se o emissor estiver em uma fase cíclica (todas as partículas são emitidas e agora morrem e nascem de forma síncrona), diminuímos o tempo da partícula e assim obtemos um loop.
Não precisamos desenhar partículas com pTime menor que zero - elas ainda não nasceram. O mesmo se aplica às partículas nas quais a soma do tempo de vida útil e do tempo atual excede o tempo de fim de emissão. Nos dois casos, não desenharemos nada anulando o tamanho da partícula e / ou largando-o atrás da tela. Essa abordagem fornecerá uma pequena sobrecarga nas fases de desvanecimento / desvanecimento, mantendo o desempenho máximo na fase de sustentação.
O algoritmo pode ser ligeiramente aprimorado enviando apenas a parte do buffer de vértice que contém partículas vivas para renderização. Devido ao fato de a emissão ocorrer sequencialmente, as partículas vivas serão segmentadas no máximo uma vez, isto é, são necessárias duas chamadas.
Agora, sabendo o tempo atual de cada partícula, você pode definir a velocidade, a aceleração (e, em geral, quaisquer outros parâmetros) para escrever a equação do movimento, resultando nas coordenadas no espaço do mundo.
Usando restaurado de vertex_id uv, já obteremos quatro pontos (mais precisamente, moveremos cada um dos pontos quádruplos na direção que precisamos), nos quais o sombreador de vértices, após concluir a projeção, concluirá seu trabalho.
p.xy+=(quaduv-.5);
Com o bônus grátis, tivemos a oportunidade não apenas de pausar o emissor, mas também de retroceder o tempo com precisão no quadro. Esse recurso acabou sendo muito útil no design de efeitos complexos.
Aumentamos a funcionalidade
A próxima iteração no desenvolvimento foi a solução para o problema de um emissor em movimento. Nosso sistema em particular não sabia nada sobre sua posição e, quando o emissor se moveu, todo o efeito se moveu sincronicamente atrás dele. Para a fumaça do tubo de escape e efeitos similares, parecia mais do que estranho.
A idéia era registrar a posição do emissor em um buffer de vértice quando uma nova partícula nasceu. Como o número dessas partículas é pequeno, a sobrecarga deveria ter sido mínima.
Um colega sugeriu que, ao desenvolver sua própria interface do usuário, ele usasse map / unmap apenas parte do buffer de vértice e ficou bastante satisfeito com o desempenho dessa solução. Fiz testes e verificamos que essa abordagem realmente funciona bem nas plataformas de desktop e móvel.
A dificuldade surgiu com a sincronização do tempo na CPU e na GPU. Era necessário garantir que a atualização do buffer fosse feita exatamente quando a partícula em loop “nova” estava em sua posição inicial. Ou seja, em relação ao buffer de anel, é necessário sincronizar os limites da região de atualização com o tempo de operação do emissor.
Transferi o código hlsl para C ++. Para o teste, escrevi o emissor movendo-se por Lissajous, e tudo isso de repente funcionou. No entanto, de tempos em tempos, o sistema "cuspia" em uma ou mais partículas, disparando-as em uma direção arbitrária, não as removendo a tempo, ou criando novas em locais arbitrários.
O problema foi resolvido auditando a precisão do cálculo do tempo no mecanismo e verificando simultaneamente o delta do tempo ao registrar a nova posição do emissor - para que toda a seção do buffer que não foi afetada pela iteração anterior fosse atualizada. Também era necessário que o sistema funcionasse nas condições de uma dessincronização forçada - um repentino rebaixamento de fps não deve interromper o efeito, principalmente porque, para dispositivos diferentes, nosso jogo registrava fps diferentes de acordo com o desempenho - 60/30/20.
O código do método cresceu bastante (é difícil processar o buffer do anel com elegância), no entanto, depois de levar em consideração todas as condições, o sistema funcionou corretamente e de forma estável.
Por essa época, o parceiro já havia feito o “peixe” do editor, suficiente para testar o sistema, e escreveu os modelos / api para integrar o sistema ao nosso mecanismo.
Portamos todo o código para o ios / opengl, integrei e finalmente fiz testes de efeitos reais em um dispositivo real. Ficou claro que o sistema não apenas funciona, mas também é adequado para produção. Faltava terminar o editor da interface do usuário e polir o código para o estado "não é assustador dar o lançamento amanhã".
Nós já nos preparamos para escrever um gerenciador de memória para não alocar / destruir um buffer (que finalmente armazenava vertex_id, uv, posição e vetor de partícula inicial) para cada novo efeito com um emissor dinâmico, quando outra idéia veio à minha cabeça.
O fato da existência do buffer de vértices nesse sistema me assombrava. Ele claramente olhou em seu arcaísmo "o legado da idade das trevas do transportador fixo". Ao fazer efeitos de teste em um protótipo do Windows, pensei que o movimento do emissor é sempre suave e sempre muito mais lento que o movimento da partícula. Além disso, com um grande número de partículas, a atualização da posição leva ao fato de que centenas de partículas registram os mesmos dados. A solução acabou sendo simples: introduzimos uma matriz fixa na qual o "histórico" da posição do emissor, normalizado pelo tempo de vida da partícula, cairá. E no gpu vamos interpolar os dados. Depois disso, a necessidade de buffers dinâmicos desapareceu na versão ios / gles2 (apenas a estática geral permaneceu para a implementação de vertex_id), e nas versões windows / dx11 os buffers desapareceram por completo devido ao vertex_id nativo e à capacidade do d3d api de aceitar nulos em vez de vincular ao buffer de vértice.
Assim, a versão vantajosa do sistema, pelos padrões modernos, não consome memória, não importando quantas partículas queremos exibir. Apenas um pequeno buffer constante com parâmetros, um buffer de posições / bases (60 pares de vetores eram suficientes, com margem, para qualquer caso) e, se necessário, textura. As medições de desempenho mostram uma velocidade próxima aos testes sintéticos.
Além disso, a “cauda” em efeitos como faíscas começou a parecer muito mais natural, pois a interpolação tornou possível remover a amostragem por quadros e, assim, o emissor mudou sua posição sem problemas, como se as chamadas de desenho fossem realizadas a uma frequência de centenas de hertz.
Funcionalidades
Além da funcionalidade básica do voo da partícula (velocidade, aceleração, gravidade, resistência do meio), precisávamos de uma certa quantidade de "gordura" funcional.
Como resultado, o desfoque de movimento (partícula se estendendo ao longo de um vetor de movimento), a orientação da partícula através do vetor de movimento (isso permite, por exemplo, formar uma esfera de partículas), o tamanho da partícula muda de acordo com o tempo atual de sua vida e dezenas de outras pequenas coisas foram implementadas.
A complexidade surgiu com os campos vetoriais: como o sistema não armazena seu estado (posição, aceleração etc.) para cada partícula, mas calcula-os cada vez através da equação do movimento, vários efeitos (como o movimento da espuma ao mexer o café) eram impossíveis em princípio. No entanto, uma simples modulação da velocidade e aceleração pelo ruído do perlin deu resultados que parecem bastante modernos. O cálculo do ruído em tempo real para tantas partículas acabou por ser muito caro (mesmo com um limite de cinco oitavas), de modo que foi gerada uma textura a partir da qual o shader de vértice seria amostrado. Para aprimorar o efeito de um campo vetorial falso, uma pequena mudança nas coordenadas da amostra foi adicionada, dependendo do horário atual do emissor.
O teste de fumaça de cigarro funciona distribuindo a velocidade e a aceleração iniciais sobre o ruído perlin.Transportador de pixel
Inicialmente, planejamos apenas alterar a cor / transparência da partícula, dependendo do tempo. Adicionei vários algoritmos ao pixel shader.
Rotação da cor da textura - simplificada, pecado (cor + tempo). Permite, em certa medida, imitar o efeito de permutação de AfterEffects.
Iluminação falsa - modulação da cor de uma partícula por um gradiente nas coordenadas do mundo, independentemente do ângulo de rotação da partícula.
Evolução das fronteiras - quando uma partícula se move no espaço, seus limites (canal alfa) são modulados por uma combinação de holofotes e ruído perlin, o que fornece uma dinâmica de fluxo muito semelhante a nuvens, fumaça e outros efeitos de fluidos.
Código Pseudo Shader:
b=perlin(uv)
Em uma versão um pouco complicada, esse shader pode traçar bordas com suavidade arbitrária e com um destaque de contorno, o que acrescentou efeitos "explosivos" ao realismo.
Os primeiros experimentos com a evolução dos limites.O que vem a seguir?
Apesar do editor, já pronto para trabalhar e integrado ao mecanismo, os designers não tiveram tempo de produzir um único efeito - o projeto foi encerrado. No entanto, não há obstáculos para usar essas práticas em outros lugares - por exemplo, para trabalhar na revisão de demonstração.
Do ponto de vista tecnológico, também há espaço para se mover - agora, por exemplo, vários efeitos da destruição de objetos de arame estão em operação:

A questão de classificar partículas para a mistura alfa permanece em aberto até agora: como tudo é considerado analiticamente no shader, na verdade não há dados de entrada para a classificação. Mas há um grande campo para experimentação!
Durante o desenvolvimento do Titan World, muitos truques foram aplicados na parte gráfica do jogo, mas mais sobre isso na próxima vez.
PS Você pode cavar no mecanismo alfa de origem
aqui . Os exemplos estão na pasta release / samples, as principais teclas de controle são space, alt | control + mouse. Os shaders estão diretamente nos arquivos fxp, seu código está disponível na janela do editor.