Custom drawing in 2D

왜?

Godot has nodes to draw sprites, polygons, particles, and all sorts of stuff. For most cases, this is enough; but not always. Before crying in fear, angst, and rage because a node to draw that specific something does not exist... it would be good to know that it is possible to easily make any 2D node (be it Control or Node2D based) draw custom commands. It is really easy to do it, too.

But...

노드에서 수동으로 사용자 임의 그림은 진짜 유용합니다. 여기 왜 그런지 몇 가지 예가 있습니다:

  • Drawing shapes or logic that is not handled by nodes (example: making a node that draws a circle, an image with trails, a special kind of animated polygon, etc).
  • Visualizations that are not that compatible with nodes: (example: a tetris board). The tetris example uses a custom draw function to draw the blocks.
  • Drawing a large number of simple objects. Custom drawing avoids the overhead of using nodes which makes it less memory intensive and potentially faster.
  • 사용자 정의 UI 컨트롤 만들기. 사용할 수 있는 컨트롤은 많지만, 새롭고 맞춤화된 컨트롤을 만들어야 할 필요성에 부딪치기 쉽습니다.

OK, how?

CanvasItem 를 상속받는 Control 또는 Node2D 와 같은 노드에 스크립트를 추가하세요. 그런 다음 _draw() 함수를 재정의하세요.

extends Node2D

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

Draw commands are described in the CanvasItem class reference. There are plenty of them.

Updating

_draw() 함수는 단 한 번만 호출하고, 그 다음에는 그리기 명령을 캐싱하여 기억하므로 추가 호출은 불필요합니다.

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

여기 좀 더 복잡한 예가 있는데 텍스쳐 변수는 수정하면 다시 그려지게 됩니다:

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

In some cases, it may be desired to draw every frame. For this, just call update() from the _process() callback, like this:

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

예제: 원호 그리기

이제 Godot 엔진의 사용자 정의 그리기 기능을 사용하여 Godot이 제공하지 않는 기능을 그릴 겁니다. 예를 들어, Godot은 전체 원을 그리는 draw_circle() 함수를 제공합니다. 하지만 원의 일부를 그리는 것은 어떨까요? 이것을 수행하려면 함수를 코드화해서 직접 그려야 할 것입니다.

호(Arc) 함수

호는 보조하는 원 매개변수, 즉 중심 위치와 반지름에 의해 정의됩니다. 그 다음, 호 자체는 시작하는 각도와 정지하는 각도에 의해 정의됩니다. 이것들은 우리가 그리기 함수에 제공해야 하는 네 가지 인수입니다. 색 값도 제공해서 원하면 다른 색으로 호를 그릴 수 있을 겁니다.

Basically, drawing a shape on the screen requires it to be decomposed into a certain number of points linked from one to the next. As you can imagine, the more points your shape is made of, the smoother it will appear, but the heavier it will also be in terms of processing cost. In general, if your shape is huge (or in 3D, close to the camera), it will require more points to be drawn without it being angular-looking. On the contrary, if your shape is small (or in 3D, far from the camera), you may decrease its number of points to save processing costs; this is known as Level of Detail (LoD). In our example, we will simply use a fixed number of points, no matter the radius.

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);
}

우리 도형이 몇 개의 점으로 분해되어야 하는지 기억하시나요? 우리는 이 수를 nb_points 변수에 32 값으로 정합니다. 그 다음 우리는 빈 PoolVector2Array 를 초기화합니다. 그냥 Vector2 의 배열입니다.

The next step consists of computing the actual positions of these 32 points that compose an arc. This is done in the first for-loop: we iterate over the number of points for which we want to compute the positions, plus one to include the last point. We first determine the angle of each point, between the starting and ending angles.

각각의 각도가 90° 씩 줄어드는 이유는 각 각도를 삼각법을 사용하여 2D 위치를 계산하기 때문입니다(코사인과 사인을 알아야 합니다). 그러나 간단히 말하면 cos()sin() 는 각도가 아니라 라디안을 사용합니다. 0°의 각도(0 라디안)는 3시 방향에서 시작합니다(우리는 12시 방향에서 세기를 원하지만). 그래서 12시 방향에서부터 세기 위해 각각의 각도를 90° 씩 줄입니다.

The actual position of a point located on a circle at angle angle (in radians) is given by Vector2(cos(angle), sin(angle)). Since cos() and sin() return values between -1 and 1, the position is located on a circle of radius 1. To have this position on our support circle, which has a radius of radius, we simply need to multiply the position by radius. Finally, we need to position our support circle at the center position, which is performed by adding it to our Vector2 value. Finally, we insert the point in the PoolVector2Array which was previously defined.

Now, we need to actually draw our points. As you can imagine, we will not simply draw our 32 points: we need to draw everything that is between each of them. We could have computed every point ourselves using the previous method, and drew it one by one. But this is too complicated and inefficient (except if explicitly needed), so we simply draw lines between each pair of points. Unless the radius of our support circle is big, the length of each line between a pair of points will never be long enough to see them. If that were to happen, we would simply need to increase the number of points.

화면에 호 그리기

We now have a function that draws stuff on the screen; it is time to call it inside the _draw() function:

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);
}

Result:

../../_images/result_drawarc.png

Arc polygon function

We can take this a step further and not only write a function that draws the plain portion of the disc defined by the arc, but also its shape. The method is exactly the same as before, except that we draw a polygon instead of lines:

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

Dynamic custom drawing

All right, we are now able to draw custom stuff on the screen. However, it is static; let's make this shape turn around the center. The solution to do this is simply to change the angle_from and angle_to values over time. For our example, we will simply increment them by 50. This increment value has to remain constant or else the rotation speed will change accordingly.

First, we have to make both angle_from and angle_to variables global at the top of our script. Also note that you can store them in other nodes and access them using get_node().

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;
}

We make these values change in the _process(delta) function.

We also increment our angle_from and angle_to values here. However, we must not forget to wrap() the resulting values between 0 and 360°! That is, if the angle is 361°, then it is actually 1°. If you don't wrap these values, the script will work correctly, but the angle values will grow bigger and bigger over time until they reach the maximum integer value Godot can manage (2^31 - 1). When this happens, Godot may crash or produce unexpected behavior.

Finally, we must not forget to call the update() function, which automatically calls _draw(). This way, you can control when you want to refresh the frame.

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

Also, don't forget to modify the _draw() function to make use of these 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);
}

Let's run! It works, but the arc is rotating insanely fast! What's wrong?

The reason is that your GPU is actually displaying the frames as fast as it can. We need to "normalize" the drawing by this speed; to achieve that, we have to make use of the delta parameter of the _process() function. delta contains the time elapsed between the two last rendered frames. It is generally small (about 0.0003 seconds, but this depends on your hardware), so using delta to control your drawing ensures that your program runs at the same speed on everybody's hardware.

In our case, we simply need to multiply our rotation_angle variable by delta in the _process() function. This way, our 2 angles will be increased by a much smaller value, which directly depends on the rendering speed.

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

Let's run again! This time, the rotation displays fine!

도구

Drawing your own nodes might also be desired while running them in the editor to use as a preview or visualization of some feature or behavior.

Remember to use the "tool" keyword at the top of the script (check the GDScript 기초 reference if you forgot what this does).