kyrie.pe

Sobre ondas y shaders




También disponible en chino, cortesía de Indienova :)


Hola, soy Giacomo, uno de los desarrolladores de Rhythm Doctor, un juego de ritmo de un sólo botón, el cual se inspira de Rhythm Heaven. Tenemos una demo disponible, y la puedes jugar en rhythmdr.com. Actualmente lo que me dedico en el proyecto es programar la versión completa, que estimamos lanzarla a comienzos del próximo año.

En este artículo, vamos a mostrarles cómo utilizamos shaders para hacer un movimiento de una onda través de una línea (los sprites y animaciones están hechas por nuestro artista, Winston Lee):






La inspiración para esta animación fue el video musical de Arctic Monkeys, "Do I Wanna Know?" que tiene un movimiento similar. Como pueden ver, no es tan simple como mover una imagen estática (lo que originalmente hacemos en el juego):







Introducción


Hasta ahora, la mecánica principal del juego es presionar la barra espaciadora exactamente cuando el séptimo tiempo del compás suena en el juego. Cada tiempo está representado por un pulso en la línea de los latidos:






Este tipo de movimiento se hizo creciendo verticalmente un sprite de un pulso en cada tiempo.

Sin embargo, esta nueva mecánica es un patrón fluido de 'uno-dos', donde la señal sucede en el 'uno', la onda acelera de izquierda a derecha, y tienes que presionar la barra espaciadora en el 'dos'. El periodo de tiempo entre el 'uno' y el 'dos' lo canta la enfermera, parecido a Rhythm Heaven (aunque no vamos a profundizar tanto en el diseño del juego para este artículo):






Hafiz (el diseñador/compositor/programador del juego) ya había hecho la función matemática en Matlab que mostraba cómo quería la onda. Esta estaba compuesta de dos funciones: una onda triangular y una onda sinusoidal truncada.

La onda triangular es la onda que se ve como una curva de zigzag:






Y una onda sinusoidal truncada es una onda sinusoidal que es cero en la mayoría de su dominio, a excepción de un pequeño tramo. Más o menos se parece a una montaña en una planicie. Como pueden ver en la siguiente fórmula, la curva está truncada por su condición que es: si 'x' está entre 0 y el ancho (que en realidad es la longitud de la onda), se muestra la curva sinusoidal original. Si no, es sólo cero.






Ahora, la magia de conseguir la onda que necesitamos es multiplicar las dos funciones para conseguir una nueva función rdWave(x), que es exactamente lo que queríamos.







Ahora, tal vez te has dado cuenta que hay un nuevo parámetro llamado 'tElapsed' en la función 'truncSine'. Esta es una variable que usamos para desplazar la función horizontalmente, y así la onda puede moverse a través del eje x. Ahora está enlazado al tiempo que ha transcurrido desde que comenzó a ejecutarse, pero puedes establecerlo al valor que desees.

Después de entender cómo funcionaba la curva, tenía que buscar una manera de implementarlo en el mismo juego.

Esto era lo que necesitábamos
1. Hacer una onda continua que pueda moverse de manera fluida a través de la línea de los latidos.
2. La línea de la onda debe tener 1 píxel de ancho, para que se vea similar a la onda original.
3. Tiene que funcionar correctamente con los efectos que ya habíamos implementado, como glow, delineado y flashes, que ya están implementados como fragment shaders.

Lo primero que se me ocurrió fue hacerlo utilizando shaders, porque se me hace bastante cómo hacer efectos de imágenes utilizándolos. La otra razón es que podemos controlar exactamente cómo los píxeles se van a mostrar, lo cual es importante en juegos con gráficos pixel-art.

Finalmente, si se hace de esta manera, la onda sería dibujada en un gran quad (rectángulo) que sirve como lienzo para pintarlo, podemos añadir los demás efectos (glow, delineado, etc.) en el mismo shader y hacer que la onda se comporte como cualquier otro sprite en el juego, haciendo el manejo de éste mucho más simple.

Hablando sobre shaders...





Una rápida intro a fragment shaders


Antes de continuar, creo que deberíamos dar una breve introducción sobre shaders. Los shaders son programas, usualmente pequeños, que son ejecutados en el GPU (procesador gráfico). Todos los juegos modernos utilizan shader para hacer que se vean tan bien como se ven ahora, porque nos permiten aplicar luces, difuminado, y cientos de otros efectos geniales que se ven en los juegos actuales.

El beneficio de hacer estos efectos en el GPU en vez del CPU es que el GPU está mejor adecuado para ejecutar muchos procesos en paralelo. Y ya que los juegos tienen muchos triángulos y píxeles que pueden ser procesados simultáneamente, éstos son procesados en el GPU.

Hay otros tipos de shaders, cada uno con un distinto propósito, pero para dibujar nuestra onda vamos a utilizar un fragment shader (también llamado pixel shader). Un fragment shader es un programa que determina el color de cada píxel que se le ha asignado dibujar. ¿Cómo así? Ejecutando el shader en cada uno de éstos píxeles, y retornando un color para cada uno de éstos.

Hay muchos lenguajes para programar shaders, pero la mayoría son parecidos, así que no nos preocupemos mucho de eso, y utilizaremos GLSL, el que es utilizado por OpenGL y WebGL. Aquí les muestro el ejemplo de un shader:


void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}



Los objetos que son dibujados por el GPU están compuestos por triánglos, así que para hacer un quad para dibujar nuestra onda, vamos a necesitar dos triángulos. ¿Entonces, qué pasa si ejecutamos este código en un quad? Resultaría en esto:






Sí, es sólo un rectángulo rojo. ¿Entonces qué hace nuestro código? Comenzando por la primera línea, la función mainImage es básicamente el punto de entrada del código, el lugar donde nuestro código se comenzará a ejecutar. Y sus argumentos son out vec4 fragColor and vec2 fragCoord. El primero, fragColor, es la variable que tienes que cambiar para establecer el color del pixel que quieres mostrar, expresado en RGBA (rojo, verde, azul, y alfa). Ya que está marcado como out, éste funciona como una variable de retorno y cambiará el color del píxel. Y fragCoord te da la coordenada del píxel que estás procesnado en el mosmento.
Continuando con el código, si vemos la línea entre las llaves:


fragColor = vec4(1.0, 0.0, 0.0, 1.0);


Estamos asignando al píxel un color rojo. Y ya que no hemos hecho nada más, ¡todos los píxeles son rojos!

Hasta ahora, el shader no es tan útil. No si sólo vamos a pintar un gran rectángulo de color rojo. Pero si utilizamos la variable fragCoord, podremos determinar de qué color queremos pintar cada píxel. Antes de continuar, quería comentarles que estos ejemplos han sido hechos en Shadertoy, una página web donde puedes programar fragment shaders en quads (exactamente lo mismo que necesitamos en este momento). Si quieres ver o editar el código de éstos shaders, pasa el mouse por encima de cualquiera de ellos y haz click en el título para poder entrar al shader que desees).

Ahora, mira este código del siguiente shader, y su resultado:


void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    float y = fragCoord.y;
    
    if(y > 100.0)
        fragColor = vec4(0.0, 0.0, 0.0, 1.0);
    else
        fragColor = vec4(1.0, 1.0, 1.0, 1.0);
}





Lo que este shader está haciendo es que está dibujando de color negro los píxeles que tienen y > 100, y de rojo los que y < 100 (esto es, si tomamos el origen como la esquina inferior izquierda). Sabiendo la posición del píxel en el quad es suficiente para nosotros para poder hacer nuestro shader. ¡Ahora hagamos nuestra onda!



Dibujando la onda



Ya que la onda es una función matemática, tenemos que saber cómo dibujar una función gráficamente en el shader. Si te fijas en el ejemplo anterior, está la condicional y > 100. Esto represanta una inecuación. Así que la parte derecha de esta sería nuestra función. Así que por ejemplo, si esta condición sería y > sin(x) + 100, el quad mostraría esto:






Lo cual es una curva sinusoidal, pero tal vez una muy pequeña. Agregué 100 píxeles a la función sin(x) para que pueda estar un poco más arriba y sea más visible. Utilizando esta lógica, si escribimos nuestra triangle(x), truncSine(x) y rdWave(x), seríamos capaces de dibujarlos en la pantalla. Declararemos las tres funciones matemáticas como funciones en el código del shader:


float triangle(float x)
{
    // Triangle wave
    return abs(mod(x * 0.2, 2.0) - 1.0) - 0.5;
}

float truncSine(float x)
{
    // Half sine wave
    const float height = 40.0;
    const float sineWidth = 40.0;
    const float pi = 3.1415;
    
    if(x < 0.0 || x > sineWidth) 
        return 0.0;
    else
        return sin(x * pi/sineWidth) * height;
}

float rdWave(float x, float t)
{
    return truncSine(x - t) * triangle(x);
}



Básicamente este código es una representación muy similar a las funciones matemáticas definidas al comienzo del artículo. El único problema es cómo conseguir el argumento 't' en la función rdWave, que es el tiempo transcurrido, para poder mover la onda del seno truncado horizontalmente. Para nuestra suerte, Shadertoy nos da algunas variables globales que nos ayudan a hacer esto. La variable que necesitamos se llama iGlobalTime, y nos da el tiempo transcurrido desde que se mostró el shader. Ahora podemos implementar la función mainImage:



void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    const float yOffset = 100.0;
    
    float x = floor(fragCoord.x);
    float y = floor(fragCoord.y) - yOffset;

    if(y < rdWave(x, iGlobalTime * 40.0))  
        fragColor = vec4(1.0, 0.0, 0.0, 1.0);
    else
        fragColor = vec4(0.0, 0.0, 0.0, 1.0);
}



Aparte de las nuevas funciones, hemos hecho algunos cambios al shader anterior:
1. Agregué una nueva constante llamada yOffset. Esto se usa para modificar la variable y que nos permite mover la onda verticalmente de una manera rápida.
2. Le añadí una función floor() a fragCoord.x y fragCoord.y. Ya que Shadertoy nos da coordenadas de píxeles que terminan en .5 como 122.5, 123.5, 124.5, etc., estoy utilizando la función floor para que me de la parte entera del número, y así sean números enteros.
3. iGlobalTime ahora está multiplicado por 40 - sólo es una modificación puramente estética para que la onda se mueva más rápido y no se vea tan aburrida.

Ahora, si retrocedes el shader utilizando los controles, puedes ver la onda de Rhythm Doctor moviéndose:






We are almost there. Now we want the wave to be…



1 píxel de grosor


Hasta ahora, hemos trabajado con una inecuación para mostrar nuestra onda. ¿Ahora cómo hacemos para que se muestre como una curva de 1 píxel de grosor?

Utilizamos esta lógica:
1. Si el píxel está debajo de la curva de la función (la sección roja), significa que podría ser parte de la curva de 1 píxel que necesitamos.
2. Para saber si es parte de la curva, o para ser más precisos, si es un píxel que está en la parte del borde de la inecuación, buscaremos si uno de sus píxeles aledaños (los píxeles que están arriba, izquierda, derecha y abajo) NO son parte de la curva. De esta manera sabremos que el píxel actual está justo en el límite de las secciones roja y negra, y por ende, debe ser pintado.

Para saber si los píxeles aledaños son parte de la curva, utilizaremos la misma función rdWave(x) pero desplazando los valores de x e y, 1 píxel hacia el lado que corresponde, y evaluando cada uno de ellos si está debajo de la curva.

Finalmente, cambiamos el color de la onda a verde, para que sea igual a la onda de Rhythm Doctor =).



void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    const float yOffset = 100.0;
    
    float x = floor(fragCoord.x);
    float y = floor(fragCoord.y) - yOffset;
    float t = iGlobalTime * 40.0;
    
    bool center = rdWave(x      , t) >  y;
    bool right  = rdWave(x - 1.0, t) >  y; 
    bool left   = rdWave(x + 1.0, t) >  y; 
    bool up     = rdWave(x      , t) >  y + 1.0;
    bool down   = rdWave(x      , t) >  y - 1.0;

    if(center && !(right && left && up && down))
        fragColor = vec4(0.0, 1.0, 0.0, 1.0);
    else
        fragColor = vec4(0.0, 0.0, 0.0, 1.0);
}










Conclusión


¡Finalmente conseguimos lo que queríamos! Después de esto, integramos el shader en nuestro juego, que lo estamos desarrollando en Unity. Luego, le añadimos al shader los otros efectos que ya teníamos implementados, como el glow y delineado. Y para hacer las cosas más fáciles y flexibles, unimos las variables de las funciones del shader, como la longitud de onda y la frecuencia de la curva triangular en el inspector de Unity, que nos permite tener sliders y editores de curva para personalizar la curva como queremos. Aquí un video de esto en acción:






Gracias por llegar hasta aquí, ¡y espero que te guste el juego cuando salga!





Regresar