using System.Numerics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; using AcDream.App.UI; using AcDream.Core.Lighting; using AcDream.Core.World; namespace AcDream.App.Rendering; /// /// Camera contract for retail CreatureMode-style UI viewports. The /// renderer supplies the authored viewport aspect before each draw and uses /// when it publishes the private scene lighting UBO. /// internal interface IPrivateEntityViewportCamera : ICamera { Vector3 Eye { get; } } /// /// Shared render-to-texture implementation for the private 3-D creature /// viewports used by paperdoll and examination UI. Each instance owns one /// per encountered GPU flight slot, one /// synthetic render identity, and one balanced texture-owner lease. /// /// Campaign V slice V6k (V4g's first half). The target used to be a /// hand-rolled FBO, colour texture and depth renderbuffer, and the resulting GL /// texture name was published to the retained UI through /// GlGpuDevice.RegisterExternalColorTexture — the pre-approved /// transitional seam plan §7.1's final paragraph created, whose stated end was /// exactly this slice. Both are gone: the device creates the target, the pass /// DECLARES it, and the colour attachment is registered into the global texture /// table like any other texture. /// /// Declaring the target is what discharges §5.4. That section's divergence /// — GL's BeginPass not binding framebuffer 0 for a null target, so this /// renderer's own FBO survived — was reverted with V4c and is not on the tree; /// what remained was the reason it had existed, namely that this renderer bound /// a framebuffer the RHI knew nothing about. It now names its target, so /// nothing depends on inheritance and the GL and Vulkan backends agree about /// what Target: null means. /// /// The GL arm this renderer used to open an RHI pass and delegate a /// raw-GL WbDrawDispatcher into (through V10, §5.5.6) was deleted at /// Campaign V slice V11: WbDrawDispatcher now records into the pass /// this renderer publishes on both call sites the same way. /// /// /// Campaign CC gate round 1, Batch D (GF-7/GF-14). Retail's /// gmCG3DView::Update @0x004EE9D0 draws a SECOND private entity — a /// heritage-authored environment Setup (m_pbgObject) — behind the main /// one, in the SAME creature_mode_objects list. This renderer now /// supports that as an OPTIONAL second entity slot, reserved at construction /// via -shaped ctor param (see below) — /// paperdoll and creature-appraisal never pass one, so /// throws for them rather than silently doing nothing (the slot does not /// exist). See for /// the full decomp citation of the backdrop's placement (unposed, at the /// scene origin, added to the draw list BEFORE the main entity). /// /// internal sealed class PrivateEntityViewportRenderer : IUiViewportRenderer, IDisposable { private const uint PrivateLandblockId = 0u; private readonly ICurrentGpuFrameSource _frames; /// /// The world pass scope this renderer's own pass publishes into for the /// span of the draw. WbDrawDispatcher borrows its pass from the /// scope rather than opening one, so this renderer — which opens a pass of /// its own to draw into an offscreen target — has to publish it there. /// /// The raw-GL arm this used to be optional for (the GL dispatcher /// recorded against whatever framebuffer was already bound, needing no /// publication) was deleted at Campaign V slice V11. /// private readonly IWorldPassScope _scope; private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; private readonly IWbMeshAdapter _meshAdapter; private readonly IPrivateEntityViewportCamera _camera; private readonly HashSet _animatedIds; private readonly string _diagnosticName; private readonly EntitySlot _mainSlot; /// Null for every renderer that never reserved a /// backdropRenderId (paperdoll, creature-appraisal) — the backdrop /// feature does not exist for them, not just "unused". private readonly EntitySlot? _backdropSlot; // A target written by frame N cannot also be sampled by an unretired frame // N-1. Vulkan permits those command buffers to overlap, so one shared image // is a cross-frame write/read race. The current frame slot selects one // bounded target + texture-table handle; the retained UI samples that exact // handle later in the same command buffer. private readonly PrivateViewportFlightTargets _flightTargets; public PrivateEntityViewportRenderer( IWorldPassScope scope, IGpuDevice device, ICurrentGpuFrameSource frames, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, IEntityTextureLifetime textureLifetime, IWbMeshAdapter meshAdapter, uint renderId, IPrivateEntityViewportCamera camera, string diagnosticName, uint? backdropRenderId = null) { if (renderId == 0u) throw new ArgumentOutOfRangeException(nameof(renderId)); if (backdropRenderId == 0u) throw new ArgumentOutOfRangeException(nameof(backdropRenderId)); _scope = scope ?? throw new ArgumentNullException( nameof(scope), "The viewport must publish a world pass scope to draw into."); ArgumentNullException.ThrowIfNull(device); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); _meshAdapter = meshAdapter ?? throw new ArgumentNullException(nameof(meshAdapter)); _camera = camera ?? throw new ArgumentNullException(nameof(camera)); _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" : diagnosticName; _flightTargets = new PrivateViewportFlightTargets( device, _diagnosticName); IEntityTextureLifetime textureLifetimeChecked = textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)); _mainSlot = new EntitySlot(_meshAdapter, textureLifetimeChecked, renderId, _diagnosticName); _backdropSlot = backdropRenderId is uint backdropId ? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop") : null; // F14 (Campaign CC gate round 1 closeout): this set is built ONCE // here, from the RESERVED backdropRenderId (a renderer either has a // backdrop slot or it doesn't — see _backdropSlot's own doc), not // from whether a backdrop ENTITY is currently set via // SetBackdrop/BuildDrawEntities. That is deliberately harmless, not // an oversight: BuildDrawEntities below already degrades to // [main] alone whenever the backdrop slot is null or has no // meshes, so animatedEntityIds carrying a backdrop id with no // matching entry in THIS frame's actual draw-entities list is a // pure dead lookup (WbDrawDispatcher.Draw only ever consults this // set against ids it is ACTUALLY drawing) — never a wrong-entity // animation flag, never extra per-frame work beyond one inert // HashSet entry. Recomputing per-frame would add real complexity // (a second HashSet allocation or a mutable-set sync path) for a // case that is already correct by construction. _animatedIds = backdropRenderId is uint animatedBackdropId ? [renderId, animatedBackdropId] : [renderId]; } /// /// A GL framebuffer's colour texture used to sample bottom-up; that arm was /// deleted at Campaign V slice V11, and a Vulkan render target's does not. /// See . /// public bool TextureIsBottomUp => false; public void SetEntity(WorldEntity? entity) { _mainSlot.Set(entity); if (entity is null) { // A character-session reset explicitly invalidates the sampled // scenes. Do not let a replacement that is still uploading expose // the previous character through any flight target. _flightTargets.InvalidateCompletedScenes(); } } /// /// Advances the private entity's mesh and texture-composite readiness /// without allocating or clearing a render target. Paperdoll uses this /// while its tab is hidden so first-open work is already resident. /// public bool Prepare() { if (!_mainSlot.PrepareForDraw() || !(_backdropSlot?.PrepareForDraw() ?? true)) { return false; } WorldEntity? entity = _mainSlot.Entity; if (entity is null || entity.MeshRefs.Count == 0) { return false; } IReadOnlyList entities = BuildDrawEntities( _backdropSlot?.Entity, entity); return _dispatcher.PreparePrivateEntityResources(entities); } /// /// Sets or clears the environment backdrop entity drawn BEHIND the main /// entity — GF-7/GF-14's fix, retail's gmCG3DView::m_pbgObject. Only /// valid on a renderer constructed with a backdropRenderId /// ('s own construction); calling this /// on a renderer that never reserved one (paperdoll, creature-appraisal) /// throws — the slot does not exist for them, so there is nothing to make /// "inert" by silently ignoring the call instead. /// public void SetBackdrop(WorldEntity? entity) { if (_backdropSlot is null) { throw new InvalidOperationException( $"The {_diagnosticName} was not constructed with a " + "backdropRenderId and cannot render a second (backdrop) entity."); } _backdropSlot.Set(entity); } /// /// Renders the entity and returns the the /// retained UI blits — a one-based index into the device's global texture /// table, not a GL texture name. Zero means nothing was rendered. /// public uint Render(int width, int height) { if (width <= 0 || height <= 0) return 0u; IGpuFrame frame = _frames.CurrentFrame ?? throw new InvalidOperationException( $"The {_diagnosticName} requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); int frameSlot = frame.SlotIndex; // #443: acquiring a synthetic mesh reference only schedules CPU // preparation/GPU upload; it does not make the mesh drawable. Keep the // current flight slot's last completed private scene intact until every // drawable mesh in the replacement has crossed that upload barrier. // On first open there is no completed scene, so return zero and let the // authored panel art show through instead of publishing a cleared target. bool mainReady = _mainSlot.PrepareForDraw(); bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true; if (!mainReady || !backdropReady) { return _mainSlot.Entity is not null ? _flightTargets.CompletedHandle(frameSlot) : 0u; } WorldEntity? entity = _mainSlot.Entity; if (entity is null || entity.MeshRefs.Count == 0) return 0u; IReadOnlyList drawEntities = BuildDrawEntities( _backdropSlot?.Entity, entity); if (!_dispatcher.PreparePrivateEntityResources(drawEntities)) return _flightTargets.CompletedHandle(frameSlot); PrivateViewportFlightTargets.TargetSlot? targetSlot = _flightTargets.Ensure(frameSlot, width, height); if (targetSlot is null) return 0u; _camera.Aspect = width / (float)height; using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription { Name = _diagnosticName, Color = new GpuColorAttachment( Target: targetSlot.Target, Load: GpuLoadOp.Clear, Store: GpuStoreOp.Store, ClearColor: Vector4.Zero), Depth = new GpuDepthAttachment( Load: GpuLoadOp.Clear, Store: GpuStoreOp.DontCare, ClearDepth: 1f, ClearStencil: 0), SampleCount = 1, }); // The dispatcher borrows its pass from the scope, so this pass has to // BE the scope's for the span of the draw. Published after the world // phase has closed its own, which is why it does not nest; the // sections it resets are republished by UploadCreatureLight // immediately below, which is the private lighting this view wants // rather than the world's. using IDisposable publication = _scope.Publish(encoder); UploadCreatureLight(); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] { ( PrivateLandblockId, new Vector3(-1024f), new Vector3(1024f), drawEntities, null), }; // #443: a private pass must not append its transforms into the shared // world transform frame — the default mesh shaders index parallel // per-instance arrays zero-based, so a non-zero arena base zeroes the // doll's per-instance opacity and the target stays blank whenever a // world frame is active. See NextClassicDrawIsPrivatePass. _dispatcher.NextClassicDrawIsPrivatePass = true; _dispatcher.Draw( _camera, entries, frustum: null, neverCullLandblockId: PrivateLandblockId, visibleCellIds: null, animatedEntityIds: _animatedIds); targetSlot.HasRenderedScene = true; return UiTextureTableHandle.FromSlot(targetSlot.TextureSlot); } /// /// Pure helper assembling this frame's draw-entity list in retail's own /// insertion order — gmCG3DView::Update adds the backdrop object to /// creature_mode_objects BEFORE the main (player) object is /// re-added (the player's own re-AddObject happens much later, at /// ~0x004ef199, after the full clothing ObjDesc composes — see /// 's own decomp /// citation). A null or empty-meshed backdrop degrades to exactly the main /// entity — this is the paperdoll/creature-appraisal invariant (they never /// configure a backdrop slot at all, so this always takes this branch for /// them), pinned directly by /// PrivateEntityViewportRendererDrawOrderTests without needing a /// live GPU device or a constructed . /// internal static IReadOnlyList BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) => backdrop is not null && backdrop.MeshRefs.Count > 0 ? [backdrop, main] : [main]; /// /// Both retail paperdoll and creature examination call /// UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65)). /// Byte-decoded confirmation (Batch D re-derivation): the SAME three /// float32 constants (0x3e99999a/0x3ff33333/0x3F266666 /// = 0.3/1.9/0.65) appear verbatim at gmCG3DView::Update's own /// SetLight call site (pseudo-C ~0x004eecd3-0x004eece3) — the /// chargen preview uses the EXACT same light this method already ported, /// not a different value. /// private void UploadCreatureLight() { Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f)); _lightUbo.Upload(new SceneLightingUbo { Light0 = new UboLight { PosAndKind = Vector4.Zero, DirAndRange = new Vector4(direction, 1e9f), ColorAndIntensity = new Vector4(1f, 1f, 1f, 2f), ConeAngleEtc = Vector4.Zero, }, CellAmbient = new Vector4(0.3f, 0.3f, 0.3f, 1f), FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), FogColor = Vector4.Zero, CameraAndTime = new Vector4(_camera.Eye, 0f), }); } public void Dispose() { List? failures = null; try { _mainSlot.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } try { _backdropSlot?.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } try { _flightTargets.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } if (failures is not null) { throw new AggregateException( $"The {_diagnosticName} resources did not fully release.", failures); } } /// /// Bounded render-target ownership keyed by . /// A frame slot is reopened only after its previous submission retires, so /// the target selected here can be written and sampled within that frame /// without racing a different in-flight command buffer. /// internal sealed class PrivateViewportFlightTargets : IDisposable { internal sealed class TargetSlot( IGpuRenderTarget target, GpuTextureSlot textureSlot) { internal IGpuRenderTarget Target { get; } = target; internal GpuTextureSlot TextureSlot { get; } = textureSlot; internal bool HasRenderedScene { get; set; } } private readonly IGpuDevice _device; private readonly string _diagnosticName; private readonly List _slots = []; private int _width; private int _height; private bool _disposed; internal PrivateViewportFlightTargets( IGpuDevice device, string diagnosticName) { _device = device ?? throw new ArgumentNullException(nameof(device)); _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" : diagnosticName; } internal int AllocatedSlotCount => _slots.Count(static slot => slot is not null); internal TargetSlot? Ensure(int frameSlot, int width, int height) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentOutOfRangeException.ThrowIfNegative(frameSlot); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); if (_width != 0 && (_width != width || _height != height)) ReleaseAll(); while (_slots.Count <= frameSlot) _slots.Add(null); if (_slots[frameSlot] is { } existing) return existing; IGpuRenderTarget target; try { target = _device.CreateRenderTarget( new GpuRenderTargetDescription( $"{_diagnosticName}-flight-{frameSlot}", width, height, GpuTextureFormat.Rgba8UnormRenderTarget, // Depth24Stencil8, as the original private viewport // renderbuffer was. Nothing samples this attachment. GpuTextureFormat.Depth24Stencil8, SampleCount: 1)); } catch (Exception failure) { Console.WriteLine( $"[{_diagnosticName}] render target unavailable " + $"({width}x{height}, flight {frameSlot}): {failure.Message}"); return null; } try { // The device de-duplicates immutable samplers. Retained UI // blits this target through its ordinary texture-table entry. IGpuSampler sampler = _device.CreateSampler( GpuSamplerDescription.WorldClamp); GpuTextureSlot textureSlot = _device.RegisterTexture( target.ColorTexture, sampler); var created = new TargetSlot(target, textureSlot); _slots[frameSlot] = created; _width = width; _height = height; return created; } catch { target.Dispose(); throw; } } internal uint CompletedHandle(int frameSlot) { if ((uint)frameSlot >= (uint)_slots.Count || _slots[frameSlot] is not { HasRenderedScene: true } slot) { return 0u; } return UiTextureTableHandle.FromSlot(slot.TextureSlot); } internal void InvalidateCompletedScenes() { for (int i = 0; i < _slots.Count; i++) { if (_slots[i] is { } slot) slot.HasRenderedScene = false; } } private void ReleaseAll() { List? failures = null; for (int i = 0; i < _slots.Count; i++) { TargetSlot? slot = _slots[i]; if (slot is null) continue; try { _device.ReleaseTextureSlot(slot.TextureSlot); } catch (Exception error) { (failures ??= []).Add(error); } try { slot.Target.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } } _slots.Clear(); _width = 0; _height = 0; if (failures is { Count: > 0 }) throw new AggregateException(failures); } public void Dispose() { if (_disposed) return; _disposed = true; ReleaseAll(); } } /// /// One private entity's mesh-reference/texture-owner lifetime, independent /// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh /// references while preparation/upload runs, but does not replace the /// drawable entity until all of its actual /// are resident. Kept internal so #443's lifetime/readiness behavior can /// be pinned without constructing a live GPU device. /// internal sealed class EntitySlot { private sealed class MeshSnapshot { private readonly ulong[] _ownedIds; private readonly int _drawMeshCount; private MeshSnapshot(ulong[] ownedIds, int drawMeshCount) { _ownedIds = ownedIds; _drawMeshCount = drawMeshCount; } public IReadOnlyList OwnedIds => _ownedIds; public static MeshSnapshot Capture(WorldEntity entity) { int drawMeshCount = entity.MeshRefs.Count; var ids = new ulong[drawMeshCount + entity.PartOverrides.Count]; for (int i = 0; i < drawMeshCount; i++) ids[i] = entity.MeshRefs[i].GfxObjId; for (int i = 0; i < entity.PartOverrides.Count; i++) ids[drawMeshCount + i] = entity.PartOverrides[i].GfxObjId; return new MeshSnapshot(ids, drawMeshCount); } /// /// Residency identity deliberately excludes part transforms, /// palette ranges and surface overrides: changing those does not /// require another mesh upload. A different entity instance still /// stages a replacement so its fixed texture owner is refreshed. /// public bool Matches(WorldEntity entity) { if (entity.MeshRefs.Count != _drawMeshCount || entity.PartOverrides.Count != _ownedIds.Length - _drawMeshCount) { return false; } for (int i = 0; i < _drawMeshCount; i++) if (entity.MeshRefs[i].GfxObjId != _ownedIds[i]) return false; for (int i = 0; i < entity.PartOverrides.Count; i++) if (entity.PartOverrides[i].GfxObjId != _ownedIds[_drawMeshCount + i]) return false; return true; } public bool AreDrawMeshesReady(IWbMeshAdapter adapter) { for (int i = 0; i < _drawMeshCount; i++) { ulong id = _ownedIds[i]; if (id == 0u || !adapter.IsRenderDataReady(id)) return false; } return true; } } private sealed record PendingEntity( WorldEntity Entity, MeshSnapshot Snapshot, SyntheticEntityMeshReferenceOwner MeshReferences); private readonly IWbMeshAdapter _meshAdapter; private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly string _diagnosticName; private readonly List _retiringMeshReferences = []; private SyntheticEntityMeshReferenceOwner? _meshReferences; private MeshSnapshot? _meshSnapshot; private PendingEntity? _pending; public EntitySlot( IWbMeshAdapter meshAdapter, IEntityTextureLifetime textureLifetime, uint ownerLocalId, string diagnosticName) { _meshAdapter = meshAdapter; _textureOwnerLease = new FixedEntityTextureOwnerLease(textureLifetime, ownerLocalId); _diagnosticName = diagnosticName; } public WorldEntity? Entity { get; private set; } internal bool HasPending => _pending is not null; public void Set(WorldEntity? entity) { ReleaseRetiringMeshReferences(); if (entity is null) { Clear(); return; } // The common animated appraisal path reuses one clone object. Let // PrepareForDraw perform its allocation-free mesh-id comparison; // if no candidate is pending, reference equality is enough here. if (ReferenceEquals(Entity, entity) && _pending is null) return; if (ReferenceEquals(Entity, entity) && _meshSnapshot?.Matches(entity) == true) { ReleasePending(); return; } if (_pending is { } pending && ReferenceEquals(pending.Entity, entity) && pending.Snapshot.Matches(entity)) { return; } Stage(entity); } /// /// Refreshes an in-place MeshRefs mutation, re-arms missing uploads, /// and atomically promotes a fully drawable candidate. False tells the /// renderer to preserve its last completed render target this frame. /// public bool PrepareForDraw() { ReleaseRetiringMeshReferences(); if (_pending is { } pending) { if (!pending.Snapshot.Matches(pending.Entity)) Stage(pending.Entity); } else if (Entity is { } current && _meshSnapshot?.Matches(current) != true) { // Chargen animation and appraisal synchronization both mutate // a retained WorldEntity in place. Detect a changed GfxObj set // here even when the caller did not issue another Set call. Stage(current); } pending = _pending; if (pending is null) return true; if (!pending.Snapshot.AreDrawMeshesReady(_meshAdapter)) return false; PromotePending(pending); return true; } private void Stage(WorldEntity entity) { MeshSnapshot snapshot = MeshSnapshot.Capture(entity); var replacement = new SyntheticEntityMeshReferenceOwner( _meshAdapter, snapshot.OwnedIds); try { replacement.Acquire(); } catch (Exception acquisitionFailure) { try { replacement.Dispose(); } catch (Exception rollbackFailure) { _retiringMeshReferences.Add(replacement); throw new AggregateException( $"The {_diagnosticName} candidate mesh acquisition failed " + "and its rollback did not converge.", acquisitionFailure, rollbackFailure); } System.Runtime.ExceptionServices.ExceptionDispatchInfo .Capture(acquisitionFailure) .Throw(); throw new InvalidOperationException("Unreachable exception dispatch path."); } PendingEntity? previous = _pending; _pending = new PendingEntity(entity, snapshot, replacement); if (previous is not null) Retire(previous.MeshReferences); } private void PromotePending(PendingEntity pending) { // Release the fixed texture owner's prior composites only at the // same atomic edge that publishes the new mesh set. If release // fails, the candidate remains pending and can retry intact. _textureOwnerLease.Replace(hasReplacement: true); SyntheticEntityMeshReferenceOwner? previous = _meshReferences; _meshReferences = pending.MeshReferences; _meshSnapshot = pending.Snapshot; Entity = pending.Entity; _pending = null; if (previous is not null) Retire(previous); } private void Clear() { if (Entity is null && _pending is null) return; ReleasePending(); _textureOwnerLease.Replace(hasReplacement: false); SyntheticEntityMeshReferenceOwner? previous = _meshReferences; _meshReferences = null; _meshSnapshot = null; Entity = null; if (previous is not null) Retire(previous); } private void ReleasePending() { PendingEntity? pending = _pending; if (pending is null) return; _pending = null; Retire(pending.MeshReferences); } private void Retire(SyntheticEntityMeshReferenceOwner owner) { try { owner.Dispose(); } catch { _retiringMeshReferences.Add(owner); throw; } } public void Dispose() { Entity = null; _meshSnapshot = null; if (_meshReferences is { } current) { _meshReferences = null; _retiringMeshReferences.Add(current); } if (_pending is { } pending) { _pending = null; _retiringMeshReferences.Add(pending.MeshReferences); } List? failures = null; try { _textureOwnerLease.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } try { ReleaseRetiringMeshReferences(); } catch (Exception error) { (failures ??= []).Add(error); } if (failures is not null) { throw new AggregateException( $"The {_diagnosticName} resources did not fully release.", failures); } } private void ReleaseRetiringMeshReferences() { List? failures = null; for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--) { SyntheticEntityMeshReferenceOwner owner = _retiringMeshReferences[i]; try { owner.Dispose(); if (owner.IsDisposed) _retiringMeshReferences.RemoveAt(i); } catch (Exception error) { (failures ??= []).Add(error); } } if (failures is not null) { throw new AggregateException( $"One or more {_diagnosticName} mesh owners remain pending.", failures); } } } }