Crafter.Graphics/implementations/Crafter.Graphics-FontAtlas.cpp
catbot 16d291b0ad perf(ui): per-shelf dirty spans for tight atlas uploads (#129)
FontAtlas::Update used a single union bounding box for the dirty rect.
Scattered new glyphs landing on different shelves produced a tall union
box that re-uploaded mostly-unchanged texels between the shelves.

Track a dirty span per shelf instead and issue one tight UpdateRegion /
wgpuWriteAtlasRegion copy per armed span. A shelf packs glyphs
left-to-right at a fixed top, so each span stays a contiguous X run
capped by the shelf height. The whole-atlas zero-clear in Initialize
keeps using the existing dirtyRect span. `dirty` is now the OR of every
span and is cleared together with them in Update.

Verified: full test suite green (23 passed); HelloUI text renders
crisply on the WebGPU backend through the new multi-span upload path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:35:54 +00:00

181 lines
6.1 KiB
C++

/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
*/
module;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
#include "vulkan/vulkan.h"
#endif
#include "../lib/stb_truetype.h"
module Crafter.Graphics:FontAtlas_impl;
import :FontAtlas;
import :Font;
import :GraphicsTypes;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import :ImageVulkan;
import :Device;
#else
import :WebGPU;
#endif
import std;
using namespace Crafter;
std::uint8_t* FontAtlas::PixelPtr() noexcept {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
return image.buffer.value;
#else
return staging.data();
#endif
}
void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
image.Create(
kAtlasSize, kAtlasSize, /*mipLevels*/ 1, cmd,
VK_FORMAT_R8_UNORM,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
// The atlas is sampled by the UI text *compute* shader, not an RT
// pipeline — scope the upload barriers to the real consumer.
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
// Streamed: glyphs are rasterized into buffer.value on the CPU and
// re-uploaded every frame via UpdateRegion, so the persistent staging
// map must outlive the first upload rather than being released (#114).
/*streamed*/ true
);
std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize);
#else
(void)cmd;
staging.assign(kAtlasSize * kAtlasSize, 0);
textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize);
#endif
// The freshly-zeroed atlas backs an image whose contents are undefined
// (Vulkan) / unwritten (WebGPU). Mark the whole extent dirty so the
// first Update clears it once; thereafter only glyph sub-rects flip
// dirty, and every untouched texel stays the zero it was uploaded as —
// so partial uploads keep the image byte-identical to the staging copy.
MarkDirty(0, 0, kAtlasSize, kAtlasSize);
}
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY, int& outShelf) {
for (std::size_t i = 0; i < shelves_.size(); ++i) {
Shelf& s = shelves_[i];
if (h <= s.height && s.cursorX + w <= kAtlasSize) {
outX = s.cursorX;
outY = s.y;
outShelf = static_cast<int>(i);
s.cursorX += w;
return true;
}
}
if (nextShelfY_ + h > kAtlasSize) return false;
Shelf s{};
s.y = nextShelfY_;
s.height = h;
s.cursorX = w;
outX = 0;
outY = s.y;
outShelf = static_cast<int>(shelves_.size());
shelves_.push_back(s);
nextShelfY_ += h;
return true;
}
bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
return EnsureAndGet(font, codepoint) != nullptr;
}
const Glyph* FontAtlas::EnsureAndGet(Font& font, std::uint32_t codepoint) {
Key key{&font, codepoint};
if (auto it = cache_.find(key); it != cache_.end()) return &it->second;
float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
int advanceUnits = 0, lsb = 0;
stbtt_GetCodepointHMetrics(&font.font, static_cast<int>(codepoint), &advanceUnits, &lsb);
int sw = 0, sh = 0, xoff = 0, yoff = 0;
unsigned char* sdf = stbtt_GetCodepointSDF(
&font.font, fontScale, static_cast<int>(codepoint),
kPadding, static_cast<unsigned char>(kOnEdgeValue), kPixelDistScale,
&sw, &sh, &xoff, &yoff
);
Glyph g{};
g.advance = advanceUnits * fontScale;
g.xoff = static_cast<float>(xoff);
g.yoff = static_cast<float>(yoff);
if (sdf && sw > 0 && sh > 0) {
int px = 0, py = 0, shelf = 0;
if (!ShelfPlace(sw, sh, px, py, shelf)) {
stbtt_FreeSDF(sdf, nullptr);
return nullptr;
}
std::uint8_t* dst = PixelPtr();
for (int row = 0; row < sh; ++row) {
std::memcpy(
dst + (py + row) * kAtlasSize + px,
sdf + row * sw,
static_cast<std::size_t>(sw)
);
}
stbtt_FreeSDF(sdf, nullptr);
g.w = static_cast<float>(sw);
g.h = static_cast<float>(sh);
g.u0 = static_cast<float>(px) / kAtlasSize;
g.v0 = static_cast<float>(py) / kAtlasSize;
g.u1 = static_cast<float>(px + sw) / kAtlasSize;
g.v1 = static_cast<float>(py + sh) / kAtlasSize;
// Mark the *shelf's* span, not one global box — keeps each upload
// tight when glyphs land on different shelves the same frame (#129).
shelves_[shelf].dirty.Add(px, py, sw, sh);
dirty = true;
}
return &cache_.emplace(key, g).first->second;
}
const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
auto it = cache_.find(Key{&font, codepoint});
return it == cache_.end() ? nullptr : &it->second;
}
void FontAtlas::Update(GraphicsCommandBuffer cmd) {
if (!dirty) return;
// Issue one tight copy per dirty span and reset it. Clamping to the atlas
// keeps each extent provably valid even though Add() already works in
// in-bounds glyph coordinates; UploadBox skips spans that never armed.
// The spans are the whole-atlas zero-clear (dirtyRect, Initialize only)
// plus each shelf's accumulated glyph run — so scattered glyphs upload as
// several small rects, not one tall union covering the gaps between them.
auto upload = [&](DirtyRect& r) {
std::uint32_t x = 0, y = 0, w = 0, h = 0;
if (!r.UploadBox(kAtlasSize, x, y, w, h)) return;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
image.UpdateRegion(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, x, y, w, h);
#else
// The staging buffer keeps the full atlas row stride, so srcBytesPerRow
// stays kAtlasSize and (dstX, dstY) index the sub-rect's first texel.
WebGPU::wgpuWriteAtlasRegion(
textureHandle, staging.data(),
kAtlasSize, kAtlasSize, kAtlasSize,
static_cast<std::int32_t>(x), static_cast<std::int32_t>(y),
static_cast<std::int32_t>(w), static_cast<std::int32_t>(h)
);
#endif
r.Reset();
};
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
(void)cmd;
#endif
upload(dirtyRect);
for (Shelf& s : shelves_) upload(s.dirty);
dirty = false;
}