2D 中的自訂繪圖

前言

Godot 內建了用於繪製精靈、Polygon、多邊形、粒子、文字等各種常見遊戲開發場景的節點。不過,如果你需要做一些標準節點無法覆蓋的特殊需求,可以讓任何 2D 節點(例如 ControlNode2D 衍生節點)用自訂繪圖指令直接在畫面上繪製。

2D 節點中的自訂繪製*非常*有用。下面是一些用例:

  • 繪製現有節點型別無法完成的形狀或邏輯,例如帶有軌跡或特殊動態多邊形的圖像。

  • 繪製大量簡單物件,例如 2D 遊戲用的格線或棋盤。自訂繪圖可以避免使用大量節點帶來的額外負擔,有機會降低記憶體用量並提升效能。

  • 製作自訂的 UI 控制項,以滿足很多可用的控制項之外的特別需求。

繪製

將腳本新增到任何 CanvasItem 衍生節點(例如 ControlNode2D)上。然後覆寫 _draw() 方法。

extends Node2D

func _draw():
    pass  # Your draw commands here.

繪圖指令的詳細說明可參見 CanvasItem 類別說明文件。這類指令非常多,下面範例會介紹其中幾個常用的。

更新

_draw 方法只會被呼叫一次,之後繪圖指令會被快取起來,所以不需要再重複呼叫。

如果變數或狀態改變後需要重繪,只要在該節點呼叫 CanvasItem.queue_redraw,就會觸發新的 _draw() 呼叫。

以下是一個稍微複雜一點的範例,我們有一個可以隨時修改的貼圖變數,並且透過 setter,在貼圖變更時強制重繪:

extends Node2D

@export var texture : Texture2D:
    set(value):
        texture = value
        queue_redraw()

func _draw():
    draw_texture(texture, Vector2())

你可以在編輯器中將 FileSystem 分頁下的預設 icon.svg 拖曳到 Inspector 分頁的 Texture 屬性,來測試這段程式。當上面的腳本正在執行時,只要變更 Texture 屬性的值,貼圖也會隨之自動更新。

有時我們可能需要每一禎都重繪。這時可以在 _process 方法裡呼叫 queue_redraw,像這樣:

extends Node2D

func _draw():
    pass  # Your draw commands here.

func _process(_delta):
    queue_redraw()

座標與線寬對齊

繪圖 API 使用的是 CanvasItem 的座標系統,不一定是像素座標。也就是說,_draw() 用的是應用過 CanvasItem 轉換後的座標空間。此外,你還可以用 draw_set_transformdraw_set_transform_matrix 再加上自訂轉換。

使用 draw_line 時,應注意線寬。如果線寬為奇數,起點和終點座標應加上 0.5,才能讓線條置中,如下圖所示。

../../_images/draw_line.png
func _draw():
    draw_line(Vector2(1.5, 1.0), Vector2(1.5, 4.0), Color.GREEN, 1.0)
    draw_line(Vector2(4.0, 1.0), Vector2(4.0, 4.0), Color.GREEN, 2.0)
    draw_line(Vector2(7.5, 1.0), Vector2(7.5, 4.0), Color.GREEN, 3.0)

同理,對 draw_rect 方法(filled = false)也適用。

../../_images/draw_rect.png
func _draw():
    draw_rect(Rect2(1.0, 1.0, 3.0, 3.0), Color.GREEN)
    draw_rect(Rect2(5.5, 1.5, 2.0, 2.0), Color.GREEN, false, 1.0)
    draw_rect(Rect2(9.0, 1.0, 5.0, 5.0), Color.GREEN)
    draw_rect(Rect2(16.0, 2.0, 3.0, 3.0), Color.GREEN, false, 2.0)

抗鋸齒繪圖

Godot在 draw_line 中提供方法參數來啟用抗鋸齒功能,但並非所有自訂繪圖方法都提供這個 抗鋸齒(antialiased) 參數。

對於不提供 抗鋸齒(antialiased) 參數的自訂繪圖方法,你可以啟用 2D MSAA,這會影響整個視口的算繪。這個功能(2D MSAA)提供了高品質的抗鋸齒,但性能成本更高,而且只適用於特定元素。參見 2D 抗鋸齒 以瞭解更多資訊。

下圖比較了最小寬度(width=-1)的線條在 antialiased=falseantialiased=true,以及啟用 2D MSAA 2x、4x、8x(antialiased=false)下的效果。

../../_images/draw_antialiasing_options.webp

工具

有時你也會希望在編輯器中運行節點時自訂繪圖,這可以用來做功能或行為的預覽與視覺化。

你可以在 GDScript 和 C# 中使用 tool annotation 來達成這個效果。更多資訊請參考下方 範例 以及 在編輯器中執行程式碼

範例 1:繪製自訂形狀

現在我們會用 Godot 的自訂繪圖功能,畫出 Godot 內建函式無法直接繪製的東西。我們將用程式碼,純粹用繪圖指令重現 Godot 標誌。

你需要自行寫一個函式來完成這個繪圖。

備註

以下教學會使用固定座標集,這在高解析度螢幕(大於 1080p)下可能會顯得太小。如果你遇到這種情況,可以在專案設定的 Display > Window > Stretch > Scale 中調高視窗縮放比例(2 或 4 通常效果不錯),以因應高解析度顯示。

繪製自訂多邊形

雖然有專門用來繪製自訂多邊形的節點(Polygon2D),但這裡我們只用較低階的繪圖函式,將所有形狀畫在同一個節點上,這樣之後也方便擴充出更複雜的圖形。

首先,我們要定義一組點(X 和 Y 座標),作為這個形狀的基礎:

extends Node2D

var coords_head : Array = [
    [ 22.952, 83.271 ],  [ 28.385, 98.623 ],
    [ 53.168, 107.647 ], [ 72.998, 107.647 ],
    [ 99.546, 98.623 ],  [ 105.048, 83.271 ],
    [ 105.029, 55.237 ], [ 110.740, 47.082 ],
    [ 102.364, 36.104 ], [ 94.050, 40.940 ],
    [ 85.189, 34.445 ],  [ 85.963, 24.194 ],
    [ 73.507, 19.930 ],  [ 68.883, 28.936 ],
    [ 59.118, 28.936 ],  [ 54.494, 19.930 ],
    [ 42.039, 24.194 ],  [ 42.814, 34.445 ],
    [ 33.951, 40.940 ],  [ 25.637, 36.104 ],
    [ 17.262, 47.082 ],  [ 22.973, 55.237 ]
]

這種格式雖然精簡,但 Godot 並不直接支援用來繪製多邊形。在不同情境下,你可能需要從檔案讀取這些座標,或在執行時動態計算,因此可能要進行額外的轉換。

為了將這些座標轉換成 Godot 可用的格式,我們會寫一個新的方法 float_array_to_Vector2Array()。接著覆寫 _ready() 函式(Godot 會在執行開始時只呼叫一次),將這些座標載入變數:

var head : PackedVector2Array

func float_array_to_Vector2Array(coords : Array) -> PackedVector2Array:
    # Convert the array of floats into a PackedVector2Array.
    var array : PackedVector2Array = []
    for coord in coords:
        array.append(Vector2(coord[0], coord[1]))
    return array

func _ready():
    head = float_array_to_Vector2Array(coords_head);

最後,我們會使用 draw_polygon 方法來繪製這個形狀,傳入點的陣列(Vector2 格式)和顏色,如下所示:

func _draw():
    # We are going to paint with this color.
    var godot_blue : Color = Color("478cbf")
    # We pass the PackedVector2Array to draw the shape.
    draw_polygon(head, [ godot_blue ])

執行時你應該會看到類似這樣的畫面:

../../_images/draw_godot_logo_polygon.webp

請注意,Logo 下半部看起來呈現分段狀,這是因為這部分只用了少量點來定義。若想要模擬平滑曲線,可以在陣列中增加更多點,或用數學函式來插值產生曲線,直接在程式碼中建立平滑形狀(詳見 範例 2)。

多邊形會自動將最後一個點與第一個點連接,形成封閉形狀。

繪製連續線段

繪製一串不封閉成多邊形的連續線段,其方式與前述方法相似。我們會用連續線段來繪製 Godot 標誌上的嘴巴。

首先,定義構成嘴巴形狀的座標列表,如下所示:

var coords_mouth = [
    [ 22.817, 81.100 ], [ 38.522, 82.740 ],
    [ 39.001, 90.887 ], [ 54.465, 92.204 ],
    [ 55.641, 84.260 ], [ 72.418, 84.177 ],
    [ 73.629, 92.158 ], [ 88.895, 90.923 ],
    [ 89.556, 82.673 ], [ 105.005, 81.100 ]
]

我們會將這些座標載入一個變數,並另外定義一個可調整的線寬變數:

var mouth : PackedVector2Array
var _mouth_width : float = 4.4

func _ready():
    head = float_array_to_Vector2Array(coords_head);
    mouth = float_array_to_Vector2Array(coords_mouth);

最後,我們用 draw_polyline 方法來真正畫出這條線,如下:

func _draw():
    # We will use white to draw the line.
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")

    draw_polygon(head, [ godot_blue ])

    # We draw the while line on top of the previous shape.
    draw_polyline(mouth, white, _mouth_width)

你應該會看到以下結果:

../../_images/draw_godot_logo_polyline.webp

draw_polygon() 不同的是,polyline 只能為所有點指定一個單一顏色(第二個參數)。這個方法還有兩個額外參數:線寬(預設為最細),以及是否啟用抗鋸齒(預設關閉)。

_draw 的呼叫順序很重要——就像場景樹中的節點一樣,繪圖會從上到下依序執行,被後畫的圖形會覆蓋在前面的圖形上。如果兩個圖形重疊,最晚繪製的會蓋住之前的。在這個例子中,我們希望嘴巴畫在頭的上方,所以將其放在後面。

注意,我們可以用不同的方式定義顏色,例如十六進位色碼或預設顏色名稱。更多常數和顏色定義方式請參考 Color 類別。

繪製圓形

要畫出眼睛,我們會額外呼叫 4 次繪圖函式,分別以不同的大小、顏色與位置來畫出眼睛的形狀。

要畫圓形,可以用 draw_circle 方法,指定圓心的位置(以 Vector2 表示)、半徑與顏色:

func _draw():
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")
    var grey : Color = Color("414042")

    draw_polygon(head, [ godot_blue ])
    draw_polyline(mouth, white, _mouth_width)

    # Four circles for the 2 eyes: 2 white, 2 grey.
    draw_circle(Vector2(42.479, 65.4825), 9.3905, white)
    draw_circle(Vector2(85.524, 65.4825), 9.3905, white)
    draw_circle(Vector2(43.423, 65.92), 6.246, grey)
    draw_circle(Vector2(84.626, 66.008), 6.246, grey)

執行時你應該會看到這樣的畫面:

../../_images/draw_godot_logo_circle.webp

若要繪製不填滿的圓弧(由特定角度決定的圓的一部分),可以用 draw_arc 方法。

繪製線條

最後要繪製鼻子這個形狀時,我們會用一條線來近似。

你可以用 draw_line 方法來畫一段線段,只需提供起點和終點座標,如下:

func _draw():
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")
    var grey : Color = Color("414042")

    draw_polygon(head, [ godot_blue ])
    draw_polyline(mouth, white, _mouth_width)
    draw_circle(Vector2(42.479, 65.4825), 9.3905, white)
    draw_circle(Vector2(85.524, 65.4825), 9.3905, white)
    draw_circle(Vector2(43.423, 65.92), 6.246, grey)
    draw_circle(Vector2(84.626, 66.008), 6.246, grey)

    # Draw a short but thick white vertical line for the nose.
    draw_line(Vector2(64.273, 60.564), Vector2(64.273, 74.349), white, 5.8)

現在你應該能在螢幕上看到下列形狀:

../../_images/draw_godot_logo_line.webp

注意,如果要同時畫多條不連接的線段,可以用 draw_multiline 方法一次畫出所有線段,這樣效能會更好。

繪製文字

雖然最常見的文字顯示方法是使用 Label 節點,但底層的 _draw 函式也能讓你在自訂節點繪圖中加入文字。我們會用這個方法在機器人頭下方加上「GODOT」字樣。

這裡我們會用 draw_string 方法來達成:

var default_font : Font = ThemeDB.fallback_font;

func _draw():
    var white : Color = Color.WHITE
    var godot_blue : Color = Color("478cbf")
    var grey : Color = Color("414042")

    draw_polygon(head, [ godot_blue ])
    draw_polyline(mouth, white, _mouth_width)
    draw_circle(Vector2(42.479, 65.4825), 9.3905, white)
    draw_circle(Vector2(85.524, 65.4825), 9.3905, white)
    draw_circle(Vector2(43.423, 65.92), 6.246, grey)
    draw_circle(Vector2(84.626, 66.008), 6.246, grey)
    draw_line(Vector2(64.273, 60.564), Vector2(64.273, 74.349), white, 5.8)

    # Draw GODOT text below the logo with the default font, size 22.
    draw_string(default_font, Vector2(20, 130), "GODOT",
                HORIZONTAL_ALIGNMENT_CENTER, 90, 22)

這裡我們先將預設主題字型載入到 defaultFont 變數(你也可以用自訂字型),接著傳入參數:字型、位置、文字內容、水平對齊方式、寬度,以及字體大小。

你應該會在螢幕上看到下列畫面:

../../_images/draw_godot_logo_text.webp

更多文字相關的參數與方法,請參考 CanvasItem 類別說明文件。

在編輯時顯示繪圖

目前為止的程式碼雖然能在執行時畫出 logo,但在編輯器的「2D 檢視」中並不會顯示。有些情況下,你也會希望自訂的 Node2D 或控制項能在編輯器中顯示,方便定位和調整大小,就像其他節點一樣。

若要讓 logo 直接在編輯器中顯示(不執行時也能看見),你可以加上 @tool 註解,讓自訂節點的繪圖也會在編輯時出現,例如:

@tool
extends Node2D

在你第一次新增或移除 @tool 註解後,需要儲存場景,並(C# 專案)重新編譯,再從選單選取「場景 > 重新載入已儲存場景」刷新 2D 檢視,這樣才能看到節點的最新繪圖。

動畫

如果想在執行時讓自訂形狀變化,可以在執行期間修改呼叫的方法或其參數,或是套用變換。

例如,若想讓剛剛設計的自訂形狀旋轉,可以在 _ready_process 方法中加上以下變數與程式碼:

extends Node2D

@export var rotation_speed : float = 1  # In radians per second.

func _ready():
    rotation = 0
    ...

func _process(delta: float):
    rotation -= rotation_speed * delta

上面的程式碼有個問題:由於我們的點是從左上角 (0, 0) 開始,一路往右下建立,因此旋轉時會以左上角為中心。單純改變節點的位置變換沒辦法解決,因為旋轉變換會先套用。

雖然我們可以將所有點的座標重寫成以 (0, 0) 為中心(包含負座標),但這樣會相當麻煩。

一個解決方式是用較低階的 draw_set_transform 方法,先把所有點在 CanvasItem 的座標空間中平移,把中心移到你想要的軸心,然後再用節點的平移將它移回原本位置(可在編輯器或程式中設定),像這樣:

func _ready():
    rotation = 0
    position = Vector2(60, 60)
    ...

func _draw():
    draw_set_transform(Vector2(-60, -60))
    ...

這樣結果就會繞著 (60, 60) 為中心旋轉:

../../_images/draw_godot_rotation.webp

如果想讓 _draw() 中的某個屬性產生動畫,要記得呼叫 queue_redraw() 強制重繪,否則畫面不會即時更新。

例如,我們可以改變機器人嘴巴的線段寬度,讓它看起來像是在張嘴和閉嘴,讓寬度隨著正弦波(sin)變化:

var _mouth_width : float = 4.4
var _max_width : float = 7
var _time : float = 0

func _process(delta : float):
    _time += delta
    _mouth_width = abs(sin(_time) * _max_width)
    queue_redraw()

func _draw():
    ...
    draw_polyline(mouth, white, _mouth_width)
    ...

執行時大致會長這樣:

../../_images/draw_godot_mouth_animation.webp

請注意,_mouth_width 和其他自訂屬性一樣,也可以用更高階的方式(例如 TweenAnimationPlayer 節點)來製作動畫。唯一的差別是,你必須呼叫 queue_redraw() 才會讓螢幕及時顯示動畫效果。

範例 2:繪製動態線段

前面的範例讓你學會了如何用自訂形狀和動畫來繪製和修改節點。這種做法有一些優點,例如可用精確座標與向量繪圖(不是用點陣圖),因此在螢幕縮放時會維持品質。有時候也可以用更高階的元件組合出類似效果,例如用 Sprite2DAnimatedSprite2D 載入 SVG 資源(SVG 也是向量圖),搭配 AnimationPlayer 節點。

有些情況下,執行前並不知道圖形的最終樣貌,因此無法預先準備。這裡我們會示範如何畫出一條座標未知、會根據使用者輸入動態變化的線段。

繪製兩點之間的直線

假設我們要畫一條連接兩點的直線,第一個點固定在左上角 (0, 0),第二個點則由螢幕上的游標位置決定。

可以用下列方式畫出這兩點之間的動態直線:

extends Node2D

var point1 : Vector2 = Vector2(0, 0)
var width : int = 10
var color : Color = Color.GREEN

var _point2 : Vector2

func _process(_delta):
    var mouse_position = get_viewport().get_mouse_position()
    if mouse_position != _point2:
        _point2 = mouse_position
        queue_redraw()

func _draw():
    draw_line(point1, _point2, color, width)

這個範例中,我們每一禎都用 get_mouse_position 取得滑鼠在預設視口中的位置。如果位置自上次繪製以來有變化(這樣可以避免每一禎都重繪),就安排重繪。_draw() 方法只會畫一條線:從左上角到滑鼠位置的綠色線段,寬度為 10 像素。

線段的寬度、顏色和起點位置都可以用對應的屬性來設定。

執行時效果如下:

../../_images/draw_line_between_2_points.webp

繪製兩點之間的圓弧

上面的範例可以運作,不過有時候你可能想用直線以外的形狀或方式連接這兩個點。

這次我們試著在兩點間畫出一段圓弧(圓的一部分)。

如果將線的起點、分段數、寬度、顏色與抗鋸齒等屬性導出(export),就可以直接在編輯器屬性面板輕鬆調整這些參數:

extends Node2D

@export var point1 : Vector2 = Vector2(0, 0)
@export_range(1, 1000) var segments : int = 100
@export var width : int = 10
@export var color : Color = Color.GREEN
@export var antialiasing : bool = false

var _point2 : Vector2
../../_images/draw_dynamic_exported_properties.webp

畫圓弧時,可以用 draw_arc 方法。能通過兩點的圓弧有很多種,這裡我們選擇將圓心設在這兩點的中點,畫出一段半圓。

計算這段圓弧會比直線稍微複雜一點:

func _draw():
    # Average points to get center.
    var center : Vector2 = Vector2((_point2.x + point1.x) / 2,
                                   (_point2.y + point1.y) / 2)
    # Calculate the rest of the arc parameters.
    var radius : float = point1.distance_to(_point2) / 2
    var start_angle : float = (_point2 - point1).angle()
    var end_angle : float = (point1 - _point2).angle()
    if end_angle < 0:  # end_angle is likely negative, normalize it.
        end_angle += TAU

    # Finally, draw the arc.
    draw_arc(center, radius, start_angle, end_angle, segments, color,
             width, antialiasing)

這裡半圓的圓心會設在兩點的中點,半徑是兩點距離的一半。起始角度和結束角度分別是 point1 到 point2 或從 point2 到 point1 的向量角度。注意我們要把 end_angle 轉成正值,否則如果 end_angle 小於 start_angle,圓弧會逆時針繪製(這不是我們想要的,因為這樣會畫在上方)。

結果應該會如下圖,圓弧往下並連接兩點:

../../_images/draw_arc_between_2_points.webp

你可以隨意在屬性面板調整這些參數,獲得不同效果:改變顏色、線寬、抗鋸齒或增加分段數讓曲線更平滑(但會消耗更多效能)。