acdream/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs
Erik cd1cdee0e5 fix(render) #443: private viewports take the ring transform path
The paperdoll was visible only in portal space. Root cause: the classic
WbDrawDispatcher.Draw path appended its transforms into the SHARED world
transform frame (WorldTransformFrameArena.Append) with a non-zero base
instance, but the default mesh shaders index every parallel per-instance
array - clip slots, light sets, indoor, OPACITY, selection lighting,
detail category - zero-based; only the packed world submission's shader
convention subtracts the shared-arena prefix. With a world frame active
the doll drew all instances at per-instance opacity 0 into a cleared
target: counted draws, blank pixels, deterministic. Portal space worked
because no world transform frame is active there, so the same code took
the ring path with base 0. The private viewports are the only production
consumers of the classic path, hiding the defect everywhere else.

Fix: WbDrawDispatcher.NextClassicDrawIsPrivatePass - the private
viewport renderer marks its draw and WriteWorldTransformSection routes
private passes onto the plain ring path unconditionally (self-contained
render state: the private pass owns its own camera, lighting, and
target, and must not depend on the world frame's pose address space).

Also landed, each independently justified:
- Per-GPU-flight-slot private targets (PrivateViewportFlightTargets),
  restoring the pre-f6fe0f2a design: that revert's claim that frame
  submission order protects the single target's write->sample transition
  is not guaranteed across Vulkan command buffers. Per-slot completed
  scenes fix the cleared-sibling-after-reveal wart the old attempt had.
- Paperdoll resource preparation moved to the frame resource phase
  (IPrivateEntityViewportResourcePreparation) before world draws consume
  the bounded composite-upload budget.
- The presenter redresses on every dirty edge (an appearance-equal clone
  can pin retired readiness across generations; the renderer's two-phase
  promote keeps the last completed image visible during replacement),
  publishes only non-zero handles, and clears the viewport exactly once
  at the explicit character-session boundary.

Verified live on the clean build: doll visible in the NORMAL world,
visible through portal space, and still visible after arrival - the
exact reported repro cycle. 26 paperdoll/private-viewport/preparation
tests plus 60 renderer-suite tests pass; owner visual gate pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 16:51:32 +02:00

896 lines
34 KiB
C#

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;
/// <summary>
/// Camera contract for retail <c>CreatureMode</c>-style UI viewports. The
/// renderer supplies the authored viewport aspect before each draw and uses
/// <see cref="Eye"/> when it publishes the private scene lighting UBO.
/// </summary>
internal interface IPrivateEntityViewportCamera : ICamera
{
Vector3 Eye { get; }
}
/// <summary>
/// Shared render-to-texture implementation for the private 3-D creature
/// viewports used by paperdoll and examination UI. Each instance owns one
/// <see cref="IGpuRenderTarget"/> per encountered GPU flight slot, one
/// synthetic render identity, and one balanced texture-owner lease.
///
/// <para><b>Campaign V slice V6k (V4g's first half).</b> 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
/// <c>GlGpuDevice.RegisterExternalColorTexture</c> — 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.</para>
///
/// <para>Declaring the target is what discharges §5.4. That section's divergence
/// — GL's <c>BeginPass</c> 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 <c>Target: null</c> means.</para>
///
/// <para>The GL arm this renderer used to open an RHI pass and delegate a
/// raw-GL <c>WbDrawDispatcher</c> into (through V10, §5.5.6) was deleted at
/// Campaign V slice V11: <c>WbDrawDispatcher</c> now records into the pass
/// this renderer publishes on both call sites the same way.</para>
///
/// <para>
/// <b>Campaign CC gate round 1, Batch D (GF-7/GF-14).</b> Retail's
/// <c>gmCG3DView::Update @0x004EE9D0</c> draws a SECOND private entity — a
/// heritage-authored environment Setup (<c>m_pbgObject</c>) — behind the main
/// one, in the SAME <c>creature_mode_objects</c> list. This renderer now
/// supports that as an OPTIONAL second entity slot, reserved at construction
/// via <paramref name="backdropRenderId"/>-shaped ctor param (see below) —
/// paperdoll and creature-appraisal never pass one, so <see cref="SetBackdrop"/>
/// throws for them rather than silently doing nothing (the slot does not
/// exist). See <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/> for
/// the full decomp citation of the backdrop's placement (unposed, at the
/// scene origin, added to the draw list BEFORE the main entity).
/// </para>
/// </summary>
internal sealed class PrivateEntityViewportRenderer :
IUiViewportRenderer,
IDisposable
{
private const uint PrivateLandblockId = 0u;
private readonly ICurrentGpuFrameSource _frames;
/// <summary>
/// The world pass scope this renderer's own pass publishes into for the
/// span of the draw. <c>WbDrawDispatcher</c> 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.
///
/// <para>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.</para>
/// </summary>
private readonly IWorldPassScope _scope;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly IWbMeshAdapter _meshAdapter;
private readonly IPrivateEntityViewportCamera _camera;
private readonly HashSet<uint> _animatedIds;
private readonly string _diagnosticName;
private readonly EntitySlot _mainSlot;
/// <summary>Null for every renderer that never reserved a
/// <c>backdropRenderId</c> (paperdoll, creature-appraisal) — the backdrop
/// feature does not exist for them, not just "unused".</summary>
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];
}
/// <summary>
/// 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 <see cref="IUiViewportRenderer.TextureIsBottomUp"/>.
/// </summary>
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();
}
}
/// <summary>
/// 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.
/// </summary>
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<WorldEntity> entities = BuildDrawEntities(
_backdropSlot?.Entity,
entity);
return _dispatcher.PreparePrivateEntityResources(entities);
}
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the main
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
/// valid on a renderer constructed with a <c>backdropRenderId</c>
/// (<see cref="ChargenPreviewRenderer"/>'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.
/// </summary>
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);
}
/// <summary>
/// Renders the entity and returns the <see cref="UiTextureTableHandle"/> 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.
/// </summary>
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<WorldEntity> 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<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(
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);
}
/// <summary>
/// Pure helper assembling this frame's draw-entity list in retail's own
/// insertion order — <c>gmCG3DView::Update</c> adds the backdrop object to
/// <c>creature_mode_objects</c> BEFORE the main (player) object is
/// re-added (the player's own re-<c>AddObject</c> happens much later, at
/// ~0x004ef199, after the full clothing ObjDesc composes — see
/// <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/>'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
/// <c>PrivateEntityViewportRendererDrawOrderTests</c> without needing a
/// live GPU device or a constructed <see cref="WbDrawDispatcher"/>.
/// </summary>
internal static IReadOnlyList<WorldEntity> BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) =>
backdrop is not null && backdrop.MeshRefs.Count > 0
? [backdrop, main]
: [main];
/// <summary>
/// Both retail paperdoll and creature examination call
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
/// Byte-decoded confirmation (Batch D re-derivation): the SAME three
/// float32 constants (<c>0x3e99999a</c>/<c>0x3ff33333</c>/<c>0x3F266666</c>
/// = 0.3/1.9/0.65) appear verbatim at <c>gmCG3DView::Update</c>'s own
/// <c>SetLight</c> call site (pseudo-C ~0x004eecd3-0x004eece3) — the
/// chargen preview uses the EXACT same light this method already ported,
/// not a different value.
/// </summary>
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<Exception>? 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);
}
}
/// <summary>
/// Bounded render-target ownership keyed by <see cref="IGpuFrame.SlotIndex"/>.
/// 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.
/// </summary>
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<TargetSlot?> _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<Exception>? 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();
}
}
/// <summary>
/// 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 <see cref="WorldEntity.MeshRefs"/>
/// are resident. Kept internal so #443's lifetime/readiness behavior can
/// be pinned without constructing a live GPU device.
/// </summary>
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<ulong> 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);
}
/// <summary>
/// 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.
/// </summary>
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<SyntheticEntityMeshReferenceOwner> _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);
}
/// <summary>
/// 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.
/// </summary>
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<Exception>? 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<Exception>? 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);
}
}
}
}