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,7 @@
|
||||
# Google C++ style (https://google.github.io/styleguide/cppguide.html): 2-space indent,
|
||||
# 80 columns, opening braces on the same line everywhere (functions included).
|
||||
---
|
||||
Language: Cpp
|
||||
BasedOnStyle: Google
|
||||
Standard: Latest
|
||||
...
|
||||
@@ -0,0 +1,6 @@
|
||||
/build/
|
||||
/third_party/openvr-*/
|
||||
/compile_commands.json
|
||||
/.cache/
|
||||
CMakeUserPresets.json
|
||||
/dist/
|
||||
@@ -0,0 +1,306 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Working rules (agreed with the user, always apply)
|
||||
|
||||
- **Proper software engineering:** follow established procedures and processes, not ad-hoc
|
||||
hacking.
|
||||
- **Nothing without explicit permission.** Propose first, then act only on the user's go-ahead.
|
||||
Permission covers only what was asked: no extra changes, renames, rewrites or "while I'm at
|
||||
it" fixes. Suggest those instead.
|
||||
- **Ask, don't assume.** When a requirement, decision or detail is unclear, ask for
|
||||
clarification before proceeding. Don't fill gaps with your own defaults.
|
||||
- **Fast, iterative development cycle.** Each iteration runs through these phases in order, and
|
||||
each phase ends with the user's approval before the next starts:
|
||||
1. **Requirements engineering:** what is needed and why, with acceptance criteria.
|
||||
2. **Planning and design:** how it will be built, alternatives, trade-offs.
|
||||
3. **Implementation:** only what the approved design covers.
|
||||
4. **Testing:** verify against the acceptance criteria and report the results faithfully.
|
||||
5. **Assessment and decision:** review the outcome together and decide how to proceed.
|
||||
- Keep iterations small, so each cycle stays fast.
|
||||
- An informal "ok" from the user is enough to approve a phase.
|
||||
- **Document everything:** requirements, designs, decisions, test results. Documentation is
|
||||
direct and concise, but complete. It is Markdown and lives in `docs/`. Whenever something
|
||||
changes, also update the supporting documents (README, CLAUDE.md, other docs) to match.
|
||||
|
||||
## What this is
|
||||
|
||||
**FrameAssistant**, "Steam Frame companion for Home Assistant" (in the spirit of HASS.Agent):
|
||||
it reports headset/environment sensors to Home Assistant, offers services Home Assistant can
|
||||
call (e.g. notifications in the headset), and shows a Home Assistant dashboard as a SteamVR
|
||||
dashboard tab. Status: the overlay platform works; the Home Assistant parts are being planned.
|
||||
|
||||
User-facing docs: `README.md` (quick start, development, CLI), `docs/ARCHITECTURE.md`,
|
||||
`docs/STEAM-FRAME.md`. Keep them in sync with changes. This file holds what a developer needs
|
||||
on top of that.
|
||||
|
||||
Platform: a SteamVR **dashboard overlay**, built from the author's vrdashboard template,
|
||||
targeting the Steam Frame (aarch64, SteamOS) and x86_64 Linux. Stack: C++20, Qt 6 Quick (QML,
|
||||
≥ 6.5) and OpenVR `IVROverlay`. Two render backends behind one interface, chosen at runtime
|
||||
with `--graphics-api vulkan|opengl`.
|
||||
- **Naming:** the app is FrameAssistant (`project(frameassistant)`, executable
|
||||
`frame-assistant`, app key `net.matzman.frameassistant`, tab "Frame Assistant"). The
|
||||
overlay platform keeps its template names (`vrdashboard_core`, `VRDASH_*`, `vrdash_*`,
|
||||
`DashboardApplication`); don't rename them. Avoid "Steam", "Home Assistant" and "HASS" in
|
||||
product names (trademarks; the user dislikes "HASS").
|
||||
- Keep it simple: no FetchContent dependency, no plugin system.
|
||||
- **Vulkan is the default** where it is compiled in: on the Frame it is about 1.9× faster than
|
||||
OpenGL, which runs there through zink.
|
||||
- Without an explicit `--graphics-api`, a Vulkan start-up failure falls back to OpenGL
|
||||
(`startBackend()` in `src/app/DashboardApplication.cpp`). With an explicit choice there is no fallback.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
cmake --preset native-debug && cmake --build --preset native-debug # system Qt
|
||||
ctest --preset native-debug # all tests
|
||||
build/native-debug/bin/tst_inputbridge clickUsesGlCoordinates # one QtTest function
|
||||
build/native-debug/bin/frame-assistant --desktop # UI preview in a window, no SteamVR
|
||||
build/native-debug/bin/frame-assistant --snapshot x.png # one frame via the real offscreen path
|
||||
scripts/container-build.sh cross # x86_64 → aarch64 against the Frame's system Qt (default)
|
||||
scripts/container-build.sh native # same for the host arch (use on the Frame)
|
||||
scripts/container-build.sh cross-bundle # fallback: self-contained, Qt bundled (also native-bundle)
|
||||
scripts/container-build.sh cross shell # debug inside the cross container
|
||||
scripts/container-test.sh # all tests in Debian incl. Vulkan + validation layers
|
||||
VK_DRIVER=lvp scripts/container-test.sh # same on lavapipe (software Vulkan)
|
||||
build/native-debug/bin/frame-assistant --graphics-api vulkan --benchmark 600 # frame ms incl. GPU
|
||||
```
|
||||
|
||||
- Use `--snapshot` and then read the PNG to check UI or rendering changes without a headset.
|
||||
- `tst_render` runs once per backend (`VRDASH_TEST_API`, separate CTest entries, because the
|
||||
Qt graphics API is process-global). It QSKIPs without a GPU context or when Vulkan isn't
|
||||
compiled in.
|
||||
- `tst_inputbridge` runs with `QT_QPA_PLATFORM=offscreen`.
|
||||
- The Vulkan backend is optional (`VRDASH_VULKAN=AUTO|ON|OFF`). It needs the Vulkan headers
|
||||
and a Qt built with Vulkan. Hosts without headers build OpenGL only.
|
||||
- **Any Vulkan change: run `scripts/container-test.sh`.** It must report "No Vulkan validation
|
||||
messages". `VRDASH_VULKAN_VALIDATION=1` enables the layers in any run.
|
||||
- Cross bundles can be smoke-tested on x86 with `qemu-aarch64` plus Xvfb, inside a Debian
|
||||
container that has the arm64 X11/GL runtime libraries and *no* Qt.
|
||||
|
||||
## Code style
|
||||
|
||||
- **C++ follows the Google C++ style**, enforced by `.clang-format` (`BasedOnStyle: Google`):
|
||||
2-space indent, 80 columns, opening braces on the same line everywhere (functions too),
|
||||
`int* p`, sorted include blocks.
|
||||
- Run `clang-format -i $(find src tests -name '*.cpp' -o -name '*.h')`.
|
||||
`clang-format --dry-run --Werror …` must be clean.
|
||||
- Lines may exceed 80 columns only for user-visible strings (log/error messages).
|
||||
- Never let clang-format split a trailing comment: put the comment on its own line above.
|
||||
- `src/AppConfig.h.in` is formatted by hand.
|
||||
- Naming stays Qt style, not Google naming (CamelCase classes, camelCase methods, `m_`
|
||||
members, `k` constants), so overrides, signals and QML-visible names match Qt's APIs.
|
||||
- QML, CMake and shell scripts keep their usual conventions (QML: 4 spaces).
|
||||
|
||||
## Architecture
|
||||
|
||||
The data flow is QML → off-screen texture → SteamVR overlay. SteamVR overlay events flow back
|
||||
into Qt events.
|
||||
|
||||
- `src/app/DashboardApplication` is the **base class of every app** (in `vrdashboard_core`):
|
||||
CLI, logging, SteamVR connection, backend selection, surfaces, controller navigation, and the
|
||||
modes (overlay, `--desktop`, `--snapshot`, `--benchmark`, manifest install).
|
||||
- **Inheritance for the app, components for the surfaces** (the user's decision, after
|
||||
trying composition): the app derives from `DashboardApplication`. In its constructor it
|
||||
configures (`addSurface`, optional `setAppKey`/`setDisplayName`) and it
|
||||
overrides the hooks `loading()` (backends + shared engine exist, QML not loaded) and
|
||||
`started()` (everything runs). In the hooks, protected accessors give the running state:
|
||||
`mode()`, `appKey()`, `appName()`, `engine()`, `surfaces()`, `surface()` (primary),
|
||||
`surface(id)`, `vrRuntime()`, `navigation()`. The app is `src/Dashboard.h/.cpp`.
|
||||
- The base constructor creates the `QGuiApplication` (`QCoreApplication` for manifest
|
||||
commands), so derived members can be QObjects with timers.
|
||||
- All QML and surfaces are torn down **at the end of `exec()`**, while the derived object
|
||||
and its members (e.g. QML singletons) still exist.
|
||||
- `src/app/Surface` is **one view**: its QML root (from the app's module), renderer, render
|
||||
backend and overlay. All surfaces share one `QQmlEngine`.
|
||||
- It's a component: configured via `SurfaceConfig` (id, qmlRootType, displayName,
|
||||
textureSize, thumbnail), with signals `visibilityChanged`/`activated`. Never subclassed.
|
||||
- Overlay key = `<app key>.<id>`, or the app key itself for an empty id. The app key (manifest,
|
||||
`IdentifyApplication`) is one per process; `--app-key`/`--app-name` override it.
|
||||
- Backends: the first surface decides the API (incl. the Vulkan→OpenGL fallback); the
|
||||
others use the same API, because `QQuickWindow::setGraphicsApi` is process-wide.
|
||||
- Controller navigation sends keys to the *active* surface: the one last opened or pointed
|
||||
at (`Surface::activated`), if visible; else any visible one.
|
||||
- `vr/Overlay` is the overlay base (input, visibility, frames). `DashboardOverlay` is the
|
||||
only kind so far; a `WorldOverlay` would be another subclass (see "Planned extension").
|
||||
- `vrdash_add_dashboard()` (`cmake/Dashboard.cmake`) defines a dashboard executable:
|
||||
QML module, bytecode-only QML, OpenVR lib, install, registration for Qt bundling.
|
||||
The QML module's URI (`QML_URI`) is written down only there: a generated source file
|
||||
registers it as the app's default module (`DashboardApplication::setDefaultQmlModule`
|
||||
at static-init time). `setQmlModule()` exists only to load from another module.
|
||||
|
||||
- `render/RenderBackend.h` is the **only** place graphics-API code may live:
|
||||
- context/device setup, texture allocation, layout handling, `vr::Texture_t` + UV bounds
|
||||
for `SetOverlayTexture`, and CPU readback.
|
||||
- The header documents the call order (initialize → configureWindow → attachWindow →
|
||||
createRenderTarget → per frame beginFrame/finishFrame → detachWindow).
|
||||
- `createRenderBackend()` is the factory.
|
||||
- `QQuickWindow::setGraphicsApi()` must run before any `QQuickWindow` exists (see
|
||||
`startBackend()` in `DashboardApplication.cpp`).
|
||||
- In overlay mode the backend is initialized *after* `VR_Init`, because Vulkan needs
|
||||
`VrRuntime::renderInterop()`: the instance/device extensions SteamVR requires and the HMD's
|
||||
`VkPhysicalDevice`.
|
||||
- `VulkanRenderBackend`:
|
||||
- Qt Quick creates the `VkDevice`; the backend owns the `VkImage`.
|
||||
- OpenVR requires `TRANSFER_SRC_OPTIMAL` plus `TRANSFER_SRC|SAMPLED` usage. Qt leaves the
|
||||
target in `COLOR_ATTACHMENT_OPTIMAL`, so `finishFrame()`/`beginFrame()` transition between
|
||||
the two with small synchronous submits. QQuickRenderControl offscreen frames already wait
|
||||
for the GPU.
|
||||
- Everything created on Qt's device must be freed in `detachWindow()`, before
|
||||
QQuickRenderControl destroys the device.
|
||||
- The instance requests API version ≤ 1.3. QVulkanInstance's 1.0 default made Qt itself
|
||||
trigger validation errors.
|
||||
- `render/OffscreenQuickRenderer` is API-agnostic:
|
||||
- Drives `QQuickRenderControl` and renders on demand (on scene change or animation),
|
||||
capped at `--max-fps`.
|
||||
- Pacing uses an absolute schedule from frame starts. Waiting a fixed interval *after*
|
||||
each frame cost about 10% on the Frame and about 50% under load;
|
||||
`tst_render pacingHoldsFrameRateWithSlowFrames` guards this.
|
||||
- Each frame goes to a `FrameSink` callback **while the context is current**, which OpenGL
|
||||
`SetOverlayTexture` requires. `Surface` sets it to its overlay.
|
||||
- `vr/VrRuntime` handles `VR_Init(VRApplication_Overlay)` and polls events on a Qt timer. It
|
||||
routes per-overlay events to handlers registered by overlay handle.
|
||||
- **`VR_Shutdown` must run before the graphics device is destroyed** (openvr.h: "Be sure to
|
||||
call OpenVR_Shutdown before destroying these resources").
|
||||
- Order in `runOverlay()` (`DashboardApplication.cpp`): the surfaces (renderers, backends)
|
||||
are members and die after it returns; `VrRuntime` is a local, so it shuts down first; an
|
||||
`OverlayGuard` declared after it destroys the overlays before that. Otherwise SteamVR's
|
||||
client frees its query pool on a dead VkDevice at exit, which crashed on the Frame.
|
||||
- `vr/DashboardOverlay` is `CreateDashboardOverlay` plus the tab thumbnail. **Rendering follows
|
||||
`VREvent_OverlayShown/Hidden`, not the dashboard's open/closed state.** Users can float or pin
|
||||
dashboard overlays so they stay visible in games; tying rendering to the dashboard would
|
||||
freeze them there.
|
||||
- `input/OverlayInputBridge` turns OpenVR overlay events into Qt events, so QML never knows
|
||||
about VR:
|
||||
- Mouse coordinates arrive in **GL convention (y from the bottom)** and must be flipped.
|
||||
- Smooth scroll becomes `QWheelEvent` (with a sub-pixel accumulator).
|
||||
- When a text item gets active focus, it opens the SteamVR keyboard
|
||||
(`KeyboardFlag_Minimal`: characters stream in; `\b` and `\n` mean Backspace and Return).
|
||||
- The offscreen window gets an explicit `FocusIn` event (in `OffscreenQuickRenderer`),
|
||||
otherwise `activeFocusItem` stays null.
|
||||
- `input/ControllerNavigation` turns SteamVR Input actions (`resources/input/actions.json`,
|
||||
default bindings for `frame_controller`) into Tab/Backtab/arrow/Space/Escape key events.
|
||||
Qt Quick's own focus chain does the rest; no QML glue is needed.
|
||||
- **QML singletons owned by the app:** keep them as members of the app class. A singleton
|
||||
must not be default-constructible, or QML builds its own instance instead of calling
|
||||
`create()` (e.g. give the constructor a required `QObject* parent` and init it `{nullptr}`).
|
||||
- Publish per-frame values (frame stats etc.) to QML at most about once per second. Binding
|
||||
per-frame values in QML re-triggers the on-demand renderer forever.
|
||||
- **The executable is `frame-assistant` (`VRDASH_EXECUTABLE_NAME`), never `vrdashboard`.**
|
||||
SteamVR identifies processes by executable name. With `vrdashboard`, the Frame's SteamVR
|
||||
treated our process as *its own dashboard* (watchdog, "VRDashboard process failed to start
|
||||
10 times"). CMake rejects names of SteamVR processes.
|
||||
- The version is in `project()` in the root `CMakeLists.txt` (currently 0.1.0); release
|
||||
packages are named after it.
|
||||
- App identity (app key, name, executable name, texture size) lives in CMake cache variables in the root
|
||||
`CMakeLists.txt` and is generated into `AppConfig.h`.
|
||||
|
||||
## Planned extension: world overlay (not implemented)
|
||||
|
||||
Several surfaces per process exist (tested by `tests/tst_surfaces`). Still to come, for a
|
||||
notification app among others: a **`WorldOverlay`** (subclass of `vr/Overlay`, created via
|
||||
`CreateOverlay`) with configurable placement and optional interactivity. Placement modes:
|
||||
room, HUD, hand, and "spawn in front then stay". Interactivity uses
|
||||
`VROverlayFlags_MakeOverlaysInteractiveIfVisible`, which takes the system laser while
|
||||
visible. `SurfaceConfig` would get a kind (dashboard/world) plus placement; the app shows and
|
||||
hides world surfaces itself. Keep the teardown rule: overlays, then SteamVR, then backends.
|
||||
|
||||
## Build layout and deployment
|
||||
|
||||
- The build tree mirrors the install layout (`bin/`, `lib/`, `share/frameassistant/`). The binary
|
||||
finds its data at `<exe>/../share/frameassistant`, and `libopenvr_api.so` through
|
||||
RPATH `$ORIGIN/../lib`.
|
||||
- `manifest.vrmanifest` uses paths relative to itself, so `--install-manifest` works from both
|
||||
the build tree and an install.
|
||||
- `cmake/OpenVR.cmake` downloads only `openvr.h`, `LICENSE` and the matching
|
||||
`bin/linux64|linuxarm64/libopenvr_api.so` into `third_party/openvr-<ver>/` (gitignored).
|
||||
- **Deploying to the Frame uses its system Qt by default.** Bundling Qt is the fallback for
|
||||
when a SteamOS update ships a Qt that no longer fits.
|
||||
- `VRDASH_DEPLOY_QT=ON` (presets `native-bundle` and `cross-arm64-bundle`) runs `cmake/BundleQt.cmake`
|
||||
and `bundle-install.cmake.in` at install time:
|
||||
- `qmlimportscanner` collects the QML modules.
|
||||
- `file(GET_RUNTIME_DEPENDENCIES)` with the target's objdump collects the libraries.
|
||||
- `patchelf` sets relative RPATHs, and the script writes `bin/qt.conf`.
|
||||
- Qt's own `qt_generate_deploy_*` is deliberately not used: it skips cross-compiled Linux
|
||||
targets, and with a `/usr` Qt it would bundle glibc.
|
||||
- **`VRDASH_BUNDLE_EXCLUDE` must keep excluding the GPU/display stack** (libGL/EGL/GLX,
|
||||
libdrm/gbm, Vulkan, Wayland, X11/xcb, libffi, glibc, libstdc++). These must come from SteamOS,
|
||||
or GPU access breaks.
|
||||
- **Release packages** (`scripts/package-release.sh [--bundle-qt]`) contain only the
|
||||
app, `install.sh` (install/update: stops a running instance, registers,
|
||||
launches), `uninstall.sh` and `README.txt`. `deploy-to-frame.sh` rsyncs the newest one to
|
||||
the Frame and runs its `install.sh`.
|
||||
- **Licenses:** MIT (`LICENSE`); `THIRD_PARTY_NOTICES.md` and OpenVR's license are installed to
|
||||
`share/doc/frameassistant/`. Bundled builds also get the Debian copyright files of every bundled
|
||||
library plus the referenced common license texts (collected via `dpkg -S` in
|
||||
`bundle-install.cmake.in`, after resolving paths: trixie registers `/usr/lib` paths).
|
||||
- **Qt bundling runs once for all dashboards** (`vrdash_bundle_qt()` at the end of the root
|
||||
`CMakeLists.txt`). Bundling per executable made the second run see each library twice.
|
||||
- **System-Qt builds** (the default; presets `cross-arm64` and `native-systemqt`,
|
||||
`container-build.sh cross|native`): no Qt bundle, so the package is about 3 MB instead of
|
||||
46 MB.
|
||||
- `VRDASH_QML_BYTECODE_ONLY=ON` keeps qmlcachegen's version-locked C++ out of the binary.
|
||||
- The binary then needs only stable `Qt_6` symbols, `qt_version_tag@Qt_6.8` (Qt ≥ 6.8),
|
||||
and Qt's Vulkan function wrappers. Those are tagged `Qt_6_PRIVATE_API` but generated from
|
||||
the Vulkan spec.
|
||||
- Verified: the Debian-built binary (Qt 6.8.2) runs on Arch's Qt 6.8.0 (Arch Linux Archive
|
||||
snapshot 2024-10-20, image `archlinux:base-20241020.0.271562`), with both backends.
|
||||
- Check with `objdump -T bin/frame-assistant | grep Qt_6_PRIVATE_API`: only `QVulkan*` may appear.
|
||||
- **`-mno-direct-extern-access` is added whenever the compiler supports it** (root
|
||||
`CMakeLists.txt`).
|
||||
- Arch/SteamOS build Qt with protected symbols, so consumers must not use copy relocations.
|
||||
Debian's Qt doesn't pass that flag on.
|
||||
- Without it, an x86-64 Debian build fails against Arch's Qt
|
||||
(`GNU_PROPERTY_1_NEEDED_INDIRECT_EXTERN_ACCESS`). aarch64 GCC has no such flag, and arm64
|
||||
PIE never uses copy relocations.
|
||||
- The Frame's `/etc/pacman.conf` points to private Valve repositories (URLs marked
|
||||
"DO_NOT_SHARE"). Never put those URLs into this repo or into scripts.
|
||||
- Cross builds use Debian multiarch:
|
||||
- host (amd64) Qt tools via `QT_HOST_PATH=/usr`, target Qt from `/usr/lib/aarch64-linux-gnu`.
|
||||
- Cross presets turn tests off.
|
||||
|
||||
## Verified on the Steam Frame
|
||||
|
||||
- Over SSH, `run.sh opengl` finds `DISPLAY=:0`, `VR_Init` succeeds, and the bundled Qt starts
|
||||
(2026-10-04, SteamOS holo 0.4.3, system Qt 6.8.0).
|
||||
- **OpenGL on the Frame runs through zink (GL on Vulkan) on Turnip/Adreno 750.** There is no
|
||||
native GL driver, so the Vulkan backend avoids a translation layer there.
|
||||
- Both backends work in SteamVR: display orientation (no texture flip needed for either
|
||||
backend), laser, scroll direction, keyboard and D-pad navigation (SteamVR Input actions
|
||||
arrive while the dashboard is open) are all OK.
|
||||
- Floating works: a floated/pinned dashboard tab keeps rendering in games and stays
|
||||
interactive.
|
||||
- Started by SteamVR itself (`install.sh --launch`, auto-start), the app finds a working
|
||||
display connection; no `QT_QPA_PLATFORM` override is needed.
|
||||
- **The system-Qt package works on the Frame** (2026-10-04): the Debian-built binary (Qt 6.8.2)
|
||||
runs on SteamOS's Qt 6.8.0 with both backends. The package contains no Qt, so it can only
|
||||
have used the system Qt.
|
||||
- Vulkan now exits cleanly via "Quit app" (the OpenVR shutdown-order fix is confirmed).
|
||||
- **Install/launch works end to end** (2026-10-04): `install.sh` registers both test overlays,
|
||||
SteamVR knows both keys, `--launch` starts them, they auto-start again after a reboot, and
|
||||
`uninstall.sh` unregisters them.
|
||||
- **SteamVR on the Frame reads the program path from `binary_path_linux_arm`** (its own
|
||||
manifests in `/opt/steamvr` use it). Entries with only `binary_path_linux` are skipped
|
||||
("must specify binary_path for launch_type binary"), and the key then counts as unknown.
|
||||
`resources/CMakeLists.txt` picks the key from the target architecture.
|
||||
- **SteamVR on the Frame refuses `VRApplication_Utility`.** `VR_Init()` always queries
|
||||
`IVRSystem`, which fails with `VRInitError_Init_NotAvailableToUtilityApps` (123). Manifest
|
||||
commands therefore use `VRApplication_Background`, which needs a running SteamVR and never
|
||||
starts it. Don't switch back to Utility.
|
||||
- `benchmark.sh` on the Frame (600 frames, 1600×1000, incl. GPU wait): **Vulkan 1.01 ms avg
|
||||
(982 fps), OpenGL/zink 1.89 ms avg (528 fps)**, so Vulkan is about 1.9× faster.
|
||||
- After the pacing fix both run at 60 fps in VR. The (since removed) diagnostics example's frame time (Vulkan about
|
||||
2.4 ms, GL about 1.2 ms) isn't comparable, because GL leaves out its asynchronous GPU work.
|
||||
- Before the pacing fix, Vulkan showed 55 fps and GL 60. Cause: the render time was added
|
||||
to the frame interval, and GL's frame time leaves out its asynchronous GPU work.
|
||||
|
||||
## Not yet verified on a Steam Frame
|
||||
|
||||
Check these when testing on the device, and update this list:
|
||||
|
||||
- `scripts/deploy-to-frame.sh` and the release package (`install.sh` install/update,
|
||||
`uninstall.sh`).
|
||||
- The restructured app code (base class `DashboardApplication`, `Surface`, `Overlay` base,
|
||||
active-surface navigation) has only been tested off the device so far.
|
||||
- Several dashboard tabs from one process (works in `tst_surfaces`, not tried in SteamVR).
|
||||
@@ -0,0 +1,72 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(frameassistant VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App identity. The app key must be globally unique within SteamVR (reverse-DNS style).
|
||||
# ---------------------------------------------------------------------------
|
||||
set(VRDASH_APP_KEY "net.matzman.frameassistant" CACHE STRING "SteamVR application/overlay key")
|
||||
set(VRDASH_DISPLAY_NAME "Frame Assistant" CACHE STRING "Name shown in the SteamVR dashboard")
|
||||
# Must not be the name of a SteamVR process (checked in cmake/Dashboard.cmake).
|
||||
set(VRDASH_EXECUTABLE_NAME "frame-assistant" CACHE STRING "File name of the executable")
|
||||
set(VRDASH_TEXTURE_WIDTH 1600 CACHE STRING "Overlay texture width in pixels")
|
||||
set(VRDASH_TEXTURE_HEIGHT 1000 CACHE STRING "Overlay texture height in pixels")
|
||||
|
||||
set(OPENVR_VERSION "2.15.6" CACHE STRING "OpenVR SDK version to download when OPENVR_ROOT is not set")
|
||||
|
||||
set(VRDASH_VULKAN "AUTO" CACHE STRING "Build the Vulkan render backend (AUTO, ON, OFF)")
|
||||
set_property(CACHE VRDASH_VULKAN PROPERTY STRINGS AUTO ON OFF)
|
||||
option(VRDASH_QML_BYTECODE_ONLY "Compile QML to bytecode only (no C++): keeps the binary off Qt's version-locked private API, so it runs on other Qt 6 patch releases (system Qt)" OFF)
|
||||
option(VRDASH_DEPLOY_QT "Bundle Qt libraries, plugins and QML modules on install (self-contained build)" OFF)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# Mirror the install layout in the build tree (bin/, lib/, share/<project>/) so the
|
||||
# binary finds its resources and libopenvr_api.so the same way in both places.
|
||||
include(GNUInstallDirs)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
|
||||
set(VRDASH_SHARE_SUBDIR ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
|
||||
set(CMAKE_BUILD_RPATH "\$ORIGIN/../${CMAKE_INSTALL_LIBDIR}")
|
||||
set(CMAKE_INSTALL_RPATH "\$ORIGIN/../${CMAKE_INSTALL_LIBDIR}")
|
||||
|
||||
# Arch/SteamOS build Qt with -mno-direct-extern-access (protected symbols), which forbids
|
||||
# copy relocations in consumers. Qt's CMake only passes the flag on if Qt itself was built
|
||||
# with it, so a binary built against e.g. Debian's Qt would fail to load against Arch's
|
||||
# system Qt on x86-64. Always using indirect access works with both. (GCC has no such flag
|
||||
# for aarch64; its PIE code never uses copy relocations there.)
|
||||
include(CheckCXXCompilerFlag)
|
||||
check_cxx_compiler_flag(-mno-direct-extern-access VRDASH_HAS_NO_DIRECT_EXTERN_ACCESS)
|
||||
if(VRDASH_HAS_NO_DIRECT_EXTERN_ACCESS)
|
||||
add_compile_options(-mno-direct-extern-access)
|
||||
endif()
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
|
||||
include(OpenVR)
|
||||
|
||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui OpenGL Qml Quick QuickControls2)
|
||||
qt_standard_project_setup(REQUIRES 6.5)
|
||||
if(QT_KNOWN_POLICY_QTP0004)
|
||||
qt_policy(SET QTP0004 NEW) # QML files live in src/qml/
|
||||
endif()
|
||||
|
||||
include(Dashboard)
|
||||
if(VRDASH_DEPLOY_QT)
|
||||
include(BundleQt)
|
||||
endif()
|
||||
|
||||
add_subdirectory(src)
|
||||
add_subdirectory(resources)
|
||||
|
||||
# After all dashboards (and their install rules) are defined.
|
||||
if(VRDASH_DEPLOY_QT)
|
||||
vrdash_bundle_qt()
|
||||
endif()
|
||||
|
||||
option(VRDASH_BUILD_TESTS "Build unit tests" ON)
|
||||
if(VRDASH_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"version": 6,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "base",
|
||||
"hidden": true,
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build/${presetName}"
|
||||
},
|
||||
{
|
||||
"name": "native-debug",
|
||||
"displayName": "Native (host architecture), Debug",
|
||||
"inherits": "base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "native-release",
|
||||
"displayName": "Native (host architecture), Release",
|
||||
"inherits": "base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "RelWithDebInfo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "native-bundle",
|
||||
"displayName": "Native, self-contained bundle with Qt (fallback; scripts/container-build.sh native-bundle)",
|
||||
"inherits": "base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
|
||||
"VRDASH_DEPLOY_QT": "ON",
|
||||
"VRDASH_BUILD_TESTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cross-arm64-bundle",
|
||||
"displayName": "Cross-compile for aarch64, self-contained bundle with Qt (fallback; scripts/container-build.sh cross-bundle)",
|
||||
"inherits": "base",
|
||||
"toolchainFile": "${sourceDir}/cmake/toolchains/aarch64-linux-gnu.cmake",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
|
||||
"VRDASH_DEPLOY_QT": "ON",
|
||||
"VRDASH_BUILD_TESTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "native-systemqt",
|
||||
"displayName": "Native, against the target's system Qt (default for the Frame; scripts/container-build.sh native)",
|
||||
"inherits": "base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
|
||||
"VRDASH_DEPLOY_QT": "OFF",
|
||||
"VRDASH_QML_BYTECODE_ONLY": "ON",
|
||||
"VRDASH_BUILD_TESTS": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cross-arm64",
|
||||
"displayName": "Cross-compile for aarch64 against the target's system Qt 6.8.x (default; scripts/container-build.sh cross)",
|
||||
"inherits": "base",
|
||||
"toolchainFile": "${sourceDir}/cmake/toolchains/aarch64-linux-gnu.cmake",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "RelWithDebInfo",
|
||||
"VRDASH_DEPLOY_QT": "OFF",
|
||||
"VRDASH_QML_BYTECODE_ONLY": "ON",
|
||||
"VRDASH_BUILD_TESTS": "OFF"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "native-debug",
|
||||
"configurePreset": "native-debug"
|
||||
},
|
||||
{
|
||||
"name": "native-release",
|
||||
"configurePreset": "native-release"
|
||||
},
|
||||
{
|
||||
"name": "native-bundle",
|
||||
"configurePreset": "native-bundle"
|
||||
},
|
||||
{
|
||||
"name": "cross-arm64-bundle",
|
||||
"configurePreset": "cross-arm64-bundle"
|
||||
},
|
||||
{
|
||||
"name": "native-systemqt",
|
||||
"configurePreset": "native-systemqt"
|
||||
},
|
||||
{
|
||||
"name": "cross-arm64",
|
||||
"configurePreset": "cross-arm64"
|
||||
}
|
||||
],
|
||||
"testPresets": [
|
||||
{
|
||||
"name": "native-debug",
|
||||
"configurePreset": "native-debug",
|
||||
"output": {
|
||||
"outputOnFailure": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 matzman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,122 @@
|
||||
# FrameAssistant
|
||||
|
||||
**Steam Frame companion for Home Assistant.**
|
||||
|
||||
FrameAssistant runs on the Steam Frame (and on x86_64 Linux PCs with SteamVR). It connects
|
||||
the headset to Home Assistant in both directions:
|
||||
|
||||
- **Sensors:** it reports the state of the headset and its surroundings to Home Assistant.
|
||||
- **Services:** Home Assistant can call it, e.g. to show notifications in the headset.
|
||||
- **Dashboard:** a SteamVR dashboard tab to view and control Home Assistant from inside VR.
|
||||
|
||||
> **Status:** early development. The overlay platform works on the Frame; the Home Assistant
|
||||
> parts are being planned.
|
||||
|
||||
### Platform
|
||||
|
||||
FrameAssistant grew out of a SteamVR dashboard-overlay template, which it still builds on:
|
||||
|
||||
- **Plain QML UI:** rendered off-screen and shown as a SteamVR dashboard tab. Users can float or
|
||||
pin the tab anywhere, and it stays visible in games.
|
||||
- **Input:** SteamVR's laser (as mouse), thumbstick scrolling, the SteamVR keyboard (opens
|
||||
for text fields), and D-pad/button navigation via rebindable SteamVR Input actions.
|
||||
- **Vulkan** by default, with **OpenGL** as an automatic fallback. On the Frame, Vulkan is
|
||||
about 1.9× faster, since OpenGL runs through zink there.
|
||||
- **Several views per app** ("surfaces"), e.g. several dashboard tabs sharing one QML engine
|
||||
and its data.
|
||||
- **Develop without a headset:** desktop preview window, offscreen snapshots, benchmark.
|
||||
- **Frame packages** of about 3 MB, using SteamOS's own Qt; a self-contained variant with Qt
|
||||
bundled is also available. Packages come with `install.sh`/`uninstall.sh` and a deploy
|
||||
script.
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements:
|
||||
- CMake ≥ 3.22, Ninja, a C++20 compiler, and Qt ≥ 6.5 (Quick, QuickControls2, OpenGL;
|
||||
Test for the tests)
|
||||
- Vulkan headers (optional; without them the build is OpenGL only)
|
||||
- Docker or Podman, for the Frame builds
|
||||
|
||||
The OpenVR SDK files are downloaded automatically when CMake configures the build
|
||||
(`-DOPENVR_ROOT=<sdk>` to build offline).
|
||||
|
||||
```sh
|
||||
cmake --preset native-debug && cmake --build --preset native-debug
|
||||
ctest --preset native-debug
|
||||
|
||||
build/native-debug/bin/frame-assistant --desktop # UI in a window, no SteamVR
|
||||
build/native-debug/bin/frame-assistant --snapshot out.png # one frame through the VR path
|
||||
build/native-debug/bin/frame-assistant # as dashboard tab (SteamVR running)
|
||||
build/native-debug/bin/frame-assistant --install-manifest # register + auto-start with SteamVR
|
||||
```
|
||||
|
||||
On the Steam Frame:
|
||||
|
||||
```sh
|
||||
scripts/package-release.sh # → dist/frame-assistant-0.1.0-linux-arm64.tar.gz
|
||||
scripts/deploy-to-frame.sh <user>@<frame> # copy over SSH and install (SteamVR must run)
|
||||
```
|
||||
|
||||
Details are in [docs/STEAM-FRAME.md](docs/STEAM-FRAME.md).
|
||||
|
||||
## Development
|
||||
|
||||
- **App identity** (app key `net.matzman.frameassistant`, tab name, executable name
|
||||
`frame-assistant`, texture size) is set in the root `CMakeLists.txt`. The executable must not
|
||||
be named like a SteamVR process such as `vrdashboard` (CMake checks this).
|
||||
- **UI:** `src/qml/`, with `Main.qml` as the root. List new QML files under `QML_FILES` in
|
||||
`vrdash_add_dashboard()` in `src/CMakeLists.txt`. Iterate with `--desktop` and `--snapshot`.
|
||||
- **C++ logic:** the app class `src/Dashboard.h/.cpp` derives from `DashboardApplication`.
|
||||
It adds views in its constructor (`addSurface()`), keeps its objects as members and wires
|
||||
them up in the `loading()`/`started()` overrides.
|
||||
- **Controller buttons:** actions and default bindings are in `resources/input/`.
|
||||
- **Ship it:** `scripts/package-release.sh` and `scripts/deploy-to-frame.sh`, see
|
||||
[docs/STEAM-FRAME.md](docs/STEAM-FRAME.md).
|
||||
|
||||
How the pieces fit together: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Command line
|
||||
|
||||
`frame-assistant` understands:
|
||||
|
||||
| Option | Meaning |
|
||||
|--------|---------|
|
||||
| *(none)* | Run as SteamVR dashboard overlay (SteamVR must be running) |
|
||||
| `--desktop` | Show the UI in desktop windows, one per surface; no SteamVR |
|
||||
| `--snapshot <png>` | Render one frame per surface off-screen and save it (`<name>-<id>.png` with several surfaces) |
|
||||
| `--benchmark <frames>` | Render frames back to back with a full GPU wait each and print frame times |
|
||||
| `--graphics-api vulkan\|opengl` | Render backend. Default: Vulkan, falling back to OpenGL if Vulkan doesn't start; an explicit choice never falls back |
|
||||
| `--app-key <key>`, `--app-name <name>` | Override app key and name, e.g. to run a second instance |
|
||||
| `--max-fps <fps>` | Frame-rate cap (default 60); rendering only happens on change |
|
||||
| `--install-manifest [--launch]` | Register with SteamVR (auto-start) and optionally start it now; works over SSH |
|
||||
| `--uninstall-manifest` | Unregister |
|
||||
| `--manifest <path>` | Manifest for the two options above (default: the app's own) |
|
||||
| `--log-file <path>` | Also log to a file (relative paths: relative to the install folder) |
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
ctest --preset native-debug # unit + render tests (GPU tests skip without a GPU context)
|
||||
scripts/container-test.sh # all tests in Debian incl. Vulkan with validation layers
|
||||
VK_DRIVER=lvp scripts/container-test.sh # same on the lavapipe software driver
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | Content |
|
||||
|------|---------|
|
||||
| `src/main.cpp`, `src/Dashboard.*`, `src/qml/` | **The app** |
|
||||
| `src/app/` | `DashboardApplication` (app base class), `Surface` (one view) |
|
||||
| `src/render/` | Off-screen Qt Quick renderer; Vulkan and OpenGL backends |
|
||||
| `src/vr/` | SteamVR connection, overlays (dashboard tab) |
|
||||
| `src/input/` | Laser/scroll/keyboard → Qt events; SteamVR Input navigation |
|
||||
| `resources/` | SteamVR manifest template, input actions and bindings |
|
||||
| `tests/` | Unit and render tests |
|
||||
| `cmake/` | `vrdash_add_dashboard()`, OpenVR download, Qt bundling, toolchain |
|
||||
| `docker/`, `scripts/` | Build containers; build, package, deploy and test scripts |
|
||||
| `docs/` | Architecture, Steam Frame notes |
|
||||
|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](LICENSE). Third-party components (OpenVR SDK, Qt; in bundled builds also
|
||||
their dependencies) are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
@@ -0,0 +1,15 @@
|
||||
# Third-party software
|
||||
|
||||
This project is MIT-licensed (see `LICENSE`). `<project>` below is the CMake project name
|
||||
(`frameassistant`). Builds include or use:
|
||||
|
||||
| Component | License | How it is used |
|
||||
|-----------|---------|----------------|
|
||||
| [OpenVR SDK](https://github.com/ValveSoftware/openvr) (`libopenvr_api.so`, `openvr.h`) | BSD-3-Clause | Shipped in `lib/`; license in `share/doc/<project>/third-party/openvr.LICENSE` |
|
||||
| [Qt 6](https://www.qt.io/) (Core, Gui, Qml, Quick, Quick Controls, Wayland/XCB plugins) | LGPL-3.0 (Qt open-source license) | Linked dynamically. Default builds use the system's Qt (not shipped). Bundled builds ship Qt's shared libraries unmodified in `lib/`, `plugins/`, `qml/`: replace them to use another compatible Qt build |
|
||||
|
||||
**Bundled builds** (`--bundle-qt`) also ship further shared libraries that Qt depends on,
|
||||
taken unmodified from Debian. Their copyright and license files are in
|
||||
`share/doc/<project>/third-party/` (one `<package>.copyright` per Debian package, plus
|
||||
the license texts they reference). The corresponding sources are available from Debian
|
||||
(https://sources.debian.org/, package names as in those file names).
|
||||
@@ -0,0 +1,62 @@
|
||||
# vrdash_bundle_qt()
|
||||
#
|
||||
# Call once, after all dashboards are defined (vrdash_add_dashboard() registers them).
|
||||
# Adds an install step that makes the install prefix self-contained for all of them
|
||||
# together: the Qt libraries
|
||||
# (and their non-system dependencies), required Qt plugins and the QML modules the UI
|
||||
# imports are copied next to the executable, RPATHs are made relative and a qt.conf is
|
||||
# written. Works for native AND cross builds (Qt's own qt_generate_deploy_*_app_script
|
||||
# refuses cross-compiled Linux targets, and with a /usr Qt it would bundle glibc too).
|
||||
#
|
||||
# Libraries matched by VRDASH_BUNDLE_EXCLUDE are expected on the target system
|
||||
# (SteamOS): libc, the C++ runtime and everything tied to the GPU driver / display
|
||||
# server. Bundling those would break GPU access, so they always come from the host.
|
||||
# Regexes are matched against DT_NEEDED names.
|
||||
|
||||
set(VRDASH_BUNDLE_EXCLUDE
|
||||
"^ld-linux" "^libc\\.so" "^libm\\.so" "^libdl\\.so" "^libpthread\\.so" "^librt\\.so"
|
||||
"^libresolv\\.so" "^libutil\\.so" "^libstdc\\+\\+\\.so" "^libgcc_s\\.so"
|
||||
"^libGL" "^libEGL" "^libOpenGL" "^libGLX" "^libGLdispatch" "^libgbm" "^libdrm"
|
||||
"^libvulkan" "^libwayland-" "^libX" "^libxcb" "^libxkbcommon" "^libxshmfence"
|
||||
"^libfontconfig" "^libfreetype" "^libexpat" "^libz\\.so"
|
||||
"^libdbus-1" "^libsystemd" "^libudev" "^libcap\\.so" "^libgcrypt" "^libgpg-error"
|
||||
"^liblzma" "^liblz4" "^libzstd" "^libffi"
|
||||
"^libopenvr_api")
|
||||
|
||||
# SteamVR's process environment decides the platform (Wayland under gamescope/Plasma,
|
||||
# XWayland via DISPLAY, or offscreen); embedded platforms (eglfs, vnc, …) are left out.
|
||||
set(VRDASH_BUNDLE_PLUGINS
|
||||
platforms/libqwayland-*.so platforms/libqxcb.so platforms/libqoffscreen.so
|
||||
wayland-shell-integration/*.so wayland-graphics-integration-client/*.so
|
||||
wayland-decoration-client/*.so xcbglintegrations/*.so)
|
||||
|
||||
function(vrdash_bundle_qt)
|
||||
get_property(targets GLOBAL PROPERTY VRDASH_DASHBOARD_TARGETS)
|
||||
get_property(qml_dirs GLOBAL PROPERTY VRDASH_DASHBOARD_QML_DIRS)
|
||||
set(executables "")
|
||||
foreach(target IN LISTS targets)
|
||||
list(APPEND executables "$<TARGET_FILE_NAME:${target}>")
|
||||
endforeach()
|
||||
|
||||
find_program(VRDASH_PATCHELF patchelf REQUIRED)
|
||||
if(CMAKE_CROSSCOMPILING)
|
||||
# Inspect target ELF files with the target toolchain's objdump.
|
||||
set(objdump ${CMAKE_OBJDUMP})
|
||||
else()
|
||||
find_program(objdump objdump REQUIRED)
|
||||
endif()
|
||||
|
||||
set(qt_prefix ${QT6_INSTALL_PREFIX})
|
||||
set(qt_libdir ${QT6_INSTALL_PREFIX}/${QT6_INSTALL_LIBS})
|
||||
if(CMAKE_LIBRARY_ARCHITECTURE AND EXISTS ${qt_prefix}/lib/${CMAKE_LIBRARY_ARCHITECTURE})
|
||||
set(qt_libdir ${qt_prefix}/lib/${CMAKE_LIBRARY_ARCHITECTURE}) # Debian multiarch
|
||||
endif()
|
||||
|
||||
# The regexes end up inside a quoted string in the generated script: escape backslashes.
|
||||
string(REPLACE "\\" "\\\\" bundle_exclude "${VRDASH_BUNDLE_EXCLUDE}")
|
||||
|
||||
set(script ${CMAKE_BINARY_DIR}/bundle-qt.cmake)
|
||||
configure_file(${PROJECT_SOURCE_DIR}/cmake/bundle-install.cmake.in ${script}.in @ONLY)
|
||||
file(GENERATE OUTPUT ${script} INPUT ${script}.in)
|
||||
install(SCRIPT ${script})
|
||||
endfunction()
|
||||
@@ -0,0 +1,72 @@
|
||||
# vrdash_add_dashboard(<target>
|
||||
# OUTPUT_NAME <executable file name>
|
||||
# QML_URI <QML module URI>
|
||||
# SOURCES <main.cpp and other plain C++>
|
||||
# [QML_SOURCES <C++ with QML_ELEMENT types>]
|
||||
# QML_FILES <.qml files>)
|
||||
#
|
||||
# A dashboard executable on top of vrdashboard_core (DashboardApp), with everything
|
||||
# all dashboards share: its QML module, bytecode-only QML for system-Qt builds,
|
||||
# libopenvr_api.so next to it, installation, registration for vrdash_bundle_qt().
|
||||
|
||||
# SteamVR identifies processes by executable name: never use one of its own (an
|
||||
# executable called "vrdashboard" made SteamVR treat us as its dashboard).
|
||||
set(VRDASH_RESERVED_EXECUTABLE_NAMES
|
||||
vrdashboard vrserver vrcompositor vrmonitor vrstartup vrwebhelper vrservice
|
||||
vrpathreg vrcmd)
|
||||
|
||||
function(vrdash_add_dashboard target)
|
||||
cmake_parse_arguments(PARSE_ARGV 1 arg "" "OUTPUT_NAME;QML_URI"
|
||||
"SOURCES;QML_SOURCES;QML_FILES")
|
||||
if(arg_OUTPUT_NAME IN_LIST VRDASH_RESERVED_EXECUTABLE_NAMES)
|
||||
message(FATAL_ERROR "Executable name '${arg_OUTPUT_NAME}' collides with a SteamVR process")
|
||||
endif()
|
||||
|
||||
# The QML module's URI becomes DashboardApplication's default module, so it
|
||||
# is written down only here (QML_URI).
|
||||
set(uri_source ${CMAKE_CURRENT_BINARY_DIR}/${target}_qml_module.cpp)
|
||||
file(GENERATE OUTPUT ${uri_source} CONTENT
|
||||
"// Generated by vrdash_add_dashboard() (cmake/Dashboard.cmake).
|
||||
#include \"app/DashboardApplication.h\"
|
||||
|
||||
namespace {
|
||||
[[maybe_unused]] const bool registered =
|
||||
(DashboardApplication::setDefaultQmlModule(\"${arg_QML_URI}\"), true);
|
||||
} // namespace
|
||||
")
|
||||
|
||||
qt_add_executable(${target} ${arg_SOURCES} ${uri_source})
|
||||
set_target_properties(${target} PROPERTIES OUTPUT_NAME ${arg_OUTPUT_NAME})
|
||||
target_link_libraries(${target} PRIVATE vrdashboard_core)
|
||||
target_compile_options(${target} PRIVATE -Wall -Wextra)
|
||||
|
||||
if(VRDASH_QML_BYTECODE_ONLY)
|
||||
# Must be set before qt_add_qml_module() reads it.
|
||||
set_target_properties(${target} PROPERTIES QT_QMLCACHEGEN_ARGUMENTS --only-bytecode)
|
||||
endif()
|
||||
qt_add_qml_module(${target}
|
||||
URI ${arg_QML_URI}
|
||||
VERSION 1.0
|
||||
SOURCES ${arg_QML_SOURCES}
|
||||
QML_FILES ${arg_QML_FILES})
|
||||
|
||||
get_target_property(_qt_core_type Qt6::Core TYPE)
|
||||
if(_qt_core_type STREQUAL "SHARED_LIBRARY")
|
||||
# The import scan links QML plugins statically and is only meant for static Qt;
|
||||
# with Debian's shared Qt it misfires (warnings). Shared Qt loads them at runtime.
|
||||
set_target_properties(${target} PROPERTIES QT_QML_MODULE_NO_IMPORT_SCAN ON)
|
||||
endif()
|
||||
|
||||
# libopenvr_api.so is not a system library: <prefix>/lib, found via RPATH.
|
||||
add_custom_command(TARGET ${target} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR}
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${OPENVR_LIBRARY}
|
||||
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR}/
|
||||
VERBATIM)
|
||||
|
||||
install(TARGETS ${target} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||
# For vrdash_bundle_qt(), which bundles for all dashboards at once.
|
||||
set_property(GLOBAL APPEND PROPERTY VRDASH_DASHBOARD_TARGETS ${target})
|
||||
set_property(GLOBAL APPEND PROPERTY VRDASH_DASHBOARD_QML_DIRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/qml)
|
||||
endfunction()
|
||||
@@ -0,0 +1,47 @@
|
||||
# Provides the imported target OpenVR::OpenVR.
|
||||
#
|
||||
# Either point OPENVR_ROOT at an OpenVR SDK checkout (needs headers/ and bin/<platform>/),
|
||||
# or leave it empty and the required files of OPENVR_VERSION are downloaded once into
|
||||
# third_party/openvr-<version>/ (header + Linux libraries only, not the whole SDK).
|
||||
|
||||
set(OPENVR_ROOT "" CACHE PATH "Path to an OpenVR SDK (leave empty to auto-download)")
|
||||
|
||||
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
|
||||
set(_openvr_platform linuxarm64)
|
||||
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64|amd64)$")
|
||||
set(_openvr_platform linux64)
|
||||
else()
|
||||
message(FATAL_ERROR "Unsupported target processor for OpenVR: ${CMAKE_SYSTEM_PROCESSOR}")
|
||||
endif()
|
||||
|
||||
if(NOT OPENVR_ROOT)
|
||||
set(OPENVR_ROOT ${CMAKE_SOURCE_DIR}/third_party/openvr-${OPENVR_VERSION})
|
||||
set(_base "https://raw.githubusercontent.com/ValveSoftware/openvr/v${OPENVR_VERSION}")
|
||||
foreach(_file headers/openvr.h LICENSE bin/${_openvr_platform}/libopenvr_api.so)
|
||||
if(NOT EXISTS ${OPENVR_ROOT}/${_file})
|
||||
message(STATUS "Downloading OpenVR ${OPENVR_VERSION}: ${_file}")
|
||||
file(DOWNLOAD ${_base}/${_file} ${OPENVR_ROOT}/${_file}.part STATUS _status TLS_VERIFY ON)
|
||||
list(GET _status 0 _code)
|
||||
if(NOT _code EQUAL 0)
|
||||
file(REMOVE ${OPENVR_ROOT}/${_file}.part)
|
||||
message(FATAL_ERROR "Download of ${_base}/${_file} failed: ${_status}\n"
|
||||
"Set OPENVR_ROOT to a local OpenVR SDK to build offline.")
|
||||
endif()
|
||||
file(RENAME ${OPENVR_ROOT}/${_file}.part ${OPENVR_ROOT}/${_file})
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
set(_openvr_lib ${OPENVR_ROOT}/bin/${_openvr_platform}/libopenvr_api.so)
|
||||
if(NOT EXISTS ${OPENVR_ROOT}/headers/openvr.h OR NOT EXISTS ${_openvr_lib})
|
||||
message(FATAL_ERROR "OPENVR_ROOT=${OPENVR_ROOT} lacks headers/openvr.h or ${_openvr_lib}")
|
||||
endif()
|
||||
|
||||
add_library(OpenVR::OpenVR SHARED IMPORTED GLOBAL)
|
||||
set_target_properties(OpenVR::OpenVR PROPERTIES
|
||||
IMPORTED_LOCATION ${_openvr_lib}
|
||||
IMPORTED_NO_SONAME TRUE
|
||||
INTERFACE_INCLUDE_DIRECTORIES ${OPENVR_ROOT}/headers)
|
||||
set(OPENVR_LIBRARY ${_openvr_lib})
|
||||
set(OPENVR_LICENSE ${OPENVR_ROOT}/LICENSE)
|
||||
message(STATUS "OpenVR: ${OPENVR_ROOT} (${_openvr_platform})")
|
||||
@@ -0,0 +1,166 @@
|
||||
# Generated from cmake/bundle-install.cmake.in by vrdash_bundle_qt(). Runs at install time,
|
||||
# once for all dashboard executables.
|
||||
cmake_policy(VERSION 3.22)
|
||||
|
||||
get_filename_component(prefix "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}" ABSOLUTE)
|
||||
set(executables "")
|
||||
foreach(name @executables@)
|
||||
list(APPEND executables "${prefix}/@CMAKE_INSTALL_BINDIR@/${name}")
|
||||
endforeach()
|
||||
set(libdir "${prefix}/@CMAKE_INSTALL_LIBDIR@")
|
||||
set(plugindir "${prefix}/plugins")
|
||||
set(qmldir "${prefix}/qml")
|
||||
set(qt_plugins "@QT6_INSTALL_PREFIX@/@QT6_INSTALL_PLUGINS@")
|
||||
set(qt_qml "@QT6_INSTALL_PREFIX@/@QT6_INSTALL_QML@")
|
||||
set(patchelf "@VRDASH_PATCHELF@")
|
||||
set(exclude "@bundle_exclude@")
|
||||
|
||||
message(STATUS "Bundling Qt into ${prefix}")
|
||||
|
||||
# --- QML modules the UI imports (transitively) -------------------------------------------
|
||||
set(modules "")
|
||||
foreach(root @qml_dirs@)
|
||||
execute_process(
|
||||
COMMAND "$<TARGET_FILE:Qt6::qmlimportscanner>" -rootPath "${root}" -importPath "${qt_qml}"
|
||||
OUTPUT_VARIABLE imports_json
|
||||
RESULT_VARIABLE result)
|
||||
if(NOT result EQUAL 0)
|
||||
message(FATAL_ERROR "qmlimportscanner failed on ${root} (${result})")
|
||||
endif()
|
||||
string(JSON count LENGTH "${imports_json}")
|
||||
if(count EQUAL 0)
|
||||
continue()
|
||||
endif()
|
||||
math(EXPR last "${count} - 1")
|
||||
foreach(i RANGE ${last})
|
||||
string(JSON type ERROR_VARIABLE err GET "${imports_json}" ${i} type)
|
||||
string(JSON path ERROR_VARIABLE err GET "${imports_json}" ${i} path)
|
||||
if(type STREQUAL "module" AND path AND path MATCHES "^${qt_qml}/")
|
||||
list(APPEND modules "${path}")
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
list(REMOVE_DUPLICATES modules)
|
||||
|
||||
set(qml_plugins "")
|
||||
foreach(module IN LISTS modules)
|
||||
file(RELATIVE_PATH rel "${qt_qml}" "${module}")
|
||||
# Only the module's own files; submodules are listed separately if they are needed.
|
||||
file(GLOB files LIST_DIRECTORIES false "${module}/*")
|
||||
file(INSTALL ${files} DESTINATION "${qmldir}/${rel}" FOLLOW_SYMLINK_CHAIN)
|
||||
file(GLOB so_files "${qmldir}/${rel}/*.so")
|
||||
list(APPEND qml_plugins ${so_files})
|
||||
endforeach()
|
||||
|
||||
# --- Qt plugins --------------------------------------------------------------------------
|
||||
set(qt_plugin_files "")
|
||||
foreach(pattern @VRDASH_BUNDLE_PLUGINS@)
|
||||
file(GLOB files "${qt_plugins}/${pattern}")
|
||||
foreach(file IN LISTS files)
|
||||
file(RELATIVE_PATH rel "${qt_plugins}" "${file}")
|
||||
get_filename_component(dir "${rel}" DIRECTORY)
|
||||
file(INSTALL "${file}" DESTINATION "${plugindir}/${dir}")
|
||||
list(APPEND qt_plugin_files "${plugindir}/${rel}")
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
# --- Shared libraries needed by executable + plugins --------------------------------------
|
||||
set(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM "linux+elf")
|
||||
set(CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL "objdump")
|
||||
set(CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND "@objdump@")
|
||||
|
||||
file(GET_RUNTIME_DEPENDENCIES
|
||||
EXECUTABLES ${executables}
|
||||
MODULES ${qt_plugin_files} ${qml_plugins}
|
||||
DIRECTORIES "@qt_libdir@" "${libdir}"
|
||||
PRE_EXCLUDE_REGEXES ${exclude} # matched against DT_NEEDED names
|
||||
POST_EXCLUDE_REGEXES "^${libdir}/"
|
||||
RESOLVED_DEPENDENCIES_VAR resolved
|
||||
UNRESOLVED_DEPENDENCIES_VAR unresolved)
|
||||
|
||||
if(unresolved)
|
||||
message(WARNING "Unresolved (expected on target system): ${unresolved}")
|
||||
endif()
|
||||
file(INSTALL ${resolved} DESTINATION "${libdir}" FOLLOW_SYMLINK_CHAIN)
|
||||
|
||||
# --- Relative RPATHs ------------------------------------------------------------------------
|
||||
# DT_RUNPATH of the executable does not apply to transitive dependencies, so every bundled
|
||||
# ELF file needs its own path back to lib/.
|
||||
function(set_rpath file rpath)
|
||||
execute_process(COMMAND "${patchelf}" --set-rpath "${rpath}" "${file}" RESULT_VARIABLE rc)
|
||||
if(NOT rc EQUAL 0)
|
||||
message(FATAL_ERROR "patchelf failed on ${file}")
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
file(GLOB bundled_libs "${libdir}/*.so*")
|
||||
foreach(lib IN LISTS bundled_libs)
|
||||
if(NOT IS_SYMLINK "${lib}")
|
||||
set_rpath("${lib}" "\$ORIGIN")
|
||||
endif()
|
||||
endforeach()
|
||||
foreach(file IN LISTS qt_plugin_files qml_plugins)
|
||||
get_filename_component(dir "${file}" DIRECTORY)
|
||||
file(RELATIVE_PATH rel "${dir}" "${libdir}")
|
||||
set_rpath("${file}" "\$ORIGIN/${rel}")
|
||||
endforeach()
|
||||
|
||||
# --- License files of everything bundled ----------------------------------------------
|
||||
# Bundled libraries come from Debian packages: ship each package's copyright file and
|
||||
# the common license texts they reference (LGPL/GPL/...), as their licenses require.
|
||||
find_program(dpkg dpkg)
|
||||
if(dpkg)
|
||||
set(docdir "${prefix}/@CMAKE_INSTALL_DOCDIR@/third-party")
|
||||
set(packages "")
|
||||
foreach(file IN LISTS resolved qt_plugin_files qml_plugins)
|
||||
# Ask dpkg about the original file: map installed plugins back, resolve
|
||||
# symlinks and /lib -> /usr/lib (usrmerge: packages register /usr paths).
|
||||
string(REPLACE "${plugindir}" "${qt_plugins}" original "${file}")
|
||||
string(REPLACE "${qmldir}" "${qt_qml}" original "${original}")
|
||||
file(REAL_PATH "${original}" original)
|
||||
execute_process(COMMAND "${dpkg}" -S "${original}" OUTPUT_VARIABLE owner
|
||||
ERROR_QUIET OUTPUT_STRIP_TRAILING_WHITESPACE)
|
||||
string(REGEX REPLACE ":.*" "" owner "${owner}")
|
||||
if(owner)
|
||||
list(APPEND packages "${owner}")
|
||||
endif()
|
||||
endforeach()
|
||||
list(REMOVE_DUPLICATES packages)
|
||||
set(licenses "")
|
||||
foreach(package IN LISTS packages)
|
||||
set(copyright "/usr/share/doc/${package}/copyright")
|
||||
if(EXISTS "${copyright}")
|
||||
file(INSTALL "${copyright}" DESTINATION "${docdir}"
|
||||
RENAME "${package}.copyright" FOLLOW_SYMLINK_CHAIN)
|
||||
file(STRINGS "${copyright}" refs REGEX "/usr/share/common-licenses/[A-Za-z0-9.+-]+")
|
||||
foreach(ref IN LISTS refs)
|
||||
string(REGEX MATCHALL "/usr/share/common-licenses/[A-Za-z0-9.+-]+" paths "${ref}")
|
||||
list(APPEND licenses ${paths})
|
||||
endforeach()
|
||||
endif()
|
||||
endforeach()
|
||||
list(REMOVE_DUPLICATES licenses)
|
||||
foreach(license IN LISTS licenses)
|
||||
string(REGEX REPLACE "[.]$" "" license "${license}")
|
||||
if(EXISTS "${license}")
|
||||
file(INSTALL "${license}" DESTINATION "${docdir}/common-licenses" FOLLOW_SYMLINK_CHAIN)
|
||||
endif()
|
||||
endforeach()
|
||||
list(LENGTH packages n_packages)
|
||||
message(STATUS "Collected copyright files of ${n_packages} Debian packages")
|
||||
else()
|
||||
message(WARNING "dpkg not found: license files of bundled libraries not collected")
|
||||
endif()
|
||||
|
||||
file(WRITE "${prefix}/@CMAKE_INSTALL_BINDIR@/qt.conf"
|
||||
"[Paths]
|
||||
Prefix = ..
|
||||
Libraries = @CMAKE_INSTALL_LIBDIR@
|
||||
Plugins = plugins
|
||||
QmlImports = qml
|
||||
")
|
||||
|
||||
list(LENGTH resolved n_libs)
|
||||
list(LENGTH modules n_modules)
|
||||
list(LENGTH qt_plugin_files n_plugins)
|
||||
message(STATUS "Bundled ${n_libs} libraries, ${n_plugins} Qt plugins, ${n_modules} QML modules")
|
||||
@@ -0,0 +1,25 @@
|
||||
# Cross-compile for aarch64 Linux (Steam Frame) from an x86_64 Debian multiarch environment.
|
||||
# Used by the "cross-arm64" preset inside docker/cross-arm64.Dockerfile; see scripts/cross-build.sh.
|
||||
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR aarch64)
|
||||
|
||||
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
|
||||
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
|
||||
|
||||
# Debian multiarch: target libraries live in /usr/lib/aarch64-linux-gnu; headers are shared.
|
||||
set(CMAKE_LIBRARY_ARCHITECTURE aarch64-linux-gnu)
|
||||
set(CMAKE_FIND_ROOT_PATH /usr/aarch64-linux-gnu)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
|
||||
set(ENV{PKG_CONFIG_LIBDIR} /usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig)
|
||||
|
||||
# Qt 6 cross builds need a host Qt for its code generators (moc, rcc, qmlcachegen, …).
|
||||
set(QT_HOST_PATH /usr CACHE PATH "Host Qt prefix")
|
||||
set(QT_HOST_PATH_CMAKE_DIR /usr/lib/x86_64-linux-gnu/cmake CACHE PATH "Host Qt CMake dir")
|
||||
set(CMAKE_PREFIX_PATH /usr/lib/aarch64-linux-gnu/cmake)
|
||||
|
||||
# Lets CMake/Qt deploy tooling inspect target binaries.
|
||||
set(CMAKE_OBJDUMP aarch64-linux-gnu-objdump)
|
||||
@@ -0,0 +1,20 @@
|
||||
# Cross-compilation environment: x86_64 host tools + aarch64 target libraries via Debian
|
||||
# multiarch. The compiler runs natively (no QEMU); Qt's host tools (moc, rcc, qmlcachegen …)
|
||||
# are the amd64 builds, target Qt libraries are the arm64 builds.
|
||||
FROM debian:trixie
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
RUN dpkg --add-architecture arm64 \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates cmake ninja-build pkgconf file patchelf \
|
||||
g++-aarch64-linux-gnu binutils-aarch64-linux-gnu \
|
||||
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-declarative-dev-tools \
|
||||
qt6-base-dev:arm64 qt6-declarative-dev:arm64 \
|
||||
libgl-dev:arm64 libegl-dev:arm64 libvulkan-dev:arm64 libxkbcommon-dev:arm64 \
|
||||
qml6-module-qtquick:arm64 qml6-module-qtquick-controls:arm64 \
|
||||
qml6-module-qtquick-layouts:arm64 qml6-module-qtquick-templates:arm64 \
|
||||
qml6-module-qtqml-workerscript:arm64 qml6-module-qtquick-window:arm64 \
|
||||
qt6-qpa-plugins:arm64 qt6-wayland:arm64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,16 @@
|
||||
# Native build environment (any architecture): builds for the machine the container runs on.
|
||||
# On the Steam Frame (aarch64) this produces the same self-contained bundle as the x86_64 →
|
||||
# aarch64 cross build, without needing a cross toolchain.
|
||||
FROM debian:trixie
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates cmake ninja-build pkgconf file patchelf g++ \
|
||||
qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-declarative-dev-tools \
|
||||
libgl-dev libegl-dev libvulkan-dev libxkbcommon-dev \
|
||||
qml6-module-qtquick qml6-module-qtquick-controls qml6-module-qtquick-layouts \
|
||||
qml6-module-qtquick-templates qml6-module-qtqml-workerscript qml6-module-qtquick-window \
|
||||
qml6-module-qttest qt6-qpa-plugins qt6-wayland \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test environment on top of docker/native.Dockerfile: Mesa Vulkan/GL drivers (incl. the
|
||||
# lavapipe software rasterizer), Khronos validation layers and Xvfb. See scripts/container-test.sh.
|
||||
FROM frameassistant-build-native
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
mesa-vulkan-drivers vulkan-validationlayers vulkan-tools libgl1-mesa-dri \
|
||||
xvfb xauth fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,162 @@
|
||||
# Architecture
|
||||
|
||||
A dashboard is **one process**. It renders QML scenes off-screen into textures and hands
|
||||
them to SteamVR as overlays. Input flows the other way: SteamVR reports laser, scrolling,
|
||||
keyboard and controller actions, and the app turns them into ordinary Qt events. The QML
|
||||
never knows it is in VR, which is why it also runs in a plain desktop window (`--desktop`).
|
||||
|
||||
## Structure
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
app["<b>Your app</b><br/>class Dashboard : DashboardApplication<br/>members, loading() / started()"]
|
||||
subgraph base["DashboardApplication"]
|
||||
direction TB
|
||||
engine["QQmlEngine<br/>(shared by all surfaces)"]
|
||||
runtime["VrRuntime<br/>SteamVR connection"]
|
||||
nav["ControllerNavigation"]
|
||||
end
|
||||
subgraph surfaces["Surfaces: one per view"]
|
||||
direction TB
|
||||
s1["<b>Surface 'main'</b><br/>QML root → Renderer → RenderBackend<br/>DashboardOverlay + InputBridge"]
|
||||
s2["<b>Surface 'settings'</b> (optional)<br/>same parts"]
|
||||
end
|
||||
steamvr(["SteamVR"])
|
||||
app -- "inherits" --> base
|
||||
base -- "creates, drives" --> surfaces
|
||||
surfaces -- "textures" --> steamvr
|
||||
steamvr -- "laser, scroll, keyboard" --> surfaces
|
||||
steamvr -- "controller actions" --> nav
|
||||
nav -- "keys to the active surface" --> surfaces
|
||||
```
|
||||
|
||||
**`DashboardApplication`** (`src/app/DashboardApplication.*`) is the base class of every app.
|
||||
It owns everything that isn't app-specific: command line, logging, the SteamVR connection,
|
||||
choosing the render backend, the surfaces, controller navigation, the auxiliary modes, and
|
||||
the order in which all of that is created and destroyed.
|
||||
|
||||
Your app derives from it:
|
||||
- **Constructor:** configuration (`addSurface()`, optionally `setAppKey()`,
|
||||
`setDisplayName()`, `setQuickStyle()`, `setQmlModule()`).
|
||||
- **Hooks:**
|
||||
- `loading()`: the shared QML engine, the surfaces and their render backends exist; QML
|
||||
isn't loaded yet. Set up what the QML needs here.
|
||||
- `started()`: everything runs. Connect to surfaces and input here.
|
||||
- **Accessors:** `mode()`, `appKey()`, `appName()`, `engine()`, `surfaces()`, `surface()`
|
||||
(the first), `surface(id)`, `vrRuntime()`, `navigation()`.
|
||||
- **Lifetime:** the base constructor creates the `QGuiApplication`, so your members can be
|
||||
QObjects with timers. All QML and surfaces are torn down at the end of `exec()`, while your
|
||||
object and its members still exist.
|
||||
|
||||
**`Surface`** (`src/app/Surface.*`) is one view: a QML root, its renderer, its render backend
|
||||
and its overlay. It's a component you configure (`SurfaceConfig`: id, QML root type, tab name,
|
||||
texture size, icon) and connect to (`visibilityChanged`, `activated`), never subclass.
|
||||
- All surfaces share one QML engine, so models and singletons are visible in every view.
|
||||
- Overlay key: `<app key>.<id>`, or the app key itself for an empty id.
|
||||
- A surface renders only while its overlay is visible.
|
||||
|
||||
**Render layer** (`src/render/`):
|
||||
- **`OffscreenQuickRenderer`** drives Qt Quick through `QQuickRenderControl`, without a window.
|
||||
- It renders on demand (scene change or animation), capped at `--max-fps`, on an absolute
|
||||
schedule so slow frames don't lower the frame rate.
|
||||
- **`RenderBackend`** is the *only* place with graphics-API code: device setup, texture,
|
||||
hand-off description for SteamVR, readback.
|
||||
- **`VulkanRenderBackend`** (default): Qt creates the device on the GPU SteamVR names, with
|
||||
the extensions SteamVR requires. The backend owns the image and moves it to
|
||||
`TRANSFER_SRC_OPTIMAL` after each frame, the layout OpenVR wants, and back before the next.
|
||||
- **`OpenGLRenderBackend`** (fallback): on the Frame it runs through zink on Vulkan.
|
||||
|
||||
**VR layer** (`src/vr/`):
|
||||
- **`VrRuntime`**: `VR_Init` as overlay app, polls SteamVR events and routes them by overlay
|
||||
handle. It also reports SteamVR's Vulkan requirements, and registers and launches manifests.
|
||||
- **`Overlay`**: base of all overlays. It handles input routing, visibility and frame
|
||||
submission.
|
||||
- **`DashboardOverlay`** is the dashboard tab with its icon. A world overlay would be
|
||||
another subclass (planned).
|
||||
|
||||
**Input layer** (`src/input/`):
|
||||
- **`OverlayInputBridge`** (one per overlay):
|
||||
- **Laser:** turned into mouse events, with the y-axis flipped (OpenVR counts from the
|
||||
bottom).
|
||||
- **Scrolling:** thumbstick scrolling becomes wheel events.
|
||||
- **Keyboard:** opens the SteamVR keyboard when a text field gets focus, and turns typed
|
||||
characters into key events.
|
||||
- **`ControllerNavigation`** (one per app): SteamVR Input actions (`resources/input/`, rebindable
|
||||
in SteamVR) become Tab/arrow/Space/Escape for the *active* surface, i.e. the one last opened
|
||||
or pointed at. Qt Quick's own focus handling does the rest.
|
||||
|
||||
## One frame
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant QML as QML scene
|
||||
participant R as OffscreenQuickRenderer
|
||||
participant B as RenderBackend (Vulkan)
|
||||
participant O as Overlay
|
||||
participant S as SteamVR
|
||||
QML->>R: scene changed / animation tick
|
||||
Note over R: wait for the next slot (≤ max-fps)
|
||||
R->>B: beginFrame (image back to COLOR_ATTACHMENT)
|
||||
R->>R: polish, sync, render (Qt Quick)
|
||||
R->>B: finishFrame (image → TRANSFER_SRC, GPU done)
|
||||
R->>O: frame sink: texture + bounds
|
||||
O->>S: SetOverlayTexture (SteamVR copies it)
|
||||
```
|
||||
|
||||
## Input
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as SteamVR
|
||||
participant V as VrRuntime
|
||||
participant I as OverlayInputBridge
|
||||
participant Q as QML (QQuickWindow)
|
||||
S->>V: overlay event (mouse, scroll, keyboard)
|
||||
V->>I: routed by overlay handle
|
||||
I->>Q: QMouseEvent / QWheelEvent / QKeyEvent
|
||||
Q-->>I: text field got focus
|
||||
I->>S: ShowKeyboardForOverlay
|
||||
```
|
||||
|
||||
## Start and shutdown (overlay mode)
|
||||
|
||||
1. **Start:**
|
||||
1. Connect to SteamVR (`VR_Init`).
|
||||
2. Create one render backend per surface. The first surface decides the API, including a
|
||||
fallback to OpenGL; the others use the same API, because Qt's graphics API is
|
||||
process-wide.
|
||||
3. Call `loading()`.
|
||||
4. Load each surface's QML.
|
||||
5. Create the overlays.
|
||||
6. Set up controller navigation.
|
||||
7. Call `started()`, then run the event loop.
|
||||
2. **Shutdown runs in reverse**, with one rule found on the Frame: **overlays first, then
|
||||
SteamVR, then the graphics devices.** OpenVR requires shutting down before the devices it
|
||||
got textures from are destroyed. Otherwise its client library frees objects on a dead
|
||||
`VkDevice` at exit, which crashed on the Frame. `runOverlay()` encodes this order through
|
||||
the declaration order of its objects.
|
||||
|
||||
## Building blocks in CMake
|
||||
|
||||
- **`vrdash_add_dashboard()`** (`cmake/Dashboard.cmake`) turns sources plus QML files into a
|
||||
dashboard executable. It covers:
|
||||
- the QML module, whose `QML_URI` becomes the app's default module through a generated
|
||||
source file
|
||||
- bytecode-only QML for system-Qt builds
|
||||
- `libopenvr_api.so` next to the executable
|
||||
- install rules and registration for Qt bundling
|
||||
- the check against SteamVR process names
|
||||
- **App identity** (root `CMakeLists.txt`): `project()` name and version, `VRDASH_APP_KEY`,
|
||||
`VRDASH_DISPLAY_NAME`, `VRDASH_EXECUTABLE_NAME`, texture size. CMake generates from them
|
||||
`AppConfig.h` and the SteamVR manifest, with `binary_path_linux_arm` for ARM builds and
|
||||
`binary_path_linux` for x86_64.
|
||||
|
||||
## Planned: world overlays
|
||||
|
||||
For e.g. a notification app: a `WorldOverlay` (another `Overlay` subclass, created with
|
||||
`CreateOverlay`) that the app shows and hides itself.
|
||||
- **Placement modes:** room, HUD, hand, "spawn in front, then stay".
|
||||
- **Optionally operable:** while visible, it takes the system laser
|
||||
(`VROverlayFlags_MakeOverlaysInteractiveIfVisible`).
|
||||
|
||||
The surface structure is ready for it: `SurfaceConfig` would gain a kind and a placement.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Steam Frame
|
||||
|
||||
How to build, deploy and run dashboards on the Steam Frame, and what was learned on the
|
||||
device. Facts below were verified on a Frame with SteamOS `holo` 0.4.3 (system Qt 6.8.0,
|
||||
Adreno 750 GPU with Mesa Turnip).
|
||||
|
||||
## Building
|
||||
|
||||
Frame builds run in a Debian container (Docker or Podman), either cross-compiled on an x86_64
|
||||
PC (no emulation) or natively on the Frame:
|
||||
|
||||
```sh
|
||||
scripts/container-build.sh cross # on the PC → build/cross-arm64/stage
|
||||
scripts/container-build.sh native # on the Frame itself (podman/distrobox)
|
||||
scripts/container-build.sh cross-bundle # fallback variant, Qt bundled
|
||||
scripts/container-build.sh cross shell # a shell in the build container
|
||||
```
|
||||
|
||||
**System Qt (default).** Builds use SteamOS's own Qt; packages are about 3 MB.
|
||||
- The container builds against Debian's Qt 6.8.2; at runtime the Frame's Qt 6.8.0 is used.
|
||||
Qt keeps binary compatibility within 6.8, as long as the program uses no Qt-internal API.
|
||||
- **QML is compiled to bytecode only** (`VRDASH_QML_BYTECODE_ONLY`), because Qt's QML-to-C++
|
||||
compiler output is tied to the exact Qt version.
|
||||
- The binary then needs only the stable Qt API, `qt_version_tag` (Qt ≥ 6.8), and Qt's Vulkan
|
||||
wrapper functions. Verified with an Arch container frozen at Qt 6.8.0 and on the Frame.
|
||||
- After a SteamOS update with a different Qt, rebuild, or use the bundled variant.
|
||||
|
||||
**Bundled Qt (fallback, `-bundle` modes, about 46 MB).**
|
||||
- Qt, its plugins and QML modules, and their non-system dependencies are copied next to the
|
||||
program (`cmake/BundleQt.cmake`).
|
||||
- The GPU and display stack (GL/EGL, Vulkan loader, Wayland, X11, libffi, glibc) always comes
|
||||
from SteamOS; bundling it would break GPU access.
|
||||
- The Debian copyright files of all bundled libraries are included, as their licenses require.
|
||||
|
||||
## Packaging and deploying
|
||||
|
||||
```sh
|
||||
scripts/package-release.sh # → dist/<exe>-<version>-linux-arm64/ + .tar.gz
|
||||
scripts/package-release.sh --bundle-qt # → …-linux-arm64-bundled
|
||||
scripts/deploy-to-frame.sh steamos@<frame> # rsync the newest package to ~/<exe>, run install.sh
|
||||
```
|
||||
|
||||
A release folder contains the program, `install.sh` (install or update: stops a running
|
||||
instance, registers with SteamVR, starts it), `uninstall.sh`, `README.txt` and the licenses.
|
||||
SteamVR stores the folder's absolute path, so after moving the folder, run `install.sh`
|
||||
again.
|
||||
|
||||
Without the deploy script: copy the `.tar.gz`, unpack it on the Frame, run `./install.sh`
|
||||
(SteamVR must be running). Over SSH this works too: SteamVR starts the dashboard itself.
|
||||
|
||||
## What the Frame requires (all handled by the overlay platform)
|
||||
|
||||
| Finding | Consequence |
|
||||
|---------|-------------|
|
||||
| SteamVR is installed system-wide under `/opt/steamvr`; its log is `~/.local/share/Steam/logs/vrserver.txt` | Look there when a manifest or start fails |
|
||||
| The manifest needs **`binary_path_linux_arm`**; entries with only `binary_path_linux` are silently skipped ("must specify binary_path") | The manifest template picks the key from the target architecture |
|
||||
| SteamVR refuses **`VRApplication_Utility`** (`VRInitError_Init_NotAvailableToUtilityApps`) | Manifest commands connect as `VRApplication_Background` (needs a running SteamVR, never starts one) |
|
||||
| An executable named **`vrdashboard`** is taken for SteamVR's own dashboard (its watchdog then "restarts" it) | Executable `frame-assistant`; CMake rejects SteamVR process names |
|
||||
| **OpenGL runs through zink** on Vulkan (no native GL driver for the Adreno) | Vulkan is the default: 1.01 ms vs 1.89 ms per frame (`--benchmark`, 1600×1000) |
|
||||
| OpenVR must shut down **before** the graphics device is destroyed | Teardown order in `DashboardApplication::runOverlay()` (else a crash at exit with Vulkan) |
|
||||
| Qt needs a display connection even for off-screen rendering; over SSH none is set | SteamVR-launched instances inherit it; over SSH, set `DISPLAY=:0` |
|
||||
| Process names are cut to 15 characters | Scripts find running instances by command line, not by name |
|
||||
| Floated/pinned dashboard tabs stay visible and operable in games | Rendering follows the overlay's visibility, not the dashboard's |
|
||||
|
||||
## Notes
|
||||
|
||||
- **The Frame's package repositories** in `/etc/pacman.conf` are private (URLs marked
|
||||
"DO_NOT_SHARE"). Don't put them into scripts or this repository.
|
||||
- **Valve's public Holo Core preview container** (`registry.gitlab.steamos.cloud/holo/
|
||||
holo-core-aarch64-preview/base-devel`) ships Qt 6.10, newer than the Frame's 6.8. It is not
|
||||
usable for system-Qt builds.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Runtime data for SteamVR. Staged into <build>/share/<project>/ (mirroring the install
|
||||
# layout) so the binary can be run and registered straight from the build tree.
|
||||
|
||||
set(_share ${CMAKE_BINARY_DIR}/${VRDASH_SHARE_SUBDIR})
|
||||
|
||||
# Paths inside the manifest are relative to the manifest file itself. SteamVR reads the
|
||||
# program path from a per-platform key and silently skips entries without one for its
|
||||
# platform: binary_path_linux on x86-64, binary_path_linux_arm on aarch64 (Steam Frame).
|
||||
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
|
||||
set(VRDASH_MANIFEST_BINARY_KEY binary_path_linux_arm)
|
||||
else()
|
||||
set(VRDASH_MANIFEST_BINARY_KEY binary_path_linux)
|
||||
endif()
|
||||
configure_file(manifest.vrmanifest.in ${_share}/manifest.vrmanifest @ONLY)
|
||||
|
||||
set(_input_files input/actions.json input/bindings_frame_controller.json)
|
||||
foreach(_file ${_input_files})
|
||||
configure_file(${_file} ${_share}/${_file} COPYONLY)
|
||||
endforeach()
|
||||
|
||||
install(FILES ${_share}/manifest.vrmanifest DESTINATION ${VRDASH_SHARE_SUBDIR})
|
||||
install(FILES ${_input_files} DESTINATION ${VRDASH_SHARE_SUBDIR}/input)
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"default_bindings": [
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"binding_url": "bindings_frame_controller.json"
|
||||
}
|
||||
],
|
||||
"action_sets": [
|
||||
{ "name": "/actions/dashboard", "usage": "single" }
|
||||
],
|
||||
"actions": [
|
||||
{ "name": "/actions/dashboard/in/focus_next", "type": "boolean" },
|
||||
{ "name": "/actions/dashboard/in/focus_previous", "type": "boolean" },
|
||||
{ "name": "/actions/dashboard/in/increase", "type": "boolean" },
|
||||
{ "name": "/actions/dashboard/in/decrease", "type": "boolean" },
|
||||
{ "name": "/actions/dashboard/in/activate", "type": "boolean" },
|
||||
{ "name": "/actions/dashboard/in/back", "type": "boolean" }
|
||||
],
|
||||
"localization": [
|
||||
{
|
||||
"language_tag": "en_US",
|
||||
"/actions/dashboard": "Dashboard navigation",
|
||||
"/actions/dashboard/in/focus_next": "Focus next control",
|
||||
"/actions/dashboard/in/focus_previous": "Focus previous control",
|
||||
"/actions/dashboard/in/increase": "Increase / move right",
|
||||
"/actions/dashboard/in/decrease": "Decrease / move left",
|
||||
"/actions/dashboard/in/activate": "Activate focused control",
|
||||
"/actions/dashboard/in/back": "Back / cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"action_manifest_version": 0,
|
||||
"controller_type": "frame_controller",
|
||||
"name": "Default Steam Frame bindings",
|
||||
"description": "Left D-pad navigates focus and adjusts values, right A activates, right B goes back.",
|
||||
"bindings": {
|
||||
"/actions/dashboard": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_down",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/dashboard/in/focus_next" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_up",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/dashboard/in/focus_previous" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_right",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/dashboard/in/increase" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/dpad_left",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/dashboard/in/decrease" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/a",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/dashboard/in/activate" } }
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/b",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/dashboard/in/back" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"applications": [
|
||||
{
|
||||
"app_key": "@VRDASH_APP_KEY@",
|
||||
"launch_type": "binary",
|
||||
"@VRDASH_MANIFEST_BINARY_KEY@": "../../@CMAKE_INSTALL_BINDIR@/@VRDASH_EXECUTABLE_NAME@",
|
||||
"is_dashboard_overlay": true,
|
||||
"strings": {
|
||||
"en_us": {
|
||||
"name": "@VRDASH_DISPLAY_NAME@",
|
||||
"description": "@VRDASH_DISPLAY_NAME@ dashboard overlay"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the app inside a Debian container and stages an install in build/<preset>/stage,
|
||||
# ready to copy to the Steam Frame.
|
||||
#
|
||||
# scripts/container-build.sh cross x86_64 host → aarch64, uses the target's
|
||||
# system Qt 6.8.x (default way to deploy)
|
||||
# scripts/container-build.sh native same for the host's own architecture
|
||||
# (run on the Frame for a native arm64 build)
|
||||
# scripts/container-build.sh cross-bundle / native-bundle
|
||||
# self-contained: Qt bundled (fallback if the
|
||||
# target's Qt doesn't fit)
|
||||
# scripts/container-build.sh <mode> shell interactive shell in that container
|
||||
#
|
||||
# Uses podman if available, else docker (override with CONTAINER_ENGINE).
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
mode="${1:-}"
|
||||
case "$mode" in
|
||||
cross) dockerfile=cross-arm64.Dockerfile; preset=cross-arm64 ;;
|
||||
native) dockerfile=native.Dockerfile; preset=native-systemqt ;;
|
||||
cross-bundle) dockerfile=cross-arm64.Dockerfile; preset=cross-arm64-bundle ;;
|
||||
native-bundle) dockerfile=native.Dockerfile; preset=native-bundle ;;
|
||||
*) echo "usage: $0 cross|native|cross-bundle|native-bundle [shell]" >&2; exit 2 ;;
|
||||
esac
|
||||
image="frameassistant-build-${dockerfile%.Dockerfile}"
|
||||
engine="${CONTAINER_ENGINE:-$(command -v podman || command -v docker)}"
|
||||
|
||||
"$engine" build -q -t "$image" -f "$root/docker/$dockerfile" "$root/docker" >/dev/null
|
||||
|
||||
run=("$engine" run --rm -v "$root:$root:z" -w "$root")
|
||||
if [[ "$(basename "$engine")" == docker ]]; then
|
||||
run+=(--user "$(id -u):$(id -g)")
|
||||
else
|
||||
run+=(--userns=keep-id)
|
||||
fi
|
||||
[[ -t 0 ]] && run+=(-it)
|
||||
|
||||
if [[ "${2:-}" == shell ]]; then
|
||||
exec "${run[@]}" "$image" bash
|
||||
fi
|
||||
|
||||
"${run[@]}" "$image" bash -c "
|
||||
set -e
|
||||
cmake --preset $preset
|
||||
cmake --build --preset $preset
|
||||
rm -rf build/$preset/stage
|
||||
cmake --install build/$preset --prefix build/$preset/stage
|
||||
"
|
||||
echo "Staged: $root/build/$preset/stage"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds and runs all tests in a Debian container with BOTH render backends, Vulkan with the
|
||||
# Khronos validation layers enabled. Fails on any validation message.
|
||||
#
|
||||
# scripts/container-test.sh GPU via /dev/dri if available, otherwise software
|
||||
# VK_DRIVER=lvp scripts/container-test.sh force lavapipe (software Vulkan)
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
engine="${CONTAINER_ENGINE:-$(command -v podman || command -v docker)}"
|
||||
"$engine" build -q -t frameassistant-build-native -f "$root/docker/native.Dockerfile" "$root/docker" >/dev/null
|
||||
"$engine" build -q -t frameassistant-test -f "$root/docker/test.Dockerfile" "$root/docker" >/dev/null
|
||||
|
||||
run=("$engine" run --rm -v "$root:$root:z" -w "$root" -e HOME=/tmp -e "VK_DRIVER=${VK_DRIVER:-}")
|
||||
if [[ "$(basename "$engine")" == docker ]]; then
|
||||
run+=(--user "$(id -u):$(id -g)")
|
||||
else
|
||||
run+=(--userns=keep-id)
|
||||
fi
|
||||
[[ -e /dev/dri/renderD128 ]] && run+=(--device /dev/dri/renderD128)
|
||||
|
||||
"${run[@]}" frameassistant-test bash -c '
|
||||
set -e
|
||||
cmake -S . -B build/container-test -G Ninja -DCMAKE_BUILD_TYPE=Debug -DVRDASH_VULKAN=ON >/dev/null
|
||||
cmake --build build/container-test
|
||||
if [[ -n "$VK_DRIVER" ]]; then export VK_DRIVER_FILES=/usr/share/vulkan/icd.d/${VK_DRIVER}_icd.json; fi
|
||||
vulkaninfo --summary 2>/dev/null | grep -m1 deviceName || true
|
||||
Xvfb :99 >/dev/null 2>&1 &
|
||||
sleep 1
|
||||
export DISPLAY=:99 VRDASH_VULKAN_VALIDATION=1
|
||||
ctest --test-dir build/container-test --output-on-failure -V > build/container-test/ctest.log 2>&1 \
|
||||
|| { tail -40 build/container-test/ctest.log; exit 1; }
|
||||
grep -E "tests passed|Test +#" build/container-test/ctest.log
|
||||
messages=$(grep -oE "(VUID|UNASSIGNED)-[A-Za-z0-9_.-]+" build/container-test/ctest.log | sort | uniq -c || true)
|
||||
if [[ -n "$messages" ]]; then
|
||||
echo "Vulkan validation messages (see build/container-test/ctest.log):"
|
||||
echo "$messages"
|
||||
exit 1
|
||||
fi
|
||||
echo "No Vulkan validation messages."
|
||||
'
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copies a release package to the Steam Frame over SSH and installs it there (registers
|
||||
# it with SteamVR and starts it; an existing installation in the same place is updated).
|
||||
# SteamVR must be running on the Frame.
|
||||
#
|
||||
# scripts/deploy-to-frame.sh <ssh-target> [package-dir] [remote-dir]
|
||||
# e.g. scripts/deploy-to-frame.sh steamos@frame
|
||||
#
|
||||
# package-dir defaults to the newest dist/*-linux-arm64 package (scripts/package-release.sh);
|
||||
# remote-dir defaults to the package's base name in the remote home directory.
|
||||
set -euo pipefail
|
||||
|
||||
target="${1:?usage: $0 <ssh-target> [package-dir] [remote-dir]}"
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
package="${2:-$(ls -dt "$root"/dist/*-linux-arm64 "$root"/dist/*-linux-arm64-bundled 2>/dev/null | head -1)}"
|
||||
[[ -n "$package" && -x "$package/install.sh" ]] || {
|
||||
echo "No release package found (run scripts/package-release.sh first)" >&2; exit 1; }
|
||||
# Without the version: updates go to the same place and replace the old version.
|
||||
remote="${3:-$(basename "$package" | sed -E 's/-[0-9][0-9.]*-linux-arm64(-bundled)?$//')}"
|
||||
|
||||
rsync -a --delete --exclude logs/ "$package/" "$target:$remote/"
|
||||
ssh "$target" "\$HOME/$remote/install.sh"
|
||||
echo "Installed $(basename "$package") to $target:~/$remote"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the dashboard for the Steam Frame (aarch64) and packages it for release:
|
||||
#
|
||||
# dist/<executable>-<version>-linux-arm64/ uses SteamOS's Qt (default, ~3 MB)
|
||||
# dist/<executable>-<version>-linux-arm64-bundled/ Qt bundled (--bundle-qt, ~46 MB)
|
||||
# (each also as .tar.gz)
|
||||
#
|
||||
# The package contains only the dashboard itself plus install.sh,
|
||||
# uninstall.sh and a README. Installing again over an existing installation updates it.
|
||||
#
|
||||
# scripts/package-release.sh [--bundle-qt] [--no-build]
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
build=1 bundle_qt=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) build=0 ;;
|
||||
--bundle-qt) bundle_qt=1 ;;
|
||||
*) echo "usage: $0 [--bundle-qt] [--no-build]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
if [[ "$(uname -m)" == aarch64 ]]; then mode=native; else mode=cross; fi
|
||||
if (( bundle_qt )); then
|
||||
mode+=-bundle
|
||||
[[ "$mode" == native-bundle ]] && preset=native-bundle || preset=cross-arm64-bundle
|
||||
else
|
||||
[[ "$mode" == native ]] && preset=native-systemqt || preset=cross-arm64
|
||||
fi
|
||||
|
||||
(( build )) && "$root/scripts/container-build.sh" "$mode"
|
||||
|
||||
cache="$root/build/$preset/CMakeCache.txt"
|
||||
setting() { sed -n "s/^$1:[A-Z]*=//p" "$cache"; }
|
||||
exe="$(setting VRDASH_EXECUTABLE_NAME)"
|
||||
name="$(setting VRDASH_DISPLAY_NAME)"
|
||||
key="$(setting VRDASH_APP_KEY)"
|
||||
version="$(setting CMAKE_PROJECT_VERSION)"
|
||||
project="$(setting CMAKE_PROJECT_NAME)"
|
||||
stage="$root/build/$preset/stage"
|
||||
[[ "$(file -b "$stage/bin/$exe")" == *aarch64* ]] || { echo "$stage/bin/$exe is not an aarch64 build" >&2; exit 1; }
|
||||
|
||||
package="$exe-$version-linux-arm64"
|
||||
(( bundle_qt )) && package+=-bundled
|
||||
out="$root/dist/$package"
|
||||
rm -rf "$out" "$out.tar.gz"
|
||||
mkdir -p "$out"
|
||||
cp -a "$stage/." "$out/"
|
||||
# Only the dashboard: drop any other executables.
|
||||
find "$out/bin" -maxdepth 1 -type f -perm -u+x ! -name "$exe" -delete
|
||||
cp "$root/LICENSE" "$out/"
|
||||
|
||||
cat > "$out/install.sh" <<EOF
|
||||
#!/bin/sh
|
||||
# Installs or updates $name: registers it with SteamVR (starts automatically with
|
||||
# SteamVR) and starts it now. SteamVR must be running. Works over SSH.
|
||||
cd "\$(dirname "\$0")" || exit 1
|
||||
mkdir -p logs
|
||||
# An update replaces the files of a running instance: stop it, SteamVR restarts it below.
|
||||
# (Match the command line: process names are cut to 15 characters.)
|
||||
pkill -f "/bin/$exe( |\\$)" 2>/dev/null && sleep 1
|
||||
exec ./bin/$exe --install-manifest --launch --log-file logs/install.log
|
||||
EOF
|
||||
|
||||
cat > "$out/uninstall.sh" <<EOF
|
||||
#!/bin/sh
|
||||
# Unregisters $name from SteamVR and stops it. The folder can be deleted afterwards.
|
||||
cd "\$(dirname "\$0")" || exit 1
|
||||
pkill -f "/bin/$exe( |\\$)" 2>/dev/null
|
||||
exec ./bin/$exe --uninstall-manifest --log-file logs/install.log
|
||||
EOF
|
||||
chmod +x "$out/install.sh" "$out/uninstall.sh"
|
||||
|
||||
{
|
||||
echo "$name $version (SteamVR dashboard overlay, app key $key)"
|
||||
echo
|
||||
if (( bundle_qt )); then
|
||||
echo "Self-contained build: Qt is included."
|
||||
else
|
||||
echo "Uses the system's Qt (SteamOS: Qt 6.8 or newer). If it fails to start after a"
|
||||
echo "SteamOS update, use the -bundled package instead."
|
||||
fi
|
||||
echo
|
||||
echo "Install / update: ./install.sh (SteamVR must be running)"
|
||||
echo " SteamVR remembers this folder's location: after moving it,"
|
||||
echo " run ./install.sh again."
|
||||
echo "Uninstall: ./uninstall.sh"
|
||||
echo "Logs: logs/install.log; run ./bin/$exe --help for options"
|
||||
echo
|
||||
echo "License: MIT (LICENSE). Third-party notices: share/doc/$project/."
|
||||
} > "$out/README.txt"
|
||||
|
||||
tar -C "$root/dist" -czf "$out.tar.gz" "$package"
|
||||
echo "Release folder: $out"
|
||||
echo "Archive: $out.tar.gz ($(du -h "$out.tar.gz" | cut -f1))"
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import QtQuick
|
||||
|
||||
Rectangle { color: "#0000ff" }
|
||||
@@ -0,0 +1,35 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
|
||||
# Unit tests run without SteamVR or a headset. They use the "offscreen" QPA platform;
|
||||
# tests that need a GPU context QSKIP themselves when none is available.
|
||||
|
||||
qt_add_executable(tst_inputbridge tst_inputbridge.cpp)
|
||||
target_link_libraries(tst_inputbridge PRIVATE vrdashboard_core Qt6::Test)
|
||||
add_test(NAME inputbridge COMMAND tst_inputbridge)
|
||||
|
||||
qt_add_executable(tst_render tst_render.cpp)
|
||||
target_link_libraries(tst_render PRIVATE vrdashboard_core Qt6::Test)
|
||||
qt_add_qml_module(tst_render
|
||||
URI RenderTest
|
||||
VERSION 1.0
|
||||
QML_FILES TwoTone.qml Spinner.qml)
|
||||
add_test(NAME render_opengl COMMAND tst_render)
|
||||
set_tests_properties(render_opengl PROPERTIES ENVIRONMENT "VRDASH_TEST_API=opengl")
|
||||
add_test(NAME render_vulkan COMMAND tst_render)
|
||||
set_tests_properties(render_vulkan PROPERTIES ENVIRONMENT "VRDASH_TEST_API=vulkan")
|
||||
|
||||
set_tests_properties(inputbridge PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# Two surfaces in one app (snapshot mode): set-up, hooks, rendering per surface.
|
||||
qt_add_executable(tst_surfaces tst_surfaces.cpp)
|
||||
target_link_libraries(tst_surfaces PRIVATE vrdashboard_core)
|
||||
qt_add_qml_module(tst_surfaces
|
||||
URI SurfacesTest
|
||||
VERSION 1.0
|
||||
QML_FILES Red.qml Blue.qml)
|
||||
add_test(NAME surfaces_opengl COMMAND tst_surfaces --graphics-api opengl)
|
||||
add_test(NAME surfaces_vulkan COMMAND tst_surfaces --graphics-api vulkan)
|
||||
add_test(NAME surfaces_cli_key COMMAND tst_surfaces --graphics-api opengl)
|
||||
set_tests_properties(surfaces_cli_key PROPERTIES ENVIRONMENT "VRDASH_TEST_CLI_KEY=1")
|
||||
set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
|
||||
PROPERTIES SKIP_RETURN_CODE 77)
|
||||
@@ -0,0 +1,3 @@
|
||||
import QtQuick
|
||||
|
||||
Rectangle { color: "#ff0000" }
|
||||
@@ -0,0 +1,10 @@
|
||||
import QtQuick
|
||||
|
||||
// Continuously animated scene: keeps the on-demand renderer busy (pacing test).
|
||||
Item {
|
||||
Rectangle {
|
||||
width: 16; height: 16
|
||||
color: "white"
|
||||
NumberAnimation on rotation { from: 0; to: 360; duration: 1000; loops: Animation.Infinite }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import QtQuick
|
||||
|
||||
// Top half red, bottom half blue: lets the render test check content and orientation.
|
||||
Item {
|
||||
Rectangle { width: parent.width; height: parent.height / 2; color: "#ff0000" }
|
||||
Rectangle { y: parent.height / 2; width: parent.width; height: parent.height / 2; color: "#0000ff" }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
#include <QFocusEvent>
|
||||
#include <QQmlComponent>
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickItem>
|
||||
#include <QQuickRenderControl>
|
||||
#include <QQuickWindow>
|
||||
#include <QtTest>
|
||||
|
||||
#include "input/OverlayInputBridge.h"
|
||||
|
||||
// Feeds synthetic SteamVR overlay events into OverlayInputBridge and checks
|
||||
// that the QML scene reacts. No graphics are initialized; event delivery
|
||||
// doesn't need them.
|
||||
class InputBridgeTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void init() {
|
||||
m_renderControl = std::make_unique<QQuickRenderControl>();
|
||||
m_window = std::make_unique<QQuickWindow>(m_renderControl.get());
|
||||
m_window->setGeometry(0, 0, kSize.width(), kSize.height());
|
||||
|
||||
QQmlComponent component(&m_engine);
|
||||
component.setData(R"(
|
||||
import QtQuick
|
||||
Item {
|
||||
width: 400; height: 300
|
||||
property int clicks: 0
|
||||
MouseArea {
|
||||
objectName: "top"; width: 400; height: 100
|
||||
onClicked: parent.clicks++
|
||||
}
|
||||
Flickable {
|
||||
objectName: "flick"; y: 100; width: 400; height: 100
|
||||
contentHeight: 1000
|
||||
}
|
||||
TextInput {
|
||||
objectName: "input"; y: 250; width: 400; height: 50
|
||||
}
|
||||
})",
|
||||
QUrl());
|
||||
m_root.reset(qobject_cast<QQuickItem*>(component.create()));
|
||||
QVERIFY2(m_root, qPrintable(component.errorString()));
|
||||
m_root->setParentItem(m_window->contentItem());
|
||||
|
||||
QFocusEvent focusIn(QEvent::FocusIn, Qt::ActiveWindowFocusReason);
|
||||
QCoreApplication::sendEvent(m_window.get(), &focusIn);
|
||||
|
||||
m_bridge =
|
||||
std::make_unique<OverlayInputBridge>(m_window.get(), kSize, kOverlay);
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
m_bridge.reset();
|
||||
m_root.reset();
|
||||
m_window.reset();
|
||||
m_renderControl.reset();
|
||||
}
|
||||
|
||||
void clickUsesGlCoordinates() {
|
||||
// GL y=250 (from bottom) is Qt y=50: inside the top MouseArea.
|
||||
click(200, 250);
|
||||
QCOMPARE(m_root->property("clicks").toInt(), 1);
|
||||
// GL y=50 is Qt y=250: below it, must not count.
|
||||
click(200, 50);
|
||||
QCOMPARE(m_root->property("clicks").toInt(), 1);
|
||||
}
|
||||
|
||||
void smoothScrollMovesFlickable() {
|
||||
auto* flick = m_root->findChild<QQuickItem*>("flick");
|
||||
mouse(vr::VREvent_MouseMove, 200, kSize.height() - 150);
|
||||
for (int i = 0; i < 5; ++i) send(scrollEvent(0.f, -1.f));
|
||||
QTRY_VERIFY(flick->property("contentY").toReal() > 0);
|
||||
}
|
||||
|
||||
void keyboardCharsReachFocusedTextInput() {
|
||||
auto* input = m_root->findChild<QQuickItem*>("input");
|
||||
input->forceActiveFocus();
|
||||
QVERIFY(input->hasActiveFocus());
|
||||
|
||||
send(keyboardEvent("ab", kOverlay));
|
||||
send(keyboardEvent("\xc3\xa4", kOverlay)); // UTF-8 'ä'
|
||||
send(keyboardEvent("\b", kOverlay));
|
||||
// Other overlay: ignored
|
||||
QVERIFY(!m_bridge->handleEvent(keyboardEvent("c", kOverlay + 1)));
|
||||
QCOMPARE(input->property("text").toString(), QStringLiteral("ab"));
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr QSize kSize{400, 300};
|
||||
static constexpr vr::VROverlayHandle_t kOverlay = 42;
|
||||
|
||||
void send(const vr::VREvent_t& event) {
|
||||
QVERIFY(m_bridge->handleEvent(event));
|
||||
}
|
||||
|
||||
void mouse(uint32_t type, float x, float y) {
|
||||
vr::VREvent_t event{};
|
||||
event.eventType = type;
|
||||
event.data.mouse.x = x;
|
||||
event.data.mouse.y = y;
|
||||
event.data.mouse.button = vr::VRMouseButton_Left;
|
||||
send(event);
|
||||
}
|
||||
|
||||
void click(float x, float y) {
|
||||
mouse(vr::VREvent_MouseMove, x, y);
|
||||
mouse(vr::VREvent_MouseButtonDown, x, y);
|
||||
mouse(vr::VREvent_MouseButtonUp, x, y);
|
||||
}
|
||||
|
||||
static vr::VREvent_t scrollEvent(float dx, float dy) {
|
||||
vr::VREvent_t event{};
|
||||
event.eventType = vr::VREvent_ScrollSmooth;
|
||||
event.data.scroll.xdelta = dx;
|
||||
event.data.scroll.ydelta = dy;
|
||||
return event;
|
||||
}
|
||||
|
||||
static vr::VREvent_t keyboardEvent(const char* utf8,
|
||||
vr::VROverlayHandle_t overlay) {
|
||||
vr::VREvent_t event{};
|
||||
event.eventType = vr::VREvent_KeyboardCharInput;
|
||||
qstrncpy(event.data.keyboard.cNewInput, utf8,
|
||||
sizeof(event.data.keyboard.cNewInput));
|
||||
event.data.keyboard.overlayHandle = overlay;
|
||||
return event;
|
||||
}
|
||||
|
||||
QQmlEngine m_engine;
|
||||
std::unique_ptr<QQuickRenderControl> m_renderControl;
|
||||
std::unique_ptr<QQuickWindow> m_window;
|
||||
std::unique_ptr<QQuickItem> m_root;
|
||||
std::unique_ptr<OverlayInputBridge> m_bridge;
|
||||
};
|
||||
|
||||
QTEST_MAIN(InputBridgeTest)
|
||||
#include "tst_inputbridge.moc"
|
||||
@@ -0,0 +1,118 @@
|
||||
#include <QQmlEngine>
|
||||
#include <QQuickWindow>
|
||||
#include <QtTest>
|
||||
|
||||
#include "render/OffscreenQuickRenderer.h"
|
||||
#include "render/RenderBackend.h"
|
||||
|
||||
// Renders a known scene through the real offscreen pipeline (backend +
|
||||
// QQuickRenderControl) and checks the texture content via readback. The backend
|
||||
// comes from VRDASH_TEST_API (opengl / vulkan): the graphics API is
|
||||
// process-global, so CTest runs one process each.
|
||||
class RenderTest : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void initTestCase() {
|
||||
GraphicsApi api = GraphicsApi::OpenGL;
|
||||
const QString apiName =
|
||||
qEnvironmentVariable("VRDASH_TEST_API", QStringLiteral("opengl"));
|
||||
QVERIFY2(parseGraphicsApi(apiName, &api), qPrintable(apiName));
|
||||
m_backend = createRenderBackend(api);
|
||||
if (!m_backend)
|
||||
QSKIP(qPrintable(apiName + QStringLiteral(" not compiled in")));
|
||||
QQuickWindow::setGraphicsApi(m_backend->graphicsApi());
|
||||
QString error;
|
||||
if (!m_backend->initialize({}, &error))
|
||||
QSKIP(qPrintable(
|
||||
QStringLiteral("No %1 context: %2").arg(m_backend->name(), error)));
|
||||
}
|
||||
|
||||
void rendersSceneUpright() {
|
||||
QQmlEngine engine;
|
||||
const QSize size(64, 32);
|
||||
OffscreenQuickRenderer renderer(*m_backend, size);
|
||||
QString error;
|
||||
QVERIFY2(renderer.initialize(engine, "RenderTest", "TwoTone", &error),
|
||||
qPrintable(error));
|
||||
|
||||
int sinkCalls = 0;
|
||||
vr::Texture_t submitted{};
|
||||
renderer.setFrameSink(
|
||||
[&](const vr::Texture_t& texture, const vr::VRTextureBounds_t&) {
|
||||
++sinkCalls;
|
||||
submitted = texture;
|
||||
});
|
||||
renderer.renderNow();
|
||||
|
||||
QCOMPARE(sinkCalls, 1);
|
||||
QVERIFY(submitted.handle != nullptr);
|
||||
const auto expectedType =
|
||||
m_backend->graphicsApi() == QSGRendererInterface::Vulkan
|
||||
? vr::TextureType_Vulkan
|
||||
: vr::TextureType_OpenGL;
|
||||
QCOMPARE(submitted.eType, expectedType);
|
||||
if (submitted.eType == vr::TextureType_Vulkan) {
|
||||
const auto* vk =
|
||||
static_cast<const vr::VRVulkanTextureData_t*>(submitted.handle);
|
||||
QVERIFY(vk->m_nImage != 0 && vk->m_pDevice && vk->m_pQueue &&
|
||||
vk->m_pInstance);
|
||||
QCOMPARE(vk->m_nWidth, uint32_t(size.width()));
|
||||
}
|
||||
QVERIFY(!m_backend->deviceName().isEmpty());
|
||||
|
||||
const QImage image = m_backend->readback();
|
||||
QCOMPARE(image.size(), size);
|
||||
QCOMPARE(image.pixelColor(size.width() / 2, 4), QColor(255, 0, 0));
|
||||
QCOMPARE(image.pixelColor(size.width() / 2, size.height() - 4),
|
||||
QColor(0, 0, 255));
|
||||
}
|
||||
|
||||
// Several frames in a row exercise the per-frame state handling (e.g.
|
||||
// Vulkan layout transitions between Qt's frames and the SteamVR
|
||||
// hand-off).
|
||||
void rendersRepeatedly() {
|
||||
QQmlEngine engine;
|
||||
OffscreenQuickRenderer renderer(*m_backend, {32, 32});
|
||||
QString error;
|
||||
QVERIFY2(renderer.initialize(engine, "RenderTest", "TwoTone", &error),
|
||||
qPrintable(error));
|
||||
for (int i = 0; i < 5; ++i) renderer.renderNow();
|
||||
QCOMPARE(m_backend->readback().pixelColor(16, 2), QColor(255, 0, 0));
|
||||
}
|
||||
|
||||
// An animated scene must render at the fps cap even when each frame
|
||||
// takes noticeable time: pacing is measured from frame start, not added
|
||||
// after the frame.
|
||||
void pacingHoldsFrameRateWithSlowFrames() {
|
||||
QQmlEngine engine;
|
||||
OffscreenQuickRenderer renderer(*m_backend, {32, 32});
|
||||
QString error;
|
||||
QVERIFY2(renderer.initialize(engine, "RenderTest", "Spinner", &error),
|
||||
qPrintable(error));
|
||||
int frames = 0;
|
||||
renderer.setFrameSink(
|
||||
[&](const vr::Texture_t&, const vr::VRTextureBounds_t&) {
|
||||
++frames;
|
||||
QThread::usleep(3000); // e.g. a slow SteamVR hand-off
|
||||
});
|
||||
renderer.setMaxFps(60);
|
||||
renderer.setActive(true);
|
||||
QTest::qWait(300); // warm-up
|
||||
frames = 0;
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
QTest::qWait(2000);
|
||||
const double fps = frames * 1000.0 / timer.elapsed();
|
||||
renderer.setActive(false);
|
||||
qInfo("measured %.1f fps", fps);
|
||||
QVERIFY2(fps > 57.0 && fps < 63.0, qPrintable(QString::number(fps)));
|
||||
}
|
||||
|
||||
void cleanupTestCase() { m_backend.reset(); }
|
||||
|
||||
private:
|
||||
std::unique_ptr<RenderBackend> m_backend;
|
||||
};
|
||||
|
||||
QTEST_MAIN(RenderTest)
|
||||
#include "tst_render.moc"
|
||||
@@ -0,0 +1,116 @@
|
||||
// An app with two surfaces, run in snapshot mode: checks surface set-up
|
||||
// (overlay keys, names, sizes, shared engine, backends), the lifecycle hooks
|
||||
// and that every surface renders its own scene. No SteamVR involved.
|
||||
// Exit codes: 0 pass, 1 fail, 77 skipped (no GPU context).
|
||||
|
||||
#include <QDir>
|
||||
#include <QImage>
|
||||
#include <QTemporaryDir>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app/DashboardApplication.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool check(bool condition, const char* what) {
|
||||
if (!condition) fprintf(stderr, "FAIL: %s\n", what);
|
||||
return condition;
|
||||
}
|
||||
|
||||
class TwoSurfaceApp : public DashboardApplication {
|
||||
public:
|
||||
TwoSurfaceApp(int& argc, char** argv) : DashboardApplication(argc, argv) {
|
||||
setQmlModule(QStringLiteral("SurfacesTest"));
|
||||
setDisplayName(QStringLiteral("Test App"));
|
||||
setAppKey(QStringLiteral("config.key"));
|
||||
addSurface({.id = QStringLiteral("red"),
|
||||
.qmlRootType = QStringLiteral("Red"),
|
||||
.textureSize = QSize(64, 32)});
|
||||
addSurface({.id = QStringLiteral("blue"),
|
||||
.qmlRootType = QStringLiteral("Blue"),
|
||||
.displayName = QStringLiteral("Blue tab"),
|
||||
.textureSize = QSize(32, 32)});
|
||||
}
|
||||
|
||||
QString expectedKey; // --app-key overrides setAppKey()
|
||||
int loadingCalls = 0;
|
||||
int startedCalls = 0;
|
||||
bool ok = true;
|
||||
|
||||
protected:
|
||||
void loading() override {
|
||||
++loadingCalls;
|
||||
Surface* red = surface(QStringLiteral("red"));
|
||||
Surface* blue = surface(QStringLiteral("blue"));
|
||||
ok &= check(surfaces().size() == 2 && red && blue, "two surfaces by id");
|
||||
ok &= check(surface() == red, "primary is the first");
|
||||
ok &= check(surface(QStringLiteral("none")) == nullptr, "unknown id");
|
||||
ok &= check(engine() != nullptr, "shared engine");
|
||||
ok &= check(mode() == DashboardMode::Snapshot, "mode");
|
||||
ok &= check(red && blue && red->backend() && blue->backend() &&
|
||||
red->backend() != blue->backend(),
|
||||
"a backend per surface");
|
||||
ok &= check(appKey() == expectedKey, "app key (setAppKey / --app-key)");
|
||||
ok &= check(red && red->overlayKey() == expectedKey + QLatin1String(".red"),
|
||||
"overlay key = <app key>.<id>");
|
||||
ok &= check(red && red->displayName() == QLatin1String("Test App"),
|
||||
"tab name defaults to the app name");
|
||||
ok &= check(blue && blue->displayName() == QLatin1String("Blue tab"),
|
||||
"tab name from config");
|
||||
ok &= check(blue && blue->textureSize() == QSize(32, 32),
|
||||
"texture size from config");
|
||||
ok &= check(vrRuntime() == nullptr, "no VR outside overlay mode");
|
||||
}
|
||||
void started() override {
|
||||
++startedCalls;
|
||||
const QList<Surface*> all = surfaces();
|
||||
ok &= check(all.size() == 2 && all[0]->window() && all[1]->window() &&
|
||||
all[0]->window() != all[1]->window(),
|
||||
"a window per surface");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QTemporaryDir dir;
|
||||
const std::string snapshot =
|
||||
QDir(dir.path()).filePath(QStringLiteral("shot.png")).toStdString();
|
||||
// VRDASH_TEST_CLI_KEY=1: also pass --app-key (must win over setAppKey()).
|
||||
const bool cliKey = qEnvironmentVariableIntValue("VRDASH_TEST_CLI_KEY");
|
||||
std::vector<std::string> args = {argv[0], "--snapshot", snapshot};
|
||||
if (cliKey) {
|
||||
args.emplace_back("--app-key");
|
||||
args.emplace_back("cli.key");
|
||||
}
|
||||
for (int i = 1; i < argc; ++i) args.emplace_back(argv[i]);
|
||||
std::vector<char*> cargs;
|
||||
for (std::string& arg : args) cargs.push_back(arg.data());
|
||||
int cargc = int(cargs.size());
|
||||
|
||||
TwoSurfaceApp app(cargc, cargs.data());
|
||||
app.expectedKey =
|
||||
cliKey ? QStringLiteral("cli.key") : QStringLiteral("config.key");
|
||||
const int result = app.exec();
|
||||
if (app.loadingCalls == 0) {
|
||||
fprintf(stderr, "SKIP: no render backend (no GPU context?)\n");
|
||||
return 77;
|
||||
}
|
||||
bool ok = app.ok;
|
||||
ok &= check(result == 0, "exec() succeeded");
|
||||
ok &= check(app.loadingCalls == 1 && app.startedCalls == 1,
|
||||
"each hook called once");
|
||||
|
||||
const QImage red(QDir(dir.path()).filePath(QStringLiteral("shot-red.png")));
|
||||
const QImage blue(QDir(dir.path()).filePath(QStringLiteral("shot-blue.png")));
|
||||
ok &= check(red.size() == QSize(64, 32) &&
|
||||
red.pixelColor(10, 10) == QColor(255, 0, 0),
|
||||
"red surface rendered");
|
||||
ok &= check(blue.size() == QSize(32, 32) &&
|
||||
blue.pixelColor(10, 10) == QColor(0, 0, 255),
|
||||
"blue surface rendered");
|
||||
fprintf(stderr, ok ? "PASS\n" : "FAILED\n");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user