From 4c043c9962f6680c7647757fddd73fb428281150 Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 14:12:46 -0400 Subject: [PATCH] Submit the overlay through a persistent Vulkan texture --- CMakeLists.txt | 14 +- docs/design.md | 7 +- docs/overlay.md | 48 ++++-- docs/packaging.md | 5 +- docs/poc.md | 1 + scripts/stage-native-poc.py | 4 +- src/main.cpp | 2 +- src/overlay.cpp | 45 +++++- src/overlay_texture.cpp | 240 ++++++++++++++++++++++++++++ src/overlay_texture.hpp | 32 ++++ src/panel_surface.cpp | 4 +- tests/overlay_texture_gpu_check.cpp | 134 ++++++++++++++++ tests/overlay_texture_test.cpp | 240 ++++++++++++++++++++++++++++ 13 files changed, 750 insertions(+), 26 deletions(-) create mode 100644 src/overlay_texture.cpp create mode 100644 src/overlay_texture.hpp create mode 100644 tests/overlay_texture_gpu_check.cpp create mode 100644 tests/overlay_texture_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5dbb5ee..a8581a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,7 @@ if(FRAMEYAP_NATIVE) message(FATAL_ERROR "Native POC supports Linux only") endif() find_package(PkgConfig REQUIRED) + find_package(Vulkan REQUIRED) pkg_check_modules(SDL3 REQUIRED IMPORTED_TARGET sdl3>=3.2) # SDL's .pc can inject its producer-prefix RPATH. Let CMake manage build # RPATH and install only our relative path, never a developer environment. @@ -83,10 +84,10 @@ if(FRAMEYAP_NATIVE) add_custom_command(OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" COMMAND "${WAYLAND_SCANNER}" private-code "${PROTOCOL}" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" DEPENDS "${PROTOCOL}" VERBATIM) - target_sources(frameyap PRIVATE src/runtime.cpp src/overlay.cpp src/audio.cpp src/text_input.cpp + target_sources(frameyap PRIVATE src/runtime.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h") target_include_directories(frameyap PRIVATE "${OPENVR_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") - target_link_libraries(frameyap PRIVATE frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND "${OPENVR_LIBRARY}") + target_link_libraries(frameyap PRIVATE frameyap_worker frameyap_mount frameyap_panel PkgConfig::SDL3 PkgConfig::WAYLAND Vulkan::Vulkan "${OPENVR_LIBRARY}") target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1) set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") endif() @@ -134,6 +135,15 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_test(NAME frameyap.gestures COMMAND frameyap_gestures_test) find_package(Python3 3.10 COMPONENTS Interpreter) if(FRAMEYAP_NATIVE) + add_executable(frameyap_overlay_texture_test tests/overlay_texture_test.cpp src/overlay_texture.cpp) + target_include_directories(frameyap_overlay_texture_test PRIVATE src "${OPENVR_INCLUDE_DIR}" "${Vulkan_INCLUDE_DIRS}") + target_compile_options(frameyap_overlay_texture_test PRIVATE -UNDEBUG) + # Test-provided Vulkan symbols; no GPU, loader or OpenVR initialization. + add_test(NAME frameyap.overlay_texture COMMAND frameyap_overlay_texture_test) + add_executable(frameyap_texture_check EXCLUDE_FROM_ALL tests/overlay_texture_gpu_check.cpp src/overlay_texture.cpp) + target_include_directories(frameyap_texture_check PRIVATE src "${OPENVR_INCLUDE_DIR}") + target_link_libraries(frameyap_texture_check PRIVATE Vulkan::Vulkan) + # Explicit build and --run only; never register a hardware check with CTest. pkg_check_modules(WAYLAND_SERVER IMPORTED_TARGET wayland-server) if(WAYLAND_SERVER_FOUND) find_package(Threads REQUIRED) diff --git a/docs/design.md b/docs/design.md index abb18e2..540ea8b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -89,9 +89,10 @@ panel. A small overlay-specific RAII owner in this repository should manage Open handles, input manifest and shutdown. No dependency on external application libraries, assets, build trees or Python environments. -Start with one small RGBA panel updated only on UI changes and a bounded recording -indicator cadence. `SetOverlayRaw` is the simplest proof route; measure upload -cost before selecting a persistent Vulkan `SetOverlayTexture` path. No stereo +Use one small RGBA panel updated only on UI changes and a bounded recording +indicator cadence. The native implementation uploads the CPU-rasterized panel to +a persistent Vulkan image and uses `SetOverlayTexture`; the initial +`SetOverlayRaw` proof path has been replaced. No stereo eye targets or per-eye scene rendering. Keep tracking in compositor transforms, not an application-rendered hand-pose animation loop. Do not promise a particular GPU cost until measured. diff --git a/docs/overlay.md b/docs/overlay.md index 36dca3a..d62caa3 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -9,7 +9,8 @@ normal build or test. ## Rendering and controls -Explicit development dependencies: Valve OpenVR SDK v2.15.6 and FreeType 2. +Explicit development dependencies: Valve OpenVR SDK v2.15.6, Vulkan headers/loader +and FreeType 2. The native runtime needs a compatible system Vulkan driver. Configure/build must not fetch them. The default font is the bundled Inconsolata Regular, also used by kouseki; its OFL and extraction provenance are included in [third-party notes](third-party.md). `--font FILE` overrides the JSON selection. @@ -18,19 +19,35 @@ Sans face if present. Glyph coverage depends on the selected face; full CJK coverage is not claimed. `src/panel_surface.*` renders **one 1000Ă—680 RGBA canvas** for review, settings, -status and controls. One OpenVR handle receives it with `SetOverlayRaw`; tabs do -not create extra overlays or render targets. The rounded mint-to-blue perimeter, +status and controls. `src/overlay_texture.*` uploads this CPU canvas into one +persistent Vulkan RGBA8 image and submits it with `SetOverlayTexture`. The image, +staging allocation and command buffer are reused; tabs do not create extra +overlays or render targets. The rounded mint-to-blue perimeter, shallow curved accent, and dark cards borrow kouseki's VR visual language. Rounded preview, status and control surfaces use independently rasterized antialiased edges and restrained baked neon halos rather than GPU bloom. The recording indicator and selected controls remain distinguishable by their labels, not color alone. Rounded control hit areas exclude their clipped corners. Rendering/uploads occur only for changed content, page or settings; laser hover -and button down/up are hit-tested without a raw-texture upload, to reduce -compositor flicker reported during hover. Static frames are reused. +and button down/up are hit-tested without an upload. Static frames are reused. The caller may call `draw(Panel)` at 10 ms intervals. Tracking transforms do not require repainting the canvas. +The Vulkan instance/device enable the extensions requested by the running +SteamVR runtime and use its selected physical device and a graphics queue. +There is no desktop window, swapchain, SDL video dependency or raw-upload +fallback. Updates wait for the dedicated queue's previous upload and OpenVR +transfer before reusing staging memory. Image barriers finish in +`TRANSFER_SRC_OPTIMAL`, as required by +[OpenVR's Vulkan contract](https://github.com/ValveSoftware/openvr/wiki/Vulkan). +The queue is used on the overlay thread; GPU resources outlive `VR_Shutdown`. +The device selection, texture description and persistent panel-upload patterns +were compared with kouseki's `openvr_session.cpp` and `vulkan_renderer.cpp` at +`738569f4c41ff4c8fc9edd5bfff9c861957ea39e`; FrameYap owns this implementation. +GPU setup/submission errors stop startup or the run with an explicit error. +This replaces the raw-upload rendering path; headset flicker acceptance still +requires an on-device comparison. + The complete transcript preview is paginated by glyph width and four-line height; Previous and Next navigate it without changing the source transcript. Long status/detail messages show a prefix with a visible truncation marker. @@ -144,10 +161,23 @@ recentring and readability still require a separately authorized headset check. The opt-in native `--check-controls` probe logs pointer counters and action callbacks to the terminal rather than repainting them on the panel. Its canvas stays static for Record/Cancel/Insert/Enter clicks so those clicks can be checked -without diagnostic `SetOverlayRaw` traffic. Switching tabs or mount still updates -the visible panel. This isolates click-induced compositor flicker from full raw -texture replacements; it does not establish that ordinary state-changing UI -updates are flicker-free. +without diagnostic texture uploads. Switching tabs or mount still updates +the visible panel. Diagnostics identify `renderer=Vulkan` and count +`textureUploads`; raw/file `ImageLoaded` events are not GPU upload completions. +The native CTest suite tests persistent image reuse, queued transfer ordering, +coherent/noncoherent staging memory and error cleanup against Vulkan fakes; +it does not initialize the Vulkan loader, a GPU or OpenVR for that test. + +An optional offscreen check exercises the real Vulkan backend with eight +synthetic RGBA patterns, verifying exact readback and image reuse. It needs a +GPU/driver, is excluded from normal builds and CTest, and requires `--run`: + +```sh +cmake --build build-native --target frameyap_texture_check +./build-native/frameyap_texture_check --run +``` + +It does not initialize OpenVR or establish compositor/headset acceptance. `assets/actions.json` names six actions: left/right grip, PTT, cancel, insert, Enter. `bindings_frame_controller.json` maps right X click to hold-to-talk PTT; diff --git a/docs/packaging.md b/docs/packaging.md index f152759..8bca119 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -25,8 +25,9 @@ For the current **external-runtime** POC, `scripts/stage-native-poc.py --help` documents explicit inputs. It invokes `cmake --install` on an existing native build, copies SDL/OpenVR and an explicitly licensed font, and retains notices. It does not build, download, run the app, or copy a proprietary ASR runtime. The native -POC relies on Frame's system Wayland, FreeType, libstdc++ and glibc; audit `ldd` -on the installed binary. SDL/OpenVR resolve inside its own `lib/`, not a producer +POC relies on Frame's system Vulkan loader/driver, Wayland, FreeType, libstdc++ +and glibc; audit `ldd` on the installed binary. +SDL/OpenVR resolve inside its own `lib/`, not a producer prefix. ARM64/glibc packaging is not a claim of compatibility with arbitrary Linux. ```sh diff --git a/docs/poc.md b/docs/poc.md index 17c9e3e..4aa4242 100644 --- a/docs/poc.md +++ b/docs/poc.md @@ -71,6 +71,7 @@ separately scoped and independently licensed implementation—not a silent model ## Developer native build Requirements: Linux, CMake/C++20, SDL3 >=3.2, Wayland client + scanner, FreeType, +Vulkan headers/loader (plus the system GPU driver at runtime), and a deliberately provisioned standalone OpenVR **v2.15.6** SDK. No CMake fetches. ```sh diff --git a/scripts/stage-native-poc.py b/scripts/stage-native-poc.py index 6a423aa..2864abb 100644 --- a/scripts/stage-native-poc.py +++ b/scripts/stage-native-poc.py @@ -2,7 +2,7 @@ """Stage a native-only POC from an explicit native build and licensed files. No downloads, compiler invocation, proprietary ASR runtime, registration or launch. -System Wayland/FreeType/libstdc++/glibc remain platform prerequisites. +System Vulkan loader/driver, Wayland/FreeType/libstdc++/glibc remain platform prerequisites. """ import argparse from pathlib import Path @@ -41,7 +41,7 @@ def main(): shutil.copyfile(args.sdl_library, dest / "lib/libSDL3.so.0", follow_symlinks=True) shutil.copyfile(args.font, dest / "fonts/font.ttf") notices = ["FrameYap native-only POC. No ASR runtime or model is included.\n", - "Original FrameYap code: MIT. System Wayland/FreeType/libstdc++/glibc are not bundled.\n", + "Original FrameYap code: MIT. System Vulkan/Wayland/FreeType/libstdc++/glibc are not bundled.\n", "Bundled libraries: Valve OpenVR and unmodified SDL3; font license included below.\n", "This package does not grant any rights to kestrel-kernels or provide a functioning ASR environment.\n"] for label, file in (("FrameYap", root / "LICENSE"), ("OpenVR", args.openvr_license), diff --git a/src/main.cpp b/src/main.cpp index fece564..3218f9b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -122,7 +122,7 @@ int main(int argc, char** argv) { auto pointer = overlay.pointer_status(); if (pointer != pointer_status) { pointer_status = pointer; std::cout << pointer_status << std::endl; } // Keep the canvas fixed for action clicks: otherwise the - // changing counters cause SetOverlayRaw on every down/up. + // changing counters would upload a texture on every down/up. // Tab/placement changes still redraw the correct controls. overlay.draw(check_panel); std::this_thread::sleep_for(std::chrono::milliseconds(10)); diff --git a/src/overlay.cpp b/src/overlay.cpp index 6b68ba5..d29dbef 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -1,4 +1,5 @@ #include "overlay.hpp" +#include "overlay_texture.hpp" #include "gestures.hpp" #include "laser_setting.hpp" #include "panel_surface.hpp" @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +43,19 @@ std::filesystem::path absolute_file(const std::filesystem::path& p) { if (!std::filesystem::is_regular_file(result)) throw std::runtime_error("Missing file: " + result.string()); return result; } +template std::vector vulkan_extensions(Query query) { + const auto size = query(nullptr, 0); + if (!size) return {}; + if (size > 65536) throw std::runtime_error("OpenVR Vulkan extension list is too large"); + std::vector buffer(size, '\0'); + const auto written = query(buffer.data(), size); + if (!written || written > size || buffer[written - 1] != '\0') + throw std::runtime_error("OpenVR Vulkan extension list changed or is invalid"); + std::istringstream words(std::string(buffer.data(), written - 1)); + std::vector result; + for (std::string name; words >> name;) result.push_back(std::move(name)); + return result; +} } // namespace struct Overlay::Impl { @@ -48,6 +63,7 @@ struct Overlay::Impl { vr::IVROverlay* overlay = nullptr; vr::IVRInput* input = nullptr; vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid; + std::unique_ptr gpu_texture; vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle; std::array actions{}; std::filesystem::path settings_path; @@ -71,7 +87,7 @@ struct Overlay::Impl { bool persist_mount = true; vr::EVRInputError action_update_error = vr::VRInputError_None; unsigned pointer_downs = 0, pointer_ups = 0, pointer_actions = 0, pointer_resets = 0; - unsigned raw_uploads = 0, show_calls = 0, hide_calls = 0; + unsigned texture_uploads = 0, show_calls = 0, hide_calls = 0; unsigned overlay_shown_events = 0, overlay_hidden_events = 0, image_loaded_events = 0, image_failed_events = 0; unsigned overlay_focus_events = 0, global_focus_events = 0, input_focus_captured_events = 0; std::string last_pointer_event = "none"; @@ -95,6 +111,21 @@ struct Overlay::Impl { overlay = vr::VROverlay(); input = vr::VRInput(); if (!overlay || !input) throw std::runtime_error("OpenVR overlay/input interface unavailable"); + auto* compositor = vr::VRCompositor(); + if (!compositor) throw std::runtime_error("OpenVR Vulkan compositor interface unavailable"); + const auto extensions = vulkan_extensions([&](char* out, uint32_t size) { + return compositor->GetVulkanInstanceExtensionsRequired(out, size); + }); + gpu_texture = std::make_unique(W, H, extensions, + [&](VkInstance instance) { + uint64_t physical = 0; + system->GetOutputDevice(&physical, vr::TextureType_Vulkan, instance); + return reinterpret_cast(physical); + }, [&](VkPhysicalDevice physical) { + return vulkan_extensions([&](char* out, uint32_t size) { + return compositor->GetVulkanDeviceExtensionsRequired(physical, out, size); + }); + }); // The manifest launches a shell launcher, then execs this binary. // Associate manually launched instances with our registered app key too. auto* applications = vr::VRApplications(); @@ -136,6 +167,9 @@ struct Overlay::Impl { handle = vr::k_ulOverlayHandleInvalid; } if (system) { vr::VR_Shutdown(); system = nullptr; overlay = nullptr; input = nullptr; } + // OpenVR retains client-side resources for submitted Vulkan images. + // Its shutdown must finish before their device/instance are destroyed. + gpu_texture.reset(); } void visibility() { const bool wanted = placed && has_texture; @@ -198,9 +232,10 @@ struct Overlay::Impl { void draw(const Panel& p) { panel = p; if (surface.render(p)) { - // OpenVR's API takes void*, but does not modify the submitted RGBA bytes. - overlay_check(overlay->SetOverlayRaw(handle, const_cast(surface.pixels().data()), W, H, 4), overlay, "SetOverlayRaw"); - ++raw_uploads; + gpu_texture->upload(surface.pixels()); + auto texture = gpu_texture->texture(); + overlay_check(overlay->SetOverlayTexture(handle, &texture), overlay, "SetOverlayTexture (Vulkan)"); + ++texture_uploads; has_texture = true; } visibility(); @@ -399,7 +434,7 @@ std::string Overlay::pointer_status() const { return "Pointer down=" + std::to_string(impl_->pointer_downs) + " up=" + std::to_string(impl_->pointer_ups) + " hits=" + std::to_string(impl_->pointer_actions) + " resets=" + std::to_string(impl_->pointer_resets) + " last=" + impl_->last_pointer_event + - "\nOverlay raw=" + std::to_string(impl_->raw_uploads) + + "\nOverlay renderer=Vulkan textureUploads=" + std::to_string(impl_->texture_uploads) + " showCalls=" + std::to_string(impl_->show_calls) + " hideCalls=" + std::to_string(impl_->hide_calls) + " shownEvents=" + std::to_string(impl_->overlay_shown_events) + " hiddenEvents=" + std::to_string(impl_->overlay_hidden_events) + diff --git a/src/overlay_texture.cpp b/src/overlay_texture.cpp new file mode 100644 index 0000000..c6232ab --- /dev/null +++ b/src/overlay_texture.cpp @@ -0,0 +1,240 @@ +#include "overlay_texture.hpp" + +#include +#include +#include +#include + +namespace frameyap { +namespace { +void check(VkResult result, const char* operation) { + if (result != VK_SUCCESS) + throw std::runtime_error(std::string("Overlay Vulkan ") + operation + ": VkResult=" + + std::to_string(result)); +} +std::vector names(std::span extensions) { + std::vector result; + for (const auto& extension : extensions) result.push_back(extension.c_str()); + return result; +} +constexpr VkFormat format = VK_FORMAT_R8G8B8A8_UNORM; +constexpr VkImageSubresourceRange color_range{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; +} // namespace + +struct OverlayTexture::Impl { + VkInstance instance = VK_NULL_HANDLE; + VkPhysicalDevice physical = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + VkQueue queue = VK_NULL_HANDLE; + VkImage image = VK_NULL_HANDLE; + VkDeviceMemory image_memory = VK_NULL_HANDLE, staging_memory = VK_NULL_HANDLE; + VkBuffer staging = VK_NULL_HANDLE; + VkCommandPool pool = VK_NULL_HANDLE; + VkCommandBuffer commands = VK_NULL_HANDLE; + VkPhysicalDeviceMemoryProperties memory{}; + vr::VRVulkanTextureData_t description{}; + void* mapped = nullptr; + size_t bytes = 0; + uint32_t width = 0, height = 0; + bool coherent = false, uploaded = false; + + ~Impl() { + // OpenVR must already have released its client-side Vulkan resources. + // On device loss, waiting can fail; owned handles still need destruction. + if (device) vkDeviceWaitIdle(device); + if (mapped) vkUnmapMemory(device, staging_memory); + if (pool) vkDestroyCommandPool(device, pool, nullptr); + if (staging) vkDestroyBuffer(device, staging, nullptr); + if (staging_memory) vkFreeMemory(device, staging_memory, nullptr); + if (image) vkDestroyImage(device, image, nullptr); + if (image_memory) vkFreeMemory(device, image_memory, nullptr); + if (device) vkDestroyDevice(device, nullptr); + if (instance) vkDestroyInstance(instance, nullptr); + } + + uint32_t memory_type(uint32_t bits, VkMemoryPropertyFlags required, + VkMemoryPropertyFlags preferred) const { + for (auto flags : {required | preferred, required}) + for (uint32_t i = 0; i < memory.memoryTypeCount; ++i) + if ((bits & (1u << i)) && (memory.memoryTypes[i].propertyFlags & flags) == flags) + return i; + throw std::runtime_error("Overlay Vulkan: no compatible memory type"); + } + + void init(uint32_t w, uint32_t h, std::span instance_extensions, + const SelectDevice& select_device, const DeviceExtensions& device_extensions) { + if (!w || !h || uint64_t(w) * h > std::numeric_limits::max() / 4) + throw std::runtime_error("Overlay Vulkan: invalid RGBA dimensions"); + width = w; height = h; bytes = size_t(w) * h * 4; + VkApplicationInfo app{}; + app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + app.pApplicationName = "FrameYap"; + app.apiVersion = VK_API_VERSION_1_0; + auto instance_names = names(instance_extensions); + VkInstanceCreateInfo instance_info{}; + instance_info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + instance_info.pApplicationInfo = &app; + instance_info.enabledExtensionCount = uint32_t(instance_names.size()); + instance_info.ppEnabledExtensionNames = instance_names.data(); + check(vkCreateInstance(&instance_info, nullptr, &instance), "vkCreateInstance (OpenVR extensions)"); + physical = select_device(instance); + if (!physical) throw std::runtime_error("Overlay Vulkan: SteamVR did not select a physical device"); + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(physical, &properties); + if (w > properties.limits.maxImageDimension2D || h > properties.limits.maxImageDimension2D) + throw std::runtime_error("Overlay Vulkan: panel exceeds device image dimensions"); + + uint32_t family_count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(physical, &family_count, nullptr); + std::vector families(family_count); + vkGetPhysicalDeviceQueueFamilyProperties(physical, &family_count, families.data()); + uint32_t family = 0; + while (family < family_count && + (!(families[family].queueFlags & VK_QUEUE_GRAPHICS_BIT) || !families[family].queueCount)) ++family; + if (family == family_count) throw std::runtime_error("Overlay Vulkan: no graphics queue"); + const float priority = 1.0f; + VkDeviceQueueCreateInfo queue_info{}; + queue_info.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + queue_info.queueFamilyIndex = family; + queue_info.queueCount = 1; + queue_info.pQueuePriorities = &priority; + const auto extensions = device_extensions(physical); + auto device_names = names(extensions); + VkDeviceCreateInfo device_info{}; + device_info.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + device_info.queueCreateInfoCount = 1; + device_info.pQueueCreateInfos = &queue_info; + device_info.enabledExtensionCount = uint32_t(device_names.size()); + device_info.ppEnabledExtensionNames = device_names.data(); + check(vkCreateDevice(physical, &device_info, nullptr, &device), "vkCreateDevice (OpenVR extensions)"); + vkGetDeviceQueue(device, family, 0, &queue); + vkGetPhysicalDeviceMemoryProperties(physical, &memory); + + VkImageCreateInfo image_info{}; + image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + image_info.imageType = VK_IMAGE_TYPE_2D; + image_info.format = format; + image_info.extent = {width, height, 1}; + image_info.mipLevels = 1; + image_info.arrayLayers = 1; + image_info.samples = VK_SAMPLE_COUNT_1_BIT; + image_info.tiling = VK_IMAGE_TILING_OPTIMAL; + image_info.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + image_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + image_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + check(vkCreateImage(device, &image_info, nullptr, &image), "vkCreateImage"); + VkMemoryRequirements requirements{}; + vkGetImageMemoryRequirements(device, image, &requirements); + VkMemoryAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, 0); + check(vkAllocateMemory(device, &allocation, nullptr, &image_memory), "vkAllocateMemory (image)"); + check(vkBindImageMemory(device, image, image_memory, 0), "vkBindImageMemory"); + + VkBufferCreateInfo buffer_info{}; + buffer_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + buffer_info.size = bytes; + buffer_info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + buffer_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateBuffer(device, &buffer_info, nullptr, &staging), "vkCreateBuffer"); + vkGetBufferMemoryRequirements(device, staging, &requirements); + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + coherent = memory.memoryTypes[allocation.memoryTypeIndex].propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + check(vkAllocateMemory(device, &allocation, nullptr, &staging_memory), "vkAllocateMemory (staging)"); + check(vkBindBufferMemory(device, staging, staging_memory, 0), "vkBindBufferMemory"); + // Map the allocation, including any padding, so whole-allocation flushes + // satisfy nonCoherentAtomSize even when the last pixel is unaligned. + check(vkMapMemory(device, staging_memory, 0, VK_WHOLE_SIZE, 0, &mapped), "vkMapMemory"); + VkCommandPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_info.queueFamilyIndex = family; + pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + check(vkCreateCommandPool(device, &pool_info, nullptr, &pool), "vkCreateCommandPool"); + VkCommandBufferAllocateInfo command_info{}; + command_info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + command_info.commandPool = pool; + command_info.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + command_info.commandBufferCount = 1; + check(vkAllocateCommandBuffers(device, &command_info, &commands), "vkAllocateCommandBuffers"); + + description.m_nImage = std::bit_cast(image); + description.m_pDevice = device; + description.m_pPhysicalDevice = physical; + description.m_pInstance = instance; + description.m_pQueue = queue; + description.m_nQueueFamilyIndex = family; + description.m_nWidth = width; + description.m_nHeight = height; + description.m_nFormat = format; + description.m_nSampleCount = 1; + } + + void upload(std::span rgba) { + if (rgba.size() != bytes) throw std::runtime_error("Overlay Vulkan: wrong RGBA upload size"); + // Includes work enqueued by the previous SetOverlayTexture call, not + // just our upload. Reuse the staging bytes and commands only after it + // finishes. This dedicated queue is idle between content changes. + check(vkQueueWaitIdle(queue), "vkQueueWaitIdle"); + std::memcpy(mapped, rgba.data(), bytes); + if (!coherent) { + VkMappedMemoryRange range{}; + range.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; + range.memory = staging_memory; + range.size = VK_WHOLE_SIZE; + check(vkFlushMappedMemoryRanges(device, 1, &range), "vkFlushMappedMemoryRanges"); + } + check(vkResetCommandBuffer(commands, 0), "vkResetCommandBuffer"); + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check(vkBeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer"); + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = uploaded ? VK_ACCESS_TRANSFER_READ_BIT : 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.oldLayout = uploaded ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange = color_range; + vkCmdPipelineBarrier(commands, uploaded ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkBufferImageCopy copy{}; + copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + copy.imageExtent = {width, height, 1}; + vkCmdCopyBufferToImage(commands, staging, image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + vkCmdPipelineBarrier(commands, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + check(vkEndCommandBuffer(commands), "vkEndCommandBuffer"); + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &commands; + check(vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE), "vkQueueSubmit"); + // SteamVR enqueues its transfer on this same queue after this upload, + // with the image in the layout required by OpenVR's Vulkan contract. + uploaded = true; + } +}; + +OverlayTexture::OverlayTexture(uint32_t width, uint32_t height, + std::span instance_extensions, + const SelectDevice& select_device, const DeviceExtensions& device_extensions) + : impl_(std::make_unique()) { + impl_->init(width, height, instance_extensions, select_device, device_extensions); +} +OverlayTexture::~OverlayTexture() = default; +void OverlayTexture::upload(std::span rgba) { impl_->upload(rgba); } +vr::Texture_t OverlayTexture::texture() { + if (!impl_->uploaded) throw std::runtime_error("Overlay Vulkan: texture has not been uploaded"); + return {&impl_->description, vr::TextureType_Vulkan, vr::ColorSpace_Gamma}; +} +} // namespace frameyap diff --git a/src/overlay_texture.hpp b/src/overlay_texture.hpp new file mode 100644 index 0000000..6a18a5f --- /dev/null +++ b/src/overlay_texture.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +namespace frameyap { +// One persistent RGBA image, staging allocation and graphics queue. When used +// with OpenVR, construct after initialization; destroy AFTER VR_Shutdown. Upload +// and SetOverlayTexture must run on the same thread (OpenVR uses our queue). +class OverlayTexture { +public: + using SelectDevice = std::function; + using DeviceExtensions = std::function(VkPhysicalDevice)>; + OverlayTexture(uint32_t width, uint32_t height, + std::span instance_extensions, + const SelectDevice& select_device, const DeviceExtensions& device_extensions); + ~OverlayTexture(); + OverlayTexture(const OverlayTexture&) = delete; + OverlayTexture& operator=(const OverlayTexture&) = delete; + void upload(std::span rgba); + vr::Texture_t texture(); // valid after upload; descriptor lives with this object +private: + struct Impl; + std::unique_ptr impl_; +}; +} // namespace frameyap diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 69dd01d..e009b43 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -335,8 +335,8 @@ bool PanelSurface::render(const Panel& p) { return impl_->render(p); } const std::vector& PanelSurface::pixels() const { return impl_->pixels; } bool PanelSurface::available(UiAction a) const { return impl_->available(a); } void PanelSurface::pointer_move(unsigned, float, float) { - // Hit-test on down/up only. SetOverlayRaw can flicker in SteamVR when each - // laser hover frame causes another full RGBA upload. + // Hit-test on down/up only; laser movement does not change panel content + // and does not require a GPU texture upload. } void PanelSurface::pointer_down(unsigned cursor, float x, float y) { if (cursor >= impl_->pressed.size()) return; diff --git a/tests/overlay_texture_gpu_check.cpp b/tests/overlay_texture_gpu_check.cpp new file mode 100644 index 0000000..4554073 --- /dev/null +++ b/tests/overlay_texture_gpu_check.cpp @@ -0,0 +1,134 @@ +// Explicit offscreen Vulkan check. Not registered with CTest: --run opts into +// GPU access. No OpenVR initialization, desktop surface, microphone or input. +#include "overlay_texture.hpp" +#include +#include +#include +#include +#include + +namespace { +void check(VkResult result, const char* operation) { + if (result != VK_SUCCESS) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result)); +} +struct Readback { + VkDevice device{}; + VkBuffer buffer{}; + VkDeviceMemory memory{}; + VkCommandPool pool{}; + void* mapped = nullptr; + ~Readback() { + if (device) vkDeviceWaitIdle(device); + if (mapped) vkUnmapMemory(device, memory); + if (pool) vkDestroyCommandPool(device, pool, nullptr); + if (buffer) vkDestroyBuffer(device, buffer, nullptr); + if (memory) vkFreeMemory(device, memory, nullptr); + } + void verify(const vr::VRVulkanTextureData_t& texture, std::span expected) { + device = texture.m_pDevice; + VkBufferCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + info.size = expected.size(); + info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateBuffer(device, &info, nullptr, &buffer), "create readback buffer"); + VkMemoryRequirements requirements{}; + vkGetBufferMemoryRequirements(device, buffer, &requirements); + VkPhysicalDeviceMemoryProperties types{}; + vkGetPhysicalDeviceMemoryProperties(texture.m_pPhysicalDevice, &types); + uint32_t type = 0; + while (type < types.memoryTypeCount && (!(requirements.memoryTypeBits & (1u << type)) || + !(types.memoryTypes[type].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT))) ++type; + if (type == types.memoryTypeCount) throw std::runtime_error("No host-visible readback memory"); + VkMemoryAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = type; + check(vkAllocateMemory(device, &allocation, nullptr, &memory), "allocate readback memory"); + check(vkBindBufferMemory(device, buffer, memory, 0), "bind readback memory"); + VkCommandPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_info.queueFamilyIndex = texture.m_nQueueFamilyIndex; + check(vkCreateCommandPool(device, &pool_info, nullptr, &pool), "create readback command pool"); + VkCommandBufferAllocateInfo command_info{}; + command_info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + command_info.commandPool = pool; + command_info.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + command_info.commandBufferCount = 1; + VkCommandBuffer command{}; + check(vkAllocateCommandBuffers(device, &command_info, &command), "allocate readback commands"); + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check(vkBeginCommandBuffer(command, &begin), "begin readback"); + VkBufferImageCopy copy{}; + copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + copy.imageExtent = {texture.m_nWidth, texture.m_nHeight, 1}; + vkCmdCopyImageToBuffer(command, std::bit_cast(texture.m_nImage), + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, buffer, 1, ©); + VkBufferMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.buffer = buffer; + barrier.size = VK_WHOLE_SIZE; + vkCmdPipelineBarrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, + 0, 0, nullptr, 1, &barrier, 0, nullptr); + check(vkEndCommandBuffer(command), "end readback"); + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &command; + check(vkQueueSubmit(texture.m_pQueue, 1, &submit, VK_NULL_HANDLE), "submit readback"); + check(vkQueueWaitIdle(texture.m_pQueue), "wait for readback"); + check(vkMapMemory(device, memory, 0, VK_WHOLE_SIZE, 0, &mapped), "map readback"); + if (!(types.memoryTypes[type].propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) { + VkMappedMemoryRange range{}; + range.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; + range.memory = memory; + range.size = VK_WHOLE_SIZE; + check(vkInvalidateMappedMemoryRanges(device, 1, &range), "invalidate readback"); + } + if (std::memcmp(mapped, expected.data(), expected.size())) + throw std::runtime_error("RGBA readback differs from uploaded pixels"); + } +}; +} // namespace + +int main(int argc, char** argv) { + if (argc != 2 || std::string_view(argv[1]) != "--run") { + std::cout << "Use --run for an offscreen GPU upload/readback check. No OpenVR, audio or input.\n"; + return argc == 1 || (argc == 2 && std::string_view(argv[1]) == "--help") ? 0 : 2; + } + try { + frameyap::OverlayTexture texture(1000, 680, {}, [](VkInstance instance) { + uint32_t count = 0; + check(vkEnumeratePhysicalDevices(instance, &count, nullptr), "enumerate GPUs"); + if (!count) throw std::runtime_error("No Vulkan GPU"); + std::vector devices(count); + check(vkEnumeratePhysicalDevices(instance, &count, devices.data()), "enumerate GPUs"); + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(devices.front(), &properties); + std::cout << "Vulkan device: " << properties.deviceName << '\n'; + return devices.front(); + }, [](VkPhysicalDevice) { return std::vector{}; }); + std::vector pixels(1000 * 680 * 4); + uint64_t image = 0; + for (unsigned frame = 0; frame < 8; ++frame) { + for (size_t i = 0; i < pixels.size(); ++i) pixels[i] = (i * 17 + (i / 4000) * 31 + frame * 67) & 255; + texture.upload(pixels); + const auto submitted = texture.texture(); + const auto& description = *static_cast(submitted.handle); + if (frame && description.m_nImage != image) throw std::runtime_error("Texture was recreated"); + image = description.m_nImage; + Readback readback; + readback.verify(description, pixels); + } + std::cout << "8 exact 1000x680 RGBA readbacks; one persistent Vulkan image.\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; return 1; + } +} diff --git a/tests/overlay_texture_test.cpp b/tests/overlay_texture_test.cpp new file mode 100644 index 0000000..9d4a774 --- /dev/null +++ b/tests/overlay_texture_test.cpp @@ -0,0 +1,240 @@ +// Link against these Vulkan fakes, never the loader. CTest must not touch a GPU +// or initialize OpenVR. Model queued transfers so reuse before completion fails. +#include "overlay_texture.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +template T handle(uintptr_t value) { return std::bit_cast(value); } +std::set live; +uintptr_t next_handle = 10; +unsigned calls = 0, fail_at = 0, images = 0, submits = 0, flushes = 0; +bool coherent = true; +std::map> allocations; +VkDeviceMemory image_memory{}, buffer_memory{}; +VkImageLayout layout = VK_IMAGE_LAYOUT_UNDEFINED; +std::vector> recorded, pending; +std::vector> observed; +VkResult result() { return ++calls == fail_at ? VK_ERROR_OUT_OF_DEVICE_MEMORY : VK_SUCCESS; } +template VkResult create(T* out) { + auto status = result(); + if (status == VK_SUCCESS) { *out = handle(next_handle++); live.insert(std::bit_cast(*out)); } + return status; +} +template void destroy(T resource) { assert(live.erase(std::bit_cast(resource)) == 1); } +void complete() { for (auto& work : pending) work(); pending.clear(); } +void reset() { + assert(live.empty() && allocations.empty() && pending.empty()); + calls = images = submits = flushes = 0; + recorded.clear(); observed.clear(); layout = VK_IMAGE_LAYOUT_UNDEFINED; +} +const std::vector instance_extensions{"VK_KHR_external_memory_capabilities"}; +auto select_device = [](VkInstance instance) { + assert(live.contains(std::bit_cast(instance))); + return handle(2); +}; +auto device_extensions = [](VkPhysicalDevice physical) { + assert(physical == handle(2)); + return std::vector{"VK_KHR_external_memory"}; +}; +void consume(frameyap::OverlayTexture& texture) { + const auto t = texture.texture(); + assert(t.eType == vr::TextureType_Vulkan && t.eColorSpace == vr::ColorSpace_Gamma); + const auto& data = *static_cast(t.handle); + assert(data.m_nWidth == 2 && data.m_nHeight == 2 && data.m_nSampleCount == 1); + assert(data.m_nFormat == VK_FORMAT_R8G8B8A8_UNORM && data.m_nQueueFamilyIndex == 1); + assert(data.m_pPhysicalDevice == handle(2)); + assert(data.m_pQueue == handle(3)); + assert(live.contains(data.m_nImage)); + // Emulate SetOverlayTexture enqueuing a read AFTER our upload, on our queue. + pending.push_back([] { + assert(layout == VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); + auto& bytes = allocations.at(image_memory); + observed.emplace_back(bytes.begin(), bytes.begin() + 16); + }); +} +template void throws(F f) { + bool threw = false; + try { f(); } catch (const std::runtime_error&) { threw = true; } + assert(threw); +} +} // namespace + +extern "C" { +VKAPI_ATTR VkResult VKAPI_CALL vkCreateInstance(const VkInstanceCreateInfo* info, const VkAllocationCallbacks*, VkInstance* out) { + assert(info->enabledExtensionCount == 1); + assert(std::string(info->ppEnabledExtensionNames[0]) == instance_extensions[0]); + return create(out); +} +VKAPI_ATTR void VKAPI_CALL vkDestroyInstance(VkInstance instance, const VkAllocationCallbacks*) { + assert(live.size() == 1); destroy(instance); +} +VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceProperties(VkPhysicalDevice, VkPhysicalDeviceProperties* properties) { + properties->limits.maxImageDimension2D = 4096; +} +VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceQueueFamilyProperties(VkPhysicalDevice, uint32_t* count, VkQueueFamilyProperties* out) { + if (!out) { *count = 2; return; } + assert(*count == 2); + out[0] = {}; out[0].queueFlags = VK_QUEUE_TRANSFER_BIT; out[0].queueCount = 1; + out[1] = {}; out[1].queueFlags = VK_QUEUE_GRAPHICS_BIT; out[1].queueCount = 1; +} +VKAPI_ATTR VkResult VKAPI_CALL vkCreateDevice(VkPhysicalDevice physical, const VkDeviceCreateInfo* info, + const VkAllocationCallbacks*, VkDevice* out) { + assert(physical == handle(2)); + assert(info->queueCreateInfoCount == 1 && info->pQueueCreateInfos->queueFamilyIndex == 1); + assert(info->enabledExtensionCount == 1); + assert(std::string(info->ppEnabledExtensionNames[0]) == "VK_KHR_external_memory"); + return create(out); +} +VKAPI_ATTR void VKAPI_CALL vkDestroyDevice(VkDevice device, const VkAllocationCallbacks*) { + assert(live.size() == 2 && allocations.empty() && pending.empty()); destroy(device); +} +VKAPI_ATTR VkResult VKAPI_CALL vkDeviceWaitIdle(VkDevice) { complete(); return VK_SUCCESS; } +VKAPI_ATTR void VKAPI_CALL vkGetDeviceQueue(VkDevice, uint32_t family, uint32_t index, VkQueue* queue) { + assert(family == 1 && index == 0); *queue = handle(3); +} +VKAPI_ATTR void VKAPI_CALL vkGetPhysicalDeviceMemoryProperties(VkPhysicalDevice, VkPhysicalDeviceMemoryProperties* memory) { + memory->memoryTypeCount = 2; + memory->memoryTypes[0].propertyFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT; + memory->memoryTypes[1].propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + (coherent ? VK_MEMORY_PROPERTY_HOST_COHERENT_BIT : 0); +} +VKAPI_ATTR VkResult VKAPI_CALL vkCreateImage(VkDevice, const VkImageCreateInfo* info, const VkAllocationCallbacks*, VkImage* out) { + assert(info->format == VK_FORMAT_R8G8B8A8_UNORM); + assert(info->extent.width == 2 && info->extent.height == 2 && info->extent.depth == 1); + assert(info->mipLevels == 1 && info->arrayLayers == 1 && info->samples == VK_SAMPLE_COUNT_1_BIT); + assert(info->tiling == VK_IMAGE_TILING_OPTIMAL); + assert(info->usage == (VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT)); + ++images; return create(out); +} +VKAPI_ATTR void VKAPI_CALL vkDestroyImage(VkDevice, VkImage image, const VkAllocationCallbacks*) { destroy(image); } +VKAPI_ATTR void VKAPI_CALL vkGetImageMemoryRequirements(VkDevice, VkImage, VkMemoryRequirements* requirements) { + *requirements = {256, 256, 1}; +} +VKAPI_ATTR VkResult VKAPI_CALL vkAllocateMemory(VkDevice, const VkMemoryAllocateInfo* info, const VkAllocationCallbacks*, VkDeviceMemory* out) { + auto status = create(out); + if (status == VK_SUCCESS) allocations[*out].resize(info->allocationSize); + return status; +} +VKAPI_ATTR void VKAPI_CALL vkFreeMemory(VkDevice, VkDeviceMemory memory, const VkAllocationCallbacks*) { + assert(allocations.erase(memory) == 1); destroy(memory); +} +VKAPI_ATTR VkResult VKAPI_CALL vkBindImageMemory(VkDevice, VkImage, VkDeviceMemory memory, VkDeviceSize) { + image_memory = memory; return result(); +} +VKAPI_ATTR VkResult VKAPI_CALL vkCreateBuffer(VkDevice, const VkBufferCreateInfo* info, const VkAllocationCallbacks*, VkBuffer* out) { + assert(info->size == 16 && info->usage == VK_BUFFER_USAGE_TRANSFER_SRC_BIT); return create(out); +} +VKAPI_ATTR void VKAPI_CALL vkDestroyBuffer(VkDevice, VkBuffer buffer, const VkAllocationCallbacks*) { destroy(buffer); } +VKAPI_ATTR void VKAPI_CALL vkGetBufferMemoryRequirements(VkDevice, VkBuffer, VkMemoryRequirements* requirements) { + *requirements = {256, 256, 2}; +} +VKAPI_ATTR VkResult VKAPI_CALL vkBindBufferMemory(VkDevice, VkBuffer, VkDeviceMemory memory, VkDeviceSize) { + buffer_memory = memory; return result(); +} +VKAPI_ATTR VkResult VKAPI_CALL vkMapMemory(VkDevice, VkDeviceMemory memory, VkDeviceSize offset, VkDeviceSize size, + VkMemoryMapFlags, void** out) { + assert(offset == 0 && size == VK_WHOLE_SIZE); + auto status = result(); + if (status == VK_SUCCESS) *out = allocations.at(memory).data(); + return status; +} +VKAPI_ATTR void VKAPI_CALL vkUnmapMemory(VkDevice, VkDeviceMemory memory) { assert(allocations.contains(memory)); } +VKAPI_ATTR VkResult VKAPI_CALL vkFlushMappedMemoryRanges(VkDevice, uint32_t count, const VkMappedMemoryRange* range) { + assert(!coherent && count == 1 && range->offset == 0 && range->size == VK_WHOLE_SIZE); + assert(range->memory == buffer_memory); ++flushes; return result(); +} +VKAPI_ATTR VkResult VKAPI_CALL vkCreateCommandPool(VkDevice, const VkCommandPoolCreateInfo* info, const VkAllocationCallbacks*, VkCommandPool* out) { + assert(info->queueFamilyIndex == 1); return create(out); +} +VKAPI_ATTR void VKAPI_CALL vkDestroyCommandPool(VkDevice, VkCommandPool pool, const VkAllocationCallbacks*) { destroy(pool); } +VKAPI_ATTR VkResult VKAPI_CALL vkAllocateCommandBuffers(VkDevice, const VkCommandBufferAllocateInfo*, VkCommandBuffer* out) { + auto status = result(); if (status == VK_SUCCESS) *out = handle(4); return status; +} +VKAPI_ATTR VkResult VKAPI_CALL vkQueueWaitIdle(VkQueue queue) { + assert(queue == handle(3)); auto status = result(); if (status == VK_SUCCESS) complete(); return status; +} +VKAPI_ATTR VkResult VKAPI_CALL vkResetCommandBuffer(VkCommandBuffer, VkCommandBufferResetFlags) { + assert(pending.empty()); recorded.clear(); return result(); +} +VKAPI_ATTR VkResult VKAPI_CALL vkBeginCommandBuffer(VkCommandBuffer, const VkCommandBufferBeginInfo*) { return result(); } +VKAPI_ATTR void VKAPI_CALL vkCmdPipelineBarrier(VkCommandBuffer, VkPipelineStageFlags, VkPipelineStageFlags, + VkDependencyFlags, uint32_t, const VkMemoryBarrier*, uint32_t, const VkBufferMemoryBarrier*, + uint32_t count, const VkImageMemoryBarrier* barriers) { + assert(count == 1); + const auto b = barriers[0]; + assert(b.srcQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED && b.dstQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED); + assert(b.subresourceRange.aspectMask == VK_IMAGE_ASPECT_COLOR_BIT); + assert(b.subresourceRange.levelCount == 1 && b.subresourceRange.layerCount == 1); + recorded.push_back([b] { assert(layout == b.oldLayout); layout = b.newLayout; }); +} +VKAPI_ATTR void VKAPI_CALL vkCmdCopyBufferToImage(VkCommandBuffer, VkBuffer, VkImage, VkImageLayout target, + uint32_t count, const VkBufferImageCopy* copy) { + assert(count == 1 && target == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + assert(copy->bufferRowLength == 0 && copy->bufferImageHeight == 0 && copy->bufferOffset == 0); + assert(copy->imageExtent.width == 2 && copy->imageExtent.height == 2 && copy->imageExtent.depth == 1); + recorded.push_back([] { + assert(layout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + std::copy_n(allocations.at(buffer_memory).begin(), 16, allocations.at(image_memory).begin()); + }); +} +VKAPI_ATTR VkResult VKAPI_CALL vkEndCommandBuffer(VkCommandBuffer) { return result(); } +VKAPI_ATTR VkResult VKAPI_CALL vkQueueSubmit(VkQueue queue, uint32_t count, const VkSubmitInfo* submit, VkFence) { + assert(queue == handle(3) && count == 1 && submit->commandBufferCount == 1); + auto status = result(); + if (status == VK_SUCCESS) { pending.insert(pending.end(), recorded.begin(), recorded.end()); ++submits; } + return status; +} +} // extern C + +int main() { + const std::array first{255,0,0,255, 0,255,0,128, 0,0,255,0, 255,255,255,255}; + const std::array second{0,0,0,0, 4,5,6,7, 8,9,10,11, 12,13,14,15}; + for (bool use_coherent : {true, false}) { + reset(); coherent = use_coherent; + { + frameyap::OverlayTexture texture(2, 2, instance_extensions, select_device, device_extensions); + throws([&] { texture.texture(); }); + throws([&] { texture.upload(std::span(first).first(15)); }); + texture.upload(first); + const auto descriptor = texture.texture(); + const auto image = static_cast(descriptor.handle)->m_nImage; + consume(texture); + texture.upload(second); // must drain first read before replacing staging bytes + assert(observed.size() == 1 && std::ranges::equal(observed[0], first)); + assert(texture.texture().handle == descriptor.handle); + assert(static_cast(descriptor.handle)->m_nImage == image); + consume(texture); + assert(images == 1 && submits == 2 && flushes == (coherent ? 0u : 2u)); + } + assert(observed.size() == 2 && std::ranges::equal(observed[1], second)); + assert(live.empty() && allocations.empty()); + const auto operation_count = calls; + // Every fallible allocation/upload operation must unwind all owned + // resources, including a failure while a previous frame is pending. + for (unsigned failure = 1; failure <= operation_count; ++failure) { + reset(); fail_at = failure; + throws([&] { + frameyap::OverlayTexture texture(2, 2, instance_extensions, select_device, device_extensions); + texture.upload(first); consume(texture); + texture.upload(second); consume(texture); + }); + assert(live.empty() && allocations.empty() && pending.empty()); + } + fail_at = 0; + } + reset(); + throws([&] { frameyap::OverlayTexture texture(0, 2, instance_extensions, select_device, device_extensions); }); + assert(calls == 0); // invalid sizes must not initialize Vulkan + throws([&] { + frameyap::OverlayTexture texture(2, 2, instance_extensions, + [](VkInstance) { return VkPhysicalDevice{}; }, device_extensions); + }); + assert(live.empty()); // never pick an unrelated GPU when SteamVR returns none +}