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); } }