본문 바로가기

레퍼런스/고도엔진

고도엔진 튜토리얼 #22 2D에서 커스텀으로 그리기(Custom drawing in 2D)

왜?


고도는 스프라이트, 다각형, 파티클, 그런 것들에 대한 노드를 가집니다. 항상은 아니지만 많은 경우에 이로 충분합니다. 그 특정 노드가 없는 것에 공포와 분노로 울부짖기 전에... 커스텀 커맨드로 그려서 어느 2D 노드든 쉽게 만들 수 있다는 것을 알아야 합니다 (컨트롤이나 Node2D 기반의). 정말 쉽게 할 수 있습니다.



하지만...


노드에서 커스텀 드로잉을 수동으로 하는 것은 매우 유용합니다. 여기 몇몇 예제가 있습니다 :


  • 모양이나 로직을 그리는 것은 노드에 의해 다루어지지 않습니다 (예 : 원을 그리고, 흔적을 가진 이미지, 특별한 종류의 애니메이션 다각형 등 노드를 만드는 것)
  • 노드와 호환 가능하지 않은 시각화 (예 : 테트리스 보드). 블록을 그리기 위해 커스텀 드로우 함수를 이용한 테트리스 예
  • 많은 수의 간단한 객체의 그림 논리를 관리합니다 (수십만). 수천 개의 노드를 사용하는 것은 아마도 그리기만큼 효율적이지는 않지만, 수천 회의 무승부 요청은 저렵합니다. "Shower of Bullets" 데모를 예로 확인하십시오.
  • 커스텀 UI 컨트롤을 만드는 것. 많은 컨트롤을 사용하는 것이 가능하지만 새로운 것을 만드는 것도 쉽습니다. 커스텀된 것으로요.

좋습니다, 어떻게요?


노드에서 컨트롤이나 Node2D처럼 상속된 어느 CanvasItem에 스크립트를 더하세요. _draw() 함수를 오버라이드하세요.


extends Node2D

func _draw():
    #your draw commands here
    pass


그리키 커맨드들은 CanvasItem 클래스의 참조에서 설명합니다. 정말 많습니다.



업데이팅


_draw() 함수는 한번만 불리며, 그리기 커맨드들이 캐시되며 기억됩니다. 그러므로 더 많은 호출은 필요가 없습니다.


만약 상태나 무언가가 변하면 다시그리기가 필요합니다. 간단하게 같은 노드에서 CanvasItem.update()을 부르고 새로운 _draw() 호출이 생깁니다.


여기 더 복잡한 예가 있습니다. 만약 수정되면 텍스쳐 변수가 다시 그려질 것입니다 :


extends Node2D

export 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())


몇몇 경우에, 아마 모든 프레임에서 그려집니다. 이를 위해 _process() 콜백에서 update()를 호출합니다, 이와 같습니다 :


extends Node2D

func _draw():
    #your draw commands here
    pass

func _process(delta):
    update()

func _ready():
    set_process(true)



예 : 호 모양의 원 그리기


고닷 엔진의 사용자 정의 드로잉 기능을 사용하여 고닷이 기능을 제공하지 않는 것을 그려냅니다. 예를 들어, 완전한 원을 그리는 draw_circle() 함수를 제공합니다. 하지만, 원의 부분을 그리는 건 어떻게 할까요? 이를 수행하기 위해서 함수를 코딩하고 스스로 그리셔야 합니다.



호(arc) 함수


호는 지원하는 원 매개 변수, 즉 : 중심 위치, 그리고 반경으로 정의됩니다. 그리고 호 자체는 시작 각도와, 정지 각도에 의해 정의됩니다. 이것들은 우리그림을 그리기 위해 제공 되어야 하는 4개의 매개 변수입니다. 또한 색깔 값을 주어 원한다면 호를 다른 색으로 그릴 수 있습니다.


기본적으로, 화면에 도형을 그리려면 연결된 것에서 다음 것까지 특정 숫자의 점으로 분해가 필요합니다. 상상할 수 있듯이 점이 더 많을 수록 모양이 부드러워지지만, 처리 비용이 더 많이 들 것입니다. 일반적으로, 만약 모양이 크다면 (또는 3D에서는, 카메라에 가까우면) 모서리가 보이지 않게 보여주려면 더 많은 점을 그리는 것이 필요합니다. 대조적으로, 만약 모양이 작다면 (또는 3D에서는, 카메라와 멀다면), 점 수를 줄여서 프로세싱 값을 줄일 수 있습니다. 이를 디테일의 레벨 (LoD)라고 부릅니다. 우리의 예에서 반지름이 얼마이든 간단히 고정된 수의 점을 사용할 수 있습니다.


func draw_circle_arc( center, radius, angle_from, angle_to, color ):
    var nb_points = 32
    var points_arc = Vector2Array()

    for i in range(nb_points+1):
        var angle_point = angle_from + i*(angle_to-angle_from)/nb_points - 90
        var point = center + Vector2( cos(deg2rad(angle_point)), sin(deg2rad(angle_point)) ) * radius
        points_arc.push_back( point )

    for indexPoint in range(nb_points):
        draw_line(points_arc[indexPoint], points_arc[indexPoint+1], color)


우리의 형태가 몇 개의 점으로 분해되어야 하는지 기억하나요? 우리는 이 개수를 nb_points 변수를 32 값으로 고정했습니다. 그러면, 우리는 빈 Vector2Array를 Vector2의 배열로 간단하게 초기화할 수 있습니다.


다음 단계는 호를 구성하는 이 32개 점의 실제 위치 계산입니다. 이건 첫 for-반복문에서 완료됩니다 : 우리는 위치를 계산할 점의 수를 계산하며, 마지막 점을 포함하는 점까지 반복합니다. 먼저 시작과 끝 각도 사이의 각 점의 각도를 결정합니다.


각 각도가 90도 부터 줄어드는 이유는 2D 위치를 삼각법을 이용해 각 각도의 밖에서 계산할 것이기 때문이다 (다 아시죠, 코사인이나 사인 같은 것들입니다...). 하지만, 간단하게 하기 위해 cos()와 sin()은 각도가 아니라 라디안(radians)을 사용합니다. 0도(0라디안)는 3시에서 출발하며, 또한 우리는 0시에서 세는 걸 출발하기를 원합니다. 그래서, 우리는 각 90도의 각을 줄여서 순서대로 0시부터 세는 것을 시작합니다.


각도 'angle' (라디안에서)의 원에 위치한 점의 실제 위치는 Vector2(cos(각도), sin(각도))에 의해 제공됩니다. cos()과 sin()이 -1과 1 사이의 값을 반환하기 때문에, 반지름 1인 원의 위에 위치(position)가 위치하게 됩니다. 이 위치를 'radius'를 반지름으로 가지는 우리의 지원 원 위에 가지기 위해서, 우리는 간단히 위치에 'radius'를 곱해야 한다. 마지막으로, 우리는 우리의 지원 원을 'center'위치에 놓아야합니다. 이는 위치를 우리의 Vector2 값에 더함으로 수행할 수 있습니다. 마지막으로, 우리는 점을 전에 정의한 Vector2Array에 점을 삽입합니다.


이제, 우리는 실제로 우리의 점을 그릴 필요가 있습니다. 여러분이 상상할 수 있듯이, 우리는 간단하게 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 )


결과 :


../../../_images/result_drawarc.png


호 다각형 함수


이 단계에서 더 나아가 호에 의해 정의된 디스크의 부분을 모양뿐만 아니라 안을 그리는 함수를 작성할겁니다. 이 메소드는 전과 완전히 똑같지만, 선 대신에 다각형을 그리는 것만 다릅니다.

func draw_circle_arc_poly( center, radius, angle_from, angle_to, color ):
    var nb_points = 32
    var points_arc = Vector2Array()
    points_arc.push_back(center)
    var colors = ColorArray([color])

    for i in range(nb_points+1):
        var angle_point = angle_from + i*(angle_to-angle_from)/nb_points - 90
        points_arc.push_back(center + Vector2( cos( deg2rad(angle_point) ), sin( deg2rad(angle_point) ) ) * radius)
    draw_polygon(points_arc, colors)


../../../_images/result_drawarc_poly.png


동적인 커스텀 그리기


좋습니다, 우린 이제 커스텀으로 무언가를 화면에 그릴 수 있습니다. 하지만, 정적입니다 : 이 모양이 중앙에서 돌도록 만들어 봅시다. 해결책은 각을 바꾸어 _from 각도와 _to 각의 값을 시간에 따라 바꾸는 것입니다. 우리의 예에서 그 둘을 50으로 증가시키겠습니다. 이 증가 값은 상수로 남아야만 합니다. 그렇지 않으면 회전 속도가 그에 따라 달라집니다.


먼저, _from과 _to 각도 변수를 스크립트의 최상단에 놓아 전역 변수로 만들어 주어야합니다. 또한 이를 다른 노드에서 저장할 수 있고 get_node()를 이용해 접근할 수 있다는 것을 참고하세요.


extends Node2D

var rotation_ang = 50
var angle_from = 75
var angle_to = 195


이 값들을 _process(delta) 함수를 이용해 변화시킬 수 있습니다. 이 함수의 활성화를 위해, _ready() 함수 안의 set_process(true)를 호출해야 합니다.


또한 _from과 _to 값을 증가시켜야합니다. 하지만, 결과 값이 0에서 360도 사이를 넘어가지 않게 wrap() 하는 것을 잊지 마세요! 즉, 만약 각도가 361도이면 실제로는 1도인 것입니다. 만약 이 값을 보장해주지 않는다면, 이 스크립트는 잘 작동하지만 각도 값이 점점 시간에 따라 커지고 커질 것입니다. 고도가 다룰 수 있는 최대한의 정수인 (2^31 - 1)까지요. 이런 일이 생기면, 고도는 충돌하거나 예상하지 못한 행동을 합니다. 고도가 wrap() 함수를 제공하지 않기 때문에, 이를 만들어야 합니다. 이는 상대적으로 간단합니다.


마지막으로 _draw()를 자동으로 부르는 update() 함수를 호출하는 것을 잊지 말아야 합니다. 이 방법으로, 여러분은 원할 때 프레임을 새로고침하게 제어할 수 있습니다.


func _ready():
    set_process(true)

func wrap(value, min_val, max_val):
    var f1 = value - min_val
    var f2 = max_val - min_val
    return fmod(f1, f2) + min_val

func _process(delta):
    angle_from += rotation_ang
    angle_to += rotation_ang

    # we only wrap angles if both of them are bigger than 360
    if (angle_from > 360 && angle_to > 360):
        angle_from = wrap(angle_from, 0, 360)
        angle_to = wrap(angle_to, 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 )


실행해 봅시다! 잘 작동하지만, 호가 미친듯이 빠르게 돌겁니다! 뭐가 문제일까요?


GPU가 프레임을 할 수 있는 만큼 빠르게 보여주기 때문입니다. 우리는 이 속도의 그리기를 "표준화(normalize)"해 줄 필요가 있습니다. 달성하기 위해, _process() 함수의 'delta' 매개 변수를 쓸 필요가 있습니다. 'delta'는 두 렌더된 프레임 사이의 경과된 시간을 포함합니다. 일반적으로 작습니다 (0.0003초 정도, 여러분의 하드웨어에 따라 다릅니다). 그래서 'delta'를 그리기 제어에 사용하는 것은 여러분의 프로그램이 어느 하드웨어 에서나 같은 속도로 그리기를 실행하도록 보장해줍니다.


우리의 경우에서, 간단히 'rotation_ang' 변수를 _process() 함수 안에 있는 'delta'로 곱해줄 필요가 있습니다. 이 방법으로, 우리의 두 각도가 렌더링 속도에 의존하는 훨씬 더 작은 값으로 곱해집니다.


func _process(delta):
    angle_from += rotation_ang * delta
    angle_to += rotation_ang * delta

    # we only wrap angles if both of them are bigger than 360
    if (angle_from > 360 && angle_to > 360):
        angle_from = wrap(angle_from, 0, 360)
        angle_to = wrap(angle_to, 0, 360)
    update()


다시 실행해 봅시다! 이번에는, 회전이 잘 보입니다!



도구들


미리보기 또는 일부 특징이나 행동의 시각화로 에디터에서 노드를 실행하는 동안 여러분만의 노드를 그리는 것도 원하실 수 있는데 사용할 수 있습니다.


스크립트의 맨 위에 "tool" 키워드를 사용하는 것을 잊지 마세요 (잊어버리셨다면 GDScript 참조를 확인하세요.)