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.

Custom drawing in a 2D node is really useful. Here are some use cases:

  • Drawing shapes or logic that existing nodes can't do, such as an image with trails or a special animated polygon.

  • Visualizaciones que no son compatibles con los nodos, como 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 a large number of nodes, possibly lowering memory usage and improving performance.

  • Making a custom UI control. There are plenty of controls available, but when you have unusual needs, you will likely need a custom control.

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.

If re-drawing is required because a state or something else changed, call CanvasItem.update() in that same node and a new _draw() call will happen.

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!

Antialiased drawing

Godot offers method parameters in draw_line to enable antialiasing, but it doesn't work reliably in all situations (for instance, on mobile/web platforms, or when HDR is enabled). There is also no antialiased parameter available in draw_polygon.

As a workaround, install and use the Antialiased Line2D add-on (which also supports antialiased Polygon2D drawing). Note that this add-on relies on high-level nodes, rather than low-level _draw() functions.

Herramientas

Drawing your own nodes might also be desired while running them in the editor. This can be used as a preview or visualization of some feature or behavior. See Ejecutando código en el editor for more information.