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>
251 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|
|
|
|
}
|