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

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个参数。 我们还将提供颜色值,因此我们可以根据需要绘制不同颜色的圆弧。

基本上,在屏幕上绘制形状需要将其分解为一定量首位相接的点。 应该可以想象,点越多,它就越平滑,但处理开销就越大。 一般来说,如果你的形状很大(或者在3D场景中靠近相机),则需要绘制更多的点才不会看起来像是有棱角的。 相反,如果你的形状很小(或在3D场景里远离相机),你可以减少其点数以节省处理成本。 这称为 细节级别(Level of Detail, LoD) 。 在我们的示例中,无论半径如何,我们都只使用固定数量的点。

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 基础 参考文档)。