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 { /// /// GPU-side render data created on the main thread. /// public class ObjectRenderData { public uint VAO { get; set; } public uint VBO { get; set; } public int VertexCount { get; set; } public List Batches { get; set; } = new(); public bool IsSetup { get; set; } public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); /// Particle emitters from physics scripts. public List ParticleEmitters { get; set; } = new(); /// CPU-side vertex positions for raycasting. public Vector3[] CPUPositions { get; set; } = Array.Empty(); /// CPU-side indices for raycasting. public ushort[] CPUIndices { get; set; } = Array.Empty(); /// CPU-side edge line vertices for Environment wireframe rendering. public Vector3[] CPUEdgeLines { get; set; } = Array.Empty(); /// Local bounding box. public BoundingBox BoundingBox { get; set; } /// Approximate center point used for depth sorting / transparency ordering. public Vector3 SortCenter { get; set; } /// DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo. public uint DIDDegrade { get; set; } /// Sphere used for mouse selection. public Sphere? SelectionSphere { get; set; } /// Estimated GPU memory usage in bytes. public long MemorySize { get; set; } } /// /// A single GPU draw batch: IBO + texture array layer. /// 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; } } /// /// 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(). /// public class ObjectMeshManager : IDisposable { private readonly OpenGLGraphicsDevice _graphicsDevice; private readonly IDatReaderWriter _dats; private readonly ILogger _logger; /// /// 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. /// private readonly MeshExtractor _extractor; internal IDatReaderWriter Dats => _dats; public bool IsDisposed { get; private set; } private readonly ConcurrentDictionary _renderData = new(); private readonly ConcurrentDictionary _usageCount = new(); private readonly ConcurrentDictionary _boundsCache = new(); private readonly ConcurrentDictionary> _preparationTasks = new(); // LRU Cache for Unused objects private readonly LinkedList _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 _currentGpuMemory = 0; // Shared atlases grouped by (Width, Height, Format) private readonly Dictionary<(int Width, int Height, TextureFormat Format), List> _globalAtlases = new(); // CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT) private readonly Dictionary _cpuMeshCache = new(); private readonly LinkedList _cpuLruList = new(); private readonly int _maxCpuCacheSize = 100; private readonly ConcurrentQueue _stagedMeshData = new(); public ConcurrentQueue StagedMeshData => _stagedMeshData; /// #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 . public const int MaxUploadRetries = 3; /// /// #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. Increments the mesh-data's own attempt counter (resets /// on re-prepare) and gives up loudly past . /// public bool UploadOrRequeue(ObjectMeshData meshData) { if (UploadMeshData(meshData) is not null) return false; // success (incl. legitimate 0-vertex → empty render data) if (HasRenderData(meshData.ObjectId)) return false; // raced to present by another path meshData.UploadAttempts++; if (meshData.UploadAttempts < MaxUploadRetries) return true; // re-stage for next frame 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; } public GlobalMeshBuffer? GlobalBuffer { get; } private readonly bool _useModernRendering; private readonly List<(ulong Id, bool IsSetup, TaskCompletionSource Tcs, CancellationToken Ct)> _pendingRequests = new(); private int _activeWorkers = 0; private const int MaxParallelLoads = 4; public ObjectMeshManager(OpenGLGraphicsDevice graphicsDevice, IDatReaderWriter dats, ILogger 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. _extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data)); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); } } /// /// Get existing GPU render data for an object, or null if not yet uploaded. /// Increments reference count. /// public ObjectRenderData? GetRenderData(ulong id) { if (_renderData.TryGetValue(id, out var data)) { _usageCount.AddOrUpdate(id, 1, (_, count) => count + 1); if (data.IsSetup) { foreach (var (partId, _) in data.SetupParts) { IncrementRefCount(partId); } } else { // Increment ref counts for all textures in this GfxObj foreach (var batch in data.Batches) { if (batch.Atlas != null) { batch.Atlas.AddTexture(batch.Key, Array.Empty()); } } } // If it was in LRU, remove it as it's now in use lock (_lruList) { _lruList.Remove(id); } return data; } return null; } /// /// Check if GPU render data exists for an object. /// public bool HasRenderData(ulong id) => _renderData.ContainsKey(id); /// /// Get existing GPU render data without modifying reference count. /// Use this for render-loop lookups where you don't want to affect lifecycle. /// public ObjectRenderData? TryGetRenderData(ulong id) { return _renderData.TryGetValue(id, out var data) ? data : null; } /// /// Increment reference count for an object (e.g. when a landblock starts using it). /// public void IncrementRefCount(ulong id) { _usageCount.AddOrUpdate(id, 1, (_, count) => count + 1); lock (_lruList) { _lruList.Remove(id); } } public void GenerateMipmaps() { foreach (var atlasList in _globalAtlases.Values) { foreach (var atlas in atlasList) { atlas.TextureArray.ProcessDirtyUpdates(); } } } /// /// #105 diagnostic: counts staged-but-unflushed texture layer updates across all /// shared atlases (see ). /// Render thread only — _globalAtlases is render-thread-owned. /// 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); } /// /// Decrement reference count and unload GPU resources if no longer needed. /// public void DecrementRefCount(ulong id) { var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1); if (newCount <= 0) { // Instead of unloading, move to LRU lock (_lruList) { _lruList.Remove(id); _lruList.AddLast(id); } } } /// /// Decrement reference count and unload if no longer needed. /// public void ReleaseRenderData(ulong id) { if (_usageCount.TryGetValue(id, out var count) && count > 0) { var newCount = _usageCount.AddOrUpdate(id, 0, (_, c) => c - 1); if (newCount <= 0) { // Instead of unloading, move to LRU lock (_lruList) { _lruList.Remove(id); _lruList.AddLast(id); } } } } private void EvictOldResources(long neededBytes = 0) { lock (_lruList) { // Evict based on memory OR count limit while ((_currentGpuMemory + neededBytes) > _maxGpuMemory || _lruList.Count > _maxCachedObjects) { var idToEvict = _lruList.First!.Value; _lruList.RemoveFirst(); if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) { UnloadObject(idToEvict); _usageCount.TryRemove(idToEvict, out _); } } } } /// /// Force evict all unused objects from the cache. /// Use this when navigating away from a view or changing filters to free memory. /// public void EvictAllUnused() { lock (_lruList) { while (_lruList.Count > 0) { var idToEvict = _lruList.First!.Value; _lruList.RemoveFirst(); if (_usageCount.TryGetValue(idToEvict, out var count) && count <= 0) { UnloadObject(idToEvict); _usageCount.TryRemove(idToEvict, out _); } } } // Also clear CPU mesh cache lock (_cpuMeshCache) { _cpuMeshCache.Clear(); _cpuLruList.Clear(); } } public struct EnvCellGeomRequest { public uint EnvironmentId; public ushort CellStructure; public List Surfaces; } private readonly ConcurrentDictionary _pendingEnvCellRequests = new(); /// /// Phase 1 (Background Thread): Prepare CPU-side mesh data for deduplicated EnvCell geometry. /// public Task PrepareEnvCellGeomMeshDataAsync(ulong geomId, uint environmentId, ushort cellStructure, List surfaces, CancellationToken ct = default) { if (IsDisposed || HasRenderData(geomId)) return Task.FromResult(null); // Check CPU cache first lock (_cpuMeshCache) { if (_cpuMeshCache.TryGetValue(geomId, out var cachedData)) { _cpuLruList.Remove(geomId); _cpuLruList.AddLast(geomId); return Task.FromResult(cachedData); } } // Return existing task if already running or queued if (_preparationTasks.TryGetValue(geomId, out var existing)) { return existing; } var tcs = new TaskCompletionSource(); var task = tcs.Task; _preparationTasks[geomId] = task; lock (_pendingRequests) { if (IsDisposed) { tcs.TrySetCanceled(); _preparationTasks.TryRemove(geomId, out _); return task; } // Special handling for EnvCell geometry - we need to store the cell data for the worker _pendingEnvCellRequests[geomId] = new EnvCellGeomRequest { EnvironmentId = environmentId, CellStructure = cellStructure, Surfaces = surfaces }; _pendingRequests.Add((geomId, false, tcs, ct)); if (_activeWorkers < MaxParallelLoads) { _activeWorkers++; Task.Run(ProcessQueueAsync); } } return task; } public Task PrepareMeshDataAsync(ulong id, bool isSetup, CancellationToken ct = default) { if (IsDisposed || HasRenderData(id)) return Task.FromResult(null); // Check CPU cache first lock (_cpuMeshCache) { if (_cpuMeshCache.TryGetValue(id, out var cachedData)) { _cpuLruList.Remove(id); _cpuLruList.AddLast(id); return Task.FromResult(cachedData); } } // Return existing task if already running or queued if (_preparationTasks.TryGetValue(id, out var existing)) { if (!existing.IsFaulted && !existing.IsCanceled) { lock (_pendingRequests) { int idx = _pendingRequests.FindIndex(r => r.Id == id); if (idx >= 0) { var req = _pendingRequests[idx]; _pendingRequests.RemoveAt(idx); _pendingRequests.Add(req); } } return existing; } _preparationTasks.TryRemove(id, out _); } var tcs = new TaskCompletionSource(); var task = tcs.Task; _preparationTasks[id] = task; lock (_pendingRequests) { if (IsDisposed) { tcs.TrySetCanceled(); _preparationTasks.TryRemove(id, out _); return task; } _pendingRequests.Add((id, isSetup, tcs, ct)); if (_activeWorkers < MaxParallelLoads) { _activeWorkers++; Task.Run(ProcessQueueAsync); } } return task; } private async Task ProcessQueueAsync() { try { while (true) { ulong id; bool isSetup; TaskCompletionSource tcs; CancellationToken ct; lock (_pendingRequests) { // IsDisposed re-check: lets Dispose() drain the queue and // observe _activeWorkers reach 0 before the dats unmap. if (IsDisposed || _pendingRequests.Count == 0) { return; } // LIFO: Pick the most recent request var index = _pendingRequests.Count - 1; (id, isSetup, tcs, ct) = _pendingRequests[index]; _pendingRequests.RemoveAt(index); } try { ObjectMeshData? data = null; if (_pendingEnvCellRequests.TryRemove(id, out var req)) { uint envId = 0x0D000000u | req.EnvironmentId; if (_dats.Portal.TryGet(envId, out var environment)) { if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) { data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); // 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 { // 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, CancellationToken.None); } if (data != null) { lock (_cpuMeshCache) { if (_cpuMeshCache.Count >= _maxCpuCacheSize) { var oldest = _cpuLruList.First!.Value; _cpuLruList.RemoveFirst(); _cpuMeshCache.Remove(oldest); } _cpuMeshCache[id] = data; _cpuLruList.AddLast(id); } _stagedMeshData.Enqueue(data); } tcs.TrySetResult(data); } catch (OperationCanceledException) { tcs.TrySetCanceled(ct); } catch (Exception ex) { _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X8}", id); tcs.TrySetException(ex); } finally { _preparationTasks.TryRemove(id, out _); } } } finally { lock (_pendingRequests) { _activeWorkers--; } } } /// /// 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 , /// the verbatim-moved GL-free extraction dispatcher. See . /// public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) { return _extractor.PrepareMeshData(id, isSetup, ct); } /// /// Cancel preparation tasks for IDs that are no longer needed. /// public void CancelStagedUploads(IEnumerable ids) { foreach (var id in ids) { _preparationTasks.TryRemove(id, out _); } } /// /// Phase 2 (Main Thread): Upload prepared mesh data to GPU. /// Creates VAO, VBO, IBOs, and texture arrays. /// Must be called from the GL thread. /// public ObjectRenderData? UploadMeshData(ObjectMeshData meshData) { try { if (_renderData.TryGetValue(meshData.ObjectId, out var existing)) { _preparationTasks.TryRemove(meshData.ObjectId, out _); if (existing.IsSetup) { foreach (var (partId, _) in existing.SetupParts) { IncrementRefCount(partId); lock (_lruList) { _lruList.Remove(partId); } } } else { // Increment ref counts for all textures in this GfxObj foreach (var batch in existing.Batches) { if (batch.Atlas != null) { batch.Atlas.AddTexture(batch.Key, Array.Empty()); } } } IncrementRefCount(meshData.ObjectId); lock (_lruList) { _lruList.Remove(meshData.ObjectId); } return existing; } // Estimated size - evict before allocation long estimatedSize = meshData.IsSetup ? 1024 : (meshData.Vertices.Length * VertexPositionNormalTexture.Size) + meshData.TextureBatches.Values.SelectMany(l => l).Sum(b => (long)b.Indices.Count * sizeof(ushort)); EvictOldResources(estimatedSize); _preparationTasks.TryRemove(meshData.ObjectId, out _); if (meshData.IsSetup) { // Upload EnvCell geometry if present to ensure it's in _renderData if (meshData.EnvCellGeometry != null) { UploadMeshData(meshData.EnvCellGeometry); } // 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(), BoundingBox = meshData.BoundingBox, SortCenter = meshData.SortCenter, DIDDegrade = meshData.DIDDegrade, SelectionSphere = meshData.SelectionSphere, MemorySize = 1024 // Small overhead for the setup itself }; _renderData.TryAdd(meshData.ObjectId, data); IncrementRefCount(meshData.ObjectId); _currentGpuMemory += data.MemorySize; // Increment ref counts for all parts foreach (var (partId, _) in meshData.SetupParts) { IncrementRefCount(partId); } 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); IncrementRefCount(meshData.ObjectId); _currentGpuMemory += renderData.MemorySize; // Clear texture data after upload to save RAM foreach (var batchList in meshData.TextureBatches.Values) { foreach (var batch in batchList) { batch.TextureData = Array.Empty(); } } return renderData; } catch (Exception ex) { _logger.LogError(ex, "Error uploading mesh data for 0x{Id:X8}", meshData.ObjectId); return null; } } /// /// Gets bounding box for an object (for frustum culling). /// 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(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(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(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 /// /// #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). /// internal static HashSet? CollectDrawingBspPolygonIds(GfxObj gfxObj) { if (gfxObj.DrawingBSP?.Root is null) return null; var ids = new HashSet(); CollectDrawingBspPolygonIds(gfxObj.DrawingBSP.Root, ids); return ids; } private static void CollectDrawingBspPolygonIds(DatReaderWriter.Types.DrawingBSPNode node, HashSet 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; if (_useModernRendering) { // Everything goes into the global VBO/IBO 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); } var renderBatches = new List(); 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(); _globalAtlases[format] = atlasList; } atlasManager = atlasList.FirstOrDefault(a => a.FreeSlots > 0 || a.HasTexture(batch.Key)); if (atlasManager == null) { atlasManager = new TextureAtlasManager(_graphicsDevice, format.Width, format.Height, format.Format); atlasList.Add(atlasManager); } // 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. textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, (PixelFormat?)batch.UploadPixelFormat, (PixelType?)batch.UploadPixelType); if (_useModernRendering) { ibo = GlobalBuffer!.IBO; var appended = GlobalBuffer.Append(meshData.Vertices, batch.Indices.ToArray()); batchBaseVertex = appended.baseVertex; firstIndex = (uint)appended.firstIndex; } 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); } 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, }); } } var renderData = new ObjectRenderData { VAO = vao, VBO = vbo, VertexCount = meshData.Vertices.Length, Batches = renderBatches, 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 = (meshData.Vertices.Length * VertexPositionNormalTexture.Size) + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)) }; if (!_useModernRendering) { gl.BindVertexArray(0); } return renderData; } #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 void UnloadObject(ulong key) { if (!_renderData.TryGetValue(key, out var data)) return; var gl = _graphicsDevice.GL; if (!_useModernRendering) { if (data.VAO != 0) gl.DeleteVertexArray(data.VAO); if (data.VBO != 0) { gl.DeleteBuffer(data.VBO); GpuMemoryTracker.TrackDeallocation(data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); } foreach (var batch in data.Batches) { if (batch.IBO != 0) { gl.DeleteBuffer(batch.IBO); GpuMemoryTracker.TrackDeallocation(batch.IndexCount * sizeof(ushort), GpuResourceType.Buffer); } if (batch.Atlas != null) { batch.Atlas.ReleaseTexture(batch.Key); if (batch.Atlas.UsedSlots == 0) { batch.Atlas.Dispose(); var keyTuple = (batch.TextureSize.Width, batch.TextureSize.Height, batch.TextureFormat); if (_globalAtlases.TryGetValue(keyTuple, out var list)) { list.Remove(batch.Atlas); } } } } } else { foreach (var batch in data.Batches) { if (batch.Atlas != null) { batch.Atlas.ReleaseTexture(batch.Key); if (batch.Atlas.UsedSlots == 0) { batch.Atlas.Dispose(); var keyTuple = (batch.TextureSize.Width, batch.TextureSize.Height, batch.TextureFormat); if (_globalAtlases.TryGetValue(keyTuple, out var list)) { list.Remove(batch.Atlas); } } } } } if (data.IsSetup) { foreach (var (partId, _) in data.SetupParts) { DecrementRefCount(partId); } } _currentGpuMemory -= data.MemorySize; _renderData.TryRemove(key, out _); lock (_lruList) { _lruList.Remove(key); } } #endregion public void Dispose() { if (IsDisposed) return; // 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. lock (_pendingRequests) { IsDisposed = true; foreach (var (id, _, tcs, _) in _pendingRequests) { tcs.TrySetCanceled(); _preparationTasks.TryRemove(id, out _); } _pendingRequests.Clear(); _pendingEnvCellRequests.Clear(); } var deadline = System.Environment.TickCount64 + 10_000; while (System.Environment.TickCount64 < deadline) { lock (_pendingRequests) { if (_activeWorkers == 0) break; } Thread.Sleep(5); } lock (_pendingRequests) { if (_activeWorkers > 0) _logger.LogError( "Dispose: {Count} mesh-decode workers still active after 10s — dat teardown may race in-flight reads", _activeWorkers); } _graphicsDevice.QueueGLAction(gl => { foreach (var data in _renderData.Values) { if (!_useModernRendering) { if (data.VAO != 0) gl.DeleteVertexArray(data.VAO); if (data.VBO != 0) { gl.DeleteBuffer(data.VBO); GpuMemoryTracker.TrackDeallocation(data.VertexCount * VertexPositionNormalTexture.Size, GpuResourceType.Buffer); } foreach (var batch in data.Batches) { if (batch.IBO != 0) { gl.DeleteBuffer(batch.IBO); GpuMemoryTracker.TrackDeallocation(batch.IndexCount * sizeof(ushort), GpuResourceType.Buffer); } } } } _renderData.Clear(); foreach (var atlasList in _globalAtlases.Values) { foreach (var atlas in atlasList) { atlas.Dispose(); } } _globalAtlases.Clear(); if (_useModernRendering) { GlobalBuffer?.Dispose(); } }); } } }