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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||
using System.Collections.Generic;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using AcDream.Core.Content;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
|
|
@ -22,11 +23,11 @@ namespace AcDream.Core.Audio;
|
|||
/// </summary>
|
||||
public sealed class DatSoundCache
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly IDatObjectSource _dats;
|
||||
private readonly ConcurrentDictionary<uint, WaveData?> _waves = new();
|
||||
private readonly ConcurrentDictionary<uint, SoundTable?> _tables = new();
|
||||
|
||||
public DatSoundCache(DatCollection dats)
|
||||
public DatSoundCache(IDatObjectSource dats)
|
||||
{
|
||||
_dats = dats;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ public sealed class ChatLog
|
|||
private readonly ConcurrentQueue<ChatEntry> _buffer = new();
|
||||
private readonly int _maxEntries;
|
||||
private uint _localPlayerGuid;
|
||||
private long _revision;
|
||||
|
||||
// Phase J follow-up: ACE often sends the same system text via two
|
||||
// wire paths (GameMessageSystemChat 0xF7E0 + GameEventCommunication-
|
||||
|
|
@ -51,6 +53,13 @@ public sealed class ChatLog
|
|||
|
||||
public int Count => _buffer.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Monotonic content revision. It advances after every successful append and
|
||||
/// every explicit clear, allowing retained UI consumers to cache formatted
|
||||
/// transcript layout without snapshotting the concurrent queue every frame.
|
||||
/// </summary>
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
|
||||
/// <summary>
|
||||
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
|
||||
/// One-way setter — only <c>GameWindow</c> should call it, exactly
|
||||
|
|
@ -301,12 +310,14 @@ public sealed class ChatLog
|
|||
_buffer.Enqueue(entry);
|
||||
while (_buffer.Count > _maxEntries)
|
||||
_buffer.TryDequeue(out _);
|
||||
Interlocked.Increment(ref _revision);
|
||||
EntryAppended?.Invoke(entry);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
while (_buffer.TryDequeue(out _)) { /* drain */ }
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
21
src/AcDream.Core/Content/IDatObjectSource.cs
Normal file
21
src/AcDream.Core/Content/IDatObjectSource.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using DatReaderWriter.Lib.IO;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AcDream.Core.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal typed DAT-object lookup seam used by Core algorithms.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Core deliberately does not own DAT file handles or caching policy. The App
|
||||
/// runtime supplies the bounded Content-layer implementation; tests and
|
||||
/// short-lived tools may adapt another source explicitly.
|
||||
/// </remarks>
|
||||
public interface IDatObjectSource
|
||||
{
|
||||
[return: MaybeNull]
|
||||
T Get<T>(uint fileId) where T : IDBObj;
|
||||
|
||||
bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
|
||||
where T : IDBObj;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ using System.Numerics;
|
|||
using AcDream.Core.Terrain;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
|
@ -28,7 +29,7 @@ public static class CellMesh
|
|||
/// <see cref="GfxObjSubMesh.Translucency"/>. When null (e.g. offline tests)
|
||||
/// all sub-meshes default to <see cref="TranslucencyKind.Opaque"/>.
|
||||
/// </param>
|
||||
public static IReadOnlyList<GfxObjSubMesh> Build(EnvCell envCell, CellStruct cellStruct, DatCollection? dats = null)
|
||||
public static IReadOnlyList<GfxObjSubMesh> Build(EnvCell envCell, CellStruct cellStruct, IDatObjectSource? dats = null)
|
||||
{
|
||||
// Group output vertices and indices per surface dat id.
|
||||
var perSurface = new Dictionary<uint, (List<Vertex> Vertices, List<uint> Indices, Dictionary<(int pos, int uv), uint> Dedupe)>();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
|
@ -66,7 +67,7 @@ public static class GfxObjDegradeResolver
|
|||
/// this; tests use the callback overload below for easy fakes.
|
||||
/// </summary>
|
||||
public static bool TryResolveCloseGfxObj(
|
||||
DatCollection dats,
|
||||
IDatObjectSource dats,
|
||||
uint gfxObjId,
|
||||
out uint resolvedId,
|
||||
out GfxObj? resolvedGfxObj)
|
||||
|
|
@ -156,7 +157,7 @@ public static class GfxObjDegradeResolver
|
|||
/// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at
|
||||
/// distance > 0) skip such GfxObjs.
|
||||
/// </summary>
|
||||
public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId)
|
||||
public static bool IsRuntimeHiddenMarker(IDatObjectSource dats, uint gfxObjId)
|
||||
=> IsRuntimeHiddenMarker(
|
||||
id => dats.Get<GfxObj>(id),
|
||||
id => dats.Get<GfxObjDegradeInfo>(id),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Numerics;
|
|||
using AcDream.Core.Terrain;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
|
@ -50,7 +51,7 @@ public static class GfxObjMesh
|
|||
/// normals (and potentially different UVs via <c>NegUVIndices</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj, DatCollection? dats = null)
|
||||
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj, IDatObjectSource? dats = null)
|
||||
{
|
||||
// One bucket per (surface-index, isNeg) pair. Negative-side triangles
|
||||
// always land in a different bucket than their positive counterparts
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using DatReaderWriter;
|
|||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Content;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
|
|
@ -75,7 +76,7 @@ public static class MotionResolver
|
|||
/// </summary>
|
||||
public static IdleCycle? GetIdleCycle(
|
||||
Setup setup,
|
||||
DatCollection dats,
|
||||
IDatObjectSource dats,
|
||||
IAnimationLoader animationLoader,
|
||||
uint? motionTableIdOverride = null,
|
||||
ushort? stanceOverride = null,
|
||||
|
|
@ -123,7 +124,7 @@ public static class MotionResolver
|
|||
|
||||
public static AnimationFrame? GetIdleFrame(
|
||||
Setup setup,
|
||||
DatCollection dats,
|
||||
IDatObjectSource dats,
|
||||
IAnimationLoader animationLoader,
|
||||
uint? motionTableIdOverride = null,
|
||||
ushort? stanceOverride = null,
|
||||
|
|
@ -148,7 +149,7 @@ public static class MotionResolver
|
|||
/// </summary>
|
||||
private static (Animation, AnimData)? ResolveIdleCycleInternal(
|
||||
Setup setup,
|
||||
DatCollection dats,
|
||||
IDatObjectSource dats,
|
||||
IAnimationLoader animationLoader,
|
||||
uint? motionTableIdOverride,
|
||||
ushort? stanceOverride,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,20 @@ namespace AcDream.Core.Plugins;
|
|||
public sealed class WorldEvents : IEvents
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly List<WorldEntitySnapshot> _alreadySpawned = new();
|
||||
private Action<WorldEntitySnapshot>? _subscribers;
|
||||
// Late subscribers replay the current projected world, not every entity
|
||||
// encountered since process start. Spawn notifications themselves remain
|
||||
// ephemeral; the public plugin API does not expose a despawn event yet.
|
||||
private readonly Dictionary<uint, WorldEntitySnapshot> _current = new();
|
||||
private readonly List<Subscription> _subscriptions = new();
|
||||
private Subscription[] _liveSnapshot = Array.Empty<Subscription>();
|
||||
|
||||
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
|
||||
{
|
||||
public Action<WorldEntitySnapshot> Handler { get; } = handler;
|
||||
public Queue<WorldEntitySnapshot> Pending { get; } = new();
|
||||
public bool Replaying { get; set; } = true;
|
||||
public bool Active { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the host as each entity is hydrated into the world. Records the
|
||||
|
|
@ -15,42 +27,155 @@ public sealed class WorldEvents : IEvents
|
|||
/// </summary>
|
||||
public void FireEntitySpawned(WorldEntitySnapshot snapshot)
|
||||
{
|
||||
Action<WorldEntitySnapshot>? toNotify;
|
||||
Subscription[] toNotify;
|
||||
lock (_lock)
|
||||
{
|
||||
_alreadySpawned.Add(snapshot);
|
||||
toNotify = _subscribers;
|
||||
_current[snapshot.Id] = snapshot;
|
||||
for (int i = 0; i < _subscriptions.Count; i++)
|
||||
{
|
||||
Subscription subscription = _subscriptions[i];
|
||||
if (subscription.Active && subscription.Replaying)
|
||||
subscription.Pending.Enqueue(snapshot);
|
||||
}
|
||||
toNotify = _liveSnapshot;
|
||||
}
|
||||
|
||||
if (toNotify is null) return;
|
||||
foreach (Action<WorldEntitySnapshot> handler in toNotify.GetInvocationList())
|
||||
for (int i = 0; i < toNotify.Length; i++)
|
||||
{
|
||||
try { handler(snapshot); }
|
||||
try { toNotify[i].Handler(snapshot); }
|
||||
catch { /* plugin errors don't propagate out of event dispatch */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore current replay membership without emitting another logical
|
||||
/// spawn notification. Used when a still-live object re-enters the world.
|
||||
/// </summary>
|
||||
public void UpsertCurrent(WorldEntitySnapshot snapshot)
|
||||
{
|
||||
lock (_lock)
|
||||
_current[snapshot.Id] = snapshot;
|
||||
}
|
||||
|
||||
public bool ForgetEntity(uint id)
|
||||
{
|
||||
lock (_lock)
|
||||
return _current.Remove(id);
|
||||
}
|
||||
|
||||
public void ClearCurrent()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_current.Clear();
|
||||
for (int i = 0; i < _subscriptions.Count; i++)
|
||||
{
|
||||
Subscription subscription = _subscriptions[i];
|
||||
if (subscription.Replaying)
|
||||
subscription.Pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||
{
|
||||
add
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
var subscription = new Subscription(value);
|
||||
WorldEntitySnapshot[] replay;
|
||||
lock (_lock)
|
||||
{
|
||||
_subscribers += value;
|
||||
replay = _alreadySpawned.ToArray();
|
||||
_subscriptions.Add(subscription);
|
||||
replay = _current.Values.ToArray();
|
||||
}
|
||||
// Replay outside the lock to avoid deadlock if a handler re-enters.
|
||||
|
||||
// Replay outside the lock. Live events that arrive meanwhile queue
|
||||
// behind this snapshot and drain before the subscription joins the
|
||||
// direct multicast, preserving one monotonic delivery order.
|
||||
foreach (var s in replay)
|
||||
{
|
||||
try { value(s); }
|
||||
lock (_lock)
|
||||
{
|
||||
if (!subscription.Active)
|
||||
return;
|
||||
if (!_current.TryGetValue(s.Id, out WorldEntitySnapshot current)
|
||||
|| current != s)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try { subscription.Handler(s); }
|
||||
catch { /* plugin errors don't propagate out of += */ }
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
WorldEntitySnapshot pending;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!subscription.Active)
|
||||
return;
|
||||
if (!subscription.Pending.TryDequeue(out pending))
|
||||
{
|
||||
subscription.Replaying = false;
|
||||
RebuildLiveSnapshotLocked();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try { subscription.Handler(pending); }
|
||||
catch { /* plugin errors don't propagate out of += */ }
|
||||
}
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
lock (_lock)
|
||||
_subscribers -= value;
|
||||
{
|
||||
for (int i = _subscriptions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
Subscription subscription = _subscriptions[i];
|
||||
if (subscription.Handler != value)
|
||||
continue;
|
||||
|
||||
subscription.Active = false;
|
||||
subscription.Pending.Clear();
|
||||
_subscriptions.RemoveAt(i);
|
||||
if (!subscription.Replaying)
|
||||
RebuildLiveSnapshotLocked();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildLiveSnapshotLocked()
|
||||
{
|
||||
int count = 0;
|
||||
for (int i = 0; i < _subscriptions.Count; i++)
|
||||
{
|
||||
Subscription subscription = _subscriptions[i];
|
||||
if (subscription.Active && !subscription.Replaying)
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
_liveSnapshot = Array.Empty<Subscription>();
|
||||
return;
|
||||
}
|
||||
|
||||
var rebuilt = new Subscription[count];
|
||||
int write = 0;
|
||||
for (int i = 0; i < _subscriptions.Count; i++)
|
||||
{
|
||||
Subscription subscription = _subscriptions[i];
|
||||
if (subscription.Active && !subscription.Replaying)
|
||||
rebuilt[write++] = subscription;
|
||||
}
|
||||
_liveSnapshot = rebuilt;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,25 @@ namespace AcDream.Core.Plugins;
|
|||
public sealed class WorldGameState : IGameState
|
||||
{
|
||||
private readonly List<WorldEntitySnapshot> _entities = new();
|
||||
private readonly Dictionary<uint, int> _indexById = new();
|
||||
|
||||
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
|
||||
|
||||
/// <summary>Called by the host as each entity is hydrated.</summary>
|
||||
public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
|
||||
/// <summary>
|
||||
/// Publish the current projection for an entity. Re-hydration replaces the
|
||||
/// prior snapshot instead of turning the current-state API into history.
|
||||
/// </summary>
|
||||
public void Add(WorldEntitySnapshot snapshot)
|
||||
{
|
||||
if (_indexById.TryGetValue(snapshot.Id, out int index))
|
||||
{
|
||||
_entities[index] = snapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
_indexById.Add(snapshot.Id, _entities.Count);
|
||||
_entities.Add(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove any snapshot with the given local <c>Id</c>. Used when the
|
||||
|
|
@ -18,5 +32,25 @@ public sealed class WorldGameState : IGameState
|
|||
/// acdream — the host deletes the old snapshot before adding the new
|
||||
/// one so plugins don't see stale duplicates.
|
||||
/// </summary>
|
||||
public void RemoveById(uint id) => _entities.RemoveAll(e => e.Id == id);
|
||||
public bool RemoveById(uint id)
|
||||
{
|
||||
if (!_indexById.Remove(id, out int index))
|
||||
return false;
|
||||
|
||||
int lastIndex = _entities.Count - 1;
|
||||
if (index != lastIndex)
|
||||
{
|
||||
WorldEntitySnapshot moved = _entities[lastIndex];
|
||||
_entities[index] = moved;
|
||||
_indexById[moved.Id] = index;
|
||||
}
|
||||
_entities.RemoveAt(lastIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_entities.Clear();
|
||||
_indexById.Clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ public static class LandblockLoader
|
|||
/// Load a single landblock (heightmap + static objects) from the dats.
|
||||
/// </summary>
|
||||
/// <returns>Null if the landblock is missing from the cell dat.</returns>
|
||||
public static LoadedLandblock? Load(DatCollection dats, uint landblockId)
|
||||
public static LoadedLandblock? Load(IDatObjectSource dats, uint landblockId)
|
||||
{
|
||||
var block = dats.Get<LandBlock>(landblockId);
|
||||
if (block is null)
|
||||
|
|
@ -40,7 +41,7 @@ public static class LandblockLoader
|
|||
var result = new List<WorldEntity>(info.Objects.Count + info.Buildings.Count);
|
||||
|
||||
// When landblockId is non-zero, namespace stab Ids globally:
|
||||
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
|
||||
// 0xCXXYYIII, where XX = lbX, YY = lbY, III = 12-bit counter
|
||||
// distinct from the 0x8XXYYIII scenery and 0x4XXYYIII interior
|
||||
// namespaces in GameWindow.cs. The 0xC0 top byte distinguishes stabs.
|
||||
//
|
||||
|
|
@ -48,20 +49,21 @@ public static class LandblockLoader
|
|||
// legacy starting-from-1 monotonic Ids — compatible with their assertions
|
||||
// which check uniqueness within a single landblock.
|
||||
//
|
||||
// The low byte reserves 0 for the namespace base and 1..255 for
|
||||
// entities. Never wrap into the Y byte: a collision here would corrupt
|
||||
// every renderer/physics/effect table keyed by WorldEntity.Id.
|
||||
uint stabIdBase = landblockId == 0
|
||||
? 0u
|
||||
: 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8;
|
||||
uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u;
|
||||
// The 12-bit allocator fails before crossing into the adjacent
|
||||
// landblock's Y range; no wrapped id can corrupt renderer, physics, or
|
||||
// effect tables keyed by WorldEntity.Id.
|
||||
uint landblockX = (landblockId >> 24) & 0xFFu;
|
||||
uint landblockY = (landblockId >> 16) & 0xFFu;
|
||||
uint nextId = landblockId == 0 ? 1u : 0u;
|
||||
|
||||
uint AllocateId()
|
||||
{
|
||||
if (stabIdBase != 0 && nextId > stabIdBase + 0xFFu)
|
||||
throw new InvalidDataException(
|
||||
$"Landblock 0x{landblockId:X8} exceeds the 255-entry static stab id namespace.");
|
||||
return nextId++;
|
||||
if (landblockId == 0)
|
||||
return nextId++;
|
||||
return LandblockStaticEntityIdAllocator.Allocate(
|
||||
landblockX,
|
||||
landblockY,
|
||||
ref nextId);
|
||||
}
|
||||
|
||||
foreach (var stab in info.Objects)
|
||||
|
|
|
|||
36
src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs
Normal file
36
src/AcDream.Core/World/LandblockStaticEntityIdAllocator.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Allocates stable, collision-free local ids for dat-authored
|
||||
/// <c>LandBlockInfo.Objects</c> and building shells.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The top nibble is fixed at <c>0xC</c>. The remaining 28 bits encode the
|
||||
/// complete landblock X byte, Y byte, and a 12-bit per-landblock counter:
|
||||
/// <c>0xCXXYYIII</c>. The former <c>0xC0XXYYII</c> packing had only eight
|
||||
/// counter bits, so a legitimate dense retail landblock aborted after 255
|
||||
/// entries and was retried every time it streamed into view.
|
||||
/// </remarks>
|
||||
public static class LandblockStaticEntityIdAllocator
|
||||
{
|
||||
public const uint MaxCounter = 0xFFFu;
|
||||
|
||||
public static uint Base(uint landblockX, uint landblockY) =>
|
||||
0xC0000000u
|
||||
| ((landblockX & 0xFFu) << 20)
|
||||
| ((landblockY & 0xFFu) << 12);
|
||||
|
||||
public static uint Allocate(uint landblockX, uint landblockY, ref uint counter)
|
||||
{
|
||||
if (counter > MaxCounter)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Landblock ({landblockX & 0xFFu:X2},{landblockY & 0xFFu:X2}) exceeds the 4096-entry static entity id namespace.");
|
||||
}
|
||||
|
||||
return Base(landblockX, landblockY) + counter++;
|
||||
}
|
||||
|
||||
public static bool IsInNamespace(uint entityId) =>
|
||||
(entityId & 0xF0000000u) == 0xC0000000u;
|
||||
}
|
||||
|
|
@ -21,9 +21,8 @@ public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform)
|
|||
/// bind a sub-mesh's texture, it consults this map. A hit means
|
||||
/// "use the base Surface's colors/flags/palette but swap its
|
||||
/// <c>OrigTextureId</c> for the value here." The renderer calls
|
||||
/// <c>TextureCache.GetOrUploadWithOrigTextureOverride</c> which caches
|
||||
/// the decoded result under a composite key so multiple entities can
|
||||
/// share the same override without redecoding.
|
||||
/// the owning renderer resolves an owner-scoped composite. Equivalent
|
||||
/// live entities may share it, and the final owner releases it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null means "no overrides, use each sub-mesh's native surface as-is."
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Rendering.Wb;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
|
@ -50,7 +51,7 @@ public static class SceneryGenerator
|
|||
/// see <c>docs/superpowers/specs/2026-05-08-phase-n1-scenery-via-wb-helpers-design.md</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ScenerySpawn> Generate(
|
||||
DatCollection dats,
|
||||
IDatObjectSource dats,
|
||||
Region region,
|
||||
LandBlock block,
|
||||
uint landblockId,
|
||||
|
|
@ -71,7 +72,7 @@ public static class SceneryGenerator
|
|||
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
|
||||
|
||||
private static IReadOnlyList<ScenerySpawn> GenerateInternal(
|
||||
DatCollection dats,
|
||||
IDatObjectSource dats,
|
||||
Region region,
|
||||
LandBlock block,
|
||||
uint landblockId,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
|
@ -357,7 +358,7 @@ public static class SkyDescLoader
|
|||
/// Load + parse. Returns <c>null</c> if the Region doesn't have
|
||||
/// <see cref="PartsMask.HasSkyInfo"/> or the dat is absent.
|
||||
/// </summary>
|
||||
public static LoadedSkyDesc? LoadFromDat(DatCollection dats)
|
||||
public static LoadedSkyDesc? LoadFromDat(IDatObjectSource dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
var region = dats.Get<Region>(RegionDatId);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// src/AcDream.Core/World/WorldView.cs
|
||||
using DatReaderWriter;
|
||||
using AcDream.Core.Content;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ public sealed class WorldView
|
|||
/// Load the 3x3 grid of landblocks around <paramref name="centerLandblockId"/>.
|
||||
/// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped.
|
||||
/// </summary>
|
||||
public static WorldView Load(DatCollection dats, uint centerLandblockId)
|
||||
public static WorldView Load(IDatObjectSource dats, uint centerLandblockId)
|
||||
{
|
||||
var loaded = new List<LoadedLandblock>();
|
||||
foreach (var id in NeighborLandblockIds(centerLandblockId))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue