16 octubre 2024

Cómo implementar el movimiento del agua en Godot

Estampa japonesa con agua en movimiento
En mi anterior artículo expliqué cómo crear un shader para reflejar una escena sobre la superficie de un sprite. Sin embargo, un reflejo de ese tipo sólo es posible en una lámina de agua absolutamente inmóvil. Lo normal es que el agua tenga perturbaciones superficiales que distorsionen ese reflejo. En este artículo vamos a enriquecer el shader anterior para que muestre esa distorsión y que esta vaya cambiando con el tiempo.

Como en otros artículos, puedes encontrar el código de este en mi repositorio de GitHub. Te recomiendo que te lo bajes y lo habrás localmente en tu editor de Godot para seguir las explicaciones, pero asegúrate de bajarte el código del commit concreto que he enlazado. Estoy haciendo constantes cambios en el proyecto del repositorio, así que si te bajas el código de un commit posterior al que he enlazado es posible que lo que veas sea diferente a lo que te muestre aquí. 

No quiero repetir explicaciones, así que si aún no dominas lo que son las coordenadas UV de un shader de Godot, te recomiendo que leas el artículo que enlazaba al comienzo de este.

Por tanto, vamos a evolucionar el shader que aplicábamos al rectángulo del sprite del agua. El código de ese shader está en el fichero Shaders/WaterShader.gdshader.

Para parametrizar el efecto y poder configurarlo desde el inspector, he añadido las siguientes variables uniform:

Parámetros del shader configurables desde el inspector
Parámetros del shader configurables desde el inspector


Los parámetros wave_strength y wave_speed son intuitivos, el primero define la amplitud de las distorsión de la imagen reflejada, mientras que el segundo define la velocidad del movimiento de la distorsión.

Sin embargo, el parámetro wave_threshold necesita cierta explicación. Si pones un espejo a los pies de una persona esperas que el reflejo de dichos pies esté en contacto con los pies mismos. El reflejo es como la sombra, lo normal es que dé comienzo a partir de los pies. El problema es que el algoritmo que vamos a ver aquí puede distorsionar la imagen desde el mismo borde superior del reflejo haciendo que este no esté en contacto con el borde inferior de la imagen reflejada. Eso puede restarle realismo al efecto, así que he añadido el parámetro wave_threshold para definir a partir de qué tanto por uno del sprite se empieza a aplicar el efecto de distorsión. En mi ejemplo está fijado en 0.11, lo que significa que la distorsión se empezará a aplicar a partir de una distancia del borde superior del sprite del agua equivalente al 11% de su anchura (o sea, su UV.y = 0.11).

Por último, está el parámetro wave_noise. Se trata de una imagen. En un parámetro como este podríamos poner cualquier clase de imagen, lo que pasa es que para nuestro algoritmo de distorsión nos interesa una muy concreta. Nos interesa meter una imagen de ruido. Una imagen de ruido contiene manchas blancas y negras aleatorias. Los que ya pinten canas se acordarán de la imagen que se veía en las televisiones analógicas cuando se estropeaba la antena o esta no tenía buena cobertura; en este caso se trataría de una imagen muy similar. Podríamos buscar por internet una imagen de ruido, pero afortunadamente son tan comunes que Godot nos permite generarlas usando un recurso NoiseTexture2D.

Configuración de la imagen de ruido
Configuración de una imagen de ruido.

Una configuración como la de la figura bastará para nuestro caso. En realidad, sólo he cambiado dos parámetros respecto a la configuración por defecto. He desactivado la generación de Mipmaps, porque esta imagen no se verá en la lejanía: sí que he pedido que sea Seamless porque, como veremos más adelante, la recorreremos de manera continua y no querremos que haya saltos perceptibles cuando alcancemos el borde; y, por último, he configurado el algoritmo de generación de ruido a FastNoiseLite, aunque esto da más igual.

La utilidad de la imagen de ruido se va a ver ahora, cuando entremos al código del shader.

Para que te hagas una idea de las magnitudes a configurar para las variables anteriores, yo he usado los siguientes valores (no he incluido en la captura los parámetros que se configuraron en el artículo anterior, para el reflejo estático):

Valores de los parámetros del shader
Valores de los parámetros del shader.

Teniendo en cuenta lo anterior, si vemos la función principal del shader, la de fragment(), comprobaremos que hay muy poca diferencia respecto al shader de los reflejos.

Método fragment() del shader.
Método fragment() del shader.

Si comparamos con el código del artículo de los reflejos veremos que la novedad es que en la línea 50 se ha incluido una llamada a un nuevo método, get_wave_offset(), que devuelve un vector bidimensional que luego se suma a la coordenada UV utilizada para muestrear (en la línea 58) el color a reflejar.

¿Qué está pasando aquí? bueno, el efecto de distorsión del reflejo se consigue no reflejando el color que toca en un determinado punto, sino un color de un punto cercano. Lo que vamos a hacer es ir recorriendo la imagen del ruido. Esta imagen tiene colores que oscilan entre el negro y el blanco puro; es decir, de un valor 0 a 1 (simultáneamente en sus tres canales). Para cada coordenada UV que queramos renderizar del rectángulo del agua muestrearemos un punto equivalente en la imagen de ruido y la cantidad de blanco de ese punto la utilizaremos para desviar el muestreo del color del punto a reflejar en una cantidad equivalente en sus coordenadas X e Y. Dado que la imagen de ruido no tiene cambios bruscos entre negro y blanco, el resultado es que las desviaciones cambiarán gradualmente conforme recorramos las coordenadas UV, lo que hará que la distorsión resultante cambie gradualmente también.

Revisemos el código del método get_wave_offset() para entender mejor lo anterior:

Código del método get_wave_offset()
Código del método get_wave_offset()

Al método se le pasa como parámetro la coordenada UV que estemos renderizando y el número de segundos que han pasado desde el arranque del juego.

La línea 42 del método tiene que ver con el parámetro wave_threshold del que hablábamos antes. Al renderizar el rectángulo del agua desde su borde superior no queremos que la distorsión actué con toda su fuerza desde ese mismo borde porque podría generar aberraciones que llamarían la atención. Imagina por ejemplo, una persona que esté de pie en la orilla, justo al borde del agua; si la distorsión actuase con toda su fuerza desde el borde superior del agua, el reflejo de los pies de la persona aparecería separado de esos pies, lo cual sería poco natural. Así que la línea 42 sirve para hacer que la fuerza de la distorsión se incrementase gradualmente desde 0, hasta que UV.y alcanza el wave_threshold y, a partir de ahí, la fuerza se mantiene en 1.

La línea 43 es la que muestrea la imagen de ruido, con una llamada al método texture(). Si nos limitásemos a muestrear usando exclusivamente la coordenada UV, sin tener en cuenta ni el tiempo ni la velocidad, el resultado sería un reflejo con una imagen distorsionada estática. Vamos a suponer por un momento que fuese así, y que no muestreásemos teniendo en cuenta el tiempo ni la velocidad, lo que pasaría entonces es que para coordenada UV siempre muestrearíamos el mismo punto de la imagen de ruido y por tanto siempre obtendríamos el mismo vector de distorsión (para ese punto). Sin embargo, al añadir el tiempo, el punto de muestreo de la imagen de ruido sería diferente cada vez que renderizásemos la misma coordenada UV lo que provocaría que la imagen de distorsión fuese variando con el paso del tiempo. Entendiendo eso, los factores multiplicativos serían fáciles de entender: wave_speed hace que la variación del muestro de la imagen de ruido cambie con más rapidez lo que acelera también el cambio de la imagen distorsionada; al multiplicar el vector de distorsión resultante por wave_strength_by_distance reducimos la distorsión cuando estemos cerca del borde superior (tal y como explicábamos antes); y multiplicar por wave_strength hace que la amplitud de la distorsión sea mayor al aumentar el vector que devuelve el método.

Y ya está, el vector devuelto por get_wave_offset() es el que se usa luego en la línea 58 del método fragment() para desviar el muestreo del punto a reflejar en el agua. el efecto sería el de la parte inferior de esta figura:

Reflejo distorsionado en el agua.

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.

01 septiembre 2024

"Creating an RTS Game in Unity 2023" por Bruno Cicanci

Portada del libro
En todo aprendizaje hay un punto en el que sobrepasas el nivel inicial y a partir de ahí te adentras en el intermedio. Aún hay muchas cosas que ignoras, pero te encuentras con que la bibliografía existente se reduce drásticamente. Hay muchos libros para introducirte en el mundo del desarrollo de juegos, pero no tantos para el nivel medio-avanzado.

Empecé este libro porque el índice prometía tratar temas no habituales en los libros sobre Unity como, por ejemplo la creación de niveles, la creación de herramientas mediante la extensión del editor, la selección múltiple de unidades, formaciones, gestión de recursos, niebla de guerra o personalización de las compilaciones. Sin embargo, tenía mis reticencias, habida cuenta de mis experiencias pasadas con libros de PacktPub.

Afortunadamente, este libro ha demostrado estar por encima de la media respecto a otros de la misma editorial. Efectivamente, trata esos temas y más de una manera concienzuda y clara, usando un código fuente expresivo y de calidad. He percibido que el autor es un buen desarrollador y sabe lo que se hace. Gracias a ello, he aprendido unas cuantas cosas nuevas por el camino. Cosas que desconocía y que a partir de ahora enriquecerán mi manera de desarrollar en Unity. A estas alturas no es poco.

Para ilustrarlo todo, el libro desarrolla un juego de estrategia en tiempo real, a lo War of Warcraft, con perspectiva cenital. Como te puedes imaginar, es todo con recursos gratuitas y en modo maqueta, pero he de reconocer que toca muchos de las facetas que habría que tener en cuenta en un juego de esa temática.

Por poner un punto negativo, había una de las funcionalidades en la que tenía un especial interés, la niebla de guerra, y me ha decepcionado que uno de los elementos que utiliza para implementarla fuese de tipo legacy. Es imposible asegurar que un libro técnico siga actualizado pasados unos años, pero de ahí a reconocer tú mismo, en tu libro, que vas a utilizar una funcionalidad legacy... me parece muy feo.Todo lo que se explica en un libro técnico debería estar vigente en el momento de su elaboración.

Salvando ese pequeño detalle, lo cierto es que es un libro recomendable y del que se puede aprender bastante. Muy recomendado.

30 agosto 2024

Cómo implementar una niebla de guerra de dos niveles (FOW de dos niveles) en Godot

Mapa con FOW de dos niveles
En mi anterior artículo expliqué cómo se puede implementar un mapa de nivel en Godot con niebla de guerra. Aquel mapa aparecía al principio en negro, pero se iba destapando conforme el personaje se iba paseando por él. Se trataba de un mapa de un único nivel, porque una vez que el personaje transitaba por una zona, esta quedaba completamente destapada y eran visibles todos los enemigos que pasasen por ella, aunque el personaje principal estuviese lejos.

Otros juegos implementan FOW de dos niveles, en los que el área inmediatamente circundante al personaje, dentro de su rango de visión, queda completamente destapada, y los enemigos que pasan por ella plenamente visibles, mientras que las zonas que para las zonas que se hayan quedado atrás se ve sólo la parte estática del mapa, pero no los enemigos que pueda haber por allí. Generalmente, los mapas con FOW de dos niveles muestran con un tono más apagado las zonas alejadas, donde sólo se ve la parte estática, para distinguirla de la parte donde sí puede aparecer enemigos. 

Resulta que, cuando se tiene un mapa con un nivel de FOW, implementar un segundo nivel es tremendamente fácil. Así que voy a partir del punto en el que lo dejamos al final del artículo anterior. Si no lo has leído aún, es imprescindible que lo hagas, utilizando si quieres el enlace al comienzo de este. Para no repetirme, aquí voy a dar por entendido todo lo tratado en aquel artículo.

Tienes todo el código de este artículo en su repositorio de GitHub.

Partiendo de aquel mapa, debemos plantearnos que el shader que utilizábamos para el mapa (Assets/Shaders/Map.gdshader) lo que hacía era comprobar para cada pixel de la imagen del mapa si ese mismo pixel estaba coloreado en el mapa de la máscara renderizado por SubViewportMask. Si no estaba coloreado en el mapa de la máscara, asumía que el pixel no era visible y lo pintaba de negro, en vez de pintarlo con el color que le llegaba desde el SubViewport del mapa (recuerda que el TextureRect del mapa estaba conectado a la imagen renderizada por SubviewportMap). Conceptualmente, el mapa de la máscara se corresponde con las zonas ya recorridas.

En este nuevo mapa queremos darle una vuelta más de tuerca al tema distinguiendo las zonas ya recorridas (el mapa de la máscara) de las zonas directamente visibles. A las primeras, las dejaremos en un tono más apagado y sólo mostrando la parte estática, y a las segundas las seguiremos renderizando como antes.

¿Cuáles son las zonas directamente visibles? las del mapa de shapes. Las que se renderizan en SubviewportShapes con una cámara que sólo capta el círculo blanco colocado sobre el personaje. Hasta ahora habíamos usado el mapa de shapes para el shader de la máscara (Assets/Shaders/MapMask.gdshader), pero ahora lo usaremos también en el del mapa para saber qué píxeles del mapa se encuentran en la zona visible del jugador.

Una vez que ya sabemos distinguir, dentro de las zonas ya descubiertas, cuáles están en las zonas visibles y cuales no, tenemos que renderizar una imagen que sólo muestre la parte visible del mapa. Al igual que en el resto de los casos, esto se puede lograr con un SubViewport. En mi caso, me ha bastado con copiar SubviewportMap y a su cámara hija. A la copia la he llamado SubViewportStatic.

Jerarquía del mapa

Para que ese SubViewport sólo muestre la parte estática, hay que configurar la Cull Mask de su cámara para que sólo capte la capa 1, en la que he situado todos los elementos estáticos del escenario.

Captura del inspector de la cámara

Fíjate que la misma cámara, en el SubViewportMap, está configurada para captar las capas 1 y 4 (la 1 para los objetos estáticos del escenario y la 4 para los iconos identificativos de los personajes, colocados sobre ellos).

Para que el tono de la imagen captada por la cámara sea más apagado, tiene que dotarla de un recurso Environment (en el campo del mismo nombre). Cuando hayas creado dicho recurso, puedes pinchar en él para configurarlo. En el apartado Adjustments, he pinchado en Enabled y he bajado el brillo que venía por defecto a la mitad.

Configuración del Environment de la cámara

 Fíjate que el recurso Environment tiene muchísimos más apartados, así que imagina la cantidad de cosas que podrías hacerle a la imagen captada por la cámara.

Con eso ya tenemos una imagen más apagada del escenario, pero sin personajes. Justo la parte estática que necesitábamos. El shader del mapa recibirá esa imagen a través de un Uniform (Static Map Texture), que configuraremos a través del inspector para que apunte a SubViewportStatic.

Inspector del shader del mapa

Por debajo, el código del shader es muy parecido al del artículo anterior.

Nuevo código del shader

La principal novedad es el nuevo uniform que mencionábamos antes (línea 12) para recibir la imagen del mapa de elementos estáticos, y las líneas 16 a 18. Si el código llega hasta ese punto es porque en la línea 14 se ha llegado a la conclusión de que, al estar marcado con color en el mapa de la máscara, ese pixel se corresponde con una zona ya explorada. En la línea 16 se comprueba si ese pixel, además de ser de una zona explorada, se corresponde con un pixel coloreado en el mapa de shapes (es decir de las zonas directamente visibles). De no ser así, el pixel recibe el color del pixel equivalente en la imagen del mapa estático (línea 17). En caso de que el pixel sí estuviese coloreado en el mapa de shapes (y su canal rojo fuera distinto de 0), ese pixel recibiría el color por defecto que le llega desde SubViewportMap (el que muestra los iconos de los enemigos).

El resultado es el de la imagen que abre este artículo, con el área circundante al personaje mostrando a los enemigos más cercanos, las zonas exploradas más lejanas mostrando sólo los elementos estáticos del escenario, y las no exploradas coloreadas en negro.

26 agosto 2024

Cómo implementar un mapa de nivel con niebla de guerra (Fog Of War) en Godot

En los juegos que ofrecen un mapa del nivel es habitual tapar lar zonas que el jugador no ha explorado aún. Conforme el jugador avance por el terreno, esas secciones del mapa se irán descubriendo. 

Esto se conoce como niebla de guerra (Fog Of War, FOW) y, partiendo de ese concepto tan sencillo, se puede complicar bastante. Una sofisticación habitual es que el jugador tenga un rango de visión limitado, por lo que las zonas descubiertas del mapa sólo muestran a otros jugadores o NPC en el mapa si estos se encuentran dentro del rango de visión. Fuera de él el mapa sólo muestra los elementos estáticos del escenario: edificios, ríos, bosque, montañas, etc. Otra sofisticación es aplicar la niebla de guerra no sólo al mapa sino también al escenario 3D del nivel.

En este artículo explicaré el planteamiento más sencillo. Iremos destapando el mapa y las zonas desveladas conservarán plena visibilidad aunque el jugador se aleje de ellas. En cuanto entendamos la base veremos, que no es tan difícil aplicar las sofisticaciones.

Los elementos a los que me refiera en este artículo están en mi repositorio de DungeonRPG. Se trata del código que desarrollé mientras seguía el curso de GameDevTV "Godot 4 C# Action Adventure: Build your own 2.5D RPG", el cual recomiendo muchísimo. El curso no abarca mapas ni FOW, pero el minijuego que implementa es una base excelente para aprender a crearlos

Creación del mapa

Aquí no voy a pararme, porque ya lo expliqué, en realidad, en el artículo sobre cómo crear un minimap. Crear un mapa completo es similar. Sólo tienes que asegurarte de que la cámara ortográfica se sitúe en el centro del escenario (por encima) y tenga un tamaño de apertura (parámetro Camera3D > Size) lo suficientemente grande para que el enfoque abarque la totalidad del escenario. Por su parte, el SubviewPort al que proyecte la Camera3D tiene que cubrir la totalidad de la pantalla.

Te bastará con una escena que tenga la siguiente estructura:


A la hora de situar la escena anterior en el escenario, tendrás que ubicar la cámara en el punto adecuado del escenario. El problema es que no tendrás acceso a ella, por estar en su propia escena. Yo lo he resuelto, haciendo que el script del nodo raíz de la escena tenga el atributo [Tool], para la clase principal. Este atributo permite que el script puede ejecutar lógica mientras lo manipules dentro del editor.

El script que he puesto en la raíz de la escena está en la ruta Scripts/General/Map.cs. Su código fuente es Godot C# (como todo el que desarrollo), pero no creo que los desarrolladores que prefieran GDScript tengan problema en entenderlo. En concreto, al estar marcado con el atributo [Tool] su método _Process() se ejecuta constantemente en el editor, mientras haya un nodo con ese script en la jerarquía. El contenido de ese método es el siguiente:

 

Fragmento de Scripts/General/Map.cs

El método Engine.IsEditorHint() devuelve verdadero cuando el código llamante se esté llamando desde el editor. Es muy útil para definir código que queramos que se ejecute cuando estemos trabajando con el editor, pero no cuando ejecutemos el juego.

En este caso se hacen dos cosas: se busca un Marker3D para obtener su posición y dichas posición se emplea para ubicar el nodo Camera3D del mapa.

El Marker3D debe situarse como hijo de la escena, cuando la instanciemos en el escenario. Es similar a cuando instanciamos un CollisionShape como hijo de un CharacterBody3D.

Fragmento de Scripts/General/Map.cs

Lo que hace el método GetCameraPositionMarker() es mirar si la escena tiene algún Marker3D como hijo. En caso de que el usuario no haya configurado un Marker3D como hijo de la escena, lo normal en Godot es mostrar una advertencia con un icono amarillo.


La decisión de si mostrar o no esa advertencia la toma el método UpdateConfigurationWarnings(). al que llamamos en la línea 59 de la última captura de código. Ese método es propio de Godot y, para tomar su decisión, se basa en la información que le hayamos pasado en la implementación del método _GetConfigurationWarnings(), el cual es un método abstracto que pueden implementar las clases que hereden de nodos de Godot. En mi caso lo he implementado de la siguiente manera:

Fragmento de Scripts/General/Map.cs

Este método es muy sencillo. Devuelve un array de mensajes de alerta. Si el array está vacío, entonces UpdateConfigurationWarnings() interpreta que todo va bien y que no se requiere mostrar mensajes de alerta. Pero si, por el contrario, el array tiene alguna cadena, entonces muestra el icono de alerta con la cadena que hayamos incluido en el array como mensaje.

En mi caso, me he limitado a comprobar si _cameraPosition sigue siendo null (línea 87) tras la llamada a GetNodeOrNull() de la línea 58 de GetCameraPositionMarker(). Si resulta ser null (línea 87) es señal de que el usuario no ha puesto un Marker3D como hijo de la escena, por lo que se añade un mensaje de error al mensaje retornado.

Un Marker3D no es más que un Node3D con una apariencia llamativa. Es estupendo para marcar lugares dentro del escenario y que tus objetos puedan usarlos como referencias. La idea en este caso es situar el Marker3D en el punto del escenario donde queramos colocar la cámara del mapa (lo normal será el centro del nivel). 

Una vez que ya cuenta con el Marker3D, el método _Process() llama al método UpdateCameraConfiguration() (desde la línea 80), para configurar la posición de la cámara.

Fragmento de Scripts/General/Map.cs

Ese método actualiza la configuración de dos cámaras, la del mapa y la de las zonas de visibilidad (shapes), que veremos en un momento. Con la posición del Marker3D se configura la posición de la cámara del mapa, mientras que su tamaño y su relación de aspecto (líneas 66 y 67) se configuran a partir de los que hayamos configurado en el inspector, a través de campos exportados:

Fragmento de Scripts/General/Map.cs

En mi caso, he creado un InputAction para que, cuando se pulse la tecla M, la pantalla quede cubierta con el mapa, y que este desaparezca en cuanto dejemos de pulsar dicha tecla:

Fragmento de Scripts/General/Map.cs

El resto de elementos del GUI se suscriben a las señales MapShown (línea 106) y MapHidden (lçinea 110), para saber cuándo deben ocultarse o volverse a mostrar.

El mapa de zonas de visibilidad

El mapa anterior es el completo del nivel. El que ofreceríamos al jugador si este pudiera conocerlo desde el principio del juego. 

Pero hemos decidido que el jugador no tenga visibilidad infinita, sino que más bien pueda ver hasta determinada distancia. Esta distancia suele modelarse como un círculo, alrededor del jugador, cuyo radio es el rango de visión máximo del jugador.

Lo que vamos a hacer es esconder el mapa detrás de una capa negra, la niebla de guerra (FOW), y sólo abriremos agujeros en la niebla de guerra por donde pase el círculo de visión del jugador. Para hacerlo usaremos el concepto de máscara que ya empleamos en el artículo de los minimaps. En aquel caso, usamos la imagen de un círculo para definir que la parte visible del minimap fuera circular. En este caso he empleado una técnica similar ya que he creado una imagen dinámica con fondo negro en la que todo lo que aparezca de blanco definirá las zonas del mapa que se podrán ver.

Para hacer esa imagen dinámica he utilizado una aproximación similar a la de los iconos de los personajes en el minimap. He situado un sprite circular encima del personaje principal y he asignado dicho sprite a la capa 5. 


Esa capa se usa en exclusiva para el FOW. Ni la cámara del juego, ni la del minimapa incluyen la capa 5 en culling map, por lo que para ellas, ese círculo será invisible. La que sí incluye esa capa es la cámara que he añadido a su propio Subviewport en la escena del mapa con FOW.


Como se puede ver en la figura, esa cámara sólo ve los círculos blancos de la capa 5 y el resto lo coloreará de negro, que es el color que he configurado de background. El resultado será que el Subviewport renderizará una pantalla negra por la que se paseará un círculo blanco.

Aparte de lo mencionado de las capas y el color de background, es importante que el Subviewport del mapa (SubViewPortMap en la figura) y el de las zonas de visibilidad (SubViewPortShapes), así como sus respectivas cámaras, tengan la misma configuración para que tengan la misma escala y cubran la misma zona del escenerio, de manera que se solapen de manera perfecta.

Configuración de una máscara dinámica

Llegados a este punto tenemos un mapa, y una imagen dinámica con un círculo blanco que se mueve con el personaje. Si quisiéramos destapar única y exclusivamente el mapa en torno al jugador, ya tendríamos casi todos los elementos para dar el paso final. Pero queremos complicar un poco más la cosa, ya que queremos que el personaje vaya dejando un rastro por donde pase y que el mapa sea visible a largo de ese rastro. Resulta necesario por tanto que el círculo de visón vaya dejando un rastro.

Esa función la llevará un Subviewport más (SubViewportMask en la última figura) que albergará un ColorRect completamente en negro. La imagen que renderice este SubviewPort será la que usemos para tapar la del mapa, a modo de FOW. La peculiaridad es que ese ColorRect lleva un shader propio:




Ese shader se encuentra en Assets/Shaders/MapMask.gdshader y es muy sencillo:


El tema de los shaders da para libros enteros, pero voy a intentar hacer una introducción muy bestia. Utilizando la terminología de Godot, este shader "exporta" dos variables marcándolas con el término "uniform": shapesTexture y prev_frame_text. El valor de la primera se fija a través del inspector y la segunda va marcada con un atributo especial (hint_screen_texture) que marca esa variable para que Godot le de valor automáticamente con los datos de la última imagen renderizada por el Subviewport que sea padre del nodo del shader (en este caso SubViewPortMask). 

El método fragment() de un shader se ejecuta para cada uno de los pixels de la pantalla (sabes cual gracias a las variables UV y SCREEN_UV) y en función del pixel en el que te encuentres, puedes modificar el color que finalmente se renderizará en ese pixel, usando la variable COLOR. Por defecto, el método fragment() se ejecuta sobre una pantalla sin datos previos, por lo que si quieres tener en cuenta la imagen anterior, debes marcar una variable uniform con el atributo (hint_screen_texture) y asegurarte que el Subviewport del shader no se borre en cada fotograma, dejando su valor Clear Mode en Never:


He dejado el Update Mode en Always, para que los cálculos que vamos a ver ahora se realicen aunque el mapa no sea visible, de manera que, cuando el jugador decida, verlo se encuentre actualizado.

Si continuamos con el shader MapMask veremos que he cogido el valor que tiene el pixel en el mapa de zonas de visibilidad (shapesTexture, al que le asigné en el inspector una referencia a SubViewportShapes), así como el valor del pixel en el fotograma anterior (previousColor).

El mapa de zonas de visibilidad sólo tiene dos colores, el negro del fondo y el blanco de los círculos de visibilidad, por lo que, en el momento en que el pixel de la zona de visibilidad sea distinto de negro ya sabremos que es un pixel del mapa que debe hacerse visible por lo que lo marcamos de blanco fijando su COLOR a ese valor (línea 10 del shader). Para saber si el pixel es distinto de 0 me limito a mirar el canal rojo. Dado que los círculos que he usado son blancos, estoy seguro de que el canal rojo también se ve afectado a su paso, dado que ese color tiene componentes en los 3 canales. Si el pixel de la zona de visibilidad es negro significa que es una zona del mapa que no está dentro del rango de visibilidad en este momento, pero pudo estarlo en momentos previos, así que, como queremos dejar un rastro de zonas visibles, dejamos ese pixel con el valor que ya tenía en el fotograma previo (línea 12). 

El efecto de este shader será que el círculo de visibilidad del personaje se comportará como un pincel e irá dibujando de blanco, sobre un fondo negro, el rastro del recorrido del personaje.

La vista final

Nos queda mezclar la máscara dinámica y el mapa, para mostrar el resultado en algún lado. Ese será el papel del TextureRect Map, el cual he situado lo más abajo posible de la jerarquía de la escena, para asegurarme de que se dibuje por encima de todos los demás elementos, tapándolos.

Para que este TextureRect pueda leer la información del mapa, he hecho que su propiedad Texture referencie al SubViewportMap. Si no hiciéramos nada más, esto valdría para que este TextureRect reprodujese fielmente lo que renderizase SubViewportMap.

Pero nosotros queremos incorporar la información de la máscara dinámica, motivo por el cual este TextureRect tiene su propio shader, el cual puedes encontrar en Assets/Shaders/MapMask.gdshader.

Este shader consigue el efecto que queremos en unas pocas líneas:


En este caso se exportan dos variables, ambas configurables a través del inspector. En maskTexture he dejado una referencia a SubviewportMask (el de la máscara dinámica). Por su parte, en fogColor he dejado el color que queremos darle a las zonas cubiertas por el FOW.

El shader mira el valor del pixel de la textura dinámica, en la misma posición que el pixel que estamos renderizando. Si el pixel de la máscara dinámica (maskSample) es negro entonces renderizo en negro el pixel de la imagen final. Aquí vuelvo a comprobar solamente el canal rojo ya que como mis máscaras con blancas, sé que tienen presencia en el canal rojo.  En caso de que el pixel de la máscara no fuese negro significaría que el ese pixel debe ser visible, por lo que no hacemos nada y dejamos que renderice el color que le llega de SubViewportMap.

Conclusión

El resultado es el de la imagen que abre este artículo. Un mapa con FOW que va ampliando sus zonas visibles conforme el jugador se vaya paseando por el escenario.

Como comentaba al comienzo del artículo, este se trata del caso más básico de FOW. Quiero probar en un artículo posterior la opción de un mapa con FOW en dos niveles, la que tapa por completo la parte nunca explorada y la que limita la visión a los elementos estáticos en aquellas zonas exploradas, pero fuera del rango de visión del personaje. Se trata de una evolución que creo que es muy sencilla de conseguir, pero que no quiero incluir en este artículo para no alargarlo más.

En cuanto a aplicar FOW al escenario 3D y no sólo al mapa, se trata de algo que aún estoy intentando entender cómo podría hacerse. En cuanto dé con la tecla lo reflejaré en un artículo por aquí.

Espero que os haya resultado interesante.

06 agosto 2024

Cómo probar juegos multijugador en Unity

Estoy haciendo el curso de GameDevTV sobre desarrollo de juegos multijugador con Unity. Cuando lo acabe, ya os contaré mi opinión, como ya he hecho con otros cursos. Hasta entonces, quería contaros un truco nuevo que he descubierto en Unity: cómo crear varias instancias de un juego multijugador para probarlo en un PC.

Tener esta capacidad resulta indispensable durante la fase de desarrollo para asegurar que sincronizamos bien todos los elementos del juego entre los diferentes participantes. Godot incluye de manera nativa la capacidad de arrancar hasta 4 instancias independientes del juego para probarlo. Sin embargo, en Unity no ha sido así, al menos hasta ahora.

El curso que estoy haciendo, por ejemplo, se limita a probar las cosas compilando y ejecutando una instancia del juego, externa al editor (con la opción "Build and Run"), y ejecutando una segunda instancia dentro del editor de Unity. No se explica por ningún lado cómo probar con mas de dos jugadores y es que la cosa no resultaba sencilla. Buscando por internet he llegado a la conclusión de que lo más parecido que tenía Unity a la funcionalidad de Godot era una extensión de terceros llamada ParrelSync.

La novedad es que en Unity 6 van a incluir por fin la posibilidad de la que ya disfrutan los usuarios de Godot. Aunque aún no hay versión estable de Unity 6, ya se puede probar todo lo que vamos a ver en las versiones Preview que ya se pueden instalar desde el Unity Hub.

La funcionalidad se denomina Multiplayer Play Mode y te permite simular hasta 4 jugadores. Uno desde el editor y otras 3 instancias virtuales del juego.

Para instalarlo, tenemos que ir al Package Manager el editor de Unity 6 e instalar el paquete Multiplayer Play Mode del Unity Registry.

Para activar una instancia virtual del juego tienes que ir a Window > Multiplayer Play Mode y marcar cuántas instancias vas a querer arrancar. 



Aquellas instancias que marques iniciarán un proceso de arranque y, cuando pasen a estar activas, aparecerán sus respectivas ventanas. No te preocupes por el largo tiempo que emplean para arrancar. Sólo tardan tanto la primera vez. A partir de ahí, la información queda cacheada y los siguientes arranques son mucho más rápidos.


A partir de ese momento, cada vez que arranquemos el juego desde el editor, este se reproducirá tanto en el editor como en las instancias virtuales de manera, con lo que podremos simular el comportamiento de jugadores independientes.

Para operar como uno de los jugadores, bastará con seleccionar la ventana de su instancia e interactuar con el juego como haría el jugador.

Para parar el juego lo haremos desde el editor, como haríamos con un juego monojugador. Eso parará el juego en todas las instancias virtuales.

Para hacer que las ventanas desaparezcan sólo tendremos que desmarcarlas de la ventana del Multiplayer Play Mode.

Con esto ya tendríamos todo lo que necesitamos para probar cualquier juego multijugador. 

Espero que te haya resultado interesante.

19 julio 2024

Curso "Unity C# Mobile Game Development: Make 3 Games From Scratch" de GameDevTV

El curso que hice sobre desarrollo de juegos en Godot, para plataformas móviles, me supo a poco. Así que decidí hacer el equivalente para Unity y comparar las dos plataformas. En concreto, elegí otro curso de GameDevTV: "Unity C# Mobile Game Development: Make 3 Games From Scratch". En este caso lo compré en Udemy, donde ya tengo otros cursos.

Se basa en desarrollar tres proyecto: una especie de Angry Birds, un juego de carreras y un Asteroids. Todos son muy sencillos, pero tocan lo básico: configuración de físicas, tratamiento de cámara y mucho, muchísimo, de configuración de UI.

A nivel específico de plataformas móviles, el curso explica:

  • Configuración del editor para simular plataformas móviles.
  • Compilación para Android e iOS.
  • Gestión de entradas a través de pantallas táctiles, incluidas las entradas multidedo (multi-touch)
  • Notificaciones.
  • Anuncios (Ads).
  • Microcompras (in-app purchases).
Está bien en general, pero hay varios puntos en los que se nota que necesita una actualización. Lo más sangrante es que el mismo profesor reconoce alguno de esos puntos. Por ejemplo, al llegar a la parte de los Ads, la plataforma de Unity te ofrece dos opciones. El profesor admite que la opción clásica ya está obsoleta y que el desarrollo de Unity va por la segunda opción, ¿pero adivinas cuál explica al final? justo la opción que ha reconocido que es obsoleta.

También ocurre que hay varios momentos en los que Unity ha evolucionado y los componentes que usa en la clase no aparecen o no se comportan exactamente como en la clase. Cuando te pase eso y te quedes atascado te recomiendo que eches un ojo a la sección de comentarios de la clase de turno. Verás que está hasta arriba de alumnos preguntando, pero el profesor no contesta nunca. Aun así, hay que reconocer que GameDevTV pone a profesores asociados a responder las preguntas, aunque en la mayor parte de las ocasiones sus respuestas creo que no aportan mucha ayuda. Al final, las mejores pistas las consigues de los comentarios de otros alumnos. Yo he dejado también algunas aportaciones, por si sirven de ayuda a alguien.

Como plataforma, Unity está muchísimo más madura para hacer juegos, y obtener ganancias con ellos en plataformas móviles, que Godot. Se nota que tienen componentes para todo, si bien es cierto que en muchos momentos he echado de menos la mayor claridad de Godot y, sobre todo, su mayor rapidez a la hora de desarrollar. Se me llevan los demonios cada vez que modifico un script de Unity, cambio al editor para probarlo y tengo que quedarme 5 o 6 segundos mirando a la pantalla mientras Unity hace uno de sus famosos "domain reload". En comparación, el flujo de trabajo en Godot es mucho más ágil, con ejecuciones prácticamente instantáneas (incluso usando C#), por lo que no resulta tan cansino. En mi opinión, si quieres hacer un juego para móviles con la intención de sacarle rendimiento mediante anuncios o microcompras, la mejor opción es Unity, aunque yo no descartaría Godot para hacer un prototipo, ni mucho menos para juegos de PC (para los que Godot creo que está muy bien preparado).

Como conclusión: el curso está bien, merece la pena. A mí me ha permitido asentar conceptos, algunos de los cuales ya me había presentado el curso de Godot, y además me ha permitido entender por fin cómo configurar UIs en Unity (parece mentira, pero no les había cogido el tranquillo hasta ahora). Pero, en todo caso, ten en cuenta que algunos contenidos están antiguos y no funcionarán como esperas. Así que, si te atascas, lo mejor es que acudas a la sección de comentarios porque lo mismo descubres que la culpa no es de tu código. Con esa salvedad en mente, creo que es un curso al que se puede sacar mucho partido.

12 julio 2024

Curso "Master Mobile Game Development with Godot 4: From Concept to App Stores" de GameDev.TV

Sigo avanzando con los cursos incluidos en el último pack de Humble Bundle que compré con cursos de GameDev.TV.

Esta vez le ha tocado al curso "Master Mobile Game Development with Godot 4: From Concept to App Stores". Se puede encontrar tanto en GameDev.TV como en Udemy. Comprar el curso en uno u otro portal dependerá de tus preferencias y del que te ofrezca un mayor descuento en un momento determinado. El curso al final acaba siendo exactamente el mismo.

Está enfocado a la programación de juegos para plataformas móviles Android o iOS.

Se supone que el curso tiene un nivel intermedio aunque en realidad los primeros dos bloques de lecciones, en los que construyes un juego de plataformas muy básico sin tocar un móvil, funcionan muy bien como introducción (o recordatorio, si es tu caso) de toda la parte 2D de Godot.

El tercer bloque ya se mete en la parte más específica del curso y explica cómo configurar Godot para generar paquetes instalables en Android e iOS. A nivel de jugabilidad para móviles, se queda en enseñar cómo usar el acelerómetro del terminal y a detectar si la pantalla tiene escalado y a corregirlo.

El cuarto y el quinto bloques se me hicieron muy pesados, porque se centran en montar toda la parte de UI y a dotar de funcionalidad a los botones de los menús. Está bien, si no has visto ese tema antes para Godot, pero si ya te pilla sabiéndolo esos dos bloques se te pueden hacer largos. Aún así, tampoco recomiendo saltárselos porque lo que viene después se apoya en gran parte en el UI que montemos en esta parte.

El sexto bloque es el más interesante de todos porque se centra en cómo interactuar con la API de Google Play para ofrecer microcompras en tus juegos. Para ello, usa el plugin oficial de Godot. Este bloque tiene bastante complejidad y un par de puntos que no están bien explicados, no por dejadez del autor, sino porque son cosas que me parece que sólo ocurren cuando usas Google Play por primera vez y, una vez resueltas, ya no vuelven a pasar y te olvidas de ellas. Como el autor ya ha hecho varios proyectos en Google Play, no le han pasado esas cosas, y su lección avanza como un tiro, sin darse cuenta de que a los que hacemos esto por primera vez vamos a sufrir más eventualidades de las que salen. Así, por ejemplo, me ha parecido corta la explicación sobre cómo darse de alta como desarrollador en Google Play. También hay una lección, la 6.12 "Acknowledging", donde hay un hilo entero de comentarios de gente que nos hemos estrellado con lo mismo, sin que el autor hay acudido al rescate. Al final, tras muchas búsquedas por Internet, conseguí resolver mi problema y dejé mi solución en los comentarios. La solución fue desbloquear el modo desarrollador, no en el móvil (que eso sí se explica en el curso), sino en la misma aplicación de Play Store. Sospecho que el autor del curso no lo explicó porque es la típica cosa que desbloqueas al principio y luego te olvidas... hasta que vuelves a estrellarte con ello. Pero en un curso introductorio como este es un fallo no mencionarlo. 

El séptimo bloque es sobre cómo publicar la aplicación, pero, comparando con todo lo anterior, es una auténtica tontería por su sencillez.

El autor utiliza GDScript, pero yo iba traduciéndolo a Godot C# sobre la marcha, sin problema alguno hasta la parte de las microcompras, que me forzó a practicar cómo llamar a un componente en GDScript (el plugin) desde C#. Nada complejo. Una vez que te das cuenta de que se trata de cargar el plugin como un GodotObject y, a partir de ahí, llamar a sus métodos internos con el método Call(), todo se vuelve sencillísimo. Si acaso hay que tener en cuenta que el plugin te devuelve tipos propios de Godot, como Array o Dictionary, que no son exactamente los nativos de C#. En ese último caso reconozco que tuve que analizar el código fuente (en java) del plugin para entenderlo. Al autor del curso, todo esto de los tipos le resultó transparente, dado el tipado dinámico de GDScript.

El autor se explica bien. Es turco, pero su inglés es uno de los más claros que he oído en un curso, y si aun así lo de oír inglés fuera un problema para ti tienes los subtítulos, los cuales son bastante certeros por las pocas veces que he tenido que tirar de ellos. Me ha parecido también que las prácticas de programación que emplea el autor son "sanas" y que por ello acabas armando un programa bien estructurado. Eso de "aguas abajo tiras de los métodos de las referencias, mientras que aguas arriba devuelves señales" es un principio que yo ya venía sospechando por mi propia experiencia, pero que esta es la primera vez que lo oigo explícitamente mencionado, y es un principio de diseño que me ha gustado mucho. 

Por tanto, la sensación general es que, con sus pequeñas faltas, el curso es globalmente bueno e interesante. Lo recomiendo.

25 junio 2024

Cómo implementar Gizmos y Handles en Godot

Los Gizmos te permiten dibujar líneas y formas en la ventana del editor para representar visualmente valores de un objeto.

Por ejemplo, imagina que quieres crear un componente que implemente un cono de visión:


Un cono de visión se modela con dos parámetros:

  • Alcance: Es la distancia entre el observador y el objeto observado a partir de la cual este último deja de ser visible.
  • Apertura: Es el ángulo del cono. Generalmente se calcula como un ángulo desde el vector que marca la dirección de mirada del observador. Si el objeto se encuentra en un ángulo respecto al observador mayor que el ángulo de apertura, entonces el objeto no es visible.
Por tanto tu componente puede tener esos dos parámetros, en forma de campos, y los puedes editar desde el inspector de dicho componente. El problema es que resulta muy complicado fijar los valores idóneos de esos parámetros sin tener una referencia visual del resultado. Es más fácil comprender qué queda dentro del cono de visión si, al fijar la apertura en el inspector, en el editor de la escena aparece un triángulo representando el cono de visión con ese ángulo.

Para representar esas formas geométricas, que nos ayuden a representar los valores de los campos de nuestros componentes, es para lo que sirven los Gizmos. 

Por supuesto, los Gizmos sólo aparecerán en el editor. Son ayudas para el desarrollador y para el diseñador de niveles, pero serán invisibles al iniciar el juego final. 

En realidad, si has estado practicando con Godot, ya habrás usado Gizmos, por ejemplo al fijar la forma de un CollisionShape. Las cajas, círculos y demás formas geométricas que aparecen en el editor cuando quieres configurar un CollisionShape son precisamente Gizmos, dibujados tal y como vamos a ver aquí.

Precisamente, si te fijas en los CollisionShape, verás que, además del Gizmo, aparecen unos puntos sobre los que pulsar y arrastrar para cambiar la forma del componente. Esos puntos se denominan Handles y son las "agarraderas" para manipular lo que representemos en el editor. En este artículo también veremos cómo implementar nuestros propios Handles.

Visión lateral de un CollisionShape con forma de caja. Los bordes azules son el Gizmo, representando la forma, y los puntos rojos son los Handles, para cambiar la forma.


Los Gizmos se asocian a los nodos que complementan, de tal manera que Godot sepa qué Gizmos activar en caso de que se incluya un nodo determinado en la escena. Como hemos visto, muchos de los nodos de Godot ya tienen Gizmos asociados (como hemos visto con los nodos CollisionShape).

Creación de un nodo personalizado


Como no quiero enredar con los Gizmos por defecto de los nodos de Godot, vamos a empezar añadiendo un nodo personalizado a la lista de nodos de Godot. A ese nodo personalizado le asociaremos un Gizmo, con sus respectivos Handles, que nos sirva como ejemplo.

En nuestro ejemplo, para crear ese nodo personalizado he creado la carpeta nodes/CustomNode3D, dentro de la del proyecto. En esa carpeta podemos crear el script de nuestro nodo personalizado pulsando en la carpeta con el botón derecho del ratón y eligiendo Create New > Script.... Saldrá una ventana emergente como la siguiente, en la que he completado los valores para este ejemplo:



Una vez generado el script, sólo necesitaremos que implemente 2 propiedades públicas Vector3 exportadas. Yo las he llamado NodeMainPoint y NodeSecondaryPoint:

[Export] public Vector3 NodeMainPoint { get; set; }
[Export] public Vector3 NodeSecondaryPoint { get; set; }

No pongo captura, porque más adelante incluiremos código en la parte de set.

La idea es que al arrastrar los Handles en el editor se actualice el valor de las dos propiedades anteriores. A la inversa también debe funcionar: si cambiamos el valor de las propiedades en el inspector, los Handles deberán resituarse en las posiciones señaladas por las propiedades. Por otro lado, también dibujaremos un Gizmo en forma de línea, desde el origen del nodo hasta las posiciones de las propiedades.

Con eso debería bastar para ilustrar las mecánicas principales: representar con Gizmos propiedades de un nodo y cambiar dichas propiedades mediante Handles.

Lo siguiente será crear una carpeta addons dentro del proyecto de Godot. Los Gizmos y los Handles personalizados se consideran plugins por lo que el consenso es depositarlos en una carpeta addons dentro del proyecto.

Hecho eso, iremos a Project > Project Settings ... >  Plugins y pulsaremos el botón Create New Plugin. Nos saldrá una ventana como la de la siguiente figura, en la que yo ya he rellenado los valores para el ejemplo:



Hay que tener en cuenta que la carpeta que fijemos en el campo Subfolder, de la ventana anterior, se creará dentro de la carpeta addons que mencionábamos antes. Lo mismo pasará con el script del plugin, definido en el campo Script Name

Fíjate también en que he desactivado el check de Activate now?. Los plugins en GDScript se pueden activar inmediatamente, con el código de la plantilla que se genera, pero los plugin en C# requieren de cierta configuración previa, por lo que darán error si se intentan activar con el código de la plantilla por defecto. Tampoco es que el error resultante rompa nada, pero sale una ventana de error que hay que cerrar y queda feo. Así que lo mejor es desactivar ese check y dejar la activación para un paso posterior, como veremos a continuación.

Hecho lo anterior, se habrá generado una carpeta, dentro de addons, con un archivo plugin.cfg y el script de C# de la ventana anterior. El propósito de ese script será registrar en Godot el tipo representado por CustomNode3D para que podamos elegirlo en la lista de nodos del engine. Recuerda que CustomNode3D hereda de Node3D, así que tiene sentido incluirlo junto al resto de nodos.

Como en cualquier otro plugin, CustomNode3DRegister tendrá que heredar la Clase EditorPlugin e implementar los métodos _EnterTree() y _ExitTree(). En el primer método registraremos CustomNode3D como un nodo elegible en la lista de nodos, y en el segundo método lo daremos de baja para que deje de aparecer en dicha lista. La implementación es sencilla:

addons/custom_node3D_register/CustomNode3DRegister.cs


Como se puede ver, en el método _EnterTree() cargamos dos cosas: el script asociado al nodo personalizado y el icono con el que queremos representar el nodo en la lista de Godot. Para el icono he usado el que se incluye en todos los proyectos de Godot, copiándolo desde la raíz en la carpeta del nodo personalizado.

Luego, asociamos esos elementos a un nodo base, mediante el método AddCustomType() que es el que inscribe el nodo personalizado en la lista de nodos. Dado que el script del nodo personalizado hereda de Node3D es el que hemos usado como clase base en la llamada a AddCustomType(). Con esta llamada, cuando elijamos CustomNode3D en la lista de nodos, se creará un Node3D en la escena y se le asociará el script que hayamos definido.

La implementación de _ExitTree() es justo la contraria: usamos el método RemoveCustomType() para borrar al nodo personalizado de la lista de nodos.

Para hacer que se ejecute el registro, compilaremos el juego para que se hagan efectivos los cambios sobre CustomNode3DRegister.cs. Hecho eso, iremos a Project > Project Settings ... >  Plugins y nos aseguraremos de marcar el check de Enable del plugin CustomNode3DRegister. Eso hará que se ejecute su lógica y se inscriba a nuestro nodo personalizado en la lista de nodos. A partir de ahí, podremos localizar a nuestro nodo en la lista y añadirlo a la escena:



Añádelo a la escena antes de avanzar.

Creación de un Gizmo para el nodo personalizado


Ahora que ya tenemos nuestro nodo personalizado, vamos a crear un Gizmo que represente visualmente sus propiedades.

Los Gizmos se consideran addons, así que lo lógico será crear una carpeta addons/CustomNode3DGizmo para alojar sus ficheros. Esos ficheros serán dos: un script para definir el Gizmo, que será una clase que herede de EditorNode3DGizmoPlugin, y otro script para registrar el Gizmo, que heredará de EditorPlugin y se parecerá bastante al que hemos usado para registrar al nodo personalizado.

El script del Gizmo es el que tiene toda la enjundia. Yo lo he llamado CustomNode3DGizmo.cs. Como ya he dicho, debe heredar de EditorNode3DGizmoPlugin e implementar algunos de sus métodos.

El primero de esos métodos es _GetGizmoName(). Este método se limita a devolver una cadena con el nombre del Gizmo:

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs

Algo más intrigante es el método _HasGizmo(), al que se le pasarán todos los nodos de la escena hasta que el método devuelva true para alguno de ellos, señalando que el Gizmo debe aplicarse a ese nodo. Por tanto, en nuestro caso, el método deberá devolver true cuando se le pase un nodo de tipo CustomNode3D:

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs

Aquí hay que tener en cuenta cierto problema que se da en C# y no en GDScript. Aunque la comparación con "is" es sintácticamente correcta, en la práctica no funciona en Godot C# a no ser que la clase con la que comparemos esté marcada con el atributo [Tool]. Así que este es un buen momento para añadirle ese atributo a la cabecera de la clase CustomNode3D:

nodes/CustomNode3D/CustomNode3D.cs

En realidad, esto es una anomalía. No deberíamos necesitar el atributo [Tool] para hacer funcionar esa comparación. De hecho, el código equivalente de GDScript (el que aparece en la documentación oficial) no lo requiere. Se trata de un bug reportado varias veces en los foros de Godot y pendiente de solución. Hasta que lo resuelvan, el workaround en C# es usar el atributo [Tool].

El siguiente método a implementar es el constructor de la clase de nuestro Gizmo. En GDScript usaríamos el método _init(), pero en C# usaremos el constructor de la clase:


addons/custom_node3D_gizmo/CustomNode3DGizmo.cs


En ese constructor crearemos los materiales a aplicar a nuestro Gizmo y a sus Handles. No se trata de un material completo, como el que haríamos con un shader, sino más bien un conjunto de estilos a aplicar a las líneas que dibujemos para nuestro Gizmo. Se crean usando los métodos CreateMaterial(), para el Gizmo, y CreateHandleMaterial(), para el Handle. Ambos aceptan, como primer parámetro, una cadena con el nombre que queramos darle al material. Ese nombre se utiliza con el método GetMaterial() para obtener una referencia a dicho material. Esa referencia puede ser útil, por ejemplo, para asignársela a una variable de tipo StandardMaterial3D con la que personalizar a fondo el material, fijando los valores de sus respectivas propiedades. Sin embargo, lo normal es que no tengas que recurrir a ese grado de personalización y te baste con fijar el color de las líneas usando el segundo parámetro del método CreateMaterial(). Sin embargo, el método CreateHandleMaterial() no acepta ese segundo parámetro, por lo que no queda más remedio que recurrir, como decía antes, al método GetMaterial() (líneas 19 y 20 de la captura anterior) para obtener las referencias al material, con las que fijar el valor de su propiedad AlbedoColor (líneas 21 y 22).

En el constructor del ejemplo, he configurado que las líneas que se tracen entre el origen de coordenadas y la posición marcada por la propiedad NodeMainPoint utilicen el color rojo. Las líneas que vayan hasta la posición de la propiedad NodeSecondaryPoint usarán el color verde. He configurado los materiales de los respectivos Handles para que usen el mismo color.

Por último, queda el método _Redraw(). Es el que se encarga de dibujar los Gizmos cada vez que se llame al método UpdateGizmo() con el que cuentan todos los Node3D.

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs


El método _Redraw() es como nuestra pizarra, y lo normal con una pizarra es borrarla al principio para pintar sobre ella. Esa es la razón por la que se suele llamar al método Clear() al principio del método (línea 29 de la captura anterior).

Luego recopilamos en un array de Vector3 las posiciones de las líneas que queremos dibujar. En este caso queremos trazar una línea entre el origen de coordenadas y la posición marcada por la propiedad NodeMainPoint, así que guardamos ambos puntos en el array (líneas 33 a 37 de la captura anterior). 

Con los Handles hacemos lo mismo, guardando en otro array los puntos en los que queramos que aparezca un Handle. En este caso, como queremos que aparezca un Handle en el extremo de la línea, el marcado por la posición de NodeMainPoint, sólo metemos esa posición en el array de Handles (líneas 38 a 41 de la captura anterior).

Finalmente, usamos el método AddLines() para dibujar las líneas a lo largo de las posiciones recogidas en el array (línea 42); y, por otro lado, el método AddHandles() para posicionar Handles en las posiciones recogidas en su array (línea 43). Fíjate que, en ambos casos, pasamos el material que define el estilo con el que queremos que se dibujen los elementos.

No lo he incluido en la captura anterior, pero la mecánica para dibujar la línea y el Handle de un segundo punto (en este caso NodeSecondaryPoint) sería la misma: confirmaríamos sus arrays de posiciones y los pasaríamos a los métodos AddLines() y AddHandles().

Manipulación de un Gizmo usando Handles


En este punto, nuestro Gizmo dibujará líneas y Handles dependiendo de los valores recogidos en las propiedades del nodo al que se asocia (en este ejemplo, CustomNode3D). Sin embargo, si pulsamos en los Handles, no pasará nada. Estos permanecerán inmóviles.

Para interactuar con los Handles, hay que implementar unos cuantos métodos más de EditorNode3DGizmoPlugin en nuestra clase CustomNode3DGizmo. Sin embargo, la documentación oficial de Godot no cubre dichas implementaciones. Si sigues el tutorial de la documentación oficial, te quedarás en la sección anterior de este artículo. Es extrañísimo, pero no hay nada en la documentación oficial que explique cómo manipular los Handles. Todo lo que sigue a partir de aquí lo he deducido probando e interpretando los comentarios de cada función a implementar. Quizás, a partir de este artículo, haga una aportación a la documentación de Godot para resolver esta carencia.

Veamos qué métodos hay que implementar en CustomNode3DGizmo para poder manipular los Handles que ubicamos en el método _Redraw().

El primero de todos es _GetHandleName(). Este método debe devolver una cadena con el nombre identificativo del Handle. Lo normal es devolver el nombre de la propiedad que se ve modificada por el Handle:

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs


En la captura anterior hay que destacar dos cosas. 

La primera es que podríamos haber devuelto el nombre de la propiedad como una cadena puesta a mano, pero usando el método nameof() nos aseguraremos de que si refactorizamos el nombre de la propiedad, usando nuestro IDE, esta parte del código se actualizará también. 

La segunda cosa a destacar es que cada Handle está identificado con un entero, de tal manera que podemos saber para qué Handle se está pidiendo el nombre en función del parámetro handleId que se le pase al método _GetHandleName(). El entero de cada Handle depende del orden en el que metiésemos los Handles al llamar al método AddHandles() en el método _Redraw(). Por defecto, si dejas vacío el parámetro ids de AddHandles(), el primer Handle que le pases se le asignará el id o, al segundo el 1 y así. Sin embargo, si te fijas en la captura que puse más atrás de _Redraw(), yo no dejé vacío el parámetro ids. En vez de eso, le pasé un array con un único elemento. Ese elemento era un entero, definido como una constante, para forzar que a ese Handle se le asignase ese entero como id, y poder usar esa constante como identificador a lo largo del código.

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs

Una vez que se implementa cómo identificar cada Handle, el siguiente paso es definir qué valor devuelve el Handle cuando pinchamos en él. Eso se hace implementando el método _GetHandleValue().

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs


Al igual que con _GetHandleName(), a _GetHandleValue() también se le pasa el identificador del Handle para el que se está pidiendo el valor. Con el parámetro gizmo podemos obtener el nodo asociado al Gizmo, usando el método GetNode3D() (línea 130 de la captura anterior). Y una vez que tenemos una referencia al nodo podemos devolver el valor de la propiedad asociada a cada Handle (líneas 133 a 136).

Cuando pulses en un Handle, fíjate en la esquina inferior izquierda de la vista de escena del editor, aparecerá una cadena conformada por lo que devuelva _GetHandleName() y lo que dé _GetHandleValue() para ese Handle.

Ahora viene lo que puede ser la parte más difícil de este tutorial: usar el Handle para asignarle un valor a la propiedad asociada del nodo. Eso se hace implementando el método _SetHandle():

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs

A ese método se le pasa un parámetro gizmo, con el que podemos tener acceso al nodo asociado, con el método GetNode3D(), tal y como hacíamos con _GetHandleValue(). También se le pasa el identificador del Handle para el que se está fijando el valor. Pero lo más importante es que se le pasa la cámara que está visualizando la escena y la posición en pantalla del Handle.

En este método tendremos que interpretar la posición en pantalla del Handle, para fijar, en función de esa posición, el valor de la propiedad del nodo asociada al Handle. En este caso parece fácil: la posición del Handle debe ser el valor que se guarde en la propiedad asociada, dado que tanto NodeMainPoint como NodeSecondaryPoint son posiciones. El problema es que os Handles se arrastran sobre la superficie bidimensional de la pantalla, esa es la razón por la que el parámetro screenPos es un Vector2, por lo que no es inmediato saber a qué coordenada tridimensional del escenario corresponde ese punto en la pantalla.

Cuando añadimos un nodo Camera3D a una escena, dicho nodo se representa con el siguiente Guizmo:


A mí me resulta muy esclarecedor pensar que nuestra cabeza esta en la punta de la pirámide del Gizmo y que miramos a una pantalla que se encuentra en la base. Sobre dicha pantalla se retroproyecta la escena que se encuentra delante de la cámara.

Supongamos que tenemos un objeto A en el escenario. La posición de la pantalla en la que se pinta A (vamos a llamar a esa posición Ap) es el resultado de trazar una línea recta entre el objeto y el foco de la cámara y ver en qué punto corta con el plano de retroproyección de la cámara:


  
Hasta ahí, todo es muy fácil. De hecho, la clase Camera3D tiene el método UnprojectPosition() al que se le pasa la posición tridimensional (Vector3) de un objeto de la escena y devuelve la posición bidimensional (Vector2) de ese objeto en la pantalla. En nuestro caso, si le pasásemos a UnprojectPosition() la posición de A, el método nos devolvería Ap (entendida esta como una posición bidimensional de la pantalla).

Ahora supongamos que en la posición Ap de la pantalla tuviéramos un Handle que representase la posición de A, y que arrastrásemos el Handle hasta la posición Bp de la pantalla, ¿Cómo calcularíamos la nueva posición del objeto en el espacio tridimensional? (vamos a llamar a esa nueva posición B). Lo lógico será aplicar el proceso inverso al que hicimos para retroproyectar el objeto sobre el plano de la cámara. Para ello trazaríamos una línea entre el foco de la cámara y Bp. Siguiendo ese razonamiento, la nueva posición del objeto estará a lo largo de esa línea, ¿pero dónde?, ¿en qué punto de la línea?

La clave está en darse cuenta de que el objeto se desplazará en un plano (Pm) paralelo al de la cámara. La intersección entre ese plano y la línea que parte del foco de la cámara, atravesando Bp, será la nueva posición del objeto (B):



El nodo Camera3D cuenta con un método ProjectPosition(). Sirve precisamente para convertir coordenadas bidimensionales de pantalla en coordenadas tridimensionales de escena. El método acepta dos parámetros. El primero es una posición bidimensional de la pantalla (en nuestro ejemplo Bp). Con ese parámetro, el método trazará la línea que parte del foco de la cámara y atraviesa la coodenada dimensional de la cámara (Bp). El segundo parámetro del método se denomina zDepth y es un float que señala la distancia desde el foco de la cámara a la que se situará el plano Pm con el que debe cortar la línea.  




Esa distancia será la longitud de la línea que parte del foco de la cámara e incida perpendicularmente sobre el plano Pm. En el diagrama anterior será la distancia entre el foco (F) y el punto D. 

¿Pero cómo podemos calcular esa distancia? Usando trigonometría. Si recordamos nuestra enseñanza secundaria, el coseno del ángulo entre el segmento FA y FD equivale a la relación de FD dividido entre FA. Así que FA multiplicado por el coseno nos dará FD.

Resulta que el cálculo anterior es tan habitual que la biblioteca de operaciones vectoriales de los engines de desarrollo lo incluyen con el nombre de Dot Product. Con ese operador podemos transformar la fórmula anterior en la siguiente:

La fórmula anterior quiere decir que si hacemos el Dot Product del vector FA sobre el vector normalizado de FD nos dará el vector completo FD. 

Es habitual visualizar el Dot Product como una proyección de un vector sobre otro. Si colocases una luz muy potente por detrás del vector FA, enfocándola de manera perpendicular al vector normalizado FDn, la sombra que proyectaría FA sobre FDn sería precisamente FD.

Por lo tanto, para conseguir la distancia FD, que nos sirva como parámetro zDepth sólo necesitamos hacer el Dot Product de FA sobre el valor normalizado de FD, que es precisamente el vector Forward del nodo Camera3D (por defecto, el valor inverso de su eje local Z). 

Todo este razonamiento se reduce a unas pocas líneas, en el método GetZDepth():

addons/custom_node3D_gizmo/CustomNode3DGizmo.cs


En dicho método, la variable vectorToPosition corresponde a FA y cameraForwardVector a FDn. El resultado devuelto por el método zDepth es FD y es lo que se utiliza en las llamadas a ProjectPosition(), en _SetHandle() para fijar las nuevas posiciones de NodeMainPoint y NodeSecondaryPoint.

Resuelto _SetHandle(), el único método que quedaría por implementar es _CommitHandle(). Este método es el responsable de ir construyendo el historial de modificaciones que vamos realizando sobre nuestros Handles, de manera que podamos movernos por dicho historial cuando hagamos undo/redo (Ctrl+Z o Ctrl+Shift+Z).


addons/custom_node3D_gizmo/CustomNode3DGizmo.cs


El historial se construye sobre un objeto de tipo EditorUndoRedoManager (en este caso _undoRedo), que se obtienes desde el objeto EditorPlugin que registra el Gizmo (en este ejemplo CustomNode3DGizmoRegister, del que hablaremos ahora) y le pasa por su constructor una instancia del EditorUndoRedoManager.

Con el EditorUndoRedoManager, cada entrada en el historial se crea con el método CreateAction() (línea 78 de la captura anterior). En cada entrada debe haber acciones que se ejecuten en caso de que el usuario haga Undo y de Do (llamadas cuando se haga Redo). Esas acciones pueden implicar fijar una propiedad, con los métodos AddDoProperty() y AddUndoProperty(), o ejecutar un método, con AddDoMethod() y AddUndoMethod(). Si la acción directa sólo ha implicado cambiar el valor de una propiedad, lo normal es que para deshacer esa acción sólo tengas que poner la propiedad a su valor anterior. Pero si la acción directa disparó algún tipo de método, además del cambio de método de la propiedad, entonces lo más probable es que necesite lanzar otro método para deshacer lo hecho por el primero. 

En el caso de este ejemplo, me limito a cambiar el valor de las propiedades de customNode3D, así que para el historial de acciones basta con usar los métodos Add...Property(). Estos métodos piden como parámetro la instancia dueña de las propiedades a modificar, la cadena con el nombre de la propiedad a manipular y el valor al que fijar dicha propiedad. Cada acción captura el valor que le pasemos al método Add...Property(). En el caso de AddDoProperty() le pasamos el valor que tiene en ese momento la propiedad (líneas 84 y 91); mientras que en el caso de AddUndoProperty() le pasamos el valor del parámetro restore, que contiene el valor rescatado del historial cuando hacemos Undo.

Cuando _CommitHandle() se llama con el parámetro cancel a verdadero, es equivalente a un Undo sobre el Handle, así que restauramos el valor de restore sobre la propiedad (líneas 101 a 106).

Por último, pero no menos importante, una vez que hayamos dado forma a los cambios de propiedad y llamadas de método que conforman la entrada en el historial, registraremos dicha entrada con CommitAction() (línea 109).

Actualización del Gizmo tras los cambios


La representación visual de un Gizmo puede tener que actualizarse por dos razones:
  1. Porque hayamos cambiado los campos del nodo representado desde el inspector.
  2. Porque hayamos manipulado los Handle del Gizmo.
La actualización del Gizmo se hace llamando al método UpdateGizmos() que tienen todos los Node3D. 

La cuestión es desde dónde llamar a ese método para asegurar que se actualizan los dos tipos de cambios anteriores. En las capturas de código anteriores verás que hay varias llamadas a UpdateGizmos() comentadas. Son pruebas de posibles sitios desde donde ejecutar ese método. Todas las llamadas comentadas tenían algún problema: o no se llamaban en alguno de los dos casos anteriores o se actualizaban a trompicones, como si hubiera algún problema de rendimiento. 

Al final, mis pruebas me han hecho llegar a la conclusión de que, en mi caso, el mejor sitio desde donde llamar a UpdateGizmos() es desde las mismas propiedades de CustomNode3D que estamos modificando. Por ejemplo, en el caso de NodeMainPoint:

nodes/CustomNode3D/CustomNode3D.cs

Al llamar a UpdateGizmos() desde el set de la propiedad, estando esta exportada, nos aseguramos que el método se llame tanto cuando se modifique la propiedad desde el inspector como desde el _SetHandle() del Gizmo.

Registro del Gizmo

Igual que pasó con el nodo personalizado, nuestro Gizmo también debe registrarse en el editor, para que este sepa que tiene que contar con él. Para ello volveremos a usar un plugin que haga la labor de registro.

addons/custom_node3D_gizmo/CustomNode3DGizmoRegister.cs

Crearemos el plugin con el mismo método con el que creamos el plugin CustomNode3DRegister, sólo que esta vez, el plugin se llamará CustomNode3DRegister y se basará en un script C# del mismo nombre.

En este caso cargamos el script de C# en el que hemos configurado el Gizmo y lo instanciamos, pasándole a su constructor una instancia de EditorUndoRedoManager, llamando al método GetUndoRedo() (líneas 13 y 14).

Hecho eso, registramos la instancia del plugin, entregándoselo al método AddNode3DGizmoPlugin() (línea 15).

También de manera similar a como hicimos en el registro del nodo personalizado, en este caso también aprovechamos el método _ExitTree() para dar de baja al Gizmo mediante el método RemoveNode3DGizmoPlugin() (línea 21). 

Finalizado el script, podremos activar el plugin desde Project > Project Settings... > Plugins, y la próxima vez que añadamos un CustomNode3D a la escena podremos usar los Handles del Gizmo. 

Puede que al principio no se distingan bien los Handles, porque ambos coincidirán en el origen de coordenadas:



Sin embargo, están ahí. Son los puntos que se adivinan en el origen del eje de coordenadas. Si pulsamos en dichos puntitos y arrastramos veremos como estos se mueven alterando el valor de las propiedades de CustomNode3D.



Conclusiones

Este artículo ha sido extremadamente largo, pero he querido solventar con él una carencia desoladora, por parte de la documentación oficial, sobre este tema.

Mi experiencia anterior había sido en Unity, que también tiene su propia API de Gizmos y de Handles. En comparación con Unity, el enfoque de Godot me ha parecido más compacto y sencillo, al concentrar tanto la configuración de los Gizmos, como de los Handles en una misma clase heredera de EditorNode3dGizmoPlugin. En contraste, para hacer lo mismo en Unity tienes que recoger el código de los Gizmos y el de los Handles en clases dispares.

Todo hay que decirlo, la documentación de Unity para este tema me ha parecido mucho más completa.

También hay que tener en cuenta que la API de Gizmos y Handles de Unity cubre tanto los juegos 2D como los 3D, mientras que en Godot, todo lo que hemos visto en este artículo, en realidad sólo aplica a los juegos 3D. No existe una clase EditorNode2DGizmoPlugin, o yo al menos no tengo claro cual sería el equivalente de todo este código para un juego 2D de Godot. Lo investigaré y cuando lo descubra probablemente haga otro artículo, pero en un primer vistazo a la documentación oficial no me queda nada claro cómo hacer todo esto en 2D.


Código de este tutorial

El código del proyecto utilizado como ejemplo está disponible para descarga en mi repositorio GodotCustomGizmoAndHandleExample en GitHub. No dudes en descargártelo para examinar detenidamente el código y probarlo tú mismo.