acdream/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs
Erik 096dd203fa feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache UI path onto IGpuDevice
Second attempt at V4a after ceec3bc4 was reverted at 9aaf97e7 for losing world
multisampling and a 334-file scope explosion. This lands the same functional
slice with a much smaller footprint and the two structural fixes the revert
postmortem (docs/plans/2026-07-27-vulkan-campaign.md SS7.1) called for.

What moved onto the RHI:
- TextRenderer: the ui_text shader now compiles through IGpuDevice.CreatePipeline
  (one IGpuPipeline, replacing the old hand-rolled Shader class); its three
  fence-buffered per-flight VBOs are gone in favour of a per-IGpuFrame ring
  allocation per draw bucket; its 1x1 white fill texture is created via
  IGpuDevice.CreateTexture and registered into the device's texture table.
  Flush keeps TextRenderGlStateScope and the manual GL disable block verbatim
  (TextRendererFailureSafetyTests pins their literal presence) alongside the
  new pipeline bind - both target the identical final GL state, so this is
  redundant, not contradictory. Sprite/font texture binding stays classic
  (glActiveTexture/glBindTexture) because DrawSprite receives arbitrary
  externally-owned GL texture names from dozens of UI call sites outside this
  slice's scope; IGpuPassEncoder has no verb for that, by design (every other
  RHI consumer samples through the bindless texture table).
- BitmapFont: the stb-baked R8 atlas is created/uploaded through
  IGpuDevice.CreateTexture; TextureId stays a raw GL name extracted from the
  IGpuTexture, since its only consumer is TextRenderer's classic path above.
- DebugLineRenderer: the debug_line shader compiles through
  IGpuDevice.CreatePipeline (LineList topology, depth disabled); Flush ring-
  allocates its vertex data and draws through IGpuPassEncoder. uView/uProjection
  don't fit the shared GpuPushConstants block (one combined VP matrix) so they
  are set directly on the pipeline's compiled program, mirroring TextRenderer.
- TextureCache: GetOrUploadRenderSurface and the public UploadRgba8(byte[],...)
  wrapper now create IGpuTexture+GpuTextureSlot internally, extracting the raw
  GL name for their unchanged uint return type - DrawSprite's signature and its
  16 call sites across the UI are untouched. The world-material path
  (GetOrUpload, the raw layer-array upload) is untouched.
- UiViewport: TextureHandle (uint) -> TextureSlot (GpuTextureSlot), resolved
  back to a raw GL name via TextRenderer.ResolveExternalTextureSlot at draw
  time. Its texture is produced by PaperdollViewportRenderer/
  PrivateEntityViewportRenderer, both still raw GL until V4g, so
  RetailPaperdollFrameView/RetailCreatureAppraisalFrameView register it through
  the pre-approved GlGpuDevice.RegisterExternalColorTexture transitional seam
  (campaign doc SS7.1's final paragraph) instead of inventing anything broader.

The two revert-postmortem fixes, both in Gpu/Gl (never in the pinned Gpu/
contract):
- GlGpuDevice.BeginPass now resets the render-state cache unconditionally on
  every pass, not only a clearing one. The first attempt's crash came from
  exactly this gap: a raw-GL renderer running between two RHI passes changes
  GL program/blend/depth/cull state the cache never observes, so a later
  BindPipeline skipped re-issuing glUseProgram and the following push-constant
  upload threw GL_INVALID_OPERATION.
- GlGpuPassEncoder now captures ambient GL capability state (program, VAO,
  array buffer, texture0 binding, depth test/write/func, blend enable+func,
  cull enable+mode, front face, alpha-to-coverage, multisample) on construction
  and restores it on Dispose, generalizing what TextRenderGlStateScope already
  did for TextRenderer specifically to every RHI pass - this is what stops
  DebugLineRenderer's pipeline bind (which has no scope of its own) from
  leaking state into the next raw-GL renderer. Both are marked transitional,
  deleted at V4h once nothing raw-GL remains.

Frame lifecycle (additive, per the task's own description of this piece):
new GpuDeviceFrameLifetime wraps IGpuDevice.BeginFrame()/IGpuFrame.End() and
exposes the open frame via ICurrentGpuFrameSource. RenderFrameOrchestrator's
IRenderFrameLifetime now routes through this wrapper instead of calling
GpuFrameFlightController directly - GlGpuDevice.BeginFrame already calls
straight through to that same controller, so the fence/slot-rotation contract
is unchanged; the wrapper only additionally yields the IGpuFrame ported
renderers need. No clears moved, no framebuffer binding changed, frame-graph
phase order is untouched. The two now-dead per-slot TextRenderer.BeginFrame(int)
calls in RuntimeRenderFrameBeginResources are removed. The UI Studio
(RenderBootstrap/StudioWindow) gets its own independent RHI device+lifetime,
mirroring the production composition.

Real bug found and fixed while exercising this for the first time: both
BitmapFont and TextureCache's nearest-filter override called TexParameter
AFTER RegisterTexture, which made the bindless handle resident - GL_ARB_
bindless_texture forbids modifying a texture's parameters once its handle is
resident, so this threw GL_INVALID_OPERATION building the retained UI's own
TextRenderer. Fixed by moving both TexParameter blocks before RegisterTexture.

Scope note: touches 25 files (24 modified + this commit's one new file), not
the ~10 the brief estimated, because the frame-lifecycle wiring and the
viewport escape hatch (both explicitly asked for) ripple through five
composition files and two frame presenters that thread IGpuDevice/
ICurrentGpuFrameSource to construction sites. No file outside that necessary
set was touched: no visibility sweep beyond the specific constructors/
properties whose new parameter types are internal (TextRenderer/BitmapFont/
DebugLineRenderer/UiHost's constructors, TextureCache's otherwise-orphaned
convenience overload, UiViewport.TextureSlot), no world-mesh/terrain/particle/
sky file touched, no test deleted or weakened - three source-text conformance
tests (TextRendererPublishesEveryConstructorResourceBeforeLaterGlWork,
GlTextureOwnershipTests' TextRenderer.cs check, and
RenderFrameResourceControllerTests' frame-order check) were replaced with
equivalent assertions against the new construction/wiring shape, since their
pinned invariant was specifically the old raw-GL shape this slice legitimately
replaces.

Gates:
- dotnet build -c Release: 0 warnings, 0 errors.
- dotnet test tests/AcDream.App.Tests -c Release: 3,843 passed / 3 skipped -
  exactly the baseline. Complete solution: 8,906 passed / 5 skipped across all
  nine test projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent a97e04ae vs this
  commit): 26 differing pixels of 563,200 compared (fraction 4.62e-05), pass
  against the 0.001/563-pixel threshold. Verified against a same-commit control
  (two captures at this commit differ by 20 pixels) rather than accepted at
  face value - the two numbers are in the same band, confirming this is normal
  animated-content/frame-pacing noise and not the systematic silhouette-edge
  loss (1,791 pixels, 224x higher) the first attempt's revert diagnosed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:37:19 +02:00

449 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 GpuDeviceFrameLifetime? GpuFrameLifetime { 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 PublishGpuFrameLifetime(GpuDeviceFrameLifetime value) =>
GpuFrameLifetime = PublishOnce(GpuFrameLifetime, 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.");
}
}