feat(render): Campaign V slice V5 - Vulkan bring-up, dark
Instance, physical-device selection, logical device, queues, swapchain, and the three-layer capability gate, behind ACDREAM_RENDER_BACKEND=vulkan. Nothing of the game renders through it. OpenGL stays the default and the only live backend until V10, and with the variable unset or set to gl the GL path executes not one new statement. The shape of the slice. Plan §4.11 asks the Vulkan gate to mirror the GL one exactly - passive record, active probes, an Evaluate producing operator-facing sentences, NotSupportedException into Program.cs's exit-code-4 contract, and an atomic JSON report. The harder question was where to put the seam, because a capability gate is precisely the code you cannot exercise on the machine that already passes it: this box has one discrete GPU, so device ranking, the split- queue path, an sRGB-only surface, a minimised window and a device missing descriptorBindingVariableDescriptorCount are all unreachable by running the client. So every decision the gate makes is a pure function over plain records, and the Silk interop layer only has to be right about which Vulkan field feeds which property. VulkanPhysicalDeviceSelection ranks candidates, VulkanExtensionSelection does the required-versus-optional set arithmetic, VulkanSwapchainConfigurationFactory chooses format, present mode, image count, extent, usage, transform and composite alpha, VulkanSwapchainRecreationPolicy classifies every acquire and present result, and VulkanCapabilityRequirements.Evaluate turns a captured record into failure sentences. All of it is unit-tested with no driver, no device and no window. This commit is the integration of that work onto the post-revert tree. The V5 branch was written onb064668b, before V4c/V4d were reverted, so GameWindow.cs had to be merged rather than taken: the file here is eb2ba4e5's GameWindow plus V5's fifteen-line backend branch, and it keeps _terrainModernShader, which the revert restored and which the V5 branch never had. Every other file is byte- identical to the branch - git diff e1ef4313 over Rendering/Gpu/Vk, tests/.../Gpu/Vk and RenderBackendKind.cs is empty, no BOM was introduced, and CRLF is uniform across all seventeen files. Gate results, recorded verbatim. Release build: succeeded, 0 warnings, 0 errors. App tests, Release: Failed 0, Passed 3981, Skipped 3, Total 3984 - the 3,866 baseline plus V5's 115 new tests, exactly. Offline pixel gate againsteb2ba4e5: PASS world-offline.png, differing fraction 1.06534090909091E-05, which is 6 differing pixels out of the 563,200 compared after the top 280 sky rows are masked. §5.1's re-measured same-commit control band is 15-23 pixels at fraction <= 4.1e-05, so this sits below the noise floor rather than merely inside it - the expected result for a slice that adds no statement to the GL path. Vulkan check (a), ACDREAM_RENDER_BACKEND=vulkan on the RX 9070 XT with an automation artifact directory: vulkan: capability gate passed (Windows, AMD Radeon RX 9070 XT, Vulkan 1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B)); swapchain B8G8R8A8Unorm/PresentModeImmediateKhr 1280x720 x3 vulkan: device selection - automatic: 'AMD Radeon RX 9070 XT' (DiscreteGpu, 15.92 GiB device-local) ranked first of 2 enumerated device(s). [world-gate] screenshot-complete name=vulkan-bringup path=... artifacts\vk-bringup\vulkan-bringup.png size=1280x720 vulkan: presented 64609 clear-colour frame(s); shutting down. Exit code 0 on CloseMainWindow. The PNG is 5,238 bytes, 1280x720, and uniformly RGBA(11,19,39,255) - exactly ClearColor [0.043, 0.075, 0.153, 1] scaled to UNORM. Orientation is right-side-up by construction rather than by inspection, which a uniform clear could not show: VulkanBackbufferSwizzle.ToGlOriginRgba writes source row y into destination row height-1-y precisely because FrameScreenshotController flips again on the way to the PNG, so the two cancel. That double-flip is unit-tested. Vulkan check (b), ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore: [ERR] acdream's Vulkan renderer is unsupported by the selected device. Platform: win-x64, Windows, AMD Radeon RX 9070 XT (DiscreteGpu), Vulkan 1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B) - timelineSemaphore is required; the frame serial is the semaphore value. Full capability report: ...\diagnostics\graphical-capabilities-vulkan.json Exit code 4. The report records ForcedUnsupportedFeature timelineSemaphore, TimelineSemaphore false against an otherwise complete feature set, and the matching SupportFailures sentence, so the injected rejection is distinguishable from a genuinely absent feature. Both enumerated devices, all five surface formats, all four present modes and a clean FunctionProbe with no failures are recorded beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
eb2ba4e5f0
commit
e8a4c1af3f
17 changed files with 5707 additions and 2 deletions
|
|
@ -28,6 +28,10 @@
|
|||
<PackageReference Include="Arch" Version="2.1.0" />
|
||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.OpenGL.Extensions.ARB" Version="2.23.0" />
|
||||
<!-- Campaign V slice V5: dark Vulkan bring-up. Pinned to the same 2.23.0 as
|
||||
the rest of the Silk family so the shared Silk.NET.Core does not fork. -->
|
||||
<PackageReference Include="Silk.NET.Vulkan" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.OpenAL" Version="2.23.0" />
|
||||
|
|
|
|||
25
src/AcDream.App/RenderBackendKind.cs
Normal file
25
src/AcDream.App/RenderBackendKind.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace AcDream.App;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5: which rendering backend the graphical host starts.
|
||||
///
|
||||
/// OpenGL is the default and, through slice V9, the only backend that renders
|
||||
/// the game. <see cref="Vulkan"/> is dark bring-up — it creates an instance, a
|
||||
/// device, a swapchain and runs the capability gate, and nothing more, until the
|
||||
/// Vulkan RHI backend lands at V6.
|
||||
///
|
||||
/// This enum is public only because <see cref="RuntimeOptions"/> is public and
|
||||
/// exposes it as a property. The backend-neutral
|
||||
/// <c>AcDream.App.Rendering.Gpu.GpuBackendKind</c> stays internal and describes a
|
||||
/// live device rather than a startup request; the two are deliberately separate
|
||||
/// because a Vulkan-requested process can still fail its capability gate and
|
||||
/// never own a Vulkan device at all.
|
||||
/// </summary>
|
||||
public enum RenderBackendKind
|
||||
{
|
||||
/// <summary>OpenGL 4.3 core + bindless + MDI. The default, and the only live backend.</summary>
|
||||
Gl,
|
||||
|
||||
/// <summary>Vulkan 1.3 core. Dark until Campaign V slice V10 flips the default.</summary>
|
||||
Vulkan,
|
||||
}
|
||||
|
|
@ -680,6 +680,21 @@ public sealed class GameWindow :
|
|||
// attribute, so it must come from this same snapshot rather than a
|
||||
// second settings load during OnLoad.
|
||||
RuntimeSettingsSnapshot startup = _runtimeSettings.Startup;
|
||||
if (_options.RenderBackend == RenderBackendKind.Vulkan)
|
||||
{
|
||||
// Campaign V slice V5 — dark Vulkan bring-up. Reached only when
|
||||
// ACDREAM_RENDER_BACKEND=vulkan; the GL path below executes not one
|
||||
// new statement. The host owns its own window, device and swapchain,
|
||||
// and its capability gate throws NotSupportedException into the same
|
||||
// exit-code-4 contract Program.cs already publishes for GL.
|
||||
using var vulkan = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost(
|
||||
_options,
|
||||
_platformServices,
|
||||
startup.Display.VSync);
|
||||
vulkan.Run();
|
||||
return;
|
||||
}
|
||||
|
||||
FramePacingPolicy startupPacing =
|
||||
_displayFramePacing.InitializeStartup(startup.Display.VSync);
|
||||
var options = WindowOptions.Default with
|
||||
|
|
|
|||
768
src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs
Normal file
768
src/AcDream.App/Rendering/Gpu/Vk/VulkanActiveDeviceProbe.cs
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
using Silk.NET.Vulkan;
|
||||
using Semaphore = Silk.NET.Vulkan.Semaphore;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5, plan §4.11: the <b>active</b> half of the Vulkan
|
||||
/// capability gate.
|
||||
///
|
||||
/// Advertisement is not evidence. The GL gate learned that from Linux drivers
|
||||
/// that publish an extension string and then return a missing entry point, and
|
||||
/// the Vulkan probe is deliberately stronger: it really builds the production
|
||||
/// descriptor layouts, really allocates an offscreen colour target, really
|
||||
/// records a dynamic-rendering clear through a <c>barrier2</c> pair, really
|
||||
/// submits it against a timeline semaphore, and really reads the pixels back.
|
||||
///
|
||||
/// <para><b>Offscreen by construction.</b> Nothing here touches a surface, a
|
||||
/// swapchain, or a window. That is what lets the gate run — and be reasoned
|
||||
/// about — without opening one, and it is why the probe is a separate type from
|
||||
/// <c>VulkanBringUpHost</c> rather than a phase inside it.</para>
|
||||
///
|
||||
/// <para><b>Deviation from §4.11, deliberate and scoped to V5:</b> the plan also
|
||||
/// wants the probe to build "one real pipeline from the committed
|
||||
/// <c>.spv</c>" and render a triangle that samples a table slot. The
|
||||
/// <c>.spv</c> toolchain does not exist until slice V6c, so V5 proves everything
|
||||
/// the pipeline would have needed — the descriptor-indexing layout, the
|
||||
/// three-set pipeline layout, the 96-byte push-constant range, dynamic
|
||||
/// rendering, synchronization2, the timeline semaphore, host query reset and
|
||||
/// readback — and V6c adds the shader stage on top. Recorded in the slice V5
|
||||
/// commit message and in the divergence register.</para>
|
||||
/// </summary>
|
||||
internal static unsafe class VulkanActiveDeviceProbe
|
||||
{
|
||||
/// <summary>Edge of the offscreen target. Small enough to be free, large enough to be a real image.</summary>
|
||||
internal const uint ProbeExtent = 64;
|
||||
|
||||
/// <summary>
|
||||
/// The clear colour the probe writes and then verifies. Chosen so all four
|
||||
/// channels differ from each other and none is 0 or 255 — a readback that
|
||||
/// silently returns zeroed or saturated memory cannot accidentally match.
|
||||
/// </summary>
|
||||
internal static ReadOnlySpan<byte> ExpectedClearRgba => [0x33, 0x77, 0xBB, 0xEE];
|
||||
|
||||
internal static VulkanFunctionProbeResult Run(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
Device device,
|
||||
Queue graphicsQueue,
|
||||
uint graphicsFamily)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
var failures = new List<string>();
|
||||
|
||||
bool descriptorLayout = false;
|
||||
bool pushConstantLayout = false;
|
||||
bool dynamicRendering = false;
|
||||
bool timelineWait = false;
|
||||
bool hostQueryReset = false;
|
||||
bool readback = false;
|
||||
|
||||
DescriptorSetLayout storageLayout = default;
|
||||
DescriptorSetLayout uniformLayout = default;
|
||||
DescriptorSetLayout tableLayout = default;
|
||||
PipelineLayout pipelineLayout = default;
|
||||
|
||||
try
|
||||
{
|
||||
descriptorLayout = Attempt(
|
||||
"descriptor-indexing set layouts",
|
||||
() =>
|
||||
{
|
||||
storageLayout = CreateStorageSetLayout(vk, device);
|
||||
uniformLayout = CreateUniformSetLayout(vk, device);
|
||||
tableLayout = CreateTextureTableSetLayout(vk, device);
|
||||
},
|
||||
failures);
|
||||
|
||||
if (descriptorLayout)
|
||||
{
|
||||
pushConstantLayout = Attempt(
|
||||
"three-set pipeline layout with the 96-byte push-constant block",
|
||||
() => pipelineLayout = CreatePipelineLayout(
|
||||
vk,
|
||||
device,
|
||||
storageLayout,
|
||||
uniformLayout,
|
||||
tableLayout),
|
||||
failures);
|
||||
}
|
||||
|
||||
hostQueryReset = Attempt(
|
||||
"host timestamp query-pool reset",
|
||||
() => ProbeHostQueryReset(vk, device),
|
||||
failures);
|
||||
|
||||
byte[]? pixels = null;
|
||||
dynamicRendering = Attempt(
|
||||
"dynamic-rendering clear, synchronization2 barriers and timeline submit",
|
||||
() => pixels = RenderAndReadBack(vk, physicalDevice, device, graphicsQueue, graphicsFamily),
|
||||
failures);
|
||||
timelineWait = dynamicRendering;
|
||||
|
||||
if (dynamicRendering && pixels is not null)
|
||||
{
|
||||
readback = Attempt(
|
||||
"offscreen readback pixel comparison",
|
||||
() => VerifyClearColour(pixels),
|
||||
failures);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pipelineLayout.Handle != 0)
|
||||
vk.DestroyPipelineLayout(device, pipelineLayout, null);
|
||||
if (tableLayout.Handle != 0)
|
||||
vk.DestroyDescriptorSetLayout(device, tableLayout, null);
|
||||
if (uniformLayout.Handle != 0)
|
||||
vk.DestroyDescriptorSetLayout(device, uniformLayout, null);
|
||||
if (storageLayout.Handle != 0)
|
||||
vk.DestroyDescriptorSetLayout(device, storageLayout, null);
|
||||
}
|
||||
|
||||
return new VulkanFunctionProbeResult(
|
||||
DeviceCreation: true,
|
||||
DescriptorIndexingLayout: descriptorLayout,
|
||||
PushConstantLayout: pushConstantLayout,
|
||||
DynamicRenderingClear: dynamicRendering,
|
||||
TimelineSemaphoreWait: timelineWait,
|
||||
HostQueryReset: hostQueryReset,
|
||||
OffscreenReadback: readback,
|
||||
Failures: failures);
|
||||
}
|
||||
|
||||
private static bool Attempt(string name, Action action, List<string> failures)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
failures.Add($"{name}: {error.GetType().Name}: {error.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set 0 — the ten storage bindings <c>GpuBindingModel</c> pins.</summary>
|
||||
private static DescriptorSetLayout CreateStorageSetLayout(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
Device device)
|
||||
{
|
||||
int count = (int)GpuBindingModel.StorageBindingCount;
|
||||
DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
bindings[i] = new DescriptorSetLayoutBinding
|
||||
{
|
||||
Binding = (uint)i,
|
||||
DescriptorType = DescriptorType.StorageBuffer,
|
||||
DescriptorCount = 1,
|
||||
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
||||
};
|
||||
}
|
||||
|
||||
var create = new DescriptorSetLayoutCreateInfo
|
||||
{
|
||||
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
||||
BindingCount = (uint)count,
|
||||
PBindings = bindings,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
|
||||
"vkCreateDescriptorSetLayout (set 0, storage)");
|
||||
return layout;
|
||||
}
|
||||
|
||||
/// <summary>Set 1 — the SceneLighting and terrain-tiling uniform blocks.</summary>
|
||||
private static DescriptorSetLayout CreateUniformSetLayout(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
Device device)
|
||||
{
|
||||
DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[2];
|
||||
bindings[0] = new DescriptorSetLayoutBinding
|
||||
{
|
||||
Binding = GpuBindingModel.UniformSceneLighting,
|
||||
DescriptorType = DescriptorType.UniformBuffer,
|
||||
DescriptorCount = 1,
|
||||
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
||||
};
|
||||
bindings[1] = new DescriptorSetLayoutBinding
|
||||
{
|
||||
Binding = GpuBindingModel.UniformTerrainTiling,
|
||||
DescriptorType = DescriptorType.UniformBuffer,
|
||||
DescriptorCount = 1,
|
||||
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
||||
};
|
||||
|
||||
var create = new DescriptorSetLayoutCreateInfo
|
||||
{
|
||||
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
||||
BindingCount = 2,
|
||||
PBindings = bindings,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
|
||||
"vkCreateDescriptorSetLayout (set 1, uniform)");
|
||||
return layout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set 2 — the production texture table exactly as §4.4 specifies it: one
|
||||
/// combined-image-sampler binding of <see cref="GpuBindingModel.TextureTableCapacity"/>,
|
||||
/// partially bound, update-after-bind, update-unused-while-pending, variable
|
||||
/// count. This is the layout the whole bindless replacement rests on, so the
|
||||
/// probe builds the real thing rather than a token one.
|
||||
/// </summary>
|
||||
private static DescriptorSetLayout CreateTextureTableSetLayout(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
Device device)
|
||||
{
|
||||
var binding = new DescriptorSetLayoutBinding
|
||||
{
|
||||
Binding = GpuBindingModel.TextureTableBinding,
|
||||
DescriptorType = DescriptorType.CombinedImageSampler,
|
||||
DescriptorCount = GpuBindingModel.TextureTableCapacity,
|
||||
StageFlags = ShaderStageFlags.FragmentBit,
|
||||
};
|
||||
DescriptorBindingFlags flags =
|
||||
DescriptorBindingFlags.PartiallyBoundBit
|
||||
| DescriptorBindingFlags.UpdateAfterBindBit
|
||||
| DescriptorBindingFlags.UpdateUnusedWhilePendingBit
|
||||
| DescriptorBindingFlags.VariableDescriptorCountBit;
|
||||
|
||||
var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo
|
||||
{
|
||||
SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo,
|
||||
BindingCount = 1,
|
||||
PBindingFlags = &flags,
|
||||
};
|
||||
var create = new DescriptorSetLayoutCreateInfo
|
||||
{
|
||||
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
||||
PNext = &bindingFlags,
|
||||
Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit,
|
||||
BindingCount = 1,
|
||||
PBindings = &binding,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
|
||||
"vkCreateDescriptorSetLayout (set 2, texture table)");
|
||||
return layout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One shared pipeline layout: three sets plus the single 96-byte push-constant
|
||||
/// block. Creating it proves <c>maxBoundDescriptorSets</c> and
|
||||
/// <c>maxPushConstantsSize</c> for real rather than by reading a limit.
|
||||
/// </summary>
|
||||
private static PipelineLayout CreatePipelineLayout(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
Device device,
|
||||
DescriptorSetLayout storage,
|
||||
DescriptorSetLayout uniform,
|
||||
DescriptorSetLayout table)
|
||||
{
|
||||
DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[3];
|
||||
sets[0] = storage;
|
||||
sets[1] = uniform;
|
||||
sets[2] = table;
|
||||
|
||||
var pushConstants = new PushConstantRange
|
||||
{
|
||||
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
||||
Offset = 0,
|
||||
Size = GpuBindingModel.PushConstantBytes,
|
||||
};
|
||||
var create = new PipelineLayoutCreateInfo
|
||||
{
|
||||
SType = StructureType.PipelineLayoutCreateInfo,
|
||||
SetLayoutCount = 3,
|
||||
PSetLayouts = sets,
|
||||
PushConstantRangeCount = 1,
|
||||
PPushConstantRanges = &pushConstants,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout),
|
||||
"vkCreatePipelineLayout");
|
||||
return layout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset a timestamp pool from the host. This is the whole point of
|
||||
/// <c>hostQueryReset</c>: without it the reset costs a command-buffer call
|
||||
/// in every frame that measures anything.
|
||||
/// </summary>
|
||||
private static void ProbeHostQueryReset(Silk.NET.Vulkan.Vk vk, Device device)
|
||||
{
|
||||
var create = new QueryPoolCreateInfo
|
||||
{
|
||||
SType = StructureType.QueryPoolCreateInfo,
|
||||
QueryType = QueryType.Timestamp,
|
||||
QueryCount = 2,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateQueryPool(device, &create, null, out QueryPool pool),
|
||||
"vkCreateQueryPool");
|
||||
try
|
||||
{
|
||||
vk.ResetQueryPool(device, pool, 0, 2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
vk.DestroyQueryPool(device, pool, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real submission: clear a 64x64 image through dynamic rendering, move
|
||||
/// it to TRANSFER_SRC with a barrier2, copy it into host-visible memory, and
|
||||
/// wait on a timeline semaphore for the frame serial. Every mechanism the
|
||||
/// production frame skeleton (§4.8) depends on, in miniature.
|
||||
/// </summary>
|
||||
private static byte[] RenderAndReadBack(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
Device device,
|
||||
Queue queue,
|
||||
uint graphicsFamily)
|
||||
{
|
||||
Image image = default;
|
||||
DeviceMemory imageMemory = default;
|
||||
ImageView view = default;
|
||||
Silk.NET.Vulkan.Buffer readback = default;
|
||||
DeviceMemory readbackMemory = default;
|
||||
CommandPool commandPool = default;
|
||||
Semaphore timeline = default;
|
||||
|
||||
try
|
||||
{
|
||||
(image, imageMemory) = CreateColorTarget(vk, physicalDevice, device);
|
||||
view = CreateImageView(vk, device, image);
|
||||
uint byteCount = ProbeExtent * ProbeExtent * 4;
|
||||
(readback, readbackMemory) = CreateReadbackBuffer(vk, physicalDevice, device, byteCount);
|
||||
|
||||
var poolCreate = new CommandPoolCreateInfo
|
||||
{
|
||||
SType = StructureType.CommandPoolCreateInfo,
|
||||
QueueFamilyIndex = graphicsFamily,
|
||||
Flags = CommandPoolCreateFlags.TransientBit,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateCommandPool(device, &poolCreate, null, out commandPool),
|
||||
"vkCreateCommandPool");
|
||||
|
||||
var allocate = new CommandBufferAllocateInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferAllocateInfo,
|
||||
CommandPool = commandPool,
|
||||
Level = CommandBufferLevel.Primary,
|
||||
CommandBufferCount = 1,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.AllocateCommandBuffers(device, &allocate, out CommandBuffer commands),
|
||||
"vkAllocateCommandBuffers");
|
||||
|
||||
RecordClearAndCopy(vk, commands, image, view, readback);
|
||||
|
||||
var semaphoreType = new SemaphoreTypeCreateInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreTypeCreateInfo,
|
||||
SemaphoreType = SemaphoreType.Timeline,
|
||||
InitialValue = 0,
|
||||
};
|
||||
var semaphoreCreate = new SemaphoreCreateInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreCreateInfo,
|
||||
PNext = &semaphoreType,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateSemaphore(device, &semaphoreCreate, null, out timeline),
|
||||
"vkCreateSemaphore (timeline)");
|
||||
|
||||
var commandSubmit = new CommandBufferSubmitInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferSubmitInfo,
|
||||
CommandBuffer = commands,
|
||||
};
|
||||
var signal = new SemaphoreSubmitInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreSubmitInfo,
|
||||
Semaphore = timeline,
|
||||
Value = 1,
|
||||
StageMask = PipelineStageFlags2.AllCommandsBit,
|
||||
};
|
||||
var submit = new SubmitInfo2
|
||||
{
|
||||
SType = StructureType.SubmitInfo2,
|
||||
CommandBufferInfoCount = 1,
|
||||
PCommandBufferInfos = &commandSubmit,
|
||||
SignalSemaphoreInfoCount = 1,
|
||||
PSignalSemaphoreInfos = &signal,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.QueueSubmit2(queue, 1, &submit, default),
|
||||
"vkQueueSubmit2");
|
||||
|
||||
ulong waitValue = 1;
|
||||
Semaphore waitSemaphore = timeline;
|
||||
var wait = new SemaphoreWaitInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreWaitInfo,
|
||||
SemaphoreCount = 1,
|
||||
PSemaphores = &waitSemaphore,
|
||||
PValues = &waitValue,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.WaitSemaphores(device, &wait, 5_000_000_000ul),
|
||||
"vkWaitSemaphores (timeline)");
|
||||
|
||||
void* mapped = null;
|
||||
VulkanInterop.Check(
|
||||
vk.MapMemory(device, readbackMemory, 0, byteCount, 0, &mapped),
|
||||
"vkMapMemory (readback)");
|
||||
try
|
||||
{
|
||||
var pixels = new byte[byteCount];
|
||||
new ReadOnlySpan<byte>(mapped, (int)byteCount).CopyTo(pixels);
|
||||
return pixels;
|
||||
}
|
||||
finally
|
||||
{
|
||||
vk.UnmapMemory(device, readbackMemory);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (timeline.Handle != 0)
|
||||
vk.DestroySemaphore(device, timeline, null);
|
||||
if (commandPool.Handle != 0)
|
||||
vk.DestroyCommandPool(device, commandPool, null);
|
||||
if (readback.Handle != 0)
|
||||
vk.DestroyBuffer(device, readback, null);
|
||||
if (readbackMemory.Handle != 0)
|
||||
vk.FreeMemory(device, readbackMemory, null);
|
||||
if (view.Handle != 0)
|
||||
vk.DestroyImageView(device, view, null);
|
||||
if (image.Handle != 0)
|
||||
vk.DestroyImage(device, image, null);
|
||||
if (imageMemory.Handle != 0)
|
||||
vk.FreeMemory(device, imageMemory, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordClearAndCopy(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
CommandBuffer commands,
|
||||
Image image,
|
||||
ImageView view,
|
||||
Silk.NET.Vulkan.Buffer readback)
|
||||
{
|
||||
var begin = new CommandBufferBeginInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferBeginInfo,
|
||||
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
||||
};
|
||||
VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer");
|
||||
|
||||
var subresource = new ImageSubresourceRange
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
BaseMipLevel = 0,
|
||||
LevelCount = 1,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
};
|
||||
|
||||
Barrier(
|
||||
vk,
|
||||
commands,
|
||||
image,
|
||||
subresource,
|
||||
ImageLayout.Undefined,
|
||||
ImageLayout.ColorAttachmentOptimal,
|
||||
PipelineStageFlags2.TopOfPipeBit,
|
||||
AccessFlags2.None,
|
||||
PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
AccessFlags2.ColorAttachmentWriteBit);
|
||||
|
||||
var clear = new ClearValue
|
||||
{
|
||||
Color = new ClearColorValue
|
||||
{
|
||||
Float32_0 = ExpectedClearRgba[0] / 255f,
|
||||
Float32_1 = ExpectedClearRgba[1] / 255f,
|
||||
Float32_2 = ExpectedClearRgba[2] / 255f,
|
||||
Float32_3 = ExpectedClearRgba[3] / 255f,
|
||||
},
|
||||
};
|
||||
var attachment = new RenderingAttachmentInfo
|
||||
{
|
||||
SType = StructureType.RenderingAttachmentInfo,
|
||||
ImageView = view,
|
||||
ImageLayout = ImageLayout.ColorAttachmentOptimal,
|
||||
LoadOp = AttachmentLoadOp.Clear,
|
||||
StoreOp = AttachmentStoreOp.Store,
|
||||
ClearValue = clear,
|
||||
};
|
||||
var rendering = new RenderingInfo
|
||||
{
|
||||
SType = StructureType.RenderingInfo,
|
||||
RenderArea = new Rect2D(new Offset2D(0, 0), new Extent2D(ProbeExtent, ProbeExtent)),
|
||||
LayerCount = 1,
|
||||
ColorAttachmentCount = 1,
|
||||
PColorAttachments = &attachment,
|
||||
};
|
||||
vk.CmdBeginRendering(commands, &rendering);
|
||||
vk.CmdEndRendering(commands);
|
||||
|
||||
Barrier(
|
||||
vk,
|
||||
commands,
|
||||
image,
|
||||
subresource,
|
||||
ImageLayout.ColorAttachmentOptimal,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
AccessFlags2.ColorAttachmentWriteBit,
|
||||
PipelineStageFlags2.CopyBit,
|
||||
AccessFlags2.TransferReadBit);
|
||||
|
||||
var region = new BufferImageCopy
|
||||
{
|
||||
BufferOffset = 0,
|
||||
BufferRowLength = 0,
|
||||
BufferImageHeight = 0,
|
||||
ImageSubresource = new ImageSubresourceLayers
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
MipLevel = 0,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
},
|
||||
ImageOffset = new Offset3D(0, 0, 0),
|
||||
ImageExtent = new Extent3D(ProbeExtent, ProbeExtent, 1),
|
||||
};
|
||||
vk.CmdCopyImageToBuffer(
|
||||
commands,
|
||||
image,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
readback,
|
||||
1,
|
||||
®ion);
|
||||
|
||||
VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer");
|
||||
}
|
||||
|
||||
private static void Barrier(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
CommandBuffer commands,
|
||||
Image image,
|
||||
ImageSubresourceRange subresource,
|
||||
ImageLayout oldLayout,
|
||||
ImageLayout newLayout,
|
||||
PipelineStageFlags2 sourceStage,
|
||||
AccessFlags2 sourceAccess,
|
||||
PipelineStageFlags2 destinationStage,
|
||||
AccessFlags2 destinationAccess)
|
||||
{
|
||||
var barrier = new ImageMemoryBarrier2
|
||||
{
|
||||
SType = StructureType.ImageMemoryBarrier2,
|
||||
SrcStageMask = sourceStage,
|
||||
SrcAccessMask = sourceAccess,
|
||||
DstStageMask = destinationStage,
|
||||
DstAccessMask = destinationAccess,
|
||||
OldLayout = oldLayout,
|
||||
NewLayout = newLayout,
|
||||
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
|
||||
Image = image,
|
||||
SubresourceRange = subresource,
|
||||
};
|
||||
var dependency = new DependencyInfo
|
||||
{
|
||||
SType = StructureType.DependencyInfo,
|
||||
ImageMemoryBarrierCount = 1,
|
||||
PImageMemoryBarriers = &barrier,
|
||||
};
|
||||
vk.CmdPipelineBarrier2(commands, &dependency);
|
||||
}
|
||||
|
||||
private static (Image Image, DeviceMemory Memory) CreateColorTarget(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
Device device)
|
||||
{
|
||||
var create = new ImageCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageCreateInfo,
|
||||
ImageType = ImageType.Type2D,
|
||||
Format = Format.R8G8B8A8Unorm,
|
||||
Extent = new Extent3D(ProbeExtent, ProbeExtent, 1),
|
||||
MipLevels = 1,
|
||||
ArrayLayers = 1,
|
||||
Samples = SampleCountFlags.Count1Bit,
|
||||
Tiling = ImageTiling.Optimal,
|
||||
Usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
|
||||
SharingMode = SharingMode.Exclusive,
|
||||
InitialLayout = ImageLayout.Undefined,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateImage(device, &create, null, out Image image),
|
||||
"vkCreateImage (probe colour target)");
|
||||
|
||||
vk.GetImageMemoryRequirements(device, image, out MemoryRequirements requirements);
|
||||
DeviceMemory memory = Allocate(
|
||||
vk,
|
||||
physicalDevice,
|
||||
device,
|
||||
requirements,
|
||||
MemoryPropertyFlags.DeviceLocalBit);
|
||||
VulkanInterop.Check(
|
||||
vk.BindImageMemory(device, image, memory, 0),
|
||||
"vkBindImageMemory (probe colour target)");
|
||||
return (image, memory);
|
||||
}
|
||||
|
||||
private static ImageView CreateImageView(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
Device device,
|
||||
Image image)
|
||||
{
|
||||
var create = new ImageViewCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageViewCreateInfo,
|
||||
Image = image,
|
||||
ViewType = ImageViewType.Type2D,
|
||||
Format = Format.R8G8B8A8Unorm,
|
||||
SubresourceRange = new ImageSubresourceRange
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
BaseMipLevel = 0,
|
||||
LevelCount = 1,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
},
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateImageView(device, &create, null, out ImageView view),
|
||||
"vkCreateImageView (probe colour target)");
|
||||
return view;
|
||||
}
|
||||
|
||||
private static (Silk.NET.Vulkan.Buffer Buffer, DeviceMemory Memory) CreateReadbackBuffer(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
Device device,
|
||||
uint byteCount)
|
||||
{
|
||||
var create = new BufferCreateInfo
|
||||
{
|
||||
SType = StructureType.BufferCreateInfo,
|
||||
Size = byteCount,
|
||||
Usage = BufferUsageFlags.TransferDstBit,
|
||||
SharingMode = SharingMode.Exclusive,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateBuffer(device, &create, null, out Silk.NET.Vulkan.Buffer buffer),
|
||||
"vkCreateBuffer (probe readback)");
|
||||
|
||||
vk.GetBufferMemoryRequirements(device, buffer, out MemoryRequirements requirements);
|
||||
DeviceMemory memory = Allocate(
|
||||
vk,
|
||||
physicalDevice,
|
||||
device,
|
||||
requirements,
|
||||
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
|
||||
VulkanInterop.Check(
|
||||
vk.BindBufferMemory(device, buffer, memory, 0),
|
||||
"vkBindBufferMemory (probe readback)");
|
||||
return (buffer, memory);
|
||||
}
|
||||
|
||||
private static DeviceMemory Allocate(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
Device device,
|
||||
MemoryRequirements requirements,
|
||||
MemoryPropertyFlags properties)
|
||||
{
|
||||
uint? typeIndex = FindMemoryType(
|
||||
vk,
|
||||
physicalDevice,
|
||||
requirements.MemoryTypeBits,
|
||||
properties);
|
||||
if (typeIndex is not { } index)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"No Vulkan memory type satisfies {properties} for the capability probe.");
|
||||
}
|
||||
|
||||
var allocate = new MemoryAllocateInfo
|
||||
{
|
||||
SType = StructureType.MemoryAllocateInfo,
|
||||
AllocationSize = requirements.Size,
|
||||
MemoryTypeIndex = index,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.AllocateMemory(device, &allocate, null, out DeviceMemory memory),
|
||||
"vkAllocateMemory (probe)");
|
||||
return memory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First memory type that is both allowed by the resource and carries every
|
||||
/// requested property. The <paramref name="typeBits"/> mask is per-resource,
|
||||
/// which is why this cannot be hoisted to a one-time lookup.
|
||||
/// </summary>
|
||||
internal static uint? FindMemoryType(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
uint typeBits,
|
||||
MemoryPropertyFlags properties)
|
||||
{
|
||||
vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out PhysicalDeviceMemoryProperties memory);
|
||||
for (uint i = 0; i < memory.MemoryTypeCount && i < 32; i++)
|
||||
{
|
||||
bool allowed = (typeBits & (1u << (int)i)) != 0;
|
||||
if (allowed && memory.MemoryTypes[(int)i].PropertyFlags.HasFlag(properties))
|
||||
return i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify every pixel is the clear colour. Comparing all of them rather than
|
||||
/// a sample is deliberate: a driver that clears only the first tile, or that
|
||||
/// returns a row-padded copy, fails here rather than at V7.
|
||||
/// </summary>
|
||||
internal static void VerifyClearColour(ReadOnlySpan<byte> pixels)
|
||||
{
|
||||
if (pixels.Length != ProbeExtent * ProbeExtent * 4)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"the readback returned {pixels.Length} bytes; expected " +
|
||||
$"{ProbeExtent * ProbeExtent * 4}.");
|
||||
}
|
||||
|
||||
for (int i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
for (int channel = 0; channel < 4; channel++)
|
||||
{
|
||||
byte actual = pixels[i + channel];
|
||||
byte expected = ExpectedClearRgba[channel];
|
||||
// One least-significant bit of slack: the clear colour is
|
||||
// specified as a float and quantised by the implementation.
|
||||
if (Math.Abs(actual - expected) > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"pixel {i / 4} channel {channel} read 0x{actual:X2}, expected " +
|
||||
$"0x{expected:X2}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
718
src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
Normal file
718
src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Core.Native;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.Vulkan;
|
||||
using Silk.NET.Vulkan.Extensions.KHR;
|
||||
using Silk.NET.Windowing;
|
||||
using Semaphore = Silk.NET.Vulkan.Semaphore;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5 — Vulkan bring-up, dark.
|
||||
///
|
||||
/// Reached only when <c>ACDREAM_RENDER_BACKEND=vulkan</c>. It opens its own
|
||||
/// window, creates an instance, a surface, a device and a swapchain, runs the
|
||||
/// capability gate, and presents a flat clear colour until the window closes.
|
||||
/// <b>Nothing of the game renders through it</b> — the RHI backend lands at V6
|
||||
/// and the default flips at V10.
|
||||
///
|
||||
/// <para>Deliberately a self-contained host rather than a branch woven into
|
||||
/// <c>GameWindow</c>'s composition: the composition phases each own GL resources
|
||||
/// and would have to grow a backend switch apiece for a slice that draws one
|
||||
/// colour. <c>GameWindow.Run</c> delegates here in four lines and returns, so the
|
||||
/// GL path executes not one new statement.</para>
|
||||
///
|
||||
/// <para><b>Untested by the implementing slice.</b> Every line below needs a
|
||||
/// window and a driver. The pure decisions it depends on — device ranking,
|
||||
/// present-mode mapping, extent and image-count clamping, the capability
|
||||
/// accept/reject matrix, the report shape, the BGRA swizzle — are unit-tested
|
||||
/// beside it. The remaining gate is manual: "Vulkan boots to a clear
|
||||
/// colour."</para>
|
||||
/// </summary>
|
||||
internal sealed unsafe class VulkanBringUpHost : IDisposable
|
||||
{
|
||||
/// <summary>Two frames in flight, matching plan §4.8 and the GL flight controller.</summary>
|
||||
internal const int FlightCount = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The bring-up clear colour, linear-encoded straight into a UNORM
|
||||
/// swapchain. A deliberate deep blue: black would be indistinguishable from
|
||||
/// an unpainted window, and magenta is reserved as the "unresolved texture
|
||||
/// slot" sentinel everywhere else in this codebase.
|
||||
/// </summary>
|
||||
internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f];
|
||||
|
||||
private const string ScreenshotName = "vulkan-bringup";
|
||||
private const ulong AcquireTimeoutNanoseconds = 1_000_000_000ul;
|
||||
|
||||
private readonly RuntimeOptions _options;
|
||||
private readonly GraphicalHostPlatformServices _platform;
|
||||
private readonly FramePacingPolicy _pacing;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
private IWindow? _window;
|
||||
private Silk.NET.Vulkan.Vk? _vk;
|
||||
private Instance _instance;
|
||||
private KhrSurface? _surfaceApi;
|
||||
private SurfaceKHR _surface;
|
||||
private PhysicalDevice _physicalDevice;
|
||||
private Device _device;
|
||||
private KhrSwapchain? _swapchainApi;
|
||||
private Queue _graphicsQueue;
|
||||
private Queue _presentQueue;
|
||||
private VulkanQueueFamilyChoice? _families;
|
||||
private VulkanSwapchain? _swapchain;
|
||||
|
||||
private CommandPool[] _commandPools = [];
|
||||
private CommandBuffer[] _commandBuffers = [];
|
||||
private Semaphore[] _imageAcquired = [];
|
||||
private Semaphore _timeline;
|
||||
private ulong _frameSerial;
|
||||
private bool _recreateAtFrameBoundary;
|
||||
private bool _disposed;
|
||||
|
||||
internal VulkanBringUpHost(
|
||||
RuntimeOptions options,
|
||||
GraphicalHostPlatformServices platform,
|
||||
bool requestedVSync,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_platform = platform ?? throw new ArgumentNullException(nameof(platform));
|
||||
_log = log ?? Console.WriteLine;
|
||||
// Resolved from the same pure policy the GL path uses, so "VSync on means
|
||||
// FIFO" is one decision expressed once. The monitor refresh is left
|
||||
// unknown because slice V5 consumes only UseVSync: there is no software
|
||||
// pacer here to feed a limit to, and inventing one would be a number
|
||||
// nothing reads. V6 wires FramePacingController and supplies it.
|
||||
_pacing = FramePacingPolicy.Resolve(
|
||||
requestedVSync,
|
||||
_options.UncappedRendering,
|
||||
monitorRefreshHz: null);
|
||||
}
|
||||
|
||||
/// <summary>The record the gate evaluated, available after <see cref="Run"/> starts.</summary>
|
||||
internal VulkanCapabilityRecord? Capabilities { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Open the window, pass the capability gate, and present the clear colour
|
||||
/// until the window closes. Throws <see cref="NotSupportedException"/> when
|
||||
/// the gate rejects the device, which <c>Program.cs</c> turns into exit code
|
||||
/// 4 exactly as it does for the GL gate.
|
||||
/// </summary>
|
||||
internal void Run()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
CreateWindow();
|
||||
CreateInstanceAndSurface();
|
||||
SelectDeviceAndGate();
|
||||
CreateFrameResources();
|
||||
Present();
|
||||
}
|
||||
|
||||
private void CreateWindow()
|
||||
{
|
||||
var options = WindowOptions.DefaultVulkan with
|
||||
{
|
||||
Size = new Vector2D<int>(1280, 720),
|
||||
Title = "acdream — Vulkan bring-up (Campaign V slice V5)",
|
||||
VSync = _pacing.UseVSync,
|
||||
};
|
||||
_window = Window.Create(options);
|
||||
_window.Initialize();
|
||||
if (_window.VkSurface is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"The windowing backend did not expose a Vulkan surface. " +
|
||||
"acdream requires GLFW 3.4 built with Vulkan support.");
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateInstanceAndSurface()
|
||||
{
|
||||
IWindow window = _window!;
|
||||
_vk = Silk.NET.Vulkan.Vk.GetApi();
|
||||
|
||||
byte** requiredNames = window.VkSurface!.GetRequiredExtensions(out uint requiredCount);
|
||||
var required = new List<string>((int)requiredCount);
|
||||
for (uint i = 0; i < requiredCount; i++)
|
||||
required.Add(VulkanInterop.ReadString(requiredNames[i]));
|
||||
|
||||
VulkanInstanceFactory.Created instance = VulkanInstanceFactory.Create(
|
||||
_vk,
|
||||
required,
|
||||
enableOptionalExtensions: _options.DevTools);
|
||||
_instance = instance.Instance;
|
||||
InstanceExtensions = instance.EnabledExtensions;
|
||||
|
||||
if (!_vk.TryGetInstanceExtension(_instance, out KhrSurface surfaceApi))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"VK_KHR_surface is required but its entry points could not be loaded.");
|
||||
}
|
||||
|
||||
_surfaceApi = surfaceApi;
|
||||
_surface = window.VkSurface.Create<AllocationCallbacks>(
|
||||
_instance.ToHandle(),
|
||||
null).ToSurface();
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> InstanceExtensions { get; set; } = [];
|
||||
|
||||
private void SelectDeviceAndGate()
|
||||
{
|
||||
Silk.NET.Vulkan.Vk vk = _vk!;
|
||||
IReadOnlyList<VulkanPhysicalDeviceCandidate> candidates =
|
||||
VulkanPhysicalDeviceInspector.Enumerate(vk, _instance, out PhysicalDevice[] handles);
|
||||
VulkanPhysicalDeviceChoice? choice = VulkanPhysicalDeviceSelection.Choose(
|
||||
candidates,
|
||||
_options.VulkanDeviceOverride);
|
||||
if (choice is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"No Vulkan physical device was enumerated. Install or update a " +
|
||||
"Vulkan 1.3 driver for this GPU.");
|
||||
}
|
||||
|
||||
_physicalDevice = handles[choice.Device.Index];
|
||||
|
||||
IReadOnlyList<VulkanQueueFamilyCandidate> queueFamilies =
|
||||
VulkanPhysicalDeviceInspector.ReadQueueFamilies(
|
||||
vk,
|
||||
_physicalDevice,
|
||||
_surfaceApi,
|
||||
_surface);
|
||||
VulkanQueueFamilyChoice? families = VulkanQueueFamilySelection.Choose(queueFamilies);
|
||||
if (families is null)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"'{choice.Device.DeviceName}' exposes no queue family that can both " +
|
||||
"render and present to the window surface.");
|
||||
}
|
||||
|
||||
_families = families;
|
||||
VulkanLogicalDeviceFactory.Created created = VulkanLogicalDeviceFactory.Create(
|
||||
vk,
|
||||
_physicalDevice,
|
||||
families,
|
||||
requireSwapchain: true);
|
||||
_device = created.Device;
|
||||
_graphicsQueue = created.GraphicsQueue;
|
||||
_presentQueue = created.PresentQueue;
|
||||
|
||||
if (!vk.TryGetDeviceExtension(_instance, _device, out KhrSwapchain swapchainApi))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"VK_KHR_swapchain is required but its entry points could not be loaded.");
|
||||
}
|
||||
|
||||
_swapchainApi = swapchainApi;
|
||||
_swapchain = new VulkanSwapchain(
|
||||
vk,
|
||||
_surfaceApi!,
|
||||
swapchainApi,
|
||||
_physicalDevice,
|
||||
_device,
|
||||
_surface,
|
||||
families);
|
||||
|
||||
(SurfaceCapabilitiesKHR surfaceCapabilities,
|
||||
IReadOnlyList<SurfaceFormatKHR> formats,
|
||||
IReadOnlyList<PresentModeKHR> presentModes) = _swapchain.QuerySurface();
|
||||
|
||||
Vector2D<int> framebuffer = _window!.FramebufferSize;
|
||||
VulkanSwapchainConfiguration planned = VulkanSwapchainConfigurationFactory.Create(
|
||||
surfaceCapabilities,
|
||||
formats,
|
||||
presentModes,
|
||||
_pacing,
|
||||
(uint)Math.Max(0, framebuffer.X),
|
||||
(uint)Math.Max(0, framebuffer.Y));
|
||||
|
||||
var surfaceSupport = new VulkanSurfaceSupport(
|
||||
PresentSupported: true,
|
||||
SelectedFormat: planned.ImageFormat,
|
||||
SelectedColorSpace: planned.ColorSpace,
|
||||
SelectedPresentMode: planned.PresentMode,
|
||||
SelectedImageCount: planned.ImageCount,
|
||||
SelectedWidth: planned.Width,
|
||||
SelectedHeight: planned.Height,
|
||||
SupportsTransferSource:
|
||||
VulkanSwapchainConfigurationFactory.SupportsTransferSource(surfaceCapabilities),
|
||||
AvailableFormats: [.. formats.Select(format => format.Format).Distinct()],
|
||||
AvailablePresentModes: [.. presentModes]);
|
||||
|
||||
VulkanFunctionProbeResult probe = VulkanActiveDeviceProbe.Run(
|
||||
vk,
|
||||
_physicalDevice,
|
||||
_device,
|
||||
_graphicsQueue,
|
||||
families.GraphicsFamily);
|
||||
|
||||
var record = new VulkanCapabilityRecord(
|
||||
DateTimeOffset.UtcNow,
|
||||
_platform.RuntimeIdentifier,
|
||||
_platform.OperatingSystem,
|
||||
_platform.WindowBackend.RequestedProtocol,
|
||||
GlfwNativePlatformProbe.GetActiveProtocol(_platform.OperatingSystem),
|
||||
VulkanApiVersion.Describe(
|
||||
VulkanApiVersion.Make(
|
||||
VulkanCapabilityRequirements.RequiredApiMajor,
|
||||
VulkanCapabilityRequirements.RequiredApiMinor,
|
||||
0)),
|
||||
VulkanApiVersion.Describe(choice.Device.ApiVersion),
|
||||
choice.Device.ApiVersion,
|
||||
choice.Device.DeviceName,
|
||||
VulkanPhysicalDeviceInspector.DescribeDriver(choice.Device),
|
||||
choice.Device.DeviceType,
|
||||
choice.Device.Index,
|
||||
choice.Reason,
|
||||
_options.VulkanDeviceOverride,
|
||||
ForcedUnsupportedFeature: null,
|
||||
candidates,
|
||||
InstanceExtensions,
|
||||
created.EnabledExtensions,
|
||||
families.GraphicsFamily,
|
||||
families.PresentFamily,
|
||||
VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice),
|
||||
VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice),
|
||||
VulkanPhysicalDeviceInspector.ReadFormats(
|
||||
vk,
|
||||
_physicalDevice,
|
||||
VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)),
|
||||
surfaceSupport,
|
||||
probe,
|
||||
SupportFailures: []);
|
||||
|
||||
record = VulkanCapabilityRequirements.Reevaluate(record);
|
||||
record = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
record,
|
||||
_options.VulkanForcedUnsupportedFeature);
|
||||
Capabilities = record;
|
||||
|
||||
string reportPath = Path.Combine(
|
||||
_platform.Paths.DiagnosticsDirectory,
|
||||
VulkanCapabilityGuard.ReportFileName);
|
||||
VulkanCapabilityReportWriter.Write(reportPath, record);
|
||||
VulkanCapabilityGuard.ThrowIfUnsupported(record, reportPath);
|
||||
|
||||
_log(
|
||||
"vulkan: capability gate passed " +
|
||||
$"({record.ActiveDisplayProtocol}, {record.DeviceName}, " +
|
||||
$"{record.DeviceApiVersion}, {record.DriverInfo}); " +
|
||||
$"swapchain {planned.ImageFormat}/{planned.PresentMode} " +
|
||||
$"{planned.Width}x{planned.Height} x{planned.ImageCount}; " +
|
||||
$"report={reportPath}");
|
||||
_log($"vulkan: device selection — {choice.Reason}");
|
||||
}
|
||||
|
||||
private void CreateFrameResources()
|
||||
{
|
||||
Silk.NET.Vulkan.Vk vk = _vk!;
|
||||
VulkanQueueFamilyChoice families = _families!;
|
||||
|
||||
_commandPools = new CommandPool[FlightCount];
|
||||
_commandBuffers = new CommandBuffer[FlightCount];
|
||||
_imageAcquired = new Semaphore[FlightCount];
|
||||
for (int i = 0; i < FlightCount; i++)
|
||||
{
|
||||
var poolCreate = new CommandPoolCreateInfo
|
||||
{
|
||||
SType = StructureType.CommandPoolCreateInfo,
|
||||
QueueFamilyIndex = families.GraphicsFamily,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateCommandPool(_device, &poolCreate, null, out CommandPool pool),
|
||||
"vkCreateCommandPool (flight slot)");
|
||||
_commandPools[i] = pool;
|
||||
|
||||
var allocate = new CommandBufferAllocateInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferAllocateInfo,
|
||||
CommandPool = pool,
|
||||
Level = CommandBufferLevel.Primary,
|
||||
CommandBufferCount = 1,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.AllocateCommandBuffers(_device, &allocate, out CommandBuffer commands),
|
||||
"vkAllocateCommandBuffers (flight slot)");
|
||||
_commandBuffers[i] = commands;
|
||||
|
||||
var semaphoreCreate = new SemaphoreCreateInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreCreateInfo,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore acquired),
|
||||
"vkCreateSemaphore (image acquired)");
|
||||
_imageAcquired[i] = acquired;
|
||||
}
|
||||
|
||||
var timelineType = new SemaphoreTypeCreateInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreTypeCreateInfo,
|
||||
SemaphoreType = SemaphoreType.Timeline,
|
||||
InitialValue = 0,
|
||||
};
|
||||
var timelineCreate = new SemaphoreCreateInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreCreateInfo,
|
||||
PNext = &timelineType,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.CreateSemaphore(_device, &timelineCreate, null, out _timeline),
|
||||
"vkCreateSemaphore (frame timeline)");
|
||||
|
||||
RecreateSwapchain();
|
||||
}
|
||||
|
||||
private bool RecreateSwapchain()
|
||||
{
|
||||
Vector2D<int> framebuffer = _window!.FramebufferSize;
|
||||
uint width = (uint)Math.Max(0, framebuffer.X);
|
||||
uint height = (uint)Math.Max(0, framebuffer.Y);
|
||||
if (VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height)
|
||||
== VulkanSwapchainAction.Idle)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (recreate)");
|
||||
_recreateAtFrameBoundary = false;
|
||||
return _swapchain!.Recreate(_pacing, width, height);
|
||||
}
|
||||
|
||||
private void Present()
|
||||
{
|
||||
IWindow window = _window!;
|
||||
FrameScreenshotController? screenshots = CreateScreenshotController();
|
||||
bool screenshotRequested = false;
|
||||
|
||||
while (!window.IsClosing)
|
||||
{
|
||||
window.DoEvents();
|
||||
if (window.IsClosing)
|
||||
break;
|
||||
|
||||
if (_recreateAtFrameBoundary || !_swapchain!.IsCreated)
|
||||
{
|
||||
if (!RecreateSwapchain())
|
||||
{
|
||||
// Minimised: idle without burning a core, and without
|
||||
// pretending a zero-area swapchain can be created.
|
||||
Thread.Sleep(16);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!RenderClearFrame(out uint imageIndex))
|
||||
continue;
|
||||
|
||||
if (screenshots is not null && !screenshotRequested)
|
||||
{
|
||||
screenshotRequested = true;
|
||||
if (screenshots.TryRequest(ScreenshotName, out string error))
|
||||
{
|
||||
VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!;
|
||||
_lastPresentedImage = imageIndex;
|
||||
screenshots.CapturePending(
|
||||
(int)configuration.Width,
|
||||
(int)configuration.Height);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log($"vulkan: screenshot request rejected: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)");
|
||||
_log($"vulkan: presented {_frameSerial} clear-colour frame(s); shutting down.");
|
||||
}
|
||||
|
||||
private uint _lastPresentedImage;
|
||||
|
||||
private FrameScreenshotController? CreateScreenshotController()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory))
|
||||
return null;
|
||||
|
||||
return new FrameScreenshotController(
|
||||
(_, _) => _swapchain!.CaptureImage(
|
||||
_graphicsQueue,
|
||||
_families!.GraphicsFamily,
|
||||
_lastPresentedImage),
|
||||
_options.AutomationArtifactDirectory,
|
||||
_log);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One clear-colour frame: wait the timeline back to the flight window,
|
||||
/// acquire, record two barriers around a dynamic-rendering clear, submit, and
|
||||
/// present. This is the §4.8 frame skeleton with everything between the
|
||||
/// barriers removed.
|
||||
/// </summary>
|
||||
private bool RenderClearFrame(out uint imageIndex)
|
||||
{
|
||||
imageIndex = 0;
|
||||
Silk.NET.Vulkan.Vk vk = _vk!;
|
||||
VulkanSwapchain swapchain = _swapchain!;
|
||||
|
||||
ulong signalValue = _frameSerial + 1;
|
||||
if (signalValue > FlightCount)
|
||||
{
|
||||
ulong waitValue = signalValue - FlightCount;
|
||||
Semaphore timeline = _timeline;
|
||||
var wait = new SemaphoreWaitInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreWaitInfo,
|
||||
SemaphoreCount = 1,
|
||||
PSemaphores = &timeline,
|
||||
PValues = &waitValue,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.WaitSemaphores(_device, &wait, ulong.MaxValue),
|
||||
"vkWaitSemaphores (frame flight)");
|
||||
}
|
||||
|
||||
int slot = (int)((signalValue - 1) % FlightCount);
|
||||
VulkanSwapchainAction acquired = swapchain.TryAcquire(
|
||||
_imageAcquired[slot],
|
||||
AcquireTimeoutNanoseconds,
|
||||
out imageIndex);
|
||||
switch (acquired)
|
||||
{
|
||||
case VulkanSwapchainAction.RecreateNow:
|
||||
RecreateSwapchain();
|
||||
return false;
|
||||
case VulkanSwapchainAction.Idle:
|
||||
return false;
|
||||
case VulkanSwapchainAction.Fail:
|
||||
throw new InvalidOperationException(
|
||||
"vkAcquireNextImageKHR returned an unrecoverable result.");
|
||||
case VulkanSwapchainAction.RecreateAtFrameBoundary:
|
||||
_recreateAtFrameBoundary = true;
|
||||
break;
|
||||
}
|
||||
|
||||
VulkanInterop.Check(
|
||||
vk.ResetCommandPool(_device, _commandPools[slot], 0),
|
||||
"vkResetCommandPool");
|
||||
RecordClear(_commandBuffers[slot], swapchain, imageIndex);
|
||||
|
||||
var commandSubmit = new CommandBufferSubmitInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferSubmitInfo,
|
||||
CommandBuffer = _commandBuffers[slot],
|
||||
};
|
||||
var waitSemaphore = new SemaphoreSubmitInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreSubmitInfo,
|
||||
Semaphore = _imageAcquired[slot],
|
||||
StageMask = PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
};
|
||||
SemaphoreSubmitInfo* signals = stackalloc SemaphoreSubmitInfo[2];
|
||||
signals[0] = new SemaphoreSubmitInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreSubmitInfo,
|
||||
Semaphore = swapchain.RenderCompleteAt(imageIndex),
|
||||
StageMask = PipelineStageFlags2.AllCommandsBit,
|
||||
};
|
||||
signals[1] = new SemaphoreSubmitInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreSubmitInfo,
|
||||
Semaphore = _timeline,
|
||||
Value = signalValue,
|
||||
StageMask = PipelineStageFlags2.AllCommandsBit,
|
||||
};
|
||||
var submit = new SubmitInfo2
|
||||
{
|
||||
SType = StructureType.SubmitInfo2,
|
||||
WaitSemaphoreInfoCount = 1,
|
||||
PWaitSemaphoreInfos = &waitSemaphore,
|
||||
CommandBufferInfoCount = 1,
|
||||
PCommandBufferInfos = &commandSubmit,
|
||||
SignalSemaphoreInfoCount = 2,
|
||||
PSignalSemaphoreInfos = signals,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
vk.QueueSubmit2(_graphicsQueue, 1, &submit, default),
|
||||
"vkQueueSubmit2 (clear frame)");
|
||||
_frameSerial = signalValue;
|
||||
|
||||
VulkanSwapchainAction presented = swapchain.Present(_presentQueue, imageIndex);
|
||||
switch (presented)
|
||||
{
|
||||
case VulkanSwapchainAction.RecreateNow:
|
||||
RecreateSwapchain();
|
||||
return false;
|
||||
case VulkanSwapchainAction.RecreateAtFrameBoundary:
|
||||
_recreateAtFrameBoundary = true;
|
||||
break;
|
||||
case VulkanSwapchainAction.Fail:
|
||||
throw new InvalidOperationException(
|
||||
"vkQueuePresentKHR returned an unrecoverable result.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RecordClear(
|
||||
CommandBuffer commands,
|
||||
VulkanSwapchain swapchain,
|
||||
uint imageIndex)
|
||||
{
|
||||
Silk.NET.Vulkan.Vk vk = _vk!;
|
||||
VulkanSwapchainConfiguration configuration = swapchain.Configuration!;
|
||||
|
||||
var begin = new CommandBufferBeginInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferBeginInfo,
|
||||
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
||||
};
|
||||
VulkanInterop.Check(vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer");
|
||||
|
||||
var subresource = new ImageSubresourceRange
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
BaseMipLevel = 0,
|
||||
LevelCount = 1,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
};
|
||||
swapchain.TransitionImage(
|
||||
commands,
|
||||
swapchain.ImageAt(imageIndex),
|
||||
subresource,
|
||||
ImageLayout.Undefined,
|
||||
ImageLayout.ColorAttachmentOptimal,
|
||||
PipelineStageFlags2.TopOfPipeBit,
|
||||
AccessFlags2.None,
|
||||
PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
AccessFlags2.ColorAttachmentWriteBit);
|
||||
|
||||
var clear = new ClearValue
|
||||
{
|
||||
Color = new ClearColorValue
|
||||
{
|
||||
Float32_0 = ClearColor[0],
|
||||
Float32_1 = ClearColor[1],
|
||||
Float32_2 = ClearColor[2],
|
||||
Float32_3 = ClearColor[3],
|
||||
},
|
||||
};
|
||||
var attachment = new RenderingAttachmentInfo
|
||||
{
|
||||
SType = StructureType.RenderingAttachmentInfo,
|
||||
ImageView = swapchain.ViewAt(imageIndex),
|
||||
ImageLayout = ImageLayout.ColorAttachmentOptimal,
|
||||
LoadOp = AttachmentLoadOp.Clear,
|
||||
StoreOp = AttachmentStoreOp.Store,
|
||||
ClearValue = clear,
|
||||
};
|
||||
var rendering = new RenderingInfo
|
||||
{
|
||||
SType = StructureType.RenderingInfo,
|
||||
RenderArea = new Rect2D(
|
||||
new Offset2D(0, 0),
|
||||
new Extent2D(configuration.Width, configuration.Height)),
|
||||
LayerCount = 1,
|
||||
ColorAttachmentCount = 1,
|
||||
PColorAttachments = &attachment,
|
||||
};
|
||||
vk.CmdBeginRendering(commands, &rendering);
|
||||
vk.CmdEndRendering(commands);
|
||||
|
||||
swapchain.TransitionImage(
|
||||
commands,
|
||||
swapchain.ImageAt(imageIndex),
|
||||
subresource,
|
||||
ImageLayout.ColorAttachmentOptimal,
|
||||
ImageLayout.PresentSrcKhr,
|
||||
PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
AccessFlags2.ColorAttachmentWriteBit,
|
||||
PipelineStageFlags2.BottomOfPipeBit,
|
||||
AccessFlags2.None);
|
||||
|
||||
VulkanInterop.Check(vk.EndCommandBuffer(commands), "vkEndCommandBuffer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teardown in strict reverse-construction order. Every handle is checked
|
||||
/// before destruction because <see cref="Run"/> can throw at any stage — a
|
||||
/// rejected capability gate is a normal, expected exit, not a crash, and it
|
||||
/// must still leave zero Vulkan objects behind.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
Silk.NET.Vulkan.Vk? vk = _vk;
|
||||
if (vk is not null && _device.Handle != 0)
|
||||
{
|
||||
vk.DeviceWaitIdle(_device);
|
||||
|
||||
_swapchain?.Dispose();
|
||||
_swapchain = null;
|
||||
|
||||
if (_timeline.Handle != 0)
|
||||
{
|
||||
vk.DestroySemaphore(_device, _timeline, null);
|
||||
_timeline = default;
|
||||
}
|
||||
|
||||
foreach (Semaphore semaphore in _imageAcquired)
|
||||
{
|
||||
if (semaphore.Handle != 0)
|
||||
vk.DestroySemaphore(_device, semaphore, null);
|
||||
}
|
||||
|
||||
foreach (CommandPool pool in _commandPools)
|
||||
{
|
||||
if (pool.Handle != 0)
|
||||
vk.DestroyCommandPool(_device, pool, null);
|
||||
}
|
||||
|
||||
_imageAcquired = [];
|
||||
_commandBuffers = [];
|
||||
_commandPools = [];
|
||||
|
||||
vk.DestroyDevice(_device, null);
|
||||
_device = default;
|
||||
}
|
||||
else
|
||||
{
|
||||
_swapchain?.Dispose();
|
||||
_swapchain = null;
|
||||
}
|
||||
|
||||
if (vk is not null && _surfaceApi is not null && _surface.Handle != 0)
|
||||
{
|
||||
_surfaceApi.DestroySurface(_instance, _surface, null);
|
||||
_surface = default;
|
||||
}
|
||||
|
||||
_swapchainApi?.Dispose();
|
||||
_swapchainApi = null;
|
||||
_surfaceApi?.Dispose();
|
||||
_surfaceApi = null;
|
||||
|
||||
if (vk is not null && _instance.Handle != 0)
|
||||
{
|
||||
vk.DestroyInstance(_instance, null);
|
||||
_instance = default;
|
||||
}
|
||||
|
||||
vk?.Dispose();
|
||||
_vk = null;
|
||||
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
}
|
||||
}
|
||||
665
src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
Normal file
665
src/AcDream.App/Rendering/Gpu/Vk/VulkanCapabilityRecord.cs
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
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="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,
|
||||
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 ten storage
|
||||
// bindings the model declares 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.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);
|
||||
}
|
||||
}
|
||||
94
src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs
Normal file
94
src/AcDream.App/Rendering/Gpu/Vk/VulkanExtensionSelection.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>The extensions actually asked for, plus the optional ones that were not available.</summary>
|
||||
internal sealed record VulkanExtensionPlan(
|
||||
IReadOnlyList<string> Enabled,
|
||||
IReadOnlyList<string> MissingRequired,
|
||||
IReadOnlyList<string> UnavailableOptional)
|
||||
{
|
||||
internal bool IsSatisfied => MissingRequired.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5, plan §4.1: "Vulkan 1.3 core plus <c>VK_KHR_swapchain</c>
|
||||
/// (and the platform surface extensions). Optional and never required:
|
||||
/// <c>VK_EXT_memory_budget</c> (telemetry), <c>VK_EXT_debug_utils</c> (object
|
||||
/// naming in dev builds), <c>VK_KHR_present_wait</c> (an issue #235
|
||||
/// experiment)."
|
||||
///
|
||||
/// Pure set arithmetic over extension name lists, so "an optional extension that
|
||||
/// is missing must not stop startup, and a required one must" is a unit test
|
||||
/// rather than a driver-dependent discovery.
|
||||
/// </summary>
|
||||
internal static class VulkanExtensionSelection
|
||||
{
|
||||
/// <summary>Object naming for RenderDoc and validation output. Never required.</summary>
|
||||
internal const string DebugUtilsExtension = "VK_EXT_debug_utils";
|
||||
|
||||
/// <summary>Real allocator headroom for <c>GpuMemoryTracker</c>. Never required.</summary>
|
||||
internal const string MemoryBudgetExtension = "VK_EXT_memory_budget";
|
||||
|
||||
/// <summary>An issue #235 present-pacing experiment. Never required.</summary>
|
||||
internal const string PresentWaitExtension = "VK_KHR_present_wait";
|
||||
|
||||
/// <summary>Presenting to a surface. Required on the real device; absent in the headless probe.</summary>
|
||||
internal const string SwapchainExtension = "VK_KHR_swapchain";
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a plan. <paramref name="required"/> comes from Silk's
|
||||
/// <c>IVkSurface.GetRequiredExtensions</c> for the instance and is a fixed
|
||||
/// list for the device; <paramref name="optional"/> is enabled only where
|
||||
/// advertised. Comparison is ordinal — Vulkan extension names are ASCII and
|
||||
/// case-sensitive.
|
||||
/// </summary>
|
||||
internal static VulkanExtensionPlan Resolve(
|
||||
IReadOnlyList<string> available,
|
||||
IReadOnlyList<string> required,
|
||||
IReadOnlyList<string> optional)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(available);
|
||||
ArgumentNullException.ThrowIfNull(required);
|
||||
ArgumentNullException.ThrowIfNull(optional);
|
||||
|
||||
var advertised = new HashSet<string>(available, StringComparer.Ordinal);
|
||||
var enabled = new List<string>();
|
||||
var missing = new List<string>();
|
||||
var unavailable = new List<string>();
|
||||
|
||||
foreach (string name in required)
|
||||
{
|
||||
if (advertised.Contains(name))
|
||||
{
|
||||
if (!enabled.Contains(name, StringComparer.Ordinal))
|
||||
enabled.Add(name);
|
||||
}
|
||||
else if (!missing.Contains(name, StringComparer.Ordinal))
|
||||
{
|
||||
missing.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string name in optional)
|
||||
{
|
||||
if (advertised.Contains(name))
|
||||
{
|
||||
if (!enabled.Contains(name, StringComparer.Ordinal))
|
||||
enabled.Add(name);
|
||||
}
|
||||
else if (!unavailable.Contains(name, StringComparer.Ordinal))
|
||||
{
|
||||
unavailable.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return new VulkanExtensionPlan(enabled, missing, unavailable);
|
||||
}
|
||||
|
||||
/// <summary>The optional instance extensions slice V5 will take if offered.</summary>
|
||||
internal static IReadOnlyList<string> OptionalInstanceExtensions { get; } =
|
||||
[DebugUtilsExtension];
|
||||
|
||||
/// <summary>The optional device extensions slice V5 will take if offered.</summary>
|
||||
internal static IReadOnlyList<string> OptionalDeviceExtensions { get; } =
|
||||
[MemoryBudgetExtension, PresentWaitExtension];
|
||||
}
|
||||
635
src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
Normal file
635
src/AcDream.App/Rendering/Gpu/Vk/VulkanInterop.cs
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
using Silk.NET.Core;
|
||||
using Silk.NET.Core.Native;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>Raised when a Vulkan entry point returns a failure code.</summary>
|
||||
internal sealed class VulkanCallException : InvalidOperationException
|
||||
{
|
||||
internal VulkanCallException(string operation, Result result)
|
||||
: base($"Vulkan call failed: {operation} returned {result}.")
|
||||
{
|
||||
Operation = operation;
|
||||
Result = result;
|
||||
}
|
||||
|
||||
internal string Operation { get; }
|
||||
|
||||
internal Result Result { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Small marshalling and error-checking helpers shared by the slice V5 interop
|
||||
/// files. Deliberately tiny: the interesting decisions all live in the pure
|
||||
/// selection types beside this one, which is what lets them be unit-tested with
|
||||
/// no driver present.
|
||||
/// </summary>
|
||||
internal static unsafe class VulkanInterop
|
||||
{
|
||||
/// <summary>Throw with the operation named when a call did not succeed.</summary>
|
||||
internal static void Check(Result result, string operation)
|
||||
{
|
||||
if (result != Result.Success)
|
||||
throw new VulkanCallException(operation, result);
|
||||
}
|
||||
|
||||
/// <summary>Read a NUL-terminated ASCII field out of a Vulkan struct.</summary>
|
||||
internal static string ReadString(byte* value)
|
||||
=> value is null ? string.Empty : SilkMarshal.PtrToString((nint)value) ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Encode a managed string list as the <c>const char* const*</c> Vulkan
|
||||
/// expects. The returned handle owns the native memory and must be freed.
|
||||
/// </summary>
|
||||
internal static nint AllocateStringArray(IReadOnlyList<string> values)
|
||||
=> SilkMarshal.StringArrayToPtr(values.ToArray());
|
||||
|
||||
internal static void FreeStringArray(nint handle)
|
||||
{
|
||||
if (handle != 0)
|
||||
SilkMarshal.Free(handle);
|
||||
}
|
||||
|
||||
/// <summary>Enumerate the instance extension names the loader advertises.</summary>
|
||||
internal static IReadOnlyList<string> EnumerateInstanceExtensions(Silk.NET.Vulkan.Vk vk)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
uint count = 0;
|
||||
Check(
|
||||
vk.EnumerateInstanceExtensionProperties((byte*)null, ref count, null),
|
||||
"vkEnumerateInstanceExtensionProperties (count)");
|
||||
if (count == 0)
|
||||
return [];
|
||||
|
||||
var properties = new ExtensionProperties[count];
|
||||
fixed (ExtensionProperties* first = properties)
|
||||
{
|
||||
Check(
|
||||
vk.EnumerateInstanceExtensionProperties((byte*)null, ref count, first),
|
||||
"vkEnumerateInstanceExtensionProperties");
|
||||
}
|
||||
|
||||
return ReadNames(properties, count);
|
||||
}
|
||||
|
||||
/// <summary>Enumerate the device extension names one physical device advertises.</summary>
|
||||
internal static IReadOnlyList<string> EnumerateDeviceExtensions(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
uint count = 0;
|
||||
Check(
|
||||
vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, null),
|
||||
"vkEnumerateDeviceExtensionProperties (count)");
|
||||
if (count == 0)
|
||||
return [];
|
||||
|
||||
var properties = new ExtensionProperties[count];
|
||||
fixed (ExtensionProperties* first = properties)
|
||||
{
|
||||
Check(
|
||||
vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, first),
|
||||
"vkEnumerateDeviceExtensionProperties");
|
||||
}
|
||||
|
||||
return ReadNames(properties, count);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadNames(ExtensionProperties[] properties, uint count)
|
||||
{
|
||||
var names = new List<string>((int)count);
|
||||
for (uint i = 0; i < count && i < properties.Length; i++)
|
||||
{
|
||||
ExtensionProperties entry = properties[i];
|
||||
names.Add(ReadString(entry.ExtensionName));
|
||||
}
|
||||
|
||||
names.Sort(StringComparer.Ordinal);
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice V5 interop: create the Vulkan instance.
|
||||
///
|
||||
/// The instance is created at API version 1.3 because the whole feature floor is
|
||||
/// core-1.3; a loader that cannot honour that fails here with a clear message
|
||||
/// rather than producing a device that silently lacks
|
||||
/// <c>dynamicRendering</c>.
|
||||
/// </summary>
|
||||
internal sealed unsafe class VulkanInstanceFactory
|
||||
{
|
||||
internal sealed record Created(
|
||||
Instance Instance,
|
||||
IReadOnlyList<string> EnabledExtensions,
|
||||
IReadOnlyList<string> UnavailableOptionalExtensions,
|
||||
uint ApiVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Create the instance. <paramref name="requiredExtensions"/> comes from
|
||||
/// Silk's surface (<c>VK_KHR_surface</c> plus the platform one) and is empty
|
||||
/// for the headless capability probe.
|
||||
/// </summary>
|
||||
internal static Created Create(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
IReadOnlyList<string> requiredExtensions,
|
||||
bool enableOptionalExtensions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
ArgumentNullException.ThrowIfNull(requiredExtensions);
|
||||
|
||||
IReadOnlyList<string> available = VulkanInterop.EnumerateInstanceExtensions(vk);
|
||||
VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
|
||||
available,
|
||||
requiredExtensions,
|
||||
enableOptionalExtensions
|
||||
? VulkanExtensionSelection.OptionalInstanceExtensions
|
||||
: []);
|
||||
if (!plan.IsSatisfied)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"The Vulkan loader does not advertise the required instance " +
|
||||
$"extension(s): {string.Join(", ", plan.MissingRequired)}.");
|
||||
}
|
||||
|
||||
uint apiVersion = VulkanApiVersion.Make(
|
||||
VulkanCapabilityRequirements.RequiredApiMajor,
|
||||
VulkanCapabilityRequirements.RequiredApiMinor,
|
||||
0);
|
||||
|
||||
nint applicationName = SilkMarshal.StringToPtr("acdream");
|
||||
nint engineName = SilkMarshal.StringToPtr("acdream");
|
||||
nint extensionNames = VulkanInterop.AllocateStringArray(plan.Enabled);
|
||||
try
|
||||
{
|
||||
var application = new ApplicationInfo
|
||||
{
|
||||
SType = StructureType.ApplicationInfo,
|
||||
PApplicationName = (byte*)applicationName,
|
||||
ApplicationVersion = VulkanApiVersion.Make(0, 1, 0),
|
||||
PEngineName = (byte*)engineName,
|
||||
EngineVersion = VulkanApiVersion.Make(0, 1, 0),
|
||||
ApiVersion = apiVersion,
|
||||
};
|
||||
var create = new InstanceCreateInfo
|
||||
{
|
||||
SType = StructureType.InstanceCreateInfo,
|
||||
PApplicationInfo = &application,
|
||||
EnabledExtensionCount = (uint)plan.Enabled.Count,
|
||||
PpEnabledExtensionNames = (byte**)extensionNames,
|
||||
EnabledLayerCount = 0,
|
||||
PpEnabledLayerNames = null,
|
||||
};
|
||||
|
||||
VulkanInterop.Check(
|
||||
vk.CreateInstance(&create, null, out Instance instance),
|
||||
"vkCreateInstance");
|
||||
return new Created(
|
||||
instance,
|
||||
plan.Enabled,
|
||||
plan.UnavailableOptional,
|
||||
apiVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VulkanInterop.FreeStringArray(extensionNames);
|
||||
SilkMarshal.Free(engineName);
|
||||
SilkMarshal.Free(applicationName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice V5 interop: read one physical device's identity, features, limits and
|
||||
/// formats into the pure records the capability gate evaluates.
|
||||
///
|
||||
/// Everything this class produces is plain data. That is the seam: the gate's
|
||||
/// accept/reject matrix is exercised in unit tests against synthesised records,
|
||||
/// and this class only has to be right about which Vulkan field feeds which
|
||||
/// property.
|
||||
/// </summary>
|
||||
internal static unsafe class VulkanPhysicalDeviceInspector
|
||||
{
|
||||
/// <summary>Enumerate every physical device as a rankable candidate.</summary>
|
||||
internal static IReadOnlyList<VulkanPhysicalDeviceCandidate> Enumerate(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
Instance instance,
|
||||
out PhysicalDevice[] handles)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
|
||||
uint count = 0;
|
||||
VulkanInterop.Check(
|
||||
vk.EnumeratePhysicalDevices(instance, ref count, null),
|
||||
"vkEnumeratePhysicalDevices (count)");
|
||||
handles = new PhysicalDevice[count];
|
||||
if (count == 0)
|
||||
return [];
|
||||
|
||||
fixed (PhysicalDevice* first = handles)
|
||||
{
|
||||
VulkanInterop.Check(
|
||||
vk.EnumeratePhysicalDevices(instance, ref count, first),
|
||||
"vkEnumeratePhysicalDevices");
|
||||
}
|
||||
|
||||
var candidates = new List<VulkanPhysicalDeviceCandidate>((int)count);
|
||||
for (int i = 0; i < handles.Length; i++)
|
||||
{
|
||||
PhysicalDeviceProperties properties;
|
||||
vk.GetPhysicalDeviceProperties(handles[i], &properties);
|
||||
candidates.Add(
|
||||
new VulkanPhysicalDeviceCandidate(
|
||||
i,
|
||||
VulkanInterop.ReadString(properties.DeviceName),
|
||||
properties.DeviceType,
|
||||
properties.ApiVersion,
|
||||
properties.DriverVersion,
|
||||
properties.VendorID,
|
||||
properties.DeviceID,
|
||||
LargestDeviceLocalHeap(vk, handles[i])));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sum of the device-local heaps, which is the tie-break the plan specifies.
|
||||
/// Summed rather than maximised because a device that splits its VRAM across
|
||||
/// heaps is not smaller than one that does not.
|
||||
/// </summary>
|
||||
internal static ulong LargestDeviceLocalHeap(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device)
|
||||
{
|
||||
PhysicalDeviceMemoryProperties memory;
|
||||
vk.GetPhysicalDeviceMemoryProperties(device, &memory);
|
||||
ulong total = 0;
|
||||
for (uint i = 0; i < memory.MemoryHeapCount && i < 16; i++)
|
||||
{
|
||||
MemoryHeap heap = memory.MemoryHeaps[(int)i];
|
||||
if (heap.Flags.HasFlag(MemoryHeapFlags.DeviceLocalBit))
|
||||
total += heap.Size;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>Read the §4.1 feature chain through <c>vkGetPhysicalDeviceFeatures2</c>.</summary>
|
||||
internal static VulkanDeviceFeatureSupport ReadFeatures(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
|
||||
var vulkan13 = new PhysicalDeviceVulkan13Features
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceVulkan13Features,
|
||||
};
|
||||
var vulkan12 = new PhysicalDeviceVulkan12Features
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceVulkan12Features,
|
||||
PNext = &vulkan13,
|
||||
};
|
||||
var vulkan11 = new PhysicalDeviceVulkan11Features
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceVulkan11Features,
|
||||
PNext = &vulkan12,
|
||||
};
|
||||
var features2 = new PhysicalDeviceFeatures2
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceFeatures2,
|
||||
PNext = &vulkan11,
|
||||
};
|
||||
vk.GetPhysicalDeviceFeatures2(device, &features2);
|
||||
|
||||
PhysicalDeviceFeatures core = features2.Features;
|
||||
return new VulkanDeviceFeatureSupport
|
||||
{
|
||||
MultiDrawIndirect = core.MultiDrawIndirect,
|
||||
DrawIndirectFirstInstance = core.DrawIndirectFirstInstance,
|
||||
ShaderClipDistance = core.ShaderClipDistance,
|
||||
TextureCompressionBc = core.TextureCompressionBC,
|
||||
SamplerAnisotropy = core.SamplerAnisotropy,
|
||||
ShaderDrawParameters = vulkan11.ShaderDrawParameters,
|
||||
TimelineSemaphore = vulkan12.TimelineSemaphore,
|
||||
HostQueryReset = vulkan12.HostQueryReset,
|
||||
RuntimeDescriptorArray = vulkan12.RuntimeDescriptorArray,
|
||||
DescriptorBindingPartiallyBound = vulkan12.DescriptorBindingPartiallyBound,
|
||||
DescriptorBindingSampledImageUpdateAfterBind =
|
||||
vulkan12.DescriptorBindingSampledImageUpdateAfterBind,
|
||||
DescriptorBindingUpdateUnusedWhilePending =
|
||||
vulkan12.DescriptorBindingUpdateUnusedWhilePending,
|
||||
DescriptorBindingVariableDescriptorCount =
|
||||
vulkan12.DescriptorBindingVariableDescriptorCount,
|
||||
ShaderSampledImageArrayNonUniformIndexing =
|
||||
vulkan12.ShaderSampledImageArrayNonUniformIndexing,
|
||||
DynamicRendering = vulkan13.DynamicRendering,
|
||||
Synchronization2 = vulkan13.Synchronization2,
|
||||
Maintenance4 = vulkan13.Maintenance4,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the limits the plan asserts up front. The update-after-bind sampled
|
||||
/// image counts come from the descriptor-indexing properties chain, not from
|
||||
/// <c>PhysicalDeviceLimits</c> — reading the ordinary
|
||||
/// <c>maxDescriptorSetSampledImages</c> instead is a real and easy mistake,
|
||||
/// because it is usually large enough to look like a pass.
|
||||
/// </summary>
|
||||
internal static VulkanDeviceLimitSupport ReadLimits(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
|
||||
var indexing = new PhysicalDeviceDescriptorIndexingProperties
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceDescriptorIndexingProperties,
|
||||
};
|
||||
var properties2 = new PhysicalDeviceProperties2
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceProperties2,
|
||||
PNext = &indexing,
|
||||
};
|
||||
vk.GetPhysicalDeviceProperties2(device, &properties2);
|
||||
|
||||
PhysicalDeviceLimits limits = properties2.Properties.Limits;
|
||||
return new VulkanDeviceLimitSupport
|
||||
{
|
||||
MaxPushConstantsSize = limits.MaxPushConstantsSize,
|
||||
MaxClipDistances = limits.MaxClipDistances,
|
||||
MaxBoundDescriptorSets = limits.MaxBoundDescriptorSets,
|
||||
MaxDescriptorSetUpdateAfterBindSampledImages =
|
||||
indexing.MaxDescriptorSetUpdateAfterBindSampledImages,
|
||||
MaxPerStageDescriptorUpdateAfterBindSampledImages =
|
||||
indexing.MaxPerStageDescriptorUpdateAfterBindSampledImages,
|
||||
TimestampComputeAndGraphics = limits.TimestampComputeAndGraphics,
|
||||
MinStorageBufferOffsetAlignment = (uint)limits.MinStorageBufferOffsetAlignment,
|
||||
MinUniformBufferOffsetAlignment = (uint)limits.MinUniformBufferOffsetAlignment,
|
||||
MaxImageDimension2D = limits.MaxImageDimension2D,
|
||||
MaxColorSampleCount = HighestSampleCount(
|
||||
limits.FramebufferColorSampleCounts & limits.FramebufferDepthSampleCounts),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Highest set bit of a sample-count mask, as a plain count.</summary>
|
||||
internal static uint HighestSampleCount(SampleCountFlags counts)
|
||||
{
|
||||
if (counts.HasFlag(SampleCountFlags.Count8Bit)) return 8;
|
||||
if (counts.HasFlag(SampleCountFlags.Count4Bit)) return 4;
|
||||
if (counts.HasFlag(SampleCountFlags.Count2Bit)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe the formats features alone do not prove. Depth prefers
|
||||
/// <c>D32_SFLOAT_S8_UINT</c> and falls back to <c>D24_UNORM_S8_UINT</c>; the
|
||||
/// stencil aspect is required by #117's portal punch (plan §4.5).
|
||||
/// </summary>
|
||||
internal static VulkanFormatSupport ReadFormats(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device,
|
||||
bool surfaceOffersUnorm)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
|
||||
return new VulkanFormatSupport
|
||||
{
|
||||
SwapchainUnormFormat = surfaceOffersUnorm,
|
||||
DepthStencilFormat = ChooseDepthStencilFormat(vk, device),
|
||||
Bc1Sampled = SupportsOptimalSampling(vk, device, Format.BC1RgbaUnormBlock),
|
||||
Bc2Sampled = SupportsOptimalSampling(vk, device, Format.BC2UnormBlock),
|
||||
Bc3Sampled = SupportsOptimalSampling(vk, device, Format.BC3UnormBlock),
|
||||
};
|
||||
}
|
||||
|
||||
internal static Format ChooseDepthStencilFormat(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device)
|
||||
{
|
||||
foreach (Format candidate in new[] { Format.D32SfloatS8Uint, Format.D24UnormS8Uint })
|
||||
{
|
||||
FormatProperties properties;
|
||||
vk.GetPhysicalDeviceFormatProperties(device, candidate, &properties);
|
||||
if (properties.OptimalTilingFeatures.HasFlag(
|
||||
FormatFeatureFlags.DepthStencilAttachmentBit))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return Format.Undefined;
|
||||
}
|
||||
|
||||
internal static bool SupportsOptimalSampling(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device,
|
||||
Format format)
|
||||
{
|
||||
FormatProperties properties;
|
||||
vk.GetPhysicalDeviceFormatProperties(device, format, &properties);
|
||||
return properties.OptimalTilingFeatures.HasFlag(FormatFeatureFlags.SampledImageBit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate queue families, reporting present support only when a surface
|
||||
/// is supplied. The headless probe passes <c>null</c> and takes the
|
||||
/// graphics-only path.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<VulkanQueueFamilyCandidate> ReadQueueFamilies(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice device,
|
||||
Silk.NET.Vulkan.Extensions.KHR.KhrSurface? surfaceApi,
|
||||
SurfaceKHR surface)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
|
||||
uint count = 0;
|
||||
vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, null);
|
||||
if (count == 0)
|
||||
return [];
|
||||
|
||||
var properties = new QueueFamilyProperties[count];
|
||||
fixed (QueueFamilyProperties* first = properties)
|
||||
vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, first);
|
||||
|
||||
var families = new List<VulkanQueueFamilyCandidate>((int)count);
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
bool graphics = properties[i].QueueFlags.HasFlag(QueueFlags.GraphicsBit);
|
||||
bool present = false;
|
||||
if (surfaceApi is not null)
|
||||
{
|
||||
VulkanInterop.Check(
|
||||
surfaceApi.GetPhysicalDeviceSurfaceSupport(
|
||||
device,
|
||||
i,
|
||||
surface,
|
||||
out Bool32 supported),
|
||||
"vkGetPhysicalDeviceSurfaceSupportKHR");
|
||||
present = supported;
|
||||
}
|
||||
|
||||
families.Add(new VulkanQueueFamilyCandidate(i, graphics, present));
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
/// <summary>A short, stable driver identification string for bug reports.</summary>
|
||||
internal static string DescribeDriver(VulkanPhysicalDeviceCandidate device)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(device);
|
||||
return
|
||||
$"vendor 0x{device.VendorId:X4}, device 0x{device.DeviceId:X4}, " +
|
||||
$"driver {VulkanApiVersion.Major(device.DriverVersion)}." +
|
||||
$"{VulkanApiVersion.Minor(device.DriverVersion)}." +
|
||||
$"{VulkanApiVersion.Patch(device.DriverVersion)} " +
|
||||
$"(raw 0x{device.DriverVersion:X8})";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice V5 interop: create the logical device with the exact §4.1 feature
|
||||
/// chain enabled.
|
||||
///
|
||||
/// Enabling precisely the features the capability gate verified — no more —
|
||||
/// keeps the two in lockstep. A device created with a feature the gate never
|
||||
/// checked is how a V6 renderer ends up depending on something a second GPU does
|
||||
/// not have.
|
||||
/// </summary>
|
||||
internal sealed unsafe class VulkanLogicalDeviceFactory
|
||||
{
|
||||
internal sealed record Created(
|
||||
Device Device,
|
||||
Queue GraphicsQueue,
|
||||
Queue PresentQueue,
|
||||
VulkanQueueFamilyChoice Families,
|
||||
IReadOnlyList<string> EnabledExtensions,
|
||||
IReadOnlyList<string> UnavailableOptionalExtensions);
|
||||
|
||||
internal static Created Create(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
PhysicalDevice physicalDevice,
|
||||
VulkanQueueFamilyChoice families,
|
||||
bool requireSwapchain)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vk);
|
||||
ArgumentNullException.ThrowIfNull(families);
|
||||
|
||||
IReadOnlyList<string> available =
|
||||
VulkanInterop.EnumerateDeviceExtensions(vk, physicalDevice);
|
||||
VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
|
||||
available,
|
||||
requireSwapchain ? [VulkanExtensionSelection.SwapchainExtension] : [],
|
||||
VulkanExtensionSelection.OptionalDeviceExtensions);
|
||||
if (!plan.IsSatisfied)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"The selected Vulkan device does not advertise the required " +
|
||||
$"extension(s): {string.Join(", ", plan.MissingRequired)}.");
|
||||
}
|
||||
|
||||
var priority = 1f;
|
||||
uint[] uniqueFamilies = families.IsUnified
|
||||
? [families.GraphicsFamily]
|
||||
: [families.GraphicsFamily, families.PresentFamily];
|
||||
var queueCreates = new DeviceQueueCreateInfo[uniqueFamilies.Length];
|
||||
for (int i = 0; i < uniqueFamilies.Length; i++)
|
||||
{
|
||||
queueCreates[i] = new DeviceQueueCreateInfo
|
||||
{
|
||||
SType = StructureType.DeviceQueueCreateInfo,
|
||||
QueueFamilyIndex = uniqueFamilies[i],
|
||||
QueueCount = 1,
|
||||
PQueuePriorities = &priority,
|
||||
};
|
||||
}
|
||||
|
||||
var vulkan13 = new PhysicalDeviceVulkan13Features
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceVulkan13Features,
|
||||
DynamicRendering = true,
|
||||
Synchronization2 = true,
|
||||
Maintenance4 = true,
|
||||
};
|
||||
var vulkan12 = new PhysicalDeviceVulkan12Features
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceVulkan12Features,
|
||||
PNext = &vulkan13,
|
||||
DescriptorIndexing = true,
|
||||
RuntimeDescriptorArray = true,
|
||||
DescriptorBindingPartiallyBound = true,
|
||||
DescriptorBindingSampledImageUpdateAfterBind = true,
|
||||
DescriptorBindingUpdateUnusedWhilePending = true,
|
||||
DescriptorBindingVariableDescriptorCount = true,
|
||||
ShaderSampledImageArrayNonUniformIndexing = true,
|
||||
TimelineSemaphore = true,
|
||||
HostQueryReset = true,
|
||||
};
|
||||
var vulkan11 = new PhysicalDeviceVulkan11Features
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceVulkan11Features,
|
||||
PNext = &vulkan12,
|
||||
ShaderDrawParameters = true,
|
||||
};
|
||||
var core = new PhysicalDeviceFeatures
|
||||
{
|
||||
MultiDrawIndirect = true,
|
||||
DrawIndirectFirstInstance = true,
|
||||
ShaderClipDistance = true,
|
||||
TextureCompressionBC = true,
|
||||
SamplerAnisotropy = true,
|
||||
};
|
||||
var features2 = new PhysicalDeviceFeatures2
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceFeatures2,
|
||||
PNext = &vulkan11,
|
||||
Features = core,
|
||||
};
|
||||
|
||||
nint extensionNames = VulkanInterop.AllocateStringArray(plan.Enabled);
|
||||
try
|
||||
{
|
||||
fixed (DeviceQueueCreateInfo* queues = queueCreates)
|
||||
{
|
||||
var create = new DeviceCreateInfo
|
||||
{
|
||||
SType = StructureType.DeviceCreateInfo,
|
||||
PNext = &features2,
|
||||
QueueCreateInfoCount = (uint)queueCreates.Length,
|
||||
PQueueCreateInfos = queues,
|
||||
EnabledExtensionCount = (uint)plan.Enabled.Count,
|
||||
PpEnabledExtensionNames = (byte**)extensionNames,
|
||||
// PEnabledFeatures must stay null when the feature chain is
|
||||
// supplied through pNext; passing both is invalid usage.
|
||||
PEnabledFeatures = null,
|
||||
};
|
||||
|
||||
VulkanInterop.Check(
|
||||
vk.CreateDevice(physicalDevice, &create, null, out Device device),
|
||||
"vkCreateDevice");
|
||||
|
||||
vk.GetDeviceQueue(device, families.GraphicsFamily, 0, out Queue graphics);
|
||||
Queue present = graphics;
|
||||
if (!families.IsUnified)
|
||||
vk.GetDeviceQueue(device, families.PresentFamily, 0, out present);
|
||||
|
||||
return new Created(
|
||||
device,
|
||||
graphics,
|
||||
present,
|
||||
families,
|
||||
plan.Enabled,
|
||||
plan.UnavailableOptional);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
VulkanInterop.FreeStringArray(extensionNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
using System.Globalization;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>The chosen device plus the sentence explaining why, which the report records verbatim.</summary>
|
||||
internal sealed record VulkanPhysicalDeviceChoice(
|
||||
VulkanPhysicalDeviceCandidate Device,
|
||||
string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5, plan §4.11: "discrete > integrated > virtual >
|
||||
/// CPU, tie-broken by largest device-local heap, with an
|
||||
/// <c>ACDREAM_VULKAN_DEVICE</c> override recorded in the report."
|
||||
///
|
||||
/// Pure ranking over <see cref="VulkanPhysicalDeviceCandidate"/> values so the
|
||||
/// policy is unit-tested without enumerating a real instance — which matters
|
||||
/// because this machine has exactly one GPU and the ordering it exercises is
|
||||
/// therefore never the interesting case.
|
||||
/// </summary>
|
||||
internal static class VulkanPhysicalDeviceSelection
|
||||
{
|
||||
/// <summary>
|
||||
/// Preference rank; lower is better. Vulkan's <see cref="PhysicalDeviceType"/>
|
||||
/// numbering is Other(0) < Integrated(1) < Discrete(2) < Virtual(3)
|
||||
/// < Cpu(4), which is neither our order nor a monotone one, so it is
|
||||
/// mapped explicitly rather than compared numerically.
|
||||
/// </summary>
|
||||
internal static int PreferenceRank(PhysicalDeviceType type) => type switch
|
||||
{
|
||||
PhysicalDeviceType.DiscreteGpu => 0,
|
||||
PhysicalDeviceType.IntegratedGpu => 1,
|
||||
PhysicalDeviceType.VirtualGpu => 2,
|
||||
PhysicalDeviceType.Cpu => 3,
|
||||
_ => 4,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Choose a device.
|
||||
///
|
||||
/// <para><paramref name="deviceOverride"/> is an enumeration index when it is
|
||||
/// entirely decimal digits, and a case-insensitive device-name substring
|
||||
/// otherwise. The split is exact rather than "try index, then fall back to
|
||||
/// substring" because a bare digit is a substring of most real device names —
|
||||
/// <c>7</c> occurs in "AMD Radeon RX 9070 XT" — so a fall-through would make
|
||||
/// an out-of-range index quietly select a device by coincidence. A name that
|
||||
/// genuinely contains digits ("RX 7900 XTX") still matches, because it is not
|
||||
/// digits alone.</para>
|
||||
///
|
||||
/// <para>An override that matches nothing falls back to the automatic choice
|
||||
/// and says so in the reason: refusing to start over a stale environment
|
||||
/// variable is worse than starting on the right GPU anyway.</para>
|
||||
/// </summary>
|
||||
internal static VulkanPhysicalDeviceChoice? Choose(
|
||||
IReadOnlyList<VulkanPhysicalDeviceCandidate> candidates,
|
||||
string? deviceOverride)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidates);
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceOverride))
|
||||
{
|
||||
string trimmed = deviceOverride.Trim();
|
||||
if (IsDecimalIndex(trimmed))
|
||||
{
|
||||
int index = int.Parse(trimmed, NumberStyles.None, CultureInfo.InvariantCulture);
|
||||
VulkanPhysicalDeviceCandidate? byIndex =
|
||||
candidates.FirstOrDefault(candidate => candidate.Index == index);
|
||||
if (byIndex is not null)
|
||||
{
|
||||
return new VulkanPhysicalDeviceChoice(
|
||||
byIndex,
|
||||
$"ACDREAM_VULKAN_DEVICE={trimmed} selected device index {index}.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
VulkanPhysicalDeviceCandidate? byName = candidates.FirstOrDefault(
|
||||
candidate => candidate.DeviceName.Contains(
|
||||
trimmed,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (byName is not null)
|
||||
{
|
||||
return new VulkanPhysicalDeviceChoice(
|
||||
byName,
|
||||
$"ACDREAM_VULKAN_DEVICE={trimmed} matched device name '{byName.DeviceName}'.");
|
||||
}
|
||||
}
|
||||
|
||||
VulkanPhysicalDeviceCandidate automatic = Rank(candidates);
|
||||
return new VulkanPhysicalDeviceChoice(
|
||||
automatic,
|
||||
$"ACDREAM_VULKAN_DEVICE={trimmed} matched no enumerated device; " +
|
||||
$"fell back to the automatic choice '{automatic.DeviceName}' " +
|
||||
$"({automatic.DeviceType}, {Gib(automatic.DeviceLocalHeapBytes)} device-local).");
|
||||
}
|
||||
|
||||
VulkanPhysicalDeviceCandidate chosen = Rank(candidates);
|
||||
return new VulkanPhysicalDeviceChoice(
|
||||
chosen,
|
||||
$"automatic: '{chosen.DeviceName}' ({chosen.DeviceType}, " +
|
||||
$"{Gib(chosen.DeviceLocalHeapBytes)} device-local) ranked first of " +
|
||||
$"{candidates.Count} enumerated device(s).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic ordering: type preference, then largest device-local heap,
|
||||
/// then enumeration index. The final index tie-break exists so two identical
|
||||
/// GPUs always produce the same choice across launches.
|
||||
/// </summary>
|
||||
internal static VulkanPhysicalDeviceCandidate Rank(
|
||||
IReadOnlyList<VulkanPhysicalDeviceCandidate> candidates)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidates);
|
||||
if (candidates.Count == 0)
|
||||
throw new ArgumentException("At least one candidate is required.", nameof(candidates));
|
||||
|
||||
VulkanPhysicalDeviceCandidate best = candidates[0];
|
||||
for (int i = 1; i < candidates.Count; i++)
|
||||
{
|
||||
if (Compare(candidates[i], best) < 0)
|
||||
best = candidates[i];
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Negative when <paramref name="left"/> is the better device.</summary>
|
||||
internal static int Compare(
|
||||
VulkanPhysicalDeviceCandidate left,
|
||||
VulkanPhysicalDeviceCandidate right)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(left);
|
||||
ArgumentNullException.ThrowIfNull(right);
|
||||
|
||||
int byType = PreferenceRank(left.DeviceType).CompareTo(PreferenceRank(right.DeviceType));
|
||||
if (byType != 0)
|
||||
return byType;
|
||||
|
||||
int byHeap = right.DeviceLocalHeapBytes.CompareTo(left.DeviceLocalHeapBytes);
|
||||
return byHeap != 0 ? byHeap : left.Index.CompareTo(right.Index);
|
||||
}
|
||||
|
||||
/// <summary>An index override is decimal digits and nothing else.</summary>
|
||||
internal static bool IsDecimalIndex(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return false;
|
||||
foreach (char character in value)
|
||||
{
|
||||
if (character is < '0' or > '9')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string Gib(ulong bytes)
|
||||
=> (bytes / (1024d * 1024d * 1024d)).ToString("0.##", CultureInfo.InvariantCulture) + " GiB";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which queue family carries graphics and which carries present. Slice V5 uses
|
||||
/// one graphics+present queue with transfers riding it (plan §4.8); a device
|
||||
/// whose present-capable family is separate is still supported, and the
|
||||
/// swapchain then declares concurrent sharing.
|
||||
/// </summary>
|
||||
internal sealed record VulkanQueueFamilyChoice(
|
||||
uint GraphicsFamily,
|
||||
uint PresentFamily)
|
||||
{
|
||||
/// <summary>True when one queue serves both, which is the case on every GPU we target.</summary>
|
||||
internal bool IsUnified => GraphicsFamily == PresentFamily;
|
||||
}
|
||||
|
||||
/// <summary>One enumerated queue family, reduced to the two facts the selector needs.</summary>
|
||||
internal readonly record struct VulkanQueueFamilyCandidate(
|
||||
uint Index,
|
||||
bool SupportsGraphics,
|
||||
bool SupportsPresent);
|
||||
|
||||
/// <summary>
|
||||
/// Pure queue-family selection. Prefers a single family that does both, because
|
||||
/// that removes the concurrent-sharing declaration and the ownership transfers
|
||||
/// that would otherwise be needed on every swapchain image.
|
||||
/// </summary>
|
||||
internal static class VulkanQueueFamilySelection
|
||||
{
|
||||
internal static VulkanQueueFamilyChoice? Choose(
|
||||
IReadOnlyList<VulkanQueueFamilyCandidate> families)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(families);
|
||||
|
||||
foreach (VulkanQueueFamilyCandidate family in families)
|
||||
{
|
||||
if (family.SupportsGraphics && family.SupportsPresent)
|
||||
return new VulkanQueueFamilyChoice(family.Index, family.Index);
|
||||
}
|
||||
|
||||
uint? graphics = null;
|
||||
uint? present = null;
|
||||
foreach (VulkanQueueFamilyCandidate family in families)
|
||||
{
|
||||
if (graphics is null && family.SupportsGraphics)
|
||||
graphics = family.Index;
|
||||
if (present is null && family.SupportsPresent)
|
||||
present = family.Index;
|
||||
}
|
||||
|
||||
return graphics is { } g && present is { } p
|
||||
? new VulkanQueueFamilyChoice(g, p)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The headless variant used by the offscreen capability probe, which has no
|
||||
/// surface and therefore no present requirement.
|
||||
/// </summary>
|
||||
internal static uint? ChooseGraphicsOnly(
|
||||
IReadOnlyList<VulkanQueueFamilyCandidate> families)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(families);
|
||||
foreach (VulkanQueueFamilyCandidate family in families)
|
||||
{
|
||||
if (family.SupportsGraphics)
|
||||
return family.Index;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
565
src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs
Normal file
565
src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchain.cs
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Vulkan;
|
||||
using Silk.NET.Vulkan.Extensions.KHR;
|
||||
using Semaphore = Silk.NET.Vulkan.Semaphore;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5, plan §4.9: the presentation swapchain.
|
||||
///
|
||||
/// Every <em>decision</em> this class makes — colour format, present mode, image
|
||||
/// count, extent, usage, transform, composite alpha, and what to do about
|
||||
/// <c>OUT_OF_DATE</c> / <c>SUBOPTIMAL</c> / a minimised window — is delegated to
|
||||
/// <see cref="VulkanSwapchainConfigurationFactory"/> and
|
||||
/// <see cref="VulkanSwapchainRecreationPolicy"/>, which are pure and unit-tested.
|
||||
/// What is left here is the calls themselves.
|
||||
///
|
||||
/// <para><b>Untested by the implementing slice.</b> Nothing below can run
|
||||
/// without a window and a driver, so it carries no automated coverage at V5. The
|
||||
/// manual "Vulkan boots to a clear colour" check is the gate.</para>
|
||||
/// </summary>
|
||||
internal sealed unsafe class VulkanSwapchain : IDisposable
|
||||
{
|
||||
private readonly Silk.NET.Vulkan.Vk _vk;
|
||||
private readonly KhrSurface _surfaceApi;
|
||||
private readonly KhrSwapchain _swapchainApi;
|
||||
private readonly PhysicalDevice _physicalDevice;
|
||||
private readonly Device _device;
|
||||
private readonly SurfaceKHR _surface;
|
||||
private readonly VulkanQueueFamilyChoice _families;
|
||||
|
||||
private SwapchainKHR _swapchain;
|
||||
private Image[] _images = [];
|
||||
private ImageView[] _views = [];
|
||||
private Semaphore[] _renderComplete = [];
|
||||
private bool _disposed;
|
||||
|
||||
internal VulkanSwapchain(
|
||||
Silk.NET.Vulkan.Vk vk,
|
||||
KhrSurface surfaceApi,
|
||||
KhrSwapchain swapchainApi,
|
||||
PhysicalDevice physicalDevice,
|
||||
Device device,
|
||||
SurfaceKHR surface,
|
||||
VulkanQueueFamilyChoice families)
|
||||
{
|
||||
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
|
||||
_surfaceApi = surfaceApi ?? throw new ArgumentNullException(nameof(surfaceApi));
|
||||
_swapchainApi = swapchainApi ?? throw new ArgumentNullException(nameof(swapchainApi));
|
||||
_physicalDevice = physicalDevice;
|
||||
_device = device;
|
||||
_surface = surface;
|
||||
_families = families ?? throw new ArgumentNullException(nameof(families));
|
||||
}
|
||||
|
||||
/// <summary>The configuration the live swapchain was created from, or null before the first create.</summary>
|
||||
internal VulkanSwapchainConfiguration? Configuration { get; private set; }
|
||||
|
||||
/// <summary>True when a swapchain object currently exists.</summary>
|
||||
internal bool IsCreated => _swapchain.Handle != 0;
|
||||
|
||||
internal int ImageCount => _images.Length;
|
||||
|
||||
internal ImageView ViewAt(uint index) => _views[index];
|
||||
|
||||
internal Image ImageAt(uint index) => _images[index];
|
||||
|
||||
internal Semaphore RenderCompleteAt(uint index) => _renderComplete[index];
|
||||
|
||||
/// <summary>Read the surface's current capabilities, formats and present modes.</summary>
|
||||
internal (SurfaceCapabilitiesKHR Capabilities,
|
||||
IReadOnlyList<SurfaceFormatKHR> Formats,
|
||||
IReadOnlyList<PresentModeKHR> PresentModes) QuerySurface()
|
||||
{
|
||||
VulkanInterop.Check(
|
||||
_surfaceApi.GetPhysicalDeviceSurfaceCapabilities(
|
||||
_physicalDevice,
|
||||
_surface,
|
||||
out SurfaceCapabilitiesKHR capabilities),
|
||||
"vkGetPhysicalDeviceSurfaceCapabilitiesKHR");
|
||||
|
||||
uint formatCount = 0;
|
||||
VulkanInterop.Check(
|
||||
_surfaceApi.GetPhysicalDeviceSurfaceFormats(
|
||||
_physicalDevice,
|
||||
_surface,
|
||||
ref formatCount,
|
||||
null),
|
||||
"vkGetPhysicalDeviceSurfaceFormatsKHR (count)");
|
||||
var formats = new SurfaceFormatKHR[formatCount];
|
||||
if (formatCount != 0)
|
||||
{
|
||||
fixed (SurfaceFormatKHR* first = formats)
|
||||
{
|
||||
VulkanInterop.Check(
|
||||
_surfaceApi.GetPhysicalDeviceSurfaceFormats(
|
||||
_physicalDevice,
|
||||
_surface,
|
||||
ref formatCount,
|
||||
first),
|
||||
"vkGetPhysicalDeviceSurfaceFormatsKHR");
|
||||
}
|
||||
}
|
||||
|
||||
uint modeCount = 0;
|
||||
VulkanInterop.Check(
|
||||
_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
|
||||
_physicalDevice,
|
||||
_surface,
|
||||
ref modeCount,
|
||||
null),
|
||||
"vkGetPhysicalDeviceSurfacePresentModesKHR (count)");
|
||||
var modes = new PresentModeKHR[modeCount];
|
||||
if (modeCount != 0)
|
||||
{
|
||||
fixed (PresentModeKHR* first = modes)
|
||||
{
|
||||
VulkanInterop.Check(
|
||||
_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
|
||||
_physicalDevice,
|
||||
_surface,
|
||||
ref modeCount,
|
||||
first),
|
||||
"vkGetPhysicalDeviceSurfacePresentModesKHR");
|
||||
}
|
||||
}
|
||||
|
||||
return (capabilities, formats, modes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create or rebuild the swapchain for the current framebuffer size. Returns
|
||||
/// false when the surface reports zero area — a minimised window — in which
|
||||
/// case the caller idles rather than spinning on a failing create.
|
||||
/// </summary>
|
||||
internal bool Recreate(FramePacingPolicy pacing, uint framebufferWidth, uint framebufferHeight)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
(SurfaceCapabilitiesKHR capabilities,
|
||||
IReadOnlyList<SurfaceFormatKHR> formats,
|
||||
IReadOnlyList<PresentModeKHR> modes) = QuerySurface();
|
||||
|
||||
VulkanSwapchainConfiguration configuration =
|
||||
VulkanSwapchainConfigurationFactory.Create(
|
||||
capabilities,
|
||||
formats,
|
||||
modes,
|
||||
pacing,
|
||||
framebufferWidth,
|
||||
framebufferHeight);
|
||||
if (!configuration.IsPresentable)
|
||||
return false;
|
||||
|
||||
SwapchainKHR old = _swapchain;
|
||||
uint* families = stackalloc uint[2]
|
||||
{
|
||||
_families.GraphicsFamily,
|
||||
_families.PresentFamily,
|
||||
};
|
||||
var create = new SwapchainCreateInfoKHR
|
||||
{
|
||||
SType = StructureType.SwapchainCreateInfoKhr,
|
||||
Surface = _surface,
|
||||
MinImageCount = configuration.ImageCount,
|
||||
ImageFormat = configuration.ImageFormat,
|
||||
ImageColorSpace = configuration.ColorSpace,
|
||||
ImageExtent = new Extent2D(configuration.Width, configuration.Height),
|
||||
ImageArrayLayers = 1,
|
||||
ImageUsage = configuration.Usage,
|
||||
// One queue does both on every GPU we target; the concurrent path
|
||||
// exists so a split-family device still works without per-image
|
||||
// ownership transfers.
|
||||
ImageSharingMode = _families.IsUnified ? SharingMode.Exclusive : SharingMode.Concurrent,
|
||||
QueueFamilyIndexCount = _families.IsUnified ? 0u : 2u,
|
||||
PQueueFamilyIndices = _families.IsUnified ? null : families,
|
||||
PreTransform = configuration.PreTransform,
|
||||
CompositeAlpha = configuration.CompositeAlpha,
|
||||
PresentMode = configuration.PresentMode,
|
||||
Clipped = true,
|
||||
OldSwapchain = old,
|
||||
};
|
||||
|
||||
VulkanInterop.Check(
|
||||
_swapchainApi.CreateSwapchain(_device, &create, null, out SwapchainKHR created),
|
||||
"vkCreateSwapchainKHR");
|
||||
|
||||
DestroyImageResources();
|
||||
if (old.Handle != 0)
|
||||
_swapchainApi.DestroySwapchain(_device, old, null);
|
||||
|
||||
_swapchain = created;
|
||||
Configuration = configuration;
|
||||
AcquireImages(configuration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AcquireImages(VulkanSwapchainConfiguration configuration)
|
||||
{
|
||||
uint count = 0;
|
||||
VulkanInterop.Check(
|
||||
_swapchainApi.GetSwapchainImages(_device, _swapchain, ref count, null),
|
||||
"vkGetSwapchainImagesKHR (count)");
|
||||
_images = new Image[count];
|
||||
fixed (Image* first = _images)
|
||||
{
|
||||
VulkanInterop.Check(
|
||||
_swapchainApi.GetSwapchainImages(_device, _swapchain, ref count, first),
|
||||
"vkGetSwapchainImagesKHR");
|
||||
}
|
||||
|
||||
_views = new ImageView[count];
|
||||
_renderComplete = new Semaphore[count];
|
||||
for (uint i = 0; i < count; i++)
|
||||
{
|
||||
var viewCreate = new ImageViewCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageViewCreateInfo,
|
||||
Image = _images[i],
|
||||
ViewType = ImageViewType.Type2D,
|
||||
Format = configuration.ImageFormat,
|
||||
SubresourceRange = new ImageSubresourceRange
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
BaseMipLevel = 0,
|
||||
LevelCount = 1,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
},
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
|
||||
"vkCreateImageView (swapchain image)");
|
||||
_views[i] = view;
|
||||
|
||||
// One render-complete semaphore per image, not per flight slot: a
|
||||
// present waits on the semaphore the submit for THAT image signalled,
|
||||
// and reusing a slot-indexed semaphore is the classic way to signal a
|
||||
// semaphore that is still pending.
|
||||
var semaphoreCreate = new SemaphoreCreateInfo
|
||||
{
|
||||
SType = StructureType.SemaphoreCreateInfo,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.CreateSemaphore(_device, &semaphoreCreate, null, out Semaphore semaphore),
|
||||
"vkCreateSemaphore (render complete)");
|
||||
_renderComplete[i] = semaphore;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Acquire the next image. The action tells the caller what to do about the result.</summary>
|
||||
internal VulkanSwapchainAction TryAcquire(
|
||||
Semaphore acquired,
|
||||
ulong timeoutNanoseconds,
|
||||
out uint imageIndex)
|
||||
{
|
||||
imageIndex = 0;
|
||||
if (!IsCreated)
|
||||
return VulkanSwapchainAction.RecreateNow;
|
||||
|
||||
Result result = _swapchainApi.AcquireNextImage(
|
||||
_device,
|
||||
_swapchain,
|
||||
timeoutNanoseconds,
|
||||
acquired,
|
||||
default,
|
||||
ref imageIndex);
|
||||
return VulkanSwapchainRecreationPolicy.OnAcquire(result);
|
||||
}
|
||||
|
||||
/// <summary>Present the acquired image, waiting on that image's render-complete semaphore.</summary>
|
||||
internal VulkanSwapchainAction Present(Queue presentQueue, uint imageIndex)
|
||||
{
|
||||
SwapchainKHR swapchain = _swapchain;
|
||||
Semaphore wait = _renderComplete[imageIndex];
|
||||
uint index = imageIndex;
|
||||
var present = new PresentInfoKHR
|
||||
{
|
||||
SType = StructureType.PresentInfoKhr,
|
||||
WaitSemaphoreCount = 1,
|
||||
PWaitSemaphores = &wait,
|
||||
SwapchainCount = 1,
|
||||
PSwapchains = &swapchain,
|
||||
PImageIndices = &index,
|
||||
};
|
||||
Result result = _swapchainApi.QueuePresent(presentQueue, &present);
|
||||
return VulkanSwapchainRecreationPolicy.OnPresent(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy a presented image back to the CPU as tightly-packed RGBA, matching
|
||||
/// <c>FrameScreenshotController</c>'s <c>Func<int, int, byte[]></c>
|
||||
/// contract. Diagnostic-only, so it takes the simple synchronous route: idle
|
||||
/// the device, run one throwaway command buffer, swizzle, done.
|
||||
/// </summary>
|
||||
internal byte[] CaptureImage(
|
||||
Queue graphicsQueue,
|
||||
uint graphicsFamily,
|
||||
uint imageIndex)
|
||||
{
|
||||
if (Configuration is not { } configuration)
|
||||
throw new InvalidOperationException("The swapchain has not been created.");
|
||||
|
||||
uint width = configuration.Width;
|
||||
uint height = configuration.Height;
|
||||
uint byteCount = width * height * 4;
|
||||
|
||||
VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (screenshot)");
|
||||
|
||||
Silk.NET.Vulkan.Buffer buffer = default;
|
||||
DeviceMemory memory = default;
|
||||
CommandPool pool = default;
|
||||
try
|
||||
{
|
||||
var bufferCreate = new BufferCreateInfo
|
||||
{
|
||||
SType = StructureType.BufferCreateInfo,
|
||||
Size = byteCount,
|
||||
Usage = BufferUsageFlags.TransferDstBit,
|
||||
SharingMode = SharingMode.Exclusive,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.CreateBuffer(_device, &bufferCreate, null, out buffer),
|
||||
"vkCreateBuffer (screenshot)");
|
||||
_vk.GetBufferMemoryRequirements(_device, buffer, out MemoryRequirements requirements);
|
||||
uint? typeIndex = VulkanActiveDeviceProbe.FindMemoryType(
|
||||
_vk,
|
||||
_physicalDevice,
|
||||
requirements.MemoryTypeBits,
|
||||
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit);
|
||||
if (typeIndex is not { } index)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"No host-visible Vulkan memory type is available for screenshot readback.");
|
||||
}
|
||||
|
||||
var allocate = new MemoryAllocateInfo
|
||||
{
|
||||
SType = StructureType.MemoryAllocateInfo,
|
||||
AllocationSize = requirements.Size,
|
||||
MemoryTypeIndex = index,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.AllocateMemory(_device, &allocate, null, out memory),
|
||||
"vkAllocateMemory (screenshot)");
|
||||
VulkanInterop.Check(
|
||||
_vk.BindBufferMemory(_device, buffer, memory, 0),
|
||||
"vkBindBufferMemory (screenshot)");
|
||||
|
||||
var poolCreate = new CommandPoolCreateInfo
|
||||
{
|
||||
SType = StructureType.CommandPoolCreateInfo,
|
||||
QueueFamilyIndex = graphicsFamily,
|
||||
Flags = CommandPoolCreateFlags.TransientBit,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.CreateCommandPool(_device, &poolCreate, null, out pool),
|
||||
"vkCreateCommandPool (screenshot)");
|
||||
var allocateCommands = new CommandBufferAllocateInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferAllocateInfo,
|
||||
CommandPool = pool,
|
||||
Level = CommandBufferLevel.Primary,
|
||||
CommandBufferCount = 1,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.AllocateCommandBuffers(_device, &allocateCommands, out CommandBuffer commands),
|
||||
"vkAllocateCommandBuffers (screenshot)");
|
||||
|
||||
RecordCapture(commands, _images[imageIndex], buffer, width, height);
|
||||
|
||||
var commandSubmit = new CommandBufferSubmitInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferSubmitInfo,
|
||||
CommandBuffer = commands,
|
||||
};
|
||||
var submit = new SubmitInfo2
|
||||
{
|
||||
SType = StructureType.SubmitInfo2,
|
||||
CommandBufferInfoCount = 1,
|
||||
PCommandBufferInfos = &commandSubmit,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.QueueSubmit2(graphicsQueue, 1, &submit, default),
|
||||
"vkQueueSubmit2 (screenshot)");
|
||||
VulkanInterop.Check(
|
||||
_vk.QueueWaitIdle(graphicsQueue),
|
||||
"vkQueueWaitIdle (screenshot)");
|
||||
|
||||
void* mapped = null;
|
||||
VulkanInterop.Check(
|
||||
_vk.MapMemory(_device, memory, 0, byteCount, 0, &mapped),
|
||||
"vkMapMemory (screenshot)");
|
||||
try
|
||||
{
|
||||
// vkCmdCopyImageToBuffer with bufferRowLength = 0 packs rows
|
||||
// tightly, so the source pitch is exactly width * 4. The result
|
||||
// is handed back in GL's bottom-left-origin convention because
|
||||
// FrameScreenshotController flips what it receives.
|
||||
return VulkanBackbufferSwizzle.ToGlOriginRgba(
|
||||
new ReadOnlySpan<byte>(mapped, (int)byteCount),
|
||||
(int)width,
|
||||
(int)height,
|
||||
(int)width * 4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_vk.UnmapMemory(_device, memory);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pool.Handle != 0)
|
||||
_vk.DestroyCommandPool(_device, pool, null);
|
||||
if (buffer.Handle != 0)
|
||||
_vk.DestroyBuffer(_device, buffer, null);
|
||||
if (memory.Handle != 0)
|
||||
_vk.FreeMemory(_device, memory, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordCapture(
|
||||
CommandBuffer commands,
|
||||
Image image,
|
||||
Silk.NET.Vulkan.Buffer destination,
|
||||
uint width,
|
||||
uint height)
|
||||
{
|
||||
var begin = new CommandBufferBeginInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferBeginInfo,
|
||||
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.BeginCommandBuffer(commands, &begin),
|
||||
"vkBeginCommandBuffer (screenshot)");
|
||||
|
||||
var subresource = new ImageSubresourceRange
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
BaseMipLevel = 0,
|
||||
LevelCount = 1,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
};
|
||||
TransitionImage(
|
||||
commands,
|
||||
image,
|
||||
subresource,
|
||||
ImageLayout.PresentSrcKhr,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
PipelineStageFlags2.AllCommandsBit,
|
||||
AccessFlags2.None,
|
||||
PipelineStageFlags2.CopyBit,
|
||||
AccessFlags2.TransferReadBit);
|
||||
|
||||
var region = new BufferImageCopy
|
||||
{
|
||||
BufferOffset = 0,
|
||||
BufferRowLength = 0,
|
||||
BufferImageHeight = 0,
|
||||
ImageSubresource = new ImageSubresourceLayers
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
MipLevel = 0,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
},
|
||||
ImageOffset = new Offset3D(0, 0, 0),
|
||||
ImageExtent = new Extent3D(width, height, 1),
|
||||
};
|
||||
_vk.CmdCopyImageToBuffer(
|
||||
commands,
|
||||
image,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
destination,
|
||||
1,
|
||||
®ion);
|
||||
|
||||
TransitionImage(
|
||||
commands,
|
||||
image,
|
||||
subresource,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
ImageLayout.PresentSrcKhr,
|
||||
PipelineStageFlags2.CopyBit,
|
||||
AccessFlags2.TransferReadBit,
|
||||
PipelineStageFlags2.AllCommandsBit,
|
||||
AccessFlags2.None);
|
||||
|
||||
VulkanInterop.Check(
|
||||
_vk.EndCommandBuffer(commands),
|
||||
"vkEndCommandBuffer (screenshot)");
|
||||
}
|
||||
|
||||
/// <summary>One batched <c>vkCmdPipelineBarrier2</c> image transition.</summary>
|
||||
internal void TransitionImage(
|
||||
CommandBuffer commands,
|
||||
Image image,
|
||||
ImageSubresourceRange subresource,
|
||||
ImageLayout oldLayout,
|
||||
ImageLayout newLayout,
|
||||
PipelineStageFlags2 sourceStage,
|
||||
AccessFlags2 sourceAccess,
|
||||
PipelineStageFlags2 destinationStage,
|
||||
AccessFlags2 destinationAccess)
|
||||
{
|
||||
var barrier = new ImageMemoryBarrier2
|
||||
{
|
||||
SType = StructureType.ImageMemoryBarrier2,
|
||||
SrcStageMask = sourceStage,
|
||||
SrcAccessMask = sourceAccess,
|
||||
DstStageMask = destinationStage,
|
||||
DstAccessMask = destinationAccess,
|
||||
OldLayout = oldLayout,
|
||||
NewLayout = newLayout,
|
||||
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
|
||||
Image = image,
|
||||
SubresourceRange = subresource,
|
||||
};
|
||||
var dependency = new DependencyInfo
|
||||
{
|
||||
SType = StructureType.DependencyInfo,
|
||||
ImageMemoryBarrierCount = 1,
|
||||
PImageMemoryBarriers = &barrier,
|
||||
};
|
||||
_vk.CmdPipelineBarrier2(commands, &dependency);
|
||||
}
|
||||
|
||||
private void DestroyImageResources()
|
||||
{
|
||||
foreach (Semaphore semaphore in _renderComplete)
|
||||
{
|
||||
if (semaphore.Handle != 0)
|
||||
_vk.DestroySemaphore(_device, semaphore, null);
|
||||
}
|
||||
|
||||
foreach (ImageView view in _views)
|
||||
{
|
||||
if (view.Handle != 0)
|
||||
_vk.DestroyImageView(_device, view, null);
|
||||
}
|
||||
|
||||
_renderComplete = [];
|
||||
_views = [];
|
||||
_images = [];
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
DestroyImageResources();
|
||||
if (_swapchain.Handle != 0)
|
||||
{
|
||||
_swapchainApi.DestroySwapchain(_device, _swapchain, null);
|
||||
_swapchain = default;
|
||||
}
|
||||
|
||||
Configuration = null;
|
||||
}
|
||||
}
|
||||
389
src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs
Normal file
389
src/AcDream.App/Rendering/Gpu/Vk/VulkanSwapchainConfiguration.cs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Everything <c>vkCreateSwapchainKHR</c> needs, decided by pure code from the
|
||||
/// surface's advertised capabilities plus the app's pacing policy. Keeping the
|
||||
/// decision separate from the call is what lets slice V5 unit-test format,
|
||||
/// present-mode, image-count and extent selection without a window.
|
||||
/// </summary>
|
||||
internal sealed record VulkanSwapchainConfiguration(
|
||||
Format ImageFormat,
|
||||
ColorSpaceKHR ColorSpace,
|
||||
PresentModeKHR PresentMode,
|
||||
uint ImageCount,
|
||||
uint Width,
|
||||
uint Height,
|
||||
ImageUsageFlags Usage,
|
||||
SurfaceTransformFlagsKHR PreTransform,
|
||||
CompositeAlphaFlagsKHR CompositeAlpha)
|
||||
{
|
||||
/// <summary>A zero-area swapchain is not creatable; the frame loop idles instead.</summary>
|
||||
internal bool IsPresentable => Width > 0 && Height > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5, plan §4.9. Three decisions live here and each has a
|
||||
/// reason that is easy to get silently wrong:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Format is <c>B8G8R8A8_UNORM</c>, never <c>_SRGB</c>.</b>
|
||||
/// The V3 audit established that acdream never enables sRGB encoding anywhere —
|
||||
/// not on upload, not in a shader, not at the framebuffer. An <c>_SRGB</c>
|
||||
/// swapchain would encode already-display-space values and brighten every frame
|
||||
/// globally, and it would pass every automated gate until V7.</description></item>
|
||||
/// <item><description><b>Present mode follows <see cref="FramePacingPolicy"/>.</b>
|
||||
/// FIFO when VSync is on; IMMEDIATE preferred then MAILBOX when it is off, with
|
||||
/// the software pacer continuing to drive the cap. FIFO is the only mode Vulkan
|
||||
/// guarantees, so it is always the final fallback.</description></item>
|
||||
/// <item><description><b>Usage includes <c>TRANSFER_SRC</c>.</b> Screenshots copy
|
||||
/// the presented image; without the usage bit the copy is invalid.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static class VulkanSwapchainConfigurationFactory
|
||||
{
|
||||
/// <summary>The swapchain colour format. Corrected at slice V3 — see the class remarks.</summary>
|
||||
internal const Format PreferredFormat = Format.B8G8R8A8Unorm;
|
||||
|
||||
/// <summary>Paired with <see cref="PreferredFormat"/>; the only colour space core Vulkan guarantees.</summary>
|
||||
internal const ColorSpaceKHR PreferredColorSpace = ColorSpaceKHR.SpaceSrgbNonlinearKhr;
|
||||
|
||||
/// <summary>
|
||||
/// Two frames in flight (plan §4.8), so three images is the working target:
|
||||
/// one presenting, one queued, one being recorded.
|
||||
/// </summary>
|
||||
internal const uint PreferredImageCount = 3;
|
||||
|
||||
/// <summary>Colour attachment for rendering; transfer source for screenshot capture.</summary>
|
||||
internal const ImageUsageFlags RequiredUsage =
|
||||
ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit;
|
||||
|
||||
/// <summary>
|
||||
/// Pick the surface format. Falls back to whatever the surface offers first
|
||||
/// only when the preferred pair is genuinely absent; the capability gate has
|
||||
/// already refused to start in that case, so the fallback exists purely so
|
||||
/// this function is total.
|
||||
/// </summary>
|
||||
internal static SurfaceFormatKHR ChooseSurfaceFormat(
|
||||
IReadOnlyList<SurfaceFormatKHR> available)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(available);
|
||||
if (available.Count == 0)
|
||||
return new SurfaceFormatKHR(PreferredFormat, PreferredColorSpace);
|
||||
|
||||
foreach (SurfaceFormatKHR format in available)
|
||||
{
|
||||
if (format.Format == PreferredFormat && format.ColorSpace == PreferredColorSpace)
|
||||
return format;
|
||||
}
|
||||
|
||||
foreach (SurfaceFormatKHR format in available)
|
||||
{
|
||||
if (format.Format == PreferredFormat)
|
||||
return format;
|
||||
}
|
||||
|
||||
return available[0];
|
||||
}
|
||||
|
||||
/// <summary>True when the surface offers the UNORM format the renderer requires.</summary>
|
||||
internal static bool OffersUnormFormat(IReadOnlyList<SurfaceFormatKHR> available)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(available);
|
||||
foreach (SurfaceFormatKHR format in available)
|
||||
{
|
||||
if (format.Format == PreferredFormat)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map the resolved pacing policy onto a present mode.
|
||||
///
|
||||
/// VSync on means FIFO — the driver blocks and there is no tearing. VSync off
|
||||
/// prefers IMMEDIATE over MAILBOX because <see cref="FramePacingController"/>
|
||||
/// and its platform waiters already own the software ceiling; MAILBOX would
|
||||
/// add a hidden queue between our pacing decision and the display, which is
|
||||
/// the opposite of what issue #235 needs. MAILBOX is still preferred over
|
||||
/// falling back to FIFO, because FIFO would re-impose the very block the user
|
||||
/// turned off.
|
||||
/// </summary>
|
||||
internal static PresentModeKHR ChoosePresentMode(
|
||||
FramePacingPolicy pacing,
|
||||
IReadOnlyList<PresentModeKHR> available)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(available);
|
||||
|
||||
if (pacing.UseVSync)
|
||||
return PresentModeKHR.FifoKhr;
|
||||
|
||||
if (available.Contains(PresentModeKHR.ImmediateKhr))
|
||||
return PresentModeKHR.ImmediateKhr;
|
||||
if (available.Contains(PresentModeKHR.MailboxKhr))
|
||||
return PresentModeKHR.MailboxKhr;
|
||||
|
||||
// FIFO is the only mode Vulkan guarantees is present.
|
||||
return PresentModeKHR.FifoKhr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamp the requested image count into the surface's window.
|
||||
/// <c>MaxImageCount == 0</c> means "no upper limit" in Vulkan, which is the
|
||||
/// easiest clamp in the API to get backwards.
|
||||
/// </summary>
|
||||
internal static uint ChooseImageCount(in SurfaceCapabilitiesKHR capabilities)
|
||||
{
|
||||
uint count = Math.Max(PreferredImageCount, capabilities.MinImageCount);
|
||||
if (capabilities.MaxImageCount != 0 && count > capabilities.MaxImageCount)
|
||||
count = capabilities.MaxImageCount;
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the swapchain extent.
|
||||
///
|
||||
/// <c>CurrentExtent.Width == uint.MaxValue</c> is the surface saying "you
|
||||
/// choose", so the framebuffer size is clamped into the min/max window.
|
||||
/// Otherwise the surface's own extent wins verbatim — including
|
||||
/// <c>0 x 0</c>, which is what a minimised window reports and which the
|
||||
/// caller must treat as "do not create, do not present, idle".
|
||||
/// </summary>
|
||||
internal static (uint Width, uint Height) ChooseExtent(
|
||||
in SurfaceCapabilitiesKHR capabilities,
|
||||
uint framebufferWidth,
|
||||
uint framebufferHeight)
|
||||
{
|
||||
if (capabilities.CurrentExtent.Width != uint.MaxValue)
|
||||
return (capabilities.CurrentExtent.Width, capabilities.CurrentExtent.Height);
|
||||
|
||||
uint width = Math.Clamp(
|
||||
framebufferWidth,
|
||||
capabilities.MinImageExtent.Width,
|
||||
capabilities.MaxImageExtent.Width);
|
||||
uint height = Math.Clamp(
|
||||
framebufferHeight,
|
||||
capabilities.MinImageExtent.Height,
|
||||
capabilities.MaxImageExtent.Height);
|
||||
return (width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefer an identity pre-transform when the surface supports it; otherwise
|
||||
/// pass its current transform straight through so the presentation engine
|
||||
/// does not have to rotate.
|
||||
/// </summary>
|
||||
internal static SurfaceTransformFlagsKHR ChoosePreTransform(
|
||||
in SurfaceCapabilitiesKHR capabilities)
|
||||
=> capabilities.SupportedTransforms.HasFlag(SurfaceTransformFlagsKHR.IdentityBitKhr)
|
||||
? SurfaceTransformFlagsKHR.IdentityBitKhr
|
||||
: capabilities.CurrentTransform;
|
||||
|
||||
/// <summary>
|
||||
/// The window is opaque. OPAQUE is what every desktop surface supports;
|
||||
/// INHERIT is the only sane fallback if a compositor ever refuses it.
|
||||
/// </summary>
|
||||
internal static CompositeAlphaFlagsKHR ChooseCompositeAlpha(
|
||||
in SurfaceCapabilitiesKHR capabilities)
|
||||
{
|
||||
if (capabilities.SupportedCompositeAlpha.HasFlag(CompositeAlphaFlagsKHR.OpaqueBitKhr))
|
||||
return CompositeAlphaFlagsKHR.OpaqueBitKhr;
|
||||
if (capabilities.SupportedCompositeAlpha.HasFlag(CompositeAlphaFlagsKHR.InheritBitKhr))
|
||||
return CompositeAlphaFlagsKHR.InheritBitKhr;
|
||||
return CompositeAlphaFlagsKHR.OpaqueBitKhr;
|
||||
}
|
||||
|
||||
/// <summary>True when the surface permits the TRANSFER_SRC usage screenshots need.</summary>
|
||||
internal static bool SupportsTransferSource(in SurfaceCapabilitiesKHR capabilities)
|
||||
=> capabilities.SupportedUsageFlags.HasFlag(ImageUsageFlags.TransferSrcBit);
|
||||
|
||||
/// <summary>Assemble the complete configuration from surface capabilities and app policy.</summary>
|
||||
internal static VulkanSwapchainConfiguration Create(
|
||||
in SurfaceCapabilitiesKHR capabilities,
|
||||
IReadOnlyList<SurfaceFormatKHR> formats,
|
||||
IReadOnlyList<PresentModeKHR> presentModes,
|
||||
FramePacingPolicy pacing,
|
||||
uint framebufferWidth,
|
||||
uint framebufferHeight)
|
||||
{
|
||||
SurfaceFormatKHR format = ChooseSurfaceFormat(formats);
|
||||
(uint width, uint height) = ChooseExtent(
|
||||
capabilities,
|
||||
framebufferWidth,
|
||||
framebufferHeight);
|
||||
|
||||
// TRANSFER_SRC is dropped rather than forced when a surface refuses it:
|
||||
// the capability gate has already turned that into a startup failure, so
|
||||
// forcing it here would only turn a clear message into a driver error.
|
||||
ImageUsageFlags usage = SupportsTransferSource(capabilities)
|
||||
? RequiredUsage
|
||||
: ImageUsageFlags.ColorAttachmentBit;
|
||||
|
||||
return new VulkanSwapchainConfiguration(
|
||||
format.Format,
|
||||
format.ColorSpace,
|
||||
ChoosePresentMode(pacing, presentModes),
|
||||
ChooseImageCount(capabilities),
|
||||
width,
|
||||
height,
|
||||
usage,
|
||||
ChoosePreTransform(capabilities),
|
||||
ChooseCompositeAlpha(capabilities));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the frame loop must do after an acquire or a present returned.</summary>
|
||||
internal enum VulkanSwapchainAction
|
||||
{
|
||||
/// <summary>The image is usable; carry on.</summary>
|
||||
Continue,
|
||||
|
||||
/// <summary>Rebuild the swapchain before doing anything else with it.</summary>
|
||||
RecreateNow,
|
||||
|
||||
/// <summary>Usable this frame, but rebuild at the frame boundary.</summary>
|
||||
RecreateAtFrameBoundary,
|
||||
|
||||
/// <summary>Nothing to present to — a minimised window. Idle without spinning.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>An error the loop cannot recover from; surface it rather than looping on it.</summary>
|
||||
Fail,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan §4.9: "<c>OUT_OF_DATE</c> recreates immediately, <c>SUBOPTIMAL</c> at the
|
||||
/// next frame boundary, both through <c>FramebufferResizeController</c>."
|
||||
///
|
||||
/// Pure, so the swapchain lifecycle that the risk register calls out (resize,
|
||||
/// minimise, RDP) is covered by unit tests rather than only by a connected gate.
|
||||
/// </summary>
|
||||
internal static class VulkanSwapchainRecreationPolicy
|
||||
{
|
||||
/// <summary>Classify the result of <c>vkAcquireNextImageKHR</c>.</summary>
|
||||
internal static VulkanSwapchainAction OnAcquire(Result result) => result switch
|
||||
{
|
||||
Result.Success => VulkanSwapchainAction.Continue,
|
||||
// A suboptimal acquire is still a usable image, so the frame renders and
|
||||
// the rebuild happens at the boundary rather than mid-frame.
|
||||
Result.SuboptimalKhr => VulkanSwapchainAction.RecreateAtFrameBoundary,
|
||||
Result.ErrorOutOfDateKhr => VulkanSwapchainAction.RecreateNow,
|
||||
Result.Timeout or Result.NotReady => VulkanSwapchainAction.Idle,
|
||||
_ => VulkanSwapchainAction.Fail,
|
||||
};
|
||||
|
||||
/// <summary>Classify the result of <c>vkQueuePresentKHR</c>.</summary>
|
||||
internal static VulkanSwapchainAction OnPresent(Result result) => result switch
|
||||
{
|
||||
Result.Success => VulkanSwapchainAction.Continue,
|
||||
Result.SuboptimalKhr => VulkanSwapchainAction.RecreateAtFrameBoundary,
|
||||
Result.ErrorOutOfDateKhr => VulkanSwapchainAction.RecreateNow,
|
||||
_ => VulkanSwapchainAction.Fail,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A zero-area framebuffer is a minimised window. Creating a swapchain for it
|
||||
/// is invalid, so the loop idles until the surface reports area again — this
|
||||
/// is the check that must run <em>before</em> any acquire, not after a
|
||||
/// failure.
|
||||
/// </summary>
|
||||
internal static VulkanSwapchainAction OnFramebufferSize(uint width, uint height)
|
||||
=> width == 0 || height == 0
|
||||
? VulkanSwapchainAction.Idle
|
||||
: VulkanSwapchainAction.Continue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The swapchain is BGRA and <c>FrameScreenshotController</c> takes RGBA
|
||||
/// (<c>Func<int, int, byte[]></c>). Plan §4.9: "Screenshots swizzle
|
||||
/// BGRA→RGBA on the CPU to preserve <c>FrameScreenshotController</c>'s RGBA byte
|
||||
/// contract."
|
||||
/// </summary>
|
||||
internal static class VulkanBackbufferSwizzle
|
||||
{
|
||||
/// <summary>
|
||||
/// Swap the red and blue bytes of every 4-byte pixel in place. Alpha and
|
||||
/// green are untouched, so the transform is its own inverse.
|
||||
/// </summary>
|
||||
internal static void SwapRedAndBlueInPlace(Span<byte> pixels)
|
||||
{
|
||||
if (pixels.Length % 4 != 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"A BGRA/RGBA pixel span must be a whole number of 4-byte pixels.",
|
||||
nameof(pixels));
|
||||
}
|
||||
|
||||
for (int i = 0; i + 3 < pixels.Length; i += 4)
|
||||
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy a tightly-packed or row-padded BGRA image into a tightly-packed RGBA
|
||||
/// buffer. <paramref name="sourceRowPitchBytes"/> is the copy's row pitch,
|
||||
/// which Vulkan reports per image layout and which is frequently larger than
|
||||
/// <c>width * 4</c> — reading it as if it were tight is the classic way to
|
||||
/// get a diagonally sheared screenshot.
|
||||
/// </summary>
|
||||
internal static byte[] ToRgba(
|
||||
ReadOnlySpan<byte> source,
|
||||
int width,
|
||||
int height,
|
||||
int sourceRowPitchBytes)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(sourceRowPitchBytes, width * 4);
|
||||
|
||||
var destination = new byte[width * height * 4];
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
ReadOnlySpan<byte> sourceRow =
|
||||
source.Slice(y * sourceRowPitchBytes, width * 4);
|
||||
Span<byte> destinationRow =
|
||||
destination.AsSpan(y * width * 4, width * 4);
|
||||
sourceRow.CopyTo(destinationRow);
|
||||
SwapRedAndBlueInPlace(destinationRow);
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// As <see cref="ToRgba"/>, but also flips the image vertically so the result
|
||||
/// is in OpenGL's bottom-left-origin convention.
|
||||
///
|
||||
/// <para>This is the same rule §3.3 states for winding and viewport origin —
|
||||
/// "renderers always speak GL conventions ... the Vulkan backend inverts in
|
||||
/// exactly one mapping function." A Vulkan image is top-left-origin, and
|
||||
/// <c>FrameScreenshotController</c> flips what it is given because
|
||||
/// <c>glReadPixels</c> hands back bottom-up rows. Converting here, once,
|
||||
/// keeps that consumer and its PNG orientation untouched.</para>
|
||||
/// </summary>
|
||||
internal static byte[] ToGlOriginRgba(
|
||||
ReadOnlySpan<byte> source,
|
||||
int width,
|
||||
int height,
|
||||
int sourceRowPitchBytes)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(sourceRowPitchBytes, width * 4);
|
||||
|
||||
var destination = new byte[width * height * 4];
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
ReadOnlySpan<byte> sourceRow =
|
||||
source.Slice(y * sourceRowPitchBytes, width * 4);
|
||||
Span<byte> destinationRow =
|
||||
destination.AsSpan((height - 1 - y) * width * 4, width * 4);
|
||||
sourceRow.CopyTo(destinationRow);
|
||||
SwapRedAndBlueInPlace(destinationRow);
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,10 @@ public sealed record RuntimeOptions(
|
|||
float FogStartMultiplier,
|
||||
float FogEndMultiplier,
|
||||
ResidencyBudgetOptions ResidencyBudgets,
|
||||
StreamingWorkBudgetOptions StreamingWorkBudgets)
|
||||
StreamingWorkBudgetOptions StreamingWorkBudgets,
|
||||
RenderBackendKind RenderBackend,
|
||||
string? VulkanDeviceOverride,
|
||||
string? VulkanForcedUnsupportedFeature)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -118,9 +121,32 @@ public sealed record RuntimeOptions(
|
|||
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
|
||||
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f,
|
||||
ResidencyBudgets: ResidencyBudgetOptions.Parse(env),
|
||||
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env));
|
||||
StreamingWorkBudgets: StreamingWorkBudgetOptions.Parse(env),
|
||||
// Campaign V slice V5. Unset, empty, or any unrecognised value means
|
||||
// OpenGL: a typo must never silently start the dark Vulkan host.
|
||||
RenderBackend: ParseRenderBackend(env("ACDREAM_RENDER_BACKEND")),
|
||||
// Physical-device override, matched as a decimal index first and then
|
||||
// as a case-insensitive device-name substring. Recorded verbatim in
|
||||
// graphical-capabilities-vulkan.json whether or not it matched.
|
||||
VulkanDeviceOverride:
|
||||
NullIfEmpty(env("ACDREAM_VULKAN_DEVICE")),
|
||||
// Slice V5 gate knob: names one required capability to report as
|
||||
// absent so the NotSupportedException -> exit-code-4 -> report path
|
||||
// can be exercised on hardware that actually supports everything.
|
||||
VulkanForcedUnsupportedFeature:
|
||||
NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startup backend request. Only the exact lower-case token <c>vulkan</c>
|
||||
/// selects Vulkan; everything else — unset, <c>gl</c>, or a typo — is
|
||||
/// OpenGL, which is the shipping backend until Campaign V slice V10.
|
||||
/// </summary>
|
||||
private static RenderBackendKind ParseRenderBackend(string? value)
|
||||
=> string.Equals(value, "vulkan", StringComparison.OrdinalIgnoreCase)
|
||||
? RenderBackendKind.Vulkan
|
||||
: RenderBackendKind.Gl;
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
public bool HasLiveCredentials =>
|
||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5 — the parts of the active Vulkan probe that can be
|
||||
/// checked without a device.
|
||||
///
|
||||
/// The probe's submission path needs a driver and is covered by the manual
|
||||
/// "Vulkan boots to a clear colour" gate. Its <em>verdict</em> — did the
|
||||
/// readback really contain the colour we cleared to? — is pure, and it is the
|
||||
/// step most worth pinning: a probe that accepts zeroed memory would report a
|
||||
/// pass on a device that rendered nothing at all.
|
||||
/// </summary>
|
||||
public sealed class VulkanActiveDeviceProbeTests
|
||||
{
|
||||
private static byte[] Filled(byte r, byte g, byte b, byte a)
|
||||
{
|
||||
int pixels = (int)(VulkanActiveDeviceProbe.ProbeExtent * VulkanActiveDeviceProbe.ProbeExtent);
|
||||
var buffer = new byte[pixels * 4];
|
||||
for (int i = 0; i < buffer.Length; i += 4)
|
||||
{
|
||||
buffer[i] = r;
|
||||
buffer[i + 1] = g;
|
||||
buffer[i + 2] = b;
|
||||
buffer[i + 3] = a;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] Expected() => Filled(
|
||||
VulkanActiveDeviceProbe.ExpectedClearRgba[0],
|
||||
VulkanActiveDeviceProbe.ExpectedClearRgba[1],
|
||||
VulkanActiveDeviceProbe.ExpectedClearRgba[2],
|
||||
VulkanActiveDeviceProbe.ExpectedClearRgba[3]);
|
||||
|
||||
/// <summary>
|
||||
/// Every channel of the probe colour differs from the others and none is 0
|
||||
/// or 255, so zeroed or saturated memory cannot accidentally match.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheProbeColourCannotBeMatchedByZeroedOrSaturatedMemory()
|
||||
{
|
||||
ReadOnlySpan<byte> colour = VulkanActiveDeviceProbe.ExpectedClearRgba;
|
||||
|
||||
Assert.Equal(4, colour.Length);
|
||||
foreach (byte channel in colour)
|
||||
{
|
||||
Assert.NotEqual(0, channel);
|
||||
Assert.NotEqual(255, channel);
|
||||
}
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => VulkanActiveDeviceProbe.VerifyClearColour(Filled(0, 0, 0, 0)));
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => VulkanActiveDeviceProbe.VerifyClearColour(Filled(255, 255, 255, 255)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheExpectedClearColourIsAccepted()
|
||||
{
|
||||
VulkanActiveDeviceProbe.VerifyClearColour(Expected());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The clear colour is specified as a float and quantised by the
|
||||
/// implementation, so one least-significant bit of slack is allowed — and
|
||||
/// exactly one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OneBitOfQuantisationSlackIsAllowedButTwoIsNot()
|
||||
{
|
||||
byte[] colour = VulkanActiveDeviceProbe.ExpectedClearRgba.ToArray();
|
||||
|
||||
VulkanActiveDeviceProbe.VerifyClearColour(
|
||||
Filled((byte)(colour[0] + 1), colour[1], colour[2], colour[3]));
|
||||
VulkanActiveDeviceProbe.VerifyClearColour(
|
||||
Filled((byte)(colour[0] - 1), colour[1], colour[2], colour[3]));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => VulkanActiveDeviceProbe.VerifyClearColour(
|
||||
Filled((byte)(colour[0] + 2), colour[1], colour[2], colour[3])));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver that clears only the first tile, or that returns a row-padded
|
||||
/// copy, must fail here rather than at the V7 differential — which is why
|
||||
/// every pixel is compared and not a sample.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ASinglyWrongPixelAnywhereIsRejected()
|
||||
{
|
||||
byte[] pixels = Expected();
|
||||
int lastPixel = pixels.Length - 4;
|
||||
pixels[lastPixel + 1] ^= 0xFF;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => VulkanActiveDeviceProbe.VerifyClearColour(pixels));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AShortReadbackIsRejectedRatherThanPartiallyChecked()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => VulkanActiveDeviceProbe.VerifyClearColour(new byte[64]));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5 — the Vulkan capability gate's accept/reject matrix.
|
||||
///
|
||||
/// These run with no driver, no device and no window: the gate is a pure
|
||||
/// function from a captured record to a list of operator-facing sentences, and
|
||||
/// that separation is the whole reason the interop layer stays thin. Each
|
||||
/// mandatory feature gets its own "absent means rejected" case, because a gate
|
||||
/// that silently stops checking one requirement is exactly the failure the GL
|
||||
/// probe was written to prevent.
|
||||
/// </summary>
|
||||
public sealed class VulkanCapabilityGateTests
|
||||
{
|
||||
private static VulkanCapabilityRecord SupportedRecord(
|
||||
VulkanDeviceFeatureSupport? features = null,
|
||||
VulkanDeviceLimitSupport? limits = null,
|
||||
VulkanFormatSupport? formats = null,
|
||||
VulkanSurfaceSupport? surface = null,
|
||||
VulkanFunctionProbeResult? probe = null,
|
||||
uint? apiVersion = null)
|
||||
{
|
||||
var record = new VulkanCapabilityRecord(
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"win-x64",
|
||||
GraphicalHostOperatingSystem.Windows,
|
||||
GraphicalDisplayProtocol.Windows,
|
||||
GraphicalDisplayProtocol.Windows,
|
||||
"Vulkan 1.3.0",
|
||||
"Vulkan 1.3.280",
|
||||
apiVersion ?? VulkanApiVersion.Make(1, 3, 280),
|
||||
"AMD Radeon RX 9070 XT",
|
||||
"vendor 0x1002, device 0x7550, driver 2.0.0 (raw 0x00800000)",
|
||||
PhysicalDeviceType.DiscreteGpu,
|
||||
0,
|
||||
"automatic",
|
||||
RequestedDeviceOverride: null,
|
||||
ForcedUnsupportedFeature: null,
|
||||
AvailableDevices: [],
|
||||
InstanceExtensions: ["VK_KHR_surface", "VK_KHR_win32_surface"],
|
||||
DeviceExtensions: ["VK_KHR_swapchain"],
|
||||
GraphicsQueueFamily: 0,
|
||||
PresentQueueFamily: 0,
|
||||
features ?? VulkanDeviceFeatureSupport.Complete,
|
||||
limits ?? VulkanDeviceLimitSupport.Complete,
|
||||
formats ?? VulkanFormatSupport.Complete,
|
||||
surface ?? SupportedSurface(),
|
||||
probe ?? PassingProbe(),
|
||||
SupportFailures: []);
|
||||
return VulkanCapabilityRequirements.Reevaluate(record);
|
||||
}
|
||||
|
||||
private static VulkanSurfaceSupport SupportedSurface() => new(
|
||||
PresentSupported: true,
|
||||
SelectedFormat: Format.B8G8R8A8Unorm,
|
||||
SelectedColorSpace: ColorSpaceKHR.SpaceSrgbNonlinearKhr,
|
||||
SelectedPresentMode: PresentModeKHR.FifoKhr,
|
||||
SelectedImageCount: 3,
|
||||
SelectedWidth: 1280,
|
||||
SelectedHeight: 720,
|
||||
SupportsTransferSource: true,
|
||||
AvailableFormats: [Format.B8G8R8A8Unorm],
|
||||
AvailablePresentModes: [PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr]);
|
||||
|
||||
private static VulkanFunctionProbeResult PassingProbe() => new(
|
||||
DeviceCreation: true,
|
||||
DescriptorIndexingLayout: true,
|
||||
PushConstantLayout: true,
|
||||
DynamicRenderingClear: true,
|
||||
TimelineSemaphoreWait: true,
|
||||
HostQueryReset: true,
|
||||
OffscreenReadback: true,
|
||||
Failures: []);
|
||||
|
||||
[Fact]
|
||||
public void ACompleteDeviceIsAccepted()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord();
|
||||
|
||||
Assert.Empty(record.SupportFailures);
|
||||
Assert.True(record.IsSupported);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every field on <see cref="VulkanDeviceFeatureSupport"/> is mandatory, so
|
||||
/// clearing any one of them must produce exactly one new failure. Driving
|
||||
/// this by reflection rather than by hand means a feature added to the record
|
||||
/// without a matching Evaluate clause fails here instead of shipping
|
||||
/// unchecked.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EveryRequiredFeatureIsIndividuallyEnforced()
|
||||
{
|
||||
IEnumerable<string> featureNames = typeof(VulkanDeviceFeatureSupport)
|
||||
.GetProperties()
|
||||
.Where(property => property.PropertyType == typeof(bool))
|
||||
.Select(property => property.Name);
|
||||
|
||||
foreach (string name in featureNames)
|
||||
{
|
||||
VulkanDeviceFeatureSupport? reduced =
|
||||
VulkanDeviceFeatureSupport.Complete.Without(name);
|
||||
Assert.NotNull(reduced);
|
||||
|
||||
VulkanCapabilityRecord record = SupportedRecord(features: reduced);
|
||||
Assert.False(
|
||||
record.IsSupported,
|
||||
$"clearing {name} must reject the device.");
|
||||
Assert.Single(record.SupportFailures);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnknownFeatureNameIsNotSilentlyIgnored()
|
||||
{
|
||||
Assert.Null(VulkanDeviceFeatureSupport.Complete.Without("NotAVulkanFeature"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 2)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(0, 9)]
|
||||
public void ADeviceBelowVulkan13IsRejected(uint major, uint minor)
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
apiVersion: VulkanApiVersion.Make(major, minor, 0));
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("Vulkan 1.3 is required", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vulkan14IsAccepted()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
apiVersion: VulkanApiVersion.Make(1, 4, 0));
|
||||
|
||||
Assert.True(record.IsSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PushConstantsBelowThePinnedBlockAreRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MaxPushConstantsSize = GpuBindingModel.PushConstantBytes - 1,
|
||||
});
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("push-constant bytes are required", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FewerThanEightClipDistancesAreRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with { MaxClipDistances = 6 });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("clip distances are required", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets 0 (storage), 1 (uniform) and 2 (texture table) are bound at once, so
|
||||
/// two bound sets is not enough. This is the limit the §3.4 binding model
|
||||
/// silently assumes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FewerThanThreeBoundDescriptorSetsAreRejected()
|
||||
{
|
||||
Assert.Equal(3u, VulkanCapabilityRequirements.VulkanDescriptorSetCount);
|
||||
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with { MaxBoundDescriptorSets = 2 });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains(
|
||||
"simultaneously bound descriptor sets are required",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ATextureTableSmallerThanTheCapacityIsRejectedPerSetAndPerStage()
|
||||
{
|
||||
VulkanCapabilityRecord perSet = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MaxDescriptorSetUpdateAfterBindSampledImages =
|
||||
GpuBindingModel.TextureTableCapacity - 1,
|
||||
});
|
||||
VulkanCapabilityRecord perStage = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MaxPerStageDescriptorUpdateAfterBindSampledImages =
|
||||
GpuBindingModel.TextureTableCapacity - 1,
|
||||
});
|
||||
|
||||
Assert.Contains(
|
||||
perSet.SupportFailures,
|
||||
failure => failure.Contains("update-after-bind sampled images", StringComparison.Ordinal));
|
||||
Assert.Contains(
|
||||
perStage.SupportFailures,
|
||||
failure => failure.Contains("fragment stage", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingGraphicsTimestampsAreRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with { TimestampComputeAndGraphics = false });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("timestamps are required", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single highest-severity finding of the V3 audit: the swapchain is
|
||||
/// UNORM, not sRGB. A surface that cannot offer UNORM must be a startup
|
||||
/// failure, because silently taking an _SRGB format would brighten every
|
||||
/// frame and pass every automated gate until V7.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ASurfaceWithoutTheUnormFormatIsRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
formats: VulkanFormatSupport.Complete with { SwapchainUnormFormat = false });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("B8G8R8A8_UNORM", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMissingDepthStencilFormatIsRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
formats: VulkanFormatSupport.Complete with { DepthStencilFormat = Format.Undefined });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("depth+stencil format", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(true, true, false)]
|
||||
public void MissingAnyBcBlockIsRejected(bool bc1, bool bc2, bool bc3)
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
formats: VulkanFormatSupport.Complete with
|
||||
{
|
||||
Bc1Sampled = bc1,
|
||||
Bc2Sampled = bc2,
|
||||
Bc3Sampled = bc3,
|
||||
});
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("BC1, BC2 and BC3", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASurfaceWithoutTransferSourceUsageIsRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
surface: SupportedSurface() with { SupportsTransferSource = false });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("TRANSFER_SRC", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADeviceThatCannotPresentIsRejected()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
surface: SupportedSurface() with { PresentSupported = false });
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("cannot present", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A headless capture (no window) is still evaluable — that is what lets the
|
||||
/// probe be offscreen — and simply carries no surface requirements.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AHeadlessCaptureWithNoSurfaceIsAccepted()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(surface: null);
|
||||
|
||||
Assert.True(record.IsSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeFailuresArePrefixedAndReplaceTheIndividualChecks()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
probe: VulkanFunctionProbeResult.NotRun);
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.StartsWith("Vulkan device probe:", StringComparison.Ordinal));
|
||||
// The per-step sentences are suppressed when the probe reported its own
|
||||
// failure, so the operator gets the cause rather than seven symptoms.
|
||||
Assert.DoesNotContain(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("probe did not pass", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DescriptorIndexingLayout", "descriptor-indexing layout probe")]
|
||||
[InlineData("PushConstantLayout", "push-constant pipeline-layout probe")]
|
||||
[InlineData("DynamicRenderingClear", "dynamic-rendering clear probe")]
|
||||
[InlineData("TimelineSemaphoreWait", "timeline-semaphore wait probe")]
|
||||
[InlineData("HostQueryReset", "host query-reset probe")]
|
||||
[InlineData("OffscreenReadback", "offscreen readback probe")]
|
||||
public void EachActiveProbeStepIsIndividuallyEnforced(string step, string expected)
|
||||
{
|
||||
VulkanFunctionProbeResult probe = step switch
|
||||
{
|
||||
"DescriptorIndexingLayout" => PassingProbe() with { DescriptorIndexingLayout = false },
|
||||
"PushConstantLayout" => PassingProbe() with { PushConstantLayout = false },
|
||||
"DynamicRenderingClear" => PassingProbe() with { DynamicRenderingClear = false },
|
||||
"TimelineSemaphoreWait" => PassingProbe() with { TimelineSemaphoreWait = false },
|
||||
"HostQueryReset" => PassingProbe() with { HostQueryReset = false },
|
||||
_ => PassingProbe() with { OffscreenReadback = false },
|
||||
};
|
||||
|
||||
VulkanCapabilityRecord record = SupportedRecord(probe: probe);
|
||||
|
||||
Assert.Contains(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains(expected, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The V3 audit noted the GL gate requires sRGB-framebuffer support the
|
||||
/// renderer never uses, and that the Vulkan gate "must not carry the stale
|
||||
/// requirement forward". This is that tripwire.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheGateDoesNotRequireSrgbAnything()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
features: VulkanDeviceFeatureSupport.Complete.Without("MultiDrawIndirect")!);
|
||||
|
||||
Assert.DoesNotContain(
|
||||
record.SupportFailures,
|
||||
failure => failure.Contains("sRGB", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheForcedUnsupportedKnobRejectsTheNamedFeature()
|
||||
{
|
||||
VulkanCapabilityRecord forced = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
SupportedRecord(),
|
||||
"timelineSemaphore");
|
||||
|
||||
Assert.False(forced.IsSupported);
|
||||
Assert.Equal("timelineSemaphore", forced.ForcedUnsupportedFeature);
|
||||
Assert.Contains(
|
||||
forced.SupportFailures,
|
||||
failure => failure.Contains("timelineSemaphore is required", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheForcedUnsupportedKnobIgnoresAnUnsetValue()
|
||||
{
|
||||
VulkanCapabilityRecord unchanged = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
SupportedRecord(),
|
||||
featureName: null);
|
||||
|
||||
Assert.True(unchanged.IsSupported);
|
||||
Assert.Null(unchanged.ForcedUnsupportedFeature);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A knob that names a nonexistent feature must fail loudly. A silent no-op
|
||||
/// would report a pass the operator never actually exercised.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheForcedUnsupportedKnobFailsOnAnUnknownName()
|
||||
{
|
||||
VulkanCapabilityRecord forced = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
SupportedRecord(),
|
||||
"TeapotShading");
|
||||
|
||||
Assert.False(forced.IsSupported);
|
||||
Assert.Contains(
|
||||
forced.SupportFailures,
|
||||
failure => failure.Contains(
|
||||
"which is not a required Vulkan feature",
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheUnsupportedMessageNamesThePlatformDeviceFailuresAndReport()
|
||||
{
|
||||
VulkanCapabilityRecord record = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
SupportedRecord(),
|
||||
"DynamicRendering");
|
||||
|
||||
string message = VulkanCapabilityGuard.FormatUnsupportedMessage(
|
||||
record,
|
||||
"artifacts/graphical-capabilities-vulkan.json");
|
||||
|
||||
Assert.Contains("win-x64", message, StringComparison.Ordinal);
|
||||
Assert.Contains("AMD Radeon RX 9070 XT", message, StringComparison.Ordinal);
|
||||
Assert.Contains("dynamicRendering is required", message, StringComparison.Ordinal);
|
||||
Assert.Contains("graphical-capabilities-vulkan.json", message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfUnsupportedRaisesNotSupportedExceptionForTheExitFourContract()
|
||||
{
|
||||
VulkanCapabilityRecord rejected = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
SupportedRecord(),
|
||||
"Synchronization2");
|
||||
|
||||
// Program.cs maps NotSupportedException out of window.Run() to exit 4.
|
||||
Assert.Throws<NotSupportedException>(
|
||||
() => VulkanCapabilityGuard.ThrowIfUnsupported(rejected, "report.json"));
|
||||
VulkanCapabilityGuard.ThrowIfUnsupported(SupportedRecord(), "report.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheReportFileNameSitsBesideTheGlOne()
|
||||
{
|
||||
Assert.Equal(
|
||||
"graphical-capabilities-vulkan.json",
|
||||
VulkanCapabilityGuard.ReportFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The JSON report is the artifact an operator sends with a bug report, so
|
||||
/// the fields that identify the machine and explain the refusal must be
|
||||
/// present and readable — enums as names, not integers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheJsonReportCarriesTheIdentifyingFieldsAsReadableNames()
|
||||
{
|
||||
VulkanCapabilityRecord record = VulkanCapabilityRequirements.ApplyForcedUnsupported(
|
||||
SupportedRecord(),
|
||||
"Maintenance4");
|
||||
|
||||
string json = VulkanCapabilityReportWriter.Serialize(record);
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
JsonElement root = document.RootElement;
|
||||
|
||||
Assert.Equal("win-x64", root.GetProperty("RuntimeIdentifier").GetString());
|
||||
Assert.Equal("AMD Radeon RX 9070 XT", root.GetProperty("DeviceName").GetString());
|
||||
Assert.Equal("DiscreteGpu", root.GetProperty("DeviceType").GetString());
|
||||
Assert.Equal("Windows", root.GetProperty("OperatingSystem").GetString());
|
||||
Assert.Equal("Maintenance4", root.GetProperty("ForcedUnsupportedFeature").GetString());
|
||||
Assert.False(root.GetProperty("Features").GetProperty("Maintenance4").GetBoolean());
|
||||
Assert.Equal(
|
||||
"B8G8R8A8Unorm",
|
||||
root.GetProperty("Surface").GetProperty("SelectedFormat").GetString());
|
||||
Assert.NotEmpty(root.GetProperty("SupportFailures").EnumerateArray().ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The record projects onto the backend-neutral contract the renderers
|
||||
/// consult. The alignment fields are load-bearing rather than informational —
|
||||
/// a ring allocation that violates one is a driver error on Vulkan.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheRecordProjectsOntoTheBackendNeutralContract()
|
||||
{
|
||||
VulkanCapabilityRecord record = SupportedRecord(
|
||||
limits: VulkanDeviceLimitSupport.Complete with
|
||||
{
|
||||
MinStorageBufferOffsetAlignment = 16,
|
||||
MinUniformBufferOffsetAlignment = 64,
|
||||
MaxColorSampleCount = 4,
|
||||
MaxDescriptorSetUpdateAfterBindSampledImages = 500_000,
|
||||
MaxPerStageDescriptorUpdateAfterBindSampledImages = 16_384,
|
||||
});
|
||||
|
||||
GpuCapabilityRecord projected = record.ToGpuCapabilityRecord();
|
||||
|
||||
Assert.Equal(GpuBackendKind.Vulkan, projected.Backend);
|
||||
Assert.Equal("AMD Radeon RX 9070 XT", projected.DeviceName);
|
||||
Assert.Equal("Vulkan 1.3.280", projected.ApiVersion);
|
||||
Assert.Equal(16u, projected.MinStorageBufferOffsetAlignment);
|
||||
Assert.Equal(64u, projected.MinUniformBufferOffsetAlignment);
|
||||
Assert.Equal(4u, projected.MaxSampleCount);
|
||||
// The table is limited by whichever of the two counts is smaller.
|
||||
Assert.Equal(16_384u, projected.MaxTextureTableSlots);
|
||||
Assert.Equal(GpuBindingModel.StorageBindingCount, projected.MaxStorageBufferBindings);
|
||||
Assert.True(projected.SupportsMultiDrawIndirect);
|
||||
Assert.True(projected.SupportsDrawParameters);
|
||||
Assert.True(projected.SupportsTextureCompressionBc);
|
||||
Assert.True(projected.SupportsTimestampQueries);
|
||||
// The whole point of the campaign's CPU target: per-frame data written
|
||||
// straight into mapped memory rather than copied through BufferSubData.
|
||||
Assert.True(projected.SupportsPersistentlyMappedRings);
|
||||
Assert.Empty(projected.SupportFailures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A device the Vulkan gate accepted must also satisfy the backend-neutral
|
||||
/// contract's own <c>SupportFailures</c>. If the two ever disagree, one of
|
||||
/// them is checking something the other is not.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AnAcceptedDeviceAlsoSatisfiesTheNeutralContract()
|
||||
{
|
||||
GpuCapabilityRecord projected = SupportedRecord().ToGpuCapabilityRecord();
|
||||
|
||||
Assert.True(projected.IsSupported);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1u, 3u, 280u)]
|
||||
[InlineData(1u, 4u, 0u)]
|
||||
[InlineData(0u, 0u, 1u)]
|
||||
public void ApiVersionPackingRoundTrips(uint major, uint minor, uint patch)
|
||||
{
|
||||
uint packed = VulkanApiVersion.Make(major, minor, patch);
|
||||
|
||||
Assert.Equal(major, VulkanApiVersion.Major(packed));
|
||||
Assert.Equal(minor, VulkanApiVersion.Minor(packed));
|
||||
Assert.Equal(patch, VulkanApiVersion.Patch(packed));
|
||||
Assert.Equal($"Vulkan {major}.{minor}.{patch}", VulkanApiVersion.Describe(packed));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5 — physical-device ranking and queue-family selection.
|
||||
///
|
||||
/// The development machine has exactly one GPU, so the ordering that actually
|
||||
/// matters on a user's machine is never exercised by running the client. These
|
||||
/// tests are the only coverage the policy gets, which is precisely why the
|
||||
/// ranking is pure data rather than something the enumeration loop decides
|
||||
/// inline.
|
||||
/// </summary>
|
||||
public sealed class VulkanDeviceSelectionTests
|
||||
{
|
||||
private const ulong Gib = 1024ul * 1024ul * 1024ul;
|
||||
|
||||
private static VulkanPhysicalDeviceCandidate Device(
|
||||
int index,
|
||||
string name,
|
||||
PhysicalDeviceType type,
|
||||
ulong heapBytes) => new(
|
||||
index,
|
||||
name,
|
||||
type,
|
||||
ApiVersion: VulkanApiVersion.Make(1, 3, 280),
|
||||
DriverVersion: 1,
|
||||
VendorId: 0x1002,
|
||||
DeviceId: 0x7550,
|
||||
DeviceLocalHeapBytes: heapBytes);
|
||||
|
||||
[Fact]
|
||||
public void DiscreteBeatsIntegratedBeatsVirtualBeatsCpu()
|
||||
{
|
||||
// Vulkan's own enum ordering is Other < Integrated < Discrete < Virtual
|
||||
// < Cpu, which is neither our order nor monotone. Pin the mapping.
|
||||
Assert.True(
|
||||
VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.DiscreteGpu)
|
||||
< VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.IntegratedGpu));
|
||||
Assert.True(
|
||||
VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.IntegratedGpu)
|
||||
< VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.VirtualGpu));
|
||||
Assert.True(
|
||||
VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.VirtualGpu)
|
||||
< VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Cpu));
|
||||
Assert.True(
|
||||
VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Cpu)
|
||||
< VulkanPhysicalDeviceSelection.PreferenceRank(PhysicalDeviceType.Other));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheDiscreteGpuWinsOverALargerIntegratedHeap()
|
||||
{
|
||||
// An integrated GPU can report the whole of system RAM as device-local.
|
||||
// Type preference must dominate the heap tie-break, not the other way up.
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib),
|
||||
Device(1, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null);
|
||||
|
||||
Assert.NotNull(choice);
|
||||
Assert.Equal("AMD Radeon RX 9070 XT", choice.Device.DeviceName);
|
||||
Assert.Contains("automatic", choice.Reason, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoDiscreteGpusAreBrokenByTheLargerDeviceLocalHeap()
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "Small Discrete", PhysicalDeviceType.DiscreteGpu, 8 * Gib),
|
||||
Device(1, "Big Discrete", PhysicalDeviceType.DiscreteGpu, 24 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null);
|
||||
|
||||
Assert.Equal("Big Discrete", choice!.Device.DeviceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two identical GPUs must produce the same choice on every launch, or a
|
||||
/// capability report from one run does not describe the next.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IdenticalDevicesAreBrokenDeterministicallyByEnumerationIndex()
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "Twin", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
Device(1, "Twin", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
];
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, null)!.Device.Index);
|
||||
Assert.Equal(
|
||||
0,
|
||||
VulkanPhysicalDeviceSelection.Choose([.. candidates.AsEnumerable().Reverse()], null)!
|
||||
.Device.Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuAndSoftwareDevicesAreLastButStillSelectable()
|
||||
{
|
||||
// lavapipe is the CI second implementation (plan §5, slice V9), so a
|
||||
// CPU device must still be chosen when it is the only one present.
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "llvmpipe", PhysicalDeviceType.Cpu, 0),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, deviceOverride: null);
|
||||
|
||||
Assert.Equal("llvmpipe", choice!.Device.DeviceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoEnumeratedDeviceYieldsNoChoice()
|
||||
{
|
||||
Assert.Null(VulkanPhysicalDeviceSelection.Choose([], deviceOverride: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheOverrideMatchesADecimalEnumerationIndex()
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
Device(1, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, "1");
|
||||
|
||||
Assert.Equal("Intel Iris Xe", choice!.Device.DeviceName);
|
||||
Assert.Contains("ACDREAM_VULKAN_DEVICE=1", choice.Reason, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("iris")]
|
||||
[InlineData("IRIS")]
|
||||
[InlineData("Intel Iris Xe")]
|
||||
public void TheOverrideMatchesACaseInsensitiveNameSubstring(string value)
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
Device(1, "Intel Iris Xe", PhysicalDeviceType.IntegratedGpu, 32 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, value);
|
||||
|
||||
Assert.Equal("Intel Iris Xe", choice!.Device.DeviceName);
|
||||
Assert.Contains("matched device name", choice.Reason, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stale environment variable must not stop the client starting; it must
|
||||
/// say so in the report and start on the right GPU anyway.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AnOverrideThatMatchesNothingFallsBackAndSaysSo()
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, "GeForce");
|
||||
|
||||
Assert.Equal("AMD Radeon RX 9070 XT", choice!.Device.DeviceName);
|
||||
Assert.Contains("matched no enumerated device", choice.Reason, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare digit is a substring of most real device names — "7" occurs in
|
||||
/// "AMD Radeon RX 9070 XT" — so an all-digits override must be an index and
|
||||
/// nothing else. Falling through to substring matching would make an
|
||||
/// out-of-range index quietly select a device by coincidence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AnOutOfRangeIndexOverrideFallsBackRatherThanMatchingADigitInADeviceName()
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, "7");
|
||||
|
||||
Assert.Equal(0, choice!.Device.Index);
|
||||
Assert.Contains("matched no enumerated device", choice.Reason, System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact index/substring split must not break names that legitimately
|
||||
/// contain digits: "RX 7900" is not digits alone, so it is a name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ANameContainingDigitsStillMatchesAsASubstring()
|
||||
{
|
||||
List<VulkanPhysicalDeviceCandidate> candidates =
|
||||
[
|
||||
Device(0, "AMD Radeon RX 9070 XT", PhysicalDeviceType.DiscreteGpu, 24 * Gib),
|
||||
Device(1, "AMD Radeon RX 7900 XTX", PhysicalDeviceType.DiscreteGpu, 16 * Gib),
|
||||
];
|
||||
|
||||
VulkanPhysicalDeviceChoice? choice =
|
||||
VulkanPhysicalDeviceSelection.Choose(candidates, "RX 7900");
|
||||
|
||||
Assert.Equal("AMD Radeon RX 7900 XTX", choice!.Device.DeviceName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0", true)]
|
||||
[InlineData("12", true)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("-1", false)]
|
||||
[InlineData("7900 XTX", false)]
|
||||
[InlineData("Radeon", false)]
|
||||
public void OnlyAnAllDigitsOverrideIsTreatedAsAnIndex(string value, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, VulkanPhysicalDeviceSelection.IsDecimalIndex(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneFamilyThatDoesBothIsPreferredOverASplitPair()
|
||||
{
|
||||
List<VulkanQueueFamilyCandidate> families =
|
||||
[
|
||||
new(0, SupportsGraphics: true, SupportsPresent: false),
|
||||
new(1, SupportsGraphics: false, SupportsPresent: true),
|
||||
new(2, SupportsGraphics: true, SupportsPresent: true),
|
||||
];
|
||||
|
||||
VulkanQueueFamilyChoice? choice = VulkanQueueFamilySelection.Choose(families);
|
||||
|
||||
Assert.NotNull(choice);
|
||||
Assert.True(choice.IsUnified);
|
||||
Assert.Equal(2u, choice.GraphicsFamily);
|
||||
Assert.Equal(2u, choice.PresentFamily);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASplitDeviceStillProducesAUsablePair()
|
||||
{
|
||||
List<VulkanQueueFamilyCandidate> families =
|
||||
[
|
||||
new(0, SupportsGraphics: true, SupportsPresent: false),
|
||||
new(1, SupportsGraphics: false, SupportsPresent: true),
|
||||
];
|
||||
|
||||
VulkanQueueFamilyChoice? choice = VulkanQueueFamilySelection.Choose(families);
|
||||
|
||||
Assert.NotNull(choice);
|
||||
Assert.False(choice.IsUnified);
|
||||
Assert.Equal(0u, choice.GraphicsFamily);
|
||||
Assert.Equal(1u, choice.PresentFamily);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADeviceWithNoPresentCapableFamilyYieldsNoChoice()
|
||||
{
|
||||
List<VulkanQueueFamilyCandidate> families =
|
||||
[
|
||||
new(0, SupportsGraphics: true, SupportsPresent: false),
|
||||
];
|
||||
|
||||
Assert.Null(VulkanQueueFamilySelection.Choose(families));
|
||||
// The headless probe has no surface and so no present requirement.
|
||||
Assert.Equal(0u, VulkanQueueFamilySelection.ChooseGraphicsOnly(families));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AComputeOnlyDeviceHasNoGraphicsFamily()
|
||||
{
|
||||
List<VulkanQueueFamilyCandidate> families =
|
||||
[
|
||||
new(0, SupportsGraphics: false, SupportsPresent: false),
|
||||
];
|
||||
|
||||
Assert.Null(VulkanQueueFamilySelection.ChooseGraphicsOnly(families));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiredExtensionsMustBeAdvertisedButOptionalOnesMayBeAbsent()
|
||||
{
|
||||
VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
|
||||
available: ["VK_KHR_surface", "VK_KHR_win32_surface", "VK_EXT_debug_utils"],
|
||||
required: ["VK_KHR_surface", "VK_KHR_win32_surface"],
|
||||
optional:
|
||||
[
|
||||
VulkanExtensionSelection.DebugUtilsExtension,
|
||||
VulkanExtensionSelection.MemoryBudgetExtension,
|
||||
]);
|
||||
|
||||
Assert.True(plan.IsSatisfied);
|
||||
Assert.Contains("VK_KHR_surface", plan.Enabled);
|
||||
Assert.Contains(VulkanExtensionSelection.DebugUtilsExtension, plan.Enabled);
|
||||
Assert.Contains(VulkanExtensionSelection.MemoryBudgetExtension, plan.UnavailableOptional);
|
||||
Assert.Empty(plan.MissingRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMissingRequiredExtensionIsReportedByName()
|
||||
{
|
||||
VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
|
||||
available: ["VK_KHR_surface"],
|
||||
required: ["VK_KHR_surface", VulkanExtensionSelection.SwapchainExtension],
|
||||
optional: []);
|
||||
|
||||
Assert.False(plan.IsSatisfied);
|
||||
Assert.Equal(
|
||||
[VulkanExtensionSelection.SwapchainExtension],
|
||||
plan.MissingRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnExtensionNamedBothRequiredAndOptionalIsEnabledOnce()
|
||||
{
|
||||
VulkanExtensionPlan plan = VulkanExtensionSelection.Resolve(
|
||||
available: [VulkanExtensionSelection.DebugUtilsExtension],
|
||||
required: [VulkanExtensionSelection.DebugUtilsExtension],
|
||||
optional: [VulkanExtensionSelection.DebugUtilsExtension]);
|
||||
|
||||
Assert.Single(plan.Enabled);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,493 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu.Vk;
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5 — swapchain configuration and lifecycle policy.
|
||||
///
|
||||
/// Everything here is a decision the swapchain makes before it calls anything,
|
||||
/// which is what lets the risk register's "swapchain lifecycle (resize,
|
||||
/// minimize, RDP)" row be covered by unit tests rather than only by a connected
|
||||
/// gate on one machine.
|
||||
/// </summary>
|
||||
public sealed class VulkanSwapchainConfigurationTests
|
||||
{
|
||||
private static SurfaceCapabilitiesKHR Capabilities(
|
||||
uint minImages = 2,
|
||||
uint maxImages = 8,
|
||||
uint currentWidth = 1280,
|
||||
uint currentHeight = 720,
|
||||
uint minWidth = 1,
|
||||
uint minHeight = 1,
|
||||
uint maxWidth = 16384,
|
||||
uint maxHeight = 16384,
|
||||
ImageUsageFlags usage =
|
||||
ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
|
||||
SurfaceTransformFlagsKHR supportedTransforms = SurfaceTransformFlagsKHR.IdentityBitKhr,
|
||||
SurfaceTransformFlagsKHR currentTransform = SurfaceTransformFlagsKHR.IdentityBitKhr,
|
||||
CompositeAlphaFlagsKHR compositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr)
|
||||
=> new()
|
||||
{
|
||||
MinImageCount = minImages,
|
||||
MaxImageCount = maxImages,
|
||||
CurrentExtent = new Extent2D(currentWidth, currentHeight),
|
||||
MinImageExtent = new Extent2D(minWidth, minHeight),
|
||||
MaxImageExtent = new Extent2D(maxWidth, maxHeight),
|
||||
MaxImageArrayLayers = 1,
|
||||
SupportedTransforms = supportedTransforms,
|
||||
CurrentTransform = currentTransform,
|
||||
SupportedCompositeAlpha = compositeAlpha,
|
||||
SupportedUsageFlags = usage,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The single highest-severity finding of the V3 audit. acdream is plain
|
||||
/// UNORM end to end; an <c>_SRGB</c> swapchain would encode already
|
||||
/// display-space values and brighten every frame globally, and it would have
|
||||
/// passed silently right up to the V7 differential.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheSwapchainFormatIsUnormAndNeverSrgb()
|
||||
{
|
||||
Assert.Equal(
|
||||
Format.B8G8R8A8Unorm,
|
||||
VulkanSwapchainConfigurationFactory.PreferredFormat);
|
||||
|
||||
SurfaceFormatKHR chosen = VulkanSwapchainConfigurationFactory.ChooseSurfaceFormat(
|
||||
[
|
||||
new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
|
||||
new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
|
||||
]);
|
||||
|
||||
Assert.Equal(Format.B8G8R8A8Unorm, chosen.Format);
|
||||
Assert.Equal(ColorSpaceKHR.SpaceSrgbNonlinearKhr, chosen.ColorSpace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASurfaceOfferingOnlySrgbIsDetectedAsMissingUnorm()
|
||||
{
|
||||
IReadOnlyList<SurfaceFormatKHR> srgbOnly =
|
||||
[
|
||||
new SurfaceFormatKHR(Format.B8G8R8A8Srgb, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
|
||||
];
|
||||
|
||||
Assert.False(VulkanSwapchainConfigurationFactory.OffersUnormFormat(srgbOnly));
|
||||
Assert.True(
|
||||
VulkanSwapchainConfigurationFactory.OffersUnormFormat(
|
||||
[
|
||||
new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr),
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VSyncOnAlwaysSelectsFifo()
|
||||
{
|
||||
FramePacingPolicy pacing = FramePacingPolicy.Resolve(
|
||||
requestedVSync: true,
|
||||
uncappedRendering: false,
|
||||
monitorRefreshHz: 144);
|
||||
|
||||
Assert.True(pacing.UseVSync);
|
||||
Assert.Equal(
|
||||
PresentModeKHR.FifoKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
|
||||
pacing,
|
||||
[PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr, PresentModeKHR.MailboxKhr]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With VSync off the software pacer owns the ceiling, so IMMEDIATE is
|
||||
/// preferred: MAILBOX inserts a hidden queue between our pacing decision and
|
||||
/// the display, which is the opposite of what issue #235 needs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void VSyncOffPrefersImmediateThenMailbox()
|
||||
{
|
||||
FramePacingPolicy softwareCapped = FramePacingPolicy.Resolve(
|
||||
requestedVSync: false,
|
||||
uncappedRendering: false,
|
||||
monitorRefreshHz: 144);
|
||||
Assert.False(softwareCapped.UseVSync);
|
||||
Assert.Equal(144d, softwareCapped.SoftwareLimitHz);
|
||||
|
||||
Assert.Equal(
|
||||
PresentModeKHR.ImmediateKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
|
||||
softwareCapped,
|
||||
[PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr, PresentModeKHR.ImmediateKhr]));
|
||||
|
||||
Assert.Equal(
|
||||
PresentModeKHR.MailboxKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
|
||||
softwareCapped,
|
||||
[PresentModeKHR.FifoKhr, PresentModeKHR.MailboxKhr]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UncappedRenderingAlsoTakesImmediate()
|
||||
{
|
||||
FramePacingPolicy uncapped = FramePacingPolicy.Resolve(
|
||||
requestedVSync: true,
|
||||
uncappedRendering: true,
|
||||
monitorRefreshHz: 144);
|
||||
|
||||
Assert.False(uncapped.UseVSync);
|
||||
Assert.Null(uncapped.SoftwareLimitHz);
|
||||
Assert.Equal(
|
||||
PresentModeKHR.ImmediateKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
|
||||
uncapped,
|
||||
[PresentModeKHR.FifoKhr, PresentModeKHR.ImmediateKhr]));
|
||||
}
|
||||
|
||||
/// <summary>FIFO is the only present mode Vulkan guarantees exists.</summary>
|
||||
[Fact]
|
||||
public void FifoIsTheFallbackWhenNothingElseIsOffered()
|
||||
{
|
||||
FramePacingPolicy pacing = FramePacingPolicy.Resolve(false, false, 60);
|
||||
|
||||
Assert.Equal(
|
||||
PresentModeKHR.FifoKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePresentMode(
|
||||
pacing,
|
||||
[PresentModeKHR.FifoKhr]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThreeImagesAreRequestedForTwoFramesInFlight()
|
||||
{
|
||||
Assert.Equal(3u, VulkanSwapchainConfigurationFactory.PreferredImageCount);
|
||||
Assert.Equal(
|
||||
3u,
|
||||
VulkanSwapchainConfigurationFactory.ChooseImageCount(Capabilities()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheImageCountIsRaisedToTheSurfaceMinimum()
|
||||
{
|
||||
Assert.Equal(
|
||||
5u,
|
||||
VulkanSwapchainConfigurationFactory.ChooseImageCount(
|
||||
Capabilities(minImages: 5, maxImages: 8)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>maxImageCount == 0</c> means "no upper limit" in Vulkan. Reading it as
|
||||
/// a literal ceiling would clamp every swapchain to zero images.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AZeroMaximumImageCountMeansNoUpperLimit()
|
||||
{
|
||||
Assert.Equal(
|
||||
3u,
|
||||
VulkanSwapchainConfigurationFactory.ChooseImageCount(
|
||||
Capabilities(minImages: 2, maxImages: 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheImageCountIsClampedToTheSurfaceMaximum()
|
||||
{
|
||||
Assert.Equal(
|
||||
2u,
|
||||
VulkanSwapchainConfigurationFactory.ChooseImageCount(
|
||||
Capabilities(minImages: 1, maxImages: 2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSurfacesOwnExtentWinsWhenItReportsOne()
|
||||
{
|
||||
(uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent(
|
||||
Capabilities(currentWidth: 2560, currentHeight: 1440),
|
||||
framebufferWidth: 1280,
|
||||
framebufferHeight: 720);
|
||||
|
||||
Assert.Equal(2560u, width);
|
||||
Assert.Equal(1440u, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>currentExtent.width == uint.MaxValue</c> is the surface saying "you
|
||||
/// choose" — Wayland does this — so the framebuffer size is used, clamped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheFramebufferSizeIsUsedAndClampedWhenTheSurfaceDefers()
|
||||
{
|
||||
(uint width, uint height) = VulkanSwapchainConfigurationFactory.ChooseExtent(
|
||||
Capabilities(
|
||||
currentWidth: uint.MaxValue,
|
||||
currentHeight: uint.MaxValue,
|
||||
minWidth: 64,
|
||||
minHeight: 64,
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024),
|
||||
framebufferWidth: 4000,
|
||||
framebufferHeight: 32);
|
||||
|
||||
Assert.Equal(1024u, width);
|
||||
Assert.Equal(64u, height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AMinimisedWindowProducesAZeroExtentThatIsNotPresentable()
|
||||
{
|
||||
VulkanSwapchainConfiguration configuration =
|
||||
VulkanSwapchainConfigurationFactory.Create(
|
||||
Capabilities(currentWidth: 0, currentHeight: 0),
|
||||
[new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
|
||||
[PresentModeKHR.FifoKhr],
|
||||
FramePacingPolicy.Resolve(true, false, 60),
|
||||
framebufferWidth: 0,
|
||||
framebufferHeight: 0);
|
||||
|
||||
Assert.False(configuration.IsPresentable);
|
||||
}
|
||||
|
||||
/// <summary>Screenshots copy the presented image, so TRANSFER_SRC is not optional.</summary>
|
||||
[Fact]
|
||||
public void TheUsageIncludesColourAttachmentAndTransferSource()
|
||||
{
|
||||
Assert.Equal(
|
||||
ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferSrcBit,
|
||||
VulkanSwapchainConfigurationFactory.RequiredUsage);
|
||||
|
||||
VulkanSwapchainConfiguration configuration =
|
||||
VulkanSwapchainConfigurationFactory.Create(
|
||||
Capabilities(),
|
||||
[new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
|
||||
[PresentModeKHR.FifoKhr],
|
||||
FramePacingPolicy.Resolve(true, false, 60),
|
||||
1280,
|
||||
720);
|
||||
|
||||
Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit));
|
||||
Assert.True(configuration.Usage.HasFlag(ImageUsageFlags.ColorAttachmentBit));
|
||||
Assert.True(configuration.IsPresentable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A surface that refuses TRANSFER_SRC has already failed the capability
|
||||
/// gate, so the configuration drops the bit rather than forcing it — turning
|
||||
/// a clear startup message into a driver error would be a regression.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AnUnsupportedTransferSourceUsageIsDroppedRatherThanForced()
|
||||
{
|
||||
SurfaceCapabilitiesKHR capabilities =
|
||||
Capabilities(usage: ImageUsageFlags.ColorAttachmentBit);
|
||||
|
||||
Assert.False(VulkanSwapchainConfigurationFactory.SupportsTransferSource(capabilities));
|
||||
|
||||
VulkanSwapchainConfiguration configuration =
|
||||
VulkanSwapchainConfigurationFactory.Create(
|
||||
capabilities,
|
||||
[new SurfaceFormatKHR(Format.B8G8R8A8Unorm, ColorSpaceKHR.SpaceSrgbNonlinearKhr)],
|
||||
[PresentModeKHR.FifoKhr],
|
||||
FramePacingPolicy.Resolve(true, false, 60),
|
||||
1280,
|
||||
720);
|
||||
|
||||
Assert.False(configuration.Usage.HasFlag(ImageUsageFlags.TransferSrcBit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityPreTransformIsPreferredAndTheCurrentOneIsTheFallback()
|
||||
{
|
||||
Assert.Equal(
|
||||
SurfaceTransformFlagsKHR.IdentityBitKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePreTransform(Capabilities()));
|
||||
|
||||
Assert.Equal(
|
||||
SurfaceTransformFlagsKHR.Rotate90BitKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChoosePreTransform(
|
||||
Capabilities(
|
||||
supportedTransforms: SurfaceTransformFlagsKHR.Rotate90BitKhr,
|
||||
currentTransform: SurfaceTransformFlagsKHR.Rotate90BitKhr)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompositeAlphaPrefersOpaqueAndFallsBackToInherit()
|
||||
{
|
||||
Assert.Equal(
|
||||
CompositeAlphaFlagsKHR.OpaqueBitKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(Capabilities()));
|
||||
|
||||
Assert.Equal(
|
||||
CompositeAlphaFlagsKHR.InheritBitKhr,
|
||||
VulkanSwapchainConfigurationFactory.ChooseCompositeAlpha(
|
||||
Capabilities(compositeAlpha: CompositeAlphaFlagsKHR.InheritBitKhr)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan §4.9: "OUT_OF_DATE recreates immediately, SUBOPTIMAL at the next
|
||||
/// frame boundary." A suboptimal image is still renderable, so rebuilding
|
||||
/// mid-frame would throw away work for nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AcquireResultsMapToTheDocumentedRecreationTiming()
|
||||
{
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Continue,
|
||||
VulkanSwapchainRecreationPolicy.OnAcquire(Result.Success));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.RecreateNow,
|
||||
VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorOutOfDateKhr));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.RecreateAtFrameBoundary,
|
||||
VulkanSwapchainRecreationPolicy.OnAcquire(Result.SuboptimalKhr));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Idle,
|
||||
VulkanSwapchainRecreationPolicy.OnAcquire(Result.Timeout));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Idle,
|
||||
VulkanSwapchainRecreationPolicy.OnAcquire(Result.NotReady));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Fail,
|
||||
VulkanSwapchainRecreationPolicy.OnAcquire(Result.ErrorDeviceLost));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresentResultsMapToTheDocumentedRecreationTiming()
|
||||
{
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Continue,
|
||||
VulkanSwapchainRecreationPolicy.OnPresent(Result.Success));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.RecreateNow,
|
||||
VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorOutOfDateKhr));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.RecreateAtFrameBoundary,
|
||||
VulkanSwapchainRecreationPolicy.OnPresent(Result.SuboptimalKhr));
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Fail,
|
||||
VulkanSwapchainRecreationPolicy.OnPresent(Result.ErrorSurfaceLostKhr));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, 0u)]
|
||||
[InlineData(0u, 720u)]
|
||||
[InlineData(1280u, 0u)]
|
||||
public void AZeroAreaFramebufferIsIdleRatherThanAFailedCreate(uint width, uint height)
|
||||
{
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Idle,
|
||||
VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANonZeroFramebufferContinues()
|
||||
{
|
||||
Assert.Equal(
|
||||
VulkanSwapchainAction.Continue,
|
||||
VulkanSwapchainRecreationPolicy.OnFramebufferSize(1280, 720));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheBgraToRgbaSwizzleSwapsOnlyRedAndBlue()
|
||||
{
|
||||
byte[] bgra = [0x10, 0x20, 0x30, 0x40, 0x01, 0x02, 0x03, 0x04];
|
||||
|
||||
VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(bgra);
|
||||
|
||||
Assert.Equal([0x30, 0x20, 0x10, 0x40, 0x03, 0x02, 0x01, 0x04], bgra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSwizzleIsItsOwnInverse()
|
||||
{
|
||||
byte[] pixels = [0x10, 0x20, 0x30, 0x40];
|
||||
byte[] original = [.. pixels];
|
||||
|
||||
VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels);
|
||||
VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(pixels);
|
||||
|
||||
Assert.Equal(original, pixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APartialPixelSpanIsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(
|
||||
() => VulkanBackbufferSwizzle.SwapRedAndBlueInPlace(new byte[6]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulkan reports a row pitch per image layout that is frequently larger than
|
||||
/// <c>width * 4</c>; reading it as tight produces a diagonally sheared image.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RowPaddingIsHonouredWhenConvertingToRgba()
|
||||
{
|
||||
// 2x2 BGRA with 4 bytes of padding per row.
|
||||
byte[] source =
|
||||
[
|
||||
0x01, 0x02, 0x03, 0xFF, 0x11, 0x12, 0x13, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA,
|
||||
0x21, 0x22, 0x23, 0xFF, 0x31, 0x32, 0x33, 0xFF, 0xAA, 0xAA, 0xAA, 0xAA,
|
||||
];
|
||||
|
||||
byte[] rgba = VulkanBackbufferSwizzle.ToRgba(source, 2, 2, sourceRowPitchBytes: 12);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
0x03, 0x02, 0x01, 0xFF, 0x13, 0x12, 0x11, 0xFF,
|
||||
0x23, 0x22, 0x21, 0xFF, 0x33, 0x32, 0x31, 0xFF,
|
||||
],
|
||||
rgba);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Vulkan image is top-left-origin; <c>FrameScreenshotController</c> flips
|
||||
/// what it is given because <c>glReadPixels</c> hands back bottom-up rows.
|
||||
/// Converting once at the readback boundary keeps that consumer — and the
|
||||
/// PNG's orientation — untouched.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheGlOriginConversionFlipsRowsAsWellAsSwizzling()
|
||||
{
|
||||
byte[] source =
|
||||
[
|
||||
0x01, 0x02, 0x03, 0xFF,
|
||||
0x21, 0x22, 0x23, 0xFF,
|
||||
];
|
||||
|
||||
byte[] rgba = VulkanBackbufferSwizzle.ToGlOriginRgba(
|
||||
source,
|
||||
width: 1,
|
||||
height: 2,
|
||||
sourceRowPitchBytes: 4);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
0x23, 0x22, 0x21, 0xFF,
|
||||
0x03, 0x02, 0x01, 0xFF,
|
||||
],
|
||||
rgba);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARowPitchNarrowerThanTheImageIsRejected()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => VulkanBackbufferSwizzle.ToRgba(new byte[16], 2, 2, sourceRowPitchBytes: 4));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bring-up clear colour must be visibly deliberate: black reads as an
|
||||
/// unpainted window, and magenta is this codebase's "unresolved texture slot"
|
||||
/// sentinel.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheBringUpClearColourIsNeitherBlackNorTheMagentaSentinel()
|
||||
{
|
||||
float[] colour = VulkanBringUpHost.ClearColor;
|
||||
|
||||
Assert.Equal(4, colour.Length);
|
||||
Assert.Equal(1f, colour[3]);
|
||||
Assert.True(colour[0] + colour[1] + colour[2] > 0f);
|
||||
Assert.False(colour[0] == 1f && colour[1] == 0f && colour[2] == 1f);
|
||||
Assert.Equal(2, VulkanBringUpHost.FlightCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -408,4 +408,86 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(null!, EmptyEnv()));
|
||||
Assert.Throws<ArgumentNullException>(() => RuntimeOptions.Parse(AnyDatDir, null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V5. OpenGL is the shipping backend until slice V10, so
|
||||
/// the default must be GL and a typo must never silently start the dark
|
||||
/// Vulkan host.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RenderBackend_DefaultsToGl()
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Gl,
|
||||
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).RenderBackend);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("vulkan")]
|
||||
[InlineData("Vulkan")]
|
||||
[InlineData("VULKAN")]
|
||||
public void RenderBackend_SelectsVulkanCaseInsensitively(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Vulkan,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("gl")]
|
||||
[InlineData("opengl")]
|
||||
[InlineData("")]
|
||||
[InlineData("vulcan")]
|
||||
[InlineData("vk")]
|
||||
[InlineData(" vulkan")]
|
||||
public void RenderBackend_AnythingElseStaysOnGl(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
RenderBackendKind.Gl,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_RENDER_BACKEND"] = value })).RenderBackend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulkanDeviceOverride_IsNullWhenUnsetOrEmpty()
|
||||
{
|
||||
Assert.Null(RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanDeviceOverride);
|
||||
Assert.Null(
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_VULKAN_DEVICE"] = "" })).VulkanDeviceOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The override is carried verbatim — index or name substring — because the
|
||||
/// capability report records exactly what the operator asked for, matched or
|
||||
/// not.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("Radeon")]
|
||||
public void VulkanDeviceOverride_IsCarriedVerbatim(string value)
|
||||
{
|
||||
Assert.Equal(
|
||||
value,
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_VULKAN_DEVICE"] = value })).VulkanDeviceOverride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VulkanForcedUnsupportedFeature_DrivesTheExitFourGateKnob()
|
||||
{
|
||||
Assert.Null(
|
||||
RuntimeOptions.Parse(AnyDatDir, EmptyEnv()).VulkanForcedUnsupportedFeature);
|
||||
Assert.Equal(
|
||||
"timelineSemaphore",
|
||||
RuntimeOptions.Parse(
|
||||
AnyDatDir,
|
||||
Env(new() { ["ACDREAM_VULKAN_FORCE_UNSUPPORTED"] = "timelineSemaphore" }))
|
||||
.VulkanForcedUnsupportedFeature);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue