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:
catbot 2026-06-16 15:40:37 +00:00
commit 97c79c23ee
5 changed files with 276 additions and 6 deletions

View file

@ -295,6 +295,33 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
blasImpls.emplace_back("tests/BLASBuildOptions/main");
bc.GetInterfacesAndImplementations(ifaces, blasImpls);
cfg.tests.push_back(std::move(blasTest));
// Issue #51: FontAtlas only re-uploads the dirty sub-rect now,
// tracked via FontAtlas::DirtyRect. The accumulation/clamp math is
// pure CPU, so this test drives it directly — no GPU device needed
// at runtime (the real copy params are covered by HelloUI rendering
// text on both backends).
Test atlasTest;
Configuration& ac = atlasTest.config;
ac.path = cfg.path;
ac.name = "FontAtlasDirtyRect";
ac.outputName = "FontAtlasDirtyRect";
ac.type = ConfigurationType::Executable;
ac.target = cfg.target;
ac.march = cfg.march;
ac.mtune = cfg.mtune;
ac.debug = cfg.debug;
ac.sysroot = cfg.sysroot;
ac.dependencies = cfg.dependencies;
ac.externalDependencies = cfg.externalDependencies;
ac.compileFlags = cfg.compileFlags;
ac.linkFlags = cfg.linkFlags;
ac.defines = cfg.defines;
ac.cFiles = cfg.cFiles;
std::vector<fs::path> atlasImpls(impls.begin(), impls.end());
atlasImpls.emplace_back("tests/FontAtlasDirtyRect/main");
ac.GetInterfacesAndImplementations(ifaces, atlasImpls);
cfg.tests.push_back(std::move(atlasTest));
}
return cfg;