acdream/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
Erik 7a0227c12e feat(render): Vulkan campaign V11 step 3 — drop the GL packages and shaders
Commit 2 deleted the GL rendering backend's implementations; this step
removes the package references and shader vocabulary they leave behind,
so nothing in the App project still spells Silk.NET.OpenGL.

Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from
AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its
Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are
used directly and extensively across the Wb texture/mesh pipeline,
independent of the deleted GL IUniformBuffer implementers the package
comment used to cite. The stale comment is corrected in place.

IMeshPipelineDevice.Gl is removed along with the GL? gl parameter
threaded through WbMeshAdapter's four constructors, WorldRenderComposition's
CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null
implementation — nothing read any of them once the legacy per-mesh
upload bodies were gone (confirmed by grep: the sole non-doc-comment hit
was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed
a real bug along the way: its teardown still pattern-matched the deleted
GL GpuFrameFlightController to decide whether to wait for submitted work,
which VulkanFrameFlightController replaced at slice V6a without this site
being updated — so the wait had been silently dead on every Vulkan run
since then. Retargeted to VulkanFrameFlightController, which carries the
same WaitForSubmittedWork().

The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that
WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for
upload validation is replaced by AcDream.Content's existing Silk.NET-free
UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake
tool GL-free); two new members (Rgb, Red, Float) extend that enum with
their GL ABI constants to cover the full vocabulary WorldTextureArray
needs, since MP1a's original set only covered what the extractor itself
emits. ObjectMeshManager's App-boundary cast
`(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct
pass-through now that both sides share the type.

GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of
the Vulkan texture table) is deleted and StorageBindingCount drops from
10 to 9; the descriptor-set-layout code that builds from that count
(VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just
allocates one fewer always-dummy-seeded, always-unused binding.

Several fully dead GL-only classes came along for the ride, confirmed by
zero construction sites: SilkFramebufferViewportTarget
(NullFramebufferViewportTarget is the sole production
IFramebufferViewportTarget), SilkRenderGlStateReader
(NullRenderGlStateReader.Instance is the sole IRenderGlStateReader),
RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole
IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a
pass load-op instead), and GpuFrameTimer plus FrameProfiler's
GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame
bracket (RecordGpuSample is the only GPU-timing path any backend uses
now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no
longer applies, since WbDrawDispatcher's own diagnostic GPU sampling
already moved to the device's Vulkan timer pool). GpuFrameFlightController
itself stays (never constructed with a real fence API in production, but
its retirement-ledger/serial-ring logic is backend-neutral and still
covered by its own unit tests) — only its GL-specific parts (the public
GL constructor overload, SilkGpuFenceApi) are deleted, since removing the
whole class would mean restructuring the frozen Slice-8 composition
shape's GpuFrameFlightController? threading, which is out of this
commit's scope. TextureParameters.cs and BufferUsageExtensions.cs
(zero callers each) are deleted outright.

common.glsl is deleted: nothing in the actual Vulkan .spv build reads
it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair
directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own
complete self-contained preamble per file; common.glsl's textual
concatenation was exclusively Shader.cs's GL-only mechanism, deleted at
Commit 2. The five shader files that named it in comments
(mesh_modern.vert, particle.vert, particle.frag, sky.frag,
terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs
instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the
mandatory modern path already made unreachable, with zero C# consumers
and no compiled .spv — are deleted too. Regenerated via
tools/compile-shaders.ps1: 9/9 remaining shader pairs compile
(previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests
doc comment's "nine of ten are not Vulkan-expressible" was already
stale before this commit).

Test fallout: dead-subject test methods/files are deleted rather than
patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs,
GpuResourceRetirementTransactionTests.cs's GL queue tests, one
WorldRenderDiagnosticsTests source-order test, one
RenderFrameResourceControllerTests clear-phase-order test); tests whose
subject moved or was renamed are updated in place rather than deleted
(GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests'
pinned seven-member surface now reads six, ParticleBindlessInstanceTests'
cross-dialect check now covers the one surviving dialect,
WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was
always the parameter that actually threw).

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors,
with the Silk.NET.OpenGL/.Extensions.ARB package references physically
removed from the csproj (not just unreferenced in code).
Tests: full-solution `dotnet test` green across every project.
Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:58:15 +02:00

2718 lines
117 KiB
C#

using Chorizite.Core.Lib;
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using CullMode = DatReaderWriter.Enums.CullMode;
using DatReaderWriter.Types;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using AcDream.Content;
using AcDream.App.Rendering.Residency;
using AcDream.Core.Rendering.Wb;
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
namespace AcDream.App.Rendering.Wb
{
/// <summary>
/// GPU-side render data created on the main thread.
/// </summary>
public class ObjectRenderData
{
/// <summary>
/// Campaign V slice V11 deleted the per-mesh raw-GL vertex array/buffer
/// this used to carry — Vulkan bakes vertex input into the pipeline and
/// the shared <see cref="GlobalMeshBuffer"/> arena has no VAO/VBO
/// concept at all (see <see cref="GlobalMeshBuffer.VertexStore"/>). This
/// is always 0 now; it survives only because
/// <c>WbDrawDispatcher.cs</c>'s legacy (non-RHI) dispatcher still reads
/// it into its own dead <c>anyVao</c> bookkeeping.
/// </summary>
public uint VAO { get; set; }
/// <summary>See <see cref="VAO"/> — always 0 for the same reason.</summary>
public uint VBO { get; set; }
public int VertexCount { get; set; }
public List<ObjectRenderBatch> Batches { get; set; } = new();
internal GlobalMeshAllocation? GlobalAllocation { get; set; }
public bool IsSetup { get; set; }
public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new();
/// <summary>Particle emitters from physics scripts.</summary>
public List<StagedEmitter> ParticleEmitters { get; set; } = new();
/// <summary>CPU-side vertex positions for raycasting.</summary>
public Vector3[] CPUPositions { get; set; } = Array.Empty<Vector3>();
/// <summary>CPU-side indices for raycasting.</summary>
public ushort[] CPUIndices { get; set; } = Array.Empty<ushort>();
/// <summary>CPU-side edge line vertices for Environment wireframe rendering.</summary>
public Vector3[] CPUEdgeLines { get; set; } = Array.Empty<Vector3>();
/// <summary>Local bounding box.</summary>
public BoundingBox BoundingBox { get; set; }
/// <summary>Approximate center point used for depth sorting / transparency ordering.</summary>
public Vector3 SortCenter { get; set; }
/// <summary>DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo.</summary>
public uint DIDDegrade { get; set; }
/// <summary>Sphere used for mouse selection.</summary>
public Sphere? SelectionSphere { get; set; }
/// <summary>Estimated GPU memory usage in bytes.</summary>
public long MemorySize { get; set; }
/// <summary>
/// Physical bytes owned outside <see cref="GlobalMeshBuffer"/>. Modern
/// vertex/index ranges are deliberately excluded because the arena's
/// backing-store capacity is accounted once at its owner.
/// </summary>
internal long NonArenaGpuBytes { get; set; }
}
/// <summary>
/// A single GPU draw batch: IBO + texture array layer.
/// </summary>
public class ObjectRenderBatch
{
/// <summary>See <see cref="ObjectRenderData.VAO"/> — Campaign V slice
/// V11 deleted the legacy per-batch raw-GL index buffer this used to
/// carry. Always 0 now; every batch's actual index range lives in the
/// shared arena via <see cref="FirstIndex"/>/<see cref="BaseVertex"/>.
/// </summary>
public uint IBO { get; set; }
public int IndexCount { get; set; }
public TextureAtlasManager Atlas { get; set; } = null!;
public int TextureIndex { get; set; }
public (int Width, int Height) TextureSize { get; set; }
public TextureFormat TextureFormat { get; set; }
public uint SurfaceId { get; set; }
public TextureKey Key { get; set; }
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public AcDream.Core.Meshing.TranslucencyKind Translucency { get; set; }
public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; }
// Modern rendering path fields
public uint FirstIndex { get; set; }
public uint BaseVertex { get; set; }
/// <summary>
/// Campaign V slice V4t: the shared atlas's entry in the device texture
/// table, replacing the raw 64-bit <c>ARB_bindless_texture</c> handle
/// this used to carry. Wrap and clamp addressing are two different
/// entries because a bindless handle bakes its sampler — see
/// <see cref="HasWrappingUVs"/>, which selects between them.
///
/// <para><c>internal</c> on an otherwise public type because
/// <c>GpuTextureSlot</c> belongs to the internal RHI contract. Every
/// reader is a renderer inside this assembly.</para>
/// </summary>
internal AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot { get; set; }
= AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
}
/// <summary>
/// Manages scenery mesh loading, GPU resource creation, and reference counting.
/// Key design: mesh data is prepared on background threads via PrepareMeshData(),
/// then GPU resources are created on the main thread via UploadMeshData().
/// </summary>
public class ObjectMeshManager : IDisposable
{
/// <summary>
/// Campaign V slice V6i-2: the graphics device, no longer named by
/// backend. See <see cref="IMeshPipelineDevice"/> for the measured
/// surface this class actually needs, and for what still has to move
/// before there is a second implementation of it.
/// </summary>
private readonly IMeshPipelineDevice _graphicsDevice;
private readonly IPreparedAssetSource _preparedAssets;
private readonly ILogger _logger;
/// <summary>
/// Campaign V slice V4t: the GL backend's device, whose texture table
/// every shared atlas's bindless handle is interned into. Also the
/// device the two world renderers this class feeds
/// (<c>WbDrawDispatcher</c>, <c>EnvCellRenderer</c>) flush and bind
/// through — they take it from here rather than from a second
/// composition wire, because a batch's slot and the table that resolves
/// it must come from the same device by construction.
/// </summary>
private readonly AcDream.App.Rendering.Gpu.IGpuDevice _gpuDevice;
/// <summary>
/// Campaign V slice V6i-2: how a shared atlas's physical array is made.
/// Composed once; see <see cref="IWorldTextureArrayFactory"/>.
/// </summary>
private readonly IWorldTextureArrayFactory _atlasArrays;
/// <summary>
/// The immutable prepared-payload source is injected by composition.
/// Production uses the validated pak; UI Studio explicitly supplies the
/// live-DAT tooling adapter. This class owns queue/worker lifecycle,
/// bounded CPU staging, and all GL upload.
/// </summary>
public bool IsDisposed { get; private set; }
private readonly object _disposeGate = new();
private bool _disposeCompleted;
private bool _disposeRunning;
private bool _workersQuiesced;
private bool _workSignalDisposed;
private readonly ConcurrentDictionary<ulong, ObjectRenderData> _renderData = new();
// Bumped whenever TryGetRenderData's answer can change for any id
// (publish, pending-release hide, release completion, teardown).
// EnvCellRenderer's prepare gate keys its visibility snapshot on this:
// the snapshot bakes per-cell transparency from TryGetRenderData, so a
// skipped rebuild must be provably input-identical.
private long _renderDataAvailabilityVersion;
// A render-data entry remains published until every one of its physical
// resources has either released or reported a committed exceptional
// outcome. Accessors hide entries in this map because a partially
// retired mesh is no longer drawable, while retaining the entry keeps
// the unfinished resources reachable for an exact later retry.
private readonly Dictionary<ulong, ObjectReleaseTicket> _objectReleases = new();
private readonly Queue<ulong> _objectReleaseQueue = new();
// Failed upload rollback owns resources which were never published.
// Keep its per-resource ledger by object id so the bounded upload retry
// cannot allocate another copy until the prior rollback converges.
private readonly Dictionary<ulong, RetryableResourceReleaseLedger> _uploadRollbacks = new();
private readonly Queue<ulong> _uploadRollbackQueue = new();
private readonly MeshOwnershipCounter _ownership = new();
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
// LRU Cache for Unused objects
private readonly LinkedList<ulong> _lruList = new();
private readonly long _maxGpuMemory;
private readonly int _maxCachedObjects;
private long _currentNonArenaGpuMemory;
// Shared atlases grouped by (Width, Height, Format)
private readonly Dictionary<(int Width, int Height, TextureFormat Format), List<TextureAtlasManager>> _globalAtlases = new();
// Render-thread-owned set of arrays whose base layer changed since the
// last flush. Walking every atlas every frame (and after every uploaded
// object) made steady-state CPU cost grow with every area ever visited.
private readonly HashSet<TextureAtlasManager> _dirtyAtlases = new();
private long _atlasUseSequence;
private const long RetainedEmptyAtlasBudgetBytes = 64L * 1024 * 1024;
private const int RetainedEmptyAtlasCountLimit = 32;
private readonly AcDream.App.Rendering.BoundedUnownedResourceCache<TextureAtlasManager>
_safeEmptyAtlases = new(RetainedEmptyAtlasBudgetBytes, RetainedEmptyAtlasCountLimit);
// Arrays removed from the reusable atlas families remain physical GPU
// allocations until their frame-fenced release completes. Retaining
// the owners here makes that overlap both retryable and observable.
private readonly List<TextureAtlasManager> _retiringAtlases = [];
// Campaign V slice V4t recorded the two bindless handles a retiring
// atlas held so its table entries could be released once physical
// retirement completed. Slice V6i-2 moved that bookkeeping into the
// array itself — a 64-bit ARB_bindless_texture handle has no Vulkan
// spelling, so the array answers IWorldTextureArray.ReleaseTextureSlots
// and each implementation snapshots whatever it needs. The retiring set
// still carries the owners, which is what makes the release retryable.
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
private readonly CpuMeshUploadCache _cpuMeshCache;
private readonly MeshUploadStagingQueue _stagedMeshData;
private volatile bool _arenaBackpressured;
/// <summary>#125: how many times a failed GL upload is re-staged before
/// giving up loudly. Small — a transient GL error clears on the next
/// frame; anything that fails this many times is a genuine defect to
/// surface, not retry forever. See <see cref="ObjectMeshData.UploadAttempts"/>.</summary>
public const int MaxUploadRetries = 3;
/// <summary>
/// #125: drain one staged upload, returning whether it should be
/// re-staged for a later frame. The caller (the per-frame Tick drain)
/// collects the re-stages and re-enqueues them AFTER the drain loop —
/// never inside it — so a deterministic failure can't spin the queue in
/// a single frame. <see cref="UploadMeshData"/> increments the mesh
/// data's own counter only when new upload work actually starts (not
/// while a prior rollback waits); this drain gives up loudly past
/// <see cref="MaxUploadRetries"/>.
/// </summary>
internal bool UploadOrRequeue(MeshUploadQueueItem item)
{
ObjectMeshData meshData = item.Data;
if (!_ownership.IsOwned(meshData.ObjectId))
{
_stagedMeshData.CompleteOrRestageIfOwned(item, _ownership);
return false;
}
if (UploadMeshData(meshData) is not null)
{
_stagedMeshData.Complete(item);
return false; // success (incl. legitimate 0-vertex → empty render data)
}
if (HasRenderData(meshData.ObjectId))
{
_stagedMeshData.Complete(item);
return false; // raced to present by another path
}
if (_objectReleases.ContainsKey(meshData.ObjectId)
|| _uploadRollbacks.ContainsKey(meshData.ObjectId))
{
// Cleanup is a separately owned transaction, not another GL
// upload attempt. Keep this generation staged while its exact
// old resources retry one bounded pass per frame.
return true;
}
if (meshData.UploadAttempts < MaxUploadRetries)
return true; // re-stage for next frame
_stagedMeshData.Complete(item);
Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)");
return false;
}
internal bool TryDequeueStagedMeshData(out MeshUploadQueueItem item) =>
_stagedMeshData.TryDequeue(out item);
internal bool TryPeekStagedMeshData(out MeshUploadQueueItem item) =>
_stagedMeshData.TryPeek(out item);
internal int StagedMeshCount => _stagedMeshData.ClaimCount;
internal long StagedMeshBytes => _stagedMeshData.ClaimedBytes;
internal bool StagingAtHighWater => _stagedMeshData.IsAtHighWater;
internal int DiscardUnownedStagedPrefix(int maximum) =>
_stagedMeshData.DiscardUnownedPrefix(_ownership, maximum);
internal bool IsOwned(ulong id) => _ownership.IsOwned(id);
internal void RequeueStagedMeshData(MeshUploadQueueItem item) =>
_stagedMeshData.Requeue(item);
internal void RejectUnsupportedStagedUpload(
MeshUploadQueueItem item,
NotSupportedException error)
{
ArgumentNullException.ThrowIfNull(error);
_stagedMeshData.Complete(item);
lock (_pendingRequests)
_terminalPreparationFailures.Add(item.Data.ObjectId);
_logger.LogError(
error,
"Mesh 0x{Id:X10} generation {Generation} exceeds an explicit GPU upload limit",
item.Data.ObjectId,
item.Generation);
}
internal void SetArenaBackpressure(bool enabled)
{
_arenaBackpressured = enabled;
if (!enabled)
ResumePreparationWorkers();
}
public GlobalMeshBuffer? GlobalBuffer { get; }
internal (int RenderData, int AtlasArrays, int UnusedLru, long EstimatedBytes) Diagnostics
{
get
{
int atlasArrays = 0;
foreach (List<TextureAtlasManager> atlases in _globalAtlases.Values)
atlasArrays += atlases.Count;
long atlasBytes = CalculateAtlasBytes(_globalAtlases.Values);
long retiringAtlasBytes = CalculateAtlasBytes(_retiringAtlases);
long physicalBytes = CalculateTrackedGpuBytes(
checked(
_currentNonArenaGpuMemory
+ atlasBytes
+ retiringAtlasBytes),
GlobalBuffer?.PhysicalCapacityBytes ?? 0);
return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes);
}
}
internal (int Count, long Bytes) CpuCacheDiagnostics =>
(_cpuMeshCache.Count, _cpuMeshCache.ResidentBytes);
/// <summary>CPU prepared-mesh cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats CpuMeshCacheStats => _cpuMeshCache.Stats;
/// <summary>Decoded-texture cache hit/miss/eviction counts (2026-07-24 measurement-tooling review).</summary>
internal CacheStats DecodedTextureCacheStats =>
_preparedAssets.DecodedTextureCacheStats;
private sealed class PreparationRequest(
PreparedAssetRequest asset,
ObjectMeshData? cachedData,
TaskCompletionSource<ObjectMeshData?> completion,
CancellationTokenSource cancellation)
{
private readonly object _cancellationGate = new();
private bool _cancelStarted;
private bool _cancelInProgress;
private bool _disposeRequested;
private bool _cancellationDisposed;
public PreparedAssetRequest Asset { get; } = asset;
public ulong Id => Asset.RuntimeObjectId;
public ObjectMeshData? CachedData { get; } = cachedData;
public TaskCompletionSource<ObjectMeshData?> Completion { get; } = completion;
public CancellationTokenSource Cancellation { get; } = cancellation;
// Cancellation callbacks are user-extensible and run synchronously.
// Never invoke them while ObjectMeshManager's queue lock is held.
// The small request-local protocol also prevents the worker's
// terminal Dispose from racing the detached cancellation call.
public void Cancel()
{
lock (_cancellationGate)
{
if (_cancellationDisposed || _cancelStarted)
return;
_cancelStarted = true;
_cancelInProgress = true;
}
try
{
Cancellation.Cancel();
}
finally
{
bool dispose;
lock (_cancellationGate)
{
_cancelInProgress = false;
dispose = _disposeRequested && !_cancellationDisposed;
if (dispose)
_cancellationDisposed = true;
}
if (dispose)
Cancellation.Dispose();
}
}
public void DisposeCancellation()
{
lock (_cancellationGate)
{
if (_cancellationDisposed)
return;
if (_cancelInProgress)
{
_disposeRequested = true;
return;
}
_cancellationDisposed = true;
}
Cancellation.Dispose();
}
}
// LIFO preserves destination locality, while the id->node index makes
// release/cancellation O(1). The former List.FindIndex hot path was
// O(N) for every missing-mesh lookup and became O(N^2) per frame when
// portal streaming reached backpressure.
private readonly LinkedList<PreparationRequest> _pendingRequests = new();
private readonly Dictionary<ulong, LinkedListNode<PreparationRequest>> _pendingRequestById = new();
private readonly Dictionary<ulong, PreparationRequest> _activePreparationById = new();
private readonly Dictionary<ulong, EnvCellGeomRequest> _envCellDescriptors = new();
private readonly HashSet<ulong> _terminalPreparationFailures = new();
private readonly HashSet<Task> _workerTasks = new();
private readonly ManualResetEventSlim _preparationWorkAvailable = new(false);
private const int MaxParallelLoads = 4;
internal enum PreparationWorkerWakeAction
{
Process,
ResetAndWait,
Exit,
}
internal static PreparationWorkerWakeAction DecidePreparationWorkerWake(
bool isDisposed,
bool hasPendingRequests,
bool stagingAtHighWater,
bool arenaBackpressured)
{
// Shutdown has priority over every ordinary idle/backpressure state.
// Dispose sets one shared manual-reset signal for all persistent
// workers; no worker may reset that signal before its peers wake.
if (isDisposed)
return PreparationWorkerWakeAction.Exit;
if (!hasPendingRequests || stagingAtHighWater || arenaBackpressured)
return PreparationWorkerWakeAction.ResetAndWait;
return PreparationWorkerWakeAction.Process;
}
private sealed class ObjectReleaseTicket(
ulong id,
ObjectRenderData data,
long reclaimableBytes,
RetryableResourceReleaseLedger resources)
{
public ulong Id { get; } = id;
public ObjectRenderData Data { get; } = data;
public long ReclaimableBytes { get; } = reclaimableBytes;
public RetryableResourceReleaseLedger Resources { get; } = resources;
public bool IsQueued { get; set; }
}
// internal, not public: IGpuDevice is an internal type (the pinned RHI
// contract), and the shared mesh arena is created from it. Every caller
// already lives inside AcDream.App or its InternalsVisibleTo test
// assemblies.
internal ObjectMeshManager(
IMeshPipelineDevice graphicsDevice,
AcDream.App.Rendering.Gpu.IGpuDevice gpuDevice,
IPreparedAssetSource preparedAssets,
ILogger<ObjectMeshManager> logger,
ResidencyBudgetOptions? budgets = null)
{
budgets ??= ResidencyBudgetOptions.Default;
_graphicsDevice = graphicsDevice
?? throw new ArgumentNullException(nameof(graphicsDevice));
ArgumentNullException.ThrowIfNull(gpuDevice);
_gpuDevice = gpuDevice;
// Slice V6i-2: which physical array a shared atlas gets is decided
// once, here. Everything below — capacity, slot allocation, ref
// counting, layer retirement, eviction — is written against
// IWorldTextureArray and does not branch on the backend.
//
// Slice V4t downcast gpuDevice to GlGpuDevice HERE, which is what
// made a Vulkan-composed mesh pipeline throw before it had run a
// statement. The cast now lives on the one property that genuinely
// needs it — the raw-GL world renderers' handle table — so
// construction itself no longer names a backend.
_atlasArrays = IWorldTextureArrayFactory.For(
graphicsDevice,
gpuDevice,
logger ?? throw new ArgumentNullException(nameof(logger)));
_preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets));
_logger = logger
?? throw new ArgumentNullException(nameof(logger));
_maxGpuMemory = budgets.ObjectMeshGpuBytes;
_maxCachedObjects = budgets.ObjectMeshUnownedEntries;
_cpuMeshCache = new CpuMeshUploadCache(
budgets.PreparedMeshCpuEntries,
budgets.PreparedMeshCpuBytes);
_stagedMeshData = new MeshUploadStagingQueue(
budgets.MeshStagingEntries,
budgets.MeshStagingBytes);
// The modern path is mandatory (N.5 ship amendment) and Campaign V
// slice V11 deleted the only other backend, so a production
// IMeshPipelineDevice always reports both flags true. The gate
// survives because AcDream.App.Tests.Rendering.Wb.
// MeshPipelineDeviceSeamTests exercises a device that reports
// neither, to prove the arena is genuinely optional rather than
// dereferenced unconditionally.
if (_graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless)
{
GlobalBuffer = new GlobalMeshBuffer(
gpuDevice,
_graphicsDevice.ResourceRetirement);
}
}
/// <summary>
/// Get existing GPU render data for an object, or null if not yet uploaded.
/// Increments reference count.
/// </summary>
public ObjectRenderData? GetRenderData(ulong id)
{
if (!_objectReleases.ContainsKey(id)
&& _renderData.TryGetValue(id, out var data))
{
IncrementRefCount(id);
return data;
}
return null;
}
/// <summary>
/// Check if GPU render data exists for an object.
/// </summary>
public bool HasRenderData(ulong id) =>
!_objectReleases.ContainsKey(id)
&& _renderData.ContainsKey(id);
/// <summary>
/// Get existing GPU render data without modifying reference count.
/// Use this for render-loop lookups where you don't want to affect lifecycle.
/// </summary>
public ObjectRenderData? TryGetRenderData(ulong id)
{
return !_objectReleases.ContainsKey(id)
&& _renderData.TryGetValue(id, out var data)
? data
: null;
}
/// <summary>
/// Monotonic counter that changes whenever <see cref="TryGetRenderData"/>
/// can answer differently for any id. Consumers that bake availability
/// into a cached product (EnvCellRenderer's visibility snapshot) rebuild
/// when this moves.
/// </summary>
public long RenderDataAvailabilityVersion => Volatile.Read(ref _renderDataAvailabilityVersion);
private void MarkRenderDataAvailabilityChanged() =>
Interlocked.Increment(ref _renderDataAvailabilityVersion);
/// <summary>
/// Increment reference count for an object (e.g. when a landblock starts using it).
/// </summary>
public void IncrementRefCount(ulong id)
{
lock (_pendingRequests)
{
_ownership.Acquire(id);
lock (_lruList)
{
_lruList.Remove(id);
}
}
}
public (int Arrays, long Bytes) GenerateMipmaps()
{
int generatedArrays = 0;
long generatedBytes = 0;
foreach (TextureAtlasManager atlas in _dirtyAtlases)
{
long bytes = atlas.TextureArray.ProcessDirtyUpdates();
if (bytes > 0)
{
generatedArrays++;
generatedBytes = checked(generatedBytes + bytes);
}
}
_dirtyAtlases.Clear();
return (generatedArrays, generatedBytes);
}
/// <summary>
/// Retains a small LRU of empty arrays so recurring texture size classes
/// reuse their immutable storage and resident sampler handles.
/// Once that idle pool exceeds its byte/count budget, retires at most
/// one GPU-safe array per frame. Logical emptiness alone is insufficient:
/// every returned layer
/// must first pass its frame fence.
/// </summary>
internal bool EvictOneEmptyAtlas()
{
RetryRetiringAtlasDisposals();
if (!_safeEmptyAtlases.TryTakeOldestOverBudget(out TextureAtlasManager victim))
return false;
var key = (victim.Width, victim.Height, victim.Format);
if (_globalAtlases.TryGetValue(key, out List<TextureAtlasManager>? list))
{
list.Remove(victim);
if (list.Count == 0)
_globalAtlases.Remove(key);
}
_dirtyAtlases.Remove(victim);
_retiringAtlases.Add(victim);
victim.Dispose();
RemoveCompletedAtlasRetirements();
return true;
}
private void RetryRetiringAtlasDisposals()
{
for (int i = 0; i < _retiringAtlases.Count; i++)
_retiringAtlases[i].Dispose();
RemoveCompletedAtlasRetirements();
}
private void RemoveCompletedAtlasRetirements()
{
for (int i = _retiringAtlases.Count - 1; i >= 0; i--)
{
if (!_retiringAtlases[i].IsPhysicalRetirementComplete)
continue;
// Campaign V slice V4t: the array's handles are non-resident and
// its texture deleted by the time physical retirement reports
// complete, so this is the point at which its two table entries
// stop naming anything. Without it, a session that churns atlases
// would accumulate entries against the table's fixed capacity —
// the interim per-renderer tables grew without bound instead, so
// this is stricter than what it replaces, not looser. The
// device defers the index itself behind its retirement queue.
_retiringAtlases[i].TextureArray.ReleaseTextureSlots();
_retiringAtlases.RemoveAt(i);
}
}
private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas)
{
if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas))
return;
_safeEmptyAtlases.MarkUnowned(atlas, atlas.AllocatedBytes);
}
private void MarkAtlasActive(TextureAtlasManager atlas) => _safeEmptyAtlases.MarkOwned(atlas);
/// <summary>
/// #105 diagnostic: counts staged-but-unflushed texture layer updates across all
/// shared atlases (see <see cref="IWorldTextureArray.PendingUpdateCount"/>).
/// Render thread only — <c>_globalAtlases</c> is render-thread-owned.
/// </summary>
public (int PendingUpdates, int ArraysWithPending, int TotalArrays) GetPendingTextureUpdateStats()
{
int pending = 0, arraysWith = 0, total = 0;
foreach (var atlasList in _globalAtlases.Values)
{
foreach (var atlas in atlasList)
{
total++;
int p = atlas.TextureArray.PendingUpdateCount;
if (p > 0) { arraysWith++; pending += p; }
}
}
return (pending, arraysWith, total);
}
/// <summary>
/// Decrement reference count and unload GPU resources if no longer needed.
/// </summary>
public void DecrementRefCount(ulong id)
{
(PreparationRequest? Pending, PreparationRequest? Active) canceled = default;
bool finalOwner;
lock (_pendingRequests)
{
finalOwner = _ownership.Count(id) <= 1;
if (finalOwner)
canceled = DetachPendingPreparationLocked(id);
}
// Cancellation callbacks are arbitrary synchronous code and can
// throw. Run them before committing the reference decrement. A
// failure therefore leaves ownership unchanged and makes the same
// DecrementRefCount call safe to retry; the detached cancellation
// protocol is idempotent for that retry.
if (finalOwner)
CancelDetachedPreparation(canceled);
lock (_pendingRequests)
{
int newCount = _ownership.Release(id);
if (newCount > 0)
return;
_envCellDescriptors.Remove(id);
_terminalPreparationFailures.Remove(id);
if (_renderData.ContainsKey(id))
{
// Instead of unloading, move resident data to LRU.
lock (_lruList)
{
_lruList.Remove(id);
_lruList.AddLast(id);
}
}
else
{
_ownership.Remove(id);
}
}
}
/// <summary>
/// Decrement reference count and unload if no longer needed.
/// </summary>
public void ReleaseRenderData(ulong id)
{
(PreparationRequest? Pending, PreparationRequest? Active) canceled = default;
lock (_pendingRequests)
{
if (_ownership.IsOwned(id))
{
var newCount = _ownership.Release(id);
if (newCount <= 0)
{
_envCellDescriptors.Remove(id);
_terminalPreparationFailures.Remove(id);
canceled = DetachPendingPreparationLocked(id);
if (_renderData.ContainsKey(id))
{
lock (_lruList)
{
_lruList.Remove(id);
_lruList.AddLast(id);
}
}
else
{
_ownership.Remove(id);
}
}
}
}
CancelDetachedPreparation(canceled);
}
internal (int Count, long Bytes) ReclaimUnusedResources(
int maximumCount,
long maximumBytes,
bool forceArenaReclamation = false)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maximumCount, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
RetryRetiringAtlasDisposals();
RetryPendingAtlasRetirements();
// Rollbacks own unpublished resources and therefore have no LRU
// node to wake them. Advance a bounded snapshot every render tick
// even if the requesting owner disappeared after the upload failed.
AdvancePendingUploadRollbacks(maximumCount);
(int reclaimedCount, long reclaimedBytes) =
AdvancePendingObjectReleases(maximumCount, maximumBytes);
int candidateBudget;
lock (_lruList)
candidateBudget = _lruList.Count;
int attemptedCandidates = 0;
long trackedAtlasBytes = checked(
CalculateAtlasBytes(_globalAtlases.Values)
+ CalculateAtlasBytes(_retiringAtlases));
while (reclaimedCount < maximumCount
&& attemptedCandidates < candidateBudget)
{
ulong idToEvict;
lock (_lruList)
{
long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0;
long nonArenaAndAtlasBytes = checked(
_currentNonArenaGpuMemory
+ trackedAtlasBytes);
if (!forceArenaReclamation
&& IsWithinGpuCacheBudget(
nonArenaAndAtlasBytes,
physicalArenaBytes,
_maxGpuMemory)
&& _lruList.Count <= _maxCachedObjects)
{
break;
}
// Stale owned nodes are bookkeeping-only and safe to discard
// while searching. Real destruction is bounded independently
// by both object count and bytes so admission of up to eight
// meshes cannot outrun a one-object reclamation service.
if (_lruList.Count == 0)
break;
idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
}
attemptedCandidates++;
lock (_pendingRequests)
{
if (!_ownership.IsOwned(idToEvict))
{
long bytes = GetObjectReclaimableBytes(idToEvict);
if (!FitsReclamationBudget(bytes, reclaimedBytes, maximumBytes))
{
lock (_lruList)
_lruList.AddLast(idToEvict);
// This candidate is indivisible within the current
// byte allowance, but a smaller later object may
// still fit. Rotate it and inspect each original
// LRU node at most once so one large mesh cannot
// starve all reclamation forever.
continue;
}
if (TryAdvanceObjectRelease(idToEvict, out long completedBytes))
{
reclaimedBytes = checked(reclaimedBytes + completedBytes);
reclaimedCount++;
}
// An unfinished release moves from the ordinary LRU to
// _objectReleases. That dedicated queue advances once
// at the start of a later frame, including when a new
// logical owner acquired the id in the meantime.
}
}
}
return (reclaimedCount, reclaimedBytes);
}
internal static bool FitsReclamationBudget(
long candidateBytes,
long alreadyReclaimedBytes,
long maximumBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(candidateBytes);
ArgumentOutOfRangeException.ThrowIfNegative(alreadyReclaimedBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
return candidateBytes <= maximumBytes - Math.Min(alreadyReclaimedBytes, maximumBytes);
}
private void RetryPendingAtlasRetirements()
{
List<Exception>? failures = null;
foreach (List<TextureAtlasManager> atlases in _globalAtlases.Values)
{
for (int i = 0; i < atlases.Count; i++)
{
try { atlases[i].RetryPendingRetirements(); }
catch (Exception error) { (failures ??= []).Add(error); }
}
}
if (failures is not null)
{
throw new AggregateException(
"One or more texture-atlas layer retirements could not be published.",
failures);
}
}
internal bool EvictOneOldResource() =>
ReclaimUnusedResources(1, long.MaxValue).Count != 0;
private long GetReclaimableBytes(ObjectRenderData data)
{
if (data.GlobalAllocation is { } allocation)
{
return checked(
(long)allocation.Vertices.Length * VertexPositionNormalTexture.Size
+ (long)allocation.Indices.Length * sizeof(ushort));
}
return Math.Max(0, data.NonArenaGpuBytes);
}
/// <summary>
/// Force evict all unused objects from the cache.
/// Use this when navigating away from a view or changing filters to free memory.
/// </summary>
public void EvictAllUnused()
{
AdvancePendingUploadRollbacks(Math.Max(1, _uploadRollbackQueue.Count));
AdvancePendingObjectReleases(
Math.Max(1, _objectReleaseQueue.Count),
long.MaxValue);
int candidateBudget;
lock (_lruList)
candidateBudget = _lruList.Count;
for (int attempted = 0; attempted < candidateBudget; attempted++)
{
ulong idToEvict;
lock (_lruList)
{
if (_lruList.Count == 0)
break;
idToEvict = _lruList.First!.Value;
_lruList.RemoveFirst();
}
lock (_pendingRequests)
{
if (!_ownership.IsOwned(idToEvict))
{
TryAdvanceObjectRelease(idToEvict, out _);
}
}
}
// Also clear CPU mesh cache
_cpuMeshCache.Clear();
}
public struct EnvCellGeomRequest
{
public uint SourceCellId;
public uint EnvironmentId;
public ushort CellStructure;
public List<ushort> Surfaces;
}
/// <summary>
/// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry.
/// </summary>
public Task<ObjectMeshData?> PrepareEnvCellGeomMeshDataAsync(
ulong geomId,
uint sourceCellId,
uint environmentId,
ushort cellStructure,
List<ushort> surfaces,
CancellationToken ct = default)
{
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
var envCell = new EnvCellGeomRequest
{
SourceCellId = sourceCellId,
EnvironmentId = environmentId,
CellStructure = cellStructure,
Surfaces = surfaces
};
lock (_pendingRequests)
{
_envCellDescriptors[geomId] = envCell;
// An explicit schema-bearing schedule is a new opportunity to
// prepare this immutable DAT object (for example, a later
// landblock sharing geometry after an earlier failure).
_terminalPreparationFailures.Remove(geomId);
}
ObjectMeshData? deferredCachedData = null;
if (_cpuMeshCache.TryGetAndStageOwned(
geomId,
_stagedMeshData,
_ownership,
out ObjectMeshData? cachedData,
out MeshStageResult cacheStage))
{
if (cacheStage != MeshStageResult.HighWater)
return Task.FromResult(cachedData);
deferredCachedData = cachedData;
}
lock (_pendingRequests)
{
if (_preparationTasks.TryGetValue(geomId, out Task<ObjectMeshData?>? existing)
&& !existing.IsFaulted
&& !existing.IsCanceled)
{
bool canceledActiveGeneration =
_activePreparationById.TryGetValue(geomId, out PreparationRequest? active)
&& active.Cancellation.IsCancellationRequested
&& ReferenceEquals(existing, active.Completion.Task);
if (!canceledActiveGeneration)
return existing;
}
_preparationTasks.TryRemove(geomId, out _);
var tcs = new TaskCompletionSource<ObjectMeshData?>(
TaskCreationOptions.RunContinuationsAsynchronously);
Task<ObjectMeshData?> task = tcs.Task;
if (IsDisposed)
{
tcs.TrySetCanceled();
return task;
}
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct);
_preparationTasks[geomId] = task;
var request = new PreparationRequest(
PreparedAssetRequest.EnvCellGeometry(
sourceCellId,
geomId,
environmentId,
cellStructure,
surfaces),
deferredCachedData,
tcs,
cancellation);
_pendingRequestById.Add(geomId, _pendingRequests.AddLast(request));
StartPreparationWorkersLocked();
return task;
}
}
public Task<ObjectMeshData?> PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default)
{
if (IsDisposed || HasRenderData(id)) return Task.FromResult<ObjectMeshData?>(null);
lock (_pendingRequests)
_terminalPreparationFailures.Remove(id);
ObjectMeshData? deferredCachedData = null;
if (_cpuMeshCache.TryGetAndStageOwned(
id,
_stagedMeshData,
_ownership,
out ObjectMeshData? cachedData,
out MeshStageResult cacheStage))
{
if (cacheStage != MeshStageResult.HighWater)
return Task.FromResult(cachedData);
deferredCachedData = cachedData;
}
lock (_pendingRequests)
{
if (_preparationTasks.TryGetValue(id, out Task<ObjectMeshData?>? existing)
&& !existing.IsFaulted
&& !existing.IsCanceled)
{
bool canceledActiveGeneration =
_activePreparationById.TryGetValue(id, out PreparationRequest? active)
&& active.Cancellation.IsCancellationRequested
&& ReferenceEquals(existing, active.Completion.Task);
if (!canceledActiveGeneration)
return existing;
}
_preparationTasks.TryRemove(id, out _);
var tcs = new TaskCompletionSource<ObjectMeshData?>(
TaskCreationOptions.RunContinuationsAsynchronously);
Task<ObjectMeshData?> task = tcs.Task;
if (IsDisposed)
{
tcs.TrySetCanceled();
return task;
}
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(ct);
_preparationTasks[id] = task;
EnvCellGeomRequest? envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor)
? descriptor
: null;
PreparedAssetRequest asset = envCell is { } env
? PreparedAssetRequest.EnvCellGeometry(
env.SourceCellId,
id,
env.EnvironmentId,
env.CellStructure,
env.Surfaces)
: isSetup
? PreparedAssetRequest.Setup(checked((uint)id))
: PreparedAssetRequest.GfxObj(checked((uint)id));
var request = new PreparationRequest(
asset,
deferredCachedData,
tcs,
cancellation);
_pendingRequestById.Add(id, _pendingRequests.AddLast(request));
StartPreparationWorkersLocked();
return task;
}
}
private void ProcessQueue()
{
while (true)
{
_preparationWorkAvailable.Wait();
PreparationRequest request;
lock (_pendingRequests)
{
// IsDisposed re-check lets Dispose cancel and join every
// tracked worker before prepared content mappings are released.
// Exit WITHOUT resetting the shared manual-reset event:
// Dispose sets it once to wake all four persistent workers.
// If the first worker reset it, the remaining three slept
// forever and graceful client shutdown deadlocked.
PreparationWorkerWakeAction wakeAction = DecidePreparationWorkerWake(
IsDisposed,
_pendingRequests.Count != 0,
_stagedMeshData.IsAtHighWater,
_arenaBackpressured);
if (wakeAction == PreparationWorkerWakeAction.Exit)
return;
if (wakeAction == PreparationWorkerWakeAction.ResetAndWait)
{
_preparationWorkAvailable.Reset();
continue;
}
// LIFO: pick the most recently requested destination
// mesh without scanning/reordering the whole queue.
LinkedListNode<PreparationRequest>? node = _pendingRequests.Last;
while (node is not null && _activePreparationById.ContainsKey(node.Value.Id))
node = node.Previous;
if (node is null)
{
_preparationWorkAvailable.Reset();
continue;
}
request = node.Value;
_pendingRequests.Remove(node);
_pendingRequestById.Remove(request.Id);
_activePreparationById.Add(request.Id, request);
}
ulong id = request.Id;
TaskCompletionSource<ObjectMeshData?> tcs = request.Completion;
CancellationToken ct = request.Cancellation.Token;
ObjectMeshData? completionResult = null;
Exception? completionError = null;
bool completionCanceled = false;
CancellationToken completionCancellation = ct;
try
{
if (ct.IsCancellationRequested)
{
completionCanceled = true;
}
else
{
ObjectMeshData? data = request.CachedData;
if (data is null)
{
PreparedAssetReadResult read =
_preparedAssets.Read(request.Asset, ct);
data = read.Data;
if (read.Status != PreparedAssetReadStatus.Loaded)
{
_logger.LogError(
"Prepared mesh {Status} for {Type} source " +
"0x{SourceId:X8}, runtime 0x{RuntimeId:X10}",
read.Status,
request.Asset.Type,
request.Asset.SourceFileId,
request.Asset.RuntimeObjectId);
}
}
if (ct.IsCancellationRequested)
{
completionCanceled = true;
}
else if (data != null)
{
// Preserve completed work in the bounded CPU
// cache even if its last owner disappeared
// during this at-most-four-worker decode.
_cpuMeshCache.Store(data);
}
if (!completionCanceled && data != null && _ownership.IsOwned(id))
{
// A decoder that started before the watermark may
// finish after it. Keep its immutable payload in
// the bounded CPU cache above, but do not let four
// concurrent workers punch an unbounded byte hole
// through the staging queue. Point-of-use rearming
// stages the cache entry once consumer space exists.
_stagedMeshData.TryStage(data);
}
if (!completionCanceled)
completionResult = data;
}
}
catch (OperationCanceledException ex)
{
completionCanceled = true;
completionCancellation = ex.CancellationToken;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error preparing mesh data for 0x{Id:X8}", id);
completionError = ex;
}
finally
{
lock (_pendingRequests)
{
if (_activePreparationById.TryGetValue(id, out PreparationRequest? active)
&& ReferenceEquals(active, request))
{
_activePreparationById.Remove(id);
}
// Publish only after removing this generation from
// the active map. A replacement can never race into
// an id still occupied by the terminal generation.
if (completionError is not null)
tcs.TrySetException(completionError);
else if (completionCanceled)
tcs.TrySetCanceled(completionCancellation.IsCancellationRequested
? completionCancellation
: default);
else
tcs.TrySetResult(completionResult);
if (_preparationTasks.TryGetValue(id, out Task<ObjectMeshData?>? current)
&& ReferenceEquals(current, tcs.Task))
{
_preparationTasks.TryRemove(id, out _);
}
if (!completionCanceled && (completionError is not null || completionResult is null))
_terminalPreparationFailures.Add(id);
else if (completionResult is not null)
_terminalPreparationFailures.Remove(id);
if (!IsDisposed
&& _pendingRequests.Count != 0
&& !_stagedMeshData.IsAtHighWater
&& !_arenaBackpressured)
{
_preparationWorkAvailable.Set();
}
}
request.DisposeCancellation();
}
}
}
private void StartPreparationWorkersLocked()
{
if (IsDisposed)
return;
// Keep a fixed, sleeping worker set once preparation begins. The
// former high-water path destroyed and recreated up to four
// Task.Run workers on every upload/drain cycle during portals,
// producing needless thread-pool and GC churn.
while (_workerTasks.Count < MaxParallelLoads)
{
Task worker = Task.Run(ProcessQueue);
_workerTasks.Add(worker);
_ = worker.ContinueWith(
OnPreparationWorkerCompleted,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
if (_pendingRequests.Count != 0
&& !_stagedMeshData.IsAtHighWater
&& !_arenaBackpressured)
_preparationWorkAvailable.Set();
}
private void OnPreparationWorkerCompleted(Task worker)
{
if (worker.IsFaulted)
_logger.LogError(worker.Exception, "Mesh preparation worker terminated unexpectedly.");
lock (_pendingRequests)
{
_workerTasks.Remove(worker);
StartPreparationWorkersLocked();
}
}
internal void ResumePreparationWorkers()
{
lock (_pendingRequests)
StartPreparationWorkersLocked();
}
/// <summary>
/// Readiness barrier used by the streaming publisher. Missing owned
/// data is re-armed through its retained schema, so synthetic EnvCell
/// IDs can never fall into generic GfxObj decoding after cancellation.
/// A deterministic prepared-payload failure is retained until the next explicit
/// ownership schedule instead of being retried every render frame.
/// </summary>
internal bool EnsureRenderDataReady(ulong id)
{
if (HasRenderData(id))
return true;
EnvCellGeomRequest? envCell;
lock (_pendingRequests)
{
if (IsDisposed
|| !_ownership.IsOwned(id)
|| _terminalPreparationFailures.Contains(id))
{
return false;
}
envCell = _envCellDescriptors.TryGetValue(id, out EnvCellGeomRequest descriptor)
? descriptor
: null;
}
if (envCell is { } req)
{
_ = PrepareEnvCellGeomMeshDataAsync(
id,
req.SourceCellId,
req.EnvironmentId,
req.CellStructure,
req.Surfaces);
}
else
{
_ = PrepareMeshDataAsync(id, isSetup: false);
}
return false;
}
/// <summary>
/// Synchronously reads an immutable prepared payload without creating
/// GPU resources. The production source is the validated pak; explicit
/// tooling adapters may use live DAT extraction.
/// </summary>
public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default)
{
PreparedAssetRequest request = isSetup
? PreparedAssetRequest.Setup(checked((uint)id))
: PreparedAssetRequest.GfxObj(checked((uint)id));
return _preparedAssets.Read(request, ct).Data;
}
internal ResidencyDomainSnapshot CaptureObjectMeshResidency()
{
GlobalMeshBuffer? arena = GlobalBuffer;
long nonArenaRetiring = 0;
foreach (ObjectReleaseTicket ticket in _objectReleases.Values)
{
nonArenaRetiring = checked(
nonArenaRetiring
+ Math.Max(0, ticket.Data.NonArenaGpuBytes));
}
nonArenaRetiring = Math.Min(
nonArenaRetiring,
_currentNonArenaGpuMemory);
long arenaResident = arena?.ResidentCapacityBytes ?? 0;
long arenaRequested = arena?.RequestedMigrationBytes ?? 0;
long arenaRetiring = checked(
(arena?.PendingRangeRetirementBytes ?? 0)
+ (arena?.RetiredBackingBytes ?? 0));
long atlasResident = CalculateAtlasBytes(_globalAtlases.Values);
long atlasRetiring = CalculateAtlasBytes(_retiringAtlases);
return new ResidencyDomainSnapshot(
ResidencyDomain.ObjectMeshes,
EntryCount: checked(
_renderData.Count
+ _globalAtlases.Values.Sum(static atlases => atlases.Count)
+ _retiringAtlases.Count),
OwnerCount: _ownership.TotalReferenceCount,
Charges: new ResidencyCharges(
GpuRequestedBytes: arenaRequested,
GpuResidentBytes: checked(
_currentNonArenaGpuMemory
- nonArenaRetiring
+ arenaResident
+ atlasResident),
RetiringBytes: checked(
nonArenaRetiring
+ arenaRetiring
+ atlasRetiring)),
BudgetBytes: _maxGpuMemory);
}
internal static long CalculateAtlasBytes(
IEnumerable<IReadOnlyCollection<TextureAtlasManager>> atlasFamilies)
{
ArgumentNullException.ThrowIfNull(atlasFamilies);
long bytes = 0;
foreach (IReadOnlyCollection<TextureAtlasManager> family in atlasFamilies)
{
foreach (TextureAtlasManager atlas in family)
bytes = checked(bytes + atlas.AllocatedBytes);
}
return bytes;
}
internal static long CalculateAtlasBytes(
IEnumerable<TextureAtlasManager> atlases)
{
ArgumentNullException.ThrowIfNull(atlases);
long bytes = 0;
foreach (TextureAtlasManager atlas in atlases)
bytes = checked(bytes + atlas.AllocatedBytes);
return bytes;
}
internal ResidencyDomainSnapshot CapturePreparedMeshResidency()
{
CacheStats stats = _cpuMeshCache.Stats;
return new ResidencyDomainSnapshot(
ResidencyDomain.PreparedMeshCpu,
EntryCount: _cpuMeshCache.Count,
OwnerCount: 0,
Charges: new ResidencyCharges(
CpuPreparedBytes: _cpuMeshCache.ResidentBytes),
BudgetBytes: _cpuMeshCache.ByteCapacity,
Hits: stats.Hits,
Misses: stats.Misses,
Evictions: stats.Evictions);
}
internal ResidencyDomainSnapshot CaptureStagingResidency() =>
new(
ResidencyDomain.MeshStaging,
EntryCount: _stagedMeshData.ClaimCount,
OwnerCount: 0,
Charges: new ResidencyCharges(
StagingBytes: _stagedMeshData.ClaimedBytes),
BudgetBytes: _stagedMeshData.MaximumBytes);
internal ResidencyDomainSnapshot CaptureGlobalArenaResidency()
{
GlobalMeshBuffer? arena = GlobalBuffer;
return new ResidencyDomainSnapshot(
ResidencyDomain.GlobalMeshArena,
EntryCount: arena is null ? 0 : 2,
OwnerCount: 0,
// Physical arena bytes are already charged once by the
// ObjectMeshes aggregate. This row exposes allocator facts.
Charges: ResidencyCharges.Zero,
BudgetBytes: GlobalMeshBuffer.MaximumPhysicalArenaBytes,
CapacityBytes: arena?.CapacityBytes ?? 0,
UsedBytes: arena?.UsedBytes ?? 0,
LargestFreeBytes: arena?.LargestFreeBytes ?? 0);
}
/// <summary>
/// Cancel preparation tasks for IDs that are no longer needed.
/// </summary>
public void CancelStagedUploads(IEnumerable<ulong> ids)
{
foreach (ulong id in ids)
CancelPendingPreparation(id);
}
private void CancelPendingPreparation(ulong id)
{
(PreparationRequest? Pending, PreparationRequest? Active) canceled;
lock (_pendingRequests)
canceled = DetachPendingPreparationLocked(id);
CancelDetachedPreparation(canceled);
}
private (PreparationRequest? Pending, PreparationRequest? Active) DetachPendingPreparationLocked(ulong id)
{
PreparationRequest? pending = null;
if (_pendingRequestById.Remove(id, out LinkedListNode<PreparationRequest>? node))
{
_pendingRequests.Remove(node);
pending = node.Value;
if (_preparationTasks.TryGetValue(id, out Task<ObjectMeshData?>? current)
&& ReferenceEquals(current, pending.Completion.Task))
{
_preparationTasks.TryRemove(id, out _);
}
}
// Stop obsolete destination work as soon as its final owner leaves.
// A same-id reacquisition sees this still-active task until its
// terminal publication, then the point-of-use retry starts a fresh
// generation; no replacement can collide in the active map.
_activePreparationById.TryGetValue(id, out PreparationRequest? active);
return (pending, active);
}
private static void CancelDetachedPreparation(
(PreparationRequest? Pending, PreparationRequest? Active) canceled)
{
List<Exception>? failures = null;
void Attempt(Action action)
{
try { action(); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
if (canceled.Pending is { } pending)
{
Attempt(pending.Cancel);
pending.Completion.TrySetCanceled(pending.Cancellation.Token);
Attempt(pending.DisposeCancellation);
}
if (canceled.Active is { } active)
Attempt(active.Cancel);
if (failures is not null)
throw new AggregateException("Mesh preparation cancellation failed.", failures);
}
/// <summary>
/// Phase 2 (Main Thread): Upload prepared mesh data to GPU.
/// Creates VAO, VBO, IBOs, and texture arrays.
/// Must be called from the GL thread.
/// </summary>
public ObjectRenderData? UploadMeshData(ObjectMeshData meshData)
{
bool uploadAttempted = false;
try
{
// A failed eviction or failed rollback owns physical resources
// for this id. Resume those exact ledgers before admitting a
// retry; allocating another copy here would compound the leak.
if (_objectReleases.ContainsKey(meshData.ObjectId)
&& !TryAdvanceObjectRelease(meshData.ObjectId, out _))
{
return null;
}
if (!TryAdvanceUploadRollback(meshData.ObjectId))
return null;
if (_renderData.TryGetValue(meshData.ObjectId, out var existing))
{
UpdateLruAfterUpload(meshData.ObjectId);
return existing;
}
uploadAttempted = true;
if (meshData.IsSetup)
{
// Upload EnvCell geometry if present to ensure it's in _renderData
if (meshData.EnvCellGeometry != null)
{
if (UploadMeshData(meshData.EnvCellGeometry) is null)
{
throw new InvalidOperationException(
$"Nested EnvCell geometry 0x{meshData.EnvCellGeometry.ObjectId:X10} "
+ $"failed while uploading setup 0x{meshData.ObjectId:X10}.");
}
}
// Setup objects are multi-part - each part needs its own render data
var data = new ObjectRenderData
{
IsSetup = true,
SetupParts = meshData.SetupParts,
ParticleEmitters = meshData.ParticleEmitters,
Batches = new List<ObjectRenderBatch>(),
BoundingBox = meshData.BoundingBox,
SortCenter = meshData.SortCenter,
DIDDegrade = meshData.DIDDegrade,
SelectionSphere = meshData.SelectionSphere,
MemorySize = 1024 // Small overhead for the setup itself
};
var acquiredParts = new List<ulong>(meshData.SetupParts.Count);
try
{
// Acquire and schedule every dependency before publishing
// the setup. A partial schedule must not become a sticky,
// permanently incomplete render-data cache entry.
foreach (var (partId, _) in meshData.SetupParts)
{
IncrementRefCount(partId);
acquiredParts.Add(partId);
_ = PrepareMeshDataAsync(partId, isSetup: false);
}
if (!_renderData.TryAdd(meshData.ObjectId, data))
throw new InvalidOperationException(
$"Setup 0x{meshData.ObjectId:X10} was published concurrently.");
MarkRenderDataAvailabilityChanged();
_currentNonArenaGpuMemory = checked(
_currentNonArenaGpuMemory + data.NonArenaGpuBytes);
}
catch (Exception setupFailure)
{
RetryableResourceReleaseLedger rollback =
CreateSetupPartRollback(acquiredParts, DecrementRefCount);
ResourceReleaseAttempt attempt = rollback.Advance();
if (!rollback.IsComplete)
{
_uploadRollbacks[meshData.ObjectId] = rollback;
_uploadRollbackQueue.Enqueue(meshData.ObjectId);
}
if (!attempt.HasFailures)
throw;
throw new AggregateException(
$"Setup 0x{meshData.ObjectId:X10} upload and dependency rollback failed.",
setupFailure,
attempt.ToException(
$"Setup 0x{meshData.ObjectId:X10} retained unfinished part references."));
}
UpdateLruAfterUpload(meshData.ObjectId);
return data;
}
var renderData = UploadGfxObjMeshData(meshData);
if (renderData == null)
{
// 0-vertex mesh: every polygon was gated out at extraction. #119
// (2026-06-11) dat-verified this is LEGITIMATE for all-no-draw
// models (all polys NoPos + Base1Solid surfaces — retail's
// skipNoTexture never draws them either; 0x010002B4/0x010008A8
// are this class, Issue119UpNullGfxObjDumpTests). The empty
// cache is the correct terminal state for those. The line stays
// as a tripwire for the OTHER way to get here (extraction
// dropped textured polys — a real defect; dat-verify with the
// dump test before treating as one).
Console.WriteLine($"[up-null] 0x{meshData.ObjectId:X10} produced a 0-vertex mesh — caching empty render data (legitimate for all-no-draw models; dat-verify via Issue119UpNullGfxObjDumpTests)");
renderData = new ObjectRenderData();
}
renderData.BoundingBox = meshData.BoundingBox;
renderData.SortCenter = meshData.SortCenter;
renderData.DIDDegrade = meshData.DIDDegrade;
renderData.SelectionSphere = meshData.SelectionSphere;
_renderData.TryAdd(meshData.ObjectId, renderData);
MarkRenderDataAvailabilityChanged();
_currentNonArenaGpuMemory = checked(
_currentNonArenaGpuMemory + renderData.NonArenaGpuBytes);
UpdateLruAfterUpload(meshData.ObjectId);
// Keep the bounded CPU cache's texture payload intact. GPU LRU
// eviction may need to upload this same prepared mesh again;
// clearing these bytes made a cache hit produce blank textures.
return renderData;
}
catch (Exception ex)
{
if (uploadAttempted)
meshData.UploadAttempts++;
_logger.LogError(ex, "Error uploading mesh data for 0x{Id:X8}", meshData.ObjectId);
return null;
}
}
/// <summary>
/// Conservative main-thread upload work estimate used by the per-frame
/// staging budget. Texture bytes are intentionally counted even when a
/// shared atlas may deduplicate them; overestimating delays work by one
/// frame, whereas underestimating can recreate the destination spike.
/// </summary>
internal static long EstimateUploadBytes(ObjectMeshData meshData)
{
ArgumentNullException.ThrowIfNull(meshData);
return meshData.GetEstimatedUploadBytes();
}
internal static long CalculateNonArenaGeometryBytes(
bool usesGlobalArena,
long geometryBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(geometryBytes);
return usesGlobalArena ? 0 : geometryBytes;
}
internal static long CalculateTrackedGpuBytes(
long nonArenaBytes,
long physicalArenaBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(nonArenaBytes);
ArgumentOutOfRangeException.ThrowIfNegative(physicalArenaBytes);
return checked(nonArenaBytes + physicalArenaBytes);
}
internal static bool IsWithinGpuCacheBudget(
long nonArenaBytes,
long physicalArenaBytes,
long maximumBytes)
{
ArgumentOutOfRangeException.ThrowIfNegative(nonArenaBytes);
ArgumentOutOfRangeException.ThrowIfNegative(physicalArenaBytes);
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
return nonArenaBytes <= maximumBytes - Math.Min(maximumBytes, physicalArenaBytes);
}
private sealed class UploadAtlasPlan
{
public TextureAtlasManager? Existing { get; init; }
public required int Capacity { get; init; }
public required long TotalArrayBytes { get; init; }
public int AvailableSlots { get; set; }
public bool Touched { get; set; }
public HashSet<TextureKey> PlannedKeys { get; } = new();
public bool HasTexture(TextureKey key) =>
PlannedKeys.Contains(key) || Existing?.HasTexture(key) == true;
}
/// <summary>
/// Plans the actual GL work the next object would trigger against the
/// current atlas inventory. This includes array storage, global-buffer
/// growth/copies, and one full mip generation per newly-dirtied array—not merely the
/// source byte arrays held by ObjectMeshData.
/// </summary>
internal MeshUploadCost PlanUploadCost(
ObjectMeshData meshData,
IReadOnlySet<TextureAtlasManager> mipmapsAlreadyBudgeted,
ulong queueGeneration)
{
ArgumentNullException.ThrowIfNull(meshData);
ArgumentNullException.ThrowIfNull(mipmapsAlreadyBudgeted);
if (HasRenderData(meshData.ObjectId))
return default;
var plans = new Dictionary<
(int Width, int Height, TextureFormat Format),
List<UploadAtlasPlan>>();
long arrayAllocationBytes = 0;
long mipmapBytes = 0;
int newArrayCount = 0;
PlanTextureWork(
meshData,
plans,
mipmapsAlreadyBudgeted,
ref arrayAllocationBytes,
ref mipmapBytes,
ref newArrayCount);
GlobalMeshUploadPlan bufferPlan = PlanGlobalBufferWork(meshData);
return new MeshUploadCost(
EstimateUploadBytes(meshData),
arrayAllocationBytes,
mipmapBytes,
newArrayCount,
bufferPlan.UploadBytes,
bufferPlan.AllocationBytes,
bufferPlan.CopyBytes,
bufferPlan.NewBufferCount,
queueGeneration);
}
private GlobalMeshUploadPlan PlanGlobalBufferWork(ObjectMeshData meshData)
{
(int vertexCount, int indexCount) = GetGlobalMeshElementCounts(meshData);
if (vertexCount == 0 || indexCount == 0 || GlobalBuffer is null)
return default;
return GlobalBuffer.PlanUpload(vertexCount, indexCount);
}
internal GlobalMeshCapacityResult EnsureGlobalBufferCapacity(
MeshUploadQueueItem item,
out GlobalMeshMaintenanceStep step)
{
if (GlobalBuffer is null)
{
step = default;
return GlobalMeshCapacityResult.Ready;
}
(int vertexCount, int indexCount) = GetGlobalMeshElementCounts(item.Data);
return GlobalBuffer.EnsureUploadCapacity(vertexCount, indexCount, out step);
}
private (int Vertices, int Indices) GetGlobalMeshElementCounts(ObjectMeshData meshData)
{
if (meshData.IsSetup)
{
return meshData.EnvCellGeometry is { } nested
&& !HasRenderData(nested.ObjectId)
? GetGlobalMeshElementCounts(nested)
: default;
}
if (meshData.Vertices.Length == 0)
return default;
int indexCount = 0;
foreach (List<TextureBatchData> batches in meshData.TextureBatches.Values)
{
foreach (TextureBatchData batch in batches)
indexCount = checked(indexCount + batch.Indices.Count);
}
return (meshData.Vertices.Length, indexCount);
}
private void PlanTextureWork(
ObjectMeshData meshData,
Dictionary<(int Width, int Height, TextureFormat Format), List<UploadAtlasPlan>> plans,
IReadOnlySet<TextureAtlasManager> mipmapsAlreadyBudgeted,
ref long arrayAllocationBytes,
ref long mipmapBytes,
ref int newArrayCount)
{
if (meshData.IsSetup)
{
if (meshData.EnvCellGeometry is { } nested
&& !HasRenderData(nested.ObjectId))
{
PlanTextureWork(
nested,
plans,
mipmapsAlreadyBudgeted,
ref arrayAllocationBytes,
ref mipmapBytes,
ref newArrayCount);
}
return;
}
if (meshData.Vertices.Length == 0)
return;
foreach (var (format, batches) in meshData.TextureBatches)
{
if (!plans.TryGetValue(format, out List<UploadAtlasPlan>? atlasPlans))
{
atlasPlans = new List<UploadAtlasPlan>();
if (_globalAtlases.TryGetValue(format, out List<TextureAtlasManager>? existingAtlases))
{
foreach (TextureAtlasManager existing in existingAtlases)
{
atlasPlans.Add(new UploadAtlasPlan
{
Existing = existing,
Capacity = existing.TotalSlots,
AvailableSlots = existing.AvailableSlots,
TotalArrayBytes = existing.TextureArray.TotalSizeInBytes,
});
}
}
plans.Add(format, atlasPlans);
}
foreach (TextureBatchData batch in batches)
{
if (batch.Indices.Count == 0)
continue;
UploadAtlasPlan? selected = null;
for (int i = 0; i < atlasPlans.Count; i++)
{
UploadAtlasPlan candidate = atlasPlans[i];
if (candidate.HasTexture(batch.Key))
{
selected = candidate;
break;
}
}
if (selected is null)
{
for (int i = 0; i < atlasPlans.Count; i++)
{
UploadAtlasPlan candidate = atlasPlans[i];
if (candidate.AvailableSlots > 0)
{
selected = candidate;
break;
}
}
}
if (selected is null)
{
int capacity = TextureAtlasManager.CalculateInitialCapacity(
format.Width,
format.Height,
format.Format);
long totalArrayBytes = TextureAtlasManager.CalculateArrayBytes(
format.Width,
format.Height,
format.Format);
selected = new UploadAtlasPlan
{
Capacity = capacity,
AvailableSlots = capacity,
TotalArrayBytes = totalArrayBytes,
};
atlasPlans.Add(selected);
arrayAllocationBytes = checked(arrayAllocationBytes + totalArrayBytes);
newArrayCount++;
}
if (selected.HasTexture(batch.Key))
continue;
selected.PlannedKeys.Add(batch.Key);
selected.AvailableSlots--;
if (!selected.Touched)
{
bool mipAlreadyBudgeted = selected.Existing is not null
&& mipmapsAlreadyBudgeted.Contains(selected.Existing);
if (!mipAlreadyBudgeted)
mipmapBytes = checked(mipmapBytes + selected.TotalArrayBytes);
selected.Touched = true;
}
}
}
}
internal static MeshUploadCost CalculateNewAtlasFirstUploadCost(
int width,
int height,
TextureFormat format,
int uploadBytes,
long sourceBytes = 0) =>
CalculateNewAtlasUploadCost(width, height, format, [uploadBytes], sourceBytes);
internal static MeshUploadCost CalculateNewAtlasUploadCost(
int width,
int height,
TextureFormat format,
IReadOnlyList<int> uploadBytes,
long sourceBytes = 0)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
ArgumentNullException.ThrowIfNull(uploadBytes);
ArgumentOutOfRangeException.ThrowIfNegative(sourceBytes);
long arrayBytes = TextureAtlasManager.CalculateArrayBytes(width, height, format);
for (int i = 0; i < uploadBytes.Count; i++)
ArgumentOutOfRangeException.ThrowIfNegative(uploadBytes[i]);
return new MeshUploadCost(sourceBytes, arrayBytes, arrayBytes, 1);
}
internal void AddDirtyAtlasesTo(ISet<TextureAtlasManager> destination)
{
ArgumentNullException.ThrowIfNull(destination);
destination.UnionWith(_dirtyAtlases);
}
private void UpdateLruAfterUpload(ulong id)
{
lock (_lruList)
{
_lruList.Remove(id);
if (!_ownership.MarkUploadComplete(id))
_lruList.AddLast(id);
}
}
#region Private: Background Preparation
/// <summary>
/// #113: the set of polygon ids referenced by the GfxObj's drawing BSP —
/// the polys retail actually renders (D3DPolyRender traverses the BSP;
/// dictionary-orphaned polys are physics/no-draw geometry). Returns null
/// when the model has no drawing BSP (caller draws everything).
/// </summary>
internal static HashSet<ushort>? CollectDrawingBspPolygonIds(GfxObj gfxObj)
{
if (gfxObj.DrawingBSP?.Root is null) return null;
var ids = new HashSet<ushort>();
CollectDrawingBspPolygonIds(gfxObj.DrawingBSP.Root, ids);
return ids;
}
private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet<ushort> ids)
{
if (node.Polygons is not null)
foreach (var pid in node.Polygons)
ids.Add((ushort)pid);
if (node.PosNode is not null) CollectDrawingBspPolygonIds(node.PosNode, ids);
if (node.NegNode is not null) CollectDrawingBspPolygonIds(node.NegNode, ids);
}
#endregion
#region Private: GPU Upload
private ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData)
{
if (meshData.Vertices.Length == 0) return null;
var modernIndexBatches = meshData.TextureBatches.Values
.SelectMany(batches => batches)
.Where(batch => batch.Indices.Count != 0)
.Select(batch => batch.Indices.ToArray())
.ToArray();
GlobalMeshAllocation? globalAllocation = null;
var renderBatches = new List<ObjectRenderBatch>();
var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>();
try
{
// Allocate the shared vertex/index range before acquiring texture
// references. A buffer-growth failure therefore leaves every atlas
// untouched; later failures still roll this allocation back below.
//
// GlobalBuffer is null only for a test double that reports no
// modern-path capability (MeshPipelineDeviceSeamTests); every
// production IMeshPipelineDevice is Vulkan-backed and reports both
// flags true (N.5 ship amendment; Campaign V slice V11 deleted the
// only other backend). There is no longer a per-mesh vertex array
// or vertex/index buffer to build here — Vulkan bakes vertex input
// into the pipeline, and the shared arena's stores are bound once
// per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh).
if (GlobalBuffer is not null && modernIndexBatches.Length != 0)
globalAllocation = GlobalBuffer.UploadMesh(meshData.Vertices, modernIndexBatches);
foreach (var (format, batches) in meshData.TextureBatches)
{
foreach (var batch in batches)
{
if (batch.Indices.Count == 0) continue;
TextureAtlasManager? atlasManager = null;
int textureIndex = 0;
uint firstIndex = 0;
int batchBaseVertex = 0;
// Find or create a shared atlas with free space
if (!_globalAtlases.TryGetValue(format, out var atlasList))
{
atlasList = new List<TextureAtlasManager>();
_globalAtlases[format] = atlasList;
}
// Existing-key lookup must win across the entire atlas
// family. Choosing an earlier reclaimed slot first
// duplicates a layer already resident in a later array
// every time portal churn revisits that texture.
atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key))
?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0);
if (atlasManager == null)
{
atlasManager = new TextureAtlasManager(
_atlasArrays,
format.Width,
format.Height,
format.Format,
OnAtlasGpuSafeEmpty);
atlasList.Add(atlasManager);
}
atlasManager.LastUseSequence = ++_atlasUseSequence;
// MP1a: AcDream.Content is Silk.NET-free — the extraction records
// carry Content-owned UploadPixelFormat/UploadPixelType enums.
// Campaign V slice V11 moved the atlas/array stack onto that same
// Content-owned vocabulary (the GL cast this used to need is gone
// along with Silk.NET.OpenGL.PixelFormat/PixelType themselves), so
// this is now a direct pass-through rather than a lifted cast.
bool uploadsNewLayer = !atlasManager.HasTexture(batch.Key);
try
{
textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData,
batch.UploadPixelFormat, batch.UploadPixelType);
}
catch
{
if (atlasManager.IsGpuSafeEmpty)
OnAtlasGpuSafeEmpty(atlasManager);
throw;
}
acquiredTextures.Add((atlasManager, batch.Key));
// AddTexture may first publish a previously failed
// layer return, whose empty-atlas observer marks this
// array unowned. Reassert active ownership only after
// the new acquisition commits so that callback cannot
// leave a live atlas in the empty-array eviction LRU.
MarkAtlasActive(atlasManager);
if (uploadsNewLayer)
_dirtyAtlases.Add(atlasManager);
// Campaign V slice V4t interned the atlas's resident
// handle into the device's one texture table here and
// carried the slot. Slice V6i-2 asks the array for the
// slot instead: the GL array makes the same idempotent
// interning call one level down, and the RHI array
// returns the entry it registered at construction. The
// array still owns residency and the image; the entry is
// retired when its physical retirement completes.
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot =
atlasManager.TextureArray.ResolveSlot(batch.HasWrappingUVs);
renderBatches.Add(new ObjectRenderBatch
{
IndexCount = batch.Indices.Count,
Atlas = atlasManager!,
TextureIndex = textureIndex,
TextureSize = (format.Width, format.Height),
TextureFormat = format.Format,
Translucency = batch.Translucency,
IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive,
HasWrappingUVs = batch.HasWrappingUVs,
Key = batch.Key,
CullMode = batch.CullMode,
FirstIndex = firstIndex,
BaseVertex = (uint)batchBaseVertex,
TextureSlot = textureSlot,
});
}
}
if (globalAllocation is not null)
{
if (renderBatches.Count != globalAllocation.BatchFirstIndices.Count)
{
throw new InvalidOperationException("Global mesh batch allocation count mismatch.");
}
for (int i = 0; i < renderBatches.Count; i++)
{
renderBatches[i].BaseVertex = (uint)globalAllocation.Vertices.Offset;
renderBatches[i].FirstIndex = (uint)globalAllocation.BatchFirstIndices[i];
}
}
long geometryBytes = checked(
(long)meshData.Vertices.Length * VertexPositionNormalTexture.Size
+ renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)));
var renderData = new ObjectRenderData
{
VertexCount = meshData.Vertices.Length,
Batches = renderBatches,
GlobalAllocation = globalAllocation,
ParticleEmitters = meshData.ParticleEmitters,
DIDDegrade = meshData.DIDDegrade,
CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(),
CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(),
CPUEdgeLines = meshData.EdgeLines,
MemorySize = geometryBytes,
NonArenaGpuBytes = CalculateNonArenaGeometryBytes(
GlobalBuffer is not null,
geometryBytes),
};
return renderData;
}
catch (Exception uploadFailure)
{
RetryableResourceReleaseLedger rollback = CreateUploadRollback(
globalAllocation,
acquiredTextures);
ResourceReleaseAttempt attempt = rollback.Advance();
if (!rollback.IsComplete)
{
_uploadRollbacks[meshData.ObjectId] = rollback;
_uploadRollbackQueue.Enqueue(meshData.ObjectId);
}
if (!attempt.HasFailures)
throw;
throw new AggregateException(
$"Mesh 0x{meshData.ObjectId:X10} upload and rollback failed.",
uploadFailure,
attempt.ToException(
$"Mesh 0x{meshData.ObjectId:X10} upload rollback had unfinished resources."));
}
}
private RetryableResourceReleaseLedger CreateUploadRollback(
GlobalMeshAllocation? globalAllocation,
IReadOnlyList<(TextureAtlasManager Atlas, TextureKey Key)> acquiredTextures)
{
var releases = new List<(string Name, Action Release)>();
if (globalAllocation is not null)
{
releases.Add((
"global-index-range",
() => GlobalBuffer!.AbortIndexRange(globalAllocation)));
releases.Add((
"global-vertex-range",
() => GlobalBuffer!.AbortVertexRange(globalAllocation)));
}
// AddTexture is a reference-counted acquisition even when the
// pixels already existed. Each acquisition owns an independent
// marker because one texture-release failure must not skip the
// remaining layers or replay successful decrements later.
for (int i = acquiredTextures.Count - 1; i >= 0; i--)
{
int releaseIndex = i;
releases.Add((
$"atlas-texture-{releaseIndex}",
() => ReleaseAtlasTexture(
acquiredTextures[releaseIndex].Atlas,
acquiredTextures[releaseIndex].Key)));
}
return new RetryableResourceReleaseLedger(releases);
}
internal static RetryableResourceReleaseLedger CreateSetupPartRollback(
IReadOnlyList<ulong> acquiredParts,
Action<ulong> releasePart)
{
ArgumentNullException.ThrowIfNull(acquiredParts);
ArgumentNullException.ThrowIfNull(releasePart);
var releases = new List<(string Name, Action Release)>(acquiredParts.Count);
for (int i = acquiredParts.Count - 1; i >= 0; i--)
{
int releaseIndex = i;
releases.Add((
$"setup-part-{releaseIndex}",
() => releasePart(acquiredParts[releaseIndex])));
}
return new RetryableResourceReleaseLedger(releases);
}
#endregion
#region Private: Utilities
#region Raycasting
public bool IntersectMesh(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, out float distance, out Vector3 normal)
{
return IntersectMeshInternal(renderData, transform, rayOrigin, rayDirection, 0, out distance, out normal);
}
private bool IntersectMeshInternal(ObjectRenderData renderData, Matrix4x4 transform, Vector3 rayOrigin, Vector3 rayDirection, int depth, out float distance, out Vector3 normal)
{
distance = float.MaxValue;
normal = Vector3.UnitZ;
bool hit = false;
if (depth > 32) return false; // Prevent stack overflow from circular setups
if (renderData.IsSetup)
{
foreach (var part in renderData.SetupParts)
{
var partData = TryGetRenderData(part.GfxObjId);
if (partData != null)
{
if (IntersectMeshInternal(partData, part.Transform * transform, rayOrigin, rayDirection, depth + 1, out float d, out Vector3 n))
{
if (d < distance)
{
distance = d;
normal = n;
hit = true;
}
}
}
}
return hit;
}
if (renderData.CPUPositions.Length == 0 || renderData.CPUIndices.Length == 0)
{
// Fallback to sphere if no CPU mesh data
if (renderData.SelectionSphere != null && renderData.SelectionSphere.Radius > 0.001f)
{
var worldOrigin = Vector3.Transform(renderData.SelectionSphere.Origin, transform);
float radius = renderData.SelectionSphere.Radius * transform.Translation.Length(); // Rough scale
if (GeometryUtils.RayIntersectsSphere(rayOrigin, rayDirection, worldOrigin, radius, out distance))
{
normal = Vector3.Normalize(rayOrigin + rayDirection * distance - worldOrigin);
return true;
}
}
return false;
}
// Transform ray to local space
if (!Matrix4x4.Invert(transform, out var invTransform)) return false;
Vector3 localOrigin = Vector3.Transform(rayOrigin, invTransform);
Vector3 localDirection = Vector3.Normalize(Vector3.TransformNormal(rayDirection, invTransform));
// Iterate through triangles
for (int i = 0; i < renderData.CPUIndices.Length; i += 3)
{
Vector3 v0 = renderData.CPUPositions[renderData.CPUIndices[i]];
Vector3 v1 = renderData.CPUPositions[renderData.CPUIndices[i + 1]];
Vector3 v2 = renderData.CPUPositions[renderData.CPUIndices[i + 2]];
if (GeometryUtils.RayIntersectsTriangle(localOrigin, localDirection, v0, v1, v2, out float t))
{
// Convert t back to world space distance
Vector3 hitPointLocal = localOrigin + localDirection * t;
Vector3 hitPointWorld = Vector3.Transform(hitPointLocal, transform);
float worldDist = Vector3.Distance(rayOrigin, hitPointWorld);
if (worldDist < distance)
{
distance = worldDist;
// Calculate normal in local space and transform to world space
Vector3 localNormal = Vector3.Normalize(Vector3.Cross(v1 - v0, v2 - v0));
normal = Vector3.Normalize(Vector3.TransformNormal(localNormal, transform));
// Ensure normal faces the ray
if (Vector3.Dot(normal, rayDirection) > 0)
{
normal = -normal;
}
hit = true;
}
}
}
return hit;
}
#endregion
private long GetObjectReclaimableBytes(ulong key)
{
if (_objectReleases.TryGetValue(key, out ObjectReleaseTicket? release))
return release.ReclaimableBytes;
return _renderData.TryGetValue(key, out ObjectRenderData? data)
? GetReclaimableBytes(data)
: 0;
}
private ObjectReleaseTicket? GetOrCreateObjectRelease(ulong key)
{
if (_objectReleases.TryGetValue(key, out ObjectReleaseTicket? existing))
return existing;
if (!_renderData.TryGetValue(key, out ObjectRenderData? data))
return null;
var releases = new List<(string Name, Action Release)>();
if (data.GlobalAllocation is { } allocation)
{
releases.Add((
"global-index-range",
() => GlobalBuffer!.ReleaseIndexRange(allocation)));
releases.Add((
"global-vertex-range",
() => GlobalBuffer!.ReleaseVertexRange(allocation)));
}
for (int i = 0; i < data.Batches.Count; i++)
{
int batchIndex = i;
ObjectRenderBatch batch = data.Batches[batchIndex];
if (batch.Atlas is null)
continue;
releases.Add((
$"atlas-texture-{batchIndex}",
() => ReleaseAtlasTexture(
data.Batches[batchIndex].Atlas,
data.Batches[batchIndex].Key)));
}
if (data.IsSetup)
{
for (int i = 0; i < data.SetupParts.Count; i++)
{
int partIndex = i;
releases.Add((
$"setup-part-{partIndex}",
() => DecrementRefCount(data.SetupParts[partIndex].GfxObjId)));
}
}
releases.Add((
"non-arena-memory-accounting",
() => _currentNonArenaGpuMemory = checked(
_currentNonArenaGpuMemory - data.NonArenaGpuBytes)));
var ticket = new ObjectReleaseTicket(
key,
data,
GetReclaimableBytes(data),
new RetryableResourceReleaseLedger(releases));
_objectReleases.Add(key, ticket);
MarkRenderDataAvailabilityChanged();
return ticket;
}
private bool TryAdvanceObjectRelease(ulong key, out long reclaimedBytes)
{
reclaimedBytes = 0;
ObjectReleaseTicket? ticket = GetOrCreateObjectRelease(key);
if (ticket is null)
{
if (!_ownership.IsOwned(key))
_ownership.Remove(key);
return false;
}
ResourceReleaseAttempt attempt = ticket.Resources.Advance();
if (!ticket.Resources.IsComplete)
{
if (!ticket.IsQueued)
{
ticket.IsQueued = true;
_objectReleaseQueue.Enqueue(key);
}
LogReleaseFailure(
key,
"resource release",
ticket.Resources.RemainingCount,
attempt);
return false;
}
if (_renderData.TryGetValue(key, out ObjectRenderData? current)
&& ReferenceEquals(current, ticket.Data))
{
_renderData.TryRemove(key, out _);
}
_objectReleases.Remove(key);
MarkRenderDataAvailabilityChanged();
if (!_ownership.IsOwned(key))
_ownership.Remove(key);
lock (_lruList)
_lruList.Remove(key);
reclaimedBytes = ticket.ReclaimableBytes;
LogReleaseFailure(key, "resource release", 0, attempt);
return true;
}
private static void ReleaseAtlasTexture(
TextureAtlasManager atlas,
TextureKey key)
{
try
{
atlas.ReleaseTexture(key);
}
catch (Exception error) when (!atlas.HasTexture(key))
{
// TextureAtlasManager removes the logical key before it asks
// the frame-fence owner to recycle the physical layer. An
// observer/publication failure can therefore throw after the
// decrement committed. Translate that observable state into
// the shared committed-outcome contract so no later cleanup
// decrements the same acquisition again.
throw new MeshReferenceMutationException(
$"Texture {key} was released from atlas slot {atlas.Slot}, but its retirement callback failed.",
mutationCommitted: true,
error);
}
}
private bool TryAdvanceUploadRollback(ulong key)
{
if (!_uploadRollbacks.TryGetValue(key, out RetryableResourceReleaseLedger? rollback))
return true;
ResourceReleaseAttempt attempt = rollback.Advance();
if (!rollback.IsComplete)
{
LogReleaseFailure(
key,
"upload rollback",
rollback.RemainingCount,
attempt);
return false;
}
_uploadRollbacks.Remove(key);
LogReleaseFailure(key, "upload rollback", 0, attempt);
return true;
}
private void LogReleaseFailure(
ulong key,
string operation,
int remaining,
ResourceReleaseAttempt attempt)
{
if (!attempt.HasFailures)
return;
_logger.LogError(
attempt.ToException(
$"Mesh 0x{key:X10} {operation} reported exceptional resource outcomes."),
"Mesh 0x{Id:X10} {Operation} completed {Completed} of {Attempted} attempted stages; {Remaining} remain",
key,
operation,
attempt.CompletedCount,
attempt.AttemptedCount,
remaining);
}
private (int Count, long Bytes) AdvancePendingObjectReleases(
int maximumCount,
long maximumBytes)
{
int count = 0;
long bytes = 0;
int attempts = Math.Min(maximumCount, _objectReleaseQueue.Count);
for (int i = 0; i < attempts; i++)
{
ulong key = _objectReleaseQueue.Dequeue();
if (!_objectReleases.TryGetValue(key, out ObjectReleaseTicket? ticket))
continue;
ticket.IsQueued = false;
if (!FitsReclamationBudget(
ticket.ReclaimableBytes,
bytes,
maximumBytes))
{
ticket.IsQueued = true;
_objectReleaseQueue.Enqueue(key);
continue;
}
if (!TryAdvanceObjectRelease(key, out long completedBytes))
continue;
bytes = checked(bytes + completedBytes);
count++;
}
return (count, bytes);
}
private void AdvancePendingUploadRollbacks(int maximumCount)
{
int attempts = Math.Min(maximumCount, _uploadRollbackQueue.Count);
for (int i = 0; i < attempts; i++)
{
ulong key = _uploadRollbackQueue.Dequeue();
if (!_uploadRollbacks.ContainsKey(key))
continue;
try
{
TryAdvanceUploadRollback(key);
}
finally
{
if (_uploadRollbacks.ContainsKey(key))
_uploadRollbackQueue.Enqueue(key);
}
}
}
#endregion
public void Dispose()
{
lock (_disposeGate)
{
if (_disposeCompleted)
return;
if (_disposeRunning)
return;
_disposeRunning = true;
try
{
DisposeCore();
_disposeCompleted = true;
}
finally
{
_disposeRunning = false;
}
}
}
private void DisposeCore()
{
// Quiesce the background decode workers BEFORE returning: the owner
// disposes the DatCollection right after this adapter chain, which
// unmaps the dats' memory-mapped views. A worker still inside
// MemoryMappedBlockAllocator.ReadBlock at that point dereferences the
// dead view pointer — an uncatchable, process-fatal AccessViolation
// (dat-race investigation 2026-06-09). Setting IsDisposed under the
// queue lock publishes it to workers, which re-check it before every
// dequeue; draining the queue means each worker exits after at most
// its current (millisecond-scale) item.
List<Exception>? failures = null;
static void Capture(ref List<Exception>? failures, Action action)
{
try { action(); }
catch (Exception ex) { (failures ??= []).Add(ex); }
}
if (!_workersQuiesced)
{
PreparationRequest[] pendingToCancel;
PreparationRequest[] activeToCancel;
lock (_pendingRequests)
{
IsDisposed = true;
pendingToCancel = _pendingRequests.ToArray();
activeToCancel = _activePreparationById.Values.ToArray();
foreach (PreparationRequest request in pendingToCancel)
_preparationTasks.TryRemove(request.Id, out _);
_pendingRequests.Clear();
_pendingRequestById.Clear();
_envCellDescriptors.Clear();
_terminalPreparationFailures.Clear();
}
foreach (PreparationRequest request in pendingToCancel)
{
Capture(ref failures, request.Cancel);
request.Completion.TrySetCanceled(request.Cancellation.Token);
Capture(ref failures, request.DisposeCancellation);
}
foreach (PreparationRequest request in activeToCancel)
Capture(ref failures, request.Cancel);
// Release every persistent worker from its zero-CPU wait so it can
// observe IsDisposed and terminate before DAT mappings are freed.
Capture(ref failures, _preparationWorkAvailable.Set);
Task[] workers;
lock (_pendingRequests)
workers = _workerTasks.ToArray();
Capture(ref failures, () => Task.WhenAll(workers).GetAwaiter().GetResult());
lock (_pendingRequests)
{
_workerTasks.RemoveWhere(worker => worker.IsCompleted);
if (_workerTasks.Count != 0)
{
(failures ??= []).Add(
new InvalidOperationException("Mesh workers remained live after their join completed."));
}
else
{
_workersQuiesced = true;
}
}
}
// First converge every exact per-object and failed-upload ledger.
// Atlas arrays and the global arena are backing stores for these
// entries and cannot be destroyed until no child release remains.
ulong[] objectIds = _renderData.Keys
.Concat(_objectReleases.Keys)
.Distinct()
.ToArray();
for (int i = 0; i < objectIds.Length; i++)
{
ulong id = objectIds[i];
try
{
if (!TryAdvanceObjectRelease(id, out _))
(failures ??= []).Add(new InvalidOperationException(
$"Mesh 0x{id:X10} still has unfinished resource-release stages."));
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
ulong[] rollbackIds = _uploadRollbacks.Keys.ToArray();
for (int i = 0; i < rollbackIds.Length; i++)
{
ulong id = rollbackIds[i];
try
{
if (!TryAdvanceUploadRollback(id))
(failures ??= []).Add(new InvalidOperationException(
$"Mesh 0x{id:X10} still has unfinished upload-rollback stages."));
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
if (_objectReleases.Count != 0 || _uploadRollbacks.Count != 0)
{
throw new AggregateException(
"One or more mesh resource transactions remain unfinished.",
failures ?? [new InvalidOperationException("Mesh resource teardown did not converge.")]);
}
// Every layer release has now committed logically. Publish any
// callback which previously failed before queue acceptance.
Capture(ref failures, RetryPendingAtlasRetirements);
Capture(ref failures, RetryRetiringAtlasDisposals);
bool atlasFailure = false;
foreach (List<TextureAtlasManager> atlasList in _globalAtlases.Values)
{
foreach (TextureAtlasManager atlas in atlasList)
{
try { atlas.Dispose(); }
catch (Exception error)
{
atlasFailure = true;
(failures ??= []).Add(error);
}
}
}
if (atlasFailure)
throw new AggregateException(
"One or more texture atlases could not be disposed.",
failures!);
if (GlobalBuffer is not null)
Capture(ref failures, GlobalBuffer.Dispose);
if (failures is not null)
throw new AggregateException("One or more mesh-manager teardown operations failed.", failures);
_renderData.Clear();
_objectReleases.Clear();
MarkRenderDataAvailabilityChanged();
_objectReleaseQueue.Clear();
_uploadRollbacks.Clear();
_uploadRollbackQueue.Clear();
_globalAtlases.Clear();
// Slice V4t: teardown drops the retiring owners without releasing
// their table entries. The device is torn down alongside this
// manager, so there is nothing left to recycle a slot into — and
// asking a possibly-already-disposed device to defer work through
// its retirement queue would turn a clean shutdown into a throw.
_retiringAtlases.Clear();
_dirtyAtlases.Clear();
_safeEmptyAtlases.Clear();
_currentNonArenaGpuMemory = 0;
_cpuMeshCache.Clear();
lock (_lruList)
_lruList.Clear();
if (!_workSignalDisposed)
{
_preparationWorkAvailable.Dispose();
_workSignalDisposed = true;
}
}
}
}