From 18041589a924fb223ba3e43af38f0d0be7e6798f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Tue, 29 Sep 2026 10:52:56 -0400 Subject: [PATCH 01/48] refactor(vulkan): restructure the Vulkan viewport into VulkanWindow hosted by VulkanView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge from #1319 to the restructured viewport this branch builds on. The Vulkan viewport becomes a native VulkanWindow (QWindow) embedded by a VulkanView container through createWindowContainer(), mirroring GLWindow/GLView, in place of the WA_NativeWindow/WA_PaintOnScreen QWidget. The GL interop negotiation and presentation-path record from #1319 are re-ported onto VulkanWindow by "fix(vulkan): port the Vulkan presentation interop negotiation" later in this series. Signed-off-by: Cédrik Fuoco --- src/bin/apps/rv/main.cpp | 28 +- src/lib/app/RvCommon/CMakeLists.txt | 14 +- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 14 +- src/lib/app/RvCommon/GLView.cpp | 25 + src/lib/app/RvCommon/GLWindow.cpp | 72 +- src/lib/app/RvCommon/QTGLVideoDevice.cpp | 26 +- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 473 +-- src/lib/app/RvCommon/RvCommon/GLView.h | 9 + .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 105 +- src/lib/app/RvCommon/RvCommon/RvDocument.h | 6 +- src/lib/app/RvCommon/RvCommon/VulkanView.h | 382 +-- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 294 ++ src/lib/app/RvCommon/RvDocument.cpp | 73 +- src/lib/app/RvCommon/VulkanBuildProbe.cpp | 22 + src/lib/app/RvCommon/VulkanView.cpp | 2770 ++--------------- src/lib/app/RvCommon/VulkanWindow.cpp | 2302 ++++++++++++++ src/lib/ip/IPCore/IPCore/ImageRenderer.h | 5 + 17 files changed, 3384 insertions(+), 3236 deletions(-) create mode 100644 src/lib/app/RvCommon/RvCommon/VulkanWindow.h create mode 100644 src/lib/app/RvCommon/VulkanBuildProbe.cpp create mode 100644 src/lib/app/RvCommon/VulkanWindow.cpp diff --git a/src/bin/apps/rv/main.cpp b/src/bin/apps/rv/main.cpp index 8b2a760fc..af1bf4417 100644 --- a/src/bin/apps/rv/main.cpp +++ b/src/bin/apps/rv/main.cpp @@ -373,28 +373,16 @@ int utf8Main(int argc, char* argv[]) // documented QtWebEngine requirement, and it lets RV's auxiliary GL // surfaces -- the second-output ScreenView and the multithreaded-upload // worker device -- share textures/FBOs with the main viewport context - // without an explicit, ordering-sensitive setShareContext() call. Must be - // set before the QApplication is constructed. - // - // The Vulkan presentation path depends on this as well: there is no GLView - // to chain from, so the offscreen presentation context joins this global - // group instead (see QTVulkanVideoDevice::ensureGLContext). Otherwise FTGL - // font-atlas glyph uploads land in a context where the atlas texture has no - // storage. - QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); - - // Render Qt Quick through OpenGL, matching the graphics API RV's windows - // composite with. + // without an explicit, ordering-sensitive setShareContext() call. // - // Qt Quick's RHI backend defaults to Direct3D 11 on Windows. RV's top-level - // windows composite with OpenGL (GLView realizes the window up front, before - // any render-to-texture widget joins the tree), and a QQuickWidget cannot - // obtain a QRhi from a window using a different API. Without this, anything - // Quick-based inside an RV window -- most visibly a QWebEngineView, whose - // page is rendered by a QQuickWidget -- silently draws nothing and logs "The - // top-level window is not using the expected graphics API for composition" - // followed by "Attempted to render scene with no rhi". + // It is also what makes the Vulkan presentation path work: there is no + // GLView to chain from there, so QTVulkanVideoDevice::ensureGLContext() + // joins this global group instead. Without it, FTGL font-atlas glyph + // uploads land in a context where the atlas texture has no storage. // + // Must be set before the QApplication is constructed. + QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); + #ifdef PLATFORM_WINDOWS // Put Qt Quick on the same graphics API RV's windows composite with. // diff --git a/src/lib/app/RvCommon/CMakeLists.txt b/src/lib/app/RvCommon/CMakeLists.txt index 163f84cb7..c40c5dfdd 100644 --- a/src/lib/app/RvCommon/CMakeLists.txt +++ b/src/lib/app/RvCommon/CMakeLists.txt @@ -89,7 +89,17 @@ IF(RV_TARGET_LINUX MESSAGE(FATAL_ERROR "Vulkan::Vulkan target is missing; cmake/dependencies/vulkan.cmake should provide it on Linux and Windows") ENDIF() MESSAGE(STATUS "Vulkan: using managed Vulkan ${RV_DEPS_VULKAN_VERSION}") - LIST(APPEND _sources VulkanView.cpp QTVulkanVideoDevice.cpp RvCommon/VulkanView.h RvCommon/QTVulkanVideoDevice.h) + LIST( + APPEND + _sources + VulkanBuildProbe.cpp + VulkanView.cpp + VulkanWindow.cpp + QTVulkanVideoDevice.cpp + RvCommon/VulkanView.h + RvCommon/VulkanWindow.h + RvCommon/QTVulkanVideoDevice.h + ) ENDIF() FILE(GLOB _ui_sources ui/*.ui) @@ -164,7 +174,7 @@ IF(NOT (RV_TARGET_LINUX OR RV_TARGET_WINDOWS) ) - LIST(REMOVE_ITEM _files_to_moc "RvCommon/VulkanView.h" "RvCommon/QTVulkanVideoDevice.h") + LIST(REMOVE_ITEM _files_to_moc "RvCommon/VulkanView.h" "RvCommon/VulkanWindow.h" "RvCommon/QTVulkanVideoDevice.h") ENDIF() FOREACH( diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 3b159e710..6805fc1c9 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -157,10 +157,20 @@ namespace Rv { TWK_GLDEBUG; - QSurfaceFormat fmt = shareDevice()->glSurfaceFormat(); + // + // There is no GL share device when the main view presents through a + // non-OpenGL backend (the Vulkan 10-bit path), where RvApplication + // constructs the DesktopVideoModule with a null share device. Fall + // back to the default surface format and no explicit share context -- + // Qt::AA_ShareOpenGLContexts (set in main.cpp) already puts every + // QOpenGLContext in one resource-sharing group. + // + const QTGLVideoDevice* share = shareDevice(); + + QSurfaceFormat fmt = share ? share->glSurfaceFormat() : QSurfaceFormat::defaultFormat(); fmt.setSwapInterval(m_vsync ? 1 : 0); - ScreenView* vw = new ScreenView(fmt, 0, shareDevice()->glShareContext(), Qt::Window); + ScreenView* vw = new ScreenView(fmt, 0, share ? share->glShareContext() : nullptr, Qt::Window); setViewWidget(vw); QTGLVideoDevice* vd = new QTGLVideoDevice(0, "local view", vw); diff --git a/src/lib/app/RvCommon/GLView.cpp b/src/lib/app/RvCommon/GLView.cpp index a3a7bdc1a..e0b320d01 100644 --- a/src/lib/app/RvCommon/GLView.cpp +++ b/src/lib/app/RvCommon/GLView.cpp @@ -18,16 +18,36 @@ #include #include #include +#include #include #include #include #include #include +#include namespace Rv { using namespace std; + std::string glDebugEnvOrUnset(const char* name) + { + const char* value = std::getenv(name); + return value ? value : ""; + } + + std::string glDebugFormatSummary(const QSurfaceFormat& f) + { + ostringstream out; + out << "rgba " << f.redBufferSize() << " " << f.greenBufferSize() << " " << f.blueBufferSize() << " " + << (f.alphaBufferSize() <= 0 ? 0 : f.alphaBufferSize()); + out << ", depth " << f.depthBufferSize() << ", stencil " << f.stencilBufferSize(); + out << ", swapInterval " << f.swapInterval(); + out << ", stereo " << (f.stereo() ? "true" : "false"); + out << ", major.minor " << f.majorVersion() << "." << f.minorVersion(); + return out.str(); + } + GLView::GLView(QWidget* parent, QOpenGLContext* sharedContext, RvDocument* doc, bool stereo, bool vsync, bool doubleBuffer, int red, int green, int blue, int alpha, bool noResize) : QWidget(parent) @@ -389,6 +409,11 @@ namespace Rv fmt.setSwapInterval(vsync ? 1 : 0); + if (IPCore::ImageRenderer::debugGpu()) + { + cout << "INFO: GLView requested QSurfaceFormat: " << glDebugFormatSummary(fmt) << endl; + } + return fmt; } diff --git a/src/lib/app/RvCommon/GLWindow.cpp b/src/lib/app/RvCommon/GLWindow.cpp index 8c9e0317a..0d39ea258 100644 --- a/src/lib/app/RvCommon/GLWindow.cpp +++ b/src/lib/app/RvCommon/GLWindow.cpp @@ -18,20 +18,19 @@ #include #include #include -#include #include +#include #include #include #include #include -#include -#include #include #include #include +#include +#include #include #include -#include namespace Rv { @@ -39,29 +38,6 @@ namespace Rv using namespace TwkApp; using namespace IPCore; - namespace - { -#ifdef PLATFORM_LINUX - string envOrUnset(const char* name) - { - const char* value = std::getenv(name); - return value ? value : ""; - } -#endif - - string formatSummary(const QSurfaceFormat& f) - { - ostringstream out; - out << "rgba " << f.redBufferSize() << " " << f.greenBufferSize() << " " << f.blueBufferSize() << " " - << (f.alphaBufferSize() <= 0 ? 0 : f.alphaBufferSize()); - out << ", depth " << f.depthBufferSize() << ", stencil " << f.stencilBufferSize(); - out << ", swapInterval " << f.swapInterval(); - out << ", stereo " << (f.stereo() ? "true" : "false"); - out << ", major.minor " << f.majorVersion() << "." << f.minorVersion(); - return out.str(); - } - } // namespace - GLWindow::GLWindow(QOpenGLContext* sharedContext, RvDocument* doc, bool stereo, bool vsync, bool doubleBuffer, int red, int green, int blue, int alpha, bool noResize) : QOpenGLWindow(QOpenGLWindow::NoPartialUpdate) @@ -80,13 +56,6 @@ namespace Rv { setFormat(GLView::rvGLFormat(stereo, vsync, doubleBuffer, red, green, blue, alpha)); -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - if (ImageRenderer::debugGpu()) - { - cout << "INFO: GLWindow requested QSurfaceFormat: " << formatSummary(format()) << endl; - } -#endif - m_videoDevice = nullptr; // set later by the hosting GLView m_activityTimer.start(); @@ -135,32 +104,36 @@ namespace Rv QSurfaceFormat f = context()->format(); -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + // + // One-shot -debug gpu baseline: everything needed to answer "why + // did I only get 8 bits per component" without a second run -- + // what was asked for, what Qt negotiated, what the driver is, and + // which display server we are on. + // static bool baselineLogged = false; if (ImageRenderer::debugGpu() && !baselineLogged) { baselineLogged = true; - QScreen* screen = this->screen(); - if (!screen) - screen = QGuiApplication::primaryScreen(); + QScreen* scr = screen(); + if (!scr) + scr = QGuiApplication::primaryScreen(); const GLubyte* glVendor = glGetString(GL_VENDOR); const GLubyte* glRenderer = glGetString(GL_RENDERER); const GLubyte* glVersion = glGetString(GL_VERSION); const GLubyte* glslVersion = glGetString(GL_SHADING_LANGUAGE_VERSION); - const QSurfaceFormat requestedFormat = format(); cout << "INFO: GLWindow runtime baseline begin" << endl; cout << "INFO: Qt platform name: " << QGuiApplication::platformName().toStdString() << endl; cout << "INFO: Qt version: " << qVersion() << endl; - cout << "INFO: Constructor-requested color bits (GLWindow args): rgba " << m_red << " " << m_green << " " << m_blue << " " - << m_alpha << endl; - cout << "INFO: GLWindow::format() (post-negotiation): " << formatSummary(requestedFormat) << endl; - cout << "INFO: Actual QOpenGLContext format: " << formatSummary(f) << endl; - if (screen) + cout << "INFO: Constructor-requested color bits: rgba " << m_red << " " << m_green << " " << m_blue << " " << m_alpha + << endl; + cout << "INFO: QOpenGLWindow::format() (post-negotiation): " << glDebugFormatSummary(format()) << endl; + cout << "INFO: Actual QOpenGLContext format: " << glDebugFormatSummary(f) << endl; + if (scr) { - cout << "INFO: Screen name: " << screen->name().toStdString() << ", depth: " << screen->depth() << endl; + cout << "INFO: Screen name: " << scr->name().toStdString() << ", depth: " << scr->depth() << endl; } else { @@ -172,14 +145,13 @@ namespace Rv cout << "INFO: GL version: " << (glVersion ? reinterpret_cast(glVersion) : "") << endl; cout << "INFO: GLSL version: " << (glslVersion ? reinterpret_cast(glslVersion) : "") << endl; #ifdef PLATFORM_LINUX - cout << "INFO: Linux display env: XDG_SESSION_TYPE=" << envOrUnset("XDG_SESSION_TYPE") - << ", WAYLAND_DISPLAY=" << envOrUnset("WAYLAND_DISPLAY") << ", DISPLAY=" << envOrUnset("DISPLAY") - << ", XDG_CURRENT_DESKTOP=" << envOrUnset("XDG_CURRENT_DESKTOP") - << ", DESKTOP_SESSION=" << envOrUnset("DESKTOP_SESSION") << endl; + cout << "INFO: Linux display env: XDG_SESSION_TYPE=" << glDebugEnvOrUnset("XDG_SESSION_TYPE") + << ", WAYLAND_DISPLAY=" << glDebugEnvOrUnset("WAYLAND_DISPLAY") << ", DISPLAY=" << glDebugEnvOrUnset("DISPLAY") + << ", XDG_CURRENT_DESKTOP=" << glDebugEnvOrUnset("XDG_CURRENT_DESKTOP") + << ", DESKTOP_SESSION=" << glDebugEnvOrUnset("DESKTOP_SESSION") << endl; #endif cout << "INFO: GLWindow runtime baseline end" << endl; } -#endif #ifndef PLATFORM_DARWIN if (f.redBufferSize() != m_red && m_red != 0) diff --git a/src/lib/app/RvCommon/QTGLVideoDevice.cpp b/src/lib/app/RvCommon/QTGLVideoDevice.cpp index 6e652a193..4bb586b82 100644 --- a/src/lib/app/RvCommon/QTGLVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTGLVideoDevice.cpp @@ -196,9 +196,29 @@ namespace Rv if (m_view) { - // redraw() is backend-agnostic; m_view may be null when a non-GL - // backend (Vulkan/Metal) is active, so m_view->update() is unsafe. - redraw(); + if (m_view->isVisible()) + { +#ifdef PLATFORM_DARWIN + // Make sure that the QGLWidget gets redrawn by updateGL() even + // when completely overlapped by another window. + // Note that on macOS, Qt correctly detects when the QGLWidget + // is completely overlapped by another window and in which case + // resets the Qt::WA_Mapped attribute. This will prevent the + // GLView::paintGL() operation from being called by + // m_view->updateGL(), which will result in automatically + // interrupting any video playback that might be in progress + // while the RV window is completely overlapped. This is an + // undesirable behaviour during a review session, especially if + // an external video output device is used. + m_view->setAttribute(Qt::WA_Mapped); +#endif + + m_view->update(); + } + else + { + redraw(); + } } } diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 78ccb38b4..e5454309c 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -8,7 +8,7 @@ #include #include -#include +#include #include #include #include @@ -27,7 +27,6 @@ #include #include #include -#include #ifdef PLATFORM_WINDOWS // WIN32_LEAN_AND_MEAN prevents from including the legacy // , which otherwise collides with the already @@ -91,14 +90,14 @@ // The Linux build uses a newer managed GLEW that does, so the Linux call sites // can resolve the symbols at link time. On Windows we declare the function // pointer typedefs locally and resolve them at first use via wglGetProcAddress; -// if any are missing the GPU interop path is disabled and VulkanView falls +// if any are missing the GPU interop path is disabled and VulkanWindow falls // back to its CPU pack-and-upload presentation path. typedef void(GLAPIENTRY* PFNGLCREATEMEMORYOBJECTSEXTPROC_RV)(GLsizei n, GLuint* memoryObjects); typedef void(GLAPIENTRY* PFNGLDELETEMEMORYOBJECTSEXTPROC_RV)(GLsizei n, const GLuint* memoryObjects); +typedef void(GLAPIENTRY* PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV)(GLuint memoryObject, GLenum pname, const GLint* params); typedef void(GLAPIENTRY* PFNGLTEXSTORAGEMEM2DEXTPROC_RV)(GLenum target, GLsizei levels, GLenum internalFormat, GLsizei width, GLsizei height, GLuint memory, GLuint64 offset); typedef void(GLAPIENTRY* PFNGLIMPORTMEMORYWIN32HANDLEEXTPROC_RV)(GLuint memory, GLuint64 size, GLenum handleType, void* handle); -typedef void(GLAPIENTRY* PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV)(GLuint memoryObject, GLenum pname, const GLint* params); typedef void(GLAPIENTRY* PFNGLGENSEMAPHORESEXTPROC_RV)(GLsizei n, GLuint* semaphores); typedef void(GLAPIENTRY* PFNGLDELETESEMAPHORESEXTPROC_RV)(GLsizei n, const GLuint* semaphores); typedef void(GLAPIENTRY* PFNGLIMPORTSEMAPHOREWIN32HANDLEEXTPROC_RV)(GLuint semaphore, GLenum handleType, void* handle); @@ -111,9 +110,9 @@ namespace { PFNGLCREATEMEMORYOBJECTSEXTPROC_RV g_glCreateMemoryObjectsEXT = nullptr; PFNGLDELETEMEMORYOBJECTSEXTPROC_RV g_glDeleteMemoryObjectsEXT = nullptr; + PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV g_glMemoryObjectParameterivEXT = nullptr; PFNGLTEXSTORAGEMEM2DEXTPROC_RV g_glTexStorageMem2DEXT = nullptr; PFNGLIMPORTMEMORYWIN32HANDLEEXTPROC_RV g_glImportMemoryWin32HandleEXT = nullptr; - PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV g_glMemoryObjectParameterivEXT = nullptr; PFNGLGENSEMAPHORESEXTPROC_RV g_glGenSemaphoresEXT = nullptr; PFNGLDELETESEMAPHORESEXTPROC_RV g_glDeleteSemaphoresEXT = nullptr; PFNGLIMPORTSEMAPHOREWIN32HANDLEEXTPROC_RV g_glImportSemaphoreWin32HandleEXT = nullptr; @@ -132,11 +131,11 @@ namespace g_glCreateMemoryObjectsEXT = reinterpret_cast(wglGetProcAddress("glCreateMemoryObjectsEXT")); g_glDeleteMemoryObjectsEXT = reinterpret_cast(wglGetProcAddress("glDeleteMemoryObjectsEXT")); + g_glMemoryObjectParameterivEXT = + reinterpret_cast(wglGetProcAddress("glMemoryObjectParameterivEXT")); g_glTexStorageMem2DEXT = reinterpret_cast(wglGetProcAddress("glTexStorageMem2DEXT")); g_glImportMemoryWin32HandleEXT = reinterpret_cast(wglGetProcAddress("glImportMemoryWin32HandleEXT")); - g_glMemoryObjectParameterivEXT = - reinterpret_cast(wglGetProcAddress("glMemoryObjectParameterivEXT")); g_glGenSemaphoresEXT = reinterpret_cast(wglGetProcAddress("glGenSemaphoresEXT")); g_glDeleteSemaphoresEXT = reinterpret_cast(wglGetProcAddress("glDeleteSemaphoresEXT")); g_glImportSemaphoreWin32HandleEXT = @@ -144,37 +143,40 @@ namespace g_glWaitSemaphoreEXT = reinterpret_cast(wglGetProcAddress("glWaitSemaphoreEXT")); g_glSignalSemaphoreEXT = reinterpret_cast(wglGetProcAddress("glSignalSemaphoreEXT")); - // glMemoryObjectParameterivEXT is required, not optional: without it - // the GL side cannot mark an imported memory object dedicated, and a - // dedicated Vulkan export imported as non-dedicated corrupts the image. - g_glInteropAvailable = g_glCreateMemoryObjectsEXT && g_glDeleteMemoryObjectsEXT && g_glTexStorageMem2DEXT - && g_glImportMemoryWin32HandleEXT && g_glMemoryObjectParameterivEXT && g_glGenSemaphoresEXT + g_glInteropAvailable = g_glCreateMemoryObjectsEXT && g_glDeleteMemoryObjectsEXT && g_glMemoryObjectParameterivEXT + && g_glTexStorageMem2DEXT && g_glImportMemoryWin32HandleEXT && g_glGenSemaphoresEXT && g_glDeleteSemaphoresEXT && g_glImportSemaphoreWin32HandleEXT && g_glWaitSemaphoreEXT && g_glSignalSemaphoreEXT; // Identify the GL driver alongside the interop probe result. Useful when // the Windows GL context happens to be the Microsoft GDI Generic // software renderer, in which case interop is expected to fail. + // + // Unconditional, and this probe runs at most once per process. Which GL + // driver the offscreen context landed on is half of any interop + // diagnosis -- the other half is the Vulkan device name logged by + // initVulkan() -- and it has to be in the log of a session that was not + // launched with -debug gpu. + // const GLubyte* vendor = glGetString(GL_VENDOR); const GLubyte* renderer = glGetString(GL_RENDERER); const GLubyte* version = glGetString(GL_VERSION); - if (IPCore::ImageRenderer::debugGpu()) - { - std::cout << "INFO: QTVulkanVideoDevice: GL_VENDOR='" << (vendor ? reinterpret_cast(vendor) : "?") - << "' GL_RENDERER='" << (renderer ? reinterpret_cast(renderer) : "?") << "' GL_VERSION='" - << (version ? reinterpret_cast(version) : "?") << "'" << std::endl; - if (!g_glInteropAvailable) - { - std::cout << "INFO: QTVulkanVideoDevice: GL_EXT_memory_object_win32 / GL_EXT_semaphore_win32 NOT available; " - "falling back to CPU presentation path" - << std::endl; - } - else - { - std::cout << "INFO: QTVulkanVideoDevice: GL interop extensions resolved (GPU-interop available)" << std::endl; - } + std::cout << "INFO: QTVulkanVideoDevice: GL_VENDOR='" << (vendor ? reinterpret_cast(vendor) : "?") << "' GL_RENDERER='" + << (renderer ? reinterpret_cast(renderer) : "?") << "' GL_VERSION='" + << (version ? reinterpret_cast(version) : "?") << "'" << std::endl; + + if (!g_glInteropAvailable) + { + std::cout << "INFO: QTVulkanVideoDevice: GL_EXT_memory_object_win32 / GL_EXT_semaphore_win32 NOT available; " + "falling back to CPU presentation path" + << std::endl; + } + else + { + std::cout << "INFO: QTVulkanVideoDevice: GL interop extensions resolved (GPU-interop available)" << std::endl; } + return g_glInteropAvailable; } } // namespace @@ -183,9 +185,9 @@ namespace // dynamically resolved pointers. Linux still uses the real GLEW symbols. #define glCreateMemoryObjectsEXT g_glCreateMemoryObjectsEXT #define glDeleteMemoryObjectsEXT g_glDeleteMemoryObjectsEXT +#define glMemoryObjectParameterivEXT g_glMemoryObjectParameterivEXT #define glTexStorageMem2DEXT g_glTexStorageMem2DEXT #define glImportMemoryWin32HandleEXT g_glImportMemoryWin32HandleEXT -#define glMemoryObjectParameterivEXT g_glMemoryObjectParameterivEXT #define glGenSemaphoresEXT g_glGenSemaphoresEXT #define glDeleteSemaphoresEXT g_glDeleteSemaphoresEXT #define glImportSemaphoreWin32HandleEXT g_glImportSemaphoreWin32HandleEXT @@ -212,43 +214,48 @@ namespace Rv } } // namespace - QTVulkanVideoDevice::QTVulkanVideoDevice(VideoModule* module, const string& name, VulkanView* view, QWidget* eventWidget) + QTVulkanVideoDevice::QTVulkanVideoDevice(VideoModule* module, const string& name, VulkanWindow* window, QWidget* eventWidget) : TwkGLF::GLVideoDevice(module, name, VideoDevice::ImageOutput | VideoDevice::ProvidesSync | VideoDevice::SubWindow) - , m_view(view) - , m_translator(eventWidget ? std::make_unique(this, eventWidget) : nullptr) + , m_window(window) + , m_eventWidget(eventWidget) + , m_translator(eventWidget ? new QTTranslator(this, eventWidget) : nullptr) { - assert(view); + assert(window); } QTVulkanVideoDevice::~QTVulkanVideoDevice() { // Delete the FBO and its colour texture while the GL context is current. - if (m_glContext && (m_fbo || m_fboColorTex || m_sharedGL[0].memoryObject || m_cpuFlipFbo)) + if (m_glContext && (m_fbo || m_fboColorTex || m_glMemoryObject[0] || m_cpuFlipFbo)) { - m_glContext->makeCurrent(m_offscreenSurface.get()); - m_fbo.reset(); + m_glContext->makeCurrent(m_offscreenSurface); + delete m_fbo; + m_fbo = nullptr; if (m_fboColorTex) { glDeleteTextures(1, &m_fboColorTex); m_fboColorTex = 0; } - for (uint32_t i = 0; i < VulkanView::FRAMES_IN_FLIGHT; ++i) - { + for (uint32_t i = 0; i < VulkanWindow::FRAMES_IN_FLIGHT; ++i) cleanupSharedGLObjects(i); - } cleanupCpuFallbackTarget(); m_glContext->doneCurrent(); } - // Explicit resets keep the release order: surface, context, translator. - m_offscreenSurface.reset(); - m_glContext.reset(); - m_translator.reset(); + delete m_offscreenSurface; + m_offscreenSurface = nullptr; + + delete m_glContext; + m_glContext = nullptr; + + delete m_translator; } void QTVulkanVideoDevice::setEventWidget(QWidget* widget) { - m_translator = widget ? std::make_unique(this, widget) : nullptr; + m_eventWidget = widget; + delete m_translator; + m_translator = widget ? new QTTranslator(this, widget) : nullptr; } //-------------------------------------------------------------------------- @@ -262,7 +269,7 @@ namespace Rv fmt.setMajorVersion(2); fmt.setMinorVersion(1); - m_glContext = std::make_unique(); + m_glContext = new QOpenGLContext(); m_glContext->setFormat(fmt); // Join RV's global GL resource-sharing group (enabled via @@ -275,23 +282,26 @@ namespace Rv if (!m_glContext->create()) { cerr << "ERROR: QTVulkanVideoDevice: QOpenGLContext::create() failed" << endl; - m_glContext.reset(); + delete m_glContext; + m_glContext = nullptr; return; } - m_offscreenSurface = std::make_unique(); + m_offscreenSurface = new QOffscreenSurface(); m_offscreenSurface->setFormat(m_glContext->format()); m_offscreenSurface->create(); if (!m_offscreenSurface->isValid()) { cerr << "ERROR: QTVulkanVideoDevice: QOffscreenSurface::create() failed" << endl; - m_offscreenSurface.reset(); - m_glContext.reset(); + delete m_offscreenSurface; + m_offscreenSurface = nullptr; + delete m_glContext; + m_glContext = nullptr; return; } - m_glContext->makeCurrent(m_offscreenSurface.get()); + m_glContext->makeCurrent(m_offscreenSurface); glewExperimental = GL_TRUE; #ifdef PLATFORM_WINDOWS // The bundled Windows GLEW (src/pub/glew) has a Tweak-modified @@ -307,21 +317,23 @@ namespace Rv { cerr << "ERROR: QTVulkanVideoDevice: glewInit failed: " << glewGetErrorString(err) << endl; m_glContext->doneCurrent(); - m_offscreenSurface.reset(); - m_glContext.reset(); + delete m_offscreenSurface; + m_offscreenSurface = nullptr; + delete m_glContext; + m_glContext = nullptr; return; } } - if (!m_glContext->makeCurrent(m_offscreenSurface.get())) + if (!m_glContext->makeCurrent(m_offscreenSurface)) { cerr << "ERROR: QTVulkanVideoDevice: makeCurrent() failed" << endl; return; } - const float dpr = m_view ? m_view->devicePixelRatio() : 1.0f; - int newW = m_view ? static_cast(m_view->width() * dpr + 0.5f) : 128; - int newH = m_view ? static_cast(m_view->height() * dpr + 0.5f) : 128; + const float dpr = m_window ? m_window->devicePixelRatioF() : 1.0f; + int newW = m_window ? static_cast(m_window->width() * dpr + 0.5f) : 128; + int newH = m_window ? static_cast(m_window->height() * dpr + 0.5f) : 128; if (newW < 1) newW = 128; if (newH < 1) @@ -329,7 +341,8 @@ namespace Rv if (!m_fbo || m_fboWidth != newW || m_fboHeight != newH) { - m_fbo.reset(); + delete m_fbo; + m_fbo = nullptr; if (m_fboColorTex) { glDeleteTextures(1, &m_fboColorTex); @@ -341,7 +354,7 @@ namespace Rv glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, GL_RGBA16F_ARB, newW, newH, 0, GL_RGBA, GL_FLOAT, nullptr); glBindTexture(GL_TEXTURE_RECTANGLE_ARB, 0); - m_fbo = std::make_unique(newW, newH, GL_RGBA16F_ARB); + m_fbo = new TwkGLF::GLFBO(newW, newH, GL_RGBA16F_ARB); m_fbo->attachColorTexture(GL_TEXTURE_RECTANGLE_ARB, m_fboColorTex); GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT); @@ -365,19 +378,19 @@ namespace Rv { float refresh = -1.0f; - int w = m_view ? m_view->width() : 0; - int h = m_view ? m_view->height() : 0; + int w = m_window ? m_window->width() : 0; + int h = m_window ? m_window->height() : 0; int tx = x + w / 2; int ty = y + h / 2; if (const TwkApp::VideoModule* mod = TwkApp::App()->primaryVideoModule()) { - if (TwkApp::VideoDevice* device = mod->deviceFromPosition(tx, ty)) + if (TwkApp::VideoDevice* d = mod->deviceFromPosition(tx, ty)) { - setPhysicalDevice(device); - refresh = device->timing().hz; + setPhysicalDevice(d); + refresh = d->timing().hz; - VideoDeviceContextChangeEvent event("video-device-changed", this, this, device); + VideoDeviceContextChangeEvent event("video-device-changed", this, this, d); sendEvent(event); } } @@ -397,9 +410,9 @@ namespace Rv m_y = y; } - void QTVulkanVideoDevice::setPhysicalDevice(VideoDevice* device) + void QTVulkanVideoDevice::setPhysicalDevice(VideoDevice* d) { - TwkApp::VideoDevice::setPhysicalDevice(device); + TwkApp::VideoDevice::setPhysicalDevice(d); m_devicePixelRatio = 1.0f; @@ -407,7 +420,7 @@ namespace Rv if (noQtHighDPISupport) return; - if (const DesktopVideoDevice* desktopDev = dynamic_cast(device)) + if (const DesktopVideoDevice* desktopDev = dynamic_cast(d)) { const QList screens = QGuiApplication::screens(); if (desktopDev->qtScreen() < screens.size()) @@ -417,8 +430,8 @@ namespace Rv float QTVulkanVideoDevice::devicePixelRatio() const { - if (m_view) - return static_cast(m_view->devicePixelRatio()); + if (m_window) + return static_cast(m_window->devicePixelRatioF()); return m_devicePixelRatio; } @@ -431,48 +444,62 @@ namespace Rv TwkGLF::GLFBO* QTVulkanVideoDevice::defaultFBO() { ensureGLContext(); - return m_fbo.get(); + return m_fbo; } const TwkGLF::GLFBO* QTVulkanVideoDevice::defaultFBO() const { ensureGLContext(); - return m_fbo.get(); + return m_fbo; } std::string QTVulkanVideoDevice::hardwareIdentification() const { return "vulkan-hybrid"; } void QTVulkanVideoDevice::cleanupSharedGLObjects(uint32_t slot) const { - SharedGLObjects& glShared = m_sharedGL[slot]; - - if (glShared.drawFbo) + if (m_drawFbo[slot]) { - glDeleteFramebuffersEXT(1, &glShared.drawFbo); - glShared.drawFbo = 0; + glDeleteFramebuffersEXT(1, &m_drawFbo[slot]); + m_drawFbo[slot] = 0; } - if (glShared.texture) + if (m_glSharedTexture[slot]) { - glDeleteTextures(1, &glShared.texture); - glShared.texture = 0; + glDeleteTextures(1, &m_glSharedTexture[slot]); + m_glSharedTexture[slot] = 0; } - if (glShared.memoryObject) + if (m_glMemoryObject[slot]) { - glDeleteMemoryObjectsEXT(1, &glShared.memoryObject); - glShared.memoryObject = 0; + glDeleteMemoryObjectsEXT(1, &m_glMemoryObject[slot]); + m_glMemoryObject[slot] = 0; } - if (glShared.glReadySemaphore) + if (m_glReadySemaphore[slot]) { - glDeleteSemaphoresEXT(1, &glShared.glReadySemaphore); - glShared.glReadySemaphore = 0; + glDeleteSemaphoresEXT(1, &m_glReadySemaphore[slot]); + m_glReadySemaphore[slot] = 0; } - if (glShared.vkReadySemaphore) + if (m_vkReadySemaphore[slot]) + { + glDeleteSemaphoresEXT(1, &m_vkReadySemaphore[slot]); + m_vkReadySemaphore[slot] = 0; + } + m_sharedWidth[slot] = 0; + m_sharedHeight[slot] = 0; + } + + void QTVulkanVideoDevice::releaseSharedGLObjects() + { + // No context means nothing was ever imported. + if (!m_glContext || !m_offscreenSurface) + { + return; + } + + m_glContext->makeCurrent(m_offscreenSurface); + for (uint32_t i = 0; i < VulkanWindow::FRAMES_IN_FLIGHT; ++i) { - glDeleteSemaphoresEXT(1, &glShared.vkReadySemaphore); - glShared.vkReadySemaphore = 0; + cleanupSharedGLObjects(i); } - glShared.width = 0; - glShared.height = 0; + m_glContext->doneCurrent(); } void QTVulkanVideoDevice::ensureCpuFallbackTarget(int w, int h) const @@ -516,16 +543,41 @@ namespace Rv m_cpuFlipHeight = 0; } + bool QTVulkanVideoDevice::interopGLFailed(const char* what) const + { + GLenum first = glGetError(); + + if (first == GL_NO_ERROR) + { + return false; + } + + // Drain the rest so the next step starts from a clean queue and cannot + // be blamed for this one's error. + while (glGetError() != GL_NO_ERROR) + { + } + + // Unconditional: this is the message that turns an undiagnosable black + // viewport into a named failing call. + cerr << "ERROR: QTVulkanVideoDevice: " << what << " failed (GL 0x" << hex << first << dec << "); demoting '" << name() + << "' to CPU presentation." << endl; + + m_interopDisabled = true; + + return true; + } + void QTVulkanVideoDevice::presentCpuFallback(int w, int h) const { - TwkGLF::GLFBO* fbo = m_fbo.get(); + TwkGLF::GLFBO* fbo = m_fbo; // Pack in the swapchain's channel order. glReadPixels with // GL_UNSIGNED_INT_2_10_10_10_REV packs A2B10G10R10 (R low) for GL_RGBA and // A2R10G10B10 (R high) for GL_BGRA, so the read format selects the layout // directly with no CPU conversion. Linux/RADV surfaces commonly offer only // A2R10G10B10. - const VkFormat scFmt = m_view ? m_view->swapchainFormat() : VK_FORMAT_A2B10G10R10_UNORM_PACK32; + const VkFormat scFmt = m_window ? m_window->swapchainFormat() : VK_FORMAT_A2B10G10R10_UNORM_PACK32; const GLenum readFormat = (scFmt == VK_FORMAT_A2R10G10B10_UNORM_PACK32) ? GL_BGRA : GL_RGBA; ensureCpuFallbackTarget(w, h); @@ -542,7 +594,7 @@ namespace Rv // per-pixel CPU pack loop. m_cpuPackedScratch.resize(static_cast(w) * h); glReadPixels(0, 0, w, h, readFormat, GL_UNSIGNED_INT_2_10_10_10_REV, m_cpuPackedScratch.data()); - m_view->presentPixelData(m_cpuPackedScratch.data(), w, h); + m_window->presentPixelData(m_cpuPackedScratch.data(), w, h); glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fbo->fboID()); // restore } @@ -553,7 +605,7 @@ namespace Rv void QTVulkanVideoDevice::syncBuffers() const { - if (!m_view) + if (!m_window) return; if (!m_glContext || !m_fbo) @@ -563,20 +615,19 @@ namespace Rv // frame renders into. getSharedImageInfo()/presentSharedImage() below use // this same slot; presentSharedImage() advances it only at frame end, so // the value is stable for the whole call. - const uint32_t slot = m_view->currentFrame(); - SharedGLObjects& glShared = m_sharedGL[slot]; + const uint32_t slot = m_window->currentFrame(); - TwkGLF::GLFBO* fbo = m_fbo.get(); + TwkGLF::GLFBO* fbo = m_fbo; const int w = static_cast(fbo->width()); const int h = static_cast(fbo->height()); if (w <= 0 || h <= 0) return; - if (!m_glContext->makeCurrent(m_offscreenSurface.get())) + if (!m_glContext->makeCurrent(m_offscreenSurface)) return; - // Get shared image info from VulkanView. RV_VULKAN_FORCE_CPU_PRESENT skips + // Get shared image info from VulkanWindow. RV_VULKAN_FORCE_CPU_PRESENT skips // interop entirely so getSharedImageInfo() never allocates a shared image // and the CPU fallback below runs. #ifdef PLATFORM_WINDOWS @@ -584,40 +635,43 @@ namespace Rv // while the GL context is current. If the driver does not expose them, // skip the Vulkan-side export work entirely and fall through to the // CPU pack-and-upload path below. - const bool glInteropAvailable = !forceCpuPresentation() && !m_glInteropFailed && loadGLInteropExtensions(); - const VulkanView::SharedImageInfo* sharedInfo = glInteropAvailable ? m_view->getSharedImageInfo(w, h) : nullptr; + const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && loadGLInteropExtensions(); + const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; #else - const bool glInteropAvailable = !forceCpuPresentation() && !m_glInteropFailed && GLEW_EXT_memory_object && GLEW_EXT_semaphore + const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && GLEW_EXT_memory_object && GLEW_EXT_semaphore && GLEW_EXT_memory_object_fd && GLEW_EXT_semaphore_fd; - const VulkanView::SharedImageInfo* sharedInfo = glInteropAvailable ? m_view->getSharedImageInfo(w, h) : nullptr; + const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; #endif + // + // Unconditional, and re-reported on every transition. Which of the two + // present paths a device ended up on is the first thing needed to place + // a black or mis-rendered viewport, and the report has to survive a + // session that was not launched with -debug gpu -- the only kind we get + // back from QA. A first-frame-only latch was actively misleading: the + // first syncBuffers() can run before the swapchain exists, so it + // reported CPU-fallback / UNDEFINED for a device that then ran on + // interop for the rest of the session. + // + const int presentPath = sharedInfo ? 1 : 0; + if (m_loggedPresentPath != presentPath) + { + m_loggedPresentPath = presentPath; + const VkFormat scFmt = m_window ? m_window->swapchainFormat() : VK_FORMAT_UNDEFINED; + cout << "INFO: QTVulkanVideoDevice: syncBuffers: '" << name() << "' " << w << "x" << h + << " present path = " << (sharedInfo ? "GPU-interop" : "CPU-fallback") << " swapchainFormat=" << scFmt + << (scFmt == VK_FORMAT_A2B10G10R10_UNORM_PACK32 ? " (A2B10G10R10 / 10-bit)" + : scFmt == VK_FORMAT_A2R10G10B10_UNORM_PACK32 ? " (A2R10G10B10 / 10-bit)" + : scFmt == VK_FORMAT_UNDEFINED ? " (UNDEFINED -- swapchain not created yet)" + : " (NOT 10-bit)") + << endl; + } + if (!sharedInfo) { // No zero-copy interop this frame: pack + present via the CPU fallback. // The GL-packed RGB10_A2 readback handles the Y flip and the swapchain // channel order (A2B10G10R10 / A2R10G10B10) without a per-pixel loop. - // - // Report the specific reason so the startup record is conclusive for - // someone reading only a log: a bare "CPU fallback" does not say - // whether interop was forced off, unavailable in GL, or refused by - // the Vulkan-side capability probe. - std::string reason; - if (forceCpuPresentation()) - { - reason = "RV_VULKAN_FORCE_CPU_PRESENT is set"; - } - else if (!glInteropAvailable) - { - reason = "the GL driver does not expose the EXT_memory_object / EXT_semaphore interop entry points"; - } - else - { - const VulkanView::InteropConfig& config = m_view->interopConfig(); - reason = config.rejectReason.empty() ? "the Vulkan side declined to allocate a shared image" : config.rejectReason; - } - m_view->reportPresentPath(VulkanView::PresentPath::CpuReadback, reason); - presentCpuFallback(w, h); return; } @@ -625,37 +679,36 @@ namespace Rv // Re-import only when the shared image was actually reallocated, i.e. its // capacity (stride width + capacity height) changed. Within capacity the // Vulkan side keeps the same export, so a resize does not re-import here; - // glShared.width/height cache the imported capacity, not the used + // m_sharedWidth/m_sharedHeight cache the imported capacity, not the used // (requested) size. - if (glShared.width != sharedInfo->strideWidth || glShared.height != sharedInfo->capacityHeight || !glShared.memoryObject) + if (m_sharedWidth[slot] != sharedInfo->strideWidth || m_sharedHeight[slot] != sharedInfo->capacityHeight || !m_glMemoryObject[slot]) { cleanupSharedGLObjects(slot); - // Clear any pre-existing GL error so the check after the import - // sequence attributes only this sequence's failures. + // Start from a clean error queue so interopGLFailed() below cannot + // attribute an unrelated earlier error to the import. while (glGetError() != GL_NO_ERROR) { } - glCreateMemoryObjectsEXT(1, &glShared.memoryObject); + glCreateMemoryObjectsEXT(1, &m_glMemoryObject[slot]); - // Mirror the Vulkan side's dedicated-allocation decision. This must - // be set on the memory object BEFORE glTexStorageMem2DEXT, and must - // match the export exactly: a dedicated Vulkan allocation imported - // as non-dedicated (or the reverse) produces a corrupted image - // rather than an error. NVIDIA's OPAQUE_WIN32 path requires it. + // EXT_memory_object requires both sides to agree on whether the + // allocation is dedicated, and the parameter has to be set before + // the import. This follows whatever the Vulkan side allocated. + if (sharedInfo->dedicated) { - const GLint dedicated = sharedInfo->dedicatedAllocation ? GL_TRUE : GL_FALSE; - glMemoryObjectParameterivEXT(glShared.memoryObject, GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); + const GLint dedicated = GL_TRUE; + glMemoryObjectParameterivEXT(m_glMemoryObject[slot], GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); } #ifdef PLATFORM_WINDOWS // Windows GL import does NOT take ownership of the HANDLE; the // Vulkan side and this GL side each keep their own reference. - // VulkanView's cleanupSharedImage() calls CloseHandle on its + // VulkanWindow's cleanupSharedImage() calls CloseHandle on its // copy; this device's cleanupSharedGLObjects() does not need to // close anything because glImportMemoryWin32HandleEXT does not // create a new handle. - glImportMemoryWin32HandleEXT(glShared.memoryObject, sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + glImportMemoryWin32HandleEXT(m_glMemoryObject[slot], sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->memoryHandle)); #else // Duplicate the FD because glImportMemoryFdEXT takes ownership @@ -666,32 +719,27 @@ namespace Rv cleanupSharedGLObjects(slot); return; } - glImportMemoryFdEXT(glShared.memoryObject, sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_FD_EXT, memFd); + glImportMemoryFdEXT(m_glMemoryObject[slot], sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_FD_EXT, memFd); #endif - glGenTextures(1, &glShared.texture); - glBindTexture(GL_TEXTURE_2D, glShared.texture); + glGenTextures(1, &m_glSharedTexture[slot]); + glBindTexture(GL_TEXTURE_2D, m_glSharedTexture[slot]); - // Import with the tiling the Vulkan side actually created the image - // with. Importing OPTIMAL-tiled memory as LINEAR leaves the image's - // large-scale structure recognizable but scrambles pixels within - // each tile. - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, - sharedInfo->tiling == VK_IMAGE_TILING_OPTIMAL ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, sharedInfo->optimalTiling ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); // Allocate the imported texture at the image's capacity dimensions // (stride width x capacity height); the FBO blit below writes only the // used w x h sub-region into its origin corner. - glTexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGB10_A2, sharedInfo->strideWidth, sharedInfo->capacityHeight, glShared.memoryObject, + glTexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGB10_A2, sharedInfo->strideWidth, sharedInfo->capacityHeight, m_glMemoryObject[slot], 0); glBindTexture(GL_TEXTURE_2D, 0); - glGenSemaphoresEXT(1, &glShared.glReadySemaphore); - glGenSemaphoresEXT(1, &glShared.vkReadySemaphore); + glGenSemaphoresEXT(1, &m_glReadySemaphore[slot]); + glGenSemaphoresEXT(1, &m_vkReadySemaphore[slot]); #ifdef PLATFORM_WINDOWS - glImportSemaphoreWin32HandleEXT(glShared.glReadySemaphore, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + glImportSemaphoreWin32HandleEXT(m_glReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->glReadySemaphoreHandle)); - glImportSemaphoreWin32HandleEXT(glShared.vkReadySemaphore, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + glImportSemaphoreWin32HandleEXT(m_vkReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->vkReadySemaphoreHandle)); #else int glReadyFd = dup(sharedInfo->glReadySemaphoreFd); @@ -701,7 +749,7 @@ namespace Rv cleanupSharedGLObjects(slot); return; } - glImportSemaphoreFdEXT(glShared.glReadySemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, glReadyFd); + glImportSemaphoreFdEXT(m_glReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_FD_EXT, glReadyFd); int vkReadyFd = dup(sharedInfo->vkReadySemaphoreFd); if (vkReadyFd == -1) @@ -710,55 +758,59 @@ namespace Rv cleanupSharedGLObjects(slot); return; } - glImportSemaphoreFdEXT(glShared.vkReadySemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, vkReadyFd); + glImportSemaphoreFdEXT(m_vkReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_FD_EXT, vkReadyFd); #endif - // The import sequence fails by producing a GL error and an unusable - // texture rather than by any return value, so check explicitly. A - // silently failed import is what presents as a corrupted viewport; - // dropping to the CPU rung instead keeps the image correct. - const GLenum importError = glGetError(); - if (importError != GL_NO_ERROR) + // Nothing above reports failure through a return value. Without + // this check a rejected import leaves an incomplete texture, the + // blit below is silently dropped, and Vulkan presents an image + // that was never written -- a black viewport with no diagnostic. + if (interopGLFailed("GL<->Vulkan shared image import")) { - cerr << "ERROR: QTVulkanVideoDevice: GL import of the Vulkan shared image failed (GL error 0x" << std::hex << importError - << std::dec << ", tiling=" << (sharedInfo->tiling == VK_IMAGE_TILING_OPTIMAL ? "OPTIMAL" : "LINEAR") - << ", dedicated=" << (sharedInfo->dedicatedAllocation ? "yes" : "no") << "); using the CPU readback path instead." - << endl; - cleanupSharedGLObjects(slot); - m_glInteropFailed = true; - m_view->reportPresentPath(VulkanView::PresentPath::CpuReadback, "GL import of the shared image raised a GL error"); + // Every slot, not just this one: interop is off for good now, + // so the other ring slot's import would otherwise sit there + // until the device is destroyed. + for (uint32_t s = 0; s < VulkanWindow::FRAMES_IN_FLIGHT; ++s) + { + cleanupSharedGLObjects(s); + } presentCpuFallback(w, h); return; } // Cache the imported capacity so we re-import only when it grows. - glShared.width = sharedInfo->strideWidth; - glShared.height = sharedInfo->capacityHeight; - - m_view->reportGLImportState(sharedInfo->tiling, sharedInfo->dedicatedAllocation); + m_sharedWidth[slot] = sharedInfo->strideWidth; + m_sharedHeight[slot] = sharedInfo->capacityHeight; } - // Import succeeded (or was already valid from a previous frame): this - // frame presents zero-copy. Emitting here rather than before the import - // means the record reflects the path actually taken, and can report the - // GL side's settings alongside the Vulkan side's. - m_view->reportPresentPath(VulkanView::PresentPath::ZeroCopy, std::string()); + // Drain before the handshake, not after. + // + // session->render() runs earlier in this same frame and does leave + // errors pending -- that is what the "GL ERROR: *BEFORE* userRender" + // report exists to surface. Checking glGetError() after the blit + // without clearing first would attribute an unrelated error to the + // interop path and permanently demote a working device to the CPU + // fallback. Clear here so the check below sees only errors produced by + // the wait/blit/signal sequence itself. + while (glGetError() != GL_NO_ERROR) + { + } // Wait for Vulkan to be ready GLuint waitSrcLayouts[] = {GL_LAYOUT_TRANSFER_SRC_EXT}; - glWaitSemaphoreEXT(glShared.vkReadySemaphore, 0, nullptr, 1, &glShared.texture, waitSrcLayouts); + glWaitSemaphoreEXT(m_vkReadySemaphore[slot], 0, nullptr, 1, &m_glSharedTexture[slot], waitSrcLayouts); // Blit from FBO to shared texture GLuint readFbo = fbo->fboID(); - if (!glShared.drawFbo) + if (!m_drawFbo[slot]) { - glGenFramebuffersEXT(1, &glShared.drawFbo); - glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, glShared.drawFbo); - glFramebufferTexture2DEXT(GL_DRAW_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, glShared.texture, 0); + glGenFramebuffersEXT(1, &m_drawFbo[slot]); + glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_drawFbo[slot]); + glFramebufferTexture2DEXT(GL_DRAW_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, m_glSharedTexture[slot], 0); } else { - glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, glShared.drawFbo); + glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_drawFbo[slot]); } glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, readFbo); @@ -770,12 +822,26 @@ namespace Rv // Signal Vulkan that GL is done GLuint signalDstLayouts[] = {GL_LAYOUT_COLOR_ATTACHMENT_EXT}; - glSignalSemaphoreEXT(glShared.glReadySemaphore, 0, nullptr, 1, &glShared.texture, signalDstLayouts); + glSignalSemaphoreEXT(m_glReadySemaphore[slot], 0, nullptr, 1, &m_glSharedTexture[slot], signalDstLayouts); glFlush(); - // Tell VulkanView to present - m_view->presentSharedImage(); + // Same reasoning as the import check: the semaphore wait/signal and the + // blit all fail silently. Catching it here means a driver that refuses + // the handshake mid-session degrades to the CPU path with a named cause + // instead of going black. + if (interopGLFailed("GL<->Vulkan shared image blit")) + { + for (uint32_t s = 0; s < VulkanWindow::FRAMES_IN_FLIGHT; ++s) + { + cleanupSharedGLObjects(s); + } + presentCpuFallback(w, h); + return; + } + + // Tell VulkanWindow to present + m_window->presentSharedImage(); } //-------------------------------------------------------------------------- @@ -784,9 +850,12 @@ namespace Rv void QTVulkanVideoDevice::redraw() const { - if (m_view) + // QWindow::requestUpdate() posts a coalesced UpdateRequest: at most one + // render is queued at a time, so a burst of redraw requests collapses + // to a single render instead of one heavy present per request. + if (m_window) { - QCoreApplication::postEvent(m_view, new QEvent(QEvent::UpdateRequest)); + m_window->requestUpdate(); } } @@ -796,12 +865,12 @@ namespace Rv VideoDevice::Resolution QTVulkanVideoDevice::resolution() const { - if (!m_view) + if (!m_window) { return Resolution(0, 0, 1.0f, 1.0f); } - const float dpr = m_view->devicePixelRatio(); - return Resolution(static_cast(m_view->width() * dpr + 0.5f), static_cast(m_view->height() * dpr + 0.5f), 1.0f, 1.0f); + const float dpr = m_window->devicePixelRatioF(); + return Resolution(static_cast(m_window->width() * dpr + 0.5f), static_cast(m_window->height() * dpr + 0.5f), 1.0f, 1.0f); } VideoDevice::Offset QTVulkanVideoDevice::offset() const { return Offset(m_x, m_y); } @@ -810,56 +879,56 @@ namespace Rv VideoDevice::VideoFormat QTVulkanVideoDevice::format() const { - if (!m_view) + if (!m_window) { return VideoFormat(0, 0, 1.0, 1.0, 0.0, hardwareIdentification()); } - const float dpr = m_view->devicePixelRatio(); - return VideoFormat(static_cast(m_view->width() * dpr + 0.5f), static_cast(m_view->height() * dpr + 0.5f), 1.0, 1.0, + const float dpr = m_window->devicePixelRatioF(); + return VideoFormat(static_cast(m_window->width() * dpr + 0.5f), static_cast(m_window->height() * dpr + 0.5f), 1.0, 1.0, (m_refresh != -1.0f) ? m_refresh : 0.0f, hardwareIdentification()); } size_t QTVulkanVideoDevice::width() const { - if (!m_view) + if (!m_window) { return 0; } - return static_cast(m_view->width() * m_view->devicePixelRatio() + 0.5f); + return static_cast(m_window->width() * m_window->devicePixelRatioF() + 0.5f); } size_t QTVulkanVideoDevice::height() const { - if (!m_view) + if (!m_window) { return 0; } - return static_cast(m_view->height() * m_view->devicePixelRatio() + 0.5f); + return static_cast(m_window->height() * m_window->devicePixelRatioF() + 0.5f); } void QTVulkanVideoDevice::open(const StringVector& /*args*/) { - if (m_view) + if (m_window) { - m_view->show(); + m_window->show(); } m_isOpen = true; } void QTVulkanVideoDevice::close() { - if (m_view) + if (m_window) { - m_view->hide(); + m_window->hide(); } m_isOpen = false; } bool QTVulkanVideoDevice::isOpen() const { - if (m_view) + if (m_window) { - return m_view->isVisible(); + return m_window->isVisible(); } return false; } diff --git a/src/lib/app/RvCommon/RvCommon/GLView.h b/src/lib/app/RvCommon/RvCommon/GLView.h index 44d5649ef..525471530 100644 --- a/src/lib/app/RvCommon/RvCommon/GLView.h +++ b/src/lib/app/RvCommon/RvCommon/GLView.h @@ -12,6 +12,7 @@ #include #include #include +#include class QOpenGLContext; class QWindow; @@ -22,6 +23,14 @@ namespace Rv class QTGLVideoDevice; class GLWindow; + // + // -debug gpu (ImageRenderer::debugGpu()) diagnostics helpers, shared by + // GLView (which logs the format it asks for) and GLWindow (which logs the + // format and driver it actually got). Defined in GLView.cpp. + // + std::string glDebugEnvOrUnset(const char* name); + std::string glDebugFormatSummary(const QSurfaceFormat&); + // // GLView // diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index f05bd857a..9a0c6d4ab 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -7,13 +7,14 @@ #include #include -#include +#include #include #include -#include #include #include +#include + QT_BEGIN_NAMESPACE class QOpenGLContext; class QOffscreenSurface; @@ -22,20 +23,30 @@ QT_END_NAMESPACE namespace Rv { - class VulkanView; + class VulkanWindow; // // QTVulkanVideoDevice // - // Wraps a VulkanView as a TwkGLF::GLVideoDevice so that ImageRenderer's + // Wraps a VulkanWindow as a TwkGLF::GLVideoDevice so that ImageRenderer's // existing GL rendering pipeline (renderMain, shader cache, etc.) can run // unchanged on the Vulkan presentation path. // class QTVulkanVideoDevice : public TwkGLF::GLVideoDevice { public: - QTVulkanVideoDevice(TwkApp::VideoModule* module, const std::string& name, VulkanView* view, QWidget* eventWidget); - ~QTVulkanVideoDevice() override; + // + // The presentation surface is a QWindow (embedded in the widget tree + // via createWindowContainer); eventWidget is the container QWidget the + // QTTranslator uses for coordinate mapping (height/mapToGlobal) and + // mouse grab. + // + QTVulkanVideoDevice(TwkApp::VideoModule* module, const std::string& name, VulkanWindow* window, QWidget* eventWidget); + virtual ~QTVulkanVideoDevice(); + + VulkanWindow* vulkanWindow() const { return m_window; } + + QWidget* eventWidget() const { return m_eventWidget; } void setEventWidget(QWidget* widget); @@ -45,6 +56,16 @@ namespace Rv void setAbsolutePosition(int x, int y); + // + // Drop every GL object imported from the window's Vulkan side (the + // memory objects, their textures/FBOs and the shared semaphores), so + // none of them outlives the Vulkan memory it aliases. Called by + // VulkanWindow::releaseVulkanResources() before it frees that memory; + // syncBuffers() re-imports on the next frame (it rebuilds whenever + // m_glMemoryObject[slot] is 0). + // + void releaseSharedGLObjects(); + // VideoDevice API void makeCurrent() const override; void syncBuffers() const override; @@ -66,7 +87,7 @@ namespace Rv float devicePixelRatio() const override; - void setPhysicalDevice(VideoDevice* device) override; + void setPhysicalDevice(VideoDevice* d) override; // GLVideoDevice API TwkGLF::GLFBO* defaultFBO() override; @@ -74,12 +95,20 @@ namespace Rv std::string hardwareIdentification() const override; private: - // Ensure the QOpenGLContext + FBO exist and match the current view size. + // Ensure the QOpenGLContext + FBO exist and match the current window size. // Makes the GL context current and binds the FBO on return. void ensureGLContext() const; - VulkanView* m_view; - std::unique_ptr m_translator; + // + // Guarded: the window is embedded via QWidget::createWindowContainer(), + // which owns it, so Qt can delete it independently of this device (and + // of the VulkanView that created both). A QPointer makes the + // `if (m_window)` checks below actual liveness checks instead of null + // checks. + // + QPointer m_window; + QWidget* m_eventWidget; + QTTranslator* m_translator; float m_devicePixelRatio{1.0f}; int m_x{0}; int m_y{0}; @@ -87,34 +116,44 @@ namespace Rv bool m_isOpen{false}; // Qt GL context + offscreen surface for GL rendering. - mutable std::unique_ptr m_glContext; - mutable std::unique_ptr m_offscreenSurface; - mutable std::unique_ptr m_fbo; + mutable QOpenGLContext* m_glContext{nullptr}; + mutable QOffscreenSurface* m_offscreenSurface{nullptr}; + mutable TwkGLF::GLFBO* m_fbo{nullptr}; mutable GLuint m_fboColorTex{0}; // Texture attached to m_fbo; GLFBO does not own it mutable int m_fboWidth{0}; mutable int m_fboHeight{0}; - // GPU Interop GL objects, ringed per in-flight slot to match VulkanView's + // GPU Interop GL objects, ringed per in-flight slot to match VulkanWindow's // per-slot Vulkan shared image/semaphores. Indexed by the Vulkan slot for - // the frame being rendered (VulkanView::currentFrame()). - struct SharedGLObjects - { - GLuint memoryObject{0}; - GLuint texture{0}; - GLuint glReadySemaphore{0}; - GLuint vkReadySemaphore{0}; - GLuint drawFbo{0}; - int width{0}; // imported capacity width - int height{0}; // imported capacity height - }; - - mutable std::array m_sharedGL{}; - - // Latched once the GL side fails to import a Vulkan-exported shared - // image. Without this the next frame re-attempts the same import with - // the same configuration and fails identically, once per frame. The - // session stays on the CPU readback path instead. - mutable bool m_glInteropFailed{false}; + // the frame being rendered (VulkanWindow::currentFrame()). + mutable std::array m_glMemoryObject{}; + mutable std::array m_glSharedTexture{}; + mutable std::array m_glReadySemaphore{}; + mutable std::array m_vkReadySemaphore{}; + mutable std::array m_drawFbo{}; + mutable std::array m_sharedWidth{}; + mutable std::array m_sharedHeight{}; + + // Which present path this device last reported: -1 nothing yet, + // 0 CPU-fallback, 1 GPU-interop. Per-device, and reported on every + // transition rather than latched on the first frame, because the first + // syncBuffers() can run before that window's Vulkan is initialized -- + // latching there reports CPU-fallback for a device that then spends its + // whole life on interop. + mutable int m_loggedPresentPath{-1}; + + // Latched once any GL call on the interop path reports an error. The + // GL<->Vulkan bridge has no way to notice that an import silently + // produced an unusable texture: the blit is dropped, Vulkan copies a + // never-written image, and the viewport is black with nothing logged. + // Demoting permanently to the CPU pack-and-upload path keeps the image + // correct (just slower) on a driver combination we have not seen. + mutable bool m_interopDisabled{false}; + + // Drain glGetError(); on error, report which step failed, latch + // m_interopDisabled and return true. Callers must then release the + // slot's GL objects and present through the CPU fallback. + bool interopGLFailed(const char* what) const; void cleanupSharedGLObjects(uint32_t slot) const; diff --git a/src/lib/app/RvCommon/RvCommon/RvDocument.h b/src/lib/app/RvCommon/RvCommon/RvDocument.h index 5f3e5bfae..9a7b6a804 100644 --- a/src/lib/app/RvCommon/RvCommon/RvDocument.h +++ b/src/lib/app/RvCommon/RvCommon/RvDocument.h @@ -92,6 +92,8 @@ namespace Rv TwkGLF::GLVideoDevice* viewVideoDevice() const; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + VulkanView* vulkanView() const; + // True once close has been accepted or the document is being destroyed. bool isClosing() const { return m_currentlyClosing || m_closeEventReceived; } @@ -206,9 +208,9 @@ namespace Rv GLView* m_glView; GLView* m_oldGLView; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - VulkanView* m_vulkanView{nullptr}; + VulkanView* m_vulkanView; #endif - QWidget* m_viewWidget{nullptr}; + QWidget* m_viewWidget; QWidget* m_viewContainerWidget; RvTopViewToolBar* m_topViewToolBar; RvBottomViewToolBar* m_bottomViewToolBar; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanView.h b/src/lib/app/RvCommon/RvCommon/VulkanView.h index 9f82a946e..741217c80 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanView.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanView.h @@ -5,53 +5,56 @@ // #pragma once -#include +#include #include -#include -#include -#include -#include - -#include -#include -#include -#include -#include +QT_BEGIN_NAMESPACE +class QWindow; +QT_END_NAMESPACE namespace Rv { class RvDocument; class QTVulkanVideoDevice; + class VulkanWindow; // // VulkanView // - // A QWidget subclass that presents 10-bit Vulkan images on Linux and Windows. - // All IPCore image processing runs in OpenGL via a separate - // QOpenGLContext+QOffscreenSurface; the Vulkan path is used only for - // final 10-bit pixel delivery to avoid 8-bit GLX visual truncation. + // Host QWidget that embeds the native Vulkan viewport (VulkanWindow) via + // QWidget::createWindowContainer(), and owns the QTVulkanVideoDevice that + // drives it. + // + // This is deliberately the same shape as GLView/GLWindow. Keeping the + // viewport on a native window of its own -- rather than on a widget that + // Qt composites into the top-level window -- is what keeps the main window + // off a render-to-texture composite path, and it means the two backends + // share one set of embedding and lifetime rules instead of two. // class VulkanView : public QWidget { Q_OBJECT public: - using Timer = TwkUtil::Timer; - - explicit VulkanView(RvDocument* doc, QWidget* parent = nullptr, bool noResize = true); + VulkanView(RvDocument* doc, QWidget* parent = nullptr, bool noResize = true); ~VulkanView() override; - QTVulkanVideoDevice* videoDevice() const { return m_videoDevice.get(); } + VulkanWindow* vulkanWindow() const { return m_vulkanWindow; } - void setEventWidget(QWidget* widget); + QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } + // + // Delegated to the viewport window. + // void stopProcessingEvents(); - bool event(QEvent* event) override; - bool eventFilter(QObject* object, QEvent* event) override; + bool firstPaintCompleted() const; + + bool isInitialized() const; + + void absolutePosition(int& x, int& y) const; - bool firstPaintCompleted() const { return m_firstPaintCompleted; } + float devicePixelRatio() const; void setContentSize(int w, int h) { m_csize = QSize(w, h); } @@ -61,317 +64,56 @@ namespace Rv QSize minimumSizeHint() const override { return m_msize; } - void absolutePosition(int& x, int& y) const; - - float devicePixelRatio() const; - - // Format of the active swapchain image: VK_FORMAT_A2B10G10R10_UNORM_PACK32 - // or VK_FORMAT_A2R10G10B10_UNORM_PACK32 (the two differ in R/B order). - // VK_FORMAT_UNDEFINED before the swapchain is created. - VkFormat swapchainFormat() const { return m_vkSwapchainFormat; } - // - // Presentation path taken this session. RV prefers ZeroCopy, degrades - // to CpuReadback (slower, still 10-bit), and only then to OpenGL - // (which forgoes 10-bit). See emitPresentationRecord(). + // Probe for whether this machine's Vulkan can present a 10-bit format. + // Forwards to VulkanWindow; see the note there. // - enum class PresentPath - { - Undetermined, - ZeroCopy, // GL renders straight into a Vulkan-exported image - CpuReadback, // GL packs RGB10_A2 to host memory, Vulkan uploads it - OpenGL // Vulkan abandoned; RvDocument swaps in GLView - }; - - // Resolved GL<->Vulkan interop configuration. Negotiated once per - // device from what the driver reports exportable, never from GPU - // vendor identity or host platform. Both the Vulkan export and the GL - // import read their settings from this one struct so the two sides - // cannot disagree -- a disagreement about tiling or dedicated - // allocation corrupts the image rather than raising an error. - struct InteropConfig - { - bool supported{false}; - VkFormat format{VK_FORMAT_A2B10G10R10_UNORM_PACK32}; - VkImageTiling tiling{VK_IMAGE_TILING_LINEAR}; - VkImageUsageFlags usage{0}; - - // Probe-time floor for dedicated allocation: true when the handle - // type reports DEDICATED_ONLY, which is a hard requirement. The - // softer "prefers dedicated" signal belongs to a concrete image - // rather than to the format, so it is read per-image from - // VkMemoryDedicatedRequirements at allocation time and recorded in - // SharedImageInfo::dedicatedAllocation, which is what the GL side - // mirrors. - bool dedicatedAllocation{false}; - - // Raw VkExternalMemoryFeatureFlags the winning candidate reported, - // so a log read by someone without the machine can tell whether - // dedicated allocation was required by the handle type or merely - // preferred by the image. - VkExternalMemoryFeatureFlags externalFeatures{0}; - - // Set when an RV_VULKAN_FORCE_* override displaced what the probe - // would otherwise have chosen; the record reports both values. - bool tilingOverridden{false}; - bool dedicatedOverridden{false}; - VkImageTiling probedTiling{VK_IMAGE_TILING_LINEAR}; - bool probedDedicated{false}; - - // Why no candidate was usable (empty when supported is true). - std::string rejectReason; - // Per-candidate probe outcome, one entry per candidate tried. - std::vector candidateLog; - }; - - const InteropConfig& interopConfig() const { return m_interopConfig; } - - // Record the path actually taken, and the GL side's view of the shared - // image, then emit the one-per-session startup record. Called by - // QTVulkanVideoDevice once the first frame establishes which path ran. - void reportPresentPath(PresentPath path, const std::string& reason); - void reportGLImportState(VkImageTiling tiling, bool dedicated); + static bool supports10BitPresentation(); + private: // - // Vulkan presentation, called by QTVulkanVideoDevice::syncBuffers(). + // Keeping the viewport window alive across top-level window churn. // - - // GPU Interop API + // createWindowContainer() transfers ownership of the viewport window to + // the container, which parents it to the top-level QWidgetWindow. Qt + // destroys and recreates that QWidgetWindow when a widget is reparented + // into the window -- QWidget::setParent() -> destroy() -> ~QWidgetWindow + // -- as happens when a plugin adds a QWebEngineView to a layout, and + // ~QObject deletes its child QWindows, viewport included. Nothing in Qt + // puts it back, and QWindowContainer then dereferences the window it no + // longer has on the next layout pass. // - // External handles to a Vulkan device-memory block (and its - // GL<->Vulkan sync semaphores) that GL imports as a memory - // object + semaphores. On Linux these are opaque file - // descriptors; on Windows they are Win32 HANDLEs. Stored as - // void* in the header to keep out of public Qt - // includes; the .cpp casts to HANDLE. - struct SharedImageInfo - { -#ifdef PLATFORM_WINDOWS - void* memoryHandle{nullptr}; // HANDLE; nullptr when unset - void* glReadySemaphoreHandle{nullptr}; - void* vkReadySemaphoreHandle{nullptr}; -#else - int memoryFd{-1}; // -1 when unset - int glReadySemaphoreFd{-1}; - int vkReadySemaphoreFd{-1}; -#endif - size_t size{0}; - int width{0}; // used sub-region width presented this frame - int height{0}; // used sub-region height presented this frame - int strideWidth{0}; // GL texture width = capacity rowPitch / 4 - int capacityHeight{0}; // allocated image height (>= height); GL texture height - - // The negotiated tiling this image was actually created with. GL - // must import with the matching GL_{OPTIMAL,LINEAR}_TILING_EXT: - // importing OPTIMAL-tiled memory as LINEAR yields an image whose - // large-scale structure survives but whose pixels are scrambled - // within each tile. - VkImageTiling tiling{VK_IMAGE_TILING_LINEAR}; - - // Whether the export used a dedicated allocation. GL must set - // GL_DEDICATED_MEMORY_OBJECT_EXT to exactly this before - // glTexStorageMem2DEXT; a mismatch corrupts the image. - bool dedicatedAllocation{false}; - }; - - // Number of frames the present path keeps in flight. Per-frame Vulkan - // sync objects and the GL<->Vulkan shared resources are stored in rings - // of this size and indexed by currentFrame(). 2 pipelines the present so - // a frame's GL work + submit can begin before the prior present retires; - // the throttle is FIFO acquire back-pressure + the start-of-frame fence - // wait (no per-frame end-of-frame block). - static constexpr uint32_t FRAMES_IN_FLIGHT = 2; - - // Index of the in-flight ring slot the next/current frame uses. The GL - // side (QTVulkanVideoDevice) reads this to pair its own ring objects with - // the Vulkan slot for the frame being rendered. - uint32_t currentFrame() const { return m_currentFrame; } - - const SharedImageInfo* getSharedImageInfo(int w, int h); - void presentSharedImage(); - - // CPU fallback API (not used when GPU interop is active) - void presentPixelData(const void* pixels, int w, int h); - + // QObject::destroyed is emitted at the top of ~QObject, before + // deleteChildren() runs, so watching the parent window gives us a + // moment where the viewport can still be detached and kept. // - // Probe for whether this machine's Vulkan can present a 10-bit format - // (A2B10G10R10 or A2R10G10B10). Used at RvDocument construction time to - // decide whether a 10-bit display request should route to the Vulkan - // path or fall back to OpenGL. Creates a throwaway QVulkanInstance + - // dummy surface and queries the advertised surface formats; it never - // throws; returns false if Vulkan is unavailable for any reason. + // This mirrors GLView. The one Vulkan-specific consequence is that the + // VkSurfaceKHR does not survive the platform window being recreated; + // VulkanWindow detects that on the next expose and rebuilds. // - static bool supports10BitPresentation(); - - public slots: - void eventProcessingTimeout(); + void watchParentWindow(); + void parentWindowDestroyed(); + void reattachVulkanWindow(); protected: - // Called once when the widget is first shown. - void initialize(); - - // Called each time a new frame should be rendered. - void render(); - - void showEvent(QShowEvent* event) override; - void resizeEvent(QResizeEvent* event) override; - void paintEvent(QPaintEvent* event) override; - - QPaintEngine* paintEngine() const override { return nullptr; } + void showEvent(QShowEvent*) override; private: - bool initVulkan(); - void cleanupVulkan(); - bool createSwapchain(); - void cleanupSwapchain(); - - // Probe the driver for an exportable shared-image configuration and - // resolve m_interopConfig. Runs exactly once per device, at device - // creation -- not per shared-image slot and not again on resize. - void negotiateInteropConfig(); - - // Emit the one-per-session startup record describing the negotiated - // configuration and the path taken. Unconditional: it must not be - // gated on ImageRenderer::debugGpu(), because Windows/NVIDIA is - // verified by QA against a build, and a log that needs a debug flag - // set in advance costs a whole verification round. - void emitPresentationRecord(); - - // Post a coalesced UpdateRequest: at most one render is queued at a time, - // so a burst of resize events collapses to a single render at the latest - // size instead of one heavy swapchain recreate per event. - void requestUpdate(); - RvDocument* m_doc; - std::unique_ptr m_videoDevice; - - bool m_initialized{false}; - bool m_firstPaintCompleted{false}; - bool m_postFirstNonEmptyRender; - bool m_stopProcessingEvents{false}; - bool m_userActive{true}; - bool m_updatePending{false}; - - QSize m_csize{1024, 576}; - QSize m_msize{128, 128}; - QWidget* m_eventWidget{nullptr}; - - unsigned int m_lastKey{0}; - QEvent::Type m_lastKeyType{QEvent::None}; - Timer m_activityTimer; - Timer m_activationTimer; - QTimer m_eventProcessingTimer; - - // Vulkan state - VkInstance m_vkInstance{VK_NULL_HANDLE}; - VkSurfaceKHR m_vkSurface{VK_NULL_HANDLE}; - VkPhysicalDevice m_vkPhysicalDevice{VK_NULL_HANDLE}; - VkDevice m_vkDevice{VK_NULL_HANDLE}; - VkQueue m_vkQueue{VK_NULL_HANDLE}; - uint32_t m_queueFamilyIndex{0}; - VkCommandPool m_vkCommandPool{VK_NULL_HANDLE}; - - VkSwapchainKHR m_vkSwapchain{VK_NULL_HANDLE}; - VkFormat m_vkSwapchainFormat{VK_FORMAT_UNDEFINED}; - VkExtent2D m_vkSwapchainExtent{}; - std::vector m_vkSwapchainImages; - std::vector m_vkCommandBuffers; - - // Per-in-flight-slot acquire semaphore + frame fence. - struct FrameSync - { - VkSemaphore imageAvailable{VK_NULL_HANDLE}; - VkFence fence{VK_NULL_HANDLE}; - }; - - // Per-in-flight-slot ring (indexed by m_currentFrame). - std::array m_frameSync{}; - uint32_t m_currentFrame{0}; - - // Per-swapchain-image (indexed by imageIndex, sized to the swapchain - // image count, (re)built in createSwapchain / freed in cleanupSwapchain). - // The present-wait semaphore MUST be tied to the image, not the frame: - // with 2 frames in flight the same image can be re-acquired while its - // prior present is still pending, and reusing a per-frame semaphore there - // trips the present-semaphore-reuse validation error. m_imagesInFlight - // records which frame fence currently owns each image so a re-acquired - // in-flight image is waited on before reuse. - std::vector m_vkRenderFinished; - std::vector m_imagesInFlight; + VulkanWindow* m_vulkanWindow; + QWidget* m_container; + QTVulkanVideoDevice* m_videoDevice; + QSize m_csize; + QSize m_msize; - // CPU-fallback staging buffer, ringed per in-flight slot: the frame maps - // and overwrites it before acquiring, so with the per-frame block removed - // it must not alias a buffer whose copy from a still-in-flight frame is - // pending. The slot's frame fence (waited at frame start) gates reuse. - struct StagingBuffer - { - VkBuffer buffer{VK_NULL_HANDLE}; - VkDeviceMemory memory{VK_NULL_HANDLE}; - size_t size{0}; - }; - - std::array m_staging{}; - - // Shared Image for GPU Interop, ringed per in-flight slot (indexed by - // m_currentFrame). SharedImageInfo's default member initializers give the - // correct unset state (FDs/handles = -1/nullptr), so value-initializing - // the array is safe. - struct SharedImage - { - VkImage image{VK_NULL_HANDLE}; - VkDeviceMemory memory{VK_NULL_HANDLE}; - VkSemaphore glReady{VK_NULL_HANDLE}; - VkSemaphore vkReady{VK_NULL_HANDLE}; - SharedImageInfo info; - - // Grow-only allocated capacity of the shared image. A resize within - // capacity reuses the existing allocation/export (no rebuild, no FD - // re-export, no GL re-import); the image is only reallocated when the - // request exceeds capacity, at which point capacity grows to the - // componentwise max of the request and the screen size (monotonic). - int capacityW{0}; - int capacityH{0}; - }; - - std::array m_shared{}; - - void cleanupSharedImage(uint32_t slot); - - // Rebalance a slot's glReady/vkReady binary-semaphore pair when a frame is - // aborted at acquire time. The GL side (syncBuffers) has already signaled - // glReady[slot] and waited vkReady[slot] before the acquire result is - // known; if the frame returns without its normal submit, this issues a - // minimal submit that waits glReady[slot] and signals vkReady[slot] so the - // pair cannot desync across the skipped frame. - void drainSharedSemaphores(uint32_t slot); - - // Recreate swapchain (and shared image) after OUT_OF_DATE / SUBOPTIMAL. - void handleSwapchainOutOfDate(); - - // Queue a one-shot switch to GLView; no-op during shutdown. - void requestGLFallback(); - - // False while closing or when the widget has no drawable size. - bool presentationAllowed() const; - - bool m_glFallbackRequested{false}; - - // Negotiated interop configuration and the state behind the startup - // record. m_interopNegotiated guards the once-per-device probe; - // m_recordEmitted guards the once-per-session record. - InteropConfig m_interopConfig; - bool m_interopNegotiated{false}; - bool m_recordEmitted{false}; - - PresentPath m_presentPath{PresentPath::Undetermined}; - std::string m_presentPathReason; - VkColorSpaceKHR m_vkSwapchainColorSpace{VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}; - - // What the GL side reported importing, so the record can show the two - // sides agreeing (or not) rather than only what Vulkan intended. - bool m_glImportReported{false}; - VkImageTiling m_glImportTiling{VK_IMAGE_TILING_LINEAR}; - bool m_glImportDedicated{false}; + // + // The parent QWindow whose destruction is being watched, plus the + // connection to it so it can be rewired when the viewport window is + // re-parented. See watchParentWindow(). + // + QWindow* m_watchedParentWindow; + QMetaObject::Connection m_watchedParentConnection; + bool m_reattachPending; }; } // namespace Rv diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h new file mode 100644 index 000000000..2189cf47b --- /dev/null +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -0,0 +1,294 @@ +// +// Copyright (c) 2026 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +#pragma once + +#include +#include +#include +#include + +#include + +#include +#include + +QT_BEGIN_NAMESPACE +class QPlatformWindow; +QT_END_NAMESPACE + +namespace Rv +{ + class RvDocument; + class QTVulkanVideoDevice; + + // + // VulkanWindow + // + // The RV viewport rendered as a *native* Vulkan surface, presenting 10-bit + // images on Linux and Windows. All IPCore image processing runs in OpenGL + // via a separate QOpenGLContext+QOffscreenSurface (see + // QTVulkanVideoDevice); the Vulkan path is used only for final 10-bit + // pixel delivery, to avoid the 8-bit truncation the OpenGL+Qt path is + // subject to on both platforms. + // + // It is a QWindow rather than a native-attribute QWidget, and it is + // embedded in the widget hierarchy by VulkanView via + // QWidget::createWindowContainer() -- the same shape GLWindow/GLView use. + // Keeping every viewport backend on that one pattern is what keeps the + // top-level QMainWindow off a render-to-texture composite path, which is + // where the ~90 ms full-window present came from. + // + class VulkanWindow : public QWindow + { + Q_OBJECT + + public: + typedef TwkUtil::Timer Timer; + + explicit VulkanWindow(RvDocument* doc, bool noResize = true); + ~VulkanWindow(); + + QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } + + // The device is created and owned by the hosting VulkanView (it needs + // the container QWidget for event/coordinate translation), then handed + // here. VulkanWindow does not take ownership. + void setVideoDevice(QTVulkanVideoDevice* d) { m_videoDevice = d; } + + // The container QWidget the viewport is embedded in; used for focus + // and for the popup-focus check in render(). + void setEventWidget(QWidget* widget) { m_eventWidget = widget; } + + void stopProcessingEvents(); + + bool event(QEvent* event) override; + + bool firstPaintCompleted() const { return m_firstPaintCompleted; } + + void absolutePosition(int& x, int& y) const; + + float devicePixelRatioF() const; + + // Format of the active swapchain image: VK_FORMAT_A2B10G10R10_UNORM_PACK32 + // or VK_FORMAT_A2R10G10B10_UNORM_PACK32 (the two differ in R/B order). + // VK_FORMAT_UNDEFINED before the swapchain is created. + VkFormat swapchainFormat() const { return m_vkSwapchainFormat; } + + // + // Vulkan presentation — called by QTVulkanVideoDevice::syncBuffers(). + // + + // GPU Interop API + // + // External handles to a Vulkan device-memory block (and its + // GL<->Vulkan sync semaphores) that GL imports as a memory + // object + semaphores. On Linux these are opaque file + // descriptors; on Windows they are Win32 HANDLEs. Stored as + // void* in the header to keep out of public Qt + // includes; the .cpp casts to HANDLE. + struct SharedImageInfo + { +#ifdef PLATFORM_WINDOWS + void* memoryHandle{nullptr}; // HANDLE; nullptr when unset + void* glReadySemaphoreHandle{nullptr}; + void* vkReadySemaphoreHandle{nullptr}; +#else + int memoryFd{-1}; // -1 when unset + int glReadySemaphoreFd{-1}; + int vkReadySemaphoreFd{-1}; +#endif + size_t size{0}; + int width{0}; // used sub-region width presented this frame + int height{0}; // used sub-region height presented this frame + int strideWidth{0}; // GL texture width = capacity rowPitch / 4 + int capacityHeight{0}; // allocated image height (>= height); GL texture height + // Non-zero when the shared image uses VK_IMAGE_TILING_OPTIMAL (the + // default on NVIDIA, which avoids the blank large-image bug); GL + // must then import with GL_OPTIMAL_TILING_EXT instead of + // GL_LINEAR_TILING_EXT. + int optimalTiling{0}; + // Non-zero when the exported memory is a dedicated allocation + // (VkMemoryDedicatedAllocateInfo), which the driver may require for + // an image created with an external handle type. EXT_memory_object + // requires the two sides to agree, so GL must set + // GL_DEDICATED_MEMORY_OBJECT_EXT exactly when this is set. + int dedicated{0}; + }; + + // Number of frames the present path keeps in flight. Per-frame Vulkan + // sync objects and the GL<->Vulkan shared resources are stored in rings + // of this size and indexed by currentFrame(). 2 pipelines the present so + // a frame's GL work + submit can begin before the prior present retires; + // the throttle is FIFO acquire back-pressure + the start-of-frame fence + // wait (no per-frame end-of-frame block). + static constexpr uint32_t FRAMES_IN_FLIGHT = 2; + + // Index of the in-flight ring slot the next/current frame uses. The GL + // side (QTVulkanVideoDevice) reads this to pair its own ring objects with + // the Vulkan slot for the frame being rendered. + uint32_t currentFrame() const { return m_currentFrame; } + + const SharedImageInfo* getSharedImageInfo(int w, int h); + void presentSharedImage(); + + // CPU fallback API (not used when GPU interop is active) + void presentPixelData(const void* pixels, int w, int h); + + bool isInitialized() const { return m_initialized; } + + // + // Probe for whether this machine's Vulkan can present a 10-bit format + // (A2B10G10R10 or A2R10G10B10). Used at RvDocument construction time to + // decide whether a 10-bit display request should route to the Vulkan + // path or fall back to OpenGL. Creates a throwaway QVulkanInstance + + // dummy surface and queries the advertised surface formats; it never + // throws — returns false if Vulkan is unavailable for any reason. + // + static bool supports10BitPresentation(); + + public slots: + void eventProcessingTimeout(); + + protected: + // Called once when the surface is first exposed. + void initialize(); + + // Called each time a new frame should be rendered. + void render(); + + void exposeEvent(QExposeEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + + private: + bool initVulkan(); + void cleanupVulkan(); + bool createSwapchain(); + void cleanupSwapchain(); + + RvDocument* m_doc; + QTVulkanVideoDevice* m_videoDevice; + + bool m_initialized; + + // The QPlatformWindow the current VkSurfaceKHR was created against. + // Compared in exposeEvent() to detect that Qt replaced the platform + // window under us; see handleSurfaceLost(). + const QPlatformWindow* m_initializedHandle{nullptr}; + + bool m_firstPaintCompleted; + bool m_postFirstNonEmptyRender; + bool m_stopProcessingEvents; + bool m_userActive; + + QWidget* m_eventWidget; + + unsigned int m_lastKey; + QEvent::Type m_lastKeyType; + Timer m_activityTimer; + Timer m_activationTimer; + QTimer m_eventProcessingTimer; + + // Vulkan state + VkInstance m_vkInstance{VK_NULL_HANDLE}; + VkSurfaceKHR m_vkSurface{VK_NULL_HANDLE}; + VkPhysicalDevice m_vkPhysicalDevice{VK_NULL_HANDLE}; + VkDevice m_vkDevice{VK_NULL_HANDLE}; + VkQueue m_vkQueue{VK_NULL_HANDLE}; + uint32_t m_queueFamilyIndex{0}; + // Last (format, colorSpace) pair reported by createSwapchain(). + // createSwapchain() runs on every resize step, so the choice is logged + // only when it actually changes. Default-initialized to + // VK_FORMAT_UNDEFINED, which no accepted format equals, so the first + // swapchain always reports. + VkSurfaceFormatKHR m_loggedSurfaceFormat{}; + + // Whether the surface's full format list has been dumped for this + // window yet. Once per window, not once per swapchain recreate. + bool m_loggedSurfaceFormatList{false}; + + VkCommandPool m_vkCommandPool{VK_NULL_HANDLE}; + + VkSwapchainKHR m_vkSwapchain{VK_NULL_HANDLE}; + VkFormat m_vkSwapchainFormat{VK_FORMAT_UNDEFINED}; + VkExtent2D m_vkSwapchainExtent{}; + std::vector m_vkSwapchainImages; + std::vector m_vkCommandBuffers; + + // Per-in-flight-slot ring (indexed by m_currentFrame). + std::array m_vkImageAvailableSemaphore{}; + std::array m_vkFence{}; + uint32_t m_currentFrame{0}; + + // Per-swapchain-image (indexed by imageIndex, sized to the swapchain + // image count, (re)built in createSwapchain / freed in cleanupSwapchain). + // The present-wait semaphore MUST be tied to the image, not the frame: + // with 2 frames in flight the same image can be re-acquired while its + // prior present is still pending, and reusing a per-frame semaphore there + // trips the present-semaphore-reuse validation error. m_imagesInFlight + // records which frame fence currently owns each image so a re-acquired + // in-flight image is waited on before reuse. + std::vector m_vkRenderFinished; + std::vector m_imagesInFlight; + + // CPU-fallback staging buffer, ringed per in-flight slot: the frame maps + // and overwrites it before acquiring, so with the per-frame block removed + // it must not alias a buffer whose copy from a still-in-flight frame is + // pending. The slot's frame fence (waited at frame start) gates reuse. + std::array m_vkStagingBuffer{}; + std::array m_vkStagingBufferMemory{}; + std::array m_stagingBufferSize{}; + + // Shared Image for GPU Interop, ringed per in-flight slot (indexed by + // m_currentFrame). SharedImageInfo's default member initializers give the + // correct unset state (FDs/handles = -1/nullptr), so value-initializing + // the array is safe. + std::array m_vkSharedImage{}; + std::array m_vkSharedImageMemory{}; + std::array m_vkGlReadySemaphore{}; + std::array m_vkVkReadySemaphore{}; + std::array m_sharedImageInfo{}; + + // Grow-only allocated capacity of each slot's shared image. A resize + // within capacity reuses the existing allocation/export (no rebuild, no + // FD re-export, no GL re-import); the image is only reallocated when the + // request exceeds capacity, at which point capacity grows to the + // componentwise max of the request and the screen size (monotonic). + std::array m_sharedCapacityW{}; + std::array m_sharedCapacityH{}; + + void cleanupSharedImage(uint32_t slot); + + // Rebalance a slot's glReady/vkReady binary-semaphore pair when a frame is + // aborted at acquire time. The GL side (syncBuffers) has already signaled + // glReady[slot] and waited vkReady[slot] before the acquire result is + // known; if the frame returns without its normal submit, this issues a + // minimal submit that waits glReady[slot] and signals vkReady[slot] so the + // pair cannot desync across the skipped frame. + void drainSharedSemaphores(uint32_t slot); + + // Recreate swapchain (and shared image) after OUT_OF_DATE / SUBOPTIMAL. + void handleSwapchainOutOfDate(); + + // Tear down and re-initialize after Qt destroyed and recreated the + // platform window, which invalidates the VkSurfaceKHR. + void handleSurfaceLost(); + + // Destroy every Vulkan object this window owns and return it to the + // pre-initialize() state. MUST run while the platform window (and hence + // the VkSurfaceKHR) is still alive -- see the QEvent::PlatformSurface + // handler in event(). + void releaseVulkanResources(); + + // Queue a one-shot switch to GLView; no-op during shutdown. + void requestGLFallback(); + + // False while closing or when the widget has no drawable size. + bool presentationAllowed() const; + + bool m_glFallbackRequested{false}; + }; + +} // namespace Rv diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 74dd90f6a..44048ba37 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -159,6 +159,10 @@ namespace Rv , m_hdpiResizeWorkaroundDone(false) , m_oldGLView(0) , m_glView(0) + , m_viewWidget(nullptr) +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + , m_vulkanView(nullptr) +#endif , m_diagnosticsView(nullptr) , m_diagnosticsDock(nullptr) , m_sourceEditor(0) @@ -253,12 +257,9 @@ namespace Rv m_vulkanView->setAcceptDrops(true); m_vulkanView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); m_vulkanView->resize(m_vulkanView->sizeHint()); - m_vulkanView->setEventWidget(m_vulkanView); m_viewWidget = m_vulkanView; m_vulkanView->videoDevice()->makeCurrent(); - - initializeSession(); } else { @@ -272,7 +273,7 @@ namespace Rv else { RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = static_cast(s->opaquePointer()); + RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); // The front document may be on the Vulkan/Metal path, where view() // is null; share its GL context only if it has one (mirrors the // first-window case above, which passes a null share context). @@ -380,11 +381,13 @@ namespace Rv // input and dims the UI. // // It is a frameless top-level window (owned by this document) rather - // than a child widget. The viewport is a native QOpenGLWindow, which - // renders above any sibling raster child widget regardless of - // raise()/stacking order, so a child overlay could never dim or block - // the viewport. A top-level window sits above the main window and its - // native child, so it covers the viewport too. + // than a child widget. The viewport is a native window on both + // backends -- a QOpenGLWindow for GLView, a Vulkan surface for + // VulkanView -- and a native window renders above any sibling raster + // child widget regardless of raise()/stacking order, so a child + // overlay could never dim or block the viewport. A top-level window + // sits above the main window and its native child, so it covers the + // viewport too. // m_blockingOverlay = new QWidget(this, Qt::FramelessWindowHint | Qt::Tool); m_blockingOverlay->setObjectName("UIBlockingOverlay"); @@ -420,29 +423,31 @@ namespace Rv void RvDocument::initializeSession() { // - // On the OpenGL path this is called by - // RvApplication::newSessionFromFiles() once the document has been - // shown, so the viewport window exists and its GL context has been - // created. Constructing an RvSession queries + // Called by RvApplication::newSessionFromFiles() once the document has + // been shown, so the viewport window exists and its GL context has + // been created. Constructing an RvSession queries // GL_SHADING_LANGUAGE_VERSION and aborts without a current context, so // make the viewport context current first. // - // On the Vulkan/Metal presentation path there is no GLView. Those - // views make their own GL context current and call in here themselves - // once initialized, so the guard is on having *a* view, not on having - // a GLView, and makeCurrent() is skipped when GLView is absent. + // Deliberately NOT driven from a view callback (GLWindow::initializeGL, + // VulkanWindow::initialize): loading packages creates web panels, and + // adding a QWebEngineView makes Qt tear down the main window's native + // subtree -- destroying the viewport while that callback is still on + // the stack. // - if (!m_viewWidget) + // Backend-neutral: on the Vulkan path m_glView is null, and the + // context to make current is the presentation device's offscreen one. + // + TwkGLF::GLVideoDevice* viewDevice = viewVideoDevice(); + + if (!viewDevice) { return; } if (!m_session) { - if (m_glView) - { - m_glView->makeCurrent(); - } + viewDevice->makeCurrent(); m_session = new RvSession; // m_session->setFrameBuffer(fb); @@ -946,7 +951,7 @@ namespace Rv else { RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = static_cast(s->opaquePointer()); + RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; newGLView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, true, opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, @@ -987,16 +992,26 @@ namespace Rv if (DesktopVideoModule* m = RvApp()->desktopVideoModule()) { - for (TwkApp::VideoDevice* device : m->devices()) + const TwkApp::VideoModule::VideoDevices& devices = m->devices(); + + for (size_t i = 0; i < devices.size(); i++) { - if (DesktopVideoDevice* desktopDevice = dynamic_cast(device)) + if (DesktopVideoDevice* d = dynamic_cast(devices[i])) { - desktopDevice->setShareDevice(m_glView->videoDevice()); + d->setShareDevice(m_glView->videoDevice()); } } } - delete oldVulkanView; + // + // Defer the delete. This is reached from a queued callback posted by + // the VulkanView itself (VulkanView::requestGLFallback), which can be + // raised from deep inside the present path, and destroying the view + // tears down the Vulkan device and its in-flight frames. deleteLater() + // guarantees the destructor runs with no VulkanView frame on the stack + // and with its posted events already discarded. + // + oldVulkanView->deleteLater(); newGLView->videoDevice()->makeCurrent(); newGLView->update(); @@ -1289,6 +1304,10 @@ namespace Rv return m_glView ? m_glView->videoDevice() : nullptr; } +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + VulkanView* RvDocument::vulkanView() const { return m_vulkanView; } +#endif + void RvDocument::center() { QScreen* screen = QApplication::screenAt(mapToGlobal(QPoint(0, 0))); diff --git a/src/lib/app/RvCommon/VulkanBuildProbe.cpp b/src/lib/app/RvCommon/VulkanBuildProbe.cpp new file mode 100644 index 000000000..34fc63100 --- /dev/null +++ b/src/lib/app/RvCommon/VulkanBuildProbe.cpp @@ -0,0 +1,22 @@ +// +// Copyright (C) 2026 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) +#include +#endif + +namespace +{ + // Build-time probe only: keep one direct Vulkan symbol reference so opt-in + // Linux/Windows builds prove both headers and link-time loader availability. + void rvVulkanBuildProbeNoOp() + { + PFN_vkVoidFunction fn = vkGetInstanceProcAddr(VK_NULL_HANDLE, "vkCreateInstance"); + (void)fn; + } +} // namespace diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index 81e91971b..36e1fb883 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -7,2677 +7,297 @@ #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) #include +#include #include #include -#include #include #include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include +#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#ifdef PLATFORM_WINDOWS -// WIN32_LEAN_AND_MEAN prevents from including the legacy -// , which otherwise collides with the already -// pulled in transitively by Qt headers above. -#ifndef WIN32_LEAN_AND_MEAN -#define WIN32_LEAN_AND_MEAN -#endif -#include -#else -#include -#endif - -// -// Environment variables recognized by the Vulkan presentation path -// ---------------------------------------------------------------- -// These exist so a corrupted or failing display can be narrowed to a stage -// without a rebuild -- notably by a tester running a build on hardware the -// developer cannot access. Every override is reported in the startup record -// (see VulkanView::emitPresentationRecord), alongside the value negotiation -// would otherwise have chosen. -// -// RV_VULKAN_FORCE_CPU_PRESENT -// Set (to any value) to skip GL<->Vulkan zero-copy interop entirely and -// present via the CPU readback path. Still 10-bit, just slower. -// -// RV_VULKAN_FORCE_TILING = optimal | linear -// Override the negotiated shared-image tiling. The override is honored -// only if the driver reports that tiling as exportable; otherwise it is -// logged and refused, because presenting through a configuration whose -// correctness was not established is what this path is meant to avoid. -// An unrecognized value is logged and ignored (negotiation proceeds). -// -// RV_VULKAN_FORCE_NO_DEDICATED -// Set (to any value) to suppress dedicated allocation even when the -// driver reports it as preferred. Refused when the driver reports -// DEDICATED_ONLY, since that is a requirement rather than a preference. -// -// Both sides of the interop read their settings from one negotiated struct, -// so an override applies to the Vulkan export and the GL import together. -// namespace Rv { using namespace std; - using namespace TwkApp; - using namespace IPCore; - - namespace - { - // Read an env var that is treated as a boolean flag by presence. - bool envFlagSet(const char* name) { return getenv(name) != nullptr; } - - constexpr std::string_view tilingName(VkImageTiling tiling) - { - switch (tiling) - { - case VK_IMAGE_TILING_OPTIMAL: - return "OPTIMAL"; - case VK_IMAGE_TILING_LINEAR: - return "LINEAR"; - default: - return "(other)"; - } - } - - constexpr std::string_view colorSpaceName(VkColorSpaceKHR colorSpace) - { - switch (colorSpace) - { - case VK_COLOR_SPACE_SRGB_NONLINEAR_KHR: - return "SRGB_NONLINEAR"; - case VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT: - return "EXTENDED_SRGB_LINEAR"; - case VK_COLOR_SPACE_EXTENDED_SRGB_NONLINEAR_EXT: - return "EXTENDED_SRGB_NONLINEAR"; - case VK_COLOR_SPACE_HDR10_ST2084_EXT: - return "HDR10_ST2084"; - case VK_COLOR_SPACE_HDR10_HLG_EXT: - return "HDR10_HLG"; - case VK_COLOR_SPACE_BT2020_LINEAR_EXT: - return "BT2020_LINEAR"; - case VK_COLOR_SPACE_DISPLAY_P3_NONLINEAR_EXT: - return "DISPLAY_P3_NONLINEAR"; - case VK_COLOR_SPACE_PASS_THROUGH_EXT: - return "PASS_THROUGH"; - default: - return "(other)"; - } - } - - // Decode RV_VULKAN_FORCE_TILING. Returns nullopt when unset or when the - // value is not recognized; an unrecognized value is reported rather - // than silently behaving as if the variable were unset. - std::optional forcedTilingRequested() - { - const char* value = getenv("RV_VULKAN_FORCE_TILING"); - if (!value) - { - return std::nullopt; - } - - std::string lowered(value); - std::transform(lowered.begin(), lowered.end(), lowered.begin(), - [](unsigned char ch) { return static_cast(::tolower(ch)); }); - - if (lowered == "optimal") - { - return VK_IMAGE_TILING_OPTIMAL; - } - if (lowered == "linear") - { - return VK_IMAGE_TILING_LINEAR; - } - - cout << "WARNING: VulkanView: RV_VULKAN_FORCE_TILING='" << value << "' is not recognized (expected 'optimal' or 'linear'); " - << "ignoring it and using the negotiated tiling" << endl; - return std::nullopt; - } - - // Both A2B10G10R10 and A2R10G10B10 are 10-bit-per-channel packed formats; - // they differ only in R/B component order. Both are acceptable for 10-bit - // presentation -- the R/B order is handled where pixels are packed (CPU - // fallback) or blitted (GPU interop). A2B10G10R10 (== GL_RGB10_A2) is - // preferred when the surface offers it, but many Linux/RADV surfaces only - // advertise A2R10G10B10. - bool isTenBitFormat(VkFormat format) - { - return format == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || format == VK_FORMAT_A2R10G10B10_UNORM_PACK32; - } - - constexpr std::string_view formatName(VkFormat format) - { - switch (format) - { - case VK_FORMAT_B8G8R8A8_UNORM: - return "B8G8R8A8_UNORM"; - case VK_FORMAT_B8G8R8A8_SRGB: - return "B8G8R8A8_SRGB"; - case VK_FORMAT_R8G8B8A8_UNORM: - return "R8G8B8A8_UNORM"; - case VK_FORMAT_R8G8B8A8_SRGB: - return "R8G8B8A8_SRGB"; - case VK_FORMAT_A2B10G10R10_UNORM_PACK32: - return "A2B10G10R10_UNORM_PACK32"; - case VK_FORMAT_A2R10G10B10_UNORM_PACK32: - return "A2R10G10B10_UNORM_PACK32"; - case VK_FORMAT_R16G16B16A16_SFLOAT: - return "R16G16B16A16_SFLOAT"; - default: - return "(other)"; - } - } - - std::optional findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) - { - VkPhysicalDeviceMemoryProperties memProperties; - vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties); - for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) - { - if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) - { - return i; - } - } - return std::nullopt; - } - - // Resolve a device-level Vulkan entry point as its PFN type. - template Fn deviceProc(VkDevice device, const char* name) - { - return reinterpret_cast(vkGetDeviceProcAddr(device, name)); - } - - // Record a single-image layout transition (color aspect, 1 mip, 1 layer, - // no queue family ownership transfer). - void imageBarrier(VkCommandBuffer commandBuffer, VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout, - VkAccessFlags srcAccess, VkAccessFlags dstAccess, VkPipelineStageFlags srcStage, VkPipelineStageFlags dstStage) - { - VkImageMemoryBarrier barrier = {}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barrier.oldLayout = oldLayout; - barrier.newLayout = newLayout; - barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.image = image; - barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - barrier.subresourceRange.baseMipLevel = 0; - barrier.subresourceRange.levelCount = 1; - barrier.subresourceRange.baseArrayLayer = 0; - barrier.subresourceRange.layerCount = 1; - barrier.srcAccessMask = srcAccess; - barrier.dstAccessMask = dstAccess; - - vkCmdPipelineBarrier(commandBuffer, srcStage, dstStage, 0, 0, nullptr, 0, nullptr, 1, &barrier); - } - } // namespace - - //-------------------------------------------------------------------------- - // VulkanView implementation - //-------------------------------------------------------------------------- VulkanView::VulkanView(RvDocument* doc, QWidget* parent, bool noResize) : QWidget(parent) , m_doc(doc) - , m_postFirstNonEmptyRender(noResize) + , m_vulkanWindow(nullptr) + , m_container(nullptr) + , m_videoDevice(nullptr) + , m_csize(1024, 576) + , m_msize(128, 128) + , m_watchedParentWindow(nullptr) + , m_reattachPending(false) { - // Force the creation of a native window early. - setAttribute(Qt::WA_NativeWindow); - setAttribute(Qt::WA_NoSystemBackground); - setAttribute(Qt::WA_OpaquePaintEvent); - setAttribute(Qt::WA_PaintOnScreen); - setAttribute(Qt::WA_TranslucentBackground); - setAutoFillBackground(false); + // + // Native Vulkan viewport window (renders + presents on its own + // surface). + // + m_vulkanWindow = new VulkanWindow(doc, noResize); - // Wait to configure the QWindow until it's created - if (QWindow* window = windowHandle()) - { - window->setSurfaceType(QSurface::VulkanSurface); + // + // Embed the native window in the widget tree. + // + m_container = QWidget::createWindowContainer(m_vulkanWindow, this); + m_container->setFocusPolicy(Qt::StrongFocus); - // Set 10-bit format - QSurfaceFormat fmt; - fmt.setRedBufferSize(10); - fmt.setGreenBufferSize(10); - fmt.setBlueBufferSize(10); - fmt.setAlphaBufferSize(2); - window->setFormat(fmt); - } + // + // Create the platform surface up-front: Qt can only hand out a + // VkSurfaceKHR for a window that has one, and RV queries the + // presentation device during startup before the window is shown. + // + m_vulkanWindow->create(); + + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(m_container); + + // + // Last-resort guard: if the viewport window is destroyed anyway (i.e. + // detaching it in parentWindowDestroyed() did not get there first), + // make sure nothing here is left holding it. + // + connect(m_vulkanWindow, &QObject::destroyed, this, [this]() { m_vulkanWindow = nullptr; }); + // + // The device drives the Vulkan surface (the window) for presentation, + // and uses the container QWidget for event / coordinate translation + // (height-based y-flip, mapToGlobal, mouse grab). + // ostringstream str; str << UI_APPLICATION_NAME " Main Window (Vulkan)" << "/" << m_doc; - m_videoDevice = std::make_unique(nullptr, str.str(), this, nullptr); + m_videoDevice = new QTVulkanVideoDevice(nullptr, str.str(), m_vulkanWindow, m_container); + m_vulkanWindow->setVideoDevice(m_videoDevice); + m_vulkanWindow->setEventWidget(m_container); - m_activityTimer.start(); + setObjectName((m_doc && m_doc->session()) ? m_doc->session()->name().c_str() : "no session"); + setFocusProxy(m_container); - m_eventProcessingTimer.setSingleShot(true); - connect(&m_eventProcessingTimer, SIGNAL(timeout()), this, SLOT(eventProcessingTimeout())); + // + // Realize the top-level's window now, and watch for Qt replacing it. + // + // Unlike GLView this does not call createWinId() on the top level: + // that exists there to pin the window's composition to OpenGL before + // any render-to-texture widget joins the tree, and there is no such + // API to pin here -- the viewport presents through Vulkan on its own + // surface and composites with nothing. + // + watchParentWindow(); } VulkanView::~VulkanView() { - // Release the device (and its GL imports) before the Vulkan memory they alias. - m_videoDevice.reset(); - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + // + // Two things have to be undone before the device goes away, both of + // them consequences of the viewport window outliving the widget tree + // in the detached state (see parentWindowDestroyed()). + // + // The window holds a raw back-pointer to the device and would keep + // using it -- VulkanWindow::event() and render() both dereference it + // -- so clear that first. And while detached the container has no + // parent widget, so it would not be destroyed along with this widget: + // it would survive as a stray top-level owning the viewport window, + // still pointing at a deleted device. + // + if (m_vulkanWindow) { - cleanupSharedImage(i); + m_vulkanWindow->setVideoDevice(nullptr); + m_vulkanWindow->setEventWidget(nullptr); } - cleanupSwapchain(); - cleanupVulkan(); - } - - //-------------------------------------------------------------------------- - void VulkanView::setEventWidget(QWidget* widget) - { - m_eventWidget = widget; - if (m_videoDevice) + if (m_container && !m_container->parentWidget()) { - m_videoDevice->setEventWidget(widget); + delete m_container; + m_container = nullptr; } - } - void VulkanView::stopProcessingEvents() { m_stopProcessingEvents = true; } - - void VulkanView::absolutePosition(int& x, int& y) const - { - QPoint gp = mapToGlobal(QPoint(0, 0)); - x = gp.x(); - y = gp.y(); + delete m_videoDevice; } - float VulkanView::devicePixelRatio() const { return static_cast(devicePixelRatioF()); } - - //-------------------------------------------------------------------------- - // Vulkan Initialisation - //-------------------------------------------------------------------------- - - void VulkanView::initialize() + void VulkanView::showEvent(QShowEvent* event) { - if (m_initialized) - { - return; - } - - if (QWindow* window = windowHandle()) - { - window->setSurfaceType(QSurface::VulkanSurface); - QSurfaceFormat fmt; - fmt.setRedBufferSize(10); - fmt.setGreenBufferSize(10); - fmt.setBlueBufferSize(10); - fmt.setAlphaBufferSize(2); - window->setFormat(fmt); - } - - if (!initVulkan()) - { - cerr << "ERROR: VulkanView: initVulkan failed; falling back to OpenGL" << endl; - requestGLFallback(); - return; - } - - m_initialized = true; + QWidget::showEvent(event); - if (m_doc) - { - m_doc->initializeSession(); - } + // + // The container parents the viewport window to the top-level window + // while being shown, so the parent to watch only becomes known here -- + // and one turn of the event loop later, since the container's own show + // is nested inside this one. + // + watchParentWindow(); + QTimer::singleShot(0, this, &VulkanView::watchParentWindow); } - bool VulkanView::supports10BitPresentation() + void VulkanView::watchParentWindow() { - QVulkanInstance qtVkInst; - if (!qtVkInst.create()) - { - cerr << "ERROR: VulkanView: supports10BitPresentation: QVulkanInstance create failed" << endl; - return false; - } - - VkInstance instance = qtVkInst.vkInstance(); - if (instance == VK_NULL_HANDLE) - { - return false; - } - - QWindow dummyWindow; - dummyWindow.setSurfaceType(QSurface::VulkanSurface); - dummyWindow.create(); - dummyWindow.setVulkanInstance(&qtVkInst); - - VkSurfaceKHR dummySurface = qtVkInst.surfaceForWindow(&dummyWindow); - if (!dummySurface) - { - cerr << "ERROR: VulkanView: supports10BitPresentation: failed to create dummy surface" << endl; - return false; - } - - uint32_t deviceCount = 0; - vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); - if (deviceCount == 0) - { - cerr << "ERROR: VulkanView: supports10BitPresentation: vkEnumeratePhysicalDevices returned 0 devices" << endl; - return false; - } - std::vector devices(deviceCount); - vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data()); - - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: supports10BitPresentation: probing " << deviceCount << " physical device(s)" << endl; - } - - bool any10bit = false; - for (uint32_t di = 0; di < devices.size(); ++di) - { - VkPhysicalDevice dev = devices[di]; + // + // Watch the top-level widget's window rather than the viewport + // window's current parent: it is the object Qt destroys, and it is + // knowable before the container gets around to re-parenting the + // viewport into it. + // + QWidget* topLevel = window(); + QWindow* topLevelWindow = topLevel ? topLevel->windowHandle() : nullptr; - uint32_t formatCount = 0; - if (vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) - { - continue; - } - std::vector formats(formatCount); - vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, formats.data()); + if (topLevelWindow == m_watchedParentWindow) + return; - bool has10bit = false; - for (const auto& fmt : formats) - { - if (isTenBitFormat(fmt.format)) - { - has10bit = true; - any10bit = true; - break; - } - } + if (m_watchedParentConnection) + disconnect(m_watchedParentConnection); - VkPhysicalDeviceProperties props = {}; - vkGetPhysicalDeviceProperties(dev, &props); - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: device[" << di << "] '" << props.deviceName - << "': 10-bit surface format=" << (has10bit ? "YES" : "NO") << endl; - } - } + m_watchedParentWindow = topLevelWindow; - // The surface returned by surfaceForWindow() is owned by the platform - // integration and is released when dummyWindow is destroyed on return; - // QVulkanInstance has no destroySurface() in this Qt version. - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: supports10BitPresentation: returning " << (any10bit ? "true" : "false") << endl; - } - return any10bit; + if (topLevelWindow) + m_watchedParentConnection = connect(topLevelWindow, &QObject::destroyed, this, &VulkanView::parentWindowDestroyed); } - bool VulkanView::initVulkan() + void VulkanView::parentWindowDestroyed() { - // The VkInstance is created and owned by Qt. - static QVulkanInstance* qtVkInst = nullptr; - if (!qtVkInst) - { - // Ask Qt for a 1.1 instance: the interop capability probe uses - // vkGetPhysicalDeviceImageFormatProperties2, which is core in 1.1. - // Without it negotiateInteropConfig() cannot establish whether a - // configuration is exportable and has to refuse interop outright. - // A 1.0-only loader is still tolerated -- retry unversioned and let - // the probe fall back to the KHR alias, or degrade if that is - // absent too. - qtVkInst = new QVulkanInstance(); - qtVkInst->setApiVersion(QVersionNumber(1, 1)); - if (!qtVkInst->create()) - { - cout << "WARNING: VulkanView: QVulkanInstance create failed at apiVersion 1.1; retrying with the loader default" << endl; - delete qtVkInst; - qtVkInst = new QVulkanInstance(); - if (!qtVkInst->create()) - { - cerr << "ERROR: VulkanView: QVulkanInstance create failed" << endl; - delete qtVkInst; - qtVkInst = nullptr; - return false; - } - } - } - - m_vkInstance = qtVkInst->vkInstance(); - - // Create Surface - QWindow* window = windowHandle(); - if (!window) - { - return false; - } - - window->setVulkanInstance(qtVkInst); - - m_vkSurface = qtVkInst->surfaceForWindow(window); - if (!m_vkSurface) - { - cerr << "ERROR: VulkanView: Failed to create Vulkan surface" << endl; - return false; - } - - // Pick Physical Device - uint32_t deviceCount = 0; - vkEnumeratePhysicalDevices(m_vkInstance, &deviceCount, nullptr); - if (deviceCount == 0) - { - return false; - } - std::vector devices(deviceCount); - vkEnumeratePhysicalDevices(m_vkInstance, &deviceCount, devices.data()); - - m_vkPhysicalDevice = VK_NULL_HANDLE; - bool foundQueue = false; - m_queueFamilyIndex = 0; - - for (VkPhysicalDevice dev : devices) - { - uint32_t queueFamilyCount = 0; - vkGetPhysicalDeviceQueueFamilyProperties(dev, &queueFamilyCount, nullptr); - std::vector queueFamilies(queueFamilyCount); - vkGetPhysicalDeviceQueueFamilyProperties(dev, &queueFamilyCount, queueFamilies.data()); - - for (uint32_t i = 0; i < queueFamilyCount; i++) - { - VkBool32 presentSupport = false; - vkGetPhysicalDeviceSurfaceSupportKHR(dev, i, m_vkSurface, &presentSupport); - if ((queueFamilies[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) - { - m_vkPhysicalDevice = dev; - m_queueFamilyIndex = i; - foundQueue = true; - break; - } - } - if (foundQueue) - { - break; - } - } - - if (!foundQueue) - { - cerr << "ERROR: VulkanView: initVulkan: No physical device with graphics and present support found." << endl; - return false; - } - - { - VkPhysicalDeviceProperties props = {}; - vkGetPhysicalDeviceProperties(m_vkPhysicalDevice, &props); - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: initVulkan: picked physical device '" << props.deviceName << "' (of " << deviceCount - << " available)" << endl; - } - } - - // Create Logical Device - float queuePriority = 1.0f; - VkDeviceQueueCreateInfo queueCreateInfo = {}; - queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; - queueCreateInfo.queueFamilyIndex = m_queueFamilyIndex; - queueCreateInfo.queueCount = 1; - queueCreateInfo.pQueuePriorities = &queuePriority; - - std::vector deviceExtensions = { - VK_KHR_SWAPCHAIN_EXTENSION_NAME, -#ifdef PLATFORM_WINDOWS - VK_KHR_EXTERNAL_MEMORY_WIN32_EXTENSION_NAME, - VK_KHR_EXTERNAL_SEMAPHORE_WIN32_EXTENSION_NAME, -#else - VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME, - VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME, -#endif - }; - - VkDeviceCreateInfo createInfo = {}; - createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; - createInfo.pQueueCreateInfos = &queueCreateInfo; - createInfo.queueCreateInfoCount = 1; - createInfo.enabledExtensionCount = static_cast(deviceExtensions.size()); - createInfo.ppEnabledExtensionNames = deviceExtensions.data(); - - if (vkCreateDevice(m_vkPhysicalDevice, &createInfo, nullptr, &m_vkDevice) != VK_SUCCESS) - { - return false; - } - - vkGetDeviceQueue(m_vkDevice, m_queueFamilyIndex, 0, &m_vkQueue); + // + // Emitted at the top of the window's ~QObject, before it deletes its + // children, so detaching here is what saves the viewport from being + // deleted along with it. The window becomes parentless for the moment; + // it is hidden so it cannot flash on screen as a stray top-level, and + // re-attached once the top-level has its new window. + // + QWindow* destroyedWindow = m_watchedParentWindow; + m_watchedParentWindow = nullptr; - // Negotiate the GL<->Vulkan interop configuration once, here. It is a - // property of the device, not of a shared-image slot or of the current - // window size, so it must not be recomputed per slot or on resize. - negotiateInteropConfig(); - if (ImageRenderer::debugGpu()) + // + // Only the viewport's actual parent matters. Before the container has + // re-parented it, the viewport still belongs to QWindowContainer's + // internal placeholder parent, and pulling it off that would break the + // container's own bookkeeping. + // + if (m_vulkanWindow && m_vulkanWindow->parent() == destroyedWindow) { - cout << "INFO: VulkanView: initVulkan: interop negotiation ran (once per device); result=" - << (m_interopConfig.supported ? "supported" : "unsupported") << endl; + m_vulkanWindow->hide(); + m_vulkanWindow->setParent(nullptr); } - auto failInit = [this]() - { - cleanupVulkan(); - return false; - }; - - // Command pool - VkCommandPoolCreateInfo poolInfo = {}; - poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; - poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; - poolInfo.queueFamilyIndex = m_queueFamilyIndex; - if (vkCreateCommandPool(m_vkDevice, &poolInfo, nullptr, &m_vkCommandPool) != VK_SUCCESS) + // + // Take the container out of the widget tree for the duration as well. + // Qt reaches window containers through QWindowContainer::parentWasMoved() + // on every layout pass and dereferences the top-level's windowHandle() + // without checking it -- and that is null from here until Qt recreates + // the window. A layout pass runs before then, inside this same + // reparent, so a container left in the tree faults there. + // + if (m_container) { - return failInit(); - } - - // Sync objects. The per-swapchain-image renderFinished semaphores live - // in createSwapchain (sized to the image count); here we create only the - // per-in-flight-slot acquire semaphores and frame fences. - VkSemaphoreCreateInfo semaphoreInfo = {}; - semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + if (layout()) + layout()->removeWidget(m_container); - // Per-in-flight-slot acquire semaphore + frame fence. Fences are created - // signaled so the first wait on a slot passes without a prior submit. - VkFenceCreateInfo fenceInfo = {}; - fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; - fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; - for (FrameSync& sync : m_frameSync) - { - if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &sync.imageAvailable) != VK_SUCCESS) - { - return failInit(); - } - if (vkCreateFence(m_vkDevice, &fenceInfo, nullptr, &sync.fence) != VK_SUCCESS) - { - return failInit(); - } + m_container->hide(); + m_container->setParent(nullptr); } - return true; - } - - // Queue fallbackVulkanToGLView on the next event-loop tick (at most once). - void VulkanView::requestGLFallback() - { - if (m_glFallbackRequested || !m_doc || m_stopProcessingEvents || m_doc->isClosing()) - { + if (m_reattachPending) return; - } - m_glFallbackRequested = true; - // The OpenGL rung forgoes 10-bit, so it must be visible in the log - // rather than inferred from the absence of a Vulkan record. Callers - // that know why set m_presentPathReason before calling. - reportPresentPath(PresentPath::OpenGL, - m_presentPathReason.empty() ? std::string("Vulkan presentation could not be established") : m_presentPathReason); - - QTimer::singleShot(0, m_doc, [doc = m_doc]() { doc->fallbackVulkanToGLView(); }); + m_reattachPending = true; + QTimer::singleShot(0, this, &VulkanView::reattachVulkanWindow); } - // Skip swapchain work during close or zero-size resize. - bool VulkanView::presentationAllowed() const + void VulkanView::reattachVulkanWindow() { - if (m_stopProcessingEvents) - { - return false; - } - if (width() <= 0 || height() <= 0) - { - return false; - } - if (m_doc && m_doc->isClosing()) - { - return false; - } - return true; - } + m_reattachPending = false; - // Reset acquire semaphore and rebuild swapchain/shared image at the new size. - void VulkanView::handleSwapchainOutOfDate() - { - if (!m_vkDevice || !presentationAllowed()) - { + if (!m_vulkanWindow) return; - } - - VkSemaphoreCreateInfo semaphoreInfo = {}; - semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; - for (FrameSync& sync : m_frameSync) - { - if (sync.imageAvailable) - { - vkDestroySemaphore(m_vkDevice, sync.imageAvailable, nullptr); - sync.imageAvailable = VK_NULL_HANDLE; - } - if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &sync.imageAvailable) != VK_SUCCESS) - { - sync.imageAvailable = VK_NULL_HANDLE; - requestGLFallback(); - return; - } - } - - // Recreate only the swapchain, not the shared image. The shared image is - // a content-sized TRANSFER_SRC image, independent of the window-sized - // swapchain; createSwapchain() reuses the old swapchain (oldSwapchain) so - // this is a warm recreate. The next render()'s getSharedImageInfo() will - // rebuild the shared image only if the content size actually changed. - if (!createSwapchain()) - { - requestGLFallback(); - } - } - - void VulkanView::cleanupVulkan() - { - if (m_vkDevice) - { - vkDeviceWaitIdle(m_vkDevice); - - for (FrameSync& sync : m_frameSync) - { - if (sync.imageAvailable) - { - vkDestroySemaphore(m_vkDevice, sync.imageAvailable, nullptr); - sync.imageAvailable = VK_NULL_HANDLE; - } - if (sync.fence) - { - vkDestroyFence(m_vkDevice, sync.fence, nullptr); - sync.fence = VK_NULL_HANDLE; - } - } - // m_vkRenderFinished are per-swapchain-image; freed in cleanupSwapchain. - - if (m_vkCommandPool) - { - vkDestroyCommandPool(m_vkDevice, m_vkCommandPool, nullptr); - m_vkCommandPool = VK_NULL_HANDLE; - } - - vkDestroyDevice(m_vkDevice, nullptr); - m_vkDevice = VK_NULL_HANDLE; - } - m_vkQueue = VK_NULL_HANDLE; - // The VkInstance and VkSurfaceKHR are owned by Qt (QVulkanInstance), so they are not destroyed here. - } - - bool VulkanView::createSwapchain() - { - if (!m_vkDevice || !m_vkSurface) - { - return false; - } - - if (!presentationAllowed()) - { - return false; - } - - VkSurfaceCapabilitiesKHR capabilities; - vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &capabilities); - // Negotiate 10-bit format - uint32_t formatCount; - vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, nullptr); - std::vector formats(formatCount); - vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, formats.data()); + QWidget* topLevel = window(); + QWindow* topLevelWindow = topLevel ? topLevel->windowHandle() : nullptr; - if (ImageRenderer::debugGpu()) + if (!topLevelWindow) { - cout << "INFO: VulkanView: createSwapchain: surface offers " << formatCount << " format/colorSpace pair(s):" << endl; - for (uint32_t i = 0; i < formats.size(); ++i) - { - cout << "INFO: VulkanView: [" << i << "] format=" << formats[i].format << " (" << formatName(formats[i].format) - << ") colorSpace=" << formats[i].colorSpace << " (" << colorSpaceName(formats[i].colorSpace) << ")" << endl; - } + // + // Qt recreates the top-level's window lazily (on the next show), so + // keep waiting rather than forcing it here. + // + m_reattachPending = true; + QTimer::singleShot(0, this, &VulkanView::reattachVulkanWindow); + return; } - // Select the format and its color space TOGETHER, as one pairing the - // surface actually offers. Picking a format first and inheriting - // whatever color space accompanies it depends on driver list order: a - // surface that lists A2B10G10R10 under HDR10_ST2084 before listing it - // under SRGB_NONLINEAR would yield an HDR swapchain fed the SDR-encoded - // pixels RV renders. // - // A2B10G10R10 (== GL_RGB10_A2) is preferred because it is the layout - // the interop shared texture and the CPU fallback packing produce - // natively, making the transfer a plain copy. A2R10G10B10 (common on - // Linux/RADV) is accepted too: the opposite R/B order is resolved by - // component-wise packing on the CPU path and by vkCmdBlitImage on the - // interop path, so red and blue are not swapped. - VkSurfaceFormatKHR surfaceFormat = formats.empty() ? VkSurfaceFormatKHR{} : formats[0]; - bool found10bit = false; - bool sawTenBitNonSdr = false; - - constexpr std::array preferredOrder = {VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_A2R10G10B10_UNORM_PACK32}; - for (VkFormat want : preferredOrder) - { - for (const auto& fmt : formats) - { - if (fmt.format != want) - { - continue; - } - - if (fmt.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) - { - surfaceFormat = fmt; - found10bit = true; - break; - } - - // A 10-bit format, but only in a color space RV does not encode - // for. Remember it so the fallback reason can say so. - sawTenBitNonSdr = true; - } - if (found10bit) - { - break; - } - } - - if (!found10bit) - { - if (sawTenBitNonSdr) - { - cout << "WARNING: VulkanView: the surface offers 10-bit formats only in non-SDR color spaces " - "(RV renders SDR-encoded pixels, so presenting into one would mis-encode color); " - "requesting OpenGL fallback" - << endl; - m_presentPathReason = "surface offers 10-bit only in a non-SDR color space"; - } - else - { - cout << "WARNING: VulkanView: Real surface lacks a 10-bit format (A2B10G10R10/A2R10G10B10); requesting OpenGL fallback" - << endl; - m_presentPathReason = "surface offers no 10-bit format"; - } - requestGLFallback(); - return false; - } - - if (ImageRenderer::debugGpu()) + // Put the container back first: re-parenting it makes QWindowContainer + // re-adopt the viewport window into the new top-level window itself. + // + if (m_container) { - cout << "INFO: VulkanView: createSwapchain: chose " << formatName(surfaceFormat.format) << " / " - << colorSpaceName(surfaceFormat.colorSpace) << " (10-bit SDR OK)" << endl; - } + m_container->setParent(this); - m_vkSwapchainFormat = surfaceFormat.format; - m_vkSwapchainColorSpace = surfaceFormat.colorSpace; - - m_vkSwapchainExtent = capabilities.currentExtent; - if (m_vkSwapchainExtent.width == std::numeric_limits::max()) - { - m_vkSwapchainExtent = {static_cast(width()), static_cast(height())}; - } - if (m_vkSwapchainExtent.width == 0 || m_vkSwapchainExtent.height == 0) - { - requestGLFallback(); - return false; - } + if (layout()) + layout()->addWidget(m_container); - uint32_t imageCount = capabilities.minImageCount + 1; - if (capabilities.maxImageCount > 0 && imageCount > capabilities.maxImageCount) - { - imageCount = capabilities.maxImageCount; + m_container->show(); + setFocusProxy(m_container); } - VkSwapchainCreateInfoKHR createInfo = {}; - createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; - createInfo.surface = m_vkSurface; - createInfo.minImageCount = imageCount; - createInfo.imageFormat = surfaceFormat.format; - createInfo.imageColorSpace = surfaceFormat.colorSpace; - createInfo.imageExtent = m_vkSwapchainExtent; - createInfo.imageArrayLayers = 1; - createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; - createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; - createInfo.preTransform = capabilities.currentTransform; - createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; - createInfo.presentMode = VK_PRESENT_MODE_FIFO_KHR; // VSync - createInfo.clipped = VK_TRUE; - // Warm recreate: hand the retiring swapchain to the driver so it can reuse - // its backing resources (much cheaper than a cold create on every resize). - createInfo.oldSwapchain = m_vkSwapchain; + if (m_vulkanWindow->parent() != topLevelWindow) + m_vulkanWindow->setParent(topLevelWindow); - // Create into a local handle so a failed create leaves the existing - // swapchain and command buffers intact (the fallback paths stay valid). - VkSwapchainKHR newSwapchain = VK_NULL_HANDLE; - if (vkCreateSwapchainKHR(m_vkDevice, &createInfo, nullptr, &newSwapchain) != VK_SUCCESS) - { - requestGLFallback(); - return false; - } - - // New swapchain is live. Retire the old one only now: wait for its last - // submitted frame to finish, free its command buffers, then destroy it. - if (m_vkSwapchain != VK_NULL_HANDLE) - { - vkDeviceWaitIdle(m_vkDevice); - if (!m_vkCommandBuffers.empty()) - { - vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, static_cast(m_vkCommandBuffers.size()), - m_vkCommandBuffers.data()); - m_vkCommandBuffers.clear(); - } - vkDestroySwapchainKHR(m_vkDevice, m_vkSwapchain, nullptr); - } - m_vkSwapchain = newSwapchain; + m_vulkanWindow->show(); - vkGetSwapchainImagesKHR(m_vkDevice, m_vkSwapchain, &imageCount, nullptr); - m_vkSwapchainImages.resize(imageCount); - vkGetSwapchainImagesKHR(m_vkDevice, m_vkSwapchain, &imageCount, m_vkSwapchainImages.data()); + watchParentWindow(); - m_vkCommandBuffers.resize(imageCount); - VkCommandBufferAllocateInfo allocInfo = {}; - allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; - allocInfo.commandPool = m_vkCommandPool; - allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; - allocInfo.commandBufferCount = static_cast(m_vkCommandBuffers.size()); - if (vkAllocateCommandBuffers(m_vkDevice, &allocInfo, m_vkCommandBuffers.data()) != VK_SUCCESS) + // + // The container drives the viewport's geometry from its own, so nudge a + // layout pass to put the re-attached window back in place. + // + if (m_container) { - cleanupSwapchain(); - requestGLFallback(); - return false; - } + m_container->updateGeometry(); - // Per-swapchain-image present-wait semaphores + in-flight fence map. The - // device is idle here (the retire path above waited on it), so any old - // renderFinished semaphores from a previous swapchain are safe to destroy. - for (VkSemaphore sem : m_vkRenderFinished) - { - if (sem) - { - vkDestroySemaphore(m_vkDevice, sem, nullptr); - } - } - m_vkRenderFinished.assign(imageCount, VK_NULL_HANDLE); - VkSemaphoreCreateInfo rfInfo = {}; - rfInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; - for (uint32_t i = 0; i < imageCount; ++i) - { - if (vkCreateSemaphore(m_vkDevice, &rfInfo, nullptr, &m_vkRenderFinished[i]) != VK_SUCCESS) - { - cleanupSwapchain(); - requestGLFallback(); - return false; - } + if (layout()) + layout()->activate(); } - // Fresh swapchain images: none are in flight yet. - m_imagesInFlight.assign(imageCount, VK_NULL_HANDLE); - return true; + // + // Re-parenting gives the viewport a new platform window, which + // invalidates the VkSurfaceKHR. VulkanWindow notices on its next + // expose and rebuilds; asking for a redraw is what gets it there. + // + if (m_doc && m_doc->session()) + m_doc->session()->askForRedraw(); } - void VulkanView::cleanupSwapchain() + void VulkanView::stopProcessingEvents() { - if (m_vkDevice) - { - vkDeviceWaitIdle(m_vkDevice); - - for (VkSemaphore sem : m_vkRenderFinished) - { - if (sem) - { - vkDestroySemaphore(m_vkDevice, sem, nullptr); - } - } - m_vkRenderFinished.clear(); - m_imagesInFlight.clear(); - - for (StagingBuffer& staging : m_staging) - { - if (staging.buffer) - { - vkDestroyBuffer(m_vkDevice, staging.buffer, nullptr); - staging.buffer = VK_NULL_HANDLE; - } - if (staging.memory) - { - vkFreeMemory(m_vkDevice, staging.memory, nullptr); - staging.memory = VK_NULL_HANDLE; - } - staging.size = 0; - } - - if (!m_vkCommandBuffers.empty()) - { - vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, static_cast(m_vkCommandBuffers.size()), - m_vkCommandBuffers.data()); - m_vkCommandBuffers.clear(); - } - - if (m_vkSwapchain) - { - vkDestroySwapchainKHR(m_vkDevice, m_vkSwapchain, nullptr); - m_vkSwapchain = VK_NULL_HANDLE; - } - } + if (m_vulkanWindow) + m_vulkanWindow->stopProcessingEvents(); } - namespace - { - bool isNvidiaPhysicalDevice(VkPhysicalDevice dev) - { - VkPhysicalDeviceProperties props = {}; - vkGetPhysicalDeviceProperties(dev, &props); - return props.vendorID == 0x10DE; - } - - bool nvidiaInteropWorkaroundDisabled() - { - static const bool disabled = getenv("RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND") != nullptr; - return disabled; - } - - // NVIDIA Linux 550+ drivers return blank pixels to OpenGL for LINEAR shared - // images >= ~2 MiB (forum thread #349436). Allocating the shared image with - // VK_IMAGE_TILING_OPTIMAL avoids that broken linear path and restores - // correct zero-copy interop, so OPTIMAL is the default on NVIDIA. Both the - // GL and Vulkan sides here are the same NVIDIA driver/GPU, so the - // vendor-private optimal layout matches on import without needing explicit - // DRM-format-modifier negotiation. AMD/Intel keep the existing LINEAR path. - // Set RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND to revert NVIDIA to LINEAR - // (reproduces the blank-image bug, for debugging). - bool useOptimalTilingForInterop(VkPhysicalDevice dev) - { -#if defined(PLATFORM_LINUX) - return !nvidiaInteropWorkaroundDisabled() && isNvidiaPhysicalDevice(dev); -#else - (void)dev; - return false; -#endif - } + bool VulkanView::firstPaintCompleted() const { return m_vulkanWindow && m_vulkanWindow->firstPaintCompleted(); } - // Resolve vkGetPhysicalDeviceImageFormatProperties2, preferring the - // core 1.1 entry point and falling back to the KHR alias. Qt owns the - // VkInstance, so which one exists depends on the apiVersion Qt created - // it with; initVulkan asks Qt for 1.1 but must tolerate a 1.0 loader. - PFN_vkGetPhysicalDeviceImageFormatProperties2 getImageFormatProperties2(VkInstance instance) - { - static PFN_vkGetPhysicalDeviceImageFormatProperties2 fn = nullptr; - static bool resolved = false; - if (!resolved) - { - resolved = true; - fn = reinterpret_cast( - vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceImageFormatProperties2")); - if (!fn) - { - fn = reinterpret_cast( - vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceImageFormatProperties2KHR")); - } - } - return fn; - } - } // namespace + bool VulkanView::isInitialized() const { return m_vulkanWindow && m_vulkanWindow->isInitialized(); } - // - // Probe the driver for an exportable shared-image configuration. - // - // The configuration is chosen from what the driver reports, not from GPU - // vendor identity or host platform. Candidates are ordered OPTIMAL before - // LINEAR: OPTIMAL is the layout drivers are built around, and LINEAR is - // the compatibility rung that additionally carries the rowPitch % 4 - // constraint that can fail allocation outright. - // - // Usage always includes COLOR_ATTACHMENT because the GL side attaches the - // imported texture to GL_COLOR_ATTACHMENT0 and renders into it. Declaring - // only TRANSFER_SRC lets the driver pick an internal compressed layout the - // GL import does not decode, which corrupts the image rather than failing. - // If no candidate with the honest usage is exportable, interop is refused - // rather than narrowed to a declaration the code then violates. - // - void VulkanView::negotiateInteropConfig() + void VulkanView::absolutePosition(int& x, int& y) const { - if (m_interopNegotiated) + if (m_vulkanWindow) { + m_vulkanWindow->absolutePosition(x, y); return; } - m_interopNegotiated = true; - InteropConfig cfg; - cfg.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; // == GL_RGB10_A2 - cfg.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; - -#ifdef PLATFORM_WINDOWS - const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif - - PFN_vkGetPhysicalDeviceImageFormatProperties2 probe = getImageFormatProperties2(m_vkInstance); - if (!probe) - { - cfg.supported = false; - cfg.rejectReason = "vkGetPhysicalDeviceImageFormatProperties2 is unavailable " - "(Vulkan instance predates 1.1 and lacks VK_KHR_get_physical_device_properties2), " - "so exportability cannot be established"; - m_interopConfig = cfg; - return; - } - - constexpr std::array candidates = {VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_TILING_LINEAR}; - - bool found = false; - for (VkImageTiling tiling : candidates) - { - VkPhysicalDeviceExternalImageFormatInfo extInfo = {}; - extInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO; - extInfo.handleType = handleType; - - VkPhysicalDeviceImageFormatInfo2 fmtInfo = {}; - fmtInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2; - fmtInfo.pNext = &extInfo; - fmtInfo.format = cfg.format; - fmtInfo.type = VK_IMAGE_TYPE_2D; - fmtInfo.tiling = tiling; - fmtInfo.usage = cfg.usage; - fmtInfo.flags = 0; - - VkExternalImageFormatProperties extProps = {}; - extProps.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES; - - VkImageFormatProperties2 props = {}; - props.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2; - props.pNext = &extProps; + const QPoint gp = mapToGlobal(QPoint(0, 0)); + x = gp.x(); + y = gp.y(); + } - const VkResult result = probe(m_vkPhysicalDevice, &fmtInfo, &props); + float VulkanView::devicePixelRatio() const + { + return m_vulkanWindow ? m_vulkanWindow->devicePixelRatioF() : static_cast(devicePixelRatioF()); + } - ostringstream entry; - entry << tilingName(tiling) << ": "; - - if (result != VK_SUCCESS) - { - entry << "not supported for this format/usage (VkResult " << result << ")"; - cfg.candidateLog.push_back(entry.str()); - continue; - } - - const VkExternalMemoryFeatureFlags features = extProps.externalMemoryProperties.externalMemoryFeatures; - const bool exportable = (extProps.externalMemoryProperties.compatibleHandleTypes & handleType) != 0 - && (features & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0 - && (features & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0; - - if (!exportable) - { - entry << "supported but not exportable+importable for this handle type" - << " (features=0x" << std::hex << features << std::dec << ")"; - cfg.candidateLog.push_back(entry.str()); - continue; - } - - // DEDICATED_ONLY is the only dedicated-allocation signal available - // from the external-memory probe: it is a hard requirement of the - // handle type. The softer "prefers dedicated" signal is a property - // of a concrete image, not of the format, and is read per-image - // from VkMemoryDedicatedRequirements at allocation time -- see - // getSharedImageInfo(). This value is therefore the floor, and the - // per-slot SharedImageInfo::dedicatedAllocation is the final - // decision the GL side must mirror. - const bool dedicatedOnly = (features & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; - - cfg.supported = true; - cfg.tiling = tiling; - cfg.externalFeatures = features; - cfg.dedicatedAllocation = dedicatedOnly; - cfg.probedTiling = tiling; - cfg.probedDedicated = cfg.dedicatedAllocation; - - entry << "exportable (features=0x" << std::hex << features << std::dec << ") -- selected"; - cfg.candidateLog.push_back(entry.str()); - found = true; - break; - } - - if (!found) - { - cfg.supported = false; - cfg.rejectReason = "no candidate tiling is exportable at A2B10G10R10 with " - "COLOR_ATTACHMENT|TRANSFER_SRC usage"; - m_interopConfig = cfg; - return; - } - - // Apply the diagnostic overrides last, so the record can report both - // the negotiated value and the forced one. An override is honored only - // when the driver reported that configuration as usable. - const std::optional forcedTiling = forcedTilingRequested(); - if (forcedTiling && *forcedTiling != cfg.tiling) - { - // Re-probe the forced tiling rather than trusting the request: - // presenting through an unverified configuration is exactly what - // the fallback ladder exists to prevent. - VkPhysicalDeviceExternalImageFormatInfo extInfo = {}; - extInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO; - extInfo.handleType = handleType; - - VkPhysicalDeviceImageFormatInfo2 fmtInfo = {}; - fmtInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2; - fmtInfo.pNext = &extInfo; - fmtInfo.format = cfg.format; - fmtInfo.type = VK_IMAGE_TYPE_2D; - fmtInfo.tiling = *forcedTiling; - fmtInfo.usage = cfg.usage; - - VkExternalImageFormatProperties extProps = {}; - extProps.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES; - VkImageFormatProperties2 props = {}; - props.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2; - props.pNext = &extProps; - - const VkResult result = probe(m_vkPhysicalDevice, &fmtInfo, &props); - const VkExternalMemoryFeatureFlags features = extProps.externalMemoryProperties.externalMemoryFeatures; - const bool ok = result == VK_SUCCESS && (extProps.externalMemoryProperties.compatibleHandleTypes & handleType) != 0 - && (features & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0 - && (features & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0; - - if (ok) - { - cfg.tilingOverridden = true; - cfg.tiling = *forcedTiling; - cfg.externalFeatures = features; - cfg.dedicatedAllocation = (features & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; - } - else - { - cout << "WARNING: VulkanView: RV_VULKAN_FORCE_TILING=" << tilingName(*forcedTiling) - << " refused -- the driver does not report it as exportable; using the negotiated " << tilingName(cfg.tiling) << endl; - } - } - - if (envFlagSet("RV_VULKAN_FORCE_NO_DEDICATED") && cfg.dedicatedAllocation) - { - if ((cfg.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0) - { - cout << "WARNING: VulkanView: RV_VULKAN_FORCE_NO_DEDICATED refused -- the driver reports " - << "DEDICATED_ONLY for this configuration, which is a requirement rather than a preference" << endl; - } - else - { - cfg.dedicatedOverridden = true; - cfg.dedicatedAllocation = false; - } - } - - m_interopConfig = cfg; - } - - void VulkanView::reportPresentPath(PresentPath path, const std::string& reason) - { - m_presentPath = path; - m_presentPathReason = reason; - emitPresentationRecord(); - } - - void VulkanView::reportGLImportState(VkImageTiling tiling, bool dedicated) - { - m_glImportTiling = tiling; - m_glImportDedicated = dedicated; - m_glImportReported = true; - } - - // - // One record per session, emitted unconditionally. Windows/NVIDIA is - // verified by testers against a produced build rather than by the - // developer, so this has to be sufficient on its own to establish which - // path ran and what was negotiated. Per-frame and per-candidate detail - // stays behind ImageRenderer::debugGpu(). - // - void VulkanView::emitPresentationRecord() - { - if (m_recordEmitted) - { - return; - } - m_recordEmitted = true; - - VkPhysicalDeviceProperties props = {}; - if (m_vkPhysicalDevice != VK_NULL_HANDLE) - { - vkGetPhysicalDeviceProperties(m_vkPhysicalDevice, &props); - } - - std::string_view pathName = "undetermined"; - switch (m_presentPath) - { - case PresentPath::ZeroCopy: - pathName = "GPU zero-copy interop (10-bit)"; - break; - case PresentPath::CpuReadback: - pathName = "CPU readback (10-bit, slower)"; - break; - case PresentPath::OpenGL: - pathName = "OpenGL (Vulkan abandoned; not 10-bit)"; - break; - case PresentPath::Undetermined: - break; - } - - ostringstream record; - record << "INFO: RV Vulkan presentation report\n"; - record << "INFO: GPU : " << (m_vkPhysicalDevice != VK_NULL_HANDLE ? props.deviceName : "(none)") << " vendorID=0x" - << std::hex << props.vendorID << std::dec << " driverVersion=" << props.driverVersion - << " apiVersion=" << VK_VERSION_MAJOR(props.apiVersion) << "." << VK_VERSION_MINOR(props.apiVersion) << "." - << VK_VERSION_PATCH(props.apiVersion) << "\n"; - record << "INFO: Present path : " << pathName << "\n"; - if (!m_presentPathReason.empty()) - { - record << "INFO: Reason : " << m_presentPathReason << "\n"; - } - record << "INFO: Swapchain : " << formatName(m_vkSwapchainFormat) << " / " << colorSpaceName(m_vkSwapchainColorSpace) - << "\n"; - - const InteropConfig& config = m_interopConfig; - if (config.supported) - { - record << "INFO: Shared image : " << formatName(config.format) << " tiling=" << tilingName(config.tiling) - << " usage=COLOR_ATTACHMENT|TRANSFER_SRC\n"; - record << "INFO: Dedicated alloc: " << (config.dedicatedAllocation ? "yes" : "no") << " (driver externalMemoryFeatures=0x" - << std::hex << config.externalFeatures << std::dec - << (config.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT ? " DEDICATED_ONLY" : "") << ")\n"; - if (config.tilingOverridden) - { - record << "INFO: Tiling override: RV_VULKAN_FORCE_TILING forced " << tilingName(config.tiling) << "; negotiation chose " - << tilingName(config.probedTiling) << "\n"; - } - if (config.dedicatedOverridden) - { - record << "INFO: Dedicated ovr : RV_VULKAN_FORCE_NO_DEDICATED suppressed dedicated allocation; negotiation chose " - << (config.probedDedicated ? "yes" : "no") << "\n"; - } - if (m_glImportReported) - { - const bool agree = m_glImportTiling == config.tiling && m_glImportDedicated == config.dedicatedAllocation; - record << "INFO: GL import : tiling=" << tilingName(m_glImportTiling) - << " dedicated=" << (m_glImportDedicated ? "yes" : "no") << " -- " - << (agree ? "matches the Vulkan export" : "DISAGREES WITH THE VULKAN EXPORT (expect a corrupted image)") << "\n"; - } - } - else - { - record << "INFO: Shared image : not used -- " - << (config.rejectReason.empty() ? "interop not negotiated" : config.rejectReason) << "\n"; - } - - for (const std::string& entry : config.candidateLog) - { - record << "INFO: Probe candidate: " << entry << "\n"; - } - - // Kept for comparison until the Linux/NVIDIA probe result is confirmed - // to agree; the vendor heuristic is removed once it does. - record << "INFO: Legacy vendor heuristic would have chosen: " - << (m_vkPhysicalDevice != VK_NULL_HANDLE && useOptimalTilingForInterop(m_vkPhysicalDevice) ? "OPTIMAL" : "LINEAR") << "\n"; - - if (envFlagSet("RV_VULKAN_FORCE_CPU_PRESENT")) - { - record << "INFO: Override : RV_VULKAN_FORCE_CPU_PRESENT is set\n"; - } - - cout << record.str() << flush; - } - - void VulkanView::cleanupSharedImage(uint32_t slot) - { - SharedImageInfo& info = m_shared[slot].info; - - if (m_vkDevice) - { - vkDeviceWaitIdle(m_vkDevice); - - if (m_shared[slot].image) - { - vkDestroyImage(m_vkDevice, m_shared[slot].image, nullptr); - m_shared[slot].image = VK_NULL_HANDLE; - } - if (m_shared[slot].memory) - { - vkFreeMemory(m_vkDevice, m_shared[slot].memory, nullptr); - m_shared[slot].memory = VK_NULL_HANDLE; - } - if (m_shared[slot].glReady) - { - vkDestroySemaphore(m_vkDevice, m_shared[slot].glReady, nullptr); - m_shared[slot].glReady = VK_NULL_HANDLE; - } - if (m_shared[slot].vkReady) - { - vkDestroySemaphore(m_vkDevice, m_shared[slot].vkReady, nullptr); - m_shared[slot].vkReady = VK_NULL_HANDLE; - } - } - -#ifdef PLATFORM_WINDOWS - if (info.memoryHandle) - { - ::CloseHandle(static_cast(info.memoryHandle)); - info.memoryHandle = nullptr; - } - if (info.glReadySemaphoreHandle) - { - ::CloseHandle(static_cast(info.glReadySemaphoreHandle)); - info.glReadySemaphoreHandle = nullptr; - } - if (info.vkReadySemaphoreHandle) - { - ::CloseHandle(static_cast(info.vkReadySemaphoreHandle)); - info.vkReadySemaphoreHandle = nullptr; - } -#else - if (info.memoryFd != -1) - { - ::close(info.memoryFd); - info.memoryFd = -1; - } - if (info.glReadySemaphoreFd != -1) - { - ::close(info.glReadySemaphoreFd); - info.glReadySemaphoreFd = -1; - } - if (info.vkReadySemaphoreFd != -1) - { - ::close(info.vkReadySemaphoreFd); - info.vkReadySemaphoreFd = -1; - } -#endif - info.width = 0; - info.height = 0; - info.size = 0; - info.capacityHeight = 0; - info.tiling = VK_IMAGE_TILING_LINEAR; - info.dedicatedAllocation = false; - m_shared[slot].capacityW = 0; - m_shared[slot].capacityH = 0; - } - - void VulkanView::drainSharedSemaphores(uint32_t slot) - { - // No shared image for this slot yet -> the GL side never signaled/waited - // its pair, so there is nothing to rebalance. - if (!m_vkDevice || !m_shared[slot].glReady || !m_shared[slot].vkReady) - { - return; - } - - // Consume the pending glReady signal from the GL side and re-signal - // vkReady so the next use of this slot starts balanced (exactly what a - // normal present's submit would have done for the pair). - VkSubmitInfo drain = {}; - drain.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - const std::array waitSemaphores = {m_shared[slot].glReady}; - const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT}; - drain.waitSemaphoreCount = static_cast(waitSemaphores.size()); - drain.pWaitSemaphores = waitSemaphores.data(); - drain.pWaitDstStageMask = waitStages.data(); - drain.commandBufferCount = 0; - const std::array signalSemaphores = {m_shared[slot].vkReady}; - drain.signalSemaphoreCount = static_cast(signalSemaphores.size()); - drain.pSignalSemaphores = signalSemaphores.data(); - - const VkResult result = vkQueueSubmit(m_vkQueue, 1, &drain, VK_NULL_HANDLE); - if (result == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - } - - const VulkanView::SharedImageInfo* VulkanView::getSharedImageInfo(int w, int h) - { - if (!m_vkDevice) - return nullptr; - - // Build/return the shared image for the current in-flight ring slot. - const uint32_t slot = m_currentFrame; - SharedImageInfo& info = m_shared[slot].info; - - // The swapchain always tracks the window size, so recreate it on any size - // change. This is independent of the grow-only shared image below: a drag - // still recreates the (warm) swapchain each step, but no longer rebuilds - // or re-exports the shared image. - if (!m_vkSwapchain || m_vkSwapchainExtent.width != static_cast(w) - || m_vkSwapchainExtent.height != static_cast(h)) - { - // Warm recreate via oldSwapchain (createSwapchain retires the old one). - if (!createSwapchain()) - { - return nullptr; - } - } - - // Grow-only: if the request fits the slot's current allocated capacity, - // reuse the existing image/export and just update the used sub-region - // (presentSharedImage copies/blits info.width x info.height from it). - if (m_shared[slot].image && w <= m_shared[slot].capacityW && h <= m_shared[slot].capacityH) - { - info.width = w; - info.height = h; - return &info; - } - - // Grow (or first allocation): rebuild at a capacity that is the - // componentwise max of the request, the screen size, and the current - // capacity, so it grows monotonically and the common drag-to-fullscreen - // case allocates at most once. - int screenW = 0; - int screenH = 0; - if (QScreen* scr = QGuiApplication::primaryScreen()) - { - const qreal dpr = scr->devicePixelRatio(); - screenW = static_cast(scr->geometry().width() * dpr); - screenH = static_cast(scr->geometry().height() * dpr); - } - const int capW = std::max({w, screenW, m_shared[slot].capacityW}); - const int capH = std::max({h, screenH, m_shared[slot].capacityH}); - - cleanupSharedImage(slot); - - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: getSharedImageInfo: (re)allocating shared image slot " << slot << " capacity " << capW << "x" << capH - << " for request " << w << "x" << h << endl; - } - - // The interop configuration was negotiated once at device creation from - // what the driver reports exportable. If nothing was exportable, refuse - // the zero-copy path here so syncBuffers() takes the CPU readback rung - // rather than presenting through a configuration whose correctness was - // never established. - if (!m_interopConfig.supported) - { - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: getSharedImageInfo: interop unavailable (" << m_interopConfig.rejectReason - << "); using the CPU readback path" << endl; - } - return nullptr; - } - - const bool optimalTiling = m_interopConfig.tiling == VK_IMAGE_TILING_OPTIMAL; - - // 1. Create Shared Image - VkExternalMemoryImageCreateInfo extMemInfo = {}; - extMemInfo.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO; -#ifdef PLATFORM_WINDOWS - extMemInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - extMemInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif - - VkImageCreateInfo imageInfo = {}; - imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; - imageInfo.pNext = &extMemInfo; - imageInfo.imageType = VK_IMAGE_TYPE_2D; - // The shared image is imported into GL as GL_RGB10_A2, whose bit layout - // is A2B10G10R10, so the Vulkan side must use the matching format - // regardless of the swapchain format. When the swapchain is A2R10G10B10 - // the difference is reconciled by a component-wise blit in - // presentSharedImage() (not a raw copy). - imageInfo.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; // matches GL_RGB10_A2 - // Allocate at capacity; presentSharedImage transfers only the used - // info.width x info.height sub-region (anchored at origin 0,0). - imageInfo.extent = {static_cast(capW), static_cast(capH), 1}; - imageInfo.mipLevels = 1; - imageInfo.arrayLayers = 1; - imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; - imageInfo.tiling = m_interopConfig.tiling; - // Usage must cover every use on BOTH sides: Vulkan reads the image as a - // transfer source, and GL attaches it to GL_COLOR_ATTACHMENT0 and - // renders into it. Declaring only TRANSFER_SRC lets the driver pick an - // internal compressed layout that the GL import does not decode, which - // corrupts the image rather than raising an error. - imageInfo.usage = m_interopConfig.usage; - imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; - - if (vkCreateImage(m_vkDevice, &imageInfo, nullptr, &m_shared[slot].image) != VK_SUCCESS) - { - cerr << "ERROR: VulkanView: Failed to create shared image" << endl; - return nullptr; - } - - // When the swapchain format differs from the shared image format - // (A2B10G10R10), presentSharedImage() reconciles them with a blit rather - // than a raw copy. That requires the shared image to be a valid blit - // source and the swapchain image a valid blit destination. If the driver - // does not support that, refuse the GPU-interop path so syncBuffers() - // uses the (channel-correct) CPU fallback instead. - if (m_vkSwapchainFormat != VK_FORMAT_A2B10G10R10_UNORM_PACK32) - { - VkFormatProperties srcProps = {}; - VkFormatProperties dstProps = {}; - vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, VK_FORMAT_A2B10G10R10_UNORM_PACK32, &srcProps); - vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, m_vkSwapchainFormat, &dstProps); - // The shared image's blit-source support depends on its actual - // (negotiated) tiling. - const VkFormatFeatureFlags srcFeatures = optimalTiling ? srcProps.optimalTilingFeatures : srcProps.linearTilingFeatures; - const bool blitOk = - (srcFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT) && (dstProps.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT); - if (!blitOk) - { - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanView: GPU interop unavailable for " << formatName(m_vkSwapchainFormat) - << " swapchain (blit unsupported); using CPU fallback." << endl; - } - cleanupSharedImage(slot); - return nullptr; - } - } - - // rowPitch is only meaningful (and vkGetImageSubresourceLayout only valid) - // for LINEAR tiling. For OPTIMAL tiling the GL import uses the logical - // capacity width and lets the driver resolve the layout. - if (optimalTiling) - { - info.strideWidth = capW; - } - else - { - // Handle padded linear row pitch by matching the GL texture stride to Vulkan's rowPitch. - VkImageSubresource subresource = {}; - subresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - subresource.mipLevel = 0; - subresource.arrayLayer = 0; - VkSubresourceLayout layout; - vkGetImageSubresourceLayout(m_vkDevice, m_shared[slot].image, &subresource, &layout); - - if (layout.rowPitch % 4 != 0) - { - // Cannot represent this stride as an integer pixel-width texture; fall back to CPU bridge. - cleanupSharedImage(slot); - return nullptr; - } - info.strideWidth = static_cast(layout.rowPitch / 4); - } - info.capacityHeight = capH; // GL imports the texture at capacity dimensions - info.tiling = m_interopConfig.tiling; - - // Resolve the final dedicated-allocation decision for THIS image. The - // probe supplied the floor (DEDICATED_ONLY, a requirement of the handle - // type); "prefers dedicated" is a property of a concrete image and is - // only available here, from VkMemoryDedicatedRequirements. The GL side - // mirrors info.dedicatedAllocation, so this is the single decision both - // sides use -- deciding it independently is what corrupts the image. - VkMemoryRequirements memReqs; - bool useDedicated = m_interopConfig.dedicatedAllocation; - { - VkMemoryDedicatedRequirements dedicatedReqs = {}; - dedicatedReqs.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_REQUIREMENTS; - - VkMemoryRequirements2 memReqs2 = {}; - memReqs2.sType = VK_STRUCTURE_TYPE_MEMORY_REQUIREMENTS_2; - memReqs2.pNext = &dedicatedReqs; - - VkImageMemoryRequirementsInfo2 reqInfo = {}; - reqInfo.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_REQUIREMENTS_INFO_2; - reqInfo.image = m_shared[slot].image; - - auto pfnGetImageMemoryRequirements2 = - deviceProc(m_vkDevice, "vkGetImageMemoryRequirements2"); - if (!pfnGetImageMemoryRequirements2) - { - pfnGetImageMemoryRequirements2 = - deviceProc(m_vkDevice, "vkGetImageMemoryRequirements2KHR"); - } - - if (pfnGetImageMemoryRequirements2) - { - pfnGetImageMemoryRequirements2(m_vkDevice, &reqInfo, &memReqs2); - memReqs = memReqs2.memoryRequirements; - if (dedicatedReqs.requiresDedicatedAllocation || dedicatedReqs.prefersDedicatedAllocation) - { - useDedicated = true; - } - } - else - { - vkGetImageMemoryRequirements(m_vkDevice, m_shared[slot].image, &memReqs); - } - - // An explicit override may only relax a preference, never a - // requirement (DEDICATED_ONLY or requiresDedicatedAllocation). - if (m_interopConfig.dedicatedOverridden && !m_interopConfig.dedicatedAllocation && !dedicatedReqs.requiresDedicatedAllocation) - { - useDedicated = false; - } - } - info.dedicatedAllocation = useDedicated; - - // Build the allocation pNext chain back to front, so each link is - // attached exactly once regardless of which options are active: - // - // allocInfo -> exportAllocInfo [-> dedicatedAllocInfo] [-> exportWin32Info] - // - void* chain = nullptr; - -#ifdef PLATFORM_WINDOWS - // Required by the Vulkan specification for OPAQUE_WIN32 handles: the - // export must state the access rights and security attributes the - // handle is created with. Its absence is tolerated by some drivers but - // is a real violation on the platform being debugged. - VkExportMemoryWin32HandleInfoKHR exportWin32Info = {}; - exportWin32Info.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_WIN32_HANDLE_INFO_KHR; - exportWin32Info.pNext = chain; - exportWin32Info.pAttributes = nullptr; // default security attributes - exportWin32Info.dwAccess = GENERIC_ALL; - exportWin32Info.name = nullptr; // unnamed: shared within this process only - chain = &exportWin32Info; -#endif - - // Dedicated allocation when the driver requires or prefers it for this - // image. NVIDIA's OPAQUE_WIN32 path in particular needs this paired - // with GL_DEDICATED_MEMORY_OBJECT_EXT on the import side. - VkMemoryDedicatedAllocateInfo dedicatedAllocInfo = {}; - if (useDedicated) - { - dedicatedAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; - dedicatedAllocInfo.pNext = chain; - dedicatedAllocInfo.image = m_shared[slot].image; - dedicatedAllocInfo.buffer = VK_NULL_HANDLE; - chain = &dedicatedAllocInfo; - } - - VkExportMemoryAllocateInfo exportAllocInfo = {}; - exportAllocInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; - exportAllocInfo.pNext = chain; -#ifdef PLATFORM_WINDOWS - exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif - - VkMemoryAllocateInfo allocInfo = {}; - allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - allocInfo.pNext = &exportAllocInfo; - allocInfo.allocationSize = memReqs.size; - const std::optional memoryTypeIndex = - findMemoryType(m_vkPhysicalDevice, memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); - if (!memoryTypeIndex) - { - cerr << "ERROR: VulkanView: No device-local memory type for shared image" << endl; - cleanupSharedImage(slot); - return nullptr; - } - allocInfo.memoryTypeIndex = *memoryTypeIndex; - - if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_shared[slot].memory) != VK_SUCCESS) - { - cerr << "ERROR: VulkanView: Failed to allocate shared image memory" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - if (vkBindImageMemory(m_vkDevice, m_shared[slot].image, m_shared[slot].memory, 0) != VK_SUCCESS) - { - cerr << "ERROR: VulkanView: Failed to bind shared image memory" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - // Export device memory as a platform-specific external handle - // (opaque FD on Linux, Win32 HANDLE on Windows). The receiving GL - // side imports this with the matching GL_EXT_memory_object_{fd,win32} - // extension so writes from GL land in this Vulkan image. -#ifdef PLATFORM_WINDOWS - auto pfnGetMemoryWin32HandleKHR = deviceProc(m_vkDevice, "vkGetMemoryWin32HandleKHR"); - if (!pfnGetMemoryWin32HandleKHR) - { - cerr << "ERROR: VulkanView: vkGetMemoryWin32HandleKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - VkMemoryGetWin32HandleInfoKHR getHandleInfo = {}; - getHandleInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_WIN32_HANDLE_INFO_KHR; - getHandleInfo.memory = m_shared[slot].memory; - getHandleInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; - - HANDLE memHandle = nullptr; - if (pfnGetMemoryWin32HandleKHR(m_vkDevice, &getHandleInfo, &memHandle) != VK_SUCCESS || !memHandle) - { - cerr << "ERROR: VulkanView: Failed to get memory HANDLE" << endl; - cleanupSharedImage(slot); - return nullptr; - } -#else - auto pfnGetMemoryFdKHR = deviceProc(m_vkDevice, "vkGetMemoryFdKHR"); - if (!pfnGetMemoryFdKHR) - { - cerr << "ERROR: VulkanView: vkGetMemoryFdKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - VkMemoryGetFdInfoKHR getFdInfo = {}; - getFdInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; - getFdInfo.memory = m_shared[slot].memory; - getFdInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; - - int memFd = -1; - if (pfnGetMemoryFdKHR(m_vkDevice, &getFdInfo, &memFd) != VK_SUCCESS) - { - cerr << "ERROR: VulkanView: Failed to get memory FD" << endl; - cleanupSharedImage(slot); - return nullptr; - } -#endif - - // 2. Create Shared Semaphores - VkExportSemaphoreCreateInfo exportSemInfo = {}; - exportSemInfo.sType = VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO; -#ifdef PLATFORM_WINDOWS - exportSemInfo.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - exportSemInfo.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif - - VkSemaphoreCreateInfo semInfo = {}; - semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; - semInfo.pNext = &exportSemInfo; - - if (vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_shared[slot].glReady) != VK_SUCCESS - || vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_shared[slot].vkReady) != VK_SUCCESS) - { - cerr << "ERROR: VulkanView: Failed to create shared semaphores" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - // Export the GL<->Vulkan sync semaphores as external handles. -#ifdef PLATFORM_WINDOWS - auto pfnGetSemaphoreWin32HandleKHR = deviceProc(m_vkDevice, "vkGetSemaphoreWin32HandleKHR"); - if (!pfnGetSemaphoreWin32HandleKHR) - { - cerr << "ERROR: VulkanView: vkGetSemaphoreWin32HandleKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - VkSemaphoreGetWin32HandleInfoKHR getSemHandleInfo = {}; - getSemHandleInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_WIN32_HANDLE_INFO_KHR; - getSemHandleInfo.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; - - HANDLE glReadyHandle = nullptr; - HANDLE vkReadyHandle = nullptr; - - getSemHandleInfo.semaphore = m_shared[slot].glReady; - if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &glReadyHandle) != VK_SUCCESS || !glReadyHandle) - { - cerr << "ERROR: VulkanView: Failed to get glReady semaphore HANDLE" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - getSemHandleInfo.semaphore = m_shared[slot].vkReady; - if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &vkReadyHandle) != VK_SUCCESS || !vkReadyHandle) - { - cerr << "ERROR: VulkanView: Failed to get vkReady semaphore HANDLE" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - info.memoryHandle = memHandle; - info.size = memReqs.size; - info.width = w; - info.height = h; - info.glReadySemaphoreHandle = glReadyHandle; - info.vkReadySemaphoreHandle = vkReadyHandle; -#else - auto pfnGetSemaphoreFdKHR = deviceProc(m_vkDevice, "vkGetSemaphoreFdKHR"); - if (!pfnGetSemaphoreFdKHR) - { - cerr << "ERROR: VulkanView: vkGetSemaphoreFdKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - VkSemaphoreGetFdInfoKHR getSemFdInfo = {}; - getSemFdInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; - getSemFdInfo.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; - - int glReadyFd = -1; - int vkReadyFd = -1; - - getSemFdInfo.semaphore = m_shared[slot].glReady; - if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &glReadyFd) != VK_SUCCESS || glReadyFd < 0) - { - cerr << "ERROR: VulkanView: Failed to get glReady semaphore FD" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - getSemFdInfo.semaphore = m_shared[slot].vkReady; - if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &vkReadyFd) != VK_SUCCESS || vkReadyFd < 0) - { - cerr << "ERROR: VulkanView: Failed to get vkReady semaphore FD" << endl; - cleanupSharedImage(slot); - return nullptr; - } - - info.memoryFd = memFd; - info.size = memReqs.size; - info.width = w; - info.height = h; - info.glReadySemaphoreFd = glReadyFd; - info.vkReadySemaphoreFd = vkReadyFd; -#endif - - // Transition the shared image to TRANSFER_SRC optimal initially - VkCommandBuffer cb = m_vkCommandBuffers[0]; - vkResetCommandBuffer(cb, 0); - - VkCommandBufferBeginInfo beginInfo = {}; - beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cb, &beginInfo); - - imageBarrier(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, - VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - - vkEndCommandBuffer(cb); - - VkSubmitInfo submitInfo = {}; - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - submitInfo.commandBufferCount = 1; - submitInfo.pCommandBuffers = &cb; - - vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); - VkResult layoutSubmitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); - if (layoutSubmitResult != VK_SUCCESS) - { - if (layoutSubmitResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - cleanupSharedImage(slot); - return nullptr; - } - vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); - - // Signal vkReady initially so GL can start writing to it - VkSubmitInfo signalInfo = {}; - signalInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - signalInfo.signalSemaphoreCount = 1; - signalInfo.pSignalSemaphores = &m_shared[slot].vkReady; - VkResult signalResult = vkQueueSubmit(m_vkQueue, 1, &signalInfo, VK_NULL_HANDLE); - if (signalResult != VK_SUCCESS) - { - if (signalResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - cleanupSharedImage(slot); - return nullptr; - } - - // Commit the new capacity only now that the (re)build fully succeeded. - m_shared[slot].capacityW = capW; - m_shared[slot].capacityH = capH; - - return &info; - } - - //-------------------------------------------------------------------------- - // presentSharedImage - //-------------------------------------------------------------------------- - - void VulkanView::presentSharedImage() - { - const uint32_t slot = m_currentFrame; - const SharedImageInfo& info = m_shared[slot].info; - - if (!m_vkDevice || !m_shared[slot].image || !m_vkSwapchain) - { - return; - } - - // Start-of-frame throttle: wait for this slot's previous frame to finish - // before reusing its acquire semaphore and per-frame resources. This - // replaces the old end-of-frame block; with FIFO acquire back-pressure it - // is what paces the loop to display refresh while still allowing - // FRAMES_IN_FLIGHT frames outstanding. - vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); - - // Acquire image - uint32_t imageIndex; - VkResult result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, std::numeric_limits::max(), - m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, &imageIndex); - if (result == VK_ERROR_OUT_OF_DATE_KHR) - { - // The GL side already signaled glReady[slot]/waited vkReady[slot] this - // frame; rebalance the pair before bailing so the next frame on this - // slot can't desync. - drainSharedSemaphores(slot); - handleSwapchainOutOfDate(); - return; - } - if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) - { - drainSharedSemaphores(slot); - if (result == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - - // If this swapchain image is still owned by another in-flight frame, wait - // for that frame's fence before rendering into it, then mark the image as - // now owned by this frame. - if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) - { - vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); - } - m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; - - // Reset the frame fence only now, right before the submit that re-signals it. - vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); - - VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; - vkResetCommandBuffer(cb, 0); - - VkCommandBufferBeginInfo beginInfo = {}; - beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cb, &beginInfo); - - // Transition shared image from COLOR_ATTACHMENT_OPTIMAL to TRANSFER_SRC_OPTIMAL - imageBarrier(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, - VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - - // Transition swapchain image to transfer dst - imageBarrier(cb, m_vkSwapchainImages[imageIndex], 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); - - // Transfer the shared image (always A2B10G10R10, == GL_RGB10_A2) to the - // swapchain image. When the swapchain is also A2B10G10R10 the layouts - // match and a raw copy is correct and cheapest. When the swapchain is - // A2R10G10B10 a raw copy would swap red and blue, so use a blit instead: - // vkCmdBlitImage converts per component (R->R, G->G, B->B) between the - // two formats. Whether the (linear-tiled) shared image can be a blit - // source is checked at shared-image creation; if not, that path is - // refused and syncBuffers() uses the CPU fallback instead. - if (m_vkSwapchainFormat == VK_FORMAT_A2B10G10R10_UNORM_PACK32) - { - VkImageCopy region = {}; - region.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - region.srcSubresource.layerCount = 1; - region.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - region.dstSubresource.layerCount = 1; - region.extent = {static_cast(info.width), static_cast(info.height), 1}; - - vkCmdCopyImage(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], - VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); - } - else - { - VkImageBlit blit = {}; - blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - blit.srcSubresource.layerCount = 1; - blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - blit.dstSubresource.layerCount = 1; - blit.srcOffsets[0] = {0, 0, 0}; - blit.srcOffsets[1] = {info.width, info.height, 1}; - blit.dstOffsets[0] = {0, 0, 0}; - blit.dstOffsets[1] = {info.width, info.height, 1}; - - vkCmdBlitImage(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], - VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST); - } - - // Transition swapchain image to present - imageBarrier(cb, m_vkSwapchainImages[imageIndex], 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); - - vkEndCommandBuffer(cb); - - // Submit - VkSubmitInfo submitInfo = {}; - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - - // Wait for GL to finish writing (glReady) AND swapchain image to be available - const std::array waitSemaphores = {m_shared[slot].glReady, m_frameSync[slot].imageAvailable}; - const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT, - VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; - submitInfo.waitSemaphoreCount = static_cast(waitSemaphores.size()); - submitInfo.pWaitSemaphores = waitSemaphores.data(); - submitInfo.pWaitDstStageMask = waitStages.data(); - - submitInfo.commandBufferCount = 1; - submitInfo.pCommandBuffers = &cb; - - // Signal the image's renderFinished (present waits on it) AND vkReady (so - // GL can write the next frame into this slot's shared image). - const std::array signalSemaphores = {m_vkRenderFinished[imageIndex], m_shared[slot].vkReady}; - submitInfo.signalSemaphoreCount = static_cast(signalSemaphores.size()); - submitInfo.pSignalSemaphores = signalSemaphores.data(); - - VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); - if (submitResult != VK_SUCCESS) - { - if (submitResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - - // The frame is committed to the GPU; advance the ring now so the next - // frame uses the other slot. imageIndex/slot below are locals, so this is - // safe before the present call. - m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; - - // Present, waiting on the image's own renderFinished semaphore. - VkPresentInfoKHR presentInfo = {}; - presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; - presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; - const std::array swapchains = {m_vkSwapchain}; - presentInfo.swapchainCount = static_cast(swapchains.size()); - presentInfo.pSwapchains = swapchains.data(); - presentInfo.pImageIndices = &imageIndex; - - VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); - // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and - // can be reported persistently by some X11/RADV compositors; recreating - // on it every frame caused a swapchain-recreate loop that starved the Qt - // event loop (dead input, no fullscreen). Real resizes report OUT_OF_DATE. - // The submit above is tracked by m_frameSync[slot].fence (waited at the start of - // the next use of this slot), so no end-of-frame block is needed here. - if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) - { - handleSwapchainOutOfDate(); - return; - } - if (presentResult != VK_SUCCESS) - { - if (presentResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - } - - //-------------------------------------------------------------------------- - // presentPixelData - //-------------------------------------------------------------------------- - - void VulkanView::presentPixelData(const void* pixels, int w, int h) - { - const uint32_t slot = m_currentFrame; - - if (!m_vkDevice) - { - return; - } - - if (!m_vkSwapchain || m_vkSwapchainExtent.width != static_cast(w) - || m_vkSwapchainExtent.height != static_cast(h)) - { - // Warm recreate via oldSwapchain (createSwapchain retires the old one). - if (!createSwapchain()) - { - return; - } - } - - // Start-of-frame throttle (matches presentSharedImage): wait for this - // slot's previous frame to finish before reusing its staging buffer, - // acquire semaphore and command resources. - vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); - - size_t size = w * h * 4; - - // Recreate this slot's staging buffer if needed - if (size > m_staging[slot].size) - { - if (m_staging[slot].buffer) - { - vkDestroyBuffer(m_vkDevice, m_staging[slot].buffer, nullptr); - } - if (m_staging[slot].memory) - { - vkFreeMemory(m_vkDevice, m_staging[slot].memory, nullptr); - } - - VkBufferCreateInfo bufferInfo = {}; - bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; - bufferInfo.size = size; - bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; - bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - vkCreateBuffer(m_vkDevice, &bufferInfo, nullptr, &m_staging[slot].buffer); - - VkMemoryRequirements memRequirements; - vkGetBufferMemoryRequirements(m_vkDevice, m_staging[slot].buffer, &memRequirements); - - VkMemoryAllocateInfo allocInfo = {}; - allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - allocInfo.allocationSize = memRequirements.size; - const std::optional memoryTypeIndex = - findMemoryType(m_vkPhysicalDevice, memRequirements.memoryTypeBits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); - if (!memoryTypeIndex) - { - cerr << "ERROR: VulkanView: No host-visible memory type for staging buffer" << endl; - return; - } - allocInfo.memoryTypeIndex = *memoryTypeIndex; - - vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_staging[slot].memory); - vkBindBufferMemory(m_vkDevice, m_staging[slot].buffer, m_staging[slot].memory, 0); - - m_staging[slot].size = size; - } - - // Copy to staging buffer - void* data; - vkMapMemory(m_vkDevice, m_staging[slot].memory, 0, size, 0, &data); - memcpy(data, pixels, size); - vkUnmapMemory(m_vkDevice, m_staging[slot].memory); - - // Acquire image - uint32_t imageIndex; - VkResult result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, std::numeric_limits::max(), - m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, &imageIndex); - if (result == VK_ERROR_OUT_OF_DATE_KHR) - { - handleSwapchainOutOfDate(); - return; - } - if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) - { - if (result == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - - // If this swapchain image is still owned by another in-flight frame, wait - // for its fence, then mark it owned by this frame. - if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) - { - vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); - } - m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; - - vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); - - VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; - vkResetCommandBuffer(cb, 0); - - VkCommandBufferBeginInfo beginInfo = {}; - beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cb, &beginInfo); - - // Transition image to transfer dst - imageBarrier(cb, m_vkSwapchainImages[imageIndex], 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); - - // Copy buffer to image - VkBufferImageCopy region = {}; - region.bufferOffset = 0; - region.bufferRowLength = 0; - region.bufferImageHeight = 0; - region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - region.imageSubresource.mipLevel = 0; - region.imageSubresource.baseArrayLayer = 0; - region.imageSubresource.layerCount = 1; - region.imageOffset = {0, 0, 0}; - region.imageExtent = {static_cast(w), static_cast(h), 1}; - - vkCmdCopyBufferToImage(cb, m_staging[slot].buffer, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, - ®ion); - - // Transition image to present - imageBarrier(cb, m_vkSwapchainImages[imageIndex], 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); - - vkEndCommandBuffer(cb); - - // Submit - VkSubmitInfo submitInfo = {}; - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - const std::array waitSemaphores = {m_frameSync[slot].imageAvailable}; - const std::array waitStages = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; - submitInfo.waitSemaphoreCount = static_cast(waitSemaphores.size()); - submitInfo.pWaitSemaphores = waitSemaphores.data(); - submitInfo.pWaitDstStageMask = waitStages.data(); - submitInfo.commandBufferCount = 1; - submitInfo.pCommandBuffers = &cb; - const std::array signalSemaphores = {m_vkRenderFinished[imageIndex]}; - submitInfo.signalSemaphoreCount = static_cast(signalSemaphores.size()); - submitInfo.pSignalSemaphores = signalSemaphores.data(); - - VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); - if (submitResult != VK_SUCCESS) - { - if (submitResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - - // Frame committed; advance the ring (imageIndex/slot below are locals). - m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; - - // Present, waiting on the image's own renderFinished semaphore. - VkPresentInfoKHR presentInfo = {}; - presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; - presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = signalSemaphores.data(); - const std::array swapchains = {m_vkSwapchain}; - presentInfo.swapchainCount = static_cast(swapchains.size()); - presentInfo.pSwapchains = swapchains.data(); - presentInfo.pImageIndices = &imageIndex; - - VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); - // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and - // can be reported persistently by some X11/RADV compositors; recreating - // on it every frame caused a swapchain-recreate loop that starved the Qt - // event loop (dead input, no fullscreen). Real resizes report OUT_OF_DATE. - // The submit is tracked by m_frameSync[slot].fence (waited at the next use of this - // slot), so no end-of-frame block is needed here. - if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) - { - handleSwapchainOutOfDate(); - return; - } - if (presentResult != VK_SUCCESS) - { - if (presentResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - } - - //-------------------------------------------------------------------------- - - void VulkanView::requestUpdate() - { - if (m_stopProcessingEvents) - { - return; - } - // Coalesce: only queue a render if one isn't already pending. The flag is - // cleared at the start of render(), so a resize arriving mid-render - // schedules exactly one follow-up render at the newest size. - if (m_updatePending) - return; - m_updatePending = true; - QCoreApplication::postEvent(this, new QEvent(QEvent::UpdateRequest)); - } - - void VulkanView::render() - { - m_updatePending = false; - - if (m_stopProcessingEvents) - { - return; - } - - IPCore::Session* session = m_doc ? m_doc->session() : nullptr; - if (!session) - return; - - if (m_doc && session && m_videoDevice) - { - m_videoDevice->makeCurrent(); - - if (m_userActive && m_activityTimer.elapsed() > 1.0) - { - if (m_doc->mainPopup() && !m_doc->mainPopup()->isVisible() && m_eventWidget && m_eventWidget->hasFocus()) - { - TwkApp::ActivityChangeEvent aevent("user-inactive", m_videoDevice.get()); - m_videoDevice->sendEvent(aevent); - m_userActive = false; - } - } - - int x = 0, y = 0; - absolutePosition(x, y); - m_videoDevice->setAbsolutePosition(x, y); - - session->render(); - - if (!m_postFirstNonEmptyRender && session->postFirstNonEmptyRender()) - { - m_postFirstNonEmptyRender = true; - if (!session->isFullScreen()) - { - m_doc->resizeToFit(false, false); - m_doc->center(); - } - } - - m_firstPaintCompleted = true; - } - - if (m_stopProcessingEvents) - { - return; - } - - if (session) - { - if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) - { - session->outputVideoDevice()->syncBuffers(); - } - else - { - m_videoDevice->syncBuffers(); - } - } - - if (session) - { - session->addSyncSample(); - session->postRender(); - } - - m_eventProcessingTimer.start(); - } - - //-------------------------------------------------------------------------- - // QWidget overrides - //-------------------------------------------------------------------------- - - void VulkanView::showEvent(QShowEvent* event) - { - if (!m_initialized) - initialize(); - requestUpdate(); - QWidget::showEvent(event); - } - - void VulkanView::resizeEvent(QResizeEvent* event) - { - if (m_doc) - m_doc->viewSizeChanged(event->size().width(), event->size().height()); - QWidget::resizeEvent(event); - - // WA_PaintOnScreen means Qt won't repaint this native surface on resize, - // so drive a render now to recreate the swapchain at the new size and - // present immediately (instead of waiting for a mouse Enter event). - // Coalesced so a fast drag doesn't queue one heavy recreate per event. - if (!m_stopProcessingEvents) - { - requestUpdate(); - } - } - - void VulkanView::paintEvent(QPaintEvent* event) - { - if (m_stopProcessingEvents) - { - return; - } - - if (m_doc && m_doc->session() && m_doc->session()->outputVideoDevice()) - { - m_doc->session()->outputVideoDevice()->syncBuffers(); - } - else if (m_videoDevice) - { - m_videoDevice->syncBuffers(); - } - } - - //-------------------------------------------------------------------------- - // eventProcessingTimeout slot - //-------------------------------------------------------------------------- - - void VulkanView::eventProcessingTimeout() - { - if (m_doc && m_doc->session()) - m_doc->session()->userGenericEvent("per-render-event-processing", ""); - } - - //-------------------------------------------------------------------------- - // event() - //-------------------------------------------------------------------------- - - bool VulkanView::event(QEvent* event) - { - bool keyevent = false; - Rv::Session* session = m_doc ? m_doc->session() : nullptr; - - if (m_stopProcessingEvents) - { - event->accept(); - return true; - } - - if (event->type() == QEvent::WindowActivate) - m_activationTimer.start(); - - float activationTime = 0.0f; - if (m_activationTimer.isRunning()) - { - if (event->type() == QEvent::MouseButtonPress) - { - activationTime = m_activationTimer.elapsed(); - m_activationTimer.stop(); - } - if (event->type() == QEvent::MouseMove) - m_activationTimer.stop(); - } - - if (event->type() != QEvent::Paint) - { - m_activityTimer.stop(); - m_activityTimer.start(); - - if (!m_userActive) - { - TwkApp::ActivityChangeEvent aevent("user-active", m_videoDevice.get()); - m_userActive = true; - m_videoDevice->sendEvent(aevent); - } - } - - if (QKeyEvent* kevent = dynamic_cast(event)) - { - keyevent = true; - if (m_lastKey == kevent->key() - && (m_lastKeyType == QEvent::ShortcutOverride && (kevent->type() == QEvent::KeyPress) || (m_lastKeyType == kevent->type()))) - { - m_lastKey = kevent->key(); - m_lastKeyType = kevent->type(); - event->accept(); - return true; - } - m_lastKeyType = kevent->type(); - m_lastKey = kevent->key(); - } - - switch (event->type()) - { - case QEvent::FocusIn: - m_videoDevice->translator().resetModifiers(); - // fall-through - case QEvent::Enter: - if (m_eventWidget) - m_eventWidget->setFocus(Qt::MouseFocusReason); - break; - default: - break; - } - - if (event->type() == QEvent::Resize) - { - QResizeEvent* e = static_cast(event); - if (!isVisible()) - { - return true; - } - if (e->oldSize().width() != -1 && e->oldSize().height() != -1) - { - ostringstream contents; - contents << e->oldSize().width() << " " << e->oldSize().height() << "|" << e->size().width() << " " << e->size().height(); - if (m_doc && session) - session->userGenericEvent("view-resized", contents.str()); - } - return QWidget::event(event); - } - - if (event->type() == QEvent::UpdateRequest) - { - render(); - return true; - } - - if (!m_videoDevice || !m_videoDevice->hasTranslator()) - { - return QWidget::event(event); - } - - auto resetTranslator = [this]() - { - m_videoDevice->translator().setScaleAndOffset(0, 0, 1.0f, 1.0f); - m_videoDevice->translator().setRelativeDomain(width(), height()); - }; - - if (session && session->outputVideoDevice() - && session->outputVideoDevice()->displayMode() == TwkApp::VideoDevice::MirrorDisplayMode) - { - if (const TwkApp::VideoDevice* cdv = session->controlVideoDevice()) - { - const TwkApp::VideoDevice* odv = session->outputVideoDevice(); - if (odv && cdv != odv && cdv == videoDevice()) - { - const float w = static_cast(width()); - const float h = static_cast(height()); - const float ow = static_cast(odv->width()); - const float oh = static_cast(odv->height()); - const float aspect = w / h; - const float oaspect = ow / oh; - - m_videoDevice->translator().setRelativeDomain(ow, oh); - - if (aspect >= oaspect) - { - const float yscale = oh / h; - const float yoffset = 0.0f; - const float xscale = yscale; - const float xoffset = -(w * yscale - ow) / 2.0f; - m_videoDevice->translator().setScaleAndOffset(xoffset, yoffset, xscale, yscale); - } - else - { - const float xscale = ow / w; - const float xoffset = 0.0f; - const float yscale = xscale; - const float yoffset = -(xscale * h - oh) / 2.0f; - m_videoDevice->translator().setScaleAndOffset(xoffset, yoffset, xscale, yscale); - } - } - else - { - resetTranslator(); - } - } - else - { - resetTranslator(); - } - } - else - { - resetTranslator(); - } - - if (session) - session->setEventVideoDevice(videoDevice()); - - if (m_videoDevice->translator().sendQTEvent(event, activationTime)) - { - event->accept(); - return true; - } - else - { - return QWidget::event(event); - } - } - - //-------------------------------------------------------------------------- - // eventFilter() - //-------------------------------------------------------------------------- - - bool VulkanView::eventFilter(QObject* object, QEvent* event) - { - if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease || event->type() == QEvent::Shortcut - || event->type() == QEvent::ShortcutOverride) - { - if (QKeyEvent* kevent = dynamic_cast(event)) - { - if (m_lastKey == kevent->key() - && (m_lastKeyType == QEvent::ShortcutOverride && (kevent->type() == QEvent::KeyPress) - || (m_lastKeyType == kevent->type()))) - { - m_lastKey = kevent->key(); - m_lastKeyType = kevent->type(); - event->accept(); - return true; - } - m_lastKeyType = kevent->type(); - m_lastKey = kevent->key(); - } - - Session* session = m_doc ? m_doc->session() : nullptr; - if (session) - { - session->setEventVideoDevice(videoDevice()); - if (m_videoDevice->translator().sendQTEvent(event)) - { - event->accept(); - return true; - } - } - - event->accept(); - return true; - } - - return false; - } + bool VulkanView::supports10BitPresentation() { return VulkanWindow::supports10BitPresentation(); } } // namespace Rv diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp new file mode 100644 index 000000000..9f666e7fc --- /dev/null +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -0,0 +1,2302 @@ +// +// Copyright (c) 2026 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#ifdef PLATFORM_WINDOWS +// WIN32_LEAN_AND_MEAN prevents from including the legacy +// , which otherwise collides with the already +// pulled in transitively by Qt headers above. +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#endif + +namespace Rv +{ + using namespace std; + using namespace TwkApp; + using namespace IPCore; + + // Both A2B10G10R10 and A2R10G10B10 are 10-bit-per-channel packed formats; + // they differ only in R/B component order. Both are acceptable for 10-bit + // presentation -- the R/B order is handled where pixels are packed (CPU + // fallback) or blitted (GPU interop). A2B10G10R10 (== GL_RGB10_A2) is + // preferred when the surface offers it, but many Linux/RADV surfaces only + // advertise A2R10G10B10. + static bool isTenBitFormat(VkFormat f) { return f == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || f == VK_FORMAT_A2R10G10B10_UNORM_PACK32; } + + static const char* formatName(VkFormat f) + { + switch (f) + { + case VK_FORMAT_B8G8R8A8_UNORM: + return "B8G8R8A8_UNORM"; + case VK_FORMAT_B8G8R8A8_SRGB: + return "B8G8R8A8_SRGB"; + case VK_FORMAT_R8G8B8A8_UNORM: + return "R8G8B8A8_UNORM"; + case VK_FORMAT_R8G8B8A8_SRGB: + return "R8G8B8A8_SRGB"; + case VK_FORMAT_A2B10G10R10_UNORM_PACK32: + return "A2B10G10R10_UNORM_PACK32"; + case VK_FORMAT_A2R10G10B10_UNORM_PACK32: + return "A2R10G10B10_UNORM_PACK32"; + case VK_FORMAT_R16G16B16A16_SFLOAT: + return "R16G16B16A16_SFLOAT"; + default: + return "(other)"; + } + } + + //-------------------------------------------------------------------------- + // VulkanWindow implementation + //-------------------------------------------------------------------------- + + VulkanWindow::VulkanWindow(RvDocument* doc, bool noResize) + : QWindow() + , m_doc(doc) + , m_videoDevice(nullptr) + , m_initialized(false) + , m_firstPaintCompleted(false) + , m_postFirstNonEmptyRender(noResize) + , m_stopProcessingEvents(false) + , m_userActive(true) + , m_eventWidget(nullptr) + , m_lastKey(0) + , m_lastKeyType(QEvent::None) + { + // + // This is a real QWindow, so the surface type and format can simply be + // declared here -- no WA_NativeWindow / WA_PaintOnScreen dance, and no + // waiting for a widget to grow a windowHandle(). + // + setSurfaceType(QSurface::VulkanSurface); + + QSurfaceFormat fmt; + fmt.setRedBufferSize(10); + fmt.setGreenBufferSize(10); + fmt.setBlueBufferSize(10); + fmt.setAlphaBufferSize(2); + setFormat(fmt); + + m_activityTimer.start(); + + m_eventProcessingTimer.setSingleShot(true); + connect(&m_eventProcessingTimer, SIGNAL(timeout()), this, SLOT(eventProcessingTimeout())); + } + + VulkanWindow::~VulkanWindow() + { + // + // m_videoDevice is owned by the hosting VulkanView, not by this window + // (see setVideoDevice); only the Vulkan resources are torn down here. + // + m_videoDevice = nullptr; + + // + // Normally a no-op: the QEvent::PlatformSurface / SurfaceAboutToBeDestroyed + // handler in event() has already released everything, because by the time + // a QWindow reaches its destructor its surface is usually gone. This is + // only the backstop for the paths where the window is deleted without + // ever having had a platform window destroyed under it. + // + releaseVulkanResources(); + } + + //-------------------------------------------------------------------------- + + void VulkanWindow::stopProcessingEvents() { m_stopProcessingEvents = true; } + + void VulkanWindow::absolutePosition(int& x, int& y) const + { + QPoint gp = mapToGlobal(QPoint(0, 0)); + x = gp.x(); + y = gp.y(); + } + + float VulkanWindow::devicePixelRatioF() const { return static_cast(QWindow::devicePixelRatio()); } + + //-------------------------------------------------------------------------- + // Vulkan Initialisation + //-------------------------------------------------------------------------- + + void VulkanWindow::initialize() + { + if (m_initialized) + { + return; + } + + if (!initVulkan()) + { + cerr << "ERROR: VulkanWindow: initVulkan failed; falling back to OpenGL" << endl; + requestGLFallback(); + return; + } + + m_initialized = true; + m_initializedHandle = handle(); + + // + // NOTE: session initialization is deliberately NOT driven from here. + // Loading packages creates web panels, and adding a QWebEngineView + // makes Qt tear down the main window's native subtree -- destroying + // this view while this method is still on the stack, so every later + // member access is a use-after-free. + // RvApplication::newSessionFromFiles() calls + // RvDocument::initializeSession() after show() instead, with no view + // callback in the call chain. + // + } + + bool VulkanWindow::supports10BitPresentation() + { + QVulkanInstance qtVkInst; + if (!qtVkInst.create()) + { + cerr << "ERROR: VulkanWindow: supports10BitPresentation: QVulkanInstance create failed" << endl; + return false; + } + + VkInstance instance = qtVkInst.vkInstance(); + if (instance == VK_NULL_HANDLE) + { + return false; + } + + QWindow dummyWindow; + dummyWindow.setSurfaceType(QSurface::VulkanSurface); + dummyWindow.create(); + dummyWindow.setVulkanInstance(&qtVkInst); + + VkSurfaceKHR dummySurface = qtVkInst.surfaceForWindow(&dummyWindow); + if (!dummySurface) + { + cerr << "ERROR: VulkanWindow: supports10BitPresentation: failed to create dummy surface" << endl; + return false; + } + + uint32_t deviceCount = 0; + vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); + if (deviceCount == 0) + { + cerr << "ERROR: VulkanWindow: supports10BitPresentation: vkEnumeratePhysicalDevices returned 0 devices" << endl; + return false; + } + std::vector devices(deviceCount); + vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data()); + + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: supports10BitPresentation: probing " << deviceCount << " physical device(s)" << endl; + } + + bool any10bit = false; + for (uint32_t di = 0; di < devices.size(); ++di) + { + VkPhysicalDevice dev = devices[di]; + + uint32_t formatCount = 0; + if (vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + { + continue; + } + std::vector formats(formatCount); + vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, formats.data()); + + bool has10bit = false; + for (const auto& fmt : formats) + { + if (isTenBitFormat(fmt.format)) + { + has10bit = true; + any10bit = true; + break; + } + } + + VkPhysicalDeviceProperties props = {}; + vkGetPhysicalDeviceProperties(dev, &props); + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: device[" << di << "] '" << props.deviceName + << "': 10-bit surface format=" << (has10bit ? "YES" : "NO") << endl; + } + } + + // The surface returned by surfaceForWindow() is owned by the platform + // integration and is released when dummyWindow is destroyed on return; + // QVulkanInstance has no destroySurface() in this Qt version. + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: supports10BitPresentation: returning " << (any10bit ? "true" : "false") << endl; + } + return any10bit; + } + + bool VulkanWindow::initVulkan() + { + // Create Instance + VkApplicationInfo appInfo = {}; + appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + appInfo.pApplicationName = "RV VulkanWindow"; + appInfo.apiVersion = VK_API_VERSION_1_1; + + // Need surface extensions + std::vector instanceExtensions = { + VK_KHR_SURFACE_EXTENSION_NAME, +#if defined(VK_USE_PLATFORM_WIN32_KHR) + VK_KHR_WIN32_SURFACE_EXTENSION_NAME, +#elif defined(VK_USE_PLATFORM_XLIB_KHR) + VK_KHR_XLIB_SURFACE_EXTENSION_NAME, +#elif defined(VK_USE_PLATFORM_WAYLAND_KHR) + VK_KHR_WAYLAND_SURFACE_EXTENSION_NAME, +#elif defined(VK_USE_PLATFORM_XCB_KHR) + VK_KHR_XCB_SURFACE_EXTENSION_NAME, +#endif + }; + + // Try to get Qt's extensions + static QVulkanInstance* qtVkInst = nullptr; + if (!qtVkInst) + { + qtVkInst = new QVulkanInstance(); + // The dedicated-allocation query in getSharedImageInfo() uses + // vkGetImageMemoryRequirements2, which is core in Vulkan 1.1. + // QVulkanInstance otherwise creates a 1.0 instance, which would put + // that call out of contract. + qtVkInst->setApiVersion(QVersionNumber(1, 1)); + if (!qtVkInst->create()) + { + cerr << "ERROR: VulkanWindow: QVulkanInstance create failed" << endl; + delete qtVkInst; + qtVkInst = nullptr; + return false; + } + } + + m_vkInstance = qtVkInst->vkInstance(); + + // Create Surface. The platform window must exist before Qt can hand out + // a VkSurfaceKHR for it; VulkanView::create()s it up front, but + // re-creating after a reparent can land here first. + if (!handle()) + { + create(); + } + + setVulkanInstance(qtVkInst); + + m_vkSurface = qtVkInst->surfaceForWindow(this); + if (!m_vkSurface) + { + cerr << "ERROR: VulkanWindow: Failed to create Vulkan surface" << endl; + return false; + } + + // Pick Physical Device + uint32_t deviceCount = 0; + vkEnumeratePhysicalDevices(m_vkInstance, &deviceCount, nullptr); + if (deviceCount == 0) + { + return false; + } + std::vector devices(deviceCount); + vkEnumeratePhysicalDevices(m_vkInstance, &deviceCount, devices.data()); + + m_vkPhysicalDevice = VK_NULL_HANDLE; + bool foundQueue = false; + m_queueFamilyIndex = 0; + + for (VkPhysicalDevice dev : devices) + { + uint32_t queueFamilyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(dev, &queueFamilyCount, nullptr); + std::vector queueFamilies(queueFamilyCount); + vkGetPhysicalDeviceQueueFamilyProperties(dev, &queueFamilyCount, queueFamilies.data()); + + for (uint32_t i = 0; i < queueFamilyCount; i++) + { + VkBool32 presentSupport = false; + vkGetPhysicalDeviceSurfaceSupportKHR(dev, i, m_vkSurface, &presentSupport); + if ((queueFamilies[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) + { + m_vkPhysicalDevice = dev; + m_queueFamilyIndex = i; + foundQueue = true; + break; + } + } + if (foundQueue) + break; + } + + if (!foundQueue) + { + cerr << "ERROR: VulkanWindow: initVulkan: No physical device with graphics and present support found." << endl; + return false; + } + + { + // Unconditional: runs once per window init, and pairing this name + // against QTVulkanVideoDevice's GL_RENDERER is how a hybrid-GPU + // machine (GL on the iGPU, Vulkan on the dGPU) is spotted from a + // plain QA log. + VkPhysicalDeviceProperties props = {}; + vkGetPhysicalDeviceProperties(m_vkPhysicalDevice, &props); + cout << "INFO: VulkanWindow: initVulkan: picked physical device '" << props.deviceName << "' (of " << deviceCount + << " available)" << endl; + } + + // Create Logical Device + float queuePriority = 1.0f; + VkDeviceQueueCreateInfo queueCreateInfo = {}; + queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + queueCreateInfo.queueFamilyIndex = m_queueFamilyIndex; + queueCreateInfo.queueCount = 1; + queueCreateInfo.pQueuePriorities = &queuePriority; + + std::vector deviceExtensions = { + VK_KHR_SWAPCHAIN_EXTENSION_NAME, +#ifdef PLATFORM_WINDOWS + VK_KHR_EXTERNAL_MEMORY_WIN32_EXTENSION_NAME, + VK_KHR_EXTERNAL_SEMAPHORE_WIN32_EXTENSION_NAME, +#else + VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME, + VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME, +#endif + }; + + VkDeviceCreateInfo createInfo = {}; + createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + createInfo.pQueueCreateInfos = &queueCreateInfo; + createInfo.queueCreateInfoCount = 1; + createInfo.enabledExtensionCount = deviceExtensions.size(); + createInfo.ppEnabledExtensionNames = deviceExtensions.data(); + + if (vkCreateDevice(m_vkPhysicalDevice, &createInfo, nullptr, &m_vkDevice) != VK_SUCCESS) + { + return false; + } + + vkGetDeviceQueue(m_vkDevice, m_queueFamilyIndex, 0, &m_vkQueue); + + auto failInit = [this]() + { + cleanupVulkan(); + return false; + }; + + // Command pool + VkCommandPoolCreateInfo poolInfo = {}; + poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + poolInfo.queueFamilyIndex = m_queueFamilyIndex; + if (vkCreateCommandPool(m_vkDevice, &poolInfo, nullptr, &m_vkCommandPool) != VK_SUCCESS) + { + return failInit(); + } + + // Sync objects. The per-swapchain-image renderFinished semaphores live + // in createSwapchain (sized to the image count); here we create only the + // per-in-flight-slot acquire semaphores and frame fences. + VkSemaphoreCreateInfo semaphoreInfo = {}; + semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + + // Per-in-flight-slot acquire semaphore + frame fence. Fences are created + // signaled so the first wait on a slot passes without a prior submit. + VkFenceCreateInfo fenceInfo = {}; + fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; + for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + { + if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &m_vkImageAvailableSemaphore[i]) != VK_SUCCESS) + { + return failInit(); + } + if (vkCreateFence(m_vkDevice, &fenceInfo, nullptr, &m_vkFence[i]) != VK_SUCCESS) + { + return failInit(); + } + } + + return true; + } + + // Queue fallbackVulkanToGLView on the next event-loop tick (at most once). + void VulkanWindow::requestGLFallback() + { + if (m_glFallbackRequested || !m_doc || m_stopProcessingEvents || m_doc->isClosing()) + { + return; + } + m_glFallbackRequested = true; + QTimer::singleShot(0, m_doc, [doc = m_doc]() { doc->fallbackVulkanToGLView(); }); + } + + // Skip swapchain work during close or zero-size resize. + bool VulkanWindow::presentationAllowed() const + { + if (m_stopProcessingEvents) + { + return false; + } + if (width() <= 0 || height() <= 0) + { + return false; + } + if (m_doc && m_doc->isClosing()) + { + return false; + } + return true; + } + + // Reset acquire semaphore and rebuild swapchain/shared image at the new size. + void VulkanWindow::handleSurfaceLost() + { + // + // The platform window was destroyed and recreated underneath us, so + // the VkSurfaceKHR (and everything derived from it) belongs to a + // window that no longer exists. Qt does this whenever the top-level + // QWidgetWindow is replaced -- inserting a QWebEngineView is the + // common trigger -- and VulkanView re-parents this window into the + // new one afterwards (see VulkanView::reattachVulkanWindow). + // + // Tear the Vulkan device down completely and re-initialize against the + // new handle. A swapchain recreate is not enough: the surface handle + // itself is stale, so vkGetPhysicalDeviceSurfaceCapabilitiesKHR and + // vkCreateSwapchainKHR would both be querying a dead object. + // + cout << "INFO: VulkanWindow: platform window recreated; rebuilding Vulkan surface" << endl; + + releaseVulkanResources(); + } + + // + // Release everything initVulkan()/createSwapchain()/getSharedImageInfo() + // built, in dependency order, and return to the pre-initialize() state so + // the next exposeEvent() re-initializes from scratch. + // + // Ordering constraint: every one of these destroy calls is made against + // objects the driver ties back to the presentation surface -- and the + // VkSurfaceKHR is only valid while the platform window that produced it + // lives. Callers must therefore reach here *before* the platform window is + // destroyed, not after (see the QEvent::PlatformSurface handler). + // + void VulkanWindow::releaseVulkanResources() + { + // + // The GL side imported this window's shared device memory and + // semaphores as GL memory objects; drop those first so nothing on the + // GL side is left aliasing memory freed just below. syncBuffers() + // re-imports on the next frame if the window comes back. + // + if (m_videoDevice) + { + m_videoDevice->releaseSharedGLObjects(); + } + + for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + { + cleanupSharedImage(i); + } + cleanupSwapchain(); + cleanupVulkan(); + + // Owned by the platform window / QVulkanInstance, never destroyed here; + // just forget it, since it does not outlive the window it came from. + m_vkSurface = VK_NULL_HANDLE; + m_vkPhysicalDevice = VK_NULL_HANDLE; + m_vkSwapchainFormat = VK_FORMAT_UNDEFINED; + m_vkSwapchainExtent = {}; + m_vkSwapchainImages.clear(); + m_currentFrame = 0; + + m_initialized = false; + m_initializedHandle = nullptr; + } + + void VulkanWindow::handleSwapchainOutOfDate() + { + if (!m_vkDevice || !presentationAllowed()) + { + return; + } + + VkSemaphoreCreateInfo semaphoreInfo = {}; + semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + { + if (m_vkImageAvailableSemaphore[i]) + { + vkDestroySemaphore(m_vkDevice, m_vkImageAvailableSemaphore[i], nullptr); + m_vkImageAvailableSemaphore[i] = VK_NULL_HANDLE; + } + if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &m_vkImageAvailableSemaphore[i]) != VK_SUCCESS) + { + m_vkImageAvailableSemaphore[i] = VK_NULL_HANDLE; + requestGLFallback(); + return; + } + } + + // Recreate only the swapchain, not the shared image. The shared image is + // a content-sized TRANSFER_SRC image, independent of the window-sized + // swapchain; createSwapchain() reuses the old swapchain (oldSwapchain) so + // this is a warm recreate. The next render()'s getSharedImageInfo() will + // rebuild the shared image only if the content size actually changed. + if (!createSwapchain()) + { + requestGLFallback(); + } + } + + void VulkanWindow::cleanupVulkan() + { + if (m_vkDevice) + { + vkDeviceWaitIdle(m_vkDevice); + + for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + { + if (m_vkImageAvailableSemaphore[i]) + { + vkDestroySemaphore(m_vkDevice, m_vkImageAvailableSemaphore[i], nullptr); + m_vkImageAvailableSemaphore[i] = VK_NULL_HANDLE; + } + if (m_vkFence[i]) + { + vkDestroyFence(m_vkDevice, m_vkFence[i], nullptr); + m_vkFence[i] = VK_NULL_HANDLE; + } + } + // m_vkRenderFinished are per-swapchain-image; freed in cleanupSwapchain. + + if (m_vkCommandPool) + { + vkDestroyCommandPool(m_vkDevice, m_vkCommandPool, nullptr); + m_vkCommandPool = VK_NULL_HANDLE; + } + + vkDestroyDevice(m_vkDevice, nullptr); + m_vkDevice = VK_NULL_HANDLE; + } + m_vkQueue = VK_NULL_HANDLE; + // Surface is managed by QVulkanInstance? We shouldn't destroy it here if QVulkanInstance owns it, but wait, we got it from + // surfaceForWindow. Actually QVulkanWindow destroys it. We can just leave it for QVulkanInstance to clean up, or we can + // vkDestroySurfaceKHR if needed. For safety we don't destroy instance/surface here, they are tied to Qt. + } + + bool VulkanWindow::createSwapchain() + { + if (!m_vkDevice || !m_vkSurface) + { + return false; + } + + if (!presentationAllowed()) + { + return false; + } + + VkSurfaceCapabilitiesKHR capabilities; + vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &capabilities); + + // Negotiate 10-bit format + uint32_t formatCount; + vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, nullptr); + std::vector formats(formatCount); + vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, formats.data()); + + // + // Unconditional but once per window: a handful of lines, and the pair + // that matters is not the format alone but which colour space each + // 10-bit entry is offered with, plus the order they come in. Vendor + // ordering differences in this exact list are what made a black + // NVIDIA viewport look like a working AMD one. + // + if (!m_loggedSurfaceFormatList) + { + m_loggedSurfaceFormatList = true; + + cout << "INFO: VulkanWindow: createSwapchain: surface offers " << formatCount << " format(s):" << endl; + for (uint32_t i = 0; i < formats.size(); ++i) + { + cout << "INFO: VulkanWindow: [" << i << "] format=" << formats[i].format << " (" << formatName(formats[i].format) + << ") colorSpace=" << formats[i].colorSpace << endl; + } + } + + VkSurfaceFormatKHR surfaceFormat = formats[0]; + bool found10bit = false; + // Prefer A2B10G10R10 (== GL_RGB10_A2, the layout the GPU interop shared + // texture and CPU fallback packing produce natively) so the transfer is + // a plain copy. If the surface only offers A2R10G10B10 (common on + // Linux/RADV), accept it too: the opposite R/B order is handled where + // pixels are packed (CPU fallback) and by a component-wise blit (GPU + // interop), so red and blue are not swapped. + // + // The colour space has to be matched as carefully as the format. A + // 10-bit format is commonly advertised more than once, paired with a + // different VkColorSpaceKHR each time, and the enumeration order is + // vendor-specific: with the display in HDR mode NVIDIA lists + // A2B10G10R10 + HDR10_ST2084 ahead of A2B10G10R10 + SRGB_NONLINEAR, + // while AMD lists SRGB_NONLINEAR first. Taking the first format match + // therefore gave NVIDIA a PQ swapchain fed with sRGB-encoded pixels, + // which crushes everything below mid-grey to a couple of nits -- the + // whole viewport, RV's own overlays included, reads as black. + // + // RV's renderer emits sRGB, so SRGB_NONLINEAR is the only correct + // pairing. Honouring an HDR colour space would mean re-encoding the + // shader output to that transfer function, which is a colour-pipeline + // change, not a swapchain choice. + // + const auto findTenBit = [&formats](VkFormat wanted, bool requireSrgbNonlinear, VkSurfaceFormatKHR& out) -> bool + { + for (const auto& fmt : formats) + { + if (fmt.format != wanted) + { + continue; + } + if (requireSrgbNonlinear && fmt.colorSpace != VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) + { + continue; + } + out = fmt; + return true; + } + return false; + }; + + for (const VkFormat wanted : {VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_A2R10G10B10_UNORM_PACK32}) + { + if (findTenBit(wanted, true, surfaceFormat)) + { + found10bit = true; + break; + } + } + + // No 10-bit format is paired with SRGB_NONLINEAR on this surface. Take + // the 10-bit format anyway rather than dropping to the 8-bit OpenGL + // path: the depth is what the user asked for, and the colour space is + // reported below so a wrong-looking image is traceable to it. + if (!found10bit) + { + for (const VkFormat wanted : {VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_A2R10G10B10_UNORM_PACK32}) + { + if (findTenBit(wanted, false, surfaceFormat)) + { + found10bit = true; + break; + } + } + } + + if (found10bit) + { + // Unconditional, and the colour space is part of it: the format + // alone was never enough to explain a black NVIDIA viewport. + // Latched, because createSwapchain() re-runs on every resize step. + if (surfaceFormat.format != m_loggedSurfaceFormat.format || surfaceFormat.colorSpace != m_loggedSurfaceFormat.colorSpace) + { + m_loggedSurfaceFormat = surfaceFormat; + + cout << "INFO: VulkanWindow: createSwapchain: chose " << formatName(surfaceFormat.format) + << " colorSpace=" << surfaceFormat.colorSpace + << (surfaceFormat.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR ? " (SRGB_NONLINEAR)" : " (NOT SRGB_NONLINEAR)") + << " (10-bit OK)" << endl; + + if (surfaceFormat.colorSpace != VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) + { + cout << "WARNING: VulkanWindow: no 10-bit surface format is paired with SRGB_NONLINEAR on this surface. RV emits " + "sRGB, so the image may look wrong (dark or washed out)." + << endl; + } + } + } + else + { + cout << "WARNING: VulkanWindow: Real surface lacks a 10-bit format (A2B10G10R10/A2R10G10B10); requesting OpenGL fallback" + << endl; + requestGLFallback(); + return false; + } + + m_vkSwapchainFormat = surfaceFormat.format; + + m_vkSwapchainExtent = capabilities.currentExtent; + if (m_vkSwapchainExtent.width == 0xFFFFFFFF) + { + m_vkSwapchainExtent = {(uint32_t)width(), (uint32_t)height()}; + } + if (m_vkSwapchainExtent.width == 0 || m_vkSwapchainExtent.height == 0) + { + requestGLFallback(); + return false; + } + + uint32_t imageCount = capabilities.minImageCount + 1; + if (capabilities.maxImageCount > 0 && imageCount > capabilities.maxImageCount) + { + imageCount = capabilities.maxImageCount; + } + + VkSwapchainCreateInfoKHR createInfo = {}; + createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; + createInfo.surface = m_vkSurface; + createInfo.minImageCount = imageCount; + createInfo.imageFormat = surfaceFormat.format; + createInfo.imageColorSpace = surfaceFormat.colorSpace; + createInfo.imageExtent = m_vkSwapchainExtent; + createInfo.imageArrayLayers = 1; + createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; + createInfo.preTransform = capabilities.currentTransform; + createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + createInfo.presentMode = VK_PRESENT_MODE_FIFO_KHR; // VSync + createInfo.clipped = VK_TRUE; + // Warm recreate: hand the retiring swapchain to the driver so it can reuse + // its backing resources (much cheaper than a cold create on every resize). + createInfo.oldSwapchain = m_vkSwapchain; + + // Create into a local handle so a failed create leaves the existing + // swapchain and command buffers intact (the fallback paths stay valid). + VkSwapchainKHR newSwapchain = VK_NULL_HANDLE; + if (vkCreateSwapchainKHR(m_vkDevice, &createInfo, nullptr, &newSwapchain) != VK_SUCCESS) + { + requestGLFallback(); + return false; + } + + // New swapchain is live. Retire the old one only now: wait for its last + // submitted frame to finish, free its command buffers, then destroy it. + if (m_vkSwapchain != VK_NULL_HANDLE) + { + vkDeviceWaitIdle(m_vkDevice); + if (!m_vkCommandBuffers.empty()) + { + vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, (uint32_t)m_vkCommandBuffers.size(), m_vkCommandBuffers.data()); + m_vkCommandBuffers.clear(); + } + vkDestroySwapchainKHR(m_vkDevice, m_vkSwapchain, nullptr); + } + m_vkSwapchain = newSwapchain; + + vkGetSwapchainImagesKHR(m_vkDevice, m_vkSwapchain, &imageCount, nullptr); + m_vkSwapchainImages.resize(imageCount); + vkGetSwapchainImagesKHR(m_vkDevice, m_vkSwapchain, &imageCount, m_vkSwapchainImages.data()); + + m_vkCommandBuffers.resize(imageCount); + VkCommandBufferAllocateInfo allocInfo = {}; + allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocInfo.commandPool = m_vkCommandPool; + allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocInfo.commandBufferCount = (uint32_t)m_vkCommandBuffers.size(); + if (vkAllocateCommandBuffers(m_vkDevice, &allocInfo, m_vkCommandBuffers.data()) != VK_SUCCESS) + { + cleanupSwapchain(); + requestGLFallback(); + return false; + } + + // Per-swapchain-image present-wait semaphores + in-flight fence map. The + // device is idle here (the retire path above waited on it), so any old + // renderFinished semaphores from a previous swapchain are safe to destroy. + for (VkSemaphore sem : m_vkRenderFinished) + { + if (sem) + vkDestroySemaphore(m_vkDevice, sem, nullptr); + } + m_vkRenderFinished.assign(imageCount, VK_NULL_HANDLE); + VkSemaphoreCreateInfo rfInfo = {}; + rfInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + for (uint32_t i = 0; i < imageCount; ++i) + { + if (vkCreateSemaphore(m_vkDevice, &rfInfo, nullptr, &m_vkRenderFinished[i]) != VK_SUCCESS) + { + cleanupSwapchain(); + requestGLFallback(); + return false; + } + } + // Fresh swapchain images: none are in flight yet. + m_imagesInFlight.assign(imageCount, VK_NULL_HANDLE); + + return true; + } + + void VulkanWindow::cleanupSwapchain() + { + if (m_vkDevice) + { + vkDeviceWaitIdle(m_vkDevice); + + for (VkSemaphore sem : m_vkRenderFinished) + { + if (sem) + vkDestroySemaphore(m_vkDevice, sem, nullptr); + } + m_vkRenderFinished.clear(); + m_imagesInFlight.clear(); + + for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + { + if (m_vkStagingBuffer[i]) + { + vkDestroyBuffer(m_vkDevice, m_vkStagingBuffer[i], nullptr); + m_vkStagingBuffer[i] = VK_NULL_HANDLE; + } + if (m_vkStagingBufferMemory[i]) + { + vkFreeMemory(m_vkDevice, m_vkStagingBufferMemory[i], nullptr); + m_vkStagingBufferMemory[i] = VK_NULL_HANDLE; + } + m_stagingBufferSize[i] = 0; + } + + if (!m_vkCommandBuffers.empty()) + { + vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, m_vkCommandBuffers.size(), m_vkCommandBuffers.data()); + m_vkCommandBuffers.clear(); + } + + if (m_vkSwapchain) + { + vkDestroySwapchainKHR(m_vkDevice, m_vkSwapchain, nullptr); + m_vkSwapchain = VK_NULL_HANDLE; + } + } + } + + // Helper to find memory type + uint32_t findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) + { + VkPhysicalDeviceMemoryProperties memProperties; + vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties); + for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) + { + if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) + { + return i; + } + } + return UINT32_MAX; + } + + namespace + { + bool isNvidiaPhysicalDevice(VkPhysicalDevice dev) + { + VkPhysicalDeviceProperties props = {}; + vkGetPhysicalDeviceProperties(dev, &props); + return props.vendorID == 0x10DE; + } + + bool nvidiaInteropWorkaroundDisabled() + { + static const bool disabled = getenv("RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND") != nullptr; + return disabled; + } + + // Reverts the shared image to a plain (non-dedicated) allocation, the + // behaviour before the dedicated-allocation query was added. The + // dedicated path is what the spec asks for and what a driver reporting + // requiresDedicatedAllocation needs for an externally-shared image, but + // it changes a code path that already worked, so keep a way to rule it + // out on a machine without a rebuild. + bool dedicatedAllocationDisabled() + { + static const bool disabled = getenv("RV_VULKAN_DISABLE_DEDICATED_ALLOCATION") != nullptr; + return disabled; + } + + // NVIDIA 550+ drivers return blank pixels to OpenGL for LINEAR shared + // images >= ~2 MiB (forum thread #349436). Allocating the shared image with + // VK_IMAGE_TILING_OPTIMAL avoids that broken linear path and restores + // correct zero-copy interop, so OPTIMAL is the default on NVIDIA. Both the + // GL and Vulkan sides here are the same NVIDIA driver/GPU, so the + // vendor-private optimal layout matches on import without needing explicit + // DRM-format-modifier negotiation. AMD/Intel keep the existing LINEAR path. + // Set RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND to revert NVIDIA to LINEAR + // (reproduces the blank-image bug, for debugging). + // + // The vendor decides this, not the platform: NVIDIA ships one driver core + // behind both GL and Vulkan on Windows as well as Linux, and the shared + // image is always well past the threshold because getSharedImageInfo() + // allocates at screen capacity -- ~8 MiB for a 1080p A2B10G10R10 image, + // ~33 MiB at 4K. The AMD rationale for LINEAR is specific to Mesa, where + // GL and Vulkan are separate drivers (radeonsi vs RADV) so + // GL_OPTIMAL_TILING_EXT carries no cross-driver layout guarantee. + bool useOptimalTilingForInterop(VkPhysicalDevice dev) + { +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + return !nvidiaInteropWorkaroundDisabled() && isNvidiaPhysicalDevice(dev); +#else + (void)dev; + return false; +#endif + } + } // namespace + + void VulkanWindow::cleanupSharedImage(uint32_t slot) + { + SharedImageInfo& info = m_sharedImageInfo[slot]; + + if (m_vkDevice) + { + vkDeviceWaitIdle(m_vkDevice); + + if (m_vkSharedImage[slot]) + { + vkDestroyImage(m_vkDevice, m_vkSharedImage[slot], nullptr); + m_vkSharedImage[slot] = VK_NULL_HANDLE; + } + if (m_vkSharedImageMemory[slot]) + { + vkFreeMemory(m_vkDevice, m_vkSharedImageMemory[slot], nullptr); + m_vkSharedImageMemory[slot] = VK_NULL_HANDLE; + } + if (m_vkGlReadySemaphore[slot]) + { + vkDestroySemaphore(m_vkDevice, m_vkGlReadySemaphore[slot], nullptr); + m_vkGlReadySemaphore[slot] = VK_NULL_HANDLE; + } + if (m_vkVkReadySemaphore[slot]) + { + vkDestroySemaphore(m_vkDevice, m_vkVkReadySemaphore[slot], nullptr); + m_vkVkReadySemaphore[slot] = VK_NULL_HANDLE; + } + } + +#ifdef PLATFORM_WINDOWS + if (info.memoryHandle) + { + ::CloseHandle(static_cast(info.memoryHandle)); + info.memoryHandle = nullptr; + } + if (info.glReadySemaphoreHandle) + { + ::CloseHandle(static_cast(info.glReadySemaphoreHandle)); + info.glReadySemaphoreHandle = nullptr; + } + if (info.vkReadySemaphoreHandle) + { + ::CloseHandle(static_cast(info.vkReadySemaphoreHandle)); + info.vkReadySemaphoreHandle = nullptr; + } +#else + if (info.memoryFd != -1) + { + ::close(info.memoryFd); + info.memoryFd = -1; + } + if (info.glReadySemaphoreFd != -1) + { + ::close(info.glReadySemaphoreFd); + info.glReadySemaphoreFd = -1; + } + if (info.vkReadySemaphoreFd != -1) + { + ::close(info.vkReadySemaphoreFd); + info.vkReadySemaphoreFd = -1; + } +#endif + info.width = 0; + info.height = 0; + info.size = 0; + info.capacityHeight = 0; + info.optimalTiling = 0; + m_sharedCapacityW[slot] = 0; + m_sharedCapacityH[slot] = 0; + } + + void VulkanWindow::drainSharedSemaphores(uint32_t slot) + { + // No shared image for this slot yet -> the GL side never signaled/waited + // its pair, so there is nothing to rebalance. + if (!m_vkDevice || !m_vkGlReadySemaphore[slot] || !m_vkVkReadySemaphore[slot]) + return; + + // Consume the pending glReady signal from the GL side and re-signal + // vkReady so the next use of this slot starts balanced (exactly what a + // normal present's submit would have done for the pair). + VkSubmitInfo drain = {}; + drain.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot]}; + VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT}; + drain.waitSemaphoreCount = 1; + drain.pWaitSemaphores = waitSemaphores; + drain.pWaitDstStageMask = waitStages; + drain.commandBufferCount = 0; + VkSemaphore signalSemaphores[] = {m_vkVkReadySemaphore[slot]}; + drain.signalSemaphoreCount = 1; + drain.pSignalSemaphores = signalSemaphores; + + VkResult r = vkQueueSubmit(m_vkQueue, 1, &drain, VK_NULL_HANDLE); + if (r == VK_ERROR_DEVICE_LOST) + requestGLFallback(); + } + + const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) + { + if (!m_vkDevice) + return nullptr; + + // Build/return the shared image for the current in-flight ring slot. + const uint32_t slot = m_currentFrame; + SharedImageInfo& info = m_sharedImageInfo[slot]; + + // The swapchain always tracks the window size, so recreate it on any size + // change. This is independent of the grow-only shared image below: a drag + // still recreates the (warm) swapchain each step, but no longer rebuilds + // or re-exports the shared image. + if (!m_vkSwapchain || m_vkSwapchainExtent.width != (uint32_t)w || m_vkSwapchainExtent.height != (uint32_t)h) + { + // Warm recreate via oldSwapchain (createSwapchain retires the old one). + if (!createSwapchain()) + return nullptr; + } + + // Grow-only: if the request fits the slot's current allocated capacity, + // reuse the existing image/export and just update the used sub-region + // (presentSharedImage copies/blits info.width x info.height from it). + if (m_vkSharedImage[slot] && w <= m_sharedCapacityW[slot] && h <= m_sharedCapacityH[slot]) + { + info.width = w; + info.height = h; + return &info; + } + + // Grow (or first allocation): rebuild at a capacity that is the + // componentwise max of the request, the screen size, and the current + // capacity, so it grows monotonically and the common drag-to-fullscreen + // case allocates at most once. + int screenW = 0; + int screenH = 0; + if (QScreen* scr = QGuiApplication::primaryScreen()) + { + const qreal dpr = scr->devicePixelRatio(); + screenW = static_cast(scr->geometry().width() * dpr); + screenH = static_cast(scr->geometry().height() * dpr); + } + const int capW = std::max({w, screenW, m_sharedCapacityW[slot]}); + const int capH = std::max({h, screenH, m_sharedCapacityH[slot]}); + + cleanupSharedImage(slot); + + // OPTIMAL tiling on NVIDIA (the fix for the blank large-image bug); LINEAR + // elsewhere. See useOptimalTilingForInterop(). + const bool optimalTiling = useOptimalTilingForInterop(m_vkPhysicalDevice); + + // Unconditional, and reported after the tiling decision so it can name + // it. The shared image is allocated at screen capacity, so this fires + // about once per ring slot per session rather than per resize, and the + // tiling it reports is exactly the fact that separates a working NVIDIA + // viewport from a black one. + cout << "INFO: VulkanWindow: getSharedImageInfo: (re)allocating shared image slot " << slot << " capacity " << capW << "x" << capH + << " for request " << w << "x" << h << " tiling=" << (optimalTiling ? "OPTIMAL" : "LINEAR") << endl; + + // 1. Create Shared Image + VkExternalMemoryImageCreateInfo extMemInfo = {}; + extMemInfo.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO; +#ifdef PLATFORM_WINDOWS + extMemInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; +#else + extMemInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; +#endif + + VkImageCreateInfo imageInfo = {}; + imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imageInfo.pNext = &extMemInfo; + imageInfo.imageType = VK_IMAGE_TYPE_2D; + // The shared image is imported into GL as GL_RGB10_A2, whose bit layout + // is A2B10G10R10, so the Vulkan side must use the matching format + // regardless of the swapchain format. When the swapchain is A2R10G10B10 + // the difference is reconciled by a component-wise blit in + // presentSharedImage() (not a raw copy). + imageInfo.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; // matches GL_RGB10_A2 + // Allocate at capacity; presentSharedImage transfers only the used + // info.width x info.height sub-region (anchored at origin 0,0). + imageInfo.extent = {(uint32_t)capW, (uint32_t)capH, 1}; + imageInfo.mipLevels = 1; + imageInfo.arrayLayers = 1; + imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imageInfo.tiling = optimalTiling ? VK_IMAGE_TILING_OPTIMAL : VK_IMAGE_TILING_LINEAR; + imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT; // Only used as transfer src in Vulkan + imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + if (vkCreateImage(m_vkDevice, &imageInfo, nullptr, &m_vkSharedImage[slot]) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to create shared image" << endl; + return nullptr; + } + + // When the swapchain format differs from the shared image format + // (A2B10G10R10), presentSharedImage() reconciles them with a blit rather + // than a raw copy. That requires the shared image to be a valid blit + // source and the swapchain image a valid blit destination. If the driver + // does not support that, refuse the GPU-interop path so syncBuffers() + // uses the (channel-correct) CPU fallback instead. + if (m_vkSwapchainFormat != VK_FORMAT_A2B10G10R10_UNORM_PACK32) + { + VkFormatProperties srcProps = {}; + VkFormatProperties dstProps = {}; + vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, VK_FORMAT_A2B10G10R10_UNORM_PACK32, &srcProps); + vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, m_vkSwapchainFormat, &dstProps); + // The shared image's blit-source support depends on its actual tiling + // (OPTIMAL on NVIDIA, LINEAR elsewhere). + const VkFormatFeatureFlags srcFeatures = optimalTiling ? srcProps.optimalTilingFeatures : srcProps.linearTilingFeatures; + const bool blitOk = + (srcFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT) && (dstProps.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT); + if (!blitOk) + { + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: GPU interop unavailable for " << formatName(m_vkSwapchainFormat) + << " swapchain (blit unsupported); using CPU fallback." << endl; + } + cleanupSharedImage(slot); + return nullptr; + } + } + + // rowPitch is only meaningful (and vkGetImageSubresourceLayout only valid) + // for LINEAR tiling. For OPTIMAL tiling the GL import uses the logical + // capacity width and lets the driver resolve the layout. + if (optimalTiling) + { + info.strideWidth = capW; + } + else + { + // Handle padded linear row pitch by matching the GL texture stride to Vulkan's rowPitch. + VkImageSubresource subresource = {}; + subresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + subresource.mipLevel = 0; + subresource.arrayLayer = 0; + VkSubresourceLayout layout; + vkGetImageSubresourceLayout(m_vkDevice, m_vkSharedImage[slot], &subresource, &layout); + + if (layout.rowPitch % 4 != 0) + { + // Cannot represent this stride as an integer pixel-width texture; fall back to CPU bridge. + cleanupSharedImage(slot); + return nullptr; + } + info.strideWidth = static_cast(layout.rowPitch / 4); + } + info.capacityHeight = capH; // GL imports the texture at capacity dimensions + info.optimalTiling = optimalTiling ? 1 : 0; + + // + // Ask through the 2-variant so the dedicated-allocation requirement can + // be read. An image created with an external handle type is reported + // requiresDedicatedAllocation by some drivers (AMD's Windows driver + // does), and binding non-dedicated memory to such an image is invalid + // -- the GL import of it then yields a texture with undefined (in + // practice all-zero) contents and no error on any path. Honor whatever + // this driver asks for rather than forcing dedicated everywhere. + // + VkMemoryDedicatedRequirements dedicatedReqs = {}; + dedicatedReqs.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_REQUIREMENTS; + + VkMemoryRequirements2 memReqs2 = {}; + memReqs2.sType = VK_STRUCTURE_TYPE_MEMORY_REQUIREMENTS_2; + memReqs2.pNext = &dedicatedReqs; + + VkImageMemoryRequirementsInfo2 memReqsInfo = {}; + memReqsInfo.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_REQUIREMENTS_INFO_2; + memReqsInfo.image = m_vkSharedImage[slot]; + + vkGetImageMemoryRequirements2(m_vkDevice, &memReqsInfo, &memReqs2); + + const VkMemoryRequirements& memReqs = memReqs2.memoryRequirements; + const bool useDedicated = + !dedicatedAllocationDisabled() && (dedicatedReqs.requiresDedicatedAllocation || dedicatedReqs.prefersDedicatedAllocation); + + info.dedicated = useDedicated ? 1 : 0; + + cout << "INFO: VulkanWindow: getSharedImageInfo: shared image slot " << slot + << " memory = " << (useDedicated ? "dedicated" : "non-dedicated") + << " (driver requires=" << (dedicatedReqs.requiresDedicatedAllocation ? "yes" : "no") + << " prefers=" << (dedicatedReqs.prefersDedicatedAllocation ? "yes" : "no") << ")" << endl; + + VkExportMemoryAllocateInfo exportAllocInfo = {}; + exportAllocInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; +#ifdef PLATFORM_WINDOWS + exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; +#else + exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; +#endif + + // Chained ahead of the export info when in use; both live to the + // vkAllocateMemory call below. + VkMemoryDedicatedAllocateInfo dedicatedAllocInfo = {}; + dedicatedAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + dedicatedAllocInfo.image = m_vkSharedImage[slot]; + if (useDedicated) + { + dedicatedAllocInfo.pNext = &exportAllocInfo; + } + + VkMemoryAllocateInfo allocInfo = {}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.pNext = useDedicated ? static_cast(&dedicatedAllocInfo) : static_cast(&exportAllocInfo); + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemoryType(m_vkPhysicalDevice, memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + if (allocInfo.memoryTypeIndex == UINT32_MAX) + { + cerr << "ERROR: VulkanWindow: No device-local memory type for shared image" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_vkSharedImageMemory[slot]) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to allocate shared image memory" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + if (vkBindImageMemory(m_vkDevice, m_vkSharedImage[slot], m_vkSharedImageMemory[slot], 0) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to bind shared image memory" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + // Export device memory as a platform-specific external handle + // (opaque FD on Linux, Win32 HANDLE on Windows). The receiving GL + // side imports this with the matching GL_EXT_memory_object_{fd,win32} + // extension so writes from GL land in this Vulkan image. +#ifdef PLATFORM_WINDOWS + auto pfnGetMemoryWin32HandleKHR = (PFN_vkGetMemoryWin32HandleKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryWin32HandleKHR"); + if (!pfnGetMemoryWin32HandleKHR) + { + cerr << "ERROR: VulkanWindow: vkGetMemoryWin32HandleKHR not found" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + VkMemoryGetWin32HandleInfoKHR getHandleInfo = {}; + getHandleInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_WIN32_HANDLE_INFO_KHR; + getHandleInfo.memory = m_vkSharedImageMemory[slot]; + getHandleInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; + + HANDLE memHandle = nullptr; + if (pfnGetMemoryWin32HandleKHR(m_vkDevice, &getHandleInfo, &memHandle) != VK_SUCCESS || !memHandle) + { + cerr << "ERROR: VulkanWindow: Failed to get memory HANDLE" << endl; + cleanupSharedImage(slot); + return nullptr; + } +#else + auto pfnGetMemoryFdKHR = (PFN_vkGetMemoryFdKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryFdKHR"); + if (!pfnGetMemoryFdKHR) + { + cerr << "ERROR: VulkanWindow: vkGetMemoryFdKHR not found" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + VkMemoryGetFdInfoKHR getFdInfo = {}; + getFdInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; + getFdInfo.memory = m_vkSharedImageMemory[slot]; + getFdInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; + + int memFd = -1; + if (pfnGetMemoryFdKHR(m_vkDevice, &getFdInfo, &memFd) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to get memory FD" << endl; + cleanupSharedImage(slot); + return nullptr; + } +#endif + + // 2. Create Shared Semaphores + VkExportSemaphoreCreateInfo exportSemInfo = {}; + exportSemInfo.sType = VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO; +#ifdef PLATFORM_WINDOWS + exportSemInfo.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; +#else + exportSemInfo.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; +#endif + + VkSemaphoreCreateInfo semInfo = {}; + semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + semInfo.pNext = &exportSemInfo; + + if (vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_vkGlReadySemaphore[slot]) != VK_SUCCESS + || vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_vkVkReadySemaphore[slot]) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to create shared semaphores" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + // Export the GL<->Vulkan sync semaphores as external handles. +#ifdef PLATFORM_WINDOWS + auto pfnGetSemaphoreWin32HandleKHR = + (PFN_vkGetSemaphoreWin32HandleKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreWin32HandleKHR"); + if (!pfnGetSemaphoreWin32HandleKHR) + { + cerr << "ERROR: VulkanWindow: vkGetSemaphoreWin32HandleKHR not found" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + VkSemaphoreGetWin32HandleInfoKHR getSemHandleInfo = {}; + getSemHandleInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_WIN32_HANDLE_INFO_KHR; + getSemHandleInfo.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; + + HANDLE glReadyHandle = nullptr; + HANDLE vkReadyHandle = nullptr; + + getSemHandleInfo.semaphore = m_vkGlReadySemaphore[slot]; + if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &glReadyHandle) != VK_SUCCESS || !glReadyHandle) + { + cerr << "ERROR: VulkanWindow: Failed to get glReady semaphore HANDLE" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + getSemHandleInfo.semaphore = m_vkVkReadySemaphore[slot]; + if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &vkReadyHandle) != VK_SUCCESS || !vkReadyHandle) + { + cerr << "ERROR: VulkanWindow: Failed to get vkReady semaphore HANDLE" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + info.memoryHandle = memHandle; + info.size = memReqs.size; + info.width = w; + info.height = h; + info.glReadySemaphoreHandle = glReadyHandle; + info.vkReadySemaphoreHandle = vkReadyHandle; +#else + auto pfnGetSemaphoreFdKHR = (PFN_vkGetSemaphoreFdKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreFdKHR"); + if (!pfnGetSemaphoreFdKHR) + { + cerr << "ERROR: VulkanWindow: vkGetSemaphoreFdKHR not found" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + VkSemaphoreGetFdInfoKHR getSemFdInfo = {}; + getSemFdInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; + getSemFdInfo.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; + + int glReadyFd = -1; + int vkReadyFd = -1; + + getSemFdInfo.semaphore = m_vkGlReadySemaphore[slot]; + if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &glReadyFd) != VK_SUCCESS || glReadyFd < 0) + { + cerr << "ERROR: VulkanWindow: Failed to get glReady semaphore FD" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + getSemFdInfo.semaphore = m_vkVkReadySemaphore[slot]; + if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &vkReadyFd) != VK_SUCCESS || vkReadyFd < 0) + { + cerr << "ERROR: VulkanWindow: Failed to get vkReady semaphore FD" << endl; + cleanupSharedImage(slot); + return nullptr; + } + + info.memoryFd = memFd; + info.size = memReqs.size; + info.width = w; + info.height = h; + info.glReadySemaphoreFd = glReadyFd; + info.vkReadySemaphoreFd = vkReadyFd; +#endif + + // Transition the shared image to TRANSFER_SRC optimal initially + VkCommandBuffer cb = m_vkCommandBuffers[0]; + vkResetCommandBuffer(cb, 0); + + VkCommandBufferBeginInfo beginInfo = {}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cb, &beginInfo); + + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_vkSharedImage[slot]; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + + vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + vkEndCommandBuffer(cb); + + VkSubmitInfo submitInfo = {}; + submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submitInfo.commandBufferCount = 1; + submitInfo.pCommandBuffers = &cb; + + vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); + VkResult layoutSubmitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); + if (layoutSubmitResult != VK_SUCCESS) + { + if (layoutSubmitResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + cleanupSharedImage(slot); + return nullptr; + } + vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + + // Signal vkReady initially so GL can start writing to it + VkSubmitInfo signalInfo = {}; + signalInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + signalInfo.signalSemaphoreCount = 1; + signalInfo.pSignalSemaphores = &m_vkVkReadySemaphore[slot]; + VkResult signalResult = vkQueueSubmit(m_vkQueue, 1, &signalInfo, VK_NULL_HANDLE); + if (signalResult != VK_SUCCESS) + { + if (signalResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + cleanupSharedImage(slot); + return nullptr; + } + + // Commit the new capacity only now that the (re)build fully succeeded. + m_sharedCapacityW[slot] = capW; + m_sharedCapacityH[slot] = capH; + + return &info; + } + + //-------------------------------------------------------------------------- + // presentSharedImage + //-------------------------------------------------------------------------- + + void VulkanWindow::presentSharedImage() + { + const uint32_t slot = m_currentFrame; + const SharedImageInfo& info = m_sharedImageInfo[slot]; + + if (!m_vkDevice || !m_vkSharedImage[slot] || !m_vkSwapchain) + return; + + // Start-of-frame throttle: wait for this slot's previous frame to finish + // before reusing its acquire semaphore and per-frame resources. This + // replaces the old end-of-frame block; with FIFO acquire back-pressure it + // is what paces the loop to display refresh while still allowing + // FRAMES_IN_FLIGHT frames outstanding. + vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + + // Acquire image + uint32_t imageIndex; + VkResult result = + vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, UINT64_MAX, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + if (result == VK_ERROR_OUT_OF_DATE_KHR) + { + // The GL side already signaled glReady[slot]/waited vkReady[slot] this + // frame; rebalance the pair before bailing so the next frame on this + // slot can't desync. + drainSharedSemaphores(slot); + handleSwapchainOutOfDate(); + return; + } + if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) + { + drainSharedSemaphores(slot); + if (result == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return; + } + + // If this swapchain image is still owned by another in-flight frame, wait + // for that frame's fence before rendering into it, then mark the image as + // now owned by this frame. + if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) + { + vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX); + } + m_imagesInFlight[imageIndex] = m_vkFence[slot]; + + // Reset the frame fence only now, right before the submit that re-signals it. + vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); + + VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; + vkResetCommandBuffer(cb, 0); + + VkCommandBufferBeginInfo beginInfo = {}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cb, &beginInfo); + + // Transition shared image from COLOR_ATTACHMENT_OPTIMAL to TRANSFER_SRC_OPTIMAL + VkImageMemoryBarrier sharedBarrier = {}; + sharedBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + sharedBarrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + sharedBarrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + sharedBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + sharedBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + sharedBarrier.image = m_vkSharedImage[slot]; + sharedBarrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + sharedBarrier.subresourceRange.baseMipLevel = 0; + sharedBarrier.subresourceRange.levelCount = 1; + sharedBarrier.subresourceRange.baseArrayLayer = 0; + sharedBarrier.subresourceRange.layerCount = 1; + sharedBarrier.srcAccessMask = 0; + sharedBarrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + + vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, + &sharedBarrier); + + // Transition swapchain image to transfer dst + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_vkSwapchainImages[imageIndex]; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + + vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + // Transfer the shared image (always A2B10G10R10, == GL_RGB10_A2) to the + // swapchain image. When the swapchain is also A2B10G10R10 the layouts + // match and a raw copy is correct and cheapest. When the swapchain is + // A2R10G10B10 a raw copy would swap red and blue, so use a blit instead: + // vkCmdBlitImage converts per component (R->R, G->G, B->B) between the + // two formats. Whether the (linear-tiled) shared image can be a blit + // source is checked at shared-image creation; if not, that path is + // refused and syncBuffers() uses the CPU fallback instead. + if (m_vkSwapchainFormat == VK_FORMAT_A2B10G10R10_UNORM_PACK32) + { + VkImageCopy region = {}; + region.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.srcSubresource.layerCount = 1; + region.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.dstSubresource.layerCount = 1; + region.extent = {(uint32_t)info.width, (uint32_t)info.height, 1}; + + vkCmdCopyImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + } + else + { + VkImageBlit blit = {}; + blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.srcSubresource.layerCount = 1; + blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.dstSubresource.layerCount = 1; + blit.srcOffsets[0] = {0, 0, 0}; + blit.srcOffsets[1] = {info.width, info.height, 1}; + blit.dstOffsets[0] = {0, 0, 0}; + blit.dstOffsets[1] = {info.width, info.height, 1}; + + vkCmdBlitImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST); + } + + // Transition swapchain image to present + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = 0; + + vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, + &barrier); + + vkEndCommandBuffer(cb); + + // Submit + VkSubmitInfo submitInfo = {}; + submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + + // Wait for GL to finish writing (glReady) AND swapchain image to be available + VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot], m_vkImageAvailableSemaphore[slot]}; + VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; + submitInfo.waitSemaphoreCount = 2; + submitInfo.pWaitSemaphores = waitSemaphores; + submitInfo.pWaitDstStageMask = waitStages; + + submitInfo.commandBufferCount = 1; + submitInfo.pCommandBuffers = &cb; + + // Signal the image's renderFinished (present waits on it) AND vkReady (so + // GL can write the next frame into this slot's shared image). + VkSemaphore signalSemaphores[] = {m_vkRenderFinished[imageIndex], m_vkVkReadySemaphore[slot]}; + submitInfo.signalSemaphoreCount = 2; + submitInfo.pSignalSemaphores = signalSemaphores; + + VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); + if (submitResult != VK_SUCCESS) + { + if (submitResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return; + } + + // The frame is committed to the GPU; advance the ring now so the next + // frame uses the other slot. imageIndex/slot below are locals, so this is + // safe before the present call. + m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; + + // Present, waiting on the image's own renderFinished semaphore. + VkPresentInfoKHR presentInfo = {}; + presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + presentInfo.waitSemaphoreCount = 1; + presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; + VkSwapchainKHR swapchains[] = {m_vkSwapchain}; + presentInfo.swapchainCount = 1; + presentInfo.pSwapchains = swapchains; + presentInfo.pImageIndices = &imageIndex; + + VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and + // can be reported persistently by some X11/RADV compositors; recreating + // on it every frame caused a swapchain-recreate loop that starved the Qt + // event loop (dead input, no fullscreen). Real resizes report OUT_OF_DATE. + // The submit above is tracked by m_vkFence[slot] (waited at the start of + // the next use of this slot), so no end-of-frame block is needed here. + if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) + { + handleSwapchainOutOfDate(); + return; + } + if (presentResult != VK_SUCCESS) + { + if (presentResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return; + } + } + + //-------------------------------------------------------------------------- + // presentPixelData + //-------------------------------------------------------------------------- + + void VulkanWindow::presentPixelData(const void* pixels, int w, int h) + { + const uint32_t slot = m_currentFrame; + + if (!m_vkDevice) + return; + + if (!m_vkSwapchain || m_vkSwapchainExtent.width != (uint32_t)w || m_vkSwapchainExtent.height != (uint32_t)h) + { + // Warm recreate via oldSwapchain (createSwapchain retires the old one). + if (!createSwapchain()) + return; + } + + // Start-of-frame throttle (matches presentSharedImage): wait for this + // slot's previous frame to finish before reusing its staging buffer, + // acquire semaphore and command resources. + vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + + size_t size = w * h * 4; + + // Recreate this slot's staging buffer if needed + if (size > m_stagingBufferSize[slot]) + { + if (m_vkStagingBuffer[slot]) + vkDestroyBuffer(m_vkDevice, m_vkStagingBuffer[slot], nullptr); + if (m_vkStagingBufferMemory[slot]) + vkFreeMemory(m_vkDevice, m_vkStagingBufferMemory[slot], nullptr); + + VkBufferCreateInfo bufferInfo = {}; + bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufferInfo.size = size; + bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + vkCreateBuffer(m_vkDevice, &bufferInfo, nullptr, &m_vkStagingBuffer[slot]); + + VkMemoryRequirements memRequirements; + vkGetBufferMemoryRequirements(m_vkDevice, m_vkStagingBuffer[slot], &memRequirements); + + VkMemoryAllocateInfo allocInfo = {}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memRequirements.size; + allocInfo.memoryTypeIndex = findMemoryType(m_vkPhysicalDevice, memRequirements.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (allocInfo.memoryTypeIndex == UINT32_MAX) + { + cerr << "ERROR: VulkanWindow: No host-visible memory type for staging buffer" << endl; + return; + } + + vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_vkStagingBufferMemory[slot]); + vkBindBufferMemory(m_vkDevice, m_vkStagingBuffer[slot], m_vkStagingBufferMemory[slot], 0); + + m_stagingBufferSize[slot] = size; + } + + // Copy to staging buffer + void* data; + vkMapMemory(m_vkDevice, m_vkStagingBufferMemory[slot], 0, size, 0, &data); + memcpy(data, pixels, size); + vkUnmapMemory(m_vkDevice, m_vkStagingBufferMemory[slot]); + + // Acquire image + uint32_t imageIndex; + VkResult result = + vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, UINT64_MAX, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + if (result == VK_ERROR_OUT_OF_DATE_KHR) + { + handleSwapchainOutOfDate(); + return; + } + if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) + { + if (result == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return; + } + + // If this swapchain image is still owned by another in-flight frame, wait + // for its fence, then mark it owned by this frame. + if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) + { + vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX); + } + m_imagesInFlight[imageIndex] = m_vkFence[slot]; + + vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); + + VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; + vkResetCommandBuffer(cb, 0); + + VkCommandBufferBeginInfo beginInfo = {}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cb, &beginInfo); + + // Transition image to transfer dst + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_vkSwapchainImages[imageIndex]; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + + vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + // Copy buffer to image + VkBufferImageCopy region = {}; + region.bufferOffset = 0; + region.bufferRowLength = 0; + region.bufferImageHeight = 0; + region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.imageSubresource.mipLevel = 0; + region.imageSubresource.baseArrayLayer = 0; + region.imageSubresource.layerCount = 1; + region.imageOffset = {0, 0, 0}; + region.imageExtent = {(uint32_t)w, (uint32_t)h, 1}; + + vkCmdCopyBufferToImage(cb, m_vkStagingBuffer[slot], m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, + ®ion); + + // Transition image to present + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = 0; + + vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, + &barrier); + + vkEndCommandBuffer(cb); + + // Submit + VkSubmitInfo submitInfo = {}; + submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + VkSemaphore waitSemaphores[] = {m_vkImageAvailableSemaphore[slot]}; + VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; + submitInfo.waitSemaphoreCount = 1; + submitInfo.pWaitSemaphores = waitSemaphores; + submitInfo.pWaitDstStageMask = waitStages; + submitInfo.commandBufferCount = 1; + submitInfo.pCommandBuffers = &cb; + VkSemaphore signalSemaphores[] = {m_vkRenderFinished[imageIndex]}; + submitInfo.signalSemaphoreCount = 1; + submitInfo.pSignalSemaphores = signalSemaphores; + + VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); + if (submitResult != VK_SUCCESS) + { + if (submitResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return; + } + + // Frame committed; advance the ring (imageIndex/slot below are locals). + m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; + + // Present, waiting on the image's own renderFinished semaphore. + VkPresentInfoKHR presentInfo = {}; + presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + presentInfo.waitSemaphoreCount = 1; + presentInfo.pWaitSemaphores = signalSemaphores; + VkSwapchainKHR swapchains[] = {m_vkSwapchain}; + presentInfo.swapchainCount = 1; + presentInfo.pSwapchains = swapchains; + presentInfo.pImageIndices = &imageIndex; + + VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and + // can be reported persistently by some X11/RADV compositors; recreating + // on it every frame caused a swapchain-recreate loop that starved the Qt + // event loop (dead input, no fullscreen). Real resizes report OUT_OF_DATE. + // The submit is tracked by m_vkFence[slot] (waited at the next use of this + // slot), so no end-of-frame block is needed here. + if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) + { + handleSwapchainOutOfDate(); + return; + } + if (presentResult != VK_SUCCESS) + { + if (presentResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return; + } + } + + //-------------------------------------------------------------------------- + + void VulkanWindow::render() + { + if (m_stopProcessingEvents) + { + return; + } + + IPCore::Session* session = m_doc ? m_doc->session() : nullptr; + if (!session) + return; + + if (m_doc && session && m_videoDevice) + { + m_videoDevice->makeCurrent(); + + if (m_userActive && m_activityTimer.elapsed() > 1.0) + { + if (m_doc->mainPopup() && !m_doc->mainPopup()->isVisible() && m_eventWidget && m_eventWidget->hasFocus()) + { + TwkApp::ActivityChangeEvent aevent("user-inactive", m_videoDevice); + m_videoDevice->sendEvent(aevent); + m_userActive = false; + } + } + + int x = 0, y = 0; + absolutePosition(x, y); + m_videoDevice->setAbsolutePosition(x, y); + + session->render(); + + if (!m_postFirstNonEmptyRender && session->postFirstNonEmptyRender()) + { + m_postFirstNonEmptyRender = true; + if (!session->isFullScreen()) + { + m_doc->resizeToFit(false, false); + m_doc->center(); + } + } + + m_firstPaintCompleted = true; + } + + if (m_stopProcessingEvents) + { + return; + } + + if (session) + { + if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) + { + session->outputVideoDevice()->syncBuffers(); + } + else + { + m_videoDevice->syncBuffers(); + } + } + + if (session) + { + session->addSyncSample(); + session->postRender(); + } + + m_eventProcessingTimer.start(); + } + + //-------------------------------------------------------------------------- + // QWindow overrides + //-------------------------------------------------------------------------- + + void VulkanWindow::exposeEvent(QExposeEvent* event) + { + QWindow::exposeEvent(event); + + if (m_stopProcessingEvents || !isExposed()) + { + return; + } + + // + // The VkSurfaceKHR is derived from the platform window, so it does not + // survive Qt destroying and recreating it -- which happens when the + // container re-parents the viewport after the top-level QWidgetWindow + // is replaced (see VulkanView::reattachVulkanWindow). Rebuild the + // surface and swapchain when we come back exposed on a new handle. + // + if (m_initialized && handle() != m_initializedHandle) + { + handleSurfaceLost(); + } + + if (!m_initialized) + { + initialize(); + } + + requestUpdate(); + } + + void VulkanWindow::resizeEvent(QResizeEvent* event) + { + if (m_doc) + m_doc->viewSizeChanged(event->size().width(), event->size().height()); + QWindow::resizeEvent(event); + + // Nothing repaints this native surface on resize, so drive a render now + // to recreate the swapchain at the new size and present immediately + // (instead of waiting for a mouse Enter event). QWindow::requestUpdate() + // coalesces, so a fast drag does not queue one heavy recreate per event. + if (!m_stopProcessingEvents) + { + requestUpdate(); + } + } + + //-------------------------------------------------------------------------- + // eventProcessingTimeout slot + //-------------------------------------------------------------------------- + + void VulkanWindow::eventProcessingTimeout() + { + if (m_doc && m_doc->session()) + m_doc->session()->userGenericEvent("per-render-event-processing", ""); + } + + //-------------------------------------------------------------------------- + // event() + //-------------------------------------------------------------------------- + + bool VulkanWindow::event(QEvent* event) + { + // + // This must be handled before every guard below (including the + // m_stopProcessingEvents / missing-device early-outs): it is the only + // point at which the Vulkan objects can still legally be destroyed. + // + // Qt sends SurfaceAboutToBeDestroyed from QWindow::destroy(), just + // before it deletes the QPlatformWindow -- and the VkSurfaceKHR, the + // swapchain and the X11 drawable behind them all die with it. Anything + // released later is released against a surface that no longer exists, + // which is a segfault inside the driver rather than an error code. + // + // On quit that "later" is the destructor: QWindowContainer's own + // destructor calls window->destroy() and only then deletes the window, + // so ~VulkanWindow always runs on a dead surface. The same applies on + // the reparent path, where Qt replaces the top-level QWidgetWindow + // (adding a QWebEngineView is the usual trigger). exposeEvent()'s + // handle() != m_initializedHandle check notices that one, but only + // after the fact; this notices it in time. + // + if (event->type() == QEvent::PlatformSurface) + { + if (static_cast(event)->surfaceEventType() == QPlatformSurfaceEvent::SurfaceAboutToBeDestroyed) + { + releaseVulkanResources(); + } + return QWindow::event(event); + } + + // The device (and its translator) is wired by the hosting VulkanView + // just after construction; ignore any events that arrive before then. + if (!m_videoDevice) + return QWindow::event(event); + + bool keyevent = false; + Rv::Session* session = m_doc ? m_doc->session() : nullptr; + + if (m_stopProcessingEvents) + { + event->accept(); + return true; + } + + if (event->type() == QEvent::WindowActivate) + m_activationTimer.start(); + + float activationTime = 0.0f; + if (m_activationTimer.isRunning()) + { + if (event->type() == QEvent::MouseButtonPress) + { + activationTime = m_activationTimer.elapsed(); + m_activationTimer.stop(); + } + if (event->type() == QEvent::MouseMove) + m_activationTimer.stop(); + } + + if (event->type() != QEvent::Paint) + { + m_activityTimer.stop(); + m_activityTimer.start(); + + if (!m_userActive) + { + TwkApp::ActivityChangeEvent aevent("user-active", m_videoDevice); + m_userActive = true; + m_videoDevice->sendEvent(aevent); + } + } + + if (QKeyEvent* kevent = dynamic_cast(event)) + { + keyevent = true; + if (m_lastKey == kevent->key() + && (m_lastKeyType == QEvent::ShortcutOverride && (kevent->type() == QEvent::KeyPress) || (m_lastKeyType == kevent->type()))) + { + m_lastKey = kevent->key(); + m_lastKeyType = kevent->type(); + event->accept(); + return true; + } + m_lastKeyType = kevent->type(); + m_lastKey = kevent->key(); + } + + switch (event->type()) + { + case QEvent::FocusIn: + // + // Qt has already made this the focus window by the time FocusIn is + // delivered, so there is nothing to hand over here. The case exists + // only to drop modifier state that went stale while the keyboard + // was elsewhere. + // + m_videoDevice->translator().resetModifiers(); + break; + + case QEvent::Enter: + // + // Hovering hands the keyboard to the viewport as a *widget* focus + // change, never as a window activation. QWidget::setFocus() only + // delivers FocusIn when the top-level is already active; otherwise + // it just records the window's focus_child and the keyboard + // arrives once the user activates RV. That keeps a hover from + // stealing activation from another top-level of ours (the Console) + // or from another application entirely. + // + // Skipped when this window already holds focus: QWindowContainer + // clears the container's widget focus once it has handed focus + // over, so a repeat FocusIn would take its "return to the normal + // focus chain" branch and push the keyboard to the next widget in + // the tab chain instead. This is why FocusIn above must not fall + // through into this case. + // + if (QGuiApplication::focusWindow() != this && m_eventWidget) + m_eventWidget->setFocus(Qt::MouseFocusReason); + break; + + default: + break; + } + + if (event->type() == QEvent::Resize) + { + QResizeEvent* e = static_cast(event); + if (!isVisible()) + { + return true; + } + if (e->oldSize().width() != -1 && e->oldSize().height() != -1) + { + ostringstream contents; + contents << e->oldSize().width() << " " << e->oldSize().height() << "|" << e->size().width() << " " << e->size().height(); + if (m_doc && session) + session->userGenericEvent("view-resized", contents.str()); + } + return QWindow::event(event); + } + + if (event->type() == QEvent::UpdateRequest) + { + render(); + return true; + } + + if (!m_videoDevice || !m_videoDevice->hasTranslator()) + { + return QWindow::event(event); + } + + auto resetTranslator = [this]() + { + m_videoDevice->translator().setScaleAndOffset(0, 0, 1.0f, 1.0f); + m_videoDevice->translator().setRelativeDomain(width(), height()); + }; + + if (session && session->outputVideoDevice() + && session->outputVideoDevice()->displayMode() == TwkApp::VideoDevice::MirrorDisplayMode) + { + if (const TwkApp::VideoDevice* cdv = session->controlVideoDevice()) + { + const TwkApp::VideoDevice* odv = session->outputVideoDevice(); + if (odv && cdv != odv && cdv == videoDevice()) + { + const float w = static_cast(width()); + const float h = static_cast(height()); + const float ow = static_cast(odv->width()); + const float oh = static_cast(odv->height()); + const float aspect = w / h; + const float oaspect = ow / oh; + + m_videoDevice->translator().setRelativeDomain(ow, oh); + + if (aspect >= oaspect) + { + const float yscale = oh / h; + const float yoffset = 0.0f; + const float xscale = yscale; + const float xoffset = -(w * yscale - ow) / 2.0f; + m_videoDevice->translator().setScaleAndOffset(xoffset, yoffset, xscale, yscale); + } + else + { + const float xscale = ow / w; + const float xoffset = 0.0f; + const float yscale = xscale; + const float yoffset = -(xscale * h - oh) / 2.0f; + m_videoDevice->translator().setScaleAndOffset(xoffset, yoffset, xscale, yscale); + } + } + else + { + resetTranslator(); + } + } + else + { + resetTranslator(); + } + } + else + { + resetTranslator(); + } + + if (session) + session->setEventVideoDevice(videoDevice()); + + if (m_videoDevice->translator().sendQTEvent(event, activationTime)) + { + event->accept(); + return true; + } + else + { + return QWindow::event(event); + } + } + +} // namespace Rv + +#endif // PLATFORM_LINUX || PLATFORM_WINDOWS diff --git a/src/lib/ip/IPCore/IPCore/ImageRenderer.h b/src/lib/ip/IPCore/IPCore/ImageRenderer.h index 53054ed00..0d12d3d7e 100644 --- a/src/lib/ip/IPCore/IPCore/ImageRenderer.h +++ b/src/lib/ip/IPCore/IPCore/ImageRenderer.h @@ -682,6 +682,11 @@ namespace IPCore static bool debugGpu() { return m_debugGpu; } + // Deprecated — use debugGpu() instead. + static void reportGL(bool b) { debugGpu(b); } + + static bool reportGL() { return debugGpu(); } + static void setPBOs(bool b) { m_pixelBuffers = b; } static bool hasFloatFormats() { return m_floatFormats; } From 3f9bf7e2dee481964ee1c9d23dbd1d47d865af58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:36:43 -0400 Subject: [PATCH 02/48] feat(vulkan): share one process-lifetime VkInstance between the 10-bit probe and the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanWindow.cpp | 202 ++++++++++++++++---------- 1 file changed, 124 insertions(+), 78 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 9f666e7fc..58178e4c9 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -185,89 +185,144 @@ namespace Rv // } - bool VulkanWindow::supports10BitPresentation() + // + // One process-lifetime QVulkanInstance, shared by the 10-bit probe + // (supports10BitPresentation) and every VulkanWindow (initVulkan). Created + // lazily and never destroyed. + // + // Tearing a VkInstance down and then creating or using another shortly + // after corrupts RADV's shared X11/xcb WSI state and segfaults a + // subsequent vkGetPhysicalDeviceSurfaceSupportKHR. That is exactly the + // sequence a mid-session GL->Vulkan promotion produces: the probe runs, + // drops its throwaway instance, and a VulkanWindow initializes moments + // later. Keeping one instance alive for the whole process removes the + // teardown entirely. initVulkan already relied on a never-destroyed + // static instance, so this only extends the same lifetime to the probe. + // + static QVulkanInstance* sharedVulkanInstance() { - QVulkanInstance qtVkInst; - if (!qtVkInst.create()) + static QVulkanInstance* instance = []() -> QVulkanInstance* { - cerr << "ERROR: VulkanWindow: supports10BitPresentation: QVulkanInstance create failed" << endl; - return false; - } - - VkInstance instance = qtVkInst.vkInstance(); - if (instance == VK_NULL_HANDLE) - { - return false; - } - - QWindow dummyWindow; - dummyWindow.setSurfaceType(QSurface::VulkanSurface); - dummyWindow.create(); - dummyWindow.setVulkanInstance(&qtVkInst); + auto* inst = new QVulkanInstance(); + if (!inst->create()) + { + cerr << "ERROR: VulkanWindow: shared QVulkanInstance create failed" << endl; + delete inst; + return nullptr; + } + return inst; + }(); + return instance; + } - VkSurfaceKHR dummySurface = qtVkInst.surfaceForWindow(&dummyWindow); - if (!dummySurface) + bool VulkanWindow::supports10BitPresentation() + { + // + // Memoized: 10-bit presentation support is a fixed hardware/driver + // property, so probe at most once per process. The probe uses the + // shared, never-destroyed instance and a leaked probe window (see + // sharedVulkanInstance), so it tears down nothing that could corrupt + // RADV's WSI state ahead of a later VulkanWindow init; memoization is + // then just an optimization that avoids re-running the device scan on + // every DesktopVideoDevice::shouldUseVulkanPresentation() call. + // + static const bool cached = []() -> bool { - cerr << "ERROR: VulkanWindow: supports10BitPresentation: failed to create dummy surface" << endl; - return false; - } + QVulkanInstance* qtVkInst = sharedVulkanInstance(); + if (!qtVkInst) + { + return false; + } - uint32_t deviceCount = 0; - vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); - if (deviceCount == 0) - { - cerr << "ERROR: VulkanWindow: supports10BitPresentation: vkEnumeratePhysicalDevices returned 0 devices" << endl; - return false; - } - std::vector devices(deviceCount); - vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data()); + VkInstance instance = qtVkInst->vkInstance(); + if (instance == VK_NULL_HANDLE) + { + return false; + } - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanWindow: supports10BitPresentation: probing " << deviceCount << " physical device(s)" << endl; - } + // + // Leak the probe window (process-lifetime, never shown). + // Destroying its Vulkan surface right before a real VulkanWindow + // init is part of the same WSI-teardown hazard as destroying the + // instance, so it is never torn down either. + // + static QWindow* dummyWindow = []() -> QWindow* + { + auto* w = new QWindow(); + w->setSurfaceType(QSurface::VulkanSurface); + w->create(); + return w; + }(); + dummyWindow->setVulkanInstance(qtVkInst); + + VkSurfaceKHR dummySurface = qtVkInst->surfaceForWindow(dummyWindow); + if (!dummySurface) + { + cerr << "ERROR: VulkanWindow: supports10BitPresentation: failed to create dummy surface" << endl; + return false; + } - bool any10bit = false; - for (uint32_t di = 0; di < devices.size(); ++di) - { - VkPhysicalDevice dev = devices[di]; + uint32_t deviceCount = 0; + vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); + if (deviceCount == 0) + { + cerr << "ERROR: VulkanWindow: supports10BitPresentation: vkEnumeratePhysicalDevices returned 0 devices" << endl; + return false; + } + std::vector devices(deviceCount); + vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data()); - uint32_t formatCount = 0; - if (vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + if (ImageRenderer::debugGpu()) { - continue; + cout << "INFO: VulkanWindow: supports10BitPresentation: probing " << deviceCount << " physical device(s)" << endl; } - std::vector formats(formatCount); - vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, formats.data()); - bool has10bit = false; - for (const auto& fmt : formats) + bool any10bit = false; + for (uint32_t di = 0; di < devices.size(); ++di) { - if (isTenBitFormat(fmt.format)) + VkPhysicalDevice dev = devices[di]; + + uint32_t formatCount = 0; + if (vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) { - has10bit = true; - any10bit = true; - break; + continue; + } + std::vector formats(formatCount); + vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, formats.data()); + + bool has10bit = false; + for (const auto& fmt : formats) + { + if (isTenBitFormat(fmt.format)) + { + has10bit = true; + any10bit = true; + break; + } + } + + VkPhysicalDeviceProperties props = {}; + vkGetPhysicalDeviceProperties(dev, &props); + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: device[" << di << "] '" << props.deviceName + << "': 10-bit surface format=" << (has10bit ? "YES" : "NO") << endl; } } - VkPhysicalDeviceProperties props = {}; - vkGetPhysicalDeviceProperties(dev, &props); + // + // The probe window, its surface and the shared instance are all + // kept alive for the process lifetime, so nothing is torn down + // here. + // if (ImageRenderer::debugGpu()) { - cout << "INFO: VulkanWindow: device[" << di << "] '" << props.deviceName - << "': 10-bit surface format=" << (has10bit ? "YES" : "NO") << endl; + cout << "INFO: VulkanWindow: supports10BitPresentation: returning " << (any10bit ? "true" : "false") << endl; } - } + return any10bit; + }(); - // The surface returned by surfaceForWindow() is owned by the platform - // integration and is released when dummyWindow is destroyed on return; - // QVulkanInstance has no destroySurface() in this Qt version. - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanWindow: supports10BitPresentation: returning " << (any10bit ? "true" : "false") << endl; - } - return any10bit; + return cached; } bool VulkanWindow::initVulkan() @@ -292,23 +347,14 @@ namespace Rv #endif }; - // Try to get Qt's extensions - static QVulkanInstance* qtVkInst = nullptr; + // Reuse the one process-lifetime instance, shared with the 10-bit probe. + // It is never destroyed: see sharedVulkanInstance for why tearing a + // VkInstance down near another init crashes RADV's WSI. + QVulkanInstance* qtVkInst = sharedVulkanInstance(); if (!qtVkInst) { - qtVkInst = new QVulkanInstance(); - // The dedicated-allocation query in getSharedImageInfo() uses - // vkGetImageMemoryRequirements2, which is core in Vulkan 1.1. - // QVulkanInstance otherwise creates a 1.0 instance, which would put - // that call out of contract. - qtVkInst->setApiVersion(QVersionNumber(1, 1)); - if (!qtVkInst->create()) - { - cerr << "ERROR: VulkanWindow: QVulkanInstance create failed" << endl; - delete qtVkInst; - qtVkInst = nullptr; - return false; - } + cerr << "ERROR: VulkanWindow: shared QVulkanInstance unavailable" << endl; + return false; } m_vkInstance = qtVkInst->vkInstance(); From d7fa72ad73fe74541859a5f3f99d838e654c83a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:37:26 -0400 Subject: [PATCH 03/48] fix(vulkan): present the main viewport when a separate output is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VulkanWindow::render() presented either the session output device or the main viewport, never both. Unlike the GL path, where QOpenGLWidget composites the control widget after paintGL regardless, the Vulkan viewport only appears via an explicit present -- so once presentation mode was on the main window sat on a stale frame. Present the viewport unconditionally, then additionally present a distinct output device. Also guard render() on m_initialized: initialize() runs from exposeEvent(), but resizeEvent() also calls requestUpdate(), so an UpdateRequest can reach the present path before the surface and swapchain exist. And present once from exposeEvent() for a doc-less window, which is a passive presentation output whose render() returns early at !session and would otherwise stay blank until the next main-view frame. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanWindow.cpp | 48 ++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 58178e4c9..561f2795a 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1996,6 +1996,19 @@ namespace Rv return; } + // + // Vulkan is not ready until the surface and swapchain exist, which + // happens in initialize() on first expose. resizeEvent() also calls + // requestUpdate(), so an UpdateRequest can land here before then -- + // newly reachable now that swapGLViewToVulkan() builds a VulkanView + // mid-session. Presenting into a null device would fault; another + // render is requested once initialized. + // + if (!m_initialized) + { + return; + } + IPCore::Session* session = m_doc ? m_doc->session() : nullptr; if (!session) return; @@ -2038,16 +2051,27 @@ namespace Rv return; } - if (session) + if (session && m_videoDevice) { + // + // Always present the main (control) viewport's own swapchain. + // Unlike the GL path, where QOpenGLWidget composites the control + // widget after paintGL regardless, the Vulkan viewport only + // appears via an explicit present -- so it must NOT be skipped + // when a separate output (presentation) device is active. + // Skipping it leaves the main window on a stale frame once + // presentation mode is on. + // + m_videoDevice->syncBuffers(); + + // + // In presentation mode the output is a distinct fullscreen window + // that must also be presented this frame. + // if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) { session->outputVideoDevice()->syncBuffers(); } - else - { - m_videoDevice->syncBuffers(); - } } if (session) @@ -2089,6 +2113,20 @@ namespace Rv initialize(); } + // + // A doc-less window is a passive presentation output: it is rendered + // into and presented by its owning VulkanDesktopVideoDevice, and + // render() returns at `!session` so it never drives itself. Present + // once here so a freshly exposed (or re-exposed) presentation surface + // shows the last composited frame instead of staying blank until the + // next main-view frame. + // + if (!m_doc && m_initialized && m_videoDevice) + { + m_videoDevice->syncBuffers(); + return; + } + requestUpdate(); } From 010349d6910534858a04cb36767e6f6492a97be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:39:28 -0400 Subject: [PATCH 04/48] feat(vulkan): add VulkanDesktopVideoDevice, the 10-bit second-display output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presentation output went through DesktopVideoDevice's OpenGL ScreenView even when the main view was already presenting 10-bit through Vulkan, so the second display was capped at 8 bits. Add a DesktopVideoDevice subclass that delivers the frame through a Vulkan swapchain instead. It reuses the base class's frame handoff wholesale -- transfer()/transfer2() composite into m_viewDevice->defaultFBO() exactly as they do for the ScreenView, every stereo mode included -- and overrides only the window lifecycle and the present, since Vulkan has no QOpenGLWidget auto-composite. The output window is a top-level VulkanView constructed with a null doc. That null doc is what makes it passive: VulkanWindow::render() returns at !session, requestGLFallback() returns at !m_doc, and presentationAllowed() guards on it, so a presentation surface never drives the session nor drags the main window into a GL fallback. Supporting changes: - QTVulkanVideoDevice::fboID() reports the offscreen FBO once it exists. DesktopVideoDevice::transfer() returns early while the view device reports 0, which is how the first composite is deferred until the target exists -- without the override it would never composite. - A doc-less VulkanView keys its device name on the view rather than the doc, which would otherwise collide across screens. - DesktopVideoDevice::shouldUseVulkanPresentation() is the single GL-vs-Vulkan rule, used both by createDesktopVideoDevices() and by the RvDocument constructor so the main view and the presentation output cannot disagree on the backend. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/CMakeLists.txt | 4 +- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 46 +++++- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 2 + .../RvCommon/RvCommon/DesktopVideoDevice.h | 14 ++ .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 11 ++ .../RvCommon/VulkanDesktopVideoDevice.h | 64 ++++++++ src/lib/app/RvCommon/RvDocument.cpp | 12 +- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 140 ++++++++++++++++++ src/lib/app/RvCommon/VulkanView.cpp | 15 +- 9 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h create mode 100644 src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp diff --git a/src/lib/app/RvCommon/CMakeLists.txt b/src/lib/app/RvCommon/CMakeLists.txt index c40c5dfdd..f8c3652ff 100644 --- a/src/lib/app/RvCommon/CMakeLists.txt +++ b/src/lib/app/RvCommon/CMakeLists.txt @@ -96,9 +96,11 @@ IF(RV_TARGET_LINUX VulkanView.cpp VulkanWindow.cpp QTVulkanVideoDevice.cpp + VulkanDesktopVideoDevice.cpp RvCommon/VulkanView.h RvCommon/VulkanWindow.h RvCommon/QTVulkanVideoDevice.h + RvCommon/VulkanDesktopVideoDevice.h ) ENDIF() @@ -174,7 +176,7 @@ IF(NOT (RV_TARGET_LINUX OR RV_TARGET_WINDOWS) ) - LIST(REMOVE_ITEM _files_to_moc "RvCommon/VulkanView.h" "RvCommon/VulkanWindow.h" "RvCommon/QTVulkanVideoDevice.h") + LIST(REMOVE_ITEM _files_to_moc "RvCommon/VulkanView.h" "RvCommon/VulkanWindow.h" "RvCommon/QTVulkanVideoDevice.h" "RvCommon/VulkanDesktopVideoDevice.h") ENDIF() FOREACH( diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 6805fc1c9..051667a0b 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -24,6 +24,13 @@ #include #include +#include + +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) +#include +#include +#endif + #include #include @@ -926,10 +933,37 @@ namespace Rv } #endif + bool DesktopVideoDevice::shouldUseVulkanPresentation() + { +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + // + // Presentation-output backend selection, matching the main view's rule + // in RvDocument: a 10-bit display request (RGB 10 + A 2) that this + // machine's Vulkan can actually present routes the second-display + // output through a Vulkan swapchain for true 10-bit, avoiding the + // 8-bit truncation of the OpenGL ScreenView path. Everything else + // stays on the OpenGL DesktopVideoDevice. + // + // supports10BitPresentation() is memoized, so this is cheap to re-call + // whenever the display output format or the main-view backend changes. + // + const Options& opts = Options::sharedOptions(); + const bool want10bit = (opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2); + + return want10bit && VulkanView::supports10BitPresentation(); +#else + return false; +#endif + } + std::vector DesktopVideoDevice::createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice) { std::vector devices; +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + const bool useVulkan = shouldUseVulkanPresentation(); +#endif + const auto screens = QGuiApplication::screens(); for (int screen = 0; screen < screens.size(); screen++) { @@ -941,7 +975,17 @@ namespace Rv name = QString("Screen %1").arg(screen); } - DesktopVideoDevice* sd = new DesktopVideoDevice(module, name.toUtf8().constData(), screen, shareDevice); + DesktopVideoDevice* sd = nullptr; +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + if (useVulkan) + { + sd = new VulkanDesktopVideoDevice(module, name.toUtf8().constData(), screen, shareDevice); + } + else +#endif + { + sd = new DesktopVideoDevice(module, name.toUtf8().constData(), screen, shareDevice); + } devices.push_back(sd); } diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index e5454309c..2791a5751 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -455,6 +455,8 @@ namespace Rv std::string QTVulkanVideoDevice::hardwareIdentification() const { return "vulkan-hybrid"; } + GLuint QTVulkanVideoDevice::fboID() const { return m_fbo ? m_fbo->fboID() : 0; } + void QTVulkanVideoDevice::cleanupSharedGLObjects(uint32_t slot) const { if (m_drawFbo[slot]) diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index dc78b2f2c..80e7d3829 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -241,6 +241,20 @@ namespace Rv static std::vector createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice); + // + // Effective presentation-backend decision, shared by + // createDesktopVideoDevices (the initial build) and + // DesktopVideoModule::rebuildDevices (live re-evaluation) so both + // agree on one GL-vs-Vulkan rule. True when the second-display output + // should be delivered through a Vulkan swapchain -- a 10-bit request + // that this machine's Vulkan can actually present -- false for the + // OpenGL ScreenView path. Always false on macOS. + // + // The underlying VulkanView::supports10BitPresentation() probe is + // memoized, so this is cheap to call on every rebuild request. + // + static bool shouldUseVulkanPresentation(); + protected: void addDefaultDataFormats(size_t bits = 8); void sortVideoFormatsByWidth(); diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 9a0c6d4ab..0a8bc2254 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -94,6 +94,17 @@ namespace Rv const TwkGLF::GLFBO* defaultFBO() const override; std::string hardwareIdentification() const override; + // + // GL id of the offscreen FBO once the context and FBO have been + // created, else 0. Unlike defaultFBO() this does NOT force context + // creation: it is a readiness probe. DesktopVideoDevice::transfer() + // returns early while its view device reports 0, which is how the + // first composite is deferred until the target exists -- so this must + // be overridden for a VulkanDesktopVideoDevice presentation output to + // ever receive a frame. + // + GLuint fboID() const override; + private: // Ensure the QOpenGLContext + FBO exist and match the current window size. // Makes the GL context current and binds the FBO on return. diff --git a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h new file mode 100644 index 000000000..e0e25b26d --- /dev/null +++ b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h @@ -0,0 +1,64 @@ +// +// Copyright (c) 2026 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +#ifndef __RvCommon__VulkanDesktopVideoDevice__h__ +#define __RvCommon__VulkanDesktopVideoDevice__h__ + +#include + +namespace Rv +{ + class VulkanView; + + // + // VulkanDesktopVideoDevice + // + // A desktop (second-display) presentation output device that delivers the + // final frame through a Vulkan swapchain for true 10-bit output on Linux + // and Windows, instead of the OpenGL ScreenView the base + // DesktopVideoDevice uses. + // + // It reuses the base class's frame handoff wholesale: the renderer's + // transfer() / transfer2() composite (including every stereo mode) into + // m_viewDevice->defaultFBO(), where m_viewDevice is the presentation + // VulkanView's QTVulkanVideoDevice. The only backend-specific behaviour is + // owning the Vulkan window and presenting it explicitly -- Vulkan has no + // QOpenGLWidget auto-composite -- so this subclass overrides only the + // window-lifecycle and present methods and inherits everything else + // (transfer/transfer2/fillWithTexture/format/data-format/sync) unchanged. + // + class VulkanDesktopVideoDevice : public DesktopVideoDevice + { + public: + VulkanDesktopVideoDevice(TwkApp::VideoModule* module, const std::string& name, int screen, const QTGLVideoDevice* shareDevice); + ~VulkanDesktopVideoDevice() override; + + // + // DesktopVideoDevice / VideoDevice API -- the backend-specific + // overrides. Note that none of these chain to the base + // implementation: the base drives m_view, a QOpenGLWidget, which is + // never created here. + // + void open(const StringVector& args) override; + void close() override; + bool isOpen() const override; + void makeCurrent() const override; + + void redraw() const override; + void redrawImmediately() const override; + void syncBuffers() const override; + + private: + // + // The presentation output window. It owns its own QTVulkanVideoDevice + // (VulkanView::videoDevice()), which is handed to the base + // m_viewDevice so the inherited transfer()/transfer2() drive it. + // + VulkanView* m_vulkanView; + }; + +} // namespace Rv + +#endif // __RvCommon__VulkanDesktopVideoDevice__h__ diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 44048ba37..02b6e4081 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -226,10 +226,18 @@ namespace Rv << " A" << opts.dispAlphaBits << " -> want10bit=" << (want10bit ? "true" : "false") << endl; } - bool useVulkan = false; + // + // Take the decision from DesktopVideoDevice, which applies the same + // rule to the second-display presentation output. Deriving both from + // one function is what keeps the main view and the presentation output + // on the same backend; a disagreement there is what left the second + // display black. want10bit above is kept only to phrase the + // diagnostics below. + // + const bool useVulkan = DesktopVideoDevice::shouldUseVulkanPresentation(); + if (want10bit) { - useVulkan = VulkanView::supports10BitPresentation(); if (ImageRenderer::debugGpu()) { cout << "INFO: RvDocument: supports10BitPresentation()=" << (useVulkan ? "true" : "false") << endl; diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp new file mode 100644 index 000000000..b57f38215 --- /dev/null +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -0,0 +1,140 @@ +// +// Copyright (c) 2026 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + +#include +#include +#include +#include + +namespace Rv +{ + using namespace std; + + VulkanDesktopVideoDevice::VulkanDesktopVideoDevice(TwkApp::VideoModule* module, const std::string& name, int screen, + const QTGLVideoDevice* shareDevice) + : DesktopVideoDevice(module, name, screen, shareDevice) + , m_vulkanView(nullptr) + { + } + + VulkanDesktopVideoDevice::~VulkanDesktopVideoDevice() { close(); } + + void VulkanDesktopVideoDevice::open(const StringVector& args) + { + // + // A passive Vulkan presentation surface. The null doc is what makes it + // passive: VulkanWindow::render() returns at `!session`, + // requestGLFallback() returns at `!m_doc`, and presentationAllowed() + // guards on it -- so this window never drives the session and never + // drags the main window into a GL fallback. It is composited into and + // presented by this device, via the inherited transfer()/transfer2() + // and syncBuffers() below. + // + m_vulkanView = new VulkanView(/*doc*/ nullptr, /*parent*/ nullptr, /*noResize*/ true); + + // + // The VulkanView owns its QTVulkanVideoDevice; handing it to the base + // m_viewDevice lets the inherited transfer()/transfer2() composite into + // its GL FBO exactly as they do for the GL ScreenView path. + // + // Note this does not go through setViewWidget(): that takes a + // QOpenGLWidget and there is none here, so the base m_view stays null + // and the translator has to be installed by hand. + // + setViewDevice(m_vulkanView->videoDevice()); + m_translator = new QTTranslator(this, m_vulkanView); + + // + // Place the window before showing it. VulkanView's constructor already + // realized the platform window, but the swapchain is only built on + // first expose -- so the geometry set here is what decides which + // screen it lands on. + // + const QRect g = screenGeometry(); + m_vulkanView->move(g.x(), g.y()); + m_vulkanView->setGeometry(g); + + m_vulkanView->setWindowState(useFullScreen() ? Qt::WindowFullScreen : Qt::WindowNoState); + + m_vulkanView->setGeometry(g); + + // + // show() both makes the window visible on the target screen and drives + // the expose that creates the Vulkan surface and swapchain. Without it + // the presentation surface never initializes and never presents. + // + m_vulkanView->show(); + + // + // Prime the offscreen GL context and FBO so the inherited transfer()'s + // readiness guard passes on the first frame: QTVulkanVideoDevice::fboID() + // reports 0 until the context and FBO have been created, and transfer() + // returns early while it does. + // + if (m_viewDevice) + { + m_viewDevice->makeCurrent(); + } + } + + void VulkanDesktopVideoDevice::close() + { + // + // The VulkanView owns its QTVulkanVideoDevice (== the base + // m_viewDevice), so detach the base pointer WITHOUT deleting it, then + // delete the view -- whose destructor frees the device and its + // Vulkan/interop resources. Chaining to DesktopVideoDevice::close() + // here would delete the device a second time. + // + setViewDevice(nullptr); + + delete m_vulkanView; + m_vulkanView = nullptr; + + delete m_translator; + m_translator = nullptr; + } + + bool VulkanDesktopVideoDevice::isOpen() const { return m_vulkanView != nullptr; } + + void VulkanDesktopVideoDevice::makeCurrent() const + { + if (m_viewDevice) + { + m_viewDevice->makeCurrent(); + } + } + + void VulkanDesktopVideoDevice::redraw() const + { + // + // Vulkan has no QOpenGLWidget auto-composite, so the frame the + // inherited transfer()/transfer2() already put in the + // QTVulkanVideoDevice FBO has to be presented explicitly. Neither the + // base redraw() (m_view->update(), and m_view is null) nor + // QTVulkanVideoDevice::redraw() (requestUpdate() -> render(), which + // returns immediately for a null doc) would present it. + // + if (m_viewDevice && m_vulkanView && m_vulkanView->isVisible()) + { + m_viewDevice->syncBuffers(); + } + } + + void VulkanDesktopVideoDevice::redrawImmediately() const { redraw(); } + + void VulkanDesktopVideoDevice::syncBuffers() const + { + if (m_viewDevice && m_vulkanView && m_vulkanView->isVisible()) + { + m_viewDevice->syncBuffers(); + } + } + +} // namespace Rv + +#endif // PLATFORM_LINUX || PLATFORM_WINDOWS diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index 36e1fb883..7ffd32fb8 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -71,7 +71,20 @@ namespace Rv // (height-based y-flip, mapToGlobal, mouse grab). // ostringstream str; - str << UI_APPLICATION_NAME " Main Window (Vulkan)" << "/" << m_doc; + if (m_doc) + { + str << UI_APPLICATION_NAME " Main Window (Vulkan)" << "/" << m_doc; + } + else + { + // + // A doc-less view is a presentation output owned by a + // VulkanDesktopVideoDevice. There can be one per screen and they + // all carry a null doc, so keying the name on the doc would give + // every one of them the same name; key it on the view instead. + // + str << UI_APPLICATION_NAME " Presentation (Vulkan)" << "/" << static_cast(this); + } m_videoDevice = new QTVulkanVideoDevice(nullptr, str.str(), m_vulkanWindow, m_container); m_vulkanWindow->setVideoDevice(m_videoDevice); m_vulkanWindow->setEventWidget(m_container); From 2bb850f72c103f1402367f79e33321dc3f2a7d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:40:22 -0400 Subject: [PATCH 05/48] fix(vulkan): advertise RGB10 on a Vulkan presentation device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DesktopVideoDevice's constructor calls addDefaultDataFormats(), whose depth defaults to 8, so a VulkanDesktopVideoDevice reported "RGB8" in Preferences > Video, in the top-view toolbar's device table and in humanReadableID() while presenting through a 10-bit swapchain. This was only ever a labelling bug, not truncation: depth is used solely to build the description string, and DesktopDataFormat forwards to DataFormat(const std::string&), which sets iformat = RGBA16F. That iformat is what picks the render target, so the presentation path is RGBA16F -> GL_RGB10_A2 shared texture -> A2B10G10R10 swapchain throughout. The number shown to the user was simply wrong. Two consequences worth knowing: - The persisted preference is safe. RvPreferences stores "dataFormat" as an index and setVideoDeviceStateFromSettings reads it back with toInt(); addDefaultDataFormats appends the same six stereo modes in the same order at any depth, so a user's stereo choice survives a GL <-> Vulkan device rebuild. - -presentData matches by description string, so "-presentData RGB8" no longer selects anything on a Vulkan presentation device, and a display profile saved against a VideoAndDataFormatID ending in RGB8 will not be found once the device reports RGB10. The coarser ModuleNameID and DeviceNameID profiles are unaffected. Signed-off-by: Cédrik Fuoco --- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index b57f38215..99877671b 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -19,6 +19,24 @@ namespace Rv : DesktopVideoDevice(module, name, screen, shareDevice) , m_vulkanView(nullptr) { + // + // The base constructor advertised RGB8. This device presents through a + // 10-bit Vulkan swapchain, so re-advertise at the depth actually + // delivered -- the depth is only ever used to build the description + // string, but that string is what Preferences, the top-view toolbar + // and humanReadableID() show. + // + // Claiming 10 here is sound: this class is only instantiated when + // DesktopVideoDevice::shouldUseVulkanPresentation() is true, which + // already required the 10-bit surface-format probe to succeed. + // + // addDefaultDataFormats() appends the same six stereo modes in the + // same order at any depth, so the indices are unchanged -- and the + // persisted "dataFormat" preference is an index, so a user's stereo + // choice survives a GL <-> Vulkan device rebuild. + // + m_dataFormats.clear(); + addDefaultDataFormats(10); } VulkanDesktopVideoDevice::~VulkanDesktopVideoDevice() { close(); } From 54fc89a0d0bfea29beb08bf0c2456973907d1861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:41:26 -0400 Subject: [PATCH 06/48] feat: rebuild the desktop presentation devices when the main-view backend changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presentation devices were built once at startup and only ever had their share device re-pointed afterwards, so their backend was frozen at whatever it was at launch. After a main-view backend transition the second display went black. Add DesktopVideoModule::rebuildDevices(), which re-evaluates the GL-vs-Vulkan rule and rebuilds the per-screen devices to match. It is a no-op returning false when the backend has not changed, so an unrelated depth change does not put a teardown transient on the second display; on a real change it closes each open device before destroying it, so no swapchain or ScreenView resources leak. It deliberately does not touch the session output device, because the devices it destroys may be referenced as that output. Add RvApplication::rebuildDesktopVideoDevices() to orchestrate: rebuild, re-bind the share device afterwards (so it never writes to an about-to-be-destroyed device), and re-open the presentation output, re-resolved by name through Options::presentDevice rather than by a pointer the rebuild may have invalidated. The critical piece is refreshing the session graph on a real rebuild. The DisplayGroupIPNodes built at startup by setPhysicalDevices still held the destroyed device pointers, so a later setOutputVideoDevice(newDevice) -> connectDisplayGroup -> findDisplayGroupByDevice(newDevice) matched nothing and silently no-op'd. That is the reported 10 -> 8 -> 10 -> enable-presentation black-screen repro, and it has to run even while presentation is off, since the pointers can change then and only be bound as the output later. Both hand-rolled setShareDevice loops in RvDocument now call through this instead. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoModule.cpp | 55 +++++++++ src/lib/app/RvCommon/RvApplication.cpp | 109 ++++++++++++++++++ .../RvCommon/RvCommon/DesktopVideoModule.h | 23 ++++ src/lib/app/RvCommon/RvCommon/RvApplication.h | 16 +++ src/lib/app/RvCommon/RvDocument.cpp | 40 +++---- 5 files changed, 219 insertions(+), 24 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoModule.cpp b/src/lib/app/RvCommon/DesktopVideoModule.cpp index d5aa56bbd..92ca6fa9f 100644 --- a/src/lib/app/RvCommon/DesktopVideoModule.cpp +++ b/src/lib/app/RvCommon/DesktopVideoModule.cpp @@ -8,6 +8,9 @@ #include #include #include +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) +#include +#endif #include #include #include @@ -39,6 +42,58 @@ namespace Rv DesktopVideoModule::~DesktopVideoModule() {} + bool DesktopVideoModule::rebuildDevices(const QTGLVideoDevice* shareDevice) + { + // + // Decide the target backend once (the probe behind it is memoized) and + // compare it to the backend the current devices were built with. On + // platforms without Vulkan both are false, so this is always a no-op. + // + const bool targetVulkan = DesktopVideoDevice::shouldUseVulkanPresentation(); + + bool currentVulkan = false; +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + for (size_t i = 0; i < m_devices.size(); ++i) + { + if (dynamic_cast(m_devices[i])) + { + currentVulkan = true; + break; + } + } +#endif + + // + // Backend unchanged: leave the devices in place so the second display + // does not go through a needless teardown. The caller still re-binds + // the share device on the existing devices. + // + if (!m_devices.empty() && currentVulkan == targetVulkan) + { + return false; + } + + // + // Backend changed (or this is the first build after an empty list): + // release the old devices cleanly. close() frees the Vulkan swapchain + // or GL ScreenView before the device is destroyed, mirroring the + // normal exit path, so no swapchain or interop resources leak. + // + for (size_t i = 0; i < m_devices.size(); ++i) + { + if (m_devices[i]->isOpen()) + { + m_devices[i]->close(); + } + delete m_devices[i]; + } + m_devices.clear(); + + m_devices = DesktopVideoDevice::createDesktopVideoDevices(this, shareDevice); + + return true; + } + string DesktopVideoModule::name() const { return "Desktop"; } void DesktopVideoModule::open() {} diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 9eaf1e2b0..43eaf58bd 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1948,6 +1948,115 @@ namespace Rv return options.toUtf8().constData(); } + void RvApplication::rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice) + { + if (!m_desktopModule) + return; + + // + // Capture the presentation state before any teardown. The selected + // screen is captured implicitly: it is Options::presentDevice, a + // stable device name re-resolved after the rebuild via + // findPresentationDevice, so we restore by name rather than by a + // pointer the rebuild may have destroyed. + // + const bool wasPresenting = m_presentationMode; + + TwkApp::Document* doc = TwkApp::Document::activeDocument(); + Rv::Session* session = doc ? static_cast(doc) : nullptr; + + // + // Rebuild the per-screen devices for the current backend. This is a + // no-op returning false when the backend has not changed; the + // share-device rebind below still runs, so a main-view swap that keeps + // the same backend is honored. + // + const bool rebuilt = m_desktopModule->rebuildDevices(shareDevice); + + // + // Re-bind the controller's current main-view device as the share + // device on every (possibly newly created) desktop device. This runs + // after the rebuild so it never writes to an about-to-be-destroyed + // device. + // + const VideoModule::VideoDevices& devices = m_desktopModule->devices(); + for (size_t i = 0; i < devices.size(); i++) + { + if (DesktopVideoDevice* dd = dynamic_cast(devices[i])) + { + dd->setShareDevice(shareDevice); + } + } + + // + // Refresh the session graph's per-physical-device display-group + // registry so it references the newly created device pointers. + // rebuildDevices() deleted the old per-screen devices and + // createDesktopVideoDevices() made new ones, but the graph's + // DisplayGroupIPNodes -- built once at startup by setPhysicalDevices + // -- still hold the destroyed pointers. Without this, a later + // setOutputVideoDevice(newDevice) -> connectDisplayGroup -> + // findDisplayGroupByDevice(newDevice) matches nothing and silently + // no-ops, so the presentation output is never rendered and the second + // display stays black. + // + // This mirrors the startup sequence, and must run on every real + // rebuild even when presentation is currently off: the device pointers + // can change while presentation is disabled and only be bound as the + // output later (the reported 10 -> 8 -> 10 -> enable repro). + // + if (rebuilt && session) + { + session->graph().setPhysicalDevices(videoModules()); + session->graph().setPrimaryDisplayGroup(session->controlVideoDevice()); + } + + // + // Re-establish the presentation output. The backend-transition callers + // reset the session output device to 0 while rebinding the control + // device, which is the root of the black second display. If + // presentation mode is on, re-open the presentation output on the + // selected screen with the new backend and bind it as the session + // output; a backend change also destroyed the old device, so this + // replaces any stale reference. + // + if (!wasPresenting || !session) + return; + + Rv::Options& opts = Rv::Options::sharedOptions(); + VideoDevice* d = findPresentationDevice(opts.presentDevice); + if (!d) + { + cerr << "ERROR: presentation device not found after rebuild." << endl; + session->setOutputVideoDevice(session->controlVideoDevice()); + m_presentationMode = false; + return; + } + + if (DesktopVideoDevice* dd = dynamic_cast(d)) + { + dd->setShareDevice(shareDevice); + } + + try + { + if (!d->isOpen()) + { + string optionArgs = setVideoDeviceStateFromSettings(d); + StringVector vargs; + algorithm::split(vargs, optionArgs, is_any_of(string(" \t\n\r")), token_compress_on); + d->open(vargs); + } + session->setOutputVideoDevice(d); + } + catch (std::exception& exc) + { + cerr << "ERROR: failed to re-open presentation device after rebuild: " << exc.what() << endl; + session->setOutputVideoDevice(session->controlVideoDevice()); + m_presentationMode = false; + } + } + bool RvApplication::isInPresentationMode() { return m_presentationMode; } int RvApplication::findVideoModuleIndexByName(const string& name) const diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h index fddf720cb..db5dc5b82 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h @@ -29,6 +29,29 @@ namespace Rv DesktopVideoModule(NativeDisplayPtr np, QTGLVideoDevice* shareDevice); virtual ~DesktopVideoModule(); + // + // Re-evaluate the presentation backend (GL ScreenView vs Vulkan + // swapchain) against the current display-depth preference and rebuild + // the per-screen devices to match, using shareDevice as the new GL + // share device. This is the post-startup analogue of the + // constructor's one-time createDesktopVideoDevices call, needed + // because the backend decision is no longer frozen at launch. + // + // A cleanly open device is closed -- releasing its Vulkan swapchain or + // GL ScreenView -- before it is destroyed. To avoid a needless + // teardown and a transient on the second display, this is a no-op when + // the effective backend has not changed; it then returns false and + // leaves the devices untouched (the caller still re-binds the share + // device). Returns true when the devices were actually rebuilt. + // + // This deliberately does not touch the session's output video device. + // The caller (RvApplication::rebuildDesktopVideoDevices) owns + // re-binding the share device and re-opening the presentation output, + // because the devices destroyed here may be referenced as the session + // output. + // + bool rebuildDevices(const QTGLVideoDevice* shareDevice); + virtual std::string name() const; virtual void open(); virtual void close(); diff --git a/src/lib/app/RvCommon/RvCommon/RvApplication.h b/src/lib/app/RvCommon/RvCommon/RvApplication.h index acb513f04..93ad2766d 100644 --- a/src/lib/app/RvCommon/RvCommon/RvApplication.h +++ b/src/lib/app/RvCommon/RvCommon/RvApplication.h @@ -126,6 +126,22 @@ namespace Rv void setPresentationMode(bool); bool isInPresentationMode(); + // + // Re-evaluate and rebuild the desktop presentation devices so their + // backend follows the current display-depth preference and the main + // view's live backend, then re-bind the share device and, if + // presentation mode is on, re-open the presentation output on the + // selected screen. + // + // Invoked from the RvDocument backend-transition points + // (setDisplayOutput / swapGLViewToVulkan / fallbackVulkanToGLView / + // rebuildGLView). shareDevice is the controller's new main-view GL + // device, or null when the main view has moved to Vulkan and there is + // no GL device to share. Fixes the frozen presentation bit depth and + // the black second display on a backend mismatch. + // + void rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice); + DesktopVideoModule* desktopVideoModule() const { return m_desktopModule; } static int parseInFiles(int argc, char* argv[]); diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 02b6e4081..5c6f26701 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -998,18 +998,13 @@ namespace Rv m_glView->videoDevice()->sendEvent(TwkApp::RenderContextChangeEvent("gl-context-changed", m_glView->videoDevice())); } - if (DesktopVideoModule* m = RvApp()->desktopVideoModule()) - { - const TwkApp::VideoModule::VideoDevices& devices = m->devices(); - - for (size_t i = 0; i < devices.size(); i++) - { - if (DesktopVideoDevice* d = dynamic_cast(devices[i])) - { - d->setShareDevice(m_glView->videoDevice()); - } - } - } + // + // Rebuild the desktop presentation devices for the GL backend, re-bind + // the share device, and re-open the presentation output on the + // selected screen, so the second display follows the main view back to + // OpenGL instead of being left mismatched (black). + // + RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice()); // // Defer the delete. This is reached from a queued callback posted by @@ -1122,18 +1117,15 @@ namespace Rv if (resetGLPrefs) resetGLStateAndPrefs(); - if (DesktopVideoModule* m = RvApp()->desktopVideoModule()) - { - const TwkApp::VideoModule::VideoDevices& devices = m->devices(); - - for (size_t i = 0; i < devices.size(); i++) - { - if (DesktopVideoDevice* d = dynamic_cast(devices[i])) - { - d->setShareDevice(m_glView->videoDevice()); - } - } - } + // + // Rebuild the desktop presentation devices against the current display + // depth, re-bind the share device, and re-open the presentation output + // on the selected screen. On an 8-bit depth change the backend does not + // cross the Vulkan threshold so the rebuild itself is a no-op, but the + // share device and the presentation output are still re-bound to the + // new GLView. + // + RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice()); m_glView->videoDevice()->translator().setCurrentModifiers(cur); m_oldGLView = oldGLView; From 6f0c552e1976accb42827eede608058bada15178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:42:13 -0400 Subject: [PATCH 07/48] feat: live-swap the main view between OpenGL and Vulkan on an 8/10-bit change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting 10-bit while running on the OpenGL GLView ran the display output through rebuildGLView() at 10 bits. OpenGL cannot present 10-bit on the affected hardware (Mesa GLX, Windows WGL negotiation), so the rebuild failed validity, popped a misleading "Display Configuration is Invalid" dialog and zeroed the preference on the way out. The backend was otherwise fixed at window construction, so a depth change needed a restart. setDisplayOutput() now persists the requested depth to both Options and QSettings before doing anything else -- it never zeroes the preference -- and then applies it: - 10-bit while on GLView: promote the live view with swapGLViewToVulkan() when the probe says 10-bit can be presented, or show an honest "this display cannot present 10-bit output" dialog and stay at 8-bit when it cannot. The GL rebuild path is never reached for a 10-bit request on these platforms. - 8-bit or default while Vulkan is live: fall back with fallbackVulkanToGLView(), which rebuilds GLView from the depth just persisted. That direction is always available, so it needs no restart. swapGLViewToVulkan() is the forward mirror of fallbackVulkanToGLView(). It commits optimistically: VulkanWindow creates its surface and swapchain from exposeEvent(), so initialization cannot be verified synchronously. m_vulkanView is assigned before show() because the existing backstop -- requestGLFallback() -> fallbackVulkanToGLView() -- is guarded on it, and render() no-ops until initialized, so nothing presents to an uninitialized swapchain. Worst case is a brief blank frame during the swap. m_glView is set to null on the Vulkan path because backend-neutral code across RvDocument keys the active backend on (!m_glView). The old GLView is lazy-deleted on a timer, mirroring rebuildGLView, since deleting it inline while the swap is settling can dump core. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvCommon/RvDocument.h | 4 + src/lib/app/RvCommon/RvDocument.cpp | 206 +++++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/src/lib/app/RvCommon/RvCommon/RvDocument.h b/src/lib/app/RvCommon/RvCommon/RvDocument.h index 9a7b6a804..c2f2c763c 100644 --- a/src/lib/app/RvCommon/RvCommon/RvDocument.h +++ b/src/lib/app/RvCommon/RvCommon/RvDocument.h @@ -99,6 +99,10 @@ namespace Rv // Replace a live VulkanView with GLView after a runtime Vulkan failure. void fallbackVulkanToGLView(); + + // Promote a live GLView to a VulkanView so a 10-bit request applies + // immediately -- the forward mirror of fallbackVulkanToGLView. + void swapGLViewToVulkan(); #endif const QAction* lastPopupAction() const { return m_lastPopupAction; } diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 5c6f26701..7e22febe8 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -1019,6 +1019,111 @@ namespace Rv newGLView->videoDevice()->makeCurrent(); newGLView->update(); } + + // + // Hot-swap GLView -> VulkanView and rebind the live session to the Vulkan + // device. The forward mirror of fallbackVulkanToGLView, reached when the + // user selects 10-bit while the main window is running on the OpenGL + // GLView and Vulkan reports 10-bit presentation support. + // + // The caller persists the desired 10-bit depth before invoking this, so a + // later Vulkan -> GL fallback rebuilds GL at the right depth. + // + void RvDocument::swapGLViewToVulkan() + { + if (!m_glView || isClosing()) + { + return; + } + + cout << "INFO: RvDocument: switching main view from OpenGL to Vulkan." << endl; + + // + // Flush any GLView still pending from an earlier swap before taking + // ownership of m_oldGLView below (mirrors rebuildGLView). + // + lazyDeleteGLView(); + + GLView* oldGLView = m_glView; + const Qt::KeyboardModifiers cur = oldGLView->videoDevice()->translator().currentModifiers(); + oldGLView->stopProcessingEvents(); + + VulkanView* newVulkanView = new VulkanView(this, m_centralWidget, !m_startupResize); + + newVulkanView->setContentSize(oldGLView->sizeHint().width(), oldGLView->sizeHint().height()); + newVulkanView->setMinimumContentSize(oldGLView->minimumSizeHint().width(), oldGLView->minimumSizeHint().height()); + newVulkanView->setMinimumSize(QSize(oldGLView->minimumSizeHint().width(), oldGLView->minimumSizeHint().height())); + newVulkanView->setFocusPolicy(Qt::StrongFocus); + newVulkanView->setMouseTracking(true); + newVulkanView->setAcceptDrops(true); + newVulkanView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + m_stackedLayout->addWidget(newVulkanView); + m_stackedLayout->removeWidget(oldGLView); + + // + // Optimistic commit. VulkanWindow creates its Vulkan surface and + // swapchain from exposeEvent(), so initialization is asynchronous and + // cannot be verified here. Commit to Vulkan now and rely on the + // existing backstop: if init later fails, VulkanWindow::initialize() + // calls requestGLFallback() -> fallbackVulkanToGLView(), which rebuilds + // a GLView. That backstop is guarded on m_vulkanView, so assign it + // before showing. Until the surface exists VulkanWindow::render() + // no-ops, so nothing presents to an uninitialized swapchain -- worst + // case a brief blank frame during the swap. + // + m_vulkanView = newVulkanView; + m_viewWidget = newVulkanView; + + // + // On the Vulkan path m_glView must be null: backend-neutral code + // across RvDocument keys the active backend on (!m_glView). + // + m_glView = nullptr; + + m_vulkanView->show(); + m_viewWidget->setFocus(Qt::OtherFocusReason); + + m_topViewToolBar->setDevice(m_vulkanView->videoDevice()); + + if (m_session) + { + const bool same = m_session->outputVideoDevice() == m_session->controlVideoDevice(); + m_session->setEventVideoDevice(0); + m_session->setOutputVideoDevice(0); + m_session->setControlVideoDevice(m_vulkanView->videoDevice()); + if (same) + { + m_session->setOutputVideoDevice(m_vulkanView->videoDevice()); + } + + m_vulkanView->videoDevice()->sendEvent(TwkApp::RenderContextChangeEvent("vulkan-context-changed", m_vulkanView->videoDevice())); + } + + // + // Rebuild the desktop presentation devices for the Vulkan backend, + // re-bind the share device and re-open the presentation output on the + // selected screen, so the second display agrees with the promoted + // Vulkan main view instead of showing black. There is no GL device to + // share from on this path, hence the null share device -- the + // ScreenView falls back to the default surface format and + // Qt::AA_ShareOpenGLContexts still puts every context in one group. + // + RvApp()->rebuildDesktopVideoDevices(nullptr); + + m_vulkanView->videoDevice()->translator().setCurrentModifiers(cur); + + // + // Lazy-delete the old GLView (mirrors rebuildGLView): deleting it + // inline while the swap is still settling can dump core. + // + m_oldGLView = oldGLView; + m_oldGLView->hide(); + QTimer::singleShot(100, this, SLOT(lazyDeleteGLView())); + + m_vulkanView->videoDevice()->makeCurrent(); + m_vulkanView->update(); + } #endif void RvDocument::resetSizePolicy() @@ -1203,8 +1308,109 @@ namespace Rv void RvDocument::setDisplayOutput(DisplayOutputType type) { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + // + // 10-bit output is delivered by the Vulkan backend. We must NOT + // rebuild the OpenGL context at 10-bit here: OpenGL cannot present + // 10-bit on the affected hardware (Mesa GLX, Windows WGL negotiation), + // so the rebuild fails validity and used to pop a misleading "Display + // Configuration is Invalid" dialog that also zeroed the preference. + // Persist the 10-bit intent instead, and apply it by promoting the + // live view to Vulkan. + // + if (type == OpenGL1010102) + { + // + // Persist the intent first, so a later Vulkan -> GL fallback + // rebuilds GL at the right depth and the next launch selects the + // Vulkan backend. Never reset or zero the preference here. + // + Rv::Options& opts = Options::sharedOptions(); + opts.dispRedBits = 10; + opts.dispGreenBits = 10; + opts.dispBlueBits = 10; + opts.dispAlphaBits = 2; + + { + RV_QSETTINGS; + settings.beginGroup("Display"); + settings.setValue("dispRedBits", 10); + settings.setValue("dispGreenBits", 10); + settings.setValue("dispBlueBits", 10); + settings.setValue("dispAlphaBits", 2); + settings.endGroup(); + } + + // + // Already on the Vulkan path: the running view is already 10-bit, + // so there is nothing to apply. + // + if (!m_glView) + return; + + // + // Vulkan can present 10-bit: promote the live GLView in place. No + // restart, no notice. + // + if (VulkanView::supports10BitPresentation()) + { + swapGLViewToVulkan(); + return; + } + + // + // Honest error: this hardware or driver cannot present 10-bit. Do + // not attempt the promotion; the view stays on 8-bit OpenGL. + // + QMessageBox box(this); + box.setWindowModality(Qt::WindowModal); +#ifdef PLATFORM_LINUX + // Show the RV icon so the source of the dialog is obvious. + box.setIconPixmap(QPixmap(qApp->applicationDirPath() + QString(RV_ICON_PATH_SUFFIX)).scaledToHeight(64)); +#else + box.setIcon(QMessageBox::Critical); +#endif + box.setWindowTitle(tr(UI_APPLICATION_NAME ": 10-bit Display Output Unavailable")); + box.setText(tr("This display cannot present 10-bit output")); + box.setInformativeText( + tr("This graphics hardware or driver does not provide a 10-bit presentation surface. " UI_APPLICATION_NAME + " cannot output 10-bit on this display and will continue in 8-bit.")); + + box.exec(); + return; + } + + // + // Switching away from 10-bit while the Vulkan backend is live + // (m_glView is null). Persist the new depth and hot-swap Vulkan -> + // OpenGL so the change applies immediately. Vulkan -> GL is always + // available -- it is the same path taken when Vulkan presentation + // fails at runtime -- so unlike the 10-bit request above this needs no + // restart. + // if (!m_glView) + { + const int bits = (type == OpenGL8888) ? 8 : 0; + const int alpha = (type == OpenGL8888) ? 8 : 0; + + Rv::Options& opts = Options::sharedOptions(); + opts.dispRedBits = bits; + opts.dispGreenBits = bits; + opts.dispBlueBits = bits; + opts.dispAlphaBits = alpha; + + { + RV_QSETTINGS; + settings.beginGroup("Display"); + settings.setValue("dispRedBits", bits); + settings.setValue("dispGreenBits", bits); + settings.setValue("dispBlueBits", bits); + settings.setValue("dispAlphaBits", alpha); + settings.endGroup(); + } + + fallbackVulkanToGLView(); return; + } #endif const bool vsync = m_glView->format().swapInterval() == 1; const bool stereo = m_glView->format().stereo(); From cbfa44cdb54a86a19c9c1fe0497bbbe765bad22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 10:59:58 -0400 Subject: [PATCH 08/48] fix(vulkan): do not deliver destroyed() into a half-destructed VulkanView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disabling presentation mode aborted with ASSERT failure in Rv::VulkanView: "Called object is not of the correct type (class destructor may have already run)", qobjectdefs_impl.h:121 VulkanDesktopVideoDevice::close() deletes its VulkanView directly. The ~VulkanView body runs first, then ~QWidget destroys the widget's own QWidgetWindow and its children -- both of which emit destroyed() at a point where the VulkanView sub-object no longer exists. Qt's assertObjectType dynamic_casts the receiver before invoking a member slot, that cast fails, and Q_ASSERT_X aborts. A presentation output view is what makes this reachable. It is top-level, so window() returns the view itself and watchParentWindow() connects parentWindowDestroyed to its *own* QWidgetWindow, which dies with it. The main view watches the enclosing document window, which outlives it, so it never delivered the signal during destruction. Fix both ends. The destructor severs the watched-window connection and the viewport-window destroyed lambda before the base destructors run -- the lambda would not assert, being a functor, but would write m_vulkanWindow through a dangling this. And watchParentWindow() no longer watches anything when the view is its own top level: the point of that machinery is surviving Qt replacing the *enclosing* window, and a standalone output window has no enclosing tree to lose. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanView.cpp | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index 7ffd32fb8..d0f8bbd0b 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -118,8 +118,33 @@ namespace Rv // it would survive as a stray top-level owning the viewport window, // still pointing at a deleted device. // + // Everything below also has to happen before ~QWidget runs. + // + // ~QWidget destroys the widget's own QWidgetWindow and its child + // widgets, and both of those emit destroyed() -- at a point where the + // VulkanView sub-object is already gone. Delivering either signal + // there invokes a slot on an object that no longer dynamic_casts to + // VulkanView, which is a hard Q_ASSERT_X in Qt + // (qobjectdefs_impl.h assertObjectType) for the parentWindowDestroyed + // member slot, and a write through a dangling `this` for the lambda + // below. + // + // A presentation output view is what makes this reachable: it is + // top-level, so the window it watches is its own (see + // watchParentWindow) and dies with it. The main view watches the + // enclosing document window, which outlives it. + // + if (m_watchedParentConnection) + { + disconnect(m_watchedParentConnection); + m_watchedParentConnection = QMetaObject::Connection(); + } + m_watchedParentWindow = nullptr; + if (m_vulkanWindow) { + disconnect(m_vulkanWindow, nullptr, this, nullptr); + m_vulkanWindow->setVideoDevice(nullptr); m_vulkanWindow->setEventWidget(nullptr); } @@ -155,7 +180,17 @@ namespace Rv // knowable before the container gets around to re-parenting the // viewport into it. // + // Nothing to watch when this view is its own top level, as a + // presentation output view is. The point of this is to survive Qt + // replacing the *enclosing* window (see parentWindowDestroyed); a + // standalone output window has no such enclosing tree, and watching + // itself only creates a connection that fires while the view is being + // destroyed. + // QWidget* topLevel = window(); + if (topLevel == this) + return; + QWindow* topLevelWindow = topLevel ? topLevel->windowHandle() : nullptr; if (topLevelWindow == m_watchedParentWindow) From 08248fc0a9706e0cbdc2ee2952769f5f1642afa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 10 Sep 2026 11:12:39 -0400 Subject: [PATCH 09/48] fix(vulkan): stop stealing the GL context from the presentation output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With 10-bit presentation enabled, an annotation stroke drew its first point and then stopped following the pointer. 10-bit alone was fine. Session::askForRedraw() redraws the control device *and* the output device, and it runs from arbitrary places -- including a mouse-motion handler, mid-stroke, with the main view's GL context current. VulkanDesktopVideoDevice::redraw() presented from there, and presenting means QTVulkanVideoDevice::syncBuffers(), which makes its own offscreen context current and never restores the previous one. The stroke's first point landed, the context was then silently swapped out from under the paint code, and every subsequent GL call went to the presentation device's context. It also ran a full vsync-blocking swapchain present per motion event. The GL output path never had this problem: its syncBuffers() is a QOpenGLWidget update(), which schedules a composite without touching the current context. Make redraw()/redrawImmediately() do nothing. The present is already driven in-frame: askForRedraw() redraws the control device too, which schedules VulkanWindow::render(), which renders, composites into this device via the inherited transfer(), and presents it through syncBuffers(). A doc-less presentation window also presents once on expose, so it is never left blank. Restore the viewport's context in VulkanWindow::render() after presenting the output device, for the same reason -- otherwise postRender() and everything after the frame run with the presentation device's context current. Signed-off-by: Cédrik Fuoco --- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 34 +++++++++++++------ src/lib/app/RvCommon/VulkanWindow.cpp | 11 ++++++ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 99877671b..1e1aa8999 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -130,17 +130,31 @@ namespace Rv void VulkanDesktopVideoDevice::redraw() const { // - // Vulkan has no QOpenGLWidget auto-composite, so the frame the - // inherited transfer()/transfer2() already put in the - // QTVulkanVideoDevice FBO has to be presented explicitly. Neither the - // base redraw() (m_view->update(), and m_view is null) nor - // QTVulkanVideoDevice::redraw() (requestUpdate() -> render(), which - // returns immediately for a null doc) would present it. + // Deliberately empty -- do NOT present from here. + // + // Session::askForRedraw() calls redraw() on the control device and on + // the output device, and it is called from arbitrary places, including + // from inside a mouse-motion handler while an annotation stroke is + // being drawn with the main view's GL context current. + // + // Presenting here means QTVulkanVideoDevice::syncBuffers(), which + // makes its own offscreen context current and does not restore the + // previous one. That silently steals the context from whatever was + // mid-draw: the first stroke point lands, then every later GL call + // goes to the presentation device's context and the stroke stops. It + // would also run a full vsync-blocking swapchain present per motion + // event. + // + // Nothing is lost by doing nothing. askForRedraw() also redraws the + // control device, which schedules VulkanWindow::render(); that renders + // the frame, composites into this device through the inherited + // transfer(), and then presents this device in-frame via + // syncBuffers(). A doc-less presentation window additionally presents + // once on expose, so it is never left blank. + // + // The base does effectively the same thing: its m_view->update() only + // asks Qt to composite a QOpenGLWidget whose paintGL() is empty. // - if (m_viewDevice && m_vulkanView && m_vulkanView->isVisible()) - { - m_viewDevice->syncBuffers(); - } } void VulkanDesktopVideoDevice::redrawImmediately() const { redraw(); } diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 561f2795a..87ee68eb6 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -2071,6 +2071,17 @@ namespace Rv if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) { session->outputVideoDevice()->syncBuffers(); + + // + // Presenting the output device made *its* offscreen GL context + // current and did not put ours back, so restore it before + // postRender() and anything else that runs after this frame + // expects the viewport's context. The GL output path never + // needed this: its syncBuffers() is a QOpenGLWidget update(), + // which schedules a composite without touching the current + // context. + // + m_videoDevice->makeCurrent(); } } From 4ef7e1cf327c9c4376fb51ff02e2545b4d0de85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 11:25:26 -0400 Subject: [PATCH 10/48] fix(vulkan): keep the presentation output inert for input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A doc-less VulkanView is a passive presentation output owned by a VulkanDesktopVideoDevice: it is composited into and presented by that device and must never take part in input handling. Giving it focus is actively harmful -- a second focusable top-level fights the main window for activation, and the resulting WindowActivate storm starves the event loop, which was observed as annotation strokes never receiving their drag events. Build a passive output with no focus, no focus proxy and no event widget, so QTVulkanVideoDevice never creates a translator for it and VulkanWindow::event() bails at !hasTranslator() -- the window is inert by construction rather than relying on each handler to notice it has no document. WA_ShowWithoutActivating keeps show() from stealing activation, and WindowDoesNotAcceptFocus keeps the window manager from handing it back later. Also correct the reasoning in VulkanDesktopVideoDevice::redraw(): it claimed Session::askForRedraw() reaches it, which it cannot, because askForRedraw() casts to TwkGLF::GLVideoDevice while every desktop output device derives from the sibling GLBindableVideoDevice. The empty override still matters, because DesktopVideoDevice::syncBuffers() does reach redraw(). Signed-off-by: Cédrik Fuoco --- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 49 ++++++++++++++----- src/lib/app/RvCommon/VulkanView.cpp | 34 +++++++++++-- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 1e1aa8999..4e29c7350 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -64,6 +64,26 @@ namespace Rv // and the translator has to be installed by hand. // setViewDevice(m_vulkanView->videoDevice()); + + // + // Never take focus or activation. This is a second-display output + // surface, not something the user interacts with: a focusable + // top-level here fights the main window for activation, and the + // resulting WindowActivate storm saturates the event loop -- which is + // what stopped annotation strokes from ever receiving their drag + // events. WA_ShowWithoutActivating keeps show() from stealing + // activation; WindowDoesNotAcceptFocus keeps the window manager from + // handing it back later. + // + m_vulkanView->setAttribute(Qt::WA_ShowWithoutActivating, true); + m_vulkanView->setWindowFlag(Qt::WindowDoesNotAcceptFocus, true); + + // + // The base class allocates a translator alongside its ScreenView (see + // setViewWidget), so one is created here for parity. It is inert: + // DesktopVideoDevice::translator() has no callers, and the view is + // built without an event widget so it produces no events of its own. + // m_translator = new QTTranslator(this, m_vulkanView); // @@ -132,25 +152,28 @@ namespace Rv // // Deliberately empty -- do NOT present from here. // - // Session::askForRedraw() calls redraw() on the control device and on - // the output device, and it is called from arbitrary places, including - // from inside a mouse-motion handler while an annotation stroke is - // being drawn with the main view's GL context current. + // Note that Session::askForRedraw() cannot actually reach this: it + // casts the output device to TwkGLF::GLVideoDevice, and every desktop + // output device derives from TwkGLF::GLBindableVideoDevice, a sibling + // class. The override is defence, not a hot path -- but the behaviour + // it defends against is real, because redraw() is also reachable from + // DesktopVideoDevice::syncBuffers() and from arbitrary callers, + // including from inside a mouse-motion handler while an annotation + // stroke is being drawn with the main view's GL context current. // // Presenting here means QTVulkanVideoDevice::syncBuffers(), which // makes its own offscreen context current and does not restore the // previous one. That silently steals the context from whatever was // mid-draw: the first stroke point lands, then every later GL call // goes to the presentation device's context and the stroke stops. It - // would also run a full vsync-blocking swapchain present per motion - // event. - // - // Nothing is lost by doing nothing. askForRedraw() also redraws the - // control device, which schedules VulkanWindow::render(); that renders - // the frame, composites into this device through the inherited - // transfer(), and then presents this device in-frame via - // syncBuffers(). A doc-less presentation window additionally presents - // once on expose, so it is never left blank. + // would also run a swapchain present per motion event. + // + // Nothing is lost by doing nothing. askForRedraw() redraws the control + // device, which schedules VulkanWindow::render(); that renders the + // frame, composites into this device through the inherited transfer(), + // and then presents this device in-frame via syncBuffers(). A doc-less + // presentation window additionally presents once on expose, so it is + // never left blank. // // The base does effectively the same thing: its m_view->update() only // asks Qt to composite a QOpenGLWidget whose paintGL() is empty. diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index d0f8bbd0b..72978d025 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -44,7 +44,19 @@ namespace Rv // Embed the native window in the widget tree. // m_container = QWidget::createWindowContainer(m_vulkanWindow, this); - m_container->setFocusPolicy(Qt::StrongFocus); + + // + // A doc-less view is a passive presentation output owned by a + // VulkanDesktopVideoDevice: it is composited into and presented by + // that device and must never take part in input handling. Giving it + // focus is actively harmful -- a second top-level that accepts focus + // fights the main window for activation, and the resulting + // WindowActivate storm starves the event loop (observed as annotation + // strokes never receiving their drag events). + // + const bool passiveOutput = (m_doc == nullptr); + + m_container->setFocusPolicy(passiveOutput ? Qt::NoFocus : Qt::StrongFocus); // // Create the platform surface up-front: Qt can only hand out a @@ -85,12 +97,26 @@ namespace Rv // str << UI_APPLICATION_NAME " Presentation (Vulkan)" << "/" << static_cast(this); } - m_videoDevice = new QTVulkanVideoDevice(nullptr, str.str(), m_vulkanWindow, m_container); + // + // No event widget for a passive output: QTVulkanVideoDevice only + // builds a QTTranslator when given one, and VulkanWindow::event() + // bails at !hasTranslator(), so this makes the whole window inert for + // input instead of relying on each handler to notice it has no doc. + // + m_videoDevice = new QTVulkanVideoDevice(nullptr, str.str(), m_vulkanWindow, passiveOutput ? nullptr : m_container); m_vulkanWindow->setVideoDevice(m_videoDevice); - m_vulkanWindow->setEventWidget(m_container); + m_vulkanWindow->setEventWidget(passiveOutput ? nullptr : m_container); setObjectName((m_doc && m_doc->session()) ? m_doc->session()->name().c_str() : "no session"); - setFocusProxy(m_container); + + if (!passiveOutput) + { + setFocusProxy(m_container); + } + else + { + setFocusPolicy(Qt::NoFocus); + } // // Realize the top-level's window now, and watch for Qt replacing it. From 75d35b5e5885beeabed982e87dac2721d82d2972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 11:25:44 -0400 Subject: [PATCH 11/48] chore(vulkan): report frame timing and interactive latency under -debug gpu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentation-mode latency could not be attributed from the outside: the frame loop, the GPU, the swapchain and the Qt event loop all plausibly explain "the annotation trails the cursor", and guessing between them wasted several rounds. Measure them instead. VulkanWindow::render() now accumulates an averaged breakdown every 60 frames -- session->render(), viewport present split into fence-wait vs acquire, output present, postRender, and the loop period -- plus the pointer side: per-event handler cost, eventToRender (input to start-of-render), and eventToRetire. eventToRetire is the one that matters and the one nothing measured before: the age of the pointer event a frame answered, closed out when that frame's GPU work retires, sampled with a non-blocking vkGetFenceStatus poll per slot per frame. It is end-to-end interactive latency. eventToRender covers only the input half, and stayed flat at ~4.5ms across every configuration while the feel changed completely, which is precisely why it explained nothing. GLWindow gets the comparable subset so the two backends can be read side by side. It has no mainPresent term by construction: QOpenGLWindow swaps the control surface after paintGL returns, so that cost lands outside the measured region. QTVulkanVideoDevice reports its present path (GPU-interop vs CPU-fallback) per device and on every transition, not latched on the first frame -- the first syncBuffers() can run before that window's Vulkan is initialised, and latching there reported CPU-fallback for a device that then spent its whole life on interop. Also records, above useOptimalTilingForInterop(), that OPTIMAL tiling was measured on AMD (RADV, Mesa) and renders tile-pattern garbage, because GL is radeonsi there and GL_OPTIMAL_TILING_EXT carries no cross-driver layout guarantee. All of it is gated on ImageRenderer::debugGpu(). Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/GLWindow.cpp | 61 ++++ .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 11 +- src/lib/app/RvCommon/VulkanWindow.cpp | 321 +++++++++++++++++- 3 files changed, 385 insertions(+), 8 deletions(-) diff --git a/src/lib/app/RvCommon/GLWindow.cpp b/src/lib/app/RvCommon/GLWindow.cpp index 0d39ea258..5f031f368 100644 --- a/src/lib/app/RvCommon/GLWindow.cpp +++ b/src/lib/app/RvCommon/GLWindow.cpp @@ -34,6 +34,18 @@ namespace Rv { + + // Accumulators for the -debug gpu frame-time report in paintGL(), + // mirroring VulkanWindow's so the two backends can be compared. + static unsigned int s_glDiagFrames = 0; + static double s_glDiagRenderMs = 0.0; + static double s_glDiagOutPresentMs = 0.0; + // Wall clock between successive paintGL() entries: the loop period. For + // the GL path this includes the implicit buffer swap, which happens after + // paintGL returns. + static double s_glDiagLoopMs = 0.0; + static TwkUtil::Timer s_glDiagLoopTimer; + using namespace std; using namespace TwkApp; using namespace IPCore; @@ -219,6 +231,14 @@ namespace Rv } } + // See s_glDiagLoopTimer. + if (IPCore::ImageRenderer::debugGpu()) + { + if (s_glDiagLoopTimer.isRunning()) + s_glDiagLoopMs += s_glDiagLoopTimer.elapsed() * 1000.0; + s_glDiagLoopTimer.start(); + } + if (m_doc && session && m_videoDevice) { m_videoDevice->makeCurrent(); @@ -243,7 +263,17 @@ namespace Rv m_videoDevice->setAbsolutePosition(x, y); TWK_GLDEBUG; + // Same breakdown as VulkanWindow, so the GL presentation path can + // be compared like-for-like. + const bool diagTiming = IPCore::ImageRenderer::debugGpu(); + Timer diagTimer; + if (diagTiming) + diagTimer.start(); + session->render(); + + if (diagTiming) + s_glDiagRenderMs += diagTimer.elapsed() * 1000.0; TWK_GLDEBUG; m_firstPaintCompleted = true; @@ -272,14 +302,45 @@ namespace Rv // If a separate output device is presenting, sync it. The control // (window) surface presents itself: QOpenGLWindow swaps automatically // after paintGL returns. + const bool diagPresent = IPCore::ImageRenderer::debugGpu(); + Timer diagPresentTimer; + if (session->outputVideoDevice() != m_videoDevice) { + if (diagPresent) + diagPresentTimer.start(); + session->outputVideoDevice()->syncBuffers(); + + if (diagPresent) + s_glDiagOutPresentMs += diagPresentTimer.elapsed() * 1000.0; } session->addSyncSample(); session->postRender(); + // + // Note there is no "mainPresent" term here: QOpenGLWindow swaps the + // control surface implicitly after paintGL returns, so that cost lands + // outside this function. + // + if (IPCore::ImageRenderer::debugGpu()) + { + if (++s_glDiagFrames >= 60) + { + const double n = double(s_glDiagFrames); + const double loopMs = s_glDiagLoopMs / n; + cout << "INFO: GLWindow frame avg over " << s_glDiagFrames << ": session->render()=" << (s_glDiagRenderMs / n) + << "ms outputPresent=" << (s_glDiagOutPresentMs / n) + << "ms total=" << ((s_glDiagRenderMs + s_glDiagOutPresentMs) / n) << "ms frameInterval=" << loopMs << "ms (" + << (loopMs > 0.0 ? 1000.0 / loopMs : 0.0) << " fps)" << endl; + s_glDiagFrames = 0; + s_glDiagRenderMs = 0.0; + s_glDiagOutPresentMs = 0.0; + s_glDiagLoopMs = 0.0; + } + } + m_eventProcessingTimer.start(); TWK_GLDEBUG; diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 0a8bc2254..988c41863 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -146,11 +146,12 @@ namespace Rv mutable std::array m_sharedHeight{}; // Which present path this device last reported: -1 nothing yet, - // 0 CPU-fallback, 1 GPU-interop. Per-device, and reported on every - // transition rather than latched on the first frame, because the first - // syncBuffers() can run before that window's Vulkan is initialized -- - // latching there reports CPU-fallback for a device that then spends its - // whole life on interop. + // 0 CPU-fallback, 1 GPU-interop. Per-device, because the presentation + // output has its own device and can land on a different path than the + // viewport -- and reported on every transition rather than latched on + // the first frame, because the first syncBuffers() can run before that + // window's Vulkan is initialized. Latching there reports CPU-fallback + // for a device that then spends its whole life on interop. mutable int m_loggedPresentPath{-1}; // Latched once any GL call on the interop path reports an error. The diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 87ee68eb6..9208cbe6d 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -50,6 +50,57 @@ namespace Rv { using namespace std; + + // Accumulators for the -debug gpu frame-time report in render(). One + // VulkanWindow drives the frame loop, so file statics are sufficient. + static unsigned int s_diagFrames = 0; + static double s_diagRenderMs = 0.0; + static double s_diagMainPresentMs = 0.0; + static double s_diagOutPresentMs = 0.0; + static double s_diagFenceWaitMs = 0.0; + static double s_diagAcquireMs = 0.0; + // Wall clock between successive render() entries: the loop period. + static double s_diagLoopMs = 0.0; + static TwkUtil::Timer s_diagLoopTimer; + // addSyncSample()/postRender() run inside render() but after the presents, + // so they are part of the frame period without being part of "total". + static double s_diagPostRenderMs = 0.0; + // Pointer side of the same report. handler = time spent inside the + // Mu/annotation handler for one pointer event; eventToRender = age of the + // newest pointer event when the frame answering it starts rendering, so it + // includes the handler and any wait in the event loop. + static double s_diagPointerHandlerMs = 0.0; + static unsigned int s_diagPointerEvents = 0; + static double s_diagPointerAgeMs = 0.0; + static unsigned int s_diagPointerAgeSamples = 0; + static TwkUtil::Timer s_diagPointerTimer; + static bool s_diagPointerPending = false; + + // eventToRetire: age of the pointer event a frame answered, measured when + // that frame's GPU work retires. This is the end-to-end interactive + // latency; eventToRender covers only the input half of it. + // + // Closing a sample out needs an absolute clock, because a frame retires + // some frames after the event that produced it, so one free-running timer + // is read as a timestamp source. + static TwkUtil::Timer s_diagClock; + + static double diagNow() + { + if (!s_diagClock.isRunning()) + s_diagClock.start(); + return s_diagClock.elapsed(); + } + + // Timestamp of the pointer event the frame currently being rendered + // answers (-1 when this frame answers no new event), handed to the + // in-flight slot when that frame is submitted. + static double s_diagFrameEventTime = -1.0; + static std::array s_diagSlotEventTime{}; + static std::array s_diagSlotArmed{}; + static double s_diagEventToRetireMs = 0.0; + static unsigned int s_diagEventToRetireSamples = 0; + using namespace TwkApp; using namespace IPCore; @@ -670,6 +721,80 @@ namespace Rv // vkDestroySurfaceKHR if needed. For safety we don't destroy instance/surface here, they are tied to Qt. } + // + // Present mode + // + // FIFO everywhere, which is what a viewport and a presentation output + // both want: every image scanned out, none torn. + // + // The env overrides are for measurement. Note that MAILBOX only differs + // from FIFO once the loop is fast enough to fill a swapchain queue; below + // that, both acquires return immediately and the mode is not observable. + // + // RV_VULKAN_PRESENT_MODE (control viewport) + // RV_VULKAN_OUTPUT_PRESENT_MODE (passive presentation output) + // with values fifo | relaxed | mailbox | immediate. + // + static const char* presentModeName(VkPresentModeKHR m) + { + switch (m) + { + case VK_PRESENT_MODE_IMMEDIATE_KHR: + return "IMMEDIATE"; + case VK_PRESENT_MODE_MAILBOX_KHR: + return "MAILBOX"; + case VK_PRESENT_MODE_FIFO_KHR: + return "FIFO"; + case VK_PRESENT_MODE_FIFO_RELAXED_KHR: + return "FIFO_RELAXED"; + default: + return "(other)"; + } + } + + static bool presentModeFromName(const char* name, VkPresentModeKHR& mode) + { + if (!name) + return false; + const string n(name); + if (n == "fifo") + mode = VK_PRESENT_MODE_FIFO_KHR; + else if (n == "relaxed") + mode = VK_PRESENT_MODE_FIFO_RELAXED_KHR; + else if (n == "mailbox") + mode = VK_PRESENT_MODE_MAILBOX_KHR; + else if (n == "immediate") + mode = VK_PRESENT_MODE_IMMEDIATE_KHR; + else + return false; + return true; + } + + // FIFO is the only mode required to be supported, so it is always the + // last resort of the preference list. + static VkPresentModeKHR choosePresentMode(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, bool passiveOutput) + { + uint32_t count = 0; + vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, nullptr); + std::vector available(count); + if (count) + vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, available.data()); + + const auto supported = [&](VkPresentModeKHR m) { return std::find(available.begin(), available.end(), m) != available.end(); }; + + VkPresentModeKHR forced = VK_PRESENT_MODE_FIFO_KHR; + if (presentModeFromName(getenv(passiveOutput ? "RV_VULKAN_OUTPUT_PRESENT_MODE" : "RV_VULKAN_PRESENT_MODE"), forced)) + { + if (supported(forced)) + return forced; + cout << "WARNING: VulkanWindow: requested present mode " << presentModeName(forced) << " is unsupported; using FIFO" << endl; + return VK_PRESENT_MODE_FIFO_KHR; + } + + (void)passiveOutput; + return VK_PRESENT_MODE_FIFO_KHR; + } + bool VulkanWindow::createSwapchain() { if (!m_vkDevice || !m_vkSurface) @@ -820,12 +945,30 @@ namespace Rv return false; } + // A doc-less window is a passive presentation output; see + // choosePresentMode(). + const VkPresentModeKHR presentMode = choosePresentMode(m_vkPhysicalDevice, m_vkSurface, /*passiveOutput*/ m_doc == nullptr); + uint32_t imageCount = capabilities.minImageCount + 1; + // MAILBOX only stays non-blocking with an image to spare: one being + // scanned out, one queued as the newest-wins candidate, one to render + // into. With fewer, acquire blocks and the mode buys nothing. + if (presentMode == VK_PRESENT_MODE_MAILBOX_KHR && imageCount < 3) + { + imageCount = 3; + } if (capabilities.maxImageCount > 0 && imageCount > capabilities.maxImageCount) { imageCount = capabilities.maxImageCount; } + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: createSwapchain: " << (m_doc ? "control viewport" : "presentation output") + << ": presentMode=" << presentModeName(presentMode) << " images=" << imageCount + << " (surface min=" << capabilities.minImageCount << " max=" << capabilities.maxImageCount << ")" << endl; + } + VkSwapchainCreateInfoKHR createInfo = {}; createInfo.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR; createInfo.surface = m_vkSurface; @@ -838,7 +981,7 @@ namespace Rv createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; createInfo.preTransform = capabilities.currentTransform; createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; - createInfo.presentMode = VK_PRESENT_MODE_FIFO_KHR; // VSync + createInfo.presentMode = presentMode; createInfo.clipped = VK_TRUE; // Warm recreate: hand the retiring swapchain to the driver so it can reuse // its backing resources (much cheaper than a cold create on every resize). @@ -1095,6 +1238,17 @@ namespace Rv m_sharedCapacityH[slot] = 0; } + // + // A best-effort present that skipped has to be retried, or the output is + // left showing the frame before the one just composited -- and nothing + // else will come back for it, because the control viewport only renders + // when the session asks. The OpenGL output never needed this: its + // m_view->update() is a dirty flag Qt is obliged to honour eventually. + // + // QWindow::requestUpdate() coalesces, so at most one retry is ever + // outstanding. render() picks it up in the passive-output branch and + // re-presents the frame already sitting in the device's FBO. + // void VulkanWindow::drainSharedSemaphores(uint32_t slot) { // No shared image for this slot yet -> the GL side never signaled/waited @@ -1581,17 +1735,36 @@ namespace Rv if (!m_vkDevice || !m_vkSharedImage[slot] || !m_vkSwapchain) return; + // Split the present cost into fence-wait vs acquire, the only two + // blocking calls here, so GPU back-pressure can be told apart from + // swapchain/vblank back-pressure. Main viewport only. + const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; + Timer diagTimer; + // Start-of-frame throttle: wait for this slot's previous frame to finish // before reusing its acquire semaphore and per-frame resources. This // replaces the old end-of-frame block; with FIFO acquire back-pressure it // is what paces the loop to display refresh while still allowing // FRAMES_IN_FLIGHT frames outstanding. + if (diagPresent) + diagTimer.start(); + vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + if (diagPresent) + s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; + // Acquire image uint32_t imageIndex; + if (diagPresent) + diagTimer.start(); + VkResult result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, UINT64_MAX, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + + if (diagPresent) + s_diagAcquireMs += diagTimer.elapsed() * 1000.0; + if (result == VK_ERROR_OUT_OF_DATE_KHR) { // The GL side already signaled glReady[slot]/waited vkReady[slot] this @@ -1745,6 +1918,15 @@ namespace Rv return; } + // Hand this frame's pointer-event timestamp to the slot so + // eventToRetire can be closed out when the fence signals. + if (m_doc && s_diagFrameEventTime >= 0.0) + { + s_diagSlotEventTime[slot] = s_diagFrameEventTime; + s_diagSlotArmed[slot] = true; + s_diagFrameEventTime = -1.0; + } + // The frame is committed to the GPU; advance the ring now so the next // frame uses the other slot. imageIndex/slot below are locals, so this is // safe before the present call. @@ -2013,6 +2195,37 @@ namespace Rv if (!session) return; + // See s_diagLoopTimer. + if (IPCore::ImageRenderer::debugGpu()) + { + if (s_diagLoopTimer.isRunning()) + s_diagLoopMs += s_diagLoopTimer.elapsed() * 1000.0; + s_diagLoopTimer.start(); + + if (s_diagPointerPending) + { + s_diagPointerAgeMs += s_diagPointerTimer.elapsed() * 1000.0; + ++s_diagPointerAgeSamples; + s_diagPointerPending = false; + // This frame answers that event; presentSharedImage() pins the + // timestamp to the slot it submits into. + s_diagFrameEventTime = diagNow() - s_diagPointerTimer.elapsed(); + } + + // Close out any slot whose GPU work has retired since last frame. + // vkGetFenceStatus does not block, so this costs two calls a frame + // and works at any pipeline depth, with one frame of quantisation. + for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + { + if (s_diagSlotArmed[i] && m_vkDevice && m_vkFence[i] && vkGetFenceStatus(m_vkDevice, m_vkFence[i]) == VK_SUCCESS) + { + s_diagEventToRetireMs += (diagNow() - s_diagSlotEventTime[i]) * 1000.0; + ++s_diagEventToRetireSamples; + s_diagSlotArmed[i] = false; + } + } + } + if (m_doc && session && m_videoDevice) { m_videoDevice->makeCurrent(); @@ -2031,8 +2244,17 @@ namespace Rv absolutePosition(x, y); m_videoDevice->setAbsolutePosition(x, y); + // Frame-time breakdown; see the report at the end of render(). + const bool diagTiming = IPCore::ImageRenderer::debugGpu(); + Timer diagTimer; + if (diagTiming) + diagTimer.start(); + session->render(); + if (diagTiming) + s_diagRenderMs += diagTimer.elapsed() * 1000.0; + if (!m_postFirstNonEmptyRender && session->postFirstNonEmptyRender()) { m_postFirstNonEmptyRender = true; @@ -2062,16 +2284,30 @@ namespace Rv // Skipping it leaves the main window on a stale frame once // presentation mode is on. // + const bool diagPresent = IPCore::ImageRenderer::debugGpu(); + Timer diagPresentTimer; + if (diagPresent) + diagPresentTimer.start(); + m_videoDevice->syncBuffers(); + if (diagPresent) + s_diagMainPresentMs += diagPresentTimer.elapsed() * 1000.0; + // // In presentation mode the output is a distinct fullscreen window // that must also be presented this frame. // if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) { + if (diagPresent) + diagPresentTimer.start(); + session->outputVideoDevice()->syncBuffers(); + if (diagPresent) + s_diagOutPresentMs += diagPresentTimer.elapsed() * 1000.0; + // // Presenting the output device made *its* offscreen GL context // current and did not put ours back, so restore it before @@ -2087,8 +2323,59 @@ namespace Rv if (session) { + // See s_diagPostRenderMs. + const bool diagPost = IPCore::ImageRenderer::debugGpu(); + Timer diagPostTimer; + if (diagPost) + diagPostTimer.start(); + session->addSyncSample(); session->postRender(); + + if (diagPost) + s_diagPostRenderMs += diagPostTimer.elapsed() * 1000.0; + } + + // + // Report an averaged breakdown every 60 frames: where the frame goes + // (session render vs viewport present vs output present), and what the + // pointer sees end to end. + // + if (IPCore::ImageRenderer::debugGpu() && m_doc) + { + if (++s_diagFrames >= 60) + { + const double n = double(s_diagFrames); + const double loopMs = s_diagLoopMs / n; + cout << "INFO: VulkanWindow frame avg over " << s_diagFrames + << " [tiling=" << (m_sharedImageInfo[0].optimalTiling ? "OPTIMAL" : "LINEAR") << "]" + << ": session->render()=" << (s_diagRenderMs / n) << "ms mainPresent=" << (s_diagMainPresentMs / n) + << "ms outputPresent=" << (s_diagOutPresentMs / n) + << "ms total=" << ((s_diagRenderMs + s_diagMainPresentMs + s_diagOutPresentMs) / n) + << "ms [mainPresent breakdown: fenceWait=" << (s_diagFenceWaitMs / n) << "ms acquire=" << (s_diagAcquireMs / n) + << "ms]" + << " postRender=" << (s_diagPostRenderMs / n) << "ms frameInterval=" << loopMs << "ms (" + << (loopMs > 0.0 ? 1000.0 / loopMs : 0.0) << " fps)" + << " pointer: events=" << s_diagPointerEvents + << " handler=" << (s_diagPointerEvents ? s_diagPointerHandlerMs / s_diagPointerEvents : 0.0) + << "ms eventToRender=" << (s_diagPointerAgeSamples ? s_diagPointerAgeMs / s_diagPointerAgeSamples : 0.0) + << "ms eventToRetire=" << (s_diagEventToRetireSamples ? s_diagEventToRetireMs / s_diagEventToRetireSamples : 0.0) + << "ms" << endl; + s_diagFrames = 0; + s_diagRenderMs = 0.0; + s_diagMainPresentMs = 0.0; + s_diagOutPresentMs = 0.0; + s_diagFenceWaitMs = 0.0; + s_diagAcquireMs = 0.0; + s_diagLoopMs = 0.0; + s_diagPostRenderMs = 0.0; + s_diagPointerHandlerMs = 0.0; + s_diagPointerEvents = 0; + s_diagPointerAgeMs = 0.0; + s_diagPointerAgeSamples = 0; + s_diagEventToRetireMs = 0.0; + s_diagEventToRetireSamples = 0; + } } m_eventProcessingTimer.start(); @@ -2267,7 +2554,14 @@ namespace Rv // only to drop modifier state that went stale while the keyboard // was elsewhere. // - m_videoDevice->translator().resetModifiers(); + // Guarded: a passive presentation output window is built with no + // event widget, so its device has no translator (the general + // hasTranslator() check below this switch is too late). + // + if (m_videoDevice->hasTranslator()) + { + m_videoDevice->translator().resetModifiers(); + } break; case QEvent::Enter: @@ -2381,7 +2675,28 @@ namespace Rv if (session) session->setEventVideoDevice(videoDevice()); - if (m_videoDevice->translator().sendQTEvent(event, activationTime)) + // See s_diagPointerHandlerMs. A drag arrives here and is dispatched + // synchronously into Mu, so this call *is* the handler's cost. + const bool diagPointer = + IPCore::ImageRenderer::debugGpu() + && (event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonPress || event->type() == QEvent::TabletMove); + Timer diagPointerTimer; + if (diagPointer) + { + diagPointerTimer.start(); + s_diagPointerTimer.start(); + s_diagPointerPending = true; + } + + const bool handled = m_videoDevice->translator().sendQTEvent(event, activationTime); + + if (diagPointer) + { + s_diagPointerHandlerMs += diagPointerTimer.elapsed() * 1000.0; + ++s_diagPointerEvents; + } + + if (handled) { event->accept(); return true; From 1322bcfb7ee6c54d44ef9511ab1f6b64df524e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 11:29:00 -0400 Subject: [PATCH 12/48] perf(vulkan): present the control viewport one frame deep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotation trailed the cursor whenever presentation mode was on. The cause was not throughput: measured on a 4K presentation output, OpenGL and Vulkan ran presentation mode at the same frame interval (24-44ms vs 23-41ms), and only the Vulkan one felt like it lagged. What differed was how old the displayed pixels were. With two frames in flight the CPU runs a frame ahead, so the screen answers input from two frames back -- some 60ms at 30fps. The OpenGL path is effectively one deep, because paintGL() draws and Qt's following swap waits on that same work. Giving up the second frame costs no measurable frame rate here, since the loop is GPU-bound either way: fenceWait was 11-25ms in presentation mode against 0.004ms without it. So block on the frame's own fence after its present is queued, rather than letting the next frame's start-of-frame wait absorb it two frames later. The present is queued first so the driver still gets the frame as early as possible; this only stops the CPU running ahead. The passive presentation output is excluded. Measured after: eventToRetire settled at eventToRender plus one frame interval, with no queue left behind it. RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 restores the previous behaviour, for a machine where the CPU has enough independent work to be worth overlapping. The effective depth is reported in the -debug gpu frame line, because a silent knob cost a wasted test round. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 6 ++ src/lib/app/RvCommon/VulkanWindow.cpp | 107 +++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index 2189cf47b..1df2de05b 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -131,6 +131,12 @@ namespace Rv // the Vulkan slot for the frame being rendered. uint32_t currentFrame() const { return m_currentFrame; } + // A doc-less window is a passive presentation output: it is composited + // into and presented by its owning VulkanDesktopVideoDevice and never + // drives the frame loop. It must therefore never block that loop either + // -- see the best-effort present in presentSharedImage(). + bool isPassiveOutput() const { return m_doc == nullptr; } + const SharedImageInfo* getSharedImageInfo(int w, int h); void presentSharedImage(); diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 9208cbe6d..08d5b45fe 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1138,23 +1138,74 @@ namespace Rv return disabled; } - // NVIDIA 550+ drivers return blank pixels to OpenGL for LINEAR shared - // images >= ~2 MiB (forum thread #349436). Allocating the shared image with - // VK_IMAGE_TILING_OPTIMAL avoids that broken linear path and restores - // correct zero-copy interop, so OPTIMAL is the default on NVIDIA. Both the - // GL and Vulkan sides here are the same NVIDIA driver/GPU, so the - // vendor-private optimal layout matches on import without needing explicit - // DRM-format-modifier negotiation. AMD/Intel keep the existing LINEAR path. - // Set RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND to revert NVIDIA to LINEAR - // (reproduces the blank-image bug, for debugging). + // How many frames the control viewport may keep in flight. Default 1: + // block on this frame's fence before returning, rather than letting the + // next frame's start-of-frame wait absorb it two frames later. + // RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 restores the deeper pipeline. // - // The vendor decides this, not the platform: NVIDIA ships one driver core - // behind both GL and Vulkan on Windows as well as Linux, and the shared - // image is always well past the threshold because getSharedImageInfo() - // allocates at screen capacity -- ~8 MiB for a 1080p A2B10G10R10 image, - // ~33 MiB at 4K. The AMD rationale for LINEAR is specific to Mesa, where - // GL and Vulkan are separate drivers (radeonsi vs RADV) so - // GL_OPTIMAL_TILING_EXT carries no cross-driver layout guarantee. + // Depth is latency, not throughput, and separating the two is what + // fixed the "annotation trails the cursor in presentation mode" bug. + // Both backends ran presentation mode at a similar frame interval, so + // throughput was never the difference; what differed was how old the + // displayed pixels were. With two frames in flight the screen answers + // input from two frames back, and the OpenGL path is effectively one + // deep because paintGL() draws and Qt's following swap waits on that + // same work. + // + // Giving up the second frame costs no measurable frame rate here + // because the loop is GPU-bound either way, and it buys a whole frame: + // measured on a 4K presentation output, eventToRetire settled to + // eventToRender + one frame interval, with no queue left behind it. + unsigned int maxFramesInFlight() + { + static const unsigned int depth = [] + { + const unsigned int kDefault = 1; + const char* v = getenv("RV_VULKAN_MAX_FRAMES_IN_FLIGHT"); + if (!v) + return kDefault; + const int n = atoi(v); + if (n < 1 || n > static_cast(VulkanWindow::FRAMES_IN_FLIGHT)) + { + cout << "WARNING: VulkanWindow: RV_VULKAN_MAX_FRAMES_IN_FLIGHT must be 1.." << VulkanWindow::FRAMES_IN_FLIGHT + << "; using " << kDefault << endl; + return kDefault; + } + return static_cast(n); + }(); + return depth; + } + + // Tiling for the GL<->Vulkan shared image: OPTIMAL on NVIDIA, LINEAR + // everywhere else. + // + // NVIDIA needs OPTIMAL. Its 550+ drivers return blank pixels to OpenGL + // for LINEAR shared images >= ~2 MiB (forum thread #349436), and + // OPTIMAL avoids that broken linear path. It is safe there because the + // GL and Vulkan sides are the same driver, so the vendor-private + // optimal layout matches on import. Set + // RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND to revert NVIDIA to + // LINEAR (reproduces the blank-image bug, for debugging). + // + // The vendor decides this, not the platform: NVIDIA ships one driver + // core behind both GL and Vulkan on Windows as well as Linux, which is + // why the check below covers both, and the shared image is always well + // past the threshold because getSharedImageInfo() allocates at screen + // capacity -- ~8 MiB for a 1080p A2B10G10R10 image, ~33 MiB at 4K. + // + // Everywhere else LINEAR is not conservatism, it is the only correct + // choice: OPTIMAL was measured on AMD (RADV PHOENIX2, Mesa) and renders + // tile-pattern garbage -- sparse tile-aligned fragments of the frame, + // the rest dropped. Under Mesa, GL and Vulkan are different drivers + // (radeonsi vs RADV) and GL_OPTIMAL_TILING_EXT carries no cross-driver + // layout guarantee, so the importer reads a layout the exporter never + // wrote. Making OPTIMAL usable off NVIDIA means negotiating the layout + // explicitly with VK_EXT_image_drm_format_modifier. + // + // This costs real time at a 4K presentation output -- the GL side blits + // a full frame into the linear image and Vulkan blits it back out every + // present, both without their tiled fast paths -- so it is worth + // revisiting, but not by flipping this flag. bool useOptimalTilingForInterop(VkPhysicalDevice dev) { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) @@ -1943,6 +1994,16 @@ namespace Rv presentInfo.pImageIndices = &imageIndex; VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + + // Depth-1 pipeline, opt-in: see maxFramesInFlight(). Done after the + // present is queued so the driver still gets the frame as early as + // possible; this only stops the CPU running a second frame ahead. The + // passive output is excluded -- it is best-effort by design and must + // never block the loop. + if (maxFramesInFlight() == 1 && !isPassiveOutput()) + { + vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + } // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and // can be reported persistently by some X11/RADV compositors; recreating // on it every frame caused a swapchain-recreate loop that starved the Qt @@ -2148,6 +2209,16 @@ namespace Rv presentInfo.pImageIndices = &imageIndex; VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + + // Depth-1 pipeline, opt-in: see maxFramesInFlight(). Done after the + // present is queued so the driver still gets the frame as early as + // possible; this only stops the CPU running a second frame ahead. The + // passive output is excluded -- it is best-effort by design and must + // never block the loop. + if (maxFramesInFlight() == 1 && !isPassiveOutput()) + { + vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + } // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and // can be reported persistently by some X11/RADV compositors; recreating // on it every frame caused a swapchain-recreate loop that starved the Qt @@ -2347,8 +2418,8 @@ namespace Rv { const double n = double(s_diagFrames); const double loopMs = s_diagLoopMs / n; - cout << "INFO: VulkanWindow frame avg over " << s_diagFrames - << " [tiling=" << (m_sharedImageInfo[0].optimalTiling ? "OPTIMAL" : "LINEAR") << "]" + cout << "INFO: VulkanWindow frame avg over " << s_diagFrames << " [depth=" << maxFramesInFlight() + << " tiling=" << (m_sharedImageInfo[0].optimalTiling ? "OPTIMAL" : "LINEAR") << "]" << ": session->render()=" << (s_diagRenderMs / n) << "ms mainPresent=" << (s_diagMainPresentMs / n) << "ms outputPresent=" << (s_diagOutPresentMs / n) << "ms total=" << ((s_diagRenderMs + s_diagMainPresentMs + s_diagOutPresentMs) / n) From ce660449d7de302524988cb1f9449df7371a6844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 11:39:25 -0400 Subject: [PATCH 13/48] perf(vulkan): match the presentation output's FBO to its swapchain format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QTVulkanVideoDevice allocated its offscreen FBO as GL_RGBA16F_ARB for every device. The control viewport needs that depth -- session->render() composites the whole main view into it across blended passes -- but a passive presentation output never does. It is only ever a blit destination for the inherited transfer()/transfer2()/fillWithTexture(), which hand over an already-composited frame. Keeping it at 16F there cost two full passes' worth of bandwidth at a 3840x2160 output: transfer() wrote 8 bytes/px, some 66MB, and syncBuffers() read all of it back to convert down to the 10-bit shared image. Matching the shared image's format halves both, and the conversion happens once, in a blit that was already going to run. The output is 10-bit either way, so no precision is lost that the present did not already discard. Measured on a 4K presentation output: the per-frame GPU wait dropped from 23ms to 5.5-16ms, frame interval from 33-45ms to 18-29ms, and eventToRetire from 38-49ms to 22-33ms -- parity with presentation off. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 33 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 2791a5751..26e576a0c 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -349,12 +349,41 @@ namespace Rv m_fboColorTex = 0; } + // + // RGBA16F for the control viewport, RGB10_A2 for a passive + // presentation output. + // + // The control viewport needs the half-float depth: session->render() + // composites the whole main view into this FBO across multiple + // blended passes. A passive output never does -- it is only ever a + // blit destination for the inherited + // transfer()/transfer2()/fillWithTexture(), which hand over an + // already-composited frame. + // + // Keeping it at 16F there costs two full passes' worth of bandwidth + // at a 4K output: transfer() writes 8 bytes/px (66 MB) and + // syncBuffers() reads all of it back to convert down to the + // 10-bit shared image. Matching the shared image's format halves + // both, and the conversion happens once, in the blit that was + // already going to run. The final output is 10-bit either way, so + // no precision is lost that the present did not already discard. + // + const bool passiveOutput = m_window && m_window->isPassiveOutput(); + const GLenum fboFormat = passiveOutput ? GL_RGB10_A2 : GL_RGBA16F_ARB; + glGenTextures(1, &m_fboColorTex); glBindTexture(GL_TEXTURE_RECTANGLE_ARB, m_fboColorTex); - glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, GL_RGBA16F_ARB, newW, newH, 0, GL_RGBA, GL_FLOAT, nullptr); + if (passiveOutput) + { + glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, fboFormat, newW, newH, 0, GL_RGBA, GL_UNSIGNED_INT_2_10_10_10_REV, nullptr); + } + else + { + glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, fboFormat, newW, newH, 0, GL_RGBA, GL_FLOAT, nullptr); + } glBindTexture(GL_TEXTURE_RECTANGLE_ARB, 0); - m_fbo = new TwkGLF::GLFBO(newW, newH, GL_RGBA16F_ARB); + m_fbo = new TwkGLF::GLFBO(newW, newH, fboFormat); m_fbo->attachColorTexture(GL_TEXTURE_RECTANGLE_ARB, m_fboColorTex); GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT); From 5173d5fe45f2010d49970ec93b78db2a5e567d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 11:39:44 -0400 Subject: [PATCH 14/48] perf(vulkan): skip an output present while its GPU work is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenGL presentation path never lets the second display gate the viewport's loop: DesktopVideoDevice::syncBuffers() is a coalesced QOpenGLWidget::update() on an empty paintGL() that Qt drops when it falls behind. The Vulkan path queued its output work unconditionally, every frame, and at a 4K output that work is what the control viewport ends up waiting for in vkWaitForFences. Gate the output present on whether this device's own GPU work has caught up, rather than on whether the swapchain is full -- the latter never fires, because a loop slower than the display always leaves the queue room. canPresentNow() is checked from syncBuffers() ahead of any GL work, so a skipped frame costs nothing at all instead of costing the full-resolution blit, and no GL semaphore has been signalled yet so there is nothing to rebalance. The blocking paths inside presentSharedImage()/presentPixelData() keep a second, later skip for safety, which does have to drain the semaphore pair. A skipped present must be retried or the output is left on the frame before the one just composited, with nothing else coming back for it: the control viewport only renders when the session asks. Qt's update() is a dirty flag it must eventually honour, so parity needs the same guarantee -- requestBestEffortRetry() posts a coalesced UpdateRequest that render() serves from its passive-output branch, and a staleness timer forces a blocking frame through if the output has gone unpresented for more than 100ms. Note this does not currently fire on the hardware it was developed against: with a one-deep control pipeline the viewport waits for its own GPU work each frame, which gives the output's fences time to retire, so the gate passes every time and outputPresent never reaches zero. It is kept as the protection for the reverse balance -- a slower output display, a heavier output scene, or a faster control GPU -- which also means its skip and starvation paths are untested in practice. Isolated in its own commit so it can be dropped with a single revert. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 11 ++ src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 10 ++ src/lib/app/RvCommon/VulkanWindow.cpp | 161 +++++++++++++++++-- 3 files changed, 172 insertions(+), 10 deletions(-) diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 26e576a0c..719240d6c 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -655,6 +655,17 @@ namespace Rv if (w <= 0 || h <= 0) return; + // + // Best-effort gate for a passive presentation output: skip the whole + // frame while this device's own GPU work is still in flight, rather + // than queueing another full-resolution blit behind it. Checked here, + // ahead of the GL work below, so a skipped frame costs nothing -- the + // blit into the shared image is the single most expensive thing in + // this function at a 4K output. Always true for the control viewport. + // + if (m_window->isPassiveOutput() && !m_window->canPresentNow()) + return; + if (!m_glContext->makeCurrent(m_offscreenSurface)) return; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index 1df2de05b..c953562f2 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -137,6 +137,12 @@ namespace Rv // -- see the best-effort present in presentSharedImage(). bool isPassiveOutput() const { return m_doc == nullptr; } + // Best-effort gate for a passive presentation output, called by + // QTVulkanVideoDevice::syncBuffers() *before* it does any GL work. + // False means skip this frame entirely; a retry is armed internally so + // the frame is not lost. Always true for the control viewport. + bool canPresentNow(); + const SharedImageInfo* getSharedImageInfo(int w, int h); void presentSharedImage(); @@ -171,6 +177,7 @@ namespace Rv private: bool initVulkan(); void cleanupVulkan(); + void requestBestEffortRetry(); bool createSwapchain(); void cleanupSwapchain(); @@ -195,6 +202,9 @@ namespace Rv QEvent::Type m_lastKeyType; Timer m_activityTimer; Timer m_activationTimer; + // Time since a passive output last actually presented; drives the + // forward-progress guard in canPresentNow(). + Timer m_lastPresentTimer; QTimer m_eventProcessingTimer; // Vulkan state diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 08d5b45fe..a9b36958c 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1300,6 +1300,63 @@ namespace Rv // outstanding. render() picks it up in the passive-output branch and // re-presents the frame already sitting in the device's FBO. // + // + // Best-effort gate for a passive presentation output. + // + // The OpenGL presentation path gets this for free: its syncBuffers() is a + // QOpenGLWidget::update() that Qt coalesces and drops when it falls + // behind, so the second display skips frames under load rather than + // deepening the GPU queue. The Vulkan path queues its work + // unconditionally, and at a 4K output that work is what the control + // viewport ends up waiting for in vkWaitForFences. + // + // Gate on whether this device's *own* GPU work has caught up, not on + // whether the swapchain is full -- with a slower loop than display the + // queue always has room, so a swapchain-full test never fires. + // + // Called before any GL work, so a skipped frame costs nothing -- in + // particular no GL semaphore has been signaled yet, so there is nothing to + // rebalance. + // + bool VulkanWindow::canPresentNow() + { + if (!isPassiveOutput()) + return true; + + // Nothing allocated yet: let the frame through so syncBuffers() can + // build the swapchain and shared image. + if (!m_vkDevice || !m_vkSwapchain) + return true; + + // + // Forward progress. Under sustained GPU pressure the catch-up test + // below can be false indefinitely, which would freeze the presentation + // display rather than merely thin it out. Qt's update() is a dirty flag + // it must eventually service; matching that behaviour means forcing a + // blocking frame through once the output has gone stale for longer than + // a few refreshes. + // + static const double kMaxStaleSeconds = 0.1; + if (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds) + return true; + + // waitAll with a zero timeout: every in-flight frame of this device + // must have retired, not just the one two frames back that this slot + // happens to own. + const VkResult r = vkWaitForFences(m_vkDevice, FRAMES_IN_FLIGHT, m_vkFence.data(), VK_TRUE, 0); + if (r == VK_SUCCESS) + return true; + + requestBestEffortRetry(); + return false; + } + + void VulkanWindow::requestBestEffortRetry() + { + if (!m_stopProcessingEvents && isExposed()) + requestUpdate(); + } + void VulkanWindow::drainSharedSemaphores(uint32_t slot) { // No shared image for this slot yet -> the GL side never signaled/waited @@ -1792,30 +1849,70 @@ namespace Rv const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; Timer diagTimer; - // Start-of-frame throttle: wait for this slot's previous frame to finish - // before reusing its acquire semaphore and per-frame resources. This - // replaces the old end-of-frame block; with FIFO acquire back-pressure it - // is what paces the loop to display refresh while still allowing - // FRAMES_IN_FLIGHT frames outstanding. + // + // Best-effort present for a passive presentation output. + // + // This function has exactly two blocking calls, the fence wait and the + // acquire, and on the control viewport they are the throttle: FIFO + // acquire back-pressure plus the start-of-frame fence is what paces + // the loop to display refresh. + // + // A presentation output should not be part of that. render() presents + // the control viewport and then the output inside one frame, so a + // second blocking pair puts a second display's vblank in the loop's + // path. The OpenGL output is never in it: DesktopVideoDevice's + // syncBuffers() is a coalesced QOpenGLWidget::update() on an empty + // paintGL() that Qt drops when it falls behind. + // + // So do the same explicitly -- poll with a zero timeout and skip the + // frame when the swapchain cannot take an image right now, leaving the + // output on its previous frame as a dropped Qt update would. See + // canPresentNow() for the gate that fires first, before any GL work. + // + const bool bestEffort = isPassiveOutput(); + const uint64_t waitTimeout = bestEffort ? 0 : UINT64_MAX; + if (diagPresent) diagTimer.start(); - vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout); if (diagPresent) s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; + if (fenceResult == VK_TIMEOUT) + { + // The GL side already signaled glReady[slot] and waited vkReady[slot] + // for this frame, so the pair has to be rebalanced before bailing -- + // same contract as the VK_ERROR_OUT_OF_DATE_KHR path below. The slot + // is deliberately not advanced: the next frame retries this one. + drainSharedSemaphores(slot); + requestBestEffortRetry(); + return; + } + // Acquire image uint32_t imageIndex; if (diagPresent) diagTimer.start(); VkResult result = - vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, UINT64_MAX, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); if (diagPresent) s_diagAcquireMs += diagTimer.elapsed() * 1000.0; + if (result == VK_NOT_READY || result == VK_TIMEOUT) + { + // No image free this frame. An acquire that fails this way leaves + // m_vkImageAvailableSemaphore[slot] unsignaled, so nothing leaks -- + // which is why the skip has to happen here and not after a + // successful acquire. + drainSharedSemaphores(slot); + requestBestEffortRetry(); + return; + } + if (result == VK_ERROR_OUT_OF_DATE_KHR) { // The GL side already signaled glReady[slot]/waited vkReady[slot] this @@ -1995,6 +2092,11 @@ namespace Rv VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + // This device has presented; the forward-progress guard in + // canPresentNow() measures staleness from here. + m_lastPresentTimer.stop(); + m_lastPresentTimer.start(); + // Depth-1 pipeline, opt-in: see maxFramesInFlight(). Done after the // present is queued so the driver still gets the frame as early as // possible; this only stops the CPU running a second frame ahead. The @@ -2045,8 +2147,19 @@ namespace Rv // Start-of-frame throttle (matches presentSharedImage): wait for this // slot's previous frame to finish before reusing its staging buffer, - // acquire semaphore and command resources. - vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + // acquire semaphore and command resources. A passive presentation output + // polls instead of blocking and skips the frame -- see the best-effort + // present in presentSharedImage() for why. + const bool bestEffort = isPassiveOutput(); + const uint64_t waitTimeout = bestEffort ? 0 : UINT64_MAX; + + if (vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout) == VK_TIMEOUT) + { + // Unlike the interop path there are no GL<->Vulkan semaphores to + // rebalance here: presentCpuFallback() hands over plain pixels. + requestBestEffortRetry(); + return; + } size_t size = w * h * 4; @@ -2094,7 +2207,14 @@ namespace Rv // Acquire image uint32_t imageIndex; VkResult result = - vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, UINT64_MAX, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + if (result == VK_NOT_READY || result == VK_TIMEOUT) + { + // Best-effort: no image free this frame, leave the output on the one + // it is already showing and come back for it. + requestBestEffortRetry(); + return; + } if (result == VK_ERROR_OUT_OF_DATE_KHR) { handleSwapchainOutOfDate(); @@ -2210,6 +2330,11 @@ namespace Rv VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + // This device has presented; the forward-progress guard in + // canPresentNow() measures staleness from here. + m_lastPresentTimer.stop(); + m_lastPresentTimer.start(); + // Depth-1 pipeline, opt-in: see maxFramesInFlight(). Done after the // present is queued so the driver still gets the frame as early as // possible; this only stops the CPU running a second frame ahead. The @@ -2262,6 +2387,22 @@ namespace Rv return; } + // + // A passive presentation output never drives the frame loop below: it + // is composited into and presented by its owning + // VulkanDesktopVideoDevice, in-frame, from the control viewport's + // render(). The only reason it gets an UpdateRequest of its own is a + // best-effort present that was skipped (see requestBestEffortRetry), + // so re-present what the device already composited -- the same handoff + // exposeEvent() uses. + // + if (isPassiveOutput()) + { + if (m_videoDevice) + m_videoDevice->syncBuffers(); + return; + } + IPCore::Session* session = m_doc ? m_doc->session() : nullptr; if (!session) return; From 8c922f57ed89b9f4b7d0f4870a741b76f522bf81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 11:39:59 -0400 Subject: [PATCH 15/48] fix(color): keep the display colour pipeline across a main-view backend swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching between 8- and 10-bit reset the display transfer function from sRGB to None, discarding any assigned display profile with it. Two causes, in sequence. IPGraph::deviceChanged() adopted newDevice->physicalDevice() unconditionally. VideoDevice's constructor seeds m_physicalDevice with the device itself, and a viewport device only learns the monitor it sits on when it first renders, via setAbsolutePosition() -> deviceFromPosition(). Its one caller is Session::setControlVideoDevice(), which during a backend swap runs on a view that has never rendered -- so physicalDevice() was still that view, and the display group's device.name was rewritten from the monitor ("Dell Inc. DELL U2725QE DP-1") to the viewport's own name ("RV Main Window (Vulkan)/0x..."). Only adopt a physical device the new device actually knows; the monitor has not changed, only the object drawing to it. RvApplication::rebuildDesktopVideoDevices() then called IPGraph::setPhysicalDevices() to refresh stale device pointers, but that is the startup routine: it deletes every DisplayGroupIPNode and rebuilds them, and a new display group comes with a new colorPipeline holding default contents. So the group was discarded and its colour state with it. Add IPGraph::refreshPhysicalDevices(), which re-points existing groups at the rebuilt devices by (module name, device name) -- the same key display profiles are stored under, and stable across a rebuild because createDesktopVideoDevices() names every screen from its QScreen regardless of backend. Groups are created or deleted only for devices that genuinely appeared or vanished. It also clears a group's output device when that pointer is not among the new devices. findDisplayGroupByDevice() compares raw pointers, so a dangling one can alias a freshly allocated device at the same address and return the wrong group. The control device is kept, being alive and never in the module list. Both changes are needed: the guard alone still lost the group to the rebuild, and the refresh alone could not match a group whose name had already been rewritten. Note this touches IPCore paths shared with SDI/AJA output and multi-monitor setups, which were not exercised here. The guard assumes a device reporting itself as its own physical device carries no monitor information -- true for viewport devices, and deviceChanged() only ever runs on the control device today, but a real physical device legitimately is its own physical device. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvApplication.cpp | 23 ++- src/lib/ip/IPCore/IPCore/IPGraph.h | 10 ++ src/lib/ip/IPCore/IPGraph.cpp | 213 ++++++++++++++++++++++++- 3 files changed, 237 insertions(+), 9 deletions(-) diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 43eaf58bd..82b60d1ba 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1993,21 +1993,28 @@ namespace Rv // registry so it references the newly created device pointers. // rebuildDevices() deleted the old per-screen devices and // createDesktopVideoDevices() made new ones, but the graph's - // DisplayGroupIPNodes -- built once at startup by setPhysicalDevices - // -- still hold the destroyed pointers. Without this, a later - // setOutputVideoDevice(newDevice) -> connectDisplayGroup -> + // DisplayGroupIPNodes still hold the destroyed pointers. Without this, + // a later setOutputVideoDevice(newDevice) -> connectDisplayGroup -> // findDisplayGroupByDevice(newDevice) matches nothing and silently // no-ops, so the presentation output is never rendered and the second // display stays black. // - // This mirrors the startup sequence, and must run on every real - // rebuild even when presentation is currently off: the device pointers - // can change while presentation is disabled and only be bound as the - // output later (the reported 10 -> 8 -> 10 -> enable repro). + // This must run on every real rebuild even when presentation is + // currently off: the device pointers can change while presentation is + // disabled and only be bound as the output later (the reported + // 10 -> 8 -> 10 -> enable repro). + // + // refreshPhysicalDevices(), not setPhysicalDevices(): the latter is the + // startup path and deletes every display group, which also throws away + // its colour pipeline. Doing that here reset the main view's transfer + // function from sRGB to None on every 8/10-bit switch, along with any + // assigned display profile. The monitors have not changed at this + // point -- only the device objects in front of them -- so the groups + // should be re-pointed, not rebuilt. // if (rebuilt && session) { - session->graph().setPhysicalDevices(videoModules()); + session->graph().refreshPhysicalDevices(videoModules()); session->graph().setPrimaryDisplayGroup(session->controlVideoDevice()); } diff --git a/src/lib/ip/IPCore/IPCore/IPGraph.h b/src/lib/ip/IPCore/IPCore/IPGraph.h index 714c8d0c9..d47a88ad2 100644 --- a/src/lib/ip/IPCore/IPCore/IPGraph.h +++ b/src/lib/ip/IPCore/IPCore/IPGraph.h @@ -332,6 +332,15 @@ namespace IPCore void setPhysicalDevices(const VideoModules&); + // + // Re-point the existing DisplayGroupIPNodes at a rebuilt set of + // physical devices, keeping each group (and its colour pipeline) + // alive. Use this, not setPhysicalDevices(), when the devices were + // recreated but the monitors behind them did not change -- see the + // implementation for why the difference matters. + // + void refreshPhysicalDevices(const VideoModules&); + // // Create a NodeValidation object on the stack. A node validation // context will become current. When complete the former context @@ -763,6 +772,7 @@ namespace IPCore void promoteFBsInFrameRange(int beg, int mid, int end, TwkUtil::Timer t); void setPhysicalDevicesInternal(const VideoModules&); + void refreshPhysicalDevicesInternal(const VideoModules&); void dispatchCachingThreadsSafely(); diff --git a/src/lib/ip/IPCore/IPGraph.cpp b/src/lib/ip/IPCore/IPGraph.cpp index 9998e2d94..a2d12c580 100644 --- a/src/lib/ip/IPCore/IPGraph.cpp +++ b/src/lib/ip/IPCore/IPGraph.cpp @@ -746,6 +746,188 @@ namespace IPCore m_rootNode->appendInput(displayGroup); } + void IPGraph::refreshPhysicalDevices(const VideoModules& modules) + { + beginGraphEdit(); + refreshPhysicalDevicesInternal(modules); + endGraphEdit(); + } + + void IPGraph::refreshPhysicalDevicesInternal(const VideoModules& modules) + { + // + // Re-point the existing display groups at a rebuilt set of physical + // devices, rather than rebuilding the groups the way + // setPhysicalDevicesInternal() does. + // + // That function is the startup path: it deletes every + // DisplayGroupIPNode and makes new ones, and a new display group comes + // with a new colorPipeline holding default contents. Using it as a + // refresh therefore discards the display colour state -- the transfer + // function and any assigned display profile. That is what reset the + // main view from sRGB to None on every 8/10-bit switch: swapping the + // main-view backend rebuilds the desktop devices, behind the very same + // monitors, and took the colour pipeline with it. + // + // Devices are matched to groups by (module name, device name), the + // same key display profiles are stored under, and stable across a + // rebuild precisely because the monitors have not changed. + // + + std::vector devices; + + for (size_t i = 0; i < modules.size(); i++) + { + const TwkApp::VideoModule::VideoDevices& mdevices = modules[i]->devices(); + devices.insert(devices.end(), mdevices.begin(), mdevices.end()); + } + + // + // With nothing to match on either side there is no state worth + // preserving, and setPhysicalDevicesInternal() already handles the + // empty-modules (defaultOutputGroup) case correctly. + // + if (devices.empty() || m_displayGroups.empty()) + { + setPhysicalDevicesInternal(modules); + return; + } + + std::vector deviceMatched(devices.size(), false); + DisplayGroups survivors; + DisplayGroups doomed; + + for (size_t gi = 0; gi < m_displayGroups.size(); gi++) + { + DisplayGroupIPNode* group = m_displayGroups[gi]; + + if (group == m_defaultOutputGroup) + { + survivors.push_back(group); + continue; + } + + // + // Match on the stored names, never on physicalDevice(): that + // pointer refers to a device the caller has already destroyed. + // + const StringProperty* nameProp = group->property("device.name"); + const StringProperty* moduleProp = group->property("device.moduleName"); + const string groupName = (nameProp && !nameProp->empty()) ? nameProp->front() : ""; + const string groupModule = (moduleProp && !moduleProp->empty()) ? moduleProp->front() : ""; + + size_t match = devices.size(); + + for (size_t di = 0; di < devices.size(); di++) + { + if (deviceMatched[di]) + continue; + + const string deviceModule = devices[di]->module() ? devices[di]->module()->name() : ""; + + if (devices[di]->name() == groupName && deviceModule == groupModule) + { + match = di; + break; + } + } + + if (match == devices.size()) + { + // + // Nothing answers to this group's device any more -- a monitor + // was unplugged, or a module stopped advertising it. + // + if (m_rootNode->isInput(group)) + { + m_rootNode->removeInput(group); + } + group->willDelete(); + group->disconnectInputs(); + doomed.push_back(group); + continue; + } + + deviceMatched[match] = true; + group->setPhysicalVideoDevice(devices[match]); + + // + // Drop a stale output pointer. The device it named was just + // destroyed, and findDisplayGroupByDevice() compares pointers -- + // a dangling one can alias a freshly allocated device at the same + // address and hand back the wrong group. The control device is + // still alive and is not in the module list, so it is kept. + // + if (group->outputDevice() && group->outputDevice() != m_controlDevice) + { + bool stillPresent = false; + + for (size_t di = 0; di < devices.size(); di++) + { + if (devices[di] == group->outputDevice()) + { + stillPresent = true; + break; + } + } + + if (!stillPresent) + { + group->setOutputVideoDevice(0); + } + } + + survivors.push_back(group); + } + + // + // Publish the surviving set before deleting anything: ~DisplayGroupIPNode + // calls back into removeDisplayGroup(). + // + m_displayGroups = survivors; + + for (size_t i = 0; i < doomed.size(); i++) + { + delete doomed[i]; + m_topologyChanged = true; + } + + // + // Devices that no existing group describes are genuinely new. + // + for (size_t di = 0; di < devices.size(); di++) + { + if (deviceMatched[di]) + continue; + + size_t n = m_displayGroups.size(); + string name; + + do + { + ostringstream str; + str << "displayGroup" << n++; + name = str.str(); + } while (findNode(name)); + + m_displayGroups.push_back(newDisplayGroup(name, devices[di])); + } + + // + // The primary may have been deleted above, so re-establish the view + // connection and the root input for whichever group leads now. + // + if (DisplayGroupIPNode* displayGroup = primaryDisplayGroup()) + { + displayGroup->setInputs1(m_viewGroupNode); + + if (!m_rootNode->isInput(displayGroup)) + { + m_rootNode->appendInput(displayGroup); + } + } + } + void IPGraph::setPrimaryDisplayGroup(DisplayGroupIPNode* node) { DisplayGroups::iterator i = std::find(m_displayGroups.begin(), m_displayGroups.end(), node); @@ -798,7 +980,36 @@ namespace IPCore if (newDevice) { dnode->setOutputVideoDevice(newDevice); - dnode->setPhysicalVideoDevice(newDevice->physicalDevice()); + + // + // Only adopt a physical device the new device actually knows. + // + // VideoDevice's constructor seeds m_physicalDevice with the + // device itself, and a viewport device only learns the monitor + // it sits on when it first renders (setAbsolutePosition -> + // deviceFromPosition). The one caller of this is + // Session::setControlVideoDevice(), which during a main-view + // backend swap runs on a view that has never rendered -- so + // physicalDevice() is still that view. + // + // Adopting it anyway rewrote the group's device.name from the + // monitor ("Dell Inc. DELL U2725QE DP-1") to the viewport's own + // name ("RV Main Window (Vulkan)/0x..."). The group then + // described no physical device, so the rebuild that follows + // discarded it and its colour pipeline with it -- which is why + // the display transfer function fell back from sRGB to None on + // every 8/10-bit switch. Keeping the old value is right: the + // monitor has not changed, only the object drawing to it, and + // refreshPhysicalDevices() re-points the pointer by name. + // + if (const VideoDevice* physical = newDevice->physicalDevice()) + { + if (physical != newDevice) + { + dnode->setPhysicalVideoDevice(physical); + } + } + m_deviceChangedSignal(oldDevice, newDevice); } } From 38dbb9b7d1ec663f5bd77f87bc0515eb5d9af398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 11 Sep 2026 15:18:46 -0400 Subject: [PATCH 16/48] fix(vulkan): require matching GL/Vulkan device UUIDs before GPU interop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QTVulkanVideoDevice now compares the GL physical-device UUID against the Vulkan physical device via VulkanWindow::physicalDeviceMatchesUUID(). If the UUIDs do not match (or cannot be queried), it falls back to the CPU pack-and-upload path instead of exporting GL memory to a different GPU. The result is cached and can be reset. VulkanWindow now creates a Vulkan 1.1 instance so vkGetPhysicalDeviceProperties2 and device UUIDs are available, and tightens physical-device selection to require a graphics+present queue family, VK_KHR_swapchain, and a 10-bit surface format when applicable. RvDocument's Vulkan-to-OpenGL fallback now preserves the requested display depth except when recovering from a 10-bit Vulkan failure, in which case it explicitly falls back to 8-bit OpenGL with clearer log messages. MuUICommands::colorAtCursor now reads the cursor pixel through the active GLVideoDevice with glReadPixels instead of requiring a GLView and QImage, making it backend-agnostic. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/MuUICommands.cpp | 28 +- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 42 ++- .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 6 + src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 18 +- src/lib/app/RvCommon/RvDocument.cpp | 23 +- src/lib/app/RvCommon/VulkanWindow.cpp | 294 +++++++++++++----- 6 files changed, 299 insertions(+), 112 deletions(-) diff --git a/src/lib/app/RvCommon/MuUICommands.cpp b/src/lib/app/RvCommon/MuUICommands.cpp index f90e4018a..e132c8612 100644 --- a/src/lib/app/RvCommon/MuUICommands.cpp +++ b/src/lib/app/RvCommon/MuUICommands.cpp @@ -9,7 +9,11 @@ #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif +#endif + #include + +#ifdef PLATFORM_WINDOWS #include #include #include @@ -38,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -383,9 +388,6 @@ namespace Rv MuLangContext* c = static_cast(p->context()); Session* s = Session::currentSession(); RvDocument* doc = reinterpret_cast(s->opaquePointer()); - QWidget* w = doc->view(); - - GLView* glview = dynamic_cast(w); Mu::Vector4f v; v[0] = 0; @@ -393,7 +395,7 @@ namespace Rv v[2] = 0; v[3] = 0; - if (glview != NULL) + if (TwkGLF::GLVideoDevice* device = doc->viewVideoDevice()) { float x = NODE_ARG(0, float); float y = NODE_ARG(1, float); @@ -401,17 +403,15 @@ namespace Rv int ix = (int)(x + 0.5f); int iy = (int)(y + 0.5f); - QImage image = glview->readPixels(ix, iy, 1, 1); - - if ((image.width() > 0) && (image.height() > 0)) + if (ix >= 0 && iy >= 0 && static_cast(ix) < device->width() && static_cast(iy) < device->height()) { - QRgb rgba = image.pixel(0, 0); - QColor qc(rgba); - - v[0] = qc.redF(); - v[1] = qc.greenF(); - v[2] = qc.blueF(); - v[3] = qc.alphaF(); + device->makeCurrent(); + GLubyte rgba[4] = {}; + glReadPixels(ix, iy, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, rgba); + v[0] = static_cast(rgba[0]) / 255.0f; + v[1] = static_cast(rgba[1]) / 255.0f; + v[2] = static_cast(rgba[2]) / 255.0f; + v[3] = static_cast(rgba[3]) / 255.0f; } } diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 719240d6c..f55ba2075 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -599,6 +599,37 @@ namespace Rv return true; } + bool QTVulkanVideoDevice::glDeviceMatchesVulkan() const + { + if (m_glVulkanDeviceMatch != -1) + return m_glVulkanDeviceMatch == 1; + if (!m_glContext || !m_window || !m_window->isInitialized()) + return false; + + using GetUnsignedByteIndexedProc = void(GLAPIENTRY*)(GLenum, GLuint, GLubyte*); + const auto getUnsignedByteIndexed = + reinterpret_cast(m_glContext->getProcAddress("glGetUnsignedBytei_vEXT")); + + GLint deviceCount = 0; + if (getUnsignedByteIndexed) + glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &deviceCount); + + bool matched = false; + for (GLint i = 0; i < deviceCount && !matched; ++i) + { + std::array uuid{}; + getUnsignedByteIndexed(GL_DEVICE_UUID_EXT, static_cast(i), uuid.data()); + matched = m_window->physicalDeviceMatchesUUID(uuid.data(), uuid.size()); + } + + m_glVulkanDeviceMatch = matched ? 1 : 0; + if (!matched && ImageRenderer::debugGpu()) + { + cout << "INFO: QTVulkanVideoDevice: GL/Vulkan device UUIDs do not match or are unavailable; using CPU fallback." << endl; + } + return matched; + } + void QTVulkanVideoDevice::presentCpuFallback(int w, int h) const { TwkGLF::GLFBO* fbo = m_fbo; @@ -655,6 +686,12 @@ namespace Rv if (w <= 0 || h <= 0) return; + // A presentation device can be primed before its window receives the + // first expose event. There is no swapchain to present to yet; wait for + // exposeEvent(), which initializes Vulkan and calls syncBuffers() again. + if (!m_window->isInitialized()) + return; + // // Best-effort gate for a passive presentation output: skip the whole // frame while this device's own GPU work is still in flight, rather @@ -677,11 +714,12 @@ namespace Rv // while the GL context is current. If the driver does not expose them, // skip the Vulkan-side export work entirely and fall through to the // CPU pack-and-upload path below. - const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && loadGLInteropExtensions(); + const bool glInteropAvailable = + !forceCpuPresentation() && !m_interopDisabled && loadGLInteropExtensions() && glDeviceMatchesVulkan(); const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; #else const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && GLEW_EXT_memory_object && GLEW_EXT_semaphore - && GLEW_EXT_memory_object_fd && GLEW_EXT_semaphore_fd; + && GLEW_EXT_memory_object_fd && GLEW_EXT_semaphore_fd && glDeviceMatchesVulkan(); const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; #endif diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 988c41863..45354e6cf 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -50,6 +50,8 @@ namespace Rv void setEventWidget(QWidget* widget); + void resetInteropDeviceMatch() const { m_glVulkanDeviceMatch = -1; } + const QTTranslator& translator() const { return *m_translator; } bool hasTranslator() const { return m_translator != nullptr; } @@ -153,6 +155,9 @@ namespace Rv // window's Vulkan is initialized. Latching there reports CPU-fallback // for a device that then spends its whole life on interop. mutable int m_loggedPresentPath{-1}; + // -1 until queried, 0 when GL and Vulkan use different/unidentifiable + // physical devices, 1 when their device UUIDs match. + mutable int m_glVulkanDeviceMatch{-1}; // Latched once any GL call on the interop path reports an error. The // GL<->Vulkan bridge has no way to notice that an import silently @@ -168,6 +173,7 @@ namespace Rv bool interopGLFailed(const char* what) const; void cleanupSharedGLObjects(uint32_t slot) const; + bool glDeviceMatchesVulkan() const; // CPU-fallback GL state (used only when GPU interop is unavailable or // refused). A flipped RGB10_A2 blit target lets GL pack the 10-bit pixels diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index c953562f2..b6e207c5f 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -13,6 +13,7 @@ #include #include +#include #include QT_BEGIN_NAMESPACE @@ -77,6 +78,10 @@ namespace Rv // VK_FORMAT_UNDEFINED before the swapchain is created. VkFormat swapchainFormat() const { return m_vkSwapchainFormat; } + // True when uuid identifies the physical device backing this window. + // Used by the GL bridge to refuse external-memory interop across GPUs. + bool physicalDeviceMatchesUUID(const unsigned char* uuid, size_t size) const; + // // Vulkan presentation — called by QTVulkanVideoDevice::syncBuffers(). // @@ -118,12 +123,10 @@ namespace Rv int dedicated{0}; }; - // Number of frames the present path keeps in flight. Per-frame Vulkan - // sync objects and the GL<->Vulkan shared resources are stored in rings - // of this size and indexed by currentFrame(). 2 pipelines the present so - // a frame's GL work + submit can begin before the prior present retires; - // the throttle is FIFO acquire back-pressure + the start-of-frame fence - // wait (no per-frame end-of-frame block). + // Capacity of the present-resource ring. Per-frame Vulkan sync objects + // and GL<->Vulkan shared resources are indexed by currentFrame(). + // Runtime depth defaults to one for interactive latency; + // RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 enables both slots. static constexpr uint32_t FRAMES_IN_FLIGHT = 2; // Index of the in-flight ring slot the next/current frame uses. The GL @@ -225,6 +228,7 @@ namespace Rv // window yet. Once per window, not once per swapchain recreate. bool m_loggedSurfaceFormatList{false}; + bool m_externalInteropSupported{false}; VkCommandPool m_vkCommandPool{VK_NULL_HANDLE}; VkSwapchainKHR m_vkSwapchain{VK_NULL_HANDLE}; @@ -285,7 +289,7 @@ namespace Rv // pair cannot desync across the skipped frame. void drainSharedSemaphores(uint32_t slot); - // Recreate swapchain (and shared image) after OUT_OF_DATE / SUBOPTIMAL. + // Recreate the swapchain after OUT_OF_DATE. SUBOPTIMAL remains usable. void handleSwapchainOutOfDate(); // Tear down and re-initialize after Qt destroyed and recreated the diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 7e22febe8..0c947ecd2 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -939,14 +939,27 @@ namespace Rv return; } - cout << "INFO: Vulkan 10-bit presentation failed at runtime; falling back to OpenGL." << endl; + Rv::Options& opts = Options::sharedOptions(); + const bool requestedTenBit = + opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2; + if (requestedTenBit) + cout << "INFO: Vulkan 10-bit presentation failed at runtime; falling back to 8-bit OpenGL." << endl; + else + cout << "INFO: Switching the main view from Vulkan to OpenGL for the requested display depth." << endl; VulkanView* oldVulkanView = m_vulkanView; m_vulkanView = nullptr; oldVulkanView->stopProcessingEvents(); - Rv::Options& opts = Options::sharedOptions(); + // A runtime Vulkan failure cannot be recovered by asking Qt/OpenGL for + // the same 10/10/10/2 surface that required Vulkan in the first place. + // Preserve the user's persisted 10-bit intent, but make this recovery + // view explicitly 8-bit so it is valid and usable. + const int fallbackRedBits = requestedTenBit ? 8 : opts.dispRedBits; + const int fallbackGreenBits = requestedTenBit ? 8 : opts.dispGreenBits; + const int fallbackBlueBits = requestedTenBit ? 8 : opts.dispBlueBits; + const int fallbackAlphaBits = requestedTenBit ? 8 : opts.dispAlphaBits; const TwkApp::Application::Documents& docs = TwkApp::App()->documents(); GLView* newGLView = nullptr; @@ -954,7 +967,7 @@ namespace Rv { newGLView = new GLView(this, 0, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, - true, opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits, !m_startupResize); + true, fallbackRedBits, fallbackGreenBits, fallbackBlueBits, fallbackAlphaBits, !m_startupResize); } else { @@ -962,8 +975,8 @@ namespace Rv RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; newGLView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), - opts.vsync != 0 && !m_vsyncDisabled, true, opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, - opts.dispAlphaBits, !m_startupResize); + opts.vsync != 0 && !m_vsyncDisabled, true, fallbackRedBits, fallbackGreenBits, fallbackBlueBits, + fallbackAlphaBits, !m_startupResize); } newGLView->setContentSize(oldVulkanView->sizeHint().width(), oldVulkanView->sizeHint().height()); diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index a9b36958c..afced750c 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -33,6 +34,7 @@ #include #include #include +#include #include #include #ifdef PLATFORM_WINDOWS @@ -112,6 +114,53 @@ namespace Rv // advertise A2R10G10B10. static bool isTenBitFormat(VkFormat f) { return f == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || f == VK_FORMAT_A2R10G10B10_UNORM_PACK32; } + static bool findGraphicsPresentQueue(VkPhysicalDevice device, VkSurfaceKHR surface, uint32_t& familyIndex) + { + uint32_t familyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, nullptr); + std::vector families(familyCount); + vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, families.data()); + + for (uint32_t i = 0; i < familyCount; ++i) + { + VkBool32 presentSupport = VK_FALSE; + if (vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport) == VK_SUCCESS + && (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) + { + familyIndex = i; + return true; + } + } + return false; + } + + static bool surfaceHasTenBitFormat(VkPhysicalDevice device, VkSurfaceKHR surface) + { + uint32_t formatCount = 0; + if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + return false; + + std::vector formats(formatCount); + if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, formats.data()) != VK_SUCCESS) + return false; + + return std::any_of(formats.begin(), formats.end(), [](const VkSurfaceFormatKHR& format) { return isTenBitFormat(format.format); }); + } + + static bool deviceHasExtension(VkPhysicalDevice device, const char* name) + { + uint32_t extensionCount = 0; + if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr) != VK_SUCCESS) + return false; + + std::vector extensions(extensionCount); + if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, extensions.data()) != VK_SUCCESS) + return false; + + return std::any_of(extensions.begin(), extensions.end(), + [name](const VkExtensionProperties& extension) { return strcmp(extension.extensionName, name) == 0; }); + } + static const char* formatName(VkFormat f) { switch (f) @@ -203,6 +252,22 @@ namespace Rv float VulkanWindow::devicePixelRatioF() const { return static_cast(QWindow::devicePixelRatio()); } + bool VulkanWindow::physicalDeviceMatchesUUID(const unsigned char* uuid, size_t size) const + { + if (!m_vkPhysicalDevice || !uuid || size != VK_UUID_SIZE) + return false; + + VkPhysicalDeviceIDProperties idProperties = {}; + idProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES; + + VkPhysicalDeviceProperties2 properties = {}; + properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + properties.pNext = &idProperties; + vkGetPhysicalDeviceProperties2(m_vkPhysicalDevice, &properties); + + return std::equal(idProperties.deviceUUID, idProperties.deviceUUID + VK_UUID_SIZE, uuid); + } + //-------------------------------------------------------------------------- // Vulkan Initialisation //-------------------------------------------------------------------------- @@ -255,6 +320,10 @@ namespace Rv static QVulkanInstance* instance = []() -> QVulkanInstance* { auto* inst = new QVulkanInstance(); + // Device UUID matching uses vkGetPhysicalDeviceProperties2, which + // is core in Vulkan 1.1. QVulkanInstance otherwise defaults to a + // 1.0 instance even though the presentation code targets 1.1. + inst->setApiVersion(QVersionNumber(1, 1)); if (!inst->create()) { cerr << "ERROR: VulkanWindow: shared QVulkanInstance create failed" << endl; @@ -333,31 +402,19 @@ namespace Rv { VkPhysicalDevice dev = devices[di]; - uint32_t formatCount = 0; - if (vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) - { - continue; - } - std::vector formats(formatCount); - vkGetPhysicalDeviceSurfaceFormatsKHR(dev, dummySurface, &formatCount, formats.data()); - - bool has10bit = false; - for (const auto& fmt : formats) - { - if (isTenBitFormat(fmt.format)) - { - has10bit = true; - any10bit = true; - break; - } - } + uint32_t queueFamily = 0; + const bool canPresent = + deviceHasExtension(dev, VK_KHR_SWAPCHAIN_EXTENSION_NAME) && findGraphicsPresentQueue(dev, dummySurface, queueFamily); + const bool has10bit = canPresent && surfaceHasTenBitFormat(dev, dummySurface); + any10bit = any10bit || has10bit; VkPhysicalDeviceProperties props = {}; vkGetPhysicalDeviceProperties(dev, &props); if (ImageRenderer::debugGpu()) { cout << "INFO: VulkanWindow: device[" << di << "] '" << props.deviceName - << "': 10-bit surface format=" << (has10bit ? "YES" : "NO") << endl; + << "': graphics+present=" << (canPresent ? "YES" : "NO") << " 10-bit surface format=" << (has10bit ? "YES" : "NO") + << endl; } } @@ -443,30 +500,19 @@ namespace Rv for (VkPhysicalDevice dev : devices) { - uint32_t queueFamilyCount = 0; - vkGetPhysicalDeviceQueueFamilyProperties(dev, &queueFamilyCount, nullptr); - std::vector queueFamilies(queueFamilyCount); - vkGetPhysicalDeviceQueueFamilyProperties(dev, &queueFamilyCount, queueFamilies.data()); - - for (uint32_t i = 0; i < queueFamilyCount; i++) + uint32_t queueFamily = 0; + if (findGraphicsPresentQueue(dev, m_vkSurface, queueFamily) && surfaceHasTenBitFormat(dev, m_vkSurface)) { - VkBool32 presentSupport = false; - vkGetPhysicalDeviceSurfaceSupportKHR(dev, i, m_vkSurface, &presentSupport); - if ((queueFamilies[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) - { - m_vkPhysicalDevice = dev; - m_queueFamilyIndex = i; - foundQueue = true; - break; - } - } - if (foundQueue) + m_vkPhysicalDevice = dev; + m_queueFamilyIndex = queueFamily; + foundQueue = true; break; + } } if (!foundQueue) { - cerr << "ERROR: VulkanWindow: initVulkan: No physical device with graphics and present support found." << endl; + cerr << "ERROR: VulkanWindow: initVulkan: No physical device with graphics, present, and 10-bit surface support found." << endl; return false; } @@ -489,16 +535,31 @@ namespace Rv queueCreateInfo.queueCount = 1; queueCreateInfo.pQueuePriorities = &queuePriority; - std::vector deviceExtensions = { - VK_KHR_SWAPCHAIN_EXTENSION_NAME, + std::vector deviceExtensions = {VK_KHR_SWAPCHAIN_EXTENSION_NAME}; + if (!deviceHasExtension(m_vkPhysicalDevice, VK_KHR_SWAPCHAIN_EXTENSION_NAME)) + { + cerr << "ERROR: VulkanWindow: selected device does not support VK_KHR_swapchain." << endl; + return false; + } + #ifdef PLATFORM_WINDOWS - VK_KHR_EXTERNAL_MEMORY_WIN32_EXTENSION_NAME, - VK_KHR_EXTERNAL_SEMAPHORE_WIN32_EXTENSION_NAME, + const char* externalMemoryExtension = VK_KHR_EXTERNAL_MEMORY_WIN32_EXTENSION_NAME; + const char* externalSemaphoreExtension = VK_KHR_EXTERNAL_SEMAPHORE_WIN32_EXTENSION_NAME; #else - VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME, - VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME, + const char* externalMemoryExtension = VK_KHR_EXTERNAL_MEMORY_FD_EXTENSION_NAME; + const char* externalSemaphoreExtension = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME; #endif - }; + m_externalInteropSupported = deviceHasExtension(m_vkPhysicalDevice, externalMemoryExtension) + && deviceHasExtension(m_vkPhysicalDevice, externalSemaphoreExtension); + if (m_externalInteropSupported) + { + deviceExtensions.push_back(externalMemoryExtension); + deviceExtensions.push_back(externalSemaphoreExtension); + } + else if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: external memory/semaphore extensions unavailable; using CPU fallback." << endl; + } VkDeviceCreateInfo createInfo = {}; createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; @@ -585,7 +646,7 @@ namespace Rv return true; } - // Reset acquire semaphore and rebuild swapchain/shared image at the new size. + // Rebuild all Vulkan state after the native surface is lost. void VulkanWindow::handleSurfaceLost() { // @@ -628,6 +689,11 @@ namespace Rv if (m_videoDevice) { m_videoDevice->releaseSharedGLObjects(); + + // The next initVulkan() may land on a different VkPhysicalDevice, + // so the GL/Vulkan device-UUID match has to be probed again + // rather than reused from the device just torn down. + m_videoDevice->resetInteropDeviceMatch(); } for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) @@ -657,28 +723,12 @@ namespace Rv return; } - VkSemaphoreCreateInfo semaphoreInfo = {}; - semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) - { - if (m_vkImageAvailableSemaphore[i]) - { - vkDestroySemaphore(m_vkDevice, m_vkImageAvailableSemaphore[i], nullptr); - m_vkImageAvailableSemaphore[i] = VK_NULL_HANDLE; - } - if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &m_vkImageAvailableSemaphore[i]) != VK_SUCCESS) - { - m_vkImageAvailableSemaphore[i] = VK_NULL_HANDLE; - requestGLFallback(); - return; - } - } - // Recreate only the swapchain, not the shared image. The shared image is // a content-sized TRANSFER_SRC image, independent of the window-sized - // swapchain; createSwapchain() reuses the old swapchain (oldSwapchain) so - // this is a warm recreate. The next render()'s getSharedImageInfo() will - // rebuild the shared image only if the content size actually changed. + // swapchain. Keep the acquire semaphores too: they are per-frame + // resources, not swapchain resources, and can still be referenced by + // queued submissions when OUT_OF_DATE is reported. createSwapchain() + // waits for the device before retiring swapchain-owned resources. if (!createSwapchain()) { requestGLFallback(); @@ -716,6 +766,7 @@ namespace Rv m_vkDevice = VK_NULL_HANDLE; } m_vkQueue = VK_NULL_HANDLE; + m_externalInteropSupported = false; // Surface is managed by QVulkanInstance? We shouldn't destroy it here if QVulkanInstance owns it, but wait, we got it from // surfaceForWindow. Actually QVulkanWindow destroys it. We can just leave it for QVulkanInstance to clean up, or we can // vkDestroySurfaceKHR if needed. For safety we don't destroy instance/surface here, they are tied to Qt. @@ -808,13 +859,25 @@ namespace Rv } VkSurfaceCapabilitiesKHR capabilities; - vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &capabilities); + if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &capabilities) != VK_SUCCESS) + { + requestGLFallback(); + return false; + } // Negotiate 10-bit format - uint32_t formatCount; - vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, nullptr); + uint32_t formatCount = 0; + if (vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + { + requestGLFallback(); + return false; + } std::vector formats(formatCount); - vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, formats.data()); + if (vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, formats.data()) != VK_SUCCESS) + { + requestGLFallback(); + return false; + } // // Unconditional but once per window: a handful of lines, and the pair @@ -935,9 +998,13 @@ namespace Rv m_vkSwapchainFormat = surfaceFormat.format; m_vkSwapchainExtent = capabilities.currentExtent; - if (m_vkSwapchainExtent.width == 0xFFFFFFFF) + if (m_vkSwapchainExtent.width == UINT32_MAX) { - m_vkSwapchainExtent = {(uint32_t)width(), (uint32_t)height()}; + const qreal dpr = devicePixelRatio(); + const uint32_t pixelWidth = static_cast(std::max(1.0, width() * dpr)); + const uint32_t pixelHeight = static_cast(std::max(1.0, height() * dpr)); + m_vkSwapchainExtent = {std::clamp(pixelWidth, capabilities.minImageExtent.width, capabilities.maxImageExtent.width), + std::clamp(pixelHeight, capabilities.minImageExtent.height, capabilities.maxImageExtent.height)}; } if (m_vkSwapchainExtent.width == 0 || m_vkSwapchainExtent.height == 0) { @@ -977,10 +1044,31 @@ namespace Rv createInfo.imageColorSpace = surfaceFormat.colorSpace; createInfo.imageExtent = m_vkSwapchainExtent; createInfo.imageArrayLayers = 1; - createInfo.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + if (!(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT)) + { + cout << "WARNING: VulkanWindow: surface does not support transfer-destination swapchain images; requesting OpenGL fallback" + << endl; + requestGLFallback(); + return false; + } + createInfo.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; createInfo.preTransform = capabilities.currentTransform; + const VkCompositeAlphaFlagBitsKHR compositeAlphaPreference[] = { + VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR, + VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR, + VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR, + VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR, + }; createInfo.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; + for (const VkCompositeAlphaFlagBitsKHR alpha : compositeAlphaPreference) + { + if (capabilities.supportedCompositeAlpha & alpha) + { + createInfo.compositeAlpha = alpha; + break; + } + } createInfo.presentMode = presentMode; createInfo.clipped = VK_TRUE; // Warm recreate: hand the retiring swapchain to the driver so it can reuse @@ -1331,10 +1419,9 @@ namespace Rv // // Forward progress. Under sustained GPU pressure the catch-up test // below can be false indefinitely, which would freeze the presentation - // display rather than merely thin it out. Qt's update() is a dirty flag - // it must eventually service; matching that behaviour means forcing a - // blocking frame through once the output has gone stale for longer than - // a few refreshes. + // display rather than merely thin it out. Once stale, let syncBuffers() + // reach the present functions; they convert their normal zero-timeout + // polling into one blocking present. // static const double kMaxStaleSeconds = 0.1; if (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds) @@ -1386,7 +1473,7 @@ namespace Rv const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) { - if (!m_vkDevice) + if (!m_vkDevice || !m_externalInteropSupported) return nullptr; // Build/return the shared image for the current in-flight ring slot. @@ -1870,7 +1957,9 @@ namespace Rv // canPresentNow() for the gate that fires first, before any GL work. // const bool bestEffort = isPassiveOutput(); - const uint64_t waitTimeout = bestEffort ? 0 : UINT64_MAX; + static const double kMaxStaleSeconds = 0.1; + const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); + const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; if (diagPresent) diagTimer.start(); @@ -1890,6 +1979,11 @@ namespace Rv requestBestEffortRetry(); return; } + if (fenceResult != VK_SUCCESS) + { + requestGLFallback(); + return; + } // Acquire image uint32_t imageIndex; @@ -2042,7 +2136,10 @@ namespace Rv // Wait for GL to finish writing (glReady) AND swapchain image to be available VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot], m_vkImageAvailableSemaphore[slot]}; - VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; + // Both waits protect transfer operations. The acquired swapchain image + // is first touched by its TRANSFER_DST layout transition, so waiting at + // COLOR_ATTACHMENT_OUTPUT would not block that earlier stage. + VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT}; submitInfo.waitSemaphoreCount = 2; submitInfo.pWaitSemaphores = waitSemaphores; submitInfo.pWaitDstStageMask = waitStages; @@ -2117,7 +2214,7 @@ namespace Rv handleSwapchainOutOfDate(); return; } - if (presentResult != VK_SUCCESS) + if (presentResult != VK_SUCCESS && presentResult != VK_SUBOPTIMAL_KHR) { if (presentResult == VK_ERROR_DEVICE_LOST) { @@ -2138,6 +2235,9 @@ namespace Rv if (!m_vkDevice) return; + const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; + Timer diagTimer; + if (!m_vkSwapchain || m_vkSwapchainExtent.width != (uint32_t)w || m_vkSwapchainExtent.height != (uint32_t)h) { // Warm recreate via oldSwapchain (createSwapchain retires the old one). @@ -2151,15 +2251,28 @@ namespace Rv // polls instead of blocking and skips the frame -- see the best-effort // present in presentSharedImage() for why. const bool bestEffort = isPassiveOutput(); - const uint64_t waitTimeout = bestEffort ? 0 : UINT64_MAX; + static const double kMaxStaleSeconds = 0.1; + const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); + const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; + + if (diagPresent) + diagTimer.start(); + const VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout); + if (diagPresent) + s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; - if (vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout) == VK_TIMEOUT) + if (fenceResult == VK_TIMEOUT) { // Unlike the interop path there are no GL<->Vulkan semaphores to // rebalance here: presentCpuFallback() hands over plain pixels. requestBestEffortRetry(); return; } + if (fenceResult != VK_SUCCESS) + { + requestGLFallback(); + return; + } size_t size = w * h * 4; @@ -2206,8 +2319,12 @@ namespace Rv // Acquire image uint32_t imageIndex; + if (diagPresent) + diagTimer.start(); VkResult result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + if (diagPresent) + s_diagAcquireMs += diagTimer.elapsed() * 1000.0; if (result == VK_NOT_READY || result == VK_TIMEOUT) { // Best-effort: no image free this frame, leave the output on the one @@ -2295,7 +2412,9 @@ namespace Rv VkSubmitInfo submitInfo = {}; submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; VkSemaphore waitSemaphores[] = {m_vkImageAvailableSemaphore[slot]}; - VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT}; + // The acquired image is first used by a TRANSFER_DST layout transition + // and vkCmdCopyBufferToImage, not as a color attachment. + VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT}; submitInfo.waitSemaphoreCount = 1; submitInfo.pWaitSemaphores = waitSemaphores; submitInfo.pWaitDstStageMask = waitStages; @@ -2315,6 +2434,13 @@ namespace Rv return; } + if (m_doc && s_diagFrameEventTime >= 0.0) + { + s_diagSlotEventTime[slot] = s_diagFrameEventTime; + s_diagSlotArmed[slot] = true; + s_diagFrameEventTime = -1.0; + } + // Frame committed; advance the ring (imageIndex/slot below are locals). m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; @@ -2355,7 +2481,7 @@ namespace Rv handleSwapchainOutOfDate(); return; } - if (presentResult != VK_SUCCESS) + if (presentResult != VK_SUCCESS && presentResult != VK_SUBOPTIMAL_KHR) { if (presentResult == VK_ERROR_DEVICE_LOST) { From c46e9bc4cc01297c6a9e15cabc6fbe3267d22932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:36:29 -0400 Subject: [PATCH 17/48] fix(gl): distinguish a missing GL context from a failed GL call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no context current, glGetError() on Windows returns GL_INVALID_OPERATION for every call, for as long as nothing is current. TWK_GLDEBUG reported that as a GL error at each instrumented site that followed, so one missing context surfaced as a dozen copies of itself, attributed to whichever innocent line checked next. A missing context during presentation teardown showed up as an error inside makeCurrent(), a frame late and in the wrong place. twkGlAnyContextIsCurrent() answers the question directly. QOpenGLContext::currentContext() only knows about contexts Qt made current, and TwkGLFFBO's FBOVideoDevice binds its own natively, so trusting Qt alone would claim "no context" while a perfectly good one is current and would suppress the real errors the macro exists to print. glGetString() settles the cases Qt cannot see, and is only reached when Qt says no. twkGlPrintError() now checks that first and, when nothing is current, reports once per episode at the first site to notice, resetting when a context returns so a later episode is not swallowed. Both are declared outside the NDEBUG guard. TWK_GLDEBUG still compiles out in release -- polling glGetError() at every instrumented site is a debug-only cost -- but "no current GL context" is not instrumentation. It fires only when GL work cannot land, which is a fault in a release build too, and the callers that need to say so are compiled in both. Signed-off-by: Cédrik Fuoco --- src/lib/graphics/TwkGLF/GL.cpp | 49 +++++++++++++++++++++++++++++ src/lib/graphics/TwkGLF/TwkGLF/GL.h | 20 +++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/lib/graphics/TwkGLF/GL.cpp b/src/lib/graphics/TwkGLF/GL.cpp index ec520ce54..395917e1e 100644 --- a/src/lib/graphics/TwkGLF/GL.cpp +++ b/src/lib/graphics/TwkGLF/GL.cpp @@ -12,6 +12,8 @@ using namespace std; #include +#include + namespace { @@ -211,8 +213,55 @@ namespace TwkGLF } // namespace TwkGLF +// +// Is any GL context current? +// +// QOpenGLContext::currentContext() only knows about contexts Qt made current, +// and TwkGLFFBO's FBOVideoDevice creates and binds its own natively +// (wglMakeCurrent / glXMakeCurrent / CGLSetCurrentContext). Trusting Qt alone +// would claim "no context" there while a perfectly good one is current, and +// would suppress the real GL errors the debug macro exists to print. +// glGetString() returns null only when nothing at all is current -- on every +// platform, for either kind of context -- so it settles the cases Qt cannot +// see. It is only reached when Qt says no, and it is a cached string lookup +// rather than a round trip. +// +bool twkGlAnyContextIsCurrent() +{ + return QOpenGLContext::currentContext() != nullptr || glGetString(GL_VERSION) != nullptr; +} + bool twkGlPrintError(std::string_view file, std::string_view function, const int line, const std::string_view msg) { + // + // Check that some context is current before asking glGetError() anything. + // With no context current, glGetError() says nothing about this call: on + // Windows it returns GL_INVALID_OPERATION for every call, for as long as + // no context is current. Left unchecked, one missing context is reported + // as a GL error at every TWK_GLDEBUG that follows it, which buries the + // real fault under a dozen copies of itself and pins it on whichever + // innocent line happens to check next -- the reason a missing context in + // presentation teardown used to surface as an error in makeCurrent(), a + // frame late and in the wrong place. + // + // Report once per episode, at the first site to notice, and reset when a + // context comes back so a later episode is not silently swallowed. + // + static std::atomic noContextReported{false}; + + if (!twkGlAnyContextIsCurrent()) + { + if (!noContextReported.exchange(true)) + { + std::cerr << "GL_ERROR: " << shorterPath(file).data() << "::" << function.data() << ":" << line + << " [no current GL context -- this GL call, and any until a context is made current, did nothing]" << std::endl; + } + + return false; + } + + noContextReported = false; + if (GLuint err = glGetError()) { std::cerr << "GL_ERROR: " << shorterPath(file).data() << "::" << function.data() << ":" << line << " [" << TwkGLF::errorString(err) diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GL.h b/src/lib/graphics/TwkGLF/TwkGLF/GL.h index f26b09cda..24202772f 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GL.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GL.h @@ -7,6 +7,8 @@ //****************************************************************************** #ifndef __TwkGLF__GL__h__ #define __TwkGLF__GL__h__ +#include + #include #include #include @@ -99,10 +101,26 @@ struct GLPushMatrix // DEBUG macro // +// +// Is any GL context current, Qt's or a natively-bound one? Declared outside +// the NDEBUG guard below because callers other than the debug macro need it to +// decide whether GL work can land at all. +// +bool twkGlAnyContextIsCurrent(); + +// +// Declared in every build, for the same reason. TWK_GLDEBUG below still +// compiles out under NDEBUG -- polling glGetError() at every instrumented call +// site is a debug-only cost -- but the "no current GL context" report this +// also emits is not instrumentation. It fires only when GL work is being +// issued that cannot land, which is a fault in a release build too, and the +// caller that has to say so (GLFBO's destructor) is compiled in both. +// +bool twkGlPrintError(std::string_view file, std::string_view function, const int line, std::string_view msg); + #ifdef NDEBUG #define TWK_GLDEBUG ; #else -bool twkGlPrintError(std::string_view file, std::string_view function, const int line, std::string_view msg); #define TWK_GLDEBUG twkGlPrintError(__FILE__, __FUNCTION__, __LINE__, ""); #define TWK_GLDEBUG_MSG(msg) twkGlPrintError(__FILE__, __FUNCTION__, __LINE__, msg); #endif From ae0344874df2f96f063739f99536b46eefe50033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:36:44 -0400 Subject: [PATCH 18/48] feat(gl): add GLContextScope, guaranteeing a context for GL teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glDeleteFramebuffers() and friends are silent no-ops with no context current: the C++ object goes away, the driver's does not, and nothing says so. Teardown paths are where this bites, because they run from destructors and event callbacks rather than from inside a render, so nothing has arranged a context for them. Fixing that per call site does not converge -- each one found reveals the next, because nothing in the code states the invariant. This scope states it once. Open it at the top of anything that deletes GL objects and the question stops being the caller's problem. It resolves a context in three steps: already current, so do nothing -- the common case, a pointer compare, safe to put on paths that also run mid-render; else makeCurrent() on a supplied device, which is cheaper and is the context the objects were most likely created under; else a process-lifetime fallback. Destruction restores what was current, which -- because it only acquires when nothing was -- means making nothing current again. The fallback is what lets this work in destructors that have already had their device pointers cleared out from under them, which is exactly where the problem lives. It insists on QOpenGLContext::globalShareContext(): GL names belong to a share group, so deleting an FBO under a context outside the group that created it does nothing at all, and a non-sharing fallback would look like a fix while behaving like the bug. It is created once and never destroyed, because the paths needing it run while the application object is being torn down, so any owner freeing it at static-destruction time would free it either too early to be useful or after QGuiApplication has gone. If no context can be resolved it reports once and does nothing. It never throws and never aborts: a teardown helper must not be the reason a process fails to exit. Signed-off-by: Cédrik Fuoco --- src/lib/graphics/TwkGLF/CMakeLists.txt | 1 + src/lib/graphics/TwkGLF/GLContextScope.cpp | 205 ++++++++++++++++++ .../graphics/TwkGLF/TwkGLF/GLContextScope.h | 73 +++++++ 3 files changed, 279 insertions(+) create mode 100644 src/lib/graphics/TwkGLF/GLContextScope.cpp create mode 100644 src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h diff --git a/src/lib/graphics/TwkGLF/CMakeLists.txt b/src/lib/graphics/TwkGLF/CMakeLists.txt index f782294d4..51d7b41de 100644 --- a/src/lib/graphics/TwkGLF/CMakeLists.txt +++ b/src/lib/graphics/TwkGLF/CMakeLists.txt @@ -14,6 +14,7 @@ SET(_target SET(_sources GLVideoDevice.cpp GL.cpp + GLContextScope.cpp GLFBO.cpp GLVBO.cpp GLFence.cpp diff --git a/src/lib/graphics/TwkGLF/GLContextScope.cpp b/src/lib/graphics/TwkGLF/GLContextScope.cpp new file mode 100644 index 000000000..5229beb07 --- /dev/null +++ b/src/lib/graphics/TwkGLF/GLContextScope.cpp @@ -0,0 +1,205 @@ +// +// Copyright (c) 2025 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace TwkGLF +{ + + namespace + { + + // + // The fallback teardown context. + // + // Created once and never destroyed. The paths that need it run while + // the application object is itself being torn down, so anything that + // freed this at static-destruction time would free it either too + // early to be useful or after QGuiApplication has already gone. One + // leaked context at process exit costs nothing; getting that ordering + // wrong costs a crash on the way out, which is the class of bug this + // exists to remove. + // + QOpenGLContext* s_fallbackContext = nullptr; + QOffscreenSurface* s_fallbackSurface = nullptr; + bool s_fallbackAttempted = false; + + void reportNoContext(const char* why) + { + static bool reported = false; + + if (!reported) + { + reported = true; + std::cerr << "WARNING: no GL context available for teardown (" << why + << "); GL objects destroyed without one will leak in the driver" << std::endl; + } + } + + // + // Build the fallback context, once. Returns false -- quietly after + // the first time -- if it cannot be had. + // + bool createFallbackContext() + { + s_fallbackAttempted = true; + + // + // Insist on the global share group. GL names live in a share + // group, so deleting an FBO under a context outside the group + // that created it is not an error -- it simply does nothing, + // which is the exact silent leak this class is meant to stop. A + // non-sharing fallback would look like a fix and behave like the + // bug. + // + QOpenGLContext* share = QOpenGLContext::globalShareContext(); + + if (!share) + { + reportNoContext("no global share context; Qt::AA_ShareOpenGLContexts is not set"); + return false; + } + + QOpenGLContext* context = new QOpenGLContext; + context->setShareContext(share); + context->setFormat(share->format()); + + if (!context->create() || !context->shareContext()) + { + delete context; + reportNoContext("shared context creation failed"); + return false; + } + + QOffscreenSurface* surface = new QOffscreenSurface; + surface->setFormat(context->format()); + surface->create(); + + if (!surface->isValid()) + { + delete surface; + delete context; + reportNoContext("offscreen surface creation failed"); + return false; + } + + s_fallbackContext = context; + s_fallbackSurface = surface; + + return true; + } + + bool makeFallbackCurrent() + { + // + // QOpenGLContext is thread-affine and QOffscreenSurface::create() + // is GUI-thread only, so this fallback serves the GUI thread. That + // is where teardown runs. Anywhere else, decline rather than + // silently misbehave. + // + QCoreApplication* app = QCoreApplication::instance(); + + if (!qobject_cast(app)) + { + reportNoContext("no QGuiApplication"); + return false; + } + + if (QThread::currentThread() != app->thread()) + { + reportNoContext("not on the GUI thread"); + return false; + } + + if (!s_fallbackAttempted && !createFallbackContext()) + { + return false; + } + + if (!s_fallbackContext) + { + reportNoContext("fallback context unavailable"); + return false; + } + + if (!s_fallbackContext->makeCurrent(s_fallbackSurface)) + { + reportNoContext("fallback context could not be made current"); + return false; + } + + return true; + } + + } // namespace + + GLContextScope::GLContextScope(const GLVideoDevice* device) + : m_acquired(false) + , m_hasContext(false) + { + // + // Already current -- including a context bound natively rather than + // through Qt -- so leave it alone. Displacing a live context from + // inside a destructor would be far worse than the problem this scope + // solves. + // + if (twkGlAnyContextIsCurrent()) + { + m_hasContext = true; + return; + } + + if (device) + { + device->makeCurrent(); + + if (twkGlAnyContextIsCurrent()) + { + m_acquired = true; + m_hasContext = true; + return; + } + } + + if (makeFallbackCurrent()) + { + m_acquired = true; + m_hasContext = true; + } + } + + GLContextScope::~GLContextScope() + { + if (!m_acquired) + { + return; + } + + // + // Nothing was current on entry -- that is the only state in which + // this scope acquires -- so restoring means making nothing current. + // + // A context bound natively by the device cannot be released this way; + // Qt does not know about it. That is acceptable: it belongs to the + // device, which will bind or release it on its own terms. + // + if (QOpenGLContext* current = QOpenGLContext::currentContext()) + { + current->doneCurrent(); + } + } + +} // namespace TwkGLF diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h new file mode 100644 index 000000000..823ecc96a --- /dev/null +++ b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h @@ -0,0 +1,73 @@ +// +// Copyright (c) 2025 Autodesk, Inc. All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +// +#ifndef __TwkGLF__GLContextScope__h__ +#define __TwkGLF__GLContextScope__h__ + +class QOpenGLContext; + +namespace TwkGLF +{ + class GLVideoDevice; + + // + // GLContextScope + // + // Guarantees that a GL context is current for the lifetime of the scope, + // so that code which destroys GL objects actually destroys them. + // + // glDeleteFramebuffers() and friends are silent no-ops with no context + // current: the C++ object goes away, the driver's object does not, and + // nothing says so. Teardown paths are where this bites, because they are + // reached from destructors and from event callbacks rather than from + // inside a render, so nothing has arranged a context for them. Open one of + // these at the top of any scope that deletes GL objects and the question + // stops being the caller's problem. + // + // Construction resolves a context in this order: + // + // 1. A context is already current -- do nothing. This is the common + // case and costs a pointer compare, so the scope is safe to put on + // paths that also run mid-render. + // 2. A GLVideoDevice was supplied -- makeCurrent() on it. Prefer this + // where the caller knows its device; it is cheaper than 3 and it is + // the context the objects were most likely created under. + // 3. Otherwise -- a process-lifetime fallback context in RV's global + // share group. This is what makes the scope work in destructors that + // have already had their device pointers cleared out from under them. + // + // Destruction puts back what was current before, which -- because the + // scope only acquires when nothing was current -- means making nothing + // current again. + // + // If no context can be resolved, the scope reports once and does nothing. + // It never throws and never aborts: a teardown helper must not be the + // reason the process fails to exit. Callers that need to know can ask + // hasContext(). + // + class GLContextScope + { + public: + explicit GLContextScope(const GLVideoDevice* device = nullptr); + ~GLContextScope(); + + GLContextScope(const GLContextScope&) = delete; + GLContextScope& operator=(const GLContextScope&) = delete; + + // + // Is a context current for the body of this scope? False only when + // none could be resolved, in which case GL work here will not land. + // + bool hasContext() const { return m_hasContext; } + + private: + bool m_acquired; // did this scope make something current? + bool m_hasContext; + }; + +} // namespace TwkGLF + +#endif // __TwkGLF__GLContextScope__h__ From 773e78ccc0689d790ce8e226e323b6d9b1c92788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:38:38 -0400 Subject: [PATCH 19/48] fix(gl): destroy renderer FBOs with a GL context current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving presentation mode resizes the main view, which reaches Session::deviceSizeChanged() from the resize rather than from a render, so no context is current. It flushed the renderer's entire ImageFBO pool into nothing: the C++ objects went away, the driver's did not, and the only sign was a GL_ERROR from ~GLFBO after the fact. Open a GLContextScope at the owner instead of at each caller. ImageFBOManager::flushImageFBOs() and destroyImageFBO() cover every path that reaches them, present and future, so the hand-placed makeCurrent() in Session::clearVideoDeviceCaches() and the context restore in ~RvDocument go away rather than accumulating. Session passes the device it already knows -- deviceSizeChanged() its argument, clearVideoDeviceCaches() the control device -- which is cheaper than the fallback and is the context the objects were created under. ImageRenderer needs the same at three more points. Device::clearFBOs() deletes the FBO ring buffer, clearState() deletes the program cache immediately after flushing the pool -- so the pool's own scope closing would leave a gap -- and ~ImageRenderer covers both plus the program cache object. Session clears the renderer's device pointers before destroying it, so by then it has nothing to ask and the scope falls back to its own context; that case is the reason the fallback exists. ~GLFBO keeps a backstop. With no context current it reports once, naming the destruction site, and skips the GL calls rather than pretending the names were released. It asks only for an FBO that owns GL names: the GLFBO(const GLVideoDevice*) constructor builds a handle onto whatever the device has bound, with no id and no PBO, so destroying one issues nothing and needs no context. Checking the context before checking for work reported a leak that cannot happen on the ordinary path where a device outlives its window. It reports rather than asserts. A leaked FBO is worth a line of output; it is not worth aborting a shutdown that would otherwise have completed, least of all in the debug build someone is using to diagnose that shutdown. Signed-off-by: Cédrik Fuoco --- src/lib/graphics/TwkGLF/GLFBO.cpp | 60 +++++++++++++++++++++++++++-- src/lib/ip/IPCore/ImageFBO.cpp | 20 +++++++++- src/lib/ip/IPCore/ImageRenderer.cpp | 30 +++++++++++++++ src/lib/ip/IPCore/Session.cpp | 22 +++++++++++ 4 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src/lib/graphics/TwkGLF/GLFBO.cpp b/src/lib/graphics/TwkGLF/GLFBO.cpp index 3f739a1c1..942cc4d65 100644 --- a/src/lib/graphics/TwkGLF/GLFBO.cpp +++ b/src/lib/graphics/TwkGLF/GLFBO.cpp @@ -14,6 +14,8 @@ #include #include +#include + /// #define NDEBUG namespace TwkGLF @@ -83,7 +85,45 @@ namespace TwkGLF GLFBO::~GLFBO() { - if (m_id && m_ownsFBOHandle) + // + // Does this destructor have any GL work to do at all? + // + // Not every GLFBO owns GL names. The GLFBO(const GLVideoDevice*) + // constructor builds a handle *onto* whatever the device has bound -- + // m_id is 0, m_ownsFBOHandle is false, there is no PBO -- so + // destroying one issues nothing and needs no context. Asking about + // the context before asking this would report a leak that cannot + // happen, on the ordinary path where a device outlives its window. + // + const bool ownsHandles = (m_id != 0 && m_ownsFBOHandle); + const bool issuesGL = ownsHandles || (m_pbo != 0); + + // + // Backstop, for the FBOs that do own something. With no context + // current every GL call below is a silent no-op: this object goes + // away, the driver's does not, and nothing says so. Report it at this + // line -- rather than letting the stuck GL_INVALID_OPERATION surface + // at whichever unrelated call site checks glGetError() next -- and do + // not pretend the names were released. + // + // With GLContextScope on the teardown paths this should never fire. + // It is here so that the next path which forgets announces itself + // where the fault is, instead of a frame later somewhere else. + // + // It reports and carries on rather than asserting. A leaked FBO is + // worth a line of output; it is not worth aborting a shutdown that + // would otherwise have completed, least of all in the debug build + // someone is using to diagnose that shutdown. + // + bool canIssueGL = true; + + if (issuesGL && !twkGlAnyContextIsCurrent()) + { + twkGlPrintError(__FILE__, __FUNCTION__, __LINE__, ""); + canIssueGL = false; + } + + if (canIssueGL && ownsHandles) { glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0); TWK_GLDEBUG; @@ -115,11 +155,23 @@ namespace TwkGLF if (m_pbo) { - if (m_fence) + // + // Waiting on a fence with no context current cannot complete -- + // there is nothing to signal it -- so skip the wait rather than + // risk blocking here. The fence object itself is still ours to + // delete either way. + // + if (m_fence && canIssueGL) + { m_fence->wait(); + } delete m_fence; - glDeleteBuffers(1, &m_pbo); - TWK_GLDEBUG; + + if (canIssueGL) + { + glDeleteBuffers(1, &m_pbo); + TWK_GLDEBUG; + } } } diff --git a/src/lib/ip/IPCore/ImageFBO.cpp b/src/lib/ip/IPCore/ImageFBO.cpp index 1ce642624..356aeecff 100644 --- a/src/lib/ip/IPCore/ImageFBO.cpp +++ b/src/lib/ip/IPCore/ImageFBO.cpp @@ -6,6 +6,7 @@ //****************************************************************************** #include +#include #include namespace @@ -238,6 +239,17 @@ namespace IPCore void ImageFBOManager::destroyImageFBO(ImageFBO* imageFBO) { + // + // Everything below deletes GL objects -- the fence, then the FBO and + // its attachments -- so a context has to be current for any of it to + // reach the driver. This is reached from destructors and from event + // callbacks as well as from renders, so nothing upstream guarantees + // one. flushImageFBOs() opens a scope of its own around the whole + // loop; this one covers every other caller and costs a pointer + // compare when a context is already current. + // + const TwkGLF::GLContextScope contextScope; + m_totalSizeInBytes -= imageFBO->fbo()->totalSizeInBytes(); deleteFBOFence(imageFBO->fbo()); delete imageFBO->fbo(); @@ -246,7 +258,6 @@ namespace IPCore void ImageFBOManager::gcImageFBOs(size_t fullSerialNum) { - // Number of render cycles an unused regular FBO is kept before being freed. // A small grace window (roughly 200ms at 24fps) prevents thrashing when a // frame is temporarily skipped during cache warm-up or off-screen evaluation. @@ -545,6 +556,13 @@ namespace IPCore void ImageFBOManager::flushImageFBOs() { + // + // One scope for the whole flush rather than one per FBO. Same + // guarantee, but the fallback context -- if it is the one that ends + // up being used -- is made current once instead of once per object. + // + const TwkGLF::GLContextScope contextScope; + for (size_t i = 0; i < m_outputImageFBOs.size(); i++) destroyImageFBO(m_outputImageFBOs[i]); diff --git a/src/lib/ip/IPCore/ImageRenderer.cpp b/src/lib/ip/IPCore/ImageRenderer.cpp index 9808c99eb..93ebb7d5e 100644 --- a/src/lib/ip/IPCore/ImageRenderer.cpp +++ b/src/lib/ip/IPCore/ImageRenderer.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -182,6 +183,15 @@ namespace IPCore void ImageRenderer::Device::clearFBOs() { + // + // The ring buffer holds GLFBOs, so this is GL destruction and needs + // a context like any other. ~ImageRenderer reaches it after the + // renderer's device pointers have been cleared, which is why the + // scope may have to fall back to its own context; glDevice is still + // worth offering for the callers that reach here with one alive. + // + const TwkGLF::GLContextScope contextScope(glDevice); + for (size_t i = 0; i < fboRingBuffer.size(); i++) { FBOVector& views = fboRingBuffer[i].views; @@ -478,6 +488,17 @@ namespace IPCore m_uploadThread.join(); } + // + // Everything from here down deletes GL objects -- the FBO pool and + // program cache via clearState(), the program cache object itself, + // each device's FBO ring buffer, then the GL state. Session tears the + // renderer down after its device pointers have been cleared, so the + // members below have nothing to offer and the scope falls back to its + // own context. Holding one here means it is acquired once rather than + // once per inner scope. + // + const TwkGLF::GLContextScope contextScope(m_controlDevice.glDevice); + clearState(); // clean up @@ -610,6 +631,15 @@ namespace IPCore void ImageRenderer::clearState() { + // + // One scope around the whole of it. flushImageFBOs() opens its own, + // but that one closes when it returns -- and flushProgramCache() + // after it deletes GL programs, which needs a context just as much. + // Holding it here keeps the inner scopes as no-ops and leaves no gap + // between them. + // + const TwkGLF::GLContextScope contextScope(m_controlDevice.glDevice); + clearRenderedImages(); // clear state will unbind the FBO currently bound diff --git a/src/lib/ip/IPCore/Session.cpp b/src/lib/ip/IPCore/Session.cpp index ab35721ef..4e41bae53 100644 --- a/src/lib/ip/IPCore/Session.cpp +++ b/src/lib/ip/IPCore/Session.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -1144,6 +1145,18 @@ namespace IPCore void Session::clearVideoDeviceCaches() { + // + // Everything below destroys GL objects -- each device's cached FBO + // clones, then the renderer's entire ImageFBO pool via clearState() -> + // flushImageFBOs(). Nothing guarantees a context on entry: this runs + // from a RenderContextChangeEvent and from shutdown, not only from + // inside a render where one happens to be bound. + // + // We know our control device, so hand it over rather than making the + // scope fall back to its own context. + // + const TwkGLF::GLContextScope contextScope(dynamic_cast(m_controlVideoDevice)); + if (m_controlVideoDevice) m_controlVideoDevice->clearCaches(); if (m_outputVideoDevice) @@ -1156,6 +1169,15 @@ namespace IPCore { if (d == m_outputVideoDevice || d == m_controlVideoDevice) { + // + // This arrives from the view's resize, not from a render, so + // there is no context current -- and leaving presentation mode + // resizes the main view, which is how a whole FBO pool came to be + // deleted into nothing at exit. The device that changed size is + // right here, so use its context. + // + const TwkGLF::GLContextScope contextScope(dynamic_cast(d)); + m_renderer->flushImageFBOs(); } From b39d73ea3d229de5e605d4ef848fb95a939f512a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:38:49 -0400 Subject: [PATCH 20/48] fix(gl): release pooled PBOs while a context still exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UninitPBOPools() never released a PBO. PBOWrap::uninitPBOPool() only flipped an initialized flag, leaving gPoolToGPU and gPoolFromGPU -- both file-scope statics -- to delete their buffers from ~GLPixelBufferObjectPool at static destruction, after main() has returned. Qt is gone by then and no context can be obtained on any platform, so every glDeleteBuffers() in there was a silent no-op and the driver reclaimed the memory with the process. Give the pool a clear() that does the release, and call it from uninitPBOPool() so it happens while the application is still up. clear() empties the containers and resets the accounting as well as deleting, because the destructor still runs later and would otherwise walk the same entries a second time. UninitPBOPools() is called from main() once the event loop has returned, so the views and their contexts are already gone and nothing is current. A GLContextScope supplies the fallback context -- still available there, since the QApplication outlives the call -- which also covers the GLSyncObject fences deleted alongside each buffer. Signed-off-by: Cédrik Fuoco --- .../TwkGLF/GLPixelBufferObjectPool.cpp | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp b/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp index 86315af39..5b6c73bd7 100644 --- a/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp +++ b/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp @@ -8,6 +8,7 @@ #include +#include #include #include @@ -500,6 +501,34 @@ namespace TwkGLF _cleanupNoLock(_usedPool); } + // + // Release every buffer now, rather than at destruction. + // + // The two pools are file-scope statics, so their destructors run + // after main() has returned -- with Qt gone and no GL context + // obtainable, which makes every glDeleteBuffers() in there a silent + // no-op. UninitPBOPools() exists to release these while the + // application is still up; until now it only flipped a flag and left + // the buffers to that unreachable destructor. + // + // Empty the containers and reset the accounting as well as deleting: + // the destructor still runs later, and would otherwise walk the same + // entries a second time. + // + void clear() + { + std::unique_lock guard(_mutex); + + _cleanupNoLock(_freePool); + _cleanupNoLock(_usedPool); + + _freePool.clear(); + _usedPool.clear(); + + _allocSize = 0; + _allocNbBuffers = 0; + } + void setSoftMaxSize(size_t softMaxSize) { std::unique_lock guard(_mutex); @@ -825,6 +854,7 @@ namespace TwkGLF if (gPoolToGPUInitialized) { gPoolToGPUInitialized = false; + gPoolToGPU.clear(); } } else @@ -832,6 +862,7 @@ namespace TwkGLF if (gPoolFromGPUInitialized) { gPoolFromGPUInitialized = false; + gPoolFromGPU.clear(); } } } @@ -922,11 +953,24 @@ namespace TwkGLF // void UninitPBOPools() { + // + // This is called from main() once the event loop has returned, so + // the views and their contexts are already gone and nothing is + // current. The scope supplies the fallback teardown context -- still + // available here, since the QApplication outlives this call -- so + // that the buffers released below are genuinely released. + // + const GLContextScope contextScope; + if (prefetchUsePBOs) + { PBOWrap::uninitPBOPool(GLPixelBufferObject::TO_GPU); + } if (writeBehindUsePBOs) + { PBOWrap::uninitPBOPool(GLPixelBufferObject::FROM_GPU); + } } } // namespace TwkGLF From 55133aae4e230d83727e36349c24f86812b2e693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:39:15 -0400 Subject: [PATCH 21/48] fix(rv): restore cout/cerr when the console window is destroyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RvConsoleWindow installs its cout/cerr redirect under #if defined(NDEBUG) || defined(PLATFORM_WINDOWS) and took it down under #if defined(NDEBUG) || !defined(PLATFORM_WINDOWS) A Windows debug build is the one combination where those disagree, so there the redirect went in and never came out. ConsoleBuf stayed on cout and cerr with m_console pointing at the destroyed window. main() deletes RvApplication before finalizePython(), and Py_Finalize's garbage collection can still write -- a ResourceWarning from an unclosed socket, for one. That write reached ConsoleBuf, followed m_console into freed memory, and locked a QMutex whose bits happened to read "contended". Nothing ever releases it, so RV hung on the way out and had to be killed. It also meant any late shutdown output was lost. Guard on having installed the redirect rather than on a second attempt at the same #if. m_stdoutBuf and m_stderrBuf are non-null only if the install ran, and processLastTextBuffer() nulls them if it got there first, so this is correct in every build and safe to run twice. Delete the ConsoleBuf too, so nothing is left pointing at the window. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvConsoleWindow.cpp | 33 ++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/app/RvCommon/RvConsoleWindow.cpp b/src/lib/app/RvCommon/RvConsoleWindow.cpp index f4deb0acd..e13c2cd94 100644 --- a/src/lib/app/RvCommon/RvConsoleWindow.cpp +++ b/src/lib/app/RvCommon/RvConsoleWindow.cpp @@ -133,12 +133,41 @@ namespace Rv m_consoleBuf->sync(); processTextBuffer(); -#if defined(NDEBUG) || !defined(PLATFORM_WINDOWS) + // + // Put cout/cerr back, and take the buffer down with us. + // + // Guard on having installed the redirect rather than on a second + // #if. The install above is compiled in when NDEBUG *or* + // PLATFORM_WINDOWS; this restore used to ask for NDEBUG or + // *!*PLATFORM_WINDOWS. A Windows debug build is the one combination + // where those disagree, so there the redirect went in and never came + // out: ConsoleBuf stayed on cout/cerr with m_console pointing at this + // destroyed window. + // + // main() deletes RvApplication before finalizePython(), and + // Py_Finalize's GC can still write -- a ResourceWarning from an + // unclosed socket, say. That write reached ConsoleBuf, followed + // m_console into freed memory, and locked a QMutex whose bits happened + // to read "contended", which never resolves. That is the hang on exit. + // + // m_stdoutBuf/m_stderrBuf are non-null only if the install ran, and + // processLastTextBuffer() nulls them if it got here first, so this is + // correct in every build and safe to run twice. + // if (m_stdoutBuf) + { cout.rdbuf(m_stdoutBuf); + m_stdoutBuf = nullptr; + } + if (m_stderrBuf) + { cerr.rdbuf(m_stderrBuf); -#endif + m_stderrBuf = nullptr; + } + + delete m_consoleBuf; + m_consoleBuf = nullptr; } void RvConsoleWindow::processTimer() From d29e50fe996bf039d96eb4a2e2f8bc5c43aba59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:39:51 -0400 Subject: [PATCH 22/48] fix(rv): stop auxiliary windows from blocking application exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RV never calls quit(). It relies entirely on Qt's quitOnLastWindowClosed, and QApplicationPrivate::shouldQuit() counts every visible top-level widget carrying WA_QuitOnClose, which is on by default. So any auxiliary window left visible when the session window goes stops exec() from ever returning: the process stays up with a stray dialog on screen and has to be killed. The console reached that state on its own. RvConsoleWindow::processTextBuffer() calls show() and raise() for any line the show-on preference considers interesting, and at showOn=3 processLine() returns true for every line. It runs from a queued event, so it lands after ~RvDocument has closed the console, reopening it as the last visible window while the rest of shutdown is still producing output. Two changes, because either alone leaves a hole. WA_QuitOnClose is cleared on the console, the preferences dialog and the profile manager, so a log or settings window can never hold the process open however it came to be visible -- including a user deliberately leaving one open. And RvApplication::isShuttingDown(), set in ~RvDocument before the last document closes those windows, gates the auto-show so no console appears on the way out. The attribute is applied to all three because they are closed in the same ~RvDocument block and have exactly the same exposure; fixing only the one that was observed would leave two identical bugs behind. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvCommon/RvApplication.h | 14 +++++++++++ src/lib/app/RvCommon/RvConsoleWindow.cpp | 24 ++++++++++++++++++- src/lib/app/RvCommon/RvDocument.cpp | 17 +++++++++++++ src/lib/app/RvCommon/RvPreferences.cpp | 6 +++++ src/lib/app/RvCommon/RvProfileManager.cpp | 7 ++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/lib/app/RvCommon/RvCommon/RvApplication.h b/src/lib/app/RvCommon/RvCommon/RvApplication.h index 93ad2766d..f2ca4daa4 100644 --- a/src/lib/app/RvCommon/RvCommon/RvApplication.h +++ b/src/lib/app/RvCommon/RvCommon/RvApplication.h @@ -99,6 +99,19 @@ namespace Rv RvPreferences* prefDialog(); RvProfileManager* profileManager(); + // + // Has the last document begun tearing down? + // + // Set once, on the way out, so that code running from queued events + // during shutdown can tell it is too late to put something back on + // screen. The console uses it: its auto-show is driven by output, and + // shutdown produces plenty of that after the windows have been asked + // to close. + // + bool isShuttingDown() const { return m_shuttingDown; } + + void setShuttingDown() { m_shuttingDown = true; } + bool networkDialogRunning() const { return m_networkDialog ? true : false; } void processNetworkOpts(bool startup = true); @@ -177,6 +190,7 @@ namespace Rv RvWebManager* m_webManager; TwkApp::VideoDevice* m_presentationDevice; bool m_presentationMode; + bool m_shuttingDown{false}; mutable pthread_mutex_t m_deleteLock; std::string m_executableNameCaps; DesktopVideoModule* m_desktopModule; diff --git a/src/lib/app/RvCommon/RvConsoleWindow.cpp b/src/lib/app/RvCommon/RvConsoleWindow.cpp index e13c2cd94..222bdafaa 100644 --- a/src/lib/app/RvCommon/RvConsoleWindow.cpp +++ b/src/lib/app/RvCommon/RvConsoleWindow.cpp @@ -7,6 +7,7 @@ //****************************************************************************** #include +#include #include #include #include @@ -101,6 +102,18 @@ namespace Rv setWindowTitle(UI_APPLICATION_NAME " Console"); setWindowIcon(QIcon(qApp->applicationDirPath() + QString(RV_ICON_PATH_SUFFIX))); setSizeGripEnabled(true); + + // + // A log window must never be what keeps RV alive. + // + // RV has no explicit quit anywhere; it relies entirely on Qt's + // quitOnLastWindowClosed. Qt counts every visible top-level widget + // that has WA_QuitOnClose, which is on by default, so leaving this + // dialog open -- whether the user opened it or output reopened it -- + // was enough to stop exec() from ever returning once the session + // window had gone. + // + setAttribute(Qt::WA_QuitOnClose, false); bool doRedirect = (getenv("RV_NO_CONSOLE_REDIRECT") == 0); // setAttribute(Qt::WA_MacBrushedMetal); @@ -275,7 +288,16 @@ namespace Rv } } - if (shouldShow) + // + // Not on the way out. This runs from a queued event, so it lands + // after the last document's destructor has already closed this + // window, and shutdown emits plenty of output for it to react to. + // Re-showing here put the console back on screen as the only + // visible window, which -- with no explicit quit anywhere in RV + // -- meant quitOnLastWindowClosed never fired and exec() never + // returned. The console stayed up and the process hung. + // + if (shouldShow && !(RvApp() && RvApp()->isShuttingDown())) { show(); raise(); diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 0c947ecd2..279b0b57c 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -632,6 +632,15 @@ namespace Rv // Then this is the last document, so shutdown network // + // + // Say so before closing anything. The closes below are not the + // end of the output -- tearing down the session produces plenty + // more -- and the console's auto-show runs from a queued event, + // so it would otherwise reopen after being closed here and, being + // the last visible window, keep the application alive forever. + // + RvApp()->setShuttingDown(); + if (RvNetworkDialog* d = RvApp()->networkWindow()) { if (d->serverRunning()) @@ -649,6 +658,14 @@ namespace Rv RvSettings::cleanupGlobalSettings(); } + // + // ~Session deletes the renderer, and ~ImageRenderer tears down every + // FBO that ImageFBOManager still holds. Those deletes need a context + // and cannot ask for one -- the two setters above nulled the + // renderer's device pointers, so by now it has nothing left to ask. + // TwkGLF::GLContextScope inside ImageFBOManager covers it with the + // fallback teardown context, so there is nothing to arrange here. + // delete m_session; delete m_menuTimer; diff --git a/src/lib/app/RvCommon/RvPreferences.cpp b/src/lib/app/RvCommon/RvPreferences.cpp index d47c33e0a..d9a4d7596 100644 --- a/src/lib/app/RvCommon/RvPreferences.cpp +++ b/src/lib/app/RvCommon/RvPreferences.cpp @@ -128,6 +128,12 @@ namespace Rv connect(m_ui.exrNumThreadsEdit, SIGNAL(textChanged(const QString&)), this, SLOT(exrThreadNumChanged(const QString&))); setWindowTitle(UI_APPLICATION_NAME " Preferences"); + // + // Auxiliary window: never the reason RV stays alive. See + // RvConsoleWindow's constructor for why that matters. + // + setAttribute(Qt::WA_QuitOnClose, false); + #ifndef PLATFORM_DARWIN m_ui.appleClientStorageToggle->setEnabled(false); #endif diff --git a/src/lib/app/RvCommon/RvProfileManager.cpp b/src/lib/app/RvCommon/RvProfileManager.cpp index 298925842..af39bedf0 100644 --- a/src/lib/app/RvCommon/RvProfileManager.cpp +++ b/src/lib/app/RvCommon/RvProfileManager.cpp @@ -33,6 +33,13 @@ namespace Rv , m_createDialog(0) { m_ui.setupUi(this); + + // + // Auxiliary window: never the reason RV stays alive. See + // RvConsoleWindow's constructor for why that matters. + // + setAttribute(Qt::WA_QuitOnClose, false); + loadModel(); m_createDialogUI.setupUi(m_createDialog = new QDialog(this, Qt::Sheet)); From 8d955026d7dd6ac2407ac2c1939c78f8d576f293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Sat, 19 Sep 2026 08:40:18 -0400 Subject: [PATCH 23/48] fix(audio): do not block shutdown on a thread with no event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detachAudioOutputDevice() hands work to the audio thread with Qt::BlockingQueuedConnection three times over -- emitStopDevice(), emitStopAudio(), and the delete of the output objects -- and none of them checked that the thread could run it. A BlockingQueuedConnection blocks until the target's event loop dispatches the call, so a thread that never started, whose createAudioOutput() failed, or that has already left exec() never releases the caller. detachAudioOutputDevice() then never reaches its own quit() and wait(). Calling from the audio thread itself would deadlock outright. canBlockOnAudioThread() answers that before each handoff: the thread is running, it has an event dispatcher, and it is not us. Skipping the stops costs nothing when it is false, since a thread not running its loop is not playing either. The delete becomes best-effort rather than all-or-nothing. It is still marshalled when there is a loop, which is what keeps Qt6 debug builds from tripping QObject::~QObject()'s cross-thread assertion on the QIODevice that QWindowsAudioSink parents. When there is not, the now idempotent deleteAudioOutputObjects() runs after wait() has returned and the thread is finished. A Qt warning on the way out beats never getting out. wait() is bounded and reports once if the bound is reached, so a wedged audio thread degrades to a slow exit rather than no exit. This is not the hang reported on Windows -- a captured stack put that in RvConsoleWindow -- but it is the same failure waiting to happen, and it is not reachable by inspection alone. Signed-off-by: Cédrik Fuoco --- .../audio/QTAudioRenderer/QTAudioRenderer.cpp | 92 +++++++++++++++++-- .../QTAudioRenderer/QTAudioRenderer.h | 27 ++++++ 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp index 11a01bf26..8a5216c5e 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp @@ -238,6 +238,17 @@ namespace IPCore } #endif + // + // Reached during shutdown, when the audio thread may already be on + // its way out. Blocking on a thread with no event loop would never + // return; skipping the stop costs nothing there, since a thread that + // is not running its loop is not playing either. + // + if (!canBlockOnAudioThread()) + { + return; + } + QMetaObject::invokeMethod(m_audioOutput, "stopAudio", Qt::BlockingQueuedConnection); } @@ -298,6 +309,15 @@ namespace IPCore } #endif + // + // See emitStopAudio(): also reached during shutdown, and blocking on + // a thread that cannot run the call never returns. + // + if (!canBlockOnAudioThread()) + { + return; + } + QMetaObject::invokeMethod(m_ioDevice, "stopDevice", Qt::BlockingQueuedConnection); } @@ -382,22 +402,76 @@ namespace IPCore emitStopAudio(); } - quit(); - wait(); - - if (m_audioOutput) + // + // m_audioOutput/m_ioDevice were created inside run(), so they belong to + // this audio thread, not to whatever thread is calling + // detachAudioOutputDevice() (typically the main/UI thread, via + // ~QTAudioThread()). On Windows, QAudioSink's backing QWindowsAudioSink + // parents an internal QIODevice, so deleting these objects directly + // from another thread trips QObject::~QObject()'s cross-thread + // sendEvent() assertion (fatal in Qt6 debug builds). Delete them on the + // thread that owns them, while its event loop is still running to + // process the call. + // + // Only while there is such a loop, though. See canBlockOnAudioThread(): + // handing work to a thread that cannot run it and then waiting is a + // shutdown that never completes, which is strictly worse than the + // assertion this marshalling avoids. + if ((m_audioOutput || m_ioDevice) && canBlockOnAudioThread()) { - delete m_audioOutput; - m_audioOutput = 0; + QObject* owner = m_audioOutput ? static_cast(m_audioOutput) : static_cast(m_ioDevice); + + QMetaObject::invokeMethod(owner, [this]() { deleteAudioOutputObjects(); }, Qt::BlockingQueuedConnection); } - if (m_ioDevice) + quit(); + waitForAudioThreadToFinish(); + + // + // Anything the marshalled delete could not reach -- because there was + // no loop to marshal onto, or because it timed out. The thread is + // finished or beyond help by now, so doing it here is the last + // resort, and a Qt warning on the way out beats never getting out. + // + deleteAudioOutputObjects(); + } + + bool QTAudioThread::canBlockOnAudioThread() const + { + return isRunning() && eventDispatcher() != nullptr && QThread::currentThread() != this; + } + + void QTAudioThread::waitForAudioThreadToFinish() + { + // + // Long enough that a healthy thread always makes it, short enough + // that a wedged one does not strand the user in a process they have + // to kill. + // + constexpr unsigned long audioThreadExitTimeoutMS = 5000; + + if (!wait(audioThreadExitTimeoutMS)) { - delete m_ioDevice; - m_ioDevice = 0; + static bool reported = false; + + if (!reported) + { + reported = true; + std::cerr << "WARNING: audio thread did not exit within " << audioThreadExitTimeoutMS + << " ms; continuing shutdown without it" << std::endl; + } } } + void QTAudioThread::deleteAudioOutputObjects() + { + delete m_audioOutput; + m_audioOutput = 0; + + delete m_ioDevice; + m_ioDevice = 0; + } + // // This is the callback called by QTAudioIODevice::readData() for // pushing audio data to the audio device. diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h b/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h index bf9012794..4674e4c54 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h @@ -167,6 +167,33 @@ namespace IPCore void detachAudioOutputDevice(); + // + // Can work be handed to this thread and waited on? + // + // Qt::BlockingQueuedConnection blocks until the target thread's + // event loop runs the call, so that loop has to exist and has to + // belong to somebody else. A thread that never started, whose + // createAudioOutput() failed, or that has already left exec() has no + // loop, and waiting on one never returns -- a process that will not + // exit rather than one that exits late. Calling from the audio + // thread itself would deadlock outright. + // + bool canBlockOnAudioThread() const; + + // + // wait(), bounded. An unbounded wait on a thread that will not + // finish is the same hang by another route; a late exit is worth + // more than a perfect one. Reports once if the bound is reached. + // + void waitForAudioThreadToFinish(); + + // + // Delete the output objects and null them. Idempotent, so it can be + // run on the audio thread and then again afterwards to catch + // whatever that could not reach. + // + void deleteAudioOutputObjects(); + private: QMutex m_mutex; From e690e15b734bca29abda24d86d4e6904b299ccf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:08 -0400 Subject: [PATCH 24/48] fix(container): return null from an empty property's data() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit front() on an empty container is undefined behaviour, and a hard assert ("front() called on empty vector") in an MSVC debug build. Callers pair data()/rawData() with size(), so reporting the absence of storage with a null pointer makes a zero-length copy out of or into a cleared property a no-op rather than a crash. Release builds already returned null here for any property that never held a value, so this only makes the existing behaviour well defined. Signed-off-by: Cédrik Fuoco --- .../TwkContainer/TwkContainer/Property.h | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/geometry/TwkContainer/TwkContainer/Property.h b/src/lib/geometry/TwkContainer/TwkContainer/Property.h index 92f5a663b..ab1e31fd0 100644 --- a/src/lib/geometry/TwkContainer/TwkContainer/Property.h +++ b/src/lib/geometry/TwkContainer/TwkContainer/Property.h @@ -517,10 +517,25 @@ namespace TwkContainer } } + // + // An empty property has no storage to point at. front() on an empty + // container is undefined behaviour -- and a hard assert ("front() called + // on empty vector") in an MSVC debug build -- so report the absence with + // a null pointer instead. Callers that pair data()/rawData() with size() + // then do the right thing for free: a zero-length copy out of or into a + // cleared property becomes a no-op rather than a crash. Release builds + // already returned null here for any property that never held a value, + // so this only makes the existing behaviour well defined. + // template typename TypedProperty::const_value_pointer TypedProperty::data() const { + if (m_container.empty()) + { + return nullptr; + } + return &(m_container.front()); } @@ -528,6 +543,11 @@ namespace TwkContainer typename TypedProperty::value_pointer TypedProperty::data() { + if (m_container.empty()) + { + return nullptr; + } + return &(m_container.front()); } From 8e6f6c16979c8a5f3de90fc70514aa843da49968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:10 -0400 Subject: [PATCH 25/48] fix(rv): clear the global settings pointer after deleting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PackageManager deleted m_globalSettingsP without clearing it. globalSettings() only allocates when that pointer is null, so every later caller got a reference to freed memory and died dereferencing the destroyed QSettings inside it. RvDocument reaches this while closing the last document, so anything that saves settings after that point -- RvConsoleWindow::done() closing the console dialog, for one -- crashed on the way out. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvPackage/PackageManager.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/app/RvPackage/PackageManager.cpp b/src/lib/app/RvPackage/PackageManager.cpp index 34eb7c68f..977dfe8ec 100644 --- a/src/lib/app/RvPackage/PackageManager.cpp +++ b/src/lib/app/RvPackage/PackageManager.cpp @@ -2043,6 +2043,17 @@ namespace Rv { m_globalSettingsP->sync(); delete m_globalSettingsP; + + // + // Clear it: globalSettings() only allocates when this is null, so + // leaving it dangling means every later caller gets a reference to + // freed memory and dies dereferencing the destroyed QSettings + // inside it. RvDocument calls this while closing the last + // document, and anything that saves settings after that point -- + // RvConsoleWindow::done() closing the console dialog, for one -- + // then crashes on the way out. + // + m_globalSettingsP = 0; } } From ebb63055015398a1d6132caef13b41d12666cfd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:12 -0400 Subject: [PATCH 26/48] fix(audio): release the audio output objects when creation fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When createAudioOutput() fails the render thread returns without reaching exec(), so no event loop ever runs on it. detachAudioOutputDevice() cannot marshal the deletion back onto that thread afterwards -- its BlockingQueuedConnection has no loop to run on -- so whatever was allocated before the failure was never freed. Release it here, on the thread that owns it, before returning. Signed-off-by: Cédrik Fuoco --- src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp index 8a5216c5e..aa6c0257c 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp @@ -350,7 +350,19 @@ namespace IPCore // so that the QTAudioOuput and QTAudioIODevice // is created within run()'s execution thread. if (!createAudioOutput()) + { + // + // Release whatever was allocated before the failure, here, on the + // thread that owns it. Returning without exec() means no event + // loop ever runs on this thread, so detachAudioOutputDevice() + // could not marshal the deletion onto it -- its + // BlockingQueuedConnection would have no loop to run on. Clearing + // the pointers leaves it nothing to do. + // + deleteAudioOutputObjects(); + return; + } exec(); } From c24ea6a1f96be8ff9151646d8075ecf9341a3f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:15 -0400 Subject: [PATCH 27/48] fix(rv): make the display colour-profile query safe on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults in DesktopVideoDevice::queryColorProfile(). The search for a window on the target screen walked every top-level QWindow in the process, and QWindow::winId() creates the platform window when there is none. Every QQuickWidget -- so every QWebEngineView panel, Live Review among them -- owns a parentless offscreen QQuickWindow that Qt is explicit must never be created ("Do not call create() on offscreenWindow", qquickwidget.cpp). Handing it a platform window trips Q_ASSERT(!d->offscreenWindow->handle()) at the end of QQuickWidget::createFramebufferObject() and aborts RV the moment that panel is first shown. Consider only windows that are already realized: any of them on that screen reports the same monitor profile. The ICC lookup below it sized its path buffer from an unchecked length, freed a new[] allocation with scalar delete, used UrlCreateFromPath's output without checking it succeeded, and dereferenced cmsOpenProfileFromFile's result without a null test -- it returns null when the path the driver reported is gone or unreadable -- and never closed the profile. Use std::vector, take the DWORD the API actually wants, and check both calls. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 68 +++++++++++++-------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 051667a0b..51317e11f 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -33,6 +33,7 @@ #include #include +#include // #define DEBUG_NO_FULLSCREEN @@ -861,33 +862,48 @@ namespace Rv // XXX The following steps are not Unicode safe // + m_colorProfile = ColorProfile(); + // Get the context for this screen const QList screens = QGuiApplication::screens(); // Ensure the screen index is valid. if (m_screen < 0 || m_screen >= screens.size()) { - m_colorProfile = ColorProfile(); return m_colorProfile; } - QScreen* targetScreen = screens[m_screen]; + const QScreen* targetScreen = screens[m_screen]; QWindow* windowOnTargetScreen = nullptr; - const QList windows = QGuiApplication::topLevelWindows(); - // Check all windows to find one on the target screen. - for (QWindow* window : windows) + // + // Find a window on the target screen to borrow a device context from, + // and consider only windows that are *already* realized. + // + // QWindow::winId() creates the platform window when there is none, + // and this loop walks every top-level QWindow in the process -- + // including ones that must never be realized. Every QQuickWidget, so + // every QWebEngineView panel and Live Review among them, owns a + // parentless offscreen QQuickWindow that Qt is explicit about ("Do + // not call create() on offscreenWindow", qquickwidget.cpp). Handing it + // a platform window trips Q_ASSERT(!d->offscreenWindow->handle()) at + // the end of QQuickWidget::createFramebufferObject() and aborts RV the + // moment that panel is first shown. Any already-realized window on the + // screen reports the same monitor profile, so there is nothing to gain + // by creating one. + // + for (QWindow* window : QGuiApplication::topLevelWindows()) { - if (window->screen() == targetScreen) + if (window->handle() && window->screen() == targetScreen) { windowOnTargetScreen = window; + break; } } if (!windowOnTargetScreen) { // Return empty profile if no window is found on the screen. - m_colorProfile = ColorProfile(); return m_colorProfile; } @@ -897,37 +913,39 @@ namespace Rv if (hdc) { // Look for the profile path - unsigned long pathLen; + DWORD pathLen = 0; GetICMProfile(hdc, &pathLen, NULL); - char* path = new char[pathLen]; - if (GetICMProfile(hdc, &pathLen, path)) + std::vector path(pathLen > 0 ? pathLen : 1); + + if (pathLen > 0 && GetICMProfile(hdc, &pathLen, path.data())) { // If we found a profile lets set the type, // url, and description m_colorProfile.type = ICCProfile; - unsigned long maxLen = 2084; - char* url = new char[maxLen]; - UrlCreateFromPath(path, url, &maxLen, NULL); - m_colorProfile.url = url; - - char desc[256]; - cmsHPROFILE profile = cmsOpenProfileFromFile(path, "r"); - cmsGetProfileInfoASCII(profile, cmsInfoDescription, "en", "US", desc, 256); - m_colorProfile.description = desc; + DWORD maxLen = 2084; + std::vector url(maxLen); + if (SUCCEEDED(UrlCreateFromPath(path.data(), url.data(), &maxLen, NULL))) + { + m_colorProfile.url = url.data(); + } - delete url; + // cmsOpenProfileFromFile returns null when the path the driver + // reported is gone or unreadable; cmsGetProfileInfoASCII would + // dereference it. + if (cmsHPROFILE profile = cmsOpenProfileFromFile(path.data(), "r")) + { + char desc[256] = {0}; + cmsGetProfileInfoASCII(profile, cmsInfoDescription, "en", "US", desc, sizeof(desc)); + m_colorProfile.description = desc; + cmsCloseProfile(profile); + } } - delete path; ReleaseDC(hwnd, hdc); } - else - { - m_colorProfile = ColorProfile(); - } return m_colorProfile; } From f48aa6a02d15c3552e0646d0636c5e71f205c311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:18 -0400 Subject: [PATCH 28/48] fix(rv): present the second display through a shared-context QOpenGLWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presentation output's ScreenView was a top-level QOpenGLWidget. In Qt 6 that is composited through its own top-level window's RHI backing store and takes that window's GL context as its share parent, not the application's global share context -- so it can land in a private share group. transfer() wraps the renderer's output FBO colour texture in a local FBO, and a texture is only visible across contexts in the same group: in the wrong one glIsTexture() is false for a live texture, every transfer() is refused and the second display stays black. Which group it landed in varied run to run, which is what made the black presentation output intermittent. Split the class. ScreenWindow is a QOpenGLWindow, which takes the context to share with as a constructor argument -- the only point at which sharing can be established. ScreenView becomes a plain QWidget container around it via createWindowContainer, so the top-level is not forced onto the OpenGL RHI backend. This mirrors GLView/GLWindow, which is the main view and demonstrably sits in the renderer's group. PartialUpdateBlit keeps a backing FBO -- transfer() requires fboID() to be non-zero -- and does not clear before paintGL(). initializeGL() no longer calls context()->setShareContext(): that only takes effect on the next create(), and the context already exists by then, so it never did what it looked like it did. It verifies the share group and reports a mismatch instead. Two consequences of the new surface: - open() makes the share device current before copying its surface format. A QOpenGLWindow creates its context lazily, so right after a main-view backend swap the new main view's context does not exist yet and glShareContext() is null -- which is another way to end up outside the renderer's group. - Nothing primes the context in open() any more. A PartialUpdateBlit window's makeCurrent() binds the backing FBO that Qt only creates on the first paint, so calling it before then dereferences a null FBO inside Qt and takes the process down. transfer(), transfer2() and makeCurrent() gate on fboID() for the same reason and skip the frame; show() drives the expose that creates the FBO. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 212 +++++++++++++++--- .../RvCommon/RvCommon/DesktopVideoDevice.h | 70 +++++- 2 files changed, 247 insertions(+), 35 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 51317e11f..62610798f 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -33,6 +33,8 @@ #include #include +#include + #include // #define DEBUG_NO_FULLSCREEN @@ -82,21 +84,22 @@ namespace Rv void DesktopVideoDevice::redraw() const { - if (m_view) + if (m_view && m_view->glWindow()) { ScopedLock lock(m_mutex); - QSize s = m_view->size(); - m_view->update(); + // Update the GL surface, not the container: only the window has + // anything to present. + m_view->glWindow()->update(); } } void DesktopVideoDevice::redrawImmediately() const { - if (m_view && m_view->isVisible()) + if (m_view && m_view->glWindow() && m_view->isVisible()) { ScopedLock lock(m_mutex); TWK_GLDEBUG; - m_view->update(); + m_view->glWindow()->update(); TWK_GLDEBUG; } else @@ -121,7 +124,21 @@ namespace Rv // later. GLint svFboId = m_viewDevice->fboID(); if (svFboId == 0) + { + // Report the stall once, not once per frame. + if (!m_transferStalled) + { + m_transferStalled = true; + cerr << "WARNING: DesktopVideoDevice: '" << name() << "' present stalled: the GL surface has no backing FBO yet" + << " (fboID()==0), so nothing can be composited into it. The output will hold its last frame until the" + << " surface paints." << endl; + } return; + } + + // Re-arm the stall report, silently: the interesting event is the + // next stall, not the recovery from this one. + m_transferStalled = false; // Switch to the ScreenView's OpenGL context. m_viewDevice->makeCurrent(); // calls screenview's makeCurrent, sets the @@ -175,13 +192,37 @@ namespace Rv // const QTGLVideoDevice* share = shareDevice(); + // + // Realize the share device's GL context before copying its format and + // creating ours. + // + // A QOpenGLWindow creates its QOpenGLContext lazily, on the first + // makeCurrent/paint -- creating the platform window is not enough. So + // when this runs right after a main-view backend swap (RvDocument's + // swap paths call RvApplication::rebuildDesktopVideoDevices, which + // re-opens the presentation output in the same call frame), the new + // main view's context does not exist yet: glShareContext() is null and + // glSurfaceFormat() is not yet the format we are going to have to + // share with. Building our context from that is how this device can + // end up outside the renderer's share group, which makes the + // renderer's output textures unreachable and the display black. + // + if (share) + { + share->makeCurrent(); + } + QSurfaceFormat fmt = share ? share->glSurfaceFormat() : QSurfaceFormat::defaultFormat(); fmt.setSwapInterval(m_vsync ? 1 : 0); ScreenView* vw = new ScreenView(fmt, 0, share ? share->glShareContext() : nullptr, Qt::Window); setViewWidget(vw); - QTGLVideoDevice* vd = new QTGLVideoDevice(0, "local view", vw); + // + // The GL surface is the embedded window; the container widget is what + // handles events and coordinate translation. Same split as GLView. + // + QTGLVideoDevice* vd = new QTGLVideoDevice(0, "local view", vw->glWindow(), vw); setViewDevice(vd); QRect g = screenGeometry(); @@ -198,9 +239,21 @@ namespace Rv viewWidget()->setGeometry(g); viewWidget()->show(); - // QCoreApplication::processEvents(); // force the window to - // show. m_share->makeCurrent(); + // + // Deliberately no makeCurrent() here to "prime" the context. + // + // VulkanDesktopVideoDevice::open() can end that way because + // QTVulkanVideoDevice::makeCurrent() builds its own offscreen FBO on + // demand. A PartialUpdateBlit QOpenGLWindow cannot: its makeCurrent() + // binds the backing FBO that Qt only creates on the first paint, so + // calling it before this window has painted dereferences a null FBO + // inside Qt and takes the process down. + // + // Nothing needs priming anyway -- show() drives the expose that + // creates the FBO, and transfer() skips any frame where fboID() is + // still 0 and picks up the next one. + // TWK_GLDEBUG; } @@ -214,7 +267,7 @@ namespace Rv m_translator = 0; } - void DesktopVideoDevice::setViewWidget(QOpenGLWidget* widget) + void DesktopVideoDevice::setViewWidget(ScreenView* widget) { m_view = widget; m_translator = new QTTranslator(this, m_view); @@ -222,8 +275,48 @@ namespace Rv void DesktopVideoDevice::makeCurrent() const { - if (m_view) - m_view->makeCurrent(); + // + // Route through the view device rather than the widget: the GL context + // belongs to the embedded ScreenWindow, and QTGLVideoDevice:: + // makeCurrent() knows how to bind it (and its default FBO). + // + if (!m_viewDevice) + { + return; + } + + // + // Not before the surface has produced its backing FBO. The GL surface + // here is a PartialUpdateBlit QOpenGLWindow, whose makeCurrent() binds + // that FBO, and Qt creates it on the first paint -- so calling this + // earlier crashes inside Qt on a null FBO rather than failing softly. + // + // fboID() is the cheap, null-safe way to ask whether the surface is + // ready (QOpenGLWindow::defaultFramebufferObject() returns 0 until the + // FBO exists). transfer() gates on the same test, so a frame that + // arrives too early is skipped rather than lost. + // + // A surface that is gone is a different matter from one that has not + // painted yet. fboID() is 0 for both, but with no platform window + // QTGLVideoDevice::makeCurrent() takes its offscreen path, which binds + // the context to a QOffscreenSurface and never touches that backing + // FBO -- so it cannot crash the way the pre-first-paint case can. + // Teardown needs exactly that path: releaseFBOClones() calls this + // before deleting the clones, and blocking it here is what left + // ~GLFBO running with no context at all while quitting. transfer() + // keeps the stricter test on purpose -- a vanished surface has nothing + // to present to, so skipping the frame there is right. + // + const QTGLVideoDevice* glViewDevice = dynamic_cast(m_viewDevice); + const QOpenGLWindow* surfaceWindow = glViewDevice ? glViewDevice->window() : nullptr; + const bool surfaceGone = surfaceWindow && !surfaceWindow->handle(); + + if (!surfaceGone && m_viewDevice->fboID() == 0) + { + return; + } + + m_viewDevice->makeCurrent(); } void DesktopVideoDevice::setupModelviewAndProjection(float w, float h, GLPipeline* glPipeline) const @@ -303,6 +396,17 @@ namespace Rv // ScopedLock lock(m_mutex); + + // + // Same readiness gate as transfer(): the GL surface has no backing FBO + // until it has painted once, and binding its context before then + // crashes inside Qt. Skip the frame; the next one will land. + // + if (m_viewDevice->fboID() == 0) + { + return; + } + m_viewDevice->makeCurrent(); TWK_GLDEBUG; const float w = m_viewDevice->width(); @@ -765,35 +869,91 @@ namespace Rv void DesktopVideoDevice::sortVideoFormatsByWidth() { sort(m_videoFormats.begin(), m_videoFormats.end(), widthSort); } - DesktopVideoDevice::ScreenView::ScreenView(const QSurfaceFormat& fmt, QWidget* parent, QOpenGLContext* glShareContext, - Qt::WindowFlags flags) - : QOpenGLWidget(parent, flags) + DesktopVideoDevice::ScreenWindow::ScreenWindow(const QSurfaceFormat& fmt, QOpenGLContext* glShareContext) + // + // The share context goes in here, at construction, because that is the + // only point where sharing can be established -- QOpenGLContext ties + // its share group at create() time. A null share context leaves + // QOpenGLWindow to use Qt's global share context, which is also the + // renderer's group. + // + // PartialUpdateBlit keeps a backing FBO and does not clear before + // paintGL(); see the class comment. + // + : QOpenGLWindow(glShareContext, QOpenGLWindow::PartialUpdateBlit) + , m_glShareContext(glShareContext) { - m_glShareContext = glShareContext; setFormat(fmt); - - // Important: set PartialUpdate, because otherwise - // before every call to paintGL Qt will call glClear(), - // thereby erasing the FBO we just transferred pixels to. - setUpdateBehavior(QOpenGLWidget::PartialUpdate); } - void DesktopVideoDevice::ScreenView::initializeGL() + void DesktopVideoDevice::ScreenWindow::initializeGL() { - QOpenGLWidget::initializeGL(); + QOpenGLWindow::initializeGL(); + + // + // Confirm the sharing actually happened. Everything the presentation + // output does depends on it and nothing else reports it: a mismatch is + // invisible from the outside and shows up only as a black second + // display. + // + // Note this deliberately does NOT call context()->setShareContext(). + // That only takes effect on the *next* create(), and the context is + // already created by the time initializeGL() runs, so it never did + // what it looked like it did -- it only left a stale share request + // behind for a later re-create to fail on. + // + const QOpenGLContext* ours = context(); + const QOpenGLContext* global = QOpenGLContext::globalShareContext(); + const QOpenGLContext* wanted = m_glShareContext ? m_glShareContext : global; - if (m_glShareContext && context() && context()->isValid()) + if (!wanted) + { + cerr << "ERROR: DesktopVideoDevice::ScreenWindow: no context to share with;" + << " Qt::AA_ShareOpenGLContexts must be set before the QApplication is created." << endl; + } + else if (ours && ours->shareGroup() != wanted->shareGroup()) { - context()->setShareContext(m_glShareContext); + cerr << "ERROR: DesktopVideoDevice::ScreenWindow: GL context did not join the renderer's share group" + << " (ours=" << static_cast(ours->shareGroup()) + << " wanted=" << static_cast(wanted->shareGroup()) + << "); the renderer's textures cannot be reached from this context, so this" + << " presentation output would render black." << endl; } } - void DesktopVideoDevice::ScreenView::paintGL() + void DesktopVideoDevice::ScreenWindow::paintGL() { - // This method is explicitely empty because this widget's FBO is + // This method is explicitely empty because this window's FBO is // written to by the transfer/transfer2() method } + DesktopVideoDevice::ScreenView::ScreenView(const QSurfaceFormat& fmt, QWidget* parent, QOpenGLContext* glShareContext, + Qt::WindowFlags flags) + : QWidget(parent, flags) + { + m_glWindow = new ScreenWindow(fmt, glShareContext); + + // + // Embed the native GL window in the widget tree, exactly as GLView + // does for the main view. The container is a plain QWidget, so this + // top-level is not forced onto the OpenGL RHI backend -- which is what + // put a top-level QOpenGLWidget in its own share group. + // + m_container = QWidget::createWindowContainer(m_glWindow, this); + + // + // Realize the platform surface up front: a QOpenGLWindow has no GL + // context until its window surface exists, and the device primes the + // context (makeCurrent) during open() before this is ever shown. + // + m_glWindow->create(); + + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(m_container); + } + //---------------------------------------------------------------------- bool DesktopVideoDevice::isOpen() const { return m_view != 0; } diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index 80e7d3829..4f9b585b2 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -17,7 +17,7 @@ #include #include -#include +#include #include #include @@ -55,22 +55,70 @@ namespace Rv class DesktopVideoDevice : public TwkGLF::GLBindableVideoDevice { public: - class ScreenView : public QOpenGLWidget + // + // The GL surface the presentation output composites into. + // + // This is a QOpenGLWindow rather than a QOpenGLWidget on purpose. The + // whole transfer() design depends on this context sharing with the + // renderer's: transfer() wraps the renderer's output FBO colour + // texture in a local FBO (see cloneForSource), and a texture is only + // visible across contexts in the same share group. + // + // A *top-level* QOpenGLWidget does not give us that. In Qt 6 it is + // composited through its own top-level window's RHI backing store and + // takes that window's GL context as its share parent, not the + // application's global share context -- so it can land in a private + // share group, and the renderer's textures then do not exist as far as + // it is concerned (glIsTexture() false for a live texture), every + // transfer() is refused and the second display stays black. Whether it + // happened to land in the right group varied run to run, which is what + // made the black presentation output intermittent. + // + // QOpenGLWindow takes the context to share with as a constructor + // argument, before the context is created -- the only point at which + // sharing can be established. This mirrors GLView/GLWindow, which is + // the main view and demonstrably sits in the renderer's group. + // + // PartialUpdateBlit, not the default NoPartialUpdate: it keeps a + // backing FBO (so QTGLVideoDevice::fboID() is non-zero, which + // transfer() requires) and does not clear before paintGL(), which + // would erase the pixels transfer() just blitted in. + // + class ScreenWindow : public QOpenGLWindow + { + public: + ScreenWindow(const QSurfaceFormat& fmt, QOpenGLContext* glShareContext); + + void initializeGL() override; + void paintGL() override; + + private: + QOpenGLContext* m_glShareContext = nullptr; + }; + + // + // Plain QWidget container holding the ScreenWindow, so the device can + // keep driving the output through the QWidget API it already uses + // (move/setGeometry/setWindowState/show/fullscreen on a given screen). + // Same arrangement as GLView around GLWindow. + // + class ScreenView : public QWidget { public: // // glShareContext is the control view's GL context to share with // (so blits/FBOs are usable across the two surfaces). It comes // from QTGLVideoDevice::glShareContext() and is backing-agnostic: - // the control view may be a QOpenGLWidget or a QOpenGLWindow. + // the control view may be a QOpenGLWidget or a QOpenGLWindow. A + // null share context falls back to Qt's global share context. // ScreenView(const QSurfaceFormat& fmt, QWidget* parent, QOpenGLContext* glShareContext, Qt::WindowFlags flags); - void initializeGL() override; - void paintGL() override; + ScreenWindow* glWindow() const { return m_glWindow; } private: - QOpenGLContext* m_glShareContext = nullptr; + ScreenWindow* m_glWindow = nullptr; + QWidget* m_container = nullptr; }; public: @@ -208,9 +256,9 @@ namespace Rv // From QTGLVideoDevice - void setViewWidget(QOpenGLWidget*); + void setViewWidget(ScreenView*); - QOpenGLWidget* viewWidget() const { return m_view; } + ScreenView* viewWidget() const { return m_view; } virtual void makeCurrent() const; @@ -275,9 +323,13 @@ namespace Rv protected: const QTGLVideoDevice* m_share; const TwkGLF::GLVideoDevice* m_viewDevice; - QOpenGLWidget* m_view; + ScreenView* m_view; DesktopStereoMode m_stereoMode; mutable FBOMap m_fboMap; + + // Latches the "the surface has no backing FBO" report, so a present + // path that is stalled for many frames says so once. + mutable bool m_transferStalled{false}; TwkGLF::GLState* m_glGlobalState; DesktopVideoFormats m_videoFormats; DesktopDataFormats m_dataFormats; From 90b83447f983ce0614a9be03be689e21d5301f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:21 -0400 Subject: [PATCH 29/48] fix(gl): verify presentation FBO clones before blitting from them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FBOs are not shared between contexts but textures are, so transfer() and transfer2() keep a per-context clone of the renderer's output FBO wrapped around its colour texture. The cache was keyed on the source pointer and trusted forever. Two ways that goes wrong: - The renderer deletes and reallocates those FBOs (ImageRenderer::Device::clearFBOs, ImageFBOManager::newImageFBO), so the same address comes back as a different FBO. - The borrowed texture can be dead by the time it is attached, which leaves the clone incomplete without any call failing outright. Nothing ever invalidated a cached incomplete clone, so a single transient error turned into a permanently black output that every later frame blitted from. Route both paths through cloneForSource(), which re-verifies a cached clone against the source's texture, target and size, discards an incomplete clone rather than caching it, and returns null so the caller skips the frame. On the first refusal it reports whether the texture is gone or merely in another share group, which are otherwise indistinguishable from the outside. GLFBO::isComplete() is the non-throwing completeness test that needs, for callers assembling an FBO from attachments they do not own. releaseFBOClones() drops the cache with a context current, and clearCaches()/unbind() now go through it. TWK_GLDEBUG after glBlitFramebuffer so an incomplete framebuffer is attributed to the blit instead of to the next frame's makeCurrent(). Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 176 +++++++++++++++--- .../RvCommon/RvCommon/DesktopVideoDevice.h | 35 +++- src/lib/graphics/TwkGLF/GLFBO.cpp | 20 ++ src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h | 9 + 4 files changed, 210 insertions(+), 30 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 62610798f..ca4e82a37 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -144,23 +144,15 @@ namespace Rv m_viewDevice->makeCurrent(); // calls screenview's makeCurrent, sets the // font current, etc etc. - // Next, because we can't blit from FBOs belonging to different - // contexts, check to see if we already have an clone of the source FBO - // associated to the view's context. - GLFBO* svSourceFbo = m_fboMap[sourceFbo]; + // Because we can't blit from FBOs belonging to different contexts, use + // a clone of the source FBO associated to the view's context. + // cloneForSource() creates and caches it on first use, reusing the + // source FBO's color attachment because color attachments *can* be + // shared for the blit operation. + GLFBO* svSourceFbo = cloneForSource(sourceFbo); if (!svSourceFbo) { - // We don't yet have a clone of the source FBO living in the context - // of the ScreenView. Therefore, we now create this new clone FBO - // with the dimensions/format as the source. - // Afterwards, associate the source FBO's color attachment to the - // clone's color attachment, because color attachments *can* be - // shared for the blit operation - svSourceFbo = new GLFBO(sourceFbo->width(), sourceFbo->height(), sourceFbo->primaryColorFormat()); - - svSourceFbo->attachColorTexture(sourceFbo->colorTarget(0), - sourceFbo->colorID(0)); // PB: What's colorId ? - m_fboMap[sourceFbo] = svSourceFbo; + return; } // Finally, create a temporary GLFBO with the ID of the ScreenView's FBO @@ -412,21 +404,12 @@ namespace Rv const float w = m_viewDevice->width(); const float h = m_viewDevice->height(); - GLFBO* local_fbo1 = m_fboMap[fbo1]; - GLFBO* local_fbo2 = m_fboMap[fbo2]; - - if (!local_fbo1) - { - local_fbo1 = new GLFBO(fbo1->width(), fbo1->height(), fbo1->primaryColorFormat()); - local_fbo1->attachColorTexture(fbo1->colorTarget(0), fbo1->colorID(0)); - m_fboMap[fbo1] = local_fbo1; - } + GLFBO* local_fbo1 = cloneForSource(fbo1); + GLFBO* local_fbo2 = cloneForSource(fbo2); - if (!local_fbo2) + if (!local_fbo1 || !local_fbo2) { - local_fbo2 = new GLFBO(fbo2->width(), fbo2->height(), fbo2->primaryColorFormat()); - local_fbo2->attachColorTexture(fbo2->colorTarget(0), fbo2->colorID(0)); - m_fboMap[fbo2] = local_fbo2; + return; } const GLFBO *leftFBO = local_fbo1, *rightFBO = local_fbo2; @@ -641,10 +624,143 @@ namespace Rv bool DesktopVideoDevice::isDualStereo() const { return isStereo(); } - void DesktopVideoDevice::unbind() const + TwkGLF::GLFBO* DesktopVideoDevice::cloneForSource(const GLFBO* sourceFbo) const + { + if (!sourceFbo) + { + return nullptr; + } + + FBOMap::iterator i = m_fboMap.find(sourceFbo); + + if (i != m_fboMap.end()) + { + GLFBO* cached = i->second; + + // + // m_fboMap is keyed on the raw source pointer, and the renderer + // deletes and reallocates those (ImageRenderer::Device::clearFBOs + // / ImageFBOManager::newImageFBO), so the same address can come + // back as a different FBO. Re-verify the clone still mirrors the + // texture and size it was built from before trusting it. + // + const bool stillMatches = cached && cached->colorID(0) == sourceFbo->colorID(0) + && cached->colorTarget(0) == sourceFbo->colorTarget(0) && cached->width() == sourceFbo->width() + && cached->height() == sourceFbo->height(); + + if (stillMatches) + { + return cached; + } + + delete cached; + m_fboMap.erase(i); + } + + // + // Build the clone in this device's context around the source's colour + // texture. The texture belongs to the control context; if it has been + // destroyed since, attaching it leaves the FBO incomplete rather than + // failing loudly, so check before caching. Caching an incomplete clone + // is what turns a transient error into a permanently black output -- + // nothing ever invalidates it and every later frame blits from it. + // + GLFBO* clone = new GLFBO(sourceFbo->width(), sourceFbo->height(), sourceFbo->primaryColorFormat()); + + clone->attachColorTexture(sourceFbo->colorTarget(0), sourceFbo->colorID(0)); + + if (!clone->isComplete()) + { + // + // Latched per source texture: this is called every frame, and the + // interesting event is the transition, not the repetition. + // + const GLuint badTex = sourceFbo->colorID(0); + if (m_reportedBadSourceTex != badTex) + { + m_reportedBadSourceTex = badTex; + + cerr << "WARNING: DesktopVideoDevice: '" << name() << "' could not mirror the renderer's " << sourceFbo->width() << "x" + << sourceFbo->height() << " FBO (source colour texture " << badTex << " is not usable in this context); skipping" + << " until it changes." << endl; + + // + // Which of the two possible causes this is: the texture does + // not exist any more (deleted, or never created because no + // context was current when the renderer made it), or it exists + // but in a context that does not share with ours. glIsTexture + // answers the first; the share-group pointers answer the + // second. Without this the two are indistinguishable from the + // outside and each costs a build-and-repro cycle to guess at. + // + const QOpenGLContext* cur = QOpenGLContext::currentContext(); + const QTGLVideoDevice* share = shareDevice(); + const QOpenGLContext* shareCtx = share ? share->glShareContext() : nullptr; + + cerr << "WARNING: DesktopVideoDevice: glIsTexture(" << badTex << ")=" << (glIsTexture(badTex) ? "true" : "false") + << " srcTarget=0x" << hex << sourceFbo->colorTarget(0) << " srcFormat=0x" << sourceFbo->primaryColorFormat() << dec + << " (GL_TEXTURE_2D=0x" << hex << GL_TEXTURE_2D << " GL_TEXTURE_RECTANGLE_ARB=0x" << GL_TEXTURE_RECTANGLE_ARB << dec + << ")" << endl; + cerr << "WARNING: DesktopVideoDevice: currentContext=" << static_cast(cur) + << " shareGroup=" << static_cast(cur ? cur->shareGroup() : nullptr) + << " rendererShareContext=" << static_cast(shareCtx) + << " shareGroup=" << static_cast(shareCtx ? shareCtx->shareGroup() : nullptr) + << " globalShare=" << static_cast(QOpenGLContext::globalShareContext()) << endl; + } + + delete clone; + return nullptr; + } + + m_reportedBadSourceTex = 0; + + m_fboMap[sourceFbo] = clone; + + return clone; + } + + void DesktopVideoDevice::releaseFBOClones() const { ScopedLock lock(m_mutex); + if (m_fboMap.empty()) + { + return; + } + + // + // ~GLFBO deletes the FBO handle, so a context has to be current or + // the delete is a no-op that also poisons glGetError() for the next + // unrelated call site. Make this device's view context current the + // same way transfer() does. + // + // If the view is already gone there is nothing to make current and + // the FBO names died with that context anyway; free the wrappers so + // the memory is not leaked and let the (harmless) GL no-ops happen. + // Phase-ordered callers never land here -- close() calls this before + // destroying the view -- so it is a backstop, not a normal path. + // + // + // Through the virtual, so each subclass binds its own surface -- and + // so the "surface not ready yet" guard in there applies. If it cannot + // make a context current there is nothing cached to free either: the + // clones are only ever created by a transfer() that got that far. + // + makeCurrent(); + + // + // Deleting these without a context current frees nothing -- the + // handles leak and ~GLFBO reports an error nowhere near the cause -- + // so say it rather than leaving it to be inferred from a stray + // GL_ERROR later. By construction this should not happen: close() + // calls us while the view is alive. + // + if (!QOpenGLContext::currentContext()) + { + cerr << "WARNING: DesktopVideoDevice: '" << name() << "' released " << m_fboMap.size() + << " FBO clone(s) WITHOUT a current context -- the GL handles leaked." << endl; + } + for (FBOMap::iterator i = m_fboMap.begin(); i != m_fboMap.end(); ++i) { delete i->second; @@ -653,6 +769,8 @@ namespace Rv m_fboMap.clear(); } + void DesktopVideoDevice::unbind() const { releaseFBOClones(); } + size_t DesktopVideoDevice::numVideoFormats() const { return m_videoFormats.size(); } DesktopVideoDevice::VideoFormat DesktopVideoDevice::videoFormatAtIndex(size_t i) const diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index 4f9b585b2..d2be1b378 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -225,7 +225,36 @@ namespace Rv virtual void unbind() const; - virtual void clearCaches() const {} + virtual void clearCaches() const { releaseFBOClones(); } + + // + // Delete the per-context FBO clones in m_fboMap. + // + // These alias textures owned by the renderer's control context, so + // they must be destroyed while *this* device's view context is still + // alive and current -- deleting them afterwards issues GL calls with + // no context current. Callers that are about to tear the view down + // (close()) must therefore call this first. Safe to call repeatedly + // and safe to call when nothing was ever cached. + // + void releaseFBOClones() const; + + // + // Return this context's clone of a source FBO owned by the renderer's + // control context, creating and caching it on first use. + // + // FBOs are not shared between contexts but textures are, so the clone + // wraps the source's colour texture. Two things make that fragile and + // are handled here: the cache is keyed on the source pointer, which + // the renderer frees and reallocates (so a cached clone is re-verified + // against the source it is meant to mirror), and the borrowed texture + // name can be dead by the time we attach it (so an incomplete clone is + // discarded instead of cached and blitted from every frame). + // + // Returns null if no usable clone could be built; callers must skip + // the transfer for this frame. + // + TwkGLF::GLFBO* cloneForSource(const TwkGLF::GLFBO* sourceFbo) const; // // Configurations @@ -327,6 +356,10 @@ namespace Rv DesktopStereoMode m_stereoMode; mutable FBOMap m_fboMap; + // Source colour texture last reported as unusable by cloneForSource(), + // so the report fires on the transition rather than every frame. + mutable GLuint m_reportedBadSourceTex{0}; + // Latches the "the surface has no backing FBO" report, so a present // path that is stalled for many frames says so once. mutable bool m_transferStalled{false}; diff --git a/src/lib/graphics/TwkGLF/GLFBO.cpp b/src/lib/graphics/TwkGLF/GLFBO.cpp index 942cc4d65..867a56656 100644 --- a/src/lib/graphics/TwkGLF/GLFBO.cpp +++ b/src/lib/graphics/TwkGLF/GLFBO.cpp @@ -526,6 +526,19 @@ namespace TwkGLF } } + bool GLFBO::isComplete() const + { + // + // GLFBO must be bound for glCheckFramebufferStatus() + // + bind(); + + const GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT); + TWK_GLDEBUG; + + return status == GL_FRAMEBUFFER_COMPLETE_EXT; + } + void GLFBO::bindColorTexture(size_t i) const { assert(i < m_attachments.size()); @@ -583,6 +596,13 @@ namespace TwkGLF destinationGLFBO->bind(GL_DRAW_FRAMEBUFFER_EXT); glBlitFramebufferEXT(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); + // + // Without this, an incomplete read/draw framebuffer here raises + // GL_INVALID_FRAMEBUFFER_OPERATION that nothing pops until the next + // frame's makeCurrent(), which reports it against an unrelated call + // site one frame late. Attribute it where it happens. + // + TWK_GLDEBUG; HOP_CALL(glFinish();) } diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h b/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h index 55b2c9c18..0e513d3ee 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h @@ -239,6 +239,15 @@ namespace TwkGLF void check() const; + // + // Non-throwing completeness test, for callers that assemble an FBO + // from attachments they do not own and have to be able to reject the + // result. An FBO built around a foreign texture name can come out + // incomplete without any call failing outright, and blitting from it + // then fails every frame far from the cause. + // + bool isComplete() const; + // // Copy uses glBlitFramebuffer to do the work. The entire image // is copyed from the window of one to the other (so if aspect From 44c28ce5c7f8b5a3765b10a6792dd68ef0168de2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:25 -0400 Subject: [PATCH 30/48] fix(gl): keep a context current through GL teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quitting out of presentation mode logged a long tail of GL_INVALID_OPERATION starting in ~GLFBO, and left the presentation output black for the rest of the session. All of it is GL teardown running with no context current: once none is, glGetError() keeps returning that same error, so one lost context is worth a great many messages, none of them near the cause. - QTGLVideoDevice::makeCurrent() did nothing at all when the platform surface was gone but the QOpenGLContext was not -- which is the state Qt leaves the device in while shutting down, since the native window is destroyed before the C++ object. Keep a QOffscreenSurface, created while the window is still healthy, and bind the context to that instead: GL deletion needs a current context, not a visible one. The handle() test also belongs in the outer condition rather than nested inside it, where a live window with a dead surface fell through every branch silently. The genuinely unreachable case now says so once. - DesktopVideoDevice::close() deleted the view before m_viewDevice. ~GLVideoDevice deletes the device's GL text context, and ~GLTextContext deletes the FTGL fonts, which delete GL textures -- all of it against a context the view had just taken with it, so the textures leaked on every presentation-mode toggle, not only at exit. Release the FBO clones first (that needs the view's context), then the device, then the view, then hand the main view's context back. - VulkanDesktopVideoDevice::close() deliberately does not chain to the base, so it has to release the FBO clones itself -- before setViewDevice(nullptr) takes away the device it needs to make a context current. - RvApplication dropped the session's output device only after close()ing it. Unbind first: ImageRenderer::setOutputDevice() calls unbind() on the outgoing device, and it has to run while that context is alive. It then restores the main view's context, because close() leaves nothing current and DesktopVideoDevice's own restore goes through its share device, which is null whenever the main view is Vulkan. That is where the bogus "Could not retrieve OpenGL version. Make sure you have installed the Nvidia drivers." came from: queryGLIntoContainer() reading GL_VERSION with no context. - ImageRenderer::setOutputDevice() falls back to the control device's context. m_outputDevice.glDevice is a dynamic_cast to GLVideoDevice and is null for every GLBindableVideoDevice output -- presentation, AJA, NDI -- because the two are siblings, not base and derived. The control context is the right fallback: it owns the FBOs being cleared, and the rest of the function already depends on it further down. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 36 ++++++++- src/lib/app/RvCommon/QTGLVideoDevice.cpp | 81 ++++++++++++++++--- src/lib/app/RvCommon/RvApplication.cpp | 46 ++++++++++- .../app/RvCommon/RvCommon/QTGLVideoDevice.h | 13 +++ .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 6 ++ src/lib/ip/IPCore/ImageRenderer.cpp | 36 +++++++++ 6 files changed, 203 insertions(+), 15 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index ca4e82a37..e6ce5a2b6 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -251,12 +251,46 @@ namespace Rv void DesktopVideoDevice::close() { - delete m_view; + // + // Before the view (and its GL context) goes away: the cached FBO + // clones can only be deleted while that context is alive. + // + releaseFBOClones(); + + // + // The device before the view that owns its context. + // + // ~GLVideoDevice deletes this device's GL text context, and + // ~GLTextContext deletes the FTGL fonts, which delete GL textures. + // Deleting the view first destroys the window and the context those + // textures live in, so those deletes reached nothing and the textures + // leaked -- every time presentation mode was switched off, not only + // at exit. releaseFBOClones() above has just made this device's + // context current, so in this order they land. + // delete m_viewDevice; + delete m_view; delete m_translator; m_view = 0; m_viewDevice = 0; m_translator = 0; + + // + // Hand the main view's context back before leaving. + // + // releaseFBOClones() above made *this* device's context current, and + // the deletes just destroyed it, so right now nothing is current at + // all. Whatever tears down GL objects next -- the renderer's own FBOs, + // later in this same shutdown -- would then run against no context, + // which is futile and reports errors nowhere near the cause. + // + // This is the restore that transfer() documents it relies on its + // callers to perform, done here for the teardown path. + // + if (m_share) + { + m_share->makeCurrent(); + } } void DesktopVideoDevice::setViewWidget(ScreenView* widget) diff --git a/src/lib/app/RvCommon/QTGLVideoDevice.cpp b/src/lib/app/RvCommon/QTGLVideoDevice.cpp index 4bb586b82..db0db2732 100644 --- a/src/lib/app/RvCommon/QTGLVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTGLVideoDevice.cpp @@ -19,8 +19,11 @@ #include #include +#include #include +#include + namespace Rv { using namespace std; @@ -70,7 +73,11 @@ namespace Rv assert(view); } - QTGLVideoDevice::~QTGLVideoDevice() { delete m_translator; } + QTGLVideoDevice::~QTGLVideoDevice() + { + delete m_translator; + delete m_teardownSurface; + } void QTGLVideoDevice::setWidget(QOpenGLWidget* widget) { @@ -104,20 +111,35 @@ namespace Rv void QTGLVideoDevice::makeCurrent() const { - if (m_window) + // + // The handle() test belongs in this condition, not nested inside it: + // QOpenGLWindow creates its GL context lazily on the first + // makeCurrent() and only if the platform window (surface) exists, so a + // live m_window with a dead surface can make nothing current. Nested, + // that case fell through every branch and returned silently. + // + if (m_window && m_window->handle()) { - // QOpenGLWindow creates its GL context lazily on the first - // makeCurrent(), provided the platform window (surface) exists. - if (m_window->handle()) + m_window->makeCurrent(); + TWK_GLDEBUG; + + // + // Build the teardown surface now, while there is a live context to + // copy a compatible format from. See m_teardownSurface. + // + if (!m_teardownSurface && m_window->context()) { - m_window->makeCurrent(); - TWK_GLDEBUG; + m_teardownSurface = new QOffscreenSurface(); + m_teardownSurface->setFormat(m_window->context()->format()); + m_teardownSurface->create(); + } - GLint surfaceFBO = m_window->defaultFramebufferObject(); - if (surfaceFBO != 0) - glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, surfaceFBO); - TWK_GLDEBUG; + GLint surfaceFBO = m_window->defaultFramebufferObject(); + if (surfaceFBO != 0) + { + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, surfaceFBO); } + TWK_GLDEBUG; } else if (m_view && m_view->context() && m_view->context()->isValid()) { @@ -126,9 +148,46 @@ namespace Rv GLint widgetFBO = m_view->defaultFramebufferObject(); if (widgetFBO != 0) + { glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, widgetFBO); + } TWK_GLDEBUG; } + else if (m_window && m_window->context() && m_teardownSurface && m_teardownSurface->isValid() + && m_window->context()->makeCurrent(m_teardownSurface)) + { + // + // The platform surface is gone but the context is not, so bind it + // to the offscreen surface instead. GL object deletion needs a + // current context, not a visible one, so this lets the teardown + // actually free what it is trying to free. + // + TWK_GLDEBUG; + } + else + { + // + // There is no surface left to make current. m_window is a + // QPointer, so it self-nulls once the QOpenGLWindow is destroyed, + // and m_view is null in the native-window port -- which means this + // function can quietly do nothing while its caller carries on + // believing it has a context. That is how GL teardown ends up + // running with no context at all. Say so once instead. + // + static bool reported = false; + if (!reported) + { + reported = true; + cerr << "ERROR: QTGLVideoDevice::makeCurrent: '" << name() << "' cannot make a context current (window=" + << (!m_window ? "destroyed" : (m_window->handle() ? "alive" : "no surface")) << " widget=" << (m_view ? "alive" : "null") + << " currentContext=" << (QOpenGLContext::currentContext() ? "yes" : "none") + // Whether the context outlives the surface decides if a + // QOffscreenSurface could be used to make it current for + // teardown, the way QTVulkanVideoDevice already does. + << " ownContext=" << (m_window && m_window->context() ? "alive" : "null") + << "); the caller's GL work has no current context" << endl; + } + } if (!isWorkerDevice()) GLVideoDevice::makeCurrent(); diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 82b60d1ba..38fbe8f27 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1801,16 +1801,56 @@ namespace Rv else { const VideoDevice* d = session->outputVideoDevice(); + const bool separateOutput = d && d != session->controlVideoDevice(); - if (d != session->controlVideoDevice()) + // + // Unbind before closing, never the other way round. close() + // destroys the device's view and with it the GL context that owns + // every FBO the renderer cloned for that device. Dropping the + // session's reference first means ImageRenderer::setOutputDevice() + // -- which calls unbind() on the outgoing device -- still runs + // while that context is alive. Closing first left it deleting GL + // objects with no context current, which is undiagnosable from the + // outside: it shows up as GL_INVALID_OPERATION at innocent call + // sites and leaves a permanently incomplete FBO cached in the + // device, i.e. a black presentation output for the rest of the + // session. + // + session->setOutputVideoDevice(session->controlVideoDevice()); + + if (separateOutput) { const_cast(d)->close(); #ifdef PLATFORM_DARWIN // rvDoc->setDoubleBuffer(true); #endif - } - session->setOutputVideoDevice(session->controlVideoDevice()); + // + // Put the main view's context back, whatever backend it is. + // + // close() destroyed the presentation device's view and its + // context, so nothing is current on return. DesktopVideoDevice + // tries to restore from its share device, but that is a + // QTGLVideoDevice and is null whenever the main view is + // Vulkan -- so on the Vulkan path nothing was made current at + // all, and the next code to touch GL did so against no + // context. That is not a teardown path, so GLContextScope does + // not cover it: it surfaced as ImageRenderer's + // queryGLIntoContainer() reading an empty GL_VERSION (which is + // what prints the bogus "Could not retrieve OpenGL version. + // Make sure you have installed the Nvidia drivers.") and as a + // no-context report on the next entry into this function. + // + // Do it here rather than inside close(): the session knows its + // control device, both backends derive from GLVideoDevice, and + // this is the moment the device is authoritative. + // + if (const TwkGLF::GLVideoDevice* mainView = + dynamic_cast(session->controlVideoDevice())) + { + mainView->makeCurrent(); + } + } #if 0 if (opts.vsync && !rvDoc->vsyncDisabled()) diff --git a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h index 6f2f20a8f..acd081901 100644 --- a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -136,6 +137,18 @@ namespace Rv // it, redraw() would call update() on a freed QOpenGLWindow. // QPointer m_window; + // + // A surface to fall back on when m_window's platform surface is gone + // but its QOpenGLContext is not, which is the state Qt leaves this + // device in while shutting down: the native window is destroyed before + // the C++ object, so makeCurrent() has nothing to bind and every GL + // deletion in the teardown below silently does nothing. + // + // Created while the window is still healthy, never during shutdown -- + // a QOffscreenSurface needs the platform plugin to hand out a surface, + // which is not something to ask for on the way out. + // + mutable QOffscreenSurface* m_teardownSurface{nullptr}; QTTranslator* m_translator; }; diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 4e29c7350..b8aebac39 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -128,6 +128,12 @@ namespace Rv // Vulkan/interop resources. Chaining to DesktopVideoDevice::close() // here would delete the device a second time. // + // Because we do not chain, the base's FBO-clone teardown has to be + // invoked explicitly -- and it has to run before setViewDevice(nullptr), + // since it needs that device to make the GL context current. + // + releaseFBOClones(); + setViewDevice(nullptr); delete m_vulkanView; diff --git a/src/lib/ip/IPCore/ImageRenderer.cpp b/src/lib/ip/IPCore/ImageRenderer.cpp index 93ebb7d5e..a26df4935 100644 --- a/src/lib/ip/IPCore/ImageRenderer.cpp +++ b/src/lib/ip/IPCore/ImageRenderer.cpp @@ -1344,8 +1344,44 @@ namespace IPCore // unique device pair (controller and output). // + // + // m_outputDevice.glDevice is a dynamic_cast to GLVideoDevice, and that + // is null for every GLBindableVideoDevice output -- presentation, AJA, + // NDI -- because GLVideoDevice and GLBindableVideoDevice are siblings + // (both derive TwkApp::VideoDevice directly), not base and derived. + // With no fallback, everything below here tears down FBOs, fences and + // textures with no context current at all. That is what makes quitting + // out of presentation mode log a long tail of GL_INVALID_OPERATION + // starting in ~GLFBO: once no context is current, glGetError() keeps + // returning that same error, so a single lost context is worth a great + // many messages. + // + // The control device's context is the right one to fall back to. It + // owns the FBOs cleared below -- for a bindable output the renderer + // draws in the control context on purpose, see the comment above -- + // and it is what the rest of this function already relies on further + // down, where defaultFBO() happens to make it current as a side + // effect. + // if (m_outputDevice.glDevice) + { m_outputDevice.glDevice->makeCurrent(); + } + else if (m_controlDevice.glDevice) + { + m_controlDevice.glDevice->makeCurrent(); + } + else + { + static bool reported = false; + if (!reported) + { + reported = true; + cerr << "ERROR: ImageRenderer::setOutputDevice: neither the output nor the control device is a GLVideoDevice; " + "the GL objects released below have no current context" + << endl; + } + } TWK_GLDEBUG; if (d) From 5f7b1947cb50ffce755a4c2eb004b6da9a57e89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:29 -0400 Subject: [PATCH 31/48] fix(rv): rebuild presentation devices onto the live main-view backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DesktopVideoModule::rebuildDevices() re-derived the GL-vs-Vulkan decision from shouldUseVulkanPresentation(), which reads the persisted display-depth preference. That is the requested intent, not the backend the main view is actually running: a 10-bit request that fell back to GL at runtime keeps its 10-bit intent on purpose. A presentation output built on the opposite backend to the viewport is a black second display. Pass the backend down from RvDocument instead. It is the only place that knows which widget actually exists now, and it calls rebuildDesktopVideoDevices() from each of the three swap paths anyway. shouldUseVulkanPresentation() stays for the initial build, when there is no main view to ask, and now says so. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 10 ++++-- src/lib/app/RvCommon/DesktopVideoModule.cpp | 14 ++++---- src/lib/app/RvCommon/RvApplication.cpp | 4 +-- .../RvCommon/RvCommon/DesktopVideoDevice.h | 32 ++++++++++++++----- .../RvCommon/RvCommon/DesktopVideoModule.h | 13 +++++--- src/lib/app/RvCommon/RvCommon/RvApplication.h | 7 +++- src/lib/app/RvCommon/RvDocument.cpp | 6 ++-- 7 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index e6ce5a2b6..1b373ad01 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -1287,11 +1287,17 @@ namespace Rv } std::vector DesktopVideoDevice::createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice) + { + return createDesktopVideoDevices(module, shareDevice, shouldUseVulkanPresentation()); + } + + std::vector DesktopVideoDevice::createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice, + bool useVulkan) { std::vector devices; -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - const bool useVulkan = shouldUseVulkanPresentation(); +#if !defined(PLATFORM_LINUX) && !defined(PLATFORM_WINDOWS) + (void)useVulkan; #endif const auto screens = QGuiApplication::screens(); diff --git a/src/lib/app/RvCommon/DesktopVideoModule.cpp b/src/lib/app/RvCommon/DesktopVideoModule.cpp index 92ca6fa9f..d545bda66 100644 --- a/src/lib/app/RvCommon/DesktopVideoModule.cpp +++ b/src/lib/app/RvCommon/DesktopVideoModule.cpp @@ -42,14 +42,16 @@ namespace Rv DesktopVideoModule::~DesktopVideoModule() {} - bool DesktopVideoModule::rebuildDevices(const QTGLVideoDevice* shareDevice) + bool DesktopVideoModule::rebuildDevices(const QTGLVideoDevice* shareDevice, bool targetVulkan) { // - // Decide the target backend once (the probe behind it is memoized) and - // compare it to the backend the current devices were built with. On - // platforms without Vulkan both are false, so this is always a no-op. + // targetVulkan is the live main-view backend, decided by the caller. + // Compare it to the backend the current devices were built with. On + // platforms without Vulkan it is always false, so this is a no-op. // - const bool targetVulkan = DesktopVideoDevice::shouldUseVulkanPresentation(); +#if !defined(PLATFORM_LINUX) && !defined(PLATFORM_WINDOWS) + targetVulkan = false; +#endif bool currentVulkan = false; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) @@ -89,7 +91,7 @@ namespace Rv } m_devices.clear(); - m_devices = DesktopVideoDevice::createDesktopVideoDevices(this, shareDevice); + m_devices = DesktopVideoDevice::createDesktopVideoDevices(this, shareDevice, targetVulkan); return true; } diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 38fbe8f27..c91bdfc1c 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1988,7 +1988,7 @@ namespace Rv return options.toUtf8().constData(); } - void RvApplication::rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice) + void RvApplication::rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice, bool mainViewIsVulkan) { if (!m_desktopModule) return; @@ -2011,7 +2011,7 @@ namespace Rv // share-device rebind below still runs, so a main-view swap that keeps // the same backend is honored. // - const bool rebuilt = m_desktopModule->rebuildDevices(shareDevice); + const bool rebuilt = m_desktopModule->rebuildDevices(shareDevice, mainViewIsVulkan); // // Re-bind the controller's current main-view device as the share diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index d2be1b378..bd6a0b6c8 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -319,16 +319,32 @@ namespace Rv static std::vector createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice); // - // Effective presentation-backend decision, shared by - // createDesktopVideoDevices (the initial build) and - // DesktopVideoModule::rebuildDevices (live re-evaluation) so both - // agree on one GL-vs-Vulkan rule. True when the second-display output - // should be delivered through a Vulkan swapchain -- a 10-bit request - // that this machine's Vulkan can actually present -- false for the - // OpenGL ScreenView path. Always false on macOS. + // As above, but with the backend decided by the caller rather than + // re-derived from the persisted display-depth preference. Use this + // whenever the main view is already live: its backend is the ground + // truth, and the preference can disagree with it (see + // shouldUseVulkanPresentation). + // + static std::vector createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice, + bool useVulkan); + + // + // Effective presentation-backend decision for the *initial* build, + // when there is no main view yet to ask. True when the second-display + // output should be delivered through a Vulkan swapchain -- a 10-bit + // request that this machine's Vulkan can actually present -- false for + // the OpenGL ScreenView path. Always false on macOS. // // The underlying VulkanView::supports10BitPresentation() probe is - // memoized, so this is cheap to call on every rebuild request. + // memoized, so this is cheap to call. + // + // NOTE: this reads the persisted intent in Options, which is NOT the + // same thing as the backend the main view is actually running. The two + // diverge (a 10-bit request that fell back to GL at runtime keeps its + // 10-bit intent on purpose), and a presentation output built on the + // other backend than the viewport is a black second display. Once a + // view exists, pass its backend explicitly instead -- see + // RvApplication::rebuildDesktopVideoDevices. // static bool shouldUseVulkanPresentation(); diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h index db5dc5b82..f8b24aea4 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h @@ -30,13 +30,18 @@ namespace Rv virtual ~DesktopVideoModule(); // - // Re-evaluate the presentation backend (GL ScreenView vs Vulkan - // swapchain) against the current display-depth preference and rebuild - // the per-screen devices to match, using shareDevice as the new GL + // Rebuild the per-screen presentation devices onto targetVulkan (GL + // ScreenView vs Vulkan swapchain), using shareDevice as the new GL // share device. This is the post-startup analogue of the // constructor's one-time createDesktopVideoDevices call, needed // because the backend decision is no longer frozen at launch. // + // targetVulkan is supplied by the caller rather than re-derived here: + // it must be the backend the main view is *actually* running, which + // the persisted display-depth preference does not reliably reflect + // (see DesktopVideoDevice::shouldUseVulkanPresentation). A presentation + // output on the opposite backend to the viewport is a black display. + // // A cleanly open device is closed -- releasing its Vulkan swapchain or // GL ScreenView -- before it is destroyed. To avoid a needless // teardown and a transient on the second display, this is a no-op when @@ -50,7 +55,7 @@ namespace Rv // because the devices destroyed here may be referenced as the session // output. // - bool rebuildDevices(const QTGLVideoDevice* shareDevice); + bool rebuildDevices(const QTGLVideoDevice* shareDevice, bool targetVulkan); virtual std::string name() const; virtual void open(); diff --git a/src/lib/app/RvCommon/RvCommon/RvApplication.h b/src/lib/app/RvCommon/RvCommon/RvApplication.h index f2ca4daa4..8a47b2c55 100644 --- a/src/lib/app/RvCommon/RvCommon/RvApplication.h +++ b/src/lib/app/RvCommon/RvCommon/RvApplication.h @@ -153,7 +153,12 @@ namespace Rv // no GL device to share. Fixes the frozen presentation bit depth and // the black second display on a backend mismatch. // - void rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice); + // mainViewIsVulkan is the backend the calling document's main view has + // just settled on. It is passed rather than re-derived from the + // display-depth preference because only the caller knows which widget + // actually exists now. + // + void rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice, bool mainViewIsVulkan); DesktopVideoModule* desktopVideoModule() const { return m_desktopModule; } diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 279b0b57c..bb94e90b6 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -1034,7 +1034,7 @@ namespace Rv // selected screen, so the second display follows the main view back to // OpenGL instead of being left mismatched (black). // - RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice()); + RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice(), false); // // Defer the delete. This is reached from a queued callback posted by @@ -1139,7 +1139,7 @@ namespace Rv // ScreenView falls back to the default surface format and // Qt::AA_ShareOpenGLContexts still puts every context in one group. // - RvApp()->rebuildDesktopVideoDevices(nullptr); + RvApp()->rebuildDesktopVideoDevices(nullptr, true); m_vulkanView->videoDevice()->translator().setCurrentModifiers(cur); @@ -1260,7 +1260,7 @@ namespace Rv // share device and the presentation output are still re-bound to the // new GLView. // - RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice()); + RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice(), false); m_glView->videoDevice()->translator().setCurrentModifiers(cur); m_oldGLView = oldGLView; From e61b2801825a64f13771c047a0c70522a94774b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:34 -0400 Subject: [PATCH 32/48] fix(rv): persist the display depth when switching back to 8-bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RvDocument's "OpenGL is already live" branch wrote neither Options nor QSettings, and returns early whenever the GL context already has the requested depth. The 10-bit and Vulkan-live branches above it both write those first. Selecting 8-bit from 10-bit therefore did nothing to the very state other subsystems read back as "the requested display depth", leaving Options claiming 10-bit for the rest of the session. Write the depth before anything below can return early. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvDocument.cpp | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index bb94e90b6..3d4329af5 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -1442,6 +1442,37 @@ namespace Rv return; } #endif + // + // Persist the requested depth before anything below can early-return. + // + // The 10-bit and the Vulkan-live branches above both write Options and + // QSettings first; this branch -- OpenGL already live -- used to write + // neither, and it returns early whenever the GL context already has the + // requested depth. Selecting 8-bit here was therefore a no-op on the + // very state that other subsystems read back as "the requested display + // depth", leaving Options claiming 10-bit for the rest of the session. + // + { + const int bits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 10 : 0); + const int alphaBits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 2 : 0); + + Rv::Options& opts = Options::sharedOptions(); + opts.dispRedBits = bits; + opts.dispGreenBits = bits; + opts.dispBlueBits = bits; + opts.dispAlphaBits = alphaBits; + + { + RV_QSETTINGS; + settings.beginGroup("Display"); + settings.setValue("dispRedBits", bits); + settings.setValue("dispGreenBits", bits); + settings.setValue("dispBlueBits", bits); + settings.setValue("dispAlphaBits", alphaBits); + settings.endGroup(); + } + } + const bool vsync = m_glView->format().swapInterval() == 1; const bool stereo = m_glView->format().stereo(); bool dbl = false; From 2ff282917c03385d082316a651398512e7f0b30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 21 Sep 2026 10:28:39 -0400 Subject: [PATCH 33/48] fix(vulkan): correct presentation sizing on a second display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sizing faults, all of which surface once the output lives on a screen whose devicePixelRatio differs from the one its window was created on. - The swapchain-recreate test compared the caller's requested size against m_vkSwapchainExtent. createSwapchain() takes its extent from capabilities.currentExtent, so it cannot be driven to match a request the surface disagrees with: any caller off by even a pixel recreated the swapchain on every frame, forever and silently, since both surface-format reports are latched. Compare against the surface's current extent instead. - The grow-only shared image sized its headroom from the primary screen. Use this window's own screen: a presentation output lives on a second display, and the primary is both the wrong one and, when it is the smaller of the two, useless as headroom. - The present copy and blit took their destination extent from the shared image, which is sized from the caller's request. A stale devicePixelRatio inflating that -- a 3840x2160 output asking for 5760x3240 -- wrote outside the swapchain image, which is invalid usage and so undefined contents or a faulted submit rather than a visible error. vkCmdCopyImage cannot scale, so clamp it to the overlap; vkCmdBlitImage can, so fill the swapchain from the used sub-region. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanWindow.cpp | 46 ++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index afced750c..1f2d61c15 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1484,7 +1484,27 @@ namespace Rv // change. This is independent of the grow-only shared image below: a drag // still recreates the (warm) swapchain each step, but no longer rebuilds // or re-exports the shared image. - if (!m_vkSwapchain || m_vkSwapchainExtent.width != (uint32_t)w || m_vkSwapchainExtent.height != (uint32_t)h) + // + // The test is against the *surface's* extent, not against the caller's + // requested size. createSwapchain() takes its extent from + // capabilities.currentExtent, so it cannot be driven to match a request + // that disagrees with the surface: comparing to the request instead + // meant that any caller whose size was off by even a pixel -- e.g. a + // presentation output that sampled a devicePixelRatio belonging to the + // screen it was created on rather than the one it was moved to -- + // recreated the swapchain on *every frame*, forever and silently, since + // both surface-format reports are latched. + VkExtent2D surfaceExtent = m_vkSwapchainExtent; + { + VkSurfaceCapabilitiesKHR caps = {}; + if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &caps) == VK_SUCCESS + && caps.currentExtent.width != UINT32_MAX) + { + surfaceExtent = caps.currentExtent; + } + } + + if (!m_vkSwapchain || m_vkSwapchainExtent.width != surfaceExtent.width || m_vkSwapchainExtent.height != surfaceExtent.height) { // Warm recreate via oldSwapchain (createSwapchain retires the old one). if (!createSwapchain()) @@ -1505,9 +1525,15 @@ namespace Rv // componentwise max of the request, the screen size, and the current // capacity, so it grows monotonically and the common drag-to-fullscreen // case allocates at most once. + // + // This window's own screen, not the primary one: a presentation output + // lives on a second display, and sizing its headroom from the primary + // screen is both wrong and, when the primary is the smaller of the two, + // useless as headroom. + // int screenW = 0; int screenH = 0; - if (QScreen* scr = QGuiApplication::primaryScreen()) + if (QScreen* scr = screen() ? screen() : QGuiApplication::primaryScreen()) { const qreal dpr = scr->devicePixelRatio(); screenW = static_cast(scr->geometry().width() * dpr); @@ -2091,20 +2117,32 @@ namespace Rv // two formats. Whether the (linear-tiled) shared image can be a blit // source is checked at shared-image creation; if not, that path is // refused and syncBuffers() uses the CPU fallback instead. + // + // The destination is bounded by the swapchain, never by the shared + // image. They are normally the same size, but the shared image is + // sized from the caller's request and a stale devicePixelRatio can + // inflate that (a 3840x2160 output asking for 5760x3240), which would + // otherwise write outside the swapchain image -- invalid usage, so + // undefined contents or a faulted submit rather than a visible error. + // if (m_vkSwapchainFormat == VK_FORMAT_A2B10G10R10_UNORM_PACK32) { + // vkCmdCopyImage cannot scale, so clamp to the overlapping region. VkImageCopy region = {}; region.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; region.srcSubresource.layerCount = 1; region.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; region.dstSubresource.layerCount = 1; - region.extent = {(uint32_t)info.width, (uint32_t)info.height, 1}; + region.extent = {std::min((uint32_t)info.width, m_vkSwapchainExtent.width), + std::min((uint32_t)info.height, m_vkSwapchainExtent.height), 1}; vkCmdCopyImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); } else { + // vkCmdBlitImage can scale, so fill the swapchain from the used + // sub-region of the shared image instead of truncating. VkImageBlit blit = {}; blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; blit.srcSubresource.layerCount = 1; @@ -2113,7 +2151,7 @@ namespace Rv blit.srcOffsets[0] = {0, 0, 0}; blit.srcOffsets[1] = {info.width, info.height, 1}; blit.dstOffsets[0] = {0, 0, 0}; - blit.dstOffsets[1] = {info.width, info.height, 1}; + blit.dstOffsets[1] = {(int32_t)m_vkSwapchainExtent.width, (int32_t)m_vkSwapchainExtent.height, 1}; vkCmdBlitImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST); From 6c9c68077f89eb162b6861f9ddebfd1f9acc786a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Tue, 22 Sep 2026 15:15:47 -0400 Subject: [PATCH 34/48] fix(vulkan): port the Vulkan presentation interop negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 55 +- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 111 +++- src/lib/app/RvCommon/VulkanWindow.cpp | 553 ++++++++++++++++++- 3 files changed, 684 insertions(+), 35 deletions(-) diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index f55ba2075..737d8a34e 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #ifdef PLATFORM_WINDOWS // WIN32_LEAN_AND_MEAN prevents from including the legacy // , which otherwise collides with the already @@ -752,6 +753,36 @@ namespace Rv // No zero-copy interop this frame: pack + present via the CPU fallback. // The GL-packed RGB10_A2 readback handles the Y flip and the swapchain // channel order (A2B10G10R10 / A2R10G10B10) without a per-pixel loop. + // + // Report the specific reason so the startup record is conclusive + // for someone reading only a log: a bare "CPU fallback" does not + // say whether interop was forced off, demoted by an earlier GL + // failure, unavailable in the GL driver, or refused by the + // Vulkan-side capability probe. + std::string reason; + if (forceCpuPresentation()) + { + reason = "RV_VULKAN_FORCE_CPU_PRESENT is set"; + } + else if (m_interopDisabled) + { + reason = "an earlier GL call on the interop path failed; the device is demoted for the rest of the session"; + } + else if (!glDeviceMatchesVulkan()) + { + reason = "the GL context and the Vulkan device are different GPUs, so external-memory interop is unsafe"; + } + else if (!glInteropAvailable) + { + reason = "the GL driver does not expose the EXT_memory_object / EXT_semaphore interop entry points"; + } + else + { + const VulkanWindow::InteropConfig& c = m_window->interopConfig(); + reason = c.rejectReason.empty() ? "the Vulkan side declined to allocate a shared image" : c.rejectReason; + } + m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, reason); + presentCpuFallback(w, h); return; } @@ -776,9 +807,11 @@ namespace Rv // EXT_memory_object requires both sides to agree on whether the // allocation is dedicated, and the parameter has to be set before // the import. This follows whatever the Vulkan side allocated. - if (sharedInfo->dedicated) + // Set explicitly in both directions rather than only when + // dedicated: a mismatch corrupts the image rather than raising an + // error, so the value is stated instead of left at a default. { - const GLint dedicated = GL_TRUE; + const GLint dedicated = sharedInfo->dedicatedAllocation ? GL_TRUE : GL_FALSE; glMemoryObjectParameterivEXT(m_glMemoryObject[slot], GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); } #ifdef PLATFORM_WINDOWS @@ -805,7 +838,12 @@ namespace Rv glGenTextures(1, &m_glSharedTexture[slot]); glBindTexture(GL_TEXTURE_2D, m_glSharedTexture[slot]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, sharedInfo->optimalTiling ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); + // Import with the tiling the Vulkan side actually created the image + // with. Importing OPTIMAL-tiled memory as LINEAR leaves the + // image's large-scale structure recognizable but scrambles pixels + // within each tile. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, + sharedInfo->tiling == VK_IMAGE_TILING_OPTIMAL ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); // Allocate the imported texture at the image's capacity dimensions // (stride width x capacity height); the FBO blit below writes only the @@ -854,6 +892,7 @@ namespace Rv { cleanupSharedGLObjects(s); } + m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "GL import of the shared image raised a GL error"); presentCpuFallback(w, h); return; } @@ -861,8 +900,18 @@ namespace Rv // Cache the imported capacity so we re-import only when it grows. m_sharedWidth[slot] = sharedInfo->strideWidth; m_sharedHeight[slot] = sharedInfo->capacityHeight; + + // Let the Vulkan side's startup record show what GL actually + // imported, so the two sides can be compared in one place rather + // than only what Vulkan intended being visible. + m_window->reportGLImportState(sharedInfo->tiling, sharedInfo->dedicatedAllocation); } + // Import succeeded (or was already valid from a previous frame): this + // frame presents zero-copy. Reported here rather than before the + // import so the record reflects the path actually taken. + m_window->reportPresentPath(VulkanWindow::PresentPath::ZeroCopy, std::string()); + // Drain before the handshake, not after. // // session->render() runs earlier in this same frame and does leave diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index b6e207c5f..d69b4483e 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -15,6 +15,8 @@ #include #include #include +#include +#include QT_BEGIN_NAMESPACE class QPlatformWindow; @@ -78,6 +80,68 @@ namespace Rv // VK_FORMAT_UNDEFINED before the swapchain is created. VkFormat swapchainFormat() const { return m_vkSwapchainFormat; } + // + // Presentation path taken this session. RV prefers ZeroCopy, degrades + // to CpuReadback (slower, still 10-bit), and only then to OpenGL + // (which forgoes 10-bit). See emitPresentationRecord(). + // + enum class PresentPath + { + Undetermined, + ZeroCopy, // GL renders straight into a Vulkan-exported image + CpuReadback, // GL packs RGB10_A2 to host memory, Vulkan uploads it + OpenGL // Vulkan abandoned; RvDocument swaps in GLView + }; + + // Resolved GL<->Vulkan interop configuration. Negotiated once per + // device from what the driver reports exportable, never from GPU + // vendor identity or host platform. Both the Vulkan export and the GL + // import read their settings from this one struct so the two sides + // cannot disagree -- a disagreement about tiling or dedicated + // allocation corrupts the image rather than raising an error. + struct InteropConfig + { + bool supported{false}; + VkFormat format{VK_FORMAT_A2B10G10R10_UNORM_PACK32}; + VkImageTiling tiling{VK_IMAGE_TILING_LINEAR}; + VkImageUsageFlags usage{0}; + + // Probe-time floor for dedicated allocation: true when the handle + // type reports DEDICATED_ONLY, which is a hard requirement. The + // softer "prefers dedicated" signal belongs to a concrete image + // rather than to the format, so it is read per-image from + // VkMemoryDedicatedRequirements at allocation time and recorded in + // SharedImageInfo::dedicatedAllocation, which is what the GL side + // mirrors. + bool dedicatedAllocation{false}; + + // Raw VkExternalMemoryFeatureFlags the winning candidate reported, + // so a log read by someone without the machine can tell whether + // dedicated allocation was required by the handle type or merely + // preferred by the image. + VkExternalMemoryFeatureFlags externalFeatures{0}; + + // Set when an RV_VULKAN_FORCE_* override displaced what the probe + // would otherwise have chosen; the record reports both values. + bool tilingOverridden{false}; + bool dedicatedOverridden{false}; + VkImageTiling probedTiling{VK_IMAGE_TILING_LINEAR}; + bool probedDedicated{false}; + + // Why no candidate was usable (empty when supported is true). + std::string rejectReason; + // Per-candidate probe outcome, one entry per candidate tried. + std::vector candidateLog; + }; + + const InteropConfig& interopConfig() const { return m_interopConfig; } + + // Record the path actually taken, and the GL side's view of the shared + // image, then emit the one-per-session startup record. Called by + // QTVulkanVideoDevice once the first frame establishes which path ran. + void reportPresentPath(PresentPath path, const std::string& reason); + void reportGLImportState(VkImageTiling tiling, bool dedicated); + // True when uuid identifies the physical device backing this window. // Used by the GL bridge to refuse external-memory interop across GPUs. bool physicalDeviceMatchesUUID(const unsigned char* uuid, size_t size) const; @@ -110,17 +174,20 @@ namespace Rv int height{0}; // used sub-region height presented this frame int strideWidth{0}; // GL texture width = capacity rowPitch / 4 int capacityHeight{0}; // allocated image height (>= height); GL texture height - // Non-zero when the shared image uses VK_IMAGE_TILING_OPTIMAL (the - // default on NVIDIA, which avoids the blank large-image bug); GL - // must then import with GL_OPTIMAL_TILING_EXT instead of - // GL_LINEAR_TILING_EXT. - int optimalTiling{0}; - // Non-zero when the exported memory is a dedicated allocation + // The negotiated tiling this image was actually created with. GL + // must import with the matching GL_{OPTIMAL,LINEAR}_TILING_EXT: + // importing OPTIMAL-tiled memory as LINEAR yields an image whose + // large-scale structure survives but whose pixels are scrambled + // within each tile. + VkImageTiling tiling{VK_IMAGE_TILING_LINEAR}; + + // Whether the export used a dedicated allocation // (VkMemoryDedicatedAllocateInfo), which the driver may require for // an image created with an external handle type. EXT_memory_object // requires the two sides to agree, so GL must set - // GL_DEDICATED_MEMORY_OBJECT_EXT exactly when this is set. - int dedicated{0}; + // GL_DEDICATED_MEMORY_OBJECT_EXT to exactly this before + // glTexStorageMem2DEXT; a mismatch corrupts the image. + bool dedicatedAllocation{false}; }; // Capacity of the present-resource ring. Per-frame Vulkan sync objects @@ -184,6 +251,18 @@ namespace Rv bool createSwapchain(); void cleanupSwapchain(); + // Probe the driver for an exportable shared-image configuration and + // resolve m_interopConfig. Runs exactly once per device, at device + // creation -- not per shared-image slot and not again on resize. + void negotiateInteropConfig(); + + // Emit the one-per-session startup record describing the negotiated + // configuration and the path taken. Unconditional: it must not be + // gated on ImageRenderer::debugGpu(), because Windows/NVIDIA is + // verified by QA against a build, and a log that needs a debug flag + // set in advance costs a whole verification round. + void emitPresentationRecord(); + RvDocument* m_doc; QTVulkanVideoDevice* m_videoDevice; @@ -309,6 +388,22 @@ namespace Rv bool presentationAllowed() const; bool m_glFallbackRequested{false}; + + // Negotiated interop configuration and the state behind the startup + // record. m_interopNegotiated guards the once-per-device probe; + // m_recordEmitted guards the once-per-session record. + InteropConfig m_interopConfig; + bool m_interopNegotiated{false}; + bool m_recordEmitted{false}; + + PresentPath m_presentPath{PresentPath::Undetermined}; + std::string m_presentPathReason; + + // What the GL side reported importing, so the record can show the two + // sides agreeing (or not) rather than only what Vulkan intended. + bool m_glImportReported{false}; + VkImageTiling m_glImportTiling{VK_IMAGE_TILING_LINEAR}; + bool m_glImportDedicated{false}; }; } // namespace Rv diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 1f2d61c15..086ad85e7 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -32,11 +32,13 @@ #include #include +#include #include #include #include #include #include +#include #ifdef PLATFORM_WINDOWS // WIN32_LEAN_AND_MEAN prevents from including the legacy // , which otherwise collides with the already @@ -49,6 +51,36 @@ #include #endif +// +// Environment variables recognized by the Vulkan presentation path +// ---------------------------------------------------------------- +// These exist so a corrupted or failing display can be narrowed to a stage +// without a rebuild -- notably by a tester running a build on hardware the +// developer cannot access. Every override is reported in the startup record +// (see VulkanWindow::emitPresentationRecord), alongside the value negotiation +// would otherwise have chosen. +// +// RV_VULKAN_FORCE_CPU_PRESENT +// Set (to any value) to skip GL<->Vulkan zero-copy interop entirely and +// present via the CPU readback path. Still 10-bit, just slower. +// +// RV_VULKAN_FORCE_TILING = optimal | linear +// Override the negotiated shared-image tiling. The override is honored +// only if the driver reports that tiling as exportable; otherwise it is +// logged and refused, because presenting through a configuration whose +// correctness was not established is what this path is meant to avoid. +// An unrecognized value is logged and ignored (negotiation proceeds). +// +// RV_VULKAN_FORCE_NO_DEDICATED +// Set (to any value) to suppress dedicated allocation even when the +// driver reports it as preferred. Refused when the driver reports +// DEDICATED_ONLY, since that is a requirement rather than a preference. +// RV_VULKAN_DISABLE_DEDICATED_ALLOCATION is kept as an older alias. +// +// Both sides of the interop read their settings from one negotiated struct, +// so an override applies to the Vulkan export and the GL import together. +// + namespace Rv { using namespace std; @@ -575,6 +607,16 @@ namespace Rv vkGetDeviceQueue(m_vkDevice, m_queueFamilyIndex, 0, &m_vkQueue); + // Negotiate the GL<->Vulkan interop configuration once, here. It is a + // property of the device, not of a shared-image slot or of the current + // window size, so it must not be recomputed per slot or on resize. + negotiateInteropConfig(); + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: initVulkan: interop negotiation ran (once per device); result=" + << (m_interopConfig.supported ? "supported" : "unsupported") << endl; + } + auto failInit = [this]() { cleanupVulkan(); @@ -625,6 +667,13 @@ namespace Rv return; } m_glFallbackRequested = true; + + // The OpenGL rung forgoes 10-bit, so it must be visible in the log + // rather than inferred from the absence of a Vulkan record. Callers + // that know why set m_presentPathReason before calling. + reportPresentPath(PresentPath::OpenGL, + m_presentPathReason.empty() ? std::string("Vulkan presentation could not be established") : m_presentPathReason); + QTimer::singleShot(0, m_doc, [doc = m_doc]() { doc->fallbackVulkanToGLView(); }); } @@ -1201,6 +1250,99 @@ namespace Rv namespace { + // Read an env var that is treated as a boolean flag by presence. + bool envFlagSet(const char* name) { return getenv(name) != nullptr; } + + const char* tilingName(VkImageTiling t) + { + switch (t) + { + case VK_IMAGE_TILING_OPTIMAL: + return "OPTIMAL"; + case VK_IMAGE_TILING_LINEAR: + return "LINEAR"; + default: + return "(other)"; + } + } + + const char* colorSpaceName(VkColorSpaceKHR cs) + { + switch (cs) + { + case VK_COLOR_SPACE_SRGB_NONLINEAR_KHR: + return "SRGB_NONLINEAR"; + case VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT: + return "EXTENDED_SRGB_LINEAR"; + case VK_COLOR_SPACE_EXTENDED_SRGB_NONLINEAR_EXT: + return "EXTENDED_SRGB_NONLINEAR"; + case VK_COLOR_SPACE_HDR10_ST2084_EXT: + return "HDR10_ST2084"; + case VK_COLOR_SPACE_HDR10_HLG_EXT: + return "HDR10_HLG"; + case VK_COLOR_SPACE_BT2020_LINEAR_EXT: + return "BT2020_LINEAR"; + case VK_COLOR_SPACE_DISPLAY_P3_NONLINEAR_EXT: + return "DISPLAY_P3_NONLINEAR"; + case VK_COLOR_SPACE_PASS_THROUGH_EXT: + return "PASS_THROUGH"; + default: + return "(other)"; + } + } + + // Decode RV_VULKAN_FORCE_TILING. Returns false when unset or when the + // value is not recognized; an unrecognized value is reported rather + // than silently behaving as if the variable were unset. + bool forcedTilingRequested(VkImageTiling& out) + { + const char* v = getenv("RV_VULKAN_FORCE_TILING"); + if (!v) + return false; + + std::string s(v); + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(::tolower(c)); }); + + if (s == "optimal") + { + out = VK_IMAGE_TILING_OPTIMAL; + return true; + } + if (s == "linear") + { + out = VK_IMAGE_TILING_LINEAR; + return true; + } + + cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING='" << v << "' is not recognized (expected 'optimal' or 'linear'); " + << "ignoring it and using the negotiated tiling" << endl; + return false; + } + + // vkGetPhysicalDeviceImageFormatProperties2 is core in Vulkan 1.1 and + // available as a KHR alias on 1.0 loaders that expose + // VK_KHR_get_physical_device_properties2. Resolved through + // vkGetInstanceProcAddr rather than called directly so a 1.0-only + // loader degrades to "cannot establish exportability" instead of + // dispatching into an entry point it does not implement. + PFN_vkGetPhysicalDeviceImageFormatProperties2 getImageFormatProperties2(VkInstance instance) + { + static PFN_vkGetPhysicalDeviceImageFormatProperties2 fn = nullptr; + static bool resolved = false; + if (!resolved) + { + resolved = true; + fn = reinterpret_cast( + vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceImageFormatProperties2")); + if (!fn) + { + fn = reinterpret_cast( + vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceImageFormatProperties2KHR")); + } + } + return fn; + } + bool isNvidiaPhysicalDevice(VkPhysicalDevice dev) { VkPhysicalDeviceProperties props = {}; @@ -1222,7 +1364,12 @@ namespace Rv // out on a machine without a rebuild. bool dedicatedAllocationDisabled() { - static const bool disabled = getenv("RV_VULKAN_DISABLE_DEDICATED_ALLOCATION") != nullptr; + // RV_VULKAN_FORCE_NO_DEDICATED is the documented name, matching + // the other RV_VULKAN_FORCE_* overrides; + // RV_VULKAN_DISABLE_DEDICATED_ALLOCATION is kept as an alias so + // existing notes and scripts keep working. + static const bool disabled = + getenv("RV_VULKAN_FORCE_NO_DEDICATED") != nullptr || getenv("RV_VULKAN_DISABLE_DEDICATED_ALLOCATION") != nullptr; return disabled; } @@ -1305,6 +1452,303 @@ namespace Rv } } // namespace + // + // Probe the driver for an exportable shared-image configuration. + // + // Runs once per device. The previous code chose OPTIMAL tiling when the + // physical device reported vendorID 0x10DE and LINEAR otherwise, which + // encoded one machine's observed behaviour as a rule about a whole + // vendor. Asking the driver which configurations it will actually export + // covers the same NVIDIA case without guessing about the others, and + // produces a reason when nothing is usable. + // + void VulkanWindow::negotiateInteropConfig() + { + if (m_interopNegotiated) + { + return; + } + m_interopNegotiated = true; + + InteropConfig cfg; + cfg.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; // == GL_RGB10_A2 + // Usage must cover every use on BOTH sides: Vulkan reads the image as + // a transfer source, and GL attaches it to GL_COLOR_ATTACHMENT0 and + // renders into it. Declaring only TRANSFER_SRC lets the driver pick an + // internal compressed layout the GL import does not decode, which + // corrupts the image rather than raising an error. + cfg.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + +#ifdef PLATFORM_WINDOWS + const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; +#else + const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; +#endif + + PFN_vkGetPhysicalDeviceImageFormatProperties2 probe = getImageFormatProperties2(m_vkInstance); + if (!probe) + { + cfg.supported = false; + cfg.rejectReason = "vkGetPhysicalDeviceImageFormatProperties2 is unavailable " + "(Vulkan instance predates 1.1 and lacks VK_KHR_get_physical_device_properties2), " + "so exportability cannot be established"; + m_interopConfig = cfg; + return; + } + + // OPTIMAL first: it is the layout the driver is free to arrange for + // its own access, and the one that fixed the blank large-image bug on + // NVIDIA. LINEAR is the portable fallback. + const VkImageTiling candidates[] = {VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_TILING_LINEAR}; + + auto probeTiling = [&](VkImageTiling tiling, VkExternalMemoryFeatureFlags& features) -> bool + { + VkPhysicalDeviceExternalImageFormatInfo extInfo = {}; + extInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO; + extInfo.handleType = handleType; + + VkPhysicalDeviceImageFormatInfo2 fmtInfo = {}; + fmtInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2; + fmtInfo.pNext = &extInfo; + fmtInfo.format = cfg.format; + fmtInfo.type = VK_IMAGE_TYPE_2D; + fmtInfo.tiling = tiling; + fmtInfo.usage = cfg.usage; + fmtInfo.flags = 0; + + VkExternalImageFormatProperties extProps = {}; + extProps.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES; + + VkImageFormatProperties2 props = {}; + props.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2; + props.pNext = &extProps; + + const VkResult r = probe(m_vkPhysicalDevice, &fmtInfo, &props); + features = extProps.externalMemoryProperties.externalMemoryFeatures; + + if (r != VK_SUCCESS) + { + return false; + } + + return (extProps.externalMemoryProperties.compatibleHandleTypes & handleType) != 0 + && (features & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0 + && (features & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0; + }; + + bool found = false; + for (VkImageTiling tiling : candidates) + { + VkExternalMemoryFeatureFlags features = 0; + const bool exportable = probeTiling(tiling, features); + + ostringstream entry; + entry << tilingName(tiling) << ": "; + + if (!exportable) + { + entry << "not exportable+importable for this handle type" + << " (features=0x" << std::hex << features << std::dec << ")"; + cfg.candidateLog.push_back(entry.str()); + continue; + } + + // DEDICATED_ONLY is the only dedicated-allocation signal available + // from the external-memory probe: it is a hard requirement of the + // handle type. The softer "prefers dedicated" signal is a property + // of a concrete image, not of the format, and is read per-image + // from VkMemoryDedicatedRequirements at allocation time -- see + // getSharedImageInfo(). This value is therefore the floor, and the + // per-slot SharedImageInfo::dedicatedAllocation is the final + // decision the GL side must mirror. + const bool dedicatedOnly = (features & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; + + cfg.supported = true; + cfg.tiling = tiling; + cfg.externalFeatures = features; + cfg.dedicatedAllocation = dedicatedOnly; + cfg.probedTiling = tiling; + cfg.probedDedicated = dedicatedOnly; + + entry << "exportable (features=0x" << std::hex << features << std::dec << ") -- selected"; + cfg.candidateLog.push_back(entry.str()); + found = true; + break; + } + + if (!found) + { + cfg.supported = false; + cfg.rejectReason = "no candidate tiling is exportable at A2B10G10R10 with " + "COLOR_ATTACHMENT|TRANSFER_SRC usage"; + m_interopConfig = cfg; + return; + } + + // Apply the diagnostic overrides last, so the record can report both + // the negotiated value and the forced one. An override is honored only + // when the driver reported that configuration as usable. + VkImageTiling forcedTiling = VK_IMAGE_TILING_LINEAR; + if (forcedTilingRequested(forcedTiling) && forcedTiling != cfg.tiling) + { + // Re-probe the forced tiling rather than trusting the request: + // presenting through an unverified configuration is exactly what + // the fallback ladder exists to prevent. + VkExternalMemoryFeatureFlags features = 0; + if (probeTiling(forcedTiling, features)) + { + cfg.tilingOverridden = true; + cfg.tiling = forcedTiling; + cfg.externalFeatures = features; + cfg.dedicatedAllocation = (features & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; + } + else + { + cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING=" << tilingName(forcedTiling) + << " refused -- the driver does not report it as exportable; using the negotiated " << tilingName(cfg.tiling) << endl; + } + } + + if (dedicatedAllocationDisabled() && cfg.dedicatedAllocation) + { + if ((cfg.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0) + { + cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_NO_DEDICATED refused -- the driver reports " + << "DEDICATED_ONLY for this configuration, which is a requirement rather than a preference" << endl; + } + else + { + cfg.dedicatedOverridden = true; + cfg.dedicatedAllocation = false; + } + } + else if (dedicatedAllocationDisabled()) + { + // Nothing to relax at probe level, but the per-image decision in + // getSharedImageInfo() still needs to know an override is active. + cfg.dedicatedOverridden = true; + } + + m_interopConfig = cfg; + } + + void VulkanWindow::reportPresentPath(PresentPath path, const std::string& reason) + { + m_presentPath = path; + m_presentPathReason = reason; + emitPresentationRecord(); + } + + void VulkanWindow::reportGLImportState(VkImageTiling tiling, bool dedicated) + { + m_glImportTiling = tiling; + m_glImportDedicated = dedicated; + m_glImportReported = true; + } + + // + // One record per session, emitted unconditionally. Windows/NVIDIA is + // verified by testers against a produced build rather than by the + // developer, so this has to be sufficient on its own to establish which + // path ran and what was negotiated. Per-frame and per-candidate detail + // stays behind ImageRenderer::debugGpu(). + // + void VulkanWindow::emitPresentationRecord() + { + if (m_recordEmitted) + { + return; + } + m_recordEmitted = true; + + VkPhysicalDeviceProperties props = {}; + if (m_vkPhysicalDevice != VK_NULL_HANDLE) + { + vkGetPhysicalDeviceProperties(m_vkPhysicalDevice, &props); + } + + const char* pathName = "undetermined"; + switch (m_presentPath) + { + case PresentPath::ZeroCopy: + pathName = "GPU zero-copy interop (10-bit)"; + break; + case PresentPath::CpuReadback: + pathName = "CPU readback (10-bit, slower)"; + break; + case PresentPath::OpenGL: + pathName = "OpenGL (Vulkan abandoned; not 10-bit)"; + break; + case PresentPath::Undetermined: + break; + } + + ostringstream o; + o << "INFO: RV Vulkan presentation report\n"; + o << "INFO: Role : " << (isPassiveOutput() ? "presentation output" : "control viewport") << "\n"; + o << "INFO: GPU : " << (m_vkPhysicalDevice != VK_NULL_HANDLE ? props.deviceName : "(none)") << " vendorID=0x" + << std::hex << props.vendorID << std::dec << " driverVersion=" << props.driverVersion + << " apiVersion=" << VK_VERSION_MAJOR(props.apiVersion) << "." << VK_VERSION_MINOR(props.apiVersion) << "." + << VK_VERSION_PATCH(props.apiVersion) << "\n"; + o << "INFO: Present path : " << pathName << "\n"; + if (!m_presentPathReason.empty()) + { + o << "INFO: Reason : " << m_presentPathReason << "\n"; + } + o << "INFO: Swapchain : " << formatName(m_vkSwapchainFormat) << " / " << colorSpaceName(m_loggedSurfaceFormat.colorSpace) + << "\n"; + + const InteropConfig& c = m_interopConfig; + if (c.supported) + { + o << "INFO: Shared image : " << formatName(c.format) << " tiling=" << tilingName(c.tiling) + << " usage=COLOR_ATTACHMENT|TRANSFER_SRC\n"; + o << "INFO: Dedicated alloc: " << (c.dedicatedAllocation ? "yes" : "no") << " (driver externalMemoryFeatures=0x" << std::hex + << c.externalFeatures << std::dec + << (c.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT ? " DEDICATED_ONLY" : "") << ")\n"; + if (c.tilingOverridden) + { + o << "INFO: Tiling override: RV_VULKAN_FORCE_TILING forced " << tilingName(c.tiling) << "; negotiation chose " + << tilingName(c.probedTiling) << "\n"; + } + if (c.dedicatedOverridden) + { + o << "INFO: Dedicated ovr : RV_VULKAN_FORCE_NO_DEDICATED suppressed dedicated allocation; negotiation chose " + << (c.probedDedicated ? "yes" : "no") << "\n"; + } + if (m_glImportReported) + { + const bool agree = m_glImportTiling == c.tiling; + o << "INFO: GL import : tiling=" << tilingName(m_glImportTiling) + << " dedicated=" << (m_glImportDedicated ? "yes" : "no") << " -- " + << (agree ? "tiling matches the Vulkan export" : "TILING DISAGREES WITH THE VULKAN EXPORT (expect a corrupted image)") + << "\n"; + } + } + else + { + o << "INFO: Shared image : not used -- " << (c.rejectReason.empty() ? "interop not negotiated" : c.rejectReason) << "\n"; + } + + for (const std::string& entry : c.candidateLog) + { + o << "INFO: Probe candidate: " << entry << "\n"; + } + + // Kept for comparison until the probe result is confirmed to agree + // with the vendor heuristic on Linux/NVIDIA; the heuristic is removed + // once it does. + o << "INFO: Legacy vendor heuristic would have chosen: " + << (m_vkPhysicalDevice != VK_NULL_HANDLE && useOptimalTilingForInterop(m_vkPhysicalDevice) ? "OPTIMAL" : "LINEAR") << "\n"; + + if (envFlagSet("RV_VULKAN_FORCE_CPU_PRESENT")) + { + o << "INFO: Override : RV_VULKAN_FORCE_CPU_PRESENT is set\n"; + } + + cout << o.str() << flush; + } + void VulkanWindow::cleanupSharedImage(uint32_t slot) { SharedImageInfo& info = m_sharedImageInfo[slot]; @@ -1372,7 +1816,8 @@ namespace Rv info.height = 0; info.size = 0; info.capacityHeight = 0; - info.optimalTiling = 0; + info.tiling = VK_IMAGE_TILING_LINEAR; + info.dedicatedAllocation = false; m_sharedCapacityW[slot] = 0; m_sharedCapacityH[slot] = 0; } @@ -1544,9 +1989,22 @@ namespace Rv cleanupSharedImage(slot); - // OPTIMAL tiling on NVIDIA (the fix for the blank large-image bug); LINEAR - // elsewhere. See useOptimalTilingForInterop(). - const bool optimalTiling = useOptimalTilingForInterop(m_vkPhysicalDevice); + // The interop configuration was negotiated once at device creation from + // what the driver reports exportable. If nothing was exportable, refuse + // the zero-copy path here so syncBuffers() takes the CPU readback rung + // rather than presenting through a configuration whose correctness was + // never established. + if (!m_interopConfig.supported) + { + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: getSharedImageInfo: interop unavailable (" << m_interopConfig.rejectReason + << "); using the CPU readback path" << endl; + } + return nullptr; + } + + const bool optimalTiling = m_interopConfig.tiling == VK_IMAGE_TILING_OPTIMAL; // Unconditional, and reported after the tiling decision so it can name // it. The shared image is allocated at screen capacity, so this fires @@ -1554,7 +2012,7 @@ namespace Rv // tiling it reports is exactly the fact that separates a working NVIDIA // viewport from a black one. cout << "INFO: VulkanWindow: getSharedImageInfo: (re)allocating shared image slot " << slot << " capacity " << capW << "x" << capH - << " for request " << w << "x" << h << " tiling=" << (optimalTiling ? "OPTIMAL" : "LINEAR") << endl; + << " for request " << w << "x" << h << " tiling=" << tilingName(m_interopConfig.tiling) << endl; // 1. Create Shared Image VkExternalMemoryImageCreateInfo extMemInfo = {}; @@ -1581,8 +2039,14 @@ namespace Rv imageInfo.mipLevels = 1; imageInfo.arrayLayers = 1; imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; - imageInfo.tiling = optimalTiling ? VK_IMAGE_TILING_OPTIMAL : VK_IMAGE_TILING_LINEAR; - imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT; // Only used as transfer src in Vulkan + imageInfo.tiling = m_interopConfig.tiling; + // Usage must cover every use on BOTH sides: Vulkan reads the image as a + // transfer source, and GL attaches it to GL_COLOR_ATTACHMENT0 and + // renders into it. Declaring only TRANSFER_SRC lets the driver pick an + // internal compressed layout that the GL import does not decode, which + // corrupts the image rather than raising an error. This is the usage + // the probe established as exportable. + imageInfo.usage = m_interopConfig.usage; imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; @@ -1647,7 +2111,7 @@ namespace Rv info.strideWidth = static_cast(layout.rowPitch / 4); } info.capacityHeight = capH; // GL imports the texture at capacity dimensions - info.optimalTiling = optimalTiling ? 1 : 0; + info.tiling = m_interopConfig.tiling; // // Ask through the 2-variant so the dedicated-allocation requirement can @@ -1672,37 +2136,78 @@ namespace Rv vkGetImageMemoryRequirements2(m_vkDevice, &memReqsInfo, &memReqs2); const VkMemoryRequirements& memReqs = memReqs2.memoryRequirements; - const bool useDedicated = - !dedicatedAllocationDisabled() && (dedicatedReqs.requiresDedicatedAllocation || dedicatedReqs.prefersDedicatedAllocation); - info.dedicated = useDedicated ? 1 : 0; + // Resolve the final dedicated-allocation decision for THIS image. The + // probe supplied the floor (DEDICATED_ONLY, a requirement of the handle + // type); "prefers dedicated" is a property of a concrete image and is + // only available here. The GL side mirrors info.dedicatedAllocation, so + // this is the single decision both sides use -- deciding it + // independently is what corrupts the image. + // + // An explicit override may only relax a preference, never a + // requirement (DEDICATED_ONLY or requiresDedicatedAllocation). + bool useDedicated = + m_interopConfig.dedicatedAllocation || dedicatedReqs.requiresDedicatedAllocation || dedicatedReqs.prefersDedicatedAllocation; + if (dedicatedAllocationDisabled() && !dedicatedReqs.requiresDedicatedAllocation + && (m_interopConfig.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) == 0) + { + useDedicated = false; + } + + info.dedicatedAllocation = useDedicated; cout << "INFO: VulkanWindow: getSharedImageInfo: shared image slot " << slot << " memory = " << (useDedicated ? "dedicated" : "non-dedicated") << " (driver requires=" << (dedicatedReqs.requiresDedicatedAllocation ? "yes" : "no") << " prefers=" << (dedicatedReqs.prefersDedicatedAllocation ? "yes" : "no") << ")" << endl; - VkExportMemoryAllocateInfo exportAllocInfo = {}; - exportAllocInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; + // Build the allocation pNext chain back to front, so each link is + // attached exactly once regardless of which options are active: + // + // allocInfo -> exportAllocInfo [-> dedicatedAllocInfo] [-> exportWin32Info] + // + // Every link lives until the vkAllocateMemory call below. + void* chain = nullptr; + #ifdef PLATFORM_WINDOWS - exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; + // Required by the Vulkan specification for OPAQUE_WIN32 handles: the + // export must state the access rights and security attributes the + // handle is created with. Its absence is tolerated by some drivers but + // is a real violation on the platform being debugged. + VkExportMemoryWin32HandleInfoKHR exportWin32Info = {}; + exportWin32Info.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_WIN32_HANDLE_INFO_KHR; + exportWin32Info.pNext = chain; + exportWin32Info.pAttributes = nullptr; // default security attributes + exportWin32Info.dwAccess = GENERIC_ALL; + exportWin32Info.name = nullptr; // unnamed: shared within this process only + chain = &exportWin32Info; #endif - // Chained ahead of the export info when in use; both live to the - // vkAllocateMemory call below. + // Dedicated allocation when the driver requires or prefers it for this + // image. NVIDIA's OPAQUE_WIN32 path in particular needs this paired + // with GL_DEDICATED_MEMORY_OBJECT_EXT on the import side. VkMemoryDedicatedAllocateInfo dedicatedAllocInfo = {}; - dedicatedAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; - dedicatedAllocInfo.image = m_vkSharedImage[slot]; if (useDedicated) { - dedicatedAllocInfo.pNext = &exportAllocInfo; + dedicatedAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; + dedicatedAllocInfo.pNext = chain; + dedicatedAllocInfo.image = m_vkSharedImage[slot]; + dedicatedAllocInfo.buffer = VK_NULL_HANDLE; + chain = &dedicatedAllocInfo; } + VkExportMemoryAllocateInfo exportAllocInfo = {}; + exportAllocInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; + exportAllocInfo.pNext = chain; +#ifdef PLATFORM_WINDOWS + exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; +#else + exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; +#endif + VkMemoryAllocateInfo allocInfo = {}; allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; - allocInfo.pNext = useDedicated ? static_cast(&dedicatedAllocInfo) : static_cast(&exportAllocInfo); + allocInfo.pNext = &exportAllocInfo; allocInfo.allocationSize = memReqs.size; allocInfo.memoryTypeIndex = findMemoryType(m_vkPhysicalDevice, memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); if (allocInfo.memoryTypeIndex == UINT32_MAX) @@ -2724,7 +3229,7 @@ namespace Rv const double n = double(s_diagFrames); const double loopMs = s_diagLoopMs / n; cout << "INFO: VulkanWindow frame avg over " << s_diagFrames << " [depth=" << maxFramesInFlight() - << " tiling=" << (m_sharedImageInfo[0].optimalTiling ? "OPTIMAL" : "LINEAR") << "]" + << " tiling=" << tilingName(m_sharedImageInfo[0].tiling) << "]" << ": session->render()=" << (s_diagRenderMs / n) << "ms mainPresent=" << (s_diagMainPresentMs / n) << "ms outputPresent=" << (s_diagOutPresentMs / n) << "ms total=" << ((s_diagRenderMs + s_diagMainPresentMs + s_diagOutPresentMs) / n) From 8ffbde96fc89bab36b50e613aac4e408c04c6e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Fri, 25 Sep 2026 09:12:25 -0400 Subject: [PATCH 35/48] Reduce comments and make sure curly braces are used for single line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédrik Fuoco --- src/bin/apps/rv/main.cpp | 11 +- src/lib/app/RvApp/Options.cpp | 1 - src/lib/app/RvCommon/DesktopVideoDevice.cpp | 243 +--- src/lib/app/RvCommon/DesktopVideoModule.cpp | 17 +- src/lib/app/RvCommon/GLWindow.cpp | 35 +- src/lib/app/RvCommon/MuUICommands.cpp | 4 - src/lib/app/RvCommon/QTGLVideoDevice.cpp | 35 +- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 252 ++-- src/lib/app/RvCommon/RvApplication.cpp | 113 +- .../RvCommon/RvCommon/DesktopVideoDevice.h | 99 +- .../RvCommon/RvCommon/DesktopVideoModule.h | 29 +- src/lib/app/RvCommon/RvCommon/GLView.h | 6 +- .../app/RvCommon/RvCommon/QTGLVideoDevice.h | 12 +- .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 87 +- src/lib/app/RvCommon/RvCommon/RvApplication.h | 31 +- src/lib/app/RvCommon/RvCommon/RvDocument.h | 10 +- .../RvCommon/VulkanDesktopVideoDevice.h | 31 +- src/lib/app/RvCommon/RvCommon/VulkanView.h | 46 +- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 237 ++-- src/lib/app/RvCommon/RvConsoleWindow.cpp | 43 +- src/lib/app/RvCommon/RvDocument.cpp | 252 +--- src/lib/app/RvCommon/RvPreferences.cpp | 5 +- src/lib/app/RvCommon/RvProfileManager.cpp | 5 +- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 120 +- src/lib/app/RvCommon/VulkanView.cpp | 183 +-- src/lib/app/RvCommon/VulkanWindow.cpp | 1082 +++++------------ src/lib/app/RvPackage/PackageManager.cpp | 23 +- .../audio/QTAudioRenderer/QTAudioRenderer.cpp | 47 +- .../QTAudioRenderer/QTAudioRenderer.h | 19 +- .../TwkContainer/TwkContainer/Property.h | 10 +- src/lib/graphics/TwkGLF/GL.cpp | 33 +- src/lib/graphics/TwkGLF/GLContextScope.cpp | 41 +- src/lib/graphics/TwkGLF/GLFBO.cpp | 38 +- .../TwkGLF/GLPixelBufferObjectPool.cpp | 21 +- src/lib/graphics/TwkGLF/TwkGLF/GL.h | 12 +- .../graphics/TwkGLF/TwkGLF/GLContextScope.h | 40 +- src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h | 7 +- src/lib/ip/IPCore/IPCore/IPGraph.h | 6 +- src/lib/ip/IPCore/IPCore/ImageRenderer.h | 2 +- src/lib/ip/IPCore/IPGraph.cpp | 64 +- src/lib/ip/IPCore/ImageFBO.cpp | 13 +- src/lib/ip/IPCore/ImageRenderer.cpp | 43 +- src/lib/ip/IPCore/Session.cpp | 15 +- 43 files changed, 843 insertions(+), 2580 deletions(-) diff --git a/src/bin/apps/rv/main.cpp b/src/bin/apps/rv/main.cpp index af1bf4417..ef9bb5c11 100644 --- a/src/bin/apps/rv/main.cpp +++ b/src/bin/apps/rv/main.cpp @@ -373,14 +373,11 @@ int utf8Main(int argc, char* argv[]) // documented QtWebEngine requirement, and it lets RV's auxiliary GL // surfaces -- the second-output ScreenView and the multithreaded-upload // worker device -- share textures/FBOs with the main viewport context - // without an explicit, ordering-sensitive setShareContext() call. + // without an explicit, ordering-sensitive setShareContext() call. Must be + // set before the QApplication is constructed. // - // It is also what makes the Vulkan presentation path work: there is no - // GLView to chain from there, so QTVulkanVideoDevice::ensureGLContext() - // joins this global group instead. Without it, FTGL font-atlas glyph - // uploads land in a context where the atlas texture has no storage. - // - // Must be set before the QApplication is constructed. + // The Vulkan path also relies on it: QTVulkanVideoDevice::ensureGLContext() + // has no GLView to share with, so it joins this global group. QApplication::setAttribute(Qt::AA_ShareOpenGLContexts); #ifdef PLATFORM_WINDOWS diff --git a/src/lib/app/RvApp/Options.cpp b/src/lib/app/RvApp/Options.cpp index ed1696b24..9c6751733 100644 --- a/src/lib/app/RvApp/Options.cpp +++ b/src/lib/app/RvApp/Options.cpp @@ -71,7 +71,6 @@ namespace Rv else if (name == "threads") stl_ext::thread_group::debug_all(true); else if (name == "gpu") - // Enables GLView format baseline, ImageRenderer GL reporting, and Vulkan presentation/interop diagnostics. ImageRenderer::debugGpu(true); else if (name == "audio") { diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 1b373ad01..43234b20a 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -87,8 +87,6 @@ namespace Rv if (m_view && m_view->glWindow()) { ScopedLock lock(m_mutex); - // Update the GL surface, not the container: only the window has - // anything to present. m_view->glWindow()->update(); } } @@ -136,19 +134,13 @@ namespace Rv return; } - // Re-arm the stall report, silently: the interesting event is the - // next stall, not the recovery from this one. m_transferStalled = false; // Switch to the ScreenView's OpenGL context. m_viewDevice->makeCurrent(); // calls screenview's makeCurrent, sets the // font current, etc etc. - // Because we can't blit from FBOs belonging to different contexts, use - // a clone of the source FBO associated to the view's context. - // cloneForSource() creates and caches it on first use, reusing the - // source FBO's color attachment because color attachments *can* be - // shared for the blit operation. + // FBOs can't be blitted across contexts, so use this context's clone. GLFBO* svSourceFbo = cloneForSource(sourceFbo); if (!svSourceFbo) { @@ -175,29 +167,15 @@ namespace Rv TWK_GLDEBUG; // - // There is no GL share device when the main view presents through a - // non-OpenGL backend (the Vulkan 10-bit path), where RvApplication - // constructs the DesktopVideoModule with a null share device. Fall - // back to the default surface format and no explicit share context -- - // Qt::AA_ShareOpenGLContexts (set in main.cpp) already puts every - // QOpenGLContext in one resource-sharing group. + // No share device on the Vulkan main-view path; AA_ShareOpenGLContexts + // still puts every context in one share group. // const QTGLVideoDevice* share = shareDevice(); // - // Realize the share device's GL context before copying its format and - // creating ours. - // - // A QOpenGLWindow creates its QOpenGLContext lazily, on the first - // makeCurrent/paint -- creating the platform window is not enough. So - // when this runs right after a main-view backend swap (RvDocument's - // swap paths call RvApplication::rebuildDesktopVideoDevices, which - // re-opens the presentation output in the same call frame), the new - // main view's context does not exist yet: glShareContext() is null and - // glSurfaceFormat() is not yet the format we are going to have to - // share with. Building our context from that is how this device can - // end up outside the renderer's share group, which makes the - // renderer's output textures unreachable and the display black. + // QOpenGLWindow creates its context lazily, so realize the share + // device's context (e.g. right after a backend swap) before copying + // its format and share context. // if (share) { @@ -210,10 +188,7 @@ namespace Rv ScreenView* vw = new ScreenView(fmt, 0, share ? share->glShareContext() : nullptr, Qt::Window); setViewWidget(vw); - // - // The GL surface is the embedded window; the container widget is what - // handles events and coordinate translation. Same split as GLView. - // + // GL surface is the embedded window; the container handles events, as in GLView. QTGLVideoDevice* vd = new QTGLVideoDevice(0, "local view", vw->glWindow(), vw); setViewDevice(vd); @@ -233,41 +208,17 @@ namespace Rv viewWidget()->show(); // - // Deliberately no makeCurrent() here to "prime" the context. - // - // VulkanDesktopVideoDevice::open() can end that way because - // QTVulkanVideoDevice::makeCurrent() builds its own offscreen FBO on - // demand. A PartialUpdateBlit QOpenGLWindow cannot: its makeCurrent() - // binds the backing FBO that Qt only creates on the first paint, so - // calling it before this window has painted dereferences a null FBO - // inside Qt and takes the process down. - // - // Nothing needs priming anyway -- show() drives the expose that - // creates the FBO, and transfer() skips any frame where fboID() is - // still 0 and picks up the next one. + // No makeCurrent() here: a PartialUpdateBlit window's backing FBO only + // exists after the first paint, and binding it earlier crashes in Qt. // TWK_GLDEBUG; } void DesktopVideoDevice::close() { - // - // Before the view (and its GL context) goes away: the cached FBO - // clones can only be deleted while that context is alive. - // releaseFBOClones(); - // - // The device before the view that owns its context. - // - // ~GLVideoDevice deletes this device's GL text context, and - // ~GLTextContext deletes the FTGL fonts, which delete GL textures. - // Deleting the view first destroys the window and the context those - // textures live in, so those deletes reached nothing and the textures - // leaked -- every time presentation mode was switched off, not only - // at exit. releaseFBOClones() above has just made this device's - // context current, so in this order they land. - // + // The device first: its font textures live in the view's context. delete m_viewDevice; delete m_view; delete m_translator; @@ -275,18 +226,7 @@ namespace Rv m_viewDevice = 0; m_translator = 0; - // - // Hand the main view's context back before leaving. - // - // releaseFBOClones() above made *this* device's context current, and - // the deletes just destroyed it, so right now nothing is current at - // all. Whatever tears down GL objects next -- the renderer's own FBOs, - // later in this same shutdown -- would then run against no context, - // which is futile and reports errors nowhere near the cause. - // - // This is the restore that transfer() documents it relies on its - // callers to perform, done here for the teardown path. - // + // The current context was just destroyed; restore the main view's for later GL teardown. if (m_share) { m_share->makeCurrent(); @@ -301,37 +241,15 @@ namespace Rv void DesktopVideoDevice::makeCurrent() const { - // - // Route through the view device rather than the widget: the GL context - // belongs to the embedded ScreenWindow, and QTGLVideoDevice:: - // makeCurrent() knows how to bind it (and its default FBO). - // if (!m_viewDevice) { return; } // - // Not before the surface has produced its backing FBO. The GL surface - // here is a PartialUpdateBlit QOpenGLWindow, whose makeCurrent() binds - // that FBO, and Qt creates it on the first paint -- so calling this - // earlier crashes inside Qt on a null FBO rather than failing softly. - // - // fboID() is the cheap, null-safe way to ask whether the surface is - // ready (QOpenGLWindow::defaultFramebufferObject() returns 0 until the - // FBO exists). transfer() gates on the same test, so a frame that - // arrives too early is skipped rather than lost. - // - // A surface that is gone is a different matter from one that has not - // painted yet. fboID() is 0 for both, but with no platform window - // QTGLVideoDevice::makeCurrent() takes its offscreen path, which binds - // the context to a QOffscreenSurface and never touches that backing - // FBO -- so it cannot crash the way the pre-first-paint case can. - // Teardown needs exactly that path: releaseFBOClones() calls this - // before deleting the clones, and blocking it here is what left - // ~GLFBO running with no context at all while quitting. transfer() - // keeps the stricter test on purpose -- a vanished surface has nothing - // to present to, so skipping the frame there is right. + // Binding before the first paint crashes in Qt (no backing FBO yet). + // A destroyed surface is allowed through: makeCurrent() then uses the + // offscreen teardown surface, which releaseFBOClones() relies on. // const QTGLVideoDevice* glViewDevice = dynamic_cast(m_viewDevice); const QOpenGLWindow* surfaceWindow = glViewDevice ? glViewDevice->window() : nullptr; @@ -423,11 +341,7 @@ namespace Rv ScopedLock lock(m_mutex); - // - // Same readiness gate as transfer(): the GL surface has no backing FBO - // until it has painted once, and binding its context before then - // crashes inside Qt. Skip the frame; the next one will land. - // + // Same readiness gate as transfer(). if (m_viewDevice->fboID() == 0) { return; @@ -671,13 +585,7 @@ namespace Rv { GLFBO* cached = i->second; - // - // m_fboMap is keyed on the raw source pointer, and the renderer - // deletes and reallocates those (ImageRenderer::Device::clearFBOs - // / ImageFBOManager::newImageFBO), so the same address can come - // back as a different FBO. Re-verify the clone still mirrors the - // texture and size it was built from before trusting it. - // + // The renderer reallocates FBOs, so the same address can be a different FBO. const bool stillMatches = cached && cached->colorID(0) == sourceFbo->colorID(0) && cached->colorTarget(0) == sourceFbo->colorTarget(0) && cached->width() == sourceFbo->width() && cached->height() == sourceFbo->height(); @@ -692,12 +600,8 @@ namespace Rv } // - // Build the clone in this device's context around the source's colour - // texture. The texture belongs to the control context; if it has been - // destroyed since, attaching it leaves the FBO incomplete rather than - // failing loudly, so check before caching. Caching an incomplete clone - // is what turns a transient error into a permanently black output -- - // nothing ever invalidates it and every later frame blits from it. + // A dead source texture leaves the clone incomplete; never cache one, + // or every later frame would blit from it. // GLFBO* clone = new GLFBO(sourceFbo->width(), sourceFbo->height(), sourceFbo->primaryColorFormat()); @@ -705,10 +609,7 @@ namespace Rv if (!clone->isComplete()) { - // - // Latched per source texture: this is called every frame, and the - // interesting event is the transition, not the repetition. - // + // Report once per source texture; this runs every frame. const GLuint badTex = sourceFbo->colorID(0); if (m_reportedBadSourceTex != badTex) { @@ -718,15 +619,7 @@ namespace Rv << sourceFbo->height() << " FBO (source colour texture " << badTex << " is not usable in this context); skipping" << " until it changes." << endl; - // - // Which of the two possible causes this is: the texture does - // not exist any more (deleted, or never created because no - // context was current when the renderer made it), or it exists - // but in a context that does not share with ours. glIsTexture - // answers the first; the share-group pointers answer the - // second. Without this the two are indistinguishable from the - // outside and each costs a build-and-repro cycle to guess at. - // + // Distinguish a dead texture (glIsTexture) from a share-group mismatch. const QOpenGLContext* cur = QOpenGLContext::currentContext(); const QTGLVideoDevice* share = shareDevice(); const QOpenGLContext* shareCtx = share ? share->glShareContext() : nullptr; @@ -762,33 +655,9 @@ namespace Rv return; } - // - // ~GLFBO deletes the FBO handle, so a context has to be current or - // the delete is a no-op that also poisons glGetError() for the next - // unrelated call site. Make this device's view context current the - // same way transfer() does. - // - // If the view is already gone there is nothing to make current and - // the FBO names died with that context anyway; free the wrappers so - // the memory is not leaked and let the (harmless) GL no-ops happen. - // Phase-ordered callers never land here -- close() calls this before - // destroying the view -- so it is a backstop, not a normal path. - // - // - // Through the virtual, so each subclass binds its own surface -- and - // so the "surface not ready yet" guard in there applies. If it cannot - // make a context current there is nothing cached to free either: the - // clones are only ever created by a transfer() that got that far. - // + // Through the virtual so each subclass binds its own surface. makeCurrent(); - // - // Deleting these without a context current frees nothing -- the - // handles leak and ~GLFBO reports an error nowhere near the cause -- - // so say it rather than leaving it to be inferred from a stray - // GL_ERROR later. By construction this should not happen: close() - // calls us while the view is alive. - // if (!QOpenGLContext::currentContext()) { cerr << "WARNING: DesktopVideoDevice: '" << name() << "' released " << m_fboMap.size() @@ -1022,16 +891,6 @@ namespace Rv void DesktopVideoDevice::sortVideoFormatsByWidth() { sort(m_videoFormats.begin(), m_videoFormats.end(), widthSort); } DesktopVideoDevice::ScreenWindow::ScreenWindow(const QSurfaceFormat& fmt, QOpenGLContext* glShareContext) - // - // The share context goes in here, at construction, because that is the - // only point where sharing can be established -- QOpenGLContext ties - // its share group at create() time. A null share context leaves - // QOpenGLWindow to use Qt's global share context, which is also the - // renderer's group. - // - // PartialUpdateBlit keeps a backing FBO and does not clear before - // paintGL(); see the class comment. - // : QOpenGLWindow(glShareContext, QOpenGLWindow::PartialUpdateBlit) , m_glShareContext(glShareContext) { @@ -1043,16 +902,8 @@ namespace Rv QOpenGLWindow::initializeGL(); // - // Confirm the sharing actually happened. Everything the presentation - // output does depends on it and nothing else reports it: a mismatch is - // invisible from the outside and shows up only as a black second - // display. - // - // Note this deliberately does NOT call context()->setShareContext(). - // That only takes effect on the *next* create(), and the context is - // already created by the time initializeGL() runs, so it never did - // what it looked like it did -- it only left a stale share request - // behind for a later re-create to fail on. + // Verify the share group; a mismatch only shows as a black display. + // setShareContext() is useless here: the context is already created. // const QOpenGLContext* ours = context(); const QOpenGLContext* global = QOpenGLContext::globalShareContext(); @@ -1085,19 +936,10 @@ namespace Rv { m_glWindow = new ScreenWindow(fmt, glShareContext); - // - // Embed the native GL window in the widget tree, exactly as GLView - // does for the main view. The container is a plain QWidget, so this - // top-level is not forced onto the OpenGL RHI backend -- which is what - // put a top-level QOpenGLWidget in its own share group. - // + // A plain QWidget container keeps this top-level off the OpenGL RHI backing store. m_container = QWidget::createWindowContainer(m_glWindow, this); - // - // Realize the platform surface up front: a QOpenGLWindow has no GL - // context until its window surface exists, and the device primes the - // context (makeCurrent) during open() before this is ever shown. - // + // A QOpenGLWindow has no GL context until its platform surface exists. m_glWindow->create(); QVBoxLayout* layout = new QVBoxLayout(this); @@ -1189,20 +1031,9 @@ namespace Rv QWindow* windowOnTargetScreen = nullptr; // - // Find a window on the target screen to borrow a device context from, - // and consider only windows that are *already* realized. - // - // QWindow::winId() creates the platform window when there is none, - // and this loop walks every top-level QWindow in the process -- - // including ones that must never be realized. Every QQuickWidget, so - // every QWebEngineView panel and Live Review among them, owns a - // parentless offscreen QQuickWindow that Qt is explicit about ("Do - // not call create() on offscreenWindow", qquickwidget.cpp). Handing it - // a platform window trips Q_ASSERT(!d->offscreenWindow->handle()) at - // the end of QQuickWidget::createFramebufferObject() and aborts RV the - // moment that panel is first shown. Any already-realized window on the - // screen reports the same monitor profile, so there is nothing to gain - // by creating one. + // Only already-realized windows: winId() would create a platform window + // for QQuickWidget's offscreen QQuickWindow, which asserts in Qt + // ("Do not call create() on offscreenWindow", qquickwidget.cpp). // for (QWindow* window : QGuiApplication::topLevelWindows()) { @@ -1239,14 +1070,12 @@ namespace Rv DWORD maxLen = 2084; std::vector url(maxLen); - if (SUCCEEDED(UrlCreateFromPath(path.data(), url.data(), &maxLen, NULL))) + if (SUCCEEDED(UrlCreateFromPath(path.data(), url.data(), &maxLen, nullptr))) { m_colorProfile.url = url.data(); } - // cmsOpenProfileFromFile returns null when the path the driver - // reported is gone or unreadable; cmsGetProfileInfoASCII would - // dereference it. + // Null when the reported profile path is gone or unreadable. if (cmsHPROFILE profile = cmsOpenProfileFromFile(path.data(), "r")) { char desc[256] = {0}; @@ -1266,17 +1095,7 @@ namespace Rv bool DesktopVideoDevice::shouldUseVulkanPresentation() { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - // - // Presentation-output backend selection, matching the main view's rule - // in RvDocument: a 10-bit display request (RGB 10 + A 2) that this - // machine's Vulkan can actually present routes the second-display - // output through a Vulkan swapchain for true 10-bit, avoiding the - // 8-bit truncation of the OpenGL ScreenView path. Everything else - // stays on the OpenGL DesktopVideoDevice. - // - // supports10BitPresentation() is memoized, so this is cheap to re-call - // whenever the display output format or the main-view backend changes. - // + // Same rule as the main view in RvDocument. const Options& opts = Options::sharedOptions(); const bool want10bit = (opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2); diff --git a/src/lib/app/RvCommon/DesktopVideoModule.cpp b/src/lib/app/RvCommon/DesktopVideoModule.cpp index d545bda66..d428ec858 100644 --- a/src/lib/app/RvCommon/DesktopVideoModule.cpp +++ b/src/lib/app/RvCommon/DesktopVideoModule.cpp @@ -44,11 +44,6 @@ namespace Rv bool DesktopVideoModule::rebuildDevices(const QTGLVideoDevice* shareDevice, bool targetVulkan) { - // - // targetVulkan is the live main-view backend, decided by the caller. - // Compare it to the backend the current devices were built with. On - // platforms without Vulkan it is always false, so this is a no-op. - // #if !defined(PLATFORM_LINUX) && !defined(PLATFORM_WINDOWS) targetVulkan = false; #endif @@ -65,22 +60,12 @@ namespace Rv } #endif - // - // Backend unchanged: leave the devices in place so the second display - // does not go through a needless teardown. The caller still re-binds - // the share device on the existing devices. - // if (!m_devices.empty() && currentVulkan == targetVulkan) { return false; } - // - // Backend changed (or this is the first build after an empty list): - // release the old devices cleanly. close() frees the Vulkan swapchain - // or GL ScreenView before the device is destroyed, mirroring the - // normal exit path, so no swapchain or interop resources leak. - // + // close() releases the Vulkan swapchain or GL ScreenView before the delete. for (size_t i = 0; i < m_devices.size(); ++i) { if (m_devices[i]->isOpen()) diff --git a/src/lib/app/RvCommon/GLWindow.cpp b/src/lib/app/RvCommon/GLWindow.cpp index 5f031f368..89583b40d 100644 --- a/src/lib/app/RvCommon/GLWindow.cpp +++ b/src/lib/app/RvCommon/GLWindow.cpp @@ -35,14 +35,11 @@ namespace Rv { - // Accumulators for the -debug gpu frame-time report in paintGL(), - // mirroring VulkanWindow's so the two backends can be compared. + // -debug gpu frame-time accumulators, mirroring VulkanWindow's. static unsigned int s_glDiagFrames = 0; static double s_glDiagRenderMs = 0.0; static double s_glDiagOutPresentMs = 0.0; - // Wall clock between successive paintGL() entries: the loop period. For - // the GL path this includes the implicit buffer swap, which happens after - // paintGL returns. + // Time between paintGL() entries; includes the implicit swap after paintGL. static double s_glDiagLoopMs = 0.0; static TwkUtil::Timer s_glDiagLoopTimer; @@ -116,12 +113,7 @@ namespace Rv QSurfaceFormat f = context()->format(); - // - // One-shot -debug gpu baseline: everything needed to answer "why - // did I only get 8 bits per component" without a second run -- - // what was asked for, what Qt negotiated, what the driver is, and - // which display server we are on. - // + // One-shot -debug gpu baseline: requested vs negotiated format, driver and display server. static bool baselineLogged = false; if (ImageRenderer::debugGpu() && !baselineLogged) { @@ -129,7 +121,9 @@ namespace Rv QScreen* scr = screen(); if (!scr) + { scr = QGuiApplication::primaryScreen(); + } const GLubyte* glVendor = glGetString(GL_VENDOR); const GLubyte* glRenderer = glGetString(GL_RENDERER); @@ -231,11 +225,12 @@ namespace Rv } } - // See s_glDiagLoopTimer. if (IPCore::ImageRenderer::debugGpu()) { if (s_glDiagLoopTimer.isRunning()) + { s_glDiagLoopMs += s_glDiagLoopTimer.elapsed() * 1000.0; + } s_glDiagLoopTimer.start(); } @@ -263,17 +258,19 @@ namespace Rv m_videoDevice->setAbsolutePosition(x, y); TWK_GLDEBUG; - // Same breakdown as VulkanWindow, so the GL presentation path can - // be compared like-for-like. const bool diagTiming = IPCore::ImageRenderer::debugGpu(); Timer diagTimer; if (diagTiming) + { diagTimer.start(); + } session->render(); if (diagTiming) + { s_glDiagRenderMs += diagTimer.elapsed() * 1000.0; + } TWK_GLDEBUG; m_firstPaintCompleted = true; @@ -308,22 +305,22 @@ namespace Rv if (session->outputVideoDevice() != m_videoDevice) { if (diagPresent) + { diagPresentTimer.start(); + } session->outputVideoDevice()->syncBuffers(); if (diagPresent) + { s_glDiagOutPresentMs += diagPresentTimer.elapsed() * 1000.0; + } } session->addSyncSample(); session->postRender(); - // - // Note there is no "mainPresent" term here: QOpenGLWindow swaps the - // control surface implicitly after paintGL returns, so that cost lands - // outside this function. - // + // No mainPresent term: QOpenGLWindow swaps after paintGL returns. if (IPCore::ImageRenderer::debugGpu()) { if (++s_glDiagFrames >= 60) diff --git a/src/lib/app/RvCommon/MuUICommands.cpp b/src/lib/app/RvCommon/MuUICommands.cpp index e132c8612..c284fa8d5 100644 --- a/src/lib/app/RvCommon/MuUICommands.cpp +++ b/src/lib/app/RvCommon/MuUICommands.cpp @@ -1731,10 +1731,6 @@ namespace Rv MuLangContext* c = static_cast(p->context()); Session* s = Session::currentSession(); RvDocument* doc = reinterpret_cast(s->opaquePointer()); - // Use the neutral view-widget accessor: doc->view() is the GL-only - // m_glView, which is null on the Vulkan/Metal presentation path. Wrapping - // a null QWidget* here makes the Mu side (e.g. the Session Manager event - // filter) dereference null and crash. QWidget* w = doc->viewWidget(); const QWidgetType* type = c->findSymbolOfTypeByQualifiedName(c->internName("qt.QWidget"), false); diff --git a/src/lib/app/RvCommon/QTGLVideoDevice.cpp b/src/lib/app/RvCommon/QTGLVideoDevice.cpp index db0db2732..594a85fbd 100644 --- a/src/lib/app/RvCommon/QTGLVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTGLVideoDevice.cpp @@ -111,22 +111,13 @@ namespace Rv void QTGLVideoDevice::makeCurrent() const { - // - // The handle() test belongs in this condition, not nested inside it: - // QOpenGLWindow creates its GL context lazily on the first - // makeCurrent() and only if the platform window (surface) exists, so a - // live m_window with a dead surface can make nothing current. Nested, - // that case fell through every branch and returned silently. - // + // QOpenGLWindow creates its context lazily, and only if the platform surface exists. if (m_window && m_window->handle()) { m_window->makeCurrent(); TWK_GLDEBUG; - // - // Build the teardown surface now, while there is a live context to - // copy a compatible format from. See m_teardownSurface. - // + // Needs a live context to copy the format from. if (!m_teardownSurface && m_window->context()) { m_teardownSurface = new QOffscreenSurface(); @@ -156,34 +147,20 @@ namespace Rv else if (m_window && m_window->context() && m_teardownSurface && m_teardownSurface->isValid() && m_window->context()->makeCurrent(m_teardownSurface)) { - // - // The platform surface is gone but the context is not, so bind it - // to the offscreen surface instead. GL object deletion needs a - // current context, not a visible one, so this lets the teardown - // actually free what it is trying to free. - // + // Surface gone, context alive: GL deletion only needs a current context. TWK_GLDEBUG; } else { - // - // There is no surface left to make current. m_window is a - // QPointer, so it self-nulls once the QOpenGLWindow is destroyed, - // and m_view is null in the native-window port -- which means this - // function can quietly do nothing while its caller carries on - // believing it has a context. That is how GL teardown ends up - // running with no context at all. Say so once instead. - // + // Callers assume a current context afterwards, so report the failure once. static bool reported = false; if (!reported) { reported = true; cerr << "ERROR: QTGLVideoDevice::makeCurrent: '" << name() << "' cannot make a context current (window=" - << (!m_window ? "destroyed" : (m_window->handle() ? "alive" : "no surface")) << " widget=" << (m_view ? "alive" : "null") + << (!m_window ? "destroyed" : (m_window->handle() ? "alive" : "no surface")) + << " widget=" << (m_view ? "alive" : "null") << " currentContext=" << (QOpenGLContext::currentContext() ? "yes" : "none") - // Whether the context outlives the surface decides if a - // QOffscreenSurface could be used to make it current for - // teardown, the way QTVulkanVideoDevice already does. << " ownContext=" << (m_window && m_window->context() ? "alive" : "null") << "); the caller's GL work has no current context" << endl; } diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 737d8a34e..945afa541 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -86,13 +86,9 @@ #endif #ifdef PLATFORM_WINDOWS -// The bundled GLEW under src/pub/glew (version 2.3.0) does not declare the -// EXT_memory_object / EXT_semaphore (or their Win32 companions) entry points. -// The Linux build uses a newer managed GLEW that does, so the Linux call sites -// can resolve the symbols at link time. On Windows we declare the function -// pointer typedefs locally and resolve them at first use via wglGetProcAddress; -// if any are missing the GPU interop path is disabled and VulkanWindow falls -// back to its CPU pack-and-upload presentation path. +// The bundled Windows GLEW (2.3.0) does not declare the EXT_memory_object / +// EXT_semaphore (+ _win32) entry points, so resolve them via wglGetProcAddress. +// If any are missing, presentation falls back to the CPU path. typedef void(GLAPIENTRY* PFNGLCREATEMEMORYOBJECTSEXTPROC_RV)(GLsizei n, GLuint* memoryObjects); typedef void(GLAPIENTRY* PFNGLDELETEMEMORYOBJECTSEXTPROC_RV)(GLsizei n, const GLuint* memoryObjects); typedef void(GLAPIENTRY* PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV)(GLuint memoryObject, GLenum pname, const GLint* params); @@ -127,7 +123,9 @@ namespace bool loadGLInteropExtensions() { if (g_glInteropProbed) + { return g_glInteropAvailable; + } g_glInteropProbed = true; g_glCreateMemoryObjectsEXT = reinterpret_cast(wglGetProcAddress("glCreateMemoryObjectsEXT")); @@ -149,16 +147,8 @@ namespace && g_glDeleteSemaphoresEXT && g_glImportSemaphoreWin32HandleEXT && g_glWaitSemaphoreEXT && g_glSignalSemaphoreEXT; - // Identify the GL driver alongside the interop probe result. Useful when - // the Windows GL context happens to be the Microsoft GDI Generic - // software renderer, in which case interop is expected to fail. - // - // Unconditional, and this probe runs at most once per process. Which GL - // driver the offscreen context landed on is half of any interop - // diagnosis -- the other half is the Vulkan device name logged by - // initVulkan() -- and it has to be in the log of a session that was not - // launched with -debug gpu. - // + // Unconditional (runs once): identifies the GL driver, e.g. the GDI + // Generic software renderer, which cannot do interop. const GLubyte* vendor = glGetString(GL_VENDOR); const GLubyte* renderer = glGetString(GL_RENDERER); const GLubyte* version = glGetString(GL_VERSION); @@ -205,9 +195,6 @@ namespace Rv namespace { // Forces the CPU pack-and-upload present path regardless of GPU/driver. - // Useful for exercising the fallback, which the NVIDIA optimal-tiling fix - // otherwise makes rare. Read once. Platform-neutral (unlike the Windows-only - // GL-interop entry-point helpers above). bool forceCpuPresentation() { static const bool forced = getenv("RV_VULKAN_FORCE_CPU_PRESENT") != nullptr; @@ -226,7 +213,6 @@ namespace Rv QTVulkanVideoDevice::~QTVulkanVideoDevice() { - // Delete the FBO and its colour texture while the GL context is current. if (m_glContext && (m_fbo || m_fboColorTex || m_glMemoryObject[0] || m_cpuFlipFbo)) { m_glContext->makeCurrent(m_offscreenSurface); @@ -237,8 +223,10 @@ namespace Rv glDeleteTextures(1, &m_fboColorTex); m_fboColorTex = 0; } - for (uint32_t i = 0; i < VulkanWindow::FRAMES_IN_FLIGHT; ++i) + for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) + { cleanupSharedGLObjects(i); + } cleanupCpuFallbackTarget(); m_glContext->doneCurrent(); } @@ -273,11 +261,8 @@ namespace Rv m_glContext = new QOpenGLContext(); m_glContext->setFormat(fmt); - // Join RV's global GL resource-sharing group (enabled via - // Qt::AA_ShareOpenGLContexts at startup). Without this the offscreen - // context is isolated and FTGL font-atlas textures created in - // another context have no storage here, so glyph uploads fail with - // GL_INVALID_OPERATION. + // Join the global share group, or FTGL font-atlas textures created + // elsewhere have no storage here and glyph uploads fail. m_glContext->setShareContext(QOpenGLContext::globalShareContext()); if (!m_glContext->create()) @@ -305,11 +290,8 @@ namespace Rv m_glContext->makeCurrent(m_offscreenSurface); glewExperimental = GL_TRUE; #ifdef PLATFORM_WINDOWS - // The bundled Windows GLEW (src/pub/glew) has a Tweak-modified - // signature: glewInit(GLEWGetProcAddress F). Pass nullptr to use - // the default GL entry-point loader, matching every other Windows - // glewInit call site (rvio main.cpp, InitGL.cpp, FBOVideoDevice.cpp, - // NDIModule.cpp, BlackMagicModule.cpp, AJAModule.cpp). + // The bundled Windows GLEW's glewInit takes a loader; nullptr + // selects the default, as at the other Windows call sites. GLenum err = glewInit(nullptr); #else GLenum err = glewInit(); @@ -336,9 +318,13 @@ namespace Rv int newW = m_window ? static_cast(m_window->width() * dpr + 0.5f) : 128; int newH = m_window ? static_cast(m_window->height() * dpr + 0.5f) : 128; if (newW < 1) + { newW = 128; + } if (newH < 1) + { newH = 128; + } if (!m_fbo || m_fboWidth != newW || m_fboHeight != newH) { @@ -350,25 +336,9 @@ namespace Rv m_fboColorTex = 0; } - // - // RGBA16F for the control viewport, RGB10_A2 for a passive - // presentation output. - // - // The control viewport needs the half-float depth: session->render() - // composites the whole main view into this FBO across multiple - // blended passes. A passive output never does -- it is only ever a - // blit destination for the inherited - // transfer()/transfer2()/fillWithTexture(), which hand over an - // already-composited frame. - // - // Keeping it at 16F there costs two full passes' worth of bandwidth - // at a 4K output: transfer() writes 8 bytes/px (66 MB) and - // syncBuffers() reads all of it back to convert down to the - // 10-bit shared image. Matching the shared image's format halves - // both, and the conversion happens once, in the blit that was - // already going to run. The final output is 10-bit either way, so - // no precision is lost that the present did not already discard. - // + // The control viewport composites blended passes here, so it needs + // RGBA16F. A passive output only receives already-composited blits, + // so RGB10_A2 (the shared image's format) halves its bandwidth. const bool passiveOutput = m_window && m_window->isPassiveOutput(); const GLenum fboFormat = passiveOutput ? GL_RGB10_A2 : GL_RGBA16F_ARB; @@ -389,7 +359,9 @@ namespace Rv GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT); if (status != GL_FRAMEBUFFER_COMPLETE_EXT) + { cerr << "ERROR: QTVulkanVideoDevice: FBO incomplete: 0x" << hex << status << dec << endl; + } m_fboWidth = newW; m_fboHeight = newH; @@ -428,12 +400,18 @@ namespace Rv if (refresh != m_refresh) { if (refresh > 0) + { m_refresh = refresh; + } else if (IPCore::debugPlayback) + { cout << "WARNING: ignoring intended desktop refresh rate = " << refresh << endl; + } if (IPCore::debugPlayback) + { cout << "INFO: new desktop refresh rate " << m_refresh << endl; + } } } m_x = x; @@ -448,20 +426,26 @@ namespace Rv static bool noQtHighDPISupport = (getenv("RV_NO_QT_HDPI_SUPPORT") != nullptr); if (noQtHighDPISupport) + { return; + } if (const DesktopVideoDevice* desktopDev = dynamic_cast(d)) { const QList screens = QGuiApplication::screens(); if (desktopDev->qtScreen() < screens.size()) + { m_devicePixelRatio = screens[desktopDev->qtScreen()]->devicePixelRatio(); + } } } float QTVulkanVideoDevice::devicePixelRatio() const { if (m_window) + { return static_cast(m_window->devicePixelRatioF()); + } return m_devicePixelRatio; } @@ -527,7 +511,7 @@ namespace Rv } m_glContext->makeCurrent(m_offscreenSurface); - for (uint32_t i = 0; i < VulkanWindow::FRAMES_IN_FLIGHT; ++i) + for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) { cleanupSharedGLObjects(i); } @@ -537,7 +521,9 @@ namespace Rv void QTVulkanVideoDevice::ensureCpuFallbackTarget(int w, int h) const { if (m_cpuFlipFbo && m_cpuFlipWidth == w && m_cpuFlipHeight == h) + { return; + } cleanupCpuFallbackTarget(); @@ -584,14 +570,11 @@ namespace Rv return false; } - // Drain the rest so the next step starts from a clean queue and cannot - // be blamed for this one's error. + // Drain the rest so the next step starts from a clean queue. while (glGetError() != GL_NO_ERROR) { } - // Unconditional: this is the message that turns an undiagnosable black - // viewport into a named failing call. cerr << "ERROR: QTVulkanVideoDevice: " << what << " failed (GL 0x" << hex << first << dec << "); demoting '" << name() << "' to CPU presentation." << endl; @@ -603,9 +586,13 @@ namespace Rv bool QTVulkanVideoDevice::glDeviceMatchesVulkan() const { if (m_glVulkanDeviceMatch != -1) + { return m_glVulkanDeviceMatch == 1; + } if (!m_glContext || !m_window || !m_window->isInitialized()) + { return false; + } using GetUnsignedByteIndexedProc = void(GLAPIENTRY*)(GLenum, GLuint, GLubyte*); const auto getUnsignedByteIndexed = @@ -613,7 +600,9 @@ namespace Rv GLint deviceCount = 0; if (getUnsignedByteIndexed) + { glGetIntegerv(GL_NUM_DEVICE_UUIDS_EXT, &deviceCount); + } bool matched = false; for (GLint i = 0; i < deviceCount && !matched; ++i) @@ -635,26 +624,19 @@ namespace Rv { TwkGLF::GLFBO* fbo = m_fbo; - // Pack in the swapchain's channel order. glReadPixels with - // GL_UNSIGNED_INT_2_10_10_10_REV packs A2B10G10R10 (R low) for GL_RGBA and - // A2R10G10B10 (R high) for GL_BGRA, so the read format selects the layout - // directly with no CPU conversion. Linux/RADV surfaces commonly offer only - // A2R10G10B10. + // With GL_UNSIGNED_INT_2_10_10_10_REV, GL_RGBA packs A2B10G10R10 and + // GL_BGRA packs A2R10G10B10, so the read format matches the swapchain. const VkFormat scFmt = m_window ? m_window->swapchainFormat() : VK_FORMAT_A2B10G10R10_UNORM_PACK32; const GLenum readFormat = (scFmt == VK_FORMAT_A2R10G10B10_UNORM_PACK32) ? GL_BGRA : GL_RGBA; ensureCpuFallbackTarget(w, h); - // Y-flip blit (GL bottom-left -> Vulkan top-left) into the RGB10_A2 target, - // so glReadPixels below reads top-down and packs to the swapchain layout. + // Y-flip (GL bottom-left to Vulkan top-left) into the RGB10_A2 target. glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, fbo->fboID()); glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_cpuFlipFbo); glBlitFramebufferEXT(0, 0, w, h, 0, h, w, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, m_cpuFlipFbo); - // GL-packed readback: glReadPixels stalls until the flip blit finishes, but - // the driver packs directly to the swapchain bit layout, so there is no - // per-pixel CPU pack loop. m_cpuPackedScratch.resize(static_cast(w) * h); glReadPixels(0, 0, w, h, readFormat, GL_UNSIGNED_INT_2_10_10_10_REV, m_cpuPackedScratch.data()); m_window->presentPixelData(m_cpuPackedScratch.data(), w, h); @@ -669,15 +651,17 @@ namespace Rv void QTVulkanVideoDevice::syncBuffers() const { if (!m_window) + { return; + } if (!m_glContext || !m_fbo) + { return; + } - // Pair this frame's GL ring objects with the Vulkan in-flight slot the - // frame renders into. getSharedImageInfo()/presentSharedImage() below use - // this same slot; presentSharedImage() advances it only at frame end, so - // the value is stable for the whole call. + // The Vulkan in-flight slot; presentSharedImage() advances it only at + // frame end, so it is stable for the whole call. const uint32_t slot = m_window->currentFrame(); TwkGLF::GLFBO* fbo = m_fbo; @@ -685,36 +669,29 @@ namespace Rv const int h = static_cast(fbo->height()); if (w <= 0 || h <= 0) + { return; + } - // A presentation device can be primed before its window receives the - // first expose event. There is no swapchain to present to yet; wait for - // exposeEvent(), which initializes Vulkan and calls syncBuffers() again. + // No swapchain before the first expose; exposeEvent() calls us again. if (!m_window->isInitialized()) + { return; + } - // - // Best-effort gate for a passive presentation output: skip the whole - // frame while this device's own GPU work is still in flight, rather - // than queueing another full-resolution blit behind it. Checked here, - // ahead of the GL work below, so a skipped frame costs nothing -- the - // blit into the shared image is the single most expensive thing in - // this function at a 4K output. Always true for the control viewport. - // + // Skip the frame, before any GL work, while a passive output's previous + // GPU work is still in flight. Always true for the control viewport. if (m_window->isPassiveOutput() && !m_window->canPresentNow()) + { return; + } if (!m_glContext->makeCurrent(m_offscreenSurface)) + { return; + } - // Get shared image info from VulkanWindow. RV_VULKAN_FORCE_CPU_PRESENT skips - // interop entirely so getSharedImageInfo() never allocates a shared image - // and the CPU fallback below runs. #ifdef PLATFORM_WINDOWS - // Probe the EXT_memory_object/EXT_semaphore (+ _win32) entry points - // while the GL context is current. If the driver does not expose them, - // skip the Vulkan-side export work entirely and fall through to the - // CPU pack-and-upload path below. const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && loadGLInteropExtensions() && glDeviceMatchesVulkan(); const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; @@ -724,16 +701,8 @@ namespace Rv const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; #endif - // - // Unconditional, and re-reported on every transition. Which of the two - // present paths a device ended up on is the first thing needed to place - // a black or mis-rendered viewport, and the report has to survive a - // session that was not launched with -debug gpu -- the only kind we get - // back from QA. A first-frame-only latch was actively misleading: the - // first syncBuffers() can run before the swapchain exists, so it - // reported CPU-fallback / UNDEFINED for a device that then ran on - // interop for the rest of the session. - // + // Logged on every transition: the first call can precede swapchain + // creation. const int presentPath = sharedInfo ? 1 : 0; if (m_loggedPresentPath != presentPath) { @@ -750,15 +719,6 @@ namespace Rv if (!sharedInfo) { - // No zero-copy interop this frame: pack + present via the CPU fallback. - // The GL-packed RGB10_A2 readback handles the Y flip and the swapchain - // channel order (A2B10G10R10 / A2R10G10B10) without a per-pixel loop. - // - // Report the specific reason so the startup record is conclusive - // for someone reading only a log: a bare "CPU fallback" does not - // say whether interop was forced off, demoted by an earlier GL - // failure, unavailable in the GL driver, or refused by the - // Vulkan-side capability probe. std::string reason; if (forceCpuPresentation()) { @@ -787,11 +747,8 @@ namespace Rv return; } - // Re-import only when the shared image was actually reallocated, i.e. its - // capacity (stride width + capacity height) changed. Within capacity the - // Vulkan side keeps the same export, so a resize does not re-import here; - // m_sharedWidth/m_sharedHeight cache the imported capacity, not the used - // (requested) size. + // Re-import only when the shared image's capacity changed; m_sharedWidth + // and m_sharedHeight cache the imported capacity, not the used size. if (m_sharedWidth[slot] != sharedInfo->strideWidth || m_sharedHeight[slot] != sharedInfo->capacityHeight || !m_glMemoryObject[slot]) { cleanupSharedGLObjects(slot); @@ -804,23 +761,15 @@ namespace Rv glCreateMemoryObjectsEXT(1, &m_glMemoryObject[slot]); - // EXT_memory_object requires both sides to agree on whether the - // allocation is dedicated, and the parameter has to be set before - // the import. This follows whatever the Vulkan side allocated. - // Set explicitly in both directions rather than only when - // dedicated: a mismatch corrupts the image rather than raising an - // error, so the value is stated instead of left at a default. + // Both sides must agree on dedicated allocation before the import; + // a mismatch corrupts the image silently, so always set it. { const GLint dedicated = sharedInfo->dedicatedAllocation ? GL_TRUE : GL_FALSE; glMemoryObjectParameterivEXT(m_glMemoryObject[slot], GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); } #ifdef PLATFORM_WINDOWS - // Windows GL import does NOT take ownership of the HANDLE; the - // Vulkan side and this GL side each keep their own reference. - // VulkanWindow's cleanupSharedImage() calls CloseHandle on its - // copy; this device's cleanupSharedGLObjects() does not need to - // close anything because glImportMemoryWin32HandleEXT does not - // create a new handle. + // The Win32 import does not take ownership of the HANDLE; + // VulkanWindow::cleanupSharedImage() closes it. glImportMemoryWin32HandleEXT(m_glMemoryObject[slot], sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->memoryHandle)); #else @@ -838,16 +787,12 @@ namespace Rv glGenTextures(1, &m_glSharedTexture[slot]); glBindTexture(GL_TEXTURE_2D, m_glSharedTexture[slot]); - // Import with the tiling the Vulkan side actually created the image - // with. Importing OPTIMAL-tiled memory as LINEAR leaves the - // image's large-scale structure recognizable but scrambles pixels - // within each tile. + // Must match the Vulkan image's tiling, or pixels scramble within + // each tile. glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, sharedInfo->tiling == VK_IMAGE_TILING_OPTIMAL ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); - // Allocate the imported texture at the image's capacity dimensions - // (stride width x capacity height); the FBO blit below writes only the - // used w x h sub-region into its origin corner. + // Allocated at capacity; the blit below writes only the used w x h. glTexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGB10_A2, sharedInfo->strideWidth, sharedInfo->capacityHeight, m_glMemoryObject[slot], 0); glBindTexture(GL_TEXTURE_2D, 0); @@ -879,16 +824,11 @@ namespace Rv glImportSemaphoreFdEXT(m_vkReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_FD_EXT, vkReadyFd); #endif - // Nothing above reports failure through a return value. Without - // this check a rejected import leaves an incomplete texture, the - // blit below is silently dropped, and Vulkan presents an image - // that was never written -- a black viewport with no diagnostic. + // The import calls above report failure only through glGetError(). if (interopGLFailed("GL<->Vulkan shared image import")) { - // Every slot, not just this one: interop is off for good now, - // so the other ring slot's import would otherwise sit there - // until the device is destroyed. - for (uint32_t s = 0; s < VulkanWindow::FRAMES_IN_FLIGHT; ++s) + // Interop is now off for good, so release every slot. + for (uint32_t s = 0; s < VulkanWindow::kFramesInFlight; ++s) { cleanupSharedGLObjects(s); } @@ -897,39 +837,23 @@ namespace Rv return; } - // Cache the imported capacity so we re-import only when it grows. m_sharedWidth[slot] = sharedInfo->strideWidth; m_sharedHeight[slot] = sharedInfo->capacityHeight; - // Let the Vulkan side's startup record show what GL actually - // imported, so the two sides can be compared in one place rather - // than only what Vulkan intended being visible. m_window->reportGLImportState(sharedInfo->tiling, sharedInfo->dedicatedAllocation); } - // Import succeeded (or was already valid from a previous frame): this - // frame presents zero-copy. Reported here rather than before the - // import so the record reflects the path actually taken. m_window->reportPresentPath(VulkanWindow::PresentPath::ZeroCopy, std::string()); - // Drain before the handshake, not after. - // - // session->render() runs earlier in this same frame and does leave - // errors pending -- that is what the "GL ERROR: *BEFORE* userRender" - // report exists to surface. Checking glGetError() after the blit - // without clearing first would attribute an unrelated error to the - // interop path and permanently demote a working device to the CPU - // fallback. Clear here so the check below sees only errors produced by - // the wait/blit/signal sequence itself. + // session->render() can leave errors pending; drain them so the check + // below sees only the wait/blit/signal sequence's errors. while (glGetError() != GL_NO_ERROR) { } - // Wait for Vulkan to be ready GLuint waitSrcLayouts[] = {GL_LAYOUT_TRANSFER_SRC_EXT}; glWaitSemaphoreEXT(m_vkReadySemaphore[slot], 0, nullptr, 1, &m_glSharedTexture[slot], waitSrcLayouts); - // Blit from FBO to shared texture GLuint readFbo = fbo->fboID(); if (!m_drawFbo[slot]) { @@ -944,24 +868,19 @@ namespace Rv glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, readFbo); - // Note: GL origin is bottom-left, Vulkan origin is top-left. We need to flip Y. + // Flip Y: GL origin is bottom-left, Vulkan origin is top-left. glBlitFramebufferEXT(0, 0, w, h, 0, h, w, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, readFbo); // restore - // Signal Vulkan that GL is done GLuint signalDstLayouts[] = {GL_LAYOUT_COLOR_ATTACHMENT_EXT}; glSignalSemaphoreEXT(m_glReadySemaphore[slot], 0, nullptr, 1, &m_glSharedTexture[slot], signalDstLayouts); glFlush(); - // Same reasoning as the import check: the semaphore wait/signal and the - // blit all fail silently. Catching it here means a driver that refuses - // the handshake mid-session degrades to the CPU path with a named cause - // instead of going black. if (interopGLFailed("GL<->Vulkan shared image blit")) { - for (uint32_t s = 0; s < VulkanWindow::FRAMES_IN_FLIGHT; ++s) + for (uint32_t s = 0; s < VulkanWindow::kFramesInFlight; ++s) { cleanupSharedGLObjects(s); } @@ -969,7 +888,6 @@ namespace Rv return; } - // Tell VulkanWindow to present m_window->presentSharedImage(); } @@ -979,9 +897,7 @@ namespace Rv void QTVulkanVideoDevice::redraw() const { - // QWindow::requestUpdate() posts a coalesced UpdateRequest: at most one - // render is queued at a time, so a burst of redraw requests collapses - // to a single render instead of one heavy present per request. + // requestUpdate() coalesces, so a burst of redraws renders once. if (m_window) { m_window->requestUpdate(); diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index c91bdfc1c..60112d3ae 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -867,8 +867,7 @@ namespace Rv if (videoModules().empty()) { - // With a non-OpenGL presentation backend view() returns null — no - // GL context to make current; presentation handles it per-frame. + // view() is null on a non-OpenGL backend. if (doc->view()) { doc->view()->makeCurrent(); @@ -876,10 +875,7 @@ namespace Rv try { - // With a non-OpenGL presentation backend view() is null — pass - // nullptr as the GL share device. DesktopVideoDevice can still - // be created; it only needs the share device when open() is - // called later. + // DesktopVideoDevice only needs the share device at open(). QTGLVideoDevice* shareDevice = doc->view() ? doc->view()->videoDevice() : nullptr; addVideoModule(m_desktopModule = new DesktopVideoModule(0, shareDevice)); } @@ -907,7 +903,6 @@ namespace Rv // we're on (video device) so make sure the primary display group is // correct. // - // Use the session's control device — valid for any presentation backend. doc->session()->graph().setPrimaryDisplayGroup(doc->session()->controlVideoDevice()); if (RvApp()->documents().size() == 1 && opts.present) @@ -959,8 +954,6 @@ namespace Rv if (!m->isOpen()) { RvDocument* doc = reinterpret_cast(documents().front()->opaquePointer()); - // With a non-OpenGL presentation backend view() is null — no GL - // context to make current. if (doc->view()) { doc->view()->makeCurrent(); @@ -1709,7 +1702,6 @@ namespace Rv #endif string optionArgs = setVideoDeviceStateFromSettings(d); - // With a non-OpenGL presentation backend view() is null — skip GL makeCurrent. if (rvDoc->view()) { rvDoc->view()->videoDevice()->makeCurrent(); @@ -1804,17 +1796,9 @@ namespace Rv const bool separateOutput = d && d != session->controlVideoDevice(); // - // Unbind before closing, never the other way round. close() - // destroys the device's view and with it the GL context that owns - // every FBO the renderer cloned for that device. Dropping the - // session's reference first means ImageRenderer::setOutputDevice() - // -- which calls unbind() on the outgoing device -- still runs - // while that context is alive. Closing first left it deleting GL - // objects with no context current, which is undiagnosable from the - // outside: it shows up as GL_INVALID_OPERATION at innocent call - // sites and leaves a permanently incomplete FBO cached in the - // device, i.e. a black presentation output for the rest of the - // session. + // Unbind before closing: close() destroys the GL context owning the + // FBOs the renderer cloned for this device, and unbind() must + // release them while it is still alive. // session->setOutputVideoDevice(session->controlVideoDevice()); @@ -1826,27 +1810,11 @@ namespace Rv #endif // - // Put the main view's context back, whatever backend it is. + // close() leaves no context current, and DesktopVideoDevice + // cannot restore one when the main view is Vulkan (its share + // device is null). Make the main view's context current. // - // close() destroyed the presentation device's view and its - // context, so nothing is current on return. DesktopVideoDevice - // tries to restore from its share device, but that is a - // QTGLVideoDevice and is null whenever the main view is - // Vulkan -- so on the Vulkan path nothing was made current at - // all, and the next code to touch GL did so against no - // context. That is not a teardown path, so GLContextScope does - // not cover it: it surfaced as ImageRenderer's - // queryGLIntoContainer() reading an empty GL_VERSION (which is - // what prints the bogus "Could not retrieve OpenGL version. - // Make sure you have installed the Nvidia drivers.") and as a - // no-context report on the next entry into this function. - // - // Do it here rather than inside close(): the session knows its - // control device, both backends derive from GLVideoDevice, and - // this is the moment the device is authoritative. - // - if (const TwkGLF::GLVideoDevice* mainView = - dynamic_cast(session->controlVideoDevice())) + if (const TwkGLF::GLVideoDevice* mainView = dynamic_cast(session->controlVideoDevice())) { mainView->makeCurrent(); } @@ -1991,34 +1959,21 @@ namespace Rv void RvApplication::rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice, bool mainViewIsVulkan) { if (!m_desktopModule) + { return; + } - // - // Capture the presentation state before any teardown. The selected - // screen is captured implicitly: it is Options::presentDevice, a - // stable device name re-resolved after the rebuild via - // findPresentationDevice, so we restore by name rather than by a - // pointer the rebuild may have destroyed. - // + // The selected screen is restored by name (Options::presentDevice) + // since the rebuild may destroy the device it pointed to. const bool wasPresenting = m_presentationMode; TwkApp::Document* doc = TwkApp::Document::activeDocument(); Rv::Session* session = doc ? static_cast(doc) : nullptr; - // - // Rebuild the per-screen devices for the current backend. This is a - // no-op returning false when the backend has not changed; the - // share-device rebind below still runs, so a main-view swap that keeps - // the same backend is honored. - // + // Returns false when the backend is unchanged; the share device is + // still rebound below. const bool rebuilt = m_desktopModule->rebuildDevices(shareDevice, mainViewIsVulkan); - // - // Re-bind the controller's current main-view device as the share - // device on every (possibly newly created) desktop device. This runs - // after the rebuild so it never writes to an about-to-be-destroyed - // device. - // const VideoModule::VideoDevices& devices = m_desktopModule->devices(); for (size_t i = 0; i < devices.size(); i++) { @@ -2029,28 +1984,10 @@ namespace Rv } // - // Refresh the session graph's per-physical-device display-group - // registry so it references the newly created device pointers. - // rebuildDevices() deleted the old per-screen devices and - // createDesktopVideoDevices() made new ones, but the graph's - // DisplayGroupIPNodes still hold the destroyed pointers. Without this, - // a later setOutputVideoDevice(newDevice) -> connectDisplayGroup -> - // findDisplayGroupByDevice(newDevice) matches nothing and silently - // no-ops, so the presentation output is never rendered and the second - // display stays black. - // - // This must run on every real rebuild even when presentation is - // currently off: the device pointers can change while presentation is - // disabled and only be bound as the output later (the reported - // 10 -> 8 -> 10 -> enable repro). - // - // refreshPhysicalDevices(), not setPhysicalDevices(): the latter is the - // startup path and deletes every display group, which also throws away - // its colour pipeline. Doing that here reset the main view's transfer - // function from sRGB to None on every 8/10-bit switch, along with any - // assigned display profile. The monitors have not changed at this - // point -- only the device objects in front of them -- so the groups - // should be re-pointed, not rebuilt. + // Re-point the graph's display groups at the new device objects, even + // with presentation off, or a later setOutputVideoDevice() finds no + // group. refreshPhysicalDevices() keeps each group's colour pipeline, + // unlike setPhysicalDevices(). // if (rebuilt && session) { @@ -2058,17 +1995,11 @@ namespace Rv session->graph().setPrimaryDisplayGroup(session->controlVideoDevice()); } - // - // Re-establish the presentation output. The backend-transition callers - // reset the session output device to 0 while rebinding the control - // device, which is the root of the black second display. If - // presentation mode is on, re-open the presentation output on the - // selected screen with the new backend and bind it as the session - // output; a backend change also destroyed the old device, so this - // replaces any stale reference. - // + // The callers reset the session output device, so re-open and re-bind it. if (!wasPresenting || !session) + { return; + } Rv::Options& opts = Rv::Options::sharedOptions(); VideoDevice* d = findPresentationDevice(opts.presentDevice); diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index bd6a0b6c8..1aff26001 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -56,33 +56,10 @@ namespace Rv { public: // - // The GL surface the presentation output composites into. - // - // This is a QOpenGLWindow rather than a QOpenGLWidget on purpose. The - // whole transfer() design depends on this context sharing with the - // renderer's: transfer() wraps the renderer's output FBO colour - // texture in a local FBO (see cloneForSource), and a texture is only - // visible across contexts in the same share group. - // - // A *top-level* QOpenGLWidget does not give us that. In Qt 6 it is - // composited through its own top-level window's RHI backing store and - // takes that window's GL context as its share parent, not the - // application's global share context -- so it can land in a private - // share group, and the renderer's textures then do not exist as far as - // it is concerned (glIsTexture() false for a live texture), every - // transfer() is refused and the second display stays black. Whether it - // happened to land in the right group varied run to run, which is what - // made the black presentation output intermittent. - // - // QOpenGLWindow takes the context to share with as a constructor - // argument, before the context is created -- the only point at which - // sharing can be established. This mirrors GLView/GLWindow, which is - // the main view and demonstrably sits in the renderer's group. - // - // PartialUpdateBlit, not the default NoPartialUpdate: it keeps a - // backing FBO (so QTGLVideoDevice::fboID() is non-zero, which - // transfer() requires) and does not clear before paintGL(), which - // would erase the pixels transfer() just blitted in. + // QOpenGLWindow, not a top-level QOpenGLWidget: only a ctor-supplied + // share context guarantees the renderer's share group, which + // transfer() needs to see the renderer's textures. PartialUpdateBlit + // keeps a backing FBO and does not clear before paintGL(). // class ScreenWindow : public QOpenGLWindow { @@ -96,12 +73,7 @@ namespace Rv QOpenGLContext* m_glShareContext = nullptr; }; - // - // Plain QWidget container holding the ScreenWindow, so the device can - // keep driving the output through the QWidget API it already uses - // (move/setGeometry/setWindowState/show/fullscreen on a given screen). - // Same arrangement as GLView around GLWindow. - // + // QWidget container for the ScreenWindow, as GLView is for GLWindow. class ScreenView : public QWidget { public: @@ -228,31 +200,16 @@ namespace Rv virtual void clearCaches() const { releaseFBOClones(); } // - // Delete the per-context FBO clones in m_fboMap. - // - // These alias textures owned by the renderer's control context, so - // they must be destroyed while *this* device's view context is still - // alive and current -- deleting them afterwards issues GL calls with - // no context current. Callers that are about to tear the view down - // (close()) must therefore call this first. Safe to call repeatedly - // and safe to call when nothing was ever cached. + // Delete the FBO clones in m_fboMap. Must run while this device's view + // context is still alive, so close() calls it first. Idempotent. // void releaseFBOClones() const; // - // Return this context's clone of a source FBO owned by the renderer's - // control context, creating and caching it on first use. - // - // FBOs are not shared between contexts but textures are, so the clone - // wraps the source's colour texture. Two things make that fragile and - // are handled here: the cache is keyed on the source pointer, which - // the renderer frees and reallocates (so a cached clone is re-verified - // against the source it is meant to mirror), and the borrowed texture - // name can be dead by the time we attach it (so an incomplete clone is - // discarded instead of cached and blitted from every frame). - // - // Returns null if no usable clone could be built; callers must skip - // the transfer for this frame. + // This context's cached clone of a renderer FBO, wrapping its colour + // texture (FBOs are not shared across contexts, textures are). The + // cache key is a pointer the renderer can reuse, so hits are + // re-verified. Returns null if no complete clone could be built. // TwkGLF::GLFBO* cloneForSource(const TwkGLF::GLFBO* sourceFbo) const; @@ -318,33 +275,15 @@ namespace Rv static std::vector createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice); - // - // As above, but with the backend decided by the caller rather than - // re-derived from the persisted display-depth preference. Use this - // whenever the main view is already live: its backend is the ground - // truth, and the preference can disagree with it (see - // shouldUseVulkanPresentation). - // + // As above, with the backend supplied by the caller. Use once the main view is live. static std::vector createDesktopVideoDevices(TwkApp::VideoModule* module, const QTGLVideoDevice* shareDevice, bool useVulkan); // - // Effective presentation-backend decision for the *initial* build, - // when there is no main view yet to ask. True when the second-display - // output should be delivered through a Vulkan swapchain -- a 10-bit - // request that this machine's Vulkan can actually present -- false for - // the OpenGL ScreenView path. Always false on macOS. - // - // The underlying VulkanView::supports10BitPresentation() probe is - // memoized, so this is cheap to call. - // - // NOTE: this reads the persisted intent in Options, which is NOT the - // same thing as the backend the main view is actually running. The two - // diverge (a 10-bit request that fell back to GL at runtime keeps its - // 10-bit intent on purpose), and a presentation output built on the - // other backend than the viewport is a black second display. Once a - // view exists, pass its backend explicitly instead -- see - // RvApplication::rebuildDesktopVideoDevices. + // Presentation backend for the initial build: true for a 10-bit request + // this machine's Vulkan can present. Reads the persisted preference, + // which can differ from the live main-view backend, so it must not be + // used once a view exists. Always false on macOS. // static bool shouldUseVulkanPresentation(); @@ -372,12 +311,10 @@ namespace Rv DesktopStereoMode m_stereoMode; mutable FBOMap m_fboMap; - // Source colour texture last reported as unusable by cloneForSource(), - // so the report fires on the transition rather than every frame. + // Last unusable source texture reported, so the report fires once per transition. mutable GLuint m_reportedBadSourceTex{0}; - // Latches the "the surface has no backing FBO" report, so a present - // path that is stalled for many frames says so once. + // Latches the "no backing FBO" report. mutable bool m_transferStalled{false}; TwkGLF::GLState* m_glGlobalState; DesktopVideoFormats m_videoFormats; diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h index f8b24aea4..5dbc6b0ee 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoModule.h @@ -30,30 +30,11 @@ namespace Rv virtual ~DesktopVideoModule(); // - // Rebuild the per-screen presentation devices onto targetVulkan (GL - // ScreenView vs Vulkan swapchain), using shareDevice as the new GL - // share device. This is the post-startup analogue of the - // constructor's one-time createDesktopVideoDevices call, needed - // because the backend decision is no longer frozen at launch. - // - // targetVulkan is supplied by the caller rather than re-derived here: - // it must be the backend the main view is *actually* running, which - // the persisted display-depth preference does not reliably reflect - // (see DesktopVideoDevice::shouldUseVulkanPresentation). A presentation - // output on the opposite backend to the viewport is a black display. - // - // A cleanly open device is closed -- releasing its Vulkan swapchain or - // GL ScreenView -- before it is destroyed. To avoid a needless - // teardown and a transient on the second display, this is a no-op when - // the effective backend has not changed; it then returns false and - // leaves the devices untouched (the caller still re-binds the share - // device). Returns true when the devices were actually rebuilt. - // - // This deliberately does not touch the session's output video device. - // The caller (RvApplication::rebuildDesktopVideoDevices) owns - // re-binding the share device and re-opening the presentation output, - // because the devices destroyed here may be referenced as the session - // output. + // Rebuild the per-screen presentation devices onto targetVulkan, the + // backend the main view is actually running (not the persisted + // preference). Returns false and leaves the devices untouched when the + // backend has not changed. The caller re-binds the share device and + // re-opens the session's presentation output. // bool rebuildDevices(const QTGLVideoDevice* shareDevice, bool targetVulkan); diff --git a/src/lib/app/RvCommon/RvCommon/GLView.h b/src/lib/app/RvCommon/RvCommon/GLView.h index 525471530..58cd8250e 100644 --- a/src/lib/app/RvCommon/RvCommon/GLView.h +++ b/src/lib/app/RvCommon/RvCommon/GLView.h @@ -23,11 +23,7 @@ namespace Rv class QTGLVideoDevice; class GLWindow; - // - // -debug gpu (ImageRenderer::debugGpu()) diagnostics helpers, shared by - // GLView (which logs the format it asks for) and GLWindow (which logs the - // format and driver it actually got). Defined in GLView.cpp. - // + // -debug gpu helpers shared by GLView and GLWindow. std::string glDebugEnvOrUnset(const char* name); std::string glDebugFormatSummary(const QSurfaceFormat&); diff --git a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h index acd081901..1fbd31d2d 100644 --- a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h @@ -138,15 +138,9 @@ namespace Rv // QPointer m_window; // - // A surface to fall back on when m_window's platform surface is gone - // but its QOpenGLContext is not, which is the state Qt leaves this - // device in while shutting down: the native window is destroyed before - // the C++ object, so makeCurrent() has nothing to bind and every GL - // deletion in the teardown below silently does nothing. - // - // Created while the window is still healthy, never during shutdown -- - // a QOffscreenSurface needs the platform plugin to hand out a surface, - // which is not something to ask for on the way out. + // Fallback surface for makeCurrent() once Qt has destroyed m_window's + // native surface but not its context (shutdown). Created while the + // window is healthy: QOffscreenSurface needs the platform plugin. // mutable QOffscreenSurface* m_teardownSurface{nullptr}; QTTranslator* m_translator; diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 45354e6cf..8c9f153b4 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -35,12 +35,8 @@ namespace Rv class QTVulkanVideoDevice : public TwkGLF::GLVideoDevice { public: - // - // The presentation surface is a QWindow (embedded in the widget tree - // via createWindowContainer); eventWidget is the container QWidget the - // QTTranslator uses for coordinate mapping (height/mapToGlobal) and - // mouse grab. - // + // eventWidget is the window's container QWidget, used by QTTranslator + // for coordinate mapping and mouse grab. QTVulkanVideoDevice(TwkApp::VideoModule* module, const std::string& name, VulkanWindow* window, QWidget* eventWidget); virtual ~QTVulkanVideoDevice(); @@ -58,14 +54,8 @@ namespace Rv void setAbsolutePosition(int x, int y); - // - // Drop every GL object imported from the window's Vulkan side (the - // memory objects, their textures/FBOs and the shared semaphores), so - // none of them outlives the Vulkan memory it aliases. Called by - // VulkanWindow::releaseVulkanResources() before it frees that memory; - // syncBuffers() re-imports on the next frame (it rebuilds whenever - // m_glMemoryObject[slot] is 0). - // + // Drop every GL object imported from the Vulkan side so none outlives + // the memory it aliases. syncBuffers() re-imports on the next frame. void releaseSharedGLObjects(); // VideoDevice API @@ -96,15 +86,9 @@ namespace Rv const TwkGLF::GLFBO* defaultFBO() const override; std::string hardwareIdentification() const override; - // - // GL id of the offscreen FBO once the context and FBO have been - // created, else 0. Unlike defaultFBO() this does NOT force context - // creation: it is a readiness probe. DesktopVideoDevice::transfer() - // returns early while its view device reports 0, which is how the - // first composite is deferred until the target exists -- so this must - // be overridden for a VulkanDesktopVideoDevice presentation output to - // ever receive a frame. - // + // Readiness probe: the FBO id, or 0 before it exists. Unlike + // defaultFBO() it does not create the context; DesktopVideoDevice:: + // transfer() waits for a non-zero id. GLuint fboID() const override; private: @@ -112,13 +96,8 @@ namespace Rv // Makes the GL context current and binds the FBO on return. void ensureGLContext() const; - // - // Guarded: the window is embedded via QWidget::createWindowContainer(), - // which owns it, so Qt can delete it independently of this device (and - // of the VulkanView that created both). A QPointer makes the - // `if (m_window)` checks below actual liveness checks instead of null - // checks. - // + // The window container owns the window, so Qt can delete it + // independently of this device. QPointer m_window; QWidget* m_eventWidget; QTTranslator* m_translator; @@ -128,7 +107,6 @@ namespace Rv float m_refresh{-1.0f}; bool m_isOpen{false}; - // Qt GL context + offscreen surface for GL rendering. mutable QOpenGLContext* m_glContext{nullptr}; mutable QOffscreenSurface* m_offscreenSurface{nullptr}; mutable TwkGLF::GLFBO* m_fbo{nullptr}; @@ -136,35 +114,23 @@ namespace Rv mutable int m_fboWidth{0}; mutable int m_fboHeight{0}; - // GPU Interop GL objects, ringed per in-flight slot to match VulkanWindow's - // per-slot Vulkan shared image/semaphores. Indexed by the Vulkan slot for - // the frame being rendered (VulkanWindow::currentFrame()). - mutable std::array m_glMemoryObject{}; - mutable std::array m_glSharedTexture{}; - mutable std::array m_glReadySemaphore{}; - mutable std::array m_vkReadySemaphore{}; - mutable std::array m_drawFbo{}; - mutable std::array m_sharedWidth{}; - mutable std::array m_sharedHeight{}; - - // Which present path this device last reported: -1 nothing yet, - // 0 CPU-fallback, 1 GPU-interop. Per-device, because the presentation - // output has its own device and can land on a different path than the - // viewport -- and reported on every transition rather than latched on - // the first frame, because the first syncBuffers() can run before that - // window's Vulkan is initialized. Latching there reports CPU-fallback - // for a device that then spends its whole life on interop. + // Interop GL objects, indexed by VulkanWindow::currentFrame(). + mutable std::array m_glMemoryObject{}; + mutable std::array m_glSharedTexture{}; + mutable std::array m_glReadySemaphore{}; + mutable std::array m_vkReadySemaphore{}; + mutable std::array m_drawFbo{}; + mutable std::array m_sharedWidth{}; + mutable std::array m_sharedHeight{}; + + // Last reported present path: -1 none yet, 0 CPU-fallback, 1 GPU-interop. mutable int m_loggedPresentPath{-1}; // -1 until queried, 0 when GL and Vulkan use different/unidentifiable // physical devices, 1 when their device UUIDs match. mutable int m_glVulkanDeviceMatch{-1}; - // Latched once any GL call on the interop path reports an error. The - // GL<->Vulkan bridge has no way to notice that an import silently - // produced an unusable texture: the blit is dropped, Vulkan copies a - // never-written image, and the viewport is black with nothing logged. - // Demoting permanently to the CPU pack-and-upload path keeps the image - // correct (just slower) on a driver combination we have not seen. + // Latched once any GL call on the interop path fails; the device then + // stays on the CPU path. mutable bool m_interopDisabled{false}; // Drain glGetError(); on error, report which step failed, latch @@ -175,13 +141,8 @@ namespace Rv void cleanupSharedGLObjects(uint32_t slot) const; bool glDeviceMatchesVulkan() const; - // CPU-fallback GL state (used only when GPU interop is unavailable or - // refused). A flipped RGB10_A2 blit target lets GL pack the 10-bit pixels - // directly with glReadPixels(GL_UNSIGNED_INT_2_10_10_10_REV) and handle the - // Y flip, eliminating the per-pixel CPU pack loop. The readback format - // (GL_RGBA vs GL_BGRA) selects the swapchain's channel order - // (A2B10G10R10 / A2R10G10B10). Not ringed: the fallback is a synchronous - // readback, so a single reused target is sufficient. + // CPU-fallback target: a Y-flipped RGB10_A2 copy that glReadPixels packs + // directly. Not ringed, since the readback is synchronous. mutable GLuint m_cpuFlipFbo{0}; mutable GLuint m_cpuFlipTex{0}; mutable int m_cpuFlipWidth{0}; @@ -191,8 +152,6 @@ namespace Rv void ensureCpuFallbackTarget(int w, int h) const; void cleanupCpuFallbackTarget() const; - // Pack + present the framebuffer via the CPU fallback (GL-packed RGB10_A2 - // readback). Used when no zero-copy interop path is available. void presentCpuFallback(int w, int h) const; }; diff --git a/src/lib/app/RvCommon/RvCommon/RvApplication.h b/src/lib/app/RvCommon/RvCommon/RvApplication.h index 8a47b2c55..5904b6fa7 100644 --- a/src/lib/app/RvCommon/RvCommon/RvApplication.h +++ b/src/lib/app/RvCommon/RvCommon/RvApplication.h @@ -100,13 +100,8 @@ namespace Rv RvProfileManager* profileManager(); // - // Has the last document begun tearing down? - // - // Set once, on the way out, so that code running from queued events - // during shutdown can tell it is too late to put something back on - // screen. The console uses it: its auto-show is driven by output, and - // shutdown produces plenty of that after the windows have been asked - // to close. + // Set once the last document starts tearing down, so queued events + // (e.g. the console's output-driven auto-show) do not reopen windows. // bool isShuttingDown() const { return m_shuttingDown; } @@ -140,23 +135,11 @@ namespace Rv bool isInPresentationMode(); // - // Re-evaluate and rebuild the desktop presentation devices so their - // backend follows the current display-depth preference and the main - // view's live backend, then re-bind the share device and, if - // presentation mode is on, re-open the presentation output on the - // selected screen. - // - // Invoked from the RvDocument backend-transition points - // (setDisplayOutput / swapGLViewToVulkan / fallbackVulkanToGLView / - // rebuildGLView). shareDevice is the controller's new main-view GL - // device, or null when the main view has moved to Vulkan and there is - // no GL device to share. Fixes the frozen presentation bit depth and - // the black second display on a backend mismatch. - // - // mainViewIsVulkan is the backend the calling document's main view has - // just settled on. It is passed rather than re-derived from the - // display-depth preference because only the caller knows which widget - // actually exists now. + // Rebuild the desktop presentation devices to match the main view's + // backend, re-bind the share device, and re-open the presentation + // output if presentation mode is on. shareDevice is null when the main + // view is Vulkan. mainViewIsVulkan comes from the caller because only + // it knows which view widget exists now. // void rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice, bool mainViewIsVulkan); diff --git a/src/lib/app/RvCommon/RvCommon/RvDocument.h b/src/lib/app/RvCommon/RvCommon/RvDocument.h index c2f2c763c..7ac2fd527 100644 --- a/src/lib/app/RvCommon/RvCommon/RvDocument.h +++ b/src/lib/app/RvCommon/RvCommon/RvDocument.h @@ -83,11 +83,8 @@ namespace Rv QWidget* viewWidget() const; // - // Active presentation video device for whichever backend is in use - // (the OpenGL GLView or, on Linux, the Vulkan VulkanView). Returns - // nullptr if no view has been created yet. Prefer this over - // view()->videoDevice() in backend-neutral code so the Vulkan/Metal - // paths (where view() is null) stay crash-safe. + // Video device of the active view (GLView or VulkanView), or nullptr. + // Prefer it over view()->videoDevice(): view() is null on Vulkan. // TwkGLF::GLVideoDevice* viewVideoDevice() const; @@ -100,8 +97,7 @@ namespace Rv // Replace a live VulkanView with GLView after a runtime Vulkan failure. void fallbackVulkanToGLView(); - // Promote a live GLView to a VulkanView so a 10-bit request applies - // immediately -- the forward mirror of fallbackVulkanToGLView. + // Promote a live GLView to a VulkanView so a 10-bit request applies immediately. void swapGLViewToVulkan(); #endif diff --git a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h index e0e25b26d..10b6dd4f4 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h @@ -15,19 +15,10 @@ namespace Rv // // VulkanDesktopVideoDevice // - // A desktop (second-display) presentation output device that delivers the - // final frame through a Vulkan swapchain for true 10-bit output on Linux - // and Windows, instead of the OpenGL ScreenView the base - // DesktopVideoDevice uses. - // - // It reuses the base class's frame handoff wholesale: the renderer's - // transfer() / transfer2() composite (including every stereo mode) into - // m_viewDevice->defaultFBO(), where m_viewDevice is the presentation - // VulkanView's QTVulkanVideoDevice. The only backend-specific behaviour is - // owning the Vulkan window and presenting it explicitly -- Vulkan has no - // QOpenGLWidget auto-composite -- so this subclass overrides only the - // window-lifecycle and present methods and inherits everything else - // (transfer/transfer2/fillWithTexture/format/data-format/sync) unchanged. + // A second-display presentation output that presents through a 10-bit + // Vulkan swapchain on Linux and Windows. The inherited transfer() and + // transfer2() composite into the view's QTVulkanVideoDevice; only the + // window lifecycle and presentation are overridden. // class VulkanDesktopVideoDevice : public DesktopVideoDevice { @@ -35,12 +26,8 @@ namespace Rv VulkanDesktopVideoDevice(TwkApp::VideoModule* module, const std::string& name, int screen, const QTGLVideoDevice* shareDevice); ~VulkanDesktopVideoDevice() override; - // - // DesktopVideoDevice / VideoDevice API -- the backend-specific - // overrides. Note that none of these chain to the base - // implementation: the base drives m_view, a QOpenGLWidget, which is - // never created here. - // + // None of these chain to the base, which drives a QOpenGLWidget + // (m_view) that is never created here. void open(const StringVector& args) override; void close() override; bool isOpen() const override; @@ -51,11 +38,7 @@ namespace Rv void syncBuffers() const override; private: - // - // The presentation output window. It owns its own QTVulkanVideoDevice - // (VulkanView::videoDevice()), which is handed to the base - // m_viewDevice so the inherited transfer()/transfer2() drive it. - // + // Owns the QTVulkanVideoDevice used as the base m_viewDevice. VulkanView* m_vulkanView; }; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanView.h b/src/lib/app/RvCommon/RvCommon/VulkanView.h index 741217c80..983e3302f 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanView.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanView.h @@ -23,13 +23,8 @@ namespace Rv // // Host QWidget that embeds the native Vulkan viewport (VulkanWindow) via // QWidget::createWindowContainer(), and owns the QTVulkanVideoDevice that - // drives it. - // - // This is deliberately the same shape as GLView/GLWindow. Keeping the - // viewport on a native window of its own -- rather than on a widget that - // Qt composites into the top-level window -- is what keeps the main window - // off a render-to-texture composite path, and it means the two backends - // share one set of embedding and lifetime rules instead of two. + // drives it. Mirrors GLView/GLWindow; the native window keeps the main + // window off Qt's render-to-texture composite path. // class VulkanView : public QWidget { @@ -43,9 +38,6 @@ namespace Rv QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } - // - // Delegated to the viewport window. - // void stopProcessingEvents(); bool firstPaintCompleted() const; @@ -64,32 +56,16 @@ namespace Rv QSize minimumSizeHint() const override { return m_msize; } - // - // Probe for whether this machine's Vulkan can present a 10-bit format. - // Forwards to VulkanWindow; see the note there. - // + // See VulkanWindow::supports10BitPresentation(). static bool supports10BitPresentation(); private: // - // Keeping the viewport window alive across top-level window churn. - // - // createWindowContainer() transfers ownership of the viewport window to - // the container, which parents it to the top-level QWidgetWindow. Qt - // destroys and recreates that QWidgetWindow when a widget is reparented - // into the window -- QWidget::setParent() -> destroy() -> ~QWidgetWindow - // -- as happens when a plugin adds a QWebEngineView to a layout, and - // ~QObject deletes its child QWindows, viewport included. Nothing in Qt - // puts it back, and QWindowContainer then dereferences the window it no - // longer has on the next layout pass. - // - // QObject::destroyed is emitted at the top of ~QObject, before - // deleteChildren() runs, so watching the parent window gives us a - // moment where the viewport can still be detached and kept. - // - // This mirrors GLView. The one Vulkan-specific consequence is that the - // VkSurfaceKHR does not survive the platform window being recreated; - // VulkanWindow detects that on the next expose and rebuilds. + // Keep the viewport window alive across top-level window churn. Qt + // recreates the top-level QWidgetWindow when a widget is reparented + // into it (e.g. a plugin adding a QWebEngineView), deleting the child + // viewport. destroyed() fires before children are deleted, so the + // viewport is detached there and re-attached later (as in GLView). // void watchParentWindow(); void parentWindowDestroyed(); @@ -106,11 +82,7 @@ namespace Rv QSize m_csize; QSize m_msize; - // - // The parent QWindow whose destruction is being watched, plus the - // connection to it so it can be rewired when the viewport window is - // re-parented. See watchParentWindow(). - // + // See watchParentWindow(). QWindow* m_watchedParentWindow; QMetaObject::Connection m_watchedParentConnection; bool m_reattachPending; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index d69b4483e..2006217e9 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -30,19 +30,13 @@ namespace Rv // // VulkanWindow // - // The RV viewport rendered as a *native* Vulkan surface, presenting 10-bit - // images on Linux and Windows. All IPCore image processing runs in OpenGL - // via a separate QOpenGLContext+QOffscreenSurface (see - // QTVulkanVideoDevice); the Vulkan path is used only for final 10-bit - // pixel delivery, to avoid the 8-bit truncation the OpenGL+Qt path is - // subject to on both platforms. + // The RV viewport as a native Vulkan surface, presenting 10-bit images on + // Linux and Windows. IPCore still renders in OpenGL (see + // QTVulkanVideoDevice); Vulkan only delivers the final pixels. // - // It is a QWindow rather than a native-attribute QWidget, and it is - // embedded in the widget hierarchy by VulkanView via - // QWidget::createWindowContainer() -- the same shape GLWindow/GLView use. - // Keeping every viewport backend on that one pattern is what keeps the - // top-level QMainWindow off a render-to-texture composite path, which is - // where the ~90 ms full-window present came from. + // Embedded by VulkanView via QWidget::createWindowContainer(), like + // GLWindow/GLView, which keeps the QMainWindow off Qt's render-to-texture + // composite path. // class VulkanWindow : public QWindow { @@ -56,13 +50,10 @@ namespace Rv QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } - // The device is created and owned by the hosting VulkanView (it needs - // the container QWidget for event/coordinate translation), then handed - // here. VulkanWindow does not take ownership. + // Owned by the hosting VulkanView, not by this window. void setVideoDevice(QTVulkanVideoDevice* d) { m_videoDevice = d; } - // The container QWidget the viewport is embedded in; used for focus - // and for the popup-focus check in render(). + // The container QWidget; used for focus and the popup check in render(). void setEventWidget(QWidget* widget) { m_eventWidget = widget; } void stopProcessingEvents(); @@ -75,16 +66,11 @@ namespace Rv float devicePixelRatioF() const; - // Format of the active swapchain image: VK_FORMAT_A2B10G10R10_UNORM_PACK32 - // or VK_FORMAT_A2R10G10B10_UNORM_PACK32 (the two differ in R/B order). - // VK_FORMAT_UNDEFINED before the swapchain is created. + // A2B10G10R10 or A2R10G10B10 (R/B order differs); UNDEFINED before + // the swapchain exists. VkFormat swapchainFormat() const { return m_vkSwapchainFormat; } - // - // Presentation path taken this session. RV prefers ZeroCopy, degrades - // to CpuReadback (slower, still 10-bit), and only then to OpenGL - // (which forgoes 10-bit). See emitPresentationRecord(). - // + // Presentation path taken, in order of preference. enum class PresentPath { Undetermined, @@ -93,12 +79,10 @@ namespace Rv OpenGL // Vulkan abandoned; RvDocument swaps in GLView }; - // Resolved GL<->Vulkan interop configuration. Negotiated once per - // device from what the driver reports exportable, never from GPU - // vendor identity or host platform. Both the Vulkan export and the GL - // import read their settings from this one struct so the two sides - // cannot disagree -- a disagreement about tiling or dedicated - // allocation corrupts the image rather than raising an error. + // GL<->Vulkan interop configuration, negotiated once per device from + // what the driver reports exportable. Both the Vulkan export and the + // GL import read it: a tiling or dedicated-allocation mismatch + // silently corrupts the image. struct InteropConfig { bool supported{false}; @@ -106,19 +90,12 @@ namespace Rv VkImageTiling tiling{VK_IMAGE_TILING_LINEAR}; VkImageUsageFlags usage{0}; - // Probe-time floor for dedicated allocation: true when the handle - // type reports DEDICATED_ONLY, which is a hard requirement. The - // softer "prefers dedicated" signal belongs to a concrete image - // rather than to the format, so it is read per-image from - // VkMemoryDedicatedRequirements at allocation time and recorded in - // SharedImageInfo::dedicatedAllocation, which is what the GL side - // mirrors. + // True when the handle type reports DEDICATED_ONLY. The per-image + // "prefers dedicated" result lives in + // SharedImageInfo::dedicatedAllocation. bool dedicatedAllocation{false}; - // Raw VkExternalMemoryFeatureFlags the winning candidate reported, - // so a log read by someone without the machine can tell whether - // dedicated allocation was required by the handle type or merely - // preferred by the image. + // Raw flags of the winning candidate, for the startup record. VkExternalMemoryFeatureFlags externalFeatures{0}; // Set when an RV_VULKAN_FORCE_* override displaced what the probe @@ -136,28 +113,17 @@ namespace Rv const InteropConfig& interopConfig() const { return m_interopConfig; } - // Record the path actually taken, and the GL side's view of the shared - // image, then emit the one-per-session startup record. Called by - // QTVulkanVideoDevice once the first frame establishes which path ran. + // Called by QTVulkanVideoDevice once the first frame establishes the + // path; emits the startup record. void reportPresentPath(PresentPath path, const std::string& reason); void reportGLImportState(VkImageTiling tiling, bool dedicated); - // True when uuid identifies the physical device backing this window. - // Used by the GL bridge to refuse external-memory interop across GPUs. + // Lets the GL bridge refuse external-memory interop across GPUs. bool physicalDeviceMatchesUUID(const unsigned char* uuid, size_t size) const; - // - // Vulkan presentation — called by QTVulkanVideoDevice::syncBuffers(). - // - - // GPU Interop API - // - // External handles to a Vulkan device-memory block (and its - // GL<->Vulkan sync semaphores) that GL imports as a memory - // object + semaphores. On Linux these are opaque file - // descriptors; on Windows they are Win32 HANDLEs. Stored as - // void* in the header to keep out of public Qt - // includes; the .cpp casts to HANDLE. + // External handles GL imports as a memory object and semaphores: + // opaque FDs on Linux, Win32 HANDLEs on Windows (void* to keep + // out of this header). struct SharedImageInfo { #ifdef PLATFORM_WINDOWS @@ -174,43 +140,26 @@ namespace Rv int height{0}; // used sub-region height presented this frame int strideWidth{0}; // GL texture width = capacity rowPitch / 4 int capacityHeight{0}; // allocated image height (>= height); GL texture height - // The negotiated tiling this image was actually created with. GL - // must import with the matching GL_{OPTIMAL,LINEAR}_TILING_EXT: - // importing OPTIMAL-tiled memory as LINEAR yields an image whose - // large-scale structure survives but whose pixels are scrambled - // within each tile. + // GL must import with the matching GL_{OPTIMAL,LINEAR}_TILING_EXT. VkImageTiling tiling{VK_IMAGE_TILING_LINEAR}; - // Whether the export used a dedicated allocation - // (VkMemoryDedicatedAllocateInfo), which the driver may require for - // an image created with an external handle type. EXT_memory_object - // requires the two sides to agree, so GL must set - // GL_DEDICATED_MEMORY_OBJECT_EXT to exactly this before - // glTexStorageMem2DEXT; a mismatch corrupts the image. + // GL must set GL_DEDICATED_MEMORY_OBJECT_EXT to exactly this. bool dedicatedAllocation{false}; }; - // Capacity of the present-resource ring. Per-frame Vulkan sync objects - // and GL<->Vulkan shared resources are indexed by currentFrame(). - // Runtime depth defaults to one for interactive latency; - // RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 enables both slots. - static constexpr uint32_t FRAMES_IN_FLIGHT = 2; + // Ring capacity. Runtime depth defaults to 1 for latency; + // RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 uses both slots. + static constexpr uint32_t kFramesInFlight = 2; - // Index of the in-flight ring slot the next/current frame uses. The GL - // side (QTVulkanVideoDevice) reads this to pair its own ring objects with - // the Vulkan slot for the frame being rendered. + // Ring slot for the current frame; the GL side pairs its objects to it. uint32_t currentFrame() const { return m_currentFrame; } - // A doc-less window is a passive presentation output: it is composited - // into and presented by its owning VulkanDesktopVideoDevice and never - // drives the frame loop. It must therefore never block that loop either - // -- see the best-effort present in presentSharedImage(). + // A doc-less window is a passive presentation output, presented by its + // VulkanDesktopVideoDevice. It never drives or blocks the frame loop. bool isPassiveOutput() const { return m_doc == nullptr; } - // Best-effort gate for a passive presentation output, called by - // QTVulkanVideoDevice::syncBuffers() *before* it does any GL work. - // False means skip this frame entirely; a retry is armed internally so - // the frame is not lost. Always true for the control viewport. + // Checked before any GL work. False means skip the frame (a retry is + // armed). Always true for the control viewport. bool canPresentNow(); const SharedImageInfo* getSharedImageInfo(int w, int h); @@ -221,24 +170,14 @@ namespace Rv bool isInitialized() const { return m_initialized; } - // - // Probe for whether this machine's Vulkan can present a 10-bit format - // (A2B10G10R10 or A2R10G10B10). Used at RvDocument construction time to - // decide whether a 10-bit display request should route to the Vulkan - // path or fall back to OpenGL. Creates a throwaway QVulkanInstance + - // dummy surface and queries the advertised surface formats; it never - // throws — returns false if Vulkan is unavailable for any reason. - // + // Whether Vulkan can present a 10-bit format here. Never throws. static bool supports10BitPresentation(); public slots: void eventProcessingTimeout(); protected: - // Called once when the surface is first exposed. void initialize(); - - // Called each time a new frame should be rendered. void render(); void exposeEvent(QExposeEvent* event) override; @@ -251,16 +190,10 @@ namespace Rv bool createSwapchain(); void cleanupSwapchain(); - // Probe the driver for an exportable shared-image configuration and - // resolve m_interopConfig. Runs exactly once per device, at device - // creation -- not per shared-image slot and not again on resize. + // Resolves m_interopConfig once per device (not per slot or resize). void negotiateInteropConfig(); - // Emit the one-per-session startup record describing the negotiated - // configuration and the path taken. Unconditional: it must not be - // gated on ImageRenderer::debugGpu(), because Windows/NVIDIA is - // verified by QA against a build, and a log that needs a debug flag - // set in advance costs a whole verification round. + // Once per session. Unconditional: needed in logs without -debug gpu. void emitPresentationRecord(); RvDocument* m_doc; @@ -268,9 +201,8 @@ namespace Rv bool m_initialized; - // The QPlatformWindow the current VkSurfaceKHR was created against. - // Compared in exposeEvent() to detect that Qt replaced the platform - // window under us; see handleSurfaceLost(). + // Platform window the VkSurfaceKHR was created against; see + // handleSurfaceLost(). const QPlatformWindow* m_initializedHandle{nullptr}; bool m_firstPaintCompleted; @@ -284,27 +216,19 @@ namespace Rv QEvent::Type m_lastKeyType; Timer m_activityTimer; Timer m_activationTimer; - // Time since a passive output last actually presented; drives the - // forward-progress guard in canPresentNow(). + // Forward-progress guard for canPresentNow(). Timer m_lastPresentTimer; QTimer m_eventProcessingTimer; - // Vulkan state VkInstance m_vkInstance{VK_NULL_HANDLE}; VkSurfaceKHR m_vkSurface{VK_NULL_HANDLE}; VkPhysicalDevice m_vkPhysicalDevice{VK_NULL_HANDLE}; VkDevice m_vkDevice{VK_NULL_HANDLE}; VkQueue m_vkQueue{VK_NULL_HANDLE}; uint32_t m_queueFamilyIndex{0}; - // Last (format, colorSpace) pair reported by createSwapchain(). - // createSwapchain() runs on every resize step, so the choice is logged - // only when it actually changes. Default-initialized to - // VK_FORMAT_UNDEFINED, which no accepted format equals, so the first - // swapchain always reports. + // Logged only on change: createSwapchain() runs on every resize. VkSurfaceFormatKHR m_loggedSurfaceFormat{}; - // Whether the surface's full format list has been dumped for this - // window yet. Once per window, not once per swapchain recreate. bool m_loggedSurfaceFormatList{false}; bool m_externalInteropSupported{false}; @@ -317,55 +241,38 @@ namespace Rv std::vector m_vkCommandBuffers; // Per-in-flight-slot ring (indexed by m_currentFrame). - std::array m_vkImageAvailableSemaphore{}; - std::array m_vkFence{}; + std::array m_vkImageAvailableSemaphore{}; + std::array m_vkFence{}; uint32_t m_currentFrame{0}; - // Per-swapchain-image (indexed by imageIndex, sized to the swapchain - // image count, (re)built in createSwapchain / freed in cleanupSwapchain). - // The present-wait semaphore MUST be tied to the image, not the frame: - // with 2 frames in flight the same image can be re-acquired while its - // prior present is still pending, and reusing a per-frame semaphore there - // trips the present-semaphore-reuse validation error. m_imagesInFlight - // records which frame fence currently owns each image so a re-acquired - // in-flight image is waited on before reuse. + // Per swapchain image. The present-wait semaphore must be per image, + // not per frame: an image can be re-acquired while its present is still + // pending. m_imagesInFlight holds the frame fence owning each image. std::vector m_vkRenderFinished; std::vector m_imagesInFlight; - // CPU-fallback staging buffer, ringed per in-flight slot: the frame maps - // and overwrites it before acquiring, so with the per-frame block removed - // it must not alias a buffer whose copy from a still-in-flight frame is - // pending. The slot's frame fence (waited at frame start) gates reuse. - std::array m_vkStagingBuffer{}; - std::array m_vkStagingBufferMemory{}; - std::array m_stagingBufferSize{}; - - // Shared Image for GPU Interop, ringed per in-flight slot (indexed by - // m_currentFrame). SharedImageInfo's default member initializers give the - // correct unset state (FDs/handles = -1/nullptr), so value-initializing - // the array is safe. - std::array m_vkSharedImage{}; - std::array m_vkSharedImageMemory{}; - std::array m_vkGlReadySemaphore{}; - std::array m_vkVkReadySemaphore{}; - std::array m_sharedImageInfo{}; - - // Grow-only allocated capacity of each slot's shared image. A resize - // within capacity reuses the existing allocation/export (no rebuild, no - // FD re-export, no GL re-import); the image is only reallocated when the - // request exceeds capacity, at which point capacity grows to the - // componentwise max of the request and the screen size (monotonic). - std::array m_sharedCapacityW{}; - std::array m_sharedCapacityH{}; + // CPU-fallback staging buffer, per slot so a frame never overwrites a + // buffer an in-flight copy still reads; the slot fence gates reuse. + std::array m_vkStagingBuffer{}; + std::array m_vkStagingBufferMemory{}; + std::array m_stagingBufferSize{}; + + // Shared interop image, per slot. + std::array m_vkSharedImage{}; + std::array m_vkSharedImageMemory{}; + std::array m_vkGlReadySemaphore{}; + std::array m_vkVkReadySemaphore{}; + std::array m_sharedImageInfo{}; + + // Grow-only capacity: a resize within it reuses the export and the GL + // import. + std::array m_sharedCapacityW{}; + std::array m_sharedCapacityH{}; void cleanupSharedImage(uint32_t slot); - // Rebalance a slot's glReady/vkReady binary-semaphore pair when a frame is - // aborted at acquire time. The GL side (syncBuffers) has already signaled - // glReady[slot] and waited vkReady[slot] before the acquire result is - // known; if the frame returns without its normal submit, this issues a - // minimal submit that waits glReady[slot] and signals vkReady[slot] so the - // pair cannot desync across the skipped frame. + // GL has already signaled glReady and waited vkReady when an acquire + // fails; this minimal submit keeps the semaphore pair balanced. void drainSharedSemaphores(uint32_t slot); // Recreate the swapchain after OUT_OF_DATE. SUBOPTIMAL remains usable. @@ -375,10 +282,8 @@ namespace Rv // platform window, which invalidates the VkSurfaceKHR. void handleSurfaceLost(); - // Destroy every Vulkan object this window owns and return it to the - // pre-initialize() state. MUST run while the platform window (and hence - // the VkSurfaceKHR) is still alive -- see the QEvent::PlatformSurface - // handler in event(). + // Must run while the platform window (and the VkSurfaceKHR) is still + // alive; see the QEvent::PlatformSurface handler in event(). void releaseVulkanResources(); // Queue a one-shot switch to GLView; no-op during shutdown. @@ -389,9 +294,6 @@ namespace Rv bool m_glFallbackRequested{false}; - // Negotiated interop configuration and the state behind the startup - // record. m_interopNegotiated guards the once-per-device probe; - // m_recordEmitted guards the once-per-session record. InteropConfig m_interopConfig; bool m_interopNegotiated{false}; bool m_recordEmitted{false}; @@ -399,8 +301,7 @@ namespace Rv PresentPath m_presentPath{PresentPath::Undetermined}; std::string m_presentPathReason; - // What the GL side reported importing, so the record can show the two - // sides agreeing (or not) rather than only what Vulkan intended. + // What GL reported importing, so the record shows both sides. bool m_glImportReported{false}; VkImageTiling m_glImportTiling{VK_IMAGE_TILING_LINEAR}; bool m_glImportDedicated{false}; diff --git a/src/lib/app/RvCommon/RvConsoleWindow.cpp b/src/lib/app/RvCommon/RvConsoleWindow.cpp index 222bdafaa..4a8ab8ce1 100644 --- a/src/lib/app/RvCommon/RvConsoleWindow.cpp +++ b/src/lib/app/RvCommon/RvConsoleWindow.cpp @@ -103,16 +103,7 @@ namespace Rv setWindowIcon(QIcon(qApp->applicationDirPath() + QString(RV_ICON_PATH_SUFFIX))); setSizeGripEnabled(true); - // - // A log window must never be what keeps RV alive. - // - // RV has no explicit quit anywhere; it relies entirely on Qt's - // quitOnLastWindowClosed. Qt counts every visible top-level widget - // that has WA_QuitOnClose, which is on by default, so leaving this - // dialog open -- whether the user opened it or output reopened it -- - // was enough to stop exec() from ever returning once the session - // window had gone. - // + // RV relies on quitOnLastWindowClosed; a log window must not keep it alive. setAttribute(Qt::WA_QuitOnClose, false); bool doRedirect = (getenv("RV_NO_CONSOLE_REDIRECT") == 0); // setAttribute(Qt::WA_MacBrushedMetal); @@ -147,25 +138,9 @@ namespace Rv processTextBuffer(); // - // Put cout/cerr back, and take the buffer down with us. - // - // Guard on having installed the redirect rather than on a second - // #if. The install above is compiled in when NDEBUG *or* - // PLATFORM_WINDOWS; this restore used to ask for NDEBUG or - // *!*PLATFORM_WINDOWS. A Windows debug build is the one combination - // where those disagree, so there the redirect went in and never came - // out: ConsoleBuf stayed on cout/cerr with m_console pointing at this - // destroyed window. - // - // main() deletes RvApplication before finalizePython(), and - // Py_Finalize's GC can still write -- a ResourceWarning from an - // unclosed socket, say. That write reached ConsoleBuf, followed - // m_console into freed memory, and locked a QMutex whose bits happened - // to read "contended", which never resolves. That is the hang on exit. - // - // m_stdoutBuf/m_stderrBuf are non-null only if the install ran, and - // processLastTextBuffer() nulls them if it got here first, so this is - // correct in every build and safe to run twice. + // Restore cout/cerr whenever the redirect was installed: Py_Finalize + // can still write after this window is gone. The buffers are non-null + // only if the install ran, so this is safe in every build and twice. // if (m_stdoutBuf) { @@ -288,15 +263,7 @@ namespace Rv } } - // - // Not on the way out. This runs from a queued event, so it lands - // after the last document's destructor has already closed this - // window, and shutdown emits plenty of output for it to react to. - // Re-showing here put the console back on screen as the only - // visible window, which -- with no explicit quit anywhere in RV - // -- meant quitOnLastWindowClosed never fired and exec() never - // returned. The console stayed up and the process hung. - // + // Never re-show during shutdown: it would be the last visible window and block exit. if (shouldShow && !(RvApp() && RvApp()->isShuttingDown())) { show(); diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 3d4329af5..b80eaa066 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -27,10 +27,7 @@ #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) #include #include -// The X11/QtX11Extras includes (Linux-only, above) drag in GL headers that -// conflict with GLEW; only assert the include-order invariant on Linux. -// On Windows, GLEW is intentionally included first (see the -// PLATFORM_WINDOWS block higher up) and the conflict does not apply. +// Windows includes GLEW first on purpose; only Linux has the X11 conflict. #if defined(PLATFORM_LINUX) #ifdef __glew_h_ #error "GLEW IS DEFINED BEFORE QTGUI!" @@ -206,17 +203,10 @@ namespace Rv // #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - // --- Backend selection: Vulkan for 10-bit, OpenGL otherwise --- // - // The display-depth preference is the user intent. A 10-bit request - // (RGB 10 + A 2) routes to the Vulkan presentation path, which avoids - // the 8-bit truncation that the OpenGL+Qt path is subject to on both - // Linux (GLX visual) and Windows (WGL pixel-format negotiation); - // everything else (8-bit, default) stays on the legacy OpenGL GLView. - // If 10-bit is requested but this machine's Vulkan cannot present - // 10-bit, we fall back to GLView and log why. The choice is made - // once per window at construction; changing the preference takes - // effect on the next launch / new window. + // 10-bit (RGB 10 + A 2) presents through Vulkan, since GLX/WGL + // pixel-format negotiation truncates to 8-bit; everything else stays + // on GLView. // const bool want10bit = (opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2); @@ -226,14 +216,7 @@ namespace Rv << " A" << opts.dispAlphaBits << " -> want10bit=" << (want10bit ? "true" : "false") << endl; } - // - // Take the decision from DesktopVideoDevice, which applies the same - // rule to the second-display presentation output. Deriving both from - // one function is what keeps the main view and the presentation output - // on the same backend; a disagreement there is what left the second - // display black. want10bit above is kept only to phrase the - // diagnostics below. - // + // Shared with the presentation output so both stay on the same backend. const bool useVulkan = DesktopVideoDevice::shouldUseVulkanPresentation(); if (want10bit) @@ -257,7 +240,6 @@ namespace Rv if (useVulkan) { - // --- Vulkan path --- m_vulkanView = new VulkanView(this, m_centralWidget, !m_startupResize); m_vulkanView->setFocusPolicy(Qt::StrongFocus); @@ -271,7 +253,6 @@ namespace Rv } else { - // --- OpenGL path --- if (docs.empty()) { m_glView = @@ -282,9 +263,7 @@ namespace Rv { RvSession* s = static_cast(docs.front()); RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); - // The front document may be on the Vulkan/Metal path, where view() - // is null; share its GL context only if it has one (mirrors the - // first-window case above, which passes a null share context). + // view() is null if the front document is on Vulkan. QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; m_glView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, @@ -294,7 +273,6 @@ namespace Rv m_viewWidget = m_glView; } #else - // --- OpenGL path --- if (docs.empty()) { m_glView = @@ -305,8 +283,6 @@ namespace Rv { RvSession* s = static_cast(docs.front()); RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); - // The front document may be on an alternative presentation path, where - // view() is null; share its GL context only if it has one. QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; m_glView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, @@ -314,27 +290,20 @@ namespace Rv opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits, !m_startupResize); } m_viewWidget = m_glView; -#endif // PLATFORM_LINUX +#endif - // DiagnosticsView is an independent QOpenGLWidget with its own GL context; - // it only needs a valid surface format, not the main view's context. On the - // Vulkan/Metal presentation path m_glView is null, so fall back to the global - // default format (OpenGL 2.1, set in RV/main.cpp) which ImGui's GL2 backend - // expects. + // DiagnosticsView has its own context and only needs a surface format. const QSurfaceFormat diagnosticsFormat = m_glView ? m_glView->format() : QSurfaceFormat::defaultFormat(); m_diagnosticsView = new DiagnosticsView(nullptr, diagnosticsFormat); // Dockable to QMainWindow, not centralwidget. - if (m_diagnosticsView) - { - m_diagnosticsDock = new QDockWidget(tr("Diagnostics"), this); - m_diagnosticsDock->setObjectName("Diagnostics"); - m_diagnosticsDock->setWidget(m_diagnosticsView); - m_diagnosticsDock->setAllowedAreas(Qt::AllDockWidgetAreas); - addDockWidget(Qt::BottomDockWidgetArea, m_diagnosticsDock); - m_diagnosticsDock->hide(); // Hide by default - m_diagnosticsView->setWindowFlag(Qt::Widget); // Not a top-level window - } + m_diagnosticsDock = new QDockWidget(tr("Diagnostics"), this); + m_diagnosticsDock->setObjectName("Diagnostics"); + m_diagnosticsDock->setWidget(m_diagnosticsView); + m_diagnosticsDock->setAllowedAreas(Qt::AllDockWidgetAreas); + addDockWidget(Qt::BottomDockWidgetArea, m_diagnosticsDock); + m_diagnosticsDock->hide(); // Hide by default + m_diagnosticsView->setWindowFlag(Qt::Widget); // Not a top-level window m_stackedLayout = new QStackedLayout(m_centralWidget); m_stackedLayout->setStackingMode(QStackedLayout::StackAll); @@ -390,12 +359,11 @@ namespace Rv // // It is a frameless top-level window (owned by this document) rather // than a child widget. The viewport is a native window on both - // backends -- a QOpenGLWindow for GLView, a Vulkan surface for - // VulkanView -- and a native window renders above any sibling raster - // child widget regardless of raise()/stacking order, so a child - // overlay could never dim or block the viewport. A top-level window - // sits above the main window and its native child, so it covers the - // viewport too. + // backends (QOpenGLWindow or a Vulkan surface), which renders above + // any sibling raster child widget regardless of raise()/stacking + // order, so a child overlay could never dim or block the viewport. A + // top-level window sits above the main window and its native child, + // so it covers the viewport too. // m_blockingOverlay = new QWidget(this, Qt::FramelessWindowHint | Qt::Tool); m_blockingOverlay->setObjectName("UIBlockingOverlay"); @@ -437,14 +405,9 @@ namespace Rv // GL_SHADING_LANGUAGE_VERSION and aborts without a current context, so // make the viewport context current first. // - // Deliberately NOT driven from a view callback (GLWindow::initializeGL, - // VulkanWindow::initialize): loading packages creates web panels, and - // adding a QWebEngineView makes Qt tear down the main window's native - // subtree -- destroying the viewport while that callback is still on - // the stack. - // - // Backend-neutral: on the Vulkan path m_glView is null, and the - // context to make current is the presentation device's offscreen one. + // Not driven from a view init callback: loading packages can add a + // QWebEngineView, which destroys the viewport's native window while + // that callback is still on the stack. // TwkGLF::GLVideoDevice* viewDevice = viewVideoDevice(); @@ -632,13 +595,8 @@ namespace Rv // Then this is the last document, so shutdown network // - // - // Say so before closing anything. The closes below are not the - // end of the output -- tearing down the session produces plenty - // more -- and the console's auto-show runs from a queued event, - // so it would otherwise reopen after being closed here and, being - // the last visible window, keep the application alive forever. - // + // Before closing anything, so the console's queued auto-show cannot + // reopen it and keep the application alive. RvApp()->setShuttingDown(); if (RvNetworkDialog* d = RvApp()->networkWindow()) @@ -658,14 +616,6 @@ namespace Rv RvSettings::cleanupGlobalSettings(); } - // - // ~Session deletes the renderer, and ~ImageRenderer tears down every - // FBO that ImageFBOManager still holds. Those deletes need a context - // and cannot ask for one -- the two setters above nulled the - // renderer's device pointers, so by now it has nothing left to ask. - // TwkGLF::GLContextScope inside ImageFBOManager covers it with the - // fallback teardown context, so there is nothing to arrange here. - // delete m_session; delete m_menuTimer; @@ -748,8 +698,7 @@ namespace Rv } else if (m == IPCore::Session::eventDeviceChangedMessage()) { - // translator() is not on the shared GLVideoDevice base, so it is - // accessed via the concrete backend view here (still null-safe). + // translator() is not on the GLVideoDevice base. if (m_session->eventVideoDevice()) { const int w = m_session->eventVideoDevice()->width(); @@ -960,19 +909,21 @@ namespace Rv const bool requestedTenBit = opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2; if (requestedTenBit) + { cout << "INFO: Vulkan 10-bit presentation failed at runtime; falling back to 8-bit OpenGL." << endl; + } else + { cout << "INFO: Switching the main view from Vulkan to OpenGL for the requested display depth." << endl; + } VulkanView* oldVulkanView = m_vulkanView; m_vulkanView = nullptr; oldVulkanView->stopProcessingEvents(); - // A runtime Vulkan failure cannot be recovered by asking Qt/OpenGL for - // the same 10/10/10/2 surface that required Vulkan in the first place. - // Preserve the user's persisted 10-bit intent, but make this recovery - // view explicitly 8-bit so it is valid and usable. + // OpenGL cannot provide the 10/10/10/2 surface that required Vulkan, so + // the recovery view is 8-bit; the persisted 10-bit preference is kept. const int fallbackRedBits = requestedTenBit ? 8 : opts.dispRedBits; const int fallbackGreenBits = requestedTenBit ? 8 : opts.dispGreenBits; const int fallbackBlueBits = requestedTenBit ? 8 : opts.dispBlueBits; @@ -1028,37 +979,16 @@ namespace Rv m_glView->videoDevice()->sendEvent(TwkApp::RenderContextChangeEvent("gl-context-changed", m_glView->videoDevice())); } - // - // Rebuild the desktop presentation devices for the GL backend, re-bind - // the share device, and re-open the presentation output on the - // selected screen, so the second display follows the main view back to - // OpenGL instead of being left mismatched (black). - // RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice(), false); - // - // Defer the delete. This is reached from a queued callback posted by - // the VulkanView itself (VulkanView::requestGLFallback), which can be - // raised from deep inside the present path, and destroying the view - // tears down the Vulkan device and its in-flight frames. deleteLater() - // guarantees the destructor runs with no VulkanView frame on the stack - // and with its posted events already discarded. - // + // Deferred: this can be reached from inside the VulkanView's present path. oldVulkanView->deleteLater(); newGLView->videoDevice()->makeCurrent(); newGLView->update(); } - // - // Hot-swap GLView -> VulkanView and rebind the live session to the Vulkan - // device. The forward mirror of fallbackVulkanToGLView, reached when the - // user selects 10-bit while the main window is running on the OpenGL - // GLView and Vulkan reports 10-bit presentation support. - // - // The caller persists the desired 10-bit depth before invoking this, so a - // later Vulkan -> GL fallback rebuilds GL at the right depth. - // + // Hot-swap GLView -> VulkanView and rebind the live session to the Vulkan device. void RvDocument::swapGLViewToVulkan() { if (!m_glView || isClosing()) @@ -1068,10 +998,7 @@ namespace Rv cout << "INFO: RvDocument: switching main view from OpenGL to Vulkan." << endl; - // - // Flush any GLView still pending from an earlier swap before taking - // ownership of m_oldGLView below (mirrors rebuildGLView). - // + // Flush any GLView pending from an earlier swap before reusing m_oldGLView. lazyDeleteGLView(); GLView* oldGLView = m_glView; @@ -1092,23 +1019,14 @@ namespace Rv m_stackedLayout->removeWidget(oldGLView); // - // Optimistic commit. VulkanWindow creates its Vulkan surface and - // swapchain from exposeEvent(), so initialization is asynchronous and - // cannot be verified here. Commit to Vulkan now and rely on the - // existing backstop: if init later fails, VulkanWindow::initialize() - // calls requestGLFallback() -> fallbackVulkanToGLView(), which rebuilds - // a GLView. That backstop is guarded on m_vulkanView, so assign it - // before showing. Until the surface exists VulkanWindow::render() - // no-ops, so nothing presents to an uninitialized swapchain -- worst - // case a brief blank frame during the swap. + // Vulkan initializes asynchronously from exposeEvent(), so commit now; + // a failure falls back through requestGLFallback(), which is guarded + // on m_vulkanView, so assign it before showing. // m_vulkanView = newVulkanView; m_viewWidget = newVulkanView; - // - // On the Vulkan path m_glView must be null: backend-neutral code - // across RvDocument keys the active backend on (!m_glView). - // + // Backend-neutral code keys the active backend on m_glView being null. m_glView = nullptr; m_vulkanView->show(); @@ -1130,23 +1048,12 @@ namespace Rv m_vulkanView->videoDevice()->sendEvent(TwkApp::RenderContextChangeEvent("vulkan-context-changed", m_vulkanView->videoDevice())); } - // - // Rebuild the desktop presentation devices for the Vulkan backend, - // re-bind the share device and re-open the presentation output on the - // selected screen, so the second display agrees with the promoted - // Vulkan main view instead of showing black. There is no GL device to - // share from on this path, hence the null share device -- the - // ScreenView falls back to the default surface format and - // Qt::AA_ShareOpenGLContexts still puts every context in one group. - // + // No GL share device on Vulkan; Qt::AA_ShareOpenGLContexts still shares. RvApp()->rebuildDesktopVideoDevices(nullptr, true); m_vulkanView->videoDevice()->translator().setCurrentModifiers(cur); - // - // Lazy-delete the old GLView (mirrors rebuildGLView): deleting it - // inline while the swap is still settling can dump core. - // + // Lazy-delete, as in rebuildGLView: deleting inline can crash. m_oldGLView = oldGLView; m_oldGLView->hide(); QTimer::singleShot(100, this, SLOT(lazyDeleteGLView())); @@ -1181,12 +1088,6 @@ namespace Rv void RvDocument::rebuildGLView(bool stereo, bool vsync, bool doubleBuffer, int red, int green, int blue, int alpha) { - // - // Rebuilding the GLView only makes sense on the OpenGL path. On the - // Vulkan/Metal presentation path m_glView is null and every deref below - // would crash. Current callers already bail when !m_glView, so this is a - // defensive guard against future callers. - // if (!m_glView) { return; @@ -1252,14 +1153,6 @@ namespace Rv if (resetGLPrefs) resetGLStateAndPrefs(); - // - // Rebuild the desktop presentation devices against the current display - // depth, re-bind the share device, and re-open the presentation output - // on the selected screen. On an 8-bit depth change the backend does not - // cross the Vulkan threshold so the rebuild itself is a no-op, but the - // share device and the presentation output are still re-bound to the - // new GLView. - // RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice(), false); m_glView->videoDevice()->translator().setCurrentModifiers(cur); @@ -1271,16 +1164,18 @@ namespace Rv void RvDocument::showDiagnostics() { if (m_diagnosticsDock) + { m_diagnosticsDock->show(); + } } void RvDocument::setStereo(bool b) { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - // GL-format queries below are only valid on the OpenGL path; on the - // Vulkan presentation path m_glView is null. if (!m_glView) + { return; + } #endif const bool vsync = m_glView->format().swapInterval() == 1; const bool stereo = m_glView->format().stereo(); @@ -1301,7 +1196,9 @@ namespace Rv return; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) if (!m_glView) + { return; + } #endif const bool vsync = m_glView->format().swapInterval() == 1; const bool stereo = m_glView->format().stereo(); @@ -1320,7 +1217,9 @@ namespace Rv { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) if (!m_glView) + { return; + } #endif bool vsync = m_glView->format().swapInterval() == 1; const bool stereo = m_glView->format().stereo(); @@ -1339,21 +1238,12 @@ namespace Rv { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) // - // 10-bit output is delivered by the Vulkan backend. We must NOT - // rebuild the OpenGL context at 10-bit here: OpenGL cannot present - // 10-bit on the affected hardware (Mesa GLX, Windows WGL negotiation), - // so the rebuild fails validity and used to pop a misleading "Display - // Configuration is Invalid" dialog that also zeroed the preference. - // Persist the 10-bit intent instead, and apply it by promoting the - // live view to Vulkan. + // 10-bit goes through Vulkan: an OpenGL context rebuild at 10-bit + // fails on Mesa GLX and WGL. Persist the preference first so a later + // Vulkan -> GL fallback and the next launch both honour it. // if (type == OpenGL1010102) { - // - // Persist the intent first, so a later Vulkan -> GL fallback - // rebuilds GL at the right depth and the next launch selects the - // Vulkan backend. Never reset or zero the preference here. - // Rv::Options& opts = Options::sharedOptions(); opts.dispRedBits = 10; opts.dispGreenBits = 10; @@ -1370,31 +1260,21 @@ namespace Rv settings.endGroup(); } - // - // Already on the Vulkan path: the running view is already 10-bit, - // so there is nothing to apply. - // + // Already on Vulkan. if (!m_glView) + { return; + } - // - // Vulkan can present 10-bit: promote the live GLView in place. No - // restart, no notice. - // if (VulkanView::supports10BitPresentation()) { swapGLViewToVulkan(); return; } - // - // Honest error: this hardware or driver cannot present 10-bit. Do - // not attempt the promotion; the view stays on 8-bit OpenGL. - // QMessageBox box(this); box.setWindowModality(Qt::WindowModal); #ifdef PLATFORM_LINUX - // Show the RV icon so the source of the dialog is obvious. box.setIconPixmap(QPixmap(qApp->applicationDirPath() + QString(RV_ICON_PATH_SUFFIX)).scaledToHeight(64)); #else box.setIcon(QMessageBox::Critical); @@ -1409,14 +1289,7 @@ namespace Rv return; } - // - // Switching away from 10-bit while the Vulkan backend is live - // (m_glView is null). Persist the new depth and hot-swap Vulkan -> - // OpenGL so the change applies immediately. Vulkan -> GL is always - // available -- it is the same path taken when Vulkan presentation - // fails at runtime -- so unlike the 10-bit request above this needs no - // restart. - // + // Leaving 10-bit while Vulkan is live: persist and swap back to OpenGL. if (!m_glView) { const int bits = (type == OpenGL8888) ? 8 : 0; @@ -1442,16 +1315,7 @@ namespace Rv return; } #endif - // - // Persist the requested depth before anything below can early-return. - // - // The 10-bit and the Vulkan-live branches above both write Options and - // QSettings first; this branch -- OpenGL already live -- used to write - // neither, and it returns early whenever the GL context already has the - // requested depth. Selecting 8-bit here was therefore a no-op on the - // very state that other subsystems read back as "the requested display - // depth", leaving Options claiming 10-bit for the rest of the session. - // + // Persist the requested depth before anything below can early-return. { const int bits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 10 : 0); const int alphaBits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 2 : 0); @@ -2743,7 +2607,9 @@ namespace Rv { TwkApp::VideoDevice* vdev = viewVideoDevice(); if (!vdev) + { return; + } TwkApp::GenericStringEvent event("file-changed", vdev, path.toUtf8().data()); vdev->sendEvent(event); } diff --git a/src/lib/app/RvCommon/RvPreferences.cpp b/src/lib/app/RvCommon/RvPreferences.cpp index d9a4d7596..35295dfb3 100644 --- a/src/lib/app/RvCommon/RvPreferences.cpp +++ b/src/lib/app/RvCommon/RvPreferences.cpp @@ -128,10 +128,7 @@ namespace Rv connect(m_ui.exrNumThreadsEdit, SIGNAL(textChanged(const QString&)), this, SLOT(exrThreadNumChanged(const QString&))); setWindowTitle(UI_APPLICATION_NAME " Preferences"); - // - // Auxiliary window: never the reason RV stays alive. See - // RvConsoleWindow's constructor for why that matters. - // + // Auxiliary window: never keeps RV alive. setAttribute(Qt::WA_QuitOnClose, false); #ifndef PLATFORM_DARWIN diff --git a/src/lib/app/RvCommon/RvProfileManager.cpp b/src/lib/app/RvCommon/RvProfileManager.cpp index af39bedf0..6fd0a8b4c 100644 --- a/src/lib/app/RvCommon/RvProfileManager.cpp +++ b/src/lib/app/RvCommon/RvProfileManager.cpp @@ -34,10 +34,7 @@ namespace Rv { m_ui.setupUi(this); - // - // Auxiliary window: never the reason RV stays alive. See - // RvConsoleWindow's constructor for why that matters. - // + // Auxiliary window: never keeps RV alive. setAttribute(Qt::WA_QuitOnClose, false); loadModel(); diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index b8aebac39..738997d2d 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -20,20 +20,9 @@ namespace Rv , m_vulkanView(nullptr) { // - // The base constructor advertised RGB8. This device presents through a - // 10-bit Vulkan swapchain, so re-advertise at the depth actually - // delivered -- the depth is only ever used to build the description - // string, but that string is what Preferences, the top-view toolbar - // and humanReadableID() show. - // - // Claiming 10 here is sound: this class is only instantiated when - // DesktopVideoDevice::shouldUseVulkanPresentation() is true, which - // already required the 10-bit surface-format probe to succeed. - // - // addDefaultDataFormats() appends the same six stereo modes in the - // same order at any depth, so the indices are unchanged -- and the - // persisted "dataFormat" preference is an index, so a user's stereo - // choice survives a GL <-> Vulkan device rebuild. + // Re-advertise at 10-bit (the base advertised RGB8); only built once + // the 10-bit probe has passed. The format indices are unchanged, so + // the persisted "dataFormat" preference survives a backend rebuild. // m_dataFormats.clear(); addDefaultDataFormats(10); @@ -43,55 +32,21 @@ namespace Rv void VulkanDesktopVideoDevice::open(const StringVector& args) { - // - // A passive Vulkan presentation surface. The null doc is what makes it - // passive: VulkanWindow::render() returns at `!session`, - // requestGLFallback() returns at `!m_doc`, and presentationAllowed() - // guards on it -- so this window never drives the session and never - // drags the main window into a GL fallback. It is composited into and - // presented by this device, via the inherited transfer()/transfer2() - // and syncBuffers() below. - // + // A null doc makes the view passive (see VulkanWindow.h). m_vulkanView = new VulkanView(/*doc*/ nullptr, /*parent*/ nullptr, /*noResize*/ true); - // - // The VulkanView owns its QTVulkanVideoDevice; handing it to the base - // m_viewDevice lets the inherited transfer()/transfer2() composite into - // its GL FBO exactly as they do for the GL ScreenView path. - // - // Note this does not go through setViewWidget(): that takes a - // QOpenGLWidget and there is none here, so the base m_view stays null - // and the translator has to be installed by hand. - // + // The view owns this device. There is no QOpenGLWidget, so this + // bypasses setViewWidget() and installs the translator by hand. setViewDevice(m_vulkanView->videoDevice()); - // - // Never take focus or activation. This is a second-display output - // surface, not something the user interacts with: a focusable - // top-level here fights the main window for activation, and the - // resulting WindowActivate storm saturates the event loop -- which is - // what stopped annotation strokes from ever receiving their drag - // events. WA_ShowWithoutActivating keeps show() from stealing - // activation; WindowDoesNotAcceptFocus keeps the window manager from - // handing it back later. - // + // A focusable second top-level fights the main window for activation. m_vulkanView->setAttribute(Qt::WA_ShowWithoutActivating, true); m_vulkanView->setWindowFlag(Qt::WindowDoesNotAcceptFocus, true); - // - // The base class allocates a translator alongside its ScreenView (see - // setViewWidget), so one is created here for parity. It is inert: - // DesktopVideoDevice::translator() has no callers, and the view is - // built without an event widget so it produces no events of its own. - // + // Inert, created for parity with the base class's setViewWidget(). m_translator = new QTTranslator(this, m_vulkanView); - // - // Place the window before showing it. VulkanView's constructor already - // realized the platform window, but the swapchain is only built on - // first expose -- so the geometry set here is what decides which - // screen it lands on. - // + // Place before show(): the swapchain is built on first expose. const QRect g = screenGeometry(); m_vulkanView->move(g.x(), g.y()); m_vulkanView->setGeometry(g); @@ -100,19 +55,10 @@ namespace Rv m_vulkanView->setGeometry(g); - // - // show() both makes the window visible on the target screen and drives - // the expose that creates the Vulkan surface and swapchain. Without it - // the presentation surface never initializes and never presents. - // m_vulkanView->show(); - // - // Prime the offscreen GL context and FBO so the inherited transfer()'s - // readiness guard passes on the first frame: QTVulkanVideoDevice::fboID() - // reports 0 until the context and FBO have been created, and transfer() - // returns early while it does. - // + // Create the FBO now so transfer()'s fboID() readiness check passes on + // the first frame. if (m_viewDevice) { m_viewDevice->makeCurrent(); @@ -122,15 +68,9 @@ namespace Rv void VulkanDesktopVideoDevice::close() { // - // The VulkanView owns its QTVulkanVideoDevice (== the base - // m_viewDevice), so detach the base pointer WITHOUT deleting it, then - // delete the view -- whose destructor frees the device and its - // Vulkan/interop resources. Chaining to DesktopVideoDevice::close() - // here would delete the device a second time. - // - // Because we do not chain, the base's FBO-clone teardown has to be - // invoked explicitly -- and it has to run before setViewDevice(nullptr), - // since it needs that device to make the GL context current. + // The view owns m_viewDevice, so do not chain to the base close(), + // which would delete it again. releaseFBOClones() needs the device + // current, so it runs before setViewDevice(nullptr). // releaseFBOClones(); @@ -155,35 +95,9 @@ namespace Rv void VulkanDesktopVideoDevice::redraw() const { - // - // Deliberately empty -- do NOT present from here. - // - // Note that Session::askForRedraw() cannot actually reach this: it - // casts the output device to TwkGLF::GLVideoDevice, and every desktop - // output device derives from TwkGLF::GLBindableVideoDevice, a sibling - // class. The override is defence, not a hot path -- but the behaviour - // it defends against is real, because redraw() is also reachable from - // DesktopVideoDevice::syncBuffers() and from arbitrary callers, - // including from inside a mouse-motion handler while an annotation - // stroke is being drawn with the main view's GL context current. - // - // Presenting here means QTVulkanVideoDevice::syncBuffers(), which - // makes its own offscreen context current and does not restore the - // previous one. That silently steals the context from whatever was - // mid-draw: the first stroke point lands, then every later GL call - // goes to the presentation device's context and the stroke stops. It - // would also run a swapchain present per motion event. - // - // Nothing is lost by doing nothing. askForRedraw() redraws the control - // device, which schedules VulkanWindow::render(); that renders the - // frame, composites into this device through the inherited transfer(), - // and then presents this device in-frame via syncBuffers(). A doc-less - // presentation window additionally presents once on expose, so it is - // never left blank. - // - // The base does effectively the same thing: its m_view->update() only - // asks Qt to composite a QOpenGLWidget whose paintGL() is empty. - // + // Intentionally empty: presenting here would steal the current GL + // context mid-draw. The control device's render() presents this device + // in-frame via syncBuffers(). } void VulkanDesktopVideoDevice::redrawImmediately() const { redraw(); } diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index 72978d025..213ba4c3a 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -34,35 +34,16 @@ namespace Rv , m_watchedParentWindow(nullptr) , m_reattachPending(false) { - // - // Native Vulkan viewport window (renders + presents on its own - // surface). - // m_vulkanWindow = new VulkanWindow(doc, noResize); - - // - // Embed the native window in the widget tree. - // m_container = QWidget::createWindowContainer(m_vulkanWindow, this); - // - // A doc-less view is a passive presentation output owned by a - // VulkanDesktopVideoDevice: it is composited into and presented by - // that device and must never take part in input handling. Giving it - // focus is actively harmful -- a second top-level that accepts focus - // fights the main window for activation, and the resulting - // WindowActivate storm starves the event loop (observed as annotation - // strokes never receiving their drag events). - // + // A doc-less view is a passive presentation output (see VulkanWindow.h). const bool passiveOutput = (m_doc == nullptr); m_container->setFocusPolicy(passiveOutput ? Qt::NoFocus : Qt::StrongFocus); - // - // Create the platform surface up-front: Qt can only hand out a - // VkSurfaceKHR for a window that has one, and RV queries the - // presentation device during startup before the window is shown. - // + // RV queries the presentation device before the window is shown, and + // Qt only hands out a VkSurfaceKHR once the platform surface exists. m_vulkanWindow->create(); QVBoxLayout* layout = new QVBoxLayout(this); @@ -70,18 +51,9 @@ namespace Rv layout->setSpacing(0); layout->addWidget(m_container); - // - // Last-resort guard: if the viewport window is destroyed anyway (i.e. - // detaching it in parentWindowDestroyed() did not get there first), - // make sure nothing here is left holding it. - // + // Last-resort guard in case parentWindowDestroyed() did not detach it. connect(m_vulkanWindow, &QObject::destroyed, this, [this]() { m_vulkanWindow = nullptr; }); - // - // The device drives the Vulkan surface (the window) for presentation, - // and uses the container QWidget for event / coordinate translation - // (height-based y-flip, mapToGlobal, mouse grab). - // ostringstream str; if (m_doc) { @@ -89,20 +61,11 @@ namespace Rv } else { - // - // A doc-less view is a presentation output owned by a - // VulkanDesktopVideoDevice. There can be one per screen and they - // all carry a null doc, so keying the name on the doc would give - // every one of them the same name; key it on the view instead. - // + // One output per screen, all doc-less: key the name on the view. str << UI_APPLICATION_NAME " Presentation (Vulkan)" << "/" << static_cast(this); } - // - // No event widget for a passive output: QTVulkanVideoDevice only - // builds a QTTranslator when given one, and VulkanWindow::event() - // bails at !hasTranslator(), so this makes the whole window inert for - // input instead of relying on each handler to notice it has no doc. - // + // No event widget, so no QTTranslator: VulkanWindow::event() then + // ignores all input for a passive output. m_videoDevice = new QTVulkanVideoDevice(nullptr, str.str(), m_vulkanWindow, passiveOutput ? nullptr : m_container); m_vulkanWindow->setVideoDevice(m_videoDevice); m_vulkanWindow->setEventWidget(passiveOutput ? nullptr : m_container); @@ -118,47 +81,19 @@ namespace Rv setFocusPolicy(Qt::NoFocus); } - // - // Realize the top-level's window now, and watch for Qt replacing it. - // - // Unlike GLView this does not call createWinId() on the top level: - // that exists there to pin the window's composition to OpenGL before - // any render-to-texture widget joins the tree, and there is no such - // API to pin here -- the viewport presents through Vulkan on its own - // surface and composites with nothing. - // watchParentWindow(); } VulkanView::~VulkanView() { // - // Two things have to be undone before the device goes away, both of - // them consequences of the viewport window outliving the widget tree - // in the detached state (see parentWindowDestroyed()). - // - // The window holds a raw back-pointer to the device and would keep - // using it -- VulkanWindow::event() and render() both dereference it - // -- so clear that first. And while detached the container has no - // parent widget, so it would not be destroyed along with this widget: - // it would survive as a stray top-level owning the viewport window, - // still pointing at a deleted device. + // A detached viewport window (see parentWindowDestroyed()) outlives the + // widget tree and holds a raw pointer to the device, and a detached + // container is not deleted with this widget. Undo both here. // - // Everything below also has to happen before ~QWidget runs. - // - // ~QWidget destroys the widget's own QWidgetWindow and its child - // widgets, and both of those emit destroyed() -- at a point where the - // VulkanView sub-object is already gone. Delivering either signal - // there invokes a slot on an object that no longer dynamic_casts to - // VulkanView, which is a hard Q_ASSERT_X in Qt - // (qobjectdefs_impl.h assertObjectType) for the parentWindowDestroyed - // member slot, and a write through a dangling `this` for the lambda - // below. - // - // A presentation output view is what makes this reachable: it is - // top-level, so the window it watches is its own (see - // watchParentWindow) and dies with it. The main view watches the - // enclosing document window, which outlives it. + // This must run before ~QWidget, which emits destroyed() from children + // after the VulkanView sub-object is gone; delivering it to our slots + // would trip Qt's assertObjectType or write through a dangling `this`. // if (m_watchedParentConnection) { @@ -188,12 +123,8 @@ namespace Rv { QWidget::showEvent(event); - // - // The container parents the viewport window to the top-level window - // while being shown, so the parent to watch only becomes known here -- - // and one turn of the event loop later, since the container's own show - // is nested inside this one. - // + // The parent is only known once the container is shown, which is + // nested inside this show, so also check one event-loop turn later. watchParentWindow(); QTimer::singleShot(0, this, &VulkanView::watchParentWindow); } @@ -201,54 +132,48 @@ namespace Rv void VulkanView::watchParentWindow() { // - // Watch the top-level widget's window rather than the viewport - // window's current parent: it is the object Qt destroys, and it is - // knowable before the container gets around to re-parenting the - // viewport into it. - // - // Nothing to watch when this view is its own top level, as a - // presentation output view is. The point of this is to survive Qt - // replacing the *enclosing* window (see parentWindowDestroyed); a - // standalone output window has no such enclosing tree, and watching - // itself only creates a connection that fires while the view is being - // destroyed. + // Watch the top-level widget's window: it is what Qt destroys, and it + // is known before the container re-parents the viewport into it. A + // top-level view (a presentation output) has no enclosing window. // QWidget* topLevel = window(); if (topLevel == this) + { return; + } QWindow* topLevelWindow = topLevel ? topLevel->windowHandle() : nullptr; if (topLevelWindow == m_watchedParentWindow) + { return; + } if (m_watchedParentConnection) + { disconnect(m_watchedParentConnection); + } m_watchedParentWindow = topLevelWindow; if (topLevelWindow) + { m_watchedParentConnection = connect(topLevelWindow, &QObject::destroyed, this, &VulkanView::parentWindowDestroyed); + } } void VulkanView::parentWindowDestroyed() { // - // Emitted at the top of the window's ~QObject, before it deletes its - // children, so detaching here is what saves the viewport from being - // deleted along with it. The window becomes parentless for the moment; - // it is hidden so it cannot flash on screen as a stray top-level, and - // re-attached once the top-level has its new window. + // Emitted before the window deletes its children, so detaching here + // saves the viewport. It is hidden while parentless and re-attached + // once the top-level has its new window. // QWindow* destroyedWindow = m_watchedParentWindow; m_watchedParentWindow = nullptr; - // - // Only the viewport's actual parent matters. Before the container has - // re-parented it, the viewport still belongs to QWindowContainer's - // internal placeholder parent, and pulling it off that would break the - // container's own bookkeeping. - // + // Leave it alone while it still belongs to QWindowContainer's + // placeholder parent. if (m_vulkanWindow && m_vulkanWindow->parent() == destroyedWindow) { m_vulkanWindow->hide(); @@ -256,24 +181,25 @@ namespace Rv } // - // Take the container out of the widget tree for the duration as well. - // Qt reaches window containers through QWindowContainer::parentWasMoved() - // on every layout pass and dereferences the top-level's windowHandle() - // without checking it -- and that is null from here until Qt recreates - // the window. A layout pass runs before then, inside this same - // reparent, so a container left in the tree faults there. + // QWindowContainer::parentWasMoved() dereferences the top-level's + // windowHandle() unchecked, and it is null until Qt recreates it, so + // the container must leave the tree too. // if (m_container) { if (layout()) + { layout()->removeWidget(m_container); + } m_container->hide(); m_container->setParent(nullptr); } if (m_reattachPending) + { return; + } m_reattachPending = true; QTimer::singleShot(0, this, &VulkanView::reattachVulkanWindow); @@ -284,69 +210,68 @@ namespace Rv m_reattachPending = false; if (!m_vulkanWindow) + { return; + } QWidget* topLevel = window(); QWindow* topLevelWindow = topLevel ? topLevel->windowHandle() : nullptr; if (!topLevelWindow) { - // - // Qt recreates the top-level's window lazily (on the next show), so - // keep waiting rather than forcing it here. - // + // Qt recreates the top-level's window lazily; keep waiting. m_reattachPending = true; QTimer::singleShot(0, this, &VulkanView::reattachVulkanWindow); return; } - // - // Put the container back first: re-parenting it makes QWindowContainer - // re-adopt the viewport window into the new top-level window itself. - // + // Re-parenting the container makes it re-adopt the viewport window. if (m_container) { m_container->setParent(this); if (layout()) + { layout()->addWidget(m_container); + } m_container->show(); setFocusProxy(m_container); } if (m_vulkanWindow->parent() != topLevelWindow) + { m_vulkanWindow->setParent(topLevelWindow); + } m_vulkanWindow->show(); watchParentWindow(); - // - // The container drives the viewport's geometry from its own, so nudge a - // layout pass to put the re-attached window back in place. - // if (m_container) { m_container->updateGeometry(); if (layout()) + { layout()->activate(); + } } - // - // Re-parenting gives the viewport a new platform window, which - // invalidates the VkSurfaceKHR. VulkanWindow notices on its next - // expose and rebuilds; asking for a redraw is what gets it there. - // + // The new platform window invalidates the VkSurfaceKHR; the redraw + // makes VulkanWindow rebuild it. if (m_doc && m_doc->session()) + { m_doc->session()->askForRedraw(); + } } void VulkanView::stopProcessingEvents() { if (m_vulkanWindow) + { m_vulkanWindow->stopProcessingEvents(); + } } bool VulkanView::firstPaintCompleted() const { return m_vulkanWindow && m_vulkanWindow->firstPaintCompleted(); } diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 086ad85e7..c976a2573 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -40,9 +40,8 @@ #include #include #ifdef PLATFORM_WINDOWS -// WIN32_LEAN_AND_MEAN prevents from including the legacy -// , which otherwise collides with the already -// pulled in transitively by Qt headers above. +// Keep from pulling in , which collides with Qt's +// . #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif @@ -52,57 +51,34 @@ #endif // -// Environment variables recognized by the Vulkan presentation path -// ---------------------------------------------------------------- -// These exist so a corrupted or failing display can be narrowed to a stage -// without a rebuild -- notably by a tester running a build on hardware the -// developer cannot access. Every override is reported in the startup record -// (see VulkanWindow::emitPresentationRecord), alongside the value negotiation -// would otherwise have chosen. +// Environment overrides, all reported in the startup record: // -// RV_VULKAN_FORCE_CPU_PRESENT -// Set (to any value) to skip GL<->Vulkan zero-copy interop entirely and -// present via the CPU readback path. Still 10-bit, just slower. -// -// RV_VULKAN_FORCE_TILING = optimal | linear -// Override the negotiated shared-image tiling. The override is honored -// only if the driver reports that tiling as exportable; otherwise it is -// logged and refused, because presenting through a configuration whose -// correctness was not established is what this path is meant to avoid. -// An unrecognized value is logged and ignored (negotiation proceeds). -// -// RV_VULKAN_FORCE_NO_DEDICATED -// Set (to any value) to suppress dedicated allocation even when the -// driver reports it as preferred. Refused when the driver reports -// DEDICATED_ONLY, since that is a requirement rather than a preference. -// RV_VULKAN_DISABLE_DEDICATED_ALLOCATION is kept as an older alias. -// -// Both sides of the interop read their settings from one negotiated struct, -// so an override applies to the Vulkan export and the GL import together. +// RV_VULKAN_FORCE_CPU_PRESENT skip zero-copy interop; CPU readback. +// RV_VULKAN_FORCE_TILING optimal | linear; refused if the driver +// does not report it exportable. +// RV_VULKAN_FORCE_NO_DEDICATED suppress preferred dedicated allocation; +// refused for DEDICATED_ONLY. Alias: +// RV_VULKAN_DISABLE_DEDICATED_ALLOCATION. // namespace Rv { using namespace std; - // Accumulators for the -debug gpu frame-time report in render(). One - // VulkanWindow drives the frame loop, so file statics are sufficient. + // -debug gpu frame-time accumulators. Only one VulkanWindow drives the + // frame loop, so file statics suffice. static unsigned int s_diagFrames = 0; static double s_diagRenderMs = 0.0; static double s_diagMainPresentMs = 0.0; static double s_diagOutPresentMs = 0.0; static double s_diagFenceWaitMs = 0.0; static double s_diagAcquireMs = 0.0; - // Wall clock between successive render() entries: the loop period. static double s_diagLoopMs = 0.0; static TwkUtil::Timer s_diagLoopTimer; - // addSyncSample()/postRender() run inside render() but after the presents, - // so they are part of the frame period without being part of "total". + // Post-present work: in the frame period but not in "total". static double s_diagPostRenderMs = 0.0; - // Pointer side of the same report. handler = time spent inside the - // Mu/annotation handler for one pointer event; eventToRender = age of the - // newest pointer event when the frame answering it starts rendering, so it - // includes the handler and any wait in the event loop. + // handler: time in the pointer handler. eventToRender: age of the newest + // pointer event when its frame starts rendering. static double s_diagPointerHandlerMs = 0.0; static unsigned int s_diagPointerEvents = 0; static double s_diagPointerAgeMs = 0.0; @@ -110,40 +86,33 @@ namespace Rv static TwkUtil::Timer s_diagPointerTimer; static bool s_diagPointerPending = false; - // eventToRetire: age of the pointer event a frame answered, measured when - // that frame's GPU work retires. This is the end-to-end interactive - // latency; eventToRender covers only the input half of it. - // - // Closing a sample out needs an absolute clock, because a frame retires - // some frames after the event that produced it, so one free-running timer - // is read as a timestamp source. + // eventToRetire: end-to-end latency, from the pointer event to its + // frame's GPU retirement. Needs an absolute clock since retirement lags + // by several frames. static TwkUtil::Timer s_diagClock; static double diagNow() { if (!s_diagClock.isRunning()) + { s_diagClock.start(); + } return s_diagClock.elapsed(); } - // Timestamp of the pointer event the frame currently being rendered - // answers (-1 when this frame answers no new event), handed to the - // in-flight slot when that frame is submitted. + // Event time for the frame being rendered (-1 if none), handed to its + // slot at submit. static double s_diagFrameEventTime = -1.0; - static std::array s_diagSlotEventTime{}; - static std::array s_diagSlotArmed{}; + static std::array s_diagSlotEventTime{}; + static std::array s_diagSlotArmed{}; static double s_diagEventToRetireMs = 0.0; static unsigned int s_diagEventToRetireSamples = 0; using namespace TwkApp; using namespace IPCore; - // Both A2B10G10R10 and A2R10G10B10 are 10-bit-per-channel packed formats; - // they differ only in R/B component order. Both are acceptable for 10-bit - // presentation -- the R/B order is handled where pixels are packed (CPU - // fallback) or blitted (GPU interop). A2B10G10R10 (== GL_RGB10_A2) is - // preferred when the surface offers it, but many Linux/RADV surfaces only - // advertise A2R10G10B10. + // R/B order is handled where pixels are packed or blitted. Many RADV + // surfaces only advertise A2R10G10B10. static bool isTenBitFormat(VkFormat f) { return f == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || f == VK_FORMAT_A2R10G10B10_UNORM_PACK32; } static bool findGraphicsPresentQueue(VkPhysicalDevice device, VkSurfaceKHR surface, uint32_t& familyIndex) @@ -170,11 +139,15 @@ namespace Rv { uint32_t formatCount = 0; if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + { return false; + } std::vector formats(formatCount); if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, formats.data()) != VK_SUCCESS) + { return false; + } return std::any_of(formats.begin(), formats.end(), [](const VkSurfaceFormatKHR& format) { return isTenBitFormat(format.format); }); } @@ -183,11 +156,15 @@ namespace Rv { uint32_t extensionCount = 0; if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr) != VK_SUCCESS) + { return false; + } std::vector extensions(extensionCount); if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, extensions.data()) != VK_SUCCESS) + { return false; + } return std::any_of(extensions.begin(), extensions.end(), [name](const VkExtensionProperties& extension) { return strcmp(extension.extensionName, name) == 0; }); @@ -233,11 +210,6 @@ namespace Rv , m_lastKey(0) , m_lastKeyType(QEvent::None) { - // - // This is a real QWindow, so the surface type and format can simply be - // declared here -- no WA_NativeWindow / WA_PaintOnScreen dance, and no - // waiting for a widget to grow a windowHandle(). - // setSurfaceType(QSurface::VulkanSurface); QSurfaceFormat fmt; @@ -255,19 +227,10 @@ namespace Rv VulkanWindow::~VulkanWindow() { - // - // m_videoDevice is owned by the hosting VulkanView, not by this window - // (see setVideoDevice); only the Vulkan resources are torn down here. - // m_videoDevice = nullptr; - // - // Normally a no-op: the QEvent::PlatformSurface / SurfaceAboutToBeDestroyed - // handler in event() has already released everything, because by the time - // a QWindow reaches its destructor its surface is usually gone. This is - // only the backstop for the paths where the window is deleted without - // ever having had a platform window destroyed under it. - // + // Usually a no-op: the SurfaceAboutToBeDestroyed handler in event() + // already released everything. releaseVulkanResources(); } @@ -287,7 +250,9 @@ namespace Rv bool VulkanWindow::physicalDeviceMatchesUUID(const unsigned char* uuid, size_t size) const { if (!m_vkPhysicalDevice || !uuid || size != VK_UUID_SIZE) + { return false; + } VkPhysicalDeviceIDProperties idProperties = {}; idProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES; @@ -321,40 +286,23 @@ namespace Rv m_initialized = true; m_initializedHandle = handle(); - // - // NOTE: session initialization is deliberately NOT driven from here. - // Loading packages creates web panels, and adding a QWebEngineView - // makes Qt tear down the main window's native subtree -- destroying - // this view while this method is still on the stack, so every later - // member access is a use-after-free. - // RvApplication::newSessionFromFiles() calls - // RvDocument::initializeSession() after show() instead, with no view - // callback in the call chain. - // + // Session init must not run from here: loading packages can create a + // QWebEngineView, which destroys this window while it is on the stack. + // RvApplication::newSessionFromFiles() does it after show() instead. } // - // One process-lifetime QVulkanInstance, shared by the 10-bit probe - // (supports10BitPresentation) and every VulkanWindow (initVulkan). Created - // lazily and never destroyed. - // - // Tearing a VkInstance down and then creating or using another shortly - // after corrupts RADV's shared X11/xcb WSI state and segfaults a - // subsequent vkGetPhysicalDeviceSurfaceSupportKHR. That is exactly the - // sequence a mid-session GL->Vulkan promotion produces: the probe runs, - // drops its throwaway instance, and a VulkanWindow initializes moments - // later. Keeping one instance alive for the whole process removes the - // teardown entirely. initVulkan already relied on a never-destroyed - // static instance, so this only extends the same lifetime to the probe. + // One process-lifetime QVulkanInstance, shared by the 10-bit probe and + // every VulkanWindow, never destroyed: destroying a VkInstance shortly + // before another init corrupts RADV's X11 WSI state and segfaults in + // vkGetPhysicalDeviceSurfaceSupportKHR. // static QVulkanInstance* sharedVulkanInstance() { static QVulkanInstance* instance = []() -> QVulkanInstance* { auto* inst = new QVulkanInstance(); - // Device UUID matching uses vkGetPhysicalDeviceProperties2, which - // is core in Vulkan 1.1. QVulkanInstance otherwise defaults to a - // 1.0 instance even though the presentation code targets 1.1. + // 1.1 for vkGetPhysicalDeviceProperties2 (device UUID matching). inst->setApiVersion(QVersionNumber(1, 1)); if (!inst->create()) { @@ -369,15 +317,7 @@ namespace Rv bool VulkanWindow::supports10BitPresentation() { - // - // Memoized: 10-bit presentation support is a fixed hardware/driver - // property, so probe at most once per process. The probe uses the - // shared, never-destroyed instance and a leaked probe window (see - // sharedVulkanInstance), so it tears down nothing that could corrupt - // RADV's WSI state ahead of a later VulkanWindow init; memoization is - // then just an optimization that avoids re-running the device scan on - // every DesktopVideoDevice::shouldUseVulkanPresentation() call. - // + // A fixed hardware property: probe once per process. static const bool cached = []() -> bool { QVulkanInstance* qtVkInst = sharedVulkanInstance(); @@ -392,12 +332,7 @@ namespace Rv return false; } - // - // Leak the probe window (process-lifetime, never shown). - // Destroying its Vulkan surface right before a real VulkanWindow - // init is part of the same WSI-teardown hazard as destroying the - // instance, so it is never torn down either. - // + // Leaked for the same RADV WSI reason as sharedVulkanInstance(). static QWindow* dummyWindow = []() -> QWindow* { auto* w = new QWindow(); @@ -450,11 +385,6 @@ namespace Rv } } - // - // The probe window, its surface and the shared instance are all - // kept alive for the process lifetime, so nothing is torn down - // here. - // if (ImageRenderer::debugGpu()) { cout << "INFO: VulkanWindow: supports10BitPresentation: returning " << (any10bit ? "true" : "false") << endl; @@ -467,13 +397,11 @@ namespace Rv bool VulkanWindow::initVulkan() { - // Create Instance VkApplicationInfo appInfo = {}; appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; appInfo.pApplicationName = "RV VulkanWindow"; appInfo.apiVersion = VK_API_VERSION_1_1; - // Need surface extensions std::vector instanceExtensions = { VK_KHR_SURFACE_EXTENSION_NAME, #if defined(VK_USE_PLATFORM_WIN32_KHR) @@ -487,9 +415,6 @@ namespace Rv #endif }; - // Reuse the one process-lifetime instance, shared with the 10-bit probe. - // It is never destroyed: see sharedVulkanInstance for why tearing a - // VkInstance down near another init crashes RADV's WSI. QVulkanInstance* qtVkInst = sharedVulkanInstance(); if (!qtVkInst) { @@ -499,9 +424,7 @@ namespace Rv m_vkInstance = qtVkInst->vkInstance(); - // Create Surface. The platform window must exist before Qt can hand out - // a VkSurfaceKHR for it; VulkanView::create()s it up front, but - // re-creating after a reparent can land here first. + // Re-creating after a reparent can get here before the platform window. if (!handle()) { create(); @@ -516,7 +439,6 @@ namespace Rv return false; } - // Pick Physical Device uint32_t deviceCount = 0; vkEnumeratePhysicalDevices(m_vkInstance, &deviceCount, nullptr); if (deviceCount == 0) @@ -549,17 +471,14 @@ namespace Rv } { - // Unconditional: runs once per window init, and pairing this name - // against QTVulkanVideoDevice's GL_RENDERER is how a hybrid-GPU - // machine (GL on the iGPU, Vulkan on the dGPU) is spotted from a - // plain QA log. + // Unconditional: compared against GL_RENDERER to spot hybrid-GPU + // machines. VkPhysicalDeviceProperties props = {}; vkGetPhysicalDeviceProperties(m_vkPhysicalDevice, &props); cout << "INFO: VulkanWindow: initVulkan: picked physical device '" << props.deviceName << "' (of " << deviceCount << " available)" << endl; } - // Create Logical Device float queuePriority = 1.0f; VkDeviceQueueCreateInfo queueCreateInfo = {}; queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; @@ -607,9 +526,6 @@ namespace Rv vkGetDeviceQueue(m_vkDevice, m_queueFamilyIndex, 0, &m_vkQueue); - // Negotiate the GL<->Vulkan interop configuration once, here. It is a - // property of the device, not of a shared-image slot or of the current - // window size, so it must not be recomputed per slot or on resize. negotiateInteropConfig(); if (ImageRenderer::debugGpu()) { @@ -623,7 +539,6 @@ namespace Rv return false; }; - // Command pool VkCommandPoolCreateInfo poolInfo = {}; poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; @@ -633,18 +548,15 @@ namespace Rv return failInit(); } - // Sync objects. The per-swapchain-image renderFinished semaphores live - // in createSwapchain (sized to the image count); here we create only the - // per-in-flight-slot acquire semaphores and frame fences. + // Per-image renderFinished semaphores are created in createSwapchain(). VkSemaphoreCreateInfo semaphoreInfo = {}; semaphoreInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; - // Per-in-flight-slot acquire semaphore + frame fence. Fences are created - // signaled so the first wait on a slot passes without a prior submit. + // Signaled so the first wait on a slot passes. VkFenceCreateInfo fenceInfo = {}; fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + for (uint32_t i = 0; i < kFramesInFlight; ++i) { if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &m_vkImageAvailableSemaphore[i]) != VK_SUCCESS) { @@ -668,9 +580,7 @@ namespace Rv } m_glFallbackRequested = true; - // The OpenGL rung forgoes 10-bit, so it must be visible in the log - // rather than inferred from the absence of a Vulkan record. Callers - // that know why set m_presentPathReason before calling. + // Logged explicitly since OpenGL forgoes 10-bit. reportPresentPath(PresentPath::OpenGL, m_presentPathReason.empty() ? std::string("Vulkan presentation could not be established") : m_presentPathReason); @@ -698,62 +608,33 @@ namespace Rv // Rebuild all Vulkan state after the native surface is lost. void VulkanWindow::handleSurfaceLost() { - // - // The platform window was destroyed and recreated underneath us, so - // the VkSurfaceKHR (and everything derived from it) belongs to a - // window that no longer exists. Qt does this whenever the top-level - // QWidgetWindow is replaced -- inserting a QWebEngineView is the - // common trigger -- and VulkanView re-parents this window into the - // new one afterwards (see VulkanView::reattachVulkanWindow). - // - // Tear the Vulkan device down completely and re-initialize against the - // new handle. A swapchain recreate is not enough: the surface handle - // itself is stale, so vkGetPhysicalDeviceSurfaceCapabilitiesKHR and - // vkCreateSwapchainKHR would both be querying a dead object. - // + // Qt replaces the platform window when the top-level QWidgetWindow is + // recreated (e.g. inserting a QWebEngineView). The VkSurfaceKHR itself + // is stale, so a swapchain recreate is not enough. cout << "INFO: VulkanWindow: platform window recreated; rebuilding Vulkan surface" << endl; releaseVulkanResources(); } - // - // Release everything initVulkan()/createSwapchain()/getSharedImageInfo() - // built, in dependency order, and return to the pre-initialize() state so - // the next exposeEvent() re-initializes from scratch. - // - // Ordering constraint: every one of these destroy calls is made against - // objects the driver ties back to the presentation surface -- and the - // VkSurfaceKHR is only valid while the platform window that produced it - // lives. Callers must therefore reach here *before* the platform window is - // destroyed, not after (see the QEvent::PlatformSurface handler). - // void VulkanWindow::releaseVulkanResources() { - // - // The GL side imported this window's shared device memory and - // semaphores as GL memory objects; drop those first so nothing on the - // GL side is left aliasing memory freed just below. syncBuffers() - // re-imports on the next frame if the window comes back. - // + // Drop the GL imports first so GL never aliases memory freed below. if (m_videoDevice) { m_videoDevice->releaseSharedGLObjects(); - // The next initVulkan() may land on a different VkPhysicalDevice, - // so the GL/Vulkan device-UUID match has to be probed again - // rather than reused from the device just torn down. + // The next initVulkan() may pick a different physical device. m_videoDevice->resetInteropDeviceMatch(); } - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + for (uint32_t i = 0; i < kFramesInFlight; ++i) { cleanupSharedImage(i); } cleanupSwapchain(); cleanupVulkan(); - // Owned by the platform window / QVulkanInstance, never destroyed here; - // just forget it, since it does not outlive the window it came from. + // Owned by QVulkanInstance; not destroyed here. m_vkSurface = VK_NULL_HANDLE; m_vkPhysicalDevice = VK_NULL_HANDLE; m_vkSwapchainFormat = VK_FORMAT_UNDEFINED; @@ -772,12 +653,9 @@ namespace Rv return; } - // Recreate only the swapchain, not the shared image. The shared image is - // a content-sized TRANSFER_SRC image, independent of the window-sized - // swapchain. Keep the acquire semaphores too: they are per-frame - // resources, not swapchain resources, and can still be referenced by - // queued submissions when OUT_OF_DATE is reported. createSwapchain() - // waits for the device before retiring swapchain-owned resources. + // The shared image is content-sized and the acquire semaphores are + // per frame and may still be referenced by queued submissions, so only + // the swapchain is recreated. if (!createSwapchain()) { requestGLFallback(); @@ -790,7 +668,7 @@ namespace Rv { vkDeviceWaitIdle(m_vkDevice); - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + for (uint32_t i = 0; i < kFramesInFlight; ++i) { if (m_vkImageAvailableSemaphore[i]) { @@ -803,7 +681,6 @@ namespace Rv m_vkFence[i] = VK_NULL_HANDLE; } } - // m_vkRenderFinished are per-swapchain-image; freed in cleanupSwapchain. if (m_vkCommandPool) { @@ -816,24 +693,13 @@ namespace Rv } m_vkQueue = VK_NULL_HANDLE; m_externalInteropSupported = false; - // Surface is managed by QVulkanInstance? We shouldn't destroy it here if QVulkanInstance owns it, but wait, we got it from - // surfaceForWindow. Actually QVulkanWindow destroys it. We can just leave it for QVulkanInstance to clean up, or we can - // vkDestroySurfaceKHR if needed. For safety we don't destroy instance/surface here, they are tied to Qt. + // The VkSurfaceKHR is owned by QVulkanInstance; do not destroy it here. } // - // Present mode - // - // FIFO everywhere, which is what a viewport and a presentation output - // both want: every image scanned out, none torn. - // - // The env overrides are for measurement. Note that MAILBOX only differs - // from FIFO once the loop is fast enough to fill a swapchain queue; below - // that, both acquires return immediately and the mode is not observable. - // - // RV_VULKAN_PRESENT_MODE (control viewport) - // RV_VULKAN_OUTPUT_PRESENT_MODE (passive presentation output) - // with values fifo | relaxed | mailbox | immediate. + // FIFO everywhere. For measurement, RV_VULKAN_PRESENT_MODE (control + // viewport) and RV_VULKAN_OUTPUT_PRESENT_MODE (presentation output) take + // fifo | relaxed | mailbox | immediate. // static const char* presentModeName(VkPresentModeKHR m) { @@ -855,30 +721,42 @@ namespace Rv static bool presentModeFromName(const char* name, VkPresentModeKHR& mode) { if (!name) + { return false; + } const string n(name); if (n == "fifo") + { mode = VK_PRESENT_MODE_FIFO_KHR; + } else if (n == "relaxed") + { mode = VK_PRESENT_MODE_FIFO_RELAXED_KHR; + } else if (n == "mailbox") + { mode = VK_PRESENT_MODE_MAILBOX_KHR; + } else if (n == "immediate") + { mode = VK_PRESENT_MODE_IMMEDIATE_KHR; + } else + { return false; + } return true; } - // FIFO is the only mode required to be supported, so it is always the - // last resort of the preference list. static VkPresentModeKHR choosePresentMode(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, bool passiveOutput) { uint32_t count = 0; vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, nullptr); std::vector available(count); if (count) + { vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, available.data()); + } const auto supported = [&](VkPresentModeKHR m) { return std::find(available.begin(), available.end(), m) != available.end(); }; @@ -886,7 +764,9 @@ namespace Rv if (presentModeFromName(getenv(passiveOutput ? "RV_VULKAN_OUTPUT_PRESENT_MODE" : "RV_VULKAN_PRESENT_MODE"), forced)) { if (supported(forced)) + { return forced; + } cout << "WARNING: VulkanWindow: requested present mode " << presentModeName(forced) << " is unsupported; using FIFO" << endl; return VK_PRESENT_MODE_FIFO_KHR; } @@ -914,7 +794,6 @@ namespace Rv return false; } - // Negotiate 10-bit format uint32_t formatCount = 0; if (vkGetPhysicalDeviceSurfaceFormatsKHR(m_vkPhysicalDevice, m_vkSurface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) { @@ -928,13 +807,8 @@ namespace Rv return false; } - // - // Unconditional but once per window: a handful of lines, and the pair - // that matters is not the format alone but which colour space each - // 10-bit entry is offered with, plus the order they come in. Vendor - // ordering differences in this exact list are what made a black - // NVIDIA viewport look like a working AMD one. - // + // Unconditional, once per window: the colour space and order of each + // 10-bit entry are vendor-specific. if (!m_loggedSurfaceFormatList) { m_loggedSurfaceFormatList = true; @@ -949,28 +823,9 @@ namespace Rv VkSurfaceFormatKHR surfaceFormat = formats[0]; bool found10bit = false; - // Prefer A2B10G10R10 (== GL_RGB10_A2, the layout the GPU interop shared - // texture and CPU fallback packing produce natively) so the transfer is - // a plain copy. If the surface only offers A2R10G10B10 (common on - // Linux/RADV), accept it too: the opposite R/B order is handled where - // pixels are packed (CPU fallback) and by a component-wise blit (GPU - // interop), so red and blue are not swapped. - // - // The colour space has to be matched as carefully as the format. A - // 10-bit format is commonly advertised more than once, paired with a - // different VkColorSpaceKHR each time, and the enumeration order is - // vendor-specific: with the display in HDR mode NVIDIA lists - // A2B10G10R10 + HDR10_ST2084 ahead of A2B10G10R10 + SRGB_NONLINEAR, - // while AMD lists SRGB_NONLINEAR first. Taking the first format match - // therefore gave NVIDIA a PQ swapchain fed with sRGB-encoded pixels, - // which crushes everything below mid-grey to a couple of nits -- the - // whole viewport, RV's own overlays included, reads as black. - // - // RV's renderer emits sRGB, so SRGB_NONLINEAR is the only correct - // pairing. Honouring an HDR colour space would mean re-encoding the - // shader output to that transfer function, which is a colour-pipeline - // change, not a swapchain choice. - // + // Prefer A2B10G10R10 (== GL_RGB10_A2) so the transfer is a plain copy. + // RV emits sRGB, so require SRGB_NONLINEAR: in HDR mode NVIDIA lists a + // 10-bit HDR10_ST2084 entry first, which renders the viewport black. const auto findTenBit = [&formats](VkFormat wanted, bool requireSrgbNonlinear, VkSurfaceFormatKHR& out) -> bool { for (const auto& fmt : formats) @@ -998,10 +853,7 @@ namespace Rv } } - // No 10-bit format is paired with SRGB_NONLINEAR on this surface. Take - // the 10-bit format anyway rather than dropping to the 8-bit OpenGL - // path: the depth is what the user asked for, and the colour space is - // reported below so a wrong-looking image is traceable to it. + // No SRGB_NONLINEAR pairing: keep 10-bit anyway and warn below. if (!found10bit) { for (const VkFormat wanted : {VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_A2R10G10B10_UNORM_PACK32}) @@ -1016,9 +868,7 @@ namespace Rv if (found10bit) { - // Unconditional, and the colour space is part of it: the format - // alone was never enough to explain a black NVIDIA viewport. - // Latched, because createSwapchain() re-runs on every resize step. + // Unconditional, but only on change (runs on every resize). if (surfaceFormat.format != m_loggedSurfaceFormat.format || surfaceFormat.colorSpace != m_loggedSurfaceFormat.colorSpace) { m_loggedSurfaceFormat = surfaceFormat; @@ -1061,14 +911,11 @@ namespace Rv return false; } - // A doc-less window is a passive presentation output; see - // choosePresentMode(). const VkPresentModeKHR presentMode = choosePresentMode(m_vkPhysicalDevice, m_vkSurface, /*passiveOutput*/ m_doc == nullptr); uint32_t imageCount = capabilities.minImageCount + 1; - // MAILBOX only stays non-blocking with an image to spare: one being - // scanned out, one queued as the newest-wins candidate, one to render - // into. With fewer, acquire blocks and the mode buys nothing. + // MAILBOX needs three images (scanout, queued, rendering) or acquire + // blocks. if (presentMode == VK_PRESENT_MODE_MAILBOX_KHR && imageCount < 3) { imageCount = 3; @@ -1120,12 +967,10 @@ namespace Rv } createInfo.presentMode = presentMode; createInfo.clipped = VK_TRUE; - // Warm recreate: hand the retiring swapchain to the driver so it can reuse - // its backing resources (much cheaper than a cold create on every resize). + // Lets the driver reuse the retiring swapchain's resources on resize. createInfo.oldSwapchain = m_vkSwapchain; - // Create into a local handle so a failed create leaves the existing - // swapchain and command buffers intact (the fallback paths stay valid). + // A failed create leaves the existing swapchain intact. VkSwapchainKHR newSwapchain = VK_NULL_HANDLE; if (vkCreateSwapchainKHR(m_vkDevice, &createInfo, nullptr, &newSwapchain) != VK_SUCCESS) { @@ -1133,14 +978,13 @@ namespace Rv return false; } - // New swapchain is live. Retire the old one only now: wait for its last - // submitted frame to finish, free its command buffers, then destroy it. if (m_vkSwapchain != VK_NULL_HANDLE) { vkDeviceWaitIdle(m_vkDevice); if (!m_vkCommandBuffers.empty()) { - vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, (uint32_t)m_vkCommandBuffers.size(), m_vkCommandBuffers.data()); + vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, static_cast(m_vkCommandBuffers.size()), + m_vkCommandBuffers.data()); m_vkCommandBuffers.clear(); } vkDestroySwapchainKHR(m_vkDevice, m_vkSwapchain, nullptr); @@ -1156,7 +1000,7 @@ namespace Rv allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; allocInfo.commandPool = m_vkCommandPool; allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; - allocInfo.commandBufferCount = (uint32_t)m_vkCommandBuffers.size(); + allocInfo.commandBufferCount = static_cast(m_vkCommandBuffers.size()); if (vkAllocateCommandBuffers(m_vkDevice, &allocInfo, m_vkCommandBuffers.data()) != VK_SUCCESS) { cleanupSwapchain(); @@ -1164,13 +1008,14 @@ namespace Rv return false; } - // Per-swapchain-image present-wait semaphores + in-flight fence map. The - // device is idle here (the retire path above waited on it), so any old - // renderFinished semaphores from a previous swapchain are safe to destroy. + // The device is idle here, so old renderFinished semaphores are safe + // to destroy. for (VkSemaphore sem : m_vkRenderFinished) { if (sem) + { vkDestroySemaphore(m_vkDevice, sem, nullptr); + } } m_vkRenderFinished.assign(imageCount, VK_NULL_HANDLE); VkSemaphoreCreateInfo rfInfo = {}; @@ -1184,7 +1029,6 @@ namespace Rv return false; } } - // Fresh swapchain images: none are in flight yet. m_imagesInFlight.assign(imageCount, VK_NULL_HANDLE); return true; @@ -1199,12 +1043,14 @@ namespace Rv for (VkSemaphore sem : m_vkRenderFinished) { if (sem) + { vkDestroySemaphore(m_vkDevice, sem, nullptr); + } } m_vkRenderFinished.clear(); m_imagesInFlight.clear(); - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + for (uint32_t i = 0; i < kFramesInFlight; ++i) { if (m_vkStagingBuffer[i]) { @@ -1233,7 +1079,6 @@ namespace Rv } } - // Helper to find memory type uint32_t findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) { VkPhysicalDeviceMemoryProperties memProperties; @@ -1250,7 +1095,6 @@ namespace Rv namespace { - // Read an env var that is treated as a boolean flag by presence. bool envFlagSet(const char* name) { return getenv(name) != nullptr; } const char* tilingName(VkImageTiling t) @@ -1291,14 +1135,14 @@ namespace Rv } } - // Decode RV_VULKAN_FORCE_TILING. Returns false when unset or when the - // value is not recognized; an unrecognized value is reported rather - // than silently behaving as if the variable were unset. + // False when RV_VULKAN_FORCE_TILING is unset or unrecognized. bool forcedTilingRequested(VkImageTiling& out) { const char* v = getenv("RV_VULKAN_FORCE_TILING"); if (!v) + { return false; + } std::string s(v); std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(::tolower(c)); }); @@ -1319,12 +1163,8 @@ namespace Rv return false; } - // vkGetPhysicalDeviceImageFormatProperties2 is core in Vulkan 1.1 and - // available as a KHR alias on 1.0 loaders that expose - // VK_KHR_get_physical_device_properties2. Resolved through - // vkGetInstanceProcAddr rather than called directly so a 1.0-only - // loader degrades to "cannot establish exportability" instead of - // dispatching into an entry point it does not implement. + // Resolved dynamically (with the KHR alias) so a 1.0-only loader + // degrades to "not exportable" instead of crashing. PFN_vkGetPhysicalDeviceImageFormatProperties2 getImageFormatProperties2(VkInstance instance) { static PFN_vkGetPhysicalDeviceImageFormatProperties2 fn = nullptr; @@ -1356,41 +1196,16 @@ namespace Rv return disabled; } - // Reverts the shared image to a plain (non-dedicated) allocation, the - // behaviour before the dedicated-allocation query was added. The - // dedicated path is what the spec asks for and what a driver reporting - // requiresDedicatedAllocation needs for an externally-shared image, but - // it changes a code path that already worked, so keep a way to rule it - // out on a machine without a rebuild. bool dedicatedAllocationDisabled() { - // RV_VULKAN_FORCE_NO_DEDICATED is the documented name, matching - // the other RV_VULKAN_FORCE_* overrides; - // RV_VULKAN_DISABLE_DEDICATED_ALLOCATION is kept as an alias so - // existing notes and scripts keep working. static const bool disabled = getenv("RV_VULKAN_FORCE_NO_DEDICATED") != nullptr || getenv("RV_VULKAN_DISABLE_DEDICATED_ALLOCATION") != nullptr; return disabled; } - // How many frames the control viewport may keep in flight. Default 1: - // block on this frame's fence before returning, rather than letting the - // next frame's start-of-frame wait absorb it two frames later. - // RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 restores the deeper pipeline. - // - // Depth is latency, not throughput, and separating the two is what - // fixed the "annotation trails the cursor in presentation mode" bug. - // Both backends ran presentation mode at a similar frame interval, so - // throughput was never the difference; what differed was how old the - // displayed pixels were. With two frames in flight the screen answers - // input from two frames back, and the OpenGL path is effectively one - // deep because paintGL() draws and Qt's following swap waits on that - // same work. - // - // Giving up the second frame costs no measurable frame rate here - // because the loop is GPU-bound either way, and it buys a whole frame: - // measured on a 4K presentation output, eventToRetire settled to - // eventToRender + one frame interval, with no queue left behind it. + // Frames the control viewport may keep in flight. Default 1: depth is + // input latency, and the loop is GPU-bound so a second frame buys no + // throughput. RV_VULKAN_MAX_FRAMES_IN_FLIGHT=2 restores the deeper ring. unsigned int maxFramesInFlight() { static const unsigned int depth = [] @@ -1398,11 +1213,13 @@ namespace Rv const unsigned int kDefault = 1; const char* v = getenv("RV_VULKAN_MAX_FRAMES_IN_FLIGHT"); if (!v) + { return kDefault; + } const int n = atoi(v); - if (n < 1 || n > static_cast(VulkanWindow::FRAMES_IN_FLIGHT)) + if (n < 1 || n > static_cast(VulkanWindow::kFramesInFlight)) { - cout << "WARNING: VulkanWindow: RV_VULKAN_MAX_FRAMES_IN_FLIGHT must be 1.." << VulkanWindow::FRAMES_IN_FLIGHT + cout << "WARNING: VulkanWindow: RV_VULKAN_MAX_FRAMES_IN_FLIGHT must be 1.." << VulkanWindow::kFramesInFlight << "; using " << kDefault << endl; return kDefault; } @@ -1411,36 +1228,12 @@ namespace Rv return depth; } - // Tiling for the GL<->Vulkan shared image: OPTIMAL on NVIDIA, LINEAR - // everywhere else. - // - // NVIDIA needs OPTIMAL. Its 550+ drivers return blank pixels to OpenGL - // for LINEAR shared images >= ~2 MiB (forum thread #349436), and - // OPTIMAL avoids that broken linear path. It is safe there because the - // GL and Vulkan sides are the same driver, so the vendor-private - // optimal layout matches on import. Set - // RV_VULKAN_DISABLE_NVIDIA_INTEROP_WORKAROUND to revert NVIDIA to - // LINEAR (reproduces the blank-image bug, for debugging). - // - // The vendor decides this, not the platform: NVIDIA ships one driver - // core behind both GL and Vulkan on Windows as well as Linux, which is - // why the check below covers both, and the shared image is always well - // past the threshold because getSharedImageInfo() allocates at screen - // capacity -- ~8 MiB for a 1080p A2B10G10R10 image, ~33 MiB at 4K. - // - // Everywhere else LINEAR is not conservatism, it is the only correct - // choice: OPTIMAL was measured on AMD (RADV PHOENIX2, Mesa) and renders - // tile-pattern garbage -- sparse tile-aligned fragments of the frame, - // the rest dropped. Under Mesa, GL and Vulkan are different drivers - // (radeonsi vs RADV) and GL_OPTIMAL_TILING_EXT carries no cross-driver - // layout guarantee, so the importer reads a layout the exporter never - // wrote. Making OPTIMAL usable off NVIDIA means negotiating the layout - // explicitly with VK_EXT_image_drm_format_modifier. - // - // This costs real time at a 4K presentation output -- the GL side blits - // a full frame into the linear image and Vulkan blits it back out every - // present, both without their tiled fast paths -- so it is worth - // revisiting, but not by flipping this flag. + // Vendor heuristic: OPTIMAL on NVIDIA, LINEAR elsewhere. NVIDIA 550+ + // returns blank pixels to GL for LINEAR shared images >= ~2 MiB + // (forum thread #349436); its GL and Vulkan share one driver, so the + // optimal layout matches on import. Under Mesa, GL and Vulkan are + // different drivers and OPTIMAL renders tile garbage (RADV PHOENIX2); + // making it usable there needs VK_EXT_image_drm_format_modifier. bool useOptimalTilingForInterop(VkPhysicalDevice dev) { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) @@ -1452,16 +1245,6 @@ namespace Rv } } // namespace - // - // Probe the driver for an exportable shared-image configuration. - // - // Runs once per device. The previous code chose OPTIMAL tiling when the - // physical device reported vendorID 0x10DE and LINEAR otherwise, which - // encoded one machine's observed behaviour as a rule about a whole - // vendor. Asking the driver which configurations it will actually export - // covers the same NVIDIA case without guessing about the others, and - // produces a reason when nothing is usable. - // void VulkanWindow::negotiateInteropConfig() { if (m_interopNegotiated) @@ -1472,11 +1255,8 @@ namespace Rv InteropConfig cfg; cfg.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; // == GL_RGB10_A2 - // Usage must cover every use on BOTH sides: Vulkan reads the image as - // a transfer source, and GL attaches it to GL_COLOR_ATTACHMENT0 and - // renders into it. Declaring only TRANSFER_SRC lets the driver pick an - // internal compressed layout the GL import does not decode, which - // corrupts the image rather than raising an error. + // Must cover GL's use too (it renders into the image): with only + // TRANSFER_SRC the driver may pick a compressed layout GL cannot read. cfg.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; #ifdef PLATFORM_WINDOWS @@ -1496,9 +1276,8 @@ namespace Rv return; } - // OPTIMAL first: it is the layout the driver is free to arrange for - // its own access, and the one that fixed the blank large-image bug on - // NVIDIA. LINEAR is the portable fallback. + // OPTIMAL first (avoids NVIDIA's blank large-LINEAR-image bug); + // LINEAR is the portable fallback. const VkImageTiling candidates[] = {VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_TILING_LINEAR}; auto probeTiling = [&](VkImageTiling tiling, VkExternalMemoryFeatureFlags& features) -> bool @@ -1553,14 +1332,7 @@ namespace Rv continue; } - // DEDICATED_ONLY is the only dedicated-allocation signal available - // from the external-memory probe: it is a hard requirement of the - // handle type. The softer "prefers dedicated" signal is a property - // of a concrete image, not of the format, and is read per-image - // from VkMemoryDedicatedRequirements at allocation time -- see - // getSharedImageInfo(). This value is therefore the floor, and the - // per-slot SharedImageInfo::dedicatedAllocation is the final - // decision the GL side must mirror. + // Only the floor; the per-image decision is in getSharedImageInfo(). const bool dedicatedOnly = (features & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; cfg.supported = true; @@ -1585,15 +1357,10 @@ namespace Rv return; } - // Apply the diagnostic overrides last, so the record can report both - // the negotiated value and the forced one. An override is honored only - // when the driver reported that configuration as usable. + // Overrides last, so the record reports both values. VkImageTiling forcedTiling = VK_IMAGE_TILING_LINEAR; if (forcedTilingRequested(forcedTiling) && forcedTiling != cfg.tiling) { - // Re-probe the forced tiling rather than trusting the request: - // presenting through an unverified configuration is exactly what - // the fallback ladder exists to prevent. VkExternalMemoryFeatureFlags features = 0; if (probeTiling(forcedTiling, features)) { @@ -1624,8 +1391,7 @@ namespace Rv } else if (dedicatedAllocationDisabled()) { - // Nothing to relax at probe level, but the per-image decision in - // getSharedImageInfo() still needs to know an override is active. + // getSharedImageInfo() still needs to know the override is active. cfg.dedicatedOverridden = true; } @@ -1646,13 +1412,6 @@ namespace Rv m_glImportReported = true; } - // - // One record per session, emitted unconditionally. Windows/NVIDIA is - // verified by testers against a produced build rather than by the - // developer, so this has to be sufficient on its own to establish which - // path ran and what was negotiated. Per-frame and per-candidate detail - // stays behind ImageRenderer::debugGpu(). - // void VulkanWindow::emitPresentationRecord() { if (m_recordEmitted) @@ -1735,9 +1494,7 @@ namespace Rv o << "INFO: Probe candidate: " << entry << "\n"; } - // Kept for comparison until the probe result is confirmed to agree - // with the vendor heuristic on Linux/NVIDIA; the heuristic is removed - // once it does. + // TODO: remove once the probe is confirmed on Linux/NVIDIA. o << "INFO: Legacy vendor heuristic would have chosen: " << (m_vkPhysicalDevice != VK_NULL_HANDLE && useOptimalTilingForInterop(m_vkPhysicalDevice) ? "OPTIMAL" : "LINEAR") << "\n"; @@ -1823,82 +1580,58 @@ namespace Rv } // - // A best-effort present that skipped has to be retried, or the output is - // left showing the frame before the one just composited -- and nothing - // else will come back for it, because the control viewport only renders - // when the session asks. The OpenGL output never needed this: its - // m_view->update() is a dirty flag Qt is obliged to honour eventually. - // - // QWindow::requestUpdate() coalesces, so at most one retry is ever - // outstanding. render() picks it up in the passive-output branch and - // re-presents the frame already sitting in the device's FBO. - // - // - // Best-effort gate for a passive presentation output. - // - // The OpenGL presentation path gets this for free: its syncBuffers() is a - // QOpenGLWidget::update() that Qt coalesces and drops when it falls - // behind, so the second display skips frames under load rather than - // deepening the GPU queue. The Vulkan path queues its work - // unconditionally, and at a 4K output that work is what the control - // viewport ends up waiting for in vkWaitForFences. - // - // Gate on whether this device's *own* GPU work has caught up, not on - // whether the swapchain is full -- with a slower loop than display the - // queue always has room, so a swapchain-full test never fires. - // - // Called before any GL work, so a skipped frame costs nothing -- in - // particular no GL semaphore has been signaled yet, so there is nothing to - // rebalance. + // Skip frames under load (as Qt does for the OpenGL output) instead of + // deepening the GPU queue the control viewport then waits on. Gates on + // this device's own GPU work; the swapchain queue always has room. // bool VulkanWindow::canPresentNow() { if (!isPassiveOutput()) + { return true; + } - // Nothing allocated yet: let the frame through so syncBuffers() can - // build the swapchain and shared image. if (!m_vkDevice || !m_vkSwapchain) + { return true; + } - // - // Forward progress. Under sustained GPU pressure the catch-up test - // below can be false indefinitely, which would freeze the presentation - // display rather than merely thin it out. Once stale, let syncBuffers() - // reach the present functions; they convert their normal zero-timeout - // polling into one blocking present. - // + // Forward progress: under sustained load, force one blocking present + // rather than freezing the output. static const double kMaxStaleSeconds = 0.1; if (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds) + { return true; + } - // waitAll with a zero timeout: every in-flight frame of this device - // must have retired, not just the one two frames back that this slot - // happens to own. - const VkResult r = vkWaitForFences(m_vkDevice, FRAMES_IN_FLIGHT, m_vkFence.data(), VK_TRUE, 0); + // Every in-flight frame must have retired, not only this slot's. + const VkResult r = vkWaitForFences(m_vkDevice, kFramesInFlight, m_vkFence.data(), VK_TRUE, 0); if (r == VK_SUCCESS) + { return true; + } requestBestEffortRetry(); return false; } + // Re-present a skipped best-effort frame; requestUpdate() coalesces, so at + // most one retry is pending. void VulkanWindow::requestBestEffortRetry() { if (!m_stopProcessingEvents && isExposed()) + { requestUpdate(); + } } void VulkanWindow::drainSharedSemaphores(uint32_t slot) { - // No shared image for this slot yet -> the GL side never signaled/waited - // its pair, so there is nothing to rebalance. if (!m_vkDevice || !m_vkGlReadySemaphore[slot] || !m_vkVkReadySemaphore[slot]) + { return; + } - // Consume the pending glReady signal from the GL side and re-signal - // vkReady so the next use of this slot starts balanced (exactly what a - // normal present's submit would have done for the pair). VkSubmitInfo drain = {}; drain.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot]}; @@ -1913,32 +1646,24 @@ namespace Rv VkResult r = vkQueueSubmit(m_vkQueue, 1, &drain, VK_NULL_HANDLE); if (r == VK_ERROR_DEVICE_LOST) + { requestGLFallback(); + } } const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) { if (!m_vkDevice || !m_externalInteropSupported) + { return nullptr; + } - // Build/return the shared image for the current in-flight ring slot. const uint32_t slot = m_currentFrame; SharedImageInfo& info = m_sharedImageInfo[slot]; - // The swapchain always tracks the window size, so recreate it on any size - // change. This is independent of the grow-only shared image below: a drag - // still recreates the (warm) swapchain each step, but no longer rebuilds - // or re-exports the shared image. - // - // The test is against the *surface's* extent, not against the caller's - // requested size. createSwapchain() takes its extent from - // capabilities.currentExtent, so it cannot be driven to match a request - // that disagrees with the surface: comparing to the request instead - // meant that any caller whose size was off by even a pixel -- e.g. a - // presentation output that sampled a devicePixelRatio belonging to the - // screen it was created on rather than the one it was moved to -- - // recreated the swapchain on *every frame*, forever and silently, since - // both surface-format reports are latched. + // Compare against the surface extent, not the requested size: + // createSwapchain() can only match the surface, so a mismatched request + // would recreate the swapchain every frame. VkExtent2D surfaceExtent = m_vkSwapchainExtent; { VkSurfaceCapabilitiesKHR caps = {}; @@ -1951,14 +1676,13 @@ namespace Rv if (!m_vkSwapchain || m_vkSwapchainExtent.width != surfaceExtent.width || m_vkSwapchainExtent.height != surfaceExtent.height) { - // Warm recreate via oldSwapchain (createSwapchain retires the old one). if (!createSwapchain()) + { return nullptr; + } } - // Grow-only: if the request fits the slot's current allocated capacity, - // reuse the existing image/export and just update the used sub-region - // (presentSharedImage copies/blits info.width x info.height from it). + // Within capacity: reuse the export, update the used sub-region. if (m_vkSharedImage[slot] && w <= m_sharedCapacityW[slot] && h <= m_sharedCapacityH[slot]) { info.width = w; @@ -1966,16 +1690,8 @@ namespace Rv return &info; } - // Grow (or first allocation): rebuild at a capacity that is the - // componentwise max of the request, the screen size, and the current - // capacity, so it grows monotonically and the common drag-to-fullscreen - // case allocates at most once. - // - // This window's own screen, not the primary one: a presentation output - // lives on a second display, and sizing its headroom from the primary - // screen is both wrong and, when the primary is the smaller of the two, - // useless as headroom. - // + // Grow to at least this window's screen (not the primary: an output + // lives on a second display) so drag-to-fullscreen allocates once. int screenW = 0; int screenH = 0; if (QScreen* scr = screen() ? screen() : QGuiApplication::primaryScreen()) @@ -1989,11 +1705,6 @@ namespace Rv cleanupSharedImage(slot); - // The interop configuration was negotiated once at device creation from - // what the driver reports exportable. If nothing was exportable, refuse - // the zero-copy path here so syncBuffers() takes the CPU readback rung - // rather than presenting through a configuration whose correctness was - // never established. if (!m_interopConfig.supported) { if (ImageRenderer::debugGpu()) @@ -2006,15 +1717,10 @@ namespace Rv const bool optimalTiling = m_interopConfig.tiling == VK_IMAGE_TILING_OPTIMAL; - // Unconditional, and reported after the tiling decision so it can name - // it. The shared image is allocated at screen capacity, so this fires - // about once per ring slot per session rather than per resize, and the - // tiling it reports is exactly the fact that separates a working NVIDIA - // viewport from a black one. + // Unconditional: fires about once per slot per session. cout << "INFO: VulkanWindow: getSharedImageInfo: (re)allocating shared image slot " << slot << " capacity " << capW << "x" << capH << " for request " << w << "x" << h << " tiling=" << tilingName(m_interopConfig.tiling) << endl; - // 1. Create Shared Image VkExternalMemoryImageCreateInfo extMemInfo = {}; extMemInfo.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO; #ifdef PLATFORM_WINDOWS @@ -2027,25 +1733,14 @@ namespace Rv imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; imageInfo.pNext = &extMemInfo; imageInfo.imageType = VK_IMAGE_TYPE_2D; - // The shared image is imported into GL as GL_RGB10_A2, whose bit layout - // is A2B10G10R10, so the Vulkan side must use the matching format - // regardless of the swapchain format. When the swapchain is A2R10G10B10 - // the difference is reconciled by a component-wise blit in - // presentSharedImage() (not a raw copy). - imageInfo.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; // matches GL_RGB10_A2 - // Allocate at capacity; presentSharedImage transfers only the used - // info.width x info.height sub-region (anchored at origin 0,0). - imageInfo.extent = {(uint32_t)capW, (uint32_t)capH, 1}; + // Matches GL_RGB10_A2 whatever the swapchain format; an A2R10G10B10 + // swapchain is reconciled by a blit in presentSharedImage(). + imageInfo.format = VK_FORMAT_A2B10G10R10_UNORM_PACK32; + imageInfo.extent = {static_cast(capW), static_cast(capH), 1}; imageInfo.mipLevels = 1; imageInfo.arrayLayers = 1; imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; imageInfo.tiling = m_interopConfig.tiling; - // Usage must cover every use on BOTH sides: Vulkan reads the image as a - // transfer source, and GL attaches it to GL_COLOR_ATTACHMENT0 and - // renders into it. Declaring only TRANSFER_SRC lets the driver pick an - // internal compressed layout that the GL import does not decode, which - // corrupts the image rather than raising an error. This is the usage - // the probe established as exportable. imageInfo.usage = m_interopConfig.usage; imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; @@ -2056,20 +1751,14 @@ namespace Rv return nullptr; } - // When the swapchain format differs from the shared image format - // (A2B10G10R10), presentSharedImage() reconciles them with a blit rather - // than a raw copy. That requires the shared image to be a valid blit - // source and the swapchain image a valid blit destination. If the driver - // does not support that, refuse the GPU-interop path so syncBuffers() - // uses the (channel-correct) CPU fallback instead. + // A format mismatch needs a blit; without blit support, use the CPU + // fallback. if (m_vkSwapchainFormat != VK_FORMAT_A2B10G10R10_UNORM_PACK32) { VkFormatProperties srcProps = {}; VkFormatProperties dstProps = {}; vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, VK_FORMAT_A2B10G10R10_UNORM_PACK32, &srcProps); vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, m_vkSwapchainFormat, &dstProps); - // The shared image's blit-source support depends on its actual tiling - // (OPTIMAL on NVIDIA, LINEAR elsewhere). const VkFormatFeatureFlags srcFeatures = optimalTiling ? srcProps.optimalTilingFeatures : srcProps.linearTilingFeatures; const bool blitOk = (srcFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT) && (dstProps.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT); @@ -2085,16 +1774,14 @@ namespace Rv } } - // rowPitch is only meaningful (and vkGetImageSubresourceLayout only valid) - // for LINEAR tiling. For OPTIMAL tiling the GL import uses the logical - // capacity width and lets the driver resolve the layout. + // vkGetImageSubresourceLayout is only valid for LINEAR tiling. if (optimalTiling) { info.strideWidth = capW; } else { - // Handle padded linear row pitch by matching the GL texture stride to Vulkan's rowPitch. + // Match the GL texture stride to a padded rowPitch. VkImageSubresource subresource = {}; subresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; subresource.mipLevel = 0; @@ -2104,24 +1791,18 @@ namespace Rv if (layout.rowPitch % 4 != 0) { - // Cannot represent this stride as an integer pixel-width texture; fall back to CPU bridge. + // Not an integer pixel width; use the CPU fallback. cleanupSharedImage(slot); return nullptr; } info.strideWidth = static_cast(layout.rowPitch / 4); } - info.capacityHeight = capH; // GL imports the texture at capacity dimensions + info.capacityHeight = capH; info.tiling = m_interopConfig.tiling; - // - // Ask through the 2-variant so the dedicated-allocation requirement can - // be read. An image created with an external handle type is reported - // requiresDedicatedAllocation by some drivers (AMD's Windows driver - // does), and binding non-dedicated memory to such an image is invalid - // -- the GL import of it then yields a texture with undefined (in - // practice all-zero) contents and no error on any path. Honor whatever - // this driver asks for rather than forcing dedicated everywhere. - // + // Some drivers (AMD on Windows) require dedicated memory for external + // images; binding non-dedicated memory silently yields an all-zero GL + // texture. VkMemoryDedicatedRequirements dedicatedReqs = {}; dedicatedReqs.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_REQUIREMENTS; @@ -2137,15 +1818,8 @@ namespace Rv const VkMemoryRequirements& memReqs = memReqs2.memoryRequirements; - // Resolve the final dedicated-allocation decision for THIS image. The - // probe supplied the floor (DEDICATED_ONLY, a requirement of the handle - // type); "prefers dedicated" is a property of a concrete image and is - // only available here. The GL side mirrors info.dedicatedAllocation, so - // this is the single decision both sides use -- deciding it - // independently is what corrupts the image. - // - // An explicit override may only relax a preference, never a - // requirement (DEDICATED_ONLY or requiresDedicatedAllocation). + // The single decision GL mirrors. An override may relax a preference, + // never a requirement. bool useDedicated = m_interopConfig.dedicatedAllocation || dedicatedReqs.requiresDedicatedAllocation || dedicatedReqs.prefersDedicatedAllocation; if (dedicatedAllocationDisabled() && !dedicatedReqs.requiresDedicatedAllocation @@ -2161,19 +1835,12 @@ namespace Rv << " (driver requires=" << (dedicatedReqs.requiresDedicatedAllocation ? "yes" : "no") << " prefers=" << (dedicatedReqs.prefersDedicatedAllocation ? "yes" : "no") << ")" << endl; - // Build the allocation pNext chain back to front, so each link is - // attached exactly once regardless of which options are active: - // + // pNext chain, built back to front: // allocInfo -> exportAllocInfo [-> dedicatedAllocInfo] [-> exportWin32Info] - // - // Every link lives until the vkAllocateMemory call below. void* chain = nullptr; #ifdef PLATFORM_WINDOWS - // Required by the Vulkan specification for OPAQUE_WIN32 handles: the - // export must state the access rights and security attributes the - // handle is created with. Its absence is tolerated by some drivers but - // is a real violation on the platform being debugged. + // Required by the spec for OPAQUE_WIN32 handles. VkExportMemoryWin32HandleInfoKHR exportWin32Info = {}; exportWin32Info.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_WIN32_HANDLE_INFO_KHR; exportWin32Info.pNext = chain; @@ -2183,9 +1850,6 @@ namespace Rv chain = &exportWin32Info; #endif - // Dedicated allocation when the driver requires or prefers it for this - // image. NVIDIA's OPAQUE_WIN32 path in particular needs this paired - // with GL_DEDICATED_MEMORY_OBJECT_EXT on the import side. VkMemoryDedicatedAllocateInfo dedicatedAllocInfo = {}; if (useDedicated) { @@ -2231,12 +1895,9 @@ namespace Rv return nullptr; } - // Export device memory as a platform-specific external handle - // (opaque FD on Linux, Win32 HANDLE on Windows). The receiving GL - // side imports this with the matching GL_EXT_memory_object_{fd,win32} - // extension so writes from GL land in this Vulkan image. #ifdef PLATFORM_WINDOWS - auto pfnGetMemoryWin32HandleKHR = (PFN_vkGetMemoryWin32HandleKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryWin32HandleKHR"); + auto pfnGetMemoryWin32HandleKHR = + reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryWin32HandleKHR")); if (!pfnGetMemoryWin32HandleKHR) { cerr << "ERROR: VulkanWindow: vkGetMemoryWin32HandleKHR not found" << endl; @@ -2257,7 +1918,7 @@ namespace Rv return nullptr; } #else - auto pfnGetMemoryFdKHR = (PFN_vkGetMemoryFdKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryFdKHR"); + auto pfnGetMemoryFdKHR = reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryFdKHR")); if (!pfnGetMemoryFdKHR) { cerr << "ERROR: VulkanWindow: vkGetMemoryFdKHR not found" << endl; @@ -2279,7 +1940,6 @@ namespace Rv } #endif - // 2. Create Shared Semaphores VkExportSemaphoreCreateInfo exportSemInfo = {}; exportSemInfo.sType = VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO; #ifdef PLATFORM_WINDOWS @@ -2300,10 +1960,9 @@ namespace Rv return nullptr; } - // Export the GL<->Vulkan sync semaphores as external handles. #ifdef PLATFORM_WINDOWS auto pfnGetSemaphoreWin32HandleKHR = - (PFN_vkGetSemaphoreWin32HandleKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreWin32HandleKHR"); + reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreWin32HandleKHR")); if (!pfnGetSemaphoreWin32HandleKHR) { cerr << "ERROR: VulkanWindow: vkGetSemaphoreWin32HandleKHR not found" << endl; @@ -2341,7 +2000,7 @@ namespace Rv info.glReadySemaphoreHandle = glReadyHandle; info.vkReadySemaphoreHandle = vkReadyHandle; #else - auto pfnGetSemaphoreFdKHR = (PFN_vkGetSemaphoreFdKHR)vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreFdKHR"); + auto pfnGetSemaphoreFdKHR = reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreFdKHR")); if (!pfnGetSemaphoreFdKHR) { cerr << "ERROR: VulkanWindow: vkGetSemaphoreFdKHR not found" << endl; @@ -2380,7 +2039,6 @@ namespace Rv info.vkReadySemaphoreFd = vkReadyFd; #endif - // Transition the shared image to TRANSFER_SRC optimal initially VkCommandBuffer cb = m_vkCommandBuffers[0]; vkResetCommandBuffer(cb, 0); @@ -2426,7 +2084,7 @@ namespace Rv } vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); - // Signal vkReady initially so GL can start writing to it + // Signal vkReady so GL can write the first frame. VkSubmitInfo signalInfo = {}; signalInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; signalInfo.signalSemaphoreCount = 1; @@ -2442,7 +2100,6 @@ namespace Rv return nullptr; } - // Commit the new capacity only now that the (re)build fully succeeded. m_sharedCapacityW[slot] = capW; m_sharedCapacityH[slot] = capH; @@ -2459,53 +2116,37 @@ namespace Rv const SharedImageInfo& info = m_sharedImageInfo[slot]; if (!m_vkDevice || !m_vkSharedImage[slot] || !m_vkSwapchain) + { return; + } - // Split the present cost into fence-wait vs acquire, the only two - // blocking calls here, so GPU back-pressure can be told apart from - // swapchain/vblank back-pressure. Main viewport only. + // Fence wait vs acquire timing tells GPU from vblank back-pressure. const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; Timer diagTimer; - // - // Best-effort present for a passive presentation output. - // - // This function has exactly two blocking calls, the fence wait and the - // acquire, and on the control viewport they are the throttle: FIFO - // acquire back-pressure plus the start-of-frame fence is what paces - // the loop to display refresh. - // - // A presentation output should not be part of that. render() presents - // the control viewport and then the output inside one frame, so a - // second blocking pair puts a second display's vblank in the loop's - // path. The OpenGL output is never in it: DesktopVideoDevice's - // syncBuffers() is a coalesced QOpenGLWidget::update() on an empty - // paintGL() that Qt drops when it falls behind. - // - // So do the same explicitly -- poll with a zero timeout and skip the - // frame when the swapchain cannot take an image right now, leaving the - // output on its previous frame as a dropped Qt update would. See - // canPresentNow() for the gate that fires first, before any GL work. - // + // The fence wait and acquire pace the control viewport to refresh. A + // passive output polls instead, so a second display's vblank never + // enters the loop; a skipped frame keeps the previous image. const bool bestEffort = isPassiveOutput(); static const double kMaxStaleSeconds = 0.1; const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; if (diagPresent) + { diagTimer.start(); + } VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout); if (diagPresent) + { s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; + } if (fenceResult == VK_TIMEOUT) { - // The GL side already signaled glReady[slot] and waited vkReady[slot] - // for this frame, so the pair has to be rebalanced before bailing -- - // same contract as the VK_ERROR_OUT_OF_DATE_KHR path below. The slot - // is deliberately not advanced: the next frame retries this one. + // The slot is not advanced: the next frame retries it. drainSharedSemaphores(slot); requestBestEffortRetry(); return; @@ -2516,23 +2157,24 @@ namespace Rv return; } - // Acquire image uint32_t imageIndex; if (diagPresent) + { diagTimer.start(); + } VkResult result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); if (diagPresent) + { s_diagAcquireMs += diagTimer.elapsed() * 1000.0; + } if (result == VK_NOT_READY || result == VK_TIMEOUT) { - // No image free this frame. An acquire that fails this way leaves - // m_vkImageAvailableSemaphore[slot] unsignaled, so nothing leaks -- - // which is why the skip has to happen here and not after a - // successful acquire. + // Skip here, not after a successful acquire: a failed acquire + // leaves the semaphore unsignaled. drainSharedSemaphores(slot); requestBestEffortRetry(); return; @@ -2540,9 +2182,6 @@ namespace Rv if (result == VK_ERROR_OUT_OF_DATE_KHR) { - // The GL side already signaled glReady[slot]/waited vkReady[slot] this - // frame; rebalance the pair before bailing so the next frame on this - // slot can't desync. drainSharedSemaphores(slot); handleSwapchainOutOfDate(); return; @@ -2557,16 +2196,12 @@ namespace Rv return; } - // If this swapchain image is still owned by another in-flight frame, wait - // for that frame's fence before rendering into it, then mark the image as - // now owned by this frame. if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) { vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX); } m_imagesInFlight[imageIndex] = m_vkFence[slot]; - // Reset the frame fence only now, right before the submit that re-signals it. vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; @@ -2577,7 +2212,6 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - // Transition shared image from COLOR_ATTACHMENT_OPTIMAL to TRANSFER_SRC_OPTIMAL VkImageMemoryBarrier sharedBarrier = {}; sharedBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; sharedBarrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; @@ -2614,22 +2248,9 @@ namespace Rv vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); - // Transfer the shared image (always A2B10G10R10, == GL_RGB10_A2) to the - // swapchain image. When the swapchain is also A2B10G10R10 the layouts - // match and a raw copy is correct and cheapest. When the swapchain is - // A2R10G10B10 a raw copy would swap red and blue, so use a blit instead: - // vkCmdBlitImage converts per component (R->R, G->G, B->B) between the - // two formats. Whether the (linear-tiled) shared image can be a blit - // source is checked at shared-image creation; if not, that path is - // refused and syncBuffers() uses the CPU fallback instead. - // - // The destination is bounded by the swapchain, never by the shared - // image. They are normally the same size, but the shared image is - // sized from the caller's request and a stale devicePixelRatio can - // inflate that (a 3840x2160 output asking for 5760x3240), which would - // otherwise write outside the swapchain image -- invalid usage, so - // undefined contents or a faulted submit rather than a visible error. - // + // Same format: raw copy. A2R10G10B10: a raw copy would swap R and B, + // so blit (per-component conversion). The destination is bounded by + // the swapchain: a stale devicePixelRatio can inflate the request. if (m_vkSwapchainFormat == VK_FORMAT_A2B10G10R10_UNORM_PACK32) { // vkCmdCopyImage cannot scale, so clamp to the overlapping region. @@ -2638,16 +2259,14 @@ namespace Rv region.srcSubresource.layerCount = 1; region.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; region.dstSubresource.layerCount = 1; - region.extent = {std::min((uint32_t)info.width, m_vkSwapchainExtent.width), - std::min((uint32_t)info.height, m_vkSwapchainExtent.height), 1}; + region.extent = {std::min(static_cast(info.width), m_vkSwapchainExtent.width), + std::min(static_cast(info.height), m_vkSwapchainExtent.height), 1}; vkCmdCopyImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); } else { - // vkCmdBlitImage can scale, so fill the swapchain from the used - // sub-region of the shared image instead of truncating. VkImageBlit blit = {}; blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; blit.srcSubresource.layerCount = 1; @@ -2656,13 +2275,12 @@ namespace Rv blit.srcOffsets[0] = {0, 0, 0}; blit.srcOffsets[1] = {info.width, info.height, 1}; blit.dstOffsets[0] = {0, 0, 0}; - blit.dstOffsets[1] = {(int32_t)m_vkSwapchainExtent.width, (int32_t)m_vkSwapchainExtent.height, 1}; + blit.dstOffsets[1] = {static_cast(m_vkSwapchainExtent.width), static_cast(m_vkSwapchainExtent.height), 1}; vkCmdBlitImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST); } - // Transition swapchain image to present barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; @@ -2673,15 +2291,12 @@ namespace Rv vkEndCommandBuffer(cb); - // Submit VkSubmitInfo submitInfo = {}; submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - // Wait for GL to finish writing (glReady) AND swapchain image to be available VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot], m_vkImageAvailableSemaphore[slot]}; - // Both waits protect transfer operations. The acquired swapchain image - // is first touched by its TRANSFER_DST layout transition, so waiting at - // COLOR_ATTACHMENT_OUTPUT would not block that earlier stage. + // TRANSFER, not COLOR_ATTACHMENT_OUTPUT: the swapchain image is first + // touched by its TRANSFER_DST transition. VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT}; submitInfo.waitSemaphoreCount = 2; submitInfo.pWaitSemaphores = waitSemaphores; @@ -2690,8 +2305,6 @@ namespace Rv submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &cb; - // Signal the image's renderFinished (present waits on it) AND vkReady (so - // GL can write the next frame into this slot's shared image). VkSemaphore signalSemaphores[] = {m_vkRenderFinished[imageIndex], m_vkVkReadySemaphore[slot]}; submitInfo.signalSemaphoreCount = 2; submitInfo.pSignalSemaphores = signalSemaphores; @@ -2706,8 +2319,6 @@ namespace Rv return; } - // Hand this frame's pointer-event timestamp to the slot so - // eventToRetire can be closed out when the fence signals. if (m_doc && s_diagFrameEventTime >= 0.0) { s_diagSlotEventTime[slot] = s_diagFrameEventTime; @@ -2715,12 +2326,8 @@ namespace Rv s_diagFrameEventTime = -1.0; } - // The frame is committed to the GPU; advance the ring now so the next - // frame uses the other slot. imageIndex/slot below are locals, so this is - // safe before the present call. - m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; + m_currentFrame = (m_currentFrame + 1) % kFramesInFlight; - // Present, waiting on the image's own renderFinished semaphore. VkPresentInfoKHR presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount = 1; @@ -2732,26 +2339,17 @@ namespace Rv VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); - // This device has presented; the forward-progress guard in - // canPresentNow() measures staleness from here. m_lastPresentTimer.stop(); m_lastPresentTimer.start(); - // Depth-1 pipeline, opt-in: see maxFramesInFlight(). Done after the - // present is queued so the driver still gets the frame as early as - // possible; this only stops the CPU running a second frame ahead. The - // passive output is excluded -- it is best-effort by design and must - // never block the loop. + // See maxFramesInFlight(). After the present so the driver gets the + // frame early; never for the passive output. if (maxFramesInFlight() == 1 && !isPassiveOutput()) { vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); } - // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and - // can be reported persistently by some X11/RADV compositors; recreating - // on it every frame caused a swapchain-recreate loop that starved the Qt - // event loop (dead input, no fullscreen). Real resizes report OUT_OF_DATE. - // The submit above is tracked by m_vkFence[slot] (waited at the start of - // the next use of this slot), so no end-of-frame block is needed here. + // Recreate only on OUT_OF_DATE: some X11/RADV compositors report + // SUBOPTIMAL persistently. if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) { handleSwapchainOutOfDate(); @@ -2776,38 +2374,40 @@ namespace Rv const uint32_t slot = m_currentFrame; if (!m_vkDevice) + { return; + } const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; Timer diagTimer; - if (!m_vkSwapchain || m_vkSwapchainExtent.width != (uint32_t)w || m_vkSwapchainExtent.height != (uint32_t)h) + if (!m_vkSwapchain || m_vkSwapchainExtent.width != static_cast(w) + || m_vkSwapchainExtent.height != static_cast(h)) { - // Warm recreate via oldSwapchain (createSwapchain retires the old one). if (!createSwapchain()) + { return; + } } - // Start-of-frame throttle (matches presentSharedImage): wait for this - // slot's previous frame to finish before reusing its staging buffer, - // acquire semaphore and command resources. A passive presentation output - // polls instead of blocking and skips the frame -- see the best-effort - // present in presentSharedImage() for why. + // Same best-effort throttle as presentSharedImage(). const bool bestEffort = isPassiveOutput(); static const double kMaxStaleSeconds = 0.1; const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; if (diagPresent) + { diagTimer.start(); + } const VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout); if (diagPresent) + { s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; + } if (fenceResult == VK_TIMEOUT) { - // Unlike the interop path there are no GL<->Vulkan semaphores to - // rebalance here: presentCpuFallback() hands over plain pixels. requestBestEffortRetry(); return; } @@ -2819,13 +2419,16 @@ namespace Rv size_t size = w * h * 4; - // Recreate this slot's staging buffer if needed if (size > m_stagingBufferSize[slot]) { if (m_vkStagingBuffer[slot]) + { vkDestroyBuffer(m_vkDevice, m_vkStagingBuffer[slot], nullptr); + } if (m_vkStagingBufferMemory[slot]) + { vkFreeMemory(m_vkDevice, m_vkStagingBufferMemory[slot], nullptr); + } VkBufferCreateInfo bufferInfo = {}; bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; @@ -2854,24 +2457,24 @@ namespace Rv m_stagingBufferSize[slot] = size; } - // Copy to staging buffer void* data; vkMapMemory(m_vkDevice, m_vkStagingBufferMemory[slot], 0, size, 0, &data); memcpy(data, pixels, size); vkUnmapMemory(m_vkDevice, m_vkStagingBufferMemory[slot]); - // Acquire image uint32_t imageIndex; if (diagPresent) + { diagTimer.start(); + } VkResult result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); if (diagPresent) + { s_diagAcquireMs += diagTimer.elapsed() * 1000.0; + } if (result == VK_NOT_READY || result == VK_TIMEOUT) { - // Best-effort: no image free this frame, leave the output on the one - // it is already showing and come back for it. requestBestEffortRetry(); return; } @@ -2889,8 +2492,6 @@ namespace Rv return; } - // If this swapchain image is still owned by another in-flight frame, wait - // for its fence, then mark it owned by this frame. if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) { vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX); @@ -2907,7 +2508,6 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - // Transition image to transfer dst VkImageMemoryBarrier barrier = {}; barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; @@ -2925,7 +2525,6 @@ namespace Rv vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); - // Copy buffer to image VkBufferImageCopy region = {}; region.bufferOffset = 0; region.bufferRowLength = 0; @@ -2935,12 +2534,11 @@ namespace Rv region.imageSubresource.baseArrayLayer = 0; region.imageSubresource.layerCount = 1; region.imageOffset = {0, 0, 0}; - region.imageExtent = {(uint32_t)w, (uint32_t)h, 1}; + region.imageExtent = {static_cast(w), static_cast(h), 1}; vkCmdCopyBufferToImage(cb, m_vkStagingBuffer[slot], m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); - // Transition image to present barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; @@ -2951,12 +2549,9 @@ namespace Rv vkEndCommandBuffer(cb); - // Submit VkSubmitInfo submitInfo = {}; submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; VkSemaphore waitSemaphores[] = {m_vkImageAvailableSemaphore[slot]}; - // The acquired image is first used by a TRANSFER_DST layout transition - // and vkCmdCopyBufferToImage, not as a color attachment. VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT}; submitInfo.waitSemaphoreCount = 1; submitInfo.pWaitSemaphores = waitSemaphores; @@ -2984,10 +2579,8 @@ namespace Rv s_diagFrameEventTime = -1.0; } - // Frame committed; advance the ring (imageIndex/slot below are locals). - m_currentFrame = (m_currentFrame + 1) % FRAMES_IN_FLIGHT; + m_currentFrame = (m_currentFrame + 1) % kFramesInFlight; - // Present, waiting on the image's own renderFinished semaphore. VkPresentInfoKHR presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount = 1; @@ -2999,26 +2592,16 @@ namespace Rv VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); - // This device has presented; the forward-progress guard in - // canPresentNow() measures staleness from here. m_lastPresentTimer.stop(); m_lastPresentTimer.start(); - // Depth-1 pipeline, opt-in: see maxFramesInFlight(). Done after the - // present is queued so the driver still gets the frame as early as - // possible; this only stops the CPU running a second frame ahead. The - // passive output is excluded -- it is best-effort by design and must - // never block the loop. + // See maxFramesInFlight(). After the present so the driver gets the + // frame early; never for the passive output. if (maxFramesInFlight() == 1 && !isPassiveOutput()) { vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); } - // Recreate only on OUT_OF_DATE. VK_SUBOPTIMAL_KHR still presents fine and - // can be reported persistently by some X11/RADV compositors; recreating - // on it every frame caused a swapchain-recreate loop that starved the Qt - // event loop (dead input, no fullscreen). Real resizes report OUT_OF_DATE. - // The submit is tracked by m_vkFence[slot] (waited at the next use of this - // slot), so no end-of-frame block is needed here. + // See presentSharedImage() on SUBOPTIMAL. if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) { handleSwapchainOutOfDate(); @@ -3043,44 +2626,35 @@ namespace Rv return; } - // - // Vulkan is not ready until the surface and swapchain exist, which - // happens in initialize() on first expose. resizeEvent() also calls - // requestUpdate(), so an UpdateRequest can land here before then -- - // newly reachable now that swapGLViewToVulkan() builds a VulkanView - // mid-session. Presenting into a null device would fault; another - // render is requested once initialized. - // + // resizeEvent() can request an update before the first expose. if (!m_initialized) { return; } - // - // A passive presentation output never drives the frame loop below: it - // is composited into and presented by its owning - // VulkanDesktopVideoDevice, in-frame, from the control viewport's - // render(). The only reason it gets an UpdateRequest of its own is a - // best-effort present that was skipped (see requestBestEffortRetry), - // so re-present what the device already composited -- the same handoff - // exposeEvent() uses. - // + // A passive output only gets its own UpdateRequest from + // requestBestEffortRetry(): re-present what is already composited. if (isPassiveOutput()) { if (m_videoDevice) + { m_videoDevice->syncBuffers(); + } return; } IPCore::Session* session = m_doc ? m_doc->session() : nullptr; if (!session) + { return; + } - // See s_diagLoopTimer. if (IPCore::ImageRenderer::debugGpu()) { if (s_diagLoopTimer.isRunning()) + { s_diagLoopMs += s_diagLoopTimer.elapsed() * 1000.0; + } s_diagLoopTimer.start(); if (s_diagPointerPending) @@ -3088,15 +2662,11 @@ namespace Rv s_diagPointerAgeMs += s_diagPointerTimer.elapsed() * 1000.0; ++s_diagPointerAgeSamples; s_diagPointerPending = false; - // This frame answers that event; presentSharedImage() pins the - // timestamp to the slot it submits into. s_diagFrameEventTime = diagNow() - s_diagPointerTimer.elapsed(); } - // Close out any slot whose GPU work has retired since last frame. - // vkGetFenceStatus does not block, so this costs two calls a frame - // and works at any pipeline depth, with one frame of quantisation. - for (uint32_t i = 0; i < FRAMES_IN_FLIGHT; ++i) + // Close out retired slots (non-blocking; one frame of quantisation). + for (uint32_t i = 0; i < kFramesInFlight; ++i) { if (s_diagSlotArmed[i] && m_vkDevice && m_vkFence[i] && vkGetFenceStatus(m_vkDevice, m_vkFence[i]) == VK_SUCCESS) { @@ -3125,16 +2695,19 @@ namespace Rv absolutePosition(x, y); m_videoDevice->setAbsolutePosition(x, y); - // Frame-time breakdown; see the report at the end of render(). const bool diagTiming = IPCore::ImageRenderer::debugGpu(); Timer diagTimer; if (diagTiming) + { diagTimer.start(); + } session->render(); if (diagTiming) + { s_diagRenderMs += diagTimer.elapsed() * 1000.0; + } if (!m_postFirstNonEmptyRender && session->postFirstNonEmptyRender()) { @@ -3156,72 +2729,60 @@ namespace Rv if (session && m_videoDevice) { - // - // Always present the main (control) viewport's own swapchain. - // Unlike the GL path, where QOpenGLWidget composites the control - // widget after paintGL regardless, the Vulkan viewport only - // appears via an explicit present -- so it must NOT be skipped - // when a separate output (presentation) device is active. - // Skipping it leaves the main window on a stale frame once - // presentation mode is on. - // + // Always present the control viewport, even with a separate output + // device: unlike GL, nothing else composites it. const bool diagPresent = IPCore::ImageRenderer::debugGpu(); Timer diagPresentTimer; if (diagPresent) + { diagPresentTimer.start(); + } m_videoDevice->syncBuffers(); if (diagPresent) + { s_diagMainPresentMs += diagPresentTimer.elapsed() * 1000.0; + } - // - // In presentation mode the output is a distinct fullscreen window - // that must also be presented this frame. - // if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) { if (diagPresent) + { diagPresentTimer.start(); + } session->outputVideoDevice()->syncBuffers(); if (diagPresent) + { s_diagOutPresentMs += diagPresentTimer.elapsed() * 1000.0; + } - // - // Presenting the output device made *its* offscreen GL context - // current and did not put ours back, so restore it before - // postRender() and anything else that runs after this frame - // expects the viewport's context. The GL output path never - // needed this: its syncBuffers() is a QOpenGLWidget update(), - // which schedules a composite without touching the current - // context. - // + // The output made its own GL context current; restore ours. m_videoDevice->makeCurrent(); } } if (session) { - // See s_diagPostRenderMs. const bool diagPost = IPCore::ImageRenderer::debugGpu(); Timer diagPostTimer; if (diagPost) + { diagPostTimer.start(); + } session->addSyncSample(); session->postRender(); if (diagPost) + { s_diagPostRenderMs += diagPostTimer.elapsed() * 1000.0; + } } - // - // Report an averaged breakdown every 60 frames: where the frame goes - // (session render vs viewport present vs output present), and what the - // pointer sees end to end. - // + // Averaged -debug gpu breakdown every 60 frames. if (IPCore::ImageRenderer::debugGpu() && m_doc) { if (++s_diagFrames >= 60) @@ -3275,13 +2836,6 @@ namespace Rv return; } - // - // The VkSurfaceKHR is derived from the platform window, so it does not - // survive Qt destroying and recreating it -- which happens when the - // container re-parents the viewport after the top-level QWidgetWindow - // is replaced (see VulkanView::reattachVulkanWindow). Rebuild the - // surface and swapchain when we come back exposed on a new handle. - // if (m_initialized && handle() != m_initializedHandle) { handleSurfaceLost(); @@ -3292,14 +2846,8 @@ namespace Rv initialize(); } - // - // A doc-less window is a passive presentation output: it is rendered - // into and presented by its owning VulkanDesktopVideoDevice, and - // render() returns at `!session` so it never drives itself. Present - // once here so a freshly exposed (or re-exposed) presentation surface - // shows the last composited frame instead of staying blank until the - // next main-view frame. - // + // Show the last composited frame instead of blank until the next + // main-view frame. if (!m_doc && m_initialized && m_videoDevice) { m_videoDevice->syncBuffers(); @@ -3312,13 +2860,12 @@ namespace Rv void VulkanWindow::resizeEvent(QResizeEvent* event) { if (m_doc) + { m_doc->viewSizeChanged(event->size().width(), event->size().height()); + } QWindow::resizeEvent(event); - // Nothing repaints this native surface on resize, so drive a render now - // to recreate the swapchain at the new size and present immediately - // (instead of waiting for a mouse Enter event). QWindow::requestUpdate() - // coalesces, so a fast drag does not queue one heavy recreate per event. + // Nothing else repaints this surface on resize; requestUpdate() coalesces. if (!m_stopProcessingEvents) { requestUpdate(); @@ -3332,7 +2879,9 @@ namespace Rv void VulkanWindow::eventProcessingTimeout() { if (m_doc && m_doc->session()) + { m_doc->session()->userGenericEvent("per-render-event-processing", ""); + } } //-------------------------------------------------------------------------- @@ -3342,23 +2891,10 @@ namespace Rv bool VulkanWindow::event(QEvent* event) { // - // This must be handled before every guard below (including the - // m_stopProcessingEvents / missing-device early-outs): it is the only - // point at which the Vulkan objects can still legally be destroyed. - // - // Qt sends SurfaceAboutToBeDestroyed from QWindow::destroy(), just - // before it deletes the QPlatformWindow -- and the VkSurfaceKHR, the - // swapchain and the X11 drawable behind them all die with it. Anything - // released later is released against a surface that no longer exists, - // which is a segfault inside the driver rather than an error code. - // - // On quit that "later" is the destructor: QWindowContainer's own - // destructor calls window->destroy() and only then deletes the window, - // so ~VulkanWindow always runs on a dead surface. The same applies on - // the reparent path, where Qt replaces the top-level QWidgetWindow - // (adding a QWebEngineView is the usual trigger). exposeEvent()'s - // handle() != m_initializedHandle check notices that one, but only - // after the fact; this notices it in time. + // Before every guard below: this is the last point the Vulkan objects + // can legally be destroyed. The VkSurfaceKHR dies with the + // QPlatformWindow, and QWindowContainer destroys it before deleting + // this window, so releasing later segfaults inside the driver. // if (event->type() == QEvent::PlatformSurface) { @@ -3369,10 +2905,10 @@ namespace Rv return QWindow::event(event); } - // The device (and its translator) is wired by the hosting VulkanView - // just after construction; ignore any events that arrive before then. if (!m_videoDevice) + { return QWindow::event(event); + } bool keyevent = false; Rv::Session* session = m_doc ? m_doc->session() : nullptr; @@ -3384,7 +2920,9 @@ namespace Rv } if (event->type() == QEvent::WindowActivate) + { m_activationTimer.start(); + } float activationTime = 0.0f; if (m_activationTimer.isRunning()) @@ -3395,7 +2933,9 @@ namespace Rv m_activationTimer.stop(); } if (event->type() == QEvent::MouseMove) + { m_activationTimer.stop(); + } } if (event->type() != QEvent::Paint) @@ -3429,16 +2969,7 @@ namespace Rv switch (event->type()) { case QEvent::FocusIn: - // - // Qt has already made this the focus window by the time FocusIn is - // delivered, so there is nothing to hand over here. The case exists - // only to drop modifier state that went stale while the keyboard - // was elsewhere. - // - // Guarded: a passive presentation output window is built with no - // event widget, so its device has no translator (the general - // hasTranslator() check below this switch is too late). - // + // Drop stale modifiers. A passive output has no translator. if (m_videoDevice->hasTranslator()) { m_videoDevice->translator().resetModifiers(); @@ -3446,24 +2977,14 @@ namespace Rv break; case QEvent::Enter: - // - // Hovering hands the keyboard to the viewport as a *widget* focus - // change, never as a window activation. QWidget::setFocus() only - // delivers FocusIn when the top-level is already active; otherwise - // it just records the window's focus_child and the keyboard - // arrives once the user activates RV. That keeps a hover from - // stealing activation from another top-level of ours (the Console) - // or from another application entirely. - // - // Skipped when this window already holds focus: QWindowContainer - // clears the container's widget focus once it has handed focus - // over, so a repeat FocusIn would take its "return to the normal - // focus chain" branch and push the keyboard to the next widget in - // the tab chain instead. This is why FocusIn above must not fall - // through into this case. - // + // A widget focus change, not a window activation, so hovering + // never steals activation from another window. Skipped when + // already focused: a repeat FocusIn makes QWindowContainer move + // focus to the next widget in the tab chain. if (QGuiApplication::focusWindow() != this && m_eventWidget) + { m_eventWidget->setFocus(Qt::MouseFocusReason); + } break; default: @@ -3482,7 +3003,9 @@ namespace Rv ostringstream contents; contents << e->oldSize().width() << " " << e->oldSize().height() << "|" << e->size().width() << " " << e->size().height(); if (m_doc && session) + { session->userGenericEvent("view-resized", contents.str()); + } } return QWindow::event(event); } @@ -3554,10 +3077,11 @@ namespace Rv } if (session) + { session->setEventVideoDevice(videoDevice()); + } - // See s_diagPointerHandlerMs. A drag arrives here and is dispatched - // synchronously into Mu, so this call *is* the handler's cost. + // Dispatch into Mu is synchronous, so this times the handler. const bool diagPointer = IPCore::ImageRenderer::debugGpu() && (event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonPress || event->type() == QEvent::TabletMove); diff --git a/src/lib/app/RvPackage/PackageManager.cpp b/src/lib/app/RvPackage/PackageManager.cpp index 977dfe8ec..5f98a8bd0 100644 --- a/src/lib/app/RvPackage/PackageManager.cpp +++ b/src/lib/app/RvPackage/PackageManager.cpp @@ -2043,17 +2043,8 @@ namespace Rv { m_globalSettingsP->sync(); delete m_globalSettingsP; - - // - // Clear it: globalSettings() only allocates when this is null, so - // leaving it dangling means every later caller gets a reference to - // freed memory and dies dereferencing the destroyed QSettings - // inside it. RvDocument calls this while closing the last - // document, and anything that saves settings after that point -- - // RvConsoleWindow::done() closing the console dialog, for one -- - // then crashes on the way out. - // - m_globalSettingsP = 0; + // globalSettings() reallocates only when this is null. + m_globalSettingsP = nullptr; } } @@ -2206,17 +2197,17 @@ namespace Rv qs->setFallbacksEnabled(false); #ifdef PLATFORM_WINDOWS - // Qt's atomic write (temp-file + rename) can fail with AccessError when Windows - // security software holds RV.ini open at the moment of the rename. Writing - // directly to the file avoids that failure point. Available since Qt 5.13. + // The atomic temp-file rename fails with AccessError when security + // software holds RV.ini open. qs->setAtomicSyncRequired(false); #endif - // Qt IniFormat on Windows does not create the parent directory automatically. - // Create it here so sync() does not fail with AccessError (err: 1). + // IniFormat does not create the parent directory, and sync() fails without it. QDir settingsDir(QFileInfo(qs->fileName()).absolutePath()); if (!settingsDir.exists()) + { settingsDir.mkpath("."); + } if (qs->status() != QSettings::NoError) { diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp index aa6c0257c..dde26a99b 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp @@ -239,10 +239,8 @@ namespace IPCore #endif // - // Reached during shutdown, when the audio thread may already be on - // its way out. Blocking on a thread with no event loop would never - // return; skipping the stop costs nothing there, since a thread that - // is not running its loop is not playing either. + // A thread without a running event loop is not playing, and blocking + // on it would never return. // if (!canBlockOnAudioThread()) { @@ -310,8 +308,7 @@ namespace IPCore #endif // - // See emitStopAudio(): also reached during shutdown, and blocking on - // a thread that cannot run the call never returns. + // See emitStopAudio(). // if (!canBlockOnAudioThread()) { @@ -352,12 +349,8 @@ namespace IPCore if (!createAudioOutput()) { // - // Release whatever was allocated before the failure, here, on the - // thread that owns it. Returning without exec() means no event - // loop ever runs on this thread, so detachAudioOutputDevice() - // could not marshal the deletion onto it -- its - // BlockingQueuedConnection would have no loop to run on. Clearing - // the pointers leaves it nothing to do. + // No event loop will run here, so detachAudioOutputDevice() cannot + // marshal the deletion onto this thread. Delete on the owner now. // deleteAudioOutputObjects(); @@ -415,20 +408,10 @@ namespace IPCore } // - // m_audioOutput/m_ioDevice were created inside run(), so they belong to - // this audio thread, not to whatever thread is calling - // detachAudioOutputDevice() (typically the main/UI thread, via - // ~QTAudioThread()). On Windows, QAudioSink's backing QWindowsAudioSink - // parents an internal QIODevice, so deleting these objects directly - // from another thread trips QObject::~QObject()'s cross-thread - // sendEvent() assertion (fatal in Qt6 debug builds). Delete them on the - // thread that owns them, while its event loop is still running to - // process the call. + // Delete the output objects on the audio thread that owns them: on + // Windows, deleting them cross-thread trips QObject's cross-thread + // sendEvent() assertion (fatal in Qt6 debug builds). // - // Only while there is such a loop, though. See canBlockOnAudioThread(): - // handing work to a thread that cannot run it and then waiting is a - // shutdown that never completes, which is strictly worse than the - // assertion this marshalling avoids. if ((m_audioOutput || m_ioDevice) && canBlockOnAudioThread()) { QObject* owner = m_audioOutput ? static_cast(m_audioOutput) : static_cast(m_ioDevice); @@ -440,10 +423,7 @@ namespace IPCore waitForAudioThreadToFinish(); // - // Anything the marshalled delete could not reach -- because there was - // no loop to marshal onto, or because it timed out. The thread is - // finished or beyond help by now, so doing it here is the last - // resort, and a Qt warning on the way out beats never getting out. + // Last resort for anything the marshalled delete could not reach. // deleteAudioOutputObjects(); } @@ -455,11 +435,6 @@ namespace IPCore void QTAudioThread::waitForAudioThreadToFinish() { - // - // Long enough that a healthy thread always makes it, short enough - // that a wedged one does not strand the user in a process they have - // to kill. - // constexpr unsigned long audioThreadExitTimeoutMS = 5000; if (!wait(audioThreadExitTimeoutMS)) @@ -478,10 +453,10 @@ namespace IPCore void QTAudioThread::deleteAudioOutputObjects() { delete m_audioOutput; - m_audioOutput = 0; + m_audioOutput = nullptr; delete m_ioDevice; - m_ioDevice = 0; + m_ioDevice = nullptr; } // diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h b/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h index 4674e4c54..2bbb55add 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h @@ -168,29 +168,18 @@ namespace IPCore void detachAudioOutputDevice(); // - // Can work be handed to this thread and waited on? - // - // Qt::BlockingQueuedConnection blocks until the target thread's - // event loop runs the call, so that loop has to exist and has to - // belong to somebody else. A thread that never started, whose - // createAudioOutput() failed, or that has already left exec() has no - // loop, and waiting on one never returns -- a process that will not - // exit rather than one that exits late. Calling from the audio - // thread itself would deadlock outright. + // True if a BlockingQueuedConnection call to this thread can return: + // it is running an event loop and is not the calling thread. // bool canBlockOnAudioThread() const; // - // wait(), bounded. An unbounded wait on a thread that will not - // finish is the same hang by another route; a late exit is worth - // more than a perfect one. Reports once if the bound is reached. + // Bounded wait(); reports once if the bound is reached. // void waitForAudioThreadToFinish(); // - // Delete the output objects and null them. Idempotent, so it can be - // run on the audio thread and then again afterwards to catch - // whatever that could not reach. + // Delete the output objects and null them. Idempotent. // void deleteAudioOutputObjects(); diff --git a/src/lib/geometry/TwkContainer/TwkContainer/Property.h b/src/lib/geometry/TwkContainer/TwkContainer/Property.h index ab1e31fd0..1fb630cf1 100644 --- a/src/lib/geometry/TwkContainer/TwkContainer/Property.h +++ b/src/lib/geometry/TwkContainer/TwkContainer/Property.h @@ -518,14 +518,8 @@ namespace TwkContainer } // - // An empty property has no storage to point at. front() on an empty - // container is undefined behaviour -- and a hard assert ("front() called - // on empty vector") in an MSVC debug build -- so report the absence with - // a null pointer instead. Callers that pair data()/rawData() with size() - // then do the right thing for free: a zero-length copy out of or into a - // cleared property becomes a no-op rather than a crash. Release builds - // already returned null here for any property that never held a value, - // so this only makes the existing behaviour well defined. + // front() on an empty container is undefined behaviour (and asserts in + // MSVC debug builds), so an empty property returns null. // template typename TypedProperty::const_value_pointer diff --git a/src/lib/graphics/TwkGLF/GL.cpp b/src/lib/graphics/TwkGLF/GL.cpp index 395917e1e..ed7e4a8ab 100644 --- a/src/lib/graphics/TwkGLF/GL.cpp +++ b/src/lib/graphics/TwkGLF/GL.cpp @@ -214,38 +214,17 @@ namespace TwkGLF } // namespace TwkGLF // -// Is any GL context current? +// Qt only knows about contexts it made current; FBOVideoDevice binds its own +// natively. glGetString() returns null only when no context at all is current. // -// QOpenGLContext::currentContext() only knows about contexts Qt made current, -// and TwkGLFFBO's FBOVideoDevice creates and binds its own natively -// (wglMakeCurrent / glXMakeCurrent / CGLSetCurrentContext). Trusting Qt alone -// would claim "no context" there while a perfectly good one is current, and -// would suppress the real GL errors the debug macro exists to print. -// glGetString() returns null only when nothing at all is current -- on every -// platform, for either kind of context -- so it settles the cases Qt cannot -// see. It is only reached when Qt says no, and it is a cached string lookup -// rather than a round trip. -// -bool twkGlAnyContextIsCurrent() -{ - return QOpenGLContext::currentContext() != nullptr || glGetString(GL_VERSION) != nullptr; -} +bool twkGlAnyContextIsCurrent() { return QOpenGLContext::currentContext() != nullptr || glGetString(GL_VERSION) != nullptr; } bool twkGlPrintError(std::string_view file, std::string_view function, const int line, const std::string_view msg) { // - // Check that some context is current before asking glGetError() anything. - // With no context current, glGetError() says nothing about this call: on - // Windows it returns GL_INVALID_OPERATION for every call, for as long as - // no context is current. Left unchecked, one missing context is reported - // as a GL error at every TWK_GLDEBUG that follows it, which buries the - // real fault under a dozen copies of itself and pins it on whichever - // innocent line happens to check next -- the reason a missing context in - // presentation teardown used to surface as an error in makeCurrent(), a - // frame late and in the wrong place. - // - // Report once per episode, at the first site to notice, and reset when a - // context comes back so a later episode is not silently swallowed. + // With no context current, glGetError() is meaningless (Windows returns + // GL_INVALID_OPERATION for every call). Report once per episode instead, + // and reset when a context comes back. // static std::atomic noContextReported{false}; diff --git a/src/lib/graphics/TwkGLF/GLContextScope.cpp b/src/lib/graphics/TwkGLF/GLContextScope.cpp index 5229beb07..9d9bacbb3 100644 --- a/src/lib/graphics/TwkGLF/GLContextScope.cpp +++ b/src/lib/graphics/TwkGLF/GLContextScope.cpp @@ -23,15 +23,9 @@ namespace TwkGLF { // - // The fallback teardown context. - // - // Created once and never destroyed. The paths that need it run while - // the application object is itself being torn down, so anything that - // freed this at static-destruction time would free it either too - // early to be useful or after QGuiApplication has already gone. One - // leaked context at process exit costs nothing; getting that ordering - // wrong costs a crash on the way out, which is the class of bug this - // exists to remove. + // The fallback teardown context. Intentionally leaked: it is needed + // while the application is being torn down, so there is no safe + // point to free it. // QOpenGLContext* s_fallbackContext = nullptr; QOffscreenSurface* s_fallbackSurface = nullptr; @@ -50,20 +44,15 @@ namespace TwkGLF } // - // Build the fallback context, once. Returns false -- quietly after - // the first time -- if it cannot be had. + // Build the fallback context, once. // bool createFallbackContext() { s_fallbackAttempted = true; // - // Insist on the global share group. GL names live in a share - // group, so deleting an FBO under a context outside the group - // that created it is not an error -- it simply does nothing, - // which is the exact silent leak this class is meant to stop. A - // non-sharing fallback would look like a fix and behave like the - // bug. + // Must share with the global group: deleting a name from outside + // its share group silently does nothing. // QOpenGLContext* share = QOpenGLContext::globalShareContext(); @@ -105,10 +94,7 @@ namespace TwkGLF bool makeFallbackCurrent() { // - // QOpenGLContext is thread-affine and QOffscreenSurface::create() - // is GUI-thread only, so this fallback serves the GUI thread. That - // is where teardown runs. Anywhere else, decline rather than - // silently misbehave. + // QOffscreenSurface::create() is GUI-thread only. // QCoreApplication* app = QCoreApplication::instance(); @@ -151,10 +137,7 @@ namespace TwkGLF , m_hasContext(false) { // - // Already current -- including a context bound natively rather than - // through Qt -- so leave it alone. Displacing a live context from - // inside a destructor would be far worse than the problem this scope - // solves. + // Never displace a live context, including a natively bound one. // if (twkGlAnyContextIsCurrent()) { @@ -189,12 +172,8 @@ namespace TwkGLF } // - // Nothing was current on entry -- that is the only state in which - // this scope acquires -- so restoring means making nothing current. - // - // A context bound natively by the device cannot be released this way; - // Qt does not know about it. That is acceptable: it belongs to the - // device, which will bind or release it on its own terms. + // Nothing was current on entry. A context bound natively by the + // device is invisible to Qt and is left to the device. // if (QOpenGLContext* current = QOpenGLContext::currentContext()) { diff --git a/src/lib/graphics/TwkGLF/GLFBO.cpp b/src/lib/graphics/TwkGLF/GLFBO.cpp index 867a56656..938570e95 100644 --- a/src/lib/graphics/TwkGLF/GLFBO.cpp +++ b/src/lib/graphics/TwkGLF/GLFBO.cpp @@ -86,34 +86,15 @@ namespace TwkGLF GLFBO::~GLFBO() { // - // Does this destructor have any GL work to do at all? - // - // Not every GLFBO owns GL names. The GLFBO(const GLVideoDevice*) - // constructor builds a handle *onto* whatever the device has bound -- - // m_id is 0, m_ownsFBOHandle is false, there is no PBO -- so - // destroying one issues nothing and needs no context. Asking about - // the context before asking this would report a leak that cannot - // happen, on the ordinary path where a device outlives its window. + // A GLFBO wrapping a device's bound framebuffer owns no GL names and + // needs no context to destroy. // const bool ownsHandles = (m_id != 0 && m_ownsFBOHandle); const bool issuesGL = ownsHandles || (m_pbo != 0); // - // Backstop, for the FBOs that do own something. With no context - // current every GL call below is a silent no-op: this object goes - // away, the driver's does not, and nothing says so. Report it at this - // line -- rather than letting the stuck GL_INVALID_OPERATION surface - // at whichever unrelated call site checks glGetError() next -- and do - // not pretend the names were released. - // - // With GLContextScope on the teardown paths this should never fire. - // It is here so that the next path which forgets announces itself - // where the fault is, instead of a frame later somewhere else. - // - // It reports and carries on rather than asserting. A leaked FBO is - // worth a line of output; it is not worth aborting a shutdown that - // would otherwise have completed, least of all in the debug build - // someone is using to diagnose that shutdown. + // With no context current the deletes below are silent no-ops. Report + // the leak here (without asserting) and skip the GL calls. // bool canIssueGL = true; @@ -156,10 +137,7 @@ namespace TwkGLF if (m_pbo) { // - // Waiting on a fence with no context current cannot complete -- - // there is nothing to signal it -- so skip the wait rather than - // risk blocking here. The fence object itself is still ours to - // delete either way. + // A fence wait with no context current can never complete. // if (m_fence && canIssueGL) { @@ -596,12 +574,6 @@ namespace TwkGLF destinationGLFBO->bind(GL_DRAW_FRAMEBUFFER_EXT); glBlitFramebufferEXT(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); - // - // Without this, an incomplete read/draw framebuffer here raises - // GL_INVALID_FRAMEBUFFER_OPERATION that nothing pops until the next - // frame's makeCurrent(), which reports it against an unrelated call - // site one frame late. Attribute it where it happens. - // TWK_GLDEBUG; HOP_CALL(glFinish();) diff --git a/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp b/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp index 5b6c73bd7..f1319d7a6 100644 --- a/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp +++ b/src/lib/graphics/TwkGLF/GLPixelBufferObjectPool.cpp @@ -502,18 +502,9 @@ namespace TwkGLF } // - // Release every buffer now, rather than at destruction. - // - // The two pools are file-scope statics, so their destructors run - // after main() has returned -- with Qt gone and no GL context - // obtainable, which makes every glDeleteBuffers() in there a silent - // no-op. UninitPBOPools() exists to release these while the - // application is still up; until now it only flipped a flag and left - // the buffers to that unreachable destructor. - // - // Empty the containers and reset the accounting as well as deleting: - // the destructor still runs later, and would otherwise walk the same - // entries a second time. + // Release every buffer while a context is still obtainable: the pools + // are statics whose destructors run after Qt is gone. Emptying the + // containers keeps that destructor from walking them again. // void clear() { @@ -954,11 +945,7 @@ namespace TwkGLF void UninitPBOPools() { // - // This is called from main() once the event loop has returned, so - // the views and their contexts are already gone and nothing is - // current. The scope supplies the fallback teardown context -- still - // available here, since the QApplication outlives this call -- so - // that the buffers released below are genuinely released. + // Called after the event loop returns, when no view context is left. // const GLContextScope contextScope; diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GL.h b/src/lib/graphics/TwkGLF/TwkGLF/GL.h index 24202772f..b3714edfb 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GL.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GL.h @@ -102,19 +102,13 @@ struct GLPushMatrix // // -// Is any GL context current, Qt's or a natively-bound one? Declared outside -// the NDEBUG guard below because callers other than the debug macro need it to -// decide whether GL work can land at all. +// Is any GL context current, Qt's or a natively-bound one? // bool twkGlAnyContextIsCurrent(); // -// Declared in every build, for the same reason. TWK_GLDEBUG below still -// compiles out under NDEBUG -- polling glGetError() at every instrumented call -// site is a debug-only cost -- but the "no current GL context" report this -// also emits is not instrumentation. It fires only when GL work is being -// issued that cannot land, which is a fault in a release build too, and the -// caller that has to say so (GLFBO's destructor) is compiled in both. +// Declared in every build: GLFBO's destructor uses it to report a missing +// context in release builds too. TWK_GLDEBUG still compiles out under NDEBUG. // bool twkGlPrintError(std::string_view file, std::string_view function, const int line, std::string_view msg); diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h index 823ecc96a..4114f5a8a 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h @@ -17,36 +17,18 @@ namespace TwkGLF // GLContextScope // // Guarantees that a GL context is current for the lifetime of the scope, - // so that code which destroys GL objects actually destroys them. - // - // glDeleteFramebuffers() and friends are silent no-ops with no context - // current: the C++ object goes away, the driver's object does not, and - // nothing says so. Teardown paths are where this bites, because they are - // reached from destructors and from event callbacks rather than from - // inside a render, so nothing has arranged a context for them. Open one of - // these at the top of any scope that deletes GL objects and the question - // stops being the caller's problem. + // so that GL deletes in teardown paths are not silent no-ops. // // Construction resolves a context in this order: // - // 1. A context is already current -- do nothing. This is the common - // case and costs a pointer compare, so the scope is safe to put on - // paths that also run mid-render. - // 2. A GLVideoDevice was supplied -- makeCurrent() on it. Prefer this - // where the caller knows its device; it is cheaper than 3 and it is - // the context the objects were most likely created under. - // 3. Otherwise -- a process-lifetime fallback context in RV's global - // share group. This is what makes the scope work in destructors that - // have already had their device pointers cleared out from under them. - // - // Destruction puts back what was current before, which -- because the - // scope only acquires when nothing was current -- means making nothing - // current again. + // 1. A context is already current: do nothing (a pointer compare). + // 2. A GLVideoDevice was supplied: makeCurrent() on it. + // 3. Otherwise: a process-lifetime fallback context in RV's global + // share group. // - // If no context can be resolved, the scope reports once and does nothing. - // It never throws and never aborts: a teardown helper must not be the - // reason the process fails to exit. Callers that need to know can ask - // hasContext(). + // Destruction makes nothing current again if this scope acquired. If no + // context can be resolved, the scope reports once and does nothing; it + // never throws. Check hasContext() if needed. // class GLContextScope { @@ -57,14 +39,10 @@ namespace TwkGLF GLContextScope(const GLContextScope&) = delete; GLContextScope& operator=(const GLContextScope&) = delete; - // - // Is a context current for the body of this scope? False only when - // none could be resolved, in which case GL work here will not land. - // bool hasContext() const { return m_hasContext; } private: - bool m_acquired; // did this scope make something current? + bool m_acquired; // did this scope make something current? bool m_hasContext; }; diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h b/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h index 0e513d3ee..5805ae46c 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GLFBO.h @@ -240,11 +240,8 @@ namespace TwkGLF void check() const; // - // Non-throwing completeness test, for callers that assemble an FBO - // from attachments they do not own and have to be able to reject the - // result. An FBO built around a foreign texture name can come out - // incomplete without any call failing outright, and blitting from it - // then fails every frame far from the cause. + // Non-throwing completeness test, for FBOs built around foreign + // attachments that can come out incomplete without any call failing. // bool isComplete() const; diff --git a/src/lib/ip/IPCore/IPCore/IPGraph.h b/src/lib/ip/IPCore/IPCore/IPGraph.h index d47a88ad2..f62556ee4 100644 --- a/src/lib/ip/IPCore/IPCore/IPGraph.h +++ b/src/lib/ip/IPCore/IPCore/IPGraph.h @@ -334,10 +334,8 @@ namespace IPCore // // Re-point the existing DisplayGroupIPNodes at a rebuilt set of - // physical devices, keeping each group (and its colour pipeline) - // alive. Use this, not setPhysicalDevices(), when the devices were - // recreated but the monitors behind them did not change -- see the - // implementation for why the difference matters. + // physical devices, keeping each group's colour pipeline. Use this + // instead of setPhysicalDevices() when the monitors did not change. // void refreshPhysicalDevices(const VideoModules&); diff --git a/src/lib/ip/IPCore/IPCore/ImageRenderer.h b/src/lib/ip/IPCore/IPCore/ImageRenderer.h index 0d12d3d7e..28eb09007 100644 --- a/src/lib/ip/IPCore/IPCore/ImageRenderer.h +++ b/src/lib/ip/IPCore/IPCore/ImageRenderer.h @@ -682,7 +682,7 @@ namespace IPCore static bool debugGpu() { return m_debugGpu; } - // Deprecated — use debugGpu() instead. + // Deprecated: use debugGpu() instead. static void reportGL(bool b) { debugGpu(b); } static bool reportGL() { return debugGpu(); } diff --git a/src/lib/ip/IPCore/IPGraph.cpp b/src/lib/ip/IPCore/IPGraph.cpp index a2d12c580..a87e38f2e 100644 --- a/src/lib/ip/IPCore/IPGraph.cpp +++ b/src/lib/ip/IPCore/IPGraph.cpp @@ -756,22 +756,9 @@ namespace IPCore void IPGraph::refreshPhysicalDevicesInternal(const VideoModules& modules) { // - // Re-point the existing display groups at a rebuilt set of physical - // devices, rather than rebuilding the groups the way - // setPhysicalDevicesInternal() does. - // - // That function is the startup path: it deletes every - // DisplayGroupIPNode and makes new ones, and a new display group comes - // with a new colorPipeline holding default contents. Using it as a - // refresh therefore discards the display colour state -- the transfer - // function and any assigned display profile. That is what reset the - // main view from sRGB to None on every 8/10-bit switch: swapping the - // main-view backend rebuilds the desktop devices, behind the very same - // monitors, and took the colour pipeline with it. - // - // Devices are matched to groups by (module name, device name), the - // same key display profiles are stored under, and stable across a - // rebuild precisely because the monitors have not changed. + // Unlike setPhysicalDevicesInternal(), keep the existing display groups + // so their colour pipelines survive. Groups are matched to devices by + // (module name, device name), which is stable while the monitors are. // std::vector devices; @@ -783,9 +770,7 @@ namespace IPCore } // - // With nothing to match on either side there is no state worth - // preserving, and setPhysicalDevicesInternal() already handles the - // empty-modules (defaultOutputGroup) case correctly. + // Nothing to preserve. // if (devices.empty() || m_displayGroups.empty()) { @@ -821,7 +806,9 @@ namespace IPCore for (size_t di = 0; di < devices.size(); di++) { if (deviceMatched[di]) + { continue; + } const string deviceModule = devices[di]->module() ? devices[di]->module()->name() : ""; @@ -835,8 +822,7 @@ namespace IPCore if (match == devices.size()) { // - // Nothing answers to this group's device any more -- a monitor - // was unplugged, or a module stopped advertising it. + // The group's device is gone (e.g. a monitor was unplugged). // if (m_rootNode->isInput(group)) { @@ -852,11 +838,9 @@ namespace IPCore group->setPhysicalVideoDevice(devices[match]); // - // Drop a stale output pointer. The device it named was just - // destroyed, and findDisplayGroupByDevice() compares pointers -- - // a dangling one can alias a freshly allocated device at the same - // address and hand back the wrong group. The control device is - // still alive and is not in the module list, so it is kept. + // Drop a dangling output pointer: findDisplayGroupByDevice() + // compares pointers and it could alias a new device. The control + // device is still alive, so keep it. // if (group->outputDevice() && group->outputDevice() != m_controlDevice) { @@ -873,7 +857,7 @@ namespace IPCore if (!stillPresent) { - group->setOutputVideoDevice(0); + group->setOutputVideoDevice(nullptr); } } @@ -893,12 +877,14 @@ namespace IPCore } // - // Devices that no existing group describes are genuinely new. + // Unmatched devices are new. // for (size_t di = 0; di < devices.size(); di++) { if (deviceMatched[di]) + { continue; + } size_t n = m_displayGroups.size(); string name; @@ -982,25 +968,9 @@ namespace IPCore dnode->setOutputVideoDevice(newDevice); // - // Only adopt a physical device the new device actually knows. - // - // VideoDevice's constructor seeds m_physicalDevice with the - // device itself, and a viewport device only learns the monitor - // it sits on when it first renders (setAbsolutePosition -> - // deviceFromPosition). The one caller of this is - // Session::setControlVideoDevice(), which during a main-view - // backend swap runs on a view that has never rendered -- so - // physicalDevice() is still that view. - // - // Adopting it anyway rewrote the group's device.name from the - // monitor ("Dell Inc. DELL U2725QE DP-1") to the viewport's own - // name ("RV Main Window (Vulkan)/0x..."). The group then - // described no physical device, so the rebuild that follows - // discarded it and its colour pipeline with it -- which is why - // the display transfer function fell back from sRGB to None on - // every 8/10-bit switch. Keeping the old value is right: the - // monitor has not changed, only the object drawing to it, and - // refreshPhysicalDevices() re-points the pointer by name. + // A view that has not rendered yet reports itself as its own + // physical device. Keep the group's monitor in that case, or + // its device.name no longer matches on refreshPhysicalDevices(). // if (const VideoDevice* physical = newDevice->physicalDevice()) { diff --git a/src/lib/ip/IPCore/ImageFBO.cpp b/src/lib/ip/IPCore/ImageFBO.cpp index 356aeecff..b1da1c508 100644 --- a/src/lib/ip/IPCore/ImageFBO.cpp +++ b/src/lib/ip/IPCore/ImageFBO.cpp @@ -240,13 +240,7 @@ namespace IPCore void ImageFBOManager::destroyImageFBO(ImageFBO* imageFBO) { // - // Everything below deletes GL objects -- the fence, then the FBO and - // its attachments -- so a context has to be current for any of it to - // reach the driver. This is reached from destructors and from event - // callbacks as well as from renders, so nothing upstream guarantees - // one. flushImageFBOs() opens a scope of its own around the whole - // loop; this one covers every other caller and costs a pointer - // compare when a context is already current. + // Reached from destructors and event callbacks, not only renders. // const TwkGLF::GLContextScope contextScope; @@ -258,6 +252,7 @@ namespace IPCore void ImageFBOManager::gcImageFBOs(size_t fullSerialNum) { + // Number of render cycles an unused regular FBO is kept before being freed. // A small grace window (roughly 200ms at 24fps) prevents thrashing when a // frame is temporarily skipped during cache warm-up or off-screen evaluation. @@ -557,9 +552,7 @@ namespace IPCore void ImageFBOManager::flushImageFBOs() { // - // One scope for the whole flush rather than one per FBO. Same - // guarantee, but the fallback context -- if it is the one that ends - // up being used -- is made current once instead of once per object. + // Acquire once for the whole flush rather than per FBO. // const TwkGLF::GLContextScope contextScope; diff --git a/src/lib/ip/IPCore/ImageRenderer.cpp b/src/lib/ip/IPCore/ImageRenderer.cpp index a26df4935..cb80720f2 100644 --- a/src/lib/ip/IPCore/ImageRenderer.cpp +++ b/src/lib/ip/IPCore/ImageRenderer.cpp @@ -183,13 +183,6 @@ namespace IPCore void ImageRenderer::Device::clearFBOs() { - // - // The ring buffer holds GLFBOs, so this is GL destruction and needs - // a context like any other. ~ImageRenderer reaches it after the - // renderer's device pointers have been cleared, which is why the - // scope may have to fall back to its own context; glDevice is still - // worth offering for the callers that reach here with one alive. - // const TwkGLF::GLContextScope contextScope(glDevice); for (size_t i = 0; i < fboRingBuffer.size(); i++) @@ -489,13 +482,7 @@ namespace IPCore } // - // Everything from here down deletes GL objects -- the FBO pool and - // program cache via clearState(), the program cache object itself, - // each device's FBO ring buffer, then the GL state. Session tears the - // renderer down after its device pointers have been cleared, so the - // members below have nothing to offer and the scope falls back to its - // own context. Holding one here means it is acquired once rather than - // once per inner scope. + // Everything from here down deletes GL objects. // const TwkGLF::GLContextScope contextScope(m_controlDevice.glDevice); @@ -632,11 +619,7 @@ namespace IPCore void ImageRenderer::clearState() { // - // One scope around the whole of it. flushImageFBOs() opens its own, - // but that one closes when it returns -- and flushProgramCache() - // after it deletes GL programs, which needs a context just as much. - // Holding it here keeps the inner scopes as no-ops and leaves no gap - // between them. + // Covers flushProgramCache() too, not just flushImageFBOs(). // const TwkGLF::GLContextScope contextScope(m_controlDevice.glDevice); @@ -1345,23 +1328,9 @@ namespace IPCore // // - // m_outputDevice.glDevice is a dynamic_cast to GLVideoDevice, and that - // is null for every GLBindableVideoDevice output -- presentation, AJA, - // NDI -- because GLVideoDevice and GLBindableVideoDevice are siblings - // (both derive TwkApp::VideoDevice directly), not base and derived. - // With no fallback, everything below here tears down FBOs, fences and - // textures with no context current at all. That is what makes quitting - // out of presentation mode log a long tail of GL_INVALID_OPERATION - // starting in ~GLFBO: once no context is current, glGetError() keeps - // returning that same error, so a single lost context is worth a great - // many messages. - // - // The control device's context is the right one to fall back to. It - // owns the FBOs cleared below -- for a bindable output the renderer - // draws in the control context on purpose, see the comment above -- - // and it is what the rest of this function already relies on further - // down, where defaultFBO() happens to make it current as a side - // effect. + // m_outputDevice.glDevice is null for GLBindableVideoDevice outputs + // (presentation, AJA, NDI). Fall back to the control device, whose + // context owns the FBOs released below. // if (m_outputDevice.glDevice) { @@ -2480,7 +2449,7 @@ namespace IPCore // or waiting for the sync to complete before continuing. // // NOTE: I still think its possible to get stomped on -- you can - // tell if that's happen by setting m_debugGpu (-debug gpu in RV) + // tell if that has happened by setting m_debugGpu (-debug gpu in RV) // which will cause some debug code to clear to blue. If you see // blue flashing on the pres device that's the problem. // diff --git a/src/lib/ip/IPCore/Session.cpp b/src/lib/ip/IPCore/Session.cpp index 4e41bae53..41c807aaa 100644 --- a/src/lib/ip/IPCore/Session.cpp +++ b/src/lib/ip/IPCore/Session.cpp @@ -1146,14 +1146,7 @@ namespace IPCore void Session::clearVideoDeviceCaches() { // - // Everything below destroys GL objects -- each device's cached FBO - // clones, then the renderer's entire ImageFBO pool via clearState() -> - // flushImageFBOs(). Nothing guarantees a context on entry: this runs - // from a RenderContextChangeEvent and from shutdown, not only from - // inside a render where one happens to be bound. - // - // We know our control device, so hand it over rather than making the - // scope fall back to its own context. + // Runs from RenderContextChangeEvent and shutdown, outside a render. // const TwkGLF::GLContextScope contextScope(dynamic_cast(m_controlVideoDevice)); @@ -1170,11 +1163,7 @@ namespace IPCore if (d == m_outputVideoDevice || d == m_controlVideoDevice) { // - // This arrives from the view's resize, not from a render, so - // there is no context current -- and leaving presentation mode - // resizes the main view, which is how a whole FBO pool came to be - // deleted into nothing at exit. The device that changed size is - // right here, so use its context. + // Arrives from a resize, outside a render. // const TwkGLF::GLContextScope contextScope(dynamic_cast(d)); From 72448b4db5be7facd8190efa9437a080eab62e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 28 Sep 2026 09:32:11 -0400 Subject: [PATCH 36/48] fix: harden GL, audio and Vulkan shutdown and failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from a C++ review of this branch. - twkGlAnyContextIsCurrent() probed with glGetString(), which is undefined with no context current and can crash on macOS. Ask the platform instead (CGLGetCurrentContext / wglGetCurrentContext); Linux keeps glGetString(), which GLVND answers with null. - When the audio thread does not exit within the bounded wait, stop deleting objects it still owns and leak the thread, detached from its parent, instead of destroying a running QThread (fatal in Qt6). - presentPixelData() compared the swapchain against the requested size rather than the surface extent, recreating it every frame on a mismatch and copying past the swapchain image. Share the surface check with getSharedImageInfo() and clamp the copy. - Check every staging buffer create/allocate/bind/map result and null freed handles, so a failed map no longer writes through an uninitialised pointer and an early return cannot double free. - A failed submit after the fence reset left the fence unsignaled, so the next wait on that slot hung forever. Re-signal it with an empty submit that also consumes the acquire semaphore, then fall back to GL. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 7 + src/lib/app/RvCommon/VulkanWindow.cpp | 126 +++++++++++------- .../audio/QTAudioRenderer/QTAudioRenderer.cpp | 47 +++++-- .../QTAudioRenderer/QTAudioRenderer.h | 13 +- src/lib/graphics/TwkGLF/GL.cpp | 19 ++- 5 files changed, 149 insertions(+), 63 deletions(-) diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index 2006217e9..2425fdf8f 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -190,6 +190,9 @@ namespace Rv bool createSwapchain(); void cleanupSwapchain(); + // Recreates the swapchain if it no longer matches the surface extent. + bool ensureSwapchainMatchesSurface(); + // Resolves m_interopConfig once per device (not per slot or resize). void negotiateInteropConfig(); @@ -275,6 +278,10 @@ namespace Rv // fails; this minimal submit keeps the semaphore pair balanced. void drainSharedSemaphores(uint32_t slot); + // After a failed fenced submit: re-signal the slot fence so the next + // wait on it cannot hang, consuming waitSemaphore if one is given. + void recoverFailedSubmit(uint32_t slot, VkSemaphore waitSemaphore); + // Recreate the swapchain after OUT_OF_DATE. SUBOPTIMAL remains usable. void handleSwapchainOutOfDate(); diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index c976a2573..a0c1abac5 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1651,35 +1651,60 @@ namespace Rv } } - const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) + void VulkanWindow::recoverFailedSubmit(uint32_t slot, VkSemaphore waitSemaphore) { - if (!m_vkDevice || !m_externalInteropSupported) + VkSubmitInfo recover = {}; + recover.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_TRANSFER_BIT; + if (waitSemaphore != VK_NULL_HANDLE) { - return nullptr; + recover.waitSemaphoreCount = 1; + recover.pWaitSemaphores = &waitSemaphore; + recover.pWaitDstStageMask = &waitStage; } - const uint32_t slot = m_currentFrame; - SharedImageInfo& info = m_sharedImageInfo[slot]; + if (vkQueueSubmit(m_vkQueue, 1, &recover, m_vkFence[slot]) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: could not recover from a failed submit" << endl; + } - // Compare against the surface extent, not the requested size: - // createSwapchain() can only match the surface, so a mismatched request - // would recreate the swapchain every frame. + // An unexpected submit failure means Vulkan is unreliable here. + requestGLFallback(); + } + + // Compare against the surface extent, not the requested size: + // createSwapchain() can only match the surface, so a mismatched request + // would recreate the swapchain every frame. + bool VulkanWindow::ensureSwapchainMatchesSurface() + { VkExtent2D surfaceExtent = m_vkSwapchainExtent; + VkSurfaceCapabilitiesKHR caps = {}; + if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &caps) == VK_SUCCESS + && caps.currentExtent.width != UINT32_MAX) { - VkSurfaceCapabilitiesKHR caps = {}; - if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &caps) == VK_SUCCESS - && caps.currentExtent.width != UINT32_MAX) - { - surfaceExtent = caps.currentExtent; - } + surfaceExtent = caps.currentExtent; } if (!m_vkSwapchain || m_vkSwapchainExtent.width != surfaceExtent.width || m_vkSwapchainExtent.height != surfaceExtent.height) { - if (!createSwapchain()) - { - return nullptr; - } + return createSwapchain(); + } + return true; + } + + const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) + { + if (!m_vkDevice || !m_externalInteropSupported) + { + return nullptr; + } + + const uint32_t slot = m_currentFrame; + SharedImageInfo& info = m_sharedImageInfo[slot]; + + if (!ensureSwapchainMatchesSurface()) + { + return nullptr; } // Within capacity: reuse the export, update the used sub-region. @@ -2075,10 +2100,7 @@ namespace Rv VkResult layoutSubmitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); if (layoutSubmitResult != VK_SUCCESS) { - if (layoutSubmitResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } + recoverFailedSubmit(slot, VK_NULL_HANDLE); cleanupSharedImage(slot); return nullptr; } @@ -2312,10 +2334,8 @@ namespace Rv VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); if (submitResult != VK_SUCCESS) { - if (submitResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } + drainSharedSemaphores(slot); + recoverFailedSubmit(slot, m_vkImageAvailableSemaphore[slot]); return; } @@ -2381,13 +2401,9 @@ namespace Rv const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; Timer diagTimer; - if (!m_vkSwapchain || m_vkSwapchainExtent.width != static_cast(w) - || m_vkSwapchainExtent.height != static_cast(h)) + if (w <= 0 || h <= 0 || !ensureSwapchainMatchesSurface()) { - if (!createSwapchain()) - { - return; - } + return; } // Same best-effort throttle as presentSharedImage(). @@ -2417,25 +2433,33 @@ namespace Rv return; } - size_t size = w * h * 4; + const size_t size = static_cast(w) * static_cast(h) * 4; if (size > m_stagingBufferSize[slot]) { if (m_vkStagingBuffer[slot]) { vkDestroyBuffer(m_vkDevice, m_vkStagingBuffer[slot], nullptr); + m_vkStagingBuffer[slot] = VK_NULL_HANDLE; } if (m_vkStagingBufferMemory[slot]) { vkFreeMemory(m_vkDevice, m_vkStagingBufferMemory[slot], nullptr); + m_vkStagingBufferMemory[slot] = VK_NULL_HANDLE; } + m_stagingBufferSize[slot] = 0; VkBufferCreateInfo bufferInfo = {}; bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; bufferInfo.size = size; bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - vkCreateBuffer(m_vkDevice, &bufferInfo, nullptr, &m_vkStagingBuffer[slot]); + if (vkCreateBuffer(m_vkDevice, &bufferInfo, nullptr, &m_vkStagingBuffer[slot]) != VK_SUCCESS) + { + m_vkStagingBuffer[slot] = VK_NULL_HANDLE; + cerr << "ERROR: VulkanWindow: Failed to create staging buffer" << endl; + return; + } VkMemoryRequirements memRequirements; vkGetBufferMemoryRequirements(m_vkDevice, m_vkStagingBuffer[slot], &memRequirements); @@ -2451,14 +2475,27 @@ namespace Rv return; } - vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_vkStagingBufferMemory[slot]); - vkBindBufferMemory(m_vkDevice, m_vkStagingBuffer[slot], m_vkStagingBufferMemory[slot], 0); + if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_vkStagingBufferMemory[slot]) != VK_SUCCESS) + { + m_vkStagingBufferMemory[slot] = VK_NULL_HANDLE; + cerr << "ERROR: VulkanWindow: Failed to allocate staging buffer memory" << endl; + return; + } + if (vkBindBufferMemory(m_vkDevice, m_vkStagingBuffer[slot], m_vkStagingBufferMemory[slot], 0) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to bind staging buffer memory" << endl; + return; + } m_stagingBufferSize[slot] = size; } - void* data; - vkMapMemory(m_vkDevice, m_vkStagingBufferMemory[slot], 0, size, 0, &data); + void* data = nullptr; + if (vkMapMemory(m_vkDevice, m_vkStagingBufferMemory[slot], 0, size, 0, &data) != VK_SUCCESS) + { + cerr << "ERROR: VulkanWindow: Failed to map staging buffer memory" << endl; + return; + } memcpy(data, pixels, size); vkUnmapMemory(m_vkDevice, m_vkStagingBufferMemory[slot]); @@ -2525,16 +2562,18 @@ namespace Rv vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + // The buffer is w x h, but the destination is bounded by the swapchain. VkBufferImageCopy region = {}; region.bufferOffset = 0; - region.bufferRowLength = 0; - region.bufferImageHeight = 0; + region.bufferRowLength = static_cast(w); + region.bufferImageHeight = static_cast(h); region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; region.imageSubresource.mipLevel = 0; region.imageSubresource.baseArrayLayer = 0; region.imageSubresource.layerCount = 1; region.imageOffset = {0, 0, 0}; - region.imageExtent = {static_cast(w), static_cast(h), 1}; + region.imageExtent = {std::min(static_cast(w), m_vkSwapchainExtent.width), + std::min(static_cast(h), m_vkSwapchainExtent.height), 1}; vkCmdCopyBufferToImage(cb, m_vkStagingBuffer[slot], m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); @@ -2565,10 +2604,7 @@ namespace Rv VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); if (submitResult != VK_SUCCESS) { - if (submitResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } + recoverFailedSubmit(slot, m_vkImageAvailableSemaphore[slot]); return; } diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp index dde26a99b..afaf4fed0 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp @@ -392,7 +392,7 @@ namespace IPCore return true; } - void QTAudioThread::detachAudioOutputDevice() + bool QTAudioThread::detachAudioOutputDevice() { if (AudioRenderer::debug) TwkUtil::Log("AUDIO") << "detachAudioOutputDevice"; @@ -420,12 +420,20 @@ namespace IPCore } quit(); - waitForAudioThreadToFinish(); + if (!waitForAudioThreadToFinish()) + { + // + // The objects still belong to the running thread; leak them + // rather than delete them cross-thread. + // + return false; + } // // Last resort for anything the marshalled delete could not reach. // deleteAudioOutputObjects(); + return true; } bool QTAudioThread::canBlockOnAudioThread() const @@ -433,21 +441,25 @@ namespace IPCore return isRunning() && eventDispatcher() != nullptr && QThread::currentThread() != this; } - void QTAudioThread::waitForAudioThreadToFinish() + bool QTAudioThread::waitForAudioThreadToFinish() { constexpr unsigned long audioThreadExitTimeoutMS = 5000; - if (!wait(audioThreadExitTimeoutMS)) + if (wait(audioThreadExitTimeoutMS)) { - static bool reported = false; + return true; + } - if (!reported) - { - reported = true; - std::cerr << "WARNING: audio thread did not exit within " << audioThreadExitTimeoutMS - << " ms; continuing shutdown without it" << std::endl; - } + static bool reported = false; + + if (!reported) + { + reported = true; + std::cerr << "WARNING: audio thread did not exit within " << audioThreadExitTimeoutMS << " ms; continuing shutdown without it" + << std::endl; } + + return false; } void QTAudioThread::deleteAudioOutputObjects() @@ -1086,7 +1098,18 @@ namespace IPCore { if (m_thread) { - delete m_thread; + // + // Destroying a QThread that is still running is fatal in Qt6, so + // a wedged audio thread is leaked, detached from its parent. + // + if (m_thread->detachAudioOutputDevice()) + { + delete m_thread; + } + else + { + m_thread->setParent(nullptr); + } } } diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h b/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h index 2bbb55add..f5758b9c0 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer/QTAudioRenderer.h @@ -111,6 +111,12 @@ namespace IPCore void startMe(); + // + // Stop the device and the thread. Returns false if the thread did + // not exit in time; it must then be leaked, not destroyed. + // + bool detachAudioOutputDevice(); + size_t processedSamples() const; void setProcessedSamples(size_t n); @@ -165,8 +171,6 @@ namespace IPCore private: bool createAudioOutput(); - void detachAudioOutputDevice(); - // // True if a BlockingQueuedConnection call to this thread can return: // it is running an event loop and is not the calling thread. @@ -174,9 +178,10 @@ namespace IPCore bool canBlockOnAudioThread() const; // - // Bounded wait(); reports once if the bound is reached. + // Bounded wait(); reports once and returns false if the bound is + // reached. // - void waitForAudioThreadToFinish(); + bool waitForAudioThreadToFinish(); // // Delete the output objects and null them. Idempotent. diff --git a/src/lib/graphics/TwkGLF/GL.cpp b/src/lib/graphics/TwkGLF/GL.cpp index ed7e4a8ab..49827d5be 100644 --- a/src/lib/graphics/TwkGLF/GL.cpp +++ b/src/lib/graphics/TwkGLF/GL.cpp @@ -215,9 +215,24 @@ namespace TwkGLF // // Qt only knows about contexts it made current; FBOVideoDevice binds its own -// natively. glGetString() returns null only when no context at all is current. +// natively, so also ask the platform. A GL call with no context current is +// undefined (macOS crashes), so glGetString() is only used on Linux, where +// GLVND returns null. // -bool twkGlAnyContextIsCurrent() { return QOpenGLContext::currentContext() != nullptr || glGetString(GL_VERSION) != nullptr; } +bool twkGlAnyContextIsCurrent() +{ + if (QOpenGLContext::currentContext() != nullptr) + { + return true; + } +#if defined(PLATFORM_DARWIN) + return CGLGetCurrentContext() != nullptr; +#elif defined(PLATFORM_WINDOWS) + return wglGetCurrentContext() != nullptr; +#else + return glGetString(GL_VERSION) != nullptr; +#endif +} bool twkGlPrintError(std::string_view file, std::string_view function, const int line, const std::string_view msg) { From d0cdaedeaa117097d0928474aa7acfe5a0f4d697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 28 Sep 2026 11:23:17 -0400 Subject: [PATCH 37/48] refactor: address remaining C++ review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the C++ review of this branch. - Order interop tiling candidates by vendor preference: OPTIMAL first on NVIDIA, LINEAR first elsewhere. Mesa reports OPTIMAL as exportable but renders tile garbage with it, so trying it first everywhere regressed RADV. Drop the now redundant legacy-heuristic log line. - ~QTVulkanVideoDevice cleans every slot whenever its context exists; slot 1 imports could previously leak and pin the Vulkan memory. - Document that GLContextScope keeps an already-current context, which is right for shared objects but not for FBOs. - Store exported fds/handles in the slot record as soon as they are obtained, so a later failure in getSharedImageInfo() closes them. - Delete copy operations on QTVulkanVideoDevice and QTGLVideoDevice, and close an existing view in VulkanDesktopVideoDevice::open(). - rebuildDesktopVideoDevices() takes the initiating document's session instead of the active document's. - Extract DesktopVideoDevice::tenBitDisplayRequested() and persist the display depth once in setDisplayOutput(). - Make the report-once flags atomic, guard a negative screen index, catch by const reference, fix narrowing and -Wparentheses, and initialise the new RvDocument members in declaration order. - Remove dead code (unused instance setup, duplicated kMaxStaleSeconds, VulkanBuildProbe.cpp), give findMemoryType internal linkage with an unsigned shift, and use include guards in the new RvCommon headers. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/CMakeLists.txt | 1 - src/lib/app/RvCommon/DesktopVideoDevice.cpp | 11 ++- src/lib/app/RvCommon/QTGLVideoDevice.cpp | 6 +- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 9 +- src/lib/app/RvCommon/RvApplication.cpp | 7 +- .../RvCommon/RvCommon/DesktopVideoDevice.h | 3 + .../app/RvCommon/RvCommon/QTGLVideoDevice.h | 3 + .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 8 +- src/lib/app/RvCommon/RvCommon/RvApplication.h | 3 +- src/lib/app/RvCommon/RvCommon/VulkanView.h | 5 +- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 8 +- src/lib/app/RvCommon/RvDocument.cpp | 96 ++++++------------- src/lib/app/RvCommon/VulkanBuildProbe.cpp | 22 ----- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 5 + src/lib/app/RvCommon/VulkanWindow.cpp | 83 ++++++---------- .../audio/QTAudioRenderer/QTAudioRenderer.cpp | 6 +- src/lib/graphics/TwkGLF/GLContextScope.cpp | 7 +- .../graphics/TwkGLF/TwkGLF/GLContextScope.h | 7 +- src/lib/ip/IPCore/ImageRenderer.cpp | 6 +- 19 files changed, 122 insertions(+), 174 deletions(-) delete mode 100644 src/lib/app/RvCommon/VulkanBuildProbe.cpp diff --git a/src/lib/app/RvCommon/CMakeLists.txt b/src/lib/app/RvCommon/CMakeLists.txt index f8c3652ff..36babe6a4 100644 --- a/src/lib/app/RvCommon/CMakeLists.txt +++ b/src/lib/app/RvCommon/CMakeLists.txt @@ -92,7 +92,6 @@ IF(RV_TARGET_LINUX LIST( APPEND _sources - VulkanBuildProbe.cpp VulkanView.cpp VulkanWindow.cpp QTVulkanVideoDevice.cpp diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 43234b20a..03687d4fa 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -1092,14 +1092,17 @@ namespace Rv } #endif + bool DesktopVideoDevice::tenBitDisplayRequested() + { + const Options& opts = Options::sharedOptions(); + return opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2; + } + bool DesktopVideoDevice::shouldUseVulkanPresentation() { #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) // Same rule as the main view in RvDocument. - const Options& opts = Options::sharedOptions(); - const bool want10bit = (opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2); - - return want10bit && VulkanView::supports10BitPresentation(); + return tenBitDisplayRequested() && VulkanView::supports10BitPresentation(); #else return false; #endif diff --git a/src/lib/app/RvCommon/QTGLVideoDevice.cpp b/src/lib/app/RvCommon/QTGLVideoDevice.cpp index 594a85fbd..da4be93db 100644 --- a/src/lib/app/RvCommon/QTGLVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTGLVideoDevice.cpp @@ -22,6 +22,7 @@ #include #include +#include #include namespace Rv @@ -153,10 +154,9 @@ namespace Rv else { // Callers assume a current context afterwards, so report the failure once. - static bool reported = false; - if (!reported) + static std::atomic reported{false}; + if (!reported.exchange(true)) { - reported = true; cerr << "ERROR: QTGLVideoDevice::makeCurrent: '" << name() << "' cannot make a context current (window=" << (!m_window ? "destroyed" : (m_window->handle() ? "alive" : "no surface")) << " widget=" << (m_view ? "alive" : "null") diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 945afa541..1914a0de1 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -213,7 +213,9 @@ namespace Rv QTVulkanVideoDevice::~QTVulkanVideoDevice() { - if (m_glContext && (m_fbo || m_fboColorTex || m_glMemoryObject[0] || m_cpuFlipFbo)) + // Every slot, unconditionally: the imports live in the global share + // group and would outlive m_glContext, pinning the Vulkan memory. + if (m_glContext && m_offscreenSurface) { m_glContext->makeCurrent(m_offscreenSurface); delete m_fbo; @@ -433,9 +435,10 @@ namespace Rv if (const DesktopVideoDevice* desktopDev = dynamic_cast(d)) { const QList screens = QGuiApplication::screens(); - if (desktopDev->qtScreen() < screens.size()) + const int screen = desktopDev->qtScreen(); + if (screen >= 0 && screen < screens.size()) { - m_devicePixelRatio = screens[desktopDev->qtScreen()]->devicePixelRatio(); + m_devicePixelRatio = screens[screen]->devicePixelRatio(); } } } diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 60112d3ae..89fc19931 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1956,7 +1956,7 @@ namespace Rv return options.toUtf8().constData(); } - void RvApplication::rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice, bool mainViewIsVulkan) + void RvApplication::rebuildDesktopVideoDevices(RvSession* session, QTGLVideoDevice* shareDevice, bool mainViewIsVulkan) { if (!m_desktopModule) { @@ -1967,9 +1967,6 @@ namespace Rv // since the rebuild may destroy the device it pointed to. const bool wasPresenting = m_presentationMode; - TwkApp::Document* doc = TwkApp::Document::activeDocument(); - Rv::Session* session = doc ? static_cast(doc) : nullptr; - // Returns false when the backend is unchanged; the share device is // still rebound below. const bool rebuilt = m_desktopModule->rebuildDevices(shareDevice, mainViewIsVulkan); @@ -2027,7 +2024,7 @@ namespace Rv } session->setOutputVideoDevice(d); } - catch (std::exception& exc) + catch (const std::exception& exc) { cerr << "ERROR: failed to re-open presentation device after rebuild: " << exc.what() << endl; session->setOutputVideoDevice(session->controlVideoDevice()); diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index 1aff26001..3dd9e6e9a 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -287,6 +287,9 @@ namespace Rv // static bool shouldUseVulkanPresentation(); + // True when the persisted display depth is RGB 10 + A 2. + static bool tenBitDisplayRequested(); + protected: void addDefaultDataFormats(size_t bits = 8); void sortVideoFormatsByWidth(); diff --git a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h index 1fbd31d2d..1b8ababd7 100644 --- a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h @@ -44,6 +44,9 @@ namespace Rv QTGLVideoDevice(TwkApp::VideoModule*, const std::string& name); virtual ~QTGLVideoDevice(); + QTGLVideoDevice(const QTGLVideoDevice&) = delete; + QTGLVideoDevice& operator=(const QTGLVideoDevice&) = delete; + void setWidget(QOpenGLWidget*); // diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 8c9f153b4..62b038ecf 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -3,7 +3,8 @@ // // SPDX-License-Identifier: Apache-2.0 // -#pragma once +#ifndef __RvCommon__QTVulkanVideoDevice__h__ +#define __RvCommon__QTVulkanVideoDevice__h__ #include #include @@ -40,6 +41,9 @@ namespace Rv QTVulkanVideoDevice(TwkApp::VideoModule* module, const std::string& name, VulkanWindow* window, QWidget* eventWidget); virtual ~QTVulkanVideoDevice(); + QTVulkanVideoDevice(const QTVulkanVideoDevice&) = delete; + QTVulkanVideoDevice& operator=(const QTVulkanVideoDevice&) = delete; + VulkanWindow* vulkanWindow() const { return m_window; } QWidget* eventWidget() const { return m_eventWidget; } @@ -156,3 +160,5 @@ namespace Rv }; } // namespace Rv + +#endif // __RvCommon__QTVulkanVideoDevice__h__ diff --git a/src/lib/app/RvCommon/RvCommon/RvApplication.h b/src/lib/app/RvCommon/RvCommon/RvApplication.h index 5904b6fa7..987530606 100644 --- a/src/lib/app/RvCommon/RvCommon/RvApplication.h +++ b/src/lib/app/RvCommon/RvCommon/RvApplication.h @@ -141,7 +141,8 @@ namespace Rv // view is Vulkan. mainViewIsVulkan comes from the caller because only // it knows which view widget exists now. // - void rebuildDesktopVideoDevices(QTGLVideoDevice* shareDevice, bool mainViewIsVulkan); + // session is the document whose main view changed backend. + void rebuildDesktopVideoDevices(RvSession* session, QTGLVideoDevice* shareDevice, bool mainViewIsVulkan); DesktopVideoModule* desktopVideoModule() const { return m_desktopModule; } diff --git a/src/lib/app/RvCommon/RvCommon/VulkanView.h b/src/lib/app/RvCommon/RvCommon/VulkanView.h index 983e3302f..bc362cd59 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanView.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanView.h @@ -3,7 +3,8 @@ // // SPDX-License-Identifier: Apache-2.0 // -#pragma once +#ifndef __RvCommon__VulkanView__h__ +#define __RvCommon__VulkanView__h__ #include #include @@ -89,3 +90,5 @@ namespace Rv }; } // namespace Rv + +#endif // __RvCommon__VulkanView__h__ diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index 2425fdf8f..a16948396 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -3,7 +3,8 @@ // // SPDX-License-Identifier: Apache-2.0 // -#pragma once +#ifndef __RvCommon__VulkanWindow__h__ +#define __RvCommon__VulkanWindow__h__ #include #include @@ -20,6 +21,7 @@ QT_BEGIN_NAMESPACE class QPlatformWindow; +class QWidget; QT_END_NAMESPACE namespace Rv @@ -46,7 +48,7 @@ namespace Rv typedef TwkUtil::Timer Timer; explicit VulkanWindow(RvDocument* doc, bool noResize = true); - ~VulkanWindow(); + ~VulkanWindow() override; QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } @@ -315,3 +317,5 @@ namespace Rv }; } // namespace Rv + +#endif // __RvCommon__VulkanWindow__h__ diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index b80eaa066..ce6ef8837 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -154,14 +154,14 @@ namespace Rv , m_closeEventReceived(false) , m_vsyncDisabled(false) , m_hdpiResizeWorkaroundDone(false) + , m_diagnosticsView(nullptr) + , m_diagnosticsDock(nullptr) , m_oldGLView(0) , m_glView(0) - , m_viewWidget(nullptr) #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) , m_vulkanView(nullptr) #endif - , m_diagnosticsView(nullptr) - , m_diagnosticsDock(nullptr) + , m_viewWidget(nullptr) , m_sourceEditor(0) , m_displayLink(0) , m_blockingOverlay(0) @@ -208,7 +208,7 @@ namespace Rv // pixel-format negotiation truncates to 8-bit; everything else stays // on GLView. // - const bool want10bit = (opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2); + const bool want10bit = DesktopVideoDevice::tenBitDisplayRequested(); if (ImageRenderer::debugGpu()) { @@ -906,8 +906,7 @@ namespace Rv } Rv::Options& opts = Options::sharedOptions(); - const bool requestedTenBit = - opts.dispRedBits == 10 && opts.dispGreenBits == 10 && opts.dispBlueBits == 10 && opts.dispAlphaBits == 2; + const bool requestedTenBit = DesktopVideoDevice::tenBitDisplayRequested(); if (requestedTenBit) { cout << "INFO: Vulkan 10-bit presentation failed at runtime; falling back to 8-bit OpenGL." << endl; @@ -979,7 +978,7 @@ namespace Rv m_glView->videoDevice()->sendEvent(TwkApp::RenderContextChangeEvent("gl-context-changed", m_glView->videoDevice())); } - RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice(), false); + RvApp()->rebuildDesktopVideoDevices(m_session, m_glView->videoDevice(), false); // Deferred: this can be reached from inside the VulkanView's present path. oldVulkanView->deleteLater(); @@ -1049,7 +1048,7 @@ namespace Rv } // No GL share device on Vulkan; Qt::AA_ShareOpenGLContexts still shares. - RvApp()->rebuildDesktopVideoDevices(nullptr, true); + RvApp()->rebuildDesktopVideoDevices(m_session, nullptr, true); m_vulkanView->videoDevice()->translator().setCurrentModifiers(cur); @@ -1153,7 +1152,7 @@ namespace Rv if (resetGLPrefs) resetGLStateAndPrefs(); - RvApp()->rebuildDesktopVideoDevices(m_glView->videoDevice(), false); + RvApp()->rebuildDesktopVideoDevices(m_session, m_glView->videoDevice(), false); m_glView->videoDevice()->translator().setCurrentModifiers(cur); m_oldGLView = oldGLView; @@ -1236,30 +1235,36 @@ namespace Rv void RvDocument::setDisplayOutput(DisplayOutputType type) { -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - // - // 10-bit goes through Vulkan: an OpenGL context rebuild at 10-bit - // fails on Mesa GLX and WGL. Persist the preference first so a later - // Vulkan -> GL fallback and the next launch both honour it. - // - if (type == OpenGL1010102) + // Persist the requested depth first, so an early return below, a later + // Vulkan -> GL fallback and the next launch all honour it. { + const int bits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 10 : 0); + const int alphaBits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 2 : 0); + Rv::Options& opts = Options::sharedOptions(); - opts.dispRedBits = 10; - opts.dispGreenBits = 10; - opts.dispBlueBits = 10; - opts.dispAlphaBits = 2; + opts.dispRedBits = bits; + opts.dispGreenBits = bits; + opts.dispBlueBits = bits; + opts.dispAlphaBits = alphaBits; { RV_QSETTINGS; settings.beginGroup("Display"); - settings.setValue("dispRedBits", 10); - settings.setValue("dispGreenBits", 10); - settings.setValue("dispBlueBits", 10); - settings.setValue("dispAlphaBits", 2); + settings.setValue("dispRedBits", bits); + settings.setValue("dispGreenBits", bits); + settings.setValue("dispBlueBits", bits); + settings.setValue("dispAlphaBits", alphaBits); settings.endGroup(); } + } +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + // + // 10-bit goes through Vulkan: an OpenGL context rebuild at 10-bit + // fails on Mesa GLX and WGL. + // + if (type == OpenGL1010102) + { // Already on Vulkan. if (!m_glView) { @@ -1289,54 +1294,13 @@ namespace Rv return; } - // Leaving 10-bit while Vulkan is live: persist and swap back to OpenGL. + // Leaving 10-bit while Vulkan is live: swap back to OpenGL. if (!m_glView) { - const int bits = (type == OpenGL8888) ? 8 : 0; - const int alpha = (type == OpenGL8888) ? 8 : 0; - - Rv::Options& opts = Options::sharedOptions(); - opts.dispRedBits = bits; - opts.dispGreenBits = bits; - opts.dispBlueBits = bits; - opts.dispAlphaBits = alpha; - - { - RV_QSETTINGS; - settings.beginGroup("Display"); - settings.setValue("dispRedBits", bits); - settings.setValue("dispGreenBits", bits); - settings.setValue("dispBlueBits", bits); - settings.setValue("dispAlphaBits", alpha); - settings.endGroup(); - } - fallbackVulkanToGLView(); return; } #endif - // Persist the requested depth before anything below can early-return. - { - const int bits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 10 : 0); - const int alphaBits = (type == OpenGL8888) ? 8 : (type == OpenGL1010102 ? 2 : 0); - - Rv::Options& opts = Options::sharedOptions(); - opts.dispRedBits = bits; - opts.dispGreenBits = bits; - opts.dispBlueBits = bits; - opts.dispAlphaBits = alphaBits; - - { - RV_QSETTINGS; - settings.beginGroup("Display"); - settings.setValue("dispRedBits", bits); - settings.setValue("dispGreenBits", bits); - settings.setValue("dispBlueBits", bits); - settings.setValue("dispAlphaBits", alphaBits); - settings.endGroup(); - } - } - const bool vsync = m_glView->format().swapInterval() == 1; const bool stereo = m_glView->format().stereo(); bool dbl = false; diff --git a/src/lib/app/RvCommon/VulkanBuildProbe.cpp b/src/lib/app/RvCommon/VulkanBuildProbe.cpp deleted file mode 100644 index 34fc63100..000000000 --- a/src/lib/app/RvCommon/VulkanBuildProbe.cpp +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright (C) 2026 Autodesk, Inc. All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -#include - -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) -#include -#endif - -namespace -{ - // Build-time probe only: keep one direct Vulkan symbol reference so opt-in - // Linux/Windows builds prove both headers and link-time loader availability. - void rvVulkanBuildProbeNoOp() - { - PFN_vkVoidFunction fn = vkGetInstanceProcAddr(VK_NULL_HANDLE, "vkCreateInstance"); - (void)fn; - } -} // namespace diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 738997d2d..4c8142f15 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -32,6 +32,11 @@ namespace Rv void VulkanDesktopVideoDevice::open(const StringVector& args) { + if (isOpen()) + { + close(); + } + // A null doc makes the view passive (see VulkanWindow.h). m_vulkanView = new VulkanView(/*doc*/ nullptr, /*parent*/ nullptr, /*noResize*/ true); diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index a0c1abac5..50ad7a0af 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -65,8 +65,11 @@ namespace Rv { using namespace std; - // -debug gpu frame-time accumulators. Only one VulkanWindow drives the - // frame loop, so file statics suffice. + // A best-effort output forces one blocking present once it is this stale. + static constexpr double kMaxStaleSeconds = 0.1; + + // -debug gpu frame-time accumulators, fed only by control viewports. They + // are shared: with several documents open the totals are combined. static unsigned int s_diagFrames = 0; static double s_diagRenderMs = 0.0; static double s_diagMainPresentMs = 0.0; @@ -397,24 +400,6 @@ namespace Rv bool VulkanWindow::initVulkan() { - VkApplicationInfo appInfo = {}; - appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; - appInfo.pApplicationName = "RV VulkanWindow"; - appInfo.apiVersion = VK_API_VERSION_1_1; - - std::vector instanceExtensions = { - VK_KHR_SURFACE_EXTENSION_NAME, -#if defined(VK_USE_PLATFORM_WIN32_KHR) - VK_KHR_WIN32_SURFACE_EXTENSION_NAME, -#elif defined(VK_USE_PLATFORM_XLIB_KHR) - VK_KHR_XLIB_SURFACE_EXTENSION_NAME, -#elif defined(VK_USE_PLATFORM_WAYLAND_KHR) - VK_KHR_WAYLAND_SURFACE_EXTENSION_NAME, -#elif defined(VK_USE_PLATFORM_XCB_KHR) - VK_KHR_XCB_SURFACE_EXTENSION_NAME, -#endif - }; - QVulkanInstance* qtVkInst = sharedVulkanInstance(); if (!qtVkInst) { @@ -516,7 +501,7 @@ namespace Rv createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; createInfo.pQueueCreateInfos = &queueCreateInfo; createInfo.queueCreateInfoCount = 1; - createInfo.enabledExtensionCount = deviceExtensions.size(); + createInfo.enabledExtensionCount = static_cast(deviceExtensions.size()); createInfo.ppEnabledExtensionNames = deviceExtensions.data(); if (vkCreateDevice(m_vkPhysicalDevice, &createInfo, nullptr, &m_vkDevice) != VK_SUCCESS) @@ -771,7 +756,6 @@ namespace Rv return VK_PRESENT_MODE_FIFO_KHR; } - (void)passiveOutput; return VK_PRESENT_MODE_FIFO_KHR; } @@ -1067,7 +1051,8 @@ namespace Rv if (!m_vkCommandBuffers.empty()) { - vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, m_vkCommandBuffers.size(), m_vkCommandBuffers.data()); + vkFreeCommandBuffers(m_vkDevice, m_vkCommandPool, static_cast(m_vkCommandBuffers.size()), + m_vkCommandBuffers.data()); m_vkCommandBuffers.clear(); } @@ -1079,13 +1064,13 @@ namespace Rv } } - uint32_t findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) + static uint32_t findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) { VkPhysicalDeviceMemoryProperties memProperties; vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties); for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) { - if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) + if ((typeFilter & (1u << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) { return i; } @@ -1228,21 +1213,14 @@ namespace Rv return depth; } - // Vendor heuristic: OPTIMAL on NVIDIA, LINEAR elsewhere. NVIDIA 550+ + // Vendor preference: OPTIMAL on NVIDIA, LINEAR elsewhere. NVIDIA 550+ // returns blank pixels to GL for LINEAR shared images >= ~2 MiB // (forum thread #349436); its GL and Vulkan share one driver, so the // optimal layout matches on import. Under Mesa, GL and Vulkan are - // different drivers and OPTIMAL renders tile garbage (RADV PHOENIX2); - // making it usable there needs VK_EXT_image_drm_format_modifier. - bool useOptimalTilingForInterop(VkPhysicalDevice dev) - { -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - return !nvidiaInteropWorkaroundDisabled() && isNvidiaPhysicalDevice(dev); -#else - (void)dev; - return false; -#endif - } + // different drivers and OPTIMAL renders tile garbage (RADV PHOENIX2) + // even when reported exportable; making it usable there needs + // VK_EXT_image_drm_format_modifier. + bool useOptimalTilingForInterop(VkPhysicalDevice dev) { return !nvidiaInteropWorkaroundDisabled() && isNvidiaPhysicalDevice(dev); } } // namespace void VulkanWindow::negotiateInteropConfig() @@ -1276,9 +1254,11 @@ namespace Rv return; } - // OPTIMAL first (avoids NVIDIA's blank large-LINEAR-image bug); - // LINEAR is the portable fallback. - const VkImageTiling candidates[] = {VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_TILING_LINEAR}; + // Exportability alone does not guarantee a usable import, so try the + // vendor's preferred tiling first. See useOptimalTilingForInterop(). + const bool preferOptimal = useOptimalTilingForInterop(m_vkPhysicalDevice); + const VkImageTiling candidates[] = {preferOptimal ? VK_IMAGE_TILING_OPTIMAL : VK_IMAGE_TILING_LINEAR, + preferOptimal ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL}; auto probeTiling = [&](VkImageTiling tiling, VkExternalMemoryFeatureFlags& features) -> bool { @@ -1494,10 +1474,6 @@ namespace Rv o << "INFO: Probe candidate: " << entry << "\n"; } - // TODO: remove once the probe is confirmed on Linux/NVIDIA. - o << "INFO: Legacy vendor heuristic would have chosen: " - << (m_vkPhysicalDevice != VK_NULL_HANDLE && useOptimalTilingForInterop(m_vkPhysicalDevice) ? "OPTIMAL" : "LINEAR") << "\n"; - if (envFlagSet("RV_VULKAN_FORCE_CPU_PRESENT")) { o << "INFO: Override : RV_VULKAN_FORCE_CPU_PRESENT is set\n"; @@ -1598,7 +1574,6 @@ namespace Rv // Forward progress: under sustained load, force one blocking present // rather than freezing the output. - static const double kMaxStaleSeconds = 0.1; if (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds) { return true; @@ -1942,6 +1917,8 @@ namespace Rv cleanupSharedImage(slot); return nullptr; } + // Stored at once so cleanupSharedImage() closes it on a later failure. + info.memoryHandle = memHandle; #else auto pfnGetMemoryFdKHR = reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryFdKHR")); if (!pfnGetMemoryFdKHR) @@ -1963,6 +1940,8 @@ namespace Rv cleanupSharedImage(slot); return nullptr; } + // Stored at once so cleanupSharedImage() closes it on a later failure. + info.memoryFd = memFd; #endif VkExportSemaphoreCreateInfo exportSemInfo = {}; @@ -2009,6 +1988,7 @@ namespace Rv cleanupSharedImage(slot); return nullptr; } + info.glReadySemaphoreHandle = glReadyHandle; getSemHandleInfo.semaphore = m_vkVkReadySemaphore[slot]; if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &vkReadyHandle) != VK_SUCCESS || !vkReadyHandle) @@ -2017,13 +1997,11 @@ namespace Rv cleanupSharedImage(slot); return nullptr; } + info.vkReadySemaphoreHandle = vkReadyHandle; - info.memoryHandle = memHandle; info.size = memReqs.size; info.width = w; info.height = h; - info.glReadySemaphoreHandle = glReadyHandle; - info.vkReadySemaphoreHandle = vkReadyHandle; #else auto pfnGetSemaphoreFdKHR = reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreFdKHR")); if (!pfnGetSemaphoreFdKHR) @@ -2047,6 +2025,7 @@ namespace Rv cleanupSharedImage(slot); return nullptr; } + info.glReadySemaphoreFd = glReadyFd; getSemFdInfo.semaphore = m_vkVkReadySemaphore[slot]; if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &vkReadyFd) != VK_SUCCESS || vkReadyFd < 0) @@ -2055,13 +2034,11 @@ namespace Rv cleanupSharedImage(slot); return nullptr; } + info.vkReadySemaphoreFd = vkReadyFd; - info.memoryFd = memFd; info.size = memReqs.size; info.width = w; info.height = h; - info.glReadySemaphoreFd = glReadyFd; - info.vkReadySemaphoreFd = vkReadyFd; #endif VkCommandBuffer cb = m_vkCommandBuffers[0]; @@ -2150,7 +2127,6 @@ namespace Rv // passive output polls instead, so a second display's vblank never // enters the loop; a skipped frame keeps the previous image. const bool bestEffort = isPassiveOutput(); - static const double kMaxStaleSeconds = 0.1; const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; @@ -2408,7 +2384,6 @@ namespace Rv // Same best-effort throttle as presentSharedImage(). const bool bestEffort = isPassiveOutput(); - static const double kMaxStaleSeconds = 0.1; const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; @@ -2991,7 +2966,7 @@ namespace Rv { keyevent = true; if (m_lastKey == kevent->key() - && (m_lastKeyType == QEvent::ShortcutOverride && (kevent->type() == QEvent::KeyPress) || (m_lastKeyType == kevent->type()))) + && ((m_lastKeyType == QEvent::ShortcutOverride && kevent->type() == QEvent::KeyPress) || (m_lastKeyType == kevent->type()))) { m_lastKey = kevent->key(); m_lastKeyType = kevent->type(); diff --git a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp index afaf4fed0..85da740e3 100644 --- a/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp +++ b/src/lib/audio/QTAudioRenderer/QTAudioRenderer.cpp @@ -5,6 +5,7 @@ // SPDX-License-Identifier: Apache-2.0 // // +#include #include #include @@ -450,11 +451,10 @@ namespace IPCore return true; } - static bool reported = false; + static std::atomic reported{false}; - if (!reported) + if (!reported.exchange(true)) { - reported = true; std::cerr << "WARNING: audio thread did not exit within " << audioThreadExitTimeoutMS << " ms; continuing shutdown without it" << std::endl; } diff --git a/src/lib/graphics/TwkGLF/GLContextScope.cpp b/src/lib/graphics/TwkGLF/GLContextScope.cpp index 9d9bacbb3..2dd370c48 100644 --- a/src/lib/graphics/TwkGLF/GLContextScope.cpp +++ b/src/lib/graphics/TwkGLF/GLContextScope.cpp @@ -14,6 +14,7 @@ #include #include +#include #include namespace TwkGLF @@ -33,11 +34,11 @@ namespace TwkGLF void reportNoContext(const char* why) { - static bool reported = false; + // Atomic: teardown can run off the GUI thread. + static std::atomic reported{false}; - if (!reported) + if (!reported.exchange(true)) { - reported = true; std::cerr << "WARNING: no GL context available for teardown (" << why << "); GL objects destroyed without one will leak in the driver" << std::endl; } diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h index 4114f5a8a..db5af5957 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h @@ -7,8 +7,6 @@ #ifndef __TwkGLF__GLContextScope__h__ #define __TwkGLF__GLContextScope__h__ -class QOpenGLContext; - namespace TwkGLF { class GLVideoDevice; @@ -30,6 +28,11 @@ namespace TwkGLF // context can be resolved, the scope reports once and does nothing; it // never throws. Check hasContext() if needed. // + // Limitation: an already-current context is kept even when it is not the + // supplied device's, since a natively bound context cannot be restored + // afterwards. That is correct for shared objects (textures, buffers, + // programs) but not for FBOs, which belong to the context that made them. + // class GLContextScope { public: diff --git a/src/lib/ip/IPCore/ImageRenderer.cpp b/src/lib/ip/IPCore/ImageRenderer.cpp index cb80720f2..de538aad9 100644 --- a/src/lib/ip/IPCore/ImageRenderer.cpp +++ b/src/lib/ip/IPCore/ImageRenderer.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -1342,10 +1343,9 @@ namespace IPCore } else { - static bool reported = false; - if (!reported) + static std::atomic reported{false}; + if (!reported.exchange(true)) { - reported = true; cerr << "ERROR: ImageRenderer::setOutputDevice: neither the output nor the control device is a GLVideoDevice; " "the GL objects released below have no current context" << endl; From d2e50daa58f3735d140dbd8df6ff580d987d3aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Mon, 28 Sep 2026 14:28:54 -0400 Subject: [PATCH 38/48] fix(windows): pass 0 for UrlCreateFromPath's reserved flags argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth parameter of UrlCreateFromPath is a DWORD dwFlags (reserved, must be 0), not a pointer. The NULL -> nullptr sweep in b7505568 turned the original NULL (which MSVC defines as 0) into nullptr, which does not convert to DWORD and fails the Windows build with C2664. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 03687d4fa..734010418 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -1070,7 +1070,7 @@ namespace Rv DWORD maxLen = 2084; std::vector url(maxLen); - if (SUCCEEDED(UrlCreateFromPath(path.data(), url.data(), &maxLen, nullptr))) + if (SUCCEEDED(UrlCreateFromPath(path.data(), url.data(), &maxLen, 0))) { m_colorProfile.url = url.data(); } From d10e525bde70f435a74b89b5e473b0864788a5b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Tue, 29 Sep 2026 08:35:29 -0400 Subject: [PATCH 39/48] refactor(vulkan): carry #1319 review feedback into VulkanWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VulkanWindow/VulkanView/RvDocument/VulkanDesktopVideoDevice: move members not set from ctor params to default member initializers. - VulkanView owns QTVulkanVideoDevice via std::unique_ptr, still reset explicitly at the same point in the destructor. - QTVulkanVideoDevice: GL context, offscreen surface, FBO and translator held by std::unique_ptr with the explicit release order kept; ~QTVulkanVideoDevice() override. - Group per-frame parallel arrays into FrameSync, StagingBuffer and SharedImage structs (VulkanWindow) and SharedGLObjects (QTVulkanVideoDevice). - File-static helpers into an anonymous namespace; findMemoryType returns std::optional; deviceProc<> helper for vkGetDeviceProcAddr; transitionImageLayout() helper for the image barriers. - std::numeric_limits instead of UINT32_MAX/UINT64_MAX; std::array for candidates, composite alpha preference and submit/present arrays; std::string_view name helpers; descriptive local names. - using instead of typedef for Timer; range-based for where the index is unused. - Remove unused RvDocument::vulkanView(), VulkanView::vulkanWindow(), VulkanView::isInitialized(), QTVulkanVideoDevice::vulkanWindow() and QTVulkanVideoDevice::eventWidget(). - Remove ImageRenderer::reportGL(), replaced by debugGpu(). Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoModule.cpp | 12 +- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 175 ++-- src/lib/app/RvCommon/RvApplication.cpp | 4 +- .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 38 +- src/lib/app/RvCommon/RvCommon/RvDocument.h | 6 +- .../RvCommon/VulkanDesktopVideoDevice.h | 2 +- src/lib/app/RvCommon/RvCommon/VulkanView.h | 22 +- src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 65 +- src/lib/app/RvCommon/RvDocument.cpp | 8 - .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 9 +- src/lib/app/RvCommon/VulkanView.cpp | 15 +- src/lib/app/RvCommon/VulkanWindow.cpp | 908 +++++++++--------- src/lib/ip/IPCore/IPCore/ImageRenderer.h | 5 - 13 files changed, 622 insertions(+), 647 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoModule.cpp b/src/lib/app/RvCommon/DesktopVideoModule.cpp index d428ec858..29171c4ac 100644 --- a/src/lib/app/RvCommon/DesktopVideoModule.cpp +++ b/src/lib/app/RvCommon/DesktopVideoModule.cpp @@ -50,9 +50,9 @@ namespace Rv bool currentVulkan = false; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - for (size_t i = 0; i < m_devices.size(); ++i) + for (TwkApp::VideoDevice* device : m_devices) { - if (dynamic_cast(m_devices[i])) + if (dynamic_cast(device)) { currentVulkan = true; break; @@ -66,13 +66,13 @@ namespace Rv } // close() releases the Vulkan swapchain or GL ScreenView before the delete. - for (size_t i = 0; i < m_devices.size(); ++i) + for (TwkApp::VideoDevice* device : m_devices) { - if (m_devices[i]->isOpen()) + if (device->isOpen()) { - m_devices[i]->close(); + device->close(); } - delete m_devices[i]; + delete device; } m_devices.clear(); diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 1914a0de1..997d3034e 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -206,7 +206,7 @@ namespace Rv : TwkGLF::GLVideoDevice(module, name, VideoDevice::ImageOutput | VideoDevice::ProvidesSync | VideoDevice::SubWindow) , m_window(window) , m_eventWidget(eventWidget) - , m_translator(eventWidget ? new QTTranslator(this, eventWidget) : nullptr) + , m_translator(eventWidget ? std::make_unique(this, eventWidget) : nullptr) { assert(window); } @@ -217,9 +217,8 @@ namespace Rv // group and would outlive m_glContext, pinning the Vulkan memory. if (m_glContext && m_offscreenSurface) { - m_glContext->makeCurrent(m_offscreenSurface); - delete m_fbo; - m_fbo = nullptr; + m_glContext->makeCurrent(m_offscreenSurface.get()); + m_fbo.reset(); if (m_fboColorTex) { glDeleteTextures(1, &m_fboColorTex); @@ -233,20 +232,21 @@ namespace Rv m_glContext->doneCurrent(); } - delete m_offscreenSurface; - m_offscreenSurface = nullptr; + m_offscreenSurface.reset(); - delete m_glContext; - m_glContext = nullptr; + m_glContext.reset(); - delete m_translator; + m_translator.reset(); } void QTVulkanVideoDevice::setEventWidget(QWidget* widget) { m_eventWidget = widget; - delete m_translator; - m_translator = widget ? new QTTranslator(this, widget) : nullptr; + m_translator.reset(); + if (widget) + { + m_translator = std::make_unique(this, widget); + } } //-------------------------------------------------------------------------- @@ -260,7 +260,7 @@ namespace Rv fmt.setMajorVersion(2); fmt.setMinorVersion(1); - m_glContext = new QOpenGLContext(); + m_glContext = std::make_unique(); m_glContext->setFormat(fmt); // Join the global share group, or FTGL font-atlas textures created @@ -270,26 +270,23 @@ namespace Rv if (!m_glContext->create()) { cerr << "ERROR: QTVulkanVideoDevice: QOpenGLContext::create() failed" << endl; - delete m_glContext; - m_glContext = nullptr; + m_glContext.reset(); return; } - m_offscreenSurface = new QOffscreenSurface(); + m_offscreenSurface = std::make_unique(); m_offscreenSurface->setFormat(m_glContext->format()); m_offscreenSurface->create(); if (!m_offscreenSurface->isValid()) { cerr << "ERROR: QTVulkanVideoDevice: QOffscreenSurface::create() failed" << endl; - delete m_offscreenSurface; - m_offscreenSurface = nullptr; - delete m_glContext; - m_glContext = nullptr; + m_offscreenSurface.reset(); + m_glContext.reset(); return; } - m_glContext->makeCurrent(m_offscreenSurface); + m_glContext->makeCurrent(m_offscreenSurface.get()); glewExperimental = GL_TRUE; #ifdef PLATFORM_WINDOWS // The bundled Windows GLEW's glewInit takes a loader; nullptr @@ -302,15 +299,13 @@ namespace Rv { cerr << "ERROR: QTVulkanVideoDevice: glewInit failed: " << glewGetErrorString(err) << endl; m_glContext->doneCurrent(); - delete m_offscreenSurface; - m_offscreenSurface = nullptr; - delete m_glContext; - m_glContext = nullptr; + m_offscreenSurface.reset(); + m_glContext.reset(); return; } } - if (!m_glContext->makeCurrent(m_offscreenSurface)) + if (!m_glContext->makeCurrent(m_offscreenSurface.get())) { cerr << "ERROR: QTVulkanVideoDevice: makeCurrent() failed" << endl; return; @@ -330,8 +325,7 @@ namespace Rv if (!m_fbo || m_fboWidth != newW || m_fboHeight != newH) { - delete m_fbo; - m_fbo = nullptr; + m_fbo.reset(); if (m_fboColorTex) { glDeleteTextures(1, &m_fboColorTex); @@ -356,7 +350,7 @@ namespace Rv } glBindTexture(GL_TEXTURE_RECTANGLE_ARB, 0); - m_fbo = new TwkGLF::GLFBO(newW, newH, fboFormat); + m_fbo = std::make_unique(newW, newH, fboFormat); m_fbo->attachColorTexture(GL_TEXTURE_RECTANGLE_ARB, m_fboColorTex); GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT); @@ -389,12 +383,12 @@ namespace Rv if (const TwkApp::VideoModule* mod = TwkApp::App()->primaryVideoModule()) { - if (TwkApp::VideoDevice* d = mod->deviceFromPosition(tx, ty)) + if (TwkApp::VideoDevice* device = mod->deviceFromPosition(tx, ty)) { - setPhysicalDevice(d); - refresh = d->timing().hz; + setPhysicalDevice(device); + refresh = device->timing().hz; - VideoDeviceContextChangeEvent event("video-device-changed", this, this, d); + VideoDeviceContextChangeEvent event("video-device-changed", this, this, device); sendEvent(event); } } @@ -420,9 +414,9 @@ namespace Rv m_y = y; } - void QTVulkanVideoDevice::setPhysicalDevice(VideoDevice* d) + void QTVulkanVideoDevice::setPhysicalDevice(VideoDevice* device) { - TwkApp::VideoDevice::setPhysicalDevice(d); + TwkApp::VideoDevice::setPhysicalDevice(device); m_devicePixelRatio = 1.0f; @@ -432,7 +426,7 @@ namespace Rv return; } - if (const DesktopVideoDevice* desktopDev = dynamic_cast(d)) + if (const DesktopVideoDevice* desktopDev = dynamic_cast(device)) { const QList screens = QGuiApplication::screens(); const int screen = desktopDev->qtScreen(); @@ -461,13 +455,13 @@ namespace Rv TwkGLF::GLFBO* QTVulkanVideoDevice::defaultFBO() { ensureGLContext(); - return m_fbo; + return m_fbo.get(); } const TwkGLF::GLFBO* QTVulkanVideoDevice::defaultFBO() const { ensureGLContext(); - return m_fbo; + return m_fbo.get(); } std::string QTVulkanVideoDevice::hardwareIdentification() const { return "vulkan-hybrid"; } @@ -476,33 +470,33 @@ namespace Rv void QTVulkanVideoDevice::cleanupSharedGLObjects(uint32_t slot) const { - if (m_drawFbo[slot]) + if (m_glShared[slot].drawFbo) { - glDeleteFramebuffersEXT(1, &m_drawFbo[slot]); - m_drawFbo[slot] = 0; + glDeleteFramebuffersEXT(1, &m_glShared[slot].drawFbo); + m_glShared[slot].drawFbo = 0; } - if (m_glSharedTexture[slot]) + if (m_glShared[slot].texture) { - glDeleteTextures(1, &m_glSharedTexture[slot]); - m_glSharedTexture[slot] = 0; + glDeleteTextures(1, &m_glShared[slot].texture); + m_glShared[slot].texture = 0; } - if (m_glMemoryObject[slot]) + if (m_glShared[slot].memoryObject) { - glDeleteMemoryObjectsEXT(1, &m_glMemoryObject[slot]); - m_glMemoryObject[slot] = 0; + glDeleteMemoryObjectsEXT(1, &m_glShared[slot].memoryObject); + m_glShared[slot].memoryObject = 0; } - if (m_glReadySemaphore[slot]) + if (m_glShared[slot].glReadySemaphore) { - glDeleteSemaphoresEXT(1, &m_glReadySemaphore[slot]); - m_glReadySemaphore[slot] = 0; + glDeleteSemaphoresEXT(1, &m_glShared[slot].glReadySemaphore); + m_glShared[slot].glReadySemaphore = 0; } - if (m_vkReadySemaphore[slot]) + if (m_glShared[slot].vkReadySemaphore) { - glDeleteSemaphoresEXT(1, &m_vkReadySemaphore[slot]); - m_vkReadySemaphore[slot] = 0; + glDeleteSemaphoresEXT(1, &m_glShared[slot].vkReadySemaphore); + m_glShared[slot].vkReadySemaphore = 0; } - m_sharedWidth[slot] = 0; - m_sharedHeight[slot] = 0; + m_glShared[slot].width = 0; + m_glShared[slot].height = 0; } void QTVulkanVideoDevice::releaseSharedGLObjects() @@ -513,7 +507,7 @@ namespace Rv return; } - m_glContext->makeCurrent(m_offscreenSurface); + m_glContext->makeCurrent(m_offscreenSurface.get()); for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) { cleanupSharedGLObjects(i); @@ -625,7 +619,7 @@ namespace Rv void QTVulkanVideoDevice::presentCpuFallback(int w, int h) const { - TwkGLF::GLFBO* fbo = m_fbo; + TwkGLF::GLFBO* fbo = m_fbo.get(); // With GL_UNSIGNED_INT_2_10_10_10_REV, GL_RGBA packs A2B10G10R10 and // GL_BGRA packs A2R10G10B10, so the read format matches the swapchain. @@ -667,7 +661,7 @@ namespace Rv // frame end, so it is stable for the whole call. const uint32_t slot = m_window->currentFrame(); - TwkGLF::GLFBO* fbo = m_fbo; + TwkGLF::GLFBO* fbo = m_fbo.get(); const int w = static_cast(fbo->width()); const int h = static_cast(fbo->height()); @@ -689,7 +683,7 @@ namespace Rv return; } - if (!m_glContext->makeCurrent(m_offscreenSurface)) + if (!m_glContext->makeCurrent(m_offscreenSurface.get())) { return; } @@ -741,8 +735,8 @@ namespace Rv } else { - const VulkanWindow::InteropConfig& c = m_window->interopConfig(); - reason = c.rejectReason.empty() ? "the Vulkan side declined to allocate a shared image" : c.rejectReason; + const VulkanWindow::InteropConfig& config = m_window->interopConfig(); + reason = config.rejectReason.empty() ? "the Vulkan side declined to allocate a shared image" : config.rejectReason; } m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, reason); @@ -750,9 +744,10 @@ namespace Rv return; } - // Re-import only when the shared image's capacity changed; m_sharedWidth - // and m_sharedHeight cache the imported capacity, not the used size. - if (m_sharedWidth[slot] != sharedInfo->strideWidth || m_sharedHeight[slot] != sharedInfo->capacityHeight || !m_glMemoryObject[slot]) + // Re-import only when the shared image's capacity changed; the slot's + // width and height cache the imported capacity, not the used size. + if (m_glShared[slot].width != sharedInfo->strideWidth || m_glShared[slot].height != sharedInfo->capacityHeight + || !m_glShared[slot].memoryObject) { cleanupSharedGLObjects(slot); @@ -762,18 +757,18 @@ namespace Rv { } - glCreateMemoryObjectsEXT(1, &m_glMemoryObject[slot]); + glCreateMemoryObjectsEXT(1, &m_glShared[slot].memoryObject); // Both sides must agree on dedicated allocation before the import; // a mismatch corrupts the image silently, so always set it. { const GLint dedicated = sharedInfo->dedicatedAllocation ? GL_TRUE : GL_FALSE; - glMemoryObjectParameterivEXT(m_glMemoryObject[slot], GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); + glMemoryObjectParameterivEXT(m_glShared[slot].memoryObject, GL_DEDICATED_MEMORY_OBJECT_EXT, &dedicated); } #ifdef PLATFORM_WINDOWS // The Win32 import does not take ownership of the HANDLE; // VulkanWindow::cleanupSharedImage() closes it. - glImportMemoryWin32HandleEXT(m_glMemoryObject[slot], sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + glImportMemoryWin32HandleEXT(m_glShared[slot].memoryObject, sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->memoryHandle)); #else // Duplicate the FD because glImportMemoryFdEXT takes ownership @@ -784,11 +779,11 @@ namespace Rv cleanupSharedGLObjects(slot); return; } - glImportMemoryFdEXT(m_glMemoryObject[slot], sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_FD_EXT, memFd); + glImportMemoryFdEXT(m_glShared[slot].memoryObject, sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_FD_EXT, memFd); #endif - glGenTextures(1, &m_glSharedTexture[slot]); - glBindTexture(GL_TEXTURE_2D, m_glSharedTexture[slot]); + glGenTextures(1, &m_glShared[slot].texture); + glBindTexture(GL_TEXTURE_2D, m_glShared[slot].texture); // Must match the Vulkan image's tiling, or pixels scramble within // each tile. @@ -796,16 +791,16 @@ namespace Rv sharedInfo->tiling == VK_IMAGE_TILING_OPTIMAL ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); // Allocated at capacity; the blit below writes only the used w x h. - glTexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGB10_A2, sharedInfo->strideWidth, sharedInfo->capacityHeight, m_glMemoryObject[slot], - 0); + glTexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGB10_A2, sharedInfo->strideWidth, sharedInfo->capacityHeight, + m_glShared[slot].memoryObject, 0); glBindTexture(GL_TEXTURE_2D, 0); - glGenSemaphoresEXT(1, &m_glReadySemaphore[slot]); - glGenSemaphoresEXT(1, &m_vkReadySemaphore[slot]); + glGenSemaphoresEXT(1, &m_glShared[slot].glReadySemaphore); + glGenSemaphoresEXT(1, &m_glShared[slot].vkReadySemaphore); #ifdef PLATFORM_WINDOWS - glImportSemaphoreWin32HandleEXT(m_glReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + glImportSemaphoreWin32HandleEXT(m_glShared[slot].glReadySemaphore, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->glReadySemaphoreHandle)); - glImportSemaphoreWin32HandleEXT(m_vkReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, + glImportSemaphoreWin32HandleEXT(m_glShared[slot].vkReadySemaphore, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->vkReadySemaphoreHandle)); #else int glReadyFd = dup(sharedInfo->glReadySemaphoreFd); @@ -815,7 +810,7 @@ namespace Rv cleanupSharedGLObjects(slot); return; } - glImportSemaphoreFdEXT(m_glReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_FD_EXT, glReadyFd); + glImportSemaphoreFdEXT(m_glShared[slot].glReadySemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, glReadyFd); int vkReadyFd = dup(sharedInfo->vkReadySemaphoreFd); if (vkReadyFd == -1) @@ -824,24 +819,24 @@ namespace Rv cleanupSharedGLObjects(slot); return; } - glImportSemaphoreFdEXT(m_vkReadySemaphore[slot], GL_HANDLE_TYPE_OPAQUE_FD_EXT, vkReadyFd); + glImportSemaphoreFdEXT(m_glShared[slot].vkReadySemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, vkReadyFd); #endif // The import calls above report failure only through glGetError(). if (interopGLFailed("GL<->Vulkan shared image import")) { // Interop is now off for good, so release every slot. - for (uint32_t s = 0; s < VulkanWindow::kFramesInFlight; ++s) + for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) { - cleanupSharedGLObjects(s); + cleanupSharedGLObjects(i); } m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "GL import of the shared image raised a GL error"); presentCpuFallback(w, h); return; } - m_sharedWidth[slot] = sharedInfo->strideWidth; - m_sharedHeight[slot] = sharedInfo->capacityHeight; + m_glShared[slot].width = sharedInfo->strideWidth; + m_glShared[slot].height = sharedInfo->capacityHeight; m_window->reportGLImportState(sharedInfo->tiling, sharedInfo->dedicatedAllocation); } @@ -855,18 +850,18 @@ namespace Rv } GLuint waitSrcLayouts[] = {GL_LAYOUT_TRANSFER_SRC_EXT}; - glWaitSemaphoreEXT(m_vkReadySemaphore[slot], 0, nullptr, 1, &m_glSharedTexture[slot], waitSrcLayouts); + glWaitSemaphoreEXT(m_glShared[slot].vkReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, waitSrcLayouts); GLuint readFbo = fbo->fboID(); - if (!m_drawFbo[slot]) + if (!m_glShared[slot].drawFbo) { - glGenFramebuffersEXT(1, &m_drawFbo[slot]); - glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_drawFbo[slot]); - glFramebufferTexture2DEXT(GL_DRAW_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, m_glSharedTexture[slot], 0); + glGenFramebuffersEXT(1, &m_glShared[slot].drawFbo); + glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_glShared[slot].drawFbo); + glFramebufferTexture2DEXT(GL_DRAW_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, m_glShared[slot].texture, 0); } else { - glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_drawFbo[slot]); + glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_glShared[slot].drawFbo); } glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, readFbo); @@ -877,15 +872,15 @@ namespace Rv glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, readFbo); // restore GLuint signalDstLayouts[] = {GL_LAYOUT_COLOR_ATTACHMENT_EXT}; - glSignalSemaphoreEXT(m_glReadySemaphore[slot], 0, nullptr, 1, &m_glSharedTexture[slot], signalDstLayouts); + glSignalSemaphoreEXT(m_glShared[slot].glReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, signalDstLayouts); glFlush(); if (interopGLFailed("GL<->Vulkan shared image blit")) { - for (uint32_t s = 0; s < VulkanWindow::kFramesInFlight; ++s) + for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) { - cleanupSharedGLObjects(s); + cleanupSharedGLObjects(i); } presentCpuFallback(w, h); return; diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 89fc19931..8682d028a 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1972,9 +1972,9 @@ namespace Rv const bool rebuilt = m_desktopModule->rebuildDevices(shareDevice, mainViewIsVulkan); const VideoModule::VideoDevices& devices = m_desktopModule->devices(); - for (size_t i = 0; i < devices.size(); i++) + for (VideoDevice* device : devices) { - if (DesktopVideoDevice* dd = dynamic_cast(devices[i])) + if (DesktopVideoDevice* dd = dynamic_cast(device)) { dd->setShareDevice(shareDevice); } diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 62b038ecf..1c923c636 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -39,15 +40,11 @@ namespace Rv // eventWidget is the window's container QWidget, used by QTTranslator // for coordinate mapping and mouse grab. QTVulkanVideoDevice(TwkApp::VideoModule* module, const std::string& name, VulkanWindow* window, QWidget* eventWidget); - virtual ~QTVulkanVideoDevice(); + ~QTVulkanVideoDevice() override; QTVulkanVideoDevice(const QTVulkanVideoDevice&) = delete; QTVulkanVideoDevice& operator=(const QTVulkanVideoDevice&) = delete; - VulkanWindow* vulkanWindow() const { return m_window; } - - QWidget* eventWidget() const { return m_eventWidget; } - void setEventWidget(QWidget* widget); void resetInteropDeviceMatch() const { m_glVulkanDeviceMatch = -1; } @@ -83,7 +80,7 @@ namespace Rv float devicePixelRatio() const override; - void setPhysicalDevice(VideoDevice* d) override; + void setPhysicalDevice(VideoDevice* device) override; // GLVideoDevice API TwkGLF::GLFBO* defaultFBO() override; @@ -104,28 +101,35 @@ namespace Rv // independently of this device. QPointer m_window; QWidget* m_eventWidget; - QTTranslator* m_translator; + std::unique_ptr m_translator; float m_devicePixelRatio{1.0f}; int m_x{0}; int m_y{0}; float m_refresh{-1.0f}; bool m_isOpen{false}; - mutable QOpenGLContext* m_glContext{nullptr}; - mutable QOffscreenSurface* m_offscreenSurface{nullptr}; - mutable TwkGLF::GLFBO* m_fbo{nullptr}; + mutable std::unique_ptr m_glContext; + mutable std::unique_ptr m_offscreenSurface; + mutable std::unique_ptr m_fbo; mutable GLuint m_fboColorTex{0}; // Texture attached to m_fbo; GLFBO does not own it mutable int m_fboWidth{0}; mutable int m_fboHeight{0}; // Interop GL objects, indexed by VulkanWindow::currentFrame(). - mutable std::array m_glMemoryObject{}; - mutable std::array m_glSharedTexture{}; - mutable std::array m_glReadySemaphore{}; - mutable std::array m_vkReadySemaphore{}; - mutable std::array m_drawFbo{}; - mutable std::array m_sharedWidth{}; - mutable std::array m_sharedHeight{}; + struct SharedGLObjects + { + GLuint memoryObject{0}; + GLuint texture{0}; + GLuint glReadySemaphore{0}; + GLuint vkReadySemaphore{0}; + GLuint drawFbo{0}; + + // Imported capacity, not the used size. + int width{0}; + int height{0}; + }; + + mutable std::array m_glShared{}; // Last reported present path: -1 none yet, 0 CPU-fallback, 1 GPU-interop. mutable int m_loggedPresentPath{-1}; diff --git a/src/lib/app/RvCommon/RvCommon/RvDocument.h b/src/lib/app/RvCommon/RvCommon/RvDocument.h index 7ac2fd527..271aa3e4f 100644 --- a/src/lib/app/RvCommon/RvCommon/RvDocument.h +++ b/src/lib/app/RvCommon/RvCommon/RvDocument.h @@ -89,8 +89,6 @@ namespace Rv TwkGLF::GLVideoDevice* viewVideoDevice() const; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - VulkanView* vulkanView() const; - // True once close has been accepted or the document is being destroyed. bool isClosing() const { return m_currentlyClosing || m_closeEventReceived; } @@ -208,9 +206,9 @@ namespace Rv GLView* m_glView; GLView* m_oldGLView; #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - VulkanView* m_vulkanView; + VulkanView* m_vulkanView{nullptr}; #endif - QWidget* m_viewWidget; + QWidget* m_viewWidget{nullptr}; QWidget* m_viewContainerWidget; RvTopViewToolBar* m_topViewToolBar; RvBottomViewToolBar* m_bottomViewToolBar; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h index 10b6dd4f4..e29effc3e 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h @@ -39,7 +39,7 @@ namespace Rv private: // Owns the QTVulkanVideoDevice used as the base m_viewDevice. - VulkanView* m_vulkanView; + VulkanView* m_vulkanView{nullptr}; }; } // namespace Rv diff --git a/src/lib/app/RvCommon/RvCommon/VulkanView.h b/src/lib/app/RvCommon/RvCommon/VulkanView.h index bc362cd59..eb8f65078 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanView.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanView.h @@ -9,6 +9,8 @@ #include #include +#include + QT_BEGIN_NAMESPACE class QWindow; QT_END_NAMESPACE @@ -35,16 +37,12 @@ namespace Rv VulkanView(RvDocument* doc, QWidget* parent = nullptr, bool noResize = true); ~VulkanView() override; - VulkanWindow* vulkanWindow() const { return m_vulkanWindow; } - - QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } + QTVulkanVideoDevice* videoDevice() const { return m_videoDevice.get(); } void stopProcessingEvents(); bool firstPaintCompleted() const; - bool isInitialized() const; - void absolutePosition(int& x, int& y) const; float devicePixelRatio() const; @@ -77,16 +75,16 @@ namespace Rv private: RvDocument* m_doc; - VulkanWindow* m_vulkanWindow; - QWidget* m_container; - QTVulkanVideoDevice* m_videoDevice; - QSize m_csize; - QSize m_msize; + VulkanWindow* m_vulkanWindow{nullptr}; + QWidget* m_container{nullptr}; + std::unique_ptr m_videoDevice; + QSize m_csize{1024, 576}; + QSize m_msize{128, 128}; // See watchParentWindow(). - QWindow* m_watchedParentWindow; + QWindow* m_watchedParentWindow{nullptr}; QMetaObject::Connection m_watchedParentConnection; - bool m_reattachPending; + bool m_reattachPending{false}; }; } // namespace Rv diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index a16948396..6b3e83a86 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -45,7 +45,7 @@ namespace Rv Q_OBJECT public: - typedef TwkUtil::Timer Timer; + using Timer = TwkUtil::Timer; explicit VulkanWindow(RvDocument* doc, bool noResize = true); ~VulkanWindow() override; @@ -53,7 +53,7 @@ namespace Rv QTVulkanVideoDevice* videoDevice() const { return m_videoDevice; } // Owned by the hosting VulkanView, not by this window. - void setVideoDevice(QTVulkanVideoDevice* d) { m_videoDevice = d; } + void setVideoDevice(QTVulkanVideoDevice* device) { m_videoDevice = device; } // The container QWidget; used for focus and the popup check in render(). void setEventWidget(QWidget* widget) { m_eventWidget = widget; } @@ -202,23 +202,23 @@ namespace Rv void emitPresentationRecord(); RvDocument* m_doc; - QTVulkanVideoDevice* m_videoDevice; + QTVulkanVideoDevice* m_videoDevice{nullptr}; - bool m_initialized; + bool m_initialized{false}; // Platform window the VkSurfaceKHR was created against; see // handleSurfaceLost(). const QPlatformWindow* m_initializedHandle{nullptr}; - bool m_firstPaintCompleted; + bool m_firstPaintCompleted{false}; bool m_postFirstNonEmptyRender; - bool m_stopProcessingEvents; - bool m_userActive; + bool m_stopProcessingEvents{false}; + bool m_userActive{true}; - QWidget* m_eventWidget; + QWidget* m_eventWidget{nullptr}; - unsigned int m_lastKey; - QEvent::Type m_lastKeyType; + unsigned int m_lastKey{0}; + QEvent::Type m_lastKeyType{QEvent::None}; Timer m_activityTimer; Timer m_activationTimer; // Forward-progress guard for canPresentNow(). @@ -246,8 +246,13 @@ namespace Rv std::vector m_vkCommandBuffers; // Per-in-flight-slot ring (indexed by m_currentFrame). - std::array m_vkImageAvailableSemaphore{}; - std::array m_vkFence{}; + struct FrameSync + { + VkSemaphore imageAvailable{VK_NULL_HANDLE}; + VkFence fence{VK_NULL_HANDLE}; + }; + + std::array m_frameSync{}; uint32_t m_currentFrame{0}; // Per swapchain image. The present-wait semaphore must be per image, @@ -258,21 +263,31 @@ namespace Rv // CPU-fallback staging buffer, per slot so a frame never overwrites a // buffer an in-flight copy still reads; the slot fence gates reuse. - std::array m_vkStagingBuffer{}; - std::array m_vkStagingBufferMemory{}; - std::array m_stagingBufferSize{}; + struct StagingBuffer + { + VkBuffer buffer{VK_NULL_HANDLE}; + VkDeviceMemory memory{VK_NULL_HANDLE}; + size_t size{0}; + }; + + std::array m_staging{}; // Shared interop image, per slot. - std::array m_vkSharedImage{}; - std::array m_vkSharedImageMemory{}; - std::array m_vkGlReadySemaphore{}; - std::array m_vkVkReadySemaphore{}; - std::array m_sharedImageInfo{}; - - // Grow-only capacity: a resize within it reuses the export and the GL - // import. - std::array m_sharedCapacityW{}; - std::array m_sharedCapacityH{}; + struct SharedImage + { + VkImage image{VK_NULL_HANDLE}; + VkDeviceMemory memory{VK_NULL_HANDLE}; + VkSemaphore glReadySemaphore{VK_NULL_HANDLE}; + VkSemaphore vkReadySemaphore{VK_NULL_HANDLE}; + SharedImageInfo info{}; + + // Grow-only capacity: a resize within it reuses the export and the + // GL import. + int capacityW{0}; + int capacityH{0}; + }; + + std::array m_shared{}; void cleanupSharedImage(uint32_t slot); diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index ce6ef8837..37f921404 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -158,10 +158,6 @@ namespace Rv , m_diagnosticsDock(nullptr) , m_oldGLView(0) , m_glView(0) -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - , m_vulkanView(nullptr) -#endif - , m_viewWidget(nullptr) , m_sourceEditor(0) , m_displayLink(0) , m_blockingOverlay(0) @@ -1399,10 +1395,6 @@ namespace Rv return m_glView ? m_glView->videoDevice() : nullptr; } -#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) - VulkanView* RvDocument::vulkanView() const { return m_vulkanView; } -#endif - void RvDocument::center() { QScreen* screen = QApplication::screenAt(mapToGlobal(QPoint(0, 0))); diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 4c8142f15..8746f2308 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -17,7 +17,6 @@ namespace Rv VulkanDesktopVideoDevice::VulkanDesktopVideoDevice(TwkApp::VideoModule* module, const std::string& name, int screen, const QTGLVideoDevice* shareDevice) : DesktopVideoDevice(module, name, screen, shareDevice) - , m_vulkanView(nullptr) { // // Re-advertise at 10-bit (the base advertised RGB8); only built once @@ -52,13 +51,13 @@ namespace Rv m_translator = new QTTranslator(this, m_vulkanView); // Place before show(): the swapchain is built on first expose. - const QRect g = screenGeometry(); - m_vulkanView->move(g.x(), g.y()); - m_vulkanView->setGeometry(g); + const QRect screenRect = screenGeometry(); + m_vulkanView->move(screenRect.x(), screenRect.y()); + m_vulkanView->setGeometry(screenRect); m_vulkanView->setWindowState(useFullScreen() ? Qt::WindowFullScreen : Qt::WindowNoState); - m_vulkanView->setGeometry(g); + m_vulkanView->setGeometry(screenRect); m_vulkanView->show(); diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index 213ba4c3a..e64c8644a 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -26,13 +26,6 @@ namespace Rv VulkanView::VulkanView(RvDocument* doc, QWidget* parent, bool noResize) : QWidget(parent) , m_doc(doc) - , m_vulkanWindow(nullptr) - , m_container(nullptr) - , m_videoDevice(nullptr) - , m_csize(1024, 576) - , m_msize(128, 128) - , m_watchedParentWindow(nullptr) - , m_reattachPending(false) { m_vulkanWindow = new VulkanWindow(doc, noResize); m_container = QWidget::createWindowContainer(m_vulkanWindow, this); @@ -66,8 +59,8 @@ namespace Rv } // No event widget, so no QTTranslator: VulkanWindow::event() then // ignores all input for a passive output. - m_videoDevice = new QTVulkanVideoDevice(nullptr, str.str(), m_vulkanWindow, passiveOutput ? nullptr : m_container); - m_vulkanWindow->setVideoDevice(m_videoDevice); + m_videoDevice = std::make_unique(nullptr, str.str(), m_vulkanWindow, passiveOutput ? nullptr : m_container); + m_vulkanWindow->setVideoDevice(m_videoDevice.get()); m_vulkanWindow->setEventWidget(passiveOutput ? nullptr : m_container); setObjectName((m_doc && m_doc->session()) ? m_doc->session()->name().c_str() : "no session"); @@ -116,7 +109,7 @@ namespace Rv m_container = nullptr; } - delete m_videoDevice; + m_videoDevice.reset(); } void VulkanView::showEvent(QShowEvent* event) @@ -276,8 +269,6 @@ namespace Rv bool VulkanView::firstPaintCompleted() const { return m_vulkanWindow && m_vulkanWindow->firstPaintCompleted(); } - bool VulkanView::isInitialized() const { return m_vulkanWindow && m_vulkanWindow->isInitialized(); } - void VulkanView::absolutePosition(int& x, int& y) const { if (m_vulkanWindow) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 50ad7a0af..502ee78fe 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -32,13 +32,16 @@ #include #include +#include #include -#include #include #include #include +#include +#include #include #include +#include #ifdef PLATFORM_WINDOWS // Keep from pulling in , which collides with Qt's // . @@ -94,14 +97,17 @@ namespace Rv // by several frames. static TwkUtil::Timer s_diagClock; - static double diagNow() + namespace { - if (!s_diagClock.isRunning()) + double diagNow() { - s_diagClock.start(); + if (!s_diagClock.isRunning()) + { + s_diagClock.start(); + } + return s_diagClock.elapsed(); } - return s_diagClock.elapsed(); - } + } // namespace // Event time for the frame being rendered (-1 if none), handed to its // slot at submit. @@ -114,87 +120,94 @@ namespace Rv using namespace TwkApp; using namespace IPCore; - // R/B order is handled where pixels are packed or blitted. Many RADV - // surfaces only advertise A2R10G10B10. - static bool isTenBitFormat(VkFormat f) { return f == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || f == VK_FORMAT_A2R10G10B10_UNORM_PACK32; } - - static bool findGraphicsPresentQueue(VkPhysicalDevice device, VkSurfaceKHR surface, uint32_t& familyIndex) + namespace { - uint32_t familyCount = 0; - vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, nullptr); - std::vector families(familyCount); - vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, families.data()); - - for (uint32_t i = 0; i < familyCount; ++i) + // R/B order is handled where pixels are packed or blitted. Many RADV + // surfaces only advertise A2R10G10B10. + bool isTenBitFormat(VkFormat format) { - VkBool32 presentSupport = VK_FALSE; - if (vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport) == VK_SUCCESS - && (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) - { - familyIndex = i; - return true; - } + return format == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || format == VK_FORMAT_A2R10G10B10_UNORM_PACK32; } - return false; - } - static bool surfaceHasTenBitFormat(VkPhysicalDevice device, VkSurfaceKHR surface) - { - uint32_t formatCount = 0; - if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + bool findGraphicsPresentQueue(VkPhysicalDevice device, VkSurfaceKHR surface, uint32_t& familyIndex) { + uint32_t familyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, nullptr); + std::vector families(familyCount); + vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, families.data()); + + for (uint32_t i = 0; i < familyCount; ++i) + { + VkBool32 presentSupport = VK_FALSE; + if (vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport) == VK_SUCCESS + && (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) + { + familyIndex = i; + return true; + } + } return false; } - std::vector formats(formatCount); - if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, formats.data()) != VK_SUCCESS) + bool surfaceHasTenBitFormat(VkPhysicalDevice device, VkSurfaceKHR surface) { - return false; - } + uint32_t formatCount = 0; + if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr) != VK_SUCCESS || formatCount == 0) + { + return false; + } - return std::any_of(formats.begin(), formats.end(), [](const VkSurfaceFormatKHR& format) { return isTenBitFormat(format.format); }); - } + std::vector formats(formatCount); + if (vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, formats.data()) != VK_SUCCESS) + { + return false; + } - static bool deviceHasExtension(VkPhysicalDevice device, const char* name) - { - uint32_t extensionCount = 0; - if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr) != VK_SUCCESS) - { - return false; + return std::any_of(formats.begin(), formats.end(), + [](const VkSurfaceFormatKHR& format) { return isTenBitFormat(format.format); }); } - std::vector extensions(extensionCount); - if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, extensions.data()) != VK_SUCCESS) + bool deviceHasExtension(VkPhysicalDevice device, const char* name) { - return false; - } + uint32_t extensionCount = 0; + if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr) != VK_SUCCESS) + { + return false; + } - return std::any_of(extensions.begin(), extensions.end(), - [name](const VkExtensionProperties& extension) { return strcmp(extension.extensionName, name) == 0; }); - } + std::vector extensions(extensionCount); + if (vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, extensions.data()) != VK_SUCCESS) + { + return false; + } - static const char* formatName(VkFormat f) - { - switch (f) - { - case VK_FORMAT_B8G8R8A8_UNORM: - return "B8G8R8A8_UNORM"; - case VK_FORMAT_B8G8R8A8_SRGB: - return "B8G8R8A8_SRGB"; - case VK_FORMAT_R8G8B8A8_UNORM: - return "R8G8B8A8_UNORM"; - case VK_FORMAT_R8G8B8A8_SRGB: - return "R8G8B8A8_SRGB"; - case VK_FORMAT_A2B10G10R10_UNORM_PACK32: - return "A2B10G10R10_UNORM_PACK32"; - case VK_FORMAT_A2R10G10B10_UNORM_PACK32: - return "A2R10G10B10_UNORM_PACK32"; - case VK_FORMAT_R16G16B16A16_SFLOAT: - return "R16G16B16A16_SFLOAT"; - default: - return "(other)"; + return std::any_of(extensions.begin(), extensions.end(), + [name](const VkExtensionProperties& extension) { return strcmp(extension.extensionName, name) == 0; }); } - } + + constexpr std::string_view formatName(VkFormat format) + { + switch (format) + { + case VK_FORMAT_B8G8R8A8_UNORM: + return "B8G8R8A8_UNORM"; + case VK_FORMAT_B8G8R8A8_SRGB: + return "B8G8R8A8_SRGB"; + case VK_FORMAT_R8G8B8A8_UNORM: + return "R8G8B8A8_UNORM"; + case VK_FORMAT_R8G8B8A8_SRGB: + return "R8G8B8A8_SRGB"; + case VK_FORMAT_A2B10G10R10_UNORM_PACK32: + return "A2B10G10R10_UNORM_PACK32"; + case VK_FORMAT_A2R10G10B10_UNORM_PACK32: + return "A2R10G10B10_UNORM_PACK32"; + case VK_FORMAT_R16G16B16A16_SFLOAT: + return "R16G16B16A16_SFLOAT"; + default: + return "(other)"; + } + } + } // namespace //-------------------------------------------------------------------------- // VulkanWindow implementation @@ -203,15 +216,7 @@ namespace Rv VulkanWindow::VulkanWindow(RvDocument* doc, bool noResize) : QWindow() , m_doc(doc) - , m_videoDevice(nullptr) - , m_initialized(false) - , m_firstPaintCompleted(false) , m_postFirstNonEmptyRender(noResize) - , m_stopProcessingEvents(false) - , m_userActive(true) - , m_eventWidget(nullptr) - , m_lastKey(0) - , m_lastKeyType(QEvent::None) { setSurfaceType(QSurface::VulkanSurface); @@ -300,23 +305,26 @@ namespace Rv // before another init corrupts RADV's X11 WSI state and segfaults in // vkGetPhysicalDeviceSurfaceSupportKHR. // - static QVulkanInstance* sharedVulkanInstance() + namespace { - static QVulkanInstance* instance = []() -> QVulkanInstance* + QVulkanInstance* sharedVulkanInstance() { - auto* inst = new QVulkanInstance(); - // 1.1 for vkGetPhysicalDeviceProperties2 (device UUID matching). - inst->setApiVersion(QVersionNumber(1, 1)); - if (!inst->create()) + static QVulkanInstance* instance = []() -> QVulkanInstance* { - cerr << "ERROR: VulkanWindow: shared QVulkanInstance create failed" << endl; - delete inst; - return nullptr; - } - return inst; - }(); - return instance; - } + auto* inst = new QVulkanInstance(); + // 1.1 for vkGetPhysicalDeviceProperties2 (device UUID matching). + inst->setApiVersion(QVersionNumber(1, 1)); + if (!inst->create()) + { + cerr << "ERROR: VulkanWindow: shared QVulkanInstance create failed" << endl; + delete inst; + return nullptr; + } + return inst; + }(); + return instance; + } + } // namespace bool VulkanWindow::supports10BitPresentation() { @@ -338,10 +346,10 @@ namespace Rv // Leaked for the same RADV WSI reason as sharedVulkanInstance(). static QWindow* dummyWindow = []() -> QWindow* { - auto* w = new QWindow(); - w->setSurfaceType(QSurface::VulkanSurface); - w->create(); - return w; + auto* window = new QWindow(); + window->setSurfaceType(QSurface::VulkanSurface); + window->create(); + return window; }(); dummyWindow->setVulkanInstance(qtVkInst); @@ -541,13 +549,13 @@ namespace Rv VkFenceCreateInfo fenceInfo = {}; fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; - for (uint32_t i = 0; i < kFramesInFlight; ++i) + for (FrameSync& frame : m_frameSync) { - if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &m_vkImageAvailableSemaphore[i]) != VK_SUCCESS) + if (vkCreateSemaphore(m_vkDevice, &semaphoreInfo, nullptr, &frame.imageAvailable) != VK_SUCCESS) { return failInit(); } - if (vkCreateFence(m_vkDevice, &fenceInfo, nullptr, &m_vkFence[i]) != VK_SUCCESS) + if (vkCreateFence(m_vkDevice, &fenceInfo, nullptr, &frame.fence) != VK_SUCCESS) { return failInit(); } @@ -653,17 +661,17 @@ namespace Rv { vkDeviceWaitIdle(m_vkDevice); - for (uint32_t i = 0; i < kFramesInFlight; ++i) + for (FrameSync& frame : m_frameSync) { - if (m_vkImageAvailableSemaphore[i]) + if (frame.imageAvailable) { - vkDestroySemaphore(m_vkDevice, m_vkImageAvailableSemaphore[i], nullptr); - m_vkImageAvailableSemaphore[i] = VK_NULL_HANDLE; + vkDestroySemaphore(m_vkDevice, frame.imageAvailable, nullptr); + frame.imageAvailable = VK_NULL_HANDLE; } - if (m_vkFence[i]) + if (frame.fence) { - vkDestroyFence(m_vkDevice, m_vkFence[i], nullptr); - m_vkFence[i] = VK_NULL_HANDLE; + vkDestroyFence(m_vkDevice, frame.fence, nullptr); + frame.fence = VK_NULL_HANDLE; } } @@ -686,78 +694,83 @@ namespace Rv // viewport) and RV_VULKAN_OUTPUT_PRESENT_MODE (presentation output) take // fifo | relaxed | mailbox | immediate. // - static const char* presentModeName(VkPresentModeKHR m) + namespace { - switch (m) - { - case VK_PRESENT_MODE_IMMEDIATE_KHR: - return "IMMEDIATE"; - case VK_PRESENT_MODE_MAILBOX_KHR: - return "MAILBOX"; - case VK_PRESENT_MODE_FIFO_KHR: - return "FIFO"; - case VK_PRESENT_MODE_FIFO_RELAXED_KHR: - return "FIFO_RELAXED"; - default: - return "(other)"; + constexpr std::string_view presentModeName(VkPresentModeKHR mode) + { + switch (mode) + { + case VK_PRESENT_MODE_IMMEDIATE_KHR: + return "IMMEDIATE"; + case VK_PRESENT_MODE_MAILBOX_KHR: + return "MAILBOX"; + case VK_PRESENT_MODE_FIFO_KHR: + return "FIFO"; + case VK_PRESENT_MODE_FIFO_RELAXED_KHR: + return "FIFO_RELAXED"; + default: + return "(other)"; + } } - } - static bool presentModeFromName(const char* name, VkPresentModeKHR& mode) - { - if (!name) - { - return false; - } - const string n(name); - if (n == "fifo") - { - mode = VK_PRESENT_MODE_FIFO_KHR; - } - else if (n == "relaxed") - { - mode = VK_PRESENT_MODE_FIFO_RELAXED_KHR; - } - else if (n == "mailbox") - { - mode = VK_PRESENT_MODE_MAILBOX_KHR; - } - else if (n == "immediate") - { - mode = VK_PRESENT_MODE_IMMEDIATE_KHR; - } - else + bool presentModeFromName(const char* name, VkPresentModeKHR& mode) { - return false; + if (!name) + { + return false; + } + const string modeName(name); + if (modeName == "fifo") + { + mode = VK_PRESENT_MODE_FIFO_KHR; + } + else if (modeName == "relaxed") + { + mode = VK_PRESENT_MODE_FIFO_RELAXED_KHR; + } + else if (modeName == "mailbox") + { + mode = VK_PRESENT_MODE_MAILBOX_KHR; + } + else if (modeName == "immediate") + { + mode = VK_PRESENT_MODE_IMMEDIATE_KHR; + } + else + { + return false; + } + return true; } - return true; - } - static VkPresentModeKHR choosePresentMode(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, bool passiveOutput) - { - uint32_t count = 0; - vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, nullptr); - std::vector available(count); - if (count) + VkPresentModeKHR choosePresentMode(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, bool passiveOutput) { - vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, available.data()); - } + uint32_t count = 0; + vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, nullptr); + std::vector available(count); + if (count) + { + vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice, surface, &count, available.data()); + } - const auto supported = [&](VkPresentModeKHR m) { return std::find(available.begin(), available.end(), m) != available.end(); }; + const auto supported = [&](VkPresentModeKHR mode) + { return std::find(available.begin(), available.end(), mode) != available.end(); }; - VkPresentModeKHR forced = VK_PRESENT_MODE_FIFO_KHR; - if (presentModeFromName(getenv(passiveOutput ? "RV_VULKAN_OUTPUT_PRESENT_MODE" : "RV_VULKAN_PRESENT_MODE"), forced)) - { - if (supported(forced)) + VkPresentModeKHR forced = VK_PRESENT_MODE_FIFO_KHR; + if (presentModeFromName(getenv(passiveOutput ? "RV_VULKAN_OUTPUT_PRESENT_MODE" : "RV_VULKAN_PRESENT_MODE"), forced)) { - return forced; + if (supported(forced)) + { + return forced; + } + cout << "WARNING: VulkanWindow: requested present mode " << presentModeName(forced) << " is unsupported; using FIFO" + << endl; + return VK_PRESENT_MODE_FIFO_KHR; } - cout << "WARNING: VulkanWindow: requested present mode " << presentModeName(forced) << " is unsupported; using FIFO" << endl; + return VK_PRESENT_MODE_FIFO_KHR; } - - return VK_PRESENT_MODE_FIFO_KHR; - } + } // namespace bool VulkanWindow::createSwapchain() { @@ -881,7 +894,7 @@ namespace Rv m_vkSwapchainFormat = surfaceFormat.format; m_vkSwapchainExtent = capabilities.currentExtent; - if (m_vkSwapchainExtent.width == UINT32_MAX) + if (m_vkSwapchainExtent.width == std::numeric_limits::max()) { const qreal dpr = devicePixelRatio(); const uint32_t pixelWidth = static_cast(std::max(1.0, width() * dpr)); @@ -934,7 +947,7 @@ namespace Rv createInfo.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; createInfo.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; createInfo.preTransform = capabilities.currentTransform; - const VkCompositeAlphaFlagBitsKHR compositeAlphaPreference[] = { + constexpr std::array compositeAlphaPreference = { VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR, VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR, VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR, @@ -1034,19 +1047,19 @@ namespace Rv m_vkRenderFinished.clear(); m_imagesInFlight.clear(); - for (uint32_t i = 0; i < kFramesInFlight; ++i) + for (StagingBuffer& staging : m_staging) { - if (m_vkStagingBuffer[i]) + if (staging.buffer) { - vkDestroyBuffer(m_vkDevice, m_vkStagingBuffer[i], nullptr); - m_vkStagingBuffer[i] = VK_NULL_HANDLE; + vkDestroyBuffer(m_vkDevice, staging.buffer, nullptr); + staging.buffer = VK_NULL_HANDLE; } - if (m_vkStagingBufferMemory[i]) + if (staging.memory) { - vkFreeMemory(m_vkDevice, m_vkStagingBufferMemory[i], nullptr); - m_vkStagingBufferMemory[i] = VK_NULL_HANDLE; + vkFreeMemory(m_vkDevice, staging.memory, nullptr); + staging.memory = VK_NULL_HANDLE; } - m_stagingBufferSize[i] = 0; + staging.size = 0; } if (!m_vkCommandBuffers.empty()) @@ -1064,27 +1077,56 @@ namespace Rv } } - static uint32_t findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) + namespace { - VkPhysicalDeviceMemoryProperties memProperties; - vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties); - for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) + std::optional findMemoryType(VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) { - if ((typeFilter & (1u << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) + VkPhysicalDeviceMemoryProperties memProperties; + vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties); + for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) { - return i; + if ((typeFilter & (1u << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) + { + return i; + } } + return std::nullopt; + } + + // Device-level entry point lookup, cast to its PFN type. + template Fn deviceProc(VkDevice device, const char* name) + { + return reinterpret_cast(vkGetDeviceProcAddr(device, name)); + } + + // Single-subresource colour image layout transition. + void transitionImageLayout(VkCommandBuffer commandBuffer, VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout, + VkAccessFlags srcAccessMask, VkAccessFlags dstAccessMask, VkPipelineStageFlags srcStageMask, + VkPipelineStageFlags dstStageMask) + { + VkImageMemoryBarrier barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = oldLayout; + barrier.newLayout = newLayout; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = srcAccessMask; + barrier.dstAccessMask = dstAccessMask; + + vkCmdPipelineBarrier(commandBuffer, srcStageMask, dstStageMask, 0, 0, nullptr, 0, nullptr, 1, &barrier); } - return UINT32_MAX; - } - namespace - { bool envFlagSet(const char* name) { return getenv(name) != nullptr; } - const char* tilingName(VkImageTiling t) + constexpr std::string_view tilingName(VkImageTiling tiling) { - switch (t) + switch (tiling) { case VK_IMAGE_TILING_OPTIMAL: return "OPTIMAL"; @@ -1095,9 +1137,9 @@ namespace Rv } } - const char* colorSpaceName(VkColorSpaceKHR cs) + constexpr std::string_view colorSpaceName(VkColorSpaceKHR colorSpace) { - switch (cs) + switch (colorSpace) { case VK_COLOR_SPACE_SRGB_NONLINEAR_KHR: return "SRGB_NONLINEAR"; @@ -1123,27 +1165,28 @@ namespace Rv // False when RV_VULKAN_FORCE_TILING is unset or unrecognized. bool forcedTilingRequested(VkImageTiling& out) { - const char* v = getenv("RV_VULKAN_FORCE_TILING"); - if (!v) + const char* value = getenv("RV_VULKAN_FORCE_TILING"); + if (!value) { return false; } - std::string s(v); - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(::tolower(c)); }); + std::string lowered(value); + std::transform(lowered.begin(), lowered.end(), lowered.begin(), + [](unsigned char ch) { return static_cast(::tolower(ch)); }); - if (s == "optimal") + if (lowered == "optimal") { out = VK_IMAGE_TILING_OPTIMAL; return true; } - if (s == "linear") + if (lowered == "linear") { out = VK_IMAGE_TILING_LINEAR; return true; } - cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING='" << v << "' is not recognized (expected 'optimal' or 'linear'); " + cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING='" << value << "' is not recognized (expected 'optimal' or 'linear'); " << "ignoring it and using the negotiated tiling" << endl; return false; } @@ -1196,19 +1239,19 @@ namespace Rv static const unsigned int depth = [] { const unsigned int kDefault = 1; - const char* v = getenv("RV_VULKAN_MAX_FRAMES_IN_FLIGHT"); - if (!v) + const char* value = getenv("RV_VULKAN_MAX_FRAMES_IN_FLIGHT"); + if (!value) { return kDefault; } - const int n = atoi(v); - if (n < 1 || n > static_cast(VulkanWindow::kFramesInFlight)) + const int requested = atoi(value); + if (requested < 1 || requested > static_cast(VulkanWindow::kFramesInFlight)) { cout << "WARNING: VulkanWindow: RV_VULKAN_MAX_FRAMES_IN_FLIGHT must be 1.." << VulkanWindow::kFramesInFlight << "; using " << kDefault << endl; return kDefault; } - return static_cast(n); + return static_cast(requested); }(); return depth; } @@ -1257,8 +1300,8 @@ namespace Rv // Exportability alone does not guarantee a usable import, so try the // vendor's preferred tiling first. See useOptimalTilingForInterop(). const bool preferOptimal = useOptimalTilingForInterop(m_vkPhysicalDevice); - const VkImageTiling candidates[] = {preferOptimal ? VK_IMAGE_TILING_OPTIMAL : VK_IMAGE_TILING_LINEAR, - preferOptimal ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL}; + const std::array candidates = {preferOptimal ? VK_IMAGE_TILING_OPTIMAL : VK_IMAGE_TILING_LINEAR, + preferOptimal ? VK_IMAGE_TILING_LINEAR : VK_IMAGE_TILING_OPTIMAL}; auto probeTiling = [&](VkImageTiling tiling, VkExternalMemoryFeatureFlags& features) -> bool { @@ -1282,10 +1325,10 @@ namespace Rv props.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2; props.pNext = &extProps; - const VkResult r = probe(m_vkPhysicalDevice, &fmtInfo, &props); + const VkResult result = probe(m_vkPhysicalDevice, &fmtInfo, &props); features = extProps.externalMemoryProperties.externalMemoryFeatures; - if (r != VK_SUCCESS) + if (result != VK_SUCCESS) { return false; } @@ -1406,7 +1449,7 @@ namespace Rv vkGetPhysicalDeviceProperties(m_vkPhysicalDevice, &props); } - const char* pathName = "undetermined"; + std::string_view pathName = "undetermined"; switch (m_presentPath) { case PresentPath::ZeroCopy: @@ -1422,93 +1465,95 @@ namespace Rv break; } - ostringstream o; - o << "INFO: RV Vulkan presentation report\n"; - o << "INFO: Role : " << (isPassiveOutput() ? "presentation output" : "control viewport") << "\n"; - o << "INFO: GPU : " << (m_vkPhysicalDevice != VK_NULL_HANDLE ? props.deviceName : "(none)") << " vendorID=0x" - << std::hex << props.vendorID << std::dec << " driverVersion=" << props.driverVersion - << " apiVersion=" << VK_VERSION_MAJOR(props.apiVersion) << "." << VK_VERSION_MINOR(props.apiVersion) << "." - << VK_VERSION_PATCH(props.apiVersion) << "\n"; - o << "INFO: Present path : " << pathName << "\n"; + ostringstream report; + report << "INFO: RV Vulkan presentation report\n"; + report << "INFO: Role : " << (isPassiveOutput() ? "presentation output" : "control viewport") << "\n"; + report << "INFO: GPU : " << (m_vkPhysicalDevice != VK_NULL_HANDLE ? props.deviceName : "(none)") << " vendorID=0x" + << std::hex << props.vendorID << std::dec << " driverVersion=" << props.driverVersion + << " apiVersion=" << VK_VERSION_MAJOR(props.apiVersion) << "." << VK_VERSION_MINOR(props.apiVersion) << "." + << VK_VERSION_PATCH(props.apiVersion) << "\n"; + report << "INFO: Present path : " << pathName << "\n"; if (!m_presentPathReason.empty()) { - o << "INFO: Reason : " << m_presentPathReason << "\n"; + report << "INFO: Reason : " << m_presentPathReason << "\n"; } - o << "INFO: Swapchain : " << formatName(m_vkSwapchainFormat) << " / " << colorSpaceName(m_loggedSurfaceFormat.colorSpace) - << "\n"; + report << "INFO: Swapchain : " << formatName(m_vkSwapchainFormat) << " / " + << colorSpaceName(m_loggedSurfaceFormat.colorSpace) << "\n"; - const InteropConfig& c = m_interopConfig; - if (c.supported) + const InteropConfig& config = m_interopConfig; + if (config.supported) { - o << "INFO: Shared image : " << formatName(c.format) << " tiling=" << tilingName(c.tiling) - << " usage=COLOR_ATTACHMENT|TRANSFER_SRC\n"; - o << "INFO: Dedicated alloc: " << (c.dedicatedAllocation ? "yes" : "no") << " (driver externalMemoryFeatures=0x" << std::hex - << c.externalFeatures << std::dec - << (c.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT ? " DEDICATED_ONLY" : "") << ")\n"; - if (c.tilingOverridden) + report << "INFO: Shared image : " << formatName(config.format) << " tiling=" << tilingName(config.tiling) + << " usage=COLOR_ATTACHMENT|TRANSFER_SRC\n"; + report << "INFO: Dedicated alloc: " << (config.dedicatedAllocation ? "yes" : "no") << " (driver externalMemoryFeatures=0x" + << std::hex << config.externalFeatures << std::dec + << (config.externalFeatures & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT ? " DEDICATED_ONLY" : "") << ")\n"; + if (config.tilingOverridden) { - o << "INFO: Tiling override: RV_VULKAN_FORCE_TILING forced " << tilingName(c.tiling) << "; negotiation chose " - << tilingName(c.probedTiling) << "\n"; + report << "INFO: Tiling override: RV_VULKAN_FORCE_TILING forced " << tilingName(config.tiling) << "; negotiation chose " + << tilingName(config.probedTiling) << "\n"; } - if (c.dedicatedOverridden) + if (config.dedicatedOverridden) { - o << "INFO: Dedicated ovr : RV_VULKAN_FORCE_NO_DEDICATED suppressed dedicated allocation; negotiation chose " - << (c.probedDedicated ? "yes" : "no") << "\n"; + report << "INFO: Dedicated ovr : RV_VULKAN_FORCE_NO_DEDICATED suppressed dedicated allocation; negotiation chose " + << (config.probedDedicated ? "yes" : "no") << "\n"; } if (m_glImportReported) { - const bool agree = m_glImportTiling == c.tiling; - o << "INFO: GL import : tiling=" << tilingName(m_glImportTiling) - << " dedicated=" << (m_glImportDedicated ? "yes" : "no") << " -- " - << (agree ? "tiling matches the Vulkan export" : "TILING DISAGREES WITH THE VULKAN EXPORT (expect a corrupted image)") - << "\n"; + const bool agree = m_glImportTiling == config.tiling; + report << "INFO: GL import : tiling=" << tilingName(m_glImportTiling) + << " dedicated=" << (m_glImportDedicated ? "yes" : "no") << " -- " + << (agree ? "tiling matches the Vulkan export" + : "TILING DISAGREES WITH THE VULKAN EXPORT (expect a corrupted image)") + << "\n"; } } else { - o << "INFO: Shared image : not used -- " << (c.rejectReason.empty() ? "interop not negotiated" : c.rejectReason) << "\n"; + report << "INFO: Shared image : not used -- " + << (config.rejectReason.empty() ? "interop not negotiated" : config.rejectReason) << "\n"; } - for (const std::string& entry : c.candidateLog) + for (const std::string& entry : config.candidateLog) { - o << "INFO: Probe candidate: " << entry << "\n"; + report << "INFO: Probe candidate: " << entry << "\n"; } if (envFlagSet("RV_VULKAN_FORCE_CPU_PRESENT")) { - o << "INFO: Override : RV_VULKAN_FORCE_CPU_PRESENT is set\n"; + report << "INFO: Override : RV_VULKAN_FORCE_CPU_PRESENT is set\n"; } - cout << o.str() << flush; + cout << report.str() << flush; } void VulkanWindow::cleanupSharedImage(uint32_t slot) { - SharedImageInfo& info = m_sharedImageInfo[slot]; + SharedImageInfo& info = m_shared[slot].info; if (m_vkDevice) { vkDeviceWaitIdle(m_vkDevice); - if (m_vkSharedImage[slot]) + if (m_shared[slot].image) { - vkDestroyImage(m_vkDevice, m_vkSharedImage[slot], nullptr); - m_vkSharedImage[slot] = VK_NULL_HANDLE; + vkDestroyImage(m_vkDevice, m_shared[slot].image, nullptr); + m_shared[slot].image = VK_NULL_HANDLE; } - if (m_vkSharedImageMemory[slot]) + if (m_shared[slot].memory) { - vkFreeMemory(m_vkDevice, m_vkSharedImageMemory[slot], nullptr); - m_vkSharedImageMemory[slot] = VK_NULL_HANDLE; + vkFreeMemory(m_vkDevice, m_shared[slot].memory, nullptr); + m_shared[slot].memory = VK_NULL_HANDLE; } - if (m_vkGlReadySemaphore[slot]) + if (m_shared[slot].glReadySemaphore) { - vkDestroySemaphore(m_vkDevice, m_vkGlReadySemaphore[slot], nullptr); - m_vkGlReadySemaphore[slot] = VK_NULL_HANDLE; + vkDestroySemaphore(m_vkDevice, m_shared[slot].glReadySemaphore, nullptr); + m_shared[slot].glReadySemaphore = VK_NULL_HANDLE; } - if (m_vkVkReadySemaphore[slot]) + if (m_shared[slot].vkReadySemaphore) { - vkDestroySemaphore(m_vkDevice, m_vkVkReadySemaphore[slot], nullptr); - m_vkVkReadySemaphore[slot] = VK_NULL_HANDLE; + vkDestroySemaphore(m_vkDevice, m_shared[slot].vkReadySemaphore, nullptr); + m_shared[slot].vkReadySemaphore = VK_NULL_HANDLE; } } @@ -1551,8 +1596,8 @@ namespace Rv info.capacityHeight = 0; info.tiling = VK_IMAGE_TILING_LINEAR; info.dedicatedAllocation = false; - m_sharedCapacityW[slot] = 0; - m_sharedCapacityH[slot] = 0; + m_shared[slot].capacityW = 0; + m_shared[slot].capacityH = 0; } // @@ -1580,8 +1625,14 @@ namespace Rv } // Every in-flight frame must have retired, not only this slot's. - const VkResult r = vkWaitForFences(m_vkDevice, kFramesInFlight, m_vkFence.data(), VK_TRUE, 0); - if (r == VK_SUCCESS) + std::array fences{}; + for (uint32_t i = 0; i < kFramesInFlight; ++i) + { + fences[i] = m_frameSync[i].fence; + } + + const VkResult result = vkWaitForFences(m_vkDevice, kFramesInFlight, fences.data(), VK_TRUE, 0); + if (result == VK_SUCCESS) { return true; } @@ -1602,25 +1653,25 @@ namespace Rv void VulkanWindow::drainSharedSemaphores(uint32_t slot) { - if (!m_vkDevice || !m_vkGlReadySemaphore[slot] || !m_vkVkReadySemaphore[slot]) + if (!m_vkDevice || !m_shared[slot].glReadySemaphore || !m_shared[slot].vkReadySemaphore) { return; } VkSubmitInfo drain = {}; drain.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot]}; - VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT}; - drain.waitSemaphoreCount = 1; - drain.pWaitSemaphores = waitSemaphores; - drain.pWaitDstStageMask = waitStages; + const std::array waitSemaphores = {m_shared[slot].glReadySemaphore}; + const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT}; + drain.waitSemaphoreCount = static_cast(waitSemaphores.size()); + drain.pWaitSemaphores = waitSemaphores.data(); + drain.pWaitDstStageMask = waitStages.data(); drain.commandBufferCount = 0; - VkSemaphore signalSemaphores[] = {m_vkVkReadySemaphore[slot]}; - drain.signalSemaphoreCount = 1; - drain.pSignalSemaphores = signalSemaphores; + const std::array signalSemaphores = {m_shared[slot].vkReadySemaphore}; + drain.signalSemaphoreCount = static_cast(signalSemaphores.size()); + drain.pSignalSemaphores = signalSemaphores.data(); - VkResult r = vkQueueSubmit(m_vkQueue, 1, &drain, VK_NULL_HANDLE); - if (r == VK_ERROR_DEVICE_LOST) + VkResult result = vkQueueSubmit(m_vkQueue, 1, &drain, VK_NULL_HANDLE); + if (result == VK_ERROR_DEVICE_LOST) { requestGLFallback(); } @@ -1638,7 +1689,7 @@ namespace Rv recover.pWaitDstStageMask = &waitStage; } - if (vkQueueSubmit(m_vkQueue, 1, &recover, m_vkFence[slot]) != VK_SUCCESS) + if (vkQueueSubmit(m_vkQueue, 1, &recover, m_frameSync[slot].fence) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: could not recover from a failed submit" << endl; } @@ -1655,7 +1706,7 @@ namespace Rv VkExtent2D surfaceExtent = m_vkSwapchainExtent; VkSurfaceCapabilitiesKHR caps = {}; if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_vkPhysicalDevice, m_vkSurface, &caps) == VK_SUCCESS - && caps.currentExtent.width != UINT32_MAX) + && caps.currentExtent.width != std::numeric_limits::max()) { surfaceExtent = caps.currentExtent; } @@ -1675,7 +1726,7 @@ namespace Rv } const uint32_t slot = m_currentFrame; - SharedImageInfo& info = m_sharedImageInfo[slot]; + SharedImageInfo& info = m_shared[slot].info; if (!ensureSwapchainMatchesSurface()) { @@ -1683,7 +1734,7 @@ namespace Rv } // Within capacity: reuse the export, update the used sub-region. - if (m_vkSharedImage[slot] && w <= m_sharedCapacityW[slot] && h <= m_sharedCapacityH[slot]) + if (m_shared[slot].image && w <= m_shared[slot].capacityW && h <= m_shared[slot].capacityH) { info.width = w; info.height = h; @@ -1700,8 +1751,8 @@ namespace Rv screenW = static_cast(scr->geometry().width() * dpr); screenH = static_cast(scr->geometry().height() * dpr); } - const int capW = std::max({w, screenW, m_sharedCapacityW[slot]}); - const int capH = std::max({h, screenH, m_sharedCapacityH[slot]}); + const int capW = std::max({w, screenW, m_shared[slot].capacityW}); + const int capH = std::max({h, screenH, m_shared[slot].capacityH}); cleanupSharedImage(slot); @@ -1745,7 +1796,7 @@ namespace Rv imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; - if (vkCreateImage(m_vkDevice, &imageInfo, nullptr, &m_vkSharedImage[slot]) != VK_SUCCESS) + if (vkCreateImage(m_vkDevice, &imageInfo, nullptr, &m_shared[slot].image) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: Failed to create shared image" << endl; return nullptr; @@ -1787,7 +1838,7 @@ namespace Rv subresource.mipLevel = 0; subresource.arrayLayer = 0; VkSubresourceLayout layout; - vkGetImageSubresourceLayout(m_vkDevice, m_vkSharedImage[slot], &subresource, &layout); + vkGetImageSubresourceLayout(m_vkDevice, m_shared[slot].image, &subresource, &layout); if (layout.rowPitch % 4 != 0) { @@ -1812,7 +1863,7 @@ namespace Rv VkImageMemoryRequirementsInfo2 memReqsInfo = {}; memReqsInfo.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_REQUIREMENTS_INFO_2; - memReqsInfo.image = m_vkSharedImage[slot]; + memReqsInfo.image = m_shared[slot].image; vkGetImageMemoryRequirements2(m_vkDevice, &memReqsInfo, &memReqs2); @@ -1855,7 +1906,7 @@ namespace Rv { dedicatedAllocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO; dedicatedAllocInfo.pNext = chain; - dedicatedAllocInfo.image = m_vkSharedImage[slot]; + dedicatedAllocInfo.image = m_shared[slot].image; dedicatedAllocInfo.buffer = VK_NULL_HANDLE; chain = &dedicatedAllocInfo; } @@ -1873,22 +1924,24 @@ namespace Rv allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; allocInfo.pNext = &exportAllocInfo; allocInfo.allocationSize = memReqs.size; - allocInfo.memoryTypeIndex = findMemoryType(m_vkPhysicalDevice, memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); - if (allocInfo.memoryTypeIndex == UINT32_MAX) + const std::optional memoryTypeIndex = + findMemoryType(m_vkPhysicalDevice, memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + if (!memoryTypeIndex) { cerr << "ERROR: VulkanWindow: No device-local memory type for shared image" << endl; cleanupSharedImage(slot); return nullptr; } + allocInfo.memoryTypeIndex = *memoryTypeIndex; - if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_vkSharedImageMemory[slot]) != VK_SUCCESS) + if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_shared[slot].memory) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: Failed to allocate shared image memory" << endl; cleanupSharedImage(slot); return nullptr; } - if (vkBindImageMemory(m_vkDevice, m_vkSharedImage[slot], m_vkSharedImageMemory[slot], 0) != VK_SUCCESS) + if (vkBindImageMemory(m_vkDevice, m_shared[slot].image, m_shared[slot].memory, 0) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: Failed to bind shared image memory" << endl; cleanupSharedImage(slot); @@ -1896,8 +1949,7 @@ namespace Rv } #ifdef PLATFORM_WINDOWS - auto pfnGetMemoryWin32HandleKHR = - reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryWin32HandleKHR")); + auto pfnGetMemoryWin32HandleKHR = deviceProc(m_vkDevice, "vkGetMemoryWin32HandleKHR"); if (!pfnGetMemoryWin32HandleKHR) { cerr << "ERROR: VulkanWindow: vkGetMemoryWin32HandleKHR not found" << endl; @@ -1907,7 +1959,7 @@ namespace Rv VkMemoryGetWin32HandleInfoKHR getHandleInfo = {}; getHandleInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_WIN32_HANDLE_INFO_KHR; - getHandleInfo.memory = m_vkSharedImageMemory[slot]; + getHandleInfo.memory = m_shared[slot].memory; getHandleInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; HANDLE memHandle = nullptr; @@ -1920,7 +1972,7 @@ namespace Rv // Stored at once so cleanupSharedImage() closes it on a later failure. info.memoryHandle = memHandle; #else - auto pfnGetMemoryFdKHR = reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetMemoryFdKHR")); + auto pfnGetMemoryFdKHR = deviceProc(m_vkDevice, "vkGetMemoryFdKHR"); if (!pfnGetMemoryFdKHR) { cerr << "ERROR: VulkanWindow: vkGetMemoryFdKHR not found" << endl; @@ -1930,7 +1982,7 @@ namespace Rv VkMemoryGetFdInfoKHR getFdInfo = {}; getFdInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; - getFdInfo.memory = m_vkSharedImageMemory[slot]; + getFdInfo.memory = m_shared[slot].memory; getFdInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; int memFd = -1; @@ -1956,8 +2008,8 @@ namespace Rv semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; semInfo.pNext = &exportSemInfo; - if (vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_vkGlReadySemaphore[slot]) != VK_SUCCESS - || vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_vkVkReadySemaphore[slot]) != VK_SUCCESS) + if (vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_shared[slot].glReadySemaphore) != VK_SUCCESS + || vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_shared[slot].vkReadySemaphore) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: Failed to create shared semaphores" << endl; cleanupSharedImage(slot); @@ -1965,8 +2017,7 @@ namespace Rv } #ifdef PLATFORM_WINDOWS - auto pfnGetSemaphoreWin32HandleKHR = - reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreWin32HandleKHR")); + auto pfnGetSemaphoreWin32HandleKHR = deviceProc(m_vkDevice, "vkGetSemaphoreWin32HandleKHR"); if (!pfnGetSemaphoreWin32HandleKHR) { cerr << "ERROR: VulkanWindow: vkGetSemaphoreWin32HandleKHR not found" << endl; @@ -1981,7 +2032,7 @@ namespace Rv HANDLE glReadyHandle = nullptr; HANDLE vkReadyHandle = nullptr; - getSemHandleInfo.semaphore = m_vkGlReadySemaphore[slot]; + getSemHandleInfo.semaphore = m_shared[slot].glReadySemaphore; if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &glReadyHandle) != VK_SUCCESS || !glReadyHandle) { cerr << "ERROR: VulkanWindow: Failed to get glReady semaphore HANDLE" << endl; @@ -1990,7 +2041,7 @@ namespace Rv } info.glReadySemaphoreHandle = glReadyHandle; - getSemHandleInfo.semaphore = m_vkVkReadySemaphore[slot]; + getSemHandleInfo.semaphore = m_shared[slot].vkReadySemaphore; if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &vkReadyHandle) != VK_SUCCESS || !vkReadyHandle) { cerr << "ERROR: VulkanWindow: Failed to get vkReady semaphore HANDLE" << endl; @@ -2003,7 +2054,7 @@ namespace Rv info.width = w; info.height = h; #else - auto pfnGetSemaphoreFdKHR = reinterpret_cast(vkGetDeviceProcAddr(m_vkDevice, "vkGetSemaphoreFdKHR")); + auto pfnGetSemaphoreFdKHR = deviceProc(m_vkDevice, "vkGetSemaphoreFdKHR"); if (!pfnGetSemaphoreFdKHR) { cerr << "ERROR: VulkanWindow: vkGetSemaphoreFdKHR not found" << endl; @@ -2018,7 +2069,7 @@ namespace Rv int glReadyFd = -1; int vkReadyFd = -1; - getSemFdInfo.semaphore = m_vkGlReadySemaphore[slot]; + getSemFdInfo.semaphore = m_shared[slot].glReadySemaphore; if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &glReadyFd) != VK_SUCCESS || glReadyFd < 0) { cerr << "ERROR: VulkanWindow: Failed to get glReady semaphore FD" << endl; @@ -2027,7 +2078,7 @@ namespace Rv } info.glReadySemaphoreFd = glReadyFd; - getSemFdInfo.semaphore = m_vkVkReadySemaphore[slot]; + getSemFdInfo.semaphore = m_shared[slot].vkReadySemaphore; if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &vkReadyFd) != VK_SUCCESS || vkReadyFd < 0) { cerr << "ERROR: VulkanWindow: Failed to get vkReady semaphore FD" << endl; @@ -2049,22 +2100,8 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - VkImageMemoryBarrier barrier = {}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; - barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; - barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.image = m_vkSharedImage[slot]; - barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - barrier.subresourceRange.baseMipLevel = 0; - barrier.subresourceRange.levelCount = 1; - barrier.subresourceRange.baseArrayLayer = 0; - barrier.subresourceRange.layerCount = 1; - barrier.srcAccessMask = 0; - barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; - - vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, + VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); vkEndCommandBuffer(cb); @@ -2073,21 +2110,21 @@ namespace Rv submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &cb; - vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); - VkResult layoutSubmitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); + vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); + VkResult layoutSubmitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); if (layoutSubmitResult != VK_SUCCESS) { recoverFailedSubmit(slot, VK_NULL_HANDLE); cleanupSharedImage(slot); return nullptr; } - vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); // Signal vkReady so GL can write the first frame. VkSubmitInfo signalInfo = {}; signalInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; signalInfo.signalSemaphoreCount = 1; - signalInfo.pSignalSemaphores = &m_vkVkReadySemaphore[slot]; + signalInfo.pSignalSemaphores = &m_shared[slot].vkReadySemaphore; VkResult signalResult = vkQueueSubmit(m_vkQueue, 1, &signalInfo, VK_NULL_HANDLE); if (signalResult != VK_SUCCESS) { @@ -2099,8 +2136,8 @@ namespace Rv return nullptr; } - m_sharedCapacityW[slot] = capW; - m_sharedCapacityH[slot] = capH; + m_shared[slot].capacityW = capW; + m_shared[slot].capacityH = capH; return &info; } @@ -2112,9 +2149,9 @@ namespace Rv void VulkanWindow::presentSharedImage() { const uint32_t slot = m_currentFrame; - const SharedImageInfo& info = m_sharedImageInfo[slot]; + const SharedImageInfo& info = m_shared[slot].info; - if (!m_vkDevice || !m_vkSharedImage[slot] || !m_vkSwapchain) + if (!m_vkDevice || !m_shared[slot].image || !m_vkSwapchain) { return; } @@ -2128,14 +2165,14 @@ namespace Rv // enters the loop; a skipped frame keeps the previous image. const bool bestEffort = isPassiveOutput(); const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); - const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; + const uint64_t waitTimeout = (!bestEffort || forceProgress) ? std::numeric_limits::max() : 0; if (diagPresent) { diagTimer.start(); } - VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout); + VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, waitTimeout); if (diagPresent) { @@ -2162,7 +2199,7 @@ namespace Rv } VkResult result = - vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, &imageIndex); if (diagPresent) { @@ -2196,11 +2233,11 @@ namespace Rv if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) { - vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX); + vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); } - m_imagesInFlight[imageIndex] = m_vkFence[slot]; + m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; - vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); + vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; vkResetCommandBuffer(cb, 0); @@ -2210,41 +2247,12 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - VkImageMemoryBarrier sharedBarrier = {}; - sharedBarrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - sharedBarrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; - sharedBarrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; - sharedBarrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - sharedBarrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - sharedBarrier.image = m_vkSharedImage[slot]; - sharedBarrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - sharedBarrier.subresourceRange.baseMipLevel = 0; - sharedBarrier.subresourceRange.levelCount = 1; - sharedBarrier.subresourceRange.baseArrayLayer = 0; - sharedBarrier.subresourceRange.layerCount = 1; - sharedBarrier.srcAccessMask = 0; - sharedBarrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; - - vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, - &sharedBarrier); + transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, + VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); // Transition swapchain image to transfer dst - VkImageMemoryBarrier barrier = {}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; - barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; - barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.image = m_vkSwapchainImages[imageIndex]; - barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - barrier.subresourceRange.baseMipLevel = 0; - barrier.subresourceRange.levelCount = 1; - barrier.subresourceRange.baseArrayLayer = 0; - barrier.subresourceRange.layerCount = 1; - barrier.srcAccessMask = 0; - barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - - vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); // Same format: raw copy. A2R10G10B10: a raw copy would swap R and B, // so blit (per-component conversion). The destination is bounded by @@ -2260,7 +2268,7 @@ namespace Rv region.extent = {std::min(static_cast(info.width), m_vkSwapchainExtent.width), std::min(static_cast(info.height), m_vkSwapchainExtent.height), 1}; - vkCmdCopyImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], + vkCmdCopyImage(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); } else @@ -2275,43 +2283,38 @@ namespace Rv blit.dstOffsets[0] = {0, 0, 0}; blit.dstOffsets[1] = {static_cast(m_vkSwapchainExtent.width), static_cast(m_vkSwapchainExtent.height), 1}; - vkCmdBlitImage(cb, m_vkSharedImage[slot], VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], + vkCmdBlitImage(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST); } - barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; - barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; - barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - barrier.dstAccessMask = 0; - - vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, - &barrier); + transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); vkEndCommandBuffer(cb); VkSubmitInfo submitInfo = {}; submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - VkSemaphore waitSemaphores[] = {m_vkGlReadySemaphore[slot], m_vkImageAvailableSemaphore[slot]}; + const std::array waitSemaphores = {m_shared[slot].glReadySemaphore, m_frameSync[slot].imageAvailable}; // TRANSFER, not COLOR_ATTACHMENT_OUTPUT: the swapchain image is first // touched by its TRANSFER_DST transition. - VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT}; - submitInfo.waitSemaphoreCount = 2; - submitInfo.pWaitSemaphores = waitSemaphores; - submitInfo.pWaitDstStageMask = waitStages; + const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT}; + submitInfo.waitSemaphoreCount = static_cast(waitSemaphores.size()); + submitInfo.pWaitSemaphores = waitSemaphores.data(); + submitInfo.pWaitDstStageMask = waitStages.data(); submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &cb; - VkSemaphore signalSemaphores[] = {m_vkRenderFinished[imageIndex], m_vkVkReadySemaphore[slot]}; - submitInfo.signalSemaphoreCount = 2; - submitInfo.pSignalSemaphores = signalSemaphores; + const std::array signalSemaphores = {m_vkRenderFinished[imageIndex], m_shared[slot].vkReadySemaphore}; + submitInfo.signalSemaphoreCount = static_cast(signalSemaphores.size()); + submitInfo.pSignalSemaphores = signalSemaphores.data(); - VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); + VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); if (submitResult != VK_SUCCESS) { drainSharedSemaphores(slot); - recoverFailedSubmit(slot, m_vkImageAvailableSemaphore[slot]); + recoverFailedSubmit(slot, m_frameSync[slot].imageAvailable); return; } @@ -2328,9 +2331,9 @@ namespace Rv presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount = 1; presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; - VkSwapchainKHR swapchains[] = {m_vkSwapchain}; - presentInfo.swapchainCount = 1; - presentInfo.pSwapchains = swapchains; + const std::array swapchains = {m_vkSwapchain}; + presentInfo.swapchainCount = static_cast(swapchains.size()); + presentInfo.pSwapchains = swapchains.data(); presentInfo.pImageIndices = &imageIndex; VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); @@ -2342,7 +2345,7 @@ namespace Rv // frame early; never for the passive output. if (maxFramesInFlight() == 1 && !isPassiveOutput()) { - vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); } // Recreate only on OUT_OF_DATE: some X11/RADV compositors report // SUBOPTIMAL persistently. @@ -2385,13 +2388,13 @@ namespace Rv // Same best-effort throttle as presentSharedImage(). const bool bestEffort = isPassiveOutput(); const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); - const uint64_t waitTimeout = (!bestEffort || forceProgress) ? UINT64_MAX : 0; + const uint64_t waitTimeout = (!bestEffort || forceProgress) ? std::numeric_limits::max() : 0; if (diagPresent) { diagTimer.start(); } - const VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, waitTimeout); + const VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, waitTimeout); if (diagPresent) { s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; @@ -2410,69 +2413,71 @@ namespace Rv const size_t size = static_cast(w) * static_cast(h) * 4; - if (size > m_stagingBufferSize[slot]) + if (size > m_staging[slot].size) { - if (m_vkStagingBuffer[slot]) + if (m_staging[slot].buffer) { - vkDestroyBuffer(m_vkDevice, m_vkStagingBuffer[slot], nullptr); - m_vkStagingBuffer[slot] = VK_NULL_HANDLE; + vkDestroyBuffer(m_vkDevice, m_staging[slot].buffer, nullptr); + m_staging[slot].buffer = VK_NULL_HANDLE; } - if (m_vkStagingBufferMemory[slot]) + if (m_staging[slot].memory) { - vkFreeMemory(m_vkDevice, m_vkStagingBufferMemory[slot], nullptr); - m_vkStagingBufferMemory[slot] = VK_NULL_HANDLE; + vkFreeMemory(m_vkDevice, m_staging[slot].memory, nullptr); + m_staging[slot].memory = VK_NULL_HANDLE; } - m_stagingBufferSize[slot] = 0; + m_staging[slot].size = 0; VkBufferCreateInfo bufferInfo = {}; bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; bufferInfo.size = size; bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; - if (vkCreateBuffer(m_vkDevice, &bufferInfo, nullptr, &m_vkStagingBuffer[slot]) != VK_SUCCESS) + if (vkCreateBuffer(m_vkDevice, &bufferInfo, nullptr, &m_staging[slot].buffer) != VK_SUCCESS) { - m_vkStagingBuffer[slot] = VK_NULL_HANDLE; + m_staging[slot].buffer = VK_NULL_HANDLE; cerr << "ERROR: VulkanWindow: Failed to create staging buffer" << endl; return; } VkMemoryRequirements memRequirements; - vkGetBufferMemoryRequirements(m_vkDevice, m_vkStagingBuffer[slot], &memRequirements); + vkGetBufferMemoryRequirements(m_vkDevice, m_staging[slot].buffer, &memRequirements); VkMemoryAllocateInfo allocInfo = {}; allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; allocInfo.allocationSize = memRequirements.size; - allocInfo.memoryTypeIndex = findMemoryType(m_vkPhysicalDevice, memRequirements.memoryTypeBits, - VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); - if (allocInfo.memoryTypeIndex == UINT32_MAX) + const std::optional memoryTypeIndex = + findMemoryType(m_vkPhysicalDevice, memRequirements.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (!memoryTypeIndex) { cerr << "ERROR: VulkanWindow: No host-visible memory type for staging buffer" << endl; return; } + allocInfo.memoryTypeIndex = *memoryTypeIndex; - if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_vkStagingBufferMemory[slot]) != VK_SUCCESS) + if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_staging[slot].memory) != VK_SUCCESS) { - m_vkStagingBufferMemory[slot] = VK_NULL_HANDLE; + m_staging[slot].memory = VK_NULL_HANDLE; cerr << "ERROR: VulkanWindow: Failed to allocate staging buffer memory" << endl; return; } - if (vkBindBufferMemory(m_vkDevice, m_vkStagingBuffer[slot], m_vkStagingBufferMemory[slot], 0) != VK_SUCCESS) + if (vkBindBufferMemory(m_vkDevice, m_staging[slot].buffer, m_staging[slot].memory, 0) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: Failed to bind staging buffer memory" << endl; return; } - m_stagingBufferSize[slot] = size; + m_staging[slot].size = size; } void* data = nullptr; - if (vkMapMemory(m_vkDevice, m_vkStagingBufferMemory[slot], 0, size, 0, &data) != VK_SUCCESS) + if (vkMapMemory(m_vkDevice, m_staging[slot].memory, 0, size, 0, &data) != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: Failed to map staging buffer memory" << endl; return; } memcpy(data, pixels, size); - vkUnmapMemory(m_vkDevice, m_vkStagingBufferMemory[slot]); + vkUnmapMemory(m_vkDevice, m_staging[slot].memory); uint32_t imageIndex; if (diagPresent) @@ -2480,7 +2485,7 @@ namespace Rv diagTimer.start(); } VkResult result = - vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_vkImageAvailableSemaphore[slot], VK_NULL_HANDLE, &imageIndex); + vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, &imageIndex); if (diagPresent) { s_diagAcquireMs += diagTimer.elapsed() * 1000.0; @@ -2506,11 +2511,11 @@ namespace Rv if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) { - vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, UINT64_MAX); + vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); } - m_imagesInFlight[imageIndex] = m_vkFence[slot]; + m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; - vkResetFences(m_vkDevice, 1, &m_vkFence[slot]); + vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; vkResetCommandBuffer(cb, 0); @@ -2520,22 +2525,8 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - VkImageMemoryBarrier barrier = {}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; - barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; - barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barrier.image = m_vkSwapchainImages[imageIndex]; - barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - barrier.subresourceRange.baseMipLevel = 0; - barrier.subresourceRange.levelCount = 1; - barrier.subresourceRange.baseArrayLayer = 0; - barrier.subresourceRange.layerCount = 1; - barrier.srcAccessMask = 0; - barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - - vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); // The buffer is w x h, but the destination is bounded by the swapchain. VkBufferImageCopy region = {}; @@ -2550,36 +2541,31 @@ namespace Rv region.imageExtent = {std::min(static_cast(w), m_vkSwapchainExtent.width), std::min(static_cast(h), m_vkSwapchainExtent.height), 1}; - vkCmdCopyBufferToImage(cb, m_vkStagingBuffer[slot], m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, + vkCmdCopyBufferToImage(cb, m_staging[slot].buffer, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); - barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; - barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; - barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; - barrier.dstAccessMask = 0; - - vkCmdPipelineBarrier(cb, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, - &barrier); + transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); vkEndCommandBuffer(cb); VkSubmitInfo submitInfo = {}; submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - VkSemaphore waitSemaphores[] = {m_vkImageAvailableSemaphore[slot]}; - VkPipelineStageFlags waitStages[] = {VK_PIPELINE_STAGE_TRANSFER_BIT}; - submitInfo.waitSemaphoreCount = 1; - submitInfo.pWaitSemaphores = waitSemaphores; - submitInfo.pWaitDstStageMask = waitStages; + const std::array waitSemaphores = {m_frameSync[slot].imageAvailable}; + const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT}; + submitInfo.waitSemaphoreCount = static_cast(waitSemaphores.size()); + submitInfo.pWaitSemaphores = waitSemaphores.data(); + submitInfo.pWaitDstStageMask = waitStages.data(); submitInfo.commandBufferCount = 1; submitInfo.pCommandBuffers = &cb; - VkSemaphore signalSemaphores[] = {m_vkRenderFinished[imageIndex]}; - submitInfo.signalSemaphoreCount = 1; - submitInfo.pSignalSemaphores = signalSemaphores; + const std::array signalSemaphores = {m_vkRenderFinished[imageIndex]}; + submitInfo.signalSemaphoreCount = static_cast(signalSemaphores.size()); + submitInfo.pSignalSemaphores = signalSemaphores.data(); - VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_vkFence[slot]); + VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); if (submitResult != VK_SUCCESS) { - recoverFailedSubmit(slot, m_vkImageAvailableSemaphore[slot]); + recoverFailedSubmit(slot, m_frameSync[slot].imageAvailable); return; } @@ -2595,10 +2581,10 @@ namespace Rv VkPresentInfoKHR presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = signalSemaphores; - VkSwapchainKHR swapchains[] = {m_vkSwapchain}; - presentInfo.swapchainCount = 1; - presentInfo.pSwapchains = swapchains; + presentInfo.pWaitSemaphores = signalSemaphores.data(); + const std::array swapchains = {m_vkSwapchain}; + presentInfo.swapchainCount = static_cast(swapchains.size()); + presentInfo.pSwapchains = swapchains.data(); presentInfo.pImageIndices = &imageIndex; VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); @@ -2610,7 +2596,7 @@ namespace Rv // frame early; never for the passive output. if (maxFramesInFlight() == 1 && !isPassiveOutput()) { - vkWaitForFences(m_vkDevice, 1, &m_vkFence[slot], VK_TRUE, UINT64_MAX); + vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); } // See presentSharedImage() on SUBOPTIMAL. if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) @@ -2679,7 +2665,8 @@ namespace Rv // Close out retired slots (non-blocking; one frame of quantisation). for (uint32_t i = 0; i < kFramesInFlight; ++i) { - if (s_diagSlotArmed[i] && m_vkDevice && m_vkFence[i] && vkGetFenceStatus(m_vkDevice, m_vkFence[i]) == VK_SUCCESS) + if (s_diagSlotArmed[i] && m_vkDevice && m_frameSync[i].fence + && vkGetFenceStatus(m_vkDevice, m_frameSync[i].fence) == VK_SUCCESS) { s_diagEventToRetireMs += (diagNow() - s_diagSlotEventTime[i]) * 1000.0; ++s_diagEventToRetireSamples; @@ -2798,16 +2785,16 @@ namespace Rv { if (++s_diagFrames >= 60) { - const double n = double(s_diagFrames); - const double loopMs = s_diagLoopMs / n; + const double frames = static_cast(s_diagFrames); + const double loopMs = s_diagLoopMs / frames; cout << "INFO: VulkanWindow frame avg over " << s_diagFrames << " [depth=" << maxFramesInFlight() - << " tiling=" << tilingName(m_sharedImageInfo[0].tiling) << "]" - << ": session->render()=" << (s_diagRenderMs / n) << "ms mainPresent=" << (s_diagMainPresentMs / n) - << "ms outputPresent=" << (s_diagOutPresentMs / n) - << "ms total=" << ((s_diagRenderMs + s_diagMainPresentMs + s_diagOutPresentMs) / n) - << "ms [mainPresent breakdown: fenceWait=" << (s_diagFenceWaitMs / n) << "ms acquire=" << (s_diagAcquireMs / n) - << "ms]" - << " postRender=" << (s_diagPostRenderMs / n) << "ms frameInterval=" << loopMs << "ms (" + << " tiling=" << tilingName(m_shared[0].info.tiling) << "]" + << ": session->render()=" << (s_diagRenderMs / frames) << "ms mainPresent=" << (s_diagMainPresentMs / frames) + << "ms outputPresent=" << (s_diagOutPresentMs / frames) + << "ms total=" << ((s_diagRenderMs + s_diagMainPresentMs + s_diagOutPresentMs) / frames) + << "ms [mainPresent breakdown: fenceWait=" << (s_diagFenceWaitMs / frames) + << "ms acquire=" << (s_diagAcquireMs / frames) << "ms]" + << " postRender=" << (s_diagPostRenderMs / frames) << "ms frameInterval=" << loopMs << "ms (" << (loopMs > 0.0 ? 1000.0 / loopMs : 0.0) << " fps)" << " pointer: events=" << s_diagPointerEvents << " handler=" << (s_diagPointerEvents ? s_diagPointerHandlerMs / s_diagPointerEvents : 0.0) @@ -3004,15 +2991,16 @@ namespace Rv if (event->type() == QEvent::Resize) { - QResizeEvent* e = static_cast(event); + QResizeEvent* resize = static_cast(event); if (!isVisible()) { return true; } - if (e->oldSize().width() != -1 && e->oldSize().height() != -1) + if (resize->oldSize().width() != -1 && resize->oldSize().height() != -1) { ostringstream contents; - contents << e->oldSize().width() << " " << e->oldSize().height() << "|" << e->size().width() << " " << e->size().height(); + contents << resize->oldSize().width() << " " << resize->oldSize().height() << "|" << resize->size().width() << " " + << resize->size().height(); if (m_doc && session) { session->userGenericEvent("view-resized", contents.str()); diff --git a/src/lib/ip/IPCore/IPCore/ImageRenderer.h b/src/lib/ip/IPCore/IPCore/ImageRenderer.h index 28eb09007..53054ed00 100644 --- a/src/lib/ip/IPCore/IPCore/ImageRenderer.h +++ b/src/lib/ip/IPCore/IPCore/ImageRenderer.h @@ -682,11 +682,6 @@ namespace IPCore static bool debugGpu() { return m_debugGpu; } - // Deprecated: use debugGpu() instead. - static void reportGL(bool b) { debugGpu(b); } - - static bool reportGL() { return debugGpu(); } - static void setPBOs(bool b) { m_pixelBuffers = b; } static bool hasFloatFormats() { return m_floatFormats; } From f612001963871fec7809d1a8501d5e6038eddd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Tue, 29 Sep 2026 10:52:21 -0400 Subject: [PATCH 40/48] refactor(vulkan): carry the forcedTilingRequested optional into VulkanWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - forcedTilingRequested() returns std::optional instead of a bool plus an out-parameter - QTVulkanVideoDevice::setEventWidget uses the same ternary as the constructor Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 6 +---- src/lib/app/RvCommon/VulkanWindow.cpp | 24 +++++++++----------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 997d3034e..3708ea960 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -242,11 +242,7 @@ namespace Rv void QTVulkanVideoDevice::setEventWidget(QWidget* widget) { m_eventWidget = widget; - m_translator.reset(); - if (widget) - { - m_translator = std::make_unique(this, widget); - } + m_translator = widget ? std::make_unique(this, widget) : nullptr; } //-------------------------------------------------------------------------- diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 502ee78fe..00b6a22ac 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1162,13 +1162,13 @@ namespace Rv } } - // False when RV_VULKAN_FORCE_TILING is unset or unrecognized. - bool forcedTilingRequested(VkImageTiling& out) + // nullopt when RV_VULKAN_FORCE_TILING is unset or unrecognized. + std::optional forcedTilingRequested() { const char* value = getenv("RV_VULKAN_FORCE_TILING"); if (!value) { - return false; + return std::nullopt; } std::string lowered(value); @@ -1177,18 +1177,16 @@ namespace Rv if (lowered == "optimal") { - out = VK_IMAGE_TILING_OPTIMAL; - return true; + return VK_IMAGE_TILING_OPTIMAL; } if (lowered == "linear") { - out = VK_IMAGE_TILING_LINEAR; - return true; + return VK_IMAGE_TILING_LINEAR; } cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING='" << value << "' is not recognized (expected 'optimal' or 'linear'); " << "ignoring it and using the negotiated tiling" << endl; - return false; + return std::nullopt; } // Resolved dynamically (with the KHR alias) so a 1.0-only loader @@ -1381,20 +1379,20 @@ namespace Rv } // Overrides last, so the record reports both values. - VkImageTiling forcedTiling = VK_IMAGE_TILING_LINEAR; - if (forcedTilingRequested(forcedTiling) && forcedTiling != cfg.tiling) + const std::optional forcedTiling = forcedTilingRequested(); + if (forcedTiling && *forcedTiling != cfg.tiling) { VkExternalMemoryFeatureFlags features = 0; - if (probeTiling(forcedTiling, features)) + if (probeTiling(*forcedTiling, features)) { cfg.tilingOverridden = true; - cfg.tiling = forcedTiling; + cfg.tiling = *forcedTiling; cfg.externalFeatures = features; cfg.dedicatedAllocation = (features & VK_EXTERNAL_MEMORY_FEATURE_DEDICATED_ONLY_BIT) != 0; } else { - cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING=" << tilingName(forcedTiling) + cout << "WARNING: VulkanWindow: RV_VULKAN_FORCE_TILING=" << tilingName(*forcedTiling) << " refused -- the driver does not report it as exportable; using the negotiated " << tilingName(cfg.tiling) << endl; } } From 2e925d6a451d89f7c575ed63d08ca5bf5630ea61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Tue, 29 Sep 2026 16:15:42 -0400 Subject: [PATCH 41/48] fix(vulkan): address self-review findings on the presentation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getSharedImageInfo() checks interop support and blit support before touching the slot, and remembers blit/row-pitch failures until the next swapchain rebuild, so the CPU fallback no longer runs vkDeviceWaitIdle every frame. - releaseVulkanResources() resets the interop negotiation, since the next initVulkan() may pick a different physical device. - The presentation record is re-emitted on the OpenGL fallback. - QTVulkanVideoDevice reports ZeroCopy only after a successful blit, reports CpuReadback when the blit fails, falls back to the CPU path on a failed Linux dup(), reports missing GL extensions before a GPU mismatch, and skips GL deletes when makeCurrent() fails. - Remove unused QTVulkanVideoDevice::setEventWidget, m_eventWidget and m_isOpen, VulkanView::absolutePosition and devicePixelRatio, GLContextScope::hasContext, and the unused keyevent local. - using instead of typedef for the Windows GL entry points; static_cast instead of C casts in RvDocument; constexpr GLenum / std::array instead of C arrays; range-based for and std::find in IPGraph. - std::unique_ptr for QTGLVideoDevice's teardown surface and VulkanDesktopVideoDevice's view (explicit reset kept in close()), and make_unique + release() for the intentionally leaked QVulkanInstance and GLContextScope fallback context. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 7 +- src/lib/app/RvCommon/MuUICommands.cpp | 5 +- src/lib/app/RvCommon/QTGLVideoDevice.cpp | 10 +-- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 88 +++++++++---------- .../app/RvCommon/RvCommon/QTGLVideoDevice.h | 3 +- .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 4 - .../RvCommon/VulkanDesktopVideoDevice.h | 4 +- src/lib/app/RvCommon/RvCommon/VulkanView.h | 4 - src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 3 + src/lib/app/RvCommon/RvDocument.cpp | 6 +- .../app/RvCommon/VulkanDesktopVideoDevice.cpp | 7 +- src/lib/app/RvCommon/VulkanView.cpp | 18 ---- src/lib/app/RvCommon/VulkanWindow.cpp | 80 +++++++++-------- src/lib/graphics/TwkGLF/GLContextScope.cpp | 17 ++-- .../graphics/TwkGLF/TwkGLF/GLContextScope.h | 7 +- src/lib/ip/IPCore/IPGraph.cpp | 22 ++--- 16 files changed, 123 insertions(+), 162 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index 734010418..f0086539b 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -35,6 +35,7 @@ #include #include +#include #include // #define DEBUG_NO_FULLSCREEN @@ -1078,9 +1079,9 @@ namespace Rv // Null when the reported profile path is gone or unreadable. if (cmsHPROFILE profile = cmsOpenProfileFromFile(path.data(), "r")) { - char desc[256] = {0}; - cmsGetProfileInfoASCII(profile, cmsInfoDescription, "en", "US", desc, sizeof(desc)); - m_colorProfile.description = desc; + std::array desc{}; + cmsGetProfileInfoASCII(profile, cmsInfoDescription, "en", "US", desc.data(), desc.size()); + m_colorProfile.description = desc.data(); cmsCloseProfile(profile); } } diff --git a/src/lib/app/RvCommon/MuUICommands.cpp b/src/lib/app/RvCommon/MuUICommands.cpp index c284fa8d5..95ac15662 100644 --- a/src/lib/app/RvCommon/MuUICommands.cpp +++ b/src/lib/app/RvCommon/MuUICommands.cpp @@ -69,6 +69,7 @@ #include #include #include +#include #include #include // WINDOWS NEEDS THIS LAST // #include @@ -406,8 +407,8 @@ namespace Rv if (ix >= 0 && iy >= 0 && static_cast(ix) < device->width() && static_cast(iy) < device->height()) { device->makeCurrent(); - GLubyte rgba[4] = {}; - glReadPixels(ix, iy, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, rgba); + std::array rgba{}; + glReadPixels(ix, iy, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); v[0] = static_cast(rgba[0]) / 255.0f; v[1] = static_cast(rgba[1]) / 255.0f; v[2] = static_cast(rgba[2]) / 255.0f; diff --git a/src/lib/app/RvCommon/QTGLVideoDevice.cpp b/src/lib/app/RvCommon/QTGLVideoDevice.cpp index da4be93db..3fc90d160 100644 --- a/src/lib/app/RvCommon/QTGLVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTGLVideoDevice.cpp @@ -74,11 +74,7 @@ namespace Rv assert(view); } - QTGLVideoDevice::~QTGLVideoDevice() - { - delete m_translator; - delete m_teardownSurface; - } + QTGLVideoDevice::~QTGLVideoDevice() { delete m_translator; } void QTGLVideoDevice::setWidget(QOpenGLWidget* widget) { @@ -121,7 +117,7 @@ namespace Rv // Needs a live context to copy the format from. if (!m_teardownSurface && m_window->context()) { - m_teardownSurface = new QOffscreenSurface(); + m_teardownSurface = std::make_unique(); m_teardownSurface->setFormat(m_window->context()->format()); m_teardownSurface->create(); } @@ -146,7 +142,7 @@ namespace Rv TWK_GLDEBUG; } else if (m_window && m_window->context() && m_teardownSurface && m_teardownSurface->isValid() - && m_window->context()->makeCurrent(m_teardownSurface)) + && m_window->context()->makeCurrent(m_teardownSurface.get())) { // Surface gone, context alive: GL deletion only needs a current context. TWK_GLDEBUG; diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 3708ea960..28eed23e1 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #ifdef PLATFORM_WINDOWS // WIN32_LEAN_AND_MEAN prevents from including the legacy // , which otherwise collides with the already @@ -86,21 +87,21 @@ #endif #ifdef PLATFORM_WINDOWS -// The bundled Windows GLEW (2.3.0) does not declare the EXT_memory_object / +// The bundled src/pub/glew does not declare the EXT_memory_object / // EXT_semaphore (+ _win32) entry points, so resolve them via wglGetProcAddress. // If any are missing, presentation falls back to the CPU path. -typedef void(GLAPIENTRY* PFNGLCREATEMEMORYOBJECTSEXTPROC_RV)(GLsizei n, GLuint* memoryObjects); -typedef void(GLAPIENTRY* PFNGLDELETEMEMORYOBJECTSEXTPROC_RV)(GLsizei n, const GLuint* memoryObjects); -typedef void(GLAPIENTRY* PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV)(GLuint memoryObject, GLenum pname, const GLint* params); -typedef void(GLAPIENTRY* PFNGLTEXSTORAGEMEM2DEXTPROC_RV)(GLenum target, GLsizei levels, GLenum internalFormat, GLsizei width, +using PFNGLCREATEMEMORYOBJECTSEXTPROC_RV = void(GLAPIENTRY*)(GLsizei n, GLuint* memoryObjects); +using PFNGLDELETEMEMORYOBJECTSEXTPROC_RV = void(GLAPIENTRY*)(GLsizei n, const GLuint* memoryObjects); +using PFNGLMEMORYOBJECTPARAMETERIVEXTPROC_RV = void(GLAPIENTRY*)(GLuint memoryObject, GLenum pname, const GLint* params); +using PFNGLTEXSTORAGEMEM2DEXTPROC_RV = void(GLAPIENTRY*)(GLenum target, GLsizei levels, GLenum internalFormat, GLsizei width, GLsizei height, GLuint memory, GLuint64 offset); -typedef void(GLAPIENTRY* PFNGLIMPORTMEMORYWIN32HANDLEEXTPROC_RV)(GLuint memory, GLuint64 size, GLenum handleType, void* handle); -typedef void(GLAPIENTRY* PFNGLGENSEMAPHORESEXTPROC_RV)(GLsizei n, GLuint* semaphores); -typedef void(GLAPIENTRY* PFNGLDELETESEMAPHORESEXTPROC_RV)(GLsizei n, const GLuint* semaphores); -typedef void(GLAPIENTRY* PFNGLIMPORTSEMAPHOREWIN32HANDLEEXTPROC_RV)(GLuint semaphore, GLenum handleType, void* handle); -typedef void(GLAPIENTRY* PFNGLWAITSEMAPHOREEXTPROC_RV)(GLuint semaphore, GLuint numBufferBarriers, const GLuint* buffers, +using PFNGLIMPORTMEMORYWIN32HANDLEEXTPROC_RV = void(GLAPIENTRY*)(GLuint memory, GLuint64 size, GLenum handleType, void* handle); +using PFNGLGENSEMAPHORESEXTPROC_RV = void(GLAPIENTRY*)(GLsizei n, GLuint* semaphores); +using PFNGLDELETESEMAPHORESEXTPROC_RV = void(GLAPIENTRY*)(GLsizei n, const GLuint* semaphores); +using PFNGLIMPORTSEMAPHOREWIN32HANDLEEXTPROC_RV = void(GLAPIENTRY*)(GLuint semaphore, GLenum handleType, void* handle); +using PFNGLWAITSEMAPHOREEXTPROC_RV = void(GLAPIENTRY*)(GLuint semaphore, GLuint numBufferBarriers, const GLuint* buffers, GLuint numTextureBarriers, const GLuint* textures, const GLenum* dstLayouts); -typedef void(GLAPIENTRY* PFNGLSIGNALSEMAPHOREEXTPROC_RV)(GLuint semaphore, GLuint numBufferBarriers, const GLuint* buffers, +using PFNGLSIGNALSEMAPHOREEXTPROC_RV = void(GLAPIENTRY*)(GLuint semaphore, GLuint numBufferBarriers, const GLuint* buffers, GLuint numTextureBarriers, const GLuint* textures, const GLenum* srcLayouts); namespace @@ -205,7 +206,6 @@ namespace Rv QTVulkanVideoDevice::QTVulkanVideoDevice(VideoModule* module, const string& name, VulkanWindow* window, QWidget* eventWidget) : TwkGLF::GLVideoDevice(module, name, VideoDevice::ImageOutput | VideoDevice::ProvidesSync | VideoDevice::SubWindow) , m_window(window) - , m_eventWidget(eventWidget) , m_translator(eventWidget ? std::make_unique(this, eventWidget) : nullptr) { assert(window); @@ -215,9 +215,8 @@ namespace Rv { // Every slot, unconditionally: the imports live in the global share // group and would outlive m_glContext, pinning the Vulkan memory. - if (m_glContext && m_offscreenSurface) + if (m_glContext && m_offscreenSurface && m_glContext->makeCurrent(m_offscreenSurface.get())) { - m_glContext->makeCurrent(m_offscreenSurface.get()); m_fbo.reset(); if (m_fboColorTex) { @@ -239,12 +238,6 @@ namespace Rv m_translator.reset(); } - void QTVulkanVideoDevice::setEventWidget(QWidget* widget) - { - m_eventWidget = widget; - m_translator = widget ? std::make_unique(this, widget) : nullptr; - } - //-------------------------------------------------------------------------- void QTVulkanVideoDevice::ensureGLContext() const @@ -503,7 +496,10 @@ namespace Rv return; } - m_glContext->makeCurrent(m_offscreenSurface.get()); + if (!m_glContext->makeCurrent(m_offscreenSurface.get())) + { + return; + } for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) { cleanupSharedGLObjects(i); @@ -685,14 +681,13 @@ namespace Rv } #ifdef PLATFORM_WINDOWS - const bool glInteropAvailable = - !forceCpuPresentation() && !m_interopDisabled && loadGLInteropExtensions() && glDeviceMatchesVulkan(); - const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; + const bool glExtensionsAvailable = loadGLInteropExtensions(); #else - const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && GLEW_EXT_memory_object && GLEW_EXT_semaphore - && GLEW_EXT_memory_object_fd && GLEW_EXT_semaphore_fd && glDeviceMatchesVulkan(); - const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; + const bool glExtensionsAvailable = + GLEW_EXT_memory_object && GLEW_EXT_semaphore && GLEW_EXT_memory_object_fd && GLEW_EXT_semaphore_fd; #endif + const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && glExtensionsAvailable && glDeviceMatchesVulkan(); + const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; // Logged on every transition: the first call can precede swapchain // creation. @@ -721,13 +716,13 @@ namespace Rv { reason = "an earlier GL call on the interop path failed; the device is demoted for the rest of the session"; } - else if (!glDeviceMatchesVulkan()) + else if (!glExtensionsAvailable) { - reason = "the GL context and the Vulkan device are different GPUs, so external-memory interop is unsafe"; + reason = "the GL driver does not expose the EXT_memory_object / EXT_semaphore interop entry points"; } - else if (!glInteropAvailable) + else if (!glDeviceMatchesVulkan()) { - reason = "the GL driver does not expose the EXT_memory_object / EXT_semaphore interop entry points"; + reason = "the GL context and the Vulkan device are different GPUs, so external-memory interop is unsafe"; } else { @@ -767,12 +762,19 @@ namespace Rv glImportMemoryWin32HandleEXT(m_glShared[slot].memoryObject, sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_WIN32_EXT, static_cast(sharedInfo->memoryHandle)); #else + const auto dupFailed = [&](std::string_view fdName) + { + cerr << "ERROR: QTVulkanVideoDevice: dup(" << fdName << ") failed." << endl; + cleanupSharedGLObjects(slot); + m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "dup() of a shared image FD failed"); + presentCpuFallback(w, h); + }; + // Duplicate the FD because glImportMemoryFdEXT takes ownership int memFd = dup(sharedInfo->memoryFd); if (memFd == -1) { - cerr << "ERROR: QTVulkanVideoDevice: dup(memoryFd) failed." << endl; - cleanupSharedGLObjects(slot); + dupFailed("memoryFd"); return; } glImportMemoryFdEXT(m_glShared[slot].memoryObject, sharedInfo->size, GL_HANDLE_TYPE_OPAQUE_FD_EXT, memFd); @@ -802,8 +804,7 @@ namespace Rv int glReadyFd = dup(sharedInfo->glReadySemaphoreFd); if (glReadyFd == -1) { - cerr << "ERROR: QTVulkanVideoDevice: dup(glReadySemaphoreFd) failed." << endl; - cleanupSharedGLObjects(slot); + dupFailed("glReadySemaphoreFd"); return; } glImportSemaphoreFdEXT(m_glShared[slot].glReadySemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, glReadyFd); @@ -811,8 +812,7 @@ namespace Rv int vkReadyFd = dup(sharedInfo->vkReadySemaphoreFd); if (vkReadyFd == -1) { - cerr << "ERROR: QTVulkanVideoDevice: dup(vkReadySemaphoreFd) failed." << endl; - cleanupSharedGLObjects(slot); + dupFailed("vkReadySemaphoreFd"); return; } glImportSemaphoreFdEXT(m_glShared[slot].vkReadySemaphore, GL_HANDLE_TYPE_OPAQUE_FD_EXT, vkReadyFd); @@ -837,16 +837,14 @@ namespace Rv m_window->reportGLImportState(sharedInfo->tiling, sharedInfo->dedicatedAllocation); } - m_window->reportPresentPath(VulkanWindow::PresentPath::ZeroCopy, std::string()); - // session->render() can leave errors pending; drain them so the check // below sees only the wait/blit/signal sequence's errors. while (glGetError() != GL_NO_ERROR) { } - GLuint waitSrcLayouts[] = {GL_LAYOUT_TRANSFER_SRC_EXT}; - glWaitSemaphoreEXT(m_glShared[slot].vkReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, waitSrcLayouts); + constexpr GLenum waitSrcLayout = GL_LAYOUT_TRANSFER_SRC_EXT; + glWaitSemaphoreEXT(m_glShared[slot].vkReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, &waitSrcLayout); GLuint readFbo = fbo->fboID(); if (!m_glShared[slot].drawFbo) @@ -867,8 +865,8 @@ namespace Rv glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, readFbo); // restore - GLuint signalDstLayouts[] = {GL_LAYOUT_COLOR_ATTACHMENT_EXT}; - glSignalSemaphoreEXT(m_glShared[slot].glReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, signalDstLayouts); + constexpr GLenum signalDstLayout = GL_LAYOUT_COLOR_ATTACHMENT_EXT; + glSignalSemaphoreEXT(m_glShared[slot].glReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, &signalDstLayout); glFlush(); @@ -878,10 +876,12 @@ namespace Rv { cleanupSharedGLObjects(i); } + m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "GL blit into the shared image raised a GL error"); presentCpuFallback(w, h); return; } + m_window->reportPresentPath(VulkanWindow::PresentPath::ZeroCopy, std::string()); m_window->presentSharedImage(); } @@ -951,7 +951,6 @@ namespace Rv { m_window->show(); } - m_isOpen = true; } void QTVulkanVideoDevice::close() @@ -960,7 +959,6 @@ namespace Rv { m_window->hide(); } - m_isOpen = false; } bool QTVulkanVideoDevice::isOpen() const diff --git a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h index 1b8ababd7..9f40ef61c 100644 --- a/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTGLVideoDevice.h @@ -8,6 +8,7 @@ #ifndef __RvCommon__QTGLVideoDevice__h__ #define __RvCommon__QTGLVideoDevice__h__ #include +#include #include #include #include @@ -145,7 +146,7 @@ namespace Rv // native surface but not its context (shutdown). Created while the // window is healthy: QOffscreenSurface needs the platform plugin. // - mutable QOffscreenSurface* m_teardownSurface{nullptr}; + mutable std::unique_ptr m_teardownSurface; QTTranslator* m_translator; }; diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index 1c923c636..a415148bb 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -45,8 +45,6 @@ namespace Rv QTVulkanVideoDevice(const QTVulkanVideoDevice&) = delete; QTVulkanVideoDevice& operator=(const QTVulkanVideoDevice&) = delete; - void setEventWidget(QWidget* widget); - void resetInteropDeviceMatch() const { m_glVulkanDeviceMatch = -1; } const QTTranslator& translator() const { return *m_translator; } @@ -100,13 +98,11 @@ namespace Rv // The window container owns the window, so Qt can delete it // independently of this device. QPointer m_window; - QWidget* m_eventWidget; std::unique_ptr m_translator; float m_devicePixelRatio{1.0f}; int m_x{0}; int m_y{0}; float m_refresh{-1.0f}; - bool m_isOpen{false}; mutable std::unique_ptr m_glContext; mutable std::unique_ptr m_offscreenSurface; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h index e29effc3e..1250a7009 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h @@ -8,6 +8,8 @@ #include +#include + namespace Rv { class VulkanView; @@ -39,7 +41,7 @@ namespace Rv private: // Owns the QTVulkanVideoDevice used as the base m_viewDevice. - VulkanView* m_vulkanView{nullptr}; + std::unique_ptr m_vulkanView; }; } // namespace Rv diff --git a/src/lib/app/RvCommon/RvCommon/VulkanView.h b/src/lib/app/RvCommon/RvCommon/VulkanView.h index eb8f65078..dcd67d69c 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanView.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanView.h @@ -43,10 +43,6 @@ namespace Rv bool firstPaintCompleted() const; - void absolutePosition(int& x, int& y) const; - - float devicePixelRatio() const; - void setContentSize(int w, int h) { m_csize = QSize(w, h); } void setMinimumContentSize(int w, int h) { m_msize = QSize(w, h); } diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index 6b3e83a86..caa7b87b5 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -320,6 +320,9 @@ namespace Rv InteropConfig m_interopConfig; bool m_interopNegotiated{false}; + // Set when the shared image cannot be used with the current swapchain + // (no blit support, unaligned row pitch); cleared on swapchain rebuild. + bool m_sharedImageUnusable{false}; bool m_recordEmitted{false}; PresentPath m_presentPath{PresentPath::Undetermined}; diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index 37f921404..cc0ca5094 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -219,7 +219,7 @@ namespace Rv { if (ImageRenderer::debugGpu()) { - cout << "INFO: RvDocument: supports10BitPresentation()=" << (useVulkan ? "true" : "false") << endl; + cout << "INFO: RvDocument: shouldUseVulkanPresentation()=" << (useVulkan ? "true" : "false") << endl; } if (!useVulkan) { @@ -258,7 +258,7 @@ namespace Rv else { RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); + RvDocument* rvDoc = static_cast(s->opaquePointer()); // view() is null if the front document is on Vulkan. QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; m_glView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), @@ -935,7 +935,7 @@ namespace Rv else { RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); + RvDocument* rvDoc = static_cast(s->opaquePointer()); QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; newGLView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, true, fallbackRedBits, fallbackGreenBits, fallbackBlueBits, diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 8746f2308..70a5d7402 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -37,7 +37,7 @@ namespace Rv } // A null doc makes the view passive (see VulkanWindow.h). - m_vulkanView = new VulkanView(/*doc*/ nullptr, /*parent*/ nullptr, /*noResize*/ true); + m_vulkanView = std::make_unique(/*doc*/ nullptr, /*parent*/ nullptr, /*noResize*/ true); // The view owns this device. There is no QOpenGLWidget, so this // bypasses setViewWidget() and installs the translator by hand. @@ -48,7 +48,7 @@ namespace Rv m_vulkanView->setWindowFlag(Qt::WindowDoesNotAcceptFocus, true); // Inert, created for parity with the base class's setViewWidget(). - m_translator = new QTTranslator(this, m_vulkanView); + m_translator = new QTTranslator(this, m_vulkanView.get()); // Place before show(): the swapchain is built on first expose. const QRect screenRect = screenGeometry(); @@ -80,8 +80,7 @@ namespace Rv setViewDevice(nullptr); - delete m_vulkanView; - m_vulkanView = nullptr; + m_vulkanView.reset(); delete m_translator; m_translator = nullptr; diff --git a/src/lib/app/RvCommon/VulkanView.cpp b/src/lib/app/RvCommon/VulkanView.cpp index e64c8644a..d4b8f6406 100644 --- a/src/lib/app/RvCommon/VulkanView.cpp +++ b/src/lib/app/RvCommon/VulkanView.cpp @@ -269,24 +269,6 @@ namespace Rv bool VulkanView::firstPaintCompleted() const { return m_vulkanWindow && m_vulkanWindow->firstPaintCompleted(); } - void VulkanView::absolutePosition(int& x, int& y) const - { - if (m_vulkanWindow) - { - m_vulkanWindow->absolutePosition(x, y); - return; - } - - const QPoint gp = mapToGlobal(QPoint(0, 0)); - x = gp.x(); - y = gp.y(); - } - - float VulkanView::devicePixelRatio() const - { - return m_vulkanWindow ? m_vulkanWindow->devicePixelRatioF() : static_cast(devicePixelRatioF()); - } - bool VulkanView::supports10BitPresentation() { return VulkanWindow::supports10BitPresentation(); } } // namespace Rv diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 00b6a22ac..3369238d9 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -311,16 +312,15 @@ namespace Rv { static QVulkanInstance* instance = []() -> QVulkanInstance* { - auto* inst = new QVulkanInstance(); + auto inst = std::make_unique(); // 1.1 for vkGetPhysicalDeviceProperties2 (device UUID matching). inst->setApiVersion(QVersionNumber(1, 1)); if (!inst->create()) { cerr << "ERROR: VulkanWindow: shared QVulkanInstance create failed" << endl; - delete inst; return nullptr; } - return inst; + return inst.release(); }(); return instance; } @@ -573,7 +573,9 @@ namespace Rv } m_glFallbackRequested = true; - // Logged explicitly since OpenGL forgoes 10-bit. + // Logged explicitly since OpenGL forgoes 10-bit, even if a record was + // already emitted for an earlier path. + m_recordEmitted = false; reportPresentPath(PresentPath::OpenGL, m_presentPathReason.empty() ? std::string("Vulkan presentation could not be established") : m_presentPathReason); @@ -635,6 +637,10 @@ namespace Rv m_vkSwapchainImages.clear(); m_currentFrame = 0; + m_interopNegotiated = false; + m_interopConfig = {}; + m_sharedImageUnusable = false; + m_initialized = false; m_initializedHandle = nullptr; } @@ -892,6 +898,7 @@ namespace Rv } m_vkSwapchainFormat = surfaceFormat.format; + m_sharedImageUnusable = false; m_vkSwapchainExtent = capabilities.currentExtent; if (m_vkSwapchainExtent.width == std::numeric_limits::max()) @@ -1718,11 +1725,21 @@ namespace Rv const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) { - if (!m_vkDevice || !m_externalInteropSupported) + if (!m_vkDevice || !m_externalInteropSupported || m_sharedImageUnusable) { return nullptr; } + if (!m_interopConfig.supported) + { + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: getSharedImageInfo: interop unavailable (" << m_interopConfig.rejectReason + << "); using the CPU readback path" << endl; + } + return nullptr; + } + const uint32_t slot = m_currentFrame; SharedImageInfo& info = m_shared[slot].info; @@ -1752,19 +1769,32 @@ namespace Rv const int capW = std::max({w, screenW, m_shared[slot].capacityW}); const int capH = std::max({h, screenH, m_shared[slot].capacityH}); - cleanupSharedImage(slot); + const bool optimalTiling = m_interopConfig.tiling == VK_IMAGE_TILING_OPTIMAL; - if (!m_interopConfig.supported) + // A format mismatch needs a blit; without blit support, use the CPU + // fallback. + if (m_vkSwapchainFormat != VK_FORMAT_A2B10G10R10_UNORM_PACK32) { - if (ImageRenderer::debugGpu()) + VkFormatProperties srcProps = {}; + VkFormatProperties dstProps = {}; + vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, VK_FORMAT_A2B10G10R10_UNORM_PACK32, &srcProps); + vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, m_vkSwapchainFormat, &dstProps); + const VkFormatFeatureFlags srcFeatures = optimalTiling ? srcProps.optimalTilingFeatures : srcProps.linearTilingFeatures; + const bool blitOk = + (srcFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT) && (dstProps.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT); + if (!blitOk) { - cout << "INFO: VulkanWindow: getSharedImageInfo: interop unavailable (" << m_interopConfig.rejectReason - << "); using the CPU readback path" << endl; + if (ImageRenderer::debugGpu()) + { + cout << "INFO: VulkanWindow: GPU interop unavailable for " << formatName(m_vkSwapchainFormat) + << " swapchain (blit unsupported); using CPU fallback." << endl; + } + m_sharedImageUnusable = true; + return nullptr; } - return nullptr; } - const bool optimalTiling = m_interopConfig.tiling == VK_IMAGE_TILING_OPTIMAL; + cleanupSharedImage(slot); // Unconditional: fires about once per slot per session. cout << "INFO: VulkanWindow: getSharedImageInfo: (re)allocating shared image slot " << slot << " capacity " << capW << "x" << capH @@ -1800,29 +1830,6 @@ namespace Rv return nullptr; } - // A format mismatch needs a blit; without blit support, use the CPU - // fallback. - if (m_vkSwapchainFormat != VK_FORMAT_A2B10G10R10_UNORM_PACK32) - { - VkFormatProperties srcProps = {}; - VkFormatProperties dstProps = {}; - vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, VK_FORMAT_A2B10G10R10_UNORM_PACK32, &srcProps); - vkGetPhysicalDeviceFormatProperties(m_vkPhysicalDevice, m_vkSwapchainFormat, &dstProps); - const VkFormatFeatureFlags srcFeatures = optimalTiling ? srcProps.optimalTilingFeatures : srcProps.linearTilingFeatures; - const bool blitOk = - (srcFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT) && (dstProps.optimalTilingFeatures & VK_FORMAT_FEATURE_BLIT_DST_BIT); - if (!blitOk) - { - if (ImageRenderer::debugGpu()) - { - cout << "INFO: VulkanWindow: GPU interop unavailable for " << formatName(m_vkSwapchainFormat) - << " swapchain (blit unsupported); using CPU fallback." << endl; - } - cleanupSharedImage(slot); - return nullptr; - } - } - // vkGetImageSubresourceLayout is only valid for LINEAR tiling. if (optimalTiling) { @@ -1842,6 +1849,7 @@ namespace Rv { // Not an integer pixel width; use the CPU fallback. cleanupSharedImage(slot); + m_sharedImageUnusable = true; return nullptr; } info.strideWidth = static_cast(layout.rowPitch / 4); @@ -2906,7 +2914,6 @@ namespace Rv return QWindow::event(event); } - bool keyevent = false; Rv::Session* session = m_doc ? m_doc->session() : nullptr; if (m_stopProcessingEvents) @@ -2949,7 +2956,6 @@ namespace Rv if (QKeyEvent* kevent = dynamic_cast(event)) { - keyevent = true; if (m_lastKey == kevent->key() && ((m_lastKeyType == QEvent::ShortcutOverride && kevent->type() == QEvent::KeyPress) || (m_lastKeyType == kevent->type()))) { diff --git a/src/lib/graphics/TwkGLF/GLContextScope.cpp b/src/lib/graphics/TwkGLF/GLContextScope.cpp index 2dd370c48..50bfda919 100644 --- a/src/lib/graphics/TwkGLF/GLContextScope.cpp +++ b/src/lib/graphics/TwkGLF/GLContextScope.cpp @@ -16,6 +16,7 @@ #include #include +#include namespace TwkGLF { @@ -63,31 +64,28 @@ namespace TwkGLF return false; } - QOpenGLContext* context = new QOpenGLContext; + auto context = std::make_unique(); context->setShareContext(share); context->setFormat(share->format()); if (!context->create() || !context->shareContext()) { - delete context; reportNoContext("shared context creation failed"); return false; } - QOffscreenSurface* surface = new QOffscreenSurface; + auto surface = std::make_unique(); surface->setFormat(context->format()); surface->create(); if (!surface->isValid()) { - delete surface; - delete context; reportNoContext("offscreen surface creation failed"); return false; } - s_fallbackContext = context; - s_fallbackSurface = surface; + s_fallbackContext = context.release(); + s_fallbackSurface = surface.release(); return true; } @@ -134,15 +132,12 @@ namespace TwkGLF } // namespace GLContextScope::GLContextScope(const GLVideoDevice* device) - : m_acquired(false) - , m_hasContext(false) { // // Never displace a live context, including a natively bound one. // if (twkGlAnyContextIsCurrent()) { - m_hasContext = true; return; } @@ -153,7 +148,6 @@ namespace TwkGLF if (twkGlAnyContextIsCurrent()) { m_acquired = true; - m_hasContext = true; return; } } @@ -161,7 +155,6 @@ namespace TwkGLF if (makeFallbackCurrent()) { m_acquired = true; - m_hasContext = true; } } diff --git a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h index db5af5957..dbe4d05aa 100644 --- a/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h +++ b/src/lib/graphics/TwkGLF/TwkGLF/GLContextScope.h @@ -26,7 +26,7 @@ namespace TwkGLF // // Destruction makes nothing current again if this scope acquired. If no // context can be resolved, the scope reports once and does nothing; it - // never throws. Check hasContext() if needed. + // never throws. // // Limitation: an already-current context is kept even when it is not the // supplied device's, since a natively bound context cannot be restored @@ -42,11 +42,8 @@ namespace TwkGLF GLContextScope(const GLContextScope&) = delete; GLContextScope& operator=(const GLContextScope&) = delete; - bool hasContext() const { return m_hasContext; } - private: - bool m_acquired; // did this scope make something current? - bool m_hasContext; + bool m_acquired{false}; // did this scope make something current? }; } // namespace TwkGLF diff --git a/src/lib/ip/IPCore/IPGraph.cpp b/src/lib/ip/IPCore/IPGraph.cpp index a87e38f2e..b57d49a2c 100644 --- a/src/lib/ip/IPCore/IPGraph.cpp +++ b/src/lib/ip/IPCore/IPGraph.cpp @@ -763,9 +763,9 @@ namespace IPCore std::vector devices; - for (size_t i = 0; i < modules.size(); i++) + for (const auto& module : modules) { - const TwkApp::VideoModule::VideoDevices& mdevices = modules[i]->devices(); + const TwkApp::VideoModule::VideoDevices& mdevices = module->devices(); devices.insert(devices.end(), mdevices.begin(), mdevices.end()); } @@ -782,9 +782,8 @@ namespace IPCore DisplayGroups survivors; DisplayGroups doomed; - for (size_t gi = 0; gi < m_displayGroups.size(); gi++) + for (DisplayGroupIPNode* group : m_displayGroups) { - DisplayGroupIPNode* group = m_displayGroups[gi]; if (group == m_defaultOutputGroup) { @@ -844,16 +843,7 @@ namespace IPCore // if (group->outputDevice() && group->outputDevice() != m_controlDevice) { - bool stillPresent = false; - - for (size_t di = 0; di < devices.size(); di++) - { - if (devices[di] == group->outputDevice()) - { - stillPresent = true; - break; - } - } + const bool stillPresent = std::find(devices.begin(), devices.end(), group->outputDevice()) != devices.end(); if (!stillPresent) { @@ -870,9 +860,9 @@ namespace IPCore // m_displayGroups = survivors; - for (size_t i = 0; i < doomed.size(); i++) + for (DisplayGroupIPNode* group : doomed) { - delete doomed[i]; + delete group; m_topologyChanged = true; } From a1820b410a11b990c132655edf097a631f21c79d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Tue, 29 Sep 2026 16:15:43 -0400 Subject: [PATCH 42/48] refactor(vulkan): require Vulkan 1.3 and use synchronization2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Request a 1.3 instance, select only 1.3 devices (in both the startup 10-bit probe and initVulkan), and enable synchronization2. Devices below 1.3 use the existing OpenGL path. - transitionImageLayout() uses vkCmdPipelineBarrier2; all submits go through a queueSubmit2() helper built on vkQueueSubmit2. - Barriers that follow a semaphore wait now use a TRANSFER source stage, so the layout transition chains with the wait. The previous TOP_OF_PIPE source stage did not. - Call vkGetPhysicalDeviceImageFormatProperties2 directly and drop the pre-1.1 fallback. - findGraphicsPresentQueue() and presentModeFromName() return std::optional instead of a bool plus an out-parameter. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanWindow.cpp | 288 ++++++++++++-------------- 1 file changed, 135 insertions(+), 153 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 3369238d9..d353c6cd5 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -130,7 +131,7 @@ namespace Rv return format == VK_FORMAT_A2B10G10R10_UNORM_PACK32 || format == VK_FORMAT_A2R10G10B10_UNORM_PACK32; } - bool findGraphicsPresentQueue(VkPhysicalDevice device, VkSurfaceKHR surface, uint32_t& familyIndex) + std::optional findGraphicsPresentQueue(VkPhysicalDevice device, VkSurfaceKHR surface) { uint32_t familyCount = 0; vkGetPhysicalDeviceQueueFamilyProperties(device, &familyCount, nullptr); @@ -143,11 +144,10 @@ namespace Rv if (vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport) == VK_SUCCESS && (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) && presentSupport) { - familyIndex = i; - return true; + return i; } } - return false; + return std::nullopt; } bool surfaceHasTenBitFormat(VkPhysicalDevice device, VkSurfaceKHR surface) @@ -186,6 +186,14 @@ namespace Rv [name](const VkExtensionProperties& extension) { return strcmp(extension.extensionName, name) == 0; }); } + // synchronization2 is a required 1.3 feature, so the version is enough. + bool deviceSupportsVulkan13(VkPhysicalDevice device) + { + VkPhysicalDeviceProperties props = {}; + vkGetPhysicalDeviceProperties(device, &props); + return props.apiVersion >= VK_API_VERSION_1_3; + } + constexpr std::string_view formatName(VkFormat format) { switch (format) @@ -313,8 +321,8 @@ namespace Rv static QVulkanInstance* instance = []() -> QVulkanInstance* { auto inst = std::make_unique(); - // 1.1 for vkGetPhysicalDeviceProperties2 (device UUID matching). - inst->setApiVersion(QVersionNumber(1, 1)); + // 1.3 for synchronization2 (vkQueueSubmit2 / vkCmdPipelineBarrier2). + inst->setApiVersion(QVersionNumber(1, 3)); if (!inst->create()) { cerr << "ERROR: VulkanWindow: shared QVulkanInstance create failed" << endl; @@ -380,19 +388,19 @@ namespace Rv { VkPhysicalDevice dev = devices[di]; - uint32_t queueFamily = 0; + const bool vulkan13 = deviceSupportsVulkan13(dev); const bool canPresent = - deviceHasExtension(dev, VK_KHR_SWAPCHAIN_EXTENSION_NAME) && findGraphicsPresentQueue(dev, dummySurface, queueFamily); + deviceHasExtension(dev, VK_KHR_SWAPCHAIN_EXTENSION_NAME) && findGraphicsPresentQueue(dev, dummySurface).has_value(); const bool has10bit = canPresent && surfaceHasTenBitFormat(dev, dummySurface); - any10bit = any10bit || has10bit; + any10bit = any10bit || (vulkan13 && has10bit); VkPhysicalDeviceProperties props = {}; vkGetPhysicalDeviceProperties(dev, &props); if (ImageRenderer::debugGpu()) { cout << "INFO: VulkanWindow: device[" << di << "] '" << props.deviceName - << "': graphics+present=" << (canPresent ? "YES" : "NO") << " 10-bit surface format=" << (has10bit ? "YES" : "NO") - << endl; + << "': vulkan1.3=" << (vulkan13 ? "YES" : "NO") << " graphics+present=" << (canPresent ? "YES" : "NO") + << " 10-bit surface format=" << (has10bit ? "YES" : "NO") << endl; } } @@ -447,11 +455,15 @@ namespace Rv for (VkPhysicalDevice dev : devices) { - uint32_t queueFamily = 0; - if (findGraphicsPresentQueue(dev, m_vkSurface, queueFamily) && surfaceHasTenBitFormat(dev, m_vkSurface)) + if (!deviceSupportsVulkan13(dev)) + { + continue; + } + const std::optional queueFamily = findGraphicsPresentQueue(dev, m_vkSurface); + if (queueFamily && surfaceHasTenBitFormat(dev, m_vkSurface)) { m_vkPhysicalDevice = dev; - m_queueFamilyIndex = queueFamily; + m_queueFamilyIndex = *queueFamily; foundQueue = true; break; } @@ -459,7 +471,9 @@ namespace Rv if (!foundQueue) { - cerr << "ERROR: VulkanWindow: initVulkan: No physical device with graphics, present, and 10-bit surface support found." << endl; + cerr << "ERROR: VulkanWindow: initVulkan: No Vulkan 1.3 physical device with graphics, present, and 10-bit surface support " + "found." + << endl; return false; } @@ -505,8 +519,13 @@ namespace Rv cout << "INFO: VulkanWindow: external memory/semaphore extensions unavailable; using CPU fallback." << endl; } + VkPhysicalDeviceVulkan13Features features13 = {}; + features13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES; + features13.synchronization2 = VK_TRUE; + VkDeviceCreateInfo createInfo = {}; createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + createInfo.pNext = &features13; createInfo.pQueueCreateInfos = &queueCreateInfo; createInfo.queueCreateInfoCount = 1; createInfo.enabledExtensionCount = static_cast(deviceExtensions.size()); @@ -719,34 +738,31 @@ namespace Rv } } - bool presentModeFromName(const char* name, VkPresentModeKHR& mode) + // name may be null (unset environment variable). + std::optional presentModeFromName(const char* name) { if (!name) { - return false; + return std::nullopt; } - const string modeName(name); + const std::string_view modeName(name); if (modeName == "fifo") { - mode = VK_PRESENT_MODE_FIFO_KHR; + return VK_PRESENT_MODE_FIFO_KHR; } - else if (modeName == "relaxed") + if (modeName == "relaxed") { - mode = VK_PRESENT_MODE_FIFO_RELAXED_KHR; + return VK_PRESENT_MODE_FIFO_RELAXED_KHR; } - else if (modeName == "mailbox") + if (modeName == "mailbox") { - mode = VK_PRESENT_MODE_MAILBOX_KHR; + return VK_PRESENT_MODE_MAILBOX_KHR; } - else if (modeName == "immediate") + if (modeName == "immediate") { - mode = VK_PRESENT_MODE_IMMEDIATE_KHR; + return VK_PRESENT_MODE_IMMEDIATE_KHR; } - else - { - return false; - } - return true; + return std::nullopt; } VkPresentModeKHR choosePresentMode(VkPhysicalDevice physicalDevice, VkSurfaceKHR surface, bool passiveOutput) @@ -762,19 +778,16 @@ namespace Rv const auto supported = [&](VkPresentModeKHR mode) { return std::find(available.begin(), available.end(), mode) != available.end(); }; - VkPresentModeKHR forced = VK_PRESENT_MODE_FIFO_KHR; - if (presentModeFromName(getenv(passiveOutput ? "RV_VULKAN_OUTPUT_PRESENT_MODE" : "RV_VULKAN_PRESENT_MODE"), forced)) + const std::optional forced = + presentModeFromName(getenv(passiveOutput ? "RV_VULKAN_OUTPUT_PRESENT_MODE" : "RV_VULKAN_PRESENT_MODE")); + if (forced && !supported(*forced)) { - if (supported(forced)) - { - return forced; - } - cout << "WARNING: VulkanWindow: requested present mode " << presentModeName(forced) << " is unsupported; using FIFO" + cout << "WARNING: VulkanWindow: requested present mode " << presentModeName(*forced) << " is unsupported; using FIFO" << endl; return VK_PRESENT_MODE_FIFO_KHR; } - return VK_PRESENT_MODE_FIFO_KHR; + return forced.value_or(VK_PRESENT_MODE_FIFO_KHR); } } // namespace @@ -1108,11 +1121,13 @@ namespace Rv // Single-subresource colour image layout transition. void transitionImageLayout(VkCommandBuffer commandBuffer, VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout, - VkAccessFlags srcAccessMask, VkAccessFlags dstAccessMask, VkPipelineStageFlags srcStageMask, - VkPipelineStageFlags dstStageMask) + VkAccessFlags2 srcAccessMask, VkAccessFlags2 dstAccessMask, VkPipelineStageFlags2 srcStageMask, + VkPipelineStageFlags2 dstStageMask) { - VkImageMemoryBarrier barrier = {}; - barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + VkImageMemoryBarrier2 barrier = {}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2; + barrier.srcStageMask = srcStageMask; + barrier.dstStageMask = dstStageMask; barrier.oldLayout = oldLayout; barrier.newLayout = newLayout; barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; @@ -1126,7 +1141,47 @@ namespace Rv barrier.srcAccessMask = srcAccessMask; barrier.dstAccessMask = dstAccessMask; - vkCmdPipelineBarrier(commandBuffer, srcStageMask, dstStageMask, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkDependencyInfo dependency = {}; + dependency.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO; + dependency.imageMemoryBarrierCount = 1; + dependency.pImageMemoryBarriers = &barrier; + + vkCmdPipelineBarrier2(commandBuffer, &dependency); + } + + VkSemaphoreSubmitInfo semaphoreSubmit(VkSemaphore semaphore, VkPipelineStageFlags2 stageMask) + { + VkSemaphoreSubmitInfo info = {}; + info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO; + info.semaphore = semaphore; + info.stageMask = stageMask; + return info; + } + + // Signal semaphores use ALL_COMMANDS, matching legacy vkQueueSubmit. + VkSemaphoreSubmitInfo signalSubmit(VkSemaphore semaphore) + { + return semaphoreSubmit(semaphore, VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT); + } + + // One vkQueueSubmit2 batch; commandBuffer may be VK_NULL_HANDLE. + VkResult queueSubmit2(VkQueue queue, std::initializer_list waits, VkCommandBuffer commandBuffer, + std::initializer_list signals, VkFence fence) + { + VkCommandBufferSubmitInfo commandBufferInfo = {}; + commandBufferInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; + commandBufferInfo.commandBuffer = commandBuffer; + + VkSubmitInfo2 submit = {}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2; + submit.waitSemaphoreInfoCount = static_cast(waits.size()); + submit.pWaitSemaphoreInfos = waits.begin(); + submit.commandBufferInfoCount = commandBuffer != VK_NULL_HANDLE ? 1 : 0; + submit.pCommandBufferInfos = &commandBufferInfo; + submit.signalSemaphoreInfoCount = static_cast(signals.size()); + submit.pSignalSemaphoreInfos = signals.begin(); + + return vkQueueSubmit2(queue, 1, &submit, fence); } bool envFlagSet(const char* name) { return getenv(name) != nullptr; } @@ -1196,26 +1251,6 @@ namespace Rv return std::nullopt; } - // Resolved dynamically (with the KHR alias) so a 1.0-only loader - // degrades to "not exportable" instead of crashing. - PFN_vkGetPhysicalDeviceImageFormatProperties2 getImageFormatProperties2(VkInstance instance) - { - static PFN_vkGetPhysicalDeviceImageFormatProperties2 fn = nullptr; - static bool resolved = false; - if (!resolved) - { - resolved = true; - fn = reinterpret_cast( - vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceImageFormatProperties2")); - if (!fn) - { - fn = reinterpret_cast( - vkGetInstanceProcAddr(instance, "vkGetPhysicalDeviceImageFormatProperties2KHR")); - } - } - return fn; - } - bool isNvidiaPhysicalDevice(VkPhysicalDevice dev) { VkPhysicalDeviceProperties props = {}; @@ -1291,17 +1326,6 @@ namespace Rv const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; #endif - PFN_vkGetPhysicalDeviceImageFormatProperties2 probe = getImageFormatProperties2(m_vkInstance); - if (!probe) - { - cfg.supported = false; - cfg.rejectReason = "vkGetPhysicalDeviceImageFormatProperties2 is unavailable " - "(Vulkan instance predates 1.1 and lacks VK_KHR_get_physical_device_properties2), " - "so exportability cannot be established"; - m_interopConfig = cfg; - return; - } - // Exportability alone does not guarantee a usable import, so try the // vendor's preferred tiling first. See useOptimalTilingForInterop(). const bool preferOptimal = useOptimalTilingForInterop(m_vkPhysicalDevice); @@ -1330,7 +1354,7 @@ namespace Rv props.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2; props.pNext = &extProps; - const VkResult result = probe(m_vkPhysicalDevice, &fmtInfo, &props); + const VkResult result = vkGetPhysicalDeviceImageFormatProperties2(m_vkPhysicalDevice, &fmtInfo, &props); features = extProps.externalMemoryProperties.externalMemoryFeatures; if (result != VK_SUCCESS) @@ -1663,19 +1687,9 @@ namespace Rv return; } - VkSubmitInfo drain = {}; - drain.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - const std::array waitSemaphores = {m_shared[slot].glReadySemaphore}; - const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT}; - drain.waitSemaphoreCount = static_cast(waitSemaphores.size()); - drain.pWaitSemaphores = waitSemaphores.data(); - drain.pWaitDstStageMask = waitStages.data(); - drain.commandBufferCount = 0; - const std::array signalSemaphores = {m_shared[slot].vkReadySemaphore}; - drain.signalSemaphoreCount = static_cast(signalSemaphores.size()); - drain.pSignalSemaphores = signalSemaphores.data(); - - VkResult result = vkQueueSubmit(m_vkQueue, 1, &drain, VK_NULL_HANDLE); + const VkResult result = + queueSubmit2(m_vkQueue, {semaphoreSubmit(m_shared[slot].glReadySemaphore, VK_PIPELINE_STAGE_2_TRANSFER_BIT)}, VK_NULL_HANDLE, + {signalSubmit(m_shared[slot].vkReadySemaphore)}, VK_NULL_HANDLE); if (result == VK_ERROR_DEVICE_LOST) { requestGLFallback(); @@ -1684,17 +1698,12 @@ namespace Rv void VulkanWindow::recoverFailedSubmit(uint32_t slot, VkSemaphore waitSemaphore) { - VkSubmitInfo recover = {}; - recover.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_TRANSFER_BIT; - if (waitSemaphore != VK_NULL_HANDLE) - { - recover.waitSemaphoreCount = 1; - recover.pWaitSemaphores = &waitSemaphore; - recover.pWaitDstStageMask = &waitStage; - } - - if (vkQueueSubmit(m_vkQueue, 1, &recover, m_frameSync[slot].fence) != VK_SUCCESS) + // Consume the wait semaphore (if any) and signal the fence. + const VkResult result = waitSemaphore != VK_NULL_HANDLE + ? queueSubmit2(m_vkQueue, {semaphoreSubmit(waitSemaphore, VK_PIPELINE_STAGE_2_TRANSFER_BIT)}, + VK_NULL_HANDLE, {}, m_frameSync[slot].fence) + : queueSubmit2(m_vkQueue, {}, VK_NULL_HANDLE, {}, m_frameSync[slot].fence); + if (result != VK_SUCCESS) { cerr << "ERROR: VulkanWindow: could not recover from a failed submit" << endl; } @@ -2106,18 +2115,13 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, - VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_2_NONE, + VK_ACCESS_2_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_2_NONE, VK_PIPELINE_STAGE_2_TRANSFER_BIT); vkEndCommandBuffer(cb); - VkSubmitInfo submitInfo = {}; - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - submitInfo.commandBufferCount = 1; - submitInfo.pCommandBuffers = &cb; - vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); - VkResult layoutSubmitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); + const VkResult layoutSubmitResult = queueSubmit2(m_vkQueue, {}, cb, {}, m_frameSync[slot].fence); if (layoutSubmitResult != VK_SUCCESS) { recoverFailedSubmit(slot, VK_NULL_HANDLE); @@ -2127,11 +2131,8 @@ namespace Rv vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); // Signal vkReady so GL can write the first frame. - VkSubmitInfo signalInfo = {}; - signalInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - signalInfo.signalSemaphoreCount = 1; - signalInfo.pSignalSemaphores = &m_shared[slot].vkReadySemaphore; - VkResult signalResult = vkQueueSubmit(m_vkQueue, 1, &signalInfo, VK_NULL_HANDLE); + const VkResult signalResult = + queueSubmit2(m_vkQueue, {}, VK_NULL_HANDLE, {signalSubmit(m_shared[slot].vkReadySemaphore)}, VK_NULL_HANDLE); if (signalResult != VK_SUCCESS) { if (signalResult == VK_ERROR_DEVICE_LOST) @@ -2253,12 +2254,15 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, - VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + // Source stage TRANSFER chains the transition after the glReady wait. + transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_2_NONE, VK_ACCESS_2_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_PIPELINE_STAGE_2_TRANSFER_BIT); // Transition swapchain image to transfer dst - transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); + transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_ACCESS_2_NONE, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_PIPELINE_STAGE_2_TRANSFER_BIT); // Same format: raw copy. A2R10G10B10: a raw copy would swap R and B, // so blit (per-component conversion). The destination is bounded by @@ -2294,29 +2298,17 @@ namespace Rv } transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); + VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_ACCESS_2_NONE, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_PIPELINE_STAGE_2_NONE); vkEndCommandBuffer(cb); - VkSubmitInfo submitInfo = {}; - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - - const std::array waitSemaphores = {m_shared[slot].glReadySemaphore, m_frameSync[slot].imageAvailable}; - // TRANSFER, not COLOR_ATTACHMENT_OUTPUT: the swapchain image is first - // touched by its TRANSFER_DST transition. - const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT}; - submitInfo.waitSemaphoreCount = static_cast(waitSemaphores.size()); - submitInfo.pWaitSemaphores = waitSemaphores.data(); - submitInfo.pWaitDstStageMask = waitStages.data(); - - submitInfo.commandBufferCount = 1; - submitInfo.pCommandBuffers = &cb; - - const std::array signalSemaphores = {m_vkRenderFinished[imageIndex], m_shared[slot].vkReadySemaphore}; - submitInfo.signalSemaphoreCount = static_cast(signalSemaphores.size()); - submitInfo.pSignalSemaphores = signalSemaphores.data(); - - VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); + // TRANSFER, not COLOR_ATTACHMENT_OUTPUT: both images are first touched + // by TRANSFER-stage transitions. + const VkResult submitResult = queueSubmit2( + m_vkQueue, + {semaphoreSubmit(m_shared[slot].glReadySemaphore, VK_PIPELINE_STAGE_2_TRANSFER_BIT), + semaphoreSubmit(m_frameSync[slot].imageAvailable, VK_PIPELINE_STAGE_2_TRANSFER_BIT)}, + cb, {signalSubmit(m_vkRenderFinished[imageIndex]), signalSubmit(m_shared[slot].vkReadySemaphore)}, m_frameSync[slot].fence); if (submitResult != VK_SUCCESS) { drainSharedSemaphores(slot); @@ -2531,8 +2523,9 @@ namespace Rv beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(cb, &beginInfo); - transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); + transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_ACCESS_2_NONE, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_TRANSFER_BIT, + VK_PIPELINE_STAGE_2_TRANSFER_BIT); // The buffer is w x h, but the destination is bounded by the swapchain. VkBufferImageCopy region = {}; @@ -2551,24 +2544,13 @@ namespace Rv ®ion); transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], 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); + VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_ACCESS_2_NONE, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_PIPELINE_STAGE_2_NONE); vkEndCommandBuffer(cb); - VkSubmitInfo submitInfo = {}; - submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; - const std::array waitSemaphores = {m_frameSync[slot].imageAvailable}; - const std::array waitStages = {VK_PIPELINE_STAGE_TRANSFER_BIT}; - submitInfo.waitSemaphoreCount = static_cast(waitSemaphores.size()); - submitInfo.pWaitSemaphores = waitSemaphores.data(); - submitInfo.pWaitDstStageMask = waitStages.data(); - submitInfo.commandBufferCount = 1; - submitInfo.pCommandBuffers = &cb; - const std::array signalSemaphores = {m_vkRenderFinished[imageIndex]}; - submitInfo.signalSemaphoreCount = static_cast(signalSemaphores.size()); - submitInfo.pSignalSemaphores = signalSemaphores.data(); - - VkResult submitResult = vkQueueSubmit(m_vkQueue, 1, &submitInfo, m_frameSync[slot].fence); + const VkResult submitResult = + queueSubmit2(m_vkQueue, {semaphoreSubmit(m_frameSync[slot].imageAvailable, VK_PIPELINE_STAGE_2_TRANSFER_BIT)}, cb, + {signalSubmit(m_vkRenderFinished[imageIndex])}, m_frameSync[slot].fence); if (submitResult != VK_SUCCESS) { recoverFailedSubmit(slot, m_frameSync[slot].imageAvailable); From d989e8a157e86aaea791070df662b1168abf1119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Wed, 30 Sep 2026 11:19:26 -0400 Subject: [PATCH 43/48] refactor(vulkan): require Vulkan 1.3 and use synchronization2 - part2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanWindow.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index d353c6cd5..689fe42a3 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -1166,7 +1166,7 @@ namespace Rv // One vkQueueSubmit2 batch; commandBuffer may be VK_NULL_HANDLE. VkResult queueSubmit2(VkQueue queue, std::initializer_list waits, VkCommandBuffer commandBuffer, - std::initializer_list signals, VkFence fence) + std::initializer_list signalInfos, VkFence fence) { VkCommandBufferSubmitInfo commandBufferInfo = {}; commandBufferInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO; @@ -1178,8 +1178,8 @@ namespace Rv submit.pWaitSemaphoreInfos = waits.begin(); submit.commandBufferInfoCount = commandBuffer != VK_NULL_HANDLE ? 1 : 0; submit.pCommandBufferInfos = &commandBufferInfo; - submit.signalSemaphoreInfoCount = static_cast(signals.size()); - submit.pSignalSemaphoreInfos = signals.begin(); + submit.signalSemaphoreInfoCount = static_cast(signalInfos.size()); + submit.pSignalSemaphoreInfos = signalInfos.begin(); return vkQueueSubmit2(queue, 1, &submit, fence); } @@ -2569,7 +2569,7 @@ namespace Rv VkPresentInfoKHR presentInfo = {}; presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = signalSemaphores.data(); + presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; const std::array swapchains = {m_vkSwapchain}; presentInfo.swapchainCount = static_cast(swapchains.size()); presentInfo.pSwapchains = swapchains.data(); From ac03ba305ee41844f0080f15df7f53d460b535f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Wed, 30 Sep 2026 11:15:48 -0400 Subject: [PATCH 44/48] fix(vulkan): keep the initVulkan device-selection error on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/VulkanWindow.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 689fe42a3..c78f26b01 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -471,9 +471,7 @@ namespace Rv if (!foundQueue) { - cerr << "ERROR: VulkanWindow: initVulkan: No Vulkan 1.3 physical device with graphics, present, and 10-bit surface support " - "found." - << endl; + cerr << "ERROR: VulkanWindow: initVulkan: no Vulkan 1.3 device with graphics, present and 10-bit surface support" << endl; return false; } From 572ecedb11ccbe31db907e82c5b5c163bece2a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Wed, 30 Sep 2026 14:10:17 -0400 Subject: [PATCH 45/48] refactor(vulkan): address remaining self-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VulkanWindow: failSharedImage lambda for the getSharedImageInfo error paths; ScopedDiagTimer for the -debug gpu timings; DiagStats struct in an anonymous namespace, with std::optional instead of the slot parallel arrays and the -1.0 sentinel; presentWaitTimeout, waitForFrameFence, acquireFrameImage and presentFrame shared by both present paths; beginOneTimeCommands; kMemoryHandleType and kSemaphoreHandleType defined once; descriptive width/height names. - QTVulkanVideoDevice: resolveGL<>, cleanupAllSharedGLObjects, drainGLErrors and toDevicePixels helpers; std::optional for m_refresh, m_loggedPresentPath and m_glVulkanDeviceMatch; descriptive names. - RvDocument: createGLView and configureViewWidget helpers replace the three copies of the GLView construction and widget setup. - GLWindow: -debug gpu accumulators in an anonymous-namespace struct. - Descriptive names in IPGraph, RvApplication and GLView. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/GLView.cpp | 14 +- src/lib/app/RvCommon/GLWindow.cpp | 47 +- src/lib/app/RvCommon/QTVulkanVideoDevice.cpp | 218 ++--- src/lib/app/RvCommon/RvApplication.cpp | 20 +- src/lib/app/RvCommon/RvCommon/GLView.h | 2 +- .../RvCommon/RvCommon/QTVulkanVideoDevice.h | 22 +- src/lib/app/RvCommon/RvCommon/RvDocument.h | 3 + src/lib/app/RvCommon/RvCommon/VulkanWindow.h | 21 +- src/lib/app/RvCommon/RvDocument.cpp | 104 +-- src/lib/app/RvCommon/VulkanWindow.cpp | 742 ++++++++---------- src/lib/ip/IPCore/IPGraph.cpp | 4 +- 11 files changed, 541 insertions(+), 656 deletions(-) diff --git a/src/lib/app/RvCommon/GLView.cpp b/src/lib/app/RvCommon/GLView.cpp index e0b320d01..029cc06fc 100644 --- a/src/lib/app/RvCommon/GLView.cpp +++ b/src/lib/app/RvCommon/GLView.cpp @@ -36,15 +36,15 @@ namespace Rv return value ? value : ""; } - std::string glDebugFormatSummary(const QSurfaceFormat& f) + std::string glDebugFormatSummary(const QSurfaceFormat& format) { ostringstream out; - out << "rgba " << f.redBufferSize() << " " << f.greenBufferSize() << " " << f.blueBufferSize() << " " - << (f.alphaBufferSize() <= 0 ? 0 : f.alphaBufferSize()); - out << ", depth " << f.depthBufferSize() << ", stencil " << f.stencilBufferSize(); - out << ", swapInterval " << f.swapInterval(); - out << ", stereo " << (f.stereo() ? "true" : "false"); - out << ", major.minor " << f.majorVersion() << "." << f.minorVersion(); + out << "rgba " << format.redBufferSize() << " " << format.greenBufferSize() << " " << format.blueBufferSize() << " " + << (format.alphaBufferSize() <= 0 ? 0 : format.alphaBufferSize()); + out << ", depth " << format.depthBufferSize() << ", stencil " << format.stencilBufferSize(); + out << ", swapInterval " << format.swapInterval(); + out << ", stereo " << (format.stereo() ? "true" : "false"); + out << ", major.minor " << format.majorVersion() << "." << format.minorVersion(); return out.str(); } diff --git a/src/lib/app/RvCommon/GLWindow.cpp b/src/lib/app/RvCommon/GLWindow.cpp index 89583b40d..05821ffe6 100644 --- a/src/lib/app/RvCommon/GLWindow.cpp +++ b/src/lib/app/RvCommon/GLWindow.cpp @@ -35,13 +35,21 @@ namespace Rv { - // -debug gpu frame-time accumulators, mirroring VulkanWindow's. - static unsigned int s_glDiagFrames = 0; - static double s_glDiagRenderMs = 0.0; - static double s_glDiagOutPresentMs = 0.0; - // Time between paintGL() entries; includes the implicit swap after paintGL. - static double s_glDiagLoopMs = 0.0; - static TwkUtil::Timer s_glDiagLoopTimer; + namespace + { + // -debug gpu frame-time accumulators, mirroring VulkanWindow's. + struct GLFrameDiag + { + unsigned int frames{0}; + double renderMs{0.0}; + double outPresentMs{0.0}; + // Time between paintGL() entries; includes the implicit swap after paintGL. + double loopMs{0.0}; + }; + + GLFrameDiag s_glDiag; + TwkUtil::Timer s_glDiagLoopTimer; + } // namespace using namespace std; using namespace TwkApp; @@ -229,7 +237,7 @@ namespace Rv { if (s_glDiagLoopTimer.isRunning()) { - s_glDiagLoopMs += s_glDiagLoopTimer.elapsed() * 1000.0; + s_glDiag.loopMs += s_glDiagLoopTimer.elapsed() * 1000.0; } s_glDiagLoopTimer.start(); } @@ -269,7 +277,7 @@ namespace Rv if (diagTiming) { - s_glDiagRenderMs += diagTimer.elapsed() * 1000.0; + s_glDiag.renderMs += diagTimer.elapsed() * 1000.0; } TWK_GLDEBUG; @@ -313,7 +321,7 @@ namespace Rv if (diagPresent) { - s_glDiagOutPresentMs += diagPresentTimer.elapsed() * 1000.0; + s_glDiag.outPresentMs += diagPresentTimer.elapsed() * 1000.0; } } @@ -323,18 +331,15 @@ namespace Rv // No mainPresent term: QOpenGLWindow swaps after paintGL returns. if (IPCore::ImageRenderer::debugGpu()) { - if (++s_glDiagFrames >= 60) + if (++s_glDiag.frames >= 60) { - const double n = double(s_glDiagFrames); - const double loopMs = s_glDiagLoopMs / n; - cout << "INFO: GLWindow frame avg over " << s_glDiagFrames << ": session->render()=" << (s_glDiagRenderMs / n) - << "ms outputPresent=" << (s_glDiagOutPresentMs / n) - << "ms total=" << ((s_glDiagRenderMs + s_glDiagOutPresentMs) / n) << "ms frameInterval=" << loopMs << "ms (" - << (loopMs > 0.0 ? 1000.0 / loopMs : 0.0) << " fps)" << endl; - s_glDiagFrames = 0; - s_glDiagRenderMs = 0.0; - s_glDiagOutPresentMs = 0.0; - s_glDiagLoopMs = 0.0; + const double frameCount = static_cast(s_glDiag.frames); + const double loopMs = s_glDiag.loopMs / frameCount; + cout << "INFO: GLWindow frame avg over " << s_glDiag.frames << ": session->render()=" << (s_glDiag.renderMs / frameCount) + << "ms outputPresent=" << (s_glDiag.outPresentMs / frameCount) + << "ms total=" << ((s_glDiag.renderMs + s_glDiag.outPresentMs) / frameCount) << "ms frameInterval=" << loopMs + << "ms (" << (loopMs > 0.0 ? 1000.0 / loopMs : 0.0) << " fps)" << endl; + s_glDiag = {}; } } diff --git a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp index 28eed23e1..ac95c7ac8 100644 --- a/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp +++ b/src/lib/app/RvCommon/QTVulkanVideoDevice.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #ifdef PLATFORM_WINDOWS @@ -120,6 +121,12 @@ namespace bool g_glInteropProbed = false; bool g_glInteropAvailable = false; + // Null when the driver does not expose the entry point. + template void resolveGL(Proc& function, const char* name) + { + function = reinterpret_cast(wglGetProcAddress(name)); + } + // Must be called with a current GL context. bool loadGLInteropExtensions() { @@ -129,19 +136,16 @@ namespace } g_glInteropProbed = true; - g_glCreateMemoryObjectsEXT = reinterpret_cast(wglGetProcAddress("glCreateMemoryObjectsEXT")); - g_glDeleteMemoryObjectsEXT = reinterpret_cast(wglGetProcAddress("glDeleteMemoryObjectsEXT")); - g_glMemoryObjectParameterivEXT = - reinterpret_cast(wglGetProcAddress("glMemoryObjectParameterivEXT")); - g_glTexStorageMem2DEXT = reinterpret_cast(wglGetProcAddress("glTexStorageMem2DEXT")); - g_glImportMemoryWin32HandleEXT = - reinterpret_cast(wglGetProcAddress("glImportMemoryWin32HandleEXT")); - g_glGenSemaphoresEXT = reinterpret_cast(wglGetProcAddress("glGenSemaphoresEXT")); - g_glDeleteSemaphoresEXT = reinterpret_cast(wglGetProcAddress("glDeleteSemaphoresEXT")); - g_glImportSemaphoreWin32HandleEXT = - reinterpret_cast(wglGetProcAddress("glImportSemaphoreWin32HandleEXT")); - g_glWaitSemaphoreEXT = reinterpret_cast(wglGetProcAddress("glWaitSemaphoreEXT")); - g_glSignalSemaphoreEXT = reinterpret_cast(wglGetProcAddress("glSignalSemaphoreEXT")); + resolveGL(g_glCreateMemoryObjectsEXT, "glCreateMemoryObjectsEXT"); + resolveGL(g_glDeleteMemoryObjectsEXT, "glDeleteMemoryObjectsEXT"); + resolveGL(g_glMemoryObjectParameterivEXT, "glMemoryObjectParameterivEXT"); + resolveGL(g_glTexStorageMem2DEXT, "glTexStorageMem2DEXT"); + resolveGL(g_glImportMemoryWin32HandleEXT, "glImportMemoryWin32HandleEXT"); + resolveGL(g_glGenSemaphoresEXT, "glGenSemaphoresEXT"); + resolveGL(g_glDeleteSemaphoresEXT, "glDeleteSemaphoresEXT"); + resolveGL(g_glImportSemaphoreWin32HandleEXT, "glImportSemaphoreWin32HandleEXT"); + resolveGL(g_glWaitSemaphoreEXT, "glWaitSemaphoreEXT"); + resolveGL(g_glSignalSemaphoreEXT, "glSignalSemaphoreEXT"); g_glInteropAvailable = g_glCreateMemoryObjectsEXT && g_glDeleteMemoryObjectsEXT && g_glMemoryObjectParameterivEXT && g_glTexStorageMem2DEXT && g_glImportMemoryWin32HandleEXT && g_glGenSemaphoresEXT @@ -201,6 +205,13 @@ namespace Rv static const bool forced = getenv("RV_VULKAN_FORCE_CPU_PRESENT") != nullptr; return forced; } + + void drainGLErrors() + { + while (glGetError() != GL_NO_ERROR) + { + } + } } // namespace QTVulkanVideoDevice::QTVulkanVideoDevice(VideoModule* module, const string& name, VulkanWindow* window, QWidget* eventWidget) @@ -223,10 +234,7 @@ namespace Rv glDeleteTextures(1, &m_fboColorTex); m_fboColorTex = 0; } - for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) - { - cleanupSharedGLObjects(i); - } + cleanupAllSharedGLObjects(); cleanupCpuFallbackTarget(); m_glContext->doneCurrent(); } @@ -300,19 +308,18 @@ namespace Rv return; } - const float dpr = m_window ? m_window->devicePixelRatioF() : 1.0f; - int newW = m_window ? static_cast(m_window->width() * dpr + 0.5f) : 128; - int newH = m_window ? static_cast(m_window->height() * dpr + 0.5f) : 128; - if (newW < 1) + int pixelWidth = m_window ? toDevicePixels(m_window->width()) : 128; + int pixelHeight = m_window ? toDevicePixels(m_window->height()) : 128; + if (pixelWidth < 1) { - newW = 128; + pixelWidth = 128; } - if (newH < 1) + if (pixelHeight < 1) { - newH = 128; + pixelHeight = 128; } - if (!m_fbo || m_fboWidth != newW || m_fboHeight != newH) + if (!m_fbo || m_fboWidth != pixelWidth || m_fboHeight != pixelHeight) { m_fbo.reset(); if (m_fboColorTex) @@ -331,15 +338,16 @@ namespace Rv glBindTexture(GL_TEXTURE_RECTANGLE_ARB, m_fboColorTex); if (passiveOutput) { - glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, fboFormat, newW, newH, 0, GL_RGBA, GL_UNSIGNED_INT_2_10_10_10_REV, nullptr); + glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, fboFormat, pixelWidth, pixelHeight, 0, GL_RGBA, GL_UNSIGNED_INT_2_10_10_10_REV, + nullptr); } else { - glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, fboFormat, newW, newH, 0, GL_RGBA, GL_FLOAT, nullptr); + glTexImage2D(GL_TEXTURE_RECTANGLE_ARB, 0, fboFormat, pixelWidth, pixelHeight, 0, GL_RGBA, GL_FLOAT, nullptr); } glBindTexture(GL_TEXTURE_RECTANGLE_ARB, 0); - m_fbo = std::make_unique(newW, newH, fboFormat); + m_fbo = std::make_unique(pixelWidth, pixelHeight, fboFormat); m_fbo->attachColorTexture(GL_TEXTURE_RECTANGLE_ARB, m_fboColorTex); GLenum status = glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT); @@ -348,8 +356,8 @@ namespace Rv cerr << "ERROR: QTVulkanVideoDevice: FBO incomplete: 0x" << hex << status << dec << endl; } - m_fboWidth = newW; - m_fboHeight = newH; + m_fboWidth = pixelWidth; + m_fboHeight = pixelHeight; } m_fbo->bind(); @@ -361,18 +369,18 @@ namespace Rv void QTVulkanVideoDevice::setAbsolutePosition(int x, int y) { - if (x != m_x || y != m_y || m_refresh == -1.0f) + if (x != m_x || y != m_y || !m_refresh) { - float refresh = -1.0f; + std::optional refresh; - int w = m_window ? m_window->width() : 0; - int h = m_window ? m_window->height() : 0; - int tx = x + w / 2; - int ty = y + h / 2; + const int windowWidth = m_window ? m_window->width() : 0; + const int windowHeight = m_window ? m_window->height() : 0; + const int centerX = x + windowWidth / 2; + const int centerY = y + windowHeight / 2; if (const TwkApp::VideoModule* mod = TwkApp::App()->primaryVideoModule()) { - if (TwkApp::VideoDevice* device = mod->deviceFromPosition(tx, ty)) + if (TwkApp::VideoDevice* device = mod->deviceFromPosition(centerX, centerY)) { setPhysicalDevice(device); refresh = device->timing().hz; @@ -384,18 +392,18 @@ namespace Rv if (refresh != m_refresh) { - if (refresh > 0) + if (refresh && *refresh > 0) { m_refresh = refresh; } else if (IPCore::debugPlayback) { - cout << "WARNING: ignoring intended desktop refresh rate = " << refresh << endl; + cout << "WARNING: ignoring intended desktop refresh rate = " << refresh.value_or(-1.0f) << endl; } if (IPCore::debugPlayback) { - cout << "INFO: new desktop refresh rate " << m_refresh << endl; + cout << "INFO: new desktop refresh rate " << m_refresh.value_or(-1.0f) << endl; } } } @@ -488,6 +496,14 @@ namespace Rv m_glShared[slot].height = 0; } + void QTVulkanVideoDevice::cleanupAllSharedGLObjects() const + { + for (uint32_t slot = 0; slot < VulkanWindow::kFramesInFlight; ++slot) + { + cleanupSharedGLObjects(slot); + } + } + void QTVulkanVideoDevice::releaseSharedGLObjects() { // No context means nothing was ever imported. @@ -500,16 +516,13 @@ namespace Rv { return; } - for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) - { - cleanupSharedGLObjects(i); - } + cleanupAllSharedGLObjects(); m_glContext->doneCurrent(); } - void QTVulkanVideoDevice::ensureCpuFallbackTarget(int w, int h) const + void QTVulkanVideoDevice::ensureCpuFallbackTarget(int targetWidth, int targetHeight) const { - if (m_cpuFlipFbo && m_cpuFlipWidth == w && m_cpuFlipHeight == h) + if (m_cpuFlipFbo && m_cpuFlipWidth == targetWidth && m_cpuFlipHeight == targetHeight) { return; } @@ -520,7 +533,7 @@ namespace Rv // packed readback below is a direct copy (no conversion in glReadPixels). glGenTextures(1, &m_cpuFlipTex); glBindTexture(GL_TEXTURE_2D, m_cpuFlipTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB10_A2, w, h, 0, GL_RGBA, GL_UNSIGNED_INT_2_10_10_10_REV, nullptr); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB10_A2, targetWidth, targetHeight, 0, GL_RGBA, GL_UNSIGNED_INT_2_10_10_10_REV, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glBindTexture(GL_TEXTURE_2D, 0); @@ -530,8 +543,8 @@ namespace Rv glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_TEXTURE_2D, m_cpuFlipTex, 0); glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0); - m_cpuFlipWidth = w; - m_cpuFlipHeight = h; + m_cpuFlipWidth = targetWidth; + m_cpuFlipHeight = targetHeight; } void QTVulkanVideoDevice::cleanupCpuFallbackTarget() const @@ -560,9 +573,7 @@ namespace Rv } // Drain the rest so the next step starts from a clean queue. - while (glGetError() != GL_NO_ERROR) - { - } + drainGLErrors(); cerr << "ERROR: QTVulkanVideoDevice: " << what << " failed (GL 0x" << hex << first << dec << "); demoting '" << name() << "' to CPU presentation." << endl; @@ -574,9 +585,9 @@ namespace Rv bool QTVulkanVideoDevice::glDeviceMatchesVulkan() const { - if (m_glVulkanDeviceMatch != -1) + if (m_glVulkanDeviceMatch) { - return m_glVulkanDeviceMatch == 1; + return *m_glVulkanDeviceMatch; } if (!m_glContext || !m_window || !m_window->isInitialized()) { @@ -601,7 +612,7 @@ namespace Rv matched = m_window->physicalDeviceMatchesUUID(uuid.data(), uuid.size()); } - m_glVulkanDeviceMatch = matched ? 1 : 0; + m_glVulkanDeviceMatch = matched; if (!matched && ImageRenderer::debugGpu()) { cout << "INFO: QTVulkanVideoDevice: GL/Vulkan device UUIDs do not match or are unavailable; using CPU fallback." << endl; @@ -609,26 +620,26 @@ namespace Rv return matched; } - void QTVulkanVideoDevice::presentCpuFallback(int w, int h) const + void QTVulkanVideoDevice::presentCpuFallback(int frameWidth, int frameHeight) const { TwkGLF::GLFBO* fbo = m_fbo.get(); // With GL_UNSIGNED_INT_2_10_10_10_REV, GL_RGBA packs A2B10G10R10 and // GL_BGRA packs A2R10G10B10, so the read format matches the swapchain. - const VkFormat scFmt = m_window ? m_window->swapchainFormat() : VK_FORMAT_A2B10G10R10_UNORM_PACK32; - const GLenum readFormat = (scFmt == VK_FORMAT_A2R10G10B10_UNORM_PACK32) ? GL_BGRA : GL_RGBA; + const VkFormat swapchainFormat = m_window ? m_window->swapchainFormat() : VK_FORMAT_A2B10G10R10_UNORM_PACK32; + const GLenum readFormat = (swapchainFormat == VK_FORMAT_A2R10G10B10_UNORM_PACK32) ? GL_BGRA : GL_RGBA; - ensureCpuFallbackTarget(w, h); + ensureCpuFallbackTarget(frameWidth, frameHeight); // Y-flip (GL bottom-left to Vulkan top-left) into the RGB10_A2 target. glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, fbo->fboID()); glBindFramebufferEXT(GL_DRAW_FRAMEBUFFER_EXT, m_cpuFlipFbo); - glBlitFramebufferEXT(0, 0, w, h, 0, h, w, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glBlitFramebufferEXT(0, 0, frameWidth, frameHeight, 0, frameHeight, frameWidth, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, m_cpuFlipFbo); - m_cpuPackedScratch.resize(static_cast(w) * h); - glReadPixels(0, 0, w, h, readFormat, GL_UNSIGNED_INT_2_10_10_10_REV, m_cpuPackedScratch.data()); - m_window->presentPixelData(m_cpuPackedScratch.data(), w, h); + m_cpuPackedScratch.resize(static_cast(frameWidth) * frameHeight); + glReadPixels(0, 0, frameWidth, frameHeight, readFormat, GL_UNSIGNED_INT_2_10_10_10_REV, m_cpuPackedScratch.data()); + m_window->presentPixelData(m_cpuPackedScratch.data(), frameWidth, frameHeight); glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, fbo->fboID()); // restore } @@ -654,10 +665,10 @@ namespace Rv const uint32_t slot = m_window->currentFrame(); TwkGLF::GLFBO* fbo = m_fbo.get(); - const int w = static_cast(fbo->width()); - const int h = static_cast(fbo->height()); + const int frameWidth = static_cast(fbo->width()); + const int frameHeight = static_cast(fbo->height()); - if (w <= 0 || h <= 0) + if (frameWidth <= 0 || frameHeight <= 0) { return; } @@ -687,21 +698,22 @@ namespace Rv GLEW_EXT_memory_object && GLEW_EXT_semaphore && GLEW_EXT_memory_object_fd && GLEW_EXT_semaphore_fd; #endif const bool glInteropAvailable = !forceCpuPresentation() && !m_interopDisabled && glExtensionsAvailable && glDeviceMatchesVulkan(); - const VulkanWindow::SharedImageInfo* sharedInfo = glInteropAvailable ? m_window->getSharedImageInfo(w, h) : nullptr; + const VulkanWindow::SharedImageInfo* sharedInfo = + glInteropAvailable ? m_window->getSharedImageInfo(frameWidth, frameHeight) : nullptr; // Logged on every transition: the first call can precede swapchain // creation. - const int presentPath = sharedInfo ? 1 : 0; - if (m_loggedPresentPath != presentPath) - { - m_loggedPresentPath = presentPath; - const VkFormat scFmt = m_window ? m_window->swapchainFormat() : VK_FORMAT_UNDEFINED; - cout << "INFO: QTVulkanVideoDevice: syncBuffers: '" << name() << "' " << w << "x" << h - << " present path = " << (sharedInfo ? "GPU-interop" : "CPU-fallback") << " swapchainFormat=" << scFmt - << (scFmt == VK_FORMAT_A2B10G10R10_UNORM_PACK32 ? " (A2B10G10R10 / 10-bit)" - : scFmt == VK_FORMAT_A2R10G10B10_UNORM_PACK32 ? " (A2R10G10B10 / 10-bit)" - : scFmt == VK_FORMAT_UNDEFINED ? " (UNDEFINED -- swapchain not created yet)" - : " (NOT 10-bit)") + const bool gpuInterop = sharedInfo != nullptr; + if (m_loggedPresentPath != gpuInterop) + { + m_loggedPresentPath = gpuInterop; + const VkFormat swapchainFormat = m_window ? m_window->swapchainFormat() : VK_FORMAT_UNDEFINED; + cout << "INFO: QTVulkanVideoDevice: syncBuffers: '" << name() << "' " << frameWidth << "x" << frameHeight + << " present path = " << (sharedInfo ? "GPU-interop" : "CPU-fallback") << " swapchainFormat=" << swapchainFormat + << (swapchainFormat == VK_FORMAT_A2B10G10R10_UNORM_PACK32 ? " (A2B10G10R10 / 10-bit)" + : swapchainFormat == VK_FORMAT_A2R10G10B10_UNORM_PACK32 ? " (A2R10G10B10 / 10-bit)" + : swapchainFormat == VK_FORMAT_UNDEFINED ? " (UNDEFINED -- swapchain not created yet)" + : " (NOT 10-bit)") << endl; } @@ -731,7 +743,7 @@ namespace Rv } m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, reason); - presentCpuFallback(w, h); + presentCpuFallback(frameWidth, frameHeight); return; } @@ -744,9 +756,7 @@ namespace Rv // Start from a clean error queue so interopGLFailed() below cannot // attribute an unrelated earlier error to the import. - while (glGetError() != GL_NO_ERROR) - { - } + drainGLErrors(); glCreateMemoryObjectsEXT(1, &m_glShared[slot].memoryObject); @@ -767,7 +777,7 @@ namespace Rv cerr << "ERROR: QTVulkanVideoDevice: dup(" << fdName << ") failed." << endl; cleanupSharedGLObjects(slot); m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "dup() of a shared image FD failed"); - presentCpuFallback(w, h); + presentCpuFallback(frameWidth, frameHeight); }; // Duplicate the FD because glImportMemoryFdEXT takes ownership @@ -788,7 +798,7 @@ namespace Rv glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_TILING_EXT, sharedInfo->tiling == VK_IMAGE_TILING_OPTIMAL ? GL_OPTIMAL_TILING_EXT : GL_LINEAR_TILING_EXT); - // Allocated at capacity; the blit below writes only the used w x h. + // Allocated at capacity; the blit below writes only the used region. glTexStorageMem2DEXT(GL_TEXTURE_2D, 1, GL_RGB10_A2, sharedInfo->strideWidth, sharedInfo->capacityHeight, m_glShared[slot].memoryObject, 0); glBindTexture(GL_TEXTURE_2D, 0); @@ -822,12 +832,9 @@ namespace Rv if (interopGLFailed("GL<->Vulkan shared image import")) { // Interop is now off for good, so release every slot. - for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) - { - cleanupSharedGLObjects(i); - } + cleanupAllSharedGLObjects(); m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "GL import of the shared image raised a GL error"); - presentCpuFallback(w, h); + presentCpuFallback(frameWidth, frameHeight); return; } @@ -839,9 +846,7 @@ namespace Rv // session->render() can leave errors pending; drain them so the check // below sees only the wait/blit/signal sequence's errors. - while (glGetError() != GL_NO_ERROR) - { - } + drainGLErrors(); constexpr GLenum waitSrcLayout = GL_LAYOUT_TRANSFER_SRC_EXT; glWaitSemaphoreEXT(m_glShared[slot].vkReadySemaphore, 0, nullptr, 1, &m_glShared[slot].texture, &waitSrcLayout); @@ -861,7 +866,7 @@ namespace Rv glBindFramebufferEXT(GL_READ_FRAMEBUFFER_EXT, readFbo); // Flip Y: GL origin is bottom-left, Vulkan origin is top-left. - glBlitFramebufferEXT(0, 0, w, h, 0, h, w, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glBlitFramebufferEXT(0, 0, frameWidth, frameHeight, 0, frameHeight, frameWidth, 0, GL_COLOR_BUFFER_BIT, GL_NEAREST); glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, readFbo); // restore @@ -872,12 +877,9 @@ namespace Rv if (interopGLFailed("GL<->Vulkan shared image blit")) { - for (uint32_t i = 0; i < VulkanWindow::kFramesInFlight; ++i) - { - cleanupSharedGLObjects(i); - } + cleanupAllSharedGLObjects(); m_window->reportPresentPath(VulkanWindow::PresentPath::CpuReadback, "GL blit into the shared image raised a GL error"); - presentCpuFallback(w, h); + presentCpuFallback(frameWidth, frameHeight); return; } @@ -902,19 +904,24 @@ namespace Rv void QTVulkanVideoDevice::clearCaches() const {} + int QTVulkanVideoDevice::toDevicePixels(int logical) const + { + const float dpr = m_window->devicePixelRatioF(); + return static_cast(logical * dpr + 0.5f); + } + VideoDevice::Resolution QTVulkanVideoDevice::resolution() const { if (!m_window) { return Resolution(0, 0, 1.0f, 1.0f); } - const float dpr = m_window->devicePixelRatioF(); - return Resolution(static_cast(m_window->width() * dpr + 0.5f), static_cast(m_window->height() * dpr + 0.5f), 1.0f, 1.0f); + return Resolution(toDevicePixels(m_window->width()), toDevicePixels(m_window->height()), 1.0f, 1.0f); } VideoDevice::Offset QTVulkanVideoDevice::offset() const { return Offset(m_x, m_y); } - VideoDevice::Timing QTVulkanVideoDevice::timing() const { return Timing((m_refresh != -1.0f) ? m_refresh : 0.0f); } + VideoDevice::Timing QTVulkanVideoDevice::timing() const { return Timing(m_refresh.value_or(0.0f)); } VideoDevice::VideoFormat QTVulkanVideoDevice::format() const { @@ -922,9 +929,8 @@ namespace Rv { return VideoFormat(0, 0, 1.0, 1.0, 0.0, hardwareIdentification()); } - const float dpr = m_window->devicePixelRatioF(); - return VideoFormat(static_cast(m_window->width() * dpr + 0.5f), static_cast(m_window->height() * dpr + 0.5f), 1.0, 1.0, - (m_refresh != -1.0f) ? m_refresh : 0.0f, hardwareIdentification()); + return VideoFormat(toDevicePixels(m_window->width()), toDevicePixels(m_window->height()), 1.0, 1.0, m_refresh.value_or(0.0f), + hardwareIdentification()); } size_t QTVulkanVideoDevice::width() const @@ -933,7 +939,7 @@ namespace Rv { return 0; } - return static_cast(m_window->width() * m_window->devicePixelRatioF() + 0.5f); + return static_cast(toDevicePixels(m_window->width())); } size_t QTVulkanVideoDevice::height() const @@ -942,7 +948,7 @@ namespace Rv { return 0; } - return static_cast(m_window->height() * m_window->devicePixelRatioF() + 0.5f); + return static_cast(toDevicePixels(m_window->height())); } void QTVulkanVideoDevice::open(const StringVector& /*args*/) diff --git a/src/lib/app/RvCommon/RvApplication.cpp b/src/lib/app/RvCommon/RvApplication.cpp index 8682d028a..4d7330df8 100644 --- a/src/lib/app/RvCommon/RvApplication.cpp +++ b/src/lib/app/RvCommon/RvApplication.cpp @@ -1974,9 +1974,9 @@ namespace Rv const VideoModule::VideoDevices& devices = m_desktopModule->devices(); for (VideoDevice* device : devices) { - if (DesktopVideoDevice* dd = dynamic_cast(device)) + if (DesktopVideoDevice* desktopDevice = dynamic_cast(device)) { - dd->setShareDevice(shareDevice); + desktopDevice->setShareDevice(shareDevice); } } @@ -1999,8 +1999,8 @@ namespace Rv } Rv::Options& opts = Rv::Options::sharedOptions(); - VideoDevice* d = findPresentationDevice(opts.presentDevice); - if (!d) + VideoDevice* presentDevice = findPresentationDevice(opts.presentDevice); + if (!presentDevice) { cerr << "ERROR: presentation device not found after rebuild." << endl; session->setOutputVideoDevice(session->controlVideoDevice()); @@ -2008,21 +2008,21 @@ namespace Rv return; } - if (DesktopVideoDevice* dd = dynamic_cast(d)) + if (DesktopVideoDevice* desktopDevice = dynamic_cast(presentDevice)) { - dd->setShareDevice(shareDevice); + desktopDevice->setShareDevice(shareDevice); } try { - if (!d->isOpen()) + if (!presentDevice->isOpen()) { - string optionArgs = setVideoDeviceStateFromSettings(d); + string optionArgs = setVideoDeviceStateFromSettings(presentDevice); StringVector vargs; algorithm::split(vargs, optionArgs, is_any_of(string(" \t\n\r")), token_compress_on); - d->open(vargs); + presentDevice->open(vargs); } - session->setOutputVideoDevice(d); + session->setOutputVideoDevice(presentDevice); } catch (const std::exception& exc) { diff --git a/src/lib/app/RvCommon/RvCommon/GLView.h b/src/lib/app/RvCommon/RvCommon/GLView.h index 58cd8250e..08b5370d8 100644 --- a/src/lib/app/RvCommon/RvCommon/GLView.h +++ b/src/lib/app/RvCommon/RvCommon/GLView.h @@ -25,7 +25,7 @@ namespace Rv // -debug gpu helpers shared by GLView and GLWindow. std::string glDebugEnvOrUnset(const char* name); - std::string glDebugFormatSummary(const QSurfaceFormat&); + std::string glDebugFormatSummary(const QSurfaceFormat& format); // // GLView diff --git a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h index a415148bb..927f16a2e 100644 --- a/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/QTVulkanVideoDevice.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -45,7 +46,7 @@ namespace Rv QTVulkanVideoDevice(const QTVulkanVideoDevice&) = delete; QTVulkanVideoDevice& operator=(const QTVulkanVideoDevice&) = delete; - void resetInteropDeviceMatch() const { m_glVulkanDeviceMatch = -1; } + void resetInteropDeviceMatch() const { m_glVulkanDeviceMatch.reset(); } const QTTranslator& translator() const { return *m_translator; } @@ -95,6 +96,9 @@ namespace Rv // Makes the GL context current and binds the FBO on return. void ensureGLContext() const; + // Logical window pixels to device pixels, rounded. Requires m_window. + int toDevicePixels(int logical) const; + // The window container owns the window, so Qt can delete it // independently of this device. QPointer m_window; @@ -102,7 +106,7 @@ namespace Rv float m_devicePixelRatio{1.0f}; int m_x{0}; int m_y{0}; - float m_refresh{-1.0f}; + std::optional m_refresh; mutable std::unique_ptr m_glContext; mutable std::unique_ptr m_offscreenSurface; @@ -127,11 +131,10 @@ namespace Rv mutable std::array m_glShared{}; - // Last reported present path: -1 none yet, 0 CPU-fallback, 1 GPU-interop. - mutable int m_loggedPresentPath{-1}; - // -1 until queried, 0 when GL and Vulkan use different/unidentifiable - // physical devices, 1 when their device UUIDs match. - mutable int m_glVulkanDeviceMatch{-1}; + // Last logged present path (true for GPU-interop); empty until the first. + mutable std::optional m_loggedPresentPath; + // Empty until queried; true when the GL and Vulkan device UUIDs match. + mutable std::optional m_glVulkanDeviceMatch; // Latched once any GL call on the interop path fails; the device then // stays on the CPU path. @@ -143,6 +146,7 @@ namespace Rv bool interopGLFailed(const char* what) const; void cleanupSharedGLObjects(uint32_t slot) const; + void cleanupAllSharedGLObjects() const; bool glDeviceMatchesVulkan() const; // CPU-fallback target: a Y-flipped RGB10_A2 copy that glReadPixels packs @@ -153,10 +157,10 @@ namespace Rv mutable int m_cpuFlipHeight{0}; mutable std::vector m_cpuPackedScratch; - void ensureCpuFallbackTarget(int w, int h) const; + void ensureCpuFallbackTarget(int targetWidth, int targetHeight) const; void cleanupCpuFallbackTarget() const; - void presentCpuFallback(int w, int h) const; + void presentCpuFallback(int frameWidth, int frameHeight) const; }; } // namespace Rv diff --git a/src/lib/app/RvCommon/RvCommon/RvDocument.h b/src/lib/app/RvCommon/RvCommon/RvDocument.h index 271aa3e4f..dce279a9e 100644 --- a/src/lib/app/RvCommon/RvCommon/RvDocument.h +++ b/src/lib/app/RvCommon/RvCommon/RvDocument.h @@ -191,6 +191,9 @@ namespace Rv void rebuildGLView(bool stereo, bool vsync, bool dbl, int, int, int, int); + // New GLView sharing the front document's GL context when it has one. + GLView* createGLView(int redBits, int greenBits, int blueBits, int alphaBits); + void setActiveViewContentSize(int w, int h); void setActiveViewMinimumContentSize(int w, int h); bool activeViewFirstPaintCompleted() const; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h index caa7b87b5..7dc928b9b 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanWindow.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanWindow.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -164,11 +165,11 @@ namespace Rv // armed). Always true for the control viewport. bool canPresentNow(); - const SharedImageInfo* getSharedImageInfo(int w, int h); + const SharedImageInfo* getSharedImageInfo(int requestedWidth, int requestedHeight); void presentSharedImage(); // CPU fallback API (not used when GPU interop is active) - void presentPixelData(const void* pixels, int w, int h); + void presentPixelData(const void* pixels, int pixelWidth, int pixelHeight); bool isInitialized() const { return m_initialized; } @@ -299,6 +300,22 @@ namespace Rv // wait on it cannot hang, consuming waitSemaphore if one is given. void recoverFailedSubmit(uint32_t slot, VkSemaphore waitSemaphore); + // Fence/acquire timeout: blocking for the control viewport, polling + // for a passive output unless its last present is stale. + uint64_t presentWaitTimeout() const; + + // Waits for the slot fence. False means skip the frame. drainShared + // is set on the interop path, where GL has already signaled glReady. + bool waitForFrameFence(uint32_t slot, uint64_t waitTimeout, bool drainShared); + + // Acquires a swapchain image and claims the slot fence for it. Empty + // means skip the frame; the failure is already handled. + std::optional acquireFrameImage(uint32_t slot, uint64_t waitTimeout, bool drainShared); + + // Hands the diag event time to the slot, advances the frame and + // presents imageIndex. + void presentFrame(uint32_t slot, uint32_t imageIndex); + // Recreate the swapchain after OUT_OF_DATE. SUBOPTIMAL remains usable. void handleSwapchainOutOfDate(); diff --git a/src/lib/app/RvCommon/RvDocument.cpp b/src/lib/app/RvCommon/RvDocument.cpp index cc0ca5094..d2cd9b6bd 100644 --- a/src/lib/app/RvCommon/RvDocument.cpp +++ b/src/lib/app/RvCommon/RvDocument.cpp @@ -98,6 +98,20 @@ namespace Rv #define DBL(level, x) #endif +#if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) + namespace + { + // Input and layout settings shared by every main view widget. + void configureViewWidget(QWidget* view) + { + view->setFocusPolicy(Qt::StrongFocus); + view->setMouseTracking(true); + view->setAcceptDrops(true); + view->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + } // namespace +#endif + inline QString utf8(const std::string& us) { return QString::fromUtf8(us.c_str()); } static int sessionCount = 0; @@ -168,8 +182,6 @@ namespace Rv setMenuBar(new QMenuBar(0)); #endif - const TwkApp::Application::Documents& docs = TwkApp::App()->documents(); - setWindowIcon(QIcon(qApp->applicationDirPath() + QString(RV_ICON_PATH_SUFFIX))); Rv::Options& opts = Options::sharedOptions(); @@ -238,10 +250,7 @@ namespace Rv { m_vulkanView = new VulkanView(this, m_centralWidget, !m_startupResize); - m_vulkanView->setFocusPolicy(Qt::StrongFocus); - m_vulkanView->setMouseTracking(true); - m_vulkanView->setAcceptDrops(true); - m_vulkanView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + configureViewWidget(m_vulkanView); m_vulkanView->resize(m_vulkanView->sizeHint()); m_viewWidget = m_vulkanView; @@ -249,42 +258,11 @@ namespace Rv } else { - if (docs.empty()) - { - m_glView = - new GLView(this, 0, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, - true, opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits, !m_startupResize); - } - else - { - RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = static_cast(s->opaquePointer()); - // view() is null if the front document is on Vulkan. - QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; - m_glView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), - opts.vsync != 0 && !m_vsyncDisabled, - true, // double buffer - opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits, !m_startupResize); - } + m_glView = createGLView(opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits); m_viewWidget = m_glView; } #else - if (docs.empty()) - { - m_glView = - new GLView(this, 0, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, - true, opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits, !m_startupResize); - } - else - { - RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = (RvDocument*)s->opaquePointer(); - QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; - m_glView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), - opts.vsync != 0 && !m_vsyncDisabled, - true, // double buffer - opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits, !m_startupResize); - } + m_glView = createGLView(opts.dispRedBits, opts.dispGreenBits, opts.dispBlueBits, opts.dispAlphaBits); m_viewWidget = m_glView; #endif @@ -892,6 +870,26 @@ namespace Rv m_oldGLView = 0; } + GLView* RvDocument::createGLView(int redBits, int greenBits, int blueBits, int alphaBits) + { + const Rv::Options& opts = Options::sharedOptions(); + const TwkApp::Application::Documents& docs = TwkApp::App()->documents(); + + // Share with the front document's context. It is null when that + // document is on Vulkan, or is this document mid-fallback. + QOpenGLContext* shareContext = nullptr; + if (!docs.empty()) + { + const RvDocument* frontDoc = static_cast(static_cast(docs.front())->opaquePointer()); + shareContext = frontDoc->view() ? frontDoc->view()->context() : nullptr; + } + + return new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), + opts.vsync != 0 && !m_vsyncDisabled, + true, // double buffer + redBits, greenBits, blueBits, alphaBits, !m_startupResize); + } + #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) // Hot-swap VulkanView -> GLView and rebind the live session to the GL device. void RvDocument::fallbackVulkanToGLView() @@ -923,31 +921,12 @@ namespace Rv const int fallbackGreenBits = requestedTenBit ? 8 : opts.dispGreenBits; const int fallbackBlueBits = requestedTenBit ? 8 : opts.dispBlueBits; const int fallbackAlphaBits = requestedTenBit ? 8 : opts.dispAlphaBits; - const TwkApp::Application::Documents& docs = TwkApp::App()->documents(); - GLView* newGLView = nullptr; - if (docs.size() <= 1) - { - newGLView = - new GLView(this, 0, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), opts.vsync != 0 && !m_vsyncDisabled, - true, fallbackRedBits, fallbackGreenBits, fallbackBlueBits, fallbackAlphaBits, !m_startupResize); - } - else - { - RvSession* s = static_cast(docs.front()); - RvDocument* rvDoc = static_cast(s->opaquePointer()); - QOpenGLContext* shareContext = rvDoc->view() ? rvDoc->view()->context() : nullptr; - newGLView = new GLView(this, shareContext, this, opts.stereoMode && !strcmp(opts.stereoMode, "hardware"), - opts.vsync != 0 && !m_vsyncDisabled, true, fallbackRedBits, fallbackGreenBits, fallbackBlueBits, - fallbackAlphaBits, !m_startupResize); - } + GLView* newGLView = createGLView(fallbackRedBits, fallbackGreenBits, fallbackBlueBits, fallbackAlphaBits); newGLView->setContentSize(oldVulkanView->sizeHint().width(), oldVulkanView->sizeHint().height()); newGLView->setMinimumSize(QSize(oldVulkanView->minimumSizeHint().width(), oldVulkanView->minimumSizeHint().height())); - newGLView->setFocusPolicy(Qt::StrongFocus); - newGLView->setMouseTracking(true); - newGLView->setAcceptDrops(true); - newGLView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + configureViewWidget(newGLView); m_stackedLayout->removeWidget(oldVulkanView); m_stackedLayout->addWidget(newGLView); @@ -1005,10 +984,7 @@ namespace Rv newVulkanView->setContentSize(oldGLView->sizeHint().width(), oldGLView->sizeHint().height()); newVulkanView->setMinimumContentSize(oldGLView->minimumSizeHint().width(), oldGLView->minimumSizeHint().height()); newVulkanView->setMinimumSize(QSize(oldGLView->minimumSizeHint().width(), oldGLView->minimumSizeHint().height())); - newVulkanView->setFocusPolicy(Qt::StrongFocus); - newVulkanView->setMouseTracking(true); - newVulkanView->setAcceptDrops(true); - newVulkanView->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + configureViewWidget(newVulkanView); m_stackedLayout->addWidget(newVulkanView); m_stackedLayout->removeWidget(oldGLView); diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index c78f26b01..a9961940f 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -70,37 +70,48 @@ namespace Rv { using namespace std; - // A best-effort output forces one blocking present once it is this stale. - static constexpr double kMaxStaleSeconds = 0.1; - - // -debug gpu frame-time accumulators, fed only by control viewports. They - // are shared: with several documents open the totals are combined. - static unsigned int s_diagFrames = 0; - static double s_diagRenderMs = 0.0; - static double s_diagMainPresentMs = 0.0; - static double s_diagOutPresentMs = 0.0; - static double s_diagFenceWaitMs = 0.0; - static double s_diagAcquireMs = 0.0; - static double s_diagLoopMs = 0.0; - static TwkUtil::Timer s_diagLoopTimer; - // Post-present work: in the frame period but not in "total". - static double s_diagPostRenderMs = 0.0; - // handler: time in the pointer handler. eventToRender: age of the newest - // pointer event when its frame starts rendering. - static double s_diagPointerHandlerMs = 0.0; - static unsigned int s_diagPointerEvents = 0; - static double s_diagPointerAgeMs = 0.0; - static unsigned int s_diagPointerAgeSamples = 0; - static TwkUtil::Timer s_diagPointerTimer; - static bool s_diagPointerPending = false; - - // eventToRetire: end-to-end latency, from the pointer event to its - // frame's GPU retirement. Needs an absolute clock since retirement lags - // by several frames. - static TwkUtil::Timer s_diagClock; - namespace { + // A best-effort output forces one blocking present once it is this stale. + constexpr double kMaxStaleSeconds = 0.1; + + // -debug gpu frame-time accumulators, fed only by control viewports. + // They are shared: with several documents open the totals are + // combined. Reset after each 60-frame report. + struct DiagStats + { + unsigned int frames{0}; + double renderMs{0.0}; + double mainPresentMs{0.0}; + double outPresentMs{0.0}; + double fenceWaitMs{0.0}; + double acquireMs{0.0}; + double loopMs{0.0}; + // Post-present work: in the frame period but not in "total". + double postRenderMs{0.0}; + // handler: time in the pointer handler. eventToRender: age of the + // newest pointer event when its frame starts rendering. + double pointerHandlerMs{0.0}; + unsigned int pointerEvents{0}; + double pointerAgeMs{0.0}; + unsigned int pointerAgeSamples{0}; + // eventToRetire: end-to-end latency, from the pointer event to its + // frame's GPU retirement. + double eventToRetireMs{0.0}; + unsigned int eventToRetireSamples{0}; + }; + + DiagStats s_diag; + + // Timing state that survives a report reset. + TwkUtil::Timer s_diagLoopTimer; + TwkUtil::Timer s_diagPointerTimer; + bool s_diagPointerPending = false; + + // Absolute clock for eventToRetire, since retirement lags by several + // frames. + TwkUtil::Timer s_diagClock; + double diagNow() { if (!s_diagClock.isRunning()) @@ -109,15 +120,52 @@ namespace Rv } return s_diagClock.elapsed(); } - } // namespace - // Event time for the frame being rendered (-1 if none), handed to its - // slot at submit. - static double s_diagFrameEventTime = -1.0; - static std::array s_diagSlotEventTime{}; - static std::array s_diagSlotArmed{}; - static double s_diagEventToRetireMs = 0.0; - static unsigned int s_diagEventToRetireSamples = 0; + // Event time for the frame being rendered, handed to its slot at + // submit. A slot holds a value until its fence retires. + std::optional s_diagFrameEventTime; + std::array, VulkanWindow::kFramesInFlight> s_diagSlotEventTime{}; + + // Adds the scope's duration in milliseconds to accumulatorMs when + // enabled. + class ScopedDiagTimer + { + public: + ScopedDiagTimer(double& accumulatorMs, bool enabled) + : m_accumulatorMs(accumulatorMs) + , m_enabled(enabled) + { + if (m_enabled) + { + m_timer.start(); + } + } + + ~ScopedDiagTimer() + { + if (m_enabled) + { + m_accumulatorMs += m_timer.elapsed() * 1000.0; + } + } + + ScopedDiagTimer(const ScopedDiagTimer&) = delete; + ScopedDiagTimer& operator=(const ScopedDiagTimer&) = delete; + + private: + double& m_accumulatorMs; + bool m_enabled; + TwkUtil::Timer m_timer; + }; + +#ifdef PLATFORM_WINDOWS + constexpr VkExternalMemoryHandleTypeFlagBits kMemoryHandleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; + constexpr VkExternalSemaphoreHandleTypeFlagBits kSemaphoreHandleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; +#else + constexpr VkExternalMemoryHandleTypeFlagBits kMemoryHandleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; + constexpr VkExternalSemaphoreHandleTypeFlagBits kSemaphoreHandleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; +#endif + } // namespace using namespace TwkApp; using namespace IPCore; @@ -1117,6 +1165,17 @@ namespace Rv return reinterpret_cast(vkGetDeviceProcAddr(device, name)); } + // Resets commandBuffer and begins a one-time-submit recording. + void beginOneTimeCommands(VkCommandBuffer commandBuffer) + { + vkResetCommandBuffer(commandBuffer, 0); + + VkCommandBufferBeginInfo beginInfo = {}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(commandBuffer, &beginInfo); + } + // Single-subresource colour image layout transition. void transitionImageLayout(VkCommandBuffer commandBuffer, VkImage image, VkImageLayout oldLayout, VkImageLayout newLayout, VkAccessFlags2 srcAccessMask, VkAccessFlags2 dstAccessMask, VkPipelineStageFlags2 srcStageMask, @@ -1318,12 +1377,6 @@ namespace Rv // TRANSFER_SRC the driver may pick a compressed layout GL cannot read. cfg.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; -#ifdef PLATFORM_WINDOWS - const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - const VkExternalMemoryHandleTypeFlagBits handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif - // Exportability alone does not guarantee a usable import, so try the // vendor's preferred tiling first. See useOptimalTilingForInterop(). const bool preferOptimal = useOptimalTilingForInterop(m_vkPhysicalDevice); @@ -1334,7 +1387,7 @@ namespace Rv { VkPhysicalDeviceExternalImageFormatInfo extInfo = {}; extInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO; - extInfo.handleType = handleType; + extInfo.handleType = kMemoryHandleType; VkPhysicalDeviceImageFormatInfo2 fmtInfo = {}; fmtInfo.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2; @@ -1360,7 +1413,7 @@ namespace Rv return false; } - return (extProps.externalMemoryProperties.compatibleHandleTypes & handleType) != 0 + return (extProps.externalMemoryProperties.compatibleHandleTypes & kMemoryHandleType) != 0 && (features & VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT) != 0 && (features & VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT) != 0; }; @@ -1730,7 +1783,7 @@ namespace Rv return true; } - const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int w, int h) + const VulkanWindow::SharedImageInfo* VulkanWindow::getSharedImageInfo(int requestedWidth, int requestedHeight) { if (!m_vkDevice || !m_externalInteropSupported || m_sharedImageUnusable) { @@ -1756,10 +1809,10 @@ namespace Rv } // Within capacity: reuse the export, update the used sub-region. - if (m_shared[slot].image && w <= m_shared[slot].capacityW && h <= m_shared[slot].capacityH) + if (m_shared[slot].image && requestedWidth <= m_shared[slot].capacityW && requestedHeight <= m_shared[slot].capacityH) { - info.width = w; - info.height = h; + info.width = requestedWidth; + info.height = requestedHeight; return &info; } @@ -1773,8 +1826,8 @@ namespace Rv screenW = static_cast(scr->geometry().width() * dpr); screenH = static_cast(scr->geometry().height() * dpr); } - const int capW = std::max({w, screenW, m_shared[slot].capacityW}); - const int capH = std::max({h, screenH, m_shared[slot].capacityH}); + const int capW = std::max({requestedWidth, screenW, m_shared[slot].capacityW}); + const int capH = std::max({requestedHeight, screenH, m_shared[slot].capacityH}); const bool optimalTiling = m_interopConfig.tiling == VK_IMAGE_TILING_OPTIMAL; @@ -1803,17 +1856,20 @@ namespace Rv cleanupSharedImage(slot); + const auto failSharedImage = [this, slot](std::string_view message) -> const SharedImageInfo* + { + cerr << "ERROR: VulkanWindow: " << message << endl; + cleanupSharedImage(slot); + return nullptr; + }; + // Unconditional: fires about once per slot per session. cout << "INFO: VulkanWindow: getSharedImageInfo: (re)allocating shared image slot " << slot << " capacity " << capW << "x" << capH - << " for request " << w << "x" << h << " tiling=" << tilingName(m_interopConfig.tiling) << endl; + << " for request " << requestedWidth << "x" << requestedHeight << " tiling=" << tilingName(m_interopConfig.tiling) << endl; VkExternalMemoryImageCreateInfo extMemInfo = {}; extMemInfo.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO; -#ifdef PLATFORM_WINDOWS - extMemInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - extMemInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif + extMemInfo.handleTypes = kMemoryHandleType; VkImageCreateInfo imageInfo = {}; imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; @@ -1927,11 +1983,7 @@ namespace Rv VkExportMemoryAllocateInfo exportAllocInfo = {}; exportAllocInfo.sType = VK_STRUCTURE_TYPE_EXPORT_MEMORY_ALLOCATE_INFO; exportAllocInfo.pNext = chain; -#ifdef PLATFORM_WINDOWS - exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - exportAllocInfo.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif + exportAllocInfo.handleTypes = kMemoryHandleType; VkMemoryAllocateInfo allocInfo = {}; allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; @@ -1941,46 +1993,36 @@ namespace Rv findMemoryType(m_vkPhysicalDevice, memReqs.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); if (!memoryTypeIndex) { - cerr << "ERROR: VulkanWindow: No device-local memory type for shared image" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("No device-local memory type for shared image"); } allocInfo.memoryTypeIndex = *memoryTypeIndex; if (vkAllocateMemory(m_vkDevice, &allocInfo, nullptr, &m_shared[slot].memory) != VK_SUCCESS) { - cerr << "ERROR: VulkanWindow: Failed to allocate shared image memory" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to allocate shared image memory"); } if (vkBindImageMemory(m_vkDevice, m_shared[slot].image, m_shared[slot].memory, 0) != VK_SUCCESS) { - cerr << "ERROR: VulkanWindow: Failed to bind shared image memory" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to bind shared image memory"); } #ifdef PLATFORM_WINDOWS auto pfnGetMemoryWin32HandleKHR = deviceProc(m_vkDevice, "vkGetMemoryWin32HandleKHR"); if (!pfnGetMemoryWin32HandleKHR) { - cerr << "ERROR: VulkanWindow: vkGetMemoryWin32HandleKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("vkGetMemoryWin32HandleKHR not found"); } VkMemoryGetWin32HandleInfoKHR getHandleInfo = {}; getHandleInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_WIN32_HANDLE_INFO_KHR; getHandleInfo.memory = m_shared[slot].memory; - getHandleInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_WIN32_BIT; + getHandleInfo.handleType = kMemoryHandleType; HANDLE memHandle = nullptr; if (pfnGetMemoryWin32HandleKHR(m_vkDevice, &getHandleInfo, &memHandle) != VK_SUCCESS || !memHandle) { - cerr << "ERROR: VulkanWindow: Failed to get memory HANDLE" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to get memory HANDLE"); } // Stored at once so cleanupSharedImage() closes it on a later failure. info.memoryHandle = memHandle; @@ -1988,22 +2030,18 @@ namespace Rv auto pfnGetMemoryFdKHR = deviceProc(m_vkDevice, "vkGetMemoryFdKHR"); if (!pfnGetMemoryFdKHR) { - cerr << "ERROR: VulkanWindow: vkGetMemoryFdKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("vkGetMemoryFdKHR not found"); } VkMemoryGetFdInfoKHR getFdInfo = {}; getFdInfo.sType = VK_STRUCTURE_TYPE_MEMORY_GET_FD_INFO_KHR; getFdInfo.memory = m_shared[slot].memory; - getFdInfo.handleType = VK_EXTERNAL_MEMORY_HANDLE_TYPE_OPAQUE_FD_BIT; + getFdInfo.handleType = kMemoryHandleType; int memFd = -1; if (pfnGetMemoryFdKHR(m_vkDevice, &getFdInfo, &memFd) != VK_SUCCESS) { - cerr << "ERROR: VulkanWindow: Failed to get memory FD" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to get memory FD"); } // Stored at once so cleanupSharedImage() closes it on a later failure. info.memoryFd = memFd; @@ -2011,11 +2049,7 @@ namespace Rv VkExportSemaphoreCreateInfo exportSemInfo = {}; exportSemInfo.sType = VK_STRUCTURE_TYPE_EXPORT_SEMAPHORE_CREATE_INFO; -#ifdef PLATFORM_WINDOWS - exportSemInfo.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; -#else - exportSemInfo.handleTypes = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; -#endif + exportSemInfo.handleTypes = kSemaphoreHandleType; VkSemaphoreCreateInfo semInfo = {}; semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; @@ -2024,23 +2058,19 @@ namespace Rv if (vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_shared[slot].glReadySemaphore) != VK_SUCCESS || vkCreateSemaphore(m_vkDevice, &semInfo, nullptr, &m_shared[slot].vkReadySemaphore) != VK_SUCCESS) { - cerr << "ERROR: VulkanWindow: Failed to create shared semaphores" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to create shared semaphores"); } #ifdef PLATFORM_WINDOWS auto pfnGetSemaphoreWin32HandleKHR = deviceProc(m_vkDevice, "vkGetSemaphoreWin32HandleKHR"); if (!pfnGetSemaphoreWin32HandleKHR) { - cerr << "ERROR: VulkanWindow: vkGetSemaphoreWin32HandleKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("vkGetSemaphoreWin32HandleKHR not found"); } VkSemaphoreGetWin32HandleInfoKHR getSemHandleInfo = {}; getSemHandleInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_WIN32_HANDLE_INFO_KHR; - getSemHandleInfo.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_WIN32_BIT; + getSemHandleInfo.handleType = kSemaphoreHandleType; HANDLE glReadyHandle = nullptr; HANDLE vkReadyHandle = nullptr; @@ -2048,36 +2078,30 @@ namespace Rv getSemHandleInfo.semaphore = m_shared[slot].glReadySemaphore; if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &glReadyHandle) != VK_SUCCESS || !glReadyHandle) { - cerr << "ERROR: VulkanWindow: Failed to get glReady semaphore HANDLE" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to get glReady semaphore HANDLE"); } info.glReadySemaphoreHandle = glReadyHandle; getSemHandleInfo.semaphore = m_shared[slot].vkReadySemaphore; if (pfnGetSemaphoreWin32HandleKHR(m_vkDevice, &getSemHandleInfo, &vkReadyHandle) != VK_SUCCESS || !vkReadyHandle) { - cerr << "ERROR: VulkanWindow: Failed to get vkReady semaphore HANDLE" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to get vkReady semaphore HANDLE"); } info.vkReadySemaphoreHandle = vkReadyHandle; info.size = memReqs.size; - info.width = w; - info.height = h; + info.width = requestedWidth; + info.height = requestedHeight; #else auto pfnGetSemaphoreFdKHR = deviceProc(m_vkDevice, "vkGetSemaphoreFdKHR"); if (!pfnGetSemaphoreFdKHR) { - cerr << "ERROR: VulkanWindow: vkGetSemaphoreFdKHR not found" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("vkGetSemaphoreFdKHR not found"); } VkSemaphoreGetFdInfoKHR getSemFdInfo = {}; getSemFdInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR; - getSemFdInfo.handleType = VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT; + getSemFdInfo.handleType = kSemaphoreHandleType; int glReadyFd = -1; int vkReadyFd = -1; @@ -2085,33 +2109,24 @@ namespace Rv getSemFdInfo.semaphore = m_shared[slot].glReadySemaphore; if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &glReadyFd) != VK_SUCCESS || glReadyFd < 0) { - cerr << "ERROR: VulkanWindow: Failed to get glReady semaphore FD" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to get glReady semaphore FD"); } info.glReadySemaphoreFd = glReadyFd; getSemFdInfo.semaphore = m_shared[slot].vkReadySemaphore; if (pfnGetSemaphoreFdKHR(m_vkDevice, &getSemFdInfo, &vkReadyFd) != VK_SUCCESS || vkReadyFd < 0) { - cerr << "ERROR: VulkanWindow: Failed to get vkReady semaphore FD" << endl; - cleanupSharedImage(slot); - return nullptr; + return failSharedImage("Failed to get vkReady semaphore FD"); } info.vkReadySemaphoreFd = vkReadyFd; info.size = memReqs.size; - info.width = w; - info.height = h; + info.width = requestedWidth; + info.height = requestedHeight; #endif VkCommandBuffer cb = m_vkCommandBuffers[0]; - vkResetCommandBuffer(cb, 0); - - VkCommandBufferBeginInfo beginInfo = {}; - beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cb, &beginInfo); + beginOneTimeCommands(cb); transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_2_NONE, VK_ACCESS_2_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_2_NONE, VK_PIPELINE_STAGE_2_TRANSFER_BIT); @@ -2148,109 +2163,157 @@ namespace Rv } //-------------------------------------------------------------------------- - // presentSharedImage + // Frame helpers shared by presentSharedImage() and presentPixelData() //-------------------------------------------------------------------------- - void VulkanWindow::presentSharedImage() + uint64_t VulkanWindow::presentWaitTimeout() const { - const uint32_t slot = m_currentFrame; - const SharedImageInfo& info = m_shared[slot].info; - - if (!m_vkDevice || !m_shared[slot].image || !m_vkSwapchain) - { - return; - } - - // Fence wait vs acquire timing tells GPU from vblank back-pressure. - const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; - Timer diagTimer; - // The fence wait and acquire pace the control viewport to refresh. A // passive output polls instead, so a second display's vblank never // enters the loop; a skipped frame keeps the previous image. const bool bestEffort = isPassiveOutput(); const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); - const uint64_t waitTimeout = (!bestEffort || forceProgress) ? std::numeric_limits::max() : 0; - - if (diagPresent) - { - diagTimer.start(); - } - - VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, waitTimeout); + return (!bestEffort || forceProgress) ? std::numeric_limits::max() : 0; + } - if (diagPresent) + bool VulkanWindow::waitForFrameFence(uint32_t slot, uint64_t waitTimeout, bool drainShared) + { + // Fence wait vs acquire timing tells GPU from vblank back-pressure. + VkResult fenceResult = VK_SUCCESS; { - s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; + ScopedDiagTimer diagTimer(s_diag.fenceWaitMs, IPCore::ImageRenderer::debugGpu() && m_doc); + fenceResult = vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, waitTimeout); } if (fenceResult == VK_TIMEOUT) { // The slot is not advanced: the next frame retries it. - drainSharedSemaphores(slot); + if (drainShared) + { + drainSharedSemaphores(slot); + } requestBestEffortRetry(); - return; + return false; } if (fenceResult != VK_SUCCESS) { requestGLFallback(); - return; + return false; } + return true; + } - uint32_t imageIndex; - if (diagPresent) + std::optional VulkanWindow::acquireFrameImage(uint32_t slot, uint64_t waitTimeout, bool drainShared) + { + uint32_t imageIndex = 0; + VkResult result = VK_SUCCESS; { - diagTimer.start(); + ScopedDiagTimer diagTimer(s_diag.acquireMs, IPCore::ImageRenderer::debugGpu() && m_doc); + result = vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, + &imageIndex); } - VkResult result = - vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, &imageIndex); + if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) + { + // A failed acquire leaves the semaphore unsignaled, so skip here. + if (drainShared) + { + drainSharedSemaphores(slot); + } + if (result == VK_NOT_READY || result == VK_TIMEOUT) + { + requestBestEffortRetry(); + } + else if (result == VK_ERROR_OUT_OF_DATE_KHR) + { + handleSwapchainOutOfDate(); + } + else if (result == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + return std::nullopt; + } - if (diagPresent) + if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) { - s_diagAcquireMs += diagTimer.elapsed() * 1000.0; + vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); } + m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; + + vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); + + return imageIndex; + } - if (result == VK_NOT_READY || result == VK_TIMEOUT) + void VulkanWindow::presentFrame(uint32_t slot, uint32_t imageIndex) + { + if (m_doc && s_diagFrameEventTime) { - // Skip here, not after a successful acquire: a failed acquire - // leaves the semaphore unsignaled. - drainSharedSemaphores(slot); - requestBestEffortRetry(); - return; + s_diagSlotEventTime[slot] = s_diagFrameEventTime; + s_diagFrameEventTime.reset(); } - if (result == VK_ERROR_OUT_OF_DATE_KHR) + m_currentFrame = (m_currentFrame + 1) % kFramesInFlight; + + VkPresentInfoKHR presentInfo = {}; + presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + presentInfo.waitSemaphoreCount = 1; + presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; + const std::array swapchains = {m_vkSwapchain}; + presentInfo.swapchainCount = static_cast(swapchains.size()); + presentInfo.pSwapchains = swapchains.data(); + presentInfo.pImageIndices = &imageIndex; + + const VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); + + m_lastPresentTimer.stop(); + m_lastPresentTimer.start(); + + // See maxFramesInFlight(). After the present so the driver gets the + // frame early; never for the passive output. + if (maxFramesInFlight() == 1 && !isPassiveOutput()) + { + vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); + } + + // Recreate only on OUT_OF_DATE: some X11/RADV compositors report + // SUBOPTIMAL persistently. + if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) { - drainSharedSemaphores(slot); handleSwapchainOutOfDate(); - return; } - if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) + else if (presentResult == VK_ERROR_DEVICE_LOST) + { + requestGLFallback(); + } + } + + void VulkanWindow::presentSharedImage() + { + const uint32_t slot = m_currentFrame; + const SharedImageInfo& info = m_shared[slot].info; + + if (!m_vkDevice || !m_shared[slot].image || !m_vkSwapchain) { - drainSharedSemaphores(slot); - if (result == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } return; } - if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) + const uint64_t waitTimeout = presentWaitTimeout(); + if (!waitForFrameFence(slot, waitTimeout, /*drainShared*/ true)) { - vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); + return; } - m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; - vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); + const std::optional acquiredIndex = acquireFrameImage(slot, waitTimeout, /*drainShared*/ true); + if (!acquiredIndex) + { + return; + } + const uint32_t imageIndex = *acquiredIndex; VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; - vkResetCommandBuffer(cb, 0); - - VkCommandBufferBeginInfo beginInfo = {}; - beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cb, &beginInfo); + beginOneTimeCommands(cb); // Source stage TRANSFER chains the transition after the glReady wait. transitionImageLayout(cb, m_shared[slot].image, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, @@ -2314,57 +2377,14 @@ namespace Rv return; } - if (m_doc && s_diagFrameEventTime >= 0.0) - { - s_diagSlotEventTime[slot] = s_diagFrameEventTime; - s_diagSlotArmed[slot] = true; - s_diagFrameEventTime = -1.0; - } - - m_currentFrame = (m_currentFrame + 1) % kFramesInFlight; - - VkPresentInfoKHR presentInfo = {}; - presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; - presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; - const std::array swapchains = {m_vkSwapchain}; - presentInfo.swapchainCount = static_cast(swapchains.size()); - presentInfo.pSwapchains = swapchains.data(); - presentInfo.pImageIndices = &imageIndex; - - VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); - - m_lastPresentTimer.stop(); - m_lastPresentTimer.start(); - - // See maxFramesInFlight(). After the present so the driver gets the - // frame early; never for the passive output. - if (maxFramesInFlight() == 1 && !isPassiveOutput()) - { - vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); - } - // Recreate only on OUT_OF_DATE: some X11/RADV compositors report - // SUBOPTIMAL persistently. - if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) - { - handleSwapchainOutOfDate(); - return; - } - if (presentResult != VK_SUCCESS && presentResult != VK_SUBOPTIMAL_KHR) - { - if (presentResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } + presentFrame(slot, imageIndex); } //-------------------------------------------------------------------------- // presentPixelData //-------------------------------------------------------------------------- - void VulkanWindow::presentPixelData(const void* pixels, int w, int h) + void VulkanWindow::presentPixelData(const void* pixels, int pixelWidth, int pixelHeight) { const uint32_t slot = m_currentFrame; @@ -2373,41 +2393,19 @@ namespace Rv return; } - const bool diagPresent = IPCore::ImageRenderer::debugGpu() && m_doc; - Timer diagTimer; - - if (w <= 0 || h <= 0 || !ensureSwapchainMatchesSurface()) + if (pixelWidth <= 0 || pixelHeight <= 0 || !ensureSwapchainMatchesSurface()) { return; } // Same best-effort throttle as presentSharedImage(). - const bool bestEffort = isPassiveOutput(); - const bool forceProgress = bestEffort && (!m_lastPresentTimer.isRunning() || m_lastPresentTimer.elapsed() > kMaxStaleSeconds); - const uint64_t waitTimeout = (!bestEffort || forceProgress) ? std::numeric_limits::max() : 0; - - if (diagPresent) - { - diagTimer.start(); - } - const VkResult fenceResult = vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, waitTimeout); - if (diagPresent) - { - s_diagFenceWaitMs += diagTimer.elapsed() * 1000.0; - } - - if (fenceResult == VK_TIMEOUT) + const uint64_t waitTimeout = presentWaitTimeout(); + if (!waitForFrameFence(slot, waitTimeout, /*drainShared*/ false)) { - requestBestEffortRetry(); - return; - } - if (fenceResult != VK_SUCCESS) - { - requestGLFallback(); return; } - const size_t size = static_cast(w) * static_cast(h) * 4; + const size_t size = static_cast(pixelWidth) * static_cast(pixelHeight) * 4; if (size > m_staging[slot].size) { @@ -2475,68 +2473,33 @@ namespace Rv memcpy(data, pixels, size); vkUnmapMemory(m_vkDevice, m_staging[slot].memory); - uint32_t imageIndex; - if (diagPresent) + const std::optional acquiredIndex = acquireFrameImage(slot, waitTimeout, /*drainShared*/ false); + if (!acquiredIndex) { - diagTimer.start(); - } - VkResult result = - vkAcquireNextImageKHR(m_vkDevice, m_vkSwapchain, waitTimeout, m_frameSync[slot].imageAvailable, VK_NULL_HANDLE, &imageIndex); - if (diagPresent) - { - s_diagAcquireMs += diagTimer.elapsed() * 1000.0; - } - if (result == VK_NOT_READY || result == VK_TIMEOUT) - { - requestBestEffortRetry(); return; } - if (result == VK_ERROR_OUT_OF_DATE_KHR) - { - handleSwapchainOutOfDate(); - return; - } - if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) - { - if (result == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } - - if (m_imagesInFlight[imageIndex] != VK_NULL_HANDLE) - { - vkWaitForFences(m_vkDevice, 1, &m_imagesInFlight[imageIndex], VK_TRUE, std::numeric_limits::max()); - } - m_imagesInFlight[imageIndex] = m_frameSync[slot].fence; - - vkResetFences(m_vkDevice, 1, &m_frameSync[slot].fence); + const uint32_t imageIndex = *acquiredIndex; VkCommandBuffer cb = m_vkCommandBuffers[imageIndex]; - vkResetCommandBuffer(cb, 0); - - VkCommandBufferBeginInfo beginInfo = {}; - beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; - beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - vkBeginCommandBuffer(cb, &beginInfo); + beginOneTimeCommands(cb); transitionImageLayout(cb, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_2_NONE, VK_ACCESS_2_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_PIPELINE_STAGE_2_TRANSFER_BIT); - // The buffer is w x h, but the destination is bounded by the swapchain. + // The buffer is pixelWidth x pixelHeight, but the destination is + // bounded by the swapchain. VkBufferImageCopy region = {}; region.bufferOffset = 0; - region.bufferRowLength = static_cast(w); - region.bufferImageHeight = static_cast(h); + region.bufferRowLength = static_cast(pixelWidth); + region.bufferImageHeight = static_cast(pixelHeight); region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; region.imageSubresource.mipLevel = 0; region.imageSubresource.baseArrayLayer = 0; region.imageSubresource.layerCount = 1; region.imageOffset = {0, 0, 0}; - region.imageExtent = {std::min(static_cast(w), m_vkSwapchainExtent.width), - std::min(static_cast(h), m_vkSwapchainExtent.height), 1}; + region.imageExtent = {std::min(static_cast(pixelWidth), m_vkSwapchainExtent.width), + std::min(static_cast(pixelHeight), m_vkSwapchainExtent.height), 1}; vkCmdCopyBufferToImage(cb, m_staging[slot].buffer, m_vkSwapchainImages[imageIndex], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); @@ -2555,49 +2518,7 @@ namespace Rv return; } - if (m_doc && s_diagFrameEventTime >= 0.0) - { - s_diagSlotEventTime[slot] = s_diagFrameEventTime; - s_diagSlotArmed[slot] = true; - s_diagFrameEventTime = -1.0; - } - - m_currentFrame = (m_currentFrame + 1) % kFramesInFlight; - - VkPresentInfoKHR presentInfo = {}; - presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; - presentInfo.waitSemaphoreCount = 1; - presentInfo.pWaitSemaphores = &m_vkRenderFinished[imageIndex]; - const std::array swapchains = {m_vkSwapchain}; - presentInfo.swapchainCount = static_cast(swapchains.size()); - presentInfo.pSwapchains = swapchains.data(); - presentInfo.pImageIndices = &imageIndex; - - VkResult presentResult = vkQueuePresentKHR(m_vkQueue, &presentInfo); - - m_lastPresentTimer.stop(); - m_lastPresentTimer.start(); - - // See maxFramesInFlight(). After the present so the driver gets the - // frame early; never for the passive output. - if (maxFramesInFlight() == 1 && !isPassiveOutput()) - { - vkWaitForFences(m_vkDevice, 1, &m_frameSync[slot].fence, VK_TRUE, std::numeric_limits::max()); - } - // See presentSharedImage() on SUBOPTIMAL. - if (presentResult == VK_ERROR_OUT_OF_DATE_KHR) - { - handleSwapchainOutOfDate(); - return; - } - if (presentResult != VK_SUCCESS && presentResult != VK_SUBOPTIMAL_KHR) - { - if (presentResult == VK_ERROR_DEVICE_LOST) - { - requestGLFallback(); - } - return; - } + presentFrame(slot, imageIndex); } //-------------------------------------------------------------------------- @@ -2636,14 +2557,14 @@ namespace Rv { if (s_diagLoopTimer.isRunning()) { - s_diagLoopMs += s_diagLoopTimer.elapsed() * 1000.0; + s_diag.loopMs += s_diagLoopTimer.elapsed() * 1000.0; } s_diagLoopTimer.start(); if (s_diagPointerPending) { - s_diagPointerAgeMs += s_diagPointerTimer.elapsed() * 1000.0; - ++s_diagPointerAgeSamples; + s_diag.pointerAgeMs += s_diagPointerTimer.elapsed() * 1000.0; + ++s_diag.pointerAgeSamples; s_diagPointerPending = false; s_diagFrameEventTime = diagNow() - s_diagPointerTimer.elapsed(); } @@ -2651,12 +2572,12 @@ namespace Rv // Close out retired slots (non-blocking; one frame of quantisation). for (uint32_t i = 0; i < kFramesInFlight; ++i) { - if (s_diagSlotArmed[i] && m_vkDevice && m_frameSync[i].fence + if (s_diagSlotEventTime[i] && m_vkDevice && m_frameSync[i].fence && vkGetFenceStatus(m_vkDevice, m_frameSync[i].fence) == VK_SUCCESS) { - s_diagEventToRetireMs += (diagNow() - s_diagSlotEventTime[i]) * 1000.0; - ++s_diagEventToRetireSamples; - s_diagSlotArmed[i] = false; + s_diag.eventToRetireMs += (diagNow() - *s_diagSlotEventTime[i]) * 1000.0; + ++s_diag.eventToRetireSamples; + s_diagSlotEventTime[i].reset(); } } } @@ -2679,18 +2600,9 @@ namespace Rv absolutePosition(x, y); m_videoDevice->setAbsolutePosition(x, y); - const bool diagTiming = IPCore::ImageRenderer::debugGpu(); - Timer diagTimer; - if (diagTiming) { - diagTimer.start(); - } - - session->render(); - - if (diagTiming) - { - s_diagRenderMs += diagTimer.elapsed() * 1000.0; + ScopedDiagTimer diagTimer(s_diag.renderMs, IPCore::ImageRenderer::debugGpu()); + session->render(); } if (!m_postFirstNonEmptyRender && session->postFirstNonEmptyRender()) @@ -2716,31 +2628,16 @@ namespace Rv // Always present the control viewport, even with a separate output // device: unlike GL, nothing else composites it. const bool diagPresent = IPCore::ImageRenderer::debugGpu(); - Timer diagPresentTimer; - if (diagPresent) - { - diagPresentTimer.start(); - } - - m_videoDevice->syncBuffers(); - - if (diagPresent) { - s_diagMainPresentMs += diagPresentTimer.elapsed() * 1000.0; + ScopedDiagTimer diagTimer(s_diag.mainPresentMs, diagPresent); + m_videoDevice->syncBuffers(); } if (session->outputVideoDevice() && session->outputVideoDevice() != videoDevice()) { - if (diagPresent) { - diagPresentTimer.start(); - } - - session->outputVideoDevice()->syncBuffers(); - - if (diagPresent) - { - s_diagOutPresentMs += diagPresentTimer.elapsed() * 1000.0; + ScopedDiagTimer diagTimer(s_diag.outPresentMs, diagPresent); + session->outputVideoDevice()->syncBuffers(); } // The output made its own GL context current; restore ours. @@ -2750,57 +2647,33 @@ namespace Rv if (session) { - const bool diagPost = IPCore::ImageRenderer::debugGpu(); - Timer diagPostTimer; - if (diagPost) - { - diagPostTimer.start(); - } - + ScopedDiagTimer diagTimer(s_diag.postRenderMs, IPCore::ImageRenderer::debugGpu()); session->addSyncSample(); session->postRender(); - - if (diagPost) - { - s_diagPostRenderMs += diagPostTimer.elapsed() * 1000.0; - } } // Averaged -debug gpu breakdown every 60 frames. if (IPCore::ImageRenderer::debugGpu() && m_doc) { - if (++s_diagFrames >= 60) + if (++s_diag.frames >= 60) { - const double frames = static_cast(s_diagFrames); - const double loopMs = s_diagLoopMs / frames; - cout << "INFO: VulkanWindow frame avg over " << s_diagFrames << " [depth=" << maxFramesInFlight() + const double frames = static_cast(s_diag.frames); + const double loopMs = s_diag.loopMs / frames; + cout << "INFO: VulkanWindow frame avg over " << s_diag.frames << " [depth=" << maxFramesInFlight() << " tiling=" << tilingName(m_shared[0].info.tiling) << "]" - << ": session->render()=" << (s_diagRenderMs / frames) << "ms mainPresent=" << (s_diagMainPresentMs / frames) - << "ms outputPresent=" << (s_diagOutPresentMs / frames) - << "ms total=" << ((s_diagRenderMs + s_diagMainPresentMs + s_diagOutPresentMs) / frames) - << "ms [mainPresent breakdown: fenceWait=" << (s_diagFenceWaitMs / frames) - << "ms acquire=" << (s_diagAcquireMs / frames) << "ms]" - << " postRender=" << (s_diagPostRenderMs / frames) << "ms frameInterval=" << loopMs << "ms (" + << ": session->render()=" << (s_diag.renderMs / frames) << "ms mainPresent=" << (s_diag.mainPresentMs / frames) + << "ms outputPresent=" << (s_diag.outPresentMs / frames) + << "ms total=" << ((s_diag.renderMs + s_diag.mainPresentMs + s_diag.outPresentMs) / frames) + << "ms [mainPresent breakdown: fenceWait=" << (s_diag.fenceWaitMs / frames) + << "ms acquire=" << (s_diag.acquireMs / frames) << "ms]" + << " postRender=" << (s_diag.postRenderMs / frames) << "ms frameInterval=" << loopMs << "ms (" << (loopMs > 0.0 ? 1000.0 / loopMs : 0.0) << " fps)" - << " pointer: events=" << s_diagPointerEvents - << " handler=" << (s_diagPointerEvents ? s_diagPointerHandlerMs / s_diagPointerEvents : 0.0) - << "ms eventToRender=" << (s_diagPointerAgeSamples ? s_diagPointerAgeMs / s_diagPointerAgeSamples : 0.0) - << "ms eventToRetire=" << (s_diagEventToRetireSamples ? s_diagEventToRetireMs / s_diagEventToRetireSamples : 0.0) + << " pointer: events=" << s_diag.pointerEvents + << " handler=" << (s_diag.pointerEvents ? s_diag.pointerHandlerMs / s_diag.pointerEvents : 0.0) + << "ms eventToRender=" << (s_diag.pointerAgeSamples ? s_diag.pointerAgeMs / s_diag.pointerAgeSamples : 0.0) + << "ms eventToRetire=" << (s_diag.eventToRetireSamples ? s_diag.eventToRetireMs / s_diag.eventToRetireSamples : 0.0) << "ms" << endl; - s_diagFrames = 0; - s_diagRenderMs = 0.0; - s_diagMainPresentMs = 0.0; - s_diagOutPresentMs = 0.0; - s_diagFenceWaitMs = 0.0; - s_diagAcquireMs = 0.0; - s_diagLoopMs = 0.0; - s_diagPostRenderMs = 0.0; - s_diagPointerHandlerMs = 0.0; - s_diagPointerEvents = 0; - s_diagPointerAgeMs = 0.0; - s_diagPointerAgeSamples = 0; - s_diagEventToRetireMs = 0.0; - s_diagEventToRetireSamples = 0; + s_diag = DiagStats{}; } } @@ -3018,29 +2891,29 @@ namespace Rv const TwkApp::VideoDevice* odv = session->outputVideoDevice(); if (odv && cdv != odv && cdv == videoDevice()) { - const float w = static_cast(width()); - const float h = static_cast(height()); - const float ow = static_cast(odv->width()); - const float oh = static_cast(odv->height()); - const float aspect = w / h; - const float oaspect = ow / oh; + const float viewWidth = static_cast(width()); + const float viewHeight = static_cast(height()); + const float outputWidth = static_cast(odv->width()); + const float outputHeight = static_cast(odv->height()); + const float aspect = viewWidth / viewHeight; + const float oaspect = outputWidth / outputHeight; - m_videoDevice->translator().setRelativeDomain(ow, oh); + m_videoDevice->translator().setRelativeDomain(outputWidth, outputHeight); if (aspect >= oaspect) { - const float yscale = oh / h; + const float yscale = outputHeight / viewHeight; const float yoffset = 0.0f; const float xscale = yscale; - const float xoffset = -(w * yscale - ow) / 2.0f; + const float xoffset = -(viewWidth * yscale - outputWidth) / 2.0f; m_videoDevice->translator().setScaleAndOffset(xoffset, yoffset, xscale, yscale); } else { - const float xscale = ow / w; + const float xscale = outputWidth / viewWidth; const float xoffset = 0.0f; const float yscale = xscale; - const float yoffset = -(xscale * h - oh) / 2.0f; + const float yoffset = -(xscale * viewHeight - outputHeight) / 2.0f; m_videoDevice->translator().setScaleAndOffset(xoffset, yoffset, xscale, yscale); } } @@ -3068,20 +2941,21 @@ namespace Rv const bool diagPointer = IPCore::ImageRenderer::debugGpu() && (event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonPress || event->type() == QEvent::TabletMove); - Timer diagPointerTimer; if (diagPointer) { - diagPointerTimer.start(); s_diagPointerTimer.start(); s_diagPointerPending = true; } - const bool handled = m_videoDevice->translator().sendQTEvent(event, activationTime); + bool handled = false; + { + ScopedDiagTimer diagTimer(s_diag.pointerHandlerMs, diagPointer); + handled = m_videoDevice->translator().sendQTEvent(event, activationTime); + } if (diagPointer) { - s_diagPointerHandlerMs += diagPointerTimer.elapsed() * 1000.0; - ++s_diagPointerEvents; + ++s_diag.pointerEvents; } if (handled) diff --git a/src/lib/ip/IPCore/IPGraph.cpp b/src/lib/ip/IPCore/IPGraph.cpp index b57d49a2c..751339f65 100644 --- a/src/lib/ip/IPCore/IPGraph.cpp +++ b/src/lib/ip/IPCore/IPGraph.cpp @@ -876,13 +876,13 @@ namespace IPCore continue; } - size_t n = m_displayGroups.size(); + size_t suffix = m_displayGroups.size(); string name; do { ostringstream str; - str << "displayGroup" << n++; + str << "displayGroup" << suffix++; name = str.str(); } while (findNode(name)); From e6543ed8079566551f15c5cca4de9e2a948803d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Wed, 30 Sep 2026 16:11:48 -0400 Subject: [PATCH 46/48] fix(presentation): recomposite when the output surface is shown or resized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presentation output only shows what the control session composites into it, and nothing requested a frame once its surface became usable, so it stayed black until the main viewport repainted (e.g. on mouse move). On GL, transfer() cannot run before ScreenWindow's first paint creates its backing FBO; on Vulkan, the first expose presents an empty FBO. Add DesktopVideoDevice::requestOutputRecomposite() and call it from ScreenWindow::paintGL() when the painted size changes, and from VulkanWindow's passive expose and resize. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 31 +++++++++++++++++-- .../RvCommon/RvCommon/DesktopVideoDevice.h | 11 +++++++ src/lib/app/RvCommon/VulkanWindow.cpp | 7 +++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index f0086539b..f1430b13b 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -25,6 +25,8 @@ #include #include +#include +#include #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS) #include @@ -891,6 +893,23 @@ namespace Rv void DesktopVideoDevice::sortVideoFormatsByWidth() { sort(m_videoFormats.begin(), m_videoFormats.end(), widthSort); } + void DesktopVideoDevice::requestOutputRecomposite() + { + if (!IPCore::App()) + { + return; + } + + for (TwkApp::Document* doc : IPCore::App()->documents()) + { + IPCore::Session* session = dynamic_cast(doc); + if (session && session->outputVideoDevice() && session->outputVideoDevice() != session->controlVideoDevice()) + { + session->askForRedraw(); + } + } + } + DesktopVideoDevice::ScreenWindow::ScreenWindow(const QSurfaceFormat& fmt, QOpenGLContext* glShareContext) : QOpenGLWindow(glShareContext, QOpenGLWindow::PartialUpdateBlit) , m_glShareContext(glShareContext) @@ -927,8 +946,16 @@ namespace Rv void DesktopVideoDevice::ScreenWindow::paintGL() { - // This method is explicitely empty because this window's FBO is - // written to by the transfer/transfer2() method + // Nothing is drawn here: this window's FBO is written to by the + // transfer/transfer2() method. transfer() cannot run before the first + // paint creates the FBO, and a resize reallocates it empty, so ask for + // a new frame then. Not on every paint: each transfer triggers one. + const QSize paintedSize = size() * devicePixelRatio(); + if (paintedSize != m_paintedSize) + { + m_paintedSize = paintedSize; + requestOutputRecomposite(); + } } DesktopVideoDevice::ScreenView::ScreenView(const QSurfaceFormat& fmt, QWidget* parent, QOpenGLContext* glShareContext, diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index 3dd9e6e9a..de7260016 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -71,6 +71,10 @@ namespace Rv private: QOpenGLContext* m_glShareContext = nullptr; + + // Size of the backing FBO at the last paint; a change means it + // was (re)allocated empty. + QSize m_paintedSize; }; // QWidget container for the ScreenWindow, as GLView is for GLWindow. @@ -290,6 +294,13 @@ namespace Rv // True when the persisted display depth is RGB 10 + A 2. static bool tenBitDisplayRequested(); + // + // An output surface only shows what the control session composites + // into it, so a freshly shown or resized one holds nothing. Asks every + // session driving a separate output device for a new frame. + // + static void requestOutputRecomposite(); + protected: void addDefaultDataFormats(size_t bits = 8); void sortVideoFormatsByWidth(); diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index a9961940f..5797bee02 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -2708,6 +2709,8 @@ namespace Rv if (!m_doc && m_initialized && m_videoDevice) { m_videoDevice->syncBuffers(); + // That frame is empty on first expose; ask for a real one. + DesktopVideoDevice::requestOutputRecomposite(); return; } @@ -2726,6 +2729,10 @@ namespace Rv if (!m_stopProcessingEvents) { requestUpdate(); + if (isPassiveOutput()) + { + DesktopVideoDevice::requestOutputRecomposite(); + } } } From 4293c3df3a305f750e1c0285d9192ee0216a1ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 1 Oct 2026 09:34:04 -0400 Subject: [PATCH 47/48] refactor: address self-review findings on presentation and GL teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VulkanWindow: findTenBit returns std::optional instead of a bool plus out-parameter, and the two search passes become one loop. - DesktopVideoDevice: cloneForSource owns the clone with unique_ptr until it is cached; ScreenView's container is a constructor local. - GLContextScope: note that the fallback statics are GUI-thread only. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 12 ++++---- .../RvCommon/RvCommon/DesktopVideoDevice.h | 1 - src/lib/app/RvCommon/VulkanWindow.cpp | 28 ++++++++----------- src/lib/graphics/TwkGLF/GLContextScope.cpp | 3 ++ 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index f1430b13b..a08d6ca53 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -606,7 +606,7 @@ namespace Rv // A dead source texture leaves the clone incomplete; never cache one, // or every later frame would blit from it. // - GLFBO* clone = new GLFBO(sourceFbo->width(), sourceFbo->height(), sourceFbo->primaryColorFormat()); + auto clone = std::make_unique(sourceFbo->width(), sourceFbo->height(), sourceFbo->primaryColorFormat()); clone->attachColorTexture(sourceFbo->colorTarget(0), sourceFbo->colorID(0)); @@ -638,15 +638,15 @@ namespace Rv << " globalShare=" << static_cast(QOpenGLContext::globalShareContext()) << endl; } - delete clone; return nullptr; } m_reportedBadSourceTex = 0; - m_fboMap[sourceFbo] = clone; + GLFBO* cached = clone.release(); + m_fboMap[sourceFbo] = cached; - return clone; + return cached; } void DesktopVideoDevice::releaseFBOClones() const @@ -965,7 +965,7 @@ namespace Rv m_glWindow = new ScreenWindow(fmt, glShareContext); // A plain QWidget container keeps this top-level off the OpenGL RHI backing store. - m_container = QWidget::createWindowContainer(m_glWindow, this); + QWidget* container = QWidget::createWindowContainer(m_glWindow, this); // A QOpenGLWindow has no GL context until its platform surface exists. m_glWindow->create(); @@ -973,7 +973,7 @@ namespace Rv QVBoxLayout* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - layout->addWidget(m_container); + layout->addWidget(container); } //---------------------------------------------------------------------- diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index de7260016..177407ab6 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -94,7 +94,6 @@ namespace Rv private: ScreenWindow* m_glWindow = nullptr; - QWidget* m_container = nullptr; }; public: diff --git a/src/lib/app/RvCommon/VulkanWindow.cpp b/src/lib/app/RvCommon/VulkanWindow.cpp index 5797bee02..c03e5b3b5 100644 --- a/src/lib/app/RvCommon/VulkanWindow.cpp +++ b/src/lib/app/RvCommon/VulkanWindow.cpp @@ -889,7 +889,7 @@ namespace Rv // Prefer A2B10G10R10 (== GL_RGB10_A2) so the transfer is a plain copy. // RV emits sRGB, so require SRGB_NONLINEAR: in HDR mode NVIDIA lists a // 10-bit HDR10_ST2084 entry first, which renders the viewport black. - const auto findTenBit = [&formats](VkFormat wanted, bool requireSrgbNonlinear, VkSurfaceFormatKHR& out) -> bool + const auto findTenBit = [&formats](VkFormat wanted, bool requireSrgbNonlinear) -> std::optional { for (const auto& fmt : formats) { @@ -901,32 +901,28 @@ namespace Rv { continue; } - out = fmt; - return true; + return fmt; } - return false; + return std::nullopt; }; - for (const VkFormat wanted : {VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_A2R10G10B10_UNORM_PACK32}) - { - if (findTenBit(wanted, true, surfaceFormat)) - { - found10bit = true; - break; - } - } - - // No SRGB_NONLINEAR pairing: keep 10-bit anyway and warn below. - if (!found10bit) + // A second pass without SRGB_NONLINEAR keeps 10-bit anyway and warns below. + for (const bool requireSrgbNonlinear : {true, false}) { for (const VkFormat wanted : {VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_A2R10G10B10_UNORM_PACK32}) { - if (findTenBit(wanted, false, surfaceFormat)) + if (const auto match = findTenBit(wanted, requireSrgbNonlinear)) { + surfaceFormat = *match; found10bit = true; break; } } + + if (found10bit) + { + break; + } } if (found10bit) diff --git a/src/lib/graphics/TwkGLF/GLContextScope.cpp b/src/lib/graphics/TwkGLF/GLContextScope.cpp index 50bfda919..29bc77e02 100644 --- a/src/lib/graphics/TwkGLF/GLContextScope.cpp +++ b/src/lib/graphics/TwkGLF/GLContextScope.cpp @@ -29,6 +29,9 @@ namespace TwkGLF // while the application is being torn down, so there is no safe // point to free it. // + // GUI thread only: makeFallbackCurrent() returns before touching + // these from any other thread. + // QOpenGLContext* s_fallbackContext = nullptr; QOffscreenSurface* s_fallbackSurface = nullptr; bool s_fallbackAttempted = false; From 40bfab81b3071381043a7d9ac74b416b5a0aa893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drik=20Fuoco?= Date: Thu, 1 Oct 2026 11:30:58 -0400 Subject: [PATCH 48/48] refactor: manage DesktopVideoDevice translator with unique_ptr and guard unbind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DesktopVideoDevice: m_translator is now a std::unique_ptr; update construction, destruction and assignment accordingly. - Add a null check for m_viewDevice in unbind() to avoid dereferencing it before the view has painted. Signed-off-by: Cédrik Fuoco --- src/lib/app/RvCommon/DesktopVideoDevice.cpp | 9 +++------ src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h | 3 ++- src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h | 2 +- src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp | 5 ++--- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/lib/app/RvCommon/DesktopVideoDevice.cpp b/src/lib/app/RvCommon/DesktopVideoDevice.cpp index a08d6ca53..7953f7a44 100644 --- a/src/lib/app/RvCommon/DesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/DesktopVideoDevice.cpp @@ -59,7 +59,6 @@ namespace Rv , m_dataFormatIndex(0) , m_syncing(false) , m_screen(screen) - , m_translator(0) , m_view(0) { m_glGlobalState = new GLState(); @@ -224,10 +223,9 @@ namespace Rv // The device first: its font textures live in the view's context. delete m_viewDevice; delete m_view; - delete m_translator; + m_translator.reset(); m_view = 0; m_viewDevice = 0; - m_translator = 0; // The current context was just destroyed; restore the main view's for later GL teardown. if (m_share) @@ -239,7 +237,7 @@ namespace Rv void DesktopVideoDevice::setViewWidget(ScreenView* widget) { m_view = widget; - m_translator = new QTTranslator(this, m_view); + m_translator = std::make_unique(this, m_view); } void DesktopVideoDevice::makeCurrent() const @@ -344,8 +342,7 @@ namespace Rv ScopedLock lock(m_mutex); - // Same readiness gate as transfer(). - if (m_viewDevice->fboID() == 0) + if (!m_viewDevice || m_viewDevice->fboID() == 0) { return; } diff --git a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h index 177407ab6..c19326cfe 100644 --- a/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/DesktopVideoDevice.h @@ -9,6 +9,7 @@ #ifndef __RvCommon__DesktopVideoDevice__h__ #define __RvCommon__DesktopVideoDevice__h__ #include +#include #include #include #include @@ -334,7 +335,7 @@ namespace Rv DesktopDataFormats m_dataFormats; int m_screen; - QTTranslator* m_translator; + std::unique_ptr m_translator; mutable ColorProfile m_colorProfile; size_t m_videoFormatIndex; diff --git a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h index 1250a7009..6989957ea 100644 --- a/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h +++ b/src/lib/app/RvCommon/RvCommon/VulkanDesktopVideoDevice.h @@ -40,7 +40,7 @@ namespace Rv void syncBuffers() const override; private: - // Owns the QTVulkanVideoDevice used as the base m_viewDevice. + // Its QTVulkanVideoDevice is the base m_viewDevice. std::unique_ptr m_vulkanView; }; diff --git a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp index 70a5d7402..1091f8c5c 100644 --- a/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp +++ b/src/lib/app/RvCommon/VulkanDesktopVideoDevice.cpp @@ -48,7 +48,7 @@ namespace Rv m_vulkanView->setWindowFlag(Qt::WindowDoesNotAcceptFocus, true); // Inert, created for parity with the base class's setViewWidget(). - m_translator = new QTTranslator(this, m_vulkanView.get()); + m_translator = std::make_unique(this, m_vulkanView.get()); // Place before show(): the swapchain is built on first expose. const QRect screenRect = screenGeometry(); @@ -82,8 +82,7 @@ namespace Rv m_vulkanView.reset(); - delete m_translator; - m_translator = nullptr; + m_translator.reset(); } bool VulkanDesktopVideoDevice::isOpen() const { return m_vulkanView != nullptr; }