acdream/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
Erik 3718e341be fix #225: stabilize render pacing and frame CPU
Replace scheduler-quantized software sleeps with a reusable Windows high-resolution deadline timer, expose pacing in the frame profiler, and make shutdown wake every persistent mesh worker without losing the shared signal.

Preserve retail alpha order while using a stable radix, skip duplicate deferred-alpha SSBO packing, pack light sets, cache static selection descriptors, and retire historical material groups at the whole-frame boundary. The fixed dense-Caul sample improved from roughly 9-12 ms CPU to 5.3-6.2 ms without reducing visual quality.

Release build succeeds with zero warnings and all 6,300 tests pass with five intentional skips. Three independent retail, architecture, and adversarial reviews are clean; the post-review connected route remains pending because local ACE is offline.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-19 06:29:30 +02:00

2657 lines
113 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 Silk.NET.OpenGL;
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.Core.Rendering.Wb;
using PixelFormat = Silk.NET.OpenGL.PixelFormat;
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
{
public uint VAO { get; set; }
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
{
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 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; }
public ulong BindlessTextureHandle { get; set; }
}
/// <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
{
private readonly OpenGLGraphicsDevice _graphicsDevice;
private readonly IDatReaderWriter _dats;
private readonly ILogger _logger;
/// <summary>
/// MP1a (2026-07-05): the GL-free CPU extraction half, verbatim-moved to
/// AcDream.Content so the MP1b bake tool can run it without a GL context.
/// Owns the dat read → mesh build → inline texture decode pipeline; this
/// class keeps the queue/worker lifecycle and all GL upload.
/// </summary>
private readonly MeshExtractor _extractor;
internal IDatReaderWriter Dats => _dats;
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();
// 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, (Vector3 Min, Vector3 Max)?> _boundsCache = new();
private readonly ConcurrentDictionary<ulong, Task<ObjectMeshData?>> _preparationTasks = new();
// LRU Cache for Unused objects
private readonly LinkedList<ulong> _lruList = new();
private readonly long _maxGpuMemory = 1024 * 1024 * 1024; // 1GB
private readonly int _maxCachedObjects = 50; // Max number of cached objects (count-based limit)
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);
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
private readonly int _maxCpuCacheSize = 100;
private readonly CpuMeshUploadCache _cpuMeshCache;
private readonly MeshUploadStagingQueue _stagedMeshData = new();
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; }
private readonly bool _useModernRendering;
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 physicalBytes = CalculateTrackedGpuBytes(
_currentNonArenaGpuMemory,
GlobalBuffer?.PhysicalCapacityBytes ?? 0);
return (_renderData.Count, atlasArrays, _lruList.Count, physicalBytes);
}
}
internal (int Count, long Bytes) CpuCacheDiagnostics =>
(_cpuMeshCache.Count, _cpuMeshCache.ResidentBytes);
private sealed class PreparationRequest(
ulong id,
bool isSetup,
EnvCellGeomRequest? envCell,
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 ulong Id { get; } = id;
public bool IsSetup { get; } = isSetup;
public EnvCellGeomRequest? EnvCell { get; } = envCell;
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; }
}
public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger<ObjectMeshManager> logger)
{
_graphicsDevice = graphicsDevice;
_dats = dats;
_logger = logger;
// Side-stage sink: particle-preload meshes staged mid-extraction go
// straight onto the staged-upload queue, exactly as the pre-MP1a code
// did — immediate enqueue, surviving a later throw in the same
// Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the
// extractor's up-to-4 concurrent decode workers.
_cpuMeshCache = new CpuMeshUploadCache(_maxCpuCacheSize);
_extractor = new MeshExtractor(_dats, _logger, data =>
{
// Side-staged particle/setup dependencies obey the same bounded
// producer policy as primary results. Keeping them in the CPU
// cache makes a later explicit owner able to re-arm upload.
_cpuMeshCache.Store(data);
if (_ownership.IsOwned(data.ObjectId))
_stagedMeshData.Stage(data);
});
_useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless;
if (_useModernRendering)
{
GlobalBuffer = new GlobalMeshBuffer(
_graphicsDevice.GL,
_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>
/// 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()
{
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);
victim.Dispose();
return true;
}
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="ManagedGLTextureArray.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);
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;
while (reclaimedCount < maximumCount
&& attemptedCandidates < candidateBudget)
{
ulong idToEvict;
lock (_lruList)
{
long physicalArenaBytes = GlobalBuffer?.PhysicalCapacityBytes ?? 0;
if (!forceArenaReclamation
&& IsWithinGpuCacheBudget(
_currentNonArenaGpuMemory,
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 (_useModernRendering && 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 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 environmentId, ushort cellStructure, List<ushort> surfaces, CancellationToken ct = default)
{
if (IsDisposed || HasRenderData(geomId)) return Task.FromResult<ObjectMeshData?>(null);
var envCell = new EnvCellGeomRequest
{
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.TryGetAndStage(
geomId,
_stagedMeshData,
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(
geomId,
false,
envCell,
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.TryGetAndStage(
id,
_stagedMeshData,
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;
var request = new PreparationRequest(
id,
isSetup,
envCell,
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 the DAT 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;
bool isSetup = request.IsSetup;
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 && request.EnvCell is { } req)
{
uint envId = 0x0D000000u | req.EnvironmentId;
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment))
{
if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct))
{
data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, ct);
// TEMP diagnostic #105 (strip with fix): a null prep here means
// this deduplicated cell geometry will NEVER render anywhere.
if (data == null)
Console.WriteLine($"[geom-null] prepare-null geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
else
{
Console.WriteLine($"[geom-null] cellstruct-missing geom=0x{id:X10} env=0x{envId:X8} cs=0x{req.CellStructure:X4}");
}
}
else
{
Console.WriteLine($"[geom-null] env-read-failed geom=0x{id:X10} env=0x{envId:X8}");
}
}
else if (data is null)
{
// TEMP diagnostic #105 (strip with fix): an EnvCell geom id (bit 33)
// whose pending request vanished gets misrouted to the generic path,
// where its hash-derived low bits resolve to nothing -> silent null.
if ((id & 0x2_0000_0000UL) != 0)
Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it");
// If it's a direct setup or gfxobj, make sure background loads don't abort half-way
data = _extractor.PrepareMeshData(id, isSetup, ct);
}
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 DAT 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.EnvironmentId,
req.CellStructure,
req.Surfaces);
}
else
{
_ = PrepareMeshDataAsync(id, isSetup: false);
}
return false;
}
/// <summary>
/// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT.
/// This loads vertices, indices, and texture data but creates NO GPU resources.
/// Thread-safe: only reads from DAT files.
///
/// MP1a (2026-07-05): delegates to <see cref="MeshExtractor.PrepareMeshData"/>,
/// the verbatim-moved GL-free extraction dispatcher. See <see cref="_extractor"/>.
/// </summary>
public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default)
{
return _extractor.PrepareMeshData(id, isSetup, ct);
}
/// <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.");
_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);
_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);
}
}
/// <summary>
/// Gets bounding box for an object (for frustum culling).
/// </summary>
public (Vector3 Min, Vector3 Max)? GetBounds(ulong id, bool isSetup)
{
if (_boundsCache.TryGetValue(id, out var cachedBounds))
{
return cachedBounds;
}
try
{
(Vector3 Min, Vector3 Max)? result = null;
uint datId = (uint)(id & 0xFFFFFFFFu);
var resolutions = _dats.ResolveId(datId).ToList();
var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault();
if (selectedResolution == null) return null;
var type = selectedResolution.Type;
var db = selectedResolution.Database;
if (type == DBObjType.Setup)
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
bool hasBounds = false;
var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>();
_extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None);
result = hasBounds ? (min, max) : null;
}
else if (type == DBObjType.EnvCell)
{
if (!db.TryGet<EnvCell>(datId, out var envCell)) return null;
// If bit 32 is set, this is a request for the cell's synthetic geometry only
if ((id & 0x1_0000_0000UL) != 0)
{
uint envId = 0x0D000000u | envCell.EnvironmentId;
if (_dats.Portal.TryGet<DatReaderWriter.DBObjs.Environment>(envId, out var environment))
{
if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct))
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var vert in cellStruct.VertexArray.Vertices.Values)
{
min = Vector3.Min(min, vert.Origin);
max = Vector3.Max(max, vert.Origin);
}
result = (min, max);
}
}
}
else
{
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
bool hasBounds = false;
var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>();
_extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None);
result = hasBounds ? (min, max) : null;
}
}
else
{
if (!db.TryGet<GfxObj>(datId, out var gfxObj)) return null;
result = _extractor.ComputeBounds(gfxObj, Vector3.One);
}
_boundsCache[id] = result;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error computing bounds for 0x{Id:X8}", id);
return null;
}
}
#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 unsafe ObjectRenderData? UploadGfxObjMeshData(ObjectMeshData meshData)
{
if (meshData.Vertices.Length == 0) return null;
var gl = _graphicsDevice.GL;
uint vao = 0, vbo = 0;
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)>();
var legacyIndexBuffers = new List<(uint Name, int Bytes)>();
try
{
if (_useModernRendering)
{
// One mesh owns one vertex range and one contiguous index
// range. The former append path duplicated the full vertex
// array per material and never reclaimed evicted ranges.
vao = GlobalBuffer!.VAO;
vbo = GlobalBuffer!.VBO;
}
else
{
gl.GenVertexArrays(1, out vao);
gl.BindVertexArray(vao);
gl.GenBuffers(1, out vbo);
gl.BindBuffer(GLEnum.ArrayBuffer, vbo);
fixed (VertexPositionNormalTexture* ptr = meshData.Vertices)
{
gl.BufferData(GLEnum.ArrayBuffer, (nuint)(meshData.Vertices.Length * VertexPositionNormalTexture.Size), ptr, GLEnum.StaticDraw);
}
GpuMemoryTracker.TrackAllocation(meshData.Vertices.Length * VertexPositionNormalTexture.Size, GpuResourceType.Buffer);
int stride = VertexPositionNormalTexture.Size;
// Position (location 0)
gl.EnableVertexAttribArray(0);
gl.VertexAttribPointer(0, 3, GLEnum.Float, false, (uint)stride, (void*)0);
// Normal (location 1)
gl.EnableVertexAttribArray(1);
gl.VertexAttribPointer(1, 3, GLEnum.Float, false, (uint)stride, (void*)(3 * sizeof(float)));
// TexCoord (location 2)
gl.EnableVertexAttribArray(2);
gl.VertexAttribPointer(2, 2, GLEnum.Float, false, (uint)stride, (void*)(6 * sizeof(float)));
// Instance data (shared VBO)
gl.BindBuffer(GLEnum.ArrayBuffer, _graphicsDevice.InstanceVBO);
for (uint i = 0; i < 4; i++)
{
var loc = 3 + i;
gl.EnableVertexAttribArray(loc);
gl.VertexAttribPointer(loc, 4, GLEnum.Float, false, (uint)sizeof(InstanceData), (void*)(i * 16));
gl.VertexAttribDivisor(loc, 1);
}
gl.EnableVertexAttribArray(8);
gl.VertexAttribIPointer(8, 1, GLEnum.UnsignedInt, (uint)sizeof(InstanceData), (void*)64);
gl.VertexAttribDivisor(8, 1);
}
// 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.
if (_useModernRendering && 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;
uint ibo = 0;
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(
_graphicsDevice,
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 whose
// underlying values are the GL ABI constants (numerically identical
// to Silk.NET.OpenGL.PixelFormat/PixelType), so this lifted nullable
// cast is value- and null-preserving.
bool uploadsNewLayer = !atlasManager.HasTexture(batch.Key);
try
{
textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData,
(PixelFormat?)batch.UploadPixelFormat, (PixelType?)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);
if (_useModernRendering)
{
ibo = GlobalBuffer!.IBO;
}
else
{
gl.GenBuffers(1, out ibo);
gl.BindBuffer(GLEnum.ElementArrayBuffer, ibo);
var indexArray = batch.Indices.ToArray();
fixed (ushort* iptr = indexArray)
{
gl.BufferData(GLEnum.ElementArrayBuffer, (nuint)(indexArray.Length * sizeof(ushort)), iptr, GLEnum.StaticDraw);
}
GpuMemoryTracker.TrackAllocation(indexArray.Length * sizeof(ushort), GpuResourceType.Buffer);
legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort)));
}
ulong bindlessHandle = batch.HasWrappingUVs
? atlasManager.TextureArray.BindlessWrapHandle
: atlasManager.TextureArray.BindlessClampHandle;
renderBatches.Add(new ObjectRenderBatch
{
IBO = ibo,
IndexCount = batch.Indices.Count,
Atlas = atlasManager!,
TextureIndex = textureIndex,
TextureSize = (format.Width, format.Height),
TextureFormat = format.Format,
IsTransparent = batch.IsTransparent,
IsAdditive = batch.IsAdditive,
HasWrappingUVs = batch.HasWrappingUVs,
Key = batch.Key,
CullMode = batch.CullMode,
FirstIndex = firstIndex,
BaseVertex = (uint)batchBaseVertex,
BindlessTextureHandle = bindlessHandle,
});
}
}
if (_useModernRendering && 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
{
VAO = vao,
VBO = vbo,
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(
_useModernRendering,
geometryBytes),
};
if (!_useModernRendering)
{
gl.BindVertexArray(0);
}
return renderData;
}
catch (Exception uploadFailure)
{
RetryableResourceReleaseLedger rollback = CreateUploadRollback(
meshData,
gl,
vao,
vbo,
globalAllocation,
acquiredTextures,
legacyIndexBuffers);
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(
ObjectMeshData meshData,
GL gl,
uint vao,
uint vbo,
GlobalMeshAllocation? globalAllocation,
IReadOnlyList<(TextureAtlasManager Atlas, TextureKey Key)> acquiredTextures,
IReadOnlyList<(uint Name, int Bytes)> legacyIndexBuffers)
{
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)));
}
if (!_useModernRendering)
{
for (int i = 0; i < legacyIndexBuffers.Count; i++)
{
int bufferIndex = i;
releases.Add((
$"legacy-index-buffer-{bufferIndex}-delete",
() => gl.DeleteBuffer(legacyIndexBuffers[bufferIndex].Name)));
releases.Add((
$"legacy-index-buffer-{bufferIndex}-accounting",
() => GpuMemoryTracker.TrackDeallocation(
legacyIndexBuffers[bufferIndex].Bytes,
GpuResourceType.Buffer)));
}
if (vbo != 0)
{
releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(vbo)));
releases.Add((
"legacy-vertex-buffer-accounting",
() => GpuMemoryTracker.TrackDeallocation(
meshData.Vertices.Length * VertexPositionNormalTexture.Size,
GpuResourceType.Buffer)));
}
if (vao != 0)
releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(vao)));
}
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)>();
GL gl = _graphicsDevice.GL;
if (_useModernRendering)
{
if (data.GlobalAllocation is { } allocation)
{
releases.Add((
"global-index-range",
() => GlobalBuffer!.ReleaseIndexRange(allocation)));
releases.Add((
"global-vertex-range",
() => GlobalBuffer!.ReleaseVertexRange(allocation)));
}
}
else
{
if (data.VAO != 0)
releases.Add(("legacy-vertex-array-delete", () => gl.DeleteVertexArray(data.VAO)));
if (data.VBO != 0)
{
releases.Add(("legacy-vertex-buffer-delete", () => gl.DeleteBuffer(data.VBO)));
releases.Add((
"legacy-vertex-buffer-accounting",
() => GpuMemoryTracker.TrackDeallocation(
data.VertexCount * VertexPositionNormalTexture.Size,
GpuResourceType.Buffer)));
}
for (int i = 0; i < data.Batches.Count; i++)
{
int batchIndex = i;
ObjectRenderBatch batch = data.Batches[batchIndex];
if (batch.IBO == 0)
continue;
releases.Add((
$"legacy-index-buffer-{batchIndex}-delete",
() => gl.DeleteBuffer(data.Batches[batchIndex].IBO)));
releases.Add((
$"legacy-index-buffer-{batchIndex}-accounting",
() => GpuMemoryTracker.TrackDeallocation(
data.Batches[batchIndex].IndexCount * sizeof(ushort),
GpuResourceType.Buffer)));
}
}
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);
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);
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);
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 (_useModernRendering && 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();
_objectReleaseQueue.Clear();
_uploadRollbacks.Clear();
_uploadRollbackQueue.Clear();
_globalAtlases.Clear();
_dirtyAtlases.Clear();
_safeEmptyAtlases.Clear();
_currentNonArenaGpuMemory = 0;
_cpuMeshCache.Clear();
lock (_lruList)
_lruList.Clear();
if (!_workSignalDisposed)
{
_preparationWorkAvailable.Dispose();
_workSignalDisposed = true;
}
}
}
}