feat(render): Campaign V slice V1 - OpenGL RHI backend (dark)
Implements GlGpuDevice and the rest of AcDream.App.Rendering.Gpu.Gl,
filling the V0-pinned IGpuDevice contract on OpenGL 4.3. This is the
first of the port slices described in
docs/plans/2026-07-27-vulkan-campaign.md: every later renderer port
(V2 onward) needs a real, driver-proven GL implementation of the RHI
to port onto, and the GL backend is deliberately built to be
behaviour-preserving rather than optimal, because that is what turns
each subsequent slice's pixel gate into a strict identity check
instead of a moving target. The Vulkan backend (V5+) is where the
actual efficiency gains land.
GlGpuDevice is a fresh root, not derived from Chorizite's
BaseGraphicsDevice/OpenGLGraphicsDevice - shedding that inheritance is
one of the things this campaign explicitly does. It owns its own
BindlessSupport instance rather than sharing the legacy WB render
path's, which is what lets it be constructed the moment a GL context
and a GpuFrameFlightController exist, with no dependency on when
WorldRenderCompositionPhase happens to detect bindless support later
in startup. The ring buffer keeps a managed staging array plus a real
GL buffer per flight slot and flushes with one BufferSubData
immediately before each Draw/DrawIndexed/MultiDrawIndexedIndirect
(never at bind time, since a renderer may still write after binding);
V1 throws on an over-capacity ring request rather than growing it,
since nothing consumes the device yet and a silent grow would hide a
future renderer's real working set. The texture table is a bump/free-
list allocator over a managed uvec2 handle array, gated through the
frame-flight retirement queue so a released slot cannot be reused
while a submitted frame might still read it. Push constants are
applied by uniform name on the currently-bound program, cached per
program, and explicitly re-applied whenever BindPipeline switches
programs - GL uniforms are per-program state, so the "survives
pipeline changes within a pass" guarantee the interface documents (a
freebie on Vulkan's shared pipeline layout) has to be emulated here.
BindlessSupport gained one additive method,
GetResidentHandle(texture, sampler), calling the same
ArbBindlessTexture.GetTextureSamplerHandle entry point
ManagedGLTextureArray already uses through a different path. The
existing GetResidentHandle(texture) cannot express
IGpuDevice.RegisterTexture's documented pair semantics ("the same
texture registered with two samplers occupies two slots"), so this
was the minimal change needed rather than a workaround.
The pure bookkeeping - ring watermark/alignment arithmetic, the
texture-slot allocator, render-state diffing, the push-constant field-
to-uniform-name table, and GL format mapping - lives in small GL-free
classes so it is unit-testable without a live context, following the
same seam pattern GpuFrameFlightController already uses for its fence
API. GlGpuTimerPool follows suit with an injectable timer-query API.
The device is constructed in HostInputCameraCompositionPhase
immediately after the frame-flight controller (the same phase that
already builds GpuFrameFlightController), rather than in
WorldRenderCompositionPhase as first considered: GlGpuDevice's self-
contained bindless detection means it has no ordering dependency on
the legacy WB path's BindlessSupport, so it can be proven against the
real driver as early as possible while keeping the composition change
to one phase. Composition, publication, and shutdown wiring follow
the existing acquire/publish/fault-injection pattern exactly, and GPU
device disposal is scheduled through the frame-flight retirement queue
before that queue itself is torn down. Nothing consumes the device
yet - that starts at V4a - so this slice's pixel gate is trivially a
tripwire.
App tests: 3834 passed / 3 skipped (V0 baseline 3785 + 49 new: ring,
texture-slot, render-state, push-constant, format-mapping, enum-
mapping, and timer-pool tests, plus one new fault-injection point in
the existing composition theory).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
90f7c6f2f4
commit
4f94ad7ddd
30 changed files with 2843 additions and 2 deletions
235
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs
Normal file
235
src/AcDream.App/Rendering/Gpu/Gl/GlGpuPassEncoder.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Records one pass's draw work. Binding calls translate almost mechanically
|
||||
/// to GL (a storage/uniform binding is <c>glBindBufferRange</c>, an indexed
|
||||
/// draw is <c>glDrawElementsInstancedBaseVertexBaseInstance</c>, and so on);
|
||||
/// the two pieces of real logic are the render-state diff applied on
|
||||
/// <see cref="BindPipeline"/> / the dynamic setters, and the "flush dirty
|
||||
/// ring + texture-table bytes immediately before every draw" discipline
|
||||
/// described on <see cref="GlGpuDevice"/>.
|
||||
/// </summary>
|
||||
internal sealed class GlGpuPassEncoder : IGpuPassEncoder
|
||||
{
|
||||
private readonly GlGpuDevice _device;
|
||||
private readonly GlGpuFrame _frame;
|
||||
private readonly GL _gl;
|
||||
private bool _closed;
|
||||
|
||||
private GlGpuPipeline? _currentPipeline;
|
||||
private GpuIndexType _currentIndexType = GpuIndexType.UInt16;
|
||||
private uint _currentIndexBufferBaseOffset;
|
||||
private GpuPushConstants? _currentPushConstants;
|
||||
|
||||
internal GlGpuPassEncoder(GlGpuDevice device, GlGpuFrame frame, GpuPassDescription pass)
|
||||
{
|
||||
_device = device;
|
||||
_frame = frame;
|
||||
_gl = device.Gl;
|
||||
Pass = pass;
|
||||
}
|
||||
|
||||
public GpuPassDescription Pass { get; }
|
||||
|
||||
public void BindPipeline(IGpuPipeline pipeline)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pipeline);
|
||||
ThrowIfClosed();
|
||||
var p = (GlGpuPipeline)pipeline;
|
||||
_currentPipeline = p;
|
||||
|
||||
GpuPipelineDescription description = p.Description;
|
||||
var desired = new GlRenderStateSnapshot(
|
||||
p.GlProgram,
|
||||
description.Blend,
|
||||
description.Depth.Test,
|
||||
description.Depth.Write,
|
||||
description.Depth.Compare,
|
||||
description.Cull,
|
||||
description.FrontFace,
|
||||
description.AlphaToCoverage,
|
||||
description.ColorWrite);
|
||||
_device.ApplyRenderState(desired);
|
||||
|
||||
_gl.BindVertexArray(p.GlVertexArray);
|
||||
GLHelpers.ThrowOnResourceError(_gl, $"bind pipeline '{description.Name}' VAO");
|
||||
|
||||
// Push constants "survive pipeline changes within a pass" per the
|
||||
// IGpuPassEncoder contract. GL uniforms are per-program state, so the
|
||||
// GL backend must explicitly re-apply the last value to the newly
|
||||
// bound program to honour that — Vulkan gets this for free from a
|
||||
// shared pipeline layout.
|
||||
if (_currentPushConstants is { } constants)
|
||||
_device.PushConstants.Apply(p.GlProgram, in constants);
|
||||
}
|
||||
|
||||
public void BindStorageBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
var b = RequireGlBuffer(buffer);
|
||||
_gl.BindBufferRange(GLEnum.ShaderStorageBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes);
|
||||
GLHelpers.ThrowOnResourceError(_gl, $"bind storage buffer '{buffer.Name}' at binding {binding}");
|
||||
}
|
||||
|
||||
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
var b = RequireGlBuffer(buffer);
|
||||
_gl.BindBufferRange(GLEnum.UniformBuffer, binding, b.GlName, (nint)offsetBytes, sizeBytes);
|
||||
GLHelpers.ThrowOnResourceError(_gl, $"bind uniform buffer '{buffer.Name}' at binding {binding}");
|
||||
}
|
||||
|
||||
public unsafe void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
if (_currentPipeline is not { } pipeline)
|
||||
throw new InvalidOperationException("BindPipeline must be called before BindVertexBuffer.");
|
||||
var b = RequireGlBuffer(buffer);
|
||||
|
||||
_gl.BindBuffer(GLEnum.ArrayBuffer, b.GlName);
|
||||
GpuVertexLayout layout = pipeline.Description.VertexLayout;
|
||||
foreach (GpuVertexAttribute attribute in layout.Attributes)
|
||||
{
|
||||
GlVertexAttributeShape shape = GlEnumMapping.VertexShapeOf(attribute.Format);
|
||||
nint attributeOffset = (nint)(offsetBytes + attribute.OffsetBytes);
|
||||
_gl.VertexAttribPointer(
|
||||
attribute.Location,
|
||||
shape.ComponentCount,
|
||||
shape.Type,
|
||||
shape.Normalized,
|
||||
layout.StrideBytes,
|
||||
(void*)attributeOffset);
|
||||
}
|
||||
GLHelpers.ThrowOnResourceError(_gl, $"bind vertex buffer '{buffer.Name}'");
|
||||
_gl.BindBuffer(GLEnum.ArrayBuffer, 0);
|
||||
}
|
||||
|
||||
public void BindIndexBuffer(IGpuBuffer buffer, uint offsetBytes, GpuIndexType indexType)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
var b = RequireGlBuffer(buffer);
|
||||
_currentIndexType = indexType;
|
||||
_currentIndexBufferBaseOffset = offsetBytes;
|
||||
_gl.BindBuffer(GLEnum.ElementArrayBuffer, b.GlName);
|
||||
GLHelpers.ThrowOnResourceError(_gl, $"bind index buffer '{buffer.Name}'");
|
||||
}
|
||||
|
||||
public void SetPushConstants(in GpuPushConstants constants)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_currentPushConstants = constants;
|
||||
if (_currentPipeline is { } pipeline)
|
||||
_device.PushConstants.Apply(pipeline.GlProgram, in constants);
|
||||
}
|
||||
|
||||
public void SetViewport(int x, int y, int width, int height)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_gl.Viewport(x, y, (uint)width, (uint)height);
|
||||
}
|
||||
|
||||
public void SetScissor(int x, int y, int width, int height)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_gl.Enable(EnableCap.ScissorTest);
|
||||
_gl.Scissor(x, y, (uint)width, (uint)height);
|
||||
}
|
||||
|
||||
public void SetCullMode(GpuCullMode cullMode)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_device.ApplyRenderState(_device.CurrentRenderState with { Cull = cullMode });
|
||||
}
|
||||
|
||||
public void SetFrontFace(GpuFrontFace frontFace)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_device.ApplyRenderState(_device.CurrentRenderState with { FrontFace = frontFace });
|
||||
}
|
||||
|
||||
public void SetDepthWrite(bool enabled)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_device.ApplyRenderState(_device.CurrentRenderState with { DepthWrite = enabled });
|
||||
}
|
||||
|
||||
public unsafe void DrawIndexed(uint indexCount, uint instanceCount, uint firstIndex, int vertexOffset, uint firstInstance)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_device.FlushBeforeDraw(_frame.SlotIndex);
|
||||
int indexSize = GlEnumMapping.IndexSizeBytesOf(_currentIndexType);
|
||||
nint indexOffset = (nint)(_currentIndexBufferBaseOffset + firstIndex * (uint)indexSize);
|
||||
_gl.DrawElementsInstancedBaseVertexBaseInstance(
|
||||
GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
|
||||
indexCount,
|
||||
GlEnumMapping.DrawElementsTypeOf(_currentIndexType),
|
||||
(void*)indexOffset,
|
||||
instanceCount,
|
||||
vertexOffset,
|
||||
firstInstance);
|
||||
GLHelpers.ThrowOnResourceError(_gl, "DrawIndexed");
|
||||
}
|
||||
|
||||
public void Draw(uint vertexCount, uint instanceCount, uint firstVertex, uint firstInstance)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
_device.FlushBeforeDraw(_frame.SlotIndex);
|
||||
_gl.DrawArraysInstancedBaseInstance(
|
||||
(GLEnum)GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
|
||||
(int)firstVertex,
|
||||
vertexCount,
|
||||
instanceCount,
|
||||
firstInstance);
|
||||
GLHelpers.ThrowOnResourceError(_gl, "Draw");
|
||||
}
|
||||
|
||||
public unsafe void MultiDrawIndexedIndirect(IGpuBuffer commands, uint offsetBytes, uint drawCount, uint strideBytes)
|
||||
{
|
||||
ThrowIfClosed();
|
||||
var indirect = RequireGlBuffer(commands);
|
||||
_device.FlushBeforeDraw(_frame.SlotIndex);
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, indirect.GlName);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
GlEnumMapping.PrimitiveTypeOf(RequirePipeline().Description.Topology),
|
||||
GlEnumMapping.DrawElementsTypeOf(_currentIndexType),
|
||||
(void*)(nint)offsetBytes,
|
||||
drawCount,
|
||||
strideBytes);
|
||||
GLHelpers.ThrowOnResourceError(_gl, "MultiDrawIndexedIndirect");
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||||
}
|
||||
|
||||
public IDisposable BeginTimerScope(string scopeName) => _device.TimerPool.BeginScope(scopeName);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_closed)
|
||||
return;
|
||||
_closed = true;
|
||||
// GL has no store-op work to do here: GpuStoreOp.Resolve was already
|
||||
// rejected at BeginPass (V1 targets are single-sampled), and
|
||||
// Store/DontCare need no explicit action — the framebuffer's contents
|
||||
// simply persist until the next pass rebinds a target.
|
||||
_frame.ClosePass(this);
|
||||
}
|
||||
|
||||
private GlGpuPipeline RequirePipeline() =>
|
||||
_currentPipeline ?? throw new InvalidOperationException("BindPipeline must be called before drawing.");
|
||||
|
||||
private static GlGpuBuffer RequireGlBuffer(IGpuBuffer buffer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
if (buffer is not GlGpuBuffer glBuffer)
|
||||
throw new ArgumentException("The GL backend can only bind GL buffers.", nameof(buffer));
|
||||
return glBuffer;
|
||||
}
|
||||
|
||||
private void ThrowIfClosed()
|
||||
{
|
||||
if (_closed)
|
||||
throw new ObjectDisposedException(nameof(GlGpuPassEncoder));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue