acdream/src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Erik 8a7a0837e1 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>
2026-07-29 02:19:53 +02:00

251 lines
9.8 KiB
C#

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace AcDream.App.Diagnostics;
/// <summary>
/// Render-thread owner for diagnostic captures of the complete default
/// framebuffer. Requests may be made while retained UI ticks; capture occurs
/// later in the same frame after world, retained UI, and optional ImGui draw.
/// </summary>
internal sealed class FrameScreenshotController
{
private enum CaptureState
{
Pending,
Complete,
Failed,
}
private sealed record CaptureStatus(CaptureState State, string? Error = null);
private readonly Func<int, int, byte[]> _readRgba;
private readonly string _directory;
private readonly Action<string> _log;
private readonly Queue<string> _pending = new();
private readonly Dictionary<string, CaptureStatus> _status =
new(StringComparer.OrdinalIgnoreCase);
internal FrameScreenshotController(
Func<int, int, byte[]> readRgba,
string directory,
Action<string>? log = null)
{
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
_directory = string.IsNullOrWhiteSpace(directory)
? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
: Path.GetFullPath(directory);
_log = log ?? (_ => { });
}
public bool TryRequest(string name, out string error)
{
if (!AutomationArtifactName.TryValidate(name, out error))
return false;
if (_status.TryGetValue(name, out CaptureStatus? status))
{
if (status.State != CaptureState.Failed)
return true;
error = status.Error ?? $"screenshot '{name}' failed";
return false;
}
_status.Add(name, new CaptureStatus(CaptureState.Pending));
_pending.Enqueue(name);
_log($"[world-gate] screenshot-request name={name}");
return true;
}
public bool IsComplete(string name) =>
_status.TryGetValue(name, out CaptureStatus? status)
&& status.State == CaptureState.Complete;
/// <summary>Captures at most one queued image on the current GL thread.</summary>
public bool CapturePending(int width, int height)
{
if (_pending.Count == 0)
return false;
string name = _pending.Dequeue();
try
{
if (width <= 0 || height <= 0)
throw new InvalidOperationException($"invalid framebuffer size {width}x{height}");
byte[] pixels = _readRgba(width, height);
int expected = checked(width * height * 4);
if (pixels.Length != expected)
throw new InvalidOperationException(
$"framebuffer read returned {pixels.Length} bytes; expected {expected}");
byte[] flipped = FlipRows(pixels, width, height);
Directory.CreateDirectory(_directory);
string path = Path.Combine(_directory, name + ".png");
string temporaryPath = path + ".tmp";
using (Image<Rgba32> image = Image.LoadPixelData<Rgba32>(flipped, width, height))
image.SaveAsPng(temporaryPath);
File.Move(temporaryPath, path, overwrite: true);
_status[name] = new CaptureStatus(CaptureState.Complete);
_log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}");
return true;
}
catch (Exception exception)
{
string message = $"screenshot '{name}' failed: {exception.Message}";
_status[name] = new CaptureStatus(CaptureState.Failed, message);
_log($"[world-gate] screenshot-failed name={name} error={exception.Message}");
return false;
}
}
internal static byte[] FlipRows(byte[] pixels, int width, int height)
{
int stride = checked(width * 4);
byte[] flipped = new byte[pixels.Length];
for (int row = 0; row < height; row++)
{
System.Buffer.BlockCopy(
pixels,
row * stride,
flipped,
(height - 1 - row) * stride,
stride);
}
return flipped;
}
/// <summary>
/// The GL surface state a default-framebuffer capture touches, isolated so
/// the bind/read/restore order is assertable without a GL context.
/// </summary>
internal interface IDefaultFramebufferSurface
{
/// <summary>The name currently bound to <c>GL_READ_FRAMEBUFFER</c>.</summary>
uint ReadFramebufferBinding { get; }
/// <summary>The name currently bound to <c>GL_DRAW_FRAMEBUFFER</c>.</summary>
uint DrawFramebufferBinding { get; }
/// <summary>
/// <c>GL_SAMPLES</c> for the default framebuffer. Queried with
/// framebuffer 0 bound to both targets, because the value is
/// framebuffer-dependent state and would otherwise report whichever
/// offscreen target the frame left bound.
/// </summary>
int DefaultFramebufferSamples { get; }
void BindReadFramebuffer(uint framebuffer);
void BindDrawFramebuffer(uint framebuffer);
/// <summary>
/// Creates a single-sampled RGBA8 colour framebuffer of the given size
/// and returns its name.
/// </summary>
uint CreateResolveTarget(int width, int height);
void DeleteResolveTarget(uint framebuffer);
/// <summary>
/// Blits the whole colour buffer from the bound read framebuffer to the
/// bound draw framebuffer with <c>GL_NEAREST</c> and identical rectangles
/// — the multisample resolve.
/// </summary>
void BlitColorNearest(int width, int height);
void ReadRgba(int width, int height, byte[] destination);
}
/// <summary>
/// Reads the default framebuffer — framebuffer name 0, the backbuffer —
/// and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// <c>glReadPixels</c> reads whatever is bound to <c>GL_READ_FRAMEBUFFER</c>,
/// so a capture that does not name its source silently captures whichever
/// offscreen target the previous renderer left bound. The frame this runs in
/// draws several: <c>PrivateEntityViewportRenderer</c> clears its paperdoll
/// and appraisal FBOs to exactly RGBA(0,0,0,0), which is what a leaked
/// binding writes to disk — a fully transparent PNG that reads as a
/// blank-world failure while the backbuffer on screen was correct.
/// </para>
/// <para>
/// This was latent for as long as something else rebound framebuffer 0 often
/// enough to mask it (before Campaign V slice V4c, GL <c>BeginPass</c> did so
/// on every pass — see plan §5.4). The capture states its own source instead
/// of inheriting one, and restores the caller's binding so a diagnostic
/// capture cannot perturb the frame it observes.
/// </para>
/// <para>
/// <b>Multisampling.</b> The window is created with the quality preset's
/// MSAA sample count, so the default framebuffer is normally 4x multisampled,
/// and <c>glReadPixels</c> against a multisampled read framebuffer is
/// <i>undefined</i> per the GL spec (GL 4.6 §18.2: an INVALID_OPERATION is
/// generated only for framebuffer objects; for the default framebuffer the
/// result is simply unspecified, and AMD returns real pixels most of the time
/// and something else the rest). Every automated pixel gate and every blank-
/// world verdict in Campaign V reads through here, so an unspecified read is
/// an unsound instrument, not a cosmetic issue. When the default framebuffer
/// is multisampled the capture resolves it first — blit the whole colour
/// buffer into a single-sampled RGBA8 framebuffer with identical rectangles
/// and <c>GL_NEAREST</c>, which is the defined resolve — and reads that.
/// A single-sampled default framebuffer keeps the original direct read, so
/// non-MSAA captures stay byte-for-byte what they were.
/// </para>
/// <para>
/// The resolve target is created and destroyed per capture rather than
/// cached: captures are rare (a handful per gate run), and a cache would have
/// to track window resizes and context teardown for no measurable gain.
/// </para>
/// </remarks>
internal static byte[] ReadDefaultFramebuffer(
IDefaultFramebufferSurface surface,
int width,
int height)
{
ArgumentNullException.ThrowIfNull(surface);
byte[] pixels = new byte[checked(width * height * 4)];
uint previousRead = surface.ReadFramebufferBinding;
uint previousDraw = surface.DrawFramebufferBinding;
surface.BindReadFramebuffer(0u);
surface.BindDrawFramebuffer(0u);
try
{
if (surface.DefaultFramebufferSamples > 1)
ResolveThenRead(surface, width, height, pixels);
else
surface.ReadRgba(width, height, pixels);
}
finally
{
surface.BindReadFramebuffer(previousRead);
surface.BindDrawFramebuffer(previousDraw);
}
return pixels;
}
private static void ResolveThenRead(
IDefaultFramebufferSurface surface,
int width,
int height,
byte[] pixels)
{
uint resolve = surface.CreateResolveTarget(width, height);
try
{
// Read is still framebuffer 0 — the multisampled source.
surface.BindDrawFramebuffer(resolve);
surface.BlitColorNearest(width, height);
surface.BindReadFramebuffer(resolve);
surface.ReadRgba(width, height, pixels);
}
finally
{
surface.DeleteResolveTarget(resolve);
}
}
}