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();
}
});
}
}
}