Amendment 3 of three: the paperdoll and creature-appraisal views render on the
Vulkan arm. Plan section 5.5.16 defect 3 named two backend fixes as the
precondition; both are here, and running it found two more the note could not
have known about.
Fix 1: a layered sampled view per render target. An ATTACHMENT view must be
VK_IMAGE_VIEW_TYPE_2D and the global texture table's descriptor array is
sampler2DArray, so the attachment view cannot legally be registered into it -
section 5.5.7 recorded that as invalid usage rather than a mismatch that samples
oddly, and V6k made RegisterTexture refuse it loudly and name this fix.
VulkanGpuTexture now creates a SECOND, layered view over the same image for a
colour render target: one image, one allocation, two ways of looking at it,
legal without any creation flag. SampledView is what the table registers for
every texture, so the question disappears rather than being answered.
Fix 2: sample-count pipeline variants for WbDrawDispatcher. Vulkan requires a
pipeline's rasterizationSamples to equal the pass it draws in, and this
dispatcher draws in two passes with different counts - the multisampled
backbuffer world pass and the single-sampled offscreen target, which the
contract fixes at one sample. Its five pipelines became a MeshPipelineSet with
two instances, selected at bind time from the live pass rather than from the
scope, which is the same shape section 5.5.8 gave the depth-format problem. When
the backbuffer is single-sampled the two sets are one object, so nothing is
built twice and nothing is freed twice. The offscreen target's DEPTH attachment
also had to take the device's own combined depth/stencil format rather than the
contract enum's literal D24_UNORM_S8_UINT: a pipeline bakes one depth/stencil
format under dynamic rendering and the same pipelines draw in both passes, so a
second format would make one of the two undefined.
Fix 3, which running it found: entity APPEARANCE composites were still
bindless-only, so no entity with a palette override could be drawn on the Vulkan
arm at all - the doll being one, and every creature and player besides. The
backend that serves it has existed since V6i-2 and had no production consumer;
it has one now. TextureCache builds the composite cache on both arms, and
EnsureCompositeTexturesAvailable stops asking about bindless. Nothing about the
cache itself changed: the sharing, the bounded unowned LRU, the metered upload
budget and the retirement fence were already backend-neutral.
Fix 4, which the first successful capture found: the doll rendered upside down.
UiViewport has flipped V since V4a because a GL framebuffer's origin is
bottom-left, so its colour texture samples bottom-up. A Vulkan image's origin is
top-left and the backend's negative viewport height stores the rendered image
that way round, so the same flip stands the doll on its head. That is a property
of the backend that made the texture, not of the widget that draws it, so
IUiViewportRenderer answers TextureIsBottomUp and UiViewport asks. The line this
replaces had predicted exactly this failure since it was written.
The seam. WbDrawDispatcher's RHI arm borrows its pass from IWorldPassScope
rather than opening one, so a viewport that opens a pass of its own has to
publish it there for the span of the draw. Publish is on the interface now for
that. It does not nest: the world phase has closed its own pass by the time
private presentation runs, which is where these viewports have always drawn.
Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in the App suite alone or in a second solution-wide run - the
documented rerun-singly flake class; the failing test name was not surfaced by
the runner and is not carried forward as a claim). Strict GL offline pixel gate
against 08ffe141: 3.55e-05, 20 differing pixels of 563,200, inside the
documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the desktop witness
and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings.
And the two captures the offline gate cannot reach, both connected and both
inspected. The Vulkan paperdoll (artifacts/v6l-vk-paperdoll3) renders the doll
upright, in armour, at the right scale, over a transparent background, and is
indistinguishable from the same capture on GL taken minutes later
(artifacts/v6l-gl-paperdoll) - which is also the no-regression check for the V
change. Particles (artifacts/v6l-vk-poi versus artifacts/v6l-gl-poi, cropped
4x at artifacts/crop-vk-glow.png and crop-gl-glow.png): Holtburg's forge plume
and its field of glint sprites draw in the same places with the same alpha
compositing on both backends, the puffs differing only in phase because two
launches cannot agree on an emitter's age.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
348 lines
16 KiB
C#
348 lines
16 KiB
C#
using Silk.NET.Vulkan;
|
|
|
|
namespace AcDream.App.Rendering.Gpu.Vk;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6b: <see cref="IGpuTexture"/> on Vulkan.
|
|
///
|
|
/// <para>Images are device-local and filled through the staging path. 2D arrays
|
|
/// are allocated at full size and filled layer by layer, mirroring
|
|
/// <c>ManagedGLTextureArray</c> — which is why the upload queue tracks the
|
|
/// layout an image is in on entry rather than always discarding its contents.
|
|
/// </para>
|
|
///
|
|
/// <para><see cref="GenerateMipChain"/> is explicit rather than automatic
|
|
/// because the two backends genuinely cannot do it the same way, and the
|
|
/// contract says so. Uncompressed images get a <c>vkCmdBlitImage</c> chain.
|
|
/// Block-compressed images cannot: a compressed image is not a legal blit
|
|
/// destination, so this method throws and the caller supplies a CPU-built chain
|
|
/// through <see cref="Upload"/> (<see cref="BlockCompressionMipChain"/> builds
|
|
/// it). That is a deliberate improvement rather than a limitation — the GL path
|
|
/// calls <c>glGenerateMipmap</c> on compressed arrays, whose result is
|
|
/// implementation-defined.</para>
|
|
/// </summary>
|
|
internal sealed unsafe class VulkanGpuTexture : IGpuTexture
|
|
{
|
|
private readonly Silk.NET.Vulkan.Vk _vk;
|
|
private readonly Device _device;
|
|
private readonly VulkanDeviceMemoryAllocator _allocator;
|
|
private readonly VulkanUploadQueue _uploads;
|
|
private readonly IGpuResourceRetirementQueue _retirement;
|
|
private readonly VulkanAllocation _allocation;
|
|
private bool _disposed;
|
|
|
|
internal VulkanGpuTexture(
|
|
Silk.NET.Vulkan.Vk vk,
|
|
Device device,
|
|
VulkanDeviceMemoryAllocator allocator,
|
|
VulkanUploadQueue uploads,
|
|
IGpuResourceRetirementQueue retirement,
|
|
VulkanDebugNames debugNames,
|
|
in GpuTextureDescription description,
|
|
int sampleCount = 1,
|
|
bool renderTarget = false,
|
|
// Fully qualified: in a parameter-default expression the simple name
|
|
// `Format` binds to this type's own GpuTextureFormat property first.
|
|
Format formatOverride = Silk.NET.Vulkan.Format.Undefined)
|
|
{
|
|
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
|
|
_device = device;
|
|
_allocator = allocator ?? throw new ArgumentNullException(nameof(allocator));
|
|
_uploads = uploads ?? throw new ArgumentNullException(nameof(uploads));
|
|
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(description.Name);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Width);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.Height);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.LayerCount);
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(description.MipLevelCount);
|
|
|
|
Name = description.Name;
|
|
Kind = description.Kind;
|
|
Format = description.Format;
|
|
Width = description.Width;
|
|
Height = description.Height;
|
|
LayerCount = description.LayerCount;
|
|
MipLevelCount = description.MipLevelCount;
|
|
SampleCount = sampleCount;
|
|
// Slice V6l: an offscreen target's DEPTH attachment takes the format the
|
|
// device already chose for the backbuffer, because a pipeline bakes one
|
|
// depth/stencil format and draws in both kinds of pass. The contract's
|
|
// Depth24Stencil8 means "a combined depth+stencil attachment"; which
|
|
// combined format that is belongs to the device that probed for it.
|
|
VkFormat = formatOverride != Silk.NET.Vulkan.Format.Undefined
|
|
? formatOverride
|
|
: VulkanTextureFormatMapping.FormatOf(description.Format);
|
|
Aspect = VulkanTextureFormatMapping.AspectOf(description.Format);
|
|
|
|
bool depthStencil = VulkanTextureFormatMapping.IsDepthStencil(description.Format);
|
|
ImageUsageFlags usage = depthStencil
|
|
? ImageUsageFlags.DepthStencilAttachmentBit
|
|
: ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit | ImageUsageFlags.TransferSrcBit;
|
|
if (renderTarget && !depthStencil)
|
|
usage |= ImageUsageFlags.ColorAttachmentBit;
|
|
if (sampleCount > 1)
|
|
{
|
|
// A multisampled image is never sampled or copied directly; it is
|
|
// resolved. Declaring TRANSIENT lets a tiler keep it in on-chip
|
|
// memory and never write it out at all.
|
|
usage = depthStencil
|
|
? ImageUsageFlags.DepthStencilAttachmentBit | ImageUsageFlags.TransientAttachmentBit
|
|
: ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransientAttachmentBit;
|
|
}
|
|
|
|
var create = new ImageCreateInfo
|
|
{
|
|
SType = StructureType.ImageCreateInfo,
|
|
ImageType = ImageType.Type2D,
|
|
Format = VkFormat,
|
|
Extent = new Extent3D((uint)description.Width, (uint)description.Height, 1),
|
|
MipLevels = (uint)description.MipLevelCount,
|
|
ArrayLayers = (uint)description.LayerCount,
|
|
Samples = VulkanTextureFormatMapping.SampleCountOf(sampleCount),
|
|
Tiling = ImageTiling.Optimal,
|
|
Usage = usage,
|
|
SharingMode = SharingMode.Exclusive,
|
|
InitialLayout = ImageLayout.Undefined,
|
|
};
|
|
VulkanInterop.Check(
|
|
_vk.CreateImage(_device, &create, null, out Image image),
|
|
$"vkCreateImage ('{description.Name}')");
|
|
Image = image;
|
|
|
|
try
|
|
{
|
|
_vk.GetImageMemoryRequirements(_device, image, out MemoryRequirements requirements);
|
|
_allocation = _allocator.Allocate(requirements, GpuMemoryResidency.DeviceLocal, description.Name);
|
|
VulkanInterop.Check(
|
|
_vk.BindImageMemory(_device, image, _allocation.Memory, _allocation.OffsetBytes),
|
|
$"vkBindImageMemory ('{description.Name}')");
|
|
|
|
var viewCreate = new ImageViewCreateInfo
|
|
{
|
|
SType = StructureType.ImageViewCreateInfo,
|
|
Image = image,
|
|
// Slice V6d: a texture that is not an attachment exists to be
|
|
// sampled, and every sampled view has to be layered because the
|
|
// global table's descriptor type is sampler2DArray. Attachments
|
|
// keep the literal view type of their kind.
|
|
ViewType = renderTarget
|
|
? VulkanTextureFormatMapping.ViewTypeOf(description.Kind)
|
|
: VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind),
|
|
Format = VkFormat,
|
|
SubresourceRange = new ImageSubresourceRange
|
|
{
|
|
AspectMask = Aspect,
|
|
BaseMipLevel = 0,
|
|
LevelCount = (uint)description.MipLevelCount,
|
|
BaseArrayLayer = 0,
|
|
LayerCount = (uint)description.LayerCount,
|
|
},
|
|
};
|
|
VulkanInterop.Check(
|
|
_vk.CreateImageView(_device, &viewCreate, null, out ImageView view),
|
|
$"vkCreateImageView ('{description.Name}')");
|
|
View = view;
|
|
SampledView = view;
|
|
|
|
// Campaign V slice V6l: a colour render target needs TWO views.
|
|
//
|
|
// An ATTACHMENT view must be VK_IMAGE_VIEW_TYPE_2D, and the global
|
|
// texture table's descriptor array is declared sampler2DArray, so the
|
|
// attachment view cannot legally be registered into it — plan §5.5.7
|
|
// recorded that as invalid usage rather than a mismatch that samples
|
|
// oddly, and V6k made VulkanGpuDevice.RegisterTexture refuse it and
|
|
// name this fix. A second, LAYERED view over the same image is that
|
|
// fix: one image, one allocation, two ways of looking at it. Legal
|
|
// without any creation flag — a 2D_ARRAY view over an imageType-2D
|
|
// image with arrayLayers >= 1 is exactly what the spec permits.
|
|
if (renderTarget && !depthStencil)
|
|
{
|
|
viewCreate.ViewType = VulkanTextureFormatMapping.SampledViewTypeOf(description.Kind);
|
|
VulkanInterop.Check(
|
|
_vk.CreateImageView(_device, &viewCreate, null, out ImageView sampled),
|
|
$"vkCreateImageView ('{description.Name}', sampled)");
|
|
SampledView = sampled;
|
|
debugNames.NameImageView(sampled, $"{description.Name}-sampled-view");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
_vk.DestroyImage(_device, image, null);
|
|
throw;
|
|
}
|
|
|
|
debugNames.NameImage(image, description.Name);
|
|
debugNames.NameImageView(View, $"{description.Name}-view");
|
|
}
|
|
|
|
public string Name { get; }
|
|
public GpuTextureKind Kind { get; }
|
|
public GpuTextureFormat Format { get; }
|
|
public int Width { get; }
|
|
public int Height { get; }
|
|
public int LayerCount { get; }
|
|
public int MipLevelCount { get; }
|
|
|
|
internal int SampleCount { get; }
|
|
internal Image Image { get; }
|
|
|
|
/// <summary>The view a pass names as an attachment, and the only view a non-attachment has.</summary>
|
|
internal ImageView View { get; }
|
|
|
|
/// <summary>
|
|
/// The view the global texture table samples. Identical to <see cref="View"/>
|
|
/// for every ordinary texture; a second, LAYERED view for a colour render
|
|
/// target, because an attachment view is 2-D and the table's descriptor array
|
|
/// is <c>sampler2DArray</c> (slice V6l, plan §5.5.7).
|
|
/// </summary>
|
|
internal ImageView SampledView { get; }
|
|
internal Format VkFormat { get; }
|
|
internal ImageAspectFlags Aspect { get; }
|
|
|
|
/// <summary>
|
|
/// Layout the image is currently in, as far as the CPU-side record knows.
|
|
/// Starts UNDEFINED so the first upload may discard, and becomes
|
|
/// SHADER_READ_ONLY once anything has been written — which is what stops an
|
|
/// incremental array-layer fill from erasing the layers already there.
|
|
/// </summary>
|
|
internal ImageLayout CurrentLayout { get; private set; } = ImageLayout.Undefined;
|
|
|
|
internal void MarkLayout(ImageLayout layout) => CurrentLayout = layout;
|
|
|
|
public void Upload(int mipLevel, int layer, ReadOnlySpan<byte> data)
|
|
{
|
|
ThrowIfDisposed();
|
|
ArgumentOutOfRangeException.ThrowIfNegative(mipLevel);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(mipLevel, MipLevelCount);
|
|
ArgumentOutOfRangeException.ThrowIfNegative(layer);
|
|
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, LayerCount);
|
|
if (data.IsEmpty)
|
|
return;
|
|
|
|
(int width, int height) = VulkanTextureFormatMapping.LevelExtent(Width, Height, mipLevel);
|
|
int expected = VulkanTextureFormatMapping.LevelSizeBytes(Format, width, height);
|
|
if (data.Length < expected)
|
|
{
|
|
throw new ArgumentException(
|
|
$"Mip {mipLevel} of '{Name}' is {width}x{height} and needs {expected} bytes; " +
|
|
$"{data.Length} were supplied.",
|
|
nameof(data));
|
|
}
|
|
|
|
_uploads.StageImageWrite(Image, mipLevel, layer, width, height, CurrentLayout, data, Name);
|
|
CurrentLayout = ImageLayout.ShaderReadOnlyOptimal;
|
|
}
|
|
|
|
public void GenerateMipChain()
|
|
{
|
|
ThrowIfDisposed();
|
|
if (MipLevelCount <= 1)
|
|
return;
|
|
|
|
if (BlockCompressionCodec.IsBlockCompressed(Format))
|
|
{
|
|
throw new NotSupportedException(
|
|
$"'{Name}' is {Format}, and Vulkan cannot blit into a block-compressed image. " +
|
|
"Build the chain on the CPU with BlockCompressionMipChain and upload each level " +
|
|
"through Upload(mipLevel, layer, data). The GL path's reliance on driver-defined " +
|
|
"glGenerateMipmap for compressed arrays is deliberately not carried forward.");
|
|
}
|
|
|
|
_uploads.EnqueueMipBlit(Image, Width, Height, MipLevelCount, LayerCount, CurrentLayout);
|
|
CurrentLayout = ImageLayout.ShaderReadOnlyOptimal;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
|
|
Image image = Image;
|
|
ImageView view = View;
|
|
ImageView sampledView = SampledView;
|
|
VulkanAllocation allocation = _allocation;
|
|
_retirement.Retire(() =>
|
|
{
|
|
if (sampledView.Handle != view.Handle)
|
|
_vk.DestroyImageView(_device, sampledView, null);
|
|
_vk.DestroyImageView(_device, view, null);
|
|
_vk.DestroyImage(_device, image, null);
|
|
_allocator.Free(allocation);
|
|
});
|
|
}
|
|
|
|
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6b: <see cref="IGpuSampler"/> on Vulkan.
|
|
///
|
|
/// Immutable and de-duplicated by value at the device, because the set of
|
|
/// distinct samplers acdream uses is tiny — wrap or clamp, crossed with nearest
|
|
/// or linear. That is exactly what makes a combined image-sampler descriptor
|
|
/// table practical: a texture registered with two samplers occupies two slots,
|
|
/// the same way it holds two bindless handles today.
|
|
/// </summary>
|
|
internal sealed unsafe class VulkanGpuSampler : IGpuSampler
|
|
{
|
|
private readonly Silk.NET.Vulkan.Vk _vk;
|
|
private readonly Device _device;
|
|
private readonly IGpuResourceRetirementQueue _retirement;
|
|
private bool _disposed;
|
|
|
|
internal VulkanGpuSampler(
|
|
Silk.NET.Vulkan.Vk vk,
|
|
Device device,
|
|
IGpuResourceRetirementQueue retirement,
|
|
VulkanDebugNames debugNames,
|
|
in GpuSamplerDescription description,
|
|
float maxSupportedAnisotropy)
|
|
{
|
|
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
|
|
_device = device;
|
|
_retirement = retirement ?? throw new ArgumentNullException(nameof(retirement));
|
|
Description = description;
|
|
|
|
float anisotropy = Math.Clamp(description.MaxAnisotropy, 1f, Math.Max(1f, maxSupportedAnisotropy));
|
|
var create = new SamplerCreateInfo
|
|
{
|
|
SType = StructureType.SamplerCreateInfo,
|
|
MinFilter = VulkanTextureFormatMapping.FilterOf(description.MinFilter),
|
|
MagFilter = VulkanTextureFormatMapping.FilterOf(description.MagFilter),
|
|
MipmapMode = VulkanTextureFormatMapping.MipmapModeOf(description.MipFilter),
|
|
AddressModeU = VulkanTextureFormatMapping.AddressModeOf(description.AddressU),
|
|
AddressModeV = VulkanTextureFormatMapping.AddressModeOf(description.AddressV),
|
|
AddressModeW = VulkanTextureFormatMapping.AddressModeOf(description.AddressV),
|
|
AnisotropyEnable = anisotropy > 1f,
|
|
MaxAnisotropy = anisotropy,
|
|
MinLod = 0f,
|
|
// GpuMipFilter.None means "level 0 only", which Vulkan expresses as a
|
|
// zero-width LOD range rather than as a filter mode.
|
|
MaxLod = description.MipFilter == GpuMipFilter.None ? 0f : Silk.NET.Vulkan.Vk.LodClampNone,
|
|
BorderColor = BorderColor.FloatTransparentBlack,
|
|
CompareEnable = false,
|
|
UnnormalizedCoordinates = false,
|
|
};
|
|
VulkanInterop.Check(
|
|
_vk.CreateSampler(_device, &create, null, out Sampler sampler),
|
|
"vkCreateSampler");
|
|
Handle = sampler;
|
|
debugNames.NameSampler(
|
|
sampler,
|
|
$"sampler-{description.MinFilter}-{description.MipFilter}-{description.AddressU}");
|
|
}
|
|
|
|
public GpuSamplerDescription Description { get; }
|
|
|
|
internal Sampler Handle { get; }
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
Sampler handle = Handle;
|
|
_retirement.Retire(() => _vk.DestroySampler(_device, handle, null));
|
|
}
|
|
}
|