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

Na primeira parte, vimos como configurar o ambiente e a superfície da água. Nesta parte, daremos flutuabilidade aos objetos, adicionaremos linhas de água na superfície e criaremos linhas de espuma com um buffer de profundidade em torno dos limites dos objetos que se cruzam com a superfície.

Para fazer a cena parecer um pouco melhor, fiz pequenas alterações. Você pode personalizar sua cena da maneira que quiser, mas eu fiz o seguinte:

  • Modelos adicionados de um farol e um polvo.
  • Adicionado um modelo de solo com a cor #FFA457 .
  • Adicionada uma cor do céu de #6CC8FF .
  • Adicionada cor de luz de fundo #FFC480 à cena (esses parâmetros podem ser encontrados nas configurações de cena).

Minha cena original agora se parece com isso.


Flutuabilidade


A maneira mais fácil de criar flutuabilidade é usar um script para empurrar objetos para cima e para baixo. Crie um novo script Buoyancy.js e defina o seguinte em sua inicialização:

 Buoyancy.prototype.initialize = function() { this.initialPosition = this.entity.getPosition().clone(); this.initialRotation = this.entity.getEulerAngles().clone(); //     ,  //        //     this.time = Math.random() * 2 * Math.PI; }; 

Agora, na atualização, executamos o incremento de tempo e giramos o objeto:

 Buoyancy.prototype.update = function(dt) { this.time += 0.1; //      var pos = this.entity.getPosition().clone(); pos.y = this.initialPosition.y + Math.cos(this.time) * 0.07; this.entity.setPosition(pos.x,pos.y,pos.z); //    var rot = this.entity.getEulerAngles().clone(); rot.x = this.initialRotation.x + Math.cos(this.time * 0.25) * 1; rot.z = this.initialRotation.z + Math.sin(this.time * 0.5) * 2; this.entity.setLocalEulerAngles(rot.x,rot.y,rot.z); }; 

Aplique esse script ao barco e veja como ele pula para cima e para baixo na água! Você pode aplicar esse script a vários objetos (incluindo a câmera - experimente)!

Textura da superfície


Enquanto podemos ver as ondas, olhamos para as bordas da superfície da água. A adição de textura tornará o movimento da superfície mais perceptível. Além disso, é uma maneira de baixo custo para simular reflexões e cáusticos.

Você pode tentar encontrar algumas texturas cáusticas ou criar uma você mesmo. Eu desenhei uma textura no Gimp que você pode usar livremente. Qualquer textura é adequada, desde que possa ser ladrilhada sem juntas perceptíveis.

Depois de escolher a textura desejada, arraste-a para a janela Ativos do seu projeto. Precisamos fazer referência a essa textura a partir do script Water.js, então vamos criar um atributo para ela:

 Water.attributes.add('surfaceTexture', { type: 'asset', assetType: 'texture', title: 'Surface Texture' }); 

E, em seguida, atribua-o no editor:


Agora precisamos passar para o shader. Acesse Water.js e defina a função CreateWaterMaterial novo parâmetro:

 material.setParameter('uSurfaceTexture',this.surfaceTexture.resource); 

Agora volte para Water.frag e declare um novo uniforme:

 uniform sampler2D uSurfaceTexture; 

Estamos quase terminando. Para renderizar uma textura em um plano, precisamos saber onde cada pixel está na malha. Ou seja, precisamos transferir dados do sombreador de vértice para o fragmento.

Variáveis ​​Variáveis


Variáveis variáveis ​​permitem transferir dados do sombreador de vértice para o fragmento. Este é o terceiro tipo de variáveis ​​especiais que podem ser usadas no shader (as duas primeiras são uniformes e atributo ). Uma variável é definida para cada vértice e cada pixel pode acessá-lo. Como há muito mais pixels do que vértices, o valor é interpolado entre os vértices (daí o nome “variando” - ele se desvia dos valores passados ​​para ele).

Para testá-lo em operação, declare uma nova variável em Water.vert como variável:

 varying vec2 ScreenPosition; 

E, em seguida, atribua o valor gl_Position após o cálculo:

 ScreenPosition = gl_Position.xyz; 

Agora, volte para Water.frag e declare a mesma variável. Não podemos obter a saída de dados de depuração do shader, mas podemos usar cores para depuração visual. Veja como fazê-lo:

 uniform sampler2D uSurfaceTexture; varying vec3 ScreenPosition; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); //    varying- color = vec4(vec3(ScreenPosition.x),1.0); gl_FragColor = color; } 

O plano agora deve parecer preto e branco, e a linha de divisão de cores irá para ScreenPosition.x = 0. Os valores das cores mudam apenas de 0 para 1, mas os valores no ScreenPosition podem estar fora desse intervalo. Eles são automaticamente limitados; portanto, quando você vê preto, pode ser 0 ou um número negativo.

O que acabamos de fazer é passar a posição da tela de cada vértice para cada pixel. Você pode ver que a linha que separa os lados preto e branco sempre passará no centro da tela, independentemente de onde a superfície realmente esteja no mundo.

Tarefa 1: crie uma nova variável variável para transferir a posição no mundo em vez da posição na tela. Visualize-o da mesma maneira. Se a cor não mudar com o movimento da câmera, tudo será feito corretamente.

Usando UV


UV são as coordenadas 2D de cada vértice na malha, normalizadas de 0 a 1. Elas são necessárias para a amostragem correta da textura no plano e já as configuramos na parte anterior.

Declararemos um novo atributo em Water.vert (esse nome é obtido da definição de sombreador em Water.js):

 attribute vec2 aUv0; 

E agora só precisamos passá-lo para o shader de fragmento, então basta criar variações e atribuir o valor do atributo a ele:

 //  Water.vert //        varying vec2 vUv0; // .. //        //  varying,        vUv0 = aUv0; 

Agora declararemos a mesma variável variável no shader de fragmento. Para garantir que tudo funcione, podemos visualizar a depuração como antes, e então o Water.frag ficará assim:

 uniform sampler2D uSurfaceTexture; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); //  UV color = vec4(vec3(vUv0.x),1.0); gl_FragColor = color; } 

Você deve ver um gradiente confirmando que temos um valor de 0 em uma extremidade e 1 na outra. Agora, para provar a textura de verdade, tudo o que precisamos fazer é:

 color = texture2D(uSurfaceTexture,vUv0); 

Depois disso, veremos a textura na superfície:


Estilo de textura


Em vez de apenas definir a textura como a nova cor, vamos combiná-la com o azul existente:

 uniform sampler2D uSurfaceTexture; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec4 WaterLines = texture2D(uSurfaceTexture,vUv0); color.rgba += WaterLines.r; gl_FragColor = color; } 

Isso funciona porque a cor da textura é preta (0) em todos os lugares, exceto nas linhas de água. Adicionando-o, não alteramos a cor azul inicial, com exceção dos locais com linhas onde ela fica mais clara.

No entanto, essa não é a única maneira de combinar cores.

Tarefa 2: Você pode combinar as cores para obter o efeito mais fraco mostrado abaixo?


Textura em movimento


Como efeito final, queremos que as linhas se movam ao longo da superfície e ela não pareça tão estática. Para fazer isso, tiraremos vantagem do fato de que qualquer valor fora do intervalo de 0 a 1 passado para a função texture2D será transferido (por exemplo, 1,5 e 2,5 se tornam iguais a 0,5). Portanto, podemos aumentar nossa posição pela variável de tempo uniforme que já definimos para aumentar ou diminuir a densidade de linhas na superfície, o que dará ao fragmento final shader esta forma:

 uniform sampler2D uSurfaceTexture; uniform float uTime; varying vec2 vUv0; void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec2 pos = vUv0; //      1 //     pos *= 2.0; //   ,      pos.y += uTime * 0.02; vec4 WaterLines = texture2D(uSurfaceTexture,pos); color.rgba += WaterLines.r; gl_FragColor = color; } 

Linhas de espuma e tampão de profundidade


A renderização de linhas de espuma em torno de objetos na água facilita a visualização da imersão dos objetos e de onde eles cruzam a superfície. Além disso, dessa maneira nossa água se torna muito mais crível. Para perceber as linhas de espuma, precisamos descobrir de alguma forma onde estão os limites de cada objeto e fazê-lo efetivamente.

Truque


Precisamos aprender a determinar se um pixel na superfície da água está próximo ao objeto. Nesse caso, podemos pintá-lo na cor da espuma. Não há maneiras simples de resolver esse problema (tanto quanto eu sei). Portanto, para resolvê-lo, uso uma técnica útil para resolver problemas: darei um exemplo para o qual sabemos a resposta e veremos se podemos generalizá-la.

Dê uma olhada na imagem abaixo.


Quais pixels devem fazer parte da espuma? Sabemos que deve ser algo como isto:


Então, vamos olhar para dois pixels específicos. Abaixo os marquei com asteriscos. O preto estará na espuma e o vermelho não. Como os distinguimos em um sombreador?


Sabemos que, embora esses dois pixels no espaço da tela estejam próximos um do outro (ambos renderizados no topo do farol), na verdade eles estão muito distantes no espaço do mundo. Podemos verificar isso olhando a mesma cena de um ângulo diferente.


Note que a estrela vermelha não está localizada no farol, como nos pareceu, mas a estrela preta está realmente lá. Podemos distinguir do uso da distância da câmera, que geralmente é chamada de "profundidade". A profundidade 1 significa que o ponto está muito próximo da câmera, a profundidade 0 significa que está muito longe. Mas isso não é apenas uma questão de distâncias absolutas no mundo, profundidade ou câmera. A profundidade relativa ao pixel atrás dele é importante.

Olhe novamente para a primeira visualização. Digamos que o farol tenha um valor de profundidade de 0,5. A profundidade da estrela negra será muito próxima de 0,5. Ou seja, ele e o pixel abaixo dele têm valores de profundidade muito próximos. Por outro lado, o asterisco vermelho terá uma profundidade muito maior, porque fica mais perto da câmera, digamos 0,7. E embora o pixel atrás dele ainda esteja no farol, ele tem um valor de profundidade de 0,5, ou seja, há mais diferença.

Esse é o truque. Quando a profundidade de um pixel na superfície da água está próxima o suficiente da profundidade do pixel sobre o qual é desenhada, estamos bem perto da borda de algum objeto e podemos renderizá-lo como espuma.

Ou seja, precisamos de mais informações do que em qualquer pixel. De alguma forma, precisamos descobrir a profundidade do pixel sobre o qual ele deve ser desenhado. E aqui o buffer de profundidade é útil para nós.

Buffer de profundidade


Você pode pensar em um buffer de quadro ou buffer de quadro como uma renderização ou textura de destino fora da tela. Quando precisamos ler dados, precisamos renderizar fora da tela. Esta técnica é usada no efeito de fumaça .

O buffer de profundidade é uma renderização de destino especial que contém informações sobre os valores de profundidade de cada pixel. Não esqueça que o valor em gl_Position calculado no sombreador de vértice era o valor do espaço da tela, mas também possui uma terceira coordenada - o valor Z. Esse valor Z é usado para calcular a profundidade, que é gravada no buffer de profundidade.

O buffer de profundidade destina-se à renderização correta da cena, sem a necessidade de classificar objetos de trás para frente. Cada pixel a ser desenhado primeiro verifica o buffer de profundidade. Se seu valor de profundidade for maior que o valor no buffer, ele será desenhado e seu próprio valor substituirá o valor do buffer. Caso contrário, ele será descartado (porque significa que há outro objeto à sua frente).

Na verdade, você pode desativar a gravação no buffer de profundidade para ver como tudo ficaria sem ele. Vamos tentar fazer isso no Water.js:

 material.depthTest = false; 

Você notará que a água será sempre sempre retirada de cima, mesmo que esteja atrás de objetos opacos.

Visualização do buffer de profundidade


Vamos adicionar uma maneira de renderizar o buffer de profundidade para fins de depuração. Crie um novo script DepthVisualize.js . Anexe-o à câmera.

Para acessar o buffer de profundidade no PlayCanvas, basta escrever o seguinte:

 this.entity.camera.camera.requestDepthMap(); 

Portanto, injetamos automaticamente a variável uniforme em todos os nossos shaders, que podemos usar declarando-a da seguinte maneira:

 uniform sampler2D uDepthMap; 

Abaixo está um exemplo de script solicitando um mapa de profundidade e renderizando-o sobre uma cena. Ele configurou uma reinicialização a quente.

 var DepthVisualize = pc.createScript('depthVisualize'); //  initialize,       DepthVisualize.prototype.initialize = function() { this.entity.camera.camera.requestDepthMap(); this.antiCacheCount = 0; //    ,         this.SetupDepthViz(); }; DepthVisualize.prototype.SetupDepthViz = function(){ var device = this.app.graphicsDevice; var chunks = pc.shaderChunks; this.fs = ''; this.fs += 'varying vec2 vUv0;'; this.fs += 'uniform sampler2D uDepthMap;'; this.fs += ''; this.fs += 'float unpackFloat(vec4 rgbaDepth) {'; this.fs += ' const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0);'; this.fs += ' float depth = dot(rgbaDepth, bitShift);'; this.fs += ' return depth;'; this.fs += '}'; this.fs += ''; this.fs += 'void main(void) {'; this.fs += ' float depth = unpackFloat(texture2D(uDepthMap, vUv0)) * 30.0; '; this.fs += ' gl_FragColor = vec4(vec3(depth),1.0);'; this.fs += '}'; this.shader = chunks.createShaderFromCode(device, chunks.fullscreenQuadVS, this.fs, "renderDepth" + this.antiCacheCount); this.antiCacheCount ++; //     ,        this.command = new pc.Command(pc.LAYER_FX, pc.BLEND_NONE, function () { pc.drawQuadWithShader(device, null, this.shader); }.bind(this)); this.command.isDepthViz = true; //    ,      this.app.scene.drawCalls.push(this.command); }; //  update,     DepthVisualize.prototype.update = function(dt) { }; //  swap,      //      DepthVisualize.prototype.swap = function(old) { this.antiCacheCount = old.antiCacheCount; //      for(var i=0;i<this.app.scene.drawCalls.length;i++){ if(this.app.scene.drawCalls[i].isDepthViz){ this.app.scene.drawCalls.splice(i,1); break; } } //    this.SetupDepthViz(); }; //      ,  : // http://developer.playcanvas.com/en/user-manual/scripting/ 

Tente copiar o código e comentar / descomentar a linha this.app.scene.drawCalls.push(this.command); para ativar / desativar a renderização em profundidade. Isso deve se parecer com a imagem abaixo.


Tarefa 3: a superfície da água não é atraída para o buffer de profundidade. O mecanismo PlayCanvas faz isso de propósito. Você pode descobrir o porquê? O que há de especial no material da água? Em outras palavras, dadas nossas regras para verificar profundidades, o que aconteceria se pixels de água fossem gravados no buffer de profundidade?

Dica: Você pode alterar uma linha no Water.js, que permite gravar água no buffer de profundidade.

Deve-se notar também que, na função de inicialização, multiplico o valor da profundidade por 30. Isso é necessário para ver claramente, pois, caso contrário, o intervalo de valores seria muito pequeno para exibir tons de cores.

Implementação de truques


Existem várias funções auxiliares no mecanismo PlayCanvas para trabalhar com valores de profundidade, mas, no momento da redação, eles não foram liberados na produção, portanto teremos que configurá-los nós mesmos.

Definimos as seguintes variáveis ​​uniformes em Water.frag :

 //   uniform-    PlayCanvas uniform sampler2D uDepthMap; uniform vec4 uScreenSize; uniform mat4 matrix_view; //      uniform vec4 camera_params; 

Definimos essas funções auxiliares sobre a função principal:

 #ifdef GL2 float linearizeDepth(float z) { z = z * 2.0 - 1.0; return 1.0 / (camera_params.z * z + camera_params.w); } #else #ifndef UNPACKFLOAT #define UNPACKFLOAT float unpackFloat(vec4 rgbaDepth) { const vec4 bitShift = vec4(1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0); return dot(rgbaDepth, bitShift); } #endif #endif float getLinearScreenDepth(vec2 uv) { #ifdef GL2 return linearizeDepth(texture2D(uDepthMap, uv).r) * camera_params.y; #else return unpackFloat(texture2D(uDepthMap, uv)) * camera_params.y; #endif } float getLinearDepth(vec3 pos) { return -(matrix_view * vec4(pos, 1.0)).z; } float getLinearScreenDepth() { vec2 uv = gl_FragCoord.xy * uScreenSize.zw; return getLinearScreenDepth(uv); } 

Passaremos as informações do shader sobre a câmera no Water.js . Cole isso para onde você passa as outras variáveis ​​uniformes como uTime:

 if(!this.camera){ this.camera = this.app.root.findByName("Camera").camera; } var camera = this.camera; var n = camera.nearClip; var f = camera.farClip; var camera_params = [ 1/f, f, (1-f / n) / 2, (1 + f / n) / 2 ]; material.setParameter('camera_params', camera_params); 

Finalmente, precisamos de uma posição no mundo de cada pixel para o nosso shader de fragmento. Temos que obtê-lo do vertex shader. Portanto, definiremos uma variável variável em Water.frag :

 varying vec3 WorldPosition; 

Defina a mesma variável variável em Water.vert . Em seguida, atribuímos a ela uma posição distorcida do vertex shader, para que o código completo fique assim:

 attribute vec3 aPosition; attribute vec2 aUv0; varying vec2 vUv0; varying vec3 WorldPosition; uniform mat4 matrix_model; uniform mat4 matrix_viewProjection; uniform float uTime; void main(void) { vUv0 = aUv0; vec3 pos = aPosition; pos.y += cos(pos.z*5.0+uTime) * 0.1 * sin(pos.x * 5.0 + uTime); gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0); WorldPosition = pos; } 

Percebemos o truque de verdade


Agora estamos finalmente prontos para implementar a técnica descrita no início desta seção. Queremos comparar a profundidade do pixel em que estamos com a profundidade do pixel abaixo dele. O pixel em que estamos é retirado de uma posição no mundo e o pixel abaixo dele é obtido na posição da tela. Portanto, tomamos essas duas profundidades:

 float worldDepth = getLinearDepth(WorldPosition); float screenDepth = getLinearScreenDepth(); 

Tarefa 4: um desses valores nunca será maior que o outro (assumindo depthTest = true). Você pode determinar qual?

Sabemos que a espuma será onde a distância entre os dois valores é pequena. Portanto, vamos renderizar essa diferença para cada pixel. Cole isso no final do sombreador (e desative o script de visualização em profundidade da seção anterior):

 color = vec4(vec3(screenDepth - worldDepth),1.0); gl_FragColor = color; 

E deve ser algo como isto:


Ou seja, escolhemos corretamente os limites de qualquer objeto imerso na água em tempo real! Obviamente, você pode escalar a diferença para tornar a espuma mais espessa ou menos comum.

Agora temos muitas opções para combinar essa saída com a superfície da água para criar belas linhas de espuma. Você pode deixá-los com um gradiente, usar para obter amostras de uma textura diferente ou atribuir a eles uma cor específica se a diferença for menor ou igual a um determinado valor limite.

O que eu mais gostei foi a atribuição de uma cor semelhante às linhas de água estática; portanto, minha função principal final é assim:

 void main(void) { vec4 color = vec4(0.0,0.7,1.0,0.5); vec2 pos = vUv0 * 2.0; pos.y += uTime * 0.02; vec4 WaterLines = texture2D(uSurfaceTexture,pos); color.rgba += WaterLines.r * 0.1; float worldDepth = getLinearDepth(WorldPosition); float screenDepth = getLinearScreenDepth(); float foamLine = clamp((screenDepth - worldDepth),0.0,1.0) ; if(foamLine < 0.7){ color.rgba += 0.2; } gl_FragColor = color; } 

Resumir


Criamos a flutuabilidade dos objetos imersos na água, aplicamos uma textura móvel na superfície para simular cáusticos e aprendemos a usar o buffer de profundidade para criar faixas dinâmicas de espuma.

Na terceira e na última parte, adicionaremos os efeitos do pós-processamento e aprenderemos como usá-los para criar o efeito de distorção subaquática.

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/pt416983/


All Articles