acdream/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

630 lines
28 KiB
C#

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<TextureLayerUpdate> _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();
/// <summary>
/// #105 diagnostic: staged layer updates (retained decoded payloads) not yet
/// applied to the GL texture by <see cref="ProcessDirtyUpdates"/>. 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.
/// </summary>
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<Exception>? 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<byte> 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);
}
/// <summary>
/// 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.
/// </summary>
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<GL> 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;
}
}
}
}