Crafter.Graphics/implementations/Crafter.Graphics-Window.cpp

1747 lines
72 KiB
C++
Raw Normal View History

2025-11-25 02:21:06 +01:00
/*
Crafter®.Graphics
2026-03-09 20:10:19 +01:00
Copyright (C) 2026 Catcrafts®
2025-11-25 02:21:06 +01:00
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
*/
2026-03-09 20:10:19 +01:00
module;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <linux/input-event-codes.h>
#include "../lib/xdg-shell-client-protocol.h"
#include "../lib/wayland-xdg-decoration-unstable-v1-client-protocol.h"
#include "../lib/fractional-scale-v1.h"
#include "../lib/viewporter.h"
#include <string.h>
#include <cassert>
#include <linux/input.h>
#include <sys/mman.h>
#include <wayland-cursor.h>
#include <xkbcommon/xkbcommon.h>
#include <errno.h>
#include <fcntl.h>
#include <print>
#include <wayland-client.h>
#include <wayland-client-protocol.h>
#include <sys/stat.h>
#include <time.h>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#include <windows.h>
#include <cassert>
#endif
2026-05-18 02:07:48 +02:00
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
2026-03-09 20:10:19 +01:00
#include "vulkan/vulkan.h"
2026-05-18 02:07:48 +02:00
#endif
2026-03-09 20:10:19 +01:00
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
#include "vulkan/vulkan_wayland.h"
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#include "vulkan/vulkan_win32.h"
#endif
2026-05-18 02:07:48 +02:00
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
2026-05-01 23:35:37 +02:00
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "../lib/stb_image_write.h"
2026-05-18 02:07:48 +02:00
#endif
2025-11-25 02:21:06 +01:00
module Crafter.Graphics:Window_impl;
import :Window;
2026-03-09 20:10:19 +01:00
import :Device;
2026-05-12 00:24:48 +02:00
import :Gamepad;
2026-05-18 02:07:48 +02:00
// The Vulkan-typed partitions exist as empty stubs in DOM builds (the
// build system scans `import :X` statements pre-preprocessor, so even
// guarded imports must resolve to a real partition). Their bodies are
// gated under !CRAFTER_GRAPHICS_WINDOW_DOM so DOM compiles see empty
// modules. Cheap.
2026-03-09 20:10:19 +01:00
import :VulkanTransition;
2026-04-05 22:53:59 +02:00
import :DescriptorHeapVulkan;
2026-05-01 23:35:37 +02:00
import :RenderPass;
2026-05-18 04:58:52 +02:00
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
import :WebGPU;
import :DescriptorHeapWebGPU;
#endif
2025-11-25 02:21:06 +01:00
import std;
using namespace Crafter;
2026-05-18 02:07:48 +02:00
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
2026-03-09 20:10:19 +01:00
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
void randname(char *buf) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
long r = ts.tv_nsec;
for (int i = 0; i < 6; ++i) {
buf[i] = 'A'+(r&15)+(r&16)*2;
r >>= 5;
}
2025-11-25 18:52:32 +01:00
}
2026-03-09 20:10:19 +01:00
int anonymous_shm_open(void) {
char name[] = "/hello-wayland-XXXXXX";
int retries = 100;
do {
randname(name + strlen(name) - 6);
--retries;
// shm_open guarantees that O_CLOEXEC is set
int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd >= 0) {
shm_unlink(name);
return fd;
}
} while (retries > 0 && errno == EEXIST);
return -1;
}
int create_shm_file(off_t size) {
int fd = anonymous_shm_open();
if (fd < 0) {
return fd;
}
if (ftruncate(fd, size) < 0) {
close(fd);
return -1;
}
return fd;
}
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
2026-05-12 00:24:48 +02:00
// Extract the layout-independent raw key code from a WM_KEY* lParam. Bits
// 16-23 hold the PS/2 set-1 scancode byte; bit 24 is the extended-key flag
// (the 0xE0-prefixed variants — RightCtrl, RightAlt, the cursor cluster,
// keypad Enter/Slash, the Windows keys). We pack the extended flag into bit
// 8 of the returned KeyCode so it round-trips with the compile-time
// `Key(CrafterKeys::...)` table in :Keys.
static inline KeyCode KeyCodeFromLParam(LPARAM lParam) {
return ((KeyCode)((lParam >> 16) & 0xFF))
| (((lParam >> 24) & 1u) << 8);
2026-03-09 20:10:19 +01:00
}
// Define a window class name
const char g_szClassName[] = "myWindowClass";
// Window procedure function that processes messages
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
Window* window = nullptr;
if (msg == WM_NCCREATE)
{
CREATESTRUCT* pCreate = reinterpret_cast<CREATESTRUCT*>(lParam);
window = static_cast<Window*>(pCreate->lpCreateParams);
SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
return TRUE;
}
else
{
window = reinterpret_cast<Window*>(
GetWindowLongPtr(hwnd, GWLP_USERDATA)
);
2025-12-29 18:56:06 +01:00
}
2026-03-09 20:10:19 +01:00
switch (msg) {
case WM_DESTROY:{
PostQuitMessage(0);
break;
}
2026-05-12 00:24:48 +02:00
case WM_SIZE: {
// SIZE_MINIMIZED reports (0, 0) — Resize() short-circuits, so
// we just propagate the values directly. WM_SIZE fires
// synchronously during a drag-resize loop; the StartSync
// pump runs WndProc between frames, so the swapchain is
// never touched mid-Render.
if (window) {
window->Resize(LOWORD(lParam), HIWORD(lParam));
}
break;
}
2026-04-16 23:03:24 +02:00
case WM_KEYDOWN:
case WM_SYSKEYDOWN: { // SYSKEYDOWN catches Alt combos, F10, etc.
2026-05-12 00:24:48 +02:00
KeyCode code = KeyCodeFromLParam(lParam);
2026-04-16 23:03:24 +02:00
bool isRepeat = (lParam & (1 << 30)) != 0;
if (isRepeat) {
2026-05-12 00:24:48 +02:00
window->onRawKeyHold.Invoke(code);
2026-04-16 23:03:24 +02:00
} else {
2026-05-12 00:24:48 +02:00
window->heldKeys.insert(code);
window->onRawKeyDown.Invoke(code);
2026-03-09 20:10:19 +01:00
}
break;
}
2026-04-16 23:03:24 +02:00
case WM_KEYUP:
case WM_SYSKEYUP: {
2026-05-12 00:24:48 +02:00
KeyCode code = KeyCodeFromLParam(lParam);
window->heldKeys.erase(code);
window->onRawKeyUp.Invoke(code);
2026-03-09 20:10:19 +01:00
break;
}
2026-04-16 23:03:24 +02:00
case WM_CHAR: {
// wParam is a UTF-16 code unit. May be a surrogate — buffer until we have a pair.
wchar_t wc = (wchar_t)wParam;
// Filter control characters (backspace=0x08, tab=0x09, enter=0x0D, escape=0x1B, etc.)
if (wc < 0x20 || wc == 0x7f) break;
// Handle UTF-16 surrogate pairs (characters outside the BMP, e.g. emoji).
static wchar_t highSurrogate = 0;
wchar_t utf16[2];
int utf16Len;
if (wc >= 0xD800 && wc <= 0xDBFF) {
// High surrogate — stash it and wait for the low surrogate.
highSurrogate = wc;
break;
} else if (wc >= 0xDC00 && wc <= 0xDFFF) {
// Low surrogate — pair with the stashed high surrogate.
if (highSurrogate == 0) break; // orphaned low surrogate, ignore
utf16[0] = highSurrogate;
utf16[1] = wc;
utf16Len = 2;
highSurrogate = 0;
} else {
utf16[0] = wc;
utf16Len = 1;
}
// Convert UTF-16 to UTF-8.
char utf8[8];
int n = WideCharToMultiByte(CP_UTF8, 0, utf16, utf16Len, utf8, sizeof(utf8), nullptr, nullptr);
if (n > 0) {
window->onTextInput.Invoke(std::string(utf8, n));
}
2026-03-09 20:10:19 +01:00
break;
}
case WM_LBUTTONDOWN: {
window->mouseLeftHeld = true;
window->onMouseLeftClick.Invoke();
break;
}
case WM_LBUTTONUP: {
window->mouseLeftHeld = false;
window->onMouseLeftRelease.Invoke();
break;
}
case WM_RBUTTONDOWN: {
window->mouseRightHeld = true;
window->onMouseRightClick.Invoke();
break;
}
case WM_RBUTTONUP: {
window->mouseRightHeld = false;
window->onMouseRightRelease.Invoke();
break;
}
case WM_MOUSEWHEEL: {
// High word of wParam is the signed wheel movement, WHEEL_DELTA
// (120) per detent, positive = wheel away from the user (scroll
// up). Window::onMouseScroll speaks whole detents with +1 =
// wheel down (the DOM deltaY / Wayland axis sign), so negate.
// Free-spinning wheels report sub-WHEEL_DELTA steps — accumulate
// so they add up instead of each truncating to zero.
static int wheelRemainder = 0;
wheelRemainder += -static_cast<int>(GET_WHEEL_DELTA_WPARAM(wParam));
int detents = wheelRemainder / WHEEL_DELTA; // trunc toward zero
if (detents != 0) {
wheelRemainder -= detents * WHEEL_DELTA;
// Event payload is uint32 — preserve sign via two's complement.
window->onMouseScroll.Invoke(
static_cast<std::uint32_t>(static_cast<std::int32_t>(detents)));
}
break;
}
2026-04-02 16:52:10 +02:00
case WM_SETCURSOR: {
if (LOWORD(lParam) == HTCLIENT && window->cursorHandle) {
SetCursor(window->cursorHandle);
return TRUE;
}
break;
}
2026-03-09 20:10:19 +01:00
default: return DefWindowProc(hwnd, msg, wParam, lParam);
}
return 0;
2025-11-25 18:52:32 +01:00
}
2026-03-09 20:10:19 +01:00
#endif
Window::Window(std::uint32_t width, std::uint32_t height, const std::string_view title) : Window(width, height) {
SetTitle(title);
2025-11-25 18:52:32 +01:00
}
2026-03-09 20:10:19 +01:00
Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height(height) {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
Device::windows.push_back(this);
surface = wl_compositor_create_surface(Device::compositor);
xdgSurface = xdg_wm_base_get_xdg_surface(Device::xdgWmBase, surface);
xdgToplevel = xdg_surface_get_toplevel(xdgSurface);
xdg_surface_add_listener(xdgSurface, &xdg_surface_listener, this);
xdg_toplevel_add_listener(xdgToplevel, &xdg_toplevel_listener, this);
wl_surface_commit(surface);
wp_scale = wp_fractional_scale_manager_v1_get_fractional_scale(Device::fractionalScaleManager, surface);
wp_fractional_scale_v1_add_listener(wp_scale, &wp_fractional_scale_v1_listener, this);
while (wl_display_dispatch(Device::display) != -1 && !configured) {}
wl_surface_commit(surface);
zxdg_toplevel_decoration_v1* decoration = zxdg_decoration_manager_v1_get_toplevel_decoration(Device::manager, xdgToplevel);
zxdg_toplevel_decoration_v1_set_mode(decoration, ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE);
wpViewport = wp_viewporter_get_viewport(Device::wpViewporter, surface);
wp_viewport_set_destination(wpViewport, std::ceil(width/scale), std::ceil(height/scale));
wl_surface_commit(surface);
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
// Initialize the window class
WNDCLASS wc = {0};
wc.lpfnWndProc = WndProc; // Set window procedure
wc.hInstance = GetModuleHandle(NULL); // Get instance handle
wc.lpszClassName = g_szClassName;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
if (!RegisterClass(&wc)) {
MessageBox(NULL, "Window Class Registration Failed!", "Error", MB_ICONERROR);
}
RECT rc = {0, 0, static_cast<LONG>(width), static_cast<LONG>(height)};
AdjustWindowRect(&rc, WS_OVERLAPPEDWINDOW, FALSE);
HWND hwnd = CreateWindowEx(
0,
g_szClassName,
"",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT,
rc.right - rc.left,
rc.bottom - rc.top,
NULL, NULL, wc.hInstance, this
);
if (hwnd == NULL) {
MessageBox(NULL, "Window Creation Failed!", "Error", MB_ICONERROR);
}
// Show the window
ShowWindow(hwnd, SW_SHOWNORMAL);
UpdateWindow(hwnd);
MSG msg;
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
VkWin32SurfaceCreateInfoKHR createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR;
createInfo.hinstance = wc.hInstance;
createInfo.hwnd = hwnd;
Device::CheckVkResult(vkCreateWin32SurfaceKHR(Device::instance, &createInfo, NULL, &vulkanSurface));
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
VkWaylandSurfaceCreateInfoKHR createInfo = {};
createInfo.sType = VK_STRUCTURE_TYPE_WAYLAND_SURFACE_CREATE_INFO_KHR;
createInfo.display = Device::display;
createInfo.surface = surface;
Device::CheckVkResult(vkCreateWaylandSurfaceKHR(Device::instance, &createInfo, NULL, &vulkanSurface));
#endif
// Get list of supported surface formats
std::uint32_t formatCount;
Device::CheckVkResult(vkGetPhysicalDeviceSurfaceFormatsKHR(Device::physDevice, vulkanSurface, &formatCount, NULL));
assert(formatCount > 0);
std::vector<VkSurfaceFormatKHR> surfaceFormats(formatCount);
Device::CheckVkResult(vkGetPhysicalDeviceSurfaceFormatsKHR(Device::physDevice, vulkanSurface, &formatCount, surfaceFormats.data()));
// We want to get a format that best suits our needs, so we try to get one from a set of preferred formats
// Initialize the format to the first one returned by the implementation in case we can't find one of the preffered formats
VkSurfaceFormatKHR selectedFormat = surfaceFormats[0];
std::vector<VkFormat> preferredImageFormats = {
VK_FORMAT_R8G8B8A8_UNORM,
VK_FORMAT_B8G8R8A8_UNORM
};
for (auto& availableFormat : surfaceFormats) {
if (std::find(preferredImageFormats.begin(), preferredImageFormats.end(), availableFormat.format) != preferredImageFormats.end()) {
selectedFormat = availableFormat;
break;
}
}
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;
}
}
}
2026-03-09 20:10:19 +01:00
CreateSwapchain();
VkCommandBufferAllocateInfo cmdBufAllocateInfo {};
cmdBufAllocateInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
cmdBufAllocateInfo.commandPool = Device::commandPool;
cmdBufAllocateInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cmdBufAllocateInfo.commandBufferCount = numFrames;
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &cmdBufAllocateInfo, drawCmdBuffers));
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
// 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.
2026-03-09 20:10:19 +01:00
VkSemaphoreCreateInfo semaphoreCreateInfo {};
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
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]));
}
2026-03-10 03:05:10 +01:00
// The deferred-deletion queue (issue #101) retires a resource after this
// many recorded frames — exactly the depth the per-frame fences (#40)
// allow in flight. Set it here, where numFrames is in scope.
Device::framesInFlight = numFrames;
// Per-frame info structs: everything that never varies between frames is
// 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,
.levelCount = VK_REMAINING_MIP_LEVELS,
.baseArrayLayer = 0,
.layerCount = VK_REMAINING_ARRAY_LAYERS,
};
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
// Transition into VK_IMAGE_LAYOUT_GENERAL before recording passes. image,
// oldLayout (depends on first use) and dstAccessMask (derived from the
// per-pass stage union) are patched per frame in Render(); the dstAccessMask
// here is just a well-formed default.
acquireBarrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = 0,
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
.dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = VK_NULL_HANDLE,
.subresourceRange = range,
};
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
// Transition back to PRESENT_SRC_KHR after the passes. image and
// srcAccessMask (derived from the per-pass stage union) are patched per
// frame in Render(); the srcAccessMask here is just a well-formed default.
presentBarrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = 0,
.oldLayout = VK_IMAGE_LAYOUT_GENERAL,
.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = VK_NULL_HANDLE,
.subresourceRange = range,
};
// pImageIndices/pSwapchains point at members, so they follow value changes
// (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;
presentInfo.swapchainCount = 1;
presentInfo.pSwapchains = &swapChain;
presentInfo.pImageIndices = &currentBuffer;
presentInfo.waitSemaphoreCount = 1;
2026-03-10 03:05:10 +01:00
lastMousePos = {0,0};
mouseDelta = {0,0};
currentMousePos = {0,0};
2025-11-25 18:52:32 +01:00
}
2025-11-25 19:43:40 +01:00
2026-05-12 00:24:48 +02:00
void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) {
// Skip degenerate resizes. Win32 minimised windows give (0, 0); Wayland
// sometimes echoes the current size in a configure.
if (newWidth == 0 || newHeight == 0) return;
if (newWidth == width && newHeight == height) return;
// Win32 fires WM_SIZE synchronously inside CreateWindowEx (before the
// constructor's CreateSwapchain). Defer the first resize to that
// CreateSwapchain call instead of trying to recreate a non-existent
// swapchain.
if (swapChain == VK_NULL_HANDLE) {
width = newWidth;
height = newHeight;
return;
}
width = newWidth;
height = newHeight;
// Caller (configure handler / WM_SIZE) runs between frames, but be
// defensive: ensure no in-flight commands reference the old swapchain.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
// The queue is idle, so every deferred resource is now safe to free
// regardless of its retire frame (issue #101).
Device::DrainDeletions();
2026-05-12 00:24:48 +02:00
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
if (wpViewport) {
wp_viewport_set_destination(wpViewport,
static_cast<int>(std::ceil(width / scale)),
static_cast<int>(std::ceil(height / scale)));
}
#endif
RecreateSwapchainAndImages();
onResize.Invoke();
}
void Window::RecreateSwapchainAndImages() {
// CreateSwapchain leaves the new swapchain images in
// VK_IMAGE_LAYOUT_UNDEFINED and resets imageInitialised. The images are
// NOT transitioned here: a presentable image may only be touched after
// vkAcquireNextImageKHR returns it, so Render() performs each image's
// first layout transition lazily (from UNDEFINED) once it has been
// acquired. Pre-transitioning unacquired images is a validation error.
2026-05-12 00:24:48 +02:00
CreateSwapchain();
}
2026-03-09 20:10:19 +01:00
void Window::SetTitle(const std::string_view title) {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
xdg_toplevel_set_title(xdgToplevel, title.data());
#endif
}
2026-05-02 21:08:20 +02:00
void Window::SetCursorImage(std::uint16_t width, std::uint16_t height,
std::uint16_t hotspotX, std::uint16_t hotspotY,
const std::uint8_t* pixels) {
2026-03-12 01:07:46 +01:00
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
2026-05-02 21:08:20 +02:00
if (width == 0 || height == 0 || pixels == nullptr) {
SetDefaultCursor();
return;
}
if (cursorSurface == nullptr) {
2026-03-12 01:07:46 +01:00
cursorSurface = wl_compositor_create_surface(Device::compositor);
2026-05-02 21:08:20 +02:00
}
int stride = width * 4;
int size = stride * height;
// Reuse the existing mmap+buffer if the size is unchanged; otherwise
// tear down and re-allocate.
if (cursorWlBuffer != nullptr &&
cursorBufferOldSize == static_cast<std::uint32_t>(size)) {
// size unchanged — keep the buffer and mmap.
2026-03-12 01:07:46 +01:00
} else {
2026-05-02 21:08:20 +02:00
if (cursorMmap_) {
munmap(cursorMmap_, cursorBufferOldSize);
cursorMmap_ = nullptr;
}
if (cursorWlBuffer) {
wl_buffer_destroy(cursorWlBuffer);
cursorWlBuffer = nullptr;
}
int fd = create_shm_file(size);
if (fd < 0) {
throw std::runtime_error(std::format(
"Window::SetCursorImage: shm allocation for {}B failed", size));
}
void* mapped = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (mapped == MAP_FAILED) {
close(fd);
throw std::runtime_error("Window::SetCursorImage: mmap failed");
}
cursorMmap_ = static_cast<std::uint8_t*>(mapped);
wl_shm_pool* pool = wl_shm_create_pool(Device::shm, fd, size);
cursorWlBuffer = wl_shm_pool_create_buffer(
pool, 0, width, height, stride, WL_SHM_FORMAT_ARGB8888);
wl_shm_pool_destroy(pool);
close(fd);
cursorBufferOldSize = static_cast<std::uint32_t>(size);
}
// Convert the user's straight-alpha RGBA8 pixels into the compositor's
// expected premultiplied BGRA8 (= ARGB8888 little-endian byte order).
for (int i = 0; i < width * height; ++i) {
std::uint8_t r = pixels[i * 4 + 0];
std::uint8_t g = pixels[i * 4 + 1];
std::uint8_t b = pixels[i * 4 + 2];
std::uint8_t a = pixels[i * 4 + 3];
cursorMmap_[i * 4 + 0] = static_cast<std::uint8_t>((b * a) / 255);
cursorMmap_[i * 4 + 1] = static_cast<std::uint8_t>((g * a) / 255);
cursorMmap_[i * 4 + 2] = static_cast<std::uint8_t>((r * a) / 255);
cursorMmap_[i * 4 + 3] = a;
}
cursorHotspotX_ = hotspotX;
cursorHotspotY_ = hotspotY;
wl_surface_attach(cursorSurface, cursorWlBuffer, 0, 0);
wl_surface_damage(cursorSurface, 0, 0, width, height);
wl_surface_commit(cursorSurface);
// If the pointer is currently inside our window, re-apply the cursor
// so the new hotspot takes effect immediately. Otherwise the next
// pointer-enter event will pick it up.
if (Device::wlPointer && Device::focusedWindow == this && lastPointerSerial_) {
wl_pointer_set_cursor(Device::wlPointer, lastPointerSerial_,
cursorSurface, hotspotX, hotspotY);
}
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
// Win32 cursor support is not implemented for the v2 Window.
(void)width; (void)height; (void)hotspotX; (void)hotspotY; (void)pixels;
#endif
}
void Window::SetDefaultCursor() {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
if (cursorMmap_) {
munmap(cursorMmap_, cursorBufferOldSize);
cursorMmap_ = nullptr;
}
if (cursorWlBuffer) {
2026-03-12 01:07:46 +01:00
wl_buffer_destroy(cursorWlBuffer);
2026-05-02 21:08:20 +02:00
cursorWlBuffer = nullptr;
2026-03-12 01:07:46 +01:00
}
2026-05-02 21:08:20 +02:00
if (cursorSurface) {
wl_surface_destroy(cursorSurface);
cursorSurface = nullptr;
2026-04-02 16:52:10 +02:00
}
2026-05-02 21:08:20 +02:00
cursorBufferOldSize = 0;
cursorHotspotX_ = 0;
cursorHotspotY_ = 0;
// Tell the compositor to drop our cursor surface — passing nullptr
// makes it fall back to the system default.
if (Device::wlPointer && Device::focusedWindow == this && lastPointerSerial_) {
wl_pointer_set_cursor(Device::wlPointer, lastPointerSerial_, nullptr, 0, 0);
2026-04-02 16:52:10 +02:00
}
#endif
2026-03-12 01:07:46 +01:00
}
2026-03-09 20:10:19 +01:00
void Window::StartSync() {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
while (open && wl_display_dispatch(Device::display) != -1) {
2026-05-12 00:24:48 +02:00
Gamepad::Tick();
onBeforeUpdate.Invoke();
2026-03-09 20:10:19 +01:00
}
#endif
2026-03-10 02:47:28 +01:00
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
// While updating, the FIFO present inside Update() paces this loop to the
// vblank, so the PeekMessage poll is vsync-gated. While NOT updating
// (paused / minimized / stopped) nothing throttles the loop, and a bare
// PeekMessage(PM_REMOVE) spin pins a core at 100% while uselessly re-running
// Gamepad::Tick() (mutex + per-pad COM reads) and onBeforeUpdate every
// iteration. Block on the message queue instead, waking on input or after a
// short timeout so gamepad polling / onBeforeUpdate still tick at a sane
// rate (e.g. to detect a controller button asking to resume).
constexpr DWORD kPausedPollIntervalMs = 16;
2026-03-09 20:10:19 +01:00
while(open) {
if (!updating) {
MsgWaitForMultipleObjectsEx(0, nullptr, kPausedPollIntervalMs,
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
}
2026-03-09 20:10:19 +01:00
MSG msg;
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
2026-05-12 00:24:48 +02:00
Gamepad::Tick();
onBeforeUpdate.Invoke();
2026-03-09 20:10:19 +01:00
if(updating) {
Update();
}
}
#endif
}
void Window::StartUpdate() {
lastFrameBegin = std::chrono::high_resolution_clock::now();
updating = true;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
cb = wl_surface_frame(surface);
wl_callback_add_listener(cb, &wl_callback_listener, this);
#endif
}
void Window::StopUpdate() {
updating = false;
}
2026-03-09 21:00:58 +01:00
std::chrono::time_point<std::chrono::high_resolution_clock> startTime;
2026-03-09 20:10:19 +01:00
void Window::Update() {
2026-03-09 21:00:58 +01:00
startTime = std::chrono::high_resolution_clock::now();
2026-03-09 20:10:19 +01:00
#ifdef CRAFTER_TIMING
2026-03-09 21:00:58 +01:00
vblank = duration_cast<std::chrono::milliseconds>(startTime - frameEnd);
2026-03-09 20:10:19 +01:00
#endif
2026-03-09 21:50:24 +01:00
mouseDelta = {currentMousePos.x-lastMousePos.x, currentMousePos.y-lastMousePos.y};
2026-03-09 22:15:36 +01:00
currentFrameTime = {startTime, startTime-lastFrameBegin};
2026-03-09 20:10:19 +01:00
#ifdef CRAFTER_TIMING
auto renderStart = std::chrono::high_resolution_clock::now();
renderTimings.clear();
#endif
Render();
#ifdef CRAFTER_TIMING
auto renderEnd = std::chrono::high_resolution_clock::now();
totalRender = renderEnd - renderStart;
#endif
2026-03-09 21:50:24 +01:00
lastMousePos = currentMousePos;
2026-03-09 20:10:19 +01:00
#ifdef CRAFTER_TIMING
frameEnd = std::chrono::high_resolution_clock::now();
// Ring buffer: overwrite the oldest entry once full. Order doesn't matter
// to LogTiming, so we never need to shift elements.
frameTimes[frameTimesHead] = totalUpdate+totalRender;
frameTimesHead = (frameTimesHead + 1) % frameTimeCapacity;
if (frameTimesCount < frameTimeCapacity) {
++frameTimesCount;
2026-03-09 20:10:19 +01:00
}
#endif
2026-03-09 21:00:58 +01:00
lastFrameBegin = startTime;
2026-03-09 20:10:19 +01:00
}
void Window::Render() {
2026-05-12 00:24:48 +02:00
// 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.
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
// 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.
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
const std::uint32_t acquireSlot = static_cast<std::uint32_t>(frameCounter % numAcquireSemaphores);
VkSemaphore imageAcquired = imageAcquiredSemaphores[acquireSlot];
2026-05-12 00:24:48 +02:00
{
VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
imageAcquired, (VkFence)nullptr, &currentBuffer);
2026-05-12 00:24:48 +02:00
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
Device::DrainDeletions();
2026-05-12 00:24:48 +02:00
RecreateSwapchainAndImages();
onResize.Invoke();
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
imageAcquired, (VkFence)nullptr, &currentBuffer);
2026-05-12 00:24:48 +02:00
}
if (acquire != VK_SUBOPTIMAL_KHR) {
Device::CheckVkResult(acquire);
}
}
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
// 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]));
2026-03-09 20:10:19 +01:00
// Now that a frame's fence has been waited, free any deferred resources
// whose retire frame has been reached (issue #101). frameCounter is bumped
// at end of frame, so it still holds this frame's index here — exactly the
// value entries enqueued this frame are tagged with.
Device::ReclaimDeletions();
2026-03-09 21:14:53 +01:00
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
2026-03-09 20:10:19 +01:00
// On an image's first use after (re)creating the swapchain it is still in
// VK_IMAGE_LAYOUT_UNDEFINED; every subsequent frame leaves it in
// PRESENT_SRC_KHR. Transitioning from the wrong oldLayout is a validation
// error, so pick based on whether this image has been rendered before.
// The image was just acquired above, so touching it here is legal.
const bool firstUse = !imageInitialised[currentBuffer];
imageInitialised[currentBuffer] = true;
acquireBarrier.oldLayout = firstUse ? VK_IMAGE_LAYOUT_UNDEFINED
: VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
acquireBarrier.image = images[currentBuffer];
2026-03-09 20:10:19 +01:00
// Real per-pass stage union for this frame's passes — the swapchain image
// is only ever written by compute/RT (and possibly transfer), never by the
// whole pipeline, so the frame-edge barriers wait on exactly those stages
// instead of ALL_COMMANDS. Derived once here and reused for the present
// barrier below (passes don't change within a Render()).
const VkPipelineStageFlags swapWriterStages = SwapchainStageUnion(passes);
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
// dstAccessMask must only contain access flags supported by the dst stage
// mask (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02820). Derive it
// from the same per-pass stage union the barrier uses as its dst stage mask
// instead of hardcoding SHADER_WRITE|TRANSFER_WRITE, which would carry
// TRANSFER_WRITE into a compute-only frame's COMPUTE_SHADER dst stage.
acquireBarrier.dstAccessMask = SwapchainWriterAccess(swapWriterStages);
// The acquire->GENERAL transition only needs to be visible before the first
// pass writes the image, i.e. at the writer stages — not ALL_COMMANDS.
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, swapWriterStages, 0, 0, nullptr, 0, nullptr, 1, &acquireBarrier);
2026-03-09 20:10:19 +01:00
2026-05-02 00:03:24 +02:00
// Synthesise key-repeat events before listeners run, so the focused
// widget's OnTextInput / OnKeyDown sees them in the same frame.
Device::TickKeyRepeats();
2026-05-05 23:49:29 +02:00
// Bind the descriptor heaps BEFORE the user's update event fires.
// Any compute work the update lambda records (e.g. physics dispatches)
// needs the heaps bound at execution time; recording order in the cmd
// buffer dictates GPU execution order, so the bind must come first.
// Pass-side dispatches still run with the same heaps bound — moving
// the bind earlier doesn't change anything for them.
2026-05-01 23:35:37 +02:00
if (descriptorHeap) {
// Rebuild the cached per-frame bind structs only when the heap changes.
// The address/size of each frame's heap and the reservedRangeOffset
// bitmask are stable for a given heap, so this runs once per assignment
// rather than every frame.
if (descriptorHeap != cachedDescriptorHeap) {
for (std::uint8_t i = 0; i < numFrames; ++i) {
resourceHeapInfos[i] = {
.sType = VK_STRUCTURE_TYPE_BIND_HEAP_INFO_EXT,
.heapRange = {
.address = descriptorHeap->resourceHeap[i].address,
.size = static_cast<std::uint32_t>(descriptorHeap->resourceHeap[i].size)
},
.reservedRangeOffset = (descriptorHeap->resourceHeap[i].size - Device::descriptorHeapProperties.minResourceHeapReservedRange) & ~(Device::descriptorHeapProperties.imageDescriptorAlignment - 1),
.reservedRangeSize = Device::descriptorHeapProperties.minResourceHeapReservedRange
};
samplerHeapInfos[i] = {
.sType = VK_STRUCTURE_TYPE_BIND_HEAP_INFO_EXT,
.heapRange = {
.address = descriptorHeap->samplerHeap[i].address,
.size = static_cast<std::uint32_t>(descriptorHeap->samplerHeap[i].size)
},
.reservedRangeOffset = descriptorHeap->samplerHeap[i].size - Device::descriptorHeapProperties.minSamplerHeapReservedRange,
.reservedRangeSize = Device::descriptorHeapProperties.minSamplerHeapReservedRange
};
}
cachedDescriptorHeap = descriptorHeap;
}
Device::vkCmdBindResourceHeapEXT(drawCmdBuffers[currentBuffer], &resourceHeapInfos[currentBuffer]);
Device::vkCmdBindSamplerHeapEXT(drawCmdBuffers[currentBuffer], &samplerHeapInfos[currentBuffer]);
2026-05-01 23:35:37 +02:00
}
2026-04-05 22:53:59 +02:00
2026-05-05 23:49:29 +02:00
onUpdate.Invoke({startTime, startTime-lastFrameBegin});
#ifdef CRAFTER_TIMING
totalUpdate = std::chrono::nanoseconds(0);
updateTimings.clear();
for (const std::pair<const EventListener<FrameTime>*, std::chrono::nanoseconds>& entry : onUpdate.listenerTimes) {
updateTimings.push_back(entry);
totalUpdate += entry.second;
}
#endif
2026-05-01 23:35:37 +02:00
// Note: vkCmdClearColorImage is unavailable here — the swapchain is
// created with VK_IMAGE_USAGE_STORAGE_BIT only (no TRANSFER_DST_BIT).
// Passes that need a background should write one explicitly (UIScene
// exposes a `background()` setter for this purpose).
(void)clearColor;
for (std::size_t i = 0; i < passes.size(); ++i) {
passes[i]->Record(drawCmdBuffers[currentBuffer], currentBuffer, *this);
if (i + 1 < passes.size()) {
// Make pass i's writes to the swapchain image visible to pass i+1.
// The only resource the inter-pass hazard touches is that one image
// (later passes overdraw earlier ones), so use an image memory
// barrier scoped to its subresource instead of a queue-wide
// VkMemoryBarrier — only this image's caches round-trip, exactly as
// the intra-pass UI barrier already does. The stage masks come from
// each pass's own SwapchainStage(), so a compute->compute edge only
// serialises COMPUTE_SHADER while an RT pass on either side pulls in
// RAY_TRACING_SHADER — never the ALL_COMMANDS full-pipeline stall.
VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer]);
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer],
passes[i]->SwapchainStage(), passes[i + 1]->SwapchainStage(),
0, 0, nullptr, 0, nullptr, 1, &ib);
2026-05-01 23:35:37 +02:00
}
}
2026-03-09 20:10:19 +01:00
presentBarrier.image = images[currentBuffer];
2026-03-09 20:10:19 +01:00
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
// Mirror of the acquire barrier: srcAccessMask must be supported by the src
// stage mask (swapWriterStages). SHADER_WRITE alone is valid for COMPUTE/RT
// and so never fires today, but a transfer-stage writer would need
// TRANSFER_WRITE here too — derive both from the same union for symmetry.
presentBarrier.srcAccessMask = SwapchainWriterAccess(swapWriterStages);
// The ->PRESENT_SRC transition only needs to wait on the stages that
// actually wrote the image (the per-pass union), not ALL_COMMANDS; present
// itself is gated by the render-finished semaphore, so dst stays BOTTOM.
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], swapWriterStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier);
2026-03-09 20:10:19 +01:00
2026-03-09 21:14:53 +01:00
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
2026-03-09 20:10:19 +01:00
// 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];
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
submitInfo.pSignalSemaphores = &renderFinishedSemaphores[currentBuffer];
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, waitFences[currentBuffer]));
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
presentInfo.pWaitSemaphores = &renderFinishedSemaphores[currentBuffer];
2026-03-09 20:10:19 +01:00
2026-03-09 21:14:53 +01:00
VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo);
2026-05-12 00:24:48 +02:00
if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) {
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
// 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.
2026-05-12 00:24:48 +02:00
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
Device::DrainDeletions();
2026-05-12 00:24:48 +02:00
RecreateSwapchainAndImages();
onResize.Invoke();
2026-03-09 20:10:19 +01:00
} else {
2026-03-09 21:14:53 +01:00
Device::CheckVkResult(result);
2026-03-09 20:10:19 +01:00
}
feat(window): multi-frame-in-flight frame pacing (#40) The renderer was effectively single-buffered despite allocating triple-buffered infrastructure: Render() ended with an unconditional vkQueueWaitIdle, and a single (presentComplete, renderComplete) semaphore pair with zero fences was shared for the Window's lifetime. Rework the pacing model so up to numFrames frames overlap: - Per-swapchain-image VkFence, signaled by the submit and waited+reset before that image's command buffer / descriptor-heap slot is re-recorded. Keyed by acquired image index (drawCmdBuffers, the heap slots, and the swapchain images are all image-indexed — see WriteSwapchainDescriptors). Created signaled so first use passes. - Per-image render-finished (present) semaphore: the presentation engine holds it until the image is re-acquired, so per-image is the only safe key (per-CPU-frame trips VUID-vkQueueSubmit-pSignalSemaphores-00067). - Per-CPU-frame acquire semaphores (image index unknown until acquire returns), sized numFrames+1: in-flight depth is bounded by the per-image fences, so numFrames+1 distinct acquire semaphores guarantee the reused one has no pending op (VUID-vkAcquireNextImageKHR-01779). - Drop the steady-state vkQueueWaitIdle; keep it on resize / OUT_OF_DATE / teardown. Add tests/FrameLoopSync: drives the real frame loop against a live Wayland compositor for 60 frames (>> in-flight slots) and asserts the CPU frame counter advanced, the swapchain rotated across multiple images, and the validation layer stayed silent — the load-bearing check, since the old single-pair design only avoided being an active race because the wait-idle masked it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:37:11 +00:00
// 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++;
// Advance the global deferred-deletion clock too (issue #101). Kept
// separate from the per-window frameCounter above (which also keys the
// acquire-semaphore slot) so non-window code can enqueue against it.
Device::frameCounter++;
2025-11-25 19:43:40 +01:00
}
2025-11-25 23:29:48 +01:00
#ifdef CRAFTER_TIMING
void Window::LogTiming() {
std::cout << std::format("Update: {}", duration_cast<std::chrono::milliseconds>(totalUpdate)) << std::endl;
for (const std::pair<const EventListener<FrameTime>*, std::chrono::nanoseconds>& entry : updateTimings) {
std::cout << std::format("\t{} {}", reinterpret_cast<const void*>(entry.first), duration_cast<std::chrono::microseconds>(entry.second)) << std::endl;
}
std::cout << std::format("Render: {}", duration_cast<std::chrono::milliseconds>(totalRender)) << std::endl;
2026-03-09 20:10:19 +01:00
for (const std::tuple<const RenderingElement*, std::uint32_t, std::uint32_t, std::chrono::nanoseconds>& entry : renderer.renderTimings) {
2025-11-25 23:29:48 +01:00
std::cout << std::format("\t{} {}x{} {}", reinterpret_cast<const void*>(std::get<0>(entry)), std::get<1>(entry), std::get<2>(entry), duration_cast<std::chrono::microseconds>(std::get<3>(entry))) << std::endl;
}
2025-11-25 23:36:43 +01:00
std::cout << std::format("Total: {}", duration_cast<std::chrono::milliseconds>(totalUpdate+totalRender)) << std::endl;
2025-11-25 23:29:48 +01:00
std::cout << std::format("Vblank: {}", duration_cast<std::chrono::milliseconds>(vblank)) << std::endl;
2025-11-25 23:36:43 +01:00
// Add 100-frame average and min-max timing info. Only the first
// frameTimesCount entries of the ring buffer hold valid samples.
if (frameTimesCount != 0) {
2025-11-25 23:36:43 +01:00
// Calculate average
std::chrono::nanoseconds sum(0);
for (std::size_t i = 0; i < frameTimesCount; ++i) {
sum += frameTimes[i];
2025-11-25 23:36:43 +01:00
}
auto average = sum / frameTimesCount;
2025-11-25 23:36:43 +01:00
// Find min and max
auto min = frameTimes[0];
auto max = frameTimes[0];
for (std::size_t i = 0; i < frameTimesCount; ++i) {
if (frameTimes[i] < min) min = frameTimes[i];
if (frameTimes[i] > max) max = frameTimes[i];
2025-11-25 23:36:43 +01:00
}
std::cout << std::format("Last 100 Frame Times - Avg: {}, Min: {}, Max: {}",
duration_cast<std::chrono::milliseconds>(average),
duration_cast<std::chrono::milliseconds>(min),
duration_cast<std::chrono::milliseconds>(max)) << std::endl;
}
2025-11-25 23:29:48 +01:00
}
2025-11-26 18:48:58 +01:00
#endif
2026-03-09 20:10:19 +01:00
void Window::CreateSwapchain()
{
// Store the current swap chain handle so we can use it later on to ease up recreation
VkSwapchainKHR oldSwapchain = swapChain;
2025-11-26 18:48:58 +01:00
2026-03-09 20:10:19 +01:00
// Get physical device surface properties and formats
VkSurfaceCapabilitiesKHR surfCaps;
Device::CheckVkResult(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(Device::physDevice, vulkanSurface, &surfCaps));
2025-11-26 18:48:58 +01:00
2026-03-09 20:10:19 +01:00
VkExtent2D swapchainExtent = {};
// If width (and height) equals the special value 0xFFFFFFFF, the size of the surface will be set by the swapchain
if (surfCaps.currentExtent.width == (uint32_t)-1)
{
// If the surface size is undefined, the size is set to the size of the images requested
swapchainExtent.width = width;
swapchainExtent.height = height;
}
else
{
// If the surface size is defined, the swap chain size must match
swapchainExtent = surfCaps.currentExtent;
width = surfCaps.currentExtent.width;
height = surfCaps.currentExtent.height;
}
2025-11-26 20:15:25 +01:00
2025-11-26 23:36:08 +01:00
2026-03-09 20:10:19 +01:00
// Find the transformation of the surface
VkSurfaceTransformFlagsKHR preTransform;
if (surfCaps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR)
{
// We prefer a non-rotated transform
preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR;
}
else
{
preTransform = surfCaps.currentTransform;
}
VkSwapchainCreateInfoKHR swapchainCI = {};
swapchainCI.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
swapchainCI.surface = vulkanSurface;
swapchainCI.minImageCount = numFrames;
swapchainCI.imageFormat = colorFormat;
swapchainCI.imageColorSpace = colorSpace;
swapchainCI.imageExtent = { swapchainExtent.width, swapchainExtent.height };
2026-05-01 23:35:37 +02:00
swapchainCI.imageUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
2026-03-09 20:10:19 +01:00
swapchainCI.preTransform = (VkSurfaceTransformFlagBitsKHR)preTransform;
swapchainCI.imageArrayLayers = 1;
swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
swapchainCI.queueFamilyIndexCount = 0;
// VK_PRESENT_MODE_FIFO_KHR ("v-sync") is guaranteed available per spec, so
// it is the default. For benchmarking we want frame pacing uncapped from
// the display refresh, so when CRAFTER_PRESENT_IMMEDIATE is set in the
// environment we enumerate the surface's present modes and prefer
// IMMEDIATE (then MAILBOX) when offered, falling back to FIFO otherwise.
swapchainCI.presentMode = VK_PRESENT_MODE_FIFO_KHR;
if (std::getenv("CRAFTER_PRESENT_IMMEDIATE")) {
std::uint32_t presentModeCount = 0;
vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, nullptr);
std::vector<VkPresentModeKHR> presentModes(presentModeCount);
vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, presentModes.data());
bool haveImmediate = false, haveMailbox = false;
for (VkPresentModeKHR m : presentModes) {
if (m == VK_PRESENT_MODE_IMMEDIATE_KHR) haveImmediate = true;
if (m == VK_PRESENT_MODE_MAILBOX_KHR) haveMailbox = true;
}
if (haveImmediate) swapchainCI.presentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
else if (haveMailbox) swapchainCI.presentMode = VK_PRESENT_MODE_MAILBOX_KHR;
}
2026-03-09 20:10:19 +01:00
// 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
swapchainCI.clipped = VK_TRUE;
swapchainCI.compositeAlpha = compositeAlpha;
Device::CheckVkResult(vkCreateSwapchainKHR(Device::device, &swapchainCI, nullptr, &swapChain));
// If an existing swap chain is re-created, destroy the old swap chain and the ressources owned by the application (image views, images are owned by the swap chain)
if (oldSwapchain != VK_NULL_HANDLE) {
vkDestroySwapchainKHR(Device::device, oldSwapchain, nullptr);
}
uint32_t imageCount{ 0 };
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr));
// Get the swap chain images
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, images));
// Brand-new swapchain images are in VK_IMAGE_LAYOUT_UNDEFINED; none have
// been rendered/presented yet. Render() consults this to pick the correct
// oldLayout for each image's first post-acquire transition.
imageInitialised.fill(false);
2026-04-05 22:53:59 +02:00
for (std::uint8_t i = 0; i < numFrames; i++) {
imageViews[i] = {
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.flags = 0,
.image = images[i],
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = colorFormat,
.components = {
VK_COMPONENT_SWIZZLE_R,
VK_COMPONENT_SWIZZLE_G,
VK_COMPONENT_SWIZZLE_B,
VK_COMPONENT_SWIZZLE_A
},
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1,
},
};
2026-03-09 20:10:19 +01:00
}
}
VkCommandBuffer Window::StartInit() {
VkCommandBufferBeginInfo cmdBufInfo {};
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
// StartInit reuses drawCmdBuffers[currentBuffer] — the same buffer the
// steady-state Render() loop submits, guarded by waitFences[currentBuffer].
// When setup runs mid-session (e.g. building map meshes / acceleration
// structures on a scene transition) the loop's last submission of this
// buffer may still be in flight: re-beginning it would trip
// VUID-vkBeginCommandBuffer-commandBuffer-00049, re-submitting it
// VUID-vkQueueSubmit-pCommandBuffers-00071, and any resource the caller
// destroys between here and FinishInit could still be referenced by that
// pending submission (vkDestroyBuffer-in-use). Drain the queue first so the
// buffer is free to re-record and no in-flight frame still references the
// resources this setup is about to replace (issue #153). Setup is rare
// (not the per-frame path), so a full wait-idle here is fine — FinishInit
// already wait-idles at the end.
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
2026-03-09 20:10:19 +01:00
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
// The swapchain images are deliberately NOT transitioned here. They are
// presentable images and may only be touched after vkAcquireNextImageKHR
// returns them; transitioning an unacquired presentable image is a
// validation error. Render() instead transitions each image from
// VK_IMAGE_LAYOUT_UNDEFINED on its first acquired use (see
// imageInitialised). This command buffer is for the caller's one-time
// setup work (uploads, acceleration-structure builds, etc.).
2026-03-09 20:10:19 +01:00
return drawCmdBuffers[currentBuffer];
}
void Window::FinishInit() {
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
}
VkCommandBuffer Window::GetCmd() {
VkCommandBufferBeginInfo cmdBufInfo {};
cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
fix(window): silence per-frame and setup-path Vulkan validation errors (#153) Two distinct validation errors the native frame loop emitted, both originating in Crafter.Graphics with no consumer-side influence. Problem 1 — per-frame acquire-barrier access/stage mismatch. The acquire->GENERAL barrier hardcoded dstAccessMask = SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER, which does not support TRANSFER_WRITE, so VUID-02820 fired every frame. Derive the access mask from the same stage union via a new SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply it to both the acquire dst and present src masks for symmetry. Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the loop's last submission of that buffer may still be in flight when scene setup runs (building map meshes / acceleration structures), so the old code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer, and resources freed in the StartInit..FinishInit bracket could still be referenced by it. Drain the queue at the start of StartInit/GetCmd before re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd already wait-idle at the end). Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage (pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression test driving a compute pass plus interleaved mid-session StartInit rounds, asserting the validation layer stays silent. Verified both halves fail (reproducing the exact VUIDs) when their respective fix is reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
// Same lifecycle hazard as StartInit: GetCmd re-begins and EndCmd
// re-submits the shared drawCmdBuffers[currentBuffer], so drain any
// in-flight submission of it (and free the resources it references) before
// re-recording (issue #153).
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
VkImageSubresourceRange range{};
range.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
range.baseMipLevel = 0;
range.levelCount = VK_REMAINING_MIP_LEVELS;
range.baseArrayLayer = 0;
range.layerCount = VK_REMAINING_ARRAY_LAYERS;
return drawCmdBuffers[currentBuffer];
}
void Window::EndCmd(VkCommandBuffer cmd) {
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
}
2026-03-09 20:10:19 +01:00
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
void Window::wl_surface_frame_done(void* data, struct wl_callback *cb, uint32_t time) {
wl_callback_destroy(cb);
Window* window = reinterpret_cast<Window*>(data);
if(window->updating) {
cb = wl_surface_frame(window->surface);
wl_callback_add_listener(cb, &Window::wl_callback_listener, window);
2026-03-09 21:43:25 +01:00
window->Update();
} else {
cb = nullptr;
2026-03-09 20:10:19 +01:00
}
}
2026-05-12 00:24:48 +02:00
void Window::xdg_toplevel_configure(void* data, xdg_toplevel*, std::int32_t width, std::int32_t height, wl_array*){
// xdg-shell batches state — width/height are pending until the matching
// xdg_surface.configure arrives. Width/height are in surface-local
// (logical DP) units; (0, 0) means "compositor has no preference".
Window* window = reinterpret_cast<Window*>(data);
window->pendingLogicalWidth = width;
window->pendingLogicalHeight = height;
2026-03-09 20:10:19 +01:00
}
void Window::xdg_toplevel_handle_close(void* data, xdg_toplevel*) {
Window* window = reinterpret_cast<Window*>(data);
window->onClose.Invoke();
window->open = false;
}
void Window::xdg_surface_handle_configure(void* data, xdg_surface* xdg_surface, std::uint32_t serial) {
Window* window = reinterpret_cast<Window*>(data);
// The compositor configures our surface, acknowledge the configure event
xdg_surface_ack_configure(xdg_surface, serial);
if (window->configured) {
2026-05-12 00:24:48 +02:00
// Subsequent configure: if the toplevel asked for a new size
// (non-zero, different from current), drive the resize end-to-end.
// (0, 0) means "compositor has no preference, keep current size".
// The swapchain may not exist yet on the very first frame between
// the constructor's wait loop and CreateSwapchain — the Resize
// guard against equal sizes already covers that path.
if (window->pendingLogicalWidth > 0 && window->pendingLogicalHeight > 0 &&
window->swapChain != VK_NULL_HANDLE) {
std::uint32_t newWidth = static_cast<std::uint32_t>(
std::ceil(window->pendingLogicalWidth * window->scale));
std::uint32_t newHeight = static_cast<std::uint32_t>(
std::ceil(window->pendingLogicalHeight * window->scale));
window->Resize(newWidth, newHeight);
}
2026-03-09 20:10:19 +01:00
wl_surface_commit(window->surface);
}
window->configured = true;
}
void Window::xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_v1*, std::uint32_t scale) {
Window* window = reinterpret_cast<Window*>(data);
window->scale = scale / 120.0f;
}
2026-05-01 23:35:37 +02:00
#endif
void Window::SaveFrame(const std::filesystem::path& path) {
// Staging buffer big enough for one RGBA frame.
VkDeviceSize bufSize = static_cast<VkDeviceSize>(width) * height * 4;
VkBufferCreateInfo bci{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.size = bufSize,
.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
};
VkBuffer stagingBuf = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateBuffer(Device::device, &bci, nullptr, &stagingBuf));
VkMemoryRequirements memReqs;
vkGetBufferMemoryRequirements(Device::device, stagingBuf, &memReqs);
// Readback path: the CPU *reads* this whole buffer back. The first
// HOST_VISIBLE|HOST_COHERENT type is usually write-combined (uncached) on
// discrete GPUs, where CPU reads are pathologically slow. Ask for
// HOST_CACHED so reads hit the CPU cache, preferring a cached-coherent type
// when one exists. HOST_CACHED is near-universal but not spec-guaranteed,
// so fall back to any HOST_VISIBLE type (#59) if absent — that path then
// relies on the coherency-gated invalidate below. (Deliberately NOT routed
// through the upload-strategy helper, which steers toward write-combined
// DEVICE_LOCAL|HOST_VISIBLE — the worst type for readback. See issue #89.)
std::uint32_t memTypeIndex;
try {
memTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT,
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
} catch (const std::runtime_error&) {
memTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT);
}
2026-05-01 23:35:37 +02:00
VkMemoryAllocateInfo mai{
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
.allocationSize = memReqs.size,
.memoryTypeIndex = memTypeIndex,
2026-05-01 23:35:37 +02:00
};
VkDeviceMemory stagingMem = VK_NULL_HANDLE;
Device::CheckVkResult(vkAllocateMemory(Device::device, &mai, nullptr, &stagingMem));
Device::CheckVkResult(vkBindBufferMemory(Device::device, stagingBuf, stagingMem, 0));
// One-shot command buffer so we don't trash the per-frame ones.
VkCommandBufferAllocateInfo cba{
.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, &cba, &cmd));
VkCommandBufferBeginInfo cbi{
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
};
Device::CheckVkResult(vkBeginCommandBuffer(cmd, &cbi));
VkImageSubresourceRange range{
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1,
};
// Render() leaves the image in PRESENT_SRC_KHR.
VkImageMemoryBarrier toSrc{
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = images[currentBuffer],
.subresourceRange = range,
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 1, &toSrc);
VkBufferImageCopy region{
.bufferOffset = 0,
.bufferRowLength = 0,
.bufferImageHeight = 0,
.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 },
.imageOffset = { 0, 0, 0 },
.imageExtent = { width, height, 1 },
};
vkCmdCopyImageToBuffer(cmd, images[currentBuffer],
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, stagingBuf, 1, &region);
VkImageMemoryBarrier back{
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT,
.dstAccessMask = 0,
.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = images[currentBuffer],
.subresourceRange = range,
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
0, 0, nullptr, 0, nullptr, 1, &back);
Device::CheckVkResult(vkEndCommandBuffer(cmd));
VkSubmitInfo si{
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
.commandBufferCount = 1,
.pCommandBuffers = &cmd,
};
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &si, VK_NULL_HANDLE));
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
// Read back, swizzle BGRA → RGBA if needed, write PNG.
void* mapped = nullptr;
Device::CheckVkResult(vkMapMemory(Device::device, stagingMem, 0, VK_WHOLE_SIZE, 0, &mapped));
// If the chosen type is cached-but-not-coherent, the transfer's writes may
// not yet be visible to the CPU read; invalidate to pull them in. Gate on
// the chosen type's actual coherency (not the requested flags) — a coherent
// type needs no invalidate. Whole-range invalidate sidesteps
// nonCoherentAtomSize. This is the read-path counterpart of FlushDevice (#60).
if (!(Device::memoryProperties.memoryTypes[memTypeIndex].propertyFlags &
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) {
VkMappedMemoryRange invalidateRange{
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = stagingMem,
.offset = 0,
.size = VK_WHOLE_SIZE,
};
Device::CheckVkResult(
vkInvalidateMappedMemoryRanges(Device::device, 1, &invalidateRange));
}
2026-05-01 23:35:37 +02:00
const std::uint8_t* src = static_cast<const std::uint8_t*>(mapped);
std::vector<std::uint8_t> rgba(static_cast<std::size_t>(width) * height * 4);
bool bgr = (colorFormat == VK_FORMAT_B8G8R8A8_UNORM);
for (std::uint32_t i = 0; i < width * height; ++i) {
if (bgr) {
rgba[i*4+0] = src[i*4+2];
rgba[i*4+1] = src[i*4+1];
rgba[i*4+2] = src[i*4+0];
rgba[i*4+3] = src[i*4+3];
} else {
rgba[i*4+0] = src[i*4+0];
rgba[i*4+1] = src[i*4+1];
rgba[i*4+2] = src[i*4+2];
rgba[i*4+3] = src[i*4+3];
}
}
vkUnmapMemory(Device::device, stagingMem);
stbi_write_png(path.string().c_str(), static_cast<int>(width), static_cast<int>(height),
4, rgba.data(), static_cast<int>(width) * 4);
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
vkDestroyBuffer(Device::device, stagingBuf, nullptr);
vkFreeMemory(Device::device, stagingMem, nullptr);
2026-05-18 02:07:48 +02:00
}
#endif // !CRAFTER_GRAPHICS_WINDOW_DOM
// ──────────────────────────────────────────────────────────────────────
// DOM backend
// ──────────────────────────────────────────────────────────────────────
//
// In DOM mode the "window" IS the browser page; there is no separate
// surface to create, no swapchain to manage, no GPU pipeline to wait on.
// All Window does here is:
// - mirror requested title onto document.title
// - register itself with the JS bridge so DOM-level events route into
// its event objects
// - hand the frame loop to requestAnimationFrame; `Update` runs on
// each rAF tick when `updating` is true
//
// The C exports (__crafterDom_*) below are how the JS bridge reaches
// back into the live Window instance. We keep a process-global pointer
// for V1 — only one Window per page — and lookups are O(1).
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
// The JS runtime initializes itself before main() runs, so there's
// nothing to do here. Defined as a no-op so user code calling
// `Device::Initialize()` links the same way it does on native.
void Device::Initialize() {}
namespace {
Window* g_domWindow = nullptr;
}
namespace Crafter::DomEnv {
__attribute__((import_module("env"), import_name("domAttachWindow")))
void domAttachWindow(std::int32_t handle);
__attribute__((import_module("env"), import_name("domSetTitle")))
void domSetTitle(const char* title, std::int32_t titleLen);
__attribute__((import_module("env"), import_name("domGetInnerWidth")))
std::int32_t domGetInnerWidth();
__attribute__((import_module("env"), import_name("domGetInnerHeight")))
std::int32_t domGetInnerHeight();
__attribute__((import_module("env"), import_name("domStartFrameLoop")))
void domStartFrameLoop();
__attribute__((import_module("env"), import_name("domStopFrameLoop")))
void domStopFrameLoop();
}
// Compile-time string hash matching what dom-env.js sends through. The
// JS bridge marshals `KeyboardEvent.code` as a UTF-8 string; we hash it
// to a 32-bit KeyCode here so the same value compares equal against the
// table in :Keys (DOM branch). FNV-1a, deterministic, no allocation.
namespace {
constexpr KeyCode HashKeyCode(const char* p, std::size_t n) {
std::uint32_t h = 2166136261u;
for (std::size_t i = 0; i < n; ++i) {
h ^= static_cast<std::uint8_t>(p[i]);
h *= 16777619u;
}
return h;
}
}
Window::Window(std::uint32_t w, std::uint32_t h, const std::string_view title)
: Window(w, h) {
SetTitle(title);
}
Window::Window(std::uint32_t w, std::uint32_t h) : width(w), height(h) {
if (g_domWindow != nullptr) {
std::println("Crafter::Window: only one DOM Window per page; "
"overwriting the previous instance.");
}
g_domWindow = this;
2026-05-18 04:58:52 +02:00
// Browser owns the real surface size. The width/height passed in are
// advisory only — useful as a native-side hint, ignored on DOM. We
// always sync to innerWidth/innerHeight so:
// - window.width/.height match the canvas's CSS pixel size,
// - MouseEvent.clientX/.clientY (CSS pixels) compare correctly
// against any layout done with window.width/.height,
// - the dispatch group count from window.width/8 covers the
// canvas exactly.
(void)w; (void)h;
width = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerWidth());
height = static_cast<std::uint32_t>(Crafter::DomEnv::domGetInnerHeight());
2026-05-18 02:07:48 +02:00
// The handle passed to attach is just a non-zero token the JS side
// includes back in every dispatcher call. We don't use it on the
// C++ side (g_domWindow is the lookup) but it has to be non-zero so
// the JS bridge treats the window as "attached".
Crafter::DomEnv::domAttachWindow(1);
lastMousePos = {0, 0};
currentMousePos = {0, 0};
mouseDelta = {0, 0};
}
Window::~Window() {
// Clear the global pointer iff it still references us — defensive
// against a stack-allocated Window in main() that goes out of scope
// while rAF / DOM event callbacks are still queued. After this, the
// JS-side dispatchers (__crafterDom_*) early-return harmlessly. A
// shrill warning to the console flags the (almost certainly
// unintended) lifetime mistake so the user notices before everything
// mysteriously stops working.
if (g_domWindow == this) {
g_domWindow = nullptr;
std::println("Crafter::Window: destroyed while DOM mode is active. "
"Browser events will no-op until a new Window is constructed. "
"Did you forget to put the Window in `static` / `new`d storage?");
}
}
void Window::SetTitle(const std::string_view title) {
Crafter::DomEnv::domSetTitle(title.data(), static_cast<std::int32_t>(title.size()));
}
void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) {
if (newWidth == 0 || newHeight == 0) return;
if (newWidth == width && newHeight == height) return;
width = newWidth;
height = newHeight;
onResize.Invoke();
}
void Window::SetCursorImage(std::uint16_t /*cw*/, std::uint16_t /*ch*/,
std::uint16_t /*hx*/, std::uint16_t /*hy*/,
const std::uint8_t* /*pixels*/) {
// V1: not wired. The natural impl is to base64-encode an inline PNG
// and assign it via document.body.style.cursor = `url(data:...) hx hy, auto`.
// Left for a follow-up so the first DOM build can ship without an
// inline PNG encoder.
}
void Window::SetDefaultCursor() {
// Mirror SetCursorImage stub. Future impl: clear body.style.cursor.
}
void Window::StartSync() {
2026-05-18 04:58:52 +02:00
// Hand the loop to rAF, then exit the wasm via _Exit so wasi-libc
// skips __wasm_call_dtors. If we let main return normally, _start
// calls __wasm_call_dtors → static destructors fire (including
// Window's own), then __wasi_proc_exit → wasm trap. Subsequent rAF
// calls into the wasm would then trap too, killing rendering.
// _Exit jumps straight to __wasi_proc_exit, which our runtime.js
// catches via a thrown sentinel so the instance stays alive while
// every static-allocated object (Window, UIRenderer, GPU buffers,
// event listeners) remains untouched. Callers' code after
// StartSync() never runs — match that contract on native too.
2026-05-18 02:07:48 +02:00
Crafter::DomEnv::domStartFrameLoop();
2026-05-18 04:58:52 +02:00
std::_Exit(0);
2026-05-18 02:07:48 +02:00
}
void Window::StartUpdate() {
lastFrameBegin = std::chrono::high_resolution_clock::now();
updating = true;
}
void Window::StopUpdate() {
updating = false;
Crafter::DomEnv::domStopFrameLoop();
}
void Window::Update() {
auto now = std::chrono::high_resolution_clock::now();
mouseDelta = {currentMousePos.x - lastMousePos.x,
currentMousePos.y - lastMousePos.y};
currentFrameTime = {now, now - lastFrameBegin};
onUpdate.Invoke(currentFrameTime);
lastMousePos = currentMousePos;
lastFrameBegin = now;
}
void Window::Render() {
2026-05-18 04:58:52 +02:00
if (!open) return;
Crafter::WebGPU::wgpuFrameBegin();
for (RenderPass* p : passes) {
if (p) p->Record(/*cmd*/ 0u, currentBuffer, *this);
}
Crafter::WebGPU::wgpuFrameEnd();
}
WebGPUCommandEncoderRef Window::StartInit() {
// DOM init: no command buffer needed — texture / buffer creation goes
// through synchronous wgpu* imports. Return 0 as a placeholder; the
// value is opaque to user code (auto-typed in HelloUI).
Crafter::WebGPU::wgpuInit();
return 0;
}
void Window::FinishInit() {
// Nothing to submit in DOM mode; all init writes are queued at call
// time via queue.writeBuffer / writeTexture.
2026-05-18 02:07:48 +02:00
}
// ─── C exports the JS bridge calls back into ──────────────────────────
extern "C" {
__attribute__((export_name("__crafterDom_frame")))
void __crafterDom_frame(std::int32_t /*handle*/) {
if (!g_domWindow) return;
Gamepad::Tick();
g_domWindow->onBeforeUpdate.Invoke();
if (g_domWindow->updating) {
g_domWindow->Update();
2026-05-18 04:58:52 +02:00
g_domWindow->Render();
2026-05-18 02:07:48 +02:00
}
}
__attribute__((export_name("__crafterDom_mouseMove")))
void __crafterDom_mouseMove(std::int32_t /*handle*/, double x, double y) {
if (!g_domWindow) return;
g_domWindow->currentMousePos = {static_cast<float>(x), static_cast<float>(y)};
g_domWindow->onMouseMove.Invoke();
}
__attribute__((export_name("__crafterDom_mouseDown")))
void __crafterDom_mouseDown(std::int32_t /*handle*/, std::int32_t button) {
if (!g_domWindow) return;
// MouseEvent.button: 0=left, 1=middle, 2=right
if (button == 0) {
g_domWindow->mouseLeftHeld = true;
g_domWindow->onMouseLeftClick.Invoke();
} else if (button == 2) {
g_domWindow->mouseRightHeld = true;
g_domWindow->onMouseRightClick.Invoke();
}
}
__attribute__((export_name("__crafterDom_mouseUp")))
void __crafterDom_mouseUp(std::int32_t /*handle*/, std::int32_t button) {
if (!g_domWindow) return;
if (button == 0) {
g_domWindow->mouseLeftHeld = false;
g_domWindow->onMouseLeftRelease.Invoke();
} else if (button == 2) {
g_domWindow->mouseRightHeld = false;
g_domWindow->onMouseRightRelease.Invoke();
}
}
__attribute__((export_name("__crafterDom_wheel")))
void __crafterDom_wheel(std::int32_t /*handle*/, double detents) {
2026-05-18 02:07:48 +02:00
if (!g_domWindow) return;
// dom-env.js has already normalized WheelEvent deltas to whole
// detents (+1 = wheel down) — see __windowListeners.wheel.
2026-05-18 02:07:48 +02:00
// Window::onMouseScroll is uint32 — preserve sign via two's complement.
g_domWindow->onMouseScroll.Invoke(static_cast<std::uint32_t>(static_cast<std::int32_t>(detents)));
2026-05-18 02:07:48 +02:00
}
__attribute__((export_name("__crafterDom_keyDown")))
void __crafterDom_keyDown(std::int32_t /*handle*/,
const char* codePtr, std::int32_t codeLen,
const char* keyPtr, std::int32_t keyLen,
bool repeat) {
if (!g_domWindow) return;
KeyCode code = HashKeyCode(codePtr, static_cast<std::size_t>(codeLen));
if (repeat) {
g_domWindow->onRawKeyHold.Invoke(code);
} else {
g_domWindow->heldKeys.insert(code);
g_domWindow->onRawKeyDown.Invoke(code);
}
2026-05-26 22:50:49 +02:00
// KeyboardEvent.key is the printable form for character keys, BUT
// it's also the named identifier for non-character keys —
// "Backspace", "Shift", "ArrowLeft", "Enter", … Those are
// multi-byte ASCII strings, and a naive `keyLen > 1` check
// forwards them straight into input fields as literal text.
//
// The reliable discriminator: multi-byte UTF-8 has the high bit
// set on its first byte (lead byte ≥ 0xC0; continuation bytes
// ≥ 0x80). Named keys are pure ASCII letters → first byte < 0x80.
// So forward when:
// • keyLen == 1 and the byte is a printable ASCII char, OR
// • keyLen > 1 and the first byte is ≥ 0x80 (true UTF-8).
// The onRawKeyDown event above already carries the key code for
// control-key consumers (InputField_OnKey handles Backspace/etc
// through that channel).
const auto first = static_cast<unsigned char>(keyPtr[0]);
if (keyLen == 1 && first >= 0x20 && first != 0x7F) {
2026-05-18 02:07:48 +02:00
g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast<std::size_t>(keyLen)));
2026-05-26 22:50:49 +02:00
} else if (keyLen > 1 && first >= 0x80) {
// Real multi-byte UTF-8 (non-ASCII printable). Forward as-is —
2026-05-18 02:07:48 +02:00
// dom-env.js always sends valid UTF-8.
g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast<std::size_t>(keyLen)));
}
}
__attribute__((export_name("__crafterDom_keyUp")))
void __crafterDom_keyUp(std::int32_t /*handle*/, const char* codePtr, std::int32_t codeLen) {
if (!g_domWindow) return;
KeyCode code = HashKeyCode(codePtr, static_cast<std::size_t>(codeLen));
g_domWindow->heldKeys.erase(code);
g_domWindow->onRawKeyUp.Invoke(code);
}
__attribute__((export_name("__crafterDom_resize")))
void __crafterDom_resize(std::int32_t /*handle*/, std::int32_t newW, std::int32_t newH) {
if (!g_domWindow) return;
g_domWindow->Resize(static_cast<std::uint32_t>(newW),
static_cast<std::uint32_t>(newH));
}
__attribute__((export_name("__crafterDom_close")))
void __crafterDom_close(std::int32_t /*handle*/) {
if (!g_domWindow) return;
g_domWindow->open = false;
g_domWindow->onClose.Invoke();
}
}
#endif // CRAFTER_GRAPHICS_WINDOW_DOM