I am creating a 2D procedural terrain using many Tile GameObjects. To make this infinite (and more performant), I dynamically load and unload chunks based on the players position and a variable renderDistance.
Since the chunks are made of individual squares, hundreds/thousands of GameObjects have to be created in a single function, this causes a lot of lag as it halts the main game loop/thread. Initially I thought it would be a simple fix, I could just async the functions, that way they run along with the main thread and don't halt anything. But after doing so, none of the chunks generate. After some debugging I found it seemed to stop at creating the GameObject:
private async Task LoadChunkAsync(int x)
{
if (!m_chunks.TryGetValue(x, out Chunk chunk))
{
// v HERE v
GameObject chunkObject = new($"Chunk: {x}");
chunkObject.transform.SetParent(m_chunkPool.transform);
chunkObject.transform.position = new Vector3(x * Chunk.CHUNK_WIDTH - Chunk.CHUNK_WIDTH / 2, 0);
chunk = chunkObject.AddComponent<Chunk>();
chunk.chunkX = x;
}
Terrain terrain = await m_terrainGenerator.GenerateTerrainAsync(x * Chunk.CHUNK_WIDTH - Chunk.CHUNK_WIDTH / 2, Chunk.CHUNK_WIDTH, Chunk.CHUNK_HEIGHT);
await chunk.LoadAsync(terrain.GetTiles());
m_chunks.Add(x, chunk);
}
I'm not exactly sure why this occurs, but I guessed it was to do with Unity not being thread-safe. To get around this issue, I made a UnityMainThreadDispatcher class:
using System.Collections.Generic;
using System;
using UnityEngine;
using System.Threading.Tasks;
public class UnityMainThreadDispatcher : MonoBehaviour
{
public static UnityMainThreadDispatcher Instance { get; private set; }
private Queue<Action> m_pending = new();
private void Awake()
{
if (Instance != null)
{
Debug.Log("More than 1 UnityThreadDispatcher exists in Scene.");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
void Update()
{
lock (m_pending)
{
while (m_pending.Count != 0) m_pending.Dequeue().Invoke();
}
}
public void Invoke(Action a)
{
lock (m_pending) { m_pending.Enqueue(a); }
}
public Task<T> InvokeAsync<T>(Func<T> func)
{
var tcs = new TaskCompletionSource<T>();
Invoke(() =>
{
try
{
tcs.SetResult(func());
}
catch (Exception e)
{
tcs.SetException(e);
}
});
return tcs.Task;
}
}
This worked, for one chunk. It also didn't work for the tiles within the chunk. I've been at this for a while now with no solution, it's difficult to debug as its async, and I'm not that great with async functions to begin with so I'm hoping someone can help.
Here is my ChunkManager and Chunk class:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
public class ChunkManager
{
private Dictionary<int, Chunk> m_chunks;
private GameManager m_gameManager;
private TerrainGenerator m_terrainGenerator;
private UnityMainThreadDispatcher m_mainThreadDispatcher;
private ObjectPool m_chunkPool;
private Queue<int> m_loadQueue;
private Queue<int> m_unloadQueue;
private bool m_isProcessingLoadQueue;
private bool m_isProcessingUnloadQueue;
public ChunkManager(int seed)
{
m_chunks = new();
m_gameManager = GameManager.Instance;
m_terrainGenerator = GameManager.Instance.terrainGenerator;
m_mainThreadDispatcher = UnityMainThreadDispatcher.Instance;
m_chunkPool = PoolManager.GetPool("Chunks");
m_loadQueue = new Queue<int>();
m_unloadQueue = new Queue<int>();
m_isProcessingLoadQueue = false;
m_isProcessingUnloadQueue = false;
}
public void UpdateSeed(int seed)
{
Task.Run(() => UpdateSeedAsync(seed));
}
public void ClearAllChunks()
{
Task.Run(() => ClearAllChunksAsync());
}
public void LoadChunksAroundPlayer(float playerX)
{
Task.Run(() => LoadChunksAroundPlayerAsync(playerX));
}
public void UnloadDistantChunks(float playerX)
{
Task.Run(() => UnloadDistantChunksAsync(playerX));
}
public async Task UpdateSeedAsync(int seed)
{
m_terrainGenerator.SetSeed(seed);
List<Task> unloadTasks = new();
foreach (var chunk in m_chunks.Values)
{
unloadTasks.Add(UnloadChunkAsync(chunk.chunkX));
}
await Task.WhenAll(unloadTasks);
m_chunks.Clear();
}
public async Task ClearAllChunksAsync()
{
List<Task> unloadTasks = new();
foreach (var chunk in m_chunks.Values)
{
unloadTasks.Add(UnloadChunkAsync(chunk.chunkX));
}
await Task.WhenAll(unloadTasks);
}
public WorldTile GetWorldTile(float x, float y)
{
Chunk chunk = GetChunkByX(x + 0.5f);
if (chunk == null)
{
return null;
}
int tileX = (Mathf.FloorToInt(x + 0.5f) - Chunk.CHUNK_WIDTH / 2) % Chunk.CHUNK_WIDTH;
if (tileX < 0)
{
tileX += Chunk.CHUNK_WIDTH;
}
int tileY = Mathf.FloorToInt(y + 0.5f);
return chunk.GetWorldTile(tileX, tileY);
}
public async Task LoadChunksAroundPlayerAsync(float playerX)
{
int renderDistance = m_gameManager.settings.renderDistance;
int playerChunkX = Mathf.FloorToInt(playerX / Chunk.CHUNK_WIDTH);
for (int x = playerChunkX - renderDistance; x <= playerChunkX + renderDistance; x++)
{
if (!m_chunks.ContainsKey(x))
{
m_loadQueue.Enqueue(x);
}
}
if (!m_isProcessingLoadQueue)
{
await ProcessLoadQueueAsync();
}
}
public async Task UnloadDistantChunksAsync(float playerX)
{
int renderDistance = m_gameManager.settings.renderDistance;
int playerChunkX = Mathf.FloorToInt(playerX / Chunk.CHUNK_WIDTH);
foreach (var chunk in m_chunks.Where(chunk => Mathf.Abs(chunk.Key - playerChunkX) > renderDistance).ToList())
{
m_unloadQueue.Enqueue(chunk.Key);
}
if (!m_isProcessingUnloadQueue)
{
await ProcessUnloadQueueAsync();
}
}
private async Task ProcessLoadQueueAsync()
{
m_isProcessingLoadQueue = true;
while (m_loadQueue.Count > 0)
{
int chunkX = m_loadQueue.Dequeue();
await LoadChunkAsync(chunkX);
}
m_isProcessingLoadQueue = false;
}
private async Task ProcessUnloadQueueAsync()
{
m_isProcessingUnloadQueue = true;
while (m_unloadQueue.Count > 0)
{
int chunkX = m_unloadQueue.Dequeue();
await UnloadChunkAsync(chunkX);
}
m_isProcessingUnloadQueue = false;
}
private async Task LoadChunkAsync(int x)
{
if (!m_chunks.TryGetValue(x, out Chunk chunk))
{
GameObject chunkObject = await m_mainThreadDispatcher.InvokeAsync(() =>
{
GameObject obj = new($"Chunk: {x}");
obj.transform.SetParent(m_chunkPool.transform);
obj.transform.position = new Vector3(x * Chunk.CHUNK_WIDTH - Chunk.CHUNK_WIDTH / 2, 0);
return obj;
});
chunk = await m_mainThreadDispatcher.InvokeAsync(() =>
{
Chunk newChunk = chunkObject.AddComponent<Chunk>();
newChunk.chunkX = x;
return newChunk;
});
}
Terrain terrain = await m_terrainGenerator.GenerateTerrainAsync(x * Chunk.CHUNK_WIDTH - Chunk.CHUNK_WIDTH / 2, Chunk.CHUNK_WIDTH, Chunk.CHUNK_HEIGHT);
await chunk.LoadAsync(terrain.GetTiles());
m_chunks.Add(x, chunk);
}
private async Task UnloadChunkAsync(int chunkId)
{
if (m_chunks.TryGetValue(chunkId, out Chunk chunk))
{
await Task.Run(() => GameObject.Destroy(chunk.gameObject));
m_chunks.Remove(chunkId);
}
}
private Chunk GetChunkByX(float x)
{
int chunkX = Mathf.RoundToInt(x / Chunk.CHUNK_WIDTH);
if (m_chunks.TryGetValue(chunkX, out Chunk chunk))
{
return chunk;
}
return null;
}
}
using System.Threading.Tasks;
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D))]
public class Chunk : MonoBehaviour
{
public const int CHUNK_WIDTH = 16;
public const int CHUNK_HEIGHT = 256;
public int chunkX;
private UnityMainThreadDispatcher m_mainThreadDispatcher;
private WorldTile[,] m_worldTiles;
private Tile[,] m_tiles;
private void Awake()
{
Rigidbody2D rb = gameObject.GetComponent<Rigidbody2D>();
rb.bodyType = RigidbodyType2D.Static;
CompositeCollider2D compositeCollider = gameObject.AddComponent<CompositeCollider2D>();
compositeCollider.geometryType = CompositeCollider2D.GeometryType.Polygons;
}
private void Start()
{
m_mainThreadDispatcher = UnityMainThreadDispatcher.Instance;
}
public async Task LoadAsync(Tile[,] tiles)
{
await Task.Run(async () =>
{
m_tiles = tiles;
m_worldTiles = new WorldTile[CHUNK_WIDTH, CHUNK_HEIGHT];
for (int x = 0; x < CHUNK_WIDTH; x++)
{
for (int y = 0; y < CHUNK_HEIGHT; y++)
{
Tile tile = m_tiles[x, y];
if (tile != null && tile.sprite != null)
{
GameObject tileObject = await m_mainThreadDispatcher.InvokeAsync(() =>
{
GameObject obj = new GameObject($"Tile_{x}_{y}");
obj.transform.SetParent(transform);
obj.transform.localPosition = new Vector3(x, y);
return obj;
});
m_mainThreadDispatcher.Invoke(() =>
{
SpriteRenderer spriteRenderer = tileObject.AddComponent<SpriteRenderer>();
spriteRenderer.sprite = tile.sprite;
WorldTile worldTile = tileObject.AddComponent<WorldTile>();
worldTile.tile = tile;
m_worldTiles[x, y] = worldTile;
PolygonCollider2D polygonCollider = tileObject.AddComponent<PolygonCollider2D>();
polygonCollider.compositeOperation = Collider2D.CompositeOperation.Merge;
});
}
}
}
});
}
public void SetTile(int x, int y, Tile tile)
{
m_tiles[x, y] = tile;
}
public Tile GetTile(int x, int y)
{
return m_tiles[x, y];
}
public WorldTile GetWorldTile(int x, int y)
{
if (x < 0 || x >= CHUNK_WIDTH || y < 0 || y >= CHUNK_HEIGHT) return null;
return m_worldTiles[x, y];
}
}
I have also looked into various other methods as follows with no results I'm happy with:
- Coroutines: rely too much on the main game loop (ideally I want this to be completely independent so it causes no lag).
- Object pooling: wouldn't work in this case since world generation is infinite and I cannot pool an infinite number of tiles.
Edit: As Olivier said, you actually can pool the tiles. Since only a certain number are loaded at a time. However, this still isn't a great option, whilst pooling tiles saves the GameObject creation, it's still super laggy. Meaning that it's probably not the GameObject creation but in fact the moving/reparenting etc causing the lag.
I will try the TileMap at some point, however that's quite a long task since I will have to modify a lot of my existing code. Currently I'm just using a Coroutine that lags but over more time so it isn't one long stutter (this is the best solution I've found)
Any help would be appreciated.