fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -3,6 +3,7 @@ using System.Collections.Concurrent;
using System.Numerics;
using DatReaderWriter;
using DatReaderWriter.Lib.IO;
using AcDream.Core.Content;
using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
using DatGfxObjDegradeInfo = DatReaderWriter.DBObjs.GfxObjDegradeInfo;
@ -27,7 +28,7 @@ public sealed class EmitterDescRegistry
{
}
public EmitterDescRegistry(DatCollection dats)
public EmitterDescRegistry(IDatObjectSource dats)
{
ArgumentNullException.ThrowIfNull(dats);
_resolver = id => SafeGet<DatParticleEmitter>(dats, id);
@ -155,7 +156,7 @@ public sealed class EmitterDescRegistry
}
private static float? ResolveMaxDegradeDistance(
DatCollection dats,
IDatObjectSource dats,
DatParticleEmitter emitter)
{
uint gfxObjId = GetRetailHardwareGfxObjId(emitter);
@ -178,7 +179,7 @@ public sealed class EmitterDescRegistry
internal static uint GetRetailHardwareGfxObjId(DatParticleEmitter emitter)
=> emitter.HwGfxObjId.DataId;
private static T? SafeGet<T>(DatCollection dats, uint id) where T : class, IDBObj
private static T? SafeGet<T>(IDatObjectSource dats, uint id) where T : class, IDBObj
{
if (dats is null)
return null;

View file

@ -14,6 +14,22 @@ public interface IEntityEffectPoseSource
bool TryGetPartPose(uint localEntityId, int partIndex, out Matrix4x4 partLocal);
}
/// <summary>
/// Optional update-thread notification surface for pose registries. Effect
/// consumers use this to refresh only owners whose final root, part, or cell
/// pose actually changed instead of polling every retained owner each frame.
/// </summary>
/// <remarks>
/// Notifications are synchronous and occur after the new pose is published.
/// Implementations must suppress notifications when a publish is byte-for-byte
/// equivalent to the current pose. Consumers must not mutate the pose source
/// from inside the callback.
/// </remarks>
public interface IEntityEffectPoseChangeSource
{
event Action<uint>? EffectPoseChanged;
}
/// <summary>
/// Optional spatial companion for consumers, such as object lights, whose
/// render registration is scoped to the owner's current AC cell.

View file

@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
@ -29,17 +28,35 @@ public sealed class ParticleHookSink : IAnimationHookSink
private readonly ParticleSystem _system;
private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectCellSource? _cells;
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, byte> _examinationOwners = new();
private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new();
private readonly IEntityEffectPoseChangeSource? _poseChanges;
// Game-state, pose, script, and particle mutation is update-thread owned.
// Ordinary collections avoid ConcurrentDictionary's per-binding node and
// enumeration overhead while preserving that single-writer contract.
private readonly Dictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
private readonly Dictionary<uint, HashSet<int>> _handlesByEntity = new();
private readonly Dictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly Dictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly HashSet<uint> _examinationOwners = [];
private readonly HashSet<uint> _hiddenPresentationOwners = [];
private readonly HashSet<uint> _stoppingOwners = [];
// Production pose registries publish actual root/part/cell changes. Only
// those owners are recomposed; static world emitters do no refresh work.
// Sources without the optional notification interface retain the safe
// owner-scoped polling fallback used by tests and plugins.
private readonly HashSet<uint> _spatialOwners = [];
private readonly HashSet<uint> _dirtyOwnerSet = [];
private readonly List<uint> _dirtyOwnerOrder = [];
private readonly List<uint> _refreshOwnerSnapshot = [];
public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
{
_system = system ?? throw new ArgumentNullException(nameof(system));
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
_cells = poses as IEntityEffectCellSource;
_poseChanges = poses as IEntityEffectPoseChangeSource;
if (_poseChanges is not null)
_poseChanges.EffectPoseChanged += OnEffectPoseChanged;
_system.EmitterDied += OnEmitterDied;
}
@ -57,23 +74,28 @@ public sealed class ParticleHookSink : IAnimationHookSink
public int RenderPassOwnerCount => _renderPassByEntity.Count;
public int ExaminationOwnerCount => _examinationOwners.Count;
public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count;
internal int LastRefreshOwnerVisitCount { get; private set; }
internal int LastRefreshBindingVisitCount { get; private set; }
private void OnEmitterDied(int handle)
{
if (!_bindingsByHandle.TryRemove(handle, out EmitterBinding binding))
if (!_bindingsByHandle.Remove(handle, out EmitterBinding binding))
return;
if (binding.LogicalId != 0
&& _handlesByKey.TryGetValue((binding.OwnerLocalId, binding.LogicalId), out int current)
&& current == handle)
{
_handlesByKey.TryRemove((binding.OwnerLocalId, binding.LogicalId), out _);
_handlesByKey.Remove((binding.OwnerLocalId, binding.LogicalId));
}
if (_handlesByEntity.TryGetValue(binding.OwnerLocalId, out var handles))
{
handles.TryRemove(handle, out _);
if (handles.IsEmpty)
_handlesByEntity.TryRemove(binding.OwnerLocalId, out _);
handles.Remove(handle);
if (handles.Count == 0)
{
_handlesByEntity.Remove(binding.OwnerLocalId);
_spatialOwners.Remove(binding.OwnerLocalId);
}
}
}
@ -127,7 +149,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
public void ClearEntityRenderPass(uint entityId)
{
_renderPassByEntity.TryRemove(entityId, out _);
_renderPassByEntity.Remove(entityId);
RefreshOwnerVisibilityPolicy(entityId);
}
@ -139,9 +161,9 @@ public sealed class ParticleHookSink : IAnimationHookSink
public void SetEntityExaminationObject(uint entityId, bool isExaminationObject)
{
if (isExaminationObject)
_examinationOwners[entityId] = 0;
_examinationOwners.Add(entityId);
else
_examinationOwners.TryRemove(entityId, out _);
_examinationOwners.Remove(entityId);
RefreshOwnerVisibilityPolicy(entityId);
}
@ -154,16 +176,27 @@ public sealed class ParticleHookSink : IAnimationHookSink
public void SetEntityPresentationVisible(uint entityId, bool visible)
{
if (visible)
_hiddenPresentationOwners.TryRemove(entityId, out _);
_hiddenPresentationOwners.Remove(entityId);
else
_hiddenPresentationOwners[entityId] = 0;
_hiddenPresentationOwners.Add(entityId);
if (visible)
{
if (_handlesByEntity.ContainsKey(entityId))
_spatialOwners.Add(entityId);
MarkOwnerDirty(entityId);
}
else
{
_spatialOwners.Remove(entityId);
}
// Withdrawal is immediate. Re-entry is enabled by the next pose
// refresh so an attached owner cannot flash for one frame at its old
// anchor before the composed child pose is published.
if (!visible && _handlesByEntity.TryGetValue(entityId, out var handles))
{
foreach (int handle in handles.Keys)
foreach (int handle in handles)
{
_system.SetEmitterPresentationVisible(handle, false);
_system.SetEmitterSimulationEnabled(handle, false);
@ -178,57 +211,141 @@ public sealed class ParticleHookSink : IAnimationHookSink
/// </summary>
public void RefreshAttachedEmitters()
{
foreach ((int handle, EmitterBinding binding) in _bindingsByHandle)
LastRefreshOwnerVisitCount = 0;
LastRefreshBindingVisitCount = 0;
_refreshOwnerSnapshot.Clear();
if (_poseChanges is null)
{
bool presentationVisible =
!_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
binding.HookOffsetOrigin,
out Vector3 anchor,
out Quaternion rotation,
out Vector3 ownerPosition))
foreach (uint ownerLocalId in _handlesByEntity.Keys)
_refreshOwnerSnapshot.Add(ownerLocalId);
}
else
{
_refreshOwnerSnapshot.AddRange(_dirtyOwnerOrder);
_dirtyOwnerOrder.Clear();
_dirtyOwnerSet.Clear();
}
for (int ownerIndex = 0; ownerIndex < _refreshOwnerSnapshot.Count; ownerIndex++)
{
uint ownerLocalId = _refreshOwnerSnapshot[ownerIndex];
if (!_handlesByEntity.TryGetValue(ownerLocalId, out HashSet<int>? handles))
{
_system.UpdateEmitterAnchor(handle, anchor, rotation);
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
_system.UpdateEmitterOwnerCell(
handle,
_cells is not null && _cells.TryGetCellId(binding.OwnerLocalId, out uint cellId)
? cellId
: 0u);
// Keep the anchor current while spatially paused; re-entry
// resumes at the authoritative pose without generating the
// absent interval's time- or distance-driven emissions.
_system.SetEmitterPresentationVisible(handle, presentationVisible);
_system.SetEmitterSimulationEnabled(handle, presentationVisible);
continue;
}
LastRefreshOwnerVisitCount++;
bool presentationVisible =
!_hiddenPresentationOwners.Contains(ownerLocalId);
foreach (int handle in handles)
{
if (!_bindingsByHandle.TryGetValue(handle, out EmitterBinding binding))
continue;
LastRefreshBindingVisitCount++;
if (TryResolveAnchor(ownerLocalId, binding.PartIndex,
binding.HookOffsetOrigin,
out Vector3 anchor,
out Quaternion rotation,
out Vector3 ownerPosition))
{
_system.UpdateEmitterAnchor(handle, anchor, rotation);
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
_system.UpdateEmitterOwnerCell(
handle,
_cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId)
? cellId
: 0u);
// Keep the anchor current while spatially paused; re-entry
// resumes at the authoritative pose without generating the
// absent interval's time- or distance-driven emissions.
_system.SetEmitterPresentationVisible(handle, presentationVisible);
_system.SetEmitterSimulationEnabled(handle, presentationVisible);
}
else
{
_system.SetEmitterPresentationVisible(handle, false);
_system.SetEmitterSimulationEnabled(handle, false);
}
}
else
_system.SetEmitterPresentationVisible(handle, false);
}
}
public void StopAllForEntity(uint entityId, bool fadeOut)
{
if (_handlesByEntity.TryRemove(entityId, out var handles))
// EmitterDied callbacks may execute arbitrary hook routing. Keep one
// owner-scoped teardown gate active until the initial bag is drained;
// a callback cannot resurrect an emitter after its logical owner has
// already been accepted for destruction.
if (!_stoppingOwners.Add(entityId))
return;
try
{
foreach (int handle in handles.Keys)
_spatialOwners.Remove(entityId);
List<Exception>? failures = null;
if (_handlesByEntity.TryGetValue(entityId, out HashSet<int>? handles))
{
// A fading emitter needs its clock to retire naturally. A
// hard destroy is removed synchronously by ParticleSystem.
if (fadeOut)
_system.SetEmitterSimulationEnabled(handle, true);
_system.StopEmitter(handle, fadeOut);
_bindingsByHandle.TryRemove(handle, out _);
int[] snapshot = [.. handles];
for (int i = 0; i < snapshot.Length; i++)
{
int handle = snapshot[i];
if (_bindingsByHandle.TryGetValue(handle, out EmitterBinding binding)
&& binding.LogicalId != 0)
{
_handlesByKey.Remove((entityId, binding.LogicalId));
}
// A fading emitter needs its clock to retire naturally. A
// hard destroy is removed synchronously by ParticleSystem.
bool stopCommitted = false;
try
{
if (fadeOut)
_system.SetEmitterSimulationEnabled(handle, true);
_system.StopEmitter(handle, fadeOut);
stopCommitted = true;
}
catch (Exception error)
{
// DestroyParticleEmitter removes the table entry before
// broadcasting EmitterDied. A subscriber failure therefore
// reports after the stop mutation committed; never replay
// that dead handle, but retain genuinely live failures for
// the next owner-teardown attempt.
stopCommitted = !_system.IsEmitterAlive(handle);
(failures ??= []).Add(error);
}
if (!stopCommitted)
continue;
handles.Remove(handle);
_bindingsByHandle.Remove(handle);
}
if (handles.Count == 0
&& _handlesByEntity.TryGetValue(entityId, out HashSet<int>? current)
&& ReferenceEquals(current, handles))
{
_handlesByEntity.Remove(entityId);
}
}
ClearEntityRenderPass(entityId);
_examinationOwners.Remove(entityId);
_hiddenPresentationOwners.Remove(entityId);
if (failures is not null)
{
throw new AggregateException(
$"One or more particle emitters for owner 0x{entityId:X8} failed to stop cleanly.",
failures);
}
}
foreach (var key in _handlesByKey.Keys)
finally
{
if (key.EntityId == entityId)
_handlesByKey.TryRemove(key, out _);
_stoppingOwners.Remove(entityId);
}
ClearEntityRenderPass(entityId);
_examinationOwners.TryRemove(entityId, out _);
_hiddenPresentationOwners.TryRemove(entityId, out _);
}
private void DestroyLogical(uint ownerLocalId, uint logicalId, bool fadeOut)
@ -244,7 +361,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
// A blocking create with the same logical ID must therefore continue
// to see it. DestroyParticleEmitter (0x0051B770) removes it at once.
if (!fadeOut)
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
_handlesByKey.Remove((ownerLocalId, logicalId));
if (fadeOut)
_system.SetEmitterSimulationEnabled(handle, true);
_system.StopEmitter(handle, fadeOut);
@ -258,13 +375,20 @@ public sealed class ParticleHookSink : IAnimationHookSink
uint logicalId,
bool isBlocking)
{
if (_stoppingOwners.Contains(ownerLocalId))
{
DiagnosticSink?.Invoke(
$"Particle creation for owner 0x{ownerLocalId:X8} was ignored while its emitters were stopping.");
return;
}
if (logicalId != 0
&& _handlesByKey.TryGetValue((ownerLocalId, logicalId), out int existing))
{
if (isBlocking && _system.IsEmitterAlive(existing))
return;
_handlesByKey.TryRemove((ownerLocalId, logicalId), out _);
_handlesByKey.Remove((ownerLocalId, logicalId));
_system.StopEmitter(existing, fadeOut: false);
}
@ -306,7 +430,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
_cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId)
? cellId
: 0u);
if (_hiddenPresentationOwners.ContainsKey(ownerLocalId))
if (_hiddenPresentationOwners.Contains(ownerLocalId))
{
_system.SetEmitterPresentationVisible(handle, false);
_system.SetEmitterSimulationEnabled(handle, false);
@ -320,16 +444,21 @@ public sealed class ParticleHookSink : IAnimationHookSink
logicalId,
renderPass);
_bindingsByHandle[handle] = binding;
_handlesByEntity
.GetOrAdd(ownerLocalId, _ => new ConcurrentDictionary<int, byte>())
.TryAdd(handle, 0);
if (!_handlesByEntity.TryGetValue(ownerLocalId, out HashSet<int>? handles))
{
handles = [];
_handlesByEntity.Add(ownerLocalId, handles);
}
handles.Add(handle);
if (!_hiddenPresentationOwners.Contains(ownerLocalId))
_spatialOwners.Add(ownerLocalId);
if (logicalId != 0)
_handlesByKey[(ownerLocalId, logicalId)] = handle;
}
private ParticleVisibilityPolicy ResolveVisibilityPolicy(uint ownerLocalId)
{
if (_examinationOwners.ContainsKey(ownerLocalId))
if (_examinationOwners.Contains(ownerLocalId))
return ParticleVisibilityPolicy.Examination;
return _renderPassByEntity.TryGetValue(ownerLocalId, out ParticleRenderPass pass)
@ -344,10 +473,24 @@ public sealed class ParticleHookSink : IAnimationHookSink
return;
ParticleVisibilityPolicy policy = ResolveVisibilityPolicy(ownerLocalId);
foreach (int handle in handles.Keys)
foreach (int handle in handles)
_system.SetEmitterVisibilityPolicy(handle, policy);
}
private void OnEffectPoseChanged(uint ownerLocalId)
=> MarkOwnerDirty(ownerLocalId);
private void MarkOwnerDirty(uint ownerLocalId)
{
if (!_handlesByEntity.ContainsKey(ownerLocalId)
|| !_dirtyOwnerSet.Add(ownerLocalId))
{
return;
}
_dirtyOwnerOrder.Add(ownerLocalId);
}
private bool TryResolveAnchor(
uint ownerLocalId,
int partIndex,

View file

@ -14,7 +14,79 @@ public sealed class ParticleSystem : IParticleSystem
private readonly EmitterDescRegistry _registry;
private readonly Random _rng;
private readonly Dictionary<int, ParticleEmitter> _byHandle = new();
private readonly List<int> _handleOrder = new();
// Handles are monotonic, so sorted indexes preserve retail emitter-spawn
// order while making every lifecycle edge O(log E). The old List.Remove
// hard-stop path was O(E) and became visible when portal routes retained
// thousands of finite/fading emitters.
private readonly SortedSet<int> _allHandles = [];
private readonly SortedSet<int> _simulationHandles = [];
private readonly SortedSet<int> _worldSimulationHandles = [];
private readonly SortedSet<int>[] _renderableHandlesByPass =
[[], [], []];
private readonly SortedSet<int>[] _renderableUnattachedHandlesByPass =
[[], [], []];
private readonly Dictionary<uint, OwnerEmitterBucket>[] _ownerHandlesByPass =
[new(), new(), new()];
private readonly List<int> _tickSnapshot = [];
private readonly List<int> _scopeHandleScratch = [];
private sealed class OwnerEmitterBucket
{
public int LogicalCount;
public int FirstRenderableHandle;
public List<int>? AdditionalRenderableHandles;
public void AddRenderable(int handle)
{
if (FirstRenderableHandle == 0)
{
FirstRenderableHandle = handle;
return;
}
if (handle < FirstRenderableHandle)
{
(FirstRenderableHandle, handle) = (handle, FirstRenderableHandle);
}
List<int> additional = AdditionalRenderableHandles ??= [];
int index = additional.BinarySearch(handle);
if (index < 0)
additional.Insert(~index, handle);
}
public void RemoveRenderable(int handle)
{
if (FirstRenderableHandle == handle)
{
if (AdditionalRenderableHandles is { Count: > 0 } additional)
{
FirstRenderableHandle = additional[0];
additional.RemoveAt(0);
}
else
{
FirstRenderableHandle = 0;
}
return;
}
if (AdditionalRenderableHandles is { } others)
{
int index = others.BinarySearch(handle);
if (index >= 0)
others.RemoveAt(index);
}
}
public void CopyRenderableHandlesTo(List<int> destination)
{
if (FirstRenderableHandle == 0)
return;
destination.Add(FirstRenderableHandle);
if (AdditionalRenderableHandles is { Count: > 0 } additional)
destination.AddRange(additional);
}
}
private int _nextHandle = 1;
private float _time;
@ -29,6 +101,10 @@ public sealed class ParticleSystem : IParticleSystem
public int ActiveEmitterCount => _byHandle.Count;
public int ActiveParticleCount => _activeParticleCount;
internal int LastTickEmitterVisitCount { get; private set; }
internal int LastRetailViewEmitterVisitCount { get; private set; }
internal int LastRenderScopeEmitterVisitCount { get; private set; }
public int SpawnEmitter(
EmitterDesc desc,
Vector3 anchor,
@ -59,7 +135,11 @@ public sealed class ParticleSystem : IParticleSystem
};
_byHandle[handle] = emitter;
_handleOrder.Add(handle);
_allHandles.Add(handle);
_simulationHandles.Add(handle);
if (visibilityPolicy == ParticleVisibilityPolicy.World)
_worldSimulationHandles.Add(handle);
AddEmitterToRenderIndexes(emitter);
for (int i = 0; i < desc.InitialParticles; i++)
SpawnOne(emitter, allowWhenFull: false);
@ -133,9 +213,7 @@ public sealed class ParticleSystem : IParticleSystem
// Retail DestroyParticleEmitter removes the table entry now; it
// does not wait for the next update. This is also required for a
// hard-stopped emitter whose cell-less simulation is paused.
_byHandle.Remove(handle);
_handleOrder.Remove(handle);
EmitterDied?.Invoke(handle);
RemoveEmitter(handle, em);
}
}
@ -180,8 +258,28 @@ public sealed class ParticleSystem : IParticleSystem
int handle,
ParticleVisibilityPolicy visibilityPolicy)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.VisibilityPolicy = visibilityPolicy;
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|| emitter.VisibilityPolicy == visibilityPolicy)
{
return;
}
bool wasRenderable = IsRenderable(emitter);
if (emitter.VisibilityPolicy == ParticleVisibilityPolicy.World)
_worldSimulationHandles.Remove(handle);
emitter.VisibilityPolicy = visibilityPolicy;
if (visibilityPolicy == ParticleVisibilityPolicy.World)
{
if (emitter.SimulationEnabled)
_worldSimulationHandles.Add(handle);
}
else
{
// Examination and dedicated-pass objects bypass world PView.
emitter.ViewEligible = true;
}
RefreshRenderableIndex(emitter, wasRenderable);
}
/// <summary>
@ -200,21 +298,19 @@ public sealed class ParticleSystem : IParticleSystem
if (!float.IsFinite(rangeMultiplier) || rangeMultiplier <= 0f)
rangeMultiplier = 1f;
foreach (int handle in _handleOrder)
LastRetailViewEmitterVisitCount = 0;
foreach (int handle in _worldSimulationHandles)
{
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
continue;
if (emitter.VisibilityPolicy != ParticleVisibilityPolicy.World)
{
emitter.ViewEligible = true;
continue;
}
LastRetailViewEmitterVisitCount++;
bool wasRenderable = IsRenderable(emitter);
if (!hasCompletedView)
{
// With no completed world PView (portal space, login, or a
// reset frame), a world cell cannot report IsInView.
emitter.ViewEligible = false;
RefreshRenderableIndex(emitter, wasRenderable);
continue;
}
@ -227,6 +323,7 @@ public sealed class ParticleSystem : IParticleSystem
&& (float.IsNaN(distance)
|| float.IsNaN(maxDistance)
|| distance <= maxDistance);
RefreshRenderableIndex(emitter, wasRenderable);
}
}
@ -235,8 +332,15 @@ public sealed class ParticleSystem : IParticleSystem
/// </summary>
public void SetEmitterPresentationVisible(int handle, bool visible)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.PresentationVisible = visible;
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter)
|| emitter.PresentationVisible == visible)
{
return;
}
bool wasRenderable = IsRenderable(emitter);
emitter.PresentationVisible = visible;
RefreshRenderableIndex(emitter, wasRenderable);
}
/// <summary>
@ -256,7 +360,19 @@ public sealed class ParticleSystem : IParticleSystem
if (!enabled)
{
bool wasRenderable = IsRenderable(emitter);
emitter.SimulationEnabled = false;
_simulationHandles.Remove(handle);
_worldSimulationHandles.Remove(handle);
if (emitter.VisibilityPolicy == ParticleVisibilityPolicy.World)
{
// A spatially withdrawn owner has no current CObjCell::IsInView
// result. Invalidate the previous PView decision so re-entry
// cannot become renderable until ApplyRetailView evaluates the
// owner against a freshly completed view.
emitter.ViewEligible = false;
RefreshRenderableIndex(emitter, wasRenderable);
}
return;
}
@ -266,6 +382,11 @@ public sealed class ParticleSystem : IParticleSystem
emitter.EmittedAccumulator = 0f;
}
emitter.SimulationEnabled = true;
_simulationHandles.Add(handle);
if (emitter.VisibilityPolicy == ParticleVisibilityPolicy.World)
_worldSimulationHandles.Add(handle);
else
emitter.ViewEligible = true;
}
/// <summary>True when the given handle still maps to a live emitter.</summary>
@ -287,14 +408,24 @@ public sealed class ParticleSystem : IParticleSystem
_time += dt;
_activeParticleCount = 0;
LastTickEmitterVisitCount = 0;
for (int i = 0; i < _handleOrder.Count; i++)
// EmitterDied callbacks may synchronously stop another emitter or
// create a replacement. Snapshot the current workset into retained
// storage so those mutations cannot invalidate traversal; newly
// created emitters begin on the following retail object update.
_tickSnapshot.Clear();
foreach (int handle in _simulationHandles)
_tickSnapshot.Add(handle);
for (int i = 0; i < _tickSnapshot.Count; i++)
{
int handle = _handleOrder[i];
int handle = _tickSnapshot[i];
if (!_byHandle.TryGetValue(handle, out var em))
continue;
if (!em.SimulationEnabled)
continue;
LastTickEmitterVisitCount++;
bool wasFinishedBeforeUpdate = em.Finished;
if (em.ViewEligible)
@ -321,10 +452,7 @@ public sealed class ParticleSystem : IParticleSystem
// branch may return num_particles == 0 and retire the emitter.
if (em.Finished && live == 0 && wasFinishedBeforeUpdate)
{
_byHandle.Remove(handle);
_handleOrder.RemoveAt(i);
i--;
EmitterDied?.Invoke(handle);
RemoveEmitter(handle, em);
}
}
}
@ -357,6 +485,65 @@ public sealed class ParticleSystem : IParticleSystem
/// </summary>
public LiveEmitterEnumerable EnumerateEmitters() => new(this);
/// <summary>
/// Enumerates only presentation-visible, view-eligible emitters in the
/// requested pass, retaining global spawn order. Renderers should use this
/// workset instead of rejecting every logical emitter on every pass.
/// </summary>
public RenderableEmitterEnumerable EnumerateRenderableEmitters(
ParticleRenderPass renderPass) => new(this, renderPass);
/// <summary>
/// Copies the renderable emitters owned by a visibility scope without
/// walking unrelated emitters in the same render pass. Results retain
/// global emitter-spawn order so equal-distance retail alpha ties remain
/// identical to the unscoped path.
/// </summary>
public void CopyRenderableEmittersForOwners(
ParticleRenderPass renderPass,
IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached,
List<ParticleEmitter> destination,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
{
ArgumentNullException.ThrowIfNull(attachedOwnerIds);
ArgumentNullException.ThrowIfNull(destination);
destination.Clear();
LastRenderScopeEmitterVisitCount = 0;
int passIndex = RenderPassIndex(renderPass);
if (includeUnattached)
{
foreach (int handle in _renderableUnattachedHandlesByPass[passIndex])
{
LastRenderScopeEmitterVisitCount++;
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
destination.Add(emitter);
}
}
Dictionary<uint, OwnerEmitterBucket> owners = _ownerHandlesByPass[passIndex];
foreach (uint ownerId in attachedOwnerIds)
{
if (excludedAttachedOwnerIds?.Contains(ownerId) == true
|| !owners.TryGetValue(ownerId, out OwnerEmitterBucket? bucket))
{
continue;
}
_scopeHandleScratch.Clear();
bucket.CopyRenderableHandlesTo(_scopeHandleScratch);
LastRenderScopeEmitterVisitCount += _scopeHandleScratch.Count;
foreach (int handle in _scopeHandleScratch)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
destination.Add(emitter);
}
}
destination.Sort(static (left, right) => left.Handle.CompareTo(right.Handle));
}
public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
{
private readonly ParticleSystem _owner;
@ -372,7 +559,7 @@ public sealed class ParticleSystem : IParticleSystem
private static IEnumerable<ParticleEmitter> EnumerateBoxed(ParticleSystem owner)
{
foreach (int handle in owner._handleOrder)
foreach (int handle in owner._allHandles)
{
if (owner._byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
yield return emitter;
@ -382,12 +569,12 @@ public sealed class ParticleSystem : IParticleSystem
public struct Enumerator
{
private readonly ParticleSystem _owner;
private int _index;
private SortedSet<int>.Enumerator _handles;
internal Enumerator(ParticleSystem owner)
{
_owner = owner;
_index = -1;
_handles = owner._allHandles.GetEnumerator();
Current = null!;
}
@ -395,11 +582,68 @@ public sealed class ParticleSystem : IParticleSystem
public bool MoveNext()
{
while (++_index < _owner._handleOrder.Count)
while (_handles.MoveNext())
{
if (_owner._byHandle.TryGetValue(
_owner._handleOrder[_index],
out ParticleEmitter? emitter))
if (_owner._byHandle.TryGetValue(_handles.Current, out ParticleEmitter? emitter))
{
Current = emitter;
return true;
}
}
return false;
}
}
}
public readonly struct RenderableEmitterEnumerable : IEnumerable<ParticleEmitter>
{
private readonly ParticleSystem _owner;
private readonly int _passIndex;
internal RenderableEmitterEnumerable(ParticleSystem owner, ParticleRenderPass renderPass)
{
_owner = owner;
_passIndex = RenderPassIndex(renderPass);
}
public Enumerator GetEnumerator() => new(_owner, _passIndex);
IEnumerator<ParticleEmitter> IEnumerable<ParticleEmitter>.GetEnumerator()
=> EnumerateBoxed(_owner, _passIndex).GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> EnumerateBoxed(_owner, _passIndex).GetEnumerator();
private static IEnumerable<ParticleEmitter> EnumerateBoxed(
ParticleSystem owner,
int passIndex)
{
foreach (int handle in owner._renderableHandlesByPass[passIndex])
{
if (owner._byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
yield return emitter;
}
}
public struct Enumerator
{
private readonly ParticleSystem _owner;
private SortedSet<int>.Enumerator _handles;
internal Enumerator(ParticleSystem owner, int passIndex)
{
_owner = owner;
_handles = owner._renderableHandlesByPass[passIndex].GetEnumerator();
Current = null!;
}
public ParticleEmitter Current { get; private set; }
public bool MoveNext()
{
while (_handles.MoveNext())
{
if (_owner._byHandle.TryGetValue(_handles.Current, out ParticleEmitter? emitter))
{
Current = emitter;
return true;
@ -432,7 +676,7 @@ public sealed class ParticleSystem : IParticleSystem
private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner)
{
foreach (var handle in owner._handleOrder)
foreach (var handle in owner._allHandles)
{
if (!owner._byHandle.TryGetValue(handle, out var em))
continue;
@ -449,14 +693,14 @@ public sealed class ParticleSystem : IParticleSystem
public struct Enumerator
{
private readonly ParticleSystem _owner;
private int _handleIdx;
private SortedSet<int>.Enumerator _handles;
private ParticleEmitter? _currentEmitter;
private int _particleIdx;
internal Enumerator(ParticleSystem owner)
{
_owner = owner;
_handleIdx = -1;
_handles = owner._allHandles.GetEnumerator();
_currentEmitter = null;
_particleIdx = -1;
}
@ -477,11 +721,10 @@ public sealed class ParticleSystem : IParticleSystem
_currentEmitter = null;
}
_handleIdx++;
if (_handleIdx >= _owner._handleOrder.Count)
if (!_handles.MoveNext())
return false;
if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em))
if (!_owner._byHandle.TryGetValue(_handles.Current, out var em))
continue;
_currentEmitter = em;
@ -491,6 +734,126 @@ public sealed class ParticleSystem : IParticleSystem
}
}
private void RemoveEmitter(int handle, ParticleEmitter emitter)
{
if (!_byHandle.Remove(handle))
return;
_allHandles.Remove(handle);
_simulationHandles.Remove(handle);
_worldSimulationHandles.Remove(handle);
int passIndex = RenderPassIndex(emitter.RenderPass);
_renderableHandlesByPass[passIndex].Remove(handle);
if (emitter.AttachedObjectId == 0)
{
_renderableUnattachedHandlesByPass[passIndex].Remove(handle);
}
else if (_ownerHandlesByPass[passIndex].TryGetValue(
emitter.AttachedObjectId,
out OwnerEmitterBucket? bucket))
{
bucket.RemoveRenderable(handle);
bucket.LogicalCount--;
if (bucket.LogicalCount == 0)
_ownerHandlesByPass[passIndex].Remove(emitter.AttachedObjectId);
}
NotifyEmitterDied(handle);
}
private void NotifyEmitterDied(int handle)
{
Action<int>? callbacks = EmitterDied;
if (callbacks is null)
return;
List<Exception>? failures = null;
foreach (Action<int> callback in callbacks.GetInvocationList().Cast<Action<int>>())
{
try
{
callback(handle);
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (failures is not null)
throw new AggregateException(
$"One or more emitter-death callbacks failed for handle {handle}.",
failures);
}
private void AddEmitterToRenderIndexes(ParticleEmitter emitter)
{
int passIndex = RenderPassIndex(emitter.RenderPass);
OwnerEmitterBucket? ownerBucket = null;
if (emitter.AttachedObjectId != 0)
{
if (!_ownerHandlesByPass[passIndex].TryGetValue(
emitter.AttachedObjectId,
out ownerBucket))
{
ownerBucket = new OwnerEmitterBucket();
_ownerHandlesByPass[passIndex].Add(emitter.AttachedObjectId, ownerBucket);
}
ownerBucket.LogicalCount++;
}
if (IsRenderable(emitter))
{
_renderableHandlesByPass[passIndex].Add(emitter.Handle);
if (emitter.AttachedObjectId == 0)
_renderableUnattachedHandlesByPass[passIndex].Add(emitter.Handle);
else
ownerBucket!.AddRenderable(emitter.Handle);
}
}
private void RefreshRenderableIndex(ParticleEmitter emitter, bool wasRenderable)
{
bool isRenderable = IsRenderable(emitter);
if (wasRenderable == isRenderable)
return;
SortedSet<int> index =
_renderableHandlesByPass[RenderPassIndex(emitter.RenderPass)];
if (isRenderable)
index.Add(emitter.Handle);
else
index.Remove(emitter.Handle);
int passIndex = RenderPassIndex(emitter.RenderPass);
if (emitter.AttachedObjectId == 0)
{
if (isRenderable)
_renderableUnattachedHandlesByPass[passIndex].Add(emitter.Handle);
else
_renderableUnattachedHandlesByPass[passIndex].Remove(emitter.Handle);
}
else if (_ownerHandlesByPass[passIndex].TryGetValue(
emitter.AttachedObjectId,
out OwnerEmitterBucket? bucket))
{
if (isRenderable)
bucket.AddRenderable(emitter.Handle);
else
bucket.RemoveRenderable(emitter.Handle);
}
}
private static bool IsRenderable(ParticleEmitter emitter)
=> emitter.PresentationVisible && emitter.ViewEligible;
private static int RenderPassIndex(ParticleRenderPass renderPass)
{
int index = (int)renderPass;
if ((uint)index >= 3u)
throw new ArgumentOutOfRangeException(nameof(renderPass));
return index;
}
private void AdvanceEmitter(ParticleEmitter em)
{
for (int i = 0; i < em.Particles.Length; i++)

View file

@ -30,6 +30,7 @@ public sealed class PhysicsScriptRunner
private readonly Dictionary<uint, OwnerQueue> _owners = new();
private readonly Dictionary<uint, Vector3> _ownerAnchors = new();
private readonly List<DelayedCallPes> _delayedCalls = new();
private readonly List<uint> _ownerIterationSnapshot = new();
private double _gameTime;
private long _tickGeneration;
private bool _frameTimePublished;
@ -196,10 +197,12 @@ public sealed class PhysicsScriptRunner
// Snapshot owner IDs. Hooks may append to their current owner, create a
// different owner, or delete an owner without invalidating iteration.
uint[] ownerIds = _owners.Keys.ToArray();
for (int ownerIndex = 0; ownerIndex < ownerIds.Length; ownerIndex++)
_ownerIterationSnapshot.Clear();
foreach (uint ownerId in _owners.Keys)
_ownerIterationSnapshot.Add(ownerId);
for (int ownerIndex = 0; ownerIndex < _ownerIterationSnapshot.Count; ownerIndex++)
{
uint ownerId = ownerIds[ownerIndex];
uint ownerId = _ownerIterationSnapshot[ownerIndex];
if (!_owners.TryGetValue(ownerId, out OwnerQueue? owner))
continue;
if (!_canAdvanceOwner(ownerId))