Initial FrameAssistant baseline from the vrdashboard template

Template stripped to the overlay platform (examples, demo UI and Frame
test package removed), renamed to FrameAssistant, and working rules for
the project recorded in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 00:06:58 +02:00
co-authored by Claude Opus 5.5
commit 4a5b09053e
63 changed files with 5193 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
import QtQuick
Rectangle { color: "#0000ff" }
+35
View File
@@ -0,0 +1,35 @@
find_package(Qt6 REQUIRED COMPONENTS Test)
# Unit tests run without SteamVR or a headset. They use the "offscreen" QPA platform;
# tests that need a GPU context QSKIP themselves when none is available.
qt_add_executable(tst_inputbridge tst_inputbridge.cpp)
target_link_libraries(tst_inputbridge PRIVATE vrdashboard_core Qt6::Test)
add_test(NAME inputbridge COMMAND tst_inputbridge)
qt_add_executable(tst_render tst_render.cpp)
target_link_libraries(tst_render PRIVATE vrdashboard_core Qt6::Test)
qt_add_qml_module(tst_render
URI RenderTest
VERSION 1.0
QML_FILES TwoTone.qml Spinner.qml)
add_test(NAME render_opengl COMMAND tst_render)
set_tests_properties(render_opengl PROPERTIES ENVIRONMENT "VRDASH_TEST_API=opengl")
add_test(NAME render_vulkan COMMAND tst_render)
set_tests_properties(render_vulkan PROPERTIES ENVIRONMENT "VRDASH_TEST_API=vulkan")
set_tests_properties(inputbridge PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# Two surfaces in one app (snapshot mode): set-up, hooks, rendering per surface.
qt_add_executable(tst_surfaces tst_surfaces.cpp)
target_link_libraries(tst_surfaces PRIVATE vrdashboard_core)
qt_add_qml_module(tst_surfaces
URI SurfacesTest
VERSION 1.0
QML_FILES Red.qml Blue.qml)
add_test(NAME surfaces_opengl COMMAND tst_surfaces --graphics-api opengl)
add_test(NAME surfaces_vulkan COMMAND tst_surfaces --graphics-api vulkan)
add_test(NAME surfaces_cli_key COMMAND tst_surfaces --graphics-api opengl)
set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1")
set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
PROPERTIES SKIP_RETURN_CODE 77)
+3
View File
@@ -0,0 +1,3 @@
import QtQuick
Rectangle { color: "#ff0000" }
+10
View File
@@ -0,0 +1,10 @@
import QtQuick
// Continuously animated scene: keeps the on-demand renderer busy (pacing test).
Item {
Rectangle {
width: 16; height: 16
color: "white"
NumberAnimation on rotation { from: 0; to: 360; duration: 1000; loops: Animation.Infinite }
}
}
+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" }
}
+137
View File
@@ -0,0 +1,137 @@
#include <QFocusEvent>
#include <QQmlComponent>
#include <QQmlEngine>
#include <QQuickItem>
#include <QQuickRenderControl>
#include <QQuickWindow>
#include <QtTest>
#include "input/OverlayInputBridge.h"
// Feeds synthetic SteamVR overlay events into OverlayInputBridge and checks
// that the QML scene reacts. No graphics are initialized; event delivery
// doesn't need them.
class InputBridgeTest : public QObject {
Q_OBJECT
private slots:
void init() {
m_renderControl = std::make_unique<QQuickRenderControl>();
m_window = std::make_unique<QQuickWindow>(m_renderControl.get());
m_window->setGeometry(0, 0, kSize.width(), kSize.height());
QQmlComponent component(&m_engine);
component.setData(R"(
import QtQuick
Item {
width: 400; height: 300
property int clicks: 0
MouseArea {
objectName: "top"; width: 400; height: 100
onClicked: parent.clicks++
}
Flickable {
objectName: "flick"; y: 100; width: 400; height: 100
contentHeight: 1000
}
TextInput {
objectName: "input"; y: 250; width: 400; height: 50
}
})",
QUrl());
m_root.reset(qobject_cast<QQuickItem*>(component.create()));
QVERIFY2(m_root, qPrintable(component.errorString()));
m_root->setParentItem(m_window->contentItem());
QFocusEvent focusIn(QEvent::FocusIn, Qt::ActiveWindowFocusReason);
QCoreApplication::sendEvent(m_window.get(), &focusIn);
m_bridge =
std::make_unique<OverlayInputBridge>(m_window.get(), kSize, kOverlay);
}
void cleanup() {
m_bridge.reset();
m_root.reset();
m_window.reset();
m_renderControl.reset();
}
void clickUsesGlCoordinates() {
// GL y=250 (from bottom) is Qt y=50: inside the top MouseArea.
click(200, 250);
QCOMPARE(m_root->property("clicks").toInt(), 1);
// GL y=50 is Qt y=250: below it, must not count.
click(200, 50);
QCOMPARE(m_root->property("clicks").toInt(), 1);
}
void smoothScrollMovesFlickable() {
auto* flick = m_root->findChild<QQuickItem*>("flick");
mouse(vr::VREvent_MouseMove, 200, kSize.height() - 150);
for (int i = 0; i < 5; ++i) send(scrollEvent(0.f, -1.f));
QTRY_VERIFY(flick->property("contentY").toReal() > 0);
}
void keyboardCharsReachFocusedTextInput() {
auto* input = m_root->findChild<QQuickItem*>("input");
input->forceActiveFocus();
QVERIFY(input->hasActiveFocus());
send(keyboardEvent("ab", kOverlay));
send(keyboardEvent("\xc3\xa4", kOverlay)); // UTF-8 'ä'
send(keyboardEvent("\b", kOverlay));
// Other overlay: ignored
QVERIFY(!m_bridge->handleEvent(keyboardEvent("c", kOverlay + 1)));
QCOMPARE(input->property("text").toString(), QStringLiteral("ab"));
}
private:
static constexpr QSize kSize{400, 300};
static constexpr vr::VROverlayHandle_t kOverlay = 42;
void send(const vr::VREvent_t& event) {
QVERIFY(m_bridge->handleEvent(event));
}
void mouse(uint32_t type, float x, float y) {
vr::VREvent_t event{};
event.eventType = type;
event.data.mouse.x = x;
event.data.mouse.y = y;
event.data.mouse.button = vr::VRMouseButton_Left;
send(event);
}
void click(float x, float y) {
mouse(vr::VREvent_MouseMove, x, y);
mouse(vr::VREvent_MouseButtonDown, x, y);
mouse(vr::VREvent_MouseButtonUp, x, y);
}
static vr::VREvent_t scrollEvent(float dx, float dy) {
vr::VREvent_t event{};
event.eventType = vr::VREvent_ScrollSmooth;
event.data.scroll.xdelta = dx;
event.data.scroll.ydelta = dy;
return event;
}
static vr::VREvent_t keyboardEvent(const char* utf8,
vr::VROverlayHandle_t overlay) {
vr::VREvent_t event{};
event.eventType = vr::VREvent_KeyboardCharInput;
qstrncpy(event.data.keyboard.cNewInput, utf8,
sizeof(event.data.keyboard.cNewInput));
event.data.keyboard.overlayHandle = overlay;
return event;
}
QQmlEngine m_engine;
std::unique_ptr<QQuickRenderControl> m_renderControl;
std::unique_ptr<QQuickWindow> m_window;
std::unique_ptr<QQuickItem> m_root;
std::unique_ptr<OverlayInputBridge> m_bridge;
};
QTEST_MAIN(InputBridgeTest)
#include "tst_inputbridge.moc"
+118
View File
@@ -0,0 +1,118 @@
#include <QQmlEngine>
#include <QQuickWindow>
#include <QtTest>
#include "render/OffscreenQuickRenderer.h"
#include "render/RenderBackend.h"
// Renders a known scene through the real offscreen pipeline (backend +
// QQuickRenderControl) and checks the texture content via readback. The backend
// comes from VRDASH_TEST_API (opengl / vulkan): the graphics API is
// process-global, so CTest runs one process each.
class RenderTest : public QObject {
Q_OBJECT
private slots:
void initTestCase() {
GraphicsApi api = GraphicsApi::OpenGL;
const QString apiName =
qEnvironmentVariable("VRDASH_TEST_API", QStringLiteral("opengl"));
QVERIFY2(parseGraphicsApi(apiName, &api), qPrintable(apiName));
m_backend = createRenderBackend(api);
if (!m_backend)
QSKIP(qPrintable(apiName + QStringLiteral(" not compiled in")));
QQuickWindow::setGraphicsApi(m_backend->graphicsApi());
QString error;
if (!m_backend->initialize({}, &error))
QSKIP(qPrintable(
QStringLiteral("No %1 context: %2").arg(m_backend->name(), error)));
}
void rendersSceneUpright() {
QQmlEngine engine;
const QSize size(64, 32);
OffscreenQuickRenderer renderer(*m_backend, size);
QString error;
QVERIFY2(renderer.initialize(engine, "RenderTest", "TwoTone", &error),
qPrintable(error));
int sinkCalls = 0;
vr::Texture_t submitted{};
renderer.setFrameSink(
[&](const vr::Texture_t& texture, const vr::VRTextureBounds_t&) {
++sinkCalls;
submitted = texture;
});
renderer.renderNow();
QCOMPARE(sinkCalls, 1);
QVERIFY(submitted.handle != nullptr);
const auto expectedType =
m_backend->graphicsApi() == QSGRendererInterface::Vulkan
? vr::TextureType_Vulkan
: vr::TextureType_OpenGL;
QCOMPARE(submitted.eType, expectedType);
if (submitted.eType == vr::TextureType_Vulkan) {
const auto* vk =
static_cast<const vr::VRVulkanTextureData_t*>(submitted.handle);
QVERIFY(vk->m_nImage != 0 && vk->m_pDevice && vk->m_pQueue &&
vk->m_pInstance);
QCOMPARE(vk->m_nWidth, uint32_t(size.width()));
}
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.
// Vulkan layout transitions between Qt's frames and the SteamVR
// hand-off).
void rendersRepeatedly() {
QQmlEngine engine;
OffscreenQuickRenderer renderer(*m_backend, {32, 32});
QString error;
QVERIFY2(renderer.initialize(engine, "RenderTest", "TwoTone", &error),
qPrintable(error));
for (int i = 0; i < 5; ++i) renderer.renderNow();
QCOMPARE(m_backend->readback().pixelColor(16, 2), QColor(255, 0, 0));
}
// An animated scene must render at the fps cap even when each frame
// takes noticeable time: pacing is measured from frame start, not added
// after the frame.
void pacingHoldsFrameRateWithSlowFrames() {
QQmlEngine engine;
OffscreenQuickRenderer renderer(*m_backend, {32, 32});
QString error;
QVERIFY2(renderer.initialize(engine, "RenderTest", "Spinner", &error),
qPrintable(error));
int frames = 0;
renderer.setFrameSink(
[&](const vr::Texture_t&, const vr::VRTextureBounds_t&) {
++frames;
QThread::usleep(3000); // e.g. a slow SteamVR hand-off
});
renderer.setMaxFps(60);
renderer.setActive(true);
QTest::qWait(300); // warm-up
frames = 0;
QElapsedTimer timer;
timer.start();
QTest::qWait(2000);
const double fps = frames * 1000.0 / timer.elapsed();
renderer.setActive(false);
qInfo("measured %.1f fps", fps);
QVERIFY2(fps > 57.0 && fps < 63.0, qPrintable(QString::number(fps)));
}
void cleanupTestCase() { m_backend.reset(); }
private:
std::unique_ptr<RenderBackend> m_backend;
};
QTEST_MAIN(RenderTest)
#include "tst_render.moc"
+116
View File
@@ -0,0 +1,116 @@
// An app with two surfaces, run in snapshot mode: checks surface set-up
// (overlay keys, names, sizes, shared engine, backends), the lifecycle hooks
// and that every surface renders its own scene. No SteamVR involved.
// Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
#include <QDir>
#include <QImage>
#include <QTemporaryDir>
#include <cstdio>
#include <string>
#include <vector>
#include "app/DashboardApplication.h"
namespace {
bool check(bool condition, const char* what) {
if (!condition) fprintf(stderr, "FAIL: %s\n", what);
return condition;
}
class TwoSurfaceApp : public DashboardApplication {
public:
TwoSurfaceApp(int& argc, char** argv) : DashboardApplication(argc, argv) {
setQmlModule(QStringLiteral("SurfacesTest"));
setDisplayName(QStringLiteral("Test App"));
setAppKey(QStringLiteral("config.key"));
addSurface({.id = QStringLiteral("red"),
.qmlRootType = QStringLiteral("Red"),
.textureSize = QSize(64, 32)});
addSurface({.id = QStringLiteral("blue"),
.qmlRootType = QStringLiteral("Blue"),
.displayName = QStringLiteral("Blue tab"),
.textureSize = QSize(32, 32)});
}
QString expectedKey; // --app-key overrides setAppKey()
int loadingCalls = 0;
int startedCalls = 0;
bool ok = true;
protected:
void loading() override {
++loadingCalls;
Surface* red = surface(QStringLiteral("red"));
Surface* blue = surface(QStringLiteral("blue"));
ok &= check(surfaces().size() == 2 && red && blue, "two surfaces by id");
ok &= check(surface() == red, "primary is the first");
ok &= check(surface(QStringLiteral("none")) == nullptr, "unknown id");
ok &= check(engine() != nullptr, "shared engine");
ok &= check(mode() == DashboardMode::Snapshot, "mode");
ok &= check(red && blue && red->backend() && blue->backend() &&
red->backend() != blue->backend(),
"a backend per surface");
ok &= check(appKey() == expectedKey, "app key (setAppKey / --app-key)");
ok &= check(red && red->overlayKey() == expectedKey + QLatin1String(".red"),
"overlay key = <app key>.<id>");
ok &= check(red && red->displayName() == QLatin1String("Test App"),
"tab name defaults to the app name");
ok &= check(blue && blue->displayName() == QLatin1String("Blue tab"),
"tab name from config");
ok &= check(blue && blue->textureSize() == QSize(32, 32),
"texture size from config");
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode");
}
void started() override {
++startedCalls;
const QList<Surface*> all = surfaces();
ok &= check(all.size() == 2 && all[0]->window() && all[1]->window() &&
all[0]->window() != all[1]->window(),
"a window per surface");
}
};
} // namespace
int main(int argc, char* argv[]) {
QTemporaryDir dir;
const std::string snapshot =
QDir(dir.path()).filePath(QStringLiteral("shot.png")).toStdString();
// VRDASH_TEST_CLI_KEY=1: also pass --app-key (must win over setAppKey()).
const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY");
std::vector<std::string> args = {argv[0], "--snapshot", snapshot};
if (cliKey) {
args.emplace_back("--app-key");
args.emplace_back("cli.key");
}
for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]);
std::vector<char*> cargs;
for (std::string& arg : args) cargs.push_back(arg.data());
int cargc = int(cargs.size());
TwoSurfaceApp app(cargc, cargs.data());
app.expectedKey =
cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
const int result = app.exec();
if (app.loadingCalls == 0) {
fprintf(stderr, "SKIP: no render backend (no GPU context?)\n");
return 77;
}
bool ok = app.ok;
ok &= check(result == 0, "exec() succeeded");
ok &= check(app.loadingCalls == 1 && app.startedCalls == 1,
"each hook called once");
const QImage red(QDir(dir.path()).filePath(QStringLiteral("shot-red.png")));
const QImage blue(QDir(dir.path()).filePath(QStringLiteral("shot-blue.png")));
ok &= check(red.size() == QSize(64, 32) &&
red.pixelColor(10, 10) == QColor(255, 0, 0),
"red surface rendered");
ok &= check(blue.size() == QSize(32, 32) &&
blue.pixelColor(10, 10) == QColor(0, 0, 255),
"blue surface rendered");
fprintf(stderr, ok ? "PASS\n" : "FAILED\n");
return ok ? 0 : 1;
}