Crie um sombreador de água de desenho animado para a web. Parte 1

No meu tutorial “Criando Shaders”, observei principalmente os shaders de fragmento, que são suficientes para implementar quaisquer efeitos e exemplos 2D no ShaderToy . Mas há toda uma categoria de técnicas que requerem o uso de shaders de vértice. Neste tutorial, falarei sobre a criação de um shader de água estilizado para desenhos animados e apresentarei os shaders de vértice. Também falarei sobre o buffer de profundidade e como usá-lo para obter mais informações sobre a cena e criar linhas de espuma do mar.

Aqui está como será o efeito final. Uma demonstração interativa pode ser vista aqui .


Este efeito consiste nos seguintes elementos:

  1. Uma malha translúcida de água com polígonos subdivididos e vértices deslocados para criar ondas.
  2. Linhas de água estáticas na superfície.
  3. Flutuabilidade simulada do barco.
  4. Linhas dinâmicas de espuma em torno dos limites dos objetos na água.
  5. Pós-processamento para criar distorção de tudo debaixo d'água.

Nesse sentido, gosto do fato de que ele aborda muitos conceitos diferentes de computação gráfica, por isso nos permitirá usar as idéias dos tutoriais anteriores, bem como desenvolver técnicas que possam ser aplicadas em novos efeitos.

Neste tutorial, usarei o PlayCanvas , simplesmente porque é um IDE da web gratuito e conveniente, mas tudo pode ser aplicado a qualquer outro ambiente WebGL sem problemas. No final do artigo, a versão do código fonte do Three.js será apresentada. Assumiremos que você já conhece bem os shaders de fragmentos e a interface PlayCanvas. Você pode atualizar seu conhecimento sobre shaders aqui e se familiarizar com o PlayCanvas aqui .

Configuração do ambiente


O objetivo desta seção é configurar nosso projeto PlayCanvas e inserir nele vários objetos ambientais que a água influenciará.

Se você não possui uma conta PlayCanvas, registre-a e crie um novo projeto em branco . Por padrão, você deve ter alguns objetos na cena, uma câmera e uma fonte de luz.


Inserir modelos


Um ótimo recurso para encontrar modelos 3D para a web é o projeto Google Poly . Peguei o modelo de barco de lá. Depois de baixar e descompactar o arquivo, você encontrará arquivos .obj e .png nele.

  1. Arraste os dois arquivos para a janela Assets do projeto PlayCanvas.
  2. Selecione o material gerado automaticamente e selecione o arquivo .png como seu mapa difuso.


Agora você pode arrastar Tugboat.json para a cena e excluir os objetos Box e Plane. Se o barco parecer muito pequeno, você poderá aumentar sua escala (defino o valor para 50).


Da mesma forma, você pode adicionar outros modelos à cena.

Câmera em órbita


Para configurar a câmera voando em órbita, copiaremos o script deste exemplo do PlayCanvas . Siga o link e clique no Editor para abrir o projeto.

  1. Copie o conteúdo de mouse-input.js e orbit-camera.js deste projeto tutorial em arquivos com os mesmos nomes do seu projeto.
  2. Adicione um componente Script à câmera.
  3. Anexe dois scripts à câmera.

Dica: para organizar o projeto, você pode criar pastas na janela Ativos. Coloquei esses dois scripts de câmera na pasta Scripts / Camera /, meu modelo em Models / e o material na pasta Materials /.

Agora, quando você inicia o jogo (o botão de lançamento na parte superior direita da janela da cena), você deve ver um barco que você pode inspecionar com uma câmera, movendo-o em órbita com o mouse.

Divisão de polígonos de superfície da água


O objetivo desta seção é criar uma malha subdividida que será usada como superfície da água.

Para criar uma superfície da água, adaptamos parte do código do tutorial de geração de relevo . Crie um novo Water.js script Water.js . Abra este script para edição e crie uma nova função GeneratePlaneMesh que será parecida com esta:

 Water.prototype.GeneratePlaneMesh = function(options){ // 1 -    ,     if(options === undefined) options = {subdivisions:100, width:10, height:10}; // 2 -  , UV   var positions = []; var uvs = []; var indices = []; var row, col; var normals; for (row = 0; row <= options.subdivisions; row++) { for (col = 0; col <= options.subdivisions; col++) { var position = new pc.Vec3((col * options.width) / options.subdivisions - (options.width / 2.0), 0, ((options.subdivisions - row) * options.height) / options.subdivisions - (options.height / 2.0)); positions.push(position.x, position.y, position.z); uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions); } } for (row = 0; row < options.subdivisions; row++) { for (col = 0; col < options.subdivisions; col++) { indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + row * (options.subdivisions + 1)); indices.push(col + 1 + (row + 1) * (options.subdivisions + 1)); indices.push(col + (row + 1) * (options.subdivisions + 1)); } } //   normals = pc.calculateNormals(positions, indices); //    var node = new pc.GraphNode(); var material = new pc.StandardMaterial(); //   var mesh = pc.createMesh(this.app.graphicsDevice, positions, { normals: normals, uvs: uvs, indices: indices }); var meshInstance = new pc.MeshInstance(node, mesh, material); //      var model = new pc.Model(); model.graph = node; model.meshInstances.push(meshInstance); this.entity.addComponent('model'); this.entity.model.model = model; this.entity.model.castShadows = false; //   ,       }; 

Agora podemos chamá-lo na função de initialize :

 Water.prototype.initialize = function() { this.GeneratePlaneMesh({subdivisions:100, width:10, height:10}); }; 

Agora, quando você iniciar o jogo, verá apenas uma superfície plana. Mas isso não é apenas uma superfície plana, é uma malha composta por milhares de picos. Como exercício, tente verificar isso sozinho (esse é um bom motivo para estudar o código que acabou de copiar).

Problema 1: altere a coordenada Y de cada vértice por um valor aleatório para que o plano se pareça com a figura abaixo.


As ondas


O objetivo desta seção é designar a superfície da água do seu próprio material e criar ondas animadas.

Para obter os efeitos de que precisamos, você precisa configurar seu próprio material. A maioria dos mecanismos 3D possui um conjunto de shaders predefinidos para renderizar objetos e uma maneira de redefini-los. Aqui está um bom link sobre como fazer isso no PlayCanvas.

Acessório Shader


Vamos criar uma nova função CreateWaterMaterial que CreateWaterMaterial novo material com um shader alterado e o retorna:

 Water.prototype.CreateWaterMaterial = function(){ //     var material = new pc.Material(); //    ,       material.name = "DynamicWater_Material"; //    //        . var gd = this.app.graphicsDevice; var fragmentShader = "precision " + gd.precision + " float;\n"; fragmentShader = fragmentShader + this.fs.resource; var vertexShader = this.vs.resource; //       . var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; //     this.shader = new pc.Shader(gd, shaderDefinition); //      material.setShader(this.shader); return material; }; 

Essa função pega o código de sombreamento de vértice e fragmento dos atributos de script Então, vamos defini-los na parte superior do arquivo (após a linha pc.createScript ):

 Water.attributes.add('vs', { type: 'asset', assetType: 'shader', title: 'Vertex Shader' }); Water.attributes.add('fs', { type: 'asset', assetType: 'shader', title: 'Fragment Shader' }); 

Agora podemos criar esses arquivos de sombreador e anexá-lo ao nosso script. Retorne ao editor e crie dois arquivos de sombreador: Water.frag e Water.vert . Anexe esses sombreadores ao script, como mostra a figura abaixo.


Se os novos atributos não forem exibidos no editor, clique no botão Analisar para atualizar o script.

Agora cole esse shader básico no Water.frag :

 void main(void) { vec4 color = vec4(0.0,0.0,1.0,0.5); gl_FragColor = color; } 

E este está em Water.vert :

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); } 

Por fim, retorne ao Water.js para usar nosso novo material em vez do material padrão. Ou seja, em vez de:

 var material = new pc.StandardMaterial(); 

inserir:

 var material = this.CreateWaterMaterial(); 

Agora, depois de iniciar o jogo, o avião deve estar azul.


Reinicialização a quente


Por enquanto, apenas configuramos espaços em branco para o nosso novo material. Antes de começar a escrever efeitos reais, desejo configurar o recarregamento automático de código.

Depois de descomentar a função de swap em qualquer arquivo de script (por exemplo, em Water.js), ativaremos o recarregamento a quente. Mais tarde, veremos como usar isso para manter o estado, mesmo ao atualizar o código em tempo real. Mas, por enquanto, queremos apenas reaplicar os shaders depois de fazer as alterações. Antes de executar no WebGL, os shaders são compilados; portanto, para isso, precisamos recriar nosso material.

Verificaremos se o conteúdo do nosso código de sombreador mudou e, se houver, criaremos o material novamente. Primeiro, salve os shaders atuais na inicialização :

 //  initialize,       Water.prototype.initialize = function() { this.GeneratePlaneMesh(); //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; }; 

E, na atualização , verificamos se alguma alteração ocorreu:

 //  update,     Water.prototype.update = function(dt) { if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){ //   ,      var newMaterial = this.CreateWaterMaterial(); //     var model = this.entity.model.model; model.meshInstances[0].material = newMaterial; //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; } }; 

Agora, para garantir que isso funcione, inicie o jogo e altere a cor do avião no Water.frag para um azul mais agradável. Após salvar o arquivo, ele deve ser atualizado mesmo sem uma reinicialização e reinicie! Aqui está a cor que eu escolhi:

 vec4 color = vec4(0.0,0.7,1.0,0.5); 

Vertex Shaders


Para criar ondas, devemos mover cada vértice da nossa malha em cada quadro. Parece que será muito ineficiente, mas cada vértice de cada modelo já está transformado em cada quadro renderizado. É isso que o shader de vértice faz.

Se percebermos um shader de fragmento como uma função executada para cada pixel, obtém sua posição e retorna a cor, um shader de vértice é uma função que roda para cada pixel, obtém sua posição e retorna sua posição .

Um sombreador de vértice, por padrão, obtém uma posição no mundo do modelo e retorna sua posição na tela . Nossa cena 3D é definida nas coordenadas x, ye z, mas como o monitor é um plano bidimensional plano, projetamos um mundo 3D em uma tela 2D. Matrizes do tipo, projeção e modelo estão envolvidas nessa projeção; portanto, não vamos considerá-lo neste tutorial. Mas se você quiser entender o que exatamente acontece em cada estágio, aqui está um guia muito bom .

Ou seja, esta linha:

 gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0); 

recebe aPosition como uma posição no mundo 3D de um vértice específico e o converte em gl_Position , ou seja, na posição final na tela 2D. O prefixo "a" em aPosition indica que esse valor é um atributo . Não esqueça que a variável uniforme é um valor que podemos definir na CPU e passá-lo ao shader. Mantém o mesmo valor para todos os pixels / vértices. Por outro lado, o valor do atributo é obtido da matriz da CPU especificada. Um sombreador de vértice é chamado para cada valor dessa matriz de atributos.

Você pode ver que esses atributos estão configurados na definição de sombreador que definimos no Water.js:

 var shaderDefinition = { attributes: { aPosition: pc.gfx.SEMANTIC_POSITION, aUv0: pc.SEMANTIC_TEXCOORD0, }, vshader: vertexShader, fshader: fragmentShader }; 

O PlayCanvas se encarrega de configurar e transmitir uma matriz de posições de vértices para aPosition ao passar essa enumeração, mas, no caso geral, podemos passar qualquer matriz de dados para o sombreador de vértices.

Movimento de vértice


Suponha que queremos compactar o plano inteiro multiplicando todos os valores de x por 0,5. Precisamos alterar aPosition ou gl_Position ?

Vamos tentar uma aPosition primeiro. Não podemos alterar o atributo diretamente, mas podemos criar uma cópia:

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); } 

Agora o avião deve se parecer mais com um retângulo. E não há nada de estranho nisso. Mas o que acontece se tentarmos mudar a gl_Position ?

 attribute vec3 aPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; void main(void) { vec3 pos = aPosition; //pos.x *= 0.5; gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); gl_Position.x *= 0.5; } 

Até você começar a mover a câmera, ela pode ter a mesma aparência. Alteramos as coordenadas do espaço da tela, ou seja, a imagem depende de como a vemos .

Assim, podemos mover os vértices e, ao mesmo tempo, é importante distinguir entre trabalho nos espaços do mundo e da tela.

Tarefa 2: você pode mover toda a superfície do plano várias unidades para cima (ao longo do eixo Y) no sombreador de vértices sem distorcer sua forma?

Tarefa 3: Eu disse que gl_Position é bidimensional, mas gl_Position.z também existe. Você pode verificar se esse valor afeta algo e, em caso afirmativo, para que é usado?

Adicionando tempo


A última coisa que precisamos antes de começar a criar ondas em movimento é uma variável uniforme que pode ser usada com o tempo. Declare uniforme no shader de vértice:

 uniform float uTime; 

Agora, para passá-lo ao shader, vamos voltar ao Water.js e definir a variável time na inicialização:

 Water.prototype.initialize = function() { this.time = 0; /////     this.GeneratePlaneMesh(); //    this.savedVS = this.vs.resource; this.savedFS = this.fs.resource; }; 

Agora, para transferir a variável para o shader, usamos material.setParameter . Primeiro, configuramos o valor inicial no final da função CreateWaterMaterial :

 //     this.shader = new pc.Shader(gd, shaderDefinition); //////////////   material.setParameter('uTime',this.time); this.material = material; //      //////////////// //      material.setShader(this.shader); return material; 

Agora, na função de update , podemos executar um incremento de tempo e acessar o material usando o link criado para isso:

 this.time += 0.1; this.material.setParameter('uTime',this.time); 

Finalmente, na função de troca, copiamos o valor do horário antigo para que, mesmo após alterar o código, ele continue aumentando sem redefinir para 0.

 Water.prototype.swap = function(old) { this.time = old.time; }; 

Agora está tudo pronto. Execute o jogo para garantir que não haja erros. Agora vamos mover nosso avião usando a função de tempo em Water.vert :

 pos.y += cos(uTime) 

E o nosso avião deve começar a subir e descer! Como agora temos uma função de troca, também podemos atualizar o Water.js sem precisar reiniciar. Para garantir que isso funcione, tente alterar o incremento de tempo.


Tarefa 4: você pode mover os vértices para que se pareçam com as ondas na figura abaixo?


Deixe-me dizer-lhe que examinei em detalhes o tópico de várias maneiras de criar ondas aqui . O artigo está relacionado ao 2D, mas os cálculos matemáticos são aplicáveis ​​ao nosso caso. Se você só quer ver a solução, aqui está a essência .

Translucência


O objetivo desta seção é criar uma superfície translúcida da água.

Você pode perceber que a cor retornada ao Water.frag tem um valor de canal alfa de 0,5, mas a superfície ainda permanece opaca. Em muitos casos, a transparência ainda se torna um problema não resolvido na computação gráfica. Uma maneira de baixo custo para resolvê-lo é usar a mistura.

Geralmente, antes de desenhar um pixel, ele verifica o valor no buffer de profundidade e o compara com seu próprio valor de profundidade (sua posição no eixo Z) para determinar se deve ou não redesenhar o pixel atual da tela. É isso que permite renderizar a cena corretamente sem precisar classificar os objetos de volta para a frente.

Ao misturar, em vez de simplesmente rejeitar o pixel ou substituir, podemos combinar a cor do pixel já renderizado (destino) com o pixel que vamos desenhar (a fonte). Uma lista de todas as funções de mixagem disponíveis no WebGL pode ser encontrada aqui .

Para que o canal alfa funcione de acordo com nossas expectativas, queremos que a cor combinada do resultado seja uma fonte multiplicada por um canal alfa mais um pixel de destino multiplicado por um menos alfa. Em outras palavras, se alfa = 0,4, a cor final deve ter um valor:

 finalColor = source * 0.4 + destination * 0.6; 

No PlayCanvas, esta é a operação que o pc.BLEND_NORMAL executa .

Para habilitá-lo, basta definir a propriedade do material dentro de CreateWaterMaterial :

 material.blendType = pc.BLEND_NORMAL; 

Se você iniciar o jogo agora, a água ficará translúcida! No entanto, ainda é imperfeito. O problema surge quando a superfície translúcida é sobreposta a si mesma, como mostrado abaixo.


Podemos eliminá-lo usando alfa para cobertura , uma técnica de multisampling para transparência, em vez de mesclar:

 //material.blendType = pc.BLEND_NORMAL; material.alphaToCoverage = true; 

Mas está disponível apenas no WebGL 2. No restante do tutorial, por uma questão de simplicidade, usarei a mistura.

Resumir


Montamos o ambiente e criamos uma superfície translúcida da água com ondas animadas do vertex shader. Na segunda parte do tutorial, consideraremos a flutuabilidade dos objetos, adicionaremos linhas à superfície da água e criaremos linhas de espuma ao longo dos limites dos objetos que se cruzam com a superfície.

Na terceira (última) parte, consideraremos a aplicação do efeito pós-processamento de distorções subaquáticas e consideraremos idéias para melhorias adicionais.

Código fonte


O projeto concluído do PlayCanvas pode ser encontrado aqui . Nosso repositório também possui uma porta de projeto em Three.js .

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


All Articles