Skip to content

feat(cursor): the runtime draws the depth cursor — Vulkan + GL (ADR-046 Amendment 2 §B) - #1946

Merged
dfattal merged 3 commits into
mainfrom
feat/cursor-runtime-draw-vk-gl
Oct 11, 2026
Merged

dfattal merged 3 commits into
mainfrom
feat/cursor-runtime-draw-vk-gl

Conversation

@dfattal

@dfattal dfattal commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Extends ADR-046 Amendment 2 §B (the runtime draws the depth cursor) from Metal (#1941) to the in-process vk_native and GL compositors. XR_DXR_cursor_depth stays at spec v6 — no new structs, no new XrStructureType, no version bump. This implements the existing v5 XrCursorDepthDrawInfoDXR / XrCursorDepthDrawStateDXR.

What changed

  • One default look for all three backends. The Metal compositor's OS-cursor mirror moved to compositor/util/comp_cursor_look.{h,c} + comp_cursor_look_macos.m, a small library that Metal, vk_native and GL all link. It uses the mirrored [NSCursor currentCursor] on the main thread, otherwise the last snapshot, otherwise the procedural arrow from u_cursor_draw_arrow_rgba(). DXR_CURSOR_DEFAULT_LOOK=arrow still forces the arrow. Metal now uses the shared module, and its behaviour is unchanged (re-measured below). OS-cursor mirroring works on macOS only. Linux (XFixesGetCursorImage) and Windows draw the arrow, and that is noted in the ADR as future work.
  • vk_native:
    • The request is consumed by the next commit and resolved there into tile corners for each view.
    • A cursor frame never zero-copies and always goes through the renderer's compose pass. The cursor is the pass's last draw, using the pass's own pipelines through the QUAD vertex path with an affine MVP. It is scissored to each tile, with depth testing off.
    • The pass draws into the private _SRGB target and publishes to the atlas with a raw copy, so the sprite blends in linear light and the atlas stays ENCODED (no double encode).
    • The sprite is sampled through the single compose_source_view(), the same source-view choice every layer uses. tests_comp_color_policy holds that choice to one code line.
    • Ordering differs from Metal: window-space layers are stamped later, at weave time, so on vk_native the cursor ends up under window-space HUD layers. This is documented in the ADR.
  • GL:
    • Drawn after the window-space layers and before the DP, into whatever the atlas pass is painting.
    • The source is bound with gl_bind_layer_source().
    • A cursor frame counts as a sub-rect layer in gl_frame_takes_fast_path().
    • Small lazy program; blend, scissor and viewport state are restored afterwards.
    • GL sprites are sampled bottom-up. The spec now says so explicitly.
  • State tracker: additive arms only for is_vk_native_compositor / is_gl_native_compositor in oxr_session_cursor_draw_supported / _hand_request. oxr_session_frame_end.c is untouched.
  • Apps: cube_handle_vk_macos and cube_handle_gl_macos now adopt the cursor through dxr::CursorDepth (displayxr-common v2.29.0, the same way as test_apps(cube_handle_metal_macos): adopt the depth cursor through dxr::CursorDepth (needs displayxr-common tag) #1943). Supported env vars: DISPLAYXR_CURSOR_DEPTH_DRAW=runtime|app, DISPLAYXR_CURSOR_DEPTH_SPRITE=custom, DISPLAYXR_CURSOR_DEPTH_UV, DISPLAYXR_CURSOR_DEPTH=0 (on the GL cube the cursor is opt-in with DISPLAYXR_CURSOR_DEPTH=1, see GL compositor repaint thread vs AppKit cursor hide: ~1/5 crash in Apple's GL driver (cube_handle_gl_macos) #1947). The GL cube had no cursor-depth code before; it now has the cube ray test and an app-mode crosshair.
  • Docs:
    • ADR-046 has a new "§B as built on Vulkan and GL" section.
    • The spec's backends note is updated, plus the note about the GL sprite's row order.
    • The DXR_CURSOR_DEFAULT_LOOK census row is updated.

Evidence (this Mac, sim-display Cropped SBS, cursorUV 0.5,0.5 over the cube)

backend atlas look disparity (tile1 − tile0) colour
vk_native 1280×1440 arrow −22.0 px (−18…−22 as the cube turns) fill (255,255,255), outline (5,5,8)
vk_native 1280×1440 mirrored OS cursor −20.8 px —
vk_native 1280×1440 custom ring, 1 cm −21.7 px ring (0,220,255) = authored bytes
GL 1512×1646 arrow −24.1 px (255,255,255) / (5,5,8)
GL 1512×1646 mirrored OS cursor −20.0…−24.0 px —
GL 1512×1646 custom ring −23.4 px (0,220,255)
Metal (after the refactor) 1512×1646 mirrored OS cursor −24.0 px —
  • At UV 0.1,0.1 (no content): nothing is drawn on either backend.
  • With DISPLAYXR_CURSOR_DEPTH_DRAW=app: the app's amber crosshair only, and no runtime pixels.
  • With DISPLAYXR_CURSOR_DEPTH=0: nothing.
  • Both captures are taken after the cursor pass. The vk_native capture is the POST_COMPOSE atlas, $TMPDIR/displayxr_atlas_trigger. The GL capture comes from /tmp/dxr_atlas_trigger, written after the cursor and the compose publish.
  • tests_aux_cursor_depth and tests_comp_color_policy pass. ctest is 105/108; the 3 failures are not caused by this change:
    • tests_service_client_class: path comparison on a case-insensitive filesystem.
    • tests_oxr_view_space_mode_pinned: CI already excludes this suite.
    • tests_stereo_camera_manager: a load flake that passes on rerun.
  • check_displayxr_app.py reports nothing new. The vk app's INV-5.9 (enable1) and the missing manifest warnings were already there.

Known issue found (not caused by the runtime draw)

cube_handle_gl_macos crashes in about 1 of 5 launches once the depth cursor is active. The crash is inside Apple's GL driver, either on the #868 repaint thread (gl_repaint_thread → glBlitFramebuffer) or in the app's own draw.

It follows the app's [NSCursor hide], not the runtime draw:

configuration crashes
app-draw mode yes
runtime draw with the OS cursor left visible 0/10
DXR_WEAVE_REPAINT=0 0/8
extension off 0/20
cursor at UV 0.1,0.1, so the depth cursor is never active (no hide) 0/10

So this looks like a latent race between the GL repaint thread and AppKit, which surfaces now that the GL cube hides the OS cursor. It is tracked as #1947. Until that is fixed, the GL cube's depth cursor is opt-in (DISPLAYXR_CURSOR_DEPTH=1); the Metal and Vulkan cubes stay default-on. vk_native showed no crashes in about 30 launches.

Also found in passing: #1948. On the Vulkan-app-on-Metal fallback, a custom sprite is handed to the Metal compositor as the client swapchain and cast to the native Metal type (#1941; found by reading the code, not reproduced).

Standalone pins: the displayxr-common FetchContent pin of cube_handle_metal/vk/gl_macos is now v2.29.0, the same as the aggregate pin. Each app was configured and built standalone and builds.

Not tested

  • The GL linear compose-target path. Apple's GL 4.1 has no glCopyImageSubData, so every macOS GL frame composes in encoded space. The Windows GL path is untested.
  • vk_native on Linux, Windows and Android (in-process), including the arrow fallback there.
  • Layered (arraySize > 1) sprites: vk handles them through the array pipeline, but that path was not run. GL skips them with a one-time WARN.
  • Multi-screen routed frames. vk and GL skip the cursor on these.
  • The legacy colour hatch (DXR_COLOR_LEGACY_UNORM_ENCODED=1).
  • A real Leia panel.

🤖 Generated with Claude Code

dfattal and others added 2 commits October 11, 2026 02:37
…R-046 Amendment 2 B)

XR_DXR_cursor_depth v5 on the in-process vk_native and GL compositors; no
new structs, no spec bump.

- Shared default look: the Metal compositor's OS-cursor mirror moved to
  compositor/util/comp_cursor_look (+ _macos.m), a small lib Metal,
  vk_native and GL link. Mirrored [NSCursor currentCursor] on the main
  thread on macOS, else the last snapshot, else the procedural arrow;
  DXR_CURSOR_DEFAULT_LOOK=arrow forces it. Other platforms: the arrow.
- vk_native: request consumed by the next commit, resolved to per-view
  tile corners; a cursor frame never zero-copies and always takes the
  renderer's compose pass, where the cursor is the last draw (QUAD vertex
  path, affine MVP, scissored per tile) into the private _SRGB target,
  published by the pass's raw copy - linear blend, sprite sampled through
  the single compose_source_view(), atlas stays ENCODED. Drawn under
  window-space layers (stamped later, at weave time) - documented.
- GL: drawn after the window-space layers into whatever the atlas pass
  paints (compose target or encoded atlas), source bound through
  gl_bind_layer_source(); a cursor frame counts as a sub-rect layer in the
  fast-path predicate; GL sprites are sampled bottom-up.
- State tracker: additive is_vk_native / is_gl_native arms in
  oxr_session_cursor_draw_supported/_hand_request.
- ADR-046 "B as built on Vulkan and GL", spec backends note, DXR_* census.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or via dxr::CursorDepth (XR_DXR_cursor_depth v5)

Same adoption as cube_handle_metal_macos (#1943): the app keeps only its
cube ray test (RayHitCube) and env knobs; pointer UV, footprint rays, the
struct chains and OS-cursor hide/show are displayxr-common's
dxr::CursorDepth (v2.29.0, the aggregate test_apps pin).

DISPLAYXR_CURSOR_DEPTH_DRAW=runtime (default) | app,
DISPLAYXR_CURSOR_DEPTH_SPRITE=custom (64x64 cyan ring sprite swapchain;
GL uploads it bottom row first), DISPLAYXR_CURSOR_DEPTH_UV,
DISPLAYXR_CURSOR_DEPTH=0 opt-out. The GL cube had no cursor-depth code: it
gains RayHitCube, the helper, and an app-mode crosshair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…9.0 standalone pins

- cube_handle_gl_macos: XR_DXR_cursor_depth is now OPT-IN
  (DISPLAYXR_CURSOR_DEPTH=1). With the OS cursor hidden the GL cube
  crashes ~1 in 5 launches in Apple's GL driver (GL compositor repaint
  thread vs AppKit, runtime#1947). Metal and Vulkan cubes stay default-on.
- cube_handle_{metal,vk,gl}_macos: standalone FetchContent pin of
  displayxr-common -> v2.29.0 (dxr::CursorDepth), matching the aggregate
  test_apps pin; each app builds standalone.
- ADR-046: link runtime#1947 and runtime#1948.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dfattal
dfattal merged commit bbc1292 into main Oct 11, 2026
42 checks passed
@dfattal
dfattal deleted the feat/cursor-runtime-draw-vk-gl branch October 11, 2026 10:51
dfattal added a commit that referenced this pull request Oct 11, 2026
…look

Follow #1946, which moved the default look into
compositor/util/comp_cursor_look (Metal, vk_native, GL): one entry point for
every backend instead of a second module in aux.

- The Windows GetCursorInfo/DrawIconEx mirror becomes the Windows platform
  half, comp_cursor_look_win.c (the stub is now !__APPLE__ && !_WIN32;
  links user32 + gdi32). Backing scale = DPI / 96; height_pt = drawn px /
  scale. Hidden-cursor fallback (class cursor under the pointer, else last,
  else IDC_ARROW) and the unreadable-handle memo kept (two additive fields).
- aux u_cursor_look.{c,h} removed; u_cursor_draw_unblend now outputs
  PREMULTIPLIED (the look's contract), u_cursor_draw_unpremultiply dropped.
- D3D11/D3D12 renderers blend the default look premultiplied (D3D11: a
  second blend state; D3D12: a third PSO variant), the app sprite straight.
- comp_d3d11 / comp_d3d12 link comp_cursor_look. ADR section updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dfattal added a commit that referenced this pull request Oct 11, 2026
…look

Follow #1946, which moved the default look into
compositor/util/comp_cursor_look (Metal, vk_native, GL): one entry point for
every backend instead of a second module in aux.

- The Windows GetCursorInfo/DrawIconEx mirror becomes the Windows platform
  half, comp_cursor_look_win.c (the stub is now !__APPLE__ && !_WIN32;
  links user32 + gdi32). Backing scale = DPI / 96; height_pt = drawn px /
  scale. Hidden-cursor fallback (class cursor under the pointer, else last,
  else IDC_ARROW) and the unreadable-handle memo kept (two additive fields).
- aux u_cursor_look.{c,h} removed; u_cursor_draw_unblend now outputs
  PREMULTIPLIED (the look's contract), u_cursor_draw_unpremultiply dropped.
- D3D11/D3D12 renderers blend the default look premultiplied (D3D11: a
  second blend state; D3D12: a third PSO variant), the app sprite straight.
- comp_d3d11 / comp_d3d12 link comp_cursor_look. ADR section updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dfattal added a commit that referenced this pull request Oct 11, 2026
…-046 Amendment 2 §B) (#1944)

* feat(cursor): the runtime draws the depth cursor on D3D11 + D3D12 (ADR-046 Amendment 2 B)

XR_DXR_cursor_depth v5 on the in-process D3D11 and D3D12 compositors,
mirroring the Metal pass (#1941). The state tracker dispatch is additive:
oxr_session_cursor_draw_supported / _hand_request now also route to
comp_d3d11/12_compositor_set_cursor_draw_request, so these backends report
willDraw = true instead of the one-time "cannot draw" WARN.

Compositors: the one-frame request is consumed by the next commit (one
field test otherwise); a cursor frame never zero-copies; the request is
resolved into per-tile corners (u_cursor_draw_corners, sprite metres over
canvas metres from the window metrics' pixel pitch) and handed to the
renderer before the projection pass. Routed multi-screen frames draw
nothing (one-time WARN).

Renderers: drawn LAST in the window-space pass - after the projection,
quad and window-space layers, before the compose publish and (D3D12) the
atlas's final barrier, so before the DP. One straight-alpha strip per
tile, depth off, no culling, scissored. Shared HLSL in
d3d_shared/comp_cursor_shaders.h, D3DCompile'd on the first cursor frame
only (DXR_LAYERED variant samples array layer 0). D3D12 reuses the quad
root signature, four PSOs [layered][_SRGB target]. Colour per ADR-044: the
frame's compose target + format-honest source view, or the encoded atlas
+ non-decoding view; a non-_SRGB sprite makes the frame compose.

Default look (u_cursor_look, aux, API-agnostic): Windows mirrors the OS
cursor - GetCursorInfo -> HCURSOR, DrawIconEx into a 32-bit DIB over black
and over white, u_cursor_draw_unblend recovers straight RGBA (handles
alpha, colour+mask and monochrome AND/XOR cursors; XOR pixels -> opaque
white), at GetSystemMetricsForDpi(SM_CYCURSOR, window DPI), cached by
HCURSOR + DPI. While the app has hidden its cursor (SetCursor(NULL)) the
class cursor of the window under the pointer is used. Elsewhere or with
DXR_CURSOR_DEFAULT_LOOK=arrow: the classic arrow, un-premultiplied.

Pure helpers u_cursor_draw_unpremultiply / u_cursor_draw_unblend and the
arrow path of u_cursor_look are unit-tested (mutants killed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_apps: depth cursor (XR_DXR_cursor_depth v5) in cube_handle_d3d11/d3d12_win

Reference adoption mirroring cube_handle_metal_macos: analytic cube
footprint hit test -> XrCursorDepthHintDXR; DISPLAYXR_CURSOR_DEPTH_DRAW=
runtime (default) | app; DISPLAYXR_CURSOR_DEPTH_SPRITE=custom (64x64 cyan
ring sprite swapchain, filled via UpdateSubresource / CopyTextureRegion);
DISPLAYXR_CURSOR_DEPTH_UV=u,v scripted pointer; DISPLAYXR_CURSOR_DEPTH=0
opt-out. The OS cursor is hidden over the client area exactly while
willDraw (WM_SETCURSOR -> SetCursor(NULL); D3D12 posts the flip from its
render thread to the window thread). App mode keeps the OS cursor and logs
the placement (no in-app crosshair on these renderers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(cursor): D3D11/D3D12 default look -> the shared comp_cursor_look

Follow #1946, which moved the default look into
compositor/util/comp_cursor_look (Metal, vk_native, GL): one entry point for
every backend instead of a second module in aux.

- The Windows GetCursorInfo/DrawIconEx mirror becomes the Windows platform
  half, comp_cursor_look_win.c (the stub is now !__APPLE__ && !_WIN32;
  links user32 + gdi32). Backing scale = DPI / 96; height_pt = drawn px /
  scale. Hidden-cursor fallback (class cursor under the pointer, else last,
  else IDC_ARROW) and the unreadable-handle memo kept (two additive fields).
- aux u_cursor_look.{c,h} removed; u_cursor_draw_unblend now outputs
  PREMULTIPLIED (the look's contract), u_cursor_draw_unpremultiply dropped.
- D3D11/D3D12 renderers blend the default look premultiplied (D3D11: a
  second blend state; D3D12: a third PSO variant), the app sprite straight.
- comp_d3d11 / comp_d3d12 link comp_cursor_look. ADR section updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cursor): D3D11/D3D12 draw the depth cursor in display-zones frames (#1949)

Adopt #1950 at the D3D resolve step, matching Metal / vk_native / GL: a
zone request is re-expressed in the tile with u_cursor_draw_into_zone()
(the window dims the renderers scale zone_3d.rect by), sized against the
zone with u_cursor_draw_canvas_px_h(), and scissored to tile box ∩ the
returned clip (new comp_d3d11/12_cursor_quads::clip). Skip only on a zones
frame / zone request mismatch (one-time WARN). A non-zone request clips to
the whole tile, so that path is unchanged.

#1951's willDraw gate is state-tracker-side (u_cursor_draw_placement_drawable
on located + submitted views); D3D goes through the same dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test_apps: depth cursor on cube_zones_d3d11/d3d12_win (display zones, #1949)

Same adoption as cube_zones_{metal,vk}_macos: the shared
test_apps/common/cursor_depth_zones.h (dxr::CursorDepth, displayxr-common
v2.29.0) picks the topmost 3D zone under the pointer and chains the hint
on that zone's locate only. The header gains an additive Win32 path
(initWin32: dxr::CursorDepth's Win32 window adapter for pointer UV and the
WM_SETCURSOR hide; app mode attaches no adapter). The apps enable
XR_DXR_cursor_depth unless DISPLAYXR_CURSOR_DEPTH=0;
DISPLAYXR_CURSOR_DEPTH_DRAW=runtime|app, DISPLAYXR_CURSOR_DEPTH_UV=u,v
(window-normalised).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cursor): cap the runtime-drawn cursor to the ACTIVE views; log the first OS-cursor mirror (#1944 Windows verification)

Two quirks from the Windows panel run of #1944:

- The cursor request took proj->viewCount views. An app submitting for the worst-case mode
  under PRIMARY_MULTIVIEW_DXR (the D3D cube apps submit 4 while the active mode has 2) put
  inactive views into the request: the one-time notice read "4 views", and those views' poses
  fed u_cursor_draw_placement_drawable. The arming locate now records its active view count
  (sess->cursor_draw.view_count) and xrEndFrame caps the request to it. The renderers already
  clamped drawing to the layout's tiles, so pixels are unchanged.
- "default look mirrors the OS cursor" was U_LOG_I, which never reaches the log file, so the
  mirror looked like it never ran. The first mirror is now a one-time WARN; later shape
  changes stay INFO.

Verified on the win SR laptop: the notice reads "2 views", the mirror line logs
"64x64 px at 240 DPI", and David's live mouse check still passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dfattal added a commit that referenced this pull request Oct 12, 2026
Undo the #1946 opt-in (DISPLAYXR_CURSOR_DEPTH=1) now that the crash it
worked around is fixed in the GL compositor: like the Metal and Vulkan
cubes, the cursor is on by default and DISPLAYXR_CURSOR_DEPTH=0 opts out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant