Crea un sombreador de agua de dibujos animados para la web. Parte 2

En la primera parte, vimos cómo configurar el entorno y la superficie del agua. En esta parte, daremos flotabilidad a los objetos, agregaremos líneas de agua en la superficie y crearemos líneas de espuma con un amortiguador de profundidad alrededor de los límites de los objetos que se cruzan con la superficie.

Para que la escena se vea un poco mejor, le hice pequeños cambios. Puedes personalizar tu escena como quieras, pero hice lo siguiente:

  • Se agregaron modelos de un faro y un pulpo.
  • Se agregó un modelo de suelo con color #FFA457 .
  • Se agregó un color de cielo de #6CC8FF .
  • Se #FFC480 color de retroiluminación #FFC480 a la escena (estos parámetros se pueden encontrar en la configuración de la escena).

Mi escena original ahora se ve así.


Flotabilidad


La forma más fácil de crear flotabilidad es usar un script para empujar objetos hacia arriba y hacia abajo. Cree un nuevo script Buoyancy.js y establezca lo siguiente en su inicialización:

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

Ahora en la actualización ejecutamos el incremento de tiempo y giramos el 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 este script al bote y vea cómo salta arriba y abajo del agua! Puede aplicar este script a varios objetos (incluida la cámara; pruébelo).

Textura superficial


Por ahora, podemos ver las olas mirando los bordes de la superficie del agua. Agregar textura hará que el movimiento de la superficie sea más notable. Además, es una forma de bajo costo para simular reflexiones y cáusticos.

Puedes intentar encontrar algunas texturas cáusticas o crear una tú mismo. Dibujé una textura en Gimp que puedes usar libremente. Cualquier textura es adecuada, siempre que se pueda revestir sin juntas notables.

Después de elegir la textura que le gusta, arrástrela a la ventana Activos de su proyecto. Necesitamos hacer referencia a esta textura desde el script Water.js, así que creemos un atributo para ello:

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

Y luego asignarlo en el editor:


Ahora tenemos que pasarlo al sombreador. Vaya a Water.js y configure la función CreateWaterMaterial nuevo parámetro:

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

Ahora vuelve a Water.frag y declara un nuevo uniforme:

 uniform sampler2D uSurfaceTexture; 

Ya casi hemos terminado. Para representar una textura en un plano, necesitamos saber dónde está cada píxel en la malla. Es decir, necesitamos transferir datos desde el sombreador de vértices al fragmento.

Variables variables


Las variables variables le permiten transferir datos desde el sombreador de vértices al fragmento. Este es el tercer tipo de variables especiales que se pueden usar en el sombreador (las dos primeras son uniformes y de atributo ). Se establece una variable para cada vértice y cada píxel puede acceder a ella. Dado que hay muchos más píxeles que vértices, el valor se interpola entre los vértices (de ahí el nombre "variable"; se desvía de los valores que se le pasan).

Para probarlo en funcionamiento, declare una nueva variable en Water.vert como variable:

 varying vec2 ScreenPosition; 

Y luego asígnele el valor gl_Position después de calcularlo:

 ScreenPosition = gl_Position.xyz; 

Ahora regrese a Water.frag y declare la misma variable. No podemos obtener la salida de los datos de depuración del sombreador, pero podemos usar el color para la depuración visual. Aquí se explica cómo hacerlo:

 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; } 

El plano ahora debería verse en blanco y negro, y la línea de división de color irá a ScreenPosition.x = 0. Los valores de color solo cambian de 0 a 1, pero los valores en ScreenPosition pueden estar fuera de este rango. Se limitan automáticamente, por lo tanto, cuando ve negro, puede ser 0 o un número negativo.

Lo que acabamos de hacer es pasar la posición de la pantalla de cada vértice a cada píxel. Puede ver que la línea que separa los lados blanco y negro siempre pasará por el centro de la pantalla, independientemente de dónde esté realmente la superficie del mundo.

Tarea 1: cree una nueva variable variable para transferir la posición en el mundo en lugar de la posición de la pantalla. Visualízalo de la misma manera. Si el color no cambia con el movimiento de la cámara, entonces todo se hace correctamente.

Usando UV


UV son las coordenadas 2D de cada vértice en la malla, normalizadas de 0 a 1. Son necesarias para el muestreo correcto de la textura en el plano, y ya las configuramos en la parte anterior.

Declararemos un nuevo atributo en Water.vert (este nombre se toma de la definición de sombreador en Water.js):

 attribute vec2 aUv0; 

Y ahora solo tenemos que pasarlo al sombreador de fragmentos, así que solo cree variables y asígnele el valor del atributo:

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

Ahora declararemos la misma variable variable en el fragment shader. Para asegurarnos de que todo funciona, podemos visualizar la depuración como antes, y luego Water.frag se verá así:

 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; } 

Debería ver un gradiente que confirma que tenemos un valor de 0 de un extremo y 1 del otro. Ahora para probar la textura de verdad, todo lo que tenemos que hacer es:

 color = texture2D(uSurfaceTexture,vUv0); 

Después de eso, veremos la textura en la superficie:


Estilo de textura


En lugar de simplemente establecer la textura como el nuevo color, combinémoslo con el 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; } 

Esto funciona porque el color de la textura es negro (0) en todas partes, excepto en las líneas de agua. Agregándolo, no cambiamos el color azul inicial, con la excepción de los lugares con líneas donde se vuelve más claro.

Sin embargo, esta no es la única forma de combinar colores.

Tarea 2: ¿Puedes combinar los colores para obtener el efecto más débil que se muestra a continuación?


Textura en movimiento


Como efecto final, queremos que las líneas se muevan a lo largo de la superficie y no se vea tan estática. Para hacer esto, aprovecharemos el hecho de que cualquier valor fuera del intervalo de 0 a 1, pasado a la función texture2D , será transferido (por ejemplo, tanto 1.5 como 2.5 son iguales a 0.5). Por lo tanto, podemos aumentar nuestra posición mediante la variable de tiempo uniforme que ya hemos establecido para aumentar o disminuir la densidad de las líneas en la superficie, lo que le dará al sombreador de fragmentos final 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; } 

Líneas de espuma y tampón de profundidad


Al representar líneas de espuma alrededor de los objetos en el agua, es mucho más fácil ver qué tan inmersos están los objetos y dónde cruzan la superficie. Además, de esta manera nuestra agua se vuelve mucho más creíble. Para darnos cuenta de las líneas de espuma, de alguna manera necesitamos descubrir dónde están los límites de cada objeto y hacerlo de manera efectiva.

Truco


Necesitamos aprender a determinar si un píxel en la superficie del agua está cerca del objeto. Si es así, podemos pintarlo del color de la espuma. No hay formas simples de resolver este problema (que yo sepa). Por lo tanto, para resolverlo, utilizo una técnica útil para resolver problemas: tomaré un ejemplo para el cual conocemos la respuesta y veremos si podemos generalizarla.

Echa un vistazo a la imagen de abajo.


¿Qué píxeles deberían ser parte de la espuma? Sabemos que debería verse así:


Así que veamos dos píxeles específicos. A continuación los marqué con asteriscos. El negro estará en la espuma y el rojo no. ¿Cómo los distinguimos en un sombreador?


Sabemos que a pesar de que estos dos píxeles en el espacio de la pantalla están cerca uno del otro (ambos se representan en la parte superior del faro), de hecho, están muy lejos en el espacio del mundo. Podemos verificar esto mirando la misma escena desde un ángulo diferente.


Tenga en cuenta que la estrella roja no se encuentra en el faro, como nos pareció, pero la negra está realmente allí. Podemos distinguir el uso de la distancia a la cámara, que generalmente se llama "profundidad". La profundidad 1 significa que el punto está muy cerca de la cámara, la profundidad 0 significa que está muy lejos. Pero esto no es solo una cuestión de distancias absolutas en el mundo, profundidad o cámara. La profundidad relativa al píxel detrás es importante.

Mira de nuevo a la primera vista. Digamos que el faro tiene un valor de profundidad de 0.5. La profundidad de la estrella negra estará muy cerca de 0.5. Es decir, él y el píxel debajo tienen valores de profundidad muy cercanos. Por otro lado, el asterisco rojo tendrá una profundidad mucho mayor, ya que está más cerca de la cámara, digamos 0.7. Y aunque el píxel detrás de él todavía está en el faro, tiene un valor de profundidad de 0.5, es decir, hay más diferencia.

Este es el truco. Cuando la profundidad de un píxel en la superficie del agua está lo suficientemente cerca de la profundidad del píxel sobre el que se dibuja, entonces estamos bastante cerca del borde de algún objeto y podemos convertir el píxel como espuma.

Es decir, necesitamos más información de la que tenemos en cualquier píxel. De alguna manera, necesitamos descubrir la profundidad del píxel sobre el que se debe dibujar. Y aquí el buffer de profundidad es útil para nosotros.

Tampón de profundidad


Puede pensar en un búfer de cuadro o un búfer de cuadro como un renderizado o textura objetivo fuera de la pantalla. Cuando necesitamos leer datos, necesitamos renderizar fuera de la pantalla. Esta técnica se usa en el efecto de humo .

El búfer de profundidad es un renderizado objetivo especial que contiene información sobre los valores de profundidad de cada píxel. No olvide que el valor en gl_Position , calculado en el sombreador de vértices, era el valor del espacio de la pantalla, pero también tiene una tercera coordenada: el valor Z. Este valor Z se utiliza para calcular la profundidad, que se escribe en el búfer de profundidad.

El búfer de profundidad está destinado a la representación correcta de la escena sin la necesidad de ordenar objetos de atrás hacia adelante. Cada píxel a dibujar primero verifica el búfer de profundidad. Si su valor de profundidad es mayor que el valor en el búfer, entonces se dibuja y su propio valor sobrescribe el valor del búfer. De lo contrario, se descarta (porque significa que hay otro objeto delante de él).

De hecho, puede deshabilitar la escritura en el búfer de profundidad para ver cómo se vería todo sin él. Intentemos hacerlo en Water.js:

 material.depthTest = false; 

Notará que ahora el agua siempre se extraerá desde arriba, incluso si está detrás de objetos opacos.

Visualización del buffer de profundidad


Agreguemos una forma de representar el búfer de profundidad con fines de depuración. Cree un nuevo script DepthVisualize.js . Adjuntarlo a la cámara.

Para acceder al búfer de profundidad en PlayCanvas, solo escriba lo siguiente:

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

Entonces, inyectamos automáticamente la variable uniforme en todos nuestros sombreadores, que podemos usar al declararla de la siguiente manera:

 uniform sampler2D uDepthMap; 

A continuación se muestra un script de ejemplo que solicita un mapa de profundidad y lo representa en la parte superior de una escena. Ha configurado un reinicio en caliente.

 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/ 

Intente copiar el código y comente / descomente la línea this.app.scene.drawCalls.push(this.command); para habilitar / deshabilitar la representación de profundidad. Esto debería verse como la imagen de abajo.


Tarea 3: la superficie del agua no se introduce en el búfer de profundidad. El motor de PlayCanvas hace esto a propósito. ¿Puedes entender por qué? ¿Qué tiene de especial el material de agua? En otras palabras, dadas nuestras reglas para verificar profundidades, ¿qué pasaría si se escribieran píxeles de agua en el búfer de profundidad?

Sugerencia: puede cambiar una línea en Water.js, que le permite escribir agua en el búfer de profundidad.

También se debe tener en cuenta que en la función de inicialización, multiplico el valor de profundidad por 30. Esto es necesario para verlo claramente, porque de lo contrario el rango de valores sería demasiado pequeño para mostrar tonos de color.

Implementación de trucos


Hay varias funciones auxiliares en el motor PlayCanvas para trabajar con valores de profundidad, pero al momento de la escritura, no se lanzaron en producción, por lo que tendremos que configurarlos nosotros mismos.

Definimos las siguientes variables uniformes en Water.frag :

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

Definimos estas funciones auxiliares sobre la función 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); } 

Pasaremos la información del sombreador sobre la cámara en Water.js . Pegue esto donde pase las otras variables 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, necesitamos una posición en el mundo de cada píxel para nuestro sombreador de fragmentos. Tenemos que obtenerlo del sombreador de vértices. Por lo tanto, definiremos una variable variable en Water.frag :

 varying vec3 WorldPosition; 

Defina la misma variable variable en Water.vert . Luego le asignamos una posición distorsionada desde el sombreador de vértices para que el código completo se vea así:

 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; } 

Nos damos cuenta del truco de verdad


Ahora finalmente estamos listos para implementar la técnica descrita al comienzo de esta sección. Queremos comparar la profundidad del píxel en el que estamos con la profundidad del píxel debajo de él. El píxel en el que estamos se toma de una posición en el mundo, y el píxel debajo se obtiene de la posición de la pantalla. Por lo tanto, tomamos estas dos profundidades:

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

Tarea 4: uno de estos valores nunca será mayor que el otro (suponiendo depthTest = true). ¿Puedes determinar cuál?

Sabemos que la espuma será donde la distancia entre los dos valores es pequeña. Por lo tanto, hagamos esta diferencia para cada píxel. Pegue esto al final del sombreador (y apague el script de visualización de profundidad de la sección anterior):

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

Y debería verse más o menos así:


Es decir, ¡elegimos correctamente los límites de cualquier objeto sumergido en agua en tiempo real! Por supuesto, puede escalar la diferencia para hacer que la espuma sea más gruesa o menos común.

Ahora tenemos muchas opciones para combinar esta salida con la superficie del agua para crear hermosas líneas de espuma. Puede dejarlos con un degradado, usarlos para tomar muestras de una textura diferente o asignarles un color específico si la diferencia es menor o igual a un cierto valor límite.

Lo que más me gustó fue la asignación de un color similar a las líneas de agua estática, por lo que mi función principal terminada se ve así:

 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; } 

Para resumir


Creamos la flotabilidad de los objetos sumergidos en el agua, aplicamos una textura en movimiento a la superficie para simular cáusticos y aprendimos cómo usar el tampón de profundidad para crear franjas de espuma dinámicas.

En la tercera y última parte, agregaremos los efectos del postprocesamiento y aprenderemos cómo usarlos para crear el efecto de distorsión bajo el agua.

Código fuente


El proyecto PlayCanvas terminado se puede encontrar aquí . Nuestro repositorio también tiene un puerto de proyecto en Three.js .

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


All Articles