Win32
- WM_MOUSEMOVE is handled again: c9ebd44 put the WM_CHAR case where it
was, so currentMousePos stopped following the mouse and onMouseMove
never fired. Positions are read signed (GET_X_LPARAM), since under
capture they run past the client area.
- onMouseEnter and onMouseLeave. The first move over the client area
after a leave is the enter, and arms the TME_LEAVE whose WM_MOUSELEAVE
is the leave. An enter counts only once that is armed, a leave only
after an enter, and a move past the client area is a move only.
- A press captures the mouse, so a drag that leaves the window still
sends its moves and its release, as Wayland's implicit grab does.
Losing the capture releases what is held, and a button-up without its
press fires nothing.
- WM_CLOSE fires onClose and clears open, as xdg_toplevel.close does,
instead of destroying the HWND under the swapchain. WM_DESTROY no
longer posts WM_QUIT, which nothing read.
- ~Window detaches the HWND and destroys it. It stayed on screen, and its
messages went to the freed Window. WndProc hands messages without a
Window to DefWindowProc, and looks the Window up again after an event
that more work follows, in case a listener destroyed it.
- The constructor's message pump leaves input queued for StartSync: the
WM_MOUSEMOVE sent when a window appears under the cursor made an
onMouseEnter nobody could hear yet. The mouse state is reset before
the platform code, no longer after it.
- A window after the first finds its class registered, which showed an
error box.
- The wheel remainder is the window's, no longer a function static that
every window shared, and a leave drops it.
Window
- PointerEnter, PointerMove, PointerLeave and PointerScroll hold the rules
both native backends report the pointer by. An enter puts the entry
position in currentMousePos before onMouseEnter fires, and is followed
by a move, on Wayland too now. scrollRemainder replaces
Device::scrollDetentRemainder, and mouseInside is shared.
Tests
- PointerEnterLeave: enter, move and leave through the wl_pointer
callbacks.
- PointerWin32: posts a real HWND the messages above and pumps them as
StartSync does. Built for x86_64-w64-mingw32 it runs under Wine, and is
skipped where Wine is not on PATH.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2105 lines
No EOL
91 KiB
C++
2105 lines
No EOL
91 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
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 "../lib/text-input-unstable-v3-client-protocol.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>
|
|
#include <poll.h>
|
|
#endif
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
|
|
#include <windows.h>
|
|
#include <windowsx.h>
|
|
#include <cassert>
|
|
#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";
|
|
|
|
// The Window an HWND belongs to, or nullptr once ~Window has detached it.
|
|
// Looked up again after an Invoke that more work follows: a listener may
|
|
// have destroyed the Window.
|
|
static Window* WindowOf(HWND hwnd) {
|
|
return reinterpret_cast<Window*>(GetWindowLongPtr(hwnd, GWLP_USERDATA));
|
|
}
|
|
|
|
// Window procedure function that processes messages
|
|
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
|
|
|
if (msg == WM_NCCREATE)
|
|
{
|
|
CREATESTRUCT* pCreate = reinterpret_cast<CREATESTRUCT*>(lParam);
|
|
Window* window = static_cast<Window*>(pCreate->lpCreateParams);
|
|
|
|
SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
|
|
|
|
return TRUE;
|
|
}
|
|
|
|
// Messages before WM_NCCREATE, and those DestroyWindow sends from
|
|
// ~Window, have no Window to go to.
|
|
Window* window = WindowOf(hwnd);
|
|
if (window == nullptr) {
|
|
return DefWindowProc(hwnd, msg, wParam, lParam);
|
|
}
|
|
|
|
switch (msg) {
|
|
case WM_CLOSE: {
|
|
// A request, as xdg_toplevel.close is: StartSync's loop ends
|
|
// and the HWND lives on until ~Window destroys it, so the
|
|
// swapchain never presents to a window that is gone.
|
|
// DefWindowProc would destroy it here.
|
|
window->onClose.Invoke();
|
|
window = WindowOf(hwnd);
|
|
if (window != nullptr) {
|
|
window->open = false;
|
|
}
|
|
return 0;
|
|
}
|
|
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_MOUSEMOVE: {
|
|
// Signed: under capture, a drag that has left the window keeps
|
|
// reporting positions past the client area.
|
|
const POINT point{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
|
|
const Vector<float, 2> position(static_cast<float>(point.x), static_cast<float>(point.y));
|
|
// Win32 has no enter message: the first move over the client
|
|
// area after a leave is the enter. TME_LEAVE is one-shot, so it
|
|
// is armed again for each enter, and the enter only counts once
|
|
// it is armed, or its leave would never come.
|
|
RECT client;
|
|
if (!window->mouseInside && GetClientRect(hwnd, &client) && PtInRect(&client, point)) {
|
|
TRACKMOUSEEVENT track{sizeof(track), TME_LEAVE, hwnd, 0};
|
|
if (TrackMouseEvent(&track)) {
|
|
window->PointerEnter(position);
|
|
window = WindowOf(hwnd);
|
|
if (window == nullptr) break;
|
|
}
|
|
}
|
|
window->PointerMove(position);
|
|
break;
|
|
}
|
|
case WM_MOUSELEAVE: {
|
|
// Also sent when the pointer moves onto the non-client area
|
|
// (title bar, borders) — it has left the drawable surface.
|
|
window->PointerLeave();
|
|
break;
|
|
}
|
|
// A press captures the mouse, so a drag that leaves the window
|
|
// still sends its moves and its release here, as Wayland's
|
|
// implicit grab does. The capture goes when no button is held.
|
|
case WM_LBUTTONDOWN: {
|
|
SetCapture(hwnd);
|
|
window->mouseLeftHeld = true;
|
|
window->onMouseLeftClick.Invoke();
|
|
break;
|
|
}
|
|
|
|
case WM_LBUTTONUP: {
|
|
// Not held: the press went to another window, or losing the
|
|
// capture has released it already.
|
|
if (!window->mouseLeftHeld) break;
|
|
window->mouseLeftHeld = false;
|
|
if (!window->mouseRightHeld && GetCapture() == hwnd) ReleaseCapture();
|
|
window->onMouseLeftRelease.Invoke();
|
|
break;
|
|
}
|
|
|
|
case WM_RBUTTONDOWN: {
|
|
SetCapture(hwnd);
|
|
window->mouseRightHeld = true;
|
|
window->onMouseRightClick.Invoke();
|
|
break;
|
|
}
|
|
|
|
case WM_RBUTTONUP: {
|
|
if (!window->mouseRightHeld) break;
|
|
window->mouseRightHeld = false;
|
|
if (!window->mouseLeftHeld && GetCapture() == hwnd) ReleaseCapture();
|
|
window->onMouseRightRelease.Invoke();
|
|
break;
|
|
}
|
|
|
|
case WM_CAPTURECHANGED: {
|
|
// The capture was taken mid-drag (another window took it, the
|
|
// window lost activation), so the release will not come here:
|
|
// release what is still held. The ReleaseCapture above lands
|
|
// here with nothing held.
|
|
if (reinterpret_cast<HWND>(lParam) == hwnd) break;
|
|
if (window->mouseLeftHeld) {
|
|
window->mouseLeftHeld = false;
|
|
window->onMouseLeftRelease.Invoke();
|
|
window = WindowOf(hwnd);
|
|
if (window == nullptr) break;
|
|
}
|
|
if (window->mouseRightHeld) {
|
|
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 detents with +1 = wheel down
|
|
// (the DOM deltaY / Wayland axis sign), so negate. Free-spinning
|
|
// wheels report sub-WHEEL_DELTA steps, which PointerScroll adds
|
|
// up instead of each truncating to zero.
|
|
window->PointerScroll(-GET_WHEEL_DELTA_WPARAM(wParam) / static_cast<double>(WHEEL_DELTA));
|
|
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) {
|
|
// Before the platform code below, which may report the pointer.
|
|
lastMousePos = {0,0};
|
|
mouseDelta = {0,0};
|
|
currentMousePos = {0,0};
|
|
|
|
#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);
|
|
|
|
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);
|
|
|
|
// The first window registers the class; every later one finds it there.
|
|
if (!RegisterClass(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) {
|
|
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 = 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);
|
|
|
|
// Input stays queued for StartSync. Dispatched here, it would reach the
|
|
// window before anyone could listen: the WM_MOUSEMOVE Windows sends
|
|
// when a window appears under the cursor would make an onMouseEnter
|
|
// nobody hears, and later an onMouseLeave without one.
|
|
MSG msg;
|
|
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE | PM_QS_POSTMESSAGE | PM_QS_PAINT | PM_QS_SENDMESSAGE)) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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));
|
|
|
|
// Per-frame-in-flight synchronisation objects (issue #40). The fences are
|
|
// created signaled so the first wait on each image's fence — before that
|
|
// image has ever been submitted — returns immediately instead of
|
|
// deadlocking. The submitInfo is rebuilt per frame in Render() now that
|
|
// the wait/signal semaphores vary by frame.
|
|
VkSemaphoreCreateInfo semaphoreCreateInfo {};
|
|
semaphoreCreateInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
|
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]));
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
|
|
// 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,
|
|
.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,
|
|
};
|
|
|
|
// 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 = ¤tBuffer;
|
|
presentInfo.waitSemaphoreCount = 1;
|
|
}
|
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
Window::~Window() {
|
|
// Nothing may still run against the semaphores, fences or images below.
|
|
if (Device::device != VK_NULL_HANDLE) vkDeviceWaitIdle(Device::device);
|
|
if (Device::device != VK_NULL_HANDLE) {
|
|
if (drawCmdBuffers[0] != VK_NULL_HANDLE) vkFreeCommandBuffers(Device::device, Device::commandPool, numFrames, drawCmdBuffers);
|
|
for (std::uint8_t i = 0; i < numFrames; i++) {
|
|
if (renderFinishedSemaphores[i]) vkDestroySemaphore(Device::device, renderFinishedSemaphores[i], nullptr);
|
|
if (waitFences[i]) vkDestroyFence(Device::device, waitFences[i], nullptr);
|
|
}
|
|
for (std::uint8_t i = 0; i < numAcquireSemaphores; i++) {
|
|
if (imageAcquiredSemaphores[i]) vkDestroySemaphore(Device::device, imageAcquiredSemaphores[i], nullptr);
|
|
}
|
|
if (swapChain != VK_NULL_HANDLE) vkDestroySwapchainKHR(Device::device, swapChain, nullptr);
|
|
swapChain = VK_NULL_HANDLE;
|
|
}
|
|
if (vulkanSurface != VK_NULL_HANDLE) vkDestroySurfaceKHR(Device::instance, vulkanSurface, nullptr);
|
|
vulkanSurface = VK_NULL_HANDLE;
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
SetDefaultCursor();
|
|
// A pending frame callback would still dispatch into this window;
|
|
// destroying the proxy makes libwayland drop its event.
|
|
if (cb) wl_callback_destroy(cb);
|
|
cb = nullptr;
|
|
if (wpViewport) wp_viewport_destroy(wpViewport);
|
|
if (wp_scale) {
|
|
wp_fractional_scale_v1_destroy(wp_scale);
|
|
wp_scale = nullptr;
|
|
}
|
|
if (decoration) zxdg_toplevel_decoration_v1_destroy(decoration);
|
|
if (xdgToplevel) xdg_toplevel_destroy(xdgToplevel);
|
|
if (xdgSurface) xdg_surface_destroy(xdgSurface);
|
|
if (surface) wl_surface_destroy(surface);
|
|
wpViewport = nullptr;
|
|
decoration = nullptr;
|
|
xdgToplevel = nullptr;
|
|
xdgSurface = nullptr;
|
|
surface = nullptr;
|
|
if (Device::display) wl_display_flush(Device::display);
|
|
std::erase(Device::windows, this);
|
|
if (Device::focusedWindow == this) Device::focusedWindow = nullptr;
|
|
if (Device::keyboardWindow == this) Device::keyboardWindow = nullptr;
|
|
if (Device::touchWindow == this) {
|
|
Device::touchWindow = nullptr;
|
|
Device::ForgetTouches();
|
|
}
|
|
if (Device::textInputWindow == this) Device::textInputWindow = nullptr;
|
|
#endif
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
|
|
// Detached first: what DestroyWindow sends (WM_CAPTURECHANGED,
|
|
// WM_DESTROY) and anything still queued for the HWND must not reach
|
|
// this Window.
|
|
if (hwnd != nullptr) {
|
|
SetWindowLongPtr(hwnd, GWLP_USERDATA, 0);
|
|
DestroyWindow(hwnd);
|
|
hwnd = nullptr;
|
|
}
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
void Window::PointerEnter(Vector<float, 2> position) {
|
|
currentMousePos = position;
|
|
if (mouseInside) return;
|
|
mouseInside = true;
|
|
onMouseEnter.Invoke();
|
|
}
|
|
|
|
void Window::PointerMove(Vector<float, 2> position) {
|
|
currentMousePos = position;
|
|
onMouseMove.Invoke();
|
|
}
|
|
|
|
void Window::PointerLeave() {
|
|
// A partial detent from before must not count towards the next scroll,
|
|
// after a leave without an enter too.
|
|
scrollRemainder = 0.0;
|
|
if (!mouseInside) return;
|
|
mouseInside = false;
|
|
onMouseLeave.Invoke();
|
|
}
|
|
|
|
void Window::PointerScroll(double detents) {
|
|
scrollRemainder += detents;
|
|
const double whole = std::trunc(scrollRemainder);
|
|
if (whole == 0.0) return;
|
|
scrollRemainder -= whole;
|
|
// Event payload is uint32 — preserve sign via two's complement.
|
|
onMouseScroll.Invoke(static_cast<std::uint32_t>(static_cast<std::int32_t>(whole)));
|
|
}
|
|
|
|
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();
|
|
|
|
#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.
|
|
CreateSwapchain();
|
|
}
|
|
|
|
void Window::SetPresentMode(PresentMode mode) {
|
|
presentMode = mode;
|
|
presentModeUnfit = false;
|
|
// A window without a swapchain yet picks the mode up when it makes one.
|
|
if (swapChain == VK_NULL_HANDLE) return;
|
|
// The mode is fixed when a swapchain is made, so make a new one, as
|
|
// Resize does: nothing in flight may still use the old images.
|
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
|
Device::DrainDeletions();
|
|
RecreateSwapchainAndImages();
|
|
onResize.Invoke();
|
|
}
|
|
|
|
void Window::SetTitle(const std::string_view title) {
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
xdg_toplevel_set_title(xdgToplevel, title.data());
|
|
#endif
|
|
}
|
|
|
|
void Window::SetAppId(const std::string_view id) {
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
xdg_toplevel_set_app_id(xdgToplevel, std::string(id).c_str());
|
|
#endif
|
|
}
|
|
|
|
void Window::SetTextInput(bool active, std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, TextInputPurpose purpose) {
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
textInputActive_ = active;
|
|
textInputRect_[0] = x;
|
|
textInputRect_[1] = y;
|
|
textInputRect_[2] = width;
|
|
textInputRect_[3] = height;
|
|
textInputPurpose_ = purpose;
|
|
Device::ApplyTextInput(this);
|
|
#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<std::uint32_t>(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<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) {
|
|
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
|
|
}
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
namespace {
|
|
// Milliseconds until the first window that updates on its own clock is
|
|
// due, rounded up so the wait doesn't end just short of it; -1 when no
|
|
// window does, so the loop waits for events alone.
|
|
int MillisecondsToNextClockedUpdate() {
|
|
const auto now = std::chrono::high_resolution_clock::now();
|
|
std::optional<std::chrono::high_resolution_clock::duration> soonest;
|
|
for (const Window* window : Device::windows) {
|
|
if (!window->updating || window->updateInterval <= std::chrono::nanoseconds::zero()) continue;
|
|
const auto wait = window->lastFrameBegin + window->updateInterval - now;
|
|
if (!soonest || wait < *soonest) soonest = wait;
|
|
}
|
|
if (!soonest) return -1;
|
|
if (*soonest <= std::chrono::high_resolution_clock::duration::zero()) return 0;
|
|
return static_cast<int>(std::chrono::ceil<std::chrono::milliseconds>(*soonest).count());
|
|
}
|
|
}
|
|
#endif
|
|
|
|
void Window::StartSync() {
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
// libwayland's read sequence, as wl_display_dispatch runs it, but the
|
|
// poll also watches the descriptors windows registered with AddPollFd,
|
|
// and times out when a window that updates on its own clock is due.
|
|
std::vector<pollfd> fds;
|
|
std::vector<int> ready;
|
|
bool flushPending = false;
|
|
while (open) {
|
|
while (wl_display_prepare_read(Device::display) != 0) {
|
|
if (wl_display_dispatch_pending(Device::display) == -1) return;
|
|
}
|
|
// A full socket (EAGAIN) is flushed again once poll says it is
|
|
// writable. EPIPE goes on to the read, which reports the
|
|
// compositor's error.
|
|
flushPending = false;
|
|
if (wl_display_flush(Device::display) == -1) {
|
|
if (errno == EAGAIN) {
|
|
flushPending = true;
|
|
} else if (errno != EPIPE) {
|
|
wl_display_cancel_read(Device::display);
|
|
return;
|
|
}
|
|
}
|
|
|
|
fds.clear();
|
|
fds.push_back({ .fd = wl_display_get_fd(Device::display), .events = static_cast<short>(POLLIN | (flushPending ? POLLOUT : 0)), .revents = 0 });
|
|
for (const Window* window : Device::windows) {
|
|
for (const PollFd& entry : window->pollFds) {
|
|
fds.push_back({ .fd = entry.fd, .events = POLLIN, .revents = 0 });
|
|
}
|
|
}
|
|
if (poll(fds.data(), fds.size(), MillisecondsToNextClockedUpdate()) == -1) {
|
|
wl_display_cancel_read(Device::display);
|
|
if (errno == EINTR) continue;
|
|
return;
|
|
}
|
|
if (fds[0].revents & (POLLIN | POLLERR | POLLHUP)) {
|
|
if (wl_display_read_events(Device::display) == -1) return;
|
|
} else {
|
|
wl_display_cancel_read(Device::display);
|
|
}
|
|
// Frame callbacks run Update here for windows on the compositor's clock.
|
|
if (wl_display_dispatch_pending(Device::display) == -1) return;
|
|
|
|
// A callback may add or remove descriptors, so each ready one is
|
|
// looked up again, and its callback copied, before it is called.
|
|
ready.clear();
|
|
for (std::size_t i = 1; i < fds.size(); ++i) {
|
|
if (fds[i].revents != 0) ready.push_back(fds[i].fd);
|
|
}
|
|
for (int fd : ready) {
|
|
for (const Window* window : Device::windows) {
|
|
auto entry = std::ranges::find(window->pollFds, fd, &PollFd::fd);
|
|
if (entry != window->pollFds.end()) {
|
|
std::function<void()> onReadable = entry->onReadable;
|
|
onReadable();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Gamepad::Tick();
|
|
onBeforeUpdate.Invoke();
|
|
|
|
// By index: an Update may open or close a window.
|
|
const auto now = std::chrono::high_resolution_clock::now();
|
|
for (std::size_t i = 0; i < Device::windows.size(); ++i) {
|
|
Window* window = Device::windows[i];
|
|
if (window->updating && window->updateInterval > std::chrono::nanoseconds::zero()
|
|
&& now >= window->lastFrameBegin + window->updateInterval) {
|
|
window->Update();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
#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).
|
|
//
|
|
// A window on its own clock (updateInterval) sleeps the same way until
|
|
// its next update is due, instead of updating every pass.
|
|
constexpr DWORD kPausedPollIntervalMs = 16;
|
|
while(open) {
|
|
// Read each pass, as the Wayland loop does: the clock may be set or
|
|
// changed from inside the loop.
|
|
const bool clocked = updateInterval > std::chrono::nanoseconds::zero();
|
|
if (!updating) {
|
|
MsgWaitForMultipleObjectsEx(0, nullptr, kPausedPollIntervalMs,
|
|
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
|
} else if (clocked) {
|
|
const auto wait = lastFrameBegin + updateInterval - std::chrono::high_resolution_clock::now();
|
|
if (wait > std::chrono::high_resolution_clock::duration::zero()) {
|
|
MsgWaitForMultipleObjectsEx(0, nullptr, static_cast<DWORD>(std::chrono::ceil<std::chrono::milliseconds>(wait).count()),
|
|
QS_ALLINPUT, MWMO_INPUTAVAILABLE);
|
|
}
|
|
}
|
|
MSG msg;
|
|
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
|
|
TranslateMessage(&msg);
|
|
DispatchMessage(&msg);
|
|
}
|
|
Gamepad::Tick();
|
|
onBeforeUpdate.Invoke();
|
|
if(updating && (!clocked || std::chrono::high_resolution_clock::now() >= lastFrameBegin + updateInterval)) {
|
|
Update();
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void Window::StayAlive() {
|
|
// Native has no equivalent of the browser's "return from main but keep
|
|
// the instance alive" trick — the process simply ends. The event loop
|
|
// IS what keeps a native window alive, so the only correct native
|
|
// behaviour is StartSync's loop. Delegating keeps a single source that
|
|
// compiles identically on both targets: an app can call StayAlive
|
|
// unconditionally and get the cheap path in the browser without a
|
|
// #ifdef at the call site.
|
|
StartSync();
|
|
}
|
|
|
|
void Window::StartUpdate() {
|
|
lastFrameBegin = std::chrono::high_resolution_clock::now();
|
|
updating = true;
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
// A window on its own clock is updated by StartSync's loop instead.
|
|
if (updateInterval == std::chrono::nanoseconds::zero()) {
|
|
cb = wl_surface_frame(surface);
|
|
wl_callback_add_listener(cb, &wl_callback_listener, this);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void Window::StopUpdate() {
|
|
updating = false;
|
|
}
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
void Window::AddPollFd(int fd, std::function<void()> onReadable) {
|
|
pollFds.push_back({ .fd = fd, .onReadable = std::move(onReadable) });
|
|
}
|
|
|
|
void Window::RemovePollFd(int fd) {
|
|
std::erase_if(pollFds, [fd](const PollFd& entry) { return entry.fd == fd; });
|
|
}
|
|
#endif
|
|
|
|
std::chrono::time_point<std::chrono::high_resolution_clock> startTime;
|
|
|
|
void Window::Update() {
|
|
startTime = std::chrono::high_resolution_clock::now();
|
|
#ifdef CRAFTER_TIMING
|
|
vblank = duration_cast<std::chrono::milliseconds>(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();
|
|
|
|
// 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;
|
|
}
|
|
#endif
|
|
lastFrameBegin = startTime;
|
|
}
|
|
|
|
namespace {
|
|
// A frame's readback for CaptureNextFrame: the buffer the swapchain image
|
|
// is copied into, recorded into the frame's own command buffer.
|
|
// It owns the buffer and its memory, which go through the deletion queue
|
|
// however the capture ends: written, or unwound past (a failed
|
|
// allocation, a lost device), as the frame may be copying into it.
|
|
struct FrameCapture {
|
|
VkBuffer buffer = VK_NULL_HANDLE;
|
|
VkDeviceMemory memory = VK_NULL_HANDLE;
|
|
VkMemoryPropertyFlags memoryFlags = 0;
|
|
std::uint32_t width = 0;
|
|
std::uint32_t height = 0;
|
|
// Where it goes: taken from Window::pendingCapture as the copy is
|
|
// recorded, so a capture asked for meanwhile (from onResize, say) is
|
|
// the next frame's.
|
|
std::filesystem::path path;
|
|
|
|
FrameCapture(std::uint32_t width, std::uint32_t height) : width(width), height(height) {}
|
|
FrameCapture(FrameCapture&& other) noexcept
|
|
: buffer(std::exchange(other.buffer, VK_NULL_HANDLE)), memory(std::exchange(other.memory, VK_NULL_HANDLE)),
|
|
memoryFlags(other.memoryFlags), width(other.width), height(other.height), path(std::move(other.path)) {}
|
|
FrameCapture& operator=(FrameCapture&&) = delete;
|
|
~FrameCapture() {
|
|
Device::EnqueueDeletion(buffer, memory);
|
|
}
|
|
};
|
|
|
|
// Records the copy of `image`, in GENERAL and last written at `writers`
|
|
// with `writerAccess`, into a new host-visible buffer.
|
|
FrameCapture RecordFrameCapture(VkCommandBuffer cmd, VkImage image, std::uint32_t width, std::uint32_t height, VkPipelineStageFlags writers, VkAccessFlags writerAccess) {
|
|
FrameCapture capture(width, height);
|
|
const VkBufferCreateInfo bufferInfo {
|
|
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
|
|
.size = static_cast<VkDeviceSize>(width) * height * 4,
|
|
.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT,
|
|
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
|
};
|
|
// Into a local first: a call that fails leaves its output undefined.
|
|
VkBuffer buffer = VK_NULL_HANDLE;
|
|
Device::CheckVkResult(vkCreateBuffer(Device::device, &bufferInfo, nullptr, &buffer));
|
|
capture.buffer = buffer;
|
|
VkMemoryRequirements requirements;
|
|
vkGetBufferMemoryRequirements(Device::device, capture.buffer, &requirements);
|
|
// The CPU reads all of it: cached memory where there is some, as
|
|
// write-combined memory is very slow to read (#59, #89).
|
|
std::uint32_t memoryType;
|
|
try {
|
|
memoryType = Device::GetMemoryType(requirements.memoryTypeBits,
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT, VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
|
} catch (const std::runtime_error&) {
|
|
memoryType = Device::GetMemoryType(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT);
|
|
}
|
|
capture.memoryFlags = Device::memoryProperties.memoryTypes[memoryType].propertyFlags;
|
|
const VkMemoryAllocateInfo allocation {
|
|
.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO,
|
|
.allocationSize = requirements.size,
|
|
.memoryTypeIndex = memoryType,
|
|
};
|
|
VkDeviceMemory memory = VK_NULL_HANDLE;
|
|
Device::CheckVkResult(vkAllocateMemory(Device::device, &allocation, nullptr, &memory));
|
|
capture.memory = memory;
|
|
Device::CheckVkResult(vkBindBufferMemory(Device::device, capture.buffer, capture.memory, 0));
|
|
|
|
// The passes' writes, before the copy reads them.
|
|
const VkImageMemoryBarrier written {
|
|
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
|
|
.srcAccessMask = writerAccess,
|
|
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT,
|
|
.oldLayout = VK_IMAGE_LAYOUT_GENERAL,
|
|
.newLayout = VK_IMAGE_LAYOUT_GENERAL,
|
|
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
|
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
|
.image = image,
|
|
.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 },
|
|
};
|
|
vkCmdPipelineBarrier(cmd, writers, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &written);
|
|
const VkBufferImageCopy region {
|
|
.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 },
|
|
.imageExtent = { width, height, 1 },
|
|
};
|
|
vkCmdCopyImageToBuffer(cmd, image, VK_IMAGE_LAYOUT_GENERAL, capture.buffer, 1, ®ion);
|
|
const VkBufferMemoryBarrier copied {
|
|
.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER,
|
|
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
.dstAccessMask = VK_ACCESS_HOST_READ_BIT,
|
|
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
|
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
|
.buffer = capture.buffer,
|
|
.offset = 0,
|
|
.size = VK_WHOLE_SIZE,
|
|
};
|
|
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &copied, 0, nullptr);
|
|
return capture;
|
|
}
|
|
|
|
// Once the frame has finished: the readback as an RGBA PNG at `path`.
|
|
void WriteFrameCapture(const FrameCapture& capture, VkFormat format) {
|
|
const std::filesystem::path& path = capture.path;
|
|
void* mapped = nullptr;
|
|
Device::CheckVkResult(vkMapMemory(Device::device, capture.memory, 0, VK_WHOLE_SIZE, 0, &mapped));
|
|
// Cached but not coherent: the copy's writes have to be pulled in.
|
|
if ((capture.memoryFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) {
|
|
const VkMappedMemoryRange range {
|
|
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
|
|
.memory = capture.memory,
|
|
.offset = 0,
|
|
.size = VK_WHOLE_SIZE,
|
|
};
|
|
Device::CheckVkResult(vkInvalidateMappedMemoryRanges(Device::device, 1, &range));
|
|
}
|
|
const std::uint8_t* src = static_cast<const std::uint8_t*>(mapped);
|
|
const std::size_t texels = static_cast<std::size_t>(capture.width) * capture.height;
|
|
std::vector<std::uint8_t> rgba(texels * 4);
|
|
const bool bgr = format == VK_FORMAT_B8G8R8A8_UNORM;
|
|
for (std::size_t i = 0; i < texels; ++i) {
|
|
rgba[i * 4 + 0] = src[i * 4 + (bgr ? 2 : 0)];
|
|
rgba[i * 4 + 1] = src[i * 4 + 1];
|
|
rgba[i * 4 + 2] = src[i * 4 + (bgr ? 0 : 2)];
|
|
rgba[i * 4 + 3] = src[i * 4 + 3];
|
|
}
|
|
vkUnmapMemory(Device::device, capture.memory);
|
|
if (stbi_write_png(path.string().c_str(), static_cast<int>(capture.width), static_cast<int>(capture.height), 4, rgba.data(), static_cast<int>(capture.width) * 4) == 0) {
|
|
throw std::runtime_error(std::format("CaptureNextFrame: could not write {}", path.string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
void Window::Render() {
|
|
// Acquire the next image from the swap chain. If the surface has
|
|
// changed size out from under us (compositor/Win32 resize delivered
|
|
// between Render calls), recreate and retry once.
|
|
// Pick this frame's acquire semaphore from a free-running CPU counter: the
|
|
// image index (currentBuffer) isn't known until acquire returns, so the
|
|
// acquire's signal semaphore can't be keyed by the image.
|
|
const std::uint32_t acquireSlot = static_cast<std::uint32_t>(frameCounter % numAcquireSemaphores);
|
|
VkSemaphore imageAcquired = imageAcquiredSemaphores[acquireSlot];
|
|
{
|
|
VkResult acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
|
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
|
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
|
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
|
Device::DrainDeletions();
|
|
RecreateSwapchainAndImages();
|
|
onResize.Invoke();
|
|
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
|
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
|
}
|
|
if (acquire != VK_SUBOPTIMAL_KHR) {
|
|
Device::CheckVkResult(acquire);
|
|
}
|
|
}
|
|
|
|
// 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]));
|
|
|
|
// 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();
|
|
|
|
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
|
|
|
|
// 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];
|
|
|
|
// 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()).
|
|
// Device::stageMask drops RAY_TRACING_SHADER on devices without ray
|
|
// tracing: the no-pass fallback and any un-narrowed pass report the
|
|
// conservative writer union, which names that stage, and a stage whose
|
|
// feature is not enabled is invalid in a barrier.
|
|
const VkPipelineStageFlags swapWriterStages = SwapchainStageUnion(passes) & Device::stageMask;
|
|
|
|
// 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);
|
|
|
|
// 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 && !Device::descriptorHeapSupported) {
|
|
// Sets mode: the heap object holds one bindless descriptor set per
|
|
// frame instead of heap buffers. Every compute pipeline loaded against
|
|
// this heap shares its pipeline layout, so one bind here stays valid
|
|
// across all of them, exactly like the heap bind below.
|
|
vkCmdBindDescriptorSets(drawCmdBuffers[currentBuffer], VK_PIPELINE_BIND_POINT_COMPUTE,
|
|
descriptorHeap->pipelineLayout, 0, 1,
|
|
&descriptorHeap->sets[currentBuffer], 0, nullptr);
|
|
} else 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]);
|
|
}
|
|
|
|
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
|
|
|
|
// The window doesn't clear the image: not every surface allows the
|
|
// swapchain TRANSFER_DST (swapchainUsage says whether this one does), so
|
|
// passes that need a background write one themselves, by a clear where
|
|
// the usage allows it or from a shader (UIScene's `background()`).
|
|
(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.
|
|
// Masked like swapWriterStages above: a pass that keeps the
|
|
// default stage reports a union that names RAY_TRACING_SHADER.
|
|
const VkPipelineStageFlags srcStages = passes[i]->SwapchainStage() & Device::stageMask;
|
|
const VkPipelineStageFlags dstStages = passes[i + 1]->SwapchainStage() & Device::stageMask;
|
|
VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer], srcStages, dstStages);
|
|
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], srcStages, dstStages,
|
|
0, 0, nullptr, 0, nullptr, 1, &ib);
|
|
}
|
|
}
|
|
|
|
presentBarrier.image = images[currentBuffer];
|
|
|
|
// 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);
|
|
|
|
// CaptureNextFrame: the copy goes here, while the image is still this
|
|
// frame's, and the transition to present waits for it.
|
|
std::optional<FrameCapture> capture;
|
|
VkPipelineStageFlags presentWaitStages = swapWriterStages;
|
|
if (pendingCapture) {
|
|
capture.emplace(RecordFrameCapture(drawCmdBuffers[currentBuffer], images[currentBuffer], width, height, swapWriterStages, presentBarrier.srcAccessMask));
|
|
presentWaitStages |= VK_PIPELINE_STAGE_TRANSFER_BIT;
|
|
capture->path = std::move(*pendingCapture);
|
|
pendingCapture.reset();
|
|
}
|
|
|
|
// 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], presentWaitStages, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &presentBarrier);
|
|
|
|
Device::CheckVkResult(vkEndCommandBuffer(drawCmdBuffers[currentBuffer]));
|
|
|
|
// Patch the per-frame fields of the cached submitInfo/presentInfo (the
|
|
// invariant fields were set once in the constructor): submit waits on this
|
|
// frame's acquire semaphore and signals this image's render-finished
|
|
// semaphore, present waits on that same render-finished semaphore. The
|
|
// fence (keyed by image index) lets a future Render() reuse this image's
|
|
// command buffer + heap slot safely. The semaphore handles live in member
|
|
// arrays, so taking their addresses here is stable for the call.
|
|
submitInfo.pWaitSemaphores = &imageAcquiredSemaphores[acquireSlot];
|
|
submitInfo.pSignalSemaphores = &renderFinishedSemaphores[currentBuffer];
|
|
submitInfo.pCommandBuffers = &drawCmdBuffers[currentBuffer];
|
|
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, waitFences[currentBuffer]));
|
|
|
|
presentInfo.pWaitSemaphores = &renderFinishedSemaphores[currentBuffer];
|
|
|
|
VkResult result = vkQueuePresentKHR(Device::queue, &presentInfo);
|
|
if (result == VK_SUBOPTIMAL_KHR || result == VK_ERROR_OUT_OF_DATE_KHR) {
|
|
// Surface size changed mid-present. Drain the queue (the only place we
|
|
// still wait-idle, alongside Resize() and the acquire OUT_OF_DATE
|
|
// path), rebuild the swapchain, and let dependents (descriptors
|
|
// holding old image handles) re-bind via onResize before the next
|
|
// frame. The wait-idle here also re-settles every per-frame fence to a
|
|
// signaled state, so the next Render()'s fence wait passes through.
|
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
|
Device::DrainDeletions();
|
|
RecreateSwapchainAndImages();
|
|
onResize.Invoke();
|
|
} else {
|
|
Device::CheckVkResult(result);
|
|
}
|
|
// 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++;
|
|
|
|
if (capture) {
|
|
// A capture is for tests and screenshots: waiting for the frame here
|
|
// is fine.
|
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
|
WriteFrameCapture(*capture, colorFormat);
|
|
}
|
|
}
|
|
|
|
#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;
|
|
for (const std::tuple<const RenderingElement*, std::uint32_t, std::uint32_t, std::chrono::nanoseconds>& entry : renderer.renderTimings) {
|
|
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;
|
|
}
|
|
std::cout << std::format("Total: {}", duration_cast<std::chrono::milliseconds>(totalUpdate+totalRender)) << std::endl;
|
|
std::cout << std::format("Vblank: {}", duration_cast<std::chrono::milliseconds>(vblank)) << std::endl;
|
|
|
|
// Add 100-frame average and min-max timing info. Only the first
|
|
// frameTimesCount entries of the ring buffer hold valid samples.
|
|
if (frameTimesCount != 0) {
|
|
// Calculate average
|
|
std::chrono::nanoseconds sum(0);
|
|
for (std::size_t i = 0; i < frameTimesCount; ++i) {
|
|
sum += frameTimes[i];
|
|
}
|
|
auto average = sum / frameTimesCount;
|
|
|
|
// 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];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
#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;
|
|
}
|
|
|
|
|
|
// 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 };
|
|
swapchainUsage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT
|
|
| (surfCaps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT);
|
|
swapchainCI.imageUsage = swapchainUsage;
|
|
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 fallback for a mode the surface lacks. For benchmarking we
|
|
// want frame pacing uncapped from the display refresh, so when
|
|
// CRAFTER_PRESENT_IMMEDIATE is set in the environment we prefer IMMEDIATE
|
|
// (then MAILBOX) whatever presentMode asks for.
|
|
{
|
|
std::uint32_t presentModeCount = 0;
|
|
vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, nullptr);
|
|
std::vector<VkPresentModeKHR> presentModes(presentModeCount);
|
|
vkGetPhysicalDeviceSurfacePresentModesKHR(Device::physDevice, vulkanSurface, &presentModeCount, presentModes.data());
|
|
auto offered = [&](VkPresentModeKHR mode) { return std::ranges::find(presentModes, mode) != presentModes.end(); };
|
|
swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
|
|
if (presentModeUnfit) {
|
|
// Stays FIFO; see below.
|
|
} else if (std::getenv("CRAFTER_PRESENT_IMMEDIATE")) {
|
|
if (offered(VK_PRESENT_MODE_IMMEDIATE_KHR)) swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
|
|
else if (offered(VK_PRESENT_MODE_MAILBOX_KHR)) swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR;
|
|
} else if (presentMode == PresentMode::Mailbox && offered(VK_PRESENT_MODE_MAILBOX_KHR)) {
|
|
swapchainPresentMode = VK_PRESENT_MODE_MAILBOX_KHR;
|
|
} else if (presentMode == PresentMode::Immediate && offered(VK_PRESENT_MODE_IMMEDIATE_KHR)) {
|
|
swapchainPresentMode = VK_PRESENT_MODE_IMMEDIATE_KHR;
|
|
}
|
|
}
|
|
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));
|
|
// images[] and every other per-frame array hold exactly numFrames
|
|
// entries. A driver may create more images than minImageCount asked for
|
|
// (a surface whose minimum is above numFrames must, and Mesa gives
|
|
// MAILBOX at least four). MAILBOX and IMMEDIATE are wishes: FIFO, with
|
|
// numFrames, is the frame loop's own. If that has other than numFrames
|
|
// too, writing them into images[] would run off its end, so refuse.
|
|
if (imageCount != numFrames && swapchainPresentMode != VK_PRESENT_MODE_FIFO_KHR) {
|
|
std::println(std::cerr, "Window: the {} swapchain has {} images, not the frame loop's {}; using FIFO",
|
|
swapchainPresentMode == VK_PRESENT_MODE_MAILBOX_KHR ? "MAILBOX" : "IMMEDIATE", imageCount, numFrames);
|
|
vkDestroySwapchainKHR(Device::device, swapChain, nullptr);
|
|
presentModeUnfit = true;
|
|
swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR;
|
|
swapchainCI.presentMode = swapchainPresentMode;
|
|
swapchainCI.oldSwapchain = VK_NULL_HANDLE;
|
|
Device::CheckVkResult(vkCreateSwapchainKHR(Device::device, &swapchainCI, nullptr, &swapChain));
|
|
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr));
|
|
}
|
|
if (imageCount != numFrames) {
|
|
throw std::runtime_error(std::format("Window: the swapchain has {} images, but the frame loop is built for exactly {}", imageCount, numFrames));
|
|
}
|
|
|
|
// 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;
|
|
// 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));
|
|
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;
|
|
// 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));
|
|
}
|
|
|
|
#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);
|
|
|
|
// The member names the live callback, so the destructor can cancel it.
|
|
// A window that has since moved to its own clock lets the chain end.
|
|
if(window->updating && window->updateInterval == std::chrono::nanoseconds::zero()) {
|
|
window->cb = wl_surface_frame(window->surface);
|
|
wl_callback_add_listener(window->cb, &Window::wl_callback_listener, window);
|
|
window->Update();
|
|
} else {
|
|
window->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<Window*>(data);
|
|
window->pendingLogicalWidth = width;
|
|
window->pendingLogicalHeight = height;
|
|
}
|
|
|
|
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) {
|
|
// 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);
|
|
}
|
|
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;
|
|
}
|
|
|
|
#endif
|
|
|
|
void Window::CaptureNextFrame(std::filesystem::path path) {
|
|
pendingCapture = std::move(path);
|
|
}
|
|
#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;
|
|
|
|
// 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());
|
|
|
|
// 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::SetAppId(const std::string_view) {}
|
|
|
|
void Window::SetTextInput(bool, std::int32_t, std::int32_t, std::int32_t, std::int32_t, TextInputPurpose) {}
|
|
|
|
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::StayAlive() {
|
|
// As StartSync, minus the rAF loop.
|
|
//
|
|
// The _Exit(0) is the part that keeps the instance alive: it skips
|
|
// __wasm_call_dtors so every statically allocated object (event
|
|
// listeners, HtmlElementPtr handles, the Window itself) survives, and
|
|
// runtime.js catches the resulting __wasi_proc_exit via a sentinel.
|
|
// The frame loop is a separate concern, and a page that only renders
|
|
// DOM has no use for it — a rAF tick that runs forever to do nothing
|
|
// costs battery on every open tab.
|
|
//
|
|
// Use this for event-driven DOM pages; use StartSync when something
|
|
// actually animates or draws. As with StartSync, no caller code after
|
|
// this point ever runs.
|
|
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::SetPresentMode(PresentMode mode) {
|
|
// The page presents the canvas; there is no mode to pick.
|
|
presentMode = mode;
|
|
}
|
|
|
|
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<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) {
|
|
if (!g_domWindow) return;
|
|
// dom-env.js has already normalized WheelEvent deltas to whole
|
|
// detents (+1 = wheel down) — see __windowListeners.wheel.
|
|
// 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)));
|
|
}
|
|
|
|
__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);
|
|
}
|
|
// 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) {
|
|
g_domWindow->onTextInput.Invoke(std::string_view(keyPtr, static_cast<std::size_t>(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<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
|