perf(font): upload only the dirty sub-rect of the glyph atlas (#51)
FontAtlas::Update re-uploaded the entire 1024x1024 R8 atlas (1 MiB) whenever a single glyph was rasterized, so warm-up / language-switch / size-churn cost roughly one full-atlas copy per frame that added a codepoint. Accumulate a dirty bounding box (FontAtlas::DirtyRect) in MarkDirty as glyphs are placed, and copy only that sub-rect. On Vulkan this is a new ImageVulkan::UpdateRegion: the staging buffer keeps the full atlas row stride, so bufferRowLength stays the atlas width and bufferOffset points at the rect origin while imageOffset/imageExtent carve out the rect. Layout transitions stay whole-image (cheap); only the copy extent shrinks. WebGPU passes the rect straight to wgpuWriteAtlasRegion, which already took x/y/w/h. The freshly-zeroed atlas marks its whole extent dirty in Initialize so the first Update clears the image once; thereafter every untouched texel stays the zero it was uploaded as, keeping the image byte-identical to the staging copy. Tested: new FontAtlasDirtyRect unit test covers the accumulation / clamp / lockstep-with-`dirty` math (no GPU needed); HelloUI renders text correctly on both Vulkan (NVIDIA, validation-clean) and WebGPU. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
459f10afa8
commit
97c79c23ee
5 changed files with 276 additions and 6 deletions
|
|
@ -51,13 +51,62 @@ export namespace Crafter {
|
|||
static constexpr int kOnEdgeValue = 128;
|
||||
static constexpr float kPixelDistScale = 32.0f;
|
||||
|
||||
// Bounding box of the atlas pixels touched since the last upload,
|
||||
// used to copy only the sub-rect that actually changed instead of
|
||||
// the whole 1 MiB atlas. Half-open: covers [minX, maxX) × [minY,
|
||||
// maxY). `active` distinguishes "nothing dirty" from a zero-origin
|
||||
// rect.
|
||||
struct DirtyRect {
|
||||
int minX = 0, minY = 0, maxX = 0, maxY = 0;
|
||||
bool active = false;
|
||||
|
||||
bool Empty() const noexcept { return !active; }
|
||||
void Reset() noexcept { active = false; }
|
||||
|
||||
// Grow the box to include [x, x+w) × [y, y+h). No-op for an
|
||||
// empty rect (a glyph that rasterized to nothing marks nothing).
|
||||
void Add(int x, int y, int w, int h) noexcept {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
if (!active) {
|
||||
minX = x; minY = y; maxX = x + w; maxY = y + h;
|
||||
active = true;
|
||||
} else {
|
||||
minX = std::min(minX, x);
|
||||
minY = std::min(minY, y);
|
||||
maxX = std::max(maxX, x + w);
|
||||
maxY = std::max(maxY, y + h);
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp the box to [0, limit] on both axes and emit it as an
|
||||
// (origin, extent) pair for a GPU copy. Returns false when
|
||||
// empty, leaving the outputs untouched.
|
||||
bool UploadBox(int limit, std::uint32_t& x, std::uint32_t& y, std::uint32_t& w, std::uint32_t& h) const noexcept {
|
||||
if (!active) return false;
|
||||
const int x0 = std::clamp(minX, 0, limit);
|
||||
const int y0 = std::clamp(minY, 0, limit);
|
||||
const int x1 = std::clamp(maxX, 0, limit);
|
||||
const int y1 = std::clamp(maxY, 0, limit);
|
||||
x = static_cast<std::uint32_t>(x0);
|
||||
y = static_cast<std::uint32_t>(y0);
|
||||
w = static_cast<std::uint32_t>(x1 - x0);
|
||||
h = static_cast<std::uint32_t>(y1 - y0);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||
ImageVulkan<std::uint8_t> image;
|
||||
#else
|
||||
WebGPUTextureRef textureHandle = 0;
|
||||
std::vector<std::uint8_t> staging;
|
||||
#endif
|
||||
// `dirty` stays the cheap "is there anything to flush?" flag the
|
||||
// renderer polls each frame; `dirtyRect` carries the bounds Update
|
||||
// copies. The two are always set and cleared together (dirty ==
|
||||
// !dirtyRect.Empty()).
|
||||
bool dirty = false;
|
||||
DirtyRect dirtyRect;
|
||||
|
||||
void Initialize(GraphicsCommandBuffer cmd);
|
||||
|
||||
|
|
@ -69,6 +118,13 @@ export namespace Crafter {
|
|||
const Glyph* Lookup(Font& font, std::uint32_t codepoint) const;
|
||||
void Update(GraphicsCommandBuffer cmd);
|
||||
|
||||
// Flag the [x, x+w) × [y, y+h) atlas region as needing re-upload,
|
||||
// keeping `dirty` in lockstep with the accumulated `dirtyRect`.
|
||||
void MarkDirty(int x, int y, int w, int h) noexcept {
|
||||
dirtyRect.Add(x, y, w, h);
|
||||
dirty = !dirtyRect.Empty();
|
||||
}
|
||||
|
||||
private:
|
||||
struct Shelf { int y = 0; int height = 0; int cursorX = 0; };
|
||||
std::vector<Shelf> shelves_;
|
||||
|
|
|
|||
|
|
@ -162,6 +162,34 @@ export namespace Crafter {
|
|||
}
|
||||
}
|
||||
|
||||
// Upload only the sub-rectangle [x, x+w) × [y, y+h) of the staging
|
||||
// buffer into the image, instead of the whole extent. The staging
|
||||
// buffer keeps the full image row stride, so bufferRowLength stays
|
||||
// `width` and bufferOffset just points at the rect's first texel —
|
||||
// imageOffset/imageExtent then carve out the rect on the GPU side.
|
||||
// Layout transitions stay whole-image (cheap); only the copy extent
|
||||
// shrinks. Single mip level only: the partial-upload path is for
|
||||
// CPU-streamed atlases (FontAtlas) which carry no mips, so there is
|
||||
// no blit chain to regenerate.
|
||||
void UpdateRegion(VkCommandBuffer cmd, VkImageLayout layout, std::uint32_t x, std::uint32_t y, std::uint32_t w, std::uint32_t h) {
|
||||
buffer.FlushDevice(cmd, VK_ACCESS_MEMORY_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
||||
TransitionImageLayout(cmd, image, layout, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, 0, mipLevels);
|
||||
|
||||
VkBufferImageCopy region{};
|
||||
region.bufferOffset = (static_cast<VkDeviceSize>(y) * width + x) * sizeof(PixelType);
|
||||
region.bufferRowLength = width;
|
||||
region.bufferImageHeight = 0;
|
||||
region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
|
||||
region.imageSubresource.mipLevel = 0;
|
||||
region.imageSubresource.baseArrayLayer = 0;
|
||||
region.imageSubresource.layerCount = 1;
|
||||
region.imageOffset = { static_cast<std::int32_t>(x), static_cast<std::int32_t>(y), 0 };
|
||||
region.imageExtent = { w, h, 1 };
|
||||
vkCmdCopyBufferToImage(cmd, buffer.buffer, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion);
|
||||
|
||||
TransitionImageLayout(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, layout, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 0, mipLevels);
|
||||
}
|
||||
|
||||
// GPU compressed-asset Update: stage compressed bytes, decompress
|
||||
// into `buffer` via VK_EXT_memory_decompression, then copy buffer→image
|
||||
// and transition to `layout`. Falls back to CPU decode + the existing
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue