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

Merged
catbot merged 1 commit from claude/issue-114 into master 2026-06-17 21:41:07 +02:00
Member

Problem

ImageVulkan allocated a dedicated image VkDeviceMemory plus a persistent HOST_VISIBLE staging buffer (sized w*h), kept mapped for the image's whole lifetime — and Destroy() never freed it. Static textures (e.g. Sponza albedo, uploaded once) pinned host-visible / small-BAR memory forever.

Fix

Update now releases the staging buffer via VulkanBuffer::DeferredClear() right after recording the buffer→image copy — routed through the same fence-keyed deletion queue (#101/#102) the compressed Mesh path already uses, so the allocation outlives the upload submit and is freed once that frame clears.

A new streamed flag (set by FontAtlas) keeps the persistent map for images re-uploaded from a CPU-side staging buffer every frame; those 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.

Both the plain and compressed (GPU + CPU-fallback) upload paths share one ReleaseStaging() helper.

Tests

Adds tests/ImageStagingRelease, driving the real upload + image→buffer readback on a headless device. Asserts:

  • a static Update releases the staging (no handle pinned) and enqueues exactly one allocation tagged with the current frame;
  • the image reads back byte-equal to the staged source — proof the released staging genuinely outlived the submit (no use-after-free);
  • the deferred entry retires only after framesInFlight frames;
  • a streamed image keeps its staging across Update/UpdateRegion and frees it validation-clean on Destroy().

Full suite: 21 passed (ImageStagingRelease: 17/17 assertions, zero validation errors).

Resolves #114

🤖 Generated with Claude Code

## Problem `ImageVulkan` allocated a dedicated image `VkDeviceMemory` **plus** a persistent HOST_VISIBLE staging `buffer` (sized `w*h`), kept mapped for the image's whole lifetime — and `Destroy()` never freed it. Static textures (e.g. Sponza albedo, uploaded once) pinned host-visible / small-BAR memory forever. ## Fix `Update` now releases the staging buffer via `VulkanBuffer::DeferredClear()` right after recording the buffer→image copy — routed through the same fence-keyed deletion queue (#101/#102) the compressed `Mesh` path already uses, so the allocation outlives the upload submit and is freed once that frame clears. A new `streamed` flag (set by `FontAtlas`) keeps the persistent map for images re-uploaded from a CPU-side staging buffer every frame; those 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. Both the plain and compressed (GPU + CPU-fallback) upload paths share one `ReleaseStaging()` helper. ## Tests Adds `tests/ImageStagingRelease`, driving the real upload + image→buffer readback on a headless device. Asserts: - a static `Update` releases the staging (no handle pinned) and enqueues exactly one allocation tagged with the current frame; - the image reads back byte-equal to the staged source — proof the released staging genuinely outlived the submit (no use-after-free); - the deferred entry retires only after `framesInFlight` frames; - a `streamed` image keeps its staging across `Update`/`UpdateRegion` and frees it validation-clean on `Destroy()`. Full suite: **21 passed** (`ImageStagingRelease`: 17/17 assertions, zero validation errors). Resolves #114 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
catbot merged commit faf1e5f5e8 into master 2026-06-17 21:41:07 +02:00
catbot deleted branch claude/issue-114 2026-06-17 21:41:07 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Catcrafts/Crafter.Graphics!136
No description provided.