perf(image): release static texture staging after upload (#114)

ImageVulkan kept its host-visible staging `buffer` (sized w*h, persistently
mapped) alive for the whole life of the image and Destroy() never freed it, so
static textures (e.g. Sponza albedo) pinned HOST_VISIBLE / small-BAR memory
forever.

Update now releases the staging via VulkanBuffer::DeferredClear() right after
recording the buffer→image copy — the same fence-keyed deletion queue
(#101/#102) the compressed Mesh path already uses — so it is freed once the
upload submit's frame has cleared. A new `streamed` flag (set by FontAtlas)
keeps the persistent map for images re-uploaded from a CPU-side staging buffer
every frame; its uploads go through UpdateRegion, which never releases. Destroy
now also frees any staging the image still owns, gated on a live handle so a
released static texture can't double-free.

Adds tests/ImageStagingRelease driving the real upload + readback on a headless
device: asserts the staging is enqueued (not pinned), the image reads back
byte-equal (released staging outlived the submit), the entry retires only after
framesInFlight frames, and a streamed image keeps then frees its staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-17 19:40:35 +00:00
commit b4fa6e596e
4 changed files with 314 additions and 3 deletions

View file

@ -90,12 +90,24 @@ export namespace Crafter {
// consumer. Defaults to RT — the font atlas overrides it to COMPUTE,
// since UI text is rendered by a compute shader, not an RT pipeline.
VkPipelineStageFlags consumerStage = VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR;
// Whether this image is continuously re-uploaded from a persistent
// CPU-side staging map. False (the default) marks a static texture
// uploaded once — e.g. Sponza albedo: the first Update's buffer→image
// copy is the only reader of the host-visible staging `buffer`, so it
// is released to the fence-keyed deletion queue (#101/#102) right after
// the copy instead of pinning HOST_VISIBLE / small-BAR memory for the
// image's whole life (issue #114). True keeps `buffer` alive: a streamed
// image (the FontAtlas) re-fills buffer.value on the CPU and re-uploads
// every frame, so the persistent map must survive — its uploads go
// through UpdateRegion, which never releases the staging.
bool streamed = false;
void Create(std::uint16_t width, std::uint16_t height, std::uint8_t mipLevels, VkCommandBuffer cmd, VkFormat format, VkImageCreateFlags flags, VkImageLayout layout, VkPipelineStageFlags consumerStage = VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) {
void Create(std::uint16_t width, std::uint16_t height, std::uint8_t mipLevels, VkCommandBuffer cmd, VkFormat format, VkImageCreateFlags flags, VkImageLayout layout, VkPipelineStageFlags consumerStage = VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, bool streamed = false) {
this->width = width;
this->height = height;
this->mipLevels = mipLevels;
this->consumerStage = consumerStage;
this->streamed = streamed;
buffer.Create(
VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
@ -212,6 +224,7 @@ export namespace Crafter {
} else {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
}
ReleaseStaging();
}
// Upload only the sub-rectangle [x, x+w) × [y, y+h) of the staging
@ -365,15 +378,37 @@ export namespace Crafter {
} else {
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, consumerStage, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
}
ReleaseStaging();
}
void Destroy() {
vkDestroyImageView(Device::device, imageView, nullptr);
vkDestroyImage(Device::device, image, nullptr);
vkFreeMemory(Device::device, imageMemory, nullptr);
// Free any staging this image still owns: a streamed image's
// persistent `buffer`, or either staging buffer on an image
// destroyed before its first upload released them (issue #114).
// ReleaseStaging()/Update already null released handles, so these
// are no-ops then; Clear() is gated on a live handle so a static
// texture (whose `buffer` was deferred-cleared) can't double-free.
if (buffer.buffer != VK_NULL_HANDLE) buffer.Clear();
if (compressedStaging.buffer != VK_NULL_HANDLE) compressedStaging.Clear();
}
private:
// Release the host-visible staging `buffer` after a one-shot upload,
// unless this is a streamed image (FontAtlas) whose persistent map must
// survive. The recorded buffer→image copy still references buffer.buffer,
// so route through the fence-keyed deletion queue (#101/#102) instead of
// an immediate Clear(): DeferredClear nulls the handle (so re-entry and
// Destroy treat the staging as already gone) while the allocation
// outlives this submit's frame. Issue #114.
void ReleaseStaging() {
if (!streamed) {
buffer.DeferredClear();
}
}
void TransitionImageLayout(VkCommandBuffer cmd, VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout, VkPipelineStageFlags sourceStage, VkPipelineStageFlags destinationStage, VkAccessFlags srcAccessMask, VkAccessFlags dstAccessMask, std::uint32_t mipLevel, std::uint32_t count) {
VkImageMemoryBarrier barrier = {};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;