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
|
|
@ -3,6 +3,8 @@ using System.Reflection;
|
|||
using AcDream.App.Composition;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
using Silk.NET.Maths;
|
||||
|
|
@ -23,6 +25,7 @@ public sealed class HostInputCameraCompositionTests
|
|||
Enum.GetValues<HostInputCameraCompositionPoint>(),
|
||||
fixture.Points);
|
||||
Assert.Same(fixture.Publication.GpuFrames, result.GpuFrameFlights);
|
||||
Assert.Same(fixture.Publication.GpuDevice, result.GpuDevice);
|
||||
Assert.Same(fixture.Publication.Keyboard, result.KeyboardSource);
|
||||
Assert.Same(fixture.Publication.Mouse, result.MouseSource);
|
||||
Assert.Same(fixture.Publication.Dispatcher, result.InputDispatcher);
|
||||
|
|
@ -155,6 +158,7 @@ public sealed class HostInputCameraCompositionTests
|
|||
IDisposable
|
||||
{
|
||||
public GpuFrameFlightController? GpuFrames { get; private set; }
|
||||
public IGpuDevice? GpuDevice { get; private set; }
|
||||
public SilkKeyboardSource? Keyboard { get; private set; }
|
||||
public SilkMouseSource? Mouse { get; private set; }
|
||||
public IMouseLookCursor? Cursor { get; private set; }
|
||||
|
|
@ -165,6 +169,9 @@ public sealed class HostInputCameraCompositionTests
|
|||
public void PublishGpuFrameFlights(GpuFrameFlightController value) =>
|
||||
GpuFrames = PublishOnce(GpuFrames, value);
|
||||
|
||||
public void PublishGpuDevice(IGpuDevice value) =>
|
||||
GpuDevice = PublishOnce(GpuDevice, value);
|
||||
|
||||
public void PublishKeyboardSource(SilkKeyboardSource value) =>
|
||||
Keyboard = PublishOnce(Keyboard, value);
|
||||
|
||||
|
|
@ -188,6 +195,9 @@ public sealed class HostInputCameraCompositionTests
|
|||
Assert.Equal(
|
||||
point >= HostInputCameraCompositionPoint.GpuFrameFlightsPublished,
|
||||
GpuFrames is not null);
|
||||
Assert.Equal(
|
||||
point >= HostInputCameraCompositionPoint.GpuDevicePublished,
|
||||
GpuDevice is not null);
|
||||
Assert.Equal(
|
||||
point >= HostInputCameraCompositionPoint.KeyboardPublished,
|
||||
Keyboard is not null);
|
||||
|
|
@ -218,6 +228,8 @@ public sealed class HostInputCameraCompositionTests
|
|||
Mouse = null;
|
||||
Keyboard?.Dispose();
|
||||
Keyboard = null;
|
||||
GpuDevice?.Dispose();
|
||||
GpuDevice = null;
|
||||
GpuFrames?.Dispose();
|
||||
GpuFrames = null;
|
||||
}
|
||||
|
|
@ -243,6 +255,9 @@ public sealed class HostInputCameraCompositionTests
|
|||
public GpuFrameFlightController CreateGpuFrameFlights(GL gl) =>
|
||||
new(new FenceApi());
|
||||
|
||||
public IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights) =>
|
||||
new RecordingGpuDevice();
|
||||
|
||||
public WorldRenderDiagnostics CreateWorldRenderDiagnostics(
|
||||
GL gl,
|
||||
IRenderFrameDiagnosticLog log) =>
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ public sealed class SettingsDevToolsCompositionTests
|
|||
_dispatcher.Attach();
|
||||
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
|
||||
Host = new HostInputCameraResult(
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlEnumMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void VertexShapesMatchTheDeclaredComponentCounts()
|
||||
{
|
||||
Assert.Equal(new GlVertexAttributeShape(1, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float1));
|
||||
Assert.Equal(new GlVertexAttributeShape(2, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float2));
|
||||
Assert.Equal(new GlVertexAttributeShape(3, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float3));
|
||||
Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.Float, false), GlEnumMapping.VertexShapeOf(GpuVertexFormat.Float4));
|
||||
Assert.Equal(new GlVertexAttributeShape(4, VertexAttribPointerType.UnsignedByte, true), GlEnumMapping.VertexShapeOf(GpuVertexFormat.UByte4Normalized));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndexTypesMapToTheirGlSizesAndEnums()
|
||||
{
|
||||
Assert.Equal(DrawElementsType.UnsignedShort, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt16));
|
||||
Assert.Equal(2, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt16));
|
||||
Assert.Equal(DrawElementsType.UnsignedInt, GlEnumMapping.DrawElementsTypeOf(GpuIndexType.UInt32));
|
||||
Assert.Equal(4, GlEnumMapping.IndexSizeBytesOf(GpuIndexType.UInt32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlendFactorsMatchTheDocumentedFormulas()
|
||||
{
|
||||
(BlendingFactor source, BlendingFactor destination) straight = GlEnumMapping.BlendFactorsOf(GpuBlendMode.StraightAlpha);
|
||||
Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha), straight);
|
||||
|
||||
(BlendingFactor source, BlendingFactor destination) additive = GlEnumMapping.BlendFactorsOf(GpuBlendMode.Additive);
|
||||
Assert.Equal((BlendingFactor.SrcAlpha, BlendingFactor.One), additive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlendNoneHasNoGlFactors()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => GlEnumMapping.BlendFactorsOf(GpuBlendMode.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CullNoneHasNoGlCullFaceMode()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => GlEnumMapping.CullFaceModeOf(GpuCullMode.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DepthCompareOpsMapOneToOne()
|
||||
{
|
||||
Assert.Equal(DepthFunction.Never, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Never));
|
||||
Assert.Equal(DepthFunction.Less, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Less));
|
||||
Assert.Equal(DepthFunction.Lequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.LessOrEqual));
|
||||
Assert.Equal(DepthFunction.Equal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Equal));
|
||||
Assert.Equal(DepthFunction.Greater, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Greater));
|
||||
Assert.Equal(DepthFunction.Gequal, GlEnumMapping.DepthFunctionOf(GpuCompareOp.GreaterOrEqual));
|
||||
Assert.Equal(DepthFunction.Always, GlEnumMapping.DepthFunctionOf(GpuCompareOp.Always));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterCombinationsProduceTheExpectedMinFilter()
|
||||
{
|
||||
Assert.Equal(TextureMinFilter.Nearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.None));
|
||||
Assert.Equal(TextureMinFilter.Linear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.None));
|
||||
Assert.Equal(TextureMinFilter.LinearMipmapLinear, GlEnumMapping.MinFilterOf(GpuFilter.Linear, GpuMipFilter.Linear));
|
||||
Assert.Equal(TextureMinFilter.NearestMipmapNearest, GlEnumMapping.MinFilterOf(GpuFilter.Nearest, GpuMipFilter.Nearest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryNonNoneEnumValueResolvesForFrontFaceAndWrapMode()
|
||||
{
|
||||
Assert.Equal(FrontFaceDirection.Ccw, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.CounterClockwise));
|
||||
Assert.Equal(FrontFaceDirection.CW, GlEnumMapping.FrontFaceDirectionOf(GpuFrontFace.Clockwise));
|
||||
Assert.Equal(TextureWrapMode.Repeat, GlEnumMapping.WrapModeOf(GpuAddressMode.Repeat));
|
||||
Assert.Equal(TextureWrapMode.ClampToEdge, GlEnumMapping.WrapModeOf(GpuAddressMode.ClampToEdge));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlGpuTextureFormatMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Rgba8AndItsRenderTargetVariantShareTheSameGlShape()
|
||||
{
|
||||
GlTextureFormatInfo plain = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8Unorm);
|
||||
GlTextureFormatInfo target = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Rgba8UnormRenderTarget);
|
||||
|
||||
Assert.Equal(plain, target);
|
||||
Assert.Equal(SizedInternalFormat.Rgba8, plain.SizedInternalFormat);
|
||||
Assert.Equal(PixelFormat.Rgba, plain.UploadPixelFormat);
|
||||
Assert.Equal(PixelType.UnsignedByte, plain.UploadPixelType);
|
||||
Assert.False(plain.IsCompressed);
|
||||
Assert.Equal(4, plain.LayerByteCount(1, 1));
|
||||
Assert.Equal(4 * 16 * 16, plain.LayerByteCount(16, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R8IsOneByteUncompressed()
|
||||
{
|
||||
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.R8Unorm);
|
||||
Assert.Equal(SizedInternalFormat.R8, info.SizedInternalFormat);
|
||||
Assert.False(info.IsCompressed);
|
||||
Assert.Equal(1, info.LayerByteCount(1, 1));
|
||||
Assert.Equal(64, info.LayerByteCount(8, 8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bc1IsCompressedFourByFourEightByteBlocks() =>
|
||||
AssertBcFormat(GpuTextureFormat.Bc1Unorm, expectedBlockBytes: 8);
|
||||
|
||||
[Fact]
|
||||
public void Bc2IsCompressedFourByFourSixteenByteBlocks() =>
|
||||
AssertBcFormat(GpuTextureFormat.Bc2Unorm, expectedBlockBytes: 16);
|
||||
|
||||
[Fact]
|
||||
public void Bc3IsCompressedFourByFourSixteenByteBlocks() =>
|
||||
AssertBcFormat(GpuTextureFormat.Bc3Unorm, expectedBlockBytes: 16);
|
||||
|
||||
private static void AssertBcFormat(GpuTextureFormat format, int expectedBlockBytes)
|
||||
{
|
||||
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(format);
|
||||
Assert.True(info.IsCompressed);
|
||||
Assert.Equal(4, info.BlockDimension);
|
||||
Assert.Equal(expectedBlockBytes, info.BlockOrTexelBytes);
|
||||
|
||||
// A single 4x4 block for a 4x4 texture.
|
||||
Assert.Equal(expectedBlockBytes, info.LayerByteCount(4, 4));
|
||||
// Partial blocks round up: a 5x5 texture needs a 2x2 block grid.
|
||||
Assert.Equal(expectedBlockBytes * 4, info.LayerByteCount(5, 5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Depth24Stencil8IsAttachmentShapedNotCompressed()
|
||||
{
|
||||
GlTextureFormatInfo info = GlGpuTextureFormatMapping.Resolve(GpuTextureFormat.Depth24Stencil8);
|
||||
Assert.Equal(SizedInternalFormat.Depth24Stencil8, info.SizedInternalFormat);
|
||||
Assert.Equal(PixelFormat.DepthStencil, info.UploadPixelFormat);
|
||||
Assert.Equal(PixelType.UnsignedInt248, info.UploadPixelType);
|
||||
Assert.False(info.IsCompressed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryGpuTextureFormatValueResolvesWithoutThrowing()
|
||||
{
|
||||
foreach (GpuTextureFormat format in Enum.GetValues<GpuTextureFormat>())
|
||||
GlGpuTextureFormatMapping.Resolve(format);
|
||||
}
|
||||
}
|
||||
123
tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs
Normal file
123
tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlGpuTimerPoolTests.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlGpuTimerPoolTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnsupportedPoolReturnsNoOpScopesAndNeverResolves()
|
||||
{
|
||||
var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: false);
|
||||
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.False(pool.TryResolve("world", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeNamesGetIndependentDoubleBufferedQueries()
|
||||
{
|
||||
var api = new FakeTimerQueryApi();
|
||||
var pool = new GlGpuTimerPool(api, isSupported: true);
|
||||
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
using (pool.BeginScope("ui"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.Equal(4, api.CreatedQueryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestingAScopeBeforeDisposingThePreviousOneThrows()
|
||||
{
|
||||
var pool = new GlGpuTimerPool(new FakeTimerQueryApi(), isSupported: true);
|
||||
pool.BeginScope("world");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => pool.BeginScope("ui"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AResolvedResultIsPromotedTheNextTimeTheSameScopeBegins()
|
||||
{
|
||||
var api = new FakeTimerQueryApi();
|
||||
var pool = new GlGpuTimerPool(api, isSupported: true);
|
||||
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
// No result available yet — the first slot hasn't been revisited.
|
||||
Assert.False(pool.TryResolve("world", out _));
|
||||
|
||||
api.MakeNextResultReady(milliseconds: 1.5);
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.True(pool.TryResolve("world", out double milliseconds));
|
||||
Assert.Equal(1.5, milliseconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeQueriesDeletesEveryCreatedQuery()
|
||||
{
|
||||
var api = new FakeTimerQueryApi();
|
||||
var pool = new GlGpuTimerPool(api, isSupported: true);
|
||||
using (pool.BeginScope("world"))
|
||||
{
|
||||
}
|
||||
|
||||
pool.DisposeQueries();
|
||||
|
||||
Assert.Equal(2, api.DeletedQueryCount);
|
||||
}
|
||||
|
||||
private sealed class FakeTimerQueryApi : IGlTimerQueryApi
|
||||
{
|
||||
private uint _nextQuery = 1;
|
||||
private bool _nextResultReady;
|
||||
private double _nextResultMilliseconds;
|
||||
|
||||
public int CreatedQueryCount { get; private set; }
|
||||
public int DeletedQueryCount { get; private set; }
|
||||
|
||||
public uint CreateQuery()
|
||||
{
|
||||
CreatedQueryCount++;
|
||||
return _nextQuery++;
|
||||
}
|
||||
|
||||
public void DeleteQuery(uint query) => DeletedQueryCount++;
|
||||
|
||||
public void Begin(uint query)
|
||||
{
|
||||
}
|
||||
|
||||
public void End()
|
||||
{
|
||||
}
|
||||
|
||||
public void MakeNextResultReady(double milliseconds)
|
||||
{
|
||||
_nextResultReady = true;
|
||||
_nextResultMilliseconds = milliseconds;
|
||||
}
|
||||
|
||||
public bool TryGetResult(uint query, out double milliseconds)
|
||||
{
|
||||
if (_nextResultReady)
|
||||
{
|
||||
milliseconds = _nextResultMilliseconds;
|
||||
_nextResultReady = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
milliseconds = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlPushConstantUniformNamesTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveryDocumentedFieldMapsToItsDocumentedUniformName()
|
||||
{
|
||||
Assert.Equal("uViewProjection", GlPushConstantUniformNames.ByFieldName["ViewProjection"]);
|
||||
Assert.Equal("uDrawIDOffset", GlPushConstantUniformNames.ByFieldName["DrawIdOffset"]);
|
||||
Assert.Equal("uLightingMode", GlPushConstantUniformNames.ByFieldName["LightingMode"]);
|
||||
Assert.Equal("uRenderPass", GlPushConstantUniformNames.ByFieldName["RenderPass"]);
|
||||
Assert.Equal("uLightDebug", GlPushConstantUniformNames.ByFieldName["LightDebug"]);
|
||||
Assert.Equal("uTextureIndexA", GlPushConstantUniformNames.ByFieldName["TextureIndexA"]);
|
||||
Assert.Equal("uTextureIndexB", GlPushConstantUniformNames.ByFieldName["TextureIndexB"]);
|
||||
Assert.Equal("uParamA", GlPushConstantUniformNames.ByFieldName["ParamA"]);
|
||||
Assert.Equal("uParamB", GlPushConstantUniformNames.ByFieldName["ParamB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableCoversEveryFieldOnTheSharedStructWithNoStaleEntries()
|
||||
{
|
||||
// This is the drift guard: if a later slice adds/removes/renames a
|
||||
// GpuPushConstants field without updating the mapping table, this
|
||||
// test fails instead of the GL backend silently skipping a uniform.
|
||||
GlPushConstantUniformNames.AssertMapsEveryField();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableHasExactlyNineEntries()
|
||||
{
|
||||
Assert.Equal(9, GlPushConstantUniformNames.ByFieldName.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlRenderStateCacheTests
|
||||
{
|
||||
private static GlRenderStateSnapshot Default(uint program = 1) => new(
|
||||
program,
|
||||
GpuBlendMode.None,
|
||||
DepthTest: true,
|
||||
DepthWrite: true,
|
||||
GpuCompareOp.LessOrEqual,
|
||||
GpuCullMode.Back,
|
||||
GpuFrontFace.CounterClockwise,
|
||||
AlphaToCoverage: false,
|
||||
ColorWrite: true);
|
||||
|
||||
[Fact]
|
||||
public void FirstApplyReportsEveryDimensionChanged()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
GlRenderStateChanges changes = cache.Apply(Default());
|
||||
Assert.Equal(GlRenderStateChanges.All, changes);
|
||||
Assert.True(changes.AnyChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReapplyingTheIdenticalSnapshotReportsNoChanges()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
GlRenderStateSnapshot snapshot = Default();
|
||||
cache.Apply(snapshot);
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(snapshot);
|
||||
|
||||
Assert.False(changes.AnyChange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingOnlyCullModeReportsOnlyThatDimension()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default());
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default() with { Cull = GpuCullMode.None });
|
||||
|
||||
Assert.True(changes.Cull);
|
||||
Assert.True(changes.AnyChange);
|
||||
Assert.False(changes.Program);
|
||||
Assert.False(changes.Blend);
|
||||
Assert.False(changes.DepthTest);
|
||||
Assert.False(changes.DepthWrite);
|
||||
Assert.False(changes.DepthCompare);
|
||||
Assert.False(changes.FrontFace);
|
||||
Assert.False(changes.AlphaToCoverage);
|
||||
Assert.False(changes.ColorWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingTheProgramReportsProgramChangedEvenWhenEveryOtherFieldMatches()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default(program: 1));
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default(program: 2));
|
||||
|
||||
Assert.True(changes.Program);
|
||||
Assert.False(changes.Blend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForcesTheNextApplyToReportEveryDimensionAgain()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
GlRenderStateSnapshot snapshot = Default();
|
||||
cache.Apply(snapshot);
|
||||
|
||||
cache.Reset();
|
||||
GlRenderStateChanges changes = cache.Apply(snapshot);
|
||||
|
||||
Assert.Equal(GlRenderStateChanges.All, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleDimensionsChangingAreAllReported()
|
||||
{
|
||||
var cache = new GlRenderStateCache();
|
||||
cache.Apply(Default());
|
||||
|
||||
GlRenderStateChanges changes = cache.Apply(Default() with
|
||||
{
|
||||
Blend = GpuBlendMode.StraightAlpha,
|
||||
DepthWrite = false,
|
||||
FrontFace = GpuFrontFace.Clockwise,
|
||||
});
|
||||
|
||||
Assert.True(changes.Blend);
|
||||
Assert.True(changes.DepthWrite);
|
||||
Assert.True(changes.FrontFace);
|
||||
Assert.False(changes.Cull);
|
||||
Assert.False(changes.DepthTest);
|
||||
Assert.False(changes.DepthCompare);
|
||||
Assert.False(changes.AlphaToCoverage);
|
||||
Assert.False(changes.ColorWrite);
|
||||
Assert.False(changes.Program);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlRingBufferStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void FirstAllocationStartsAtZero()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
uint offset = state.Allocate(64, alignmentBytes: 16);
|
||||
Assert.Equal(0u, offset);
|
||||
Assert.Equal(64u, state.AllocatedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubsequentAllocationsAlignUpToTheRequestedBoundary()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(12, alignmentBytes: 4);
|
||||
uint second = state.Allocate(64, alignmentBytes: 256);
|
||||
Assert.Equal(0u, second % 256);
|
||||
Assert.True(second >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllocationsMergeIntoOneDirtyRange()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(16, alignmentBytes: 4);
|
||||
state.Allocate(32, alignmentBytes: 4);
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.Equal(0, start);
|
||||
Assert.Equal(48, length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TakingTheDirtyRangeClearsItUntilTheNextWrite()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(16, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.Equal(0, start);
|
||||
Assert.Equal(0, length);
|
||||
Assert.False(state.HasDirtyBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WritesAfterAFlushExtendANewDirtyRangeWithoutRewindingTheCursor()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(16, alignmentBytes: 4);
|
||||
state.TakeDirtyRange();
|
||||
|
||||
uint secondOffset = state.Allocate(8, alignmentBytes: 4);
|
||||
Assert.Equal(16u, secondOffset);
|
||||
|
||||
(int start, int length) = state.TakeDirtyRange();
|
||||
Assert.Equal(16, start);
|
||||
Assert.Equal(8, length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetRewindsTheCursorAndClearsDirtyState()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(64, alignmentBytes: 4);
|
||||
|
||||
state.Reset();
|
||||
|
||||
Assert.Equal(0u, state.AllocatedBytes);
|
||||
Assert.False(state.HasDirtyBytes);
|
||||
Assert.Equal(0u, state.Allocate(4, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverCapacityRequestThrowsRatherThanTruncating()
|
||||
{
|
||||
var state = new GlRingBufferState(64);
|
||||
Assert.Throws<InvalidOperationException>(() => state.Allocate(128, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignmentPushingPastCapacityAlsoThrows()
|
||||
{
|
||||
var state = new GlRingBufferState(64);
|
||||
state.Allocate(60, alignmentBytes: 4);
|
||||
Assert.Throws<InvalidOperationException>(() => state.Allocate(8, alignmentBytes: 4));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 256u, 0u)]
|
||||
[InlineData(1u, 256u, 256u)]
|
||||
[InlineData(255u, 256u, 256u)]
|
||||
[InlineData(256u, 256u, 256u)]
|
||||
[InlineData(10u, 1u, 10u)]
|
||||
public void AlignUpMatchesStandardAlignmentArithmetic(uint value, uint alignment, uint expected)
|
||||
{
|
||||
Assert.Equal(expected, GlRingBufferState.AlignUp(value, alignment));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroByteAllocationDoesNotMarkAnythingDirty()
|
||||
{
|
||||
var state = new GlRingBufferState(1024);
|
||||
state.Allocate(0, alignmentBytes: 4);
|
||||
Assert.False(state.HasDirtyBytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using AcDream.App.Rendering.Gpu.Gl;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
|
||||
|
||||
public sealed class GlTextureSlotAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllocationsBumpSequentiallyFromZero()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 4);
|
||||
Assert.Equal(0u, allocator.Allocate());
|
||||
Assert.Equal(1u, allocator.Allocate());
|
||||
Assert.Equal(2u, allocator.Allocate());
|
||||
Assert.Equal(3, allocator.LiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExhaustingCapacityThrows()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 2);
|
||||
allocator.Allocate();
|
||||
allocator.Allocate();
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.Allocate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasedSlotsAreReusedBeforeBumpingFurther()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 4);
|
||||
uint first = allocator.Allocate();
|
||||
allocator.Allocate();
|
||||
|
||||
allocator.Release(first);
|
||||
Assert.Equal(1, allocator.LiveCount);
|
||||
|
||||
uint reused = allocator.Allocate();
|
||||
Assert.Equal(first, reused);
|
||||
Assert.Equal(2, allocator.LiveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasingAnUnallocatedSlotThrows()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 4);
|
||||
allocator.Allocate();
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => allocator.Release(3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeingThenExhaustingTheBumpRangeStillThrowsPastCapacity()
|
||||
{
|
||||
var allocator = new GlTextureSlotAllocator(capacity: 2);
|
||||
uint first = allocator.Allocate();
|
||||
allocator.Allocate();
|
||||
allocator.Release(first);
|
||||
allocator.Allocate();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.Allocate());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue