Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions .claude/skills/new-displayxr-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
name: new-displayxr-app
description: |
Scaffold a new DisplayXR OpenXR app from a known-good test app, wired to the authoring rules so it starts correct instead of tripping the common pitfalls. Clones the nearest cube_* reference app, parameterizes it, drops a workspace manifest + icon placeholders + a per-app CLAUDE.md, wires CMake, and runs the app linter until clean.
Usage: /new-displayxr-app <name> [class=handle|texture|hosted] [api=d3d11|d3d12|gl|vk|metal] [platform=win|macos]
Usage: /new-displayxr-app <name> [class=handle|texture|hosted] [api=d3d11|d3d12|gl|vk|metal] [platform=win|macos|linux|android]
Examples:
/new-displayxr-app photo_viewer class=handle api=d3d11 platform=win
/new-displayxr-app stereo_player class=handle api=vk platform=macos
/new-displayxr-app editor class=texture api=d3d11 platform=win
/new-displayxr-app tablet_viewer class=handle api=vk platform=android
---

# new-displayxr-app — scaffold a correct DisplayXR app
Expand All @@ -30,7 +31,8 @@ Resolve these; if any is missing or ambiguous, **ask the user** (AskUserQuestion
- **class** — `handle` (owns its window; the default and simplest) | `texture` (weaved 3D in a
regions expressed via `XR_DXR_display_zones`; the shared texture receives the runtime's zones composite) | `hosted` (runtime creates the window).
- **api** — `d3d11` | `d3d12` | `gl` | `vk` | `metal`.
- **platform** — `win` | `macos`.
- **platform** — `win` | `macos` | `linux` | `android`. Linux and Android are **Vulkan-only**
(`api=vk`); anything else there has no reference app — say so and ask.
- **location** — default `test_apps/<class>/<name>_<class>_<api>_<platform>/` (in-tree; apps are
grouped by **handoff class** folder — `handle/ hosted/ texture/ legacy/ probes/` — with `zones`
a feature-name modifier, not a folder, so `cube_zones_*` go under `handle/` and
Expand All @@ -42,11 +44,18 @@ Target name follows the convention `cube_*`-style: `<name>_<class>_<api>_<platfo
Pick the exact match; if none exists, pick the same **class+platform** (swap API) or same
**class+api** (swap platform) and adapt, and tell the user which you used.

| class \ target | win | macos |
|---|---|---|
| **handle** | d3d11 → `cube_handle_d3d11_win` · d3d12 → `cube_handle_d3d12_win` · gl → `cube_handle_gl_win` · vk → `cube_handle_vk_win` | gl → `cube_handle_gl_macos` · metal → `cube_handle_metal_macos` · vk → `cube_handle_vk_macos` |
| **texture** | d3d11 → `cube_zones_texture_d3d11_win` · d3d12 → `cube_zones_texture_d3d12_win` | metal → `cube_zones_texture_metal_macos` |
| **hosted** | d3d11 → `cube_hosted_d3d11_win` | metal → `cube_hosted_metal_macos` |
| class \ target | win | macos | linux | android |
|---|---|---|---|---|
| **handle** | d3d11 → `cube_handle_d3d11_win` · d3d12 → `cube_handle_d3d12_win` · gl → `cube_handle_gl_win` · vk → `cube_handle_vk_win` | gl → `cube_handle_gl_macos` · metal → `cube_handle_metal_macos` · vk → `cube_handle_vk_macos` | vk → `cube_handle_vk_linux` (zones: `cube_zones_vk_linux`) | vk → `handle/cube_handle_vk_android` (zones: `zones/cube_zones_vk_android`) |
| **texture** | d3d11 → `cube_zones_texture_d3d11_win` · d3d12 → `cube_zones_texture_d3d12_win` | metal → `cube_zones_texture_metal_macos` | — | — |
| **hosted** | d3d11 → `cube_hosted_d3d11_win` | metal → `cube_hosted_metal_macos` | vk → `legacy/cube_hosted_legacy_vk_linux` | — |

Linux and Android references sit outside the Windows/macOS class-folder aggregator: the Linux
cubes are flat under `test_apps/` (or `legacy/`) and are built by an explicit list in
`scripts/build_linux.sh --apps` (add the new target there); the Android cubes are Gradle modules
(`include` + `projectDir` in the root `settings.gradle`, built with `./gradlew`), and an Android app
must also pass the §11 pixel-exactness rules (manifest, SurfaceView, `<queries>`), which the linter
checks from its `AndroidManifest.xml`.

## Procedure

Expand Down Expand Up @@ -124,5 +133,15 @@ Pick the exact match; if none exists, pick the same **class+platform** (swap API
`VkPhysicalDevicePresentIdFeaturesKHR` + `PresentWaitFeaturesKHR` yourself. Watch the proc names
— the v1 spellings (`xrGetVulkanGraphicsRequirementsKHR`) are not dispatchable under
enable2-only, a trap that shipped twice in demo repos.
- **Vulkan frame-submit discipline (INV-4.10):** every `vk` reference app today still submits
then waits (Android: per-frame command-buffer alloc + `vkQueueWaitIdle`; Windows/Linux: wait on
the fence just submitted; macOS: one fence, one frame in flight), and the linter WARNs on each.
Do **not** copy that loop into a new app. Render through `dxr::VkFrameRing`
(displayxr-common `common/vk_frame_ring.h`, ≥ v2.28.0): `beginFrame()` → record every tile into
one command buffer with `tileBarrier()` between tiles → `endFrame()` (the one submit) →
`xrReleaseSwapchainImage` → `xrEndFrame`; one-off uploads through `submitImmediate()`, resize /
teardown through `waitAll()`. On Android this is the difference between ~10 and ~30 fps (#1917).
Until the helper is tagged, copy the header in and note the pin to bump. The cube references are
converted in a follow-up — do not edit them as part of scaffolding.
- The linter is advisory on WARNs but **blocking on ERRORs**; treat a clean linter run as the
definition of "scaffold complete."
4 changes: 4 additions & 0 deletions .claude/skills/new-displayxr-app/templates/app-CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ They are binding. The traps agents hit most:
- **INV-4.3** — per-tile size = **window/canvas × `recommendedViewScaleX/Y`**, never display size.
- **INV-4.6** — request an **sRGB swapchain** and store a correctly-encoded image (render linear
with GPU sRGB-write, *or* write display-referred bytes — not both). Data textures stay linear.
- **INV-4.10** (Vulkan) — keep **≥2 frames in flight** with **one `vkQueueSubmit` per frame**;
never wait the fence you just submitted, no `vkQueueWaitIdle`/`vkDeviceWaitIdle` on the frame
path, allocate command buffers once and reset them. Use `dxr::VkFrameRing` (displayxr-common
`common/vk_frame_ring.h`). A serialised submit→wait loop parks mobile GPUs at ~1/3 clock.
- **INV-2.4** — the runtime owns the active mode; you *request* via
`xrRequestDisplayRenderingModeDXR` and update local state only on the change **event**.
- **INV-7.1/7.2** — capture via `xrCaptureAtlasDXR` (prefix with **no** extension); never
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,10 @@ jobs:
# would have caught the gauss demo's Android leg preferring UNORM after
# #1589 made UNORM swapchains linear.
run: python3 scripts/tests/test_check_displayxr_app_color.py

- name: App-linter INV-4.10 Vulkan frame-submit unit tests
# Hermetic (synthetic app trees). Pins the per-function submit/drain,
# wait-own-fence and per-frame command-buffer checks (#1917: a serialised
# submit -> wait loop parks Adreno at ~1/3 clock), and that one-off
# uploads, VkFrameRing-style rings and the exemption comment stay quiet.
run: python3 scripts/tests/test_check_displayxr_app_vk_frame.py
2 changes: 1 addition & 1 deletion docs/getting-started/android-build-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ and `Leia CNSDK DP: alpha-gate pipeline ready`.

ADPF hint sessions are **unsupported** on the Lume phone and the NP02J (Android 13, Adreno 740): `dumpsys performance_hint` reports `HAL Support: false`, so every app logs `ADPF: createSession failed` (the line now carries `APerformanceHint_getPreferredUpdateRateNanos` — `-1` confirms the HAL, `>0` would mean our arguments). Without hints the msm-adreno-tz governor parks the GPU near **220 MHz** (floor 124.8, max 680) at ~50% busy; pinning 680 MHz gave the model viewer +60% fps, while `cmd power set-fixed-performance-mode-enabled` changed nothing.
Experiment knob, default **off** until measured: `adb shell setprop debug.dxr.sustained_perf 1` makes `MonadoView` request `Window.setSustainedPerformanceMode(true)` on the host Activity (one WARN line reports supported / applied / Activity class; service-owned overlays skip it).
**App rule:** keep **≥2 frames in flight**. A strictly serialised submit → `vkWaitForFences` → submit loop never looks busy to the governor and runs the GPU at about a third of its max clock.
**App rule:** keep **≥2 frames in flight**. A strictly serialised submit → `vkWaitForFences` → submit loop never looks busy to the governor and runs the GPU at about a third of its max clock. The full rule (one submit per frame, no per-frame drains or command-buffer allocation), the shared `dxr::VkFrameRing` helper and the lint check: [INV-4.10](../guides/displayxr-app-rules.md) in the app-authoring rules.

### Device not found by ADB

Expand Down
70 changes: 70 additions & 0 deletions docs/guides/displayxr-app-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,75 @@ re-implementing — see [INV-8.1](#8-app-folder-layout--what-to-include)).
most one event per view configuration per second — the runtime coalesces, as the spec requires.
Advisory rule (WARN). Ref: `#1488`.

- **INV-4.10 — Vulkan: keep ≥2 frames in flight, ONE `vkQueueSubmit` per frame, never wait the
frame you just submitted.** How you feed the GPU is part of rendering correctly into the
swapchain, not an optimisation, because on mobile it decides the GPU clock. The rule is
platform-neutral:
1. **≥2 frames in flight** (3 is the measured default) — a ring of command buffers + fences
indexed by frame slot, not one of each.
2. **Exactly one `vkQueueSubmit` per frame per queue.** All tiles/views of the atlas go into
one command buffer, ordered by a barrier between tiles — never one submit per tile or per eye
(with or without a wait in between).
3. **Never wait the fence of the frame you just submitted.** The only wait on the hot path is on
the slot's fence from N frames ago (normally long signalled).
4. **No `vkQueueWaitIdle` / `vkDeviceWaitIdle` on the steady-state frame path.**
5. **Command buffers are allocated once and reset** (`vkResetCommandBuffer` / a per-slot pool
reset) — never `vkAllocateCommandBuffers` + `vkFreeCommandBuffers` per frame.

**Why.** Mobile GPU governors pick the clock from GPU *busy %*. A loop that submits, waits, then
submits again leaves the GPU idle while the CPU waits, so the governor reads ~50 % busy and parks
the GPU at about a third of its clock — every frame is then slower, which keeps it idle. Measured
on Adreno 740 (`msm-adreno-tz`, ADPF hint sessions unsupported): the GPU sits near 220 MHz of
680, ~10 fps ([#1917](https://github.com/DisplayXR/displayxr-runtime/issues/1917); see
[android-build-guide § Low frame rate with an idle-looking GPU](../getting-started/android-build-guide.md#low-frame-rate-with-an-idle-looking-gpu-the-clock-never-ramps-1917)).
Per-frame command-buffer allocation adds a fresh zeroed allocation in the Adreno driver every
frame ([#1935](https://github.com/DisplayXR/displayxr-runtime/issues/1935) /
[#1936](https://github.com/DisplayXR/displayxr-runtime/pull/1936) — the runtime's own `vk_native`
compositor was fixed the same way). On a desktop dGPU the same mistake costs only the lost
CPU↔GPU overlap, which is why it survives desktop testing.

**Evidence.** `displayxr-demo-modelviewer` on the Leia phone went **13 → 30 fps** by keeping 3
frames in flight with one submit per frame and nothing else changed
([modelviewer#170](https://github.com/DisplayXR/displayxr-demo-modelviewer/pull/170)).

**Sanctioned pattern — `dxr::VkFrameRing`** (displayxr-common `common/vk_frame_ring.h`,
≥ v2.28.0, header-only; [displayxr-common#74](https://github.com/DisplayXR/displayxr-common/pull/74)):
```cpp
dxr::VkFrameRing ring; // init(device, physDevice, queue, family, cfg) once
// per frame:
xrWaitFrame / xrBeginFrame / xrLocateViews
xrAcquireSwapchainImage + xrWaitSwapchainImage
VkCommandBuffer cmd = ring.beginFrame(); // waits only the slot from N frames ago
for (uint32_t i = 0; i < viewCount; ++i) {
if (i > 0) dxr::VkFrameRing::tileBarrier(cmd); // shared targets between tiles
// ... record tile i into its atlas sub-rect (per-slot UBO via ring.viewUbo(i, &dynOff)) ...
}
ring.endFrame(); // the ONE vkQueueSubmit; never waits
xrReleaseSwapchainImage // AFTER endFrame: the runtime composes on the same queue
xrEndFrame
```
**Allowed exceptions:** genuine one-off uploads (textures, meshes, a static quad-layer fill) go
through `ring.submitImmediate()` — the one named blocking escape hatch; resize and teardown drain
with `ring.waitAll()`; a resource the GPU may still be reading is released with
`ring.deferDestroy()`. `DXR_VK_FRAMES_IN_FLIGHT=1` reproduces the anti-pattern for an A/B
(desktop only — Android processes pass `Config::framesInFlight` explicitly).

**Lint (WARN, advisory).** `scripts/check_displayxr_app.py` finds the enclosing function of every
`vkQueueSubmit` in a `.c/.cc/.cpp/.cxx/.mm` source by brace matching (falling back to a ±60-line
window when no function body can be delimited) and judges it only if it is on the **frame path**
— it records a draw / render pass / dispatch, calls `xrWaitFrame`/`xrBeginFrame`/`xrEndFrame`, or
is named `*render*` (not `*Renderer*`) / `*draw*` / `*frame*` / `*present*`. Any other function is
treated as a one-off upload and left alone. In a frame-path function it flags: (a)
`vkQueueWaitIdle`/`vkDeviceWaitIdle` anywhere in the body; (b) `vkWaitForFences` *after* the
submit on the same handle passed as its fence; (c) `vkAllocateCommandBuffers` **and**
`vkFreeCommandBuffers` both in the body. As INFO only, (d) one un-indexed fence waited at the top
of the function and signalled by its submit (at most one frame in flight — a fence reached through
a slot pointer looks the same to a text matcher). A deliberate blocking submit (a capture
readback tool, a single-shot test) is exempted with `// dxr-lint: allow-blocking-submit <reason>`
on the submit line or on the function's signature line. It does not see a per-frame drain hidden
behind a helper call in another function, and does not count submits per frame. Ref: `#1917`,
`#1935`, `modelviewer#170`, `displayxr-common#74`.

---

## 5. Texture apps: express regions via display-zones
Expand Down Expand Up @@ -1073,6 +1142,7 @@ macOS app (no manifest → no Android findings).
- [ ] Color space: request an **sRGB swapchain** and write a correctly-encoded image (linear render + GPU sRGB-write, or display-referred bytes — not both); a linear/UNORM swapchain is read as LINEAR and encoded by the runtime (display-referred bytes in it wash out); same `_SRGB`-first choice on every leg; data textures always linear (INV-4.6)
- [ ] Whole declared `imageRect` is written — partial-tile renders clear the full tile to `(0,0,0,0)` first (or shrink the rect); no undefined pixels reach the atlas, esp. transparent-bg (INV-4.7)
- [ ] (if `XR_EXT_view_configuration_views_change` is enabled) the handler re-enumerates and **moves `subImage.imageRect`** — no `xrCreateSwapchain` from the event; filters on `systemId` **and** `viewConfigurationType` (INV-4.9)
- [ ] (Vulkan) ≥2 frames in flight, ONE `vkQueueSubmit` per frame, no wait on the frame just submitted, no `vkQueueWaitIdle`/`vkDeviceWaitIdle` on the frame path, command buffers allocated once — `dxr::VkFrameRing` (INV-4.10)
- [ ] (texture) regions declared via display-zones — 3D zones + Local2D zones, not output-rect/surround (INV-5.1/5.3)
- [ ] (texture) shared surface sized once to worst-case; full composite presented (INV-5.2)
- [ ] Window-relative Kooima; matrices transposed for DirectX (INV-6.3/6.4)
Expand Down
Loading
Loading