/* Crafter®.Graphics Copyright (C) 2026 Catcrafts® catcrafts.net This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3.0 as published by the Free Software Foundation; This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ module; #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #include #include #include #include #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 #include #include #include #include #include #include #include #include #include #include #include #include #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #include #include #endif #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #include "vulkan/vulkan.h" #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #include "vulkan/vulkan_wayland.h" #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #include "vulkan/vulkan_win32.h" #endif #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #define STB_IMAGE_WRITE_IMPLEMENTATION #include "../lib/stb_image_write.h" #endif module Crafter.Graphics:Window_impl; import :Window; import :Device; import :Gamepad; // 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. import :VulkanTransition; import :DescriptorHeapVulkan; import :RenderPass; #ifdef CRAFTER_GRAPHICS_WINDOW_DOM import :WebGPU; import :DescriptorHeapWebGPU; #endif import std; using namespace Crafter; #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #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; } } 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 // 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); } // 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(lParam); window = static_cast(pCreate->lpCreateParams); SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast(window)); return TRUE; } else { window = reinterpret_cast( GetWindowLongPtr(hwnd, GWLP_USERDATA) ); } switch (msg) { case WM_DESTROY:{ PostQuitMessage(0); break; } 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; } case WM_KEYDOWN: case WM_SYSKEYDOWN: { // SYSKEYDOWN catches Alt combos, F10, etc. KeyCode code = KeyCodeFromLParam(lParam); bool isRepeat = (lParam & (1 << 30)) != 0; if (isRepeat) { window->onRawKeyHold.Invoke(code); } else { window->heldKeys.insert(code); window->onRawKeyDown.Invoke(code); } break; } case WM_KEYUP: case WM_SYSKEYUP: { KeyCode code = KeyCodeFromLParam(lParam); window->heldKeys.erase(code); window->onRawKeyUp.Invoke(code); break; } 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)); } 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_SETCURSOR: { if (LOWORD(lParam) == HTCLIENT && window->cursorHandle) { SetCursor(window->cursorHandle); return TRUE; } break; } default: return DefWindowProc(hwnd, msg, wParam, lParam); } return 0; } #endif Window::Window(std::uint32_t width, std::uint32_t height, const std::string_view title) : Window(width, height) { SetTitle(title); } 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(width), static_cast(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 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 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; 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)); VkSemaphoreCreateInfo semaphoreCreateInfo {}; semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.presentComplete)); Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &semaphores.renderComplete)); // Set up submit info structure // Semaphores will stay the same during application lifetime // Command buffer submission info is set by each example submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; submitInfo.pWaitDstStageMask = &submitPipelineStages; submitInfo.waitSemaphoreCount = 1; submitInfo.pWaitSemaphores = &semaphores.presentComplete; submitInfo.signalSemaphoreCount = 1; submitInfo.pSignalSemaphores = &semaphores.renderComplete; submitInfo.pNext = VK_NULL_HANDLE; lastMousePos = {0,0}; mouseDelta = {0,0}; currentMousePos = {0,0}; } 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)); #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND if (wpViewport) { wp_viewport_set_destination(wpViewport, static_cast(std::ceil(width / scale)), static_cast(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. CreateSwapchain(); } void Window::SetTitle(const std::string_view title) { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND xdg_toplevel_set_title(xdgToplevel, title.data()); #endif } void Window::SetCursorImage(std::uint16_t width, std::uint16_t height, std::uint16_t hotspotX, std::uint16_t hotspotY, const std::uint8_t* pixels) { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND if (width == 0 || height == 0 || pixels == nullptr) { SetDefaultCursor(); return; } if (cursorSurface == nullptr) { cursorSurface = wl_compositor_create_surface(Device::compositor); } 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(size)) { // size unchanged — keep the buffer and mmap. } else { 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(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(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((b * a) / 255); cursorMmap_[i * 4 + 1] = static_cast((g * a) / 255); cursorMmap_[i * 4 + 2] = static_cast((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) { wl_buffer_destroy(cursorWlBuffer); cursorWlBuffer = nullptr; } if (cursorSurface) { wl_surface_destroy(cursorSurface); cursorSurface = nullptr; } 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); } #endif } void Window::StartSync() { #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND while (open && wl_display_dispatch(Device::display) != -1) { Gamepad::Tick(); onBeforeUpdate.Invoke(); } #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 while(open) { MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } Gamepad::Tick(); onBeforeUpdate.Invoke(); 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; } std::chrono::time_point startTime; void Window::Update() { startTime = std::chrono::high_resolution_clock::now(); #ifdef CRAFTER_TIMING vblank = duration_cast(startTime - frameEnd); #endif mouseDelta = {currentMousePos.x-lastMousePos.x, currentMousePos.y-lastMousePos.y}; currentFrameTime = {startTime, startTime-lastFrameBegin}; #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 lastMousePos = currentMousePos; #ifdef CRAFTER_TIMING frameEnd = std::chrono::high_resolution_clock::now(); frameTimes.push_back(totalUpdate+totalRender); // Keep only the last 100 frame times if (frameTimes.size() > 100) { frameTimes.erase(frameTimes.begin()); } #endif lastFrameBegin = startTime; } void Window::Render() { // Acquire the next image from the swap chain. If the surface has // changed size out from under us (compositor/Win32 resize delivered // between Render calls), recreate and retry once. { VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer); if (acquire == VK_ERROR_OUT_OF_DATE_KHR) { Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); RecreateSwapchainAndImages(); onResize.Invoke(); acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX, semaphores.presentComplete, (VkFence)nullptr, ¤tBuffer); } if (acquire != VK_SUBOPTIMAL_KHR) { Device::CheckVkResult(acquire); } } submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer]; VkCommandBufferBeginInfo cmdBufInfo {}; cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; 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; // 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; VkImageMemoryBarrier image_memory_barrier { .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .srcAccessMask = 0, .dstAccessMask = VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT, .oldLayout = firstUse ? VK_IMAGE_LAYOUT_UNDEFINED : VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, .newLayout = VK_IMAGE_LAYOUT_GENERAL, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .image = images[currentBuffer], .subresourceRange = range }; vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 1, &image_memory_barrier); // Synthesise key-repeat events before listeners run, so the focused // widget's OnTextInput / OnKeyDown sees them in the same frame. Device::TickKeyRepeats(); // 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. if (descriptorHeap) { VkBindHeapInfoEXT resourceHeapInfo = { .sType = VK_STRUCTURE_TYPE_BIND_HEAP_INFO_EXT, .heapRange = { .address = descriptorHeap->resourceHeap[currentBuffer].address, .size = static_cast(descriptorHeap->resourceHeap[currentBuffer].size) }, .reservedRangeOffset = (descriptorHeap->resourceHeap[currentBuffer].size - Device::descriptorHeapProperties.minResourceHeapReservedRange) & ~(Device::descriptorHeapProperties.imageDescriptorAlignment - 1), .reservedRangeSize = Device::descriptorHeapProperties.minResourceHeapReservedRange }; Device::vkCmdBindResourceHeapEXT(drawCmdBuffers[currentBuffer], &resourceHeapInfo); VkBindHeapInfoEXT samplerHeapInfo = { .sType = VK_STRUCTURE_TYPE_BIND_HEAP_INFO_EXT, .heapRange = { .address = descriptorHeap->samplerHeap[currentBuffer].address, .size = static_cast(descriptorHeap->samplerHeap[currentBuffer].size) }, .reservedRangeOffset = descriptorHeap->samplerHeap[currentBuffer].size - Device::descriptorHeapProperties.minSamplerHeapReservedRange, .reservedRangeSize = Device::descriptorHeapProperties.minSamplerHeapReservedRange }; Device::vkCmdBindSamplerHeapEXT(drawCmdBuffers[currentBuffer], &samplerHeapInfo); } onUpdate.Invoke({startTime, startTime-lastFrameBegin}); #ifdef CRAFTER_TIMING totalUpdate = std::chrono::nanoseconds(0); updateTimings.clear(); for (const std::pair*, std::chrono::nanoseconds>& entry : onUpdate.listenerTimes) { updateTimings.push_back(entry); totalUpdate += entry.second; } #endif // 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()) { VkMemoryBarrier mb { .sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER, .srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, }; vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 1, &mb, 0, nullptr, 0, nullptr); } } VkImageMemoryBarrier image_memory_barrier2 { .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 = images[currentBuffer], .subresourceRange = range }; vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &image_memory_barrier2); Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer])); Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE)); VkPresentInfoKHR presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.pNext = NULL; presentInfo.swapchainCount = 1; presentInfo.pSwapchains = &swapChain; presentInfo.pImageIndices = ¤tBuffer; // Check if a wait semaphore has been specified to wait for before presenting the image if (semaphores.renderComplete != VK_NULL_HANDLE) { presentInfo.pWaitSemaphores = &semaphores.renderComplete; presentInfo.waitSemaphoreCount = 1; } VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo); if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) { // Surface size changed mid-present. Drain the queue, rebuild the // swapchain, and let dependents (descriptors holding old image // handles) re-bind via onResize before the next frame. Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); RecreateSwapchainAndImages(); onResize.Invoke(); } else { Device::CheckVkResult(result); } Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); } #ifdef CRAFTER_TIMING void Window::LogTiming() { std::cout << std::format("Update: {}", duration_cast(totalUpdate)) << std::endl; for (const std::pair*, std::chrono::nanoseconds>& entry : updateTimings) { std::cout << std::format("\t{} {}", reinterpret_cast(entry.first), duration_cast(entry.second)) << std::endl; } std::cout << std::format("Render: {}", duration_cast(totalRender)) << std::endl; for (const std::tuple& entry : renderer.renderTimings) { std::cout << std::format("\t{} {}x{} {}", reinterpret_cast(std::get<0>(entry)), std::get<1>(entry), std::get<2>(entry), duration_cast(std::get<3>(entry))) << std::endl; } std::cout << std::format("Total: {}", duration_cast(totalUpdate+totalRender)) << std::endl; std::cout << std::format("Vblank: {}", duration_cast(vblank)) << std::endl; // Add 100-frame average and min-max timing info if (!frameTimes.empty()) { // Calculate average std::chrono::nanoseconds sum(0); for (const auto& frameTime : frameTimes) { sum += frameTime; } auto average = sum / frameTimes.size(); // Find min and max auto min = frameTimes.front(); auto max = frameTimes.front(); for (const auto& frameTime : frameTimes) { if (frameTime < min) min = frameTime; if (frameTime > max) max = frameTime; } std::cout << std::format("Last 100 Frame Times - Avg: {}, Min: {}, Max: {}", duration_cast(average), duration_cast(min), duration_cast(max)) << std::endl; } } #endif void Window::CreateSwapchain() { // Store the current swap chain handle so we can use it later on to ease up recreation VkSwapchainKHR oldSwapchain = swapChain; // Get physical device surface properties and formats VkSurfaceCapabilitiesKHR surfCaps; Device::CheckVkResult(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(Device::physDevice, vulkanSurface, &surfCaps)); 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; } // Select a present mode for the swapchain uint32_t presentModeCount; Device::CheckVkResult(vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, NULL)); assert(presentModeCount > 0); std::vector presentModes(presentModeCount); Device::CheckVkResult(vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, presentModes.data())); // The VK_PRESENT_MODE_FIFO_KHR mode must always be present as per spec // This mode waits for the vertical blank ("v-sync") VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR; // Find the transformation of the surface VkSurfaceTransformFlagsKHR preTransform; if (surfCaps.supportedTransforms & VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR) { // We prefer a non-rotated transform preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR; } else { preTransform = surfCaps.currentTransform; } // Find a supported composite alpha format (not all devices support alpha opaque) VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; // Simply select the first composite alpha format available std::vector compositeAlphaFlags = { VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR, VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR, VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR, VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR, }; for (auto& compositeAlphaFlag : compositeAlphaFlags) { if (surfCaps.supportedCompositeAlpha & compositeAlphaFlag) { compositeAlpha = compositeAlphaFlag; break; }; } VkSwapchainCreateInfoKHR swapchainCI = {}; swapchainCI.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; swapchainCI.surface = vulkanSurface; swapchainCI.minImageCount = numFrames; swapchainCI.imageFormat = colorFormat; swapchainCI.imageColorSpace = colorSpace; swapchainCI.imageExtent = { swapchainExtent.width, swapchainExtent.height }; swapchainCI.imageUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; swapchainCI.preTransform = (VkSurfaceTransformFlagBitsKHR)preTransform; swapchainCI.imageArrayLayers = 1; swapchainCI.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; swapchainCI.queueFamilyIndexCount = 0; swapchainCI.presentMode = swapchainPresentMode; // 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); 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, }, }; } } VkCommandBuffer Window::StartInit() { VkCommandBufferBeginInfo cmdBufInfo {}; cmdBufInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; 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.). 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; 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)); } #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(data); if(window->updating) { cb = wl_surface_frame(window->surface); wl_callback_add_listener(cb, &Window::wl_callback_listener, window); window->Update(); } else { cb = nullptr; } } 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(data); window->pendingLogicalWidth = width; window->pendingLogicalHeight = height; } void Window::xdg_toplevel_handle_close(void* data, xdg_toplevel*) { Window* window = reinterpret_cast(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(data); // The compositor configures our surface, acknowledge the configure event xdg_surface_ack_configure(xdg_surface, serial); if (window->configured) { // 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::ceil(window->pendingLogicalWidth * window->scale)); std::uint32_t newHeight = static_cast( std::ceil(window->pendingLogicalHeight * window->scale)); window->Resize(newWidth, newHeight); } 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(data); window->scale = scale / 120.0f; } #endif void Window::SaveFrame(const std::filesystem::path& path) { // Staging buffer big enough for one RGBA frame. VkDeviceSize bufSize = static_cast(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); VkMemoryAllocateInfo mai{ .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .allocationSize = memReqs.size, .memoryTypeIndex = Device::GetMemoryType(memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT), }; 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, ®ion); 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)); const std::uint8_t* src = static_cast(mapped); std::vector rgba(static_cast(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(width), static_cast(height), 4, rgba.data(), static_cast(width) * 4); vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd); vkDestroyBuffer(Device::device, stagingBuf, nullptr); vkFreeMemory(Device::device, stagingMem, nullptr); } #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(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; // 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(Crafter::DomEnv::domGetInnerWidth()); height = static_cast(Crafter::DomEnv::domGetInnerHeight()); // 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(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() { // 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. Crafter::DomEnv::domStartFrameLoop(); std::_Exit(0); } 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() { 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. } // ─── 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(); g_domWindow->Render(); } } __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(x), static_cast(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 deltaY) { if (!g_domWindow) return; // Window::onMouseScroll is uint32 — preserve sign via two's complement. g_domWindow->onMouseScroll.Invoke(static_cast(static_cast(deltaY))); } __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(codeLen)); if (repeat) { g_domWindow->onRawKeyHold.Invoke(code); } else { g_domWindow->heldKeys.insert(code); g_domWindow->onRawKeyDown.Invoke(code); } // 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(keyPtr[0]); if (keyLen == 1 && first >= 0x20 && first != 0x7F) { g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast(keyLen))); } else if (keyLen > 1 && first >= 0x80) { // Real multi-byte UTF-8 (non-ASCII printable). Forward as-is — // dom-env.js always sends valid UTF-8. g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast(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(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(newW), static_cast(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