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>
445 lines
16 KiB
C#
445 lines
16 KiB
C#
using System.Numerics;
|
|
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;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Tests.Composition;
|
|
|
|
public sealed class HostInputCameraCompositionTests
|
|
{
|
|
[Fact]
|
|
public void ProductionPhasePreservesTheCompleteHostInputCameraOrder()
|
|
{
|
|
using var fixture = new Fixture();
|
|
|
|
HostInputCameraResult result = fixture.Phase().Compose(fixture.Platform);
|
|
|
|
Assert.Equal(
|
|
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);
|
|
Assert.Same(fixture.Publication.Camera, result.CameraController);
|
|
Assert.Same(fixture.Publication.Pointer, result.CameraPointerInput);
|
|
Assert.Equal(1280f / 720f, fixture.ViewportAspect.Aspect, precision: 5);
|
|
Assert.Equal((1280, 720), fixture.Factory.Viewport.Size);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(FaultPointValues))]
|
|
public void FailureAtEveryProductionBoundaryStopsTheExactSuffix(
|
|
int faultPointValue)
|
|
{
|
|
var faultPoint = (HostInputCameraCompositionPoint)faultPointValue;
|
|
using var fixture = new Fixture(faultPoint);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Phase().Compose(fixture.Platform));
|
|
|
|
HostInputCameraCompositionPoint[] expected =
|
|
Enum.GetValues<HostInputCameraCompositionPoint>()
|
|
.TakeWhile(point => point <= faultPoint)
|
|
.ToArray();
|
|
Assert.Equal(expected, fixture.Points);
|
|
fixture.Publication.AssertPublishedThrough(faultPoint);
|
|
}
|
|
|
|
public static TheoryData<int> FaultPointValues()
|
|
{
|
|
var data = new TheoryData<int>();
|
|
foreach (HostInputCameraCompositionPoint point in
|
|
Enum.GetValues<HostInputCameraCompositionPoint>())
|
|
{
|
|
data.Add((int)point);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindowUsesTheExactPlatformPreludeAndPhaseOneType()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"GameWindow.cs"));
|
|
|
|
Assert.Contains("GameWindowPlatformAcquisition.Acquire(", source,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("new HostInputCameraCompositionPhase(", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_gl = GL.GetApi(_window!)", source,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain("_input = _window!.CreateInput()", source,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
private sealed class Fixture : IDisposable
|
|
{
|
|
private readonly HostInputCameraCompositionPoint? _failure;
|
|
|
|
public Fixture(HostInputCameraCompositionPoint? failure = null)
|
|
{
|
|
_failure = failure;
|
|
IKeyboard keyboard = DispatchProxy.Create<IKeyboard, NullDeviceProxy>();
|
|
IMouse mouse = DispatchProxy.Create<IMouse, NullDeviceProxy>();
|
|
Input = new InputContext(keyboard, mouse);
|
|
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, Input);
|
|
ViewportAspect = new ViewportAspectState();
|
|
Framebuffer = new FramebufferResizeController(ViewportAspect);
|
|
Capture = new CaptureSource();
|
|
MovementState = new RuntimeLocalPlayerMovementState();
|
|
Movement = new DispatcherMovementInputSource(MovementState, Capture);
|
|
CameraInput = new DispatcherCameraInputSource();
|
|
PlayerMode = new LocalPlayerModeState();
|
|
Chase = new ChaseCameraInputState();
|
|
Pointer = new PointerPositionState();
|
|
Factory = new Factory();
|
|
Publication = new Publication();
|
|
}
|
|
|
|
public List<HostInputCameraCompositionPoint> Points { get; } = [];
|
|
public InputContext Input { get; }
|
|
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
|
|
public ViewportAspectState ViewportAspect { get; }
|
|
public FramebufferResizeController Framebuffer { get; }
|
|
public CaptureSource Capture { get; }
|
|
public RuntimeLocalPlayerMovementState MovementState { get; }
|
|
public DispatcherMovementInputSource Movement { get; }
|
|
public DispatcherCameraInputSource CameraInput { get; }
|
|
public LocalPlayerModeState PlayerMode { get; }
|
|
public ChaseCameraInputState Chase { get; }
|
|
public PointerPositionState Pointer { get; }
|
|
public Factory Factory { get; }
|
|
public Publication Publication { get; }
|
|
|
|
public HostInputCameraCompositionPhase Phase() => new(
|
|
new HostInputCameraDependencies(
|
|
Framebuffer,
|
|
new Vector2D<int>(1280, 720),
|
|
new HostQuiescenceGate(),
|
|
Capture,
|
|
KeyBindings.RetailDefaults(),
|
|
Movement,
|
|
CameraInput,
|
|
PlayerMode,
|
|
Chase,
|
|
Pointer,
|
|
new DiagnosticLog()),
|
|
Publication,
|
|
Factory,
|
|
point =>
|
|
{
|
|
Points.Add(point);
|
|
if (_failure == point)
|
|
throw new InvalidOperationException($"fault at {point}");
|
|
});
|
|
|
|
public void Dispose()
|
|
{
|
|
Publication.Dispose();
|
|
MovementState.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class Publication :
|
|
IGameWindowHostInputCameraPublication,
|
|
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; }
|
|
public InputDispatcher? Dispatcher { get; private set; }
|
|
public CameraController? Camera { get; private set; }
|
|
public CameraPointerInputController? Pointer { get; private set; }
|
|
|
|
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);
|
|
|
|
public void PublishMouseSource(SilkMouseSource value) =>
|
|
Mouse = PublishOnce(Mouse, value);
|
|
|
|
public void PublishMouseLookCursor(IMouseLookCursor value) =>
|
|
Cursor = PublishOnce(Cursor, value);
|
|
|
|
public void PublishInputDispatcher(InputDispatcher value) =>
|
|
Dispatcher = PublishOnce(Dispatcher, value);
|
|
|
|
public void PublishCameraController(CameraController value) =>
|
|
Camera = PublishOnce(Camera, value);
|
|
|
|
public void PublishCameraPointerInput(CameraPointerInputController value) =>
|
|
Pointer = PublishOnce(Pointer, value);
|
|
|
|
public void AssertPublishedThrough(HostInputCameraCompositionPoint point)
|
|
{
|
|
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);
|
|
Assert.Equal(
|
|
point >= HostInputCameraCompositionPoint.MousePublished,
|
|
Mouse is not null);
|
|
Assert.Equal(
|
|
point >= HostInputCameraCompositionPoint.MouseLookCursorPublished,
|
|
Cursor is not null);
|
|
Assert.Equal(
|
|
point >= HostInputCameraCompositionPoint.DispatcherPublished,
|
|
Dispatcher is not null);
|
|
Assert.Equal(
|
|
point >= HostInputCameraCompositionPoint.CameraPublished,
|
|
Camera is not null);
|
|
Assert.Equal(
|
|
point >= HostInputCameraCompositionPoint.CameraPointerPublished,
|
|
Pointer is not null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Pointer?.Dispose();
|
|
Pointer = null;
|
|
Dispatcher?.Dispose();
|
|
Dispatcher = null;
|
|
Mouse?.Dispose();
|
|
Mouse = null;
|
|
Keyboard?.Dispose();
|
|
Keyboard = null;
|
|
GpuDevice?.Dispose();
|
|
GpuDevice = null;
|
|
GpuFrames?.Dispose();
|
|
GpuFrames = null;
|
|
}
|
|
|
|
private static T PublishOnce<T>(T? current, T value)
|
|
where T : class
|
|
{
|
|
if (current is not null)
|
|
throw new InvalidOperationException("duplicate publication");
|
|
return value;
|
|
}
|
|
}
|
|
|
|
private sealed class Factory : IHostInputCameraCompositionFactory
|
|
{
|
|
public ViewportTarget Viewport { get; } = new();
|
|
private readonly KeyboardSurface _keyboard = new();
|
|
private readonly MouseSurface _mouse = new();
|
|
private readonly RawPointerSurface _rawPointer = new();
|
|
|
|
public IFramebufferViewportTarget CreateViewportTarget(GL gl) => Viewport;
|
|
|
|
public GpuFrameFlightController CreateGpuFrameFlights(GL gl) =>
|
|
new(new FenceApi());
|
|
|
|
public IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights) =>
|
|
new RecordingGpuDevice();
|
|
|
|
public WorldRenderDiagnostics CreateWorldRenderDiagnostics(
|
|
GL gl,
|
|
IRenderFrameDiagnosticLog log) =>
|
|
new(new GlStateReader(), log);
|
|
|
|
public SilkKeyboardSource CreateKeyboardSource(
|
|
IKeyboard keyboard,
|
|
HostQuiescenceGate quiescence) =>
|
|
SilkKeyboardSource.CreateDetached(_keyboard, quiescence);
|
|
|
|
public SilkMouseSource CreateMouseSource(
|
|
IMouse mouse,
|
|
IInputCaptureSource capture,
|
|
IKeyboardSource? keyboard,
|
|
HostQuiescenceGate quiescence) =>
|
|
SilkMouseSource.CreateDetached(_mouse, capture, keyboard, quiescence);
|
|
|
|
public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) => new Cursor();
|
|
|
|
public InputDispatcher CreateInputDispatcher(
|
|
IKeyboardSource keyboard,
|
|
IMouseSource mouse,
|
|
KeyBindings bindings) =>
|
|
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
|
|
|
|
public CameraController CreateCameraController() =>
|
|
new(new OrbitCamera(), new FlyCamera());
|
|
|
|
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
|
|
new CameraTarget(camera);
|
|
|
|
public CameraPointerInputController CreateCameraPointerInput(
|
|
IReadOnlyList<IMouse> mice,
|
|
HostQuiescenceGate quiescence,
|
|
IInputCaptureSource capture,
|
|
LocalPlayerModeState playerMode,
|
|
CameraController camera,
|
|
ChaseCameraInputState chase,
|
|
IMouseSource mouse,
|
|
PointerPositionState pointer) =>
|
|
new(
|
|
[_rawPointer],
|
|
new CursorModeTarget(),
|
|
quiescence,
|
|
capture,
|
|
playerMode,
|
|
camera,
|
|
chase,
|
|
mouse,
|
|
pointer,
|
|
new Clock());
|
|
}
|
|
|
|
private sealed class InputContext(IKeyboard keyboard, IMouse mouse)
|
|
: IInputContext
|
|
{
|
|
public nint Handle => 1;
|
|
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
|
|
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
|
|
public IReadOnlyList<IKeyboard> Keyboards { get; } = [keyboard];
|
|
public IReadOnlyList<IMouse> Mice { get; } = [mouse];
|
|
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
|
|
public event Action<IInputDevice, bool>? ConnectionChanged
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public void Dispose() { }
|
|
}
|
|
|
|
public class NullDeviceProxy : DispatchProxy
|
|
{
|
|
protected override object? Invoke(
|
|
MethodInfo? targetMethod,
|
|
object?[]? args)
|
|
{
|
|
Type returnType = targetMethod?.ReturnType ?? typeof(void);
|
|
if (returnType == typeof(void))
|
|
return null;
|
|
if (returnType == typeof(string))
|
|
return string.Empty;
|
|
return returnType.IsValueType
|
|
? Activator.CreateInstance(returnType)
|
|
: null;
|
|
}
|
|
}
|
|
|
|
private sealed class KeyboardSurface : IKeyboardEventSurface
|
|
{
|
|
public void AddKeyDown(Action<Key> callback) { }
|
|
public void RemoveKeyDown(Action<Key> callback) { }
|
|
public void AddKeyUp(Action<Key> callback) { }
|
|
public void RemoveKeyUp(Action<Key> callback) { }
|
|
public bool IsKeyPressed(Key key) => false;
|
|
}
|
|
|
|
private sealed class MouseSurface : IMouseEventSurface
|
|
{
|
|
public void AddMouseDown(Action<MouseButton> callback) { }
|
|
public void RemoveMouseDown(Action<MouseButton> callback) { }
|
|
public void AddMouseUp(Action<MouseButton> callback) { }
|
|
public void RemoveMouseUp(Action<MouseButton> callback) { }
|
|
public void AddMouseMove(Action<Vector2> callback) { }
|
|
public void RemoveMouseMove(Action<Vector2> callback) { }
|
|
public void AddScroll(Action<float> callback) { }
|
|
public void RemoveScroll(Action<float> callback) { }
|
|
public bool IsButtonPressed(MouseButton button) => false;
|
|
}
|
|
|
|
private sealed class RawPointerSurface : IRawPointerSurface
|
|
{
|
|
public void AddMouseMove(Action<Vector2> callback) { }
|
|
public void RemoveMouseMove(Action<Vector2> callback) { }
|
|
}
|
|
|
|
private sealed class CursorModeTarget : IPointerCursorModeTarget
|
|
{
|
|
public CursorMode CursorMode { get; set; }
|
|
}
|
|
|
|
private sealed class Cursor : IMouseLookCursor
|
|
{
|
|
public bool HasSavedMode { get; private set; }
|
|
public void Hide() => HasSavedMode = true;
|
|
public void Restore() => HasSavedMode = false;
|
|
}
|
|
|
|
private sealed class Clock : IInputMonotonicClock
|
|
{
|
|
public float NowSeconds => 0;
|
|
}
|
|
|
|
private sealed class CaptureSource : IInputCaptureSource
|
|
{
|
|
public bool WantCaptureMouse => false;
|
|
public bool WantCaptureKeyboard => false;
|
|
public bool DevToolsWantCaptureKeyboard => false;
|
|
}
|
|
|
|
private sealed class ViewportTarget : IFramebufferViewportTarget
|
|
{
|
|
public (int Width, int Height) Size { get; private set; }
|
|
public void ResizeViewport(int width, int height) => Size = (width, height);
|
|
}
|
|
|
|
private sealed class CameraTarget(CameraController camera)
|
|
: IFramebufferCameraTarget
|
|
{
|
|
public void SetAspect(float aspect) => camera.SetAspect(aspect);
|
|
}
|
|
|
|
private sealed class FenceApi : IGpuFenceApi
|
|
{
|
|
public nint Insert() => 1;
|
|
public GpuFenceWaitResult Wait(
|
|
nint fence,
|
|
bool flushCommands,
|
|
ulong timeoutNanoseconds) => GpuFenceWaitResult.Signaled;
|
|
public void Delete(nint fence) { }
|
|
}
|
|
|
|
private sealed class GlStateReader : IRenderGlStateReader
|
|
{
|
|
public RenderGlStateSnapshot CaptureState() => default;
|
|
public RenderGlScissorSnapshot CaptureScissor() => default;
|
|
}
|
|
|
|
private sealed class DiagnosticLog : IRenderFrameDiagnosticLog
|
|
{
|
|
public void WriteLine(string message) { }
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
|
while (directory is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
return directory.FullName;
|
|
directory = directory.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
|
}
|
|
}
|