Advertisement
  1. Game Development
  2. Shaders

Creando Agua de Caricatura para la Internet: Parte 3

Scroll to top
Read Time: 14 min

() translation by (you can also view the original English article)

Bienvenidos a esta serie de tres partes sobre la creación de agua toon estilizada en PlayCanvas utilizando sombreadores de vértices. En la Parte 2 cubrimos las líneas de flotabilidad y espuma. En esta parte final, vamos a aplicar la distorsión subacuática como un efecto posterior al proceso.

Efectos de Refracción y Post-Proceso

Nuestro objetivo es comunicar visualmente la refracción de la luz a través del agua. Ya hemos explicado cómo crear este tipo de distorsión en un sombreador de fragmentos en un tutorial previo para una escena 2D. La única diferencia aquí es que tendremos que averiguar qué área de la pantalla está bajo el agua y solo aplicar la distorsión allí.

Post-Procesamiento

En general, un efecto de post-proceso es cualquier cosa que se aplica a toda la escena después de que se representa, como un tinte de color o un efecto de pantalla de CRT antigua. En lugar de renderizar su escena directamente en la pantalla, primero la renderiza en un búfer o textura, y luego la renderiza en la pantalla, pasando por un sombreador personalizado.

En PlayCanvas, puede configurar un efecto de postproceso creando un nuevo guión. Llámalo Refraction.js y copia esta plantilla para comenzar con:

1
//--------------- POST EFFECT DEFINITION------------------------//

2
pc.extend(pc, function () {
3
    // Constructor - Creates an instance of our post effect

4
    var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) {
5
        var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
6
        fragmentShader = fragmentShader + fs;
7
        
8
        // this is the shader definition for our effect

9
        this.shader = new pc.Shader(graphicsDevice, {
10
            attributes: {
11
                aPosition: pc.SEMANTIC_POSITION
12
            },
13
            vshader: vs,
14
            fshader: fs
15
        });
16
        
17
        this.buffer =  buffer; 
18
    };
19
20
    // Our effect must derive from pc.PostEffect

21
    RefractionPostEffect = pc.inherits(RefractionPostEffect, pc.PostEffect);
22
23
    RefractionPostEffect.prototype = pc.extend(RefractionPostEffect.prototype, {
24
        // Every post effect must implement the render method which

25
        // sets any parameters that the shader might require and

26
        // also renders the effect on the screen

27
        render: function (inputTarget, outputTarget, rect) {
28
            var device = this.device;
29
            var scope = device.scope;
30
31
            // Set the input render target to the shader. This is the image rendered from our camera

32
            scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);            
33
34
            // Draw a full screen quad on the output target. In this case the output target is the screen.

35
            // Drawing a full screen quad will run the shader that we defined above

36
            pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
37
        }
38
    });
39
40
    return {
41
        RefractionPostEffect: RefractionPostEffect
42
    };
43
}());
44
45
//--------------- SCRIPT DEFINITION------------------------//

46
var Refraction = pc.createScript('refraction');
47
48
Refraction.attributes.add('vs', {
49
    type: 'asset',
50
    assetType: 'shader',
51
    title: 'Vertex Shader'
52
});
53
54
Refraction.attributes.add('fs', {
55
    type: 'asset',
56
    assetType: 'shader',
57
    title: 'Fragment Shader'
58
});
59
60
// initialize code called once per entity

61
Refraction.prototype.initialize = function() {
62
    var effect = new pc.RefractionPostEffect(this.app.graphicsDevice, this.vs.resource, this.fs.resource);
63
64
    // add the effect to the camera's postEffects queue

65
    var queue = this.entity.camera.postEffects;
66
    queue.addEffect(effect);
67
    
68
    this.effect = effect;
69
    
70
    // Save the current shaders for hot reload 

71
    this.savedVS = this.vs.resource;
72
    this.savedFS = this.fs.resource;
73
};
74
75
Refraction.prototype.update = function(){
76
     if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){
77
         this.swap(this);
78
     }
79
};
80
81
Refraction.prototype.swap = function(old){
82
    this.entity.camera.postEffects.removeEffect(old.effect);
83
    this.initialize(); 
84
};

Esto es como una secuencia de comandos normal, pero definimos una clase RefractionPostEffect que se puede aplicar a la cámara. Esto necesita un vértice y un sombreador de fragmentos para renderizar. Los atributos ya están configurados, así que vamos a crear Refraction.frag con este contenido:

1
precision highp float;
2
3
uniform sampler2D uColorBuffer;
4
varying vec2 vUv0;
5
6
void main() {
7
    vec4 color = texture2D(uColorBuffer, vUv0);
8
    
9
    gl_FragColor = color;
10
}

Y Refraction.vert con un sombreador de vértices básico:

1
attribute vec2 aPosition;
2
varying vec2 vUv0;
3
4
void main(void)
5
{
6
    gl_Position = vec4(aPosition, 0.0, 1.0);
7
    vUv0 = (aPosition.xy + 1.0) * 0.5;
8
}

Ahora adjunte el script Refraction.js a la cámara y asigne los sombreadores a los atributos apropiados. Cuando inicies el juego, deberías ver la escena exactamente como estaba antes. Este es un efecto de publicación en blanco que simplemente vuelve a renderizar la escena. Para verificar que esto funcione, intente darle a la escena un tono rojo.

En Refraction.frag, en lugar de simplemente devolver el color, intente configurar el componente rojo en 1.0, que debería verse como la imagen de abajo.

Scene rendered with a red tint Scene rendered with a red tint Scene rendered with a red tint

Sombreador de Distorsión

Necesitamos agregar un uniforme de tiempo para la distorsión animada, así que ve y crea uno en Refraction.js, dentro de este constructor para el efecto de publicación:

1
var RefractionPostEffect = function (graphicsDevice, vs, fs) {
2
    var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
3
    fragmentShader = fragmentShader + fs;
4
    
5
    // this is the shader definition for our effect

6
    this.shader = new pc.Shader(graphicsDevice, {
7
        attributes: {
8
            aPosition: pc.SEMANTIC_POSITION
9
        },
10
        vshader: vs,
11
        fshader: fs
12
    });
13
    
14
    // >>>>>>>>>>>>> Initialize the time here 

15
    this.time = 0;
16
    
17
    };

Ahora, dentro de esta función de renderización, la pasamos a nuestro sombreador y la incrementamos:

1
RefractionPostEffect.prototype = pc.extend(RefractionPostEffect.prototype, {
2
    // Every post effect must implement the render method which

3
    // sets any parameters that the shader might require and

4
    // also renders the effect on the screen

5
    render: function (inputTarget, outputTarget, rect) {
6
        var device = this.device;
7
        var scope = device.scope;
8
9
        // Set the input render target to the shader. This is the image rendered from our camera

10
        scope.resolve("uColorBuffer").setValue(inputTarget.colorBuffer);   
11
        /// >>>>>>>>>>>>>>>>>> Pass the time uniform here 

12
        scope.resolve("uTime").setValue(this.time);
13
        this.time += 0.1;
14
15
        // Draw a full screen quad on the output target. In this case the output target is the screen.

16
        // Drawing a full screen quad will run the shader that we defined above

17
        pc.drawFullscreenQuad(device, outputTarget, this.vertexBuffer, this.shader, rect);
18
    }
19
});

Ahora podemos usar el mismo código de sombreado del tutorial de distorsión de agua, haciendo que nuestro sombreador de fragmentos completo se vea así:

1
precision highp float;
2
3
uniform sampler2D uColorBuffer;
4
uniform float uTime;
5
6
varying vec2 vUv0;
7
8
void main() {
9
    vec2 pos = vUv0;
10
    
11
    float X = pos.x*15.+uTime*0.5;
12
    float Y = pos.y*15.+uTime*0.5;
13
    pos.y += cos(X+Y)*0.01*cos(Y);
14
    pos.x += sin(X-Y)*0.01*sin(Y);
15
    
16
    vec4 color = texture2D(uColorBuffer, pos);
17
    
18
    gl_FragColor = color;
19
}

Si todo salió bien, ahora todo debería verse como si estuviera bajo el agua, como se muestra a continuación.

Underwater distortion applied to the whole scene Underwater distortion applied to the whole scene Underwater distortion applied to the whole scene
Reto # 1: Haga que la distorsión solo se aplique a la mitad inferior de la pantalla.

Máscaras de Cámara

Casi estamos allí. Todo lo que tenemos que hacer ahora es aplicar este efecto de distorsión solo en la parte submarina de la pantalla. La manera más directa que he encontrado para hacer esto es volver a renderizar la escena con la superficie del agua representada como un blanco sólido, como se muestra a continuación.

Water surface rendered as a solid white to act as a maskWater surface rendered as a solid white to act as a maskWater surface rendered as a solid white to act as a mask

Esto se representaría en una textura que actuaría como una máscara. Luego, pasaríamos esta textura a nuestro sombreador de refracción, que solo distorsionaría un píxel en la imagen final si el píxel correspondiente en la máscara es blanco.

Agreguemos un atributo booleano en la superficie del agua para saber si se está utilizando como una máscara. Agregue esto a Water.js:

1
Water.attributes.add('isMask', {type:'boolean',title:"Is Mask?"});

Luego podemos pasarlo al shader con material.setParameter ('isMask', this.isMask); como siempre. Luego, declare en Water.frag y establezca el color en blanco si es verdadero.

1
// Declare the new uniform at the top

2
uniform bool isMask;
3
4
// At the end of the main function, override the color to be white 

5
// if the mask is true 

6
if(isMask){
7
   color = vec4(1.0); 
8
}

Confirme que esto funciona alternar entre "¿Es la máscara?" propiedad en el editor y relanzamiento del juego. Debería verse blanco, como en la imagen anterior.

Ahora, para volver a renderizar la escena, necesitamos una segunda cámara. Crea una nueva cámara en el editor y llámala CameraMask. Duplique también la entidad Water en el editor y llámela WaterMask. Asegúrate de que "¿Es la máscara?" es falso para la entidad Agua pero es cierto para la WaterMask.

Para decirle a la nueva cámara que represente una textura en lugar de la pantalla, cree una nueva secuencia de comandos llamada CameraMask.js y conéctela a la nueva cámara. Creamos un RenderTarget para capturar la salida de esta cámara así:

1
// initialize code called once per entity

2
CameraMask.prototype.initialize = function() {
3
    // Create a 512x512x24-bit render target with a depth buffer

4
    var colorBuffer = new pc.Texture(this.app.graphicsDevice, {
5
        width: 512,
6
        height: 512,
7
        format: pc.PIXELFORMAT_R8_G8_B8,
8
        autoMipmap: true
9
    });
10
    colorBuffer.minFilter = pc.FILTER_LINEAR;
11
    colorBuffer.magFilter = pc.FILTER_LINEAR;
12
    var renderTarget = new pc.RenderTarget(this.app.graphicsDevice, colorBuffer, {
13
        depth: true
14
    });
15
16
    this.entity.camera.renderTarget = renderTarget;
17
};

Ahora, si inicia, verá que esta cámara ya no se está renderizando en la pantalla. Podemos tomar la salida de su objetivo de renderizado en Refraction.js de la siguiente manera:

1
Refraction.prototype.initialize = function() {
2
    var cameraMask = this.app.root.findByName('CameraMask');
3
    var maskBuffer = cameraMask.camera.renderTarget.colorBuffer;
4
    
5
    var effect = new pc.RefractionPostEffect(this.app.graphicsDevice, this.vs.resource, this.fs.resource, maskBuffer);
6
    
7
    // ...

8
    // The rest of this function is the same as before

9
    
10
};

Tenga en cuenta que paso esta textura de máscara como un argumento para el constructor de efectos posteriores. Necesitamos crear una referencia en nuestro constructor, por lo que se ve así:

1
//// Added an extra argument on the line below

2
var RefractionPostEffect = function (graphicsDevice, vs, fs, buffer) {
3
        var fragmentShader = "precision " + graphicsDevice.precision + " float;\n";
4
        fragmentShader = fragmentShader + fs;
5
        
6
        // this is the shader definition for our effect

7
        this.shader = new pc.Shader(graphicsDevice, {
8
            attributes: {
9
                aPosition: pc.SEMANTIC_POSITION
10
            },
11
            vshader: vs,
12
            fshader: fs
13
        });
14
        
15
        this.time = 0;
16
        //// <<<<<<<<<<<<< Saving the buffer here 

17
        this.buffer = buffer; 
18
    };

Finalmente, en la función de renderización, pase el búfer a nuestro sombreador con:

1
scope.resolve("uMaskBuffer").setValue(this.buffer); 

Ahora para verificar que todo esto funciona, lo dejo como un desafío.

Reto # 2: renderiza uMaskBuffer a la pantalla para confirmar que es la salida de la segunda cámara.

Una cosa a tener en cuenta es que el destino del renderizado está configurado en la inicialización de CameraMask.js, y que debe estar listo cuando se llame a Refraction.js. Si los scripts se ejecutan al revés, obtendrás un error. Para asegurarse de que se ejecutan en el orden correcto, arrastre CameraMask a la parte superior de la lista de entidades en el editor, como se muestra a continuación.

PlayCanvas editor with CameraMask at top of entity listPlayCanvas editor with CameraMask at top of entity listPlayCanvas editor with CameraMask at top of entity list

La segunda cámara siempre debería estar mirando la misma vista que la original, así que hagamos que siempre siga su posición y rotación en la actualización de CameraMask.js:

1
CameraMask.prototype.update = function(dt) {
2
    var pos = this.CameraToFollow.getPosition();
3
    var rot = this.CameraToFollow.getRotation();
4
    this.entity.setPosition(pos.x,pos.y,pos.z);
5
    this.entity.setRotation(rot);
6
};

Y defina CameraToFollow en la inicialización:

1
this.CameraToFollow = this.app.root.findByName('Camera');

Máscaras de Sacrificio

Ambas cámaras están renderizando lo mismo. Queremos que la cámara con máscara represente todo excepto el agua real, y queremos que la cámara real represente todo, excepto el agua de la máscara.

Para hacer esto, podemos usar la máscara de bits de descarte de la cámara. Esto funciona de manera similar a las máscaras de colisión si alguna vez las utilizó. Un objeto será eliminado (no renderizado) si el resultado de un Y a nivel de bit entre su máscara y la máscara de la cámara es 1.

Digamos que Water tendrá el bit 2 configurado, y WaterMask tendrá el bit 3. Entonces la cámara real necesita tener todos los bits configurados a excepción de 3, y la máscara de cámara debe tener todos los bits configurados, excepto 2. Una manera fácil de decir "todos los bits, excepto N" es hacer:

1
~(1 << N) >>> 0

Puede leer más sobre los operadores bitwise aquí.

Para configurar las máscaras de eliminación de imágenes de la cámara, podemos poner esto dentro de la inicialización de CameraMask.js en la parte inferior:

1
    // Set all bits except for 2 

2
    this.entity.camera.camera.cullingMask &= ~(1 << 2) >>> 0;
3
    // Set all bits except for 3

4
    this.CameraToFollow.camera.camera.cullingMask &= ~(1 << 3) >>> 0;
5
    // If you want to print out this bit mask, try:

6
    // console.log((this.CameraToFollow.camera.camera.cullingMask >>> 0).toString(2));

Ahora, en Water.js, establece la máscara de malla de agua en el bit 2 y la versión de máscara en el bit 3:

1
// Put this at the bottom of the initialize of Water.js

2
3
// Set the culling masks 

4
var bit = this.isMask ? 3 : 2; 
5
meshInstance.mask = 0; 
6
meshInstance.mask |= (1 << bit);

Ahora, una vista tendrá el agua normal y la otra tendrá el agua blanca sólida. La mitad izquierda de la imagen a continuación es la vista desde la cámara original, y la mitad derecha es desde la cámara con máscara.

Split view of mask camera and original cameraSplit view of mask camera and original cameraSplit view of mask camera and original camera

Aplicando la Máscara

¡Un último paso ahora! Sabemos que las áreas bajo el agua están marcadas con píxeles blancos. Solo debemos comprobar si no estamos en un píxel blanco, y si es así, desactivar la distorsión en Refraction.frag:

1
// Check original position as well as new distorted position

2
vec4 maskColor = texture2D(uMaskBuffer, pos);
3
vec4 maskColor2 = texture2D(uMaskBuffer, vUv0);
4
// We're not at a white pixel?

5
if(maskColor != vec4(1.0) || maskColor2 != vec4(1.0)){
6
    // Return it back to the original position

7
    pos = vUv0;
8
}

¡Y eso debería hacerlo!

Una cosa a tener en cuenta es que dado que la textura de la máscara se inicializa en el inicio, si cambia el tamaño de la ventana en el tiempo de ejecución, ya no coincidirá con el tamaño de la pantalla.

Anti-Aliasing

Como paso de limpieza opcional, es posible que haya notado que los bordes de la escena ahora se ven un poco nítidos. Esto se debe a que cuando aplicamos nuestro efecto de publicación, perdimos el anti-aliasing.

Podemos aplicar un anti-alias adicional sobre nuestro efecto como otro efecto de publicación. Afortunadamente, hay uno disponible en la tienda PlayCanvas que podemos usar. Vaya a la página de activos del script, haga clic en el botón de descarga verde grande y elija su proyecto de la lista que aparece. El script aparecerá en la raíz de su ventana de activos como posteffect-fxaa.js. ¡Simplemente adjunte esto a la entidad de la Cámara, y su escena debería verse un poco mejor!

Pensamientos Finales

Si has llegado hasta aquí, ¡date una palmadita en la espalda! Cubrimos muchas técnicas en esta serie. Ahora debería sentirse cómodo con los sombreadores de vértices, renderizando texturas, aplicando efectos de posprocesamiento, eliminando selectivamente objetos, utilizando el búfer de profundidad y trabajando con mezcla y transparencia. A pesar de que estábamos implementando esto en PlayCanvas, estos son todos los conceptos generales de gráficos que encontrarás de alguna forma en cualquier plataforma en la que termines.

Todas estas técnicas también son aplicables a una variedad de otros efectos. Una aplicación particularmente interesante que he encontrado de los sombreadores de vértices es en esta charla sobre el arte de Abzu, donde explican cómo usaron sombreadores de vértices para animar eficientemente a decenas de miles de peces en la pantalla.

¡Ahora también deberías tener un agradable efecto de agua que puedes aplicar a tus juegos! Puede personalizarlo fácilmente ahora que ha reunido cada detalle usted mismo. Todavía hay mucho más que puedes hacer con el agua (ni siquiera he mencionado ningún tipo de reflejo). A continuación hay un par de ideas.

Olas Basadas en Sonido

En lugar de simplemente animar las ondas con una combinación de seno y coseno, puede muestrear una textura de ruido para que las ondas se vean un poco más naturales e impredecibles.

Rutas de Espuma Dinámica

En lugar de líneas de agua completamente estáticas en la superficie, puede dibujar sobre esa textura cuando los objetos se mueven, para crear un rastro dinámico de espuma. Hay muchas maneras de hacerlo, por lo que este podría ser su propio proyecto.

Código Fuente

Aquí puede encontrar el proyecto PlayCanvas alojado terminado. Un puerto Three.js también está disponible en este repositorio.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.