Steam Frame build, packaging and deployment scripts; render test

- build_scripts/steamframe/: Debian trixie (Qt 6.8) containers for the
  aarch64 cross build, native build and tests; toolchain; scripts to build,
  test (Vulkan validation layers), package (system Qt, ~3 MB) and deploy over
  SSH. Adapted from the vrdashboard template.
- test/render/: render test for the OpenGL and Vulkan backends, driven like
  OverlayController (option OVRAS_BUILD_TESTS).
- RPATH only $ORIGIN, without the build machine's link directories.
- Ignore dist/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthias Janetschek
2026-10-07 13:50:57 +02:00
co-authored by Claude Opus 5.5
parent b22c35ae29
commit bde675f1b8
15 changed files with 610 additions and 14 deletions
+35
View File
@@ -0,0 +1,35 @@
# Render test for src/render/ (see tst_render.cpp). Runs without SteamVR or a
# headset; skips itself without a GPU context. One CTest entry per graphics
# API, because the API is process-wide.
find_package(Qt6 REQUIRED COMPONENTS Test)
qt_add_executable(tst_render
tst_render.cpp
${CMAKE_SOURCE_DIR}/src/render/RenderBackend.cpp
${CMAKE_SOURCE_DIR}/src/render/OpenGLRenderBackend.cpp
)
target_include_directories(tst_render PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/third-party/openvr/headers
)
# Only openvr.h's types are used: nothing links against libopenvr_api.
target_link_libraries(tst_render PRIVATE Qt::Gui Qt::Quick Qt::Qml Qt6::Test)
if (QT_FEATURE_vulkan)
target_sources(tst_render PRIVATE
${CMAKE_SOURCE_DIR}/src/render/VulkanRenderBackend.cpp
)
target_compile_definitions(tst_render PRIVATE OVRAS_HAS_VULKAN=1)
else ()
target_compile_definitions(tst_render PRIVATE OVRAS_HAS_VULKAN=0)
endif ()
qt_add_qml_module(tst_render
URI RenderTest
VERSION 1.0
QML_FILES TwoTone.qml
)
add_test(NAME render_opengl COMMAND tst_render)
set_tests_properties(render_opengl PROPERTIES ENVIRONMENT "OVRAS_TEST_API=opengl")
add_test(NAME render_vulkan COMMAND tst_render)
set_tests_properties(render_vulkan PROPERTIES ENVIRONMENT "OVRAS_TEST_API=vulkan")
+7
View File
@@ -0,0 +1,7 @@
import QtQuick
// Top half red, bottom half blue: lets the render test check content and orientation.
Item {
Rectangle { width: parent.width; height: parent.height / 2; color: "#ff0000" }
Rectangle { y: parent.height / 2; width: parent.width; height: parent.height / 2; color: "#0000ff" }
}
+183
View File
@@ -0,0 +1,183 @@
// Render test for the render backends (src/render/), adapted from the
// vrdashboard template's tst_render. It drives a backend and
// QQuickRenderControl in the same order as OverlayController::SetWidget() and
// renderOverlay(), then checks the texture handed to SteamVR and its content
// (via readback).
//
// The backend comes from OVRAS_TEST_API (opengl / vulkan): the graphics API is
// process-wide, so CTest runs one process per API. Without a GPU context the
// test skips itself. With OVRAS_VULKAN_VALIDATION=1 the Vulkan validation
// layers are enabled (build_scripts/steamframe/container-test.sh).
#include <QQmlComponent>
#include <QQmlEngine>
#include <QQuickItem>
#include <QQuickRenderControl>
#include <QQuickWindow>
#include <QtTest>
#include <memory>
#include "render/RenderBackend.h"
class RenderTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase()
{
GraphicsApi api = GraphicsApi::OpenGL;
const QString apiName
= qEnvironmentVariable( "OVRAS_TEST_API", "opengl" );
QVERIFY2( parseGraphicsApi( apiName, &api ), qPrintable( apiName ) );
m_backend = createRenderBackend( api );
if ( !m_backend )
{
QSKIP( qPrintable( apiName + " not compiled in" ) );
}
// Before any QQuickWindow exists, as in main().
QQuickWindow::setGraphicsApi( m_backend->graphicsApi() );
QString error;
// No SteamVR here: empty interop, the backend picks defaults.
if ( !m_backend->initialize( {}, &error ) )
{
QSKIP( qPrintable( QString( "No %1 context: %2" )
.arg( m_backend->name(), error ) ) );
}
}
// One frame of a known scene (top red, bottom blue): texture type and
// description for SteamVR, content and orientation.
void rendersSceneUpright()
{
const QSize size( 64, 32 );
Scene scene( *m_backend, size );
QVERIFY2( scene.error.isEmpty(), qPrintable( scene.error ) );
const vr::Texture_t texture = scene.renderFrame();
QVERIFY( texture.handle != nullptr );
const auto expectedType
= m_backend->graphicsApi() == QSGRendererInterface::Vulkan
? vr::TextureType_Vulkan
: vr::TextureType_OpenGL;
QCOMPARE( texture.eType, expectedType );
if ( texture.eType == vr::TextureType_Vulkan )
{
const auto* vk = static_cast<const vr::VRVulkanTextureData_t*>(
texture.handle );
QVERIFY( vk->m_nImage != 0 && vk->m_pDevice && vk->m_pQueue
&& vk->m_pInstance );
QCOMPARE( vk->m_nWidth, static_cast<uint32_t>( size.width() ) );
// VK_FORMAT_R8G8B8A8_UNORM, the format SteamVR gets told.
QCOMPARE( vk->m_nFormat, 37u );
}
QVERIFY( !m_backend->deviceName().isEmpty() );
const QImage image = m_backend->readback();
QCOMPARE( image.size(), size );
QCOMPARE( image.pixelColor( size.width() / 2, 4 ),
QColor( 255, 0, 0 ) );
QCOMPARE( image.pixelColor( size.width() / 2, size.height() - 4 ),
QColor( 0, 0, 255 ) );
}
// Several frames in a row exercise the per-frame state handling (e.g.
// the Vulkan layout transitions between Qt's frames and the SteamVR
// hand-off).
void rendersRepeatedly()
{
Scene scene( *m_backend, { 32, 32 } );
QVERIFY2( scene.error.isEmpty(), qPrintable( scene.error ) );
for ( int i = 0; i < 5; ++i )
{
scene.renderFrame();
}
QCOMPARE( m_backend->readback().pixelColor( 16, 2 ),
QColor( 255, 0, 0 ) );
}
void cleanupTestCase()
{
m_backend.reset();
}
private:
// The TwoTone scene in an offscreen window, set up like
// OverlayController::SetWidget(). Tears down like
// OverlayController::Shutdown() (minus VR_Shutdown).
struct Scene
{
Scene( RenderBackend& backend, QSize size ) : backend( backend )
{
window = std::make_unique<QQuickWindow>( &renderControl );
window->setGeometry( 0, 0, size.width(), size.height() );
QQmlComponent component( &engine );
component.loadFromModule( "RenderTest", "TwoTone" );
root.reset( qobject_cast<QQuickItem*>( component.create() ) );
if ( !root )
{
error = component.errorString();
return;
}
root->setSize( size );
root->setParentItem( window->contentItem() );
backend.configureWindow( window.get() );
if ( !backend.makeCurrent() || !renderControl.initialize() )
{
error = "QQuickRenderControl::initialize failed";
return;
}
if ( !backend.attachWindow( window.get(), &error ) )
{
return;
}
const QQuickRenderTarget target
= backend.createRenderTarget( size );
if ( target.isNull() )
{
error = "createRenderTarget failed";
return;
}
window->setRenderTarget( target );
}
~Scene()
{
backend.makeCurrent();
root.reset();
backend.releaseRenderTarget();
backend.detachWindow();
window.reset();
}
// One frame as in OverlayController::renderOverlay(); returns what
// would be handed to IVROverlay::SetOverlayTexture.
vr::Texture_t renderFrame()
{
backend.makeCurrent();
backend.beginFrame();
renderControl.polishItems();
renderControl.beginFrame();
renderControl.sync();
renderControl.render();
renderControl.endFrame();
backend.finishFrame();
return backend.vrTexture();
}
RenderBackend& backend;
QQmlEngine engine;
// Destroyed after the window (declared before it): the window
// belongs to this render control.
QQuickRenderControl renderControl;
std::unique_ptr<QQuickWindow> window;
std::unique_ptr<QQuickItem> root;
QString error;
};
std::unique_ptr<RenderBackend> m_backend;
};
QTEST_MAIN( RenderTest )
#include "tst_render.moc"