다이나믹 게임 오브젝트를 저장하는 가장 효율적인 컨테이너는 무엇입니까? [닫은]


20

나는 1 인칭 슈팅 게임을 만들고 있으며 다양한 컨테이너 유형에 대해 알고 있지만 게임에서 자주 추가되고 삭제되는 동적 객체를 저장하는 데 가장 효율적인 컨테이너를 찾고 싶습니다. 전 총알.

나는이 경우 메모리가 연속적이지 않고 계속되는 크기 조정이 없도록 목록 일 것이라고 생각합니다. 그러나 나는지도 또는 세트를 사용하여 고민하고 있습니다. 누구든지 도움이되는 정보가 있으면 감사하겠습니다.

그건 그렇고 C ++로 작성 중입니다.

또한 나는 효과가 있다고 생각되는 해결책을 생각해 냈습니다.

먼저 큰 크기의 벡터를 할당하려고합니다. 1000 개의 객체를 말합니다. 객체의 끝이 어디에 있는지 알 수 있도록이 벡터에 마지막으로 추가 된 인덱스를 추적하려고합니다. 그런 다음 벡터에서 "삭제 된"모든 객체의 인덱스를 보유 할 대기열을 만듭니다. (실제 삭제는 수행되지 않으며 해당 슬롯에 여유 공간이 있음을 알게됩니다.) 따라서 대기열이 비어 있으면 벡터 + 1에서 마지막으로 추가 된 색인에 추가하고 그렇지 않으면 대기열의 앞에있는 벡터의 색인에 추가합니다.


특정 언어를 타겟팅하고 있습니까?
Phill.Zitt

이 질문은 하드웨어 플랫폼, 언어 / 프레임 워크 등을 포함하여 더 많은 세부 사항 없이는 대답하기가 너무 어렵습니다.
PlayDeezGames

1
전문가 팁, 삭제 된 요소의 메모리에 여유 목록을 저장할 수 있으므로 추가 대기열이 필요하지 않습니다.
Jeff Gates

2
이 질문에 질문이 있습니까?
트레버 파월

가장 큰 인덱스를 추적하거나 여러 요소를 미리 할당 할 필요가 없습니다. std :: vector가 모든 것을 처리합니다.
API-Beast

답변:


33

답은 항상 배열이나 std :: vector를 사용하는 것입니다. 링크 된 목록 또는 std :: map과 같은 유형은 일반적으로 게임에서 절대적으로 끔찍 하며 게임 개체 모음과 같은 경우가 포함됩니다.

배열 / 벡터에 객체 자체 (포인터가 아닌)를 저장해야합니다.

당신은 원하는 인접 메모리를. 당신은 정말로 그것을 정말로 원합니다. 비 연속 메모리의 모든 데이터를 반복하면 일반적으로 많은 캐시 미스가 발생하고 컴파일러와 CPU가 효과적인 캐시 프리 페치를 수행하는 기능이 제거됩니다. 이것만으로도 성능이 저하 될 수 있습니다.

또한 메모리 할당 및 할당 해제를 피하려고합니다. 메모리 할당 기가 빠르더라도 매우 느립니다. 게임마다 프레임 당 수백 개의 메모리 할당을 제거하여 10x FPS 충돌을 경험하는 것을 보았습니다. 그렇게 나쁘게 보이지는 않지만 그렇게 될 수 있습니다.

마지막으로, 게임 오브젝트 관리에 관심이있는 대부분의 데이터 구조는 트리 또는 목록을 사용하는 것보다 배열 또는 벡터에서 훨씬 효율적으로 구현할 수 있습니다.

예를 들어 게임 오브젝트를 제거하기 위해 swap-and-pop을 사용할 수 있습니다. 다음과 같이 쉽게 구현할 수 있습니다.

std::swap(objects[index], objects.back());
objects.pop_back();

다음에 새 객체를 만들어야 할 때 객체를 삭제 된 것으로 표시하고 색인을 빈 목록에 추가 할 수도 있지만 스왑 앤 팝을 수행하는 것이 좋습니다. 루프 자체를 제외하고 분기가없는 모든 라이브 객체에 대해 간단한 for 루프를 수행 할 수 있습니다. 불릿 물리 통합 등의 경우 성능이 크게 향상 될 수 있습니다.

더 중요한 것은 안정적인 고유 한 단일 테이블 조회 쌍이있는 객체를 슬롯 맵 구조를 사용하여 찾을 수 있다는 것입니다.

게임 오브젝트는 메인 배열에 인덱스가 있습니다. 이 인덱스만으로도 매우 효율적으로 조회 할 수 있습니다 (맵 또는 해시 테이블보다 훨씬 빠름). 그러나 개체를 제거 할 때 스왑 및 팝으로 인해 인덱스가 안정적이지 않습니다.

슬롯 맵에는 두 개의 간접 레이어가 필요하지만 둘 다 인덱스가 일정한 간단한 배열 조회입니다. 그들은 빠르다 . 정말 빠릅니다.

기본 아이디어는 기본 객체 목록, 간접 목록 및 간접 목록에 대한 빈 목록의 세 가지 배열을 갖는 것입니다. 기본 개체 목록에는 실제 개체가 포함되어 있으며 각 개체는 고유 한 ID를 알고 있습니다. 고유 ID는 색인과 버전 태그로 구성됩니다. 간접 목록은 단순히 주 개체 목록에 대한 인덱스 배열입니다. 자유리스트는 간접리스트에 대한 인덱스 스택입니다.

기본 목록에서 개체를 만들면 빈 목록을 사용하여 간접 목록에서 사용되지 않는 항목을 찾습니다. 간접 목록의 항목은 기본 목록에서 사용되지 않은 항목을 가리 킵니다. 해당 위치에서 객체를 초기화하고 고유 한 ID를 선택한 간접 목록 항목의 색인 및 기본 목록 요소의 기존 버전 태그에 1을 더한 값으로 설정합니다.

객체를 파괴하면 정상적으로 스왑 앤 팝을 수행하지만 버전 번호도 증가시킵니다. 그런 다음 간접 목록 색인 (객체 고유 ID의 일부)을 사용 가능 목록에 추가하십시오. 스왑 앤 팝의 일부로 객체를 이동할 때 간접 목록의 항목도 새 위치로 업데이트합니다.

의사 코드 예 :

Object:
  int index
  int version
  other data

SlotMap:
  Object objects[]
  int slots[]
  int freelist[]
  int count

  Get(id):
    index = indirection[id.index]
    if objects[index].version = id.version:
      return &objects[index]
    else:
      return null

  CreateObject():
    index = freelist.pop()

    objects[count].index = id
    objects[count].version += 1

    indirection[index] = count

    Object* object = &objects[count].object
    object.initialize()

    count += 1

    return object

  Remove(id):
    index = indirection[id.index]
    if objects[index].version = id.version:
      objects[index].version += 1
      objects[count - 1].version += 1

      swap(objects[index].data, objects[count - 1].data)

간접 계층을 사용하면 압축하는 동안 이동할 수있는 리소스 (주 개체 목록)에 대해 안정적인 식별자 (항목이 이동하지 않는 간접 계층의 인덱스)를 가질 수 있습니다.

버전 태그를 사용하면 삭제 될 수있는 객체에 ID를 저장할 수 있습니다. 예를 들어, ID는 (10,1)입니다. 인덱스가 10 인 오브젝트가 삭제됩니다 (예 : 글 머리 기호가 오브젝트에 부딪쳐서 파괴됨). 주 객체 목록에서 해당 메모리 위치에있는 객체의 버전 번호가 충돌하여 (10,2)가됩니다. 오래된 ID에서 (10,1)을 다시 찾으려면 색인 10을 통해 해당 객체를 반환하지만 버전 번호가 변경된 것을 볼 수 있으므로 ID가 더 이상 유효하지 않습니다.

이것은 데이터가 메모리에서 움직일 수있게하는 안정적인 ID로 가질 수있는 가장 빠른 데이터 구조입니다. 이는 데이터 위치 및 캐시 일관성에 중요합니다. 이것은 가능한 해시 테이블 구현보다 빠릅니다. 해시 테이블은 해시 (테이블 조회보다 많은 명령)를 계산해야하며 해시 체인을 따라야합니다 (std :: unordered_map의 끔찍한 경우의 링크 된 목록 또는 열린 주소 목록) 멍청하지 않은 해시 테이블을 구현 한 다음 각 키에 대해 값을 비교해야합니다 (버전 태그 확인보다 비싸지는 않지만 저렴합니다). 매우 좋은 해시 테이블 (STL 구현시에는 해당되지 않습니다. STL은 게임 객체 목록에 대해 게임과 다른 사용 사례에 대해 최적화하는 해시 테이블을 요구하므로) 하나의 간접적 인 저장에 저장 될 수 있습니다.

기본 알고리즘을 다양하게 개선 할 수 있습니다. 예를 들어 기본 객체 목록에 std :: deque와 같은 것을 사용하는 경우; 하나의 추가 간접 레이어이지만 슬롯 맵에서 얻은 임시 포인터를 무효화하지 않고 객체를 전체 목록에 삽입 할 수 있습니다.

객체의 메모리 주소 (this-objects)에서 색인을 계산할 수 있기 때문에 객체 내부에 색인을 저장하지 않아도 될 수 있으며 객체를 제거 할 때만 필요합니다. index)를 매개 변수로 사용하십시오.

쓰기에 대한 사과; 나는 그것이 가장 분명한 설명이라고 생각하지 않습니다. 늦었고 코드 샘플보다 더 많은 시간을 소비하지 않고 설명하기가 어렵습니다.


1
당신은 '컴팩트'스토리지에 대한 모든 액세스를 추가 비용과 높은 할당량 / 무료 비용 (스왑)으로 교환하고 있습니다. 비디오 게임에 대한 나의 경험에서, 그것은 나쁜 거래입니다 :) 물론 YMMV.
Jeff Gates

1
실제로 실제 시나리오에서 종종 역 참조를 수행하지는 않습니다. 그렇게 할 때, 특히 deque 변형을 사용하거나 포인터가있는 동안 새 객체를 만들지 않을 경우 반환 된 포인터를 로컬에 저장할 수 있습니다. 컬렉션을 반복하는 것은 매우 비싸고 빈번한 작업이며, 안정적인 ID가 필요하고, 불릿 한 객체 (탄환, 파티클 등)에 대한 메모리 압축이 필요하며 간접적으로 모뎀 하드웨어에서 매우 효율적입니다. 이 기술은 몇 가지 고성능 상용 엔진에 사용됩니다. :)
Sean Middleditch

1
내 경험상 : (1) 비디오 게임은 평균 사례 성능이 아니라 최악의 경우 성능으로 판단됩니다. (2) 일반적으로 프레임 당 컬렉션에 대해 1 회 반복되므로 압축은 단순히 '최악의 경우를 덜 빈번하게 만듭니다'. (3) 단일 프레임에 많은 할당량 / 무료가있는 경우가 많으므로 비용이 높으면 해당 기능이 제한됩니다. (4) 당신은 프레임 당 무한한 기준을 가지고 있습니다 (Diablo 3를 포함하여 작업 한 게임에서 보통 기준 최적화 후 서버 비용의> 5 % 인 기준 비용이 가장 높았습니다). 내 경험과 추론을 지적하면서 다른 솔루션을 무시한다는 의미는 아닙니다!
Jeff Gates

3
나는이 데이터 구조를 좋아한다. 더 잘 알려지지 않은 것에 놀랐습니다. 간단하고 몇 달 동안 내 머리를 부딪치게 만드는 모든 문제를 해결합니다. 공유해 주셔서 감사합니다.
조 베이츠

2
이것을 읽는 초보자는이 조언에 매우주의해야합니다. 이것은 매우 잘못된 답변입니다. "답변은 항상 배열이나 std :: vector를 사용하는 것입니다. 링크 된 목록이나 std :: map과 같은 유형은 일반적으로 게임에서 절대적으로 끔찍하며 게임 개체 컬렉션과 같은 경우가 포함됩니다." 과장된 것입니다. "항상"답변이 없습니다. 그렇지 않으면 이러한 다른 컨테이너가 만들어지지 않았을 것입니다. 지도 / 목록이 "끔찍하다"고 말하는 것도 과장된 표현입니다. 이것을 사용하는 비디오 게임이 많이 있습니다. "가장 효율적"은 "가장 실용적"이 아니며 주관적인 "최고"로 오판 될 수 있습니다.
user50286

12

약한 참조 키 (슬롯 무효화 키 재사용 )가
있는 내부 사용 가능리스트 (O (1) 할당 / 사용 가능, 안정적인 인덱스)가
있는 고정 크기 배열 (선형 메모리)
-오버 헤드없는 참조 없음 (알려진 경우)

struct DataArray<T>
{
  void Init(int count); // allocs items (max 64k), then Clear()
  void Dispose();       // frees items
  void Clear();         // resets data members, (runs destructors* on outstanding items, *optional)

  T &Alloc();           // alloc (memclear* and/or construct*, *optional) an item from freeList or items[maxUsed++], sets id to (nextKey++ << 16) | index
  void Free(T &);       // puts entry on free list (uses id to store next)

  int GetID(T &);       // accessor to the id part if Item

  T &Get(id)            // return item[id & 0xFFFF]; 
  T *TryToGet(id);      // validates id, then returns item, returns null if invalid.  for cases like AI references and others where 'the thing might have been deleted out from under me'

  bool Next(T *&);      // return next item where id & 0xFFFF0000 != 0 (ie items not on free list)

  struct Item {
    T item;
    int id;             // (key << 16 | index) for alloced entries, (0 | nextFreeIndex) for free list entries
  };

  Item *items;
  int maxSize;          // total size
  int maxUsed;          // highest index ever alloced
  int count;            // num alloced items
  int nextKey;          // [1..2^16] (don't let == 0)
  int freeHead;         // index of first free entry
};

글 머리 기호부터 괴물, 텍스처, 입자 등 모든 것을 처리합니다. 이것은 비디오 게임에 가장 적합한 데이터 구조입니다. 나는 그것이 Bungie (마라톤 / 신화 시대로 돌아 왔음)에서 왔고, Blizzard에서 그것에 대해 배웠으며, 그것은 그날 게임 프로그래밍 보석에 있다고 생각합니다. 이 시점에서 아마도 게임 산업 전체에있을 것입니다.

Q : "동적 배열을 사용하지 않는 이유는 무엇입니까?" A : 동적 배열로 인해 충돌이 발생합니다. 간단한 예 :

foreach(Foo *foo in array)
  if (ShouldSpawnBaby(*foo))
    Foo &baby = array.Alloc();
    foo->numBabies++; // crash!

딥 콜 스택과 같이 더 복잡한 경우를 상상할 수 있습니다. 컨테이너와 같은 모든 배열에 적용됩니다. 게임을 만들 때, 우리는 성능에 대한 대가로 모든 것에 대한 규모와 예산을 강요하기 위해 문제를 충분히 이해하고 있습니다.

그리고 나는 그것을 충분히 말할 수 없습니다 : 실제로, 이것이 가장 좋은 것입니다. (동의하지 않는 경우 더 나은 솔루션을 게시하십시오!주의 사항-이 게시물의 맨 위에 나열된 문제를 해결해야합니다 : 선형 메모리 / 반복, O (1) 할당량 없음, 안정적인 인덱스, 약한 참조, 약한 오버 헤드 참조 또는 그중 하나가 필요하지 않은 놀라운 이유가 있습니다.)


동적 배열 은 무엇을 의미 합니까? DataArrayctor에서 동적으로 배열을 할당하는 것처럼 보이기 때문에 이것을 묻습니다 . 내 이해와는 다른 의미를 가질 수 있습니다.
Eonil

나는 사용하는 동안 (구성과 반대로) 크기가 조정되거나 이동하는 어레이를 의미합니다. stl 벡터는 내가 동적 배열이라고 부르는 것의 예입니다.
Jeff Gates

@JeffGates이 답변을 정말 좋아합니다. 최악의 경우를 표준 사례 런타임 비용으로 받아들이는 것에 전적으로 동의합니다. 기존 배열을 사용하여 무료 연결 목록을 백업하는 것은 매우 우아합니다. 질문 Q1 : maxUsed의 목적은 무엇입니까? Q2 : 할당 된 항목에 대해 하위 비트의 ID로 인덱스를 저장하는 목적은 무엇입니까? 왜 0이 아닌가? Q3 : 엔티티 생성을 어떻게 처리합니까? 그렇지 않다면 Ushort 생성 카운트에 Q2의 하위 비트를 사용하는 것이 좋습니다. - 감사.
엔지니어

1
A1 : Max used를 사용하면 반복을 제한 할 수 있습니다. 또한 건설 비용을 상각합니다. A2 : 1) 당신은 종종 항목-> id에서갑니다. 2) 비교가 싸고 / 명백합니다. A3 : '세대'가 무엇을 의미하는지 잘 모르겠습니다. 이것을 '슬롯 7에 할당 된 5 번째 항목과 6 번째 항목을 어떻게 구분합니까?'로 해석하겠습니다. 여기서 5와 6은 세대입니다. 제안 된 방식은 모든 슬롯에 대해 하나의 카운터를 전체적으로 사용합니다. (실제로 각 DataArray 인스턴스마다 다른 숫자로이 카운터를 시작하여 ID를보다 쉽게 ​​구분할 수 있습니다.) 항목 추적 당 비트를 다시 트리거 할 수 있다고 확신합니다.
Jeff Gates

1
@JeffGates-나는 이것이 오래된 주제라는 것을 알고 있지만 정말이 아이디어를 좋아합니다 .void Free (id)에 대한 void Free (T &)의 내부 작동에 대한 정보를 제공해 주시겠습니까?
TheStatehz

1

이에 대한 정답은 없습니다. 그것은 모두 알고리즘의 구현에 달려 있습니다. 최고라고 생각하는 사람과 함께 가십시오. 이 초기 단계에서 최적화를 시도하지 마십시오.

객체를 자주 삭제하고 다시 만드는 경우 객체 풀이 어떻게 구현되는지 살펴 보는 것이 좋습니다.

편집 : 왜 슬롯이있는 것과 복잡하지 않은지. 왜 스택을 사용하고 마지막 항목을 꺼내서 재사용하지 않습니까? 따라서 하나를 추가하면 ++를 수행하고, ++를 수행하면 최종 색인을 추적합니다.


간단한 스택은 항목이 임의의 순서로 삭제되는 경우를 처리하지 않습니다.
Jeff Gates

공정하게, 그의 목표는 명확하지 않았다. 적어도 나 한테는 안돼
Sidar

1

게임에 따라 다릅니다. 컨테이너는 특정 요소에 대한 액세스 속도, 요소 제거 속도 및 요소 추가 속도가 다릅니다.


  • std :: vector 빠른 액세스 및 제거 및 끝에 추가하는 것이 빠릅니다. 처음과 중간에서 제거하는 속도가 느립니다.
  • std :: list- 리스트를 반복하는 것은 벡터보다 훨씬 느리지 않지만리스트의 특정 지점에 액세스하는 것은 느립니다 (반복이 기본적으로리스트로 할 수있는 유일한 것이기 때문에). 어디에서나 항목 추가 및 제거가 빠릅니다. 대부분의 메모리 오버 헤드. 비 연속.
  • std :: deque 끝과 시작 부분에 빠르게 액세스하고 제거 / 추가하는 것이 빠르지 만 중간에는 느립니다.

일반적으로 객체 목록을 시간순과 다른 방식으로 정렬하여 새 객체를 삽입하는 것이 아니라 추가해야하는 경우 목록을 사용하려고합니다. deque는 벡터에 대한 유연성을 증가 시켰지만 실제로 많은 단점이 없습니다.

엔터티가 실제로 많은 경우 공간 분할을 살펴 봐야합니다.


사실이 아님 : 목록. Deque 조언은 Deque 구현에 전적으로 달려 있으며 속도와 실행이 크게 다릅니다.
변태
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.