Campaign V slice V6d, commit 2 of 3. TextRenderer and DebugLineRenderer were the only two renderers speaking the RHI, and both refused any device that was not a GlGpuDevice. They now refuse nothing: this is the first production rendering acdream can do on Vulkan.
Three things had to go.
The loose uniforms. debug_line declared uView and uProjection separately and DebugLineRenderer set them straight against the compiled GL program, because the pinned push-constant block carries one combined matrix and IGpuPassEncoder has no verb for arbitrary named uniforms. That was never portable — Vulkan has no default uniform block at all — so the shader converged on uViewProjection and Flush multiplies on the CPU. System.Numerics is row-vector convention while GLSL reads the floats column-major, which transposes, so the CPU equivalent of the old per-vertex uProjection * uView is view * projection. The product now rounds once per frame rather than once per vertex; these lines only draw when collision wireframes are switched on, so the offline gate sees nothing of it. ui_text's uScreenSize became the block's two spare scalars, uParamA and uParamB, with the same two divisions and the same NDC mapping around them.
The sampling mode. uUseTexture selected between font coverage, RGBA modulate and flat colour, and no field of the 96-byte block means that. It did not need one: which of the two texture-table slots is assigned IS the mode. uTextureIndexB assigned means a single-channel coverage source, uTextureIndexA assigned means an RGBA colour source, neither assigned means the vertex colour alone. GpuTextureSlot.Unassigned is already a loud sentinel for exactly this kind of question, and both branches guard so it never reaches a sampler. That also retired the 1x1 white fill texture: DrawFill routed solid quads through the sprite bucket relying on white times colour, and the untextured branch produces the same value with no texture at all. Multiplying by 1.0 changes no bits, and the gate agrees.
The texture binding. The classic glActiveTexture/glBindTexture path survived V4a because DrawSprite takes an arbitrary texture from sixty-odd widget call sites. But TextureCache had already registered every one of those into the device's table — the classic path was consuming the raw GL name that registration also produced. The UI's currency is now UiTextureTableHandle, a one-based table index whose zero is the same "no texture" every widget already guards on; a raw slot index would have turned all of those guards into silent false negatives, since slot 0 is perfectly valid. One-based rather than the slot itself because GpuTextureSlot is internal to the pinned contract while UiRenderContext.DrawSprite, TextureCache.GetOrUploadRenderSurface and a dozen widget properties are public, and neither publishing a contract type nor converting the retained UI to internal belongs in this slice.
Two consequences worth stating. The two backends disagree about what a 2-D table entry is — GL reconstructs a sampler2D from the bindless handle, Vulkan reads layer 0 of its sampler2DArray descriptor array — and ACDREAM_SAMPLE_2D is the one place that lives. Keeping GL on sampler2D is what leaves the UI's textures exactly as they are, including the paperdoll/appraisal FBO colour texture, which is an externally-owned GL_TEXTURE_2D from the §7.1 transitional seam and cannot become an array before V4g. On the Vulkan side, sampled views are now always layered, which also removes a latent invalid usage V6c shipped: it registered a Type2D offscreen view into a descriptor array whose element type is sampler2DArray.
And one real fix. Sampling through the table means a bound sampler object overrides the texture's own parameters. Nearest-requested UI art used to get its point filtering from a glTexParameter applied before the bindless handle went resident, so registering it with the stock WorldRepeat sampler would have made every retail icon and dat-font glyph silently bilinear. Those now register with a nearest-and-repeat sampler.
Supporting moves: GlGpuDevice.CreatePipeline splices common.glsl the same way Shader does, since an RHI shader that reads the table needs the table declared; GlGpuPassEncoder binds the device's table with the pipeline, which is the GL analogue of Vulkan binding descriptor set 2 per draw, and has to be per-bind because every raw-GL world renderer puts its own privately-numbered table at that binding; and the encoder derives GL_MULTISAMPLE from the pass's SampleCount, which is where the retained UI's hand-rolled glDisable belonged all along. TextRenderGlStateScope is deleted — the encoder's ambient capture restored a strict superset of it — and its failure-safety test follows the guarantee to GlAmbientCapabilityState, which gains a fakeable seam and, with it, the multisample-dimension coverage #249 recorded as missing.
App tests 4,057 passed / 3 skipped, unchanged from commit 1. Offline pixel gate against 871c406b: differing fraction 2.31e-05, 13 pixels of 563,200 compared — below the documented 15-23 pixel same-commit noise band, on a change that redraws every pixel of the retained UI through a different sampling path. The capture was inspected: vitals, spell bar, radar, toolbar icons and slot digits, chat window and Send button all present and correctly placed. Both new .spv pairs compile; the manifest records ui_text and debug_line as Vulkan-ready, leaving six pairs blocked on the world-renderer slices.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
303 lines
13 KiB
C#
303 lines
13 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)
|
|
{
|
|
_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;
|
|
VkFormat = 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;
|
|
}
|
|
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; }
|
|
internal ImageView View { 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;
|
|
VulkanAllocation allocation = _allocation;
|
|
_retirement.Retire(() =>
|
|
{
|
|
_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));
|
|
}
|
|
}
|