Dibujos personalizados en 2D

Introducción

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.

Dibujar manualmente en un nodo 2D es realmente útil, aquí hay algunos casos de uso:

  • Dibujar formas o lógica que los nodos existentes no pueden hacer, como una imagen con estelas o un polígono animado especial.

  • Visualizaciones que no son tan compatibles con los nodos, como un tablero de tetris. (El ejemplo del tetris utiliza una función de dibujo personalizada para dibujar los bloques)

  • Dibujar un gran número de objetos simples. El dibujo personalizado evita la sobrecarga del uso de nodos lo que lo hace menos intensivo en el uso de memoria y potencialmente más rápido.

  • Hacer un control de UI personalizado. Hay muchos nodos Control disponibles, Sin embargo, cuando tienes necesidades poco comunes, es probable que necesites un control personalizado.

Dibujando

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

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 redibujar 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's visual representation.

func _draw():
    draw_texture(texture, 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()

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 un 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 costo de procesamiento. En general, si la forma de un figura es muy grande (o en 3D, cercana a la cámara), requerirá más puntos para ser dibujada sin verse muy angulosa. Al contrario, si una figura es muy pequeña(o en 3D, lejos de la cámara), se pueden reducir los puntos para ahorrar costo de procesamiento. Esto es conocido 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)

¿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 array 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)

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)
../../_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

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()

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 )

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()

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

Dibujo antialiasing

Godot ofrece parámetros de método en draw_line para habilitar el antialiasing, pero no funciona de manera confiable en todas las situaciones (por ejemplo, en plataformas móviles/web o cuando el HDR está habilitado). Además, no hay un parámetro "antialiased" disponible en draw_polygon.

Como solución alternativa, puedes instalar y utilizar el complemento "Antialiased Line2D" <https://github.com/godot-extended-libraries/godot-antialiased-line2d>, el cual también admite el dibujo antialiasing de Polygon2D. Ten en cuenta que este complemento se basa en nodos de alto nivel en lugar de las funciones "_draw()" de bajo nivel.

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. Consulta Ejecutando código en el editor para obtener más información.