Dibujos personalizados en 2D

¿Por qué?

Godot posee nodos para dibujar sprites, polígonos, partículas, etc., para la mayoría de los casos eso es suficiente, pero no siempre. Antes de desesperar al ver que no existe un nodo para dibujar algo específico, es bueno saber que es posible hacer que cualquier nodo 2D (ya sea uno basado en Control o Node2D ) puede aceptar comandos de dibujo personalizados. Esto es muy fácil de hacer.

Pero…

Dibujar manualmente en un nodo es muy útil, aquí hay unos ejemplos de por qué:

  • Dibujar figuras o crear lógica que no es manejada por nodos (ejemplo: hacer que un nodo dibuje un círculo, una imagen con un rastro o trail, un modo especial de polígono animado, etc.).
  • Visualizaciones que no son compatibles con los nodos: (ejemplo: un tablero de tetris). El ejemplo del tetris utiliza una función de dibujo personalizado para dibujar los bloques.
  • Drawing a large number of simple objects. Custom drawing avoids the overhead of using nodes which makes it less memory intensive and potentially faster.
  • Hacer un control de UI personalizado. Hay muchos nodos Control disponibles pero puede existir la necesidad de crear uno nuevo, diferente.

Bien, ¿cómo?

Agrega un script a cualquier nodo derivado de CanvasItem, como Control o Node2D. Luego sobreescribe la función _draw().

extends Node2D

func _draw():
    # Your draw commands here
    pass
public override void _Draw()
{
    // Your draw commands here
}

Los comandos están descriptos en la referencia de la clase CanvasItem.

Actualizando

La función _draw() es llamada una vez y los comandos son guardados y recordados, así que no es necesario llamarla continuamente.

Si es necesario volver a dibujar porque algo ha cambiado, simplemente llama CanvasItem.update() en el nodo y se ejecutará una nueva llamada a _draw().

Aquí hay un ejemplo más complejo. Una textura variable que se dibujará nuevamente si es modificada:

extends Node2D

export (Texture) var texture setget _set_texture

func _set_texture(value):
    # If the texture variable is modified externally,
    # this callback is called.
    texture = value #texture was changed
    update() # update the node

func _draw():
    draw_texture(texture, Vector2())
public class CustomNode2D : Node2D
{
    private Texture _texture;
    public Texture Texture
    {
        get
        {
            return _texture;
        }

        set
        {
            _texture = value;
            Update();
        }
    }

    public override void _Draw()
    {
        DrawTexture(_texture, new Vector2());
    }
}

En algunos casos, puede ser necesario dibujar en cada frame. Para esto, sólo llama update() dentro de _process(), así:

extends Node2D

func _draw():
    # Your draw commands here
    pass

func _process(delta):
    update()
public class CustomNode2D : Node2D
{
    public override void _Draw()
    {
        // Your draw commands here
    }

    public override void _Process(float delta)
    {
        Update();
    }
}

Un ejemplo: dibujando arcos de curva

Ahora usaremos la funcionalidad de dibujo personalizado de Godot Engine para dibujar algo para lo que Godot no ofrece ninguna función. Por ejemplo, Godot proporciona una función draw_circle() que dibuja un círculo entero. Sin embargo, ¿qué tal dibujar una porción de un círculo? Tendrás que crear tu mismo una función para realizarlo y dibujarlo.

Función arco

Un arco está definido por los parámetros de un círculo de soporte, es decir, posición central y radio. El arco en sí es definido por el ángulo en el que comienza y el ángulo en el que termina. Esos son los 4 parámetros que pasamos a nuestra función de dibujo. Podemos también agregar un valor de color para dibujar el arco en diferentes colores si o deseamos.

Básicamente, dibujar una figura requiere descomponerla en cierto número de puntos, vinculados uno al otro. Como puedes imaginar, mientras más puntos posea la figura, más suave que se verá, pero será más «pesada» en términos de procesamiento. En general, si una figura es muy grande, requerirá más puntos para ser dibujada sin verse muy angulosa. Al contrario, si una figura es muy pequeña, se pueden reducir los puntos para ahorrar costo de procesamiento. Esto es llamado Nivel de Detalle (LoD, por sus siglas en inglés). En nuestro ejemplo usaremos un número fijo de puntos, sin importar el radio.

func draw_circle_arc(center, radius, angle_from, angle_to, color):
    var nb_points = 32
    var points_arc = PoolVector2Array()

    for i in range(nb_points + 1):
        var angle_point = deg2rad(angle_from + i * (angle_to-angle_from) / nb_points - 90)
        points_arc.push_back(center + Vector2(cos(angle_point), sin(angle_point)) * radius)

    for index_point in range(nb_points):
        draw_line(points_arc[index_point], points_arc[index_point + 1], color)
public void DrawCircleArc(Vector2 center, float radius, float angleFrom, float angleTo, Color color)
{
    int nbPoints = 32;
    var pointsArc = new Vector2[nbPoints];

    for (int i = 0; i < nbPoints; ++i)
    {
        float anglePoint = Mathf.Deg2Rad(angleFrom + i * (angleTo - angleFrom) / nbPoints - 90f);
        pointsArc[i] = center + new Vector2(Mathf.Cos(anglePoint), Mathf.Sin(anglePoint)) * radius;
    }

    for (int i = 0; i < nbPoints - 1; ++i)
        DrawLine(pointsArc[i], pointsArc[i + 1], color);
}

¿Recuerdas el número de puntos en el cual fue descompuesta nuestra figura? Fijamos este número en la variable nb_points a un valor de 32. Luego inicializamos un PoolVector2Array vacío, que es un arreglo de Vector2.

El siguiente paso consiste en calcular las posiciones reales de estos 32 puntos que componen un arco. Esto se hace en el primer bucle for: iteramos sobre el número de puntos para los que queremos calcular las posiciones, más uno para incluir el último punto. Primero determinamos el ángulo de cada punto, entre el ángulo inicial y el ángulo final.

La razón por la que cada ángulo se reduce en 90° es que calcularemos las posiciones 2D de cada ángulo usando trigonometría (ya sabes, cosas de coseno y seno…). Sin embargo, para simplificarlo, cos() y sin() usan radianes, no grados. El ángulo de 0° (0 radianes) comienza a las 3 en punto, aunque queremos empezar a contar a las 12 en punto. Así que reducimos cada ángulo en 90° para empezar a contar desde las 12 en punto.

La posición actual de un punto ubicado en un angulo angle (en radianes) es dada por Vector2(cos(angle), sin(angle)). Como cos() y sin() retornan valores entre -1 y 1, la posición es ubicada en un círculo de radio 1. Para tener esta posición en nuestro círculo de soporte, que tiene un radio de radius, simplemente tenemos que multiplicar la posición por radius. Finalmente, tenemos la posición del círculo de soporte del arco en la posición center, lo que se consigue agregando esto a nuestro valor Vector2. Finalmente insertamos el punto en e PoolVector2Array que fue definido previamente.

Ahora necesitamos dibujar los puntos. Como puedes imaginar, no dibujaremos nuestros 32 puntos solamente, tendremos que dibujar todo lo que está entre ellos. Podríamos haber calculado los puntos nosotros mismos utilizando el método previo y dibujarlos uno a uno, pero esto es muy complicado e ineficiente (a menos que sea necesario). Así que simplemente dibujaremos líneas entre cada par de puntos. A menos que el radio de nuestro círculo de soporte sea muy grande, el largo de la línea entre cada par de puntos nunca será lo suficientemente larga para verlos. Si esto sucede, simplemente aumentamos el número de puntos.

Dibujar el arco en pantalla

Ahora tenemos una función que dibuja cosas en la pantalla: Es momento de llamar a la función _draw():

func _draw():
    var center = Vector2(200, 200)
    var radius = 80
    var angle_from = 75
    var angle_to = 195
    var color = Color(1.0, 0.0, 0.0)
    draw_circle_arc(center, radius, angle_from, angle_to, color)
public override void _Draw()
{
    var center = new Vector2(200, 200);
    float radius = 80;
    float angleFrom = 75;
    float angleTo = 195;
    var color = new Color(1, 0, 0);
    DrawCircleArc(center, radius, angleFrom, angleTo, color);
}

Resultado:

../../_images/result_drawarc.png

Función de polígono arco

Podemos llevar esto un paso más allá y no sólo escribir una función que dibuje la parte plana del disco definida por el arco, sino también su forma. El método es exactamente el mismo que el anterior, excepto que dibujamos un polígono en lugar de líneas:

func draw_circle_arc_poly(center, radius, angle_from, angle_to, color):
    var nb_points = 32
    var points_arc = PoolVector2Array()
    points_arc.push_back(center)
    var colors = PoolColorArray([color])

    for i in range(nb_points + 1):
        var angle_point = deg2rad(angle_from + i * (angle_to - angle_from) / nb_points - 90)
        points_arc.push_back(center + Vector2(cos(angle_point), sin(angle_point)) * radius)
    draw_polygon(points_arc, colors)
public void DrawCircleArcPoly(Vector2 center, float radius, float angleFrom, float angleTo, Color color)
{
    int nbPoints = 32;
    var pointsArc = new Vector2[nbPoints + 1];
    pointsArc[0] = center;
    var colors = new Color[] { color };

    for (int i = 0; i < nbPoints; ++i)
    {
        float anglePoint = Mathf.Deg2Rad(angleFrom + i * (angleTo - angleFrom) / nbPoints - 90);
        pointsArc[i + 1] = center + new Vector2(Mathf.Cos(anglePoint), Mathf.Sin(anglePoint)) * radius;
    }

    DrawPolygon(pointsArc, colors);
}
../../_images/result_drawarc_poly.png

Dibujos personalizados dinámicos

Muy bien, ahora podemos hacer dibujos personalizados en la pantalla. Sin embargo, es estático: Hagamos que esta forma gire alrededor del centro. La solución para hacer esto es simplemente cambiar los valores angle_from y angle_to a lo largo del tiempo. Para nuestro ejemplo, simplemente los incrementaremos en 50. Este valor de incremento tiene que permanecer constante o bien la velocidad de rotación cambiará en consecuencia.

Primero, tenemos que hacer que ambos, angle_from y angle_to sean variables del ámbito de la instancia (al alcance de cualquier función) al principio del script. También se pueden colocar en otros nodos y accederlas mediante get_node("nombre_nodo").nombre_variable.

extends Node2D

var rotation_angle = 50
var angle_from = 75
var angle_to = 195
public class CustomNode2D : Node2D
{
    private float _rotationAngle = 50;
    private float _angleFrom = 75;
    private float _angleTo = 195;
}

Haremos que esos valores cambien en la función _process(delta).

También aquí incrementamos nuestros valores angle_from y angle_to. Sin embargo, no debemos olvidarnos de usar una función wrap() para ajustar los valores resultantes entre 0 y 360°, es decir, si el ángulo es 361°, entonces en realidad es 1°. Si no ajustas estos valores, el script funcionará correctamente, pero los valores de los ángulos crecerán más y más con el tiempo hasta que alcancen el máximo valor entero que Godot puede manejar (2^31 - 1). Cuando esto suceda, Godot puede fallar o producir un comportamiento inesperado.

Finalmente, no debemos olvidar llamar la función update(), la que provoca una llamada a _draw(). De este modo, podrás controlar cuando quieres que se actualice.

func _process(delta):
    angle_from += rotation_angle
    angle_to += rotation_angle

    # We only wrap angles when both of them are bigger than 360.
    if angle_from > 360 and angle_to > 360:
        angle_from = wrapf(angle_from, 0, 360)
        angle_to = wrapf(angle_to, 0, 360)
    update()
private float Wrap(float value, float minVal, float maxVal)
{
    float f1 = value - minVal;
    float f2 = maxVal - minVal;
    return (f1 % f2) + minVal;
}

public override void _Process(float delta)
{
    _angleFrom += _rotationAngle;
    _angleTo += _rotationAngle;

    // We only wrap angles when both of them are bigger than 360.
    if (_angleFrom > 360 && _angleTo > 360)
    {
        _angleFrom = Wrap(_angleFrom, 0, 360);
        _angleTo = Wrap(_angleTo, 0, 360);
    }
    Update();
}

Y no olvides modificar la función _draw() para que haga uso de estas variables:

func _draw():
   var center = Vector2(200, 200)
   var radius = 80
   var color = Color(1.0, 0.0, 0.0)

   draw_circle_arc( center, radius, angle_from, angle_to, color )
public override void _Draw()
{
    var center = new Vector2(200, 200);
    float radius = 80;
    var color = new Color(1, 0, 0);

    DrawCircleArc(center, radius, _angleFrom, _angleTo, color);
}

Ejecutémoslo y veamos como funciona. Podemos notar que el arco rota demasiado rápido, qué está mal?

La razón es que tu GPU está mostrando frames tan rápido como puede, necesitamos «estabilizar» la velocidad de dibujado acorde a esta. Para conseguirlo, nos aseguraremos de utilizar el parámetro delta de la función _process(). delta contiene un número que representa el tiempo transcurrido entre dos frames. Generalmente es un valor muy bajo (alrededor de 0.0003 segundos, pero depende del hardware). Así que utilizando delta se puede asegurar que el programa se ejecutará a la misma velocidad en todo tipo de hardware.

En nuestro caso, simplemente multiplicaremos nuestra variable rotation_angle por delta en la función _process(). De este modo, nuestros 2 ángulos se incrementarán por un valor muy bajo, dependiendo directamente de nuestra velocidad de procesamiento.

func _process(delta):
    angle_from += rotation_angle * delta
    angle_to += rotation_angle * delta

    # We only wrap angles when both of them are bigger than 360.
    if angle_from > 360 and angle_to > 360:
        angle_from = wrapf(angle_from, 0, 360)
        angle_to = wrapf(angle_to, 0, 360)
    update()
public override void _Process(float delta)
{
    _angleFrom += _rotationAngle * delta;
    _angleTo += _rotationAngle * delta;

    // We only wrap angles when both of them are bigger than 360.
    if (_angleFrom > 360 && _angleTo > 360)
    {
        _angleFrom = Wrap(_angleFrom, 0, 360);
        _angleTo = Wrap(_angleTo, 0, 360);
    }
    Update();
}

¡Ejecutémoslo de nuevo! ¡Esta vez, la rotación se muestra bien!

Herramientas

Dibujar tus propios nodos también puede ser conveniente mientras los ejecutas en el editor para usarlos como vista previa o visualización de alguna característica o comportamiento.

Recuerde usar la palabra clave «tool» al principio del script (ver la referencia Bases de GDScript si olvidas lo que hace).