diff --git a/Nerfed.Runtime/Engine.cs b/Nerfed.Runtime/Engine.cs index 3a7e894..8df6f15 100644 --- a/Nerfed.Runtime/Engine.cs +++ b/Nerfed.Runtime/Engine.cs @@ -71,6 +71,8 @@ public static class Engine AudioDevice = new AudioDevice(); + JobSystem.Default.Initialize(); + OnInitialize?.Invoke(); while (!quit) @@ -84,6 +86,7 @@ public static class Engine MainWindow.Dispose(); GraphicsDevice.Dispose(); AudioDevice.Dispose(); + JobSystem.Default.Shutdown(); SDL.SDL_Quit(); } diff --git a/Nerfed.Runtime/JobSystem.cs b/Nerfed.Runtime/JobSystem.cs new file mode 100644 index 0000000..5ca2ac4 --- /dev/null +++ b/Nerfed.Runtime/JobSystem.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; + +namespace Nerfed.Runtime; + +public sealed class JobSystem : IDisposable +{ + public static readonly JobSystem Default = new JobSystem(); + + private Thread[] workers; + private SemaphoreSlim startSignal; + private CountdownEvent completionEvent; + private volatile bool running; + + // Shared per-dispatch state written by main thread before workers wake. + private volatile Action currentAction; + private int workCount; + private int nextIndex; // grabbed with Interlocked.Increment for work-stealing + + public int WorkerCount => workers?.Length ?? 0; + public bool IsInitialized => workers != null; + + public void Initialize(int threadCount = -1) + { + if (IsInitialized) + { + throw new InvalidOperationException("JobSystem is already initialized. Call Shutdown first."); + } + + threadCount = threadCount < 0 + ? Math.Max(1, Environment.ProcessorCount - 2) + : Math.Max(1, threadCount); + + running = true; + startSignal = new SemaphoreSlim(0); + completionEvent = new CountdownEvent(1); + workers = new Thread[threadCount]; + + for (int i = 0; i < threadCount; i++) + { + workers[i] = new Thread(WorkerLoop) + { + IsBackground = true, + Name = $"Job-{i}", + }; + workers[i].Start(); + } + } + + public void Dispatch(int count, Action action) + { + if (count <= 0) return; + + if (!IsInitialized) + { + // Safe fallback: run inline if Initialize was never called. + for (int i = 0; i < count; i++) + action(i); + return; + } + + currentAction = action; + workCount = count; + Volatile.Write(ref nextIndex, 0); + + completionEvent.Reset(workers.Length); + startSignal.Release(workers.Length); + completionEvent.Wait(); + + currentAction = null; + } + + public void Shutdown() + { + if (!IsInitialized) return; + + running = false; + startSignal.Release(workers.Length); // wake all workers so they can see running=false and exit + + foreach (Thread t in workers) + t.Join(); + + completionEvent.Dispose(); + startSignal.Dispose(); + workers = null; + } + + public void Dispose() => Shutdown(); + + private void WorkerLoop() + { + while (true) + { + startSignal.Wait(); + if (!running) return; + + Action action = currentAction; + int total = workCount; + + while (true) + { + int index = Interlocked.Increment(ref nextIndex) - 1; + if (index >= total) break; + action(index); + } + + completionEvent.Signal(); + } + } +} diff --git a/Nerfed.Runtime/Systems/LocalToWorldSystem.cs b/Nerfed.Runtime/Systems/LocalToWorldSystem.cs index 1949807..4144281 100644 --- a/Nerfed.Runtime/Systems/LocalToWorldSystem.cs +++ b/Nerfed.Runtime/Systems/LocalToWorldSystem.cs @@ -15,19 +15,17 @@ namespace Nerfed.Runtime.Systems { public class LocalToWorldSystem : MoonTools.ECS.System { - private readonly bool useParallelFor = true; // When having a low amount of transforms or when in debug mode this might be slower. + private readonly JobSystem jobs; private readonly Filter rootEntitiesFilter; private readonly Filter entitiesWithoutLocalToWorldFilter; - private readonly Action updateWorldTransform; + private readonly Action updateWorldTransformByIndex; - public LocalToWorldSystem(World world) : base(world) + public LocalToWorldSystem(World world, JobSystem jobs = null) : base(world) { + this.jobs = jobs ?? JobSystem.Default; rootEntitiesFilter = FilterBuilder.Include().Exclude().Build(); - if (useParallelFor) - { - entitiesWithoutLocalToWorldFilter = FilterBuilder.Include().Exclude().Build(); - updateWorldTransform = UpdateWorldTransformByIndex; - } + entitiesWithoutLocalToWorldFilter = FilterBuilder.Include().Exclude().Build(); + updateWorldTransformByIndex = UpdateWorldTransformByIndex; } public override void Update(TimeSpan delta) @@ -37,20 +35,18 @@ namespace Nerfed.Runtime.Systems return; } - if (useParallelFor) + if (this.jobs.WorkerCount > 0) { - Profiler.BeginSample("ParallelFor.LocalToWorldCheck"); - // This check is needed because some entities might not have a LocalToWorld component yet. - // Adding this during the loop will break. - foreach (Entity entity in entitiesWithoutLocalToWorldFilter.Entities) { + Profiler.BeginSample("LocalToWorldCheck"); + // Structural pre-pass: ensure LocalToWorld exists on all entities before parallel writes. + foreach (Entity entity in entitiesWithoutLocalToWorldFilter.Entities) + { Set(entity, new LocalToWorld(Matrix4x4.Identity)); } Profiler.EndSample(); - Profiler.BeginSample("ParallelFor.LocalToWorldUpdate"); - // This should only be used when the filter doesn't change by executing these functions! - // So no entity deletion or setting/removing of components used by the filters in this loop. - Parallel.For(0, rootEntitiesFilter.Count, updateWorldTransform); + Profiler.BeginSample("LocalToWorldUpdate"); + this.jobs.Dispatch(rootEntitiesFilter.Count, updateWorldTransformByIndex); Profiler.EndSample(); } else @@ -66,22 +62,25 @@ namespace Nerfed.Runtime.Systems private void UpdateWorldTransformByIndex(int entityFilterIndex) { - Profiler.BeginSample("UpdateWorldTransformByIndex"); + using ProfilerScope scope = new("UpdateWorldTransformByIndex"); Entity entity = rootEntitiesFilter.NthEntity(entityFilterIndex); UpdateWorldTransform(entity, Matrix4x4.Identity); - Profiler.EndSample(); } private void UpdateWorldTransform(in Entity entity, Matrix4x4 localToWorldMatrix) { - // TODO: Only update dirty transforms. - // If a parent is dirty all the children need to update their localToWorld matrix. - // How do we check if something is dirty? How do we know if a LocalTransform has been changed? if (Has(entity)) { LocalTransform localTransform = Get(entity); localToWorldMatrix = Matrix4x4.Multiply(localToWorldMatrix, localTransform.TRS()); LocalToWorld localToWorld = new(localToWorldMatrix); +#if DEBUG + if (!Has(entity)) + { + throw new InvalidOperationException( + $"Entity {entity} is missing LocalToWorld. Ensure the structural pre-pass runs before parallel dispatch."); + } +#endif Set(entity, localToWorld); }