feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend

Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 02:19:53 +02:00
parent b70b9832ff
commit 8a7a0837e1
121 changed files with 1243 additions and 19840 deletions

View file

@ -1,313 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// The narrow slice of GL that <see cref="GlAmbientCapabilityState"/> reads and
/// writes. It exists so the save/restore transaction can be exercised without a
/// GL context: the property that matters is "every value this pass can change
/// is put back, including when a draw throws", and that is a property of the
/// bookkeeping, not of the driver.
///
/// Slice V6d generalized this from <c>ITextRenderGlStateApi</c>, which
/// <c>TextRenderer</c> owned privately and which restored a strict subset of the
/// same values. That renderer no longer touches GL at all, and the guarantee now
/// lives in one place for every RHI pass.
/// </summary>
internal interface IGlAmbientStateApi
{
bool IsEnabled(EnableCap capability);
int GetInteger(GetPName parameter);
bool GetBoolean(GetPName parameter);
/// <summary>The four colour-mask channels, in RGBA order.</summary>
bool[] GetColorMask();
void SetCapability(EnableCap capability, bool enabled);
void DepthMask(bool enabled);
void DepthFunc(DepthFunction function);
void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha);
void CullFace(TriangleFace face);
void FrontFace(FrontFaceDirection direction);
void StencilFunc(StencilFunction function, int reference, uint mask);
void StencilOp(StencilOp fail, StencilOp depthFail, StencilOp pass);
void StencilMask(uint mask);
void ColorMask(bool red, bool green, bool blue, bool alpha);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
void ActiveTexture(TextureUnit unit);
void BindTexture(TextureTarget target, uint texture);
}
internal sealed class SilkGlAmbientStateApi : IGlAmbientStateApi
{
private readonly GL _gl;
public SilkGlAmbientStateApi(GL gl) => _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability);
public int GetInteger(GetPName parameter)
{
_gl.GetInteger(parameter, out int value);
return value;
}
public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter);
public unsafe bool[] GetColorMask()
{
var values = new bool[4];
fixed (bool* first = values)
_gl.GetBoolean(GetPName.ColorWritemask, first);
return values;
}
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void DepthFunc(DepthFunction function) => _gl.DepthFunc(function);
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha) =>
_gl.BlendFuncSeparate(sourceRgb, destinationRgb, sourceAlpha, destinationAlpha);
public void CullFace(TriangleFace face) => _gl.CullFace(face);
public void FrontFace(FrontFaceDirection direction) => _gl.FrontFace(direction);
public void StencilFunc(StencilFunction function, int reference, uint mask) =>
_gl.StencilFunc(function, reference, mask);
public void StencilOp(StencilOp fail, StencilOp depthFail, StencilOp pass) =>
_gl.StencilOp(fail, depthFail, pass);
public void StencilMask(uint mask) => _gl.StencilMask(mask);
public void ColorMask(bool red, bool green, bool blue, bool alpha) =>
_gl.ColorMask(red, green, blue, alpha);
public void UseProgram(uint program) => _gl.UseProgram(program);
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
public void BindBuffer(BufferTargetARB target, uint buffer) => _gl.BindBuffer(target, buffer);
public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit);
public void BindTexture(TextureTarget target, uint texture) => _gl.BindTexture(target, texture);
}
/// <summary>
/// Every ambient GL capability/binding a <see cref="GlGpuPipeline"/> bind (or a
/// dynamic setter, or the pass's own sample count) can change, captured by raw
/// query and restored by raw call.
///
/// Transitional for as long as raw-GL renderers coexist with RHI-ported ones:
/// each raw-GL renderer assumes whatever state the previous one left behind is
/// still there, so a pass that changes state and does not put it back is
/// invisible until the world silhouette changes. That is precisely how the first
/// V4a attempt lost multisampling (plan §7.1 rule 1). Deleted at V4h.
/// </summary>
internal readonly struct GlAmbientCapabilityState
{
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
private readonly bool _depthTest;
private readonly bool _depthWrite;
private readonly int _depthFunc;
private readonly bool _blend;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly bool _cullFace;
private readonly int _cullFaceMode;
private readonly int _frontFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
// Slice V6l: the stencil dimension. #117's portal punch is the only pipeline
// that enables it, and it draws in the middle of a frame whose other
// renderers are still raw GL and assume the test is off.
private readonly bool _stencilTest;
private readonly int _stencilFunc;
private readonly int _stencilReference;
private readonly int _stencilValueMask;
private readonly int _stencilWriteMask;
private readonly int _stencilFail;
private readonly int _stencilDepthFail;
private readonly int _stencilPass;
// Slice V6l: GpuPipelineDescription.ColorWrite has had no consumer until the
// portal depth mask, which is colour-invisible by construction. A pass that
// left the mask off would black out every raw-GL renderer that followed it,
// which is §7.1 rule 1's exact failure mode wearing a different name.
private readonly bool _colorMaskRed;
private readonly bool _colorMaskGreen;
private readonly bool _colorMaskBlue;
private readonly bool _colorMaskAlpha;
private GlAmbientCapabilityState(
int program, int vertexArray, int arrayBuffer, int activeTexture, int texture0Binding2D,
bool depthTest, bool depthWrite, int depthFunc,
bool blend, int blendSourceRgb, int blendDestinationRgb, int blendSourceAlpha, int blendDestinationAlpha,
bool cullFace, int cullFaceMode, int frontFace,
bool alphaToCoverage, bool multisample,
bool stencilTest, int stencilFunc, int stencilReference, int stencilValueMask,
int stencilWriteMask, int stencilFail, int stencilDepthFail, int stencilPass,
bool colorMaskRed, bool colorMaskGreen, bool colorMaskBlue, bool colorMaskAlpha)
{
_program = program;
_vertexArray = vertexArray;
_arrayBuffer = arrayBuffer;
_activeTexture = activeTexture;
_texture0Binding2D = texture0Binding2D;
_depthTest = depthTest;
_depthWrite = depthWrite;
_depthFunc = depthFunc;
_blend = blend;
_blendSourceRgb = blendSourceRgb;
_blendDestinationRgb = blendDestinationRgb;
_blendSourceAlpha = blendSourceAlpha;
_blendDestinationAlpha = blendDestinationAlpha;
_cullFace = cullFace;
_cullFaceMode = cullFaceMode;
_frontFace = frontFace;
_alphaToCoverage = alphaToCoverage;
_multisample = multisample;
_stencilTest = stencilTest;
_stencilFunc = stencilFunc;
_stencilReference = stencilReference;
_stencilValueMask = stencilValueMask;
_stencilWriteMask = stencilWriteMask;
_stencilFail = stencilFail;
_stencilDepthFail = stencilDepthFail;
_stencilPass = stencilPass;
_colorMaskRed = colorMaskRed;
_colorMaskGreen = colorMaskGreen;
_colorMaskBlue = colorMaskBlue;
_colorMaskAlpha = colorMaskAlpha;
}
internal static GlAmbientCapabilityState Capture(IGlAmbientStateApi gl)
{
ArgumentNullException.ThrowIfNull(gl);
int program = gl.GetInteger(GetPName.CurrentProgram);
int vertexArray = gl.GetInteger(GetPName.VertexArrayBinding);
int arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding);
int activeTexture = gl.GetInteger(GetPName.ActiveTexture);
int texture0Binding2D;
try
{
gl.ActiveTexture(TextureUnit.Texture0);
texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D);
}
finally
{
gl.ActiveTexture((TextureUnit)activeTexture);
}
bool[] colorMask = gl.GetColorMask();
return new GlAmbientCapabilityState(
program, vertexArray, arrayBuffer, activeTexture, texture0Binding2D,
gl.IsEnabled(EnableCap.DepthTest),
gl.GetBoolean(GetPName.DepthWritemask),
gl.GetInteger(GetPName.DepthFunc),
gl.IsEnabled(EnableCap.Blend),
gl.GetInteger(GetPName.BlendSrcRgb),
gl.GetInteger(GetPName.BlendDstRgb),
gl.GetInteger(GetPName.BlendSrcAlpha),
gl.GetInteger(GetPName.BlendDstAlpha),
gl.IsEnabled(EnableCap.CullFace),
gl.GetInteger(GetPName.CullFaceMode),
gl.GetInteger(GetPName.FrontFace),
gl.IsEnabled(EnableCap.SampleAlphaToCoverage),
gl.IsEnabled(EnableCap.Multisample),
gl.IsEnabled(EnableCap.StencilTest),
gl.GetInteger(GetPName.StencilFunc),
gl.GetInteger(GetPName.StencilRef),
gl.GetInteger(GetPName.StencilValueMask),
gl.GetInteger(GetPName.StencilWritemask),
gl.GetInteger(GetPName.StencilFail),
gl.GetInteger(GetPName.StencilPassDepthFail),
gl.GetInteger(GetPName.StencilPassDepthPass),
colorMask[0], colorMask[1], colorMask[2], colorMask[3]);
}
internal void Restore(IGlAmbientStateApi gl)
{
ArgumentNullException.ThrowIfNull(gl);
gl.UseProgram((uint)_program);
gl.BindVertexArray((uint)_vertexArray);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer);
gl.ActiveTexture(TextureUnit.Texture0);
gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D);
gl.ActiveTexture((TextureUnit)_activeTexture);
gl.SetCapability(EnableCap.DepthTest, _depthTest);
gl.DepthMask(_depthWrite);
gl.DepthFunc((DepthFunction)_depthFunc);
gl.SetCapability(EnableCap.Blend, _blend);
gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
gl.SetCapability(EnableCap.CullFace, _cullFace);
gl.CullFace((TriangleFace)_cullFaceMode);
gl.FrontFace((FrontFaceDirection)_frontFace);
gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
gl.SetCapability(EnableCap.Multisample, _multisample);
gl.SetCapability(EnableCap.StencilTest, _stencilTest);
gl.StencilFunc(
(StencilFunction)_stencilFunc,
_stencilReference,
(uint)_stencilValueMask);
gl.StencilOp(
(StencilOp)_stencilFail,
(StencilOp)_stencilDepthFail,
(StencilOp)_stencilPass);
gl.StencilMask((uint)_stencilWriteMask);
gl.ColorMask(_colorMaskRed, _colorMaskGreen, _colorMaskBlue, _colorMaskAlpha);
}
}

View file

@ -1,108 +0,0 @@
namespace AcDream.App.Rendering.Gpu.Gl;
/// <summary>
/// Tracks which texture-table slots changed since the last flush and gives them
/// back as maximal runs of <i>consecutive</i> dirty slots.
///
/// <para><b>Why runs, and not one merged range.</b> The ring can flush a single
/// span covering everything written since the last draw, because every byte in
/// that span was allocated this frame and nothing in flight reads it. The
/// texture table cannot: it is a long-lived array of bindless handles, and two
/// registrations in one frame can land on slots 5 and 50 with forty-four live
/// slots in between. Those live slots are read by draws already submitted this
/// frame, so a single mapped write over [5, 51) — mapped with
/// <c>GL_MAP_INVALIDATE_RANGE_BIT</c>, which lets the driver discard the whole
/// range's contents while it is mapped, and <c>GL_MAP_UNSYNCHRONIZED_BIT</c>,
/// which stops it from waiting first — would expose those draws to torn
/// handles. Splitting on the gaps keeps every mapped range made only of slots
/// that no submitted draw can be reading.</para>
///
/// <para><b>Why a dirty slot is safe to write unsynchronized.</b> Two producers
/// touch a slot: <c>GlGpuDevice.RegisterTexture</c>, which writes a slot fresh
/// from <see cref="GlTextureSlotAllocator"/> and therefore one no batch has ever
/// indexed; and <c>ReleaseTextureSlot</c>'s zeroing write, which runs inside a
/// retirement callback, i.e. after the fence covering every frame that could
/// still have referenced it. Both are already past the point where the GPU can
/// read the old value — which is exactly the assertion
/// <c>GL_MAP_UNSYNCHRONIZED_BIT</c> makes.</para>
///
/// <para>GL-free by design, like <see cref="GlRingBufferState"/> and
/// <see cref="GlTextureSlotAllocator"/>, so the bookkeeping is unit tested
/// without a context. Enumeration is allocation-free: the tracker keeps the
/// min/max window that has been dirtied so a clean table costs one comparison,
/// and a dirty one scans only that window.</para>
/// </summary>
internal sealed class GlDirtySlotRuns
{
private readonly bool[] _dirty;
private int _windowStart = -1;
private int _windowEnd = -1;
public GlDirtySlotRuns(uint capacity)
{
ArgumentOutOfRangeException.ThrowIfZero(capacity);
_dirty = new bool[capacity];
}
public uint Capacity => (uint)_dirty.Length;
public bool HasDirtySlots => _windowStart >= 0;
public void Mark(uint slot)
{
if (slot >= (uint)_dirty.Length)
{
throw new ArgumentOutOfRangeException(
nameof(slot),
slot,
$"The tracked table holds {_dirty.Length} slots.");
}
_dirty[slot] = true;
_windowStart = _windowStart < 0 ? (int)slot : Math.Min(_windowStart, (int)slot);
_windowEnd = Math.Max(_windowEnd, (int)slot + 1);
}
/// <summary>
/// Takes the lowest remaining run of consecutive dirty slots, clearing it,
/// and reports its first slot and length. Returns <c>false</c> once none
/// remain, so a caller drains with a <c>while</c> loop.
/// </summary>
public bool TryTakeNextRun(out uint firstSlot, out uint slotCount)
{
firstSlot = 0;
slotCount = 0;
if (_windowStart < 0)
return false;
int index = _windowStart;
while (index < _windowEnd && !_dirty[index])
index++;
if (index >= _windowEnd)
{
CloseWindow();
return false;
}
int start = index;
while (index < _windowEnd && _dirty[index])
{
_dirty[index] = false;
index++;
}
firstSlot = (uint)start;
slotCount = (uint)(index - start);
if (index >= _windowEnd)
CloseWindow();
else
_windowStart = index;
return true;
}
private void CloseWindow()
{
_windowStart = -1;
_windowEnd = -1;
}
}

View file

@ -1,145 +0,0 @@
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>
/// <summary>
/// How one vertex attribute reaches GL. <paramref name="Integer"/> selects
/// <c>glVertexAttribIPointer</c> over <c>glVertexAttribPointer</c>: GL requires
/// the integer entry point for an integer shader input (<c>uvec4</c> and friends)
/// and leaves the value undefined otherwise.
/// </summary>
internal readonly record struct GlVertexAttributeShape(
int ComponentCount,
VertexAttribPointerType Type,
bool Normalized,
bool Integer = false);
/// <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),
// Integer attributes carry Integer = true; the encoder must route them
// through glVertexAttribIPointer, not the normalized float path.
GpuVertexFormat.UByte4UInt => new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, false, Integer: true),
// Slice V6l: particle.vert's per-instance `in uint aTextureIndex`.
GpuVertexFormat.UInt1 => new GlVertexAttributeShape(1, VertexAttribPointerType.UnsignedInt, false, Integer: true),
_ => throw new NotSupportedException($"No GL vertex attribute shape for {format}."),
};
/// <summary>Slice V6l: the stencil comparison, which shares GL's depth-function enum values.</summary>
public static StencilFunction StencilFunctionOf(GpuCompareOp compare) => compare switch
{
GpuCompareOp.Never => StencilFunction.Never,
GpuCompareOp.Less => StencilFunction.Less,
GpuCompareOp.LessOrEqual => StencilFunction.Lequal,
GpuCompareOp.Equal => StencilFunction.Equal,
GpuCompareOp.Greater => StencilFunction.Greater,
GpuCompareOp.GreaterOrEqual => StencilFunction.Gequal,
GpuCompareOp.Always => StencilFunction.Always,
_ => throw new NotSupportedException($"No GL stencil function for {compare}."),
};
/// <summary>Slice V6l: what a stencil outcome does to the stored value.</summary>
public static StencilOp StencilOpOf(GpuStencilOp op) => op switch
{
GpuStencilOp.Keep => StencilOp.Keep,
GpuStencilOp.Zero => StencilOp.Zero,
GpuStencilOp.Replace => StencilOp.Replace,
_ => throw new NotSupportedException($"No GL stencil operation for {op}."),
};
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.InverseAlpha => (BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha),
// 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}."),
};
}

View file

@ -1,287 +0,0 @@
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. There are two write paths after that, and which one applies is a
/// property of the buffer's role rather than of the buffer object:
/// <see cref="Upload"/> is an ordinary synchronized <c>glBufferSubData</c>, used
/// for one-off and streaming writes the driver must order for us (the mesh
/// arena, texture staging); <see cref="WriteRangeUnsynchronized"/> maps the
/// range and is used only by the per-frame upload ring, whose non-overlap
/// invariant the caller can state. Neither path holds a persistent mapping.
///
/// The <c>glBufferData</c> usage hint follows
/// <see cref="GpuBufferDescription.Residency"/>:
/// <see cref="GpuMemoryResidency.DeviceLocal"/> is written rarely and read by
/// many draws, so it takes <c>StaticDraw</c>; the host-writable rings and
/// tables are rewritten every frame and take <c>DynamicDraw</c>. The hint is
/// advisory to the driver, but keeping it per-residency is what let the mesh
/// arena (Campaign V slice V4b) move onto this class without changing the
/// allocation it has always requested.
/// </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);
try
{
_gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
unsafe
{
_gl.BufferData(GLEnum.CopyWriteBuffer, (nuint)SizeBytes, null, UsageHintFor(Residency));
}
GLHelpers.ThrowOnResourceError(_gl, $"allocate buffer '{Name}' ({SizeBytes} bytes)");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
}
catch
{
// A rejected data store (GL_OUT_OF_MEMORY is a real outcome for the
// mesh arena's 384 MiB growth destination) must not strand the name
// that was already created for it. The caller sees the original
// failure and owns nothing.
GlResourceCommand.DeleteBuffer(
_gl,
_name,
$"rollback buffer '{Name}' after a failed allocation");
_name = 0;
throw;
}
}
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;
private static BufferUsageARB UsageHintFor(GpuMemoryResidency residency) =>
residency == GpuMemoryResidency.DeviceLocal
? BufferUsageARB.StaticDraw
: BufferUsageARB.DynamicDraw;
/// <summary>
/// Deletes the physical buffer on the calling thread instead of deferring it
/// through the device's retirement queue the way <see cref="Dispose"/> does.
///
/// Campaign V slice V4b: the mesh arena (<c>GlobalMeshBuffer</c>) already gates
/// every arena delete behind its own <c>GpuRetirementLedger</c> and decrements
/// its <c>MaximumPhysicalArenaBytes</c> accounting in the same retirement stage.
/// Routing the physical free through the queue a second time would delay it by
/// a further flight generation, so the arena's physical-capacity accounting
/// would run ahead of real GPU residency and could admit a migration that
/// breaches the 896 MiB dual-generation ceiling. A caller of this method must
/// therefore already have proved no submitted frame can still reference the
/// buffer.
///
/// Retryable by construction: the managed name is cleared only after the driver
/// reports success, so a failed delete is re-issued by the next attempt, and any
/// later call (including <see cref="Dispose"/>) is a no-op.
/// </summary>
internal void DeleteRetired(string context)
{
uint name = _name;
if (name == 0)
return;
_gl.DeleteBuffer(name);
// Per the GL error contract a command which generates an error does not
// change object state, so validation stays in the same stage as the
// mutation and the name is only surrendered once deletion committed.
GLHelpers.ThrowOnResourceError(_gl, context);
_name = 0;
}
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);
}
/// <summary>
/// Writes <paramref name="data"/> at <paramref name="offsetBytes"/> through
/// <c>glMapBufferRange</c> with
/// <c>GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT | GL_MAP_INVALIDATE_RANGE_BIT</c>
/// — the canonical GL upload-ring idiom, and the only write path the
/// per-frame ring uses.
///
/// <para><b>Why not <see cref="Upload"/>.</b> A partial <c>glBufferSubData</c>
/// into a buffer object that already-submitted draws are still reading leaves
/// the driver to guess how to keep both truths alive: it can stall, it can
/// rename the whole data store and copy the untouched remainder forward, or
/// it can route the write through an internal staging copy. Which one it
/// picks is a heuristic, and the heuristic is fed by the update pattern —
/// Campaign V's V4c revert (plan §5.5) saw a world that rendered blank
/// roughly one launch in three, with no GL error at any point, on the
/// connected path that issues 1040 such partial updates per frame, and zero
/// times on the offline path that issues 24. Mapping the range instead
/// removes the guess: the three bits together say "I am writing this range,
/// I am overwriting all of it, and nothing in flight reads it," which is
/// exactly the ring's actual invariant.</para>
///
/// <para><b>The precondition is the caller's to prove.</b>
/// <c>GL_MAP_UNSYNCHRONIZED_BIT</c> means the driver inserts no wait, so the
/// caller asserts that no GL command already submitted and not yet complete
/// reads any byte of <c>[offsetBytes, offsetBytes + data.Length)</c>. The ring
/// has that invariant on both axes: within a frame the allocation cursor only
/// moves forward and <see cref="GlRingBufferState"/> refuses a write below the
/// flushed high-water mark, and across frames a slot is only rewritten after
/// <c>GpuFrameFlightController.BeginFrame</c> has waited on the fence for the
/// last frame that used it. <c>GL_MAP_INVALIDATE_RANGE_BIT</c> is likewise
/// only sound because every byte of the mapped range is then written.</para>
///
/// <para>A <c>false</c> return from <c>glUnmapBuffer</c> means the data store
/// was lost while mapped (a GPU reset or display-mode change), so the write
/// did not land. That throws rather than being retried: the frame's uploads
/// are already partly gone, and silently continuing would draw from a
/// half-written ring.</para>
/// </summary>
internal unsafe void WriteRangeUnsynchronized(long offsetBytes, ReadOnlySpan<byte> data, string context)
{
ThrowIfDisposed();
if (offsetBytes < 0 || offsetBytes + data.Length > SizeBytes)
{
throw new ArgumentOutOfRangeException(
nameof(offsetBytes),
$"Mapped write of {data.Length} bytes at offset {offsetBytes} exceeds buffer '{Name}' ({SizeBytes} bytes).");
}
if (data.IsEmpty)
return;
_gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
void* mapped = _gl.MapBufferRange(
GLEnum.CopyWriteBuffer,
(nint)offsetBytes,
(nuint)data.Length,
MapBufferAccessMask.WriteBit
| MapBufferAccessMask.UnsynchronizedBit
| MapBufferAccessMask.InvalidateRangeBit);
if (mapped is null)
{
// A null return always sets a GL error, so surface that first — it
// names the actual rejection instead of the symptom.
GLHelpers.ThrowOnResourceError(
_gl,
$"map {data.Length} bytes of buffer '{Name}' at offset {offsetBytes} ({context})");
throw new InvalidOperationException(
$"glMapBufferRange returned no pointer for {data.Length} bytes of buffer '{Name}' " +
$"at offset {offsetBytes} ({context}).");
}
data.CopyTo(new Span<byte>(mapped, data.Length));
bool unmapped = _gl.UnmapBuffer(GLEnum.CopyWriteBuffer);
GLHelpers.ThrowOnResourceError(_gl, $"unmap buffer '{Name}' after writing {context}");
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
if (!unmapped)
{
throw new InvalidOperationException(
$"Buffer '{Name}' lost its data store while {data.Length} bytes at offset " +
$"{offsetBytes} ({context}) were mapped; the write did not land.");
}
}
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);
}
}

View file

@ -1,667 +0,0 @@
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 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. Both flushes go through
/// <see cref="GlGpuBuffer.WriteRangeUnsynchronized"/>, i.e.
/// <c>glMapBufferRange</c> with
/// <c>GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT | GL_MAP_INVALIDATE_RANGE_BIT</c>,
/// rather than <c>glBufferSubData</c>: these are the only two buffers this
/// backend rewrites while the frame's own draws are still in flight, and that
/// is precisely the case where a partial <c>glBufferSubData</c> leaves the
/// result to a driver heuristic. See that method's remarks and campaign plan
/// §5.5 for the failure it produced. The two flushes differ in shape because
/// their non-overlap proofs differ: the ring writes one span (its cursor only
/// moves forward within a frame), the table writes one span per run of
/// consecutive dirty slots (see <see cref="GlDirtySlotRuns"/>).</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 readonly GlDirtySlotRuns _textureTableDirtySlots =
new(GpuBindingModel.TextureTableCapacity);
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;
/// <summary>
/// GL name of the buffer emulating the global texture table
/// (<see cref="GpuBindingModel.StorageTextureTable"/>). Slice V6d:
/// <see cref="GlGpuPassEncoder.BindPipeline"/> binds it on every pipeline
/// bind, which is the GL analogue of the Vulkan backend binding descriptor
/// set 2 on every draw. Without it an RHI shader that samples the table
/// would read whichever raw-GL renderer's private handle table was left at
/// binding 9 — a different slot numbering entirely, which is the loudest
/// possible way to sample the wrong texture.
/// </summary>
internal uint TextureTableGlName => _textureTableBuffer.GlName;
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");
// Campaign V slice V6d: every RHI pipeline gets the shared preamble,
// unconditionally. The Vulkan backend injects its own preamble into
// every shader it compiles, so making the GL side selective would mean
// one source file compiling against two different sets of definitions
// depending on which pipeline happened to ask for it. A shader that
// reads nothing from the preamble simply carries an unused declaration.
string common = File.ReadAllText(Path.Combine(_shadersDirectory, "common.glsl"));
string vertexSource = Shader.InjectPreamble(File.ReadAllText(vertexPath), common);
string fragmentSource = Shader.InjectPreamble(File.ReadAllText(fragmentPath), common);
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>
/// Writes this slot's dirty ring bytes and every dirty run of the texture
/// table into their GL buffers, 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.
///
/// Both writes are mapped and unsynchronized (see the class remarks and
/// <see cref="GlGpuBuffer.WriteRangeUnsynchronized"/>). The ring's dirty
/// span may include alignment padding between allocations; those bytes are
/// below the cursor, were allocated this frame, and are read by nothing, so
/// covering them in one map is sound and saves a call. The table has no
/// equivalent licence, which is why it drains run by run.
/// </summary>
internal void FlushBeforeDraw(int slotIndex)
{
(int start, int length) = _ringStates[slotIndex].TakeDirtyRange();
if (length > 0)
{
_ringBuffers[slotIndex].WriteRangeUnsynchronized(
start,
_ringStaging[slotIndex].AsSpan(start, length),
$"ring slot {slotIndex}");
}
FlushTextureTable();
}
/// <summary>
/// Drains every dirty run of the texture table into its GL buffer. Called by
/// <see cref="FlushBeforeDraw"/> for RHI draws, and directly by the still-raw-GL
/// world renderers before their own draws — see the V4t remarks on
/// <see cref="RegisterWorldTextureHandle"/>.
/// </summary>
internal void FlushTextureTable()
{
while (_textureTableDirtySlots.TryTakeNextRun(out uint firstSlot, out uint slotCount))
{
ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(
_textureHandleTable.AsSpan((int)firstSlot, (int)slotCount));
_textureTableBuffer.WriteRangeUnsynchronized(
(long)firstSlot * sizeof(ulong),
bytes,
$"texture table slots {firstSlot}..{firstSlot + slotCount - 1}");
}
}
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.
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}'");
}
// Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 2):
// reset unconditionally on EVERY pass, not only a clearing one. While
// raw-GL renderers coexist with RHI-ported ones (through V4h), a
// raw-GL renderer can run between two RHI passes within the same frame
// and change GL program/blend/depth/cull state the cache never
// observes. A reset gated on "this pass cleared" left the cache
// trusting a stale belief in that case, so a later BindPipeline
// skipped re-issuing glUseProgram and the following push-constant
// upload threw GL_INVALID_OPERATION against whatever program was
// actually bound — exactly the failure the first V4a attempt hit.
// Resetting on every BeginPass costs one redundant state application
// on the pass's first bind and is removed at V4h once nothing raw-GL
// remains.
_renderState.Reset();
}
// Campaign V slice V6k deleted the V4a pre-approved transitional seam
// (RegisterExternalColorTexture / TryResolveExternalColorTexture, campaign
// doc §7.1's final paragraph). It existed so the retained UI could blit the
// paperdoll and creature-appraisal viewport textures while their renderer
// still owned a hand-rolled FBO the RHI knew nothing about. That renderer now
// creates an IGpuRenderTarget and registers its colour attachment through
// RegisterTexture like anything else, so the escape hatch has no caller —
// exactly the end §7.1 wrote for it.
// ── V4t transitional seam: the world texture stack's entry to this table ──
//
// Campaign V slice V4t moves the world's CPU data model off the raw 64-bit
// ARB_bindless_texture handle and onto GpuTextureSlot, but §5.5.6 closed the
// GL re-land of V4c/V4d, so WbDrawDispatcher, EnvCellRenderer,
// TerrainModernRenderer and ParticleRenderer still submit through raw GL and
// cannot reach FlushBeforeDraw. They therefore call FlushTextureTable and
// bind TextureTableGlName at GpuBindingModel.StorageTextureTable themselves,
// immediately before their own draws — the same shape their retired private
// GlBindlessHandleTable had, against the one table that is now the device's.
//
// Residency ownership does NOT move. Each world texture is created, made
// resident and destroyed by its own cache (TerrainAtlas,
// CompositeTextureArrayCache, StandaloneBindlessTextureCache,
// ManagedGLTextureArray); this device only owns the table entry, keyed 1:1 by
// the caller's already-resident handle. That is what separates this from
// RegisterTexture, which owns the residency it creates.
//
// Deleted with the raw-GL world path when the Vulkan world arm lands, at
// which point every one of those caches registers through RegisterTexture.
private readonly Dictionary<ulong, GpuTextureSlot> _worldTextureSlotsByHandle = new();
/// <summary>
/// Interns an already-resident world texture handle into the device's table
/// and returns its slot. Idempotent: the same handle always resolves to the
/// same slot until <see cref="ReleaseWorldTextureHandle"/> retires it, which
/// is what lets a cache call this per draw rather than tracking the slot.
/// </summary>
internal GpuTextureSlot RegisterWorldTextureHandle(ulong residentHandle)
{
ThrowIfDisposed();
if (residentHandle == 0)
return GpuTextureSlot.Unassigned;
if (_worldTextureSlotsByHandle.TryGetValue(residentHandle, out GpuTextureSlot existing))
return existing;
uint slotIndex = _textureSlotAllocator.Allocate();
WriteHandle(slotIndex, residentHandle);
var slot = new GpuTextureSlot(slotIndex);
_worldTextureSlotsByHandle.Add(residentHandle, slot);
return slot;
}
/// <summary>
/// Retires the table entry for a world handle the caller is about to make
/// non-resident. The slot itself returns to the free list only once the
/// retirement queue confirms no submitted frame can still read it, exactly
/// as for <see cref="ReleaseTextureSlot"/>. A handle that was never
/// registered is a no-op, so a cache may call this unconditionally on its
/// teardown path.
/// </summary>
internal void ReleaseWorldTextureHandle(ulong residentHandle)
{
if (residentHandle == 0)
return;
if (!_worldTextureSlotsByHandle.Remove(residentHandle, out GpuTextureSlot slot))
return;
ReleaseTextureSlot(slot);
}
/// <summary>Live world-handle registrations. Diagnostics and tests only.</summary>
internal int WorldTextureSlotCount => _worldTextureSlotsByHandle.Count;
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);
}
// Slice V6l. The stencil VALUES are issued whenever the test is on, even
// if only the enable changed: GL keeps func/op/mask as global state that a
// raw-GL renderer may have moved since this cache last saw it, and the
// portal punch's correctness depends on the exact triple it asked for.
if (changes.StencilTest)
{
if (desired.StencilTest)
_gl.Enable(EnableCap.StencilTest);
else
_gl.Disable(EnableCap.StencilTest);
}
if (desired.StencilTest && (changes.StencilTest || changes.Stencil))
{
GpuStencilState stencil = desired.Stencil;
_gl.StencilFunc(
GlEnumMapping.StencilFunctionOf(stencil.Compare),
(int)stencil.Reference,
stencil.CompareMask);
_gl.StencilOp(
GlEnumMapping.StencilOpOf(stencil.Fail),
GlEnumMapping.StencilOpOf(stencil.DepthFail),
GlEnumMapping.StencilOpOf(stencil.Pass));
_gl.StencilMask(stencil.WriteMask);
}
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);
// FrameScreenshotController owns the one backbuffer read in the process:
// it names framebuffer 0 rather than inheriting a binding, and it
// resolves the multisampled default framebuffer before reading, because
// glReadPixels against a multisampled read framebuffer is undefined.
// A second implementation here would be a second instrument to keep
// sound. FlipRows is the same top-left-origin flip the screenshot gates
// rely on, so this seam stays byte-for-byte compatible with them.
byte[] pixels = FrameScreenshotController.ReadDefaultFramebuffer(_gl, width, height);
GLHelpers.ThrowOnResourceError(_gl, $"capture backbuffer {width}x{height}");
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;
_textureTableDirtySlots.Mark(slot);
}
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 ring maps and unmaps per flush; it never holds a persistent
// mapping a renderer could write into between frames, which is what
// this capability advertises. Only Vulkan reports true.
SupportsPersistentlyMappedRings = false,
};
}
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}

View file

@ -1,58 +0,0 @@
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();
}

View file

@ -1,316 +0,0 @@
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 readonly IGlAmbientStateApi _ambientApi;
private readonly GlAmbientCapabilityState _ambientOnEntry;
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;
// Campaign V slice V4a (2026-07-27 revert postmortem, plan §7.1 rule 1):
// capture every ambient capability a bound pipeline can change, so
// Dispose can put it back. Every acdream renderer is still raw GL
// until V4c/V4d, so each one assumes whatever capability state the
// PREVIOUS renderer left behind is still there — GL_MULTISAMPLE and
// GL_SAMPLE_ALPHA_TO_COVERAGE in particular are set once per frame by
// quality settings and never re-asserted per draw. The first V4a
// attempt bound a pipeline that changed this state and never restored
// it, so the world drew without multisampling from the first UI frame
// on. Capturing here and restoring on Dispose keeps the GL backend's
// behaviour-preserving property true at this seam. Deleted at V4h
// once nothing raw-GL remains.
_ambientApi = new SilkGlAmbientStateApi(_gl);
_ambientOnEntry = GlAmbientCapabilityState.Capture(_ambientApi);
// Campaign V slice V6d. GL_MULTISAMPLE is the one piece of pass state
// with no representation in GpuPipelineDescription, and the pass's own
// SampleCount is the contract's answer for it: a single-sampled pass
// does not multisample. Until now the retained UI asserted that with a
// raw glDisable of its own — exactly the kind of state a
// backend-neutral renderer cannot own. Quality settings enable
// GL_MULTISAMPLE once per frame for the world, and if it leaks into the
// UI pass every glyph's soft alpha edge becomes dithered coverage
// instead of a clean alpha blend (the "fuzzy text" artifact). The
// ambient capture above puts it back on Dispose, so the raw-GL world
// renderers that follow are unaffected.
_ambientApi.SetCapability(EnableCap.Multisample, pass.SampleCount > 1);
}
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,
description.StencilTest,
description.Stencil);
_device.ApplyRenderState(desired);
_gl.BindVertexArray(p.GlVertexArray);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO");
// Campaign V slice V6d: the device's texture table is bound with the
// pipeline, the GL analogue of the Vulkan backend binding descriptor
// set 2 on every draw. It has to happen here rather than once per frame
// because every raw-GL world renderer binds its OWN private handle
// table at this same binding before its own draws, with its own slot
// numbering; an RHI shader that read that instead would sample a
// plausible but entirely unrelated texture. Removed at V4h with the
// per-renderer tables.
_gl.BindBufferBase(
GLEnum.ShaderStorageBuffer,
GpuBindingModel.StorageTextureTable,
_device.TextureTableGlName);
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' texture table");
// 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(uint binding, 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;
// Slice V6l: only the attributes this binding actually supplies. GL has
// no binding indirection of its own — glVertexAttribPointer records the
// currently bound ARRAY_BUFFER per attribute — so the binding index is
// resolved here, by filtering, rather than by the driver.
uint stride = layout.StrideOf(binding);
foreach (GpuVertexAttribute attribute in layout.Attributes)
{
if (attribute.Binding != binding)
continue;
GlVertexAttributeShape shape = GlEnumMapping.VertexShapeOf(attribute.Format);
nint attributeOffset = (nint)(offsetBytes + attribute.OffsetBytes);
if (shape.Integer)
{
// An integer shader input (uvec4) must come through the I-form.
// Supplying it via glVertexAttribPointer leaves the value undefined.
_gl.VertexAttribIPointer(
attribute.Location,
shape.ComponentCount,
(VertexAttribIType)shape.Type,
stride,
(void*)attributeOffset);
}
else
{
_gl.VertexAttribPointer(
attribute.Location,
shape.ComponentCount,
shape.Type,
shape.Normalized,
stride,
(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 void SetStencil(in GpuStencilState stencil)
{
ThrowIfClosed();
_device.ApplyRenderState(_device.CurrentRenderState with { Stencil = stencil });
}
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.
//
// Restore whatever capability state was ambient before this pass
// opened (see the constructor's comment) so a still-raw-GL renderer
// running immediately after this pass sees exactly what it would have
// seen had this pass never bound a pipeline.
_ambientOnEntry.Restore(_ambientApi);
_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));
}
}

View file

@ -1,97 +0,0 @@
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);
GpuVertexLayout layout = description.VertexLayout;
foreach (GpuVertexAttribute attribute in layout.Attributes)
{
_gl.EnableVertexAttribArray(attribute.Location);
// Slice V6l: the divisor is VAO state and survives every later
// glVertexAttribPointer, so it belongs here with the enables
// rather than in the per-frame rebind. Zero is the GL default and
// is restated explicitly — a VAO name can be recycled by the
// driver, and inheriting a stale divisor draws one instance's
// data across every vertex.
_gl.VertexAttribDivisor(
attribute.Location,
layout.InputRateOf(attribute.Binding) == GpuVertexInputRate.Instance ? 1u : 0u);
}
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");
}
});
}
}

View file

@ -1,81 +0,0 @@
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);
}

View file

@ -1,121 +0,0 @@
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();
}
}

View file

@ -1,61 +0,0 @@
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}");
});
}
}

View file

@ -1,202 +0,0 @@
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);
}
}

View file

@ -1,56 +0,0 @@
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}."),
};
}

View file

@ -1,186 +0,0 @@
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()
{
}
}
}

View file

@ -1,72 +0,0 @@
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.");
}
}
}

View file

@ -1,84 +0,0 @@
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,
bool StencilTest,
GpuStencilState Stencil);
/// <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,
bool StencilTest,
bool Stencil)
{
public bool AnyChange =>
Program || Blend || DepthTest || DepthWrite || DepthCompare
|| Cull || FrontFace || AlphaToCoverage || ColorWrite
|| StencilTest || Stencil;
/// <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, 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,
p.StencilTest != desired.StencilTest,
p.Stencil != desired.Stencil);
}
/// <summary>Discards the cached baseline — the next <see cref="Apply"/> reports every dimension changed.</summary>
public void Reset() => _last = null;
}

View file

@ -1,140 +0,0 @@
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, a "dirty" watermark tracking the byte range
/// written since the last flush, and the flushed high-water mark that makes the
/// ring's write path safe to issue unsynchronized.
///
/// This is deliberately GL-free so it can be unit tested without a live
/// context. <see cref="GlGpuFrame"/>'s device 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 writes exactly the dirty range
/// into the GL buffer through
/// <see cref="GlGpuBuffer.WriteRangeUnsynchronized"/> 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.
///
/// <para><b>The forward-only invariant is load-bearing, not incidental.</b> The
/// device's per-flush write is mapped with <c>GL_MAP_UNSYNCHRONIZED_BIT</c>,
/// which means the driver inserts no wait and the caller is asserting that no
/// submitted-and-unfinished draw reads the range. Within a frame that holds
/// because <see cref="Allocate"/> only ever hands out bytes at or above the
/// cursor, so each flush covers a range strictly above every range already
/// flushed. Rather than leave that as an emergent property of the arithmetic,
/// <see cref="MarkDirty"/> refuses a write below <see cref="FlushedEndBytes"/>:
/// a future change that reused ring bytes mid-frame would fail loudly here
/// instead of producing an undefined read on the GPU. Across frames the
/// invariant belongs to <c>GpuFrameFlightController</c>, which waits on a
/// slot's fence in <c>BeginFrame</c> before this state is <see cref="Reset"/>.
/// </para>
/// </summary>
internal sealed class GlRingBufferState
{
private readonly int _capacityBytes;
private uint _cursor;
private int _dirtyStart = -1;
private int _dirtyEnd = -1;
private int _flushedEnd;
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>
/// The exclusive end of the bytes already handed to the GPU this frame.
/// Every subsequent write must start at or above it.
/// </summary>
public int FlushedEndBytes => _flushedEnd;
/// <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;
_flushedEnd = 0;
}
/// <summary>
/// Returns the byte range written since the last flush (start, length),
/// or (0, 0) when nothing is dirty, and clears the watermark while
/// advancing <see cref="FlushedEndBytes"/> over it.
/// </summary>
public (int Start, int Length) TakeDirtyRange()
{
if (_dirtyStart < 0)
return (0, 0);
(int start, int length) = (_dirtyStart, _dirtyEnd - _dirtyStart);
_flushedEnd = Math.Max(_flushedEnd, _dirtyEnd);
_dirtyStart = -1;
_dirtyEnd = -1;
return (start, length);
}
public bool HasDirtyBytes => _dirtyStart >= 0;
/// <summary>
/// Records that <c>[start, end)</c> was written. Internal rather than
/// private for the same reason <see cref="AlignUp"/> is: the forward-only
/// guard below is unreachable through <see cref="Allocate"/> by
/// construction, so proving it fires at all needs a direct call.
/// </summary>
internal void MarkDirty(int start, int end)
{
if (start < _flushedEnd)
{
throw new InvalidOperationException(
$"Ring write [{start}, {end}) reaches below the {_flushedEnd} bytes already " +
"uploaded this frame. Ring uploads are issued with GL_MAP_UNSYNCHRONIZED_BIT, " +
"so rewriting bytes a submitted draw may still be reading is undefined — the " +
"allocation cursor must only move forward until Reset.");
}
_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;
}

View file

@ -1,62 +0,0 @@
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);
}
}

View file

@ -6,9 +6,6 @@ internal enum GpuBackendKind
/// <summary>Test double — records calls, owns no driver objects.</summary>
Recording,
/// <summary>OpenGL 4.3 + bindless/MDI. Deleted at Campaign V slice V11.</summary>
OpenGl,
/// <summary>Vulkan 1.3 core.</summary>
Vulkan,
}