Render through public Qt API: OpenGL and Vulkan backends, --graphics-api

Replace the QRhi rendering in OverlayController (private Qt API) with the
render backends of the vrdashboard template (src/render/), which use public
Qt API only (QQuickRenderTarget::fromOpenGLTexture / fromVulkanImage). The
binary then runs on another Qt 6 patch release, e.g. the Steam Frame's system
Qt.

The Vulkan backend also does what SteamVR requires and the QRhi path lacked:
the required instance/device extensions and the HMD's GPU (via
openvr_init::renderInterop()), VK_FORMAT_R8G8B8A8_UNORM, and the transition to
TRANSFER_SRC_OPTIMAL after each frame.

- New option --graphics-api opengl|vulkan (default opengl, no fallback).
- Shutdown() calls VR_Shutdown before the render target and Qt's device go
  (crash on the Steam Frame with Vulkan otherwise); it runs only once.
- Mouse y is flipped on every platform: SteamVR reports it in GL convention.
- Render delay 5 -> 16 ms (about 60 fps).
- GuiPrivate is no longer needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthias Janetschek
2026-10-07 13:41:13 +02:00
co-authored by Claude Opus 5.5
parent 423ea57db7
commit 4826686a4d
15 changed files with 1243 additions and 140 deletions
+461
View File
@@ -0,0 +1,461 @@
// Adapted from the vrdashboard template (FrameAssistant commit 4a5b090),
// verified on the Steam Frame. See docs/steamframe/DESIGN.md, section 3.
#include "VulkanRenderBackend.h"
#include <QQuickGraphicsConfiguration>
#include <QQuickGraphicsDevice>
#include <QQuickWindow>
#include <QVersionNumber>
#include <QVulkanDeviceFunctions>
#include <QVulkanFunctions>
#include <cstring>
namespace
{
constexpr VkFormat kFormat = VK_FORMAT_R8G8B8A8_UNORM;
}
VulkanRenderBackend::~VulkanRenderBackend()
{
// The VkDevice belongs to Qt Quick; everything we created on it must
// already be gone.
Q_ASSERT( m_commandPool == VK_NULL_HANDLE && m_image == VK_NULL_HANDLE );
}
void VulkanRenderBackend::detachWindow()
{
releaseRenderTarget(); // waits for the device to be idle
if ( m_df )
{
for ( Submission* submission : { &m_restore, &m_handoff } )
{
if ( submission->fence != VK_NULL_HANDLE )
m_df->vkDestroyFence( m_device, submission->fence, nullptr );
*submission = {};
}
// Destroying the pool also frees its command buffers.
if ( m_commandPool != VK_NULL_HANDLE )
m_df->vkDestroyCommandPool( m_device, m_commandPool, nullptr );
}
m_commandPool = VK_NULL_HANDLE;
m_df = nullptr;
m_device = VK_NULL_HANDLE;
m_queue = VK_NULL_HANDLE;
}
bool VulkanRenderBackend::initialize( const RenderInterop& interop,
QString* error )
{
QByteArrayList instanceExtensions
= QQuickGraphicsConfiguration::preferredInstanceExtensions();
if ( interop.vulkanInstanceExtensions )
{
for ( const QByteArray& ext : interop.vulkanInstanceExtensions() )
{
if ( !instanceExtensions.contains( ext ) )
instanceExtensions.append( ext );
}
}
m_instance.setExtensions( instanceExtensions );
// QVulkanInstance defaults to Vulkan 1.0; request what the loader
// supports (capped at 1.3) so Qt can rely on core 1.1+ features instead
// of half-enabled extensions.
const QVersionNumber supported = m_instance.supportedApiVersion();
m_instance.setApiVersion( qMin( supported, QVersionNumber( 1, 3 ) ) );
if ( qEnvironmentVariableIntValue( "OVRAS_VULKAN_VALIDATION" ) )
m_instance.setLayers( { "VK_LAYER_KHRONOS_validation" } );
if ( !m_instance.create() )
{
*error
= QStringLiteral( "Failed to create Vulkan instance (VkResult %1)" )
.arg( m_instance.errorCode() );
return false;
}
// Use the GPU SteamVR renders the HMD with; otherwise Qt picks one.
if ( interop.vulkanOutputDevice )
m_physicalDevice
= interop.vulkanOutputDevice( m_instance.vkInstance() );
if ( m_physicalDevice != VK_NULL_HANDLE && interop.vulkanDeviceExtensions )
m_deviceExtensions = interop.vulkanDeviceExtensions( m_physicalDevice );
return true;
}
void VulkanRenderBackend::configureWindow( QQuickWindow* window )
{
window->setVulkanInstance( &m_instance );
if ( m_physicalDevice != VK_NULL_HANDLE )
window->setGraphicsDevice(
QQuickGraphicsDevice::fromPhysicalDevice( m_physicalDevice ) );
QQuickGraphicsConfiguration config = window->graphicsConfiguration();
config.setDeviceExtensions( m_deviceExtensions );
window->setGraphicsConfiguration( config );
}
bool VulkanRenderBackend::attachWindow( QQuickWindow* window, QString* error )
{
QSGRendererInterface* rif = window->rendererInterface();
auto resource = [&]( QSGRendererInterface::Resource r )
{ return rif->getResource( window, r ); };
auto* device = static_cast<VkDevice*>(
resource( QSGRendererInterface::DeviceResource ) );
auto* physicalDevice = static_cast<VkPhysicalDevice*>(
resource( QSGRendererInterface::PhysicalDeviceResource ) );
auto* queue = static_cast<VkQueue*>(
resource( QSGRendererInterface::CommandQueueResource ) );
auto* family = static_cast<uint32_t*>(
resource( QSGRendererInterface::GraphicsQueueFamilyIndexResource ) );
if ( !device || !physicalDevice || !queue || !family )
{
*error = QStringLiteral(
"Qt Quick did not expose its Vulkan device objects" );
return false;
}
m_device = *device;
m_physicalDevice = *physicalDevice;
m_queue = *queue;
m_queueFamilyIndex = *family;
m_df = m_instance.deviceFunctions( m_device );
VkPhysicalDeviceProperties props{};
m_instance.functions()->vkGetPhysicalDeviceProperties( m_physicalDevice,
&props );
m_deviceName = QStringLiteral( "%1 (Vulkan %2.%3.%4)" )
.arg( QString::fromUtf8( props.deviceName ) )
.arg( VK_API_VERSION_MAJOR( props.apiVersion ) )
.arg( VK_API_VERSION_MINOR( props.apiVersion ) )
.arg( VK_API_VERSION_PATCH( props.apiVersion ) );
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 ( m_df->vkCreateCommandPool(
m_device, &poolInfo, nullptr, &m_commandPool )
!= VK_SUCCESS )
{
*error = QStringLiteral( "vkCreateCommandPool failed" );
return false;
}
for ( Submission* submission : { &m_restore, &m_handoff } )
{
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = m_commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1;
VkFenceCreateInfo fenceInfo{};
fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
if ( m_df->vkAllocateCommandBuffers(
m_device, &allocInfo, &submission->commandBuffer )
!= VK_SUCCESS
|| m_df->vkCreateFence(
m_device, &fenceInfo, nullptr, &submission->fence )
!= VK_SUCCESS )
{
*error
= QStringLiteral( "Allocating command buffers/fences failed" );
return false;
}
}
return true;
}
bool VulkanRenderBackend::findMemoryType( uint32_t typeBits,
VkMemoryPropertyFlags properties,
uint32_t* index ) const
{
VkPhysicalDeviceMemoryProperties memProps{};
m_instance.functions()->vkGetPhysicalDeviceMemoryProperties(
m_physicalDevice, &memProps );
for ( uint32_t i = 0; i < memProps.memoryTypeCount; ++i )
{
if ( ( typeBits & ( 1u << i ) )
&& ( memProps.memoryTypes[i].propertyFlags & properties )
== properties )
{
*index = i;
return true;
}
}
return false;
}
QQuickRenderTarget VulkanRenderBackend::createRenderTarget( QSize size )
{
releaseRenderTarget();
if ( !m_df )
return {};
VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = kFormat;
imageInfo.extent
= { uint32_t( size.width() ), uint32_t( size.height() ), 1 };
imageInfo.mipLevels = 1;
imageInfo.arrayLayers = 1;
imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
// COLOR_ATTACHMENT for Qt; TRANSFER_SRC | SAMPLED are required by
// OpenVR.
imageInfo.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT
| VK_IMAGE_USAGE_TRANSFER_SRC_BIT
| VK_IMAGE_USAGE_SAMPLED_BIT;
imageInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
if ( m_df->vkCreateImage( m_device, &imageInfo, nullptr, &m_image )
!= VK_SUCCESS )
return {};
VkMemoryRequirements req{};
m_df->vkGetImageMemoryRequirements( m_device, m_image, &req );
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = req.size;
if ( !findMemoryType( req.memoryTypeBits,
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
&allocInfo.memoryTypeIndex )
|| m_df->vkAllocateMemory(
m_device, &allocInfo, nullptr, &m_imageMemory )
!= VK_SUCCESS
|| m_df->vkBindImageMemory( m_device, m_image, m_imageMemory, 0 )
!= VK_SUCCESS )
{
releaseRenderTarget();
return {};
}
m_size = size;
m_layout = VK_IMAGE_LAYOUT_UNDEFINED;
// Qt transitions UNDEFINED -> COLOR_ATTACHMENT_OPTIMAL in the first
// frame.
return QQuickRenderTarget::fromVulkanImage(
m_image, VK_IMAGE_LAYOUT_UNDEFINED, kFormat, size );
}
void VulkanRenderBackend::releaseRenderTarget()
{
if ( !m_df )
return;
m_df->vkDeviceWaitIdle( m_device );
if ( m_image != VK_NULL_HANDLE )
m_df->vkDestroyImage( m_device, m_image, nullptr );
if ( m_imageMemory != VK_NULL_HANDLE )
m_df->vkFreeMemory( m_device, m_imageMemory, nullptr );
m_image = VK_NULL_HANDLE;
m_imageMemory = VK_NULL_HANDLE;
m_layout = VK_IMAGE_LAYOUT_UNDEFINED;
m_size = {};
}
bool VulkanRenderBackend::waitFor( Submission& submission )
{
if ( !submission.pending )
return true;
submission.pending = false;
const bool ok = m_df->vkWaitForFences(
m_device, 1, &submission.fence, VK_TRUE, UINT64_MAX )
== VK_SUCCESS;
m_df->vkResetFences( m_device, 1, &submission.fence );
return ok;
}
bool VulkanRenderBackend::submit(
Submission& submission,
const std::function<void( VkCommandBuffer )>& record,
bool wait )
{
if ( !waitFor( submission ) ) // the command buffer may still be in use
return false;
m_df->vkResetCommandBuffer( submission.commandBuffer, 0 );
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
m_df->vkBeginCommandBuffer( submission.commandBuffer, &beginInfo );
record( submission.commandBuffer );
m_df->vkEndCommandBuffer( submission.commandBuffer );
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &submission.commandBuffer;
if ( m_df->vkQueueSubmit( m_queue, 1, &submitInfo, submission.fence )
!= VK_SUCCESS )
return false;
submission.pending = true;
return wait ? waitFor( submission ) : true;
}
void VulkanRenderBackend::transitionImage( Submission& submission,
VkImageLayout from,
VkImageLayout to,
bool wait )
{
// Only two transitions exist: rendered -> hand-off and back.
const bool toTransfer = to == VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
const VkAccessFlags colorWrite = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
const VkAccessFlags transferRead = VK_ACCESS_TRANSFER_READ_BIT;
const VkPipelineStageFlags colorStage
= VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
const VkPipelineStageFlags transferStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.srcAccessMask = toTransfer ? colorWrite : transferRead;
barrier.dstAccessMask = toTransfer ? transferRead : colorWrite;
barrier.oldLayout = from;
barrier.newLayout = to;
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.image = m_image;
barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
const VkPipelineStageFlags srcStage
= toTransfer ? colorStage : transferStage;
const VkPipelineStageFlags dstStage
= toTransfer ? transferStage : colorStage;
submit(
submission,
[&]( VkCommandBuffer cb )
{
m_df->vkCmdPipelineBarrier( cb,
srcStage,
dstStage,
0,
0,
nullptr,
0,
nullptr,
1,
&barrier );
},
wait );
m_layout = to;
}
void VulkanRenderBackend::beginFrame()
{
// Hand the image back to Qt in the layout its tracking assumes (after
// the first frame). No CPU wait needed: Qt's frame is submitted to the
// same queue afterwards.
if ( m_layout == VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL )
transitionImage( m_restore,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
false );
}
void VulkanRenderBackend::finishFrame()
{
// Qt leaves the render target in COLOR_ATTACHMENT_OPTIMAL; OpenVR wants
// TRANSFER_SRC_OPTIMAL. Wait, so the texture is complete and in the
// right layout when SteamVR gets it.
transitionImage( m_handoff,
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
true );
}
void VulkanRenderBackend::waitForGpu()
{
if ( m_df )
m_df->vkQueueWaitIdle( m_queue );
}
vr::Texture_t VulkanRenderBackend::vrTexture()
{
m_vrTextureData.m_nImage = reinterpret_cast<uint64_t>( m_image );
m_vrTextureData.m_pDevice = m_device;
m_vrTextureData.m_pPhysicalDevice = m_physicalDevice;
m_vrTextureData.m_pInstance = m_instance.vkInstance();
m_vrTextureData.m_pQueue = m_queue;
m_vrTextureData.m_nQueueFamilyIndex = m_queueFamilyIndex;
m_vrTextureData.m_nWidth = uint32_t( m_size.width() );
m_vrTextureData.m_nHeight = uint32_t( m_size.height() );
m_vrTextureData.m_nFormat = uint32_t( kFormat );
m_vrTextureData.m_nSampleCount = 1;
// For TextureType_Vulkan the handle points to a VRVulkanTextureData_t.
return vr::Texture_t{ &m_vrTextureData,
vr::TextureType_Vulkan,
vr::ColorSpace_Auto };
}
vr::VRTextureBounds_t VulkanRenderBackend::vrTextureBounds() const
{
// Vulkan images are top-down, like D3D — SteamVR's native convention
// (verified on the Steam Frame).
return vr::VRTextureBounds_t{ 0.f, 0.f, 1.f, 1.f };
}
QImage VulkanRenderBackend::readback()
{
if ( m_image == VK_NULL_HANDLE
|| m_layout != VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL )
return {}; // no finished frame yet
const VkDeviceSize byteSize
= VkDeviceSize( m_size.width() ) * m_size.height() * 4;
VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = byteSize;
bufferInfo.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
if ( m_df->vkCreateBuffer( m_device, &bufferInfo, nullptr, &buffer )
!= VK_SUCCESS )
return {};
QImage image;
VkMemoryRequirements req{};
m_df->vkGetBufferMemoryRequirements( m_device, buffer, &req );
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = req.size;
if ( findMemoryType( req.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
| VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
&allocInfo.memoryTypeIndex )
&& m_df->vkAllocateMemory( m_device, &allocInfo, nullptr, &memory )
== VK_SUCCESS
&& m_df->vkBindBufferMemory( m_device, buffer, memory, 0 )
== VK_SUCCESS )
{
const bool copied = submit(
m_handoff,
[&]( VkCommandBuffer cb )
{
VkBufferImageCopy region{};
region.imageSubresource
= { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
region.imageExtent = { uint32_t( m_size.width() ),
uint32_t( m_size.height() ),
1 };
m_df->vkCmdCopyImageToBuffer(
cb,
m_image,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
buffer,
1,
&region );
},
true );
void* data = nullptr;
if ( copied
&& m_df->vkMapMemory( m_device, memory, 0, byteSize, 0, &data )
== VK_SUCCESS )
{
image = QImage( m_size, QImage::Format_RGBA8888_Premultiplied );
std::memcpy( image.bits(), data, size_t( byteSize ) );
m_df->vkUnmapMemory( m_device, memory );
}
}
m_df->vkDestroyBuffer( m_device, buffer, nullptr );
if ( memory != VK_NULL_HANDLE )
m_df->vkFreeMemory( m_device, memory, nullptr );
return image;
}