Skip to content

feat: SG-43127: SG-43594: 10-bit Vulkan presentation (Linux + Windows) - #1319

Open
cedrik-fuoco-adsk wants to merge 6 commits into
AcademySoftwareFoundation:mainfrom
cedrik-fuoco-adsk:vulkna-10bit-windows
Open

feat: SG-43127: SG-43594: 10-bit Vulkan presentation (Linux + Windows)#1319
cedrik-fuoco-adsk wants to merge 6 commits into
AcademySoftwareFoundation:mainfrom
cedrik-fuoco-adsk:vulkna-10bit-windows

Conversation

@cedrik-fuoco-adsk

@cedrik-fuoco-adsk cedrik-fuoco-adsk commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

SG-43127 - 10-bit support with Vulkan (Linux and Windows)

Linked issues

none

Summarize your change.

Adds a 10-bit Vulkan presentation backend for both Linux and Windows, enabling 30-bit (RGB10 + A2) display output. This supersedes the Linux-only work in #1310 by extending the same backend to Windows in a single PR.

New rendering path (Vulkan 10-bit):

When the user requests a 10-bit display (RGB10 + A2) and the hardware/driver supports it, RV renders through a Vulkan presentation path instead of the usual OpenGL one. The renderer still draws into an offscreen 16-bit float framebuffer (GL_RGBA16F) using OpenGL as before.

The difference is the final step: instead of presenting that framebuffer through a GLView, the GL color buffer is handed to QTVulkanVideoDevice, which blits it into a texture whose memory is shared with a Vulkan image through external-memory interop, and VulkanView presents it through a true 10-bit (VK_FORMAT_A2B10G10R10_UNORM_PACK32) Vulkan swapchain. The GL-to-Vulkan handoff is zero-copy (shared memory and semaphores, no cross-API blit or CPU copy). This is what gives us real 10-bit output without banding, which the OpenGL path could not do on either platform.

Backend selection happens at view-creation time: a 10-bit preference routes to Vulkan when VulkanView::supports10BitPresentation() returns true, and otherwise everything falls back to the existing GLView path unchanged. On the Vulkan path m_glView is null, but the DiagnosticsView is still created: it is an independent QOpenGLWidget with its own GL context, so when m_glView is null it simply falls back to the global default surface format (OpenGL 2.1) instead of the main view's format.

Platform-neutral view abstraction:

RvDocument is decoupled from GLView by routing the active view through a generic QWidget* m_viewWidget / viewWidget() accessor instead of hardcoding m_glView at every shared call site (focus, geometry, stacked-layout, popup/menu coordinate mapping). RvApplication now tolerates a null view()/GL context and uses the session's control video device for the primary display group. The GL path is unchanged; this is the shared base for the Vulkan backend (and the same base can be shared with a future macOS Metal backend).

Vulkan backend (Linux + Windows):

Adds VulkanView (a Vulkan swapchain that selects the A2B10G10R10_UNORM_PACK32 format) and QTVulkanVideoDevice (offscreen 16F FBO + GL/Vulkan external-memory interop), wired into RvDocument behind #if defined(PLATFORM_LINUX) || defined(PLATFORM_WINDOWS). Most of the backend is platform-neutral and lives in shared .cpp files; only the GPU-interop handle plumbing differs per platform:

  • Linux uses opaque-FD external memory and semaphores (VK_KHR_external_memory_fd, VK_KHR_external_semaphore_fd, GL_EXT_memory_object_fd, GL_EXT_semaphore_fd).
  • Windows uses the Win32-HANDLE equivalents (VK_KHR_external_memory_win32, VK_KHR_external_semaphore_win32, GL_EXT_memory_object_win32, GL_EXT_semaphore_win32).

Handle ownership differs between the two: on Linux the FD is duplicated with dup() and the GL import takes ownership, while on Windows glImportMemoryWin32HandleEXT does not take ownership, so the GL and Vulkan sides each keep and close their own handle. Cleanup paths are written accordingly.

When the driver lacks the interop extensions (or the stride cannot be represented as an integer pixel width), the path degrades gracefully: getSharedImageInfo returns null and a CPU pack-and-upload fallback (VulkanView::presentPixelData) presents through the same Vulkan swapchain, so the output is still 10-bit even when the zero-copy GPU bridge is off. If Vulkan 10-bit presentation is unavailable entirely (or the real surface does not offer A2B10G10R10), it falls back to the OpenGL GLView path.

Vulkan (headers + loader) is fetched as a managed dependency (cmake/dependencies/vulkan.cmake) and linked on both Linux and Windows. CMake gates that were previously IF(RV_TARGET_LINUX) are widened to IF(RV_TARGET_LINUX OR RV_TARGET_WINDOWS), and Windows links Vulkan::Vulkan without pulling in X11. A small VulkanBuildProbe.cpp keeps a direct vkGetInstanceProcAddr reference so a missing loader fails at link time rather than at runtime.

Describe the reason for the change.

OpenRV's OpenGL presentation path is limited to 8-bit output on both Linux and Windows, which causes visible banding on 10-bit-capable displays. Vulkan provides reliable access to 30-bit swapchain formats on modern drivers, allowing true 10-bit presentation. The view abstraction was extracted first so the new backend could be plugged in cleanly without disturbing the existing GL path.

Describe what you have tested and on which operating system.

Linux: 10-bit gradient ramps render smoothly through the Vulkan swapchain (A2B10G10R10_UNORM_PACK32) with the GPU-interop path active. The 8-bit default launch is unchanged and uses GLView.

Windows (AMD Radeon 740M, dual monitor): the Vulkan path builds and runs, selects a true A2B10G10R10_UNORM_PACK32 swapchain, and activates the GPU-interop bridge. One important platform caveat surfaced during testing:

  • Windows always composites windowed apps through the Desktop Window Manager (DWM), and DWM only composes at greater than 8 bpc when the target display is in HDR mode. On an SDR-only monitor, DWM truncates the 10-bit swapchain to 8 bpc before scanout, so banding remains even though the swapchain, the GL/Vulkan interop, and the AMD link depth are all correctly 10-bit.
  • On an HDR-capable monitor with HDR enabled, the 10-bit swapchain visibly reaches the panel and banding is reduced (confirmed: the surface then advertises HDR colorspaces, a clear signal DWM is in deep-color composition mode).
  • This is a known Windows platform limitation, not a bug in RV. Linux has no mandatory compositor gate, so the same Vulkan code, same GPU, and same monitor deliver 10-bit directly.

On the format/colorspace choice: createSwapchain() selects the first A2B10G10R10_UNORM_PACK32 format the surface offers and uses whichever colorspace is paired with it. On the tested hardware that first match is VK_COLOR_SPACE_SRGB_NONLINEAR_KHR, which is the correct 10-bit target for our sRGB-emitting renderer (the HDR10/scRGB colorspaces would require a separate color-managed re-encode of the shader output and are out of scope here).

Add a list of changes, and note any that might need special attention during the review.

  • RvDocument / RvApplication view abstraction (m_viewWidget / viewWidget()), shared and platform-neutral.
  • New VulkanView (10-bit swapchain) and QTVulkanVideoDevice (16F FBO + GL/Vulkan interop), shared .cpp with per-platform handle plumbing.
  • New VulkanBuildProbe.cpp to force loader symbol resolution at link time on both platforms.
  • CMake: Vulkan dependency and link gates widened from Linux-only to Linux + Windows; Windows links Vulkan::Vulkan without X11.
  • Graceful CPU pack-and-upload fallback and Vulkan-to-GLView fallback paths.

Worth special attention during review:

  • Handle-ownership asymmetry between platforms: Linux dup()s the FD and the GL import takes ownership, while the Windows glImportMemoryWin32HandleEXT import does not take ownership, so each side must close exactly the handles it owns.
  • GLEW Win32 import symbols (glImportMemoryWin32HandleEXT, glImportSemaphoreWin32HandleEXT) are resolved through wglGetProcAddress on Windows; confirm they are present on the target driver.
  • No 10-bit Windows surface in CI yet, so the Windows GPU path was verified manually on a 10-bit-capable monitor.
full-vulkan

Comment thread src/lib/app/RvPackage/PackageManager.cpp

@bernie-laberge bernie-laberge left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cedrik-fuoco-adsk

Copy link
Copy Markdown
Contributor Author

Before the latest changes:

The present was single-frame-in-flight. Every frame ended with a blocking wait on the GPU fence, so the CPU sat idle until the GPU finished before starting the next frame. Resize made it worse by rebuilding the shared image each step. The result was choppy resize and playback that stalled then fast-forwarded.

After:

Up to 2 frames run in flight. Per-frame resources (fence, semaphores, shared image, staging buffer) are now per-slot rings indexed by m_currentFrame, so the CPU can prepare the next frame while the GPU finishes the current one. The GL side reads m_view->currentFrame() and uses the same slot, so its imported objects stay paired with the matching Vulkan ones. The blocking wait moved from the end of the frame to the start of the slot it reuses, so it only blocks when the CPU is a full frame ahead. Pacing still comes from FIFO vsync plus that start-of-frame wait.

@cedrik-fuoco-adsk

Copy link
Copy Markdown
Contributor Author

Added @bernie-laberge fix for Nvidia and optimization for the CPU fallback.

Comment on lines +53 to +78
virtual void makeCurrent() const override;
virtual void syncBuffers() const override;
virtual void redraw() const override;
virtual void redrawImmediately() const override;
virtual void clearCaches() const override;

virtual Resolution resolution() const override;
virtual Offset offset() const override;
virtual Timing timing() const override;
virtual VideoFormat format() const override;

virtual size_t width() const override;
virtual size_t height() const override;

virtual void open(const StringVector& args) override;
virtual void close() override;
virtual bool isOpen() const override;

virtual float devicePixelRatio() const override;

virtual void setPhysicalDevice(VideoDevice* d) override;

// GLVideoDevice API
virtual TwkGLF::GLFBO* defaultFBO() override;
virtual const TwkGLF::GLFBO* defaultFBO() const override;
virtual std::string hardwareIdentification() const override;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

virtual is redundant since these functions are already declared override

#include <array>
#include <cstdint>
#include <string>
#include <memory>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this header is not used in this file

Comment on lines +48 to +49
virtual bool event(QEvent* event) override;
virtual bool eventFilter(QObject* object, QEvent* event) override;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

virtual is redundant since these functions are already declared override

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <vector>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this header is unsed in this file

#include <QOpenGLContext>
#include <QOffscreenSurface>

#include <algorithm>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this header is unsed in this file

// With a non-OpenGL presentation backend view() returns null — no
// GL context to make current; presentation handles it per-frame.
if (doc->view())
doc->view()->makeCurrent();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This statement should be inside braces

// With a non-OpenGL presentation backend view() is null — no GL
// context to make current.
if (doc->view())
doc->view()->makeCurrent();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This statement should be inside braces

rvDoc->view()->videoDevice()->makeCurrent();
// With a non-OpenGL presentation backend view() is null — skip GL makeCurrent.
if (rvDoc->view())
rvDoc->view()->videoDevice()->makeCurrent();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This statement should be inside braces

… baseline

`-debug gpu` only toggled ImageRenderer's GL reporting, and the flag was
named after that one consumer. The Vulkan presentation work needs the same
switch to gate its own diagnostics, so rename it to the backend-neutral
debugGpu() and keep reportGL() as thin deprecated forwarders for existing
callers.

Under the flag, the GL viewport now reports a one-shot baseline that answers
"why did I only get 8 bits per component" without a second run: the format
GLView asked for, the format Qt negotiated, the context Qt actually created,
the screen depth, the GL vendor/renderer/version, and -- on Linux -- which
display server we are on. glDebugFormatSummary()/glDebugEnvOrUnset() live in
GLView.cpp and are shared with GLWindow, which owns the post-negotiation half
of that report.

Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com>
The 10-bit presentation backend needs <vulkan/vulkan.h> and the loader, so
add Vulkan to the managed dependency set rather than requiring a VULKAN_SDK
environment variable or a setup-env.sh on the developer's machine.

Vulkan here is Vulkan-Headers plus Vulkan-Loader, fetched from hash-pinned
Khronos tarballs and modeled on glew.cmake. RV consumes only those two: every
API call goes through vkGetInstanceProcAddr/vkGetDeviceProcAddr, so no
validation layers or shader tools are pulled in. RV_DEPS_PREFER_INSTALLED
still lets a system Vulkan win via the built-in FindVulkan module or the
`vulkan` pkg-config module.

FORCE_LIB pins the install dir to install/lib so the loader lands in a
deterministic place across RHEL (lib64) and non-RHEL. Only Linux and Windows
include it; macOS presents through Metal.

Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com>
…idge

ImageRenderer's pipeline -- renderMain, the shader cache, the paint effects --
is GL code, and rewriting it for Vulkan is not on the table. So keep rendering
in GL and change only the final present: this device wraps a VulkanWindow as a
TwkGLF::GLVideoDevice, so everything above it runs unchanged while the frame
reaches the screen through a Vulkan swapchain that can actually carry 10 bits
per component.

RV renders into a GL texture that is exported to Vulkan as a shared image
(external memory, plus external semaphores for the GL/Vulkan handoff), which
VulkanWindow then blits into the swapchain. The device owns the GL side of
that bridge: ensureGLContext() creates the context lazily and joins the
Qt::AA_ShareOpenGLContexts group so FTGL font-atlas uploads land somewhere the
atlas texture has storage, and the shared image is re-exported when the
viewport is resized.

The eventWidget is the QWindow's container QWidget, which QTTranslator needs
for coordinate mapping (height/mapToGlobal) and mouse grab -- a QWindow alone
cannot serve those.

Not built yet; the CMake wiring lands with the integration commit.

Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com>
VulkanWindow is the native presentation surface: it owns the instance, device,
swapchain and per-frame sync, picks a 10-bit format when the surface offers
one, and blits the shared GL image exported by QTVulkanVideoDevice into the
swapchain. Presentation is pipelined rather than one-frame-in-flight, so a
frame does not end in a blocking vkWaitForFences under FIFO vsync -- that is
what kept dragging a dock splitter over the media view from starving the Qt
event loop and stalling playback. Resizes reuse the exported image and
recreate only the swapchain where they can.

VulkanView is the host QWidget that embeds it via createWindowContainer() and
owns the video device. This is deliberately the same shape as GLView/GLWindow:
keeping the viewport on a native window of its own, rather than on a widget Qt
composites into the top-level, keeps the main window off a render-to-texture
composite path, and it leaves the two backends sharing one set of embedding
and lifetime rules instead of two.

VulkanBuildProbe.cpp holds one direct vkGetInstanceProcAddr reference so a
Linux/Windows build proves headers and loader link availability up front
instead of failing deep in the presentation code.

Not built yet; the CMake wiring lands with the integration commit.

Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com>
Builds the Vulkan sources and switches RvDocument over to creating a
VulkanView instead of a GLView on Linux and Windows, with a runtime fallback
to GLView (fallbackVulkanToGLView) when instance/device/swapchain creation
fails so a machine without a usable Vulkan driver still starts.

That fallback is why the rest of this is a spread of small changes rather than
one call site. RvDocument::view() is the GL-only accessor and is null on the
Vulkan path, so backend-neutral callers move to viewWidget() (the host QWidget)
or viewVideoDevice() (the active presentation device):

  - MuUICommands/PyUICommands used view() for coordinate mapping, cursor and
    focus. Wrapping a null QWidget* for Mu made the Session Manager's event
    filter dereference null and crash.
  - RvApplication called view()->makeCurrent() and view()->videoDevice()
    unconditionally; there is no GL context to make current when presentation
    is Vulkan, and the primary display group now comes from the session's
    control device.
  - DesktopVideoDevice is constructed with a null GL share device on this path;
    it falls back to the default surface format and no explicit share context,
    which Qt::AA_ShareOpenGLContexts already covers.

main.cpp keeps Qt::AA_ShareOpenGLContexts for a second reason now: with no
GLView to chain from, QTVulkanVideoDevice::ensureGLContext() joins that global
group instead.

The CMake change compiles the Vulkan sources only on Linux and Windows and
promotes WIN32_LEAN_AND_MEAN to target scope, since vulkan_win32.h includes
<windows.h> unconditionally and the legacy winsock.h that drags in collides
with the winsock2.h Qt pulls in. MuUICommands/PyUICommands guarded their GL
includes *inside* `#ifndef WIN32_LEAN_AND_MEAN`, so a target-scope define
would have skipped those includes entirely -- the `#endif` now closes the
define guard before the includes.

Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com>
RvSettings could fail to persist with AccessError (err: 1) on Windows for two
independent reasons.

QSettings writes atomically by default -- temp file plus rename -- and the
rename fails if security software has RV.ini open at that instant. Writing
directly to the file removes that failure point (setAtomicSyncRequired, Qt
5.13+).

QSettings with IniFormat also does not create the parent directory on Windows,
so the very first sync() on a clean machine fails outright. Create it before
use.

Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants