Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

현재 씬 변경하기

데이터 구조 Y 또는 Z로 문제 X에 접근해야 하는지 궁금한 적이 있습니까? 이 기사에서는 이러한 딜레마와 관련된 다양한 주제를 다룹니다.

참고

이 문서에서는 "[something]-time" 작업을 참조합니다. 이 용어는 알고리즘 분석' `Big O Notation <https://rob-bell.net/2009/06/a-beginners-guide-to-big-o-notation/>`_에서 유래되었습니다.

간단히 말해서 런타임 길이의 최악의 시나리오를 설명합니다. 평신도의 용어로:

"문제 영역의 크기가 커질수록 알고리즘의 런타임 길이는..."

  • 상수 시간, O(1): "...증가하지 않습니다."

  • 로그 시간, O(log n): "...느린 속도로 증가합니다."

  • 선형 시간, O(n): "...같은 비율로 증가합니다."

  • Etc.

단일 프레임 내에서 300만 개의 데이터 포인트를 처리해야 한다고 상상해 보십시오. 데이터의 크기가 할당된 시간보다 훨씬 더 길어지기 때문에 선형 시간 알고리즘으로 기능을 만드는 것은 불가능합니다. 이에 비해 상수 시간 알고리즘을 사용하면 문제 없이 작업을 처리할 수 있습니다.

전반적으로 개발자는 선형 시간 작업에 최대한 관여하지 않기를 원합니다. 그러나 선형시간 연산의 규모를 작게 유지하고 연산을 자주 수행할 필요가 없다면 허용될 수 있습니다. 이러한 요구 사항의 균형을 맞추고 작업에 적합한 알고리즘/데이터 구조를 선택하는 것은 프로그래머의 기술을 가치있게 만드는 요소 중 하나입니다.

배열 vs. 사전 vs. 객체

Godot는 모든 변수를 Variant 클래스 안의 스크립팅 API 안에 저장합니다. Variants는 Array, Dictionary, Object 등의 Variant-compatible 데이터 구조를 저장할 수 있습니다.

Godot는 배열을 ``Vector<Variant>``로 구현합니다. 엔진은 배열 내용을 메모리의 연속된 섹션에 저장합니다. 즉, 서로 인접한 행에 있습니다.

참고

C++에 익숙하지 않은 사람들을 위해 설명하면 벡터는 기존 C++ 라이브러리에 있는 배열 개체의 이름입니다. 이는 "템플릿형" 유형입니다. 즉, 해당 레코드는 특정 유형(꺾쇠 괄호로 표시)만 포함할 수 있습니다. 예를 들어 :ref:`PackedStringArray <class_PackedStringArray>`는 ``Vector<String>``와 유사합니다.

연속 메모리 저장소는 다음과 같은 작업 성능을 의미합니다.

  • 반복: 가장 빠릅니다. 루프에 적합합니다.

    • Op: 그것이 하는 일은 다음 레코드에 도달하기 위해 카운터를 증가시키는 것뿐입니다.

  • 삽입, 지우기, 이동: 위치에 따라 다릅니다. 일반적으로 느립니다.

    • Op: 콘텐츠 추가/제거/이동에는 인접한 레코드를 이동하는 작업이 포함됩니다(공간 만들기/공간 채우기).

    • 끝에서 빠르게 추가/제거하세요.

    • 임의의 위치에서 천천히 추가/제거합니다.

    • 전면에서 추가/제거하는 속도가 가장 느립니다.

    • *앞면*에서 삽입/제거를 많이 수행하는 경우...

      1. 배열을 반전시킵니다.

      2. 끝에 배열 변경을 실행하는 루프를 수행합니다.

      3. 배열을 다시 반전시킵니다.

      이는 배열의 약 1/2을 평균적으로 N번(선형 시간) 복사하는 것과 비교하여 배열의 복사본을 2개만 만듭니다(여전히 일정한 시간이지만 느림).

  • Get, Set: 위치별 가장 빠릅니다. 예: 0번째, 2번째, 10번째 레코드 등을 요청할 수 있지만 원하는 레코드를 지정할 수는 없습니다.

    • Op: 배열 시작 위치에서 원하는 인덱스까지 1개의 추가 작업입니다.

  • 찾기: 가장 느림. 값의 색인/위치를 식별합니다.

    • Op: 일치하는 항목을 찾을 때까지 배열을 반복하고 값을 비교해야 합니다.

      • 성능은 철저한 검색이 필요한지 여부에 따라 달라집니다.

    • 순서대로 유지하면 사용자 정의 검색 작업을 통해 로그 시간(상대적으로 빠름)이 될 수 있습니다. 그러나 평신도 사용자는 이에 대해 만족하지 않을 것입니다. 편집할 때마다 배열을 다시 정렬하고 순서 인식 검색 알고리즘을 작성하여 수행됩니다.

Godot는 사전을 ``HashMap<Variant, Variant, VariantHasher, StringLikeVariantComparator>``로 구현합니다. 엔진은 키-값 쌍의 작은 배열(2^3 또는 8개 레코드로 초기화됨)을 저장합니다. 값에 액세스하려고 하면 값에 키를 제공합니다. 그런 다음 키를 *해시*합니다. 즉, 키를 숫자로 변환합니다. "해시"는 배열의 인덱스를 계산하는 데 사용됩니다. 배열로서 HM은 값에 매핑된 키의 "테이블" 내에서 빠른 조회를 수행합니다. HashMap이 너무 가득 차면 2의 다음 거듭제곱(즉, 16개 레코드, 32개 레코드 등)으로 증가하고 구조를 다시 빌드합니다.

해시는 키 충돌 가능성을 줄이는 것입니다. 이러한 일이 발생하면 테이블은 이전 위치를 고려하는 값에 대한 다른 인덱스를 다시 계산해야 합니다. 결국 이는 메모리와 약간의 운영 효율성을 희생하면서 모든 레코드에 대한 지속적인 액세스를 초래합니다.

  1. 모든 키를 임의의 횟수만큼 해싱합니다.

    • 해시 작업은 일정 시간이므로 알고리즘이 둘 이상의 작업을 수행해야 하더라도 해시 계산 수가 테이블 밀도에 너무 의존하지 않는 한 작업은 빠르게 유지됩니다. 이는 다음과 같은 결과로 이어집니다...

  2. 점점 커지는 테이블 크기를 유지합니다.

    • HashMap은 해시 충돌을 줄이고 액세스 속도를 유지하기 위해 의도적으로 테이블에 산재된 사용되지 않는 메모리의 간격을 유지합니다. 이것이 바로 2의 거듭제곱만큼 크기가 기하급수적으로 증가하는 이유입니다.

알 수 있듯이 사전은 배열이 할 수 없는 작업을 전문으로 합니다. 그들의 운영 세부 사항에 대한 개요는 다음과 같습니다.

  • 반복: 빠릅니다.

    • Op: 맵의 내부 해시 벡터를 반복합니다. 각 키를 반환합니다. 그런 다음 사용자는 키를 사용하여 원하는 값으로 이동하고 반환합니다.

  • 삽입, 지우기, 이동: 가장 빠릅니다.

    • Op: 주어진 키를 해시합니다. 적절한 값(배열 시작 + 오프셋)을 찾기 위해 추가 작업을 1회 수행합니다. 이동은 이 중 2개(삽입 1개, 삭제 1개)입니다. 지도의 기능을 유지하려면 지도에서 몇 가지 유지 관리를 수행해야 합니다.

      • 주문된 레코드 목록을 업데이트합니다.

      • 테이블 밀도로 인해 테이블 용량을 확장해야 하는지 확인합니다.

    • 사전은 사용자가 키를 삽입한 순서를 기억합니다. 이를 통해 안정적인 반복을 실행할 수 있습니다.

  • 가져오기, 설정: 가장 빠릅니다. 키별 조회와 동일합니다.

    • Op: 삽입/삭제/이동과 동일합니다.

  • 찾기: 가장 느림. 값의 키를 식별합니다.

    • Op: 레코드를 반복하고 일치하는 항목을 찾을 때까지 값을 비교해야 합니다.

    • Godot는 이 기능을 기본적으로 제공하지 않는다는 점에 유의하세요(이 기능은 이 작업을 위한 것이 아니기 때문입니다).

Godot는 객체를 멍청하지만 데이터 콘텐츠의 동적 컨테이너로 구현합니다. 질문이 제기되면 개체가 데이터 소스를 쿼리합니다. 예를 들어, "'위치'라는 속성이 있습니까?"라는 질문에 답하기 위해 스크립트 또는 ClassDB <class_ClassDB>`을 물을 수 있습니다. :ref:`doc_what_are_godot_classes 기사에서 객체가 무엇인지, 객체가 어떻게 작동하는지에 대한 자세한 정보를 찾을 수 있습니다.

여기서 중요한 세부 사항은 개체 작업의 복잡성입니다. 이러한 다중 소스 쿼리 중 하나를 수행할 때마다 여러 반복 루프와 HashMap 조회를 통해 실행됩니다. 게다가 쿼리는 개체의 상속 계층 구조 크기에 따라 달라지는 선형 시간 작업입니다. Object가 쿼리하는 클래스(현재 클래스)가 아무것도 찾지 못하면 요청은 원래 Object 클래스까지 다음 기본 클래스로 연기됩니다. 이는 각각 독립적으로 빠른 작업이지만 너무 많은 확인을 수행해야 한다는 사실로 인해 데이터를 검색하는 두 가지 대안보다 속도가 느려집니다.

참고

개발자가 스크립팅 API의 속도가 얼마나 느린지 언급할 때 이는 바로 이러한 쿼리 체인을 참조하는 것입니다. 애플리케이션이 무엇이든 찾기 위해 어디로 가야 하는지 정확히 알고 있는 컴파일된 C++ 코드와 비교할 때, 스크립팅 API 작업은 훨씬 더 오래 걸릴 수밖에 없습니다. 관련 데이터에 액세스하려면 먼저 해당 데이터의 소스를 찾아야 합니다.

GDScript가 느린 이유는 GDScript가 수행하는 모든 작업이 이 시스템을 통과하기 때문입니다.

C#은 더욱 최적화된 바이트코드를 통해 일부 콘텐츠를 더 빠른 속도로 처리할 수 있습니다. 그러나 C# 스크립트가 엔진 클래스의 콘텐츠를 호출하거나 스크립트가 엔진 클래스의 외부 항목에 액세스하려고 하면 이 파이프라인을 통과합니다.

NativeScript C++는 더욱 발전하여 기본적으로 모든 것을 내부에 유지합니다. 외부 구조에 대한 호출은 스크립팅 API를 통해 진행됩니다. NativeScript C++에서는 메소드를 등록하여 스크립팅 API에 노출시키는 것이 수동 작업입니다. 이 시점에서 C++가 아닌 외부 클래스가 API를 사용하여 해당 클래스를 찾습니다.

그렇다면 하나가 참조에서 확장되어 배열이나 사전과 같은 데이터 구조를 생성한다고 가정하면 왜 다른 두 옵션 대신 객체를 선택합니까?

  1. 제어: 개체를 사용하면 더욱 정교한 구조를 만들 수 있습니다. 내부 데이터 구조 변경에 따라 외부 API가 변경되지 않도록 데이터 위에 추상화 계층을 적용할 수 있습니다. 게다가 객체는 시그널를 가질 수 있어 반응적인 동작을 허용합니다.

  2. 명확성: 개체는 스크립트 및 엔진 클래스가 개체에 대해 정의한 데이터와 관련하여 신뢰할 수 있는 데이터 소스입니다. 속성은 기대하는 값을 보유하지 않을 수도 있지만, 애초에 속성이 존재하는지 여부에 대해 걱정할 필요는 없습니다.

  3. 편리성: 유사한 데이터 구조를 이미 염두에 두고 있는 경우 기존 클래스에서 확장하면 데이터 구조 구축 작업이 훨씬 쉬워집니다. 이에 비해 배열과 사전은 가능한 모든 사용 사례를 충족하지 않습니다.

또한 객체는 사용자에게 더욱 전문화된 데이터 구조를 만들 수 있는 기회를 제공합니다. 이를 통해 자신만의 목록, 이진 검색 트리, 힙, 스플레이 트리, 그래프, 분리 세트 및 기타 다양한 옵션을 디자인할 수 있습니다.

"트리 구조에 노드를 사용하지 않는 이유는 무엇입니까?" 누군가는 물어볼 수도 있습니다. 글쎄, 노드 클래스에는 사용자 정의 데이터 구조와 관련이 없는 것들이 포함되어 있습니다. 따라서 트리 구조를 구축할 때 자신만의 노드 유형을 구축하는 것이 도움이 될 수 있습니다.

class_name TreeNode
extends Object

var _parent: TreeNode = null
var _children := []

func _notification(p_what):
    match p_what:
        NOTIFICATION_PREDELETE:
            # Destructor.
            for a_child in _children:
                a_child.free()

여기에서 상상력에 의해서만 제한되는 특정 기능을 갖춘 자신만의 구조를 만들 수 있습니다.

열거형: int 대 string

대부분의 언어는 열거 유형 옵션을 제공합니다. GDScript도 다르지 않지만 대부분의 다른 언어와는 달리 열거형 값에 정수나 문자열을 사용할 수 있습니다(후자는 GDScript에서 @export_enum 주석을 사용할 때만 해당). 그러면 "어떤 것을 사용해야 합니까?"라는 질문이 생깁니다.

짧은 대답은 "어느 쪽이든 당신이 더 편한 것"입니다. 이것은 일반적인 Godot 스크립팅이 아닌 GDScript에 특정한 기능입니다; 언어는 성능보다 유용성을 우선시합니다.

기술적인 수준에서 정수 비교(상수)는 문자열 비교(선형 시간)보다 빠르게 발생합니다. 하지만 다른 언어의 규칙을 유지하려면 정수를 사용해야 합니다.

정수 사용과 관련된 주요 문제는 열거형 값을 *인쇄*하려고 할 때 발생합니다. 정수로 MY_ENUM``를 인쇄하려고 시도하면 ``"MyEnum"``와 같은 것이 아니라 ``5 또는 what-have-you가 인쇄됩니다. 정수 열거형을 인쇄하려면 각 열거형에 해당하는 문자열 값을 매핑하는 사전을 작성해야 합니다.

열거형을 사용하는 주요 목적이 값을 인쇄하는 것이고 이를 관련 개념으로 그룹화하려는 경우 문자열로 사용하는 것이 합리적입니다. 이렇게 하면 인쇄 시 실행할 별도의 데이터 구조가 필요하지 않습니다.

AnimatedTexture vs. AnimatedSprite2D vs. AnimationPlayer vs. AnimationTree

어떤 상황에서 Godot의 각 애니메이션 클래스를 사용해야 합니까? 새로운 Godot 사용자에게는 답변이 즉시 명확하지 않을 수 있습니다.

:ref:`AnimatedTexture <class_AnimatedTexture>`는 엔진이 정적 이미지가 아닌 애니메이션 루프로 그리는 텍스처입니다. 사용자가 조작할 수 있습니다...

  1. 텍스처의 각 섹션을 가로질러 이동하는 속도(FPS)입니다.

  2. 텍스처(프레임) 내에 포함된 영역의 수입니다.

Godot의 :ref:`RenderingServer <class_RenderingServer>`는 규정된 속도로 영역을 순서대로 그립니다. 좋은 소식은 여기에 엔진 측의 추가 논리가 포함되지 않는다는 것입니다. 나쁜 소식은 사용자가 제어할 수 있는 권한이 거의 없다는 것입니다.

또한 AnimatedTexture는 여기서 설명한 다른 노드 개체와 달리 Resource 노드를 만들 수 있습니다. 또는 (다른 사람들이 할 수 없는 일) :ref:`TileSet <class_TileSet>`에 타일로 AnimatedTextures를 추가하고 이를 :ref:`TileMapLayer <class_TileMapLayer>`와 통합하여 단일 일괄 그리기 호출로 모두 렌더링되는 많은 자동 애니메이션 배경을 만들 수 있습니다.

AnimatedSprite2D 노드는 SpriteFrames 리소스와 함께 스프라이트 시트를 통해 다양한 애니메이션 시퀀스를 생성하고 애니메이션 간을 전환하며 속도, 지역 오프셋 및 방향을 제어할 수 있습니다. 따라서 2D 프레임 기반 애니메이션을 제어하는 데 적합합니다.

애니메이션 변경과 관련하여 다른 효과를 트리거해야 하는 경우(예: 입자 효과 생성, 함수 호출 또는 프레임 기반 애니메이션 이외의 기타 주변 요소 조작) AnimatedSprite2D와 함께 AnimationPlayer 노드를 사용해야 합니다.

AnimationPlayer는 다음과 같은 보다 복잡한 2D 애니메이션 시스템을 디자인하려는 경우 사용해야 하는 도구이기도 합니다.

  1. 컷아웃 애니메이션: 런타임 시 스프라이트의 변형을 편집합니다.

  2. 2D 메시 애니메이션: 스프라이트의 텍스처 영역을 정의하고 여기에 뼈대를 리깅합니다. 그런 다음 뼈의 서로 관계에 비례하여 텍스처를 늘리고 구부리는 뼈에 애니메이션을 적용합니다.

  3. 위의 내용을 혼합한 것입니다.

게임의 개별 애니메이션 시퀀스 각각을 디자인하려면 AnimationPlayer가 필요하지만 블렌딩을 위해 애니메이션을 결합하는 것, 즉 이러한 애니메이션 간의 원활한 전환을 활성화하는 것도 유용할 수 있습니다. 개체에 대해 계획하는 애니메이션 사이에는 계층 구조가 있을 수도 있습니다. AnimationTree 사용에 대한 심층적인 가이드를 찾을 수 있습니다.