fix(ui): wait for private viewport mesh residency

This commit is contained in:
Erik 2026-08-25 19:16:53 +02:00
parent 82e4b4cb6d
commit f160f3fee1
3 changed files with 476 additions and 63 deletions

View file

@ -99,6 +99,7 @@ internal sealed class PrivateEntityViewportRenderer :
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
private int _fbW;
private int _fbH;
private bool _hasRenderedScene;
public PrivateEntityViewportRenderer(
IWorldPassScope scope,
@ -197,6 +198,23 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
public uint Render(int width, int height)
{
// #443: acquiring a synthetic mesh reference only schedules CPU
// preparation/GPU upload; it does not make the mesh drawable. Keep the
// 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 freshly-cleared black target.
bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady)
{
return _mainSlot.Entity is not null
&& _hasRenderedScene
&& _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
@ -256,6 +274,7 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null,
animatedEntityIds: _animatedIds);
_hasRenderedScene = true;
return UiTextureTableHandle.FromSlot(_slot);
}
@ -367,6 +386,7 @@ internal sealed class PrivateEntityViewportRenderer :
_target = null;
_fbW = 0;
_fbH = 0;
_hasRenderedScene = false;
}
public void Dispose()
@ -405,31 +425,88 @@ internal sealed class PrivateEntityViewportRenderer :
}
}
private static IEnumerable<ulong> CollectMeshIds(WorldEntity entity)
{
for (int i = 0; i < entity.MeshRefs.Count; i++)
yield return entity.MeshRefs[i].GfxObjId;
for (int i = 0; i < entity.PartOverrides.Count; i++)
yield return entity.PartOverrides[i].GfxObjId;
}
/// <summary>
/// One private entity's own mesh-reference/texture-owner lifetime,
/// independent of any other slot on the same renderer. Factored out at
/// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets
/// the EXACT SAME acquire/replace/retire behavior the main entity already
/// had — a single-owner class shared by both slots rather than a second,
/// hand-duplicated copy of <see cref="PrivateEntityViewportRenderer.SetEntity"/>'s
/// pre-Batch-D body.
/// 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>
private sealed class EntitySlot
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,
@ -444,75 +521,181 @@ internal sealed class PrivateEntityViewportRenderer :
public WorldEntity? Entity { get; private set; }
internal bool HasPending => _pending is not null;
public void Set(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
if (ReferenceEquals(Entity, entity))
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;
SyntheticEntityMeshReferenceOwner? replacement = null;
if (entity is not null)
if (ReferenceEquals(Entity, entity)
&& _meshSnapshot?.Matches(entity) == true)
{
replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
CollectMeshIds(entity));
replacement.Acquire();
ReleasePending();
return;
}
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
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
{
_textureOwnerLease.Replace(entity is not null);
replacement.Acquire();
}
catch (Exception textureFailure)
catch (Exception acquisitionFailure)
{
if (replacement is null)
throw;
try
{
replacement.Dispose();
}
catch (Exception rollbackFailure)
{
_retiringMeshReferences.Add(replacement);
throw new AggregateException(
$"The {_diagnosticName} texture-owner replacement failed "
+ "and the replacement mesh-owner rollback did not converge.",
textureFailure,
$"The {_diagnosticName} candidate mesh acquisition failed "
+ "and its rollback did not converge.",
acquisitionFailure,
rollbackFailure);
}
System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(textureFailure)
.Capture(acquisitionFailure)
.Throw();
throw new InvalidOperationException("Unreachable exception dispatch path.");
}
_meshReferences = replacement;
Entity = entity;
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
{
try
{
previous.Dispose();
}
catch
{
_retiringMeshReferences.Add(previous);
throw;
}
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