diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 76897682..250d4df1 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -1,6 +1,7 @@ # Campaign V — OpenGL → Vulkan rendering migration -**Status:** Active. V0 (pinned RHI contract) landed 2026-07-27. +**Status:** Active. V0 (pinned RHI contract) landed 2026-07-27. V1 (GL backend +implementation, dark) landed 2026-07-27. **Scope:** Windows x64 + Linux x64. No macOS. **End state:** one Vulkan 1.3 backend; the OpenGL backend is deleted. @@ -398,7 +399,7 @@ commit's build, capture again at slice HEAD, compare with the | Slice | Scope | Gate | |---|---|---| | **V0** ✅ | Pinned RHI contract, `RecordingGpuDevice`, contract tests, this document, roadmap entry. | build + tests + contract tests | -| **V1** | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, timers, backbuffer capture. Constructed in composition; no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) | +| **V1** ✅ | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, timers, backbuffer capture. Constructed in composition (`HostInputCameraCompositionPhase`, right after the frame-flight controller); no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) | | **V2** | Shader dialect + texture-index migration **on GL**: `uvec2 textureHandle` → `uint textureIndex`, binding-9 table, `common.glsl` preamble, CPU batch-struct change, caches registering into the device table. Sub-commits: V2a mesh, V2b terrain, V2c particles. | pixel gate per sub-commit | | **V3** | Clip-space and sRGB audit: verify every projection producer is [0,1] convention, confirm clip-plane derivation, record the sRGB swapchain decision and the depth-precision divergence class here. | pixel gate + connected lifecycle | | **V4a** | `TextRenderer` (three fence-buffered VBO sets → ring allocations), `BitmapFont`, `DebugLineRenderer`, the UI RenderSurface upload path, `UiViewport`'s texture handoff. | pixel gate (UI-heavy checkpoints) | diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs index 76f8d28a..52e0592b 100644 --- a/src/AcDream.App/Composition/HostInputCameraComposition.cs +++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs @@ -1,5 +1,6 @@ using AcDream.App.Input; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; using Silk.NET.Maths; @@ -10,6 +11,7 @@ namespace AcDream.App.Composition; internal interface IGameWindowHostInputCameraPublication { void PublishGpuFrameFlights(GpuFrameFlightController value); + void PublishGpuDevice(IGpuDevice value); void PublishKeyboardSource(SilkKeyboardSource value); void PublishMouseSource(SilkMouseSource value); void PublishMouseLookCursor(IMouseLookCursor value); @@ -20,6 +22,7 @@ internal interface IGameWindowHostInputCameraPublication internal sealed record HostInputCameraResult( GpuFrameFlightController GpuFrameFlights, + IGpuDevice GpuDevice, WorldRenderDiagnostics WorldRenderDiagnostics, SilkKeyboardSource? KeyboardSource, SilkMouseSource? MouseSource, @@ -45,6 +48,7 @@ internal interface IHostInputCameraCompositionFactory { IFramebufferViewportTarget CreateViewportTarget(GL gl); GpuFrameFlightController CreateGpuFrameFlights(GL gl); + IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights); WorldRenderDiagnostics CreateWorldRenderDiagnostics( GL gl, IRenderFrameDiagnosticLog log); @@ -82,6 +86,12 @@ internal sealed class RetailHostInputCameraCompositionFactory public GpuFrameFlightController CreateGpuFrameFlights(GL gl) => new(gl); + public IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights) => + new AcDream.App.Rendering.Gpu.Gl.GlGpuDevice( + gl, + frameFlights, + Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders")); + public WorldRenderDiagnostics CreateWorldRenderDiagnostics( GL gl, IRenderFrameDiagnosticLog log) => @@ -139,6 +149,7 @@ internal enum HostInputCameraCompositionPoint { ViewportBound, GpuFrameFlightsPublished, + GpuDevicePublished, KeyboardPublished, KeyboardAttached, MousePublished, @@ -220,6 +231,20 @@ internal sealed class HostInputCameraCompositionPhase : _publication.PublishGpuFrameFlights); Fault(HostInputCameraCompositionPoint.GpuFrameFlightsPublished); + // Constructed the moment a GL context and the frame flight controller + // exist — it owns its own BindlessSupport detection (see + // GlGpuDevice's class comment), so unlike the legacy WB render path it + // has no dependency on WorldRenderCompositionPhase running first. + // Nothing consumes this device yet (Campaign V slice V1); it is + // proven against the real driver here and torn down with the render + // stack so later slices (starting at V4a) have somewhere to plug in. + IGpuDevice gpuDevice = scope.Acquire( + "GPU device (RHI)", + () => _factory.CreateGpuDevice(gl, gpuFrames), + static value => value.Dispose()).Publish( + _publication.PublishGpuDevice); + Fault(HostInputCameraCompositionPoint.GpuDevicePublished); + WorldRenderDiagnostics diagnostics = _factory.CreateWorldRenderDiagnostics( gl, @@ -319,6 +344,7 @@ internal sealed class HostInputCameraCompositionPhase : return new HostInputCameraResult( gpuFrames, + gpuDevice, diagnostics, keyboard, mouse, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 70030b40..a638ba48 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,6 +1,7 @@ using AcDream.Core.Plugins; using AcDream.App.Composition; using AcDream.App.Physics; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Wb; using AcDream.App.Settings; @@ -114,6 +115,7 @@ public sealed class GameWindow : private FrameRootRuntimeBindings? _frameRootBindings; private IDisposable? _frameGraphPublication; private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights; + private IGpuDevice? _gpuDevice; private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new(); private readonly AcDream.App.Rendering.GameRenderResourceLifetime _renderResourceLifetime = new(); @@ -742,6 +744,10 @@ public sealed class GameWindow : GpuFrameFlightController value) => PublishCompositionOwner(ref _gpuFrameFlights, value, "GPU frame flights"); + void IGameWindowHostInputCameraPublication.PublishGpuDevice( + IGpuDevice value) => + PublishCompositionOwner(ref _gpuDevice, value, "GPU device (RHI)"); + void IGameWindowHostInputCameraPublication.PublishKeyboardSource( AcDream.App.Input.SilkKeyboardSource value) => PublishCompositionOwner(ref _kbSource, value, "keyboard source"); @@ -1649,6 +1655,7 @@ public sealed class GameWindow : _audioEngine), new RenderShutdownRoots( _gpuFrameFlights, + _gpuDevice, _devToolsComposition, _localPlayerTeleport, _portalTunnelFallback, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 650d77be..2fc22eb9 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -5,6 +5,7 @@ using AcDream.App.Diagnostics; using AcDream.App.Input; using AcDream.App.Net; using AcDream.App.Physics; +using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Sky; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; @@ -100,6 +101,7 @@ internal sealed record LiveShutdownRoots( internal sealed record RenderShutdownRoots( GpuFrameFlightController? FrameFlights, + IGpuDevice? GpuDevice, DevToolsCompositionOwner? DevTools, LocalPlayerTeleportController? LocalTeleport, TransferableResourceSlot PortalTunnelFallback, @@ -460,6 +462,7 @@ internal static class GameWindowShutdownManifest Hard("debug font", () => render.DebugFont?.Dispose()), Hard("frame pacing", render.FramePacing.Dispose), Hard("frame profiler", render.FrameProfiler.Dispose), + Hard("GPU device (RHI)", () => render.GpuDevice?.Dispose()), ]), new ResourceShutdownStage("dedicated render resources", [ diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs new file mode 100644 index 00000000..6a4a938b --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs @@ -0,0 +1,107 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// One vertex attribute's GL shape: component count, element type, and whether integer values normalize to [0,1]/[-1,1]. +internal readonly record struct GlVertexAttributeShape(int ComponentCount, VertexAttribPointerType Type, bool Normalized); + +/// +/// Pure, GL-context-free mappings from the RHI's backend-neutral enums to +/// Silk.NET's OpenGL enum values. Kept as small switch expressions so each +/// mapping is independently unit-testable and — per the campaign spec's +/// "when in doubt, set the state" rule — every backend-neutral value has an +/// explicit case rather than a fallthrough default. +/// +internal static class GlEnumMapping +{ + public static GlVertexAttributeShape VertexShapeOf(GpuVertexFormat format) => format switch + { + GpuVertexFormat.Float1 => new GlVertexAttributeShape(1, VertexAttribPointerType.Float, false), + GpuVertexFormat.Float2 => new GlVertexAttributeShape(2, VertexAttribPointerType.Float, false), + GpuVertexFormat.Float3 => new GlVertexAttributeShape(3, VertexAttribPointerType.Float, false), + GpuVertexFormat.Float4 => new GlVertexAttributeShape(4, VertexAttribPointerType.Float, false), + GpuVertexFormat.UByte4Normalized => new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, true), + _ => throw new NotSupportedException($"No GL vertex attribute shape for {format}."), + }; + + public static PrimitiveType PrimitiveTypeOf(GpuPrimitiveTopology topology) => topology switch + { + GpuPrimitiveTopology.TriangleList => PrimitiveType.Triangles, + GpuPrimitiveTopology.LineList => PrimitiveType.Lines, + _ => throw new NotSupportedException($"No GL primitive type for {topology}."), + }; + + public static DrawElementsType DrawElementsTypeOf(GpuIndexType indexType) => indexType switch + { + GpuIndexType.UInt16 => DrawElementsType.UnsignedShort, + GpuIndexType.UInt32 => DrawElementsType.UnsignedInt, + _ => throw new NotSupportedException($"No GL index type for {indexType}."), + }; + + public static int IndexSizeBytesOf(GpuIndexType indexType) => indexType switch + { + GpuIndexType.UInt16 => sizeof(ushort), + GpuIndexType.UInt32 => sizeof(uint), + _ => throw new NotSupportedException($"No index byte size for {indexType}."), + }; + + public static DepthFunction DepthFunctionOf(GpuCompareOp compareOp) => compareOp switch + { + GpuCompareOp.Never => DepthFunction.Never, + GpuCompareOp.Less => DepthFunction.Less, + GpuCompareOp.LessOrEqual => DepthFunction.Lequal, + GpuCompareOp.Equal => DepthFunction.Equal, + GpuCompareOp.Greater => DepthFunction.Greater, + GpuCompareOp.GreaterOrEqual => DepthFunction.Gequal, + GpuCompareOp.Always => DepthFunction.Always, + _ => throw new NotSupportedException($"No GL depth function for {compareOp}."), + }; + + public static TriangleFace CullFaceModeOf(GpuCullMode cullMode) => cullMode switch + { + GpuCullMode.Back => TriangleFace.Back, + GpuCullMode.Front => TriangleFace.Front, + // GpuCullMode.None never reaches glCullFace — culling is disabled instead. + _ => throw new NotSupportedException($"No GL cull face mode for {cullMode}."), + }; + + public static FrontFaceDirection FrontFaceDirectionOf(GpuFrontFace frontFace) => frontFace switch + { + GpuFrontFace.CounterClockwise => FrontFaceDirection.Ccw, + GpuFrontFace.Clockwise => FrontFaceDirection.CW, + _ => throw new NotSupportedException($"No GL front-face direction for {frontFace}."), + }; + + public static (BlendingFactor Source, BlendingFactor Destination) BlendFactorsOf(GpuBlendMode blend) => blend switch + { + GpuBlendMode.StraightAlpha => (BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha), + GpuBlendMode.Additive => (BlendingFactor.SrcAlpha, BlendingFactor.One), + // GpuBlendMode.None never reaches glBlendFunc — blending is disabled instead. + _ => throw new NotSupportedException($"No GL blend factors for {blend}."), + }; + + public static TextureMinFilter MinFilterOf(GpuFilter filter, GpuMipFilter mipFilter) => (filter, mipFilter) switch + { + (GpuFilter.Nearest, GpuMipFilter.None) => TextureMinFilter.Nearest, + (GpuFilter.Linear, GpuMipFilter.None) => TextureMinFilter.Linear, + (GpuFilter.Nearest, GpuMipFilter.Nearest) => TextureMinFilter.NearestMipmapNearest, + (GpuFilter.Linear, GpuMipFilter.Nearest) => TextureMinFilter.LinearMipmapNearest, + (GpuFilter.Nearest, GpuMipFilter.Linear) => TextureMinFilter.NearestMipmapLinear, + (GpuFilter.Linear, GpuMipFilter.Linear) => TextureMinFilter.LinearMipmapLinear, + _ => throw new NotSupportedException($"No GL min filter for {filter}/{mipFilter}."), + }; + + public static TextureMagFilter MagFilterOf(GpuFilter filter) => filter switch + { + GpuFilter.Nearest => TextureMagFilter.Nearest, + GpuFilter.Linear => TextureMagFilter.Linear, + _ => throw new NotSupportedException($"No GL mag filter for {filter}."), + }; + + public static TextureWrapMode WrapModeOf(GpuAddressMode addressMode) => addressMode switch + { + GpuAddressMode.Repeat => TextureWrapMode.Repeat, + GpuAddressMode.ClampToEdge => TextureWrapMode.ClampToEdge, + _ => throw new NotSupportedException($"No GL wrap mode for {addressMode}."), + }; +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs new file mode 100644 index 00000000..6295bc6a --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs @@ -0,0 +1,138 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// A plain GL buffer object. Deliberately not one of the existing +/// ManagedGL* wrappers: those implement Chorizite's IVertexBuffer/ +/// IIndexBuffer (generic-over-IVertex, single-usage) and are +/// constructed against , which the campaign +/// explicitly sheds — is a fresh root. One GL buffer +/// object already serves every combination (the +/// usage only matters at bind time), so a single small class covers the whole +/// surface without forking per usage. +/// +/// Allocated once at the description's size via glBufferData with null +/// data; every write after that is glBufferSubData — no persistent +/// mapping, matching the campaign's "GL backend is deliberately behaviour- +/// preserving" rule for this slice. +/// +internal sealed class GlGpuBuffer : IGpuBuffer +{ + private readonly GL _gl; + private readonly IGpuResourceRetirementQueue _retirement; + private uint _name; + + public GlGpuBuffer(GL gl, IGpuResourceRetirementQueue retirement, GpuBufferDescription description) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Name = description.Name; + SizeBytes = description.SizeBytes; + Usage = description.Usage; + Residency = description.Residency; + + _name = GlResourceCommand.CreateName(_gl, $"buffer '{Name}'", _gl.GenBuffer, _gl.DeleteBuffer); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, _name); + unsafe + { + _gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, BufferUsageARB.DynamicDraw); + } + GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)"); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, 0); + } + + public string Name { get; } + public long SizeBytes { get; } + public GpuBufferUsage Usage { get; } + public GpuMemoryResidency Residency { get; } + + /// The physical GL buffer name. For bind calls issued by . + internal uint GlName => _name; + + public void Upload(long offsetBytes, ReadOnlySpan data) + { + ThrowIfDisposed(); + if (offsetBytes < 0 || offsetBytes + data.Length > SizeBytes) + { + throw new ArgumentOutOfRangeException( + nameof(offsetBytes), + $"Upload of {data.Length} bytes at offset {offsetBytes} exceeds buffer '{Name}' ({SizeBytes} bytes)."); + } + if (data.IsEmpty) + return; + + _gl.BindBuffer(GLEnum.CopyWriteBuffer, _name); + unsafe + { + fixed (byte* pointer = data) + _gl.BufferSubData(GLEnum.CopyWriteBuffer, (nint)offsetBytes, (nuint)data.Length, pointer); + } + GLHelpers.ThrowOnResourceError(_gl, $"upload {data.Length} bytes to buffer '{Name}' at offset {offsetBytes}"); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, 0); + } + + public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(destination); + if (destination is not GlGpuBuffer target) + throw new ArgumentException("A GL device can only copy into a GL buffer.", nameof(destination)); + if (byteCount == 0) + return; + + _gl.BindBuffer(GLEnum.CopyReadBuffer, _name); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, target._name); + _gl.CopyBufferSubData( + GLEnum.CopyReadBuffer, + GLEnum.CopyWriteBuffer, + (nint)sourceOffsetBytes, + (nint)destinationOffsetBytes, + (nuint)byteCount); + GLHelpers.ThrowOnResourceError( + _gl, + $"copy {byteCount} bytes from buffer '{Name}' to '{target.Name}'"); + _gl.BindBuffer(GLEnum.CopyReadBuffer, 0); + _gl.BindBuffer(GLEnum.CopyWriteBuffer, 0); + } + + public void Read(long offsetBytes, Span destination) + { + ThrowIfDisposed(); + if (destination.IsEmpty) + return; + + _gl.BindBuffer(GLEnum.CopyReadBuffer, _name); + unsafe + { + fixed (byte* pointer = destination) + _gl.GetBufferSubData(GLEnum.CopyReadBuffer, (nint)offsetBytes, (nuint)destination.Length, pointer); + } + GLHelpers.ThrowOnResourceError(_gl, $"read {destination.Length} bytes from buffer '{Name}' at offset {offsetBytes}"); + _gl.BindBuffer(GLEnum.CopyReadBuffer, 0); + } + + public void Dispose() + { + uint name = _name; + if (name == 0) + return; + _name = 0; + + GL gl = _gl; + string label = Name; + _retirement.Retire(() => + { + gl.DeleteBuffer(name); + GLHelpers.ThrowOnResourceError(gl, $"delete buffer '{label}' ({name})"); + }); + } + + private void ThrowIfDisposed() + { + if (_name == 0) + throw new ObjectDisposedException(Name); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs new file mode 100644 index 00000000..b416b597 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs @@ -0,0 +1,517 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Diagnostics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// OpenGL 4.3 implementation of — Campaign V slice V1. +/// See docs/plans/2026-07-27-vulkan-campaign.md §3 for the pinned +/// contract this backend fills and §5 for what this slice covers. +/// +/// Deliberately NOT derived from Chorizite's BaseGraphicsDevice/ +/// — this is a fresh root, which is one of +/// the things Campaign V sheds. It owns its own +/// instance (created in the constructor) rather than sharing the legacy WB +/// render path's; both simply wrap the same stateless +/// GL_ARB_bindless_texture extension, so two instances coexist safely, +/// and it lets this device be constructed the moment a GL context and a +/// exist — no dependency on when the +/// legacy path happens to detect bindless support during composition. +/// +/// Ring capacity. Each flight slot gets one managed staging +/// byte[] and one same-sized GL buffer, both fixed at construction +/// (default 16 MiB — see ). V1 +/// chose "throw with a message naming the needed size" over "grow by create- +/// copy-retire" for an over-capacity request: nothing consumes this device +/// yet, so there is no real per-frame data volume to size against, and a +/// silent/implicit grow would hide a future renderer's actual working set +/// from the person porting it. is the pure +/// piece that enforces this. +/// +/// Flush discipline. Ring bytes and the texture-handle table are +/// both flushed with one glBufferSubData each, immediately before +/// every Draw/DrawIndexed/MultiDrawIndexedIndirect — see +/// — never at bind time, so a renderer that +/// writes after binding still uploads correctly. +/// +internal sealed class GlGpuDevice : IGpuDevice +{ + internal const int DefaultRingCapacityBytesPerSlot = 16 * 1024 * 1024; + + // GL tokens not exposed as named Silk.NET GetPName members (mirrors the + // same raw-hex pattern GraphicalCapabilityRecord.cs already uses for + // GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS). + private const int GlMaxShaderStorageBufferBindings = 0x90DD; + private const int GlMaxClipDistances = 0x0D32; + private const int GlMaxSamples = 0x8D57; + private const int GlShaderStorageBufferOffsetAlignment = 0x90DF; + + private readonly GL _gl; + private readonly GpuFrameFlightController _frameFlights; + private readonly BindlessSupport _bindless; + private readonly string _shadersDirectory; + private readonly int _ringCapacityBytesPerSlot; + + private readonly GlRingBufferState[] _ringStates; + private readonly byte[][] _ringStaging; + private readonly GlGpuBuffer[] _ringBuffers; + + private readonly GlTextureSlotAllocator _textureSlotAllocator = + new(GpuBindingModel.TextureTableCapacity); + private readonly ulong[] _textureHandleTable = new ulong[GpuBindingModel.TextureTableCapacity]; + private readonly GlGpuBuffer _textureTableBuffer; + private int _textureTableDirtyStart = -1; + private int _textureTableDirtyEnd = -1; + + private readonly GlRenderStateCache _renderState = new(); + private readonly GlGpuPushConstantBinder _pushConstants; + private readonly GlGpuTimerPool _timerPool; + private readonly Dictionary _samplers = []; + private readonly List _queuedActions = []; + private readonly GlGpuTexture _defaultTexture; + + private long _nextSerial; + private bool _disposed; + + public GlGpuDevice( + GL gl, + GpuFrameFlightController frameFlights, + string shadersDirectory, + int ringCapacityBytesPerSlot = DefaultRingCapacityBytesPerSlot) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _frameFlights = frameFlights ?? throw new ArgumentNullException(nameof(frameFlights)); + ArgumentException.ThrowIfNullOrWhiteSpace(shadersDirectory); + _shadersDirectory = shadersDirectory; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(ringCapacityBytesPerSlot); + _ringCapacityBytesPerSlot = ringCapacityBytesPerSlot; + + if (!BindlessSupport.TryCreate(_gl, out BindlessSupport? bindless) || bindless is null) + { + throw new NotSupportedException( + "GlGpuDevice requires GL_ARB_bindless_texture. The startup capability gate " + + "(GraphicalCapabilityGuard) should already have rejected a driver without it, " + + "so reaching this means the device was constructed before that gate ran."); + } + _bindless = bindless; + + Capabilities = CaptureCapabilities(); + + int slotCount = _frameFlights.SlotCount; + _ringStates = new GlRingBufferState[slotCount]; + _ringStaging = new byte[slotCount][]; + _ringBuffers = new GlGpuBuffer[slotCount]; + for (int slot = 0; slot < slotCount; slot++) + { + _ringStates[slot] = new GlRingBufferState(_ringCapacityBytesPerSlot); + _ringStaging[slot] = new byte[_ringCapacityBytesPerSlot]; + _ringBuffers[slot] = new GlGpuBuffer( + _gl, + Retirement, + new GpuBufferDescription( + $"gpu-ring-slot-{slot}", + _ringCapacityBytesPerSlot, + GpuBufferUsage.Storage | GpuBufferUsage.Uniform | GpuBufferUsage.Indirect, + GpuMemoryResidency.HostWritable)); + } + + _textureTableBuffer = new GlGpuBuffer( + _gl, + Retirement, + new GpuBufferDescription( + "gpu-texture-table", + GpuBindingModel.TextureTableCapacity * sizeof(ulong), + GpuBufferUsage.Storage, + GpuMemoryResidency.HostWritable)); + + _pushConstants = new GlGpuPushConstantBinder(_gl); + _timerPool = new GlGpuTimerPool(new SilkGlTimerQueryApi(_gl), Capabilities.SupportsTimestampQueries); + + _defaultTexture = new GlGpuTexture( + _gl, + Retirement, + new GpuTextureDescription( + "default-white", + GpuTextureKind.Texture2D, + GpuTextureFormat.Rgba8Unorm, + Width: 1, + Height: 1, + LayerCount: 1, + MipLevelCount: 1)); + _defaultTexture.Upload(0, 0, [255, 255, 255, 255]); + IGpuSampler defaultSampler = CreateSampler(GpuSamplerDescription.UiNearest); + DefaultTextureSlot = RegisterTexture(_defaultTexture, defaultSampler); + } + + public GpuBackendKind Backend => GpuBackendKind.OpenGl; + public GpuCapabilityRecord Capabilities { get; } + public IGpuResourceRetirementQueue Retirement => _frameFlights; + public IGpuTimerPool Timers => _timerPool; + public GpuTextureSlot DefaultTextureSlot { get; } + + internal GL Gl => _gl; + internal GlGpuPushConstantBinder PushConstants => _pushConstants; + internal GlGpuTimerPool TimerPool => _timerPool; + + internal GlRenderStateSnapshot CurrentRenderState { get; private set; } + + public IGpuBuffer CreateBuffer(in GpuBufferDescription description) + { + ThrowIfDisposed(); + return new GlGpuBuffer(_gl, Retirement, description); + } + + public IGpuTexture CreateTexture(in GpuTextureDescription description) + { + ThrowIfDisposed(); + return new GlGpuTexture(_gl, Retirement, description); + } + + public IGpuSampler CreateSampler(in GpuSamplerDescription description) + { + ThrowIfDisposed(); + if (_samplers.TryGetValue(description, out GlGpuSampler? existing)) + return existing; + + var created = new GlGpuSampler(_gl, Retirement, description); + _samplers.Add(description, created); + return created; + } + + public IGpuPipeline CreatePipeline(GpuPipelineDescription description) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(description); + string vertexPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.vert"); + string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag"); + string vertexSource = File.ReadAllText(vertexPath); + string fragmentSource = File.ReadAllText(fragmentPath); + return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource); + } + + public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description) + { + ThrowIfDisposed(); + return new GlGpuRenderTarget(_gl, Retirement, description); + } + + public GpuTextureSlot RegisterTexture(IGpuTexture texture, IGpuSampler sampler) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(texture); + ArgumentNullException.ThrowIfNull(sampler); + if (texture is not GlGpuTexture glTexture) + throw new ArgumentException("The GL backend can only register a GL texture.", nameof(texture)); + if (sampler is not GlGpuSampler glSampler) + throw new ArgumentException("The GL backend can only register a GL sampler.", nameof(sampler)); + + uint slot = _textureSlotAllocator.Allocate(); + ulong handle = _bindless.GetResidentHandle(glTexture.GlName, glSampler.GlName); + WriteHandle(slot, handle); + return new GpuTextureSlot(slot); + } + + public void ReleaseTextureSlot(GpuTextureSlot slot) + { + ThrowIfDisposed(); + if (!slot.IsAssigned) + throw new ArgumentException("Cannot release an unassigned texture slot.", nameof(slot)); + + uint index = slot.Index; + // Deferred through the retirement queue: a submitted-but-not-yet- + // retired frame may still read this slot's handle, so the free list + // (and thus reuse) must wait for that frame to retire. + Retirement.Retire(() => + { + WriteHandle(index, 0); + _textureSlotAllocator.Release(index); + }); + } + + public IGpuFrame BeginFrame() + { + ThrowIfDisposed(); + _frameFlights.BeginFrame(); + long serial = ++_nextSerial; + int slot = _frameFlights.CurrentSlot; + _ringStates[slot].Reset(); + return new GlGpuFrame(this, slot, serial); + } + + internal void EndFrame() => _frameFlights.EndFrame(); + + internal GpuRingAllocation AllocateRing(int slotIndex, int byteCount, GpuRingUsage usage) + { + uint alignment = usage switch + { + GpuRingUsage.Storage => Capabilities.MinStorageBufferOffsetAlignment, + GpuRingUsage.Uniform => Capabilities.MinUniformBufferOffsetAlignment, + _ => 4u, + }; + uint offset = _ringStates[slotIndex].Allocate(byteCount, alignment); + Span data = byteCount == 0 + ? Span.Empty + : _ringStaging[slotIndex].AsSpan((int)offset, byteCount); + return new GpuRingAllocation(_ringBuffers[slotIndex], offset, data); + } + + /// + /// Uploads this slot's dirty ring bytes and the texture table's dirty + /// range (if any) with one glBufferSubData each, then clears both + /// watermarks. Called immediately before every draw — never at bind time, + /// because a renderer may still write into a ring allocation after + /// binding it. + /// + internal void FlushBeforeDraw(int slotIndex) + { + (int start, int length) = _ringStates[slotIndex].TakeDirtyRange(); + if (length > 0) + _ringBuffers[slotIndex].Upload(start, _ringStaging[slotIndex].AsSpan(start, length)); + + if (_textureTableDirtyStart >= 0) + { + int tableStart = _textureTableDirtyStart; + int tableEnd = _textureTableDirtyEnd; + _textureTableDirtyStart = -1; + _textureTableDirtyEnd = -1; + + ReadOnlySpan bytes = MemoryMarshal.AsBytes( + _textureHandleTable.AsSpan(tableStart, tableEnd - tableStart)); + _textureTableBuffer.Upload((long)tableStart * sizeof(ulong), bytes); + } + } + + internal void BeginPass(GpuPassDescription description) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(description); + if (description.Color.Store == GpuStoreOp.Resolve + || description.Depth is { Store: GpuStoreOp.Resolve }) + { + throw new NotSupportedException( + "GpuStoreOp.Resolve is a Vulkan-only path; Campaign V slice V1's GL backend " + + "only ever renders single-sampled targets."); + } + + uint framebuffer = 0; + if (description.Color.Target is { } target) + { + if (target is not GlGpuRenderTarget glTarget) + throw new ArgumentException("The GL backend can only render into a GL render target."); + framebuffer = glTarget.GlFramebufferName; + } + _gl.BindFramebuffer(GLEnum.Framebuffer, framebuffer); + + bool clearsColor = description.Color.Load == GpuLoadOp.Clear; + bool clearsDepth = description.Depth is { Load: GpuLoadOp.Clear }; + if (clearsColor || clearsDepth) + { + // Force the write masks on before clearing, regardless of what + // the previous pass's last draw left them at (e.g. depth-write + // disabled mid-translucent-pass) — glClear silently no-ops for a + // buffer whose mask is off. GlRenderStateCache.Reset() afterward + // stops the cache from believing this forced state is the + // baseline the next BindPipeline should diff against. + ClearBufferMask mask = 0; + if (clearsColor) + { + _gl.ColorMask(true, true, true, true); + Vector4 clearColor = description.Color.ClearColor; + _gl.ClearColor(clearColor.X, clearColor.Y, clearColor.Z, clearColor.W); + mask |= ClearBufferMask.ColorBufferBit; + } + if (description.Depth is { Load: GpuLoadOp.Clear } depth) + { + _gl.DepthMask(true); + _gl.ClearDepth(depth.ClearDepth); + _gl.ClearStencil((int)depth.ClearStencil); + mask |= ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit; + } + _gl.Clear(mask); + GLHelpers.ThrowOnResourceError(_gl, $"clear pass '{description.Name}'"); + _renderState.Reset(); + } + } + + internal void ApplyRenderState(GlRenderStateSnapshot desired) + { + GlRenderStateChanges changes = _renderState.Apply(desired); + CurrentRenderState = desired; + if (!changes.AnyChange) + return; + + if (changes.Program) + _gl.UseProgram(desired.Program); + if (changes.Blend) + { + if (desired.Blend == GpuBlendMode.None) + { + _gl.Disable(EnableCap.Blend); + } + else + { + _gl.Enable(EnableCap.Blend); + (BlendingFactor source, BlendingFactor destination) = GlEnumMapping.BlendFactorsOf(desired.Blend); + _gl.BlendFunc(source, destination); + } + } + if (changes.DepthTest) + { + if (desired.DepthTest) + _gl.Enable(EnableCap.DepthTest); + else + _gl.Disable(EnableCap.DepthTest); + } + if (changes.DepthWrite) + _gl.DepthMask(desired.DepthWrite); + if (changes.DepthCompare) + _gl.DepthFunc(GlEnumMapping.DepthFunctionOf(desired.DepthCompare)); + if (changes.Cull) + { + if (desired.Cull == GpuCullMode.None) + { + _gl.Disable(EnableCap.CullFace); + } + else + { + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(GlEnumMapping.CullFaceModeOf(desired.Cull)); + } + } + if (changes.FrontFace) + _gl.FrontFace(GlEnumMapping.FrontFaceDirectionOf(desired.FrontFace)); + if (changes.AlphaToCoverage) + { + if (desired.AlphaToCoverage) + _gl.Enable(EnableCap.SampleAlphaToCoverage); + else + _gl.Disable(EnableCap.SampleAlphaToCoverage); + } + if (changes.ColorWrite) + { + _gl.ColorMask(desired.ColorWrite, desired.ColorWrite, desired.ColorWrite, desired.ColorWrite); + } + GLHelpers.ThrowOnResourceError(_gl, "apply GL render state"); + } + + public void QueueDeviceAction(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _queuedActions.Add(action); + } + + public void ProcessDeviceActions() + { + Action[] pending = [.. _queuedActions]; + _queuedActions.Clear(); + foreach (Action action in pending) + action(); + } + + public byte[] CaptureBackbuffer(int width, int height) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + + byte[] pixels = new byte[checked(width * height * 4)]; + unsafe + { + fixed (byte* pointer = pixels) + { + _gl.ReadPixels(0, 0, (uint)width, (uint)height, PixelFormat.Rgba, PixelType.UnsignedByte, pointer); + } + } + GLHelpers.ThrowOnResourceError(_gl, $"capture backbuffer {width}x{height}"); + + // FrameScreenshotController.FlipRows is the same top-left-origin flip + // the existing screenshot gates already rely on — reusing it (rather + // than reimplementing the loop) is what keeps this seam byte-for-byte + // compatible with those gates. + return FrameScreenshotController.FlipRows(pixels, width, height); + } + + public void WaitIdle() + { + ThrowIfDisposed(); + _frameFlights.WaitForSubmittedWork(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + // Only resources THIS device created are disposed here. + // GpuFrameFlightController is constructor-injected and owned by + // whoever composed this device — disposing it would be a double- + // dispose from that owner's perspective, and every disposal below + // routes through it as the retirement queue, so it must still be + // alive when this method returns. + foreach (GlGpuSampler sampler in _samplers.Values) + sampler.Dispose(); + _samplers.Clear(); + + _defaultTexture.Dispose(); + + foreach (GlGpuBuffer ringBuffer in _ringBuffers) + ringBuffer.Dispose(); + + _textureTableBuffer.Dispose(); + _timerPool.DisposeQueries(); + } + + private void WriteHandle(uint slot, ulong handle) + { + _textureHandleTable[slot] = handle; + _textureTableDirtyStart = _textureTableDirtyStart < 0 + ? (int)slot + : Math.Min(_textureTableDirtyStart, (int)slot); + _textureTableDirtyEnd = Math.Max(_textureTableDirtyEnd, (int)slot + 1); + } + + private GpuCapabilityRecord CaptureCapabilities() + { + int major = _gl.GetInteger(GetPName.MajorVersion); + int minor = _gl.GetInteger(GetPName.MinorVersion); + bool openGl43 = major > 4 || (major == 4 && minor >= 3); + + _gl.GetInteger((GetPName)GlMaxShaderStorageBufferBindings, out int maxStorageBindings); + _gl.GetInteger((GetPName)GlMaxClipDistances, out int maxClipDistances); + _gl.GetInteger((GetPName)GlMaxSamples, out int maxSamples); + _gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int uniformAlignment); + _gl.GetInteger((GetPName)GlShaderStorageBufferOffsetAlignment, out int storageAlignment); + + bool timerQuery = major > 3 || (major == 3 && minor >= 3) || _gl.IsExtensionPresent("GL_ARB_timer_query"); + + return new GpuCapabilityRecord + { + Backend = GpuBackendKind.OpenGl, + DeviceName = _gl.GetStringS(GLEnum.Renderer), + DriverInfo = _gl.GetStringS(GLEnum.Vendor), + ApiVersion = $"OpenGL {_gl.GetStringS(GLEnum.Version)}", + MaxTextureTableSlots = GpuBindingModel.TextureTableCapacity, + MaxStorageBufferBindings = (uint)maxStorageBindings, + MaxPushConstantBytes = (uint)GpuBindingModel.MaxPushConstantBytes, + MinStorageBufferOffsetAlignment = (uint)storageAlignment, + MinUniformBufferOffsetAlignment = (uint)uniformAlignment, + MaxClipDistances = (uint)maxClipDistances, + MaxSampleCount = (uint)maxSamples, + SupportsMultiDrawIndirect = openGl43 || _gl.IsExtensionPresent("GL_ARB_multi_draw_indirect"), + SupportsDrawParameters = _gl.IsExtensionPresent("GL_ARB_shader_draw_parameters"), + SupportsTextureCompressionBc = _gl.IsExtensionPresent("GL_EXT_texture_compression_s3tc"), + SupportsTimestampQueries = timerQuery, + // The GL backend deliberately never writes into mapped memory — + // it keeps BufferSubData uploads so every renderer port slice's + // pixel gate is a strict identity check. Only Vulkan reports true. + SupportsPersistentlyMappedRings = false, + }; + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuFrame.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuFrame.cs new file mode 100644 index 00000000..49a9f231 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuFrame.cs @@ -0,0 +1,58 @@ +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// One frame's recording context on the GL backend. Ring allocations are +/// forwarded to the device's per-slot ; +/// passes are single-level (no nesting, matching every acdream renderer +/// today) and enforced here. +/// +internal sealed class GlGpuFrame : IGpuFrame +{ + private readonly GlGpuDevice _device; + private bool _ended; + private GlGpuPassEncoder? _openPass; + + internal GlGpuFrame(GlGpuDevice device, int slotIndex, long serial) + { + _device = device; + SlotIndex = slotIndex; + Serial = serial; + } + + public int SlotIndex { get; } + public long Serial { get; } + + public GpuRingAllocation AllocateRing(int byteCount, GpuRingUsage usage) => + _device.AllocateRing(SlotIndex, byteCount, usage); + + public IGpuPassEncoder BeginPass(GpuPassDescription description) + { + ArgumentNullException.ThrowIfNull(description); + if (_openPass is not null) + { + throw new InvalidOperationException( + "A pass is already open on this frame; dispose it before beginning another."); + } + + _device.BeginPass(description); + var encoder = new GlGpuPassEncoder(_device, this, description); + _openPass = encoder; + return encoder; + } + + internal void ClosePass(GlGpuPassEncoder encoder) + { + if (ReferenceEquals(_openPass, encoder)) + _openPass = null; + } + + public void End() + { + if (_ended) + return; + _ended = true; + _device.EndFrame(); + } + + public void Dispose() => End(); +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs new file mode 100644 index 00000000..f3ac77c8 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs @@ -0,0 +1,235 @@ +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Records one pass's draw work. Binding calls translate almost mechanically +/// to GL (a storage/uniform binding is glBindBufferRange, an indexed +/// draw is glDrawElementsInstancedBaseVertexBaseInstance, and so on); +/// the two pieces of real logic are the render-state diff applied on +/// / the dynamic setters, and the "flush dirty +/// ring + texture-table bytes immediately before every draw" discipline +/// described on . +/// +internal sealed class GlGpuPassEncoder : IGpuPassEncoder +{ + private readonly GlGpuDevice _device; + private readonly GlGpuFrame _frame; + private readonly GL _gl; + private bool _closed; + + private GlGpuPipeline? _currentPipeline; + private GpuIndexType _currentIndexType = GpuIndexType.UInt16; + private uint _currentIndexBufferBaseOffset; + private GpuPushConstants? _currentPushConstants; + + internal GlGpuPassEncoder(GlGpuDevice device, GlGpuFrame frame, GpuPassDescription pass) + { + _device = device; + _frame = frame; + _gl = device.Gl; + Pass = pass; + } + + public GpuPassDescription Pass { get; } + + public void BindPipeline(IGpuPipeline pipeline) + { + ArgumentNullException.ThrowIfNull(pipeline); + ThrowIfClosed(); + var p = (GlGpuPipeline)pipeline; + _currentPipeline = p; + + GpuPipelineDescription description = p.Description; + var desired = new GlRenderStateSnapshot( + p.GlProgram, + description.Blend, + description.Depth.Test, + description.Depth.Write, + description.Depth.Compare, + description.Cull, + description.FrontFace, + description.AlphaToCoverage, + description.ColorWrite); + _device.ApplyRenderState(desired); + + _gl.BindVertexArray(p.GlVertexArray); + GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO"); + + // Push constants "survive pipeline changes within a pass" per the + // IGpuPassEncoder contract. GL uniforms are per-program state, so the + // GL backend must explicitly re-apply the last value to the newly + // bound program to honour that — Vulkan gets this for free from a + // shared pipeline layout. + if (_currentPushConstants is { } constants) + _device.PushConstants.Apply(p.GlProgram, in constants); + } + + public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ThrowIfClosed(); + var b = RequireGlBuffer(buffer); + _gl.BindBufferRange(GLEnum.ShaderStorageBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes); + GLHelpers.ThrowOnResourceError(_gl, $"bind storage buffer '{buffer.Name}' at binding {binding}"); + } + + public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes) + { + ThrowIfClosed(); + var b = RequireGlBuffer(buffer); + _gl.BindBufferRange(GLEnum.UniformBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes); + GLHelpers.ThrowOnResourceError(_gl, $"bind uniform buffer '{buffer.Name}' at binding {binding}"); + } + + public unsafe void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes) + { + ThrowIfClosed(); + if (_currentPipeline is not { } pipeline) + throw new InvalidOperationException("BindPipeline must be called before BindVertexBuffer."); + var b = RequireGlBuffer(buffer); + + _gl.BindBuffer(GLEnum.ArrayBuffer, b.GlName); + GpuVertexLayout layout = pipeline.Description.VertexLayout; + foreach (GpuVertexAttribute attribute in layout.Attributes) + { + GlVertexAttributeShape shape = GlEnumMapping.VertexShapeOf(attribute.Format); + nint attributeOffset = (nint)(offsetBytes + attribute.OffsetBytes); + _gl.VertexAttribPointer( + attribute.Location, + shape.ComponentCount, + shape.Type, + shape.Normalized, + layout.StrideBytes, + (void*)attributeOffset); + } + GLHelpers.ThrowOnResourceError(_gl, $"bind vertex buffer '{buffer.Name}'"); + _gl.BindBuffer(GLEnum.ArrayBuffer, 0); + } + + public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType) + { + ThrowIfClosed(); + var b = RequireGlBuffer(buffer); + _currentIndexType = indexType; + _currentIndexBufferBaseOffset = offsetBytes; + _gl.BindBuffer(GLEnum.ElementArrayBuffer, b.GlName); + GLHelpers.ThrowOnResourceError(_gl, $"bind index buffer '{buffer.Name}'"); + } + + public void SetPushConstants(in GpuPushConstants constants) + { + ThrowIfClosed(); + _currentPushConstants = constants; + if (_currentPipeline is { } pipeline) + _device.PushConstants.Apply(pipeline.GlProgram, in constants); + } + + public void SetViewport(int x, int y, int width, int height) + { + ThrowIfClosed(); + _gl.Viewport(x, y, (uint)width, (uint)height); + } + + public void SetScissor(int x, int y, int width, int height) + { + ThrowIfClosed(); + _gl.Enable(EnableCap.ScissorTest); + _gl.Scissor(x, y, (uint)width, (uint)height); + } + + public void SetCullMode(GpuCullMode cullMode) + { + ThrowIfClosed(); + _device.ApplyRenderState(_device.CurrentRenderState with { Cull = cullMode }); + } + + public void SetFrontFace(GpuFrontFace frontFace) + { + ThrowIfClosed(); + _device.ApplyRenderState(_device.CurrentRenderState with { FrontFace = frontFace }); + } + + public void SetDepthWrite(bool enabled) + { + ThrowIfClosed(); + _device.ApplyRenderState(_device.CurrentRenderState with { DepthWrite = enabled }); + } + + public unsafe void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance) + { + ThrowIfClosed(); + _device.FlushBeforeDraw(_frame.SlotIndex); + int indexSize = GlEnumMapping.IndexSizeBytesOf(_currentIndexType); + nint indexOffset = (nint)(_currentIndexBufferBaseOffset + firstIndex * (uint)indexSize); + _gl.DrawElementsInstancedBaseVertexBaseInstance( + GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology), + indexCount, + GlEnumMapping.DrawElementsTypeOf(_currentIndexType), + (void*)indexOffset, + instanceCount, + vertexOffset, + firstInstance); + GLHelpers.ThrowOnResourceError(_gl, "DrawIndexed"); + } + + public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance) + { + ThrowIfClosed(); + _device.FlushBeforeDraw(_frame.SlotIndex); + _gl.DrawArraysInstancedBaseInstance( + (GLEnum)GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology), + (int)firstVertex, + vertexCount, + instanceCount, + firstInstance); + GLHelpers.ThrowOnResourceError(_gl, "Draw"); + } + + public unsafe void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes) + { + ThrowIfClosed(); + var indirect = RequireGlBuffer(commands); + _device.FlushBeforeDraw(_frame.SlotIndex); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, indirect.GlName); + _gl.MultiDrawElementsIndirect( + GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology), + GlEnumMapping.DrawElementsTypeOf(_currentIndexType), + (void*)(nint)offsetBytes, + drawCount, + strideBytes); + GLHelpers.ThrowOnResourceError(_gl, "MultiDrawIndexedIndirect"); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + } + + public IDisposable BeginTimerScope(string scopeName) => _device.TimerPool.BeginScope(scopeName); + + public void Dispose() + { + if (_closed) + return; + _closed = true; + // GL has no store-op work to do here: GpuStoreOp.Resolve was already + // rejected at BeginPass (V1 targets are single-sampled), and + // Store/DontCare need no explicit action — the framebuffer's contents + // simply persist until the next pass rebinds a target. + _frame.ClosePass(this); + } + + private GlGpuPipeline RequirePipeline() => + _currentPipeline ?? throw new InvalidOperationException("BindPipeline must be called before drawing."); + + private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer) + { + ArgumentNullException.ThrowIfNull(buffer); + if (buffer is not GlGpuBuffer glBuffer) + throw new ArgumentException("The GL backend can only bind GL buffers.", nameof(buffer)); + return glBuffer; + } + + private void ThrowIfClosed() + { + if (_closed) + throw new ObjectDisposedException(nameof(GlGpuPassEncoder)); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs new file mode 100644 index 00000000..8ddb347a --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs @@ -0,0 +1,85 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Compiles through the existing +/// (the same compiler every other GL +/// shader in the codebase uses — this is not a second compiler) and owns one +/// VAO shaped by . +/// +/// The VAO only records which attribute locations are enabled and their +/// component shape; it does NOT bind a vertex buffer at creation time. Vertex +/// attribute pointers are re-issued by +/// every time a buffer/offset is bound, because a ring allocation's offset +/// changes every frame — GL has no notion of "rebase this VAO's buffer at a +/// new offset" independent of re-issuing glVertexAttribPointer. +/// +internal sealed class GlGpuPipeline : IGpuPipeline +{ + private readonly GL _gl; + private readonly IGpuResourceRetirementQueue _retirement; + private uint _program; + private uint _vertexArray; + + public GlGpuPipeline(GL gl, IGpuResourceRetirementQueue retirement, GpuPipelineDescription description, string vertexSource, string fragmentSource) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Description = description ?? throw new ArgumentNullException(nameof(description)); + + _program = ShaderProgramConstruction.Build(new GlShaderProgramBuildApi(_gl), vertexSource, fragmentSource); + try + { + _vertexArray = GlResourceCommand.CreateName( + _gl, + $"pipeline '{description.Name}' VAO", + _gl.GenVertexArray, + _gl.DeleteVertexArray); + _gl.BindVertexArray(_vertexArray); + foreach (GpuVertexAttribute attribute in description.VertexLayout.Attributes) + _gl.EnableVertexAttribArray(attribute.Location); + GLHelpers.ThrowOnResourceError(_gl, $"configure pipeline '{description.Name}' VAO"); + _gl.BindVertexArray(0); + } + catch + { + _gl.DeleteProgram(_program); + _program = 0; + throw; + } + } + + public GpuPipelineDescription Description { get; } + + internal uint GlProgram => _program; + internal uint GlVertexArray => _vertexArray; + + public void Dispose() + { + uint program = _program; + uint vertexArray = _vertexArray; + if (program == 0 && vertexArray == 0) + return; + _program = 0; + _vertexArray = 0; + + GL gl = _gl; + string label = Description.Name; + _retirement.Retire(() => + { + if (vertexArray != 0) + { + gl.DeleteVertexArray(vertexArray); + GLHelpers.ThrowOnResourceError(gl, $"delete pipeline '{label}' VAO"); + } + if (program != 0) + { + gl.DeleteProgram(program); + GLHelpers.ThrowOnResourceError(gl, $"delete pipeline '{label}' program"); + } + }); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPushConstantBinder.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPushConstantBinder.cs new file mode 100644 index 00000000..74713463 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuPushConstantBinder.cs @@ -0,0 +1,81 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Applies a value to the currently bound GL +/// program by uniform name (), +/// caching each program's resolved locations so the name lookup only happens +/// once per program. A location of -1 (the program does not declare that +/// uniform) is silently skipped, exactly as the contract's XML docs specify. +/// +internal sealed class GlGpuPushConstantBinder +{ + private readonly GL _gl; + private readonly Dictionary _locationsByProgram = new(); + + public GlGpuPushConstantBinder(GL gl) => _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public unsafe void Apply(uint program, in GpuPushConstants constants) + { + ProgramLocations locations = GetOrResolveLocations(program); + + if (locations.ViewProjection >= 0) + { + Matrix4x4 m = constants.ViewProjection; + _gl.UniformMatrix4(locations.ViewProjection, 1, false, (float*)&m); + } + if (locations.DrawIdOffset >= 0) + _gl.Uniform1(locations.DrawIdOffset, constants.DrawIdOffset); + if (locations.LightingMode >= 0) + _gl.Uniform1(locations.LightingMode, constants.LightingMode); + if (locations.RenderPass >= 0) + _gl.Uniform1(locations.RenderPass, constants.RenderPass); + if (locations.LightDebug >= 0) + _gl.Uniform1(locations.LightDebug, constants.LightDebug); + if (locations.TextureIndexA >= 0) + _gl.Uniform1(locations.TextureIndexA, constants.TextureIndexA); + if (locations.TextureIndexB >= 0) + _gl.Uniform1(locations.TextureIndexB, constants.TextureIndexB); + if (locations.ParamA >= 0) + _gl.Uniform1(locations.ParamA, constants.ParamA); + if (locations.ParamB >= 0) + _gl.Uniform1(locations.ParamB, constants.ParamB); + GLHelpers.ThrowOnResourceError(_gl, $"apply push constants to program {program}"); + } + + /// Drops cached locations for a deleted program. Call before its GL name is reused. + public void Forget(uint program) => _locationsByProgram.Remove(program); + + private ProgramLocations GetOrResolveLocations(uint program) + { + if (_locationsByProgram.TryGetValue(program, out ProgramLocations existing)) + return existing; + + ProgramLocations locations = new( + _gl.GetUniformLocation(program, GlPushConstantUniformNames.ViewProjection), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.DrawIdOffset), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.LightingMode), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.RenderPass), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.LightDebug), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.TextureIndexA), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.TextureIndexB), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.ParamA), + _gl.GetUniformLocation(program, GlPushConstantUniformNames.ParamB)); + _locationsByProgram[program] = locations; + return locations; + } + + private readonly record struct ProgramLocations( + int ViewProjection, + int DrawIdOffset, + int LightingMode, + int RenderPass, + int LightDebug, + int TextureIndexA, + int TextureIndexB, + int ParamA, + int ParamB); +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuRenderTarget.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuRenderTarget.cs new file mode 100644 index 00000000..329374d3 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuRenderTarget.cs @@ -0,0 +1,121 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// An offscreen colour(+depth) FBO. The colour attachment is a +/// so it can be registered into the texture table +/// after the pass; the depth/stencil attachment (when requested) is a plain +/// renderbuffer, mirroring ManagedGLFramebuffer's approach — nothing +/// ever samples depth for the targets this slice's contract describes +/// (paperdoll, creature appraisal, portal masking). +/// +internal sealed class GlGpuRenderTarget : IGpuRenderTarget +{ + private readonly GL _gl; + private readonly IGpuResourceRetirementQueue _retirement; + private uint _framebuffer; + private uint _depthStencilRenderbuffer; + private GlGpuTexture? _color; + + public GlGpuRenderTarget(GL gl, IGpuResourceRetirementQueue retirement, GpuRenderTargetDescription description) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Description = description; + if (description.SampleCount != 1) + { + throw new NotSupportedException( + "GL render targets are single-sampled in Campaign V slice V1; " + + "MSAA offscreen targets are not part of this contract."); + } + + _color = new GlGpuTexture( + _gl, + _retirement, + new GpuTextureDescription( + $"{description.Name}-color", + GpuTextureKind.Texture2D, + description.ColorFormat, + description.Width, + description.Height, + LayerCount: 1, + MipLevelCount: 1)); + + _framebuffer = GlResourceCommand.CreateName(_gl, $"framebuffer '{description.Name}'", _gl.GenFramebuffer, _gl.DeleteFramebuffer); + _gl.BindFramebuffer(GLEnum.Framebuffer, _framebuffer); + _gl.FramebufferTexture2D( + GLEnum.Framebuffer, + GLEnum.ColorAttachment0, + GLEnum.Texture2D, + _color.GlName, + 0); + + if (description.DepthFormat is not null) + { + _depthStencilRenderbuffer = GlResourceCommand.CreateName( + _gl, + $"depth renderbuffer '{description.Name}'", + _gl.GenRenderbuffer, + _gl.DeleteRenderbuffer); + _gl.BindRenderbuffer(GLEnum.Renderbuffer, _depthStencilRenderbuffer); + _gl.RenderbufferStorage(GLEnum.Renderbuffer, GLEnum.Depth24Stencil8, (uint)description.Width, (uint)description.Height); + _gl.FramebufferRenderbuffer( + GLEnum.Framebuffer, + GLEnum.DepthStencilAttachment, + GLEnum.Renderbuffer, + _depthStencilRenderbuffer); + } + + GLEnum status = _gl.CheckFramebufferStatus(GLEnum.Framebuffer); + _gl.BindFramebuffer(GLEnum.Framebuffer, 0); + if (status != GLEnum.FramebufferComplete) + { + // Roll back the two names this constructor already owns before + // surfacing the failure — nothing has been published yet. + _gl.DeleteFramebuffer(_framebuffer); + if (_depthStencilRenderbuffer != 0) + _gl.DeleteRenderbuffer(_depthStencilRenderbuffer); + _color.Dispose(); + throw new InvalidOperationException( + $"Render target '{description.Name}' framebuffer is incomplete: {status}."); + } + } + + public GpuRenderTargetDescription Description { get; } + + public IGpuTexture ColorTexture => _color ?? throw new ObjectDisposedException(Description.Name); + + internal uint GlFramebufferName => _framebuffer; + + public void Dispose() + { + uint framebuffer = _framebuffer; + uint renderbuffer = _depthStencilRenderbuffer; + GlGpuTexture? color = _color; + if (framebuffer == 0 && color is null) + return; + _framebuffer = 0; + _depthStencilRenderbuffer = 0; + _color = null; + + GL gl = _gl; + string label = Description.Name; + _retirement.Retire(() => + { + if (framebuffer != 0) + { + gl.DeleteFramebuffer(framebuffer); + GLHelpers.ThrowOnResourceError(gl, $"delete framebuffer '{label}'"); + } + if (renderbuffer != 0) + { + gl.DeleteRenderbuffer(renderbuffer); + GLHelpers.ThrowOnResourceError(gl, $"delete depth renderbuffer '{label}'"); + } + }); + color?.Dispose(); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuSampler.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuSampler.cs new file mode 100644 index 00000000..e2d8421f --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuSampler.cs @@ -0,0 +1,61 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// One GL sampler object. +/// de-duplicates by value, so a given +/// wrap/filter combination is only ever backed by one physical sampler name — +/// the same pattern SamplerCache already uses for its two fixed +/// samplers, generalized to the full description space the RHI exposes. +/// +internal sealed class GlGpuSampler : IGpuSampler +{ + private const uint GlTextureMaxAnisotropy = 0x84FE; + + private readonly GL _gl; + private readonly IGpuResourceRetirementQueue _retirement; + private uint _name; + + public GlGpuSampler(GL gl, IGpuResourceRetirementQueue retirement, GpuSamplerDescription description) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Description = description; + + _name = GlResourceCommand.CreateName(_gl, "sampler", _gl.GenSampler, _gl.DeleteSampler); + TextureMinFilter minFilter = GlEnumMapping.MinFilterOf(description.MinFilter, description.MipFilter); + TextureMagFilter magFilter = GlEnumMapping.MagFilterOf(description.MagFilter); + TextureWrapMode wrapU = GlEnumMapping.WrapModeOf(description.AddressU); + TextureWrapMode wrapV = GlEnumMapping.WrapModeOf(description.AddressV); + + _gl.SamplerParameter(_name, SamplerParameterI.MinFilter, (int)minFilter); + _gl.SamplerParameter(_name, SamplerParameterI.MagFilter, (int)magFilter); + _gl.SamplerParameter(_name, SamplerParameterI.WrapS, (int)wrapU); + _gl.SamplerParameter(_name, SamplerParameterI.WrapT, (int)wrapV); + if (description.MaxAnisotropy > 1f) + _gl.SamplerParameter(_name, (SamplerParameterF)GlTextureMaxAnisotropy, description.MaxAnisotropy); + GLHelpers.ThrowOnResourceError(_gl, $"configure sampler {_name}"); + } + + public GpuSamplerDescription Description { get; } + + internal uint GlName => _name; + + public void Dispose() + { + uint name = _name; + if (name == 0) + return; + _name = 0; + + GL gl = _gl; + _retirement.Retire(() => + { + gl.DeleteSampler(name); + GLHelpers.ThrowOnResourceError(gl, $"delete sampler {name}"); + }); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTexture.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTexture.cs new file mode 100644 index 00000000..f4f96e3d --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTexture.cs @@ -0,0 +1,202 @@ +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// A GL texture (2D or 2D array) allocated with immutable storage +/// (glTexStorage2D/3D) so every mip level is defined the moment the +/// object is created, regardless of upload order — mirroring +/// ManagedGLTextureArray's allocation strategy without inheriting its +/// Chorizite/ coupling. +/// +/// The texture's own min/mag filter is set at creation (not left at GL +/// defaults) purely so the texture is "complete" for sampling the instant it +/// exists — completeness is judged from the texture object's own filter +/// state, independent of whatever a draw later +/// binds. The bound sampler object overrides actual filtering at draw time +/// (same override rule SamplerCache already documents), so this +/// default never affects the rendered image. +/// +internal sealed class GlGpuTexture : IGpuTexture +{ + private readonly GL _gl; + private readonly IGpuResourceRetirementQueue _retirement; + private readonly GlTextureFormatInfo _formatInfo; + private readonly GLEnum _target; + private uint _name; + + public GlGpuTexture(GL gl, IGpuResourceRetirementQueue retirement, GpuTextureDescription description) + { + _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _retirement = retirement ?? throw new ArgumentNullException(nameof(retirement)); + Name = description.Name; + Kind = description.Kind; + Format = description.Format; + Width = description.Width; + Height = description.Height; + LayerCount = description.LayerCount; + MipLevelCount = description.MipLevelCount; + _formatInfo = GlGpuTextureFormatMapping.Resolve(Format); + _target = Kind == GpuTextureKind.Texture2D ? GLEnum.Texture2D : GLEnum.Texture2DArray; + + _name = GlResourceCommand.CreateName(_gl, $"texture '{Name}'", _gl.GenTexture, _gl.DeleteTexture); + _gl.BindTexture(_target, _name); + if (Kind == GpuTextureKind.Texture2D) + { + _gl.TexStorage2D(_target, (uint)MipLevelCount, _formatInfo.SizedInternalFormat, (uint)Width, (uint)Height); + } + else + { + _gl.TexStorage3D( + _target, + (uint)MipLevelCount, + _formatInfo.SizedInternalFormat, + (uint)Width, + (uint)Height, + (uint)LayerCount); + } + GLHelpers.ThrowOnResourceError( + _gl, + $"allocate texture '{Name}' {Format} {Width}x{Height}x{LayerCount} ({MipLevelCount} mips)"); + + TextureMinFilter minFilter = MipLevelCount > 1 ? TextureMinFilter.LinearMipmapLinear : TextureMinFilter.Linear; + _gl.TexParameter(_target, TextureParameterName.TextureMinFilter, (int)minFilter); + _gl.TexParameter(_target, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + GLHelpers.ThrowOnResourceError(_gl, $"set default filter for texture '{Name}'"); + _gl.BindTexture(_target, 0); + } + + public string Name { get; } + public GpuTextureKind Kind { get; } + public GpuTextureFormat Format { get; } + public int Width { get; } + public int Height { get; } + public int LayerCount { get; } + public int MipLevelCount { get; } + + /// The physical GL texture name. Used by to obtain a bindless handle. + internal uint GlName => _name; + + internal GLEnum Target => _target; + + public void Upload(int mipLevel, int layer, ReadOnlySpan data) + { + ThrowIfDisposed(); + if (Format == GpuTextureFormat.Depth24Stencil8) + { + throw new NotSupportedException( + "Depth24Stencil8 textures are attachment-only; they are never uploaded from the CPU."); + } + if ((uint)mipLevel >= (uint)MipLevelCount) + throw new ArgumentOutOfRangeException(nameof(mipLevel)); + if (Kind == GpuTextureKind.Texture2D && layer != 0) + throw new ArgumentOutOfRangeException(nameof(layer), "A 2D texture only has layer 0."); + if (Kind == GpuTextureKind.Texture2DArray && (uint)layer >= (uint)LayerCount) + throw new ArgumentOutOfRangeException(nameof(layer)); + + int mipWidth = Math.Max(1, Width >> mipLevel); + int mipHeight = Math.Max(1, Height >> mipLevel); + long expected = _formatInfo.LayerByteCount(mipWidth, mipHeight); + if (data.Length != expected) + { + throw new ArgumentException( + $"Upload for texture '{Name}' mip {mipLevel} has {data.Length} bytes; expected {expected} " + + $"for {Format} {mipWidth}x{mipHeight}.", + nameof(data)); + } + + _gl.BindTexture(_target, _name); + _gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1); + unsafe + { + fixed (byte* pointer = data) + { + if (Kind == GpuTextureKind.Texture2D) + UploadTexture2D(mipLevel, mipWidth, mipHeight, data.Length, pointer); + else + UploadTexture2DArray(mipLevel, layer, mipWidth, mipHeight, data.Length, pointer); + } + } + GLHelpers.ThrowOnResourceError(_gl, $"upload texture '{Name}' mip {mipLevel} layer {layer}"); + _gl.BindTexture(_target, 0); + } + + private unsafe void UploadTexture2D(int mipLevel, int mipWidth, int mipHeight, int byteCount, byte* pointer) + { + if (_formatInfo.IsCompressed) + { + _gl.CompressedTexSubImage2D( + _target, mipLevel, 0, 0, (uint)mipWidth, (uint)mipHeight, + (InternalFormat)_formatInfo.SizedInternalFormat, (uint)byteCount, pointer); + } + else + { + _gl.TexSubImage2D( + _target, mipLevel, 0, 0, (uint)mipWidth, (uint)mipHeight, + _formatInfo.UploadPixelFormat, _formatInfo.UploadPixelType, pointer); + } + } + + private unsafe void UploadTexture2DArray(int mipLevel, int layer, int mipWidth, int mipHeight, int byteCount, byte* pointer) + { + if (_formatInfo.IsCompressed) + { + _gl.CompressedTexSubImage3D( + _target, mipLevel, 0, 0, layer, (uint)mipWidth, (uint)mipHeight, 1, + (InternalFormat)_formatInfo.SizedInternalFormat, (uint)byteCount, pointer); + } + else + { + _gl.TexSubImage3D( + _target, mipLevel, 0, 0, layer, (uint)mipWidth, (uint)mipHeight, 1, + _formatInfo.UploadPixelFormat, _formatInfo.UploadPixelType, pointer); + } + } + + public void GenerateMipChain() + { + ThrowIfDisposed(); + if (MipLevelCount <= 1) + return; + + // Matches ManagedGLTextureArray.ProcessDirtyUpdatesInternal: GL's + // driver-defined compressed-mip regeneration is the one behaviour this + // migration deliberately does not carry into the RHI contract (the + // Vulkan backend cannot blit BC images at all and requires a CPU-built + // chain instead). Callers that need BC mips must supply every level + // through Upload directly; this is a documented no-op for them on GL, + // not a silent gap, since the GL renderer path today already skips + // glGenerateMipmap for compressed arrays. + if (_formatInfo.IsCompressed) + return; + + _gl.BindTexture(_target, _name); + _gl.GenerateMipmap(_target); + GLHelpers.ThrowOnResourceError(_gl, $"generate mip chain for texture '{Name}'"); + _gl.BindTexture(_target, 0); + } + + public void Dispose() + { + uint name = _name; + if (name == 0) + return; + _name = 0; + + GL gl = _gl; + string label = Name; + _retirement.Retire(() => + { + gl.DeleteTexture(name); + GLHelpers.ThrowOnResourceError(gl, $"delete texture '{label}' ({name})"); + }); + } + + private void ThrowIfDisposed() + { + if (_name == 0) + throw new ObjectDisposedException(Name); + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTextureFormatMapping.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTextureFormatMapping.cs new file mode 100644 index 00000000..8c4aeb47 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTextureFormatMapping.cs @@ -0,0 +1,56 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// GL format triple for a : the sized internal +/// format used at allocation (glTexStorage2D/3D), the upload +/// format/type for uncompressed uploads, and whether uploads go through +/// glCompressedTexSubImage2D/3D instead. +/// +/// is acdream's own enum, not the Chorizite +/// TextureFormat the existing TextureFormatExtensions targets, +/// so this is the "otherwise add a private mapper" fallback the campaign spec +/// calls for rather than an extension of that existing type. Pure data — +/// safe to unit test without a live GL context, since Silk's GL enums are +/// plain value types. +/// +internal readonly record struct GlTextureFormatInfo( + SizedInternalFormat SizedInternalFormat, + PixelFormat UploadPixelFormat, + PixelType UploadPixelType, + bool IsCompressed, + int BlockOrTexelBytes, + int BlockDimension) +{ + /// Bytes required for one full, uncompressed mip layer at the given dimensions, or one compressed layer rounded up to whole blocks. + public long LayerByteCount(int width, int height) + { + if (!IsCompressed) + return (long)width * height * BlockOrTexelBytes; + + int blocksWide = (width + BlockDimension - 1) / BlockDimension; + int blocksHigh = (height + BlockDimension - 1) / BlockDimension; + return (long)blocksWide * blocksHigh * BlockOrTexelBytes; + } +} + +internal static class GlGpuTextureFormatMapping +{ + public static GlTextureFormatInfo Resolve(GpuTextureFormat format) => format switch + { + GpuTextureFormat.Rgba8Unorm or GpuTextureFormat.Rgba8UnormRenderTarget => + new GlTextureFormatInfo(SizedInternalFormat.Rgba8, PixelFormat.Rgba, PixelType.UnsignedByte, false, 4, 1), + GpuTextureFormat.R8Unorm => + new GlTextureFormatInfo(SizedInternalFormat.R8, PixelFormat.Red, PixelType.UnsignedByte, false, 1, 1), + GpuTextureFormat.Bc1Unorm => + new GlTextureFormatInfo(SizedInternalFormat.CompressedRgbaS3TCDxt1Ext, PixelFormat.Rgba, PixelType.UnsignedByte, true, 8, 4), + GpuTextureFormat.Bc2Unorm => + new GlTextureFormatInfo(SizedInternalFormat.CompressedRgbaS3TCDxt3Ext, PixelFormat.Rgba, PixelType.UnsignedByte, true, 16, 4), + GpuTextureFormat.Bc3Unorm => + new GlTextureFormatInfo(SizedInternalFormat.CompressedRgbaS3TCDxt5Ext, PixelFormat.Rgba, PixelType.UnsignedByte, true, 16, 4), + GpuTextureFormat.Depth24Stencil8 => + new GlTextureFormatInfo(SizedInternalFormat.Depth24Stencil8, PixelFormat.DepthStencil, PixelType.UnsignedInt248, false, 4, 1), + _ => throw new NotSupportedException($"No GL format mapping for {format}."), + }; +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTimerPool.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTimerPool.cs new file mode 100644 index 00000000..a1c015c6 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuTimerPool.cs @@ -0,0 +1,186 @@ +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Seam over the four GL calls a TimeElapsed timer scope needs, mirroring +/// IGpuFenceApi's role for : it lets +/// 's double-buffering and result-promotion logic run +/// under a unit test with no live GL context. +/// +internal interface IGlTimerQueryApi +{ + uint CreateQuery(); + void DeleteQuery(uint query); + void Begin(uint query); + void End(); + + /// Non-blocking: false when the result for is not yet available (or the query was never begun). + bool TryGetResult(uint query, out double milliseconds); +} + +internal sealed class SilkGlTimerQueryApi(GL gl) : IGlTimerQueryApi +{ + private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + + public uint CreateQuery() => _gl.GenQuery(); + + public void DeleteQuery(uint query) => _gl.DeleteQuery(query); + + public void Begin(uint query) => _gl.BeginQuery(QueryTarget.TimeElapsed, query); + + public void End() => _gl.EndQuery(QueryTarget.TimeElapsed); + + public bool TryGetResult(uint query, out double milliseconds) + { + _gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available); + if (available == 0) + { + milliseconds = 0; + return false; + } + + _gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNanoseconds); + milliseconds = elapsedNanoseconds / 1_000_000d; + return true; + } +} + +/// +/// backed by TimeElapsed queries. Core GL +/// only allows one GL_TIME_ELAPSED query active at a time (the +/// restriction is per target, not per query object), so scopes must not +/// nest — throws if a previous scope in the same +/// pass hasn't been disposed yet, exactly as WbDrawDispatcher already +/// requires for its own opaque/transparent query pair. +/// +/// Each distinct scope name gets its own double-buffered pair of query +/// objects (per the campaign spec: "double-buffered so results are read from +/// a retired frame and never block"). A scope's second call reads back the +/// FIRST call's result non-blockingly — by the time a scope name repeats, +/// at least one full frame has usually retired, so the result is normally +/// ready; if it is not, the previous value is kept until it is. +/// +internal sealed class GlGpuTimerPool : IGpuTimerPool +{ + private const int BufferDepth = 2; + + private sealed class ScopeState + { + public readonly uint[] Queries = new uint[BufferDepth]; + public readonly bool[] Began = new bool[BufferDepth]; + public int NextSlot; + public double LastResolvedMilliseconds; + public bool HasResolvedValue; + } + + private readonly IGlTimerQueryApi _api; + private readonly Dictionary _scopes = new(StringComparer.Ordinal); + private string? _activeScopeName; + + public GlGpuTimerPool(IGlTimerQueryApi api, bool isSupported) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + IsSupported = isSupported; + } + + public bool IsSupported { get; } + + /// + /// Begins (or continues) the named scope. Returns a disposable that ends + /// the query; dispose it before beginning another scope in the same pass. + /// + public IDisposable BeginScope(string scopeName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(scopeName); + if (!IsSupported) + return NullTimerScope.Instance; + + if (_activeScopeName is not null) + { + throw new InvalidOperationException( + $"GPU timer scope '{_activeScopeName}' is still active; TimeElapsed queries " + + "cannot nest. Dispose the previous scope before beginning another."); + } + + if (!_scopes.TryGetValue(scopeName, out ScopeState? state)) + { + state = new ScopeState(); + for (int i = 0; i < BufferDepth; i++) + state.Queries[i] = _api.CreateQuery(); + _scopes.Add(scopeName, state); + } + + int slot = state.NextSlot; + state.NextSlot = (slot + 1) % BufferDepth; + + // Poll the OTHER slot's query — the one begun on the previous call to + // this scope name — before beginning this one. That is what makes a + // scope's Nth call read back the (N-1)th call's result: with only two + // slots, "the previous slot" and "the other slot" are the same index, + // and by now it has usually had at least one frame to retire. + int previousSlot = (slot + BufferDepth - 1) % BufferDepth; + if (state.Began[previousSlot] && _api.TryGetResult(state.Queries[previousSlot], out double milliseconds)) + { + state.LastResolvedMilliseconds = milliseconds; + state.HasResolvedValue = true; + } + + _api.Begin(state.Queries[slot]); + state.Began[slot] = true; + _activeScopeName = scopeName; + return new ActiveTimerScope(this, scopeName); + } + + public bool TryResolve(string scopeName, out double milliseconds) + { + if (_scopes.TryGetValue(scopeName, out ScopeState? state) && state.HasResolvedValue) + { + milliseconds = state.LastResolvedMilliseconds; + return true; + } + + milliseconds = 0; + return false; + } + + private void EndScope(string scopeName) + { + if (_activeScopeName != scopeName) + return; + + _api.End(); + _activeScopeName = null; + } + + internal void DisposeQueries() + { + foreach (ScopeState state in _scopes.Values) + { + foreach (uint query in state.Queries) + _api.DeleteQuery(query); + } + _scopes.Clear(); + } + + private sealed class ActiveTimerScope(GlGpuTimerPool pool, string scopeName) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + pool.EndScope(scopeName); + } + } + + private sealed class NullTimerScope : IDisposable + { + public static NullTimerScope Instance { get; } = new(); + public void Dispose() + { + } + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlPushConstantUniformNames.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlPushConstantUniformNames.cs new file mode 100644 index 00000000..c61637a0 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlPushConstantUniformNames.cs @@ -0,0 +1,72 @@ +using System.Reflection; + +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// The single source of truth mapping each +/// field to the GLSL uniform name the GL backend binds it to — the names +/// documented on the fields themselves. +/// reads these constants (not string literals of its own) when it calls +/// GL.GetUniformLocation, and lets a +/// test prove the table cannot silently drop a field that a later slice adds +/// to the shared struct. +/// +internal static class GlPushConstantUniformNames +{ + public const string ViewProjection = "uViewProjection"; + public const string DrawIdOffset = "uDrawIDOffset"; + public const string LightingMode = "uLightingMode"; + public const string RenderPass = "uRenderPass"; + public const string LightDebug = "uLightDebug"; + public const string TextureIndexA = "uTextureIndexA"; + public const string TextureIndexB = "uTextureIndexB"; + public const string ParamA = "uParamA"; + public const string ParamB = "uParamB"; + + /// + /// Field name (as declared on ) to GLSL + /// uniform name, built from the same constants the binder applies with — + /// so the binder and this completeness table can never disagree. + /// + public static IReadOnlyDictionary ByFieldName { get; } = + new Dictionary + { + [nameof(GpuPushConstants.ViewProjection)] = ViewProjection, + [nameof(GpuPushConstants.DrawIdOffset)] = DrawIdOffset, + [nameof(GpuPushConstants.LightingMode)] = LightingMode, + [nameof(GpuPushConstants.RenderPass)] = RenderPass, + [nameof(GpuPushConstants.LightDebug)] = LightDebug, + [nameof(GpuPushConstants.TextureIndexA)] = TextureIndexA, + [nameof(GpuPushConstants.TextureIndexB)] = TextureIndexB, + [nameof(GpuPushConstants.ParamA)] = ParamA, + [nameof(GpuPushConstants.ParamB)] = ParamB, + }; + + /// + /// Throws if declares a public instance + /// field this table does not map — the drift guard the campaign spec asks + /// for. Called from a unit test, not from production code. + /// + internal static void AssertMapsEveryField() + { + FieldInfo[] fields = typeof(GpuPushConstants).GetFields( + BindingFlags.Public | BindingFlags.Instance); + foreach (FieldInfo field in fields) + { + if (!ByFieldName.ContainsKey(field.Name)) + { + throw new InvalidOperationException( + $"GpuPushConstants.{field.Name} has no mapped GLSL uniform name in " + + $"{nameof(GlPushConstantUniformNames)}."); + } + } + + if (ByFieldName.Count != fields.Length) + { + throw new InvalidOperationException( + $"{nameof(GlPushConstantUniformNames)} maps {ByFieldName.Count} names but " + + $"{nameof(GpuPushConstants)} declares {fields.Length} fields — a stale entry " + + "survives a field rename or removal."); + } + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs new file mode 100644 index 00000000..846f8d35 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs @@ -0,0 +1,76 @@ +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Everything bakes plus the handful of fields +/// core Vulkan 1.3 (and this backend) makes dynamic per draw. +/// is the GL program name, included so a pipeline +/// switch is itself a tracked dimension. +/// +internal readonly record struct GlRenderStateSnapshot( + uint Program, + GpuBlendMode Blend, + bool DepthTest, + bool DepthWrite, + GpuCompareOp DepthCompare, + GpuCullMode Cull, + GpuFrontFace FrontFace, + bool AlphaToCoverage, + bool ColorWrite); + +/// Which GL state calls are needed to move from the previous snapshot to the new one. +internal readonly record struct GlRenderStateChanges( + bool Program, + bool Blend, + bool DepthTest, + bool DepthWrite, + bool DepthCompare, + bool Cull, + bool FrontFace, + bool AlphaToCoverage, + bool ColorWrite) +{ + public bool AnyChange => + Program || Blend || DepthTest || DepthWrite || DepthCompare + || Cull || FrontFace || AlphaToCoverage || ColorWrite; + + /// Every dimension reported changed — used for the first apply after a reset. + internal static GlRenderStateChanges All { get; } = new(true, true, true, true, true, true, true, true, true); +} + +/// +/// Pure GL-free state cache: given the previously applied +/// and a newly desired one, reports which +/// dimensions actually changed so only issues +/// the GL calls that matter. Starts with no baseline, so the very first +/// call always reports every dimension changed — "when in +/// doubt, set the state" rather than risk stale driver state from before this +/// cache existed (e.g. from a previous pass, or default GL state that may not +/// match a pipeline's baked defaults). +/// +internal sealed class GlRenderStateCache +{ + private GlRenderStateSnapshot? _last; + + public GlRenderStateChanges Apply(GlRenderStateSnapshot desired) + { + GlRenderStateSnapshot? previous = _last; + _last = desired; + + if (previous is not { } p) + return GlRenderStateChanges.All; + + return new GlRenderStateChanges( + p.Program != desired.Program, + p.Blend != desired.Blend, + p.DepthTest != desired.DepthTest, + p.DepthWrite != desired.DepthWrite, + p.DepthCompare != desired.DepthCompare, + p.Cull != desired.Cull, + p.FrontFace != desired.FrontFace, + p.AlphaToCoverage != desired.AlphaToCoverage, + p.ColorWrite != desired.ColorWrite); + } + + /// Discards the cached baseline — the next reports every dimension changed. + public void Reset() => _last = null; +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs new file mode 100644 index 00000000..1c7caca6 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs @@ -0,0 +1,99 @@ +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Pure bookkeeping for one flight slot's upload ring: an allocation cursor +/// that only grows across a frame, plus a separate "dirty" watermark that +/// tracks the byte range written since the last flush. +/// +/// This is deliberately GL-free so it can be unit tested without a live +/// context. owns one instance per flight slot and +/// pairs it with a managed staging byte[] and a real GL buffer; the +/// staging array receives every write, +/// and immediately before each Draw/DrawIndexed/ +/// MultiDrawIndexedIndirect the device uploads exactly the dirty range +/// via one glBufferSubData call and calls +/// to reset the watermark. The allocation cursor itself only resets at +/// (once per BeginFrame) — writes made after a +/// flush simply extend the dirty range again, to be picked up by the next +/// flush. This is what makes "flush before every draw, not at bind time" +/// correct: a renderer that writes after binding still gets uploaded before +/// its draw call runs. +/// +internal sealed class GlRingBufferState +{ + private readonly int _capacityBytes; + private uint _cursor; + private int _dirtyStart = -1; + private int _dirtyEnd = -1; + + public GlRingBufferState(int capacityBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacityBytes); + _capacityBytes = capacityBytes; + } + + public int CapacityBytes => _capacityBytes; + + /// Bytes handed out since the last . + public uint AllocatedBytes => _cursor; + + /// + /// Reserves bytes aligned to + /// , returning the aligned offset. + /// Throws — rather than truncating — when the request would exceed the + /// slot's capacity, because a silently shortened allocation would corrupt + /// the frame invisibly. + /// + public uint Allocate(int byteCount, uint alignmentBytes) + { + ArgumentOutOfRangeException.ThrowIfNegative(byteCount); + uint aligned = AlignUp(_cursor, alignmentBytes); + long end = (long)aligned + byteCount; + if (end > _capacityBytes) + { + throw new InvalidOperationException( + $"Ring allocation of {byteCount} bytes at aligned offset {aligned} " + + $"needs {end} bytes; the ring slot is {_capacityBytes} bytes. " + + "Increase the per-slot ring capacity (GlGpuDevice's ringCapacityBytesPerSlot)."); + } + + _cursor = (uint)end; + if (byteCount > 0) + MarkDirty((int)aligned, (int)end); + return aligned; + } + + /// Starts a new frame: rewinds the allocation cursor. Dirty state is untouched — a flush always runs before this is called. + public void Reset() + { + _cursor = 0; + _dirtyStart = -1; + _dirtyEnd = -1; + } + + /// + /// Returns the byte range written since the last flush (start, length), + /// or (0, 0) when nothing is dirty, and clears the watermark. + /// + public (int Start, int Length) TakeDirtyRange() + { + if (_dirtyStart < 0) + return (0, 0); + + (int start, int length) = (_dirtyStart, _dirtyEnd - _dirtyStart); + _dirtyStart = -1; + _dirtyEnd = -1; + return (start, length); + } + + public bool HasDirtyBytes => _dirtyStart >= 0; + + private void MarkDirty(int start, int end) + { + _dirtyStart = _dirtyStart < 0 ? start : Math.Min(_dirtyStart, start); + _dirtyEnd = Math.Max(_dirtyEnd, end); + } + + internal static uint AlignUp(uint value, uint alignmentBytes) => + alignmentBytes <= 1 ? value : (value + alignmentBytes - 1) / alignmentBytes * alignmentBytes; +} diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlTextureSlotAllocator.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlTextureSlotAllocator.cs new file mode 100644 index 00000000..4db8c218 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlTextureSlotAllocator.cs @@ -0,0 +1,62 @@ +namespace AcDream.App.Rendering.Gpu.Gl; + +/// +/// Pure bump-plus-free-list allocator for the GL texture table (the storage +/// buffer of bindless handles at ). +/// +/// GL-free by design: it knows nothing about bindless handles, retirement +/// queues, or the SSBO itself. calls +/// to get a slot to write a handle into; +/// defers the call to +/// through the device's IGpuResourceRetirementQueue +/// so a freed slot is never handed back out while a submitted frame could +/// still be reading the old handle at that index. +/// +internal sealed class GlTextureSlotAllocator +{ + private readonly uint _capacity; + private readonly Stack _freeList = new(); + private uint _nextBumpSlot; + + public GlTextureSlotAllocator(uint capacity) + { + ArgumentOutOfRangeException.ThrowIfZero(capacity); + _capacity = capacity; + } + + /// Slots currently handed out (bumped or reused) and not yet released. + public int LiveCount => (int)_nextBumpSlot - _freeList.Count; + + public uint Allocate() + { + if (_freeList.Count > 0) + return _freeList.Pop(); + + if (_nextBumpSlot >= _capacity) + { + throw new InvalidOperationException( + $"The GL texture table is exhausted: {_capacity} slots are all live. " + + "Every texture cache/atlas must release slots it no longer needs before " + + "registering new ones."); + } + + return _nextBumpSlot++; + } + + /// + /// Returns a slot to the free list. Callers must only call this once the + /// retirement queue confirms no live frame could still reference the slot. + /// + public void Release(uint slot) + { + if (slot >= _nextBumpSlot) + { + throw new ArgumentOutOfRangeException( + nameof(slot), + slot, + "Cannot release a slot that was never allocated."); + } + + _freeList.Push(slot); + } +} diff --git a/src/AcDream.App/Rendering/Wb/BindlessSupport.cs b/src/AcDream.App/Rendering/Wb/BindlessSupport.cs index 0047b1d0..cde447c2 100644 --- a/src/AcDream.App/Rendering/Wb/BindlessSupport.cs +++ b/src/AcDream.App/Rendering/Wb/BindlessSupport.cs @@ -57,6 +57,46 @@ public sealed class BindlessSupport return h; } + /// + /// Get a 64-bit bindless handle combining a texture with an EXPLICIT + /// sampler object (rather than the texture's own baked sampler state) and + /// make it resident. Idempotent per (texture, sampler) pair. + /// + /// Added for Campaign V slice V1's GlGpuDevice.RegisterTexture, + /// which registers a (texture, sampler) pair per the RHI contract — "the + /// same texture registered with two samplers occupies two slots." The + /// texture-only above cannot express + /// that; ManagedGLTextureArray already calls the equivalent + /// ArbBindlessTexture.GetTextureSamplerHandle directly through + /// OpenGLGraphicsDevice.BindlessExtension, so this simply exposes + /// the same GL entry point through this class for the RHI's use. + /// + public ulong GetResidentHandle(uint textureName, uint samplerName) + { + ulong h = GlResourceCommand.Execute( + _gl, + $"get bindless handle for texture {textureName} + sampler {samplerName}", + () => _ext.GetTextureSamplerHandle(textureName, samplerName)); + if (h == 0) + { + throw new InvalidOperationException( + $"OpenGL returned no bindless handle for texture {textureName} + sampler {samplerName}."); + } + + bool resident = GlResourceCommand.Execute( + _gl, + $"query bindless handle {h} residency", + () => _ext.IsTextureHandleResident(h)); + if (!resident) + { + GlResourceCommand.Execute( + _gl, + $"make bindless handle {h} resident", + () => _ext.MakeTextureHandleResident(h)); + } + return h; + } + /// Release residency for a handle. Call before deleting the underlying texture. public void MakeNonResident(ulong handle) { diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs index b9fd011d..7b081a55 100644 --- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs @@ -3,6 +3,8 @@ using System.Reflection; using AcDream.App.Composition; using AcDream.App.Input; using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; using Silk.NET.Maths; @@ -23,6 +25,7 @@ public sealed class HostInputCameraCompositionTests Enum.GetValues(), fixture.Points); Assert.Same(fixture.Publication.GpuFrames, result.GpuFrameFlights); + Assert.Same(fixture.Publication.GpuDevice, result.GpuDevice); Assert.Same(fixture.Publication.Keyboard, result.KeyboardSource); Assert.Same(fixture.Publication.Mouse, result.MouseSource); Assert.Same(fixture.Publication.Dispatcher, result.InputDispatcher); @@ -155,6 +158,7 @@ public sealed class HostInputCameraCompositionTests IDisposable { public GpuFrameFlightController? GpuFrames { get; private set; } + public IGpuDevice? GpuDevice { get; private set; } public SilkKeyboardSource? Keyboard { get; private set; } public SilkMouseSource? Mouse { get; private set; } public IMouseLookCursor? Cursor { get; private set; } @@ -165,6 +169,9 @@ public sealed class HostInputCameraCompositionTests public void PublishGpuFrameFlights(GpuFrameFlightController value) => GpuFrames = PublishOnce(GpuFrames, value); + public void PublishGpuDevice(IGpuDevice value) => + GpuDevice = PublishOnce(GpuDevice, value); + public void PublishKeyboardSource(SilkKeyboardSource value) => Keyboard = PublishOnce(Keyboard, value); @@ -188,6 +195,9 @@ public sealed class HostInputCameraCompositionTests Assert.Equal( point >= HostInputCameraCompositionPoint.GpuFrameFlightsPublished, GpuFrames is not null); + Assert.Equal( + point >= HostInputCameraCompositionPoint.GpuDevicePublished, + GpuDevice is not null); Assert.Equal( point >= HostInputCameraCompositionPoint.KeyboardPublished, Keyboard is not null); @@ -218,6 +228,8 @@ public sealed class HostInputCameraCompositionTests Mouse = null; Keyboard?.Dispose(); Keyboard = null; + GpuDevice?.Dispose(); + GpuDevice = null; GpuFrames?.Dispose(); GpuFrames = null; } @@ -243,6 +255,9 @@ public sealed class HostInputCameraCompositionTests public GpuFrameFlightController CreateGpuFrameFlights(GL gl) => new(new FenceApi()); + public IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights) => + new RecordingGpuDevice(); + public WorldRenderDiagnostics CreateWorldRenderDiagnostics( GL gl, IRenderFrameDiagnosticLog log) => diff --git a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs index 0ef2b5be..d9d3db6c 100644 --- a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs @@ -215,6 +215,7 @@ public sealed class SettingsDevToolsCompositionTests _dispatcher.Attach(); var camera = new CameraController(new OrbitCamera(), new FlyCamera()); Host = new HostInputCameraResult( + null!, null!, null!, null, diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlEnumMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlEnumMappingTests.cs new file mode 100644 index 00000000..8cf17908 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlEnumMappingTests.cs @@ -0,0 +1,79 @@ +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlEnumMappingTests +{ + [Fact] + public void VertexShapesMatchTheDeclaredComponentCounts() + { + Assert.Equal(new GlVertexAttributeShape(1, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float1)); + Assert.Equal(new GlVertexAttributeShape(2, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float2)); + Assert.Equal(new GlVertexAttributeShape(3, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float3)); + Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float4)); + Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, true), GlEnumMapping.VertexShapeOf(GpuVertexFormat.UByte4Normalized)); + } + + [Fact] + public void IndexTypesMapToTheirGlSizesAndEnums() + { + Assert.Equal(DrawElementsType.UnsignedShort, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt16)); + Assert.Equal(2, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt16)); + Assert.Equal(DrawElementsType.UnsignedInt, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt32)); + Assert.Equal(4, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt32)); + } + + [Fact] + public void BlendFactorsMatchTheDocumentedFormulas() + { + (BlendingFactor source, BlendingFactor destination) straight = GlEnumMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha); + Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha), straight); + + (BlendingFactor source, BlendingFactor destination) additive = GlEnumMapping.BlendFactorsOf(GpuBlendMode.Additive); + Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.One), additive); + } + + [Fact] + public void BlendNoneHasNoGlFactors() + { + Assert.Throws(() => GlEnumMapping.BlendFactorsOf(GpuBlendMode.None)); + } + + [Fact] + public void CullNoneHasNoGlCullFaceMode() + { + Assert.Throws(() => GlEnumMapping.CullFaceModeOf(GpuCullMode.None)); + } + + [Fact] + public void DepthCompareOpsMapOneToOne() + { + Assert.Equal(DepthFunction.Never, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Never)); + Assert.Equal(DepthFunction.Less, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Less)); + Assert.Equal(DepthFunction.Lequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.LessOrEqual)); + Assert.Equal(DepthFunction.Equal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Equal)); + Assert.Equal(DepthFunction.Greater, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Greater)); + Assert.Equal(DepthFunction.Gequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.GreaterOrEqual)); + Assert.Equal(DepthFunction.Always, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Always)); + } + + [Fact] + public void FilterCombinationsProduceTheExpectedMinFilter() + { + Assert.Equal(TextureMinFilter.Nearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.None)); + Assert.Equal(TextureMinFilter.Linear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.None)); + Assert.Equal(TextureMinFilter.LinearMipmapLinear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.Linear)); + Assert.Equal(TextureMinFilter.NearestMipmapNearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.Nearest)); + } + + [Fact] + public void EveryNonNoneEnumValueResolvesForFrontFaceAndWrapMode() + { + Assert.Equal(FrontFaceDirection.Ccw, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.CounterClockwise)); + Assert.Equal(FrontFaceDirection.CW, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.Clockwise)); + Assert.Equal(TextureWrapMode.Repeat, GlEnumMapping.WrapModeOf(GpuAddressMode.Repeat)); + Assert.Equal(TextureWrapMode.ClampToEdge, GlEnumMapping.WrapModeOf(GpuAddressMode.ClampToEdge)); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTextureFormatMappingTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTextureFormatMappingTests.cs new file mode 100644 index 00000000..c31791bb --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTextureFormatMappingTests.cs @@ -0,0 +1,75 @@ +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlGpuTextureFormatMappingTests +{ + [Fact] + public void Rgba8AndItsRenderTargetVariantShareTheSameGlShape() + { + GlTextureFormatInfo plain = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8Unorm); + GlTextureFormatInfo target = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8UnormRenderTarget); + + Assert.Equal(plain, target); + Assert.Equal(SizedInternalFormat.Rgba8, plain.SizedInternalFormat); + Assert.Equal(PixelFormat.Rgba, plain.UploadPixelFormat); + Assert.Equal(PixelType.UnsignedByte, plain.UploadPixelType); + Assert.False(plain.IsCompressed); + Assert.Equal(4, plain.LayerByteCount(1, 1)); + Assert.Equal(4 * 16 * 16, plain.LayerByteCount(16, 16)); + } + + [Fact] + public void R8IsOneByteUncompressed() + { + GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.R8Unorm); + Assert.Equal(SizedInternalFormat.R8, info.SizedInternalFormat); + Assert.False(info.IsCompressed); + Assert.Equal(1, info.LayerByteCount(1, 1)); + Assert.Equal(64, info.LayerByteCount(8, 8)); + } + + [Fact] + public void Bc1IsCompressedFourByFourEightByteBlocks() => + AssertBcFormat(GpuTextureFormat.Bc1Unorm, expectedBlockBytes: 8); + + [Fact] + public void Bc2IsCompressedFourByFourSixteenByteBlocks() => + AssertBcFormat(GpuTextureFormat.Bc2Unorm, expectedBlockBytes: 16); + + [Fact] + public void Bc3IsCompressedFourByFourSixteenByteBlocks() => + AssertBcFormat(GpuTextureFormat.Bc3Unorm, expectedBlockBytes: 16); + + private static void AssertBcFormat(GpuTextureFormat format, int expectedBlockBytes) + { + GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(format); + Assert.True(info.IsCompressed); + Assert.Equal(4, info.BlockDimension); + Assert.Equal(expectedBlockBytes, info.BlockOrTexelBytes); + + // A single 4x4 block for a 4x4 texture. + Assert.Equal(expectedBlockBytes, info.LayerByteCount(4, 4)); + // Partial blocks round up: a 5x5 texture needs a 2x2 block grid. + Assert.Equal(expectedBlockBytes * 4, info.LayerByteCount(5, 5)); + } + + [Fact] + public void Depth24Stencil8IsAttachmentShapedNotCompressed() + { + GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Depth24Stencil8); + Assert.Equal(SizedInternalFormat.Depth24Stencil8, info.SizedInternalFormat); + Assert.Equal(PixelFormat.DepthStencil, info.UploadPixelFormat); + Assert.Equal(PixelType.UnsignedInt248, info.UploadPixelType); + Assert.False(info.IsCompressed); + } + + [Fact] + public void EveryGpuTextureFormatValueResolvesWithoutThrowing() + { + foreach (GpuTextureFormat format in Enum.GetValues()) + GlGpuTextureFormatMapping.Resolve(format); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs new file mode 100644 index 00000000..4e2435b1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs @@ -0,0 +1,123 @@ +using AcDream.App.Rendering.Gpu.Gl; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlGpuTimerPoolTests +{ + [Fact] + public void UnsupportedPoolReturnsNoOpScopesAndNeverResolves() + { + var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: false); + + using (pool.BeginScope("world")) + { + } + + Assert.False(pool.TryResolve("world", out _)); + } + + [Fact] + public void ScopeNamesGetIndependentDoubleBufferedQueries() + { + var api = new FakeTimerQueryApi(); + var pool = new GlGpuTimerPool(api, isSupported: true); + + using (pool.BeginScope("world")) + { + } + using (pool.BeginScope("ui")) + { + } + + Assert.Equal(4, api.CreatedQueryCount); + } + + [Fact] + public void NestingAScopeBeforeDisposingThePreviousOneThrows() + { + var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: true); + pool.BeginScope("world"); + + Assert.Throws(() => pool.BeginScope("ui")); + } + + [Fact] + public void AResolvedResultIsPromotedTheNextTimeTheSameScopeBegins() + { + var api = new FakeTimerQueryApi(); + var pool = new GlGpuTimerPool(api, isSupported: true); + + using (pool.BeginScope("world")) + { + } + // No result available yet — the first slot hasn't been revisited. + Assert.False(pool.TryResolve("world", out _)); + + api.MakeNextResultReady(milliseconds: 1.5); + using (pool.BeginScope("world")) + { + } + + Assert.True(pool.TryResolve("world", out double milliseconds)); + Assert.Equal(1.5, milliseconds); + } + + [Fact] + public void DisposeQueriesDeletesEveryCreatedQuery() + { + var api = new FakeTimerQueryApi(); + var pool = new GlGpuTimerPool(api, isSupported: true); + using (pool.BeginScope("world")) + { + } + + pool.DisposeQueries(); + + Assert.Equal(2, api.DeletedQueryCount); + } + + private sealed class FakeTimerQueryApi : IGlTimerQueryApi + { + private uint _nextQuery = 1; + private bool _nextResultReady; + private double _nextResultMilliseconds; + + public int CreatedQueryCount { get; private set; } + public int DeletedQueryCount { get; private set; } + + public uint CreateQuery() + { + CreatedQueryCount++; + return _nextQuery++; + } + + public void DeleteQuery(uint query) => DeletedQueryCount++; + + public void Begin(uint query) + { + } + + public void End() + { + } + + public void MakeNextResultReady(double milliseconds) + { + _nextResultReady = true; + _nextResultMilliseconds = milliseconds; + } + + public bool TryGetResult(uint query, out double milliseconds) + { + if (_nextResultReady) + { + milliseconds = _nextResultMilliseconds; + _nextResultReady = false; + return true; + } + + milliseconds = 0; + return false; + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlPushConstantUniformNamesTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlPushConstantUniformNamesTests.cs new file mode 100644 index 00000000..21d50c60 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlPushConstantUniformNamesTests.cs @@ -0,0 +1,35 @@ +using AcDream.App.Rendering.Gpu.Gl; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlPushConstantUniformNamesTests +{ + [Fact] + public void EveryDocumentedFieldMapsToItsDocumentedUniformName() + { + Assert.Equal("uViewProjection", GlPushConstantUniformNames.ByFieldName["ViewProjection"]); + Assert.Equal("uDrawIDOffset", GlPushConstantUniformNames.ByFieldName["DrawIdOffset"]); + Assert.Equal("uLightingMode", GlPushConstantUniformNames.ByFieldName["LightingMode"]); + Assert.Equal("uRenderPass", GlPushConstantUniformNames.ByFieldName["RenderPass"]); + Assert.Equal("uLightDebug", GlPushConstantUniformNames.ByFieldName["LightDebug"]); + Assert.Equal("uTextureIndexA", GlPushConstantUniformNames.ByFieldName["TextureIndexA"]); + Assert.Equal("uTextureIndexB", GlPushConstantUniformNames.ByFieldName["TextureIndexB"]); + Assert.Equal("uParamA", GlPushConstantUniformNames.ByFieldName["ParamA"]); + Assert.Equal("uParamB", GlPushConstantUniformNames.ByFieldName["ParamB"]); + } + + [Fact] + public void TableCoversEveryFieldOnTheSharedStructWithNoStaleEntries() + { + // This is the drift guard: if a later slice adds/removes/renames a + // GpuPushConstants field without updating the mapping table, this + // test fails instead of the GL backend silently skipping a uniform. + GlPushConstantUniformNames.AssertMapsEveryField(); + } + + [Fact] + public void TableHasExactlyNineEntries() + { + Assert.Equal(9, GlPushConstantUniformNames.ByFieldName.Count); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs new file mode 100644 index 00000000..d4f32c6d --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRenderStateCacheTests.cs @@ -0,0 +1,108 @@ +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Gl; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlRenderStateCacheTests +{ + private static GlRenderStateSnapshot Default(uint program = 1) => new( + program, + GpuBlendMode.None, + DepthTest: true, + DepthWrite: true, + GpuCompareOp.LessOrEqual, + GpuCullMode.Back, + GpuFrontFace.CounterClockwise, + AlphaToCoverage: false, + ColorWrite: true); + + [Fact] + public void FirstApplyReportsEveryDimensionChanged() + { + var cache = new GlRenderStateCache(); + GlRenderStateChanges changes = cache.Apply(Default()); + Assert.Equal(GlRenderStateChanges.All, changes); + Assert.True(changes.AnyChange); + } + + [Fact] + public void ReapplyingTheIdenticalSnapshotReportsNoChanges() + { + var cache = new GlRenderStateCache(); + GlRenderStateSnapshot snapshot = Default(); + cache.Apply(snapshot); + + GlRenderStateChanges changes = cache.Apply(snapshot); + + Assert.False(changes.AnyChange); + } + + [Fact] + public void ChangingOnlyCullModeReportsOnlyThatDimension() + { + var cache = new GlRenderStateCache(); + cache.Apply(Default()); + + GlRenderStateChanges changes = cache.Apply(Default() with { Cull = GpuCullMode.None }); + + Assert.True(changes.Cull); + Assert.True(changes.AnyChange); + Assert.False(changes.Program); + Assert.False(changes.Blend); + Assert.False(changes.DepthTest); + Assert.False(changes.DepthWrite); + Assert.False(changes.DepthCompare); + Assert.False(changes.FrontFace); + Assert.False(changes.AlphaToCoverage); + Assert.False(changes.ColorWrite); + } + + [Fact] + public void ChangingTheProgramReportsProgramChangedEvenWhenEveryOtherFieldMatches() + { + var cache = new GlRenderStateCache(); + cache.Apply(Default(program: 1)); + + GlRenderStateChanges changes = cache.Apply(Default(program: 2)); + + Assert.True(changes.Program); + Assert.False(changes.Blend); + } + + [Fact] + public void ResetForcesTheNextApplyToReportEveryDimensionAgain() + { + var cache = new GlRenderStateCache(); + GlRenderStateSnapshot snapshot = Default(); + cache.Apply(snapshot); + + cache.Reset(); + GlRenderStateChanges changes = cache.Apply(snapshot); + + Assert.Equal(GlRenderStateChanges.All, changes); + } + + [Fact] + public void MultipleDimensionsChangingAreAllReported() + { + var cache = new GlRenderStateCache(); + cache.Apply(Default()); + + GlRenderStateChanges changes = cache.Apply(Default() with + { + Blend = GpuBlendMode.StraightAlpha, + DepthWrite = false, + FrontFace = GpuFrontFace.Clockwise, + }); + + Assert.True(changes.Blend); + Assert.True(changes.DepthWrite); + Assert.True(changes.FrontFace); + Assert.False(changes.Cull); + Assert.False(changes.DepthTest); + Assert.False(changes.DepthCompare); + Assert.False(changes.AlphaToCoverage); + Assert.False(changes.ColorWrite); + Assert.False(changes.Program); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs new file mode 100644 index 00000000..163aa76a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs @@ -0,0 +1,112 @@ +using AcDream.App.Rendering.Gpu.Gl; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlRingBufferStateTests +{ + [Fact] + public void FirstAllocationStartsAtZero() + { + var state = new GlRingBufferState(1024); + uint offset = state.Allocate(64, alignmentBytes: 16); + Assert.Equal(0u, offset); + Assert.Equal(64u, state.AllocatedBytes); + } + + [Fact] + public void SubsequentAllocationsAlignUpToTheRequestedBoundary() + { + var state = new GlRingBufferState(1024); + state.Allocate(12, alignmentBytes: 4); + uint second = state.Allocate(64, alignmentBytes: 256); + Assert.Equal(0u, second % 256); + Assert.True(second >= 12); + } + + [Fact] + public void AllocationsMergeIntoOneDirtyRange() + { + var state = new GlRingBufferState(1024); + state.Allocate(16, alignmentBytes: 4); + state.Allocate(32, alignmentBytes: 4); + + (int start, int length) = state.TakeDirtyRange(); + Assert.Equal(0, start); + Assert.Equal(48, length); + } + + [Fact] + public void TakingTheDirtyRangeClearsItUntilTheNextWrite() + { + var state = new GlRingBufferState(1024); + state.Allocate(16, alignmentBytes: 4); + state.TakeDirtyRange(); + + (int start, int length) = state.TakeDirtyRange(); + Assert.Equal(0, start); + Assert.Equal(0, length); + Assert.False(state.HasDirtyBytes); + } + + [Fact] + public void WritesAfterAFlushExtendANewDirtyRangeWithoutRewindingTheCursor() + { + var state = new GlRingBufferState(1024); + state.Allocate(16, alignmentBytes: 4); + state.TakeDirtyRange(); + + uint secondOffset = state.Allocate(8, alignmentBytes: 4); + Assert.Equal(16u, secondOffset); + + (int start, int length) = state.TakeDirtyRange(); + Assert.Equal(16, start); + Assert.Equal(8, length); + } + + [Fact] + public void ResetRewindsTheCursorAndClearsDirtyState() + { + var state = new GlRingBufferState(1024); + state.Allocate(64, alignmentBytes: 4); + + state.Reset(); + + Assert.Equal(0u, state.AllocatedBytes); + Assert.False(state.HasDirtyBytes); + Assert.Equal(0u, state.Allocate(4, alignmentBytes: 4)); + } + + [Fact] + public void OverCapacityRequestThrowsRatherThanTruncating() + { + var state = new GlRingBufferState(64); + Assert.Throws(() => state.Allocate(128, alignmentBytes: 4)); + } + + [Fact] + public void AlignmentPushingPastCapacityAlsoThrows() + { + var state = new GlRingBufferState(64); + state.Allocate(60, alignmentBytes: 4); + Assert.Throws(() => state.Allocate(8, alignmentBytes: 4)); + } + + [Theory] + [InlineData(0u, 256u, 0u)] + [InlineData(1u, 256u, 256u)] + [InlineData(255u, 256u, 256u)] + [InlineData(256u, 256u, 256u)] + [InlineData(10u, 1u, 10u)] + public void AlignUpMatchesStandardAlignmentArithmetic(uint value, uint alignment, uint expected) + { + Assert.Equal(expected, GlRingBufferState.AlignUp(value, alignment)); + } + + [Fact] + public void ZeroByteAllocationDoesNotMarkAnythingDirty() + { + var state = new GlRingBufferState(1024); + state.Allocate(0, alignmentBytes: 4); + Assert.False(state.HasDirtyBytes); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlTextureSlotAllocatorTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlTextureSlotAllocatorTests.cs new file mode 100644 index 00000000..e5973468 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlTextureSlotAllocatorTests.cs @@ -0,0 +1,60 @@ +using AcDream.App.Rendering.Gpu.Gl; + +namespace AcDream.App.Tests.Rendering.Gpu.Gl; + +public sealed class GlTextureSlotAllocatorTests +{ + [Fact] + public void AllocationsBumpSequentiallyFromZero() + { + var allocator = new GlTextureSlotAllocator(capacity: 4); + Assert.Equal(0u, allocator.Allocate()); + Assert.Equal(1u, allocator.Allocate()); + Assert.Equal(2u, allocator.Allocate()); + Assert.Equal(3, allocator.LiveCount); + } + + [Fact] + public void ExhaustingCapacityThrows() + { + var allocator = new GlTextureSlotAllocator(capacity: 2); + allocator.Allocate(); + allocator.Allocate(); + Assert.Throws(() => allocator.Allocate()); + } + + [Fact] + public void ReleasedSlotsAreReusedBeforeBumpingFurther() + { + var allocator = new GlTextureSlotAllocator(capacity: 4); + uint first = allocator.Allocate(); + allocator.Allocate(); + + allocator.Release(first); + Assert.Equal(1, allocator.LiveCount); + + uint reused = allocator.Allocate(); + Assert.Equal(first, reused); + Assert.Equal(2, allocator.LiveCount); + } + + [Fact] + public void ReleasingAnUnallocatedSlotThrows() + { + var allocator = new GlTextureSlotAllocator(capacity: 4); + allocator.Allocate(); + Assert.Throws(() => allocator.Release(3)); + } + + [Fact] + public void FreeingThenExhaustingTheBumpRangeStillThrowsPastCapacity() + { + var allocator = new GlTextureSlotAllocator(capacity: 2); + uint first = allocator.Allocate(); + allocator.Allocate(); + allocator.Release(first); + allocator.Allocate(); + + Assert.Throws(() => allocator.Allocate()); + } +}