A resposta é sempre usar uma matriz ou std :: vector. Tipos como uma lista vinculada ou um std :: map geralmente são absolutamente horríveis nos jogos, e isso definitivamente inclui casos como coleções de objetos do jogo.
Você deve armazenar os próprios objetos (não ponteiros para eles) na matriz / vetor.
Você quer memória contígua. Você realmente quer isso. A iteração sobre quaisquer dados na memória não contígua impõe muitas falhas de cache em geral e remove a capacidade do compilador e da CPU de realizar uma pré-busca de cache eficaz. Isso por si só pode prejudicar o desempenho.
Você também deseja evitar alocações e desalocações de memória. Eles são muito lentos, mesmo com um alocador de memória rápido. Eu já vi jogos obterem um aumento de 10x FPS ao remover algumas centenas de alocações de memória a cada quadro. Não parece que deve ser tão ruim, mas pode ser.
Por fim, a maioria das estruturas de dados de que você gosta para gerenciar objetos de jogo pode ser implementada com muito mais eficiência em uma matriz ou em um vetor do que com uma árvore ou uma lista.
Por exemplo, para remover objetos de jogo, você pode usar swap-and-pop. Facilmente implementado com algo como:
std::swap(objects[index], objects.back());
objects.pop_back();
Você também pode marcar os objetos como excluídos e colocar o índice em uma lista gratuita para a próxima vez que precisar criar um novo objeto, mas fazer o swap-and-pop é melhor. Ele permite que você faça um loop for simples sobre todos os objetos ativos, sem ramificações além do próprio loop. Para integração da física de balas e similares, isso pode ser um aumento significativo no desempenho.
Mais importante, você pode encontrar objetos com um simples par de pesquisas de tabela a partir de um único estável usando a estrutura do mapa de slots.
Os objetos do seu jogo têm um índice em sua matriz principal. Eles podem ser pesquisados de maneira muito eficiente com apenas esse índice (muito mais rápido que um mapa ou mesmo uma tabela de hash). No entanto, o índice não é estável devido à troca e pop ao remover objetos.
Um mapa de slots requer duas camadas de indireção, mas ambas são simples pesquisas de matriz com índices constantes. Eles são rápidos . Muito depressa.
A idéia básica é que você tenha três matrizes: sua lista principal de objetos, sua lista indireta e uma lista grátis para a lista indireta. Sua lista de objetos principal contém seus objetos reais, onde cada objeto conhece seu próprio ID exclusivo. O ID exclusivo é composto por um índice e uma tag de versão. A lista indireta é simplesmente uma matriz de índices para a lista principal de objetos. A lista livre é uma pilha de índices na lista indireta.
Ao criar um objeto na lista principal, você encontra uma entrada não utilizada na lista indireta (usando a lista gratuita). A entrada na lista indireta aponta para uma entrada não utilizada na lista principal. Você inicializa seu objeto nesse local e define seu ID exclusivo para o índice da entrada da lista indireta escolhida e a tag de versão existente no elemento principal da lista, mais uma.
Quando você destrói um objeto, faz o swap e pop normalmente, mas também incrementa o número da versão. Você também adiciona o índice da lista indireta (parte do ID exclusivo do objeto) à lista gratuita. Ao mover um objeto como parte do swap-and-pop, você também atualiza sua entrada na lista indireta para seu novo local.
Exemplo de pseudocódigo:
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)
A camada indireta permite que você tenha um identificador estável (o índice na camada indireta, para onde as entradas não se movem) para um recurso que pode se mover durante a compactação (a lista de objetos principal).
A tag version permite armazenar um ID em um objeto que pode ser excluído. Por exemplo, você tem o ID (10,1). O objeto com o índice 10 é excluído (digamos, seu marcador atinge um objeto e é destruído). O objeto nesse local da memória na lista principal de objetos tem seu número de versão aumentado, fornecendo-o (10,2). Se você tentar procurar (10,1) novamente a partir de um ID antigo, a pesquisa retornará esse objeto através do índice 10, mas poderá ver que o número da versão foi alterado, portanto o ID não é mais válido.
Essa é a estrutura de dados mais rápida e absoluta que você pode ter com um ID estável que permite que os objetos se movam na memória, o que é importante para a localidade dos dados e a coerência do cache. Isso é mais rápido do que qualquer implementação possível de uma tabela de hash; uma tabela de hash precisa, no mínimo, calcular um hash (mais instruções do que uma pesquisa de tabela) e seguir a cadeia de hash (uma lista vinculada no caso horrível de std :: unordered_map ou uma lista de endereços abertos em qualquer implementação não estúpida de uma tabela de hash) e, em seguida, faça uma comparação de valor em cada chave (não mais cara, mas possível menos cara do que a verificação da tag de versão). Uma tabela de hash muito boa (não a de qualquer implementação da STL, pois a STL exige uma tabela de hash otimizada para diferentes casos de uso do que você pensa em uma lista de objetos do jogo) pode economizar em um indireto,
Existem várias melhorias que você pode fazer no algoritmo base. Usando algo como um std :: deque para a lista principal de objetos, por exemplo; uma camada extra de indireção, mas permite que os objetos sejam inseridos em uma lista completa sem invalidar os ponteiros temporários que você adquiriu no mapa de slots.
Você também pode evitar o armazenamento do índice dentro do objeto, pois o índice pode ser calculado a partir do endereço de memória do objeto (this - objects) e, ainda melhor, só é necessário ao remover o objeto. Nesse caso, você já tem o ID do objeto (e, portanto, índice) como um parâmetro.
Desculpas pela redação; Não acho que seja a descrição mais clara possível. É tarde e é difícil de explicar sem gastar mais tempo do que eu tenho em amostras de código.