feat(render): Campaign V slice V1 - OpenGL RHI backend (dark)
Implements GlGpuDevice and the rest of AcDream.App.Rendering.Gpu.Gl,
filling the V0-pinned IGpuDevice contract on OpenGL 4.3. This is the
first of the port slices described in
docs/plans/2026-07-27-vulkan-campaign.md: every later renderer port
(V2 onward) needs a real, driver-proven GL implementation of the RHI
to port onto, and the GL backend is deliberately built to be
behaviour-preserving rather than optimal, because that is what turns
each subsequent slice's pixel gate into a strict identity check
instead of a moving target. The Vulkan backend (V5+) is where the
actual efficiency gains land.
GlGpuDevice is a fresh root, not derived from Chorizite's
BaseGraphicsDevice/OpenGLGraphicsDevice - shedding that inheritance is
one of the things this campaign explicitly does. It owns its own
BindlessSupport instance rather than sharing the legacy WB render
path's, which is what lets it be constructed the moment a GL context
and a GpuFrameFlightController exist, with no dependency on when
WorldRenderCompositionPhase happens to detect bindless support later
in startup. The ring buffer keeps a managed staging array plus a real
GL buffer per flight slot and flushes with one BufferSubData
immediately before each Draw/DrawIndexed/MultiDrawIndexedIndirect
(never at bind time, since a renderer may still write after binding);
V1 throws on an over-capacity ring request rather than growing it,
since nothing consumes the device yet and a silent grow would hide a
future renderer's real working set. The texture table is a bump/free-
list allocator over a managed uvec2 handle array, gated through the
frame-flight retirement queue so a released slot cannot be reused
while a submitted frame might still read it. Push constants are
applied by uniform name on the currently-bound program, cached per
program, and explicitly re-applied whenever BindPipeline switches
programs - GL uniforms are per-program state, so the "survives
pipeline changes within a pass" guarantee the interface documents (a
freebie on Vulkan's shared pipeline layout) has to be emulated here.
BindlessSupport gained one additive method,
GetResidentHandle(texture, sampler), calling the same
ArbBindlessTexture.GetTextureSamplerHandle entry point
ManagedGLTextureArray already uses through a different path. The
existing GetResidentHandle(texture) cannot express
IGpuDevice.RegisterTexture's documented pair semantics ("the same
texture registered with two samplers occupies two slots"), so this
was the minimal change needed rather than a workaround.
The pure bookkeeping - ring watermark/alignment arithmetic, the
texture-slot allocator, render-state diffing, the push-constant field-
to-uniform-name table, and GL format mapping - lives in small GL-free
classes so it is unit-testable without a live context, following the
same seam pattern GpuFrameFlightController already uses for its fence
API. GlGpuTimerPool follows suit with an injectable timer-query API.
The device is constructed in HostInputCameraCompositionPhase
immediately after the frame-flight controller (the same phase that
already builds GpuFrameFlightController), rather than in
WorldRenderCompositionPhase as first considered: GlGpuDevice's self-
contained bindless detection means it has no ordering dependency on
the legacy WB path's BindlessSupport, so it can be proven against the
real driver as early as possible while keeping the composition change
to one phase. Composition, publication, and shutdown wiring follow
the existing acquire/publish/fault-injection pattern exactly, and GPU
device disposal is scheduled through the frame-flight retirement queue
before that queue itself is torn down. Nothing consumes the device
yet - that starts at V4a - so this slice's pixel gate is trivially a
tripwire.
App tests: 3834 passed / 3 skipped (V0 baseline 3785 + 49 new: ring,
texture-slot, render-state, push-constant, format-mapping, enum-
mapping, and timer-pool tests, plus one new fault-injection point in
the existing composition theory).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
90f7c6f2f4
commit
4f94ad7ddd
30 changed files with 2843 additions and 2 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PortalTunnelPresentation> 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",
|
||||
[
|
||||
|
|
|
|||
107
src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs
Normal file
107
src/AcDream.App/Rendering/Gpu/Gl/GlEnumMapping.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>One vertex attribute's GL shape: component count, element type, and whether integer values normalize to [0,1]/[-1,1].</summary>
|
||||
internal readonly record struct GlVertexAttributeShape(int ComponentCount, VertexAttribPointerType Type, bool Normalized);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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}."),
|
||||
};
|
||||
}
|
||||
138
src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs
Normal file
138
src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// A plain GL buffer object. Deliberately not one of the existing
|
||||
/// <c>ManagedGL*</c> wrappers: those implement Chorizite's <c>IVertexBuffer</c>/
|
||||
/// <c>IIndexBuffer</c> (generic-over-<c>IVertex</c>, single-usage) and are
|
||||
/// constructed against <see cref="OpenGLGraphicsDevice"/>, which the campaign
|
||||
/// explicitly sheds — <see cref="GlGpuDevice"/> is a fresh root. One GL buffer
|
||||
/// object already serves every <see cref="GpuBufferUsage"/> combination (the
|
||||
/// usage only matters at bind time), so a single small class covers the whole
|
||||
/// <see cref="IGpuBuffer"/> surface without forking per usage.
|
||||
///
|
||||
/// Allocated once at the description's size via <c>glBufferData</c> with null
|
||||
/// data; every write after that is <c>glBufferSubData</c> — no persistent
|
||||
/// mapping, matching the campaign's "GL backend is deliberately behaviour-
|
||||
/// preserving" rule for this slice.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>The physical GL buffer name. For bind calls issued by <see cref="GlGpuPassEncoder"/>.</summary>
|
||||
internal uint GlName => _name;
|
||||
|
||||
public void Upload(long offsetBytes, ReadOnlySpan<byte> 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<byte> 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);
|
||||
}
|
||||
}
|
||||
517
src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
Normal file
517
src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// OpenGL 4.3 implementation of <see cref="IGpuDevice"/> — Campaign V slice V1.
|
||||
/// See <c>docs/plans/2026-07-27-vulkan-campaign.md</c> §3 for the pinned
|
||||
/// contract this backend fills and §5 for what this slice covers.
|
||||
///
|
||||
/// Deliberately NOT derived from Chorizite's <c>BaseGraphicsDevice</c>/
|
||||
/// <see cref="OpenGLGraphicsDevice"/> — this is a fresh root, which is one of
|
||||
/// the things Campaign V sheds. It owns its own <see cref="BindlessSupport"/>
|
||||
/// instance (created in the constructor) rather than sharing the legacy WB
|
||||
/// render path's; both simply wrap the same stateless
|
||||
/// <c>GL_ARB_bindless_texture</c> extension, so two instances coexist safely,
|
||||
/// and it lets this device be constructed the moment a GL context and a
|
||||
/// <see cref="GpuFrameFlightController"/> exist — no dependency on when the
|
||||
/// legacy path happens to detect bindless support during composition.
|
||||
///
|
||||
/// <para><b>Ring capacity.</b> Each flight slot gets one managed staging
|
||||
/// <c>byte[]</c> and one same-sized GL buffer, both fixed at construction
|
||||
/// (default 16 MiB — see <see cref="DefaultRingCapacityBytesPerSlot"/>). 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. <see cref="GlRingBufferState"/> is the pure
|
||||
/// piece that enforces this.</para>
|
||||
///
|
||||
/// <para><b>Flush discipline.</b> Ring bytes and the texture-handle table are
|
||||
/// both flushed with one <c>glBufferSubData</c> each, immediately before
|
||||
/// every <c>Draw</c>/<c>DrawIndexed</c>/<c>MultiDrawIndexedIndirect</c> — see
|
||||
/// <see cref="FlushBeforeDraw"/> — never at bind time, so a renderer that
|
||||
/// writes after binding still uploads correctly.</para>
|
||||
/// </summary>
|
||||
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<GpuSamplerDescription, GlGpuSampler> _samplers = [];
|
||||
private readonly List<Action> _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<byte> data = byteCount == 0
|
||||
? Span<byte>.Empty
|
||||
: _ringStaging[slotIndex].AsSpan((int)offset, byteCount);
|
||||
return new GpuRingAllocation(_ringBuffers[slotIndex], offset, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads this slot's dirty ring bytes and the texture table's dirty
|
||||
/// range (if any) with one <c>glBufferSubData</c> 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.
|
||||
/// </summary>
|
||||
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<byte> 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);
|
||||
}
|
||||
58
src/AcDream.App/Rendering/Gpu/Gl/GlGpuFrame.cs
Normal file
58
src/AcDream.App/Rendering/Gpu/Gl/GlGpuFrame.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// One frame's recording context on the GL backend. Ring allocations are
|
||||
/// forwarded to the device's per-slot <see cref="GlRingBufferState"/>;
|
||||
/// passes are single-level (no nesting, matching every acdream renderer
|
||||
/// today) and enforced here.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
235
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs
Normal file
235
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Records one pass's draw work. Binding calls translate almost mechanically
|
||||
/// to GL (a storage/uniform binding is <c>glBindBufferRange</c>, an indexed
|
||||
/// draw is <c>glDrawElementsInstancedBaseVertexBaseInstance</c>, and so on);
|
||||
/// the two pieces of real logic are the render-state diff applied on
|
||||
/// <see cref="BindPipeline"/> / the dynamic setters, and the "flush dirty
|
||||
/// ring + texture-table bytes immediately before every draw" discipline
|
||||
/// described on <see cref="GlGpuDevice"/>.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
85
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs
Normal file
85
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPipeline.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles <see cref="GpuPipelineDescription.Shaders"/> through the existing
|
||||
/// <see cref="ShaderProgramConstruction"/> (the same compiler every other GL
|
||||
/// shader in the codebase uses — this is not a second compiler) and owns one
|
||||
/// VAO shaped by <see cref="GpuPipelineDescription.VertexLayout"/>.
|
||||
///
|
||||
/// 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 <see cref="GlGpuPassEncoder.BindVertexBuffer"/>
|
||||
/// 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 <c>glVertexAttribPointer</c>.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
81
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPushConstantBinder.cs
Normal file
81
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPushConstantBinder.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a <see cref="GpuPushConstants"/> value to the currently bound GL
|
||||
/// program by uniform name (<see cref="GlPushConstantUniformNames"/>),
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class GlGpuPushConstantBinder
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly Dictionary<uint, ProgramLocations> _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}");
|
||||
}
|
||||
|
||||
/// <summary>Drops cached locations for a deleted program. Call before its GL name is reused.</summary>
|
||||
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);
|
||||
}
|
||||
121
src/AcDream.App/Rendering/Gpu/Gl/GlGpuRenderTarget.cs
Normal file
121
src/AcDream.App/Rendering/Gpu/Gl/GlGpuRenderTarget.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// An offscreen colour(+depth) FBO. The colour attachment is a
|
||||
/// <see cref="GlGpuTexture"/> so it can be registered into the texture table
|
||||
/// after the pass; the depth/stencil attachment (when requested) is a plain
|
||||
/// renderbuffer, mirroring <c>ManagedGLFramebuffer</c>'s approach — nothing
|
||||
/// ever samples depth for the targets this slice's contract describes
|
||||
/// (paperdoll, creature appraisal, portal masking).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
61
src/AcDream.App/Rendering/Gpu/Gl/GlGpuSampler.cs
Normal file
61
src/AcDream.App/Rendering/Gpu/Gl/GlGpuSampler.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// One GL sampler object. <see cref="GlGpuDevice.CreateSampler"/>
|
||||
/// de-duplicates by <see cref="GpuSamplerDescription"/> value, so a given
|
||||
/// wrap/filter combination is only ever backed by one physical sampler name —
|
||||
/// the same pattern <c>SamplerCache</c> already uses for its two fixed
|
||||
/// samplers, generalized to the full description space the RHI exposes.
|
||||
/// </summary>
|
||||
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}");
|
||||
});
|
||||
}
|
||||
}
|
||||
202
src/AcDream.App/Rendering/Gpu/Gl/GlGpuTexture.cs
Normal file
202
src/AcDream.App/Rendering/Gpu/Gl/GlGpuTexture.cs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// A GL texture (2D or 2D array) allocated with immutable storage
|
||||
/// (<c>glTexStorage2D/3D</c>) so every mip level is defined the moment the
|
||||
/// object is created, regardless of upload order — mirroring
|
||||
/// <c>ManagedGLTextureArray</c>'s allocation strategy without inheriting its
|
||||
/// Chorizite/<see cref="OpenGLGraphicsDevice"/> 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 <see cref="IGpuSampler"/> a draw later
|
||||
/// binds. The bound sampler object overrides actual filtering at draw time
|
||||
/// (same override rule <c>SamplerCache</c> already documents), so this
|
||||
/// default never affects the rendered image.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>The physical GL texture name. Used by <see cref="GlGpuDevice.RegisterTexture"/> to obtain a bindless handle.</summary>
|
||||
internal uint GlName => _name;
|
||||
|
||||
internal GLEnum Target => _target;
|
||||
|
||||
public void Upload(int mipLevel, int layer, ReadOnlySpan<byte> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// GL format triple for a <see cref="GpuTextureFormat"/>: the sized internal
|
||||
/// format used at allocation (<c>glTexStorage2D/3D</c>), the upload
|
||||
/// format/type for uncompressed uploads, and whether uploads go through
|
||||
/// <c>glCompressedTexSubImage2D/3D</c> instead.
|
||||
///
|
||||
/// <see cref="GpuTextureFormat"/> is acdream's own enum, not the Chorizite
|
||||
/// <c>TextureFormat</c> the existing <c>TextureFormatExtensions</c> 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.
|
||||
/// </summary>
|
||||
internal readonly record struct GlTextureFormatInfo(
|
||||
SizedInternalFormat SizedInternalFormat,
|
||||
PixelFormat UploadPixelFormat,
|
||||
PixelType UploadPixelType,
|
||||
bool IsCompressed,
|
||||
int BlockOrTexelBytes,
|
||||
int BlockDimension)
|
||||
{
|
||||
/// <summary>Bytes required for one full, uncompressed mip layer at the given dimensions, or one compressed layer rounded up to whole blocks.</summary>
|
||||
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}."),
|
||||
};
|
||||
}
|
||||
186
src/AcDream.App/Rendering/Gpu/Gl/GlGpuTimerPool.cs
Normal file
186
src/AcDream.App/Rendering/Gpu/Gl/GlGpuTimerPool.cs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Seam over the four GL calls a <c>TimeElapsed</c> timer scope needs, mirroring
|
||||
/// <c>IGpuFenceApi</c>'s role for <see cref="GpuFrameFlightController"/>: it lets
|
||||
/// <see cref="GlGpuTimerPool"/>'s double-buffering and result-promotion logic run
|
||||
/// under a unit test with no live GL context.
|
||||
/// </summary>
|
||||
internal interface IGlTimerQueryApi
|
||||
{
|
||||
uint CreateQuery();
|
||||
void DeleteQuery(uint query);
|
||||
void Begin(uint query);
|
||||
void End();
|
||||
|
||||
/// <summary>Non-blocking: false when the result for <paramref name="query"/> is not yet available (or the query was never begun).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IGpuTimerPool"/> backed by <c>TimeElapsed</c> queries. Core GL
|
||||
/// only allows one <c>GL_TIME_ELAPSED</c> query active at a time (the
|
||||
/// restriction is per target, not per query object), so scopes must not
|
||||
/// nest — <see cref="BeginScope"/> throws if a previous scope in the same
|
||||
/// pass hasn't been disposed yet, exactly as <c>WbDrawDispatcher</c> 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.
|
||||
/// </summary>
|
||||
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<string, ScopeState> _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; }
|
||||
|
||||
/// <summary>
|
||||
/// Begins (or continues) the named scope. Returns a disposable that ends
|
||||
/// the query; dispose it before beginning another scope in the same pass.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
using System.Reflection;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth mapping each <see cref="GpuPushConstants"/>
|
||||
/// field to the GLSL uniform name the GL backend binds it to — the names
|
||||
/// documented on the fields themselves. <see cref="GlGpuPushConstantBinder"/>
|
||||
/// reads these constants (not string literals of its own) when it calls
|
||||
/// <c>GL.GetUniformLocation</c>, and <see cref="AssertMapsEveryField"/> lets a
|
||||
/// test prove the table cannot silently drop a field that a later slice adds
|
||||
/// to the shared struct.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Field name (as declared on <see cref="GpuPushConstants"/>) to GLSL
|
||||
/// uniform name, built from the same constants the binder applies with —
|
||||
/// so the binder and this completeness table can never disagree.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> ByFieldName { get; } =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Throws if <see cref="GpuPushConstants"/> 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.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs
Normal file
76
src/AcDream.App/Rendering/Gpu/Gl/GlRenderStateCache.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Everything <see cref="GlGpuPipeline"/> bakes plus the handful of fields
|
||||
/// core Vulkan 1.3 (and this backend) makes dynamic per draw.
|
||||
/// <see cref="Program"/> is the GL program name, included so a pipeline
|
||||
/// switch is itself a tracked dimension.
|
||||
/// </summary>
|
||||
internal readonly record struct GlRenderStateSnapshot(
|
||||
uint Program,
|
||||
GpuBlendMode Blend,
|
||||
bool DepthTest,
|
||||
bool DepthWrite,
|
||||
GpuCompareOp DepthCompare,
|
||||
GpuCullMode Cull,
|
||||
GpuFrontFace FrontFace,
|
||||
bool AlphaToCoverage,
|
||||
bool ColorWrite);
|
||||
|
||||
/// <summary>Which GL state calls are needed to move from the previous snapshot to the new one.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Every dimension reported changed — used for the first apply after a reset.</summary>
|
||||
internal static GlRenderStateChanges All { get; } = new(true, true, true, true, true, true, true, true, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure GL-free state cache: given the previously applied
|
||||
/// <see cref="GlRenderStateSnapshot"/> and a newly desired one, reports which
|
||||
/// dimensions actually changed so <see cref="GlGpuPassEncoder"/> only issues
|
||||
/// the GL calls that matter. Starts with no baseline, so the very first
|
||||
/// <see cref="Apply"/> 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Discards the cached baseline — the next <see cref="Apply"/> reports every dimension changed.</summary>
|
||||
public void Reset() => _last = null;
|
||||
}
|
||||
99
src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs
Normal file
99
src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// 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. <see cref="GlGpuFrame"/> owns one instance per flight slot and
|
||||
/// pairs it with a managed staging <c>byte[]</c> and a real GL buffer; the
|
||||
/// staging array receives every <see cref="GpuRingAllocation.Data"/> write,
|
||||
/// and immediately before each <c>Draw</c>/<c>DrawIndexed</c>/
|
||||
/// <c>MultiDrawIndexedIndirect</c> the device uploads exactly the dirty range
|
||||
/// via one <c>glBufferSubData</c> call and calls <see cref="TakeDirtyRange"/>
|
||||
/// to reset the watermark. The allocation cursor itself only resets at
|
||||
/// <see cref="Reset"/> (once per <c>BeginFrame</c>) — 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Bytes handed out since the last <see cref="Reset"/>.</summary>
|
||||
public uint AllocatedBytes => _cursor;
|
||||
|
||||
/// <summary>
|
||||
/// Reserves <paramref name="byteCount"/> bytes aligned to
|
||||
/// <paramref name="alignmentBytes"/>, 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Starts a new frame: rewinds the allocation cursor. Dirty state is untouched — a flush always runs before this is called.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
_cursor = 0;
|
||||
_dirtyStart = -1;
|
||||
_dirtyEnd = -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the byte range written since the last flush (start, length),
|
||||
/// or (0, 0) when nothing is dirty, and clears the watermark.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
62
src/AcDream.App/Rendering/Gpu/Gl/GlTextureSlotAllocator.cs
Normal file
62
src/AcDream.App/Rendering/Gpu/Gl/GlTextureSlotAllocator.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Pure bump-plus-free-list allocator for the GL texture table (the storage
|
||||
/// buffer of bindless handles at <see cref="GpuBindingModel.StorageTextureTable"/>).
|
||||
///
|
||||
/// GL-free by design: it knows nothing about bindless handles, retirement
|
||||
/// queues, or the SSBO itself. <see cref="GlGpuDevice.RegisterTexture"/> calls
|
||||
/// <see cref="Allocate"/> to get a slot to write a handle into;
|
||||
/// <see cref="GlGpuDevice.ReleaseTextureSlot"/> defers the call to
|
||||
/// <see cref="Release"/> through the device's <c>IGpuResourceRetirementQueue</c>
|
||||
/// so a freed slot is never handed back out while a submitted frame could
|
||||
/// still be reading the old handle at that index.
|
||||
/// </summary>
|
||||
internal sealed class GlTextureSlotAllocator
|
||||
{
|
||||
private readonly uint _capacity;
|
||||
private readonly Stack<uint> _freeList = new();
|
||||
private uint _nextBumpSlot;
|
||||
|
||||
public GlTextureSlotAllocator(uint capacity)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfZero(capacity);
|
||||
_capacity = capacity;
|
||||
}
|
||||
|
||||
/// <summary>Slots currently handed out (bumped or reused) and not yet released.</summary>
|
||||
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++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,46 @@ public sealed class BindlessSupport
|
|||
return h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>GlGpuDevice.RegisterTexture</c>,
|
||||
/// which registers a (texture, sampler) pair per the RHI contract — "the
|
||||
/// same texture registered with two samplers occupies two slots." The
|
||||
/// texture-only <see cref="GetResidentHandle(uint)"/> above cannot express
|
||||
/// that; <c>ManagedGLTextureArray</c> already calls the equivalent
|
||||
/// <c>ArbBindlessTexture.GetTextureSamplerHandle</c> directly through
|
||||
/// <c>OpenGLGraphicsDevice.BindlessExtension</c>, so this simply exposes
|
||||
/// the same GL entry point through this class for the RHI's use.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Release residency for a handle. Call before deleting the underlying texture.</summary>
|
||||
public void MakeNonResident(ulong handle)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<HostInputCameraCompositionPoint>(),
|
||||
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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<NotSupportedException>(() => GlEnumMapping.BlendFactorsOf(GpuBlendMode.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CullNoneHasNoGlCullFaceMode()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GpuTextureFormat>())
|
||||
GlGpuTextureFormatMapping.Resolve(format);
|
||||
}
|
||||
}
|
||||
123
tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs
Normal file
123
tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs
Normal file
|
|
@ -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<InvalidOperationException>(() => 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(() => state.Allocate(128, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignmentPushingPastCapacityAlsoThrows()
|
||||
{
|
||||
var state = new GlRingBufferState(64);
|
||||
state.Allocate(60, alignmentBytes: 4);
|
||||
Assert.Throws<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(() => 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<ArgumentOutOfRangeException>(() => 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<InvalidOperationException>(() => allocator.Allocate());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue