diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 58b17402e..872c4ceb3 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -39,7 +39,9 @@ jobs: - name: Install Apt dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential cmake libx11-dev clang-format-14 ccache libvulkan-dev glslang-tools + sudo apt-get install -y build-essential cmake libx11-dev clang-format-14 ccache libvulkan-dev glslang-tools \ + libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev libxkbcommon-dev \ + libwayland-dev wayland-protocols - name: Install patchelf (Release only) if: ${{ matrix.build_type == 'Release' }} @@ -274,7 +276,9 @@ jobs: - name: Install Apt dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential cmake libx11-dev clang-format-14 ccache libvulkan-dev glslang-tools + sudo apt-get install -y build-essential cmake libx11-dev clang-format-14 ccache libvulkan-dev glslang-tools \ + libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxext-dev libxkbcommon-dev \ + libwayland-dev wayland-protocols - name: Install CUDA toolkit uses: ./.github/actions/setup-cuda diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c4c0e2f7..df8842ce4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -138,6 +138,9 @@ if(BUILD_EXAMPLES) add_subdirectory(examples/teleop_ros2) add_subdirectory(examples/schemaio) add_subdirectory(examples/native_openxr) + if(BUILD_VIZ) + add_subdirectory(examples/televiz) + endif() elseif(BUILD_EXAMPLE_TELEOP_ROS2) add_subdirectory(examples/teleop_ros2) endif() diff --git a/deps/third_party/CMakeLists.txt b/deps/third_party/CMakeLists.txt index de4931b3d..7a0765666 100644 --- a/deps/third_party/CMakeLists.txt +++ b/deps/third_party/CMakeLists.txt @@ -176,3 +176,29 @@ if(BUILD_VIZ) FetchContent_MakeAvailable(glm) message(STATUS "glm 1.0.1 fetched (header-only)") endif() + +# ============================================================================== +# GLFW (window + Vulkan surface for kWindow) +# ============================================================================== +# Owns GLFWwindow + VkSurfaceKHR for VizSession's kWindow display backend. +# Static build to avoid runtime .so dependency. +if(BUILD_VIZ) + message(STATUS "Fetching GLFW from GitHub...") + FetchContent_Declare( + glfw + GIT_REPOSITORY https://github.com/glfw/glfw.git + GIT_TAG 3.4 + GIT_SHALLOW TRUE + ) + set(GLFW_BUILD_DOCS OFF CACHE BOOL "Skip GLFW docs" FORCE) + set(GLFW_BUILD_TESTS OFF CACHE BOOL "Skip GLFW tests" FORCE) + set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "Skip GLFW examples" FORCE) + set(GLFW_INSTALL OFF CACHE BOOL "Skip GLFW install target" FORCE) + # Build with both X11 and Wayland (GLFW 3.4 Linux defaults). + # Requires libxrandr-dev / libxinerama-dev / libxcursor-dev / + # libxi-dev / libxext-dev / libxkbcommon-dev plus libwayland-dev / + # wayland-scanner. CI installs them in build-ubuntu.yml. Override + # with -DGLFW_BUILD_WAYLAND=OFF on hosts without Wayland tooling. + FetchContent_MakeAvailable(glfw) + message(STATUS "GLFW 3.4 fetched") +endif() diff --git a/examples/televiz/CMakeLists.txt b/examples/televiz/CMakeLists.txt new file mode 100644 index 000000000..df30b4c39 --- /dev/null +++ b/examples/televiz/CMakeLists.txt @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +add_subdirectory(window_smoke) diff --git a/examples/televiz/window_smoke/CMakeLists.txt b/examples/televiz/window_smoke/CMakeLists.txt new file mode 100644 index 000000000..3e688e1ce --- /dev/null +++ b/examples/televiz/window_smoke/CMakeLists.txt @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20) + +add_executable(viz_window_smoke main.cpp) + +target_link_libraries(viz_window_smoke PRIVATE + viz::session + viz::layers +) + +set_target_properties(viz_window_smoke PROPERTIES OUTPUT_NAME "viz_window_smoke") +install(TARGETS viz_window_smoke RUNTIME DESTINATION examples/televiz/window_smoke) diff --git a/examples/televiz/window_smoke/main.cpp b/examples/televiz/window_smoke/main.cpp new file mode 100644 index 000000000..74964df0a --- /dev/null +++ b/examples/televiz/window_smoke/main.cpp @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Minimal kWindow demo: opens a 1024x768 GLFW window, fills four +// QuadLayers with solid RGBA patterns tiled 2x2, runs the render +// loop until the window closes. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +struct Rgba +{ + uint8_t r, g, b, a; +}; + +// RAII wrapper around a cudaMalloc'd buffer. +struct CudaDeviceBuffer +{ + void* ptr = nullptr; + CudaDeviceBuffer() = default; + explicit CudaDeviceBuffer(size_t bytes) + { + if (cudaMalloc(&ptr, bytes) != cudaSuccess) + { + ptr = nullptr; + throw std::runtime_error("cudaMalloc failed"); + } + } + ~CudaDeviceBuffer() + { + if (ptr != nullptr) + { + cudaFree(ptr); + } + } + CudaDeviceBuffer(const CudaDeviceBuffer&) = delete; + CudaDeviceBuffer& operator=(const CudaDeviceBuffer&) = delete; + CudaDeviceBuffer(CudaDeviceBuffer&& o) noexcept : ptr(o.ptr) + { + o.ptr = nullptr; + } + CudaDeviceBuffer& operator=(CudaDeviceBuffer&& o) noexcept + { + if (this != &o) + { + if (ptr != nullptr) + { + cudaFree(ptr); + } + ptr = o.ptr; + o.ptr = nullptr; + } + return *this; + } +}; + +CudaDeviceBuffer make_solid_color_buffer(uint32_t width, uint32_t height, Rgba color) +{ + std::vector host(static_cast(width) * height, color); + CudaDeviceBuffer buf(host.size() * sizeof(Rgba)); + if (cudaMemcpy(buf.ptr, host.data(), host.size() * sizeof(Rgba), cudaMemcpyHostToDevice) != cudaSuccess) + { + throw std::runtime_error("cudaMemcpy failed"); + } + return buf; +} + +void submit_solid(viz::QuadLayer& layer, void* dev_ptr, uint32_t w, uint32_t h) +{ + viz::VizBuffer src{}; + src.data = dev_ptr; + src.width = w; + src.height = h; + src.format = viz::PixelFormat::kRGBA8; + src.pitch = static_cast(w) * 4; + src.space = viz::MemorySpace::kDevice; + layer.submit(src); +} + +} // namespace + +int main() +{ + constexpr uint32_t kWindowW = 1024; + constexpr uint32_t kWindowH = 768; + constexpr uint32_t kQuadW = 256; + constexpr uint32_t kQuadH = 256; + + viz::VizSession::Config cfg{}; + cfg.mode = viz::DisplayMode::kWindow; + cfg.window_width = kWindowW; + cfg.window_height = kWindowH; + cfg.app_name = "viz_window_smoke"; + // Dark grey clear so letterbox margins are visible against the quads. + cfg.clear_color[0] = 0.1f; + cfg.clear_color[1] = 0.1f; + cfg.clear_color[2] = 0.1f; + cfg.clear_color[3] = 1.0f; + + try + { + auto session = viz::VizSession::create(cfg); + const viz::VkContext* ctx = session->get_vk_context(); + const VkRenderPass render_pass = session->get_render_pass(); + + const std::array palette = { { + { 220, 60, 60, 255 }, // red + { 60, 220, 60, 255 }, // green + { 60, 100, 220, 255 }, // blue + { 220, 220, 220, 255 }, // white + } }; + + // RAII: buffers freed on scope exit (normal or exception). + // Outlive the session — submit() copies into the mailbox, so + // the device pointers can be freed any time after. + std::vector device_buffers; + device_buffers.reserve(palette.size()); + for (size_t i = 0; i < palette.size(); ++i) + { + viz::QuadLayer::Config layer_cfg; + layer_cfg.name = "smoke_quad_" + std::to_string(i); + layer_cfg.resolution = { kQuadW, kQuadH }; + auto* layer = session->add_layer(*ctx, render_pass, layer_cfg); + + device_buffers.push_back(make_solid_color_buffer(kQuadW, kQuadH, palette[i])); + submit_solid(*layer, device_buffers.back().ptr, kQuadW, kQuadH); + } + + // Print fps once per second (60 frames at 60Hz) so resize / + // move stalls show up as drops in the terminal output. + while (!session->should_close()) + { + const auto info = session->render(); + if (info.frame_index > 0 && info.frame_index % 60 == 0) + { + const auto stats = session->get_frame_timing_stats(); + std::printf("frame %llu: %.1f fps (%.2f ms/frame)\n", static_cast(info.frame_index), + stats.render_fps, stats.avg_frame_time_ms); + std::fflush(stdout); + } + } + + session.reset(); // tear down before buffers go out of scope + } + catch (const std::exception& e) + { + std::fprintf(stderr, "viz_window_smoke: %s\n", e.what()); + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/src/viz/core/cpp/inc/viz/core/render_target.hpp b/src/viz/core/cpp/inc/viz/core/render_target.hpp index 2be054311..e46fe46e0 100644 --- a/src/viz/core/cpp/inc/viz/core/render_target.hpp +++ b/src/viz/core/cpp/inc/viz/core/render_target.hpp @@ -95,6 +95,11 @@ class RenderTarget return resolution_; } + // Recreate color/depth/framebuffer at new_size. Keeps the render + // pass alive; pipelines built against it stay valid. Caller must + // ensure GPU work is retired (vkDeviceWaitIdle / fence wait). + void resize(Resolution new_size); + private: explicit RenderTarget(const VkContext& ctx); @@ -104,6 +109,7 @@ class RenderTarget void create_depth_image(const Config& config); void create_render_pass(); void create_framebuffer(); + void destroy_attachments(); // images + views + memory + framebuffer const VkContext* ctx_ = nullptr; diff --git a/src/viz/core/cpp/inc/viz/core/viz_types.hpp b/src/viz/core/cpp/inc/viz/core/viz_types.hpp index d39be127e..7f272770a 100644 --- a/src/viz/core/cpp/inc/viz/core/viz_types.hpp +++ b/src/viz/core/cpp/inc/viz/core/viz_types.hpp @@ -18,6 +18,16 @@ struct Resolution uint32_t height = 0; }; +// 2D pixel-coordinate rectangle. Mirrors VkRect2D (offset + extent) but +// stays Vulkan-free so viz_types.hpp doesn't pull in vulkan.h. +struct Rect2D +{ + int32_t x = 0; + int32_t y = 0; + uint32_t width = 0; + uint32_t height = 0; +}; + // 3D pose in OpenXR stage space: right-handed, Y-up, meters for distance, // orientation as a unit quaternion. Default-constructed is identity. // @@ -55,6 +65,12 @@ struct ViewInfo glm::mat4 projection_matrix{ 1.0f }; // identity Fov fov{}; Pose3D pose{}; + // Pixel rect in the framebuffer the layer should draw into for + // this view. Filled by the compositor before record(). In window + // mode it's the layer's aspect-fit content rect inside its tile; + // in XR stereo it's the eye's subImage.imageRect; in offscreen + // it's the full target. + Rect2D viewport{}; }; } // namespace viz diff --git a/src/viz/core/cpp/render_target.cpp b/src/viz/core/cpp/render_target.cpp index 9461689bd..3767453a7 100644 --- a/src/viz/core/cpp/render_target.cpp +++ b/src/viz/core/cpp/render_target.cpp @@ -96,16 +96,22 @@ void RenderTarget::destroy() { return; } - if (framebuffer_ != VK_NULL_HANDLE) - { - vkDestroyFramebuffer(device, framebuffer_, nullptr); - framebuffer_ = VK_NULL_HANDLE; - } + destroy_attachments(); if (render_pass_ != VK_NULL_HANDLE) { vkDestroyRenderPass(device, render_pass_, nullptr); render_pass_ = VK_NULL_HANDLE; } +} + +void RenderTarget::destroy_attachments() +{ + const VkDevice device = ctx_->device(); + if (framebuffer_ != VK_NULL_HANDLE) + { + vkDestroyFramebuffer(device, framebuffer_, nullptr); + framebuffer_ = VK_NULL_HANDLE; + } if (depth_view_ != VK_NULL_HANDLE) { vkDestroyImageView(device, depth_view_, nullptr); @@ -138,6 +144,51 @@ void RenderTarget::destroy() } } +void RenderTarget::resize(Resolution new_size) +{ + if (new_size.width == 0 || new_size.height == 0) + { + return; + } + if (new_size.width == resolution_.width && new_size.height == resolution_.height) + { + return; + } + const Resolution old_size = resolution_; + destroy_attachments(); + resolution_ = new_size; + Config c{}; + c.resolution = new_size; + try + { + create_color_image(c); + create_depth_image(c); + create_framebuffer(); + } + catch (...) + { + // Restore the old attachments so the object stays usable. + // If the restore itself fails, drop everything — caller has + // to recreate the render target. + destroy_attachments(); + resolution_ = old_size; + try + { + Config old_c{}; + old_c.resolution = old_size; + create_color_image(old_c); + create_depth_image(old_c); + create_framebuffer(); + } + catch (...) + { + destroy_attachments(); + resolution_ = Resolution{}; + } + throw; + } +} + void RenderTarget::create_color_image(const Config& config) { const VkDevice device = ctx_->device(); diff --git a/src/viz/layers/cpp/inc/viz/layers/layer_base.hpp b/src/viz/layers/cpp/inc/viz/layers/layer_base.hpp index 355d3fbf1..66c2c85f3 100644 --- a/src/viz/layers/cpp/inc/viz/layers/layer_base.hpp +++ b/src/viz/layers/cpp/inc/viz/layers/layer_base.hpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -15,6 +16,23 @@ namespace viz class RenderTarget; +// Standard mapping from ViewInfo::viewport to vkCmdSetViewport: origin +// top-left, depth 0..1, no y-flip. Layers call this once per view in +// record() before issuing draws. Layer authors should NOT bind scissor +// — the compositor pre-binds it for tile isolation in window mode and +// per-eye composition layers in XR. +inline void bind_view_viewport(VkCommandBuffer cmd, const ViewInfo& view) +{ + VkViewport vp{}; + vp.x = static_cast(view.viewport.x); + vp.y = static_cast(view.viewport.y); + vp.width = static_cast(view.viewport.width); + vp.height = static_cast(view.viewport.height); + vp.minDepth = 0.0f; + vp.maxDepth = 1.0f; + vkCmdSetViewport(cmd, 0, 1, &vp); +} + // Abstract base class for content rendered by Televiz's compositor. // // A layer represents one piece of GPU content drawn into the active render @@ -44,11 +62,19 @@ class LayerBase LayerBase& operator=(const LayerBase&) = delete; // Issue draw commands inside the currently-active render pass. - // cmd: the compositor's command buffer with the render pass active - // views: per-view parameters (1 entry in window/offscreen, 2 in XR - // stereo). Indexable by view index for stereo viewport setup. - // target: the framebuffer dimensions and Vulkan handles the layer - // draws into; const so layers cannot modify the target. + // cmd: command buffer with render pass active and the layer's + // SCISSOR pre-bound by the compositor. + // views: per-view parameters (1 in window/offscreen, 2 in XR stereo). + // Each entry's `viewport` is the rect this layer must draw + // into for that view — bind it via vkCmdSetViewport (use + // viz::bind_view_viewport) before drawing. + // target: framebuffer handles. Read-only. + // + // Contract: + // - DO bind viewport per view via vkCmdSetViewport. + // - DO NOT bind scissor — the compositor sets it. Overriding scissor + // breaks tile isolation in window mode and per-eye comp + // layers in XR. virtual void record(VkCommandBuffer cmd, const std::vector& views, const RenderTarget& target) = 0; // Per-frame wait wiring for layers that synchronize against CUDA @@ -75,6 +101,16 @@ class LayerBase return {}; } + // Optional aspect ratio (width / height) hint for window-mode tiling. + // The compositor uses this to compute the layer's content rect inside + // its tile so content keeps its aspect when the tile doesn't match. + // Returning nullopt means "no preferred aspect — fill the tile". XR + // mode ignores this (per-eye viewports come from the OpenXR runtime). + virtual std::optional aspect_ratio() const noexcept + { + return std::nullopt; + } + const std::string& name() const noexcept; // Visibility flag is atomic so it can be toggled from any thread (UI diff --git a/src/viz/layers/cpp/inc/viz/layers/quad_layer.hpp b/src/viz/layers/cpp/inc/viz/layers/quad_layer.hpp index b320ee0fa..cf8fc120a 100644 --- a/src/viz/layers/cpp/inc/viz/layers/quad_layer.hpp +++ b/src/viz/layers/cpp/inc/viz/layers/quad_layer.hpp @@ -95,6 +95,10 @@ class QuadLayer : public LayerBase // cuda_done_writing before the fragment shader samples it. std::vector get_wait_semaphores() const override; + // resolution().width / resolution().height. Drives aspect-fit + // letterbox in window mode; XR mode ignores it. + std::optional aspect_ratio() const noexcept override; + Resolution resolution() const noexcept; PixelFormat format() const noexcept; diff --git a/src/viz/layers/cpp/quad_layer.cpp b/src/viz/layers/cpp/quad_layer.cpp index 8b861dea0..6c5fb00dd 100644 --- a/src/viz/layers/cpp/quad_layer.cpp +++ b/src/viz/layers/cpp/quad_layer.cpp @@ -173,6 +173,15 @@ PixelFormat QuadLayer::format() const noexcept return config_.format; } +std::optional QuadLayer::aspect_ratio() const noexcept +{ + if (config_.resolution.height == 0) + { + return std::nullopt; + } + return static_cast(config_.resolution.width) / static_cast(config_.resolution.height); +} + const DeviceImage* QuadLayer::device_image(uint32_t slot) const noexcept { if (slot >= kSlotCount) @@ -242,7 +251,7 @@ void QuadLayer::submit(const VizBuffer& src, cudaStream_t stream) latest_.store(slot, std::memory_order_release); } -void QuadLayer::record(VkCommandBuffer cmd, const std::vector& /*views*/, const RenderTarget& target) +void QuadLayer::record(VkCommandBuffer cmd, const std::vector& views, const RenderTarget& /*target*/) { require_alive(slots_[0], "record"); @@ -262,29 +271,19 @@ void QuadLayer::record(VkCommandBuffer cmd, const std::vector& /*views return; } - const Resolution res = target.resolution(); - - VkViewport viewport{}; - viewport.x = 0.0f; - viewport.y = 0.0f; - viewport.width = static_cast(res.width); - viewport.height = static_cast(res.height); - viewport.minDepth = 0.0f; - viewport.maxDepth = 1.0f; - vkCmdSetViewport(cmd, 0, 1, &viewport); - - VkRect2D scissor{}; - scissor.offset = { 0, 0 }; - scissor.extent = { res.width, res.height }; - vkCmdSetScissor(cmd, 0, 1, &scissor); - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); vkCmdBindDescriptorSets( cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout_, 0, 1, &descriptor_sets_[cur], 0, nullptr); - // 3 vertices, no vertex buffer — vertex shader emits a fullscreen - // triangle from gl_VertexIndex. - vkCmdDraw(cmd, 3, 1, 0, 0); + // 1 view in window/offscreen, 2 in XR stereo. Compositor pre-bound + // the layer's scissor; we bind viewport per view and draw. + for (const auto& view : views) + { + bind_view_viewport(cmd, view); + // 3 vertices, no vertex buffer — vertex shader emits a + // fullscreen triangle from gl_VertexIndex. + vkCmdDraw(cmd, 3, 1, 0, 0); + } } std::vector QuadLayer::get_wait_semaphores() const diff --git a/src/viz/session/cpp/CMakeLists.txt b/src/viz/session/cpp/CMakeLists.txt index 2e93b31b3..cfea904a8 100644 --- a/src/viz/session/cpp/CMakeLists.txt +++ b/src/viz/session/cpp/CMakeLists.txt @@ -6,11 +6,23 @@ cmake_minimum_required(VERSION 3.20) # VizSession + VizCompositor + frame info: orchestration layer that drives # the per-frame loop and manages the layer registry. add_library(viz_session STATIC + glfw_window.cpp + offscreen_backend.cpp + swapchain.cpp + tile_layout.cpp viz_compositor.cpp viz_session.cpp + window_backend.cpp + inc/viz/session/display_backend.hpp + inc/viz/session/display_mode.hpp inc/viz/session/frame_info.hpp + inc/viz/session/glfw_window.hpp + inc/viz/session/offscreen_backend.hpp + inc/viz/session/swapchain.hpp + inc/viz/session/tile_layout.hpp inc/viz/session/viz_compositor.hpp inc/viz/session/viz_session.hpp + inc/viz/session/window_backend.hpp ) target_include_directories(viz_session @@ -22,6 +34,7 @@ target_link_libraries(viz_session PUBLIC viz::core viz::layers + glfw ) # Aliased as viz::session. diff --git a/src/viz/session/cpp/glfw_window.cpp b/src/viz/session/cpp/glfw_window.cpp new file mode 100644 index 000000000..7916a5674 --- /dev/null +++ b/src/viz/session/cpp/glfw_window.cpp @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include + +#define GLFW_INCLUDE_NONE +#define GLFW_INCLUDE_VULKAN +#include + +#include +#include +#include +#include + +namespace viz +{ + +namespace +{ + +// Process-wide refcount so glfwInit/Terminate stay balanced across +// concurrent GlfwWindows and external retain/release callers. +std::mutex& glfw_init_mutex() +{ + static std::mutex m; + return m; +} + +uint32_t& glfw_init_count() +{ + static uint32_t n = 0; + return n; +} + +} // namespace + +void GlfwWindow::retain() +{ + std::lock_guard lock(glfw_init_mutex()); + if (glfw_init_count() == 0) + { + if (glfwInit() != GLFW_TRUE) + { + const char* desc = nullptr; + glfwGetError(&desc); + throw std::runtime_error(std::string("GlfwWindow: glfwInit() failed: ") + (desc ? desc : "(no description)")); + } + } + ++glfw_init_count(); +} + +void GlfwWindow::release() noexcept +{ + std::lock_guard lock(glfw_init_mutex()); + if (glfw_init_count() == 0) + { + return; + } + if (--glfw_init_count() == 0) + { + glfwTerminate(); + } +} + +std::unique_ptr GlfwWindow::create(VkInstance instance, uint32_t width, uint32_t height, const std::string& title) +{ + if (instance == VK_NULL_HANDLE) + { + throw std::invalid_argument("GlfwWindow::create: instance is VK_NULL_HANDLE"); + } + if (width == 0 || height == 0) + { + throw std::invalid_argument("GlfwWindow::create: width/height must be non-zero"); + } + + GlfwWindow::retain(); + + glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // Vulkan, not GL + glfwWindowHint(GLFW_RESIZABLE, GLFW_TRUE); + + GLFWwindow* w = glfwCreateWindow(static_cast(width), static_cast(height), title.c_str(), nullptr, nullptr); + if (w == nullptr) + { + GlfwWindow::release(); + const char* desc = nullptr; + glfwGetError(&desc); + throw std::runtime_error(std::string("GlfwWindow: glfwCreateWindow failed: ") + + (desc ? desc : "(no description)")); + } + + VkSurfaceKHR surface = VK_NULL_HANDLE; + const VkResult r = glfwCreateWindowSurface(instance, w, nullptr, &surface); + if (r != VK_SUCCESS) + { + glfwDestroyWindow(w); + GlfwWindow::release(); + throw std::runtime_error("GlfwWindow: glfwCreateWindowSurface failed: VkResult=" + std::to_string(r)); + } + + std::unique_ptr self(new GlfwWindow(instance, w, surface)); + glfwSetWindowUserPointer(w, self.get()); + glfwSetFramebufferSizeCallback(w, &GlfwWindow::framebuffer_resize_callback); + return self; +} + +GlfwWindow::GlfwWindow(VkInstance instance, GLFWwindow* window, VkSurfaceKHR surface) + : instance_(instance), window_(window), surface_(surface) +{ +} + +GlfwWindow::~GlfwWindow() +{ + destroy(); +} + +void GlfwWindow::destroy() +{ + if (surface_ != VK_NULL_HANDLE && instance_ != VK_NULL_HANDLE) + { + vkDestroySurfaceKHR(instance_, surface_, nullptr); + surface_ = VK_NULL_HANDLE; + } + if (window_ != nullptr) + { + glfwDestroyWindow(window_); + window_ = nullptr; + GlfwWindow::release(); + } +} + +bool GlfwWindow::should_close() const noexcept +{ + return window_ != nullptr && glfwWindowShouldClose(window_) == GLFW_TRUE; +} + +void GlfwWindow::poll_events() noexcept +{ + if (window_ != nullptr) + { + glfwPollEvents(); + } +} + +Resolution GlfwWindow::framebuffer_size() const noexcept +{ + if (window_ == nullptr) + { + return Resolution{ 0, 0 }; + } + int w = 0; + int h = 0; + glfwGetFramebufferSize(window_, &w, &h); + return Resolution{ static_cast(std::max(0, w)), static_cast(std::max(0, h)) }; +} + +void GlfwWindow::framebuffer_resize_callback(GLFWwindow* w, int /*width*/, int /*height*/) +{ + auto* self = static_cast(glfwGetWindowUserPointer(w)); + if (self != nullptr) + { + self->resized_.store(true, std::memory_order_release); + } +} + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/display_backend.hpp b/src/viz/session/cpp/inc/viz/session/display_backend.hpp new file mode 100644 index 000000000..1a9bb42e1 --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/display_backend.hpp @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace viz +{ + +class VkContext; + +// Abstract presentation target. VizSession instantiates one per +// DisplayMode; VizCompositor drives it. +// +// Backends own the intermediate RenderTarget plus any mode-specific +// resources (window+swapchain, readback staging, XR session). The +// RT's render pass stays compatibility-stable across resize so layer +// pipelines built against it remain valid. +// +// Per-frame: begin_frame -> compositor renders into render_target() +// -> record_post_render_pass (backend's blit/transitions) -> compositor +// submits with the backend's wait/signal semaphores -> end_frame +// (present / no-op). +class DisplayBackend +{ +public: + virtual ~DisplayBackend() = default; + + DisplayBackend(const DisplayBackend&) = delete; + DisplayBackend& operator=(const DisplayBackend&) = delete; + DisplayBackend(DisplayBackend&&) = delete; + DisplayBackend& operator=(DisplayBackend&&) = delete; + + // Vulkan extensions the backend needs; VizSession merges these + // into VkContext::Config before init. + virtual std::vector required_instance_extensions() const + { + return {}; + } + virtual std::vector required_device_extensions() const + { + return {}; + } + + // Allocate device resources. Throws on failure. + virtual void init(const VkContext& ctx, Resolution preferred_size) = 0; + + struct Frame + { + // Per-view info: 1 entry for window/offscreen, 2 for XR stereo. + // Compositor overrides per-layer viewport rects via tile_layout. + std::vector views; + + // Binary semaphores threaded into the compositor's submit. + // VK_NULL_HANDLE means none needed (kOffscreen). + VkSemaphore wait_before_render = VK_NULL_HANDLE; + VkPipelineStageFlags wait_stage = 0; + VkSemaphore signal_after_render = VK_NULL_HANDLE; + + // Backend-private bookkeeping round-tripped to record_post_* / + // end_frame (e.g. swapchain image_index). + uint64_t backend_token = 0; + }; + + // Acquire the next frame target. nullopt = skip this frame. + virtual std::optional begin_frame(int64_t predicted_display_time) = 0; + + // Intermediate RT layers render into. Render pass stays compatible + // across resize so layer pipelines remain valid. + virtual const RenderTarget& render_target() const = 0; + + // Backend-specific cmds between vkCmdEndRenderPass and submit + // (blit + transitions for kWindow, no-op for kOffscreen). + virtual void record_post_render_pass(VkCommandBuffer /*cmd*/, const Frame& /*frame*/) + { + } + + // Called after a successful submit AND the in-flight fence wait, + // so the GPU has finished this frame's command buffer and + // signal_after_render is signaled. Safe to vkQueuePresentKHR + // here. On any throw between submit and this call, abort_frame + // is called instead. + virtual void end_frame(const Frame& /*frame*/) + { + } + + // Called instead of end_frame when the frame is being abandoned + // due to exception. Backends MUST NOT present (the binary + // signal_after_render semaphore may be unsignaled), but should + // make the next begin_frame recover — typically by marking the + // swapchain dirty so it gets recreated. + virtual void abort_frame(const Frame& /*frame*/) + { + } + + virtual void poll_events() + { + } + + virtual bool should_close() const + { + return false; + } + + // Read-and-clear: returns true once after a resize event arrived. + virtual bool consume_resized() + { + return false; + } + + // Drain + recreate per-extent resources at the new size. The + // render pass survives. + virtual void resize(Resolution /*new_size*/) + { + } + + virtual Resolution current_extent() const = 0; + + // Only kOffscreen overrides; the rest throw. + virtual HostImage readback_to_host() + { + throw std::runtime_error("DisplayBackend: readback_to_host not supported on this backend"); + } + +protected: + DisplayBackend() = default; +}; + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/display_mode.hpp b/src/viz/session/cpp/inc/viz/session/display_mode.hpp new file mode 100644 index 000000000..f1067d7cd --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/display_mode.hpp @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +namespace viz +{ + +// Display backend for a VizSession. In its own header so VizSession +// and VizCompositor can both reference it without an include cycle. +enum class DisplayMode +{ + kOffscreen, + kWindow, + kXr, +}; + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/glfw_window.hpp b/src/viz/session/cpp/inc/viz/session/glfw_window.hpp new file mode 100644 index 000000000..c4438712b --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/glfw_window.hpp @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include +#include +#include + +struct GLFWwindow; + +namespace viz +{ + +// Owns one GLFWwindow + its VkSurfaceKHR. Refcount-initializes GLFW +// process-wide so multiple GlfwWindows can coexist; terminates GLFW +// when the last one is destroyed. The framebuffer-resize callback +// flips an atomic flag; VizCompositor checks it at frame start and +// recreates the swapchain on the next render() if set. +class GlfwWindow +{ +public: + // Creates the window + surface. Throws std::runtime_error if + // GLFW init fails (no display, missing libs) — call sites should + // catch and SKIP if running headless. + static std::unique_ptr create(VkInstance instance, + uint32_t width, + uint32_t height, + const std::string& title); + + // Process-wide refcounted glfwInit/Terminate. Pair these around + // any GLFW query (e.g. glfwGetRequiredInstanceExtensions) made + // outside a live GlfwWindow. retain() throws on init failure; + // release() must always be called on success paths. + static void retain(); + static void release() noexcept; + + ~GlfwWindow(); + void destroy(); + + GlfwWindow(const GlfwWindow&) = delete; + GlfwWindow& operator=(const GlfwWindow&) = delete; + GlfwWindow(GlfwWindow&&) = delete; + GlfwWindow& operator=(GlfwWindow&&) = delete; + + GLFWwindow* glfw() const noexcept + { + return window_; + } + VkSurfaceKHR surface() const noexcept + { + return surface_; + } + bool should_close() const noexcept; + void poll_events() noexcept; + Resolution framebuffer_size() const noexcept; + + // Returns true and clears the flag if the framebuffer was resized + // since the last call. Called by VizCompositor at frame start to + // decide whether to recreate the swapchain. + bool consume_resized() noexcept + { + return resized_.exchange(false, std::memory_order_acq_rel); + } + +private: + GlfwWindow(VkInstance instance, GLFWwindow* window, VkSurfaceKHR surface); + static void framebuffer_resize_callback(GLFWwindow* w, int width, int height); + + VkInstance instance_ = VK_NULL_HANDLE; + GLFWwindow* window_ = nullptr; + VkSurfaceKHR surface_ = VK_NULL_HANDLE; + std::atomic resized_{ false }; +}; + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/offscreen_backend.hpp b/src/viz/session/cpp/inc/viz/session/offscreen_backend.hpp new file mode 100644 index 000000000..e64882202 --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/offscreen_backend.hpp @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include + +namespace viz +{ + +// Renders into an intermediate RT; readback_to_host copies it to a +// host-visible buffer on demand. No present, no events. +class OffscreenBackend final : public DisplayBackend +{ +public: + OffscreenBackend(); + ~OffscreenBackend() override; + + void init(const VkContext& ctx, Resolution preferred_size) override; + + std::optional begin_frame(int64_t predicted_display_time) override; + const RenderTarget& render_target() const override; + + Resolution current_extent() const override; + + // Synchronous tightly-packed RGBA8 copy of the RT's color attachment. + HostImage readback_to_host() override; + + void destroy(); + +private: + void create_readback_staging(); + void destroy_readback_staging(); + + const VkContext* ctx_ = nullptr; + Resolution extent_{}; + std::unique_ptr render_target_; + + // Pre-allocated; reused per readback. + VkBuffer readback_buffer_ = VK_NULL_HANDLE; + VkDeviceMemory readback_memory_ = VK_NULL_HANDLE; + VkDeviceSize readback_byte_size_ = 0; + + // Dedicated cmd buffer so readback never races the compositor's. + VkCommandPool readback_command_pool_ = VK_NULL_HANDLE; + VkCommandBuffer readback_command_buffer_ = VK_NULL_HANDLE; +}; + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/swapchain.hpp b/src/viz/session/cpp/inc/viz/session/swapchain.hpp new file mode 100644 index 000000000..88f1cdeed --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/swapchain.hpp @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace viz +{ + +class VkContext; + +// VkSwapchainKHR + per-image semaphores. Prefers MAILBOX present +// mode, falls back to FIFO. Surface format prefers B8G8R8A8_SRGB +// then any *_SRGB then the runtime's first. +class Swapchain +{ +public: + static std::unique_ptr create(const VkContext& ctx, VkSurfaceKHR surface, Resolution preferred_size); + + ~Swapchain(); + void destroy(); + + Swapchain(const Swapchain&) = delete; + Swapchain& operator=(const Swapchain&) = delete; + Swapchain(Swapchain&&) = delete; + Swapchain& operator=(Swapchain&&) = delete; + + // Caller waits on image_available before TRANSFER_DST writes, + // signals render_done when done. Both semaphores are owned by + // Swapchain. nullopt only on OUT_OF_DATE; SUBOPTIMAL returns the + // image and lets the WSI scale on present. + struct AcquiredImage + { + uint32_t image_index; + VkImage image; + VkSemaphore image_available; + VkSemaphore render_done; + }; + std::optional acquire_next_image(); + + // Returns false on OUT_OF_DATE; SUBOPTIMAL is reported as success. + bool present(uint32_t image_index, VkSemaphore render_done); + + // Drain + recreate at the requested extent. Passes the old handle + // via oldSwapchain so the driver recycles internal resources. + void recreate(Resolution preferred_size); + + Resolution extent() const noexcept + { + return Resolution{ extent_.width, extent_.height }; + } + VkFormat format() const noexcept + { + return format_; + } + VkSwapchainKHR handle() const noexcept + { + return swapchain_; + } + uint32_t image_count() const noexcept + { + return static_cast(images_.size()); + } + // Look up a swapchain image by acquired index; VK_NULL_HANDLE if out of range. + VkImage image_at(uint32_t index) const noexcept + { + return index < images_.size() ? images_[index] : VK_NULL_HANDLE; + } + +private: + Swapchain(const VkContext& ctx, VkSurfaceKHR surface); + // old_swapchain is passed as VkSwapchainCreateInfoKHR::oldSwapchain + // so the driver recycles resources. VK_NULL_HANDLE on first create. + void init(Resolution preferred_size, VkSwapchainKHR old_swapchain = VK_NULL_HANDLE); + void destroy_swapchain_only(); + void create_semaphores(); + void destroy_semaphores(); + + const VkContext* ctx_ = nullptr; + VkSurfaceKHR surface_ = VK_NULL_HANDLE; + VkSwapchainKHR swapchain_ = VK_NULL_HANDLE; + VkFormat format_ = VK_FORMAT_UNDEFINED; + VkColorSpaceKHR color_space_ = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR; + VkExtent2D extent_{}; + std::vector images_; // not owned (swapchain owns) + + // Per-image-slot semaphore ring so an in-flight image never tries + // to reuse a semaphore another in-flight image still consumes. + std::vector image_available_; + std::vector render_done_; + uint32_t frame_slot_ = 0; +}; + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/tile_layout.hpp b/src/viz/session/cpp/inc/viz/session/tile_layout.hpp new file mode 100644 index 000000000..128074d14 --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/tile_layout.hpp @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include + +#include +#include + +namespace viz +{ + +// Per-layer rects from tile_layout(): outer is the equal-slice tile +// (used as scissor); content is the aspect-fit rect inside outer +// (used as viewport). Margins between them keep the clear color — +// free letterbox. +struct TileSlot +{ + VkRect2D outer{}; + VkRect2D content{}; +}; + +// Row-major aspect-preserving grid. cols = ceil(sqrt(N)), rows = +// ceil(N / cols). padding is the inter-tile gap in pixels. Empty +// input -> empty output. +std::vector tile_layout(const std::vector& aspects, Resolution fb_size, uint32_t padding = 0); + +} // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/viz_compositor.hpp b/src/viz/session/cpp/inc/viz/session/viz_compositor.hpp index 774e9ef17..857ba7935 100644 --- a/src/viz/session/cpp/inc/viz/session/viz_compositor.hpp +++ b/src/viz/session/cpp/inc/viz/session/viz_compositor.hpp @@ -5,7 +5,6 @@ #include #include -#include #include #include @@ -15,27 +14,22 @@ namespace viz { +class DisplayBackend; class LayerBase; class VkContext; -// VizCompositor: the per-session GPU pipeline that runs one render pass -// per frame. Owns the intermediate RenderTarget, command pool / buffer, -// and FrameSync. Iterates a layer registry (held by VizSession) calling -// each visible layer's record() inside the active render pass, then -// submits to the queue. -// -// Lifetime: owned by VizSession. Created when the session moves from -// kUninitialized to kReady; destroyed when the session is destroyed. +// One render pass per frame. Drives a non-owning DisplayBackend for +// mode-specific work (target image, present, readback). Owns the +// per-frame fence and command buffer; lifetime tied to VizSession. class VizCompositor { public: struct Config { - Resolution resolution{}; VkClearColorValue clear_color{ { 0.0f, 0.0f, 0.0f, 1.0f } }; }; - static std::unique_ptr create(const VkContext& ctx, const Config& config); + static std::unique_ptr create(const VkContext& ctx, DisplayBackend& backend, const Config& config); ~VizCompositor(); void destroy(); @@ -45,61 +39,36 @@ class VizCompositor VizCompositor(VizCompositor&&) = delete; VizCompositor& operator=(VizCompositor&&) = delete; - // Records and submits one frame. Iterates `layers` (insertion order), - // skipping invisible ones, calling layer->record() inside the active - // render pass. Blocks on the previous frame's fence before recording - // and on the new fence before returning (1-frame-in-flight today). - // - // Throws std::runtime_error on Vulkan failure. - void render(const std::vector& layers, const std::vector& views); - - // Read the most recent frame's color attachment back to a host - // buffer. Returns a HostImage owning tightly-packed RGBA8 bytes; - // call HostImage::view() to obtain a VizBuffer view suitable for - // image helpers. The caller must have called render() at least - // once; pixels are undefined otherwise. Used by tests / debug - // tooling — production (CUDA-pointer) readback ships with - // CUDA-Vulkan interop. + // Records and submits one frame. Synchronous (waits for GPU + // completion before returning). QuadLayer's mailbox depends on + // that — see quad_layer.hpp. + void render(const std::vector& layers); + + // Forwards to backend; convenience for VizSession. HostImage readback_to_host(); - // Accessors for layers / external code that needs to build pipelines - // against the compositor's render pass. VkRenderPass render_pass() const noexcept; Resolution resolution() const noexcept; private: - VizCompositor(const VkContext& ctx, const Config& config); + VizCompositor(const VkContext& ctx, DisplayBackend& backend, const Config& config); void init(); void create_command_pool(); void create_command_buffer(); - void create_readback_staging(); - - // vkQueueSubmit wrapper that recovers the fence if submit fails. - // After frame_sync_->reset(), the fence is unsignaled; if the real - // submit then fails, the next frame_sync_->wait() would deadlock - // forever on UINT64_MAX. On submit failure we attempt an empty - // no-op submit so the fence gets signaled, converting "silent - // hang" into "throw on next call" — the caller can then destroy + - // recreate the session. + + // vkQueueSubmit wrapper. On failure, posts an empty submit so the + // fence still gets signaled — converts "silent deadlock on next + // wait" into "throw on next call". void submit_or_signal_fence(const VkSubmitInfo& info, const char* what); const VkContext* ctx_ = nullptr; + DisplayBackend* backend_ = nullptr; Config config_{}; - std::unique_ptr render_target_; std::unique_ptr frame_sync_; - VkCommandPool command_pool_ = VK_NULL_HANDLE; VkCommandBuffer command_buffer_ = VK_NULL_HANDLE; - - // Pre-allocated host-visible staging buffer for readback_to_host. - // Created once at init() (sized to the configured resolution), - // reused on every readback, freed in destroy(). Avoids per-call - // allocation churn and removes the leak-on-throw concern entirely. - VkBuffer readback_buffer_ = VK_NULL_HANDLE; - VkDeviceMemory readback_memory_ = VK_NULL_HANDLE; - VkDeviceSize readback_byte_size_ = 0; }; } // namespace viz diff --git a/src/viz/session/cpp/inc/viz/session/viz_session.hpp b/src/viz/session/cpp/inc/viz/session/viz_session.hpp index 1a4be519c..6b664ecd8 100644 --- a/src/viz/session/cpp/inc/viz/session/viz_session.hpp +++ b/src/viz/session/cpp/inc/viz/session/viz_session.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -19,17 +20,7 @@ namespace viz { -// Display backend selection at session creation time. -// -// kOffscreen is the only mode implemented today; readback_to_host() is -// the primary output. kWindow (GLFW) and kXr (OpenXR + CloudXR) ship -// with the window-mode and XR-mode milestones respectively. -enum class DisplayMode -{ - kOffscreen, - kWindow, - kXr, -}; +class DisplayBackend; // Lifecycle states for a VizSession. The full set covers XR; window / // offscreen modes only transition through: @@ -76,9 +67,14 @@ class VizSession // Layers render on top of this. Defaults to opaque black. float clear_color[4] = { 0.0f, 0.0f, 0.0f, 1.0f }; - // Optional pre-built Vulkan context. If null, the session creates - // its own VkContext. Pass an externally-owned ctx (heap or static) - // when sharing the device with another component. + // Optional pre-built Vulkan context. If null, the session + // creates its own VkContext. The caller-supplied context + // MUST already have the backend's required extensions + // enabled — VK_KHR_swapchain (+ surface extensions) for + // kWindow, OpenXR-Vulkan extensions for kXr. VizSession does + // NOT retroactively enable them; backend init will fail late + // if they're missing. The physical device must also support + // present on the eventual surface in kWindow mode. VkContext* external_context = nullptr; // OpenXR instance extensions to enable beyond Televiz's required @@ -161,12 +157,21 @@ class VizSession // their own pipelines. nullptr before create() / after destroy(). const VkContext* get_vk_context() const noexcept; + // True when the underlying display target has been asked to close + // (user clicked the window close button, etc.). Always false in + // kOffscreen / kXr. Drives application loops: + // while (!session.should_close()) session.render(); + bool should_close() const noexcept; + private: explicit VizSession(const Config& config); void init(); const VkContext& ctx() const noexcept; void update_timing_stats(float frame_time_seconds); + // Poll backend events + handle resize. Called by render() and + // begin_frame() so explicit-loop users get the same behavior. + void pump_events(); Config config_{}; @@ -174,6 +179,11 @@ class VizSession std::unique_ptr owned_ctx_; VkContext* ctx_ptr_ = nullptr; + // Display backend (picked from config_.mode at init). Owns mode- + // specific resources. Must outlive compositor_ (compositor holds + // a non-owning ref) and is destroyed before the VkContext. + std::unique_ptr backend_; + std::unique_ptr compositor_; std::vector> layers_; diff --git a/src/viz/session/cpp/inc/viz/session/window_backend.hpp b/src/viz/session/cpp/inc/viz/session/window_backend.hpp new file mode 100644 index 000000000..3e59bd18b --- /dev/null +++ b/src/viz/session/cpp/inc/viz/session/window_backend.hpp @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include + +#include +#include +#include +#include + +namespace viz +{ + +class GlfwWindow; +class Swapchain; + +// GLFW window + Vulkan swapchain. record_post_render_pass blits the +// intermediate RT to the swapchain image; end_frame presents. +class WindowBackend final : public DisplayBackend +{ +public: + struct Config + { + uint32_t width = 1024; + uint32_t height = 1024; + std::string title = "televiz"; + // Soft fps cap; 0 = primary monitor's refresh rate. + uint32_t target_fps = 0; + }; + + explicit WindowBackend(Config config); + ~WindowBackend() override; + + std::vector required_instance_extensions() const override; + std::vector required_device_extensions() const override; + void init(const VkContext& ctx, Resolution preferred_size) override; + + std::optional begin_frame(int64_t predicted_display_time) override; + const RenderTarget& render_target() const override; + void record_post_render_pass(VkCommandBuffer cmd, const Frame& frame) override; + void end_frame(const Frame& frame) override; + void abort_frame(const Frame& frame) override; + + void poll_events() override; + bool should_close() const override; + bool consume_resized() override; + void resize(Resolution new_size) override; + Resolution current_extent() const override; + + void destroy(); + +private: + Config config_; + const VkContext* ctx_ = nullptr; + + std::unique_ptr window_; + std::unique_ptr swapchain_; + std::unique_ptr render_target_; + + // MAILBOX doesn't throttle acquire; the pacer at begin_frame's + // start caps render rate (and runs even on OUT_OF_DATE early-out + // so the loop can't spin). + std::chrono::nanoseconds frame_period_{ 0 }; + std::chrono::steady_clock::time_point next_frame_deadline_{}; + + // Set by abort_frame and by acquire-time OUT_OF_DATE; consumed + // at the top of the next begin_frame, which forces a swapchain + // recreate before doing anything else. + bool needs_recreate_ = false; + + // Recreate swapchain + RT at the current window framebuffer size. + // Skips the size-match check that resize() applies, because + // OUT_OF_DATE fires for non-size reasons too (monitor reconfig, + // format change). Returns false if the recreate cannot run (e.g. + // minimized window) so the caller can keep the dirty flag set. + bool force_recreate(); +}; + +} // namespace viz diff --git a/src/viz/session/cpp/offscreen_backend.cpp b/src/viz/session/cpp/offscreen_backend.cpp new file mode 100644 index 000000000..9b2a86ac1 --- /dev/null +++ b/src/viz/session/cpp/offscreen_backend.cpp @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include + +namespace viz +{ + +namespace +{ + +void check_vk(VkResult r, const char* what) +{ + if (r != VK_SUCCESS) + { + throw std::runtime_error(std::string("OffscreenBackend: ") + what + " failed: VkResult=" + std::to_string(r)); + } +} + +uint32_t find_memory_type(VkPhysicalDevice physical_device, uint32_t type_bits, VkMemoryPropertyFlags properties) +{ + VkPhysicalDeviceMemoryProperties mem_props; + vkGetPhysicalDeviceMemoryProperties(physical_device, &mem_props); + for (uint32_t i = 0; i < mem_props.memoryTypeCount; ++i) + { + if ((type_bits & (1u << i)) != 0 && (mem_props.memoryTypes[i].propertyFlags & properties) == properties) + { + return i; + } + } + throw std::runtime_error("OffscreenBackend: no memory type matches readback requirements"); +} + +} // namespace + +OffscreenBackend::OffscreenBackend() = default; + +OffscreenBackend::~OffscreenBackend() +{ + destroy(); +} + +void OffscreenBackend::init(const VkContext& ctx, Resolution preferred_size) +{ + if (preferred_size.width == 0 || preferred_size.height == 0) + { + throw std::invalid_argument("OffscreenBackend::init: extent must be non-zero"); + } + ctx_ = &ctx; + extent_ = preferred_size; + try + { + render_target_ = RenderTarget::create(ctx, RenderTarget::Config{ extent_ }); + create_readback_staging(); + } + catch (...) + { + destroy(); + throw; + } +} + +void OffscreenBackend::destroy() +{ + destroy_readback_staging(); + render_target_.reset(); + extent_ = Resolution{}; + ctx_ = nullptr; +} + +std::optional OffscreenBackend::begin_frame(int64_t /*predicted_display_time*/) +{ + if (render_target_ == nullptr) + { + return std::nullopt; + } + Frame f{}; + // Single identity view; compositor overrides viewport per-layer + // via tile_layout. + f.views.assign(1, ViewInfo{}); + f.views[0].viewport = Rect2D{ 0, 0, extent_.width, extent_.height }; + return f; +} + +const RenderTarget& OffscreenBackend::render_target() const +{ + if (render_target_ == nullptr) + { + throw std::runtime_error("OffscreenBackend::render_target: backend not initialized"); + } + return *render_target_; +} + +Resolution OffscreenBackend::current_extent() const +{ + return extent_; +} + +HostImage OffscreenBackend::readback_to_host() +{ + if (render_target_ == nullptr || readback_buffer_ == VK_NULL_HANDLE) + { + throw std::runtime_error("OffscreenBackend::readback_to_host: backend not initialized"); + } + + // RT is in TRANSFER_SRC_OPTIMAL from the render pass's final layout. + check_vk(vkResetCommandBuffer(readback_command_buffer_, 0), "vkResetCommandBuffer(readback)"); + + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check_vk(vkBeginCommandBuffer(readback_command_buffer_, &begin), "vkBeginCommandBuffer(readback)"); + + VkBufferImageCopy region{}; + region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.imageSubresource.layerCount = 1; + region.imageExtent = { extent_.width, extent_.height, 1 }; + vkCmdCopyImageToBuffer(readback_command_buffer_, render_target_->color_image(), + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback_buffer_, 1, ®ion); + + check_vk(vkEndCommandBuffer(readback_command_buffer_), "vkEndCommandBuffer(readback)"); + + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &readback_command_buffer_; + check_vk(vkQueueSubmit(ctx_->queue(), 1, &submit, VK_NULL_HANDLE), "vkQueueSubmit(readback)"); + check_vk(vkQueueWaitIdle(ctx_->queue()), "vkQueueWaitIdle(readback)"); + + HostImage result(extent_, PixelFormat::kRGBA8); + void* mapped = nullptr; + check_vk(vkMapMemory(ctx_->device(), readback_memory_, 0, readback_byte_size_, 0, &mapped), "vkMapMemory(readback)"); + std::memcpy(result.data(), mapped, readback_byte_size_); + vkUnmapMemory(ctx_->device(), readback_memory_); + return result; +} + +void OffscreenBackend::create_readback_staging() +{ + readback_byte_size_ = + static_cast(extent_.width) * extent_.height * bytes_per_pixel(PixelFormat::kRGBA8); + + VkBufferCreateInfo bi{}; + bi.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bi.size = readback_byte_size_; + bi.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + bi.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check_vk(vkCreateBuffer(ctx_->device(), &bi, nullptr, &readback_buffer_), "vkCreateBuffer(readback)"); + + VkMemoryRequirements reqs; + vkGetBufferMemoryRequirements(ctx_->device(), readback_buffer_, &reqs); + + VkMemoryAllocateInfo ai{}; + ai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + ai.allocationSize = reqs.size; + ai.memoryTypeIndex = find_memory_type(ctx_->physical_device(), reqs.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + check_vk(vkAllocateMemory(ctx_->device(), &ai, nullptr, &readback_memory_), "vkAllocateMemory(readback)"); + check_vk(vkBindBufferMemory(ctx_->device(), readback_buffer_, readback_memory_, 0), "vkBindBufferMemory(readback)"); + + // Dedicated cmd pool — never races the compositor's per-frame buffer. + VkCommandPoolCreateInfo pi{}; + pi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pi.queueFamilyIndex = ctx_->queue_family_index(); + check_vk(vkCreateCommandPool(ctx_->device(), &pi, nullptr, &readback_command_pool_), "vkCreateCommandPool(readback)"); + VkCommandBufferAllocateInfo ai2{}; + ai2.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + ai2.commandPool = readback_command_pool_; + ai2.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + ai2.commandBufferCount = 1; + check_vk(vkAllocateCommandBuffers(ctx_->device(), &ai2, &readback_command_buffer_), + "vkAllocateCommandBuffers(readback)"); +} + +void OffscreenBackend::destroy_readback_staging() +{ + if (ctx_ == nullptr) + { + return; + } + const VkDevice device = ctx_->device(); + if (device == VK_NULL_HANDLE) + { + return; + } + if (readback_command_pool_ != VK_NULL_HANDLE) + { + vkDestroyCommandPool(device, readback_command_pool_, nullptr); + readback_command_pool_ = VK_NULL_HANDLE; + readback_command_buffer_ = VK_NULL_HANDLE; + } + if (readback_buffer_ != VK_NULL_HANDLE) + { + vkDestroyBuffer(device, readback_buffer_, nullptr); + readback_buffer_ = VK_NULL_HANDLE; + } + if (readback_memory_ != VK_NULL_HANDLE) + { + vkFreeMemory(device, readback_memory_, nullptr); + readback_memory_ = VK_NULL_HANDLE; + } + readback_byte_size_ = 0; +} + +} // namespace viz diff --git a/src/viz/session/cpp/swapchain.cpp b/src/viz/session/cpp/swapchain.cpp new file mode 100644 index 000000000..60583a808 --- /dev/null +++ b/src/viz/session/cpp/swapchain.cpp @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include + +namespace viz +{ + +namespace +{ + +void check_vk(VkResult r, const char* what) +{ + if (r != VK_SUCCESS) + { + throw std::runtime_error(std::string("Swapchain: ") + what + " failed: VkResult=" + std::to_string(r)); + } +} + +// Pick a surface format. Prefer B8G8R8A8_SRGB (common Linux default, +// matches our intermediate framebuffer's sRGB color space). Fall back +// to any *_SRGB format. Else accept whatever the runtime offers first. +VkSurfaceFormatKHR pick_surface_format(const std::vector& formats) +{ + for (const auto& f : formats) + { + if (f.format == VK_FORMAT_B8G8R8A8_SRGB && f.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) + { + return f; + } + } + for (const auto& f : formats) + { + if (f.format == VK_FORMAT_R8G8B8A8_SRGB && f.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) + { + return f; + } + } + return formats.empty() ? VkSurfaceFormatKHR{ VK_FORMAT_UNDEFINED, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR } : formats[0]; +} + +VkExtent2D clamp_extent(const VkSurfaceCapabilitiesKHR& caps, Resolution preferred) +{ + // Surface may dictate the extent (currentExtent != UINT32_MAX); + // otherwise we pick within minImageExtent..maxImageExtent. + if (caps.currentExtent.width != UINT32_MAX) + { + return caps.currentExtent; + } + VkExtent2D e{ preferred.width, preferred.height }; + e.width = std::clamp(e.width, caps.minImageExtent.width, caps.maxImageExtent.width); + e.height = std::clamp(e.height, caps.minImageExtent.height, caps.maxImageExtent.height); + return e; +} + +} // namespace + +std::unique_ptr Swapchain::create(const VkContext& ctx, VkSurfaceKHR surface, Resolution preferred_size) +{ + if (!ctx.is_initialized()) + { + throw std::invalid_argument("Swapchain::create: VkContext is not initialized"); + } + if (surface == VK_NULL_HANDLE) + { + throw std::invalid_argument("Swapchain::create: surface is VK_NULL_HANDLE"); + } + if (preferred_size.width == 0 || preferred_size.height == 0) + { + throw std::invalid_argument("Swapchain::create: preferred size must be non-zero"); + } + + // Validate the chosen queue family supports presentation on this + // surface — required by Vulkan spec for vkQueuePresentKHR. + // + // KNOWN LIMITATION: VkContext picks the physical device before + // the surface exists, so we can only fail here rather than route + // around it. On a multi-GPU host where the Vulkan-preferred + // device isn't the one connected to the display, this throws + // and the caller has to pick a different physical_device_index. + // Proper fix is a presentation-support callback through + // VkContext::Config (e.g., glfwGetPhysicalDevicePresentationSupport) + // — deferred until a real multi-GPU user reports this. + VkBool32 present_supported = VK_FALSE; + check_vk(vkGetPhysicalDeviceSurfaceSupportKHR( + ctx.physical_device(), ctx.queue_family_index(), surface, &present_supported), + "vkGetPhysicalDeviceSurfaceSupportKHR"); + if (!present_supported) + { + throw std::runtime_error("Swapchain::create: chosen queue family does not support present on this surface"); + } + + std::unique_ptr sc(new Swapchain(ctx, surface)); + sc->init(preferred_size); + return sc; +} + +Swapchain::Swapchain(const VkContext& ctx, VkSurfaceKHR surface) : ctx_(&ctx), surface_(surface) +{ +} + +Swapchain::~Swapchain() +{ + destroy(); +} + +void Swapchain::init(Resolution preferred_size, VkSwapchainKHR old_swapchain) +{ + try + { + const VkPhysicalDevice phys = ctx_->physical_device(); + const VkDevice device = ctx_->device(); + + VkSurfaceCapabilitiesKHR caps{}; + check_vk(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(phys, surface_, &caps), + "vkGetPhysicalDeviceSurfaceCapabilitiesKHR"); + + uint32_t format_count = 0; + vkGetPhysicalDeviceSurfaceFormatsKHR(phys, surface_, &format_count, nullptr); + std::vector formats(format_count); + if (format_count > 0) + { + vkGetPhysicalDeviceSurfaceFormatsKHR(phys, surface_, &format_count, formats.data()); + } + const VkSurfaceFormatKHR chosen = pick_surface_format(formats); + if (chosen.format == VK_FORMAT_UNDEFINED) + { + throw std::runtime_error("Swapchain::init: surface reports no formats"); + } + format_ = chosen.format; + color_space_ = chosen.colorSpace; + extent_ = clamp_extent(caps, preferred_size); + + // Triple-buffer if the runtime allows it; otherwise the min. + uint32_t image_count = caps.minImageCount + 1; + if (caps.maxImageCount > 0) + { + image_count = std::min(image_count, caps.maxImageCount); + } + + VkSwapchainCreateInfoKHR info{}; + info.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; + info.surface = surface_; + info.minImageCount = image_count; + info.imageFormat = format_; + info.imageColorSpace = color_space_; + info.imageExtent = extent_; + info.imageArrayLayers = 1; + // TRANSFER_DST: we blit the intermediate framebuffer into the + // swapchain image. No COLOR_ATTACHMENT — we never render + // directly into swapchain images. + info.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; + info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; + info.preTransform = caps.currentTransform; + info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + + // Prefer MAILBOX (no compositor sync stalls); FIFO is the + // universal fallback. App throttles its own render rate. + VkPresentModeKHR present_mode = VK_PRESENT_MODE_FIFO_KHR; + uint32_t pm_count = 0; + vkGetPhysicalDeviceSurfacePresentModesKHR(phys, surface_, &pm_count, nullptr); + std::vector available_modes(pm_count); + if (pm_count > 0) + { + vkGetPhysicalDeviceSurfacePresentModesKHR(phys, surface_, &pm_count, available_modes.data()); + } + for (VkPresentModeKHR m : available_modes) + { + if (m == VK_PRESENT_MODE_MAILBOX_KHR) + { + present_mode = m; + break; + } + } + info.presentMode = present_mode; + info.clipped = VK_TRUE; + info.oldSwapchain = old_swapchain; + + check_vk(vkCreateSwapchainKHR(device, &info, nullptr, &swapchain_), "vkCreateSwapchainKHR"); + + uint32_t actual = 0; + vkGetSwapchainImagesKHR(device, swapchain_, &actual, nullptr); + images_.resize(actual); + vkGetSwapchainImagesKHR(device, swapchain_, &actual, images_.data()); + + create_semaphores(); + } + catch (...) + { + destroy_swapchain_only(); + throw; + } +} + +void Swapchain::create_semaphores() +{ + const VkDevice device = ctx_->device(); + image_available_.resize(images_.size(), VK_NULL_HANDLE); + render_done_.resize(images_.size(), VK_NULL_HANDLE); + VkSemaphoreCreateInfo sem_info{}; + sem_info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + for (size_t i = 0; i < images_.size(); ++i) + { + check_vk( + vkCreateSemaphore(device, &sem_info, nullptr, &image_available_[i]), "vkCreateSemaphore(image_available)"); + check_vk(vkCreateSemaphore(device, &sem_info, nullptr, &render_done_[i]), "vkCreateSemaphore(render_done)"); + } +} + +void Swapchain::destroy_semaphores() +{ + if (ctx_ == nullptr) + { + return; + } + const VkDevice device = ctx_->device(); + if (device == VK_NULL_HANDLE) + { + image_available_.clear(); + render_done_.clear(); + return; + } + for (VkSemaphore s : image_available_) + { + if (s != VK_NULL_HANDLE) + { + vkDestroySemaphore(device, s, nullptr); + } + } + image_available_.clear(); + for (VkSemaphore s : render_done_) + { + if (s != VK_NULL_HANDLE) + { + vkDestroySemaphore(device, s, nullptr); + } + } + render_done_.clear(); +} + +void Swapchain::destroy_swapchain_only() +{ + if (ctx_ == nullptr) + { + return; + } + const VkDevice device = ctx_->device(); + if (device != VK_NULL_HANDLE) + { + // Drain so we don't destroy semaphores still referenced by the queue. + (void)vkDeviceWaitIdle(device); + } + destroy_semaphores(); + if (swapchain_ != VK_NULL_HANDLE && device != VK_NULL_HANDLE) + { + vkDestroySwapchainKHR(device, swapchain_, nullptr); + swapchain_ = VK_NULL_HANDLE; + } + images_.clear(); + extent_ = VkExtent2D{ 0, 0 }; + frame_slot_ = 0; +} + +void Swapchain::destroy() +{ + destroy_swapchain_only(); + surface_ = VK_NULL_HANDLE; + ctx_ = nullptr; +} + +void Swapchain::recreate(Resolution preferred_size) +{ + if (swapchain_ == VK_NULL_HANDLE) + { + init(preferred_size); + return; + } + + const VkDevice device = ctx_->device(); + (void)vkDeviceWaitIdle(device); + + // Hand the old swapchain to vkCreateSwapchainKHR via oldSwapchain + // so the driver can recycle resources. Keep the old handle alive + // until init() succeeds; destroy it after. + VkSwapchainKHR old = swapchain_; + swapchain_ = VK_NULL_HANDLE; + destroy_semaphores(); + images_.clear(); + extent_ = VkExtent2D{ 0, 0 }; + frame_slot_ = 0; + + try + { + init(preferred_size, old); + } + catch (...) + { + if (old != VK_NULL_HANDLE) + { + vkDestroySwapchainKHR(device, old, nullptr); + } + throw; + } + + // Success: the new swapchain has assumed ownership of any + // recyclable resources. Destroy the old handle now. + vkDestroySwapchainKHR(device, old, nullptr); +} + +std::optional Swapchain::acquire_next_image() +{ + if (swapchain_ == VK_NULL_HANDLE || image_available_.empty()) + { + return std::nullopt; + } + const VkSemaphore sem = image_available_[frame_slot_]; + uint32_t image_index = 0; + const VkResult r = vkAcquireNextImageKHR(ctx_->device(), swapchain_, UINT64_MAX, sem, VK_NULL_HANDLE, &image_index); + // OUT_OF_DATE: caller must recreate. SUBOPTIMAL: image is valid, + // pass it through and let the WSI scale on present. + if (r == VK_ERROR_OUT_OF_DATE_KHR) + { + return std::nullopt; + } + if (r != VK_SUCCESS && r != VK_SUBOPTIMAL_KHR) + { + throw std::runtime_error("Swapchain::acquire_next_image: VkResult=" + std::to_string(r)); + } + return AcquiredImage{ image_index, images_[image_index], sem, render_done_[frame_slot_] }; +} + +bool Swapchain::present(uint32_t image_index, VkSemaphore render_done) +{ + if (swapchain_ == VK_NULL_HANDLE) + { + return false; + } + VkPresentInfoKHR info{}; + info.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + info.waitSemaphoreCount = (render_done != VK_NULL_HANDLE) ? 1 : 0; + info.pWaitSemaphores = (render_done != VK_NULL_HANDLE) ? &render_done : nullptr; + info.swapchainCount = 1; + info.pSwapchains = &swapchain_; + info.pImageIndices = &image_index; + const VkResult r = vkQueuePresentKHR(ctx_->queue(), &info); + // Advance the slot regardless — next frame needs fresh semaphores. + if (!images_.empty()) + { + frame_slot_ = (frame_slot_ + 1) % static_cast(images_.size()); + } + if (r == VK_ERROR_OUT_OF_DATE_KHR) + { + return false; + } + if (r != VK_SUCCESS && r != VK_SUBOPTIMAL_KHR) + { + throw std::runtime_error("Swapchain::present: VkResult=" + std::to_string(r)); + } + return true; +} + +} // namespace viz diff --git a/src/viz/session/cpp/tile_layout.cpp b/src/viz/session/cpp/tile_layout.cpp new file mode 100644 index 000000000..bd45b03a0 --- /dev/null +++ b/src/viz/session/cpp/tile_layout.cpp @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include + +namespace viz +{ + +namespace +{ + +// Aspect-fit `content_aspect` (w/h) inside an outer rect with sides +// `outer_w` x `outer_h`. Returns offset+extent inside the outer. +VkRect2D aspect_fit(float content_aspect, uint32_t outer_w, uint32_t outer_h) +{ + if (outer_w == 0 || outer_h == 0 || content_aspect <= 0.0f) + { + return VkRect2D{ { 0, 0 }, { 0, 0 } }; + } + const float outer_aspect = static_cast(outer_w) / static_cast(outer_h); + uint32_t fit_w = outer_w; + uint32_t fit_h = outer_h; + if (content_aspect > outer_aspect) + { + // content wider than outer → letterbox top/bottom + fit_h = static_cast(static_cast(outer_w) / content_aspect); + } + else + { + // content taller than outer → letterbox left/right + fit_w = static_cast(static_cast(outer_h) * content_aspect); + } + const int32_t off_x = static_cast((outer_w - fit_w) / 2); + const int32_t off_y = static_cast((outer_h - fit_h) / 2); + return VkRect2D{ { off_x, off_y }, { fit_w, fit_h } }; +} + +} // namespace + +std::vector tile_layout(const std::vector& aspects, Resolution fb_size, uint32_t padding) +{ + const uint32_t n = static_cast(aspects.size()); + if (n == 0 || fb_size.width == 0 || fb_size.height == 0) + { + return {}; + } + + // Row-major grid. cols = ceil(sqrt(n)), rows = ceil(n / cols). + const uint32_t cols = static_cast(std::ceil(std::sqrt(static_cast(n)))); + const uint32_t rows = (n + cols - 1) / cols; + + // Equal-slice per tile (integer division — last column/row absorbs + // the remainder so the grid covers the whole framebuffer). + const uint32_t base_tile_w = fb_size.width / cols; + const uint32_t base_tile_h = fb_size.height / rows; + + std::vector slots; + slots.reserve(n); + for (uint32_t i = 0; i < n; ++i) + { + const uint32_t row = i / cols; + const uint32_t col = i % cols; + + const uint32_t tile_x = col * base_tile_w; + const uint32_t tile_y = row * base_tile_h; + const uint32_t tile_w = (col == cols - 1) ? (fb_size.width - tile_x) : base_tile_w; + const uint32_t tile_h = (row == rows - 1) ? (fb_size.height - tile_y) : base_tile_h; + + // Apply padding by shrinking the outer tile symmetrically. If + // padding swallows the tile, clamp to a 1x1 to keep downstream + // viewport binds happy. + const uint32_t pad_w = std::min(padding, tile_w / 2); + const uint32_t pad_h = std::min(padding, tile_h / 2); + const uint32_t outer_w = std::max(1, tile_w - 2 * pad_w); + const uint32_t outer_h = std::max(1, tile_h - 2 * pad_h); + const int32_t outer_x = static_cast(tile_x + pad_w); + const int32_t outer_y = static_cast(tile_y + pad_h); + + TileSlot slot{}; + slot.outer = VkRect2D{ { outer_x, outer_y }, { outer_w, outer_h } }; + + // Aspect-fit content rect inside outer, then translate. + const VkRect2D fit = aspect_fit(aspects[i], outer_w, outer_h); + slot.content = VkRect2D{ { outer_x + fit.offset.x, outer_y + fit.offset.y }, fit.extent }; + + slots.push_back(slot); + } + return slots; +} + +} // namespace viz diff --git a/src/viz/session/cpp/viz_compositor.cpp b/src/viz/session/cpp/viz_compositor.cpp index 9c2a6a76d..7b13e4264 100644 --- a/src/viz/session/cpp/viz_compositor.cpp +++ b/src/viz/session/cpp/viz_compositor.cpp @@ -3,10 +3,11 @@ #include #include +#include +#include #include #include -#include #include #include @@ -24,38 +25,26 @@ void check_vk(VkResult result, const char* what) } } -uint32_t find_memory_type(VkPhysicalDevice physical_device, uint32_t type_bits, VkMemoryPropertyFlags properties) +Rect2D to_rect2d(const VkRect2D& r) { - VkPhysicalDeviceMemoryProperties mem_props; - vkGetPhysicalDeviceMemoryProperties(physical_device, &mem_props); - for (uint32_t i = 0; i < mem_props.memoryTypeCount; ++i) - { - if ((type_bits & (1u << i)) != 0 && (mem_props.memoryTypes[i].propertyFlags & properties) == properties) - { - return i; - } - } - throw std::runtime_error("VizCompositor: no memory type matches readback requirements"); + return Rect2D{ r.offset.x, r.offset.y, r.extent.width, r.extent.height }; } } // namespace -std::unique_ptr VizCompositor::create(const VkContext& ctx, const Config& config) +std::unique_ptr VizCompositor::create(const VkContext& ctx, DisplayBackend& backend, const Config& config) { if (!ctx.is_initialized()) { throw std::invalid_argument("VizCompositor: VkContext is not initialized"); } - if (config.resolution.width == 0 || config.resolution.height == 0) - { - throw std::invalid_argument("VizCompositor: resolution must be non-zero"); - } - std::unique_ptr c(new VizCompositor(ctx, config)); + std::unique_ptr c(new VizCompositor(ctx, backend, config)); c->init(); return c; } -VizCompositor::VizCompositor(const VkContext& ctx, const Config& config) : ctx_(&ctx), config_(config) +VizCompositor::VizCompositor(const VkContext& ctx, DisplayBackend& backend, const Config& config) + : ctx_(&ctx), backend_(&backend), config_(config) { } @@ -68,11 +57,9 @@ void VizCompositor::init() { try { - render_target_ = RenderTarget::create(*ctx_, RenderTarget::Config{ config_.resolution }); frame_sync_ = FrameSync::create(*ctx_); create_command_pool(); create_command_buffer(); - create_readback_staging(); } catch (...) { @@ -92,17 +79,6 @@ void VizCompositor::destroy() { return; } - if (readback_buffer_ != VK_NULL_HANDLE) - { - vkDestroyBuffer(device, readback_buffer_, nullptr); - readback_buffer_ = VK_NULL_HANDLE; - } - if (readback_memory_ != VK_NULL_HANDLE) - { - vkFreeMemory(device, readback_memory_, nullptr); - readback_memory_ = VK_NULL_HANDLE; - } - readback_byte_size_ = 0; if (command_pool_ != VK_NULL_HANDLE) { // Pool destruction frees all command buffers allocated from it. @@ -111,7 +87,6 @@ void VizCompositor::destroy() command_buffer_ = VK_NULL_HANDLE; } frame_sync_.reset(); - render_target_.reset(); } void VizCompositor::create_command_pool() @@ -133,34 +108,6 @@ void VizCompositor::create_command_buffer() check_vk(vkAllocateCommandBuffers(ctx_->device(), &info, &command_buffer_), "vkAllocateCommandBuffers"); } -void VizCompositor::create_readback_staging() -{ - // Sized to one tightly-packed RGBA8 frame at the configured - // resolution. destroy() owns cleanup; readback_to_host() never - // allocates per call. - readback_byte_size_ = static_cast(config_.resolution.width) * config_.resolution.height * - bytes_per_pixel(PixelFormat::kRGBA8); - - VkBufferCreateInfo bi{}; - bi.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; - bi.size = readback_byte_size_; - bi.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; - bi.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - check_vk(vkCreateBuffer(ctx_->device(), &bi, nullptr, &readback_buffer_), "vkCreateBuffer(readback staging)"); - - VkMemoryRequirements reqs; - vkGetBufferMemoryRequirements(ctx_->device(), readback_buffer_, &reqs); - - VkMemoryAllocateInfo ai{}; - ai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - ai.allocationSize = reqs.size; - ai.memoryTypeIndex = find_memory_type(ctx_->physical_device(), reqs.memoryTypeBits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); - check_vk(vkAllocateMemory(ctx_->device(), &ai, nullptr, &readback_memory_), "vkAllocateMemory(readback staging)"); - check_vk(vkBindBufferMemory(ctx_->device(), readback_buffer_, readback_memory_, 0), - "vkBindBufferMemory(readback staging)"); -} - void VizCompositor::submit_or_signal_fence(const VkSubmitInfo& info, const char* what) { const VkResult r = vkQueueSubmit(ctx_->queue(), 1, &info, frame_sync_->in_flight_fence()); @@ -168,24 +115,98 @@ void VizCompositor::submit_or_signal_fence(const VkSubmitInfo& info, const char* { return; } - // Real submit failed; the fence is still unsignaled. Best-effort - // signal it via an empty no-op submit so the next wait() throws - // (or returns) instead of deadlocking on UINT64_MAX. If this also - // fails the original error still propagates and the caller should - // destroy + recreate the session. VkSubmitInfo empty{}; empty.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; (void)vkQueueSubmit(ctx_->queue(), 1, &empty, frame_sync_->in_flight_fence()); throw std::runtime_error(std::string("VizCompositor: ") + what + " failed: VkResult=" + std::to_string(r)); } -void VizCompositor::render(const std::vector& layers, const std::vector& views) +void VizCompositor::render(const std::vector& layers) { - // Wait for the previous frame's GPU work to complete before reusing - // the command buffer / fence (1 frame in flight today). + // Wait for previous frame (1 frame in flight). frame_sync_->wait(); - check_vk(vkResetCommandBuffer(command_buffer_, 0), "vkResetCommandBuffer"); + // RAII: leave the command buffer in INITIAL state on every exit + // path (success or throw). VizSession::pump_events() runs between + // render() calls and may destroy framebuffer attachments, which + // Vulkan forbids while any cmd buffer that references them is in + // RECORDING / EXECUTABLE / PENDING state. The trailing fence wait + // below guarantees we're never PENDING when this destructor runs. + struct CmdResetGuard + { + VkCommandBuffer cmd; + ~CmdResetGuard() + { + if (cmd != VK_NULL_HANDLE) + { + (void)vkResetCommandBuffer(cmd, 0); + } + } + } cmd_guard{ command_buffer_ }; + + // Snapshot visible layers ONCE — is_visible() is atomic; reading + // it twice could record a draw without the matching wait (or vice + // versa) and race the producer's CUDA copy. + std::vector visible_layers; + visible_layers.reserve(layers.size()); + for (LayerBase* layer : layers) + { + if (layer != nullptr && layer->is_visible()) + { + visible_layers.push_back(layer); + } + } + + auto frame = backend_->begin_frame(/*predicted_display_time=*/0); + if (!frame.has_value()) + { + // Backend skipped this frame; fence stays signaled, next call won't deadlock. + return; + } + + // RAII: if we unwind before the explicit end_frame below, call + // abort_frame instead. We must NOT call end_frame on the + // exception path — its present would wait on signal_after_render, + // which our submit may have never signaled (e.g., if recording + // threw before vkQueueSubmit). abort_frame is the backend's + // "drop this frame, recover next" hook (window backend marks + // the swapchain dirty for recreate; offscreen no-ops). + struct FrameGuard + { + DisplayBackend* backend; + const DisplayBackend::Frame* frame; + bool released = false; + ~FrameGuard() + { + if (!released && backend != nullptr && frame != nullptr) + { + try + { + backend->abort_frame(*frame); + } + catch (...) + { + } + } + } + } frame_guard{ backend_, &*frame }; + + const RenderTarget& rt = backend_->render_target(); + const Resolution rt_extent = rt.resolution(); + + // Per-layer aspect-fit tiles; nullopt aspect = fill the tile. + std::vector tiles; + if (!visible_layers.empty()) + { + const float fb_aspect = static_cast(rt_extent.width) / static_cast(rt_extent.height); + std::vector aspects; + aspects.reserve(visible_layers.size()); + for (LayerBase* layer : visible_layers) + { + aspects.push_back(layer->aspect_ratio().value_or(fb_aspect)); + } + tiles = tile_layout(aspects, rt_extent, /*padding=*/0); + } VkCommandBufferBeginInfo begin{}; begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; @@ -198,49 +219,41 @@ void VizCompositor::render(const std::vector& layers, const std::vec VkRenderPassBeginInfo rp{}; rp.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; - rp.renderPass = render_target_->render_pass(); - rp.framebuffer = render_target_->framebuffer(); + rp.renderPass = rt.render_pass(); + rp.framebuffer = rt.framebuffer(); rp.renderArea.offset = { 0, 0 }; - rp.renderArea.extent = { config_.resolution.width, config_.resolution.height }; + rp.renderArea.extent = { rt_extent.width, rt_extent.height }; rp.clearValueCount = static_cast(clears.size()); rp.pClearValues = clears.data(); - // Snapshot the visible-layer set ONCE per frame. is_visible() is - // an atomic flag; sampling it twice across record / wait-collect - // would let a mid-frame toggle record draws but skip the - // matching cuda_done_writing wait (or vice versa), which would - // race the producer's CUDA copy. - std::vector visible_layers; - visible_layers.reserve(layers.size()); - for (LayerBase* layer : layers) + vkCmdBeginRenderPass(command_buffer_, &rp, VK_SUBPASS_CONTENTS_INLINE); + + // Per-layer: pre-bind scissor (tile.outer); per-layer ViewInfo + // gets viewport = tile.content. + for (size_t i = 0; i < visible_layers.size(); ++i) { - if (layer != nullptr && layer->is_visible()) + const VkRect2D scissor_rect = tiles[i].outer; + const VkRect2D viewport_rect = tiles[i].content; + vkCmdSetScissor(command_buffer_, 0, 1, &scissor_rect); + + std::vector layer_views = frame->views; + if (layer_views.empty()) { - visible_layers.push_back(layer); + layer_views.push_back(ViewInfo{}); } + layer_views[0].viewport = to_rect2d(viewport_rect); + visible_layers[i]->record(command_buffer_, layer_views, rt); } - vkCmdBeginRenderPass(command_buffer_, &rp, VK_SUBPASS_CONTENTS_INLINE); + vkCmdEndRenderPass(command_buffer_); - // Layer dispatch: insertion order, only the snapshotted visible set. - for (LayerBase* layer : visible_layers) - { - layer->record(command_buffer_, views, *render_target_); - } + // Backend-specific post-render commands (blit + transitions etc.). + backend_->record_post_render_pass(command_buffer_, *frame); - vkCmdEndRenderPass(command_buffer_); check_vk(vkEndCommandBuffer(command_buffer_), "vkEndCommandBuffer"); - // Reset the fence immediately before submit. If anything between - // wait() and here threw (a layer's record(), a Vulkan API failure - // during recording), the fence stays signaled from the previous - // frame and the next render() doesn't deadlock on wait(). - frame_sync_->reset(); - - // Collect layer-provided wait timeline semaphores. Each visible - // layer contributes; flatten into the arrays vkQueueSubmit - // expects (with a chained VkTimelineSemaphoreSubmitInfo for the - // per-semaphore counter values). + // Layer waits (timeline) + backend's wait_before_render (binary, + // value 0 ignored). std::vector wait_semaphores; std::vector wait_values; std::vector wait_stages; @@ -256,11 +269,27 @@ void VizCompositor::render(const std::vector& layers, const std::vec } } } + if (frame->wait_before_render != VK_NULL_HANDLE) + { + wait_semaphores.push_back(frame->wait_before_render); + wait_values.push_back(0); + wait_stages.push_back(frame->wait_stage); + } + + std::vector signal_semaphores; + std::vector signal_values; + if (frame->signal_after_render != VK_NULL_HANDLE) + { + signal_semaphores.push_back(frame->signal_after_render); + signal_values.push_back(0); + } VkTimelineSemaphoreSubmitInfo timeline{}; timeline.sType = VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO; timeline.waitSemaphoreValueCount = static_cast(wait_values.size()); timeline.pWaitSemaphoreValues = wait_values.empty() ? nullptr : wait_values.data(); + timeline.signalSemaphoreValueCount = static_cast(signal_values.size()); + timeline.pSignalSemaphoreValues = signal_values.empty() ? nullptr : signal_values.data(); VkSubmitInfo submit{}; submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; @@ -270,70 +299,51 @@ void VizCompositor::render(const std::vector& layers, const std::vec submit.waitSemaphoreCount = static_cast(wait_semaphores.size()); submit.pWaitSemaphores = wait_semaphores.empty() ? nullptr : wait_semaphores.data(); submit.pWaitDstStageMask = wait_stages.empty() ? nullptr : wait_stages.data(); + submit.signalSemaphoreCount = static_cast(signal_semaphores.size()); + submit.pSignalSemaphores = signal_semaphores.empty() ? nullptr : signal_semaphores.data(); + + // Reset the fence immediately before submit. Anything that + // throws above this point leaves the fence signaled from the + // previous frame, so the next render()'s wait() won't deadlock. + // submit_or_signal_fence handles vkQueueSubmit failure by + // submitting an empty signal so the fence still transitions. + frame_sync_->reset(); submit_or_signal_fence(submit, "vkQueueSubmit"); - // Wait for completion before returning so readback / next frame sees - // a consistent state. With 1 frame in flight this is the natural - // synchronization point; multi-buffered swapchain rendering moves - // this wait to the start of the next frame. QuadLayer's mailbox - // depends on this — see quad_layer.hpp. + // Drain before end_frame: if end_frame throws, the cmd buffer is + // EXECUTABLE (resettable by CmdResetGuard) instead of PENDING. + // QuadLayer's mailbox also relies on this synchronous-frame + // contract — see quad_layer.hpp. frame_sync_->wait(); + + backend_->end_frame(*frame); + frame_guard.released = true; } HostImage VizCompositor::readback_to_host() { - // Reuses the staging buffer allocated at init() — no per-call alloc, - // no cleanup-on-throw concerns. Buffer lifetime tracks the - // compositor's; destroy() frees it. - const uint32_t w = config_.resolution.width; - const uint32_t h = config_.resolution.height; - - // Record + submit a single copy. The render pass already transitioned - // the color image to TRANSFER_SRC_OPTIMAL, so no barrier is needed. - check_vk(vkResetCommandBuffer(command_buffer_, 0), "vkResetCommandBuffer(readback)"); - - VkCommandBufferBeginInfo begin{}; - begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - check_vk(vkBeginCommandBuffer(command_buffer_, &begin), "vkBeginCommandBuffer(readback)"); - - VkBufferImageCopy region{}; - region.bufferOffset = 0; - region.bufferRowLength = 0; - region.bufferImageHeight = 0; - region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - region.imageSubresource.layerCount = 1; - region.imageExtent = { w, h, 1 }; - vkCmdCopyImageToBuffer(command_buffer_, render_target_->color_image(), VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - readback_buffer_, 1, ®ion); - - check_vk(vkEndCommandBuffer(command_buffer_), "vkEndCommandBuffer(readback)"); - - frame_sync_->reset(); - VkSubmitInfo submit{}; - submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - submit.commandBufferCount = 1; - submit.pCommandBuffers = &command_buffer_; - submit_or_signal_fence(submit, "vkQueueSubmit(readback)"); - frame_sync_->wait(); - - HostImage result(config_.resolution, PixelFormat::kRGBA8); - void* mapped = nullptr; - check_vk(vkMapMemory(ctx_->device(), readback_memory_, 0, readback_byte_size_, 0, &mapped), "vkMapMemory(readback)"); - std::memcpy(result.data(), mapped, readback_byte_size_); - vkUnmapMemory(ctx_->device(), readback_memory_); - - return result; + return backend_->readback_to_host(); } VkRenderPass VizCompositor::render_pass() const noexcept { - return render_target_ ? render_target_->render_pass() : VK_NULL_HANDLE; + if (backend_ == nullptr) + { + return VK_NULL_HANDLE; + } + try + { + return backend_->render_target().render_pass(); + } + catch (...) + { + return VK_NULL_HANDLE; + } } Resolution VizCompositor::resolution() const noexcept { - return config_.resolution; + return backend_ ? backend_->current_extent() : Resolution{}; } } // namespace viz diff --git a/src/viz/session/cpp/viz_session.cpp b/src/viz/session/cpp/viz_session.cpp index d9e6eea6e..58150e2c1 100644 --- a/src/viz/session/cpp/viz_session.cpp +++ b/src/viz/session/cpp/viz_session.cpp @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +#include +#include #include +#include #include #include @@ -12,14 +15,25 @@ namespace viz namespace { -void check_offscreen_only(DisplayMode mode, const char* what) +// Factory: instantiate the backend matching the requested mode. +std::unique_ptr make_backend(const VizSession::Config& cfg) { - if (mode != DisplayMode::kOffscreen) + switch (cfg.mode) { - throw std::runtime_error(std::string("VizSession: ") + what + - " is not implemented for the requested DisplayMode " - "(only kOffscreen is currently supported)"); + case DisplayMode::kOffscreen: + return std::make_unique(); + case DisplayMode::kWindow: + { + WindowBackend::Config wc{}; + wc.width = cfg.window_width; + wc.height = cfg.window_height; + wc.title = cfg.app_name; + return std::make_unique(wc); + } + case DisplayMode::kXr: + throw std::runtime_error("VizSession: kXr is not yet implemented"); } + throw std::runtime_error("VizSession: unknown DisplayMode"); } } // namespace @@ -46,14 +60,16 @@ VizSession::~VizSession() void VizSession::init() { - // Reject unsupported display modes before allocating any Vulkan - // state — saves a wasted vkCreateInstance + device on a config we - // know we can't support yet. - check_offscreen_only(config_.mode, "create"); + // Backend first — it dictates the required Vulkan extensions and + // rejects unsupported modes before any Vulkan work. + backend_ = make_backend(config_); try { - // Acquire / create the Vulkan context. + VkContext::Config vk_cfg{}; + vk_cfg.instance_extensions = backend_->required_instance_extensions(); + vk_cfg.device_extensions = backend_->required_device_extensions(); + if (config_.external_context != nullptr) { if (!config_.external_context->is_initialized()) @@ -65,16 +81,16 @@ void VizSession::init() else { owned_ctx_ = std::make_unique(); - owned_ctx_->init(VkContext::Config{}); + owned_ctx_->init(vk_cfg); ctx_ptr_ = owned_ctx_.get(); } + backend_->init(*ctx_ptr_, Resolution{ config_.window_width, config_.window_height }); VizCompositor::Config c_cfg{}; - c_cfg.resolution = { config_.window_width, config_.window_height }; c_cfg.clear_color = { { config_.clear_color[0], config_.clear_color[1], config_.clear_color[2], config_.clear_color[3] } }; - compositor_ = VizCompositor::create(*ctx_ptr_, c_cfg); + compositor_ = VizCompositor::create(*ctx_ptr_, *backend_, c_cfg); state_ = SessionState::kReady; } @@ -88,7 +104,9 @@ void VizSession::init() void VizSession::destroy() { layers_.clear(); + // Order: compositor (holds backend ref) -> backend -> context. compositor_.reset(); + backend_.reset(); if (owned_ctx_) { owned_ctx_.reset(); @@ -108,6 +126,20 @@ void VizSession::remove_layer(LayerBase* layer) layers_.erase(it, layers_.end()); } +void VizSession::pump_events() +{ + if (!backend_) + { + return; + } + backend_->poll_events(); + if (backend_->consume_resized()) + { + // Hint ignored — backend reads its own framebuffer size. + backend_->resize(Resolution{}); + } +} + FrameInfo VizSession::begin_frame() { if (state_ == SessionState::kDestroyed || state_ == SessionState::kLost) @@ -120,6 +152,7 @@ FrameInfo VizSession::begin_frame() "VizSession: begin_frame called while a frame is already in " "progress (missing end_frame for previous begin_frame)"); } + pump_events(); if (state_ == SessionState::kReady) { state_ = SessionState::kRunning; @@ -140,14 +173,12 @@ FrameInfo VizSession::begin_frame() current_frame_info_.frame_index = frame_index_; current_frame_info_.predicted_display_time = 0; // XR-only; 0 in offscreen current_frame_info_.should_render = (state_ == SessionState::kRunning); - current_frame_info_.resolution = compositor_->resolution(); - // Single identity view in window/offscreen; XR backend extends to per-eye. + current_frame_info_.resolution = compositor_ ? compositor_->resolution() : Resolution{}; + // Public FrameInfo carries a single identity entry as a hint; + // backends populate the actual per-view info inside render(). current_frame_info_.views.assign(1, ViewInfo{}); - // Set last so any earlier throw leaves the flag false and the next - // begin_frame() can proceed normally. frame_in_progress_ = true; - return current_frame_info_; } @@ -159,16 +190,10 @@ void VizSession::end_frame() } if (state_ != SessionState::kRunning) { - // No-op in non-running states (matches the design: kStopping - // submits an empty frame; kReady never enters end_frame). - // Still clear the in-progress flag so the pairing contract holds. frame_in_progress_ = false; return; } - // Always clear the in-progress flag, even if the render call below - // throws — leaving it true would lock out all subsequent begin_frame() - // calls for the rest of the session. struct ClearGuard { bool* flag; @@ -178,8 +203,6 @@ void VizSession::end_frame() } } guard{ &frame_in_progress_ }; - // Build a raw-pointer view of the layer registry for the compositor — - // avoids forcing the compositor to know about std::unique_ptr. std::vector raw_layers; raw_layers.reserve(layers_.size()); for (const auto& l : layers_) @@ -189,7 +212,7 @@ void VizSession::end_frame() if (current_frame_info_.should_render) { - compositor_->render(raw_layers, current_frame_info_.views); + compositor_->render(raw_layers); } update_timing_stats(current_frame_info_.delta_time); @@ -198,6 +221,7 @@ void VizSession::end_frame() FrameInfo VizSession::render() { + // begin_frame() now pumps events itself; no need to do it twice. auto info = begin_frame(); end_frame(); return info; @@ -209,8 +233,6 @@ void VizSession::update_timing_stats(float frame_time_seconds) { return; } - // Simple exponential moving average; full FPS smoothing arrives with - // the window/XR backends' real frame pacing. constexpr float kSmoothing = 0.1f; const float frame_ms = frame_time_seconds * 1000.0f; timing_stats_.avg_frame_time_ms = kSmoothing * frame_ms + (1.0f - kSmoothing) * timing_stats_.avg_frame_time_ms; @@ -220,17 +242,25 @@ void VizSession::update_timing_stats(float frame_time_seconds) Resolution VizSession::get_recommended_resolution() const noexcept { - return compositor_ ? compositor_->resolution() : Resolution{ config_.window_width, config_.window_height }; + if (compositor_) + { + return compositor_->resolution(); + } + return Resolution{ config_.window_width, config_.window_height }; } HostImage VizSession::readback_to_host() { - check_offscreen_only(config_.mode, "readback_to_host"); - if (!compositor_) + if (!backend_) { throw std::runtime_error("VizSession: readback_to_host called before init"); } - return compositor_->readback_to_host(); + return backend_->readback_to_host(); +} + +bool VizSession::should_close() const noexcept +{ + return backend_ ? backend_->should_close() : false; } const VkContext& VizSession::ctx() const noexcept diff --git a/src/viz/session/cpp/window_backend.cpp b/src/viz/session/cpp/window_backend.cpp new file mode 100644 index 000000000..476c21eb0 --- /dev/null +++ b/src/viz/session/cpp/window_backend.cpp @@ -0,0 +1,326 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include + +#include +#include +#include + +#define GLFW_INCLUDE_NONE +#define GLFW_INCLUDE_VULKAN +#include + +namespace viz +{ + +namespace +{ + +void transition_image(VkCommandBuffer cmd, + VkImage image, + VkImageLayout old_layout, + VkImageLayout new_layout, + VkAccessFlags src_access, + VkAccessFlags dst_access, + VkPipelineStageFlags src_stage, + VkPipelineStageFlags dst_stage) +{ + VkImageMemoryBarrier b{}; + b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + b.oldLayout = old_layout; + b.newLayout = new_layout; + b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.image = image; + b.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + b.subresourceRange.levelCount = 1; + b.subresourceRange.layerCount = 1; + b.srcAccessMask = src_access; + b.dstAccessMask = dst_access; + vkCmdPipelineBarrier(cmd, src_stage, dst_stage, 0, 0, nullptr, 0, nullptr, 1, &b); +} + +} // namespace + +WindowBackend::WindowBackend(Config config) : config_(std::move(config)) +{ +} + +WindowBackend::~WindowBackend() +{ + destroy(); +} + +std::vector WindowBackend::required_instance_extensions() const +{ + // RAII through the refcounted init shared with GlfwWindow so + // concurrent windows / repeated calls don't race glfwTerminate. + GlfwWindow::retain(); + struct ReleaseGuard + { + ~ReleaseGuard() + { + GlfwWindow::release(); + } + } guard; + + uint32_t count = 0; + const char** raw = glfwGetRequiredInstanceExtensions(&count); + if (raw == nullptr) + { + throw std::runtime_error("WindowBackend: no Vulkan loader visible to GLFW"); + } + std::vector out; + out.reserve(count); + for (uint32_t i = 0; i < count; ++i) + { + out.emplace_back(raw[i]); + } + return out; +} + +std::vector WindowBackend::required_device_extensions() const +{ + return { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; +} + +void WindowBackend::init(const VkContext& ctx, Resolution preferred_size) +{ + ctx_ = &ctx; + try + { + window_ = GlfwWindow::create(ctx.instance(), preferred_size.width, preferred_size.height, config_.title); + swapchain_ = Swapchain::create(ctx, window_->surface(), preferred_size); + // Match intermediate extent to swapchain for a 1:1 post-render blit. + render_target_ = RenderTarget::create(ctx, RenderTarget::Config{ swapchain_->extent() }); + + // Pacer target: monitor refresh rate, falling back to 60. + uint32_t fps = config_.target_fps; + if (fps == 0) + { + GLFWmonitor* monitor = glfwGetPrimaryMonitor(); + const GLFWvidmode* mode = monitor != nullptr ? glfwGetVideoMode(monitor) : nullptr; + if (mode != nullptr && mode->refreshRate > 0) + { + fps = static_cast(mode->refreshRate); + } + } + if (fps == 0) + { + fps = 60; + } + frame_period_ = std::chrono::nanoseconds(1'000'000'000ULL / fps); + // Subtract one period so begin_frame's first += lands at now() + // and the first frame doesn't burn ~16ms in sleep_until before + // rendering anything. + next_frame_deadline_ = std::chrono::steady_clock::now() - frame_period_; + } + catch (...) + { + destroy(); + throw; + } +} + +void WindowBackend::destroy() +{ + // Order: RT + swapchain before the window (which owns the surface). + render_target_.reset(); + swapchain_.reset(); + window_.reset(); + ctx_ = nullptr; +} + +std::optional WindowBackend::begin_frame(int64_t /*predicted_display_time*/) +{ + if (swapchain_ == nullptr) + { + return std::nullopt; + } + + // Pacer first — runs once per loop iteration even when we return + // nullopt below; otherwise OUT_OF_DATE recovery spins. + next_frame_deadline_ += frame_period_; + const auto now = std::chrono::steady_clock::now(); + if (next_frame_deadline_ < now) + { + next_frame_deadline_ = now; // fell behind; don't accumulate debt + } + else + { + std::this_thread::sleep_until(next_frame_deadline_); + } + + // Drain a deferred recreate (set by abort_frame or a prior + // OUT_OF_DATE acquire) before touching the swapchain. Only + // clear the flag once the recreate actually ran — a minimized + // window leaves it pending so the next frame retries. + if (needs_recreate_) + { + if (!force_recreate()) + { + return std::nullopt; + } + needs_recreate_ = false; + } + + auto acquired = swapchain_->acquire_next_image(); + if (!acquired.has_value()) + { + // OUT_OF_DATE: swapchain is unusable regardless of size — + // can fire on monitor reconfig / format change too. If the + // window is minimized we can't recreate now; defer. + if (!force_recreate()) + { + needs_recreate_ = true; + } + return std::nullopt; + } + + Frame f{}; + f.views.assign(1, ViewInfo{}); + f.views[0].viewport = Rect2D{ 0, 0, swapchain_->extent().width, swapchain_->extent().height }; + f.wait_before_render = acquired->image_available; + f.wait_stage = VK_PIPELINE_STAGE_TRANSFER_BIT; + f.signal_after_render = acquired->render_done; + f.backend_token = static_cast(acquired->image_index); + return f; +} + +const RenderTarget& WindowBackend::render_target() const +{ + if (render_target_ == nullptr) + { + throw std::runtime_error("WindowBackend::render_target: backend not initialized"); + } + return *render_target_; +} + +void WindowBackend::record_post_render_pass(VkCommandBuffer cmd, const Frame& frame) +{ + if (swapchain_ == nullptr || render_target_ == nullptr) + { + return; + } + const uint32_t image_index = static_cast(frame.backend_token); + const VkImage swap_image = swapchain_->image_at(image_index); + + transition_image(cmd, swap_image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + + const Resolution intermediate_extent{ render_target_->resolution() }; + const Resolution sc_extent = swapchain_->extent(); + VkImageBlit region{}; + region.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.srcSubresource.layerCount = 1; + region.srcOffsets[1] = { static_cast(intermediate_extent.width), + static_cast(intermediate_extent.height), 1 }; + region.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.dstSubresource.layerCount = 1; + region.dstOffsets[1] = { static_cast(sc_extent.width), static_cast(sc_extent.height), 1 }; + vkCmdBlitImage(cmd, render_target_->color_image(), VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, swap_image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion, VK_FILTER_LINEAR); + + transition_image(cmd, swap_image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_ACCESS_TRANSFER_WRITE_BIT, 0, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); +} + +void WindowBackend::end_frame(const Frame& frame) +{ + if (swapchain_ == nullptr) + { + return; + } + const uint32_t image_index = static_cast(frame.backend_token); + if (!swapchain_->present(image_index, frame.signal_after_render)) + { + // OUT_OF_DATE on present: defer recreate to the next + // begin_frame instead of waiting for acquire to notice. + needs_recreate_ = true; + } +} + +void WindowBackend::abort_frame(const Frame& /*frame*/) +{ + // The acquired image's render_done semaphore may be unsignaled + // (exception fired before our submit). Don't present — that + // would block on a semaphore that never signals. Defer a swapchain + // recreate to the next begin_frame; it retires all images + // including the one we held. + needs_recreate_ = true; +} + +void WindowBackend::poll_events() +{ + if (window_) + { + window_->poll_events(); + } +} + +bool WindowBackend::should_close() const +{ + return window_ ? window_->should_close() : false; +} + +bool WindowBackend::consume_resized() +{ + return window_ ? window_->consume_resized() : false; +} + +void WindowBackend::resize(Resolution /*hint*/) +{ + // Backend reads its own target size from the window — the caller's + // hint is ignored. + if (swapchain_ == nullptr || ctx_ == nullptr || window_ == nullptr || render_target_ == nullptr) + { + return; + } + const Resolution target = window_->framebuffer_size(); + if (target.width == 0 || target.height == 0) + { + return; // minimized + } + const Resolution current = swapchain_->extent(); + if (target.width == current.width && target.height == current.height) + { + return; + } + swapchain_->recreate(target); + render_target_->resize(swapchain_->extent()); +} + +bool WindowBackend::force_recreate() +{ + // No size-match guard. Used when the WSI demands a recreate + // (OUT_OF_DATE) or after an aborted frame, where the swapchain + // is unusable independent of the framebuffer extent. + if (swapchain_ == nullptr || ctx_ == nullptr || window_ == nullptr || render_target_ == nullptr) + { + return false; + } + const Resolution target = window_->framebuffer_size(); + if (target.width == 0 || target.height == 0) + { + return false; + } + swapchain_->recreate(target); + render_target_->resize(swapchain_->extent()); + return true; +} + +Resolution WindowBackend::current_extent() const +{ + if (swapchain_ != nullptr) + { + return swapchain_->extent(); + } + return Resolution{ config_.width, config_.height }; +} + +} // namespace viz diff --git a/src/viz/session_tests/cpp/CMakeLists.txt b/src/viz/session_tests/cpp/CMakeLists.txt index 31734b870..1bfdb8a5f 100644 --- a/src/viz/session_tests/cpp/CMakeLists.txt +++ b/src/viz/session_tests/cpp/CMakeLists.txt @@ -6,7 +6,9 @@ cmake_minimum_required(VERSION 3.20) add_executable(viz_session_tests test_offscreen_render.cpp test_quad_milestone.cpp + test_tile_layout.cpp test_viz_session.cpp + test_window_primitives.cpp ) target_link_libraries(viz_session_tests PRIVATE diff --git a/src/viz/session_tests/cpp/test_tile_layout.cpp b/src/viz/session_tests/cpp/test_tile_layout.cpp new file mode 100644 index 000000000..7f2a85e66 --- /dev/null +++ b/src/viz/session_tests/cpp/test_tile_layout.cpp @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Pure-math tests for tile_layout — no GPU needed. + +#include +#include + +using viz::Resolution; +using viz::tile_layout; +using viz::TileSlot; + +TEST_CASE("tile_layout returns empty for zero layers", "[unit][tile_layout]") +{ + const auto slots = tile_layout({}, Resolution{ 800, 600 }); + CHECK(slots.empty()); +} + +TEST_CASE("tile_layout returns empty for zero framebuffer", "[unit][tile_layout]") +{ + const auto slots = tile_layout({ 1.0f }, Resolution{ 0, 600 }); + CHECK(slots.empty()); +} + +TEST_CASE("tile_layout single layer fills the whole framebuffer", "[unit][tile_layout]") +{ + const auto slots = tile_layout({ 1.0f }, Resolution{ 800, 600 }); + REQUIRE(slots.size() == 1); + CHECK(slots[0].outer.offset.x == 0); + CHECK(slots[0].outer.offset.y == 0); + CHECK(slots[0].outer.extent.width == 800); + CHECK(slots[0].outer.extent.height == 600); +} + +TEST_CASE("tile_layout 4 layers form a 2x2 grid", "[unit][tile_layout]") +{ + const auto slots = tile_layout({ 1.0f, 1.0f, 1.0f, 1.0f }, Resolution{ 800, 600 }); + REQUIRE(slots.size() == 4); + // Row-major: (0,0), (0,1), (1,0), (1,1) + CHECK(slots[0].outer.offset.x == 0); + CHECK(slots[0].outer.offset.y == 0); + CHECK(slots[1].outer.offset.x == 400); + CHECK(slots[1].outer.offset.y == 0); + CHECK(slots[2].outer.offset.x == 0); + CHECK(slots[2].outer.offset.y == 300); + CHECK(slots[3].outer.offset.x == 400); + CHECK(slots[3].outer.offset.y == 300); + for (const auto& s : slots) + { + CHECK(s.outer.extent.width == 400); + CHECK(s.outer.extent.height == 300); + } +} + +TEST_CASE("tile_layout 5 layers use a 3-col grid (last row partially filled)", "[unit][tile_layout]") +{ + // ceil(sqrt(5)) = 3 cols, ceil(5/3) = 2 rows. Last cell is empty + // but the grid math is symmetric. + const auto slots = tile_layout({ 1.0f, 1.0f, 1.0f, 1.0f, 1.0f }, Resolution{ 900, 600 }); + REQUIRE(slots.size() == 5); + CHECK(slots[0].outer.offset.x == 0); + CHECK(slots[0].outer.offset.y == 0); + CHECK(slots[2].outer.offset.x == 600); // (col=2, row=0) + CHECK(slots[3].outer.offset.x == 0); + CHECK(slots[3].outer.offset.y == 300); // (col=0, row=1) + CHECK(slots[4].outer.offset.x == 300); // (col=1, row=1) +} + +TEST_CASE("tile_layout last column absorbs framebuffer width remainder", "[unit][tile_layout]") +{ + // 4 layers → ceil(sqrt(4)) = 2 cols. fb_w = 801 → base 400, last + // column gets 801 - 400 = 401 to cover the full framebuffer. + const auto slots = tile_layout({ 1.0f, 1.0f, 1.0f, 1.0f }, Resolution{ 801, 600 }); + REQUIRE(slots.size() == 4); + CHECK(slots[0].outer.extent.width == 400); // col 0 + CHECK(slots[1].outer.extent.width == 401); // col 1, last → absorbs remainder + CHECK(slots[2].outer.extent.width == 400); + CHECK(slots[3].outer.extent.width == 401); +} + +TEST_CASE("tile_layout aspect-fits 16:9 content inside a 1:1 tile (letterbox)", "[unit][tile_layout]") +{ + // 1 layer with 16:9 aspect in a 600x600 framebuffer. + // Content fills full width (600), height = 600 / (16/9) = 337. + // Centered vertically: y = (600 - 337) / 2 = 131. + const auto slots = tile_layout({ 16.0f / 9.0f }, Resolution{ 600, 600 }); + REQUIRE(slots.size() == 1); + CHECK(slots[0].outer.extent.width == 600); + CHECK(slots[0].outer.extent.height == 600); + CHECK(slots[0].content.extent.width == 600); + CHECK(slots[0].content.extent.height == 337); + CHECK(slots[0].content.offset.x == 0); + CHECK(slots[0].content.offset.y == 131); // (600 - 337) / 2 +} + +TEST_CASE("tile_layout aspect-fits 9:16 content inside a 1:1 tile (pillarbox)", "[unit][tile_layout]") +{ + const auto slots = tile_layout({ 9.0f / 16.0f }, Resolution{ 600, 600 }); + REQUIRE(slots.size() == 1); + CHECK(slots[0].content.extent.height == 600); + CHECK(slots[0].content.extent.width == 337); + CHECK(slots[0].content.offset.x == 131); + CHECK(slots[0].content.offset.y == 0); +} + +TEST_CASE("tile_layout content matches outer when aspects match", "[unit][tile_layout]") +{ + // 4:3 aspect in a 4:3 framebuffer → no letterbox. + const auto slots = tile_layout({ 4.0f / 3.0f }, Resolution{ 800, 600 }); + REQUIRE(slots.size() == 1); + CHECK(slots[0].content.offset.x == 0); + CHECK(slots[0].content.offset.y == 0); + CHECK(slots[0].content.extent.width == 800); + CHECK(slots[0].content.extent.height == 600); +} + +TEST_CASE("tile_layout padding shrinks tile and translates content", "[unit][tile_layout]") +{ + // 4 square tiles in 800x600 with 10px padding. Each base tile is + // 400x300, padded to 380x280 (shrink 10px each side), and the + // outer offset moves by +10 inside its base tile. + const auto slots = tile_layout({ 1.0f, 1.0f, 1.0f, 1.0f }, Resolution{ 800, 600 }, 10); + REQUIRE(slots.size() == 4); + CHECK(slots[0].outer.offset.x == 10); + CHECK(slots[0].outer.offset.y == 10); + CHECK(slots[0].outer.extent.width == 380); + CHECK(slots[0].outer.extent.height == 280); + // Bottom-right tile starts at (410, 310) after padding within the + // (400, 300) base. + CHECK(slots[3].outer.offset.x == 410); + CHECK(slots[3].outer.offset.y == 310); +} diff --git a/src/viz/session_tests/cpp/test_viz_session.cpp b/src/viz/session_tests/cpp/test_viz_session.cpp index 6cad012a8..d4593eb60 100644 --- a/src/viz/session_tests/cpp/test_viz_session.cpp +++ b/src/viz/session_tests/cpp/test_viz_session.cpp @@ -42,15 +42,10 @@ TEST_CASE("SessionState enum exposes the full lifecycle set", "[unit][viz_sessio CHECK(static_cast(SessionState::kDestroyed) == 5); } -TEST_CASE("VizSession::create rejects unsupported display modes early", "[unit][viz_session]") +TEST_CASE("VizSession::create rejects kXr (not yet implemented)", "[unit][viz_session]") { - // Mode validation must happen before any Vulkan work — verified by - // not requiring a GPU here. Both kWindow and kXr should throw - // before VkContext creation. - VizSession::Config cfg_window{}; - cfg_window.mode = DisplayMode::kWindow; - CHECK_THROWS_AS(VizSession::create(cfg_window), std::runtime_error); - + // Mode validation must happen before any Vulkan work — verified + // by not requiring a GPU here. VizSession::Config cfg_xr{}; cfg_xr.mode = DisplayMode::kXr; CHECK_THROWS_AS(VizSession::create(cfg_xr), std::runtime_error); diff --git a/src/viz/session_tests/cpp/test_window_primitives.cpp b/src/viz/session_tests/cpp/test_window_primitives.cpp new file mode 100644 index 000000000..aefed95bc --- /dev/null +++ b/src/viz/session_tests/cpp/test_window_primitives.cpp @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// [gpu][window] tests for GlfwWindow, Swapchain, and the VizSession +// kWindow render loop. Skip cleanly without a display. + +#include "test_helpers.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define GLFW_INCLUDE_NONE +#define GLFW_INCLUDE_VULKAN +#include + +using viz::DisplayMode; +using viz::GlfwWindow; +using viz::PixelFormat; +using viz::QuadLayer; +using viz::Resolution; +using viz::Swapchain; +using viz::VizSession; +using viz::VkContext; +using viz::testing::is_gpu_available; + +namespace +{ + +// True iff GLFW init succeeds and a Vulkan-capable display is reachable. +bool window_environment_available() +{ + static const bool cached = []() -> bool + { + if (glfwInit() != GLFW_TRUE) + { + return false; + } + const bool ok = (glfwVulkanSupported() == GLFW_TRUE); + glfwTerminate(); + return ok; + }(); + return cached; +} + +std::vector glfw_required_instance_extensions() +{ + if (glfwInit() != GLFW_TRUE) + { + return {}; + } + uint32_t count = 0; + const char** raw = glfwGetRequiredInstanceExtensions(&count); + std::vector out; + out.reserve(count); + for (uint32_t i = 0; i < count; ++i) + { + out.emplace_back(raw[i]); + } + glfwTerminate(); + return out; +} + +} // namespace + +TEST_CASE("GlfwWindow construct + destroy with a real Vulkan instance", "[gpu][window]") +{ + if (!is_gpu_available() || !window_environment_available()) + { + SKIP("No GPU or no display"); + } + + VkContext::Config cfg{}; + cfg.instance_extensions = glfw_required_instance_extensions(); + VkContext ctx; + ctx.init(cfg); + + auto win = GlfwWindow::create(ctx.instance(), 320, 240, "viz-test"); + REQUIRE(win != nullptr); + CHECK(win->glfw() != nullptr); + CHECK(win->surface() != VK_NULL_HANDLE); + CHECK_FALSE(win->should_close()); + + const auto fb = win->framebuffer_size(); + // Compositors need non-zero framebuffer to allocate intermediate + // RT — assert the window came up with usable dims. + CHECK(fb.width > 0); + CHECK(fb.height > 0); + + win->destroy(); + win->destroy(); // idempotent +} + +TEST_CASE("GlfwWindow rejects null instance and zero dims", "[gpu][window]") +{ + if (!window_environment_available()) + { + SKIP("No display"); + } + CHECK_THROWS_AS(GlfwWindow::create(VK_NULL_HANDLE, 320, 240, "x"), std::invalid_argument); + // Need a valid instance to exercise the dim check. + if (!is_gpu_available()) + { + SKIP("No GPU"); + } + VkContext::Config cfg{}; + cfg.instance_extensions = glfw_required_instance_extensions(); + VkContext ctx; + ctx.init(cfg); + CHECK_THROWS_AS(GlfwWindow::create(ctx.instance(), 0, 240, "x"), std::invalid_argument); +} + +TEST_CASE("Swapchain creates with non-zero image count and matching extent", "[gpu][window]") +{ + if (!is_gpu_available() || !window_environment_available()) + { + SKIP("No GPU or no display"); + } + + VkContext::Config cfg{}; + cfg.instance_extensions = glfw_required_instance_extensions(); + cfg.device_extensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; + VkContext ctx; + ctx.init(cfg); + + auto win = GlfwWindow::create(ctx.instance(), 320, 240, "viz-test-sc"); + auto sc = Swapchain::create(ctx, win->surface(), Resolution{ 320, 240 }); + REQUIRE(sc != nullptr); + CHECK(sc->image_count() >= 2); + CHECK(sc->extent().width > 0); + CHECK(sc->extent().height > 0); + CHECK(sc->format() != VK_FORMAT_UNDEFINED); +} + +TEST_CASE("Swapchain recreate preserves usable state", "[gpu][window]") +{ + if (!is_gpu_available() || !window_environment_available()) + { + SKIP("No GPU or no display"); + } + + VkContext::Config cfg{}; + cfg.instance_extensions = glfw_required_instance_extensions(); + cfg.device_extensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; + VkContext ctx; + ctx.init(cfg); + + auto win = GlfwWindow::create(ctx.instance(), 320, 240, "viz-test-sc-recreate"); + auto sc = Swapchain::create(ctx, win->surface(), Resolution{ 320, 240 }); + const uint32_t before = sc->image_count(); + + sc->recreate(Resolution{ 480, 320 }); + CHECK(sc->image_count() == before); // image count is driver-fixed + CHECK(sc->extent().width > 0); + CHECK(sc->extent().height > 0); +} + +TEST_CASE("Swapchain destroy is idempotent", "[gpu][window]") +{ + if (!is_gpu_available() || !window_environment_available()) + { + SKIP("No GPU or no display"); + } + + VkContext::Config cfg{}; + cfg.instance_extensions = glfw_required_instance_extensions(); + cfg.device_extensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; + VkContext ctx; + ctx.init(cfg); + + auto win = GlfwWindow::create(ctx.instance(), 320, 240, "viz-test-sc-idem"); + auto sc = Swapchain::create(ctx, win->surface(), Resolution{ 320, 240 }); + sc->destroy(); + sc->destroy(); +} + +TEST_CASE("VizSession kWindow renders multiple QuadLayers without errors", "[gpu][window]") +{ + if (!is_gpu_available() || !window_environment_available()) + { + SKIP("No GPU or no display"); + } + + constexpr uint32_t kWindowW = 320; + constexpr uint32_t kWindowH = 240; + constexpr uint32_t kQuadW = 64; + constexpr uint32_t kQuadH = 64; + + VizSession::Config cfg{}; + cfg.mode = DisplayMode::kWindow; + cfg.window_width = kWindowW; + cfg.window_height = kWindowH; + cfg.app_name = "viz-window-integration-test"; + + auto session = VizSession::create(cfg); + REQUIRE(session != nullptr); + REQUIRE(session->get_state() == viz::SessionState::kReady); + + const auto* ctx = session->get_vk_context(); + const VkRenderPass render_pass = session->get_render_pass(); + + // Three QuadLayers — exercises the row-major tile grid (cols=2, + // rows=2 with one empty cell). Each is fed a solid-color CUDA + // buffer once at setup; render loop just composites + presents. + struct Rgba + { + uint8_t r, g, b, a; + }; + const std::array palette = { { { 255, 0, 0, 255 }, { 0, 255, 0, 255 }, { 0, 0, 255, 255 } } }; + std::vector dev_buffers; + dev_buffers.reserve(palette.size()); + for (size_t i = 0; i < palette.size(); ++i) + { + std::vector host(static_cast(kQuadW) * kQuadH, palette[i]); + void* dev = nullptr; + REQUIRE(cudaMalloc(&dev, host.size() * sizeof(Rgba)) == cudaSuccess); + dev_buffers.push_back(dev); + REQUIRE(cudaMemcpy(dev, host.data(), host.size() * sizeof(Rgba), cudaMemcpyHostToDevice) == cudaSuccess); + + QuadLayer::Config layer_cfg; + layer_cfg.name = "tile_layer_" + std::to_string(i); + layer_cfg.resolution = { kQuadW, kQuadH }; + auto* layer = session->add_layer(*ctx, render_pass, layer_cfg); + + viz::VizBuffer src{}; + src.data = dev; + src.width = kQuadW; + src.height = kQuadH; + src.format = PixelFormat::kRGBA8; + src.pitch = static_cast(kQuadW) * 4; + src.space = viz::MemorySpace::kDevice; + layer->submit(src); + } + + // Run a few frames. We can't readback in kWindow (the swapchain + // present path doesn't have a host-readable buffer), so the test + // verifies: no exceptions thrown, frame_index advances, validation + // layers (debug build) report no errors. + constexpr uint32_t kFrames = 8; + for (uint32_t i = 0; i < kFrames; ++i) + { + const auto info = session->render(); + CHECK(info.frame_index == i); + CHECK(info.resolution.width == kWindowW); + CHECK(info.resolution.height == kWindowH); + } + + session.reset(); + for (void* dev : dev_buffers) + { + cudaFree(dev); + } +}