using AcDream.Core.Rendering.Wb; using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; // Use our extracted TextureHelpers (T3), not the WB original — disambiguate explicitly using TextureHelpers = AcDream.Core.Rendering.Wb.TextureHelpers; using Microsoft.Extensions.Logging; using Silk.NET.OpenGL; using System.Runtime.InteropServices; using AcDream.App.Rendering; namespace AcDream.App.Rendering.Wb { public class ManagedGLTextureArray : ITextureArray { private readonly bool[] _usedLayers; private readonly GL GL; private readonly OpenGLGraphicsDevice _device; private readonly ILogger _logger; private static int _nextId = 0; private bool _needsMipmapRegeneration = false; private readonly bool _isCompressed; private int _mipmapDirtyCount = 0; private readonly object _mipmapLock = new object(); private readonly List _pendingUpdates = new(); private int _disposeQueued; private int _disposePublicationQueued; private int _disposeRetirementAccepted; private RetryableGpuResourceRelease? _disposeRelease; private struct TextureLayerUpdate { public int Layer; public required byte[] Data; public PixelFormat? UploadPixelFormat; public PixelType? UploadPixelType; } public int Slot { get; } = _nextId++; public int Width { get; private set; } public int Height { get; private set; } public int Size { get; private set; } public TextureFormat Format { get; private set; } public nint NativePtr { get; private set; } public ulong BindlessWrapHandle { get; private set; } public ulong BindlessClampHandle { get; private set; } public long TotalSizeInBytes => CalculateTotalSize(); /// /// #105 diagnostic: staged layer updates (retained decoded payloads) not yet /// applied to the GL texture by . Layers with /// a pending update sample UNDEFINED content (TexStorage3D contents) until the /// flush runs — a stuck non-zero count at standstill is the white-walls mechanism. /// public int PendingUpdateCount { get { lock (_mipmapLock) { return _pendingUpdates.Count; } } } public ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height, int size, ILogger logger, TextureParameters? texParams = null) { var p = texParams ?? TextureParameters.Default; if (width <= 0 || height <= 0 || size <= 0) { throw new ArgumentException($"Invalid texture array dimensions: {width}x{height}x{size}"); } Format = format; Width = width; Height = height; Size = size; _usedLayers = new bool[size]; _device = graphicsDevice; GL = graphicsDevice.GL; _logger = logger; _isCompressed = IsCompressedFormat(format); GLHelpers.CheckErrors(GL); uint textureName = 0; ulong wrapHandle = 0; ulong clampHandle = 0; bool textureTracked = false; bool textureBytesTracked = false; bool wrapResident = false; bool clampResident = false; long textureBytes = CalculateTotalSize(); try { textureName = GL.GenTexture(); if (textureName == 0) throw new InvalidOperationException("Failed to generate texture array."); GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture); textureTracked = true; GL.BindTexture(GLEnum.Texture2DArray, textureName); int maxDimension = Math.Max(width, height); int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1; GL.TexStorage3D(GLEnum.Texture2DArray, (uint)mipLevels, format.ToGL(), (uint)width, (uint)height, (uint)size); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter, (int)p.MinFilter); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, mipLevels - 1); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMagFilter, (int)p.MagFilter); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapS, (int)p.WrapS); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureWrapT, (int)p.WrapT); if (p.EnableAnisotropicFiltering && graphicsDevice.RenderSettings.EnableAnisotropicFiltering && graphicsDevice.MaxSupportedAnisotropy > 0) { GL.TexParameter( GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, graphicsDevice.MaxSupportedAnisotropy); } if (format == TextureFormat.A8) { GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleR, (int)GLEnum.One); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleG, (int)GLEnum.One); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleB, (int)GLEnum.One); GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureSwizzleA, (int)GLEnum.Red); } GLHelpers.ThrowOnResourceError( GL, $"creating texture array {format} {width}x{height}x{size} ({mipLevels} mip levels)"); GpuMemoryTracker.TrackAllocation(textureBytes, GpuResourceType.Texture); textureBytesTracked = true; if (_device.HasBindless && _device.BindlessExtension != null) { wrapHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.WrapSampler); clampHandle = _device.BindlessExtension.GetTextureSamplerHandle(textureName, _device.ClampSampler); _device.BindlessExtension.MakeTextureHandleResident(wrapHandle); wrapResident = true; _device.BindlessExtension.MakeTextureHandleResident(clampHandle); clampResident = true; GLHelpers.ThrowOnResourceError(GL, "making texture-array sampler handles resident"); } NativePtr = (nint)textureName; BindlessWrapHandle = wrapHandle; BindlessClampHandle = clampHandle; } catch (Exception constructionFailure) { // Constructor failure cannot use Dispose: the object was never // published and queued teardown would make retries accumulate // invalid resident handles. Attempt every independent cleanup. List? cleanupFailures = null; void Attempt(Action cleanup) { try { cleanup(); } catch (Exception ex) { (cleanupFailures ??= []).Add(ex); } } if (_device.BindlessExtension != null) { if (clampResident) Attempt(() => { _device.BindlessExtension.MakeTextureHandleNonResident(clampHandle); GLHelpers.ThrowOnResourceError(GL, "rolling back clamp texture-array handle"); clampResident = false; }); if (wrapResident) Attempt(() => { _device.BindlessExtension.MakeTextureHandleNonResident(wrapHandle); GLHelpers.ThrowOnResourceError(GL, "rolling back wrap texture-array handle"); wrapResident = false; }); } // Deleting a texture while either bindless sampler handle is // still resident is undefined. A pre-commit residency failure // therefore retains the texture instead of risking a driver // reset during constructor rollback. if (textureName != 0 && !clampResident && !wrapResident) Attempt(() => { GL.DeleteTexture(textureName); GLHelpers.ThrowOnResourceError(GL, "rolling back texture array"); if (textureBytesTracked) GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture); if (textureTracked) GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); }); if (cleanupFailures is not null) { cleanupFailures.Insert(0, constructionFailure); throw new AggregateException( "Texture-array construction and rollback both failed.", cleanupFailures); } throw; } finally { GL.ActiveTexture(TextureUnit.Texture0); GL.BindTexture(GLEnum.Texture2DArray, 0); RenderStateCache.CurrentAtlas = 0; } } public long CalculateTotalSize() { int maxDimension = Math.Max(Width, Height); int mipLevels = (int)Math.Floor(Math.Log2(maxDimension)) + 1; long layerSize = GetExpectedDataSize(); long totalSize = 0; for (int i = 0; i < mipLevels; i++) { int w = Math.Max(1, Width >> i); int h = Math.Max(1, Height >> i); if (_isCompressed) { totalSize += TextureHelpers.GetCompressedLayerSize(w, h, Format) * Size; } else { totalSize += (long)w * h * (layerSize / (Width * Height)) * Size; } } return totalSize; } private static bool IsCompressedFormat(TextureFormat format) { return format == TextureFormat.DXT1 || format == TextureFormat.DXT3 || format == TextureFormat.DXT5; } public void Bind(int slot = 0) { if (NativePtr == 0) { return; } GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture); GLEnum targetTextureUnit = GLEnum.Texture0 + slot; bool changedUnit = (GLEnum)oldActiveTexture != targetTextureUnit; if (changedUnit) { GL.ActiveTexture(targetTextureUnit); } GL.BindSampler((uint)slot, 0); GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); if (changedUnit) { GL.ActiveTexture((GLEnum)oldActiveTexture); } GLHelpers.CheckErrors(GL); } public unsafe int AddLayer(byte[] data) { return AddLayer(data, null, null); } public unsafe int AddLayer(byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType) { for (int i = 0; i < _usedLayers.Length; i++) { if (!_usedLayers[i]) { UpdateLayerInternal(i, data, uploadPixelFormat, uploadPixelType); _usedLayers[i] = true; return i; } } throw new InvalidOperationException( $"No free layers available in texture array (Slot={Slot}, Size={Width}x{Height}x{Size})."); } public unsafe int AddLayer(Span data) { return AddLayer(data.ToArray()); } public void UpdateLayer(int layer, byte[] data) { UpdateLayer(layer, data, null, null); } public void UpdateLayer(int layer, byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType) { UpdateLayerInternal(layer, data, uploadPixelFormat, uploadPixelType); _usedLayers[layer] = true; } private unsafe void UpdateLayerInternal(int layer, byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType) { if (NativePtr == 0) { throw new InvalidOperationException("Texture array not created."); } if (layer < 0 || layer >= Size) { throw new ArgumentOutOfRangeException(nameof(layer), $"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot})."); } ValidateUploadPayload( Format, Width, Height, data.Length, uploadPixelFormat, uploadPixelType); lock (_mipmapLock) { // Retain the immutable decoded payload until the once-per-frame // atlas flush. The former per-atlas PBO permanently reserved // several MiB for every array and duplicated each upload // through BufferSubData before TexSubImage3D. var update = new TextureLayerUpdate { Layer = layer, Data = data, UploadPixelFormat = uploadPixelFormat, UploadPixelType = uploadPixelType }; int existingIndex = _pendingUpdates.FindLastIndex(pending => pending.Layer == layer); if (existingIndex >= 0) _pendingUpdates[existingIndex] = update; else _pendingUpdates.Add(update); _needsMipmapRegeneration = true; if (existingIndex < 0) _mipmapDirtyCount++; } } public long ProcessDirtyUpdates() { lock (_mipmapLock) { return ProcessDirtyUpdatesInternal(generateMipmaps: true); } } private unsafe long ProcessDirtyUpdatesInternal(bool generateMipmaps) { if (_pendingUpdates.Count == 0 && (!generateMipmaps || !_needsMipmapRegeneration)) return 0; long generatedBytes = 0; GLHelpers.CheckErrors(GL); // This runs in WbMeshAdapter.Tick before any draw pass. Establish // the upload phase's canonical texture state directly instead of // synchronously querying driver state for every dirty array. GL.ActiveTexture(TextureUnit.Texture0); RenderStateCache.CurrentAtlas = 0; bool mipmapWorkCompleted = false; try { GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr); if (_pendingUpdates.Count > 0) { // A non-zero pixel-unpack binding changes pointer arguments // into byte offsets. Direct client-memory uploads therefore // establish the canonical zero binding once for the batch. GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0); GL.PixelStore(PixelStoreParameter.UnpackAlignment, 1); GL.PixelStore(PixelStoreParameter.UnpackRowLength, 0); GL.PixelStore(PixelStoreParameter.UnpackSkipRows, 0); GL.PixelStore(PixelStoreParameter.UnpackSkipPixels, 0); foreach (var update in _pendingUpdates) { fixed (byte* data = update.Data) { if (_isCompressed) { var internalFormat = Format.ToCompressedGL(); GL.CompressedTexSubImage3D( GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1, internalFormat, (uint)update.Data.Length, data); } else { var pixelFormat = update.UploadPixelFormat ?? Format.ToPixelFormat(); var pixelType = update.UploadPixelType ?? Format.ToPixelType(); GL.TexSubImage3D( GLEnum.Texture2DArray, 0, 0, 0, update.Layer, (uint)Width, (uint)Height, 1, pixelFormat, pixelType, data); } } } } if (generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0) { if (_isCompressed) { _logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot); } else { try { // Width, height and format were validated when the // immutable storage was allocated. Re-reading them // here forced three CPU/GPU synchronization points // for every dirty atlas without adding safety. GL.GenerateMipmap(GLEnum.Texture2DArray); generatedBytes = TotalSizeInBytes; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}); retaining upload state for retry.", Slot); throw; } } } // Release builds must observe transfer/OOM/context errors // before the pending offsets and dirty mip state are cleared. // One check covers every layer in this array plus its single // mip generation, keeping the synchronization cost bounded by // dirty arrays rather than uploaded textures. GLHelpers.ThrowOnResourceError( GL, $"committing texture-array updates (Slot={Slot}, Layers={_pendingUpdates.Count})"); mipmapWorkCompleted = generateMipmaps && _needsMipmapRegeneration && _mipmapDirtyCount > 0; } finally { GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0); GL.BindTexture(GLEnum.Texture2DArray, 0); GL.ActiveTexture(TextureUnit.Texture0); } // Commit CPU-side completion only after glGetError confirms the // uploads/mipmap work succeeded. If the driver rejects an // operation, the retained payloads and dirty flags remain intact and the // atlas stays in ObjectMeshManager's dirty set for a later retry. _pendingUpdates.Clear(); if (mipmapWorkCompleted) { _mipmapDirtyCount = 0; _needsMipmapRegeneration = false; } return generatedBytes; } private void ClearLayerForMipmap(int layer) { // Upload a single black/transparent pixel to make layer defined byte[] clearData = new byte[GetExpectedDataSize()]; Array.Clear(clearData, 0, clearData.Length); // Zero-fill (black/transparent) UpdateLayerInternal(layer, clearData, null, null); } private int GetExpectedDataSize() { return CalculateExpectedDataSize(Format, Width, Height); } internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) { if (IsCompressedFormat(format)) return TextureHelpers.GetCompressedLayerSize(width, height, format); return format switch { TextureFormat.RGBA8 => checked(width * height * 4), TextureFormat.RGB8 => checked(width * height * 3), TextureFormat.A8 => checked(width * height), TextureFormat.Rgba32f => checked(width * height * 16), _ => throw new NotSupportedException($"Unsupported format {format}") }; } internal static void ValidateUploadPayload( TextureFormat format, int width, int height, int dataLength, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType) { int expectedBytes = CalculateExpectedDataSize(format, width, height); if (dataLength != expectedBytes) { throw new ArgumentException( $"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} " + $"for {format} {width}x{height}.", nameof(dataLength)); } if (IsCompressedFormat(format)) { if (uploadPixelFormat.HasValue || uploadPixelType.HasValue) throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides."); return; } PixelFormat expectedFormat = format.ToPixelFormat(); PixelType expectedType = format.ToPixelType(); if ((uploadPixelFormat ?? expectedFormat) != expectedFormat || (uploadPixelType ?? expectedType) != expectedType) { throw new ArgumentException( $"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match " + $"the {expectedFormat}/{expectedType} transfer required by {format}."); } } public void RemoveLayer(int layer) { if (layer < 0 || layer >= Size) { throw new ArgumentOutOfRangeException(nameof(layer), $"Layer index {layer} is out of range [0, {Size - 1}] (Slot={Slot})."); } if (!_usedLayers[layer]) { throw new InvalidOperationException($"Layer {layer} is already free (Slot={Slot})."); } _usedLayers[layer] = false; // An unreferenced layer needs no clear or whole-array mip // regeneration before AddTexture overwrites it on reuse. } public bool IsLayerUsed(int layer) { if (layer < 0 || layer >= Size) return false; return _usedLayers[layer]; } public int GetUsedLayerCount() { return _usedLayers.Count(x => x); } /// /// True once disposal is durably owned by a queued GL publication, /// the frame-retirement queue, or a completed retained release. A /// caller may only commit its own logical disposal after this becomes /// true; otherwise a synchronous enqueue failure still needs retry. /// internal bool HasDurableDisposeOwnership { get { if (Volatile.Read(ref _disposeQueued) == 0) return false; return Volatile.Read(ref _disposePublicationQueued) != 0 || Volatile.Read(ref _disposeRetirementAccepted) != 0 || Volatile.Read(ref _disposeRelease) is null; } } public void Unbind() { GL.BindTexture(GLEnum.Texture2DArray, 0); GLHelpers.CheckErrors(GL); } public void GenerateMipmaps() { _needsMipmapRegeneration = true; lock (_mipmapLock) { _mipmapDirtyCount++; } } public void Dispose() { if (Interlocked.CompareExchange(ref _disposeQueued, 1, 0) != 0) { ScheduleDisposeRelease(); return; } uint textureName = (uint)NativePtr; ulong bindlessWrapHandle = BindlessWrapHandle; ulong bindlessClampHandle = BindlessClampHandle; long textureBytes = CalculateTotalSize(); NativePtr = 0; BindlessWrapHandle = 0; BindlessClampHandle = 0; _disposeRelease = new RetryableGpuResourceRelease( () => { if (_device.BindlessExtension != null && bindlessWrapHandle != 0) GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle (precondition)"); }, () => { if (_device.BindlessExtension != null && bindlessWrapHandle != 0) { _device.BindlessExtension.MakeTextureHandleNonResident(bindlessWrapHandle); GLHelpers.ThrowOnResourceError(GL, "releasing wrap texture-array handle"); } }, () => { if (_device.BindlessExtension != null && bindlessClampHandle != 0) GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle (precondition)"); }, () => { if (_device.BindlessExtension != null && bindlessClampHandle != 0) { _device.BindlessExtension.MakeTextureHandleNonResident(bindlessClampHandle); GLHelpers.ThrowOnResourceError(GL, "releasing clamp texture-array handle"); } }, () => { if (textureName != 0) GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName} (precondition)"); }, () => { if (textureName != 0) { GL.DeleteTexture(textureName); GLHelpers.ThrowOnResourceError(GL, $"deleting texture array {textureName}"); } }, () => { if (textureName != 0) GpuMemoryTracker.TrackDeallocation(textureBytes, GpuResourceType.Texture); }, () => { if (textureName != 0) GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture); }, () => _disposeRelease = null); ScheduleDisposeRelease(); } private void ScheduleDisposeRelease(bool forNextPass = false) { RetryableGpuResourceRelease? release = _disposeRelease; if (release is null || release.IsComplete || Volatile.Read(ref _disposeRetirementAccepted) != 0) return; if (Interlocked.CompareExchange(ref _disposePublicationQueued, 1, 0) != 0) return; try { Action publish = GL => { Volatile.Write(ref _disposePublicationQueued, 0); try { _device.RetireGpuResource(release.Run); Volatile.Write(ref _disposeRetirementAccepted, 1); } catch { // Retire may fail before accepting the callback, or an // immediate queue may surface a partial release. The // release cursor makes this next-pass retry exact. ScheduleDisposeRelease(forNextPass: true); throw; } }; if (forNextPass) _device.QueueGLActionForNextPass(publish); else _device.QueueGLAction(publish); } catch { Volatile.Write(ref _disposePublicationQueued, 0); throw; } } } }