feat(O-T3): extract GL infrastructure to AcDream.App

Phase O Task 3 — verbatim-copy GL infra from Chorizite.OpenGLSDLBackend
into src/AcDream.App/Rendering/Wb/ (namespace AcDream.App.Rendering.Wb).

18 files extracted (all namespace-changed; no algorithm changes):
  OpenGLGraphicsDevice, ManagedGLTexture, ManagedGLTextureArray,
  ManagedGLVertexBuffer, ManagedGLIndexBuffer, ManagedGLVertexArray,
  ManagedGLFrameBuffer, ManagedGLUniformBuffer, GLSLShader, GLHelpers,
  GLStateScope, GpuMemoryTracker, SceneData, DebugRenderSettings,
  TextureParameters, TextureFormatExtensions, BufferUsageExtensions,
  EmbeddedResourceReader.

3 internals promoted to public (O-D9):
  EmbeddedResourceReader, TextureFormatExtensions, BufferUsageExtensions.

SixLabors.ImageSharp not reachable: TextureHelpers was placed in
AcDream.Core (no GL/ImageSharp dep); only the GL types went to App.

TextureHelpers.GetCompressedLayerSize added to AcDream.Core.Rendering.Wb
(was in Chorizite.OpenGLSDLBackend.Lib.TextureHelpers; uses
Chorizite.Core.Render.Enums.TextureFormat which Core gets transitively
via the still-present WB project refs).

T3/T4 boundary interims:
  - WbMeshAdapter._graphicsDevice stays Chorizite.OpenGLSDLBackend.OpenGLGraphicsDevice
    (T4 will swap it when ObjectMeshManager is extracted).
  - OpenGLGraphicsDevice.ParticleBatcher deferred to null! (T4 extracts
    ParticleBatcher alongside ObjectMeshManager; can't pass `this` of our
    new type to the WB-original ctor before T4).
  - ManagedGLTextureArray uses our TextureHelpers via explicit alias.
  - IUniformBuffer is in Chorizite.Core.dll under Chorizite.OpenGLSDLBackend
    namespace (unusual packaging); resolved via type alias.
  - AcDream.App.csproj gets explicit Chorizite.Core 0.0.18 PackageReference
    (IUniformBuffer + other Chorizite.Core types now used directly in App).

Build green. Test baseline 1147+8 maintained (1902 passing, 8 pre-existing
MotionInterpreterTests failures unrelated to T3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-21 16:00:31 +02:00
parent 16bc10c99d
commit 4cc38805b5
21 changed files with 3018 additions and 4 deletions

View file

@ -0,0 +1,436 @@
using AcDream.Core.Rendering.Wb;
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
using Chorizite.OpenGLSDLBackend.Lib;
// 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;
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 uint _pboId;
private int _pboSize;
private readonly List<TextureLayerUpdate> _pendingUpdates = new();
private struct TextureLayerUpdate {
public int Layer;
public int Offset;
public int Size;
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 BindlessHandle { get; private set; }
public ulong BindlessWrapHandle { get; private set; }
public ulong BindlessClampHandle { get; private set; }
public long TotalSizeInBytes => CalculateTotalSize();
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);
NativePtr = (nint)GL.GenTexture();
if (NativePtr == 0) {
throw new InvalidOperationException("Failed to generate texture array.");
}
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Texture);
GLHelpers.CheckErrors(GL);
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
GLHelpers.CheckErrors(GL);
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);
GLHelpers.CheckErrorsWithContext(GL,
$"Creating texture array storage (Format={format}, Size={width}x{height}x{size}, MipLevels={mipLevels})");
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMinFilter,
(int)p.MinFilter);
GL.TexParameter(GLEnum.Texture2DArray, TextureParameterName.TextureMaxLevel, (int)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) {
float maxAnisotropy = 0f;
GL.GetFloat(GLEnum.MaxTextureMaxAnisotropy, out maxAnisotropy);
if (maxAnisotropy > 0) {
GL.TexParameter(GLEnum.Texture2DArray, GLEnum.TextureMaxAnisotropy, maxAnisotropy);
}
}
// Set texture swizzle for single-channel formats
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.CheckErrors(GL);
GpuMemoryTracker.TrackAllocation(CalculateTotalSize(), GpuResourceType.Texture);
if (_device.HasBindless && _device.BindlessExtension != null) {
BindlessHandle = _device.BindlessExtension.GetTextureHandle((uint)NativePtr);
BindlessWrapHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.WrapSampler);
BindlessClampHandle = _device.BindlessExtension.GetTextureSamplerHandle((uint)NativePtr, _device.ClampSampler);
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessWrapHandle);
_device.BindlessExtension.MakeTextureHandleResident(BindlessClampHandle);
}
_pboId = GL.GenBuffer();
GpuMemoryTracker.TrackResourceAllocation(GpuResourceType.Buffer);
}
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 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}).");
}
int currentPboOffset = 0;
lock (_mipmapLock) {
if (_pendingUpdates.Count > 0) {
var lastUpdate = _pendingUpdates[^1];
currentPboOffset = lastUpdate.Offset + lastUpdate.Size;
}
// Align to 4 bytes for safety
currentPboOffset = (currentPboOffset + 3) & ~3;
if (currentPboOffset + data.Length > _pboSize) {
// Flush existing updates first because BufferData will orphan/clear the PBO
if (_pendingUpdates.Count > 0) {
ProcessDirtyUpdatesInternal();
}
currentPboOffset = 0;
int newSize = Math.Max(_pboSize * 2, data.Length);
newSize = Math.Max(newSize, GetExpectedDataSize() * 4); // Initial size 4 layers
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
GL.BufferData(GLEnum.PixelUnpackBuffer, (nuint)newSize, (void*)0, GLEnum.StreamDraw);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
}
_pboSize = newSize;
GpuMemoryTracker.TrackAllocation(_pboSize, GpuResourceType.Buffer);
}
else {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
}
fixed (byte* ptr = data) {
GL.BufferSubData(GLEnum.PixelUnpackBuffer, (nint)currentPboOffset, (nuint)data.Length, ptr);
}
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Add(new TextureLayerUpdate {
Layer = layer,
Offset = currentPboOffset,
Size = data.Length,
UploadPixelFormat = uploadPixelFormat,
UploadPixelType = uploadPixelType
});
_needsMipmapRegeneration = true;
_mipmapDirtyCount++;
}
}
public void ProcessDirtyUpdates() {
lock (_mipmapLock) {
ProcessDirtyUpdatesInternal();
}
}
private unsafe void ProcessDirtyUpdatesInternal() {
if (_pendingUpdates.Count == 0 && !_needsMipmapRegeneration) return;
GLHelpers.CheckErrors(GL);
GL.GetInteger(GLEnum.ActiveTexture, out int oldActiveTexture);
BaseObjectRenderManager.CurrentAtlas = 0;
GL.GetInteger(GLEnum.TextureBinding2DArray, out int oldBinding);
GL.BindTexture(GLEnum.Texture2DArray, (uint)NativePtr);
bool wasResident = false;
if (BindlessHandle != 0 && _device.BindlessExtension != null && _device.BindlessExtension.IsTextureHandleResident(BindlessHandle)) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
wasResident = true;
}
if (_pendingUpdates.Count > 0) {
GL.BindBuffer(GLEnum.PixelUnpackBuffer, _pboId);
foreach (var update in _pendingUpdates) {
if (_isCompressed) {
var internalFormat = Format.ToCompressedGL();
GL.CompressedTexSubImage3D(GLEnum.Texture2DArray, 0, 0, 0, update.Layer,
(uint)Width, (uint)Height, 1, internalFormat, (uint)update.Size, (void*)update.Offset);
}
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, (void*)update.Offset);
}
}
GL.BindBuffer(GLEnum.PixelUnpackBuffer, 0);
_pendingUpdates.Clear();
}
if (_needsMipmapRegeneration && _mipmapDirtyCount > 0) {
if (_isCompressed) {
_logger.LogDebug("Skipping automatic mipmap generation for compressed texture array (Slot={Slot})", Slot);
}
else if (!GLHelpers.ValidateTextureMipmapStatus(GL, GLEnum.Texture2DArray, out var errorMessage)) {
_logger.LogWarning("Mipmap validation failed for texture array (Slot={Slot}): {Error}", Slot, errorMessage);
}
else {
try {
GL.GenerateMipmap(GLEnum.Texture2DArray);
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to generate mipmaps for texture array (Slot={Slot}).", Slot);
}
}
_mipmapDirtyCount = 0;
_needsMipmapRegeneration = false;
}
if (wasResident && BindlessHandle != 0 && _device.BindlessExtension != null) {
_device.BindlessExtension.MakeTextureHandleResident(BindlessHandle);
}
GL.BindTexture(GLEnum.Texture2DArray, (uint)oldBinding);
GL.ActiveTexture((GLEnum)oldActiveTexture);
GLHelpers.CheckErrors(GL);
}
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() {
if (_isCompressed) {
return TextureHelpers.GetCompressedLayerSize(Width, Height, Format);
}
return Format switch {
TextureFormat.RGBA8 => Width * Height * 4,
TextureFormat.RGB8 => Width * Height * 3,
TextureFormat.A8 => Width * Height * 1,
TextureFormat.Rgba32f => Width * Height * 16,
_ => throw new NotSupportedException($"Unsupported format {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;
// Make layer defined for mipmap completeness (uncompressed only)
if (!_isCompressed) {
ClearLayerForMipmap(layer);
}
lock (_mipmapLock) {
_mipmapDirtyCount++; // Mark dirty to regen
_needsMipmapRegeneration = true;
}
}
public bool IsLayerUsed(int layer) {
if (layer < 0 || layer >= Size) return false;
return _usedLayers[layer];
}
public int GetUsedLayerCount() {
return _usedLayers.Count(x => x);
}
public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL);
}
public void GenerateMipmaps() {
_needsMipmapRegeneration = true;
lock (_mipmapLock) {
_mipmapDirtyCount++;
}
}
public void Dispose() {
_device.QueueGLAction(GL => {
if (_device.BindlessExtension != null) {
if (BindlessHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessHandle);
BindlessHandle = 0;
}
if (BindlessWrapHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessWrapHandle);
BindlessWrapHandle = 0;
}
if (BindlessClampHandle != 0) {
_device.BindlessExtension.MakeTextureHandleNonResident(BindlessClampHandle);
BindlessClampHandle = 0;
}
}
if (NativePtr != 0) {
GL.DeleteTexture((uint)NativePtr);
GLHelpers.CheckErrors(GL);
GpuMemoryTracker.TrackDeallocation(CalculateTotalSize(), GpuResourceType.Texture);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Texture);
NativePtr = 0;
}
if (_pboId != 0) {
GL.DeleteBuffer(_pboId);
GpuMemoryTracker.TrackResourceDeallocation(GpuResourceType.Buffer);
if (_pboSize > 0) {
GpuMemoryTracker.TrackDeallocation(_pboSize, GpuResourceType.Buffer);
}
_pboId = 0;
}
});
}
}
}