Advertisement
  1. Game Development
  2. Shaders

Creando Agua de Caricatura para la Web: Parte 1

Scroll to top
Read Time: 16 min

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

En mi Guía para Principiantes de Shaders, me centré exclusivamente en los sombreadores de fragmentos, lo cual es suficiente para cualquier efecto 2D y cada ejemplo de ShaderToy. Pero hay toda una categoría de técnicas que requieren sombreadores de vértices. Este tutorial lo guiará a través de la creación de agua estilizada de Toon al introducir sombreadores de vértices. También presentaré el búfer de profundidad y cómo usarlo para obtener más información sobre su escena y crear líneas de espuma.

Así es como debería verse el efecto final. Puede probar una demostración en vivo aquí (el botón izquierdo del mouse a la órbita, el botón derecho del mouse para desplazarse, la rueda de desplazamiento para hacer zoom).

Kayak and lighthouse in waterKayak and lighthouse in waterKayak and lighthouse in water

Específicamente, éste efecto está compuesto de:

  1. Una malla de agua translúcida subdividida con vértices desplazados para crear ondas.
  2. Líneas de agua estática en la superficie.
  3. Fabulosa flotabilidad en los barcos.
  4. Líneas de espuma dinámica alrededor del borde de los objetos en el agua.
  5. Una distorsión posterior al proceso de todo bajo el agua.

Lo que me gusta de este efecto es que toca muchos conceptos diferentes en gráficos de computadora, por lo que nos permitirá utilizar ideas de tutoriales anteriores, así como desarrollar técnicas que podamos utilizar para una variedad de efectos futuros.

Voy a utilizar PlayCanvas para esto solo porque tiene un IDE gratuito basado en la web, pero todo debe ser aplicable a cualquier entorno que ejecute WebGL. Puede encontrar una versión Three.js del código fuente al final. Asumiré que te sientes cómodo usando fragmentadores de sombreado y navegando por la interfaz de PlayCanvas. Puede repasar los sombreadores aquí y leer una introducción a PlayCanvas aquí.

Ajuste de Ambiente

El objetivo de esta sección es configurar nuestro proyecto PlayCanvas y colocar algunos objetos de entorno para probar el agua.

Si aún no tienes una cuenta con PlayCanvas, regístrate en una y crea un nuevo proyecto en blanco. Por defecto, debe tener un par de objetos, una cámara y una luz en su escena.

A blank PlayCanvas project showing the objects the scene contains A blank PlayCanvas project showing the objects the scene contains A blank PlayCanvas project showing the objects the scene contains

Insertando Modelos

El proyecto Poly de Google es un gran recurso para modelos 3D para la web. Aquí está el modelo de barco que utilicé. Una vez que descargues y descomprimas eso, deberías encontrar un archivo .obj y uno .png.

  1. Arrastra ambos archivos a la ventana de activos en tu proyecto PlayCanvas.
  2. Seleccione el material que se creó automáticamente y establezca su mapa difuso en el archivo .png.
Click on the diffuse tab and select the boat imageClick on the diffuse tab and select the boat imageClick on the diffuse tab and select the boat image

Ahora puede arrastrar el Tugboat.json a su escena y eliminar los objetos Box y Plane. Puede escalar el barco si se ve demasiado pequeño (configuré el mío a 50).

You can scale the model up using the properties panel on the right once its selectedYou can scale the model up using the properties panel on the right once its selectedYou can scale the model up using the properties panel on the right once its selected

Puede agregar cualquier otro modelo a su escena de la misma manera.

Cámara de Órbita

Para configurar una cámara en órbita, copiaremos una secuencia de comandos de este ejemplo de PlayCanvas. Vaya a ese enlace y haga clic en Editor para ingresar al proyecto.

  1. Copie los contenidos de mouse-input.js y orbit-camera.js de ese proyecto tutorial en los archivos del mismo nombre en su propio proyecto.
  2. Añade un componente Script a tu cámara.
  3. Adjunta los dos scripts a la cámara.

Consejo: Puede crear carpetas en la ventana de activos para mantener las cosas organizadas. Puse estos dos scripts de cámara en Scripts / Camera /, mi modelo en Models / y mi material en Materials /.

Ahora, cuando inicies el juego (botón de reproducción en la esquina superior derecha de la vista de escena), podrás ver tu bote y orbitar alrededor con el mouse.

Superficie Subdividida del Agua

El objetivo de esta sección es generar una malla subdividida para usar como nuestra superficie de agua.

Para generar la superficie del agua, vamos a adaptar algunos códigos de este tutorial de generación de terreno. Cree un nuevo archivo de script llamado Water.js. Edite este script y cree una nueva función llamada GeneratePlaneMesh que se ve así:

1
Water.prototype.GeneratePlaneMesh = function(options){
2
    // 1 - Set default options if none are provided 

3
    if(options === undefined)
4
        options = {subdivisions:100, width:10, height:10};
5
    // 2 - Generate points, uv's and indices 

6
    var positions = [];
7
    var uvs = [];
8
    var indices = [];
9
    var row, col;
10
    var normals;
11
12
    for (row = 0; row <= options.subdivisions; row++) {
13
        for (col = 0; col <= options.subdivisions; col++) {
14
            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));
15
            
16
            positions.push(position.x, position.y, position.z);
17
            
18
            uvs.push(col / options.subdivisions, 1.0 - row / options.subdivisions);
19
        }
20
    }
21
22
    for (row = 0; row < options.subdivisions; row++) {
23
        for (col = 0; col < options.subdivisions; col++) {
24
            indices.push(col + row * (options.subdivisions + 1));
25
            indices.push(col + 1 + row * (options.subdivisions + 1));
26
            indices.push(col + 1 + (row + 1) * (options.subdivisions + 1));
27
28
            indices.push(col + row * (options.subdivisions + 1));
29
            indices.push(col + 1 + (row + 1) * (options.subdivisions + 1));
30
            indices.push(col + (row + 1) * (options.subdivisions + 1));
31
        }
32
    }
33
    
34
    // Compute the normals 

35
    normals = pc.calculateNormals(positions, indices);
36
37
    
38
    // Make the actual model

39
    var node = new pc.GraphNode();
40
    var material = new pc.StandardMaterial();
41
   
42
    // Create the mesh 

43
    var mesh = pc.createMesh(this.app.graphicsDevice, positions, {
44
        normals: normals,
45
        uvs: uvs,
46
        indices: indices
47
    });
48
49
    var meshInstance = new pc.MeshInstance(node, mesh, material);
50
    
51
    // Add it to this entity 

52
    var model = new pc.Model();
53
    model.graph = node;
54
    model.meshInstances.push(meshInstance);
55
    
56
    this.entity.addComponent('model');
57
    this.entity.model.model = model;
58
    this.entity.model.castShadows = false; // We don't want the water surface itself to cast a shadow 

59
};

Ahora puede llamar esto en la función de inicialización:

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

Deberías ver solo un avión plano cuando inicies el juego ahora. Pero esto no es solo un plano plano. Es una malla compuesta de mil vértices. Como desafío, intente verificar esto (es una buena excusa para leer el código que acaba de copiar).

Desafío n.º 1: desplaza la coordenada Y de cada vértice en una cantidad aleatoria para que el avión se parezca a la imagen siguiente.
A subdivided plane with displaced verticesA subdivided plane with displaced verticesA subdivided plane with displaced vertices

Olas

El objetivo de esta sección es dar a la superficie del agua un material personalizado y crear ondas animadas.

Para obtener los efectos que queremos, debemos configurar un material personalizado. La mayoría de los motores 3D tendrán sombreadores predefinidos para representar objetos y una forma de anularlos. Aquí hay una buena referencia para hacer esto en PlayCanvas.

Adjuntando un Sombreado

Creemos una nueva función llamada CreateWaterMaterial que define un nuevo material con un sombreador personalizado y lo devuelve:

1
Water.prototype.CreateWaterMaterial = function(){
2
    // Create a new blank material  

3
    var material = new pc.Material();
4
    // A name just makes it easier to identify when debugging 

5
    material.name = "DynamicWater_Material";
6
    
7
    // Create the shader definition 

8
    // dynamically set the precision depending on device.

9
    var gd = this.app.graphicsDevice;
10
    var fragmentShader = "precision " + gd.precision + " float;\n";
11
    fragmentShader = fragmentShader + this.fs.resource;
12
    
13
    var vertexShader = this.vs.resource;
14
15
    // A shader definition used to create a new shader.

16
    var shaderDefinition = {
17
        attributes: {           
18
            aPosition: pc.gfx.SEMANTIC_POSITION,
19
            aUv0: pc.SEMANTIC_TEXCOORD0,
20
        },
21
        vshader: vertexShader,
22
        fshader: fragmentShader
23
    };
24
    
25
    // Create the shader from the definition

26
    this.shader = new pc.Shader(gd, shaderDefinition);
27
    
28
    // Apply shader to this material 

29
    material.setShader(this.shader);
30
    
31
    return material;
32
};

Esta función toma el vértice y fragmenta el código del sombreador de los atributos del script. Así que vamos a definir aquellos en la parte superior del archivo (después de la línea pc.createScript):

1
Water.attributes.add('vs', {
2
    type: 'asset',
3
    assetType: 'shader',
4
    title: 'Vertex Shader'
5
});
6
7
Water.attributes.add('fs', {
8
    type: 'asset',
9
    assetType: 'shader',
10
    title: 'Fragment Shader'
11
});

Ahora podemos crear estos archivos de sombreado y adjuntarlos a nuestro script. Regrese al editor y cree dos nuevos archivos de sombreado: Water.frag y Water.vert. Adjunte estos sombreadores a su secuencia de comandos como se muestra a continuación.

Watervert and Waterfrag are attached to WaterInitWatervert and Waterfrag are attached to WaterInitWatervert and Waterfrag are attached to WaterInit

Si los nuevos atributos no se muestran en el editor, haga clic en el botón Analizar para actualizar el script.

Ahora ponga este sombreador básico en Water.frag:

1
void main(void)
2
{
3
    vec4 color = vec4(0.0,0.0,1.0,0.5);
4
    gl_FragColor = color;
5
}

Y esto en Water.vert:

1
attribute vec3 aPosition;
2
3
uniform mat4 matrix_model;
4
uniform mat4 matrix_viewProjection;
5
6
void main(void)
7
{
8
    gl_Position = matrix_viewProjection * matrix_model * vec4(aPosition, 1.0);
9
}

Finalmente, regrese a Water.js y haga que use nuestro nuevo material personalizado en lugar del material estándar. Entonces, en lugar de:

1
var material = new pc.StandardMaterial();

Haz:

1
var material = this.CreateWaterMaterial();

Ahora, si inicias el juego, el avión ahora debería ser azul.

The shader we wrote renders the plane as blueThe shader we wrote renders the plane as blueThe shader we wrote renders the plane as blue

Recarga Caliente

Hasta ahora, hemos configurado algunos sombreadores ficticios en nuestro nuevo material. Antes de escribir los efectos reales, una última cosa que quiero configurar es la recarga automática de código.

Descomentando la función de intercambio en cualquier archivo de script (como Water.is) habilite la recarga en caliente. Veremos cómo usar esto más adelante para mantener el estado incluso mientras actualizamos el código en tiempo real. Pero por ahora solo queremos volver a aplicar los sombreadores una vez que hayamos detectado un cambio. Los sombreadores se compilan antes de que se ejecuten en WebGL, por lo que necesitaremos volver a crear el material personalizado para desencadenar esto.

Vamos a verificar si el contenido de nuestro código de sombreado se ha actualizado y, si es así, recrear el material. Primero, guarde los sombreadores actuales en la inicialización:

1
// initialize code called once per entity

2
Water.prototype.initialize = function() {
3
    this.GeneratePlaneMesh();
4
    
5
    // Save the current shaders 

6
    this.savedVS = this.vs.resource;
7
    this.savedFS = this.fs.resource;
8
    
9
};

Y en la actualización, verifique si ha habido algún cambio:

1
// update code called every frame

2
Water.prototype.update = function(dt) {
3
    
4
    if(this.savedFS != this.fs.resource || this.savedVS != this.vs.resource){
5
        // Re-create the material so the shaders can be recompiled 

6
        var newMaterial = this.CreateWaterMaterial();
7
        // Apply it to the model 

8
        var model = this.entity.model.model;
9
        model.meshInstances[0].material = newMaterial;  
10
        
11
        // Save the new shaders

12
        this.savedVS = this.vs.resource;
13
        this.savedFS = this.fs.resource;
14
    }
15
    
16
};

Ahora, para confirmar esto, inicie el juego y cambie el color del avión en Water.frag a un azul más elegante. Una vez que guarde el archivo, ¡debería actualizarse sin tener que actualizar o reiniciar! Este fue el color que elegí:

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

Sombreados Vertex

Para crear ondas, necesitamos mover cada vértice de nuestra malla en cada cuadro. Parece que va a ser muy ineficiente, pero cada vértice de cada modelo ya se transforma en cada cuadro que renderizamos. Esto es lo que hace el sombreador de vértices.

Si piensas en un sombreador de fragmentos como una función que se ejecuta en cada píxel, toma una posición y devuelve un color, entonces un sombreador de vértices es una función que se ejecuta en cada vértice, toma una posición y devuelve una posición.

El sombreador de vértices predeterminado tomará la posición mundial de un modelo dado y devolverá la posición de la pantalla. Nuestra escena en 3D se define en términos de x, y y z, pero su monitor es un plano bidimensional plano, por lo que proyectamos nuestro mundo 3D en nuestra pantalla 2D. Esta proyección es de lo que se ocupan las matrices de vistas, proyecciones y modelos, y está fuera del alcance de este tutorial, pero si desea saber exactamente qué sucede en este paso, aquí hay una guía muy buena.

Así que ésta línea:

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

Toma aPosition como la posición mundial 3D de un vértice particular y la transforma en gl_Position, que es la posición final de la pantalla 2D. El prefijo 'a' en una posición es para indicar que este valor es un atributo. Recuerde que una variable uniforme es un valor que podemos definir en la CPU para pasar a un sombreador que conserva el mismo valor en todos los píxeles / vértices. El valor de un atributo, por otro lado, proviene de una matriz definida en la CPU. El sombreador de vértices se llama una vez para cada valor en esa matriz de atributos

Puede ver que estos atributos están configurados en la definición del sombreador que configuramos en Water.js:

1
var shaderDefinition = {
2
    attributes: {           
3
        aPosition: pc.gfx.SEMANTIC_POSITION,
4
        aUv0: pc.SEMANTIC_TEXCOORD0,
5
    },
6
    vshader: vertexShader,
7
    fshader: fragmentShader
8
};

PlayCanvas se encarga de configurar y pasar una serie de posiciones de vértice para aPosition cuando pasamos esta enumeración, pero en general se puede pasar cualquier matriz de datos al sombreador de vértices.

Moviendo los Vértices

Digamos que quieres aplastar el avión multiplicando todos los valores x por la mitad. ¿Deberías cambiar aPosition o gl_Position?

Probemos aPosition primero. No podemos modificar un atributo directamente, pero podemos hacer una copia:

1
attribute vec3 aPosition;
2
3
uniform mat4 matrix_model;
4
uniform mat4 matrix_viewProjection;
5
6
void main(void)
7
{
8
    vec3 pos = aPosition;
9
    pos.x *= 0.5;
10
    
11
    gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0);    
12
}

El avión ahora debería verse más rectangular. Nada extraño allí. Ahora, ¿qué sucede si, en cambio, intentamos modificar gl_Position?

1
attribute vec3 aPosition;
2
3
uniform mat4 matrix_model;
4
uniform mat4 matrix_viewProjection;
5
6
void main(void)
7
{
8
    vec3 pos = aPosition;
9
    //pos.x *= 0.5;

10
    
11
    gl_Position = matrix_viewProjection * matrix_model * vec4(pos, 1.0);    
12
    gl_Position.x *= 0.5;
13
}

Puede parecer igual hasta que empiece a girar la cámara. Estamos modificando las coordenadas del espacio de la pantalla, lo que significa que se verá diferente dependiendo de cómo lo esté mirando.

Entonces, así es como puedes mover los vértices, y es importante hacer esta distinción entre si estás en el mundo o en el espacio de la pantalla.

Desafío n.º 2: ¿Se puede mover la superficie de todo el avión unas pocas unidades hacia arriba (a lo largo del eje Y) en el sombreador de vértices sin distorsionar su forma?
Reto # 3: Dije que gl_Position es 2D, pero gl_Position.z existe. ¿Puede ejecutar algunas pruebas para determinar si este valor afecta algo y, de ser así, para qué se utiliza?

Añadiendo Tiempo

Una última cosa que necesitamos antes de que podamos crear ondas en movimiento es una variable uniforme para usar como tiempo. Declara un uniforme en tu sombreador de vértices:

1
uniform float uTime;

Luego, para pasar esto a nuestro sombreador, regrese a Water.js y defina una variable de tiempo en la inicialización:

1
Water.prototype.initialize = function() {
2
    this.time = 0; ///// First define the time here 

3
    
4
    this.GeneratePlaneMesh();
5
    
6
    // Save the current shaders 

7
    this.savedVS = this.vs.resource;
8
    this.savedFS = this.fs.resource;
9
};

Ahora, para pasar esto a nuestro sombreador, usamos material.setParameter. Primero establecemos un valor inicial al final de la función CreateWaterMaterial:

1
// Create the shader from the definition

2
this.shader = new pc.Shader(gd, shaderDefinition);
3
4
////////////// The new part

5
material.setParameter('uTime',this.time);
6
this.material = material; // Save a reference to this material

7
////////////////

8
9
// Apply shader to this material 

10
material.setShader(this.shader);
11
12
return material;

Ahora en la función de actualización podemos incrementar el tiempo y acceder al material utilizando la referencia que creamos para él:

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

Como paso final, en la función de intercambio, copie el valor anterior de tiempo, de modo que incluso si cambia el código, continuará incrementándose sin restablecerse a 0.

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

Ahora todo está listo. Inicie el juego para asegurarse de que no haya errores. Ahora vamos a mover nuestro avión por una función de tiempo en Water.vert:

1
pos.y += cos(uTime)

¡Y tu avión debería moverse hacia arriba y hacia abajo ahora! Como ahora tenemos una función de intercambio, también puede actualizar Water.js sin tener que reiniciar. Intente hacer que el incremento de tiempo sea más rápido o más lento para confirmar que esto funciona.

Moving the plane up and down with a vertex shaderMoving the plane up and down with a vertex shaderMoving the plane up and down with a vertex shader
Desafío # 4: ¿Puedes mover los vértices para que se vea como la ola de abajo?

Como una pista, hablé en profundidad sobre las diferentes formas de crear olas aquí. Eso fue en 2D, pero las mismas matemáticas se aplican aquí. Si prefieres solo echar un vistazo a la solución, aquí está la esencia.

Translucidez

El objetivo de esta sección es hacer que la superficie del agua sea translúcida.

Es posible que haya notado que el color que estamos devolviendo en Water.frag tiene un valor alfa de 0.5, pero la superficie sigue siendo completamente opaca. La transparencia en muchos aspectos sigue siendo un problema abierto en los gráficos por computadora. Una manera económica de lograrlo es usar blending.

Normalmente, cuando un pixel está a punto de dibujarse, verifica el valor en el buffer de profundidad contra su propio valor de profundidad (su posición a lo largo del eje Z) para determinar si se sobrescribe el píxel actual en la pantalla o se descarta a sí mismo. Esto es lo que le permite representar una escena correctamente sin tener que ordenar los objetos de nuevo al frente.

Con la mezcla, en lugar de simplemente descartar o sobrescribir, podemos combinar el color del píxel que ya está dibujado (el destino) con el píxel que está a punto de dibujarse (la fuente). Aquí puede ver todas las funciones de fusión disponibles en WebGL.

Para que el alfa funcione de la manera que lo esperamos, queremos que el color combinado del resultado sea la fuente multiplicada por el alfa más el destino multiplicado por uno menos el alfa. En otras palabras, si el alfa es 0.4, el color final debería ser:

1
finalColor = source * 0.4 + destination * 0.6;

En PlayCanvas, la opción pc.BLEND_NORMAL hace exactamente esto.

Para habilitar esto, simplemente configure la propiedad en el material dentro de CreateWaterMaterial:

1
 material.blendType = pc.BLEND_NORMAL;

¡Si lanzas el juego ahora, el agua será translúcida! Esto no es perfecto, sin embargo. Surge un problema si la superficie translúcida se solapa consigo misma, como se muestra a continuación.

Artifacts arise when a translucent surface overlaps with itself Artifacts arise when a translucent surface overlaps with itself Artifacts arise when a translucent surface overlaps with itself

Podemos solucionar esto utilizando alpha para cubrir, que es una técnica de muestreo múltiple para lograr transparencia en lugar de combinar:

1
//material.blendType = pc.BLEND_NORMAL;

2
material.alphaToCoverage = true;

Pero esto solo está disponible en WebGL 2. Durante el resto de este tutorial, usaré blending para mantenerlo simple.

Resumen

Hasta ahora hemos configurado nuestro entorno y creado nuestra superficie de agua translúcida con ondas animadas de nuestro sombreador de vértices. La segunda parte cubrirá la aplicación de flotabilidad en los objetos, la adición de líneas de agua a la superficie y la creación de líneas de espuma alrededor de los bordes de los objetos que se cruzan con la superficie.

La parte final cubrirá la aplicación del efecto de distorsión posterior al proceso bajo el agua y algunas ideas sobre dónde ir a continuación.

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.