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

@ -651,6 +651,40 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
msc.GetInterfacesAndImplementations(ifaces, meshStagingImpls);
cfg.tests.push_back(std::move(meshStagingTest));
// Issue #114: a static ImageVulkan no longer pins its host-visible
// staging `buffer` for the image's life — Update releases it via
// DeferredClear() right after recording the buffer→image copy, so the
// fence-keyed deletion queue (#101/#102) frees it once that submit's
// frame clears, while a `streamed` image (the FontAtlas) keeps its
// persistent map. Drives the real upload path on a headless device (no
// swapchain/window — a buffer→image copy + readback only needs the queue
// + command pool) and asserts the staging is enqueued (not pinned), the
// image reads back byte-equal (the released staging outlived the submit),
// the entry retires only after framesInFlight frames, and a streamed
// image keeps + then frees its staging on Destroy. Shares the native
// build settings.
Test imageStagingTest;
Configuration& isc = imageStagingTest.config;
isc.path = cfg.path;
isc.name = "ImageStagingRelease";
isc.outputName = "ImageStagingRelease";
isc.type = ConfigurationType::Executable;
isc.target = cfg.target;
isc.march = cfg.march;
isc.mtune = cfg.mtune;
isc.debug = cfg.debug;
isc.sysroot = cfg.sysroot;
isc.dependencies = cfg.dependencies;
isc.externalDependencies = cfg.externalDependencies;
isc.compileFlags = cfg.compileFlags;
isc.linkFlags = cfg.linkFlags;
isc.defines = cfg.defines;
isc.cFiles = cfg.cFiles;
std::vector<fs::path> imageStagingImpls(impls.begin(), impls.end());
imageStagingImpls.emplace_back("tests/ImageStagingRelease/main");
isc.GetInterfacesAndImplementations(ifaces, imageStagingImpls);
cfg.tests.push_back(std::move(imageStagingTest));
// Issue #89: Device::PreferDirectDeviceWrite chooses the upload strategy
// for a CPU-written, GPU-read buffer — direct HOST_VISIBLE|DEVICE_LOCAL
// map+write on ReBAR/UMA vs. staged-into-pure-DEVICE_LOCAL on a small