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>
2657 lines
113 KiB
C#
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;
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|