11 octubre 2024

Cómo implementar el reflejo del agua en Godot

La imagen japonesa de nuestro ejemplo
Me encanta la sencillez de los shaders de Godot. Es increíble lo sencillo que resulta aplicar muchísimos de los efectos visuales que dotan de vida a un juego. He estado aprendiendo alguno de esos efectos y los quiero practicar en un escenario de pruebas que combine varios de ellos.

El escenario representará una típica postal japonesa en pixel-art: un cerezo, movido por el viento, soltando flores y enmarcado en una noche estrellada de luna llena, con el monte Fuji al fondo y un cauce de agua en primer plano. Aparte del movimiento del árbol, quiero implementar los efectos de las flores cayendo, lluvia, viento, movimiento de las nubes, rayos y el agua, tanto en su movimiento como en su reflejo. En este primer artículo cubriré como implementar el reflejo en el agua.

Lo primero es que el código fuente de este proyecto está en mi repositorio de GitHub. El enlace apunta a la versión del proyecto que implementa el reflejo. En futuros proyectos, marcaré con etiquetas los commits donde se incluya el código de ese proyecto y serán esos commits los que enlace en el artículo. Conforme avance con los artículos, es posible que toque código anterior, así que es importante que te bajes el commit que enlace para asegurar que veas el mismo código que en el artículo. 

Te recomiendo que bajes el proyecto y lo abras en Godot para que puedas ver cómo he conformado las escena. Verás que lo he hecho superponiendo Sprites2D en el siguiente orden de Z-Index:

  1. Cielo estrellado
  2. Luna
  3. Monte Fuji
  4. Nube grande negra
  5. Nube media gris
  6. Nube pequeña
  7. Césped
  8. Árbol
  9. Agua
  10. Occluder del cesped
Recuerda que los sprites con un Z-Index menor se pintan antes que los que tienen uno mayor, lo que permite que estos últimos tapen a los primeros.

El occluder del césped es una especie de parche que uso para tapar parte del sprite del agua y que no se note que este es rectangular. Más adelante veremos por qué lo he usado.

Configuración del agua


Vamos a centrarnos en el agua. Su Sprite2D (Water) no es más que un cuadrado en blanco. Lo he movido y redimensionado para que ocupase todo el tercio inferior de la imagen. Por dejar más ordenada la escena, lo he puesto en su propia capa (la 6) fijando tanto su "Visibility Layer", como su "Light Mask" en su configuración de CanvasItem. 

En esa misma sección, la de CanvasItem, he fijado su Z-Index a 1000 para asegurarme que está por encima del resto de sprites de la escena, salvando el occluder. Esto tiene sentido no sólo desde el punto de vista artístico, ya que el agua se encuentra en primer plano de la escena, sino también porque la técnica que vamos a ver en este artículo sólo permite reflejar las imágenes de aquellos objetos que tengan un Z-Index inferior al del nodo del shader. Ahora veremos la razón.

Por último, he dotado a la propiedad "Material" del agua con un ShaderMaterial basado en GDShader.

Vamos a ver el código de ese shader.

Shader del reflejo del agua


Este shader se encuentra en el fichero Shaders/WaterShader.gdshader del repositorio. Como no puede ser de otra manera, ya que la escena es 2D, se trata de un shader del tipo canvas_item.

Ofrece los siguientes parámetros al exterior:

Uniforms del shader

Todos ellos se pueden fijar desde el inspector, salvando el de screen_texture:

Inspector del shader

Recuerda que, como ya he contado en artículos anteriores, un uniform Sampler2D que tenga el atributo de hint_screen_texture se tratará como una textura que se actualizará constantemente con la imagen dibujada en la pantalla. Eso nos permitirá acceder a los colores presentes en la pantalla, para replicarlos en el reflejo del agua.

Este shader en concreto no deforma la imagen, sólo juega con sus colores, así que sólo tiene componente fragment():

Método fragment
Método fragment()

Como se pude ver en el código anterior, el código del shader se basa en tres pasos.

El primer paso es el de la línea 33. En él se calcula qué punto de la imagen en pantalla es el que se refleja en el punto del agua que está renderizando el shader.

Una vez que sabemos qué punto reflejar, se muestra la imagen de la pantalla en ese punto para obtener su color (línea 38).

Por último, el color a reflejar se mezcla con el que deseamos para el agua para dar lugar al color definitivo a mostrar al renderizar. Recuerda que, en GDShader, el color que guardes en la variable COLOR será el que finalmente se muestre en el pixel a renderizar.

El segundo paso (línea 38) ya lo hemos visto en otros shaders de artículos anteriores. Básicamente es cómo usar la herramienta de cuentagotas de Photoshop o Gimp: coge el color de un punto determinado de una imagen. Aquí sólo hay que tener en cuenta que, en el momento que ejecutamos el shader, la imagen está conformada sólo por aquellos sprites que se hayan dibujado hasta ese momento. Es decir, por los sprites con Z-Index inferiores al sprite con nuestro shader. Si lo piensas tiene sentido: no puedes muestrear un color de la pantalla que aún no se ha pintado en ella. Si echas en falta objetos en el reflejo, no pienses que se han vuelto vampíricos, los más probable es que su Z-Index sea superior al del nodo del shader.

Entendido lo anterior, vamos a ver cómo calcular la coordenada de la pantalla a reflejar:

Código del método get_mirrored_coordinate.
Método get_mirrored_coordinate()

En este método se pone a prueba tu comprensión de lo que son los distintos tipos de coordenadas que se usan en un shader 2D.

Recuerda que en 2D tienes dos tipos de coordenadas:
  • Coordenadas UV: Estas coordenadas tiene componentes X e Y y ambas van de 0 a 1. En el rectángulo del sprite del agua que queremos renderizar con nuestro shader,  el origen de coordenadas corresponde a la esquina superior izquierda del rectángulo. Hacia la izquierda crece el eje X y hacia abajo el eje Y. La esquina inferior derecha del rectángulo del agua se corresponde con el límite de coordenadas, (1, 1).
  • Coordenadas SCREEN_UV: Su orientación es la misma que la de las coordenadas UV, con la diferencia de que el origen de coordenadas está en la esquina superior izquierda de la pantalla y el límite de coordenadas en las esquina inferior derecha de esta. 
Fíjate que cuando el sprite del agua se muestra por completo en la pantalla, el rango que cubren las coordenadas UV resultan ser un subconjunto de las coordenadas SCREEN_UV.

Para entender mejor cómo funcionan los dos tipos de coordenadas UV, fíjate en el siguiente diagrama:

Tipos de coordenadas UV


El diagrama pretende representar de manera esquemática la escena con la que estamos trabajando. La zona roja representa la imagen mostrada en la pantalla; en nuestro caso comprendería: el cielo, las nubes, la luna, la montaña, el árbol, la hierba y el agua. Por su parte el rectángulo azul representa al agua, y en concreto al sprite rectangular al que se aplica el shader que estamos implementando.

Ambos sistemas de coordenadas comienzan en la esquina superior izquierda. El sistema de SCREEN_UV en la esquina superior izquierda de la pantalla; y el sistema UV en la esquina superior izquierda del sprite al que se le aplica el render. En ambos casos, el extremo del sistema de coordinadas, el punto (1,1), se encuentra en la esquina inferior derecha de la pantalla y del sprite respectivamente. Fíjate que se trata de coordenadas normalizadas, así que siempre nos moveremos entre el 0 y el 1, independientemente del tamaño real del elemento.

Para explicar cómo calcular el reflejo, he incluido un triángulo azul para representar el monte Fuji. El de la zona roja es la montaña en sí, mientras que el triángulo de la zona azul representa su correspondiente reflejo.

Supongamos que nuestro shader está renderizando la coordenada (0,66, 0,66), representado en la figura (por favor, no te tomes demasiado en serio las medidas de la figura, todo está colocado a ojo). El shader no sabe qué color mostrar como reflejo, para ello tiene que muestrear el color de un punto de la zona roja ¿pero cual?. 

Calcular la coordenada X del punto reflejado es fácil, porque es la misma que la del punto de reflejo: 0,66.

El truco está en la coordenada Y. Si el punto de nuestro reflejo está en la coordenada UV.Y = 0,66 significa que se encuentra a una distancia se 1 - 0,66 = 0,33 del borde inferior (vamos a redondear al segundo decimal para entendernos). En la composición que nos ocupa, en la que la imagen que se refleja está arriba y su correspondiente reflejo abajo, lo natural es esperar que la imagen aparezca verticalmente invertida al reflejarse. Por eso, si a al punto de reflejo le quedaba una distancia de 0,33 para llegar al borde inferior del rectángulo, eso significa que al punto reflejado se encontraba a 0,33 del borde superior de la pantalla. Por tanto la coordenada Y del punto reflejado será 0,33. Este es precisamente el cálculo que se hace en la línea 11 del listado del método get_mirrored_coordinate().

Por tanto, conforme el shader vaya recorriendo el rectángulo de izquierda a derecha y de arriba a abajo, para renderizar sus puntos, irá muestreando también la pantalla de izquierda a derecha y de abajo a arriba (observa la diferencia) para adquirir los colores de los puntos a reflejar.

Este proceso tiene dos efectos colaterales a tener en cuenta.

El primero es que si la superficie de reflejo (el rectángulo de nuestro shader) tiene menos anchura vertical que la superficie reflejada (la pantalla), como es nuestro caso, el reflejo será una versión "aplastada" de la imagen original. Se puede ver a qué me refiero en la imagen que abre el artículo. En nuestro caso, eso no es un problema, y es incluso deseable porque da más sensación de profundidad, ya que ese efecto es el que se daría si el plano del agua fuese longitudinal a nuestro eje de visión.

El segundo efecto colateral es que, si vamos recorriendo la pantalla para coger los colores a reflejar, habrá un momento en el que muestreemos el tercio inferior de la pantalla en el que está el mismo rectángulo del agua. Se producirá entonces un fenómeno interesante: ¿Qué pasará cuando el shader del reflejo empiece a muestrear pixels donde aparece el área sobre el que pinta el shader del reflejo?. Bueno, en el mejor de los casos, el color que se muestree será negro ya que estaremos intentando muestrear pixels sobre los que no se ha pintado aún (precisamente es en eso en lo que se ocupa nuestro shader), así que lo que se reflejará será una mancha negra. Para evitar eso, tenemos que asegurarnos de que nuestra labor de muestreo no baje de la altura de la pantalla donde empiece el rectángulo del agua.

Usando como ejemplo nuestro caso, el de la imagen que da comienzo al artículo, podemos estimar que el rectángulo del agua ocupa el tercio inferior de la pantalla, por lo que la labor de muestreo debería desarrollarse sólo entre SCREEN_UV.Y = 0 y SCREEN_UV.Y = 0.66. Para lograrlo, uso la línea 13 de get_mirrored_coordinate(). Ese método mix() te permite obtener el valor equivalente a su tercer parámetro dentro de un intervalo definido por los dos primeros. Por ejemplo, mix (0, 0.66, 0,5) señalaría a la mitad del intervalo entre 0 y 0,66, arrojando un resultado de 0,33.

Limitando así el rango vertical de los píxeles de pantalla, a muestrear para ser reflejados, conseguiremos que sólo se refleje la zona de la pantalla que nos interesa realmente.

Con todo lo anterior, ya tendremos la coordenada de la pantalla que debemos muestrear para obtener el color a reflejar en el pixel que nuestro shader está renderizando (línea 15 de get_mirrored_coordinate()).

Esa coordenada será por tanto la que se use en la línea 38 del método fragment() para muestrear la pantalla.

Obtenido el color de ese punto de la pantalla, podríamos volcarlo directamente en la propiedad COLOR del pixel que esté renderizando nuestro shader, pero eso crearía un reflejo con unos colores exactos al objeto reflejado, lo cual sería poco realista ya que lo normal es que la superficie de reflejo superponga cierta tonalidad sobre los colores reflejados, debido a suciedad en la superficie de reflejo o al mismo color de esta. En nuestro caso, vamos a suponer que el agua es azul y por tanto tendremos que superponer cierta tonalidad azul a los colores reflejados. De ello se encarga el método get_resulting_water_color() al que se llama desde la línea 40 del método principal fragment().

Método get_resulting_water_color()


El efecto principal que persigue este método es que el agua sea más azul conforme se acerque al borde inferior del rectángulo del agua. Por contra, cuanto más cerca estemos del borde superior deben predominar los colores originales reflejados. Por ese motivo, se utiliza el método mix() en la línea 29 de get_resulting_water_color(). Cuanto mayor sea el tercer parámetro (water_color_mix) más cerca estará el color resultante del valor marcado por el segundo parámetro (water_color, definido desde el inspector). Si el tercer parámetro fuera cero, mix() devolvería el color del primer parámetro (highlighted_color).

Partiendo de ese funcionamiento básico hay un par de matizaciones. En muchas implementaciones usan UV.Y como el tercer parámetro de mix(). Sin embargo, yo he preferido añadir la posibilidad de configurar un límite en cuanto al máximo de intensidad que queremos que tenga el color del agua. Eso lo he hecho en las líneas 25  28, usando el metodo clamp(). Ese método devolverá el valor de currentUV.y mientras este se encuentre en el intervalo limitado por 0.0 y water_color_intensity. En cambio de que el valor esté por debajo del límite inferior entonces el método devolverá 0.0, y si estuviera por encima del límite superior devolverá el valor de water_color_intensity. Como es el valor resultante de clamp() el que le pasemos a mix() tendremos que el valor del tercer parámetro nunca podrá ser superior al límite que marquemos en el inspector, a través del uniform water_color_intensity.

La siguiente matización, es que he añadido un realce del brillo de las imágenes reflejadas. Se hace entre las líneas 20 a 22. Para ello he configurado un uniform, mirrored_colors_intensity, para poder definir el valor de realce desde el inspector. En la línea 22 se usa ese valor para incrementar las tres componentes del color a reflejar, lo que supone en la práctica subirle el brillo al color. En la línea 22 me aseguro de que el valor resultante no sobrepasa los valores límites de los colores, aunque sospecho que es una comprobación redundante.

El occluder


Recuerda que decíamos que este shader sólo puede reflejar sprites que se hayan dibujado antes que el del shader. Es decir, sólo puede reflejar sprites que tienen un Z Index inferior al del shader. Dado que queremos que queremos reflejar todos los sprites (incluso, parcialmente, el de la hierba), eso supone que el rectángulo del agua debe quedar por encima de todos los demás. 

Si nos limitamos a poner el rectángulo del agua por encima de todo lo demás, el resultado sería como este:

El agua sin occluder

Quedaría raro que la orilla fuese perfectamente rectilínea. Por ese motivo he superpuesto un occluder.

Un occluder es un fragmento de sprite que actúa, a modo de parche, para tapar algo. Generalmente comparte el color del elemento sobre el que se superpone para que parezcan la misma pieza. En mi caso, he utilizado el siguiente occluder:

Occluder


El occluder que he usado tiene el mismo color que la hierba, por lo que, superpuesto sobre la parte inferior de la hierba, no parece que sean dos piezas distintas. Por otro lado, al tapar el borde superior del rectángulo del agua, hace la orilla más irregular, lo que da una apariencia más natural.

El resultado es el de la imagen que abría este hilo, que vuelvo a reproducir aquí para que se vea con todo detalle.

El agua sin occluder

Con esto doy por terminado este artículo. En otro de los que están por venir quiero implementar cierto movimiento en el agua, para dotarlo de vida.