Performance work on Horizon OS fails in one of two ways: optimising without a
capture, or reading the capture wrong. This skill fixes the second first, because
the most-quoted number on the platform — GPU% — is misleading exactly when it
matters.
For a whole-product roadmap or stage audit, use quest-lifecycle; a single technical task stays with this owner. If absent, identify the current stage, its evidence and the next prerequisite inline.
Read references/mobile-rendering.md when choosing the render pipeline or investigating tile, render-pass, geometry, overdraw and asset costs.
The budget is a deadline, not a target
| Refresh rate | Time per frame |
|---|---|
| 72 Hz | 13.9 ms |
| 90 Hz | 11.1 ms |
| 120 Hz | 8.3 ms |
CPU, render-thread, GPU and compositor work form a pipeline and can overlap across frames. Compare the critical path and synchronization against the deadline; do not add independent CPU/GPU samples as a universal frame-time formula. Missed submissions may invoke reprojection, with visible artifacts that depend on motion, depth and the selected runtime path.
The GPU% lie, and the 50% threshold
When an app drops to half rate the GPU finishes early and idles, so utilisation falls while the app is firmly GPU-bound.
FPS=36/72 Stale=36 GPU%=0.65 App=18.05ms
FPS=36/72— half rate: every frame shown twice.App=18.05ms— the real per-frame GPU time, against a 13.9 ms budget.GPU%=0.65— measured across the doubled 27.8 ms interval, which is why it reads 65% while the app needs a 23% cut, not the 35% "headroom" it implies.
Rule: read App in milliseconds against the refresh-rate budget. Only trust
GPU% near saturation. To climb back out of half rate, GPU work must fit one
refresh interval — i.e. utilisation must fall below ~50% while at half rate.
If App is inside budget and Stale remains non-zero, inspect CPU submission,
synchronization and compositor timing in a trace. That symptom alone does not
prove a CPU bottleneck. The worked half-rate example above assumes the stated
metric definitions and no intentional SpaceWarp mode; verify both first.
Select a lever from the measured bottleneck
Read references/rendering-playbook.md when choosing multiview, render scale,
foveation, layers, assets, shaders or reprojection. It links each experiment to
its prerequisites, visual failure cases and target-device evidence.
- Dynamic Resolution — tune render scale through the engine/SDK's supported control path; verify actual behavior rather than assuming the OS enables it.
- Foveation — distinguish fixed from eye-tracked; test supported levels and quality. A simple shader can cost more with FFR than without it.
- Refresh rate — request a rate the representative workload sustains; observe actual runtime and thermal behavior rather than promising an automatic rate change.
- Application SpaceWarp — a supported depth/motion-vector integration with artifact and latency tests, not an automatic rescue or guaranteed percentage gain.
Capture chain — what each tool answers
| Question | Tool | How |
|---|---|---|
| Am I missing frames at all, and where | logcat VrApi stats (FPS, Stale, App, GPU%) |
metavr log / adb logcat |
| Live numbers inside the headset | OVR Metrics Tool (ovrmetric APK) |
metavr tools install ovrmetric, then enable its overlay |
| CPU vs GPU, thread timeline, stalls | Perfetto | metavr MCP start_perfetto_capture → stop_perfetto_capture → analyze_trace, or the CLI equivalents |
| Which C/C++ functions burn the CPU | simpleperf | Android NDK's simpleperf against the running package |
| Which draw call, shader or render stage burns the GPU | RenderDoc Meta fork | metavr tools install renderdoc; draw-call trace, render-stage trace, Vulkan shader stats |
Meta ships task skills for two of these — hz-perfetto-debug and
hz-simpleperf-debug, plus hz-vr-debug for general on-device debugging. When
they are installed, this skill decides what to measure and they drive the tool.
references/capture-playbook.md holds the exact sequences, including what to
capture for a report that another agent can act on.
Method, so a second capture means something
- Reproduce on a release build on a real headset. A simulator run
(
xrsim,spatialsim) checks supported simulated behavior, not headset performance. - Note refresh rate and the resulting budget before looking at anything else.
- Capture with the app in the worst scene, not the menu.
- Change one thing; re-capture; compare
Appms, not impressions. - Record the pair (before/after
App,Stale, scene, build id) in the project's own notes — a performance claim with no capture is an opinion.
Gotchas that cost a debugging round
- A thermally throttled headset invalidates the comparison. Long sessions drift; let it cool or state the temperature state with the number.
- Dev-mode overlays and logging cost frames. Measure with them off, then turn them on only to read the numbers you cannot get otherwise.
- Counter semantics depend on capture/runtime mode. Inspect timestamps and reprojection state before interpreting a stale-frame count as dropped work or pipeline latency.
- Draw calls are a CPU cost and fill is a GPU cost. Cutting draw calls on a fill-bound frame moves nothing, which is why the capture comes first.
- WebXR has its own counters — draw-call metrics in the browser docs; see
quest-webxrbefore applying native advice to a web build.
Frame budgets, the 65%/18.05 ms worked example and the recovery mechanisms were
read from documentation/native/android/os-missed-frames.md on 2026-09-20;
re-verify with metavr docs search "missed frames".