La respuesta siempre es usar una matriz o std :: vector. Los tipos como una lista vinculada o un std :: map suelen ser absolutamente horrendos en los juegos, y eso definitivamente incluye casos como colecciones de objetos de juego.
Debe almacenar los objetos mismos (no punteros) en la matriz / vector.
Usted desea memoria contigua. Realmente realmente lo quieres. La iteración sobre cualquier dato en memoria no contigua impone una gran cantidad de errores de caché en general y elimina la capacidad del compilador y la CPU para realizar una captación previa efectiva de caché. Esto solo puede matar el rendimiento.
También desea evitar asignaciones de memoria y desasignaciones. Son muy lentos, incluso con un asignador de memoria rápido. He visto que los juegos obtienen un aumento de FPS de 10x simplemente eliminando unos cientos de asignaciones de memoria en cada cuadro. No parece que deba ser tan malo, pero puede serlo.
Por último, la mayoría de las estructuras de datos que le interesan para administrar objetos de juego pueden implementarse de manera mucho más eficiente en una matriz o un vector que en un árbol o una lista.
Por ejemplo, para eliminar objetos del juego, puedes usar swap-and-pop. Implementado fácilmente con algo como:
std::swap(objects[index], objects.back());
objects.pop_back();
También puede marcar los objetos como eliminados y poner su índice en una lista gratuita para la próxima vez que necesite crear un nuevo objeto, pero hacer el intercambio y el pop es mejor. Le permite hacer un bucle for simple sobre todos los objetos vivos sin ramificación aparte del bucle en sí. Para la integración física de balas y similares, esto puede ser un aumento significativo del rendimiento.
Más importante aún, puede encontrar objetos con un simple par de búsquedas de tabla desde un único estable utilizando la estructura del mapa de ranuras.
Sus objetos de juego tienen un índice en su matriz principal. Se pueden buscar de manera muy eficiente con solo este índice (mucho más rápido que un mapa o incluso una tabla hash). Sin embargo, el índice no es estable debido al intercambio y pop al eliminar objetos.
Un mapa de tragamonedas requiere dos capas de indirección, pero ambas son simples búsquedas de matriz con índices constantes. Son rápida . Realmente rápido.
La idea básica es que tiene tres matrices: su lista de objetos principal, su lista de indirección y una lista libre para la lista de indirección. Su lista principal de objetos contiene sus objetos reales, donde cada objeto conoce su propia identificación única. La identificación única se compone de un índice y una etiqueta de versión. La lista de indirección es simplemente una matriz de índices para la lista de objetos principal. La lista gratuita es una pila de índices en la lista de indirección.
Cuando crea un objeto en la lista principal, encuentra una entrada no utilizada en la lista de indirección (usando la lista libre). La entrada en la lista de indirección apunta a una entrada no utilizada en la lista principal. Inicializa su objeto en esa ubicación y establece su ID única en el índice de la entrada de la lista de indirección que eligió y la etiqueta de versión existente en el elemento de la lista principal, más uno.
Cuando destruye un objeto, realiza el intercambio y despliegue de forma normal, pero también incrementa el número de versión. Luego también agrega el índice de la lista de indirección (parte de la ID única del objeto) a la lista libre. Al mover un objeto como parte del intercambio y pop, también actualiza su entrada en la lista de indirección a su nueva ubicación.
Pseudocódigo de ejemplo:
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)
La capa de indirección le permite tener un identificador estable (el índice en la capa de indirección, donde las entradas no se mueven) para un recurso que se puede mover durante la compactación (la lista de objetos principal).
La etiqueta de versión le permite almacenar una ID en un objeto que podría eliminarse. Por ejemplo, tiene la identificación (10,1). El objeto con el índice 10 se elimina (por ejemplo, su bala golpea un objeto y se destruye). El objeto en esa ubicación de memoria en la lista de objetos principal tiene su número de versión eliminado, dándole (10,2). Si intenta buscar (10,1) nuevamente desde una ID obsoleta, la búsqueda devuelve ese objeto a través del índice 10, pero puede ver que el número de versión ha cambiado, por lo que la ID ya no es válida.
Esta es la estructura de datos más rápida que puede tener con una ID estable que permite que los objetos se muevan en la memoria, lo cual es importante para la localidad de datos y la coherencia de la memoria caché. Esto es más rápido que cualquier implementación de una tabla hash posible; una tabla hash, como mínimo, necesita calcular un hash (más instrucciones que una búsqueda de tabla) y luego debe seguir la cadena hash (ya sea una lista vinculada en el horrible caso de std :: unordered_map o una lista de direcciones abiertas en cualquier implementación no estúpida de una tabla hash), y luego tiene que hacer una comparación de valores en cada clave (no más costosa, pero posiblemente menos costosa, que la verificación de la etiqueta de versión). Una tabla hash muy buena (no la que se encuentra en ninguna implementación de la STL, ya que la STL exige una tabla hash que se optimice para diferentes casos de uso de los que se usa para una lista de objetos del juego) podría ahorrar en una dirección indirecta,
Hay varias mejoras que puede hacer al algoritmo base. Usando algo como std :: deque para la lista de objetos principal, por ejemplo; una capa adicional de indirección, pero permite que los objetos se inserten en una lista completa sin invalidar ningún puntero temporal que haya adquirido del mapa de ranuras.
También puede evitar almacenar el índice dentro del objeto, ya que el índice se puede calcular a partir de la dirección de memoria del objeto (esto - objetos), y aún mejor solo es necesario cuando se elimina el objeto, en cuyo caso ya tiene la identificación del objeto (y por lo tanto índice) como parámetro.
Disculpas por la redacción; No creo que sea la descripción más clara que podría ser. Es tarde y es difícil de explicar sin pasar más tiempo del que tengo en ejemplos de código.