Merge remote-tracking branch 'origin/master' into claude/issue-46
# Conflicts: # shaders/ui-text.comp.glsl
This commit is contained in:
commit
256ce56f1d
15 changed files with 983 additions and 112 deletions
|
|
@ -39,13 +39,17 @@ void FontAtlas::Initialize(GraphicsCommandBuffer cmd) {
|
||||||
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
|
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
|
||||||
);
|
);
|
||||||
std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize);
|
std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize);
|
||||||
dirty = true;
|
|
||||||
#else
|
#else
|
||||||
(void)cmd;
|
(void)cmd;
|
||||||
staging.assign(kAtlasSize * kAtlasSize, 0);
|
staging.assign(kAtlasSize * kAtlasSize, 0);
|
||||||
textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize);
|
textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize);
|
||||||
dirty = true;
|
|
||||||
#endif
|
#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) {
|
bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
|
||||||
|
|
@ -70,8 +74,12 @@ bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
|
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};
|
Key key{&font, codepoint};
|
||||||
if (cache_.contains(key)) return true;
|
if (auto it = cache_.find(key); it != cache_.end()) return &it->second;
|
||||||
|
|
||||||
float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
|
float fontScale = stbtt_ScaleForPixelHeight(&font.font, kBaseSize);
|
||||||
|
|
||||||
|
|
@ -94,7 +102,7 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
|
||||||
int px = 0, py = 0;
|
int px = 0, py = 0;
|
||||||
if (!ShelfPlace(sw, sh, px, py)) {
|
if (!ShelfPlace(sw, sh, px, py)) {
|
||||||
stbtt_FreeSDF(sdf, nullptr);
|
stbtt_FreeSDF(sdf, nullptr);
|
||||||
return false;
|
return nullptr;
|
||||||
}
|
}
|
||||||
std::uint8_t* dst = PixelPtr();
|
std::uint8_t* dst = PixelPtr();
|
||||||
for (int row = 0; row < sh; ++row) {
|
for (int row = 0; row < sh; ++row) {
|
||||||
|
|
@ -112,11 +120,10 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) {
|
||||||
g.v0 = static_cast<float>(py) / kAtlasSize;
|
g.v0 = static_cast<float>(py) / kAtlasSize;
|
||||||
g.u1 = static_cast<float>(px + sw) / kAtlasSize;
|
g.u1 = static_cast<float>(px + sw) / kAtlasSize;
|
||||||
g.v1 = static_cast<float>(py + sh) / kAtlasSize;
|
g.v1 = static_cast<float>(py + sh) / kAtlasSize;
|
||||||
dirty = true;
|
MarkDirty(px, py, sw, sh);
|
||||||
}
|
}
|
||||||
|
|
||||||
cache_.emplace(key, g);
|
return &cache_.emplace(key, g).first->second;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
|
const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
|
||||||
|
|
@ -126,16 +133,28 @@ const Glyph* FontAtlas::Lookup(Font& font, std::uint32_t codepoint) const {
|
||||||
|
|
||||||
void FontAtlas::Update(GraphicsCommandBuffer cmd) {
|
void FontAtlas::Update(GraphicsCommandBuffer cmd) {
|
||||||
if (!dirty) return;
|
if (!dirty) return;
|
||||||
|
|
||||||
|
// Clamp the accumulated box to the atlas and convert to (origin,
|
||||||
|
// extent). Add() works in glyph coordinates that are always in-bounds,
|
||||||
|
// but clamping keeps the copy extent provably valid. The `dirty` guard
|
||||||
|
// above guarantees the rect is non-empty, so UploadBox always fills the
|
||||||
|
// outputs.
|
||||||
|
std::uint32_t x = 0, y = 0, w = 0, h = 0;
|
||||||
|
dirtyRect.UploadBox(kAtlasSize, x, y, w, h);
|
||||||
|
|
||||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||||
image.Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
image.UpdateRegion(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, x, y, w, h);
|
||||||
#else
|
#else
|
||||||
(void)cmd;
|
(void)cmd;
|
||||||
// Full-atlas upload. Future: track dirty region.
|
// 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(
|
WebGPU::wgpuWriteAtlasRegion(
|
||||||
textureHandle, staging.data(),
|
textureHandle, staging.data(),
|
||||||
kAtlasSize, kAtlasSize, kAtlasSize,
|
kAtlasSize, kAtlasSize, kAtlasSize,
|
||||||
0, 0, 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
|
#endif
|
||||||
dirty = false;
|
dirty = false;
|
||||||
|
dirtyRect.Reset();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,39 +48,78 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize,
|
||||||
std::string_view utf8,
|
std::string_view utf8,
|
||||||
std::array<float,4> color,
|
std::array<float,4> color,
|
||||||
GlyphItem* out, std::uint32_t outCapacity,
|
GlyphItem* out, std::uint32_t outCapacity,
|
||||||
float* outAdvance) {
|
float* outAdvance,
|
||||||
|
TextAlign align) {
|
||||||
if (fontAtlas == nullptr) {
|
if (fontAtlas == nullptr) {
|
||||||
std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)");
|
std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)");
|
||||||
std::abort();
|
std::abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
const float scale = pxSize / FontAtlas::kBaseSize;
|
// Look up (or build) the origin-relative shaped run. It is stored with
|
||||||
float cursor = x;
|
// the pen starting at x=0, baseline=0 so a cache hit only needs the
|
||||||
std::uint32_t written = 0;
|
// translate below. The whole string is shaped (no outCapacity limit) so
|
||||||
|
// a later call with a larger buffer still gets the full run.
|
||||||
|
ShapedRunKey key{&font, pxSize, color, std::string(utf8)};
|
||||||
|
auto it = shapedRuns_.find(key);
|
||||||
|
if (it == shapedRuns_.end()) {
|
||||||
|
// Miss: shape from scratch. Ensure() each glyph so brand-new ones
|
||||||
|
// rasterise into the atlas and mark it dirty for the next upload —
|
||||||
|
// a hit skips this, which is safe because atlas entries are never
|
||||||
|
// evicted once placed.
|
||||||
|
const float scale = pxSize / FontAtlas::kBaseSize;
|
||||||
|
ShapedRun run;
|
||||||
|
float cursor = 0.0f;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < utf8.size()) {
|
||||||
|
std::uint32_t cp = DecodeUtf8(utf8, i);
|
||||||
|
if (cp == 0) break;
|
||||||
|
if (cp == '\n') { continue; }
|
||||||
|
|
||||||
std::size_t i = 0;
|
const Glyph* g = fontAtlas->EnsureAndGet(font, cp);
|
||||||
while (i < utf8.size() && written < outCapacity) {
|
if (g == nullptr) continue;
|
||||||
std::uint32_t cp = DecodeUtf8(utf8, i);
|
|
||||||
if (cp == 0) break;
|
|
||||||
if (cp == '\n') { continue; }
|
|
||||||
|
|
||||||
fontAtlas->Ensure(font, cp);
|
if (g->w > 0 && g->h > 0) {
|
||||||
const Glyph* g = fontAtlas->Lookup(font, cp);
|
GlyphItem gi;
|
||||||
if (g == nullptr) continue;
|
gi.x = cursor + g->xoff * scale;
|
||||||
|
gi.y = g->yoff * scale;
|
||||||
if (g->w > 0 && g->h > 0) {
|
gi.w = g->w * scale;
|
||||||
GlyphItem& gi = out[written++];
|
gi.h = g->h * scale;
|
||||||
gi.x = cursor + g->xoff * scale;
|
gi.u0 = g->u0; gi.v0 = g->v0;
|
||||||
gi.y = baselineY + g->yoff * scale;
|
gi.u1 = g->u1; gi.v1 = g->v1;
|
||||||
gi.w = g->w * scale;
|
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
|
||||||
gi.h = g->h * scale;
|
run.glyphs.push_back(gi);
|
||||||
gi.u0 = g->u0; gi.v0 = g->v0;
|
}
|
||||||
gi.u1 = g->u1; gi.v1 = g->v1;
|
cursor += g->advance * scale;
|
||||||
gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3];
|
|
||||||
}
|
}
|
||||||
cursor += g->advance * scale;
|
run.advance = cursor;
|
||||||
|
|
||||||
|
if (shapedRuns_.size() >= kMaxShapedRuns) shapedRuns_.clear();
|
||||||
|
it = shapedRuns_.emplace(std::move(key), std::move(run)).first;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outAdvance) *outAdvance = cursor - x;
|
const ShapedRun& run = it->second;
|
||||||
|
if (outAdvance) *outAdvance = run.advance;
|
||||||
|
|
||||||
|
// Fold the alignment shift into the translate so callers don't need a
|
||||||
|
// second pass over the glyphs.
|
||||||
|
const float alignShift = (align == TextAlign::Center) ? -run.advance * 0.5f
|
||||||
|
: (align == TextAlign::Right) ? -run.advance
|
||||||
|
: 0.0f;
|
||||||
|
const float tx = x + alignShift;
|
||||||
|
|
||||||
|
std::uint32_t written = 0;
|
||||||
|
for (const GlyphItem& src : run.glyphs) {
|
||||||
|
if (written >= outCapacity) break;
|
||||||
|
GlyphItem& gi = out[written++];
|
||||||
|
gi = src;
|
||||||
|
gi.x += tx;
|
||||||
|
gi.y += baselineY;
|
||||||
|
}
|
||||||
return written;
|
return written;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UIRenderer::InvalidateFont(const Font& font) noexcept {
|
||||||
|
std::erase_if(shapedRuns_, [&font](const auto& kv) {
|
||||||
|
return kv.first.font == &font;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,17 +58,12 @@ namespace {
|
||||||
GlyphItem* writePos = buf.glyphs + before;
|
GlyphItem* writePos = buf.glyphs + before;
|
||||||
float baseline = centerY + fontSize * 0.32f;
|
float baseline = centerY + fontSize * 0.32f;
|
||||||
float advance = 0.0f;
|
float advance = 0.0f;
|
||||||
|
// ShapeText folds the -advance/2 centering shift into its own pass.
|
||||||
std::uint32_t n = buf.renderer->ShapeText(
|
std::uint32_t n = buf.renderer->ShapeText(
|
||||||
font, fontSize, centerX, baseline,
|
font, fontSize, centerX, baseline,
|
||||||
label, color, writePos, cap, &advance
|
label, color, writePos, cap, &advance, TextAlign::Center
|
||||||
);
|
);
|
||||||
*buf.glyphCount = before + n;
|
*buf.glyphCount = before + n;
|
||||||
|
|
||||||
// Center: shift each glyph's x by -advance/2.
|
|
||||||
float shift = -advance * 0.5f;
|
|
||||||
for (std::uint32_t i = 0; i < n; ++i) {
|
|
||||||
writePos[i].x += shift;
|
|
||||||
}
|
|
||||||
return advance;
|
return advance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,16 +197,12 @@ float Crafter::DrawText(UIBuffer& buf, std::string_view text,
|
||||||
|
|
||||||
GlyphItem* writePos = buf.glyphs + before;
|
GlyphItem* writePos = buf.glyphs + before;
|
||||||
float advance = 0.0f;
|
float advance = 0.0f;
|
||||||
|
// Alignment is applied inside ShapeText (folded into its single pass).
|
||||||
std::uint32_t n = buf.renderer->ShapeText(
|
std::uint32_t n = buf.renderer->ShapeText(
|
||||||
font, fontSize, x, baselineY,
|
font, fontSize, x, baselineY,
|
||||||
text, color, writePos, cap, &advance
|
text, color, writePos, cap, &advance, align
|
||||||
);
|
);
|
||||||
*buf.glyphCount = before + n;
|
*buf.glyphCount = before + n;
|
||||||
|
|
||||||
if (align != TextAlign::Left && n > 0) {
|
|
||||||
float shift = (align == TextAlign::Center) ? -advance * 0.5f : -advance;
|
|
||||||
for (std::uint32_t i = 0; i < n; ++i) writePos[i].x += shift;
|
|
||||||
}
|
|
||||||
return advance;
|
return advance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,26 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
|
||||||
colorFormat = selectedFormat.format;
|
colorFormat = selectedFormat.format;
|
||||||
colorSpace = selectedFormat.colorSpace;
|
colorSpace = selectedFormat.colorSpace;
|
||||||
|
|
||||||
|
// Select a supported composite alpha format (not all devices support alpha
|
||||||
|
// opaque). This is a surface property, so pick it once here rather than on
|
||||||
|
// every CreateSwapchain. Simply select the first available.
|
||||||
|
{
|
||||||
|
VkSurfaceCapabilitiesKHR surfCaps;
|
||||||
|
Device::CheckVkResult(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(Device::physDevice, vulkanSurface, &surfCaps));
|
||||||
|
const VkCompositeAlphaFlagBitsKHR compositeAlphaFlags[] = {
|
||||||
|
VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR,
|
||||||
|
VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR,
|
||||||
|
VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR,
|
||||||
|
VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR,
|
||||||
|
};
|
||||||
|
for (const VkCompositeAlphaFlagBitsKHR compositeAlphaFlag : compositeAlphaFlags) {
|
||||||
|
if (surfCaps.supportedCompositeAlpha & compositeAlphaFlag) {
|
||||||
|
compositeAlpha = compositeAlphaFlag;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CreateSwapchain();
|
CreateSwapchain();
|
||||||
|
|
||||||
VkCommandBufferAllocateInfo cmdBufAllocateInfo {};
|
VkCommandBufferAllocateInfo cmdBufAllocateInfo {};
|
||||||
|
|
@ -414,27 +434,41 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
|
||||||
cmdBufAllocateInfo.commandBufferCount = numFrames;
|
cmdBufAllocateInfo.commandBufferCount = numFrames;
|
||||||
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cmdBufAllocateInfo, drawCmdBuffers));
|
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cmdBufAllocateInfo, drawCmdBuffers));
|
||||||
|
|
||||||
|
// Per-frame-in-flight synchronisation objects (issue #40). The fences are
|
||||||
|
// created signaled so the first wait on each image's fence — before that
|
||||||
|
// image has ever been submitted — returns immediately instead of
|
||||||
|
// deadlocking. The submitInfo is rebuilt per frame in Render() now that
|
||||||
|
// the wait/signal semaphores vary by frame.
|
||||||
VkSemaphoreCreateInfo semaphoreCreateInfo {};
|
VkSemaphoreCreateInfo semaphoreCreateInfo {};
|
||||||
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
||||||
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.presentComplete));
|
VkFenceCreateInfo fenceCreateInfo {};
|
||||||
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.renderComplete));
|
fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
|
||||||
|
fenceCreateInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT;
|
||||||
// Set up submit info structure
|
for (std::uint8_t i = 0; i < numFrames; i++) {
|
||||||
// Semaphores will stay the same during application lifetime
|
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &renderFinishedSemaphores[i]));
|
||||||
// Command buffer submission info is set by each example
|
Device::CheckVkResult(vkCreateFence(Device::device, &fenceCreateInfo, nullptr, &waitFences[i]));
|
||||||
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
}
|
||||||
submitInfo.pWaitDstStageMask = &submitPipelineStages;
|
for (std::uint8_t i = 0; i < numAcquireSemaphores; i++) {
|
||||||
submitInfo.waitSemaphoreCount = 1;
|
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &imageAcquiredSemaphores[i]));
|
||||||
submitInfo.pWaitSemaphores = &semaphores.presentComplete;
|
}
|
||||||
submitInfo.signalSemaphoreCount = 1;
|
|
||||||
submitInfo.pSignalSemaphores = &semaphores.renderComplete;
|
|
||||||
submitInfo.pNext = VK_NULL_HANDLE;
|
|
||||||
|
|
||||||
// Per-frame info structs: everything that never varies between frames is
|
// Per-frame info structs: everything that never varies between frames is
|
||||||
// set here once. Render() only patches the barriers' image/oldLayout.
|
// set here once. Render() patches the barriers' image/oldLayout and the
|
||||||
|
// sync handles that follow currentBuffer/frameCounter (submitInfo's
|
||||||
|
// wait/signal semaphores + command buffer, presentInfo's wait semaphore).
|
||||||
cmdBufInfo = {};
|
cmdBufInfo = {};
|
||||||
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||||
|
|
||||||
|
// Submit: stage mask + the 1/1/1 counts are invariant; the wait/signal
|
||||||
|
// semaphore handles and the command buffer are patched per frame.
|
||||||
|
submitInfo = {};
|
||||||
|
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
||||||
|
submitInfo.pNext = VK_NULL_HANDLE;
|
||||||
|
submitInfo.pWaitDstStageMask = &submitPipelineStages;
|
||||||
|
submitInfo.waitSemaphoreCount = 1;
|
||||||
|
submitInfo.signalSemaphoreCount = 1;
|
||||||
|
submitInfo.commandBufferCount = 1;
|
||||||
|
|
||||||
const VkImageSubresourceRange range {
|
const VkImageSubresourceRange range {
|
||||||
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
|
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
|
||||||
.baseMipLevel = 0,
|
.baseMipLevel = 0,
|
||||||
|
|
@ -471,8 +505,9 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
|
||||||
};
|
};
|
||||||
|
|
||||||
// pImageIndices/pSwapchains point at members, so they follow value changes
|
// pImageIndices/pSwapchains point at members, so they follow value changes
|
||||||
// (currentBuffer each frame, swapChain across recreation) on their own.
|
// (currentBuffer each frame, swapChain across recreation) on their own. The
|
||||||
// The render-complete wait semaphore is fixed for the window's lifetime.
|
// wait semaphore is the per-image render-finished semaphore, patched per
|
||||||
|
// frame in Render() (it follows currentBuffer).
|
||||||
presentInfo = {};
|
presentInfo = {};
|
||||||
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
|
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
|
||||||
presentInfo.pNext = NULL;
|
presentInfo.pNext = NULL;
|
||||||
|
|
@ -480,7 +515,6 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
|
||||||
presentInfo.pSwapchains = &swapChain;
|
presentInfo.pSwapchains = &swapChain;
|
||||||
presentInfo.pImageIndices = ¤tBuffer;
|
presentInfo.pImageIndices = ¤tBuffer;
|
||||||
presentInfo.waitSemaphoreCount = 1;
|
presentInfo.waitSemaphoreCount = 1;
|
||||||
presentInfo.pWaitSemaphores = &semaphores.renderComplete;
|
|
||||||
|
|
||||||
lastMousePos = {0,0};
|
lastMousePos = {0,0};
|
||||||
mouseDelta = {0,0};
|
mouseDelta = {0,0};
|
||||||
|
|
@ -724,22 +758,33 @@ void Window::Render() {
|
||||||
// Acquire the next image from the swap chain. If the surface has
|
// Acquire the next image from the swap chain. If the surface has
|
||||||
// changed size out from under us (compositor/Win32 resize delivered
|
// changed size out from under us (compositor/Win32 resize delivered
|
||||||
// between Render calls), recreate and retry once.
|
// between Render calls), recreate and retry once.
|
||||||
|
// Pick this frame's acquire semaphore from a free-running CPU counter: the
|
||||||
|
// image index (currentBuffer) isn't known until acquire returns, so the
|
||||||
|
// acquire's signal semaphore can't be keyed by the image.
|
||||||
|
const std::uint32_t acquireSlot = static_cast<std::uint32_t>(frameCounter % numAcquireSemaphores);
|
||||||
|
VkSemaphore imageAcquired = imageAcquiredSemaphores[acquireSlot];
|
||||||
{
|
{
|
||||||
VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
||||||
semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer);
|
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
||||||
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
|
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
|
||||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||||
RecreateSwapchainAndImages();
|
RecreateSwapchainAndImages();
|
||||||
onResize.Invoke();
|
onResize.Invoke();
|
||||||
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
||||||
semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer);
|
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
||||||
}
|
}
|
||||||
if (acquire != VK_SUBOPTIMAL_KHR) {
|
if (acquire != VK_SUBOPTIMAL_KHR) {
|
||||||
Device::CheckVkResult(acquire);
|
Device::CheckVkResult(acquire);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
submitInfo.commandBufferCount = 1;
|
|
||||||
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
|
// drawCmdBuffers[currentBuffer] and this image's descriptor-heap slot are
|
||||||
|
// about to be re-recorded. Wait on the fence guarding this image's
|
||||||
|
// previous submission (keyed by image index, since acquire returns an
|
||||||
|
// arbitrary index), then reset it for this submit. This — not a full-queue
|
||||||
|
// wait-idle — is what bounds frames-in-flight and gives the CPU/GPU overlap.
|
||||||
|
Device::CheckVkResult(vkWaitForFences(Device::device, 1, &waitFences[currentBuffer], VK_TRUE, UINT64_MAX));
|
||||||
|
Device::CheckVkResult(vkResetFences(Device::device, 1, &waitFences[currentBuffer]));
|
||||||
|
|
||||||
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
|
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
|
||||||
|
|
||||||
|
|
@ -834,20 +879,38 @@ void Window::Render() {
|
||||||
|
|
||||||
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
|
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
|
||||||
|
|
||||||
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
|
// Patch the per-frame fields of the cached submitInfo/presentInfo (the
|
||||||
|
// invariant fields were set once in the constructor): submit waits on this
|
||||||
|
// frame's acquire semaphore and signals this image's render-finished
|
||||||
|
// semaphore, present waits on that same render-finished semaphore. The
|
||||||
|
// fence (keyed by image index) lets a future Render() reuse this image's
|
||||||
|
// command buffer + heap slot safely. The semaphore handles live in member
|
||||||
|
// arrays, so taking their addresses here is stable for the call.
|
||||||
|
submitInfo.pWaitSemaphores = &imageAcquiredSemaphores[acquireSlot];
|
||||||
|
submitInfo.pSignalSemaphores = &renderFinishedSemaphores[currentBuffer];
|
||||||
|
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
|
||||||
|
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, waitFences[currentBuffer]));
|
||||||
|
|
||||||
|
presentInfo.pWaitSemaphores = &renderFinishedSemaphores[currentBuffer];
|
||||||
|
|
||||||
VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo);
|
VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo);
|
||||||
if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) {
|
if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) {
|
||||||
// Surface size changed mid-present. Drain the queue, rebuild the
|
// Surface size changed mid-present. Drain the queue (the only place we
|
||||||
// swapchain, and let dependents (descriptors holding old image
|
// still wait-idle, alongside Resize() and the acquire OUT_OF_DATE
|
||||||
// handles) re-bind via onResize before the next frame.
|
// path), rebuild the swapchain, and let dependents (descriptors
|
||||||
|
// holding old image handles) re-bind via onResize before the next
|
||||||
|
// frame. The wait-idle here also re-settles every per-frame fence to a
|
||||||
|
// signaled state, so the next Render()'s fence wait passes through.
|
||||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||||
RecreateSwapchainAndImages();
|
RecreateSwapchainAndImages();
|
||||||
onResize.Invoke();
|
onResize.Invoke();
|
||||||
} else {
|
} else {
|
||||||
Device::CheckVkResult(result);
|
Device::CheckVkResult(result);
|
||||||
}
|
}
|
||||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
// No vkQueueWaitIdle in the steady-state path: per-frame fences now gate
|
||||||
|
// command-buffer reuse, so the CPU is free to acquire/record frame N+1
|
||||||
|
// while the GPU is still executing frame N.
|
||||||
|
frameCounter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef CRAFTER_TIMING
|
#ifdef CRAFTER_TIMING
|
||||||
|
|
@ -915,18 +978,6 @@ void Window::CreateSwapchain()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Select a present mode for the swapchain
|
|
||||||
uint32_t presentModeCount;
|
|
||||||
Device::CheckVkResult(vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, NULL));
|
|
||||||
assert(presentModeCount > 0);
|
|
||||||
|
|
||||||
std::vector<VkPresentModeKHR> presentModes(presentModeCount);
|
|
||||||
Device::CheckVkResult(vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, presentModes.data()));
|
|
||||||
|
|
||||||
// The VK_PRESENT_MODE_FIFO_KHR mode must always be present as per spec
|
|
||||||
// This mode waits for the vertical blank ("v-sync")
|
|
||||||
VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
|
|
||||||
|
|
||||||
// Find the transformation of the surface
|
// Find the transformation of the surface
|
||||||
VkSurfaceTransformFlagsKHR preTransform;
|
VkSurfaceTransformFlagsKHR preTransform;
|
||||||
if (surfCaps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
|
if (surfCaps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
|
||||||
|
|
@ -939,22 +990,6 @@ void Window::CreateSwapchain()
|
||||||
preTransform = surfCaps.currentTransform;
|
preTransform = surfCaps.currentTransform;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find a supported composite alpha format (not all devices support alpha opaque)
|
|
||||||
VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
|
|
||||||
// Simply select the first composite alpha format available
|
|
||||||
std::vector<VkCompositeAlphaFlagBitsKHR> compositeAlphaFlags = {
|
|
||||||
VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR,
|
|
||||||
VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR,
|
|
||||||
VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR,
|
|
||||||
VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR,
|
|
||||||
};
|
|
||||||
for (auto& compositeAlphaFlag : compositeAlphaFlags) {
|
|
||||||
if (surfCaps.supportedCompositeAlpha & compositeAlphaFlag) {
|
|
||||||
compositeAlpha = compositeAlphaFlag;
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
VkSwapchainCreateInfoKHR swapchainCI = {};
|
VkSwapchainCreateInfoKHR swapchainCI = {};
|
||||||
swapchainCI.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
|
swapchainCI.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
|
||||||
swapchainCI.surface = vulkanSurface;
|
swapchainCI.surface = vulkanSurface;
|
||||||
|
|
@ -967,7 +1002,9 @@ void Window::CreateSwapchain()
|
||||||
swapchainCI.imageArrayLayers = 1;
|
swapchainCI.imageArrayLayers = 1;
|
||||||
swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
||||||
swapchainCI.queueFamilyIndexCount = 0;
|
swapchainCI.queueFamilyIndexCount = 0;
|
||||||
swapchainCI.presentMode = swapchainPresentMode;
|
// VK_PRESENT_MODE_FIFO_KHR ("v-sync") is guaranteed available per spec, so
|
||||||
|
// it is used unconditionally — no present-mode enumeration is needed.
|
||||||
|
swapchainCI.presentMode = VK_PRESENT_MODE_FIFO_KHR;
|
||||||
// Setting oldSwapChain to the saved handle of the previous swapchain aids in resource reuse and makes sure that we can still present already acquired images
|
// Setting oldSwapChain to the saved handle of the previous swapchain aids in resource reuse and makes sure that we can still present already acquired images
|
||||||
swapchainCI.oldSwapchain = oldSwapchain;
|
swapchainCI.oldSwapchain = oldSwapchain;
|
||||||
// Setting clipped to VK_TRUE allows the implementation to discard rendering outside of the surface area
|
// Setting clipped to VK_TRUE allows the implementation to discard rendering outside of the surface area
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,62 @@ export namespace Crafter {
|
||||||
static constexpr int kOnEdgeValue = 128;
|
static constexpr int kOnEdgeValue = 128;
|
||||||
static constexpr float kPixelDistScale = 32.0f;
|
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
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||||
ImageVulkan<std::uint8_t> image;
|
ImageVulkan<std::uint8_t> image;
|
||||||
#else
|
#else
|
||||||
WebGPUTextureRef textureHandle = 0;
|
WebGPUTextureRef textureHandle = 0;
|
||||||
std::vector<std::uint8_t> staging;
|
std::vector<std::uint8_t> staging;
|
||||||
#endif
|
#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;
|
bool dirty = false;
|
||||||
|
DirtyRect dirtyRect;
|
||||||
|
|
||||||
void Initialize(GraphicsCommandBuffer cmd);
|
void Initialize(GraphicsCommandBuffer cmd);
|
||||||
|
|
||||||
|
|
@ -66,9 +115,21 @@ export namespace Crafter {
|
||||||
std::uint8_t* PixelPtr() noexcept;
|
std::uint8_t* PixelPtr() noexcept;
|
||||||
|
|
||||||
bool Ensure(Font& font, std::uint32_t codepoint);
|
bool Ensure(Font& font, std::uint32_t codepoint);
|
||||||
|
// Ensure the glyph is in the atlas and return a pointer to it in one
|
||||||
|
// hash+probe. Returns nullptr only when a brand-new glyph cannot be
|
||||||
|
// placed (ShelfPlace failure); a glyph with no SDF coverage (e.g.
|
||||||
|
// space) still returns a valid pointer.
|
||||||
|
const Glyph* EnsureAndGet(Font& font, std::uint32_t codepoint);
|
||||||
const Glyph* Lookup(Font& font, std::uint32_t codepoint) const;
|
const Glyph* Lookup(Font& font, std::uint32_t codepoint) const;
|
||||||
void Update(GraphicsCommandBuffer cmd);
|
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:
|
private:
|
||||||
struct Shelf { int y = 0; int height = 0; int cursorX = 0; };
|
struct Shelf { int y = 0; int height = 0; int cursorX = 0; };
|
||||||
std::vector<Shelf> shelves_;
|
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
|
// GPU compressed-asset Update: stage compressed bytes, decompress
|
||||||
// into `buffer` via VK_EXT_memory_decompression, then copy buffer→image
|
// into `buffer` via VK_EXT_memory_decompression, then copy buffer→image
|
||||||
// and transition to `layout`. Falls back to CPU decode + the existing
|
// and transition to `layout`. Falls back to CPU decode + the existing
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,12 @@ export namespace Crafter {
|
||||||
};
|
};
|
||||||
static_assert(sizeof(GlyphItem) == 48);
|
static_assert(sizeof(GlyphItem) == 48);
|
||||||
|
|
||||||
|
// Where the X coordinate sits relative to the emitted glyph run. Used by
|
||||||
|
// UIRenderer::ShapeText and the Tier 3 text components (DrawText). Lives
|
||||||
|
// here (not in :UIComponents) so ShapeText can fold the alignment shift
|
||||||
|
// into its single pass.
|
||||||
|
enum class TextAlign : std::uint8_t { Left, Center, Right };
|
||||||
|
|
||||||
// ─── tiny rect-carving helper ───────────────────────────────────────
|
// ─── tiny rect-carving helper ───────────────────────────────────────
|
||||||
struct Rect {
|
struct Rect {
|
||||||
float x = 0, y = 0, w = 0, h = 0;
|
float x = 0, y = 0, w = 0, h = 0;
|
||||||
|
|
@ -206,12 +212,30 @@ export namespace Crafter {
|
||||||
#endif
|
#endif
|
||||||
SamplerSlot RegisterLinearClampSampler();
|
SamplerSlot RegisterLinearClampSampler();
|
||||||
|
|
||||||
|
// Shapes `utf8` into `out` (up to `outCapacity` glyphs), positioning
|
||||||
|
// the run at `(x, baselineY)` with horizontal `align`. Returns the
|
||||||
|
// number of glyphs written; `*outAdvance` (if non-null) receives the
|
||||||
|
// full advance width regardless of truncation.
|
||||||
|
//
|
||||||
|
// The origin-relative shaped run is memoized in a CPU cache keyed by
|
||||||
|
// (Font*, pxSize, color, utf8); a hit skips UTF-8 decode, glyph
|
||||||
|
// lookup, and layout, translating the cached run into `out` instead.
|
||||||
|
// Caching is byte-equivalent — the glyph buffer is fully rewritten
|
||||||
|
// and re-flushed every frame regardless. Call InvalidateFont before
|
||||||
|
// destroying a Font (the cache keys on the Font pointer).
|
||||||
std::uint32_t ShapeText(Font& font, float pxSize,
|
std::uint32_t ShapeText(Font& font, float pxSize,
|
||||||
float x, float baselineY,
|
float x, float baselineY,
|
||||||
std::string_view utf8,
|
std::string_view utf8,
|
||||||
std::array<float,4> color,
|
std::array<float,4> color,
|
||||||
GlyphItem* out, std::uint32_t outCapacity,
|
GlyphItem* out, std::uint32_t outCapacity,
|
||||||
float* outAdvance = nullptr);
|
float* outAdvance = nullptr,
|
||||||
|
TextAlign align = TextAlign::Left);
|
||||||
|
|
||||||
|
// Drop every cached shaped run that references `font`. Must be called
|
||||||
|
// before a Font is destroyed: the run cache (and FontAtlas's glyph
|
||||||
|
// cache) key on the Font pointer, so a Font freed and a new one
|
||||||
|
// reallocated at the same address would otherwise alias stale runs.
|
||||||
|
void InvalidateFont(const Font& font) noexcept;
|
||||||
|
|
||||||
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
|
std::uint16_t FontAtlasImageSlot() const noexcept { return fontAtlasImageSlot_; }
|
||||||
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
|
std::uint16_t FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; }
|
||||||
|
|
@ -222,6 +246,41 @@ export namespace Crafter {
|
||||||
Window* window_ = nullptr;
|
Window* window_ = nullptr;
|
||||||
GraphicsDescriptorHeap* heap_ = nullptr;
|
GraphicsDescriptorHeap* heap_ = nullptr;
|
||||||
|
|
||||||
|
// ─── shaped-run cache ───────────────────────────────────────────
|
||||||
|
// Glyphs are stored origin-relative (pen at x=0, baseline=0); a hit
|
||||||
|
// only needs a translate by (x + alignShift, baselineY). The full
|
||||||
|
// string is part of the key (compared on lookup) so hash collisions
|
||||||
|
// can't return the wrong run.
|
||||||
|
struct ShapedRunKey {
|
||||||
|
const Font* font;
|
||||||
|
float pxSize;
|
||||||
|
std::array<float, 4> color;
|
||||||
|
std::string text;
|
||||||
|
bool operator==(const ShapedRunKey&) const = default;
|
||||||
|
};
|
||||||
|
struct ShapedRunKeyHash {
|
||||||
|
std::size_t operator()(const ShapedRunKey& k) const noexcept {
|
||||||
|
std::size_t h = std::hash<const void*>{}(k.font);
|
||||||
|
auto mix = [&h](std::size_t v) noexcept {
|
||||||
|
h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2);
|
||||||
|
};
|
||||||
|
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(k.pxSize)));
|
||||||
|
for (float c : k.color)
|
||||||
|
mix(std::hash<std::uint32_t>{}(std::bit_cast<std::uint32_t>(c)));
|
||||||
|
mix(std::hash<std::string_view>{}(k.text));
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
struct ShapedRun {
|
||||||
|
std::vector<GlyphItem> glyphs; // origin-relative
|
||||||
|
float advance = 0;
|
||||||
|
};
|
||||||
|
// Soft cap. A pathological caller drawing a fresh string every frame
|
||||||
|
// would grow this without bound; on overflow we drop everything and
|
||||||
|
// rebuild (correctness is unaffected, only the hit rate after a flush).
|
||||||
|
static constexpr std::size_t kMaxShapedRuns = 8192;
|
||||||
|
std::unordered_map<ShapedRunKey, ShapedRun, ShapedRunKeyHash> shapedRuns_;
|
||||||
|
|
||||||
ImageSlot outImageSlot_;
|
ImageSlot outImageSlot_;
|
||||||
ImageSlot fontAtlasImageSlot_;
|
ImageSlot fontAtlasImageSlot_;
|
||||||
SamplerSlot fontAtlasSamplerSlot_;
|
SamplerSlot fontAtlasSamplerSlot_;
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,7 @@ export namespace Crafter {
|
||||||
float cornerRadius = 0;
|
float cornerRadius = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Where the X coordinate sits relative to the emitted glyph run.
|
// TextAlign lives in the :UI partition (used by UIRenderer::ShapeText).
|
||||||
enum class TextAlign : std::uint8_t { Left, Center, Right };
|
|
||||||
|
|
||||||
// ─── component functions ───────────────────────────────────────────
|
// ─── component functions ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,11 @@ export namespace Crafter {
|
||||||
VkSwapchainKHR swapChain = VK_NULL_HANDLE;
|
VkSwapchainKHR swapChain = VK_NULL_HANDLE;
|
||||||
VkFormat colorFormat;
|
VkFormat colorFormat;
|
||||||
VkColorSpaceKHR colorSpace;
|
VkColorSpaceKHR colorSpace;
|
||||||
|
// Supported composite-alpha mode for vulkanSurface. A surface property
|
||||||
|
// that does not change across swapchain recreation, so it is selected
|
||||||
|
// once at construction rather than re-derived on every CreateSwapchain
|
||||||
|
// (i.e. on every resize configure).
|
||||||
|
VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
|
||||||
VkImage images[numFrames];
|
VkImage images[numFrames];
|
||||||
VkImageViewCreateInfo imageViews[numFrames];
|
VkImageViewCreateInfo imageViews[numFrames];
|
||||||
// Tracks whether each swapchain image has been rendered (and thus
|
// Tracks whether each swapchain image has been rendered (and thus
|
||||||
|
|
@ -265,18 +270,59 @@ export namespace Crafter {
|
||||||
std::array<bool, numFrames> imageInitialised{};
|
std::array<bool, numFrames> imageInitialised{};
|
||||||
std::thread thread;
|
std::thread thread;
|
||||||
VkCommandBuffer drawCmdBuffers[numFrames];
|
VkCommandBuffer drawCmdBuffers[numFrames];
|
||||||
VkSubmitInfo submitInfo;
|
|
||||||
// Per-frame Vulkan info structs whose contents are almost entirely
|
// Per-frame Vulkan info structs whose contents are almost entirely
|
||||||
// invariant. Initialised once in the constructor; Render() patches only
|
// invariant. Initialised once in the constructor; Render() patches only
|
||||||
// the few fields that change each frame (the two barriers' image and
|
// the few fields that change each frame (the two barriers' image and
|
||||||
// the acquire barrier's oldLayout). presentInfo.pImageIndices and
|
// the acquire barrier's oldLayout, and — for the multi-frame-in-flight
|
||||||
// pSwapchains point at the currentBuffer/swapChain members, so they
|
// sync below — submitInfo/presentInfo's wait/signal semaphores plus
|
||||||
// track value changes (including swapchain recreation) automatically.
|
// submitInfo's command buffer, which follow currentBuffer/frameCounter).
|
||||||
|
// presentInfo.pImageIndices and pSwapchains point at the
|
||||||
|
// currentBuffer/swapChain members, so they track value changes
|
||||||
|
// (including swapchain recreation) automatically.
|
||||||
|
VkSubmitInfo submitInfo;
|
||||||
VkCommandBufferBeginInfo cmdBufInfo;
|
VkCommandBufferBeginInfo cmdBufInfo;
|
||||||
VkImageMemoryBarrier acquireBarrier;
|
VkImageMemoryBarrier acquireBarrier;
|
||||||
VkImageMemoryBarrier presentBarrier;
|
VkImageMemoryBarrier presentBarrier;
|
||||||
VkPresentInfoKHR presentInfo;
|
VkPresentInfoKHR presentInfo;
|
||||||
Semaphores semaphores;
|
|
||||||
|
// ── Multi-frame-in-flight synchronisation (issue #40) ──────────────
|
||||||
|
// The renderer keeps up to numFrames frames in flight, so a single
|
||||||
|
// semaphore pair + a per-frame wait-idle is no longer enough. Note
|
||||||
|
// that drawCmdBuffers, the per-frame descriptor heap slots, and the
|
||||||
|
// swapchain images are ALL keyed by the acquired image index
|
||||||
|
// (currentBuffer) — WriteSwapchainDescriptors bakes heap slot i to
|
||||||
|
// write imageViews[i], so the index that selects a command buffer /
|
||||||
|
// heap slot must equal the acquired image index. Everything below
|
||||||
|
// follows from that.
|
||||||
|
|
||||||
|
// Signaled by the queue submit, waited by vkQueuePresentKHR. Keyed by
|
||||||
|
// the acquired IMAGE index: the presentation engine keeps this
|
||||||
|
// semaphore in use until the image is re-acquired, so a per-image
|
||||||
|
// semaphore is the only safe key. Keying it per-CPU-frame trips
|
||||||
|
// VUID-vkQueueSubmit-pSignalSemaphores-00067.
|
||||||
|
// https://docs.vulkan.org/guide/latest/swapchain_semaphore_reuse.html
|
||||||
|
std::array<VkSemaphore, numFrames> renderFinishedSemaphores{};
|
||||||
|
|
||||||
|
// Signaled by the queue submit, waited + reset before
|
||||||
|
// drawCmdBuffers[image] and that image's descriptor-heap slot are
|
||||||
|
// re-recorded. Keyed by image index. Created signaled so the first
|
||||||
|
// wait on each image passes through instead of deadlocking.
|
||||||
|
std::array<VkFence, numFrames> waitFences{};
|
||||||
|
|
||||||
|
// Signaled by vkAcquireNextImageKHR, waited by the queue submit. Keyed
|
||||||
|
// by a free-running CPU frame counter, because the image index isn't
|
||||||
|
// known until acquire returns. Sized numFrames+1: at most numFrames
|
||||||
|
// frames are ever in flight (each image's command buffer is gated by
|
||||||
|
// its own fence, and there are numFrames images), so numFrames+1
|
||||||
|
// distinct acquire semaphores guarantees the one being reused has no
|
||||||
|
// pending operation — required by
|
||||||
|
// VUID-vkAcquireNextImageKHR-semaphore-01779.
|
||||||
|
static constexpr std::uint8_t numAcquireSemaphores = numFrames + 1;
|
||||||
|
std::array<VkSemaphore, numAcquireSemaphores> imageAcquiredSemaphores{};
|
||||||
|
|
||||||
|
// Free-running count of Render() calls; drives the acquire-semaphore
|
||||||
|
// slot (frameCounter % numAcquireSemaphores).
|
||||||
|
std::uint64_t frameCounter = 0;
|
||||||
std::uint32_t currentBuffer = 0;
|
std::uint32_t currentBuffer = 0;
|
||||||
VkPipelineStageFlags submitPipelineStages = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
VkPipelineStageFlags submitPipelineStages = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||||||
std::vector<RenderPass*> passes;
|
std::vector<RenderPass*> passes;
|
||||||
|
|
|
||||||
89
project.cpp
89
project.cpp
|
|
@ -264,6 +264,37 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
scrollImpls.emplace_back("tests/MouseScroll/main");
|
scrollImpls.emplace_back("tests/MouseScroll/main");
|
||||||
sc.GetInterfacesAndImplementations(ifaces, scrollImpls);
|
sc.GetInterfacesAndImplementations(ifaces, scrollImpls);
|
||||||
cfg.tests.push_back(std::move(scrollTest));
|
cfg.tests.push_back(std::move(scrollTest));
|
||||||
|
|
||||||
|
// Issue #40: multi-frame-in-flight frame pacing (per-image fences
|
||||||
|
// + per-frame semaphores, no steady-state wait-idle). Drives the
|
||||||
|
// real frame loop against a live Wayland compositor for many more
|
||||||
|
// frames than there are in-flight slots and asserts the validation
|
||||||
|
// layer stays silent — the old singleton-semaphore design only
|
||||||
|
// avoided being an active race because the wait-idle masked it, so
|
||||||
|
// a clean multi-frame run is the regression guard. Needs the
|
||||||
|
// Wayland backend + a real compositor, hence inside the !windows
|
||||||
|
// block alongside MouseScroll.
|
||||||
|
Test frameLoopTest;
|
||||||
|
Configuration& fl = frameLoopTest.config;
|
||||||
|
fl.path = cfg.path;
|
||||||
|
fl.name = "FrameLoopSync";
|
||||||
|
fl.outputName = "FrameLoopSync";
|
||||||
|
fl.type = ConfigurationType::Executable;
|
||||||
|
fl.target = cfg.target;
|
||||||
|
fl.march = cfg.march;
|
||||||
|
fl.mtune = cfg.mtune;
|
||||||
|
fl.debug = cfg.debug;
|
||||||
|
fl.sysroot = cfg.sysroot;
|
||||||
|
fl.dependencies = cfg.dependencies;
|
||||||
|
fl.externalDependencies = cfg.externalDependencies;
|
||||||
|
fl.compileFlags = cfg.compileFlags;
|
||||||
|
fl.linkFlags = cfg.linkFlags;
|
||||||
|
fl.defines = cfg.defines;
|
||||||
|
fl.cFiles = cfg.cFiles;
|
||||||
|
std::vector<fs::path> frameLoopImpls(impls.begin(), impls.end());
|
||||||
|
frameLoopImpls.emplace_back("tests/FrameLoopSync/main");
|
||||||
|
fl.GetInterfacesAndImplementations(ifaces, frameLoopImpls);
|
||||||
|
cfg.tests.push_back(std::move(frameLoopTest));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #36: BLAS build options. Drives the real hardware AS-build
|
// Issue #36: BLAS build options. Drives the real hardware AS-build
|
||||||
|
|
@ -295,6 +326,64 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
blasImpls.emplace_back("tests/BLASBuildOptions/main");
|
blasImpls.emplace_back("tests/BLASBuildOptions/main");
|
||||||
bc.GetInterfacesAndImplementations(ifaces, blasImpls);
|
bc.GetInterfacesAndImplementations(ifaces, blasImpls);
|
||||||
cfg.tests.push_back(std::move(blasTest));
|
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));
|
||||||
|
|
||||||
|
// Issue #52: shaped-run cache for UIRenderer::ShapeText. Shapes a
|
||||||
|
// string against a real CPU-side FontAtlas and asserts that a cache
|
||||||
|
// hit is byte-equivalent to the uncached path, that hits don't touch
|
||||||
|
// the atlas, and that translate / alignment / truncation /
|
||||||
|
// InvalidateFont all behave. Needs a headless Vulkan device (the
|
||||||
|
// atlas image is a real GPU image) but no swapchain — same native
|
||||||
|
// build settings as the others. The font file is copied next to the
|
||||||
|
// binary; the test also probes the project-root path.
|
||||||
|
Test textTest;
|
||||||
|
Configuration& xc = textTest.config;
|
||||||
|
xc.path = cfg.path;
|
||||||
|
xc.name = "ShapeTextCache";
|
||||||
|
xc.outputName = "ShapeTextCache";
|
||||||
|
xc.type = ConfigurationType::Executable;
|
||||||
|
xc.target = cfg.target;
|
||||||
|
xc.march = cfg.march;
|
||||||
|
xc.mtune = cfg.mtune;
|
||||||
|
xc.debug = cfg.debug;
|
||||||
|
xc.sysroot = cfg.sysroot;
|
||||||
|
xc.dependencies = cfg.dependencies;
|
||||||
|
xc.externalDependencies = cfg.externalDependencies;
|
||||||
|
xc.compileFlags = cfg.compileFlags;
|
||||||
|
xc.linkFlags = cfg.linkFlags;
|
||||||
|
xc.defines = cfg.defines;
|
||||||
|
xc.cFiles = cfg.cFiles;
|
||||||
|
xc.files = { fs::path("tests/ShapeTextCache/font.ttf") };
|
||||||
|
std::vector<fs::path> textImpls(impls.begin(), impls.end());
|
||||||
|
textImpls.emplace_back("tests/ShapeTextCache/main");
|
||||||
|
xc.GetInterfacesAndImplementations(ifaces, textImpls);
|
||||||
|
cfg.tests.push_back(std::move(textTest));
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,12 @@ void main() {
|
||||||
vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw;
|
vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw;
|
||||||
vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
|
vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
|
||||||
|
|
||||||
|
// Font slots are push constants — provably dynamically uniform, so no
|
||||||
|
// nonuniformEXT: lets the compiler hoist the descriptor load out of the
|
||||||
|
// per-pixel glyph loop and avoid the divergent-descriptor path.
|
||||||
float sdf = texture(
|
float sdf = texture(
|
||||||
sampler2D(uiTextures[nonuniformEXT(pc.fontTextureSlot)],
|
sampler2D(uiTextures[pc.fontTextureSlot],
|
||||||
uiSamplers[nonuniformEXT(pc.fontSamplerSlot)]),
|
uiSamplers[pc.fontSamplerSlot]),
|
||||||
uv
|
uv
|
||||||
).r;
|
).r;
|
||||||
|
|
||||||
|
|
|
||||||
143
tests/FontAtlasDirtyRect/main.cpp
Normal file
143
tests/FontAtlasDirtyRect/main.cpp
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/*
|
||||||
|
Crafter®.Graphics
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
catcrafts.net
|
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or
|
||||||
|
modify it under the terms of the GNU Lesser General Public
|
||||||
|
License version 3.0 as published by the Free Software Foundation;
|
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public
|
||||||
|
License along with this library; if not, write to the Free Software
|
||||||
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Regression test for issue #51: FontAtlas::Update used to re-upload the
|
||||||
|
// whole 1024×1024 atlas every time a single glyph was rasterized. It now
|
||||||
|
// accumulates a dirty bounding box (FontAtlas::DirtyRect) and copies only
|
||||||
|
// that sub-rect. The bookkeeping is pure CPU math, so this test drives it
|
||||||
|
// directly — no Vulkan device or WebGPU context needed:
|
||||||
|
//
|
||||||
|
// - a fresh rect is empty; the first Add seeds exact bounds
|
||||||
|
// - further Adds grow the half-open union, never shrink it
|
||||||
|
// - degenerate glyphs (w<=0 / h<=0) leave the rect untouched
|
||||||
|
// - UploadBox converts (min,max) → (origin, extent) and clamps to the
|
||||||
|
// atlas, and reports emptiness so Update can early-out
|
||||||
|
// - FontAtlas::MarkDirty keeps the public `dirty` flag in lockstep with
|
||||||
|
// the rect, and Reset() clears both notions of dirtiness
|
||||||
|
//
|
||||||
|
// The actual GPU copy params (bufferOffset / bufferRowLength on Vulkan,
|
||||||
|
// dst origin / bytesPerRow on WebGPU) are exercised end-to-end by rendering
|
||||||
|
// text in the HelloUI example on both backends.
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
import Crafter.Graphics;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read UploadBox into locals and compare against an expected (x,y,w,h).
|
||||||
|
bool BoxIs(const FontAtlas::DirtyRect& r, int limit,
|
||||||
|
std::uint32_t ex, std::uint32_t ey, std::uint32_t ew, std::uint32_t eh) {
|
||||||
|
std::uint32_t x = 99999, y = 99999, w = 99999, h = 99999;
|
||||||
|
if (!r.UploadBox(limit, x, y, w, h)) return false;
|
||||||
|
return x == ex && y == ey && w == ew && h == eh;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// --- DirtyRect: empty / seed / grow ------------------------------------
|
||||||
|
{
|
||||||
|
FontAtlas::DirtyRect r;
|
||||||
|
Check(r.Empty(), "fresh rect is empty");
|
||||||
|
std::uint32_t x, y, w, h;
|
||||||
|
Check(!r.UploadBox(1024, x, y, w, h), "UploadBox on empty rect returns false");
|
||||||
|
|
||||||
|
r.Add(10, 20, 4, 6);
|
||||||
|
Check(!r.Empty(), "rect is non-empty after first Add");
|
||||||
|
Check(BoxIs(r, 1024, 10, 20, 4, 6), "first Add seeds exact (origin, extent)");
|
||||||
|
|
||||||
|
// A box fully inside the current one must not change anything.
|
||||||
|
r.Add(11, 21, 1, 1);
|
||||||
|
Check(BoxIs(r, 1024, 10, 20, 4, 6), "contained Add does not shrink or move the box");
|
||||||
|
|
||||||
|
// Grow up-left and down-right; result is the union.
|
||||||
|
r.Add(5, 8, 2, 2); // extends min corner to (5,8)
|
||||||
|
r.Add(100, 200, 10, 10); // extends max corner to (110,210)
|
||||||
|
Check(BoxIs(r, 1024, 5, 8, 105, 202), "Adds grow to the half-open union");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DirtyRect: degenerate glyphs are no-ops ---------------------------
|
||||||
|
{
|
||||||
|
FontAtlas::DirtyRect r;
|
||||||
|
r.Add(3, 3, 0, 5);
|
||||||
|
r.Add(3, 3, 5, 0);
|
||||||
|
r.Add(3, 3, -1, -1);
|
||||||
|
Check(r.Empty(), "zero/negative-extent Adds leave the rect empty");
|
||||||
|
|
||||||
|
r.Add(3, 3, 5, 5);
|
||||||
|
r.Add(50, 50, 0, 0); // must not move the max corner out
|
||||||
|
Check(BoxIs(r, 1024, 3, 3, 5, 5), "degenerate Add after a real one is ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DirtyRect: clamp to atlas extent ----------------------------------
|
||||||
|
{
|
||||||
|
FontAtlas::DirtyRect r;
|
||||||
|
r.Add(-4, -4, 8, 8); // straddles the top-left corner
|
||||||
|
Check(BoxIs(r, 1024, 0, 0, 4, 4), "negative origin clamps to 0, extent trimmed");
|
||||||
|
|
||||||
|
FontAtlas::DirtyRect r2;
|
||||||
|
r2.Add(1020, 1020, 16, 16); // straddles the bottom-right corner
|
||||||
|
Check(BoxIs(r2, 1024, 1020, 1020, 4, 4), "over-extent box clamps to the atlas edge");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FontAtlas::MarkDirty keeps `dirty` and the rect in sync -----------
|
||||||
|
{
|
||||||
|
FontAtlas atlas;
|
||||||
|
Check(!atlas.dirty && atlas.dirtyRect.Empty(), "default FontAtlas is clean");
|
||||||
|
|
||||||
|
atlas.MarkDirty(0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize);
|
||||||
|
Check(atlas.dirty, "MarkDirty sets the public dirty flag");
|
||||||
|
Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize,
|
||||||
|
0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize),
|
||||||
|
"full-atlas MarkDirty covers the whole extent (the Initialize clear)");
|
||||||
|
|
||||||
|
atlas.MarkDirty(8, 8, 16, 16); // contained — full extent already dirty
|
||||||
|
Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize,
|
||||||
|
0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize),
|
||||||
|
"subsequent contained MarkDirty keeps the full extent");
|
||||||
|
|
||||||
|
// Simulate the reset Update performs after a successful upload.
|
||||||
|
atlas.dirty = false;
|
||||||
|
atlas.dirtyRect.Reset();
|
||||||
|
Check(!atlas.dirty && atlas.dirtyRect.Empty(), "post-upload reset clears dirtiness");
|
||||||
|
|
||||||
|
// After reset, a single glyph marks only its own rect.
|
||||||
|
atlas.MarkDirty(64, 128, 12, 20);
|
||||||
|
Check(atlas.dirty, "a glyph after reset re-arms dirty");
|
||||||
|
Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize, 64, 128, 12, 20),
|
||||||
|
"post-reset MarkDirty tracks just the new glyph, not the old full extent");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures != 0) {
|
||||||
|
std::println("{} check(s) failed", failures);
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::println("all checks passed");
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
126
tests/FrameLoopSync/main.cpp
Normal file
126
tests/FrameLoopSync/main.cpp
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/*
|
||||||
|
Crafter®.Graphics
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
catcrafts.net
|
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or
|
||||||
|
modify it under the terms of the GNU Lesser General Public
|
||||||
|
License version 3.0 as published by the Free Software Foundation;
|
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public
|
||||||
|
License along with this library; if not, write to the Free Software
|
||||||
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Issue #40: multi-frame-in-flight frame pacing. The renderer used to be
|
||||||
|
// effectively single-buffered — Render() ended with an unconditional
|
||||||
|
// vkQueueWaitIdle and shared a single (presentComplete, renderComplete)
|
||||||
|
// semaphore pair with zero fences for the Window's whole lifetime. This
|
||||||
|
// reworks the pacing model:
|
||||||
|
//
|
||||||
|
// - one VkFence per swapchain slot, keyed by the ACQUIRED IMAGE INDEX,
|
||||||
|
// passed to vkQueueSubmit and waited on (then reset) before that image's
|
||||||
|
// command buffer is re-recorded;
|
||||||
|
// - one semaphore pair per in-flight frame, keyed by a free-running CPU
|
||||||
|
// frame counter % numFrames (the image index isn't known until acquire
|
||||||
|
// returns), so frame N's render-complete and frame N+1's acquire can be
|
||||||
|
// pending at once;
|
||||||
|
// - the steady-state vkQueueWaitIdle is gone (kept only on resize /
|
||||||
|
// OUT_OF_DATE / teardown).
|
||||||
|
//
|
||||||
|
// The fence and semaphore changes are mutually load-bearing: dropping the
|
||||||
|
// wait-idle while reusing one binary semaphore pair across in-flight frames is
|
||||||
|
// a textbook Vulkan race, and per-frame semaphores without per-frame fences is
|
||||||
|
// a command-buffer use-after-free. So this test drives the *real* frame loop —
|
||||||
|
// a real Wayland surface, a real swapchain, real acquire/submit/present — for
|
||||||
|
// many more frames than there are in-flight slots, then asserts:
|
||||||
|
//
|
||||||
|
// - every Render() completed and the CPU frame counter advanced by the
|
||||||
|
// expected amount (no deadlock on the per-image fence wait);
|
||||||
|
// - the acquired image index rotated across more than one slot (proof the
|
||||||
|
// swapchain is genuinely multi-buffered, not pinned to image 0);
|
||||||
|
// - the Vulkan validation layer reported ZERO errors over the whole run.
|
||||||
|
// This is the load-bearing check: the old singleton-pair design only
|
||||||
|
// avoided being an active race because the wait-idle masked it. Reusing a
|
||||||
|
// binary semaphore across overlapping frames, or recording into a command
|
||||||
|
// buffer still in flight, both light up the validation layer immediately.
|
||||||
|
//
|
||||||
|
// Needs a live Wayland compositor + a Vulkan device at runtime (same as the
|
||||||
|
// windowed examples), so it shares the native build settings and is Linux-only.
|
||||||
|
|
||||||
|
#include "vulkan/vulkan.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
import Crafter.Graphics;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
Device::Initialize();
|
||||||
|
|
||||||
|
// A real, configured window against the running compositor. Construction
|
||||||
|
// performs the xdg configure handshake and creates the swapchain + the
|
||||||
|
// per-frame fences/semaphores under test.
|
||||||
|
Window window(640, 480, "FrameLoopSync test");
|
||||||
|
|
||||||
|
// Render() with no passes still exercises the full pacing path: acquire
|
||||||
|
// (signals frameSemaphores[frame].presentComplete) → per-image fence
|
||||||
|
// wait+reset → barriers → submit (signals renderComplete, fences the
|
||||||
|
// command buffer) → present. Run well past numFrames so every semaphore
|
||||||
|
// slot and every per-image fence is reused several times — exactly the
|
||||||
|
// condition the old single-pair / wait-idle design could not survive
|
||||||
|
// without draining the queue every frame.
|
||||||
|
constexpr int kFrames = 60;
|
||||||
|
static_assert(kFrames > Window::numFrames * 3,
|
||||||
|
"must loop enough to reuse each in-flight slot multiple times");
|
||||||
|
|
||||||
|
std::array<bool, Window::numFrames> imageSeen{};
|
||||||
|
for (int i = 0; i < kFrames; ++i) {
|
||||||
|
window.Render();
|
||||||
|
if (window.currentBuffer < Window::numFrames) {
|
||||||
|
imageSeen[window.currentBuffer] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain before inspecting — no steady-state wait-idle means GPU work may
|
||||||
|
// still be in flight when the loop exits.
|
||||||
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||||
|
|
||||||
|
Check(window.frameCounter == static_cast<std::uint64_t>(kFrames),
|
||||||
|
std::format("CPU frame counter advanced once per Render() ({} of {})",
|
||||||
|
window.frameCounter, kFrames));
|
||||||
|
|
||||||
|
int distinctImages = 0;
|
||||||
|
for (bool seen : imageSeen) if (seen) ++distinctImages;
|
||||||
|
Check(distinctImages > 1,
|
||||||
|
std::format("swapchain rotated across multiple images ({} of {} slots used)",
|
||||||
|
distinctImages, Window::numFrames));
|
||||||
|
|
||||||
|
Check(Device::validationErrorCount == 0,
|
||||||
|
std::format("no Vulkan validation errors across {} frames ({} seen)",
|
||||||
|
kFrames, Device::validationErrorCount));
|
||||||
|
|
||||||
|
if (failures != 0) {
|
||||||
|
std::println("{} check(s) failed", failures);
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
std::println("all checks passed");
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
BIN
tests/ShapeTextCache/font.ttf
Normal file
BIN
tests/ShapeTextCache/font.ttf
Normal file
Binary file not shown.
231
tests/ShapeTextCache/main.cpp
Normal file
231
tests/ShapeTextCache/main.cpp
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
/*
|
||||||
|
Crafter®.Graphics
|
||||||
|
Copyright (C) 2026 Catcrafts®
|
||||||
|
catcrafts.net
|
||||||
|
|
||||||
|
This library is free software; you can redistribute it and/or
|
||||||
|
modify it under the terms of the GNU Lesser General Public
|
||||||
|
License version 3.0 as published by the Free Software Foundation;
|
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Lesser General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Lesser General Public
|
||||||
|
License along with this library; if not, write to the Free Software
|
||||||
|
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Issue #52: shaped-run cache for UIRenderer::ShapeText. The slow path
|
||||||
|
// (UTF-8 decode + per-glyph atlas lookup + layout) is memoized into an
|
||||||
|
// origin-relative run keyed by (Font*, pxSize, color, utf8); a hit only
|
||||||
|
// translates the cached run into the output buffer. This must be exactly
|
||||||
|
// byte-equivalent to the uncached path, since the glyph buffer is rewritten
|
||||||
|
// and re-flushed every frame regardless.
|
||||||
|
//
|
||||||
|
// What is asserted:
|
||||||
|
// - A cache HIT produces byte-identical glyphs to the original (MISS) call.
|
||||||
|
// - The HIT path does NOT touch the atlas (no Ensure → atlas stays clean),
|
||||||
|
// while the MISS path rasterises new glyphs and dirties it.
|
||||||
|
// - Translating the same string to a different (x, baselineY) shifts every
|
||||||
|
// glyph by exactly that delta (cache stores origin-relative geometry).
|
||||||
|
// - Center / Right alignment fold the advance-based shift into ShapeText
|
||||||
|
// (Center = -advance/2, Right = -advance), matching the old two-pass code.
|
||||||
|
// - outCapacity truncates the written count but still reports full advance.
|
||||||
|
// - InvalidateFont drops the run; a re-shape afterwards is still correct.
|
||||||
|
// - Distinct color / pxSize / font are distinct cache entries.
|
||||||
|
//
|
||||||
|
// Needs a headless Vulkan device (the FontAtlas image is a real GPU image),
|
||||||
|
// but no swapchain/window — ShapeText only touches the CPU-side atlas. The
|
||||||
|
// UIRenderer is used without Initialize(): ShapeText reads only fontAtlas.
|
||||||
|
|
||||||
|
#include "vulkan/vulkan.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
import Crafter.Graphics;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
VkCommandBuffer BeginCmd() {
|
||||||
|
VkCommandBufferAllocateInfo allocInfo {
|
||||||
|
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
|
||||||
|
.commandPool = Device::commandPool,
|
||||||
|
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
|
||||||
|
.commandBufferCount = 1,
|
||||||
|
};
|
||||||
|
VkCommandBuffer cmd = VK_NULL_HANDLE;
|
||||||
|
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd));
|
||||||
|
VkCommandBufferBeginInfo beginInfo {
|
||||||
|
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||||
|
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
|
||||||
|
};
|
||||||
|
Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo));
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SubmitWait(VkCommandBuffer cmd) {
|
||||||
|
Device::CheckVkResult(vkEndCommandBuffer(cmd));
|
||||||
|
VkSubmitInfo submitInfo {
|
||||||
|
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
|
||||||
|
.commandBufferCount = 1,
|
||||||
|
.pCommandBuffers = &cmd,
|
||||||
|
};
|
||||||
|
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
|
||||||
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||||
|
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GlyphEq(const GlyphItem& a, const GlyphItem& b) {
|
||||||
|
return a.x == b.x && a.y == b.y && a.w == b.w && a.h == b.h
|
||||||
|
&& a.u0 == b.u0 && a.v0 == b.v0 && a.u1 == b.u1 && a.v1 == b.v1
|
||||||
|
&& a.r == b.r && a.g == b.g && a.b == b.b && a.a == b.a;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RunsEqual(std::span<const GlyphItem> a, std::span<const GlyphItem> b) {
|
||||||
|
if (a.size() != b.size()) return false;
|
||||||
|
for (std::size_t i = 0; i < a.size(); ++i)
|
||||||
|
if (!GlyphEq(a[i], b[i])) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path FindFont() {
|
||||||
|
// The runner's cwd is not guaranteed: cfg.files copies the font next to
|
||||||
|
// the binary, but `crafter-build test` may also run from the project root.
|
||||||
|
for (const char* cand : {
|
||||||
|
"font.ttf",
|
||||||
|
"tests/ShapeTextCache/font.ttf",
|
||||||
|
"../../examples/HelloUI/font.ttf" }) {
|
||||||
|
if (std::filesystem::exists(cand)) return cand;
|
||||||
|
}
|
||||||
|
return "font.ttf";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
Device::Initialize();
|
||||||
|
|
||||||
|
FontAtlas atlas;
|
||||||
|
{
|
||||||
|
VkCommandBuffer cmd = BeginCmd();
|
||||||
|
atlas.Initialize(cmd);
|
||||||
|
SubmitWait(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
Font font(FindFont());
|
||||||
|
|
||||||
|
UIRenderer ui;
|
||||||
|
ui.fontAtlas = &atlas; // ShapeText needs only the atlas — no Initialize.
|
||||||
|
|
||||||
|
const std::array<float, 4> white{1, 1, 1, 1};
|
||||||
|
constexpr float kSize = 18.0f;
|
||||||
|
const std::string_view kText = "Hover me";
|
||||||
|
|
||||||
|
std::array<GlyphItem, 64> bufMiss{};
|
||||||
|
std::array<GlyphItem, 64> bufHit{};
|
||||||
|
|
||||||
|
// ── 1. First call is a MISS: it rasterises glyphs → atlas goes dirty. ──
|
||||||
|
atlas.dirty = false; // pretend a prior Update() flushed the atlas clean
|
||||||
|
float advMiss = 0.0f;
|
||||||
|
std::uint32_t nMiss = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
|
||||||
|
bufMiss.data(), bufMiss.size(), &advMiss);
|
||||||
|
Check(nMiss > 0, "MISS produced glyphs for a non-empty string");
|
||||||
|
Check(advMiss > 0.0f, "MISS reported a positive advance");
|
||||||
|
Check(atlas.dirty, "MISS rasterised new glyphs and dirtied the atlas");
|
||||||
|
|
||||||
|
// ── 2. Second call is a HIT: byte-identical, and atlas stays clean. ────
|
||||||
|
atlas.dirty = false;
|
||||||
|
float advHit = 0.0f;
|
||||||
|
std::uint32_t nHit = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
|
||||||
|
bufHit.data(), bufHit.size(), &advHit);
|
||||||
|
Check(nHit == nMiss, "HIT wrote the same glyph count as MISS");
|
||||||
|
Check(advHit == advMiss, "HIT reported the same advance as MISS");
|
||||||
|
Check(RunsEqual({bufMiss.data(), nMiss}, {bufHit.data(), nHit}),
|
||||||
|
"HIT is byte-identical to MISS");
|
||||||
|
Check(!atlas.dirty, "HIT did not touch the atlas (no re-rasterise)");
|
||||||
|
|
||||||
|
// ── 3. Translation: a different origin shifts every glyph by the delta. ─
|
||||||
|
std::array<GlyphItem, 64> bufMoved{};
|
||||||
|
const float dx = 37.0f, dy = -15.0f;
|
||||||
|
std::uint32_t nMoved = ui.ShapeText(font, kSize, 100.0f + dx, 200.0f + dy,
|
||||||
|
kText, white, bufMoved.data(),
|
||||||
|
bufMoved.size(), nullptr);
|
||||||
|
bool shifted = (nMoved == nHit);
|
||||||
|
for (std::uint32_t i = 0; i < nMoved && shifted; ++i) {
|
||||||
|
shifted = std::abs((bufMoved[i].x - bufHit[i].x) - dx) < 1e-3f
|
||||||
|
&& std::abs((bufMoved[i].y - bufHit[i].y) - dy) < 1e-3f
|
||||||
|
&& bufMoved[i].w == bufHit[i].w && bufMoved[i].h == bufHit[i].h;
|
||||||
|
}
|
||||||
|
Check(shifted, "translate-only move shifts every glyph by exactly (dx, dy)");
|
||||||
|
|
||||||
|
// ── 4. Alignment folded into ShapeText (Center = -adv/2, Right = -adv). ─
|
||||||
|
std::array<GlyphItem, 64> bufCenter{};
|
||||||
|
std::array<GlyphItem, 64> bufRight{};
|
||||||
|
float advCenter = 0.0f, advRight = 0.0f;
|
||||||
|
std::uint32_t nCenter = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
|
||||||
|
bufCenter.data(), bufCenter.size(),
|
||||||
|
&advCenter, TextAlign::Center);
|
||||||
|
std::uint32_t nRight = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
|
||||||
|
bufRight.data(), bufRight.size(),
|
||||||
|
&advRight, TextAlign::Right);
|
||||||
|
bool centerOk = (nCenter == nHit) && (advCenter == advHit);
|
||||||
|
for (std::uint32_t i = 0; i < nCenter && centerOk; ++i)
|
||||||
|
centerOk = std::abs((bufCenter[i].x - bufHit[i].x) - (-advHit * 0.5f)) < 1e-3f;
|
||||||
|
Check(centerOk, "Center alignment shifts the run left by advance/2");
|
||||||
|
|
||||||
|
bool rightOk = (nRight == nHit) && (advRight == advHit);
|
||||||
|
for (std::uint32_t i = 0; i < nRight && rightOk; ++i)
|
||||||
|
rightOk = std::abs((bufRight[i].x - bufHit[i].x) - (-advHit)) < 1e-3f;
|
||||||
|
Check(rightOk, "Right alignment shifts the run left by full advance");
|
||||||
|
|
||||||
|
// ── 5. outCapacity truncates count but still reports full advance. ─────
|
||||||
|
std::array<GlyphItem, 64> bufTrunc{};
|
||||||
|
const std::uint32_t capLimit = (nHit > 1) ? (nHit - 1) : 0;
|
||||||
|
float advTrunc = 0.0f;
|
||||||
|
std::uint32_t nTrunc = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
|
||||||
|
bufTrunc.data(), capLimit, &advTrunc);
|
||||||
|
Check(nTrunc == capLimit, "writing is capped at outCapacity");
|
||||||
|
Check(advTrunc == advHit, "advance is full even when the buffer truncates");
|
||||||
|
|
||||||
|
// ── 6. InvalidateFont drops the run; a re-shape is still correct. ──────
|
||||||
|
ui.InvalidateFont(font);
|
||||||
|
std::array<GlyphItem, 64> bufReshape{};
|
||||||
|
float advReshape = 0.0f;
|
||||||
|
std::uint32_t nReshape = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText, white,
|
||||||
|
bufReshape.data(), bufReshape.size(),
|
||||||
|
&advReshape);
|
||||||
|
Check(nReshape == nHit && advReshape == advHit
|
||||||
|
&& RunsEqual({bufReshape.data(), nReshape}, {bufHit.data(), nHit}),
|
||||||
|
"re-shape after InvalidateFont matches the original output");
|
||||||
|
|
||||||
|
// ── 7. Distinct color and pxSize are distinct entries (different runs). ─
|
||||||
|
std::array<GlyphItem, 64> bufRed{};
|
||||||
|
std::uint32_t nRed = ui.ShapeText(font, kSize, 100.0f, 200.0f, kText,
|
||||||
|
{1, 0, 0, 1}, bufRed.data(), bufRed.size(),
|
||||||
|
nullptr);
|
||||||
|
bool colorDistinct = (nRed == nHit);
|
||||||
|
for (std::uint32_t i = 0; i < nRed && colorDistinct; ++i)
|
||||||
|
colorDistinct = bufRed[i].r == 1.0f && bufRed[i].g == 0.0f && bufRed[i].b == 0.0f;
|
||||||
|
Check(colorDistinct, "a different color yields a correctly-colored run");
|
||||||
|
|
||||||
|
std::array<GlyphItem, 64> bufBig{};
|
||||||
|
float advBig = 0.0f;
|
||||||
|
ui.ShapeText(font, kSize * 2.0f, 100.0f, 200.0f, kText, white,
|
||||||
|
bufBig.data(), bufBig.size(), &advBig);
|
||||||
|
Check(advBig > advHit * 1.5f, "a larger pxSize produces a wider advance");
|
||||||
|
|
||||||
|
if (failures == 0) std::println("\nAll ShapeText cache checks passed.");
|
||||||
|
else std::println("\n{} ShapeText cache check(s) FAILED.", failures);
|
||||||
|
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue