27 diciembre 2024

Interpolación en Unity

La interpolación es un método matemático utilizado para encontrar un valor intermedio entre dos puntos puntos conocidos. Cuando esos dos puntos conocidos están unidos por una línea recta, decimos que estamos ante una interpolación lineal.

Aunque la definición matemática es un poco áspera, lo cierto es que nuestra vida diaria está poblada de interpolaciones:

  • Si mi coche automático tiene una velocidad máxima de 160 Km/h ¿A qué velocidad iremos cuando pisemos el acelerador a la mitad de su profundidad?
  • Si la autonomía de mi avión es de 300 Km, ¿Cuántos kilómetros más podremos recorrer cuando llevemos consumido un tercio del depósito?
  • Si mi nevera llena me dura 7 días y tengo la mitad de las baldas vacías ¿Cuándo me tocará hacer la compra?
Aunque los ejemplos anteriores parecen heterogéneos, en realidad no lo son. Todos se modelan mediante una función lineal, con una gráfica como la siguiente:

Una función lineal
Una función lineal

La diferencia estriba en qué representan los ejes en cada uno de los casos:
  • En el ejemplo del coche automático, el eje X es la profundidad de pisado del acelerador, mientras que el eje Y es la velocidad del coche.
  • En el caso del avión, el eje X son los litros de queroseno consumidos y el eje Y los kilómetros recorridos.
  • Para la nevera, el eje X son los días que van trascurriendo y el eje Y las baldas de la nevera que se van vaciando.
En todos los casos, la estructura de la gráfica (y de la función que representa) es la misma: una línea recta. Lo que diferenciará un caso u otro es la pendiente de la línea, es decir el ritmo de cambio del eje Y conforme avanzamos en el eje X.

Si en la vida real tenemos que hacer interpolaciones, te puede imaginar que en el desarrollo de juegos también. Al fin y al cabo, ¿Qué son los videojuegos sino una recreación de la vida real?

Pongamos un ejemplo similar a los anteriores. Supongamos que estoy desarrollando un simulador y quiero dar soporte de HOTAS.

Un mando HOTAS
Un mando HOTAS


Cuando nuestro jugador mueva la palanca de gases (la de la izquierda), el Input System de Unity nos devolverá un valor entre 0 y 1: 0 cuando la palanca esté situada en su posición mínima (la más cercana al cuerpo del jugador) y 1 cuando esté en su posición máxima (la más lejana al cuerpo del jugador). Con esa información, tendremos que hacer una interpolación para calcular la velocidad del vehículo, en todas las posiciones intermedias de la palanca, sabiendo que cuando esté en 0 el vehículo debe pararse y cuando esté en 1 debe ir a la velocidad máxima.

Si volvemos a nuestra gráfica, tendremos la misma estructura, sólo que en el eje X se representará la posición de la palanca (que sólo se moverá entre X=0 y X=1) y en el eje Y se representará la velocidad del vehículo de nuestro juego.

¿Cómo haremos ese cálculo? Hay varias posibilidades, dependiendo de los rangos que representemos en los ejes X e Y. Vamos a estudiarlas de menor a mayor complejidad.

Interpolación con rango entre 0 y 1 del eje X 


Es el caso más sencillo: el eje X varía entre 0 y 1, mientras que el eje Y varía entre un valor inicial (V1) y un valor final (V2).

Matemáticamente, el valor a interpolar (Y) se obtendría de la siguiente manera:

La fórmula de una función lineal
La fórmula de una función lineal


Podríamos escribir una función que implementase esa fórmula, pero sería reinventar la rueda, porque Unity ya ofrece un método que hace eso mismo: Mathf.Lerp().

Lerp() acepta tres parámetros:
  • Un valor inicial.
  • Un valor final.
  • Un valor entre 0 y 1.
Si el tercer parámetro fuera 1, la función devolvería el valor final, si fuera 0 devolvería el valor inicial y si estuviera entre 0 y 1 devolvería un valor intermedio obtenido con la línea que une los dos valores anteriores.

En nuestro ejemplo de la palanca de gases, le pasaríamos a Lerp(), la velocidad mínima del vehículo, su velocidad máxima y el valor, entre 0 y 1, que nos devolvería el Input System sobre la posición de la palanca de gases. El valor retornado por el método sería la nueva velocidad del vehículo. 

También puede interesarnos obtener la inversa de una interpolación.

Supón que la nave de nuestro juego cuenta con unos escudos y que estos han estado recibiendo los impactos de los proyectiles de nuestro enemigos. El escudo tiene un valor que representa su fuerza y a este valor le hemos estado restando una cantidad por cada uno de los impactos. Nos interesará coger el valor actual del escudo y representarlo en el panel de mandos de la cabina para que el jugador pueda saber a qué porcentaje tiene los escudos. Es decir, tenemos el valor máximo de los escudos (pongamos que 300), tenemos el valor mínimo de los escudos (lo normal es que sea 0), tenemos el valor actual de los escudos (supongamos que 150) y queremos saber el valor sobre 1 que representa ese valor actual respecto al total de los escudos, para poder presentar un porcentaje al jugador.

Si reorganizamos la fórmula anterior, veremos que la fórmula de función inversa es:

La fórmula de la función inversa
La fórmula de la función inversa


Si aplicamos los datos de nuestro ejemplo:
  • V2, como valor máximo de los escudos: 300
  • V1, como el valor mínimo de los escudos: 0
  • Y, como el valor actual de los escudos: 150
Nos sale que los escudos están al 0,5 en tanto por uno. Para sacar el porcentaje no tienes más que multiplicar por 100 el tanto por uno, así que te sale que el escudo está al 50% de su capacidad.

La fórmula de la función inversa, aplicada al ejemplo
La fórmula de la función inversa, aplicada al ejemplo


Por suerte, tampoco tenemos que implementar esta fórmula en un método propio. Unity ya lo ha hecho por nosotros a través del método Mathf.InverseLerp().

InverseLerp() acepta tres parámetros:
  • Un valor inicial, en nuestro ejemplo 0.
  • Un valor final, en nuestro caso 300.
  • El valor intermedio del que queremos obtener su interpolación inversa, en nuestro caso 150.
Con esos datos, InverseLerp() devolvería 0,5.

Hay que tener en cuenta que si el valor intermedio no lo es, es decir que está por debajo del valor inicial o por encima del final, entonces InverseLerp() devuelve 0 o 1, respectivamente.

Interpolación angular


Cuando operamos con ángulos, puede ser tentador utilizar Lerp(), pero debemos evitarlo. Los ángulos son un caso especial, ya que vuelven a empezar cuando llegan al máximo de 360º. Por ejemplo, un ángulo de 380º es lo mismo que un ángulo de 20º.

Para tener en cuenta esa peculiaridad, Unity ofrece el método Mathf.LerpAngle().

Este método acepta tres parámetros:
  • El ángulo inicial, en grados.
  • El ángulo final, en grados.
  • El valor intermedio entre 0 y 1.
Ten cuidado, porque el método no juega con ángulos de 0 a 360º, sino con un rango de -180º a 180º. Si visualizas tus 0º como un vector vertical, los 180º se alcanzarán cuando gires el vector hacia la derecha hasta que se dé media vuelta. Los -180º se alcanzarán girando a la izquierda. 

Para que te hagas una idea, una llamada a LerpAngle(0.0f, 190.0f, 1.0f) devolverá -170.0f, porque a los 180º interpretará que los 10 restantes se han obtenido girando 170º hacia la izquierda. Es decir, que LerpAngle() siempre te devolverá el giro más corto para rotar tu vector. Si lo que te interesase es el giro largo (en este caso girar los 190º hacia la derecha) tendrías que usar Lerp().

Interpolación lineal con valores del eje X más allá de 0 y 1


Por defecto, Lerp() sólo te dejará meter valores intermedios entre 0 y 1, pero puede interesarte obtner valores por encima o por debajo. En ese caso, Unity ofrece Mathf.LerpUnclamped().

Sus parámetros son los mismos que Lerp(), sólo que te deja meter valores intermedios por debajo de 0 y por encima de 1. El método se limita a prolongar la línea a ambos lados del rango [0,1] y a darte el el valor resultante.

Interpolación no lineal


Hasta ahora hemos supuesto que nuestro rango en el eje X se limitaba a 0 y 1. Todos los valores intermedios que le metíamos a Lerp() estaban limitados a ese rango. 

Sin embargo, puede haber situaciones en las que nos interese que el eje X sea otro rango distinto, por ejemplo porque queremos aplicar funciones lineales diferentes según avanzamos por el eje X. 

Imagina que tenemos una nave y queremos que su velocidad se vea afectada por el daño acumulado, ralentizando la nave conforme acumulemos más daños. Sin embargo, no queremos que la ralentización sea uniforme, sino que preferimos que, a partir de cierto umbral de daños, la ralentización se acelere. 

Tendríamos una gráfica con los daños en el eje X, la ralentización en el eje Y. De 0 al umbral del daños, la gráfica sería una línea que iría ascendiendo lentamente en un primer tramo, pero tendría un codo al llegar al umbral y, a partir de él, iniciaría un segundo tramos en el que ascendería de manera mucho más pronunciada. Ya no tendríamos una línea recta continua, sino una que se torcería en un punto determinado. Este tipo de funciones se denominan no lineales.

Hay varias maneras de implementar algo así. Podríamos normalizar el eje X del primer tramo usando el InverseLerp() con los valores inicial, final y actual del tramo en el eje X y utilizar el valor resultante para hacer un Lerp() de los valores mínimo y máximo del eje Y para ese tramo. El problema es que habría que repetir todas esas operaciones con el segundo tramo.

Podríamos simplificar un poco los cálculos usando la función remap() del paquete Mathematics de Unity. Ese método permite pasar un rango de origen (en el eje X), un rango de destino (en el ejeY) y un valor intermedio en el rango de origen, para obtener el valor equivalente en el rango de destino. Esto nos ahorraría tener que encadenar InverseLerp() y Lerp(), pero seguiría suponiendo aplicar el método remap() a cada uno de los tramos. Aparte de que nos obligaría a instalar el paquete Mathematics, a través del Package Manager.

Este método resulta insostenible en el caso de que nuestra gráfica tenga múltiples tramos y además, la transición entre un tramo y otro podría ser demasiado llamativa.

En esos casos, lo mejor es que recurramos a las AnimationCurve. Como su nombre indica, están pensadas para ser usadas en el ventana de animación, pero las podemos usar desde nuestro código. Las AnimationCurve nos permiten definir gráficamente nuestra función, con todos los tramos, curvas y rectas que queramos.

Por ejemplo, supongamos que queremos implementar un vehículo con una aceleración y una frenada suaves. Si fueran lineales sería poco natural. En vez de eso vamos a aplicar curvas. Para incluirlas en nuestro código podríamos hacer:

Inclusión de AnimationCurves en nuestro código
Inclusión de AnimationCurves en nuestro código

Los campos anteriores se verían desde el inspector de la siguiente manera:

AnimationCurves desde el inspector
AnimationCurves desde el inspector

Pinchando cualquiera de las AnimationCurve podremos editarla para darle forma. Por ejemplo, la de aceleración tiene este aspecto:

Curva de la aceleración
Curva de la aceleración

Si te fijas, la curva anterior está normalizada tanto en el eje X, como en el Y, ya que ambas se sitúan entre los valores 0 y 1. Sin embargo, eso no debería ser un problema con lo que que hemos aprendido hasta ahora. Por ejemplo, para calcular la velocidad durante la aceleración podemos hacer:

Muestreo de una AnimationCurve

Dado que la variable accelerationRadius define la distancia, desde el arranque, a la que el vehículo alcanza su velocidad máxima, sabemos que esa distancia se corresponde con el valor X=1 de la curva de aceleración. Por eso, para saber en qué punto de la curva de aceleración estamos, haremos un InverseLerp(), pasando como valor intermedio nuestra distancia al punto de arranque (línea 94).

El punto obtenido del InverseLerp(), se lo pasaremos al método Evaluate() de la AnimationCurve, para que nos devuelva el valor de la gráfica en ese punto (línea 93). Dado que el eje Y de la gráfica se corresponde con la velocidad, y que el punto Y=1 es el de velocidad máxima, nos basta con multiplicar el valor que nos devuelve la curva por la velocidad máxima.

Como verás, el uso de una AnimationCurve te libera de tener que aplicar cálculos diferentes por tramos y hace las transiciones mucho más naturales y suaves.

Conclusión


Las interpolaciones son muy útiles en el desarrollo juegos. Los casos más simples se pueden resolver con el uso de los métodos Lerp e InverseLerp, contando con LerpAngle cuando manipulemos ángulos. Para casos más sofisticados y complejos lo más cómodo será contar con AnimationCurves y exponerlas a través del inspector para poder editarlas de manera visual.

24 diciembre 2024

Testeo automatizado (TDD) en Godot - GdUnit4

El TDD es una metodología de desarrollo de software donde primero escribes las pruebas y luego el código necesario para que las pruebas pasen. Al tener que definir las pruebas primero, el TDD te obliga a plantearte qué funcionalidad quieres conseguir, antes de ponerte a tirar líneas de código. Tienes que diseñar qué entradas va a recibir tu prueba y qué salidas considerará como correctas. Esas entradas y salidas serán las de tu funcionalidad, y haberlas definido con antelación te permitirás implementar dicha funcionalidad de manera más limpia y encapsulada.

A muchos no les gusta esta metodología porque les resulta aburrido empezar por las pruebas y prefieren zambullirse en tirar líneas de código y probar luego manualmente. Suelen disculparse con que no pueden perder tiempo programando pruebas. El problema se lo encuentran cuando su proyecto empieza a ganar tamaño y complejidad e introducir nuevas funcionalidades se convierte en un infierno porque se rompen las funcionalidades anteriores. A menudo, esto pasa de manera inadvertida, porque resulta imposible probar manualmente toda la aplicación en cada cambio. Así que van acumulando errores ocultos en cada actualización. Para cuando descubren alguno de esos errores ya resulta muy complicado averiguar cuál de las actualizaciones lo provocó, lo que convierte en un calvario resolverlo.

Con TDD no pasa esto, porque las pruebas que diseñas para cada funcionalidad se añaden a la base de pruebas y pueden ejecutarse de nuevo automáticamente al incorporar nuevas funcionalidades. Cuando una nueva funcionalidad rompe algo de las anteriores, sus correspondientes pruebas fallarán, lo que servirá de piloto de alarma para señalar dónde está el problema e ir a tiro hecho a solucionarlo.

Los juegos son una aplicación más. TDD se le puede aplicar igual y por eso, todos los engines cuentan con frameworks de pruebas automatizadas. En este artículo nos centraremos en uno de los más populares en Godot Engine: GdUnit4.

GdUnit4 permite la automatización de pruebas usando tanto GdScript, como C#. Dado que yo uso este último para desarrollar en Godot, nos centraremos en él a lo largo de este artículo. Aun así, si eres usuario de GdScript te recomiendo que sigas leyendo porque muchos conceptos son similares en ese lenguaje. 

Instalación del plugin

Para instalarlo, tienes que ir a la pestaña AssetLib, de la parte superior del editor de Godot. Allí, debes introducir "gdunit4" en el buscador. Pincha en el resultado que te salga.

Búsqueda de GdUnit4 en el AssetLib

En la ventana emergente que te sale, pincha en "Download" y acepta la carpeta de instalación por defecto.

Hecho lo anterior, el plugin habrá quedado instalado en la carpeta de tu proyecto, pero estará deshabilitado. Para activarlo, tienes que ir a Project --> Project Settings... --> Plugins y activar la casilla Enabled.

Activación del plugin de GdUnit4
Activación del plugin de GdUnit4

Te recomiendo que reinicies Godot después de la activación del plugin, de otra manera puede que te salgan errores.

Configuración del entorno C#

La siguiente fase es configurar tu entorno C# para usar GdUnit4 sin problemas.

Para empezar, tienes que asegurarte de tener la versión 8.0 del .NET.

Luego, tienes que abrir tu archivo .csproj y asegurarte de hacer los siguientes cambios en la sección <PropertyGroup>:

  • Cambiar el TargetFramework a net8.0.
  • Añadir la etiqueta <LangVersion>11.0</LangVersion>
  • Añadir la etiqueta <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
Además, tienes que crear una sección <ItemGroup>, en caso de que no la tengas, y añadirle el siguiente contenido:

<ItemGroup>
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
        <PackageReference Include="gdUnit4.api" Version="4.3.*" />
        <PackageReference Include="gdUnit4.test.adapter" Version="2.*" />
</ItemGroup>


Para que te hagas una idea del resultado, este es el contenido de mi fichero .csproj:

Contenido del fichero csproj adaptado a GdUnit4
Contenido del fichero csproj adaptado a GdUnit4

Llegados a este punto, si haces Rebuild de tu proyecto (bien desde el IDE o bien desde esquina superior derecha la pestaña MSBuild) y no te sale ningún error, significará que la configuración es correcta.

Configuración del IDE

Oficialmente, GdUnit4 puede ser usado desde Visual Studio, Visual Studio Code y Rider. La configuración necesaria varía en cada caso.

Uso Rider, así que centraré el ejemplo en ese IDE.

GdUnit4 espera encontrar una variable de entorno denominada GODOT_BIN con la ruta al ejecutable de Godot. Yo uso la herramienta GodotEnv de ChickenSoft para instalar y desinstalar las sucesivas versiones de Godot. Esto tiene la ventaja de que GodotEnv se encarga de mantener una variable de entorono llamada GODOT, con la ruta al ejecutable del editor. Así que lo que yo he hecho es crear una variable de entorno (de usuario, para no enredar con las de sistema) GODOT_BIN, que lo que hace es apuntar a la de GODOT. Se hace abriendo la ventana de Sistema del Windows, pulsando "Configuración avanzada del Sistema", pestaña "Opciones avanzadas", botón "Variables de entorno" y, en el apartado de "Variables de usuario" , botón "Nueva":

Configuración de la variable de entorno
Configuración de la variable de entorno

Si tu no usas GodotEnv, y careces de una variable previa con la ruta al ejecutable del editor, en el valor de la variable tendrás que poner esa ruta y acordarte de cambiarla cada vez que instales una nueva versión del editor.

Luego, en Rider, tendrás que asegurarte de tener el plugin de soporte a Godot activado (lo normal es que si está leyendo este artículo ya lo tengas) y de tener activado el soporte a los adaptadores de VSTest:

Configuración al soporte de los adaptadores de VSTest


En la captura anterior, he activado la casilla "Enable VSTest adapters support" y he incluido una línea con un asterisco en la lista "Projects with unit tests". Ojo a esto último que a mí se me pasó y el IDE no me identificaba los test como tales hasta que metí esta línea en la lista.

Para configurar la ejecución de los tests, debes crear un fichero .runsettings en la raíz de tu proyecto. A modo de ejemplo, el mío es el siguiente (copiado de la página de GdUnit4):

<?xml version="1.0" encoding="utf-8"?>

<RunSettings>

    <RunConfiguration>

        <MaxCpuCount>1</MaxCpuCount>

        <ResultsDirectory>./TestResults</ResultsDirectory>

        <TargetFrameworks>net7.0;net8.0</TargetFrameworks>

        <TestSessionTimeout>180000</TestSessionTimeout>

        <TreatNoTestsAsError>true</TreatNoTestsAsError>

    </RunConfiguration>


    <LoggerRunSettings>

        <Loggers>

            <Logger friendlyName="console" enabled="True">

                <Configuration>

                    <Verbosity>detailed</Verbosity>

                </Configuration>

            </Logger>

            <Logger friendlyName="html" enabled="True">

                <Configuration>

                    <LogFileName>test-result.html</LogFileName>

                </Configuration>

            </Logger>

            <Logger friendlyName="trx" enabled="True">

                <Configuration>

                    <LogFileName>test-result.trx</LogFileName>

                </Configuration>

            </Logger>

        </Loggers>

    </LoggerRunSettings>


    <GdUnit4>

        <!-- Additional Godot runtime parameters-->

        <Parameters></Parameters>

        <!-- Controls the Display name attribute of the TestCase. Allowed values are SimpleName and FullyQualifiedName.

             This likely determines how the test names are displayed in the test results.-->

        <DisplayName>FullyQualifiedName</DisplayName>

    </GdUnit4>

</RunSettings>

 

Para que Rider lea esa configuración, se lo tienes que decir en la configuración de su Test Runner:

Configuración de la ruta al .runsettings
Configuración de la ruta al .runsettings

Acabado todo lo anterior, tienes que reiniciar el Rider para que active la configuración.

Si quieres probar que la configuración es correcta, puedes crear una carpeta Tests en tu proyecto y, dentro de ella, un fichero de C# (lo puedes llamar ExampleTest.cs) con el siguiente contenido:

Contenido de Tests/ExampleTest.cs
Contenido de Tests/ExampleTest.cs

El ejemplo está pensado para que Success() sea una prueba exitosa y Failed() fracase.

Puedes ejecutar este test desde Rider o bien desde el editor de Godot.

Desde Rider, primero tienes que habilitar la pestaña de los tests pulsando View --> Tool Windows --> Tests. Desde la pestaña de tests puedes ejecutar un test en concreto o todos seguidos.

Pestaña para ejecutar tests en Rider
Pestaña para ejecutar tests en Rider

Para ejecutarlos desde Godot, tienes que ir a la pestaña de GdUnit.

Pestaña de ejecución de tests en Godot
Pestaña de ejecución de tests en Godot

Si la pestaña de Godot no mostrase los tests, podría ser que no estuviese buscando en la carpeta correcta. Para comprobarlo, debes pulsar en el botón de la herramientas de la esquina superior izquierda de la pestaña y asegurarte de que el parámetro "Test Lookup Folder" apunta a la carpeta donde tengas los tests.

Ruta a la carpeta con los tests
Ruta a la carpeta con los tests

Si aún así no te mostrase los test, te recomiendo que pruebes a reiniciar. Tanto Godot, como Rider, tienden a no detectar a veces los cambios y los nuevos tests. Cuando me pasa reinicio Godot o Rider (lo que me esté fallando) y entonces ya se detectan los cambios. Supongo que con el tiempo irán resolviendo esa problemática. En todo caso, en las pruebas que he ido haciendo, las que he hecho a través de Rider han funcionado muchísimo mejor que las que he realizado desde el editor de Godot mismo.

Prueba de un juego

Toda la configuración anterior ha sido ardua, pero lo bueno es que sólo hay que hacerla una vez. A partir de ahí se trata de ir creando tests y probándolos.

Hay múltiples cosas que probar en un juego. Las pruebas unitarias se centran en probar métodos y funciones concretas, yo voy a explicarte algo más amplio: las pruebas de integración. Estas prueban el juego al mismo nivel que lo haría un jugador, actuando sobre sus objetos y evaluando si la reacción del juego es la esperada. Para ello, es habitual crear niveles especiales de test en los que se concentran todas las funcionalidades para poder probarlas de una manera rápida.

Para entendernos, vamos a poner como ejemplo una prueba sencilla. Supongamos que tenemos un juego en el que hay un elemento (un agente inteligente) que tiene que desplazarse hasta la posición de un determinado marcador. Para comprobar el correcto funcionamiento del agente, lo colocaríamos en un extremo del escenario, al marcador en otro y esperaríamos uno segundos antes de evaluar la posición del agente. Si este se hubiera acercado lo suficiente al marcador, podríamos concluir que funciona correctamente.

El código Godot de este ejemplo lo puedes bajar de este commit concreto de uno de mis repositorios. Si lo abres en el editor de Godot, cargas la escena Tests/TestLevels/SimpleBehaviorTestLevel.tscn, la conviertes en la principal del juego (Project --> Project Settings...--> General --> Application - Run --> Main Scene) y ejecutas el juego, verás que el luego se comporta tal y como decíamos en el párrafo anterior: la mirilla roja se situará allá donde pinches el ratón y la bola verde se dirigirá a la posición de la mirilla. Por tanto, manualmente podemos comprobar que el juego se comporta como debe. Ahora vamos a comprobarlo de manera automatizada.

El juego que estamos probando
El juego que estamos probando

Lo primero es configurar el nivel con los elementos necesarios para facilitar la prueba. Estos elementos auxiliares no harán sino estorbar en los niveles a los que accedan los jugadores, y esa es la razón por la que generalmente se crean niveles específicos para los tests (por eso, este está en la carpeta Tests/TestLevels).

Nuestro nivel de pruebas tiene la siguiente estructura:

Estructura del nivel de pruebas
Estructura del nivel de pruebas

 Los elementos son los siguientes:

  • ClearCourtyard: Es la caja por la que se mueve nuestro agente. Un simple tilemap con el que he dibujado en gris oscuro las paredes, que no se pueden traspasar, y en gris claro el suelo, por el que nos desplazamos.
  • Target: Es una escena cuyo nodo principal es un Marker2D y por debajo tiene un Sprite2D con la imagen de la mirilla. El nodo principal tiene un script que escucha los eventos de Input y reacciona a la pulsación del botón izquierdo del ratón, situándose en la posición de la pantalla donde se haya hecho click.
  • SeekMovingAgent: Es el agente cuyo comportamiento queremos comprobar.
  • StartPosition1: Es la posición en la que queremos que se sitúe el agente al comenzar la prueba.
  • TargetPosition1: Es la posición en la que queremos que se sitúe el Target al comienzo de la prueba.
El código de nuestra prueba será uno o varios ficheros de C#, situados en la carpeta de tests. En este caso sólo tendremos un fichero, pero podemos tener varios si estamos probando diferentes funcionalidades. Cada fichero puede tener múltiples pruebas. Una prueba no es más que un método marcado con el atributo [TestCase]. La clase a la que pertenezca el método anterior debe estar marcada con el atributo [TestSuite] para que el test runner la tenga en cuenta al ejecutar la prueba. Nuestra prueba de ejemplo está en el fichero Tests/SimpleBehaviorTests.cs.

La primera mitad del fichero se dedica a los preparativos:


Como puedes ver en la línea 9, he guardado en una constante la ruta al nivel en el que queremos desarrollar la prueba. 

Dicho nivel lo arrancamos en la línea 17. Esa línea pertenece al método LoadScene(), el cual podemos llamar como queramos siempre y cuando lo marquemos con el atributo [BeforeTest] para señalar que queremos que LoadScene() se ejecute justo antes de cada prueba. De esta manera cargaremos el nivel desde el principio en cada prueba, con lo que nos aseguraremos de que sus elementos estén en el punto de partida cada vez y de que las pruebas anteriores no interfieren. Hay otros tributos que pueden resultarte útiles, como [Before], que ejecuta el método que decora una sola vez justo antes de que se ejecuten todas las pruebas de la clase. Puede ser útil para inicializar un recurso común a todas las pruebas y que no necesite ser reseteado entre una y otra. Por otro lado, para hacer limpia, existen las contrapartes, los atributos [AfterTest] y [After].

Una vez que tenemos una referencia al nivel recién arrancado podemos dar comienzo a la prueba:

El código de nuestra prueba
El código de nuestra prueba

Entre las líneas 24 y 30 recabamos referencias a los elementos de la prueba usando el método FindChild(). En cada una de sus llamadas pasamos el nombre que tenga el nodo que queremos obtener. Como FindChild() devuelve un tipo Node, tendremos que hacer cast al tipo real que sabemos que tiene.

Una vez que tenemos las referencias a los distintos elementos, colocaremos cada uno en su posición inicial correcta: _target en la posición marcada por _targetPosition (línea 33) y _movingAgent en la posición marcada por _agentStartPosition (línea 24). Podríamos haber situado a los elementos fijando su posición por código, pero en caso de tener que recolocarlos de nuevo siempre es más cómodo hacerlo de manera visual, ajustando la posición de los marcadores en el editor, que teniendo que modificar las posiciones en el código.

En la línea 37 activamos al agente para que empiece a moverse. En general, te recomiendo que mantengas desactivados tus agentes y sólo actives los imprescindibles para la prueba. Así, si estás probando varios en el mismo nivel, evitarás que unos entorpezcan a otros.

En la línea 39, nos sentamos a esperar (durante 2,5 segundos) para darle al agente el tiempo que hemos estimado que tardará en desplazarse a la posición del objetivo.

Finalizada esa espera, llegamos al momento de la verdad. Hay que evaluar si el agente ha llegado hasta la posición objetivo. Para eso, en la línea 41, medimos la distancia entre el agente y su objetivo. Si la distancia es menor que un umbral muy pequeñito (recuerda que en matemáticas de punto flotante no podemos hacer comparaciones por igualdad) entonces podemos suponer que el agente se encuentra correctamente situado en la posiión del objetivo (línea 43).

Conclusión


Como la anterior, podemos hacer multitud de pruebas. Lo normal es meter en una misma clase (fichero .cs) todas las que correspondan a las múltiples facetas de una misma funcionalidad.

La prueba de este ejemplo ha sido muy visual, porque evaluaba el movimiento, a través del cambio de posición. Pero habrá pruebas en las que quieras comprobar que un valor interno del agente resulte ser el correcto. En ese caso, lo que evaluaremos en nuestros Assert serán los valores de las propiedades del agente.

No quiero acabar sin enlazarte la página de documentación de GdUnit4. Parte del uso de GdScript pero ha empezado a adaptarse a C# desde el momento que GdScript empezó a ofrecer soporte en ese lenguaje. Hay secciones específicas con las peculiaridades de instalación de GdUnit4 para C# y los códigos de ejemplo tienen pestañas con ambos lenguajes. Se trata de una documentación muy completa y útil.

21 diciembre 2024

Curso "Unity Cutscenes: Master Cinematics, Animation and Trailers" de GameDev.tv

Acabo de terminar otro de los cursos de GameDev.tv incluidos en el pack de Humble Bundle que compré hace poco. Esta vez ha sido el de "Unity Cutscenes: Master Cinematics, Animation and Trailers". Al igual que los otros, este curso está disponible tanto en la plataforma propia de GameDev.tv, como en Udemy. Su nombre es bastante descriptivo de su enfoque: enseñarte las herramientas con las que cuenta Unity para hacer escenas cinemáticas. Como probablemente sepas, una escena cinemática es una secuencia generada en tiempo real, con el engine del juego, que narra una parte importante de la historia. Usar cinemáticas tiene dos ventajas importantes: por un lado ahorra el espacio que supondría almacenar el video de una escena prerenderizada; y por otro lado, al ejecutarse con los mismos assets que el resto del juego, no hay riesgo de romper con la estética de este y permite transiciones más fluidas del juego a la secuencia y viceversa. 

El curso estructura sus 10 horas en 5 apartados. En mi opinión, los dos primeros son los más interesantes porque es donde explica los conceptos realmente nuevos. En el primer apartado cubre la importación de animaciones de Mixamo, explica lo básico del rigging y los esqueletos (desde el lado de Unity), explica en profundidad el Timeline. El segundo apartado es, para mi gusto, el mejor de todos porque explica con detalle esa joya de Unity que es el sistema Cinemachine. En concreto, cubre el uso y transición entre múltiples cámaras, las cámaras de seguimiento, los "travelling" mediante Dolly Tracks, y el cambio automático de cámaras cuando pierden la visibilidad del personaje (la base de un sistema de cámaras como el del primer Resident Evil). Todo eso lo explica bien y con detalle. Realmente disfruté esa parte.

El problema viene a partir de ahí. Los tres últimos apartados abarcan algo menos de 7 horas del curso, pero creo que podría haberse reducido a una tercera parte. En ellos, el autor deja de explicar conceptos nuevos y se centra en ilustrar con ejemplos lo visto en los dos apartados previos. En si mismo tampoco me parece mal, pero dedicarle sólo 3'5 horas a descubrir conceptos, para luego echar más de 6'5 en darle vueltas a ejemplos me parece muy descompensado. Se me ha hecho muy aburrido estar haciendo una y otra vez ajustes en las mismas ventanas sólo para que la imagen quedase encuadrada en el ángulo "cool" que buscaba el autor. Es cierto que los ejemplos molan y que se ve que el autor disfruta puliéndolos, pero creo que habría sido mucho más productivo y entretenido que el autor se hubiese limitado a ejemplos más sencillos, a cambio de explicar más conceptos que encajarían en el alcance del curso, pero que sin embargo se tocan muy de pasada, como la iluminación, los VFX (con el VFX-Graph) o los shaders.

Además, el autor tiene un acento muy cerrado, por lo que a veces cuesta entenderle por muy acostumbrado que estés a seguir cursos en inglés. Para agravarlo, el curso carece de subtítulos (en contra de lo que es habitual en GameDev.tv) por lo que no se puede recurrir a ellos en los momentos en los que no queda claro lo que está diciendo el profesor. Una pena.   

Por tanto, mi recomendación es que te compres este curso en una rebaja, si tienes el gusanillo de aprender a usar el Cinemachine o el Timeline, y que te centres en las dos primeras partes. Las siguientes se pueden ver al doble de velocidad de lo normal, y ponerlas a velocidad normal, cuando el autor haga algo que te resulte novedoso (no suele pasar); o bien te las puedes saltar directamente, en mi opinión no te perderás mucho.

13 diciembre 2024

Introducción a los sistemas de partículas en Godot (2ª parte) - La lluvia

En uno de mis artículos anteriores conté cómo podíamos utilizar los sistemas de partículas de Godot para simular la caída de las flores de un cerezo. Se trataba de un efecto muy sutil pero que encierra los principios básicos de lo que ofrecen los sistemas de partículas. Otro efecto mucho más espectacular, y habitual, es el de la lluvia. Si nos paramos a pensarlo, incluir lluvia en nuestro juego, usando un sistema de partículas, es muy sencillo. En este artículo veremos cómo.

Como en los otros artículos, he dejado el código y recursos de este en GitHub. Te recomiendo que te lo bajes y lo abras en el editor de Godot, para poder seguir cómodamente lo que te cuente aquí. El código está en el repositorio de SakuraDemo, aunque te recomiendo que te bajes este commit concreto para estar seguros de que veas exactamente la versión que voy a explicar.

Hecho lo anterior, ya puedes abrir el proyecto en Godot. La novedad, respecto a los ejemplos anteriores de SakuraDemo es que he introducido tres nodos nuevos: dos emisores de partículas (Rain y RainSplash) y un LightOccluder2D (WaterRainCollider), aunque  he deshabilitado este último haciéndolo invisible en el editor (cerrando el icono del ojo en la jerarquía) y fijando su Process--> Mode a Disabled en el inspector (para que el nodo no ejecute lógica alguna en cada frame).

Jerarquía de la escena
Jerarquía de la escena

En este artículo explicaremos los dos emisores de partículas. El primero, Rain, es el que genera las gotas de lluvia, mientras que el segundo, RainSplash, se activa en el punto en el que una gota deja de existir, para simular las pequeñas salpicaduras que se producirían cuando lloviese sobre el agua del lago. En términos de sistemas de partículas, se denomina sub-emisor. Se denominan así porque se originan en los puntos de finalización (bien por fin de vida o por colisión) de las partículas de un sistema de partículas padre.

Hemos dicho que las partículas pueden dejar de existir porque expire su tiempo de vida o bien porque colisionen. Lo que pasa es que los sistemas de partículas se gestionan en la GPU, la cual no tiene conocimiento de los CollisionShape que pueblan nuestro escenarios, ya que estos últimos se gestionan en la CPU. Lo que sí pueden detectar esas partículas son los polígonos que definamos en pantalla con un LightOccluder2D. En teoría, cuando una partícula toque alguno de los lados de un LightOccluder 2D dejará de existir. La práctica resulta ser algo más compleja. Si las partículas son demasiado pequeñas y rápidas, pueden atravesar los lados de un LightOccluder2D sin detectarlos. Para resolverlo hay que incrementar el tamaño físico (que no visible) de las partículas aumentando el parámetro Collision--> Base Size, del nodo GPUParticles2D del sistema de partículas. Si pruebas a aumentar el tamaño de la partícula dentro de la simulación de físicas verás que las partículas penetran menos en el polígono del LightOccluder2D. Otra dificultad de los nodos LightOccluder2D es que, en mi opinión, sirven para simular superficies, sobre las que puede chocar tu lluvia, sólo si tu juego es puramente lateral. Sin embargo, en una escena con perspectiva como esta no queremos que nuestras gotas choquen contra el borde de un polígono, sino sobre la zona visual que cubre el sprite del agua. Es cierto, que reduciendo el tamaño físico de las partículas puedes hacer que estas entren en la zona del agua, pero no he observado mucha diferencia respecto al efecto conseguido al hacer aleatorio el tiempo de vida de las partículas, como vamos a ver a continuación. Esa es la razón por la que, al final, he acabado desactivando el LightOccluder2D. Aun así te recomiendo que lo actives y juegues con el tamaño físico de las partículas para que puedas comprobar su efecto. 

Forma del LightOccluder2D
Forma del LightOccluder2D


Vamos a centrarnos en el efecto principal de lluvia. Si accedes al nodo Rain, verás que he hecho las siguientes configuraciones:

  • Amount: Fijado a 1000 para que haya una cantidad abundante de gotas.
  • Sub Emitter: Aquí debe arrastrarse el nodo con el sistema de partículas que queremos que se active cuando mueran nuestras gotas de lluvia. En este caso será RainSplash, que veremos más adelante en este artículo.
  • Time --> Lifetime: He puesto 0,7 porque es el tiempo mínimo que necesita una gota de agua para llegar desde el punto de generación, encima del borde superior de la pantalla, hasta la zona de la pantalla donde está el agua. 
  • Time --> Preprocess: Quedaría raro si las gotas aparecieran casi paradas en el borde superior de la pantalla y acelerasen desde allí. Intuitivamente, lo esperable es que la gota ya venga a mucha velocidad, al haberse originado a gran altura sobre la pantalla. Esa aceleración previa de las gotas se consigue con este parámetro, que viene a decir "haz aparecer las partículas con las características físicas como si se hubieran empezado a caer 0,5 segundos antes". En mi caso, a base de prueba y error, han bastado 0,5 segundos para conseguir un efecto creíble.
  • Drawing --> Visibility Rect: Es el rectángulo en el que serán visibles las partículas del sistema. En mi caso, quería que la lluvia cubriese la pantalla así que he configurado un rectángulo que abarca toda la pantalla.
  • CanvasItem --> LightMask y Visibility Layer: He situado el sistema de partículas en su propia capa. En este caso concreto, creo que tiene no afecta en nada, pero me gusta tener las cosas ordenaditas.
  • Ordering --> Z Index: Este parámetro es importante. Se supone que nada debe tapar a las gotas de agua, así que su Z-Index debe estar por encima de la del resto de sprites de la imagen. Por esa razón lo he fijado a 1.200. 

Como en el caso del sistema de partículas de las flores del cerezo, el ParticleProcessMaterial que hayamos creado para el parámetro Process Material tiene sus propios parámetros:

  • Lifetime Randomness: Al ampliarlo aumentará el rango de valores posibles para el lifetime. El valor que pongamos aquí debe restarse de 1.0 para calcular el extremo inferior del rango. En mi caso, he fijado 0.22, lo que significa que las gotas se iniciaran con valores de lifetime aleatorios entre 0.78 * lifetime y 1.0 * lifetime. Con este parámetro podemos conseguir que las gotas  cubran todo el sprite del lago. El valor inferior del rango son las gotas que alcanzarán la orilla del lago y el valor superior las gotas que vivirán lo suficiente para alcanzar las cercanías del borde inferior de la pantalla. Todos los valores intermedios se distribuirán por el la superficie de pantalla que ocupa el lago.
  • Disable Z: Estamos en una escena 2D. No tiene sentido que las partículas se desplacen en el eje Z.
  • Spawn --> Position --> Emission Shape: He fijado Box porque quiero que las partículas se generen, más o menos al mismo tiempo, a lo largo de todo el borde superior de la pantalla. Eso encaja muy bien con el volumen de una caja alargada, longitudinal a la pantalla.
  • Spawn --> Position --> Emission Box Extents: Permite definir la forma de la caja desde la que se generarán las partículas. En mi caso es estrechita (Y=1) pero lo suficientemente alargada para cubrir el borde superior de la pantalla (X=700).
  • Accelerations --> Gravity: Se supone que las gotas de lluvia se generan en el cielo, por lo que, para cuando llegan al suelo, bajan a gran velocidad. Podemos simular esa velocidad tan elevada disparando el valor de la gravedad a 3.000 en el eje vertical (Y).
  • Display --> Scale: Sería muy aburrido si todas las gotas fueran del mismo tamaño. En vez de eso, les he dado variedad fijando un tamaño mínimo de 0.2 y un máximo de 1.
  • Display --> Scale Over Velocity curve: La aceleración de las gotas resulta más visual si hacemos que las gotas se alarguen conforme se van acelerando. Eso se consigue con este parámetro, si creas un recurso de curva y haces que esta se eleve, en la dimensión Y, conforme se acerque a la velocidad máxima. En mi caso he hecho que las gotas tengan un tamaño inicial de 15 al iniciar su caída y de 30 cuando lleguen al 25% de su velocidad máxima.

Curva para escalar el tamaño en el eje Y de la partícula conforme se incremente su velocidad.
Curva para escalar el tamaño en el eje Y de la partícula conforme se incremente su velocidad.

  • Display --> Color Curves --> Color Initial Ramp: Otra manera de darle variedad a las gotas es que tengan colores ligeramente diferentes. con este parámetro podemos fijar un gradiente, para que cada gota adopte un color de él.
  • Display --> Color Curves --> Alpha Curve: Otra curva. En este caso para variar la opacidad a lo largo del tiempo de vida de la partícula. Yo he configurado una campana de manera que la partícula es transparente al comienzo y final de su vida, pero visible en la franja intermedia.
  • Collision --> Mode: Define qué hacer con la partícula al colisionar. Si usases un LightOccluder2D para simular zonas de impacto, lo normal sería que quisieras que la gota desapareciese al impactar, por lo que fijarías "Hide On Contact". Sin embargo, si estuvieses simulando otro tipo de partículas, y quisieses que estas rebotasen, deberías utilizar "Rigid".
  • Sub Emitter --> Mode: Define cuándo activar el sistema de partículas secundario. Es decir, el subemisor. En nuestro caso recuerda que se trata de RainSplash. He fijado el parámetro en "At End" para que el sistema de partículas secundario se active en la posición en la que se extinga una partícula por finalización de su tiempo de vida. Si hubiéramos simulado colisiones, aquí deberíamos haber puesto "At Collision".
Si sólo quisiéramos simular las gotas, lo podríamos dejar aquí; pero como he ido comentando a lo largo del artículo, queremos simular las salpicaduras que producirían las gotas del agua al chocar con el agua del lago. Por eso, tenemos que configurar el nodo RainSplash, el sistema de partículas que se activará cada vez que se extinga una gota.

Al igual que con el nodo Rain, hay dos niveles de configuración, primero a nivel general del nodo y luego a nivel particular del Process Material.

A nivel general, los parámetros que he usado son:
  • Emitting: Lo he fijado a falso, porque ya se encargará de la activación el nodo Rain, cada vez que se extinga una de sus gotas.
  • Amount: He fijado 200, aunque es un efecto tan sutil y rápido que no hay grandes diferencias entre un valor de este parámetro u otro.
  • Time --> Lifetime: El efecto de salpicadura es tan rápido, que he dejado este tiempo en 0.2
  • CanvasItem --> LightMask y Visibility Layer: He djado estas salpicaduras en la misma capa de la lluvia.
  • Ordering --> > Index: Lo he situado por encima de las gotas de lluvia, en un valor de 1.300.

En cuanto a nivel de Process Material, bastaría con:

  • Lifetime Randomness: Lo he fijado a 1 para que el nivel de aleatoriedad de del tiempo de vida de las salpicaduras sea máximo.
  • Spawn --> Velocity --> Direction: Para que las salpicaduras asciendan antes de caer, he fijado un vector inicial de velocidad de (0, -1, 0)
  • Spawn --> Velocity --> spread: En mi caso, he notado poca diferencia al modificar este parámetro, pero en teoría te debería permitir aleatorizar el vector inicial de velocidad en un rango angular.
  • Accelerations --> Gravity: Las salpicaduras deben caer rápido, pero no tan rápido como las gotas. Así que yo lo he dejado en 500.
  • Display --> Color Curves --> Alpha Curve: He dejado una curva similar a la del nodo Rain.

Y ya está. Con esta configuración he conseguido un efecto de lluvias y de salpicaduras suficientemente convincente. Sin embargo, no dio que esta no sea ni la única, ni la mejor manera de hacerlo. Todos los parámetros que hemos tocado se pueden fijar a valores diferentes a los míos, y hay muchos otros que ni siquiera hemos tocado. Te animo a que experimentes con otros parámetros y valores, y que veas el efecto que tienen. No me cabe duda alguna de que, a poco que le dediques algo de tiempo, conseguirás efectos mejores que el mío.

Efecto de lluvia resultante
Efecto de lluvia resultante


06 diciembre 2024

Curso "Mastering Game Feel in Unity: Where Code Meets Fun!" de GameDev.tv

Portada curso
Sigo haciendo los cursos del ultimo pack de Humble Bundle que compré, con cursos de GameDev.tv. Esta vez le ha tocado a "Mastering Game Feel in Unity: Where Code Meets Fun!". Un curso de unas 8 horas enfocado a desarrolladores que ya hayan hecho cursos introductorios y quieran empezar a mojarse los dedos de los pies en materias más avanzadas. Lo puedes comprar en la misma plataforma de GameDev.tv o en Udemy.

Para ello, el autor parte de un juego ya hecho con lo que se aprende en el nivel básico. Un shoot'em up 2D, en el que movemos a nuestro personaje con el teclado y apuntamos con el ratón. En este punto, el juego es completamente funcional, pero es plano y aburrido. La tesis del autor es que basta añadir pequeños detalles puntuales para hacer que el juego de vuelva tremendamente atractivos y sugerente. Casi todos esos detalles responden a la idea de retroalimentar al jugador, reforzando visualmente sus acciones. Si el jugador dispara, la cámara debe vibrar levemente; si hace estallar una granada, la cámara debe vibrar con más fuerza. Si el jugador se mueve, su personaje debe oscilar en el sentido del movimiento y levantar una leve estela de polvo. Y así toda una larga serie de ejemplos, cada uno de los cuales sirve para probar una funcionalidad diferente de Unity.

Con las vibraciones de cámara se introduce muy por encima los impulsos de Cinemachine. Las estelas de polvo, o las explosiones, son una excusa para perderle el miedo a los sistemas de partículas. Se dota al personaje principal de la capacidad de volar con un jetpack, lo que sirve para configurar un Trail Renderer. Le aporta vida al escenario utilizando las capacidades de iluminación 2D del URP de Unity, y le saca un juego inesperado a los colores HDR con un mero postprocesado Boom. Tampoco descuida la parte del sonido, lo que sirve para usar por primera vez (al menos en mi caso) el AudioMixer de Unity.

Por tanto, toca muchas cosas. Todas a nivel de ejemplo. Ninguna con profundidad, pero sí con el suficiente detenimiento como para perderle el miedo a determinadas herramientas de Unity y que las puedas ubicar mentalmente, reconociendo en qué situaciones te pueden servir. Cuando llegue el momento de emplearlas ya tendrás tiempo para profundizar.

Donde me he separado bastante del curso es a nivel de código. No digo que el autor programe mal, ni mucho menos, pero su manera de organizar las cosas no me ha acabado de encajar. Es una cuestión de gustos. Por poner un ejemplo, tiende a usar eventos para suscribir métodos dentro de la misma clase del evento y, sin embargo, tiende a abusar (en mi opinión) de la referencia directa entre componentes. Yo tiendo a trabajar al revés, usando llamadas directas a métodos dentro de la misma clase, referencias a componentes que están por debajo del llamante y eventos para comunicar cosas hacia arriba en la jerarquía o entre componentes al mismo nivel. No digo que mi manera de programar sea mejor, pero sí que me siento más cómodo con ella que con el estilo del autor. Al final he ido implementando el código a mi manera y, conforme avanzaba el curso, este se ha ido pareciendo cada vez menos al del autor. No es nada grave. Te lo comento porque si haces el curso, y no te encaja cómo programa el autor, no tengas miedo a hacer las cosas a tu manera. Probablemente aprendas mucho más que limitándote a copiar el código del profesor. 

También es cierto que los cursos de GameDev.tv te dan margen para hacer las cosas a tu manera. Es una seña de identidad de ellos que, cada poco tiempo, te van proponiendo desafíos. En cada uno de ellos, el profesor te describe funcionalmente lo que se quiere conseguir y luego te propone que pares el video y que intentes implementarlo tú mismo. Al reanudar el video, el profesor te explica cómo lo ha resuelto él. Si te has quedado atascado, su implementación te ayuda a resolverlo; y si has conseguido resolverlo, la manera de hacerlo del profesor te puede descubrir nuevas opciones que puede que no hubieses tenido en cuenta. En todo caso, me parece una manera mucho más interesante, divertida y provechosa de hacer un curso que limitarse a seguir fielmente los pasos del profesor. 

Lo único que hay que tener en cuenta es que, con este método, lo normal es que tu implementación se vaya separando progresivamente de la del profesor, por lo que no es raro que en los capítulos finales tengas que invertir algo más de tiempo viendo cómo adaptar lo que va haciendo el profesor a tu propia versión del código. Es cierto que empleas mucho más tiempo que el que dice cada capítulo que vas a emplear, pero creo que el proceso es mucho más didáctico.

Por tanto, no me arrepiento de haber hecho el curso. Me ha resultado entretenido, interesante y lo he acabado habiendo aprendido bastantes posibilidades de Unity que antes ignoraba. Que un curso te aporte eso no está nada mal.

23 noviembre 2024

Curso "Unity Shader Graph: Create Procedural Shaders & Dynamic FX" de GameDev.tv

De aquí a fin de año estoy haciendo todos los cursos que puedo, antes de dedicarme de lleno a mi nuevo proyecto. Estoy optando por los cursos de GameDev.tv que venían en un pack de Humble Bundle que adquirí hace poco. Los cursos de este pack están en la plataforma original de GameDev.tv, aunque la gran mayoría se pueden encontrar también en Udemy. Dado que hace poco acabé un curso de Penny de Byl sobre shaders en Unity, este que nos ocupa parecía la progresión natural.

A diferencia del de Penny de Byl, este curso no implementa shaders en código sino el lenguaje visual para shaders (Shader Graph) que incluye Unity. Los shader graph me siguen pareciendo una opción confusa frente al código, pero he de reconocer que el código de los shader de Godot me resulta inmensamente más sencillo que el CG/HLSL de Unity. Sospecho que en Godot seguiré usando código para los shaders, pero en Unity tirare por los shader graph (aunque me pese).

Este curso utiliza varios ejemplos para explicarte cómo utilizar los shader graph. Estos ejemplos van en un orden creciente de complejidad, empezando por los más sencillos de desplazamiento de vértices hasta el más complejo para emular el movimiento del mar, pasando por otros para simular fuego o nieve. El del fuego, la nieve y el fuego fueron los que más me aportaron, aunque el de la nieve tiene partes que están explicados de manera muy confusa y atropellada y fue el único que no me acabó de salir bien.

En general, el curso cumple con creces el objetivo de que le pierdas el miedo a los shader graph, aunque adolece del mismo problema que otros muchos cursos sobre shaders: centrarse en cómo implementar determinados shaders y no explicar por qué se implementan así. Con demasiada frecuencia, el curso se reduce a enumerar los nodos que hay que desplegar y cómo hay que conectarlos entre sí. Aunque puedes tirar de la documentación de Unity (por cierto excelente) para cada nodo, esta se limita a explicarte las entradas y salidas de un nodos, así como su operativa más inmediata, pero no te ofrece el contexto y los conceptos más amplios que hay detrás. En este sentido, creo que el curso de Penny de Byl que mencioné en mi artículo anterior hace un esfuerzo mucho mayor para que entiendas los conceptos que hay detrás de la implementación de cada shader. Es cierto que al final he ido avanzando en el curso y, en general, las cosas me han ido saliendo, pero me ha dado la sensación de que muchas cosas las he implementado más por intuición que por certeza y no estoy seguro de si esa intuición viene por mérito del profesor de este curso o más bien porque ya he hecho varios cursos sobre el tema y, por muy torpe que siga en esta disciplina, siempre queda algo.

Lo que me ha gustado mucho es la estructura orientada a desafíos que ya he observado en otros cursos de GameDev.tv. Por lo que ya he visto en otros cursos que he hecho, parece ser marca de la casa el explicarte determinadas cosas del ejemplo y luego pedirte, a modo de desafío, que implementes por ti mismo el siguiente tramo, usando lo recién explicado. Tras una pausa se explica la resolución del desafío para que contrastes tu solución o, en caso de que no hayas conseguido resolverlo, veas cómo se hacía. Esa dinámica hace bastante participativos los cursos, te mantiene interesado y te fuerza a poner en práctica lo recién explicado. Parece mentira todo lo que aprendes cuando te pones manos a la obra. He de reconocer que hecho de menos esa manera de funcionar cuando hago otros cursos con una dinámica más clásica, meramente expositiva.

Es en inglés, pero el profesor tiene buena pronunciación y se le entiende bien; además, puedes activar subtítulos (en inglés) por si te costase entender alguna palabra. En general se sigue bastante bien.

¿Merece la pena? Yo no me lo compraría por su precio completo. Me parece un precio excesivo. Pero sí puede merecer la pena si aprovechas una de las frecuentes rebajas, y te lo compras por 10 ó 12 €, o integrado en un pack más amplio de cursos.

15 noviembre 2024

Curso "Shader Development from Scratch for Unity" de Penny de Byl

He de reconocer que una de la cosas que más me está costando aprender del desarrollo de juegos son los shaders. Por mucho que ya sepas desarrollar en Unity, Unreal o Godot, con los shaders tienes que empezar desde cero. Son imprescindibles para que las texturas y los efectos visuales de tu juego sean realmente atractivos, pero para empezar a usarlos tienes que dominar un conjunto de conceptos que parecen tener muy poca relación con el resto de disciplinas. No me extraña nada que en los equipos grandes haya gente completamente especializada en este tema. Mi mayor problema sin embargo no es aprender algo nuevo, sino que todos los conceptos parecen artificialmente crípticos, la documentación opaca y la sintaxis de los shaders artificiosa y poco intuitiva. He de reconocer que lo poco que he aprendido de los shaders de Godot me parece lo más intuitivo que he visto hasta ahora, pero incluso así tiene numerosos puntos "ortopédicos". En el caso de Unity, me consta que sus shaders son extremadamente potentes, pero todo me parece bastante revuelto y confuso.

A pesar de ello, yo no cejo en mi empeño de comprenderlos y obtener un razonable dominio de ellos. Por eso, ya llevo varios cursos sobre el tema con los que poco a poco he ido avanzando con el tema, pero mucho menos de lo que prometían sus autores. La razón es que la mayoría de los cursos se limitan a enumerar los pasos para conseguir un determinado efecto, pero muy pocos se detienen a explicar los conceptos que hay detrás de los shaders que justifican esos pasos.

Afortunadamente, Penny de Byl (a la que conocí por sus excelentes cursos sobre inteligencia artificial para juegos) hace un esfuerzo excepcional por explicar esos conceptos en este curso, "Shader Development from Scratch for Unity", disponible en Udemy. No siempre tiene un éxito completo a la hora de explicarlos, pero al menos lo intenta y lo hace desde los conceptos más básicos hasta los más avanzados.

El curso se enfoca a shaders 3D. Así que la mayor parte del tiempo se dedica a cómo aplicar efectos a las texturas de objetos 3D. Todo ello dejando de lado el lenguaje visual de Unity para los shaders y utilizando el lenguaje de programación específico de este engine. A pesar de lo que pudiera parecer a priori, me he dado cuenta de que entiendo mucho mejor los shaders cuando están implementados en código que cuando lo están en cualquiera de los lenguajes visuales de los engines. Debe ser que, como vengo del mundo de la programación, me resulta más sencillo leer código de arriba a abajo que tener que bucear en el plato de spaguetti resultante de un lenguaje visual.

De las 9 secciones de este curso, creo que se pueden entender bastante bien las 6 primeras. Me resultó especialmente esclarecedora la explicación sobre el uso del Dot Product en los shaders. La sección 7 y 8 se empiezan a enredar y me han dado la sensación de que se explican bastante menos. La última sección, en la que se explican los shaders volumétricos, me debe haber cogido especialmente cansado por que reconozco que la he entendido bien poco. Probablemente me dedique a otros temas un tiempo y revisite esa sección pasado un tiempo. A ver si en una segunda pasada, estando yo más fresco, lo explicado en esa sección cobra más sentido.

A pesar de que haya cosas que no haya comprendido, hay otras muchas que me encontrado en otros cursos y que sólo aquí he alcanzado a entender, gracias a las explicaciones de Penny de Byl. Además, la sección de preguntas y respuestas de cada una de las clases resulta casi tan interesante como la clase en si misma. Te recomiendo que te leas todos las preguntas planteadas por otros alumnos en cada una de las clases, probablemente te encuentres con otros que hayan tenido las mismas dudas que tú. La profesora es rigurosa y responde a casi todas las preguntas lo cual resulta muy esclarecedor. Hay veces que incluso reconoce gazapos en la clase o incluye correcciones o enlaces a recursos externos para resolver dudas, lo que resulta especialmente valioso para acabar de encajar todos los conceptos.

¿El curso merece la pena? Sí, sin duda. Si te encuentras el curso rebajado en Udemy, creo que es una buena oportunidad para mojarse los pies en el mundo de los shaders y una buena oportunidad para conocer a una autora con unos cursos la mar de interesantes.

09 noviembre 2024

Introducción a los sistemas de partículas en Godot (1ª parte) - La caída de la flores del cerezo

Cerezo soltando flores
En artículos anteriores trabajamos en una estampa japonesa, usando shaders para dotar de reflejo y movimiento al agua, así como al árbol del escenario. En este artículo vamos a dejar a un lado los shaders y vamos a introducir una herramienta con un concepto engañosamente sencillo, pero que bien utilizado nos puede servir para realizar múltiples efectos. Se trata de los sistemas de particulas.

Un sistema de partículas es un componente que emite un flujo continuo de partículas. Los diferentes efectos se consiguen configurando la cantidad de partículas, su velocidad, dirección o apariencia visual. Mi idea es dotar al escenario japonés del artículo de tres sistemas de partículas: uno que reproduzca la caída de las flores del cerezo, otro que simule visualmente el viento y otro que implemente un efecto de lluvia. 

En este artículo veremos cómo implementar la caída de flores del cerezo de la escena. Verás que es un ejemplo sencillo, pero creo que puede servir entender los sistemas de partículas y perderles el miedo. Además, con pocas variaciones puedes aprovechar este ejemplo para implementar efectos similares, como por ejemplo el de la caída de las hojas en otoño.

Como en los ejemplos anteriores, es importante que te bajes el proyecto que tengo en GitHub para que puedas seguir las explicaciones que voy a dar aquí. Asegúrate de bajar el commit exacto o de lo contrario puede ser que veas un código diferente al de este artículo.

El efecto que queremos conseguir es el de las flores del cerezo cayendo suavemente desde la copa del árbol. Para no perder el carácter zen de la escena, la cantidad de flores a caer será reducida, aunque veremos cómo aumentar su cantidad. De hecho, la incrementaremos sutilmente cuando aumente la fuerza del viento. También veremos cómo mover el sistema de partículas al compás del movimiento de la copa del árbol.

Configuración del sistema de partículas

En Godot, un sistema de partículas es un nodo más, de nombre GPUParticles2D (en 3D hay un nodo equivalente). En nuestro caso, dado que está ligado al árbol, parece lógico que hagamos que el nodo del sistema de partículas sea hijo del nodo del árbol.

Una vez que hayamos creado el nodo por debajo del nodo del árbol podremos empezar a configurarlo.

Nodo del emisor de partículas
Nodo del emisor de partículas

Un emisor de partículas tiene dos niveles de configuración: a nivel de nodo y a nivel de material.

La configuración más básica se hace a nivel de nodo:

Configuración de nodo del emisor de partículas.
Configuración de nodo del emisor de partículas

Hay muchísimas cosas que se pueden configurar, así que me voy a centrar en explicar las que yo he usado para este ejemplo.

El campo Emitting es el que activa el emisor de partículas. Si lo desmarcamos el emisor dejará de funcionar.

El campo Amount es el caudal de partículas a emitir por unidad de tiempo. Yo quería un efecto muy sutil, así que lo he fijado en 1, pero si quisieras ver el emisor en todo su esplendor podrías fijarlo a 5 o a 10 y verías al cerezo en todo su apogeo.

El campo Amount Ratio se multiplica en cada ciclo por el valor del campo Amount. Es importante porque nos permite variar el caudal del emisor sin que este se reinicie. Si variases por script el campo Amount verías que el emisor se reiniciaría completamente desde el principio, lo que quedaría poco realista. Sin embargo, si lo que cambias en el Amount Ratio, el emisor no se interrumpirá, pero reducirá su caudal. 

El campo Time--> Lifetime es el que define cuánto tiempo se mantiene visible la partícula, desde si nacimiento, hasta que desaparece. Yo lo he fijado a 3 para que le dé tiempo a las flores a alcanzar el suelo antes de desaparecer.

En cuanto al campo Drawing--> Visibility Rect, nos permite dimensionar un rectángulo dentro del cual serán visibles las partículas del emisor. En cuanto las partículas abandonen el recuadro, dejarán de ser visibles. Lo normal es fijar el rectángulo para que sea algo más grande que la pantalla de manera que las partículas sean visibles dentro de esta, pero dejen de procesarse al salir de ella.

El último campo del nodo que he configurado es el de Texture. En él podemos meter un sprite que le dé apariencia visual a la partícula. En vez de un sprite individual, yo he usado un sprite sheet para generar una animación. 

Un sprite sheet es una imagen compuesta por imágenes individuales. Se usa por comodidad, para que el grupo artístico le pase a los desarrolladores todas las imágenes en unos pocos sprite sheets, en vez de en innumerables ficheros. Por otro lado, los engines están optimizados para leer sprite sheets. Eso quiere decir que es mucho más eficiente que el engine cargue memoria un sprite sheet y "recorte" de él las imágenes individuales que forzarle a abrir y cargar en memoria múltiples ficheros de imagen. 

Sprite sheet de las flores
Sprite sheet de las flores

Puedes hacer un sprite sheet juntando las imágenes individuales en un editor como Gimp o usando una herramienta especializada como TexturePacker. Yo he usado esta última. Lo importante en todo caso es que, si quieres usar un sprite sheet para generar una animación, ordenes las distintas imágenes individuales horizontalmente y de manera equiespaciada a lo largo de la imagen conjunta.

Para animar el sprite tienes que configurar el nodo a nivel de material y, para ello, tienes que dotar al campo Process Material de un recurso ParticleProcessMaterial y entrar a configurar este. Hecho eso, tienes que configurar su parámetro Process Material--> Animation --> Speed a 1 (tanto min como max) y el parámetro Process Material--> Animation --> Offset (con el min en 0 y el max en 1). El resultado es que el sprite sheet se recorrerá a velocidad constante conforme caiga la partícula, pero no siempre empezará desde el mismo punto. Eso hará que la flor cambie de sprite conforme vaya cayendo, como si fuera girando con la caída, pero al no empezar todas las partículas desde la misma imagen individual, habrá más variedad y será más orgánico. Precisamente, para ahondar esa variedad he hecho que las distintas partículas sean de distinto tamaño, haciendo que se generen de un tamaño aleatorio entre 0.03 y 0.05, para ello he fijado el parámetro Process Material--> Display --> Scale en mínimo 0.03 y máximo 0.05.

Para que las flores no estén visibles exactamente el mismo tiempo, lo que sería poco natural, he fijado el parámetro Process Material --> Lifetime Randomness a 0.07. Eso hace que el tiempo de vida de cada partícula varíe ligeramente de manera aleatoria.

Como la escena es 2D, he desactivado la posibilidad de que las partículas se muevan en el eje Z con el parámetro ProcessMaterial --> Particle Flags --> Disable Z.

Otra configuración importante es la forma del generador de partículas, Estas se generan en puntos aleatorios del interior de un volumen con una forma geométrica determinada. Por defecto, esta forma es un cubo. Pero se pueden configurar múltiples formas: un punto o varios, el volumen de una esfera, la superficie de una esfera o un anillo. En mi opinión, la forma de la copa del cerezo se parece más a una esfera que a un cubo, así que he fijado el volumen de generación a la primera forma, en el parámetro Process Material--> Spawn--> Position--> Emission Shape. El radio de la esfera se fija con el parámetro Process Material--> Spawn--> Position--> Emission Shape Radius. También he escorzado ligeramente la esfera a la derecha (ya que la copa del árbol es ligeramente asimétrica hacia ese lado) fijando a X=100, Y=0 y Z=0 el parámetro Process Material--> Spawn--> Position--> Emission Shape Offset.

Para que la partícula caiga, hay que configurar una fuerza de la gravedad, esto se hace fijando un valor para la componente Y del parámetro Process Material --> Accelerations--> Gravity. Mediante prueba y error, he llegado a la conclusión de que el valor que mejor me encajaba era 50.

Movimiento del sistema de partículas

El sistema que hemos configurado se comporta como un volumen esférico en el que se crean aleatoriamente partículas que caen al suelo por efecto de la gravedad. Si nuestro árbol estuviera quieto, ahí podríamos acabar el artículo, pero no es así. En capítulos anteriores implementamos que el árbol pudiera oscilar por efecto del viento. Quedaría raro que la copa del árbol se moviese y que la esfera de creación de las partículas no se moviese en la copa. En un caso extremo, la copa podría oscilar tanto que la esfera de creación de las partículas podría quedar fuera de ella haciendo que las flores pareciesen surgir del aire. No, la esfera de creación de las partículas se tiene que mover junto con la copa, al compas de la oscilación de esta. Si la oscilación de la copa fuera física, quizás podríamos aprovechar que el sistema de partículas es hijo del árbol, pero la oscilación es un mero efecto visual obtenido a través de un shader. Más allá de la apariencia visual, lo cierto es que el árbol no altera su posición por lo que el su sistema de partículas hijo tampoco lo hará.

En vez de eso, lo que vamos a hacer es dotar al nodo del sistema de partículas de su propio script de tal manera que sea este el que se encargue de desplazar su posición. Este script reproducirá el mismo algoritmo que el del shader de manera que el desplazamiento del sistema de partículas sea similar al desplazamiento de la copa.

El script del sistema de partículas se encuentra en la ruta Scripts/LeafEmitter.cs.

Se configura desde el inspector a través de 4 campos exportados:

Campos exportados por LeafEmitter.cs
Campos exportados por LeafEmitter.cs

El primer campo, el de _minimumFlowerAmount es la cantidad de flores emitidas por el sistema cuando el árbol se encuentra en reposo,

El segundo campo, OscillationMultiplier, sirve para definir la amplitud máxima del movimiento oscilante del sistema de particulas.

El tercer campo es WindStrength, es la fuerza del viento.

Por último, el campo HorizontalOffset es similar al uniform del mismo nombre que usamos en el shader del árbol para sesgar la oscilación hacia la derecha o hacia la izquierda.

Una vez fijados esos campos, se ejecuta el siguiente algoritmo en cada fotograma visual:

Método _Process() de LeafEmitter.cs
Método _Process() de LeafEmitter.cs

En sí mismo el algoritmo es muy sencillo: en la línea 55 se calcula la oscilación y en la línea 57 se usa esa oscilación para desplazar la posición horizontal del sistema de partículas.

La oscilación se calcula a partir del método get_oscillation():

Método get_oscilation() en LeafEmitter.cs.
Método get_oscilation() en LeafEmitter.cs.

Este método se limita reproducir el algoritmo que ya usamos en el shader del árbol para definir el movimiento de los vértices superiores de la textura.

Claro, para que el movimiento oscilatorio del sistema de partículas coincida con el de la copa no sólo se necesita que su algoritmo sea el mismo, sino que lo alimentemos con las mismas variables. Eso lo podemos hacer manualmente desde el inspector, asegurándonos de que los valores del shader del movimiento del árbol y los del movimiento del sistema de partículas sean los mismos, pero es mucho más sencillo si implementamos un mecanismo de sincronización entre el shader del árbol y el script del sistema de partículas.

Sincronización del shader con el sistema de partículas

Esa sincronización se puede hacer desde un script situado en el árbol y que pueda acceder simultáneamente al shader del árbol y al script del sistema de partículas. 

El script se encuentra en la ruta Scripts/Tree.cs.

Los campos de configuración que exporta al inspector son los siguientes:

Campos exportados por Tree.cs
Campos exportados por Tree.cs

El truco está en que esos campos son en realidad propiedades que aplican sus modificaciones tanto al shader de la oscilación del árbol, como al script LeafEmitter del emisor de partículas.

Propiedad de WindStrength de Tree.cs
Propiedad de WindStrength de Tree.cs

Como se puede ver en el código anterior, cuando se le pide un valor a la propiedad, esta devuelve el valor fijado en el shader de la oscilación del árbol (línea 20). Cuando, por el contrario, se le pide fijar un valor, la propiedad se lo fija al shader (línea 27) y también al script del emisor de partículas (línea 30).

Las otras dos propiedades, HorizontalOffset y MaximumOscillationAmplitude, tienen un funcionamiento similar.

De esta manera, cualquier cambio que hagamos en las propiedades de Tree.cs se replicarán automáticamente en las propiedades equivalentes de LeafEmitter.

Eso sirve para configurar todas las propiedades de la oscilación menos una: el tiempo. ¿Cómo podemos hacer para que tanto Tree como LeafEmitter usen la misma referencia de tiempo para sus respectivas oscilaciones? generando la cuenta de tiempo nosotros y pasándosela tanto al shadel del árbol como al script del emisor de partículas.

El método _Process() del script del árbol es el que lleva la cuenta:

Método Process() de Tree.cs
Método Process() de Tree.cs

En la línea 102 del método nos limitamos a sumar el tiempo desde el último fotograma (delta).

Time es en realidad una propiedad, y es la que se encarga de actualizar tanto el shader de la oscilación del árbol, como el emisor de partículas.

Propiedad Time de Tree.cs
Propiedad Time de Tree.cs

La línea 83 es la que actualiza el shader y la 84 el emisor de partículas. Precisamente por esto exponíamos un uniform para el tiempo desde el shader del árbol.

Con el mismo algoritmo, los mismos parámetros y la misma variable de tiempo, el árbol y el emisor de partículas pueden oscilar al compás. En la siguiente captura he seleccionado el nodo del emisor de partículas. Su posición se señala con una cruz. Fíjate cómo esta se desplaza de manera acompasada con el árbol.


Aquí acaba esta introducción a los emisores de partículas. En futuros artículos explicaré cómo implementar un efecto lluvia, así como una representación visual del viento.