2D中的自定义绘图

为什么?

Godot有Sprite、多边形、粒子和各种东西的节点。 在大多数情况下,他们已经足够了, 但并不总是这样。在因为不存在绘制特定 东西 的节点,而在恐惧、焦虑和愤怒中哭泣之前...有知道, 让任何2D节点(不管它是继承自 Control 还是 Node2D ) 绘制一个自定义命令是很轻松的。 而且这 真的非常 容易操作。

但是...

在节点中手动自定义绘图 真的 非常有用。 以下是一些示例原因:

  • 绘制未由节点处理的形状或逻辑(例如:制作一个可以绘制圆,带有轨迹的图像,特殊类型的动画多边形等的节点)。
  • 与节点不兼容的可视化:(例如:俄罗斯方块)。 Godot俄罗斯方块示例使用自定义绘制功能来绘制那些小块。
  • 绘制大量的简单对象。自定义绘制避免了使用节点的开销,这减少了内存占用,而且有可能更快。
  • 制作自定义UI控件。 尽管有很多可用的控件,但很容易遇到需要制作新自定义控件的情况。

好吧,怎么做?

添加一个脚本到任何 CanvasItem 的派生节点,如 ControlNode2D。 然后重载_draw()函数。

extends Node2D

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

绘制命令在 CanvasItem 类型引文中有所描述, 有不少绘制命令。

更新

``_draw()``函数只调用一次,然后绘制命令被缓存并记住,因此不需要进一步调用。

如果由于状态或其他更改而需要重新绘制,只需在同一节点中调用 CanvasItem.update() 就会发生新的_draw()调用。

这是一个更复杂的示例。 这是一个被修改就会重新绘制的纹理变量:

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

在某些情况下,可能需要绘制每一帧。 为此,只需从 _process()``回调调用``update(),如下所示:

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 Engine的自定义绘图功能来绘制Godot不提供功能的内容。 比如,Godot提供了``draw_circle()``函数,它会绘制一个完整的圆。 但是,画一个圆的一部分怎么说?你必须编写一个函数来执行此操作, 自己绘制它。

弧函数

弧由其所在的圆的参数定义。 即:中心位置和半径。弧本身由开始的角度和停止的角度来定义。 这些是我们必须为绘图提供的4个参数。 我们还将提供颜色值,因此我们可以根据需要绘制不同颜色的圆弧。

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数组。

下一步包括计算构成弧的这32个点的实际位置。 这是在第一个for循环中完成的:我们迭代我们想要计算位置的点的数量,后面+1来包括最后一个点。 我们首先确定起点和终点之间每个点的角度。

每个角度减小90°的原因是我们将使用三角函数计算每个角度的2D位置(你知道,余弦和正弦之类的东西......)。 但是,为了简单,cos()和sin()使用弧度,而不是度数。 虽然我们想在12点钟位置开始计数,但0°(0弧度)的角度从3点钟位置开始。 因此我们将每个角度减小90°,以便从12点位置开始计数。

以角度 angle (单位是弧度)位于圆上的点的实际位置由 Vector2(cos(angle), sin(angle)) 给出。 由于cos()和sin()返回介于-1和1之间的值,因此位置位于半径为1的圆上。要将此位置放在我们的半径为 radius 的辅助圆上,我们只需要将那个位置乘以 radius 。 最后,我们需要将我们的辅助圆定位在 center 位置,这是通过将其与我们的``Vector2``相加来实现的。 最后,我们在之前定义的``PoolVector2Array``中插入这个点。

现在,我们需要实际绘制我们的点。 你可以想象,我们不会简单地画出我们的32个点:我们需要绘制每一点之间的所有内容。 我们可以使用前面的方法自己计算每个点,然后逐个绘制。 但这太复杂和低效了(除非确实需要)。 因此,我们只需在每对点之间绘制线条。 除非我们的辅助圆的半径很大,否则一对点之间每条线的长度永远不会长到足以看到它们。 如果发生这种情况,我们只需要增加点的个数就可以了。

在屏幕上绘制弧形

我们现在有一个在屏幕上绘制内容的函数; 是时候在 _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);
}

结果:

../../_images/result_drawarc.png

弧多边形函数

我们可以更进一步,不仅仅绘制一个由弧定义的扇形的边缘,还可以绘制其形体。 该方法与以前完全相同,只是我们绘制的是多边形而不是线条:

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

动态自定义绘图

好吧,我们现在能够在屏幕上绘制自定义内容。 然而,它是静态的; 我们让这个形状围绕中心转动吧。 这样做的方法就是随着时间的推移改变angle_from和angle_to值。 对于我们的示例,我们将简单地将它们递增50。此增量值必须保持不变,否则旋转速度将相应地改变。

首先,我们必须在我们的angle_from和angle_to变量变成全局变量, 放在脚本顶部。 另请注意,您可以将它们存储在其他节点中并使用``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;
}

我们在_process(delta)函数中更改这些值。

我们也在这里增加angle_from和angle_to值。 但是,我们不能忘记将结果 wrap() 在0到360°之间! 也就是说,如果角度是361°,那么它实际上是1°。 如果您不包装这些值,脚本将正常工作,但角度值将随着时间的推移变得越来越大,直到它们达到Godot可以管理的最大整数值(2^31 - 1)。 当发生这种情况时,Godot可能会崩溃或产生意外行为。

最后,我们不要忘记调用update()函数,它自动调用_draw()。 这样,您可以控制何时要刷新这一帧。

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

另外,不要忘记修改``_draw()``函数来使用这些变量:

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

我们运行吧! 它工作正常,但弧线旋转快得疯掉了! 怎么了?

原因是你的GPU实际上正在尽可能快地显示帧。 我们需要以这个速度为基准“标准化”绘图的速度。 为了实现这个效果,我们必须使用_process()函数的``delta``参数。 delta 包含最后两个渲染帧之间经过的时间。 它通常很小(约0.0003秒,但这取决于你的硬件)。 因此,使用``delta`` 来控制绘图可确保程序在每个人的硬件上以相同的速度运行。

在我们的示例中,我们只需要在 _process()``函数中将``rotation_angle``变量乘以``delta 。 这样,我们的2个角度会以一个小得多的值递增,值的大小直接取决于渲染速度。

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

让我们再运行一次! 这次,旋转显示正常!

工具

绘制自己的节点的功能在编辑器中, 用来可视化/预览某种特性或行为的时候可能会有用。

请记住在脚本顶部使用“tool”关键字(如果您忘记了它做什么,请查看 GDScript 基础 参考文档)。