Okay, then my misconception was that I thought unity physics is nonblocking (Edit: It is blocking other systems that depend on the same data. It is not blocking the main thread) (which in retrospective is quite strange, because I have seen the profilers performance spikes often enough to know better).
So, just to recap: for the default world: one frame is always the smallest time frame. And only if the frame rate drops below 1/60 (default) the physics engine would make use of its catch-up mechanism.
So probably the cleanest solution for my problem would be to write a native class like EntityCommandBuffer that just queues changes on Entity Components rather than Entities and that does not playback after the ComponentGroup is done, but only when it is told to do so. It would then also have to check if an Entity and the Component still exists before it updates.
But this solution sounds to me more like a feature request than something I should write myself.
For now I will settle with something like this:
- Simple nonnative Buffer where component changes can be added to and retrieved from
- SystemGroup with FixedRateManager that executes once a second
- System that schedules and cleans up the Buffer appropriately
Excuse the amount of code:
Buffer could look like this:
using System;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
public struct ComponentChangeBuffer<T> : INativeDisposable, IDisposable where T : struct
{
private NativeHashMap<Entity, T> _result;
public ComponentChangeBuffer(int expectedSize)
{
_result = new NativeHashMap<Entity, T>(expectedSize, Allocator.Persistent);
}
public void AddToBuffer(Entity entity, T component)
{
_result.Add(entity, component);
}
public T GetComponent(Entity entity, T defaultReturn)
{
if (_result.ContainsKey(entity))
return _result[entity];
return defaultReturn;
}
public void Clear()
{
_result.Clear();
}
public void Dispose()
{
_result.Dispose();
}
public void TryDispose()
{
if(_result.IsCreated)
_result.Dispose();
}
public JobHandle Dispose(JobHandle inputDeps)
{
return _result.Dispose(inputDeps);
}
}
System like this (shortest version I could come up with that demonstrates the usecase):
using System;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
[UpdateInGroup(typeof(TryExecuteOnceASecondSystemGroup))]
public class TryExecuteOnceASecondSystem : JobComponentSystem, IDisposable
{
private EntityQuery _expensiveComponentsQuery;
private expensiveJob _expensiveJob;
private JobHandle _expensiveJobHandle;
private JobHandle _applyJobHandle;
ComponentChangeBuffer<ExpensiveComponent> _expensiveComponentChangeBuffer;
private bool needsToApply = false;
private const int _expensiveness = 1000;
protected override void OnCreate()
{
base.OnCreate();
_expensiveComponentsQuery = GetEntityQuery(ComponentType.ReadOnly<ExpensiveComponent>(), ComponentType.ReadOnly<Translation>());
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
Debug.Log("TryExecuteOnceASecondSystem was called");
if (_expensiveJobHandle.IsCompleted && _applyJobHandle.IsCompleted)
{
// if a job is completed apply results
if (needsToApply)
{
Debug.Log("Applying result");
needsToApply = false;
var expensiveComponentChangeBuffer = _expensiveComponentChangeBuffer;
_applyJobHandle = Entities.WithReadOnly(expensiveComponentChangeBuffer)
.ForEach((ref Entity entity,
ref Translation translation,
in ExpensiveComponent expensiveComponent) =>
{
var updatedComponent = expensiveComponentChangeBuffer.GetComponent(entity, expensiveComponent);
translation.Value = translation.Value + updatedComponent.complexData / _expensiveness * new float3(0, 1, 0);
}).WithDisposeOnCompletion(expensiveComponentChangeBuffer)
.Schedule(JobHandle.CombineDependencies(inputDeps, _expensiveJobHandle));
return _applyJobHandle;
}
// if results were applied start execution again
Debug.Log("Starting new execution.");
needsToApply = true;
int expensiveComponentsCount = _expensiveComponentsQuery.CalculateEntityCount();
var expensiveComponents = _expensiveComponentsQuery.ToComponentDataArray<ExpensiveComponent>(Allocator.Persistent);
var expensiveEntities = _expensiveComponentsQuery.ToEntityArray(Allocator.Persistent);
_expensiveComponentChangeBuffer = new ComponentChangeBuffer<ExpensiveComponent>(expensiveComponentsCount);
_expensiveJob = new expensiveJob
{
expensiveComponents = expensiveComponents,
expensiveEntities = expensiveEntities,
expensiveComponentChangeBuffer = _expensiveComponentChangeBuffer
};
_expensiveJobHandle = _expensiveJob.Schedule(expensiveComponents.Length, JobHandle.CombineDependencies(_applyJobHandle, inputDeps));
}
else
{
Debug.LogWarning("Skipped execution");
}
// return only inputDeps and do not depend on expensiveJobHandle
return inputDeps;
}
[BurstCompile]
public struct expensiveJob : IJobFor
{
[DeallocateOnJobCompletion]
public NativeArray<ExpensiveComponent> expensiveComponents;
[DeallocateOnJobCompletion]
public NativeArray<Entity> expensiveEntities;
public ComponentChangeBuffer<ExpensiveComponent> expensiveComponentChangeBuffer;
public void Execute(int index)
{
var expensiveComponent = expensiveComponents[index];
expensiveComponent.complexData = ExpensiveCalculation(_expensiveness);
expensiveComponentChangeBuffer.AddToBuffer(expensiveEntities[index], expensiveComponent);
}
}
protected override void OnStopRunning()
{
base.OnStopRunning();
_expensiveJobHandle.Complete();
// will clean up _expensiveComponentChangeBuffer if it runs
_applyJobHandle.Complete();
// if not clean up by hand
if (needsToApply)
_expensiveComponentChangeBuffer.Dispose();
}
public void Dispose()
{
_expensiveComponentChangeBuffer.Dispose();
}
#region Helper
private static long ExpensiveCalculation(int n)
{
int count = 0;
long a = 2;
while (count < n)
{
long b = 2;
int prime = 1;
while (b * b <= a)
{
if (a % b == 0)
{
prime = 0;
break;
}
b++;
}
if (prime > 0)
{
count++;
}
a++;
}
return (--a);
}
#endregion
}
Used Component:
using System;
using Unity.Entities;
using Unity.Transforms;
[Serializable, GenerateAuthoringComponent]
public struct ExpensiveComponent : IComponentData
{
public long complexData;
}
SystemGroup:
using Unity.Entities;
[UpdateInGroup(typeof(SimulationSystemGroup))]
public class TryExecuteOnceASecondSystemGroup : ComponentSystemGroup
{
public TryExecuteOnceASecondSystemGroup() => FixedRateManager = new UpdateIntervalFixedRateManager(1);
protected override void OnCreate()
{
base.OnCreate();
}
protected override void OnUpdate()
{
base.OnUpdate();
}
}
FixedRateManager:
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
public class UpdateIntervalFixedRateManager : IFixedRateManager
{
float _maximumDeltaTime;
public float MaximumDeltaTime
{
get => _maximumDeltaTime;
set => _maximumDeltaTime = math.max(value, _fixedTimestep);
}
private double _lastUpdateTime;
bool _didPushTime;
double _maxFinalElapsedTime;
long _fixedUpdateCount;
private float _fixedTimestep { get; set; }
public float Timestep
{
get => _fixedTimestep;
set
{
_fixedTimestep = math.clamp(value, 0.01f, 10f);
}
}
public UpdateIntervalFixedRateManager(float updateIntervalSeconds)
{
Timestep = updateIntervalSeconds;
}
public bool ShouldGroupUpdate(ComponentSystemGroup group)
{
float worldMaximumDeltaTime = group.World.MaximumDeltaTime;
float maximumDeltaTime = math.max(worldMaximumDeltaTime, _fixedTimestep);
// if this is true, means we're being called a second or later time in a loop
if (_didPushTime)
{
group.World.PopTime();
}
else
{
_maxFinalElapsedTime = _lastUpdateTime + maximumDeltaTime;
}
var finalElapsedTime = math.min(_maxFinalElapsedTime, group.World.Time.ElapsedTime);
if (_fixedUpdateCount == 0)
{
// First update should always occur at t=0
}
else if (finalElapsedTime - _lastUpdateTime >= _fixedTimestep)
{
// Advance the timestep and update the system group
_lastUpdateTime += _fixedTimestep;
}
else
{
// No update is necessary at this time.
_didPushTime = false;
return false;
}
_fixedUpdateCount++;
group.World.PushTime(new TimeData(
elapsedTime: _lastUpdateTime,
deltaTime: _fixedTimestep));
_didPushTime = true;
return true;
}
}