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:
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
// Generated by CMake from src/AppConfig.h.in — edit the cache variables in
|
||||
// CMakeLists.txt.
|
||||
|
||||
namespace AppConfig {
|
||||
inline constexpr char kAppKey[] = "@VRDASH_APP_KEY@";
|
||||
inline constexpr char kDisplayName[] = "@VRDASH_DISPLAY_NAME@";
|
||||
inline constexpr char kVersion[] = "@PROJECT_VERSION@";
|
||||
inline constexpr int kTextureWidth = @VRDASH_TEXTURE_WIDTH@;
|
||||
inline constexpr int kTextureHeight = @VRDASH_TEXTURE_HEIGHT@;
|
||||
// Resource directory relative to the executable (same in build and install
|
||||
// tree).
|
||||
inline constexpr char kShareDirFromBin[] = "../@VRDASH_SHARE_SUBDIR@";
|
||||
} // namespace AppConfig
|
||||
@@ -0,0 +1,69 @@
|
||||
configure_file(AppConfig.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/AppConfig.h @ONLY)
|
||||
|
||||
# Everything except main() lives in a static library: the dashboard executables and
|
||||
# the tests link against it.
|
||||
qt_add_library(vrdashboard_core STATIC
|
||||
app/DashboardApplication.h app/DashboardApplication.cpp
|
||||
app/Surface.h app/Surface.cpp
|
||||
vr/Overlay.h vr/Overlay.cpp
|
||||
render/RenderBackend.h render/RenderBackend.cpp
|
||||
render/OpenGLRenderBackend.h render/OpenGLRenderBackend.cpp
|
||||
render/OffscreenQuickRenderer.h render/OffscreenQuickRenderer.cpp
|
||||
vr/VrRuntime.h vr/VrRuntime.cpp
|
||||
vr/DashboardOverlay.h vr/DashboardOverlay.cpp
|
||||
input/OverlayInputBridge.h input/OverlayInputBridge.cpp
|
||||
input/ControllerNavigation.h input/ControllerNavigation.cpp
|
||||
)
|
||||
target_include_directories(vrdashboard_core PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
target_link_libraries(vrdashboard_core PUBLIC
|
||||
Qt6::Core Qt6::Gui Qt6::OpenGL Qt6::Qml Qt6::Quick Qt6::QuickControls2
|
||||
OpenVR::OpenVR)
|
||||
target_compile_options(vrdashboard_core PRIVATE -Wall -Wextra)
|
||||
|
||||
# Optional Vulkan backend: needs the Vulkan headers and a Qt built with Vulkan support.
|
||||
if(VRDASH_VULKAN STREQUAL "AUTO")
|
||||
find_package(Vulkan QUIET)
|
||||
set(_vulkan_ok FALSE)
|
||||
if(TARGET Vulkan::Headers AND QT_FEATURE_vulkan)
|
||||
set(_vulkan_ok TRUE)
|
||||
endif()
|
||||
elseif(VRDASH_VULKAN)
|
||||
find_package(Vulkan REQUIRED)
|
||||
if(NOT QT_FEATURE_vulkan)
|
||||
message(FATAL_ERROR "VRDASH_VULKAN=ON but Qt was built without Vulkan support")
|
||||
endif()
|
||||
set(_vulkan_ok TRUE)
|
||||
else()
|
||||
set(_vulkan_ok FALSE)
|
||||
endif()
|
||||
if(_vulkan_ok)
|
||||
target_sources(vrdashboard_core PRIVATE
|
||||
render/VulkanRenderBackend.h render/VulkanRenderBackend.cpp)
|
||||
# Headers only: Qt loads libvulkan at runtime, nothing links against it.
|
||||
target_link_libraries(vrdashboard_core PUBLIC Vulkan::Headers)
|
||||
target_compile_definitions(vrdashboard_core PUBLIC VRDASH_HAS_VULKAN=1)
|
||||
else()
|
||||
target_compile_definitions(vrdashboard_core PUBLIC VRDASH_HAS_VULKAN=0)
|
||||
endif()
|
||||
message(STATUS "Vulkan backend: ${_vulkan_ok}")
|
||||
|
||||
# The app: its UI is the QML module "FrameAssistant" (src/qml/).
|
||||
vrdash_add_dashboard(frameassistant
|
||||
OUTPUT_NAME ${VRDASH_EXECUTABLE_NAME}
|
||||
QML_URI FrameAssistant
|
||||
SOURCES main.cpp Dashboard.h Dashboard.cpp
|
||||
QML_FILES
|
||||
qml/Main.qml
|
||||
qml/Card.qml)
|
||||
|
||||
install(FILES ${OPENVR_LIBRARY} DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
|
||||
# Licenses: ours, the overview of third-party components, OpenVR's.
|
||||
install(FILES ${PROJECT_SOURCE_DIR}/LICENSE ${PROJECT_SOURCE_DIR}/THIRD_PARTY_NOTICES.md
|
||||
DESTINATION ${CMAKE_INSTALL_DOCDIR})
|
||||
if(EXISTS ${OPENVR_LICENSE})
|
||||
install(FILES ${OPENVR_LICENSE} DESTINATION ${CMAKE_INSTALL_DOCDIR}/third-party
|
||||
RENAME openvr.LICENSE)
|
||||
endif()
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "Dashboard.h"
|
||||
|
||||
Dashboard::Dashboard(int& argc, char** argv)
|
||||
: DashboardApplication(argc, argv) {
|
||||
addSurface({.qmlRootType = QStringLiteral("Main")});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "app/DashboardApplication.h"
|
||||
|
||||
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
|
||||
// (QML_URI FrameAssistant, files in src/qml/).
|
||||
//
|
||||
// Add surfaces (views) in the constructor, override loading()/started() to
|
||||
// wire your own C++ objects (members of this class) to the surfaces and QML.
|
||||
class Dashboard : public DashboardApplication {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Dashboard(int& argc, char** argv);
|
||||
};
|
||||
@@ -0,0 +1,680 @@
|
||||
#include "app/DashboardApplication.h"
|
||||
|
||||
#include <QCommandLineParser>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QGuiApplication>
|
||||
#include <QPainter>
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickStyle>
|
||||
#include <QQuickView>
|
||||
#include <QQuickWindow>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "AppConfig.h"
|
||||
#include "input/ControllerNavigation.h"
|
||||
#include "render/OffscreenQuickRenderer.h"
|
||||
#include "render/RenderBackend.h"
|
||||
#include "vr/Overlay.h"
|
||||
#include "vr/VrRuntime.h"
|
||||
|
||||
namespace {
|
||||
|
||||
QString bundleRoot() {
|
||||
return QDir::cleanPath(QCoreApplication::applicationDirPath() +
|
||||
QStringLiteral("/.."));
|
||||
}
|
||||
|
||||
QString shareDir() {
|
||||
return QDir::cleanPath(QCoreApplication::applicationDirPath() + '/' +
|
||||
AppConfig::kShareDirFromBin);
|
||||
}
|
||||
|
||||
// Relative paths are resolved against the bundle root, so manifest arguments
|
||||
// work no matter which working directory SteamVR starts the process in.
|
||||
QString resolvePath(const QString& path) {
|
||||
return QDir::isAbsolutePath(path) ? path
|
||||
: QDir(bundleRoot()).absoluteFilePath(path);
|
||||
}
|
||||
|
||||
FILE* g_logFile = nullptr;
|
||||
|
||||
void logToFile(QtMsgType type, const QMessageLogContext&,
|
||||
const QString& message) {
|
||||
static const char* const levels[] = {"debug", "warning", "critical", "fatal",
|
||||
"info"};
|
||||
const QByteArray line =
|
||||
QStringLiteral("%1 [%2] %3\n")
|
||||
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs),
|
||||
QLatin1String(levels[type]), message)
|
||||
.toUtf8();
|
||||
fputs(line.constData(), stderr);
|
||||
fputs(line.constData(), g_logFile);
|
||||
fflush(g_logFile);
|
||||
}
|
||||
|
||||
// Qt.quit() / Qt.exit() in QML only emit signals on the engine; route them to
|
||||
// the app. Queued, so QML finishes the current handler before the event loop
|
||||
// exits.
|
||||
void connectQuit(QQmlEngine* engine) {
|
||||
QObject::connect(engine, &QQmlEngine::quit, qApp, &QCoreApplication::quit,
|
||||
Qt::QueuedConnection);
|
||||
QObject::connect(engine, &QQmlEngine::exit, qApp, &QCoreApplication::exit,
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
QString& defaultQmlModule() {
|
||||
static QString uri;
|
||||
return uri;
|
||||
}
|
||||
|
||||
int fail(const QString& message) {
|
||||
qCritical().noquote() << message;
|
||||
return 1;
|
||||
}
|
||||
|
||||
QString initials(const QString& name) {
|
||||
QString result;
|
||||
for (const QString& word : name.split(' ', Qt::SkipEmptyParts)) {
|
||||
if (result.size() < 2) result += word.front().toUpper();
|
||||
}
|
||||
return result.isEmpty() ? QStringLiteral("?") : result;
|
||||
}
|
||||
|
||||
// Creates and initializes the render backend; must run before the first
|
||||
// QQuickWindow exists. If the API was not chosen explicitly, a Vulkan start-up
|
||||
// failure (e.g. no Vulkan driver) falls back to OpenGL.
|
||||
std::unique_ptr<RenderBackend> startBackend(GraphicsApi api, bool apiExplicit,
|
||||
const RenderInterop& interop,
|
||||
QString* error) {
|
||||
for (;;) {
|
||||
std::unique_ptr<RenderBackend> backend = createRenderBackend(api);
|
||||
if (!backend) {
|
||||
*error = QStringLiteral("This build has no Vulkan support");
|
||||
return nullptr;
|
||||
}
|
||||
QQuickWindow::setGraphicsApi(backend->graphicsApi());
|
||||
QString reason;
|
||||
if (backend->initialize(interop, &reason)) return backend;
|
||||
*error = QStringLiteral("%1 backend: %2").arg(backend->name(), reason);
|
||||
if (apiExplicit || api == GraphicsApi::OpenGL) return nullptr;
|
||||
qWarning().noquote() << *error << "- falling back to OpenGL";
|
||||
api = GraphicsApi::OpenGL;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// What the command line asked for (parsed in the constructor).
|
||||
struct DashboardApplication::Options {
|
||||
QString error; // set: exec() reports it and fails
|
||||
bool manifestCommand = false;
|
||||
bool install = false;
|
||||
bool launch = false;
|
||||
QString manifest;
|
||||
DashboardMode mode = DashboardMode::Overlay;
|
||||
QString snapshotFile;
|
||||
int benchmarkFrames = 0;
|
||||
// Vulkan by default where compiled in; see startBackend() for the fallback.
|
||||
GraphicsApi graphicsApi =
|
||||
VRDASH_HAS_VULKAN ? GraphicsApi::Vulkan : GraphicsApi::OpenGL;
|
||||
bool graphicsApiExplicit = false; // --graphics-api given: no fallback
|
||||
int maxFps = 60;
|
||||
QString appKey; // --app-key, empty: not given
|
||||
QString appName; // --app-name, empty: not given
|
||||
};
|
||||
|
||||
const char* dashboardModeName(DashboardMode mode) {
|
||||
switch (mode) {
|
||||
case DashboardMode::Overlay:
|
||||
return "SteamVR overlay";
|
||||
case DashboardMode::Desktop:
|
||||
return "desktop preview";
|
||||
case DashboardMode::Snapshot:
|
||||
return "snapshot";
|
||||
case DashboardMode::Benchmark:
|
||||
return "benchmark";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
QImage DashboardApplication::makeThumbnail(const QString& label) {
|
||||
QImage image(256, 256, QImage::Format_RGBA8888);
|
||||
image.fill(Qt::transparent);
|
||||
QPainter p(&image);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
p.setBrush(QColor(0x2a, 0x6f, 0xdb));
|
||||
p.setPen(Qt::NoPen);
|
||||
p.drawRoundedRect(image.rect().adjusted(16, 16, -16, -16), 40, 40);
|
||||
p.setPen(Qt::white);
|
||||
QFont font = p.font();
|
||||
font.setPixelSize(label.size() > 2 ? 72 : 96);
|
||||
font.setBold(true);
|
||||
p.setFont(font);
|
||||
p.drawText(image.rect(), Qt::AlignCenter, label);
|
||||
return image;
|
||||
}
|
||||
|
||||
void DashboardApplication::setDefaultQmlModule(const char* uri) {
|
||||
defaultQmlModule() = QString::fromUtf8(uri);
|
||||
}
|
||||
|
||||
DashboardApplication::DashboardApplication(int& argc, char** argv)
|
||||
: m_options(std::make_unique<Options>()), m_qmlModule(defaultQmlModule()) {
|
||||
// When launched by SteamVR, stderr is not a TTY and Qt would log to
|
||||
// journald only; keep messages on stderr so they show up wherever the
|
||||
// process output goes.
|
||||
if (!qEnvironmentVariableIsSet("QT_FORCE_STDERR_LOGGING"))
|
||||
qputenv("QT_FORCE_STDERR_LOGGING", "1");
|
||||
|
||||
// Manifest commands only talk to SteamVR: no GUI (and no display
|
||||
// connection) needed, so they also work over SSH.
|
||||
Options& o = *m_options;
|
||||
o.manifestCommand = std::any_of(argv + 1, argv + argc, [](const char* arg) {
|
||||
return qstrcmp(arg, "--install-manifest") == 0 ||
|
||||
qstrcmp(arg, "--uninstall-manifest") == 0;
|
||||
});
|
||||
if (o.manifestCommand)
|
||||
m_app = std::make_unique<QCoreApplication>(argc, argv);
|
||||
else
|
||||
m_app = std::make_unique<QGuiApplication>(argc, argv);
|
||||
QCoreApplication::setApplicationName(
|
||||
QString::fromUtf8(AppConfig::kDisplayName));
|
||||
QCoreApplication::setApplicationVersion(
|
||||
QString::fromLatin1(AppConfig::kVersion));
|
||||
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription(QStringLiteral("SteamVR dashboard overlay"));
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
const QCommandLineOption desktopOption(
|
||||
QStringLiteral("desktop"),
|
||||
QStringLiteral("Show the UI in a desktop window (no SteamVR)."));
|
||||
const QCommandLineOption snapshotOption(
|
||||
QStringLiteral("snapshot"),
|
||||
QStringLiteral("Render one frame offscreen to <png> and exit."),
|
||||
QStringLiteral("png"));
|
||||
const QCommandLineOption benchmarkOption(
|
||||
QStringLiteral("benchmark"),
|
||||
QStringLiteral("Render <frames> frames offscreen (no SteamVR), "
|
||||
"waiting for the GPU after each, and print timings."),
|
||||
QStringLiteral("frames"));
|
||||
const QCommandLineOption apiOption(
|
||||
QStringLiteral("graphics-api"),
|
||||
QStringLiteral("Render backend: vulkan (default; falls back to opengl "
|
||||
"if Vulkan is unavailable) or opengl."),
|
||||
QStringLiteral("api"));
|
||||
const QCommandLineOption keyOption(
|
||||
QStringLiteral("app-key"),
|
||||
QStringLiteral("SteamVR app key; overlay keys derive from it "
|
||||
"(default from build)."),
|
||||
QStringLiteral("key"));
|
||||
const QCommandLineOption nameOption(
|
||||
QStringLiteral("app-name"),
|
||||
QStringLiteral("App name, default name of the dashboard tabs "
|
||||
"(default from build)."),
|
||||
QStringLiteral("name"));
|
||||
const QCommandLineOption fpsOption(
|
||||
QStringLiteral("max-fps"),
|
||||
QStringLiteral("Upper bound for overlay frame rate (default 60)."),
|
||||
QStringLiteral("fps"), QStringLiteral("60"));
|
||||
const QCommandLineOption installOption(
|
||||
QStringLiteral("install-manifest"),
|
||||
QStringLiteral("Register the manifest with SteamVR (auto-launch)."));
|
||||
const QCommandLineOption uninstallOption(
|
||||
QStringLiteral("uninstall-manifest"),
|
||||
QStringLiteral("Unregister the manifest from SteamVR."));
|
||||
const QCommandLineOption manifestOption(
|
||||
QStringLiteral("manifest"),
|
||||
QStringLiteral("Manifest for --(un)install-manifest "
|
||||
"(default: share/<app>/manifest.vrmanifest)."),
|
||||
QStringLiteral("path"));
|
||||
const QCommandLineOption launchOption(
|
||||
QStringLiteral("launch"),
|
||||
QStringLiteral(
|
||||
"With --install-manifest: start the overlays via SteamVR now."));
|
||||
const QCommandLineOption logOption(
|
||||
QStringLiteral("log-file"),
|
||||
QStringLiteral(
|
||||
"Also append log output to <path> (relative to the bundle root)."),
|
||||
QStringLiteral("path"));
|
||||
parser.addOptions({desktopOption, snapshotOption, benchmarkOption, apiOption,
|
||||
keyOption, nameOption, fpsOption, installOption,
|
||||
uninstallOption, manifestOption, launchOption, logOption});
|
||||
parser.process(*m_app); // exits for --help / --version
|
||||
|
||||
if (parser.isSet(logOption)) {
|
||||
const QString path = resolvePath(parser.value(logOption));
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
g_logFile = fopen(QFile::encodeName(path).constData(), "a");
|
||||
if (g_logFile)
|
||||
qInstallMessageHandler(logToFile);
|
||||
else
|
||||
qWarning().noquote() << "Cannot open log file" << path;
|
||||
}
|
||||
qInfo().noquote() << QCoreApplication::applicationName()
|
||||
<< QCoreApplication::applicationVersion()
|
||||
<< "started:" << QCoreApplication::arguments().join(' ');
|
||||
|
||||
o.install = parser.isSet(installOption);
|
||||
o.launch = parser.isSet(launchOption);
|
||||
o.manifest = parser.isSet(manifestOption)
|
||||
? resolvePath(parser.value(manifestOption))
|
||||
: shareDir() + QStringLiteral("/manifest.vrmanifest");
|
||||
if (o.manifestCommand) return;
|
||||
|
||||
if (parser.isSet(apiOption)) {
|
||||
if (!parseGraphicsApi(parser.value(apiOption), &o.graphicsApi))
|
||||
o.error = QStringLiteral("Unknown graphics API: %1")
|
||||
.arg(parser.value(apiOption));
|
||||
o.graphicsApiExplicit = true;
|
||||
}
|
||||
bool fpsOk = false;
|
||||
o.maxFps = parser.value(fpsOption).toInt(&fpsOk);
|
||||
if (!fpsOk || o.maxFps <= 0)
|
||||
o.error = QStringLiteral("--max-fps needs a positive number, got '%1'")
|
||||
.arg(parser.value(fpsOption));
|
||||
o.appKey = parser.value(keyOption);
|
||||
o.appName = parser.value(nameOption);
|
||||
|
||||
if (parser.isSet(desktopOption)) {
|
||||
o.mode = DashboardMode::Desktop;
|
||||
} else if (parser.isSet(benchmarkOption)) {
|
||||
o.mode = DashboardMode::Benchmark;
|
||||
bool framesOk = false;
|
||||
o.benchmarkFrames = parser.value(benchmarkOption).toInt(&framesOk);
|
||||
if (!framesOk || o.benchmarkFrames <= 0)
|
||||
o.error = QStringLiteral("--benchmark needs a positive frame count");
|
||||
} else if (parser.isSet(snapshotOption)) {
|
||||
o.mode = DashboardMode::Snapshot;
|
||||
o.snapshotFile = parser.value(snapshotOption);
|
||||
}
|
||||
}
|
||||
|
||||
DashboardApplication::~DashboardApplication() = default;
|
||||
|
||||
void DashboardApplication::setQmlModule(const QString& uri) {
|
||||
m_qmlModule = uri;
|
||||
}
|
||||
|
||||
void DashboardApplication::addSurface(SurfaceConfig config) {
|
||||
m_surfaceConfigs.append(std::move(config));
|
||||
}
|
||||
|
||||
void DashboardApplication::setAppKey(const QString& key) {
|
||||
m_configAppKey = key;
|
||||
}
|
||||
|
||||
void DashboardApplication::setDisplayName(const QString& name) {
|
||||
m_configDisplayName = name;
|
||||
}
|
||||
|
||||
void DashboardApplication::setQuickStyle(const QString& style) {
|
||||
m_quickStyle = style;
|
||||
}
|
||||
|
||||
void DashboardApplication::loading() {}
|
||||
|
||||
void DashboardApplication::started() {}
|
||||
|
||||
QList<Surface*> DashboardApplication::surfaces() const {
|
||||
QList<Surface*> result;
|
||||
for (const auto& surface : m_surfaces) result.append(surface.get());
|
||||
return result;
|
||||
}
|
||||
|
||||
Surface* DashboardApplication::surface() const {
|
||||
return m_surfaces.empty() ? nullptr : m_surfaces.front().get();
|
||||
}
|
||||
|
||||
Surface* DashboardApplication::surface(const QString& id) const {
|
||||
for (const auto& surface : m_surfaces) {
|
||||
if (surface->id() == id) return surface.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool DashboardApplication::validateConfig(QString* error) const {
|
||||
if (m_qmlModule.isEmpty()) {
|
||||
*error = QStringLiteral(
|
||||
"No QML module: build with vrdash_add_dashboard() or call "
|
||||
"setQmlModule()");
|
||||
return false;
|
||||
}
|
||||
if (m_surfaceConfigs.isEmpty()) {
|
||||
*error = QStringLiteral("No surface configured (addSurface())");
|
||||
return false;
|
||||
}
|
||||
QSet<QString> ids;
|
||||
for (const SurfaceConfig& config : m_surfaceConfigs) {
|
||||
if (config.qmlRootType.isEmpty()) {
|
||||
*error = QStringLiteral("Surface '%1' has no qmlRootType").arg(config.id);
|
||||
return false;
|
||||
}
|
||||
if (ids.contains(config.id)) {
|
||||
*error = QStringLiteral("Surface id '%1' is used twice").arg(config.id);
|
||||
return false;
|
||||
}
|
||||
ids.insert(config.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int DashboardApplication::exec() {
|
||||
const Options& o = *m_options;
|
||||
if (!o.error.isEmpty()) return fail(o.error);
|
||||
if (o.manifestCommand) return runManifestCommand();
|
||||
QString error;
|
||||
if (!validateConfig(&error)) return fail(error);
|
||||
|
||||
m_mode = o.mode;
|
||||
m_appKey = !o.appKey.isEmpty() ? o.appKey
|
||||
: !m_configAppKey.isEmpty()
|
||||
? m_configAppKey
|
||||
: QString::fromUtf8(AppConfig::kAppKey);
|
||||
m_appName = !o.appName.isEmpty() ? o.appName
|
||||
: !m_configDisplayName.isEmpty()
|
||||
? m_configDisplayName
|
||||
: QString::fromUtf8(AppConfig::kDisplayName);
|
||||
QCoreApplication::setApplicationName(m_appName);
|
||||
if (!m_quickStyle.isEmpty()) QQuickStyle::setStyle(m_quickStyle);
|
||||
|
||||
m_engine = std::make_unique<QQmlEngine>();
|
||||
connectQuit(m_engine.get());
|
||||
createSurfaces();
|
||||
|
||||
int result = 1;
|
||||
switch (o.mode) {
|
||||
case DashboardMode::Desktop:
|
||||
result = runDesktopPreview();
|
||||
break;
|
||||
case DashboardMode::Snapshot:
|
||||
result = runSnapshot();
|
||||
break;
|
||||
case DashboardMode::Benchmark:
|
||||
result = runBenchmark();
|
||||
break;
|
||||
case DashboardMode::Overlay:
|
||||
result = runOverlay();
|
||||
break;
|
||||
}
|
||||
// Still within exec(): the derived object (and what it owns) outlives all
|
||||
// QML. Surfaces (their QML scenes, renderers, backends) before the engine.
|
||||
m_navigation = nullptr;
|
||||
m_runtime = nullptr;
|
||||
m_surfaces.clear();
|
||||
m_engine.reset();
|
||||
return result;
|
||||
}
|
||||
|
||||
void DashboardApplication::createSurfaces() {
|
||||
const QSize defaultSize(AppConfig::kTextureWidth, AppConfig::kTextureHeight);
|
||||
for (const SurfaceConfig& config : m_surfaceConfigs) {
|
||||
const QString key = config.id.isEmpty()
|
||||
? m_appKey
|
||||
: m_appKey + QLatin1Char('.') + config.id;
|
||||
const QString name =
|
||||
config.displayName.isEmpty() ? m_appName : config.displayName;
|
||||
const QSize size =
|
||||
config.textureSize.isValid() ? config.textureSize : defaultSize;
|
||||
m_surfaces.push_back(std::make_unique<Surface>(config, key, name, size));
|
||||
}
|
||||
}
|
||||
|
||||
bool DashboardApplication::startBackends(const VrRuntime* runtime,
|
||||
QString* error) {
|
||||
const Options& o = *m_options;
|
||||
const RenderInterop interop =
|
||||
runtime ? runtime->renderInterop() : RenderInterop{};
|
||||
// The first surface decides (incl. a possible fallback); all others use the
|
||||
// same API: QQuickWindow::setGraphicsApi() is process-wide.
|
||||
GraphicsApi api = o.graphicsApi;
|
||||
bool apiExplicit = o.graphicsApiExplicit;
|
||||
for (auto& surface : m_surfaces) {
|
||||
std::unique_ptr<RenderBackend> backend =
|
||||
startBackend(api, apiExplicit, interop, error);
|
||||
if (!backend) return false;
|
||||
api = backend->graphicsApi() == QSGRendererInterface::Vulkan
|
||||
? GraphicsApi::Vulkan
|
||||
: GraphicsApi::OpenGL;
|
||||
apiExplicit = true;
|
||||
surface->setBackend(std::move(backend), o.maxFps);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QImage DashboardApplication::thumbnailFor(const Surface& surface) const {
|
||||
return surface.config().thumbnail
|
||||
? surface.config().thumbnail(surface)
|
||||
: makeThumbnail(initials(surface.displayName()));
|
||||
}
|
||||
|
||||
// Plain desktop windows (one per surface) for fast UI iteration — no SteamVR,
|
||||
// no offscreen rendering.
|
||||
int DashboardApplication::runDesktopPreview() {
|
||||
loading();
|
||||
std::vector<std::unique_ptr<QQuickView>> views;
|
||||
for (Surface* surface : surfaces()) {
|
||||
auto view = std::make_unique<QQuickView>(m_engine.get(), nullptr);
|
||||
view->setTitle(surface->displayName());
|
||||
view->setResizeMode(QQuickView::SizeRootObjectToView);
|
||||
view->resize(surface->textureSize());
|
||||
view->loadFromModule(m_qmlModule, surface->config().qmlRootType);
|
||||
if (view->status() == QQuickView::Error) return 1;
|
||||
view->show();
|
||||
views.push_back(std::move(view));
|
||||
}
|
||||
started();
|
||||
return QCoreApplication::exec();
|
||||
}
|
||||
|
||||
// Renders one frame of every surface through the real offscreen pipeline and
|
||||
// writes PNGs (<file>, or <file-base>-<id>.png per surface if there are
|
||||
// several). Verifies a render backend without a headset.
|
||||
int DashboardApplication::runSnapshot() {
|
||||
QString error;
|
||||
if (!startBackends(nullptr, &error)) return fail(error);
|
||||
loading();
|
||||
for (Surface* surface : surfaces()) {
|
||||
if (!surface->loadQml(*m_engine, m_qmlModule, &error)) return fail(error);
|
||||
}
|
||||
started();
|
||||
|
||||
const QString file = m_options->snapshotFile;
|
||||
int result = 0;
|
||||
// Let pending bindings/loaders settle for a moment before grabbing.
|
||||
QTimer::singleShot(250, m_app.get(), [&] {
|
||||
const QList<Surface*> all = surfaces();
|
||||
for (int i = 0; i < all.size(); ++i) {
|
||||
Surface* surface = all[i];
|
||||
QString path = file;
|
||||
if (all.size() > 1) {
|
||||
const QFileInfo info(file);
|
||||
const QString suffix =
|
||||
surface->id().isEmpty() ? QString::number(i) : surface->id();
|
||||
path = info.dir().filePath(info.completeBaseName() + '-' + suffix +
|
||||
'.' + info.suffix());
|
||||
}
|
||||
surface->renderer()->renderNow();
|
||||
const QImage image = surface->backend()->readback();
|
||||
if (image.isNull() || !image.save(path)) {
|
||||
qCritical().noquote() << "Could not write snapshot" << path;
|
||||
result = 1;
|
||||
}
|
||||
}
|
||||
QCoreApplication::quit();
|
||||
});
|
||||
QCoreApplication::exec();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Renders the primary surface's frames back to back, each followed by a full
|
||||
// GPU wait, and prints timing statistics. No SteamVR involved (and no hand-off
|
||||
// cost). For a meaningful load the UI should animate (hooks see Benchmark).
|
||||
int DashboardApplication::runBenchmark() {
|
||||
QString error;
|
||||
if (!startBackends(nullptr, &error)) return fail(error);
|
||||
loading();
|
||||
for (Surface* surface : surfaces()) {
|
||||
if (!surface->loadQml(*m_engine, m_qmlModule, &error)) return fail(error);
|
||||
}
|
||||
started();
|
||||
|
||||
OffscreenQuickRenderer& renderer = *surface()->renderer();
|
||||
RenderBackend& backend = *surface()->backend();
|
||||
const int frames = m_options->benchmarkFrames;
|
||||
const QSize size = surface()->textureSize();
|
||||
auto frame = [&] {
|
||||
// Advance animations, apply scene changes
|
||||
QCoreApplication::processEvents();
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
renderer.renderNow();
|
||||
backend.waitForGpu();
|
||||
return timer.nsecsElapsed() / 1e6;
|
||||
};
|
||||
for (int i = 0; i < 30; ++i) // warm-up: pipelines, caches, clocks
|
||||
frame();
|
||||
|
||||
std::vector<double> times;
|
||||
times.reserve(size_t(frames));
|
||||
QElapsedTimer total;
|
||||
total.start();
|
||||
for (int i = 0; i < frames; ++i) times.push_back(frame());
|
||||
const double totalMs = total.nsecsElapsed() / 1e6;
|
||||
|
||||
std::sort(times.begin(), times.end());
|
||||
double sum = 0;
|
||||
for (double t : times) sum += t;
|
||||
const double avg = sum / times.size();
|
||||
auto percentile = [&](double p) {
|
||||
return times[std::min(times.size() - 1, size_t(p * times.size()))];
|
||||
};
|
||||
qInfo().noquote() << QStringLiteral(
|
||||
"benchmark %1 | %2 | %3 frames %4x%5 | frame ms: "
|
||||
"avg %6, median %7, p95 %8, max %9 | "
|
||||
"throughput %10 fps (unpaced, incl. GPU wait)")
|
||||
.arg(backend.name(), backend.deviceName())
|
||||
.arg(frames)
|
||||
.arg(size.width())
|
||||
.arg(size.height())
|
||||
.arg(avg, 0, 'f', 3)
|
||||
.arg(percentile(0.5), 0, 'f', 3)
|
||||
.arg(percentile(0.95), 0, 'f', 3)
|
||||
.arg(times.back(), 0, 'f', 3)
|
||||
.arg(frames * 1000.0 / totalMs, 0, 'f', 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int DashboardApplication::runOverlay() {
|
||||
// Teardown order matters: overlays before SteamVR shuts down (VrRuntime's
|
||||
// destructor), SteamVR before the graphics devices are destroyed ("Be sure
|
||||
// to call OpenVR_Shutdown before destroying these resources", openvr.h;
|
||||
// otherwise SteamVR's client frees its objects on a dead VkDevice at exit).
|
||||
// The surfaces (renderers, backends) are members and go after this
|
||||
// function; the overlays go by `overlayGuard`, declared after `runtime`.
|
||||
VrRuntime runtime;
|
||||
QString error;
|
||||
if (!runtime.initialize(&error)) return fail(error);
|
||||
QObject::connect(&runtime, &VrRuntime::quitRequested, qApp,
|
||||
&QCoreApplication::quit);
|
||||
m_runtime = &runtime;
|
||||
|
||||
// Associate this process with its manifest entry (needed when started
|
||||
// by hand).
|
||||
vr::VRApplications()->IdentifyApplication(
|
||||
uint32_t(QCoreApplication::applicationPid()),
|
||||
m_appKey.toUtf8().constData());
|
||||
|
||||
// Device creation may depend on what SteamVR requires (Vulkan
|
||||
// extensions, HMD GPU).
|
||||
if (!startBackends(&runtime, &error)) return fail(error);
|
||||
loading();
|
||||
for (Surface* surface : surfaces()) {
|
||||
if (!surface->loadQml(*m_engine, m_qmlModule, &error)) return fail(error);
|
||||
}
|
||||
qInfo().noquote() << "Rendering with" << surface()->backend()->name() << "on"
|
||||
<< surface()->backend()->deviceName();
|
||||
|
||||
struct OverlayGuard {
|
||||
std::vector<std::unique_ptr<Surface>>& surfaces;
|
||||
~OverlayGuard() {
|
||||
for (auto& surface : surfaces) surface->destroyOverlay();
|
||||
}
|
||||
} overlayGuard{m_surfaces};
|
||||
for (Surface* surface : surfaces()) {
|
||||
if (!surface->createDashboardOverlay(runtime, thumbnailFor(*surface),
|
||||
&error))
|
||||
return fail(error);
|
||||
}
|
||||
|
||||
// Controller navigation drives the active surface: the one the user last
|
||||
// opened or pointed at, if still visible; else any visible one.
|
||||
ControllerNavigation navigation;
|
||||
Surface* active = surface();
|
||||
for (Surface* surface : surfaces()) {
|
||||
QObject::connect(surface, &Surface::activated, &navigation,
|
||||
[&active, surface] { active = surface; });
|
||||
}
|
||||
navigation.setTargetProvider([&]() -> QQuickWindow* {
|
||||
if (active && active->isVisible()) return active->window();
|
||||
for (Surface* surface : surfaces()) {
|
||||
if (surface->isVisible()) return surface->window();
|
||||
}
|
||||
return nullptr;
|
||||
});
|
||||
if (navigation.initialize(shareDir() + QStringLiteral("/input/actions.json"),
|
||||
&error)) {
|
||||
m_navigation = &navigation;
|
||||
} else {
|
||||
qWarning().noquote() << "Controller navigation disabled:" << error;
|
||||
m_navigationError = error;
|
||||
}
|
||||
|
||||
started();
|
||||
|
||||
QObject::connect(qApp, &QCoreApplication::aboutToQuit, &navigation, [this] {
|
||||
for (Surface* surface : surfaces()) surface->renderer()->setActive(false);
|
||||
});
|
||||
return QCoreApplication::exec();
|
||||
}
|
||||
|
||||
int DashboardApplication::runManifestCommand() {
|
||||
const bool install = m_options->install;
|
||||
const bool launch = m_options->launch;
|
||||
const QString& manifest = m_options->manifest;
|
||||
// Not VRApplication_Utility: VR_Init() always queries IVRSystem, which
|
||||
// current SteamVR (e.g. on the Steam Frame) refuses to utility apps
|
||||
// (VRInitError_Init_NotAvailableToUtilityApps). A background app gets the
|
||||
// interfaces and never starts or keeps SteamVR running by itself.
|
||||
vr::EVRInitError initError = vr::VRInitError_None;
|
||||
vr::VR_Init(&initError, vr::VRApplication_Background);
|
||||
if (initError != vr::VRInitError_None)
|
||||
return fail(
|
||||
QStringLiteral("VR_Init failed: %1 (%2). Is SteamVR running?")
|
||||
.arg(QString::fromUtf8(
|
||||
vr::VR_GetVRInitErrorAsEnglishDescription(initError)),
|
||||
QString::fromUtf8(vr::VR_GetVRInitErrorAsSymbol(initError))));
|
||||
|
||||
QString error;
|
||||
QStringList keys;
|
||||
bool ok = install ? VrRuntime::installManifest(manifest, &keys, &error)
|
||||
: VrRuntime::uninstallManifest(manifest, &error);
|
||||
if (ok)
|
||||
qInfo().noquote() << (install ? "Registered" : "Unregistered") << manifest
|
||||
<< keys.join(", ");
|
||||
for (const QString& key : std::as_const(keys)) {
|
||||
if (ok && launch) {
|
||||
ok = VrRuntime::launchDashboardOverlay(key, &error);
|
||||
if (ok) qInfo().noquote() << "Launched" << key;
|
||||
}
|
||||
}
|
||||
vr::VR_Shutdown();
|
||||
return ok ? 0 : fail(error);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "app/Surface.h"
|
||||
|
||||
class ControllerNavigation;
|
||||
class OffscreenQuickRenderer;
|
||||
class Overlay;
|
||||
class QCoreApplication;
|
||||
class QQmlEngine;
|
||||
class RenderBackend;
|
||||
class VrRuntime;
|
||||
|
||||
enum class DashboardMode { Overlay, Desktop, Snapshot, Benchmark };
|
||||
const char* dashboardModeName(DashboardMode mode);
|
||||
|
||||
// Base class of a dashboard app. It owns everything app- and surface-related:
|
||||
// command line, logging, SteamVR connection, render backend selection, the
|
||||
// surfaces (QML scene + renderer + overlay each, sharing one QML engine),
|
||||
// controller navigation, and the auxiliary modes (desktop preview, snapshot,
|
||||
// benchmark, manifest (un)installation).
|
||||
//
|
||||
// An app derives from it, configures it in its constructor and overrides the
|
||||
// lifecycle hooks it needs. Surfaces are components: configure them
|
||||
// (SurfaceConfig) and connect to their signals, don't subclass them.
|
||||
//
|
||||
// class MyDash : public DashboardApplication {
|
||||
// public:
|
||||
// MyDash(int& argc, char** argv) : DashboardApplication(argc, argv) {
|
||||
// addSurface({.qmlRootType = QStringLiteral("Main")});
|
||||
// }
|
||||
//
|
||||
// protected:
|
||||
// void started() override {
|
||||
// connect(surface(), &Surface::activated, this, &MyDash::onActivated);
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// int main(int argc, char* argv[]) {
|
||||
// MyDash app(argc, argv);
|
||||
// return app.exec();
|
||||
// }
|
||||
//
|
||||
// All QML and surfaces are torn down at the end of exec(), while the derived
|
||||
// object (and e.g. QML singletons it owns as members) still exists.
|
||||
class DashboardApplication : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
// Creates the Qt application object and parses the command line.
|
||||
DashboardApplication(int& argc, char** argv);
|
||||
~DashboardApplication() override;
|
||||
|
||||
// Runs the mode selected on the command line; returns the exit code.
|
||||
int exec();
|
||||
|
||||
// A simple tab icon: `label` (1-3 characters) on a rounded square.
|
||||
static QImage makeThumbnail(const QString& label);
|
||||
|
||||
// Default for setQmlModule(): called by code that vrdash_add_dashboard()
|
||||
// generates with the target's QML_URI (before main()).
|
||||
static void setDefaultQmlModule(const char* uri);
|
||||
|
||||
protected:
|
||||
// --- Configuration (in the derived class's constructor) ---
|
||||
// QML module (URI) holding the surfaces' root types. Default: the module
|
||||
// of this executable (QML_URI in vrdash_add_dashboard()).
|
||||
void setQmlModule(const QString& uri);
|
||||
// Adds a view; at least one is required. The first is the primary one.
|
||||
void addSurface(SurfaceConfig config);
|
||||
// Defaults: AppConfig (VRDASH_APP_KEY / VRDASH_DISPLAY_NAME). The command
|
||||
// line (--app-key / --app-name) overrides both.
|
||||
void setAppKey(const QString& key);
|
||||
void setDisplayName(const QString& name);
|
||||
// Qt Quick Controls style; default "Material".
|
||||
void setQuickStyle(const QString& style);
|
||||
|
||||
// --- Lifecycle hooks ---
|
||||
// Before the QML scenes are created: the shared engine, the surfaces and
|
||||
// (except in desktop preview) their render backends exist.
|
||||
virtual void loading();
|
||||
// Everything runs; called right before the event loop starts.
|
||||
virtual void started();
|
||||
|
||||
// --- The running app (valid in the hooks and until exec() returns) ---
|
||||
DashboardMode mode() const { return m_mode; }
|
||||
QString appKey() const { return m_appKey; }
|
||||
QString appName() const { return m_appName; }
|
||||
// Shared by all surfaces.
|
||||
QQmlEngine* engine() const { return m_engine.get(); }
|
||||
// All views, in addSurface() order; the primary (first) one; one by id.
|
||||
// Each surface gives access to its renderer, backend and overlay (null
|
||||
// where the mode has none: no backend/renderer in desktop preview, no
|
||||
// overlay outside overlay mode).
|
||||
QList<Surface*> surfaces() const;
|
||||
Surface* surface() const;
|
||||
Surface* surface(const QString& id) const;
|
||||
// Overlay mode only (else null).
|
||||
VrRuntime* vrRuntime() const { return m_runtime; }
|
||||
// Null if SteamVR Input could not be set up; navigationError() says why.
|
||||
ControllerNavigation* navigation() const { return m_navigation; }
|
||||
QString navigationError() const { return m_navigationError; }
|
||||
|
||||
private:
|
||||
struct Options;
|
||||
|
||||
bool validateConfig(QString* error) const;
|
||||
void createSurfaces();
|
||||
bool startBackends(const VrRuntime* runtime, QString* error);
|
||||
QImage thumbnailFor(const Surface& surface) const;
|
||||
int runDesktopPreview();
|
||||
int runSnapshot();
|
||||
int runBenchmark();
|
||||
int runOverlay();
|
||||
int runManifestCommand();
|
||||
|
||||
std::unique_ptr<QCoreApplication> m_app;
|
||||
std::unique_ptr<Options> m_options;
|
||||
// Configuration.
|
||||
QString m_qmlModule;
|
||||
QString m_configAppKey;
|
||||
QString m_configDisplayName;
|
||||
QString m_quickStyle = QStringLiteral("Material");
|
||||
QList<SurfaceConfig> m_surfaceConfigs;
|
||||
// Running state.
|
||||
DashboardMode m_mode = DashboardMode::Overlay;
|
||||
QString m_appKey;
|
||||
QString m_appName;
|
||||
VrRuntime* m_runtime = nullptr;
|
||||
ControllerNavigation* m_navigation = nullptr;
|
||||
QString m_navigationError;
|
||||
// Torn down at the end of exec(): surfaces first, then the engine.
|
||||
std::unique_ptr<QQmlEngine> m_engine;
|
||||
std::vector<std::unique_ptr<Surface>> m_surfaces;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "app/Surface.h"
|
||||
|
||||
#include "render/OffscreenQuickRenderer.h"
|
||||
#include "render/RenderBackend.h"
|
||||
#include "vr/DashboardOverlay.h"
|
||||
|
||||
Surface::Surface(SurfaceConfig config, QString overlayKey, QString displayName,
|
||||
QSize textureSize, QObject* parent)
|
||||
: QObject(parent),
|
||||
m_config(std::move(config)),
|
||||
m_overlayKey(std::move(overlayKey)),
|
||||
m_displayName(std::move(displayName)),
|
||||
m_textureSize(textureSize) {}
|
||||
|
||||
Surface::~Surface() {
|
||||
m_overlay.reset();
|
||||
m_renderer.reset(); // frees the backend's objects on Qt's device
|
||||
m_backend.reset();
|
||||
}
|
||||
|
||||
QQuickWindow* Surface::window() const {
|
||||
return m_renderer ? m_renderer->window() : nullptr;
|
||||
}
|
||||
|
||||
bool Surface::isVisible() const { return m_overlay && m_overlay->isVisible(); }
|
||||
|
||||
void Surface::setBackend(std::unique_ptr<RenderBackend> backend, int maxFps) {
|
||||
m_backend = std::move(backend);
|
||||
m_renderer =
|
||||
std::make_unique<OffscreenQuickRenderer>(*m_backend, m_textureSize);
|
||||
m_renderer->setMaxFps(maxFps);
|
||||
}
|
||||
|
||||
bool Surface::loadQml(QQmlEngine& engine, const QString& qmlModule,
|
||||
QString* error) {
|
||||
return m_renderer->initialize(engine, qmlModule, m_config.qmlRootType, error);
|
||||
}
|
||||
|
||||
bool Surface::createDashboardOverlay(VrRuntime& runtime,
|
||||
const QImage& thumbnail, QString* error) {
|
||||
auto overlay = std::make_unique<DashboardOverlay>(runtime);
|
||||
if (!overlay->create(m_overlayKey, m_displayName, thumbnail, window(),
|
||||
m_textureSize, error))
|
||||
return false;
|
||||
m_overlay = std::move(overlay);
|
||||
|
||||
// Every rendered frame goes to the overlay; rendering only while visible.
|
||||
m_renderer->setFrameSink([this](const vr::Texture_t& texture,
|
||||
const vr::VRTextureBounds_t& bounds) {
|
||||
m_overlay->submitFrame(texture, bounds);
|
||||
});
|
||||
connect(m_overlay.get(), &Overlay::visibilityChanged, m_renderer.get(),
|
||||
&OffscreenQuickRenderer::setActive);
|
||||
connect(m_overlay.get(), &Overlay::visibilityChanged, this,
|
||||
[this](bool visible) {
|
||||
emit visibilityChanged(visible);
|
||||
if (visible) emit activated(); // a dashboard tab was opened
|
||||
});
|
||||
connect(m_overlay.get(), &Overlay::pointerEntered, this, &Surface::activated);
|
||||
m_renderer->renderNow(); // initial texture, so the tab is never empty
|
||||
m_renderer->setActive(m_overlay->isVisible());
|
||||
return true;
|
||||
}
|
||||
|
||||
void Surface::destroyOverlay() {
|
||||
if (m_renderer) {
|
||||
m_renderer->setActive(false);
|
||||
m_renderer->setFrameSink({});
|
||||
}
|
||||
m_overlay.reset();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QObject>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class OffscreenQuickRenderer;
|
||||
class Overlay;
|
||||
class QQmlEngine;
|
||||
class QQuickWindow;
|
||||
class RenderBackend;
|
||||
class Surface;
|
||||
class VrRuntime;
|
||||
|
||||
// Configuration of one view of the app (see DashboardApplication::addSurface).
|
||||
// Members not given (designated initializers) keep their defaults.
|
||||
struct SurfaceConfig {
|
||||
// Unique within the app; the overlay key is "<app key>.<id>", or the app key
|
||||
// itself for an empty id (allowed for one surface).
|
||||
QString id = {};
|
||||
// Root type in the app's QML module (required).
|
||||
QString qmlRootType = {};
|
||||
// Tab name; default: the app's display name.
|
||||
QString displayName = {};
|
||||
// Texture size in pixels; default: AppConfig (VRDASH_TEXTURE_*).
|
||||
QSize textureSize = {};
|
||||
// Dashboard tab icon; default: the display name's initials.
|
||||
std::function<QImage(const Surface&)> thumbnail = {};
|
||||
};
|
||||
|
||||
// One view of the app: its QML scene, rendered offscreen by its own renderer
|
||||
// and render backend, shown in SteamVR by its overlay (a dashboard tab).
|
||||
// A component: configure it (SurfaceConfig) and connect to its signals; it is
|
||||
// created and driven by DashboardApplication.
|
||||
//
|
||||
// Lifetime rule (enforced by DashboardApplication): the overlay goes before
|
||||
// SteamVR shuts down, SteamVR shuts down before the render backend goes.
|
||||
class Surface : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
Surface(SurfaceConfig config, QString overlayKey, QString displayName,
|
||||
QSize textureSize, QObject* parent = nullptr);
|
||||
~Surface() override;
|
||||
|
||||
const SurfaceConfig& config() const { return m_config; }
|
||||
QString id() const { return m_config.id; }
|
||||
QString overlayKey() const { return m_overlayKey; }
|
||||
QString displayName() const { return m_displayName; }
|
||||
QSize textureSize() const { return m_textureSize; }
|
||||
|
||||
RenderBackend* backend() const { return m_backend.get(); }
|
||||
OffscreenQuickRenderer* renderer() const { return m_renderer.get(); }
|
||||
QQuickWindow* window() const;
|
||||
Overlay* overlay() const { return m_overlay.get(); } // overlay mode only
|
||||
bool isVisible() const;
|
||||
|
||||
// --- Driven by DashboardApplication ---
|
||||
// Takes the initialized backend and creates the renderer (no QML yet).
|
||||
void setBackend(std::unique_ptr<RenderBackend> backend, int maxFps);
|
||||
bool loadQml(QQmlEngine& engine, const QString& qmlModule, QString* error);
|
||||
bool createDashboardOverlay(VrRuntime& runtime, const QImage& thumbnail,
|
||||
QString* error);
|
||||
void destroyOverlay();
|
||||
|
||||
signals:
|
||||
void visibilityChanged(bool visible);
|
||||
// Became the surface the user interacts with: the laser entered it, or
|
||||
// its dashboard tab was opened.
|
||||
void activated();
|
||||
|
||||
private:
|
||||
SurfaceConfig m_config;
|
||||
QString m_overlayKey;
|
||||
QString m_displayName;
|
||||
QSize m_textureSize;
|
||||
// Destruction order: overlay, renderer, backend.
|
||||
std::unique_ptr<RenderBackend> m_backend;
|
||||
std::unique_ptr<OffscreenQuickRenderer> m_renderer;
|
||||
std::unique_ptr<Overlay> m_overlay;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "ControllerNavigation.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QKeyEvent>
|
||||
#include <QQuickWindow>
|
||||
#include <iterator>
|
||||
|
||||
namespace {
|
||||
// OpenVR has no name function for EVRInputError.
|
||||
QString inputErrorName(vr::EVRInputError err) {
|
||||
static const char* const names[] = {"VRInputError_None",
|
||||
"VRInputError_NameNotFound",
|
||||
"VRInputError_WrongType",
|
||||
"VRInputError_InvalidHandle",
|
||||
"VRInputError_InvalidParam",
|
||||
"VRInputError_NoSteam",
|
||||
"VRInputError_MaxCapacityReached",
|
||||
"VRInputError_IPCError",
|
||||
"VRInputError_NoActiveActionSet",
|
||||
"VRInputError_InvalidDevice",
|
||||
"VRInputError_InvalidSkeleton",
|
||||
"VRInputError_InvalidBoneCount",
|
||||
"VRInputError_InvalidCompressedData",
|
||||
"VRInputError_NoData",
|
||||
"VRInputError_BufferTooSmall",
|
||||
"VRInputError_MismatchedActionManifest",
|
||||
"VRInputError_MissingSkeletonData",
|
||||
"VRInputError_InvalidBoneIndex",
|
||||
"VRInputError_InvalidPriority",
|
||||
"VRInputError_PermissionDenied",
|
||||
"VRInputError_InvalidRenderModel"};
|
||||
const int index = int(err);
|
||||
if (index >= 0 && index < int(std::size(names)))
|
||||
return QString::fromLatin1(names[index]);
|
||||
return QStringLiteral("VRInputError %1").arg(index);
|
||||
}
|
||||
|
||||
constexpr qint64 kRepeatDelayMs = 400;
|
||||
constexpr qint64 kRepeatIntervalMs = 120;
|
||||
} // namespace
|
||||
|
||||
ControllerNavigation::ControllerNavigation(QObject* parent)
|
||||
: QObject(parent),
|
||||
m_actions{{
|
||||
{"/actions/dashboard/in/focus_next", Qt::Key_Tab, Qt::NoModifier,
|
||||
true},
|
||||
{"/actions/dashboard/in/focus_previous", Qt::Key_Backtab,
|
||||
Qt::ShiftModifier, true},
|
||||
{"/actions/dashboard/in/increase", Qt::Key_Right, Qt::NoModifier,
|
||||
true},
|
||||
{"/actions/dashboard/in/decrease", Qt::Key_Left, Qt::NoModifier,
|
||||
true},
|
||||
{"/actions/dashboard/in/activate", Qt::Key_Space, Qt::NoModifier,
|
||||
false},
|
||||
{"/actions/dashboard/in/back", Qt::Key_Escape, Qt::NoModifier, false},
|
||||
}} {
|
||||
m_pollTimer.setInterval(11);
|
||||
connect(&m_pollTimer, &QTimer::timeout, this, &ControllerNavigation::poll);
|
||||
}
|
||||
|
||||
bool ControllerNavigation::initialize(const QString& manifestPath,
|
||||
QString* error) {
|
||||
vr::IVRInput* input = vr::VRInput();
|
||||
if (!input) {
|
||||
*error = QStringLiteral("IVRInput interface unavailable");
|
||||
return false;
|
||||
}
|
||||
vr::EVRInputError err =
|
||||
input->SetActionManifestPath(manifestPath.toUtf8().constData());
|
||||
if (err != vr::VRInputError_None) {
|
||||
*error = QStringLiteral("SetActionManifestPath(%1) failed: %2")
|
||||
.arg(manifestPath, inputErrorName(err));
|
||||
return false;
|
||||
}
|
||||
err = input->GetActionSetHandle("/actions/dashboard", &m_actionSet);
|
||||
for (Action& action : m_actions) {
|
||||
if (err == vr::VRInputError_None)
|
||||
err = input->GetActionHandle(action.path, &action.handle);
|
||||
}
|
||||
if (err != vr::VRInputError_None) {
|
||||
*error = QStringLiteral("Resolving input actions failed: %1")
|
||||
.arg(inputErrorName(err));
|
||||
return false;
|
||||
}
|
||||
m_clock.start();
|
||||
m_pollTimer.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ControllerNavigation::poll() {
|
||||
vr::VRActiveActionSet_t activeSet{};
|
||||
activeSet.ulActionSet = m_actionSet;
|
||||
if (vr::VRInput()->UpdateActionState(&activeSet, sizeof(activeSet), 1) !=
|
||||
vr::VRInputError_None)
|
||||
return;
|
||||
|
||||
QQuickWindow* target = m_target ? m_target() : nullptr;
|
||||
const qint64 now = m_clock.elapsed();
|
||||
|
||||
for (Action& action : m_actions) {
|
||||
vr::InputDigitalActionData_t data{};
|
||||
if (vr::VRInput()->GetDigitalActionData(action.handle, &data, sizeof(data),
|
||||
vr::k_ulInvalidInputValueHandle) !=
|
||||
vr::VRInputError_None ||
|
||||
!data.bActive) {
|
||||
action.held = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.bState && !action.held) {
|
||||
action.held = true;
|
||||
action.nextRepeatMs = now + kRepeatDelayMs;
|
||||
if (target) sendKey(target, action);
|
||||
emit actionTriggered(QString::fromLatin1(action.path).section('/', -1),
|
||||
target != nullptr);
|
||||
} else if (data.bState && action.repeats && now >= action.nextRepeatMs) {
|
||||
action.nextRepeatMs = now + kRepeatIntervalMs;
|
||||
if (target) sendKey(target, action);
|
||||
} else if (!data.bState) {
|
||||
action.held = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ControllerNavigation::sendKey(QQuickWindow* window, const Action& action) {
|
||||
QKeyEvent press(QEvent::KeyPress, action.key, action.modifiers);
|
||||
QCoreApplication::sendEvent(window, &press);
|
||||
QKeyEvent release(QEvent::KeyRelease, action.key, action.modifiers);
|
||||
QCoreApplication::sendEvent(window, &release);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <array>
|
||||
#include <functional>
|
||||
|
||||
class QQuickWindow;
|
||||
|
||||
// Gamepad-style navigation through SteamVR Input (IVRInput): user-rebindable
|
||||
// actions from resources/input/actions.json are turned into key events for the
|
||||
// offscreen window.
|
||||
//
|
||||
// focus_next / focus_previous -> Tab / Backtab (Qt Quick's focus chain)
|
||||
// increase / decrease -> Right / Left (sliders, spin boxes, tab
|
||||
// bars …) activate -> Space (buttons, switches,
|
||||
// check boxes) back -> Escape
|
||||
//
|
||||
// Directional actions auto-repeat while held. Navigation is only fed to the
|
||||
// window the target provider returns (the active surface); none: ignored.
|
||||
class ControllerNavigation : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ControllerNavigation(QObject* parent = nullptr);
|
||||
|
||||
// manifestPath must be absolute. Returns false if SteamVR Input is
|
||||
// unavailable.
|
||||
bool initialize(const QString& manifestPath, QString* error);
|
||||
|
||||
// Which window gets the keys; nullptr: none (actions are ignored).
|
||||
void setTargetProvider(std::function<QQuickWindow*()> target) {
|
||||
m_target = std::move(target);
|
||||
}
|
||||
|
||||
signals:
|
||||
// An action fired; `forwarded` is false if there was no target window.
|
||||
void actionTriggered(const QString& action, bool forwarded);
|
||||
|
||||
private:
|
||||
struct Action {
|
||||
const char* path;
|
||||
int key;
|
||||
Qt::KeyboardModifiers modifiers;
|
||||
bool repeats;
|
||||
vr::VRActionHandle_t handle = vr::k_ulInvalidActionHandle;
|
||||
bool held = false;
|
||||
qint64 nextRepeatMs = 0;
|
||||
};
|
||||
|
||||
void poll();
|
||||
void sendKey(QQuickWindow* window, const Action& action);
|
||||
|
||||
QTimer m_pollTimer;
|
||||
QElapsedTimer m_clock;
|
||||
vr::VRActionSetHandle_t m_actionSet = vr::k_ulInvalidActionSetHandle;
|
||||
std::array<Action, 6> m_actions;
|
||||
std::function<QQuickWindow*()> m_target;
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
#include "OverlayInputBridge.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QInputMethodQueryEvent>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QQuickItem>
|
||||
#include <QQuickWindow>
|
||||
#include <QWheelEvent>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
Qt::MouseButton toQtButton(uint32_t vrButton) {
|
||||
switch (vrButton) {
|
||||
case vr::VRMouseButton_Left:
|
||||
return Qt::LeftButton;
|
||||
case vr::VRMouseButton_Right:
|
||||
return Qt::RightButton;
|
||||
case vr::VRMouseButton_Middle:
|
||||
return Qt::MiddleButton;
|
||||
default:
|
||||
return Qt::NoButton;
|
||||
}
|
||||
}
|
||||
|
||||
bool acceptsTextInput(QQuickItem* item) {
|
||||
if (!item || !(item->flags() & QQuickItem::ItemAcceptsInputMethod))
|
||||
return false;
|
||||
QInputMethodQueryEvent query(Qt::ImEnabled);
|
||||
QCoreApplication::sendEvent(item, &query);
|
||||
return query.value(Qt::ImEnabled).toBool();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
OverlayInputBridge::OverlayInputBridge(QQuickWindow* window, QSize textureSize,
|
||||
vr::VROverlayHandle_t overlay,
|
||||
QObject* parent)
|
||||
: QObject(parent),
|
||||
m_window(window),
|
||||
m_size(textureSize),
|
||||
m_overlay(overlay) {
|
||||
connect(m_window, &QQuickWindow::activeFocusItemChanged, this,
|
||||
&OverlayInputBridge::onActiveFocusItemChanged);
|
||||
}
|
||||
|
||||
bool OverlayInputBridge::handleEvent(const vr::VREvent_t& event) {
|
||||
switch (event.eventType) {
|
||||
case vr::VREvent_MouseMove:
|
||||
case vr::VREvent_MouseButtonDown:
|
||||
case vr::VREvent_MouseButtonUp: {
|
||||
// OpenVR mouse coordinates are in GL convention (origin
|
||||
// bottom-left) and scaled by SetOverlayMouseScale, which
|
||||
// DashboardOverlay sets to the texture size.
|
||||
m_cursor =
|
||||
QPointF(event.data.mouse.x, m_size.height() - event.data.mouse.y);
|
||||
if (event.eventType == vr::VREvent_MouseMove) {
|
||||
sendMouse(QEvent::MouseMove, Qt::NoButton);
|
||||
emit pointerMoved(m_cursor);
|
||||
} else {
|
||||
const Qt::MouseButton button = toQtButton(event.data.mouse.button);
|
||||
const bool down = event.eventType == vr::VREvent_MouseButtonDown;
|
||||
m_buttons.setFlag(button, down);
|
||||
sendMouse(down ? QEvent::MouseButtonPress : QEvent::MouseButtonRelease,
|
||||
button);
|
||||
emit inputEvent(QStringLiteral("mouse %1 button %2 at %3,%4")
|
||||
.arg(down ? "down" : "up")
|
||||
.arg(event.data.mouse.button)
|
||||
.arg(qRound(m_cursor.x()))
|
||||
.arg(qRound(m_cursor.y())),
|
||||
false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case vr::VREvent_ScrollSmooth:
|
||||
case vr::VREvent_ScrollDiscrete:
|
||||
sendScroll(event.data.scroll.xdelta, event.data.scroll.ydelta);
|
||||
emit inputEvent(
|
||||
QStringLiteral("scroll%1 dx=%2 dy=%3")
|
||||
.arg(event.eventType == vr::VREvent_ScrollSmooth ? ""
|
||||
: " (discrete)")
|
||||
.arg(event.data.scroll.xdelta, 0, 'f', 3)
|
||||
.arg(event.data.scroll.ydelta, 0, 'f', 3),
|
||||
true);
|
||||
return true;
|
||||
case vr::VREvent_FocusEnter: {
|
||||
QEnterEvent enter(m_cursor, m_cursor, m_cursor);
|
||||
QCoreApplication::sendEvent(m_window, &enter);
|
||||
emit focusEntered();
|
||||
emit inputEvent(QStringLiteral("laser entered overlay"), false);
|
||||
return true;
|
||||
}
|
||||
case vr::VREvent_FocusLeave: {
|
||||
// Release anything still held so drags don't get stuck when the
|
||||
// laser leaves.
|
||||
for (Qt::MouseButton button :
|
||||
{Qt::LeftButton, Qt::RightButton, Qt::MiddleButton}) {
|
||||
if (m_buttons.testFlag(button)) {
|
||||
m_buttons.setFlag(button, false);
|
||||
sendMouse(QEvent::MouseButtonRelease, button);
|
||||
}
|
||||
}
|
||||
QEvent leave(QEvent::Leave);
|
||||
QCoreApplication::sendEvent(m_window, &leave);
|
||||
emit inputEvent(QStringLiteral("laser left overlay"), false);
|
||||
return true;
|
||||
}
|
||||
case vr::VREvent_KeyboardCharInput:
|
||||
if (event.data.keyboard.overlayHandle != m_overlay) return false;
|
||||
sendKeyboardInput(event.data.keyboard.cNewInput);
|
||||
return true;
|
||||
case vr::VREvent_KeyboardDone:
|
||||
case vr::VREvent_KeyboardClosed:
|
||||
case vr::VREvent_KeyboardClosed_Global:
|
||||
if (event.data.keyboard.overlayHandle != m_overlay) return false;
|
||||
emit inputEvent(event.eventType == vr::VREvent_KeyboardDone
|
||||
? QStringLiteral("VR keyboard: done")
|
||||
: QStringLiteral("VR keyboard: closed"),
|
||||
false);
|
||||
// Keyboard dismissed by the user: end editing so focus state
|
||||
// matches what they see.
|
||||
if (m_keyboardItem) {
|
||||
m_keyboardItem.clear();
|
||||
m_window->contentItem()->forceActiveFocus(Qt::OtherFocusReason);
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void OverlayInputBridge::sendMouse(QEvent::Type type, Qt::MouseButton button) {
|
||||
QMouseEvent mouseEvent(type, m_cursor, m_cursor, button, m_buttons,
|
||||
Qt::NoModifier);
|
||||
QCoreApplication::sendEvent(m_window, &mouseEvent);
|
||||
}
|
||||
|
||||
void OverlayInputBridge::sendScroll(float xdelta, float ydelta) {
|
||||
// Accumulate sub-pixel deltas so slow thumbstick movement still
|
||||
// scrolls.
|
||||
m_scrollRemainder += QPointF(xdelta, ydelta) * m_scrollPixelsPerUnit;
|
||||
const QPoint pixels(int(m_scrollRemainder.x()), int(m_scrollRemainder.y()));
|
||||
if (pixels.isNull()) return;
|
||||
m_scrollRemainder -= pixels;
|
||||
|
||||
QWheelEvent wheel(m_cursor, m_cursor, pixels, pixels, m_buttons,
|
||||
Qt::NoModifier, Qt::NoScrollPhase, false);
|
||||
QCoreApplication::sendEvent(m_window, &wheel);
|
||||
}
|
||||
|
||||
void OverlayInputBridge::sendKeyboardInput(const char* utf8) {
|
||||
// In KeyboardFlag_Minimal mode SteamVR streams characters as they are
|
||||
// typed and encodes editing keys as control characters.
|
||||
const QString text = QString::fromUtf8(utf8, int(strnlen(utf8, 8)));
|
||||
for (const QChar ch : text) {
|
||||
int key = 0;
|
||||
QString keyText;
|
||||
switch (ch.unicode()) {
|
||||
case '\b':
|
||||
key = Qt::Key_Backspace;
|
||||
break;
|
||||
case '\n':
|
||||
case '\r':
|
||||
key = Qt::Key_Return;
|
||||
break;
|
||||
case '\t':
|
||||
key = Qt::Key_Tab;
|
||||
break;
|
||||
case 0x1b:
|
||||
key = Qt::Key_Escape;
|
||||
break;
|
||||
default:
|
||||
keyText = ch;
|
||||
break;
|
||||
}
|
||||
QKeyEvent press(QEvent::KeyPress, key, Qt::NoModifier, keyText);
|
||||
QCoreApplication::sendEvent(m_window, &press);
|
||||
emit inputEvent(keyText.isEmpty()
|
||||
? QStringLiteral("VR keyboard key 0x%1")
|
||||
.arg(int(ch.unicode()), 2, 16, QLatin1Char('0'))
|
||||
: QStringLiteral("VR keyboard '%1'").arg(keyText),
|
||||
false);
|
||||
QKeyEvent release(QEvent::KeyRelease, key, Qt::NoModifier, keyText);
|
||||
QCoreApplication::sendEvent(m_window, &release);
|
||||
}
|
||||
}
|
||||
|
||||
void OverlayInputBridge::onActiveFocusItemChanged() {
|
||||
QQuickItem* item = m_window->activeFocusItem();
|
||||
if (acceptsTextInput(item))
|
||||
showKeyboard(item);
|
||||
else if (m_keyboardItem)
|
||||
hideKeyboard();
|
||||
}
|
||||
|
||||
void OverlayInputBridge::showKeyboard(QQuickItem* item) {
|
||||
// No runtime: tests / snapshot
|
||||
if (item == m_keyboardItem || !vr::VROverlay()) return;
|
||||
|
||||
QInputMethodQueryEvent query(Qt::ImHints);
|
||||
QCoreApplication::sendEvent(item, &query);
|
||||
const auto hints = Qt::InputMethodHints(query.value(Qt::ImHints).toInt());
|
||||
|
||||
const auto mode = hints.testFlag(Qt::ImhHiddenText)
|
||||
? vr::k_EGamepadTextInputModePassword
|
||||
: vr::k_EGamepadTextInputModeNormal;
|
||||
const auto lineMode = hints.testFlag(Qt::ImhMultiLine)
|
||||
? vr::k_EGamepadTextInputLineModeMultipleLines
|
||||
: vr::k_EGamepadTextInputLineModeSingleLine;
|
||||
|
||||
const vr::EVROverlayError err = vr::VROverlay()->ShowKeyboardForOverlay(
|
||||
m_overlay, mode, lineMode,
|
||||
vr::KeyboardFlag_Minimal | vr::KeyboardFlag_Modal, "", 1024, "", 0);
|
||||
if (err == vr::VROverlayError_None) {
|
||||
m_keyboardItem = item;
|
||||
emit inputEvent(QStringLiteral("VR keyboard shown"), false);
|
||||
} else
|
||||
qWarning("ShowKeyboardForOverlay failed: %s",
|
||||
vr::VROverlay()->GetOverlayErrorNameFromEnum(err));
|
||||
}
|
||||
|
||||
void OverlayInputBridge::hideKeyboard() {
|
||||
m_keyboardItem.clear();
|
||||
if (vr::VROverlay()) vr::VROverlay()->HideKeyboard();
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <QEvent>
|
||||
#include <QObject>
|
||||
#include <QPointF>
|
||||
#include <QPointer>
|
||||
#include <QSize>
|
||||
|
||||
class QQuickItem;
|
||||
class QQuickWindow;
|
||||
|
||||
// Translates SteamVR overlay input into Qt events for an offscreen
|
||||
// QQuickWindow:
|
||||
// - laser pointer (VROverlayInputMethod_Mouse) -> QMouseEvent / hover / enter
|
||||
// / leave
|
||||
// - smooth thumbstick/trackpad scrolling -> QWheelEvent (pixel + angle
|
||||
// delta)
|
||||
// - SteamVR virtual keyboard -> QKeyEvent; shown
|
||||
// automatically when a
|
||||
// text input gets active
|
||||
// focus
|
||||
// QML stays unaware of VR: it only sees ordinary mouse, wheel and key events.
|
||||
class OverlayInputBridge : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
OverlayInputBridge(QQuickWindow* window, QSize textureSize,
|
||||
vr::VROverlayHandle_t overlay, QObject* parent = nullptr);
|
||||
|
||||
// Returns true if the event was an input event and has been consumed.
|
||||
bool handleEvent(const vr::VREvent_t& event);
|
||||
|
||||
// Scale applied to VREvent_ScrollSmooth deltas (SteamVR delivers small
|
||||
// fractions). Negative values invert the scroll direction.
|
||||
void setScrollPixelsPerUnit(qreal pixels) { m_scrollPixelsPerUnit = pixels; }
|
||||
|
||||
signals:
|
||||
// For diagnostics: laser position in window coordinates, and a short
|
||||
// description of every other input event. `coalesce` marks
|
||||
// high-frequency events (scrolling).
|
||||
void pointerMoved(QPointF position);
|
||||
// The laser entered the overlay.
|
||||
void focusEntered();
|
||||
void inputEvent(const QString& description, bool coalesce);
|
||||
|
||||
private:
|
||||
void sendMouse(QEvent::Type type, Qt::MouseButton button);
|
||||
void sendScroll(float xdelta, float ydelta);
|
||||
void sendKeyboardInput(const char* utf8);
|
||||
void onActiveFocusItemChanged();
|
||||
void showKeyboard(QQuickItem* item);
|
||||
void hideKeyboard();
|
||||
|
||||
QQuickWindow* m_window;
|
||||
QSize m_size;
|
||||
vr::VROverlayHandle_t m_overlay;
|
||||
QPointF m_cursor;
|
||||
Qt::MouseButtons m_buttons;
|
||||
QPointF m_scrollRemainder;
|
||||
qreal m_scrollPixelsPerUnit = 120.0;
|
||||
// Text item the VR keyboard currently feeds
|
||||
QPointer<QQuickItem> m_keyboardItem;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
#include "Dashboard.h"
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
Dashboard app(argc, argv);
|
||||
return app.exec();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A titled panel. Children go into the content column.
|
||||
Pane {
|
||||
id: card
|
||||
|
||||
property string title
|
||||
default property alias content: column.data
|
||||
|
||||
Material.elevation: 2
|
||||
Material.roundedScale: Material.MediumScale
|
||||
padding: 24
|
||||
|
||||
ColumnLayout {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
spacing: 16
|
||||
|
||||
Label {
|
||||
text: card.title
|
||||
font.pixelSize: 28
|
||||
font.weight: Font.DemiBold
|
||||
opacity: 0.85
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.Material
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Root of the dashboard. Its size is the overlay texture size (see AppConfig / CMake).
|
||||
// Everything here is ordinary Qt Quick: the C++ side turns VR laser, scroll, keyboard
|
||||
// and controller-navigation input into normal mouse/wheel/key events.
|
||||
Pane {
|
||||
id: root
|
||||
|
||||
Material.theme: Material.Dark
|
||||
Material.accent: Material.Blue
|
||||
padding: 32
|
||||
// Large controls: the overlay is read from ~1–2 m away and hit with a laser.
|
||||
font.pixelSize: 24
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 20
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: Qt.application.name
|
||||
font.pixelSize: 40
|
||||
font.weight: Font.Bold
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Label {
|
||||
id: clock
|
||||
font.pixelSize: 32
|
||||
opacity: 0.7
|
||||
text: Qt.formatTime(new Date(), "hh:mm")
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: clock.text = Qt.formatTime(new Date(), "hh:mm")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder until the real UI exists.
|
||||
Card {
|
||||
title: qsTr("Home Assistant")
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
Label {
|
||||
text: qsTr("Not connected yet.")
|
||||
opacity: 0.7
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
opacity: 0.6
|
||||
font.pixelSize: 20
|
||||
text: qsTr("Laser: point & pull trigger · Thumbstick: scroll · D-pad: move focus / adjust · A: activate · B: back")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "OffscreenQuickRenderer.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFocusEvent>
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickItem>
|
||||
#include <QQuickRenderControl>
|
||||
#include <QQuickWindow>
|
||||
|
||||
OffscreenQuickRenderer::OffscreenQuickRenderer(RenderBackend& backend,
|
||||
QSize size, QObject* parent)
|
||||
: QObject(parent), m_backend(backend), m_size(size) {
|
||||
m_renderTimer.setSingleShot(true);
|
||||
m_renderTimer.setTimerType(Qt::PreciseTimer);
|
||||
m_frameClock.start();
|
||||
setMaxFps(60);
|
||||
connect(&m_renderTimer, &QTimer::timeout, this,
|
||||
&OffscreenQuickRenderer::renderNow);
|
||||
}
|
||||
|
||||
OffscreenQuickRenderer::~OffscreenQuickRenderer() {
|
||||
// Tear down with the context current: the scene first, then everything
|
||||
// the backend created on Qt's device/context (QQuickRenderControl
|
||||
// destroys it), then window and control.
|
||||
m_backend.makeCurrent();
|
||||
m_rootItem.reset();
|
||||
if (m_window) m_backend.detachWindow();
|
||||
m_window.reset();
|
||||
m_renderControl.reset();
|
||||
}
|
||||
|
||||
bool OffscreenQuickRenderer::initialize(QQmlEngine& engine, const QString& uri,
|
||||
const QString& typeName,
|
||||
QString* error) {
|
||||
m_renderControl = std::make_unique<QQuickRenderControl>();
|
||||
m_window = std::make_unique<QQuickWindow>(m_renderControl.get());
|
||||
m_backend.configureWindow(m_window.get());
|
||||
m_window->setGeometry(0, 0, m_size.width(), m_size.height());
|
||||
m_window->setColor(Qt::transparent);
|
||||
|
||||
if (!m_backend.makeCurrent()) {
|
||||
*error = QStringLiteral("%1: makeCurrent failed").arg(m_backend.name());
|
||||
return false;
|
||||
}
|
||||
if (!m_renderControl->initialize()) {
|
||||
*error = QStringLiteral("QQuickRenderControl::initialize failed (%1)")
|
||||
.arg(m_backend.name());
|
||||
return false;
|
||||
}
|
||||
if (!m_backend.attachWindow(m_window.get(), error)) return false;
|
||||
const QQuickRenderTarget target = m_backend.createRenderTarget(m_size);
|
||||
if (target.isNull()) {
|
||||
*error = QStringLiteral("%1: could not create render target")
|
||||
.arg(m_backend.name());
|
||||
return false;
|
||||
}
|
||||
m_window->setRenderTarget(target);
|
||||
|
||||
QQmlComponent component(&engine);
|
||||
component.loadFromModule(uri, typeName);
|
||||
if (component.isError()) {
|
||||
*error = component.errorString();
|
||||
return false;
|
||||
}
|
||||
QObject* object = component.create();
|
||||
m_rootItem.reset(qobject_cast<QQuickItem*>(object));
|
||||
if (!m_rootItem) {
|
||||
delete object;
|
||||
*error = component.isError()
|
||||
? component.errorString()
|
||||
: QStringLiteral("Root QML object is not an Item");
|
||||
return false;
|
||||
}
|
||||
m_rootItem->setParentItem(m_window->contentItem());
|
||||
m_rootItem->setSize(m_size);
|
||||
|
||||
// An offscreen window never becomes the platform's focus window, so
|
||||
// tell Qt Quick explicitly that it has focus — otherwise
|
||||
// activeFocusItem stays null and key events (VR keyboard, controller
|
||||
// navigation) go nowhere.
|
||||
QFocusEvent focusIn(QEvent::FocusIn, Qt::ActiveWindowFocusReason);
|
||||
QCoreApplication::sendEvent(m_window.get(), &focusIn);
|
||||
|
||||
connect(m_renderControl.get(), &QQuickRenderControl::renderRequested, this,
|
||||
[this] { scheduleRender(); });
|
||||
connect(m_renderControl.get(), &QQuickRenderControl::sceneChanged, this,
|
||||
[this] {
|
||||
m_dirty = true;
|
||||
scheduleRender();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
void OffscreenQuickRenderer::setMaxFps(int fps) {
|
||||
m_frameIntervalNs = fps > 0 ? 1'000'000'000LL / fps : 0;
|
||||
}
|
||||
|
||||
void OffscreenQuickRenderer::setActive(bool active) {
|
||||
if (m_active == active) return;
|
||||
m_active = active;
|
||||
if (m_active) {
|
||||
m_dirty = true;
|
||||
scheduleRender();
|
||||
}
|
||||
}
|
||||
|
||||
void OffscreenQuickRenderer::scheduleRender() {
|
||||
if (!m_active || m_renderTimer.isActive()) return;
|
||||
// Frames follow an absolute schedule (see renderNow()), so neither the
|
||||
// frame's own duration nor QTimer's millisecond rounding adds up: e.g.
|
||||
// a 2 ms frame at a 60 fps cap still gets 60.
|
||||
const qint64 waitNs = m_nextFrameNs - m_frameClock.nsecsElapsed();
|
||||
m_renderTimer.start(int(qMax<qint64>(0, (waitNs + 500'000) / 1'000'000)));
|
||||
}
|
||||
|
||||
void OffscreenQuickRenderer::renderNow() {
|
||||
if (!m_renderControl || !m_backend.makeCurrent()) return;
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
// Next slot: one interval after this frame's slot; after idle time,
|
||||
// half an interval from now (so a burst of requests after a pause
|
||||
// doesn't render frames back to back).
|
||||
const qint64 nowNs = m_frameClock.nsecsElapsed();
|
||||
m_nextFrameNs =
|
||||
qMax(m_nextFrameNs + m_frameIntervalNs, nowNs + m_frameIntervalNs / 2);
|
||||
m_backend.beginFrame();
|
||||
m_renderControl->polishItems();
|
||||
m_renderControl->beginFrame();
|
||||
if (m_dirty) {
|
||||
m_renderControl->sync();
|
||||
m_dirty = false;
|
||||
}
|
||||
m_renderControl->render();
|
||||
m_renderControl->endFrame();
|
||||
m_backend.finishFrame();
|
||||
m_lastFrameTimeMs = timer.nsecsElapsed() / 1e6;
|
||||
|
||||
if (m_sink) m_sink(m_backend.vrTexture(), m_backend.vrTextureBounds());
|
||||
emit frameRendered();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include <QElapsedTimer>
|
||||
#include <QObject>
|
||||
#include <QSize>
|
||||
#include <QTimer>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
#include "RenderBackend.h"
|
||||
|
||||
class QQmlEngine;
|
||||
class QQuickItem;
|
||||
class QQuickRenderControl;
|
||||
class QQuickWindow;
|
||||
|
||||
// Renders a QML scene off-screen via QQuickRenderControl into the texture owned
|
||||
// by a RenderBackend. Graphics-API agnostic: it only talks to the RenderBackend
|
||||
// interface.
|
||||
//
|
||||
// Rendering is on demand (scene changes / animations) and rate-limited to
|
||||
// maxFps: frames start at most every 1/maxFps seconds, measured from the start
|
||||
// of the previous frame. Every finished frame is passed to the frame sink while
|
||||
// the backend's context is current, which is what IVROverlay::SetOverlayTexture
|
||||
// requires for OpenGL.
|
||||
class OffscreenQuickRenderer : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using FrameSink =
|
||||
std::function<void(const vr::Texture_t&, const vr::VRTextureBounds_t&)>;
|
||||
|
||||
OffscreenQuickRenderer(RenderBackend& backend, QSize size,
|
||||
QObject* parent = nullptr);
|
||||
~OffscreenQuickRenderer() override;
|
||||
|
||||
// Instantiates `typeName` from QML module `uri` as the root item.
|
||||
bool initialize(QQmlEngine& engine, const QString& uri,
|
||||
const QString& typeName, QString* error);
|
||||
|
||||
QQuickWindow* window() const { return m_window.get(); }
|
||||
QSize size() const { return m_size; }
|
||||
|
||||
void setFrameSink(FrameSink sink) { m_sink = std::move(sink); }
|
||||
void setMaxFps(int fps);
|
||||
|
||||
// While inactive (overlay not visible) no frames are rendered.
|
||||
void setActive(bool active);
|
||||
bool isActive() const { return m_active; }
|
||||
|
||||
// Renders one frame synchronously, regardless of the active state.
|
||||
void renderNow();
|
||||
|
||||
RenderBackend& backend() const { return m_backend; }
|
||||
|
||||
// Wall time of the last renderNow() incl. GPU wait (offscreen frames
|
||||
// are synchronous).
|
||||
double lastFrameTimeMs() const { return m_lastFrameTimeMs; }
|
||||
|
||||
signals:
|
||||
void frameRendered();
|
||||
|
||||
private:
|
||||
void scheduleRender();
|
||||
|
||||
RenderBackend& m_backend;
|
||||
QSize m_size;
|
||||
std::unique_ptr<QQuickRenderControl> m_renderControl;
|
||||
std::unique_ptr<QQuickWindow> m_window;
|
||||
std::unique_ptr<QQuickItem> m_rootItem;
|
||||
QTimer m_renderTimer;
|
||||
QElapsedTimer m_frameClock; // running since construction
|
||||
// Earliest start of the next frame (absolute schedule)
|
||||
qint64 m_nextFrameNs = 0;
|
||||
qint64 m_frameIntervalNs = 0;
|
||||
FrameSink m_sink;
|
||||
bool m_active = false;
|
||||
bool m_dirty = true;
|
||||
double m_lastFrameTimeMs = 0.0;
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
#include "OpenGLRenderBackend.h"
|
||||
|
||||
#include <QOpenGLFunctions>
|
||||
#include <QQuickGraphicsDevice>
|
||||
#include <QQuickWindow>
|
||||
#include <QSurfaceFormat>
|
||||
#include <cstdint>
|
||||
|
||||
OpenGLRenderBackend::~OpenGLRenderBackend() { releaseRenderTarget(); }
|
||||
|
||||
bool OpenGLRenderBackend::initialize(const RenderInterop&, QString* error) {
|
||||
QSurfaceFormat format = QSurfaceFormat::defaultFormat();
|
||||
format.setDepthBufferSize(24);
|
||||
format.setStencilBufferSize(8);
|
||||
|
||||
m_context = std::make_unique<QOpenGLContext>();
|
||||
m_context->setFormat(format);
|
||||
if (!m_context->create()) {
|
||||
if (error) *error = QStringLiteral("Failed to create OpenGL context");
|
||||
return false;
|
||||
}
|
||||
|
||||
m_surface = std::make_unique<QOffscreenSurface>();
|
||||
m_surface->setFormat(m_context->format());
|
||||
m_surface->create();
|
||||
if (!m_surface->isValid()) {
|
||||
if (error) *error = QStringLiteral("Failed to create offscreen surface");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void OpenGLRenderBackend::configureWindow(QQuickWindow* window) {
|
||||
window->setGraphicsDevice(
|
||||
QQuickGraphicsDevice::fromOpenGLContext(m_context.get()));
|
||||
}
|
||||
|
||||
bool OpenGLRenderBackend::attachWindow(QQuickWindow*, QString* error) {
|
||||
if (!makeCurrent()) {
|
||||
*error = QStringLiteral("makeCurrent failed");
|
||||
return false;
|
||||
}
|
||||
QOpenGLFunctions* f = m_context->functions();
|
||||
m_deviceName = QStringLiteral("%1 (%2)").arg(
|
||||
QString::fromLatin1(
|
||||
reinterpret_cast<const char*>(f->glGetString(GL_RENDERER))),
|
||||
QString::fromLatin1(
|
||||
reinterpret_cast<const char*>(f->glGetString(GL_VERSION))));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool OpenGLRenderBackend::makeCurrent() {
|
||||
return m_context && m_context->makeCurrent(m_surface.get());
|
||||
}
|
||||
|
||||
void OpenGLRenderBackend::doneCurrent() {
|
||||
if (m_context) m_context->doneCurrent();
|
||||
}
|
||||
|
||||
QQuickRenderTarget OpenGLRenderBackend::createRenderTarget(QSize size) {
|
||||
releaseRenderTarget();
|
||||
if (!makeCurrent()) return {};
|
||||
|
||||
QOpenGLFunctions* f = m_context->functions();
|
||||
GLuint texture = 0;
|
||||
f->glGenTextures(1, &texture);
|
||||
f->glBindTexture(GL_TEXTURE_2D, texture);
|
||||
f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
f->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
f->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size.width(), size.height(), 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
f->glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
m_texture = texture;
|
||||
m_size = size;
|
||||
return QQuickRenderTarget::fromOpenGLTexture(m_texture, size);
|
||||
}
|
||||
|
||||
void OpenGLRenderBackend::releaseRenderTarget() {
|
||||
if (m_texture && makeCurrent()) {
|
||||
GLuint texture = m_texture;
|
||||
m_context->functions()->glDeleteTextures(1, &texture);
|
||||
}
|
||||
m_texture = 0;
|
||||
m_size = {};
|
||||
}
|
||||
|
||||
void OpenGLRenderBackend::finishFrame() {
|
||||
// SteamVR reads the texture from another process; make sure all
|
||||
// rendering commands have been submitted before the handle is passed
|
||||
// on.
|
||||
m_context->functions()->glFlush();
|
||||
}
|
||||
|
||||
void OpenGLRenderBackend::waitForGpu() {
|
||||
if (makeCurrent()) m_context->functions()->glFinish();
|
||||
}
|
||||
|
||||
vr::Texture_t OpenGLRenderBackend::vrTexture() {
|
||||
// For TextureType_OpenGL the handle *is* the GL texture name, cast to a
|
||||
// pointer.
|
||||
return vr::Texture_t{
|
||||
reinterpret_cast<void*>(static_cast<uintptr_t>(m_texture)),
|
||||
vr::TextureType_OpenGL, vr::ColorSpace_Auto};
|
||||
}
|
||||
|
||||
vr::VRTextureBounds_t OpenGLRenderBackend::vrTextureBounds() const {
|
||||
// Qt Quick renders into GL textures bottom-up (GL convention) and SteamVR
|
||||
// treats TextureType_OpenGL accordingly: no flip needed (verified on the
|
||||
// Steam Frame).
|
||||
return vr::VRTextureBounds_t{0.f, 0.f, 1.f, 1.f};
|
||||
}
|
||||
|
||||
QImage OpenGLRenderBackend::readback() {
|
||||
if (!m_texture || !makeCurrent()) return {};
|
||||
|
||||
QOpenGLFunctions* f = m_context->functions();
|
||||
GLuint fbo = 0;
|
||||
f->glGenFramebuffers(1, &fbo);
|
||||
f->glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
||||
f->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||
m_texture, 0);
|
||||
|
||||
QImage image(m_size, QImage::Format_RGBA8888_Premultiplied);
|
||||
f->glReadPixels(0, 0, m_size.width(), m_size.height(), GL_RGBA,
|
||||
GL_UNSIGNED_BYTE, image.bits());
|
||||
|
||||
f->glBindFramebuffer(GL_FRAMEBUFFER, m_context->defaultFramebufferObject());
|
||||
f->glDeleteFramebuffers(1, &fbo);
|
||||
// GL rows are bottom-up.
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
|
||||
return image.flipped(Qt::Vertical);
|
||||
#else
|
||||
return image.mirrored(false, true);
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <QOffscreenSurface>
|
||||
#include <QOpenGLContext>
|
||||
|
||||
#include "RenderBackend.h"
|
||||
|
||||
class OpenGLRenderBackend final : public RenderBackend {
|
||||
public:
|
||||
OpenGLRenderBackend() = default;
|
||||
~OpenGLRenderBackend() override;
|
||||
|
||||
QString name() const override { return QStringLiteral("OpenGL"); }
|
||||
QSGRendererInterface::GraphicsApi graphicsApi() const override {
|
||||
return QSGRendererInterface::OpenGL;
|
||||
}
|
||||
|
||||
bool initialize(const RenderInterop& interop, QString* error) override;
|
||||
void configureWindow(QQuickWindow* window) override;
|
||||
bool attachWindow(QQuickWindow* window, QString* error) override;
|
||||
void detachWindow() override { releaseRenderTarget(); }
|
||||
QString deviceName() const override { return m_deviceName; }
|
||||
|
||||
bool makeCurrent() override;
|
||||
void doneCurrent() override;
|
||||
|
||||
QQuickRenderTarget createRenderTarget(QSize size) override;
|
||||
void releaseRenderTarget() override;
|
||||
void beginFrame() override {}
|
||||
void finishFrame() override;
|
||||
void waitForGpu() override;
|
||||
|
||||
vr::Texture_t vrTexture() override;
|
||||
vr::VRTextureBounds_t vrTextureBounds() const override;
|
||||
QImage readback() override;
|
||||
|
||||
private:
|
||||
std::unique_ptr<QOpenGLContext> m_context;
|
||||
std::unique_ptr<QOffscreenSurface> m_surface;
|
||||
unsigned int m_texture = 0; // GLuint
|
||||
QSize m_size;
|
||||
QString m_deviceName;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "RenderBackend.h"
|
||||
|
||||
#include "OpenGLRenderBackend.h"
|
||||
#if VRDASH_HAS_VULKAN
|
||||
#include "VulkanRenderBackend.h"
|
||||
#endif
|
||||
|
||||
std::unique_ptr<RenderBackend> createRenderBackend(GraphicsApi api) {
|
||||
switch (api) {
|
||||
case GraphicsApi::OpenGL:
|
||||
return std::make_unique<OpenGLRenderBackend>();
|
||||
case GraphicsApi::Vulkan:
|
||||
#if VRDASH_HAS_VULKAN
|
||||
return std::make_unique<VulkanRenderBackend>();
|
||||
#else
|
||||
return nullptr; // built without Vulkan support
|
||||
#endif
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool parseGraphicsApi(const QString& name, GraphicsApi* api) {
|
||||
const QString lower = name.toLower();
|
||||
if (lower == QLatin1String("opengl") || lower == QLatin1String("gl"))
|
||||
*api = GraphicsApi::OpenGL;
|
||||
else if (lower == QLatin1String("vulkan") || lower == QLatin1String("vk"))
|
||||
*api = GraphicsApi::Vulkan;
|
||||
else
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <QByteArrayList>
|
||||
#include <QImage>
|
||||
#include <QQuickRenderTarget>
|
||||
#include <QSGRendererInterface>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class QQuickWindow;
|
||||
|
||||
// Requirements the VR runtime imposes on the graphics device. All optional:
|
||||
// without them (snapshot mode, tests) backends pick defaults.
|
||||
struct RenderInterop {
|
||||
std::function<QByteArrayList()> vulkanInstanceExtensions;
|
||||
std::function<QByteArrayList(VkPhysicalDevice_T*)> vulkanDeviceExtensions;
|
||||
// The physical device driving the HMD, for a given VkInstance.
|
||||
std::function<VkPhysicalDevice_T*(VkInstance_T*)> vulkanOutputDevice;
|
||||
};
|
||||
|
||||
// Abstraction over the graphics API used to render the Qt Quick scene into a
|
||||
// texture that SteamVR can consume. Everything API-specific (context/device
|
||||
// setup, texture allocation, layout transitions, handing the texture to OpenVR,
|
||||
// CPU readback) lives behind this interface; OffscreenQuickRenderer and the
|
||||
// VR/input code stay API-agnostic.
|
||||
//
|
||||
// Call order (driven by OffscreenQuickRenderer):
|
||||
// initialize → configureWindow → [QQuickRenderControl::initialize] →
|
||||
// attachWindow → createRenderTarget → per frame: beginFrame … [Qt renders] …
|
||||
// finishFrame → vrTexture → releaseRenderTarget → detachWindow → [Qt destroys
|
||||
// its device/context]
|
||||
class RenderBackend {
|
||||
public:
|
||||
virtual ~RenderBackend() = default;
|
||||
|
||||
virtual QString name() const = 0;
|
||||
|
||||
// Must be applied via QQuickWindow::setGraphicsApi() before any
|
||||
// QQuickWindow exists.
|
||||
virtual QSGRendererInterface::GraphicsApi graphicsApi() const = 0;
|
||||
|
||||
// Creates API-level objects (context / instance). Requires a
|
||||
// QGuiApplication.
|
||||
virtual bool initialize(const RenderInterop& interop, QString* error) = 0;
|
||||
|
||||
// Tells Qt Quick which device/context/instance to use. Before
|
||||
// QQuickRenderControl::initialize().
|
||||
virtual void configureWindow(QQuickWindow* window) = 0;
|
||||
|
||||
// Picks up objects Qt created during QQuickRenderControl::initialize()
|
||||
// (e.g. VkDevice).
|
||||
virtual bool attachWindow(QQuickWindow* window, QString* error) = 0;
|
||||
// Releases everything created on Qt's device; called before Qt destroys
|
||||
// it.
|
||||
virtual void detachWindow() = 0;
|
||||
|
||||
// Human-readable GPU/driver description (valid after attachWindow).
|
||||
virtual QString deviceName() const = 0;
|
||||
|
||||
// Bind the API context to the current thread (no-op for APIs without
|
||||
// one).
|
||||
virtual bool makeCurrent() = 0;
|
||||
virtual void doneCurrent() = 0;
|
||||
|
||||
// (Re)allocates the shared color texture; returns the target for
|
||||
// QQuickWindow::setRenderTarget.
|
||||
virtual QQuickRenderTarget createRenderTarget(QSize size) = 0;
|
||||
// Frees the texture. Called before Qt's device/context goes away.
|
||||
virtual void releaseRenderTarget() = 0;
|
||||
|
||||
// Before Qt renders a frame / after QQuickRenderControl::endFrame().
|
||||
// Backends put the texture into the state Qt resp. SteamVR expects
|
||||
// (layouts, flushes).
|
||||
virtual void beginFrame() = 0;
|
||||
virtual void finishFrame() = 0;
|
||||
|
||||
// Blocks until the GPU has finished all submitted work (benchmarks:
|
||||
// makes the asynchronous OpenGL path comparable to the synchronous
|
||||
// Vulkan one).
|
||||
virtual void waitForGpu() = 0;
|
||||
|
||||
// Texture description for IVROverlay::SetOverlayTexture. Only valid
|
||||
// while the render target exists; must be submitted with the context
|
||||
// current (see makeCurrent()).
|
||||
virtual vr::Texture_t vrTexture() = 0;
|
||||
|
||||
// UV bounds for IVROverlay::SetOverlayTextureBounds (compensates
|
||||
// API-specific origin).
|
||||
virtual vr::VRTextureBounds_t vrTextureBounds() const = 0;
|
||||
|
||||
// Reads the last finished frame back to the CPU (snapshots, tests).
|
||||
// Slow.
|
||||
virtual QImage readback() = 0;
|
||||
};
|
||||
|
||||
enum class GraphicsApi { OpenGL, Vulkan };
|
||||
|
||||
// Returns nullptr if the API was not compiled in (Vulkan is optional, see
|
||||
// VRDASH_VULKAN).
|
||||
std::unique_ptr<RenderBackend> createRenderBackend(GraphicsApi api);
|
||||
|
||||
// "opengl" / "vulkan" (case-insensitive). Returns false for unknown names.
|
||||
bool parseGraphicsApi(const QString& name, GraphicsApi* api);
|
||||
@@ -0,0 +1,383 @@
|
||||
#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("VRDASH_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, ®ion);
|
||||
},
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include <QVulkanInstance>
|
||||
|
||||
#include "RenderBackend.h"
|
||||
|
||||
// Vulkan implementation. Qt Quick creates the VkDevice (on the physical device
|
||||
// SteamVR reports for the HMD, with the device extensions SteamVR requires);
|
||||
// this backend owns the color image Qt renders into and handles the layout
|
||||
// dance SteamVR needs:
|
||||
//
|
||||
// Qt renders -> image in COLOR_ATTACHMENT_OPTIMAL
|
||||
// finishFrame() -> transition to TRANSFER_SRC_OPTIMAL (required by
|
||||
// OpenVR);
|
||||
// the CPU waits for it, the texture is handed over
|
||||
// right after
|
||||
// SetOverlayTexture -> SteamVR copies from the image using our queue
|
||||
// beginFrame() (next) -> transition back to COLOR_ATTACHMENT_OPTIMAL, the
|
||||
// layout Qt's
|
||||
// resource tracking expects; no CPU wait: queue order
|
||||
// puts it before Qt's frame
|
||||
//
|
||||
// Everything runs on the GUI thread; QQuickRenderControl::endFrame() waits for
|
||||
// the GPU (offscreen frames are synchronous).
|
||||
class VulkanRenderBackend final : public RenderBackend {
|
||||
public:
|
||||
VulkanRenderBackend() = default;
|
||||
~VulkanRenderBackend() override;
|
||||
|
||||
QString name() const override { return QStringLiteral("Vulkan"); }
|
||||
QSGRendererInterface::GraphicsApi graphicsApi() const override {
|
||||
return QSGRendererInterface::Vulkan;
|
||||
}
|
||||
|
||||
bool initialize(const RenderInterop& interop, QString* error) override;
|
||||
void configureWindow(QQuickWindow* window) override;
|
||||
bool attachWindow(QQuickWindow* window, QString* error) override;
|
||||
void detachWindow() override;
|
||||
QString deviceName() const override { return m_deviceName; }
|
||||
|
||||
bool makeCurrent() override { return true; }
|
||||
void doneCurrent() override {}
|
||||
|
||||
QQuickRenderTarget createRenderTarget(QSize size) override;
|
||||
void releaseRenderTarget() override;
|
||||
void beginFrame() override;
|
||||
void finishFrame() override;
|
||||
void waitForGpu() override;
|
||||
|
||||
vr::Texture_t vrTexture() override;
|
||||
vr::VRTextureBounds_t vrTextureBounds() const override;
|
||||
QImage readback() override;
|
||||
|
||||
private:
|
||||
bool findMemoryType(uint32_t typeBits, VkMemoryPropertyFlags properties,
|
||||
uint32_t* index) const;
|
||||
// A command buffer with its fence, for small submissions of our own.
|
||||
struct Submission {
|
||||
VkCommandBuffer commandBuffer = VK_NULL_HANDLE;
|
||||
VkFence fence = VK_NULL_HANDLE;
|
||||
bool pending = false;
|
||||
};
|
||||
// Records `record` into the submission's command buffer (after its
|
||||
// previous use has finished) and submits it; with `wait`, blocks until
|
||||
// the GPU has executed it.
|
||||
bool submit(Submission& submission,
|
||||
const std::function<void(VkCommandBuffer)>& record, bool wait);
|
||||
bool waitFor(Submission& submission);
|
||||
void transitionImage(Submission& submission, VkImageLayout from,
|
||||
VkImageLayout to, bool wait);
|
||||
|
||||
QVulkanInstance m_instance;
|
||||
QByteArrayList m_deviceExtensions;
|
||||
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
|
||||
VkDevice m_device = VK_NULL_HANDLE;
|
||||
VkQueue m_queue = VK_NULL_HANDLE;
|
||||
uint32_t m_queueFamilyIndex = 0;
|
||||
QVulkanDeviceFunctions* m_df = nullptr;
|
||||
|
||||
VkCommandPool m_commandPool = VK_NULL_HANDLE;
|
||||
Submission m_restore; // beginFrame(): async
|
||||
Submission m_handoff; // finishFrame() and readback(): synchronous
|
||||
|
||||
VkImage m_image = VK_NULL_HANDLE;
|
||||
VkDeviceMemory m_imageMemory = VK_NULL_HANDLE;
|
||||
// Actual layout outside of Qt's frame
|
||||
VkImageLayout m_layout = VK_IMAGE_LAYOUT_UNDEFINED;
|
||||
QSize m_size;
|
||||
|
||||
vr::VRVulkanTextureData_t m_vrTextureData{};
|
||||
QString m_deviceName;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "vr/DashboardOverlay.h"
|
||||
|
||||
DashboardOverlay::DashboardOverlay(VrRuntime& runtime, QObject* parent)
|
||||
: Overlay(runtime, parent) {}
|
||||
|
||||
DashboardOverlay::~DashboardOverlay() { destroy(); }
|
||||
|
||||
void DashboardOverlay::destroyExtras() {
|
||||
// Explicitly: OpenVR doesn't document whether destroying the main overlay
|
||||
// takes the thumbnail with it.
|
||||
if (m_thumbnail != vr::k_ulOverlayHandleInvalid)
|
||||
vr::VROverlay()->DestroyOverlay(m_thumbnail);
|
||||
m_thumbnail = vr::k_ulOverlayHandleInvalid;
|
||||
}
|
||||
|
||||
bool DashboardOverlay::create(const QString& key, const QString& name,
|
||||
const QImage& thumbnail, QQuickWindow* window,
|
||||
QSize textureSize, QString* error) {
|
||||
vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid;
|
||||
const vr::EVROverlayError err = vr::VROverlay()->CreateDashboardOverlay(
|
||||
key.toUtf8().constData(), name.toUtf8().constData(), &handle,
|
||||
&m_thumbnail);
|
||||
if (err != vr::VROverlayError_None) {
|
||||
*error = QStringLiteral("CreateDashboardOverlay(%1): %2")
|
||||
.arg(key, errorName(err));
|
||||
if (err == vr::VROverlayError_KeyInUse)
|
||||
*error += QStringLiteral(
|
||||
" - already running? (Use --app-key for a second instance.)");
|
||||
return false;
|
||||
}
|
||||
setUp(handle, window, textureSize);
|
||||
// The dashboard sizes its tabs itself; this only matters once floated.
|
||||
vr::VROverlay()->SetOverlayWidthInMeters(handle, 1.6f);
|
||||
|
||||
if (!thumbnail.isNull()) {
|
||||
QImage rgba = thumbnail.convertToFormat(QImage::Format_RGBA8888);
|
||||
vr::VROverlay()->SetOverlayRaw(m_thumbnail, rgba.bits(),
|
||||
uint32_t(rgba.width()),
|
||||
uint32_t(rgba.height()), 4);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <QImage>
|
||||
#include <QString>
|
||||
|
||||
#include "vr/Overlay.h"
|
||||
|
||||
// A SteamVR dashboard tab (CreateDashboardOverlay) with its thumbnail icon.
|
||||
//
|
||||
// The user can float/pin it from the SteamVR dashboard (SteamVR then owns its
|
||||
// placement and keeps it visible in games), so rendering follows the
|
||||
// overlay's own visibility (VREvent_OverlayShown/Hidden), NOT whether the
|
||||
// dashboard is open.
|
||||
class DashboardOverlay : public Overlay {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DashboardOverlay(VrRuntime& runtime, QObject* parent = nullptr);
|
||||
~DashboardOverlay() override;
|
||||
|
||||
// `key` must be unique within SteamVR; `window` receives the input.
|
||||
bool create(const QString& key, const QString& name, const QImage& thumbnail,
|
||||
QQuickWindow* window, QSize textureSize, QString* error);
|
||||
|
||||
protected:
|
||||
void destroyExtras() override;
|
||||
|
||||
private:
|
||||
vr::VROverlayHandle_t m_thumbnail = vr::k_ulOverlayHandleInvalid;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "vr/Overlay.h"
|
||||
|
||||
#include "input/OverlayInputBridge.h"
|
||||
#include "vr/VrRuntime.h"
|
||||
|
||||
Overlay::Overlay(VrRuntime& runtime, QObject* parent)
|
||||
: QObject(parent), m_runtime(runtime) {}
|
||||
|
||||
Overlay::~Overlay() {
|
||||
// Subclasses call destroy() in their destructors (destroyExtras() is
|
||||
// virtual); this is the safety net for the main overlay.
|
||||
destroy();
|
||||
}
|
||||
|
||||
QString Overlay::errorName(vr::EVROverlayError err) {
|
||||
return QString::fromUtf8(vr::VROverlay()->GetOverlayErrorNameFromEnum(err));
|
||||
}
|
||||
|
||||
void Overlay::setUp(vr::VROverlayHandle_t handle, QQuickWindow* window,
|
||||
QSize textureSize) {
|
||||
m_handle = handle;
|
||||
vr::IVROverlay* overlay = vr::VROverlay();
|
||||
const vr::HmdVector2_t mouseScale{
|
||||
{float(textureSize.width()), float(textureSize.height())}};
|
||||
overlay->SetOverlayMouseScale(m_handle, &mouseScale);
|
||||
overlay->SetOverlayInputMethod(m_handle, vr::VROverlayInputMethod_Mouse);
|
||||
overlay->SetOverlayFlag(m_handle, vr::VROverlayFlags_SendVRSmoothScrollEvents,
|
||||
true);
|
||||
overlay->SetOverlayFlag(m_handle, vr::VROverlayFlags_EnableClickStabilization,
|
||||
true);
|
||||
|
||||
m_input = std::make_unique<OverlayInputBridge>(window, textureSize, m_handle);
|
||||
connect(m_input.get(), &OverlayInputBridge::focusEntered, this,
|
||||
&Overlay::pointerEntered);
|
||||
m_runtime.registerOverlay(
|
||||
m_handle, [this](const vr::VREvent_t& event) { handleEvent(event); });
|
||||
}
|
||||
|
||||
void Overlay::destroy() {
|
||||
if (m_handle == vr::k_ulOverlayHandleInvalid) return;
|
||||
m_runtime.unregisterOverlay(m_handle);
|
||||
// After VR_Shutdown there is no IVROverlay any more (nothing to destroy).
|
||||
if (vr::VROverlay()) {
|
||||
destroyExtras();
|
||||
vr::VROverlay()->DestroyOverlay(m_handle);
|
||||
}
|
||||
m_handle = vr::k_ulOverlayHandleInvalid;
|
||||
}
|
||||
|
||||
bool Overlay::isVisible() const {
|
||||
return m_handle != vr::k_ulOverlayHandleInvalid &&
|
||||
vr::VROverlay()->IsOverlayVisible(m_handle);
|
||||
}
|
||||
|
||||
void Overlay::submitFrame(const vr::Texture_t& texture,
|
||||
const vr::VRTextureBounds_t& bounds) {
|
||||
if (m_handle == vr::k_ulOverlayHandleInvalid) return;
|
||||
vr::VROverlay()->SetOverlayTextureBounds(m_handle, &bounds);
|
||||
vr::Texture_t tex = texture;
|
||||
const vr::EVROverlayError err =
|
||||
vr::VROverlay()->SetOverlayTexture(m_handle, &tex);
|
||||
if (err != vr::VROverlayError_None)
|
||||
qWarning("SetOverlayTexture failed: %s", qPrintable(errorName(err)));
|
||||
}
|
||||
|
||||
void Overlay::handleEvent(const vr::VREvent_t& event) {
|
||||
switch (event.eventType) {
|
||||
case vr::VREvent_OverlayShown:
|
||||
emit visibilityChanged(true);
|
||||
return;
|
||||
case vr::VREvent_OverlayHidden:
|
||||
emit visibilityChanged(false);
|
||||
return;
|
||||
default:
|
||||
m_input->handleEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <QObject>
|
||||
#include <QSize>
|
||||
#include <memory>
|
||||
|
||||
class OverlayInputBridge;
|
||||
class QQuickWindow;
|
||||
class VrRuntime;
|
||||
|
||||
// Base of the SteamVR overlays a surface can show: creation is up to the
|
||||
// subclass (DashboardOverlay; later e.g. a world overlay), everything else is
|
||||
// shared: laser/scroll/keyboard input into the surface's window, visibility
|
||||
// events and frame submission.
|
||||
class Overlay : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
~Overlay() override;
|
||||
|
||||
vr::VROverlayHandle_t handle() const { return m_handle; }
|
||||
OverlayInputBridge* inputBridge() const { return m_input.get(); }
|
||||
bool isVisible() const;
|
||||
|
||||
void submitFrame(const vr::Texture_t& texture,
|
||||
const vr::VRTextureBounds_t& bounds);
|
||||
|
||||
signals:
|
||||
void visibilityChanged(bool visible);
|
||||
// The laser entered the overlay.
|
||||
void pointerEntered();
|
||||
|
||||
protected:
|
||||
explicit Overlay(VrRuntime& runtime, QObject* parent = nullptr);
|
||||
|
||||
// For subclasses, once they created the overlay as `handle`: mouse input
|
||||
// scaled to `textureSize`, smooth scrolling, event routing to `window`.
|
||||
void setUp(vr::VROverlayHandle_t handle, QQuickWindow* window,
|
||||
QSize textureSize);
|
||||
// Subclasses destroy extra overlays (e.g. thumbnails) here; called while
|
||||
// the runtime is still up, before the main overlay is destroyed.
|
||||
virtual void destroyExtras() {}
|
||||
void destroy();
|
||||
|
||||
static QString errorName(vr::EVROverlayError err);
|
||||
|
||||
private:
|
||||
void handleEvent(const vr::VREvent_t& event);
|
||||
|
||||
VrRuntime& m_runtime;
|
||||
vr::VROverlayHandle_t m_handle = vr::k_ulOverlayHandleInvalid;
|
||||
std::unique_ptr<OverlayInputBridge> m_input;
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
#include "VrRuntime.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace {
|
||||
|
||||
// OpenVR returns extension lists as one space-separated string.
|
||||
template <typename Query>
|
||||
QByteArrayList queryExtensionList(Query query) {
|
||||
const uint32_t size = query(nullptr, 0);
|
||||
if (size == 0) return {};
|
||||
QByteArray buffer(int(size), '\0');
|
||||
query(buffer.data(), size);
|
||||
QByteArrayList result;
|
||||
for (const QByteArray& ext : buffer.left(buffer.indexOf('\0')).split(' ')) {
|
||||
if (!ext.isEmpty()) result.append(ext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VrRuntime::VrRuntime(QObject* parent) : QObject(parent) {
|
||||
// Events are cheap to poll; 90 Hz keeps laser hover/drag responsive.
|
||||
m_pollTimer.setInterval(11);
|
||||
m_pollTimer.setTimerType(Qt::PreciseTimer);
|
||||
connect(&m_pollTimer, &QTimer::timeout, this, &VrRuntime::pollEvents);
|
||||
}
|
||||
|
||||
VrRuntime::~VrRuntime() { shutdown(); }
|
||||
|
||||
bool VrRuntime::initialize(QString* error) {
|
||||
vr::EVRInitError initError = vr::VRInitError_None;
|
||||
vr::VR_Init(&initError, vr::VRApplication_Overlay);
|
||||
if (initError != vr::VRInitError_None) {
|
||||
const char* reason = vr::VR_GetVRInitErrorAsEnglishDescription(initError);
|
||||
*error =
|
||||
QStringLiteral("VR_Init failed: %1").arg(QString::fromUtf8(reason));
|
||||
return false;
|
||||
}
|
||||
if (!vr::VROverlay()) {
|
||||
*error = QStringLiteral("IVROverlay interface unavailable");
|
||||
vr::VR_Shutdown();
|
||||
return false;
|
||||
}
|
||||
m_initialized = true;
|
||||
m_pollTimer.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrRuntime::shutdown() {
|
||||
if (!m_initialized) return;
|
||||
m_pollTimer.stop();
|
||||
m_overlays.clear();
|
||||
vr::VR_Shutdown();
|
||||
m_initialized = false;
|
||||
}
|
||||
|
||||
void VrRuntime::registerOverlay(vr::VROverlayHandle_t handle,
|
||||
OverlayEventHandler handler) {
|
||||
m_overlays.insert(handle, std::move(handler));
|
||||
}
|
||||
|
||||
void VrRuntime::unregisterOverlay(vr::VROverlayHandle_t handle) {
|
||||
m_overlays.remove(handle);
|
||||
}
|
||||
|
||||
void VrRuntime::pollEvents() {
|
||||
vr::VREvent_t event{};
|
||||
|
||||
while (vr::VRSystem()->PollNextEvent(&event, sizeof(event))) {
|
||||
switch (event.eventType) {
|
||||
case vr::VREvent_Quit:
|
||||
// SteamVR is shutting down; overlays must exit promptly
|
||||
// or they get killed.
|
||||
vr::VRSystem()->AcknowledgeQuit_Exiting();
|
||||
emit quitRequested();
|
||||
break;
|
||||
case vr::VREvent_DashboardActivated:
|
||||
emit dashboardActivated();
|
||||
break;
|
||||
case vr::VREvent_DashboardDeactivated:
|
||||
emit dashboardDeactivated();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
emit systemEvent(event);
|
||||
}
|
||||
|
||||
// Copy: a handler may (un)register overlays.
|
||||
const auto overlays = m_overlays;
|
||||
for (auto it = overlays.cbegin(); it != overlays.cend(); ++it) {
|
||||
while (
|
||||
vr::VROverlay()->PollNextOverlayEvent(it.key(), &event, sizeof(event)))
|
||||
it.value()(event);
|
||||
}
|
||||
}
|
||||
|
||||
RenderInterop VrRuntime::renderInterop() const {
|
||||
RenderInterop interop;
|
||||
interop.vulkanInstanceExtensions = [] {
|
||||
vr::IVRCompositor* compositor = vr::VRCompositor();
|
||||
if (!compositor) {
|
||||
qWarning(
|
||||
"IVRCompositor unavailable: cannot query required Vulkan instance "
|
||||
"extensions");
|
||||
return QByteArrayList();
|
||||
}
|
||||
return queryExtensionList([&](char* buf, uint32_t size) {
|
||||
return compositor->GetVulkanInstanceExtensionsRequired(buf, size);
|
||||
});
|
||||
};
|
||||
interop.vulkanDeviceExtensions = [](VkPhysicalDevice_T* device) {
|
||||
vr::IVRCompositor* compositor = vr::VRCompositor();
|
||||
if (!compositor) return QByteArrayList();
|
||||
return queryExtensionList([&](char* buf, uint32_t size) {
|
||||
return compositor->GetVulkanDeviceExtensionsRequired(device, buf, size);
|
||||
});
|
||||
};
|
||||
interop.vulkanOutputDevice = [](VkInstance_T* instance) {
|
||||
uint64_t device = 0;
|
||||
vr::VRSystem()->GetOutputDevice(&device, vr::TextureType_Vulkan, instance);
|
||||
return reinterpret_cast<VkPhysicalDevice_T*>(device);
|
||||
};
|
||||
return interop;
|
||||
}
|
||||
|
||||
bool VrRuntime::installManifest(const QString& manifestPath,
|
||||
QStringList* appKeys, QString* error) {
|
||||
QFile file(manifestPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
*error = QStringLiteral("Cannot read %1").arg(manifestPath);
|
||||
return false;
|
||||
}
|
||||
appKeys->clear();
|
||||
const QJsonArray applications = QJsonDocument::fromJson(file.readAll())
|
||||
.object()
|
||||
.value("applications")
|
||||
.toArray();
|
||||
for (const QJsonValue& app : applications)
|
||||
appKeys->append(app.toObject().value("app_key").toString());
|
||||
|
||||
vr::IVRApplications* apps = vr::VRApplications();
|
||||
const QByteArray path = manifestPath.toUtf8();
|
||||
auto errorName = [&](vr::EVRApplicationError err) {
|
||||
return QString::fromUtf8(apps->GetApplicationsErrorNameFromEnum(err));
|
||||
};
|
||||
// Re-register (path or content may have changed)
|
||||
apps->RemoveApplicationManifest(path.constData());
|
||||
|
||||
vr::EVRApplicationError err =
|
||||
apps->AddApplicationManifest(path.constData(), false);
|
||||
if (err != vr::VRApplicationError_None) {
|
||||
*error = QStringLiteral("AddApplicationManifest(%1): %2")
|
||||
.arg(manifestPath, errorName(err));
|
||||
return false;
|
||||
}
|
||||
qInfo().noquote() << "Added manifest" << manifestPath;
|
||||
// SteamVR silently skips entries it can't use (e.g. no binary path for this
|
||||
// platform); report each key's state so such problems are visible.
|
||||
for (const QString& key : std::as_const(*appKeys)) {
|
||||
const QByteArray k = key.toUtf8();
|
||||
const bool known = apps->IsApplicationInstalled(k.constData());
|
||||
qInfo().noquote() << " " << key
|
||||
<< (known ? "known to SteamVR" : "NOT known to SteamVR");
|
||||
err = apps->SetApplicationAutoLaunch(k.constData(), true);
|
||||
if (err != vr::VRApplicationError_None) {
|
||||
*error = QStringLiteral("SetApplicationAutoLaunch(%1): %2")
|
||||
.arg(key, errorName(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrRuntime::launchDashboardOverlay(const QString& appKey, QString* error) {
|
||||
vr::IVRApplications* apps = vr::VRApplications();
|
||||
const vr::EVRApplicationError err =
|
||||
apps->LaunchDashboardOverlay(appKey.toUtf8().constData());
|
||||
if (err != vr::VRApplicationError_None) {
|
||||
*error = QStringLiteral("LaunchDashboardOverlay(%1): %2")
|
||||
.arg(appKey, QString::fromUtf8(
|
||||
apps->GetApplicationsErrorNameFromEnum(err)));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrRuntime::uninstallManifest(const QString& manifestPath, QString* error) {
|
||||
vr::IVRApplications* apps = vr::VRApplications();
|
||||
const vr::EVRApplicationError err =
|
||||
apps->RemoveApplicationManifest(manifestPath.toUtf8().constData());
|
||||
if (err != vr::VRApplicationError_None) {
|
||||
*error = QString::fromUtf8(apps->GetApplicationsErrorNameFromEnum(err));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
#include <openvr.h>
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <functional>
|
||||
|
||||
#include "render/RenderBackend.h"
|
||||
|
||||
// Owns the OpenVR connection (as VRApplication_Overlay) and pumps events on the
|
||||
// Qt event loop. Overlay-specific events are routed to the handler registered
|
||||
// for the overlay handle; global events are emitted as signals.
|
||||
class VrRuntime : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
using OverlayEventHandler = std::function<void(const vr::VREvent_t&)>;
|
||||
|
||||
explicit VrRuntime(QObject* parent = nullptr);
|
||||
~VrRuntime() override;
|
||||
|
||||
bool initialize(QString* error);
|
||||
void shutdown();
|
||||
|
||||
void registerOverlay(vr::VROverlayHandle_t handle,
|
||||
OverlayEventHandler handler);
|
||||
void unregisterOverlay(vr::VROverlayHandle_t handle);
|
||||
|
||||
// What SteamVR requires from the graphics device (Vulkan extensions,
|
||||
// HMD GPU).
|
||||
RenderInterop renderInterop() const;
|
||||
|
||||
// Registers the .vrmanifest with SteamVR and enables auto-launch for
|
||||
// every app key in it (returned in *appKeys). Requires an initialized
|
||||
// VR connection (any app type).
|
||||
static bool installManifest(const QString& manifestPath, QStringList* appKeys,
|
||||
QString* error);
|
||||
static bool uninstallManifest(const QString& manifestPath, QString* error);
|
||||
// Starts a registered dashboard overlay through SteamVR (with SteamVR's
|
||||
// environment).
|
||||
static bool launchDashboardOverlay(const QString& appKey, QString* error);
|
||||
|
||||
signals:
|
||||
void quitRequested();
|
||||
void dashboardActivated();
|
||||
void dashboardDeactivated();
|
||||
// Every global (non-overlay) event, for features that need more than
|
||||
// the above.
|
||||
void systemEvent(const vr::VREvent_t& event);
|
||||
|
||||
private:
|
||||
void pollEvents();
|
||||
|
||||
QTimer m_pollTimer;
|
||||
QHash<vr::VROverlayHandle_t, OverlayEventHandler> m_overlays;
|
||||
bool m_initialized = false;
|
||||
};
|
||||
Reference in New Issue
Block a user