using MoonTools.ECS;
using System.Collections.Generic;
using System;
using System.Numerics;
using Nerfed.Runtime.Components;
namespace Nerfed.Runtime.Scene.Streaming;
///
/// Status of a chunk in the streaming system.
///
public enum ChunkState
{
Unloaded,
Loading,
Loaded,
Unloading
}
///
/// A system that manages spatial partitioning. It determines which chunks should be loaded based on observers.
///
public class ChunkStreamingSystem : MoonTools.ECS.System
{
private readonly struct ChunkCoord : IEquatable
{
public readonly int X;
public readonly int Y;
public readonly int Z;
// Pre-calculated on creation
public readonly long Id;
public ChunkCoord(int x, int y, int z)
{
X = x;
Y = y;
Z = z;
// We allocate 21 bits per axis (allowing ~2 million chunks positive and negative).
var hashX = (long)x & 0x1FFFFF;
var hashY = (long)y & 0x1FFFFF;
var hashZ = (long)z & 0x1FFFFF;
Id = hashX | (hashY << 21) | (hashZ << 42);
}
public bool Equals(ChunkCoord other) => Id == other.Id;
public override bool Equals(object? obj) => obj is ChunkCoord other && Equals(other);
public override int GetHashCode() => Id.GetHashCode();
}
// Configurable size of a chunk in world coordinates.
public float ChunkSize { get; set; } = 64f;
private readonly Filter _observerFilter;
private readonly Filter _chunkMemberFilter;
private readonly Filter _unloadedFilter;
// Active loaded/loading chunks
private readonly Dictionary _activeChunks = new();
// Queue of chunks waiting to be loaded
private readonly Queue _pendingLoads = new();
// Queue of chunks waiting to be completely tagged for unloading
private readonly Queue _pendingUnloads = new();
public ChunkStreamingSystem(World world) : base(world)
{
_observerFilter = FilterBuilder
.Include()
.Include() // Needs a world position
.Exclude() // Ignore dying observers
.Build();
_chunkMemberFilter = FilterBuilder
.Include()
.Exclude() // Ignore entities already marked for death
.Build();
_unloadedFilter = FilterBuilder
.Include()
.Build();
}
public override void Update(TimeSpan delta)
{
var requiredChunks = new HashSet();
// 1. Find all chunks that should be loaded based on observers
foreach (var observerEntity in _observerFilter.Entities)
{
var observer = Get(observerEntity);
var transform = Get(observerEntity);
// Convert world pos to grid coordinates
var worldPos = transform.localToWorldMatrix.Translation;
var centerChunk = GetChunkCoord(worldPos);
// Determine chunk radius based on observer radius and chunk size
int chunkRadius = (int)MathF.Ceiling(observer.ViewRadius / ChunkSize);
for (int x = -chunkRadius; x <= chunkRadius; x++)
{
for (int y = -chunkRadius; y <= chunkRadius; y++)
{
for (int z = -chunkRadius; z <= chunkRadius; z++)
{
var coord = new ChunkCoord(centerChunk.X + x, centerChunk.Y + y, centerChunk.Z + z);
requiredChunks.Add(coord);
}
}
}
}
// 2. Unload chunks that are active but no longer required
var chunksToUnload = new List();
foreach (var activeChunk in _activeChunks.Keys)
{
if (!requiredChunks.Contains(activeChunk) && _activeChunks[activeChunk] != ChunkState.Unloading)
{
chunksToUnload.Add(activeChunk);
}
}
foreach (var coord in chunksToUnload)
{
_activeChunks[coord] = ChunkState.Unloading;
_pendingUnloads.Enqueue(coord);
}
// 3. Queue newly required chunks
foreach (var coord in requiredChunks)
{
if (!_activeChunks.ContainsKey(coord))
{
// Mark as unloaded so we don't queue it multiple times
_activeChunks[coord] = ChunkState.Unloaded;
_pendingLoads.Enqueue(coord);
}
}
// 4. Process only ONE chunk load per frame to prevent stuttering
if (_pendingLoads.Count > 0)
{
var chunkToLoad = _pendingLoads.Dequeue();
// Double check it wasn't unloaded before we got around to loading it
if (_activeChunks.TryGetValue(chunkToLoad, out var state) && state == ChunkState.Unloaded)
{
LoadChunk(chunkToLoad);
}
}
// 5. Process only ONE chunk unload tagging per frame
if (_pendingUnloads.Count > 0)
{
var chunkToUnload = _pendingUnloads.Dequeue();
UnloadChunk(chunkToUnload);
}
}
private ChunkCoord GetChunkCoord(Vector3 worldPos)
{
return new ChunkCoord(
(int)MathF.Floor(worldPos.X / ChunkSize),
(int)MathF.Floor(worldPos.Y / ChunkSize),
(int)MathF.Floor(worldPos.Z / ChunkSize)
);
}
private void LoadChunk(ChunkCoord coord)
{
_activeChunks[coord] = ChunkState.Loading;
// TODO: In a real system, you'd queue async I/O here to read SceneData for this chunk
// and spawn the entities. Once they are all spawned, set state to Loaded.
// For demonstration, immediately set to loaded.
_activeChunks[coord] = ChunkState.Loaded;
}
private void UnloadChunk(ChunkCoord coord)
{
// Instead of destroying everything instantly, we tag the entities as 'Unloaded'
// so that they stop participating in rendering/gameplay, and get destroyed slowly
// by the ChunkTeardownSystem.
long coordId = coord.Id;
foreach (var entity in _chunkMemberFilter.Entities)
{
var chunkMember = Get(entity);
if (chunkMember.ChunkId == coordId)
{
Set(entity, new ChunkUnloadPendingTag());
}
}
// Immediately remove it from the required grid so it can be re-loaded
// if the player turns around quickly, while older entities are just garbage collected.
_activeChunks.Remove(coord);
}
}