acdream/src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
Erik 7a0227c12e feat(render): Vulkan campaign V11 step 3 — drop the GL packages and shaders
Commit 2 deleted the GL rendering backend's implementations; this step
removes the package references and shader vocabulary they leave behind,
so nothing in the App project still spells Silk.NET.OpenGL.

Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from
AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its
Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are
used directly and extensively across the Wb texture/mesh pipeline,
independent of the deleted GL IUniformBuffer implementers the package
comment used to cite. The stale comment is corrected in place.

IMeshPipelineDevice.Gl is removed along with the GL? gl parameter
threaded through WbMeshAdapter's four constructors, WorldRenderComposition's
CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null
implementation — nothing read any of them once the legacy per-mesh
upload bodies were gone (confirmed by grep: the sole non-doc-comment hit
was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed
a real bug along the way: its teardown still pattern-matched the deleted
GL GpuFrameFlightController to decide whether to wait for submitted work,
which VulkanFrameFlightController replaced at slice V6a without this site
being updated — so the wait had been silently dead on every Vulkan run
since then. Retargeted to VulkanFrameFlightController, which carries the
same WaitForSubmittedWork().

The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that
WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for
upload validation is replaced by AcDream.Content's existing Silk.NET-free
UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake
tool GL-free); two new members (Rgb, Red, Float) extend that enum with
their GL ABI constants to cover the full vocabulary WorldTextureArray
needs, since MP1a's original set only covered what the extractor itself
emits. ObjectMeshManager's App-boundary cast
`(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct
pass-through now that both sides share the type.

GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of
the Vulkan texture table) is deleted and StorageBindingCount drops from
10 to 9; the descriptor-set-layout code that builds from that count
(VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just
allocates one fewer always-dummy-seeded, always-unused binding.

Several fully dead GL-only classes came along for the ride, confirmed by
zero construction sites: SilkFramebufferViewportTarget
(NullFramebufferViewportTarget is the sole production
IFramebufferViewportTarget), SilkRenderGlStateReader
(NullRenderGlStateReader.Instance is the sole IRenderGlStateReader),
RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole
IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a
pass load-op instead), and GpuFrameTimer plus FrameProfiler's
GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame
bracket (RecordGpuSample is the only GPU-timing path any backend uses
now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no
longer applies, since WbDrawDispatcher's own diagnostic GPU sampling
already moved to the device's Vulkan timer pool). GpuFrameFlightController
itself stays (never constructed with a real fence API in production, but
its retirement-ledger/serial-ring logic is backend-neutral and still
covered by its own unit tests) — only its GL-specific parts (the public
GL constructor overload, SilkGpuFenceApi) are deleted, since removing the
whole class would mean restructuring the frozen Slice-8 composition
shape's GpuFrameFlightController? threading, which is out of this
commit's scope. TextureParameters.cs and BufferUsageExtensions.cs
(zero callers each) are deleted outright.

common.glsl is deleted: nothing in the actual Vulkan .spv build reads
it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair
directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own
complete self-contained preamble per file; common.glsl's textual
concatenation was exclusively Shader.cs's GL-only mechanism, deleted at
Commit 2. The five shader files that named it in comments
(mesh_modern.vert, particle.vert, particle.frag, sky.frag,
terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs
instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the
mandatory modern path already made unreachable, with zero C# consumers
and no compiled .spv — are deleted too. Regenerated via
tools/compile-shaders.ps1: 9/9 remaining shader pairs compile
(previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests
doc comment's "nine of ten are not Vulkan-expressible" was already
stale before this commit).

Test fallout: dead-subject test methods/files are deleted rather than
patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs,
GpuResourceRetirementTransactionTests.cs's GL queue tests, one
WorldRenderDiagnosticsTests source-order test, one
RenderFrameResourceControllerTests clear-phase-order test); tests whose
subject moved or was renamed are updated in place rather than deleted
(GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests'
pinned seven-member surface now reads six, ParticleBindlessInstanceTests'
cross-dialect check now covers the one surviving dialect,
WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was
always the parameter that actually threw).

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors,
with the Silk.NET.OpenGL/.Extensions.ARB package references physically
removed from the csproj (not just unreferenced in code).
Tests: full-solution `dotnet test` green across every project.
Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:58:15 +02:00

699 lines
32 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.App.Platform;
using Silk.NET.Vulkan;
namespace AcDream.App.Rendering.Gpu.Vk;
/// <summary>
/// Every Vulkan device feature the acdream renderer requires, with the reason
/// each one is mandatory recorded next to it (Campaign V plan §4.1).
///
/// This is a plain data record with no Silk handles in it, which is the point:
/// the interop layer fills it from <c>vkGetPhysicalDeviceFeatures2</c>, the
/// forced-unsupported gate knob clears one field of it, and
/// <see cref="VulkanCapabilityRequirements"/> turns it into operator-facing
/// sentences — all three testable with no driver, no device, and no window.
/// </summary>
internal sealed record VulkanDeviceFeatureSupport
{
// ---- core 1.0 ----
/// <summary>The three MDI dispatch sites are the entire draw architecture.</summary>
public required bool MultiDrawIndirect { get; init; }
/// <summary>Indirect commands carry a non-zero <c>firstInstance</c> as the per-group instance base.</summary>
public required bool DrawIndirectFirstInstance { get; init; }
/// <summary>Phase U.3's per-cell screen-space clip gate writes <c>gl_ClipDistance[8]</c>.</summary>
public required bool ShaderClipDistance { get; init; }
/// <summary>DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode.</summary>
public required bool TextureCompressionBc { get; init; }
/// <summary>Sampler-quality parity with the GL path.</summary>
public required bool SamplerAnisotropy { get; init; }
// ---- 1.1 ----
/// <summary><c>gl_DrawID</c>. Resets per indirect dispatch exactly as GL's does.</summary>
public required bool ShaderDrawParameters { get; init; }
// ---- 1.2 ----
/// <summary>One monotonic serial replaces the GL fence array; the retirement ledger keeps its keys.</summary>
public required bool TimelineSemaphore { get; init; }
/// <summary>Reset timestamp pools from the CPU instead of burning command-buffer calls.</summary>
public required bool HostQueryReset { get; init; }
/// <summary>The global texture table is a runtime-sized descriptor array.</summary>
public required bool RuntimeDescriptorArray { get; init; }
/// <summary>Unregistered table slots are legitimately absent rather than an error.</summary>
public required bool DescriptorBindingPartiallyBound { get; init; }
/// <summary>Texture registration appends a descriptor write without rebuilding the set.</summary>
public required bool DescriptorBindingSampledImageUpdateAfterBind { get; init; }
/// <summary>A slot may be rewritten while a command buffer that does not read it is pending.</summary>
public required bool DescriptorBindingUpdateUnusedWhilePending { get; init; }
/// <summary>The table's 16384-slot capacity is a variable descriptor count.</summary>
public required bool DescriptorBindingVariableDescriptorCount { get; init; }
/// <summary>
/// <c>nonuniformEXT</c> in the fragment shaders. Required, not optional:
/// within one MDI dispatch different draws read different <c>Batches[]</c>
/// entries, and "dynamically uniform" is defined over the whole dispatch on
/// some implementations (plan §4.6).
/// </summary>
public required bool ShaderSampledImageArrayNonUniformIndexing { get; init; }
// ---- 1.3 ----
/// <summary>No render-pass or framebuffer objects anywhere in the frame.</summary>
public required bool DynamicRendering { get; init; }
/// <summary>Every barrier in the frame skeleton is a <c>vkCmdPipelineBarrier2</c>.</summary>
public required bool Synchronization2 { get; init; }
/// <summary>Relaxed shader interface rules for the dual-legal GLSL sources.</summary>
public required bool Maintenance4 { get; init; }
/// <summary>
/// Every feature present. The starting point for the forced-unsupported gate
/// knob and for tests that assert one specific absence at a time.
/// </summary>
internal static VulkanDeviceFeatureSupport Complete { get; } = new()
{
MultiDrawIndirect = true,
DrawIndirectFirstInstance = true,
ShaderClipDistance = true,
TextureCompressionBc = true,
SamplerAnisotropy = true,
ShaderDrawParameters = true,
TimelineSemaphore = true,
HostQueryReset = true,
RuntimeDescriptorArray = true,
DescriptorBindingPartiallyBound = true,
DescriptorBindingSampledImageUpdateAfterBind = true,
DescriptorBindingUpdateUnusedWhilePending = true,
DescriptorBindingVariableDescriptorCount = true,
ShaderSampledImageArrayNonUniformIndexing = true,
DynamicRendering = true,
Synchronization2 = true,
Maintenance4 = true,
};
/// <summary>
/// Returns this record with the named feature cleared, or <c>null</c> when
/// the name matches no required feature. Drives
/// <c>ACDREAM_VULKAN_FORCE_UNSUPPORTED</c>; matching is case-insensitive on
/// the property name.
/// </summary>
internal VulkanDeviceFeatureSupport? Without(string featureName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(featureName);
return featureName.Trim() switch
{
var n when Is(n, nameof(MultiDrawIndirect)) => this with { MultiDrawIndirect = false },
var n when Is(n, nameof(DrawIndirectFirstInstance)) => this with { DrawIndirectFirstInstance = false },
var n when Is(n, nameof(ShaderClipDistance)) => this with { ShaderClipDistance = false },
var n when Is(n, nameof(TextureCompressionBc)) => this with { TextureCompressionBc = false },
var n when Is(n, nameof(SamplerAnisotropy)) => this with { SamplerAnisotropy = false },
var n when Is(n, nameof(ShaderDrawParameters)) => this with { ShaderDrawParameters = false },
var n when Is(n, nameof(TimelineSemaphore)) => this with { TimelineSemaphore = false },
var n when Is(n, nameof(HostQueryReset)) => this with { HostQueryReset = false },
var n when Is(n, nameof(RuntimeDescriptorArray)) => this with { RuntimeDescriptorArray = false },
var n when Is(n, nameof(DescriptorBindingPartiallyBound)) => this with { DescriptorBindingPartiallyBound = false },
var n when Is(n, nameof(DescriptorBindingSampledImageUpdateAfterBind)) => this with { DescriptorBindingSampledImageUpdateAfterBind = false },
var n when Is(n, nameof(DescriptorBindingUpdateUnusedWhilePending)) => this with { DescriptorBindingUpdateUnusedWhilePending = false },
var n when Is(n, nameof(DescriptorBindingVariableDescriptorCount)) => this with { DescriptorBindingVariableDescriptorCount = false },
var n when Is(n, nameof(ShaderSampledImageArrayNonUniformIndexing)) => this with { ShaderSampledImageArrayNonUniformIndexing = false },
var n when Is(n, nameof(DynamicRendering)) => this with { DynamicRendering = false },
var n when Is(n, nameof(Synchronization2)) => this with { Synchronization2 = false },
var n when Is(n, nameof(Maintenance4)) => this with { Maintenance4 = false },
_ => null,
};
static bool Is(string candidate, string name)
=> string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase);
}
}
/// <summary>
/// The device limits the plan asserts up front rather than discovering at draw
/// time (plan §4.1, §3.4).
/// </summary>
internal sealed record VulkanDeviceLimitSupport
{
/// <summary>Must reach <see cref="GpuBindingModel.PushConstantBytes"/>; Vulkan guarantees 128.</summary>
public required uint MaxPushConstantsSize { get; init; }
/// <summary>Must reach <see cref="GpuBindingModel.ClipPlanesPerSlot"/>.</summary>
public required uint MaxClipDistances { get; init; }
/// <summary>Sets 0, 1 and 2 are all bound simultaneously, so at least 3.</summary>
public required uint MaxBoundDescriptorSets { get; init; }
/// <summary>
/// Must reach <see cref="VulkanPipelineLayouts.DynamicStorageBindingCount"/>.
///
/// <para>Added at slice V6g. The V6b layout declared all ten of set 0's
/// bindings dynamic and was rejected by this limit on the RX 9070 XT, which
/// allows 8 — silently, at <c>vkCreatePipelineLayout</c>, and only visible
/// with the validation layer loaded (plan §5.5.7 defect 1). Reading the limit
/// into the record means the gate rejects such a device at startup, in the
/// report, under the same exit-code-4 contract as every other requirement,
/// rather than the layout failing at the first pipeline.</para>
/// </summary>
public required uint MaxDescriptorSetStorageBuffersDynamic { get; init; }
/// <summary>Must reach the number of dynamic uniform bindings set 1 declares.</summary>
public required uint MaxDescriptorSetUniformBuffersDynamic { get; init; }
/// <summary>Must reach <see cref="GpuBindingModel.TextureTableCapacity"/>.</summary>
public required uint MaxDescriptorSetUpdateAfterBindSampledImages { get; init; }
/// <summary>Must reach <see cref="GpuBindingModel.TextureTableCapacity"/> for the fragment stage.</summary>
public required uint MaxPerStageDescriptorUpdateAfterBindSampledImages { get; init; }
/// <summary>The frame profiler's GPU timings need graphics-queue timestamps.</summary>
public required bool TimestampComputeAndGraphics { get; init; }
/// <summary>Ring allocations must satisfy this; getting it wrong is a driver error on Vulkan.</summary>
public required uint MinStorageBufferOffsetAlignment { get; init; }
/// <summary>As above, for the SceneLighting uniform block.</summary>
public required uint MinUniformBufferOffsetAlignment { get; init; }
/// <summary>Largest 2D image edge; the terrain atlas and composite arrays are sized against it.</summary>
public required uint MaxImageDimension2D { get; init; }
/// <summary>Highest colour sample count the framebuffer supports, as a plain count (1/2/4/8...).</summary>
public required uint MaxColorSampleCount { get; init; }
/// <summary>
/// A profile that satisfies every requirement, used as the base for tests
/// and for the forced-unsupported knob. The numbers are the Vulkan 1.3
/// guaranteed minimums where a guarantee exists, and the acdream requirement
/// where it does not.
/// </summary>
internal static VulkanDeviceLimitSupport Complete { get; } = new()
{
MaxPushConstantsSize = GpuBindingModel.MaxPushConstantBytes,
MaxClipDistances = GpuBindingModel.ClipPlanesPerSlot,
MaxBoundDescriptorSets = 4,
// Vulkan's guaranteed minimums. That the layout fits inside them is the
// point of slice V6g's split — see VulkanPipelineLayouts.
MaxDescriptorSetStorageBuffersDynamic = 4,
MaxDescriptorSetUniformBuffersDynamic = 8,
MaxDescriptorSetUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
MaxPerStageDescriptorUpdateAfterBindSampledImages = GpuBindingModel.TextureTableCapacity,
TimestampComputeAndGraphics = true,
MinStorageBufferOffsetAlignment = 256,
MinUniformBufferOffsetAlignment = 256,
MaxImageDimension2D = 16384,
MaxColorSampleCount = 8,
};
}
/// <summary>
/// Format-level support that features alone do not prove: the swapchain colour
/// format, a depth+stencil format for #117's portal punch, and the three BC
/// blocks the DAT surfaces upload as.
/// </summary>
internal sealed record VulkanFormatSupport
{
/// <summary>
/// <c>B8G8R8A8_UNORM</c> is offered by the surface. Corrected at slice V3:
/// the renderer is plain UNORM end to end and an <c>_SRGB</c> swapchain
/// would apply an unwanted encode to already-display-space values.
/// </summary>
public required bool SwapchainUnormFormat { get; init; }
/// <summary>The chosen depth+stencil format, or <see cref="Format.Undefined"/> when none is usable.</summary>
public required Format DepthStencilFormat { get; init; }
/// <summary>BC1 (DXT1) sampled-image support with optimal tiling.</summary>
public required bool Bc1Sampled { get; init; }
/// <summary>BC2 (DXT3) sampled-image support with optimal tiling.</summary>
public required bool Bc2Sampled { get; init; }
/// <summary>BC3 (DXT5) sampled-image support with optimal tiling.</summary>
public required bool Bc3Sampled { get; init; }
internal static VulkanFormatSupport Complete { get; } = new()
{
SwapchainUnormFormat = true,
DepthStencilFormat = Format.D32SfloatS8Uint,
Bc1Sampled = true,
Bc2Sampled = true,
Bc3Sampled = true,
};
}
/// <summary>
/// What the presentation surface offers, and what slice V5 selected from it.
/// Null on the record when the device was probed headlessly (no window), which
/// is how the capability logic stays testable without opening one.
/// </summary>
internal sealed record VulkanSurfaceSupport(
bool PresentSupported,
Format SelectedFormat,
ColorSpaceKHR SelectedColorSpace,
PresentModeKHR SelectedPresentMode,
uint SelectedImageCount,
uint SelectedWidth,
uint SelectedHeight,
bool SupportsTransferSource,
IReadOnlyList<Format> AvailableFormats,
IReadOnlyList<PresentModeKHR> AvailablePresentModes);
/// <summary>
/// One physical device as the selector sees it. Ordering and tie-breaking are
/// computed from these fields alone (plan §4.11), so the ranking is unit-tested
/// without enumerating a real instance.
/// </summary>
internal sealed record VulkanPhysicalDeviceCandidate(
int Index,
string DeviceName,
PhysicalDeviceType DeviceType,
uint ApiVersion,
uint DriverVersion,
uint VendorId,
uint DeviceId,
ulong DeviceLocalHeapBytes);
/// <summary>
/// Result of the active Vulkan probe. Mirrors
/// <c>GraphicalFunctionProbeResult</c>: advertisement is not evidence, so the
/// probe really creates the device, the descriptor layouts, an offscreen target,
/// and a submitted command buffer, then reads pixels back.
/// </summary>
internal sealed record VulkanFunctionProbeResult(
bool DeviceCreation,
bool DescriptorIndexingLayout,
bool PushConstantLayout,
bool DynamicRenderingClear,
bool TimelineSemaphoreWait,
bool HostQueryReset,
bool OffscreenReadback,
IReadOnlyList<string> Failures)
{
internal static VulkanFunctionProbeResult NotRun { get; } = new(
false,
false,
false,
false,
false,
false,
false,
["active Vulkan device probe did not run"]);
}
/// <summary>
/// Campaign V slice V5 — the Vulkan sibling of
/// <c>GraphicalCapabilityRecord</c>. Passive capture plus the active probe
/// result plus the derived failure list, written atomically to
/// <c>graphical-capabilities-vulkan.json</c> and turned into the same
/// <see cref="NotSupportedException"/> → exit-code-4 contract.
/// </summary>
internal sealed record VulkanCapabilityRecord(
DateTimeOffset CapturedAtUtc,
string RuntimeIdentifier,
GraphicalHostOperatingSystem OperatingSystem,
GraphicalDisplayProtocol RequestedDisplayProtocol,
GraphicalDisplayProtocol ActiveDisplayProtocol,
string InstanceApiVersion,
string DeviceApiVersion,
uint DeviceApiVersionPacked,
string DeviceName,
string DriverInfo,
PhysicalDeviceType DeviceType,
int SelectedDeviceIndex,
string DeviceSelectionReason,
string? RequestedDeviceOverride,
string? ForcedUnsupportedFeature,
IReadOnlyList<VulkanPhysicalDeviceCandidate> AvailableDevices,
IReadOnlyList<string> InstanceExtensions,
IReadOnlyList<string> DeviceExtensions,
uint GraphicsQueueFamily,
uint PresentQueueFamily,
VulkanDeviceFeatureSupport Features,
VulkanDeviceLimitSupport Limits,
VulkanFormatSupport Formats,
VulkanSurfaceSupport? Surface,
VulkanFunctionProbeResult FunctionProbe,
IReadOnlyList<string> SupportFailures)
{
internal bool IsSupported => SupportFailures.Count == 0;
/// <summary>
/// Project onto the backend-neutral contract the renderers consult. The
/// alignment fields carry across verbatim because ring allocations are
/// validated against them, and <c>SupportsPersistentlyMappedRings</c> is
/// unconditionally true: writing per-frame data straight into mapped memory
/// is the mechanism behind Campaign V's CPU-cost target (plan §4.3).
/// </summary>
internal GpuCapabilityRecord ToGpuCapabilityRecord() => new()
{
Backend = GpuBackendKind.Vulkan,
DeviceName = DeviceName,
DriverInfo = DriverInfo,
ApiVersion = DeviceApiVersion,
MaxTextureTableSlots =
Math.Min(
Limits.MaxDescriptorSetUpdateAfterBindSampledImages,
Limits.MaxPerStageDescriptorUpdateAfterBindSampledImages),
// Sets 0..2 give each binding its own namespace, so the storage
// bindings the model declares (nine, since Campaign V slice V11
// deleted the GL-only StorageTextureTable binding) are always all
// available once the set count requirement passes. There is no
// per-set binding-count limit in Vulkan below
// maxPerStageDescriptorStorageBuffers, which is far higher.
MaxStorageBufferBindings = GpuBindingModel.StorageBindingCount,
MaxPushConstantBytes = Limits.MaxPushConstantsSize,
MinStorageBufferOffsetAlignment = Limits.MinStorageBufferOffsetAlignment,
MinUniformBufferOffsetAlignment = Limits.MinUniformBufferOffsetAlignment,
MaxClipDistances = Limits.MaxClipDistances,
MaxSampleCount = Limits.MaxColorSampleCount,
SupportsMultiDrawIndirect = Features.MultiDrawIndirect,
SupportsDrawParameters = Features.ShaderDrawParameters,
SupportsTextureCompressionBc = Features.TextureCompressionBc,
SupportsTimestampQueries = Limits.TimestampComputeAndGraphics,
SupportsPersistentlyMappedRings = true,
};
}
/// <summary>
/// Turns a captured <see cref="VulkanCapabilityRecord"/> into operator-facing
/// failure sentences. Deliberately the exact shape of
/// <c>GraphicalCapabilityRequirements.Evaluate</c>, and deliberately NOT
/// carrying its stale sRGB-framebuffer requirement forward: slice V3 established
/// that the renderer never enables sRGB encoding anywhere (plan §4.10).
/// </summary>
internal static class VulkanCapabilityRequirements
{
/// <summary>Vulkan 1.3 is the floor; nothing below it is considered.</summary>
internal const uint RequiredApiMajor = 1;
/// <summary>Vulkan 1.3 is the floor; nothing below it is considered.</summary>
internal const uint RequiredApiMinor = 3;
internal static IReadOnlyList<string> Evaluate(VulkanCapabilityRecord capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
var failures = new List<string>();
uint major = VulkanApiVersion.Major(capabilities.DeviceApiVersionPacked);
uint minor = VulkanApiVersion.Minor(capabilities.DeviceApiVersionPacked);
if (major < RequiredApiMajor || (major == RequiredApiMajor && minor < RequiredApiMinor))
{
failures.Add(
$"Vulkan {RequiredApiMajor}.{RequiredApiMinor} is required; " +
$"the selected device reports {major}.{minor}.");
}
VulkanDeviceFeatureSupport features = capabilities.Features;
if (!features.MultiDrawIndirect)
failures.Add("multiDrawIndirect is required to submit world geometry.");
if (!features.DrawIndirectFirstInstance)
failures.Add("drawIndirectFirstInstance is required; indirect commands carry a per-group instance base.");
if (!features.ShaderDrawParameters)
failures.Add("shaderDrawParameters (gl_DrawID) is required to select per-draw batch data.");
if (!features.ShaderClipDistance)
failures.Add("shaderClipDistance is required by the per-cell clip gate.");
if (!features.TextureCompressionBc)
failures.Add("textureCompressionBC is required to upload DAT surfaces without transcoding.");
if (!features.SamplerAnisotropy)
failures.Add("samplerAnisotropy is required for sampler-quality parity.");
if (!features.TimelineSemaphore)
failures.Add("timelineSemaphore is required; the frame serial is the semaphore value.");
if (!features.HostQueryReset)
failures.Add("hostQueryReset is required to reset timestamp pools from the CPU.");
if (!features.RuntimeDescriptorArray)
failures.Add("runtimeDescriptorArray is required by the global texture table.");
if (!features.DescriptorBindingPartiallyBound)
failures.Add("descriptorBindingPartiallyBound is required; unregistered texture slots are legitimately absent.");
if (!features.DescriptorBindingSampledImageUpdateAfterBind)
failures.Add("descriptorBindingSampledImageUpdateAfterBind is required to register textures without rebuilding the set.");
if (!features.DescriptorBindingUpdateUnusedWhilePending)
failures.Add("descriptorBindingUpdateUnusedWhilePending is required to recycle texture slots while frames are in flight.");
if (!features.DescriptorBindingVariableDescriptorCount)
failures.Add("descriptorBindingVariableDescriptorCount is required to size the texture table.");
if (!features.ShaderSampledImageArrayNonUniformIndexing)
failures.Add("shaderSampledImageArrayNonUniformIndexing is required; one indirect dispatch reads different texture slots per draw.");
if (!features.DynamicRendering)
failures.Add("dynamicRendering is required; the frame uses no render-pass or framebuffer objects.");
if (!features.Synchronization2)
failures.Add("synchronization2 is required; every barrier in the frame is a barrier2.");
if (!features.Maintenance4)
failures.Add("maintenance4 is required for the relaxed shader interface rules the shared GLSL relies on.");
VulkanDeviceLimitSupport limits = capabilities.Limits;
if (limits.MaxPushConstantsSize < GpuBindingModel.PushConstantBytes)
{
failures.Add(
$"{GpuBindingModel.PushConstantBytes} push-constant bytes are required; " +
$"this device provides {limits.MaxPushConstantsSize}.");
}
if (limits.MaxClipDistances < GpuBindingModel.ClipPlanesPerSlot)
{
failures.Add(
$"{GpuBindingModel.ClipPlanesPerSlot} clip distances are required by the per-cell clip gate; " +
$"this device provides {limits.MaxClipDistances}.");
}
if (limits.MaxBoundDescriptorSets < VulkanDescriptorSetCount)
{
failures.Add(
$"{VulkanDescriptorSetCount} simultaneously bound descriptor sets are required " +
$"(storage, uniform, texture table); this device provides {limits.MaxBoundDescriptorSets}.");
}
if (limits.MaxDescriptorSetStorageBuffersDynamic < VulkanPipelineLayouts.DynamicStorageBindingCount)
{
failures.Add(
$"set 0 declares {VulkanPipelineLayouts.DynamicStorageBindingCount} dynamic storage bindings " +
$"(Vulkan guarantees 4); this device provides {limits.MaxDescriptorSetStorageBuffersDynamic}.");
}
if (limits.MaxDescriptorSetUniformBuffersDynamic < VulkanFrameBindings.DynamicUniformBindingCount)
{
failures.Add(
$"set 1 declares {VulkanFrameBindings.DynamicUniformBindingCount} dynamic uniform bindings " +
$"(Vulkan guarantees 8); this device provides {limits.MaxDescriptorSetUniformBuffersDynamic}.");
}
if (limits.MaxDescriptorSetUpdateAfterBindSampledImages < GpuBindingModel.TextureTableCapacity)
{
failures.Add(
$"the texture table needs {GpuBindingModel.TextureTableCapacity} update-after-bind sampled images; " +
$"this device provides {limits.MaxDescriptorSetUpdateAfterBindSampledImages} per set.");
}
if (limits.MaxPerStageDescriptorUpdateAfterBindSampledImages < GpuBindingModel.TextureTableCapacity)
{
failures.Add(
$"the texture table needs {GpuBindingModel.TextureTableCapacity} update-after-bind sampled images " +
$"in the fragment stage; this device provides {limits.MaxPerStageDescriptorUpdateAfterBindSampledImages}.");
}
if (!limits.TimestampComputeAndGraphics)
failures.Add("graphics-queue timestamps are required by the frame profiler.");
VulkanFormatSupport formats = capabilities.Formats;
if (!formats.SwapchainUnormFormat)
{
failures.Add(
"the presentation surface must offer B8G8R8A8_UNORM; the renderer is " +
"plain UNORM end to end and an sRGB swapchain would re-encode every frame.");
}
if (formats.DepthStencilFormat == Format.Undefined)
failures.Add("a combined depth+stencil format is required by the portal aperture punch.");
if (!formats.Bc1Sampled || !formats.Bc2Sampled || !formats.Bc3Sampled)
failures.Add("BC1, BC2 and BC3 sampled-image support is required to upload DAT surfaces.");
if (capabilities.Surface is { } surface)
{
if (!surface.PresentSupported)
failures.Add("the selected device cannot present to the window surface.");
if (!surface.SupportsTransferSource)
failures.Add("the swapchain must support TRANSFER_SRC usage for screenshot capture.");
}
if (capabilities.FunctionProbe.Failures.Count != 0)
{
failures.AddRange(
capabilities.FunctionProbe.Failures.Select(
failure => $"Vulkan device probe: {failure}"));
}
else
{
if (!capabilities.FunctionProbe.DeviceCreation)
failures.Add("the Vulkan device-creation probe did not pass.");
if (!capabilities.FunctionProbe.DescriptorIndexingLayout)
failures.Add("the descriptor-indexing layout probe did not pass.");
if (!capabilities.FunctionProbe.PushConstantLayout)
failures.Add("the push-constant pipeline-layout probe did not pass.");
if (!capabilities.FunctionProbe.DynamicRenderingClear)
failures.Add("the dynamic-rendering clear probe did not pass.");
if (!capabilities.FunctionProbe.TimelineSemaphoreWait)
failures.Add("the timeline-semaphore wait probe did not pass.");
if (!capabilities.FunctionProbe.HostQueryReset)
failures.Add("the host query-reset probe did not pass.");
if (!capabilities.FunctionProbe.OffscreenReadback)
failures.Add("the offscreen readback probe did not return the expected pixels.");
}
return failures;
}
/// <summary>
/// Sets 0 (storage), 1 (uniform) and 2 (texture table) are bound at once,
/// so <c>maxBoundDescriptorSets</c> must reach 3. Vulkan guarantees 4.
/// </summary>
internal const uint VulkanDescriptorSetCount =
GpuBindingModel.TextureTableSet + 1;
/// <summary>
/// Re-derive <see cref="VulkanCapabilityRecord.SupportFailures"/> after any
/// mutation. Mirrors <c>GraphicalCapabilityProbe.WithFunctionProbe</c>: the
/// record is never trusted to carry a stale failure list.
/// </summary>
internal static VulkanCapabilityRecord Reevaluate(VulkanCapabilityRecord capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
VulkanCapabilityRecord cleared = capabilities with { SupportFailures = [] };
return cleared with { SupportFailures = Evaluate(cleared) };
}
/// <summary>
/// Apply <c>ACDREAM_VULKAN_FORCE_UNSUPPORTED</c>. An unrecognised name is a
/// hard failure rather than a silent no-op: a gate knob that quietly does
/// nothing would report a pass the operator did not actually get.
/// </summary>
internal static VulkanCapabilityRecord ApplyForcedUnsupported(
VulkanCapabilityRecord capabilities,
string? featureName)
{
ArgumentNullException.ThrowIfNull(capabilities);
if (string.IsNullOrWhiteSpace(featureName))
return capabilities;
VulkanDeviceFeatureSupport? forced = capabilities.Features.Without(featureName);
if (forced is null)
{
return Reevaluate(
capabilities with
{
ForcedUnsupportedFeature = featureName,
FunctionProbe = capabilities.FunctionProbe with
{
Failures =
[
.. capabilities.FunctionProbe.Failures,
$"ACDREAM_VULKAN_FORCE_UNSUPPORTED named '{featureName}', " +
"which is not a required Vulkan feature.",
],
},
});
}
return Reevaluate(
capabilities with
{
Features = forced,
ForcedUnsupportedFeature = featureName,
});
}
}
/// <summary>Packed <c>VK_MAKE_API_VERSION</c> arithmetic, kept out of the interop layer so it is testable.</summary>
internal static class VulkanApiVersion
{
internal static uint Major(uint packed) => (packed >> 22) & 0x7Fu;
internal static uint Minor(uint packed) => (packed >> 12) & 0x3FFu;
internal static uint Patch(uint packed) => packed & 0xFFFu;
internal static uint Make(uint major, uint minor, uint patch)
=> (major << 22) | (minor << 12) | patch;
internal static string Describe(uint packed)
=> $"Vulkan {Major(packed)}.{Minor(packed)}.{Patch(packed)}";
}
/// <summary>
/// The same throw/format/report contract the GL gate publishes: an unsupported
/// device raises <see cref="NotSupportedException"/>, which <c>Program.cs</c>
/// turns into exit code 4 next to the written report.
/// </summary>
internal static class VulkanCapabilityGuard
{
/// <summary>File name of the Vulkan report, beside the GL one in the diagnostics directory.</summary>
internal const string ReportFileName = "graphical-capabilities-vulkan.json";
internal static void ThrowIfUnsupported(
VulkanCapabilityRecord capabilities,
string reportPath)
{
ArgumentNullException.ThrowIfNull(capabilities);
if (!capabilities.IsSupported)
throw new NotSupportedException(FormatUnsupportedMessage(capabilities, reportPath));
}
internal static string FormatUnsupportedMessage(
VulkanCapabilityRecord capabilities,
string reportPath)
{
ArgumentNullException.ThrowIfNull(capabilities);
ArgumentException.ThrowIfNullOrWhiteSpace(reportPath);
return
"acdream's Vulkan renderer is unsupported by the selected device.\n" +
$"Platform: {capabilities.RuntimeIdentifier}, " +
$"{capabilities.ActiveDisplayProtocol}, " +
$"{capabilities.DeviceName} ({capabilities.DeviceType}), " +
$"{capabilities.DeviceApiVersion}, {capabilities.DriverInfo}\n" +
string.Join(
"\n",
capabilities.SupportFailures.Select(failure => $" - {failure}")) +
$"\nFull capability report: {Path.GetFullPath(reportPath)}";
}
}
/// <summary>
/// Atomic JSON writer, byte-for-byte the same temp-then-move contract as
/// <c>GraphicalCapabilityReportWriter</c> so a crashed launch never leaves a
/// half-written report behind.
/// </summary>
internal static class VulkanCapabilityReportWriter
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter(),
},
};
internal static void Write(string path, VulkanCapabilityRecord capabilities)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(capabilities);
string fullPath = Path.GetFullPath(path);
string? directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
string temporaryPath = fullPath + ".tmp";
File.WriteAllText(temporaryPath, Serialize(capabilities));
File.Move(temporaryPath, fullPath, overwrite: true);
}
/// <summary>Exposed so the report's shape can be asserted without touching the file system.</summary>
internal static string Serialize(VulkanCapabilityRecord capabilities)
{
ArgumentNullException.ThrowIfNull(capabilities);
return JsonSerializer.Serialize(capabilities, Options);
}
}