fix(rendering): bound portal resource lifetime

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

View file

@ -23,5 +23,14 @@ public sealed class AcSurfaceMetadataTable
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
public void Remove(ulong gfxObjId)
{
foreach ((ulong Id, int SurfaceIndex) key in _table.Keys)
{
if (key.Id == gfxObjId)
_table.TryRemove(key, out _);
}
}
public void Clear() => _table.Clear();
}

View file

@ -26,6 +26,10 @@ internal sealed class ContiguousRangeAllocator
public int HighWaterMark { get; private set; }
public int Free => Capacity - Used;
public int LargestFreeRange => _free.Count == 0 ? 0 : _free.Max(range => range.Length);
public int TrailingFreeLength =>
_free.Count != 0 && _free[^1].End == Capacity
? _free[^1].Length
: 0;
public bool TryAllocate(int length, out MeshBufferRange allocation)
{
@ -73,6 +77,30 @@ internal sealed class ContiguousRangeAllocator
InsertAndCoalesce(new MeshBufferRange(oldCapacity, newCapacity - oldCapacity));
}
/// <summary>
/// Removes an unused tail after the matching physical GPU buffer has been
/// replaced. Live offsets are unchanged, so no render-data rewrite is
/// required.
/// </summary>
public void Shrink(int newCapacity)
{
if (newCapacity <= 0 || newCapacity >= Capacity)
throw new ArgumentOutOfRangeException(nameof(newCapacity));
if (newCapacity < HighWaterMark)
throw new InvalidOperationException("Cannot trim a GPU arena through a live allocation.");
MeshBufferRange tail = _free.Count == 0 ? default : _free[^1];
if (tail.End != Capacity || tail.Offset > newCapacity)
throw new InvalidOperationException("The requested GPU arena tail is not wholly free.");
if (tail.Offset == newCapacity)
_free.RemoveAt(_free.Count - 1);
else
_free[^1] = new MeshBufferRange(tail.Offset, newCapacity - tail.Offset);
Capacity = newCapacity;
RecalculateHighWaterMark();
}
public void Release(MeshBufferRange allocation)
{
if (allocation.Length <= 0
@ -84,6 +112,7 @@ internal sealed class ContiguousRangeAllocator
InsertAndCoalesce(allocation);
Used = checked(Used - allocation.Length);
RecalculateHighWaterMark();
}
private void InsertAndCoalesce(MeshBufferRange released)
@ -114,4 +143,11 @@ internal sealed class ContiguousRangeAllocator
_free.Insert(index, new MeshBufferRange(start, end - start));
}
private void RecalculateHighWaterMark()
{
HighWaterMark = _free.Count != 0 && _free[^1].End == Capacity
? _free[^1].Offset
: Capacity;
}
}

View file

@ -5,13 +5,21 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// The exact owner is still inside a reference transition, so its requested
/// logical removal has been queued and must be retried by the live-entity
/// teardown owner after the transition unwinds.
/// </summary>
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
: InvalidOperationException(
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
/// <summary>
/// Routes server-spawned (<c>CreateObject</c>) entities through the
/// per-instance rendering path. Server entities always carry per-instance
/// customizations (palette overrides, texture changes, part swaps) that
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// path which already hash-keys overrides for caching.
/// customizations (palette overrides, texture changes, part swaps). Shared
/// surfaces use the WB atlas while per-instance texture composites are owned
/// by the live entity and released with it.
///
/// <para>
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
@ -21,17 +29,6 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
/// non-null, the adapter calls
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
/// once per surface id that is known at spawn time (those on
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
/// dispatcher (Task 22) on first use — that matches the existing
/// <c>StaticMeshRenderer</c> behavior.
/// </para>
///
/// <para>
/// <b>Sequencer factory</b>: the adapter is constructed with a
/// <c>Func&lt;WorldEntity, AnimationSequencer&gt;</c> factory so tests can
/// inject a stub without needing a live DatCollection or MotionTable.
@ -47,24 +44,67 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly IEntityTextureLifetime _textureLifetime;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove.
// One logical owner per server GUID. Animated state survives projection
// suspension, while the resident bit controls the shorter GPU-presentation
// lifetime. The exact WorldEntity reference makes delayed visibility edges
// from a displaced GUID generation harmless.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
private readonly Dictionary<uint, Owner> _ownersByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
// so OnRemove can decrement each. Per-instance entities don't go through
// LandblockSpawnAdapter, so without this their meshes would never load
// (WB doesn't know they exist).
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
private sealed class Owner(
WorldEntity entity,
AnimatedEntityState state,
HashSet<ulong> meshIds)
{
public WorldEntity Entity { get; } = entity;
public AnimatedEntityState State { get; } = state;
public HashSet<ulong> MeshIds { get; set; } = meshIds;
public HashSet<ulong> MeshReferencesHeld { get; } = new();
public bool IsPresentationResident { get; set; }
public bool TextureReleaseRequired { get; set; }
public PresentationTransition Transition { get; set; }
public bool RemovalPending { get; set; }
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
public bool HasPresentationResources
{
get
{
if (TextureReleaseRequired)
return true;
return MeshReferencesHeld.Count != 0;
}
}
public bool IsFullyResident
{
get
{
if (!IsPresentationResident || !TextureReleaseRequired)
return false;
return MeshReferencesHeld.SetEquals(MeshIds);
}
}
public bool IsFullySuspended =>
!IsPresentationResident && !HasPresentationResources;
}
private enum PresentationTransition
{
None,
Resuming,
Suspending,
ChangingAppearance,
}
/// <param name="textureLifetime">
/// Per-entity texture lifetime owner. Production uses
/// <see cref="TextureCache"/>; tests use a recording implementation.
/// </param>
/// <param name="sequencerFactory">
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
@ -74,20 +114,19 @@ public sealed class EntitySpawnAdapter
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// Optional WB mesh adapter. When non-null, presentation residency
/// registers each unique <c>MeshRef.GfxObjId</c> so WB background-loads
/// the mesh data. Projection suspension or logical removal balances those
/// references. When null, the adapter only tracks per-instance state.
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
IEntityTextureLifetime textureLifetime,
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(textureLifetime);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_textureLifetime = textureLifetime;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
@ -105,29 +144,6 @@ public sealed class EntitySpawnAdapter
// are handled by LandblockSpawnAdapter, not here.
if (entity.ServerGuid == 0) return null;
// Pre-warm the per-instance texture cache for surfaces whose ids are
// already known at spawn time (those appearing as keys in
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
// covered by SurfaceOverrides are decoded lazily by the draw
// dispatcher on first use — consistent with StaticMeshRenderer.
if (entity.PaletteOverride is { } paletteOverride)
{
foreach (var meshRef in entity.MeshRefs)
{
if (meshRef.SurfaceOverrides is null) continue;
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
// meaning "no texture swap, just the palette override applies").
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
{
_textureCache.GetOrUploadWithPaletteOverride(
surfaceId,
origTexOverride == 0 ? null : origTexOverride,
paletteOverride);
}
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
@ -147,41 +163,228 @@ public sealed class EntitySpawnAdapter
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state;
HashSet<ulong> meshIds = _meshAdapter is null
? []
: CollectMeshIds(entity.MeshRefs, entity.PartOverrides);
// Register each unique GfxObj id with WB so the meshes background-load.
// Snapshot each unique GfxObj id for the shorter presentation lifetime.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null)
// Construct the replacement completely before displacing a live owner.
// Sequencer/appearance construction is allowed to fail; in that case
// the prior GUID incarnation and its presentation references remain
// valid. Retirement is also completed before replacement publication:
// a failed texture or mesh release therefore leaves the prior owner in
// the dictionary, with its per-resource progress available to retry.
var replacementOwner = new Owner(entity, state, meshIds);
if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner))
{
var unique = new HashSet<ulong>();
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
foreach (var po in entity.PartOverrides)
unique.Add((ulong)po.GfxObjId);
if (displacedOwner.RemovalPending)
{
throw new EntityPresentationRemovalDeferredException(entity.ServerGuid);
}
_meshIdsByGuid[entity.ServerGuid] = unique;
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
if (displacedOwner.Transition != PresentationTransition.None)
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation transition was already in progress.");
}
if (displacedOwner.HasPresentationResources)
{
if (!SuspendPresentation(displacedOwner))
{
throw new InvalidOperationException(
$"Live entity 0x{entity.ServerGuid:X8} replacement was requested while its presentation teardown was already in progress.");
}
}
_ownersByGuid[entity.ServerGuid] = replacementOwner;
}
else
{
_ownersByGuid.Add(entity.ServerGuid, replacementOwner);
}
return state;
}
/// <summary>
/// Changes only the shorter presentation/GPU lifetime of an already-created
/// live entity. A visible projection acquires WB mesh references; suspension
/// releases those references and every owner-scoped composite texture. The
/// animated state remains registered and no create-time scripts are replayed.
/// Duplicate edges and edges for an older incarnation of a reused server GUID
/// are ignored. A failed release keeps the published resident state and the
/// exact unfinished resources so the same edge can be retried safely.
/// </summary>
/// <returns><c>true</c> when this call applied a residency edge.</returns>
public bool SetPresentationResident(WorldEntity entity, bool resident)
{
ArgumentNullException.ThrowIfNull(entity);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| (resident ? owner.IsFullyResident : owner.IsFullySuspended))
{
return false;
}
return resident
? ResumePresentation(owner)
: SuspendPresentation(owner);
}
/// <summary>
/// Reconciles the mesh-reference set for an in-place retail
/// <c>SmartBox::UpdateVisualDesc</c> mutation. Added meshes are acquired
/// before <paramref name="publishAppearance"/> makes the new appearance
/// visible. Only then is the exact new mesh set published and superseded
/// references released. A failed release remains represented by
/// <see cref="Owner.MeshReferencesHeld"/> and is retried by the next
/// residency edge or logical teardown.
/// <paramref name="afterPublication"/> runs after that commit point but
/// before retirement, so dependent pose/cache publication cannot leave the
/// new entity appearance paired with rolled-back mesh ownership.
/// </summary>
/// <remarks>
/// The exact <see cref="WorldEntity"/> reference is the incarnation token.
/// A delayed appearance callback from a GUID-reused owner is ignored. The
/// method is render-thread only; a re-entrant request is rejected before
/// its publication callback can mutate the active owner.
/// </remarks>
/// <returns>
/// <c>true</c> when the matching owner's appearance was published;
/// otherwise <c>false</c> for a stale, removing, or re-entrant owner.
/// </returns>
public bool OnAppearanceChanged(
WorldEntity entity,
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides,
Action publishAppearance,
Action? afterPublication = null)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(meshRefs);
ArgumentNullException.ThrowIfNull(partOverrides);
ArgumentNullException.ThrowIfNull(publishAppearance);
if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner)
|| !ReferenceEquals(owner.Entity, entity)
|| owner.RemovalPending
|| owner.Transition != PresentationTransition.None)
{
return false;
}
HashSet<ulong> nextMeshIds = _meshAdapter is null
? []
: CollectMeshIds(meshRefs, partOverrides);
owner.Transition = PresentationTransition.ChangingAppearance;
var acquiredThisTransition = new List<ulong>();
bool meshSetPublished = false;
try
{
if (owner.IsPresentationResident && _meshAdapter is not null)
AcquireMissingMeshReferences(owner, nextMeshIds, acquiredThisTransition);
publishAppearance();
// Publication is the commit point: every desired resident mesh is
// owned before this assignment. Superseded references may remain
// temporarily held if their release reports a retryable failure,
// but they are no longer part of the published appearance set.
owner.MeshIds = nextMeshIds;
meshSetPublished = true;
afterPublication?.Invoke();
List<Exception>? releaseFailures = owner.IsPresentationResident
? ReleaseMeshReferencesOutside(owner, nextMeshIds)
: ReleaseAllMeshReferences(owner);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance mesh retirement failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireOrPublicationFailure) when (!meshSetPublished)
{
// Acquisition failed before publication. Preserve the prior exact
// mesh set and undo only references acquired by this transition.
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireOrPublicationFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} appearance publication and mesh rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
/// <summary>
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// removed) are silently ignored. Cleanup failure leaves the owner registered
/// and propagates the exception; a later call resumes its unfinished releases.
/// </summary>
public void OnRemove(uint serverGuid)
{
_stateByGuid.Remove(serverGuid);
=> _ = TryRemove(serverGuid, expectedEntity: null);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity)
{
if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
|| (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity)))
{
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
_meshIdsByGuid.Remove(serverGuid);
return false;
}
owner.RemovalPending = true;
if (owner.Transition != PresentationTransition.None)
throw new EntityPresentationRemovalDeferredException(serverGuid);
// A resident owner still holds both mesh and potentially-lazy texture
// resources. A suspended owner released them at the visibility edge,
// so logical removal must not decrement or release a second time. Do
// not remove the dictionary entry until cleanup succeeds: otherwise a
// release exception would orphan its remaining references and make a
// later OnRemove retry impossible.
if (owner.HasPresentationResources && !SuspendPresentation(owner))
return false;
if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current)
&& ReferenceEquals(current, owner))
{
_ownersByGuid.Remove(serverGuid);
return true;
}
return false;
}
/// <summary>
/// Releases a logical owner only when <paramref name="entity"/> is the exact
/// incarnation currently registered for its server GUID. This is the live
/// runtime teardown entry point: a delayed callback from an older generation
/// cannot remove a replacement that reused the same GUID.
/// </summary>
/// <returns><c>true</c> when the matching owner was removed.</returns>
public bool OnRemove(WorldEntity entity)
{
ArgumentNullException.ThrowIfNull(entity);
return TryRemove(entity.ServerGuid, entity);
}
/// <summary>
@ -190,5 +393,209 @@ public sealed class EntitySpawnAdapter
/// been removed.
/// </summary>
public AnimatedEntityState? GetState(uint serverGuid)
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
=> _ownersByGuid.TryGetValue(serverGuid, out Owner? owner)
? owner.State
: null;
private bool ResumePresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
owner.Transition = PresentationTransition.Resuming;
var acquiredThisTransition = new List<ulong>();
bool desiredMeshSetAcquired = false;
try
{
if (_meshAdapter is not null)
AcquireMissingMeshReferences(owner, owner.MeshIds, acquiredThisTransition);
desiredMeshSetAcquired = true;
owner.TextureReleaseRequired = true;
owner.IsPresentationResident = true;
List<Exception>? releaseFailures = ReleaseMeshReferencesOutside(
owner,
owner.MeshIds);
if (releaseFailures is not null)
{
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation mesh reconciliation failed.",
releaseFailures);
}
return true;
}
catch (Exception acquireFailure) when (!desiredMeshSetAcquired)
{
List<Exception>? rollbackFailures = RollBackAcquiredMeshReferences(
owner,
acquiredThisTransition);
if (rollbackFailures is null)
throw;
rollbackFailures.Insert(0, acquireFailure);
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation resume and rollback failed.",
rollbackFailures);
}
finally
{
owner.Transition = PresentationTransition.None;
}
}
private bool SuspendPresentation(Owner owner)
{
if (owner.Transition != PresentationTransition.None)
return false;
// IsPresentationResident deliberately remains true until every release
// succeeds. Transition prevents a re-entrant duplicate edge from
// releasing the same resource twice, while the completion markers keep
// partial progress retryable after an exception.
owner.Transition = PresentationTransition.Suspending;
List<Exception>? failures = null;
if (owner.TextureReleaseRequired)
{
try
{
_textureLifetime.ReleaseOwner(owner.Entity.Id);
owner.TextureReleaseRequired = false;
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
}
List<Exception>? meshFailures = ReleaseAllMeshReferences(owner);
if (meshFailures is not null)
(failures ??= new List<Exception>()).AddRange(meshFailures);
if (failures is not null)
{
owner.Transition = PresentationTransition.None;
throw new AggregateException(
$"Live entity 0x{owner.Entity.ServerGuid:X8} presentation suspension failed.",
failures);
}
owner.IsPresentationResident = false;
owner.Transition = PresentationTransition.None;
return true;
}
private static HashSet<ulong> CollectMeshIds(
IReadOnlyList<MeshRef> meshRefs,
IReadOnlyList<PartOverride> partOverrides)
{
var unique = new HashSet<ulong>();
for (int i = 0; i < meshRefs.Count; i++)
unique.Add(meshRefs[i].GfxObjId);
for (int i = 0; i < partOverrides.Count; i++)
unique.Add(partOverrides[i].GfxObjId);
return unique;
}
private void AcquireMissingMeshReferences(
Owner owner,
HashSet<ulong> desiredMeshIds,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return;
foreach (ulong meshId in desiredMeshIds)
{
if (owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.IncrementRefCount(meshId);
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
catch (MeshReferenceMutationException error)
{
if (error.MutationCommitted)
{
owner.MeshReferencesHeld.Add(meshId);
acquiredThisTransition.Add(meshId);
}
throw;
}
}
}
private List<Exception>? RollBackAcquiredMeshReferences(
Owner owner,
List<ulong> acquiredThisTransition)
{
if (_meshAdapter is null)
return null;
List<Exception>? failures = null;
for (int i = acquiredThisTransition.Count - 1; i >= 0; i--)
{
ulong meshId = acquiredThisTransition[i];
if (!owner.MeshReferencesHeld.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseMeshReferencesOutside(
Owner owner,
HashSet<ulong> desiredMeshIds)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
List<Exception>? failures = null;
ulong[] heldSnapshot = [.. owner.MeshReferencesHeld];
foreach (ulong meshId in heldSnapshot)
{
if (desiredMeshIds.Contains(meshId))
continue;
try
{
_meshAdapter.DecrementRefCount(meshId);
owner.MeshReferencesHeld.Remove(meshId);
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
owner.MeshReferencesHeld.Remove(meshId);
(failures ??= new List<Exception>()).Add(error);
}
}
return failures;
}
private List<Exception>? ReleaseAllMeshReferences(Owner owner)
{
if (_meshAdapter is null || owner.MeshReferencesHeld.Count == 0)
return null;
return ReleaseMeshReferencesOutside(owner, []);
}
}

View file

@ -1,9 +1,11 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Starts CPU mesh extraction for a completed EnvCell build without publishing
/// that build to the live renderer. ObjectMeshManager owns its thread-safe work
/// queue; the render-thread commit remains a small atomic state replacement.
/// Starts CPU mesh extraction after the completed EnvCell build has been
/// published and its geometry ids have acquired render ownership. This order
/// lets ObjectMeshManager cancel queued work as soon as a landblock leaves the
/// current streaming generation; stale portal destinations never keep decoder
/// jobs or surface lists alive.
/// </summary>
public static class EnvCellMeshPreparationScheduler
{
@ -11,8 +13,11 @@ public static class EnvCellMeshPreparationScheduler
EnvCellLandblockBuild build,
ObjectMeshManager meshManager)
{
var scheduled = new HashSet<ulong>();
foreach (var shell in build.Shells)
{
if (!scheduled.Add(shell.GeometryId))
continue;
_ = meshManager.PrepareEnvCellGeomMeshDataAsync(
shell.GeometryId,
shell.EnvironmentId,

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,32 @@ namespace AcDream.App.Rendering.Wb {
Device = device;
}
/// <summary>
/// Always-on error boundary for resource transactions. Most render-path
/// checks remain Debug-only because <c>glGetError</c> is a synchronous
/// driver call; allocation/upload code must not publish CPU state after
/// OpenGL reported OOM, context loss, or a rejected transfer in Release.
/// Call exactly once before committing each transaction.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ThrowOnResourceError(GL gl, string context) {
GLEnum error = gl.GetError();
if (error == GLEnum.NoError)
return;
var errors = new System.Text.StringBuilder();
do {
if (errors.Length != 0)
errors.Append(", ");
errors.Append(error).Append(" (").Append(GetErrorDetails(error)).Append(')');
error = gl.GetError();
} while (error != GLEnum.NoError);
string message = $"OpenGL resource transaction failed: {errors}. Context: {context}";
Logger?.LogError(message);
throw new InvalidOperationException(message);
}
#if DEBUG
private static bool _loggedVersion = false;
@ -140,46 +166,6 @@ namespace AcDream.App.Rendering.Wb {
return info.ToString();
}
/// <summary>
/// Validates texture completeness for mipmapping
/// </summary>
public static bool ValidateTextureMipmapStatus(GL gl, GLEnum target, out string errorMessage) {
try {
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureWidth, out int width);
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureHeight, out int height);
gl.GetTexLevelParameter(target, 0, GetTextureParameter.TextureInternalFormat, out int format);
if (width == 0 || height == 0) {
errorMessage = "Texture has zero dimensions";
return false;
}
// Check if format is valid for mipmap generation
var internalFormat = (InternalFormat)format;
if (IsCompressedFormat(internalFormat)) {
errorMessage = $"Compressed format {internalFormat} does not support automatic mipmap generation";
return false;
}
errorMessage = String.Empty;
return true;
}
catch (Exception ex) {
errorMessage = $"Exception during validation: {ex.Message}";
return false;
}
}
private static bool IsCompressedFormat(InternalFormat format) {
return format == InternalFormat.CompressedRgbaS3TCDxt1Ext ||
format == InternalFormat.CompressedRgbaS3TCDxt3Ext ||
format == InternalFormat.CompressedRgbaS3TCDxt5Ext ||
format == InternalFormat.CompressedRgbS3TCDxt1Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt1Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt3Ext ||
format == InternalFormat.CompressedSrgbAlphaS3TCDxt5Ext;
}
/// <summary>
/// Logs current OpenGL state for debugging
/// </summary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// A contiguous GPU-buffer suballocator whose released ranges become
/// reusable only after the frame-retirement queue says that older draws have
/// finished. This prevents <c>BufferSubData</c> from overwriting a range that
/// an in-flight draw still reads.
/// </summary>
internal sealed class GpuRetiredRangeAllocator
{
private readonly ContiguousRangeAllocator _allocator;
private readonly GpuRetirementLedger _retirementLedger;
private readonly Dictionary<MeshBufferRange, RetryableGpuResourceRelease> _pendingReleases = [];
private int _pendingReleaseCount;
private int _pendingReleaseLength;
public GpuRetiredRangeAllocator(int capacity, IGpuResourceRetirementQueue retirement)
{
_allocator = new ContiguousRangeAllocator(capacity);
_retirementLedger = new GpuRetirementLedger(
retirement ?? throw new ArgumentNullException(nameof(retirement)));
}
public int Capacity => _allocator.Capacity;
public int Used => _allocator.Used;
public int HighWaterMark => _allocator.HighWaterMark;
public int LargestFreeRange => _allocator.LargestFreeRange;
public int TrailingFreeLength => _allocator.TrailingFreeLength;
public int PendingReleaseCount => _pendingReleaseCount;
public int PendingReleaseLength => _pendingReleaseLength;
public bool TryAllocate(int length, out MeshBufferRange allocation) =>
_allocator.TryAllocate(length, out allocation);
public void Grow(int newCapacity) => _allocator.Grow(newCapacity);
public void Shrink(int newCapacity) => _allocator.Shrink(newCapacity);
public void ReleaseAfterGpuUse(MeshBufferRange allocation)
{
if (_pendingReleases.TryGetValue(allocation, out RetryableGpuResourceRelease? pending))
{
// The caller retries this method when queue publication failed.
// Re-publish the retained transaction instead of accounting for a
// second logical release of the same physical range.
try
{
_retirementLedger.RetryPendingPublication(pending);
}
catch when (pending.IsComplete)
{
// An immediate retirement queue may surface a wrapper failure
// after the retained transaction itself fully committed.
}
return;
}
int nextPendingCount = checked(_pendingReleaseCount + 1);
int nextPendingLength = checked(_pendingReleaseLength + allocation.Length);
var release = new RetryableGpuResourceRelease(
() => _allocator.Release(allocation),
() => _pendingReleaseCount = checked(_pendingReleaseCount - 1),
() => _pendingReleaseLength = checked(
_pendingReleaseLength - allocation.Length),
() =>
{
if (!_pendingReleases.Remove(allocation))
{
throw new InvalidOperationException(
"GPU buffer range retirement lost its ownership record.");
}
});
_pendingReleases.Add(allocation, release);
_pendingReleaseCount = nextPendingCount;
_pendingReleaseLength = nextPendingLength;
try
{
_retirementLedger.Retire(release);
}
catch when (release.IsComplete)
{
// Completion is stronger than publication acknowledgement. The
// physical range and its accounting already converged.
}
}
/// <summary>
/// Releases a range that failed before it could be submitted in a draw.
/// </summary>
public void ReleaseUnsubmitted(MeshBufferRange allocation)
{
if (_pendingReleases.ContainsKey(allocation))
{
throw new InvalidOperationException(
"A GPU-submitted range cannot be released as unsubmitted.");
}
_allocator.Release(allocation);
}
}

View file

@ -12,7 +12,6 @@ namespace AcDream.App.Rendering.Wb;
/// without depending on dispatcher internals.
/// </summary>
internal readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
int BaseVertex,
int IndexCount,

View file

@ -0,0 +1,12 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Logical-owner lifetime seam for per-entity texture composites. Retail
/// <c>CSurface::Destroy</c> (0x005361F0) releases its current <c>ImgTex</c>;
/// live-object teardown must do the same for modern bindless composites.
/// </summary>
public interface IEntityTextureLifetime
{
/// <summary>Release every composite acquired by one local entity id.</summary>
void ReleaseOwner(uint localEntityId);
}

View file

@ -1,22 +0,0 @@
using AcDream.Core.World;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Seam interface over the per-instance palette-override decode path in
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
/// can be tested without a live GL context.
/// </summary>
public interface ITextureCachePerInstance
{
/// <summary>
/// Decode (or return cached) the palette-overridden texture for
/// <paramref name="surfaceId"/>. Delegates to
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
/// production.
/// </summary>
uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride);
}

View file

@ -1,5 +1,25 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Reports the physical outcome when a mesh-reference callback cannot provide
/// the normal strong exception guarantee. <see cref="MutationCommitted"/> lets
/// a transactional owner reconcile its marker without guessing whether a
/// throwing backend already changed the reference count.
/// </summary>
public sealed class MeshReferenceMutationException : Exception
{
public MeshReferenceMutationException(
string message,
bool mutationCommitted,
Exception innerException)
: base(message, innerException)
{
MutationCommitted = mutationCommitted;
}
public bool MutationCommitted { get; }
}
/// <summary>
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
@ -7,7 +27,17 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public interface IWbMeshAdapter
{
/// <summary>
/// Acquires one logical reference. A normal exception guarantees that no
/// reference was acquired; <see cref="MeshReferenceMutationException"/>
/// explicitly reports the exceptional backend case where it was committed.
/// </summary>
void IncrementRefCount(ulong id);
/// <summary>
/// Releases one logical reference under the same committed-outcome contract
/// as <see cref="IncrementRefCount"/>.
/// </summary>
void DecrementRefCount(ulong id);
/// <summary>

View file

@ -9,20 +9,24 @@ namespace AcDream.App.Rendering.Wb;
/// entities (procedural / dat-hydrated, identified by
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
/// (per-instance tier) are skipped — those go through
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
/// <c>EntitySpawnAdapter</c> and the owner-scoped texture path
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
///
/// <para>
/// On load: walks the landblock's atlas-tier entities, collects unique
/// GfxObj ids from their <c>MeshRefs</c>, calls
/// <c>IncrementRefCount</c> per id, and pins each specialized EnvCell geometry
/// id without starting generic GfxObj decode. Snapshots both id-sets per
/// landblock so unload can match the load 1:1.
/// id without starting generic GfxObj decode. Each reference has an explicit
/// desired/held marker so a throwing backend cannot make the logical snapshot
/// disagree with the physical reference count.
/// </para>
///
/// <para>
/// On unload: looks up both snapshots, calls <c>DecrementRefCount</c> per id,
/// drops the snapshots. Unknown / never-loaded landblocks no-op.
/// On unload: releases only references whose held marker is still set. A
/// before-commit failure remains retryable; an after-commit
/// <see cref="MeshReferenceMutationException"/> advances the marker before the
/// exception is propagated. The registration is dropped only after every held
/// reference has been released. Unknown / never-loaded landblocks no-op.
/// </para>
///
/// <para>
@ -42,15 +46,21 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed class LandblockSpawnAdapter
{
private readonly IWbMeshAdapter _adapter;
private sealed class ReferenceRegistration
{
public bool Desired;
public bool Held;
}
// Maps landblock id → unique GfxObj ids registered for that landblock.
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
// EnvCell shells are prepared through PrepareEnvCellGeomMeshDataAsync rather
// than generic GfxObj loading, but still require explicit lifetime pins.
// Keep their synthetic ids separate so registration uses the no-decode pin.
private readonly Dictionary<uint, HashSet<ulong>> _additionalReadinessIdsByLandblock = new();
private sealed class LandblockRegistration
{
public bool WantsLoaded;
public Dictionary<ulong, ReferenceRegistration> Ordinary { get; } = new();
public Dictionary<ulong, ReferenceRegistration> Prepared { get; } = new();
}
private readonly IWbMeshAdapter _adapter;
private readonly Dictionary<uint, LandblockRegistration> _registrations = new();
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
{
@ -81,33 +91,40 @@ public sealed class LandblockSpawnAdapter
unique.Add((ulong)meshRef.GfxObjId);
}
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
HashSet<ulong>? preparedIds = additionalReadinessIds is null
? null
: new HashSet<ulong>(additionalReadinessIds);
if (!_registrations.TryGetValue(landblock.LandblockId, out var registration))
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
registration = new LandblockRegistration { WantsLoaded = true };
_registrations.Add(landblock.LandblockId, registration);
}
else
else if (!registration.WantsLoaded)
{
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
// This is a new load edge that arrived while a preceding unload
// still had unfinished releases. The new snapshot replaces the old
// desired set. Any still-held overlap remains acquired; obsolete
// residual references are released by Reconcile below.
MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
registration.WantsLoaded = true;
}
if (!_additionalReadinessIdsByLandblock.TryGetValue(
landblock.LandblockId,
out var additional))
{
additional = new HashSet<ulong>();
_additionalReadinessIdsByLandblock[landblock.LandblockId] = additional;
}
if (additionalReadinessIds is not null)
{
foreach (var id in additionalReadinessIds)
if (additional.Add(id))
_adapter.PinPreparedRenderData(id);
}
MarkDesired(registration.Ordinary, unique);
if (preparedIds is not null)
MarkDesired(registration.Prepared, preparedIds);
List<Exception>? failures = null;
ReleaseUndesired(registration.Ordinary, ref failures);
ReleaseUndesired(registration.Prepared, ref failures);
PruneReleasedUndesired(registration.Ordinary);
PruneReleasedUndesired(registration.Prepared);
AcquireDesired(registration.Ordinary, prepared: false, ref failures);
AcquireDesired(registration.Prepared, prepared: true, ref failures);
ThrowFailures(
failures,
$"Landblock 0x{landblock.LandblockId:X8} mesh-reference acquisition did not fully converge.");
}
/// <summary>
@ -117,17 +134,19 @@ public sealed class LandblockSpawnAdapter
/// </summary>
public bool IsLandblockRenderReady(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var registered)
|| !_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
{
if (!_registrations.TryGetValue(landblockId, out var registration)
|| !registration.WantsLoaded)
return false;
}
foreach (var id in registered)
if (!_adapter.IsRenderDataReady(id))
foreach (var pair in registration.Ordinary)
if (!pair.Value.Desired
|| !pair.Value.Held
|| !_adapter.IsRenderDataReady(pair.Key))
return false;
foreach (var id in additional)
if (!_adapter.IsRenderDataReady(id))
foreach (var pair in registration.Prepared)
if (!pair.Value.Desired
|| !pair.Value.Held
|| !_adapter.IsRenderDataReady(pair.Key))
return false;
return true;
}
@ -139,11 +158,127 @@ public sealed class LandblockSpawnAdapter
/// </summary>
public void OnLandblockUnloaded(uint landblockId)
{
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
foreach (var id in unique) _adapter.DecrementRefCount(id);
if (_additionalReadinessIdsByLandblock.TryGetValue(landblockId, out var additional))
foreach (var id in additional) _adapter.DecrementRefCount(id);
_idsByLandblock.Remove(landblockId);
_additionalReadinessIdsByLandblock.Remove(landblockId);
if (!_registrations.TryGetValue(landblockId, out var registration))
return;
registration.WantsLoaded = false;
MarkAllUndesired(registration.Ordinary);
MarkAllUndesired(registration.Prepared);
List<Exception>? failures = null;
ReleaseUndesired(registration.Ordinary, ref failures);
ReleaseUndesired(registration.Prepared, ref failures);
PruneReleasedUndesired(registration.Ordinary);
PruneReleasedUndesired(registration.Prepared);
// Even an after-commit backend exception may report failure after the
// final physical release. Drop the registration before propagating in
// that case so a caller retry cannot decrement the same reference.
if (registration.Ordinary.Count == 0 && registration.Prepared.Count == 0)
_registrations.Remove(landblockId);
ThrowFailures(
failures,
$"Landblock 0x{landblockId:X8} mesh-reference release did not fully converge.");
}
private static void MarkDesired(
Dictionary<ulong, ReferenceRegistration> registrations,
IEnumerable<ulong> ids)
{
foreach (ulong id in ids)
{
if (!registrations.TryGetValue(id, out var reference))
{
reference = new ReferenceRegistration();
registrations.Add(id, reference);
}
reference.Desired = true;
}
}
private static void MarkAllUndesired(
Dictionary<ulong, ReferenceRegistration> registrations)
{
foreach (var reference in registrations.Values)
reference.Desired = false;
}
private void AcquireDesired(
Dictionary<ulong, ReferenceRegistration> registrations,
bool prepared,
ref List<Exception>? failures)
{
foreach (var pair in registrations)
{
ReferenceRegistration reference = pair.Value;
if (!reference.Desired || reference.Held)
continue;
try
{
if (prepared)
_adapter.PinPreparedRenderData(pair.Key);
else
_adapter.IncrementRefCount(pair.Key);
reference.Held = true;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
reference.Held = true;
(failures ??= new List<Exception>()).Add(error);
}
}
}
private void ReleaseUndesired(
Dictionary<ulong, ReferenceRegistration> registrations,
ref List<Exception>? failures)
{
foreach (var pair in registrations)
{
ReferenceRegistration reference = pair.Value;
if (reference.Desired || !reference.Held)
continue;
try
{
_adapter.DecrementRefCount(pair.Key);
reference.Held = false;
}
catch (Exception error)
{
if (error is MeshReferenceMutationException { MutationCommitted: true })
reference.Held = false;
(failures ??= new List<Exception>()).Add(error);
}
}
}
private static void PruneReleasedUndesired(
Dictionary<ulong, ReferenceRegistration> registrations)
{
List<ulong>? released = null;
foreach (var pair in registrations)
{
if (!pair.Value.Desired && !pair.Value.Held)
(released ??= new List<ulong>()).Add(pair.Key);
}
if (released is null)
return;
foreach (ulong id in released)
registrations.Remove(id);
}
private static void ThrowFailures(List<Exception>? failures, string message)
{
if (failures is null)
return;
if (failures.Count == 1)
throw failures[0];
throw new AggregateException(message, failures);
}
}

View file

@ -19,9 +19,6 @@ namespace AcDream.App.Rendering.Wb {
public int Height { get; private set; }
public TextureFormat Format => TextureFormat.RGBA8;
public ulong BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
/// <inheritdoc/>
public ManagedGLTexture(OpenGLGraphicsDevice device, byte[]? source, int width, int height, TextureParameters? texParams = null) {
@ -54,12 +51,10 @@ namespace AcDream.App.Rendering.Wb {
if (p.EnableAnisotropicFiltering && _device.RenderSettings.EnableAnisotropicFiltering)
{
float maxAnisotropy = 0f;
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0)
if (_device.MaxSupportedAnisotropy > 0)
{
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
GL.TexParameter(GLEnum.Texture2D, GLEnum.TextureMaxAnisotropy,
_device.MaxSupportedAnisotropy);
}
}
@ -72,15 +67,6 @@ namespace AcDream.App.Rendering.Wb {
GpuMemoryTracker.TrackAllocation(CalculateSize(), GpuResourceType.Texture);
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle(_texture);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle(_texture, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
}
}
private long CalculateSize() {
@ -112,12 +98,6 @@ namespace AcDream.App.Rendering.Wb {
GL.GetInteger(GLEnum.TextureBinding2D, out int oldBinding);
GL.BindTexture(GLEnum.Texture2D, _texture);
bool wasResident = false;
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
wasResident = true;
}
fixed (byte* ptr = data) {
GL.TexSubImage2D(
GLEnum.Texture2D,
@ -135,10 +115,6 @@ namespace AcDream.App.Rendering.Wb {
// Generate mipmaps if needed
GL.GenerateMipmap(GLEnum.Texture2D);
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2D, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
@ -172,20 +148,6 @@ namespace AcDream.App.Rendering.Wb {
protected void ReleaseTexture() {
_device.QueueGLAction(GL => {
if (_device.BindlessExtension != null) {
if (BindlessHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
BindlessHandle = 0;
}
if (BindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
BindlessWrapHandle = 0;
}
if (BindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
BindlessClampHandle = 0;
}
}
if (_texture != 0) {
GL.DeleteTexture(_texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);

View file

@ -6,6 +6,7 @@ using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers;
using Microsoft.Extensions.Logging;
using Silk.NET.OpenGL;
using System.Runtime.InteropServices;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb {
public class ManagedGLTextureArray : ITextureArray {
@ -18,14 +19,15 @@ namespace AcDream.App.Rendering.Wb {
private readonly bool _isCompressed;
private int _mipmapDirtyCount = 0;
private readonly object _mipmapLock = new object();
private uint _pboId;
private int _pboSize;
private readonly List<TextureLayerUpdate> _pendingUpdates = new();
private int _disposeQueued;
private int _disposePublicationQueued;
private int _disposeRetirementAccepted;
private RetryableGpuResourceRelease? _disposeRelease;
private struct TextureLayerUpdate {
public int Layer;
public int Offset;
public int Size;
public required byte[] Data;
public PixelFormat? UploadPixelFormat;
public PixelType? UploadPixelType;
}
@ -36,13 +38,12 @@ namespace AcDream.App.Rendering.Wb {
public int Size { get; private set; }
public TextureFormat Format { get; private set; }
public nint NativePtr { get; private set; }
public ulong BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize();
/// <summary>
/// #105 diagnostic: staged layer updates (PBO writes + pending list) not yet
/// #105 diagnostic: staged layer updates (retained decoded payloads) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. Layers with
/// a pending update sample UNDEFINED content (TexStorage3D contents) until the
/// flush runs — a stuck non-zero count at standstill is the white-walls mechanism.
@ -69,67 +70,121 @@ namespace AcDream.App.Rendering.Wb {
_isCompressed = IsCompressedFormat(format);
GLHelpers.CheckErrors(GL);
NativePtr = (nint)GL.GenTexture();
if (NativePtr == 0) {
throw new InvalidOperationException("Failed to generate texture array.");
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
uint textureName = 0;
ulong wrapHandle = 0;
ulong clampHandle = 0;
bool textureTracked = false;
bool textureBytesTracked = false;
bool wrapResident = false;
bool clampResident = false;
long textureBytes = CalculateTotalSize();
GLHelpers.CheckErrors(GL);
try {
textureName = GL.GenTexture();
if (textureName == 0)
throw new InvalidOperationException("Failed to generate texture array.");
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
textureTracked = true;
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
GLHelpers.CheckErrors(GL);
GL.BindTexture(GLEnum.Texture2DArray, textureName);
int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
int maxDimension = Math.Max(width, height);
int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1;
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
(uint)size);
GLHelpers.CheckErrorsWithContext(GL,
$"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})");
GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height,
(uint)size);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
(int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
(int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)mipLevels - 1);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT);
if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering) {
float maxAnisotropy = 0f;
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0) {
GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
if (p.EnableAnisotropicFiltering
&& graphicsDevice.RenderSettings.EnableAnisotropicFiltering
&& graphicsDevice.MaxSupportedAnisotropy > 0) {
GL.TexParameter(
GLEnum.Texture2DArray,
GLEnum.TextureMaxAnisotropy,
graphicsDevice.MaxSupportedAnisotropy);
}
if (format == TextureFormat.A8) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
}
GLHelpers.ThrowOnResourceError(
GL,
$"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)");
GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture);
textureBytesTracked = true;
if (_device.HasBindless && _device.BindlessExtension != null) {
wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler);
clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(wrapHandle);
wrapResident = true;
_device.BindlessExtension.MakeTextureHandleResident(clampHandle);
clampResident = true;
GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident");
}
NativePtr = (nint)textureName;
BindlessWrapHandle = wrapHandle;
BindlessClampHandle = clampHandle;
}
// Set texture swizzle for single-channel formats
if (format == TextureFormat.A8) {
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red);
catch (Exception constructionFailure) {
// Constructor failure cannot use Dispose: the object was never
// published and queued teardown would make retries accumulate
// invalid resident handles. Attempt every independent cleanup.
List<Exception>? cleanupFailures = null;
void Attempt(Action cleanup) {
try { cleanup(); }
catch (Exception ex) { (cleanupFailures ??= []).Add(ex); }
}
if (_device.BindlessExtension != null) {
if (clampResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(clampHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle");
clampResident = false;
});
if (wrapResident)
Attempt(() => {
_device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle);
GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle");
wrapResident = false;
});
}
// Deleting a texture while either bindless sampler handle is
// still resident is undefined. A pre-commit residency failure
// therefore retains the texture instead of risking a driver
// reset during constructor rollback.
if (textureName != 0 && !clampResident && !wrapResident)
Attempt(() => {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, "rolling back texture array");
if (textureBytesTracked)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
if (textureTracked)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
});
if (cleanupFailures is not null) {
cleanupFailures.Insert(0, constructionFailure);
throw new AggregateException(
"Texture-array construction and rollback both failed.",
cleanupFailures);
}
throw;
}
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture);
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
finally {
GL.ActiveTexture(TextureUnit.Texture0);
GL.BindTexture(GLEnum.Texture2DArray, 0);
RenderStateCache.CurrentAtlas = 0;
}
_pboId = GL.GenBuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
}
public long CalculateTotalSize() {
@ -151,7 +206,7 @@ namespace AcDream.App.Rendering.Wb {
return totalSize;
}
private bool IsCompressedFormat(TextureFormat format) {
private static bool IsCompressedFormat(TextureFormat format) {
return format == TextureFormat.DXT1 ||
format == TextureFormat.DXT3 ||
format == TextureFormat.DXT5;
@ -220,127 +275,156 @@ namespace AcDream.App.Rendering.Wb {
$"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot}).");
}
int currentPboOffset = 0;
ValidateUploadPayload(
Format,
Width,
Height,
data.Length,
uploadPixelFormat,
uploadPixelType);
lock (_mipmapLock) {
if (_pendingUpdates.Count > 0) {
var lastUpdate = _pendingUpdates[^1];
currentPboOffset = lastUpdate.Offset + lastUpdate.Size;
}
// Align to 4 bytes for safety
currentPboOffset = (currentPboOffset + 3) & ~3;
if (currentPboOffset + data.Length > _pboSize) {
// Flush existing updates first because BufferData will orphan/clear the PBO
if (_pendingUpdates.Count > 0) {
ProcessDirtyUpdatesInternal();
}
currentPboOffset = 0;
int newSize = Math.Max(_pboSize * 2, data.Length);
newSize = Math.Max(newSize, GetExpectedDataSize() * 4); // Initial size 4 layers
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
GL.BufferData(GLEnum.PixelUnpackBuffer, (nuint)newSize, (void*)0, GLEnum.StreamDraw);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
}
_pboSize = newSize;
GpuMemoryTracker.TrackAllocation(_pboSize, GpuResourceType.Buffer);
}
else {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
}
fixed (byte* ptr = data) {
GL.BufferSubData(GLEnum.PixelUnpackBuffer, (nint)currentPboOffset, (nuint)data.Length, ptr);
}
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Add(new TextureLayerUpdate {
// Retain the immutable decoded payload until the once-per-frame
// atlas flush. The former per-atlas PBO permanently reserved
// several MiB for every array and duplicated each upload
// through BufferSubData before TexSubImage3D.
var update = new TextureLayerUpdate {
Layer = layer,
Offset = currentPboOffset,
Size = data.Length,
Data = data,
UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType
});
};
int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer);
if (existingIndex >= 0)
_pendingUpdates[existingIndex] = update;
else
_pendingUpdates.Add(update);
_needsMipmapRegeneration = true;
_mipmapDirtyCount++;
if (existingIndex < 0)
_mipmapDirtyCount++;
}
}
public void ProcessDirtyUpdates() {
public long ProcessDirtyUpdates() {
lock (_mipmapLock) {
ProcessDirtyUpdatesInternal();
return ProcessDirtyUpdatesInternal(generateMipmaps: true);
}
}
private unsafe void ProcessDirtyUpdatesInternal() {
if (_pendingUpdates.Count == 0 && !_needsMipmapRegeneration) return;
private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) {
if (_pendingUpdates.Count == 0
&& (!generateMipmaps || !_needsMipmapRegeneration)) return 0;
long generatedBytes = 0;
GLHelpers.CheckErrors(GL);
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
// This runs in WbMeshAdapter.Tick before any draw pass. Establish
// the upload phase's canonical texture state directly instead of
// synchronously querying driver state for every dirty array.
GL.ActiveTexture(TextureUnit.Texture0);
RenderStateCache.CurrentAtlas = 0;
GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding);
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
bool mipmapWorkCompleted = false;
try {
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
bool wasResident = false;
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
wasResident = true;
}
if (_pendingUpdates.Count > 0) {
// A non-zero pixel-unpack binding changes pointer arguments
// into byte offsets. Direct client-memory uploads therefore
// establish the canonical zero binding once for the batch.
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0);
GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0);
if (_pendingUpdates.Count > 0) {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
foreach (var update in _pendingUpdates) {
fixed (byte* data = update.Data) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
internalFormat,
(uint)update.Data.Length,
data);
}
else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
GL.TexSubImage3D(
GLEnum.Texture2DArray,
0,
0,
0,
update.Layer,
(uint)Width,
(uint)Height,
1,
pixelFormat,
pixelType,
data);
}
}
}
}
foreach (var update in _pendingUpdates) {
if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer,
(uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset);
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
}
else {
var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat();
var pixelType = update.UploadPixelType ?? Format.ToPixelType();
GL.TexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1,
pixelFormat, pixelType, (void*)update.Offset);
try {
// Width, height and format were validated when the
// immutable storage was allocated. Re-reading them
// here forced three CPU/GPU synchronization points
// for every dirty atlas without adding safety.
GL.GenerateMipmap(GLEnum.Texture2DArray);
generatedBytes = TotalSizeInBytes;
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot);
throw;
}
}
}
// Release builds must observe transfer/OOM/context errors
// before the pending offsets and dirty mip state are cleared.
// One check covers every layer in this array plus its single
// mip generation, keeping the synchronization cost bounded by
// dirty arrays rather than uploaded textures.
GLHelpers.ThrowOnResourceError(
GL,
$"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})");
mipmapWorkCompleted = generateMipmaps
&& _needsMipmapRegeneration
&& _mipmapDirtyCount > 0;
}
finally {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Clear();
GL.BindTexture(GLEnum.Texture2DArray, 0);
GL.ActiveTexture(TextureUnit.Texture0);
}
if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) {
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
}
else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) {
_logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage);
}
else {
try {
GL.GenerateMipmap(GLEnum.Texture2DArray);
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot);
}
}
// Commit CPU-side completion only after glGetError confirms the
// uploads/mipmap work succeeded. If the driver rejects an
// operation, the retained payloads and dirty flags remain intact and the
// atlas stays in ObjectMeshManager's dirty set for a later retry.
_pendingUpdates.Clear();
if (mipmapWorkCompleted) {
_mipmapDirtyCount = 0;
_needsMipmapRegeneration = false;
}
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2DArray, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
return generatedBytes;
}
private void ClearLayerForMipmap(int layer) {
@ -351,17 +435,51 @@ namespace AcDream.App.Rendering.Wb {
}
private int GetExpectedDataSize() {
if (_isCompressed) {
return TextureHelpers.GetCompressedLayerSize(Width, Height, Format);
return CalculateExpectedDataSize(Format, Width, Height);
}
internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) {
if (IsCompressedFormat(format))
return TextureHelpers.GetCompressedLayerSize(width, height, format);
return format switch {
TextureFormat.RGBA8 => checked(width * height * 4),
TextureFormat.RGB8 => checked(width * height * 3),
TextureFormat.A8 => checked(width * height),
TextureFormat.Rgba32f => checked(width * height * 16),
_ => throw new NotSupportedException($"Unsupported format {format}")
};
}
internal static void ValidateUploadPayload(
TextureFormat format,
int width,
int height,
int dataLength,
PixelFormat? uploadPixelFormat,
PixelType? uploadPixelType) {
int expectedBytes = CalculateExpectedDataSize(format, width, height);
if (dataLength != expectedBytes) {
throw new ArgumentException(
$"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} "
+ $"for {format} {width}x{height}.",
nameof(dataLength));
}
return Format switch {
TextureFormat.RGBA8 => Width * Height * 4,
TextureFormat.RGB8 => Width * Height * 3,
TextureFormat.A8 => Width * Height * 1,
TextureFormat.Rgba32f => Width * Height * 16,
_ => throw new NotSupportedException($"Unsupported format {Format}")
};
if (IsCompressedFormat(format)) {
if (uploadPixelFormat.HasValue || uploadPixelType.HasValue)
throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides.");
return;
}
PixelFormat expectedFormat = format.ToPixelFormat();
PixelType expectedType = format.ToPixelType();
if ((uploadPixelFormat ?? expectedFormat) != expectedFormat
|| (uploadPixelType ?? expectedType) != expectedType) {
throw new ArgumentException(
$"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match "
+ $"the {expectedFormat}/{expectedType} transfer required by {format}.");
}
}
public void RemoveLayer(int layer) {
@ -376,15 +494,8 @@ namespace AcDream.App.Rendering.Wb {
_usedLayers[layer] = false;
// Make layer defined for mipmap completeness (uncompressed only)
if (!_isCompressed) {
ClearLayerForMipmap(layer);
}
lock (_mipmapLock) {
_mipmapDirtyCount++; // Mark dirty to regen
_needsMipmapRegeneration = true;
}
// An unreferenced layer needs no clear or whole-array mip
// regeneration before AddTexture overwrites it on reuse.
}
public bool IsLayerUsed(int layer) {
@ -396,6 +507,22 @@ namespace AcDream.App.Rendering.Wb {
return _usedLayers.Count(x => x);
}
/// <summary>
/// True once disposal is durably owned by a queued GL publication,
/// the frame-retirement queue, or a completed retained release. A
/// caller may only commit its own logical disposal after this becomes
/// true; otherwise a synchronous enqueue failure still needs retry.
/// </summary>
internal bool HasDurableDisposeOwnership {
get {
if (Volatile.Read(ref _disposeQueued) == 0)
return false;
return Volatile.Read(ref _disposePublicationQueued) != 0
|| Volatile.Read(ref _disposeRetirementAccepted) != 0
|| Volatile.Read(ref _disposeRelease) is null;
}
}
public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL);
@ -409,37 +536,95 @@ namespace AcDream.App.Rendering.Wb {
}
public void Dispose() {
_device.QueueGLAction(GL => {
if (_device.BindlessExtension != null) {
if (BindlessHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
BindlessHandle = 0;
if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) {
ScheduleDisposeRelease();
return;
}
uint textureName = (uint)NativePtr;
ulong bindlessWrapHandle = BindlessWrapHandle;
ulong bindlessClampHandle = BindlessClampHandle;
long textureBytes = CalculateTotalSize();
NativePtr = 0;
BindlessWrapHandle = 0;
BindlessClampHandle = 0;
_disposeRelease = new RetryableGpuResourceRelease(
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle");
}
if (BindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
BindlessWrapHandle = 0;
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0)
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)");
},
() => {
if (_device.BindlessExtension != null && bindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle);
GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle");
}
if (BindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
BindlessClampHandle = 0;
},
() => {
if (textureName != 0)
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)");
},
() => {
if (textureName != 0) {
GL.DeleteTexture(textureName);
GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}");
}
}
if (NativePtr != 0) {
GL.DeleteTexture((uint)NativePtr);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
NativePtr = 0;
}
if (_pboId != 0) {
GL.DeleteBuffer(_pboId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
},
() => {
if (textureName != 0)
GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture);
},
() => {
if (textureName != 0)
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
},
() => _disposeRelease = null);
ScheduleDisposeRelease();
}
private void ScheduleDisposeRelease(bool forNextPass = false) {
RetryableGpuResourceRelease? release = _disposeRelease;
if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0)
return;
if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0)
return;
try {
Action<GL> publish = GL => {
Volatile.Write(ref _disposePublicationQueued, 0);
try {
_device.RetireGpuResource(release.Run);
Volatile.Write(ref _disposeRetirementAccepted, 1);
}
_pboId = 0;
}
});
catch {
// Retire may fail before accepting the callback, or an
// immediate queue may surface a partial release. The
// release cursor makes this next-pass retry exact.
ScheduleDisposeRelease(forNextPass: true);
throw;
}
};
if (forNextPass)
_device.QueueGLActionForNextPass(publish);
else
_device.QueueGLAction(publish);
}
catch {
Volatile.Write(ref _disposePublicationQueued, 0);
throw;
}
}
}
}

View file

@ -3,6 +3,23 @@ using AcDream.Content;
namespace AcDream.App.Rendering.Wb;
internal enum MeshStageResult
{
Staged,
AlreadyStaged,
HighWater,
}
/// <summary>
/// Immutable identity for one staging claim. Object ids can be released and
/// reacquired while a render-thread upload is in flight; the generation keeps
/// a stale completion or retry from consuming the replacement claim.
/// </summary>
internal readonly record struct MeshUploadQueueItem(
ObjectMeshData Data,
long SourceBytes,
ulong Generation);
/// <summary>
/// Deduplicated CPU-to-GPU staging queue. One object id may be queued or
/// in-flight at a time; a retry retains ownership until upload succeeds or
@ -10,24 +27,209 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
internal sealed class MeshUploadStagingQueue
{
private readonly ConcurrentQueue<ObjectMeshData> _queue = new();
private readonly ConcurrentDictionary<ulong, byte> _ownedIds = new();
internal const int DefaultMaximumCount = 256;
internal const long DefaultMaximumBytes = 128L * 1024 * 1024;
internal const long DefaultMaximumSingleEntryBytes = 128L * 1024 * 1024;
public bool Stage(ObjectMeshData data)
private readonly object _gate = new();
private readonly Queue<Entry> _queue = new();
private readonly Dictionary<ulong, ulong> _generationById = new();
private readonly int _maximumCount;
private readonly long _maximumBytes;
private readonly long _maximumSingleEntryBytes;
private long _queuedBytes;
private long _claimedBytes;
private ulong _nextGeneration;
private readonly record struct Entry(ObjectMeshData Data, long Bytes, ulong Generation)
{
if (!_ownedIds.TryAdd(data.ObjectId, 0))
return false;
data.UploadAttempts = 0;
_queue.Enqueue(data);
return true;
public MeshUploadQueueItem Item => new(Data, Bytes, Generation);
}
public bool TryDequeue(out ObjectMeshData? data) => _queue.TryDequeue(out data);
public MeshUploadStagingQueue(
int maximumCount = DefaultMaximumCount,
long maximumBytes = DefaultMaximumBytes,
long maximumSingleEntryBytes = 0)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
ArgumentOutOfRangeException.ThrowIfNegative(maximumSingleEntryBytes);
if (maximumSingleEntryBytes == 0)
maximumSingleEntryBytes = Math.Max(maximumBytes, DefaultMaximumSingleEntryBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumSingleEntryBytes, maximumBytes);
_maximumCount = maximumCount;
_maximumBytes = maximumBytes;
_maximumSingleEntryBytes = maximumSingleEntryBytes;
}
public void Requeue(ObjectMeshData data) => _queue.Enqueue(data);
public bool Stage(ObjectMeshData data)
=> TryStage(data) == MeshStageResult.Staged;
public void Complete(ulong objectId) => _ownedIds.TryRemove(objectId, out _);
public MeshStageResult TryStage(ObjectMeshData data)
{
ArgumentNullException.ThrowIfNull(data);
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
lock (_gate)
{
if (_generationById.ContainsKey(data.ObjectId))
return MeshStageResult.AlreadyStaged;
if (bytes > _maximumSingleEntryBytes)
throw new NotSupportedException(
$"Mesh 0x{data.ObjectId:X10} requires {bytes:N0} staged bytes; "
+ $"the supported per-object maximum is {_maximumSingleEntryBytes:N0} bytes.");
// Permit one explicitly bounded oversized head item so unusual
// content cannot starve. Every other producer observes the strict
// count/byte watermark; active decoders retain their result in the
// bounded CPU cache and retry staging after the consumer drains.
if (_generationById.Count != 0
&& (_generationById.Count >= _maximumCount
|| bytes > _maximumBytes - Math.Min(_claimedBytes, _maximumBytes)))
{
return MeshStageResult.HighWater;
}
ulong generation = checked(++_nextGeneration);
_generationById.Add(data.ObjectId, generation);
data.UploadAttempts = 0;
_queue.Enqueue(new Entry(data, bytes, generation));
_queuedBytes = checked(_queuedBytes + bytes);
_claimedBytes = checked(_claimedBytes + bytes);
return MeshStageResult.Staged;
}
}
public bool TryDequeue(out MeshUploadQueueItem item)
{
lock (_gate)
{
if (!_queue.TryDequeue(out Entry entry))
{
item = default;
return false;
}
_queuedBytes -= entry.Bytes;
item = entry.Item;
return true;
}
}
public bool TryPeek(out MeshUploadQueueItem item)
{
lock (_gate)
{
if (!_queue.TryPeek(out Entry entry))
{
item = default;
return false;
}
item = entry.Item;
return true;
}
}
public int Count { get { lock (_gate) return _queue.Count; } }
public int ClaimCount { get { lock (_gate) return _generationById.Count; } }
public long QueuedBytes { get { lock (_gate) return _queuedBytes; } }
public long ClaimedBytes { get { lock (_gate) return _claimedBytes; } }
public bool IsAtHighWater
{
get
{
lock (_gate)
return _generationById.Count >= _maximumCount || _claimedBytes >= _maximumBytes;
}
}
public void Requeue(MeshUploadQueueItem item)
{
ArgumentNullException.ThrowIfNull(item.Data);
lock (_gate)
{
ValidateCurrentGeneration(item);
if (item.SourceBytes > _maximumSingleEntryBytes)
throw new InvalidOperationException("Cannot retry mesh data after staging ownership completed.");
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, item.Generation));
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
}
}
public void Complete(MeshUploadQueueItem item)
{
lock (_gate)
{
ValidateCurrentGeneration(item);
_generationById.Remove(item.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
}
}
/// <summary>
/// Completes a dequeued, now-unowned generation and atomically hands the
/// same CPU payload to an owner that raced in while it was dequeued. A
/// concurrent cache hit either sees the old staging claim and this method
/// requeues, or runs after the claim is removed and stages for itself; the
/// mesh cannot fall through the gap between those operations.
/// </summary>
public bool CompleteOrRestageIfOwned(
MeshUploadQueueItem item,
MeshOwnershipCounter ownership)
{
ArgumentNullException.ThrowIfNull(item.Data);
ArgumentNullException.ThrowIfNull(ownership);
lock (_gate)
{
ValidateCurrentGeneration(item);
_generationById.Remove(item.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - item.SourceBytes);
if (!ownership.IsOwned(item.Data.ObjectId))
return false;
ulong generation = checked(++_nextGeneration);
_generationById.Add(item.Data.ObjectId, generation);
item.Data.UploadAttempts = 0;
_queue.Enqueue(new Entry(item.Data, item.SourceBytes, generation));
_queuedBytes = checked(_queuedBytes + item.SourceBytes);
_claimedBytes = checked(_claimedBytes + item.SourceBytes);
return true;
}
}
public int DiscardUnownedPrefix(MeshOwnershipCounter ownership, int maximum)
{
ArgumentNullException.ThrowIfNull(ownership);
ArgumentOutOfRangeException.ThrowIfNegative(maximum);
int discarded = 0;
lock (_gate)
{
while (discarded < maximum
&& _queue.TryPeek(out Entry entry)
&& !ownership.IsOwned(entry.Data.ObjectId))
{
_queue.Dequeue();
_queuedBytes -= entry.Bytes;
if (_generationById.TryGetValue(entry.Data.ObjectId, out ulong generation)
&& generation == entry.Generation)
{
_generationById.Remove(entry.Data.ObjectId);
_claimedBytes = checked(_claimedBytes - entry.Bytes);
}
discarded++;
}
}
return discarded;
}
private void ValidateCurrentGeneration(MeshUploadQueueItem item)
{
if (!_generationById.TryGetValue(item.Data.ObjectId, out ulong current)
|| current != item.Generation)
{
throw new InvalidOperationException(
$"Stale mesh-upload generation {item.Generation} for 0x{item.Data.ObjectId:X10}; current={current}.");
}
}
}
/// <summary>
@ -63,46 +265,74 @@ internal sealed class MeshOwnershipCounter
internal sealed class CpuMeshUploadCache
{
private readonly Dictionary<ulong, ObjectMeshData> _data = new();
private readonly Dictionary<ulong, long> _bytesById = new();
private readonly LinkedList<ulong> _lru = new();
private readonly int _capacity;
private readonly long _byteCapacity;
private long _residentBytes;
public CpuMeshUploadCache(int capacity)
public CpuMeshUploadCache(int capacity, long byteCapacity = 128L * 1024 * 1024)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
ArgumentOutOfRangeException.ThrowIfLessThan(byteCapacity, 1);
_capacity = capacity;
_byteCapacity = byteCapacity;
}
internal int Count { get { lock (_data) return _data.Count; } }
internal long ResidentBytes { get { lock (_data) return _residentBytes; } }
public bool TryGetAndStage(
ulong id,
MeshUploadStagingQueue staging,
out ObjectMeshData? data)
out ObjectMeshData? data,
out MeshStageResult stageResult)
{
lock (_data)
{
if (!_data.TryGetValue(id, out data))
if (!_data.TryGetValue(id, out data)) {
stageResult = default;
return false;
}
_lru.Remove(id);
_lru.AddLast(id);
staging.Stage(data);
stageResult = staging.TryStage(data);
return true;
}
}
public void Store(ObjectMeshData data)
public bool Store(ObjectMeshData data)
{
ArgumentNullException.ThrowIfNull(data);
long bytes = ObjectMeshManager.EstimateUploadBytes(data);
// Reject before touching the replacement/LRU state. The prior loop
// evicted everything and then published one arbitrarily large entry,
// allowing the cache-hit path to replay an unbounded payload forever.
if (bytes > _byteCapacity)
return false;
lock (_data)
{
if (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity)
if (_bytesById.Remove(data.ObjectId, out long replacedBytes))
_residentBytes -= replacedBytes;
while (_lru.Count != 0
&& (!_data.ContainsKey(data.ObjectId) && _data.Count >= _capacity
|| (_data.Count != 0 && bytes > _byteCapacity - _residentBytes)))
{
ulong oldest = _lru.First!.Value;
_lru.RemoveFirst();
_data.Remove(oldest);
if (_bytesById.Remove(oldest, out long oldestBytes))
_residentBytes -= oldestBytes;
}
_data[data.ObjectId] = data;
_bytesById[data.ObjectId] = bytes;
_residentBytes = checked(_residentBytes + bytes);
_lru.Remove(data.ObjectId);
_lru.AddLast(data.ObjectId);
return true;
}
}
@ -111,7 +341,9 @@ internal sealed class CpuMeshUploadCache
lock (_data)
{
_data.Clear();
_bytesById.Clear();
_lru.Clear();
_residentBytes = 0;
}
}
}

View file

@ -0,0 +1,186 @@
namespace AcDream.App.Rendering.Wb;
internal readonly record struct MeshUploadCost(
long SourceBytes,
long ArrayAllocationBytes,
long MipmapBytes,
int NewArrayCount,
long BufferUploadBytes = 0,
long BufferAllocationBytes = 0,
long BufferCopyBytes = 0,
int NewBufferCount = 0,
ulong AdmissionKey = 0);
internal readonly record struct MeshUploadBudgetLimits(
int MaximumObjects,
long MaximumSourceBytes,
long MaximumArrayAllocationBytes,
long MaximumMipmapBytes,
int MaximumNewArrays,
long MaximumBufferUploadBytes = long.MaxValue,
long MaximumBufferAllocationBytes = long.MaxValue,
long MaximumBufferCopyBytes = long.MaxValue,
int MaximumNewBuffers = int.MaxValue,
long MaximumSingleSourceBytes = long.MaxValue,
long MaximumSingleArrayAllocationBytes = long.MaxValue,
long MaximumSingleMipmapBytes = long.MaxValue,
int MaximumSingleNewArrays = int.MaxValue,
long MaximumSingleBufferUploadBytes = long.MaxValue);
/// <summary>
/// Pure prefix-admission policy for render-thread mesh uploads. Ordinary
/// uploads fit one frame in every independent CPU/GPU dimension. A physically
/// indivisible FIFO head may exceed a soft frame budget only when it is below
/// the explicit single-operation ceiling; it runs immediately rather than
/// accumulating fictional credits across idle frames. Global-buffer growth
/// and prefix copies are never admitted here: they are real, chunked
/// maintenance operations driven by <see cref="GlobalMeshBuffer"/>.
/// </summary>
internal sealed class MeshUploadFrameBudget
{
private readonly MeshUploadBudgetLimits _limits;
public MeshUploadFrameBudget(MeshUploadBudgetLimits limits)
{
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumObjects, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSourceBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumArrayAllocationBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumMipmapBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewArrays, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferUploadBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferAllocationBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumBufferCopyBytes, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumNewBuffers, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleSourceBytes, limits.MaximumSourceBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleArrayAllocationBytes, limits.MaximumArrayAllocationBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleMipmapBytes, limits.MaximumMipmapBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleNewArrays, limits.MaximumNewArrays);
ArgumentOutOfRangeException.ThrowIfLessThan(limits.MaximumSingleBufferUploadBytes, limits.MaximumBufferUploadBytes);
_limits = limits;
}
public int ObjectCount { get; private set; }
public long SourceBytes { get; private set; }
public long ArrayAllocationBytes { get; private set; }
public long MipmapBytes { get; private set; }
public int NewArrayCount { get; private set; }
public long BufferUploadBytes { get; private set; }
public long BufferAllocationBytes { get; private set; }
public long BufferCopyBytes { get; private set; }
public int NewBufferCount { get; private set; }
public void Reset()
{
ObjectCount = 0;
SourceBytes = 0;
ArrayAllocationBytes = 0;
MipmapBytes = 0;
NewArrayCount = 0;
BufferUploadBytes = 0;
BufferAllocationBytes = 0;
BufferCopyBytes = 0;
NewBufferCount = 0;
}
public bool TryAdmit(MeshUploadCost cost)
{
Validate(cost);
if (cost.BufferAllocationBytes != 0
|| cost.BufferCopyBytes != 0
|| cost.NewBufferCount != 0)
{
throw new InvalidOperationException(
"Global-buffer allocation/copy work must be progressed as real maintenance before upload admission.");
}
bool oversizedHead = ObjectCount == 0 && ExceedsSingleFrame(cost);
if (oversizedHead)
{
if (cost.SourceBytes > _limits.MaximumSingleSourceBytes
|| cost.ArrayAllocationBytes > _limits.MaximumSingleArrayAllocationBytes
|| cost.MipmapBytes > _limits.MaximumSingleMipmapBytes
|| cost.NewArrayCount > _limits.MaximumSingleNewArrays
|| cost.BufferUploadBytes > _limits.MaximumSingleBufferUploadBytes)
{
throw new NotSupportedException(
$"Mesh upload generation {cost.AdmissionKey} exceeds the supported single-operation ceiling.");
}
Admit(cost);
return true;
}
if (ObjectCount >= _limits.MaximumObjects
|| WouldExceed(SourceBytes, cost.SourceBytes, _limits.MaximumSourceBytes)
|| WouldExceed(ArrayAllocationBytes, cost.ArrayAllocationBytes, _limits.MaximumArrayAllocationBytes)
|| WouldExceed(MipmapBytes, cost.MipmapBytes, _limits.MaximumMipmapBytes)
|| cost.NewArrayCount > _limits.MaximumNewArrays - NewArrayCount
|| WouldExceed(BufferUploadBytes, cost.BufferUploadBytes, _limits.MaximumBufferUploadBytes)
|| WouldExceed(BufferAllocationBytes, cost.BufferAllocationBytes, _limits.MaximumBufferAllocationBytes)
|| WouldExceed(BufferCopyBytes, cost.BufferCopyBytes, _limits.MaximumBufferCopyBytes)
|| cost.NewBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
{
return false;
}
Admit(cost);
return true;
}
/// <summary>Records work that actually executed this frame.</summary>
public void RecordBufferMaintenance(long allocationBytes, long copyBytes, int newBufferCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(allocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(copyBytes);
ArgumentOutOfRangeException.ThrowIfNegative(newBufferCount);
if (WouldExceed(BufferAllocationBytes, allocationBytes, _limits.MaximumBufferAllocationBytes)
|| WouldExceed(BufferCopyBytes, copyBytes, _limits.MaximumBufferCopyBytes)
|| newBufferCount > _limits.MaximumNewBuffers - NewBufferCount)
{
throw new InvalidOperationException(
"Executed global-buffer maintenance exceeded its real per-frame bound.");
}
BufferAllocationBytes = checked(BufferAllocationBytes + allocationBytes);
BufferCopyBytes = checked(BufferCopyBytes + copyBytes);
NewBufferCount = checked(NewBufferCount + newBufferCount);
}
private bool ExceedsSingleFrame(MeshUploadCost cost) =>
cost.SourceBytes > _limits.MaximumSourceBytes
|| cost.ArrayAllocationBytes > _limits.MaximumArrayAllocationBytes
|| cost.MipmapBytes > _limits.MaximumMipmapBytes
|| cost.NewArrayCount > _limits.MaximumNewArrays
|| cost.BufferUploadBytes > _limits.MaximumBufferUploadBytes
|| cost.BufferAllocationBytes > _limits.MaximumBufferAllocationBytes
|| cost.BufferCopyBytes > _limits.MaximumBufferCopyBytes
|| cost.NewBufferCount > _limits.MaximumNewBuffers;
private void Admit(MeshUploadCost cost)
{
ObjectCount++;
SourceBytes = checked(SourceBytes + cost.SourceBytes);
ArrayAllocationBytes = checked(ArrayAllocationBytes + cost.ArrayAllocationBytes);
MipmapBytes = checked(MipmapBytes + cost.MipmapBytes);
NewArrayCount = checked(NewArrayCount + cost.NewArrayCount);
BufferUploadBytes = checked(BufferUploadBytes + cost.BufferUploadBytes);
BufferAllocationBytes = checked(BufferAllocationBytes + cost.BufferAllocationBytes);
BufferCopyBytes = checked(BufferCopyBytes + cost.BufferCopyBytes);
NewBufferCount = checked(NewBufferCount + cost.NewBufferCount);
}
// Keep the comparison overflow-free even when a bounded indivisible head
// has truthfully exceeded a soft per-frame budget.
private static bool WouldExceed(long current, long added, long maximum) =>
added > maximum - Math.Min(current, maximum);
private static void Validate(MeshUploadCost cost)
{
ArgumentOutOfRangeException.ThrowIfNegative(cost.SourceBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.ArrayAllocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.MipmapBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewArrayCount);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferUploadBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferAllocationBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.BufferCopyBytes);
ArgumentOutOfRangeException.ThrowIfNegative(cost.NewBufferCount);
}
}

File diff suppressed because it is too large Load diff

View file

@ -22,17 +22,43 @@ namespace AcDream.App.Rendering.Wb {
public unsafe class OpenGLGraphicsDevice : BaseGraphicsDevice {
private readonly ILogger _log;
private readonly DebugRenderSettings _renderSettings;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue _resourceRetirement;
public GL GL { get; }
public DebugRenderSettings RenderSettings => _renderSettings;
private readonly ConcurrentQueue<Action<GL>> _glThreadQueue = new();
private readonly ConcurrentQueue<Action<GL>> _nextGlThreadQueue = new();
internal bool HasPendingGLWork =>
!_glThreadQueue.IsEmpty || !_nextGlThreadQueue.IsEmpty;
public void QueueGLAction(Action<GL> action) {
_glThreadQueue.Enqueue(action);
}
internal void QueueGLActionForNextPass(Action<GL> action) {
ArgumentNullException.ThrowIfNull(action);
_nextGlThreadQueue.Enqueue(action);
}
public void ProcessGLQueue() {
// Retry the prior pass before ordinary work (notably sampler
// deletion), but process only the captured generation so a
// persistent driver failure cannot spin this frame forever.
int retryCount = _nextGlThreadQueue.Count;
for (int i = 0; i < retryCount && _nextGlThreadQueue.TryDequeue(out Action<GL>? retry); i++) {
try {
retry(GL);
} catch (Exception ex) {
_log.LogError(ex, "Error processing retryable GL queue action");
}
}
// Normal actions retain drain-to-empty semantics because teardown
// actions intentionally enqueue dependent atlas releases here.
// A persistent retryable error must not starve unrelated uploads
// and releases forever: the retry generation remains bounded to
// one attempt per pass, while ordinary work still makes progress.
while (_glThreadQueue.TryDequeue(out var action)) {
try {
action(GL);
@ -61,6 +87,7 @@ namespace AcDream.App.Rendering.Wb {
public uint WrapSampler { get; private set; }
/// <summary>OpenGL sampler object with TextureWrapMode.ClampToEdge (for meshes without wrapping UVs).</summary>
public uint ClampSampler { get; private set; }
internal float MaxSupportedAnisotropy { get; private set; }
private ManagedGLUniformBuffer? _sceneDataBuffer;
/// <summary>Shared SceneData UBO.</summary>
@ -83,12 +110,23 @@ namespace AcDream.App.Rendering.Wb {
protected OpenGLGraphicsDevice() : base() {
_log = null!;
_renderSettings = null!;
_resourceRetirement = null!;
GL = null!;
}
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true) : base() {
public OpenGLGraphicsDevice(GL gl, ILogger log, DebugRenderSettings renderSettings, bool allowBindless = true)
: this(gl, log, renderSettings, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance, allowBindless) {
}
internal OpenGLGraphicsDevice(
GL gl,
ILogger log,
DebugRenderSettings renderSettings,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement,
bool allowBindless = true) : base() {
_log = log;
_renderSettings = renderSettings;
_resourceRetirement = resourceRetirement ?? throw new ArgumentNullException(nameof(resourceRetirement));
GL = gl;
GLHelpers.Init(this, log);
@ -114,26 +152,30 @@ namespace AcDream.App.Rendering.Wb {
GL.GenBuffers(1, out uint instanceVbo);
InstanceVBO = instanceVbo;
// Query this immutable device limit once. Atlas construction can
// happen hundreds of times during portal streaming; repeating a
// driver GetFloat for every texture serialized the upload burst.
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
MaxSupportedAnisotropy = Math.Max(0f, maxAniso);
}
// Create sampler objects for wrap vs clamp
WrapSampler = GL.GenSampler();
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.Repeat);
GL.SamplerParameter(WrapSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.Repeat);
GL.SamplerParameter(WrapSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(WrapSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
if (maxAniso > 0) GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
}
if (MaxSupportedAnisotropy > 0)
GL.SamplerParameter(WrapSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
ClampSampler = GL.GenSampler();
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapS, (int)TextureWrapMode.ClampToEdge);
GL.SamplerParameter(ClampSampler, SamplerParameterI.WrapT, (int)TextureWrapMode.ClampToEdge);
GL.SamplerParameter(ClampSampler, SamplerParameterI.MinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.SamplerParameter(ClampSampler, SamplerParameterI.MagFilter, (int)TextureMagFilter.Linear);
if (renderSettings.EnableAnisotropicFiltering) {
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out float maxAniso);
if (maxAniso > 0) GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, maxAniso);
}
if (MaxSupportedAnisotropy > 0)
GL.SamplerParameter(ClampSampler, GLEnum.TextureMaxAnisotropy, MaxSupportedAnisotropy);
_sceneDataBuffer = new ManagedGLUniformBuffer(this, BufferUsage.Dynamic, Marshal.SizeOf<SceneData>());
@ -145,6 +187,15 @@ namespace AcDream.App.Rendering.Wb {
ParticleBatcher = null!;
}
/// <summary>
/// Retires a GL resource only after every submitted draw that could
/// reference it has completed on the GPU.
/// </summary>
internal void RetireGpuResource(Action release) => _resourceRetirement.Retire(release);
internal AcDream.App.Rendering.IGpuResourceRetirementQueue ResourceRetirement =>
_resourceRetirement;
private void InitializeSharedDebugResources() {
// Unit quad vertices for two triangles (0 to 1 for length, -0.5 to 0.5 for thickness)
float[] quadVertices = {
@ -608,14 +659,26 @@ namespace AcDream.App.Rendering.Wb {
GpuMemoryTracker.TrackDeallocation(instanceBufferCapacity * instanceBufferStride);
}
}
if (wrapSampler != 0) {
gl.DeleteSampler(wrapSampler);
}
if (clampSampler != 0) {
gl.DeleteSampler(clampSampler);
}
});
// Bindless texture-array retirements embed these samplers in their
// resident handles. Ordinary GL work must keep flowing when one
// retry is sick, but sampler deletion itself is dependency-ordered
// behind the retry queue. Requeue into the next generation (never
// the drain-to-empty ordinary queue) to remain one attempt/frame.
Action<GL>? deleteSamplersWhenSafe = null;
deleteSamplersWhenSafe = gl => {
if (!_nextGlThreadQueue.IsEmpty) {
QueueGLActionForNextPass(deleteSamplersWhenSafe!);
return;
}
if (wrapSampler != 0)
gl.DeleteSampler(wrapSampler);
if (clampSampler != 0)
gl.DeleteSampler(clampSampler);
};
QueueGLActionForNextPass(deleteSamplersWhenSafe);
InstanceVBO = 0;
InstanceVBOPtr = null;
WrapSampler = 0;

View file

@ -58,9 +58,11 @@ namespace AcDream.App.Rendering.Wb {
if (emitter.HwGfxObjId.DataId != 0) {
_meshManager.IncrementRefCount(emitter.HwGfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.HwGfxObjId.DataId, isSetup: false);
}
if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) {
_meshManager.IncrementRefCount(emitter.GfxObjId.DataId);
_meshManager.PrepareMeshDataAsync(emitter.GfxObjId.DataId, isSetup: false);
}
}
@ -69,10 +71,12 @@ namespace AcDream.App.Rendering.Wb {
if (_gfxRenderData == null) {
var gfxId = _emitter.HwGfxObjId.DataId != 0 ? _emitter.HwGfxObjId.DataId : _emitter.GfxObjId.DataId;
if (gfxId != 0) {
_meshManager.PrepareMeshDataAsync(gfxId, isSetup: false);
_gfxRenderData = _meshManager.TryGetRenderData(gfxId);
}
}
if (_textureRenderData == null && _emitter.GfxObjId.DataId != 0) {
_meshManager.PrepareMeshDataAsync(_emitter.GfxObjId.DataId, isSetup: false);
_textureRenderData = _meshManager.TryGetRenderData(_emitter.GfxObjId.DataId);
}

View file

@ -0,0 +1,135 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-resource progress for a teardown which must attempt every independent
/// release even when an earlier release fails. Successful operations are never
/// replayed. A backend which throws after changing ownership reports that fact
/// with <see cref="MeshReferenceMutationException.MutationCommitted"/> so the
/// ledger can preserve the physical outcome while still surfacing the error.
/// </summary>
internal sealed class RetryableResourceReleaseLedger
{
private readonly Entry[] _entries;
private int _remaining;
private bool _advancing;
public RetryableResourceReleaseLedger(IEnumerable<(string Name, Action Release)> entries)
{
ArgumentNullException.ThrowIfNull(entries);
_entries = entries
.Select(entry => new Entry(entry.Name, entry.Release))
.ToArray();
_remaining = _entries.Length;
}
public bool IsComplete => _remaining == 0;
public int RemainingCount => _remaining;
/// <summary>
/// Attempts every unfinished operation once. Ordinary exceptions retain
/// the operation for a later attempt; committed-outcome exceptions mark it
/// complete. Either kind remains in the returned diagnostics.
/// </summary>
public ResourceReleaseAttempt Advance()
{
// Release callbacks may synchronously drain their owner. The active
// top-level pass already owns every unfinished entry; a nested pass
// must not give a failed later entry a second attempt in this frame.
if (_advancing)
return new ResourceReleaseAttempt(0, 0, []);
_advancing = true;
List<ResourceReleaseFailure>? failures = null;
int attempted = 0;
int completed = 0;
try
{
for (int i = 0; i < _entries.Length; i++)
{
Entry entry = _entries[i];
if (entry.Completed || entry.Running)
continue;
attempted++;
entry.Running = true;
try
{
entry.Release();
entry.Completed = true;
_remaining--;
completed++;
}
catch (Exception error)
{
bool committed = error is MeshReferenceMutationException
{
MutationCommitted: true,
};
if (committed)
{
entry.Completed = true;
_remaining--;
completed++;
}
(failures ??= []).Add(
new ResourceReleaseFailure(entry.Name, error, committed));
}
finally
{
entry.Running = false;
}
}
}
finally
{
_advancing = false;
}
return new ResourceReleaseAttempt(
attempted,
completed,
failures ?? []);
}
private sealed class Entry
{
public Entry(string name, Action release)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("A resource-release stage needs a name.", nameof(name));
ArgumentNullException.ThrowIfNull(release);
Name = name;
Release = release;
}
public string Name { get; }
public Action Release { get; }
public bool Completed { get; set; }
public bool Running { get; set; }
}
}
internal readonly record struct ResourceReleaseFailure(
string Stage,
Exception Error,
bool MutationCommitted);
internal readonly record struct ResourceReleaseAttempt(
int AttemptedCount,
int CompletedCount,
IReadOnlyList<ResourceReleaseFailure> Failures)
{
public bool HasFailures => Failures.Count != 0;
public AggregateException ToException(string message) =>
new(
message,
Failures.Select(failure =>
new InvalidOperationException(
$"Resource-release stage '{failure.Stage}' failed "
+ (failure.MutationCommitted
? "after committing its mutation."
: "before committing its mutation."),
failure.Error)));
}

View file

@ -6,8 +6,38 @@ using Silk.NET.OpenGL;
using System;
using System.Collections.Generic;
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb {
internal sealed class TextureAtlasDisposeTransaction {
private bool _running;
public bool IsComplete { get; private set; }
public bool IsRunning => _running;
public void Advance(
Action retryLayerRetirements,
Action disposeTextureArray,
Action commitLogicalDisposal) {
ArgumentNullException.ThrowIfNull(retryLayerRetirements);
ArgumentNullException.ThrowIfNull(disposeTextureArray);
ArgumentNullException.ThrowIfNull(commitLogicalDisposal);
if (IsComplete || _running)
return;
_running = true;
try {
retryLayerRetirements();
disposeTextureArray();
commitLogicalDisposal();
IsComplete = true;
}
finally {
_running = false;
}
}
}
/// <summary>
/// Manages texture arrays grouped by (Width, Height, Format).
/// Deduplicates textures by a TextureKey and supports reference counting.
@ -20,41 +50,99 @@ namespace AcDream.App.Rendering.Wb {
private readonly TextureFormat _format;
private readonly Dictionary<TextureKey, int> _textureIndices = new();
private readonly Dictionary<int, int> _refCounts = new();
private readonly Stack<int> _freeSlots = new();
private int _nextIndex = 0;
private const int InitialCapacity = 32;
private readonly TextureAtlasSlotAllocator _slots;
private readonly TextureAtlasLayerRetirement _layerRetirement;
private readonly TextureAtlasDisposeTransaction _disposeTransaction = new();
private readonly Action<TextureAtlasManager>? _onGpuSafeEmpty;
private bool _disposed;
internal const long TargetArrayBytes = 8L * 1024 * 1024;
internal const int MaximumArrayLayers = 32;
public uint Slot { get; }
public ManagedGLTextureArray TextureArray { get; private set; } = null!;
public int UsedSlots => _textureIndices.Count;
public int TotalSlots => TextureArray?.Size ?? InitialCapacity;
public int FreeSlots => TotalSlots - UsedSlots;
public int TotalSlots => TextureArray?.Size ?? 0;
public int AvailableSlots => _slots.AvailableCount;
internal bool IsGpuSafeEmpty => UsedSlots == 0 && AvailableSlots == TotalSlots;
internal long AllocatedBytes {
get {
return TextureArray.TotalSizeInBytes;
}
}
internal long LastUseSequence { get; set; }
internal int Width => _textureWidth;
internal int Height => _textureHeight;
internal TextureFormat Format => _format;
public TextureAtlasManager(OpenGLGraphicsDevice graphicsDevice, int width, int height, TextureFormat format = TextureFormat.RGBA8) {
public TextureAtlasManager(
OpenGLGraphicsDevice graphicsDevice,
int width,
int height,
TextureFormat format = TextureFormat.RGBA8,
Action<TextureAtlasManager>? onGpuSafeEmpty = null) {
Slot = _nextSlot++;
_graphicsDevice = graphicsDevice;
_textureWidth = width;
_textureHeight = height;
_format = format;
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, InitialCapacity, TextureParameters.ClampToEdge);
_onGpuSafeEmpty = onGpuSafeEmpty;
_layerRetirement = new TextureAtlasLayerRetirement(graphicsDevice.ResourceRetirement);
int capacity = CalculateInitialCapacity(width, height, format);
TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, capacity, TextureParameters.ClampToEdge);
_slots = new TextureAtlasSlotAllocator(TextureArray.Size);
}
/// <summary>
/// Keeps each physical array near an eight-MiB target instead of
/// reserving 32 layers for every size class. The old fixed capacity
/// made a single 1024x1024 RGBA texture allocate roughly 171 MiB once
/// its mip chain was included, and made glGenerateMipmap process all
/// 32 layers during destination streaming.
/// </summary>
internal static int CalculateInitialCapacity(int width, int height, TextureFormat format) {
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
long bytesPerLayer = CalculateMipChainBytes(width, height, format);
long targetLayers = Math.Max(1L, TargetArrayBytes / bytesPerLayer);
return checked((int)Math.Min(targetLayers, MaximumArrayLayers));
}
internal static long CalculateMipChainBytes(int width, int height, TextureFormat format) {
long total = 0;
int w = width;
int h = height;
while (true) {
total = checked(total + CalculateLevelBytes(w, h, format));
if (w == 1 && h == 1) return total;
w = Math.Max(1, w >> 1);
h = Math.Max(1, h >> 1);
}
}
internal static long CalculateArrayBytes(int width, int height, TextureFormat format) =>
checked(CalculateMipChainBytes(width, height, format)
* CalculateInitialCapacity(width, height, format));
internal static long CalculateLevelBytes(int width, int height, TextureFormat format) => format switch {
TextureFormat.RGBA8 => checked((long)width * height * 4L),
TextureFormat.RGB8 => checked((long)width * height * 3L),
TextureFormat.A8 => checked((long)width * height),
TextureFormat.Rgba32f => checked((long)width * height * 16L),
TextureFormat.DXT1 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8L),
TextureFormat.DXT3 or TextureFormat.DXT5 => checked((long)Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 16L),
_ => throw new NotSupportedException($"Unsupported texture-atlas format {format}.")
};
public int AddTexture(TextureKey key, byte[] data, PixelFormat? uploadPixelFormat = null, PixelType? uploadPixelType = null) {
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
_layerRetirement.RetryPendingPublications();
if (_textureIndices.TryGetValue(key, out var existingIndex)) {
_refCounts[existingIndex]++;
return existingIndex;
}
int index;
if (_freeSlots.Count > 0) {
index = _freeSlots.Pop();
}
else {
index = _nextIndex++;
if (index >= TextureArray.Size) {
throw new Exception($"Texture atlas is full! {TextureArray.Size} / {_nextIndex} used.");
}
}
int index = _slots.Rent();
try {
TextureArray.UpdateLayer(index, data, uploadPixelFormat, uploadPixelType);
@ -63,14 +151,15 @@ namespace AcDream.App.Rendering.Wb {
return index;
}
catch (Exception) {
if (!_textureIndices.ContainsKey(key)) {
_freeSlots.Push(index);
}
if (!_textureIndices.ContainsKey(key))
_slots.Return(index);
throw;
}
}
public void ReleaseTexture(TextureKey key) {
ObjectDisposedException.ThrowIf(_disposed || _disposeTransaction.IsRunning, this);
_layerRetirement.RetryPendingPublications();
if (!_textureIndices.TryGetValue(key, out var index)) return;
if (!_refCounts.ContainsKey(index)) return;
@ -79,21 +168,74 @@ namespace AcDream.App.Rendering.Wb {
if (_refCounts[index] <= 0) {
_textureIndices.Remove(key);
_refCounts.Remove(index);
_freeSlots.Push(index);
TextureArray?.RemoveLayer(index);
// The CPU no longer references this layer, but previously
// submitted draws may still sample it. Recycle the slot only
// after their GPU fence has signaled; no clear or whole-array
// mip regeneration is needed for an unreferenced layer.
_layerRetirement.Retire(
() => {
if (!_disposed)
_slots.Return(index);
},
() => {
if (!_disposed && IsGpuSafeEmpty)
_onGpuSafeEmpty?.Invoke(this);
});
}
}
internal void RetryPendingRetirements() =>
_layerRetirement.RetryPendingPublications();
public bool HasTexture(TextureKey key) => _textureIndices.ContainsKey(key);
public int GetTextureIndex(TextureKey key) =>
_textureIndices.TryGetValue(key, out var index) ? index : -1;
public void Dispose() {
TextureArray?.Dispose();
_textureIndices.Clear();
_refCounts.Clear();
_freeSlots.Clear();
if (_disposed) return;
_disposeTransaction.Advance(
_layerRetirement.RetryPendingPublications,
() => {
TextureArray?.Dispose();
if (TextureArray is not null && !TextureArray.HasDurableDisposeOwnership)
throw new InvalidOperationException(
"Texture-array disposal returned without retaining or publishing its physical release.");
},
() => {
_textureIndices.Clear();
_refCounts.Clear();
_disposed = true;
});
}
}
/// <summary>
/// Owns the publication and callback cursor for returned atlas layers.
/// Removing the logical texture key commits immediately; this owner keeps
/// the physical layer reachable until the frame queue accepts it and keeps
/// the empty-atlas observer separate from the slot return so it cannot make
/// a callback retry return the same slot twice.
/// </summary>
internal sealed class TextureAtlasLayerRetirement
{
private readonly GpuRetirementLedger _ledger;
public TextureAtlasLayerRetirement(IGpuResourceRetirementQueue queue) =>
_ledger = new GpuRetirementLedger(queue);
internal int AwaitingPublicationCount => _ledger.AwaitingPublicationCount;
public void Retire(Action returnLayer, Action notifyGpuSafeEmpty)
{
ArgumentNullException.ThrowIfNull(returnLayer);
ArgumentNullException.ThrowIfNull(notifyGpuSafeEmpty);
_ledger.Retire(new RetryableGpuResourceRelease(
returnLayer,
notifyGpuSafeEmpty));
}
public void RetryPendingPublications() =>
_ledger.RetryPendingPublications();
}
}

View file

@ -0,0 +1,55 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Owns the physical layer slots in one texture array. A released texture
/// remains rented until the GPU-retirement callback returns its layer, so
/// <see cref="AvailableCount"/> never advertises a CPU-unreferenced layer
/// that an in-flight draw may still sample.
/// </summary>
internal sealed class TextureAtlasSlotAllocator
{
private readonly bool[] _rented;
private readonly Stack<int> _returned = new();
private int _next;
public TextureAtlasSlotAllocator(int capacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_rented = new bool[capacity];
}
public int AvailableCount => _returned.Count + (_rented.Length - _next);
public int Rent()
{
int slot;
if (_returned.Count != 0)
{
slot = _returned.Pop();
}
else
{
if (_next == _rented.Length)
throw new InvalidOperationException(
$"Texture atlas has no GPU-safe layer available ({_rented.Length} layers)."
);
slot = _next++;
}
if (_rented[slot])
throw new InvalidOperationException($"Texture atlas layer {slot} was rented twice.");
_rented[slot] = true;
return slot;
}
public void Return(int slot)
{
ArgumentOutOfRangeException.ThrowIfNegative(slot);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(slot, _next);
if (!_rented[slot])
throw new InvalidOperationException($"Texture atlas layer {slot} was returned twice.");
_rented[slot] = false;
_returned.Push(slot);
}
}

View file

@ -0,0 +1,242 @@
using Silk.NET.OpenGL;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Always-on transaction boundary and accounting for raw dynamic GL objects.
/// Growth keeps the published CPU capacity unchanged until BufferData succeeds;
/// OpenGL leaves the previous data store intact when allocation reports an
/// error, so the caller can continue using the old capacity or unwind.
/// </summary>
internal static unsafe class TrackedGlResource
{
public static RetryableGpuResourceRelease CreateRetryableBufferDeletion(
GL gl,
uint buffer,
long capacityBytes,
string context)
{
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteBuffer(buffer);
// Per the GL error contract, a command which generates an
// error does not change object state. Keep mutation and its
// validation in one retryable stage so that failed deletion
// is issued again, while later accounting remains untouched.
GLHelpers.ThrowOnResourceError(gl, context);
},
() =>
{
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
},
() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer));
}
public static RetryableGpuResourceRelease CreateRetryableVertexArrayDeletion(
GL gl,
uint vertexArray,
string context,
Action? trackDeallocation = null)
{
if (vertexArray == 0)
throw new ArgumentOutOfRangeException(nameof(vertexArray));
return new RetryableGpuResourceRelease(
() => GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)"),
() =>
{
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
},
trackDeallocation
?? (() => GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO)));
}
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
context);
public static void AllocateBufferStorage(
GL gl,
BufferTargetARB target,
uint buffer,
long previousBytes,
long newBytes,
BufferUsageARB usage,
void* data,
string context)
=> AllocateBufferStorage(
gl,
(GLEnum)target,
buffer,
previousBytes,
newBytes,
(GLEnum)usage,
data,
context);
public static uint CreateBuffer(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenBuffer();
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned buffer name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
return name;
}
catch
{
if (name != 0)
gl.DeleteBuffer(name);
throw;
}
}
public static uint CreateVertexArray(GL gl, string context)
{
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
uint name = gl.GenVertexArray();
try
{
if (name == 0)
throw new InvalidOperationException($"OpenGL returned vertex-array name zero. Context: {context}");
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.VAO);
return name;
}
catch
{
if (name != 0)
gl.DeleteVertexArray(name);
throw;
}
}
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
string context)
=> AllocateBufferStorage(
gl,
target,
buffer,
previousBytes,
newBytes,
usage,
null,
context);
public static void AllocateBufferStorage(
GL gl,
GLEnum target,
uint buffer,
long previousBytes,
long newBytes,
GLEnum usage,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(previousBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(newBytes, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferData(target, checked((nuint)newBytes), data, usage);
GLHelpers.ThrowOnResourceError(gl, context);
long delta = checked(newBytes - previousBytes);
if (delta > 0)
GpuMemoryTracker.TrackAllocation(delta, GpuResourceType.Buffer);
else if (delta < 0)
GpuMemoryTracker.TrackDeallocation(-delta, GpuResourceType.Buffer);
}
public static void UpdateBufferSubData(
GL gl,
BufferTargetARB target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
=> UpdateBufferSubData(
gl,
(GLEnum)target,
buffer,
byteOffset,
byteCount,
data,
context);
public static void UpdateBufferSubData(
GL gl,
GLEnum target,
uint buffer,
nint byteOffset,
long byteCount,
void* data,
string context)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteOffset);
ArgumentOutOfRangeException.ThrowIfLessThan(byteCount, 1);
if (buffer == 0)
throw new ArgumentOutOfRangeException(nameof(buffer));
ArgumentNullException.ThrowIfNull(data);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.BindBuffer(target, buffer);
gl.BufferSubData(target, byteOffset, checked((nuint)byteCount), data);
GLHelpers.ThrowOnResourceError(gl, context);
}
public static void DeleteBuffer(GL gl, uint buffer, long capacityBytes, string context)
{
if (buffer == 0)
return;
ArgumentOutOfRangeException.ThrowIfNegative(capacityBytes);
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteBuffer(buffer);
GLHelpers.ThrowOnResourceError(gl, context);
if (capacityBytes != 0)
GpuMemoryTracker.TrackDeallocation(capacityBytes, GpuResourceType.Buffer);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
}
public static void DeleteVertexArray(GL gl, uint vertexArray, string context)
{
if (vertexArray == 0)
return;
GLHelpers.ThrowOnResourceError(gl, $"{context} (precondition)");
gl.DeleteVertexArray(vertexArray);
GLHelpers.ThrowOnResourceError(gl, context);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.VAO);
}
}

File diff suppressed because it is too large Load diff

View file

@ -17,18 +17,56 @@ namespace AcDream.App.Rendering.Wb;
/// so the rest of the renderer doesn't need to know about WB's types directly.
///
/// <para>
/// As of Phase O-T7, all DAT I/O routes through <see cref="DatCollectionAdapter"/>
/// (backed by our shared <see cref="DatCollection"/>) — the separate
/// As of Phase O-T7, all DAT I/O routes through the runtime-owned shared
/// <see cref="IDatReaderWriter"/> facade — the separate
/// <c>DefaultDatReaderWriter</c> file-handle set has been removed.
/// </para>
/// </summary>
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
{
internal const int MaximumUploadsPerFrame = 8;
internal const long MaximumUploadBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumArrayAllocationBytesPerFrame = 8L * 1024 * 1024;
internal const long MaximumMipmapBytesPerFrame = 8L * 1024 * 1024;
internal const int MaximumNewArraysPerFrame = 1;
internal const long MaximumBufferUploadBytesPerFrame = 8L * 1024 * 1024;
// A GL buffer store is indivisible. The active vertex arena's explicit
// supported ceiling is therefore the truthful one-allocation bound; only
// its prefix copy is staged across frames.
internal const long MaximumBufferAllocationBytesPerFrame =
GlobalMeshBuffer.MaximumVertexBufferBytes;
internal const long MaximumBufferCopyBytesPerFrame = 32L * 1024 * 1024;
internal const int MaximumNewBuffersPerFrame = 1;
internal const long MaximumSingleUploadBytes = 128L * 1024 * 1024;
internal const long MaximumSingleArrayAllocationBytes = 128L * 1024 * 1024;
internal const long MaximumSingleMipmapBytes = 128L * 1024 * 1024;
internal const int MaximumSingleNewArrays = 16;
internal const long MaximumSingleBufferUploadBytes = 32L * 1024 * 1024;
internal const int MaximumReclaimedMeshesPerFrame = MaximumUploadsPerFrame;
internal const long MaximumReclaimedMeshBytesPerFrame = 64L * 1024 * 1024;
internal const int MaximumStaleDiscardsPerFrame = 64;
private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly ObjectMeshManager? _meshManager;
private readonly DatCollection? _dats;
private readonly AcDream.App.Rendering.IGpuResourceRetirementQueue? _resourceRetirement;
private readonly IDatReaderWriter? _dats;
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new();
private readonly MeshUploadFrameBudget _uploadBudget = new(new MeshUploadBudgetLimits(
MaximumUploadsPerFrame,
MaximumUploadBytesPerFrame,
MaximumArrayAllocationBytesPerFrame,
MaximumMipmapBytesPerFrame,
MaximumNewArraysPerFrame,
MaximumBufferUploadBytesPerFrame,
MaximumBufferAllocationBytesPerFrame,
MaximumBufferCopyBytesPerFrame,
MaximumNewBuffersPerFrame,
MaximumSingleUploadBytes,
MaximumSingleArrayAllocationBytes,
MaximumSingleMipmapBytes,
MaximumSingleNewArrays,
MaximumSingleBufferUploadBytes));
private readonly HashSet<TextureAtlasManager> _mipmapsBudgeted = new();
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
@ -37,32 +75,64 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly bool _isUninitialized;
private bool _disposed;
private AcDream.App.Rendering.OrderedResourceTeardown? _teardown;
internal int LastUploadCount { get; private set; }
internal long LastUploadBytes { get; private set; }
internal int LastStaleDiscardCount { get; private set; }
internal long LastArrayAllocationBytes { get; private set; }
internal long LastPlannedMipmapBytes { get; private set; }
internal int LastNewArrayCount { get; private set; }
internal long LastBufferUploadBytes { get; private set; }
internal long LastBufferAllocationBytes { get; private set; }
internal long LastBufferCopyBytes { get; private set; }
internal int LastNewBufferCount { get; private set; }
internal int LastMipmapArrayCount { get; private set; }
internal long LastMipmapBytes { get; private set; }
internal int StagedUploadBacklog => _meshManager?.StagedMeshCount ?? 0;
internal long StagedUploadBytes => _meshManager?.StagedMeshBytes ?? 0;
internal bool StagingAtHighWater => _meshManager?.StagingAtHighWater ?? false;
internal (int Count, long Bytes) CpuMeshCacheDiagnostics =>
_meshManager?.CpuCacheDiagnostics ?? default;
/// <summary>
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DatCollectionAdapter
/// → ObjectMeshManager.
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → shared bounded
/// DAT facade → ObjectMeshManager.
/// </summary>
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface
/// <param name="dats">acdream's shared runtime DAT facade, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
public WbMeshAdapter(GL gl, DatCollection dats, ILogger<WbMeshAdapter> logger)
public WbMeshAdapter(GL gl, IDatReaderWriter dats, ILogger<WbMeshAdapter> logger)
: this(gl, dats, logger, AcDream.App.Rendering.ImmediateGpuResourceRetirementQueue.Instance)
{
}
internal WbMeshAdapter(
GL gl,
IDatReaderWriter dats,
ILogger<WbMeshAdapter> logger,
AcDream.App.Rendering.IGpuResourceRetirementQueue resourceRetirement)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
_dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_resourceRetirement = resourceRetirement;
_graphicsDevice = new OpenGLGraphicsDevice(
gl,
logger,
new DebugRenderSettings(),
resourceRetirement);
_graphicsDevice.ParticleBatcher = new ParticleBatcher(_graphicsDevice);
// ConsoleErrorLogger surfaces WB's silently-caught exceptions
// (ObjectMeshManager.PrepareMeshData try/catch at line ~589).
_meshManager = new ObjectMeshManager(
_graphicsDevice,
new DatCollectionAdapter(dats),
dats,
new ConsoleErrorLogger<ObjectMeshManager>());
}
@ -157,7 +227,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// An uninitialized adapter owns no render pipeline, so it must not
// manufacture a readiness wait that can never complete. The modern
// production path is always initialized at startup.
return _isUninitialized || _meshManager?.TryGetRenderData(id) is not null;
return _isUninitialized || (_meshManager?.EnsureRenderDataReady(id) ?? false);
}
/// <inheritdoc/>
@ -166,9 +236,12 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
bool firstEver = _metadataPopulated.Add(id);
if (firstEver)
PopulateMetadata(id);
bool metadataClaimed = false;
try
{
metadataClaimed = _metadataPopulated.Add(id);
if (metadataClaimed)
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
@ -197,8 +270,37 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
if (firstEver || _meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
if (metadataClaimed || _meshManager.TryGetRenderData(id) is null)
_meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
catch (Exception acquireFailure)
{
if (metadataClaimed)
{
_metadataPopulated.Remove(id);
_metadataTable.Remove(id);
}
// IncrementRefCount is a public ownership boundary. Metadata/DAT
// preparation happens after ObjectMeshManager commits its count,
// so compensate before reporting failure. ObjectMeshManager's
// decrement has a strong exception guarantee; if compensation
// itself fails, expose that the increment remains committed so a
// higher-level transactional owner can retain its held marker.
try
{
_meshManager.DecrementRefCount(id);
}
catch (Exception rollbackFailure)
{
throw new MeshReferenceMutationException(
$"Mesh 0x{id:X10} reference acquisition failed and its committed increment could not be rolled back.",
mutationCommitted: true,
new AggregateException(acquireFailure, rollbackFailure));
}
throw;
}
}
/// <inheritdoc/>
@ -263,6 +365,7 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
if (_isUninitialized) return;
if (_disposed) return;
ObjectMeshManager meshManager = _meshManager!;
_graphicsDevice!.ProcessGLQueue();
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
// null from its catch) is re-staged for a LATER frame, not dropped. The
@ -270,43 +373,190 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// inside the while would let a deterministic failure spin the queue in a
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
// a genuine defect surfaces loudly instead of the old silent sticky drop.
List<ObjectMeshData>? requeue = null;
while (_meshManager!.TryDequeueStagedMeshData(out var meshData))
List<MeshUploadQueueItem>? requeue = null;
_uploadBudget.Reset();
_mipmapsBudgeted.Clear();
int staleDiscardCount = 0;
bool arenaBackpressured = false;
GlobalMeshBuffer? globalBuffer = meshManager.GlobalBuffer;
// A backing-store migration is real GL work, not a cost estimate. One
// bounded copy chunk runs per frame while the old VAO remains active.
// Uploads stay queued until the atomic final swap has completed.
if (globalBuffer?.IsMigrationInProgress == true)
{
if (meshData is null)
continue;
if (_meshManager.UploadOrRequeue(meshData))
GlobalMeshMaintenanceStep maintenance =
globalBuffer.AdvanceMigration(MaximumBufferCopyBytesPerFrame);
_uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
arenaBackpressured = !maintenance.Completed;
}
while (globalBuffer?.IsMigrationInProgress != true
&& _uploadBudget.BufferCopyBytes == 0
&& _uploadBudget.ObjectCount < MaximumUploadsPerFrame)
{
staleDiscardCount += meshManager.DiscardUnownedStagedPrefix(
MaximumStaleDiscardsPerFrame - staleDiscardCount);
if (staleDiscardCount >= MaximumStaleDiscardsPerFrame)
break;
if (!meshManager.TryPeekStagedMeshData(out MeshUploadQueueItem next))
break;
GlobalMeshCapacityResult capacity;
GlobalMeshMaintenanceStep maintenance;
try
{
capacity = meshManager.EnsureGlobalBufferCapacity(next, out maintenance);
}
catch (NotSupportedException error)
{
RejectUnsupportedHead(meshManager, next, error);
throw;
}
if (capacity == GlobalMeshCapacityResult.MigrationStarted)
{
_uploadBudget.RecordBufferMaintenance(
maintenance.AllocationBytes,
maintenance.CopyBytes,
maintenance.NewBufferCount);
arenaBackpressured = true;
break;
}
if (capacity == GlobalMeshCapacityResult.MigrationInProgress)
{
arenaBackpressured = true;
break;
}
if (capacity == GlobalMeshCapacityResult.NeedsReclamation)
{
// Do not evict another batch while the frame-fence queue is
// already returning ranges or retiring an old backing store.
// Waiting for real allocator state prevents three frames of
// duplicate eviction before the first release becomes reusable.
if (globalBuffer?.HasPendingReclamation != true)
{
var reclaimed = meshManager.ReclaimUnusedResources(
MaximumReclaimedMeshesPerFrame,
MaximumReclaimedMeshBytesPerFrame,
forceArenaReclamation: true);
if (reclaimed.Count == 0)
{
throw new NotSupportedException(
$"The live global-mesh working set cannot fit the supported "
+ $"{GlobalMeshBuffer.MaximumVertexBufferBytes + GlobalMeshBuffer.MaximumIndexBufferBytes:N0}-byte arena "
+ $"(blocked staging generation {next.Generation}, object 0x{next.Data.ObjectId:X10}).");
}
}
arenaBackpressured = true;
break;
}
MeshUploadCost cost;
try
{
cost = meshManager.PlanUploadCost(
next.Data,
_mipmapsBudgeted,
next.Generation);
if (!_uploadBudget.TryAdmit(cost))
break;
}
catch (NotSupportedException error)
{
RejectUnsupportedHead(meshManager, next, error);
throw;
}
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem meshData))
break;
if (meshData.Generation != next.Generation)
throw new InvalidOperationException("The staged mesh FIFO head changed during render-thread admission.");
if (meshManager.UploadOrRequeue(meshData))
(requeue ??= new()).Add(meshData);
meshManager.AddDirtyAtlasesTo(_mipmapsBudgeted);
}
if (requeue is not null)
foreach (var m in requeue)
_meshManager.RequeueStagedMeshData(m);
meshManager.RequeueStagedMeshData(m);
meshManager.SetArenaBackpressure(arenaBackpressured);
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
var pendingBefore = texProbe
? _meshManager.GetPendingTextureUpdateStats()
? meshManager.GetPendingTextureUpdateStats()
: default;
// #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES
// texture content (PBO write + ManagedGLTextureArray._pendingUpdates) — the
// immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the
// actual TexSubImage3D copies + mipmap regeneration happen in
// ProcessDirtyUpdates, which WB drives ONCE PER FRAME from its render loop
// (WB GameScene.cs:975 `_meshManager?.GenerateMipmaps()`, just before the
// opaque pass). That call site lived in the GameScene file the N.4/O-T4
// extraction replaced with GameWindow, so the driver was silently dropped:
// staged updates only ever reached the GPU as a side effect of PBO growth,
// and every layer staged after an array's LAST growth kept undefined
// TexStorage3D content behind a valid resident bindless handle — the
// staged updates never reached TexSubImage3D, leaving undefined
// TexStorage3D content behind valid resident bindless handles — the
// intermittent white indoor walls (#105). Pre-fix evidence: 126 updates
// stuck across 34/34 arrays at standstill (texflush-prefix.log). Tick()
// runs before all draw passes (GameWindow OnRender), so this is the exact
// WB-equivalent position.
_meshManager.GenerateMipmaps();
(LastMipmapArrayCount, LastMipmapBytes) = meshManager.GenerateMipmaps();
if (globalBuffer?.HasPendingReclamation != true)
{
meshManager.ReclaimUnusedResources(
MaximumReclaimedMeshesPerFrame,
MaximumReclaimedMeshBytesPerFrame);
}
meshManager.EvictOneEmptyAtlas();
// Arena maintenance is capacity-driven and uses genuinely idle upload
// frames. It never competes with destination materialization, and the
// GlobalMeshBuffer policy retains enough hysteresis that portal-route
// revisits do not alternate shrink/grow every frame.
if (_uploadBudget.ObjectCount == 0
&& LastMipmapArrayCount == 0
&& meshManager.StagedMeshCount == 0
&& globalBuffer?.IsMigrationInProgress == false
&& globalBuffer.TryTrimUnusedTail(out GlobalMeshMaintenanceStep trim))
{
_uploadBudget.RecordBufferMaintenance(
trim.AllocationBytes,
trim.CopyBytes,
trim.NewBufferCount);
meshManager.SetArenaBackpressure(true);
}
LastUploadCount = _uploadBudget.ObjectCount;
LastUploadBytes = _uploadBudget.SourceBytes;
LastStaleDiscardCount = staleDiscardCount;
LastArrayAllocationBytes = _uploadBudget.ArrayAllocationBytes;
LastPlannedMipmapBytes = _uploadBudget.MipmapBytes;
LastNewArrayCount = _uploadBudget.NewArrayCount;
LastBufferUploadBytes = _uploadBudget.BufferUploadBytes;
LastBufferAllocationBytes = _uploadBudget.BufferAllocationBytes;
LastBufferCopyBytes = _uploadBudget.BufferCopyBytes;
LastNewBufferCount = _uploadBudget.NewBufferCount;
if (texProbe)
EmitTexFlushProbe(pendingBefore);
}
private static void RejectUnsupportedHead(
ObjectMeshManager meshManager,
MeshUploadQueueItem expected,
NotSupportedException error)
{
if (!meshManager.TryDequeueStagedMeshData(out MeshUploadQueueItem rejected)
|| rejected.Generation != expected.Generation)
{
throw new InvalidOperationException(
"The staged mesh FIFO head changed while rejecting an unsupported generation.",
error);
}
meshManager.RejectUnsupportedStagedUpload(rejected, error);
}
// #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled.
private int _lastTexFlushBefore = -1;
private int _texFlushHeartbeat;
@ -351,9 +601,46 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_meshManager?.Dispose();
_graphicsDevice?.Dispose();
if (_disposed)
return;
// These are dependency edges, not merely a best-effort cleanup list.
// In particular, the sampler objects owned by the device must remain
// alive until every resident texture-array handle has been retired.
_teardown ??= new AcDream.App.Rendering.OrderedResourceTeardown(
() =>
{
// The current global arena is still directly owned by the mesh
// manager. Fence every submitted draw before manager teardown
// can delete that arena's VAO/VBO/IBO.
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => _meshManager?.Dispose(),
() => DrainGraphicsQueue("publishing mesh resource retirements"),
() =>
{
if (_resourceRetirement is AcDream.App.Rendering.GpuFrameFlightController frameFlights)
frameFlights.WaitForSubmittedWork();
},
() => DrainGraphicsQueue("releasing retired mesh resources"),
() => _graphicsDevice?.Dispose(),
() => DrainGraphicsQueue("deleting mesh graphics-device resources"));
_teardown.Advance();
_disposed = _teardown.IsComplete;
}
private void DrainGraphicsQueue(string operation)
{
if (_graphicsDevice is null)
return;
_graphicsDevice.ProcessGLQueue();
if (_graphicsDevice.HasPendingGLWork)
{
throw new InvalidOperationException(
$"OpenGL work remains pending after {operation}; retry adapter disposal to continue the exact stage.");
}
}
}

View file

@ -0,0 +1,29 @@
namespace AcDream.App.Rendering.Wb;
internal enum WbTextureResolutionKind : byte
{
SharedAtlas,
OriginalTextureOverride,
PaletteComposite,
}
/// <summary>
/// Selects the retail texture ownership path for one rendered surface.
/// Retail creates a palette-combined <c>ImgTex</c> only for indexed source
/// images; ordinary surfaces continue to reference their shared image.
/// </summary>
internal static class WbTextureResolutionPolicy
{
public static WbTextureResolutionKind Select(
bool hasOriginalTextureOverride,
bool hasPaletteOverride,
bool sourceIsPaletteIndexed)
{
if (hasPaletteOverride && sourceIsPaletteIndexed)
return WbTextureResolutionKind.PaletteComposite;
return hasOriginalTextureOverride
? WbTextureResolutionKind.OriginalTextureOverride
: WbTextureResolutionKind.SharedAtlas;
}
}