diff --git a/implementations/Crafter.Graphics-FontAtlas.cpp b/implementations/Crafter.Graphics-FontAtlas.cpp index 44179c0..c3479a7 100644 --- a/implementations/Crafter.Graphics-FontAtlas.cpp +++ b/implementations/Crafter.Graphics-FontAtlas.cpp @@ -39,13 +39,17 @@ void FontAtlas::Initialize(GraphicsCommandBuffer cmd) { VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL ); std::memset(image.buffer.value, 0, kAtlasSize * kAtlasSize); - dirty = true; #else (void)cmd; staging.assign(kAtlasSize * kAtlasSize, 0); textureHandle = WebGPU::wgpuCreateAtlasTexture(kAtlasSize, kAtlasSize); - dirty = true; #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) { @@ -70,8 +74,12 @@ bool FontAtlas::ShelfPlace(int w, int h, int& outX, int& outY) { } 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 (cache_.contains(key)) return true; + if (auto it = cache_.find(key); it != cache_.end()) return &it->second; 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; if (!ShelfPlace(sw, sh, px, py)) { stbtt_FreeSDF(sdf, nullptr); - return false; + return nullptr; } std::uint8_t* dst = PixelPtr(); for (int row = 0; row < sh; ++row) { @@ -112,11 +120,10 @@ bool FontAtlas::Ensure(Font& font, std::uint32_t codepoint) { g.v0 = static_cast(py) / kAtlasSize; g.u1 = static_cast(px + sw) / kAtlasSize; g.v1 = static_cast(py + sh) / kAtlasSize; - dirty = true; + MarkDirty(px, py, sw, sh); } - cache_.emplace(key, g); - return true; + return &cache_.emplace(key, g).first->second; } 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) { 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 - 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 (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( textureHandle, staging.data(), kAtlasSize, kAtlasSize, kAtlasSize, - 0, 0, kAtlasSize, kAtlasSize + static_cast(x), static_cast(y), + static_cast(w), static_cast(h) ); #endif dirty = false; + dirtyRect.Reset(); } diff --git a/implementations/Crafter.Graphics-UI-Shared.cpp b/implementations/Crafter.Graphics-UI-Shared.cpp index 435f728..6ac2c62 100644 --- a/implementations/Crafter.Graphics-UI-Shared.cpp +++ b/implementations/Crafter.Graphics-UI-Shared.cpp @@ -48,39 +48,78 @@ std::uint32_t UIRenderer::ShapeText(Font& font, float pxSize, std::string_view utf8, std::array color, GlyphItem* out, std::uint32_t outCapacity, - float* outAdvance) { + float* outAdvance, + TextAlign align) { if (fontAtlas == nullptr) { std::println("UIRenderer::ShapeText: no FontAtlas (set fontAtlas before Initialize)"); std::abort(); } - const float scale = pxSize / FontAtlas::kBaseSize; - float cursor = x; - std::uint32_t written = 0; + // Look up (or build) the origin-relative shaped run. It is stored with + // the pen starting at x=0, baseline=0 so a cache hit only needs the + // 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; - while (i < utf8.size() && written < outCapacity) { - std::uint32_t cp = DecodeUtf8(utf8, i); - if (cp == 0) break; - if (cp == '\n') { continue; } + const Glyph* g = fontAtlas->EnsureAndGet(font, cp); + if (g == nullptr) continue; - fontAtlas->Ensure(font, cp); - const Glyph* g = fontAtlas->Lookup(font, cp); - if (g == nullptr) continue; - - if (g->w > 0 && g->h > 0) { - GlyphItem& gi = out[written++]; - gi.x = cursor + g->xoff * scale; - gi.y = baselineY + g->yoff * scale; - gi.w = g->w * scale; - gi.h = g->h * scale; - gi.u0 = g->u0; gi.v0 = g->v0; - gi.u1 = g->u1; gi.v1 = g->v1; - gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3]; + if (g->w > 0 && g->h > 0) { + GlyphItem gi; + gi.x = cursor + g->xoff * scale; + gi.y = g->yoff * scale; + gi.w = g->w * scale; + gi.h = g->h * scale; + gi.u0 = g->u0; gi.v0 = g->v0; + gi.u1 = g->u1; gi.v1 = g->v1; + gi.r = color[0]; gi.g = color[1]; gi.b = color[2]; gi.a = color[3]; + run.glyphs.push_back(gi); + } + cursor += g->advance * scale; } - 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; } + +void UIRenderer::InvalidateFont(const Font& font) noexcept { + std::erase_if(shapedRuns_, [&font](const auto& kv) { + return kv.first.font == &font; + }); +} diff --git a/implementations/Crafter.Graphics-UIComponents.cpp b/implementations/Crafter.Graphics-UIComponents.cpp index f4d240d..450737d 100644 --- a/implementations/Crafter.Graphics-UIComponents.cpp +++ b/implementations/Crafter.Graphics-UIComponents.cpp @@ -58,17 +58,12 @@ namespace { GlyphItem* writePos = buf.glyphs + before; float baseline = centerY + fontSize * 0.32f; float advance = 0.0f; + // ShapeText folds the -advance/2 centering shift into its own pass. std::uint32_t n = buf.renderer->ShapeText( font, fontSize, centerX, baseline, - label, color, writePos, cap, &advance + label, color, writePos, cap, &advance, TextAlign::Center ); *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; } } @@ -202,16 +197,12 @@ float Crafter::DrawText(UIBuffer& buf, std::string_view text, GlyphItem* writePos = buf.glyphs + before; float advance = 0.0f; + // Alignment is applied inside ShapeText (folded into its single pass). std::uint32_t n = buf.renderer->ShapeText( font, fontSize, x, baselineY, - text, color, writePos, cap, &advance + text, color, writePos, cap, &advance, align ); *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; } diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index 09d64f3..5138839 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -405,6 +405,26 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height colorFormat = selectedFormat.format; 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(); VkCommandBufferAllocateInfo cmdBufAllocateInfo {}; @@ -414,27 +434,41 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height cmdBufAllocateInfo.commandBufferCount = numFrames; 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 {}; semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; - Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.presentComplete)); - Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.renderComplete)); - - // Set up submit info structure - // Semaphores will stay the same during application lifetime - // Command buffer submission info is set by each example - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - submitInfo.pWaitDstStageMask = &submitPipelineStages; - submitInfo.waitSemaphoreCount = 1; - submitInfo.pWaitSemaphores = &semaphores.presentComplete; - submitInfo.signalSemaphoreCount = 1; - submitInfo.pSignalSemaphores = &semaphores.renderComplete; - submitInfo.pNext = VK_NULL_HANDLE; + VkFenceCreateInfo fenceCreateInfo {}; + fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + fenceCreateInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; + for (std::uint8_t i = 0; i < numFrames; i++) { + Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &renderFinishedSemaphores[i])); + Device::CheckVkResult(vkCreateFence(Device::device, &fenceCreateInfo, nullptr, &waitFences[i])); + } + for (std::uint8_t i = 0; i < numAcquireSemaphores; i++) { + Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &imageAcquiredSemaphores[i])); + } // 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.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 { .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .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 - // (currentBuffer each frame, swapChain across recreation) on their own. - // The render-complete wait semaphore is fixed for the window's lifetime. + // (currentBuffer each frame, swapChain across recreation) on their own. The + // wait semaphore is the per-image render-finished semaphore, patched per + // frame in Render() (it follows currentBuffer). presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.pNext = NULL; @@ -480,7 +515,6 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height presentInfo.pSwapchains = &swapChain; presentInfo.pImageIndices = ¤tBuffer; presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = &semaphores.renderComplete; lastMousePos = {0,0}; mouseDelta = {0,0}; @@ -724,22 +758,33 @@ void Window::Render() { // Acquire the next image from the swap chain. If the surface has // changed size out from under us (compositor/Win32 resize delivered // 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(frameCounter % numAcquireSemaphores); + VkSemaphore imageAcquired = imageAcquiredSemaphores[acquireSlot]; { 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) { Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); RecreateSwapchainAndImages(); onResize.Invoke(); acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, - semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer); + imageAcquired, (VkFence)nullptr, ¤tBuffer); } if (acquire != VK_SUBOPTIMAL_KHR) { 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)); @@ -834,20 +879,38 @@ void Window::Render() { 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); if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) { - // Surface size changed mid-present. Drain the queue, rebuild the - // swapchain, and let dependents (descriptors holding old image - // handles) re-bind via onResize before the next frame. + // Surface size changed mid-present. Drain the queue (the only place we + // still wait-idle, alongside Resize() and the acquire OUT_OF_DATE + // 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)); RecreateSwapchainAndImages(); onResize.Invoke(); } else { 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 @@ -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 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 VkSurfaceTransformFlagsKHR preTransform; if (surfCaps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR) @@ -939,22 +990,6 @@ void Window::CreateSwapchain() 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 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 = {}; swapchainCI.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; swapchainCI.surface = vulkanSurface; @@ -967,7 +1002,9 @@ void Window::CreateSwapchain() swapchainCI.imageArrayLayers = 1; swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; 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 swapchainCI.oldSwapchain = oldSwapchain; // Setting clipped to VK_TRUE allows the implementation to discard rendering outside of the surface area diff --git a/interfaces/Crafter.Graphics-FontAtlas.cppm b/interfaces/Crafter.Graphics-FontAtlas.cppm index f80e9c6..105f011 100644 --- a/interfaces/Crafter.Graphics-FontAtlas.cppm +++ b/interfaces/Crafter.Graphics-FontAtlas.cppm @@ -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(x0); + y = static_cast(y0); + w = static_cast(x1 - x0); + h = static_cast(y1 - y0); + return true; + } + }; + #ifndef CRAFTER_GRAPHICS_WINDOW_DOM ImageVulkan image; #else WebGPUTextureRef textureHandle = 0; std::vector 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); @@ -66,9 +115,21 @@ export namespace Crafter { std::uint8_t* PixelPtr() noexcept; 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; 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 shelves_; diff --git a/interfaces/Crafter.Graphics-ImageVulkan.cppm b/interfaces/Crafter.Graphics-ImageVulkan.cppm index 46d8539..7d6a12b 100644 --- a/interfaces/Crafter.Graphics-ImageVulkan.cppm +++ b/interfaces/Crafter.Graphics-ImageVulkan.cppm @@ -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(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(x), static_cast(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 diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index dead5c7..b5760f9 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -90,6 +90,12 @@ export namespace Crafter { }; 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 ─────────────────────────────────────── struct Rect { float x = 0, y = 0, w = 0, h = 0; @@ -206,12 +212,30 @@ export namespace Crafter { #endif 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, float x, float baselineY, std::string_view utf8, std::array color, 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 FontAtlasSamplerSlot() const noexcept { return fontAtlasSamplerSlot_; } @@ -222,6 +246,41 @@ export namespace Crafter { Window* window_ = 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 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{}(k.font); + auto mix = [&h](std::size_t v) noexcept { + h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); + }; + mix(std::hash{}(std::bit_cast(k.pxSize))); + for (float c : k.color) + mix(std::hash{}(std::bit_cast(c))); + mix(std::hash{}(k.text)); + return h; + } + }; + struct ShapedRun { + std::vector 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 shapedRuns_; + ImageSlot outImageSlot_; ImageSlot fontAtlasImageSlot_; SamplerSlot fontAtlasSamplerSlot_; diff --git a/interfaces/Crafter.Graphics-UIComponents.cppm b/interfaces/Crafter.Graphics-UIComponents.cppm index 42de1ec..ec8c346 100644 --- a/interfaces/Crafter.Graphics-UIComponents.cppm +++ b/interfaces/Crafter.Graphics-UIComponents.cppm @@ -96,8 +96,7 @@ export namespace Crafter { float cornerRadius = 0; }; - // Where the X coordinate sits relative to the emitted glyph run. - enum class TextAlign : std::uint8_t { Left, Center, Right }; + // TextAlign lives in the :UI partition (used by UIRenderer::ShapeText). // ─── component functions ─────────────────────────────────────────── diff --git a/interfaces/Crafter.Graphics-Window.cppm b/interfaces/Crafter.Graphics-Window.cppm index 2b83604..0676b96 100644 --- a/interfaces/Crafter.Graphics-Window.cppm +++ b/interfaces/Crafter.Graphics-Window.cppm @@ -253,6 +253,11 @@ export namespace Crafter { VkSwapchainKHR swapChain = VK_NULL_HANDLE; VkFormat colorFormat; 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]; VkImageViewCreateInfo imageViews[numFrames]; // Tracks whether each swapchain image has been rendered (and thus @@ -265,18 +270,59 @@ export namespace Crafter { std::array imageInitialised{}; std::thread thread; VkCommandBuffer drawCmdBuffers[numFrames]; - VkSubmitInfo submitInfo; // Per-frame Vulkan info structs whose contents are almost entirely // invariant. Initialised once in the constructor; Render() patches only // the few fields that change each frame (the two barriers' image and - // the acquire barrier's oldLayout). presentInfo.pImageIndices and - // pSwapchains point at the currentBuffer/swapChain members, so they - // track value changes (including swapchain recreation) automatically. + // the acquire barrier's oldLayout, and — for the multi-frame-in-flight + // sync below — submitInfo/presentInfo's wait/signal semaphores plus + // 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; VkImageMemoryBarrier acquireBarrier; VkImageMemoryBarrier presentBarrier; 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 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 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 imageAcquiredSemaphores{}; + + // Free-running count of Render() calls; drives the acquire-semaphore + // slot (frameCounter % numAcquireSemaphores). + std::uint64_t frameCounter = 0; std::uint32_t currentBuffer = 0; VkPipelineStageFlags submitPipelineStages = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; std::vector passes; diff --git a/project.cpp b/project.cpp index 7d45dd3..ad17e7f 100644 --- a/project.cpp +++ b/project.cpp @@ -264,6 +264,37 @@ extern "C" Configuration CrafterBuildProject(std::span a scrollImpls.emplace_back("tests/MouseScroll/main"); sc.GetInterfacesAndImplementations(ifaces, scrollImpls); 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 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 @@ -295,6 +326,64 @@ extern "C" Configuration CrafterBuildProject(std::span 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 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 textImpls(impls.begin(), impls.end()); + textImpls.emplace_back("tests/ShapeTextCache/main"); + xc.GetInterfacesAndImplementations(ifaces, textImpls); + cfg.tests.push_back(std::move(textTest)); } return cfg; diff --git a/shaders/ui-text.comp.glsl b/shaders/ui-text.comp.glsl index 354a6d0..a64abbf 100644 --- a/shaders/ui-text.comp.glsl +++ b/shaders/ui-text.comp.glsl @@ -76,9 +76,12 @@ void main() { vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw; 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( - sampler2D(uiTextures[nonuniformEXT(pc.fontTextureSlot)], - uiSamplers[nonuniformEXT(pc.fontSamplerSlot)]), + sampler2D(uiTextures[pc.fontTextureSlot], + uiSamplers[pc.fontSamplerSlot]), uv ).r; diff --git a/tests/FontAtlasDirtyRect/main.cpp b/tests/FontAtlasDirtyRect/main.cpp new file mode 100644 index 0000000..c136bbe --- /dev/null +++ b/tests/FontAtlasDirtyRect/main.cpp @@ -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 + +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; +} diff --git a/tests/FrameLoopSync/main.cpp b/tests/FrameLoopSync/main.cpp new file mode 100644 index 0000000..6a1a78c --- /dev/null +++ b/tests/FrameLoopSync/main.cpp @@ -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 + +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 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(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; +} diff --git a/tests/ShapeTextCache/font.ttf b/tests/ShapeTextCache/font.ttf new file mode 100644 index 0000000..f27f4ff Binary files /dev/null and b/tests/ShapeTextCache/font.ttf differ diff --git a/tests/ShapeTextCache/main.cpp b/tests/ShapeTextCache/main.cpp new file mode 100644 index 0000000..3ef990b --- /dev/null +++ b/tests/ShapeTextCache/main.cpp @@ -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 + +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 a, std::span 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 white{1, 1, 1, 1}; + constexpr float kSize = 18.0f; + const std::string_view kText = "Hover me"; + + std::array bufMiss{}; + std::array 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 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 bufCenter{}; + std::array 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 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 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 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 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; +}