Wenn es nur ein einfaches gekacheltes Spiel in einem Raster ist, wie ein rundenbasiertes Strategiespiel, dann so etwas:
struct Tile
{
// Stores the first entity (enemy, NPC, item, etc) on the tile.
int first_entity;
...
};
struct Entity
{
// Stores the next entity on the same tile or
// the next free entity index to reclaim if
// this entity has been freed/removed.
int next;
...
};
struct Row
{
// Stores all the tiles on the row.
vector<Tile> tiles;
// Stores all the entities on the row.
vector<Entity> entities;
// Points to the first free entity index
// to reclaim on insertion.
int first_free;
};
struct Map
{
// Stores all the rows in the map.
vector<Row> rows;
};
Einige Leute fragen sich vielleicht, warum ich für jede Kartenzeile separate Vektoren speichere. Es dient dazu, die räumliche Lokalität zu verbessern, wenn wir die auf einer bestimmten Kachel stehenden Objekte durchqueren. Wenn wir einen separaten Vektor pro Zeile speichern, passen möglicherweise alle Entitäten für diese Zeile in L1 oder L2, wohingegen sie möglicherweise nicht einmal in L3 passen, wenn wir einen Entitätscontainer für alle Entitäten in der gesamten Karte gespeichert haben. Das ist immer noch recht billig im Vergleich zum Speichern eines separaten Vektors pro Kachel.
Um beispielsweise die Kachel zu erhalten (102, 72)
, machen wir Folgendes:
Row& row = map.rows[72];
Tile& tile = row.tiles[102];
Um die Objekte auf der Kachel zu durchlaufen, gehen wir wie folgt vor:
int entity = tile.first_entity;
while (entity != -1)
{
// Do something with the entity on the tile.
...
// Advance to the next entity on the tile.
entity = row.entities[entity].next;
}
Damit die Implementierung vom Typ "Separater Container pro Zeile" am meisten davon profitiert, sollten Ihre Kachelzugriffsmuster natürlich versuchen, alle interessierenden Spalten für eine Zeile zu verarbeiten, bevor Sie zur nächsten wechseln, und nicht so sehr im Zick-Zack von einer Zeile zur nächsten das nächste und wieder zurück.
Das Einfügen einer Entität in eine Kachel würde folgendermaßen aussehen:
int Map::insert_entity(Entity ent, int col_idx, int row_idx)
{
Row& row = rows[row_idx];
int ent_idx = row.first_free;
if (ent_idx != -1)
{
row.first_free = row.entities[ent_idx].next;
row.entities[ent_idx] = ent;
}
else
{
ent_idx = static_cast<int>(row.entities.size());
row.entities.push_back(ent);
}
Tile& tile = row.tiles[col_idx];
row.entities[ent_idx].next = tile.first_entity;
tile.first_entity = ent_idx;
return ent_idx;
}
... und entfernen:
void Map::remove_entity(int ent_idx, int col_idx, int row_idx)
{
Row& row = rows[row_idx];
Tile& tile = row.tiles[col_idx];
if (tile.first_entity = ent_idx)
tile.first_entity = row.entities[ent_idx].next;
row.entities[ent_idx].next = row.first_free;
row.first_free = ent_idx;
}
Der Hauptgrund, warum mir diese Lösung gefällt, ist, dass wir vermeiden, zu viele Vektoren zu speichern (z. B. ein Vektor pro Kachel: zu viele für große Karten), aber nicht so wenige, dass das Durchlaufen der Entitäten auf einer bestimmten Kachel zu epischen Schritten über die Speicheradresse führt Platz und viel Cache fehlen. Ein Entitätsvektor pro Zeile sorgt dort für eine gute Balance.
Dies setzt voraus, dass Dinge wie Gebäude und Feinde sowie Gegenstände und Schatztruhen und Spieler auf den Plättchen stehen und dass ein Großteil der in der Spiellogik aufgewendeten Zeit darin besteht, auf die auf diesen Plättchen stehenden Entitäten zuzugreifen und zu überprüfen, auf welchen Entitäten sich diese befinden eine bestimmte Fliese. Andernfalls würde ich einen 1D-Array-Ansatz mit einem einzelnen Vektor für alle Kacheln verwenden, da dies am effizientesten wäre, wenn nur auf Kacheln zugegriffen wird. Sie können dann eine Kachel erhalten, indem Sie: tiles[row*num_cols+col]
Im Zweifelsfall ein eindimensionales Array verwenden, da Sie damit die Dinge in einer einfachen sequentiellen Reihenfolge ohne verschachtelte Schleifen durchlaufen können und nur eine Heap-Zuordnung benötigen, um das gesamte Objekt zuzuweisen.
Im Allgemeinen ist das separate dynamische Array pro Zeile etwas, das ich gefunden habe, um Cache-Fehler in Fällen zu reduzieren, in denen Ihr Raster Elemente darin speichert. Wenn dies nicht der Fall ist und Ihr Raster wie ein Bild mit Pixeln ist, ist es natürlich nicht sinnvoll, ein separates dynamisches Array pro Zeile zu verwenden. Als kürzlich durchgeführter Benchmark, bei dem ich etwas Rasterartiges auf diese Weise optimiert habe (bevor nur ein riesiges Array für alles verwendet wurde; ich habe es so optimiert, dass ein separates dynamisches Array pro Zeile gespeichert wird, nachdem in vtune viele Cache-Fehler aufgetreten sind):
Vor:
--------------------------------------------
- test_grid
--------------------------------------------
time passed for 'insert': {1.799000 secs}
mem use after 'insert': 479,508,224 bytes
8560 cells, 1000000 rects
finished test_grid: {1.919000 secs}
Nach:
--------------------------------------------
- test_grid
--------------------------------------------
time passed for 'insert': {0.310000 secs}
mem use after 'insert': 410,546,720 bytes
8560 cells, 1000000 rects
finished test_grid: {0.361000 secs}
Und ich habe die gleiche Strategie wie oben beschrieben angewendet. Als Bonus können Sie auch feststellen, dass die Speichernutzung geringer ist, da die Vektoren, in denen die Entitäten gespeichert sind, tendenziell enger passen, wenn Sie eine pro Zeile anstelle einer für die gesamte Karte speichern.
Beachten Sie, dass der obige Test zum Einfügen einer Million Entitäten in das Raster auch nach der Optimierung eine lange Zeit und viel Speicher benötigt. Das liegt daran, dass jede Entität, die ich einfüge, viele Kacheln benötigt, durchschnittlich etwa 100 Kacheln pro Entität (durchschnittliche Größe 10 x 10). Ich füge also jede der Millionen Entitäten in durchschnittlich 100 Rasterkacheln ein, was eher dem Einfügen von 100 Millionen Entitäten als einer dürftigen 1 Million Entitäten entspricht. Es ist Stresstest eines pathologischen Falls. Wenn ich nur eine Million Entitäten einfüge, die jeweils 1 Kachel belegen, kann ich dies in Millisekunden tun und nur etwa 16 Megabyte Speicher verwenden.
In meinem Fall muss ich oft sogar pathologische Fälle effizient machen, da ich in VFX arbeite, anstatt zu spielen. Ich kann Künstlern nicht sagen: "Machen Sie Ihre Inhalte für diese Engine so."denn der Sinn von VFX ist es, die Künstler den Inhalt erstellen zu lassen, wie sie wollen. Sie optimieren es dann, bevor sie in ihre Lieblings-Engine exportieren, aber ich muss mich mit den nicht optimierten Dingen befassen, was bedeutet, dass ich die pathologischen Fälle oft effizient behandeln muss, wie ein Octree, der seitdem effizient mit massiven Dreiecken umgehen muss, die die gesamte Szene überspannen Die Künstler erstellen solche Inhalte häufig (weitaus häufiger als erwartet). Der obige Test testet also etwas, das niemals passieren sollte, und deshalb dauert es fast eine Drittelsekunde, um eine Million Entitäten einzufügen, aber in meinem Fall passieren diese Dinge "niemals passieren" die ganze Zeit. Der pathologische Fall ist für mich also kein seltener Fall.
Als Nebenbonus können Sie damit auch Entitäten für mehrere Zeilen gleichzeitig mithilfe von Multithreading ohne Sperren gleichzeitig einfügen und entfernen, da Sie dies jetzt sicher tun können, da jede Zeile über einen separaten Entitätscontainer verfügt, sofern zwei Threads dies nicht versuchen Einfügen / Entfernen von Inhalten in / aus derselben Zeile gleichzeitig.